AGC 发布入口迁到导出产物面板陶泥儿 tab

- 新增 useTaonierTab:接管试玩包导出、发布灰度、构建进度与阻断反馈、发布资料面板开关,状态挂在导出面板根上,切 tab 不丢
- usePanel 接入 taonier slice,TAB_PAGES 把 taonier state 交给 TaonierPage
- 重写 tabs/taonier/Page.tsx:渲染「发布到游戏广场」入口,并按 state 挂发布资料面板与进度/阻断弹窗
- useDirectProjectManifest 额外返回完整 manifest,供陶泥儿 tab 复用
- App.tsx 移除发布相关 state、灰度 effect、requestGamePublish、发布面板与两条弹窗的挂载,以及 export_local_project_package invoke
- 移除 DirectProjectChatHeader 的「发布到游戏广场」按钮,并删掉 DirectProjectChatView / DirectProjectChatBox 的 onRequestGamePublish 透传
- gamePublishFeedback 用例改为从导出面板陶泥儿 tab 进入发布;directProjectChatHeader 用例改为覆盖导出入口与「不再有发布入口」
- 同步权威合同文档:发布入口位置由聊天头改为导出产物面板陶泥儿 tab
This commit is contained in:
2026-10-06 11:24:30 +08:00
parent 8f2006439d
commit 25315299a5
12 changed files with 272 additions and 187 deletions
-123
View File
@@ -38,18 +38,11 @@ import type {
LocalGameProjectRevisionStatus,
LocalPreviewResult,
LocalPreviewStatus,
LocalProjectExportPackageResult,
LocalProjectKind,
PendingUiConfirmation,
ProjectPermissionPolicyView,
TauriInvoke,
} from './app/types';
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
import { GamePublishBlockedDialog } from './components/game-distribution/GamePublishBlockedDialog';
import {
GamePublishProgressDialog,
type GamePublishProgressState,
} from './components/game-distribution/GamePublishProgressDialog';
import {
agentConversationId,
createAgentChatRunId,
@@ -88,7 +81,6 @@ import {
type ResourceReferenceInsertManyEventDetail,
} from './features/project-workspace/resourceReferences';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
import { readGamePublishAvailability } from './services/gameDistributionPublish';
import {
setAgcPluginProjectPath,
startAvailableAgcEditorPlugins,
@@ -326,19 +318,8 @@ export function App({
const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
// 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。
const [publishPackageResult, setPublishPackageResult] =
useState<LocalProjectExportPackageResult | null>(null);
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
// 导出产物:全屏面板由工作台壳按需挂载,关闭即卸载,面板状态不留在壳上。
const [exportPanelOpen, setExportPanelOpen] = useState(false);
const [publishProgress, setPublishProgress] =
useState<GamePublishProgressState | null>(null);
const [publishBlockedMessage, setPublishBlockedMessage] = useState<
string | null
>(null);
// 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。
const [gamePublishAllowed, setGamePublishAllowed] = useState(false);
const [projectChatError, setProjectChatError] = useState('');
const designReplyAnimation = useDesignReplyAnimation();
const [designAgentStatus, setDesignAgentStatus] = useState('');
@@ -869,77 +850,6 @@ export function App({
}
}
useEffect(() => {
let cancelled = false;
void readGamePublishAvailability()
.then((allowed) => {
if (!cancelled) setGamePublishAllowed(allowed);
})
.catch(() => {
if (!cancelled) setGamePublishAllowed(false);
});
return () => {
cancelled = true;
};
}, [localProject?.projectPath]);
/**
* 导出试玩包并打开发布面板。
*
* 发布不再走旧的聊天确认卡:点击后立即打开全屏进度弹窗并锁住工作区,
* 导出失败在弹窗内回显;成功后才切换到发布资料面板。
*/
async function requestGamePublish() {
const invoke = resolveTauriInvoke();
if (!invoke) {
setPublishBlockedMessage('需要在 Tauri App 内发布');
return;
}
const nextProjectPath =
resolveChatProjectPath(localProject) ?? projectPath.trim();
if (!nextProjectPath) {
setPublishBlockedMessage('先打开一个项目再发布');
return;
}
const publishManifest = manifestRef.current;
const hasCompletedPrototype = publishManifest.tasks.some(
(task) => task.id === 'code-prototype' && task.status === 'completed',
);
const hasRunningPreview =
publishManifest.preview?.status === 'running' &&
Boolean(publishManifest.preview.url?.trim());
if (!hasCompletedPrototype && !hasRunningPreview) {
setPublishBlockedMessage(
'首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
);
return;
}
setPublishProgress({
phase: 'running',
message: '正在构建并打包试玩包,请稍候…',
});
try {
const result = await invoke<LocalProjectExportPackageResult>(
'export_local_project_package',
{ projectPath: nextProjectPath },
);
setPublishProgress(null);
setPublishPackageResult(result);
setPublishPanelOpen(true);
appendLocalPermissionLog(
nextProjectPath,
'command.auto',
'project.export_package',
);
} catch (error) {
setPublishProgress({
phase: 'failed',
message: '发布失败',
detail: error instanceof Error ? error.message : String(error),
});
}
}
async function confirmUiCommand() {
const pending = pendingUiConfirmation;
if (!pending) {
@@ -1850,9 +1760,6 @@ export function App({
ensureConversationReadAllowed={ensureDirectHistoryReadAllowed}
ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed}
initialTurn={initialDirectTurn}
onRequestGamePublish={
gamePublishAllowed ? requestGamePublish : undefined
}
onRequestExport={
localProject?.projectPath || projectPath
? () => setExportPanelOpen(true)
@@ -1872,21 +1779,6 @@ export function App({
onClose={() => setExportPanelOpen(false)}
/>
) : null}
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath ?? ''}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
<GamePublishProgressDialog
progress={publishProgress}
onClose={() => setPublishProgress(null)}
/>
<GamePublishBlockedDialog
message={publishBlockedMessage}
onClose={() => setPublishBlockedMessage(null)}
/>
</>
);
}
@@ -2003,21 +1895,6 @@ export function App({
onClose={() => setRuntimeConfigOpen(false)}
/>
) : null}
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath ?? ''}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
<GamePublishProgressDialog
progress={publishProgress}
onClose={() => setPublishProgress(null)}
/>
<GamePublishBlockedDialog
message={publishBlockedMessage}
onClose={() => setPublishBlockedMessage(null)}
/>
</>
);
}
@@ -75,8 +75,6 @@ export type DirectProjectChatViewProps = {
ensureConversationReadAllowed: DirectProjectConversationReadGate;
/** 项目对话写权限门:只在发起回合时检查;同样必填,缺省会让未传入的调用方静默跳过授权。 */
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
/** 发布入口:由工作台壳持有导出与发布面板,聊天只提供触发按钮。 */
onRequestGamePublish?: () => void;
/** 导出产物入口:由工作台壳持有导出面板,聊天只提供触发按钮。 */
onRequestExport?: () => void;
/**
@@ -91,7 +89,6 @@ export type DirectProjectChatViewProps = {
};
export function DirectProjectChatView({
onRequestGamePublish,
onRequestExport,
onConfirmConfirmation,
onCancelConfirmation,
@@ -230,7 +227,6 @@ export function DirectProjectChatView({
turnStatus={turnStatus}
activeTurnStartedAt={activeTurnStartedAt}
statusText={statusText}
{...(onRequestGamePublish ? { onRequestGamePublish } : {})}
{...(onRequestExport ? { onRequestExport } : {})}
{...(pendingConfirmation ? { pendingConfirmation } : {})}
{...(onConfirmConfirmation ? { onConfirmConfirmation } : {})}
@@ -52,7 +52,6 @@ export type DirectProjectChatBoxProps = {
turnStatus: DirectProjectTurnStatus;
activeTurnStartedAt: number;
statusText: string;
onRequestGamePublish?: () => void;
onRequestExport?: () => void;
pendingConfirmation?: PendingUiConfirmation | null;
onConfirmConfirmation?: () => void;
@@ -75,7 +74,6 @@ export function DirectProjectChatBox({
turnStatus,
activeTurnStartedAt,
statusText,
onRequestGamePublish,
onRequestExport,
pendingConfirmation = null,
onConfirmConfirmation,
@@ -100,7 +98,6 @@ export function DirectProjectChatBox({
busy={turnStatus.displayBusy}
statusText={statusText}
onOpenSettings={() => setSettingsOpen(true)}
{...(onRequestGamePublish ? { onRequestGamePublish } : {})}
{...(onRequestExport ? { onRequestExport } : {})}
/>
<DirectProjectConversation
@@ -1,23 +1,20 @@
import { Package, Rocket, Settings } from 'lucide-react';
import { Package, Settings } from 'lucide-react';
/**
* DirectProject 聊天头:运行状态与设置入口。
*
* 状态文案由容器决定(忙态优先),这里只负责渲染;DirectProject 不再把工作台壳的
* 全局状态文案搬进来。
* 全局状态文案搬进来。发布入口归导出产物面板的陶泥儿 tab,不在聊天头。
*/
export function DirectProjectChatHeader({
busy,
statusText,
onOpenSettings,
onRequestGamePublish,
onRequestExport,
}: {
busy: boolean;
statusText: string;
onOpenSettings: () => void;
/** 没有项目或不在 Tauri 宿主时不传;传了才渲染发布入口。 */
onRequestGamePublish?: () => void;
/** 没有项目时不传;传了才渲染导出产物入口。 */
onRequestExport?: () => void;
}) {
@@ -43,19 +40,6 @@ export function DirectProjectChatHeader({
<span>导出</span>
</button>
) : null}
{onRequestGamePublish ? (
<button
type="button"
className="project-chat-publish-trigger"
aria-label="发布到游戏广场"
title="发布到游戏广场"
disabled={busy}
onClick={onRequestGamePublish}
>
<Rocket size={16} aria-hidden="true" />
<span>发布</span>
</button>
) : null}
<button
type="button"
className="project-chat-settings-trigger"
@@ -126,6 +126,7 @@ export function useDirectProjectManifest(projectPath: string | null) {
return {
assets: directProjectChatAssets(manifest),
manifest,
projectId: manifest?.projectId ?? null,
refresh,
versions: manifest?.versions ?? NO_VERSIONS,
@@ -5,6 +5,7 @@ import type {
DirectProjectConversationWriteGate,
} from '../../chat/controller/useDirectProjectChatController';
import { Stage, Target } from '../model';
import { type TaonierTabState, useTaonierTab } from './useTaonierTab';
import {
useXiaohongshuTab,
type XiaohongshuTabState,
@@ -54,6 +55,11 @@ export function usePanel({
setActiveTarget(target);
}, []);
const taonier: TaonierTabState = useTaonierTab({
enabled: workspaceEntered && Boolean(projectPath),
projectPath,
});
const xiaohongshu: XiaohongshuTabState = useXiaohongshuTab({
enabled: workspaceEntered && Boolean(projectPath),
minitoolEnabled: xiaohongshuEntered && Boolean(projectPath),
@@ -68,6 +74,7 @@ export function usePanel({
openTarget,
selectTarget,
onClose,
taonier,
xiaohongshu,
};
}
@@ -0,0 +1,161 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { GameCreationAppManifest } from '../../../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../../../app/tauri';
import type { LocalProjectExportPackageResult } from '../../../../app/types';
import type { GamePublishProgressState } from '../../../../components/game-distribution/GamePublishProgressDialog';
import { readGamePublishAvailability } from '../../../../services/gameDistributionPublish';
import { useDirectProjectManifest } from '../../chat/controller/useDirectProjectManifest';
export type TaonierTabState = ReturnType<typeof useTaonierTab>;
/**
* 发布前必须完成「首个可运行原型」:完成的 `code-prototype` 任务,或正在运行的预览。
*
* 与工作台壳原先在发布入口上的判据同一口径;这里读的是 tab 自己的清单订阅。
*/
function hasRunnablePrototype(manifest: GameCreationAppManifest): boolean {
const hasCompletedPrototypeTask = manifest.tasks.some(
(task) => task.id === 'code-prototype' && task.status === 'completed',
);
const hasRunningPreview =
manifest.preview?.status === 'running' &&
Boolean(manifest.preview.url?.trim());
return hasCompletedPrototypeTask || hasRunningPreview;
}
/** 试玩包导出的审计旁路;写失败不能阻塞发布。 */
function appendPublishPermissionLog(projectPath: string) {
const invoke = resolveTauriInvoke();
if (!invoke) return;
try {
void Promise.resolve(
invoke('append_local_permission_log', {
projectPath,
event: 'command.auto',
commandId: 'project.export_package',
}),
).catch(() => undefined);
} catch {
// 权限日志只是审计旁路,写失败不能阻塞发布。
}
}
/**
* 陶泥儿导出 tab 的全部状态。
*
* 发布编排(构建 → 打包 → 发布资料面板)从工作台壳搬到这里:tab 页只渲染,状态挂在
* **面板根**(`usePanel`)上,所以切到别的 tab 再切回来时试玩包结果、封面与幂等键都还在。
* 关闭导出面板会卸载这个 hook,在途的 `export_local_project_package` 回来后由
* `mountedRef` 丢弃,不再向已卸载的组件写状态。
*/
export function useTaonierTab({
enabled,
projectPath,
}: {
/** 进过一次工作台且项目路径就绪后才读灰度:进入前不产生任何后端访问。 */
enabled: boolean;
projectPath: string | null;
}) {
const { manifest } = useDirectProjectManifest(projectPath);
const manifestRef = useRef<GameCreationAppManifest | null>(manifest);
manifestRef.current = manifest;
const [allowed, setAllowed] = useState(false);
const [packageResult, setPackageResult] =
useState<LocalProjectExportPackageResult | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [progress, setProgress] = useState<GamePublishProgressState | null>(
null,
);
const [blockedMessage, setBlockedMessage] = useState<string | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setAllowed(false);
void readGamePublishAvailability()
.then((value) => {
if (!cancelled) setAllowed(value);
})
.catch(() => {
if (!cancelled) setAllowed(false);
});
return () => {
cancelled = true;
};
}, [enabled, projectPath]);
const requestPublish = useCallback(async () => {
const currentProjectPath = projectPath?.trim() ?? '';
if (!currentProjectPath) {
setBlockedMessage('先打开一个项目再发布');
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setBlockedMessage('需要在 Tauri App 内发布');
return;
}
const currentManifest = manifestRef.current;
if (!currentManifest) {
setBlockedMessage('项目清单尚未就绪,请稍后重试');
return;
}
if (!hasRunnablePrototype(currentManifest)) {
setBlockedMessage(
'首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
);
return;
}
setProgress({
phase: 'running',
message: '正在构建并打包试玩包,请稍候…',
});
try {
const result = await invoke<LocalProjectExportPackageResult>(
'export_local_project_package',
{ projectPath: currentProjectPath },
);
if (!mountedRef.current) return;
setProgress(null);
setPackageResult(result);
setPanelOpen(true);
appendPublishPermissionLog(currentProjectPath);
} catch (error) {
if (!mountedRef.current) return;
setProgress({
phase: 'failed',
message: '发布失败',
detail: error instanceof Error ? error.message : String(error),
});
}
}, [projectPath]);
const closePanel = useCallback(() => setPanelOpen(false), []);
const closeProgress = useCallback(() => setProgress(null), []);
const closeBlocked = useCallback(() => setBlockedMessage(null), []);
return {
enabled,
allowed,
manifest,
projectPath,
packageResult,
panelOpen,
progress,
blockedMessage,
requestPublish,
closePanel,
closeProgress,
closeBlocked,
};
}
@@ -13,7 +13,7 @@ import { XiaohongshuPage } from './xiaohongshu/Page';
* 的 `PanelState` 上取,页面组件本身不持状态。
*/
export const TAB_PAGES: Record<Target, (panel: PanelState) => ReactNode> = {
[Target.Taonier]: () => <TaonierPage />,
[Target.Taonier]: (panel) => <TaonierPage tab={panel.taonier} />,
[Target.Xiaohongshu]: (panel) => <XiaohongshuPage tab={panel.xiaohongshu} />,
[Target.TapTap]: () => <TaptapPage />,
};
@@ -1,7 +1,69 @@
import { EmptyTab } from '../../components/EmptyTab';
import { Target } from '../../model';
import { Rocket } from 'lucide-react';
/** 陶泥儿导出 tab:正式产物接入前是空 tab 页。 */
export function TaonierPage() {
return <EmptyTab target={Target.Taonier} />;
import { GameDistributionPublishPanel } from '../../../../../components/game-distribution/GameDistributionPublishPanel';
import { GamePublishBlockedDialog } from '../../../../../components/game-distribution/GamePublishBlockedDialog';
import { GamePublishProgressDialog } from '../../../../../components/game-distribution/GamePublishProgressDialog';
import type { TaonierTabState } from '../../state/useTaonierTab';
/**
* 陶泥儿导出 tab:这里是「发布到游戏广场」的唯一入口。
*
* 状态全部在面板根的 `useTaonierTab`(见 `usePanel`),这一页只负责渲染入口、挂发布资料
* 面板与两条反馈弹窗;构建、打包、灰度与幂等都由状态 hook 持有。
*/
export function TaonierPage({ tab }: { tab: TaonierTabState }) {
const manifest = tab.manifest;
const busy = tab.progress?.phase === 'running';
const canPublish = tab.allowed && Boolean(manifest) && !busy;
return (
<div
className="grid h-full min-h-0 place-items-center p-6"
aria-label="陶泥儿导出页"
>
{manifest ? (
<div className="w-full max-w-md rounded-3xl border border-[#e2cbb8] bg-[#fffaf5] p-8 text-center shadow-[0_18px_40px_rgba(112,57,30,0.08)]">
<h2 className="text-lg font-semibold text-[#3d1f10]">
发布到游戏广场
</h2>
<p className="mt-3 text-sm text-[#6f5848]">
先构建并打包当前项目的试玩包,再填写发行资料提交审核。
</p>
{tab.allowed ? null : (
<p className="mt-2 text-xs text-[#988476]">
当前账号暂未开放发布入口。
</p>
)}
<button
type="button"
disabled={!canPublish}
onClick={() => void tab.requestPublish()}
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-[#c7653d] bg-[#c7653d] px-4 py-2 text-sm font-medium text-white transition enabled:hover:bg-[#b3572f] disabled:cursor-not-allowed disabled:border-[#e2cbb8] disabled:bg-[#f4e6da] disabled:text-[#b9a08f]"
>
<Rocket size={16} aria-hidden="true" />
{busy ? '正在构建并打包…' : '发布到游戏广场'}
</button>
</div>
) : (
<p className="text-sm text-[#988476]">正在读取项目清单…</p>
)}
{manifest ? (
<GameDistributionPublishPanel
open={tab.panelOpen}
projectPath={tab.projectPath ?? ''}
manifest={manifest}
packageResult={tab.packageResult}
onClose={tab.closePanel}
/>
) : null}
<GamePublishProgressDialog
progress={tab.progress}
onClose={tab.closeProgress}
/>
<GamePublishBlockedDialog
message={tab.blockedMessage}
onClose={tab.closeBlocked}
/>
</div>
);
}
@@ -1,9 +1,9 @@
// @vitest-environment jsdom
/**
* DirectProject 聊天头的发布入口。
* DirectProject 聊天头的导出入口。
*
* 发布面板与试玩包导出由工作台壳持有,聊天头只负责在壳传入回调时渲染入口;
* 没有回调(例如策划聊天面)时不能出现点不动的按钮。
* 发布入口归导出产物面板的陶泥儿 tab,聊天头不再持有;这里只验证导出产物入口按回调渲染,
* 以及没有回调(例如策划聊天面)时不出现点不动的按钮。
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -18,42 +18,38 @@ function renderHeader(
overrides: Partial<Parameters<typeof DirectProjectChatHeader>[0]> = {},
) {
const onOpenSettings = vi.fn();
const onRequestGamePublish = vi.fn();
const onRequestExport = vi.fn();
render(
<DirectProjectChatHeader
busy={false}
statusText="就绪"
onOpenSettings={onOpenSettings}
onRequestGamePublish={onRequestGamePublish}
onRequestExport={onRequestExport}
{...overrides}
/>,
);
return { onOpenSettings, onRequestGamePublish };
return { onOpenSettings, onRequestExport };
}
describe('DirectProjectChatHeader', () => {
it('传入发布回调时渲染发布入口并可触发', () => {
const { onRequestGamePublish } = renderHeader();
it('传入导出回调时渲染导出入口并可触发', () => {
const { onRequestExport } = renderHeader();
const publish = screen.getByRole('button', { name: '发布到游戏广场' });
expect(publish).toHaveProperty('disabled', false);
fireEvent.click(publish);
expect(onRequestGamePublish).toHaveBeenCalledTimes(1);
const exportButton = screen.getByRole('button', { name: '导出产物' });
fireEvent.click(exportButton);
expect(onRequestExport).toHaveBeenCalledTimes(1);
});
it('没有发布回调时不渲染发布入口', () => {
renderHeader({ onRequestGamePublish: undefined });
it('没有导出回调时不渲染导出入口', () => {
renderHeader({ onRequestExport: undefined });
expect(screen.queryByRole('button', { name: '发布到游戏广场' })).toBeNull();
expect(screen.queryByRole('button', { name: '导出产物' })).toBeNull();
expect(screen.getByRole('button', { name: '设置' })).not.toBeNull();
});
it('回合进行中禁用发布入口,避免与运行时抢占', () => {
const { onRequestGamePublish } = renderHeader({ busy: true });
it('不再渲染发布入口', () => {
renderHeader();
const publish = screen.getByRole('button', { name: '发布到游戏广场' });
expect(publish).toHaveProperty('disabled', true);
fireEvent.click(publish);
expect(onRequestGamePublish).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '发布到游戏广场' })).toBeNull();
});
});
@@ -123,11 +123,21 @@ function renderPublishProject(
);
}
async function clickPublish() {
/**
* 发布入口在导出产物面板的陶泥儿 tab:先开导出面板,再进陶泥儿,等灰度读到后才可点。
*/
async function openTaonierPublishEntry() {
fireEvent.click(await screen.findByRole('button', { name: '导出产物' }));
fireEvent.click(await screen.findByRole('button', { name: '陶泥儿' }));
const publish = await screen.findByRole('button', {
name: '发布到游戏广场',
});
fireEvent.click(publish);
await waitFor(() => expect(publish).toHaveProperty('disabled', false));
return publish;
}
async function clickPublish() {
fireEvent.click(await openTaonierPublishEntry());
return screen.findByLabelText('陶泥儿项目对话');
}
@@ -201,9 +211,7 @@ describe('客户端发布入口的可见反馈', () => {
installTauri({ exportPackage });
renderPublishProject();
fireEvent.click(
await screen.findByRole('button', { name: '发布到游戏广场' }),
);
fireEvent.click(await openTaonierPublishEntry());
const progressDialog = await screen.findByRole('dialog', {
name: '发布进度',
@@ -237,9 +245,7 @@ describe('客户端发布入口的可见反馈', () => {
});
renderPublishProject();
fireEvent.click(
await screen.findByRole('button', { name: '发布到游戏广场' }),
);
fireEvent.click(await openTaonierPublishEntry());
const failureDialog = await screen.findByRole('dialog', {
name: '发布失败',
@@ -283,9 +289,7 @@ describe('客户端发布入口的可见反馈', () => {
installTauri({ exportPackage, policy: createConfirmPolicy() });
renderPublishProject();
fireEvent.click(
await screen.findByRole('button', { name: '发布到游戏广场' }),
);
fireEvent.click(await openTaonierPublishEntry());
await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1));
expect(screen.queryByText('project.export_package')).toBeNull();
@@ -86,7 +86,7 @@
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 导出产物面板陶泥儿 tab 的「发布到游戏广场」入口据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`,游戏文档又运行在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie 与 storage。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。