抽离个人中心展示原子组件
新增 PlatformProfilePrimitives 收口个人中心统计卡快捷入口设置行与法律入口 RpgEntryHomeView 改为复用平台级个人中心展示组件 补充组件测试并更新前端收口文档与共享决策
This commit is contained in:
@@ -37,6 +37,7 @@
|
|||||||
- 背景:前端已有 `UnifiedModal` 统一遮罩和无障碍外壳,但业务页面仍反复手写“知道了”“确认 / 取消”“危险确认”的 footer 按钮和关闭禁用逻辑。
|
- 背景:前端已有 `UnifiedModal` 统一遮罩和无障碍外壳,但业务页面仍反复手写“知道了”“确认 / 取消”“危险确认”的 footer 按钮和关闭禁用逻辑。
|
||||||
- 决策:简单提示、确认 / 取消和危险确认统一使用 `src/components/common/UnifiedConfirmDialog.tsx`;剪贴板复制反馈统一使用 `src/components/common/useCopyFeedback.ts`,可点击复制按钮统一使用 `src/components/common/CopyFeedbackButton.tsx` 承载图标、三态文案、可访问名称、纯图标模式和动作按钮外观入口,作品号 / 用户号等短代码 chip 统一使用 `src/components/common/CopyCodeButton.tsx` 承载代码、三态后缀和默认可访问名称,非按钮复制提示统一使用 `src/components/common/CopyFeedbackMessage.tsx`,白底平台状态提示统一使用 `src/components/common/PlatformStatusMessage.tsx`,无操作空态 / 轻量读取态统一使用 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮统一使用 `src/components/common/PlatformActionButton.tsx` 承载 platform / profile 两类样式族、尺寸、圆角、对齐、宽度和禁用态;认证表单的提交、验证码、第三方登录和邀请码提交按钮使用 `size="lg"` 复用 48px 高度,统一创作工作台、统一创作页壳层、玩法创作工作台、结果页返回按钮和反馈页 header 返回使用 `tone="ghost"`,生成 / 提交 / 发布按钮使用主动作,自定义世界实体目录、RPG 首页作品卡删除、创作中心错误重试和素材槽的小动作使用 `size="xs"` 或 `shape="pill"` 收口,推荐回复和列表内动作使用 `align="start"` 承接左对齐,上传控件等需要 label 语义时使用 `PlatformActionButton asChild="label"`,不把文件输入伪装成普通 button。普通平台图标动作按钮和图标上传 label 统一使用 `src/components/common/PlatformIconButton.tsx` 承载 `platform-icon-button` 外观、可访问名称、默认 `type="button"`、`asChild="label"` 和可选 title;历史图片选择弹窗、RPG 发布检查弹窗、RPG 首页搜索结果清空、creative-agent 侧边栏关闭 / 外观 / 设置入口、creation-agent 参考图移除、敲木鱼结果页新增主题标签入口、拼图结果页标签生成 / 标签新增 / 关卡详情关闭 / 发布弹窗关闭 / 删除关卡入口、视觉小说结果页素材选择 / 音频生成 / 保存草稿 / 运行配置入口,以及抓大鹅结果页标签生成 / 标签新增 / 物品素材删除 / 参考图上传入口已先迁移;图标上传控件必须保留 label + file input 语义。平台 / 个人中心弹窗关闭按钮统一使用 `src/components/common/PlatformModalCloseButton.tsx` 承载 profile / profileCompact / floating / floatingPlain / platformIcon 五类圆形关闭按钮、默认图标和可访问名称;认证入口、邀请码弹窗、抓大鹅结果页弹窗关闭等平台头部关闭按钮使用 `variant="platformIcon"`,不在业务 JSX 中手写 `platform-icon-button` + X 图标。RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪结果页,拼消消 / 宝贝识物 / 视觉小说 / 汪汪声浪创作工作台,发布检查、素材生成面板和自定义世界实体目录中的错误 / 成功 / 信息 / 警告 / 中性提示使用 `PlatformStatusMessage surface="platform"` 复用平台 banner token;个人中心弹窗、账号安全弹窗、认证入口、验证码提示、统一创作工作台和通用创作输入区的错误 / 成功 / 信息 / 警告提示使用 `PlatformStatusMessage surface="profile"` 复用 profile token,不再把 `platform-profile-error` / `platform-profile-success` 或 `platform-banner--danger / success / info / warning / neutral` 作为业务 JSX 接口。`UnifiedModal` 继续作为底层模态窗口 Module。已有弹窗栈内的二级确认使用 `UnifiedConfirmDialog portal={false}` 内嵌到当前层级。特殊确认按钮外观通过 `confirmClassName` 适配,不让业务页重新手写 footer;`UnifiedConfirmDialog` 自身的 footer 按钮也复用 `PlatformActionButton`。带复制状态、渠道按钮、媒体预览或复杂网格的弹窗可以保留专用 Module,但普通确认按钮、普通动作按钮、普通图标动作按钮、复制按钮动作外观、复制状态机、copied / failed 按钮 / toast 分支、基础错误 / 成功提示条、无操作空态和普通弹窗关闭按钮不再直接写进业务页面。运行态 HUD、输入 Composer 发送 / 上传按钮、复制三态图标按钮或需要专用交互禁用语义的图标按钮先保留专用布局,等对应场景验证时再迁移。业务代码中的阻断提示、删除确认和公开作品失效恢复不得继续调用浏览器原生 `window.alert` / `window.confirm`,应由页面壳层或编辑器壳层用 `UnifiedConfirmDialog` 承接。简单确认需要像素风时使用 `UnifiedConfirmDialog variant="pixel"`,不再为同类确认单独维护壳层和按钮。
|
- 决策:简单提示、确认 / 取消和危险确认统一使用 `src/components/common/UnifiedConfirmDialog.tsx`;剪贴板复制反馈统一使用 `src/components/common/useCopyFeedback.ts`,可点击复制按钮统一使用 `src/components/common/CopyFeedbackButton.tsx` 承载图标、三态文案、可访问名称、纯图标模式和动作按钮外观入口,作品号 / 用户号等短代码 chip 统一使用 `src/components/common/CopyCodeButton.tsx` 承载代码、三态后缀和默认可访问名称,非按钮复制提示统一使用 `src/components/common/CopyFeedbackMessage.tsx`,白底平台状态提示统一使用 `src/components/common/PlatformStatusMessage.tsx`,无操作空态 / 轻量读取态统一使用 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮统一使用 `src/components/common/PlatformActionButton.tsx` 承载 platform / profile 两类样式族、尺寸、圆角、对齐、宽度和禁用态;认证表单的提交、验证码、第三方登录和邀请码提交按钮使用 `size="lg"` 复用 48px 高度,统一创作工作台、统一创作页壳层、玩法创作工作台、结果页返回按钮和反馈页 header 返回使用 `tone="ghost"`,生成 / 提交 / 发布按钮使用主动作,自定义世界实体目录、RPG 首页作品卡删除、创作中心错误重试和素材槽的小动作使用 `size="xs"` 或 `shape="pill"` 收口,推荐回复和列表内动作使用 `align="start"` 承接左对齐,上传控件等需要 label 语义时使用 `PlatformActionButton asChild="label"`,不把文件输入伪装成普通 button。普通平台图标动作按钮和图标上传 label 统一使用 `src/components/common/PlatformIconButton.tsx` 承载 `platform-icon-button` 外观、可访问名称、默认 `type="button"`、`asChild="label"` 和可选 title;历史图片选择弹窗、RPG 发布检查弹窗、RPG 首页搜索结果清空、creative-agent 侧边栏关闭 / 外观 / 设置入口、creation-agent 参考图移除、敲木鱼结果页新增主题标签入口、拼图结果页标签生成 / 标签新增 / 关卡详情关闭 / 发布弹窗关闭 / 删除关卡入口、视觉小说结果页素材选择 / 音频生成 / 保存草稿 / 运行配置入口,以及抓大鹅结果页标签生成 / 标签新增 / 物品素材删除 / 参考图上传入口已先迁移;图标上传控件必须保留 label + file input 语义。平台 / 个人中心弹窗关闭按钮统一使用 `src/components/common/PlatformModalCloseButton.tsx` 承载 profile / profileCompact / floating / floatingPlain / platformIcon 五类圆形关闭按钮、默认图标和可访问名称;认证入口、邀请码弹窗、抓大鹅结果页弹窗关闭等平台头部关闭按钮使用 `variant="platformIcon"`,不在业务 JSX 中手写 `platform-icon-button` + X 图标。RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪结果页,拼消消 / 宝贝识物 / 视觉小说 / 汪汪声浪创作工作台,发布检查、素材生成面板和自定义世界实体目录中的错误 / 成功 / 信息 / 警告 / 中性提示使用 `PlatformStatusMessage surface="platform"` 复用平台 banner token;个人中心弹窗、账号安全弹窗、认证入口、验证码提示、统一创作工作台和通用创作输入区的错误 / 成功 / 信息 / 警告提示使用 `PlatformStatusMessage surface="profile"` 复用 profile token,不再把 `platform-profile-error` / `platform-profile-success` 或 `platform-banner--danger / success / info / warning / neutral` 作为业务 JSX 接口。`UnifiedModal` 继续作为底层模态窗口 Module。已有弹窗栈内的二级确认使用 `UnifiedConfirmDialog portal={false}` 内嵌到当前层级。特殊确认按钮外观通过 `confirmClassName` 适配,不让业务页重新手写 footer;`UnifiedConfirmDialog` 自身的 footer 按钮也复用 `PlatformActionButton`。带复制状态、渠道按钮、媒体预览或复杂网格的弹窗可以保留专用 Module,但普通确认按钮、普通动作按钮、普通图标动作按钮、复制按钮动作外观、复制状态机、copied / failed 按钮 / toast 分支、基础错误 / 成功提示条、无操作空态和普通弹窗关闭按钮不再直接写进业务页面。运行态 HUD、输入 Composer 发送 / 上传按钮、复制三态图标按钮或需要专用交互禁用语义的图标按钮先保留专用布局,等对应场景验证时再迁移。业务代码中的阻断提示、删除确认和公开作品失效恢复不得继续调用浏览器原生 `window.alert` / `window.confirm`,应由页面壳层或编辑器壳层用 `UnifiedConfirmDialog` 承接。简单确认需要像素风时使用 `UnifiedConfirmDialog variant="pixel"`,不再为同类确认单独维护壳层和按钮。
|
||||||
- 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-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`。
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
19.3.9. 个人中心昵称旁的铅笔入口迁移到 `PlatformIconButton`;页面继续保留 `.platform-profile-edit-button` 局部 class 控制 1.45rem 紧凑尺寸、边框与浅色底,但按钮语义、默认 `type="button"` 和共享 icon chrome 统一由公共组件承接,不再在 `RpgEntryHomeView` 里手写原生图标按钮。
|
19.3.9. 个人中心昵称旁的铅笔入口迁移到 `PlatformIconButton`;页面继续保留 `.platform-profile-edit-button` 局部 class 控制 1.45rem 紧凑尺寸、边框与浅色底,但按钮语义、默认 `type="button"` 和共享 icon chrome 统一由公共组件承接,不再在 `RpgEntryHomeView` 里手写原生图标按钮。
|
||||||
19.3.10. RPG 首页推荐运行态卡片底部的点赞 / 分享 / 改造入口迁移到 `PlatformIconButton`;推荐卡继续保留 `.platform-recommend-work-meta__action*` 局部 class 控制透明圆角按钮尺寸、间距和玩法主题色,同时显式保留 `onPointerDown` / `onClick` 里的 `stopPropagation`,避免图标动作把推荐卡纵向拖拽切换误触发。后续任何耦合 swipe / drag 手势的图标动作都沿用“共享按钮承接语义,本地 class 保留视觉与手势隔离”的策略。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged in recommend runtime preloads adjacent work previews and drag switches like video feed"`。
|
19.3.10. RPG 首页推荐运行态卡片底部的点赞 / 分享 / 改造入口迁移到 `PlatformIconButton`;推荐卡继续保留 `.platform-recommend-work-meta__action*` 局部 class 控制透明圆角按钮尺寸、间距和玩法主题色,同时显式保留 `onPointerDown` / `onClick` 里的 `stopPropagation`,避免图标动作把推荐卡纵向拖拽切换误触发。后续任何耦合 swipe / drag 手势的图标动作都沿用“共享按钮承接语义,本地 class 保留视觉与手势隔离”的策略。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged in recommend runtime preloads adjacent work previews and drag switches like video feed"`。
|
||||||
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. 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"`。
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { Settings } from 'lucide-react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ICP_RECORD_NUMBER, LEGAL_DOCUMENTS } from '../common/legalDocuments';
|
||||||
|
import {
|
||||||
|
ProfileLegalSection,
|
||||||
|
ProfileSettingsRow,
|
||||||
|
ProfileShortcutButton,
|
||||||
|
ProfileStatCard,
|
||||||
|
} from './PlatformProfilePrimitives';
|
||||||
|
|
||||||
|
function TestIcon({ className }: { className?: string }) {
|
||||||
|
return <span className={className}>I</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PlatformProfilePrimitives', () => {
|
||||||
|
test('ProfileStatCard reports its dashboard card key on click', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProfileStatCard
|
||||||
|
cardKey="wallet"
|
||||||
|
label="泥点余额"
|
||||||
|
value="88"
|
||||||
|
icon={TestIcon}
|
||||||
|
onClick={onClick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /泥点余额\s*88/u }));
|
||||||
|
expect(onClick).toHaveBeenCalledWith('wallet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ProfileShortcutButton keeps shortcut label and sub label visible', () => {
|
||||||
|
render(
|
||||||
|
<ProfileShortcutButton
|
||||||
|
label="玩家社区"
|
||||||
|
subLabel="交流心得"
|
||||||
|
icon={TestIcon}
|
||||||
|
onClick={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /玩家社区/u });
|
||||||
|
expect(button.className).toContain('platform-profile-shortcut-button');
|
||||||
|
expect(screen.getByText('交流心得')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ProfileSettingsRow and ProfileLegalSection keep their click affordances', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSettingsClick = vi.fn();
|
||||||
|
const onOpenDocument = vi.fn();
|
||||||
|
const firstLegalDocument = LEGAL_DOCUMENTS[0];
|
||||||
|
|
||||||
|
if (!firstLegalDocument) {
|
||||||
|
throw new Error('expected legal documents fixtures');
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<ProfileSettingsRow
|
||||||
|
label="通用设置"
|
||||||
|
icon={Settings}
|
||||||
|
onClick={onSettingsClick}
|
||||||
|
/>
|
||||||
|
<ProfileLegalSection onOpenDocument={onOpenDocument} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /通用设置/u }));
|
||||||
|
expect(onSettingsClick).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: firstLegalDocument.title }),
|
||||||
|
);
|
||||||
|
expect(onOpenDocument).toHaveBeenCalledWith(firstLegalDocument.id);
|
||||||
|
expect(screen.getByText(ICP_RECORD_NUMBER)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
177
src/components/platform-entry/PlatformProfilePrimitives.tsx
Normal file
177
src/components/platform-entry/PlatformProfilePrimitives.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { ProfileDashboardCardKey } from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import {
|
||||||
|
ICP_RECORD_NUMBER,
|
||||||
|
ICP_RECORD_URL,
|
||||||
|
LEGAL_DOCUMENTS,
|
||||||
|
type LegalDocumentId,
|
||||||
|
} from '../common/legalDocuments';
|
||||||
|
|
||||||
|
type ProfileStatCardProps = {
|
||||||
|
cardKey: ProfileDashboardCardKey;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
imageSrc?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileStatCard({
|
||||||
|
cardKey,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
imageSrc,
|
||||||
|
}: ProfileStatCardProps) {
|
||||||
|
const Icon = icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||||||
|
aria-label={`${label} ${value}`}
|
||||||
|
className="platform-profile-stat-card flex min-h-[5.25rem] items-center justify-center gap-2 px-2.5 py-2.5 text-center transition"
|
||||||
|
>
|
||||||
|
<div className="platform-profile-stat-card__icon">
|
||||||
|
{imageSrc ? (
|
||||||
|
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-left">
|
||||||
|
<div className="platform-profile-stat-card__value whitespace-nowrap text-[16px] font-black leading-none text-[var(--platform-text-strong)]">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileStatCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center">
|
||||||
|
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
|
||||||
|
<div className="mt-2 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileShortcutButtonProps = {
|
||||||
|
label: string;
|
||||||
|
subLabel?: ReactNode;
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
onClick?: (() => void) | null;
|
||||||
|
imageSrc?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileShortcutButton({
|
||||||
|
label,
|
||||||
|
subLabel,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
imageSrc,
|
||||||
|
}: ProfileShortcutButtonProps) {
|
||||||
|
const Icon = icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick ?? undefined}
|
||||||
|
className="platform-profile-shortcut-button flex min-h-[4.75rem] w-full flex-col items-center justify-center gap-1.5 px-2 py-2.5 text-center transition"
|
||||||
|
>
|
||||||
|
<div className="platform-profile-shortcut-button__icon">
|
||||||
|
{imageSrc ? (
|
||||||
|
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<Icon className="h-[1.125rem] w-[1.125rem]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[12px] font-semibold text-[var(--platform-text-strong)]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{subLabel ? (
|
||||||
|
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[10px] font-medium text-[var(--platform-text-soft)]">
|
||||||
|
{subLabel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileSettingsRowProps = {
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileSettingsRow({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
}: ProfileSettingsRowProps) {
|
||||||
|
const Icon = icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
|
<span className="platform-profile-settings-row__icon">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[14px] font-semibold text-[var(--platform-text-strong)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileLegalSectionProps = {
|
||||||
|
onOpenDocument: (documentId: LegalDocumentId) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileLegalSection({
|
||||||
|
onOpenDocument,
|
||||||
|
}: ProfileLegalSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="platform-profile-legal-strip" aria-label="法律信息">
|
||||||
|
<div className="platform-profile-legal-strip__links">
|
||||||
|
{LEGAL_DOCUMENTS.map((document, index) => (
|
||||||
|
<button
|
||||||
|
key={document.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenDocument(document.id)}
|
||||||
|
className="platform-profile-legal-strip__link"
|
||||||
|
>
|
||||||
|
{document.title}
|
||||||
|
{index < LEGAL_DOCUMENTS.length - 1 ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="platform-profile-legal-strip__divider"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={ICP_RECORD_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="platform-profile-legal-strip__record"
|
||||||
|
>
|
||||||
|
{ICP_RECORD_NUMBER}
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -115,9 +115,6 @@ import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
|||||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||||
import {
|
import {
|
||||||
getLegalDocument,
|
getLegalDocument,
|
||||||
ICP_RECORD_NUMBER,
|
|
||||||
ICP_RECORD_URL,
|
|
||||||
LEGAL_DOCUMENTS,
|
|
||||||
type LegalDocumentId,
|
type LegalDocumentId,
|
||||||
} from '../common/legalDocuments';
|
} from '../common/legalDocuments';
|
||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
@@ -151,6 +148,13 @@ import {
|
|||||||
findPublicWorkForHistoryEntry,
|
findPublicWorkForHistoryEntry,
|
||||||
isEdutainmentEntryEnabled,
|
isEdutainmentEntryEnabled,
|
||||||
} from '../platform-entry/platformEdutainmentVisibility';
|
} from '../platform-entry/platformEdutainmentVisibility';
|
||||||
|
import {
|
||||||
|
ProfileLegalSection,
|
||||||
|
ProfileSettingsRow,
|
||||||
|
ProfileShortcutButton,
|
||||||
|
ProfileStatCard,
|
||||||
|
ProfileStatCardSkeleton,
|
||||||
|
} from '../platform-entry/PlatformProfilePrimitives';
|
||||||
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||||
@@ -2620,165 +2624,6 @@ function cropAvatarImage(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileStatCard({
|
|
||||||
cardKey,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onClick,
|
|
||||||
icon,
|
|
||||||
imageSrc,
|
|
||||||
}: {
|
|
||||||
cardKey: ProfileDashboardCardKey;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
|
|
||||||
icon: ComponentType<{ className?: string }>;
|
|
||||||
imageSrc?: string;
|
|
||||||
}) {
|
|
||||||
const Icon = icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
|
||||||
aria-label={`${label} ${value}`}
|
|
||||||
className="platform-profile-stat-card flex min-h-[5.25rem] items-center justify-center gap-2 px-2.5 py-2.5 text-center transition"
|
|
||||||
>
|
|
||||||
<div className="platform-profile-stat-card__icon">
|
|
||||||
{imageSrc ? (
|
|
||||||
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
|
|
||||||
) : (
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 text-left">
|
|
||||||
<div className="platform-profile-stat-card__value whitespace-nowrap text-[16px] font-black leading-none text-[var(--platform-text-strong)]">
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileStatCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center">
|
|
||||||
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
|
|
||||||
<div className="mt-2 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileShortcutButton({
|
|
||||||
label,
|
|
||||||
subLabel,
|
|
||||||
icon,
|
|
||||||
onClick,
|
|
||||||
imageSrc,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
subLabel?: ReactNode;
|
|
||||||
icon: ComponentType<{ className?: string }>;
|
|
||||||
onClick?: (() => void) | null;
|
|
||||||
imageSrc?: string;
|
|
||||||
}) {
|
|
||||||
const Icon = icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick ?? undefined}
|
|
||||||
className="platform-profile-shortcut-button flex min-h-[4.75rem] w-full flex-col items-center justify-center gap-1.5 px-2 py-2.5 text-center transition"
|
|
||||||
>
|
|
||||||
<div className="platform-profile-shortcut-button__icon">
|
|
||||||
{imageSrc ? (
|
|
||||||
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
|
|
||||||
) : (
|
|
||||||
<Icon className="h-[1.125rem] w-[1.125rem]" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[12px] font-semibold text-[var(--platform-text-strong)]">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
{subLabel ? (
|
|
||||||
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[10px] font-medium text-[var(--platform-text-soft)]">
|
|
||||||
{subLabel}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileSettingsRow({
|
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
icon: ComponentType<{ className?: string }>;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
const Icon = icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition"
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 items-center gap-3">
|
|
||||||
<span className="platform-profile-settings-row__icon">
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-[14px] font-semibold text-[var(--platform-text-strong)]">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileLegalSection({
|
|
||||||
onOpenDocument,
|
|
||||||
}: {
|
|
||||||
onOpenDocument: (documentId: LegalDocumentId) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section className="platform-profile-legal-strip" aria-label="法律信息">
|
|
||||||
<div className="platform-profile-legal-strip__links">
|
|
||||||
{LEGAL_DOCUMENTS.map((document, index) => (
|
|
||||||
<button
|
|
||||||
key={document.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenDocument(document.id)}
|
|
||||||
className="platform-profile-legal-strip__link"
|
|
||||||
>
|
|
||||||
{document.title}
|
|
||||||
{index < LEGAL_DOCUMENTS.length - 1 ? (
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="platform-profile-legal-strip__divider"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={ICP_RECORD_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="platform-profile-legal-strip__record"
|
|
||||||
>
|
|
||||||
{ICP_RECORD_NUMBER}
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileReferralUserAvatar({
|
function ProfileReferralUserAvatar({
|
||||||
name,
|
name,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
|||||||
Reference in New Issue
Block a user