refactor: 收口推荐滑动卡模型

This commit is contained in:
2026-06-03 21:11:13 +08:00
parent 30ead590e2
commit c238ef9b40
6 changed files with 248 additions and 24 deletions

View File

@@ -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` - 验证方式:`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` - 关联文档:`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 ModuleInterface 收口 `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 收口 ## 2026-06-03 Ranking ViewModel 收口
- 背景:排行 tab 的文案、metric label 与空态文案在 `RpgEntryHomeView.tsx`,排序和 metric value 在 `rpgEntryPublicGalleryViewModel.ts`,同一 `PlatformRankingTab` 的 Interface 分散且页面需要类型断言取 active config。 - 背景:排行 tab 的文案、metric label 与空态文案在 `RpgEntryHomeView.tsx`,排序和 metric value 在 `rpgEntryPublicGalleryViewModel.ts`,同一 `PlatformRankingTab` 的 Interface 分散且页面需要类型断言取 active config。

View File

@@ -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)。 推荐 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)。 排行频道的默认 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)。 每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `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)。

View File

@@ -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`

View File

@@ -189,6 +189,17 @@ import {
selectPlatformRecommendFeedWindow, selectPlatformRecommendFeedWindow,
sortPlatformCategoryEntries, sortPlatformCategoryEntries,
} from './rpgEntryPublicGalleryViewModel'; } from './rpgEntryPublicGalleryViewModel';
import {
buildRecommendShareText,
buildRecommendSwipeRailClassName,
clampRecommendDragOffset,
hasRecommendDragStarted,
RECOMMEND_ENTRY_COMMIT_ANIMATION_MS,
type RecommendSwipeDirection,
resolveRecommendCommitOffset,
resolveRecommendDragCommitDirection,
shouldAnimateRecommendSwipe,
} from './rpgEntryRecommendSwipeDeckModel';
import { import {
buildPlatformWorldDisplayTags, buildPlatformWorldDisplayTags,
describePlatformPublicWorkKind, describePlatformPublicWorkKind,
@@ -305,9 +316,6 @@ const AVATAR_OUTPUT_SIZE = 256;
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200; const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; 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_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_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
@@ -4746,7 +4754,7 @@ export function RpgEntryHomeView({
const nextRecommendEntry = recommendFeedWindow.nextEntry; const nextRecommendEntry = recommendFeedWindow.nextEntry;
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0); const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] = const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null); useState<RecommendSwipeDirection | null>(null);
const [recommendShareState, setRecommendShareState] = useState< const [recommendShareState, setRecommendShareState] = useState<
'idle' | 'copied' | 'failed' 'idle' | 'copied' | 'failed'
>('idle'); >('idle');
@@ -4759,7 +4767,7 @@ export function RpgEntryHomeView({
dragging: boolean; dragging: boolean;
} | null>(null); } | null>(null);
const commitRecommendDrag = useCallback( const commitRecommendDrag = useCallback(
(direction: 1 | -1) => { (direction: RecommendSwipeDirection) => {
if (recommendDragCommitDirection) { if (recommendDragCommitDirection) {
return; return;
} }
@@ -4767,9 +4775,8 @@ export function RpgEntryHomeView({
setRecommendDragCommitDirection(direction); setRecommendDragCommitDirection(direction);
const panelHeight = const panelHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0; recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
const commitDistance = panelHeight > 0 ? panelHeight : window.innerHeight;
setRecommendDragOffsetY( setRecommendDragOffsetY(
direction === 1 ? -commitDistance : commitDistance, resolveRecommendCommitOffset(direction, panelHeight, window.innerHeight),
); );
window.setTimeout(() => { window.setTimeout(() => {
if (direction === 1) { if (direction === 1) {
@@ -4818,9 +4825,7 @@ export function RpgEntryHomeView({
} }
const deltaY = event.clientY - drag.startY; const deltaY = event.clientY - drag.startY;
drag.dragging = drag.dragging = drag.dragging || hasRecommendDragStarted(deltaY);
drag.dragging ||
Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2;
if (!drag.dragging) { if (!drag.dragging) {
return; return;
} }
@@ -4828,9 +4833,7 @@ export function RpgEntryHomeView({
event.preventDefault(); event.preventDefault();
const cardHeight = const cardHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0; recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
const dragLimit = setRecommendDragOffsetY(clampRecommendDragOffset(deltaY, cardHeight));
cardHeight > 0 ? cardHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX;
setRecommendDragOffsetY(Math.max(-dragLimit, Math.min(dragLimit, deltaY)));
}, []); }, []);
const endRecommendDrag = useCallback( const endRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => { (event: PointerEvent<HTMLElement>) => {
@@ -4842,12 +4845,13 @@ export function RpgEntryHomeView({
event.currentTarget.releasePointerCapture?.(drag.pointerId); event.currentTarget.releasePointerCapture?.(drag.pointerId);
recommendDragStartRef.current = null; recommendDragStartRef.current = null;
const deltaY = event.clientY - drag.startY; const deltaY = event.clientY - drag.startY;
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) { const commitDirection = resolveRecommendDragCommitDirection(deltaY);
if (!commitDirection) {
setRecommendDragOffsetY(0); setRecommendDragOffsetY(0);
return; return;
} }
commitRecommendDrag(deltaY < 0 ? 1 : -1); commitRecommendDrag(commitDirection);
}, },
[commitRecommendDrag], [commitRecommendDrag],
); );
@@ -4865,16 +4869,17 @@ export function RpgEntryHomeView({
const recommendRailStyle = { const recommendRailStyle = {
transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`, transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`,
} satisfies CSSProperties; } satisfies CSSProperties;
const recommendRailClassName = recommendDragCommitDirection const recommendRailClassName = buildRecommendSwipeRailClassName({
? 'platform-recommend-swipe-rail--committing' offsetY: recommendDragOffsetY,
: recommendDragOffsetY === 0 commitDirection: recommendDragCommitDirection,
? 'platform-recommend-swipe-rail--settled' });
: 'platform-recommend-swipe-rail--dragging';
const selectNextRecommendEntry = useCallback(() => { const selectNextRecommendEntry = useCallback(() => {
if ( if (
isAuthenticated && shouldAnimateRecommendSwipe({
activeRecommendEntry && isAuthenticated,
recommendedFeedEntries.length > 1 hasActiveEntry: Boolean(activeRecommendEntry),
entryCount: recommendedFeedEntries.length,
})
) { ) {
commitRecommendDrag(1); commitRecommendDrag(1);
return; return;
@@ -4908,7 +4913,11 @@ export function RpgEntryHomeView({
return; return;
} }
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`; const shareText = buildRecommendShareText({
entry,
publicWorkCode,
detailUrl: buildPublicWorkDetailUrl(publicWorkCode),
});
void copyTextToClipboard(shareText).then((copied) => { void copyTextToClipboard(shareText).then((copied) => {
setRecommendShareState(copied ? 'copied' : 'failed'); setRecommendShareState(copied ? 'copied' : 'failed');
if (recommendShareResetTimerRef.current !== null) { if (recommendShareResetTimerRef.current !== null) {

View File

@@ -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',
};
}

View File

@@ -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}`;
}