收口个人中心弹层组件

- 新增 PlatformProfileModalShell 统一个人中心主弹层与副弹层壳层
- 抽离 PlatformProfilePlayedWorksModal 与 PlatformProfileReferralModal 并移除首页内联历史与邀请弹层实现
- 让昵称充值任务兑换码账单等弹层复用共享壳层并补齐测试和文档
This commit is contained in:
2026-06-10 19:44:19 +08:00
parent 08339b410b
commit 4e3378be65
10 changed files with 1051 additions and 815 deletions

View File

@@ -0,0 +1,146 @@
import type { ReactNode } from 'react';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { UnifiedModal } from '../common/UnifiedModal';
type PlatformProfileModalShellProps = {
title: string;
description?: ReactNode;
onClose: () => void;
children: ReactNode;
closeLabel?: string;
closeVariant?: 'profile' | 'profileCompact';
closeDisabled?: boolean;
showHeader?: boolean;
showCloseButton?: boolean;
size?: 'sm' | 'md';
zIndexClassName?: string;
panelClassName: string;
bodyClassName?: string;
descriptionClassName?: string;
};
type PlatformProfileSecondaryModalShellProps = {
title: string;
onClose: () => void;
children: ReactNode;
closeLabel?: string;
closeVariant?: 'floating' | 'floatingPlain';
closeIcon?: ReactNode;
closeButtonClassName?: string;
overlayTone?: 'default' | 'soft';
size?: 'sm' | 'md';
zIndexClassName?: string;
panelClassName: string;
contentClassName: string;
};
const PROFILE_MODAL_OVERLAY_CLASS =
'platform-modal-backdrop !items-center !justify-center !px-4 !py-6';
const PROFILE_MODAL_HEADER_CLASS = 'border-white/10 px-5 py-4';
const PROFILE_MODAL_TITLE_CLASS = 'text-base font-black';
const PROFILE_MODAL_DESCRIPTION_CLASS =
'mt-1 text-xs font-semibold text-[var(--platform-text-soft)]';
const PROFILE_SECONDARY_MODAL_OVERLAY_CLASS_BY_TONE = {
default: '!items-center !bg-black/48 !px-3 !py-5 !backdrop-blur-none',
soft: '!items-center !bg-black/42 !px-3 !py-5 !backdrop-blur-none',
} as const;
/**
* 个人中心标准弹窗壳层。
* 统一收口账户侧弹窗常用的 overlay、header 和 close button 配置。
*/
export function PlatformProfileModalShell({
title,
description,
onClose,
children,
closeLabel,
closeVariant = 'profile',
closeDisabled = false,
showHeader = true,
showCloseButton = true,
size = 'sm',
zIndexClassName = 'z-[80]',
panelClassName,
bodyClassName = 'px-5 py-5',
descriptionClassName = PROFILE_MODAL_DESCRIPTION_CLASS,
}: PlatformProfileModalShellProps) {
return (
<UnifiedModal
open
title={title}
description={description}
onClose={onClose}
closeLabel={closeLabel ?? `关闭${title}`}
closeVariant={closeVariant}
closeDisabled={closeDisabled}
showHeader={showHeader}
showCloseButton={showCloseButton}
closeOnBackdrop={false}
closeOnEscape={false}
portal={false}
size={size}
zIndexClassName={zIndexClassName}
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
panelClassName={panelClassName}
headerClassName={PROFILE_MODAL_HEADER_CLASS}
titleClassName={PROFILE_MODAL_TITLE_CLASS}
descriptionClassName={descriptionClassName}
bodyClassName={bodyClassName}
>
{children}
</UnifiedModal>
);
}
/**
* 个人中心副弹层壳层。
* 用于“玩过 / 账单 / 邀请”这类白底浮层,统一收口 overlay、floating close 和 body 外壳。
*/
export function PlatformProfileSecondaryModalShell({
title,
onClose,
children,
closeLabel,
closeVariant = 'floating',
closeIcon = '×',
closeButtonClassName,
overlayTone = 'default',
size = 'sm',
zIndexClassName = 'z-[80]',
panelClassName,
contentClassName,
}: PlatformProfileSecondaryModalShellProps) {
return (
<UnifiedModal
open
title={title}
onClose={onClose}
showHeader={false}
showCloseButton={false}
closeOnBackdrop={false}
closeOnEscape={false}
portal={false}
size={size}
zIndexClassName={zIndexClassName}
overlayClassName={
PROFILE_SECONDARY_MODAL_OVERLAY_CLASS_BY_TONE[overlayTone]
}
panelClassName={panelClassName}
bodyClassName="!p-0"
>
<div className={contentClassName}>
<PlatformModalCloseButton
label={closeLabel ?? `关闭${title}`}
variant={closeVariant}
onClick={onClose}
className={closeButtonClassName}
icon={closeIcon}
/>
{children}
</div>
</UnifiedModal>
);
}

View File

@@ -0,0 +1,91 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { PlatformProfilePlayedWorksModal } from './PlatformProfilePlayedWorksModal';
describe('PlatformProfilePlayedWorksModal', () => {
test('renders save archives and played works in one modal', async () => {
const user = userEvent.setup();
const onResumeSave = vi.fn();
const onOpenWork = vi.fn();
const saveEntry = {
worldKey: 'custom:save-1',
ownerUserId: 'user-1',
profileId: 'save-1',
worldType: 'custom',
worldName: '回声群岛',
subtitle: '雾海码头',
summaryText: '继续推进上一次保存的故事。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
};
const playedWork = {
worldKey: 'custom:world-1',
ownerUserId: 'user-1',
profileId: 'world-1',
worldType: 'CUSTOM',
worldTitle: '潮雾列岛',
worldSubtitle: '旧灯塔与失控航路',
firstPlayedAt: '2026-04-18T12:00:00.000Z',
lastPlayedAt: '2026-04-19T12:00:00.000Z',
lastObservedPlayTimeMs: 30 * 60 * 1000,
};
render(
<PlatformProfilePlayedWorksModal
stats={{
totalPlayTimeMs: 90 * 60 * 1000,
playedWorks: [playedWork],
updatedAt: '2026-04-19T12:00:00.000Z',
}}
isLoading={false}
error={null}
saveEntries={[saveEntry]}
saveError={null}
isResumingSaveWorldKey={null}
onClose={vi.fn()}
onOpenWork={onOpenWork}
onResumeSave={onResumeSave}
/>,
);
const dialog = screen.getByRole('dialog', { name: '玩过' });
expect(within(dialog).getByText('可继续')).toBeTruthy();
expect(within(dialog).getAllByText('玩过').length).toBeGreaterThan(0);
expect(within(dialog).getByText('1.5小时')).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: //u }));
expect(onResumeSave).toHaveBeenCalledWith(saveEntry);
await user.click(within(dialog).getByRole('button', { name: //u }));
expect(onOpenWork).toHaveBeenCalledWith(playedWork);
});
test('renders platform empty state when no history exists', () => {
render(
<PlatformProfilePlayedWorksModal
stats={{
totalPlayTimeMs: 0,
playedWorks: [],
updatedAt: '2026-04-19T12:00:00.000Z',
}}
isLoading={false}
error={null}
saveEntries={[]}
saveError={null}
isResumingSaveWorldKey={null}
onClose={vi.fn()}
onResumeSave={vi.fn()}
/>,
);
const emptyState = screen.getByText('暂无玩过');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('text-left');
});
});

View File

@@ -0,0 +1,260 @@
import { ArrowRight, Clock3 } from 'lucide-react';
import type {
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
import {
formatCompactPlayTime,
formatPlayedWorkId,
formatPlayedWorkType,
formatSnapshotTime,
formatTotalPlayTimeHours,
} from '../rpg-entry/rpgEntryProfileDashboardPresentation';
import { formatPlatformWorkDisplayName } from '../rpg-entry/rpgEntryWorldPresentation';
type PlatformProfilePlayedWorksModalProps = {
stats: ProfilePlayStatsResponse | null;
isLoading: boolean;
error: string | null;
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
isResumingSaveWorldKey: string | null;
onClose: () => void;
onOpenWork?: (work: ProfilePlayedWorkSummary) => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
};
function SaveArchivePreview({
entry,
className,
}: {
entry: ProfileSaveArchiveSummary;
className: string;
}) {
return (
<div
aria-hidden="true"
className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`}
>
{entry.coverImageSrc ? (
<ResolvedAssetImage
src={entry.coverImageSrc}
alt=""
aria-hidden
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_34%),linear-gradient(145deg,rgba(255,94,125,0.92),rgba(255,150,116,0.88))]" />
)}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
</div>
);
}
function SaveArchiveCard({
entry,
onClick,
loading = false,
}: {
entry: ProfileSaveArchiveSummary;
onClick: () => void;
loading?: boolean;
}) {
const summaryText =
entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
return (
<button
type="button"
onClick={onClick}
disabled={loading}
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[13rem] w-full overflow-hidden p-3.5 text-left sm:min-h-[12.5rem] sm:p-4 ${loading ? 'opacity-80' : ''}`}
>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-deep)]" />
<div className="relative z-10 flex h-full w-full flex-col gap-3">
<div className="flex flex-wrap justify-end gap-2">
<PlatformPillBadge
tone="darkNeutral"
size="xs"
className="font-medium text-[var(--platform-text-base)]"
>
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
</PlatformPillBadge>
</div>
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.35rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl">
{displayName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-2 break-words text-sm font-semibold text-[var(--platform-text-base)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-[var(--platform-text-soft)] sm:text-sm">
{summaryText}
</div>
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
<span>{loading ? '正在恢复' : '继续游玩'}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
</div>
</div>
<SaveArchivePreview
entry={entry}
className="aspect-square w-[6.5rem] self-start sm:w-[7.5rem]"
/>
</div>
</div>
</button>
);
}
export function PlatformProfilePlayedWorksModal({
stats,
isLoading,
error,
saveEntries,
saveError,
isResumingSaveWorldKey,
onClose,
onOpenWork,
onResumeSave,
}: PlatformProfilePlayedWorksModalProps) {
// 中文注释:个人中心“玩过”弹层同时承接“可继续”的存档列表,保持同一入口下的历史/恢复语义。
const playedWorks = stats?.playedWorks ?? [];
const hasArchiveEntries = saveEntries.length > 0;
const hasPlayedWorks = playedWorks.length > 0;
return (
<PlatformProfileSecondaryModalShell
title="玩过"
onClose={onClose}
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[38rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
>
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
PLAYED
</div>
<div className="mt-1 text-2xl font-black"></div>
<PlatformPillBadge
tone="profile"
icon={<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />}
className="mt-2"
>
{formatTotalPlayTimeHours(stats?.totalPlayTimeMs ?? 0)}
</PlatformPillBadge>
</div>
{error ? (
<PlatformStatusMessage tone="error" className="mt-4">
{error}
</PlatformStatusMessage>
) : null}
{saveError ? (
<PlatformStatusMessage tone="error" className="mt-4">
{saveError}
</PlatformStatusMessage>
) : null}
{isLoading ? (
<div className="mt-5 space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
))}
</div>
) : hasArchiveEntries || hasPlayedWorks ? (
<div className="mt-5 space-y-5">
{hasArchiveEntries ? (
<section>
<PlatformFieldLabel variant="section" className="mb-2 block">
</PlatformFieldLabel>
<div className="grid gap-3">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={`${entry.worldKey}:played-archive`}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
</section>
) : null}
{hasPlayedWorks ? (
<section>
<PlatformFieldLabel variant="section" className="mb-2 block">
</PlatformFieldLabel>
<div className="space-y-3">
{playedWorks.map((work) => (
<PlatformSubpanel
as="button"
type="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
surface="flat"
radius="sm"
padding="md"
interactive
className="w-full hover:border-[#ff4056]"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-1 text-base font-black text-zinc-950">
{work.worldTitle}
</div>
{work.worldSubtitle ? (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{work.worldSubtitle}
</div>
) : null}
</div>
<PlatformPillBadge
tone="profileAccent"
size="xs"
className="shrink-0 border-transparent"
>
{formatPlayedWorkType(work.worldType)}
</PlatformPillBadge>
</div>
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
<span className="truncate"> {formatPlayedWorkId(work)}</span>
<span className="truncate">
{formatSnapshotTime(work.lastPlayedAt)}
</span>
<span className="truncate">
{formatCompactPlayTime(work.lastObservedPlayTimeMs)}
</span>
</div>
</PlatformSubpanel>
))}
</div>
</section>
) : null}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5 text-left"
>
</PlatformEmptyState>
)}
</PlatformProfileSecondaryModalShell>
);
}

View File

@@ -0,0 +1,124 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
function buildCenter(
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
): ProfileReferralInviteCenterResponse {
return {
inviteCode: 'ABCD1234',
inviteLinkPath: '/invite/ABCD1234',
invitedCount: 1,
rewardedInviteCount: 1,
todayInviterRewardCount: 1,
todayInviterRewardRemaining: 9,
rewardPoints: 66,
invitedUsers: [
{
userId: 'user-2',
displayName: '海盐',
avatarUrl: null,
boundAt: '2026-06-10T08:00:00.000Z',
},
],
hasRedeemedCode: false,
boundInviterUserId: null,
boundAt: null,
updatedAt: '2026-06-10T08:00:00.000Z',
...overrides,
};
}
describe('PlatformProfileReferralModal', () => {
test('renders invite panel with shared profile content', () => {
render(
<PlatformProfileReferralModal
panel="invite"
center={buildCenter()}
isLoading={false}
isSubmittingRedeem={false}
redeemCode=""
copyInviteState="idle"
error={null}
success={null}
onClose={vi.fn()}
onCopyInvite={vi.fn()}
onRedeemCodeChange={vi.fn()}
onSubmitRedeemCode={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '邀请好友' });
expect(within(dialog).getByText('邀请码')).toBeTruthy();
expect(within(dialog).getByText('ABCD1234')).toBeTruthy();
expect(within(dialog).getByText('海盐')).toBeTruthy();
expect(within(dialog).getByText('成功邀请')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: //u }),
).toBeTruthy();
});
test('submits redeem panel with the shared form shell', async () => {
const user = userEvent.setup();
const onRedeemCodeChange = vi.fn();
const onSubmitRedeemCode = vi.fn();
render(
<PlatformProfileReferralModal
panel="redeem"
center={buildCenter()}
isLoading={false}
isSubmittingRedeem={false}
redeemCode="ab12"
copyInviteState="idle"
error={null}
success={null}
onClose={vi.fn()}
onCopyInvite={vi.fn()}
onRedeemCodeChange={onRedeemCodeChange}
onSubmitRedeemCode={onSubmitRedeemCode}
/>,
);
const dialog = screen.getByRole('dialog', { name: '填邀请码' });
const input = within(dialog).getByRole('textbox', { name: '邀请码' });
await user.type(input, ' c');
expect(onRedeemCodeChange).toHaveBeenCalled();
await user.click(within(dialog).getByRole('button', { name: '提交' }));
expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1);
});
test('renders community QR panels', () => {
render(
<PlatformProfileReferralModal
panel="community"
center={buildCenter()}
isLoading={false}
isSubmittingRedeem={false}
redeemCode=""
copyInviteState="idle"
error={null}
success={null}
onClose={vi.fn()}
onCopyInvite={vi.fn()}
onRedeemCodeChange={vi.fn()}
onSubmitRedeemCode={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '玩家社区' });
expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
expect(within(dialog).getByText('微信群')).toBeTruthy();
expect(within(dialog).getByText('QQ群')).toBeTruthy();
});
});

View File

@@ -0,0 +1,303 @@
import { Copy } from 'lucide-react';
import type { ReactNode } from 'react';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import type { CopyFeedbackState } from '../common/useCopyFeedback';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
import type { ProfileReferralPanel } from './usePlatformProfileCenterController';
type PlatformProfileReferralModalProps = {
panel: ProfileReferralPanel;
center: ProfileReferralInviteCenterResponse | null;
isLoading: boolean;
isSubmittingRedeem: boolean;
redeemCode: string;
copyInviteState: CopyFeedbackState;
error: string | null;
success: string | null;
onClose: () => void;
onCopyInvite: () => void;
onRedeemCodeChange: (value: string) => void;
onSubmitRedeemCode: () => void;
};
const COMMUNITY_QR_CODES = [
{
label: '微信群',
src: communityWechatQrImage,
alt: '玩家社区微信群二维码',
},
{
label: 'QQ群',
src: communityQqQrImage,
alt: '玩家社区 QQ 群二维码',
},
] as const;
function ProfileReferralUserAvatar({
name,
avatarUrl,
}: {
name: string;
avatarUrl: string | null;
}) {
const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
return (
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#ff4056] text-xs font-black text-white">
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
avatarLabel
)}
</span>
);
}
function resolvePanelTitle(panel: ProfileReferralPanel) {
if (panel === 'invite') {
return '邀请好友';
}
if (panel === 'redeem') {
return '填邀请码';
}
return '玩家社区';
}
/**
* 个人中心邀请能力统一弹层。
* 承接邀请码、填码和社区二维码三种 profile panel避免首页继续内联重复白底浮层实现。
*/
export function PlatformProfileReferralModal({
panel,
center,
isLoading,
isSubmittingRedeem,
redeemCode,
copyInviteState,
error,
success,
onClose,
onCopyInvite,
onRedeemCodeChange,
onSubmitRedeemCode,
}: PlatformProfileReferralModalProps) {
const title = resolvePanelTitle(panel);
const normalizedRedeemCode = redeemCode
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
let content: ReactNode;
if (panel === 'community') {
content = (
<div className="mt-5 grid grid-cols-2 gap-3">
{COMMUNITY_QR_CODES.map((qrCode) => (
<PlatformSubpanel
as="div"
key={qrCode.label}
surface="flat"
radius="xs"
padding="xs"
className="text-center"
>
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-200 bg-white p-1.5">
<img
src={qrCode.src}
alt={qrCode.alt}
className="h-full w-full object-contain"
loading="lazy"
decoding="async"
/>
</div>
<div className="mt-2 text-sm font-bold text-zinc-700">
{qrCode.label}
</div>
</PlatformSubpanel>
))}
</div>
);
} else if (panel === 'redeem') {
content = isLoading ? (
<div className="mt-5 space-y-3">
<div className="h-12 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-11 animate-pulse rounded-xl bg-zinc-100" />
</div>
) : center?.hasRedeemedCode ? (
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5"
>
</PlatformEmptyState>
) : (
<form
className="mt-5 space-y-3"
onSubmit={(event) => {
event.preventDefault();
onSubmitRedeemCode();
}}
>
<PlatformTextField
value={redeemCode}
onChange={(event) => onRedeemCodeChange(event.target.value)}
size="lg"
density="roomy"
tone="rose"
className="rounded-xl text-center font-black uppercase tracking-[0.16em]"
placeholder="邀请码"
aria-label="邀请码"
autoComplete="off"
autoFocus
/>
<PlatformActionButton
type="submit"
surface="profile"
fullWidth
size="md"
className="rounded-xl"
disabled={isSubmittingRedeem || !normalizedRedeemCode}
>
{isSubmittingRedeem ? '提交中' : '提交'}
</PlatformActionButton>
</form>
);
} else if (isLoading) {
content = (
<div className="mt-5 space-y-3">
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
</div>
);
} else {
content = (
<div className="mt-5 space-y-3">
<PlatformSubpanel
as="div"
surface="flat"
radius="xs"
padding="md"
className="text-center"
>
<PlatformFieldLabel
variant="section"
className="block text-[11px] text-zinc-500"
>
</PlatformFieldLabel>
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
{center?.inviteCode ?? '--------'}
</div>
</PlatformSubpanel>
<PlatformStatusMessage
tone="warning"
surface="profile"
size="md"
className="space-y-0.5 px-3.5 font-semibold"
>
<div>
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
</div>
<div></div>
</PlatformStatusMessage>
<CopyFeedbackButton
state={copyInviteState}
onClick={onCopyInvite}
disabled={!center?.inviteCode}
idleLabel="复制邀请"
copiedLabel="已复制"
failedLabel="复制失败"
idleIcon={<Copy className="h-4 w-4" />}
actionSurface="profile"
actionSize="md"
actionFullWidth
className="gap-2 rounded-xl"
/>
<PlatformSubpanel as="div" surface="flat" radius="xs" padding="sm">
<PlatformFieldLabel
variant="section"
className="block text-zinc-900"
>
</PlatformFieldLabel>
{center?.invitedUsers?.length ? (
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
{center.invitedUsers.map((user) => (
<PlatformSubpanel
as="div"
key={`${user.userId}-${user.boundAt}`}
surface="soft"
radius="xs"
padding="row"
className="flex items-center gap-3"
>
<ProfileReferralUserAvatar
name={user.displayName}
avatarUrl={user.avatarUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-zinc-900">
{user.displayName || '玩家'}
</div>
</div>
</PlatformSubpanel>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="mt-3 text-center text-xs font-semibold leading-normal"
>
</PlatformEmptyState>
)}
</PlatformSubpanel>
</div>
);
}
return (
<PlatformProfileSecondaryModalShell
title={title}
onClose={onClose}
closeVariant="floatingPlain"
closeIcon="×"
overlayTone="soft"
panelClassName="relative !max-w-[24rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
contentClassName="relative px-5 pb-5 pt-4"
>
<div className="text-center text-xl font-black">{title}</div>
{content}
{error ? (
<PlatformStatusMessage tone="error" className="mt-4">
{error}
</PlatformStatusMessage>
) : null}
{success ? (
<PlatformStatusMessage tone="success" className="mt-4">
{success}
</PlatformStatusMessage>
) : null}
</PlatformProfileSecondaryModalShell>
);
}

View File

@@ -48,7 +48,7 @@ const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
export type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
export type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
export type ProfilePopupPanel = ProfileReferralPanel;
export type RechargeTab = 'points' | 'membership';
type WechatPayResult = {

File diff suppressed because it is too large Load Diff

View File

@@ -75,6 +75,24 @@ export function formatPlayedWorkId(work: ProfilePlayedWorkSummary) {
return work.profileId?.trim() || work.worldKey;
}
export function formatSnapshotTime(value: string | null | undefined) {
if (!value) {
return '刚刚保存';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function buildProfileDashboardPresentation(
dashboard: ProfileDashboardSummary | null,
): ProfileDashboardPresentation {