Files
Genarrative/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx
T
lhk229 2fa967a93a
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / Native shell tests (pull_request) Failing after 15m21s
删除策划 V1 的测试脚本入口并同步退役文档
删除 agent-swarm-test-chat.mjs 的 --plan 模式、自动 GDD 审批回路与 planning 产物检查
删除根与应用 package.json 的 test:plan / test:plan:manual / agc:test:plan* 四条脚本
同步删除 agentSwarmTestEntry.test.ts 中只覆盖 V1 审批回路与 --plan 参数的用例
GddApprovalCard.tsx 注释改指 planning_gdd_model.rs 的现行路径权威定义
立项策划Agent(Fast GDD)方案文档头部标注已退役,仅作历史推导记录
策划会话 Runtime V2 方案文档状态更新为 P5 已完成并记录源码删除执行清单
Provider 兼容性缺陷文档的缺陷 4 标注相关代码已随 V1 退役删除
decision-log 新增 2026-09-08 策划 V1 链路源码整体退役决策记录
2026-09-08 10:43:38 +00:00

569 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
onMakeGame?: () => Promise<void>;
};
const stateLabels: Record<PlanGddStateViewV1['state'], string> = {
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 (
<section
className={`plan-gdd-surface${showCard ? ' plan-gdd-surface--with-card' : ''}`}
aria-label="立项策划"
>
{showProgress ? (
<PlanGddStageProgress
state={state}
active={active}
projectPath={projectPath}
onMakeGame={onMakeGame}
/>
) : null}
{showCard ? (
<GddApprovalCard
state={state}
hydrateBusy={hydrateBusy}
decisionBusy={decisionBusy}
error={error}
onRefresh={onRefresh}
onDecision={onDecision}
/>
) : null}
</section>
);
}
export function PlanGddStageProgress({
state,
active = false,
projectPath = '',
onMakeGame,
}: {
state: PlanGddStateViewV1 | null;
active?: boolean;
projectPath?: string;
onMakeGame?: () => Promise<void>;
}) {
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 (
<section className="plan-gdd-stage-progress" aria-label="立项策划阶段进度">
<div className="plan-gdd-stage-progress__header">
<strong>立项策划</strong>
<span>{stateLabels[state.state]}</span>
</div>
<div className="plan-gdd-stage-progress__meta">
<span>{roundLabel}</span>
<span>
{latestVersion === null
? '当前版本:草稿'
: `当前版本:v${latestVersion}`}
</span>
{processingSeconds > 0 ? (
<span>{`处理耗时:${processingSeconds.toFixed(1)} 秒`}</span>
) : null}
</div>
{deliveredGdd ? (
<div
className="plan-gdd-stage-progress__delivery"
aria-label="GDD 交付"
>
<code title={markdownPath}>{markdownPath}</code>
<div className="plan-gdd-stage-progress__delivery-actions">
<button type="button" onClick={() => setDetailsOpen(true)}>
查看 GDD 正文
</button>
<button
type="button"
disabled={opening || !projectPath.trim()}
onClick={() => {
const invoke = resolveTauriInvoke();
if (!invoke) {
setOpenError('当前环境不支持打开本地文件');
return;
}
setOpenError('');
setOpening(true);
void invoke('open_local_project_plan_gdd_markdown', {
projectPath,
})
.catch((error: unknown) =>
setOpenError(
error instanceof Error ? error.message : String(error),
),
)
.finally(() => setOpening(false));
}}
>
{opening ? '正在打开' : '打开文件'}
</button>
{onMakeGame ? (
<button
type="button"
disabled={opening || makingGame || !projectPath.trim()}
onClick={() => {
setMakeGameError('');
setMakingGame(true);
void onMakeGame()
.catch((error: unknown) =>
setMakeGameError(
error instanceof Error ? error.message : String(error),
),
)
.finally(() => setMakingGame(false));
}}
>
{makingGame ? '正在启动' : '做成游戏'}
</button>
) : null}
</div>
{openError ? (
<small
className="plan-gdd-stage-progress__delivery-error"
role="alert"
>
{openError}
</small>
) : null}
{makeGameError ? (
<small
className="plan-gdd-stage-progress__delivery-error"
role="alert"
>
{makeGameError}
</small>
) : null}
</div>
) : null}
{detailsOpen && deliveredGdd ? (
<GddDetailsDialog
gdd={deliveredGdd}
onClose={() => setDetailsOpen(false)}
/>
) : null}
</section>
);
}
const decisionStateLabels = {
confirmed: '已确认',
assumption_pending: 'Agent 推断,待确认',
default_pending: '待确认默认项',
prototype_pending: '待原型验证',
};
const visibleGddSections = (
gdd: NonNullable<PlanGddStateViewV1['displayGdd']>,
) => [
{
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<PlanGddStateViewV1['displayGdd']>;
onClose: () => void;
}) {
return (
<div
className="gdd-approval-card__dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<section
className="gdd-approval-card__dialog gdd-approval-card__details"
role="dialog"
aria-modal="true"
aria-labelledby="gdd-approval-details-title"
>
<header>
<h3 id="gdd-approval-details-title">{gdd.game.title}</h3>
<p>{gdd.game.oneLiner}</p>
</header>
{visibleGddSections(gdd)
.filter((section) => section.content)
.map((section) => (
<article key={section.title}>
<strong>{section.title}</strong>
<p>{section.content}</p>
</article>
))}
<small
className="gdd-approval-card__details-trace"
aria-label="GDD 版本与指纹"
>
{`Fast GDD v${gdd.version} · ${gdd.fingerprint}`}
</small>
<div className="gdd-approval-card__dialog-actions">
<button type="button" onClick={onClose}>
关闭
</button>
</div>
</section>
</div>
);
}
export function GddApprovalCard({
state,
hydrateBusy,
decisionBusy,
error,
onRefresh,
onDecision,
}: GddApprovalCardProps) {
const [commentAction, setCommentAction] = useState<Exclude<
PlanGddDecisionAction,
'approve'
> | 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 (
<section className="gdd-approval-card" aria-label="GDD 审批卡">
<header className="gdd-approval-card__header">
<h2>{gdd.game.title || `Fast GDD v${gdd.version}`}</h2>
<p>{gdd.game.oneLiner}</p>
</header>
{gdd.decisions.length > 0 ? (
<div className="gdd-approval-card__decisions" aria-label="决定状态">
{gdd.decisions.map((decision) => (
<article key={decision.id}>
<strong>{decision.topic}</strong>
<span>{decisionStateLabels[decision.state]}</span>
<small>{decision.answerSummary}</small>
</article>
))}
</div>
) : null}
<button
type="button"
className="gdd-approval-card__details-trigger"
onClick={() => setGddDetailsOpen(true)}
>
查看 GDD 正文
</button>
{state.recoveryPending ? (
<div className="gdd-approval-card__recovery" role="status">
<span>审批状态正在同步,请不要重复提交当前版本。</span>
<button type="button" disabled={hydrateBusy} onClick={onRefresh}>
{hydrateBusy ? '同步中' : '重试同步'}
</button>
</div>
) : null}
{error ? (
<div className="gdd-approval-card__error" role="alert">
{error}
</div>
) : null}
{pending ? (
<div className="gdd-approval-card__actions">
<button
type="button"
disabled={decisionDisabled}
onClick={() =>
void onDecision('approve', null).catch(() => undefined)
}
>
{decisionBusy ? '处理中' : `批准 v${pending.gddRef.version}`}
</button>
<button
type="button"
disabled={decisionDisabled}
onClick={() => setCommentAction('revise')}
>
修改
</button>
<button
type="button"
disabled={decisionDisabled}
onClick={() => setCommentAction('reject')}
>
退回重做
</button>
</div>
) : null}
{commentAction ? (
<div
className="gdd-approval-card__dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setCommentAction(null);
setComment('');
}
}}
>
<section
className="gdd-approval-card__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="gdd-approval-comment-title"
>
<h3 id="gdd-approval-comment-title">
{commentAction === 'revise' ? '填写修改意见' : '填写退回原因'}
</h3>
<textarea
autoFocus
rows={5}
value={comment}
placeholder="请输入原因"
onChange={(event) => setComment(event.currentTarget.value)}
/>
{!canDecide ? (
<p className="gdd-approval-card__dialog-hint" role="status">
审批状态正在同步,暂时不能提交决定。已输入的内容会保留。
</p>
) : null}
<div className="gdd-approval-card__dialog-actions">
<button
type="button"
disabled={decisionBusy}
onClick={() => {
setCommentAction(null);
setComment('');
}}
>
取消
</button>
<button
type="button"
disabled={decisionDisabled || !comment.trim()}
onClick={submitComment}
>
{decisionBusy ? '提交中' : '提交决定'}
</button>
</div>
</section>
</div>
) : null}
{gddDetailsOpen ? (
<GddDetailsDialog gdd={gdd} onClose={() => setGddDetailsOpen(false)} />
) : null}
</section>
);
}