From 9c9c8f468a1630a3ad47282bce765ca1547e49ab Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 18 Jul 2026 17:29:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=B9=B3=E5=8F=B0=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E9=A1=B5=E5=B9=B6=E9=98=BB=E6=96=AD=E6=97=A7=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E5=9B=9E=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复创作、项目、我的平台侧栏与现役搜索 恢复全宽个人页及资料编辑、充值兑换、社区反馈和 API Key 入口 迁移平台资料、钱包和设置客户端并移除旧创作依赖 新增 Vite 与 ESLint 退役模块门禁并补齐界面和搜索测试 同步旧创作退役方案、平台入口决策与踩坑记录 --- .eslintrc.cjs | 72 ++- .../shared-memory/decision-log.md | 7 + docs/project-memory/shared-memory/pitfalls.md | 7 + ...构下线】旧创作模板业务退役方案-2026-07-17.md | 7 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 15 +- scripts/vite-retired-css-plugin.test.ts | 70 ++- .../CreationLandingView.test.tsx | 71 ++- .../creation-home/CreationLandingView.tsx | 31 +- .../ImageCanvasEditorView.test.tsx | 27 +- .../PlatformActiveProfileView.test.tsx | 115 ++++- .../PlatformActiveProfileView.tsx | 458 +++++++++++++++++- .../PlatformEntryActiveFlowShell.test.tsx | 147 +++++- .../PlatformEntryActiveFlowShell.tsx | 119 ++++- .../PlatformProfileApiKeysModal.tsx | 46 +- .../PlatformProfileRechargeModal.tsx | 8 +- .../PlatformProfileWalletLedgerModal.tsx | 10 +- .../platformProfileFundsModel.ts | 127 +++++ .../usePlatformProfileCenterController.ts | 195 ++------ .../project/ProjectGalleryView.test.tsx | 21 + src/components/project/ProjectGalleryView.tsx | 23 +- src/hooks/useGameSettings.ts | 10 +- .../platform-entry/platformProfileClient.ts | 369 +++++++++++++- .../platform-entry/platformSettingsClient.ts | 59 +++ vite.config.ts | 148 ++++++ 24 files changed, 1853 insertions(+), 309 deletions(-) create mode 100644 src/components/platform-entry/platformProfileFundsModel.ts create mode 100644 src/services/platform-entry/platformSettingsClient.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 593462040..9ea2363c3 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -58,6 +58,70 @@ module.exports = { 'react-hooks/exhaustive-deps': 'off', }, }, + { + files: [ + 'src/active-main.tsx', + 'src/ActiveApp.tsx', + 'src/AuthenticatedApp.tsx', + 'src/hooks/useGameSettings.ts', + 'src/routing/activeApp*.ts', + 'src/routing/activeApp*.tsx', + 'src/components/auth/**/*.{ts,tsx}', + 'src/components/common/**/*.{ts,tsx}', + 'src/components/creation-home/**/*.{ts,tsx}', + 'src/components/image-editor/**/*.{ts,tsx}', + 'src/components/project/**/*.{ts,tsx}', + 'src/components/platform-entry/PlatformActiveProfileView*.tsx', + 'src/components/platform-entry/PlatformEntryActiveFlowShell*.tsx', + 'src/components/platform-entry/PlatformEntryFlowShell.tsx', + 'src/components/platform-entry/PlatformProfileApiKeysModal.tsx', + 'src/components/platform-entry/PlatformProfileModalShell.tsx', + 'src/components/platform-entry/PlatformProfilePrimitives.tsx', + 'src/components/platform-entry/PlatformProfileRechargeModal.tsx', + 'src/components/platform-entry/PlatformProfileReferralModal.tsx', + 'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx', + 'src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx', + 'src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx', + 'src/components/platform-entry/platformActiveProfileModel.ts', + 'src/components/platform-entry/platformEntryActiveTypes.ts', + 'src/components/platform-entry/platformProfileFundsModel.ts', + 'src/components/platform-entry/platformProfileHostClipboard.ts', + 'src/components/platform-entry/usePlatformProfileCenterController.ts', + 'src/services/image-editor/**/*.{ts,tsx}', + 'src/services/platform-entry/**/*.{ts,tsx}', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '**/components/rpg-entry/**', + '**/components/*-creation/**', + '**/components/*-result/**', + '**/components/*-runtime/**', + '**/components/creation-agent/**', + '**/components/creative-agent/**', + '**/components/custom-world-*/**', + '**/components/unified-creation/**', + '**/services/rpg-entry/**', + '**/services/rpg-runtime/**', + '**/services/*-creation/**', + '**/services/*-runtime/**', + '**/services/*-works/**', + '**/services/creation-agent/**', + '**/services/creative-agent/**', + '**/services/puzzle-*/**', + '**/services/storyEngine/**', + ], + message: '现役前端不得重新导入已退役的创作模板模块。', + }, + ], + }, + ], + }, + }, { files: ['src/components/game-canvas/**/*.tsx'], rules: { @@ -193,8 +257,6 @@ module.exports = { 'src/components/unified-creation/workspaces/Match3DCreationWorkspace*', 'src/components/rpg-creation-editor/**', 'src/components/rpg-entry/**', - '!src/components/rpg-entry/', - '!src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts', 'src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx', 'src/components/custom-world-home/CustomWorldCreationHub.test.tsx', 'src/components/custom-world-home/CustomWorldCreationHub.testAdapter.tsx', @@ -232,12 +294,8 @@ module.exports = { 'src/services/puzzle-runtime/**', 'src/services/puzzle-works/**', 'src/services/rpg-creation/**', - 'src/services/rpg-entry/index.ts', - 'src/services/rpg-entry/rpgEntryLibraryClient.ts', - 'src/services/rpg-entry/rpgProfileClient.test.ts', + 'src/services/rpg-entry/**', 'src/services/rpg-runtime/**', - '!src/services/rpg-runtime/', - '!src/services/rpg-runtime/rpgRuntimeRequest.ts', 'src/services/square-hole-creation/**', 'src/services/square-hole-runtime/**', 'src/services/square-hole-works/**', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8e59cd11f..236fda467 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4216,3 +4216,10 @@ - 最终落地:本次退役范围覆盖整个旧创作模板体系,包括 RPG / 自定义世界、拼图、拼消消、大鱼吃小鱼、敲木鱼、方洞挑战、视觉小说、汪汪声浪、寓教于乐、Creative Agent、Match3D、跳一跳和儿童动作 Demo。全部相关历史表继续作为数据壳参与 `spacetime-module` 编译,`migration.rs` 白名单与历史数据不变;旧 reducer/procedure/view、API 路由/handler/worker、前端页面/工作台/运行态、共享业务 DTO 和纯业务 crate 从编译链与依赖图移除,但旧源码和素材保留在仓库中用于历史追溯。 - 兼容读取:只保留历史审计、迁移和资产归属核对所需的最小读取定义;旧 `worldType`、公开作品号、URL、详情页和专属运行态均不再形成用户可访问入口。 - 方案文档:`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 + +## 2026-07-18 恢复现役平台公共壳但禁止旧业务依赖回流 + +- 背景:旧创作模板退役时误把新版 `/creation`、桌面公共侧边栏和“我的”完整资料页一起缩减;只恢复视觉后,现役 profile client 又经 `rpg-entry` barrel 把旧作品库、旧 runtime request 和展示模型重新带入 Vite 与 TypeScript 图。 +- 决策:桌面端继续使用原平台公共结构,一级导航固定为 `创作 / 项目 / 我的`;顶栏保留编辑器项目 / 素材搜索、泥点入口和账号胶囊;“我的”全宽保留资料编辑、陶泥号、三项统计、充值、兑换码、社区、反馈、通用设置、API Key 和法律信息。搜索只面向编辑器项目与公开编辑器素材,不恢复旧公开作品搜索。 +- 依赖边界:公共 dashboard、钱包、充值、兑换码、邀请码、API Key 和设置请求迁入 `services/platform-entry`,公共账单展示迁入现役 profile model。Vite 新增退役模块 graph 门禁,ESLint 对现役源码禁止导入旧目录;目录 watch ignore、Tailwind source、tsconfig include 和 tree-shaking 都不能作为依赖隔离证明。 +- 影响范围:`PlatformEntryActiveFlowShell`、`PlatformActiveProfileView`、编辑器 / 项目搜索、平台 profile clients、Vite / ESLint 门禁及旧业务退役方案。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 11fe41d28..68a0dad40 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3165,3 +3165,10 @@ - 处理:在调用 `try_with_tx` 前通过 `let caller = ctx.sender()` 捕获真实调用者,再把 `caller` 显式传入事务函数;迁移、后台账号、runtime profile、外部生成等现有 procedure 已采用这一模式。`npm run check:spacetime-runtime-access` 禁止编辑器 runtime writer 鉴权重新直接读取事务 `ctx.sender()`。运行环境升级到 2.6.1 后继续保留显式 caller,避免鉴权依赖 SDK 版本细节。 - 验证:运行 `npm run check:spacetime-runtime-access`、`cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`;发布使用 SpacetimeDB 2.6.1 构建的新模块后,以 runtime writer identity 重试 `POST /api/editor/icon-spritesheets/slices`,确认来源查询、分片素材写入与 cohort 完成不再返回 identity 403。 - 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`scripts/check-spacetime-runtime-access.mjs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 前端退役目录不能只靠扫描和 ignore 隔离 + +- 现象:Tailwind `@source`、TypeScript 根 `include`、ESLint ignore 和 Vitest include 都排除了旧创作目录,但干净打开新版页面时,Vite 仍转换 `services/rpg-entry/index.ts`,构建产物也包含旧作品库和旧 profile 逻辑。 +- 原因:现役模块的静态 import 会让 Vite、TypeScript 和打包器递归解析依赖;watch ignore 只停止监听,Tailwind source 只控制 class 扫描,tree-shaking 也发生在模块已经加载之后。经 barrel 只取一个公共函数尤其容易把同文件的旧导出一起带回图中。 +- 处理:把仍在用的公共账号 / 钱包 / 设置能力迁到明确的现役 client 与 presentation model;Vite `pre` transform 对退役模块真实路径直接失败,ESLint 在现役源上增加 restricted imports。每次恢复公共 UI 后用 `tsc --listFilesOnly` 和全新浏览器 context 复核,不能用已有 HMR 会话判绿。 +- 关联:`vite.config.ts`、`.eslintrc.cjs`、`src/services/platform-entry/`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 diff --git a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md index fa93e6cc3..c90d7e7e6 100644 --- a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md +++ b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md @@ -25,7 +25,7 @@ - `migration.rs` 中相关表的迁移白名单、表名兼容和字段目录。 - 为历史审计、迁移、资产归属核对所必需的最小只读表定义;不得借兼容读取重新暴露旧创作、发布、公开详情或运行接口。 - 编辑器、项目、账号、钱包、资产、HostBridge、运维和安全等平台公共能力。 -- 新版 `/creation` 创作工具主页、`creation-home` 展示组件与静态资产,以及桌面端“创作 / 项目 / 我的”公共侧边栏;“我的”只承载账号、钱包、统计和通用设置等平台能力,不恢复旧模板入口或旧作品架。 +- 新版 `/creation` 创作工具主页、`creation-home` 展示组件与静态资产,以及桌面端“创作 / 项目 / 我的”公共侧边栏;“我的”保留头像 / 昵称编辑、陶泥号复制、钱包与账单、统计、充值、兑换码、玩家社区、反馈、通用设置、开发者 API Key 和法律信息,不恢复旧模板入口、旧作品架或生成队列。 - 旧页面、测试、素材、handler、service、worker、生成 bindings 和纯业务 crate 的源码目录;它们仅用于历史追溯,不属于任何正式入口或编译目标。 ## 编译边界 @@ -35,6 +35,7 @@ - 主路由、Vite 入口和运行时动态 import 不得引用旧业务目录。 - 正式应用入口使用 `active-main.tsx`、`ActiveApp.tsx`、`routing/activeAppRoutes.tsx`、`routing/activeAppPageRoutes.ts`、`services/activeAppTitle.ts`、`platform-entry/PlatformEntryActiveFlowShell.tsx`、`platformEntryActiveTypes.ts` 和 `creation-home/`;原同名非 active 文件保持历史源码原貌并退出 Vite、TypeScript、ESLint 和 Vitest。 - 旧业务源码与素材保留在仓库中;Vite 不得再引用入口或动态 import,TypeScript、ESLint、Vitest 必须明确排除旧目录和专属测试。退役源码只用于历史追溯,不允许从在运代码重新导入。 +- 平台公共 profile 请求与展示模型必须位于 `services/platform-entry/` 和现役 `platform-entry` 文件,不得因为沿用账号、钱包或设置能力而继续 import `services/rpg-entry`、`services/rpg-runtime` 或 `components/rpg-entry`。Vite 对退役模块实行实际 module graph 门禁,命中即中止 dev/build;ESLint restricted imports 作为更早的源码反馈。 - 原 `main.tsx`、`App.tsx`、旧路由、旧标题映射、`PlatformEntryFlowShellImpl.tsx` 与旧入口类型保持原样;正式链路由 `active-main.tsx`、`ActiveApp.tsx`、`activeApp*`、`PlatformEntryActiveFlowShell.tsx` 和 `platformEntryActiveTypes.ts` 承载,只包含新版创作主页、项目、编辑器、账号、设置与钱包公共能力。`retired/legacy-creation-templates/frontend/original/` 另保留逐文件原样快照。 - 退役 CSS 的源码过滤必须在 Tailwind/Vite 转换前执行,产物过滤留在 `generateBundle`;禁止在 `post` transform 中把 Vite 已生成的 JavaScript 样式模块重新交给 PostCSS 解析。 - `/creation` 保持新版创作工具主页;生产网关对旧子路径返回 404,客户端若收到未知旧路径则回落当前平台公共首页,不再提供旧业务详情页或兼容壳。 @@ -51,7 +52,9 @@ ## 验收 - 旧 URL 不再命中旧页面或后端路由。 -- `/creation` 与 `/project` 显示“创作 / 项目 / 我的”公共侧边栏,新创作主页只调用编辑器项目和公开素材接口;顶栏保持公共泥点入口与账号胶囊,不重新拼装平行账号按钮组。 +- `/creation` 与 `/project` 显示“创作 / 项目 / 我的”公共侧边栏,新创作主页只调用编辑器项目和公开素材接口;顶栏保持现役搜索、公共泥点入口与账号胶囊,不重新拼装平行账号按钮组。 +- “我的”桌面布局按原平台公共资料页全宽展示四个常用入口、两行设置和法律栏;头像、昵称、复制、充值、兑换码、社区、反馈、API Key 等入口可用,但不发起旧模板、旧公开作品或旧运行态请求。 +- `tsc --listFilesOnly` 与 Vite 干净加载均不得出现 `src/components/rpg-entry/**`、`src/services/rpg-entry/**` 或 `src/services/rpg-runtime/**`。 - Vite 构建产物和依赖图不包含旧前端业务目录。 - `cargo tree` 中不存在纯模板 crate 或专属运行态 crate。 - `spacetime-module` 编译结果仍包含全部历史表,但不包含任何旧模板 reducer、procedure 和业务 view。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index f7772c268..1b613b134 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -2,9 +2,20 @@ > 2026-07-17 退役覆盖:全部旧创作模板的前端、API、worker、reducer/procedure、纯业务 crate、生成发布链路、公开业务详情和专属运行态已下线,仅保留相关历史表的数据壳与必要兼容读取。本文后续玩法章节只作为历史设计记录,不再描述当前可用能力;当前实现与验收以 `docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md` 为准。 -更新时间:`2026-06-10` +更新时间:`2026-07-18` -## 平台创作入口 +## 现役平台壳 + +旧创作模板退役后,桌面端继续保留统一平台壳,一级导航固定为 `创作 / 项目 / 我的`: + +- `/creation` 展示基于图片编辑器的创作工具主页,只读取编辑器项目与公开编辑器素材。 +- `/project` 展示当前账号的图片编辑器项目,项目卡继续进入 `/editor/canvas`。 +- “我的”不新增独立路由,切换后回到 `/`,保留头像与昵称编辑、陶泥号复制、泥点余额与账单、累计统计、泥点充值、兑换码、玩家社区、反馈与建议、通用设置、开发者 API Key 和法律信息等平台公共能力。 +- 桌面顶栏保留现役项目 / 素材搜索、泥点入口和账号胶囊。搜索只筛选当前编辑器项目与已读取的公开编辑器素材,不恢复旧公开作品号搜索、旧广场、旧作品详情或旧运行态。 + +现役入口和公共资料能力只能依赖 `creation-home`、`project`、`image-editor`、公共组件及 `services/platform-entry` 等现役模块。Vite 模块门禁会拒绝 `components/rpg-entry`、`services/rpg-entry`、旧玩法目录和旧平台业务模块进入依赖图;Tailwind `@source`、TypeScript `include`、ESLint ignore 或 Vite watch ignore 都不能替代这条运行时依赖门禁。 + +## 历史平台创作入口 创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理入口开关,通过 `/admin/api/creation-entry/config/interactions` 管理公开作品点赞 / 改造能力矩阵。前端只在展示层派生可见卡片、入口状态和作品详情互动状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。 diff --git a/scripts/vite-retired-css-plugin.test.ts b/scripts/vite-retired-css-plugin.test.ts index ef5fc1b1e..20f482cae 100644 --- a/scripts/vite-retired-css-plugin.test.ts +++ b/scripts/vite-retired-css-plugin.test.ts @@ -3,15 +3,17 @@ import { describe, expect, it } from 'vitest'; import viteConfig from '../vite.config'; -async function resolveRetiredCssPlugin() { +async function resolveVitePlugin(name: string) { const resolvedConfig = typeof viteConfig === 'function' ? await viteConfig({ command: 'serve', mode: 'test' }) : viteConfig; const plugins = (resolvedConfig.plugins ?? []).flat(Infinity) as Plugin[]; - return plugins.find( - (plugin) => plugin?.name === 'retired-creation-template-css', - ); + return plugins.find((plugin) => plugin?.name === name); +} + +function resolveRetiredCssPlugin() { + return resolveVitePlugin('retired-creation-template-css'); } describe('retired creation template CSS plugin', () => { @@ -43,3 +45,63 @@ describe('retired creation template CSS plugin', () => { expect(code).toContain('@source "./components/creation-home"'); }); }); + +describe('retired creation template module boundary plugin', () => { + it('rejects retired component and service modules from the active Vite graph', async () => { + const plugin = await resolveVitePlugin('retired-creation-template-modules'); + const transform = plugin?.transform; + + expect(plugin?.enforce).toBe('pre'); + expect(typeof transform).toBe('function'); + if (typeof transform !== 'function') { + return; + } + + expect(() => + transform.call( + {} as never, + 'export {}', + '/workspace/src/components/rpg-entry/RpgEntryHomeView.tsx', + ), + ).toThrow(/退役创作模板模块不得进入现役 Vite 依赖图/u); + expect(() => + transform.call( + {} as never, + 'export {}', + '/workspace/src/services/rpg-entry/rpgProfileClient.ts?t=1', + ), + ).toThrow(/退役创作模板模块不得进入现役 Vite 依赖图/u); + }); + + it('allows active creation, project, and profile modules', async () => { + const plugin = await resolveVitePlugin('retired-creation-template-modules'); + const transform = plugin?.transform; + + expect(typeof transform).toBe('function'); + if (typeof transform !== 'function') { + return; + } + + expect( + transform.call( + {} as never, + 'export {}', + '/workspace/src/components/creation-home/CreationLandingView.tsx', + ), + ).toBeNull(); + expect( + transform.call( + {} as never, + 'export {}', + '/workspace/src/components/platform-entry/PlatformActiveProfileView.tsx', + ), + ).toBeNull(); + expect( + transform.call( + {} as never, + 'export {}', + '/workspace/src/services/platform-entry/platformProfileClient.ts', + ), + ).toBeNull(); + }); +}); diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 1fa684dad..4f15e5210 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -166,9 +166,7 @@ describe('CreationLandingView', () => { expect( screen.getByText('陶泥儿 Genarrative|游戏美术 AI 创作工具'), ).toBeTruthy(); - const subtitle = screen.getByText( - /面向个人创作者的游戏美术 AI 工作台/u, - ); + const subtitle = screen.getByText(/面向个人创作者的游戏美术 AI 工作台/u); expect(subtitle.textContent).toContain('美术 Agent'); expect(subtitle.textContent).toContain('无限画布'); expect(subtitle.textContent).toContain('角色、场景、UI 与宣发素材'); @@ -222,6 +220,73 @@ describe('CreationLandingView', () => { expect(onOpenProject).toHaveBeenCalledWith('project-newest'); }); + it('filters recent projects by the shared search keyword', async () => { + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + + render( + + + , + ); + + expect(await screen.findByText('最新项目')).toBeTruthy(); + expect(screen.queryByText('旧项目')).toBeNull(); + }); + + it.each([ + ['森林角色', '森林角色', '海边场景'], + ['阿蓝', '海边场景', '森林角色'], + ['金色盔甲', '森林角色', '海边场景'], + ])( + 'filters featured assets by search keyword %s', + async (searchKeyword, expectedLabel, hiddenLabel) => { + listEditorProjectsMock.mockResolvedValueOnce([]); + listPublicEditorProjectResourcesMock.mockResolvedValueOnce([ + { + resourceId: 'forest-character', + projectId: 'project-newest', + label: '森林角色', + imageSrc: '/generated-editor-images/forest-character.png', + width: 512, + height: 512, + sourceType: 'generated', + prompt: '金色盔甲', + authorDisplayName: '阿绿', + publicShowcaseEnabled: true, + }, + { + resourceId: 'seaside-scene', + projectId: 'project-newest', + label: '海边场景', + imageSrc: '/generated-editor-images/seaside-scene.png', + width: 512, + height: 512, + sourceType: 'generated', + prompt: '夜晚月光', + authorDisplayName: '阿蓝', + publicShowcaseEnabled: true, + }, + ]); + + render( + + + , + ); + + expect(await screen.findByText(expectedLabel)).toBeTruthy(); + expect(screen.queryByText(hiddenLabel)).toBeNull(); + }, + ); + it('hides recent projects and opens login when an anonymous user starts creation', async () => { const user = userEvent.setup(); const openLoginModal = vi.fn(); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index c965d55df..9c829f3f6 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -38,6 +38,7 @@ type CreationLandingViewProps = { ) => void; onOpenProjects: () => void; onOpenCommunity?: () => void; + searchKeyword?: string; }; type CreationFeatureTool = @@ -424,9 +425,11 @@ export function CreationLandingView({ onOpenProject, onOpenProjects, onOpenCommunity, + searchKeyword = '', }: CreationLandingViewProps) { const authUi = useAuthUi(); const isAuthenticated = Boolean(authUi?.user); + const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase(); const [recentProjects, setRecentProjects] = useState( [], ); @@ -734,6 +737,24 @@ export function CreationLandingView({ }), [activeShowcaseTab, projectShowcaseResources, showcaseCampaign], ); + const visibleShowcaseItems = useMemo(() => { + if (!normalizedSearchKeyword) { + return showcaseItems; + } + return showcaseItems.filter((item) => + [item.label, item.author, item.prompt].some((value) => + value.toLocaleLowerCase().includes(normalizedSearchKeyword), + ), + ); + }, [normalizedSearchKeyword, showcaseItems]); + const visibleRecentProjects = useMemo(() => { + if (!normalizedSearchKeyword) { + return recentProjects; + } + return recentProjects.filter((project) => + project.title.toLocaleLowerCase().includes(normalizedSearchKeyword), + ); + }, [normalizedSearchKeyword, recentProjects]); const isShowcaseLoading = isLoadingShowcase; const showcaseEmptyText = '暂无素材'; @@ -772,13 +793,15 @@ export function CreationLandingView({ ); } - if (showcaseItems.length === 0) { + if (visibleShowcaseItems.length === 0) { return ( <> {showcaseNextCursor || isLoadingMoreShowcase ? '正在读取更多素材' - : showcaseEmptyText} + : normalizedSearchKeyword + ? '没有匹配素材' + : showcaseEmptyText} {renderShowcaseLoadMoreSentinel()} @@ -791,7 +814,7 @@ export function CreationLandingView({ className="creation-landing__asset-waterfall" aria-label="用户素材瀑布流" > - {showcaseItems.map((item) => { + {visibleShowcaseItems.map((item) => { const showcaseId = item.showcaseId?.trim(); const isLiked = Boolean( showcaseId && likedShowcaseIds.has(showcaseId), @@ -994,7 +1017,7 @@ export function CreationLandingView({ 正在读取项目 ) : ( - recentProjects.map((project) => { + visibleRecentProjects.map((project) => { const coverSnapshot = resolveProjectCoverSnapshotResource(project); return ( diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 5b40cb36a..d16cffc4a 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -111,8 +111,8 @@ const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn()); const loadFrontendRuntimeConfigMock = vi.hoisted(() => vi.fn()); -const getRpgProfileRechargeCenterMock = vi.hoisted(() => vi.fn()); -const getRpgProfileWalletLedgerMock = vi.hoisted(() => vi.fn()); +const getPlatformProfileRechargeCenterMock = vi.hoisted(() => vi.fn()); +const getPlatformProfileWalletLedgerMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< @@ -141,19 +141,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { vi.mock('../../services/platform-entry/platformProfileClient', () => ({ getPlatformProfileDashboard: getPlatformProfileDashboardMock, + getPlatformProfileRechargeCenter: getPlatformProfileRechargeCenterMock, + getPlatformProfileWalletLedger: getPlatformProfileWalletLedgerMock, })); -vi.mock('../../services/rpg-entry/rpgProfileClient', async () => { - const actual = await vi.importActual< - typeof import('../../services/rpg-entry/rpgProfileClient') - >('../../services/rpg-entry/rpgProfileClient'); - return { - ...actual, - getRpgProfileRechargeCenter: getRpgProfileRechargeCenterMock, - getRpgProfileWalletLedger: getRpgProfileWalletLedgerMock, - }; -}); - vi.mock('../../services/frontendRuntimeConfigService', () => ({ loadFrontendRuntimeConfig: loadFrontendRuntimeConfigMock, })); @@ -292,7 +283,7 @@ describe('ImageCanvasEditorView', () => { playedWorldCount: 0, updatedAt: null, }); - getRpgProfileRechargeCenterMock.mockResolvedValue({ + getPlatformProfileRechargeCenterMock.mockResolvedValue({ walletBalance: 1234, mudPointBalance: { totalPoints: 1234, @@ -321,7 +312,7 @@ describe('ImageCanvasEditorView', () => { latestOrder: null, hasPointsRecharged: false, }); - getRpgProfileWalletLedgerMock.mockResolvedValue({ + getPlatformProfileWalletLedgerMock.mockResolvedValue({ entries: [ { id: 'editor-ledger-1', @@ -341,8 +332,8 @@ describe('ImageCanvasEditorView', () => { deleteEditorAgentConversationMock.mockReset(); streamEditorAgentMessageMock.mockReset(); getPlatformProfileDashboardMock.mockReset(); - getRpgProfileRechargeCenterMock.mockReset(); - getRpgProfileWalletLedgerMock.mockReset(); + getPlatformProfileRechargeCenterMock.mockReset(); + getPlatformProfileWalletLedgerMock.mockReset(); loadFrontendRuntimeConfigMock.mockReset(); }); @@ -637,7 +628,7 @@ describe('ImageCanvasEditorView', () => { expect( await screen.findByRole('dialog', { name: '泥点账单' }), ).toBeTruthy(); - expect(getRpgProfileWalletLedgerMock).toHaveBeenCalledTimes(1); + expect(getPlatformProfileWalletLedgerMock).toHaveBeenCalledTimes(1); }); it('suspends canvas interaction during account payment dialogs and restores completed quick edit selections', async () => { diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx index b9d04417d..bd1049d3f 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx @@ -1,19 +1,46 @@ /* @vitest-environment jsdom */ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; import { PlatformActiveProfileView } from './PlatformActiveProfileView'; +const updateAuthProfileMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../services/authService', () => ({ + updateAuthProfile: updateAuthProfileMock, +})); + const callbacks = { onLogin: vi.fn(), - onOpenAccount: vi.fn(), + onOpenApiKeys: vi.fn(), + onOpenCommunity: vi.fn(), + onOpenFeedback: vi.fn(), onOpenRecharge: vi.fn(), + onOpenRewardCode: vi.fn(), onOpenSettings: vi.fn(), onOpenWalletLedger: vi.fn(), + onUserUpdated: vi.fn(), +}; + +const authenticatedUser: AuthUser = { + id: 'user-1', + publicUserCode: '100001', + displayName: '测试玩家', + avatarUrl: null, + phoneNumberMasked: null, + loginMethod: 'password' as const, + bindingStatus: 'active', + wechatBound: false, }; describe('PlatformActiveProfileView', () => { + beforeEach(() => { + Object.values(callbacks).forEach((callback) => callback.mockReset()); + updateAuthProfileMock.mockReset(); + }); + it('shows the login entry for guests', () => { render( { updatedAt: '2026-07-18T00:00:00.000Z', }} isLoadingDashboard={false} - user={{ - id: 'user-1', - publicUserCode: '100001', - displayName: '测试玩家', - avatarUrl: null, - phoneNumberMasked: null, - loginMethod: 'password', - bindingStatus: 'active', - wechatBound: false, - }} + user={authenticatedUser} />, ); expect(screen.getByText('测试玩家')).toBeTruthy(); - expect(screen.getByText('陶泥号:100001')).toBeTruthy(); + expect(screen.getByText(/陶泥号:\s*100001/u)).toBeTruthy(); expect(screen.getByRole('button', { name: '泥点余额 108' })).toBeTruthy(); expect(screen.getByRole('button', { name: /泥点充值/u })).toBeTruthy(); - expect( - screen.getAllByRole('button', { name: /账号与安全/u }), - ).toHaveLength(2); + expect(screen.getByRole('button', { name: /兑换码/u })).toBeTruthy(); + expect(screen.getByRole('button', { name: /玩家社区/u })).toBeTruthy(); + expect(screen.getByRole('button', { name: /反馈与建议/u })).toBeTruthy(); expect(screen.getByRole('button', { name: /通用设置/u })).toBeTruthy(); + expect( + screen.getByRole('button', { name: /开发者 API Key/u }), + ).toBeTruthy(); + }); + + it('edits the nickname through the profile identity action', async () => { + updateAuthProfileMock.mockResolvedValueOnce({ + ...authenticatedUser, + displayName: '新昵称', + }); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '修改昵称' })); + const input = screen.getByRole('textbox', { name: '新昵称' }); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + expect(screen.getByText('昵称需要 2 到 20 位')).toBeTruthy(); + expect(updateAuthProfileMock).not.toHaveBeenCalled(); + + fireEvent.change(input, { target: { value: '新昵称' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + expect(updateAuthProfileMock).toHaveBeenCalledWith({ + displayName: '新昵称', + }); + expect(callbacks.onUserUpdated).toHaveBeenCalledWith({ + ...authenticatedUser, + displayName: '新昵称', + }); + }); + }); + + it('keeps avatar validation on the direct camera action', () => { + render( + , + ); + + const avatarInput = screen + .getAllByLabelText('上传头像') + .find((element) => element instanceof HTMLInputElement); + expect(avatarInput).toBeTruthy(); + fireEvent.change(avatarInput as HTMLInputElement, { + target: { + files: [new File(['avatar'], 'avatar.txt', { type: 'text/plain' })], + }, + }); + + expect(screen.getByText('头像仅支持 jpg、png、webp')).toBeTruthy(); + expect(updateAuthProfileMock).not.toHaveBeenCalled(); }); }); diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index bbf03adef..ef8cf9d2b 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -1,29 +1,50 @@ import { + Camera, Coins, History, + KeyRound, + MessageCircle, + Pencil, Settings, - ShieldCheck, + Ticket, UserRound, } from 'lucide-react'; -import { useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import profileClockImage from '../../../media/profile/_Image (1).png'; import profileGamepadImage from '../../../media/profile/_Image (2).png'; import profileStillLifeImage from '../../../media/profile/_Image (3).png'; import profileCoinsImage from '../../../media/profile/_Image (4).png'; +import profileGiftImage from '../../../media/profile/_Image (6).png'; +import profileCommunityImage from '../../../media/profile/_Image (7).png'; +import profileFeedbackImage from '../../../media/profile/_Image (8).png'; import profileMascotImage from '../../../media/profile/_Image (9).png'; import profilePointImage from '../../../media/profile/_Image.png'; import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime'; +import { updateAuthProfile } from '../../services/authService'; +import { CopyFeedbackButton } from '../common/CopyFeedbackButton'; import { LegalDocumentModal } from '../common/LegalDocumentModal'; import { getLegalDocument, type LegalDocumentId, } from '../common/legalDocuments'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformIconButton } from '../common/PlatformIconButton'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { PlatformTextField } from '../common/PlatformTextField'; +import { SquareImageCropModal } from '../common/SquareImageCropModal'; +import { + buildCenteredSquareImageCropRect, + clampSquareImageCropRect, + type SquareImageCropRect, +} from '../common/squareImageCropModel'; +import { useCopyFeedback } from '../common/useCopyFeedback'; import { resolveActivePublicUserCode } from './platformActiveProfileModel'; +import { PlatformProfileModalShell } from './PlatformProfileModalShell'; import { ProfileLegalSection, + ProfileSettingsRow, ProfileShortcutButton, ProfileStatCard, ProfileStatCardSkeleton, @@ -33,13 +54,170 @@ type PlatformActiveProfileViewProps = { dashboard: ProfileDashboardSummary | null; isLoadingDashboard: boolean; onLogin: () => void; - onOpenAccount: () => void; + onOpenApiKeys: () => void; + onOpenCommunity: () => void; + onOpenFeedback: () => void; onOpenRecharge: () => void; + onOpenRewardCode: () => void; onOpenSettings: () => void; onOpenWalletLedger: () => void; + onUserUpdated: (user: AuthUser) => void; user: AuthUser | null | undefined; }; +const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024; +const AVATAR_OUTPUT_SIZE = 256; +const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); + +function validateProfileDisplayName(value: string) { + const normalized = value.trim(); + if (!normalized) { + return '请输入昵称'; + } + const length = Array.from(normalized).length; + if (length < 2 || length > 20) { + return '昵称需要 2 到 20 位'; + } + if (!/^[\u4e00-\u9fffa-zA-Z0-9_]+$/u.test(normalized)) { + return '昵称仅支持中文、英文、数字和下划线'; + } + + return null; +} + +function readImageIntrinsicSize(src: string) { + return new Promise<{ width: number; height: number }>((resolve, reject) => { + const image = new Image(); + image.onload = () => { + resolve({ + width: image.naturalWidth, + height: image.naturalHeight, + }); + }; + image.onerror = () => reject(new Error('图片读取失败')); + image.src = src; + }); +} + +function loadAvatarFile(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== 'string') { + reject(new Error('图片读取失败')); + return; + } + resolve(reader.result); + }; + reader.onerror = () => reject(new Error('图片读取失败')); + reader.readAsDataURL(file); + }); +} + +function cropAvatarImage(params: { + source: string; + cropX: number; + cropY: number; + cropSize: number; +}) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = AVATAR_OUTPUT_SIZE; + canvas.height = AVATAR_OUTPUT_SIZE; + const context = canvas.getContext('2d'); + if (!context) { + reject(new Error('头像裁剪失败')); + return; + } + + context.drawImage( + image, + params.cropX, + params.cropY, + params.cropSize, + params.cropSize, + 0, + 0, + AVATAR_OUTPUT_SIZE, + AVATAR_OUTPUT_SIZE, + ); + resolve(canvas.toDataURL('image/png')); + }; + image.onerror = () => reject(new Error('头像裁剪失败')); + image.src = params.source; + }); +} + +function ProfileNicknameModal({ + value, + error, + isSaving, + onChange, + onClose, + onSubmit, +}: { + value: string; + error: string | null; + isSaving: boolean; + onChange: (value: string) => void; + onClose: () => void; + onSubmit: () => void; +}) { + return ( + + + 取消 + + + {isSaving ? '保存中' : '保存'} + + + } + > + + {error ? ( + + {error} + + ) : null} + + ); +} + function formatDashboardCount(value: number) { return Math.max(0, Math.round(value)).toLocaleString('zh-CN'); } @@ -55,22 +233,154 @@ export function PlatformActiveProfileView({ dashboard, isLoadingDashboard, onLogin, - onOpenAccount, + onOpenApiKeys, + onOpenCommunity, + onOpenFeedback, onOpenRecharge, + onOpenRewardCode, onOpenSettings, onOpenWalletLedger, + onUserUpdated, user, }: PlatformActiveProfileViewProps) { const [activeLegalDocumentId, setActiveLegalDocumentId] = useState(null); + const { copyState, copyText } = useCopyFeedback(); + const avatarFileInputRef = useRef(null); + const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false); + const [nicknameInput, setNicknameInput] = useState(''); + const [nicknameError, setNicknameError] = useState(null); + const [isSavingNickname, setIsSavingNickname] = useState(false); + const [avatarSource, setAvatarSource] = useState(null); + const [avatarImageSize, setAvatarImageSize] = useState<{ + width: number; + height: number; + } | null>(null); + const [avatarCrop, setAvatarCrop] = useState({ + x: 0, + y: 0, + size: 1, + }); + const [avatarError, setAvatarError] = useState(null); + const [isSavingAvatar, setIsSavingAvatar] = useState(false); const activeLegalDocument = activeLegalDocumentId ? getLegalDocument(activeLegalDocumentId) : null; + const openNicknameModal = () => { + if (!user) { + onLogin(); + return; + } + setNicknameInput(user.displayName); + setNicknameError(null); + setIsNicknameModalOpen(true); + }; + const submitNickname = () => { + if (!user || isSavingNickname) { + return; + } + const validationError = validateProfileDisplayName(nicknameInput); + if (validationError) { + setNicknameError(validationError); + return; + } + + setIsSavingNickname(true); + setNicknameError(null); + void updateAuthProfile({ displayName: nicknameInput.trim() }) + .then((nextUser) => { + onUserUpdated(nextUser); + setIsNicknameModalOpen(false); + }) + .catch((error: unknown) => { + setNicknameError( + error instanceof Error ? error.message : '昵称保存失败', + ); + }) + .finally(() => setIsSavingNickname(false)); + }; + const openAvatarPicker = () => { + if (!user) { + onLogin(); + return; + } + setAvatarError(null); + avatarFileInputRef.current?.click(); + }; + const handleAvatarFileChange = (file: File | null) => { + if (avatarFileInputRef.current) { + avatarFileInputRef.current.value = ''; + } + if (!file) { + return; + } + if (!AVATAR_ALLOWED_TYPES.has(file.type)) { + setAvatarError('头像仅支持 jpg、png、webp'); + return; + } + if (file.size > AVATAR_MAX_FILE_SIZE) { + setAvatarError('头像图片不能超过 5MB'); + return; + } + + setAvatarError(null); + void loadAvatarFile(file) + .then(async (source) => { + const imageSize = await readImageIntrinsicSize(source); + setAvatarSource(source); + setAvatarImageSize(imageSize); + setAvatarCrop(buildCenteredSquareImageCropRect(imageSize)); + }) + .catch((error: unknown) => { + setAvatarError( + error instanceof Error ? error.message : '头像图片读取失败', + ); + }); + }; + const updateAvatarCrop = useCallback( + (nextCrop: SquareImageCropRect) => { + if (!avatarImageSize) { + return; + } + setAvatarCrop(clampSquareImageCropRect(avatarImageSize, nextCrop)); + }, + [avatarImageSize], + ); + const submitAvatar = () => { + if ( + !avatarSource || + !avatarImageSize || + avatarCrop.size <= 0 || + isSavingAvatar + ) { + return; + } + + setIsSavingAvatar(true); + setAvatarError(null); + void cropAvatarImage({ + source: avatarSource, + cropX: avatarCrop.x, + cropY: avatarCrop.y, + cropSize: avatarCrop.size, + }) + .then((avatarDataUrl) => updateAuthProfile({ avatarDataUrl })) + .then((nextUser) => { + onUserUpdated(nextUser); + setAvatarSource(null); + setAvatarImageSize(null); + }) + .catch((error: unknown) => { + setAvatarError(error instanceof Error ? error.message : '头像上传失败'); + }) + .finally(() => setIsSavingAvatar(false)); + }; + if (!user) { return (
@@ -108,7 +418,7 @@ export function PlatformActiveProfileView({ return (
@@ -122,9 +432,9 @@ export function PlatformActiveProfileView({
+ + handleAvatarFileChange(event.target.files?.[0] ?? null) + } + />
-
- {user.displayName} +
+
+ {user.displayName} +
+ } + onClick={openNicknameModal} + className="platform-profile-edit-button" + />
-
- 陶泥号:{publicUserCode} +
+ 陶泥号: {publicUserCode} + { + void copyText(publicUserCode); + }} + />
@@ -201,7 +541,10 @@ export function PlatformActiveProfileView({
-
+
+
+ + +
+ setActiveLegalDocumentId(null)} /> + {isNicknameModalOpen ? ( + { + setNicknameInput(value); + setNicknameError(null); + }} + onClose={() => setIsNicknameModalOpen(false)} + onSubmit={submitNickname} + /> + ) : null} + {avatarSource && avatarImageSize ? ( + { + setAvatarSource(null); + setAvatarImageSize(null); + setAvatarError(null); + }} + onSubmit={submitAvatar} + /> + ) : null} + {avatarError && !avatarSource ? ( +
+ {avatarError} +
+ ) : null}
); } diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index e0d256e83..3fec31e2e 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -20,13 +20,53 @@ vi.mock('../auth/AuthUiContext', () => ({ })); vi.mock('../creation-home/CreationLandingView', () => ({ - CreationLandingView: () => ( -
创作主页
+ CreationLandingView: ({ + onOpenProject, + onOpenProjects, + searchKeyword, + }: { + onOpenProject: ( + projectId: string, + options?: { guide?: boolean; tool?: string }, + ) => void; + onOpenProjects: () => void; + searchKeyword?: string; + }) => ( +
+ 创作主页 + + +
), })); vi.mock('../project/ProjectGalleryView', () => ({ - ProjectGalleryView: () =>
项目
, + ProjectGalleryView: ({ + onOpenProject, + searchKeyword, + }: { + onOpenProject: (projectId: string, options?: { guide?: boolean }) => void; + searchKeyword?: string; + }) => ( +
+ 项目 + +
+ ), })); vi.mock('../image-editor/ImageCanvasEditorView', () => ({ @@ -101,6 +141,17 @@ describe('PlatformEntryActiveFlowShell', () => { within(topbar as HTMLElement).queryByRole('button', { name: '设置' }), ).toBeNull(); + fireEvent.change( + screen.getByRole('searchbox', { name: '搜索项目和素材' }), + { target: { value: '角色' } }, + ); + fireEvent.click(screen.getByRole('button', { name: '搜索' })); + expect( + screen + .getByRole('main', { name: '陶泥儿创作主页' }) + .getAttribute('data-search'), + ).toBe('角色'); + fireEvent.click(within(navigation).getByRole('button', { name: '我的' })); expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy(); @@ -112,4 +163,94 @@ describe('PlatformEntryActiveFlowShell', () => { .getAttribute('aria-current'), ).toBe('page'); }); + + it('switches between creation and projects and passes search to the active page', async () => { + const setSelectionStage = vi.fn(); + const { rerender } = render( + , + ); + + const navigation = await screen.findByRole('navigation', { + name: '平台导航', + }); + fireEvent.click(within(navigation).getByRole('button', { name: '项目' })); + + expect(window.location.pathname).toBe('/project'); + expect(setSelectionStage).toHaveBeenCalledWith('project', { + path: '/project', + }); + + rerender( + , + ); + + const projectPage = await screen.findByRole('main', { name: '项目' }); + expect( + within(navigation) + .getByRole('button', { name: '项目' }) + .getAttribute('aria-current'), + ).toBe('page'); + expect( + within(navigation) + .getByRole('button', { name: '创作' }) + .getAttribute('aria-current'), + ).toBeNull(); + + fireEvent.change( + screen.getByRole('searchbox', { name: '搜索项目和素材' }), + { target: { value: '场景' } }, + ); + fireEvent.click(screen.getByRole('button', { name: '搜索' })); + expect(projectPage.getAttribute('data-search')).toBe('场景'); + + fireEvent.click(within(navigation).getByRole('button', { name: '创作' })); + expect(window.location.pathname).toBe('/creation'); + expect(setSelectionStage).toHaveBeenCalledWith('creation-home', { + path: '/creation', + }); + }); + + it('keeps guide and tool intent in editor navigation URLs', async () => { + const setSelectionStage = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: '打开引导项目' }), + ); + expect(`${window.location.pathname}${window.location.search}`).toBe( + '/editor/canvas?projectid=guide-project&guide=toolbar', + ); + expect(setSelectionStage).toHaveBeenCalledWith('image-editor', { + path: '/editor/canvas?projectid=guide-project&guide=toolbar', + }); + + window.history.replaceState(null, '', '/creation'); + setSelectionStage.mockClear(); + rerender( + , + ); + fireEvent.click( + await screen.findByRole('button', { name: '打开音乐工具' }), + ); + expect(`${window.location.pathname}${window.location.search}`).toBe( + '/editor/canvas?projectid=tool-project&tool=background-music', + ); + expect(setSelectionStage).toHaveBeenCalledWith('image-editor', { + path: '/editor/canvas?projectid=tool-project&tool=background-music', + }); + }); }); diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 6d9ec2a4a..020bc3ca2 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -1,10 +1,7 @@ -import { - FolderKanban, - Palette, - UserRound, -} from 'lucide-react'; +import { FolderKanban, Palette, Search, UserRound } from 'lucide-react'; import { type ComponentType, + type FormEvent, lazy, Suspense, useCallback, @@ -20,13 +17,17 @@ import { } from '../../routing/activeAppPageRoutes'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { useAuthUi } from '../auth/AuthUiContext'; +import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel'; import { resolveActivePublicUserCode, resolveActiveUserAvatarLabel, } from './platformActiveProfileModel'; import { PlatformActiveProfileView } from './PlatformActiveProfileView'; import type { PlatformEntryFlowShellProps } from './platformEntryActiveTypes'; +import { PlatformProfileApiKeysModal } from './PlatformProfileApiKeysModal'; import { PlatformProfileRechargeModal } from './PlatformProfileRechargeModal'; +import { PlatformProfileReferralModal } from './PlatformProfileReferralModal'; +import { PlatformProfileRewardCodeRedeemModal } from './PlatformProfileRewardCodeRedeemModal'; import { PlatformProfileWalletLedgerModal } from './PlatformProfileWalletLedgerModal'; import { PlatformRechargePaymentConfirmationMask, @@ -138,6 +139,9 @@ export function PlatformEntryFlowShellImpl({ ); const [isLoadingDashboard, setIsLoadingDashboard] = useState(false); const [isProfileStage, setIsProfileStage] = useState(false); + const [isApiKeysOpen, setIsApiKeysOpen] = useState(false); + const [searchInput, setSearchInput] = useState(''); + const [activeSearchKeyword, setActiveSearchKeyword] = useState(''); const refreshDashboard = useCallback(async () => { if (!authUi?.user || !authUi.canAccessProtectedData) { @@ -248,6 +252,17 @@ export function PlatformEntryFlowShellImpl({ } authUi?.openLoginModal(); }; + const openFeedback = () => { + window.open(FLOATING_FEEDBACK_FORM_URL, '_blank', 'noopener,noreferrer'); + }; + const submitSearch = (event: FormEvent) => { + event.preventDefault(); + const keyword = searchInput.trim(); + setActiveSearchKeyword(keyword); + if (isProfileStage) { + openCreation(); + } + }; return ( <> @@ -292,13 +307,41 @@ export function PlatformEntryFlowShellImpl({
- - {isProfileStage - ? '我的' - : isCreationStage - ? '创作工具' - : '项目'} - +
+
+ +
{isAuthenticated ? ( @@ -355,7 +398,7 @@ export function PlatformEntryFlowShellImpl({
{isProfileStage ? ( authUi?.openLoginModal()} - onOpenAccount={() => authUi?.openAccountModal()} - onOpenRecharge={openRecharge} - onOpenSettings={() => authUi?.openSettingsModal()} - onOpenWalletLedger={ - profileCenter.openWalletLedgerPanel + onOpenApiKeys={() => setIsApiKeysOpen(true)} + onOpenCommunity={() => + profileCenter.openProfilePopupPanel('community') } + onOpenFeedback={openFeedback} + onOpenRecharge={openRecharge} + onOpenRewardCode={profileCenter.openRewardCodeModal} + onOpenSettings={() => authUi?.openSettingsModal()} + onOpenWalletLedger={profileCenter.openWalletLedgerPanel} + onUserUpdated={(user) => authUi?.setCurrentUser(user)} /> ) : isCreationStage ? ( ) : ( }> - + )}
@@ -404,6 +455,36 @@ export function PlatformEntryFlowShellImpl({ onCloseNativePayment={profileCenter.closeNativeWechatPayment} /> ) : null} + {profileCenter.isRewardCodeOpen ? ( + profileCenter.setIsRewardCodeOpen(false)} + /> + ) : null} + {profileCenter.profilePopupPanel ? ( + + ) : null} + {isApiKeysOpen ? ( + setIsApiKeysOpen(false)} /> + ) : null} {profileCenter.isWalletLedgerOpen ? ( (null); const { copyState, copyText } = useCopyFeedback(); - const activeKeys = useMemo( - () => keys.filter(isActiveExternalApiKey), - [keys], + const activeKeys = useMemo(() => keys.filter(isActiveExternalApiKey), [keys]); + const shouldShowBlockingError = Boolean( + error && !isLoading && keys.length === 0, ); - const shouldShowBlockingError = Boolean(error && !isLoading && keys.length === 0); const loadKeys = useCallback(() => { setIsLoading(true); setError(null); - void listRpgProfileExternalApiKeys() + void listPlatformProfileExternalApiKeys() .then((response) => { setKeys(response.keys); }) .catch((loadError: unknown) => { - setError(loadError instanceof Error ? loadError.message : '读取 API Key 失败'); + setError( + loadError instanceof Error ? loadError.message : '读取 API Key 失败', + ); }) .finally(() => setIsLoading(false)); }, []); @@ -86,7 +96,7 @@ export function PlatformProfileApiKeysModal({ setIsCreating(true); setError(null); setCreatedKey(null); - void createRpgProfileExternalApiKey(nameInput) + void createPlatformProfileExternalApiKey(nameInput) .then((response) => { setCreatedKey(response); setKeys((current) => [ @@ -97,7 +107,9 @@ export function PlatformProfileApiKeysModal({ }) .catch((createError: unknown) => { setError( - createError instanceof Error ? createError.message : '创建 API Key 失败', + createError instanceof Error + ? createError.message + : '创建 API Key 失败', ); }) .finally(() => setIsCreating(false)); @@ -106,7 +118,7 @@ export function PlatformProfileApiKeysModal({ const revokeKey = useCallback((keyId: string) => { setRevokingKeyId(keyId); setError(null); - void revokeRpgProfileExternalApiKey(keyId) + void revokePlatformProfileExternalApiKey(keyId) .then((response) => { setKeys((current) => current.map((key) => @@ -119,7 +131,9 @@ export function PlatformProfileApiKeysModal({ }) .catch((revokeError: unknown) => { setError( - revokeError instanceof Error ? revokeError.message : '撤销 API Key 失败', + revokeError instanceof Error + ? revokeError.message + : '撤销 API Key 失败', ); }) .finally(() => setRevokingKeyId(null)); diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx index f5acb0023..9d6de2d84 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -14,7 +14,7 @@ import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; -import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel'; +import { formatPlatformRechargePrice } from './platformProfileFundsModel'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; import type { NativeWechatPaymentState } from './usePlatformProfileCenterController'; @@ -123,7 +123,7 @@ function RechargeProductCard({ interactive radius="sm" padding="none" - aria-label={`${formatMudPointCount(product.pointsAmount)}泥点${bonusLabel ? ` ${bonusLabel}` : ''} ${formatRechargePrice(product.priceCents)} 购买`} + aria-label={`${formatMudPointCount(product.pointsAmount)}泥点${bonusLabel ? ` ${bonusLabel}` : ''} ${formatPlatformRechargePrice(product.priceCents)} 购买`} className="platform-recharge-product-row platform-interactive-card relative grid min-h-[4.5rem] grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 px-3.5 py-3 text-left" >
@@ -148,7 +148,7 @@ function RechargeProductCard({
- {formatRechargePrice(product.priceCents)} + {formatPlatformRechargePrice(product.priceCents)} {submitting ? '处理中' : '购买'} @@ -194,7 +194,7 @@ function PlatformProfileWechatNativePaymentModal({ 支付金额
- {formatRechargePrice(nativePayment.amountCents)} + {formatPlatformRechargePrice(nativePayment.amountCents)}
剩余时间 diff --git a/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx b/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx index e02c9bb3a..2340b65ee 100644 --- a/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx +++ b/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx @@ -9,8 +9,10 @@ import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow'; import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList'; import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; -import { buildWalletLedgerPresentation } from '../rpg-entry/rpgEntryProfileFundsViewModel'; -import { formatPlatformWorldTime } from '../rpg-entry/rpgEntryWorldPresentation'; +import { + buildPlatformWalletLedgerPresentation, + formatPlatformProfileTime, +} from './platformProfileFundsModel'; import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell'; export type PlatformProfileWalletLedgerModalProps = { @@ -34,7 +36,7 @@ export function PlatformProfileWalletLedgerModal({ onClose, onRetry, }: PlatformProfileWalletLedgerModalProps) { - const walletLedgerPresentation = buildWalletLedgerPresentation( + const walletLedgerPresentation = buildPlatformWalletLedgerPresentation( ledger, fallbackBalance, ); @@ -116,7 +118,7 @@ export function PlatformProfileWalletLedgerModal({ {entry.sourceLabel}
- {formatPlatformWorldTime(entry.createdAt)} + {formatPlatformProfileTime(entry.createdAt)}
diff --git a/src/components/platform-entry/platformProfileFundsModel.ts b/src/components/platform-entry/platformProfileFundsModel.ts new file mode 100644 index 000000000..23a83a59d --- /dev/null +++ b/src/components/platform-entry/platformProfileFundsModel.ts @@ -0,0 +1,127 @@ +import type { + ProfileWalletLedgerEntry, + ProfileWalletLedgerResponse, +} from '../../../packages/shared/src/contracts/runtime'; + +const PLATFORM_WALLET_LEDGER_SOURCE_LABELS = { + new_user_registration_reward: '注册赠送', + points_recharge: '泥点充值', + invite_inviter_reward: '邀请奖励', + invite_invitee_reward: '填写邀请码奖励', + snapshot_sync: '账户同步', + membership_period_grant: '会员周期发放', + membership_period_reset: '会员周期重置', + daily_free_grant: '每日免费发放', + daily_free_reset: '每日免费重置', + asset_operation_consume: '资产操作消耗', + asset_operation_refund: '资产操作退回', + recharge_refund_recovery: '充值退款追回', + redeem_code_reward: '兑换码奖励', + puzzle_author_incentive_claim: '拼图作者奖励', + daily_task_reward: '每日任务奖励', +} satisfies Record; + +export type PlatformWalletLedgerEntryPresentation = { + amountLabel: string; + balanceLabel: string; + createdAt: string; + id: string; + isIncome: boolean; + sourceLabel: string; +}; + +export type PlatformWalletLedgerPresentation = { + balance: number; + balanceLabel: string; + entries: PlatformWalletLedgerEntryPresentation[]; +}; + +function getPlatformWalletLedgerSourceLabel( + sourceType: string | null | undefined, +) { + const normalizedSourceType = sourceType?.trim() ?? ''; + if (!normalizedSourceType) { + return '未知来源'; + } + + return ( + PLATFORM_WALLET_LEDGER_SOURCE_LABELS[ + normalizedSourceType as ProfileWalletLedgerEntry['sourceType'] + ] ?? normalizedSourceType + ); +} + +function buildPlatformWalletLedgerEntryPresentation( + entry: ProfileWalletLedgerEntry, +): PlatformWalletLedgerEntryPresentation { + return { + amountLabel: + entry.amountDelta > 0 ? `+${entry.amountDelta}` : `${entry.amountDelta}`, + balanceLabel: `余额 ${entry.balanceAfter}`, + createdAt: entry.createdAt, + id: entry.id, + isIncome: entry.amountDelta > 0, + sourceLabel: getPlatformWalletLedgerSourceLabel(entry.sourceType), + }; +} + +export function buildPlatformWalletLedgerPresentation( + ledger: ProfileWalletLedgerResponse | null, + fallbackBalance: number, +): PlatformWalletLedgerPresentation { + const entries = ledger?.entries ?? []; + const balance = entries[0]?.balanceAfter ?? fallbackBalance; + + return { + balance, + balanceLabel: `${balance}泥点`, + entries: entries.map(buildPlatformWalletLedgerEntryPresentation), + }; +} + +export function formatPlatformRechargePrice(priceCents: number) { + const yuan = priceCents / 100; + return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`; +} + +function parsePlatformProfileDate(value: string) { + const normalized = value.trim(); + const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u); + if (numericTimestamp?.[1]) { + const rawTimestamp = Number(numericTimestamp[1]); + if (Number.isFinite(rawTimestamp)) { + const absoluteTimestamp = Math.abs(rawTimestamp); + const timestampMs = + absoluteTimestamp >= 1_000_000_000_000_000 + ? rawTimestamp / 1000 + : absoluteTimestamp >= 1_000_000_000_000 + ? rawTimestamp + : absoluteTimestamp >= 1_000_000_000 + ? rawTimestamp * 1000 + : Number.NaN; + const date = new Date(timestampMs); + if (!Number.isNaN(date.getTime())) { + return date; + } + } + } + + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +} + +export function formatPlatformProfileTime(value: string | null) { + if (!value) { + return '未记录'; + } + + const date = parsePlatformProfileDate(value); + if (!date) { + return value; + } + + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index dafbd71c9..35e446970 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -6,15 +6,11 @@ import { type ProfileRechargeOrder, type ProfileRechargeProduct, type ProfileReferralInviteCenterResponse, - type ProfileTaskCenterResponse, type ProfileWalletLedgerResponse, type RedeemProfileRewardCodeResponse, type WechatNativePayment, } from '../../../packages/shared/src/contracts/runtime'; -import { - clearStoredAccessToken, - refreshStoredAccessToken, -} from '../../services/apiClient'; +import { clearStoredAccessToken } from '../../services/apiClient'; import { type AuthUser, startWechatBind } from '../../services/authService'; import { getHostRuntime, @@ -31,25 +27,20 @@ import { import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect'; import { requestWechatJsapiPayment } from '../../services/payment/wechatJsapiPayment'; import { - claimRpgProfileTaskReward, - confirmWechatRpgProfileRechargeOrder, - createRpgProfileRechargeOrder, - getRpgProfileRechargeCenter, - getRpgProfileReferralInviteCenter, - getRpgProfileTasks, - getRpgProfileWalletLedger, - redeemRpgProfileReferralInviteCode, - redeemRpgProfileRewardCode, - watchWechatRpgProfileRechargeOrder, -} from '../../services/rpg-entry/rpgProfileClient'; + confirmWechatPlatformProfileRechargeOrder, + createPlatformProfileRechargeOrder, + getPlatformProfileRechargeCenter, + getPlatformProfileReferralInviteCenter, + getPlatformProfileWalletLedger, + redeemPlatformProfileReferralInviteCode, + redeemPlatformProfileRewardCode, + watchWechatPlatformProfileRechargeOrder, +} from '../../services/platform-entry/platformProfileClient'; import { type CopyFeedbackState, useCopyFeedback, } from '../common/useCopyFeedback'; -const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000; -const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000; -const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000; const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; const WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS = [800, 1600] as const; const WECHAT_NATIVE_WATCH_RETRY_DELAY_MS = 1000; @@ -111,21 +102,11 @@ type UsePlatformProfileCenterControllerArgs = { activeTab: string; isAuthenticated: boolean; showRechargeEntry: boolean; - profileTaskRefreshKey?: number; onRechargeSuccess?: () => void | Promise; requestLogin: () => void; currentUser: AuthUser | null | undefined; }; -function getDelayUntilNextProfileTaskReset(nowMs = Date.now()) { - const shiftedNow = nowMs + PROFILE_TASK_BEIJING_OFFSET_MS; - const nextDayStart = - Math.floor(shiftedNow / PROFILE_TASK_DAY_MS) * PROFILE_TASK_DAY_MS + - PROFILE_TASK_DAY_MS; - const nextResetAt = nextDayStart - PROFILE_TASK_BEIJING_OFFSET_MS; - return Math.max(PROFILE_TASK_MIN_RESET_DELAY_MS, nextResetAt - nowMs); -} - function readProfileInviteCodeFromLocationSearch(search: string) { const params = new URLSearchParams(search); for (const key of PROFILE_INVITE_QUERY_KEYS) { @@ -222,7 +203,7 @@ function isWechatRechargeOrderTerminalForConfirmation( async function confirmWechatRechargeOrderUntilSettled( orderId: string, ): Promise { - let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); + let latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } @@ -230,14 +211,15 @@ async function confirmWechatRechargeOrderUntilSettled( for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) { await waitWechatPayConfirmDelay(delayMs); - latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); + latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } } try { - const streamedResponse = await watchWechatRpgProfileRechargeOrder(orderId); + const streamedResponse = + await watchWechatPlatformProfileRechargeOrder(orderId); return streamedResponse; } catch { return latestResponse; @@ -247,7 +229,7 @@ async function confirmWechatRechargeOrderUntilSettled( async function confirmWechatRechargeOrderQuickly( orderId: string, ): Promise { - let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); + let latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } @@ -255,7 +237,7 @@ async function confirmWechatRechargeOrderQuickly( for (const delayMs of WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS) { await waitWechatPayConfirmDelay(delayMs); - latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); + latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } @@ -314,7 +296,6 @@ export function usePlatformProfileCenterController({ activeTab, isAuthenticated, showRechargeEntry, - profileTaskRefreshKey = 0, onRechargeSuccess, requestLogin, currentUser, @@ -350,14 +331,6 @@ export function usePlatformProfileCenterController({ null, ); const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false); - const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false); - const [taskCenter, setTaskCenter] = - useState(null); - const [taskCenterError, setTaskCenterError] = useState(null); - const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false); - const taskCenterRequestIdRef = useRef(0); - const [claimingTaskId, setClaimingTaskId] = useState(null); - const [taskClaimSuccess, setTaskClaimSuccess] = useState(null); const [profilePopupPanel, setProfilePopupPanel] = useState(null); const [referralCenter, setReferralCenter] = @@ -410,7 +383,7 @@ export function usePlatformProfileCenterController({ const loadWalletLedger = useCallback(() => { setWalletLedgerError(null); setIsLoadingWalletLedger(true); - void getRpgProfileWalletLedger() + void getPlatformProfileWalletLedger() .then(setWalletLedger) .catch((error: unknown) => { setWalletLedger(null); @@ -429,7 +402,7 @@ export function usePlatformProfileCenterController({ const loadRechargeCenter = useCallback(() => { setRechargeError(null); setIsLoadingRechargeCenter(true); - void getRpgProfileRechargeCenter() + void getPlatformProfileRechargeCenter() .then(setRechargeCenter) .catch((error: unknown) => { setRechargeCenter(null); @@ -621,7 +594,7 @@ export function usePlatformProfileCenterController({ setRechargePaymentResult(null); setWechatRechargeOrderConfirmationState(null); setNativeWechatPayment(null); - void createRpgProfileRechargeOrder(product.productId, paymentChannel) + void createPlatformProfileRechargeOrder(product.productId, paymentChannel) .then(async (response) => { if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { pendingWechatRechargeOrderIdRef.current = response.order.orderId; @@ -833,9 +806,12 @@ export function usePlatformProfileCenterController({ const watchUntilSettled = async () => { while (!cancelled && Date.now() < expiresAtMs) { try { - const response = await watchWechatRpgProfileRechargeOrder(orderId, { - signal: abortController.signal, - }); + const response = await watchWechatPlatformProfileRechargeOrder( + orderId, + { + signal: abortController.signal, + }, + ); if ( cancelled || !response || @@ -951,90 +927,10 @@ export function usePlatformProfileCenterController({ wechatRechargeOrderConfirmationState, ]); - const loadTaskCenter = useCallback(() => { - const requestId = ++taskCenterRequestIdRef.current; - setTaskCenterError(null); - setIsLoadingTaskCenter(true); - void getRpgProfileTasks() - .then((center) => { - if (requestId === taskCenterRequestIdRef.current) { - setTaskCenter(center); - } - }) - .catch((error: unknown) => { - if (requestId !== taskCenterRequestIdRef.current) { - return; - } - setTaskCenter(null); - setTaskCenterError( - error instanceof Error ? error.message : '读取每日任务失败', - ); - }) - .finally(() => { - if (requestId === taskCenterRequestIdRef.current) { - setIsLoadingTaskCenter(false); - } - }); - }, []); - - useEffect(() => { - if (activeTab !== 'profile' || !isAuthenticated) { - taskCenterRequestIdRef.current += 1; - setTaskCenter(null); - setTaskCenterError(null); - return; - } - - loadTaskCenter(); - }, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]); - - useEffect(() => { - if (activeTab !== 'profile' || !isAuthenticated) { - return undefined; - } - - // 中文注释:每日任务重置依赖北京时间跨天与 access token 刷新,继续留在 controller 里集中托管。 - let cancelled = false; - let timer: number | null = null; - - const scheduleNextReset = () => { - if (cancelled) { - return; - } - timer = window.setTimeout(() => { - void refreshStoredAccessToken({ clearOnFailure: false }) - .catch(() => undefined) - .finally(() => { - if (cancelled) { - return; - } - loadTaskCenter(); - scheduleNextReset(); - }); - }, getDelayUntilNextProfileTaskReset()); - }; - - scheduleNextReset(); - return () => { - cancelled = true; - if (timer !== null) { - window.clearTimeout(timer); - } - }; - }, [activeTab, isAuthenticated, loadTaskCenter]); - - const openTaskCenterPanel = useCallback(() => { - setIsTaskCenterOpen(true); - setTaskClaimSuccess(null); - if (!taskCenter) { - loadTaskCenter(); - } - }, [loadTaskCenter, taskCenter]); - const loadReferralCenter = useCallback(() => { setIsLoadingReferral(true); setIsReferralCenterInitialized(false); - void getRpgProfileReferralInviteCenter() + void getPlatformProfileReferralInviteCenter() .then(setReferralCenter) .catch((error: unknown) => { setReferralCenter(null); @@ -1114,7 +1010,7 @@ export function usePlatformProfileCenterController({ setIsSubmittingReferralRedeem(true); setReferralError(null); setReferralSuccess(null); - void redeemRpgProfileReferralInviteCode(inviteCode) + void redeemPlatformProfileReferralInviteCode(inviteCode) .then((response) => { setReferralCenter(response.center); setReferralRedeemCode(''); @@ -1137,7 +1033,7 @@ export function usePlatformProfileCenterController({ setIsSubmittingRewardCode(true); setRewardCodeError(null); setRewardCodeSuccess(null); - void redeemRpgProfileRewardCode(rewardCodeInput) + void redeemPlatformProfileRewardCode(rewardCodeInput) .then((response: RedeemProfileRewardCodeResponse) => { setRewardCodeInput(''); setRewardCodeSuccess(`已到账 ${response.amountGranted} 泥点`); @@ -1149,57 +1045,26 @@ export function usePlatformProfileCenterController({ .finally(() => setIsSubmittingRewardCode(false)); }, [isSubmittingRewardCode, onRechargeSuccess, rewardCodeInput]); - const claimTaskReward = useCallback( - (taskId: string) => { - if (claimingTaskId) { - return; - } - - setClaimingTaskId(taskId); - setTaskCenterError(null); - setTaskClaimSuccess(null); - void claimRpgProfileTaskReward(taskId) - .then((response) => { - setTaskCenter(response.center); - setTaskClaimSuccess(`已领取 ${response.rewardPoints} 泥点`); - void onRechargeSuccess?.(); - }) - .catch((error: unknown) => { - setTaskCenterError( - error instanceof Error ? error.message : '领取任务奖励失败', - ); - }) - .finally(() => setClaimingTaskId(null)); - }, - [claimingTaskId, onRechargeSuccess], - ); - return { - claimTaskReward, - claimingTaskId, closeNativeWechatPayment, closeProfilePopupPanel, confirmNativeWechatPayment, inviteCopyState: inviteCopyState as CopyFeedbackState, isLoadingRechargeCenter, isLoadingReferral, - isLoadingTaskCenter, isLoadingWalletLedger, isRechargeOpen, isRewardCodeOpen, isSubmittingReferralRedeem, isSubmittingRewardCode, - isTaskCenterOpen, isWalletLedgerOpen, loadRechargeCenter, loadReferralCenter, - loadTaskCenter, loadWalletLedger, nativeWechatPayment, openProfilePopupPanel, openRechargeOrRewardCodeModal, openRewardCodeModal, - openTaskCenterPanel, openWalletLedgerPanel, profilePopupPanel, rechargeCenter, @@ -1214,7 +1079,6 @@ export function usePlatformProfileCenterController({ rewardCodeSuccess, setIsRechargeOpen, setIsRewardCodeOpen, - setIsTaskCenterOpen, setIsWalletLedgerOpen, setRechargePaymentResult, setReferralRedeemCode, @@ -1223,9 +1087,6 @@ export function usePlatformProfileCenterController({ submittingRechargeProductId, submitReferralRedeemCode, submitRewardCode, - taskCenter, - taskCenterError, - taskClaimSuccess, walletLedger, walletLedgerError, wechatRechargeOrderConfirmationState, diff --git a/src/components/project/ProjectGalleryView.test.tsx b/src/components/project/ProjectGalleryView.test.tsx index a118a2c76..dedd8d5b1 100644 --- a/src/components/project/ProjectGalleryView.test.tsx +++ b/src/components/project/ProjectGalleryView.test.tsx @@ -113,6 +113,27 @@ describe('ProjectGalleryView', () => { expect(onOpenProject).toHaveBeenCalledWith('editor-project-1'); }); + it('filters projects by title and renders the search empty state', async () => { + listEditorProjectsMock.mockResolvedValue(projectItems); + const { rerender } = renderProjectGalleryView({ + searchKeyword: '角色', + }); + + expect(await screen.findByText('角色设定板')).toBeTruthy(); + expect(screen.queryByText('场景草图')).toBeNull(); + + rerender( + , + ); + + expect(await screen.findByText('没有匹配项目')).toBeTruthy(); + expect(screen.queryByText('角色设定板')).toBeNull(); + expect(screen.queryByText('场景草图')).toBeNull(); + }); + it('uses the saved project cover snapshot resource as the project cover', async () => { listEditorProjectsMock.mockResolvedValueOnce([ { diff --git a/src/components/project/ProjectGalleryView.tsx b/src/components/project/ProjectGalleryView.tsx index 4d8286020..eb7da172e 100644 --- a/src/components/project/ProjectGalleryView.tsx +++ b/src/components/project/ProjectGalleryView.tsx @@ -38,6 +38,7 @@ import { type ProjectGalleryViewProps = { onOpenProject: (projectId: string, options?: { guide?: boolean }) => void; + searchKeyword?: string; }; type RenameDraft = { @@ -68,7 +69,10 @@ function formatProjectUpdatedAt(value: string) { }).format(date); } -export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) { +export function ProjectGalleryView({ + onOpenProject, + searchKeyword = '', +}: ProjectGalleryViewProps) { const authUi = useAuthUi(); const [projects, setProjects] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -88,6 +92,15 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) { const selectedCount = selectedProjectIds.size; const allSelected = projects.length > 0 && selectedProjectIds.size === projects.length; + const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase(); + const visibleProjects = useMemo(() => { + if (!normalizedSearchKeyword) { + return projects; + } + return projects.filter((project) => + project.title.toLocaleLowerCase().includes(normalizedSearchKeyword), + ); + }, [normalizedSearchKeyword, projects]); const localCoverUrlByProjectId = useMemo( () => @@ -252,7 +265,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) { const projectCards = useMemo( () => - projects.map((project) => { + visibleProjects.map((project) => { const selected = selectedProjectIds.has(project.projectId); const localCover = localCoverUrlByProjectId.get(project.projectId); const projectWithLocalCover = localCover @@ -377,9 +390,9 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) { isSelectionMode, localCoverUrlByProjectId, onOpenProject, - projects, selectedProjectIds, toggleProjectSelection, + visibleProjects, ], ); @@ -421,6 +434,10 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) { 正在读取项目 + ) : visibleProjects.length === 0 && normalizedSearchKeyword ? ( + + 没有匹配项目 + ) : projects.length === 0 ? ( { const nextVolume = clampVolume(settings.musicVolume); const nextPlatformTheme = normalizePlatformTheme(settings.platformTheme); @@ -133,7 +133,7 @@ export function useGameSettings(authenticatedUserId: string | null = null) { setIsPersistingSettings(true); setSettingsError(null); - void putRpgProfileSettings( + void putPlatformRuntimeSettings( { musicVolume, platformTheme, diff --git a/src/services/platform-entry/platformProfileClient.ts b/src/services/platform-entry/platformProfileClient.ts index 91380ae48..7164a8118 100644 --- a/src/services/platform-entry/platformProfileClient.ts +++ b/src/services/platform-entry/platformProfileClient.ts @@ -1,5 +1,364 @@ -/** - * 平台首页资料读取入口。 - * 复用 RPG profile 聚合出口,避免平台入口和 RPG 入口测试、鉴权包装出现两套读取口径。 - */ -export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry'; +import type { + ConfirmWechatProfileRechargeOrderResponse, + CreateProfileRechargeOrderResponse, + ExternalApiKeyCreateResponse, + ExternalApiKeyListResponse, + ExternalApiKeyMutationResponse, + ProfileDashboardSummary, + ProfileRechargeCenterResponse, + ProfileRechargeOrder, + ProfileReferralInviteCenterResponse, + ProfileWalletLedgerResponse, + RedeemProfileReferralInviteCodeResponse, + RedeemProfileRewardCodeResponse, +} from '../../../packages/shared/src/contracts/runtime'; +import { + appendApiErrorRequestId, + parseApiErrorMessage, +} from '../../../packages/shared/src/http'; +import { + type ApiAuthImpact, + type ApiRetryOptions, + fetchWithApiAuth, + requestJson, +} from '../apiClient'; +import { readSseJsonStream } from '../sseStream'; + +const PLATFORM_PROFILE_API_BASE = '/api/profile'; +const PLATFORM_PROFILE_READ_RETRY: ApiRetryOptions = { + maxRetries: 1, + baseDelayMs: 180, + maxDelayMs: 480, +}; +const PLATFORM_PROFILE_WRITE_RETRY: ApiRetryOptions = { + maxRetries: 1, + baseDelayMs: 240, + maxDelayMs: 640, + retryUnsafeMethods: true, +}; + +export type PlatformProfileRequestOptions = { + signal?: AbortSignal; + retry?: ApiRetryOptions; + skipAuth?: boolean; + skipRefresh?: boolean; + authImpact?: ApiAuthImpact; + notifyAuthStateChange?: boolean; + clearAuthOnUnauthorized?: boolean; +}; + +function requestPlatformProfileJson( + path: string, + init: RequestInit, + fallbackMessage: string, + options: PlatformProfileRequestOptions = {}, +) { + const method = (init.method ?? 'GET').toUpperCase(); + const retry = + options.retry ?? + (method === 'GET' + ? PLATFORM_PROFILE_READ_RETRY + : PLATFORM_PROFILE_WRITE_RETRY); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + + return requestJson( + `${PLATFORM_PROFILE_API_BASE}${normalizedPath}`, + { + ...init, + signal: options.signal, + }, + fallbackMessage, + { + retry, + skipAuth: options.skipAuth, + skipRefresh: options.skipRefresh, + authImpact: options.authImpact, + notifyAuthStateChange: options.notifyAuthStateChange, + clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + }, + ); +} + +export function getPlatformProfileDashboard( + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/dashboard', + { method: 'GET' }, + '读取个人看板失败', + options, + ); +} + +export function getPlatformProfileWalletLedger( + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/wallet-ledger', + { method: 'GET' }, + '读取资产流水失败', + options, + ); +} + +export function listPlatformProfileExternalApiKeys( + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/api-keys', + { method: 'GET' }, + '读取 API Key 失败', + options, + ); +} + +export function createPlatformProfileExternalApiKey( + name: string, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/api-keys', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }, + '创建 API Key 失败', + options, + ); +} + +export function revokePlatformProfileExternalApiKey( + keyId: string, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + `/api-keys/${encodeURIComponent(keyId)}`, + { method: 'DELETE' }, + '撤销 API Key 失败', + options, + ); +} + +export function getPlatformProfileRechargeCenter( + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/recharge-center', + { method: 'GET' }, + '读取泥点购买信息失败', + options, + ); +} + +export function createPlatformProfileRechargeOrder( + productId: string, + paymentChannel: string, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/recharge/orders', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ productId, paymentChannel }), + }, + '充值失败', + options, + ); +} + +export function confirmWechatPlatformProfileRechargeOrder( + orderId: string, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + `/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`, + { method: 'POST' }, + '确认微信支付订单失败', + options, + ); +} + +type PlatformProfileRechargeOrderSseEvent = + | { + type: 'order'; + payload: ConfirmWechatProfileRechargeOrderResponse; + } + | { + type: 'done'; + payload: { orderId: string; status: string }; + } + | { + type: 'error'; + payload: { message: string }; + }; + +function normalizePlatformProfileRechargeOrderSseEvent( + eventName: string, + parsed: Record, +): PlatformProfileRechargeOrderSseEvent | null { + if (eventName === 'order' && parsed.order && parsed.center) { + return { + type: 'order', + payload: parsed as ConfirmWechatProfileRechargeOrderResponse, + }; + } + + if (eventName === 'done') { + const orderId = + typeof parsed.orderId === 'string' ? parsed.orderId.trim() : ''; + const status = + typeof parsed.status === 'string' ? parsed.status.trim() : ''; + if (orderId && status) { + return { + type: 'done', + payload: { orderId, status }, + }; + } + } + + if (eventName === 'error') { + const message = + typeof parsed.message === 'string' && parsed.message.trim() + ? parsed.message.trim() + : ''; + return { + type: 'error', + payload: { message }, + }; + } + + return null; +} + +function isPlatformProfileRechargeOrderTerminal( + order: Pick, +) { + if (order.status === 'pending') { + return false; + } + if (order.status === 'expired' && !order.expirationCheckedAt) { + return false; + } + return true; +} + +export async function watchWechatPlatformProfileRechargeOrder( + orderId: string, + options: PlatformProfileRequestOptions = {}, +): Promise { + const response = await fetchWithApiAuth( + `${PLATFORM_PROFILE_API_BASE}/recharge/orders/${encodeURIComponent(orderId)}/wechat/events`, + { + method: 'GET', + headers: { Accept: 'text/event-stream' }, + signal: options.signal, + }, + { + skipRefresh: options.skipRefresh, + skipAuth: options.skipAuth, + authImpact: options.authImpact, + notifyAuthStateChange: options.notifyAuthStateChange, + clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + }, + ); + + if (!response.ok) { + const responseText = await response.text(); + throw new Error( + appendApiErrorRequestId( + parseApiErrorMessage(responseText, '订阅充值订单状态失败'), + response.headers.get('x-request-id'), + ), + ); + } + + if (!response.body) { + throw new Error('streaming response body is unavailable'); + } + + let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null; + let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null; + + await readSseJsonStream(response, ({ eventName, parsed }) => { + const normalized = normalizePlatformProfileRechargeOrderSseEvent( + eventName, + parsed, + ); + if (!normalized) { + return; + } + + if (normalized.type === 'order') { + lastResponse = normalized.payload; + if (isPlatformProfileRechargeOrderTerminal(normalized.payload.order)) { + finalResponse = normalized.payload; + return false; + } + return; + } + + if (normalized.type === 'done') { + if ( + !finalResponse && + lastResponse && + isPlatformProfileRechargeOrderTerminal(lastResponse.order) + ) { + finalResponse = lastResponse; + } + return false; + } + + throw new Error(normalized.payload.message || '订阅充值订单状态失败'); + }); + + if (!finalResponse) { + throw new Error('充值订单状态流返回不完整'); + } + + return finalResponse; +} + +export function getPlatformProfileReferralInviteCenter( + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/referrals/invite-center', + { method: 'GET' }, + '读取邀请码失败', + options, + ); +} + +export function redeemPlatformProfileReferralInviteCode( + inviteCode: string, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/referrals/redeem-code', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inviteCode }), + }, + '填写邀请码失败', + options, + ); +} + +export function redeemPlatformProfileRewardCode( + code: string, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/redeem-codes/redeem', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }, + '兑换失败', + options, + ); +} diff --git a/src/services/platform-entry/platformSettingsClient.ts b/src/services/platform-entry/platformSettingsClient.ts new file mode 100644 index 000000000..49979d7a6 --- /dev/null +++ b/src/services/platform-entry/platformSettingsClient.ts @@ -0,0 +1,59 @@ +import type { RuntimeSettings } from '../../../packages/shared/src/contracts/runtime'; +import { + type ApiRequestOptions, + type ApiRetryOptions, + requestJson, +} from '../apiClient'; + +const PLATFORM_SETTINGS_API_PATH = '/api/runtime/settings'; +const PLATFORM_SETTINGS_READ_RETRY: ApiRetryOptions = { + maxRetries: 1, + baseDelayMs: 180, + maxDelayMs: 480, +}; +const PLATFORM_SETTINGS_WRITE_RETRY: ApiRetryOptions = { + maxRetries: 1, + baseDelayMs: 240, + maxDelayMs: 640, + retryUnsafeMethods: true, +}; + +type PlatformSettingsRequestOptions = ApiRequestOptions & { + signal?: AbortSignal; +}; + +export function getPlatformRuntimeSettings( + options: PlatformSettingsRequestOptions = {}, +) { + const { signal, ...requestOptions } = options; + return requestJson( + PLATFORM_SETTINGS_API_PATH, + { method: 'GET', signal }, + '读取设置失败', + { + ...requestOptions, + retry: requestOptions.retry ?? PLATFORM_SETTINGS_READ_RETRY, + }, + ); +} + +export function putPlatformRuntimeSettings( + settings: RuntimeSettings, + options: PlatformSettingsRequestOptions = {}, +) { + const { signal, ...requestOptions } = options; + return requestJson( + PLATFORM_SETTINGS_API_PATH, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + signal, + }, + '保存设置失败', + { + ...requestOptions, + retry: requestOptions.retry ?? PLATFORM_SETTINGS_WRITE_RETRY, + }, + ); +} diff --git a/vite.config.ts b/vite.config.ts index 9fb329b3a..b05da0b06 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,87 @@ import { defineConfig, loadEnv, type Plugin } from 'vite'; const RETIRED_TEMPLATE_CSS_PATTERN = /(?:baby-object|bark-battle|big-fish|child-motion|creation-(?:agent|work)|creative-agent|custom-world|jump-hop|match3d|platform-recommend|platform-work-detail|public-work|puzzle|rpg|square-hole|unified-creation|visual-novel|wooden-fish)/iu; +const RETIRED_COMPONENT_MODULE_DIRECTORIES = new Set([ + 'asset-studio', + 'bark-battle-creation', + 'big-fish-creation', + 'big-fish-result', + 'big-fish-runtime', + 'child-motion-demo', + 'creation-agent', + 'creative-agent', + 'custom-world-agent', + 'custom-world-home', + 'edutainment-creation', + 'edutainment-result', + 'edutainment-runtime', + 'game-canvas', + 'jump-hop-result', + 'jump-hop-runtime', + 'match3d-result', + 'match3d-runtime', + 'puzzle-clear-creation', + 'puzzle-clear-result', + 'puzzle-clear-runtime', + 'puzzle-gallery', + 'puzzle-result', + 'puzzle-runtime', + 'rpg-creation-asset-studio', + 'rpg-creation-editor', + 'rpg-creation-result', + 'rpg-entry', + 'rpg-runtime-panels', + 'rpg-runtime-shell', + 'square-hole-creation', + 'square-hole-result', + 'square-hole-runtime', + 'unified-creation', + 'visual-novel-creation', + 'visual-novel-result', + 'visual-novel-runtime', + 'wooden-fish-result', + 'wooden-fish-runtime', +]); + +const RETIRED_SERVICE_MODULE_DIRECTORIES = new Set([ + 'bark-battle-creation', + 'bark-battle-runtime', + 'big-fish-creation', + 'big-fish-gallery', + 'big-fish-runtime', + 'big-fish-works', + 'child-motion-demo', + 'creation-agent', + 'creation-audio', + 'creative-agent', + 'edutainment-baby-drawing', + 'edutainment-baby-object', + 'jump-hop', + 'match3d-creation', + 'match3d-runtime', + 'match3d-works', + 'puzzle-agent', + 'puzzle-clear', + 'puzzle-gallery', + 'puzzle-onboarding', + 'puzzle-runtime', + 'puzzle-works', + 'rpg-creation', + 'rpg-entry', + 'rpg-runtime', + 'square-hole-creation', + 'square-hole-runtime', + 'square-hole-works', + 'storyEngine', + 'visual-novel-creation', + 'visual-novel-runtime', + 'visual-novel-works', + 'wooden-fish', +]); + +const RETIRED_PLATFORM_ENTRY_MODULE_PATTERN = + /\/(?:Platform(?:Draft|EntryCreation|EntryFlowShellImpl|EntryHome|EntryWorld|Error|MobileHome|Profile(?:Generation|Played|Qr|Task)|Task|Work)|barkBattle|platform(?:Creation|Dialog|Draft|Edutainment|EntryCreation|External|Generation|Host|MiniGame|Played|Public|Puzzle|Recommend|Rpg|Selection)|puzzleDraft|usePlatform(?:Creation|Entry))[^/]*\.(?:ts|tsx)$/u; + const ACTIVE_TAILWIND_SOURCES = `@import 'tailwindcss' source(none); @source "./active-main.tsx"; @source "./ActiveApp.tsx"; @@ -22,8 +103,11 @@ const ACTIVE_TAILWIND_SOURCES = `@import 'tailwindcss' source(none); @source "./components/platform-entry/PlatformEntryActiveFlowShell.tsx"; @source "./components/platform-entry/PlatformActiveProfileView.tsx"; @source "./components/platform-entry/PlatformProfilePrimitives.tsx"; +@source "./components/platform-entry/PlatformProfileApiKeysModal.tsx"; @source "./components/platform-entry/PlatformProfileModalShell.tsx"; @source "./components/platform-entry/PlatformProfileRechargeModal.tsx"; +@source "./components/platform-entry/PlatformProfileReferralModal.tsx"; +@source "./components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx"; @source "./components/platform-entry/PlatformProfileWalletLedgerModal.tsx"; @source "./components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx"; @source "./editor"; @@ -55,6 +139,66 @@ const RETIRED_PUBLIC_ASSET_PATHS = [ '/wooden-fish', ] as const; +function normalizeViteModuleId(id: string) { + return id.split('?', 1)[0]?.replaceAll('\\', '/') ?? id; +} + +function readTopLevelSourceDirectory( + normalizedId: string, + sourceKind: 'components' | 'services', +) { + const marker = `/src/${sourceKind}/`; + const markerIndex = normalizedId.lastIndexOf(marker); + if (markerIndex < 0) { + return null; + } + return ( + normalizedId.slice(markerIndex + marker.length).split('/', 1)[0] ?? null + ); +} + +function isRetiredFrontendModuleId(id: string) { + const normalizedId = normalizeViteModuleId(id); + const componentDirectory = readTopLevelSourceDirectory( + normalizedId, + 'components', + ); + if ( + componentDirectory && + RETIRED_COMPONENT_MODULE_DIRECTORIES.has(componentDirectory) + ) { + return true; + } + + const serviceDirectory = readTopLevelSourceDirectory( + normalizedId, + 'services', + ); + if ( + serviceDirectory && + RETIRED_SERVICE_MODULE_DIRECTORIES.has(serviceDirectory) + ) { + return true; + } + + return RETIRED_PLATFORM_ENTRY_MODULE_PATTERN.test(normalizedId); +} + +function retiredCreationTemplateModulesPlugin(): Plugin { + return { + name: 'retired-creation-template-modules', + enforce: 'pre', + transform(_code, id) { + if (!isRetiredFrontendModuleId(id)) { + return null; + } + throw new Error( + `退役创作模板模块不得进入现役 Vite 依赖图:${normalizeViteModuleId(id)}`, + ); + }, + }; +} + function isRetiredPublicAssetPath(pathname: string) { return RETIRED_PUBLIC_ASSET_PATHS.some( (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), @@ -189,6 +333,7 @@ export default defineConfig(({ mode }) => { '**/src/components/match3d-result/**', '**/src/components/match3d-runtime/**', '**/src/components/puzzle-*/**', + '**/src/components/rpg-entry/**', '**/src/components/rpg-creation-*/**', '**/src/components/rpg-runtime-*/**', '**/src/components/square-hole-*/**', @@ -211,7 +356,9 @@ export default defineConfig(({ mode }) => { '**/src/services/match3dGeneratedModelCache*', '**/src/services/match3dSpritesheetParser*', '**/src/services/puzzle-*/**', + '**/src/services/rpg-entry/**', '**/src/services/rpg-creation/**', + '**/src/services/rpg-runtime/**', '**/src/services/square-hole-*/**', '**/src/services/visual-novel-*/**', '**/src/services/wooden-fish/**', @@ -240,6 +387,7 @@ export default defineConfig(({ mode }) => { root: __dirname, envDir: __dirname, plugins: [ + retiredCreationTemplateModulesPlugin(), retiredCreationTemplateCssPlugin(), react(), tailwindcss(),