Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx
T
kdletters 01e97d3a02 补齐 Direct 回合跨页面恢复
新增活动 Direct 回合只读快照与状态更新
接入重进项目的忙碌态恢复和运行中项目面板
补充生命周期规范、决策记录与定向测试
2026-09-15 23:38:09 +08:00

120 lines
3.9 KiB
TypeScript

import type { GameCreatorDirectActiveTurn } from '../../app/types';
import { projectNameFromPath } from '../agent-runtime';
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
/**
* 左上角的"正在运行的项目"面板。
*
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连),
* 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时
* 整块不渲染,不留空白占位。
*/
export type ActiveProjectRunsPanelProps = {
activeTurns: GameCreatorDirectActiveTurn[];
currentProjectPath?: string | null;
readFailed?: boolean;
onOpenProject?: (projectPath: string) => void;
};
const ACTIVE_TURN_STATUS_LABELS: Record<string, string> = {
accepted: '已受理',
running: '创作中',
streaming: '生成中',
finalizing: '收尾中',
completed: '已完成',
failed: '已失败',
};
function activeTurnStatusLabel(status: string) {
return ACTIVE_TURN_STATUS_LABELS[status] ?? '创作中';
}
function formatActiveTurnElapsed(startedAt: number, now: number) {
const elapsedMs = now - startedAt;
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
return '';
}
const totalMinutes = Math.floor(elapsedMs / 60_000);
if (totalMinutes < 1) {
return '不到 1 分钟';
}
if (totalMinutes < 60) {
return `${totalMinutes} 分钟`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes} 分`;
}
function activeTurnDisplayName(turn: GameCreatorDirectActiveTurn) {
const snapshotName = turn.projectName?.trim();
return snapshotName || projectNameFromPath(turn.projectPath);
}
export function ActiveProjectRunsPanel({
activeTurns,
currentProjectPath = null,
readFailed = false,
onOpenProject,
}: ActiveProjectRunsPanelProps) {
if (activeTurns.length === 0) {
if (!readFailed) {
return null;
}
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
return (
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
<span className="launcher-runs-panel-note" role="status">
未能读取正在运行的项目
</span>
</aside>
);
}
const now = Date.now();
const orderedTurns = [...activeTurns].sort(
(left, right) => left.startedAt - right.startedAt,
);
return (
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
<header className="launcher-runs-panel-header">
<span className="launcher-runs-panel-dot" aria-hidden="true" />
<strong>正在运行</strong>
</header>
<ul className="launcher-runs-panel-list">
{orderedTurns.map((turn) => {
const name = activeTurnDisplayName(turn);
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
const isCurrent = Boolean(
currentProjectPath &&
projectPathsMatchForInvalidation(
turn.projectPath,
currentProjectPath,
),
);
return (
<li key={`${turn.projectPath}:${turn.turnId}`}>
<button
type="button"
className="launcher-runs-panel-item"
aria-current={isCurrent ? 'true' : undefined}
disabled={!onOpenProject}
onClick={() => onOpenProject?.(turn.projectPath)}
>
<span className="launcher-runs-panel-name" title={name}>
{name}
</span>
<span className="launcher-runs-panel-meta">
{[activeTurnStatusLabel(turn.status), elapsed]
.filter(Boolean)
.join(' · ')}
</span>
</button>
</li>
);
})}
</ul>
</aside>
);
}