From 914791d63c28b7c7f0bd3d1ace1498f6922de4b1 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:06:41 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=8A=B6=E6=80=81=E4=BA=92=E7=9B=B8=E6=B1=A1?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留已确认项目的独立状态 隔离项目检查代次和迟到结果 增加坏项目刷新回归测试并同步项目文档 --- .../features/app-shell/useRecentProjects.ts | 28 +++++- .../tests/recentProjectsHook.test.tsx | 93 +++++++++++++++++++ .../shared-memory/decision-log.md | 6 ++ ...】AGC客户端稳定版生命周期大切换-2026-09-14.md | 2 +- 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 2733aff91..b8f141ad3 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -3,6 +3,7 @@ import { type SetStateAction, useEffect, useMemo, + useRef, useState, } from 'react'; @@ -31,6 +32,9 @@ export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] = useState(false); const [projectSearchQuery, setProjectSearchQuery] = useState(''); + const recentWorkspacesRef = useRef(recentWorkspaces); + recentWorkspacesRef.current = recentWorkspaces; + const inspectionGenerationRef = useRef(0); async function inspectRecentWorkspace( invoke: NonNullable>, @@ -62,19 +66,36 @@ export function useRecentProjects(setStatus: Dispatch>) { useEffect(() => { const invoke = resolveTauriInvoke(); if (!invoke || recentWorkspaces.length === 0) { + inspectionGenerationRef.current += 1; setRecentWorkspaceStatuses({}); setRecentWorkspaceRefreshing(false); return; } + const inspectionGeneration = ++inspectionGenerationRef.current; let disposed = false; let pendingCount = recentWorkspaces.length; - setRecentWorkspaceStatuses({}); + // 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或 + // 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表 + // 清空成同一个异常状态。 + setRecentWorkspaceStatuses((current) => { + const next: Record = {}; + for (const workspace of recentWorkspaces) { + if (Object.prototype.hasOwnProperty.call(current, workspace)) { + next[workspace] = current[workspace] ?? null; + } + } + return next; + }); setRecentWorkspaceRefreshing(true); for (const workspace of recentWorkspaces) { void inspectRecentWorkspace(invoke, workspace).then( ([projectPath, status]) => { - if (disposed) { + if ( + disposed || + inspectionGeneration !== inspectionGenerationRef.current || + !recentWorkspacesRef.current.includes(projectPath) + ) { return; } setRecentWorkspaceStatuses((current) => ({ @@ -104,6 +125,9 @@ export function useRecentProjects(setStatus: Dispatch>) { return; } const [, status] = await inspectRecentWorkspace(invoke, projectPath); + if (!recentWorkspacesRef.current.includes(projectPath)) { + return; + } setRecentWorkspaceStatuses((current) => ({ ...current, [projectPath]: status, diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx new file mode 100644 index 000000000..e564a6fca --- /dev/null +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; + +import { useRecentProjects } from '../src/features/app-shell/useRecentProjects'; + +const READY_PROJECT = { + projectPath: '/tmp/ready-project', + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isGodotProject: false, + godotProjectRoot: null, + isCocosProject: false, + cocosProjectRoot: null, + isUnityProject: false, + unityProjectRoot: null, + projectName: '正常项目', + recentRunStatus: null, + recentRunStopReason: null, +}; + +afterEach(() => { + delete window.__TAURI__; + window.localStorage.clear(); +}); + +test('刷新坏项目时保留其它项目已确认的正常状态', async () => { + let finishPendingInspection: (() => void) | null = null; + const pendingInspection = new Promise((resolve) => { + finishPendingInspection = () => + resolve({ + ...READY_PROJECT, + projectPath: '/tmp/slow-broken-project', + projectName: '慢速坏项目', + }); + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'inspect_local_project_directory') { + throw new Error(`unexpected invoke ${command}`); + } + const projectPath = String(args?.projectPath ?? ''); + if (projectPath === '/tmp/slow-broken-project') { + return pendingInspection; + } + return READY_PROJECT; + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/ready-project']), + ); + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + await waitFor(() => { + expect(result.current.projectRows[0]).toMatchObject({ + name: '正常项目', + status: '本地项目', + canOpen: true, + }); + }); + + act(() => { + result.current.rememberRecentWorkspace('/tmp/slow-broken-project'); + }); + + await waitFor(() => { + expect(result.current.projectRows).toHaveLength(2); + const readyRow = result.current.projectRows.find( + (row) => row.path === '/tmp/ready-project', + ); + const slowRow = result.current.projectRows.find( + (row) => row.path === '/tmp/slow-broken-project', + ); + expect(readyRow).toMatchObject({ + name: '正常项目', + status: '本地项目', + canOpen: true, + }); + expect(slowRow).toMatchObject({ + name: 'slow-broken-project', + status: '检查中', + canOpen: false, + }); + }); + + await act(async () => { + finishPendingInspection?.(); + await pendingInspection; + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1e2c576da..31316e6ef 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,12 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和 > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径(2026-09-18):历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据。策划 Agent V1/V2 的 Runtime、专用命令、审批卡、展示适配和旧测试已删除;当前策划入口统一使用 Design Agent。如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-20 最近项目检查保持项目级隔离 + +- 背景:最近项目刷新会重新检查所有路径。若其中一个目录损坏、超时或不可读,清空整张状态表会让已确认正常的项目暂时全部显示“检查中”,用户只能移除坏项目后看到列表恢复。 +- 决策:最近项目状态按路径独立投影;刷新时保留仍在列表中的最后一次结果,只有新增或尚未检查的项目进入“检查中”。检查代次或列表成员变化后,迟到结果不得写回,单个项目的失败不能改变其它项目的可打开状态。 +- 验证:`recentProjectsHook.test.tsx` 覆盖“新增慢/坏项目刷新时保留正常项目”;`recentProjectsModel.test.ts`、`unityProjectOpen.test.tsx` 与前端类型检查一并执行。 + ## 2026-09-17 GameCreationApp 资源 kind 只保留一份词汇表:严格解析 + `app_log!` 留痕 - 背景:kind 曾经有三份实现——Rust 手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` + `canonical_game_creation_app_asset_kind()`(带 legacy 别名表与 `font → document` 特例)、TS 手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` + `GAME_CREATION_APP_LEGACY_ASSET_KINDS` + `canonicalGameCreationAppAssetKind()`、以及 ts-rs 生成的 TS union。两份手写表互相引用又各自收口,判据直接分叉(同一个 `"UI"` 一边归一成 `ui-design`、一边收口成 `unknown`),跨语言一致性只能靠正则解析源码的测试来钉。 diff --git a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md index ba403e9e9..63fcf35f1 100644 --- a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md +++ b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md @@ -29,7 +29,7 @@ ### RecentProjectInspection -每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。 +每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。刷新采用增量投影:已确认的项目结果继续保留,只有新增或尚未完成检查的项目显示“检查中”;项目被移除或检查代次变化后,迟到结果不得写回列表。 ### DevStackIdentity From df39d5a16f777d7e54756e617419f43eaf55c4c3 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:46:24 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=B8=A0=E9=81=93=E4=B8=8E=E9=94=99=E8=AF=AF=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户端 debug 保留服务器选择,正式包按发布渠道连接服务 修复错误报告时间解析与超时路由脱敏 重做后台错误详情面板并补齐定向测试 同步渠道、诊断与后台展示文档 --- apps/admin-web/src/api/adminApiTypes.ts | 2 + .../src/pages/AdminErrorReportsPage.test.tsx | 18 + .../src/pages/AdminErrorReportsPage.tsx | 298 +++++++++++--- apps/admin-web/src/styles/admin.css | 371 ++++++++++++++++++ .../scripts/build-release.mjs | 12 +- .../scripts/build-release.test.mjs | 15 + .../src-tauri/src/error_report/sanitize.rs | 33 +- .../src/app/AuthenticatedClient.tsx | 95 ++++- .../src/services/clientAuth.ts | 14 +- .../src/services/clientHttp.ts | 148 ++++++- .../src/services/errorReporting.ts | 23 +- .../tests/appSurface/auth.suite.ts | 13 +- .../tests/clientAuthStorage.test.ts | 16 +- .../tests/clientHttp.test.ts | 60 +-- .../tests/errorReporting.test.ts | 10 + .../shared-memory/team-conventions.md | 2 +- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 10 +- ...术方案】AGC错误报告与诊断上传-2026-08-31.md | 4 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 19 files changed, 1022 insertions(+), 124 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminErrorReportsPage.test.tsx diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 452ad30f3..1a1c6f668 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -144,6 +144,8 @@ export interface AdminErrorReportListResponse { } export interface AdminErrorReportDetail extends AdminErrorReportEntry { + firstFingerprint?: string; + firstSource?: string; note?: string; events: Array>; logNames: string[]; diff --git a/apps/admin-web/src/pages/AdminErrorReportsPage.test.tsx b/apps/admin-web/src/pages/AdminErrorReportsPage.test.tsx new file mode 100644 index 000000000..2c945abbe --- /dev/null +++ b/apps/admin-web/src/pages/AdminErrorReportsPage.test.tsx @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatAdminTimestamp, + parseAdminTimestamp, +} from './AdminErrorReportsPage'; + +describe('错误报告时间格式化', () => { + it('解析 SpacetimeDB seconds.microsZ 时间', () => { + expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731); + }); + + it('解析微秒字符串并拒绝无效时间', () => { + expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731); + expect(formatAdminTimestamp('not-a-date')).toBe('-'); + expect(formatAdminTimestamp('999999999999999999999999')).toBe('-'); + }); +}); diff --git a/apps/admin-web/src/pages/AdminErrorReportsPage.tsx b/apps/admin-web/src/pages/AdminErrorReportsPage.tsx index fbb5f8650..da2f7ebba 100644 --- a/apps/admin-web/src/pages/AdminErrorReportsPage.tsx +++ b/apps/admin-web/src/pages/AdminErrorReportsPage.tsx @@ -17,6 +17,57 @@ type Props = { token: string; onUnauthorized: (message?: string) => void }; const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const; const ERROR_REPORT_PAGE_SIZE = 50; +const ERROR_REPORT_STATUS_LABELS: Record = { + new: '待处理', + 'in-progress': '处理中', + resolved: '已解决', +}; + +export function parseAdminTimestamp(value: string | null | undefined) { + const normalized = value?.trim() ?? ''; + if (/^-?\d+\.\d{6}Z$/u.test(normalized)) { + const [secondsText, microsText] = normalized.slice(0, -1).split('.'); + const seconds = Number(secondsText); + const micros = Number(microsText); + return Number.isFinite(seconds) && Number.isFinite(micros) + ? seconds * 1000 + Math.floor(micros / 1000) + : Number.NaN; + } + if (/^-?\d+$/u.test(normalized)) { + const numeric = Number(normalized); + if (!Number.isFinite(numeric)) return Number.NaN; + if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000); + if (Math.abs(numeric) >= 1e11) return numeric; + return numeric * 1000; + } + return Date.parse(normalized); +} + +export function formatAdminTimestamp(value: string | null | undefined) { + const timestamp = parseAdminTimestamp(value); + if (!Number.isFinite(timestamp)) return '-'; + const date = new Date(timestamp); + if (!Number.isFinite(date.getTime())) return '-'; + try { + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(date); + } catch { + return '-'; + } +} + +function eventText(event: Record, key: string) { + const value = event[key]; + return typeof value === 'string' || typeof value === 'number' + ? String(value) + : ''; +} export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { const [reports, setReports] = useState([]); @@ -29,6 +80,24 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false }); const openReportRequestId = useRef(0); const loadRequestId = useRef(0); + const detailCloseButtonRef = useRef(null); + + useEffect(() => { + if (!selected) return; + const previousFocus = document.activeElement as HTMLElement | null; + const focusFrame = window.requestAnimationFrame(() => { + detailCloseButtonRef.current?.focus(); + }); + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setSelected(null); + }; + window.addEventListener('keydown', handleEscape); + return () => { + window.cancelAnimationFrame(focusFrame); + window.removeEventListener('keydown', handleEscape); + previousFocus?.focus?.(); + }; + }, [selected]); const load = useCallback(async () => { const requestId = ++loadRequestId.current; @@ -184,9 +253,15 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { {report.batchId} {report.eventCount} {report.source ?? '-'} - {report.status} + + + {ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status} + + {report.userId} - {new Date(report.createdAt).toLocaleString()} + {formatAdminTimestamp(report.createdAt)} ))} @@ -224,66 +299,173 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { className="admin-detail-modal" role="dialog" aria-label="错误报告详情" + aria-modal="true" + onMouseDown={(event) => { + if (event.target === event.currentTarget) setSelected(null); + }} > -
-
-

{selected.batchId}

-
-

- 用户:{selected.userId} · 事件:{selected.eventCount} · 日志: - {selected.logCount} -

-
{JSON.stringify(selected.events.slice(0, 20), null, 2)}
- {selected.userDescription ? ( -

用户描述:{selected.userDescription}

- ) : null} -