恢复平台个人页并阻断旧业务回流
恢复创作、项目、我的平台侧栏与现役搜索 恢复全宽个人页及资料编辑、充值兑换、社区反馈和 API Key 入口 迁移平台资料、钱包和设置客户端并移除旧创作依赖 新增 Vite 与 ESLint 退役模块门禁并补齐界面和搜索测试 同步旧创作退役方案、平台入口决策与踩坑记录
This commit is contained in:
+65
-7
@@ -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 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`。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
|
||||
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user