refactor: 收口推荐滑动卡模型
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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`
|
||||
@@ -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<RecommendSwipeDirection | null>(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<HTMLElement>) => {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
75
src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts
Normal file
75
src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user