收口个人中心弹层组件
- 新增 PlatformProfileModalShell 统一个人中心主弹层与副弹层壳层 - 抽离 PlatformProfilePlayedWorksModal 与 PlatformProfileReferralModal 并移除首页内联历史与邀请弹层实现 - 让昵称充值任务兑换码账单等弹层复用共享壳层并补齐测试和文档
This commit is contained in:
146
src/components/platform-entry/PlatformProfileModalShell.tsx
Normal file
146
src/components/platform-entry/PlatformProfileModalShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
303
src/components/platform-entry/PlatformProfileReferralModal.tsx
Normal file
303
src/components/platform-entry/PlatformProfileReferralModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user