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:
@@ -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 } : {})}
|
||||
|
||||
-3
@@ -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
|
||||
|
||||
+2
-18
@@ -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"
|
||||
|
||||
+1
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+161
@@ -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 />,
|
||||
};
|
||||
|
||||
+67
-5
@@ -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。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
|
||||
|
||||
Reference in New Issue
Block a user