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}泥点`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
ProfileMembership,
|
||||
ProfileRechargeProduct,
|
||||
ProfileWalletLedgerEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildMembershipLabel,
|
||||
buildRechargeProductValueLabel,
|
||||
buildWalletLedgerPresentation,
|
||||
formatRechargePrice,
|
||||
formatWalletLedgerAmount,
|
||||
getWalletLedgerSourceLabel,
|
||||
} from './rpgEntryProfileFundsViewModel';
|
||||
|
||||
function buildLedgerEntry(
|
||||
overrides: Partial<ProfileWalletLedgerEntry> = {},
|
||||
): ProfileWalletLedgerEntry {
|
||||
return {
|
||||
id: 'ledger-1',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 80,
|
||||
sourceType: 'invite_invitee_reward',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRechargeProduct(
|
||||
overrides: Partial<ProfileRechargeProduct> = {},
|
||||
): ProfileRechargeProduct {
|
||||
return {
|
||||
productId: 'points_60',
|
||||
title: '60泥点',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 60,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充双倍',
|
||||
description: '首充送60泥点',
|
||||
tier: 'normal',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMembership(
|
||||
overrides: Partial<ProfileMembership> = {},
|
||||
): ProfileMembership {
|
||||
return {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('profile funds ViewModel formats ledger amount labels', () => {
|
||||
expect(formatWalletLedgerAmount(-1)).toBe('-1');
|
||||
expect(formatWalletLedgerAmount(0)).toBe('0');
|
||||
expect(formatWalletLedgerAmount(30)).toBe('+30');
|
||||
});
|
||||
|
||||
test('profile funds ViewModel resolves ledger source labels with raw fallback', () => {
|
||||
expect(getWalletLedgerSourceLabel('asset_operation_consume')).toBe(
|
||||
'资产操作消耗',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('puzzle_author_incentive_claim')).toBe(
|
||||
'拼图作者奖励',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source');
|
||||
expect(getWalletLedgerSourceLabel('')).toBe('未知来源');
|
||||
});
|
||||
|
||||
test('profile funds ViewModel builds wallet ledger presentation', () => {
|
||||
const incomeEntry = buildLedgerEntry({
|
||||
id: 'ledger-income',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 80,
|
||||
sourceType: 'puzzle_author_incentive_claim',
|
||||
});
|
||||
const outcomeEntry = buildLedgerEntry({
|
||||
id: 'ledger-outcome',
|
||||
amountDelta: -1,
|
||||
balanceAfter: 79,
|
||||
sourceType: 'asset_operation_consume',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWalletLedgerPresentation(
|
||||
{ entries: [incomeEntry, outcomeEntry] },
|
||||
12,
|
||||
),
|
||||
).toEqual({
|
||||
balance: 80,
|
||||
balanceLabel: '80泥点',
|
||||
entries: [
|
||||
{
|
||||
amountLabel: '+30',
|
||||
balanceLabel: '余额 80',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
id: 'ledger-income',
|
||||
isIncome: true,
|
||||
sourceLabel: '拼图作者奖励',
|
||||
},
|
||||
{
|
||||
amountLabel: '-1',
|
||||
balanceLabel: '余额 79',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
id: 'ledger-outcome',
|
||||
isIncome: false,
|
||||
sourceLabel: '资产操作消耗',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(buildWalletLedgerPresentation({ entries: [] }, 12)).toEqual({
|
||||
balance: 12,
|
||||
balanceLabel: '12泥点',
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('profile funds ViewModel formats recharge product and membership labels', () => {
|
||||
expect(formatRechargePrice(600)).toBe('¥6');
|
||||
expect(formatRechargePrice(650)).toBe('¥6.50');
|
||||
expect(buildRechargeProductValueLabel(buildRechargeProduct())).toBe(
|
||||
'60+60泥点',
|
||||
);
|
||||
expect(
|
||||
buildRechargeProductValueLabel(
|
||||
buildRechargeProduct({
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 30,
|
||||
}),
|
||||
),
|
||||
).toBe('30天');
|
||||
expect(buildMembershipLabel(buildMembership(), (value) => value)).toBe(
|
||||
'普通用户',
|
||||
);
|
||||
expect(
|
||||
buildMembershipLabel(
|
||||
buildMembership({ status: 'active', expiresAt: null }),
|
||||
(value) => value,
|
||||
),
|
||||
).toBe('会员已生效');
|
||||
expect(
|
||||
buildMembershipLabel(
|
||||
buildMembership({
|
||||
status: 'active',
|
||||
expiresAt: '2026-06-03T00:00:00.000Z',
|
||||
}),
|
||||
() => '06/03 08:00',
|
||||
),
|
||||
).toBe('会员至 06/03 08:00');
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
ProfileMembership,
|
||||
ProfileRechargeProduct,
|
||||
ProfileWalletLedgerEntry,
|
||||
ProfileWalletLedgerResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
|
||||
new_user_registration_reward: '注册赠送',
|
||||
points_recharge: '泥点充值',
|
||||
invite_inviter_reward: '邀请奖励',
|
||||
invite_invitee_reward: '填写邀请码奖励',
|
||||
snapshot_sync: '账户同步',
|
||||
asset_operation_consume: '资产操作消耗',
|
||||
asset_operation_refund: '资产操作退回',
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
puzzle_author_incentive_claim: '拼图作者奖励',
|
||||
daily_task_reward: '每日任务奖励',
|
||||
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
|
||||
|
||||
export type ProfileWalletLedgerEntryPresentation = {
|
||||
amountLabel: string;
|
||||
balanceLabel: string;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
isIncome: boolean;
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
export type ProfileWalletLedgerPresentation = {
|
||||
balance: number;
|
||||
balanceLabel: string;
|
||||
entries: ProfileWalletLedgerEntryPresentation[];
|
||||
};
|
||||
|
||||
export function getWalletLedgerSourceLabel(
|
||||
sourceType: string | null | undefined,
|
||||
) {
|
||||
const normalizedSourceType = sourceType?.trim() ?? '';
|
||||
if (!normalizedSourceType) {
|
||||
return '未知来源';
|
||||
}
|
||||
|
||||
return (
|
||||
PROFILE_WALLET_LEDGER_SOURCE_LABELS[
|
||||
normalizedSourceType as ProfileWalletLedgerEntry['sourceType']
|
||||
] ?? normalizedSourceType
|
||||
);
|
||||
}
|
||||
|
||||
export function formatWalletLedgerAmount(amountDelta: number) {
|
||||
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
|
||||
}
|
||||
|
||||
export function buildWalletLedgerEntryPresentation(
|
||||
entry: ProfileWalletLedgerEntry,
|
||||
): ProfileWalletLedgerEntryPresentation {
|
||||
return {
|
||||
amountLabel: formatWalletLedgerAmount(entry.amountDelta),
|
||||
balanceLabel: `余额 ${entry.balanceAfter}`,
|
||||
createdAt: entry.createdAt,
|
||||
id: entry.id,
|
||||
isIncome: entry.amountDelta > 0,
|
||||
sourceLabel: getWalletLedgerSourceLabel(entry.sourceType),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWalletLedgerPresentation(
|
||||
ledger: ProfileWalletLedgerResponse | null,
|
||||
fallbackBalance: number,
|
||||
): ProfileWalletLedgerPresentation {
|
||||
const entries = ledger?.entries ?? [];
|
||||
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
|
||||
|
||||
return {
|
||||
balance,
|
||||
balanceLabel: `${balance}泥点`,
|
||||
entries: entries.map(buildWalletLedgerEntryPresentation),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function buildRechargeProductValueLabel(product: ProfileRechargeProduct) {
|
||||
if (product.kind === 'membership') {
|
||||
return `${product.durationDays}天`;
|
||||
}
|
||||
|
||||
return `${product.pointsAmount}${
|
||||
product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''
|
||||
}泥点`;
|
||||
}
|
||||
|
||||
export function buildMembershipLabel(
|
||||
membership: ProfileMembership | null | undefined,
|
||||
formatTime: (value: string) => string,
|
||||
) {
|
||||
if (membership?.status !== 'active') {
|
||||
return '普通用户';
|
||||
}
|
||||
|
||||
return membership.expiresAt
|
||||
? `会员至 ${formatTime(membership.expiresAt)}`
|
||||
: '会员已生效';
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
ProfileTaskCenterResponse,
|
||||
ProfileTaskItem,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildProfileTaskCardSummary,
|
||||
buildProfileTaskProgressLabel,
|
||||
getProfileTaskClaimButtonLabel,
|
||||
getProfileTaskStatusLabel,
|
||||
selectProfileTaskCardTask,
|
||||
selectProfileTaskCenterTasks,
|
||||
} from './rpgEntryProfileTaskViewModel';
|
||||
|
||||
function buildTask(
|
||||
overrides: Partial<ProfileTaskItem> = {},
|
||||
): ProfileTaskItem {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
title: '游玩一次',
|
||||
description: '完成一次游戏',
|
||||
eventKey: 'work_play_start',
|
||||
cycle: 'daily',
|
||||
threshold: 1,
|
||||
progressCount: 0,
|
||||
rewardPoints: 10,
|
||||
status: 'incomplete',
|
||||
dayKey: 20260603,
|
||||
claimedAt: null,
|
||||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCenter(
|
||||
tasks: ProfileTaskItem[],
|
||||
): ProfileTaskCenterResponse {
|
||||
return {
|
||||
dayKey: 20260603,
|
||||
walletBalance: 12,
|
||||
tasks,
|
||||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
test('profile task ViewModel selects one actionable task by status priority and original order', () => {
|
||||
const firstIncomplete = buildTask({
|
||||
taskId: 'incomplete-1',
|
||||
status: 'incomplete',
|
||||
});
|
||||
const secondIncomplete = buildTask({
|
||||
taskId: 'incomplete-2',
|
||||
status: 'incomplete',
|
||||
});
|
||||
const claimable = buildTask({
|
||||
taskId: 'claimable-1',
|
||||
status: 'claimable',
|
||||
});
|
||||
|
||||
expect(
|
||||
selectProfileTaskCenterTasks([
|
||||
firstIncomplete,
|
||||
secondIncomplete,
|
||||
claimable,
|
||||
]),
|
||||
).toEqual([claimable]);
|
||||
expect(selectProfileTaskCenterTasks([firstIncomplete, secondIncomplete])).toEqual(
|
||||
[firstIncomplete],
|
||||
);
|
||||
});
|
||||
|
||||
test('profile task ViewModel falls back from card task to claimed and enabled tasks', () => {
|
||||
const claimed = buildTask({ taskId: 'claimed-1', status: 'claimed' });
|
||||
const disabled = buildTask({ taskId: 'disabled-1', status: 'disabled' });
|
||||
const incomplete = buildTask({
|
||||
taskId: 'incomplete-1',
|
||||
status: 'incomplete',
|
||||
});
|
||||
|
||||
expect(selectProfileTaskCardTask([disabled, claimed])).toBe(claimed);
|
||||
expect(selectProfileTaskCardTask([disabled, incomplete])).toBe(incomplete);
|
||||
expect(selectProfileTaskCardTask([disabled])).toBeNull();
|
||||
});
|
||||
|
||||
test('profile task ViewModel builds card summary with reward fallback and clamped progress', () => {
|
||||
expect(buildProfileTaskCardSummary(null)).toEqual({
|
||||
actionLabel: '去完成',
|
||||
progressCount: 0,
|
||||
progressPercent: 0,
|
||||
rewardPoints: 10,
|
||||
threshold: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildProfileTaskCardSummary(
|
||||
buildCenter([
|
||||
buildTask({
|
||||
progressCount: 5,
|
||||
rewardPoints: 25,
|
||||
status: 'claimable',
|
||||
threshold: 3,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
).toEqual({
|
||||
actionLabel: '领取',
|
||||
progressCount: 3,
|
||||
progressPercent: 100,
|
||||
rewardPoints: 25,
|
||||
threshold: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('profile task ViewModel exposes task labels for the modal', () => {
|
||||
const task = buildTask({ progressCount: -1, threshold: 0 });
|
||||
|
||||
expect(getProfileTaskStatusLabel('claimable')).toBe('可领取');
|
||||
expect(buildProfileTaskProgressLabel(task)).toBe('0/1');
|
||||
expect(getProfileTaskClaimButtonLabel(task, true)).toBe('领取中');
|
||||
expect(getProfileTaskClaimButtonLabel(buildTask({ status: 'claimed' }), false)).toBe(
|
||||
'已领取',
|
||||
);
|
||||
expect(
|
||||
getProfileTaskClaimButtonLabel(buildTask({ status: 'claimable' }), false),
|
||||
).toBe('领取');
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
ProfileTaskCenterResponse,
|
||||
ProfileTaskItem,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<
|
||||
ProfileTaskItem['status'],
|
||||
number
|
||||
> = {
|
||||
claimable: 2,
|
||||
incomplete: 1,
|
||||
disabled: 0,
|
||||
claimed: -1,
|
||||
};
|
||||
const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10;
|
||||
const PROFILE_TASK_STATUS_LABELS: Record<ProfileTaskItem['status'], string> = {
|
||||
incomplete: '未完成',
|
||||
claimable: '可领取',
|
||||
claimed: '已领取',
|
||||
disabled: '已停用',
|
||||
};
|
||||
|
||||
export type ProfileTaskCardSummary = {
|
||||
actionLabel: string;
|
||||
progressCount: number;
|
||||
progressPercent: number;
|
||||
rewardPoints: number;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
export function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
|
||||
return tasks
|
||||
.map((task, index) => ({ task, index }))
|
||||
.filter(
|
||||
({ task }) =>
|
||||
task.status === 'claimable' || task.status === 'incomplete',
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
|
||||
PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] ||
|
||||
left.index - right.index,
|
||||
)
|
||||
.slice(0, 1)
|
||||
.map(({ task }) => task);
|
||||
}
|
||||
|
||||
export function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) {
|
||||
return (
|
||||
selectProfileTaskCenterTasks(tasks)[0] ??
|
||||
tasks.find((task) => task.status === 'claimed') ??
|
||||
tasks.find((task) => task.status !== 'disabled') ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function getProfileTaskStatusLabel(status: ProfileTaskItem['status']) {
|
||||
return PROFILE_TASK_STATUS_LABELS[status];
|
||||
}
|
||||
|
||||
export function buildProfileTaskProgressLabel(task: ProfileTaskItem) {
|
||||
const threshold = Math.max(1, task.threshold);
|
||||
const progressCount = Math.min(Math.max(0, task.progressCount), threshold);
|
||||
return `${progressCount}/${threshold}`;
|
||||
}
|
||||
|
||||
export function getProfileTaskClaimButtonLabel(
|
||||
task: ProfileTaskItem,
|
||||
isClaiming: boolean,
|
||||
) {
|
||||
if (isClaiming) {
|
||||
return '领取中';
|
||||
}
|
||||
|
||||
if (task.status === 'claimed') {
|
||||
return '已领取';
|
||||
}
|
||||
|
||||
return task.status === 'claimable' ? '领取' : '未完成';
|
||||
}
|
||||
|
||||
export function buildProfileTaskCardSummary(
|
||||
center: ProfileTaskCenterResponse | null,
|
||||
): ProfileTaskCardSummary {
|
||||
const task = selectProfileTaskCardTask(center?.tasks ?? []);
|
||||
const threshold = Math.max(1, task?.threshold ?? 1);
|
||||
const progressCount = Math.min(
|
||||
Math.max(0, task?.progressCount ?? 0),
|
||||
threshold,
|
||||
);
|
||||
const rewardPoints =
|
||||
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
|
||||
const actionLabel =
|
||||
task?.status === 'claimable'
|
||||
? '领取'
|
||||
: task?.status === 'claimed'
|
||||
? '已完成'
|
||||
: '去完成';
|
||||
|
||||
return {
|
||||
actionLabel,
|
||||
progressCount,
|
||||
progressPercent: Math.round((progressCount / threshold) * 100),
|
||||
rewardPoints,
|
||||
threshold,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRecommendShareText,
|
||||
buildRecommendSwipeRailClassName,
|
||||
clampRecommendDragOffset,
|
||||
hasRecommendDragStarted,
|
||||
resolveRecommendCommitOffset,
|
||||
resolveRecommendDragCommitDirection,
|
||||
shouldAnimateRecommendSwipe,
|
||||
} from './rpgEntryRecommendSwipeDeckModel';
|
||||
import type { PlatformPuzzleGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
describe('rpgEntryRecommendSwipeDeckModel', () => {
|
||||
test('detects drag start and clamps offset to the card stage', () => {
|
||||
expect(hasRecommendDragStarted(17)).toBe(false);
|
||||
expect(hasRecommendDragStarted(18)).toBe(true);
|
||||
expect(clampRecommendDragOffset(240, 120)).toBe(120);
|
||||
expect(clampRecommendDragOffset(-240, 120)).toBe(-120);
|
||||
expect(clampRecommendDragOffset(240, 0)).toBe(160);
|
||||
});
|
||||
|
||||
test('resolves commit direction and commit offset', () => {
|
||||
expect(resolveRecommendDragCommitDirection(35)).toBeNull();
|
||||
expect(resolveRecommendDragCommitDirection(-36)).toBe(1);
|
||||
expect(resolveRecommendDragCommitDirection(36)).toBe(-1);
|
||||
expect(resolveRecommendCommitOffset(1, 320, 720)).toBe(-320);
|
||||
expect(resolveRecommendCommitOffset(-1, 0, 720)).toBe(720);
|
||||
});
|
||||
|
||||
test('builds rail class and animation guard state', () => {
|
||||
expect(
|
||||
buildRecommendSwipeRailClassName({ offsetY: 0, commitDirection: null }),
|
||||
).toBe('platform-recommend-swipe-rail--settled');
|
||||
expect(
|
||||
buildRecommendSwipeRailClassName({ offsetY: 24, commitDirection: null }),
|
||||
).toBe('platform-recommend-swipe-rail--dragging');
|
||||
expect(
|
||||
buildRecommendSwipeRailClassName({ offsetY: -320, commitDirection: 1 }),
|
||||
).toBe('platform-recommend-swipe-rail--committing');
|
||||
|
||||
expect(
|
||||
shouldAnimateRecommendSwipe({
|
||||
isAuthenticated: true,
|
||||
hasActiveEntry: true,
|
||||
entryCount: 2,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAnimateRecommendSwipe({
|
||||
isAuthenticated: true,
|
||||
hasActiveEntry: true,
|
||||
entryCount: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('builds recommend share text from public work identity', () => {
|
||||
expect(
|
||||
buildRecommendShareText({
|
||||
entry: buildPuzzleEntry(),
|
||||
publicWorkCode: 'PZ-OCEAN',
|
||||
detailUrl: 'https://example.test/works/detail?work=PZ-OCEAN',
|
||||
}),
|
||||
).toBe(
|
||||
'邀请你来玩《潮汐拼图》\n作品号:PZ-OCEAN\nhttps://example.test/works/detail?work=PZ-OCEAN',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function buildPuzzleEntry(): PlatformPuzzleGalleryCard {
|
||||
return {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'puzzle-work-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
publicWorkCode: 'PZ-OCEAN',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
worldName: '潮汐拼图',
|
||||
subtitle: '潮汐副标题',
|
||||
summaryText: '潮汐摘要',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['海潮'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-03T08:00:00.000Z',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
export const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
||||
export const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
||||
export const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||
|
||||
export type RecommendSwipeDirection = 1 | -1;
|
||||
|
||||
export type RecommendSwipeRailState = {
|
||||
offsetY: number;
|
||||
commitDirection: RecommendSwipeDirection | null;
|
||||
};
|
||||
|
||||
/** 收口推荐卡纵向滑动的纯判定,页面只保留 pointer 与动画副作用。 */
|
||||
export function hasRecommendDragStarted(deltaY: number) {
|
||||
return Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2;
|
||||
}
|
||||
|
||||
export function clampRecommendDragOffset(
|
||||
deltaY: number,
|
||||
stageHeight: number,
|
||||
) {
|
||||
const dragLimit =
|
||||
stageHeight > 0 ? stageHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX;
|
||||
return Math.max(-dragLimit, Math.min(dragLimit, deltaY));
|
||||
}
|
||||
|
||||
export function resolveRecommendDragCommitDirection(
|
||||
deltaY: number,
|
||||
): RecommendSwipeDirection | null {
|
||||
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return deltaY < 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
export function resolveRecommendCommitOffset(
|
||||
direction: RecommendSwipeDirection,
|
||||
stageHeight: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
const commitDistance = stageHeight > 0 ? stageHeight : viewportHeight;
|
||||
return direction === 1 ? -commitDistance : commitDistance;
|
||||
}
|
||||
|
||||
export function buildRecommendSwipeRailClassName(
|
||||
state: RecommendSwipeRailState,
|
||||
) {
|
||||
if (state.commitDirection) {
|
||||
return 'platform-recommend-swipe-rail--committing';
|
||||
}
|
||||
|
||||
return state.offsetY === 0
|
||||
? 'platform-recommend-swipe-rail--settled'
|
||||
: 'platform-recommend-swipe-rail--dragging';
|
||||
}
|
||||
|
||||
export function shouldAnimateRecommendSwipe(params: {
|
||||
isAuthenticated: boolean;
|
||||
hasActiveEntry: boolean;
|
||||
entryCount: number;
|
||||
}) {
|
||||
return (
|
||||
params.isAuthenticated && params.hasActiveEntry && params.entryCount > 1
|
||||
);
|
||||
}
|
||||
|
||||
export function buildRecommendShareText(params: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
publicWorkCode: string;
|
||||
detailUrl: string;
|
||||
}) {
|
||||
return `邀请你来玩《${params.entry.worldName}》\n作品号:${params.publicWorkCode}\n${params.detailUrl}`;
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { expect, test } from 'vitest';
|
||||
import {
|
||||
buildPlatformWorldDisplayTags,
|
||||
buildPuzzleWorkCoverSlides,
|
||||
describePlatformPublicWorkKind,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
formatPlatformCompactCount,
|
||||
formatPlatformPublicAuthorAvatarLabel,
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
@@ -18,10 +21,13 @@ import {
|
||||
mapPuzzleClearWorkToPlatformGalleryCard,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
mapWoodenFishWorkToPlatformGalleryCard,
|
||||
type PlatformBarkBattleGalleryCard,
|
||||
type PlatformBigFishGalleryCard,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformPublicWorkAuthorLookup,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformWorldFallbackCoverImage,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
@@ -55,6 +61,48 @@ test('platform work display text limits names and tags by character count', () =
|
||||
).toEqual(['超长机关', '星桥']);
|
||||
});
|
||||
|
||||
test('platform public work presentation formats compact counts and kind labels', () => {
|
||||
const puzzleCard: PlatformPuzzleGalleryCard = {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'puzzle-work-kind',
|
||||
profileId: 'puzzle-profile-kind',
|
||||
publicWorkCode: 'PZ-KIND',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: '机关拼图',
|
||||
subtitle: '拼图关卡',
|
||||
summaryText: '公开作品',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['拼图'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-18T00:00:00.000Z',
|
||||
updatedAt: '2026-05-18T00:00:00.000Z',
|
||||
};
|
||||
const bigFishCard: PlatformBigFishGalleryCard = {
|
||||
sourceType: 'big-fish',
|
||||
workId: 'big-fish-work-kind',
|
||||
profileId: 'big-fish-profile-kind',
|
||||
publicWorkCode: 'BF-KIND',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: '大鱼海湾',
|
||||
subtitle: '大鱼关卡',
|
||||
summaryText: '公开作品',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['大鱼'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-18T00:00:00.000Z',
|
||||
updatedAt: '2026-05-18T00:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(formatPlatformCompactCount(-1)).toBe('0');
|
||||
expect(formatPlatformCompactCount(9999)).toBe('9999');
|
||||
expect(formatPlatformCompactCount(10000)).toBe('1.0万');
|
||||
expect(formatPlatformCompactCount(100000000)).toBe('1.0亿');
|
||||
expect(describePlatformPublicWorkKind(puzzleCard)).toBe('拼图');
|
||||
expect(describePlatformPublicWorkKind(bigFishCard)).toBe('大鱼吃小');
|
||||
});
|
||||
|
||||
test('platform public cards use play type reference images as cover fallback', () => {
|
||||
const puzzleCard: PlatformPuzzleGalleryCard = {
|
||||
sourceType: 'puzzle',
|
||||
@@ -308,6 +356,57 @@ test('public work author display keeps phone masks and hides bare public user co
|
||||
);
|
||||
});
|
||||
|
||||
test('public work author lookup keeps public user code priority and avatar labels', () => {
|
||||
const barkBattleCard: PlatformBarkBattleGalleryCard = {
|
||||
sourceType: 'bark-battle',
|
||||
workId: 'bark-battle-work-author',
|
||||
profileId: 'bark-battle-profile-author',
|
||||
sourceSessionId: null,
|
||||
publicWorkCode: 'BB-AUTHOR',
|
||||
ownerUserId: 'user-author-id',
|
||||
authorPublicUserCode: ' SY-00012345 ',
|
||||
authorDisplayName: '声浪玩家',
|
||||
worldName: '声浪擂台',
|
||||
subtitle: '汪汪声浪',
|
||||
summaryText: '公开作品',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
themeTags: ['声浪'],
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-22T00:00:00.000Z',
|
||||
updatedAt: '2026-05-22T00:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(resolvePlatformPublicWorkAuthorLookup(barkBattleCard)).toEqual({
|
||||
key: 'code:SY-00012345',
|
||||
source: 'publicUserCode',
|
||||
value: 'SY-00012345',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkAuthorLookup({
|
||||
...barkBattleCard,
|
||||
authorPublicUserCode: ' ',
|
||||
}),
|
||||
).toEqual({
|
||||
key: 'id:user-author-id',
|
||||
source: 'ownerUserId',
|
||||
value: 'user-author-id',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkAuthorLookup({
|
||||
...barkBattleCard,
|
||||
authorPublicUserCode: null,
|
||||
ownerUserId: ' ',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(formatPlatformPublicAuthorAvatarLabel(' 声浪玩家')).toBe('声');
|
||||
expect(formatPlatformPublicAuthorAvatarLabel('')).toBe('玩');
|
||||
});
|
||||
|
||||
test('keeps baby object match public card code and template label intact', () => {
|
||||
const card: PlatformEdutainmentGalleryCard = {
|
||||
sourceType: 'edutainment',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
@@ -331,6 +331,12 @@ export type PlatformPublicGalleryCard =
|
||||
| PlatformBarkBattleGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
export type PlatformPublicWorkAuthorLookup = {
|
||||
key: string;
|
||||
source: 'publicUserCode' | 'ownerUserId';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
@@ -991,6 +997,52 @@ export function formatPlatformWorkDisplayTags(
|
||||
].slice(0, limit);
|
||||
}
|
||||
|
||||
export function formatPlatformCompactCount(value: number) {
|
||||
const normalizedValue = Math.max(0, Math.round(value));
|
||||
if (normalizedValue >= 100000000) {
|
||||
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
|
||||
}
|
||||
if (normalizedValue >= 10000) {
|
||||
return `${(normalizedValue / 10000).toFixed(1)}万`;
|
||||
}
|
||||
|
||||
return `${normalizedValue}`;
|
||||
}
|
||||
|
||||
export function describePlatformPublicWorkKind(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('大鱼吃小鱼');
|
||||
}
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('拼图');
|
||||
}
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('抓大鹅');
|
||||
}
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('方洞挑战');
|
||||
}
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('跳一跳');
|
||||
}
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('敲木鱼');
|
||||
}
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('视觉小说');
|
||||
}
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('汪汪声浪');
|
||||
}
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag(entry.templateName);
|
||||
}
|
||||
|
||||
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
|
||||
}
|
||||
|
||||
export function resolvePlatformWorkAuthorDisplayName(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
authorSummary?: PublicUserSummary | null,
|
||||
@@ -1005,6 +1057,36 @@ export function resolvePlatformWorkAuthorDisplayName(
|
||||
return displayName || entryAuthorName || '玩家';
|
||||
}
|
||||
|
||||
export function resolvePlatformPublicWorkAuthorLookup(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): PlatformPublicWorkAuthorLookup | null {
|
||||
if ('authorPublicUserCode' in entry) {
|
||||
const authorPublicUserCode = entry.authorPublicUserCode?.trim();
|
||||
if (authorPublicUserCode) {
|
||||
return {
|
||||
key: `code:${authorPublicUserCode}`,
|
||||
source: 'publicUserCode',
|
||||
value: authorPublicUserCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ownerUserId = entry.ownerUserId.trim();
|
||||
return ownerUserId
|
||||
? {
|
||||
key: `id:${ownerUserId}`,
|
||||
source: 'ownerUserId',
|
||||
value: ownerUserId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function formatPlatformPublicAuthorAvatarLabel(
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩';
|
||||
}
|
||||
|
||||
function normalizePlatformPublicAuthorName(value: string | null | undefined) {
|
||||
const normalized = value?.trim() ?? '';
|
||||
if (!normalized || normalized === 'null' || normalized === 'undefined') {
|
||||
|
||||
Reference in New Issue
Block a user