From fbda61415670a5172b3b243b3463946e2a8153ea Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 26 May 2026 14:27:18 +0800 Subject: [PATCH] feat: surface platform errors in copyable dialogs --- .hermes/shared-memory/decision-log.md | 16 + .hermes/shared-memory/pitfalls.md | 24 + ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 6 +- ...项目基线】当å‰äº§å“与工程约æŸ-2026-05-15.md | 11 +- src/components/CustomWorldGenerationView.tsx | 16 +- .../CustomWorldCreationHub.tsx | 12 +- .../CustomWorldCreationStartCard.tsx | 9 +- .../PlatformEntryCreationTypeModal.test.tsx | 1 - .../PlatformEntryCreationTypeModal.tsx | 7 - .../PlatformEntryFlowShellImpl.tsx | 415 ++++++++++++++++-- .../PlatformErrorDialog.test.tsx | 60 +++ .../platform-entry/PlatformErrorDialog.tsx | 120 +++++ .../platform-entry/PlatformWorkDetailView.tsx | 7 +- .../puzzle-result/PuzzleResultView.tsx | 7 +- .../RpgEntryHomeView.recharge.test.tsx | 56 +-- src/components/rpg-entry/RpgEntryHomeView.tsx | 139 +++--- 16 files changed, 715 insertions(+), 191 deletions(-) create mode 100644 src/components/platform-entry/PlatformErrorDialog.test.tsx create mode 100644 src/components/platform-entry/PlatformErrorDialog.tsx diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index c4a20efe..070fd6ca 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-05-26 å¹³å°è·¨æµç¨‹é”™è¯¯ç»Ÿä¸€ç”¨å¯å¤åˆ¶æ¥æºå¼¹çª—展示 + +- 背景:拼图等生æˆé“¾è·¯å¯èƒ½åŒæ—¶å­˜åœ¨å¤šä¸ªè‰ç¨¿æˆ–游玩实例,页é¢å†…裸错误 banner å®¹æ˜“è®©ç”¨æˆ·è¯¯ä»¥ä¸ºå½“å‰æ­£åœ¨çœ‹çš„æ‹¼å›¾å¤±è´¥ï¼Œä¹Ÿä¸æ–¹ä¾¿å¤åˆ¶å®Œæ•´é”™è¯¯ç»™å¼€å‘排查。 +- 决策:平å°å…¥å£ã€ç”Ÿæˆé¡µã€ç»“果页ã€ä½œå“详情ã€ä½œå“æž¶å’Œè¿è¡Œæ€çš„è·¨æµç¨‹é”™è¯¯ç»Ÿä¸€æ”¶å£åˆ° `PlatformErrorDialog`ï¼›å¼¹çª—å¿…é¡»å¸¦é”™è¯¯æ¥æºï¼Œä¾‹å¦‚æŸä¸ªè‰ç¨¿ã€ç”Ÿæˆä¼šè¯ã€ä½œå“详情或游玩实例,并æä¾›å¤åˆ¶æŒ‰é’®å¤åˆ¶æ¥æºä¸Žé”™è¯¯å†…容。页é¢å†…旧的裸错误 bannerã€åˆ›ä½œå…¥å£ modal 错误ã€ç”Ÿæˆé¡µé”™è¯¯å¾½æ ‡ç­‰ä¸å†é‡å¤å±•ç¤ºï¼›è¡¨å•æ ¡éªŒå’Œå‘布确认弹窗里的局部业务错误ä»å¯ä¿ç•™åœ¨åŽŸå¼¹çª—å†…ã€‚ +- å½±å“范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/platform-entry/PlatformErrorDialog.tsx`ã€`src/components/CustomWorldGenerationView.tsx`ã€`src/components/custom-world-home/CustomWorldCreationHub.tsx`ã€`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`ã€`src/components/platform-entry/PlatformWorkDetailView.tsx`ã€`src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`ã€`src/components/puzzle-result/PuzzleResultView.tsx`。 +- éªŒè¯æ–¹å¼ï¼š`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx`ã€`npm run typecheck`ã€`npm run check:encoding` 通过;手测时异步失败应弹出包å«â€œé”™è¯¯æ¥æºâ€å’Œâ€œé”™è¯¯å†…容â€çš„弹窗,å¤åˆ¶æŒ‰é’®åº”å¤åˆ¶å®Œæ•´è¯Šæ–­æ–‡æœ¬ã€‚ +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## 2026-05-26 “我的â€é¡µä»»åŠ¡å¡è¯»åŽç«¯ä»»åŠ¡æ‘˜è¦å¹¶ç§»é™¤å¸¸é©»å¡«é‚€è¯·ç å…¥å£ + +- 背景:移动端“我的â€é¡µæ¯æ—¥ä»»åС塿›¾ç¡¬ç¼–ç  `0 / 1`,任务领å–完æˆåŽåªåˆ·æ–°å¼¹çª—内任务中心,å¡ç‰‡æœ¬èº«ä¸æ›´æ–°ï¼›é¡µé¢åº•部还ä¿ç•™æ—§çš„“填邀请ç â€æ¬¡çº§æŒ‰é’®ï¼Œå’Œå½“å‰äº”项常用功能宫格å£å¾„é‡å¤ã€‚ +- 决策:`RpgEntryHomeView` çš„æ¯æ—¥ä»»åŠ¡å¡ä»¥ `/api/profile/tasks` 返回的任务中心为事实æºï¼Œå±•示当å‰å¯æ“作任务的奖励ã€è¿›åº¦å’Œçжæ€ï¼›é¢†å–æˆåŠŸåŽåŒæ­¥ä½¿ç”¨ claim å“应里的 `center` 刷新å¡ç‰‡ã€‚移动端“我的â€é¡µä¸å†æ¸²æŸ“常驻“填邀请ç â€æ¬¡çº§å…¥å£ï¼Œé‚€è¯·ç å¡«å†™ä»…ä¿ç•™é‚€è¯·é“¾æŽ¥ query 自动打开弹窗和其它明确引导。 +- å½±å“范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`ã€`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`ã€`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`。 +- éªŒè¯æ–¹å¼ï¼š`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` åº”æ–­è¨€ä»»åŠ¡å¡æ˜¾ç¤º `1 / 1`ã€é¢†å–åŽæ˜¾ç¤ºå·²å®Œæˆï¼Œä¸”新用户账å·ä¹Ÿæ²¡æœ‰ `次级入å£` / `填邀请ç ` 常驻按钮;`npm run typecheck`ã€`npm run check:encoding` 通过。 +- å…³è”æ–‡æ¡£ï¼š`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`。 + ## 2026-05-25 å¹³å°é¦–é¡µæŽ¨èæŒ‰æ¡Œé¢ä¸Žç§»åŠ¨æ–­ç‚¹åˆ†æµ - 背景:平å°é¦–页的推è页在桌é¢ä¸Žç§»åŠ¨ç«¯ä¹‹é—´åŽŸå…ˆå…±ç”¨åŒä¸€å¥—推èè¿è¡Œæ€é€»è¾‘,容易让桌é¢å’Œç§»åŠ¨ä¸¤å¥—å†…å®¹åŒæ—¶å¯åŠ¨ï¼Œä¹Ÿè®©é¦–é¡µçš„æŽ¨èå¡ä¸Žæ¡Œé¢å‘现壳互相抢状æ€ã€‚ diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index c9f1c0d1..f086ebc2 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -15,6 +15,30 @@ - å…³è”ï¼šç›¸å…³æ–‡ä»¶ã€æ–‡æ¡£ã€æäº¤æˆ– Issue ``` +## å¹³å°å¼‚æ­¥é”™è¯¯å¿…é¡»å¸¦æ¥æºå¼¹çª—,ä¸è¦åªæ˜¾ç¤ºè£¸é”™è¯¯ + +- 现象:用户先åŽè§¦å‘多个拼图或è‰ç¨¿ç”Ÿæˆæ—¶ï¼Œæ—§è¯·æ±‚失败åŽä¼šåœ¨å½“å‰é¡µé¢æ˜¾ç¤ºâ€œå›¾ç‰‡ç”Ÿæˆå¤±è´¥â€ç­‰è£¸é”™è¯¯ï¼Œå®¹æ˜“è¯¯åˆ¤ä¸ºå½“å‰æ­£åœ¨çœ‹çš„æ‹¼å›¾å¤±è´¥ï¼›é”™è¯¯æ–‡æœ¬ä¹Ÿä¸ä¾¿å¤åˆ¶ç»™å¼€å‘排查。 +- 原因:ä¸åŒå…¥å£ã€ç”Ÿæˆé¡µã€ç»“果页ã€ä½œå“详情和è¿è¡Œæ€å„自渲染局部错误,没有统一æºå¸¦è‰ç¨¿ã€ç”Ÿæˆä¼šè¯ã€ä½œå“æˆ–æ¸¸çŽ©æ¥æºã€‚ +- 处ç†ï¼šè·¨æµç¨‹é”™è¯¯ç»Ÿä¸€ç”± `PlatformEntryFlowShellImpl` 汇总为 `PlatformErrorDialog`ï¼Œæ¥æºä½¿ç”¨çŽ©æ³•ã€è‰ç¨¿ / session / work / run 标识组æˆï¼›å¼¹çª—æä¾›å¤åˆ¶æŒ‰é’®ã€‚å…³é—­å¼¹çª—æ—¶åªæ¸…ç†å¯å®‰å…¨æ¸…ç†çš„错误状æ€ï¼›æ¢å¤ç±»é”™è¯¯ç”¨ dismiss key 防止åå¤å¼¹å‡ºä½†ä¸æ“…自改底层状æ€ã€‚ +- 验è¯ï¼šè§¦å‘任一平å°çº§å¼‚步失败时,页é¢åº”出现包å«â€œé”™è¯¯æ¥æºâ€å’Œâ€œé”™è¯¯å†…容â€çš„弹窗;å¤åˆ¶å†…å®¹åº”åŒ…å«æ¥æºå’Œé”™è¯¯æ­£æ–‡ï¼›æ—§é¡µé¢å†…错误 banner ä¸å†é‡å¤å‡ºçŽ°ã€‚ +- å…³è”:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/platform-entry/PlatformErrorDialog.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## “我的â€é¡µæ¯æ—¥ä»»åŠ¡å¡ä¸è¦ç¡¬ç¼–ç è¿›åº¦ + +- çŽ°è±¡ï¼šç”¨æˆ·å®Œæˆæˆ–领喿¯æ—¥ä»»åŠ¡åŽï¼Œä»»åŠ¡ä¸­å¿ƒå¼¹çª—é‡Œçš„ä»»åŠ¡çŠ¶æ€å·²ç»å˜åŒ–,但“我的â€é¡µå¡ç‰‡ä»æ˜¾ç¤º `0 / 1` 和“去完æˆâ€ã€‚ +- 原因:å¡ç‰‡é¦–版åªå†™äº†é™æ€å±•ç¤ºæ–‡æ¡ˆï¼Œæ²¡æœ‰è¯»å– `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`ï¼Œé¢†å–æŽ¥å£è¿”回的新 `center` 也åªç”¨äºŽå¼¹çª—。 +- 处ç†ï¼šè¿›å…¥â€œæˆ‘çš„â€é¡µæ—¶è¯»å–任务中心,å¡ç‰‡ç”¨å½“å‰å¯æ“作任务或已领å–任务派生奖励ã€è¿›åº¦æ¡å’Œæ“作状æ€ï¼›`claimRpgProfileTaskReward(...)` æˆåŠŸåŽç”¨å“应里的 `center` 覆盖本地任务中心。 +- 验è¯ï¼š`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖å¡ç‰‡ä»ŽåŽç«¯ä»»åŠ¡æ‘˜è¦æ˜¾ç¤º `1 / 1`,领å–åŽæ˜¾ç¤ºå·²å®Œæˆã€‚ +- å…³è”:`src/components/rpg-entry/RpgEntryHomeView.tsx`ã€`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`ã€`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`。 + +## “我的â€é¡µä¸è¦æ¢å¤æ—§çš„å¡«é‚€è¯·ç æ¬¡çº§æŒ‰é’® + +- 现象:移动端“我的â€é¡µåœ¨äº”项常用功能和设置入å£ä¸‹æ–¹åˆå‡ºçŽ°ä¸€ä¸ªâ€œå¡«é‚€è¯·ç â€æŒ‰é’®ï¼Œçœ‹èµ·æ¥åƒæ—§å…¥å£æ®‹ç•™ã€‚ +- åŽŸå› ï¼šé‚€è¯·ç æµç¨‹è¿ç§»åŽä»æŒ‰æ–°ç”¨æˆ·çª—å£ä¿ç•™ `canShowReferralRedeemShortcut` 次级入å£ï¼›ä½†å½“å‰é¡µé¢å£å¾„å·²ç»å›ºå®šä¸ºäº”项常用功能宫格,邀请ç å¡«å†™åº”由邀请链接 query 或明确引导打开弹窗。 +- 处ç†ï¼šç§»é™¤å¸¸é©» `次级入å£` / `填邀请ç ` 渲染,ä¸åˆ é™¤ `ProfileReferralModal` çš„ `redeem` 颿¿ï¼Œä¹Ÿä¸ç ´å `?inviteCode=` / `?invite_code=` 自动打开填写弹窗。 +- 验è¯ï¼šæ–°ç”¨æˆ·è´¦å·æ‰“开“我的â€é¡µæ—¶æ²¡æœ‰ `次级入å£` å’Œ `填邀请ç ` 按钮;带 `?inviteCode=spring-2026` 的登录用户ä»è‡ªåŠ¨æ‰“å¼€é‚€è¯·ç å¼¹çª—并预填 `SPRING2026`。 +- å…³è”:`src/components/rpg-entry/RpgEntryHomeView.tsx`ã€`.hermes/skills/genarrative-profile-invite-flow/SKILL.md`。 + ## 创作å¡ç‰‡ç‚¹å‡»è¦ç›´è¾¾å·²æœ‰å…¥å£è¡¨å•,别å†ä¿ç•™ç©ºç™½å…¥å£é¡µ - 现象:创作 Tab 模æ¿å¡ç‚¹å‡»åŽå¦‚æžœä»ç„¶åœç•™åœ¨åˆ›ä½œå¤§åŽ…ï¼Œæˆ–è€…å…ˆè¿›å…¥â€œX 创作入å£â€è¿™ç§ç©ºç™½é¡µï¼Œå°±ä¼šè®©ç”¨æˆ·å¤šèµ°ä¸€å±‚,还å¯èƒ½è¢«é”™è¯¯çš„ stage 白å啿‹‰å›žå¹³å°ã€‚ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index 0e593209..600e0327 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -12,6 +12,8 @@ åˆ›ä½œè¡¨å•æäº¤å‰çš„æ³¥ç‚¹ä½™é¢å‰ç½®æ ¡éªŒåªå…许用独立弹窗æç¤ºå¤±è´¥åŽŸå› ï¼Œä¸å¾—æŠŠç”¨æˆ·é€€å›žåˆ›ä½œå…¥å£æˆ–玩法模æ¿åˆ—表,也ä¸å¾—清空当å‰è¡¨å•状æ€ã€‚当å‰é€‚ç”¨æ‹¼å›¾ã€æŠ“å¤§é¹…å’Œæ±ªæ±ªå£°æµªç­‰ä¼šåœ¨å‰ç«¯æäº¤å‰æ ¡éªŒæ³¥ç‚¹çš„生æˆå…¥å£ï¼›ä½™é¢ä¸è¶³ã€ä½™é¢è¯»å–失败都应åœç•™åœ¨å½“å‰å·¥ä½œå°ï¼Œç”±ç”¨æˆ·å…³é—­æç¤ºåŽç»§ç»­ç¼–辑或自行补足泥点。 +å¹³å°å…¥å£ã€ç”Ÿæˆé¡µã€ç»“果页ã€ä½œå“详情ã€ä½œå“æž¶å’Œè¿è¡Œæ€çš„è·¨æµç¨‹é”™è¯¯ç»Ÿä¸€æ”¶å£åˆ° `PlatformErrorDialog`ã€‚å¼¹çª—å¿…é¡»å¸¦æ˜Žç¡®é”™è¯¯æ¥æºï¼Œä¾‹å¦‚æŸä¸ªè‰ç¨¿ã€æŸæ¬¡ç”Ÿæˆã€ä½œå“详情或æŸä¸ªæ¸¸çŽ©å®žä¾‹ï¼Œå¹¶æä¾›å¤åˆ¶æŒ‰é’®å¤åˆ¶â€œé”™è¯¯æ¥æº + 错误内容â€ã€‚页é¢å†…ä¸å†é‡å¤æ¸²æŸ“裸错误 bannerï¼›è¡¨å•æ ¡éªŒã€å‘布确认弹窗里的局部业务错误å¯ä»¥ä¿ç•™åœ¨åŽŸå¼¹çª—å†…ã€‚ + `PlatformEntryFlowShellImpl.tsx` 仿˜¯å¹³å°å…¥å£ç¼–排壳,åŽç»­ç»´æŠ¤æ—¶åº”优先把独立 UI 片段ã€å…¬å¼€ä½œå“映射ã€è‰ç¨¿ç”Ÿæˆ notice å’Œè¿è¡Œæ€çŠ¶æ€ helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或åŒç›®å½•ç´§é‚» helper 文件。拆分åªå…è®¸æ”¹å˜æ–‡ä»¶ç»„ç»‡ï¼Œä¸æ”¹å˜å…¥å£é…置事实æºã€é»˜è®¤å¯¼å‡ºã€propsã€é¡µé¢é˜¶æ®µã€UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 `platformEntryCreationTypes.ts` åªåšå‰ç«¯å±•示派生,分组时必须把åŽç«¯ `creationTypes` 里的 `categoryId` / `categoryLabel` 当作å¯ç¼ºå¤±å­—段处ç†ï¼Œç©ºå€¼ç»Ÿä¸€å›žé€€åˆ° `recent` / `最近创作`,é¿å…æ—§æ•°æ®ã€å±€éƒ¨ mock 或异常返回把创作入å£åˆå§‹åŒ–直接打崩。 @@ -44,7 +46,7 @@ 6. 点击 `generationStatus=generating` çš„è‰ç¨¿å¡å¿…é¡»æ¢å¤å¯¹åº”玩法的生æˆè¿›åº¦é¡µï¼Œä¸èƒ½è¿›å…¥ç©ºç™½ç»“果页或普通工作区;æ¢å¤ç”Ÿæˆé¡µçš„ `startedAtMs` 使用进入生æˆé¡µçš„当剿—¶é—´ï¼Œä½œå“æ‘˜è¦ `updatedAt` åªç”¨äºŽæŽ’åºå’Œæ‘˜è¦å±•示,ä¸å‚与å‡è¿›åº¦èµ·ç®—。 7. ç§æœ‰ generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` æ¢ç­¾è¯»å–。 -å‘现 Tabã€åˆ›ä½œ Tab 与è‰ç¨¿ Tab çš„é¡µé¢æ ¹å†…容区ä¸å†å¥— `platform-page-stage` 外层全局å¡ç‰‡å£³ï¼Œè®©åˆ—表ã€ç­›é€‰å’ŒçŽ©æ³•å¡èŽ·å¾—æ›´å®½çš„æ¨ªå‘空间;推èé¡µå’Œæˆ‘çš„é¡µä»æŒ‰å„自页é¢è®¾è®¡ä¿ç•™åŽŸæœ‰å…¨å±€å¡ç‰‡å£å¾„。移动端“我的â€é¡µä»æŒ‰é¡¶éƒ¨å¤´åƒ / 昵称 / é™¶æ³¥å·ã€ä¼šå‘˜æ¨ªå¹…ã€ä¸‰å¼ ç»Ÿè®¡å¡ã€æ¯æ—¥ä»»åŠ¡ã€äº”项常用功能宫格ã€è®¾ç½®å…¥å£ã€æ¬¡çº§å…¥å£å¸¦å’Œæ³•律信æ¯ç»„织,但字å·å¿…须维æŒå¹³å°æ™®é€š UI æ¡£ä½ï¼Œä¸èƒ½å› ä¸ºçª„å±æŠŠå¡ç‰‡æ ‡é¢˜ã€åŠŸèƒ½ label æˆ–æ³•å¾‹ä¿¡æ¯æ’‘æˆå±•示级字å·ï¼›æœ€åŽä¸€å±å†…容必须能在底部 dock 上方完整滚动露出,ä¸å¾—è¢«å›ºå®šåº•éƒ¨å¯¼èˆªé®æŒ¡ã€‚ +å‘现 Tabã€åˆ›ä½œ Tab 与è‰ç¨¿ Tab çš„é¡µé¢æ ¹å†…容区ä¸å†å¥— `platform-page-stage` 外层全局å¡ç‰‡å£³ï¼Œè®©åˆ—表ã€ç­›é€‰å’ŒçŽ©æ³•å¡èŽ·å¾—æ›´å®½çš„æ¨ªå‘空间;推èé¡µå’Œæˆ‘çš„é¡µä»æŒ‰å„自页é¢è®¾è®¡ä¿ç•™åŽŸæœ‰å…¨å±€å¡ç‰‡å£å¾„。移动端“我的â€é¡µä»æŒ‰é¡¶éƒ¨å¤´åƒ / 昵称 / é™¶æ³¥å·ã€ä¼šå‘˜æ¨ªå¹…ã€ä¸‰å¼ ç»Ÿè®¡å¡ã€æ¯æ—¥ä»»åŠ¡ã€äº”项常用功能宫格ã€è®¾ç½®å…¥å£å’Œæ³•律信æ¯ç»„织,ä¸ä¿ç•™æ—§çš„底部“填邀请ç â€æ¬¡çº§å…¥å£ï¼›æ¯æ—¥ä»»åŠ¡å¡å¿…é¡»è¯»å– `/api/profile/tasks` 的当å‰ä»»åŠ¡æ‘˜è¦å¹¶åœ¨é¢†å–åŽåŒæ­¥åˆ·æ–°å¡ç‰‡è¿›åº¦ã€‚å­—å·å¿…须维æŒå¹³å°æ™®é€š UI æ¡£ä½ï¼Œä¸èƒ½å› ä¸ºçª„å±æŠŠå¡ç‰‡æ ‡é¢˜ã€åŠŸèƒ½ label æˆ–æ³•å¾‹ä¿¡æ¯æ’‘æˆå±•示级字å·ï¼›æœ€åŽä¸€å±å†…容必须能在底部 dock 上方完整滚动露出,ä¸å¾—è¢«å›ºå®šåº•éƒ¨å¯¼èˆªé®æŒ¡ã€‚ ## RPG / 自定义世界 @@ -72,7 +74,7 @@ RPG ä»Žä½œå“æž¶ã€å¹¿åœºè¯¦æƒ…或作å“å·æœç´¢ç‚¹å‡»â€œå¯åЍâ€å‰ï¼Œå…¥å£ RPG è¿è¡Œæ€çš„æˆ˜æ–—终局ã€ç»§ç»­å†’险ã€ç»§ç»­æŽ¢ç´¢å’Œåˆ‡åœºæ™¯éƒ½å±žäºŽæœåŠ¡ç«¯ runtime 快照真相:`module-runtime-story` 必须在终局战斗 action åŽè°ƒç”¨ post-battle finalization,æŒä¹…写入 `story_continue_adventure`ã€`deferredOptions`ã€`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清ç†åŽçš„æˆ˜æ–—状æ€ï¼›`idle_travel_next_scene` / `camp_travel_home_scene` 必须由åŽç«¯å†™å…¥æ–°çš„ `currentScenePreset`ã€`currentSceneActState`ã€`currentEncounter` å’Œ `runtimeStats.scenesTraveled`。å‰ç«¯åªæ’­æ”¾é€€åœºã€è¿›åœºå’Œç»§ç»­æŒ‰é’®è¡¨çŽ°ï¼Œä¸èƒ½ç”¨é»˜è®¤ `观察/试探/è°ƒæ¯` fallback 或本地动画å‡è£…推进剧情。旧 bootstrap å¿«ç…§å¯èƒ½åªæœ‰ `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,åŽç«¯ç”Ÿæˆæˆ˜åŽæ—…行选项时必须兼容这些字段。 -RPG / 拼图等è¿è¡Œæ€å­˜æ¡£ä»ä»¥ `/api/profile/save-archives` çš„åŽç«¯åˆ—表为真相,æ¢å¤åŠ¨ä½œç»§ç»­èµ°å¯¹åº”æ¢å¤æŽ¥å£ï¼Œä½†ç§»åŠ¨ç«¯â€œæˆ‘çš„â€é¡µå·²ç»ä¸å†æä¾›ç‹¬ç«‹çš„ `æ¬¡çº§å…¥å£ > 存档` 和设置入å£å­˜æ¡£æŒ‰é’®ï¼›â€œçŽ©è¿‡â€å¼¹çª—å¯ä»¥ç»§ç»­åˆå¹¶å±•示å¯ç»§ç»­å­˜æ¡£ï¼Œä¸ªäººä¸­å¿ƒåªä¿ç•™è®¾ç½®ã€æ‰«ç ã€å¸¸ç”¨åŠŸèƒ½å’Œæ¡ä»¶æ€§æ¬¡çº§å…¥å£ã€‚移动端“我的â€é¡µçš„äº”é¡¹å¸¸ç”¨åŠŸèƒ½å®«æ ¼åªæ”¾æ³¥ç‚¹å……值ã€é‚€è¯·å¥½å‹ã€å…‘æ¢ç ã€çŽ©å®¶ç¤¾åŒºã€å馈与建议,é¿å…把存档挤入主宫格破åå‚考图布局。å‰ç«¯åªå±•示 `/api/profile/save-archives` 返回的列表并在用户选择åŽè°ƒç”¨å¯¹åº”æ¢å¤æŽ¥å£ï¼Œä¸èƒ½æœ¬åœ°æ‹¼è£…或筛选正å¼å­˜æ¡£çœŸç›¸ã€‚ +RPG / 拼图等è¿è¡Œæ€å­˜æ¡£ä»ä»¥ `/api/profile/save-archives` çš„åŽç«¯åˆ—表为真相,æ¢å¤åŠ¨ä½œç»§ç»­èµ°å¯¹åº”æ¢å¤æŽ¥å£ï¼Œä½†ç§»åŠ¨ç«¯â€œæˆ‘çš„â€é¡µå·²ç»ä¸å†æä¾›ç‹¬ç«‹çš„ `æ¬¡çº§å…¥å£ > 存档` 和设置入å£å­˜æ¡£æŒ‰é’®ï¼›â€œçŽ©è¿‡â€å¼¹çª—å¯ä»¥ç»§ç»­åˆå¹¶å±•示å¯ç»§ç»­å­˜æ¡£ï¼Œä¸ªäººä¸­å¿ƒåªä¿ç•™è®¾ç½®ã€æ‰«ç å’Œäº”项常用功能。移动端“我的â€é¡µçš„äº”é¡¹å¸¸ç”¨åŠŸèƒ½å®«æ ¼åªæ”¾æ³¥ç‚¹å……值ã€é‚€è¯·å¥½å‹ã€å…‘æ¢ç ã€çŽ©å®¶ç¤¾åŒºã€å馈与建议,é¿å…æŠŠå­˜æ¡£æˆ–å¡«é‚€è¯·ç æŒ¤å…¥ä¸»å®«æ ¼ç ´åå‚考图布局。å‰ç«¯åªå±•示 `/api/profile/save-archives` 返回的列表并在用户选择åŽè°ƒç”¨å¯¹åº”æ¢å¤æŽ¥å£ï¼Œä¸èƒ½æœ¬åœ°æ‹¼è£…或筛选正å¼å­˜æ¡£çœŸç›¸ã€‚ ## 拼图 diff --git a/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md b/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md index 0007216b..2544e47d 100644 --- a/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md +++ b/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md @@ -93,11 +93,12 @@ server-rs + Axum + SpacetimeDB 7. 主站入å£å·²é”定移动端页é¢çº§ç¼©æ”¾ï¼›å•个游æˆé¡µé¢ä¸è¦å†é‡å¤å®žçŽ°æ•´é¡µç¼©æ”¾é”定。 8. 图åƒè¾“入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`ã€‚å¤–å±‚é¡µé¢æŒæœ‰ä¸šåŠ¡çŠ¶æ€ï¼Œç»„ä»¶åªæ‰¿æ‹…上传å¡ã€é¢„览ã€å‚考图缩略图ã€AI é‡ç»˜å¼€å…³ã€é”™è¯¯å±•示和æäº¤æŒ‰é’®ã€‚ 9. å‘现页 `分类` å­é¢‘é“的筛选必须打开独立 dialog / drawer / modal,至少支æŒçŽ©æ³•ç±»åž‹è¿‡æ»¤ä¸ŽæŽ’åºåˆ‡æ¢ï¼›ç­›é€‰ç»“果为空时显示空状æ€ï¼Œä¸æŠŠç­›é€‰å†…容展开在当å‰åˆ—表下方。 -10. 移动端“我的â€é¡µé¡¶éƒ¨å“牌行承载扫ç å’Œè®¾ç½®å…¥å£ï¼Œæ­£æ–‡æŒ‰å‚考图顺åºç»„ç»‡ä¸ºå¤´åƒ / 昵称 / é™¶æ³¥å·ã€ä¼šå‘˜æ¨ªå¹…ã€ä¸‰å¼ ç»Ÿè®¡å¡ã€æ¯æ—¥ä»»åŠ¡ã€äº”项常用功能宫格ã€è®¾ç½®å…¥å£ã€å¯é€‰æ¬¡çº§å…¥å£å’Œæ³•律信æ¯ï¼›`media/profile/` 中的陶泥素æä½œä¸ºè¯¥é¡µå›¾å½¢èµ„产。常用功能宫格固定承载泥点充值ã€é‚€è¯·å¥½å‹ã€å…‘æ¢ç ã€çŽ©å®¶ç¤¾åŒºã€å馈与建议;页é¢ä¸å†æä¾›ç‹¬ç«‹å­˜æ¡£æŒ‰é’®å…¥å£ï¼Œå¡«é‚€è¯·ç ä»…在新用户å¯å¡«å†™çª—å£å†…展示为次级入å£ã€‚ -11. “我的â€é¡µæ³¥ç‚¹ã€æ¸¸æˆæ—¶é•¿ã€å·²çŽ©æ¸¸æˆæ•°é‡ä¸‰å¼ ç»Ÿè®¡å¡åªå±•示å„自标签和值,三个统计 icon 使用å°å°ºå¯¸æ™®é€š UI æ¡£ä½ï¼Œå†…容䏿¢è¡Œï¼Œä¸åœ¨ç»Ÿè®¡åŒºåº•éƒ¨å±•ç¤ºâ€œæ›´æ–°äºŽâ€æ—¶é—´ï¼›ç§»åŠ¨ç«¯æ˜µç§°ã€ä¼šå‘˜å¡ã€æ¯æ—¥ä»»åŠ¡ã€å¸¸ç”¨åŠŸèƒ½å’Œæ³•å¾‹ä¿¡æ¯ä¹Ÿåº”ä¿æŒ `10px` 到 `14px` 的普通 UI å­—å·åŒºé—´ï¼Œé¿å…å±•ç¤ºçº§å­—å·æŒ¤åŽ‹å†…å®¹ã€‚ -12. 移动端“我的â€é¡µéœ€è¦å…¼å®¹çª„å±ï¼šå¤´åƒ / 昵称 / é™¶æ³¥å·ã€ä¸‰å¼ ç»Ÿè®¡å¡ã€æ¯æ—¥ä»»åŠ¡ã€äº”项常用功能ã€å¯é€‰æ¬¡çº§å…¥å£å’Œæ³•律信æ¯éƒ½å¿…须能在底部固定 TabBar 上方完整滚动露出,ä¸å¾—与底部 dockã€åˆ˜æµ· safe-area 或相邻 UI å…ƒç´ é®æŒ¡é‡å ã€‚ -13. RPG ç­‰è¿è¡Œæ€çš„æˆ˜æ–—飘字ã€è¡€é‡å˜åŒ–å’Œå³æ—¶å馈必须在暗色ã€å™ªå£°é«˜çš„åœºæ™¯èƒŒæ™¯ä¸Šä¿æŒå¯è¯»ï¼šä½¿ç”¨é«˜äº®æ–‡å­—ã€æ·±è‰²æè¾¹ã€å¼ºé˜´å½±æˆ–å°é¢ç§¯åŠé€æ˜Žåº•,ä¸åªä¾èµ–红/绿文字本身表达伤害或治疗。 -14. å¹³å°äº®è‰² UI é…色以陶泥儿主视觉为准:暖白 / ç±³æåº•ã€é™¶åœŸæ©™ä¸»æŒ‰é’®ã€æ·±æ£•正文与浅æè¾¹æ¡†ï¼›æ–°å¢žç•Œé¢ä¼˜å…ˆå¤ç”¨ `src/index.css` çš„ `--platform-*` 主题å˜é‡å’Œ `apps/admin-web/src/styles/admin.css` çš„åŒç³»è‰²å€¼ï¼Œä¸å†å¼•入粉红ã€è“绿等独立主色方案。 +10. 移动端“我的â€é¡µé¡¶éƒ¨å“牌行承载扫ç å’Œè®¾ç½®å…¥å£ï¼Œæ­£æ–‡æŒ‰å‚考图顺åºç»„ç»‡ä¸ºå¤´åƒ / 昵称 / é™¶æ³¥å·ã€ä¼šå‘˜æ¨ªå¹…ã€ä¸‰å¼ ç»Ÿè®¡å¡ã€æ¯æ—¥ä»»åŠ¡ã€äº”项常用功能宫格ã€è®¾ç½®å…¥å£å’Œæ³•律信æ¯ï¼›`media/profile/` 中的陶泥素æä½œä¸ºè¯¥é¡µå›¾å½¢èµ„产。常用功能宫格固定承载泥点充值ã€é‚€è¯·å¥½å‹ã€å…‘æ¢ç ã€çŽ©å®¶ç¤¾åŒºã€å馈与建议;页é¢ä¸å†æä¾›ç‹¬ç«‹å­˜æ¡£æŒ‰é’®å…¥å£ï¼Œä¹Ÿä¸åœ¨åº•部ä¿ç•™æ—§çš„å¡«é‚€è¯·ç æ¬¡çº§å…¥å£ã€‚填邀请ç åªç”±é‚€è¯·é“¾æŽ¥ query 或其它明确引导打开独立弹窗,ä¸ä½œä¸ºâ€œæˆ‘çš„â€é¡µå¸¸é©»æŒ‰é’®ã€‚ +11. “我的â€é¡µæ¯æ—¥ä»»åŠ¡å¡å¿…须展示åŽç«¯ `/api/profile/tasks` 返回的当å‰ä»»åŠ¡æ‘˜è¦ï¼ŒåŒ…括奖励泥点数ã€è¿›åº¦å’Œé¢†å– / åŽ»å®Œæˆ / 已完æˆçжæ€ï¼›ä»»åС领喿ˆåŠŸåŽï¼Œå¡ç‰‡æ‘˜è¦å¿…须跟éšè¿”回的任务中心数æ®åŒæ­¥åˆ·æ–°ï¼Œä¸èƒ½ç»§ç»­ç¡¬ç¼–ç  `0 / 1` æˆ–åªæ›´æ–°å¼¹çª—内任务列表。 +12. “我的â€é¡µæ³¥ç‚¹ã€æ¸¸æˆæ—¶é•¿ã€å·²çŽ©æ¸¸æˆæ•°é‡ä¸‰å¼ ç»Ÿè®¡å¡åªå±•示å„自标签和值,三个统计 icon 使用å°å°ºå¯¸æ™®é€š UI æ¡£ä½ï¼Œå†…容䏿¢è¡Œï¼Œä¸åœ¨ç»Ÿè®¡åŒºåº•éƒ¨å±•ç¤ºâ€œæ›´æ–°äºŽâ€æ—¶é—´ï¼›ç§»åŠ¨ç«¯æ˜µç§°ã€ä¼šå‘˜å¡ã€æ¯æ—¥ä»»åŠ¡ã€å¸¸ç”¨åŠŸèƒ½å’Œæ³•å¾‹ä¿¡æ¯ä¹Ÿåº”ä¿æŒ `10px` 到 `14px` 的普通 UI å­—å·åŒºé—´ï¼Œé¿å…å±•ç¤ºçº§å­—å·æŒ¤åŽ‹å†…å®¹ã€‚ +13. 移动端“我的â€é¡µéœ€è¦å…¼å®¹çª„å±ï¼šå¤´åƒ / 昵称 / é™¶æ³¥å·ã€ä¸‰å¼ ç»Ÿè®¡å¡ã€æ¯æ—¥ä»»åŠ¡ã€äº”项常用功能和法律信æ¯éƒ½å¿…须能在底部固定 TabBar 上方完整滚动露出,ä¸å¾—与底部 dockã€åˆ˜æµ· safe-area 或相邻 UI å…ƒç´ é®æŒ¡é‡å ã€‚ +14. RPG ç­‰è¿è¡Œæ€çš„æˆ˜æ–—飘字ã€è¡€é‡å˜åŒ–å’Œå³æ—¶å馈必须在暗色ã€å™ªå£°é«˜çš„åœºæ™¯èƒŒæ™¯ä¸Šä¿æŒå¯è¯»ï¼šä½¿ç”¨é«˜äº®æ–‡å­—ã€æ·±è‰²æè¾¹ã€å¼ºé˜´å½±æˆ–å°é¢ç§¯åŠé€æ˜Žåº•,ä¸åªä¾èµ–红/绿文字本身表达伤害或治疗。 +15. å¹³å°äº®è‰² UI é…色以陶泥儿主视觉为准:暖白 / ç±³æåº•ã€é™¶åœŸæ©™ä¸»æŒ‰é’®ã€æ·±æ£•正文与浅æè¾¹æ¡†ï¼›æ–°å¢žç•Œé¢ä¼˜å…ˆå¤ç”¨ `src/index.css` çš„ `--platform-*` 主题å˜é‡å’Œ `apps/admin-web/src/styles/admin.css` çš„åŒç³»è‰²å€¼ï¼Œä¸å†å¼•入粉红ã€è“绿等独立主色方案。 ## æ–‡æ¡ˆä¸Žç¼–ç  diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index 9a00c842..55efe604 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -13,7 +13,7 @@ interface CustomWorldGenerationViewProps { anchorEntries?: CustomWorldStructuredAnchorEntry[]; progress: CustomWorldGenerationProgress | null; isGenerating: boolean; - error: string | null; + error?: string | null; onBack: () => void; onEditSetting: () => void; onRetry: () => void; @@ -110,7 +110,6 @@ export function CustomWorldGenerationView({ anchorEntries = [], progress, isGenerating, - error, onBack, onEditSetting, onRetry, @@ -123,7 +122,6 @@ export function CustomWorldGenerationView({ settingDescription = '这段文本会直接驱动本轮世界框架ã€è§’色与场景生æˆã€‚', progressTitle = '生æˆè¿›åº¦', activeBadgeLabel = '世界建设中', - pausedBadgeLabel = '生æˆå·²æš‚åœ', idleBadgeLabel = '等待æ“作', structuredEmptyText = '正在整ç†å½“å‰è®¾å®šç»“构,请ç¨åŽã€‚', hideBatchModule = false, @@ -169,11 +167,7 @@ export function CustomWorldGenerationView({ {backLabel}
- {isGenerating - ? activeBadgeLabel - : error - ? pausedBadgeLabel - : idleBadgeLabel} + {isGenerating ? activeBadgeLabel : idleBadgeLabel}
@@ -195,12 +189,6 @@ export function CustomWorldGenerationView({ /> - {error ? ( -
- {error} -
- ) : null} -
{!isGenerating ? ( <> diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index ccd20cd5..12520cf8 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -1,12 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; -import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; -import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; @@ -43,7 +43,6 @@ type CustomWorldCreationHubProps = { loading: boolean; error: string | null; onRetry: () => void; - createError?: string | null; createBusy?: boolean; entryConfig: CreationEntryConfig; creationTypes: readonly PlatformCreationTypeCard[]; @@ -154,7 +153,6 @@ export function CustomWorldCreationHub({ loading, error, onRetry, - createError = null, createBusy = false, entryConfig, creationTypes, @@ -360,7 +358,6 @@ export function CustomWorldCreationHub({ {showStartCard ? ( -
{error}
+
diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx index c5021674..6647e42c 100644 --- a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx +++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx @@ -1,5 +1,5 @@ import { Coins, Trophy } from 'lucide-react'; -import { useMemo, useState, type UIEvent } from 'react'; +import { type UIEvent,useMemo, useState } from 'react'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { @@ -10,7 +10,6 @@ import { type CustomWorldCreationStartCardProps = { busy?: boolean; - error?: string | null; entryConfig: CreationEntryConfig; creationTypes: readonly PlatformCreationTypeCard[]; onCreateType: (type: PlatformCreationTypeId) => void; @@ -25,7 +24,6 @@ function shouldShowCreationBadge(badge: string) { export function CustomWorldCreationStartCard({ busy = false, - error = null, entryConfig, creationTypes, onCreateType, @@ -233,11 +231,6 @@ export function CustomWorldCreationStartCard({ })}
- {error ? ( -
- {error} -
- ) : null}
); diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx index 583c19ad..fdfe2575 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx @@ -51,7 +51,6 @@ test('dispatches wooden fish creation type selection', () => { {}} diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index 8d6698aa..21b297b9 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -10,7 +10,6 @@ import { export interface PlatformEntryCreationTypeModalProps { isOpen: boolean; isBusy: boolean; - error: string | null; entryConfig: CreationEntryConfig; creationTypes: readonly PlatformCreationTypeCard[]; onClose: () => void; @@ -94,7 +93,6 @@ function CreationTypeCard(props: { export function PlatformEntryCreationTypeModal({ isOpen, isBusy, - error, entryConfig, creationTypes, onClose, @@ -172,11 +170,6 @@ export function PlatformEntryCreationTypeModal({ ))} - {error ? ( -
- {error} -
- ) : null} ); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index c6359f6b..a74239f8 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -38,6 +38,7 @@ import type { BabyObjectMatchDraft, CreateBabyObjectMatchDraftRequest, } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { CreateMatch3DSessionRequest, ExecuteMatch3DActionRequest, @@ -154,13 +155,6 @@ import { type CreationEntryConfig, fetchCreationEntryConfig, } from '../../services/creationEntryConfigService'; -import { - cancelCreativeAgentSession, - confirmCreativePuzzleTemplate, - createCreativeAgentSession, - streamCreativeAgentMessage, - streamCreativeDraftEdit, -} from '../../services/creative-agent'; import { clearCreationUrlState, type CreationUrlState, @@ -169,11 +163,12 @@ import { writeCreationUrlState, } from '../../services/creationUrlState'; import { - clearPuzzleRuntimeUrlState, - readPuzzleRuntimeUrlState, - writePuzzleRuntimeUrlState, - type PuzzleRuntimeUrlState, -} from '../../services/puzzleRuntimeUrlState'; + cancelCreativeAgentSession, + confirmCreativePuzzleTemplate, + createCreativeAgentSession, + streamCreativeAgentMessage, + streamCreativeDraftEdit, +} from '../../services/creative-agent'; import { readCustomWorldAgentUiState, shouldRestoreCustomWorldAgentUiState, @@ -196,7 +191,6 @@ import { JumpHopWorkProfileResponse, JumpHopWorkspaceCreateRequest, } from '../../services/jump-hop/jumpHopClient'; -import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import { match3dCreationClient } from '../../services/match3d-creation'; import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { @@ -287,6 +281,12 @@ import { listPuzzleWorks, updatePuzzleWork, } from '../../services/puzzle-works'; +import { + clearPuzzleRuntimeUrlState, + type PuzzleRuntimeUrlState, + readPuzzleRuntimeUrlState, + writePuzzleRuntimeUrlState, +} from '../../services/puzzleRuntimeUrlState'; import { deleteRpgCreationAgentSession } from '../../services/rpg-creation'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import { @@ -375,6 +375,7 @@ import { mapVisualNovelWorkToPlatformGalleryCard, mapWoodenFishWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, + resolvePlatformPublicWorkCode, } from '../rpg-entry/rpgEntryWorldPresentation'; import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling'; import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; @@ -414,6 +415,7 @@ import { PlatformEntryHomeView, type PlatformHomeTab, } from './PlatformEntryHomeView'; +import { usePlatformDesktopLayout } from './platformEntryResponsive'; import { buildCreationHubFallbackItems, resolveRpgCreationErrorMessage, @@ -423,11 +425,14 @@ import type { SelectionStage, } from './platformEntryTypes'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; +import { + PlatformErrorDialog, + type PlatformErrorDialogPayload, +} from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; -import { usePlatformDesktopLayout } from './platformEntryResponsive'; import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail'; import { usePlatformEntryNavigation } from './usePlatformEntryNavigation'; @@ -2012,6 +2017,22 @@ function createPendingDraftShelfState( }; } +function normalizePlatformErrorMessage(message: string | null | undefined) { + const normalized = message?.trim(); + return normalized ? normalized : null; +} + +function formatPlatformErrorSource(label: string, id?: string | null) { + const normalizedId = id?.trim(); + return normalizedId ? `${label} ${normalizedId}` : label; +} + +function buildPlatformErrorDialogDismissKey( + error: (PlatformErrorDialogPayload & { key: string }) | null, +) { + return error ? `${error.key}:${error.source}:${error.message}` : null; +} + function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, metadata?: MiniGameDraftGenerationState['metadata'], @@ -5767,6 +5788,336 @@ export function PlatformEntryFlowShellImpl({ isMiniGameDraftGenerating( activePuzzleBackgroundCompileTask?.generationState ?? null, ); + const [dismissedPlatformErrorDialogKey, setDismissedPlatformErrorDialogKey] = + useState(null); + const currentPlatformErrorDialog = useMemo< + (PlatformErrorDialogPayload & { key: string }) | null + >(() => { + const candidates: Array<{ + key: string; + source: string; + message: string | null | undefined; + }> = [ + { + key: 'creation-entry-config', + source: '创作入å£é…ç½®', + message: creationEntryConfigError, + }, + { + key: 'platform-bootstrap', + source: 'å¹³å°é¦–页', + message: platformBootstrap.platformError, + }, + { + key: 'rpg-creation-type', + source: '创作入å£', + message: sessionController.creationTypeError, + }, + { + key: 'rpg-restore', + source: 'åˆ›ä½œä½œå“æž¶', + message: sessionController.agentWorkspaceRestoreError, + }, + { + key: 'rpg-result', + source: formatPlatformErrorSource( + 'RPG è‰ç¨¿', + sessionController.agentSession?.sessionId ?? + sessionController.generatedCustomWorldProfile?.id, + ), + message: resultViewError, + }, + { + key: 'public-work-detail', + source: formatPlatformErrorSource( + '作å“详情', + selectedPublicWorkDetail + ? resolvePlatformPublicWorkCode(selectedPublicWorkDetail) + : selectedDetailEntry?.profileId, + ), + message: publicWorkDetailError ?? detailNavigation.detailError, + }, + { + key: 'big-fish', + source: formatPlatformErrorSource( + selectionStage === 'big-fish-runtime' ? '大鱼åƒå°é±¼æ¸¸çŽ©' : '大鱼è‰ç¨¿', + bigFishRun?.runId ?? bigFishSession?.sessionId, + ), + message: bigFishError, + }, + { + key: 'match3d', + source: formatPlatformErrorSource( + selectionStage === 'match3d-runtime' ? '抓大鹅游玩' : '抓大鹅è‰ç¨¿', + match3dRun?.runId ?? + match3dGenerationViewSession?.sessionId ?? + match3dSession?.sessionId, + ), + message: match3dGenerationViewError ?? match3dError, + }, + { + key: 'square-hole', + source: formatPlatformErrorSource( + selectionStage === 'square-hole-runtime' + ? '方洞挑战游玩' + : '方洞挑战è‰ç¨¿', + squareHoleRun?.runId ?? squareHoleSession?.sessionId, + ), + message: squareHoleError, + }, + { + key: 'jump-hop', + source: formatPlatformErrorSource( + selectionStage === 'jump-hop-runtime' ? '跳一跳游玩' : '跳一跳è‰ç¨¿', + jumpHopRun?.runId ?? jumpHopSession?.sessionId, + ), + message: jumpHopError, + }, + { + key: 'wooden-fish', + source: formatPlatformErrorSource( + selectionStage === 'wooden-fish-runtime' + ? '敲木鱼游玩' + : '敲木鱼è‰ç¨¿', + woodenFishRun?.runId ?? woodenFishSession?.sessionId, + ), + message: woodenFishError, + }, + { + key: 'puzzle', + source: formatPlatformErrorSource( + selectionStage === 'puzzle-runtime' ? '拼图游玩' : '拼图è‰ç¨¿', + puzzleRun?.runId ?? + puzzleGenerationViewSession?.sessionId ?? + puzzleSession?.sessionId, + ), + message: puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError, + }, + { + key: 'puzzle-onboarding', + source: '拼图首次创作', + message: puzzleOnboardingError, + }, + { + key: 'puzzle-shelf', + source: 'æ‹¼å›¾ä½œå“æž¶', + message: puzzleShelfError, + }, + { + key: 'visual-novel', + source: formatPlatformErrorSource( + selectionStage === 'visual-novel-runtime' + ? '视觉å°è¯´æ¸¸çŽ©' + : '视觉å°è¯´è‰ç¨¿', + visualNovelRun?.runId ?? visualNovelSession?.sessionId, + ), + message: visualNovelError, + }, + { + key: 'baby-object-match', + source: formatPlatformErrorSource( + selectionStage === 'baby-object-match-runtime' + ? 'å®è´è¯†ç‰©æ¸¸çŽ©' + : 'å®è´è¯†ç‰©è‰ç¨¿', + babyObjectMatchDraft?.profileId, + ), + message: babyObjectMatchError, + }, + { + key: 'bark-battle', + source: formatPlatformErrorSource( + selectionStage === 'bark-battle-runtime' + ? '汪汪声浪游玩' + : '汪汪声浪è‰ç¨¿', + barkBattlePublishedConfig?.workId ?? barkBattleDraftConfig?.workId, + ), + message: barkBattleError, + }, + { + key: 'creative-agent', + source: formatPlatformErrorSource( + '智能创作 Agent', + creativeAgentSession?.sessionId, + ), + message: creativeAgentError, + }, + { + key: 'rpg-generation', + source: formatPlatformErrorSource( + 'RPG è‰ç¨¿ç”Ÿæˆ', + sessionController.agentSession?.sessionId, + ), + message: sessionController.activeGenerationError, + }, + ]; + + for (const candidate of candidates) { + const message = normalizePlatformErrorMessage(candidate.message); + if (message) { + return { + key: candidate.key, + source: candidate.source, + message, + }; + } + } + + return null; + }, [ + babyObjectMatchDraft?.profileId, + babyObjectMatchError, + barkBattleDraftConfig?.workId, + barkBattleError, + barkBattlePublishedConfig?.workId, + bigFishError, + bigFishRun?.runId, + bigFishSession?.sessionId, + creationEntryConfigError, + creativeAgentError, + creativeAgentSession?.sessionId, + detailNavigation.detailError, + jumpHopError, + jumpHopRun?.runId, + jumpHopSession?.sessionId, + match3dError, + match3dGenerationViewError, + match3dGenerationViewSession?.sessionId, + match3dRun?.runId, + match3dSession?.sessionId, + platformBootstrap.platformError, + publicWorkDetailError, + puzzleCreationError, + puzzleError, + puzzleGenerationViewError, + puzzleGenerationViewSession?.sessionId, + puzzleOnboardingError, + puzzleRun?.runId, + puzzleSession?.sessionId, + puzzleShelfError, + resultViewError, + selectedDetailEntry?.profileId, + selectedPublicWorkDetail, + selectionStage, + sessionController.activeGenerationError, + sessionController.agentSession?.sessionId, + sessionController.agentWorkspaceRestoreError, + sessionController.creationTypeError, + sessionController.generatedCustomWorldProfile?.id, + squareHoleError, + squareHoleRun?.runId, + squareHoleSession?.sessionId, + visualNovelError, + visualNovelRun?.runId, + visualNovelSession?.sessionId, + woodenFishError, + woodenFishRun?.runId, + woodenFishSession?.sessionId, + ]); + const activePlatformErrorDialogDismissKey = + buildPlatformErrorDialogDismissKey(currentPlatformErrorDialog); + const activePlatformErrorDialog = + activePlatformErrorDialogDismissKey && + activePlatformErrorDialogDismissKey === dismissedPlatformErrorDialogKey + ? null + : currentPlatformErrorDialog; + const closePlatformErrorDialog = useCallback(() => { + if (!currentPlatformErrorDialog) { + return; + } + + const dismissKey = buildPlatformErrorDialogDismissKey( + currentPlatformErrorDialog, + ); + if (dismissKey) { + setDismissedPlatformErrorDialogKey(dismissKey); + } + + if (currentPlatformErrorDialog.key === 'creation-entry-config') { + setCreationEntryConfigError(null); + return; + } + if (currentPlatformErrorDialog.key === 'platform-bootstrap') { + platformBootstrap.setPlatformError(null); + return; + } + if (currentPlatformErrorDialog.key === 'rpg-creation-type') { + sessionController.setCreationTypeError(null); + return; + } + if (currentPlatformErrorDialog.key === 'rpg-restore') { + return; + } + if ( + currentPlatformErrorDialog.key === 'rpg-result' || + currentPlatformErrorDialog.key === 'rpg-generation' + ) { + autosaveCoordinator.setCustomWorldAutoSaveError(null); + sessionController.setCustomWorldError(null); + return; + } + if (currentPlatformErrorDialog.key === 'public-work-detail') { + setPublicWorkDetailError(null); + detailNavigation.setDetailError(null); + return; + } + if (currentPlatformErrorDialog.key === 'big-fish') { + setBigFishError(null); + return; + } + if (currentPlatformErrorDialog.key === 'match3d') { + setMatch3DError(null); + return; + } + if (currentPlatformErrorDialog.key === 'square-hole') { + setSquareHoleError(null); + return; + } + if (currentPlatformErrorDialog.key === 'jump-hop') { + setJumpHopError(null); + return; + } + if (currentPlatformErrorDialog.key === 'wooden-fish') { + setWoodenFishError(null); + return; + } + if ( + currentPlatformErrorDialog.key === 'puzzle' || + currentPlatformErrorDialog.key === 'puzzle-onboarding' || + currentPlatformErrorDialog.key === 'puzzle-shelf' + ) { + setPuzzleCreationError(null); + setPuzzleOnboardingError(null); + setPuzzleShelfError(null); + setPuzzleError(null); + return; + } + if (currentPlatformErrorDialog.key === 'visual-novel') { + setVisualNovelError(null); + return; + } + if (currentPlatformErrorDialog.key === 'baby-object-match') { + setBabyObjectMatchError(null); + return; + } + if (currentPlatformErrorDialog.key === 'bark-battle') { + setBarkBattleError(null); + return; + } + if (currentPlatformErrorDialog.key === 'creative-agent') { + setCreativeAgentError(null); + } + }, [ + autosaveCoordinator, + currentPlatformErrorDialog, + detailNavigation, + platformBootstrap, + sessionController, + setBigFishError, + setMatch3DError, + setPuzzleError, + setSquareHoleError, + setVisualNovelError, + ]); const shouldPollPuzzleGenerationSession = selectionStage === 'puzzle-generating' && activePuzzleGenerationSessionId != null && @@ -14098,19 +14449,6 @@ export function PlatformEntryFlowShellImpl({ void refreshBabyObjectMatchShelf(); void refreshBarkBattleShelf(); }} - createError={ - creationEntryConfigError ?? - sessionController.creationTypeError ?? - bigFishError ?? - match3dError ?? - (isSquareHoleCreationVisible ? squareHoleError : null) ?? - woodenFishError ?? - puzzleCreationError ?? - puzzleError ?? - (isVisualNovelCreationOpen ? visualNovelError : null) ?? - babyObjectMatchError ?? - barkBattleError - } createBusy={ !creationEntryConfig || sessionController.isCreatingAgentSession || @@ -15762,7 +16100,6 @@ export function PlatformEntryFlowShellImpl({ settingDescription={null} progressTitle="拼图è‰ç¨¿ç”Ÿæˆè¿›åº¦" activeBadgeLabel="è‰ç¨¿ç”Ÿæˆä¸­" - pausedBadgeLabel="è‰ç¨¿ç”Ÿæˆå·²æš‚åœ" idleBadgeLabel="等待返回工作区" hideBatchModule /> @@ -16420,7 +16757,7 @@ export function PlatformEntryFlowShellImpl({ {creationEntryConfig ? ( - { @@ -16542,6 +16865,12 @@ export function PlatformEntryFlowShellImpl({ payload={publishSharePayload} onClose={() => setPublishSharePayload(null)} /> + ({ + copyTextToClipboard: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('PlatformErrorDialog', () => { + test('shows source, message, and copies the full error report', async () => { + vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true); + + render( + {}} + />, + ); + + const dialog = screen.getByRole('dialog', { name: 'å‘生错误' }); + expect(within(dialog).getByText('拼图è‰ç¨¿ puzzle-session-123')).toBeTruthy(); + expect(within(dialog).getByText('图片生æˆå¤±è´¥ï¼Œè¯·ç¨åŽå†è¯•。')).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'å¤åˆ¶æŠ¥é”™' })); + + expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith( + ['æ¥æºï¼šæ‹¼å›¾è‰ç¨¿ puzzle-session-123', '错误:图片生æˆå¤±è´¥ï¼Œè¯·ç¨åŽå†è¯•。'].join( + '\n', + ), + ); + await waitFor(() => { + expect( + within(dialog).getByRole('button', { name: 'å·²å¤åˆ¶' }), + ).toBeTruthy(); + }); + }); + + test('does not render when there is no active error', () => { + render( {}} />); + + expect(screen.queryByRole('dialog', { name: 'å‘生错误' })).toBeNull(); + }); +}); diff --git a/src/components/platform-entry/PlatformErrorDialog.tsx b/src/components/platform-entry/PlatformErrorDialog.tsx new file mode 100644 index 00000000..794a6a5c --- /dev/null +++ b/src/components/platform-entry/PlatformErrorDialog.tsx @@ -0,0 +1,120 @@ +import { Check, Copy } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { copyTextToClipboard } from '../../services/clipboard'; +import { UnifiedModal } from '../common/UnifiedModal'; + +export type PlatformErrorDialogPayload = { + source: string; + message: string; +}; + +type PlatformErrorDialogProps = { + error: PlatformErrorDialogPayload | null; + onClose: () => void; + overlayClassName?: string; + panelClassName?: string; +}; + +function buildPlatformErrorReport(error: PlatformErrorDialogPayload) { + return [`æ¥æºï¼š${error.source}`, `错误:${error.message}`].join('\n'); +} + +export function PlatformErrorDialog({ + error, + onClose, + overlayClassName = 'platform-theme platform-theme--light !items-center', + panelClassName = 'platform-remap-surface rounded-[1.5rem]', +}: PlatformErrorDialogProps) { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const resetTimerRef = useRef(null); + const reportText = useMemo( + () => (error ? buildPlatformErrorReport(error) : ''), + [error], + ); + + useEffect( + () => () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }, + [], + ); + + useEffect(() => { + setCopyState('idle'); + }, [error?.source, error?.message]); + + const copyError = () => { + if (!reportText) { + return; + } + + void copyTextToClipboard(reportText).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + resetTimerRef.current = null; + setCopyState('idle'); + }, 1400); + }); + }; + + return ( + + {copyState === 'copied' ? ( + + ) : ( + + )} + {copyState === 'copied' + ? 'å·²å¤åˆ¶' + : copyState === 'failed' + ? 'å¤åˆ¶å¤±è´¥' + : 'å¤åˆ¶æŠ¥é”™'} + + } + > + {error ? ( + <> +
+
+ æ¥æº +
+
+ {error.source} +
+
+
+
+ 错误 +
+
+ {error.message} +
+
+ + ) : null} +
+ ); +} diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx index 7477d978..65d3c404 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -24,7 +24,6 @@ import { formatPlatformWorldTime, isBarkBattleGalleryEntry, isEdutainmentGalleryEntry, - isJumpHopGalleryEntry, type PlatformPublicGalleryCard, resolvePlatformPublicWorkCode, resolvePlatformWorldCoverSlides, @@ -36,7 +35,7 @@ export interface PlatformWorkDetailViewProps { authorAvatarUrl?: string | null; authorDisplayName?: string | null; isBusy: boolean; - error: string | null; + error?: string | null; visibleCoverCount?: number; onBack: () => void; onLike: () => void; @@ -89,7 +88,6 @@ export function PlatformWorkDetailView({ authorAvatarUrl, authorDisplayName, isBusy, - error, visibleCoverCount = 1, onBack, onLike, @@ -432,9 +430,6 @@ export function PlatformWorkDetailView({ {shareState === 'copied' ? '分享内容已å¤åˆ¶' : '分享失败'} ) : null} - {error ? ( -
{error}
- ) : null} diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index 4b280ad5..093b529a 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -1819,12 +1819,7 @@ export function PuzzleResultView({ ) : null} - {error ? ( -
- {error} -
- ) : null} - {!error && autoSaveError ? ( + {autoSaveError ? (
{autoSaveError}
diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index f27b62c7..f336c6d6 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1826,11 +1826,18 @@ test('non-wechat profile opens reward code from recharge-shaped entry', async () expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); }); -test('profile daily task shortcut opens task center and claims reward', async () => { +test('profile daily task shortcut reflects task progress and claim updates', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); renderProfileView(onRechargeSuccess); + + const dailyTask = screen.getByRole('button', { name: /æ¯æ—¥ä»»åŠ¡/u }); + await waitFor(() => { + expect(within(dailyTask).getByText('1 / 1')).toBeTruthy(); + }); + expect(within(dailyTask).getByText('领å–')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /æ¯æ—¥ä»»åŠ¡/u })); expect(await screen.findByText('æ¯æ—¥ç™»å½•')).toBeTruthy(); @@ -1847,6 +1854,7 @@ test('profile daily task shortcut opens task center and claims reward', async () expect(await screen.findByText('å·²é¢†å– 10 泥点')).toBeTruthy(); expect(screen.queryByRole('button', { name: '已领å–' })).toBeNull(); expect(screen.getByText('暂无任务')).toBeTruthy(); + expect(within(dailyTask).getByText('已完æˆ')).toBeTruthy(); }); test('profile task center keeps only the highest priority actionable task', async () => { @@ -1909,7 +1917,7 @@ test('profile task center keeps only the highest priority actionable task', asyn expect(screen.queryByText('低优先级已完æˆ')).toBeNull(); }); -test('profile total play time card always uses hours', () => { +test('profile total play time card always uses hours', async () => { renderProfileView(vi.fn(), { totalPlayTimeMs: 90 * 60 * 1000, }); @@ -1920,9 +1928,10 @@ test('profile total play time card always uses hours', () => { expect(within(playTimeCard).getByText('1.5å°æ—¶')).toBeTruthy(); expect(within(playTimeCard).queryByText('90分')).toBeNull(); + await screen.findByText('1 / 1'); }); -test('profile played works card shows count unit', () => { +test('profile played works card shows count unit', async () => { renderProfileView(vi.fn(), { playedWorldCount: 1, }); @@ -1932,9 +1941,10 @@ test('profile played works card shows count unit', () => { }); expect(within(playedCard).getByText('1个')).toBeTruthy(); + await screen.findByText('1 / 1'); }); -test('profile stats cards are centered without update timestamp', () => { +test('profile stats cards are centered without update timestamp', async () => { renderProfileView(vi.fn(), { updatedAt: '2026-05-03T08:01:00Z', }); @@ -1950,6 +1960,7 @@ test('profile stats cards are centered without update timestamp', () => { expect(card.className).toContain('text-center'); } expect(screen.queryByText(/更新于/u)).toBeNull(); + await screen.findByText('1 / 1'); }); test('mobile profile page matches the reference layout sections', async () => { @@ -2007,7 +2018,7 @@ test('mobile profile page matches the reference layout sections', async () => { expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy(); expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy(); expect(dailyTask.textContent).toContain('完æˆä»»åŠ¡å¯é¢†å– 10 泥点'); - expect(within(dailyTask).getByText('0 / 1')).toBeTruthy(); + expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); expect( @@ -2101,7 +2112,7 @@ test('profile scan action opens camera scanner instead of recharge panel', async expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); }); -test('desktop account entry uses saved avatar image when available', () => { +test('desktop account entry uses saved avatar image when available', async () => { mockDesktopLayout(); const avatarUrl = 'data:image/png;base64,AAAA'; @@ -2111,6 +2122,7 @@ test('desktop account entry uses saved avatar image when available', () => { const avatarImage = accountEntry.querySelector('img'); expect(avatarImage?.getAttribute('src')).toBe(avatarUrl); expect(within(accountEntry).queryByText('测')).toBeNull(); + await screen.findByText('1 / 1'); }); test('profile avatar upload uses the shared square crop tool', async () => { @@ -2184,7 +2196,7 @@ test('profile invite shortcut shows reward subtitle and invited users', async () expect(screen.queryByText('今日')).toBeNull(); }); -test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => { +test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => { renderProfileView( vi.fn(), {}, @@ -2192,20 +2204,16 @@ test('profile redeem invite shortcut sits between invite and community for fresh ); const inviteButton = screen.getByRole('button', { name: /邀请好å‹/u }); - const redeemButton = await screen.findByRole('button', { - name: /填邀请ç /u, - }); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); - const secondaryShortcuts = screen.getByRole('region', { - name: '次级入å£', + + await waitFor(() => { + expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1); }); expect(inviteButton).toBeTruthy(); expect(communityButton).toBeTruthy(); - expect( - within(secondaryShortcuts).getByRole('button', { name: /填邀请ç /u }), - ).toBeTruthy(); - expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy(); + expect(screen.queryByRole('region', { name: '次级入å£' })).toBeNull(); + expect(screen.queryByRole('button', { name: /填邀请ç /u })).toBeNull(); }); test('profile redeem invite shortcut hides after redeemed or one day old', async () => { @@ -2226,6 +2234,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async expect( within(firstShortcutRegion).queryByRole('button', { name: /填邀请ç /u }), ).toBeNull(); + await screen.findByText('1 / 1'); unmount(); renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' }); @@ -2237,6 +2246,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async name: /填邀请ç /u, }), ).toBeNull(); + await screen.findByText('1 / 1'); }); test('invite query opens login modal for logged out users', async () => { @@ -2269,9 +2279,10 @@ test('profile redeem invite modal reads query invite code after login', async () expect((input as HTMLInputElement).value).toBe('SPRING2026'); }); -test('profile redeem invite modal submits code and hides shortcut after success', async () => { +test('profile redeem invite query modal submits code after login', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); + window.history.replaceState(null, '', '/?inviteCode=spring-2026'); renderProfileView( onRechargeSuccess, @@ -2279,9 +2290,7 @@ test('profile redeem invite modal submits code and hides shortcut after success' { createdAt: buildFreshProfileCreatedAt() }, ); - await user.click(await screen.findByRole('button', { name: /填邀请ç /u })); - const input = await screen.findByLabelText('邀请ç '); - await user.type(input, 'spring-2026'); + expect(await screen.findByLabelText('邀请ç ')).toBeTruthy(); await user.click(screen.getByRole('button', { name: 'æäº¤' })); await waitFor(() => { @@ -2291,12 +2300,7 @@ test('profile redeem invite modal submits code and hides shortcut after success' }); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); expect(await screen.findByText('已填写')).toBeTruthy(); - const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); - expect( - within(shortcutRegion).queryByRole('button', { - name: /填邀请ç /u, - }), - ).toBeNull(); + expect(screen.queryByRole('region', { name: '次级入å£' })).toBeNull(); }); test('opens reward code modal from profile action on mobile', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 506b9c01..ab961e97 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -255,18 +255,25 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; -const PROFILE_TASK_STATUS_PRIORITY_RANK: Record = { +const PROFILE_TASK_STATUS_PRIORITY_RANK: Record< + ProfileTaskItem['status'], + number +> = { claimable: 2, incomplete: 1, disabled: 0, claimed: -1, }; +const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10; const PROFILE_QR_SCAN_INTERVAL_MS = 360; function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) { return tasks .map((task, index) => ({ task, index })) - .filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete') + .filter( + ({ task }) => + task.status === 'claimable' || task.status === 'incomplete', + ) .sort( (left, right) => PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] - @@ -277,6 +284,37 @@ function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) { .map(({ task }) => task); } +function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) { + return ( + selectProfileTaskCenterTasks(tasks)[0] ?? + tasks.find((task) => task.status === 'claimed') ?? + tasks.find((task) => task.status !== 'disabled') ?? + null + ); +} + +function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) { + const task = selectProfileTaskCardTask(center?.tasks ?? []); + const threshold = Math.max(1, task?.threshold ?? 1); + const progressCount = Math.min(task?.progressCount ?? 0, threshold); + const rewardPoints = + task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS; + const actionLabel = + task?.status === 'claimable' + ? '领å–' + : task?.status === 'claimed' + ? '已完æˆ' + : '去完æˆ'; + + return { + actionLabel, + progressCount, + progressPercent: Math.round((progressCount / threshold) * 100), + rewardPoints, + threshold, + }; +} + type ProfileReferralPanel = 'invite' | 'redeem' | 'community'; type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; type BarcodeDetectorLike = { @@ -2449,42 +2487,6 @@ function ProfileSettingsRow({ ); } -function ProfileSecondaryShortcutButton({ - label, - subLabel, - icon, - onClick, -}: { - label: string; - subLabel?: string; - icon: ComponentType<{ className?: string }>; - onClick: () => void; -}) { - const Icon = icon; - - return ( - - ); -} - function ProfileLegalSection({ onOpenDocument, }: { @@ -4218,12 +4220,10 @@ export function RpgEntryHomeView({ profileDashboard?.totalPlayTimeMs ?? 0, ); const playedWorkCount = profileDashboard?.playedWorldCount ?? 0; - const canShowReferralRedeemShortcut = - isAuthenticated && - isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) && - isReferralCenterInitialized && - Boolean(referralCenter) && - referralCenter?.hasRedeemedCode !== true; + const profileTaskCardSummary = useMemo( + () => buildProfileTaskCardSummary(taskCenter), + [taskCenter], + ); const tabIcons: Record< PlatformHomeTab, ComponentType<{ className?: string }> @@ -4776,7 +4776,7 @@ export function RpgEntryHomeView({ document.removeEventListener('visibilitychange', handleResume); }; }, [handleWechatPayResult]); - const loadTaskCenter = () => { + const loadTaskCenter = useCallback(() => { setTaskCenterError(null); setIsLoadingTaskCenter(true); void getRpgProfileTasks() @@ -4788,11 +4788,24 @@ export function RpgEntryHomeView({ ); }) .finally(() => setIsLoadingTaskCenter(false)); - }; + }, []); + + useEffect(() => { + if (activeTab !== 'profile' || !isAuthenticated) { + setTaskCenter(null); + setTaskCenterError(null); + return; + } + + loadTaskCenter(); + }, [activeTab, isAuthenticated, loadTaskCenter]); + const openTaskCenterPanel = () => { setIsTaskCenterOpen(true); setTaskClaimSuccess(null); - loadTaskCenter(); + if (!taskCenter) { + loadTaskCenter(); + } }; const openQrScannerPanel = () => { if (!authUi?.user) { @@ -6185,14 +6198,24 @@ export function RpgEntryHomeView({ æ¯æ—¥ä»»åŠ¡ - 完æˆä»»åŠ¡å¯é¢†å– 10 泥点 + 完æˆä»»åŠ¡å¯é¢†å–{' '} + + {profileTaskCardSummary.rewardPoints} + {' '} + 泥点 - 0 / 1 + {profileTaskCardSummary.progressCount} /{' '} + {profileTaskCardSummary.threshold} - + @@ -6202,7 +6225,7 @@ export function RpgEntryHomeView({ className="platform-profile-daily-task-card__mascot" /> - åŽ»å®Œæˆ + {profileTaskCardSummary.actionLabel} @@ -6267,20 +6290,6 @@ export function RpgEntryHomeView({ /> - {canShowReferralRedeemShortcut ? ( -
- openProfilePopupPanel('redeem')} - /> -
- ) : null} - ) : (