diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 1299cdbe..284c2347 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-02 底部加号创作入口页 banner 与最近创作口径 + +- 背景:创作入口页 banner 曾固定为前端两张主题赛卡,且模板分类兜底会产生 `recent` / `最近创作` 页签,和后台配置及真实作品数据口径冲突。 +- 决策:点击底部加号进入的创作入口页 banner 改由后端 `eventBanners` 数组配置,多条自动轮播;旧 `eventBanner` 只保留单条兼容。后台公告配置使用表单维护标题与 HTML 内容,保存时序列化为后端 `eventBannersJson` 传输字段;HTML 只允许经空权限 iframe 展示,不执行 JSX 或直接 DOM 注入。`最近创作` 不再作为模板分类,只由真实草稿 / 作品架后端数据决定是否展示,生成失败草稿也必须进入;模板分类缺失或历史 `recent` 统一归一到 `recommended` / `热门推荐`。移动端草稿页作品卡禁止长按选择文字,但输入框和可编辑区域保留选择能力。 +- 影响范围:`server-rs/crates/module-runtime`、`server-rs/crates/spacetime-module`、`server-rs/crates/spacetime-client`、`server-rs/crates/api-server`、`shared-contracts`、`src/components/custom-world-home`、`src/components/platform-entry`、`apps/admin-web`、`src/index.css`。 +- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、相关 Rust / Vitest 入口配置测试和浏览器点击底部加号截图。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-05-26 微信小程序充值全面接入虚拟支付 - 背景:泥点和会员都属于小程序内由 Genarrative 控制的虚拟资产/权益,继续走普通小程序支付不符合微信虚拟支付接入口径。 @@ -162,10 +170,10 @@ - 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 2026-05-24 创作 Tab banner 轮播只展示主题赛 +## 2026-05-24 创作入口页 banner 曾固定主题赛 -- 背景:创作 Tab banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致首屏出现 58000 奖池活动卡,和当前只强调拼图 / 抓大鹅主题赛的产品口径不一致。 -- 决策:创作 Tab 首屏 banner 轮播只展示 `拼图主题创作赛` 与 `抓大鹅主题创作赛` 两张主题卡;后端返回的 `eventBanner` 仅作为开始时间、结束时间等公共字段来源,不再直接作为一张轮播卡渲染。banner 底部顺序固定为开始 / 结束时间条在上、分页点在下,且二者都在封面内容底部。 +- 背景:点击底部加号进入的创作入口页 banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致出现 58000 奖池活动卡,和当时只强调拼图 / 抓大鹅主题赛的产品口径不一致。 +- 决策:当时固定只展示 `拼图主题创作赛` 与 `抓大鹅主题创作赛` 两张主题卡;该口径已被 2026-06-02 的后台 `eventBanners` 配置决策替代。banner 底部顺序固定为开始 / 结束时间条在上、分页点在下,且二者都在封面内容底部。 - 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/components/custom-world-home/CustomWorldCreationHub.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - 验证方式:`CustomWorldCreationHub.test.tsx` 应断言默认活动标题不出现在 start-only 创作页,且 `creation-event-banner__timebar` 位于 `creation-event-banner__pager` 前。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -189,7 +197,7 @@ ## 2026-05-24 创作 Tab 模板卡点击直达已有玩法入口表单 - 背景:创作 Tab 首屏需要对齐参考图,展示赛事 banner、玩法模板分类和两列模板卡;点击模板卡时,空白入口页会让用户多走一层,占位感也会让人误以为功能未接好。 -- 决策:`/creation/` 直达对应玩法已有的入口创作表单 stage,不再保留空白创作入口页。RPG、拼图、抓大鹅、汪汪声浪、敲木鱼、视觉小说、宝贝识物等都直接进入既有工作台,继续承接草稿恢复和后续编排。创作 Tab 首屏 banner 按参考图拆成右上泥点胶囊、主体宣传封面图文、底部开始/结束时间条和分页点;玩法模板卡使用独立 `creation-template-card` 白底信息区,不复用暗图蒙版 `platform-creation-reference-card`,确保标题、描述和“预计消耗 10-20 泥点”可见。 +- 决策:`/creation/` 直达对应玩法已有的入口创作表单 stage,不再保留空白创作入口页。RPG、拼图、抓大鹅、汪汪声浪、敲木鱼、视觉小说、宝贝识物等都直接进入既有工作台,继续承接草稿恢复和后续编排。点击底部加号进入的创作入口页 banner 按参考图拆成右上泥点胶囊、主体宣传封面图文、底部开始/结束时间条和分页点;玩法模板卡使用独立 `creation-template-card` 白底信息区,不复用暗图蒙版 `platform-creation-reference-card`,确保标题、描述和“预计消耗 10-20 泥点”可见。 - 影响范围:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、创作大厅交互测试与平台入口文档。 - 验证方式:`npm test -- src/routing/appPageRoutes.test.ts`、`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t \"create tab opens match3d entry form from the template card|create tab opens puzzle entry form from the template card|create tab opens bark battle entry form from the template card\"`、`npm run typecheck`、`npm run check:encoding` 通过;创作卡片点击后应进入对应工作台,不再出现空白入口页。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 95eebd5f..6cfb9325 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -243,7 +243,7 @@ npm run check:server-rs-ddd - 移动端优先,再兼容网页端。 - 页面只展示后端返回的状态,不自行计算结论型业务状态。 -- 创作中心入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生,api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。 +- 创作中心入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生,api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。底部加号创作入口页公告位也跟随后端 `eventBanners` 配置,前端只做展示和轮播;后台公告用表单维护标题与 HTML 内容,保存时再序列化为后端 `eventBannersJson` 传输字段。`最近创作` 不属于模板分类,不能作为分类缺失兜底;生成中和生成失败的真实草稿摘要都应进入最近创作。 - 一期统一创作页字段 spec 同样跟随 `GET /api/creation-entry/config`,由 `creationTypes[].unifiedCreationSpec` 下发;拼图、抓大鹅、敲木鱼之外的模板不接入该扩展位,前端只保留旧后端缺字段时的兜底默认。 - 优先复用现有面板、抽屉、弹窗,不新建独立大系统。 - 不在 UI 中默认写功能说明类文本。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 99c0eb8c..c365c1a6 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -106,10 +106,25 @@ ## 玩法入口分类字段缺失要前端兜底 - 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。 -- 处理:`normalizeCategoryId(...)` 和 `normalizeCategoryLabel(...)` 必须接收可空值,并分别回退到 `recent` / `最近创作`。前端这里是展示派生层,不能要求所有历史配置都先补齐字段。 +- 处理:`normalizeCategoryId(...)` 和 `normalizeCategoryLabel(...)` 必须接收可空值,并分别回退到 `recommended` / `热门推荐`;历史 `recent` / `最近创作` 也要归一到推荐分类。`最近创作` 不属于模板分类页签,只能由真实草稿 / 作品架后端数据决定是否展示。 - 验证:`npm test -- src/components/platform-entry/platformEntryCreationTypes.test.ts`,再打开本地创作页确认能正常进入创作 Tab。 - 关联:`src/components/platform-entry/platformEntryCreationTypes.ts`、`src/components/platform-entry/platformEntryCreationTypes.test.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 创作入口公告不要恢复前端固定两卡 + +- 现象:点击底部加号进入的创作入口页只展示固定的拼图 / 抓大鹅主题卡,后台改公告表单后前台没有变化。 +- 原因:前端重新硬编码 banner 列表,绕过了 `GET /api/creation-entry/config` 的 `eventBanners` 配置。 +- 处理:创作入口页公告位优先读取后端 `eventBanners` 数组,多条自动轮播;旧 `eventBanner` 只做单条兼容兜底。后台主格式是标题与 HTML 内容表单,保存时序列化为后端 `eventBannersJson` 传输字段,只允许受控 HTML 片段经空权限 iframe 展示,不执行 JSX 或直接 DOM 注入。 +- 验证:后台保存两条以上公告后,点击底部加号进入创作入口页应自动轮播这些后台配置项;`CustomWorldCreationHub` 相关测试应断言标题来自后端配置。 +- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`server-rs/crates/module-runtime/src/application.rs`、`apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx`。 + +## 移动端草稿卡不要长按选中文字 + +- 现象:移动端草稿页长按作品卡标题或摘要时触发系统文字选区,容易误触并打断作品架操作。 +- 处理:移动端只对 `#platform-tab-panel-saves .creation-work-card` 禁止 `user-select` 和 `-webkit-touch-callout`;输入框、文本域和 `[contenteditable='true']` 保留文本选择能力,避免破坏真实编辑场景。 +- 验证:移动端草稿页长按普通作品卡文字不出现系统选区;`src/index.test.ts` 应覆盖 CSS 选择器和可编辑控件例外。 +- 关联:`src/index.css`、`src/index.test.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 草稿页未读点不要继续用红色 literal - 现象:草稿页底部 Tab 和作品架的未读点视觉上仍像红点,或 glow 仍带红色阴影,和平台暖棕体系不一致。 diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 1b8d7f9c..ef176285 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -1,4 +1,5 @@ import type { + AdminUpsertCreationEntryEventBannersRequest, AdminUpsertCreationEntryTypeConfigRequest, AdminCreationEntryConfigResponse, AdminDebugHttpRequest, @@ -197,6 +198,21 @@ export function upsertAdminCreationEntryConfig( ); } +/** 保存创作入口公告表单序列化后的后端传输字段。 */ +export function upsertAdminCreationEntryBanners( + token: string, + payload: AdminUpsertCreationEntryEventBannersRequest, +) { + return request( + '/admin/api/creation-entry/config/banners', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listAdminWorkVisibility(token: string) { return request( '/admin/api/works/visibility', diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 3ba26abc..83672193 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -144,10 +144,25 @@ export interface AdminTrackingEventListQuery { } +/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */ export interface AdminCreationEntryConfigResponse { entries: AdminCreationEntryTypeConfigPayload[]; + eventBanners: AdminCreationEntryEventBannerPayload[]; } +/** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */ +export interface AdminCreationEntryEventBannerPayload { + title: string; + description: string; + coverImageSrc: string; + prizePoolMudPoints: number; + startsAtText: string; + endsAtText: string; + renderMode: 'structured' | 'html'; + htmlCode?: string | null; +} + +/** 后台单个创作模板入口配置,公告不再绑定在某一个入口上。 */ export interface AdminCreationEntryTypeConfigPayload { id: string; title: string; @@ -164,6 +179,7 @@ export interface AdminCreationEntryTypeConfigPayload { unifiedCreationSpec?: UnifiedCreationSpecPayload | null; } +/** 后台保存创作模板入口开关与统一创作契约的请求体。 */ export interface AdminUpsertCreationEntryTypeConfigRequest { id: string; title: string; @@ -179,6 +195,13 @@ export interface AdminUpsertCreationEntryTypeConfigRequest { unifiedCreationSpec?: UnifiedCreationSpecPayload | null; } +/** 后台保存创作入口公告表单序列化结果的请求体。 */ +export interface AdminUpsertCreationEntryEventBannersRequest { + /** 传输字段沿用后端契约,内容由后台表单生成。 */ + eventBannersJson: string; +} + +/** 后台统一创作工作台契约表单的传输结构。 */ export interface UnifiedCreationSpecPayload { playId: string; title: string; @@ -188,6 +211,7 @@ export interface UnifiedCreationSpecPayload { fields: UnifiedCreationFieldPayload[]; } +/** 后台统一创作字段契约,保存前会校验字段类型和必填标记。 */ export interface UnifiedCreationFieldPayload { id: string; kind: 'text' | 'select' | 'image' | 'audio'; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index e6327c48..9d50f380 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -200,6 +200,13 @@ export function AdminApp() { onResultChange={setInviteResult} /> ) : null} + {routeId === 'creation-announcement' ? ( + + ) : null} {routeId === 'creation-entry' ? ( ; diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts new file mode 100644 index 00000000..bae46be8 --- /dev/null +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -0,0 +1,16 @@ +import {expect, test} from 'vitest'; + +import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes'; + +// 中文注释:后台入口公告必须作为独立导航存在,避免公告表单被误藏在入口开关页。 +test('后台入口公告路由可通过导航和 hash 访问', () => { + expect(adminRoutes).toContainEqual({ + id: 'creation-announcement', + label: '入口公告', + hash: '#creation-announcement', + }); + expect(resolveAdminRoute('#creation-announcement')).toBe( + 'creation-announcement', + ); + expect(routeHash('creation-announcement')).toBe('#creation-announcement'); +}); diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index c84459ae..3b6ed6c3 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -1,3 +1,4 @@ +/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */ export type AdminRouteId = | 'overview' | 'tables' @@ -7,9 +8,11 @@ export type AdminRouteId = | 'invite' | 'tasks' | 'recharge-products' + | 'creation-announcement' | 'creation-entry' | 'work-visibility'; +/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */ export interface AdminRouteDefinition { id: AdminRouteId; label: string; @@ -25,10 +28,12 @@ export const adminRoutes: AdminRouteDefinition[] = [ {id: 'invite', label: '邀请码', hash: '#invite'}, {id: 'tasks', label: '任务配置', hash: '#tasks'}, {id: 'recharge-products', label: '充值商品', hash: '#recharge-products'}, + {id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'}, {id: 'creation-entry', label: '入口开关', hash: '#creation-entry'}, {id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'}, ]; +/** 根据地址栏 hash 解析后台路由,未知 hash 回落到总览页。 */ export function resolveAdminRoute(hash: string): AdminRouteId { const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? ''; return ( @@ -37,6 +42,7 @@ export function resolveAdminRoute(hash: string): AdminRouteId { ); } +/** 根据后台路由标识反查 hash,供导航点击时同步地址栏。 */ export function routeHash(routeId: AdminRouteId) { return ( adminRoutes.find((route) => route.id === routeId)?.hash ?? diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx index 75c84504..da9362d7 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx @@ -6,6 +6,7 @@ import {beforeEach, expect, test, vi} from 'vitest'; import { getAdminCreationEntryConfig, + upsertAdminCreationEntryBanners, upsertAdminCreationEntryConfig, } from '../api/adminApiClient'; import type { @@ -20,6 +21,7 @@ vi.mock('../api/adminApiClient', () => ({ ), getAdminCreationEntryConfig: vi.fn(), isAdminApiError: vi.fn(() => false), + upsertAdminCreationEntryBanners: vi.fn(), upsertAdminCreationEntryConfig: vi.fn(), })); @@ -40,6 +42,18 @@ const puzzleSpec: UnifiedCreationSpecPayload = { }; const configResponse: AdminCreationEntryConfigResponse = { + eventBanners: [ + { + title: '创作公告', + description: '', + coverImageSrc: '', + prizePoolMudPoints: 0, + startsAtText: '', + endsAtText: '', + renderMode: 'html', + htmlCode: '
后台公告
', + }, + ], entries: [ { id: 'puzzle', @@ -50,9 +64,9 @@ const configResponse: AdminCreationEntryConfigResponse = { visible: true, open: true, sortOrder: 30, - categoryId: 'recent', - categoryLabel: '最近创作', - categorySortOrder: 10, + categoryId: 'recommended', + categoryLabel: '热门推荐', + categorySortOrder: 20, updatedAtMicros: 1, unifiedCreationSpec: puzzleSpec, }, @@ -62,6 +76,7 @@ const configResponse: AdminCreationEntryConfigResponse = { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse); + vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse); vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse); }); @@ -93,7 +108,10 @@ test('创作入口后台展示并保存统一创作契约', async () => { test('创作入口后台拒绝 playId 不一致的统一创作契约', async () => { const user = userEvent.setup(); render( - , + , ); const textarea = await screen.findByLabelText('契约 JSON'); @@ -110,3 +128,109 @@ test('创作入口后台拒绝 playId 不一致的统一创作契约', async () expect(await screen.findByText('统一创作契约 playId 必须与入口 ID 一致')).toBeTruthy(); expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled(); }); + +test('创作入口后台用表单保存公告配置', async () => { + const user = userEvent.setup(); + render( + , + ); + + expect(await screen.findAllByRole('heading', {name: '创作入口公告'})).toHaveLength(2); + expect(screen.queryByLabelText('公告代码 JSON')).toBeNull(); + fireEvent.change(await screen.findByLabelText('公告 1 标题'), { + target: {value: '周末创作赛'}, + }); + fireEvent.change(screen.getByLabelText('公告 1 HTML'), { + target: {value: '
新的入口公告
'}, + }); + await user.click(screen.getByRole('button', {name: '新增公告'})); + fireEvent.change(screen.getByLabelText('公告 2 标题'), { + target: {value: '第二条公告'}, + }); + fireEvent.change(screen.getByLabelText('公告 2 HTML'), { + target: {value: '
轮播第二条
'}, + }); + await user.click(screen.getByRole('button', {name: '保存公告'})); + await user.click(screen.getByRole('button', {name: '确认'})); + + await waitFor(() => { + expect(upsertAdminCreationEntryBanners).toHaveBeenCalled(); + }); + const [, payload] = vi.mocked(upsertAdminCreationEntryBanners).mock.calls[0]!; + expect(JSON.parse(payload.eventBannersJson)).toEqual([ + { + title: '周末创作赛', + htmlCode: '
新的入口公告
', + }, + { + title: '第二条公告', + htmlCode: '
轮播第二条
', + }, + ]); + expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty( + 'description', + ); + expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty( + 'coverImageSrc', + ); +}); + +test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => { + vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({ + ...configResponse, + eventBanners: [ + { + title: '旧公告 <标题>', + description: '旧描述 & 需要转义', + coverImageSrc: '/legacy.png', + prizePoolMudPoints: 120, + startsAtText: '2026-06-01', + endsAtText: '2026-06-30', + renderMode: 'structured', + }, + ], + }); + + render( + , + ); + + expect(await screen.findByLabelText('公告 1 标题')).toHaveProperty( + 'value', + '旧公告 <标题>', + ); + expect(screen.getByLabelText('公告 1 HTML')).toHaveProperty( + 'value', + '

旧公告 <标题>

旧描述 & 需要转义

', + ); +}); + +test('创作入口后台拒绝空公告表单', async () => { + const user = userEvent.setup(); + render( + , + ); + + fireEvent.change(await screen.findByLabelText('公告 1 标题'), { + target: {value: ''}, + }); + fireEvent.change(screen.getByLabelText('公告 1 HTML'), { + target: {value: ''}, + }); + await user.click(screen.getByRole('button', {name: '保存公告'})); + + expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy(); + expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled(); +}); diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx index 4a06ddd9..9de00709 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx @@ -1,28 +1,49 @@ -import {RefreshCcw, Save} from 'lucide-react'; -import {FormEvent, useEffect, useState} from 'react'; +import { Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'; +import { FormEvent, useEffect, useState } from 'react'; import { getAdminCreationEntryConfig, + upsertAdminCreationEntryBanners, upsertAdminCreationEntryConfig, } from '../api/adminApiClient'; import type { + AdminCreationEntryEventBannerPayload, AdminCreationEntryTypeConfigPayload, UnifiedCreationFieldPayload, UnifiedCreationSpecPayload, } from '../api/adminApiTypes'; -import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; -import {handlePageError} from './pageUtils'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; +/** 创作入口后台页面参数;公告模式只展示底部加号入口公告表单。 */ interface AdminCreationEntrySwitchPageProps { token: string; onUnauthorized: (message?: string) => void; + mode?: 'switches' | 'announcements'; } +/** 后台公告表单的一行编辑态,保存时会统一序列化为后端传输字段。 */ +type AnnouncementFormItem = { + id: string; + title: string; + htmlCode: string; +}; + +/** 公告表单保存前的校验与序列化结果。 */ +type AnnouncementFormBuildResult = + | { ok: true; json: string } + | { ok: false; message: string }; + +let announcementFormItemSequence = 0; + export function AdminCreationEntrySwitchPage({ token, onUnauthorized, + mode = 'switches', }: AdminCreationEntrySwitchPageProps) { - const [entries, setEntries] = useState([]); + const [entries, setEntries] = useState< + AdminCreationEntryTypeConfigPayload[] + >([]); const [selectedId, setSelectedId] = useState('puzzle'); const [title, setTitle] = useState(''); const [subtitle, setSubtitle] = useState(''); @@ -31,15 +52,21 @@ export function AdminCreationEntrySwitchPage({ const [visible, setVisible] = useState(true); const [open, setOpen] = useState(true); const [sortOrder, setSortOrder] = useState('30'); - const [categoryId, setCategoryId] = useState('recent'); - const [categoryLabel, setCategoryLabel] = useState('最近创作'); - const [categorySortOrder, setCategorySortOrder] = useState('10'); + const [categoryId, setCategoryId] = useState('recommended'); + const [categoryLabel, setCategoryLabel] = useState('热门推荐'); + const [categorySortOrder, setCategorySortOrder] = useState('20'); const [unifiedCreationSpecJson, setUnifiedCreationSpecJson] = useState(''); + const [announcementItems, setAnnouncementItems] = useState< + AnnouncementFormItem[] + >([]); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); + const [isSavingBanners, setIsSavingBanners] = useState(false); const [listErrorMessage, setListErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState(''); - const {confirmWrite, confirmDialog} = useAdminWriteConfirm(); + const [bannerErrorMessage, setBannerErrorMessage] = useState(''); + const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); + const isAnnouncementMode = mode === 'announcements'; useEffect(() => { void refreshEntries(); @@ -53,8 +80,11 @@ export function AdminCreationEntrySwitchPage({ const response = await getAdminCreationEntryConfig(token); const nextEntries = sortEntries(response.entries); setEntries(nextEntries); + setAnnouncementItems(formatEventBannersFormItems(response.eventBanners)); fillForm( - nextEntries.find((entry) => entry.id === selectedId) ?? nextEntries[0] ?? null, + nextEntries.find((entry) => entry.id === selectedId) ?? + nextEntries[0] ?? + null, ); } catch (error: unknown) { handlePageError(error, onUnauthorized, setListErrorMessage); @@ -105,6 +135,7 @@ export function AdminCreationEntrySwitchPage({ }); const nextEntries = sortEntries(response.entries); setEntries(nextEntries); + setAnnouncementItems(formatEventBannersFormItems(response.eventBanners)); fillForm(nextEntries.find((entry) => entry.id === targetId) ?? null); } catch (error: unknown) { handlePageError(error, onUnauthorized, setErrorMessage); @@ -113,6 +144,40 @@ export function AdminCreationEntrySwitchPage({ } } + /** 保存底部加号创作入口页的多公告表单配置。 */ + async function handleSaveBanners() { + if (isSavingBanners) { + return; + } + + setBannerErrorMessage(''); + const bannerJsonResult = buildEventBannersJsonFromForm(announcementItems); + if (!bannerJsonResult.ok) { + setBannerErrorMessage(bannerJsonResult.message); + return; + } + const confirmed = await confirmWrite({ + action: '保存创作入口公告', + target: 'creation-entry-announcements', + }); + if (!confirmed) { + return; + } + + setIsSavingBanners(true); + try { + const response = await upsertAdminCreationEntryBanners(token, { + eventBannersJson: bannerJsonResult.json, + }); + setEntries(sortEntries(response.entries)); + setAnnouncementItems(formatEventBannersFormItems(response.eventBanners)); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setBannerErrorMessage); + } finally { + setIsSavingBanners(false); + } + } + function fillForm(entry: AdminCreationEntryTypeConfigPayload | null) { if (!entry) { return; @@ -128,15 +193,53 @@ export function AdminCreationEntrySwitchPage({ setCategoryId(entry.categoryId); setCategoryLabel(entry.categoryLabel); setCategorySortOrder(String(entry.categorySortOrder)); - setUnifiedCreationSpecJson(formatUnifiedCreationSpecJson(entry.unifiedCreationSpec)); + setUnifiedCreationSpecJson( + formatUnifiedCreationSpecJson(entry.unifiedCreationSpec), + ); + } + + /** 更新单条公告表单字段,避免后台页面直接暴露 JSON 编辑。 */ + function updateAnnouncementItem( + index: number, + patch: Partial>, + ) { + setAnnouncementItems((currentItems) => + currentItems.map((item, itemIndex) => + itemIndex === index ? { ...item, ...patch } : item, + ), + ); + } + + /** 新增一条空公告表单行。 */ + function addAnnouncementItem() { + setAnnouncementItems((currentItems) => [ + ...currentItems, + createAnnouncementFormItem('', ''), + ]); + } + + /** 删除指定公告表单行,至少保留一条空行供继续编辑。 */ + function removeAnnouncementItem(index: number) { + setAnnouncementItems((currentItems) => { + const nextItems = currentItems.filter( + (_, itemIndex) => itemIndex !== index, + ); + return nextItems.length > 0 + ? nextItems + : [createAnnouncementFormItem('', '')]; + }); } return (
-

创作入口开关

-

控制创作中心入口展示与运行态路由可用性

+

{isAnnouncementMode ? '创作入口公告' : '创作入口开关'}

+

+ {isAnnouncementMode + ? '配置底部加号创作入口页的公告轮播' + : '控制创作中心入口展示与运行态路由可用性'} +