diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx deleted file mode 100644 index 3018200fb..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ /dev/null @@ -1,568 +0,0 @@ -import { useState } from 'react'; - -import { resolveTauriInvoke } from '../../app/tauri'; -import type { - PlanGddDecisionAction, - PlanGddStateViewV1, -} from '../../app/types'; - -/** - * 立项策划唯一的产品产物,由策划会话提交与审批回执渲染到项目内。 - * - * 权威定义在 Rust 侧 `planning_gdd_model.rs` 的 `PLAN_FAST_GDD_PATH`,那里同时管着渲染 - * 落盘和写入守卫。跨语言没有共享常量的通道,这里只能重复一份;改路径时两处要一起动。 - */ -const PLAN_FAST_GDD_RELATIVE_PATH = 'game/fast_gdd.md'; - -/** - * 拼出交付文件的绝对路径。 - * - * 用户要拿这行去资源管理器里找文件,所以跟随项目路径本身的分隔符:Windows 下项目 - * 路径是反斜杠,混排出来的 `C:\...\project/game/fast_gdd.md` 虽然能用,但复制到 - * 地址栏之外的地方就不像一个路径了。 - */ -function planGddMarkdownDisplayPath(projectPath: string) { - const trimmed = projectPath.trim().replace(/[\\/]+$/u, ''); - if (!trimmed) { - return PLAN_FAST_GDD_RELATIVE_PATH; - } - const separator = trimmed.includes('\\') ? '\\' : '/'; - return `${trimmed}${separator}${PLAN_FAST_GDD_RELATIVE_PATH.split('/').join(separator)}`; -} - -type GddApprovalCardProps = { - state: PlanGddStateViewV1 | null; - hydrateBusy: boolean; - decisionBusy: boolean; - error: string | null; - onRefresh: () => void; - onDecision: ( - action: PlanGddDecisionAction, - comment: string | null, - ) => Promise; - onMakeGame?: () => Promise; -}; - -const stateLabels: Record = { - not_started: '未开始', - draft: '策划中', - ready_for_approval: '待审批', - revision_requested: '待修订', - approved: '已批准', - rejected: '已退回', -}; - -function stageProgressVisible( - state: PlanGddStateViewV1 | null, - active: boolean, -) { - return Boolean(state && (active || state.state !== 'not_started')); -} - -function approvalCardVisible(state: PlanGddStateViewV1 | null) { - return Boolean( - state?.displayGdd && (state.pendingApproval || state.recoveryPending), - ); -} - -/** - * 策划区的外壳:阶段进度是它的标题栏,审批卡是它的正文。 - * - * 两者原本各画一个带边框的盒子,叠在一起时「立项策划 / 状态」在标题栏和卡头各出现 - * 一次。合成一个面之后卡头只剩游戏标题和一句话,状态与版本只在标题栏画一遍。两个 - * 挂载点都从这里进,不再各自拼装。 - */ -export function PlanGddSurface({ - state, - active = false, - projectPath, - hydrateBusy, - decisionBusy, - error, - onRefresh, - onDecision, - onMakeGame, -}: GddApprovalCardProps & { active?: boolean; projectPath: string }) { - const showProgress = stageProgressVisible(state, active); - const showCard = approvalCardVisible(state); - if (!showProgress && !showCard) { - return null; - } - return ( -
- {showProgress ? ( - - ) : null} - {showCard ? ( - - ) : null} -
- ); -} - -export function PlanGddStageProgress({ - state, - active = false, - projectPath = '', - onMakeGame, -}: { - state: PlanGddStateViewV1 | null; - active?: boolean; - projectPath?: string; - onMakeGame?: () => Promise; -}) { - const [detailsOpen, setDetailsOpen] = useState(false); - const [openError, setOpenError] = useState(''); - const [opening, setOpening] = useState(false); - const [makingGame, setMakingGame] = useState(false); - const [makeGameError, setMakeGameError] = useState(''); - if (!state || !stageProgressVisible(state, active)) { - return null; - } - const latestVersion = - state.displayGdd?.version ?? - state.versions[state.versions.length - 1]?.gddRef.version ?? - null; - const answeredRounds = state.session?.clarificationRound ?? 0; - const questionLimit = state.session?.questionLimit ?? 3; - const questionLimitLabel = - questionLimit === null ? '不限' : String(questionLimit); - // `clarificationRound` 是已完成的问询数;等待回答时加一表示当前正在展示的问题, - // 避免把「已答 N 个」误显示成当前第 N 轮。旧状态没有 questionLimit 时沿用旧 UI 的 3 轮 - // 文案,V2 状态直接使用会话快照里的策略值。 - const roundLabel = state.session?.awaitingAnswerFor - ? `第 ${answeredRounds + 1} 轮 / 共 ${questionLimitLabel} 轮` - : `已完成 ${answeredRounds}/${questionLimitLabel} 轮澄清`; - const processingSeconds = (state.session?.accumulatedAgentMillis ?? 0) / 1000; - // 批准之后审批卡整张收掉,交付出口就落在这条标题栏上:GDD 的 Markdown 已经在项目 - // 里(提交时渲染、审批回执重渲染带上 approved 头),这里只是把它指出来并交给系统 - // 打开。恢复态不给出口——那时权威投影还没收敛,路径上的内容可能不是用户批的那版。 - const deliveredGdd = - state.state === 'approved' && !state.recoveryPending - ? state.displayGdd - : null; - const markdownPath = planGddMarkdownDisplayPath(projectPath); - return ( -
-
- 立项策划 - {stateLabels[state.state]} -
-
- {roundLabel} - - {latestVersion === null - ? '当前版本:草稿' - : `当前版本:v${latestVersion}`} - - {processingSeconds > 0 ? ( - {`处理耗时:${processingSeconds.toFixed(1)} 秒`} - ) : null} -
- {deliveredGdd ? ( -
- {markdownPath} -
- - - {onMakeGame ? ( - - ) : null} -
- {openError ? ( - - {openError} - - ) : null} - {makeGameError ? ( - - {makeGameError} - - ) : null} -
- ) : null} - {detailsOpen && deliveredGdd ? ( - setDetailsOpen(false)} - /> - ) : null} -
- ); -} - -const decisionStateLabels = { - confirmed: '已确认', - assumption_pending: 'Agent 推断,待确认', - default_pending: '待确认默认项', - prototype_pending: '待原型验证', -}; - -const visibleGddSections = ( - gdd: NonNullable, -) => [ - { - title: '类型与美术', - content: [ - `类型:${gdd.game.genre.primary}${gdd.game.genre.fusion ? ` / ${gdd.game.genre.fusion}` : ''}`, - `视觉:${gdd.game.artStyle.visualType}`, - `关键词:${gdd.game.artStyle.keywords.join('、')}`, - `氛围:${gdd.game.artStyle.moodAndColor}`, - `MVP 美术边界:${gdd.game.artStyle.mvpArtBoundary}`, - ].join('\n'), - }, - { - title: '游戏支柱', - content: gdd.game.pillars - .map( - (pillar) => `${pillar.name}:${pillar.playerFeel};${pillar.mechanism}`, - ) - .join('\n'), - }, - { title: '核心循环', content: gdd.game.coreLoop.join(' → ') }, - { - title: '目标用户', - content: [ - `核心用户:${gdd.game.targetUsers.coreUsers}`, - `偏好:${gdd.game.targetUsers.preferences}`, - `单局时长:${gdd.game.targetUsers.sessionLength}`, - `参考作品:${gdd.game.targetUsers.referenceGames.join('、') || '无'}`, - ].join('\n'), - }, - { - title: '平台事实', - content: [ - `运行时:${gdd.game.platformFacts.runtime}`, - `视口:${gdd.game.platformFacts.viewports.join('、')}`, - `输入:${gdd.game.platformFacts.inputs.join('、')}`, - `预览:${gdd.game.platformFacts.preview}`, - ].join('\n'), - }, - { - title: 'MVP 系统', - content: gdd.game.mvpSystems - .map( - (system) => - `${system.system}:${system.minimalFunction}\n为什么需要:${system.whyRequired}\n验证:${system.verifyMethod}`, - ) - .join('\n'), - }, - { title: '暂不纳入', content: gdd.game.outOfScope.join('、') }, - { - title: '创作者提示', - content: [ - `先做:${gdd.game.creatorTips.doFirst}`, - `暂缓:${gdd.game.creatorTips.deferForNow}`, - `验证:${gdd.game.creatorTips.howToVerify}`, - `扩展条件:${gdd.game.creatorTips.expandWhen}`, - ].join('\n'), - }, - { - title: '原型验证项', - content: gdd.prototypeValidationItems - .map( - (item) => - `${item.question}\n微型原型:${item.microPrototype}\n观察:${item.observation}\n通过标准:${item.passCriterion}`, - ) - .join('\n'), - }, -]; - -/** - * Fast GDD 的正文视图。 - * - * 审批时挂在审批卡上,批准之后审批卡收掉、改由阶段进度条那条交付行触发——同一份 - * 弹层两处共用,用户在批准前后看到的是同一个正文。 - */ -export function GddDetailsDialog({ - gdd, - onClose, -}: { - gdd: NonNullable; - onClose: () => void; -}) { - return ( -
{ - if (event.target === event.currentTarget) { - onClose(); - } - }} - > -
-
-

{gdd.game.title}

-

{gdd.game.oneLiner}

-
- {visibleGddSections(gdd) - .filter((section) => section.content) - .map((section) => ( -
- {section.title} -

{section.content}

-
- ))} - - {`Fast GDD v${gdd.version} · ${gdd.fingerprint}`} - -
- -
-
-
- ); -} - -export function GddApprovalCard({ - state, - hydrateBusy, - decisionBusy, - error, - onRefresh, - onDecision, -}: GddApprovalCardProps) { - const [commentAction, setCommentAction] = useState | null>(null); - const [comment, setComment] = useState(''); - const [gddDetailsOpen, setGddDetailsOpen] = useState(false); - - if (!state?.displayGdd || !approvalCardVisible(state)) { - return null; - } - - const gdd = state.displayGdd; - const pending = state.pendingApproval; - const canDecide = Boolean( - pending && state.state === 'ready_for_approval' && !state.recoveryPending, - ); - // `hydrateBusy` only describes an in-flight background read. Once a card is rendered, the - // loaded projection remains the actionable snapshot until it explicitly reports - // `recoveryPending`; otherwise a refresh racing the first render can transiently disable an - // already-open dialog before the user has had a chance to submit it. - const decisionDisabled = !canDecide || decisionBusy; - - const submitComment = () => { - // 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮 - // 已经由 `canDecide` 门住,但弹层是打开后才可能被后台 hydrate 翻掉资格的, - // 所以提交口要自己再判一次,不能只靠按钮 disabled。 - if (decisionDisabled || !commentAction || !comment.trim()) { - return; - } - void onDecision(commentAction, comment.trim()) - .then(() => { - setCommentAction(null); - setComment(''); - }) - .catch(() => undefined); - }; - - return ( -
-
-

{gdd.game.title || `Fast GDD v${gdd.version}`}

-

{gdd.game.oneLiner}

-
- - {gdd.decisions.length > 0 ? ( -
- {gdd.decisions.map((decision) => ( -
- {decision.topic} - {decisionStateLabels[decision.state]} - {decision.answerSummary} -
- ))} -
- ) : null} - - - - {state.recoveryPending ? ( -
- 审批状态正在同步,请不要重复提交当前版本。 - -
- ) : null} - - {error ? ( -
- {error} -
- ) : null} - - {pending ? ( -
- - - -
- ) : null} - - {commentAction ? ( -
{ - if (event.target === event.currentTarget) { - setCommentAction(null); - setComment(''); - } - }} - > -
-

- {commentAction === 'revise' ? '填写修改意见' : '填写退回原因'} -

-