From c238ef9b4026000e48ccb3ef6d58dba4f6cf7e00 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 21:11:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E6=BB=91=E5=8A=A8=E5=8D=A1=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...】RecommendSwipeDeckModel收口计划-2026-06-03.md | 42 +++++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 57 +++++++----- .../rpgEntryRecommendSwipeDeckModel.test.ts | 88 +++++++++++++++++++ .../rpgEntryRecommendSwipeDeckModel.ts | 75 ++++++++++++++++ 6 files changed, 248 insertions(+), 24 deletions(-) create mode 100644 docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md create mode 100644 src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts create mode 100644 src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index f081cdcd..28337fc3 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1249,6 +1249,14 @@ - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md`。 +## 2026-06-03 Recommend Swipe Deck Model 收口 + +- 背景:移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案仍留在 `RpgEntryHomeView.tsx` 页面 Implementation 内,页面同时承载 DOM pointer 副作用和纯规则。 +- 决策:新增 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts` 作为 Recommend Swipe Deck Module,Interface 收口 `hasRecommendDragStarted`、`clampRecommendDragOffset`、`resolveRecommendDragCommitDirection`、`resolveRecommendCommitOffset`、`buildRecommendSwipeRailClassName`、`shouldAnimateRecommendSwipe` 与 `buildRecommendShareText`;页面仅保留 pointer capture、DOM 高度读取、动画 timer、clipboard 与 like/remix/open 副作用 Adapter。 +- 影响范围:移动端推荐首页 swipe 手势、上一条 / 下一条动画、推荐分享文案与未登录时的直接切换行为。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`、`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts -t "recommend"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md`。 + ## 2026-06-03 Ranking ViewModel 收口 - 背景:排行 tab 的文案、metric label 与空态文案在 `RpgEntryHomeView.tsx`,排序和 metric value 在 `rpgEntryPublicGalleryViewModel.ts`,同一 `PlatformRankingTab` 的 Interface 分散且页面需要类型断言取 active config。 diff --git a/docs/README.md b/docs/README.md index 32f3133f..af451bc9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 推荐 feed 的公开作品去重、普通内容过滤、active 窗口与上一条 / 下一条回环选择也收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RecommendFeedViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案收口到 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts`,规则见 [【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md](./technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md)。 + 排行频道的默认 tab、tab 文案、空态文案、排序字段与指标 label/value 收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RankingViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RankingViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md b/docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md new file mode 100644 index 00000000..35d40059 --- /dev/null +++ b/docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md @@ -0,0 +1,42 @@ +# RecommendSwipeDeckModel 收口计划 + +## 背景 + +移动端推荐首页的纵向 swipe deck 曾把拖拽阈值、offset clamp、commit 方向、rail class 和分享文案直接放在 `RpgEntryHomeView.tsx` Implementation 内。页面因此同时理解 DOM pointer 副作用、动画副作用与推荐卡纯规则,后续调整手势阈值或分享文案时缺少稳定测试面。 + +## 决策 + +- 新增 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts` 作为 Recommend Swipe Deck Module。 +- Module Interface 收口: + - `hasRecommendDragStarted` + - `clampRecommendDragOffset` + - `resolveRecommendDragCommitDirection` + - `resolveRecommendCommitOffset` + - `buildRecommendSwipeRailClassName` + - `shouldAnimateRecommendSwipe` + - `buildRecommendShareText` +- `RpgEntryHomeView.tsx` 保留 pointer capture、DOM 高度读取、`setTimeout`、clipboard、like/remix/open 等副作用 Adapter;推荐卡纯规则不再散落在页面 Implementation 内。 + +## Interface 约束 + +- swipe 阈值、commit 动画时长和 drag fallback limit 只从 Module 导出,不在页面重复定义。 +- `deltaY < 0` 表示上滑进入下一条,返回方向 `1`;`deltaY > 0` 表示下滑进入上一条,返回方向 `-1`。 +- 未达到 commit 阈值时必须返回 `null`,页面 Adapter 只负责把 offset 归零。 +- rail class 仅由 `offsetY` 与 `commitDirection` 决定,CSS class 名保持现有命名。 +- 分享文案只使用公开作品名、作品号和详情 URL;公开作品码解析与复制副作用仍在页面 Adapter。 + +## Depth / Leverage / Locality + +- **Depth**:页面传入少量数值或公开作品身份,即可得到拖拽状态、提交方向、动画 class 和分享文案。 +- **Leverage**:调整推荐 swipe 体验时只需改 Module 与单测,交互测试仍护页面 Adapter。 +- **Locality**:pointer 事件生命周期与纯规则分离,推荐卡手势和分享规则集中到一个小 Module。 + +## 验收 + +- `npm run test -- src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"` +- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts -t "recommend"` +- `npx eslint src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts --max-warnings 0` +- `npx eslint src/components/rpg-entry/RpgEntryHomeView.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 0ce8e13f..f17271b9 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -189,6 +189,17 @@ import { selectPlatformRecommendFeedWindow, sortPlatformCategoryEntries, } from './rpgEntryPublicGalleryViewModel'; +import { + buildRecommendShareText, + buildRecommendSwipeRailClassName, + clampRecommendDragOffset, + hasRecommendDragStarted, + RECOMMEND_ENTRY_COMMIT_ANIMATION_MS, + type RecommendSwipeDirection, + resolveRecommendCommitOffset, + resolveRecommendDragCommitDirection, + shouldAnimateRecommendSwipe, +} from './rpgEntryRecommendSwipeDeckModel'; import { buildPlatformWorldDisplayTags, describePlatformPublicWorkKind, @@ -305,9 +316,6 @@ const AVATAR_OUTPUT_SIZE = 256; const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200; const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; -const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36; -const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; -const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; @@ -4746,7 +4754,7 @@ export function RpgEntryHomeView({ const nextRecommendEntry = recommendFeedWindow.nextEntry; const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0); const [recommendDragCommitDirection, setRecommendDragCommitDirection] = - useState<1 | -1 | null>(null); + useState(null); const [recommendShareState, setRecommendShareState] = useState< 'idle' | 'copied' | 'failed' >('idle'); @@ -4759,7 +4767,7 @@ export function RpgEntryHomeView({ dragging: boolean; } | null>(null); const commitRecommendDrag = useCallback( - (direction: 1 | -1) => { + (direction: RecommendSwipeDirection) => { if (recommendDragCommitDirection) { return; } @@ -4767,9 +4775,8 @@ export function RpgEntryHomeView({ setRecommendDragCommitDirection(direction); const panelHeight = recommendCardStageRef.current?.getBoundingClientRect().height ?? 0; - const commitDistance = panelHeight > 0 ? panelHeight : window.innerHeight; setRecommendDragOffsetY( - direction === 1 ? -commitDistance : commitDistance, + resolveRecommendCommitOffset(direction, panelHeight, window.innerHeight), ); window.setTimeout(() => { if (direction === 1) { @@ -4818,9 +4825,7 @@ export function RpgEntryHomeView({ } const deltaY = event.clientY - drag.startY; - drag.dragging = - drag.dragging || - Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2; + drag.dragging = drag.dragging || hasRecommendDragStarted(deltaY); if (!drag.dragging) { return; } @@ -4828,9 +4833,7 @@ export function RpgEntryHomeView({ event.preventDefault(); const cardHeight = recommendCardStageRef.current?.getBoundingClientRect().height ?? 0; - const dragLimit = - cardHeight > 0 ? cardHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX; - setRecommendDragOffsetY(Math.max(-dragLimit, Math.min(dragLimit, deltaY))); + setRecommendDragOffsetY(clampRecommendDragOffset(deltaY, cardHeight)); }, []); const endRecommendDrag = useCallback( (event: PointerEvent) => { @@ -4842,12 +4845,13 @@ export function RpgEntryHomeView({ event.currentTarget.releasePointerCapture?.(drag.pointerId); recommendDragStartRef.current = null; const deltaY = event.clientY - drag.startY; - if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) { + const commitDirection = resolveRecommendDragCommitDirection(deltaY); + if (!commitDirection) { setRecommendDragOffsetY(0); return; } - commitRecommendDrag(deltaY < 0 ? 1 : -1); + commitRecommendDrag(commitDirection); }, [commitRecommendDrag], ); @@ -4865,16 +4869,17 @@ export function RpgEntryHomeView({ const recommendRailStyle = { transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`, } satisfies CSSProperties; - const recommendRailClassName = recommendDragCommitDirection - ? 'platform-recommend-swipe-rail--committing' - : recommendDragOffsetY === 0 - ? 'platform-recommend-swipe-rail--settled' - : 'platform-recommend-swipe-rail--dragging'; + const recommendRailClassName = buildRecommendSwipeRailClassName({ + offsetY: recommendDragOffsetY, + commitDirection: recommendDragCommitDirection, + }); const selectNextRecommendEntry = useCallback(() => { if ( - isAuthenticated && - activeRecommendEntry && - recommendedFeedEntries.length > 1 + shouldAnimateRecommendSwipe({ + isAuthenticated, + hasActiveEntry: Boolean(activeRecommendEntry), + entryCount: recommendedFeedEntries.length, + }) ) { commitRecommendDrag(1); return; @@ -4908,7 +4913,11 @@ export function RpgEntryHomeView({ return; } - const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`; + const shareText = buildRecommendShareText({ + entry, + publicWorkCode, + detailUrl: buildPublicWorkDetailUrl(publicWorkCode), + }); void copyTextToClipboard(shareText).then((copied) => { setRecommendShareState(copied ? 'copied' : 'failed'); if (recommendShareResetTimerRef.current !== null) { diff --git a/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts b/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts new file mode 100644 index 00000000..b412a40c --- /dev/null +++ b/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildRecommendShareText, + buildRecommendSwipeRailClassName, + clampRecommendDragOffset, + hasRecommendDragStarted, + resolveRecommendCommitOffset, + resolveRecommendDragCommitDirection, + shouldAnimateRecommendSwipe, +} from './rpgEntryRecommendSwipeDeckModel'; +import type { PlatformPuzzleGalleryCard } from './rpgEntryWorldPresentation'; + +describe('rpgEntryRecommendSwipeDeckModel', () => { + test('detects drag start and clamps offset to the card stage', () => { + expect(hasRecommendDragStarted(17)).toBe(false); + expect(hasRecommendDragStarted(18)).toBe(true); + expect(clampRecommendDragOffset(240, 120)).toBe(120); + expect(clampRecommendDragOffset(-240, 120)).toBe(-120); + expect(clampRecommendDragOffset(240, 0)).toBe(160); + }); + + test('resolves commit direction and commit offset', () => { + expect(resolveRecommendDragCommitDirection(35)).toBeNull(); + expect(resolveRecommendDragCommitDirection(-36)).toBe(1); + expect(resolveRecommendDragCommitDirection(36)).toBe(-1); + expect(resolveRecommendCommitOffset(1, 320, 720)).toBe(-320); + expect(resolveRecommendCommitOffset(-1, 0, 720)).toBe(720); + }); + + test('builds rail class and animation guard state', () => { + expect( + buildRecommendSwipeRailClassName({ offsetY: 0, commitDirection: null }), + ).toBe('platform-recommend-swipe-rail--settled'); + expect( + buildRecommendSwipeRailClassName({ offsetY: 24, commitDirection: null }), + ).toBe('platform-recommend-swipe-rail--dragging'); + expect( + buildRecommendSwipeRailClassName({ offsetY: -320, commitDirection: 1 }), + ).toBe('platform-recommend-swipe-rail--committing'); + + expect( + shouldAnimateRecommendSwipe({ + isAuthenticated: true, + hasActiveEntry: true, + entryCount: 2, + }), + ).toBe(true); + expect( + shouldAnimateRecommendSwipe({ + isAuthenticated: true, + hasActiveEntry: true, + entryCount: 1, + }), + ).toBe(false); + }); + + test('builds recommend share text from public work identity', () => { + expect( + buildRecommendShareText({ + entry: buildPuzzleEntry(), + publicWorkCode: 'PZ-OCEAN', + detailUrl: 'https://example.test/works/detail?work=PZ-OCEAN', + }), + ).toBe( + '邀请你来玩《潮汐拼图》\n作品号:PZ-OCEAN\nhttps://example.test/works/detail?work=PZ-OCEAN', + ); + }); +}); + +function buildPuzzleEntry(): PlatformPuzzleGalleryCard { + return { + sourceType: 'puzzle', + workId: 'puzzle-work-ocean', + profileId: 'puzzle-profile-ocean', + publicWorkCode: 'PZ-OCEAN', + ownerUserId: 'user-1', + authorDisplayName: '拼图作者', + worldName: '潮汐拼图', + subtitle: '潮汐副标题', + summaryText: '潮汐摘要', + coverImageSrc: null, + themeTags: ['海潮'], + visibility: 'published', + publishedAt: '2026-06-03T08:00:00.000Z', + updatedAt: '2026-06-03T08:00:00.000Z', + }; +} diff --git a/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts b/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts new file mode 100644 index 00000000..f0db81cc --- /dev/null +++ b/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts @@ -0,0 +1,75 @@ +import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; + +export const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36; +export const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; +export const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; + +export type RecommendSwipeDirection = 1 | -1; + +export type RecommendSwipeRailState = { + offsetY: number; + commitDirection: RecommendSwipeDirection | null; +}; + +/** 收口推荐卡纵向滑动的纯判定,页面只保留 pointer 与动画副作用。 */ +export function hasRecommendDragStarted(deltaY: number) { + return Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2; +} + +export function clampRecommendDragOffset( + deltaY: number, + stageHeight: number, +) { + const dragLimit = + stageHeight > 0 ? stageHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX; + return Math.max(-dragLimit, Math.min(dragLimit, deltaY)); +} + +export function resolveRecommendDragCommitDirection( + deltaY: number, +): RecommendSwipeDirection | null { + if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) { + return null; + } + + return deltaY < 0 ? 1 : -1; +} + +export function resolveRecommendCommitOffset( + direction: RecommendSwipeDirection, + stageHeight: number, + viewportHeight: number, +) { + const commitDistance = stageHeight > 0 ? stageHeight : viewportHeight; + return direction === 1 ? -commitDistance : commitDistance; +} + +export function buildRecommendSwipeRailClassName( + state: RecommendSwipeRailState, +) { + if (state.commitDirection) { + return 'platform-recommend-swipe-rail--committing'; + } + + return state.offsetY === 0 + ? 'platform-recommend-swipe-rail--settled' + : 'platform-recommend-swipe-rail--dragging'; +} + +export function shouldAnimateRecommendSwipe(params: { + isAuthenticated: boolean; + hasActiveEntry: boolean; + entryCount: number; +}) { + return ( + params.isAuthenticated && params.hasActiveEntry && params.entryCount > 1 + ); +} + +export function buildRecommendShareText(params: { + entry: PlatformPublicGalleryCard; + publicWorkCode: string; + detailUrl: string; +}) { + return `邀请你来玩《${params.entry.worldName}》\n作品号:${params.publicWorkCode}\n${params.detailUrl}`; +}