diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 5d3d571c..7a478fd8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1355,6 +1355,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts`、`npm run test -- src/services/creationUrlState.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md`。 +## 2026-06-04 Platform Public Code Search Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的公开搜索回调内联判断内部用户 ID、陶泥号、RPG 作品号、各玩法公开作品号前缀和 fallback 顺序,壳层同时承担纯搜索计划与网络 / 打开副作用。 +- 决策:新增 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,以 `resolvePlatformPublicCodeSearchPlan(keyword)` 返回 `normalizedKeyword` 与 `steps`。`user_` / `user-` 只查用户 ID;玩法前缀直达对应作品;`CW` / 纯数字先查 RPG 作品再查陶泥号;普通关键词和 `SY` 保持既有用户号、RPG 作品、汪汪声浪、用户号兜底顺序。壳层只按 step 执行既有查找、详情打开、Bark Battle runtime 特例和 missing work 归航。 +- 影响范围:发现页 / 推荐页公开搜索、作品详情深链初始搜索、陶泥号命中面板、各玩法公开作品号直达。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 4d22a415..87a3878f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作入口点击的占位、隐藏模板拦截、未知入口 no-op 与工作台启动目标收口到 `src/components/platform-entry/platformCreationLaunchModel.ts`,壳层只执行启动前准备、错误提示和受保护动作,规则见 [【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md)。 +平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。 + 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md new file mode 100644 index 00000000..07c9ed2d --- /dev/null +++ b/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md @@ -0,0 +1,37 @@ +# 【前端架构】Platform Public Code Search Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的公开搜索回调曾直接在壳层内判断 `user_` / `user-`、`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB`、`CW`、纯数字和普通关键词的优先级。壳层因此既要持有搜索输入到查找顺序的纯规则,又要执行各玩法公开详情读取、用户读取、运行态启动和错误归航副作用。 + +公开搜索的“先查什么、失败后回退什么”是稳定的分流规则,应有独立测试面;壳层只应作为副作用 Adapter,按计划执行网络读取与打开动作。 + +## 决策 + +新增 `src/components/platform-entry/platformPublicCodeSearchModel.ts` 作为 Platform Public Code Search **Module**。其公开 **Interface** 为: + +- `resolvePlatformPublicCodeSearchPlan(keyword)`:输入用户搜索词,输出 `{ normalizedKeyword, steps }`;空输入返回 `null`。 +- `PlatformPublicCodeSearchStep`:枚举壳层可执行的查找步骤,包括 `user-id`、`public-user-code`、`rpg-work`、各玩法公开作品步骤与 `bark-battle-work`。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `getPublicAuthUserByCode`、各玩法 gallery 刷新 / 详情打开、Bark Battle runtime 特例和 missing work 归航副作用,只按 `steps` 顺序执行,前一步失败才尝试下一步。 + +## Interface 约束 + +- 空白搜索词返回 `null`,壳层不得进入搜索 loading。 +- `user_` / `user-` 开头的内部用户 ID 只执行 `user-id`,不回退作品号。 +- `PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀只进入对应玩法公开作品查找;`M3D-*` 继续归入 `M3` / 抓大鹅。 +- `CW` 与 `1-8` 位纯数字先查 RPG 公开作品,再回退陶泥号。 +- 普通关键词与 `SY` 陶泥号保持既有顺序:先查陶泥号,再查 RPG 公开作品,再查汪汪声浪作品,最后再以陶泥号兜底。 + +## Depth / Leverage / Locality + +- **Depth**:壳层只消费短小的 `steps` Interface,搜索前缀、优先级和回退顺序藏入 Module Implementation。 +- **Leverage**:新增公开作品前缀时,先扩展 Module 的 step union、前缀表和单测,再在壳层 Adapter 绑定对应执行函数。 +- **Locality**:搜索计划规则集中在一个纯 Module;UI、网络、详情打开与 runtime 启动副作用继续留在壳层,避免把副作用 setter 变成浅 Interface。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts` +- `npx eslint src/components/platform-entry/platformPublicCodeSearchModel.ts src/components/platform-entry/platformPublicCodeSearchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 985aa6ae..4d134bb8 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -1,6 +1,6 @@ # 平台入口与玩法链路 -更新时间:`2026-06-03` +更新时间:`2026-06-04` ## 平台创作入口 @@ -57,6 +57,8 @@ 发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。 +平台公开搜索的分流顺序统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链。 + 发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 ## RPG / 自定义世界 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 79642a0a..1ec3bdc3 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -512,6 +512,10 @@ import { resolveMatch3DRuntimeGeneratedBackgroundAsset, resolveMatch3DRuntimeGeneratedItemAssets, } from './platformMatch3DRuntimeProfile'; +import { + type PlatformPublicCodeSearchStep, + resolvePlatformPublicCodeSearchPlan, +} from './platformPublicCodeSearchModel'; import { getPlatformPublicGalleryEntryKey, getPlatformRecommendRuntimeKind, @@ -13449,53 +13453,17 @@ export function PlatformEntryFlowShellImpl({ const handlePublicCodeSearch = useCallback( async (keyword: string) => { - const normalizedKeyword = keyword.trim(); - if (!normalizedKeyword) { + const searchPlan = resolvePlatformPublicCodeSearchPlan(keyword); + if (!searchPlan) { return; } + const { normalizedKeyword } = searchPlan; + setIsSearchingPublicCode(true); setPublicSearchError(null); setSearchedPublicUser(null); - const upperKeyword = normalizedKeyword.toUpperCase(); - const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test( - normalizedKeyword, - ); - const shouldSearchBigFishFirst = upperKeyword.startsWith('BF'); - const shouldSearchBabyObjectFirst = upperKeyword.startsWith('BO'); - const shouldSearchJumpHopFirst = upperKeyword.startsWith('JH'); - const shouldSearchWoodenFishFirst = upperKeyword.startsWith('WF'); - const shouldSearchMatch3DFirst = upperKeyword.startsWith('M3'); - const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ'); - const shouldSearchSquareHoleFirst = upperKeyword.startsWith('SH'); - const shouldSearchVisualNovelFirst = upperKeyword.startsWith('VN'); - const shouldSearchBarkBattleFirst = upperKeyword.startsWith('BB'); - const shouldSearchWorkFirst = - !shouldSearchUserIdFirst && - !shouldSearchBabyObjectFirst && - !shouldSearchBigFishFirst && - !shouldSearchJumpHopFirst && - !shouldSearchWoodenFishFirst && - !shouldSearchMatch3DFirst && - !shouldSearchPuzzleFirst && - !shouldSearchSquareHoleFirst && - !shouldSearchVisualNovelFirst && - !shouldSearchBarkBattleFirst && - (upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword)); - const shouldSearchUserFirst = - shouldSearchUserIdFirst || - upperKeyword.startsWith('SY') || - (!shouldSearchWorkFirst && - !shouldSearchBigFishFirst && - !shouldSearchBabyObjectFirst && - !shouldSearchJumpHopFirst && - !shouldSearchWoodenFishFirst && - !shouldSearchMatch3DFirst && - !shouldSearchPuzzleFirst && - !shouldSearchSquareHoleFirst && - !shouldSearchVisualNovelFirst); - const tryOpenGalleryEntry = async () => { const entry = await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword); @@ -13717,95 +13685,66 @@ export function PlatformEntryFlowShellImpl({ ); }; - try { - if (shouldSearchUserIdFirst) { - const user = await getPublicAuthUserById(normalizedKeyword); - setSearchedPublicUser(user); - return; - } - - if (shouldSearchPuzzleFirst) { - await tryOpenPuzzleGalleryEntry(); - return; - } - - if (shouldSearchBigFishFirst) { - await tryOpenBigFishGalleryEntry(); - return; - } - - if (shouldSearchJumpHopFirst) { - await tryOpenJumpHopGalleryEntry(); - return; - } - - if (shouldSearchWoodenFishFirst) { - await tryOpenWoodenFishGalleryEntry(); - return; - } - - if (shouldSearchBabyObjectFirst) { - await tryOpenBabyObjectMatchGalleryEntry(); - return; - } - - if (shouldSearchMatch3DFirst) { - await tryOpenMatch3DGalleryEntry(); - return; - } - - if (shouldSearchSquareHoleFirst) { - await tryOpenSquareHoleGalleryEntry(); - return; - } - - if (shouldSearchVisualNovelFirst) { - await tryOpenVisualNovelGalleryEntry(); - return; - } - - if (shouldSearchBarkBattleFirst) { - await tryOpenBarkBattleGalleryEntry(); - return; - } - - if (shouldSearchWorkFirst) { - try { - await tryOpenGalleryEntry(); + const runSearchStep = async (step: PlatformPublicCodeSearchStep) => { + switch (step) { + case 'user-id': { + const user = await getPublicAuthUserById(normalizedKeyword); + setSearchedPublicUser(user); return; - } catch { - // 作品号优先时允许继续回退到用户号搜索。 } - } - - if (shouldSearchUserFirst) { - try { + case 'public-user-code': { const user = await getPublicAuthUserByCode(normalizedKeyword); setSearchedPublicUser(user); return; - } catch { - // 用户号优先时允许继续回退到作品号搜索。 } - } - - if (!shouldSearchWorkFirst) { - try { + case 'rpg-work': await tryOpenGalleryEntry(); return; - } catch { - // 常规作品未命中时继续尝试汪汪声浪作品号。 - } - - try { + case 'puzzle-work': + await tryOpenPuzzleGalleryEntry(); + return; + case 'big-fish-work': + await tryOpenBigFishGalleryEntry(); + return; + case 'jump-hop-work': + await tryOpenJumpHopGalleryEntry(); + return; + case 'wooden-fish-work': + await tryOpenWoodenFishGalleryEntry(); + return; + case 'baby-object-match-work': + await tryOpenBabyObjectMatchGalleryEntry(); + return; + case 'match3d-work': + await tryOpenMatch3DGalleryEntry(); + return; + case 'square-hole-work': + await tryOpenSquareHoleGalleryEntry(); + return; + case 'visual-novel-work': + await tryOpenVisualNovelGalleryEntry(); + return; + case 'bark-battle-work': await tryOpenBarkBattleGalleryEntry(); return; - } catch { - // 汪汪声浪作品未命中时继续回退到陶泥号搜索。 + default: { + const exhaustive: never = step; + return exhaustive; } } + }; - const user = await getPublicAuthUserByCode(normalizedKeyword); - setSearchedPublicUser(user); + try { + for (const [index, step] of searchPlan.steps.entries()) { + try { + await runSearchStep(step); + return; + } catch (error) { + if (index === searchPlan.steps.length - 1) { + throw error; + } + } + } } catch (error) { if (selectionStage === 'work-detail') { setSelectedPublicWorkDetail(null); diff --git a/src/components/platform-entry/platformPublicCodeSearchModel.test.ts b/src/components/platform-entry/platformPublicCodeSearchModel.test.ts new file mode 100644 index 00000000..c4bc04b1 --- /dev/null +++ b/src/components/platform-entry/platformPublicCodeSearchModel.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest'; + +import { + type PlatformPublicCodeSearchStep, + resolvePlatformPublicCodeSearchPlan, +} from './platformPublicCodeSearchModel'; + +function expectSearchSteps( + keyword: string, + steps: readonly PlatformPublicCodeSearchStep[], +) { + expect(resolvePlatformPublicCodeSearchPlan(keyword)?.steps).toEqual(steps); +} + +describe('platformPublicCodeSearchModel', () => { + test('ignores empty public code search input', () => { + expect(resolvePlatformPublicCodeSearchPlan(' ')).toBeNull(); + }); + + test('normalizes public code search keyword before planning', () => { + expect(resolvePlatformPublicCodeSearchPlan(' PZ-00000001 ')).toEqual({ + normalizedKeyword: 'PZ-00000001', + steps: ['puzzle-work'], + }); + }); + + test('searches internal user ids directly without work fallback', () => { + expectSearchSteps('user_00000001', ['user-id']); + expectSearchSteps('USER-profile-1', ['user-id']); + }); + + test('routes known public work prefixes to their play-specific lookup', () => { + const cases: Array< + [keyword: string, step: PlatformPublicCodeSearchStep] + > = [ + ['PZ-EPUBLIC1', 'puzzle-work'], + ['BF-NPUBLIC1', 'big-fish-work'], + ['JH-EPUBLIC1', 'jump-hop-work'], + ['WF-EPUBLIC1', 'wooden-fish-work'], + ['BO-EPUBLIC1', 'baby-object-match-work'], + ['M3-EPUBLIC1', 'match3d-work'], + ['M3D-LEGACY1', 'match3d-work'], + ['SH-EPUBLIC1', 'square-hole-work'], + ['VN-EPUBLIC1', 'visual-novel-work'], + ['BB-EPUBLIC1', 'bark-battle-work'], + ]; + + for (const [keyword, step] of cases) { + expectSearchSteps(keyword, [step]); + } + }); + + test('searches RPG public works before public user codes for CW and numeric codes', () => { + expectSearchSteps('CW-00000001', ['rpg-work', 'public-user-code']); + expectSearchSteps('12345678', ['rpg-work', 'public-user-code']); + }); + + test('keeps legacy user-code-first fallback for SY and ordinary keywords', () => { + const legacyFallbackSteps = [ + 'public-user-code', + 'rpg-work', + 'bark-battle-work', + 'public-user-code', + ] as const; + + expectSearchSteps('SY-00000001', legacyFallbackSteps); + expectSearchSteps('月井守望', legacyFallbackSteps); + }); +}); diff --git a/src/components/platform-entry/platformPublicCodeSearchModel.ts b/src/components/platform-entry/platformPublicCodeSearchModel.ts new file mode 100644 index 00000000..616d69aa --- /dev/null +++ b/src/components/platform-entry/platformPublicCodeSearchModel.ts @@ -0,0 +1,83 @@ +export type PlatformPublicCodeSearchStep = + | 'user-id' + | 'public-user-code' + | 'rpg-work' + | 'puzzle-work' + | 'big-fish-work' + | 'jump-hop-work' + | 'wooden-fish-work' + | 'baby-object-match-work' + | 'match3d-work' + | 'square-hole-work' + | 'visual-novel-work' + | 'bark-battle-work'; + +export type PlatformPublicCodeSearchPlan = { + normalizedKeyword: string; + steps: readonly PlatformPublicCodeSearchStep[]; +}; + +const PLATFORM_PUBLIC_USER_ID_PATTERN = /^user[_-][a-z0-9_-]+$/iu; +const PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN = /^\d{1,8}$/u; + +const DIRECT_WORK_PREFIX_STEPS: ReadonlyArray< + readonly [prefix: string, step: PlatformPublicCodeSearchStep] +> = [ + ['PZ', 'puzzle-work'], + ['BF', 'big-fish-work'], + ['JH', 'jump-hop-work'], + ['WF', 'wooden-fish-work'], + ['BO', 'baby-object-match-work'], + ['M3', 'match3d-work'], + ['SH', 'square-hole-work'], + ['VN', 'visual-novel-work'], + ['BB', 'bark-battle-work'], +]; + +/** 收口公开码搜索顺序,壳层只按步骤执行网络读取与打开副作用。 */ +export function resolvePlatformPublicCodeSearchPlan( + keyword: string, +): PlatformPublicCodeSearchPlan | null { + const normalizedKeyword = keyword.trim(); + if (!normalizedKeyword) { + return null; + } + + if (PLATFORM_PUBLIC_USER_ID_PATTERN.test(normalizedKeyword)) { + return { + normalizedKeyword, + steps: ['user-id'], + }; + } + + const upperKeyword = normalizedKeyword.toUpperCase(); + const directWorkStep = DIRECT_WORK_PREFIX_STEPS.find(([prefix]) => + upperKeyword.startsWith(prefix), + )?.[1]; + if (directWorkStep) { + return { + normalizedKeyword, + steps: [directWorkStep], + }; + } + + if ( + upperKeyword.startsWith('CW') || + PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN.test(normalizedKeyword) + ) { + return { + normalizedKeyword, + steps: ['rpg-work', 'public-user-code'], + }; + } + + return { + normalizedKeyword, + steps: [ + 'public-user-code', + 'rpg-work', + 'bark-battle-work', + 'public-user-code', + ], + }; +}