diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md
index 99051d28..f6150f07 100644
--- a/.hermes/shared-memory/decision-log.md
+++ b/.hermes/shared-memory/decision-log.md
@@ -39,6 +39,9 @@
- 2026-06-10 追加:推荐页运行态卡片底部的点赞 / 分享 / 改造入口,以及创作中心公开作品卡右上角分享入口统一迁移到 `PlatformIconButton`;这类和 swipe / drag 手势耦合的图标动作必须继续保留业务局部 class 与 `onPointerDown` / `onClick` 里的 `stopPropagation`,只把按钮语义、可访问名称和默认 `type="button"` 收口到共享组件,避免图标动作误触推荐卡切换、整卡打开或残留左滑状态。
- 2026-06-10 追加:RPG 首页个人中心里的统计卡、统计骨架、常用功能入口、设置行和法律信息入口统一抽到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;这组纯展示原子以后优先通过 props 接收图片资源、点击回调和展示文案,不再继续塞回 `RpgEntryHomeView` 的账户控制逻辑里。新建 `PlatformProfilePrimitives.test.tsx` 作为组件级护栏,页面级布局与法律入口继续由 `RpgEntryHomeView.recharge.test.tsx` 兜底。
- 2026-06-10 追加:RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑统一收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;controller 负责账户动作分流、商业状态派生与相关面板控制,`RpgEntryHomeView` 只保留展示、昵称头像编辑、扫码入口和页面级交互编排,不在页面组件里继续堆叠账户控制分支。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
+- 2026-06-10 追加:RPG 首页个人中心的“玩过 / 可继续”历史弹层统一抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和未连通的 `ProfileSaveArchivesModal`。当前产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此 controller 里的 `ProfilePopupPanel` 也去掉了没有真实入口的 `saveArchives` 分支。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
+- 2026-06-10 追加:个人中心标准头部弹窗与白底副弹层的共享壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;标准头部弹窗优先复用 `PlatformProfileModalShell`,白底副弹层优先复用 `PlatformProfileSecondaryModalShell`,不再在业务页重复手写 profile overlay、header、title、description、floating close 和关闭策略。昵称修改、账户充值、每日任务、兑换码、泥点账单、“玩过 / 可继续”以及邀请相关弹层已接入这套壳层。
+- 2026-06-10 追加:RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层统一抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;首页不再内联邀请码规范化、社区二维码卡片和邀请用户头像行,后续 profile 侧同类二级弹层优先按“独立组件 + `PlatformProfileSecondaryModalShell`”继续收口。
- 2026-06-09 追加:通用输入 Composer 的上传参考图、发送和移除参考图已迁移到 `PlatformIconButton`;图标上传仍使用 `asChild="label"` 保留 label + file input 语义,公共组件会自动写入隐藏文本,确保内嵌 file input 继承可访问名称。
- 2026-06-10 追加:creation-agent composer 的上传文档 / 上传参考图入口使用 `PlatformIconButton` 默认 `platformIcon`;工作台只保留动态 label、title、busy 状态和 picker 回调,发送按钮继续保留主题色动作布局。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`。
- 2026-06-10 追加:作品详情顶部返回 / 分享和封面轮播上一张 / 下一张入口使用 `PlatformIconButton variant="platformIcon"`;详情页保留原 `platform-work-detail__*` 局部 class 控制位置和尺寸,点赞、复制三态等专用动作暂不迁移。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`。
diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md
index f16dc978..1bf6cf28 100644
--- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md
+++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md
@@ -235,6 +235,9 @@
19.3.11. 创作中心公开作品卡右上角的分享快按钮迁移到 `PlatformIconButton`;作品卡继续保留 `.creation-work-card__quick-action-button` 局部 class 承接卡片角落定位和尺寸,并显式保留 `stopPropagation`、关闭 swipe action、清理 `suppressOpenRef` 与分享回调顺序,避免右上角分享入口误触整卡打开或遗留左滑状态。验证命令:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`。
19.3.12. RPG 首页个人中心的统计卡、统计骨架、常用功能入口、设置行与法律信息入口抽离到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;`RpgEntryHomeView` 只继续保留账户数据、图片资源、点击回调和打开弹层的控制器,不再把这一组纯展示原子和个人中心页面编排混在同一个 7k+ 首页文件里。组件级验证新增 `src/components/platform-entry/PlatformProfilePrimitives.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePrimitives.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile stats cards are centered without update timestamp|profile page shows legal entries and hides archive shortcuts"`。
19.3.13. RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;`RpgEntryHomeView` 仅保留个人中心展示、昵称头像编辑、扫码入口和页面级编排 / 交互,不再直接承接账户动作分流、商业状态派生和面板控制。该收口默认保持现有弹层与充值链路语义不变,避免在职责迁移时顺带扩张行为面。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
+19.3.14. RPG 首页个人中心的“玩过 / 可继续”历史弹层抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和旧的 `ProfileSaveArchivesModal`。当前真实产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此未连通的 `saveArchives` profile popup 分支一并删除,避免继续维护没有入口的独立壳层。组件级验证新增 `src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
+19.3.15. 个人中心标准头部弹窗与白底副弹层壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;`PlatformProfileModalShell` 负责标准账户弹窗的 overlay、header、title、description、close variant 和 `closeOnBackdrop={false} / closeOnEscape={false}` 约束,`PlatformProfileSecondaryModalShell` 负责白底副弹层的 overlay、floating close、`bodyClassName="!p-0"` 和内容外壳。`RpgEntryHomeView` 内的昵称修改、账户充值、每日任务、兑换码、泥点账单与“玩过”弹层已接到共享壳层,页面不再重复手写个人中心弹窗的基础 chrome 与关闭策略。
+19.3.16. RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;组件统一复用 `PlatformProfileSecondaryModalShell` 承接居中白底浮层、floatingPlain 关闭按钮和成功 / 失败提示区,`RpgEntryHomeView` 不再内联邀请码规范化、社区二维码卡片和邀请用户头像行。组件级验证新增 `src/components/platform-entry/PlatformProfileReferralModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的邀请链路断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders invite panel with shared profile content|submits redeem panel with the shared form shell|renders community QR panels|profile community shortcut shows reward subtitle and invited users|invite query opens redeem modal directly for logged in users|profile redeem invite query modal submits code after login"`、`npm run typecheck`。
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
20. 平台方形上传入口和紧凑虚线新增入口迁移到 `PlatformUploadTile`,上传后的图片预览迁移到 `PlatformUploadPreviewCard`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条、creation-agent 已选参考图条和拼图结果页关卡引用图横条已先迁移。方形缩略图使用默认 `layout="square"`,横向“已选择参考图 / 文件名 / 素材名 / 移除”条使用 `layout="inline"`;只读引用图条不传 `onRemove`,避免公共组件额外渲染删除入口。后续继续收口结果页素材上传、工作台参考图上传、紧凑虚线新增入口等上传 / 动作块时,业务页只保留文件选择、预览数组、预览回调、删除回调、校验逻辑或新增回调,上传方块外观、主副文案、缩略图壳、预览按钮、标题行、横向已选条、移除按钮和禁用态统一由 Module 承接;工具栏中的小图标上传仍继续使用 `PlatformIconButton asChild="label"`。
diff --git a/src/components/platform-entry/PlatformProfileModalShell.tsx b/src/components/platform-entry/PlatformProfileModalShell.tsx
new file mode 100644
index 00000000..5c8913cd
--- /dev/null
+++ b/src/components/platform-entry/PlatformProfileModalShell.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+/**
+ * 个人中心副弹层壳层。
+ * 用于“玩过 / 账单 / 邀请”这类白底浮层,统一收口 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 (
+
+
+
+ );
+}
diff --git a/src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx b/src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx
new file mode 100644
index 00000000..53706065
--- /dev/null
+++ b/src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ const emptyState = screen.getByText('暂无玩过');
+
+ expect(emptyState.className).toContain('platform-empty-state');
+ expect(emptyState.className).toContain('text-left');
+ });
+});
diff --git a/src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx b/src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx
new file mode 100644
index 00000000..ebc125d7
--- /dev/null
+++ b/src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx
@@ -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 (
+
+ {entry.coverImageSrc ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function SaveArchiveCard({
+ entry,
+ onClick,
+ loading = false,
+}: {
+ entry: ProfileSaveArchiveSummary;
+ onClick: () => void;
+ loading?: boolean;
+}) {
+ const summaryText =
+ entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
+ const displayName = formatPlatformWorkDisplayName(entry.worldName);
+
+ return (
+
+ );
+}
+
+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 (
+
+
+
+ PLAYED
+
+
玩过
+
}
+ className="mt-2"
+ >
+ {formatTotalPlayTimeHours(stats?.totalPlayTimeMs ?? 0)}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {saveError ? (
+
+ {saveError}
+
+ ) : null}
+
+ {isLoading ? (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ ) : hasArchiveEntries || hasPlayedWorks ? (
+
+ {hasArchiveEntries ? (
+
+
+ 可继续
+
+
+ {saveEntries.map((entry) => (
+ onResumeSave(entry)}
+ />
+ ))}
+
+
+ ) : null}
+
+ {hasPlayedWorks ? (
+
+
+ 玩过
+
+
+ {playedWorks.map((work) => (
+
onOpenWork?.(work)}
+ surface="flat"
+ radius="sm"
+ padding="md"
+ interactive
+ className="w-full hover:border-[#ff4056]"
+ >
+
+
+
+ {work.worldTitle}
+
+ {work.worldSubtitle ? (
+
+ {work.worldSubtitle}
+
+ ) : null}
+
+
+ {formatPlayedWorkType(work.worldType)}
+
+
+
+ 作品号 {formatPlayedWorkId(work)}
+
+ 最近 {formatSnapshotTime(work.lastPlayedAt)}
+
+
+ 时长 {formatCompactPlayTime(work.lastObservedPlayTimeMs)}
+
+
+
+ ))}
+
+
+ ) : null}
+
+ ) : (
+
+ 暂无玩过
+
+ )}
+
+ );
+}
diff --git a/src/components/platform-entry/PlatformProfileReferralModal.test.tsx b/src/components/platform-entry/PlatformProfileReferralModal.test.tsx
new file mode 100644
index 00000000..b1c48754
--- /dev/null
+++ b/src/components/platform-entry/PlatformProfileReferralModal.test.tsx
@@ -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 {
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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();
+ });
+});
diff --git a/src/components/platform-entry/PlatformProfileReferralModal.tsx b/src/components/platform-entry/PlatformProfileReferralModal.tsx
new file mode 100644
index 00000000..c97f2039
--- /dev/null
+++ b/src/components/platform-entry/PlatformProfileReferralModal.tsx
@@ -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 (
+
+ {avatarUrl ? (
+
+ ) : (
+ avatarLabel
+ )}
+
+ );
+}
+
+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 = (
+
+ {COMMUNITY_QR_CODES.map((qrCode) => (
+
+
+

+
+
+ {qrCode.label}
+
+
+ ))}
+
+ );
+ } else if (panel === 'redeem') {
+ content = isLoading ? (
+
+ ) : center?.hasRedeemedCode ? (
+
+ 已填写邀请码
+
+ ) : (
+
+ );
+ } else if (isLoading) {
+ content = (
+
+ );
+ } else {
+ content = (
+
+
+
+ 邀请码
+
+
+ {center?.inviteCode ?? '--------'}
+
+
+
+
+ {`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
+
+ 每日最多获得十次邀请奖励。
+
+
}
+ actionSurface="profile"
+ actionSize="md"
+ actionFullWidth
+ className="gap-2 rounded-xl"
+ />
+
+
+ 成功邀请
+
+ {center?.invitedUsers?.length ? (
+
+ {center.invitedUsers.map((user) => (
+
+
+
+
+ {user.displayName || '玩家'}
+
+
+
+ ))}
+
+ ) : (
+
+ 暂无成功邀请
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+ {title}
+ {content}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {success ? (
+
+ {success}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts
index 78185359..7b169384 100644
--- a/src/components/platform-entry/usePlatformProfileCenterController.ts
+++ b/src/components/platform-entry/usePlatformProfileCenterController.ts
@@ -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 = {
diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx
index d40705be..b2508faa 100644
--- a/src/components/rpg-entry/RpgEntryHomeView.tsx
+++ b/src/components/rpg-entry/RpgEntryHomeView.tsx
@@ -9,7 +9,6 @@ import {
Clock3,
Coins,
Compass,
- Copy,
Crown,
Gamepad2,
GitFork,
@@ -55,8 +54,6 @@ import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
import profileMascotImage from '../../../media/profile/_Image (9).png';
import profilePointImage from '../../../media/profile/_Image.png';
-import communityQqQrImage from '../../../media/social-media-group/qq.png';
-import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type {
CustomWorldLibraryEntry,
@@ -67,7 +64,6 @@ import type {
ProfilePlayStatsResponse,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
- ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary,
ProfileTaskCenterResponse,
ProfileWalletLedgerResponse,
@@ -111,7 +107,6 @@ import {
type SquareImageCropRect,
} from '../common/squareImageCropModel';
import {
- type CopyFeedbackState,
useCopyFeedback,
} from '../common/useCopyFeedback';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
@@ -130,10 +125,15 @@ import {
ProfileStatCard,
ProfileStatCardSkeleton,
} from '../platform-entry/PlatformProfilePrimitives';
+import {
+ PlatformProfileModalShell,
+ PlatformProfileSecondaryModalShell,
+} from '../platform-entry/PlatformProfileModalShell';
+import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
+import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import {
type NativeWechatPaymentState,
- type ProfileReferralPanel,
type RechargePaymentResult,
type RechargeTab,
usePlatformProfileCenterController,
@@ -142,10 +142,7 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
buildProfileDashboardPresentation,
- formatCompactPlayTime,
- formatPlayedWorkId,
- formatPlayedWorkType,
- formatTotalPlayTimeHours,
+ formatSnapshotTime,
} from './rpgEntryProfileDashboardPresentation';
import {
buildMembershipLabel,
@@ -911,19 +908,6 @@ type DiscoverChannel =
| 'ranking'
| 'edutainment';
-const COMMUNITY_QR_CODES = [
- {
- label: '微信群',
- src: communityWechatQrImage,
- alt: '玩家社区微信群二维码',
- },
- {
- label: 'QQ群',
- src: communityQqQrImage,
- alt: '玩家社区 QQ 群二维码',
- },
-] as const;
-
const DISCOVER_CHANNELS: Array<{
id: DiscoverChannel;
label: string;
@@ -1106,32 +1090,6 @@ function TopbarWalletShortcutButton({
);
}
-function SaveArchivePreview({
- entry,
- className,
-}: {
- entry: ProfileSaveArchiveSummary;
- className: string;
-}) {
- return (
-
- {entry.coverImageSrc ? (
-
- ) : (
-
- )}
-
-
- );
-}
-
function WorldCard({
entry,
onClick,
@@ -1864,65 +1822,6 @@ function RecommendRuntimeMeta({
);
}
-function SaveArchiveCard({
- entry,
- onClick,
- loading = false,
-}: {
- entry: ProfileSaveArchiveSummary;
- onClick: () => void;
- loading?: boolean;
-}) {
- const summaryText =
- entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
- const displayName = formatPlatformWorkDisplayName(entry.worldName);
-
- return (
-
- );
-}
-
function PlatformTabButton({
active,
label,
@@ -2433,24 +2332,6 @@ async function getPublicWorkAuthorSummary(
return null;
}
-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',
- });
-}
-
function buildPublicUserCode(user: AuthUser | null | undefined) {
if (user?.publicUserCode?.trim()) {
return user.publicUserCode.trim();
@@ -2547,42 +2428,8 @@ function cropAvatarImage(params: {
});
}
-function ProfileReferralUserAvatar({
- name,
- avatarUrl,
-}: {
- name: string;
- avatarUrl: string | null;
-}) {
- const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
-
- return (
-
- {avatarUrl ? (
-
- ) : (
- avatarLabel
- )}
-
- );
-}
-
const PROFILE_MODAL_OVERLAY_CLASS =
'platform-modal-backdrop !items-center !justify-center !px-4 !py-6';
-const PROFILE_SECONDARY_MODAL_OVERLAY_CLASS =
- '!items-center !bg-black/48 !px-3 !py-5 !backdrop-blur-none';
-const PROFILE_SECONDARY_MODAL_SOFT_OVERLAY_CLASS =
- '!items-center !bg-black/42 !px-3 !py-5 !backdrop-blur-none';
-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)]';
function ProfileNicknameModal({
value,
@@ -2600,21 +2447,12 @@ function ProfileNicknameModal({
onSubmit: () => void;
}) {
return (
-
+
);
}
@@ -2778,25 +2616,15 @@ function ProfileRechargeModal({
);
return (
-
@@ -2897,7 +2725,7 @@ function ProfileRechargeModal({
) : null}
-
+
);
}
@@ -3017,112 +2845,93 @@ function WalletLedgerModal({
const entries = walletLedgerPresentation.entries;
return (
-
-
-
-
-
- LEDGER
-
-
泥点账单
-
}
- className="mt-3 bg-white/70"
- >
- {walletLedgerPresentation.balanceLabel}
-
+
+
+ LEDGER
-
- {error ? (
-
- {error}
-
- 重新加载
-
-
- ) : isLoading ? (
-
- {Array.from({ length: 5 }).map((_, index) => (
-
- ))}
-
- ) : entries.length === 0 ? (
-
- 暂无账单记录
-
- ) : (
-
- {entries.map((entry) => {
- return (
-
-
-
- {entry.sourceLabel}
-
-
- {formatPlatformWorldTime(entry.createdAt)}
-
-
-
-
- {entry.amountLabel}
-
-
- {entry.balanceLabel}
-
-
-
- );
- })}
-
- )}
+
泥点账单
+
}
+ className="mt-3 bg-white/70"
+ >
+ {walletLedgerPresentation.balanceLabel}
+
-
+
+ {error ? (
+
+ {error}
+
+ 重新加载
+
+
+ ) : isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+ ) : entries.length === 0 ? (
+
+ 暂无账单记录
+
+ ) : (
+
+ {entries.map((entry) => {
+ return (
+
+
+
+ {entry.sourceLabel}
+
+
+ {formatPlatformWorldTime(entry.createdAt)}
+
+
+
+
+ {entry.amountLabel}
+
+
+ {entry.balanceLabel}
+
+
+
+ );
+ })}
+
+ )}
+
);
}
@@ -3151,23 +2960,12 @@ function ProfileTaskCenterModal({
const walletBalance = center?.walletBalance ?? fallbackBalance;
return (
-
{error ? (
@@ -3258,7 +3056,7 @@ function ProfileTaskCenterModal({
})}
)}
-
+
);
}
@@ -3280,21 +3078,11 @@ function RewardCodeRedeemModal({
onClose: () => void;
}) {
return (
-
) : null}
-
+
);
}
@@ -3517,490 +3305,6 @@ function ProfileQrScannerModal({
);
}
-function ProfileReferralModal({
- panel,
- center,
- isLoading,
- isSubmittingRedeem,
- redeemCode,
- copyInviteState,
- error,
- success,
- onClose,
- onCopyInvite,
- onRedeemCodeChange,
- onSubmitRedeemCode,
-}: {
- 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 title =
- panel === 'invite'
- ? '邀请好友'
- : panel === 'redeem'
- ? '填邀请码'
- : '玩家社区';
- const normalizedRedeemCode = redeemCode
- .trim()
- .replace(/[^0-9a-z]/gi, '')
- .toUpperCase();
- let content: ReactNode;
-
- if (panel === 'community') {
- content = (
-
- {COMMUNITY_QR_CODES.map((qrCode) => (
-
-
-

-
-
- {qrCode.label}
-
-
- ))}
-
- );
- } else if (panel === 'redeem') {
- content = isLoading ? (
-
- ) : center?.hasRedeemedCode ? (
-
- 已填写邀请码
-
- ) : (
-
- );
- } else if (isLoading) {
- content = (
-
- );
- } else {
- content = (
-
-
-
- 邀请码
-
-
- {center?.inviteCode ?? '--------'}
-
-
-
-
- {`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
-
- 每日最多获得十次邀请奖励。
-
-
}
- actionSurface="profile"
- actionSize="md"
- actionFullWidth
- className="gap-2 rounded-xl"
- />
-
-
- 成功邀请
-
- {center?.invitedUsers?.length ? (
-
- {center.invitedUsers.map((user) => (
-
-
-
-
- {user.displayName || '玩家'}
-
-
-
- ))}
-
- ) : (
-
- 暂无成功邀请
-
- )}
-
-
- );
- }
-
- return (
-
-
-
-
{title}
- {content}
-
- {error ? (
-
- {error}
-
- ) : null}
- {success ? (
-
- {success}
-
- ) : null}
-
-
- );
-}
-
-function ProfileSaveArchivesModal({
- saveEntries,
- saveError,
- isResumingSaveWorldKey,
- onClose,
- onResumeSave,
-}: {
- saveEntries: ProfileSaveArchiveSummary[];
- saveError: string | null;
- isResumingSaveWorldKey: string | null;
- onClose: () => void;
- onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
-}) {
- return (
-
-
-
-
-
-
- {saveError ? (
-
- {saveError}
-
- ) : null}
-
- {saveEntries.length > 0 ? (
-
- {saveEntries.map((entry) => (
- onResumeSave(entry)}
- />
- ))}
-
- ) : (
-
- 暂无存档
-
- )}
-
-
-
- );
-}
-
-function ProfilePlayedWorksModal({
- stats,
- isLoading,
- error,
- saveEntries,
- saveError,
- isResumingSaveWorldKey,
- onClose,
- onOpenWork,
- onResumeSave,
-}: {
- 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;
-}) {
- const playedWorks = stats?.playedWorks ?? [];
- const hasArchiveEntries = saveEntries.length > 0;
- const hasPlayedWorks = playedWorks.length > 0;
-
- return (
-
-
-
-
-
- PLAYED
-
-
玩过
-
}
- className="mt-2"
- >
- {formatTotalPlayTimeHours(stats?.totalPlayTimeMs ?? 0)}
-
-
-
- {error ? (
-
- {error}
-
- ) : null}
- {saveError ? (
-
- {saveError}
-
- ) : null}
-
- {isLoading ? (
-
- {Array.from({ length: 4 }).map((_, index) => (
-
- ))}
-
- ) : hasArchiveEntries || hasPlayedWorks ? (
-
- {hasArchiveEntries ? (
-
-
- 可继续
-
-
- {saveEntries.map((entry) => (
- onResumeSave(entry)}
- />
- ))}
-
-
- ) : null}
-
- {hasPlayedWorks ? (
-
-
- 玩过
-
-
- {playedWorks.map((work) => (
-
onOpenWork?.(work)}
- surface="flat"
- radius="sm"
- padding="md"
- interactive
- className="w-full hover:border-[#ff4056]"
- >
-
-
-
- {work.worldTitle}
-
- {work.worldSubtitle ? (
-
- {work.worldSubtitle}
-
- ) : null}
-
-
- {formatPlayedWorkType(work.worldType)}
-
-
-
-
- 作品号 {formatPlayedWorkId(work)}
-
-
- 最近 {formatSnapshotTime(work.lastPlayedAt)}
-
-
- 时长{' '}
- {formatCompactPlayTime(work.lastObservedPlayTimeMs)}
-
-
-
- ))}
-
-
- ) : null}
-
- ) : (
-
- 暂无玩过
-
- )}
-
-
- );
-}
-
export function RpgEntryHomeView({
activeTab,
isDesktopLayout: isDesktopLayoutProp,
@@ -6509,16 +5813,8 @@ export function RpgEntryHomeView({
))}
- {profilePopupPanel === 'saveArchives' ? (
-
- ) : profilePopupPanel ? (
-
) : null}
{isProfilePlayStatsOpen ? (
-
) : null}
- {profilePopupPanel === 'saveArchives' ? (
-
- ) : profilePopupPanel ? (
-
) : null}
{isProfilePlayStatsOpen ? (
-