From 127def0a8ae80c051e5b921b1f001984067c132d Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:21:57 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20Claude=20?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E8=AE=A4=E8=AF=81=E4=B8=8E=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方 Claude 模型复用当前平台会话令牌 保留 Claude 失败终态摘要并增加回归测试 补充 AGC 排障记忆 --- .../src-tauri/src/agent/claude_code_cli.rs | 68 +++++++++++++++++-- docs/project-memory/shared-memory/pitfalls.md | 6 ++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index a6ad51e1f..1b56483b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -385,12 +385,44 @@ fn configure_claude_code_environment( { command.env("ANTHROPIC_AUTH_TOKEN", llm.api_key.trim()); } + // Official AGC model entries use the account session rather than a + // user-supplied API key. The Claude SDK only reads Anthropic auth + // from its child-process environment, so bridge the already-held + // platform token without persisting or exposing it in argv/logs. + if llm.api_key.trim().is_empty() + && !llm.custom_enabled + && std::env::var_os("ANTHROPIC_API_KEY").is_none() + && std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none() + { + if let Some(session) = crate::platform_session::current_platform_session() { + command.env("ANTHROPIC_AUTH_TOKEN", session.access_token); + } + } if !llm.model.trim().is_empty() { command.env("ANTHROPIC_MODEL", llm.model.trim()); } } } +fn claude_result_error_detail(value: &serde_json::Value) -> Option { + let candidates = [ + value.get("result").and_then(serde_json::Value::as_str), + value.get("error").and_then(serde_json::Value::as_str), + value.get("message").and_then(serde_json::Value::as_str), + value + .get("errors") + .and_then(serde_json::Value::as_array) + .and_then(|errors| errors.first()) + .and_then(serde_json::Value::as_str), + ]; + candidates + .into_iter() + .flatten() + .map(str::trim) + .find(|detail| !detail.is_empty()) + .map(|detail| detail.chars().take(512).collect()) +} + fn configure_claude_code_process(command: &mut tokio::process::Command) { super::codex_cli::configure_game_creator_codex_cli_process(command); } @@ -495,9 +527,12 @@ fn parse_claude_code_result( platform_llm::LlmError::Deserialize("Claude Code JSON 输出无效".to_string()) })?; if value.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { - return Err(platform_llm::LlmError::Transport( - "Claude Code 返回失败终态".to_string(), - )); + let detail = claude_result_error_detail(&value) + .map(|detail| format!(":{detail}")) + .unwrap_or_default(); + return Err(platform_llm::LlmError::Transport(format!( + "Claude Code 返回失败终态{detail}" + ))); } let response_id = value .get("session_id") @@ -829,7 +864,10 @@ fn parse_direct_stream_result( } } else if event_type == Some("result") { if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { - return Err("Claude Code 返回失败终态".to_string()); + let detail = claude_result_error_detail(&event) + .map(|detail| format!(":{detail}")) + .unwrap_or_default(); + return Err(format!("Claude Code 返回失败终态{detail}")); } result = event .get("result") @@ -884,6 +922,16 @@ mod tests { assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); } + #[test] + fn preserves_claude_terminal_error_detail() { + let error = parse_claude_code_result( + br#"{"is_error":true,"result":"Authentication failed"}"#, + &request(false), + ) + .expect_err("failed terminal must be rejected"); + assert!(error.to_string().contains("Authentication failed")); + } + #[test] fn parses_direct_stream_result_and_final_text() { let mut observed = Vec::new(); @@ -903,4 +951,16 @@ mod tests { [DirectCodexTurnObservation::AgentMessageSegment { .. }] )); } + + #[test] + fn preserves_direct_stream_terminal_error_detail() { + let error = parse_direct_stream_result( + br#"{"type":"result","is_error":true,"result":"Authentication failed"} +"#, + "turn-1", + None, + ) + .expect_err("failed terminal must be rejected"); + assert!(error.contains("Authentication failed")); + } } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5c093965c..3528c71a9 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6158,3 +6158,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **同时**:主站顶栏(`platform-desktop-topbar`)在窄屏必须保持单行——加 `flex-wrap` 后新增一个动作入口就会让品牌折到第二行;窄屏降级靠 `< 640px` 图标化下载入口、`< 480px` 只留品牌 IP 标识。 - **验证**:真机尺寸下用 `document.elementFromPoint(按钮中心)` 断言命中的是按钮自身而不是底部菜单(`overlay.parentElement === document.body`);顶栏断点矩阵(320–768)断言单行且无横向溢出。 - **关联**:`src/components/common/SquareImageCropModal.tsx`、`src/components/platform-entry/PlatformProfileModalShell.tsx`、`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`、`src/index.css`。 + +## 2026-10-01 AGC 官方 Claude 模型缺少账号令牌会被误报为通道断开 + +- **现象**:模型目录把回合路由到 `cc`,本地 `game-creator.config.json` 的 `llm.apiKey` 为空时,Claude Agent SDK 返回失败终态;界面只显示“执行通道未能建立或已断开”。 +- **根因**:Claude sidecar 只从 `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` 或本地 `llm.apiKey` 读取认证,没有复用已登录的 AGC 平台会话;同时失败终态解析丢弃了上游错误摘要。 +- **处理**:官方模型且未启用自定义目录时,将当前平台会话令牌仅注入 sidecar 子进程环境;保留最多 512 字符的 Claude 终态错误摘要,继续由统一诊断层脱敏,避免凭据落盘。 From bc6f40d6db8595f2ae0ab4273d51bd422643d933 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:04:24 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E5=AE=8C=E5=96=84=20Claude=20sidecar=20?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 认证缺失时提前返回可操作错误 超时时保留 sidecar stderr 摘要 --- .../src-tauri/src/agent/claude_code_cli.rs | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 1b56483b5..84d61771d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -212,7 +212,7 @@ async fn run_sidecar_turn( .take() .ok_or_else(|| "Claude Agent SDK sidecar stderr 未建立".to_string())?; let stderr_task = tokio::spawn(read_capped(stderr, CLAUDE_CODE_OUTPUT_MAX_BYTES)); - let result = tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async { + let result = match tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async { let mut lines = BufReader::new(stdout).lines(); let mut events = Vec::new(); let mut terminal = None; @@ -257,7 +257,21 @@ async fn run_sidecar_turn( Ok(SidecarTurnResult { events, result }) }) .await - .map_err(|_| "Claude Agent SDK sidecar 回合超时".to_string())??; + { + Ok(result) => result?, + Err(_) => { + let detail = stderr_task + .await + .ok() + .and_then(Result::ok) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .map(|value| value.trim().chars().take(512).collect::()) + .filter(|value| !value.is_empty()) + .map(|value| format!(":{value}")) + .unwrap_or_default(); + return Err(format!("Claude Agent SDK sidecar 回合超时{detail}")); + } + }; let _ = stderr_task.await; Ok(result) } @@ -317,6 +331,14 @@ pub(crate) fn game_creator_claude_code_cli_route_error( if model.is_empty() || model.chars().count() > CLAUDE_CODE_MODEL_MAX_CHARS { return Some("cc 模式需要有效的 Claude Code 模型标识".to_string()); } + if llm.api_key.trim().is_empty() + && !llm.custom_enabled + && std::env::var_os("ANTHROPIC_API_KEY").is_none() + && std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none() + && crate::platform_session::current_platform_session().is_none() + { + return Some("cc 模式需要先登录陶泥儿账号".to_string()); + } None } From 97617608ba9f40cb601cf4319a48e45c764def73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Thu, 1 Oct 2026 17:54:53 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Web=20=E9=A2=84?= =?UTF-8?q?=E6=A3=80=E9=94=99=E8=AF=AF=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/app-shell/homeWebPreflight.ts | 60 +++++++++++++--- .../app-shell/useHomeProjectCreation.ts | 14 ++-- .../tests/homeWebPreflight.test.tsx | 70 +++++++++++++------ docs/project-memory/shared-memory/pitfalls.md | 8 +++ ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 5 files changed, 118 insertions(+), 35 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts b/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts index a166a5f8f..ff489639c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts @@ -4,19 +4,63 @@ import type { HomeCreationType } from '../../view/home/useHomeDraftStore'; export const HOME_WEB_PREFLIGHT_FAILURE = 'Web 游戏环境预检未通过,请检查 Node/npm 或浏览器后重试;尚未开始生成。'; +export const HOME_WEB_PREFLIGHT_IPC_FAILURE = + 'Web 游戏环境预检调用失败,客户端连接异常;请重启客户端后重试,尚未开始生成。'; + +type HomeWebPreflightReport = { + status?: string; + code?: string; +}; + +function knownPreflightCode(value: unknown): string | undefined { + const text = value instanceof Error ? value.message : value; + if (typeof text !== 'string') return undefined; + const match = text.match( + /(?:预检未通过:|预检未通过(|code[=:])([a-z0-9-]+)/i, + ); + return match?.[1]; +} + +export function homeWebPreflightErrorMessage(value: unknown): string { + if (value instanceof Error && value.message === HOME_WEB_PREFLIGHT_FAILURE) { + return HOME_WEB_PREFLIGHT_FAILURE; + } + const code = knownPreflightCode(value); + if (code) return `Web 游戏环境预检未通过(${code});尚未开始生成。`; + return HOME_WEB_PREFLIGHT_IPC_FAILURE; +} + +function isHostPreflightError(value: unknown): boolean { + if (!(value instanceof Error)) return false; + return value.message === HOME_WEB_PREFLIGHT_FAILURE || Boolean(knownPreflightCode(value.message)); +} + export async function ensureHomeWebCreationEnvironment( invoke: TauriInvoke, creationType: HomeCreationType, startMode: ProjectStartMode, ): Promise { if (creationType !== 'game' || startMode !== 'direct-build') return; - try { - const report = await invoke<{ status: string }>( - 'preflight_web_game_creation', - ); - if (report?.status !== 'ready') throw new Error(HOME_WEB_PREFLIGHT_FAILURE); - } catch { - // 不把子进程、Provider 或宿主路径的原始错误注入首页状态栏。 - throw new Error(HOME_WEB_PREFLIGHT_FAILURE); + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const report = await invoke( + 'preflight_web_game_creation', + ); + if (report?.status === 'ready') return; + throw new Error( + report?.code + ? `Web 游戏环境预检未通过:${report.code}` + : HOME_WEB_PREFLIGHT_FAILURE, + ); + } catch (error) { + lastError = error; + const code = knownPreflightCode(error); + if (isHostPreflightError(error) || code || attempt === 1) { + throw new Error(homeWebPreflightErrorMessage(error)); + } + await new Promise((resolve) => window.setTimeout(resolve, 250)); + } } + throw new Error(homeWebPreflightErrorMessage(lastError)); } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index c7a572afc..bd065b4fa 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -57,7 +57,7 @@ import { importDesignFiles } from '../project-workspace/importDesignFiles'; import { clearAclElevationDenials } from './aclElevation'; import { ensureHomeWebCreationEnvironment, - HOME_WEB_PREFLIGHT_FAILURE, + homeWebPreflightErrorMessage, } from './homeWebPreflight'; import type { ShowTransientNotice } from './model'; import { resolveSessionPreviewOnProjectOpen } from './sessionPreview'; @@ -458,9 +458,10 @@ export function useHomeProjectCreation({ } try { await ensureHomeWebCreationEnvironment(invoke, creationType, startMode); - } catch { - setStatus(HOME_WEB_PREFLIGHT_FAILURE); - return HOME_WEB_PREFLIGHT_FAILURE; + } catch (error) { + const message = homeWebPreflightErrorMessage(error); + setStatus(message); + return message; } const result = await invoke( 'init_local_game_project', @@ -894,7 +895,8 @@ export function useHomeProjectCreation({ draft.creationType, startMode, ); - } catch { + } catch (error) { + const message = homeWebPreflightErrorMessage(error); setHomeCreationOperation( transitionClientOperation( operation, @@ -902,7 +904,7 @@ export function useHomeProjectCreation({ operationScope(), ), ); - return HOME_WEB_PREFLIGHT_FAILURE; + return message; } const suggestedName = options.suggestName ? await suggestAutomaticProjectName(invoke, draft) diff --git a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx index ad93e007a..39e0a32c3 100644 --- a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx @@ -3,7 +3,10 @@ import { act, cleanup, renderHook } from '@testing-library/react'; import { afterEach, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; -import { HOME_WEB_PREFLIGHT_FAILURE } from '../src/features/app-shell/homeWebPreflight'; +import { + HOME_WEB_PREFLIGHT_FAILURE, + HOME_WEB_PREFLIGHT_IPC_FAILURE, +} from '../src/features/app-shell/homeWebPreflight'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; afterEach(() => { @@ -67,26 +70,51 @@ it('awaits the real home preflight before naming or creating a game', async () = expect(result.current.currentProjectContext?.creationType).toBe('game'); }); -it.each([false, true])( - 'blocks naming, creation, and first generation when preflight fails (throws=%s)', - async (throws) => { - const { result, calls } = mount(async () => { - if (throws) throw new Error('private host path'); - return { status: 'blocked' }; - }); - let status = ''; - await act(async () => { - status = await result.current.createHomeDraftAutomatically( - { creationType: 'game', prompt: '做一个小游戏', attachments: [] }, - 'direct-build', - ); - }); - expect(status).toBe(HOME_WEB_PREFLIGHT_FAILURE); - expect(calls).toEqual(['preflight_web_game_creation']); - expect(result.current.currentProjectContext).toBeNull(); - expect(status).not.toContain('private'); - }, -); +it('blocks naming, creation, and first generation with the host error code', async () => { + const { result, calls } = mount(async () => { + throw new Error('Web 游戏环境预检未通过:web-preflight-browser-failed;尚未启动生成'); + }); + let status = ''; + await act(async () => { + status = await result.current.createHomeDraftAutomatically( + { creationType: 'game', prompt: '做一个小游戏', attachments: [] }, + 'direct-build', + ); + }); + expect(status).toBe('Web 游戏环境预检未通过(web-preflight-browser-failed);尚未开始生成。'); + expect(calls).toEqual(['preflight_web_game_creation']); + expect(result.current.currentProjectContext).toBeNull(); +}); + +it('distinguishes an IPC failure from a host preflight failure', async () => { + const { result, calls } = mount(async () => { + throw new Error('Failed to fetch'); + }); + let status = ''; + await act(async () => { + status = await result.current.createHomeDraftAutomatically( + { creationType: 'game', prompt: '做一个小游戏', attachments: [] }, + 'direct-build', + ); + }); + expect(status).toBe(HOME_WEB_PREFLIGHT_IPC_FAILURE); + expect(calls).toEqual(['preflight_web_game_creation', 'preflight_web_game_creation']); + expect(result.current.currentProjectContext).toBeNull(); +}); + +it('keeps the legacy fallback for a blocked report without a code', async () => { + const { result, calls } = mount(async () => ({ status: 'blocked' })); + let status = ''; + await act(async () => { + status = await result.current.createHomeDraftAutomatically( + { creationType: 'game', prompt: '做一个小游戏', attachments: [] }, + 'direct-build', + ); + }); + expect(status).toBe(HOME_WEB_PREFLIGHT_FAILURE); + expect(calls).toEqual(['preflight_web_game_creation']); + expect(result.current.currentProjectContext).toBeNull(); +}); it.each(['game', 'doc'] as const)( 'does not require the Web preflight for %s planning', diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0602a9974..2bb5a853b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6183,3 +6183,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **同时**:主站顶栏(`platform-desktop-topbar`)在窄屏必须保持单行——加 `flex-wrap` 后新增一个动作入口就会让品牌折到第二行;窄屏降级靠 `< 640px` 图标化下载入口、`< 480px` 只留品牌 IP 标识。 - **验证**:真机尺寸下用 `document.elementFromPoint(按钮中心)` 断言命中的是按钮自身而不是底部菜单(`overlay.parentElement === document.body`);顶栏断点矩阵(320–768)断言单行且无横向溢出。 - **关联**:`src/components/common/SquareImageCropModal.tsx`、`src/components/platform-entry/PlatformProfileModalShell.tsx`、`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`、`src/index.css`。 + +## 2026-10-01 AGC 首页把 Web 预检错误与 Tauri IPC 错误合并,造成无法诊断的生成阻拦 + +- **现象**:用户在首页点击「做游戏」后看到「Web 游戏环境预检未通过,请检查 Node/npm 或浏览器」,但同一安装包的 `--environment-check` 可能已经返回 `status=ready`;首页仍会阻止自动命名、建项和首次生成。 +- **根因**:`homeWebPreflight.ts` 原先对 `preflight_web_game_creation` 的所有异常统一抛出 `HOME_WEB_PREFLIGHT_FAILURE`,调用方 `useHomeProjectCreation.ts` 的两层 `catch` 又再次覆盖原始错误。于是宿主真实预检码、Tauri IPC 断连、命令未注册和 WebView `Failed to fetch` 都显示成 Node/npm/浏览器故障。 +- **处理**:保留预检失败关闭语义,但提取 Rust 返回的安全错误码;对无错误码的 Tauri 调用故障显示独立 IPC 提示,并仅对这类瞬态调用失败重试一次。首页调用方必须透传该安全提示,不能重新覆盖为通用文案。 +- **验证**:`apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx` 覆盖 ready、具体宿主错误码、IPC 失败、无错误码 blocked 和 planning 绕过;同一 Windows 安装包 CLI 实测 Node `v22.22.2`、npm `10.9.7`、Edge desktop/mobile PNG 均通过。 +- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx`、`apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 21b0ba771..860ef3572 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -116,6 +116,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema - 对客户端刚创建且内容仍匹配可信模板的 Web 脚手架,在正式生成前由宿主执行受控依赖准备和真实 Vite 构建。依赖安装禁用生命周期脚本;不自动安装或覆盖导入/用户修改过的工程。 - 准备凭证的 `ready` 只证明初次环境准备成功,不代表当前游戏已验收,后续正常修改不得因此重新安装。`preparing` 中断恢复必须证明原拥有者已结束且其执行子树已回收;身份或归属未知时保留阻断,不重复执行。 - 输出明确区分“Node/npm 构建能力通过”与“本项目 Vite 构建通过”;任何一步失败保留可行动错误,不降级为预检成功。 +- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。 - 安装载荷提供相同的安全预检 CLI,验证无系统 Node 的独立运行、缺包/篡改失败关闭。NSIS 解包载荷 smoke 与真实安装器注册流程分开报告,不覆盖当前用户的既有安装。 ### 跑酷固定基线 From ac4148d24b591b718371134ad26eca46a2c0c05f Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:37:31 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E7=94=9F=E4=BA=A7=20Nginx=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E5=88=B0=E4=BB=93=E5=BA=93=E6=A8=A1=E6=9D=BF=E5=B9=B6?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E7=94=9F=E4=BA=A7=E6=9C=BA=20host-only=20?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主模板 http 段新增 genarrative_gallery_rps 限流 zone,供公开作品架与自定义世界画廊读取接口使用 - 新增 deploy/nginx/snippets/genarrative-host-extras.conf:内部工具页、公开画廊读取 location、主站官网首页入口 - 主模板改为 include host-only snippet,线上 vhost 以仓库模板为唯一来源 - Server-Provision 安装、备份并回滚 genarrative-host-extras.conf,补充 require_path 与临时文件清理 - 生产运维护栏新增 host-only snippet 安装断言 - 运维文档补充主站 SPA 白名单与 host-only 覆盖小节及生效配置核对命令 - 踩坑记录新增线上手工维护漂移条目:/profile 刷新 404 与 client_max_body_size 停在 64m - 决策记录新增生产 vhost 单一来源、host-only 独立成 snippet、退役路由不做显式 404 的长期口径 --- deploy/nginx/genarrative.conf | 8 + .../snippets/genarrative-host-extras.conf | 152 ++++++++++++++++++ .../shared-memory/decision-log.md | 7 + docs/project-memory/shared-memory/pitfalls.md | 8 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 24 +++ scripts/check-production-ops-guardrails.mjs | 6 + scripts/jenkins-server-provision.sh | 24 ++- 7 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 deploy/nginx/snippets/genarrative-host-extras.conf diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 981a7c932..ebf5d4520 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -12,6 +12,9 @@ upstream genarrative_api { } limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m; +# 中文注释:公开作品架 / 自定义世界画廊的读取 QPS 远高于普通创作接口,单独使用高阈值限流 zone; +# 连接数上限仍复用 genarrative_api_conn,避免绕过 API 侧的整体连接保护。 +limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s; limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s; limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s; @@ -135,6 +138,11 @@ server { try_files $uri =404; } + # 中文注释:以下为生产机 host-only 覆盖(内部工具页 / 官网页 / 公开画廊读取限流)。 + # 这些路径不属于平台共享发布产物,也不在 Pingora 路由矩阵覆盖范围内, + # 因此独立成 snippet:新增平台路由仍必须走矩阵 + Pingora 门禁。 + include /etc/nginx/snippets/genarrative-host-extras.conf; + # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { diff --git a/deploy/nginx/snippets/genarrative-host-extras.conf b/deploy/nginx/snippets/genarrative-host-extras.conf new file mode 100644 index 000000000..e78971432 --- /dev/null +++ b/deploy/nginx/snippets/genarrative-host-extras.conf @@ -0,0 +1,152 @@ +# 生产机 host-only 覆盖:内部工具页、主站域名上的官网页入口、公开画廊读取限流。 +# 中文注释:这些路径依赖生产机本地静态根(/srv/genarrative/tools、/srv/genarrative/corporate-site-current), +# 不属于平台共享发布产物,因此不进入 deploy/pingora/nginx-route-parity.matrix.json 的 Pingora 路由矩阵。 +# 新增平台路由仍必须在主模板内声明并同步矩阵与 Pingora 网关;只有确认与网关无关的生产机专属路径才放这里。 +# Pingora 接公网 443 前,必须为这里的路径单独确认处理方式,否则这些页面与限流会在切换后丢失。 + +# 中文注释:内部工具静态页与主站 Web 根分离,页面产物放在 /srv/genarrative/tools/<页面>/; +# 这里只声明精确入口,且不接入维护窗口,避免把内部工具页并入公网维护开关。 +location = /finance-forecast { + return 301 /finance-forecast/; +} + +location ^~ /finance-forecast/ { + root /srv/genarrative/tools; + index index.html; + try_files $uri $uri/ /finance-forecast/index.html; +} + +location = /medical-science { + return 301 /medical-science/; +} + +location ^~ /medical-science/ { + root /srv/genarrative/tools; + index index.html; + try_files $uri $uri/ /medical-science/index.html; +} + +# 中文注释:公开作品架 / 自定义世界画廊读取接口使用专用高阈值限流(zone 在主模板 http 段声明); +# 其余 /api 请求继续走主模板的通用 location,保持默认保护强度。 +location = /api/runtime/puzzle/gallery { + default_type application/json; + limit_conn genarrative_api_conn 320; + limit_req zone=genarrative_gallery_rps burst=4096 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; +} + +location = /api/runtime/custom-world-gallery { + default_type application/json; + limit_conn genarrative_api_conn 320; + limit_req zone=genarrative_gallery_rps burst=4096 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; +} + +location ~ ^/api/runtime/puzzle/gallery/[^/]+$ { + default_type application/json; + limit_conn genarrative_api_conn 32; + limit_req zone=genarrative_api_rps burst=32 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; +} + +location ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ { + default_type application/json; + limit_conn genarrative_api_conn 32; + limit_req zone=genarrative_api_rps burst=32 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; +} + +# BEGIN CORPORATE SITE HOME ROUTE +# 中文注释:主站域名保留清智创游官网首页入口,静态根与官网独立 vhost +# (/etc/nginx/conf.d/tsingnova-games.conf)共用同一份官网构建;只有 /home 与 /home/ 生效。 +# 该入口是 2026-08-11 官网拆分前的历史遗留,是否退役需要与官网侧一起确认; +# 退役时必须同时删除本块与线上对应 location,避免两侧再次漂移。 +location = /home { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + return 301 /home/; +} + +location ^~ /home/ { + error_page 503 /maintenance.html; + root /srv/genarrative/corporate-site-current; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri $uri/ /home/index.html; +} +# END CORPORATE SITE HOME ROUTE diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c01d2a426..09d50f630 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,12 @@ # 决策记录 +## 2026-10-01 生产 Nginx 以模板为唯一来源:host-only 块独立成 snippet、退役路由不做显式 404 + +- 背景:线上主站 `genarrative.conf` 长期手工维护,`profile` 未进 SPA allowlist 导致 `/profile` 刷新 404,`client_max_body_size` 也停在 `64m`;同时线上存在 4 处仓库模板没有的 host-only 块(画廊读取限流、`/finance-forecast/`、`/medical-science/`、`/home/` 官网首页入口),直接用 `Genarrative-Server-Provision` 覆盖会静默删除它们。 +- 决策:生产 vhost 以 `deploy/nginx/genarrative.conf` 为唯一来源;生产机专属路径收进 `deploy/nginx/snippets/genarrative-host-extras.conf`,由主模板 include、由 Server-Provision 安装,新增平台路由仍必须在主模板内声明并同步 `deploy/pingora/nginx-route-parity.matrix.json` 与 Pingora 网关。snippet 内的 location 有意不在矩阵覆盖范围,Pingora 接公网 443 前必须单独确认这些路径的处理方式。 +- 边界:退役路由(`/match3d`、`/puzzle`、`/runtime/*`、`/gallery/*/detail`、`/works/detail`、`/worlds/detail`、`/bark-battle` 等)不再进 SPA allowlist,也不配置显式 404,统一落 `location /` 的 `error_page 404 /404.html`。`/home/` 是 2026-08-11 官网拆分前的历史入口,是否退役需与官网侧一起决定。 +- 验证方式:`node scripts/check-nginx-spa-routes.mjs`、`node scripts/check-pingora-route-parity.mjs`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`、`bash -n scripts/jenkins-server-provision.sh`;线上改后按生效配置烟测 `/profile`、模板 12 条 SPA 路由、`/admin/`、`/home/`、`/finance-forecast/`、`/medical-science/`、画廊 API 与 `/games/game_/…` 发行网关。 + ## 2026-09-30 release 渠道移除产品名与包名后缀 - 决策:`release` 渠道的正式产品名统一为 `陶泥儿`,Windows NSIS、macOS DMG / updater 归档等由 Tauri `productName` 派生的包名不再包含 `Release` 文本;`identifier=world.genarrative.ai-game-creator.release` 与 `release-win` 更新分区保持不变。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3528c71a9..eaa5c4bee 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-01 线上 Nginx 手工维护漂移:`/profile` 刷新 404、发行包请求体仍限 64m + +- **现象**:登录后刷新 `https://www.genarrative.world/profile` 直接 404,返回 Nginx 默认 404 页(162 字节);应用内点击进入正常。`curl` 复现:`/`、`/project`、`/creation`、`/components`、`/design-system`、`/games*`、`/editor/canvas` 全部 200 且正文与 `/` 同一份 SPA 外壳,只有 `/profile`(含 `/PROFILE`、`/profile/`)404。 +- **原因**:刷新是真实 HTTP 请求,命中线上 `/etc/nginx/conf.d/genarrative.conf` 的 SPA allowlist;线上白名单是手工维护的(留档 `genarrative.conf.bak-sparoutes-20260923T153751Z`),`profile` 从未加入,该深链落回 `location /` 的 `try_files $uri $uri/ =404`。仓库三份模板当时都写了 `profile`,而 `npm run check:nginx-spa-routes` 只校验仓库模板,查不出线上漂移。同一份线上文件还把 `client_max_body_size` 停在 `64m`(模板 210m,会让 200 MiB 发行包上传 413),并保有 4 处模板没有的 host-only 块(画廊读取限流 location、`/finance-forecast/`、`/medical-science/`、`/home/` 官网首页入口),所以直接拿模板整文件覆盖会删掉这些线上能力。 +- **处理**:线上只收敛 SPA allowlist(12 条,含 `/profile`)并补 `error_page 404 /404.html`;host-only 块收进 `deploy/nginx/snippets/genarrative-host-extras.conf`,由主模板 include、由 `Genarrative-Server-Provision` 安装。退役路由不进白名单,也不需要显式 404 配置,落 `location /` 即得品牌 404(非 HTML 客户端保持纯 404)。 +- **判据/取证**:改前 `grep -c profile /etc/nginx/conf.d/genarrative.conf` 为 0;改后 `curl -s -o /dev/null -w '%{http_code}' https://www.genarrative.world/profile` 为 200(正文 sha 与 `/` 一致),`sudo nginx -T | grep -c 'editor/canvas|profile|project'` 为 1,`match3d` 计数为 0。 +- **长期口径**:任何 SPA/路由白名单改动都要同时核对**生效配置**(`sudo nginx -T`)与仓库模板;线上主站 vhost 只由模板 + `genarrative-host-extras.conf` 生成,不再手工追加路由。snippet 内的 location 不在 Pingora 路由矩阵覆盖范围,Pingora 接公网 443 前要单独确认这些 host-only 路径。 + ## 2026-09-30 Jenkins release 渠道环境污染 AGC 构建单测 - **现象**:Jenkins `Genarrative-Agc-MacOS-Build` 的 release lane 在执行 `build-release.test.mjs` 时,`release stages Node before Tauri...` 用例报 `Cannot read properties of undefined (reading 'nsis')`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 7946645c1..6db558aa9 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -23,6 +23,30 @@ grep -i '^cache-control: no-store' ~/data/tmp/tsingnova-client-download.headers 验收必须同时满足:接口返回 `200 application/json`,响应包含 `downloads` 与 `unavailablePlatforms`,并带 `Cache-Control: no-store`;`https://tsingnovagames.com/` 仍返回官网首页。若 API 直连 `http://127.0.0.1:8082/api/client-downloads` 是 `200`、官网域名仍是 `404`,说明官网 vhost 没有 include 下载 snippet,不能靠 reload 旧配置解决。 +### 主站 SPA 白名单与 host-only 覆盖 + +主站 SPA 深链(`/profile`、`/project`、`/games/*` 等)只在 `deploy/nginx/genarrative.conf` 的 `# BEGIN GENARRATIVE MAIN SPA ROUTES` allowlist 内回退 `/index.html`;不在白名单的路径落回 `location /` 的 `try_files $uri $uri/ =404`,再经 `error_page 404 /404.html` 返回品牌 404 页。非 HTML 客户端(`Accept` 不含 `text/html`)保持纯 404,由维护 snippet 的 `location = /404.html` 决定,属预期行为。 + +2026-10-01 线上 `/profile` 刷新 404 就是白名单漂移:线上 `genarrative.conf` 是手工维护的,`profile` 从未加进 allowlist,而仓库三份模板都写了它;`npm run check:nginx-spa-routes` 只校验仓库模板,查不出线上漂移。同一份线上文件还把 `client_max_body_size` 停在 `64m`,并保有 4 处模板当时没有的 host-only 块,因此**不能**直接拿模板整文件覆盖线上。 + +- 生产 vhost 以 `deploy/nginx/genarrative.conf` 为唯一来源;生产机专属路径收在 `deploy/nginx/snippets/genarrative-host-extras.conf`(`/finance-forecast/`、`/medical-science/`、公开画廊读取限流 location、`/home/` 官网首页入口),由主模板 include,并由 `Genarrative-Server-Provision` 安装到 `/etc/nginx/snippets/`。 +- snippet 内的 location 不在 `deploy/pingora/nginx-route-parity.matrix.json` 覆盖范围;新增**平台**路由必须在主模板内声明并同步矩阵与 Pingora 网关,否则 `npm run check:pingora-route-parity` 的「模板 location 必须被矩阵覆盖」会失败。 +- 退役路由(`/match3d`、`/puzzle`、`/runtime/*`、`/gallery/*/detail`、`/works/detail`、`/worlds/detail` 等)不需要任何显式 404 配置,也不进白名单,落 `location /` 即得品牌 404。 + +改动或排障后按生效配置核对,不要只看仓库模板: + +```bash +sudo nginx -T | grep -n -A6 'GENARRATIVE MAIN SPA ROUTES' # allowlist 生效内容 +sudo nginx -T | grep -n 'client_max_body_size' # 期望 210m +sudo nginx -T | grep -n 'genarrative-host-extras' # host-only snippet 已 include +for p in / /profile /project /creation /components /design-system /games /games/detail \ + /games/mine /games/play /games/publish /editor/canvas; do + printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://www.genarrative.world$p)" +done # 期望全部 200,且正文与 / 的 index.html 一致 +curl -s -o /dev/null -w '%{http_code}\n' https://www.genarrative.world/match3d # 期望 404 +sudo nginx -t && sudo systemctl reload nginx +``` + ## 构建回归的隔离与发布文件权限 Git hook 的临时仓库测试必须清除子进程继承的仓库定位环境(例如 `GIT_DIR`、`GIT_WORK_TREE`、`GIT_INDEX_FILE`);仅设置 `cwd` 不能隔离 Git。回归应从带这些变量的外层仓库运行,验证外层引用、索引与配置不变。临时测试文件必须留在独立目录并清理,不得通过测试生成主仓库提交或覆盖 ESLint、Prettier 配置。修复 lint 配置时保留原有规则、忽略范围与零警告门禁,不以关闭规则代替排障。 diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index a4ef58b77..d412db2c3 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -2079,6 +2079,12 @@ const checks = [ reason: 'Server-Provision 必须安装 Pingora 真实路径 canary snippet,供人工启用直连前中间验证。', }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'genarrative-host-extras.conf', + reason: + 'Server-Provision 必须安装生产机 host-only snippet,避免 provision 后内部工具页与官网首页入口丢失。', + }, { file: 'scripts/jenkins-server-provision.sh', includes: 'genarrative-pingora-gateway', diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 9d2bda38a..dc20e57f1 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -1094,13 +1094,16 @@ install_nginx_config_with_rollback() { local snippet_target="/etc/nginx/snippets/genarrative-maintenance.conf" local pingora_canary_snippet_target="/etc/nginx/snippets/genarrative-pingora-canary.conf" local pingora_realpath_canary_snippet_target="/etc/nginx/snippets/genarrative-pingora-realpath-canary.conf" + local host_extras_snippet_target="/etc/nginx/snippets/genarrative-host-extras.conf" local config_source local rendered_config rendered_snippet rendered_pingora_canary_snippet rendered_pingora_realpath_canary_snippet - local config_backup snippet_backup pingora_canary_snippet_backup pingora_realpath_canary_snippet_backup disabled_sites + local rendered_host_extras_snippet + local config_backup snippet_backup pingora_canary_snippet_backup pingora_realpath_canary_snippet_backup host_extras_snippet_backup disabled_sites local had_config="false" local had_snippet="false" local had_pingora_canary_snippet="false" local had_pingora_realpath_canary_snippet="false" + local had_host_extras_snippet="false" run_cmd mkdir -p /etc/nginx/snippets /etc/nginx/conf.d if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then @@ -1116,6 +1119,7 @@ install_nginx_config_with_rollback() { echo "+ install -m 0644 deploy/nginx/snippets/genarrative-maintenance.conf ${snippet_target}" echo "+ install -m 0644 deploy/nginx/snippets/genarrative-pingora-canary.conf ${pingora_canary_snippet_target}" echo "+ install -m 0644 deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf ${pingora_realpath_canary_snippet_target}" + echo "+ install -m 0644 deploy/nginx/snippets/genarrative-host-extras.conf ${host_extras_snippet_target}" if [[ "${DRY_RUN}" == "true" ]]; then echo "+ disable /etc/nginx/sites-enabled/default* if present" @@ -1128,10 +1132,12 @@ install_nginx_config_with_rollback() { rendered_snippet="$(mktemp)" rendered_pingora_canary_snippet="$(mktemp)" rendered_pingora_realpath_canary_snippet="$(mktemp)" + rendered_host_extras_snippet="$(mktemp)" config_backup="$(mktemp)" snippet_backup="$(mktemp)" pingora_canary_snippet_backup="$(mktemp)" pingora_realpath_canary_snippet_backup="$(mktemp)" + host_extras_snippet_backup="$(mktemp)" disabled_sites="$(mktemp)" if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then validate_nginx_tls @@ -1142,6 +1148,7 @@ install_nginx_config_with_rollback() { cp deploy/nginx/snippets/genarrative-maintenance.conf "${rendered_snippet}" cp deploy/nginx/snippets/genarrative-pingora-canary.conf "${rendered_pingora_canary_snippet}" cp deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf "${rendered_pingora_realpath_canary_snippet}" + cp deploy/nginx/snippets/genarrative-host-extras.conf "${rendered_host_extras_snippet}" if [[ -f "${config_target}" ]]; then cp -p "${config_target}" "${config_backup}" @@ -1159,11 +1166,16 @@ install_nginx_config_with_rollback() { cp -p "${pingora_realpath_canary_snippet_target}" "${pingora_realpath_canary_snippet_backup}" had_pingora_realpath_canary_snippet="true" fi + if [[ -f "${host_extras_snippet_target}" ]]; then + cp -p "${host_extras_snippet_target}" "${host_extras_snippet_backup}" + had_host_extras_snippet="true" + fi install -m 0644 "${rendered_config}" "${config_target}" install -m 0644 "${rendered_snippet}" "${snippet_target}" install -m 0644 "${rendered_pingora_canary_snippet}" "${pingora_canary_snippet_target}" install -m 0644 "${rendered_pingora_realpath_canary_snippet}" "${pingora_realpath_canary_snippet_target}" + install -m 0644 "${rendered_host_extras_snippet}" "${host_extras_snippet_target}" disable_nginx_default_sites_enabled "${disabled_sites}" if ! nginx -t; then @@ -1188,14 +1200,19 @@ install_nginx_config_with_rollback() { else rm -f "${pingora_realpath_canary_snippet_target}" fi + if [[ "${had_host_extras_snippet}" == "true" ]]; then + cp -p "${host_extras_snippet_backup}" "${host_extras_snippet_target}" + else + rm -f "${host_extras_snippet_target}" + fi restore_nginx_default_sites_enabled "${disabled_sites}" - rm -f "${rendered_config}" "${rendered_snippet}" "${rendered_pingora_canary_snippet}" "${rendered_pingora_realpath_canary_snippet}" "${config_backup}" "${snippet_backup}" "${pingora_canary_snippet_backup}" "${pingora_realpath_canary_snippet_backup}" "${disabled_sites}" + rm -f "${rendered_config}" "${rendered_snippet}" "${rendered_pingora_canary_snippet}" "${rendered_pingora_realpath_canary_snippet}" "${rendered_host_extras_snippet}" "${config_backup}" "${snippet_backup}" "${pingora_canary_snippet_backup}" "${pingora_realpath_canary_snippet_backup}" "${host_extras_snippet_backup}" "${disabled_sites}" exit 1 fi echo "+ nginx -s reload" nginx -s reload - rm -f "${rendered_config}" "${rendered_snippet}" "${rendered_pingora_canary_snippet}" "${rendered_pingora_realpath_canary_snippet}" "${config_backup}" "${snippet_backup}" "${pingora_canary_snippet_backup}" "${pingora_realpath_canary_snippet_backup}" "${disabled_sites}" + rm -f "${rendered_config}" "${rendered_snippet}" "${rendered_pingora_canary_snippet}" "${rendered_pingora_realpath_canary_snippet}" "${rendered_host_extras_snippet}" "${config_backup}" "${snippet_backup}" "${pingora_canary_snippet_backup}" "${pingora_realpath_canary_snippet_backup}" "${host_extras_snippet_backup}" "${disabled_sites}" } cleanup_placeholder_nginx_config() { @@ -1417,6 +1434,7 @@ require_path deploy/nginx/genarrative-dev-http.conf require_path deploy/nginx/snippets/genarrative-maintenance.conf require_path deploy/nginx/snippets/genarrative-pingora-canary.conf require_path deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf +require_path deploy/nginx/snippets/genarrative-host-extras.conf require_path deploy/logrotate/genarrative-pingora-gateway require_path deploy/env/api-server.env.example require_path deploy/env/external-generation-worker.env.example From b60aea4e71ae16e8a94117fa04925b03e7a3fc26 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 1 Oct 2026 19:19:58 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=BD=91=E7=AB=99?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E8=AF=84=E5=88=86=E4=B8=8E=E8=AF=84=E4=BB=B7?= =?UTF-8?q?=20(#561)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 1–10 分评价,评论可空且最多 4000 个 Unicode 码点;游客可分页浏览评价并查看平均分与评分人数。 - 新增私有评价表及共享 DTO/API,事务内校验游戏公开可见性、保证唯一性,并计算分页与全量评分统计;下架或封禁后关闭读写,重新公开及更新版本保留评价。 - 详情页支持编辑预填、取消、提交期间冻结输入、失败保留草稿,以及保存、翻页和账号/游戏切换时的旧响应隔离;复用现有认证与公共组件。 - 同步主规范、里程碑、实施计划、生成绑定、表目录和验收脚本。不扩展 AGC、外部 API、评论审核或评分缓存。 验证: - 本轮审查复跑前端定向测试 40 项、评价领域测试 2 项,均通过。 - 网站 typecheck、Rust/TypeScript DTO 对齐、SpacetimeDB schema、文档索引、编码与 diff 检查通过。 - 实施文档另记录本地隔离数据库/API 49 项及桌面/移动视口浏览器 smoke 通过;本轮审查未重跑这些运行时验收,也未运行完整测试套件。 发布说明:新增 SpacetimeDB 表,部署时需同步 module 与 API。尚未部署 dev/production,待用户验收。 Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/561 Co-authored-by: Linghong Co-committed-by: Linghong --- apps/admin-web/src/api/adminApiClient.test.ts | 50 + apps/admin-web/src/api/adminApiClient.ts | 62 ++ apps/admin-web/src/api/adminApiTypes.ts | 84 ++ apps/admin-web/src/app/AdminApp.tsx | 12 +- apps/admin-web/src/app/AdminShell.tsx | 1 + apps/admin-web/src/app/adminRoutes.test.ts | 16 + apps/admin-web/src/app/adminRoutes.ts | 2 + .../pages/AdminGameManagementPage.test.tsx | 14 + .../src/pages/AdminGameManagementPage.tsx | 13 + .../src/pages/AdminGameReviewsPage.test.tsx | 441 +++++++++ .../src/pages/AdminGameReviewsPage.tsx | 673 +++++++++++++ apps/admin-web/src/styles/admin.css | 27 + .../scripts/check-config.mjs | 12 +- .../scripts/start-tauri-dev.mjs | 2 +- docs/README.md | 6 +- ...【实施计划】后台游戏评价管理-2026-10-01.md | 48 + ...【实施计划】游戏广场评分展示-2026-10-01.md | 66 ++ ...�实施计划】网站游戏评分与评价-2026-09-30.md | 43 + .../【里程碑】后台游戏评价管理-2026-10-01.md | 53 + ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 2 +- .../【里程碑】游戏广场评分展示-2026-10-01.md | 66 ++ ...【里程碑】网站游戏评分与评价-2026-09-30.md | 64 ++ .../shared-memory/decision-log.md | 27 + ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 26 + ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 290 +++++- .../src/components/GameRatingSummaryText.tsx | 17 + packages/shared/src/components/index.ts | 1 + .../shared/src/contracts/gameDistribution.ts | 42 + .../check-game-distribution-dto-parity.mjs | 42 +- .../check-game-distribution-ratings-e2e.mjs | 476 +++++++++ ...ame-distribution-review-moderation-e2e.mjs | 618 ++++++++++++ server-rs/Cargo.lock | 1 + server-rs/crates/api-server/src/admin.rs | 67 ++ .../src/modules/game_distribution.rs | 917 +++++++++++++++++- .../module-game-distribution/src/lib.rs | 8 + .../module-game-distribution/src/reviews.rs | 300 ++++++ .../crates/shared-contracts/src/admin.rs | 163 +++- .../shared-contracts/src/game_distribution.rs | 88 +- .../crates/spacetime-client/src/active.rs | 17 +- .../spacetime-client/src/active/mapper.rs | 13 +- .../src/active/mapper/game_distribution.rs | 166 +++- .../spacetime-client/src/game_distribution.rs | 305 ++++++ .../spacetime-client/src/module_bindings.rs | 102 ++ ...stribution_admin_user_review_input_type.rs | 15 + ...ution_admin_user_review_list_input_type.rs | 20 + ...tribution_admin_user_review_result_type.rs | 30 + ...ibution_admin_user_review_snapshot_type.rs | 19 + .../game_distribution_my_review_input_type.rs | 16 + ..._distribution_public_game_snapshot_type.rs | 2 + ...istribution_review_game_list_input_type.rs | 17 + ..._distribution_review_game_snapshot_type.rs | 17 + ...stribution_review_moderation_input_type.rs | 20 + ...istribution_review_moderation_log_table.rs | 245 +++++ ...distribution_review_moderation_log_type.rs | 77 ++ ...view_moderation_operation_snapshot_type.rs | 23 + .../game_distribution_review_table.rs | 231 +++++ .../game_distribution_review_type.rs | 74 ++ ...ame_distribution_user_review_input_type.rs | 18 + ...istribution_user_review_list_input_type.rs | 17 + ...me_distribution_user_review_result_type.rs | 25 + ..._distribution_user_review_snapshot_type.rs | 24 + ...bution_user_review_and_return_procedure.rs | 62 ++ ...ribution_my_review_and_return_procedure.rs | 59 ++ ...ution_user_reviews_and_return_procedure.rs | 62 ++ ...ution_review_games_and_return_procedure.rs | 62 ++ ...ution_user_reviews_and_return_procedure.rs | 62 ++ ...bution_user_review_and_return_procedure.rs | 62 ++ ...ribution_my_review_and_return_procedure.rs | 59 ++ server-rs/crates/spacetime-module/Cargo.toml | 1 + .../spacetime-module/src/game_distribution.rs | 756 ++++++++++++++- .../crates/spacetime-module/src/migration.rs | 2 + .../game-distribution/GameDetailPage.tsx | 40 +- .../GameDistributionPages.test.tsx | 21 + .../game-distribution/GameGalleryPage.tsx | 4 + .../game-distribution/GameReviews.test.tsx | 421 ++++++++ .../game-distribution/GameReviews.tsx | 263 +++++ .../game-distribution/gameDistribution.css | 8 + .../game-distribution/gameReviews.css | 124 +++ .../game-distribution/useGameReviews.ts | 180 ++++ src/services/gameDistributionClient.test.ts | 38 + src/services/gameDistributionClient.ts | 35 + 81 files changed, 8594 insertions(+), 60 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminGameReviewsPage.test.tsx create mode 100644 apps/admin-web/src/pages/AdminGameReviewsPage.tsx create mode 100644 docs/project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md create mode 100644 docs/project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md create mode 100644 docs/project-memory/plans/【实施计划】网站游戏评分与评价-2026-09-30.md create mode 100644 docs/project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md create mode 100644 docs/project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md create mode 100644 docs/project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md create mode 100644 packages/shared/src/components/GameRatingSummaryText.tsx create mode 100644 scripts/check-game-distribution-ratings-e2e.mjs create mode 100644 scripts/check-game-distribution-review-moderation-e2e.mjs create mode 100644 server-rs/crates/module-game-distribution/src/reviews.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_user_review_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_user_review_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_user_review_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_user_review_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_my_review_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_game_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_game_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_moderation_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_moderation_log_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_moderation_log_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_moderation_operation_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_user_review_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_user_review_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_user_review_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_user_review_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_admin_game_distribution_user_review_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_my_review_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_user_reviews_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_review_games_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_user_reviews_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/moderate_game_distribution_user_review_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/save_game_distribution_my_review_and_return_procedure.rs create mode 100644 src/components/game-distribution/GameReviews.test.tsx create mode 100644 src/components/game-distribution/GameReviews.tsx create mode 100644 src/components/game-distribution/gameReviews.css create mode 100644 src/components/game-distribution/useGameReviews.ts diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 7c5464f06..8c6d85581 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -5,12 +5,16 @@ import { executeAdminRechargeRefund, getAdminAgcTemplates, getAdminFeatureGateConfig, + getAdminGameReview, getAdminUserDetail, importAdminAgcTemplates, listAdminAgcTrackingEvents, listAdminGameDistributionGames, listAdminGameDistributionReviews, + listAdminGameReviewGames, + listAdminGameReviews, listAdminRechargeOrders, + moderateAdminGameReview, reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, restoreAdminGameDistributionGame, @@ -27,6 +31,52 @@ afterEach(() => { vi.unstubAllGlobals(); }); +test('用户评价查询编码筛选和目标,管理请求携带原创建时间及幂等key', async () => { + const fetchMock = vi + .fn() + .mockImplementation( + async () => + new Response(JSON.stringify({ ok: true, data: {} }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + await listAdminGameReviews('token', { + gameId: 'game+1', + userId: 'user/1', + keyword: '中文 广告', + status: 'hidden', + page: 2, + pageSize: 20, + }); + expect(fetchMock.mock.calls[0]![0]).toBe( + '/admin/api/game-distribution/user-reviews?gameId=game%2B1&userId=user%2F1&keyword=%E4%B8%AD%E6%96%87+%E5%B9%BF%E5%91%8A&status=hidden&page=2&pageSize=20', + ); + await listAdminGameReviewGames('token', { query: ' 中文 ', page: 1 }); + expect(fetchMock.mock.calls[1]![0]).toBe( + '/admin/api/game-distribution/user-review-games?query=%E4%B8%AD%E6%96%87&page=1', + ); + await getAdminGameReview('token', '6:game/1user'); + expect(fetchMock.mock.calls[2]![0]).toBe( + '/admin/api/game-distribution/user-reviews/6%3Agame%2F1user', + ); + const body = { + action: 'delete' as const, + expectedCreatedAt: '2026-10-01T00:00:00Z', + reason: '广告', + }; + await moderateAdminGameReview('token', '6:game/1user', 'same-attempt', body); + expect(fetchMock).toHaveBeenLastCalledWith( + '/admin/api/game-distribution/user-reviews/6%3Agame%2F1user/moderation', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer token', + 'Idempotency-Key': 'same-attempt', + }), + body: JSON.stringify(body), + }), + ); +}); + test('客户端埋点查询传递筛选和游标并复用后台认证', async () => { const payload = { entries: [], nextCursor: null }; const fetchMock = vi.fn().mockResolvedValue( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 8aae410af..ec574237d 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -38,6 +38,13 @@ import type { AdminGameDistributionReviewListResponse, AdminGameDistributionReviewRequest, AdminGameDistributionReviewResponse, + AdminGameReviewDetailResponse, + AdminGameReviewGamesQuery, + AdminGameReviewGamesResponse, + AdminGameReviewModerationRequest, + AdminGameReviewModerationResponse, + AdminGameReviewsQuery, + AdminGameReviewsResponse, AdminImportAgcTemplatesResponse, AdminLoginResponse, AdminMeResponse, @@ -1243,6 +1250,61 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) { ); } +function gameReviewQuery( + query: AdminGameReviewGamesQuery | AdminGameReviewsQuery, +) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && String(value).trim()) + params.set(key, String(value).trim()); + } + return params.toString(); +} + +export function listAdminGameReviewGames( + token: string, + query: AdminGameReviewGamesQuery = {}, +) { + return request( + `/admin/api/game-distribution/user-review-games?${gameReviewQuery(query)}`, + { token }, + ); +} + +export function listAdminGameReviews( + token: string, + query: AdminGameReviewsQuery = {}, +) { + return request( + `/admin/api/game-distribution/user-reviews?${gameReviewQuery(query)}`, + { token }, + ); +} + +export function getAdminGameReview(token: string, reviewId: string) { + return request( + `/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}`, + { token }, + ); +} + +export function moderateAdminGameReview( + token: string, + reviewId: string, + idempotencyKey: string, + payload: AdminGameReviewModerationRequest, +) { + return request( + `/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}/moderation`, + { + token, + method: 'POST', + headers: { 'Idempotency-Key': idempotencyKey }, + body: payload, + }, + ); +} + export function listAdminGameDistributionGames( token: string, options: { limit?: number } = {}, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 429ce7c5a..637ecc213 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1186,6 +1186,90 @@ export interface AdminGameDistributionRestoreResponse { replayed: boolean; } +/** 游戏用户评价管理,与发行版本审核分开。 */ +export interface AdminGameReviewGamesQuery { + query?: string; + page?: number; + pageSize?: number; +} + +export interface AdminGameReviewGame { + gameId: string; + title: string; + status: string; +} + +export interface AdminGameReviewGamesResponse { + games: AdminGameReviewGame[]; + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +export interface AdminGameReviewsQuery { + gameId?: string; + userId?: string; + keyword?: string; + status?: 'all' | 'visible' | 'hidden'; + page?: number; + pageSize?: number; +} + +export interface AdminGameReviewGameInfo { + title: string; + status: string; +} + +export interface AdminGameReview { + id: string; + gameId: string; + game: AdminGameReviewGameInfo; + author: { id: string; name: string; avatarUrl?: string | null }; + score: number; + comment: string; + isHidden: boolean; + createdAt: string; + updatedAt: string; +} + +export interface AdminGameReviewOperation { + id: string; + reviewId: string; + gameId: string; + userId: string; + reviewCreatedAt: string; + action: string; + adminUserId: string; + reason: string | null; + createdAt: string; +} + +export interface AdminGameReviewsResponse { + reviews: AdminGameReview[]; + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +export interface AdminGameReviewDetailResponse { + review: AdminGameReview; + operations: AdminGameReviewOperation[]; +} + +export interface AdminGameReviewModerationRequest { + action: 'hide' | 'restore' | 'delete'; + expectedCreatedAt: string; + reason?: string; +} + +export interface AdminGameReviewModerationResponse { + review: AdminGameReview | null; + operation: AdminGameReviewOperation; + replayed: boolean; +} + export interface AdminAgcTemplatePayload { id: string; title: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 8caad4598..374bc8e45 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -31,6 +31,7 @@ import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseRevie import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage'; import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage'; import { AdminGameManagementPage } from '../pages/AdminGameManagementPage'; +import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; @@ -141,7 +142,7 @@ export function AdminApp() { setRouteId(nextRouteId); const nextHash = routeHash(nextRouteId); - if (window.location.hash !== nextHash) { + if ((window.location.hash.split('?')[0] ?? '').toLowerCase() !== nextHash) { window.history.replaceState(null, '', nextHash); } }, [accessibleRoutes, admin, routeId, status]); @@ -325,6 +326,15 @@ export function AdminApp() { ) : null} {activeRouteId === 'game-management' ? ( route.id === 'game-reviews', + )} + onUnauthorized={handleUnauthorized} + /> + ) : null} + {activeRouteId === 'game-reviews' ? ( + diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 9db6e1c66..cc043db90 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -55,6 +55,7 @@ const routeIcons = { 'editor-showcase': Star, 'game-distribution': Gamepad2, 'game-management': Swords, + 'game-reviews': Star, 'editor-assets': Images, 'project-snapshots': FolderArchive, accounts: Users, diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts index c6dd8430a..1e5898280 100644 --- a/apps/admin-web/src/app/adminRoutes.test.ts +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -8,6 +8,22 @@ import { routeHash, } from './adminRoutes'; +test('游戏评价路由独立授权并支持游戏参数', () => { + expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews'); + expect( + getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['game-reviews'], + }).map((route) => route.id), + ).toEqual(['game-reviews']); + expect( + getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['game-management'], + }).some((route) => route.id === 'game-reviews'), + ).toBe(false); +}); + test('客户端埋点路由遵守成员页签权限', () => { expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking'); expect( diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index d6c948348..c72a231f8 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -18,6 +18,7 @@ export type AdminRouteId = | 'editor-showcase' | 'game-distribution' | 'game-management' + | 'game-reviews' | 'editor-assets' | 'project-snapshots' | 'agc-models' @@ -62,6 +63,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, { id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' }, { id: 'game-management', label: '游戏管理', hash: '#game-management' }, + { id: 'game-reviews', label: '游戏评价', hash: '#game-reviews' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, { id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx index 18407c5bb..a105c679a 100644 --- a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx @@ -117,6 +117,20 @@ beforeEach(() => { }); }); +test('有评价权限时游戏管理提供带gameId的评价入口', async () => { + window.history.replaceState(null, '', '#game-management'); + render( + , + ); + fireEvent.click(await screen.findByRole('button', { name: '查看评价' })); + expect(window.location.hash).toBe('#game-reviews?gameId=game_1'); + window.history.replaceState(null, '', '/'); +}); + afterEach(() => { cleanup(); vi.restoreAllMocks(); diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.tsx index 074a92927..75a8f8a29 100644 --- a/apps/admin-web/src/pages/AdminGameManagementPage.tsx +++ b/apps/admin-web/src/pages/AdminGameManagementPage.tsx @@ -28,6 +28,7 @@ import { handlePageError } from './pageUtils'; interface AdminGameManagementPageProps { token: string; + canManageReviews?: boolean; onUnauthorized: (message?: string) => void; } @@ -86,6 +87,7 @@ function createGameActionIdempotencyKey( export function AdminGameManagementPage({ token, + canManageReviews = false, onUnauthorized, }: AdminGameManagementPageProps) { const [games, setGames] = useState([]); @@ -327,6 +329,17 @@ export function AdminGameManagementPage({ > 版本历史 + {canManageReviews ? ( + { + window.location.hash = `#game-reviews?gameId=${encodeURIComponent(entry.gameId)}`; + }} + > + 查看评价 + + ) : null} diff --git a/apps/admin-web/src/pages/AdminGameReviewsPage.test.tsx b/apps/admin-web/src/pages/AdminGameReviewsPage.test.tsx new file mode 100644 index 000000000..286087a05 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameReviewsPage.test.tsx @@ -0,0 +1,441 @@ +/* @vitest-environment jsdom */ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import { + getAdminGameReview, + listAdminGameReviewGames, + listAdminGameReviews, + moderateAdminGameReview, +} from '../api/adminApiClient'; +import type { + AdminGameReview, + AdminGameReviewDetailResponse, + AdminGameReviewsResponse, +} from '../api/adminApiTypes'; +import { AdminGameReviewsPage } from './AdminGameReviewsPage'; + +vi.mock('../api/adminApiClient', () => ({ + listAdminGameReviews: vi.fn(), + listAdminGameReviewGames: vi.fn(), + getAdminGameReview: vi.fn(), + moderateAdminGameReview: vi.fn(), + isAdminApiError: (error: unknown) => + typeof error === 'object' && error !== null && 'status' in error, + formatAdminApiError: (error: unknown) => + error instanceof Error ? error.message : '请求失败', +})); + +const review: AdminGameReview = { + id: '6:game_1user_1', + gameId: 'game_1', + game: { title: '测试游戏', status: 'published' }, + author: { id: 'user_1', name: '用户甲' }, + score: 8, + comment: '中文😀\n完整正文', + isHidden: false, + createdAt: '2026-10-01T01:00:00Z', + updatedAt: '2026-10-01T01:00:00Z', +}; +function list( + overrides: Partial = {}, +): AdminGameReviewsResponse { + return { + reviews: [review], + page: 1, + pageSize: 20, + total: 1, + totalPages: 1, + ...overrides, + }; +} +const unauthorized = vi.fn(); +beforeEach(() => { + vi.resetAllMocks(); + window.history.replaceState(null, '', '#game-reviews'); + vi.mocked(listAdminGameReviews).mockResolvedValue(list()); + vi.mocked(listAdminGameReviewGames).mockResolvedValue({ + games: [{ gameId: 'game_2', title: '第二个游戏', status: 'suspended' }], + page: 1, + pageSize: 20, + total: 1, + totalPages: 1, + }); + vi.mocked(getAdminGameReview).mockResolvedValue({ review, operations: [] }); + vi.mocked(moderateAdminGameReview).mockResolvedValue({ + review: { ...review, isHidden: true }, + operation: { + id: 'op', + reviewId: review.id, + gameId: review.gameId, + userId: review.author.id, + reviewCreatedAt: review.createdAt, + action: 'hide', + adminUserId: 'admin', + reason: '广告', + createdAt: review.createdAt, + }, + replayed: false, + }); +}); +afterEach(() => { + cleanup(); + window.history.replaceState(null, '', '/'); +}); +function show() { + return render( + , + ); +} +async function row() { + return (await screen.findByText('用户甲')).closest('tr')!; +} + +test('入口游戏参数和hash变化生效,组合筛选分页在服务端查询,重置回第一页', async () => { + window.history.replaceState(null, '', '#game-reviews?gameId=game_1'); + vi.mocked(listAdminGameReviews).mockResolvedValue( + list({ total: 21, totalPages: 2 }), + ); + show(); + await row(); + expect(listAdminGameReviews).toHaveBeenLastCalledWith( + 'token', + expect.objectContaining({ gameId: 'game_1', page: 1 }), + ); + fireEvent.change(screen.getByLabelText('评价用户 ID'), { + target: { value: 'user_1' }, + }); + fireEvent.change(screen.getByLabelText('评论关键词'), { + target: { value: '中文' }, + }); + fireEvent.change(screen.getByLabelText('评价状态'), { + target: { value: 'hidden' }, + }); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + await waitFor(() => + expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', { + gameId: 'game_1', + userId: 'user_1', + keyword: '中文', + status: 'hidden', + page: 1, + pageSize: 20, + }), + ); + await row(); + fireEvent.click( + within(screen.getByRole('navigation', { name: '评价分页' })).getByRole( + 'button', + { name: '下一页' }, + ), + ); + await waitFor(() => + expect(listAdminGameReviews).toHaveBeenLastCalledWith( + 'token', + expect.objectContaining({ page: 2, keyword: '中文' }), + ), + ); + fireEvent.click(screen.getByRole('button', { name: '重置' })); + await waitFor(() => + expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', { + gameId: '', + userId: '', + keyword: '', + status: 'all', + page: 1, + pageSize: 20, + }), + ); + act(() => { + window.history.replaceState(null, '', '#game-reviews?gameId=game_2'); + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + await waitFor(() => + expect(listAdminGameReviews).toHaveBeenLastCalledWith( + 'token', + expect.objectContaining({ gameId: 'game_2', page: 1 }), + ), + ); + expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe( + 'game_2', + ); +}); + +test('名称查找包含下架游戏,选择后按游戏ID筛选', async () => { + show(); + await row(); + fireEvent.click(screen.getByRole('button', { name: '按名称查找游戏' })); + const dialog = screen.getByRole('dialog', { name: '查找游戏' }); + await within(dialog).findByText('第二个游戏'); + fireEvent.change(within(dialog).getByLabelText('游戏名称或 ID'), { + target: { value: '第二个' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: '搜索' })); + await waitFor(() => + expect(listAdminGameReviewGames).toHaveBeenLastCalledWith('token', { + query: '第二个', + page: 1, + pageSize: 20, + }), + ); + await within(dialog).findByText('第二个游戏'); + fireEvent.click(within(dialog).getByRole('button', { name: '选择' })); + expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe( + 'game_2', + ); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + await waitFor(() => + expect(listAdminGameReviews).toHaveBeenLastCalledWith( + 'token', + expect.objectContaining({ gameId: 'game_2' }), + ), + ); +}); + +test('隐藏原因必填,失败保留草稿,同次重试复用幂等key,取消不写入', async () => { + vi.mocked(moderateAdminGameReview).mockRejectedValueOnce( + new Error('网络失败'), + ); + show(); + fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' })); + let dialog = screen.getByRole('dialog', { name: '隐藏评价' }); + fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' })); + expect( + await within(dialog).findByText('原因必须为 1–4000 个字符'), + ).toBeTruthy(); + expect(moderateAdminGameReview).not.toHaveBeenCalled(); + fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), { + target: { value: ' 广告😀 ' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' })); + await within(dialog).findByText('网络失败'); + expect( + (within(dialog).getByLabelText('操作原因(必填)') as HTMLTextAreaElement) + .value, + ).toBe(' 广告😀 '); + const first = vi.mocked(moderateAdminGameReview).mock.calls[0]!; + expect(first[3]).toEqual({ + action: 'hide', + expectedCreatedAt: review.createdAt, + reason: '广告😀', + }); + fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' })); + await screen.findByText('评价已隐藏'); + expect(vi.mocked(moderateAdminGameReview).mock.calls[1]![2]).toBe(first[2]); + fireEvent.click(within(await row()).getByRole('button', { name: '删除' })); + dialog = screen.getByRole('dialog', { name: '删除评价' }); + expect( + within(dialog).getByText('删除后不可恢复,用户可以重新评价'), + ).toBeTruthy(); + fireEvent.click(within(dialog).getByRole('button', { name: '取消' })); + expect(moderateAdminGameReview).toHaveBeenCalledTimes(2); +}); + +test('4001码点原因不能提交,恢复复用确认且不要求原因', async () => { + vi.mocked(listAdminGameReviews).mockResolvedValue( + list({ reviews: [{ ...review, isHidden: true }] }), + ); + show(); + fireEvent.click(within(await row()).getByRole('button', { name: '删除' })); + const deletion = screen.getByRole('dialog', { name: '删除评价' }); + fireEvent.change(within(deletion).getByLabelText('操作原因(必填)'), { + target: { value: '😀'.repeat(4001) }, + }); + expect(within(deletion).getByText('4001/4000')).toBeTruthy(); + expect( + ( + within(deletion).getByRole('button', { + name: '确认删除', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + fireEvent.click(within(deletion).getByRole('button', { name: '取消' })); + fireEvent.click(within(await row()).getByRole('button', { name: '恢复' })); + const confirmation = screen.getByRole('dialog', { name: '确认操作' }); + fireEvent.click(within(confirmation).getByRole('button', { name: '确认' })); + await waitFor(() => + expect(moderateAdminGameReview).toHaveBeenCalledWith( + 'token', + review.id, + expect.any(String), + { action: 'restore', expectedCreatedAt: review.createdAt }, + ), + ); +}); + +test('删除使当前页越界时回到最后一页并保留条件', async () => { + vi.mocked(listAdminGameReviews).mockImplementation(async (_token, query) => + list({ page: query?.page ?? 1, total: 21, totalPages: 2 }), + ); + show(); + await row(); + fireEvent.click( + within(screen.getByRole('navigation', { name: '评价分页' })).getByRole( + 'button', + { name: '下一页' }, + ), + ); + await waitFor(() => + expect(listAdminGameReviews).toHaveBeenLastCalledWith( + 'token', + expect.objectContaining({ page: 2 }), + ), + ); + fireEvent.click(within(await row()).getByRole('button', { name: '删除' })); + const dialog = screen.getByRole('dialog', { name: '删除评价' }); + fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), { + target: { value: '重复内容' }, + }); + vi.mocked(listAdminGameReviews).mockResolvedValue( + list({ total: 20, totalPages: 1 }), + ); + fireEvent.click(within(dialog).getByRole('button', { name: '确认删除' })); + await waitFor(() => + expect(listAdminGameReviews).toHaveBeenLastCalledWith( + 'token', + expect.objectContaining({ page: 1 }), + ), + ); + expect(screen.getByText('评价已删除')).toBeTruthy(); +}); + +test('详情完整纯文本只读并展示后台操作原因', async () => { + vi.mocked(getAdminGameReview).mockResolvedValue({ + review, + operations: [ + { + id: 'op', + reviewId: review.id, + gameId: review.gameId, + userId: review.author.id, + reviewCreatedAt: review.createdAt, + action: 'hide', + adminUserId: 'admin_1', + reason: '广告内容', + createdAt: review.createdAt, + }, + ], + }); + show(); + fireEvent.click( + within(await row()).getByRole('button', { name: '查看详情' }), + ); + const dialog = screen.getByRole('dialog', { name: '评价详情' }); + expect(await within(dialog).findByText('广告内容')).toBeTruthy(); + expect(dialog.textContent).toContain(review.comment); + expect(dialog.querySelector('b')).toBeNull(); + expect(within(dialog).getByText('admin_1')).toBeTruthy(); + expect(within(dialog).queryByRole('textbox')).toBeNull(); +}); + +test('详情关闭后旧响应不会覆盖新目标', async () => { + const second = { + ...review, + id: 'review_2', + author: { id: 'user_2', name: '用户乙' }, + comment: '第二条正文', + }; + vi.mocked(listAdminGameReviews).mockResolvedValue( + list({ reviews: [review, second], total: 2 }), + ); + let resolveOld!: (value: { review: AdminGameReview; operations: [] }) => void; + vi.mocked(getAdminGameReview) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOld = resolve; + }), + ) + .mockResolvedValue({ review: second, operations: [] }); + show(); + fireEvent.click( + within(await row()).getByRole('button', { name: '查看详情' }), + ); + fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: '关闭' }), + ); + fireEvent.click( + within(screen.getByText('用户乙').closest('tr')!).getByRole('button', { + name: '查看详情', + }), + ); + await within(screen.getByRole('dialog')).findByText('第二条正文'); + await act(async () => { + resolveOld({ review, operations: [] }); + }); + expect( + within(screen.getByRole('dialog')).getByText('第二条正文'), + ).toBeTruthy(); + expect(screen.getByRole('dialog').textContent).not.toContain(review.comment); +}); + +test('管理提交中切换游戏,成功后刷新当前游戏而不恢复旧筛选', async () => { + let finish!: ( + value: Awaited>, + ) => void; + vi.mocked(moderateAdminGameReview).mockImplementationOnce( + () => + new Promise>>( + (resolve) => { + finish = resolve; + }, + ), + ); + window.history.replaceState(null, '', '#game-reviews?gameId=game_1'); + show(); + fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' })); + const dialog = screen.getByRole('dialog', { name: '隐藏评价' }); + fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), { + target: { value: '广告' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' })); + act(() => { + window.history.replaceState(null, '', '#game-reviews?gameId=game_2'); + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + await waitFor(() => + expect(listAdminGameReviews).toHaveBeenLastCalledWith( + 'token', + expect.objectContaining({ gameId: 'game_2' }), + ), + ); + fireEvent.click( + within(await row()).getByRole('button', { name: '查看详情' }), + ); + await within(screen.getByRole('dialog', { name: '评价详情' })).findByText( + '评价 ID', + ); + await act(async () => { + finish({ + review: null, + operation: { + id: 'op', + reviewId: review.id, + gameId: review.gameId, + userId: review.author.id, + reviewCreatedAt: review.createdAt, + action: 'hide', + adminUserId: 'admin', + reason: '广告', + createdAt: review.createdAt, + }, + replayed: false, + }); + }); + expect(listAdminGameReviews).toHaveBeenLastCalledWith( + 'token', + expect.objectContaining({ gameId: 'game_2' }), + ); + expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe( + 'game_2', + ); + expect(screen.getByRole('dialog', { name: '评价详情' })).toBeTruthy(); +}); diff --git a/apps/admin-web/src/pages/AdminGameReviewsPage.tsx b/apps/admin-web/src/pages/AdminGameReviewsPage.tsx new file mode 100644 index 000000000..a7ad0e543 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameReviewsPage.tsx @@ -0,0 +1,673 @@ +import { + AdminActionRow, + AdminAlert, + AdminButton, + AdminDialog, + AdminFieldGroup, + AdminInfoList, + AdminListPanel, + AdminPage, + AdminPageHeading, + AdminPagination, + AdminPanel, + AdminStatusPill, + formatAdminDateTime, +} from '@genarrative/shared/components'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + getAdminGameReview, + listAdminGameReviewGames, + listAdminGameReviews, + moderateAdminGameReview, +} from '../api/adminApiClient'; +import type { + AdminGameReview, + AdminGameReviewDetailResponse, + AdminGameReviewGamesResponse, + AdminGameReviewModerationRequest, + AdminGameReviewsQuery, + AdminGameReviewsResponse, +} from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; + +interface PageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +type Filters = Required< + Pick +>; +type Action = AdminGameReviewModerationRequest['action']; +const actionLabels = { hide: '隐藏', restore: '恢复', delete: '删除' }; +const emptyFilters: Filters = { + gameId: '', + userId: '', + keyword: '', + status: 'all', +}; + +function filtersFromHash(): Filters { + const params = new URLSearchParams(window.location.hash.split('?')[1] ?? ''); + return { ...emptyFilters, gameId: params.get('gameId')?.trim() ?? '' }; +} + +function commentSummary(comment: string) { + if (!comment) return '仅评分'; + const chars = Array.from(comment); + return chars.length > 120 ? `${chars.slice(0, 120).join('')}…` : comment; +} + +export function AdminGameReviewsPage({ token, onUnauthorized }: PageProps) { + const [draft, setDraft] = useState(filtersFromHash); + const [query, setQuery] = useState(() => ({ + ...filtersFromHash(), + page: 1, + pageSize: 20, + })); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [pickingGame, setPickingGame] = useState(false); + const [detailId, setDetailId] = useState(''); + const [pending, setPending] = useState<{ + review: AdminGameReview; + action: Action; + } | null>(null); + const [reason, setReason] = useState(''); + const [saveError, setSaveError] = useState(''); + const [saving, setSaving] = useState(false); + const listSerial = useRef(0); + const queryRef = useRef(query); + queryRef.current = query; + const attempt = useRef<{ fingerprint: string; key: string } | null>(null); + const writeConfirm = useAdminWriteConfirm(); + + const load = useCallback( + async (nextQuery: AdminGameReviewsQuery) => { + const serial = ++listSerial.current; + setLoading(true); + setError(''); + setResult(null); + try { + const response = await listAdminGameReviews(token, nextQuery); + if (serial !== listSerial.current) return; + const lastPage = Math.max(1, response.totalPages); + if ((nextQuery.page ?? 1) > lastPage) { + setQuery({ ...nextQuery, page: lastPage }); + return; + } + setResult(response); + } catch (failure) { + if (serial === listSerial.current) + handlePageError(failure, onUnauthorized, setError); + } finally { + if (serial === listSerial.current) setLoading(false); + } + }, + [token, onUnauthorized], + ); + + useEffect(() => { + const requestCounter = listSerial; + void load(query); + return () => { + requestCounter.current++; + }; + }, [load, query]); + + useEffect(() => { + const changed = () => { + const filters = filtersFromHash(); + setDraft(filters); + setQuery({ ...filters, page: 1, pageSize: 20 }); + setDetailId(''); + setPending(null); + setNotice(''); + }; + window.addEventListener('hashchange', changed); + return () => window.removeEventListener('hashchange', changed); + }, []); + + async function perform(target: { review: AdminGameReview; action: Action }) { + const submittedQuery = queryRef.current; + const normalizedReason = reason.replace(/\r\n?/g, '\n').trim(); + if ( + target.action !== 'restore' && + (!normalizedReason || Array.from(normalizedReason).length > 4000) + ) { + setSaveError('原因必须为 1–4000 个字符'); + return; + } + const body: AdminGameReviewModerationRequest = { + action: target.action, + expectedCreatedAt: target.review.createdAt, + ...(target.action === 'restore' ? {} : { reason: normalizedReason }), + }; + const fingerprint = JSON.stringify([target.review.id, body]); + if (attempt.current?.fingerprint !== fingerprint) { + attempt.current = { fingerprint, key: `review-${crypto.randomUUID()}` }; + } + setSaving(true); + setSaveError(''); + try { + await moderateAdminGameReview( + token, + target.review.id, + attempt.current.key, + body, + ); + if (queryRef.current === submittedQuery) { + setPending(null); + setDetailId(''); + setNotice(`评价已${actionLabels[target.action]}`); + } + await load(queryRef.current); + } catch (failure) { + handlePageError(failure, onUnauthorized, (message) => { + if (queryRef.current === submittedQuery) setSaveError(message); + }); + } finally { + setSaving(false); + } + } + + async function startAction(review: AdminGameReview, action: Action) { + if (action === 'restore') { + const confirmed = await writeConfirm.confirmWrite({ + action: '恢复评价', + target: `${review.game.title || review.gameId} · ${review.author.name || review.author.id}`, + }); + if (!confirmed) return; + } + const target = { review, action }; + setPending(target); + setReason(''); + setSaveError(''); + attempt.current = null; + if (action === 'restore') await perform(target); + } + + const page = query.page ?? 1; + const pages = result?.totalPages ?? 0; + return ( + + void load(query)} + > + 刷新 + + } + /> + {error ? ( + + {error} + + ) : null} + {notice ? {notice} : null} + +
{ + event.preventDefault(); + setNotice(''); + setQuery({ ...draft, page: 1, pageSize: 20 }); + }} + > + + + setDraft({ ...draft, gameId: event.target.value }) + } + /> + setPickingGame(true)} + > + 按名称查找游戏 + + + + + setDraft({ ...draft, userId: event.target.value }) + } + /> + + + + setDraft({ ...draft, keyword: event.target.value }) + } + /> + + + + + + 查询 + { + setDraft(emptyFilters); + setQuery({ ...emptyFilters, page: 1, pageSize: 20 }); + setNotice(''); + }} + > + 重置 + + +
+
+ ( + + + {review.game.title || '—'} +
+ {review.gameId} +
+ + + {review.author.name || '—'} +
+ {review.author.id} +
+ + {review.score}/10 + +

+ {commentSummary(review.comment)} +

+ + + + {review.isHidden ? '已隐藏' : '公开'} + + + {formatAdminDateTime(review.createdAt)} + {formatAdminDateTime(review.updatedAt)} + + + setDetailId(review.id)} + > + 查看详情 + + + void startAction( + review, + review.isHidden ? 'restore' : 'hide', + ) + } + > + {review.isHidden ? '恢复' : '隐藏'} + + void startAction(review, 'delete')} + > + 删除 + + + + + )} + /> + setQuery({ ...query, page: page - 1 }), + }} + next={{ + disabled: page >= pages, + onClick: () => setQuery({ ...query, page: page + 1 }), + }} + /> + {pickingGame ? ( + setPickingGame(false)} + onSelect={(gameId) => { + setDraft({ ...draft, gameId }); + setPickingGame(false); + }} + /> + ) : null} + {detailId ? ( + setDetailId('')} + /> + ) : null} + {pending ? ( + { + if (!saving) setPending(null); + }} + > +

+ {pending.review.game.title || pending.review.gameId} ·{' '} + {pending.review.author.name || pending.review.author.id} ·{' '} + {pending.review.score}/10 +

+ {pending.action === 'delete' ? ( + + 删除后不可恢复,用户可以重新评价 + + ) : null} + {pending.action !== 'restore' ? ( + +