Merge codex/sse-stream-architecture into architecture adjustment
This commit is contained in:
@@ -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}泥点`,
|
||||
};
|
||||
}
|
||||
161
src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts
Normal file
161
src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts
Normal 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');
|
||||
});
|
||||
108
src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts
Normal file
108
src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts
Normal 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)}`
|
||||
: '会员已生效';
|
||||
}
|
||||
127
src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts
Normal file
127
src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts
Normal 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('领取');
|
||||
});
|
||||
107
src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts
Normal file
107
src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
505
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts
Normal file
505
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
657
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts
Normal file
657
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts
Normal 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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user