收口个人中心弹层组件
- 新增 PlatformProfileModalShell 统一个人中心主弹层与副弹层壳层 - 抽离 PlatformProfilePlayedWorksModal 与 PlatformProfileReferralModal 并移除首页内联历史与邀请弹层实现 - 让昵称充值任务兑换码账单等弹层复用共享壳层并补齐测试和文档
This commit is contained in:
@@ -39,6 +39,9 @@
|
|||||||
- 2026-06-10 追加:推荐页运行态卡片底部的点赞 / 分享 / 改造入口,以及创作中心公开作品卡右上角分享入口统一迁移到 `PlatformIconButton`;这类和 swipe / drag 手势耦合的图标动作必须继续保留业务局部 class 与 `onPointerDown` / `onClick` 里的 `stopPropagation`,只把按钮语义、可访问名称和默认 `type="button"` 收口到共享组件,避免图标动作误触推荐卡切换、整卡打开或残留左滑状态。
|
- 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/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/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-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 追加: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`。
|
- 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`。
|
||||||
|
|||||||
@@ -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.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.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.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.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 顶栏按钮壳。
|
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"`。
|
20. 平台方形上传入口和紧凑虚线新增入口迁移到 `PlatformUploadTile`,上传后的图片预览迁移到 `PlatformUploadPreviewCard`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条、creation-agent 已选参考图条和拼图结果页关卡引用图横条已先迁移。方形缩略图使用默认 `layout="square"`,横向“已选择参考图 / 文件名 / 素材名 / 移除”条使用 `layout="inline"`;只读引用图条不传 `onRemove`,避免公共组件额外渲染删除入口。后续继续收口结果页素材上传、工作台参考图上传、紧凑虚线新增入口等上传 / 动作块时,业务页只保留文件选择、预览数组、预览回调、删除回调、校验逻辑或新增回调,上传方块外观、主副文案、缩略图壳、预览按钮、标题行、横向已选条、移除按钮和禁用态统一由 Module 承接;工具栏中的小图标上传仍继续使用 `PlatformIconButton asChild="label"`。
|
||||||
|
|||||||
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;
|
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
export type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
export type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
||||||
export type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
|
export type ProfilePopupPanel = ProfileReferralPanel;
|
||||||
export type RechargeTab = 'points' | 'membership';
|
export type RechargeTab = 'points' | 'membership';
|
||||||
|
|
||||||
type WechatPayResult = {
|
type WechatPayResult = {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Clock3,
|
Clock3,
|
||||||
Coins,
|
Coins,
|
||||||
Compass,
|
Compass,
|
||||||
Copy,
|
|
||||||
Crown,
|
Crown,
|
||||||
Gamepad2,
|
Gamepad2,
|
||||||
GitFork,
|
GitFork,
|
||||||
@@ -55,8 +54,6 @@ import profileCommunityImage from '../../../media/profile/_Image (7).png';
|
|||||||
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
|
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
|
||||||
import profileMascotImage from '../../../media/profile/_Image (9).png';
|
import profileMascotImage from '../../../media/profile/_Image (9).png';
|
||||||
import profilePointImage from '../../../media/profile/_Image.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 { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
||||||
import type {
|
import type {
|
||||||
CustomWorldLibraryEntry,
|
CustomWorldLibraryEntry,
|
||||||
@@ -67,7 +64,6 @@ import type {
|
|||||||
ProfilePlayStatsResponse,
|
ProfilePlayStatsResponse,
|
||||||
ProfileRechargeCenterResponse,
|
ProfileRechargeCenterResponse,
|
||||||
ProfileRechargeProduct,
|
ProfileRechargeProduct,
|
||||||
ProfileReferralInviteCenterResponse,
|
|
||||||
ProfileSaveArchiveSummary,
|
ProfileSaveArchiveSummary,
|
||||||
ProfileTaskCenterResponse,
|
ProfileTaskCenterResponse,
|
||||||
ProfileWalletLedgerResponse,
|
ProfileWalletLedgerResponse,
|
||||||
@@ -111,7 +107,6 @@ import {
|
|||||||
type SquareImageCropRect,
|
type SquareImageCropRect,
|
||||||
} from '../common/squareImageCropModel';
|
} from '../common/squareImageCropModel';
|
||||||
import {
|
import {
|
||||||
type CopyFeedbackState,
|
|
||||||
useCopyFeedback,
|
useCopyFeedback,
|
||||||
} from '../common/useCopyFeedback';
|
} from '../common/useCopyFeedback';
|
||||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||||
@@ -130,10 +125,15 @@ import {
|
|||||||
ProfileStatCard,
|
ProfileStatCard,
|
||||||
ProfileStatCardSkeleton,
|
ProfileStatCardSkeleton,
|
||||||
} from '../platform-entry/PlatformProfilePrimitives';
|
} 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 { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
||||||
import {
|
import {
|
||||||
type NativeWechatPaymentState,
|
type NativeWechatPaymentState,
|
||||||
type ProfileReferralPanel,
|
|
||||||
type RechargePaymentResult,
|
type RechargePaymentResult,
|
||||||
type RechargeTab,
|
type RechargeTab,
|
||||||
usePlatformProfileCenterController,
|
usePlatformProfileCenterController,
|
||||||
@@ -142,10 +142,7 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
|||||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||||
import {
|
import {
|
||||||
buildProfileDashboardPresentation,
|
buildProfileDashboardPresentation,
|
||||||
formatCompactPlayTime,
|
formatSnapshotTime,
|
||||||
formatPlayedWorkId,
|
|
||||||
formatPlayedWorkType,
|
|
||||||
formatTotalPlayTimeHours,
|
|
||||||
} from './rpgEntryProfileDashboardPresentation';
|
} from './rpgEntryProfileDashboardPresentation';
|
||||||
import {
|
import {
|
||||||
buildMembershipLabel,
|
buildMembershipLabel,
|
||||||
@@ -911,19 +908,6 @@ type DiscoverChannel =
|
|||||||
| 'ranking'
|
| 'ranking'
|
||||||
| 'edutainment';
|
| 'edutainment';
|
||||||
|
|
||||||
const COMMUNITY_QR_CODES = [
|
|
||||||
{
|
|
||||||
label: '微信群',
|
|
||||||
src: communityWechatQrImage,
|
|
||||||
alt: '玩家社区微信群二维码',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'QQ群',
|
|
||||||
src: communityQqQrImage,
|
|
||||||
alt: '玩家社区 QQ 群二维码',
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const DISCOVER_CHANNELS: Array<{
|
const DISCOVER_CHANNELS: Array<{
|
||||||
id: DiscoverChannel;
|
id: DiscoverChannel;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -1106,32 +1090,6 @@ function TopbarWalletShortcutButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ? (
|
|
||||||
<ResolvedAssetBackdrop
|
|
||||||
src={entry.coverImageSrc}
|
|
||||||
alt=""
|
|
||||||
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 WorldCard({
|
function WorldCard({
|
||||||
entry,
|
entry,
|
||||||
onClick,
|
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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlatformTabButton({
|
function PlatformTabButton({
|
||||||
active,
|
active,
|
||||||
label,
|
label,
|
||||||
@@ -2433,24 +2332,6 @@ async function getPublicWorkAuthorSummary(
|
|||||||
return null;
|
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) {
|
function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||||
if (user?.publicUserCode?.trim()) {
|
if (user?.publicUserCode?.trim()) {
|
||||||
return 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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROFILE_MODAL_OVERLAY_CLASS =
|
const PROFILE_MODAL_OVERLAY_CLASS =
|
||||||
'platform-modal-backdrop !items-center !justify-center !px-4 !py-6';
|
'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({
|
function ProfileNicknameModal({
|
||||||
value,
|
value,
|
||||||
@@ -2600,21 +2447,12 @@ function ProfileNicknameModal({
|
|||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<UnifiedModal
|
<PlatformProfileModalShell
|
||||||
open
|
|
||||||
title="修改昵称"
|
title="修改昵称"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
closeLabel="关闭昵称修改"
|
closeLabel="关闭昵称修改"
|
||||||
closeVariant="profileCompact"
|
closeVariant="profileCompact"
|
||||||
closeOnBackdrop={false}
|
|
||||||
closeOnEscape={false}
|
|
||||||
portal={false}
|
|
||||||
size="sm"
|
|
||||||
zIndexClassName="z-[80]"
|
|
||||||
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
|
||||||
panelClassName="platform-remap-surface !max-w-sm rounded-[1.4rem]"
|
panelClassName="platform-remap-surface !max-w-sm rounded-[1.4rem]"
|
||||||
headerClassName={PROFILE_MODAL_HEADER_CLASS}
|
|
||||||
titleClassName={PROFILE_MODAL_TITLE_CLASS}
|
|
||||||
bodyClassName="px-5 py-5"
|
bodyClassName="px-5 py-5"
|
||||||
>
|
>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -2654,7 +2492,7 @@ function ProfileNicknameModal({
|
|||||||
{isSaving ? '保存中' : '保存'}
|
{isSaving ? '保存中' : '保存'}
|
||||||
</PlatformActionButton>
|
</PlatformActionButton>
|
||||||
</div>
|
</div>
|
||||||
</UnifiedModal>
|
</PlatformProfileModalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2778,25 +2616,15 @@ function ProfileRechargeModal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnifiedModal
|
<PlatformProfileModalShell
|
||||||
open
|
|
||||||
title="账户充值"
|
title="账户充值"
|
||||||
description={
|
description={
|
||||||
center ? `${center.walletBalance}泥点 · ${memberLabel}` : '读取中'
|
center ? `${center.walletBalance}泥点 · ${memberLabel}` : '读取中'
|
||||||
}
|
}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
closeLabel="关闭账户充值"
|
closeLabel="关闭账户充值"
|
||||||
closeVariant="profile"
|
|
||||||
closeOnBackdrop={false}
|
|
||||||
closeOnEscape={false}
|
|
||||||
portal={false}
|
|
||||||
size="md"
|
size="md"
|
||||||
zIndexClassName="z-[80]"
|
|
||||||
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
|
||||||
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
|
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
|
||||||
headerClassName={PROFILE_MODAL_HEADER_CLASS}
|
|
||||||
titleClassName={PROFILE_MODAL_TITLE_CLASS}
|
|
||||||
descriptionClassName={PROFILE_MODAL_DESCRIPTION_CLASS}
|
|
||||||
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
|
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -2897,7 +2725,7 @@ function ProfileRechargeModal({
|
|||||||
</PlatformActionButton>
|
</PlatformActionButton>
|
||||||
</PlatformSubpanel>
|
</PlatformSubpanel>
|
||||||
) : null}
|
) : null}
|
||||||
</UnifiedModal>
|
</PlatformProfileModalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3017,29 +2845,14 @@ function WalletLedgerModal({
|
|||||||
const entries = walletLedgerPresentation.entries;
|
const entries = walletLedgerPresentation.entries;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnifiedModal
|
<PlatformProfileSecondaryModalShell
|
||||||
open
|
|
||||||
title="泥点账单"
|
title="泥点账单"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
showHeader={false}
|
closeLabel="关闭泥点账单"
|
||||||
showCloseButton={false}
|
closeButtonClassName="bg-white/78"
|
||||||
closeOnBackdrop={false}
|
|
||||||
closeOnEscape={false}
|
|
||||||
portal={false}
|
|
||||||
size="sm"
|
|
||||||
zIndexClassName="z-[80]"
|
|
||||||
overlayClassName={PROFILE_SECONDARY_MODAL_OVERLAY_CLASS}
|
|
||||||
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[30rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[30rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
||||||
bodyClassName="!p-0"
|
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
|
||||||
>
|
>
|
||||||
<div className="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
|
||||||
<PlatformModalCloseButton
|
|
||||||
label="关闭泥点账单"
|
|
||||||
variant="floating"
|
|
||||||
onClick={onClose}
|
|
||||||
className="bg-white/78"
|
|
||||||
icon="×"
|
|
||||||
/>
|
|
||||||
<div className="pr-10">
|
<div className="pr-10">
|
||||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||||
LEDGER
|
LEDGER
|
||||||
@@ -3070,10 +2883,7 @@ function WalletLedgerModal({
|
|||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
<div
|
<div key={index} className="h-16 animate-pulse rounded-xl bg-zinc-100" />
|
||||||
key={index}
|
|
||||||
className="h-16 animate-pulse rounded-xl bg-zinc-100"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
@@ -3121,8 +2931,7 @@ function WalletLedgerModal({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PlatformProfileSecondaryModalShell>
|
||||||
</UnifiedModal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3151,23 +2960,12 @@ function ProfileTaskCenterModal({
|
|||||||
const walletBalance = center?.walletBalance ?? fallbackBalance;
|
const walletBalance = center?.walletBalance ?? fallbackBalance;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnifiedModal
|
<PlatformProfileModalShell
|
||||||
open
|
|
||||||
title="每日任务"
|
title="每日任务"
|
||||||
description={`${walletBalance}泥点`}
|
description={`${walletBalance}泥点`}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
closeLabel="关闭每日任务"
|
closeLabel="关闭每日任务"
|
||||||
closeVariant="profile"
|
|
||||||
closeOnBackdrop={false}
|
|
||||||
closeOnEscape={false}
|
|
||||||
portal={false}
|
|
||||||
size="sm"
|
|
||||||
zIndexClassName="z-[80]"
|
|
||||||
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
|
||||||
panelClassName="platform-recharge-modal !max-w-md rounded-[1.4rem]"
|
panelClassName="platform-recharge-modal !max-w-md rounded-[1.4rem]"
|
||||||
headerClassName={PROFILE_MODAL_HEADER_CLASS}
|
|
||||||
titleClassName={PROFILE_MODAL_TITLE_CLASS}
|
|
||||||
descriptionClassName={PROFILE_MODAL_DESCRIPTION_CLASS}
|
|
||||||
bodyClassName="space-y-3 px-5 py-5"
|
bodyClassName="space-y-3 px-5 py-5"
|
||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -3258,7 +3056,7 @@ function ProfileTaskCenterModal({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</UnifiedModal>
|
</PlatformProfileModalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3280,21 +3078,11 @@ function RewardCodeRedeemModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<UnifiedModal
|
<PlatformProfileModalShell
|
||||||
open
|
|
||||||
title="兑换码"
|
title="兑换码"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
closeLabel="关闭兑换码"
|
closeLabel="关闭兑换码"
|
||||||
closeVariant="profile"
|
|
||||||
closeOnBackdrop={false}
|
|
||||||
closeOnEscape={false}
|
|
||||||
portal={false}
|
|
||||||
size="sm"
|
|
||||||
zIndexClassName="z-[80]"
|
|
||||||
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
|
||||||
panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
|
panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
|
||||||
headerClassName={PROFILE_MODAL_HEADER_CLASS}
|
|
||||||
titleClassName={PROFILE_MODAL_TITLE_CLASS}
|
|
||||||
bodyClassName="space-y-3 px-5 py-5"
|
bodyClassName="space-y-3 px-5 py-5"
|
||||||
>
|
>
|
||||||
<PlatformTextField
|
<PlatformTextField
|
||||||
@@ -3342,7 +3130,7 @@ function RewardCodeRedeemModal({
|
|||||||
{success}
|
{success}
|
||||||
</PlatformStatusMessage>
|
</PlatformStatusMessage>
|
||||||
) : null}
|
) : null}
|
||||||
</UnifiedModal>
|
</PlatformProfileModalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 = (
|
|
||||||
<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 (
|
|
||||||
<UnifiedModal
|
|
||||||
open
|
|
||||||
title={title}
|
|
||||||
onClose={onClose}
|
|
||||||
showHeader={false}
|
|
||||||
showCloseButton={false}
|
|
||||||
closeOnBackdrop={false}
|
|
||||||
closeOnEscape={false}
|
|
||||||
portal={false}
|
|
||||||
size="sm"
|
|
||||||
zIndexClassName="z-[80]"
|
|
||||||
overlayClassName={PROFILE_SECONDARY_MODAL_SOFT_OVERLAY_CLASS}
|
|
||||||
panelClassName="relative !max-w-[24rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
|
||||||
bodyClassName="!p-0"
|
|
||||||
>
|
|
||||||
<div className="relative px-5 pb-5 pt-4">
|
|
||||||
<PlatformModalCloseButton
|
|
||||||
label={`关闭${title}`}
|
|
||||||
variant="floatingPlain"
|
|
||||||
onClick={onClose}
|
|
||||||
icon="×"
|
|
||||||
/>
|
|
||||||
<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}
|
|
||||||
</div>
|
|
||||||
</UnifiedModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileSaveArchivesModal({
|
|
||||||
saveEntries,
|
|
||||||
saveError,
|
|
||||||
isResumingSaveWorldKey,
|
|
||||||
onClose,
|
|
||||||
onResumeSave,
|
|
||||||
}: {
|
|
||||||
saveEntries: ProfileSaveArchiveSummary[];
|
|
||||||
saveError: string | null;
|
|
||||||
isResumingSaveWorldKey: string | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
|
||||||
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[38rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
|
|
||||||
<PlatformModalCloseButton
|
|
||||||
label="关闭存档"
|
|
||||||
variant="floating"
|
|
||||||
onClick={onClose}
|
|
||||||
icon="×"
|
|
||||||
/>
|
|
||||||
<div className="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]">
|
|
||||||
SAVES
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-2xl font-black">存档</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{saveError ? (
|
|
||||||
<PlatformStatusMessage tone="error" className="mt-4">
|
|
||||||
{saveError}
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{saveEntries.length > 0 ? (
|
|
||||||
<div className="mt-5 grid gap-3">
|
|
||||||
{saveEntries.map((entry) => (
|
|
||||||
<SaveArchiveCard
|
|
||||||
key={`${entry.worldKey}:profile-archive`}
|
|
||||||
entry={entry}
|
|
||||||
loading={isResumingSaveWorldKey === entry.worldKey}
|
|
||||||
onClick={() => onResumeSave(entry)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<PlatformEmptyState
|
|
||||||
surface="subpanel"
|
|
||||||
size="inline"
|
|
||||||
className="mt-5"
|
|
||||||
>
|
|
||||||
暂无存档
|
|
||||||
</PlatformEmptyState>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<UnifiedModal
|
|
||||||
open
|
|
||||||
title="玩过"
|
|
||||||
onClose={onClose}
|
|
||||||
showHeader={false}
|
|
||||||
showCloseButton={false}
|
|
||||||
closeOnBackdrop={false}
|
|
||||||
closeOnEscape={false}
|
|
||||||
portal={false}
|
|
||||||
size="sm"
|
|
||||||
zIndexClassName="z-[80]"
|
|
||||||
overlayClassName={PROFILE_SECONDARY_MODAL_OVERLAY_CLASS}
|
|
||||||
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[38rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
|
||||||
bodyClassName="!p-0"
|
|
||||||
>
|
|
||||||
<div className="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
|
||||||
<PlatformModalCloseButton
|
|
||||||
label="关闭玩过"
|
|
||||||
variant="floating"
|
|
||||||
onClick={onClose}
|
|
||||||
icon="×"
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</UnifiedModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RpgEntryHomeView({
|
export function RpgEntryHomeView({
|
||||||
activeTab,
|
activeTab,
|
||||||
isDesktopLayout: isDesktopLayoutProp,
|
isDesktopLayout: isDesktopLayoutProp,
|
||||||
@@ -6509,16 +5813,8 @@ export function RpgEntryHomeView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{profilePopupPanel === 'saveArchives' ? (
|
{profilePopupPanel ? (
|
||||||
<ProfileSaveArchivesModal
|
<PlatformProfileReferralModal
|
||||||
saveEntries={saveEntries}
|
|
||||||
saveError={saveError}
|
|
||||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
|
||||||
onClose={closeProfilePopupPanel}
|
|
||||||
onResumeSave={onResumeSave}
|
|
||||||
/>
|
|
||||||
) : profilePopupPanel ? (
|
|
||||||
<ProfileReferralModal
|
|
||||||
panel={profilePopupPanel}
|
panel={profilePopupPanel}
|
||||||
center={referralCenter}
|
center={referralCenter}
|
||||||
isLoading={isLoadingReferral}
|
isLoading={isLoadingReferral}
|
||||||
@@ -6552,7 +5848,7 @@ export function RpgEntryHomeView({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isProfilePlayStatsOpen ? (
|
{isProfilePlayStatsOpen ? (
|
||||||
<ProfilePlayedWorksModal
|
<PlatformProfilePlayedWorksModal
|
||||||
stats={profilePlayStats}
|
stats={profilePlayStats}
|
||||||
isLoading={isProfilePlayStatsLoading}
|
isLoading={isProfilePlayStatsLoading}
|
||||||
error={profilePlayStatsError}
|
error={profilePlayStatsError}
|
||||||
@@ -6699,16 +5995,8 @@ export function RpgEntryHomeView({
|
|||||||
onClaim={claimTaskReward}
|
onClaim={claimTaskReward}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{profilePopupPanel === 'saveArchives' ? (
|
{profilePopupPanel ? (
|
||||||
<ProfileSaveArchivesModal
|
<PlatformProfileReferralModal
|
||||||
saveEntries={saveEntries}
|
|
||||||
saveError={saveError}
|
|
||||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
|
||||||
onClose={closeProfilePopupPanel}
|
|
||||||
onResumeSave={onResumeSave}
|
|
||||||
/>
|
|
||||||
) : profilePopupPanel ? (
|
|
||||||
<ProfileReferralModal
|
|
||||||
panel={profilePopupPanel}
|
panel={profilePopupPanel}
|
||||||
center={referralCenter}
|
center={referralCenter}
|
||||||
isLoading={isLoadingReferral}
|
isLoading={isLoadingReferral}
|
||||||
@@ -6724,7 +6012,7 @@ export function RpgEntryHomeView({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isProfilePlayStatsOpen ? (
|
{isProfilePlayStatsOpen ? (
|
||||||
<ProfilePlayedWorksModal
|
<PlatformProfilePlayedWorksModal
|
||||||
stats={profilePlayStats}
|
stats={profilePlayStats}
|
||||||
isLoading={isProfilePlayStatsLoading}
|
isLoading={isProfilePlayStatsLoading}
|
||||||
error={profilePlayStatsError}
|
error={profilePlayStatsError}
|
||||||
|
|||||||
@@ -75,6 +75,24 @@ export function formatPlayedWorkId(work: ProfilePlayedWorkSummary) {
|
|||||||
return work.profileId?.trim() || work.worldKey;
|
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(
|
export function buildProfileDashboardPresentation(
|
||||||
dashboard: ProfileDashboardSummary | null,
|
dashboard: ProfileDashboardSummary | null,
|
||||||
): ProfileDashboardPresentation {
|
): ProfileDashboardPresentation {
|
||||||
|
|||||||
Reference in New Issue
Block a user