Merge codex/sse-stream-architecture into architecture adjustment

This commit is contained in:
2026-06-07 00:23:42 +08:00
136 changed files with 22344 additions and 7543 deletions
@@ -307,12 +307,21 @@ const {
amountDelta: -1,
balanceAfter: 29,
sourceType: 'asset_operation_consume',
createdAt: '2026-05-03T08:00:00Z',
},
{
id: 'ledger-2',
amountDelta: 30,
balanceAfter: 30,
sourceType: 'invite_invitee_reward',
createdAt: '2026-05-03T09:00:00Z',
},
{
id: 'ledger-3',
amountDelta: 5,
balanceAfter: 35,
sourceType: 'puzzle_author_incentive_claim',
createdAt: '2026-05-03T10:00:00Z',
},
],
})),
@@ -1222,6 +1231,7 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('-1')).toBeTruthy();
expect(screen.getByText('填写邀请码奖励')).toBeTruthy();
expect(screen.getByText('+30')).toBeTruthy();
expect(screen.getByText('拼图作者奖励')).toBeTruthy();
});
test('profile recharge modal shows native qr code on desktop web by default', async () => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
import { expect, test } from 'vitest';
import type {
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
} from '../../../packages/shared/src/contracts/runtime';
import {
buildProfileDashboardPresentation,
formatCompactPlayTime,
formatDashboardCount,
formatPlayedWorkId,
formatPlayedWorkType,
formatTotalPlayTimeHours,
} from './rpgEntryProfileDashboardPresentation';
function buildDashboard(
overrides: Partial<ProfileDashboardSummary> = {},
): ProfileDashboardSummary {
return {
walletBalance: 12345,
totalPlayTimeMs: 3_780_000,
playedWorldCount: 7,
updatedAt: '2026-06-03T00:00:00.000Z',
...overrides,
};
}
function buildPlayedWork(
overrides: Partial<ProfilePlayedWorkSummary> = {},
): ProfilePlayedWorkSummary {
return {
worldKey: 'rpg:world-1',
ownerUserId: 'user-1',
profileId: 'profile-1',
worldType: 'custom-world',
worldTitle: '星桥',
worldSubtitle: '',
firstPlayedAt: '2026-06-03T00:00:00.000Z',
lastPlayedAt: '2026-06-03T01:00:00.000Z',
lastObservedPlayTimeMs: 60_000,
...overrides,
};
}
test('profile dashboard presentation formats compact counts', () => {
expect(formatDashboardCount(-1)).toBe('0');
expect(formatDashboardCount(9999.4)).toBe('9,999');
expect(formatDashboardCount(12000)).toBe('1.2万');
expect(formatDashboardCount(230000000)).toBe('2.3亿');
});
test('profile dashboard presentation formats play time for cards and modal rows', () => {
expect(formatTotalPlayTimeHours(0)).toBe('0小时');
expect(formatTotalPlayTimeHours(3_780_000)).toBe('1.1小时');
expect(formatCompactPlayTime(59_000)).toBe('0分');
expect(formatCompactPlayTime(3_600_000)).toBe('1.0小时');
expect(formatCompactPlayTime(3 * 24 * 60 * 60 * 1000)).toBe('3天');
expect(formatCompactPlayTime(12 * 24 * 60 * 60 * 1000)).toBe('12天');
});
test('profile dashboard presentation normalizes played work labels and ids', () => {
expect(formatPlayedWorkType('match_3d')).toBe('抓鹅');
expect(formatPlayedWorkType('square-hole')).toBe('方洞');
expect(formatPlayedWorkType('big_fish')).toBe('大鱼');
expect(formatPlayedWorkType('unknown')).toBe('RPG');
expect(formatPlayedWorkId(buildPlayedWork({ profileId: ' ' }))).toBe(
'rpg:world-1',
);
});
test('profile dashboard presentation builds stat labels from dashboard summary', () => {
expect(buildProfileDashboardPresentation(buildDashboard())).toEqual({
playedWorkCount: 7,
playedWorkCountLabel: '7个',
totalPlayTimeLabel: '1.1小时',
walletBalance: 12345,
walletBalanceLabel: '1.2万',
walletBalanceWithUnitLabel: '1.2万泥点',
});
expect(buildProfileDashboardPresentation(null)).toEqual({
playedWorkCount: 0,
playedWorkCountLabel: '0个',
totalPlayTimeLabel: '0小时',
walletBalance: 0,
walletBalanceLabel: '0',
walletBalanceWithUnitLabel: '0泥点',
});
});
@@ -0,0 +1,95 @@
import type {
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
} from '../../../packages/shared/src/contracts/runtime';
export type ProfileDashboardPresentation = {
playedWorkCount: number;
playedWorkCountLabel: string;
totalPlayTimeLabel: string;
walletBalance: number;
walletBalanceLabel: string;
walletBalanceWithUnitLabel: string;
};
export function formatCompactPlayTime(playTimeMs: number) {
const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000));
const days = totalMinutes / 1440;
if (days >= 10) {
return `${Math.floor(days)}`;
}
if (days >= 1) {
return `${days.toFixed(days >= 3 ? 0 : 1)}`;
}
const hours = totalMinutes / 60;
if (hours >= 1) {
return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`;
}
return `${Math.max(0, totalMinutes)}`;
}
// “累计游戏时长”卡片固定用小时口径,避免卡片在分钟 / 天之间跳变。
export function formatTotalPlayTimeHours(playTimeMs: number) {
const roundedHours = Math.max(0, Math.round(playTimeMs / 360000) / 10);
return `${roundedHours.toLocaleString('zh-CN', {
maximumFractionDigits: 1,
})}小时`;
}
export function formatDashboardCount(value: number) {
const normalizedValue = Math.max(0, Math.round(value));
if (normalizedValue >= 100000000) {
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
}
if (normalizedValue >= 10000) {
return `${(normalizedValue / 10000).toFixed(1)}`;
}
return normalizedValue.toLocaleString('zh-CN');
}
// 玩法标签沿用首页既有外显口径,未知类型暂归入 RPG。
export function formatPlayedWorkType(value: string | null | undefined) {
const normalizedValue = (value ?? '').toLowerCase();
if (normalizedValue === 'puzzle') {
return '拼图';
}
if (normalizedValue === 'match3d' || normalizedValue === 'match_3d') {
return '抓鹅';
}
if (normalizedValue === 'square-hole' || normalizedValue === 'square_hole') {
return '方洞';
}
if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') {
return '大鱼';
}
return 'RPG';
}
// 现有契约尚未下发公开作品码,“玩过”列表先沿用 profileId,再兜底 worldKey。
export function formatPlayedWorkId(work: ProfilePlayedWorkSummary) {
return work.profileId?.trim() || work.worldKey;
}
export function buildProfileDashboardPresentation(
dashboard: ProfileDashboardSummary | null,
): ProfileDashboardPresentation {
const walletBalance = dashboard?.walletBalance ?? 0;
const walletBalanceLabel = formatDashboardCount(walletBalance);
const playedWorkCount = dashboard?.playedWorldCount ?? 0;
return {
playedWorkCount,
playedWorkCountLabel: `${formatDashboardCount(playedWorkCount)}`,
totalPlayTimeLabel: formatTotalPlayTimeHours(
dashboard?.totalPlayTimeMs ?? 0,
),
walletBalance,
walletBalanceLabel,
walletBalanceWithUnitLabel: `${walletBalanceLabel}泥点`,
};
}
@@ -0,0 +1,161 @@
import { expect, test } from 'vitest';
import type {
ProfileMembership,
ProfileRechargeProduct,
ProfileWalletLedgerEntry,
} from '../../../packages/shared/src/contracts/runtime';
import {
buildMembershipLabel,
buildRechargeProductValueLabel,
buildWalletLedgerPresentation,
formatRechargePrice,
formatWalletLedgerAmount,
getWalletLedgerSourceLabel,
} from './rpgEntryProfileFundsViewModel';
function buildLedgerEntry(
overrides: Partial<ProfileWalletLedgerEntry> = {},
): ProfileWalletLedgerEntry {
return {
id: 'ledger-1',
amountDelta: 30,
balanceAfter: 80,
sourceType: 'invite_invitee_reward',
createdAt: '2026-06-03T00:00:00.000Z',
...overrides,
};
}
function buildRechargeProduct(
overrides: Partial<ProfileRechargeProduct> = {},
): ProfileRechargeProduct {
return {
productId: 'points_60',
title: '60泥点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
description: '首充送60泥点',
tier: 'normal',
...overrides,
};
}
function buildMembership(
overrides: Partial<ProfileMembership> = {},
): ProfileMembership {
return {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
...overrides,
};
}
test('profile funds ViewModel formats ledger amount labels', () => {
expect(formatWalletLedgerAmount(-1)).toBe('-1');
expect(formatWalletLedgerAmount(0)).toBe('0');
expect(formatWalletLedgerAmount(30)).toBe('+30');
});
test('profile funds ViewModel resolves ledger source labels with raw fallback', () => {
expect(getWalletLedgerSourceLabel('asset_operation_consume')).toBe(
'资产操作消耗',
);
expect(getWalletLedgerSourceLabel('puzzle_author_incentive_claim')).toBe(
'拼图作者奖励',
);
expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source');
expect(getWalletLedgerSourceLabel('')).toBe('未知来源');
});
test('profile funds ViewModel builds wallet ledger presentation', () => {
const incomeEntry = buildLedgerEntry({
id: 'ledger-income',
amountDelta: 30,
balanceAfter: 80,
sourceType: 'puzzle_author_incentive_claim',
});
const outcomeEntry = buildLedgerEntry({
id: 'ledger-outcome',
amountDelta: -1,
balanceAfter: 79,
sourceType: 'asset_operation_consume',
});
expect(
buildWalletLedgerPresentation(
{ entries: [incomeEntry, outcomeEntry] },
12,
),
).toEqual({
balance: 80,
balanceLabel: '80泥点',
entries: [
{
amountLabel: '+30',
balanceLabel: '余额 80',
createdAt: '2026-06-03T00:00:00.000Z',
id: 'ledger-income',
isIncome: true,
sourceLabel: '拼图作者奖励',
},
{
amountLabel: '-1',
balanceLabel: '余额 79',
createdAt: '2026-06-03T00:00:00.000Z',
id: 'ledger-outcome',
isIncome: false,
sourceLabel: '资产操作消耗',
},
],
});
expect(buildWalletLedgerPresentation({ entries: [] }, 12)).toEqual({
balance: 12,
balanceLabel: '12泥点',
entries: [],
});
});
test('profile funds ViewModel formats recharge product and membership labels', () => {
expect(formatRechargePrice(600)).toBe('¥6');
expect(formatRechargePrice(650)).toBe('¥6.50');
expect(buildRechargeProductValueLabel(buildRechargeProduct())).toBe(
'60+60泥点',
);
expect(
buildRechargeProductValueLabel(
buildRechargeProduct({
kind: 'membership',
pointsAmount: 0,
bonusPoints: 0,
durationDays: 30,
}),
),
).toBe('30天');
expect(buildMembershipLabel(buildMembership(), (value) => value)).toBe(
'普通用户',
);
expect(
buildMembershipLabel(
buildMembership({ status: 'active', expiresAt: null }),
(value) => value,
),
).toBe('会员已生效');
expect(
buildMembershipLabel(
buildMembership({
status: 'active',
expiresAt: '2026-06-03T00:00:00.000Z',
}),
() => '06/03 08:00',
),
).toBe('会员至 06/03 08:00');
});
@@ -0,0 +1,108 @@
import type {
ProfileMembership,
ProfileRechargeProduct,
ProfileWalletLedgerEntry,
ProfileWalletLedgerResponse,
} from '../../../packages/shared/src/contracts/runtime';
const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
new_user_registration_reward: '注册赠送',
points_recharge: '泥点充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回',
redeem_code_reward: '兑换码奖励',
puzzle_author_incentive_claim: '拼图作者奖励',
daily_task_reward: '每日任务奖励',
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
export type ProfileWalletLedgerEntryPresentation = {
amountLabel: string;
balanceLabel: string;
createdAt: string;
id: string;
isIncome: boolean;
sourceLabel: string;
};
export type ProfileWalletLedgerPresentation = {
balance: number;
balanceLabel: string;
entries: ProfileWalletLedgerEntryPresentation[];
};
export function getWalletLedgerSourceLabel(
sourceType: string | null | undefined,
) {
const normalizedSourceType = sourceType?.trim() ?? '';
if (!normalizedSourceType) {
return '未知来源';
}
return (
PROFILE_WALLET_LEDGER_SOURCE_LABELS[
normalizedSourceType as ProfileWalletLedgerEntry['sourceType']
] ?? normalizedSourceType
);
}
export function formatWalletLedgerAmount(amountDelta: number) {
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
}
export function buildWalletLedgerEntryPresentation(
entry: ProfileWalletLedgerEntry,
): ProfileWalletLedgerEntryPresentation {
return {
amountLabel: formatWalletLedgerAmount(entry.amountDelta),
balanceLabel: `余额 ${entry.balanceAfter}`,
createdAt: entry.createdAt,
id: entry.id,
isIncome: entry.amountDelta > 0,
sourceLabel: getWalletLedgerSourceLabel(entry.sourceType),
};
}
export function buildWalletLedgerPresentation(
ledger: ProfileWalletLedgerResponse | null,
fallbackBalance: number,
): ProfileWalletLedgerPresentation {
const entries = ledger?.entries ?? [];
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
return {
balance,
balanceLabel: `${balance}泥点`,
entries: entries.map(buildWalletLedgerEntryPresentation),
};
}
export function formatRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
export function buildRechargeProductValueLabel(product: ProfileRechargeProduct) {
if (product.kind === 'membership') {
return `${product.durationDays}`;
}
return `${product.pointsAmount}${
product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''
}泥点`;
}
export function buildMembershipLabel(
membership: ProfileMembership | null | undefined,
formatTime: (value: string) => string,
) {
if (membership?.status !== 'active') {
return '普通用户';
}
return membership.expiresAt
? `会员至 ${formatTime(membership.expiresAt)}`
: '会员已生效';
}
@@ -0,0 +1,127 @@
import { expect, test } from 'vitest';
import type {
ProfileTaskCenterResponse,
ProfileTaskItem,
} from '../../../packages/shared/src/contracts/runtime';
import {
buildProfileTaskCardSummary,
buildProfileTaskProgressLabel,
getProfileTaskClaimButtonLabel,
getProfileTaskStatusLabel,
selectProfileTaskCardTask,
selectProfileTaskCenterTasks,
} from './rpgEntryProfileTaskViewModel';
function buildTask(
overrides: Partial<ProfileTaskItem> = {},
): ProfileTaskItem {
return {
taskId: 'task-1',
title: '游玩一次',
description: '完成一次游戏',
eventKey: 'work_play_start',
cycle: 'daily',
threshold: 1,
progressCount: 0,
rewardPoints: 10,
status: 'incomplete',
dayKey: 20260603,
claimedAt: null,
updatedAt: '2026-06-03T00:00:00.000Z',
...overrides,
};
}
function buildCenter(
tasks: ProfileTaskItem[],
): ProfileTaskCenterResponse {
return {
dayKey: 20260603,
walletBalance: 12,
tasks,
updatedAt: '2026-06-03T00:00:00.000Z',
};
}
test('profile task ViewModel selects one actionable task by status priority and original order', () => {
const firstIncomplete = buildTask({
taskId: 'incomplete-1',
status: 'incomplete',
});
const secondIncomplete = buildTask({
taskId: 'incomplete-2',
status: 'incomplete',
});
const claimable = buildTask({
taskId: 'claimable-1',
status: 'claimable',
});
expect(
selectProfileTaskCenterTasks([
firstIncomplete,
secondIncomplete,
claimable,
]),
).toEqual([claimable]);
expect(selectProfileTaskCenterTasks([firstIncomplete, secondIncomplete])).toEqual(
[firstIncomplete],
);
});
test('profile task ViewModel falls back from card task to claimed and enabled tasks', () => {
const claimed = buildTask({ taskId: 'claimed-1', status: 'claimed' });
const disabled = buildTask({ taskId: 'disabled-1', status: 'disabled' });
const incomplete = buildTask({
taskId: 'incomplete-1',
status: 'incomplete',
});
expect(selectProfileTaskCardTask([disabled, claimed])).toBe(claimed);
expect(selectProfileTaskCardTask([disabled, incomplete])).toBe(incomplete);
expect(selectProfileTaskCardTask([disabled])).toBeNull();
});
test('profile task ViewModel builds card summary with reward fallback and clamped progress', () => {
expect(buildProfileTaskCardSummary(null)).toEqual({
actionLabel: '去完成',
progressCount: 0,
progressPercent: 0,
rewardPoints: 10,
threshold: 1,
});
expect(
buildProfileTaskCardSummary(
buildCenter([
buildTask({
progressCount: 5,
rewardPoints: 25,
status: 'claimable',
threshold: 3,
}),
]),
),
).toEqual({
actionLabel: '领取',
progressCount: 3,
progressPercent: 100,
rewardPoints: 25,
threshold: 3,
});
});
test('profile task ViewModel exposes task labels for the modal', () => {
const task = buildTask({ progressCount: -1, threshold: 0 });
expect(getProfileTaskStatusLabel('claimable')).toBe('可领取');
expect(buildProfileTaskProgressLabel(task)).toBe('0/1');
expect(getProfileTaskClaimButtonLabel(task, true)).toBe('领取中');
expect(getProfileTaskClaimButtonLabel(buildTask({ status: 'claimed' }), false)).toBe(
'已领取',
);
expect(
getProfileTaskClaimButtonLabel(buildTask({ status: 'claimable' }), false),
).toBe('领取');
});
@@ -0,0 +1,107 @@
import type {
ProfileTaskCenterResponse,
ProfileTaskItem,
} from '../../../packages/shared/src/contracts/runtime';
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<
ProfileTaskItem['status'],
number
> = {
claimable: 2,
incomplete: 1,
disabled: 0,
claimed: -1,
};
const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10;
const PROFILE_TASK_STATUS_LABELS: Record<ProfileTaskItem['status'], string> = {
incomplete: '未完成',
claimable: '可领取',
claimed: '已领取',
disabled: '已停用',
};
export type ProfileTaskCardSummary = {
actionLabel: string;
progressCount: number;
progressPercent: number;
rewardPoints: number;
threshold: number;
};
export function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
return tasks
.map((task, index) => ({ task, index }))
.filter(
({ task }) =>
task.status === 'claimable' || task.status === 'incomplete',
)
.sort(
(left, right) =>
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] ||
left.index - right.index,
)
.slice(0, 1)
.map(({ task }) => task);
}
export function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) {
return (
selectProfileTaskCenterTasks(tasks)[0] ??
tasks.find((task) => task.status === 'claimed') ??
tasks.find((task) => task.status !== 'disabled') ??
null
);
}
export function getProfileTaskStatusLabel(status: ProfileTaskItem['status']) {
return PROFILE_TASK_STATUS_LABELS[status];
}
export function buildProfileTaskProgressLabel(task: ProfileTaskItem) {
const threshold = Math.max(1, task.threshold);
const progressCount = Math.min(Math.max(0, task.progressCount), threshold);
return `${progressCount}/${threshold}`;
}
export function getProfileTaskClaimButtonLabel(
task: ProfileTaskItem,
isClaiming: boolean,
) {
if (isClaiming) {
return '领取中';
}
if (task.status === 'claimed') {
return '已领取';
}
return task.status === 'claimable' ? '领取' : '未完成';
}
export function buildProfileTaskCardSummary(
center: ProfileTaskCenterResponse | null,
): ProfileTaskCardSummary {
const task = selectProfileTaskCardTask(center?.tasks ?? []);
const threshold = Math.max(1, task?.threshold ?? 1);
const progressCount = Math.min(
Math.max(0, task?.progressCount ?? 0),
threshold,
);
const rewardPoints =
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
const actionLabel =
task?.status === 'claimable'
? '领取'
: task?.status === 'claimed'
? '已完成'
: '去完成';
return {
actionLabel,
progressCount,
progressPercent: Math.round((progressCount / threshold) * 100),
rewardPoints,
threshold,
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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',
};
}
@@ -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}`;
}
@@ -3,8 +3,11 @@ import { expect, test } from 'vitest';
import {
buildPlatformWorldDisplayTags,
buildPuzzleWorkCoverSlides,
describePlatformPublicWorkKind,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
formatPlatformCompactCount,
formatPlatformPublicAuthorAvatarLabel,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
@@ -18,10 +21,13 @@ import {
mapPuzzleClearWorkToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
mapWoodenFishWorkToPlatformGalleryCard,
type PlatformBarkBattleGalleryCard,
type PlatformBigFishGalleryCard,
type PlatformEdutainmentGalleryCard,
type PlatformPuzzleGalleryCard,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformPublicWorkAuthorLookup,
resolvePlatformPublicWorkCode,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformWorldFallbackCoverImage,
} from './rpgEntryWorldPresentation';
@@ -55,6 +61,48 @@ test('platform work display text limits names and tags by character count', () =
).toEqual(['超长机关', '星桥']);
});
test('platform public work presentation formats compact counts and kind labels', () => {
const puzzleCard: PlatformPuzzleGalleryCard = {
sourceType: 'puzzle',
workId: 'puzzle-work-kind',
profileId: 'puzzle-profile-kind',
publicWorkCode: 'PZ-KIND',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: '机关拼图',
subtitle: '拼图关卡',
summaryText: '公开作品',
coverImageSrc: null,
themeTags: ['拼图'],
visibility: 'published',
publishedAt: '2026-05-18T00:00:00.000Z',
updatedAt: '2026-05-18T00:00:00.000Z',
};
const bigFishCard: PlatformBigFishGalleryCard = {
sourceType: 'big-fish',
workId: 'big-fish-work-kind',
profileId: 'big-fish-profile-kind',
publicWorkCode: 'BF-KIND',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: '大鱼海湾',
subtitle: '大鱼关卡',
summaryText: '公开作品',
coverImageSrc: null,
themeTags: ['大鱼'],
visibility: 'published',
publishedAt: '2026-05-18T00:00:00.000Z',
updatedAt: '2026-05-18T00:00:00.000Z',
};
expect(formatPlatformCompactCount(-1)).toBe('0');
expect(formatPlatformCompactCount(9999)).toBe('9999');
expect(formatPlatformCompactCount(10000)).toBe('1.0万');
expect(formatPlatformCompactCount(100000000)).toBe('1.0亿');
expect(describePlatformPublicWorkKind(puzzleCard)).toBe('拼图');
expect(describePlatformPublicWorkKind(bigFishCard)).toBe('大鱼吃小');
});
test('platform public cards use play type reference images as cover fallback', () => {
const puzzleCard: PlatformPuzzleGalleryCard = {
sourceType: 'puzzle',
@@ -308,6 +356,57 @@ test('public work author display keeps phone masks and hides bare public user co
);
});
test('public work author lookup keeps public user code priority and avatar labels', () => {
const barkBattleCard: PlatformBarkBattleGalleryCard = {
sourceType: 'bark-battle',
workId: 'bark-battle-work-author',
profileId: 'bark-battle-profile-author',
sourceSessionId: null,
publicWorkCode: 'BB-AUTHOR',
ownerUserId: 'user-author-id',
authorPublicUserCode: ' SY-00012345 ',
authorDisplayName: '声浪玩家',
worldName: '声浪擂台',
subtitle: '汪汪声浪',
summaryText: '公开作品',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
themeTags: ['声浪'],
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
visibility: 'published',
publishedAt: '2026-05-22T00:00:00.000Z',
updatedAt: '2026-05-22T00:00:00.000Z',
};
expect(resolvePlatformPublicWorkAuthorLookup(barkBattleCard)).toEqual({
key: 'code:SY-00012345',
source: 'publicUserCode',
value: 'SY-00012345',
});
expect(
resolvePlatformPublicWorkAuthorLookup({
...barkBattleCard,
authorPublicUserCode: ' ',
}),
).toEqual({
key: 'id:user-author-id',
source: 'ownerUserId',
value: 'user-author-id',
});
expect(
resolvePlatformPublicWorkAuthorLookup({
...barkBattleCard,
authorPublicUserCode: null,
ownerUserId: ' ',
}),
).toBeNull();
expect(formatPlatformPublicAuthorAvatarLabel(' 声浪玩家')).toBe('声');
expect(formatPlatformPublicAuthorAvatarLabel('')).toBe('玩');
});
test('keeps baby object match public card code and template label intact', () => {
const card: PlatformEdutainmentGalleryCard = {
sourceType: 'edutainment',
@@ -1,5 +1,5 @@
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -331,6 +331,12 @@ export type PlatformPublicGalleryCard =
| PlatformBarkBattleGalleryCard
| PlatformEdutainmentGalleryCard;
export type PlatformPublicWorkAuthorLookup = {
key: string;
source: 'publicUserCode' | 'ownerUserId';
value: string;
};
export function isLibraryWorldEntry(
entry: PlatformWorldCardLike,
): entry is CustomWorldLibraryEntry<CustomWorldProfile> {
@@ -991,6 +997,52 @@ export function formatPlatformWorkDisplayTags(
].slice(0, limit);
}
export function formatPlatformCompactCount(value: number) {
const normalizedValue = Math.max(0, Math.round(value));
if (normalizedValue >= 100000000) {
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
}
if (normalizedValue >= 10000) {
return `${(normalizedValue / 10000).toFixed(1)}`;
}
return `${normalizedValue}`;
}
export function describePlatformPublicWorkKind(
entry: PlatformPublicGalleryCard,
) {
if (isBigFishGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('大鱼吃小鱼');
}
if (isPuzzleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('拼图');
}
if (isMatch3DGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('抓大鹅');
}
if (isSquareHoleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('方洞挑战');
}
if (isJumpHopGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('跳一跳');
}
if (isWoodenFishGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('敲木鱼');
}
if (isVisualNovelGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('视觉小说');
}
if (isBarkBattleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('汪汪声浪');
}
if (isEdutainmentGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag(entry.templateName);
}
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
}
export function resolvePlatformWorkAuthorDisplayName(
entry: PlatformPublicGalleryCard,
authorSummary?: PublicUserSummary | null,
@@ -1005,6 +1057,36 @@ export function resolvePlatformWorkAuthorDisplayName(
return displayName || entryAuthorName || '玩家';
}
export function resolvePlatformPublicWorkAuthorLookup(
entry: PlatformPublicGalleryCard,
): PlatformPublicWorkAuthorLookup | null {
if ('authorPublicUserCode' in entry) {
const authorPublicUserCode = entry.authorPublicUserCode?.trim();
if (authorPublicUserCode) {
return {
key: `code:${authorPublicUserCode}`,
source: 'publicUserCode',
value: authorPublicUserCode,
};
}
}
const ownerUserId = entry.ownerUserId.trim();
return ownerUserId
? {
key: `id:${ownerUserId}`,
source: 'ownerUserId',
value: ownerUserId,
}
: null;
}
export function formatPlatformPublicAuthorAvatarLabel(
authorDisplayName: string,
) {
return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩';
}
function normalizePlatformPublicAuthorName(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
if (!normalized || normalized === 'null' || normalized === 'undefined') {