恢复平台个人页并阻断旧业务回流

恢复创作、项目、我的平台侧栏与现役搜索
恢复全宽个人页及资料编辑、充值兑换、社区反馈和 API Key 入口
迁移平台资料、钱包和设置客户端并移除旧创作依赖
新增 Vite 与 ESLint 退役模块门禁并补齐界面和搜索测试
同步旧创作退役方案、平台入口决策与踩坑记录
This commit is contained in:
2026-07-18 17:29:06 +08:00
parent f3ab2f2ec0
commit 9c9c8f468a
24 changed files with 1853 additions and 309 deletions
+65 -7
View File
@@ -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/**',
@@ -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 门禁及旧业务退役方案。
@@ -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 modelVite `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`
@@ -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 不得再引用入口或动态 importTypeScript、ESLint、Vitest 必须明确排除旧目录和专属测试。退役源码只用于历史追溯,不允许从在运代码重新导入。
- 平台公共 profile 请求与展示模型必须位于 `services/platform-entry/` 和现役 `platform-entry` 文件,不得因为沿用账号、钱包或设置能力而继续 import `services/rpg-entry``services/rpg-runtime``components/rpg-entry`。Vite 对退役模块实行实际 module graph 门禁,命中即中止 dev/buildESLint 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。
@@ -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` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
+66 -4
View File
@@ -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();
});
});
@@ -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(
<AuthUiContext.Provider value={createAuthValue()}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
searchKeyword="最新"
/>
</AuthUiContext.Provider>,
);
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(
<AuthUiContext.Provider value={createAuthValue()}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
searchKeyword={searchKeyword}
/>
</AuthUiContext.Provider>,
);
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();
@@ -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<EditorProjectSnapshot[]>(
[],
);
@@ -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({
</PlatformEmptyState>
);
}
if (showcaseItems.length === 0) {
if (visibleShowcaseItems.length === 0) {
return (
<>
<PlatformEmptyState surface="subpanel" size="inline">
{showcaseNextCursor || isLoadingMoreShowcase
? '正在读取更多素材'
: showcaseEmptyText}
: normalizedSearchKeyword
? '没有匹配素材'
: showcaseEmptyText}
</PlatformEmptyState>
{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({
</PlatformEmptyState>
) : (
recentProjects.map((project) => {
visibleRecentProjects.map((project) => {
const coverSnapshot =
resolveProjectCoverSnapshotResource(project);
return (
@@ -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 () => {
@@ -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(
<PlatformActiveProfileView
@@ -40,26 +67,80 @@ describe('PlatformActiveProfileView', () => {
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(
<PlatformActiveProfileView
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
user={authenticatedUser}
/>,
);
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(
<PlatformActiveProfileView
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
user={authenticatedUser}
/>,
);
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();
});
});
File diff suppressed because it is too large Load Diff
@@ -20,13 +20,53 @@ vi.mock('../auth/AuthUiContext', () => ({
}));
vi.mock('../creation-home/CreationLandingView', () => ({
CreationLandingView: () => (
<main aria-label="陶泥儿创作主页"></main>
CreationLandingView: ({
onOpenProject,
onOpenProjects,
searchKeyword,
}: {
onOpenProject: (
projectId: string,
options?: { guide?: boolean; tool?: string },
) => void;
onOpenProjects: () => void;
searchKeyword?: string;
}) => (
<main aria-label="陶泥儿创作主页" data-search={searchKeyword}>
<button type="button" onClick={onOpenProjects}>
</button>
<button
type="button"
onClick={() =>
onOpenProject('tool-project', { tool: 'background-music' })
}
>
</button>
</main>
),
}));
vi.mock('../project/ProjectGalleryView', () => ({
ProjectGalleryView: () => <main aria-label="项目"></main>,
ProjectGalleryView: ({
onOpenProject,
searchKeyword,
}: {
onOpenProject: (projectId: string, options?: { guide?: boolean }) => void;
searchKeyword?: string;
}) => (
<main aria-label="项目" data-search={searchKeyword}>
<button
type="button"
onClick={() => onOpenProject('guide-project', { guide: true })}
>
</button>
</main>
),
}));
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(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={setSelectionStage}
/>,
);
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(
<PlatformEntryFlowShellImpl
selectionStage="project"
setSelectionStage={setSelectionStage}
/>,
);
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(
<PlatformEntryFlowShellImpl
selectionStage="project"
setSelectionStage={setSelectionStage}
/>,
);
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(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={setSelectionStage}
/>,
);
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',
});
});
});
@@ -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<HTMLFormElement>) => {
event.preventDefault();
const keyword = searchInput.trim();
setActiveSearchKeyword(keyword);
if (isProfileStage) {
openCreation();
}
};
return (
<>
@@ -292,13 +307,41 @@ export function PlatformEntryFlowShellImpl({
<div className="min-w-0 lg:hidden">
<ActivePlatformBrand />
</div>
<span className="hidden truncate text-base font-black text-[var(--platform-text-strong)] lg:block">
{isProfileStage
? '我的'
: isCreationStage
? '创作工具'
: '项目'}
</span>
<div className="platform-desktop-topbar__brand-search hidden min-w-0 flex-1 items-center lg:flex">
<form
className="platform-desktop-search flex w-full max-w-[34rem] items-center gap-2 px-3 py-2"
role="search"
onSubmit={submitSearch}
>
<img
src="/creation-home/topbar-search.png"
alt=""
aria-hidden="true"
className="creation-home-topbar-art creation-home-topbar-art--search"
/>
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
<input
type="search"
value={searchInput}
placeholder="搜索项目、素材、作者或描述"
aria-label="搜索项目和素材"
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
onChange={(event) => {
const value = event.target.value;
setSearchInput(value);
if (!value.trim()) {
setActiveSearchKeyword('');
}
}}
/>
<button
type="submit"
className="shrink-0 text-sm font-semibold text-[var(--platform-text-base)]"
>
</button>
</form>
</div>
<div className="platform-desktop-topbar__actions flex shrink-0 items-center gap-3">
{isAuthenticated ? (
@@ -355,7 +398,7 @@ export function PlatformEntryFlowShellImpl({
<main
id={isCreationStage ? 'platform-tab-panel-create' : undefined}
className={`platform-tab-panel min-h-0 min-w-0 flex-1 overflow-auto ${isCreationStage ? '' : 'px-3 py-4 sm:px-6 sm:py-6'}`}
className={`platform-tab-panel min-h-0 min-w-0 flex-1 overflow-auto ${isCreationStage || isProfileStage ? '' : 'px-3 py-4 sm:px-6 sm:py-6'}`}
>
{isProfileStage ? (
<PlatformActiveProfileView
@@ -363,12 +406,16 @@ export function PlatformEntryFlowShellImpl({
isLoadingDashboard={isLoadingDashboard}
user={authUi?.user}
onLogin={() => 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 ? (
<Suspense
@@ -377,11 +424,15 @@ export function PlatformEntryFlowShellImpl({
<CreationLandingView
onOpenProject={openEditorProject}
onOpenProjects={openProjects}
searchKeyword={activeSearchKeyword}
/>
</Suspense>
) : (
<Suspense fallback={<LoadingPanel label="正在加载项目..." />}>
<ProjectGalleryView onOpenProject={openEditorProject} />
<ProjectGalleryView
onOpenProject={openEditorProject}
searchKeyword={activeSearchKeyword}
/>
</Suspense>
)}
</main>
@@ -404,6 +455,36 @@ export function PlatformEntryFlowShellImpl({
onCloseNativePayment={profileCenter.closeNativeWechatPayment}
/>
) : null}
{profileCenter.isRewardCodeOpen ? (
<PlatformProfileRewardCodeRedeemModal
value={profileCenter.rewardCodeInput}
isSubmitting={profileCenter.isSubmittingRewardCode}
error={profileCenter.rewardCodeError}
success={profileCenter.rewardCodeSuccess}
onChange={profileCenter.setRewardCodeInput}
onSubmit={profileCenter.submitRewardCode}
onClose={() => profileCenter.setIsRewardCodeOpen(false)}
/>
) : null}
{profileCenter.profilePopupPanel ? (
<PlatformProfileReferralModal
panel={profileCenter.profilePopupPanel}
center={profileCenter.referralCenter}
isLoading={profileCenter.isLoadingReferral}
isSubmittingRedeem={profileCenter.isSubmittingReferralRedeem}
redeemCode={profileCenter.referralRedeemCode}
copyInviteState={profileCenter.inviteCopyState}
error={profileCenter.referralError}
success={profileCenter.referralSuccess}
onClose={profileCenter.closeProfilePopupPanel}
onCopyInvite={profileCenter.copyInviteInfo}
onRedeemCodeChange={profileCenter.setReferralRedeemCode}
onSubmitRedeemCode={profileCenter.submitReferralRedeemCode}
/>
) : null}
{isApiKeysOpen ? (
<PlatformProfileApiKeysModal onClose={() => setIsApiKeysOpen(false)} />
) : null}
{profileCenter.isWalletLedgerOpen ? (
<PlatformProfileWalletLedgerModal
ledger={profileCenter.walletLedger}
@@ -6,10 +6,10 @@ import type {
ExternalApiKeyProfile,
} from '../../../packages/shared/src/contracts/runtime';
import {
createRpgProfileExternalApiKey,
listRpgProfileExternalApiKeys,
revokeRpgProfileExternalApiKey,
} from '../../services/rpg-entry/rpgProfileClient';
createPlatformProfileExternalApiKey,
listPlatformProfileExternalApiKeys,
revokePlatformProfileExternalApiKey,
} from '../../services/platform-entry/platformProfileClient';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
@@ -20,7 +20,6 @@ import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHe
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { formatPlatformWorldTime } from '../rpg-entry/rpgEntryWorldPresentation';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
type PlatformProfileApiKeysModalProps = {
@@ -32,7 +31,17 @@ function isActiveExternalApiKey(key: ExternalApiKeyProfile) {
}
function buildApiKeyTimeLabel(value: string | null) {
return value ? formatPlatformWorldTime(value) : '尚未使用';
if (!value) {
return '尚未使用';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
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}`;
}
function buildApiKeyScopeLabel(scopes: string[]) {
@@ -56,21 +65,22 @@ export function PlatformProfileApiKeysModal({
const [revokingKeyId, setRevokingKeyId] = useState<string | null>(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));
@@ -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"
>
<div className="min-w-0">
@@ -148,7 +148,7 @@ function RechargeProductCard({
</div>
</div>
<span className="text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
{formatRechargePrice(product.priceCents)}
{formatPlatformRechargePrice(product.priceCents)}
</span>
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
{submitting ? '处理中' : '购买'}
@@ -194,7 +194,7 @@ function PlatformProfileWechatNativePaymentModal({
</div>
<div className="mt-1 text-2xl font-black text-[var(--platform-text-strong)]">
{formatRechargePrice(nativePayment.amountCents)}
{formatPlatformRechargePrice(nativePayment.amountCents)}
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs font-bold text-[var(--platform-text-soft)]">
<span></span>
@@ -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}
</div>
<div className="mt-1 text-xs font-semibold text-zinc-500">
{formatPlatformWorldTime(entry.createdAt)}
{formatPlatformProfileTime(entry.createdAt)}
</div>
</div>
<div className="shrink-0 text-right">
@@ -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<ProfileWalletLedgerEntry['sourceType'], string>;
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}`;
}
@@ -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<void>;
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<ConfirmWechatProfileRechargeOrderResponse> {
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<ConfirmWechatProfileRechargeOrderResponse> {
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<ProfileTaskCenterResponse | null>(null);
const [taskCenterError, setTaskCenterError] = useState<string | null>(null);
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
const taskCenterRequestIdRef = useRef(0);
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(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,
@@ -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(
<ProjectGalleryView
onOpenProject={vi.fn()}
searchKeyword="不存在的项目"
/>,
);
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([
{
+20 -3
View File
@@ -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<EditorProjectSnapshot[]>([]);
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) {
<PlatformEmptyState surface="subpanel" size="panel">
</PlatformEmptyState>
) : visibleProjects.length === 0 && normalizedSearchKeyword ? (
<PlatformEmptyState surface="subpanel" size="panel">
</PlatformEmptyState>
) : projects.length === 0 ? (
<PlatformEmptyState
asChild="button"
+5 -5
View File
@@ -9,9 +9,9 @@ import {
} from '../persistence/gameSettingsStorage';
import { isAbortError } from '../services/apiClient';
import {
getRpgProfileSettings,
putRpgProfileSettings,
} from '../services/rpg-entry';
getPlatformRuntimeSettings,
putPlatformRuntimeSettings,
} from '../services/platform-entry/platformSettingsClient';
const SETTINGS_SYNC_DELAY_MS = 180;
@@ -68,7 +68,7 @@ export function useGameSettings(authenticatedUserId: string | null = null) {
setHasHydratedSettings(false);
setIsHydratingSettings(true);
void getRpgProfileSettings({ signal: controller.signal })
void getPlatformRuntimeSettings({ signal: controller.signal })
.then((settings) => {
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,
@@ -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<T>(
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<T>(
`${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<ProfileDashboardSummary>(
'/dashboard',
{ method: 'GET' },
'读取个人看板失败',
options,
);
}
export function getPlatformProfileWalletLedger(
options: PlatformProfileRequestOptions = {},
) {
return requestPlatformProfileJson<ProfileWalletLedgerResponse>(
'/wallet-ledger',
{ method: 'GET' },
'读取资产流水失败',
options,
);
}
export function listPlatformProfileExternalApiKeys(
options: PlatformProfileRequestOptions = {},
) {
return requestPlatformProfileJson<ExternalApiKeyListResponse>(
'/api-keys',
{ method: 'GET' },
'读取 API Key 失败',
options,
);
}
export function createPlatformProfileExternalApiKey(
name: string,
options: PlatformProfileRequestOptions = {},
) {
return requestPlatformProfileJson<ExternalApiKeyCreateResponse>(
'/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<ExternalApiKeyMutationResponse>(
`/api-keys/${encodeURIComponent(keyId)}`,
{ method: 'DELETE' },
'撤销 API Key 失败',
options,
);
}
export function getPlatformProfileRechargeCenter(
options: PlatformProfileRequestOptions = {},
) {
return requestPlatformProfileJson<ProfileRechargeCenterResponse>(
'/recharge-center',
{ method: 'GET' },
'读取泥点购买信息失败',
options,
);
}
export function createPlatformProfileRechargeOrder(
productId: string,
paymentChannel: string,
options: PlatformProfileRequestOptions = {},
) {
return requestPlatformProfileJson<CreateProfileRechargeOrderResponse>(
'/recharge/orders',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, paymentChannel }),
},
'充值失败',
options,
);
}
export function confirmWechatPlatformProfileRechargeOrder(
orderId: string,
options: PlatformProfileRequestOptions = {},
) {
return requestPlatformProfileJson<ConfirmWechatProfileRechargeOrderResponse>(
`/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<string, unknown>,
): 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<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
) {
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<ConfirmWechatProfileRechargeOrderResponse> {
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<ProfileReferralInviteCenterResponse>(
'/referrals/invite-center',
{ method: 'GET' },
'读取邀请码失败',
options,
);
}
export function redeemPlatformProfileReferralInviteCode(
inviteCode: string,
options: PlatformProfileRequestOptions = {},
) {
return requestPlatformProfileJson<RedeemProfileReferralInviteCodeResponse>(
'/referrals/redeem-code',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode }),
},
'填写邀请码失败',
options,
);
}
export function redeemPlatformProfileRewardCode(
code: string,
options: PlatformProfileRequestOptions = {},
) {
return requestPlatformProfileJson<RedeemProfileRewardCodeResponse>(
'/redeem-codes/redeem',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
},
'兑换失败',
options,
);
}
@@ -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<RuntimeSettings>(
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<RuntimeSettings>(
PLATFORM_SETTINGS_API_PATH,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
signal,
},
'保存设置失败',
{
...requestOptions,
retry: requestOptions.retry ?? PLATFORM_SETTINGS_WRITE_RETRY,
},
);
}
+148
View File
@@ -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(),