refactor: 收口推荐滑动卡模型
This commit is contained in:
@@ -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