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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)}`
: '会员已生效';
}

View File

@@ -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('领取');
});

View File

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

View File

@@ -0,0 +1,505 @@
import { expect, test } from 'vitest';
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
import {
buildPlatformRankingEntries,
buildPlatformRecommendFeedEntries,
buildPublicCategoryGroups,
buildPublicGalleryCardKey,
dedupePlatformPublicGalleryEntries,
DEFAULT_PLATFORM_CATEGORY_KIND_FILTER,
DEFAULT_PLATFORM_CATEGORY_SORT_MODE,
DEFAULT_PLATFORM_RANKING_TAB,
filterPlatformWorkSearchResults,
filterTodayPublishedEntries,
getNextPlatformCategorySortMode,
getPlatformCategoryKindFilter,
getPlatformCategoryKindFilterOption,
getPlatformCategoryPrimaryMetric,
getPlatformCategorySortOption,
getPlatformPublicEntries,
getPlatformRankingMetric,
getPlatformRankingMetricValue,
getPlatformRankingTabConfig,
matchesPlatformCategoryKindFilter,
parsePlatformEntryTimestamp,
PLATFORM_CATEGORY_KIND_FILTERS,
PLATFORM_CATEGORY_SORT_OPTIONS,
PLATFORM_RANKING_TABS,
type PlatformCategoryKindFilter,
type PlatformCategorySortMode,
selectAdjacentPlatformRecommendEntry,
selectPlatformRecommendFeedWindow,
sortPlatformCategoryEntries,
} from './rpgEntryPublicGalleryViewModel';
import type {
PlatformJumpHopGalleryCard,
PlatformPuzzleGalleryCard,
PlatformWoodenFishGalleryCard,
} from './rpgEntryWorldPresentation';
function buildPuzzleEntry(
overrides: Partial<PlatformPuzzleGalleryCard> = {},
): PlatformPuzzleGalleryCard {
return {
sourceType: 'puzzle',
workId: 'puzzle-work',
profileId: 'shared-profile',
publicWorkCode: 'PZ-SHARED',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
worldName: '星桥拼图',
subtitle: '拼图副标题',
summaryText: '星桥机关摘要',
coverImageSrc: null,
themeTags: ['星桥', '机关'],
visibility: 'published',
publishedAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
...overrides,
};
}
function buildJumpHopEntry(
overrides: Partial<PlatformJumpHopGalleryCard> = {},
): PlatformJumpHopGalleryCard {
return {
sourceType: 'jump-hop',
workId: 'jump-hop-work',
profileId: 'shared-profile',
publicWorkCode: 'JH-SHARED',
ownerUserId: 'user-1',
authorDisplayName: '跳一跳作者',
worldName: '星桥跳一跳',
subtitle: '跳一跳副标题',
summaryText: '跳一跳摘要',
coverImageSrc: null,
themeTags: ['跳跃'],
visibility: 'published',
publishedAt: '2026-05-02T00:00:00.000Z',
updatedAt: '2026-05-02T00:00:00.000Z',
...overrides,
};
}
function buildWoodenFishEntry(
overrides: Partial<PlatformWoodenFishGalleryCard> = {},
): PlatformWoodenFishGalleryCard {
return {
sourceType: 'wooden-fish',
workId: 'wooden-fish-work',
profileId: 'shared-profile',
publicWorkCode: 'WF-SHARED',
ownerUserId: 'user-1',
authorDisplayName: '木鱼作者',
worldName: '星桥木鱼',
subtitle: '木鱼副标题',
summaryText: '木鱼摘要',
coverImageSrc: null,
themeTags: ['敲木鱼'],
visibility: 'published',
publishedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
...overrides,
};
}
function buildRpgEntry(
overrides: Partial<CustomWorldGalleryCard> = {},
): CustomWorldGalleryCard {
return {
ownerUserId: 'user-1',
profileId: 'shared-profile',
publicWorkCode: 'CW-SHARED',
authorPublicUserCode: null,
visibility: 'published',
publishedAt: '2026-05-04T00:00:00.000Z',
updatedAt: '2026-05-04T00:00:00.000Z',
authorDisplayName: 'RPG 作者',
worldName: '星桥 RPG',
subtitle: 'RPG 副标题',
summaryText: 'RPG 摘要',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 1,
landmarkCount: 1,
...overrides,
};
}
test('public gallery ViewModel keeps play kinds distinct in card keys', () => {
expect(buildPublicGalleryCardKey(buildPuzzleEntry())).toBe(
'puzzle:user-1:shared-profile',
);
expect(buildPublicGalleryCardKey(buildJumpHopEntry())).toBe(
'jump-hop:user-1:shared-profile',
);
expect(buildPublicGalleryCardKey(buildWoodenFishEntry())).toBe(
'wooden-fish:user-1:shared-profile',
);
expect(buildPublicGalleryCardKey(buildRpgEntry())).toBe(
'rpg:user-1:shared-profile',
);
});
test('public gallery ViewModel dedupes merged public entries by latest source', () => {
const oldPuzzle = buildPuzzleEntry({
worldName: '旧拼图',
updatedAt: '2026-05-01T00:00:00.000Z',
});
const latestPuzzle = buildPuzzleEntry({
worldName: '新拼图',
updatedAt: '2026-05-02T00:00:00.000Z',
});
expect(getPlatformPublicEntries([oldPuzzle], [latestPuzzle])).toEqual([
latestPuzzle,
]);
const categoryGroups = buildPublicCategoryGroups([oldPuzzle], [latestPuzzle]);
expect(categoryGroups.find((group) => group.tag === '星桥')).toEqual({
tag: '星桥',
entries: [latestPuzzle],
});
});
test('public gallery ViewModel builds recommend feed from general public entries', () => {
const featuredPuzzle = buildPuzzleEntry({
profileId: 'shared',
worldName: '精选旧拼图',
});
const latestPuzzle = buildPuzzleEntry({
profileId: 'shared',
worldName: '最新拼图',
});
const edutainmentPuzzle = buildPuzzleEntry({
profileId: 'edutainment',
themeTags: ['寓教于乐'],
});
const jumpHopEntry = buildJumpHopEntry({ profileId: 'jump-hop' });
expect(
buildPlatformRecommendFeedEntries(
[featuredPuzzle, edutainmentPuzzle],
[latestPuzzle, jumpHopEntry],
),
).toEqual([latestPuzzle, jumpHopEntry]);
expect(
dedupePlatformPublicGalleryEntries([featuredPuzzle, latestPuzzle]),
).toEqual([latestPuzzle]);
});
test('public gallery ViewModel selects recommend feed window with wraparound neighbors', () => {
const firstEntry = buildPuzzleEntry({ profileId: 'first' });
const secondEntry = buildJumpHopEntry({ profileId: 'second' });
const thirdEntry = buildWoodenFishEntry({ profileId: 'third' });
const entries = [firstEntry, secondEntry, thirdEntry];
expect(selectPlatformRecommendFeedWindow([], 'missing')).toEqual({
activeEntry: null,
activeEntryKey: null,
activeIndex: -1,
nextEntry: null,
previousEntry: null,
});
expect(selectPlatformRecommendFeedWindow([firstEntry], null)).toEqual({
activeEntry: firstEntry,
activeEntryKey: buildPublicGalleryCardKey(firstEntry),
activeIndex: 0,
nextEntry: null,
previousEntry: null,
});
expect(
selectPlatformRecommendFeedWindow(
entries,
buildPublicGalleryCardKey(secondEntry),
),
).toEqual({
activeEntry: secondEntry,
activeEntryKey: buildPublicGalleryCardKey(secondEntry),
activeIndex: 1,
nextEntry: thirdEntry,
previousEntry: firstEntry,
});
expect(selectPlatformRecommendFeedWindow(entries, 'missing')).toEqual({
activeEntry: firstEntry,
activeEntryKey: buildPublicGalleryCardKey(firstEntry),
activeIndex: 0,
nextEntry: secondEntry,
previousEntry: thirdEntry,
});
expect(selectPlatformRecommendFeedWindow(entries, null)).toEqual({
activeEntry: firstEntry,
activeEntryKey: buildPublicGalleryCardKey(firstEntry),
activeIndex: 0,
nextEntry: secondEntry,
previousEntry: thirdEntry,
});
});
test('public gallery ViewModel selects adjacent recommend entry without self-loop', () => {
const onlyEntry = buildPuzzleEntry({ profileId: 'only' });
const nextEntry = buildJumpHopEntry({ profileId: 'next' });
expect(
selectAdjacentPlatformRecommendEntry([onlyEntry], 1, 'missing'),
).toBeNull();
expect(
selectAdjacentPlatformRecommendEntry(
[onlyEntry, nextEntry],
1,
buildPublicGalleryCardKey(onlyEntry),
),
).toBe(nextEntry);
expect(
selectAdjacentPlatformRecommendEntry([onlyEntry, nextEntry], -1, 'missing'),
).toBe(nextEntry);
});
test('public gallery ViewModel searches compact work codes and ranks name prefix first', () => {
const nameMatch = buildPuzzleEntry({
profileId: 'name-match',
publicWorkCode: 'PZ-OLDER',
worldName: '星桥拼图',
updatedAt: '2026-05-01T00:00:00.000Z',
});
const codeMatch = buildPuzzleEntry({
profileId: 'code-match',
publicWorkCode: 'PZ-XING-QIAO',
worldName: '海雾机关',
updatedAt: '2026-05-03T00:00:00.000Z',
});
const jumpHopCodeMatch = buildJumpHopEntry({
profileId: 'jump-code-match',
publicWorkCode: 'JH-XING-QIAO',
worldName: '海雾跳跃',
});
const woodenFishCodeMatch = buildWoodenFishEntry({
profileId: 'wooden-code-match',
publicWorkCode: 'WF-DEEP-CALM',
worldName: '静心木鱼',
});
expect(filterPlatformWorkSearchResults([codeMatch, nameMatch], '星桥')).toEqual(
[nameMatch, codeMatch],
);
expect(filterPlatformWorkSearchResults([codeMatch], 'pz xing_qiao')).toEqual([
codeMatch,
]);
expect(
filterPlatformWorkSearchResults([jumpHopCodeMatch], 'jh xing-qiao'),
).toEqual([jumpHopCodeMatch]);
expect(
filterPlatformWorkSearchResults([woodenFishCodeMatch], 'wf deep_calm'),
).toEqual([woodenFishCodeMatch]);
});
test('public gallery ViewModel keeps source kinds behind one category filter seam', () => {
const jumpHopEntry = buildJumpHopEntry();
const woodenFishEntry = buildWoodenFishEntry();
const rpgEntry = buildRpgEntry();
expect(getPlatformCategoryKindFilter(jumpHopEntry)).toBe('jump-hop');
expect(getPlatformCategoryKindFilter(woodenFishEntry)).toBe('wooden-fish');
expect(getPlatformCategoryKindFilter(rpgEntry)).toBe('custom-world');
expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'jump-hop')).toBe(
true,
);
expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'wooden-fish')).toBe(
true,
);
expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'custom-world')).toBe(
false,
);
expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'custom-world')).toBe(
false,
);
});
test('public gallery ViewModel exposes category filter and sort option interface', () => {
expect(DEFAULT_PLATFORM_CATEGORY_KIND_FILTER).toBe('all');
expect(DEFAULT_PLATFORM_CATEGORY_SORT_MODE).toBe('composite');
expect(PLATFORM_CATEGORY_KIND_FILTERS.map((option) => option.label)).toEqual([
'全部',
'拼图',
'抓鹅',
'方洞',
'视觉',
'汪汪',
'大鱼',
'跳跃',
'木鱼',
'RPG',
]);
expect(PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => option.label)).toEqual([
'综合',
'最新',
'游玩',
'点赞',
]);
expect(getPlatformCategoryKindFilterOption('match3d')).toEqual({
id: 'match3d',
label: '抓鹅',
});
expect(getPlatformCategorySortOption('latest')).toEqual({
id: 'latest',
label: '最新',
});
expect(
getPlatformCategoryKindFilterOption(
'unknown' as PlatformCategoryKindFilter,
),
).toEqual({ id: 'all', label: '全部' });
expect(
getPlatformCategorySortOption('unknown' as PlatformCategorySortMode),
).toEqual({ id: 'composite', label: '综合' });
expect(getNextPlatformCategorySortMode('composite')).toBe('latest');
expect(getNextPlatformCategorySortMode('latest')).toBe('play');
expect(getNextPlatformCategorySortMode('play')).toBe('like');
expect(getNextPlatformCategorySortMode('like')).toBe('composite');
expect(
getNextPlatformCategorySortMode('unknown' as PlatformCategorySortMode),
).toBe('composite');
});
test('public gallery ViewModel ranks entries by selected metric', () => {
const playWinner = buildJumpHopEntry({
profileId: 'play-winner',
playCount: 100,
remixCount: 1,
likeCount: 1,
recentPlayCount7d: 1,
});
const remixWinner = buildPuzzleEntry({
profileId: 'remix-winner',
playCount: 2,
remixCount: 50,
likeCount: 2,
recentPlayCount7d: 2,
});
const recentWinner = buildPuzzleEntry({
profileId: 'recent-winner',
playCount: 3,
remixCount: 3,
likeCount: 3,
recentPlayCount7d: 30,
});
const likeWinner = buildWoodenFishEntry({
profileId: 'like-winner',
playCount: 4,
remixCount: 4,
likeCount: 40,
recentPlayCount7d: 4,
});
const entries = [recentWinner, remixWinner, likeWinner, playWinner];
expect(DEFAULT_PLATFORM_RANKING_TAB).toBe('hot');
expect(PLATFORM_RANKING_TABS.map((tab) => tab.label)).toEqual([
'热门榜',
'改造榜',
'新品榜',
'点赞榜',
]);
expect(getPlatformRankingTabConfig('new')).toEqual({
id: 'new',
label: '新品榜',
metricLabel: '近7日',
emptyText: '近 7 日暂时还没有新品。',
});
expect(buildPlatformRankingEntries(entries, 'hot')[0]).toBe(playWinner);
expect(buildPlatformRankingEntries(entries, 'remix')[0]).toBe(remixWinner);
expect(buildPlatformRankingEntries(entries, 'new')[0]).toBe(recentWinner);
expect(buildPlatformRankingEntries(entries, 'like')[0]).toBe(likeWinner);
expect(getPlatformRankingMetricValue(likeWinner, 'like')).toBe(40);
expect(getPlatformRankingMetric(recentWinner, 'new')).toEqual({
label: '近7日',
value: 30,
});
expect(getPlatformRankingMetric(playWinner, 'hot')).toEqual({
label: '游玩',
value: 100,
});
});
test('public gallery ViewModel sorts category entries and exposes primary metric', () => {
const latestEntry = buildWoodenFishEntry({
profileId: 'latest',
playCount: 1,
likeCount: 0,
recentPlayCount7d: 0,
publishedAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
});
const playEntry = buildJumpHopEntry({
profileId: 'play',
playCount: 100,
likeCount: 0,
recentPlayCount7d: 0,
publishedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
});
const likeEntry = buildPuzzleEntry({
profileId: 'like',
playCount: 1,
likeCount: 20,
recentPlayCount7d: 0,
publishedAt: '2026-05-02T00:00:00.000Z',
updatedAt: '2026-05-02T00:00:00.000Z',
});
const compositeEntry = buildPuzzleEntry({
profileId: 'composite',
playCount: 30,
remixCount: 30,
likeCount: 30,
recentPlayCount7d: 30,
publishedAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
});
const entries = [likeEntry, latestEntry, compositeEntry, playEntry];
expect(sortPlatformCategoryEntries(entries, 'latest')[0]).toBe(latestEntry);
expect(sortPlatformCategoryEntries(entries, 'play')[0]).toBe(playEntry);
expect(sortPlatformCategoryEntries(entries, 'like')[0]).toBe(compositeEntry);
expect(sortPlatformCategoryEntries(entries, 'composite')[0]).toBe(
compositeEntry,
);
expect(getPlatformCategoryPrimaryMetric(likeEntry)).toEqual({
label: '点赞',
value: 20,
});
expect(
getPlatformCategoryPrimaryMetric(
buildPuzzleEntry({ likeCount: 0, recentPlayCount7d: 8, playCount: 2 }),
),
).toEqual({ label: '近7日', value: 8 });
});
test('public gallery ViewModel filters entries published on the local day', () => {
const now = new Date(2026, 5, 3, 12);
const todayEntry = buildPuzzleEntry({
profileId: 'today',
publishedAt: new Date(2026, 5, 3, 8).toISOString(),
});
const yesterdayEntry = buildPuzzleEntry({
profileId: 'yesterday',
publishedAt: new Date(2026, 5, 2, 8).toISOString(),
});
const unpublishedEntry = buildPuzzleEntry({
profileId: 'unpublished',
publishedAt: null,
});
expect(
filterTodayPublishedEntries(
[yesterdayEntry, todayEntry, unpublishedEntry],
now,
),
).toEqual([todayEntry]);
});
test('public gallery ViewModel parses backend numeric timestamps', () => {
expect(parsePlatformEntryTimestamp('1778457601.234567Z')).toBe(
1778457601234.567,
);
});

View File

@@ -0,0 +1,657 @@
import { filterGeneralPublicWorks } from '../platform-entry/platformEdutainmentVisibility';
import { getPlatformPublicGalleryEntryKey } from '../platform-entry/platformPublicGalleryFlow';
import {
buildPlatformWorldDisplayTags,
isBarkBattleGalleryEntry,
isBigFishGalleryEntry,
isJumpHopGalleryEntry,
isMatch3DGalleryEntry,
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
} from './rpgEntryWorldPresentation';
export type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
export type PlatformRankingTabConfig = {
emptyText: string;
id: PlatformRankingTab;
label: string;
metricLabel: string;
};
export type PlatformRankingMetric = {
label: string;
value: number;
};
export type PlatformCategoryKindFilter =
| 'all'
| 'puzzle'
| 'match3d'
| 'square-hole'
| 'visual-novel'
| 'bark-battle'
| 'big-fish'
| 'jump-hop'
| 'wooden-fish'
| 'custom-world';
export type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
export type PlatformCategoryKindFilterOption = {
id: PlatformCategoryKindFilter;
label: string;
};
export type PlatformCategorySortOption = {
id: PlatformCategorySortMode;
label: string;
};
export type PlatformPublicCategoryGroup = {
tag: string;
entries: PlatformPublicGalleryCard[];
};
export const DEFAULT_PLATFORM_RANKING_TAB: PlatformRankingTab = 'hot';
export const DEFAULT_PLATFORM_CATEGORY_KIND_FILTER: PlatformCategoryKindFilter =
'all';
export const DEFAULT_PLATFORM_CATEGORY_SORT_MODE: PlatformCategorySortMode =
'composite';
export const PLATFORM_RANKING_TABS: PlatformRankingTabConfig[] = [
{
id: 'hot',
label: '热门榜',
metricLabel: '游玩',
emptyText: '公开广场暂时还没有热门作品。',
},
{
id: 'remix',
label: '改造榜',
metricLabel: '改造',
emptyText: '公开广场暂时还没有改造作品。',
},
{
id: 'new',
label: '新品榜',
metricLabel: '近7日',
emptyText: '近 7 日暂时还没有新品。',
},
{
id: 'like',
label: '点赞榜',
metricLabel: '点赞',
emptyText: '公开广场暂时还没有点赞作品。',
},
];
const DEFAULT_PLATFORM_RANKING_CONFIG =
PLATFORM_RANKING_TABS.find(
(config) => config.id === DEFAULT_PLATFORM_RANKING_TAB,
) ?? PLATFORM_RANKING_TABS[0]!;
export const PLATFORM_CATEGORY_KIND_FILTERS: PlatformCategoryKindFilterOption[] =
[
{ id: 'all', label: '全部' },
{ id: 'puzzle', label: '拼图' },
{ id: 'match3d', label: '抓鹅' },
{ id: 'square-hole', label: '方洞' },
{ id: 'visual-novel', label: '视觉' },
{ id: 'bark-battle', label: '汪汪' },
{ id: 'big-fish', label: '大鱼' },
{ id: 'jump-hop', label: '跳跃' },
{ id: 'wooden-fish', label: '木鱼' },
{ id: 'custom-world', label: 'RPG' },
];
export const PLATFORM_CATEGORY_SORT_OPTIONS: PlatformCategorySortOption[] = [
{ id: 'composite', label: '综合' },
{ id: 'latest', label: '最新' },
{ id: 'play', label: '游玩' },
{ id: 'like', label: '点赞' },
];
const DEFAULT_PLATFORM_CATEGORY_KIND_FILTER_OPTION =
PLATFORM_CATEGORY_KIND_FILTERS.find(
(option) => option.id === DEFAULT_PLATFORM_CATEGORY_KIND_FILTER,
) ?? PLATFORM_CATEGORY_KIND_FILTERS[0]!;
const DEFAULT_PLATFORM_CATEGORY_SORT_OPTION =
PLATFORM_CATEGORY_SORT_OPTIONS.find(
(option) => option.id === DEFAULT_PLATFORM_CATEGORY_SORT_MODE,
) ?? PLATFORM_CATEGORY_SORT_OPTIONS[0]!;
export type PlatformRecommendFeedWindow = {
activeEntry: PlatformPublicGalleryCard | null;
activeEntryKey: string | null;
activeIndex: number;
nextEntry: PlatformPublicGalleryCard | null;
previousEntry: PlatformPublicGalleryCard | null;
};
export function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
return getPlatformPublicGalleryEntryKey(entry);
}
export function dedupePlatformPublicGalleryEntries(
entries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
entries.forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}
export function buildPlatformRecommendFeedEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
return dedupePlatformPublicGalleryEntries(
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]),
);
}
export function selectAdjacentPlatformRecommendEntry(
entries: PlatformPublicGalleryCard[],
direction: 1 | -1,
baseEntryKey?: string | null,
) {
if (entries.length <= 1) {
return null;
}
const normalizedBaseEntryKey = baseEntryKey?.trim() ?? '';
const activeIndex = normalizedBaseEntryKey
? entries.findIndex(
(entry) => buildPublicGalleryCardKey(entry) === normalizedBaseEntryKey,
)
: -1;
const baseIndex = activeIndex >= 0 ? activeIndex : 0;
const nextIndex =
(baseIndex + direction + entries.length) % entries.length;
const nextEntry = entries[nextIndex] ?? null;
if (
nextEntry &&
normalizedBaseEntryKey &&
buildPublicGalleryCardKey(nextEntry) === normalizedBaseEntryKey
) {
return null;
}
return nextEntry;
}
export function selectPlatformRecommendFeedWindow(
entries: PlatformPublicGalleryCard[],
activeEntryKey?: string | null,
): PlatformRecommendFeedWindow {
const normalizedActiveEntryKey = activeEntryKey?.trim() ?? '';
const activeEntry =
(normalizedActiveEntryKey
? entries.find(
(entry) =>
buildPublicGalleryCardKey(entry) === normalizedActiveEntryKey,
)
: null) ??
entries[0] ??
null;
const selectedActiveEntryKey = activeEntry
? buildPublicGalleryCardKey(activeEntry)
: null;
const activeIndex = selectedActiveEntryKey
? entries.findIndex(
(entry) => buildPublicGalleryCardKey(entry) === selectedActiveEntryKey,
)
: -1;
return {
activeEntry,
activeEntryKey: selectedActiveEntryKey,
activeIndex,
nextEntry: selectAdjacentPlatformRecommendEntry(
entries,
1,
selectedActiveEntryKey,
),
previousEntry: selectAdjacentPlatformRecommendEntry(
entries,
-1,
selectedActiveEntryKey,
),
};
}
export function buildPublicCategoryGroups(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
): PlatformPublicCategoryGroup[] {
const publicEntries = buildPlatformRecommendFeedEntries(
featuredEntries,
latestEntries,
);
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
publicEntries.forEach((entry) => {
const tags = buildPlatformWorldDisplayTags(entry, 3);
const normalizedTags = tags.length > 0 ? tags : ['回响'];
normalizedTags.forEach((tag) => {
const entries = categoryMap.get(tag) ?? [];
entries.push(entry);
categoryMap.set(tag, entries);
});
});
return Array.from(categoryMap.entries())
.map(([tag, entries]) => ({ tag, entries }))
.sort((left, right) => {
if (right.entries.length !== left.entries.length) {
return right.entries.length - left.entries.length;
}
return left.tag.localeCompare(right.tag, 'zh-CN');
});
}
export function getPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
return buildPlatformRecommendFeedEntries(featuredEntries, latestEntries);
}
export function getAllPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
return dedupePlatformPublicGalleryEntries([
...featuredEntries,
...latestEntries,
]);
}
function normalizePlatformSearchText(value: string | null | undefined) {
return (value ?? '').trim().toLocaleLowerCase('zh-CN');
}
function normalizePlatformCompactSearchText(value: string | null | undefined) {
return normalizePlatformSearchText(value).replace(/[\s_-]+/gu, '');
}
export function getPlatformSearchableWorkIds(
entry: PlatformPublicGalleryCard,
) {
const ids = [entry.publicWorkCode, entry.profileId];
if ('workId' in entry) {
ids.push(entry.workId);
}
return ids.filter((value): value is string => Boolean(value?.trim()));
}
function buildPlatformWorkSearchText(entry: PlatformPublicGalleryCard) {
return [
...getPlatformSearchableWorkIds(entry),
entry.worldName,
entry.authorDisplayName,
entry.summaryText,
entry.subtitle,
].join(' ');
}
function matchesPlatformWorkSearch(
entry: PlatformPublicGalleryCard,
keyword: string,
) {
const normalizedKeyword = normalizePlatformSearchText(keyword);
const compactKeyword = normalizePlatformCompactSearchText(keyword);
if (!normalizedKeyword) {
return false;
}
const normalizedSearchText = normalizePlatformSearchText(
buildPlatformWorkSearchText(entry),
);
if (normalizedSearchText.includes(normalizedKeyword)) {
return true;
}
return (
Boolean(compactKeyword) &&
normalizePlatformCompactSearchText(
buildPlatformWorkSearchText(entry),
).includes(compactKeyword)
);
}
export function filterPlatformWorkSearchResults(
entries: PlatformPublicGalleryCard[],
keyword: string,
) {
return entries
.filter((entry) => matchesPlatformWorkSearch(entry, keyword))
.sort((left, right) => {
const leftCode = getPlatformSearchableWorkIds(left)[0] ?? '';
const rightCode = getPlatformSearchableWorkIds(right)[0] ?? '';
const normalizedKeyword = normalizePlatformSearchText(keyword);
const leftNameStarts = normalizePlatformSearchText(
left.worldName,
).startsWith(normalizedKeyword);
const rightNameStarts = normalizePlatformSearchText(
right.worldName,
).startsWith(normalizedKeyword);
if (leftNameStarts !== rightNameStarts) {
return leftNameStarts ? -1 : 1;
}
const compactKeyword = normalizePlatformCompactSearchText(keyword);
const leftCodeStarts =
normalizePlatformCompactSearchText(leftCode).startsWith(compactKeyword);
const rightCodeStarts =
normalizePlatformCompactSearchText(rightCode).startsWith(
compactKeyword,
);
if (leftCodeStarts !== rightCodeStarts) {
return leftCodeStarts ? -1 : 1;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
export function isExactPublicWorkCodeSearch(
entries: PlatformPublicGalleryCard[],
keyword: string,
) {
const normalizedKeyword = normalizePlatformSearchText(keyword);
return entries.some(
(entry) =>
Boolean(entry.publicWorkCode?.trim()) &&
normalizePlatformSearchText(entry.publicWorkCode) === normalizedKeyword,
);
}
export function getPlatformWorldTimestamp(entry: PlatformWorldCardLike) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
return parsePlatformEntryTimestamp(rawTime);
}
function isSameLocalCalendarDay(left: Date, right: Date) {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function isPlatformEntryPublishedToday(
entry: PlatformPublicGalleryCard,
now = new Date(),
) {
const publishedAtTimestamp = parsePlatformEntryTimestamp(entry.publishedAt);
if (publishedAtTimestamp <= 0) {
return false;
}
return isSameLocalCalendarDay(new Date(publishedAtTimestamp), now);
}
export function filterTodayPublishedEntries(
entries: PlatformPublicGalleryCard[],
now = new Date(),
) {
return entries.filter((entry) => isPlatformEntryPublishedToday(entry, now));
}
export function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
return Math.max(0, Math.round(('likeCount' in entry && entry.likeCount) || 0));
}
export function getPlatformWorldPlayCount(entry: PlatformWorldCardLike) {
return Math.max(0, Math.round(('playCount' in entry && entry.playCount) || 0));
}
export function getPlatformWorldRemixCount(entry: PlatformWorldCardLike) {
return Math.max(
0,
Math.round(('remixCount' in entry && entry.remixCount) || 0),
);
}
function getPlatformWorldRecentPlayCount(entry: PlatformWorldCardLike) {
return Math.max(
0,
Math.round(('recentPlayCount7d' in entry && entry.recentPlayCount7d) || 0),
);
}
function sortEntriesByMetric(
entries: PlatformPublicGalleryCard[],
getMetric: (entry: PlatformPublicGalleryCard) => number,
) {
return [...entries].sort((left, right) => {
const metricDiff = getMetric(right) - getMetric(left);
if (metricDiff !== 0) {
return metricDiff;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
export function buildPlatformRankingEntries(
entries: PlatformPublicGalleryCard[],
tab: PlatformRankingTab,
) {
if (tab === 'hot') {
return sortEntriesByMetric(entries, getPlatformWorldPlayCount);
}
if (tab === 'remix') {
return sortEntriesByMetric(entries, getPlatformWorldRemixCount);
}
if (tab === 'like') {
return sortEntriesByMetric(entries, getPlatformWorldLikeCount);
}
return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount);
}
export function getPlatformRankingTabConfig(
tab: PlatformRankingTab,
): PlatformRankingTabConfig {
return (
PLATFORM_RANKING_TABS.find((config) => config.id === tab) ??
DEFAULT_PLATFORM_RANKING_CONFIG
);
}
export function getPlatformRankingMetricValue(
entry: PlatformPublicGalleryCard,
tab: PlatformRankingTab,
) {
if (tab === 'remix') {
return getPlatformWorldRemixCount(entry);
}
if (tab === 'like') {
return getPlatformWorldLikeCount(entry);
}
if (tab === 'new') {
return getPlatformWorldRecentPlayCount(entry);
}
return getPlatformWorldPlayCount(entry);
}
export function getPlatformRankingMetric(
entry: PlatformPublicGalleryCard,
tab: PlatformRankingTab,
): PlatformRankingMetric {
return {
label: getPlatformRankingTabConfig(tab).metricLabel,
value: getPlatformRankingMetricValue(entry, tab),
};
}
function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) {
// 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。
return (
getPlatformWorldPlayCount(entry) +
getPlatformWorldRemixCount(entry) +
getPlatformWorldLikeCount(entry) +
getPlatformWorldRecentPlayCount(entry)
);
}
export function getPlatformCategoryKindFilter(
entry: PlatformPublicGalleryCard,
): Exclude<PlatformCategoryKindFilter, 'all'> {
if (isPuzzleGalleryEntry(entry)) {
return 'puzzle';
}
if (isMatch3DGalleryEntry(entry)) {
return 'match3d';
}
if (isSquareHoleGalleryEntry(entry)) {
return 'square-hole';
}
if (isVisualNovelGalleryEntry(entry)) {
return 'visual-novel';
}
if (isBarkBattleGalleryEntry(entry)) {
return 'bark-battle';
}
if (isBigFishGalleryEntry(entry)) {
return 'big-fish';
}
if (isJumpHopGalleryEntry(entry)) {
return 'jump-hop';
}
if (isWoodenFishGalleryEntry(entry)) {
return 'wooden-fish';
}
return 'custom-world';
}
export function matchesPlatformCategoryKindFilter(
entry: PlatformPublicGalleryCard,
kindFilter: PlatformCategoryKindFilter,
) {
return (
kindFilter === 'all' || getPlatformCategoryKindFilter(entry) === kindFilter
);
}
export function sortPlatformCategoryEntries(
entries: PlatformPublicGalleryCard[],
sortMode: PlatformCategorySortMode,
) {
return [...entries].sort((left, right) => {
if (sortMode === 'latest') {
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
}
const metricDiff =
sortMode === 'play'
? getPlatformWorldPlayCount(right) - getPlatformWorldPlayCount(left)
: sortMode === 'like'
? getPlatformWorldLikeCount(right) - getPlatformWorldLikeCount(left)
: getPlatformCategoryCompositeScore(right) -
getPlatformCategoryCompositeScore(left);
if (metricDiff !== 0) {
return metricDiff;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
export function getPlatformCategoryPrimaryMetric(
entry: PlatformPublicGalleryCard,
) {
const likeCount = getPlatformWorldLikeCount(entry);
if (likeCount > 0) {
return { label: '点赞', value: likeCount };
}
const recentPlayCount = getPlatformWorldRecentPlayCount(entry);
if (recentPlayCount > 0) {
return { label: '近7日', value: recentPlayCount };
}
return { label: '游玩', value: getPlatformWorldPlayCount(entry) };
}
export function getPlatformCategoryKindFilterOption(
kindFilter: PlatformCategoryKindFilter,
): PlatformCategoryKindFilterOption {
return (
PLATFORM_CATEGORY_KIND_FILTERS.find((option) => option.id === kindFilter) ??
DEFAULT_PLATFORM_CATEGORY_KIND_FILTER_OPTION
);
}
export function getPlatformCategorySortOption(
sortMode: PlatformCategorySortMode,
): PlatformCategorySortOption {
return (
PLATFORM_CATEGORY_SORT_OPTIONS.find((option) => option.id === sortMode) ??
DEFAULT_PLATFORM_CATEGORY_SORT_OPTION
);
}
export function getNextPlatformCategorySortMode(
sortMode: PlatformCategorySortMode,
): PlatformCategorySortMode {
const currentIndex = PLATFORM_CATEGORY_SORT_OPTIONS.findIndex(
(option) => option.id === sortMode,
);
const nextIndex =
currentIndex >= 0
? (currentIndex + 1) % PLATFORM_CATEGORY_SORT_OPTIONS.length
: 0;
return (
PLATFORM_CATEGORY_SORT_OPTIONS[nextIndex]?.id ??
DEFAULT_PLATFORM_CATEGORY_SORT_MODE
);
}
export function parsePlatformEntryTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
const timestampMs =
absoluteTimestamp >= 1_000_000_000_000_000
? rawTimestamp / 1000
: absoluteTimestamp >= 1_000_000_000_000
? rawTimestamp
: absoluteTimestamp >= 1_000_000_000
? rawTimestamp * 1000
: Number.NaN;
return Number.isNaN(timestampMs) ? 0 : timestampMs;
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}

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

View File

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

View File

@@ -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') {