Merge remote-tracking branch 'origin/master' into codex/external-generation-worker-scaling

This commit is contained in:
2026-06-12 16:11:12 +08:00
324 changed files with 36177 additions and 12743 deletions

View File

@@ -63,6 +63,415 @@
- 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding`,并确认旧 `/api/creation/<play>/*`、历史 `/api/runtime/<play>/agent/*` 与公开 runtime 路由外部契约不变。 - 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding`,并确认旧 `/api/creation/<play>/*`、历史 `/api/runtime/<play>/agent/*` 与公开 runtime 路由外部契约不变。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-08 PlatformUiKit 弹窗与复制反馈收口
- 背景:前端已有 `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"`,不再为同类确认单独维护壳层和按钮。
- 2026-06-10 追加:推荐页运行态卡片底部的点赞 / 分享 / 改造入口,以及创作中心公开作品卡右上角分享入口统一迁移到 `PlatformIconButton`;这类和 swipe / drag 手势耦合的图标动作必须继续保留业务局部 class 与 `onPointerDown` / `onClick` 里的 `stopPropagation`,只把按钮语义、可访问名称和默认 `type="button"` 收口到共享组件,避免图标动作误触推荐卡切换、整卡打开或残留左滑状态。
- 2026-06-10 追加:标准泥点消耗确认弹窗统一收口到 `src/components/common/PlatformMudPointConfirmDialog.tsx`;该 Module 专门承接“确认消耗泥点 + 消耗 N 泥点”的同形态确认骨架,当前已覆盖 `PuzzleCreationWorkspace.tsx``Match3DCreationWorkspace.tsx``PuzzleResultView.tsx``Match3DResultView.tsx`。后续遇到同形态泥点确认时,业务页只传点数、补充说明和确认回调,不再重复拼接 `UnifiedConfirmDialog` 正文;`RpgCreationRoleAssetStudioModalImpl` 这类节奏和内容结构不同的泥点弹层继续单独评估,留作后续轮次处理。
- 2026-06-10 追加:`RpgCreationRoleAssetStudioModalImpl.tsx` 的角色形象生成 / 动作草稿生成确认也并入 `PlatformMudPointConfirmDialog`;共享组件通过自定义 title 与补充说明承接工坊语义,工坊页不再单独维护 `UnifiedConfirmDialog` 的标准泥点文案骨架。后续同类“确认消耗泥点 + 补充说明”场景继续优先复用该 Module。
- 2026-06-10 追加:平台危险确认统一收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;该 Module 专门承接“确认 / 取消 + 危险主动作”的标准骨架,当前已覆盖 `PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认和 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认。后续删除、覆盖、清空等危险动作优先复用该 Module不再在业务页重复拼接 `UnifiedConfirmDialog``showCancel + confirmTone=\"danger\"` 组合。
- 2026-06-10 追加:平台未保存离开确认统一收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;该 Module 专门承接“继续编辑 + 确认离开”的标准骨架,当前已覆盖 `RpgCreationEntityEditorShared.tsx` 里的关闭未保存修改、生成结果未保存退出和普通结果未保存退出确认。后续同类未保存离开场景优先复用该 Module不再在业务页重复拼接 `UnifiedConfirmDialog``showCancel + cancelLabel=\"继续编辑\"` 组合和重复壳层 class。
- 2026-06-10 追加:平台单按钮已读状态统一收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;该 Module 专门承接“状态提示 + 知道了”的单按钮确认已读语义,当前已覆盖 `BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示。后续同类 status-dialog 场景优先复用该 Module不再在业务页重复拼装 `action={{ label: '知道了', onClick: onClose }}`
- 2026-06-10 追加RPG 首页个人中心里的统计卡、统计骨架、常用功能入口、设置行和法律信息入口统一抽到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;这组纯展示原子以后优先通过 props 接收图片资源、点击回调和展示文案,不再继续塞回 `RpgEntryHomeView` 的账户控制逻辑里。新建 `PlatformProfilePrimitives.test.tsx` 作为组件级护栏,页面级布局与法律入口继续由 `RpgEntryHomeView.recharge.test.tsx` 兜底。
- 2026-06-10 追加RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑统一收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`controller 负责账户动作分流、商业状态派生与相关面板控制,`RpgEntryHomeView` 只保留展示、昵称头像编辑、扫码入口和页面级交互编排,不在页面组件里继续堆叠账户控制分支。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck`
- 2026-06-10 追加RPG 首页个人中心的“玩过 / 可继续”历史弹层统一抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx``RpgEntryHomeView` 不再内联 `SaveArchiveCard``ProfilePlayedWorksModal` 和未连通的 `ProfileSaveArchivesModal`。当前产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此 controller 里的 `ProfilePopupPanel` 也去掉了没有真实入口的 `saveArchives` 分支。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck`
- 2026-06-10 追加:个人中心标准头部弹窗与白底副弹层的共享壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;标准头部弹窗优先复用 `PlatformProfileModalShell`,白底副弹层优先复用 `PlatformProfileSecondaryModalShell`,不再在业务页重复手写 profile overlay、header、title、description、floating close 和关闭策略。昵称修改、账户充值、每日任务、兑换码、泥点账单、“玩过 / 可继续”以及邀请相关弹层已接入这套壳层。
- 2026-06-10 追加RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层统一抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;首页不再内联邀请码规范化、社区二维码卡片和邀请用户头像行,后续 profile 侧同类二级弹层优先按“独立组件 + `PlatformProfileSecondaryModalShell`”继续收口。
- 2026-06-10 追加RPG 首页个人中心的账户充值弹层统一抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;充值 tab、套餐卡片、Native 二维码生成和确认支付入口不再内联在 `RpgEntryHomeView`,后续 profile 侧充值入口优先复用同一个组件。
- 2026-06-10 追加RPG 首页个人中心的泥点账单、每日任务和兑换码弹层统一抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx``src/components/platform-entry/PlatformProfileTaskCenterModal.tsx``src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx``RpgEntryHomeView` 只保留打开条件和数据流,标准 profile 弹层内容以后优先沉到 `platform-entry` 独立组件,不在首页继续堆叠。
- 2026-06-10 追加:个人中心支付结果提示与支付确认遮罩统一抽到 `src/components/common/PlatformStatusDialog.tsx`,扫码面板统一抽到 `src/components/platform-entry/PlatformProfileQrScannerModal.tsx``RpgEntryHomeView` 只保留支付结果 kind 到 `success / loading / cancel / error` 的映射、确认遮罩开关和扫码结果写回,不再内联 profile 状态弹层壳层、二维码摄像头启动或 `BarcodeDetector` 轮询。后续 profile 侧同类“状态图标 + 标题正文 + 可选主动作”弹层优先复用 `PlatformStatusDialog`,扫码类弹层优先复用 `PlatformProfileQrScannerModal`
- 2026-06-10 追加:`PlatformStatusDialog` 支持自定义图标、图标可访问标签以及动作按钮 surface / size / className 透传,用来承接玩法结果页里保留品牌视觉但语义仍是“状态结果弹层”的场景;大鱼吃小鱼结果页的发布失败弹层已迁移到这套组件,业务页不再保留 `UnifiedConfirmDialog + PlatformIconBadge` 的专用组合。
- 2026-06-10 追加:`PlatformStatusDialog` 继续支持 header notice 布局、body content、close button、backdrop / Escape 关闭路径,用来承接“提示 / 规则阻断 / 作品不可用 / 泥点不足”这类带标题栏的状态 notice平台入口的 `draftGenerationPointNotice``workNotFoundRecoveryDialog` 和 RPG 大编辑器里的 `EditorNoticeDialog` 已迁移到这套共享组件,不再各自维护 `UnifiedConfirmDialog` 壳层和关闭策略。
- 2026-06-10 追加:`CustomWorldEntityCatalog``minimum-playable` 规则阻断提示也统一迁到 `PlatformStatusDialog`,不再和删除角色 / 批量删除共用 `UnifiedConfirmDialog` 配置;同日平台入口公开编号搜索把 error 分支从用户摘要 modal 中拆出,未命中结果单独走 `PlatformStatusDialog`,命中用户继续保留 `UnifiedModal + PlatformSubpanel` 信息布局。
- 2026-06-11 追加:`PlatformAsyncStatePanel` 继续从 profile modal 与作品架扩展到 RPG 首页公开分区;`RpgEntryHomeView.tsx` 的移动端排行、发现页寓教于乐 / 默认公开 feed、桌面首页“今日游戏 / 推荐”、桌面发现页寓教于乐 / 默认公开 feed以及“我的创作”分区已统一改成 `loadingState / emptyState / children` 三态 slot。页面级 `platformError` 继续留在状态壳外层,保证错误提示可以和内容并存;`recommend runtime`、分类筛选等含运行态或二级筛选语义的分支暂不硬并入这一轮。
- 2026-06-11 追加:暗色 / 像素 modal 的标准 footer 布局统一抽到 `src/components/common/PlatformDarkModalFooter.tsx`;该组件只负责 dark footer 的分隔线、padding 和常见动作区排布,不持有“取消 / 确认”业务语义。`NpcModals.tsx` 的交易 / 赠礼 / 招募 footer、`SelectionCustomizationModals.tsx``SelectionModal` footer、`RpgAdventurePanelOverlays.tsx` 的 goal panel footer以及 `InventoryItemViews.tsx` 的详情 footer wrapper 已接入sticky 工作台 footer、正文内单 CTA 收尾和 runtime HUD 工具条暂不并入这一抽象。
- 2026-06-11 追加:桌面首页里的轻量可点击扁平行开始统一收口到 `src/components/common/PlatformNavigableListItem.tsx`;目前已覆盖 `RpgEntryHomeView.tsx` 的搜索结果行、桌面“最近作品”、桌面“最近浏览”以及桌面“今日游戏”趋势行。组件只承接 `button + left content + right affordance` 结构、默认 `type="button"``leading / trailing` 插槽,暂不扩成覆盖教培 promo card、分类卡片、世界卡或 runtime 列表项的万能 row primitive。
- 2026-06-11 追加:`PlatformNavigableListItem` 继续扩展到 profile 设置行;`src/components/platform-entry/PlatformProfilePrimitives.tsx``ProfileSettingsRow` 已改成委托共享 `button + leading + trailing` 骨架,继续保留本地 `platform-profile-settings-row` class 承接分隔线、icon 胶囊和字号微调。后续 profile / 账户中心里的同类轻量导航行优先直接复用共享行骨架,不再回退成原生 `<button>` 手写布局。
- 2026-06-11 追加:`PlatformNavigableListItem` 继续扩展到 RPG 首页公开列表里的排行行与分类行;`RpgEntryHomeView.tsx``PlatformRankingItem``PlatformCategoryGameItem` 已改成委托共享 `button + leading + body + trailing` 骨架,同时保留 `platform-ranking-item__*``platform-category-game-item__*` 局部 class 承接封面、metric、badge、摘要和右侧 `试玩 / 进入` affordance。后续首页 / 发现页里同类浅色导航行优先沿“共享骨架 + 本地皮肤 class”推进不再为了这类 row 回退成原生 `<button>` 手写布局。
- 2026-06-11 追加:`PlatformAsyncStatePanel` 继续补齐 RPG 首页分类分支;移动端“发现 -> 分类”、桌面发现页“分类”和桌面首页“作品分类”模块现在都统一委托共享状态壳切换外层 `loading / empty / content`,分类控制条与排序按钮继续留在内容 slot 中。筛选后无结果的“当前筛选下没有作品。”也统一改成内层 `PlatformAsyncStatePanel` 切换,不再在三处 JSX 中各自维护嵌套 ternary。
- 2026-06-11 追加:`PlatformDarkModalFooter` 不只收动作按钮区,也继续覆盖纯内容 footer`CompanionCampModal.tsx` 底部“营地气氛”区域已改成 `layout="content"` + `padding="roomy"` 的共享 footer frame保留原有文案和卡片布局不再单独手写 `border-t border-white/10 px-5 py-4`
- 2026-06-11 追加:`PlatformDarkModalFooter` 继续从标准双按钮 footer 扩到 detail / confirm 收尾;`NpcModals.tsx` 的交易详情 footer 和 `MapModal.tsx` 的场景切换确认 footer 已改成复用同一个 dark footer frame即使只有单个“关闭”按钮也不再手写 `flex justify-end`。这条抽象继续只覆盖 dark / pixel modal 里的底部分隔线与常规动作区排布,不向白底 profile 弹窗 footer、sticky 工作台 footer 或运行态 HUD 工具条扩张。
- 2026-06-11 追加:`PlatformFilterToolbar.tsx` 作为薄结构组件收口 RPG 首页分类工具条;组件只承接“筛选按钮 + tabs + 排序按钮”的排布与 `mobile / desktop` 两种布局差异,不持有筛选状态、空态或排序逻辑。后续只有在同构壳层真的复现时才继续往 `common` 扩覆盖面;如果只是单页内局部重复、接口会越抽越胖,就优先退回文件内 helper。
- 2026-06-11 追加:`SquareImageCropModal.tsx` 的白底弹窗壳层改为复用 `UnifiedModal.tsx`,同时给 `UnifiedModal` 薄补 `titleId``closeIcon` 透传,让裁剪弹窗继续保留自定义 close icon、无 backdrop / Escape 关闭和两列 footer而不把 `PlatformProfileModalShell` 这类带页面语义的壳层倒灌回 `common/`。这条规则适用于 `common` 级工具弹窗:先看 `UnifiedModal` 能不能承接,再决定是否需要新的薄壳。
- 2026-06-11 追加:`CreativeImageInputPanel.tsx` 里参考图预览、主图预览和移除图片确认都继续并回 `UnifiedModal` 体系:两个预览弹窗直接复用 `UnifiedModal`,删除确认直接复用 `UnifiedConfirmDialog`,不再在图片面板里手写三段 `platform-modal-backdrop + platform-modal-shell`。当前没有新增 `PlatformImagePreviewModal`,因为这批差异还只在尺寸与文案层,继续组合已有 modal 原语的 leverage 更高。
- 2026-06-11 追加:`src/components/common/PlatformUtilityInfoModal.tsx` 作为 `UnifiedModal` 之上的薄壳,统一承接 `PlatformReportDialog.tsx``PublishShareModal.tsx` 共同的工具信息弹窗骨架:平台主题 overlay、白底 panel以及 body / footer 间距与标准 footer frame。该壳层不继续向上吸收报告字段列表、分享正文、复制逻辑、渠道按钮或品牌 icon后续 `common` 级工具信息弹窗若只是重复这套白底信息壳,优先复用 `PlatformUtilityInfoModal`,业务正文和 footer 交互继续留在调用方。验证命令:`npx vitest run src/components/common/PlatformUtilityInfoModal.test.tsx src/components/common/PlatformReportDialog.test.tsx src/components/common/PublishShareModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
- 2026-06-11 追加profile 白底副弹层里的摘要头、列表骨架和内容行继续沉到 `PlatformProfileSummaryHeader.tsx``PlatformProfileSkeletonList.tsx``PlatformProfileContentRow.tsx`;这组组件只承接 `kicker + title + badge` 摘要层次、重复 skeleton 行以及 `PlatformSubpanel` 上的 `div / button` 内容行语义,不持有账单金额、任务进度、邀请用户信息、充值商品结构或状态切换逻辑。后续 profile modal 若只是重复这三类白底内容骨架,优先复用这组薄组件,不再把 skeleton、摘要头和 row chrome 写回各自 modal。验证命令`npx vitest run src/components/common/PlatformProfileModalContent.shared.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
- 2026-06-11 追加:`PlatformProfileModalShell` 继续补齐标准 footer 插槽,直接透传 `UnifiedModal.footer``footerClassName``RpgEntryHomeView.tsx` 的昵称修改弹窗已改成标准 profile footer不再把双按钮动作区手写在 body 末尾。后续个人中心里同类“表单内容 + 底部双按钮”弹窗优先走壳层 footer 接法。
- 2026-06-11 追加:`PlatformProfileModalShell` 的标准 footer 接法继续扩展到单 CTA 表单收尾;`PlatformProfileRewardCodeRedeemModal.tsx` 的兑换按钮已迁到壳层 footerbody 只保留输入和反馈消息。`PlatformAsyncStatePanel` 同日继续扩展到 `PlatformAssetPickerGrid``VisualNovelSavePanel.tsx``AccountModal.tsx` 的账号安全三个子区块;其中公共素材网格继续把 `error` banner 放在状态壳外层,保持错误提示可与加载态或内容并存的原语义。
- 2026-06-11 追加:按钮层继续补齐轻量漏网项。`PlatformTagEditor.tsx` 的标签 chip 删除入口已改成紧凑 `PlatformIconButton`,保留透明背景和原 chip 高度;`RpgEntryCharacterSelectView.tsx` 的两处“返回”按钮统一沉到局部 `CharacterSelectBackButton`,底层委托 `PlatformActionButton surface="editorDark"`。同日 `GenerationProgressHero.tsx` 新增 `GenerationHeaderBackButton``CustomWorldGenerationView.tsx``BarkBattleGeneratingView.tsx` 已开始复用这套暖色生成页返回入口骨架;后续同类轻量返回按钮与 chip 删除按钮优先继续沿共享按钮 + 薄包装的方向推进。
- 2026-06-09 追加:通用输入 Composer 的上传参考图、发送和移除参考图已迁移到 `PlatformIconButton`;图标上传仍使用 `asChild="label"` 保留 label + file input 语义,公共组件会自动写入隐藏文本,确保内嵌 file input 继承可访问名称。
- 2026-06-10 追加creation-agent composer 的上传文档 / 上传参考图入口使用 `PlatformIconButton` 默认 `platformIcon`;工作台只保留动态 label、title、busy 状态和 picker 回调,发送按钮继续保留主题色动作布局。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
- 2026-06-10 追加:作品详情顶部返回 / 分享和封面轮播上一张 / 下一张入口使用 `PlatformIconButton variant="platformIcon"`;详情页保留原 `platform-work-detail__*` 局部 class 控制位置和尺寸,点赞、复制三态等专用动作暂不迁移。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`
- 2026-06-09 追加:通用输入 Composer 普通 panel 外壳迁移到 `PlatformSubpanel`,文本域迁移到 `PlatformTextField variant="textarea"`,读图错误迁移到 `PlatformStatusMessage surface="profile"`;浮动胶囊 Composer 保留专用外壳和 CSS 覆盖。
- 2026-06-10 追加:`PlatformStatusMessage` 根节点固定带 `platform-status-message` 类名供业务测试断言公共状态条接入RPG 大编辑器中的场景背景生成、作品封面生成和封面上传错误 / 成功提示先使用 `surface="tinted"` 加局部暗色 class 保留编辑器视觉,后续普通暗色编辑 / 运行面板状态提示统一迁入 `surface="editorDark"`
- 2026-06-10 追加:`PlatformStatusMessage surface="editorDark"` 承接 RPG 暗色面板里的普通错误 / 成功 / 信息 / 警告 / 中性提示;背包故事档案 QA 提示、角色聊天错误提示、营地编组战斗中提示和自定义选择弹窗错误 / 生成中提示已迁移,业务 JSX 不再手写暗色 `border-*-300/15 bg-*-500/10 text-*-50/90` 状态条 chrome。
- 2026-06-10 追加NPC 交易 / 赠礼 / 招募弹窗里的叙事提示使用 `PlatformStatusMessage surface="editorDark"`;弹窗只保留 introText 数据和业务 tone 选择,不再手写暗色提示条边框、底色、圆角、字号和换行 class。
- 2026-06-10 追加creation-agent composer 错误条使用 `PlatformStatusMessage surface="platform"`;工作台只保留错误来源合并和局部外边距 / 圆角,不再手写红色边框、底色和文字 class。验证命令`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-10 追加creative-agent 首页错误提示使用 `PlatformStatusMessage tone="error" surface="platform" size="md"`;首页只保留宽度对齐局部 class 和错误文案,不再手写 danger panel chrome。验证命令`npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-10 追加:大鱼吃小鱼结果页发布校验阻断项使用 `PlatformStatusMessage tone="warning" surface="platform" size="xs"`;结果页只保留阻断项裁剪和文案,不再手写 amber 文本列表。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-09 追加:通用创作图片面板中覆盖在图片或输入区上的更换主图、移除主图、历史入口短标签按钮和提示词参考图上传入口,以及抓大鹅封面编辑中覆盖在封面图上的移除入口,使用 `PlatformIconButton variant="surfaceFloating"`;白底圆形 / 短标签浮动图标动作的 `border-white/80``bg-white/94``backdrop-blur`、hover 和禁用态不再在业务 JSX 中重复拼。
- 2026-06-10 追加:`PlatformIconButton variant="darkMini"` 承接覆盖在缩略图上的暗色小型图标动作;`PlatformUploadPreviewCard` 的 square 右上移除按钮已迁移到该 variant上传预览卡不再手写黑底圆形移除按钮 chrome。
- 2026-06-09 追加:图片编辑面板中的白底胶囊开关统一使用 `src/components/common/PlatformPillSwitch.tsx` 承载 label + `role="switch"` 输入语义、轨道、圆点、白底浮层和禁用态;通用创作图片面板和抓大鹅封面编辑的 `AI重绘` 已先迁移,业务页只保留受控布尔值和状态变更回调。
- 2026-06-09 追加:设置面板、结果页配置和工作台白底配置项里的整行开关统一使用 `src/components/common/PlatformToggleRow.tsx` 承载 label、checkbox、只读状态 pill、可选 icon、可选点击状态行、禁用态和 soft / plain 两类白底 surface视觉小说结果页运行配置 / 玩家可见开关、视觉小说 runtime 设置面板和拼消消创作工作台 AI 生成底图开关已先迁移,业务页只保留字段写回和点击动作。
- 2026-06-09 追加:公开编号搜索结果弹窗关闭按钮使用 `PlatformModalCloseButton variant="platformIcon"`,平台壳不再手写 `platform-icon-button` + 关闭文本。
- 2026-06-10 追加RPG 大编辑器主壳层和紧凑对话壳层的右上角关闭入口使用 `PlatformModalCloseButton variant="platformIcon"`,暗色编辑器保留 `platform-icon-button` 视觉 token但业务 JSX 不再手写关闭按钮 aria、默认 X 图标和禁用态拼接。
- 2026-06-10 追加:`PlatformModalCloseButton variant="editorDark"` 承接 RPG 暗色弹窗中非像素风的圆形 X 关闭入口,根节点固定带 `platform-modal-close-button--editor-dark` 稳定类名;自定义选择弹窗头部关闭按钮已迁移,并补齐 `aria-label`,业务 JSX 不再手写暗色关闭按钮边框、底色、hover 和默认 X 图标。验证命令:`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/SelectionCustomizationModals.test.tsx`
- 2026-06-10 追加:`PlatformModalCloseButton variant="pixel"` 承接 `UnifiedModal variant="pixel"` 头部圆形关闭入口;`UnifiedModal` 只选择 `platformIcon / pixel` 变体并保留 closeDisabled、Backdrop、Escape 和 portal 语义,不再手写 X 图标、aria 和关闭按钮 class。验证命令`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx`
- 2026-06-10 追加:`UnifiedModal` 新增 `closeVariant``closeOnEscape``titleClassName``descriptionClassName`,用于在收口标准平台弹窗壳层时保留个人中心 `profile / profileCompact` 关闭按钮、原有标题层级和“不响应 Escape / backdrop”的交互语义RPG 首页个人中心里的昵称修改、账户充值、每日任务和兑换码弹窗已迁移到 `UnifiedModal`,支付结果 / 支付确认遮罩 / 泥点账单这类头部结构不同的弹窗继续保留专用实现。验证命令:`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
- 2026-06-10 追加:`UnifiedModal` 新增 `showHeader`,用于收口不需要标准头部但仍要保留 dialog 无障碍语义、遮罩和层级控制的轻量弹窗RPG 首页个人中心的支付结果提示与支付确认遮罩已迁移到 `showHeader={false}` 模式,业务页只保留 icon badge、文案与按钮不再手写 backdrop、aria 和白底壳层。个人中心移动端顶栏“扫码”“打开设置”入口统一使用 `PlatformIconButton`,并继续保留 `.platform-profile-header__icon-button` 局部 class 控制位置与主题色。验证命令:`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformIconButton.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
- 2026-06-10 追加RPG 首页发现页分类筛选弹窗和个人中心扫码面板改用 `UnifiedModal` 承接 backdrop、dialog 语义和层级;分类筛选保留本地选项 / 动作布局,扫码面板继续使用 `showHeader={false}` 保留深色自定义头部与摄像头 viewport并显式维持 `closeOnBackdrop={false}``closeOnEscape={false}`。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/common/UnifiedModal.test.tsx`
- 2026-06-10 追加RPG 首页个人中心泥点账单改用 `UnifiedModal showHeader={false}` 承接 `dialog` 语义和遮罩层级,同时保留渐变面板、`PlatformModalCloseButton variant="floating"`、余额 badge 与账单列表布局;账单继续显式维持 `closeOnBackdrop={false}``closeOnEscape={false}`,测试改为直接断言具名 dialog 和关闭后卸载。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "opens wallet ledger modal from narrative coin card|wallet ledger modal shows empty and error states" src/components/common/UnifiedModal.test.tsx`
- 2026-06-10 追加RPG 首页个人中心“玩过作品”面板改用 `UnifiedModal showHeader={false}` 承接 `dialog` 语义和遮罩层级,同时保留 `PLAYED` kicker、总时长 badge、`PlatformModalCloseButton variant="floating"``可继续 / 玩过` 双分区与作品卡布局;存档入口继续留在同一个“玩过”面板内,不再回退成独立 `SAVE ARCHIVE` / `ARCHIVE` 壳层。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile played modal summary and work type use platform pill badges|profile played modal empty state uses platform empty state" src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "authenticated users can open save archives from the profile played panel|profile page keeps save archives inside played stats panel" src/components/common/UnifiedModal.test.tsx`
- 2026-06-10 追加RPG 首页个人中心邀请相关弹层里的 live `community / redeem` 分支改用 `UnifiedModal showHeader={false}` 承接 `dialog` 语义和遮罩层级,同时保留 `PlatformModalCloseButton variant="floatingPlain"`、居中标题、社区二维码卡片、邀请码输入 / 已填写空态和成功 / 失败提示;历史 `invite` 分支没有新的入口,当前只随同一壳层维持现状。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "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" src/components/common/UnifiedModal.test.tsx`
- 2026-06-10 追加RPG 首页个人中心昵称旁的铅笔入口改用 `PlatformIconButton`,继续保留 `.platform-profile-edit-button` 局部尺寸、边框和浅色底样式;昵称编辑入口不再手写原生 `<button>``type``aria-label` 和图标壳。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile nickname modal uses platform text field and submits with Enter" src/components/common/PlatformIconButton.test.tsx`
- 2026-06-09 追加RPG 大编辑器暗色面板内的保存和角色槽动作继续走本地 `ActionButton`,不再混用白底平台 `platform-button` class平台白底动作收口和编辑器暗色动作收口保持两套视觉边界。
- 2026-06-10 追加:`PlatformActionButton surface="editorDark"` 承接 RPG 暗色弹窗 / 运行面板里的普通取消、确认、刷新和编组动作,支持 `size="xxs"``tone="success" | "warning"``tone="accent"` 承接暗色壳层内的琥珀实心 CTA`tone="accentSoft"` 承接依赖局部 accent 变量的柔和强调按钮。角色自定义 footer、自定义世界生成 footer、地图切换确认、营地编组普通动作和角色聊天刷新动作已迁移。暗色可选项卡仍使用 `PlatformDarkOptionCard`,像素风发送 / 强品牌动作继续保留专用布局。验证命令:`npm run test -- src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformActionButton.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CompanionCampModal.test.tsx src/components/MapModal.test.tsx src/components/CharacterChatModal.test.tsx`
- 2026-06-10 追加RPG 首页创作 / 草稿顶栏的钱包快捷入口通过同文件 `TopbarWalletShortcutButton` 复用 `PlatformActionButton tone="accentSoft" shape="pill" size="xs"``PlatformIconBadge`;移动端 / 桌面端继续保留 `.platform-mobile-create-wallet-chip``.platform-desktop-create-wallet-chip``.platform-desktop-search` 兼容 class承接余额截断、桌面顶栏胶囊壳和既有测试锚点点击语义仍统一走 `openRechargeOrRewardCodeModal`。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
- 2026-06-10 追加RPG 大编辑器里的当前角色、可选角色、预设背景和场景连接关系等暗色信息面板通过本地 `EditorInfoPanel` 复用 `PlatformSubpanel surface="dark"`;有右侧动作的面板也只向适配器传 actions不再在业务 JSX 中重复手写暗色面板边框、底色、圆角、标题行和内容间距。验证命令:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "场景编辑器会在场景内展示槽位化多幕配置并保存"`
- 2026-06-10 追加:作品详情底部“作品改造 / 作品编辑”和“启动”使用 `PlatformActionButton surface="platform" shape="pill" size="lg" fullWidth`;详情页保留 `platform-work-detail__remix / start` 局部 class 控制 sticky 底部栏位置、比例和品牌背景。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx`
- 2026-06-10 追加:作品详情点赞按钮使用 `PlatformActionButton tone="accentSoft"`;详情页只保留纵向排布、尺寸和 `--platform-action-accent` 局部变量,不再手写点赞按钮边框、底色、文字和阴影 chrome。验证命令`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
- 2026-06-09 追加:大鱼吃小鱼结果页白底平台动作迁移到 `PlatformActionButton shape="pill" size="xs"`;资产工坊关闭 / 生成正式图、关卡主图 / 待机 / 移动入口和场地背景生成只保留业务回调,深色 hero 返回 / 测试 / 发布按钮继续保留玩法品牌布局。
- 2026-06-10 追加:大鱼吃小鱼结果页 hero 顶部的玩法摘要 chip 使用 `PlatformPillBadge tone="lightOverlay"`,并只保留局部 `bg-white/10` 覆盖hero 只保留 `coreFun / ecologyTheme / levelCount` 文案,不再手写三段白色静态标签。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx -t "renders generated formal previews with accurate status copy"`
- 2026-06-10 追加:反馈页“查看反馈与投诉记录”这类页面内次级文本动作使用 `PlatformActionButton tone="ghost" shape="pill" size="xs"`;反馈页只保留提示回调,不再手写居中、字号、内边距和冷色文本按钮 class。验证命令`npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`
- 2026-06-10 追加:创作中心作品卡积分激励的“领取积分 / 领取中”按钮使用 `PlatformActionButton tone="secondary" size="xxs"`;作品卡保留 `creation-work-card-incentive__button` 局部 class 承接三列布局、移动端跨列、紧凑高度和玻璃底,同时保留点击 / 键盘冒泡拦截,避免触发整卡打开。验证命令:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformActionButton.test.tsx src/index.test.ts`
- 2026-06-09 追加:敲木鱼 fallback 返回、跳一跳结算、拼消消 runtime header / 结算弹窗等白底 HUD 动作使用 `PlatformActionButton`,拼消消 runtime 白底错误条使用 `PlatformStatusMessage surface="platform"`;深色半透明游戏提示和强品牌按钮仍可保留 runtime 专用布局。
- 2026-06-10 追加:运行态短错误 / 成功 / 命中反馈 chip 使用 `PlatformRuntimeStatusToast` 承接圆角、字号、阴影、色值和 `role="alert/status"` 语义;跳一跳、拼图、敲木鱼、方洞和宝贝爱画运行态短 toast 已迁移。玩法专属返回按钮、计分牌、蓄力提示和强品牌主按钮仍留在 runtime 壳层,不把位置和玩法资产耦合进公共 Module。验证命令`npm run test -- src/components/common/PlatformRuntimeStatusToast.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/square-hole-runtime/SquareHoleRuntimeShell.test.tsx src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.test.tsx`
- 2026-06-09 追加:历史图片 / 历史素材 / 可引用素材选择统一使用 `src/components/common/PlatformAssetPickerCard.tsx` 中的 `PlatformAssetPickerCard``PlatformAssetPickerGrid`,由该 Module 承载缩略图、禁用态、选中态、边框、hover、主副文案、`ResolvedAssetImage` 壳层、错误态、读取态、空态和网格布局拼图历史图片弹窗、方洞历史生成、视觉小说历史素材选择器、RPG 大编辑器历史素材弹窗和抓大鹅封面编辑可引用素材网格已先迁移业务页只传素材数组、素材地址、文案、可访问名称、surface、选中判断和选择回调。RPG 大编辑器等暗色弹窗使用 `surface="editorDark"`,不混用白底平台卡片视觉;场景横图通过 `imageShellClassName` 保留 16:9。
- 2026-06-09 追加:平台白底圆角输入框和文本域统一使用 `src/components/common/PlatformTextField.tsx` 承载 input / textarea 语义、基础边框、背景、内边距、字号 / 行高、密度和禁用态;同组下拉框使用 `PlatformSelectField` 复用同一输入 chrome。抓大鹅结果页作品名称 / 描述、封面描述、素材名称、批量新增 / 批量重生成物品名称,方洞结果页主信息表单和形状 / 洞口选项字段,拼图结果页作品信息 / 关卡名称 / 智能修订输入,敲木鱼结果页作品标题 / 简介敲木鱼创作工作台功德词条输入creative-agent 模板确认调整弹层关卡数输入,拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题,以及视觉小说结果页音乐生成、作品信息、开场、运行配置、角色、场景、阶段和世界观普通文本 / 下拉字段已先迁移,业务页只保留受控值、事件、可访问名称、占位符、选项和局部布局 class。同一面板内的主图上传和提示词参考图上传必须使用不同可访问名称避免多个同名“上传参考图”入口让测试和读屏语义混淆拼图关卡编辑中的描述参考图入口使用“上传描述参考图”。
- 2026-06-09 追加:通用创作图片输入面板的提示词文本域也使用 `PlatformTextField variant="textarea" density="roomy"`;图片面板只通过局部 class 保留高度、`pb-14` 和浮动参考图上传按钮避让,不再自己维护白底 textarea 边框、背景、字号和禁用态。
- 2026-06-09 追加:`PlatformTextField` / `PlatformSelectField``tone="warm" | "rose" | "emerald"` 统一承接平台表单焦点色;视觉小说创作工作台、统一抓大鹅创作工作台、汪汪声浪轻配置编辑器和宝贝识物工作台普通输入 / 文本域 / 下拉框已先迁移,玩法调性焦点色通过 tone 表达,不在业务 JSX 中重复拼 `focus:border-* focus:ring-*`
- 2026-06-10 追加:`PlatformTextField` / `PlatformSelectField` 支持 `surface="editorDark"``tone="sky"`,承接 RPG 暗色弹窗 / 运行面板里的普通输入框、文本域、下拉框、禁用态、密度、字号和焦点色;自定义选择弹窗角色名字 / 背景补充 / 生成模式 / 世界描述和角色聊天草稿已迁移,业务 JSX 不再手写暗色 `border-white/10 bg-black/30 px-4 py-3``focus:border-*` 输入 chrome。验证命令`npm run test -- src/components/common/PlatformTextField.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CharacterChatModal.test.tsx`
- 2026-06-10 追加:`PlatformTagEditor` 内部新增标签输入框也使用 `PlatformTextField density="compact" size="xs"`标签编辑器只保留新增状态、解析、Enter / Escape 行为和按钮组合,不再手写白底 input chrome。
- 2026-06-10 追加:认证图形验证码答案输入使用 `PlatformTextField density="compact"`;验证码组件只保留 challenge 展示、答案受控值和变更回调,不再手写 `platform-input` 输入框 chrome。
- 2026-06-10 追加:认证入口的短信 / 密码登录、重置密码、绑定手机号、邀请码和账号安全表单字段使用 `PlatformTextField surface="platform"``PlatformFieldLabel variant="form"`;认证业务组件只保留受控值、登录 / 绑定流程、原生 input 属性和校验提示,字段可访问名称继续由外层原生 `label` 承接,不再手写 `platform-input` 或表单标题 class。
- 2026-06-10 追加:个人中心兑换码和邀请兑换输入使用 `PlatformTextField surface="platform"`;业务组件只保留兑换 / 邀请码提交、归一化、大写展示、Enter 提交和原生可访问名称,不再手写 `platform-profile-input` 或白底 input chrome。
- 2026-06-10 追加:个人中心昵称弹窗输入框使用 `PlatformTextField surface="editorDark" size="lg" density="roomy"`;业务组件保留原生 `label` / sr-only “新昵称”、`autoFocus``maxLength`、Enter 提交、昵称校验和保存流程,不再手写暗色 input chrome。
- 2026-06-10 追加:平台反馈页问题描述和联系电话字段使用 `PlatformTextField surface="platform"`,标题使用 `PlatformFieldLabel variant="form"`;反馈页保留外层原生 label、受控值、长度限制、透明嵌入式局部 class 和提交校验,不再手写 textarea / input / 字段标题 chrome。验证命令`npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- 2026-06-09 追加:平台字段标签统一使用 `src/components/common/PlatformFieldLabel.tsx` 承载 `field``section``form``pill``accentPill` 五类字段标题视觉;视觉小说结果页、汪汪声浪轻配置编辑器和宝贝识物工作台已先迁移,业务页只保留字段文案和必要局部布局 class不再重复拼普通字段名、分区标题、表单标题、普通胶囊和强调胶囊 class。
- 2026-06-10 追加:通用创作图片输入面板的主图标题和提示词标题使用 `PlatformFieldLabel variant="form"`;提示词字段保留外层原生 `label htmlFor`,业务组件只保留字段文案、布局和上传 / 生成交互,不再手写 `mb-2 block text-sm font-black` 标题 class。
- 2026-06-10 追加:个人中心存档 / 玩过弹窗里的简单空态使用 `PlatformEmptyState surface="subpanel" size="inline"`,玩过弹窗的“可继续 / 玩过”分区标题使用 `PlatformFieldLabel variant="section"`,已玩作品白底按钮卡使用 `PlatformSubpanel as="button" surface="flat" radius="sm" padding="md" interactive``SaveArchiveCard` 因含图片遮罩和加载态暂不并入本轮。
- 2026-06-10 追加creative-agent 首页抽屉无创作记录使用 `PlatformEmptyState surface="subpanel" size="inline"`;抽屉只保留历史记录分组和点击行为,不再手写 bordered empty chrome。验证命令`npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformEmptyState.test.tsx`
- 2026-06-10 追加:平台入口壳纯 Suspense fallback 使用 `PlatformSubpanel radius="sm" padding="none"` 承接原 `platform-subpanel` 外壳;带恢复动作、错误语义或运行态遮罩的提示面板不和纯加载 fallback 同批迁移。
- 2026-06-10 追加:平台入口作品详情读取 / 错误提示、Agent 工作区恢复提示和生成结果恢复面板也迁移到 `PlatformSubpanel`;普通提示使用 `radius="sm" padding="none"`,带恢复动作的 `CreationResultRecoveryPanel` 使用 `radius="xl" padding="none"`,玩法 runtime overlay 继续保留专用层级语义。验证命令:`npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`
- 2026-06-10 追加RPG runtime 主阶段路由里的平台首页、角色选择和冒险面板懒加载提示使用 `PlatformSubpanel radius="sm" padding="none"`;路由器只保留 Suspense 分流和提示文案,运行态 HUD / overlay 不并入该普通提示面板规则。验证命令:`npm run test -- src/components/rpg-runtime-shell/RpgRuntimeStageRouter.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 追加:个人中心钱包账单弹窗的“暂无账单记录”使用 `PlatformEmptyState surface="subpanel" size="inline"`,账单行使用 `PlatformSubpanel as="div" surface="flat" radius="xs" padding="none"`;业务 JSX 只保留来源、时间、收支色值、余额右对齐和局部间距 / 阴影。
- 2026-06-10 追加:个人中心邀请弹窗里的社区二维码卡、邀请码展示卡、成功邀请容器和邀请用户行使用 `PlatformSubpanel`,简单空态使用 `PlatformEmptyState`,小标题使用 `PlatformFieldLabel variant="section"`外层弹窗、query 自动打开、复制邀请和提交邀请码状态机不随 UI chrome 收口改动。
- 2026-06-10 追加:个人中心邀请弹窗里的邀请奖励说明使用 `PlatformStatusMessage tone="warning" surface="profile" size="md"`;弹窗只保留奖励文案和两行排版,不再手写 amber 提示块。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut shows reward subtitle and invited users"`
- 2026-06-10 追加:个人中心任务中心任务条目使用 `PlatformSubpanel radius="sm" padding="md"` 承接原 `platform-subpanel` 外壳;业务组件只保留任务标题、进度、奖励、状态和领取按钮逻辑。
- 2026-06-10 追加:个人中心充值弹窗微信 Native 支付二维码确认面板使用 `PlatformSubpanel radius="sm" padding="md"`;业务组件只保留二维码生成、扫码展示和确认支付按钮流程。
- 2026-06-10 追加:个人中心充值弹窗商品整卡按钮使用 `PlatformSubpanel as="button" surface="platform" radius="sm" padding="none" interactive`商品标题、金额、角标、购买中态和购买回调留在业务组件按钮壳、hover、focus、默认 type 与 disabled chrome 归公共组件。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal trusts per-product first bonus display after points recharge"``npm run test -- src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 追加:个人中心充值商品卡里的“购买 / 处理中”胶囊暂不抽共享组件;该胶囊位于 `PlatformSubpanel as="button"` 内部,直接复用 `PlatformActionButton` 会形成嵌套交互,当前也还没有第二个同形态的非交互 action chip 证明需要单独沉淀共享展示基元。
- 2026-06-09 追加:抓大鹅结果页作品信息、发布封面和物品素材详情中的 section 字段标题迁移到 `PlatformFieldLabel variant="section"`;业务页不再重复拼 `text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]`
- 2026-06-09 追加:方洞结果页主信息、形状选项、洞口选项和历史生成标题迁移到 `PlatformFieldLabel variant="section"`;业务页只保留字段文案、图标和按钮布局,不再重复拼 section 标题 class。
- 2026-06-09 追加:拼图结果页关卡详情的“关卡名称”和发布弹窗的“发布检查 / 封面关卡”标题迁移到 `PlatformFieldLabel variant="section"`;业务页保留 label 关联和弹窗布局,不再重复拼 section 标题 class。
- 2026-06-09 追加:拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题、大鱼素材弹窗 prompt 和 RPG 发布弹窗发布检查 / 封面设置迁移到 `PlatformFieldLabel variant="section"`;业务组件内不再直接出现 `text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]` section 标题 class后续同类标题只从公共 Module 扩展。
- 2026-06-09 追加:平台白底分段 Tab / 二选一统一使用 `src/components/common/PlatformSegmentedTabs.tsx` 承载选项、当前 id、变更回调、响应式列数、尺寸、圆角、surface、截断标签、禁用态和 `aria-pressed`;拼图结果页、抓大鹅结果页、抓大鹅素材配置、视觉小说结果页和 creative-agent 模板确认弹窗已先迁移,业务页不再重复拼 `grid + border + bg-white/62 + button aria-pressed`
- 2026-06-09 追加:`PlatformSegmentedTabs` 支持 `columns="four"``size="choice"``tone="warm" | "rose"``surface="transparent"``frame="bare"`,用于承接创作 / 结果页里的四选一配置项;抓大鹅创作工作台和结果页难度选择已迁移,业务页只保留难度选项、当前值和派生回调。
- 2026-06-09 追加:`PlatformSegmentedTabs` 支持 `columns="one"``size="tab"``tone="underline"``semantics="tabs"`,用于承接认证入口短信 / 密码登录切换的真实 Tab 语义;认证页不再维护本地 `LoginTabButton``role="tab"``aria-selected` 和下划线选中态。登录入口不可用的白底提示也迁移到 `PlatformSubpanel`
- 2026-06-09 追加:平台结果页统计小卡和轻量状态 chip 统一使用 `src/components/common/PlatformStatGrid.tsx` 承载 `items`、响应式列数、密度、surface、对齐和 label/value 顺序;拼消消结果页素材摘要、方洞结果页封面状态 chip 和抓大鹅结果页难度摘要已迁移,业务页不再重复拼统计卡 `grid + rounded + bg-white/* + text-xl/text-xs`
- 2026-06-09 追加:平台单个胶囊状态 / 标签 chip 统一使用 `src/components/common/PlatformPillBadge.tsx` 承载 tone、尺寸、图标、圆角、边框、底色和字号宝贝识物结果页发布状态、主题标签与占位资源 overlay宝贝识物 / 拼图 / 抓大鹅 / 视觉小说工作台 BETA chip、汪汪声浪轻配置 chip、汪汪声浪结果页草稿 chip、汪汪声浪预览 VS chip、敲木鱼结果页飘字 chip、creative-agent 过程计数 / 条目 meta chip、通用音频输入面板限制标签、抓大鹅 / RPG / 拼图 / 方洞结果页自动保存状态、抓大鹅结果页当前难度 badge、拼图结果页关卡生成中 overlay / 列表 badge、大鱼吃小鱼结果页终局 / 关卡元信息 / 发布校验成功 badge、汪汪声浪生成页和通用生成页右上状态 badge、RPG 开发资产诊断数量 / 加载状态 badge、RPG 发布弹窗封面来源 badge、账号弹窗主题状态 / 会话数量 / 设备状态 badge、创作类型弹层锁定 badge、拼图图库详情页题材标签、自定义世界作品卡二级 badge 和生成失败 chip 已先迁移,业务页不再重复拼 `rounded-full border bg-* text-* px-* py-*`。多项数值 / 标签摘要仍归 `PlatformStatGrid`,可交互标签编辑仍归 `PlatformTagEditor`
- 2026-06-09 追加:`PlatformPillBadge` 支持 `profile` / `profileAccent` 个人中心玫瑰色 chip tone泥点账单余额、玩过总时长和玩过作品类型 chip 已迁移,个人中心后续轻量状态 / 分类胶囊不再在业务 JSX 中重复拼 rose / zinc 胶囊 class。
- 2026-06-10 追加:`PlatformPillBadge` 支持 `neutralSolid` 实心中性 tone承接无强调的只读状态胶囊`PlatformToggleRow mode="status"` 的开启 / 关闭状态已迁移到 `platformPillBadgeModel`,整行开关不再手写中性 pill class。
- 2026-06-10 追加:`PlatformPillBadge` 支持 `lightOverlay` 浅色叠层 tone承接主动作按钮内部的泥点消耗等小胶囊通用创作图片面板和抓大鹅创作工作台提交按钮内的消耗标签已迁移业务 JSX 不再手写 `rounded-full bg-white/24 px-2 py-0.5`
- 2026-06-10 追加:`PlatformPillBadge` 支持 `size="xxs"` 承接密集目录元信息 chip自定义世界实体目录的新生成、生成中进度、开局 CG 消耗 / 时长 / 已生成、批量删除已选数量和可扮演角色元信息 chip 已迁移,实体目录不再手写 `platform-pill platform-pill--* px-2.5 py-1 text-[10px]`
- 2026-06-10 追加creative-agent 工作台顶部阶段状态 chip 迁移到 `PlatformPillBadge tone="cool" size="xs"`;工作台只保留阶段枚举到文案的映射,不再手写 `platform-pill platform-pill--cool` 外观。
- 2026-06-10 追加RPG 首页公开作品卡标签、趋势卡标签、公开作品搜索结果类型、充值商品角标、移动端创建入口、桌面发现 hero / 今日 / 最近作品 / 最近浏览 chip 迁移到 `PlatformPillBadge`,首页不再手写 `platform-pill platform-pill--neutral / warm / cool`
- 2026-06-10 追加RPG 世界详情页的发布状态、主题、作者、发布时间 / 可见性和展示标签等静态元信息 chip 迁移到 `PlatformPillBadge`;作品号复制和分享入口仍保留 `CopyCodeButton` / `CopyFeedbackButton` 管复制状态。
- 2026-06-10 追加:`CopyFeedbackButton` 支持 `actionAppearance="pill"``CopyCodeButton` 透传同一入口,并复用 `platformPillBadgeModel.ts``getPlatformPillBadgeClassName` 视觉 chrome可点击复制 / 分享胶囊 chip 不再在业务 JSX 中手写 `platform-pill`RPG 世界详情作品号复制 / 分享入口和抓大鹅批量新增 / 重生成物品名称预览已迁移。
- 2026-06-10 追加:平台作品详情页主题标签使用 `PlatformPillBadge tone="neutralSolid" size="sm"`,作品号复制按钮使用 `CopyCodeButton actionAppearance="pill" actionPillTone="neutralSolid" actionPillSize="sm"`;详情页只保留标签映射、作品号复制状态和顶部外边距,不再手写 `platform-work-detail__chip / code` 基础 chrome。验证命令`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyCodeButton.test.tsx`
- 2026-06-10 追加:平台作品详情页分享复制反馈使用 `PlatformStatusMessage surface="platform"`,按 `shareState` 映射 `success / error`;详情页保留 `useCopyFeedback` 状态机和文案,不再让失败态复用成功 toast chrome。验证命令`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-10 追加:平台错误弹窗和生成完成弹窗的“字段展示 + 复制整段报告”能力统一收口到 `src/components/common/PlatformReportDialog.tsx``PlatformErrorDialog``PlatformTaskCompletionDialog` 只保留标题、字段语义和错误黑名单过滤,不再各自组合 `UnifiedModal``PlatformInfoBlock``CopyFeedbackButton``useCopyFeedback`。验证命令:`npm run test -- src/components/common/PlatformReportDialog.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformTaskCompletionDialog.test.tsx`
- 2026-06-10 追加:`CopyFeedbackButton` 支持 `actionShape`,用于共享复制状态按钮直接对齐 `PlatformActionButton` 的圆角外观;拼图广场详情页 hero 的分享按钮已使用 `actionSurface="editorDark" actionShape="pill"`,修改作品 / 进入第 1 关动作使用 `PlatformActionButton`,返回和封面轮播前后按钮使用 `PlatformIconButton darkMini`。验证命令:`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
- 2026-06-10 追加creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`,但继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏和抽屉品牌视觉;收口按钮语义时不强行同时抹平定制视觉。验证命令:`npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx`
- 2026-06-10 追加:像 `creative-agent-drawer__history-item` 这种纯文本轻量列表行,当前不为了单点场景单独新建共享组件;现阶段优先沿用 `PlatformActionButton` 承接动作行、`PlatformSubpanel as="button" interactive` 承接有壳列表行,等出现更多同构透明列表行再评估独立 row primitive。
- 2026-06-10 追加:绑定手机号页左侧“当前登录身份”提示块迁移到 `PlatformSubpanel radius="sm" padding="md"`;认证页只保留身份文案和绑定流程,不再手写 `platform-subpanel` 信息块壳。验证命令:`npm run test -- src/components/auth/BindPhoneScreen.test.tsx`
- 2026-06-10 追加:大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton darkMini`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布状态和提交语义,不再手写 hero 顶栏按钮壳。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx`
- 2026-06-10 追加:`PlatformPillBadge` 支持 `darkSoft` / `darkNeutral` / `darkSky` / `darkEmerald` / `darkAmber` / `darkRose` 暗色 tone用于 RPG 暗色弹窗和角色详情里的纯展示 chip角色身份 / 等级、技能列表出手方式、技能详情方式 / 风格 / 状态标签、地图节点方向标签、地图场景切换方向标签和营地编组状态数值已迁移。暗色动作按钮、runtime HUD、属性加成动态 pill 和按钮内部消耗 chip 暂不直接套静态 badge。
- 2026-06-10 追加:背景故事已解锁 / 需好感状态和好感等级 badge 也使用 `PlatformPillBadge``dark*` tone好感进度时间轴刻度、runtime HUD 和带点击卡片视觉的标签仍保留专用布局。
- 2026-06-10 追加RPG 角色资产工作室动作列表的生成中 / 已生成 / 待生成状态 chip 直接使用 `PlatformPillBadge``darkAmber` / `darkEmerald` / `darkNeutral` tone父弹窗不再维护本地 `StatusBadge` 浅封装,动作生成按钮仍保留工作室专用暗色按钮布局。
- 2026-06-10 追加NPC 交易物品数量、赠礼好感增量和背包工坊材料需求状态使用 `PlatformPillBadge``dark*` tone这些只是纯展示 chip交易 / 赠礼列表按钮和工坊锻造 / 合成动作按钮继续保留各自交互布局。
- 2026-06-10 追加RPG 角色编辑器技能列表里的动作已生成 / 待生成动作状态直接使用 `PlatformPillBadge``darkEmerald` / `darkNeutral` tone本地 `StatusBadge` 浅封装删除,技能编辑按钮卡片仍保留原有点击布局。
- 2026-06-10 追加RPG 角色编辑器两处重复的已应用主图 / 已应用动作 chip 合并为局部 `RoleAssetAppliedBadges`,内部复用 `PlatformPillBadge darkEmerald / darkAmber`;场景角色选择列表的选择 / 已选中和地标连接列表的当前连接也使用 `PlatformPillBadge dark*`,但外层按钮卡片仍保留原交互语义。
- 2026-06-10 追加RPG 作品封面来源状态使用 `PlatformPillBadge darkNeutral`,角色开局物品标签合并为局部 `RoleInitialItemTagBadges` 并复用 `PlatformPillBadge darkNeutral`;物品编辑弹窗和开局物品列表不再重复维护标签 chip class。
- 2026-06-10 追加RPG 世界地图节点中的当前状态使用 `PlatformPillBadge tone="muted"` 复用平台白底柔和 badge chrome地图节点位置、连线和整体卡片仍保留地图专用布局。
- 2026-06-10 追加:媒体 / 舞台预览上的非交互悬浮短标签使用 `src/components/common/PlatformOverlayBadge.tsx`,复合控件内部的紧凑槽位编号使用 `src/components/common/PlatformSlotBadge.tsx`RPG 场景幕预览左上幕标签和每幕角色槽位“主 / 2 / 3”已迁移。普通状态 chip 继续使用 `PlatformPillBadge`,外层按钮卡片、人物舞台位置和运行态 HUD 不迁入这两个小 Module。
- 2026-06-10 追加:拼图结果页智能修订条的白底图标圆槽使用 `PlatformIconBadge tone="soft" size="sm"`,外层编辑条使用 `PlatformSubpanel radius="lg"`;结果页只保留提交、禁用和错误提示语义,不再手写 `platform-subpanel rounded-[1.35rem] p-3 sm:p-4``hidden h-9 w-9 rounded-full bg-white/72`
- 2026-06-10 追加:拼图结果页关卡卡片外壳使用 `PlatformSubpanel radius="lg" padding="none"`,关卡列表只保留图片、生成中状态、标题打开和删除动作,不再手写 `platform-subpanel overflow-hidden rounded-[1.35rem] p-0`
- 2026-06-10 追加:`PlatformOverlayBadge` 支持 `tone="muted"``size="compact"``offset="tight"`,用于素材缩略图右上角“占位图”等紧凑非交互浮层;宝贝识物结果页占位资源标记已从绝对定位的 `PlatformPillBadge` 迁移到 overlay badge。
- 2026-06-10 追加:`PlatformSlotBadge` 支持 `tone="soft"``size="md"`,用于 creative-agent 阶段时间线的白底柔和步骤圆点;阶段卡片本体与 active / done / idle 语义仍保留在 `CreativeAgentStageTimeline`
- 2026-06-10 追加:物品格、奖励格等缩略图右下角数量使用 `src/components/common/PlatformQuantityBadge.tsx`;背包物品格和 RPG 冒险面板 / 覆盖层奖励物品数量已迁移。该 Module 只承接数量角标 chrome物品按钮、稀有度边框、选中态和详情弹窗仍归业务 Module。
- 2026-06-10 追加RPG 冒险面板和覆盖层里的任务目标状态、任务日志状态、当前幕、剩余交谈等暗色纯展示 chip 使用 `PlatformPillBadge dark*`;任务 presentation / 日志状态只返回语义 tone不再直接返回整段 `border / bg / text` class。运行态动作按钮、任务面板打开按钮和带 hover / click 语义的胶囊仍保留专用布局。任务日志状态补充验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformPillBadge.test.tsx -t "quest offer accept button|supports dark RPG badge tones"`
- 2026-06-10 追加RPG 角色面板里的标签数、适配倍数、性别和装备稀有度等暗色纯展示 chip 使用 `PlatformPillBadge darkNeutral / darkEmerald / darkAmber`;角色面板只保留标签数和 multiplier 计算,不再手写这些胶囊 chrome。
- 2026-06-10 追加RPG 首页作品卡里的发布状态、元信息、主标签,以及存档卡右上恢复 / 最近游玩时间等暗色静态 chip 使用 `PlatformPillBadge dark*`;作品卡 / 存档卡只保留可点击卡片、删除动作、进入 / 继续创作箭头和业务文案。
- 2026-06-10 追加:自定义世界实体目录里的基础设定词条标签使用 `PlatformPillBadge darkSoft`;目录页只保留词条解析和空值展示逻辑,不再手写白字暗底 tag chrome。
- 2026-06-10 追加RPG 实体编辑器基本设定里的拆分标签也使用 `PlatformPillBadge darkSoft`;编辑器只保留字段草稿、文本解析和保存逻辑,不再手写暗色静态 tag chrome。
- 2026-06-10 追加:`PlatformSubpanel` 支持 `surface="dark"``radius="xs"``padding="xs"`,用于 RPG 暗色编辑器 / 运行态里的非交互小信息卡;任务目标、区域、进度、描述、角色维度和角色形象状态已先迁移。暗色 HUD、动作按钮、可点击卡片和强玩法品牌面板继续保留业务布局。
- 2026-06-10 追加:`PlatformSubpanel` 支持 `surface="darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`,用于 RPG 暗色编辑器 / 运行态里带业务色强调的结构化信息面板;实体详情私聊提示、队友收束、玩家等级进度、角色面板等级 / 收束状态、任务奖励好感度 / 货币 / 经验数值卡、RPG 大编辑器上传封面中提示、地图场景切换目标场景面板和 `CharacterInfoShared.MultiplierContributionList` 状态标签外壳已迁移。地图场景切换当前 / 前往摘要、营地编组分区、同行者卡和营地气氛小卡走 `surface="dark"` 非强调信息卡。后续同类 sky / emerald / amber / rose 暗色信息壳不再手写 `border-*-400/18 bg-*-500/8`,普通暗色信息卡不再手写 `border-white/* bg-black/*`
- 2026-06-10 追加:自定义选择弹窗当前角色信息块使用 `PlatformSubpanel surface="dark"`;弹窗只保留角色标签文案,不再手写 `rounded-2xl border border-white/10 bg-black/20 px-4 py-3` 暗色纯展示块。验证命令:`npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 追加RPG 队伍面板和实体详情弹窗里的构筑标签效果详情统一由 `CharacterInfoShared.BuildContributionDetailPanel` 承接;标签概览、属性加成明细和无明细提示组合 `PlatformSubpanel surface="dark"`,业务弹窗只保留选中状态和属性 rows不再复制同一段标签效果暗色面板 JSX。
- 2026-06-10 追加:`CharacterInfoShared.CharacterSkillsList` 的空态使用 `PlatformEmptyState surface="editorDark"`,可点击和只读技能卡使用 `PlatformSubpanel surface="dark"`;角色信息共享模块只保留技能 render id、选择回调、数值字段和标签展示语义不再手写技能空态 / 技能卡暗色外壳。验证命令:`npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "CharacterSkillsList|supports dark compact subpanel cards"`
- 2026-06-10 验证补充:共享构筑状态标签外壳收口到 `PlatformSubpanel surface="darkSky"` 后,补跑 `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 追加RPG 实体详情弹窗的物品空态使用 `PlatformEmptyState surface="editorDark"`,技能预览 fallback、技能数值卡、技能说明和附带状态标签区使用 `PlatformSubpanel surface="dark"`;实体详情只保留技能 / 物品数据和业务文案,不再手写这些暗色小卡 chrome。
- 2026-06-10 追加RPG 实体详情弹窗最近回响中的后果、编年、载体和场景残留纯展示卡使用 `PlatformSubpanel surface="dark"`;实体详情只保留 story memory / 场景 residue 数据映射,队友收束等强调态继续保留业务语义样式。验证命令:`npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "最近回响|supports dark compact subpanel cards"`
- 2026-06-10 追加RPG 实体详情弹窗本地 `Section` 适配到 `PlatformSubpanel surface="dark"`;立绘、关系、私聊、最近回响、属性、技能和物品等主分区只保留标题与内容插槽,不再由业务组件维护 `rounded-2xl border border-white/8 bg-black/20 p-4` 外壳。验证命令:`npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "主分区|supports dark compact subpanel cards"`
- 2026-06-10 追加RPG 冒险统计弹窗的总览和统计卡使用 `PlatformSubpanel surface="dark"`;统计弹窗只保留统计字段、图标和总览文案,设置弹窗里的 range input、保存退出按钮和入口按钮继续保留运行态专用交互布局。验证命令`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "adventure statistics panel|supports dark compact subpanel cards"`
- 2026-06-10 追加RPG 覆盖层里的任务完成领奖提示、任务奖励缓存、战斗结束提示、战利品缓存和奖励物品详情描述 / 效果 / 标签使用 `PlatformSubpanel surface="dark"`,战斗结算敌人名使用 `PlatformPillBadge darkEmerald`;覆盖层只保留奖励数据、物品选择和弹窗层级语义,不再手写奖励缓存暗色面板和敌人名胶囊 chrome。验证命令`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx -t "quest offer accept button|quest completion notice|battle reward modal|supports dark compact subpanel cards|supports dark RPG badge tones"`
- 2026-06-10 追加RPG 覆盖层里的任务摘要卡和任务奖励条使用 `PlatformSubpanel surface="dark"`,奖励条内物品数量使用 `PlatformQuantityBadge`;覆盖层只保留任务文案、奖励数据和物品选择语义,不再手写任务摘要 / 奖励条暗色外壳或数量角标 chrome。验证命令`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformQuantityBadge.test.tsx -t "quest reward strip|supports dark compact subpanel cards|renders a dark bottom-right quantity badge"`
- 2026-06-10 追加RPG 覆盖层里的任务奖励好感度、货币和经验数值卡使用 `PlatformSubpanel surface="darkRose" | "darkAmber" | "darkSky"`;覆盖层不再手写三套 `rounded-xl border bg-* px-3 py-2.5` 数值卡 chrome也不再通过局部 class 覆盖 tint 调性。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 追加RPG 角色详情弹窗的装备格、背包格、旅程原因 / 目标、背景和性格小卡使用 `PlatformSubpanel surface="dark"`,候选人和性别静态 badge 使用 `PlatformPillBadge dark*` tone角色详情只保留资料、属性、技能和动画展示语义立绘框与属性网格暂保留原布局。验证命令`npm run test -- src/components/CharacterDetailModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- 2026-06-10 追加RPG 角色面板详情里的个人线阶段、背景故事、性格纯展示块和装备行使用 `PlatformSubpanel surface="dark"`;角色面板只保留选中成员、个人线状态、展示文本和装备字段映射,像素外层面板与动作入口继续保留业务布局。验证命令:`npm run test -- src/components/CharacterPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- 2026-06-10 追加:好感状态卡的等级摘要和好感进度外壳使用 `PlatformSubpanel surface="dark"`;好感卡只保留等级推导、进度刻度和文案,不再手写 `rounded-xl border border-white/8 bg-black/20 px-* py-*` 暗色面板 chrome。验证命令`npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/AffinityStatusCard.test.tsx`
- 2026-06-10 追加:背景故事公开印象、已解锁章节和锁定章节外壳使用 `PlatformSubpanel surface="dark"`,无背景线索空档案使用 `PlatformEmptyState surface="editorDark"`;背景档案只保留章节状态、好感阈值和故事文案,不再手写这些暗色小卡 / 空态 chrome。验证命令`npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/BackstoryArchive.test.tsx`
- 2026-06-10 追加NPC 交易弹窗的数量 stepper 外壳、库存计数条、详情容器和总价卡使用 `PlatformSubpanel surface="dark"`;交易弹窗只保留交易数量、库存、价格和禁用原因语义,交易物品 / 礼物 / 招募可选列表按钮改由 `PlatformDarkOptionCard` 承接暗色 selected / idle / hover chrome。验证命令`npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "NPC 交易静态信息卡|supports dark compact subpanel cards"`
- 2026-06-10 追加:背包文书、故事档案和工坊分区外壳,以及文书按钮、故事档案条目和工坊配方卡使用 `PlatformSubpanel surface="dark"`;工坊材料需求状态使用 `PlatformPillBadge dark*` tone故事档案 QA 提示使用 `PlatformStatusMessage surface="editorDark"`。锻造 / 合成动作按钮继续保留业务交互布局。验证命令:`npm run test -- src/components/InventoryPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx -t "背包文书|背包工坊|supports dark compact subpanel cards|supports editor dark surface"`
- 2026-06-10 追加NPC 交易详情里的装备位、即时使用和标签属性格使用 `PlatformSubpanel surface="dark" padding="row"`,使用效果提示使用 `PlatformStatusMessage surface="editorDark"`;物品详情弹窗只保留物品属性、效果和标签计算,不再手写 `rounded-lg border border-white/8 bg-black/20 px-3 py-2` 或 emerald 提示条 chrome。
- 2026-06-10 追加:新增 `PlatformDarkOptionCard` 承接 RPG 暗色弹窗 / 面板中的可选项按钮卡 selected / idle / hover / disabled chromeNPC 交易模式、交易物品行、赠礼候选、招募替换候选、角色素材工作室动作预览格和营地编组替换位按钮已迁移。业务组件只保留选中判断、tone、点击回调和卡片内容不再手写 `rounded-* border px-3 py-*``border-*-400/* bg-*-500/10``border-white/* bg-black/20 hover:border-white/15`
- 2026-06-10 追加:角色聊天弹窗的状态 / 总结卡使用 `PlatformSubpanel surface="dark"`,空聊天记录使用 `PlatformEmptyState surface="editorDark"`,建议回复按钮使用 `PlatformDarkOptionCard tone="sky"`;弹窗只保留角色状态、聊天记录和建议语义,不再手写这些暗色信息卡、空态或建议按钮 chrome。验证命令`npm run test -- src/components/CharacterChatModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
- 2026-06-10 追加:拼图首访 onboarding 提示词文本域使用 `PlatformTextField surface="editorDark"`,输入错误和登录保存错误使用 `PlatformStatusMessage surface="editorDark"`,生成 / 登录 CTA 使用 `PlatformActionButton surface="editorDark" tone="accent"`,跳过按钮使用 `PlatformActionButton surface="editorDark" tone="ghost" shape="pill"`onboarding 保留全屏沉浸壳层、登录 / 生成状态机和跳过行为,不再手写 textarea / 错误条 / 按钮 chrome。验证命令`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
- 2026-06-10 追加RPG 大编辑器本地 `SectionPanel` 适配到 `PlatformSubpanel surface="dark"`;可扮演角色背景故事 / 关系 / 技能 / 物品、世界基础设定等编辑分区只保留标题、subtitle、右侧动作和内容插槽不再由本地适配器手写外层暗色面板 chrome。验证命令`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "可扮演角色技能动作状态|supports dark compact subpanel cards"`
- 2026-06-10 验证补充RPG 大编辑器上传封面中提示收口到 `PlatformSubpanel surface="darkSky"` 后,补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "作品封面上传|tinted dark information panels"`
- 2026-06-10 追加RPG 角色形象参考图缩略框使用 `PlatformMediaFrame surface="editorDark"`;角色形象面板只保留参考图数组、上传 / 清空回调和状态文案,不再手写 `img + overflow-hidden + border` 缩略图 chrome。
- 2026-06-10 追加:营地编组同行者头像框使用 `PlatformMediaFrame surface="editorDark"` 和固定尺寸 class保留角色图片 `object-contain`、放大比例与 pixelated 渲染;编组卡只保留角色数据和操作语义,不再手写头像框 `border-white/10 bg-black/25` 外壳。验证命令:`npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
- 2026-06-09 追加:平台普通进度条统一使用 `src/components/common/PlatformProgressBar.tsx` 承载 `progressbar` 语义、`platform-progress-track` 壳、填充宽度、最小可见宽度、尺寸、条内覆盖层、未知进度语义和局部主题色creation-agent 主进度 / operation banner、RPG 结果页生成提示、RPG 实体目录生成中提示、开场 CG 生成占位、拼图关卡画面生成进度、生成页当前步骤线性进度、抓大鹅批量物品素材生成进度和自定义世界生成选择弹窗进度提示已先迁移,业务页只保留进度值、显示文案、状态配色和必要覆盖内容。没有准确百分比的脉冲占位条使用 `indeterminate`,不暴露假的 `aria-valuenow`;生成页环形总进度继续保留 `GenerationProgressHero` 专用 SVG。
- 2026-06-09 追加creation-agent operation banner 的状态外壳迁移到 `PlatformStatusMessage surface="platform" remapSurface`,进度条继续使用 `PlatformProgressBar`;局部 platform token 作用域需要重映射时由 `remapSurface` 承接,不在业务 JSX 中继续手写 `platform-remap-surface platform-banner``platform-banner--*`
- 2026-06-09 追加:平台只读信息块统一使用 `src/components/common/PlatformInfoBlock.tsx` 承载短标签、无标签纯正文、白底圆角边框、单行 / 多行正文排版和横向只读信息行的标签 / 值局部排版;错误弹窗和生成完成弹窗的来源、错误、状态展示、分享弹窗正文,以及汪汪声浪预览卡场景 / 形象 / 难度 / 声浪信息行已迁移,业务页不再重复拼 `rounded-[1rem] border ... bg-white/72 px-3 py-2``rounded-[1.25rem] border ... bg-white/72 p-4``rounded-[0.85rem] bg-white/74 px-* py-*`
- 2026-06-10 追加:`PlatformInfoBlock` 支持 `variant="compactRow"` 承接预览卡密集横向 label / value 行;汪汪声浪预览卡四个信息行只保留 label 和内容,不再维护本地 `PREVIEW_INFO_*` class 常量。
- 2026-06-09 追加:平台白底子面板统一使用 `src/components/common/PlatformSubpanel.tsx` 承载 `platform-subpanel` 外壳、标题行、右侧动作区、强标题、圆角和响应式内边距;静态 element 透传 `aria-*` / `data-*` 等原生属性,便于结果页预览卡保留可访问名称。拼图结果页作品信息 / 标签编辑 / 智能修订条 / 关卡卡片、拼图图库详情页封面轮播壳 / 题材标签 / 关卡摘要、拼图图片生成模式选择器菜单外壳、敲木鱼结果页元信息 / 标签 / 飘字 / 音效、汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡、通用音频输入面板和 RPG 个人中心未登录提示已先迁移。`surface="soft" padding="tight"` 用于标签编辑新增输入行等白底柔和紧凑行,不再手写 `rounded-[1rem] border ... bg-white/68 p-2``surface="soft" padding="row"` 用于上传预览横向已选素材条等白底柔和横向行,不再手写 `rounded-[1rem] border ... bg-white/68 px-3 py-2`;静态封面轮播壳使用 `radius="xl" padding="none"` 保留内部固定比例和轮播按钮;抓大鹅物品详情五视角面板使用 `radius="xl" padding="sm"` 加局部 `sm:p-5` 保留响应式间距。后续仅表达“白底子面板 + 标题 / 右侧动作 + 内容”或小型浮层菜单的片段优先使用该 Module暗色运行态 HUD、媒体预览和强玩法品牌面板继续保留专用布局。
- 2026-06-10 追加:发布分享弹窗渠道 tile 按钮使用 `PlatformSubpanel as="button" surface="flat" radius="sm" padding="tight" interactive`;弹窗只保留渠道枚举、品牌图标和复制分享文本回调,不再手写白底 tile 圆角、边框、底色、hover 或 focus chrome。验证命令`npm run test -- src/components/common/PublishShareModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 追加:平台入口创作类型弹层玩法卡片使用 `PlatformSubpanel as="button" surface="platform" radius="xl" padding="none"`;弹层只保留玩法图片、蒙版、锁定 badge、标题副标题和分流回调外层按钮语义、标准圆角和已开放卡 hover / focus chrome 归公共子面板。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 追加creation-agent 工作台聊天区外壳使用 `PlatformSubpanel radius="xl" padding="none"`;工作台只保留消息列表、引用图预览、错误提示和输入区语义,不再手写聊天面板外层圆角、边框和底色。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 追加creation-agent 无 session / 加载提示块迁移到 `PlatformSubpanel radius="sm" padding="lg"`;工作台只保留提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4` 普通居中提示面板。
- 2026-06-10 追加:拼图结果页空草稿提示块迁移到 `PlatformSubpanel radius="sm" padding="lg"`;结果页只保留提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4` 普通居中提示面板。
- 2026-06-09 追加:敲木鱼结果页主预览面板也迁移到 `PlatformSubpanel`,页面只保留标题、简介和资源叠放语义,不再手写 `platform-subpanel rounded-[1.25rem] p-4`
- 2026-06-09 追加:拼消消创作工作台左侧结构化表单面板迁移到 `PlatformSubpanel`,工作台只保留字段、开关、错误和提交语义,不再手写 `platform-subpanel rounded-[1.25rem] p-4`
- 2026-06-09 追加:抓大鹅创作工作台难度选择小面板迁移到 `PlatformSubpanel surface="flat"`,工作台只保留难度选项和 payload 派生,不再手写小白底面板边框、圆角、内边距和 inset 高光。
- 2026-06-09 追加:视觉小说创作工作台画风选择小面板迁移到 `PlatformSubpanel surface="flat"`,横向滚动、选中态和移动端 touch 行为仍由业务滚动区与样式按钮承接,不再手写外层白底面板 chrome。
- 2026-06-09 追加:创作中心作品架整块无作品 / 无筛选结果空态迁移到 `PlatformEmptyState surface="soft" size="panel"`,加载骨架卡迁移到 `PlatformSubpanel as="div"`Hub 只保留筛选、列表和打开 / 删除 / 分享语义,不再直接拼空态 `platform-subpanel` 或 skeleton 卡片外壳。
- 2026-06-09 追加:视觉小说上传资产弹窗的无历史素材本地上传占位迁移到 `PlatformEmptyState surface="dashed"`弹窗只保留上传、AI 生成、历史素材和选择回调语义,不再手写 dashed 空态面板 chrome。
- 2026-06-09 追加creative-agent 工作台目录、目标就绪、空消息、过程、关卡计划和模板确认理由等标准白底面板迁移到 `PlatformSubpanel`;模板确认的“关卡模式 / 计划关卡”摘要迁移到 `PlatformStatGrid`creative-agent 内不再直接拼 `platform-subpanel rounded-[1.35rem] p-4` / `rounded-[1.25rem] p-4` / `rounded-[1.15rem] p-4`
- 2026-06-09 追加:拼消消结果页预览、统计和操作三个标准白底面板迁移到 `PlatformSubpanel`;页面只保留图片预览、统计项和动作回调,不再直接拼 `platform-subpanel rounded-[1.25rem] p-4``platform-subpanel mt-auto rounded-[1.25rem] p-4`
- 2026-06-09 追加:跳一跳结果页预览和结果操作两个标准白底面板迁移到 `PlatformSubpanel`,公开排行榜小卡迁移到 `PlatformSubpanel surface="flat"`;操作面板标题走 `PlatformFieldLabel variant="section"`,页面只保留资源预览、排行榜数据、状态提示和动作回调,不再直接拼 `platform-subpanel rounded-[1.25rem] p-4``rounded-[1rem] border ... bg-white/70 p-3`
- 2026-06-09 追加:跳一跳结果页角色 / 图集 / 路径预览框和拼消消结果页场地底图 / 素材图集预览框使用 `PlatformSubpanel surface="flat" padding="none"`;白底媒体框只保留内部图片、占位和尺寸,不再重复拼 `rounded-[1rem] border ... bg-white/80`
- 2026-06-09 追加:`PlatformSubpanel` 支持 `radius="xl"`,用于承接方洞结果页等 `rounded-[1.5rem]` 的标准大面板;方洞结果页封面、主信息、形状选项和洞口选项面板已迁移到 `PlatformSubpanel radius="xl" padding="lg"`,页面只保留图片、字段、选项和动作逻辑。
- 2026-06-09 追加:方洞结果页形状 / 洞口选项卡迁移到 `PlatformSubpanel surface="flat"`,贴图缩略图按钮迁移到 `PlatformSubpanel as="button" interactive surface="flat"`;选项卡只保留字段写回、目标洞口选择、删除和图片槽位打开逻辑,不再重复小卡边框、白底、圆角、缩略图 hover / disabled chrome。
- 2026-06-09 追加:敲木鱼创作工作台的“功德有什么”词条面板迁移到 `PlatformSubpanel`,词条输入迁移到 `PlatformTextField`,删除词条圆形浮动入口迁移到 `PlatformIconButton variant="surfaceFloating"`;工作台只保留词条输入、新增和删除交互,不再直接拼 `platform-subpanel rounded-[1.25rem] p-4`、本地标题 class、白底输入框 chrome 或白底圆形图标按钮 chrome。
- 2026-06-09 追加:视觉小说结果页作品、开场、运行配置和世界观标准编辑面板迁移到 `PlatformSubpanel radius="lg"`;页面只保留表单字段、资产预览和运行配置写回,不再直接拼 `platform-subpanel rounded-[1.35rem] p-4`
- 2026-06-09 追加抓大鹅结果页作品信息、难度配置、难度统计、UI 素材预览和物品图集预览标准面板迁移到 `PlatformSubpanel radius="lg" padding="lg"`;页面只保留表单、滑杆、统计项和素材预览逻辑,不再直接拼 `platform-subpanel rounded-[1.35rem] p-4 sm:p-5`
- 2026-06-09 追加:`PlatformSubpanel` 支持 `surface="flat"``padding="sm"``radius="sm"`,用于承接素材 / 音频等小型白底卡片的圆角、边框、`bg-white/72`、标题行和右侧图标动作;视觉小说结果页素材选择 / 音频生成小面板已迁移,业务页不再重复手写 `rounded-[1rem] border ... bg-white/72 p-3`
- 2026-06-09 追加:抓大鹅结果页难度配置里的当前难度摘要小卡迁移到 `PlatformSubpanel surface="flat" radius="sm" padding="sm"`;结果页只保留当前难度标题、消除次数、物品种类和难度 badge不再手写 `rounded-[1rem] border ... bg-white/62 px-3 py-3` 小卡 chrome。
- 2026-06-09 追加RPG 结果页开发资产诊断面板里的摘要卡、资产条目和空态迁移到 `PlatformSubpanel`;开发开关判定拆到 `rpgCreationAssetDebugPanelModel.ts`,组件文件只保留诊断面板渲染和图片加载状态。
- 2026-06-09 追加RPG 发布弹窗封面预览壳迁移到 `PlatformSubpanel padding="none"`;发布弹窗只保留封面 presentation、设置封面和发布动作语义不再直接手写 `platform-subpanel rounded-[1.25rem] p-2`
- 2026-06-09 追加creative-agent 关卡计划小卡和抓大鹅结果页物品 spritesheet 分组卡迁移到 `PlatformSubpanel surface="flat" radius="sm"`;普通信息 / 图集分组小卡不再直接拼 `rounded-[1rem] border ... bg-white/58 p-3``px-3 py-3`
- 2026-06-09 追加:抓大鹅批量物品素材生成状态卡迁移到 `PlatformSubpanel surface="flat" radius="sm"`,内部进度条迁移到 `PlatformProgressBar`;局部进度状态不再手写白底边框和 track / fill div。
- 2026-06-09 追加:平台反馈页问题描述、上传凭证和联系方式三个普通白底区块迁移到 `PlatformSubpanel radius="md"`;平台表单页只表达字段、上传和提交语义,不再直接拼 `platform-subpanel rounded-[1.2rem] px-4 py-4`
- 2026-06-10 追加:`PlatformSubpanel` 支持 `surface="dark"``radius="xs"``padding="xs"`,用于暗色编辑 / 运行面板里的小型信息卡RPG 冒险面板 / 覆盖层任务目标、区域、进度和描述卡,以及自定义世界实体目录角色维度小卡已迁移。后续同类暗色小信息卡只保留标题、图标和值,不再手写 `rounded-xl border border-white/10 bg-black/* px-* py-*`
- 2026-06-09 追加:`PlatformSubpanel` 支持 `as="button"``interactive`,用于承接普通白底整卡点击列表项的 hover、focus、disabled 和默认 `type="button"`;视觉小说 runtime 历史条目和存档列表已迁移,业务页不再重复手写 `rounded-[1rem] border ... bg-white/78 p-3 hover:bg-white disabled:cursor-not-allowed disabled:opacity-55`
- 2026-06-09 追加:视觉小说结果页角色 / 场景 / 阶段列表项和空态迁移到 `PlatformSubpanel`;列表项使用 `as="button" interactive` 保留整卡点击、hover / focus / disabled chrome 和默认 button type空态使用静态 `PlatformSubpanel`,结果页不再直接手写 `platform-subpanel min-h-32` 列表卡片。
- 2026-06-09 追加:账号设置入口卡、主题选择卡、当前主题状态、账号绑定卡、密码 / 安全 / 设备 / 操作记录区块,以及设备 / 操作记录内的白底列表行迁移到 `PlatformSubpanel`;账号弹窗只保留换绑、撤销会话、刷新和日志展示语义,不再直接拼 `platform-subpanel rounded-2xl` 或内层白底列表边框。
- 2026-06-09 追加RPG 世界详情页的世界信息统计卡、关键角色 / 关键场景预览卡和操作区标题迁移到 `PlatformSubpanel``PlatformFieldLabel variant="section"`;详情页只保留作品展示、启动、编辑、发布、下架和删除动作语义,不再直接拼小型 `platform-subpanel` 卡片或本地 section 标题 class。
- 2026-06-10 追加RPG 运行态任务覆盖层里的任务更新提示、地点 / 人物提示和任务日志条目迁移到 `PlatformSubpanel surface="dark"`;运行态只保留任务文案、任务选择和奖励条交互,暗色边框、底色、圆角和条目 hover 外壳不再在业务 JSX 中重复拼。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx -t "quest offer accept button reuses the shared accepted-quest follow-up chain"`
- 2026-06-09 追加:大鱼吃小鱼结果页的关卡卡片、场地背景卡、发布校验卡、空草稿提示和素材工坊 PROMPT 信息块迁移到 `PlatformSubpanel`;结果页只保留大鱼玩法的青色主题按钮、预览背景、素材生成动作和发布校验语义,不再直接拼大圆角白底边框卡片。
- 2026-06-09 追加:汪汪声浪结果页草稿编译小卡迁移到 `PlatformSubpanel surface="flat"`,跳一跳结果页排行榜行卡迁移到 `PlatformSubpanel surface="flat"`,排行榜无成绩空态迁移到 `PlatformEmptyState surface="subpanel"`;结果页只保留玩法文案、排行榜字段和错误 / 空态文案,不再手写白底小卡圆角、边框、底色和 padding。
- 2026-06-09 追加:自定义世界实体目录世界页的档案规模统计迁移到 `PlatformStatGrid`,世界基调、角色维度和基本设定条目迁移到 `PlatformSubpanel`;目录只保留世界资料读取、编辑入口和标签展示语义,不再直接拼统计卡 grid 或 `platform-subpanel rounded-2xl` 设定块。
- 2026-06-09 追加:自定义世界实体目录场景幕级缩略图迁移到 `PlatformSubpanel padding="none"`;目录只保留场景名、幕标题和图片来源语义,不再手写 `platform-subpanel h-12 w-[5.25rem]` 预览框 chrome。
- 2026-06-09 追加:自定义世界实体目录 `CatalogCard` 的角色 / 场景媒体框迁移到 `PlatformSubpanel padding="none"`;目录卡片只保留图片、角色动画或占位内容,不再手写媒体框 `platform-subpanel rounded-[1rem]` / `rounded-[1.1rem]` chrome。
- 2026-06-09 追加:`PlatformSubpanel` 支持 `surface="danger"` 承接整卡危险选中态,`PlatformPillBadge` 支持 `tone="muted"` 承接白底柔和选择 badge自定义世界实体目录 `CatalogCard` 整卡壳迁移到 `PlatformSubpanel as="button"`,批量选择的“选择 / 已选”迁移到 `PlatformPillBadge`,目录只保留选择状态和点击回调,不再手写卡片 `role="button"` / 危险选中边框 / 选择 badge chrome。
- 2026-06-09 追加:平台媒体预览框统一使用 `src/components/common/PlatformMediaFrame.tsx` 承载图片源、fallback 图、fallback 文案、固定比例、refreshKey、warm / editorDark / plain / soft / bright / none / bare surface 和 overlay自定义世界实体目录场景图片框、RPG 实体编辑器 `ImagePreview` 和拼图结果页关卡列表正式图框已先迁移,业务页只保留素材地址、可访问名称和业务覆盖层。`surface="soft"` 用于由媒体框自身承接 `border border-[var(--platform-subpanel-border)] bg-white/68` 的白底柔和预览,`surface="bright"` 用于由媒体框自身承接 `border border-[var(--platform-subpanel-border)] bg-white/82` 的亮白素材槽,`surface="none"` 用于嵌在已有按钮 / 卡片交互壳里的纯图片与 fallback 内容;`PlatformSubpanel` 继续负责白底面板 / 轻量媒体壳 / 整卡点击列表项,不承接需要 fallback 或 overlay 的图片预览状态。
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `aspect="portrait"` 承接 9:16 竖版预览;拼消消结果页场地底图 / 素材图集预览已迁移到 `PlatformMediaFrame surface="none"`,外层仍用 `PlatformSubpanel surface="flat" padding="none"` 提供白底边框、圆角和 `bg-white/80` 媒体壳,页面不再手写 `ResolvedAssetImage` 与无图占位分支。
- 2026-06-09 追加:平台媒体缩略格网格统一使用 `src/components/common/PlatformMediaTileGrid.tsx` 承载列数、间距、白底容器、tile 圆角、边框、图片、refreshKey、可选 tile `testId` 和 fallback 格;跳一跳结果页地块池 / 无图集 fallback 地块池、拼消消结果页卡片预览网格和抓大鹅物品 spritesheet 解析预览分组已先迁移。结果页只保留素材数组切片、素材地址、fallback 内容和玩法色值,不再重复手写 `grid-cols-*``rounded-[0.45rem] border border-white/80 bg-white/78` 或直接依赖底层 `ResolvedAssetImage`;网格内部 tile chrome 由 `tileSurface` 承接,内层 `PlatformMediaFrame` 统一使用 `surface="none"`,不再重复加公共 subpanel fill。
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `fallbackContent` 承接图标型无图占位;方洞结果页图片查看弹窗的 4:3 预览已迁移到 `PlatformMediaFrame aspect="standard" surface="plain"`,页面不再手写图片 / 图标占位分支。
- 2026-06-09 追加:宝贝识物结果页素材卡图片框迁移到 `PlatformMediaFrame aspect="square" surface="none"`,占位资源 badge 作为 `previewOverlay` 传入;素材卡只保留外层 `PlatformSubpanel`、素材名、渐变槽局部样式和业务状态,不再手写 `ResolvedAssetImage` 绝对铺满与 overlay 分支。
- 2026-06-09 追加:视觉小说结果页封面 4:3 预览和资产字段 16:9 图片预览迁移到 `PlatformMediaFrame`;封面使用 `surface="editorDark"` 和图标型 `fallbackContent`,资产字段使用 `aspect="landscape" surface="none"` 嵌入现有小型白底卡片,页面不再手写 `ResolvedAssetImage``aspect-[4/3]` / `aspect-[16/9]` 和无图占位分支。
- 2026-06-09 追加:跳一跳结果页地块图集整图 fallback 预览迁移到 `PlatformMediaFrame aspect="square" surface="none"`;单个地块网格和路径平台预览保留专用组合布局,只有纯图片源 + 正方形比例的 atlas 分支进入公共媒体框,图集底色作为局部 `bg-white/78` 保留在媒体框 class。
- 2026-06-09 追加:方洞结果页封面和背景两个点击预览按钮内部迁移到 `PlatformMediaFrame aspect="standard" / "landscape" surface="none"`;按钮继续负责打开图片槽位弹窗和承接渐变边框交互壳,公共媒体框只负责 4:3 / 16:9 比例、图片读取和图标型 fallback占位和图片分支不再写在业务 JSX 中。
- 2026-06-09 追加:方洞结果页形状 / 洞口选项里的 80px 贴图缩略图迁移到 `PlatformMediaFrame aspect="square" surface="none"`;外层 `PlatformSubpanel as="button"` 继续负责打开素材弹窗和亮白交互壳,业务页不再直接依赖底层 `ResolvedAssetImage`,内层媒体框也不再重复承接背景。
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `aspect="wide"` 承接 9:5 宽图预览;大鱼吃小鱼素材工坊候选预览迁移到 `PlatformMediaFrame aspect="wide" surface="none"`,工坊只保留 prompt、生成动作和 cyan 主题外观适配,虚线边框与浅青底作为局部 class 保留。
- 2026-06-09 追加:拼图发布弹窗封面关卡预览迁移到 `PlatformMediaFrame aspect="square" surface="soft"`;发布弹窗只保留发布检查、泥点提示和发布动作,不再手写封面图片框 `aspect-square``ResolvedAssetImage`、白底柔和边框和空图分支。
- 2026-06-09 追加:大鱼吃小鱼结果页场地背景竖版预览迁移到 `PlatformMediaFrame aspect="portrait" surface="none"`;结果页保留青色深海背景主题和生成背景动作,不再手写 9:16 图片框与 `ResolvedAssetImage` 分支。
- 2026-06-09 追加:大鱼吃小鱼结果页关卡主图缩略图迁移到 `PlatformMediaFrame aspect="square" surface="none"`;关卡卡片只保留关卡文案、状态和工坊入口,不再直接依赖底层 `ResolvedAssetImage`
- 2026-06-10 追加:抓大鹅结果页物品素材列表缩略图和详情大图迁移到 `PlatformMediaFrame aspect="square" surface="bright"`,详情视角缩略图嵌在保留选中态的按钮壳内并使用 `surface="none"`;素材列表卡只保留打开详情、素材名和删除动作,详情预览只保留视角切换状态,不再手写正方形图片 / 图标 fallback / 亮白边框槽;需要测试 id / aria 时通过媒体框容器属性透传。
- 2026-06-10 追加:抓大鹅结果页 UI 素材子 Tab 的游戏背景、UI spritesheet 和物品 spritesheet 主图预览迁移到 `PlatformMediaFrame surface="none"`;外层按钮 / 白底预览壳继续负责交互、边框、底色和内边距媒体框只承接图片读取、fallback 和固定比例。
- 2026-06-10 追加:`PlatformMediaFrame` 根节点固定带 `platform-media-frame` 类名,供业务测试断言公共媒体框接入;拼图图库详情页封面轮播的内层正方形图片 / 暂无封面 fallback / 轮播 overlay 迁移到 `PlatformMediaFrame aspect="square" surface="none"`,外层 `PlatformSubpanel radius="xl" padding="none"` 继续承接面板边框、圆角和裁切。
- 2026-06-10 追加:认证图形验证码图片使用 `PlatformMediaFrame aspect="auto" surface="soft"`;验证码组件只保留图片 data URL、可访问名称和固定尺寸 class不再手写 `img + platform-subpanel` 图片框。
- 2026-06-09 追加:敲木鱼结果页主 9:16 背景 + 敲击物叠层预览迁移到 `PlatformMediaFrame aspect="portrait" surface="plain"`;页面保留背景图和敲击物的叠放顺序,不再手写固定比例外框、白底边框和无图占位。
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `fallbackShellClassName` 承接无图 fallback 区域的局部背景 / 渐变creative-agent 模板确认预览迁移到 `PlatformMediaFrame aspect="landscape" surface="soft"`,弹窗只保留模板标题、泥点、调整和确认语义,不再手写 16:9 图片 / 图标占位容器,也不再在业务 JSX 中重复拼基础边框和 `bg-white/68`
- 2026-06-09 追加creative-agent 模板目录卡迁移到 `PlatformSubpanel as="button" interactive surface="flat"`,卡内 16:9 预览迁移到 `PlatformMediaFrame aspect="landscape" surface="none"`工作台只保留模板选择、标题、摘要、预览渐变局部样式和泥点范围不再手写白底按钮卡、16:9 图片框或图标 fallback 容器。
- 2026-06-09 追加:非交互中性 / 柔和 / hero / 暗色琥珀 / 成功 / 危险图标槽统一使用 `src/components/common/PlatformIconBadge.tsx` 承载图标、尺寸、圆角、neutral / soft / softBright / hero / heroMuted / darkAmber / success / danger 底色和可访问隐藏语义;视觉小说 runtime 面板标题、存档列表项creative-agent 模板卡 / 模板确认 / 顶部 hero / 目标就绪 / 过程条目图标圆槽,创作类型弹层锁定卡小圆锁图标、大鱼吃小鱼发布失败弹窗图标槽、通用创作图片面板空主图上传占位图标槽,以及 GameCanvas 宝箱遭遇图标槽已先迁移,业务页不再重复拼 `grid h-* w-* place-items-center bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]`、白底柔和小圆槽、目标完成图标槽、暗色琥珀图标槽或危险提示红色圆槽。
- 2026-06-10 追加:宝贝识物工作台静态玩法预览卡迁移到 `PlatformSubpanel surface="soft"`,卡内礼物图标槽迁移到 `PlatformIconBadge tone="softBright"`;工作台只保留玩法渐变、装饰层和文案,不再手写白底柔和面板边框 / 圆角 / 内边距或图标槽 chrome。
- 2026-06-09 追加:平台标签编辑统一使用 `src/components/common/PlatformTagEditor.tsx` 承载标签 chip、删除按钮、新增输入、Enter 提交、Escape 取消、空态、可选 AI 生成动作和错误提示;拼图结果页作品标签、敲木鱼结果页主题标签和抓大鹅结果页作品标签已先迁移。业务页只保留标签 parse / normalize 规则、最大数量和最终写回,不再重复维护标签编辑 JSX 与本地新增状态机。
- 2026-06-10 追加:标签编辑 Module 内部的新增输入行由 `PlatformSubpanel surface="soft" padding="tight"` 承接外壳,输入框由 `PlatformTextField` 承接;公共标签编辑不再把子面板和输入框 chrome 混写在同一段本地 JSX class 中。
- 2026-06-09 追加:方形上传入口和紧凑虚线新增入口统一使用 `src/components/common/PlatformUploadTile.tsx` 承载虚线方块、图标、主副文案、button / label 语义和禁用态;`size="compact" showLabel={false}` 用于工作台里的纯图标虚线新增入口,仍保留隐藏可访问名称。上传后的图片预览统一使用 `src/components/common/PlatformUploadPreviewCard.tsx` 承载缩略图壳、预览图片、可选标题行、可选预览点击、横向已选素材条和移除按钮。默认 `layout="square"` 用于方形缩略图,`layout="inline"` 用于“缩略图 + 文件名 / 素材名 + 移除”的已选参考图条,内部横向行复用 `PlatformSubpanel surface="soft" padding="row"`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条和 creation-agent 已选参考图条已先迁移,业务页只保留文件选择、预览数组、预览回调、删除回调、新增回调和校验逻辑。工具栏小图标上传仍使用 `PlatformIconButton asChild="label"`,带大面积缩略图选择的历史素材仍使用 `PlatformAssetPickerGrid`
- 2026-06-09 追加:拼图结果页关卡详情中的只读引用图横条也使用 `PlatformUploadPreviewCard layout="inline"`,由公共组件承载缩略图、`ResolvedAssetImage` 换签、素材名截断和横向白底条 chrome只读场景不传 `onRemove`,避免结果页额外出现删除按钮。历史素材弹窗仍使用 `PlatformAssetPickerGrid`,结果页只展示选择后的引用关系。
- 2026-06-09 追加:白底平台子面板内的无操作空态使用 `PlatformEmptyState surface="subpanel" size="inline"`,由 Module 承载圆角、边框、`bg-white/74`、居中、字号和 soft 文本色;视觉小说 runtime 历史、属性、存档读取 / 空态已先迁移,业务页不再重复拼白底空态 class。
- 2026-06-10 追加:个人中心充值弹窗的“暂无可购买套餐”和每日任务弹窗的“暂无任务”使用 `PlatformEmptyState surface="subpanel" size="inline"`;业务组件只保留数据分支,不再手写 `platform-subpanel rounded-2xl px-4 py-8` 空态 chrome。
- 2026-06-10 追加:`PlatformEmptyState` 根节点固定带 `platform-empty-state` 类名,并支持 `surface="editorDark"` 承接 RPG 大编辑器和运行态弹窗 / 面板里的暗色虚线纯展示空态;角色槽位、可选角色、关系、技能、物品、交易空列表、赠礼空列表、招募替换空列表、奖励物品空态、任务日志空态、运行态设置保存禁用提示和营地编组空队列只保留业务文案,不再重复拼 `rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500``rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500``rounded-xl border border-dashed border-white/10 bg-black/20 px-3 py-4 text-center text-xs text-zinc-500`
- 2026-06-09 追加:自定义世界实体目录搜索框迁移到 `PlatformTextField density="compact"`,搜索无结果空态迁移到 `PlatformEmptyState surface="dashed"`;目录只保留搜索值、占位符和过滤语义,不再直接拼 `platform-subpanel rounded-2xl` 输入壳或虚线空态。
- 2026-06-10 追加creation-agent 聊天区“暂无消息”迁移到 `PlatformEmptyState surface="subpanel" size="compact"`composer 文本域迁移到 `PlatformTextField variant="textarea" size="md" density="compact"`工作台保留消息列表滚动、受控输入、禁用条件、Enter 提交和 Shift+Enter 换行语义,不再手写空态和 textarea chrome。
- 2026-06-10 追加:大鱼吃小鱼结果页缺少可编辑草稿提示迁移到 `PlatformEmptyState surface="subpanel" size="compact"`;结果页只保留草稿分支和文案,不再为白底无操作提示手写 `PlatformSubpanel` 空面板。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx`
- 2026-06-09 追加:视觉小说 runtime 普通白底面板里的保存主按钮和历史重生成行内动作使用 `PlatformActionButton surface="platform"`;保存使用默认主动作,行内重生成使用 `tone="secondary" size="xs" shape="pill"`,业务页只保留图标、禁用条件和回调。
- 影响范围:`src/components/common/UnifiedConfirmDialog.tsx``src/components/common/useCopyFeedback.ts``src/components/common/CopyFeedbackButton.tsx``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``src/components/common/platformActionButtonModel.ts``src/components/common/PlatformIconButton.tsx``src/components/common/PlatformUploadTile.tsx``src/components/common/PlatformUploadPreviewCard.tsx``src/components/common/PlatformMediaFrame.tsx``src/components/common/PlatformModalCloseButton.tsx`、平台入口壳、公共错误 / 完成 / 分享弹窗、公开详情页、大鱼 runtime / result、账号个人资料区、自定义世界实体目录、RPG 结果页重新生成确认、RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪 / 视觉小说结果页普通按钮和状态提示、历史图片选择弹窗 / RPG 发布检查弹窗 / creative-agent 侧边栏 / creation-agent 参考图 / 敲木鱼结果页 / 拼图结果页普通图标按钮、方洞结果页图片素材弹窗关闭按钮、视觉小说结果页资产 / 音频 / 编辑器弹窗和 runtime 普通面板关闭按钮、统一创作页壳层、拼图创作工作台、拼消消创作工作台、宝贝识物创作工作台、视觉小说创作工作台、汪汪声浪创作工作台、creation-agent 推荐回复、creative-agent 工作台、creative-agent 模板确认弹窗、自定义世界实体目录小动作和状态提示、创作中心错误重试、反馈页 header 返回、认证入口 / 邀请码弹窗关闭按钮、通用生成页重试 / 中断动作、RPG 详情页删除确认、RPG 角色素材工作室泥点确认、RPG 场景编辑器阻断提示、RPG 角色背景章节阻断提示、RPG 编辑器未保存关闭确认、RPG 场景背景 / 作品封面生成退出确认、公开作品深链失效恢复、账户充值 / 泥点账单 / 每日任务 / 兑换码 / 扫码 / 存档 / 玩过作品等个人中心弹窗、RPG 首页 / 公开广场 / 作品架和历史素材选择弹窗空态、个人中心充值 / 任务 / 兑换 / 邀请 / 支付结果弹窗主动作按钮、RPG 作品详情和生成结果恢复面板平台动作按钮、法律信息弹窗 footer、通用创作图片 / 音频输入面板动作按钮和上传 label、统一创作工作台返回 / 生成按钮和错误提示、短信登录 / 密码登录 / 绑定手机号认证表单动作按钮和状态提示、账号安全弹窗动作按钮和状态提示、验证码提示、邀请码弹窗提交按钮和错误提示、错误 / 完成 / 分享弹窗复制按钮外观、结果页 / 工作台后续简单弹窗迁移。
- 验证方式:`npm run test -- src/components/common/UnifiedConfirmDialog.test.tsx src/components/common/useCopyFeedback.test.tsx src/components/common/CopyFeedbackButton.test.tsx src/components/common/CopyCodeButton.test.tsx src/components/common/CopyFeedbackMessage.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformIconButton.test.tsx src/components/common/PlatformUploadTile.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`,迁移页面时补跑对应页面交互测试;实体目录删除确认、角色背景章节阻断与场景编辑器提示补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx`;公开作品深链失效恢复补跑 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail"`RPG 结果页重新生成确认补跑 `npm run test -- src/components/CustomWorldResultView.test.tsx`RPG 详情页删除 hook 补跑 `npm run test -- src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx`;角色素材工作室泥点确认补跑 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`;个人中心弹窗关闭按钮迁移补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|reward code|task center|recharge|save archive|played works"`;认证入口 / 邀请码弹窗关闭按钮迁移补跑 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`RPG 首页 / 公开广场 / 作品架空态迁移补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile discover|desktop logged in home|profile played works|logged in draft bottom tab|ranking"`;历史素材选择弹窗空态迁移补跑 `npm run test -- src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx`;结果页普通动作和状态提示迁移补跑 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx``npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx``npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx``npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformModalCloseButton.test.tsx``npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx`;玩法创作工作台普通动作和错误提示迁移补跑 `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`creation-agent 推荐回复动作迁移补跑 `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`;创作中心重试和反馈页返回按钮迁移补跑 `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`;通用生成页动作迁移补跑 `npm run test -- src/components/CustomWorldGenerationView.test.tsx src/components/common/PlatformActionButton.test.tsx`;统一创作页壳层补跑 `npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx`;拼图创作工作台返回按钮补跑 `npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`;个人中心主动作按钮迁移补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recharge|wallet ledger|task center|reward code|invite|community"`;复制弹窗外观迁移补跑 `npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/common/PublishShareModal.test.tsx`;阶段完成前复扫 `rg -n "window\\.confirm|window\\.alert" src/components src/services src/hooks -g '*.tsx' -g '*.ts'`
- 2026-06-09 验证补充:通用输入 Composer 图标按钮迁移补跑 `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
- 2026-06-10 验证补充creative-agent 首页抽屉空态和首页错误提示收口后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-10 验证补充creative-agent 过程面板空态收口到 `PlatformEmptyState surface="subpanel" size="compact"` 后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx`
- 2026-06-10 验证补充creative-agent 工作台消息空态收口到 `PlatformEmptyState surface="subpanel" size="compact"` 后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 验证补充:作品详情顶部和封面轮播图标按钮收口补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`
- 2026-06-10 验证补充:作品详情底部启动 / 改造动作收口补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx`
- 2026-06-10 验证补充:作品详情点赞按钮收口补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
- 2026-06-10 验证补充creative-agent 模板确认弹层“关卡数”行内标题收口到 `PlatformFieldLabel variant="inlineForm"` 后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- 2026-06-10 验证补充:平台入口公开编号搜索结果弹层收口到 `UnifiedModal``PlatformStatusMessage``PlatformSubpanel` 后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "public code search"`
- 2026-06-10 验证补充:平台作品详情主题标签和作品号复制 chip 收口后,补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyCodeButton.test.tsx`
- 2026-06-10 验证补充:平台作品详情分享复制反馈按状态映射到 `PlatformStatusMessage surface="platform"` 后,补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-10 验证补充:大鱼吃小鱼结果页缺草稿空态收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx`
- 2026-06-10 验证补充:大鱼吃小鱼结果页发布校验阻断项收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-09 验证补充:通用输入 Composer 面板、文本域和读图错误状态收口补跑 `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 验证补充creation-agent composer 错误条收口补跑 `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-09 验证补充:通用创作图片面板历史入口和抓大鹅封面编辑浮动图标按钮收口补跑 `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/CreativeImageInputPanel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-09 验证补充AI 重绘胶囊开关收口补跑 `npm run test -- src/components/common/PlatformPillSwitch.test.tsx src/components/common/CreativeImageInputPanel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-09 验证补充:白底整行开关收口补跑 `npm run test -- src/components/common/PlatformToggleRow.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
- 2026-06-09 验证补充RPG 大编辑器动作按钮收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "保存修改|保存角色"`
- 2026-06-09 验证补充runtime 白底 HUD 收口补跑 `npm run test -- src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
- 2026-06-09 验证补充:历史素材选择卡片收口补跑 `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
- 2026-06-09 验证补充RPG 大编辑器历史素材弹窗收口补跑 `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx`
- 2026-06-09 验证补充:抓大鹅封面编辑可引用素材网格收口补跑 `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-09 验证补充:抓大鹅结果页白底输入框和文本域收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-09 验证补充:方洞结果页主信息表单白底输入框和文本域收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
- 2026-06-09 验证补充:方洞结果页形状 / 洞口选项紧凑输入、文本域和下拉框收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
- 2026-06-09 验证补充:拼图 / 敲木鱼结果页作品信息输入、拼图关卡名称和智能修订输入收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
- 2026-06-09 验证补充:通用创作图片输入面板提示词文本域收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
- 2026-06-09 验证补充:创作工作台白底字段输入和焦点色 tone 收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx``npm run test -- src/components/common/PlatformTextField.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
- 2026-06-09 验证补充:白底分段 Tab / 二选一收口补跑 `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
- 2026-06-09 验证补充:抓大鹅难度四选一收口补跑 `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-09 验证补充:平台统计小卡收口补跑 `npm run test -- src/components/common/PlatformStatGrid.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-09 验证补充:自定义世界实体目录搜索框和空态收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx`
- 2026-06-10 验证补充RPG 大编辑器暗色纯展示空态迁移到 `PlatformEmptyState surface="editorDark"` 后,补跑 `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色空态复用暗色平台空态"`
- 2026-06-10 验证补充:角色聊天错误提示收口到 `PlatformStatusMessage surface="editorDark"` 后,补跑 `npm run test -- src/components/CharacterChatModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-10 验证补充:营地编组战斗中提示、状态数值、分区 / 同行者卡、空队列和替换位按钮分别收口到 `PlatformStatusMessage surface="editorDark"``PlatformPillBadge darkNeutral``PlatformSubpanel surface="dark" / "darkSky"``PlatformEmptyState surface="editorDark"``PlatformDarkOptionCard` 后,补跑 `npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
- 2026-06-10 验证补充:自定义选择弹窗错误 / 生成中提示收口到 `PlatformStatusMessage surface="editorDark"``PlatformProgressBar` 后,补跑 `npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformProgressBar.test.tsx`
- 2026-06-10 验证补充:地图场景切换目标场景面板、当前 / 前往摘要和方向标签收口到 `PlatformSubpanel surface="darkAmber" / "dark"``PlatformPillBadge dark*` 后,补跑 `npm run test -- src/components/MapModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- 2026-06-10 验证补充RPG 构筑标签效果详情收口到 `CharacterInfoShared.BuildContributionDetailPanel``PlatformSubpanel surface="dark"` 后,补跑 `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/AdventureEntityModal.test.tsx -t "BuildContributionDetailPanel|技能详情静态标签"`
- 2026-06-10 验证补充RPG 实体详情弹窗物品空态和技能详情暗色小卡收口后,补跑 `npm run test -- src/components/AdventureEntityModal.test.tsx -t "物品空态|技能详情静态标签"`
- 2026-06-09 验证补充:创作中心作品架空态和加载骨架卡收口补跑 `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:平台胶囊状态标签和宝贝识物结果页白底卡片收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
- 2026-06-09 验证补充:平台胶囊状态标签扩展到宝贝识物 / 拼图 / 汪汪声浪工作台和结果页 chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`
- 2026-06-09 验证补充:平台胶囊状态标签扩展到视觉小说 / 抓大鹅工作台 BETA chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`
- 2026-06-09 验证补充:平台胶囊状态标签扩展到敲木鱼结果页飘字 chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
- 2026-06-09 验证补充:平台胶囊状态标签扩展到 creative-agent 过程计数 / 条目 meta chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`
- 2026-06-10 验证补充:实心中性状态胶囊和整行状态开关收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformToggleRow.test.tsx`
- 2026-06-10 验证补充:媒体紧凑占位浮层收口补跑 `npm run test -- src/components/common/PlatformOverlayBadge.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
- 2026-06-10 验证补充creative-agent 阶段时间线柔和步骤圆点收口补跑 `npm run test -- src/components/common/PlatformSlotBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`
- 2026-06-10 验证补充creative-agent 过程条目柔和图标圆槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`
- 2026-06-10 验证补充creative-agent 模板 / hero / 目标就绪图标圆槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
- 2026-06-10 验证补充:创作类型弹层锁定卡小圆锁图标收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx`
- 2026-06-10 验证补充:大鱼吃小鱼发布失败弹窗危险图标槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx -t "shows publish failures in a dismissible modal"`
- 2026-06-10 追加:`PlatformIconBadge` 根节点固定带 `platform-icon-badge` 稳定类名;个人中心充值结果弹窗和支付确认遮罩里的 56px 圆形图标槽使用 `PlatformIconBadge size="xl"` 并保留局部 `bg-white/10` 与状态文字色覆盖,支付弹窗不再手写圆形图标容器。验证命令:`npm run test -- src/components/common/PlatformIconBadge.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "confirms virtual payment after returning without hash result|releases submitting state after cancelled wechat pay result"`
- 2026-06-10 验证补充:宝贝识物工作台静态玩法预览卡和图标槽收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformIconBadge.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
- 2026-06-10 验证补充:通用创作图片面板空主图上传占位图标槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
- 2026-06-10 验证补充GameCanvas 宝箱遭遇图标槽收口到 `PlatformIconBadge size="xxl" shape="xl" tone="darkAmber"` 后,补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/game-canvas/GameCanvasEntityLayer.test.tsx`
- 2026-06-10 验证补充:通用创作图片面板按钮内泥点消耗胶囊收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
- 2026-06-10 验证补充:抓大鹅创作工作台按钮内泥点消耗胶囊收口补跑 `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- 2026-06-10 验证补充:标签编辑新增输入行 soft 子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformTagEditor.test.tsx`
- 2026-06-10 验证补充:标签编辑新增输入框收口到 `PlatformTextField` 后,补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformTagEditor.test.tsx`
- 2026-06-10 验证补充:个人中心昵称弹窗输入框收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile nickname modal"``npm run test -- src/components/common/PlatformTextField.test.tsx`
- 2026-06-10 验证补充:认证图形验证码图片和答案输入分别收口到 `PlatformMediaFrame``PlatformTextField` 后,补跑 `npm run test -- src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
- 2026-06-10 验证补充:认证登录、重置密码、绑定手机号、邀请码和账号安全表单字段收口到 `PlatformTextField``PlatformFieldLabel` 后,补跑 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/auth/AccountModal.test.tsx src/components/auth/BindPhoneScreen.test.tsx src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- 2026-06-10 验证补充:通用创作图片输入面板主图 / 提示词字段标题收口到 `PlatformFieldLabel` 后,补跑 `npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- 2026-06-10 验证补充:个人中心存档 / 玩过弹窗简单空态、分区标题和已玩作品按钮卡收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile played modal|profile page keeps save archives inside played stats panel"`
- 2026-06-10 验证补充:平台入口壳纯 Suspense fallback 收口到 `PlatformSubpanel` 后,补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`
- 2026-06-10 验证补充:个人中心钱包账单空态和账单行收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger"``npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 验证补充:个人中心邀请弹窗内部卡片、标题和空态收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut|profile redeem invite"``npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformFieldLabel.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 验证补充:个人中心任务中心任务条目收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile daily task"``npm run test -- src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 验证补充:个人中心充值弹窗 Native 支付二维码确认面板收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal shows native qr code"``npm run test -- src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 验证补充:个人中心兑换码 / 邀请码输入和充值 / 任务空态收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "reward code|invite query|profile redeem invite|daily task"`
- 2026-06-10 验证补充:背包文书按钮收口到暗色 `PlatformSubpanel`、故事档案 QA 提示收口到 `PlatformStatusMessage surface="editorDark"` 后,补跑 `npm run test -- src/components/InventoryPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-10 验证补充NPC 叙事提示和交易详情属性格收口后,补跑 `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- 2026-06-10 验证补充NPC 暗色可选项按钮卡收口到 `PlatformDarkOptionCard` 后,补跑 `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
- 2026-06-10 验证补充:角色素材工作室动作预览格收口到 `PlatformDarkOptionCard` 后,补跑 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
- 2026-06-10 验证补充:上传预览横向已选素材条 soft row 子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx`
- 2026-06-10 验证补充creation-agent 无 session / 加载提示块收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`
- 2026-06-10 验证补充creation-agent 聊天空态和 composer 文本域收口后,补跑 `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformTextField.test.tsx`
- 2026-06-10 验证补充:拼图首访 onboarding 提示词文本域、输入错误和登录保存错误收口后,补跑 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformTextField.test.tsx`
- 2026-06-10 验证补充:拼图首访 onboarding 生成 / 登录 / 跳过按钮收口到 `PlatformActionButton` 后,补跑 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
- 2026-06-10 验证补充:拼图结果页空草稿提示块收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- 2026-06-10 验证补充RPG 个人中心未登录提示子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx`,并对 `src/components/rpg-entry/RpgEntryHomeView.tsx` 执行 ESLint / typecheck游客态当前不暴露“我的”Tab不新增不可达业务断言。
- 2026-06-10 验证补充:拼图图库详情页封面轮播壳收口到 `PlatformSubpanel radius="xl" padding="none"` 后,补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
- 2026-06-10 验证补充:抓大鹅物品详情五视角面板收口到 `PlatformSubpanel radius="xl" padding="sm"` 后,补跑 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:拼图 / 方洞结果页自动保存 badge 收口补跑 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- 2026-06-09 验证补充:抓大鹅结果页自动保存 / 当前难度 badge 收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-09 验证补充:拼图结果页关卡生成中 badge 收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- 2026-06-09 验证补充:大鱼吃小鱼结果页终局 / 发布校验成功 badge 收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
- 2026-06-10 验证补充:大鱼吃小鱼结果页关卡元信息标签收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- 2026-06-09 验证补充:宝贝识物占位资源 overlay 和方洞选项删除图标按钮收口补跑 `npm run test -- src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx``npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformIconButton.test.tsx`
- 2026-06-09 验证补充:平台普通进度条收口补跑 `npm run test -- src/components/common/PlatformProgressBar.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx`
- 2026-06-09 验证补充:汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡收口到 `PlatformSubpanel` 后,补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
- 2026-06-09 验证补充:跳一跳结果页公开排行榜小卡收口到 `PlatformSubpanel surface="flat"` 后,补跑 `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:汪汪声浪草稿编译小卡、跳一跳排行榜行卡和排行榜空态收口后,补跑 `npm run test -- src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx`
- 2026-06-09 验证补充:跳一跳 / 拼消消结果页媒体预览框收口到 `PlatformSubpanel surface="flat" padding="none"` 后,补跑 `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:方洞结果页标准大面板收口到 `PlatformSubpanel radius="xl"` 后,补跑 `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:方洞结果页形状 / 洞口选项卡和缩略图按钮收口后,补跑 `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-10 验证补充RPG 大编辑器场景背景 / 作品封面生成和封面上传状态提示收口到 `PlatformStatusMessage surface="tinted"` 后,补跑 `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "场景图片保存后会同步更新编辑页和场景列表"`
- 2026-06-09 验证补充creation-agent operation banner 状态外壳收口补跑 `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`
- 2026-06-09 验证补充:平台只读信息块收口补跑 `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx`
- 2026-06-09 验证补充:汪汪声浪预览卡横向只读信息行收口补跑 `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
- 2026-06-09 验证补充:平台白底子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/CreativeAudioInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
- 2026-06-09 验证补充:拼消消创作工作台左侧表单面板收口补跑 `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:抓大鹅创作工作台难度小面板收口补跑 `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:视觉小说创作工作台画风选择小面板收口补跑 `npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:拼消消结果页白底面板收口补跑 `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`
- 2026-06-09 验证补充creative-agent 标准白底面板收口补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`
- 2026-06-09 验证补充creative-agent 模板目录卡和 16:9 预览收口补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
- 2026-06-10 验证补充creative-agent 模板确认预览使用 `PlatformMediaFrame surface="soft"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
- 2026-06-09 验证补充:通用音频输入面板限制标签收口补跑 `npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- 2026-06-09 验证补充RPG 世界详情页白底信息卡与 section 标题收口补跑 `npm run test -- src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- 2026-06-09 验证补充:大鱼吃小鱼结果页白底卡片收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- 2026-06-09 验证补充:大鱼吃小鱼结果页白底动作按钮收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformActionButton.test.tsx`
- 2026-06-09 验证补充RPG 结果页开发资产诊断面板收口补跑 `npm run test -- src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:自定义世界实体目录世界页统计和基本设定收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`
- 2026-06-09 验证补充:自定义世界实体目录场景幕级缩略图收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:自定义世界实体目录卡片媒体框收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- 2026-06-09 验证补充:自定义世界实体目录卡片整卡壳和批量选择 badge 收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- 2026-06-10 验证补充RPG 实体编辑器基本设定 tag 和角色形象参考图 / 状态小卡收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`
- 2026-06-09 验证补充:平台媒体预览框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- 2026-06-09 验证补充:方洞图片查看弹窗媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
- 2026-06-09 验证补充:拼消消结果页卡片预览网格收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`
- 2026-06-09 验证补充:宝贝识物结果页素材卡媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
- 2026-06-09 验证补充:视觉小说结果页封面和资产字段媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
- 2026-06-09 验证补充:跳一跳结果页地块图集整图媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx`
- 2026-06-09 验证补充:平台媒体缩略格网格收口补跑 `npm run test -- src/components/common/PlatformMediaTileGrid.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`
- 2026-06-09 验证补充:方洞结果页封面 / 背景点击预览媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
- 2026-06-09 验证补充:方洞结果页形状 / 洞口贴图缩略图媒体框收口到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
- 2026-06-10 验证补充:方洞封面 / 背景、拼消消场地底图 / 素材图集、宝贝识物素材卡、跳一跳图集整图和大鱼媒体槽统一收口到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
- 2026-06-10 验证补充:拼图发布封面收口到 `surface="soft"`,拼图关卡列表、视觉小说资产字段和 creative-agent 模板目录卡收口到 `surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`;业务页面不再直接使用 `PlatformMediaFrame surface="bare"`
- 2026-06-10 验证补充:`PlatformMediaTileGrid` 内部媒体框改用 `surface="none"` 并支持 item `testId`,抓大鹅物品 spritesheet 解析分组迁移后,补跑 `npm run test -- src/components/common/PlatformMediaTileGrid.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-10 验证补充:抓大鹅 UI 素材子 Tab 的背景、UI spritesheet 和物品 spritesheet 主图迁移到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/common/PlatformMediaTileGrid.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-10 验证补充:拼图图库详情页封面轮播内层媒体框收口到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
- 2026-06-10 验证补充:`PlatformMediaFrame` 增加 `aspect="auto"`、容器 `ref``imageProps`RPG 封面上传裁剪操作区 / 裁剪结果、角色素材工作室形象预览和动作静态预览迁移到公共媒体框,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "作品封面上传会先进入 16:9 裁剪面板再提交到后端"``npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`
- 2026-06-10 验证补充RPG 编辑器场景幕背景预设、技能编辑 fallback 预览、技能列表缩略图和角色编辑顶部形象预览继续收口到 `PlatformMediaFrame` 后,补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色技能动作状态复用暗色平台胶囊标签|场景编辑器会在场景内展示槽位化多幕配置并保存"`
- 2026-06-10 验证补充RPG 大编辑器场景幕角色槽位当前角色 / 可选角色面板,以及幕背景预览 / 预设背景面板收口到本地 `EditorInfoPanel` + `PlatformSubpanel surface="dark"` 后,补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "场景编辑器会在场景内展示槽位化多幕配置并保存"`
- 2026-06-09 验证补充:大鱼吃小鱼素材工坊宽图候选预览收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
- 2026-06-09 验证补充:拼图发布弹窗封面关卡预览收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- 2026-06-09 验证补充:大鱼吃小鱼场地背景竖版预览收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
- 2026-06-09 验证补充:大鱼吃小鱼关卡主图缩略图收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
- 2026-06-10 验证补充:抓大鹅结果页物品素材列表缩略图和详情大图收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-09 验证补充:敲木鱼结果页主预览面板和 9:16 叠层预览收口补跑 `npm run test -- src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
- 2026-06-09 验证补充:平台标签编辑器收口补跑 `npm run test -- src/components/common/PlatformTagEditor.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-09 验证补充:反馈页上传方块和上传预览收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/PlatformUploadTile.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx`
- 2026-06-10 验证补充:反馈页查看记录次级动作收口补跑 `npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`
- 2026-06-10 验证补充:创作中心作品卡积分激励领取按钮收口补跑 `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformActionButton.test.tsx src/index.test.ts`
- 2026-06-10 验证补充UnifiedModal 头部关闭按钮收口到 `PlatformModalCloseButton platformIcon / pixel` 后,补跑 `npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx`
- 2026-06-10 验证补充:上传预览卡右上移除按钮收口到 `PlatformIconButton darkMini` 后,补跑 `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx`
- 2026-06-10 验证补充RPG 大编辑器参考图和封面上传入口收口到 `PlatformUploadTile surface="editorDark"`、参考图预览条收口到 `PlatformUploadPreviewCard surface="editorDark"` 后,补跑 `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "场景图片保存后会同步更新编辑页和场景列表"`
- 2026-06-10 验证补充:角色素材工作室参考图入口收口到 `PlatformUploadTile surface="editorDark"` 后,补跑 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`
- 2026-06-09 验证补充:敲木鱼工作台新增功德词条虚线入口收口补跑 `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx`
- 2026-06-09 验证补充:通用创作图片面板参考图缩略图收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
- 2026-06-09 验证补充:抓大鹅封面编辑参考图缩略图收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- 2026-06-09 验证补充:横向已选参考图条收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
- 2026-06-09 验证补充:拼图结果页关卡引用图横条收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- 2026-06-10 验证补充:汪汪声浪预览 VS chip 收口到 `PlatformPillBadge` 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
- 2026-06-10 验证补充:拼图结果页智能修订条 / 关卡卡片收口到 `PlatformSubpanel` / `PlatformIconBadge` 后,补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- 关联文档:`docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md`
## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐 ## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐
- 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动或切卡后反向回弹。 - 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动或切卡后反向回弹。
@@ -128,6 +537,7 @@
- 影响范围:`CONTEXT.md`、拼消消 PRD / 技术方案、平台玩法链路文档、`shared-contracts` / `packages/shared``api-server``spacetime-module``spacetime-client`、作品架 / 广场 / 统一作品详情 / runtime 前端分流。 - 影响范围:`CONTEXT.md`、拼消消 PRD / 技术方案、平台玩法链路文档、`shared-contracts` / `packages/shared``api-server``spacetime-module``spacetime-client`、作品架 / 广场 / 统一作品详情 / runtime 前端分流。
- 验证方式PRD 和技术方案必须覆盖资产槽位、素材工作表风险、切片验证、恢复语义、API 命名空间和验证命令;实现侧至少运行 `npm run spacetime:generate``npm run check:spacetime-schema``npm run check:spacetime-runtime-access``npm run check:server-rs-ddd``npm run typecheck``npm run check:encoding`、相关前端测试和 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml` - 验证方式PRD 和技术方案必须覆盖资产槽位、素材工作表风险、切片验证、恢复语义、API 命名空间和验证命令;实现侧至少运行 `npm run spacetime:generate``npm run check:spacetime-schema``npm run check:spacetime-runtime-access``npm run check:server-rs-ddd``npm run typecheck``npm run check:encoding`、相关前端测试和 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` - 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链 ## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链
- 背景:`Genarrative-Server-Provision``DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。 - 背景:`Genarrative-Server-Provision``DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。
@@ -817,7 +1227,7 @@
- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml``cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz` - 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml``cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``.hermes/shared-memory/pitfalls.md` - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``.hermes/shared-memory/pitfalls.md`
## 2026-05-19 系列素材 n*n 图集抽为 api-server 通用模块 ## 2026-05-19 系列素材 n\*n 图集抽为 api-server 通用模块
- 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。 - 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。
- 决策:`server-rs/crates/api-server/src/generated_asset_sheets.rs` 作为通用系列素材图集模块,`n` 作为必选 `grid_size` 参数;物品名称 prompt 模板与特殊设定 prompt 作为可选输入;模块负责 sheet prompt、`n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造,以及 sheet / item / special prompt 的 base64 元数据持久化。玩法只负责生图 provider、计费、slot 规划、失败回写和把通用切片结果映射回自身 DTO / 草稿 / runtime 字段。 - 决策:`server-rs/crates/api-server/src/generated_asset_sheets.rs` 作为通用系列素材图集模块,`n` 作为必选 `grid_size` 参数;物品名称 prompt 模板与特殊设定 prompt 作为可选输入;模块负责 sheet prompt、`n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造,以及 sheet / item / special prompt 的 base64 元数据持久化。玩法只负责生图 provider、计费、slot 规划、失败回写和把通用切片结果映射回自身 DTO / 草稿 / runtime 字段。
@@ -1448,10 +1858,10 @@
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz` - 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-05-28 跳一跳重设计为 UV图集与长按蓄力 ## 2026-05-28 跳一跳重设计为 5x5图集与弹弓拖拽
- 背景旧跳一跳模板仍保留角色生图、有限路径、score/combo 和 `2x3` 地块图集口径,和当前“俯视角平台跳跃 + 主题生成地块池 + 无限路径”的产品需求不一致。 - 背景旧跳一跳模板仍保留角色生图、有限路径、score/combo 和 `2x3` 地块图集口径,和当前“俯视角平台跳跃 + 主题生成地块池 + 无限路径”的产品需求不一致。
- 决策:`jump-hop` v1 创作端只保留主题输入image2 生成一张 `1024x1536` 竖版图集,按 `3列*6行` 容纳 18 个立方体主题物体 UV 展开包装,每个大单元内部固定 `4列*3行` UV 网并切出 `top/front/right/back/left/bottom` 六张面贴图,后端共持久化 108 张 `256x256` 不透明 PNG。`JumpHopTileAsset.faceAssets` 保存六面贴图,历史 `imageSrc/imageObjectKey/assetObjectId` 写 top 面作为旧单贴图 fallback旧作品没有 `faceAssets` 时运行态仍可把单张贴图应用到立方体所有面。角色不再单独生图,运行态使用陶泥儿 logo 透明 PNG 角色;运行态输入为按蓄力、松手起跳,前端提交蓄力值,后端始终沿当前地块中心到下一块地块中心方向裁决真实落点;`dragVectorX/dragVectorY` 仅作为旧客户端兼容字段保留且不参与裁决。草稿试玩必须使用 `runtimeMode=draft`,正式作品使用 `runtimeMode=published`;排行榜按作品维度每玩家只保留 1 条最佳记录,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。 - 决策:`jump-hop` v1 创作端只保留主题输入image2 生成一张 `5x5`、共 25 个 2D 地块图标的图集,后端按均匀网格切出 25 个 `JumpHopTileAsset`。角色不再单独生图,运行态使用陶泥儿 logo 透明 PNG 角色;运行态输入为按住后拉蓄力、松手反向弹出,前端提交 `chargeMs + dragVectorX + dragVectorY`,后端裁决落点。草稿试玩必须使用 `runtimeMode=draft`,正式作品使用 `runtimeMode=published`;排行榜按作品维度每玩家只保留 1 条最佳记录,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
- 决策补充:跳一跳创作入口的事实源仍是 SpacetimeDB `creation_entry_type_config`。默认种子和旧默认行都必须同步迁移到 `subtitle=主题驱动平台跳跃``image_src=/creation-type-references/jump-hop.webp`;后端只在系统默认旧值命中时自动纠偏,避免覆盖后台手动配置。 - 决策补充:跳一跳创作入口的事实源仍是 SpacetimeDB `creation_entry_type_config`。默认种子和旧默认行都必须同步迁移到 `subtitle=主题驱动平台跳跃``image_src=/creation-type-references/jump-hop.webp`;后端只在系统默认旧值命中时自动纠偏,避免覆盖后台手动配置。
- 影响范围:`jump-hop` PRD、`api-server` 生成编排、`module-jump-hop` 领域规则、`spacetime-module` / `spacetime-client` 跳一跳契约、前端工作台 / 结果页 / runtime / 平台壳调用链。 - 影响范围:`jump-hop` PRD、`api-server` 生成编排、`module-jump-hop` 领域规则、`spacetime-module` / `spacetime-client` 跳一跳契约、前端工作台 / 结果页 / runtime / 平台壳调用链。
- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml``cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试。 - 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml``cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试。
@@ -1465,10 +1875,10 @@
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx``cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture` - 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx``cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-02 跳一跳飞行动画缓冲与真实落点展示 ## 2026-06-02 跳一跳起跳距离减半并加入飞行动画缓冲
- 背景:用户反馈长按蓄力版本的跳跃手感偏硬,成功后角色容易被吸回地块中心,且后端回包或相机推进时会出现飞过很远再瞬间拉回的闪现 - 背景:用户反馈当前跳跃到目标位置需要拖得太远,且松手后缺少角色翻腾到目标地块的过渡动画,导致跳跃手感偏硬
- 决策:`jump-hop` 当前长按蓄力统一使用 `chargeToDistanceRatio=0.004`,相同蓄力时间的世界跳跃距离比上一轮 `0.008` 降低一半。前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测真实落点后若新 run 尚未返回,必须停在预测真实落点等待。成功落地后角色位置必须保留 `lastJump.landedX/landedY` 映射出的真实偏移,不得吸附回目标地块中心。相机推进以旧窗口真实落点和新窗口真实落点为锚点,使用`1440ms` 过渡推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。 - 决策:`jump-hop` `chargeToDistanceRatio` 统一从 `0.004` 提升到 `0.008`,让同等跳跃距离所需拖动距离减半;前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测落点后若新 run 尚未返回,必须停在预测落点等待,再进入`1440ms` 的相机层推进过渡推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs``src/services/jump-hop/jumpHopRuntimeModel.ts``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。 - 影响范围:`server-rs/crates/module-jump-hop/src/application.rs``src/services/jump-hop/jumpHopRuntimeModel.ts``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx``cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture``npm run check:encoding` - 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx``cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture``npm run check:encoding`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
@@ -1476,7 +1886,7 @@
## 2026-06-03 跳一跳角色形象改为陶泥儿 logo 透明 PNG ## 2026-06-03 跳一跳角色形象改为陶泥儿 logo 透明 PNG
- 背景:跳一跳运行态此前仍使用旧内置 / CSS 角色形象,和用户要求的陶泥儿 logo 角色不一致,也容易和 DOM 地块层出现遮挡层级问题。 - 背景:跳一跳运行态此前仍使用旧内置 / CSS 角色形象,和用户要求的陶泥儿 logo 角色不一致,也容易和 DOM 地块层出现遮挡层级问题。
- 决策:`jump-hop` v1 不再渲染内置 3D 角色几何体;运行态和结果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处理为透明 PNG 后接入。蓄力时角色只做垂直压缩,落地后保留真实落点并轻量回弹`characterAsset` 继续仅作为历史兼容描述字段,不能重新打开角色生图槽或把角色图片作为创作者可配置输入。 - 决策:`jump-hop` v1 不再渲染内置 3D 角色几何体;运行态和结果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处理为透明 PNG 后接入。蓄力时角色沿拖拽方向明显拉长,落地后向反方向回弹两次`characterAsset` 继续仅作为历史兼容描述字段,不能重新打开角色生图槽或把角色图片作为创作者可配置输入。
- 影响范围:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/components/jump-hop-result/JumpHopResultView.tsx`、跳一跳 PRD 和平台链路文档。 - 影响范围:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/components/jump-hop-result/JumpHopResultView.tsx`、跳一跳 PRD 和平台链路文档。
- 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。 - 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
@@ -1721,6 +2131,33 @@
- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author``npm run test -- scripts/rebind-orphan-work-owners.test.ts` - 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author``npm run test -- scripts/rebind-orphan-work-owners.test.ts`
- 关联文档:`server-rs/crates/module-auth/src/domain.rs``server-rs/crates/module-auth/src/lib.rs``server-rs/crates/api-server/src/work_author.rs``scripts/rebind-orphan-work-owners.mjs` - 关联文档:`server-rs/crates/module-auth/src/domain.rs``server-rs/crates/module-auth/src/lib.rs``server-rs/crates/api-server/src/work_author.rs``scripts/rebind-orphan-work-owners.mjs`
## 2026-06-11 前端组件收口补记
- 背景:个人中心 profile 弹层已抽成独立组件,但 `error / loading / empty / content` 仍在多个 modal 中重复分支,继续沿业务页各写一套会让后续 profile 面板收口越来越碎。
- 决策:新增 `src/components/common/PlatformAsyncStatePanel.tsx` 作为互斥异步状态骨架,只承接 `errorState / loadingState / emptyState / children` 四类 slot 的优先级切换;`PlatformProfileWalletLedgerModal.tsx``PlatformProfileTaskCenterModal.tsx``PlatformProfileRechargeModal.tsx``PlatformProfilePlayedWorksModal.tsx``PlatformProfileReferralModal.tsx` 已接入。若错误或成功提示需要与内容并存,继续留在业务组件外层,不把 `PlatformAsyncStatePanel` 扩成全能状态机。
- 决策:`src/components/common/PlatformSegmentedTabs.tsx` 支持 `layout="scroll"`,用于横向可滚动 tab rail`CustomWorldCreationStartCard.tsx``CustomWorldWorkTabs.tsx` 以及 `RpgEntryHomeView.tsx` 的排行 / 分类筛选已接入。共享组件先负责 tab 语义、滚动容器和基础交互;当同一类皮肤在首页、作品架、分类筛选或个人中心中重复出现时,沉淀到 `src/components/common/PlatformSegmentedTabPresets.tsx` 的薄 preset业务页不再重复复制长 `itemClassName`
- 决策:`src/components/PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层统一复用 `src/components/common/PlatformModalCloseButton.tsx``variant="pixel"`;共享 close button 现在负责 `absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 点击拦截,业务 importer 不再各自维护像素风关闭按钮壳和冒泡控制。
- 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail`CustomWorldEntityCatalog.tsx``RESULT_TABS` sticky rail以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`;首页 / 创作入口 / 作品架 / 个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤走 `PlatformSegmentedTabPresets`。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。
- 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx``Match3DCreationWorkspace.tsx``Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx``RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。
- 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页重复的白底 portal 弹窗壳层收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 支持可见标题动态、可访问名称固定的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。
- 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。
- 决策:`PlatformAsyncStatePanel` 从 profile modal 扩展到作品架类白底 panel`CustomWorldCreationHub.tsx` 的作品架主体现在也统一走 `loadingState / emptyState / children` 三段 slot但 error + 重试继续留在业务层外侧不把共享组件扩成“banner + retry + content”全能状态机。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用这套骨架。
- 决策:`CopyFeedbackButton.tsx``actionSurface` 分支继续收口到 `PlatformActionButton``pill` 分支继续保留 `PlatformPillBadge` 风格;复制反馈按钮不再直接调用 `getPlatformActionButtonClassName` 手拼平台按钮基础 chrome。后续同类“复制状态机 + 平台动作按钮”组合优先直接复用 `CopyFeedbackButton`不要在业务页重新混写图标、文案、aria 和动作按钮 class。
- 决策:白底 / 暗色面板里的轻量空态和普通 CTA 继续向共享组件收口。`PuzzleResultView.tsx` 的缺草稿提示、`RpgCreationAssetDebugPanel.tsx` 的空诊断提示、`VisualNovelEntityGrid` 的空实体列表、`AccountModal.tsx` 里账号安全分区的“无安全限制 / 无登录设备 / 无操作记录”以及 `LoginScreen.tsx` 的“当前登录入口暂不可用”都改为 `PlatformEmptyState``Match3DResultView.tsx` 的引用素材列表直接复用 `PlatformAssetPickerGrid` 自己的空态;`AdventureEntityModal.tsx` 的私聊按钮、`InventoryPanel.tsx` 的锻造 / 合成按钮、`RpgCreationRoleAssetStudioModalImpl.tsx``RpgCreationEntityEditorShared.tsx` 里的局部 `ActionButton` 包装层,以及 `RpgAdventurePanel.tsx` / `RpgAdventurePanelOverlays.tsx` 里标准 runtime CTA 都改为委托 `PlatformActionButton surface="editorDark"`。后续白底子面板里的只读空态优先使用 `PlatformEmptyState surface="subpanel"`;暗色编辑 / 运行面板里的普通动作优先使用 `PlatformActionButton surface="editorDark"`,若业务仍需 `stopPropagation`、tone 映射、运行态 icon 排版或局部字号,可保留薄包装层,但不要再直接写原生 `<button>` 基础 chrome。
- 决策:白底 / 浅色结果页和工作台顶部的“左箭头 + 返回文案”轻量返回入口统一收口到 `src/components/common/PlatformBackActionButton.tsx`;共享组件固定承接 `PlatformActionButton tone="ghost" size="xs"` 上的返回按钮骨架,并只开放 `compact / regular` 两档尺寸,分别覆盖紧凑结果页 header 与标准白底结果页顶栏。当前已覆盖 `PuzzleResultView.tsx``SquareHoleResultView.tsx``Match3DResultView.tsx``VisualNovelResultView.tsx``PuzzleClearResultView.tsx``JumpHopResultView.tsx``WoodenFishResultView.tsx``BabyObjectMatchResultView.tsx`;暖色生成页继续走 `GenerationHeaderBackButton``BigFishResultView.tsx` 这类 dark hero / 强品牌返回入口继续走 `PlatformIconButton darkMini`,不把三条视觉语义线硬并成一个组件。
- 决策:`CustomWorldNpcVisualEditor.tsx` 的本地 `ActionButton``SkillEffectPreview.tsx` 的“重新预览”按钮也继续并入这条暗色按钮收口线,统一委托 `PlatformActionButton surface="editorDark"`;局部包装层只保留 `stopPropagation`、图标排布、`tone` 映射和极少量视觉微调。后续暗色编辑器里的局部动作按钮若只是普通 CTA不再新增原生 `<button>` 实现,优先沿用“薄包装 + 共享按钮本体”模式。
- 决策RPG 创作侧标准 dark header / footer 动作也继续纳入同一条按钮收口线。`RpgCreationRoleAssetStudioModalImpl.tsx` 的 header“关闭”、`RpgCreationEntityEditorShared.tsx` 的 footer“取消”以及 `RpgCreationRoleAssetStudioFooter.tsx` 的“保存到当前角色”都改为委托 `PlatformActionButton surface="editorDark"`;局部壳层只保留布局、宽度/字号贴合和少量 tone 语义,不再为标准 dark close / cancel / save CTA 单独维护原生 `<button>` 基础 chrome。
- 决策RPG runtime overlay 里的标准 dark CTA 和可点击 dark row 也继续纳入这条收口线。`RpgAdventurePanelOverlays.tsx` 的 goal panel“知道了”、任务详情里的“领取任务 / 返回交付”、任务完成提示里的“打开任务日志”都改为委托 `PlatformActionButton surface="editorDark"`;设置面板里的“运行统计”入口改为 `PlatformSubpanel as="button" surface="dark"`。像素风 choice button、HUD launcher、奖励物品格和输入 composer 保持 runtime 专属语义,不继续硬并到普通平台按钮。
- 决策:`PlatformToolModalShell` 继续承接 RPG 结果页发布检查弹窗;`RpgCreationResultActionBar.tsx` 只保留发布检查、封面预览、封面设置和发布动作语义,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close、body/footer spacing 和遮罩关闭逻辑。后续结果页 / 工具页里同形态的白底 portal 弹窗优先迁移到 `PlatformToolModalShell`;编辑器大壳、暗色 runtime overlay 和需要专属布局的面板继续保留局部 shell。
- 决策:`PlatformToolModalShell` 继续承接方洞结果页图片槽弹窗;`SquareHoleResultView.tsx` 的封面 / 背景 / 形状 / 洞口图片查看与历史选择弹窗只保留当前图、上传、AI 生成和历史素材选择语义,不再直接维护 `createPortal`、主题 overlay、白底 remap panel、header close 和滚动 body。该弹窗使用 `ariaLabel` 保持“封面图查看 / 背景图查看”等固定可访问名称,历史生成区继续由 `PlatformAssetPickerGrid` 承接读取、错误和空态。
- 决策:`PlatformToolModalShell` 继续承接视觉小说结果页素材选择弹窗;`VisualNovelAssetPickerDialog` 只保留本地上传、AI 图片生成、历史素材读取、错误提示和素材选择回调,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close 和滚动 body。视觉小说音频生成弹窗需要保留生成中禁止关闭实体编辑器弹窗需要保留编辑 footer后续逐个迁移并补对应交互测试。
- 决策:认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该壳层只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx``RegistrationInviteModal.tsx` 只保留各自表单状态和提交流程。
- 决策:账号弹窗可以继续复用 `PlatformAuthModalShell` 的平台主题 overlay 与 auth card 壳层,但通过 `overlaySpacing``overlayStyle``showHeader` 和尺寸透传保留账号 direct mode 的唯一 dialog 语义与 safe-area 布局,不把账号安全详情、换绑手机号或修改密码子面板并进登录表单语义。
- 决策:运行态弹窗先按玩法目录沉淀薄壳,只有跨玩法接口真正稳定后才上升到 `common/`。拼图运行态用 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx` 承接道具确认、设置、退出改造、失败和通关结算的 overlay / dialog / footer / button 骨架;抓大鹅和跳一跳结算分别保留在各自 runtime shell 内抽本地 settlement shell / summary / actions。`PlatformToolModalShell` 继续只服务平台白底工具弹窗,不强塞到像素风或游戏运行态 overlay拖拽 ghost、飞行动画、原图查看和全屏 runtime 容器不按旧 modal 债务处理。
- 决策NPC dark modal footer 和暗色明细空态也继续纳入同一条收口线。`NpcModals.tsx` 里的交易 / 赠礼 / 招募弹窗 footer 按钮和物品详情“关闭”按钮都改为委托 `PlatformActionButton surface="editorDark"`,交易右侧“请选择一件物品”提示改为 `PlatformEmptyState surface="editorDark"``CharacterInfoShared.tsx``BuildContributionDetailPanel` 空明细也改为 `PlatformEmptyState surface="editorDark"`。数量 stepper、赠礼 / 招募 option card、标签强度按钮这类带独立业务语义的控件继续保留局部实现。
- 决策:详情页头部动作组合统一收口到 `src/components/common/PlatformDetailTopbar.tsx``src/components/common/PlatformDetailShareActions.tsx``PlatformDetailTopbar` 只负责返回按钮、标题居中槽位和右侧动作槽位的布局,可在 `pill` / `icon` 返回入口之间切换;`PlatformDetailShareActions` 只负责“前置 badge 区块 + 作品号复制 + 分享复制”这组稳定动作,并允许按页面关闭复制或分享其中一项。`RpgEntryWorldDetailView.tsx` 已接入 overlay 版完整动作组,`PlatformWorkDetailView.tsx` 已接入 icon topbar 与 solid 版作品号复制动作,同时继续保留公开详情页自己的顶部 icon 分享入口和分享反馈提示。后续详情页若只是复用返回、标题、作品号复制或分享动作排列,优先组合这两个薄组件,不把作者、摘要、封面、轮播或业务 CTA 塞进共享配置对象。
- 验证方式:`npm run test -- src/components/common/PlatformAsyncStatePanel.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/common/PlatformSegmentedTabs.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx``npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/PixelCloseButton.test.tsx src/components/CharacterChatModal.test.tsx src/components/MapModal.test.tsx``npm run test -- src/components/common/useMudPointConfirmController.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx``npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/AdventureEntityModal.test.tsx src/components/InventoryPanel.test.tsx src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/auth/AccountModal.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径 ## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。 - 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
@@ -1746,6 +2183,14 @@
- 验证方式:后台修改 `mudPointCost` 后保存,`GET /api/creation-entry/config` 返回同名数字字段;底部加号创作入口卡显示前端格式化后的泥点消耗;创作表单泥点不足提示和后端实际钱包扣费都使用该数字;关闭态卡片仍只显示 `暂未开放` - 验证方式:后台修改 `mudPointCost` 后保存,`GET /api/creation-entry/config` 返回同名数字字段;底部加号创作入口卡显示前端格式化后的泥点消耗;创作表单泥点不足提示和后端实际钱包扣费都使用该数字;关闭态卡片仍只显示 `暂未开放`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-11 拼图与拼消消运行态剩余阻断层继续局部收口
- 背景:账号弹窗、拼图 runtime、抓大鹅结算、跳一跳结算和拼图 onboarding 收口后,允许范围内仍剩拼图“正在准备下一关”阻断层与拼消消 runtime 的等待 / 结算层各自手写 overlay它们结构相近但又都带着玩法本地语义。
- 决策:平台入口里的拼图“正在准备下一关”只在 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 下新增 `PuzzleRuntimeBlockingOverlay.tsx` 做本地薄壳,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板上推到 `common/`。拼消消 runtime 则在 `src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 内新增 `PuzzleClearRuntimeOverlayShell``PuzzleClearRuntimePendingOverlay``PuzzleClearRuntimeSettlementDialog`,统一 `!activeRun``level_cleared``finished``level_failed` 三类局部 overlay 的结构和动作出口。拖拽 ghost、swap flight、补牌 / 消除动画和全屏 runtime 容器继续视为玩法专属视觉层,不算旧 modal 债务。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、相关测试与 PlatformUiKit 收口文档。
- 验证方式:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md`
## 2026-05-31 拼消消底图 prompt 与 atlas 切片提示词收口 ## 2026-05-31 拼消消底图 prompt 与 atlas 切片提示词收口
- 背景:拼消消生成资产检查时,用户需要区分主题词、场地底图主题词和复合图 atlas prompt 的职责;若小图案显式画出切分线或边框,运行态 1x1 切片会显得像错误素材。 - 背景:拼消消生成资产检查时,用户需要区分主题词、场地底图主题词和复合图 atlas prompt 的职责;若小图案显式画出切分线或边框,运行态 1x1 切片会显得像错误素材。

View File

@@ -232,6 +232,14 @@
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。 - 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 内嵌泥点确认弹窗必须自带平台主题作用域
- 现象:拼图 / 抓大鹅统一创作页点击生成后,“确认消耗泥点”弹窗正文和按钮存在,但弹窗面板背景透明,只剩遮罩和文字。
- 原因:`PlatformMudPointConfirmDialog` 作为二级确认常以 `portal={false}` 内嵌到工作台局部 DOM局部节点不一定继承 `.platform-theme``platform-modal-shell` 依赖 `--platform-modal-fill` 等主题变量,变量缺失时面板底色解析为空。
- 处理:共享泥点确认弹窗默认在 overlay 上带 `platform-theme platform-theme--<theme>``platform-modal-backdrop` 和实色遮罩,在 panel 上带 `platform-modal-shell platform-remap-surface`;单按钮状态弹窗也要有默认 light 主题,避免未来独立调用复现。
- 验证:浏览器触发 `/creation/puzzle``/creation/match3d` 的泥点确认弹窗,检查 overlay 最近主题 class 存在、`--platform-modal-fill` 有值且面板为实底;聚焦测试覆盖默认 overlay / panel class。
- 关联:`src/components/common/PlatformMudPointConfirmDialog.tsx``src/components/common/PlatformStatusDialog.tsx``src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx``src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx`
## 玩法入口分类字段缺失要前端兜底 ## 玩法入口分类字段缺失要前端兜底
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel``trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。 - 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel``trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。

View File

@@ -85,6 +85,8 @@ RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/compon
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。 平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。
平台 UI Kit 的提示 / 确认弹窗收口到 `src/components/common/UnifiedConfirmDialog.tsx`,复制反馈收口到 `src/components/common/useCopyFeedback.ts``src/components/common/CopyFeedbackButton.tsx``src/components/common/CopyCodeButton.tsx``src/components/common/CopyFeedbackMessage.tsx`,基础状态提示收口到 `src/components/common/PlatformStatusMessage.tsx`,运行态短错误 / 成功 / 反馈 toast 收口到 `src/components/common/PlatformRuntimeStatusToast.tsx`,平台空态 / 轻量加载态收口到 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮收口到 `src/components/common/PlatformActionButton.tsx`,平台白底子面板 / 小型列表卡片收口到 `src/components/common/PlatformSubpanel.tsx`,平台输入框 / 文本域收口到 `src/components/common/PlatformTextField.tsx`,平台字段标题收口到 `src/components/common/PlatformFieldLabel.tsx`,平台媒体预览框收口到 `src/components/common/PlatformMediaFrame.tsx`,平台胶囊状态标签收口到 `src/components/common/PlatformPillBadge.tsx`,平台 / 个人中心弹窗关闭按钮收口到 `src/components/common/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md)。
平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。 平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。
小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。

View File

@@ -0,0 +1,531 @@
# PlatformUiKit 弹窗与状态组件收口计划
## 背景
前端已经有 `UnifiedModal` 统一遮罩、无障碍属性、Escape 关闭和移动端底部贴边布局,但业务页面仍反复手写提示弹窗、确认弹窗和 footer 按钮。平台入口的泥点提示、作品删除确认、发布失败提示等都在页面实现内拼接同类 `UnifiedModal` 属性和按钮样式,导致后续调整主题、按钮状态或移动端布局时需要改多个页面。
## 收口目标
- `src/components/common/UnifiedModal.tsx` 继续作为底层模态窗口 Module负责遮罩、panel、header、body、footer 与关闭路径。
- 新增 `src/components/common/UnifiedConfirmDialog.tsx` 作为提示 / 确认弹窗 Module统一承载标题、说明、正文、主按钮、副按钮、危险动作、处理中禁用和主题样式。
- 新增 `src/components/common/useCopyFeedback.ts` 作为复制反馈 Module统一承载剪贴板写入、`idle / copied / failed` 状态、定时复位和卸载清理。
- 新增 `src/components/common/CopyFeedbackButton.tsx` 作为复制反馈按钮 Module统一承载默认复制 / 成功图标、反馈文案、`aria-label` / `title`、纯图标按钮模式和胶囊 action 外观入口。
- 新增 `src/components/common/CopyCodeButton.tsx` 作为代码复制按钮 Module统一承载作品号 / 用户号等短代码 chip 的三态文案、默认可访问名称、标题和胶囊 action 外观透传。
- 新增 `src/components/common/CopyFeedbackMessage.tsx` 作为复制反馈提示 Module统一承载成功 / 失败 toast 或行内状态提示。
- 新增 `src/components/common/PlatformStatusMessage.tsx` 作为平台状态提示 Module统一承载错误、成功、信息和警告提示条的基础边框、底色、文字颜色和默认间距。
- 新增 `src/components/common/PlatformEmptyState.tsx` 作为平台空态 / 轻量加载态 Module统一承载作品架、公开广场和素材选择弹窗的空面板外观。
- 新增 `src/components/common/PlatformAssetPickerCard.tsx` 作为平台历史素材选择 Module统一承载历史图片 / 历史素材的缩略图卡片、读取态、错误态、空态和响应式网格外观。
- 新增 `src/components/common/PlatformActionButton.tsx` 作为平台动作按钮 Module统一承载平台按钮、个人中心主动作按钮和暗色编辑 / 运行面板普通动作按钮的样式族、尺寸、圆角、宽度和禁用态 class。
- 新增 `src/components/common/PlatformIconButton.tsx` 作为平台图标动作按钮 Module统一承载普通 icon button / 图标上传 label / 白底短标签浮动图标按钮的可访问名称、默认 `type="button"`、title 和基础外观。
- 新增 `src/components/common/PlatformIconBadge.tsx` 作为平台非交互图标徽章 Module统一承载弹窗标题、列表项和小卡片里的中性图标槽。
- 新增 `src/components/common/PlatformUploadTile.tsx` 作为平台虚线入口 Module统一承载图片 / 附件上传方块和紧凑虚线动作入口的图标、主副文案、button / label 语义和禁用态。
- 新增 `src/components/common/PlatformUploadPreviewCard.tsx` 作为平台上传预览 Module统一承载上传后的缩略图壳、预览图片、右上角移除按钮和禁用态。
- 新增 `src/components/common/PlatformPillSwitch.tsx` 作为平台胶囊开关 Module统一承载图片面板中类似 AI 重绘的 label + switch 语义、轨道、圆点、禁用态和白底浮层 chrome。
- 新增 `src/components/common/PlatformToggleRow.tsx` 作为平台整行开关 Module统一承载设置面板和结果页配置里的白底 label + checkbox / 状态行。
- 新增 `src/components/common/PlatformTextField.tsx` 作为平台输入字段 Module统一承载白底 / 暗色 input、textarea 和下拉框共用 chrome认证表单也只保留受控值、原生属性和业务校验。
- 新增 `src/components/common/PlatformFieldLabel.tsx` 作为平台字段标签 Module统一承载结果页、编辑弹窗和创作工作台中的普通字段名、分区标题、表单字段标题、胶囊字段标题和强调胶囊字段标题。
- 新增 `src/components/common/PlatformSegmentedTabs.tsx` 作为平台分段选择 Module统一承载白底结果页 Tab、编辑弹窗二选一和轻量配置 Tab 的容器、按钮、选中态、禁用态、列数、尺寸和截断标签。
- 新增 `src/components/common/PlatformStatGrid.tsx` 作为平台统计小卡 Module统一承载结果页里的数值 / 标签摘要、轻量状态 chip、响应式列数、密度、surface 和 label/value 顺序。
- 新增 `src/components/common/PlatformPillBadge.tsx` 作为平台胶囊状态标签 Module统一承载结果页、作品卡和配置摘要里的单个状态 / 标签 chip。
- 新增 `src/components/common/PlatformProgressBar.tsx` 作为平台进度条 Module统一承载 `progressbar` 语义、`platform-progress-track` 壳、填充宽度、最小可见宽度、未知进度语义、条内覆盖层和局部主题色。
- 新增 `src/components/common/PlatformInfoBlock.tsx` 作为平台只读信息块 Module统一承载弹窗和详情页中的短标签、白底内容壳、单行 / 多行正文排版。
- 新增 `src/components/common/PlatformReportDialog.tsx` 作为平台可复制报告弹窗 Module统一承载来源 / 状态 / 错误这类字段块展示、报告拼装、复制反馈按钮和标准 footer。
- 新增 `src/components/common/PlatformSubpanel.tsx` 作为平台白底子面板 Module统一承载结果页、创作工作台和普通白底面板内的小型列表卡片里的 `platform-subpanel` / flat 外壳、标题行、右侧动作区、圆角、响应式内边距和交互态。
- 新增 `src/components/common/PlatformMediaFrame.tsx` 作为平台媒体预览框 Module统一承载图片源、fallback 图、fallback 文案、固定比例、surface 和可选 overlay。
- 新增 `src/components/common/PlatformMediaTileGrid.tsx` 作为平台媒体缩略格网格 Module统一承载结果页里同尺寸素材 tile 的列数、间距、白底容器、圆角、边框、图片和 fallback 格。
- 新增 `src/components/common/PlatformTagEditor.tsx` 作为平台标签编辑 Module统一承载结果页里的标签 chip、删除、新增输入、Enter / Escape 键盘行为、空态、可选 AI 生成动作和错误提示。
- 新增 `src/components/common/PlatformModalCloseButton.tsx` 作为平台弹窗关闭按钮 Module统一承载个人中心弹窗和平台浮层关闭按钮的尺寸、圆形视觉、默认图标和可访问名称。
- 新增 `src/components/common/squareImageCropModel.ts` 作为正方形图片裁剪数学 Module统一承载居中初始裁剪、尺寸边界和坐标 clamp头像裁剪和拼图参考图裁剪不再从弹窗组件文件导入 helper。
- 平台页面遇到“知道了”“确认 / 取消”“危险确认”这三类弹窗时,优先使用 `UnifiedConfirmDialog`,不再在业务 JSX 中手写 `UnifiedModal` footer。
- 带复制反馈的弹窗和详情页优先组合使用 `useCopyFeedback``CopyFeedbackButton``CopyFeedbackMessage`,不再重复写 `useState + setTimeout + clearTimeout` 的复制状态机,也不在业务 JSX 中手写 copied / failed 文案分支。
- 白底平台弹窗、详情页、结果页、个人页、认证入口、统一创作工作台和通用创作输入区中的普通错误 / 成功 / 信息 / 警告 / 中性提示条优先使用 `PlatformStatusMessage`,不再在业务 JSX 中重复拼 `border-rose-* / bg-rose-* / text-rose-*``border-emerald-* / bg-emerald-* / text-emerald-*``platform-banner--danger / success / info / warning / neutral` 或个人页 token 色值 class。
- 平台公开列表、作品架、分类列表、素材选择弹窗、RPG 暗色编辑器和 RPG 运行态弹窗 / 面板中的“正在读取 / 暂无内容 / 当前筛选下没有内容 / 还没有配置”等无操作空态优先使用 `PlatformEmptyState`,业务页只传展示内容和必要的 `surface` / `size`,不再重复写 `platform-surface--soft`、虚线空态面板或暗色编辑器 dashed 空态 class。
- 平台弹窗、个人中心弹窗、认证入口、公共确认弹窗 footer、统一创作工作台、创作面板和 RPG 暗色弹窗 / 运行面板中的普通主动作 / 次动作按钮优先使用 `PlatformActionButton`,业务页只传 `surface``tone``size``shape``fullWidth` 和动作回调,不再重复拼 `platform-button` / `platform-primary-button`、暗色按钮边框 / 底色、圆角、px/py、字号和禁用态 class。
- 普通图标动作按钮、图标上传 label 和白底短标签浮动图标按钮优先使用 `PlatformIconButton`,业务页只传 `label``icon`、可选 `children`、可选 `title``asChild="label"` 和局部尺寸 class不再重复手写 `platform-icon-button`、浮动白底按钮、`type="button"` 与 aria。平台浮层、个人中心弹窗和资料面板中只承担“关闭当前弹窗”的圆形图标按钮优先使用 `PlatformModalCloseButton`,业务页只传 `label``onClick` 和必要的 `variant` / `icon`,不再重复手写 `platform-modal-close`、绝对定位白底关闭按钮或关闭按钮 aria。
- 弹窗标题、列表项和小卡片里的非交互中性图标槽优先使用 `PlatformIconBadge`,业务页只传 icon、尺寸和形状不再重复拼 `grid h-* w-* place-items-center bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]`
- 平台表单和结果页中的方形上传入口、紧凑虚线新增入口优先使用 `PlatformUploadTile`,业务页只传 `label``hint`、可选 `icon``size``showLabel``disabled``asChild="label"` 或点击回调,不再重复手写虚线边框、图标、提示文案和 hover / 禁用态 class。上传后的方形图片预览优先使用 `PlatformUploadPreviewCard`,业务页只保留文件读取、预览数组和删除回调,不再重复手写缩略图壳、`object-cover` 图片和右上角移除按钮。
- 特殊内容弹窗仍可直接使用 `UnifiedModal`,但只有在正文需要复杂网格、媒体预览、渠道按钮或运行态专属布局时才保留自定义 footer。
- `UnifiedModal` 补充:平台入口公开编号搜索结果弹层使用 `size="sm"``closeLabel="关闭搜索结果"``closeOnBackdrop={false}`;壳层只保留搜索状态机、命中 / 未命中分支和关闭时清空结果状态,不再手写 overlay、header 和平台 close button 布局。
## 当前接口
- `open`:是否展示弹窗。
- `title` / `description` / `children`:标题、说明和正文。
- `onClose`:关闭弹窗,取消按钮、遮罩和关闭图标共用。
- `confirmLabel` / `onConfirm` / `confirmTone` / `confirmDisabled` / `confirmClassName`:主操作按钮;`confirmClassName` 只用于整行按钮、局部主题等外观适配,不让业务页重新手写 footer。
- `cancelLabel` / `showCancel` / `cancelDisabled`:副操作按钮。
- `busy` / `busyConfirmLabel`:执行中禁用关闭路径,并替换主按钮文案。
- `portal`:默认挂到 `document.body`;已有弹窗栈内的二级确认使用 `portal={false}`,避免脱离当前局部遮罩和层级。
- `variant`:默认 `platform`RPG 编辑器内需要像素风确认时使用 `pixel`,不再为简单确认另写专用壳层和按钮。
- `overlayClassName` / `panelClassName` / `zIndexClassName`:保留主题和层级 Adapter不把主题选择写死在组件内。
- `useCopyFeedback().copyText(value)`:调用统一剪贴板写入并更新反馈状态。
- `useCopyFeedback().copyState`:调用方按 `idle / copied / failed` 渲染文案或图标。
- `useCopyFeedback().resetCopyState()`:业务上下文切换时主动清空旧反馈。
- `CopyFeedbackButton`:接收 `state``idleLabel``copiedLabel``failedLabel`、三态图标、`showIcon``showLabel``labelClassName``accessibleLabel``actionSurface``actionTone``actionSize``actionFullWidth``actionAppearance="pill"``actionPillTone``actionPillSize`文本按钮、chip 按钮和运行态纯图标分享按钮都应走同一 Module。需要平台主按钮外观时通过 `actionSurface="platform"``actionSurface="profile"` 复用 `PlatformActionButton` 样式,不在业务 JSX 中传整串 `platform-button` class需要可点击胶囊复制 / 分享 chip 时用 `actionAppearance="pill"` 复用 `PlatformPillBadge` chrome不在业务 JSX 中传 `platform-pill`
- `CopyCodeButton`:接收 `state``code``codeLabel``copiedSuffix``failedSuffix``codeClassName``suffixClassName``actionAppearance="pill"``actionPillTone``actionPillSize` 和复制按钮透传属性;作品号、用户号等短代码 chip 优先用它,不在业务 JSX 中重复写 `{code} + 已复制 / 复制失败` fragments也不直接传 `platform-pill` class。
- `CopyCodeButton` 补充:作品详情页作品号复制按钮使用 `actionAppearance="pill" actionPillTone="neutralSolid" actionPillSize="sm"`;详情页只保留顶部外边距和复制回调,不再把代码 chip 基础 chrome 写在 `platform-work-detail__code`
- `CopyFeedbackMessage`:接收 `state``copiedLabel``failedLabel`toast 或行内状态只展示成功 / 失败时使用,不在业务页手写三态分支。若场景需要按成功 / 失败切换状态条色值,可在业务壳层继续使用 `useCopyFeedback` 状态机,并组合 `PlatformStatusMessage` 渲染对应 tone。
- `PlatformStatusMessage`:接收 `tone="error" | "success" | "info" | "warning" | "neutral"``surface="light" | "tinted" | "platform" | "profile" | "editorDark"``size="xs" | "sm" | "md"``remapSurface``children``className`;根节点固定带 `platform-status-message` 稳定类名,业务测试可断言公共状态条接入。局部可覆盖圆角、外边距和网格布局,但状态色值、基础内边距和字号由 Module 统一控制。结果页、发布检查、素材生成面板和 creation-agent composer 错误条等需要复用旧 `platform-banner--danger / success / info / warning / neutral` token 外观时使用 `surface="platform"`;需要在局部 platform token 作用域内重映射 CSS 变量的提示条传 `remapSurface`,不在业务 JSX 手写 `platform-remap-surface platform-banner`。个人中心弹窗、认证入口、验证码提示、统一创作工作台和通用创作输入区需要 profile token 外观时使用 `surface="profile"`RPG 暗色编辑 / 运行面板内的普通状态提示使用 `surface="editorDark"`;背包故事档案 QA、NPC 叙事提示、角色聊天错误提示、营地编组战斗中提示、自定义选择弹窗错误 / 生成中提示等暗色状态条已迁移。旧 `platform-profile-error` / `platform-profile-success`、暗色手写 `border-*-300/15 bg-*-500/10 text-*-50/90``platform-banner--danger / success / info / warning / neutral` 不再作为业务 JSX 接口。
- `PlatformStatusMessage` 补充:大鱼吃小鱼结果页发布校验阻断项使用 `tone="warning" surface="platform" size="xs"`;结果页只保留阻断项裁剪和文案,不再手写 amber 文本列表。
- `PlatformStatusMessage` 补充:个人中心邀请弹窗里的邀请奖励说明使用 `tone="warning" surface="profile" size="md"`;弹窗只保留奖励文案和两行排版,不再手写 amber 提示块。
- `PlatformStatusMessage` 补充:拼图首访 onboarding 的输入错误和登录保存错误使用 `surface="editorDark"`onboarding 只保留错误文案和条件渲染,不再手写暗色红色错误条。
- `PlatformStatusMessage` 补充:平台作品详情页分享复制反馈使用 `surface="platform"` 并按 `shareState` 映射 `success / error`;详情页只保留复制状态机和文案,不再为失败态复用成功 toast chrome。
- `PlatformStatusMessage` 补充creative-agent 首页错误提示使用 `tone="error" surface="platform" size="md"`;首页只保留宽度对齐布局 class 和错误文案,不再手写 danger panel chrome。
- `PlatformStatusMessage` 补充:平台入口公开编号搜索未命中结果使用 `tone="neutral" surface="platform" size="md"`;壳层只保留搜索错误文案,不再手写普通文本提示块。
- `PlatformRuntimeStatusToast`:接收 `tone="error" | "success" | "info" | "warning" | "neutral"``surface="light" | "dark" | "solid"``size="xs" | "sm" | "md"``shape="pill" | "rounded"``children``className`;根节点固定带 `platform-runtime-status-toast` 稳定类名,默认按 `tone` 写入 `role="alert/status"``aria-live`。它只承接运行态 HUD 中短错误、成功和反馈 chip 的圆角、字号、阴影、色值和可访问语义,具体浮层位置、玩法资产按钮、计分牌、蓄力提示、强品牌 primary 按钮仍由玩法 runtime 控制。跳一跳、拼图、敲木鱼、方洞和宝贝爱画运行态的短错误 / 成功 / 投放反馈已先迁移;后续同类短 toast 不再手写 `rounded-full bg-white/* text-*`、暗色 `border-rose/emerald bg-*/text-*` 或单玩法 `*-runtime-error-chip`
- `PlatformDarkOptionCard`:接收 `selected``tone="emerald" | "sky" | "rose" | "amber"``radius="sm" | "md" | "lg"``padding="sm" | "md" | "lg"``children``className` 和原生 button props根节点固定带 `platform-dark-option-card` 稳定类名,统一承接 RPG 暗色弹窗 / 面板中的 selected / idle / hover / disabled 可选项卡按钮外观。NPC 交易模式、交易物品行、赠礼候选、招募替换候选、角色素材工作室动作预览格、营地编组替换位按钮和角色聊天建议按钮已先迁移;业务页只保留选中判断、点击回调和内容布局,不再重复手写 `rounded-* border px-3 py-*``border-*-400/* bg-*-500/10``border-white/* bg-black/20 hover:border-white/15`
- `PlatformEmptyState`:接收 `surface="soft" | "dashed" | "subpanel" | "editorDark"``size="compact" | "panel" | "inline"``tone="base" | "soft"``children``className`;根节点固定带 `platform-empty-state` 稳定类名,业务测试可断言公共空态接入。`soft + compact` 用于公开广场、排行和作品架内的轻量空态,`soft + panel` 用于创作中心作品架整块空态,`dashed + panel` 用于素材选择、历史资源等弹窗的大面积空态或读取态,`subpanel + inline` 用于视觉小说 runtime、个人中心充值 / 任务等白底子面板内的无操作空态,`editorDark + compact/inline` 用于 RPG 大编辑器、实体详情弹窗、营地编组、角色聊天和运行态设置弹窗等暗色面板里的纯展示空态 / 禁用提示。组件只承接外观,不内置业务文案。
- `PlatformEmptyState` 补充:个人中心存档弹窗和玩过弹窗里的简单“暂无存档 / 暂无玩过”也使用 `surface="subpanel" size="inline"`;玩过弹窗可通过 `tone="base"` 和局部 `text-left` 保留原有白底列表语境,不在业务 JSX 重复写 `rounded-xl bg-zinc-50 px-4 py-* text-sm`
- `PlatformEmptyState` 补充:个人中心钱包账单弹窗里的“暂无账单记录”使用 `surface="subpanel" size="inline"`;业务组件只保留外边距和纵向留白,不再手写白底空态边框、字号和居中文案。
- `PlatformEmptyState` 补充:个人中心邀请弹窗里的“已填写邀请码 / 暂无成功邀请”使用 `surface="subpanel"`;业务组件保留面板分支和邀请状态机,不再为无操作提示手写白底空态。
- `PlatformEmptyState` 补充creation-agent 聊天区里的“暂无消息”使用 `surface="subpanel" size="compact"`;工作台保留消息列表滚动容器和文案,不再手写居中空态字号、颜色和高度 class。
- `PlatformEmptyState` 补充:大鱼吃小鱼结果页缺少可编辑草稿时使用 `surface="subpanel" size="compact"`;结果页只保留草稿分支和文案,不再为白底无操作提示手写 `PlatformSubpanel` 空面板。
- `PlatformEmptyState` 补充creative-agent 首页抽屉“暂无创作记录”使用 `surface="subpanel" size="inline"`;抽屉只保留分组和历史条目分支,不再手写白底 bordered empty chrome。
- `PlatformEmptyState` 补充creative-agent 工作台消息区“发一句想法,或加一张参考图。”使用 `surface="subpanel" size="compact"`;工作台只保留消息分支和文案,不再为白底空消息面板手写 `PlatformSubpanel` 外壳。
- `PlatformEmptyState` 补充creative-agent 过程面板空态“等待新的创作输入”使用 `surface="subpanel" size="compact"`;过程面板只保留空态分支和文案,非空时继续复用 `PlatformSubpanel` + `PlatformPillBadge` 承接过程列表。
- `PlatformTextField`:接收 `variant="input" | "textarea"``surface="platform" | "editorDark"``size="xs" | "sm" | "md" | "lg"``density="default" | "compact" | "roomy"``tone="warm" | "rose" | "emerald" | "sky"``className` 和原生 input / textarea props统一承接平台白底与 RPG 暗色弹窗里的圆角输入框、文本域、禁用态、密度、字号 / 行高和焦点色,暗色 surface 根节点固定带 `platform-text-field--editor-dark` 稳定类名。`PlatformSelectField` 复用同一套输入 chrome 承接下拉框。业务页继续持有 `value``onChange``aria-label``rows``placeholder``option` 等语义,不再重复拼 `rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3``rounded-[0.85rem] bg-white/90 px-3 py-2``bg-white/90 px-4 py-3`、暗色 `border-white/10 bg-black/30 px-4 py-3``focus:border-* focus:ring-*`。抓大鹅结果页作品信息、封面描述、素材名称和批量物品名称,方洞结果页主信息表单和形状 / 洞口选项字段,拼图 / 敲木鱼结果页作品信息字段,视觉小说结果页的音乐生成、作品信息、开场、运行配置、角色、场景、阶段和世界观普通文本 / 下拉字段,以及视觉小说 / 抓大鹅 / 汪汪声浪 / 宝贝识物 / 拼消消 / 跳一跳创作工作台普通输入字段已先迁移;自定义选择弹窗角色名字 / 背景补充 / 生成模式 / 世界描述和角色聊天草稿等暗色字段使用 `surface="editorDark"`。通用创作图片输入面板的提示词文本域也使用该 Module只通过局部 class 保留高度和底部浮动上传按钮避让。认证图形验证码答案、短信 / 密码登录、重置密码、绑定手机号、邀请码和账号安全表单字段,以及个人中心兑换码 / 邀请码输入使用 `surface="platform"`,业务层只保留认证 / 兑换流程、受控值、原生属性和校验提示。
- `PlatformTextField` 补充:个人中心昵称弹窗输入框使用 `surface="editorDark" size="lg" density="roomy"`,业务组件保留外层原生 `label` / sr-only “新昵称”、`autoFocus``maxLength`、Enter 提交和保存状态;局部 class 只保留暗色弹窗里的 `bg-white/10`、文字色和焦点边框,不再手写 input chrome。
- `PlatformTextField` 补充:`PlatformTagEditor` 内部新增标签输入框使用 `density="compact" size="xs"` 复用同一输入 chrome标签编辑器只保留新增输入状态、解析、Enter / Escape 行为和按钮组合,不再手写输入框边框、白底、字号、焦点色或禁用态。
- `PlatformTextField` 补充creation-agent composer 文本域使用 `variant="textarea" size="md" density="compact"`工作台只保留受控值、禁用条件、Enter / Shift+Enter 行为和局部布局 class不再手写 textarea chrome。
- `PlatformTextField` 补充:拼图首访 onboarding 提示词文本域使用 `variant="textarea" surface="editorDark" density="roomy" size="lg"`onboarding 保留受控输入、生成 / 已生成禁用和沉浸式壳层,不再手写 textarea 基础 chrome。
- `PlatformTextField` 补充:平台反馈页问题描述和联系电话字段使用 `surface="platform"`;反馈页保留外层原生 `label`、受控值、长度限制和透明嵌入式局部 class不再手写 textarea / input 基础语义和重复 chrome。
- `PlatformFieldLabel`:接收 `variant="field" | "section" | "form" | "inlineForm" | "pill" | "accentPill"``children``className``field` 用于视觉小说等结果页的普通字段名,`section` 用于平台白底面板内小标题,`form` 用于创作工作台、通用创作输入面板和认证表单普通字段标题,`inlineForm` 用于模板确认弹层这类行内字段标题,`pill` / `accentPill` 用于汪汪声浪等工作台里的胶囊字段标题。业务页只传字段文案和必要的局部 class不再重复写 `text-xs font-bold text-[var(--platform-text-soft)]``text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]``mb-2 block text-sm font-black``text-sm font-bold text-[var(--platform-text-base)]`、普通胶囊或 rose 强调胶囊 class。视觉小说结果页、抓大鹅结果页作品 / 封面 / 素材字段标题、方洞结果页主信息 / 形状 / 洞口 / 历史图片字段标题、拼图结果页关卡详情 / 发布弹窗字段标题、拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题、大鱼素材弹窗 prompt、RPG 发布弹窗发布检查 / 封面设置、汪汪声浪轻配置编辑器、宝贝识物工作台、通用创作图片输入面板主图 / 提示词标题,以及认证表单中的手机号 / 验证码 / 密码 / 邀请码标题已先迁移。认证和提示词字段继续保留外层原生 `label` 关联,不把可访问命名交给装饰性标题组件。
- `PlatformFieldLabel` 补充:个人中心玩过弹窗内的“可继续 / 玩过”分区标题使用 `variant="section"`;业务组件只传分区文案和 `mb-2 block` 局部布局,不再手写 `text-xs font-black text-zinc-500`
- `PlatformFieldLabel` 补充:个人中心邀请弹窗里的“邀请码 / 成功邀请”小标题使用 `variant="section"`;业务组件只保留必要的居中或深色文本局部 class不再手写同类小标题字体。
- `PlatformFieldLabel` 补充:平台反馈页问题描述和联系电话标题使用 `variant="form"`,并保留外层原生 `label htmlFor` 负责可访问名称;反馈页不再手写字段标题字体和颜色。
- `PlatformFieldLabel` 补充creative-agent 模板确认弹层里的“关卡数”行内标题使用 `variant="inlineForm"`,并继续保留外层原生 `label``PlatformTextField aria-label="计划关卡数"` 的可访问命名;弹层不再手写紧凑行内字段标题字体。
- `PlatformSegmentedTabs`:接收 `items``activeId``onChange``columns="one" | "two" | "three" | "four" | "threeToSix"``gap="sm" | "md"``radius="md" | "lg" | "xl"``size="sm" | "md" | "compact" | "choice" | "tab"``surface="default" | "soft" | "transparent"``tone="neutral" | "warm" | "rose" | "underline"``frame="panel" | "bare"``semantics="segment" | "tabs"``ariaLabel``truncateLabels``disabled``className``itemClassName`;普通分段统一写入 `aria-pressed`Tab 语义统一写入 `role="tablist"` / `role="tab"` / `aria-selected`,并承载禁用阻断、白底选中态、空闲 hover、焦点 ring、响应式列数、裸分段外壳、下划线选中态和 label 截断。拼图结果页、抓大鹅结果页、抓大鹅素材配置、抓大鹅创作 / 结果页难度选择、视觉小说结果页、creative-agent 模板确认弹窗和认证入口短信 / 密码登录切换已先迁移。后续白底结果页 Tab、弹窗分段选择、四选一配置项或认证 / 设置类下划线 Tab 只传选项、当前值和变更回调,不再重复 `grid + border + bg-white/62 + button aria-pressed` 或本地 `role="tab"` 下划线按钮。
- `PlatformStatGrid`:接收 `items``columns="two" | "three" | "four" | "twoToFour"``density="compact" | "default"``order="valueFirst" | "labelFirst"``surface="soft" | "plain"``textAlign="left" | "center"``className``itemClassName`;统一承载平台结果页里的统计小卡、状态 chip、白底摘要卡、label/value 排版和响应式列数。拼消消结果页素材摘要、方洞结果页封面状态 chip、抓大鹅结果页难度摘要、creative-agent 模板确认摘要和自定义世界实体目录世界页档案规模已先迁移,业务页只传统计项数组和少量布局参数,不再重复写 `grid + rounded + bg-white/* + text-xl/text-xs`
- `PlatformPillBadge`:接收 `tone="muted" | "neutral" | "neutralSolid" | "lightOverlay" | "success" | "warning" | "danger" | "cool" | "profile" | "profileAccent" | "darkSoft" | "darkNeutral" | "darkSky" | "darkEmerald" | "darkAmber" | "darkRose"``size="xxs" | "xs" | "sm"`、可选 `icon``children``className` 和原生 span props统一承接单个状态 / 标签 chip 的圆角、边框、底色、字号和图标间距,并通过 `platformPillBadgeModel.ts``getPlatformPillBadgeClassName` 给复制类交互按钮复用同一视觉 chrome。`xxs` 用于实体目录卡片等密集元信息 chip`muted` 用于平台白底柔和选择态和地图节点当前状态,`lightOverlay` 用于主动作按钮内部的泥点消耗等浅色叠层小胶囊,`danger` 用于删除 / 选中危险态,`profile` / `profileAccent` 用于个人中心玫瑰色信息 / 分类 chip`dark*` 用于 RPG 暗色弹窗和角色详情里的纯展示 chip。宝贝识物结果页发布状态、主题标签与占位资源 overlay宝贝识物 / 拼图 / 抓大鹅 / 视觉小说工作台 BETA chip、汪汪声浪轻配置 chip、汪汪声浪结果页草稿 chip、汪汪声浪预览 VS chip、敲木鱼结果页飘字 chip、creative-agent 顶部阶段 / 过程计数 / 条目 meta chip、通用音频输入面板限制标签、自定义世界实体目录批量选择 / 生成中 / 开局 CG / 可扮演角色元信息 badge、RPG 首页公开作品卡 / 搜索结果 / 充值商品 / 移动端创建入口 / 桌面发现区 chip、RPG 世界详情静态元信息 chip、RPG 角色身份 / 等级 / 技能出手方式 / 技能详情与状态标签 / 背景故事解锁状态 / 好感等级 / 角色资产工作室动作状态 / 角色编辑技能动作状态 / 角色资源应用状态 / 场景角色选择状态 / 地标当前连接状态 / 地图节点方向标签 / 地图场景切换方向标签 / 营地编组状态数值 / 作品封面来源状态 / 开局物品标签、NPC 交易数量 / 赠礼好感和背包工坊材料需求等暗色展示 chip、抓大鹅批量新增 / 批量重生成物品名称预览 chip、抓大鹅 / RPG / 拼图 / 方洞结果页自动保存状态、抓大鹅结果页当前难度 badge、拼图结果页关卡生成中 overlay / 列表 badge、大鱼吃小鱼结果页终局 / 关卡元信息 / 发布校验成功 badge、拼图图库详情页题材标签、自定义世界作品卡二级 badge、生成失败 chip以及个人中心泥点账单余额、玩过总时长和玩过作品类型 chip 已先迁移。后续作品卡状态、结果页标签、个人中心轻量信息、按钮内消耗标签和轻量配置 chip 优先使用该 Module多项数值 / 标签摘要仍使用 `PlatformStatGrid`,可交互标签编辑仍使用 `PlatformTagEditor`,可点击复制 / 分享 chip 使用 `CopyCodeButton` / `CopyFeedbackButton actionAppearance="pill"`
- `PlatformPillBadge` 补充:大鱼吃小鱼结果页 hero 顶部的玩法摘要 chip 使用 `tone="lightOverlay"` 并保留局部 `bg-white/10` 覆盖hero 只保留 `coreFun / ecologyTheme / levelCount` 文案,不再手写三段 `rounded-full bg-white/10 px-3 py-1` 静态标签。
- `PlatformPillBadge` 补充RPG 实体编辑器基本设定里的拆分标签也使用 `tone="darkSoft"`;这类标签只表达解析后的静态词条,不把可编辑标签输入、删除按钮或点击选择态塞进静态 badge。
- `PlatformPillBadge` 补充:`tone="neutralSolid"` 承接无强调、无业务状态色的实心中性胶囊;`PlatformToggleRow mode="status"` 的开启 / 关闭状态已改用该 tone。后续只读开关状态或类似轻量状态值优先复用它不在业务 JSX 中重复拼 `rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black`
- `PlatformPillBadge` 补充:平台作品详情页的主题标签使用 `tone="neutralSolid" size="sm"`;详情页只保留标签数组映射,不再手写 `platform-work-detail__chip` 的边框、底色、圆角、字号和内边距。
- `PlatformProgressBar`:接收 `value`、可选 `minVisibleValue``size="xs" | "sm" | "md" | "lg"``ariaLabel``labelledBy``indeterminate``className``fillClassName``fillStyle``trackStyle``children`;内部 clamp 到 0-100并统一写入 `role="progressbar"``aria-valuemin/max/now``platform-progress-track`、填充宽度和最小可见宽度。`children` 仅用于条内倒计时、加载图标等覆盖层;没有准确百分比的脉冲占位条使用 `indeterminate`,避免暴露假的 `aria-valuenow`。creation-agent 主进度 / operation banner、RPG 结果页生成提示、RPG 实体目录生成中提示、开场 CG 生成占位、拼图关卡画面生成进度、生成页当前步骤线性进度、抓大鹅批量物品素材生成进度和自定义世界生成选择弹窗进度提示已先迁移;后续普通平台进度条只传业务进度值、标签关联、局部主题色和必要覆盖内容,不再重复手写 aria、track/fill div 和 `Math.max(...)` 宽度计算。
- `PlatformSubpanel`:接收 `as="section" | "div" | "article" | "aside" | "button"``title``titleVariant="section" | "strong"``actions``interactive``padding="tight" | "row" | "xs" | "sm" | "md" | "lg" | "none"``radius="xs" | "sm" | "md" | "lg" | "xl"``surface="platform" | "flat" | "soft" | "dark" | "darkSky" | "darkEmerald" | "darkAmber" | "darkRose" | "danger"``className``headerClassName``titleClassName``actionsClassName``bodyClassName``children`;静态 element 透传 `aria-*``data-*` 等原生属性,`as="button"` 时透传普通 button 属性并默认 `type="button"`。Module 统一承接平台结果页 / 工作台 / 个人中心子面板外壳、`PlatformFieldLabel variant="section"` 标题、强标题、右侧动作区、内容容器和普通白底列表卡片的 hover / focus / disabled 交互态。`surface="platform"` 复用 `platform-subpanel` token`surface="soft" + padding="tight"` 用于标签编辑新增输入行等白底柔和紧凑行,`surface="soft" + padding="row"` 用于上传预览横向已选素材条等白底柔和横向行;`surface="danger"` 用于整卡危险选中态;`radius="xl" + padding="lg"` 用于方洞等更大圆角的标准结果页面板;`surface="platform" + radius="xl" + padding="none"` 用于只需要公共边框 / 背景 / 大圆角且内部自带固定比例内容的静态封面壳,`surface="platform" + radius="xl" + padding="sm"` 可用局部 `sm:p-5` 保留物品详情类响应式内容面板;`surface="flat" + radius="sm" + padding="sm"` 用于素材 / 音频 / 排行榜 / 选项编辑 / 局部进度状态等小型白底卡片,`surface="flat" + radius="sm" + padding="none"` 仅用于只包已有图片、图集、角色或路径预览且不需要 fallback / overlay 的白底壳需要图片源、fallback、固定比例或 overlay 时优先使用 `PlatformMediaFrame`。需要整卡点击或缩略图点击时组合 `as="button" interactive`。拼图结果页作品名称 / 描述 / 标签编辑 / 智能修订条 / 关卡卡片、拼图图库详情页封面轮播壳 / 题材标签 / 关卡摘要、敲木鱼结果页主预览面板 / 作品标题 / 简介 / 主题标签 / 飘字 / 音效、敲木鱼工作台功德词条面板、跳一跳结果页预览 / 操作面板 / 排行榜 / 轻量媒体壳、拼消消创作工作台左侧表单面板、拼消消结果页预览 / 统计 / 操作面板 / 轻量媒体壳、方洞结果页封面 / 主信息 / 形状 / 洞口标准面板、方洞形状 / 洞口选项卡与缩略图按钮、RPG 结果页开发资产诊断摘要 / 条目 / 空态、RPG 个人中心未登录提示、通用音频输入面板、视觉小说创作工作台画风选择面板、视觉小说结果页素材 / 音频小面板、视觉小说结果页作品 / 开场 / 运行配置 / 世界观标准编辑面板、视觉小说 runtime 历史条目 / 存档列表、抓大鹅创作工作台难度小面板、抓大鹅结果页作品 / 难度 / 统计 / UI素材预览标准面板 / 当前难度摘要小卡 / 物品详情五视角面板 / 物品图集分组卡 / 批量素材生成进度卡、汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡、平台反馈页问题描述 / 上传凭证 / 联系方式区块、自定义世界实体目录世界基调 / 角色维度 / 基本设定条目 / 场景幕级缩略图 / 目录卡片媒体壳 / 目录卡片整卡壳、创作中心作品架加载骨架卡,以及 creative-agent 工作台目录 / 目标就绪 / 空消息 / 过程 / 关卡计划 / 关卡计划小卡 / 模板确认理由面板已先迁移;`PlatformTagEditor` 内部新增输入行使用 `surface="soft" padding="tight"``PlatformUploadPreviewCard layout="inline"` 内部横向已选素材条使用 `surface="soft" padding="row"`。后续同类白底面板、白底轻量媒体壳或白底交互列表卡片只传标题、动作、内容、可访问属性和点击回调,不再重复写 `platform-subpanel rounded-[1.25rem] p-4``rounded-[1.35rem] p-4 sm:p-5``platform-subpanel rounded-[1.5rem] p-4 sm:p-5``rounded-[1.5rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]``rounded-[1rem] border ... bg-white/72 p-3``rounded-[1rem] border ... bg-white/68 p-2``rounded-[1rem] border ... bg-white/68 px-3 py-2``rounded-[1.1rem] border ... bg-white/58 p-3``rounded-[1rem] border ... bg-white/80``hover:bg-white disabled:cursor-not-allowed disabled:opacity-55`、标题行 flex 和 `text-xs font-bold tracking-[0.18em]`
- `PlatformSubpanel` 补充:个人中心玩过弹窗里的已玩作品按钮卡使用 `as="button" surface="flat" radius="sm" padding="md" interactive`,业务组件只保留作品标题 / 副标题 / 类型胶囊 / 作品号 / 最近游玩 / 时长内容和粉色 hover 边框,不再手写白底按钮卡 chrome。
- `PlatformSubpanel` 补充:平台入口壳纯 Suspense fallback、作品详情读取 / 错误提示和 Agent 工作区恢复提示使用 `radius="sm" padding="none"` 承接原 `platform-subpanel` 外壳,业务层只保留居中布局、提示文案和局部内边距;生成结果恢复面板使用 `radius="xl" padding="none"` 保留恢复动作与固定内容间距。玩法 runtime overlay 仍保留专用层级语义,后续单独评估。
- `PlatformSubpanel` 补充:平台入口公开编号搜索命中用户卡使用 `surface="flat" radius="sm" padding="md"``titleVariant="strong"`;壳层只保留用户摘要字段和关闭分支,不再手写白底 bordered 搜索结果卡。
- `PlatformSubpanel` 补充RPG runtime 主阶段路由里的平台首页、角色选择和冒险面板懒加载提示使用 `radius="sm" padding="none"` 承接原 `platform-subpanel` 外壳;路由器只保留 Suspense 分流和提示文案,运行态 HUD / overlay 继续保留专用层级语义。
- `PlatformSubpanel` 补充:个人中心钱包账单行使用 `as="div" surface="flat" radius="xs" padding="none"`,业务组件只保留来源、时间、收入 / 支出色值、余额右对齐和局部 `px-3 py-3 shadow-sm`;后续同类白底数据行优先从该组合扩展。
- `PlatformSubpanel` 补充:个人中心邀请弹窗里的社区二维码卡、邀请码展示卡、成功邀请容器和邀请用户行使用 `surface="flat" | "soft"` 的白底子面板;复制按钮、奖励说明卡和弹窗状态机不并入本轮。
- `PlatformSubpanel` 补充:个人中心任务中心里的任务条目使用 `radius="sm" padding="md"` 承接原 `platform-subpanel` 外壳;业务组件只保留任务标题、进度、奖励、状态和领取按钮逻辑。
- `PlatformSubpanel` 补充:个人中心充值弹窗里的微信 Native 支付二维码确认面板使用 `radius="sm" padding="md"`;业务组件保留二维码生成、扫码提示和确认支付按钮,不再手写 `platform-subpanel` 外壳。
- `PlatformSubpanel` 补充:个人中心充值弹窗里的商品整卡按钮使用 `as="button" interactive radius="sm" padding="none"`;业务组件只保留商品标题、金额、角标、购买状态和下单回调,不再手写 `platform-subpanel` 按钮壳、hover、focus 或 disabled chrome。
- `PlatformSubpanel` 补充:发布分享弹窗里的渠道 tile 按钮使用 `as="button" surface="flat" radius="sm" padding="tight" interactive`;弹窗只保留渠道枚举、品牌图标和复制分享文本回调,不再手写白底 tile 的圆角、边框、底色、hover 或 focus chrome。
- `PlatformSubpanel` 补充:平台入口创作类型弹层里的玩法卡片使用 `as="button" surface="platform" radius="xl" padding="none"`;卡片只保留玩法图片、锁定态、标题、副标题和选择分流,外层按钮语义、标准圆角和已开放卡 hover / focus chrome 由公共子面板承接。
- `PlatformSubpanel` 补充creation-agent 工作台聊天区外壳使用 `radius="xl" padding="none"`;工作台只保留消息列表、引用图预览、错误提示和输入区语义,不再手写聊天面板外层圆角、边框和底色。
- `PlatformSubpanel` 补充:当前 Interface 额外支持 `padding="xs"``radius="xs"``surface="dark"`,用于 RPG 暗色编辑器 / 运行态里的非交互小信息卡。任务目标、区域、进度、描述、角色维度、角色形象状态、自定义选择弹窗当前角色、地图场景切换当前 / 前往摘要、营地编组分区、同行者卡、营地气氛小卡、角色聊天状态和聊天总结这类只展示信息的小卡走该组合;暗色 HUD、动作按钮、可点击卡片和强玩法品牌面板仍保留业务布局。
- `PlatformSubpanel` 补充:当前 Interface 额外支持 `surface="darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`,用于 RPG 暗色编辑器 / 运行态中带业务色强调的结构化信息面板;实体详情私聊提示、队友收束、玩家等级进度、角色面板等级 / 收束状态、任务奖励好感度 / 货币 / 经验数值卡、RPG 大编辑器上传封面中提示、地图场景切换目标场景面板,以及 `CharacterInfoShared.MultiplierContributionList` 状态标签外壳已迁移,后续同类 sky / emerald / amber / rose 暗色信息壳不再手写 `border-*-400/18 bg-*-500/8`
- `PlatformSubpanel` 补充RPG 大编辑器里的标题型暗色信息块通过本地 `EditorInfoPanel` 适配到 `surface="dark" radius="md" padding="md"`;场景幕角色槽位的当前角色 / 可选角色面板、幕背景预览面板和预设背景面板已迁移。业务 JSX 只保留标题、内容和局部 grid不再重复拼 `rounded-2xl border border-white/8 bg-black/20 px-4 py-4`
- `PlatformSubpanel` 补充RPG 队伍面板和实体详情弹窗中的构筑标签效果详情统一由 `CharacterInfoShared.BuildContributionDetailPanel` 承接,标签概览、属性加成明细和无属性明细提示都组合 `surface="dark"` 的公共子面板;业务弹窗只传选中标签行和属性 rows不再重复手写同一段标签效果 JSX 或 `rounded-2xl border border-white/8 bg-black/20 p-4` / `rounded-xl border border-white/8 bg-black/25 px-4 py-3` 暗色面板 chrome。
- `PlatformSubpanel` 补充:实体详情弹窗的技能预览 fallback、伤害 / 法力 / 冷却 / 距离数值卡、技能说明和附带状态标签区使用 `surface="dark" radius="xs"`;实体详情只保留技能数值、文案和状态标签数据,不再重复手写 `rounded-xl border border-white/8 bg-black/20 px-* py-*` 暗色小卡。
- `PlatformSubpanel` 补充:宝贝识物工作台玩法预览卡使用 `surface="soft" radius="md" padding="md"`,只通过局部 `className` 保留玩法渐变和装饰层;工作台不再直接手写该类静态白底柔和卡片的边框、圆角和内边距。
- `PlatformSubpanel` 补充creation-agent 无 session / 加载提示块使用 `radius="sm" padding="lg"` 承接普通居中提示面板;工作台只传提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4`
- `PlatformSubpanel` 补充:拼图结果页空草稿提示块使用 `radius="sm" padding="lg"` 承接普通居中提示面板;结果页只传提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4`
- `PlatformMediaFrame`:接收 `src``fallbackSrc``alt``fallbackLabel``fallbackContent``aspect="auto" | "square" | "standard" | "landscape" | "wide" | "portrait" | "video"``surface="warm" | "editorDark" | "plain" | "soft" | "bright" | "none" | "bare"``loading``refreshKey``imageClassName``imageProps``className``fallbackShellClassName``fallbackClassName``previewOverlay``overlayInteractive``children` 和原生 `div``aria-*` / `data-*` 等属性;内部使用 `ResolvedAssetImage`统一承接图片换签、fallback 图、无图 fallback 文案 / 自定义占位内容、fallback 外壳局部着色、固定比例、圆角、surface 背景和绝对定位 overlay。`standard` 用于 4:3 关卡 / 封面预览,`wide` 用于 9:5 宽图候选预览,`portrait` 用于 9:16 竖版场地底图 / 海报类资产,`soft` 用于由媒体框自身承接 `border border-[var(--platform-subpanel-border)] bg-white/68` 的白底柔和预览,`bright` 用于素材缩略图等需要 `border border-[var(--platform-subpanel-border)] bg-white/82` 的亮白预览槽,`none` 用于嵌在已有按钮 / 卡片交互壳里的纯图片与 fallback 内容,不抢外层边框、背景和选中态,`bare` 用于外层卡片已经提供边框和圆角的内嵌媒体框。自定义世界实体目录场景图片框、RPG 实体编辑器本地 `ImagePreview`、拼图结果页关卡列表正式图框、拼图发布弹窗封面关卡预览、拼消消结果页场地底图 / 素材图集 / 卡片预览网格、方洞结果页图片查看弹窗预览、方洞结果页封面 / 背景点击预览、方洞结果页形状 / 洞口贴图缩略图、宝贝识物结果页素材卡图片框、视觉小说结果页封面 / 资产字段图片预览、敲木鱼结果页主 9:16 背景 + 敲击物叠层预览、跳一跳结果页地块图集整图预览、大鱼吃小鱼关卡主图缩略图、大鱼吃小鱼素材工坊候选预览、大鱼吃小鱼场地背景竖版预览、creative-agent 模板确认预览,以及抓大鹅结果页物品素材列表缩略图、详情大图、视角缩略图和 UI 素材背景 / spritesheet 主图已先迁移;拼图关卡列表正式图、拼消消场地底图 / 素材图集这类外层白底媒体壳、宝贝识物素材卡顶部媒体槽、视觉小说资产字段、creative-agent 模板目录卡、跳一跳地块图集整图、大鱼关卡主图 / 工坊候选 / 场地背景主题槽、抓大鹅 UI 素材页白底预览壳,以及方洞封面 / 背景点击预览、方洞形状 / 洞口贴图缩略图这类外层按钮已承接渐变、边框、选中或 hover 交互壳的场景,内层统一使用 `surface="none"`;拼图发布弹窗封面关卡和 creative-agent 模板确认预览这类由媒体框自身承接白底柔和槽的场景使用 `surface="soft"`。后续只是“图片 / fallback / 比例 / overlay”的预览框优先使用该 Module历史素材选择继续使用 `PlatformAssetPickerCard`,上传后预览继续使用 `PlatformUploadPreviewCard`,整块白底面板继续使用 `PlatformSubpanel`
- `PlatformMediaFrame` 补充:组件根节点固定带 `platform-media-frame` 稳定类名,业务测试可断言公共媒体框接入,不再依赖局部 Tailwind 色值作为组件归属判断。
- `PlatformMediaFrame` 补充:拼图图库详情页封面轮播的内层正方形图片 / 暂无封面 fallback / 轮播 overlay 已迁移到 `PlatformMediaFrame aspect="square" surface="none"`;外层 `PlatformSubpanel radius="xl" padding="none"` 继续承接面板边框、圆角和裁切。
- `PlatformMediaFrame` 补充RPG 角色形象参考图缩略框和营地编组同行者头像框使用 `surface="editorDark"` 与固定尺寸 class 复用媒体框;这类只展示图片源 / fallback / 圆角边框的缩略框不再在业务 JSX 中手写 `img + overflow-hidden + border`
- `PlatformMediaFrame` 补充:需要运行时计算比例、裁剪或拖拽测量的媒体区域使用 `aspect="auto"``ref``imageProps`,由业务层只传动态 `style``draggable` 等图片属性和 overlay 操作层RPG 作品封面上传裁剪操作区 / 结果预览、角色素材工作室形象预览 / 动作静态预览、场景幕背景预设、技能编辑 fallback 预览、技能列表缩略图和角色编辑顶部形象预览已迁移。后续“图片 + 动态比例 / 不可拖拽 / overlay 操作层”的场景优先扩展 `PlatformMediaFrame`,不在业务 JSX 中重新手写 `ResolvedAssetImage`、固定图片壳和 fallback 文案。
- `PlatformMediaTileGrid`:接收 `items``columns="five" | "six"``gap="xs" | "sm"``aspect="auto" | "square"``surface="none" | "soft"``tileSurface="white" | "slate" | "bare"`、默认 `fallbackLabel`、默认图片 / fallback class 和局部 class每个 item 接收稳定 `id``src``alt``refreshKey``fallbackLabel``fallbackContent``testId` 与局部 class。tile 的边框 / 底色 / 阴影统一由 `tileSurface` 承接,内部 `PlatformMediaFrame` 使用 `surface="none"`,避免重复叠加公共媒体框底色。跳一跳结果页地块池、跳一跳无图集 fallback 地块池、拼消消结果页卡片预览网格和抓大鹅物品 spritesheet 解析预览分组已先迁移。后续结果页只是展示一组同尺寸正方形素材 tile 时优先使用该 Module单张大图预览继续用 `PlatformMediaFrame`,历史素材选择继续用 `PlatformAssetPickerGrid`,上传预览继续用 `PlatformUploadPreviewCard`
- `PlatformTagEditor`:接收 `title``tags``disabled``maxTags``error``addLabel``generateLabel``inputLabel``inputPlaceholder``emptyLabel``parseInput``onChange`、可选 `onGenerate` / `generateIcon``radius``padding``tone="amber" | "warm"`内部持有新增输入态统一处理标签去重、添加、删除、Enter 提交、Escape 取消、空态、可选 AI 生成按钮和错误提示。拼图结果页作品标签、敲木鱼结果页主题标签和抓大鹅结果页作品标签已先迁移。后续结果页只保留业务标签规范化函数和写回回调,不再重复手写 tag chip、删除按钮、输入框、添加 / 取消按钮和 AI 生成按钮。
- `PlatformTagEditor` 补充:新增输入行外壳继续由 `PlatformSubpanel surface="soft" padding="tight"` 承接,输入框由 `PlatformTextField` 承接;标签编辑 Module 内部也遵守公共输入 / 子面板分工,不再把白底 input chrome 写成本地 class。
- `PlatformAssetPickerCard`:接收 `imageSrc``imageAlt`、可选 `assetTitle` / `subtitle``surface="platform" | "editorDark"``selectLabel``selected``disabled``onClick``aria-label``cardRadiusClassName``imageShellClassName``imageClassName``bodyClassName`;图片读取统一走 `ResolvedAssetImage`按钮禁用态、选中态、边框、hover、缩略图外壳和可选卡片内选择按钮由 Module 统一控制,`assetTitle` 专指卡片内展示标题,不占用原生 button `title` 属性。`PlatformAssetPickerGrid`:接收素材数组、读取 / 错误 / 空态、`getKey``getImageSrc``getImageAlt``getTitle``getSubtitle``getAriaLabel``isSelected``cardClassName``onSelect`;默认组合 `PlatformStatusMessage``PlatformEmptyState``PlatformAssetPickerCard`,业务页只保留素材字段映射、文案、选中判断和选择回调,不再重复手写缩略图卡片、选中 ring、虚线读取 / 空态和网格 JSX。白底平台弹窗使用默认 `platform` surfaceRPG 大编辑器等暗色弹窗使用 `editorDark`,并通过 `imageShellClassName` 保留场景横图比例。视觉小说等同一弹窗里混有上传 / AI 生成错误时,可继续由外层错误条承接动作错误,只把历史素材读取 / 空态 / 网格交给 `PlatformAssetPickerGrid`
- `PlatformActionButton`:接收 `tone="primary" | "secondary" | "ghost" | "danger" | "success" | "warning" | "accent" | "accentSoft"``surface="platform" | "profile" | "editorDark"``size="xxs" | "xs" | "sm" | "md" | "lg"``shape="default" | "pill"``align="center" | "start"``fullWidth``children` 和原生 button props`surface="platform"` 复用 `platform-button` 样式族,`surface="profile"` 的主按钮复用个人中心 `platform-primary-button``surface="editorDark"` 统一承接 RPG 暗色弹窗 / 运行面板里的普通取消、确认、刷新和编组动作,`tone="accent"` 承接琥珀实心 CTA`tone="accentSoft"` 承接带局部 accent 变量的柔和强调按钮,根节点固定带 `platform-action-button--accent` / `platform-action-button--accent-soft` 稳定类名。认证表单的 48px 高按钮使用 `size="lg"`,暗色微型刷新 / 工具动作使用 `size="xxs" shape="pill"`,需要文件上传等 label 语义时使用 `asChild="label"` 复用同一套按钮外观,不把上传控件改成普通 button。推荐回复、列表内动作等需要左对齐时使用 `align="start"`,不要在业务 JSX 中重复写 `justify-start text-left`;创作中心错误重试、反馈页 header 返回和暗色次要动作等普通 ghost 动作同样走 `tone="ghost"``shape="pill"`,不在业务 JSX 中直接拼按钮 class。复制按钮仍使用 `CopyFeedbackButton`,可选项按钮卡仍使用 `PlatformDarkOptionCard`,像素风发送 / 强品牌动作继续保留专用布局。
- `PlatformActionButton` 补充:反馈页内的“查看反馈与投诉记录”这类页面内次级文本动作使用 `tone="ghost" shape="pill" size="xs"`;业务组件只保留点击反馈,不再手写居中、字号、内边距和冷色文本按钮 class。
- `PlatformActionButton` 补充:作品详情底部“作品改造 / 作品编辑”和“启动”使用 `surface="platform" shape="pill" size="lg" fullWidth`,保留 `platform-work-detail__remix / start` 局部 class 控制 sticky 底部栏位置、比例和品牌背景。
- `PlatformActionButton` 补充:作品详情点赞按钮使用 `tone="accentSoft"` 并通过局部 `--platform-action-accent` 变量复用柔和强调 chrome详情页只保留纵向排布、尺寸和可访问名称不再手写点赞按钮边框、底色、文字和阴影。
- `PlatformActionButton` 补充:创作中心作品卡积分激励的“领取积分 / 领取中”按钮使用 `tone="secondary" size="xxs"`;作品卡保留 `creation-work-card-incentive__button` 局部 class 控制三列布局、移动端跨列、紧凑高度和玻璃底,不再手写原生按钮 chrome。
- `PlatformActionButton` 补充:拼图首访 onboarding 生成 / 登录 CTA 使用 `surface="editorDark" tone="accent" size="lg" fullWidth`,跳过按钮使用 `surface="editorDark" tone="ghost" shape="pill"` 并只保留右上角定位 class首访页不再手写按钮基础 chrome。
- `PlatformIconButton`:接收 `label``icon`、可选 `children`、可选 `variant="platformIcon" | "surfaceFloating" | "darkMini"``title``className``asChild="label"` 和原生 button / label props默认 `platformIcon` 用于平台弹窗 header、搜索结果弹窗、工具栏、结果页选项删除等普通图标动作按钮也用于保持 file input 原生语义的图标上传 label`surfaceFloating` 用于通用创作图片面板里覆盖在图片或输入区上的白底圆形图标动作,短文案入口通过 `children` 渲染可见短标签但仍由 `label` 提供可访问名称;`darkMini` 用于上传预览卡右上角等覆盖在缩略图上的暗色小型图标动作。creation-agent composer 中的上传文档 / 上传参考图入口使用默认 `platformIcon`,只保留动态 label、title、busy 和 picker 回调;作品详情顶部返回 / 分享与封面轮播上一张 / 下一张入口也使用默认 `platformIcon`,并通过局部 class 保留详情页专属位置和尺寸。发送按钮、点赞按钮、带复制三态或强品牌动作继续保留专用布局。关闭语义复杂或属于个人中心 / 浮层关闭按钮时仍优先使用 `PlatformModalCloseButton`,带复制三态时使用 `CopyFeedbackButton`。同一面板内存在主图上传和提示词参考图上传时,两个 file input 必须使用不同可访问名称,避免业务测试或读屏用户只能看到多个同名“上传参考图”入口。
- `PlatformIconBadge`:接收 `icon`、可选 `label``size="xs" | "sm" | "base" | "md" | "lg" | "xl" | "xxl"``shape="circle" | "rounded" | "xl"``tone="neutral" | "soft" | "softBright" | "hero" | "heroMuted" | "darkAmber" | "success" | "danger"``className`;统一承接非交互图标槽的中性 / 柔和 / hero / 暗色琥珀 / 成功 / 危险底色、文字色、尺寸、圆角和 `aria-hidden` / `aria-label`。根节点固定带 `platform-icon-badge` 稳定类名,业务测试可断言共享图标槽接入。视觉小说 runtime 面板标题、存档列表项creative-agent 模板卡 / 模板确认 / 顶部 hero / 目标就绪 / 过程条目图标圆槽,创作类型弹层锁定卡小圆锁图标,大鱼吃小鱼发布失败弹窗图标槽,通用创作图片面板空主图上传占位图标槽,拼图结果页智能修订条图标槽,以及 GameCanvas 宝箱遭遇图标槽已先迁移。后续同类图标槽不再重复手写 `grid h-* w-*``inline-flex h-* w-* items-center justify-center``rounded-full``rounded-[0.85rem]``rounded-2xl`、neutral token class、白底柔和小圆槽、暗色琥珀图标槽或危险提示红色圆槽。
- `PlatformIconBadge` 补充:宝贝识物工作台玩法预览卡内礼物图标槽使用 `size="xl" shape="rounded" tone="softBright"`,业务页只保留玩法色和投影覆盖,不再手写 `grid h-14 w-14 place-items-center rounded-* bg-white/*`
- `PlatformIconBadge` 补充:个人中心充值结果弹窗和支付确认遮罩里的 56px 圆形图标槽使用 `size="xl"` 并通过局部 `bg-white/10`、状态文字色 class 覆盖;弹窗只保留支付结果文案、支付状态图标和确认动作,不再手写 `flex h-14 w-14 items-center justify-center rounded-full bg-white/10` 图标容器。
- `PlatformUploadTile`:接收 `label`、可选 `hint``icon``size="square" | "compact" | "panel"``surface="platform" | "editorDark"``showLabel``disabled``className``asChild="label"` 和原生 button / label props默认渲染 `type="button"` 的平台虚线上传方块,`compact + showLabel={false}` 用于工作台里的纯图标虚线新增入口,`panel` 用于整行上传说明入口,`editorDark` 用于 RPG 大编辑器等暗色弹窗。label 模式保留 file input 原生关联语义,禁用时写入 `aria-disabled` 并阻断 label 默认点击。反馈页上传凭证、敲木鱼工作台新增功德词条入口、RPG 大编辑器参考图入口、角色素材工作室参考图入口和封面上传入口已迁移,后续图片 / 附件上传方块或紧凑虚线新增入口只保留业务选择文件 / 新增动作,不再重复写虚线入口 chrome。
- `PlatformUploadPreviewCard`:接收 `imageSrc``imageAlt``removeLabel`、可选 `layout="square" | "inline"``surface="platform" | "editorDark"``caption``previewLabel``onPreview``onRemove``disabled``resolveAsset``imageRefreshKey``className``imageClassName``imageShellClassName``captionClassName``previewButtonProps``removeIcon``removeButtonProps`;默认 `square` 渲染平台缩略图壳、`object-cover` 预览图、可选标题行和可选移除按钮square 右上移除按钮复用 `PlatformIconButton variant="darkMini"``inline + platform` 通过 `PlatformSubpanel surface="soft" padding="row"` 渲染白底横向已选素材条,`inline + editorDark` 通过 `PlatformSubpanel surface="dark" padding="row"` 渲染暗色编辑器横向参考图条。需要点击预览的参考图传 `previewLabel/onPreview`,需要 generated / OSS 资产换签的缩略图传 `resolveAsset`,需要展示文件名 / 素材名的参考图传 `caption`,不要在业务 JSX 中额外包一层缩略图标题栏或横向参考图条。反馈页上传凭证预览、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer、creation-agent 已选参考图条、拼图结果页关卡引用图横条和 RPG 大编辑器参考图预览条已迁移,后续上传预览只保留素材数据、预览回调和删除回调,不在业务 JSX 中重复写预览卡 chrome。
- `PlatformPillSwitch`:接收 `label``checked``disabled``className` 和原生 input props内部固定 `role="switch"``type="checkbox"``sr-only` 输入视觉层统一白底胶囊、开关轨道、圆点位置、hover / 禁用态。通用创作图片面板和抓大鹅封面编辑的 `AI重绘` 已迁移,后续同类胶囊开关只传受控 checked / onChange不再手写 switch 轨道和圆点。
- `PlatformToggleRow`:接收 `label``checked``onChange``disabled``mode="checkbox" | "status"``icon``onLabel``offLabel``onClick``surface="soft" | "plain"``className``labelClassName``checkbox` 模式用于结果页运行配置和角色可见性,`status` 模式用于 runtime 设置面板的只读开关状态,可选 `onClick` 时自身渲染为 button。视觉小说结果页运行配置 / 玩家可见开关、视觉小说 runtime 设置面板和拼消消创作工作台 AI 生成底图开关已先迁移,业务页不再重复手写 `flex min-h-12 ... bg-white/74 px-3`、checkbox class 或“开启 / 关闭”状态 pill。
- `PlatformInfoBlock`:接收可选 `label``children``multiline``className``labelClassName``valueClassName`;统一承载平台弹窗 / 详情页中的短标签、无标签只读正文、白底圆角边框、内容换行、单行加粗排版和横向只读信息行的标签 / 值局部排版。错误弹窗与生成完成弹窗的来源、错误、状态块、分享弹窗正文,以及汪汪声浪预览卡场景 / 形象 / 难度 / 声浪信息行已先迁移,后续同类只读信息展示只传 label、内容和必要局部排版 class纯正文块可省略 label不在业务 JSX 中重复写 `rounded-[1rem] border ... bg-white/72 px-3 py-2``rounded-[1.25rem] border ... bg-white/72 p-4``rounded-[0.85rem] bg-white/74 px-* py-*`
- `PlatformInfoBlock` 补充:当前 Interface 支持 `variant="compactRow"` 承接预览卡里的密集横向 label / value 行,标签、值、圆角、白底和响应式内边距由公共组件控制;汪汪声浪预览卡四个信息行已去掉本地 `PREVIEW_INFO_*` class 常量。
- `PlatformModalCloseButton`:接收 `label``variant="profile" | "profileCompact" | "floating" | "floatingPlain" | "platformIcon" | "pixel" | "editorDark"``icon` 和原生 button props`profile` 复用个人中心 `platform-modal-close` 圆形按钮,`profileCompact` 复用个人中心小弹窗 `platform-profile-icon-button` 关闭按钮,`floating` 复用平台浮层右上角白底关闭按钮,`floatingPlain` 复用个人中心邀请 / 社区浮层的透明右上角关闭按钮,`platformIcon` 复用平台弹窗头部 `platform-icon-button` 关闭入口,`pixel` 复用 `UnifiedModal variant="pixel"` 的像素风圆形关闭入口,`editorDark` 承接 RPG 暗色弹窗中非像素风的圆形 X 关闭入口并固定带 `platform-modal-close-button--editor-dark` 稳定类名。认证入口、邀请码弹窗等平台弹窗头部关闭按钮使用 `variant="platformIcon"`,像素风 `UnifiedModal` 使用 `variant="pixel"`,自定义选择弹窗使用 `variant="editorDark"`,业务页可以追加局部 class但不重新声明基础尺寸、圆角、默认图标和 `aria-label`
- `squareImageCropModel`:导出 `SquareImageCropRect``buildCenteredSquareImageCropRect(imageSize)``clampSquareImageCropRect(imageSize, crop)`;可复用裁剪数学留在 model`SquareImageCropModal` 只承接弹窗 UI、拖拽交互和提交动作。
## 迁移顺序
1. 先迁移平台入口壳中的泥点提示和作品删除确认,验证普通提示与危险确认两个分支。
2. 迁移 `PlatformErrorDialog``PlatformTaskCompletionDialog``PublishShareModal` 的复制反馈到 `useCopyFeedback``CopyFeedbackButton`,验证成功、失败和上下文切换复位。
3. 迁移公开作品详情、RPG 作品详情、拼图广场详情、大鱼 runtime 分享和账号个人资料区中的作品号 / 用户号复制与分享复制状态;短代码 chip 使用 `CopyCodeButton`,分享按钮继续按场景使用 `CopyFeedbackButton``CopyFeedbackMessage`,避免页面继续散落 `copyState / shareState + setTimeout` 或三态按钮 JSX。
4. 再迁移结果页、工作台和账号区域中只有单个确认按钮或确认 / 取消按钮的简单弹窗;拼图结果页关卡画面生成、抓大鹅结果页物品素材生成 / 重新生成的“确认消耗泥点”已使用 `UnifiedConfirmDialog` 的内嵌渲染模式,拼图 / 抓大鹅创作工作台的初始泥点确认已使用默认 portal 模式,大鱼吃小鱼结果页发布失败提示已通过 `confirmClassName` 保持整行确认按钮外观。
5. 自定义世界实体目录的删除确认、批量删除确认和“至少保留一个可扮演角色”提示统一使用 `UnifiedConfirmDialog`,不再调用浏览器原生 `window.confirm` / `window.alert`
6. RPG 结果页整页重新生成确认由页面层使用 `UnifiedConfirmDialog` 承接,`useRpgCreationResultActions` 只保留执行命令和忙碌态保护,不再在 hook 内调用浏览器原生确认框。
7. RPG 详情页删除确认由平台壳的共享作品删除弹窗承接;`useRpgEntryLibraryDetail` 只保留已确认后的删除命令、刷新和阶段回退,不再直接调用浏览器原生确认框。
8. RPG 角色素材工作室的形象 / 动作泥点消耗确认使用 `UnifiedConfirmDialog portal={false}` 内嵌在工作室弹窗栈内;点击生成只打开确认,确认后再执行生成工作流。
9. RPG 场景编辑器中的多幕数量、连接关系、主角色、幕预览和角色槽位阻断提示统一使用基于 `UnifiedConfirmDialog` 的编辑器提示弹窗,不再调用浏览器原生 `window.alert`
10. RPG 可扮演角色 / 场景角色的背景章节删除阻断提示由角色编辑器壳层承接,背景章节编辑控件只上报 `onNotice`,不直接调用原生弹窗。
11. RPG 编辑器关闭未保存草稿时使用 `UnifiedConfirmDialog` 统一承接“确认关闭 / 继续编辑”,不再维护单独的关闭确认按钮样式。
12. RPG 场景背景和作品封面生成结果未保存时,退出确认也使用 `UnifiedConfirmDialog`;像素风场景生成弹窗通过 `variant="pixel"` 适配视觉。
13. 公开作品详情或运行态深链失效时,由平台入口壳展示 `UnifiedConfirmDialog` 的“作品不可用”提示;用户确认后再回到首页,错误处理分支不再调用浏览器原生 `window.alert`
14. 带复杂内容的专用 Module 可以保留自己的布局,但复制反馈仍应复用 `useCopyFeedback`;如果有可点击复制按钮,优先复用 `CopyFeedbackButton`;如果只展示复制结果提示,优先复用 `CopyFeedbackMessage`
15. 白底平台弹窗、详情页、结果页、目录页、个人页、认证入口、统一创作工作台和通用创作输入区的基础错误 / 成功 / 信息 / 警告 / 中性状态提示逐步迁移到 `PlatformStatusMessage`RPG 结果页、拼图结果页、抓大鹅结果页、跳一跳结果页、敲木鱼结果页、拼消消结果页、宝贝识物结果页、方洞结果页、汪汪声浪结果页、视觉小说结果页、拼消消创作工作台、宝贝识物创作工作台、视觉小说创作工作台、汪汪声浪创作工作台、creative-agent 工作台、creation-agent operation banner、自定义世界实体目录、拼消消 runtime 白底错误条和平台作品详情分享复制反馈已使用 `surface="platform"` 承接发布检查、错误提示、进度提示、素材生成提示、资源未就绪提示、主线目标提示和复制反馈;个人中心、认证入口、统一创作工作台和创作输入区需要 profile token 外观时使用 `surface="profile"`RPG 暗色编辑 / 运行面板和拼图首访 onboarding 里的普通错误 / 成功 / 信息 / 警告 / 中性提示使用 `surface="editorDark"`,背包故事档案 QA 提示、NPC 交易 / 赠礼 / 招募叙事提示和角色聊天错误提示已先迁移。运行态里的短错误 / 成功 / 命中反馈 chip 使用 `PlatformRuntimeStatusToast`,位置和玩法强品牌 HUD 仍留在 runtime 壳层;深色半透明游戏内提示和强品牌样式可以暂保留专用布局,避免状态条组件过早承接游戏视觉。
16. 正方形图片裁剪的初始居中、边界 clamp 和裁剪矩形类型统一从 `squareImageCropModel` 导入,避免头像裁剪、拼图参考图裁剪等业务页面依赖弹窗组件文件里的 helper。
17. 个人中心的账户充值、泥点账单、每日任务、兑换码、扫码、存档、玩过作品、邀请 / 社区、昵称修改、头像裁剪,以及平台筛选、创作图片预览、认证入口、邀请码弹窗、公开编号搜索结果弹窗、方洞结果页图片素材弹窗、视觉小说结果页资产 / 音频 / 编辑器弹窗、视觉小说 runtime 普通面板、creative-agent 模板确认弹窗、像素风 UnifiedModal 和自定义选择弹窗等圆形关闭按钮迁移到 `PlatformModalCloseButton`;后续新增弹窗关闭按钮先判断是否属于 `profile``profileCompact``floating``floatingPlain``platformIcon``pixel``editorDark` 七类,确有品牌化或运行态 HUD 语义时才保留专用按钮。
17.1. 平台弹窗 header 和普通工具栏里的 `platform-icon-button` 迁移到 `PlatformIconButton`历史图片选择弹窗、RPG 发布检查弹窗、creative-agent 侧边栏关闭 / 外观 / 设置入口、通用输入 Composer 上传 / 发送 / 移除参考图、creation-agent composer 上传文档 / 上传参考图、creation-agent 参考图移除、敲木鱼结果页新增主题标签入口、敲木鱼创作工作台功德词条删除入口、拼图结果页标签生成 / 标签新增 / 关卡详情关闭 / 发布弹窗关闭 / 删除关卡入口、视觉小说结果页素材选择 / 音频生成 / 保存草稿 / 运行配置入口、RPG 首页搜索结果清空入口、方洞结果页形状 / 洞口选项删除入口,以及抓大鹅结果页标签生成 / 标签新增 / 物品素材删除 / 参考图上传入口已先迁移。结果页内的普通平台弹窗关闭入口使用 `PlatformModalCloseButton variant="platformIcon"`;图标上传控件使用 `PlatformIconButton asChild="label"` 保留 label + file input 语义,不改成普通按钮;`PlatformIconButton` 的 label 模式会自动写入隐藏文本,保证内嵌 file input 仍能继承可访问名称。通用创作图片面板中覆盖在图片上的更换主图、移除主图、历史入口短标签按钮和提示词参考图上传入口,抓大鹅封面编辑中覆盖在封面图上的移除入口,以及敲木鱼创作工作台功德词条删除入口使用 `PlatformIconButton variant="surfaceFloating"`,不再手写白底圆形 / 短标签浮动按钮 chrome。运行态 HUD、带复制状态或需要专用交互禁用语义的图标按钮先保留专用布局等对应场景验证时再迁移。
17.2. 非交互图标徽章迁移到 `PlatformIconBadge`;视觉小说 runtime 面板标题、存档列表项creative-agent 模板卡 / 模板确认 / 顶部 hero / 目标就绪 / 过程条目图标圆槽,创作类型弹层锁定卡小圆锁图标,大鱼吃小鱼发布失败弹窗图标槽,通用创作图片面板空主图上传占位图标槽,拼图结果页智能修订条图标槽,以及 GameCanvas 宝箱遭遇图标槽已先迁移。后续同类图标槽只表达 icon、尺寸、形状和 neutral / soft / softBright / hero / heroMuted / darkAmber / success / danger 调性不再重复中性、白底柔和、hero 叠层、暗色琥珀、成功或危险底色、文字色、居中和 shrink class。
17.3. RPG 大编辑器主壳层和紧凑对话壳层的右上角关闭入口迁移到 `PlatformModalCloseButton variant="platformIcon"`;暗色编辑器仍保留原 `platform-icon-button` 视觉 token但业务 JSX 不再手写 `button``aria-label` 和默认关闭图标。
18. RPG 首页、公开广场、排行、作品架、个人中心充值 / 任务弹窗、视觉小说 runtime 普通白底面板、历史素材选择弹窗、视觉小说上传资产弹窗本地上传占位、自定义世界实体目录搜索无结果、大鱼吃小鱼结果页缺草稿提示、RPG 大编辑器纯展示暗色列表、背景故事空档案和 RPG 运行态设置保存禁用提示中的无操作空态 / 轻量读取态迁移到 `PlatformEmptyState`;后续空态如果包含 CTA、插画、复杂列表恢复动作或玩法 HUD再保留专用布局。
18.1. 历史图片 / 历史素材 / 可引用素材选择迁移到 `PlatformAssetPickerCard``PlatformAssetPickerGrid`拼图历史图片弹窗、方洞历史生成、视觉小说历史素材选择器、RPG 大编辑器历史素材弹窗和抓大鹅封面编辑可引用素材网格已先迁移。后续素材选择只传素材数组、`imageSrc`、主副文案、可访问名称、surface、选中判断和选择回调不再在业务页重复缩略图、边框、选中 ring、禁用态、`ResolvedAssetImage` 壳层、虚线读取 / 空态和网格 JSX。
18.2. 平台白底圆角输入框和文本域迁移到 `PlatformTextField surface="platform"`RPG 暗色弹窗 / 运行面板里的普通输入框、文本域和下拉框迁移到 `PlatformTextField surface="editorDark"` / `PlatformSelectField surface="editorDark"`;抓大鹅结果页作品名称 / 描述、封面描述、素材名称、批量新增 / 批量重生成物品名称,方洞结果页游戏名称、标签、简介、题材主题、反差规则、背景提示、形状数量、形状 / 洞口名称、形状目标洞口和图片提示词,拼图结果页作品名称 / 描述、关卡名称和智能修订输入,敲木鱼结果页作品标题 / 简介,视觉小说结果页的音乐生成、作品信息、开场、运行配置、角色、场景、阶段和世界观普通文本 / 下拉字段,以及视觉小说 / 抓大鹅 / 汪汪声浪 / 宝贝识物 / 拼消消 / 跳一跳创作工作台普通输入字段、敲木鱼创作工作台功德词条输入、creative-agent 模板确认调整弹层关卡数输入已先迁移。通用输入 Composer、通用创作图片输入面板的提示词文本域、自定义世界实体目录搜索框、认证验证码答案输入、短信 / 密码登录、重置密码、绑定手机号、邀请码、账号安全表单字段、个人中心兑换码 / 邀请码输入、自定义选择弹窗角色名字 / 背景补充 / 生成模式 / 世界描述、角色聊天草稿、拼图首访 onboarding 提示词文本域和平台反馈页问题描述 / 联系电话也使用 `PlatformTextField` / `PlatformSelectField`;浮动胶囊 Composer 可继续由 `.creative-agent-composer--floating textarea` 覆盖尺寸和背景,图片输入面板可通过局部 class 保留高度与浮动上传按钮避让,实体目录搜索框可通过局部 class 保留紧凑圆角和底色,验证码答案输入和认证表单字段可通过局部 class 保留表单高度、横向验证码按钮布局和原生 `label` 关联,个人中心兑换码 / 邀请码输入通过局部 class 保留大写和居中,暗色聊天草稿和首访提示词文本域可通过局部 class 保留沉浸式底色 / 高度,反馈页字段可通过局部 class 保留透明嵌入式视觉,不在业务 JSX 中手写 textarea / input / select chrome。默认密度用于结果页主表单`density="compact"` 用于选项卡片、工具条、认证提示内或反馈页联系电话的紧凑字段,`density="roomy"` 用于宽内边距文本域、关卡详情字段、首访提示词文本域或反馈页问题描述;默认 `tone="warm"`,玩法需要保留调性焦点色时使用 `tone="rose"``tone="emerald"``tone="sky"`,不要在业务 JSX 中重复写 `focus:border-* focus:ring-*`。后续结果页、工作台、目录工具条、认证提示、认证表单、个人中心轻量表单、反馈表单、首访页或 RPG 暗色弹窗内的普通文本输入 / 下拉框只传受控值、事件、可访问名称、占位符、选项和局部布局 class不再重复基础边框、背景、内边距、字号、禁用态和焦点色。
18.2.1. 个人中心昵称弹窗输入框迁移到 `PlatformTextField surface="editorDark"`;昵称状态机、校验、保存和弹窗壳层不随输入框 chrome 收口改动。
18.3. 平台字段标签迁移到 `PlatformFieldLabel`;视觉小说结果页、抓大鹅结果页作品 / 封面 / 素材字段标题、方洞结果页主信息 / 形状 / 洞口 / 历史图片字段标题、拼图结果页关卡详情 / 发布弹窗字段标题、拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题、大鱼素材弹窗 prompt、RPG 发布弹窗发布检查 / 封面设置、汪汪声浪轻配置编辑器、宝贝识物工作台、通用创作图片输入面板主图 / 提示词标题,以及认证登录 / 绑定 / 邀请码 / 账号安全表单标题、平台反馈页问题描述 / 联系电话标题已先迁移。后续结果页、编辑弹窗、工作台、通用创作输入面板、反馈表单或认证表单中只表达字段名称的小标题,优先选择 `field` / `section` / `form` / `pill` / `accentPill`,不要在业务 JSX 中重复拼字段标题 class认证表单、反馈表单和提示词字段保留外层原生 `label`,带品牌化插画、运行态 HUD 或复杂步骤标题时可暂保留专用标题。
18.3.1. 个人中心存档 / 玩过弹窗里的简单空态、分区标题和已玩作品白底按钮卡分别迁移到 `PlatformEmptyState``PlatformFieldLabel``PlatformSubpanel``SaveArchiveCard` 带图片遮罩和加载视觉,仍保留专用实现,后续需要单独视觉验收后再决定是否收口。
18.3.2. 平台入口壳中的纯 Suspense fallback、作品详情读取 / 错误提示、Agent 工作区恢复提示、RPG runtime 主阶段懒加载提示和 `CreationResultRecoveryPanel` 外壳迁移到 `PlatformSubpanel`;加载 / 错误提示使用 `radius="sm" padding="none"`,带恢复动作的结果恢复面板使用 `radius="xl" padding="none"`,玩法 runtime overlay 后续单独评估。
18.3.3. 个人中心钱包账单弹窗里的空态和账单行分别迁移到 `PlatformEmptyState``PlatformSubpanel`;账单展示只保留收支内容、余额和时间,不在业务 JSX 重复白底列表行 chrome。
18.3.4. 个人中心邀请弹窗内部的二维码卡、邀请码卡、成功邀请列表、邀请用户行、小标题和简单空态分别迁移到 `PlatformSubpanel``PlatformFieldLabel``PlatformEmptyState`外层弹窗、query 自动打开、复制邀请、提交邀请码和社区面板信息架构不随本轮改变。
18.3.5. 个人中心任务中心任务条目迁移到 `PlatformSubpanel`;任务选择、领取、奖励和完成态仍由任务 ViewModel / 业务流程控制。
18.3.6. 个人中心充值弹窗 Native 支付二维码确认面板迁移到 `PlatformSubpanel`;支付渠道选择、二维码生成和确认支付流程不随 UI chrome 收口改动。
18.3.7. 个人中心充值弹窗商品整卡按钮迁移到 `PlatformSubpanel as="button" interactive`;支付渠道选择、商品展示、提交中态和购买回调不随按钮卡 chrome 收口改动。
18.4. 平台白底分段 Tab / 二选一 / 四选一配置项迁移到 `PlatformSegmentedTabs`;拼图结果页、抓大鹅结果页、抓大鹅素材配置、抓大鹅创作 / 结果页难度选择、视觉小说结果页和 creative-agent 模板确认弹窗已先迁移。后续同类控件只传选项、当前 id、变更回调、列数、尺寸、调性和外壳形态不再在业务 JSX 中重复容器边框、`bg-white/62`、选中态和 `aria-pressed`
18.4.1. `PlatformSegmentedTabs` 支持 `semantics="tabs"``tone="underline"``size="tab"``columns="one"`,用于承接认证入口短信 / 密码登录切换这类真实 Tab 语义;业务页不再维护本地 `LoginTabButton``role="tab"``aria-selected` 和下划线选中态。
18.5. 平台只读信息块迁移到 `PlatformInfoBlock`;错误弹窗和生成完成弹窗的来源、错误和状态展示、分享弹窗正文,以及汪汪声浪预览卡场景 / 形象 / 难度 / 声浪信息行已先迁移。后续弹窗、详情页或预览卡里只是展示短标签 + 只读正文,或无标签纯只读正文时,优先使用该 Module横向信息行通过 `labelClassName` / `valueClassName` 保留标签和值排版,不在业务 JSX 中重复白底信息块 chrome。
18.5.1. 平台来源 / 状态 / 错误这类可复制报告弹窗迁移到 `PlatformReportDialog``PlatformErrorDialog``PlatformTaskCompletionDialog` 已先迁移,业务弹窗只保留标题、字段语义和黑名单过滤,不再重复维护 `UnifiedModal``CopyFeedbackButton``useCopyFeedback`、报告拼装和 `PlatformInfoBlock` footer 组合。后续同类“字段展示 + 复制整段报告”弹窗优先复用该 Module。
18.6. 平台统计小卡和轻量状态 chip 迁移到 `PlatformStatGrid`;拼消消结果页素材摘要、方洞结果页封面状态 chip、抓大鹅结果页难度摘要、creative-agent 模板确认摘要和自定义世界实体目录世界页档案规模已先迁移。后续结果页里只表达数值 / 标签摘要时,优先传 `items`、列数、密度、surface 和 label/value 顺序,不再在业务 JSX 中重复手写统计卡 chrome。
18.6.1. 平台普通进度条迁移到 `PlatformProgressBar`creation-agent 主进度 / operation banner、RPG 结果页生成提示、RPG 实体目录生成中提示、开场 CG 生成占位、拼图关卡画面生成进度、生成页当前步骤线性进度、抓大鹅批量物品素材生成进度和自定义世界生成选择弹窗进度提示已先迁移。creation-agent operation banner 的状态外壳也迁移到 `PlatformStatusMessage surface="platform" remapSurface`,避免业务 JSX 继续组合 `platform-remap-surface platform-banner``platform-banner--*`。后续生成进度、素材进度或实体目录进度只保留进度值、显示文案、主题色、必要覆盖层和业务状态,不再重复写 `role="progressbar"``platform-progress-track`、fill 宽度和最小可见宽度计算;未知进度用 `indeterminate`。生成页环形总进度继续保留 `GenerationProgressHero` 专用 SVG。
18.6.2. 平台单个胶囊状态 / 标签 chip 迁移到 `PlatformPillBadge`;宝贝识物结果页发布状态、主题标签与占位资源 overlay宝贝识物 / 拼图 / 抓大鹅 / 视觉小说工作台 BETA chip、汪汪声浪轻配置 chip、汪汪声浪结果页草稿 chip、汪汪声浪预览 VS chip、敲木鱼结果页飘字 chip、creative-agent 顶部阶段 / 过程计数 / 条目 meta chip、通用音频输入面板限制标签、自定义世界实体目录批量选择 / 生成中 / 开局 CG / 可扮演角色元信息 badge、RPG 首页公开作品卡 / 搜索结果 / 充值商品 / 移动端创建入口 / 桌面发现区 chip、RPG 世界详情静态元信息 chip、平台作品详情主题标签、RPG 角色身份 / 等级 / 技能出手方式 / 技能详情与状态标签 / 背景故事解锁状态 / 好感等级 / 角色资产工作室动作状态 / 角色编辑技能动作状态 / 角色资源应用状态 / 场景角色选择状态 / 地标当前连接状态 / 地图节点当前状态 / 地图节点方向标签 / 地图场景切换方向标签 / 作品封面来源状态 / 开局物品标签、NPC 交易数量 / 赠礼好感和背包工坊材料需求等暗色展示 chip、抓大鹅批量新增 / 批量重生成物品名称预览 chip、抓大鹅 / RPG / 拼图 / 方洞结果页自动保存状态、抓大鹅结果页当前难度 badge、拼图结果页关卡生成中 overlay / 列表 badge、大鱼吃小鱼结果页终局 / 关卡元信息 / 发布校验成功 badge、RPG 开发资产诊断数量 / 加载状态 badge、RPG 发布弹窗封面来源 badge、账号弹窗主题状态 / 会话数量 / 设备状态 badge、汪汪声浪生成页和通用生成页右上状态 badge、创作类型弹层锁定 badge、通用创作图片面板提交按钮内泥点消耗标签以及个人中心泥点账单余额、玩过总时长和玩过作品类型 chip 已先迁移。后续只表达一个状态、标签、分类 chip 或按钮内消耗小胶囊时使用该 Module不在业务 JSX 中重复拼 `rounded-full border bg-* text-* px-* py-*`;个人中心玫瑰色 chip 使用 `tone="profile"` / `tone="profileAccent"`RPG 暗色展示 chip 使用 `dark*` tone密集目录元信息用 `size="xxs"`,平台白底柔和状态使用 `tone="muted"`,实心中性详情标签使用 `tone="neutralSolid"`,按钮内浅色叠层使用 `tone="lightOverlay"`,多项统计摘要继续使用 `PlatformStatGrid`。可点击复制 / 分享胶囊 chip 继续由 `CopyCodeButton` / `CopyFeedbackButton` 管复制状态,并通过 `actionAppearance="pill"` 复用 `PlatformPillBadge` chrome。
18.6.2.1. 抓大鹅创作工作台提交按钮内的泥点消耗标签使用 `PlatformPillBadge tone="lightOverlay" size="xs"`;工作台只保留泥点数值和提交状态,不再手写 `rounded-full bg-white/24 px-2 py-0.5`
18.6.3. 平台媒体悬浮短标签迁移到 `PlatformOverlayBadge`,复合控件内部的紧凑槽位编号迁移到 `PlatformSlotBadge`RPG 场景幕预览左上幕标签和每幕角色槽位的“主 / 2 / 3”标记已先迁移。后续覆盖在图片、素材预览或舞台画面上的非交互短标签只传文案、位置和局部 class绝对定位、白底半透明、边框、阴影与字距由 `PlatformOverlayBadge` 承接;角色槽、步骤槽等复合按钮里的小圆形序号只传文案和 active / inactive 语义,由 `PlatformSlotBadge` 承接。普通状态 / 分类仍使用 `PlatformPillBadge`,外层按钮、人物舞台布局和运行态 HUD 不迁入这两个小 Module。
18.6.3.1. `PlatformOverlayBadge` 支持 `tone="muted"``size="compact"``offset="tight"`,用于素材缩略图右上角“占位图”等更紧凑的非交互浮层;宝贝识物结果页素材卡占位图标记已迁移到该组合。后续这类贴在媒体框上的短标签优先使用 overlay badge不再把 `PlatformPillBadge` 绝对定位到图片内。
18.6.3.2. `PlatformSlotBadge` 支持 `tone="soft"``size="md"`,用于 creative-agent 阶段时间线这类白底柔和步骤圆点;时间线外层阶段卡、进行中 / 已完成 / 未开始语义配色仍保留在业务 Module公共徽标只承接圆点尺寸、白底、边框和图标居中。
18.6.4. 物品格、奖励格等缩略图右下角的数量角标迁移到 `PlatformQuantityBadge`;背包物品格和 RPG 冒险面板 / 覆盖层的奖励物品数量已先迁移。后续同类右下角数量只传数量值,绝对定位、黑底半透明、圆角、边框和字号由该 Module 承接;可交互物品按钮、选中态、稀有度边框、图标来源和详情弹窗仍留在业务 Module。
18.6.5. RPG 冒险面板和覆盖层里的任务目标状态、任务日志状态、当前幕、剩余交谈等纯展示暗色 chip 复用 `PlatformPillBadge``dark*` tone任务 presentation / 日志状态只返回语义 tone不再携带完整 `border / bg / text` class。运行态行动按钮、任务面板打开按钮和需要 hover / click 语义的胶囊仍保留专用按钮布局。
18.6.6. RPG 角色面板里的标签数、适配倍数、性别和装备稀有度等纯展示暗色 chip 复用 `PlatformPillBadge darkNeutral / darkEmerald / darkAmber`;构筑适配倍数只保留 multiplier 计算,不再手写 emerald 胶囊 chrome。后续带复杂数值拆解的统计 / 加成类展示能力再单独收口。
18.6.7. RPG 首页作品卡里的发布状态、元信息、主标签,以及存档卡右上恢复 / 最近游玩时间等暗色静态 chip 复用 `PlatformPillBadge dark*`;作品卡 / 存档卡只保留可点击卡片、删除动作、进入 / 继续创作箭头和业务文案。
18.6.8. 自定义世界实体目录里的基础设定词条标签复用 `PlatformPillBadge darkSoft`;目录页只保留词条解析和空值展示逻辑,不再手写白字暗底 tag chrome。
18.7. 平台白底子面板迁移到 `PlatformSubpanel`;拼图结果页作品信息 / 智能修订条 / 关卡卡片、敲木鱼结果页主预览面板 / 元信息、敲木鱼工作台功德词条、拼图图片生成模式选择器菜单外壳、跳一跳结果页预览 / 操作面板 / 排行榜 / 轻量媒体壳、拼消消创作工作台左侧表单面板、拼消消结果页预览 / 统计 / 操作面板 / 轻量媒体壳、方洞结果页封面 / 主信息 / 形状 / 洞口标准面板、方洞形状 / 洞口选项卡与缩略图按钮、RPG 结果页开发资产诊断摘要 / 条目 / 空态、RPG 发布弹窗封面预览壳、通用音频输入面板、视觉小说创作工作台画风选择面板、视觉小说结果页素材 / 音频小面板、视觉小说结果页作品 / 开场 / 运行配置 / 世界观标准编辑面板、视觉小说结果页角色 / 场景 / 阶段列表项与空态、视觉小说 runtime 历史条目 / 存档列表、抓大鹅创作工作台难度小面板、抓大鹅结果页作品 / 难度 / 统计 / UI素材预览标准面板 / 物品图集分组卡 / 批量素材生成进度卡、汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡、平台反馈页问题描述 / 上传凭证 / 联系方式区块、自定义世界实体目录世界基调 / 角色维度 / 基本设定条目 / 场景幕级缩略图 / 目录卡片媒体壳 / 目录卡片整卡壳,以及 creative-agent 工作台标准白底面板 / 关卡计划小卡和通用输入 Composer 普通 panel 外壳已先迁移。后续仅表达“白底子面板 + 标题 / 右侧动作 + 内容”“不需要 fallback / overlay 的白底轻量媒体壳”或“白底整卡点击列表项”的片段优先使用该 Module标准面板使用 `surface="platform"`,选中 / 删除预备等危险整卡态使用 `surface="danger"`,大圆角标准面板使用 `radius="xl" padding="lg"`,小型白底卡片或小型浮层菜单使用 `surface="flat"`,不要在业务 JSX 里继续覆盖 flat 的圆角和底色,轻量媒体壳使用 `surface="flat" padding="none"`,整卡或缩略图点击使用 `as="button" interactive`;暗色运行态 HUD、通用输入 Composer 浮动胶囊或强玩法品牌面板可继续保留专用布局。
18.7.1. 账号设置入口卡、主题选择卡、当前主题状态、账号绑定卡、密码 / 安全 / 设备 / 操作记录区块,以及设备 / 操作记录内的白底列表行已迁移到 `PlatformSubpanel`;账号弹窗只保留绑定、换绑、撤销会话和日志展示语义,不再直接拼 `platform-subpanel rounded-2xl` 或内层白底列表边框。
18.7.2. RPG 世界详情页的世界信息统计卡、关键角色 / 关键场景预览卡和操作区标题已迁移到 `PlatformSubpanel``PlatformFieldLabel variant="section"`;详情页只保留作品展示、启动、编辑、发布、下架和删除动作语义,不再直接拼小型 `platform-subpanel` 卡片或本地 section 标题 class。
18.7.3. 大鱼吃小鱼结果页的关卡卡片、场地背景卡、发布校验卡、空草稿提示和素材工坊 PROMPT 信息块已迁移到 `PlatformSubpanel`;结果页只保留大鱼玩法的青色主题按钮、预览背景、素材生成动作和发布校验语义,不再直接拼 `rounded-[1.45rem] border ... bg-[var(--platform-subpanel-fill)] p-4``rounded-[1.25rem] border ... bg-white/72 p-4`
18.7.4. 平台媒体预览框迁移到 `PlatformMediaFrame`自定义世界实体目录场景图片框、RPG 实体编辑器 `ImagePreview`、拼图结果页关卡列表正式图框、拼图发布弹窗封面关卡预览、拼消消结果页场地底图 / 素材图集 / 卡片预览网格、方洞结果页图片查看弹窗预览、方洞结果页封面 / 背景点击预览、方洞结果页形状 / 洞口贴图缩略图、宝贝识物结果页素材卡图片框、视觉小说结果页封面 / 资产字段图片预览、敲木鱼结果页主 9:16 背景 + 敲击物叠层预览、跳一跳结果页地块图集整图预览、大鱼吃小鱼关卡主图缩略图、大鱼吃小鱼素材工坊候选预览、大鱼吃小鱼场地背景竖版预览、creative-agent 模板确认预览、拼图图库详情页封面轮播媒体框、认证验证码图片以及抓大鹅结果页物品素材列表缩略图、详情大图、视角缩略图、UI 素材背景 / UI spritesheet / 物品 spritesheet 主图预览已先迁移。拼图发布弹窗封面关卡预览、creative-agent 模板确认预览和认证验证码图片使用 `surface="soft"` 承接白底柔和边框,业务 JSX 只保留局部圆角、高度或 fallback 渐变差异;拼图关卡列表正式图、拼消消场地底图 / 素材图集、宝贝识物素材卡顶部媒体槽、视觉小说资产字段、creative-agent 模板目录卡、跳一跳地块图集整图、大鱼关卡主图 / 工坊候选 / 场地背景主题槽、拼图图库详情页封面轮播媒体框、方洞封面 / 背景点击预览、方洞形状 / 洞口贴图缩略图和抓大鹅 UI 素材页白底预览壳 / 详情视角缩略图嵌在保留媒体壳或交互态的外层壳内,使用 `surface="none"` 只承接图片 / fallback抓大鹅素材列表缩略图和详情大图使用 `surface="bright"` 承接亮白素材槽,并通过容器属性透传保留测试 id / aria。后续需要图片源、fallback 图、fallback 文案 / 自定义占位内容、fallback 外壳局部着色、固定比例或 overlay 的预览框只传素材地址、可访问名称、比例、surface、刷新 key 和覆盖层,不再在业务 JSX 中重复拼图片框渐变、无图占位、`aspect-*`、基础边框 / 底色和绝对定位 overlay。
18.7.5. 汪汪声浪结果页草稿编译小卡迁移到 `PlatformSubpanel surface="flat"`,跳一跳结果页排行榜行卡迁移到 `PlatformSubpanel surface="flat"`,排行榜无成绩空态迁移到 `PlatformEmptyState surface="subpanel"`;结果页只保留玩法文案、排行字段和错误 / 空态文案,不再手写白底小卡圆角、边框、底色和 padding。
18.7.6. creative-agent 模板目录卡迁移到 `PlatformSubpanel as="button" interactive surface="flat"`,卡内 16:9 预览迁移到 `PlatformMediaFrame aspect="landscape" surface="none"`工作台只保留模板选择、标题、摘要、预览渐变局部样式和泥点范围不再手写白底按钮卡、16:9 图片框或图标 fallback 容器。
18.7.7. `PlatformSubpanel` 支持 `surface="dark"``radius="xs"``padding="xs"`,用于承接暗色编辑 / 运行面板中的小型信息卡RPG 冒险面板 / 覆盖层任务目标、区域、进度、任务摘要卡、奖励条、描述卡、任务更新提示、任务日志条目、冒险统计总览和统计卡、任务完成领奖提示、奖励缓存、战斗结束、战利品面板和奖励物品详情描述 / 效果 / 标签自定义世界实体目录角色维度小卡自定义选择弹窗当前角色信息块RPG 大编辑器场景幕背景信息、预设背景和场景连接关系面板,角色面板个人线阶段 / 背景 / 性格块 / 装备行,好感状态卡的等级摘要 / 进度面板,背景故事公开印象 / 已解锁章节 / 锁定章节面板,角色详情装备 / 背包 / 旅程 / 背景 / 性格小卡,通用角色技能卡,实体详情主分区壳和最近回响里的后果 / 编年 / 载体 / 场景残留卡,背包文书 / 故事档案 / 工坊分区与非交互条目卡NPC 交易数量 / 库存 / 详情 / 总价 / 交易详情装备与使用属性,以及角色聊天状态 / 总结等静态信息卡已先迁移。RPG 大编辑器本地 `EditorInfoPanel` / `SectionPanel` 和实体详情本地 `Section` 只保留标题、右侧动作、subtitle 或内容插槽,暗色面板 chrome 继续由 `PlatformSubpanel surface="dark"` 承接;可扮演角色背景故事、关系、技能、物品和世界基础设定等编辑分区不再手写外层暗色面板。后续暗色小信息卡只保留标题、图标、值和必要动作,不再重复手写 `rounded-xl border border-white/10 bg-black/* px-* py-*`
18.7.7.1. `PlatformSubpanel` 支持 `surface="darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`,用于承接暗色编辑 / 运行面板中的强调态结构化信息面板;实体详情私聊提示、队友收束、玩家等级进度、角色面板等级 / 收束状态、任务奖励好感度 / 货币 / 经验数值卡、RPG 大编辑器上传封面中提示、地图场景切换目标场景面板和共享构筑状态标签外壳已先迁移。后续同类只读或半结构化提示只传 surface、radius、padding 和业务内容,不再手写 `border-*-400/18 bg-*-500/8` 暗色 tint 面板。
18.7.8. 拼图图库详情页封面轮播壳迁移到 `PlatformSubpanel radius="xl" padding="none"`,内层图片 / fallback / 轮播 overlay 迁移到 `PlatformMediaFrame surface="none"`;详情页只保留图片 slide 数据、轮播按钮和 fallback 文案,不再手写 `rounded-[1.5rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]` 静态封面面板或直接依赖底层 `ResolvedAssetImage`
18.7.9. 抓大鹅结果页物品详情五视角面板迁移到 `PlatformSubpanel radius="xl" padding="sm"` 并通过局部 `sm:p-5` 保留桌面间距;详情页只保留视角预览、缩略图切换和素材名称字段,不再手写 `platform-subpanel min-h-0 rounded-[1.5rem] p-3 sm:p-5`
18.7.10. RPG 暗色弹窗里的可选项按钮卡迁移到 `PlatformDarkOptionCard`NPC 交易模式、交易物品行、赠礼候选、招募替换候选、角色素材工作室动作预览格、营地编组替换位按钮和角色聊天建议按钮已先迁移。业务页只传 selected、tone、点击回调和内容布局不再重复写选中 / 未选中暗色卡片边框、底色、hover 和 disabled chrome像素风 footer 按钮、强品牌动作按钮和含复杂禁用语义的动作按钮继续保留专用布局。
18.7.11. 发布分享弹窗渠道 tile 按钮迁移到 `PlatformSubpanel as="button" interactive surface="flat"`;复制反馈状态、渠道枚举和品牌图标继续留在分享弹窗内。
18.7.12. 平台入口创作类型弹层玩法卡片迁移到 `PlatformSubpanel as="button" surface="platform" radius="xl" padding="none"`;玩法图片蒙版、锁定 badge、标题副标题和分流回调继续由弹层组件持有。
18.7.13. creation-agent 工作台聊天区外壳迁移到 `PlatformSubpanel radius="xl" padding="none"`;消息列表、上传预览、错误提示和输入区继续由工作台组件持有。
18.7.14. 绑定手机号页左侧的“当前登录身份”提示块迁移到 `PlatformSubpanel as="div" radius="sm" padding="md"`;认证页只保留品牌说明、当前用户显示名和绑定流程,不再手写 `platform-subpanel` 信息块外壳。
18.8. 平台标签编辑器迁移到 `PlatformTagEditor`;拼图、敲木鱼和抓大鹅结果页标签编辑已先迁移。后续标签编辑只把 parse / normalize 和保存语义留在业务页,新增输入状态、删除 chip、空态、AI 生成按钮和错误提示统一由 Module 承接。
19. 个人中心充值、任务、兑换、邀请、支付结果等弹窗里的普通主动作按钮迁移到 `PlatformActionButton surface="profile"`RPG 首页作品卡删除小动作、RPG 作品详情、RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪 / 视觉小说 / 大鱼吃小鱼结果页、自定义世界实体目录小动作、生成结果恢复面板、通用生成页重试 / 中断动作、法律信息弹窗 footer、公共确认弹窗 footer、统一创作工作台、统一创作页壳层、拼图创作工作台、拼消消创作工作台、宝贝识物创作工作台、视觉小说创作工作台、汪汪声浪创作工作台、creation-agent 推荐回复、creative-agent 工作台、creative-agent 模板确认弹窗、创作中心错误重试、创作中心作品卡积分激励领取按钮、反馈页 header 返回、通用创作输入面板、认证表单、敲木鱼 fallback 返回、跳一跳结算、拼消消 runtime header / 结算弹窗和视觉小说 runtime 普通白底面板里的普通主动作 / 次动作 / 危险动作迁移到 `PlatformActionButton surface="platform"`RPG 暗色弹窗 / 运行面板中的角色自定义 footer、生成 footer、地图切换确认、营地编组普通动作、角色聊天刷新动作、角色素材工作室本地 `ActionButton`,以及 RPG 大编辑器暗色面板内的保存 / 角色槽动作都迁移到 `PlatformActionButton surface="editorDark"`。若业务侧仍需要 `stopPropagation`、局部 tone 映射或内容排版差异,可以保留局部 `ActionButton` 包装层,但包装层本体应委托共享按钮,而不是继续直接渲染原生 `<button>`。统一创作工作台、统一创作页壳层、玩法创作工作台、结果页返回按钮和反馈页 header 返回使用 `tone="ghost"`,提交 / 生成 / 发布 / 保存按钮使用默认主动作,素材槽小按钮、作品卡角落小动作、拼图图片生成模式选择器触发器和白底面板行内动作使用 `size="xs"``shape="pill"`,积分激励领取这类密集卡片小动作使用 `size="xxs"` 并由局部卡片 class 保留响应式布局,暗色微型刷新动作使用 `size="xxs" shape="pill"`,左对齐回复 / 列表动作使用 `align="start"`,认证表单提交、验证码、第三方登录和邀请码提交按钮使用 `size="lg"` 保持 48px 高度,文件上传 label 使用 `asChild="label"` 保持上传语义;复制邀请、错误复制、完成复制和分享复制继续使用 `CopyFeedbackButton` 管状态,并通过 `actionSurface` 复用动作按钮外观。大鱼吃小鱼结果页资产工坊 footer、关卡主图 / 动作入口和场地背景生成这类白底平台动作也使用 `shape="pill" size="xs"`,深色 hero 返回 / 测试 / 发布按钮保留玩法品牌布局。后续带复制三态的按钮不改用普通 ActionButton避免复制状态分支回流业务页暗色可选项卡继续使用 `PlatformDarkOptionCard`,像素风发送按钮和强品牌动作继续保留专用布局。
19.1. `CopyFeedbackButton` 支持 `actionShape`,用于在复用共享复制状态机时直接对齐 `PlatformActionButton` 的圆角外观;拼图广场详情 hero 的“分享作品”已使用 `actionSurface="editorDark" actionShape="pill"`,不再手写复制按钮 rounded / border / bg class。
19.2. 拼图广场详情 hero 的返回、上一张 / 下一张关卡图入口迁移到 `PlatformIconButton variant="darkMini"`,修改作品和进入第 1 关迁移到 `PlatformActionButton`,分享动作继续使用 `CopyFeedbackButton` 但复用共享动作按钮 chrome详情页只保留轮播、复制和跳转语义不再手写 hero 区按钮壳。
19.3. 个人中心充值商品卡里的“购买 / 处理中”胶囊暂保留局部 `span`,不直接套用 `PlatformActionButton`,避免在 `PlatformSubpanel as="button"` 内再嵌套交互按钮;待出现第二个同形态的非交互 action chip 后,再决定是否沉淀独立的共享展示基元。
19.3.1. RPG 首页创作 / 草稿顶栏的钱包快捷入口迁移到同文件适配器 `TopbarWalletShortcutButton`,内部复用 `PlatformActionButton tone="accentSoft" shape="pill" size="xs"``PlatformIconBadge`;移动端和桌面端继续保留 `.platform-mobile-create-wallet-chip``.platform-desktop-create-wallet-chip``.platform-desktop-search` 兼容 class承接移动端余额截断、桌面顶栏胶囊底色以及既有测试锚点。入口点击仍统一走 `openRechargeOrRewardCodeModal`,不把充值 / 兑换码平台分流逻辑改散到两个顶栏分支里。
19.3.2. 个人中心昵称修改、账户充值、每日任务和兑换码四类标准头部弹窗迁移到 `UnifiedModal``UnifiedModal` 新增 `closeVariant``closeOnEscape``titleClassName``descriptionClassName`,用于在收口弹窗壳层时保留个人中心 `profile / profileCompact` 关闭按钮、居中浮层布局和标题层级。上述弹窗统一通过 `closeOnBackdrop={false}``closeOnEscape={false}` 保持原有交互语义,不把 backdrop / Escape 关闭行为悄悄带进个人中心;邀请、玩过作品等结构更复杂的二级弹层继续按同一壳层策略逐步迁移。
19.3.3. 个人中心支付结果提示和支付确认遮罩迁移到 `UnifiedModal` 的 headerless 模式;`UnifiedModal` 新增 `showHeader`,用于在保留 `role="dialog"`、可访问名称、遮罩和 z-index 语义的同时,允许业务页自己排版 icon badge、轻量标题和正文。支付结果提示与确认遮罩统一使用 `showHeader={false}``showCloseButton={false}``closeOnBackdrop={false}``closeOnEscape={false}`,继续保持阻断式确认语义;业务页只保留图标、文案和按钮,不再手写 backdrop、dialog aria 和面板壳层。
19.3.4. 个人中心移动端顶栏的“扫码”“打开设置”入口迁移到 `PlatformIconButton`;页面继续保留 `.platform-profile-header__icon-button` 局部 class 控制位置、尺寸和主题色,交互语义与可访问名称统一由共享按钮承接,不再在 `RpgEntryHomeView` 里手写图标按钮的 `type``aria-label` 和基础 chrome。
19.3.5. 发现页分类筛选弹窗与个人中心扫码面板迁移到 `UnifiedModal`;分类筛选继续复用本地选项栅格和底部动作区样式,但 backdrop、dialog 语义、头部关闭入口和 `closeOnEscape={false}` 统一收口到共享壳层。扫码面板复用 `showHeader={false}` 模式保留深色自定义头部、摄像头 viewport 和状态提示,同时显式保持 `closeOnBackdrop={false}``closeOnEscape={false}`,确保不会把扫码中的资源清理语义改散到页面外层。
19.3.6. 个人中心泥点账单弹窗迁移到 `UnifiedModal` 的 headerless 模式;共享壳层承接 `dialog` 语义、层级和关闭策略,账单弹窗继续保留自定义渐变面板、浮动关闭按钮、余额 badge、列表 / 空态 / 错误态布局以及 `closeOnBackdrop={false}``closeOnEscape={false}` 的原有交互,不再手写 `fixed inset-0` 遮罩壳层。
19.3.7. 个人中心“玩过作品”面板迁移到 `UnifiedModal` 的 headerless 模式;共享壳层承接 `dialog` 语义、层级与关闭策略,面板继续保留 `PLAYED` kicker、总时长 badge、浮动关闭按钮、`可继续 / 玩过` 双分区、作品卡与空态布局,以及 `closeOnBackdrop={false}``closeOnEscape={false}` 的原有交互。存档入口仍留在同一个“玩过”面板内,不再回退成独立的 `SAVE ARCHIVE` / `ARCHIVE` 壳层。
19.3.8. 个人中心邀请相关弹层中的 live 分支迁移到 `UnifiedModal` 的 headerless 模式;玩家社区与填邀请码继续保留浮动关闭按钮、居中标题、二维码卡片、邀请码表单 / 已填写空态和成功 / 失败提示,但 `dialog` 语义、层级与关闭策略统一由共享壳层承接。`community / redeem` 两条真实入口继续显式保持 `closeOnBackdrop={false}``closeOnEscape={false}`;历史 `invite` 分支暂不扩张能力面,只随同一壳层复用现状内容。
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.11. 创作中心公开作品卡右上角的分享快按钮迁移到 `PlatformIconButton`;作品卡继续保留 `.creation-work-card__quick-action-button` 局部 class 承接卡片角落定位和尺寸,并显式保留 `stopPropagation`、关闭 swipe action、清理 `suppressOpenRef` 与分享回调顺序,避免右上角分享入口误触整卡打开或遗留左滑状态。验证命令:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`
19.3.12. RPG 首页个人中心的统计卡、统计骨架、常用功能入口、设置行与法律信息入口抽离到 `src/components/platform-entry/PlatformProfilePrimitives.tsx``RpgEntryHomeView` 只继续保留账户数据、图片资源、点击回调和打开弹层的控制器,不再把这一组纯展示原子和个人中心页面编排混在同一个 7k+ 首页文件里。组件级验证新增 `src/components/platform-entry/PlatformProfilePrimitives.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePrimitives.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile stats cards are centered without update timestamp|profile page shows legal entries and hides archive shortcuts"`
19.3.13. RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts``RpgEntryHomeView` 仅保留个人中心展示、昵称头像编辑、扫码入口和页面级编排 / 交互,不再直接承接账户动作分流、商业状态派生和面板控制。该收口默认保持现有弹层与充值链路语义不变,避免在职责迁移时顺带扩张行为面。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck`
19.3.14. RPG 首页个人中心的“玩过 / 可继续”历史弹层抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx``RpgEntryHomeView` 不再内联 `SaveArchiveCard``ProfilePlayedWorksModal` 和旧的 `ProfileSaveArchivesModal`。当前真实产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此未连通的 `saveArchives` profile popup 分支一并删除,避免继续维护没有入口的独立壳层。组件级验证新增 `src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck`
19.3.15. 个人中心标准头部弹窗与白底副弹层壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx``PlatformProfileModalShell` 负责标准账户弹窗的 overlay、header、title、description、close variant 和 `closeOnBackdrop={false} / closeOnEscape={false}` 约束,`PlatformProfileSecondaryModalShell` 负责白底副弹层的 overlay、floating close、`bodyClassName="!p-0"` 和内容外壳。`RpgEntryHomeView` 内的昵称修改、账户充值、每日任务、兑换码、泥点账单与“玩过”弹层已接到共享壳层,页面不再重复手写个人中心弹窗的基础 chrome 与关闭策略。
19.3.15.1. `PlatformProfileModalShell` 继续补齐标准 footer 插槽:壳层现已直接透传 `UnifiedModal.footer``footerClassName``RpgEntryHomeView.tsx` 的昵称修改弹窗不再把双按钮动作区塞在 body 末尾,而是改成标准 profile modal footer。后续个人中心里同类“表单 body + 底部双按钮动作区”弹窗,优先走 `PlatformProfileModalShell + footer`,不要把共享按钮再手写回内容区。
19.3.15.2. `PlatformProfileModalShell` 的 footer 接法继续扩展到单 CTA 表单收尾:`PlatformProfileRewardCodeRedeemModal.tsx` 的兑换动作已迁到标准 profile footerbody 仅保留输入与反馈消息;后续个人中心里这种“输入表单 + 底部唯一主动作”弹窗,也优先复用壳层 footer而不是把按钮继续塞在内容区。验证命令`npx vitest run src/components/platform-entry/PlatformProfileModalShell.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx src/components/common/PlatformAssetPickerCard.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/auth/AccountModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
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.17. RPG 首页个人中心的账户充值弹层抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;组件承接 Native 二维码生成、点数 / 会员 tab、套餐卡片、空态和错误重试继续复用 `PlatformProfileModalShell` 与平台白底卡片 token`RpgEntryHomeView` 不再内联 `useWechatNativeQrCode``RechargeProductCard``ProfileRechargeModal`。组件级验证新增 `src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的充值入口与 Native 二维码断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders point products and forwards buy action|shows empty state when the selected tab has no products|profile recharge modal shows native qr code on desktop web by default|create tab wallet chip opens recharge when recharge entry is enabled"``npm run typecheck`
19.3.18. RPG 首页个人中心的泥点账单、每日任务和兑换码三类标准 profile 弹层分别抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx``src/components/platform-entry/PlatformProfileTaskCenterModal.tsx``src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`;账单继续复用 `PlatformProfileSecondaryModalShell`,任务和兑换码继续复用 `PlatformProfileModalShell`,页面不再内联账单余额 badge、任务领取列表和兑换码输入提交实现。三者均新增组件级测试并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的真实入口断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders ledger entries with shared balance presentation|retries from the shared error state|renders claimable tasks and forwards claim action|keeps incomplete tasks disabled|submits on button click and enter key|disables submit when the code is blank|opens wallet ledger modal from narrative coin card|profile daily task shortcut reflects task progress and claim updates|wallet ledger modal shows empty and error states|opens reward code modal from profile action on mobile|create tab wallet chip opens reward code when recharge entry is hidden"``npm run typecheck`
19.3.19. RPG 首页个人中心的支付结果提示、支付确认遮罩与扫码面板继续向共享组件收口:支付结果 / 确认中弹层统一抽到 `src/components/common/PlatformStatusDialog.tsx`,扫码面板统一抽到 `src/components/platform-entry/PlatformProfileQrScannerModal.tsx``RpgEntryHomeView` 仅保留支付状态映射、扫码打开关闭和结果写回,不再内联 `RechargePaymentResultModal``RechargePaymentConfirmationMask``ProfileQrScannerModal``BarcodeDetector` 启动逻辑和 profile 弹层壳层参数。组件级验证新增 `src/components/common/PlatformStatusDialog.test.tsx``src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的支付 / 扫码入口断言。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx``npm run test -- src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal jumps to h5 payment on mobile web by default|profile recharge modal posts mini program payment request and reacts to success hash result|profile recharge modal releases submitting state and shows virtual payment failure detail|profile recharge modal eventually shows error text even when hashchange is not dispatched|profile recharge modal resumes virtual payment confirmation when pageshow returns with paid order|profile recharge modal blocks tab navigation while virtual payment confirmation is pending|profile scan action opens camera scanner instead of recharge panel"``npm run typecheck`
19.3.20. `PlatformStatusDialog` 继续扩展到 notice 场景:组件新增 header notice 布局、body content、close button、backdrop / Escape 关闭路径以及动作按钮样式透传;`PlatformEntryFlowShellImpl` 里的 `draftGenerationPointNotice` / `workNotFoundRecoveryDialog``RpgCreationEntityEditorShared.tsx` 里的 `EditorNoticeDialog` 已接入。创作入口泥点不足、作品不可用恢复和 RPG 大编辑器规则阻断提示不再各自维护 `UnifiedConfirmDialog` 壳层,只保留标题、正文、辅助提示和关闭回调。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "bark battle form checks mud points before creating a draft|puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|direct missing public work detail shows unified dialog before returning home"``npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色至少保留一个背景章节时使用统一提示弹窗|场景连接缺少可连接目标时使用统一提示弹窗|场景保存缺少主角色时使用统一提示弹窗"``npm run typecheck`
19.3.21. `PlatformStatusDialog` 继续收口规则阻断和搜索未命中提示:`CustomWorldEntityCatalog.tsx``minimum-playable` 规则阻断从删除确认分支中拆出,改由独立 `PlatformStatusDialog` 承接;`PlatformEntryFlowShellImpl` 的公开编号搜索弹层拆成“命中用户继续走 `UnifiedModal + PlatformSubpanel`”与“未找到结果改走 `PlatformStatusDialog`”两条分支。业务页不再让规则阻断提示和危险删除确认共用同一套 confirm config也不再在搜索结果 modal 内同时维护用户信息和错误态两套内容布局。验证命令:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "最后一个可扮演角色不可删除时使用平台状态弹窗"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "searching unmatched public work code shows not-found search result dialog|public code search shows public user summary in shared search result modal and clears it on close"``npm run typecheck`
19.3.22. 标准泥点消耗确认弹窗收口到 `src/components/common/PlatformMudPointConfirmDialog.tsx`;组件固定承接“确认消耗泥点 + 消耗 N 泥点”的同形态标题、正文骨架和确认 / 取消动作,业务页只保留点数、补充说明和确认回调。`PuzzleCreationWorkspace.tsx``Match3DCreationWorkspace.tsx``PuzzleResultView.tsx``Match3DResultView.tsx` 以及 `RpgCreationRoleAssetStudioModalImpl.tsx` 已迁移;其中角色形象生成 / 动作草稿生成继续通过自定义 title 和补充说明承接工坊语义,但不再各自拼接 `UnifiedConfirmDialog` 的相同文案和内容结构。后续同类泥点确认优先复用该 Module像 runtime 道具确认、预计消耗区间确认这类节奏不同的弹层再单独评估是否扩展变体。
19.3.23. 平台危险确认弹窗收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;组件固定承接“确认 / 取消 + 危险主动作”的标准骨架,并透传忙碌态、遮罩关闭策略、按钮文案和局部面板样式。`PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认,以及 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认已迁移;业务页继续保留标题、说明文案和确认回调,不再各自拼接 `UnifiedConfirmDialog` 的危险按钮配置。后续删除、覆盖、清空等危险动作优先复用该 Module再按需要补充更窄的语义 wrapper。
19.3.24. 平台未保存离开确认弹窗收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;组件固定承接“继续编辑 + 确认离开”的标准骨架,并按 `platform / pixel` 两类确认风格兜底默认遮罩和面板样式。`RpgCreationEntityEditorShared.tsx` 中的关闭未保存修改确认、生成结果未保存退出确认和普通结果未保存退出确认已迁移;业务页只保留标题、确认按钮文案和未保存提示内容,不再各自拼接 `UnifiedConfirmDialog` 的 cancel/confirm 组合和重复壳层 class。
19.3.25. 平台单按钮已读状态弹窗收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;组件固定承接“状态提示 + 知道了”这一类单按钮确认已读语义,并透传 action surface / size / fullWidth / class、header、关闭路径和局部 panel 覆写。`BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示已迁移;业务页继续保留 status、标题、说明和关闭回调不再各自手写 `PlatformStatusDialog``action={{ label: '知道了', onClick: onClose }}` 结构。
19.3.26. profile 侧重复的 `error / loading / empty / content` 分支统一收口到 `src/components/common/PlatformAsyncStatePanel.tsx`;该 Module 只承接互斥状态切换,不承接需要和内容并存的 success / error banner。`PlatformProfileReferralModal.tsx``PlatformProfileWalletLedgerModal.tsx``PlatformProfilePlayedWorksModal.tsx``PlatformProfileTaskCenterModal.tsx``PlatformProfileRechargeModal.tsx` 已接入。后续 profile 或白底 panel 侧若只是同形态互斥异步状态,优先传 slot 复用该骨架,不再把 `loading skeleton` / `empty state` / `retry error` 直接写回业务页。
19.3.27. `PlatformSegmentedTabs` 支持 `layout="scroll"` 承接横向可滚动 tab rail`CustomWorldCreationStartCard.tsx``CustomWorldWorkTabs.tsx``RpgEntryHomeView.tsx` 的排行 tab、分类筛选项已接入。共享组件先统一 `tablist/tab` 语义、滚动容器和基础交互;当同一类皮肤在首页、作品架、分类筛选或个人中心内重复出现时,再沉淀到 `src/components/common/PlatformSegmentedTabPresets.tsx` 的薄 preset避免业务页继续复制 `itemClassName`,也避免把一次性玩法配置项抽成过胖公共组件。
19.3.28. `PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层改为复用 `src/components/common/PlatformModalCloseButton.tsx``variant="pixel"`;共享 close button 统一承接像素风基础 chrome、`absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 冒泡拦截,`CharacterChatModal.tsx``MapModal.tsx` 的 inline / absolute 真实 importer 已补测试。后续需要像素风关闭按钮时优先使用 `PlatformModalCloseButton variant="pixel"` 或继续复用 `PixelCloseButton` 语义壳,不再手写本地 close button。
19.3.29. 平台入口创作前置泥点阻断提示抽到 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并用 `DraftGenerationPointNotice` union`insufficient-points` / `balance-load-failed`)承接业务真相;`PlatformEntryFlowShellImpl.tsx` 不再直接拼 `PlatformAcknowledgeStatusDialog` 的标题、说明和 amber icon 条件分支。后续若只是平台入口里的泥点前置检查提示,优先继续扩展这个局部语义 wrapper不要急着在 `common/` 抽泛化 `BlockingNoticeDialog`,避免把底层状态弹窗的样式透传再次包装一层。
19.3.30. `PlatformSegmentedTabs` 继续承接首页 / 结果页里剩余的横向 rail 与二选一切换:`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail`CustomWorldEntityCatalog.tsx``RESULT_TABS` sticky rail以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。`CustomWorldEntityCatalog` 通过 `ReactNode label` 保留“标题 + count”两行内容`RpgEntryHomeView`、创作入口、作品架和个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤沉淀到 `PlatformSegmentedTabPresets`,业务页只保留 items、activeId 和回调。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,不再把这些切换项当普通 button一次性玩法配置项继续直接组合 `PlatformSegmentedTabs`
19.3.31. 简单泥点确认流的开关状态机收口到 `src/components/common/useMudPointConfirmController.ts`;该 hook 只承接 `open / requestOpen / close / confirm` 四个动作,`confirm` 固定先关弹窗再执行回调,不持有 `points / title / description / confirmDisabled` 之类业务字段。`PuzzleCreationWorkspace.tsx``Match3DCreationWorkspace.tsx``Match3DResultView.tsx` 的两个批量素材面板已接入;`PuzzleCreationWorkspace` 仍在业务页判断“只有 `aiRedraw` 才弹确认”。`PuzzleResultView.tsx``RpgCreationRoleAssetStudioModalImpl.tsx` 这类要么节奏不同、要么携带 pending payload 的场景先保留本地状态机,不把 hook 扩成泛型动作路由器。
19.3.32. 标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页里重复的白底 portal 弹窗壳层进一步收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 以支持“可见标题随业务对象变化、可访问名称保持固定”的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态专属 overlay 继续保留本地 close 语义,不把 `PlatformToolModalShell` 硬塞进非平台白底工具弹窗场景。
19.3.33. `PlatformAsyncStatePanel` 从 profile modal 扩展到作品架:`CustomWorldCreationHub.tsx` 的作品架主体现在也统一通过 `loadingState / emptyState / children` 三个 slot 切换,保留外层 error + 重试提示不并入共享状态骨架。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用 `PlatformAsyncStatePanel`,不要再在业务 JSX 中重复拼 skeleton 和“当前筛选下没有内容”的分支。验证命令:`npx vitest run src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx``npm run check:encoding`
19.3.34. `CopyFeedbackButton.tsx``actionSurface` 分支继续向共享按钮收口:带平台动作外观的复制按钮现在直接组合 `PlatformActionButton`,仅保留 `pill` 分支继续复用 `PlatformPillBadge` 风格。复制反馈按钮不再手动调用 `getPlatformActionButtonClassName` 拼平台按钮基础 chrome后续同类“复制状态机 + 平台动作按钮”组合也优先走 `CopyFeedbackButton + PlatformActionButton`不要在业务页或按钮组件里重新混写图标、文案、aria 和 class。验证命令`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/common/PlatformActionButton.test.tsx`
19.3.35. 详情页头部动作组合收口到 `src/components/common/PlatformDetailTopbar.tsx``src/components/common/PlatformDetailShareActions.tsx`;前者只承接“返回 / 标题 / 右侧动作槽位”的 topbar 骨架,并允许 `pill` / `icon` 两种返回按钮语义,后者只承接“前置 badge 区块 + 作品号复制 + 分享复制”这一组稳定动作,不吸收详情页自己的标题、摘要、作者、封面轮播或业务 CTA。`RpgEntryWorldDetailView.tsx` 已接入完整的 overlay 版动作组合,统一世界主题 badge、作者、发布时间、作品号和分享入口`PlatformWorkDetailView.tsx` 已接入 icon topbar 与 solid 版作品号复制动作,并继续保留公开详情页自己的顶部 icon 分享入口和分享反馈提示。后续同类详情页若只是复用返回按钮骨架、标题居中布局或作品号 / 分享动作排列,优先直接组合这两个 Module不要把整页 detail header 抽成巨型配置对象。验证命令:`npx vitest run src/components/common/PlatformDetailTopbar.test.tsx src/components/common/PlatformDetailShareActions.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/platform-entry/PlatformWorkDetailView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check -- src/components/common/PlatformDetailTopbar.tsx src/components/common/PlatformDetailTopbar.test.tsx src/components/common/PlatformDetailShareActions.tsx src/components/common/PlatformDetailShareActions.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.tsx src/components/platform-entry/PlatformWorkDetailView.tsx`
19.3.36. `PlatformToolModalShell` 继续承接 RPG 结果页发布检查弹窗;`RpgCreationResultActionBar.tsx` 只保留发布检查、封面预览、封面设置和发布动作语义,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close、body/footer spacing 和遮罩关闭逻辑。后续结果页 / 工具页里同形态的白底 portal 弹窗优先迁移到 `PlatformToolModalShell`;编辑器大壳、暗色 runtime overlay 和需要专属布局的面板继续保留局部 shell。
19.3.37. `PlatformToolModalShell` 继续承接方洞结果页图片槽弹窗;`SquareHoleResultView.tsx` 的封面 / 背景 / 形状 / 洞口图片查看与历史选择弹窗只保留当前图、上传、AI 生成和历史素材选择语义,不再直接维护 `createPortal`、主题 overlay、白底 remap panel、header close 和滚动 body。该弹窗使用 `ariaLabel` 保持“封面图查看 / 背景图查看”等固定可访问名称,历史生成区继续由 `PlatformAssetPickerGrid` 承接读取、错误和空态。
19.3.38. `PlatformToolModalShell` 继续承接视觉小说结果页素材选择弹窗;`VisualNovelAssetPickerDialog` 只保留本地上传、AI 图片生成、历史素材读取、错误提示和素材选择回调,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close 和滚动 body。视觉小说音频生成弹窗需要保留生成中禁止关闭实体编辑器弹窗需要保留编辑 footer本轮先不混入同一提交后续逐个迁移并补对应交互测试。
19.3.35. 白底 / 暗色面板里的轻量空态和普通 CTA 继续按共享组件收口:`PuzzleResultView.tsx` 的“还没有可编辑的拼图草稿”、`RpgCreationAssetDebugPanel.tsx` 的“没有可诊断项”、`VisualNovelEntityGrid` 的空实体列表、`AccountModal.tsx` 里账号安全分区的“无安全限制 / 无登录设备 / 无操作记录”以及 `LoginScreen.tsx` 的“当前登录入口暂不可用”都改为 `PlatformEmptyState``Match3DResultView.tsx` 的引用素材列表直接交给 `PlatformAssetPickerGrid` 自己处理空态。`AdventureEntityModal.tsx` 的私聊按钮、`InventoryPanel.tsx` 的锻造 / 合成按钮、`RpgAdventurePanel.tsx` 底部 `队伍 / 背包 / 换一换 / 退出聊天` 按钮,以及 `RpgAdventurePanelOverlays.tsx` 里的“查看任务 / 保存并退出”都改为 `PlatformActionButton surface="editorDark"`,业务页只贴回局部 sky / emerald / runtime 皮肤。后续白底子面板里的只读空态优先使用 `PlatformEmptyState surface="subpanel"`;暗色编辑 / 运行面板里的普通动作优先使用 `PlatformActionButton surface="editorDark"`,若还需要 stopPropagation、局部字号或图标排版可保留薄包装层但不要再回退到原生 `<button>` 基础 chrome。验证命令`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/AdventureEntityModal.test.tsx src/components/InventoryPanel.test.tsx src/components/auth/AccountModal.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformActionButton.test.tsx``npm run check:encoding`
19.3.36. `VisualNovelEntityGrid` 的空态也继续收口到 `PlatformEmptyState surface="subpanel" size="inline"`;角色 / 场景 / 剧情阶段共用这一网格组件后,白底实体列表里的“暂无角色 / 暂无场景 / 暂无剧情阶段”等同构空态不再回退成 `PlatformSubpanel`。同时,`RpgCreationRoleAssetStudioModalImpl.tsx``RpgCreationEntityEditorShared.tsx` 保留局部 `ActionButton` 语义壳,但按钮本体已统一委托给 `PlatformActionButton surface="editorDark"`,只在包装层补最小的 `stopPropagation`、tone 映射和局部 class 适配。后续类似“暗色编辑器局部包装按钮”优先沿用这种薄包装模式,不再直接手写原生 `<button>` 基础 chrome。验证命令`npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformActionButton.test.tsx``npm run check:encoding`
19.3.37. 暗色编辑器里的局部动作按钮继续往共享 `editorDark` button 收口:`CustomWorldNpcVisualEditor.tsx` 的本地 `ActionButton``SkillEffectPreview.tsx` 的“重新预览”按钮都改为委托 `PlatformActionButton surface="editorDark"`。这类局部包装仍可保留 `stopPropagation`、图标布局、`tone` 映射和少量局部视觉覆写,但按钮本体不再直接使用原生 `<button>` 承接边框 / 底色 / hover / disabled chrome。验证命令`npm run test -- src/components/common/PlatformActionButton.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.38. `PlatformAsyncStatePanel` 继续从 profile / 作品架扩展到首页公开分区:`RpgEntryHomeView.tsx` 的移动端排行、发现页寓教于乐 / 默认公开 feed、桌面首页“今日游戏 / 推荐”、桌面发现页寓教于乐 / 默认公开 feed以及“我的创作”分区都统一改成 `loadingState / emptyState / children` 三个 slot 切换。页面继续把 `platformError` 保留在状态壳外层,让错误提示可以和内容并存;`recommend runtime`、分类筛选和其它含二级筛选 / 运行态语义的分支暂不并入这次收口。后续首页、作品架或白底列表若只是纯 `loading / empty / content` 互斥状态,优先直接复用 `PlatformAsyncStatePanel`,不要再把空态与读取态分支手写回业务 JSX。验证命令`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.39. 桌面首页里的轻量可点击扁平行统一收口到 `src/components/common/PlatformNavigableListItem.tsx`;该 Module 只承接 `button + 左侧主内容 + 右侧 affordance` 的结构、默认 `type="button"``leading / trailing` 插槽,不承接卡片封面、复杂摘要或 runtime 专属交互。`RpgEntryHomeView.tsx` 的搜索结果行、桌面“最近作品”、桌面“最近浏览”以及桌面“今日游戏”趋势行已接入。教培 promo card、分类卡片、世界卡和 runtime 列表项继续保留各自语义,等出现更多同构 desktop flat row 再逐步扩覆盖面。验证命令:`npx vitest run src/components/common/PlatformNavigableListItem.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.40. `PlatformNavigableListItem` 继续从桌面首页扩展到 profile 设置行:`src/components/platform-entry/PlatformProfilePrimitives.tsx` 里的 `ProfileSettingsRow` 现已统一委托共享 `button + leading + trailing` 骨架,保留本地 `platform-profile-settings-row` class 承接行间分隔、icon 胶囊和字号微调。后续 profile / 账户中心里同类“左图标标题 + 右箭头”的轻量导航行,优先直接复用 `PlatformNavigableListItem`,不要再回退成原生 `<button>` 手写布局。验证命令:`npx vitest run src/components/platform-entry/PlatformProfilePrimitives.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.41. `PlatformAsyncStatePanel` 继续补齐首页分类分支:`RpgEntryHomeView.tsx` 的移动端“发现 -> 分类”、桌面发现页“分类”以及桌面首页“作品分类”模块都改成共享状态壳承接外层 `loading / empty / content` 切换,分类控制条与排序按钮继续保留在内容 slot 中;筛选后无结果的“当前筛选下没有作品。”也统一改由内层 `PlatformAsyncStatePanel` 切换,不再在三处 JSX 中各自手写空态分支。后续同类“外层数据可用性 + 内层筛选空态”面板优先沿用这套双层状态壳,不要回退成嵌套 ternary。验证命令`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.42. `PlatformAsyncStatePanel` 继续从首页扩展到公共素材网格、runtime 面板和账号子区块:`PlatformAssetPickerGrid` 现已统一用共享状态壳承接 `loading / empty / content`,但继续把 `error` banner 留在外层,以保持“错误提示可与内容或加载态并存”的原语义;`VisualNovelSavePanel.tsx` 的存档列表,以及 `AccountModal.tsx` 里的“安全状态 / 当前登录设备 / 账号操作记录”三个子区块也都改成各自使用 `PlatformAsyncStatePanel`。后续白底列表、素材选择器或账号子面板若只是标准互斥异步状态,优先按这三种接法复用共享状态壳,不再把读取态和空态分支手写回组件内部。验证命令:`npx vitest run src/components/common/PlatformAssetPickerCard.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/auth/AccountModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.43. 轻量按钮漏网继续向共享按钮收口:`PlatformTagEditor.tsx` 的标签 chip 删除入口已改成紧凑 `PlatformIconButton`,保留 `label="删除标签 ${tag}"`、透明背景和原 chip 高度,不再手写裸 `<button>``RpgEntryCharacterSelectView.tsx` 两处重复的“返回”按钮已统一沉到文件内 `CharacterSelectBackButton`,底层委托 `PlatformActionButton surface="editorDark"`,保留原有暗色视觉与文案。后续同类“局部 chip 删除按钮”优先先用 `PlatformIconButton` 压缩尺寸和视觉;暗色轻量返回 / 返回上一级 CTA 则优先用 `PlatformActionButton surface="editorDark"` 包一层局部 helper不再复制原生 `<button>` class。验证命令`npx vitest run src/components/common/PlatformTagEditor.test.tsx src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.44. 暖色生成页顶部返回入口开始沉淀共享壳:`GenerationProgressHero.tsx` 新增 `GenerationHeaderBackButton`,统一承接 `ArrowLeft + 文案 + 透明背景` 的暖色生成页返回按钮骨架,并底层复用 `PlatformIconButton variant="darkMini"``CustomWorldGenerationView.tsx``BarkBattleGeneratingView.tsx` 已接入,继续保留各自 `backLabel`、禁用态和局部暖色文字样式。后续同类生成页、等待页或暖色 hero 顶栏若只是“左箭头 + 返回文案”的轻量返回入口,优先复用这个小组件,不再各自手写 `ArrowLeft`、透明按钮背景和字号间距。验证命令:`npx vitest run src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx src/components/common/PlatformIconButton.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.45. 白底 / 浅色结果页与工作台顶部的轻量返回入口继续收口到 `src/components/common/PlatformBackActionButton.tsx`;该 Module 固定承接 `PlatformActionButton tone="ghost" size="xs"` 上的 `ArrowLeft + 返回文案` 骨架,并只暴露 `compact / regular` 两档尺寸,分别覆盖紧凑结果页 header 与标准白底结果页顶栏。当前 `PuzzleResultView.tsx``SquareHoleResultView.tsx``Match3DResultView.tsx``VisualNovelResultView.tsx` 四个结果页已接入 `variant="compact"``PuzzleClearResultView.tsx``JumpHopResultView.tsx``WoodenFishResultView.tsx` 三个结果页已接入 `variant="regular"``BabyObjectMatchResultView.tsx` 继续使用紧凑款并保留 `className="px-3"` 贴合原横向留白。暖色生成页顶部返回入口继续走 `GenerationHeaderBackButton``BigFishResultView.tsx` 这类 dark hero / 强品牌 special case 继续保留 `PlatformIconButton variant="darkMini"` 路线,不强行并入同一个白底返回按钮基元。后续白底结果页、浅色工作台或普通 platform 顶栏里若只是“左箭头 + 返回”轻量返回入口,优先直接复用 `PlatformBackActionButton`,只在局部补尺寸和少量外边距,不再各页重复手写 ghost button class。
19.3.46. `PlatformNavigableListItem` 继续从桌面 flat row 扩展到首页公开列表里的排行行与分类行;`RpgEntryHomeView.tsx``PlatformRankingItem``PlatformCategoryGameItem` 现在都统一委托共享 `button + leading + body + trailing` 骨架,同时保留原有 `platform-ranking-item__*``platform-category-game-item__*` 局部 class 承接封面尺寸、标题摘要、公开 badge、metric 和右侧 `试玩 / 进入` affordance。后续首页、发现页或 profile 侧同类“封面 + 文本主体 + 右侧动作提示”的浅色导航行,优先先尝试复用 `PlatformNavigableListItem` 并把局部视觉挂在业务 class 上,不要为了这类 row 再回退成原生 `<button>` 手写布局;但教培 promo card、runtime 列表项和带复杂手势的卡片仍保留本地语义,不把共享行骨架扩成万能作品卡。验证命令:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.47. `PlatformDarkModalFooter` 继续从标准双按钮 footer 扩展到 detail / confirm 收尾:`NpcModals.tsx` 的交易详情单按钮 footer 与 `MapModal.tsx` 的场景切换确认 footer 已接入共享 dark footer frame分别保留“关闭”单 CTA 和“取消 / 确认前往”双 CTA 的业务语义、按钮 tone 与禁用态。后续 dark / pixel modal 里若只是标准底部分隔线 + 常规动作区排布,优先直接复用 `PlatformDarkModalFooter`,即使只有单个按钮也不再手写 `flex justify-end`;但像 `SquareImageCropModal.tsx` 这类白底弹窗 footer、sticky 工作台 footer 和运行态 HUD 工具条继续留在各自语义壳层,不强行混到 dark footer 抽象里。验证命令:`npx vitest run src/components/NpcModals.test.tsx src/components/MapModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.48. `RpgEntryHomeView.tsx` 里的分类筛选工具条继续从页面内重复 JSX 收口到 `src/components/common/PlatformFilterToolbar.tsx`;该 Module 只承接“筛选按钮 + 横向 tabs + 排序按钮”的结构排布,暴露 `mobile / desktop` 两种 layout 以覆盖移动端 divider + 独立排序行和桌面端同排布局差异,但不持有分类列表、筛选状态、空态或排序逻辑。当前 RPG 首页分类区已接入,后续若其它白底列表页也出现同构的筛选壳层,可直接复用这套薄结构组件;若场景只是在单页内局部重复、接口会为了兼容业务差异不断膨胀,则优先退回文件内 helper不把 `common` 扩成假的“万能筛选条”。验证命令:`npx vitest run src/components/common/PlatformFilterToolbar.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.49. `SquareImageCropModal.tsx` 的白底 modal 壳层与 footer 已收口到 `src/components/common/UnifiedModal.tsx``UnifiedModal` 为此只薄补了 `titleId``closeIcon` 透传,继续由调用方决定 `closeOnBackdrop``closeOnEscape``portal`、header/footer 样式和按钮内容,不额外掺入 profile 业务语义,也不让 `common/` 反向依赖 `platform-entry/``SquareImageCropModal.tsx` 继续保留裁剪拖拽、pointer capture、保存禁用态与两列等宽 footer 行为,只把 header / body / footer 外壳交给共享 modal 承接。后续 `common` 级白底工具弹窗若只是标准标题栏 + 内容区 + footer 按钮排布,优先先看 `UnifiedModal` 是否够用,再决定是否需要新的薄壳;不要为了一个弹窗把 `PlatformProfileModalShell` 之类带页面语义的壳层倒灌回 `common`。验证命令:`npx vitest run src/components/common/SquareImageCropModal.test.tsx src/components/common/UnifiedModal.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.50. `CreativeImageInputPanel.tsx` 里内嵌的 white tool modal 继续并回 `UnifiedModal` 体系:参考图预览与主图预览都改成直接复用 `src/components/common/UnifiedModal.tsx`,继续保留各自 `max-w` / `max-h` 节奏、点击遮罩关闭与紧凑 header移除图片确认改成复用 `src/components/common/UnifiedConfirmDialog.tsx`,不再在 panel 内手写 `platform-modal-backdrop + platform-modal-shell + 两列按钮`。这次没有新增 `PlatformImagePreviewModal`,因为当前预览弹窗差异还只在尺寸和文案层,继续直接组合 `UnifiedModal` 更深、更稳。后续 `common` 级图片面板若出现同类“预览大图 + 单标题栏 + 关闭按钮”弹窗,优先先复用 `UnifiedModal` 并把尺寸/文案留在调用方;只有当至少两到三个调用点开始重复同一套 preview body/header adapter 时,再考虑补新的薄壳。验证命令:`npx vitest run src/components/common/CreativeImageInputPanel.test.tsx src/components/common/UnifiedModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.51. `PlatformReportDialog.tsx``PublishShareModal.tsx` 共同的工具信息弹窗壳层继续收口到 `src/components/common/PlatformUtilityInfoModal.tsx`;该 Module 只承接平台主题 overlay、白底 panel以及 body / footer 的基础间距与标准 footer frame底层继续委托 `UnifiedModal.tsx`,不吸收报告字段列表、分享正文、复制逻辑、渠道按钮或品牌图标这些业务内容。`PlatformReportDialog.tsx` 继续保留 `PlatformInfoBlock` 字段列表与 joined report copy 行为,`PublishShareModal.tsx` 继续保留分享文案、主复制动作和渠道按钮网格;后续 `common` 级白底工具信息弹窗若只是重复这套“共享 modal 外壳 + 业务正文 / footer 内容”的骨架,优先复用 `PlatformUtilityInfoModal`,只有当正文编排或 footer 交互明显偏离时才回退到直接组合 `UnifiedModal`。验证命令:`npx vitest run src/components/common/PlatformUtilityInfoModal.test.tsx src/components/common/PlatformReportDialog.test.tsx src/components/common/PublishShareModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.52. profile 白底 modal 里的摘要头、列表骨架和内容行继续沉到 `src/components/common/PlatformProfileSummaryHeader.tsx``src/components/common/PlatformProfileSkeletonList.tsx``src/components/common/PlatformProfileContentRow.tsx`;这三个 Module 只承接 `kicker + title + badge` 的摘要层次、重复 skeleton 列表行,以及 `PlatformSubpanel` 上的 `div / button` 内容行语义,不持有账单金额、任务进度、邀请用户信息、充值商品结构或 modal 状态切换逻辑。`PlatformProfileWalletLedgerModal.tsx``PlatformProfileTaskCenterModal.tsx``PlatformProfilePlayedWorksModal.tsx``PlatformProfileReferralModal.tsx``PlatformProfileRechargeModal.tsx` 已接入;后续 profile 副弹层若只是重复这三类白底内容骨架,优先继续复用这组薄组件,不再把 skeleton、摘要头和 row chrome 写回各自 modal。验证命令`npx vitest run src/components/common/PlatformProfileModalContent.shared.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.53. 认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该 Module 只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx``RegistrationInviteModal.tsx` 已接入,业务组件只保留表单状态与提交流程。后续认证域新增同形态白底弹窗时优先复用该壳层;账号安全详情和绑定手机号这类布局差异较大的卡片先独立评估,不把 auth shell 扩成万能认证容器。验证命令:`npx vitest run src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.54. 账号 / 运行态 / onboarding 这轮继续分场景收口:`AccountModal.tsx` 的设置入口外层 overlay 与 auth card 壳层复用 `PlatformAuthModalShell`,并通过 `overlaySpacing``overlayStyle``showHeader` 和尺寸透传保留账号弹窗的 safe-area 与 direct account 唯一 dialog 语义;拼图运行态新增 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx`,只在 `puzzle-runtime` 内承接道具确认、设置、退出改造提示、失败弹窗和通关结算的 overlay / dialog / footer / button 骨架,原图查看、拖拽 ghost、飞行动画和全屏 runtime 容器不纳入 modal 收口;抓大鹅与跳一跳结算弹窗分别在 `Match3DRuntimeShell.tsx``JumpHopRuntimeShell.tsx` 内提取本地结算壳层 / summary / actions保留玩法视觉身份拼图 onboarding 首屏继续保留沉浸式全屏体验,只把登录保存覆盖层迁入 `UnifiedModal`,保持无关闭按钮、禁用遮罩关闭和禁用 Escape。后续 runtime 专属弹窗优先先抽玩法目录内薄壳;只有出现跨玩法稳定同构接口时再上升到 `common/`,不要把 `PlatformToolModalShell` 强行套到像素 / 游戏运行态 overlay。验证命令`npm run test -- src/components/auth/AccountModal.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.55. 拼图 / 拼消消运行态的剩余阻断层继续按玩法目录局部收口:`src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx` 只承接平台入口里拼图“正在准备下一关”的短暂阻断层,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板直接提升到 `common/``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 则在玩法目录内新增 `PuzzleClearRuntimeOverlayShell``PuzzleClearRuntimePendingOverlay``PuzzleClearRuntimeSettlementDialog`,把 `!activeRun` 的等待层和 `level_cleared / finished / level_failed` 的结算层统一成一条本地结构线,同时保留拼消消自己的视觉和动作分流。拖拽 ghost、swap flight、补牌 / 消除动画、全屏 runtime 容器和其它强玩法视觉层不算旧 modal 债务,不跟这条线混收。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
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.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx``PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx``npm run typecheck`
20. 平台方形上传入口和紧凑虚线新增入口迁移到 `PlatformUploadTile`,上传后的图片预览迁移到 `PlatformUploadPreviewCard`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条、creation-agent 已选参考图条和拼图结果页关卡引用图横条已先迁移。方形缩略图使用默认 `layout="square"`,横向“已选择参考图 / 文件名 / 素材名 / 移除”条使用 `layout="inline"`;只读引用图条不传 `onRemove`,避免公共组件额外渲染删除入口。后续继续收口结果页素材上传、工作台参考图上传、紧凑虚线新增入口等上传 / 动作块时,业务页只保留文件选择、预览数组、预览回调、删除回调、校验逻辑或新增回调,上传方块外观、主副文案、缩略图壳、预览按钮、标题行、横向已选条、移除按钮和禁用态统一由 Module 承接;工具栏中的小图标上传仍继续使用 `PlatformIconButton asChild="label"`
21. 图片编辑面板中的白底胶囊开关迁移到 `PlatformPillSwitch`;通用创作图片面板和抓大鹅封面编辑的 `AI重绘` 已先迁移。后续同类开关只保留受控布尔值和状态变更回调switch 输入语义、轨道、圆点、白底浮层和禁用态统一由 Module 承接。
22. 设置面板、结果页运行配置和工作台白底配置项中的整行开关迁移到 `PlatformToggleRow`视觉小说结果页、runtime 设置面板和拼消消创作工作台 AI 生成底图开关已先迁移。后续整行配置项只保留字段写回和可选点击动作,不再重复开关行 chrome、checkbox class 或状态 pill。
22.1. RPG 创作侧标准 dark header / footer 动作继续向共享按钮收口:`RpgCreationRoleAssetStudioModalImpl.tsx` 的 header“关闭”、`RpgCreationEntityEditorShared.tsx` 的 footer“取消”、`RpgCreationRoleAssetStudioFooter.tsx` 的“保存到当前角色”都改为委托 `PlatformActionButton surface="editorDark"`。局部壳层只继续保留 `stopPropagation`、tone 映射、布局和极少量字号/宽度贴合;标准暗色编辑器里的 close / cancel / save CTA 不再各自手写原生 `<button>` 基础 chrome。
22.2. RPG runtime overlay 里的标准 dark CTA 和可点击 dark row 继续向共享原子收口:`RpgAdventurePanelOverlays.tsx` 的 goal panel“知道了”、任务详情里的“领取任务 / 返回交付”、任务完成提示里的“打开任务日志”都改为委托 `PlatformActionButton surface="editorDark"`;设置面板里的“运行统计”入口改为 `PlatformSubpanel as="button" surface="dark"`。像素风 choice button、HUD launcher、奖励物品格和输入 composer 这类 runtime 专属控件继续保留独立语义,不并回普通平台按钮。
22.3. NPC dark modal footer 与暗色明细空态继续向共享原子收口:`NpcModals.tsx` 里的交易 / 赠礼 / 招募弹窗 footer 按钮和物品详情“关闭”按钮都改为委托 `PlatformActionButton surface="editorDark"`,交易右侧“请选择一件物品”提示改为 `PlatformEmptyState surface="editorDark"``CharacterInfoShared.tsx` 里的 `BuildContributionDetailPanel` 空明细也改为 `PlatformEmptyState surface="editorDark"`。数量 stepper、赠礼 / 招募 option card、标签强度按钮这类带更强业务语义的控件继续保留局部实现。
22.4. 暗色 / 像素 modal 的标准 footer 布局统一收口到 `src/components/common/PlatformDarkModalFooter.tsx`;该 Module 只承接 dark footer 的顶部分隔线、padding 和常见动作区排布,不承接“取消 / 确认”业务语义。`NpcModals.tsx` 的交易 / 赠礼 / 招募 footer、`SelectionCustomizationModals.tsx``SelectionModal` footer、`RpgAdventurePanelOverlays.tsx` 的 goal panel footer、`InventoryItemViews.tsx` 的详情 footer wrapper以及 `CompanionCampModal.tsx` 的“营地气氛”内容 footer 已接入。sticky 工作台 footer、正文里的单独 CTA 收尾和 runtime HUD 工具条继续保留局部布局;后续 dark / pixel modal 若只是同构 footer chrome优先直接复用这个 Module不再重复手写 `border-t border-white/10 + px/py + justify-end gap-*` 组合。验证命令:`npx vitest run src/components/common/PlatformDarkModalFooter.test.tsx src/components/CompanionCampModal.test.tsx src/components/NpcModals.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
## 验证
- `npm run test -- src/components/common/PlatformReportDialog.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx`
- `npm run test -- src/components/common/PlatformReportDialog.test.tsx src/components/platform-entry/PlatformTaskCompletionDialog.test.tsx`
- `npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
- `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/auth/BindPhoneScreen.test.tsx`
- `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx -t "renders generated formal previews with accurate status copy"`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
- `npm run test -- src/components/common/UnifiedConfirmDialog.test.tsx`
- `npm run test -- src/components/common/useCopyFeedback.test.tsx`
- `npm run test -- src/components/common/CopyFeedbackButton.test.tsx`
- `npm run test -- src/components/common/CopyCodeButton.test.tsx`
- `npm run test -- src/components/common/PublishShareModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyFeedbackButton.test.tsx src/components/common/CopyCodeButton.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/CharacterInfoShared.test.tsx src/components/AdventureEntityModal.test.tsx`
- `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/AdventureEntityModal.test.tsx -t "BuildContributionDetailPanel|技能详情静态标签"`
- `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "CharacterSkillsList|supports dark compact subpanel cards"`
- `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/AdventureEntityModal.test.tsx -t "物品空态|技能详情静态标签"`
- `npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "主分区|最近回响|supports dark compact subpanel cards"`
- `npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "私聊和队友收束|tinted dark information panels"`
- `npm run test -- src/components/InventoryPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx -t "背包文书|背包工坊|supports dark compact subpanel cards|supports editor dark surface"`
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "可扮演角色技能动作状态|supports dark compact subpanel cards"`
- `npm run test -- src/components/CharacterDetailModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- `npm run test -- src/components/CharacterPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- `npm run test -- src/components/CharacterPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "角色面板详情静态信息|tinted dark information panels"`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/BackstoryArchive.test.tsx src/components/AffinityStatusCard.test.tsx`
- `npm run test -- src/components/MapModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- `npm run test -- src/components/common/PlatformOverlayBadge.test.tsx src/components/common/PlatformSlotBadge.test.tsx`
- `npm run test -- src/components/common/PlatformQuantityBadge.test.tsx`
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx -t "quest offer accept button reuses the shared accepted-quest follow-up chain"`
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformPillBadge.test.tsx -t "quest offer accept button|supports dark RPG badge tones"`
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "adventure statistics panel|supports dark compact subpanel cards"`
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformQuantityBadge.test.tsx -t "quest offer accept button|quest reward strip|quest completion notice|battle reward modal|supports dark compact subpanel cards|supports dark RPG badge tones|renders a dark bottom-right quantity badge"`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile played modal summary"`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal trusts per-product first bonus display after points recharge"`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut shows reward subtitle and invited users"`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "confirms virtual payment after returning without hash result|releases submitting state after cancelled wechat pay result"`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx`
- `npm run test -- src/components/common/CopyFeedbackMessage.test.tsx`
- `npm run test -- src/components/common/PlatformStatusMessage.test.tsx`
- `npm run test -- src/components/common/PlatformRuntimeStatusToast.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/square-hole-runtime/SquareHoleRuntimeShell.test.tsx src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.test.tsx`
- `npm run test -- src/components/CharacterChatModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
- `npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
- `npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
- `npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformProgressBar.test.tsx`
- `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`
- `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "场景图片保存后会同步更新编辑页和场景列表"`
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx`
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx`
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色空态复用暗色平台空态"`
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "settings save-disabled hint|dark editor dashed empty state"`
- `npm run test -- src/components/NpcModals.test.tsx -t "NPC 弹窗空态复用暗色平台空态"`
- `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "NPC 交易静态信息卡|supports dark compact subpanel cards"`
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx -t "quest offer accept button reuses the shared accepted-quest follow-up chain|quest log empty state reuses dark PlatformEmptyState chrome"`
- `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx`
- `npm run test -- src/components/common/PlatformActionButton.test.tsx`
- `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
- `npm run test -- src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformActionButton.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CompanionCampModal.test.tsx src/components/MapModal.test.tsx src/components/CharacterChatModal.test.tsx`
- `npm run test -- src/components/unified-creation/shared/PuzzleImageModelPicker.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/common/PlatformIconButton.test.tsx`
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx`
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyCodeButton.test.tsx`
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx`
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx -t "shows publish failures in a dismissible modal"`
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/game-canvas/GameCanvasEntityLayer.test.tsx`
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
- `npm run test -- src/components/common/PlatformUploadTile.test.tsx`
- `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx`
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx`
- `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx`
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- `npm run test -- src/components/common/PlatformPillSwitch.test.tsx`
- `npm run test -- src/components/common/PlatformToggleRow.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx src/components/CustomWorldResultView.test.tsx`
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformToggleRow.test.tsx src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx`
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx`
- `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
- `npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile nickname modal"`
- `npm run test -- src/components/common/PlatformTextField.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut|profile redeem invite"`
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformFieldLabel.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile daily task"`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal shows native qr code"`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile played modal|profile page keeps save archives inside played stats panel"`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`
- `npm run test -- src/components/rpg-runtime-shell/RpgRuntimeStageRouter.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger"`
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformIconButton.test.tsx`
- `npm run test -- src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformTextField.test.tsx`
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformTagEditor.test.tsx`
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CharacterChatModal.test.tsx`
- `npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
- `npm run test -- src/components/auth/AuthGate.test.tsx src/components/auth/AccountModal.test.tsx src/components/auth/BindPhoneScreen.test.tsx src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- `npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "reward code|invite query|profile redeem invite|daily task"`
- `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
- `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/auth/AuthGate.test.tsx`
- `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx`
- `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
- `npm run test -- src/components/common/PlatformStatGrid.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`
- `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile played modal summary"`
- `npm run test -- src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx src/components/CustomWorldGenerationView.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- `npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- `npm run test -- src/components/common/PlatformProgressBar.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/CreativeAudioInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`
- `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
- `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
- `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`
- `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx`
- `npm run test -- src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
- `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx`
- `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`
- `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
- `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "作品封面上传|tinted dark information panels"`
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx`
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- `npm run test -- src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
- `npm run test -- src/components/common/PlatformMediaTileGrid.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`
- `npm run test -- src/components/common/PlatformTagEditor.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformTagEditor.test.tsx`
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformTextField.test.tsx`
- `npm run test -- src/components/common/PlatformPillSwitch.test.tsx src/components/common/CreativeImageInputPanel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
- `npm run test -- src/components/common/PlatformModalCloseButton.test.tsx`
- `npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx`
- `npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/SelectionCustomizationModals.test.tsx`
- `npm run test -- src/components/common/squareImageCropModel.test.ts`
- `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx`
- `npm run test -- src/components/CustomWorldResultView.test.tsx`
- `npm run test -- src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail"`
- `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx`
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx`
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
- `npm run test -- src/components/auth/AuthGate.test.tsx`
- `npm run test -- src/components/auth/AuthGate.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`
- `npm run test -- src/components/auth/AccountModal.test.tsx`
- `npm run test -- src/components/auth/AccountModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
- `npm run test -- src/components/common/CreativeImageInputPanel.test.tsx`
- `npm run test -- src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`
- `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`
- `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`
- `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
- `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
- `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
- `npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx`
- `npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx`
- `npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
- `npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx`
- `npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`
- `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`
- `npm run test -- src/components/common/PlatformActionButton.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx`
- `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
- `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx`
- `npm run test -- src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
- `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "保存修改|保存角色"`
- `npm run test -- src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
- `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
- `npm run test -- src/components/CustomWorldGenerationView.test.tsx src/components/common/PlatformActionButton.test.tsx`
- `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`
- `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformActionButton.test.tsx src/index.test.ts`
- `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx`
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/PlatformUploadTile.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx`
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "场景编辑器会在场景内展示槽位化多幕配置并保存"`
- `rg -n "window\\.confirm|window\\.alert" src/components src/services src/hooks -g '*.tsx' -g '*.ts'`
- 涉及 JSX 迁移时同步运行对应页面交互测试。

View File

@@ -1,13 +1,15 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import { import {
AnimationState, AnimationState,
type Character,
type CompanionRenderState,
type Encounter, type Encounter,
type GameState,
type EquipmentLoadout, type EquipmentLoadout,
type GameState,
WorldType, WorldType,
} from '../types'; } from '../types';
import { AdventureEntityModal } from './AdventureEntityModal'; import { AdventureEntityModal } from './AdventureEntityModal';
@@ -87,6 +89,66 @@ function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
}; };
} }
function createPlayerCharacter(): Character {
return {
id: 'player-1',
name: '潮刃客',
title: '试剑者',
description: '测试主角',
backstory: '测试背景',
personality: '冷静',
avatar: '',
portrait: '',
assetFolder: '',
assetVariant: '',
attributes: {
strength: 5,
agility: 5,
intelligence: 5,
spirit: 5,
},
skills: [
{
id: 'tide-slash',
name: '潮刃突进',
animation: AnimationState.ATTACK,
damage: 16,
manaCost: 5,
cooldownTurns: 2,
range: 1,
style: 'burst',
buildBuffs: [
{
id: 'wet-mark',
sourceType: 'skill',
sourceId: 'tide-slash',
name: '潮湿',
tags: ['控制', '潮汐'],
durationTurns: 2,
},
],
},
],
adventureOpenings: {},
};
}
function createCompanionRenderState(
character: Character,
): CompanionRenderState {
return {
npcId: 'companion-1',
character,
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
skillCooldowns: {},
animationState: AnimationState.IDLE,
slot: 'upper',
};
}
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -169,3 +231,226 @@ test('NPC 背包物品空 id 会被规范成稳定渲染 id', () => {
), ),
).toBe(false); ).toBe(false);
}); });
test('物品空态复用暗色 PlatformEmptyState chrome', () => {
render(
<AdventureEntityModal
selection={{ kind: 'player' }}
gameState={createGameState({
playerCharacter: createPlayerCharacter(),
playerInventory: [],
})}
onClose={() => undefined}
/>,
);
const emptyState = screen.getByText('暂无物品');
const attributeSection = screen.getByText('属性').closest('section');
const itemSection = screen.getByText('物品').closest('section');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('border-dashed');
expect(emptyState.className).toContain('bg-black/20');
expect(attributeSection?.className).toContain('border-white/10');
expect(attributeSection?.className).toContain('bg-black/25');
expect(itemSection?.className).toContain('border-white/10');
expect(itemSection?.className).toContain('bg-black/25');
const levelPanel = screen.getByTestId('player-level-panel');
expect(levelPanel.className).toContain('border-amber-300/18');
expect(levelPanel.className).toContain('bg-amber-500/8');
expect(levelPanel.className).toContain('rounded-xl');
});
test('最近回响纯展示小卡复用暗色 PlatformSubpanel chrome', () => {
render(
<AdventureEntityModal
selection={{ kind: 'player' }}
gameState={createGameState({
playerCharacter: createPlayerCharacter(),
playerInventory: [
{
id: 'echo-shell',
category: '材料',
name: '回声贝壳',
quantity: 1,
rarity: 'rare',
tags: [],
runtimeMetadata: {
origin: 'procedural',
generationChannel: 'discovery',
seedKey: 'echo-shell-seed',
sourceReason: '测试最近回响载体',
storyFingerprint: {
visibleClue: '贝壳里仍有潮声回响',
witnessMark: '潮痕',
unresolvedQuestion: '潮声为何未散',
currentAppearanceReason: '被最近回响唤醒',
relatedThreadIds: [],
relatedScarIds: [],
reactionHooks: [],
},
},
},
],
currentScenePreset: {
narrativeResidues: [
{
id: 'residue-1',
title: '墙上残痕',
visibleClue: '刻着潮汐暗号。',
},
],
} as unknown as GameState['currentScenePreset'],
storyEngineMemory: {
chronicle: [
{
id: 'chronicle-1',
title: '潮声编年',
summary: '潮声把旧约刻回墙面。',
},
],
recentCarrierIds: ['echo-shell'],
consequenceLedger: [
{
id: 'consequence-1',
title: '旧约后果',
summary: '盟约开始反噬。',
relatedIds: ['player-1'],
},
],
} as unknown as GameState['storyEngineMemory'],
})}
onClose={() => undefined}
/>,
);
[
'recent-consequence-echo',
'recent-chronicle-echo',
'recent-carrier-echo',
'recent-scene-residue-echo',
].forEach((testId) => {
const panel = screen.getByTestId(testId);
expect(panel.className).toContain('border-white/10');
expect(panel.className).toContain('bg-black/25');
expect(panel.className).toContain('rounded-xl');
});
});
test('私聊和队友收束复用暗色 tint PlatformSubpanel chrome', () => {
const companionCharacter = createPlayerCharacter();
render(
<AdventureEntityModal
selection={{
kind: 'companion',
companion: createCompanionRenderState(companionCharacter),
}}
gameState={createGameState({
companions: [
{
npcId: 'companion-1',
characterId: companionCharacter.id,
joinedAtAffinity: 100,
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
skillCooldowns: {},
},
],
npcStates: {
'companion-1': {
affinity: 100,
relationState: { affinity: 100, stance: 'bonded' },
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: true,
revealedFacts: [],
knownAttributeRumors: [],
firstMeaningfulContactResolved: true,
seenBackstoryChapterIds: [],
},
},
storyEngineMemory: {
companionResolutions: [
{
characterId: companionCharacter.id,
resolutionType: 'bonded',
summary: '潮声与同行者完成誓约。',
relatedThreadIds: ['thread-1'],
},
],
} as unknown as GameState['storyEngineMemory'],
})}
onClose={() => undefined}
onOpenCharacterChat={() => undefined}
/>,
);
const privateChatPanel = screen.getByTestId('private-chat-panel');
const companionResolutionEcho = screen.getByTestId(
'companion-resolution-echo',
);
const privateChatButton = screen.getByRole('button', { name: '聊天' });
expect(privateChatPanel.className).toContain('border-sky-400/18');
expect(privateChatPanel.className).toContain('bg-sky-500/8');
expect(privateChatPanel.className).toContain('rounded-[1.35rem]');
expect(companionResolutionEcho.className).toContain('border-emerald-400/18');
expect(companionResolutionEcho.className).toContain('bg-emerald-500/8');
expect(companionResolutionEcho.className).toContain('rounded-xl');
expect(privateChatButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(privateChatButton.className).toContain('rounded-xl');
expect(privateChatButton.className).toContain('bg-sky-400/15');
expect(privateChatButton.className).toContain('disabled:bg-black/20');
});
test('技能详情静态标签复用暗色 PlatformPillBadge chrome', () => {
render(
<AdventureEntityModal
selection={{ kind: 'player' }}
gameState={createGameState({
playerCharacter: createPlayerCharacter(),
})}
onClose={() => undefined}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
const skillPanel = screen
.getByText('技能详情')
.closest('.pixel-modal-shell') as HTMLElement;
const deliveryBadge = within(skillPanel).getAllByText('近战')[0]!;
const styleBadge = within(skillPanel).getAllByText('爆发')[0]!;
const buffSummaryBadge = within(skillPanel).getByText('附带 1 个状态标签');
const buffBadge = within(skillPanel).getByText('潮湿 / 控制、潮汐 / 2 回合');
const damagePanel = within(skillPanel)
.getByText('伤害')
.closest('section') as HTMLElement;
const descriptionPanel = within(skillPanel)
.getByText(/ 线/u)
.closest('section') as HTMLElement;
const buffPanel = within(skillPanel)
.getByText('附带状态标签')
.closest('section') as HTMLElement;
expect(deliveryBadge.className).toContain('bg-white/6');
expect(styleBadge.className).toContain('bg-sky-500/10');
expect(buffSummaryBadge.className).toContain('bg-emerald-500/10');
expect(buffBadge.className).toContain('rounded-full');
expect(buffBadge.className).toContain('bg-sky-500/10');
expect(damagePanel.className).toContain('bg-black/25');
expect(damagePanel.className).toContain('border-white/10');
expect(descriptionPanel.className).toContain('bg-black/25');
expect(buffPanel.className).toContain('bg-black/25');
});

View File

@@ -12,9 +12,7 @@ import {
resolveCharacterAttributeProfile, resolveCharacterAttributeProfile,
} from '../data/attributeResolver'; } from '../data/attributeResolver';
import { import {
formatBuildContributionPercent,
getBuildContributionAttributeRows, getBuildContributionAttributeRows,
getBuildContributionQualityLabel,
getCompanionBuildDamageBreakdown, getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown, getPlayerBuildDamageBreakdown,
resolveMonsterOutgoingDamage, resolveMonsterOutgoingDamage,
@@ -67,11 +65,11 @@ import { CharacterAnimator } from './CharacterAnimator';
import { import {
buildCharacterSkillRenderId, buildCharacterSkillRenderId,
getCharacterDetailSpriteStyle, getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getSkillDeliveryLabel, getSkillDeliveryLabel,
getSkillStyleLabel, getSkillStyleLabel,
} from './CharacterInfoHelpers'; } from './CharacterInfoHelpers';
import { import {
BuildContributionDetailPanel,
CharacterAttributeGrid, CharacterAttributeGrid,
CharacterIdentityBadges, CharacterIdentityBadges,
CharacterSkillsList, CharacterSkillsList,
@@ -79,6 +77,10 @@ import {
PlayerLevelProgress, PlayerLevelProgress,
StatusRow, StatusRow,
} from './CharacterInfoShared'; } from './CharacterInfoShared';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared'; import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
import type { GameCanvasEntitySelection } from './GameCanvas'; import type { GameCanvasEntitySelection } from './GameCanvas';
import { HostileNpcAnimator } from './HostileNpcAnimator'; import { HostileNpcAnimator } from './HostileNpcAnimator';
@@ -129,12 +131,28 @@ function estimateNpcMaxMana(character: Character | null) {
function Section({ title, children }: { title: string; children: ReactNode }) { function Section({ title, children }: { title: string; children: ReactNode }) {
return ( return (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4"> <PlatformSubpanel surface="dark" radius="sm" padding="md">
<div className="mb-3 text-[10px] uppercase tracking-[0.22em] text-zinc-500"> <div className="mb-3 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
{title} {title}
</div> </div>
{children} {children}
</div> </PlatformSubpanel>
);
}
function SkillMetricCard({ label, value }: { label: string; value: number }) {
return (
<PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="px-3 py-3"
>
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
{label}
</div>
<div className="mt-1 font-semibold text-white">{value}</div>
</PlatformSubpanel>
); );
} }
@@ -226,7 +244,9 @@ function buildSelectionRenderKey(selection: GameCanvasEntitySelection | null) {
}`; }`;
} }
function buildStableRenderKey(parts: Array<string | number | null | undefined>) { function buildStableRenderKey(
parts: Array<string | number | null | undefined>,
) {
return parts return parts
.map((part, index) => { .map((part, index) => {
const normalized = String(part ?? '').trim(); const normalized = String(part ?? '').trim();
@@ -746,29 +766,26 @@ export function AdventureEntityModal({
affinity: companionNpcState?.affinity ?? null, affinity: companionNpcState?.affinity ?? null,
} satisfies CharacterChatTarget) } satisfies CharacterChatTarget)
: null; : null;
const inventory = useMemo( const inventory = useMemo(() => {
() => { const rawInventory =
const rawInventory = selection?.kind === 'player'
selection?.kind === 'player' ? gameState.playerInventory
? gameState.playerInventory : selection?.kind === 'companion' && companionCharacter
: selection?.kind === 'companion' && companionCharacter ? buildCharacterInventoryPreviewItems(
? buildCharacterInventoryPreviewItems( companionCharacter,
companionCharacter, gameState.worldType,
gameState.worldType, )
) : (npcState?.inventory ?? []);
: (npcState?.inventory ?? []);
return normalizeInventoryItemRenderIds(rawInventory, selectionRenderKey); return normalizeInventoryItemRenderIds(rawInventory, selectionRenderKey);
}, }, [
[ companionCharacter,
companionCharacter, gameState.playerInventory,
gameState.playerInventory, gameState.worldType,
gameState.worldType, npcState?.inventory,
npcState?.inventory, selection?.kind,
selection?.kind, selectionRenderKey,
selectionRenderKey, ]);
],
);
const attributeSchema = resolveAttributeSchema( const attributeSchema = resolveAttributeSchema(
gameState.worldType, gameState.worldType,
gameState.customWorldProfile, gameState.customWorldProfile,
@@ -1072,7 +1089,13 @@ export function AdventureEntityModal({
{selection.kind === 'companion' && companionChatTarget ? ( {selection.kind === 'companion' && companionChatTarget ? (
<Section title="私聊"> <Section title="私聊">
<div className="rounded-2xl border border-sky-400/18 bg-sky-500/8 p-4"> <PlatformSubpanel
surface="darkSky"
radius="lg"
padding="md"
as="div"
data-testid="private-chat-panel"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<div className="text-[10px] uppercase tracking-[0.18em] text-sky-200/80"> <div className="text-[10px] uppercase tracking-[0.18em] text-sky-200/80">
@@ -1084,8 +1107,9 @@ export function AdventureEntityModal({
: `好感达到 ${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 后解锁,当前 ${companionNpcState?.affinity ?? 0}`} : `好感达到 ${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 后解锁,当前 ${companionNpcState?.affinity ?? 0}`}
</div> </div>
</div> </div>
<button <PlatformActionButton
type="button" surface="editorDark"
tone="ghost"
disabled={ disabled={
!privateChatUnlocked || !onOpenCharacterChat !privateChatUnlocked || !onOpenCharacterChat
} }
@@ -1099,18 +1123,14 @@ export function AdventureEntityModal({
onClose(); onClose();
onOpenCharacterChat(companionChatTarget); onOpenCharacterChat(companionChatTarget);
}} }}
className={`rounded-xl px-4 py-2 text-sm font-medium transition-colors ${ className="rounded-xl border-sky-300/40 bg-sky-400/15 text-sky-50 hover:bg-sky-400/22 disabled:cursor-not-allowed disabled:border-white/8 disabled:bg-black/20 disabled:text-zinc-500 disabled:opacity-100"
privateChatUnlocked && onOpenCharacterChat
? 'border border-sky-300/40 bg-sky-400/15 text-sky-50 hover:bg-sky-400/22'
: 'cursor-not-allowed border border-white/8 bg-black/20 text-zinc-500'
}`}
> >
{privateChatUnlocked {privateChatUnlocked
? '聊天' ? '聊天'
: `聊天(${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 解锁)`} : `聊天(${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 解锁)`}
</button> </PlatformActionButton>
</div> </div>
</div> </PlatformSubpanel>
</Section> </Section>
) : null} ) : null}
@@ -1122,40 +1142,54 @@ export function AdventureEntityModal({
<Section title="最近回响"> <Section title="最近回响">
<div className="space-y-3"> <div className="space-y-3">
{selectedCompanionResolution && ( {selectedCompanionResolution && (
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-100/85"> <PlatformSubpanel
surface="darkEmerald"
radius="xs"
padding="row"
as="div"
className="text-xs"
data-testid="companion-resolution-echo"
>
{selectedCompanionResolution.resolutionType} ·{' '} {selectedCompanionResolution.resolutionType} ·{' '}
{selectedCompanionResolution.summary} {selectedCompanionResolution.summary}
</div> </PlatformSubpanel>
)} )}
{relatedConsequences.length > 0 && ( {relatedConsequences.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
{relatedConsequences.map((record, index) => ( {relatedConsequences.map((record, index) => (
<div <PlatformSubpanel
key={ key={
record.id || record.id ||
`consequence-${record.title}-${index}` `consequence-${record.title}-${index}`
} }
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400" surface="dark"
radius="xs"
padding="row"
className="text-xs text-zinc-400"
data-testid="recent-consequence-echo"
> >
<span className="text-white"> <span className="text-white">
{record.title} {record.title}
</span> </span>
{''} {''}
{record.summary} {record.summary}
</div> </PlatformSubpanel>
))} ))}
</div> </div>
)} )}
{recentChronicleEntries.length > 0 && ( {recentChronicleEntries.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
{recentChronicleEntries.map((entry, index) => ( {recentChronicleEntries.map((entry, index) => (
<div <PlatformSubpanel
key={ key={
entry.id || entry.id ||
`chronicle-${entry.title}-${index}` `chronicle-${entry.title}-${index}`
} }
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2" surface="dark"
radius="xs"
padding="row"
data-testid="recent-chronicle-echo"
> >
<div className="text-sm font-medium text-white"> <div className="text-sm font-medium text-white">
{entry.title} {entry.title}
@@ -1163,31 +1197,41 @@ export function AdventureEntityModal({
<div className="mt-1 text-xs text-zinc-400"> <div className="mt-1 text-xs text-zinc-400">
{entry.summary} {entry.summary}
</div> </div>
</div> </PlatformSubpanel>
))} ))}
</div> </div>
)} )}
{recentCarrierEchoes.length > 0 && ( {recentCarrierEchoes.length > 0 && (
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-amber-100/85"> <PlatformSubpanel
surface="dark"
radius="xs"
padding="row"
className="text-xs text-amber-100/85"
data-testid="recent-carrier-echo"
>
{recentCarrierEchoes.join('')} {recentCarrierEchoes.join('')}
</div> </PlatformSubpanel>
)} )}
{sceneResidues.length > 0 && ( {sceneResidues.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
{sceneResidues.map((residue, index) => ( {sceneResidues.map((residue, index) => (
<div <PlatformSubpanel
key={ key={
residue.id || residue.id ||
`residue-${residue.title}-${index}` `residue-${residue.title}-${index}`
} }
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400" surface="dark"
radius="xs"
padding="row"
className="text-xs text-zinc-400"
data-testid="recent-scene-residue-echo"
> >
<span className="text-white"> <span className="text-white">
{residue.title} {residue.title}
</span> </span>
{''} {''}
{residue.visibleClue} {residue.visibleClue}
</div> </PlatformSubpanel>
))} ))}
</div> </div>
)} )}
@@ -1198,7 +1242,13 @@ export function AdventureEntityModal({
<Section title="属性"> <Section title="属性">
<div className="space-y-4"> <div className="space-y-4">
{selection.kind === 'player' ? ( {selection.kind === 'player' ? (
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3"> <PlatformSubpanel
surface="darkAmber"
radius="xs"
padding="sm"
as="div"
data-testid="player-level-panel"
>
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75"> <div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
</div> </div>
@@ -1211,7 +1261,7 @@ export function AdventureEntityModal({
normalizedPlayerProgression.xpToNextLevel normalizedPlayerProgression.xpToNextLevel
} }
/> />
</div> </PlatformSubpanel>
) : null} ) : null}
<div className="space-y-3"> <div className="space-y-3">
<StatusRow <StatusRow
@@ -1273,7 +1323,13 @@ export function AdventureEntityModal({
onSelectItem={(item) => setSelectedItemId(item.id)} onSelectItem={(item) => setSelectedItemId(item.id)}
/> />
) : ( ) : (
<div className="text-sm text-zinc-500"></div> <PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
>
</PlatformEmptyState>
)} )}
</Section> </Section>
</div> </div>
@@ -1320,70 +1376,10 @@ export function AdventureEntityModal({
</div> </div>
<div className="overflow-y-auto p-4 sm:p-5"> <div className="overflow-y-auto p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]"> <BuildContributionDetailPanel
<div className="space-y-4"> row={selectedContributionRow}
<div attributes={selectedContributionAttributes}
className="rounded-2xl border px-4 py-4" />
style={getContributionVisualStyle(
selectedContributionRow.bonusDelta,
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-[10px] uppercase tracking-[0.16em] text-current/70">
</div>
<div className="mt-2 text-sm font-semibold">
{selectedContributionRow.label}
</div>
</div>
<div className="rounded-xl border border-current/15 bg-black/25 px-3 py-2 text-right">
<div className="text-[11px] tracking-[0.14em] text-current/70">
{getBuildContributionQualityLabel(
selectedContributionRow.bonusDelta,
)}
</div>
<div className="mt-1 text-sm font-semibold">
{' '}
{formatBuildContributionPercent(
selectedContributionRow.bonusDelta,
)}
</div>
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div>
{selectedContributionAttributes.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{selectedContributionAttributes.map((attribute) => (
<div
key={`${selectedContributionRow.label}-${attribute.slotId}`}
className="rounded-xl border border-white/8 bg-black/25 px-4 py-3"
>
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
<span>{attribute.label}</span>
<span className="font-semibold text-white">
{formatBuildContributionPercent(
attribute.modifierDelta,
)}
</span>
</div>
</div>
))}
</div>
) : (
<div className="mt-4 rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-6 text-zinc-400">
</div>
)}
</div>
</div>
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>
@@ -1444,63 +1440,52 @@ export function AdventureEntityModal({
} }
/> />
) : ( ) : (
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-400"> <PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="px-4 py-3 text-sm text-zinc-400"
>
{detailCharacter {detailCharacter
? '当前未进入具体世界,暂时无法恢复技能预览。' ? '当前未进入具体世界,暂时无法恢复技能预览。'
: '该 NPC 当前没有独立技能演示模型,先展示真实技能数据。'} : '该 NPC 当前没有独立技能演示模型,先展示真实技能数据。'}
</div> </PlatformSubpanel>
)} )}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] text-zinc-100"> <PlatformPillBadge tone="darkSoft" size="xxs" className="px-2">
{getSkillDeliveryLabel(selectedSkill)} {getSkillDeliveryLabel(selectedSkill)}
</span> </PlatformPillBadge>
<span className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"> <PlatformPillBadge tone="darkSky" size="xxs" className="px-2">
{getSkillStyleLabel(selectedSkill)} {getSkillStyleLabel(selectedSkill)}
</span> </PlatformPillBadge>
{selectedSkill.buildBuffs?.length ? ( {selectedSkill.buildBuffs?.length ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-1 text-[10px] text-emerald-100"> <PlatformPillBadge
tone="darkEmerald"
size="xxs"
className="px-2"
>
{selectedSkill.buildBuffs.length} {selectedSkill.buildBuffs.length}
</span> </PlatformPillBadge>
) : null} ) : null}
</div> </div>
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-2 text-sm text-zinc-300 sm:grid-cols-4">
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3"> <SkillMetricCard label="伤害" value={selectedSkill.damage} />
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"> <SkillMetricCard label="法力" value={selectedSkill.manaCost} />
<SkillMetricCard
</div> label="冷却"
<div className="mt-1 font-semibold text-white"> value={selectedSkill.cooldownTurns}
{selectedSkill.damage} />
</div> <SkillMetricCard label="距离" value={selectedSkill.range} />
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 font-semibold text-white">
{selectedSkill.manaCost}
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 font-semibold text-white">
{selectedSkill.cooldownTurns}
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 font-semibold text-white">
{selectedSkill.range}
</div>
</div>
</div> </div>
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300"> <PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="px-4 py-3 text-sm leading-relaxed text-zinc-300"
>
{selectedSkill.name} {getSkillStyleLabel(selectedSkill)} {selectedSkill.name} {getSkillStyleLabel(selectedSkill)}
线{getSkillDeliveryLabel(selectedSkill)} 线{getSkillDeliveryLabel(selectedSkill)}
{selectedSkill.damage} {' '} {selectedSkill.damage} {' '}
@@ -1509,16 +1494,21 @@ export function AdventureEntityModal({
{selectedSkill.effects?.length {selectedSkill.effects?.length
? ` 该技能还会触发 ${selectedSkill.effects.length} 段战斗特效。` ? ` 该技能还会触发 ${selectedSkill.effects.length} 段战斗特效。`
: ''} : ''}
</div> </PlatformSubpanel>
{selectedSkill.buildBuffs?.length ? ( {selectedSkill.buildBuffs?.length ? (
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3"> <PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="px-4 py-3"
>
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"> <div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{selectedSkill.buildBuffs.map((buff, index) => ( {selectedSkill.buildBuffs.map((buff, index) => (
<span <PlatformPillBadge
key={buildStableRenderKey([ key={buildStableRenderKey([
'skill-buff', 'skill-buff',
selectedSkill.id, selectedSkill.id,
@@ -1526,14 +1516,16 @@ export function AdventureEntityModal({
buff.name, buff.name,
index, index,
])} ])}
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100" tone="darkSky"
size="xxs"
className="px-2"
> >
{buff.name} / {buff.tags.join('、')} /{' '} {buff.name} / {buff.tags.join('、')} /{' '}
{buff.durationTurns} {buff.durationTurns}
</span> </PlatformPillBadge>
))} ))}
</div> </div>
</div> </PlatformSubpanel>
) : null} ) : null}
</div> </div>
</motion.div> </motion.div>

View File

@@ -0,0 +1,30 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { AffinityStatusCard } from './AffinityStatusCard';
test('renders affinity level with dark platform pill badge tone', () => {
render(<AffinityStatusCard affinity={72} />);
const levelBadge = screen.getAllByText('信任')[0]!;
expect(levelBadge.className).toContain('rounded-full');
expect(levelBadge.className).toContain('bg-amber-500/10');
expect(levelBadge.className).toContain('text-amber-100');
});
test('renders affinity summary and progress with dark PlatformSubpanel chrome', () => {
render(<AffinityStatusCard affinity={28} />);
const levelPanel = screen.getByText('好感等级').closest('section');
const progressPanel = screen.getByText('好感进度').closest('section');
expect(levelPanel?.className).toContain('border-white/10');
expect(levelPanel?.className).toContain('bg-black/25');
expect(levelPanel?.className).toContain('rounded-xl');
expect(progressPanel?.className).toContain('border-white/10');
expect(progressPanel?.className).toContain('bg-black/25');
expect(progressPanel?.className).toContain('sm:p-4');
});

View File

@@ -2,8 +2,12 @@ import {
AFFINITY_PROGRESS_MARKERS, AFFINITY_PROGRESS_MARKERS,
AFFINITY_PROGRESS_MAX, AFFINITY_PROGRESS_MAX,
AFFINITY_PROGRESS_MIN, AFFINITY_PROGRESS_MIN,
type AffinityLevelId,
getAffinityLevelMeta, getAffinityLevelMeta,
} from '../data/affinityLevels'; } from '../data/affinityLevels';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import type { PlatformPillBadgeTone } from './common/platformPillBadgeModel';
import { PlatformSubpanel } from './common/PlatformSubpanel';
type AffinityProgressMarker = (typeof AFFINITY_PROGRESS_MARKERS)[number]; type AffinityProgressMarker = (typeof AFFINITY_PROGRESS_MARKERS)[number];
@@ -45,6 +49,16 @@ function isMarkerReached(marker: AffinityProgressMarker, affinity: number) {
return affinity >= marker.value; return affinity >= marker.value;
} }
function getAffinityLevelBadgeTone(
levelId: AffinityLevelId,
): PlatformPillBadgeTone {
if (levelId === 'hostile' || levelId === 'close') return 'darkRose';
if (levelId === 'guarded') return 'darkSoft';
if (levelId === 'friendly') return 'darkEmerald';
if (levelId === 'trusted') return 'darkAmber';
return 'darkSky';
}
export function AffinityStatusCard({ affinity }: { affinity: number }) { export function AffinityStatusCard({ affinity }: { affinity: number }) {
const currentLevel = getAffinityLevelMeta(affinity); const currentLevel = getAffinityLevelMeta(affinity);
const nextLevel = getNextAffinityMarker(affinity); const nextLevel = getNextAffinityMarker(affinity);
@@ -69,18 +83,20 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3"> <PlatformSubpanel surface="dark" radius="xs" padding="sm">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
<span <PlatformPillBadge
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`} tone={getAffinityLevelBadgeTone(currentLevel.id)}
size="xxs"
className="tracking-[0.16em]"
> >
{currentLevel.label} {currentLevel.label}
</span> </PlatformPillBadge>
<span className="text-sm font-semibold text-white"> <span className="text-sm font-semibold text-white">
{affinity} {affinity}
</span> </span>
@@ -107,9 +123,14 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {
<p className="mt-3 text-sm leading-relaxed text-zinc-300"> <p className="mt-3 text-sm leading-relaxed text-zinc-300">
{currentLevel.description} {currentLevel.description}
</p> </p>
</div> </PlatformSubpanel>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4"> <PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="sm:p-4"
>
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div> </div>
@@ -215,7 +236,7 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {
); );
})} })}
</div> </div>
</div> </PlatformSubpanel>
</div> </div>
); );
} }

View File

@@ -0,0 +1,87 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { BackstoryArchive } from './BackstoryArchive';
test('renders backstory chapter status with dark platform pill badges', () => {
render(
<BackstoryArchive
publicSummary="她总在旧港守灯。"
unlockedChapters={[
{
id: 'surface',
title: '表层来意',
content: '她先把所有问题都带回旧灯塔。',
},
]}
lockedChapters={[
{
id: 'truth',
title: '最终底牌',
teaser: '真正的守灯人也许不是她。',
affinityRequired: 60,
},
]}
/>,
);
const unlockedBadge = screen.getByText('已解锁');
const lockedBadge = screen.getByText('需好感 60');
expect(unlockedBadge.className).toContain('rounded-full');
expect(unlockedBadge.className).toContain('bg-amber-500/10');
expect(lockedBadge.className).toContain('rounded-full');
expect(lockedBadge.className).toContain('bg-black/20');
});
test('renders public summary and chapters with dark PlatformSubpanel chrome', () => {
render(
<BackstoryArchive
publicSummary="她总在旧港守灯。"
unlockedChapters={[
{
id: 'surface',
title: '表层来意',
content: '她先把所有问题都带回旧灯塔。',
},
]}
lockedChapters={[
{
id: 'truth',
title: '最终底牌',
teaser: '真正的守灯人也许不是她。',
affinityRequired: 60,
},
]}
/>,
);
const summaryPanel = screen.getByText('公开印象').closest('section');
const unlockedPanel = screen.getByText('表层来意').closest('section');
const lockedPanel = screen.getByText('最终底牌').closest('section');
expect(summaryPanel?.className).toContain('border-white/10');
expect(summaryPanel?.className).toContain('bg-black/25');
expect(unlockedPanel?.className).toContain('border-amber-300/18');
expect(unlockedPanel?.className).toContain('bg-black/25');
expect(lockedPanel?.className).toContain('border-white/10');
expect(lockedPanel?.className).toContain('bg-black/25');
});
test('renders empty archive with editor dark PlatformEmptyState chrome', () => {
render(
<BackstoryArchive
publicSummary={null}
unlockedChapters={[]}
lockedChapters={[]}
/>,
);
const emptyState = screen.getByText('暂无可整理的背景线索。');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('border-dashed');
expect(emptyState.className).toContain('bg-black/20');
});

View File

@@ -1,3 +1,7 @@
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
export type BackstoryUnlockedChapter = { export type BackstoryUnlockedChapter = {
id: string; id: string;
title: string; title: string;
@@ -38,58 +42,71 @@ export function BackstoryArchive({
</div> </div>
{publicSummary ? ( {publicSummary ? (
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3"> <PlatformSubpanel surface="dark" radius="xs" padding="sm">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"> <div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div> </div>
<div className="mt-2 text-sm leading-relaxed text-zinc-200"> <div className="mt-2 text-sm leading-relaxed text-zinc-200">
{publicSummary} {publicSummary}
</div> </div>
</div> </PlatformSubpanel>
) : null} ) : null}
{unlockedChapters.map((chapter) => ( {unlockedChapters.map((chapter) => (
<div <PlatformSubpanel
key={`unlocked-backstory-${chapter.id}`} key={`unlocked-backstory-${chapter.id}`}
className="rounded-xl border border-amber-300/18 bg-amber-500/[0.06] px-4 py-3" surface="dark"
radius="xs"
padding="sm"
className="border-amber-300/18 bg-amber-500/[0.06]"
> >
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold text-white"> <div className="text-sm font-semibold text-white">
{chapter.title} {chapter.title}
</div> </div>
<span className="rounded-full border border-amber-300/18 bg-amber-400/10 px-2 py-0.5 text-[10px] tracking-[0.14em] text-amber-100"> <PlatformPillBadge
tone="darkAmber"
size="xxs"
className="px-2 py-0.5 tracking-[0.14em]"
>
</span> </PlatformPillBadge>
</div> </div>
<div className="mt-2 text-sm leading-relaxed text-zinc-200"> <div className="mt-2 text-sm leading-relaxed text-zinc-200">
{chapter.content} {chapter.content}
</div> </div>
</div> </PlatformSubpanel>
))} ))}
{lockedChapters.map((chapter) => ( {lockedChapters.map((chapter) => (
<div <PlatformSubpanel
key={`locked-backstory-${chapter.id}`} key={`locked-backstory-${chapter.id}`}
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3" surface="dark"
radius="xs"
padding="sm"
> >
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold text-zinc-200"> <div className="text-sm font-semibold text-zinc-200">
{chapter.title} {chapter.title}
</div> </div>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] tracking-[0.14em] text-zinc-400"> <PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="px-2 py-0.5 tracking-[0.14em]"
>
{chapter.affinityRequired} {chapter.affinityRequired}
</span> </PlatformPillBadge>
</div> </div>
<div className="mt-2 text-sm leading-relaxed text-zinc-500"> <div className="mt-2 text-sm leading-relaxed text-zinc-500">
{chapter.teaser} {chapter.teaser}
</div> </div>
</div> </PlatformSubpanel>
))} ))}
{!publicSummary && totalChapters === 0 ? ( {!publicSummary && totalChapters === 0 ? (
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm text-zinc-500"> <PlatformEmptyState surface="editorDark" size="compact" tone="soft">
线 线
</div> </PlatformEmptyState>
) : null} ) : null}
</div> </div>
); );

View File

@@ -0,0 +1,137 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
import type { Character } from '../types';
import { CharacterChatModal } from './CharacterChatModal';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试角色',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: '冷静谨慎',
skills: [],
adventureOpenings: {},
} as Character;
}
function createModalState(
overrides: Partial<CharacterChatModalState> = {},
): CharacterChatModalState {
return {
target: {
character: createCharacter(),
npcId: 'npc-hero',
roleLabel: '队友',
hp: 80,
maxHp: 100,
mana: 24,
maxMana: 30,
},
draft: '',
messages: [],
suggestions: ['先问问线索'],
summary: '',
isSending: false,
isLoadingSuggestions: false,
error: '暂时无法生成回复。',
...overrides,
};
}
test('角色聊天错误提示复用暗色 PlatformStatusMessage chrome', () => {
render(
<CharacterChatModal
modal={createModalState()}
onClose={vi.fn()}
onDraftChange={vi.fn()}
onUseSuggestion={vi.fn()}
onRefreshSuggestions={vi.fn()}
onSendDraft={vi.fn()}
/>,
);
const errorMessage = screen.getByText('暂时无法生成回复。');
expect(errorMessage.className).toContain('platform-status-message');
expect(errorMessage.className).toContain('border-amber-300/15');
expect(errorMessage.className).toContain('bg-amber-500/10');
expect(errorMessage.className).toContain('text-amber-50/90');
});
test('角色聊天状态、空态和建议复用暗色 UI Kit chrome', () => {
render(
<CharacterChatModal
modal={createModalState()}
onClose={vi.fn()}
onDraftChange={vi.fn()}
onUseSuggestion={vi.fn()}
onRefreshSuggestions={vi.fn()}
onSendDraft={vi.fn()}
/>,
);
const hpStatus = screen.getByText('生命值 80 / 100');
const summaryFallback = screen.getByText('你们还没有形成新的私下聊天总结。');
const emptyHistory = screen.getByText(
'这里会保留你和该角色的私下聊天记录。输入框支持自由发挥,上方三条文本可以帮你快速起句。',
);
const refreshButton = screen.getByRole('button', { name: '换一组' });
const suggestionButton = screen.getByRole('button', { name: '先问问线索' });
const draftTextarea = screen.getByPlaceholderText('对沈行说点什么...');
expect(hpStatus.className).toContain('border-white/10');
expect(hpStatus.className).toContain('bg-black/25');
expect(summaryFallback.className).toContain('border-white/10');
expect(summaryFallback.className).toContain('bg-black/25');
expect(emptyHistory.className).toContain('platform-empty-state');
expect(emptyHistory.className).toContain('border-dashed');
expect(refreshButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(refreshButton.className).toContain('text-[10px]');
expect(suggestionButton.className).toContain('platform-dark-option-card');
expect(suggestionButton.className).toContain('border-white/8');
expect(draftTextarea.className).toContain('platform-text-field--editor-dark');
expect(draftTextarea.className).toContain('focus:border-sky-300/35');
});
test('角色聊天标题栏内联关闭按钮保持共享关闭行为', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<CharacterChatModal
modal={createModalState()}
onClose={onClose}
onDraftChange={vi.fn()}
onUseSuggestion={vi.fn()}
onRefreshSuggestions={vi.fn()}
onSendDraft={vi.fn()}
/>,
);
const closeButton = screen.getByRole('button', { name: '关闭角色聊天' });
await user.click(closeButton);
expect(closeButton.className).toContain('relative');
expect(closeButton.className).toContain('shrink-0');
expect(closeButton.getAttribute('title')).toBe('关闭角色聊天');
expect(onClose).toHaveBeenCalledTimes(1);
});

View File

@@ -3,6 +3,12 @@ import { useEffect, useRef } from 'react';
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PlatformTextField } from './common/PlatformTextField';
import { PixelCloseButton } from './PixelCloseButton'; import { PixelCloseButton } from './PixelCloseButton';
interface CharacterChatModalProps { interface CharacterChatModalProps {
@@ -68,23 +74,45 @@ export function CharacterChatModal({
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}> <div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-2 text-xs font-bold text-white"></div> <div className="mb-2 text-xs font-bold text-white"></div>
<div className="space-y-2 text-sm text-zinc-300"> <div className="space-y-2 text-sm text-zinc-300">
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2"> <PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
>
{modal.target.hp} / {modal.target.maxHp} {modal.target.hp} / {modal.target.maxHp}
</div> </PlatformSubpanel>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2"> <PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
>
{modal.target.mana} / {modal.target.maxMana} {modal.target.mana} / {modal.target.maxMana}
</div> </PlatformSubpanel>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2 text-xs leading-relaxed text-zinc-400"> <PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="text-xs leading-relaxed text-zinc-400"
>
{modal.target.character.personality} {modal.target.character.personality}
</div> </PlatformSubpanel>
</div> </div>
</div> </div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}> <div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-2 text-xs font-bold text-white"></div> <div className="mb-2 text-xs font-bold text-white"></div>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-3 text-sm leading-relaxed text-zinc-300"> <PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="md"
className="text-sm leading-relaxed text-zinc-300"
>
{modal.summary || '你们还没有形成新的私下聊天总结。'} {modal.summary || '你们还没有形成新的私下聊天总结。'}
</div> </PlatformSubpanel>
</div> </div>
</div> </div>
@@ -115,51 +143,57 @@ export function CharacterChatModal({
</div> </div>
)) ))
) : ( ) : (
<div className="rounded-2xl border border-dashed border-white/10 bg-black/18 px-4 py-6 text-sm leading-relaxed text-zinc-500"> <PlatformEmptyState
surface="editorDark"
size="inline"
className="py-6 font-normal leading-relaxed text-zinc-500"
>
</div> </PlatformEmptyState>
)} )}
</div> </div>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold text-white"></div> <div className="text-xs font-bold text-white"></div>
<button <PlatformActionButton
type="button" surface="editorDark"
tone="ghost"
size="xxs"
shape="pill"
onClick={onRefreshSuggestions} onClick={onRefreshSuggestions}
disabled={modal.isLoadingSuggestions || modal.isSending} disabled={modal.isLoadingSuggestions || modal.isSending}
className={`rounded-full border px-3 py-1 text-[10px] transition-colors ${
modal.isLoadingSuggestions || modal.isSending
? 'border-white/8 bg-black/20 text-zinc-600'
: 'border-white/10 bg-black/20 text-zinc-200 hover:text-white'
}`}
> >
{modal.isLoadingSuggestions ? '生成中...' : '换一组'} {modal.isLoadingSuggestions ? '生成中...' : '换一组'}
</button> </PlatformActionButton>
</div> </div>
<div className="grid gap-2 sm:grid-cols-3"> <div className="grid gap-2 sm:grid-cols-3">
{modal.suggestions.map((suggestion, index) => ( {modal.suggestions.map((suggestion, index) => (
<button <PlatformDarkOptionCard
key={`${suggestion}-${index}`} key={`${suggestion}-${index}`}
type="button"
onClick={() => onUseSuggestion(suggestion)} onClick={() => onUseSuggestion(suggestion)}
disabled={modal.isSending} disabled={modal.isSending}
className={`rounded-xl border px-3 py-2 text-left text-xs leading-relaxed transition ${ selected={false}
modal.isSending tone="sky"
? 'border-white/8 bg-black/20 text-zinc-600' radius="md"
: 'border-white/8 bg-black/20 text-zinc-200 hover:border-sky-300/30 hover:bg-sky-500/10 hover:text-white' padding="sm"
}`} className="text-xs leading-relaxed"
> >
{suggestion} {suggestion}
</button> </PlatformDarkOptionCard>
))} ))}
</div> </div>
{modal.error && ( {modal.error && (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-100"> <PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="leading-relaxed"
>
{modal.error} {modal.error}
</div> </PlatformStatusMessage>
)} )}
<form <form
@@ -169,13 +203,18 @@ export function CharacterChatModal({
onSendDraft(); onSendDraft();
}} }}
> >
<textarea <PlatformTextField
variant="textarea"
value={modal.draft} value={modal.draft}
onChange={event => onDraftChange(event.target.value)} onChange={event => onDraftChange(event.target.value)}
placeholder={`${modal.target.character.name}说点什么...`} placeholder={`${modal.target.character.name}说点什么...`}
disabled={modal.isSending} disabled={modal.isSending}
rows={4} rows={4}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-100 outline-none transition focus:border-sky-300/35" surface="editorDark"
tone="sky"
size="md"
density="roomy"
className="rounded-2xl bg-black/25 leading-relaxed text-zinc-100 focus:border-sky-300/35"
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<button <button

View File

@@ -0,0 +1,119 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { AnimationState, type Character, WorldType } from '../types';
import { CharacterDetailModal } from './CharacterDetailModal';
vi.mock('./CharacterAnimator', () => ({
CharacterAnimator: () => <div></div>,
}));
vi.mock('./MedievalNpcAnimator', () => ({
MedievalNpcAnimator: () => <div>NPC </div>,
}));
vi.mock('./PixelCloseButton', () => ({
PixelCloseButton: ({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) => (
<button type="button" onClick={onClick}>
{label}
</button>
),
}));
function createCharacter(): Character {
return {
id: 'sword-princess',
name: '剑之公主',
title: '王庭剑姬',
gender: 'female',
description: '以迅疾剑技和正面压制见长。',
backstory: '王庭旁支出身,正在追回失落誓剑。',
avatar: '/roles/sword-princess.png',
portrait: '/roles/sword-princess.png',
assetFolder: 'roles',
assetVariant: 'generated',
attributes: {
strength: 12,
agility: 14,
intelligence: 8,
spirit: 10,
},
personality: '外冷内热,做决定时很少犹豫。',
skills: [
{
id: 'oath-slash',
name: '誓剑斩',
animation: AnimationState.SKILL1,
damage: 18,
manaCost: 6,
cooldownTurns: 2,
range: 1,
style: 'burst',
},
],
adventureOpenings: {
[WorldType.WUXIA]: {
reason: '踏入王庭旧案。',
goal: '追回誓剑。',
monologue: '旧誓仍在。',
},
},
};
}
function findPanelForText(text: string) {
let current: HTMLElement | null = screen.getByText(text);
while (current) {
if (
current.className.includes('border-white/10') &&
current.className.includes('bg-black/25')
) {
return current;
}
current = current.parentElement;
}
return null;
}
test('角色详情装备背包和旅程信息复用暗色平台子面板', () => {
render(
<CharacterDetailModal
character={createCharacter()}
worldType={WorldType.WUXIA}
onClose={vi.fn()}
/>,
);
const candidateBadge = screen.getByText('候选人');
const genderBadge = screen.getByText('性别: 女');
expect(candidateBadge.className).toContain('rounded-full');
expect(candidateBadge.className).toContain('bg-sky-500/10');
expect(genderBadge.className).toContain('rounded-full');
expect(genderBadge.className).toContain('bg-black/20');
for (const text of [
'王庭剑',
'武斗牌',
'踏入王庭旧案。',
'追回誓剑。',
'王庭旁支出身,正在追回失落誓剑。',
'外冷内热,做决定时很少犹豫。',
]) {
const panel = findPanelForText(text);
expect(panel?.className).toContain('border-white/10');
expect(panel?.className).toContain('bg-black/25');
expect(panel?.className).toContain('rounded-[1rem]');
}
});

View File

@@ -36,6 +36,8 @@ import {
CharacterAttributeGrid, CharacterAttributeGrid,
CharacterSkillsList, CharacterSkillsList,
} from './CharacterInfoShared'; } from './CharacterInfoShared';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { MedievalNpcAnimator } from './MedievalNpcAnimator'; import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelCloseButton } from './PixelCloseButton'; import { PixelCloseButton } from './PixelCloseButton';
@@ -97,9 +99,12 @@ function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
return ( return (
<div className="grid gap-2 sm:grid-cols-3"> <div className="grid gap-2 sm:grid-cols-3">
{items.map((item) => ( {items.map((item) => (
<div <PlatformSubpanel
as="div"
key={`${item.slot}-${item.item}`} key={`${item.slot}-${item.item}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3" surface="dark"
radius="sm"
padding="sm"
> >
<div className="text-[10px] tracking-[0.16em] text-zinc-500"> <div className="text-[10px] tracking-[0.16em] text-zinc-500">
{item.slot} {item.slot}
@@ -108,7 +113,7 @@ function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
{item.item} {item.item}
</div> </div>
<div className="mt-1 text-xs text-zinc-400">{item.rarity}</div> <div className="mt-1 text-xs text-zinc-400">{item.rarity}</div>
</div> </PlatformSubpanel>
))} ))}
</div> </div>
); );
@@ -118,9 +123,12 @@ function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
return ( return (
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{items.map((item) => ( {items.map((item) => (
<div <PlatformSubpanel
as="div"
key={`${item.category}-${item.name}-${item.quantity}`} key={`${item.category}-${item.name}-${item.quantity}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3" surface="dark"
radius="sm"
padding="sm"
> >
<div className="text-[10px] tracking-[0.16em] text-zinc-500"> <div className="text-[10px] tracking-[0.16em] text-zinc-500">
{item.category} {item.category}
@@ -131,7 +139,7 @@ function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
<div className="mt-1 text-xs text-zinc-400"> <div className="mt-1 text-xs text-zinc-400">
x{item.quantity} x{item.quantity}
</div> </div>
</div> </PlatformSubpanel>
))} ))}
</div> </div>
); );
@@ -203,7 +211,9 @@ export function CharacterDetailModal({
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20"> <div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
{character.visual ? ( {character.visual ? (
<MedievalNpcAnimator <MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)} visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
character.visual,
)}
scale={2.08} scale={2.08}
/> />
) : ( ) : (
@@ -216,17 +226,25 @@ export function CharacterDetailModal({
/> />
)} )}
</div> </div>
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100"> <PlatformPillBadge
tone="darkSky"
size="sm"
className="mt-3 tracking-[0.18em]"
>
</div> </PlatformPillBadge>
<div className="mt-3 text-base font-bold text-white"> <div className="mt-3 text-base font-bold text-white">
{character.name} {character.name}
</div> </div>
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500"> <div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
<span>{character.title}</span> <span>{character.title}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200"> <PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="text-[9px] tracking-[0.18em]"
>
: {getGenderLabel(character.gender)} : {getGenderLabel(character.gender)}
</span> </PlatformPillBadge>
</div> </div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300"> <p className="mt-3 text-sm leading-relaxed text-zinc-300">
{character.description} {character.description}
@@ -262,18 +280,28 @@ export function CharacterDetailModal({
{opening && ( {opening && (
<Section title="旅程"> <Section title="旅程">
<div className="space-y-2 text-sm leading-relaxed text-zinc-300"> <div className="space-y-2 text-sm leading-relaxed text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"> <PlatformSubpanel
as="div"
surface="dark"
radius="sm"
padding="sm"
>
<div className="text-[10px] tracking-[0.16em] text-zinc-500"> <div className="text-[10px] tracking-[0.16em] text-zinc-500">
</div> </div>
<div className="mt-1">{opening.reason}</div> <div className="mt-1">{opening.reason}</div>
</div> </PlatformSubpanel>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"> <PlatformSubpanel
as="div"
surface="dark"
radius="sm"
padding="sm"
>
<div className="text-[10px] tracking-[0.16em] text-zinc-500"> <div className="text-[10px] tracking-[0.16em] text-zinc-500">
</div> </div>
<div className="mt-1">{opening.goal}</div> <div className="mt-1">{opening.goal}</div>
</div> </PlatformSubpanel>
</div> </div>
</Section> </Section>
)} )}
@@ -293,15 +321,27 @@ export function CharacterDetailModal({
</Section> </Section>
<Section title="背景"> <Section title="背景">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300"> <PlatformSubpanel
as="div"
surface="dark"
radius="sm"
padding="md"
className="text-sm leading-relaxed text-zinc-300"
>
{character.backstory} {character.backstory}
</div> </PlatformSubpanel>
</Section> </Section>
<Section title="性格"> <Section title="性格">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300"> <PlatformSubpanel
as="div"
surface="dark"
radius="sm"
padding="md"
className="text-sm leading-relaxed text-zinc-300"
>
{character.personality} {character.personality}
</div> </PlatformSubpanel>
</Section> </Section>
</div> </div>
</div> </div>

View File

@@ -4,10 +4,13 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import type { BuildContributionRow } from '../data/buildDamage';
import { AnimationState, type Character } from '../types'; import { AnimationState, type Character } from '../types';
import { import {
BuildContributionDetailPanel,
CharacterIdentityBadges, CharacterIdentityBadges,
CharacterSkillsList, CharacterSkillsList,
MultiplierContributionList,
PlayerLevelProgress, PlayerLevelProgress,
} from './CharacterInfoShared'; } from './CharacterInfoShared';
@@ -31,6 +34,19 @@ afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
function findNearestClassName(element: HTMLElement, className: string) {
let current: HTMLElement | null = element;
while (current) {
if (current.className.includes(className)) {
return current.className;
}
current = current.parentElement;
}
return '';
}
test('CharacterSkillsList falls back to stable render ids when skill ids are empty', async () => { test('CharacterSkillsList falls back to stable render ids when skill ids are empty', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const handleSelectSkill = vi.fn(); const handleSelectSkill = vi.fn();
@@ -49,9 +65,16 @@ test('CharacterSkillsList falls back to stable render ids when skill ids are emp
); );
const buttons = screen.getAllByRole('button'); const buttons = screen.getAllByRole('button');
expect(buttons[0]?.className).toContain('bg-black/25');
expect(buttons[0]?.className).toContain('hover:border-sky-300/25');
await user.click(buttons[0]!); await user.click(buttons[0]!);
await user.click(buttons[1]!); await user.click(buttons[1]!);
const deliveryBadge = screen.getAllByText('近战')[0]!;
expect(deliveryBadge.className).toContain('rounded-full');
expect(deliveryBadge.className).toContain('bg-white/6');
expect(handleSelectSkill).toHaveBeenNthCalledWith(1, 'skill-潮刃突进-0'); expect(handleSelectSkill).toHaveBeenNthCalledWith(1, 'skill-潮刃突进-0');
expect(handleSelectSkill).toHaveBeenNthCalledWith(2, 'skill-雾行转位-1'); expect(handleSelectSkill).toHaveBeenNthCalledWith(2, 'skill-雾行转位-1');
@@ -66,6 +89,27 @@ test('CharacterSkillsList falls back to stable render ids when skill ids are emp
expect(duplicateKeyCalls).toHaveLength(0); expect(duplicateKeyCalls).toHaveLength(0);
}); });
test('CharacterSkillsList empty state reuses dark PlatformEmptyState chrome', () => {
render(<CharacterSkillsList skills={[]} emptyText="暂未掌握技能" />);
const emptyState = screen.getByText('暂未掌握技能');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('bg-black/20');
expect(emptyState.className).toContain('border-dashed');
});
test('CharacterSkillsList readonly cards reuse dark PlatformSubpanel chrome', () => {
render(<CharacterSkillsList skills={[createSkill('潮刃突进', 'burst')]} />);
const skillCardClassName = findNearestClassName(
screen.getByText('潮刃突进'),
'bg-black/25',
);
expect(skillCardClassName).toContain('border-white/5');
});
test('CharacterIdentityBadges renders role and level chips together', () => { test('CharacterIdentityBadges renders role and level chips together', () => {
render( render(
<CharacterIdentityBadges <CharacterIdentityBadges
@@ -77,6 +121,98 @@ test('CharacterIdentityBadges renders role and level chips together', () => {
expect(screen.getByText('队长')).toBeTruthy(); expect(screen.getByText('队长')).toBeTruthy();
expect(screen.getByText('Lv.7')).toBeTruthy(); expect(screen.getByText('Lv.7')).toBeTruthy();
expect(screen.getByText('队长').className).toContain('bg-amber-500/10');
expect(screen.getByText('Lv.7').className).toContain('bg-black/20');
});
test('MultiplierContributionList empty state reuses dark platform pill badge', () => {
render(
<MultiplierContributionList
breakdown={{
tags: [],
baseTagCount: 0,
buildDamageBonus: 0,
buildDamageMultiplier: 1,
rows: [],
}}
onSelectContribution={vi.fn()}
/>,
);
const panelClassName = findNearestClassName(
screen.getByText('状态标签'),
'bg-sky-500/8',
);
const emptyBadge = screen.getByText('当前还没有形成有效标签');
expect(panelClassName).toContain('border-sky-400/18');
expect(panelClassName).toContain('rounded-xl');
expect(emptyBadge.className).toContain('rounded-full');
expect(emptyBadge.className).toContain('bg-black/20');
});
test('BuildContributionDetailPanel reuses dark PlatformSubpanel chrome', () => {
const row: BuildContributionRow = {
label: '潮汐',
source: 'character',
fitScore: 0.72,
sourceCoefficient: 1,
bonusDelta: 0.12,
attributeSimilarities: {},
attributeWeights: {},
attributeContributions: {},
attributeModifierDeltas: { axis_a: 0.12 },
};
render(
<BuildContributionDetailPanel
row={row}
attributes={[
{
slotId: 'axis_a',
label: '武力',
similarity: 0.8,
weight: 1,
value: 0.8,
modifierDelta: 0.12,
percent: 12,
},
]}
/>,
);
const overviewPanel = screen.getByText('标签概览').closest('section');
const attributePanel = screen.getByText('属性加成').closest('section');
const attributeRow = screen.getByText('武力').closest('section');
expect(screen.getByText('潮汐')).toBeTruthy();
expect(screen.getByText('总加成 +12.0%')).toBeTruthy();
expect(screen.getByText('+12.0%')).toBeTruthy();
expect(overviewPanel?.className).toContain('bg-black/25');
expect(attributePanel?.className).toContain('bg-black/25');
expect(attributeRow?.className).toContain('bg-black/25');
});
test('BuildContributionDetailPanel empty state reuses dark PlatformEmptyState chrome', () => {
const row: BuildContributionRow = {
label: '潮汐',
source: 'character',
fitScore: 0.72,
sourceCoefficient: 1,
bonusDelta: 0.12,
attributeSimilarities: {},
attributeWeights: {},
attributeContributions: {},
attributeModifierDeltas: {},
};
render(<BuildContributionDetailPanel row={row} attributes={[]} />);
const emptyState = screen.getByText('当前标签还没有可展示的属性适配明细。');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('border-dashed');
expect(emptyState.className).toContain('bg-black/20');
}); });
test('PlayerLevelProgress renders xp progress details', () => { test('PlayerLevelProgress renders xp progress details', () => {

View File

@@ -1,6 +1,7 @@
import { resolveRoleCombatStats } from '../data/attributeCombat'; import { resolveRoleCombatStats } from '../data/attributeCombat';
import { getAttributeSlotValue } from '../data/attributeResolver'; import { getAttributeSlotValue } from '../data/attributeResolver';
import { import {
type BuildContributionAttributeRow,
type BuildDamageBreakdown, type BuildDamageBreakdown,
formatBuildContributionPercent, formatBuildContributionPercent,
getBuildContributionQualityLabel, getBuildContributionQualityLabel,
@@ -21,6 +22,10 @@ import {
getSkillDeliveryLabel, getSkillDeliveryLabel,
getSkillStyleLabel, getSkillStyleLabel,
} from './CharacterInfoHelpers'; } from './CharacterInfoHelpers';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import type { PlatformPillBadgeTone } from './common/platformPillBadgeModel';
import { PlatformSubpanel } from './common/PlatformSubpanel';
export function StatusRow({ export function StatusRow({
label, label,
@@ -68,28 +73,34 @@ export function CharacterIdentityBadges({
roleTone?: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc'; roleTone?: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc';
className?: string; className?: string;
}) { }) {
const roleClass = const roleBadgeTone: PlatformPillBadgeTone =
roleTone === 'amber' roleTone === 'amber'
? 'border-amber-300/20 bg-amber-500/10 text-amber-100' ? 'darkAmber'
: roleTone === 'rose' : roleTone === 'rose'
? 'border-rose-300/20 bg-rose-500/10 text-rose-100' ? 'darkRose'
: roleTone === 'emerald' : roleTone === 'emerald'
? 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100' ? 'darkEmerald'
: roleTone === 'zinc' : roleTone === 'zinc'
? 'border-white/10 bg-black/20 text-zinc-200' ? 'darkNeutral'
: 'border-sky-300/20 bg-sky-500/10 text-sky-100'; : 'darkSky';
return ( return (
<div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}> <div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}>
<span <PlatformPillBadge
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${roleClass}`} tone={roleBadgeTone}
size="xxs"
className="tracking-[0.16em]"
> >
{roleLabel} {roleLabel}
</span> </PlatformPillBadge>
{levelText ? ( {levelText ? (
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] tracking-[0.16em] text-zinc-200"> <PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="tracking-[0.16em]"
>
{levelText} {levelText}
</span> </PlatformPillBadge>
) : null} ) : null}
</div> </div>
); );
@@ -112,10 +123,7 @@ export function PlayerLevelProgress({
const ratio = const ratio =
safeXpToNextLevel <= 0 safeXpToNextLevel <= 0
? 1 ? 1
: Math.max( : Math.max(0, Math.min(1, safeCurrentLevelXp / safeXpToNextLevel));
0,
Math.min(1, safeCurrentLevelXp / safeXpToNextLevel),
);
return ( return (
<div className={className}> <div className={className}>
@@ -150,9 +158,9 @@ export function CharacterSkillsList({
}) { }) {
if (skills.length === 0) { if (skills.length === 0) {
return ( return (
<div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500"> <PlatformEmptyState surface="editorDark" size="compact" tone="soft">
{emptyText} {emptyText}
</div> </PlatformEmptyState>
); );
} }
@@ -164,9 +172,13 @@ export function CharacterSkillsList({
<> <>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div> <div className="font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100"> <PlatformPillBadge
tone="darkSoft"
size="xxs"
className="px-2 py-0.5"
>
{getSkillDeliveryLabel(skill)} {getSkillDeliveryLabel(skill)}
</span> </PlatformPillBadge>
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400"> <div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div> <div>{skill.damage}</div>
@@ -182,24 +194,32 @@ export function CharacterSkillsList({
if (onSelectSkill) { if (onSelectSkill) {
return ( return (
<button <PlatformSubpanel
as="button"
key={skillRenderId} key={skillRenderId}
type="button" type="button"
onClick={() => onSelectSkill(skillRenderId)} onClick={() => onSelectSkill(skillRenderId)}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8" surface="dark"
radius="xs"
padding="sm"
className="text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
> >
{content} {content}
</button> </PlatformSubpanel>
); );
} }
return ( return (
<div <PlatformSubpanel
as="div"
key={skillRenderId} key={skillRenderId}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300" surface="dark"
radius="xs"
padding="sm"
className="border-white/5 bg-black/20 text-sm text-zinc-300"
> >
{content} {content}
</div> </PlatformSubpanel>
); );
})} })}
</div> </div>
@@ -220,7 +240,13 @@ export function MultiplierContributionList({
); );
return ( return (
<div className="space-y-3 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-3"> <PlatformSubpanel
as="div"
surface="darkSky"
radius="xs"
padding="sm"
className="space-y-3"
>
<div className="flex flex-col items-start gap-1 text-[10px] uppercase tracking-[0.16em] text-sky-100/80 sm:flex-row sm:items-center sm:justify-between sm:gap-3"> <div className="flex flex-col items-start gap-1 text-[10px] uppercase tracking-[0.16em] text-sky-100/80 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span></span> <span></span>
<span className="text-[9px] leading-4 text-zinc-400 sm:text-[10px]"> <span className="text-[9px] leading-4 text-zinc-400 sm:text-[10px]">
@@ -251,10 +277,91 @@ export function MultiplierContributionList({
))} ))}
</div> </div>
) : ( ) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"> <PlatformPillBadge tone="darkNeutral" size="xxs" className="px-2">
</span> </PlatformPillBadge>
)} )}
</PlatformSubpanel>
);
}
/**
* 角色构筑标签详情面板。
* 统一承接队伍面板和实体详情弹窗里的标签概览、属性加成与空明细外壳。
*/
export function BuildContributionDetailPanel({
row,
attributes,
emptyText = '当前标签还没有可展示的属性适配明细。',
}: {
row: ContributionRow;
attributes: BuildContributionAttributeRow[];
emptyText?: string;
}) {
return (
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<div className="space-y-4">
<PlatformSubpanel
surface="dark"
radius="md"
padding="md"
className="px-4 py-4"
style={getContributionVisualStyle(row.bonusDelta)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-[10px] uppercase tracking-[0.16em] text-current/70">
</div>
<div className="mt-2 text-sm font-semibold">{row.label}</div>
</div>
<div className="rounded-xl border border-current/15 bg-black/25 px-3 py-2 text-right">
<div className="text-[11px] tracking-[0.14em] text-current/70">
{getBuildContributionQualityLabel(row.bonusDelta)}
</div>
<div className="mt-1 text-sm font-semibold">
{formatBuildContributionPercent(row.bonusDelta)}
</div>
</div>
</div>
</PlatformSubpanel>
</div>
<PlatformSubpanel surface="dark" radius="md" padding="md">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div>
{attributes.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{attributes.map((attribute) => (
<PlatformSubpanel
key={`${row.label}-${attribute.slotId}`}
surface="dark"
radius="xs"
padding="sm"
className="px-4 py-3"
>
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
<span>{attribute.label}</span>
<span className="font-semibold text-white">
{formatBuildContributionPercent(attribute.modifierDelta)}
</span>
</div>
</PlatformSubpanel>
))}
</div>
) : (
<PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
className="mt-4"
>
{emptyText}
</PlatformEmptyState>
)}
</PlatformSubpanel>
</div> </div>
); );
} }

View File

@@ -0,0 +1,172 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import {
AnimationState,
type Character,
type CompanionRenderState,
type EquipmentLoadout,
WorldType,
} from '../types';
import { CharacterPanel } from './CharacterPanel';
vi.mock('./CharacterAnimator', () => ({
CharacterAnimator: () => <div></div>,
}));
vi.mock('./MedievalNpcAnimator', () => ({
MedievalNpcAnimator: () => <div>NPC </div>,
}));
vi.mock('./PixelCloseButton', () => ({
PixelCloseButton: ({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) => (
<button type="button" onClick={onClick}>
{label}
</button>
),
}));
vi.mock('./PixelIcon', () => ({
PixelIcon: ({ className }: { className?: string }) => (
<span className={className}></span>
),
}));
vi.mock('./ResolvedAssetImage', () => ({
ResolvedAssetImage: ({ alt }: { alt: string }) => <img alt={alt} />,
}));
function createCharacter(id: string, name: string): Character {
return {
id,
name,
title: `${name}称号`,
gender: 'female',
description: `${name}描述`,
backstory: `${name}背景故事`,
avatar: `/${id}.png`,
portrait: `/${id}.png`,
assetFolder: 'roles',
assetVariant: 'generated',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: `${name}性格`,
skills: [],
adventureOpenings: {},
};
}
function findSharedDarkPanelForText(text: string) {
let current: HTMLElement | null = screen.getByText(text);
while (current) {
if (
current.className.includes('border-white/10') &&
current.className.includes('bg-black/25')
) {
return current;
}
current = current.parentElement;
}
return null;
}
test('角色面板详情静态信息复用暗色平台子面板和胶囊标签', async () => {
const user = userEvent.setup();
const playerCharacter = createCharacter('hero', '沈行');
const companionCharacter = createCharacter('sword-princess', '闻雪');
const companionRenderState: CompanionRenderState = {
npcId: 'npc-companion-1',
character: companionCharacter,
hp: 42,
maxHp: 60,
mana: 18,
maxMana: 30,
skillCooldowns: {},
animationState: AnimationState.IDLE,
slot: 'upper',
};
render(
<CharacterPanel
worldType={WorldType.WUXIA}
playerCharacter={playerCharacter}
playerHp={80}
playerMaxHp={100}
playerMana={25}
playerMaxMana={40}
playerEquipment={{} as EquipmentLoadout}
companionRenderStates={[companionRenderState]}
companionArcStates={[
{
characterId: companionCharacter.id,
arcTheme: '潮声里的信任',
currentStage: 'opening',
activeConflictTags: [],
pendingEventIds: [],
resolvedEventIds: [],
},
]}
companionResolutions={[
{
characterId: companionCharacter.id,
resolutionType: 'bonded',
summary: '闻雪与主角完成潮声誓约。',
relatedThreadIds: ['thread-tide'],
},
]}
/>,
);
const multiplierBadge = screen.getAllByText(/ x/u)[0];
expect(multiplierBadge?.className).toContain('rounded-full');
expect(multiplierBadge?.className).toContain('bg-emerald-500/10');
await user.click(screen.getByRole('button', { name: //u }));
const levelProgressPanel = screen.getByTestId(
'character-panel-level-progress',
);
expect(levelProgressPanel.className).toContain('border-amber-300/18');
expect(levelProgressPanel.className).toContain('bg-amber-500/8');
expect(levelProgressPanel.className).toContain('rounded-xl');
expect(findSharedDarkPanelForText('沈行背景故事')?.className).toContain(
'bg-black/25',
);
expect(findSharedDarkPanelForText('沈行性格')?.className).toContain(
'bg-black/25',
);
await user.click(screen.getByRole('button', { name: '关闭角色详情' }));
await user.click(screen.getByRole('button', { name: //u }));
expect(findSharedDarkPanelForText('个人线阶段')?.className).toContain(
'bg-black/25',
);
expect(findSharedDarkPanelForText('潮声里的信任')?.className).toContain(
'bg-black/25',
);
const resolutionPanel = screen.getByTestId('character-panel-resolution');
expect(resolutionPanel.className).toContain('border-emerald-400/18');
expect(resolutionPanel.className).toContain('bg-emerald-500/8');
expect(resolutionPanel.className).toContain('rounded-xl');
expect(findSharedDarkPanelForText('王庭剑')?.className).toContain(
'bg-black/25',
);
});

View File

@@ -7,9 +7,7 @@ import {
} from '../data/attributeResolver'; } from '../data/attributeResolver';
import { import {
type BuildDamageBreakdown, type BuildDamageBreakdown,
formatBuildContributionPercent,
getBuildContributionAttributeRows, getBuildContributionAttributeRows,
getBuildContributionQualityLabel,
getCompanionBuildDamageBreakdown, getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown, getPlayerBuildDamageBreakdown,
} from '../data/buildDamage'; } from '../data/buildDamage';
@@ -51,10 +49,10 @@ import { BackstoryArchive } from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator'; import { CharacterAnimator } from './CharacterAnimator';
import { import {
getCharacterDetailSpriteStyle, getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getGenderLabel, getGenderLabel,
} from './CharacterInfoHelpers'; } from './CharacterInfoHelpers';
import { import {
BuildContributionDetailPanel,
CharacterAttributeGrid, CharacterAttributeGrid,
CharacterIdentityBadges, CharacterIdentityBadges,
CharacterSkillsList, CharacterSkillsList,
@@ -62,6 +60,8 @@ import {
PlayerLevelProgress, PlayerLevelProgress,
StatusRow, StatusRow,
} from './CharacterInfoShared'; } from './CharacterInfoShared';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import type { GameCanvasEntitySelection } from './GameCanvas'; import type { GameCanvasEntitySelection } from './GameCanvas';
import { MedievalNpcAnimator } from './MedievalNpcAnimator'; import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelCloseButton } from './PixelCloseButton'; import { PixelCloseButton } from './PixelCloseButton';
@@ -417,16 +417,24 @@ export function CharacterPanel({
/> />
</div> </div>
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-zinc-400"> <div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-zinc-400">
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-zinc-200"> <PlatformPillBadge
tone="darkNeutral"
size="xs"
className="px-2 py-0.5 font-normal text-zinc-200"
>
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0}{' '} {buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0}{' '}
</span> </PlatformPillBadge>
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-emerald-100"> <PlatformPillBadge
tone="darkEmerald"
size="xs"
className="px-2 py-0.5 font-normal"
>
{'\u9002\u914d'} x {'\u9002\u914d'} x
{buildBreakdownByMemberId[ {buildBreakdownByMemberId[
member.id member.id
]?.buildDamageMultiplier.toFixed(2) ?? '1.00'} ]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
</span> </PlatformPillBadge>
</div> </div>
</div> </div>
</div> </div>
@@ -473,72 +481,10 @@ export function CharacterPanel({
</div> </div>
<div className="overflow-y-auto p-4 sm:p-5"> <div className="overflow-y-auto p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]"> <BuildContributionDetailPanel
<div className="space-y-4"> row={selectedContributionRow}
<div attributes={selectedContributionAttributes}
className="rounded-2xl border px-4 py-4" />
style={getContributionVisualStyle(
selectedContributionRow.bonusDelta,
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-[10px] uppercase tracking-[0.16em] text-current/70">
</div>
<div className="mt-2 text-sm font-semibold">
{selectedContributionRow.label}
</div>
</div>
<div className="rounded-xl border border-current/15 bg-black/25 px-3 py-2 text-right">
<div className="text-[11px] tracking-[0.14em] text-current/70">
{getBuildContributionQualityLabel(
selectedContributionRow.bonusDelta,
)}
</div>
<div className="mt-1 text-sm font-semibold">
{'\u603b\u52a0\u6210'}{' '}
{formatBuildContributionPercent(
selectedContributionRow.bonusDelta,
)}
</div>
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
{'\u5c5e\u6027\u52a0\u6210'}
</div>
{selectedContributionAttributes.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{selectedContributionAttributes.map((attribute) => (
<div
key={`${selectedContributionRow.label}-${attribute.slotId}`}
className="rounded-xl border border-white/8 bg-black/25 px-4 py-3"
>
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
<span>{attribute.label}</span>
<span className="font-semibold text-white">
{formatBuildContributionPercent(
attribute.modifierDelta,
)}
</span>
</div>
</div>
))}
</div>
) : (
<div className="mt-4 rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-6 text-zinc-400">
{
'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'
}
</div>
)}
</div>
</div>
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>
@@ -580,9 +526,13 @@ export function CharacterPanel({
levelText={selectedMember.levelText} levelText={selectedMember.levelText}
roleTone={selectedMember.isLeader ? 'amber' : 'sky'} roleTone={selectedMember.isLeader ? 'amber' : 'sky'}
/> />
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200"> <PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="px-2 py-0.5 text-[9px] font-normal text-zinc-200"
>
{getGenderLabel(selectedMember.character.gender)} {getGenderLabel(selectedMember.character.gender)}
</span> </PlatformPillBadge>
</div> </div>
</div> </div>
<PixelCloseButton <PixelCloseButton
@@ -639,7 +589,13 @@ export function CharacterPanel({
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{selectedMember.isLeader && ( {selectedMember.isLeader && (
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3"> <PlatformSubpanel
as="div"
surface="darkAmber"
radius="xs"
padding="sm"
data-testid="character-panel-level-progress"
>
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75"> <div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
</div> </div>
@@ -652,7 +608,7 @@ export function CharacterPanel({
normalizedPlayerProgression.xpToNextLevel normalizedPlayerProgression.xpToNextLevel
} }
/> />
</div> </PlatformSubpanel>
)} )}
<StatusRow <StatusRow
label={resourceLabels.hp} label={resourceLabels.hp}
@@ -670,7 +626,13 @@ export function CharacterPanel({
<AffinityStatusCard affinity={selectedMemberAffinity} /> <AffinityStatusCard affinity={selectedMemberAffinity} />
)} )}
{selectedMemberArcState && ( {selectedMemberArcState && (
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-300"> <PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="text-xs text-zinc-300"
>
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500"> <div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
线 线
</div> </div>
@@ -680,10 +642,17 @@ export function CharacterPanel({
<div className="mt-1 text-[11px] text-sky-200/85"> <div className="mt-1 text-[11px] text-sky-200/85">
{selectedMemberArcState.arcTheme} {selectedMemberArcState.arcTheme}
</div> </div>
</div> </PlatformSubpanel>
)} )}
{selectedMemberResolution && ( {selectedMemberResolution && (
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-zinc-300"> <PlatformSubpanel
as="div"
surface="darkEmerald"
radius="xs"
padding="row"
className="text-xs"
data-testid="character-panel-resolution"
>
<div className="text-[10px] uppercase tracking-[0.18em] text-emerald-200/80"> <div className="text-[10px] uppercase tracking-[0.18em] text-emerald-200/80">
</div> </div>
@@ -693,7 +662,7 @@ export function CharacterPanel({
<div className="mt-1 text-[11px] text-emerald-100/85"> <div className="mt-1 text-[11px] text-emerald-100/85">
{selectedMemberResolution.summary} {selectedMemberResolution.summary}
</div> </div>
</div> </PlatformSubpanel>
)} )}
{selectedMemberAffinity != null && ( {selectedMemberAffinity != null && (
<BackstoryArchive <BackstoryArchive
@@ -735,9 +704,15 @@ export function CharacterPanel({
<div className="mb-3 text-xs font-bold text-white"> <div className="mb-3 text-xs font-bold text-white">
</div> </div>
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300"> <PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="md"
className="text-sm leading-relaxed text-zinc-300"
>
{selectedMember.character.backstory} {selectedMember.character.backstory}
</div> </PlatformSubpanel>
</div> </div>
)} )}
@@ -748,9 +723,15 @@ export function CharacterPanel({
<div className="mb-3 text-xs font-bold text-white"> <div className="mb-3 text-xs font-bold text-white">
</div> </div>
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300"> <PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="md"
className="text-sm leading-relaxed text-zinc-300"
>
{selectedMember.character.personality} {selectedMember.character.personality}
</div> </PlatformSubpanel>
</div> </div>
<div <div
@@ -774,9 +755,13 @@ export function CharacterPanel({
</div> </div>
<div className="space-y-2 text-sm text-zinc-300"> <div className="space-y-2 text-sm text-zinc-300">
{selectedEquipmentRows.map((item) => ( {selectedEquipmentRows.map((item) => (
<div <PlatformSubpanel
as="div"
key={item.key} key={item.key}
className="flex items-center justify-between rounded-lg border border-white/5 bg-black/20 px-3 py-2" surface="dark"
radius="xs"
padding="row"
className="flex items-center justify-between"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<PixelIcon <PixelIcon
@@ -790,10 +775,14 @@ export function CharacterPanel({
<div>{item.itemLabel}</div> <div>{item.itemLabel}</div>
</div> </div>
</div> </div>
<span className="rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100"> <PlatformPillBadge
tone="darkAmber"
size="xxs"
className="px-2 py-0.5 font-normal"
>
{item.rarityLabel} {item.rarityLabel}
</span> </PlatformPillBadge>
</div> </PlatformSubpanel>
))} ))}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,128 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { getCharacterById } from '../data/characterPresets';
import type { CompanionState } from '../types';
import { CompanionCampModal } from './CompanionCampModal';
function createCompanion(
overrides: Partial<CompanionState> = {},
): CompanionState {
return {
npcId: 'npc-archer',
characterId: 'archer-hero',
joinedAtAffinity: 36,
hp: 42,
maxHp: 50,
mana: 18,
maxMana: 24,
skillCooldowns: {},
...overrides,
};
}
test('营地编组战斗中提示复用暗色 PlatformStatusMessage chrome', () => {
render(
<CompanionCampModal
isOpen
playerCharacter={null}
companions={[]}
roster={[]}
inBattle
onClose={vi.fn()}
onBenchCompanion={vi.fn()}
onActivateCompanion={vi.fn()}
/>,
);
const warning = screen.getByText('战斗中无法调整编组。');
expect(warning.className).toContain('platform-status-message');
expect(warning.className).toContain('border-amber-300/15');
expect(warning.className).toContain('bg-amber-500/10');
expect(warning.className).toContain('mb-3');
const currentSection = screen.getByText('当前队伍').closest('section');
const reserveSection = screen.getByText('后备队伍').closest('section');
const activeEmptyState = screen.getByText('当前没有已出战的同行者。');
const reserveEmptyState = screen.getByText('当前还没有后备同行者。');
const activeCountBadge = screen.getByText(/^出战 0\//);
const campFooter = screen.getByText('营地气氛').closest(
'.platform-dark-modal-footer',
);
expect(currentSection?.className).toContain('bg-black/25');
expect(reserveSection?.className).toContain('bg-black/25');
expect(activeEmptyState.className).toContain('platform-empty-state');
expect(reserveEmptyState.className).toContain('platform-empty-state');
expect(activeCountBadge.className).toContain('rounded-full');
expect(activeCountBadge.className).toContain('bg-black/20');
expect(campFooter?.className).toContain('border-t');
expect(campFooter?.className).toContain('px-5');
});
test('营地编组同行者卡片和替换位按钮复用暗色公共组件', async () => {
const user = userEvent.setup();
const playerCharacter = getCharacterById('sword-princess');
if (!playerCharacter) {
throw new Error('测试需要剑姬角色预设');
}
render(
<CompanionCampModal
isOpen
playerCharacter={playerCharacter}
companions={[createCompanion()]}
roster={[
createCompanion({
npcId: 'npc-girl',
characterId: 'girl-hero',
joinedAtAffinity: 72,
}),
]}
inBattle={false}
onClose={vi.fn()}
onBenchCompanion={vi.fn()}
onActivateCompanion={vi.fn()}
/>,
);
const activeCard = screen.getByTestId('active-companion-card-npc-archer');
const reserveCard = screen.getByTestId('reserve-companion-card-npc-girl');
const replacementButton = screen.getByRole('button', {
name: '设为替换位',
});
const benchButton = screen.getByRole('button', { name: '转入后备' });
const activateButton = screen.getByRole('button', { name: '编入队伍' });
const hpBadge = within(activeCard).getByText('生命 42/50');
const activePortrait = within(activeCard).getByRole('img');
const reservePortrait = within(reserveCard).getByRole('img');
const activePortraitFrame = activePortrait.closest('.platform-media-frame');
const reservePortraitFrame = reservePortrait.closest('.platform-media-frame');
expect(activeCard.className).toContain('bg-black/25');
expect(reserveCard.className).toContain('bg-black/25');
expect(activePortraitFrame?.className).toContain('border-white/10');
expect(activePortraitFrame?.className).toContain('radial-gradient');
expect(reservePortraitFrame?.className).toContain('platform-media-frame');
expect(activePortrait.className).toContain('scale-125');
expect(replacementButton.className).toContain('platform-dark-option-card');
expect(benchButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(benchButton.className).toContain('bg-white/5');
expect(activateButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(activateButton.className).toContain('bg-emerald-400');
expect(hpBadge.className).toContain('rounded-full');
await user.click(replacementButton);
expect(activeCard.className).toContain('border-sky-400/18');
expect(activeCard.className).toContain('bg-sky-500/8');
expect(replacementButton.className).toContain('border-sky-400/45');
});

View File

@@ -5,8 +5,15 @@ import { getCharacterById } from '../data/characterPresets';
import { MAX_COMPANIONS } from '../data/npcInteractions'; import { MAX_COMPANIONS } from '../data/npcInteractions';
import { Character, CompanionState } from '../types'; import { Character, CompanionState } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformMediaFrame } from './common/PlatformMediaFrame';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PixelCloseButton } from './PixelCloseButton'; import { PixelCloseButton } from './PixelCloseButton';
import { ResolvedAssetImage } from './ResolvedAssetImage';
interface CompanionCampModalProps { interface CompanionCampModalProps {
isOpen: boolean; isOpen: boolean;
@@ -26,9 +33,13 @@ type CompanionCardData = {
function StatusPill({ label, value }: { label: string; value: string }) { function StatusPill({ label, value }: { label: string; value: string }) {
return ( return (
<div className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"> <PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="font-normal text-zinc-300"
>
{label} {value} {label} {value}
</div> </PlatformPillBadge>
); );
} }
@@ -149,7 +160,13 @@ export function CompanionCampModal({
</div> </div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[1.05fr_0.95fr] lg:overflow-hidden"> <div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[1.05fr_0.95fr] lg:overflow-hidden">
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto"> <PlatformSubpanel
as="section"
surface="dark"
radius="sm"
padding="md"
className="lg:min-h-0 lg:overflow-y-auto"
>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div> <div>
<div className="text-xs font-bold text-white"></div> <div className="text-xs font-bold text-white"></div>
@@ -160,28 +177,39 @@ export function CompanionCampModal({
<StatusPill label="出战" value={`${companions.length}/${MAX_COMPANIONS}`} /> <StatusPill label="出战" value={`${companions.length}/${MAX_COMPANIONS}`} />
</div> </div>
{inBattle && ( {inBattle && (
<div className="mb-3 rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-100"> <PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="mb-3"
>
</div> </PlatformStatusMessage>
)} )}
<div className="space-y-3"> <div className="space-y-3">
{activeCompanionCards.length > 0 ? activeCompanionCards.map(({ companion, character }) => { {activeCompanionCards.length > 0 ? activeCompanionCards.map(({ companion, character }) => {
const selectedForSwap = selectedSwapNpcId === companion.npcId; const selectedForSwap = selectedSwapNpcId === companion.npcId;
return ( return (
<div <PlatformSubpanel
as="div"
key={companion.npcId} key={companion.npcId}
className={`rounded-xl border px-3 py-3 ${selectedForSwap ? 'border-sky-400/40 bg-sky-500/10' : 'border-white/8 bg-black/20'}`} data-testid={`active-companion-card-${companion.npcId}`}
surface={selectedForSwap ? 'darkSky' : 'dark'}
radius="xs"
padding="md"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25"> <PlatformMediaFrame
<ResolvedAssetImage src={character.portrait}
src={character.portrait} alt={character.name}
alt={character.name} fallbackLabel={character.name}
className="h-full w-full scale-125 object-contain" aspect="square"
style={{ imageRendering: 'pixelated' }} surface="editorDark"
/> className="h-16 w-16 shrink-0 rounded-xl"
</div> imageClassName="h-full w-full scale-125 object-contain"
imageProps={{ style: { imageRendering: 'pixelated' } }}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{character.name}</div> <div className="text-sm font-semibold text-white">{character.name}</div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div> <div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
@@ -193,34 +221,48 @@ export function CompanionCampModal({
</div> </div>
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<button <PlatformDarkOptionCard
type="button"
disabled={inBattle} disabled={inBattle}
onClick={() => setSelectedSwapNpcId(companion.npcId)} onClick={() => setSelectedSwapNpcId(companion.npcId)}
className={`rounded-lg border px-3 py-2 text-xs ${selectedForSwap ? 'border-sky-400/30 bg-sky-500/15 text-sky-100' : 'border-white/10 bg-white/5 text-zinc-200'} ${inBattle ? 'opacity-50' : ''}`} selected={selectedForSwap}
tone="sky"
radius="sm"
padding="sm"
className="text-xs"
> >
</button> </PlatformDarkOptionCard>
<button <PlatformActionButton
type="button" surface="editorDark"
tone="secondary"
size="xs"
disabled={inBattle} disabled={inBattle}
onClick={() => onBenchCompanion(companion.npcId)} onClick={() => onBenchCompanion(companion.npcId)}
className={`rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-200 ${inBattle ? 'opacity-50' : ''}`}
> >
</button> </PlatformActionButton>
</div> </div>
</div> </PlatformSubpanel>
); );
}) : ( }) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400"> <PlatformEmptyState
surface="editorDark"
size="inline"
className="rounded-xl py-6 font-normal text-zinc-400"
>
</div> </PlatformEmptyState>
)} )}
</div> </div>
</section> </PlatformSubpanel>
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto"> <PlatformSubpanel
as="section"
surface="dark"
radius="sm"
padding="md"
className="lg:min-h-0 lg:overflow-y-auto"
>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div> <div>
<div className="text-xs font-bold text-white"></div> <div className="text-xs font-bold text-white"></div>
@@ -235,16 +277,25 @@ export function CompanionCampModal({
{reserveCompanionCards.length > 0 ? reserveCompanionCards.map(({ companion, character }) => { {reserveCompanionCards.length > 0 ? reserveCompanionCards.map(({ companion, character }) => {
const needsSwap = companions.length >= MAX_COMPANIONS; const needsSwap = companions.length >= MAX_COMPANIONS;
return ( return (
<div key={companion.npcId} className="rounded-xl border border-white/8 bg-black/20 px-3 py-3"> <PlatformSubpanel
as="div"
key={companion.npcId}
data-testid={`reserve-companion-card-${companion.npcId}`}
surface="dark"
radius="xs"
padding="md"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25"> <PlatformMediaFrame
<ResolvedAssetImage src={character.portrait}
src={character.portrait} alt={character.name}
alt={character.name} fallbackLabel={character.name}
className="h-full w-full scale-125 object-contain" aspect="square"
style={{ imageRendering: 'pixelated' }} surface="editorDark"
/> className="h-16 w-16 shrink-0 rounded-xl"
</div> imageClassName="h-full w-full scale-125 object-contain"
imageProps={{ style: { imageRendering: 'pixelated' } }}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{character.name}</div> <div className="text-sm font-semibold text-white">{character.name}</div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div> <div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
@@ -255,42 +306,49 @@ export function CompanionCampModal({
</div> </div>
</div> </div>
</div> </div>
<button <PlatformActionButton
type="button" surface="editorDark"
tone={inBattle || (needsSwap && !selectedSwapNpcId) ? 'ghost' : 'success'}
size="xs"
fullWidth
disabled={inBattle || (needsSwap && !selectedSwapNpcId)} disabled={inBattle || (needsSwap && !selectedSwapNpcId)}
onClick={() => onActivateCompanion(companion.npcId, needsSwap ? selectedSwapNpcId : null)} onClick={() => onActivateCompanion(companion.npcId, needsSwap ? selectedSwapNpcId : null)}
className={`mt-3 w-full rounded-lg border px-3 py-2 text-xs ${ className="mt-3"
inBattle || (needsSwap && !selectedSwapNpcId)
? 'border-white/6 bg-black/20 text-zinc-500'
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
}`}
> >
{needsSwap ? '换入队伍' : '编入队伍'} {needsSwap ? '换入队伍' : '编入队伍'}
</button> </PlatformActionButton>
</div> </PlatformSubpanel>
); );
}) : ( }) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400"> <PlatformEmptyState
surface="editorDark"
size="inline"
className="rounded-xl py-6 font-normal text-zinc-400"
>
</div> </PlatformEmptyState>
)} )}
</div> </div>
</section> </PlatformSubpanel>
</div> </div>
<div className="border-t border-white/10 px-5 py-4"> <PlatformDarkModalFooter layout="content" padding="roomy">
<div className="mb-3 text-xs font-bold text-white"></div> <div className="mb-3 text-xs font-bold text-white"></div>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
{campMoments.map((moment, index) => ( {campMoments.map((moment, index) => (
<div <PlatformSubpanel
as="div"
key={`camp-moment-${index}-${moment}`} key={`camp-moment-${index}-${moment}`}
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300" surface="dark"
radius="xs"
padding="md"
className="text-sm leading-relaxed text-zinc-300"
> >
{moment} {moment}
</div> </PlatformSubpanel>
))} ))}
</div> </div>
</div> </PlatformDarkModalFooter>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}

View File

@@ -17,12 +17,24 @@ import { buildCustomWorldScenePresentations } from '../services/customWorldScene
import { import {
AnimationState, AnimationState,
type Character, type Character,
type CustomWorldProfile,
type CustomWorldOpeningCgProfile, type CustomWorldOpeningCgProfile,
type CustomWorldProfile,
type SceneActBlueprint, type SceneActBlueprint,
type SceneChapterBlueprint, type SceneChapterBlueprint,
} from '../types'; } from '../types';
import { CharacterAnimator } from './CharacterAnimator'; import { CharacterAnimator } from './CharacterAnimator';
import { PlatformAcknowledgeStatusDialog } from './common/PlatformAcknowledgeStatusDialog';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDangerConfirmDialog } from './common/PlatformDangerConfirmDialog';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformMediaFrame } from './common/PlatformMediaFrame';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformProgressBar } from './common/PlatformProgressBar';
import { PlatformSegmentedTabs } from './common/PlatformSegmentedTabs';
import { PlatformStatGrid } from './common/PlatformStatGrid';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PlatformTextField } from './common/PlatformTextField';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
import { ResolvedAssetImage } from './ResolvedAssetImage'; import { ResolvedAssetImage } from './ResolvedAssetImage';
import { ResolvedAssetVideo } from './ResolvedAssetVideo'; import { ResolvedAssetVideo } from './ResolvedAssetVideo';
@@ -120,22 +132,17 @@ function SmallButton({
disabled?: boolean; disabled?: boolean;
actions?: ReactNode; actions?: ReactNode;
}) { }) {
const toneClassName =
tone === 'sky'
? 'platform-button platform-button--primary'
: tone === 'rose'
? 'platform-button platform-button--danger'
: 'platform-button platform-button--ghost';
return ( return (
<button <PlatformActionButton
type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={`${toneClassName} min-h-0 rounded-full px-3 py-1 text-[11px] ${disabled ? 'cursor-not-allowed opacity-45' : ''}`} tone={tone === 'sky' ? 'primary' : tone === 'rose' ? 'danger' : 'ghost'}
shape="pill"
size="xs"
className="min-h-0 py-1 text-[11px]"
> >
{children} {children}
</button> </PlatformActionButton>
); );
} }
@@ -149,52 +156,25 @@ function SearchBox({
placeholder: string; placeholder: string;
}) { }) {
return ( return (
<div className="platform-subpanel rounded-2xl px-3 py-2"> <PlatformTextField
<input value={value}
value={value} onChange={(event) => onChange(event.target.value)}
onChange={(event) => onChange(event.target.value)} placeholder={placeholder}
placeholder={placeholder} density="compact"
className="w-full bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]" className="rounded-2xl bg-[var(--platform-subpanel-fill)] px-3 py-2 placeholder:text-[var(--platform-text-soft)]"
/> />
</div>
);
}
function ImageFrame({
src,
alt,
fallbackLabel,
tone = 'square',
}: {
src?: string;
alt: string;
fallbackLabel: string;
tone?: 'square' | 'landscape';
}) {
return (
<div
className={`overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(204,117,76,0.9),rgba(223,127,64,0.82))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
>
{src ? (
<ResolvedAssetImage
src={src}
alt={alt}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
{fallbackLabel}
</div>
)}
</div>
); );
} }
function EmptyState({ title }: { title: string }) { function EmptyState({ title }: { title: string }) {
return ( return (
<div className="platform-subpanel rounded-2xl border-dashed px-5 py-6 text-center"> <PlatformEmptyState
surface="dashed"
size="compact"
className="rounded-2xl px-5 py-6 text-center"
>
<div className="text-sm text-[var(--platform-text-base)]">{title}</div> <div className="text-sm text-[var(--platform-text-base)]">{title}</div>
</div> </PlatformEmptyState>
); );
} }
@@ -208,9 +188,9 @@ function buildFallbackRenderKey(
function NewBadge() { function NewBadge() {
return ( return (
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px] font-semibold"> <PlatformPillBadge tone="warning" size="xxs" className="font-semibold">
</span> </PlatformPillBadge>
); );
} }
@@ -224,7 +204,12 @@ function PendingEntityCard({
progress: number; progress: number;
}) { }) {
return ( return (
<div className="platform-banner platform-banner--info rounded-[1.35rem] px-4 py-4"> <PlatformStatusMessage
tone="info"
surface="platform"
size="md"
className="rounded-[1.35rem] py-4"
>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]"> <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
@@ -232,17 +217,19 @@ function PendingEntityCard({
</div> </div>
<div className="mt-1 text-xs leading-6">{phaseLabel}</div> <div className="mt-1 text-xs leading-6">{phaseLabel}</div>
</div> </div>
<div className="platform-pill platform-pill--cool px-2.5 py-1 text-[10px]"> <PlatformPillBadge tone="cool" size="xxs">
{Math.round(progress)}% {Math.round(progress)}%
</div> </PlatformPillBadge>
</div> </div>
<div className="platform-progress-track mt-3 h-2.5 overflow-hidden rounded-full"> <PlatformProgressBar
<div value={progress}
className="h-full bg-[var(--platform-button-primary-solid)] transition-[width] duration-300" minVisibleValue={6}
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }} size="sm"
/> ariaLabel={`${title} 进度`}
</div> className="mt-3"
</div> fillClassName="bg-[var(--platform-button-primary-solid)]"
/>
</PlatformStatusMessage>
); );
} }
@@ -288,16 +275,16 @@ function OpeningCgPreview({
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]"> <PlatformPillBadge tone="neutral" size="xxs">
80 80
</span> </PlatformPillBadge>
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]"> <PlatformPillBadge tone="neutral" size="xxs">
10 10
</span> </PlatformPillBadge>
{hasVideo ? ( {hasVideo ? (
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]"> <PlatformPillBadge tone="success" size="xxs">
</span> </PlatformPillBadge>
) : null} ) : null}
{!readOnly && onGenerate ? ( {!readOnly && onGenerate ? (
<div className="ml-auto"> <div className="ml-auto">
@@ -312,14 +299,22 @@ function OpeningCgPreview({
) : null} ) : null}
</div> </div>
{isGenerating ? ( {isGenerating ? (
<div className="platform-progress-track h-2 overflow-hidden rounded-full"> <PlatformProgressBar
<div className="h-full w-2/3 animate-pulse bg-[linear-gradient(90deg,#df7f40_0%,#cc754c_52%,#eaccb3_100%)]" /> value={66}
</div> ariaLabel={`${buttonLabel} 进度`}
indeterminate
fillClassName="animate-pulse bg-[linear-gradient(90deg,#df7f40_0%,#cc754c_52%,#eaccb3_100%)]"
/>
) : null} ) : null}
{openingCg?.status === 'failed' && openingCg.errorMessage ? ( {openingCg?.status === 'failed' && openingCg.errorMessage ? (
<div className="platform-banner platform-banner--danger rounded-2xl px-3 py-2 text-xs leading-5"> <PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="rounded-2xl leading-5"
>
{openingCg.errorMessage} {openingCg.errorMessage}
</div> </PlatformStatusMessage>
) : null} ) : null}
</div> </div>
); );
@@ -392,17 +387,20 @@ function SceneActPreviewStrip({
return ( return (
<div className="flex w-full gap-1.5 overflow-x-auto pb-0.5"> <div className="flex w-full gap-1.5 overflow-x-auto pb-0.5">
{acts.map((act) => ( {acts.map((act) => (
<div <PlatformSubpanel
key={act.id} key={act.id}
className="platform-subpanel h-12 w-[5.25rem] shrink-0 overflow-hidden rounded-xl" as="div"
title={act.title} padding="none"
radius="sm"
className="h-12 w-[5.25rem] shrink-0 overflow-hidden rounded-xl"
aria-label={`${sceneName}-${act.title}预览`}
> >
<ResolvedAssetImage <ResolvedAssetImage
src={act.imageSrc} src={act.imageSrc}
alt={`${sceneName}-${act.title}`} alt={`${sceneName}-${act.title}`}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
</div> </PlatformSubpanel>
))} ))}
</div> </div>
); );
@@ -434,34 +432,37 @@ function CatalogCard({
actions?: ReactNode; actions?: ReactNode;
}) { }) {
const selectionBadge = isSelectionMode ? ( const selectionBadge = isSelectionMode ? (
<div <PlatformPillBadge
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${ tone={isSelected ? 'danger' : 'muted'}
isSelected size="xs"
? 'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]' className="shrink-0 py-1 text-[10px]"
: 'platform-subpanel text-[var(--platform-text-soft)]'
}`}
> >
{isSelected ? '已选' : '选择'} {isSelected ? '已选' : '选择'}
</div> </PlatformPillBadge>
) : null; ) : null;
if (layout === 'compact') { if (layout === 'compact') {
return ( return (
<div <PlatformSubpanel
role="button" as="button"
tabIndex={disabled ? -1 : 0} tabIndex={disabled ? -1 : 0}
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
disabled={disabled}
aria-disabled={disabled} aria-disabled={disabled}
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors xl:p-3 ${ padding="none"
isSelected ? 'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)]' : 'platform-subpanel' radius="md"
}`} surface={isSelected ? 'danger' : 'platform'}
className="w-full rounded-[1.3rem] p-2.5 text-left transition-colors xl:p-3"
> >
<div className="flex items-start gap-3 xl:gap-3.5"> <div className="flex items-start gap-3 xl:gap-3.5">
<div <PlatformSubpanel
className={`platform-subpanel shrink-0 overflow-hidden rounded-[1rem] ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`} as="div"
padding="none"
radius="sm"
className={`shrink-0 overflow-hidden rounded-[1rem] ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
> >
{media} {media}
</div> </PlatformSubpanel>
<div className="min-w-0 flex-1 xl:min-h-[5.6rem]"> <div className="min-w-0 flex-1 xl:min-h-[5.6rem]">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[15px] font-semibold leading-5 text-white xl:line-clamp-1"> <div className="min-w-0 text-[15px] font-semibold leading-5 text-white xl:line-clamp-1">
@@ -480,26 +481,31 @@ function CatalogCard({
) : null} ) : null}
</div> </div>
</div> </div>
</div> </PlatformSubpanel>
); );
} }
return ( return (
<div <PlatformSubpanel
role="button" as="button"
tabIndex={disabled ? -1 : 0} tabIndex={disabled ? -1 : 0}
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
disabled={disabled}
aria-disabled={disabled} aria-disabled={disabled}
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${ padding="none"
isSelected ? 'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)]' : 'platform-subpanel' radius="md"
}`} surface={isSelected ? 'danger' : 'platform'}
className="w-full rounded-[1.4rem] p-3 text-left transition-colors"
> >
<div className="space-y-3"> <div className="space-y-3">
<div <PlatformSubpanel
className={`platform-subpanel overflow-hidden rounded-[1.1rem] ${mediaClassName ?? ''}`} as="div"
padding="none"
radius="sm"
className={`overflow-hidden rounded-[1.1rem] ${mediaClassName ?? ''}`}
> >
{media} {media}
</div> </PlatformSubpanel>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-base font-semibold text-white"> <div className="min-w-0 text-base font-semibold text-white">
{title} {title}
@@ -514,7 +520,7 @@ function CatalogCard({
</div> </div>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null} {actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div> </div>
</div> </PlatformSubpanel>
); );
} }
@@ -584,6 +590,11 @@ type CatalogRole =
type BulkDeleteTab = 'story' | 'landmarks'; type BulkDeleteTab = 'story' | 'landmarks';
type EntityCatalogConfirmState =
| { kind: 'minimum-playable' }
| { kind: 'delete-playable'; id: string; name: string }
| { kind: 'bulk-delete'; tab: BulkDeleteTab; ids: string[]; label: string };
function buildRoleSearchText(role: CatalogRole) { function buildRoleSearchText(role: CatalogRole) {
return [ return [
role.name, role.name,
@@ -660,6 +671,8 @@ export function CustomWorldEntityCatalog({
null, null,
); );
const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]); const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]);
const [confirmState, setConfirmState] =
useState<EntityCatalogConfirmState | null>(null);
const deferredSearch = useDeferredValue(searchDraft.trim()); const deferredSearch = useDeferredValue(searchDraft.trim());
const storyNpcById = useMemo( const storyNpcById = useMemo(
@@ -821,6 +834,27 @@ export function CustomWorldEntityCatalog({
1 + 1 +
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0), (pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
} satisfies Record<ResultTab, number>; } satisfies Record<ResultTab, number>;
const resultTabItems = useMemo(
() =>
RESULT_TABS.map((tab) => ({
id: tab.id,
ariaLabel: `${tab.label} ${counts[tab.id]}`,
label: (
<div className="text-left">
<div className="font-semibold">{tab.label}</div>
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
{counts[tab.id]}
</div>
</div>
),
})),
[counts],
);
const worldStatItems = [
{ label: '可扮演角色', value: profile.playableNpcs.length },
{ label: '场景角色', value: profile.storyNpcs.length },
{ label: '场景', value: profile.landmarks.length + 1 },
];
const bulkDeleteTab: BulkDeleteTab | null = const bulkDeleteTab: BulkDeleteTab | null =
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null; activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
const isBulkDeleteMode = const isBulkDeleteMode =
@@ -845,14 +879,10 @@ export function CustomWorldEntityCatalog({
const removePlayable = (id: string, name: string) => { const removePlayable = (id: string, name: string) => {
if (profile.playableNpcs.length <= 1) { if (profile.playableNpcs.length <= 1) {
window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。'); setConfirmState({ kind: 'minimum-playable' });
return; return;
} }
if (!window.confirm(`确认删除可扮演角色「${name}」吗?`)) return; setConfirmState({ kind: 'delete-playable', id, name });
onProfileChange({
...profile,
playableNpcs: profile.playableNpcs.filter((role) => role.id !== id),
});
}; };
const startBulkDelete = (tab: BulkDeleteTab) => { const startBulkDelete = (tab: BulkDeleteTab) => {
@@ -879,21 +909,68 @@ export function CustomWorldEntityCatalog({
} }
const label = bulkDeleteTab === 'story' ? '场景角色' : '场景'; const label = bulkDeleteTab === 'story' ? '场景角色' : '场景';
const confirmed = window.confirm( setConfirmState({
`确认批量删除 ${selectedBulkIds.length}${label}吗?`, kind: 'bulk-delete',
); tab: bulkDeleteTab,
if (!confirmed) { ids: selectedBulkIds,
label,
});
};
const closeConfirmDialog = () => {
setConfirmState(null);
};
const executeConfirmAction = () => {
if (!confirmState) {
return; return;
} }
if (bulkDeleteTab === 'story') { if (confirmState.kind === 'minimum-playable') {
onDeleteStoryNpcs?.(selectedBulkIds); closeConfirmDialog();
return;
}
if (confirmState.kind === 'delete-playable') {
onProfileChange({
...profile,
playableNpcs: profile.playableNpcs.filter(
(role) => role.id !== confirmState.id,
),
});
closeConfirmDialog();
return;
}
if (confirmState.tab === 'story') {
onDeleteStoryNpcs?.(confirmState.ids);
} else { } else {
onDeleteLandmarks?.(selectedBulkIds); onDeleteLandmarks?.(confirmState.ids);
} }
cancelBulkDelete(); cancelBulkDelete();
closeConfirmDialog();
}; };
const confirmDialogConfig = (() => {
if (!confirmState || confirmState.kind === 'minimum-playable') {
return null;
}
if (confirmState.kind === 'delete-playable') {
return {
title: '删除角色',
confirmLabel: '确认删除',
body: `确认删除可扮演角色「${confirmState.name}」吗?`,
};
}
return {
title: '批量删除',
confirmLabel: '确认删除',
body: `确认批量删除 ${confirmState.ids.length}${confirmState.label}吗?`,
};
})();
return ( return (
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
@@ -914,22 +991,28 @@ export function CustomWorldEntityCatalog({
</div> </div>
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm xl:rounded-[1.75rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/70 xl:px-4 xl:py-3 xl:shadow-[0_16px_48px_rgba(112,57,30,0.08)]"> <div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm xl:rounded-[1.75rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/70 xl:px-4 xl:py-3 xl:shadow-[0_16px_48px_rgba(112,57,30,0.08)]">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0"> <PlatformSegmentedTabs
{RESULT_TABS.map((tab) => ( items={resultTabItems}
<div key={tab.id}> activeId={activeTab}
<button onChange={onActiveTabChange}
type="button" layout="scroll"
onClick={() => onActiveTabChange(tab.id)} gap="md"
className={`platform-tab px-3 py-2 text-left text-sm xl:min-w-[5.25rem] xl:px-4 xl:py-2 ${activeTab === tab.id ? 'platform-tab--active' : ''}`} frame="bare"
> surface="transparent"
<div className="font-semibold">{tab.label}</div> size="sm"
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]"> tone="neutral"
{counts[tab.id]} semantics="tabs"
</div> ariaLabel="世界实体目录"
</button> className="pb-1 xl:pb-0"
</div> itemClassName={(_, active) =>
))} [
</div> 'platform-tab shrink-0 !min-h-0 !rounded-full !px-3 !py-2 xl:min-w-[5.25rem] xl:!px-4 xl:!py-2',
active ? 'platform-tab--active' : null,
]
.filter(Boolean)
.join(' ')
}
/>
{activeTab !== 'world' ? ( {activeTab !== 'world' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center xl:gap-3"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center xl:gap-3">
@@ -943,9 +1026,9 @@ export function CustomWorldEntityCatalog({
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{isBulkDeleteMode ? ( {isBulkDeleteMode ? (
<> <>
<div className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]"> <PlatformPillBadge tone="neutral" size="xs">
{selectedBulkIds.length} {selectedBulkIds.length}
</div> </PlatformPillBadge>
<SmallButton onClick={cancelBulkDelete}></SmallButton> <SmallButton onClick={cancelBulkDelete}></SmallButton>
<SmallButton onClick={confirmBulkDelete} tone="rose"> <SmallButton onClick={confirmBulkDelete} tone="rose">
@@ -983,26 +1066,14 @@ export function CustomWorldEntityCatalog({
{activeTab === 'world' ? ( {activeTab === 'world' ? (
<div className="space-y-3 xl:grid xl:grid-cols-[minmax(18rem,0.82fr)_minmax(0,1fr)_minmax(24rem,1.08fr)] xl:items-start xl:gap-3 xl:space-y-0 2xl:gap-4"> <div className="space-y-3 xl:grid xl:grid-cols-[minmax(18rem,0.82fr)_minmax(0,1fr)_minmax(24rem,1.08fr)] xl:items-start xl:gap-3 xl:space-y-0 2xl:gap-4">
<Section title="档案规模"> <Section title="档案规模">
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300"> <PlatformStatGrid
<div className="platform-subpanel rounded-xl px-2 py-3"> items={worldStatItems}
<div className="text-xl font-black text-white"> columns="three"
{profile.playableNpcs.length} density="compact"
</div> surface="plain"
<div></div> itemClassName="platform-subpanel rounded-xl py-3"
</div> className="text-[11px] text-zinc-300"
<div className="platform-subpanel rounded-xl px-2 py-3"> />
<div className="text-xl font-black text-white">
{profile.storyNpcs.length}
</div>
<div></div>
</div>
<div className="platform-subpanel rounded-xl px-2 py-3">
<div className="text-xl font-black text-white">
{profile.landmarks.length + 1}
</div>
<div></div>
</div>
</div>
</Section> </Section>
<Section title="开局 CG"> <Section title="开局 CG">
@@ -1038,12 +1109,22 @@ export function CustomWorldEntityCatalog({
> >
<div className="space-y-3 text-sm leading-7 text-zinc-300"> <div className="space-y-3 text-sm leading-7 text-zinc-300">
<p>{profile.summary}</p> <p>{profile.summary}</p>
<div className="platform-banner platform-banner--warning rounded-2xl px-3 py-3"> <PlatformStatusMessage
tone="warning"
surface="platform"
size="sm"
className="rounded-2xl py-3"
>
线{profile.playerGoal} 线{profile.playerGoal}
</div> </PlatformStatusMessage>
<div className="platform-subpanel rounded-2xl px-3 py-3"> <PlatformSubpanel
as="div"
radius="md"
padding="sm"
className="rounded-2xl px-3 py-3 text-zinc-300"
>
{profile.tone} {profile.tone}
</div> </PlatformSubpanel>
</div> </div>
</Section> </Section>
@@ -1069,53 +1150,59 @@ export function CustomWorldEntityCatalog({
} }
> >
<div className="space-y-3"> <div className="space-y-3">
<div className="platform-subpanel rounded-2xl px-4 py-4"> <PlatformSubpanel
<div className="flex flex-wrap items-end justify-between gap-2"> as="div"
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500"> title="角色维度"
radius="md"
</div> padding="md"
</div> bodyClassName="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6"
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6"> className="rounded-2xl px-4 py-4"
{attributeSlots.map((slot) => ( >
<div {attributeSlots.map((slot) => (
key={slot.slotId} <PlatformSubpanel
className="rounded-xl border border-white/10 bg-black/15 px-3 py-3" key={slot.slotId}
> as="div"
<div className="text-sm font-semibold text-white"> surface="dark"
{slot.name} radius="xs"
</div> padding="sm"
className="bg-black/15"
>
<div className="text-sm font-semibold text-white">
{slot.name}
</div> </div>
))} </PlatformSubpanel>
</div> ))}
</div> </PlatformSubpanel>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{structuredFoundationEntries.map((entry) => ( {structuredFoundationEntries.map((entry) => (
<div <PlatformSubpanel
key={entry.id} key={entry.id}
className="platform-subpanel rounded-2xl px-4 py-4" as="div"
title={entry.label}
radius="md"
padding="md"
className="rounded-2xl px-4 py-4"
bodyClassName={
entry.value ? 'mt-3 flex flex-wrap gap-2' : ''
}
> >
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label}
</div>
{entry.value ? ( {entry.value ? (
<div className="mt-3 flex flex-wrap gap-2"> parseFoundationTagText(entry.value).map((tag, index) => (
{parseFoundationTagText(entry.value).map( <PlatformPillBadge
(tag, index) => ( key={`${entry.id}-${index}-${tag}`}
<span tone="darkSoft"
key={`${entry.id}-${index}-${tag}`} size="sm"
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100" className="leading-5"
> >
{tag} {tag}
</span> </PlatformPillBadge>
), ))
)}
</div>
) : ( ) : (
<div className="mt-2 text-sm leading-7 text-zinc-100"> <div className="mt-2 text-sm leading-7 text-zinc-100">
</div> </div>
)} )}
</div> </PlatformSubpanel>
))} ))}
</div> </div>
</div> </div>
@@ -1198,25 +1285,26 @@ export function CustomWorldEntityCatalog({
/> />
<div className="flex flex-wrap items-center gap-2 px-1"> <div className="flex flex-wrap items-center gap-2 px-1">
{lockedCharacterNames.has(role.name.trim()) ? ( {lockedCharacterNames.has(role.name.trim()) ? (
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]"> <PlatformPillBadge tone="warning" size="xxs">
</span> </PlatformPillBadge>
) : null} ) : null}
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]"> <PlatformPillBadge tone="neutral" size="xxs">
{role.initialAffinity} {role.initialAffinity}
</span> </PlatformPillBadge>
{role.generatedVisualAssetId ? ( {role.generatedVisualAssetId ? (
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]"> <PlatformPillBadge tone="success" size="xxs">
</span> </PlatformPillBadge>
) : null} ) : null}
{role.tags.slice(0, 2).map((tag) => ( {role.tags.slice(0, 2).map((tag) => (
<span <PlatformPillBadge
key={`${role.id}-${tag}`} key={`${role.id}-${tag}`}
className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]" tone="neutral"
size="xxs"
> >
{tag} {tag}
</span> </PlatformPillBadge>
))} ))}
{!readOnly ? ( {!readOnly ? (
<div className="ml-auto"> <div className="ml-auto">
@@ -1344,11 +1432,11 @@ export function CustomWorldEntityCatalog({
}) })
} }
media={ media={
<ImageFrame <PlatformMediaFrame
src={scene.imageSrc} src={scene.imageSrc}
alt={scene.name} alt={scene.name}
fallbackLabel={scene.name.slice(0, 4) || '场景'} fallbackLabel={scene.name.slice(0, 4) || '场景'}
tone="landscape" aspect="landscape"
/> />
} }
actions={ actions={
@@ -1363,6 +1451,26 @@ export function CustomWorldEntityCatalog({
)} )}
</div> </div>
) : null} ) : null}
{confirmDialogConfig ? (
<PlatformDangerConfirmDialog
open
title={confirmDialogConfig.title}
onClose={closeConfirmDialog}
onConfirm={executeConfirmAction}
confirmLabel={confirmDialogConfig.confirmLabel}
>
{confirmDialogConfig.body}
</PlatformDangerConfirmDialog>
) : null}
{confirmState?.kind === 'minimum-playable' ? (
<PlatformAcknowledgeStatusDialog
status="error"
title="无法删除"
description="至少保留一个可扮演角色,才能正常进入自定义世界。"
onClose={closeConfirmDialog}
closeOnBackdrop={false}
/>
) : null}
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import { CustomWorldGenerationView } from './CustomWorldGenerationView'; import { CustomWorldGenerationView } from './CustomWorldGenerationView';
@@ -100,26 +101,36 @@ describe('CustomWorldGenerationView', () => {
'video[data-testid="generation-page-background-video"] source[type="video/mp4"]', 'video[data-testid="generation-page-background-video"] source[type="video/mp4"]',
), ),
).toBeTruthy(); ).toBeTruthy();
expect( expect(screen.getByRole('button', { name: '返回创作中心' })).toBeTruthy();
screen.getByRole('button', { name: '返回创作中心' }),
).toBeTruthy();
expect( expect(
screen.getByRole('button', { name: '返回创作中心' }).className, screen.getByRole('button', { name: '返回创作中心' }).className,
).toContain('text-xs'); ).toContain('text-xs');
expect(
screen.getByRole('button', { name: '返回创作中心' }).className,
).toContain('bg-transparent');
expect(
screen.getByRole('button', { name: '返回创作中心' }).className,
).toContain('gap-2');
expect(screen.getByText('世界建设中')).toBeTruthy(); expect(screen.getByText('世界建设中')).toBeTruthy();
expect(screen.getByText('世界建设中').className).toContain('text-xs'); expect(screen.getByText('世界建设中').className).toContain('text-xs');
expect(screen.getByTestId('generation-hero-wait-card').className).toContain( expect(screen.getByText('世界建设中').className).toContain(
'text-center', 'border-[var(--platform-warm-border)]',
); );
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain( expect(screen.getByText('世界建设中').className).toContain(
'text-center', 'bg-[var(--platform-warm-bg)]',
);
expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
'bg-white/58',
);
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
'bg-white/58',
); );
expect(
screen.getByTestId('generation-hero-wait-card').className,
).toContain('text-center');
expect(
screen.getByTestId('generation-hero-elapsed-card').className,
).toContain('text-center');
expect(
screen.getByTestId('generation-hero-wait-card').className,
).toContain('bg-white/58');
expect(
screen.getByTestId('generation-hero-elapsed-card').className,
).toContain('bg-white/58');
expect( expect(
screen.getByTestId('generation-hero-wait-card').parentElement screen.getByTestId('generation-hero-wait-card').parentElement
?.className, ?.className,
@@ -141,31 +152,25 @@ describe('CustomWorldGenerationView', () => {
expect(screen.queryByText('预计还需 1 分 15 秒')).toBeNull(); expect(screen.queryByText('预计还需 1 分 15 秒')).toBeNull();
expect(screen.queryByText('已耗时 2 分 5 秒')).toBeNull(); expect(screen.queryByText('已耗时 2 分 5 秒')).toBeNull();
expect(screen.queryByText('计时')).toBeNull(); expect(screen.queryByText('计时')).toBeNull();
expect(screen.getByTestId('generation-hero-progress-content').className).toContain( expect(
'justify-start', screen.getByTestId('generation-hero-progress-content').className,
); ).toContain('justify-start');
expect(screen.getByTestId('generation-hero-progress-content').className).toContain( expect(
'z-30', screen.getByTestId('generation-hero-progress-content').className,
); ).toContain('z-30');
expect(screen.getByTestId('generation-hero-progress-content').className).toContain( expect(
'pt-[2%]', screen.getByTestId('generation-hero-progress-content').className,
); ).toContain('pt-[2%]');
expect(screen.getByText('总进度').className).toContain('text-[9px]'); expect(screen.getByText('总进度').className).toContain('text-[9px]');
expect(screen.getByText('42%').className).toContain('text-[1.15rem]'); expect(screen.getByText('42%').className).toContain('text-[1.15rem]');
expect( expect(
screen screen.getByRole('progressbar', { name: progressTitle }).className,
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('w-[min(400px,calc(100%_-_0.75rem))]'); ).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect( expect(
screen screen.getByRole('progressbar', { name: progressTitle }).className,
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('max-w-full'); ).toContain('max-w-full');
expect( expect(
screen screen.getByRole('progressbar', { name: progressTitle }).className,
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('aspect-square'); ).toContain('aspect-square');
expect( expect(
screen screen
@@ -195,9 +200,11 @@ describe('CustomWorldGenerationView', () => {
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe( expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
'svg', 'svg',
); );
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain( expect(
'z-0', screen
); .getByTestId('generation-hero-progress-ring')
.getAttribute('class'),
).toContain('z-0');
expect( expect(
screen screen
.getByTestId('generation-hero-progress-ring') .getByTestId('generation-hero-progress-ring')
@@ -250,8 +257,8 @@ describe('CustomWorldGenerationView', () => {
?.className, ?.className,
).toContain('mt-5'); ).toContain('mt-5');
expect( expect(
screen.getByRole('progressbar', { name: '编译草稿 进度' }), screen.getByRole('progressbar', { name: '编译草稿 进度' }).className,
).toBeTruthy(); ).toContain('platform-progress-track');
expect(screen.queryByText('收集设定')).toBeNull(); expect(screen.queryByText('收集设定')).toBeNull();
expect(screen.queryByText('写回结果')).toBeNull(); expect(screen.queryByText('写回结果')).toBeNull();
expect(screen.queryByText('当前批次')).toBeNull(); expect(screen.queryByText('当前批次')).toBeNull();
@@ -289,4 +296,29 @@ describe('CustomWorldGenerationView', () => {
expect(screen.queryByText('大鱼吃小鱼题材')).toBeNull(); expect(screen.queryByText('大鱼吃小鱼题材')).toBeNull();
expect(screen.getByTestId('generation-page-background-video')).toBeTruthy(); expect(screen.getByTestId('generation-page-background-video')).toBeTruthy();
}); });
test('keeps the shared generation back button click behavior', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
render(
<CustomWorldGenerationView
settingText="大鱼吃小鱼题材"
progress={createProgress()}
isGenerating
error={null}
onBack={onBack}
onEditSetting={() => {}}
onRetry={() => {}}
backLabel="返回创作中心"
settingDescription={null}
settingActionLabel={null}
progressTitle="大鱼吃小鱼草稿生成进度"
/>,
);
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
expect(onBack).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -1,9 +1,10 @@
import { ArrowLeft } from 'lucide-react';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress'; import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { import {
GenerationCurrentStepCard, GenerationCurrentStepCard,
GenerationHeaderBackButton,
GenerationPageBackdrop, GenerationPageBackdrop,
GenerationProgressHero, GenerationProgressHero,
} from './GenerationProgressHero'; } from './GenerationProgressHero';
@@ -117,7 +118,8 @@ export function CustomWorldGenerationView({
const currentStepProgress = currentStep const currentStepProgress = currentStep
? getStepProgressPercentage(currentStep) ? getStepProgressPercentage(currentStep)
: progressValue; : progressValue;
const currentStepLabel = currentStep?.label ?? progress?.phaseLabel ?? '准备生成'; const currentStepLabel =
currentStep?.label ?? progress?.phaseLabel ?? '准备生成';
const currentStepStatusLabel = currentStep const currentStepStatusLabel = currentStep
? getStepStatusLabel(currentStep) ? getStepStatusLabel(currentStep)
: isGenerating : isGenerating
@@ -131,22 +133,17 @@ export function CustomWorldGenerationView({
progress != null ? formatDuration(progress.elapsedMs) : '启动中'; progress != null ? formatDuration(progress.elapsedMs) : '启动中';
return ( return (
<div <div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5">
className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5"
>
<GenerationPageBackdrop /> <GenerationPageBackdrop />
<div className="relative z-30 mb-4 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-5"> <div className="relative z-30 mb-4 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-5">
<button <GenerationHeaderBackButton label={backLabel} onClick={onBack} />
type="button" <PlatformPillBadge
onClick={onBack} tone="warning"
className="inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm" size="xs"
className="px-3 py-1.5 tracking-[0.08em] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs"
> >
<ArrowLeft className="h-5 w-5 shrink-0" strokeWidth={2.6} />
<span className="break-keep">{backLabel}</span>
</button>
<div className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
{isGenerating ? activeBadgeLabel : idleBadgeLabel} {isGenerating ? activeBadgeLabel : idleBadgeLabel}
</div> </PlatformPillBadge>
</div> </div>
<div <div
@@ -172,21 +169,22 @@ export function CustomWorldGenerationView({
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end"> <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? ( {!isGenerating ? (
<button <PlatformActionButton
type="button"
onClick={onRetry} onClick={onRetry}
className="platform-button platform-button--primary w-full sm:w-auto" fullWidth
className="sm:w-auto"
> >
{retryLabel} {retryLabel}
</button> </PlatformActionButton>
) : onInterrupt ? ( ) : onInterrupt ? (
<button <PlatformActionButton
type="button" tone="danger"
shape="pill"
onClick={onInterrupt} onClick={onInterrupt}
className="rounded-full border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-4 py-2 text-sm text-[var(--platform-button-danger-text)] transition-colors hover:text-[var(--platform-text-strong)]" className="transition-colors hover:text-[var(--platform-text-strong)]"
> >
{interruptLabel} {interruptLabel}
</button> </PlatformActionButton>
) : null} ) : null}
</div> </div>
</section> </section>

View File

@@ -28,6 +28,7 @@ import {
type CustomWorldNpcVisual, type CustomWorldNpcVisual,
type CustomWorldProfile, type CustomWorldProfile,
} from '../types'; } from '../types';
import { PlatformActionButton } from './common/PlatformActionButton';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults'; import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import { HostileNpcAnimator } from './HostileNpcAnimator'; import { HostileNpcAnimator } from './HostileNpcAnimator';
import { MedievalNpcAnimator } from './MedievalNpcAnimator'; import { MedievalNpcAnimator } from './MedievalNpcAnimator';
@@ -282,9 +283,18 @@ function ActionButton({
onClick: () => void; onClick: () => void;
tone?: 'default' | 'sky'; tone?: 'default' | 'sky';
}) { }) {
const buttonTone = tone === 'sky' ? 'primary' : 'ghost';
const visualClassName =
tone === 'sky'
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:bg-sky-500/12 hover:text-white'
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:bg-black/20 hover:text-white';
return ( return (
<button <PlatformActionButton
type="button" surface="editorDark"
tone={buttonTone}
size="xs"
shape="pill"
onPointerDown={(event) => { onPointerDown={(event) => {
event.stopPropagation(); event.stopPropagation();
}} }}
@@ -292,14 +302,10 @@ function ActionButton({
event.stopPropagation(); event.stopPropagation();
}} }}
onClick={onClick} onClick={onClick}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${ className={`text-sm font-semibold ${visualClassName}`}
tone === 'sky'
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white'
}`}
> >
{label} {label}
</button> </PlatformActionButton>
); );
} }

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useState } from 'react'; import { useState } from 'react';
import { expect, test, vi } from 'vitest'; import { expect, test, vi } from 'vitest';
@@ -204,8 +204,7 @@ const baseProfile = {
'玩家以返乡守灯人继承者身份切入,首夜就撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。', '玩家以返乡守灯人继承者身份切入,首夜就撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict: coreConflict:
'守潮盟与沉钟会争夺航路解释权,有人借假航灯持续清洗整片群岛的旧证据,玩家回港当夜就被卷进禁航区封锁。', '守潮盟与沉钟会争夺航路解释权,有人借假航灯持续清洗整片群岛的旧证据,玩家回港当夜就被卷进禁航区封锁。',
keyRelationships: keyRelationships: '玩家与沈砺旧友互疑,沈砺掌握沉船夜的关键视角。',
'玩家与沈砺旧友互疑,沈砺掌握沉船夜的关键视角。',
hiddenLines: hiddenLines:
'沉钟异动和旧案灭口是同一条线,表面看像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。', '沉钟异动和旧案灭口是同一条线,表面看像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements: iconicElements:
@@ -324,7 +323,8 @@ function ResultViewRehydratingHarness() {
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => { test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null; let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null =
null;
mockedRpgCreationAssetClient.generatePlayableNpc.mockImplementation( mockedRpgCreationAssetClient.generatePlayableNpc.mockImplementation(
() => () =>
new Promise<CustomWorldPlayableNpc>((resolve) => { new Promise<CustomWorldPlayableNpc>((resolve) => {
@@ -385,7 +385,8 @@ test('world tab generates opening cg only after manual click and writes it back
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({ mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
id: 'opening-cg-1', id: 'opening-cg-1',
status: 'ready', status: 'ready',
storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png', storyboardImageSrc:
'/generated-custom-world-scenes/world/opening/storyboard.png',
storyboardAssetId: 'storyboard-1', storyboardAssetId: 'storyboard-1',
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4', videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
videoAssetId: 'video-1', videoAssetId: 'video-1',
@@ -407,9 +408,9 @@ test('world tab generates opening cg only after manual click and writes it back
await user.click(screen.getByRole('button', { name: '生成' })); await user.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => { await waitFor(() => {
expect(mockedRpgCreationAssetClient.generateOpeningCg).toHaveBeenCalledTimes( expect(
1, mockedRpgCreationAssetClient.generateOpeningCg,
); ).toHaveBeenCalledTimes(1);
}); });
await waitFor(() => { await waitFor(() => {
expect( expect(
@@ -425,7 +426,8 @@ test('world tab keeps opening cg visible after parent rehydrates normalized prof
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({ mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
id: 'opening-cg-1', id: 'opening-cg-1',
status: 'ready', status: 'ready',
storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png', storyboardImageSrc:
'/generated-custom-world-scenes/world/opening/storyboard.png',
storyboardAssetId: 'storyboard-1', storyboardAssetId: 'storyboard-1',
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4', videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
videoAssetId: 'video-1', videoAssetId: 'video-1',
@@ -521,14 +523,18 @@ test('landmark tab previews every generated act image while keeping chapter deta
); );
expect( expect(
(screen.getByRole('img', { (
name: '沉钟栈桥-潮声逼近', screen.getByRole('img', {
}) as HTMLImageElement).getAttribute('src'), name: '沉钟栈桥-潮声逼近',
}) as HTMLImageElement
).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-1.png'); ).toBe('/generated-custom-world-scenes/scene-act-1.png');
expect( expect(
(screen.getByRole('img', { (
name: '沉钟栈桥-钟楼回响', screen.getByRole('img', {
}) as HTMLImageElement).getAttribute('src'), name: '沉钟栈桥-钟楼回响',
}) as HTMLImageElement
).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-2.png'); ).toBe('/generated-custom-world-scenes/scene-act-2.png');
}); });
@@ -580,9 +586,7 @@ test('agent result view shows error when entity generation returns no new profil
await user.click(screen.getByRole('button', { name: //u })); await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '新增场景角色' })); await user.click(screen.getByRole('button', { name: '新增场景角色' }));
expect( expect(await screen.findByText(//u)).toBeTruthy();
await screen.findByText(//u),
).toBeTruthy();
}); });
test('agent result view keeps publish-enter action clickable and hides sticky publish hints', () => { test('agent result view keeps publish-enter action clickable and hides sticky publish hints', () => {
@@ -652,11 +656,9 @@ test('agent result view opens publish blocker dialog only when user clicks publi
await user.click(screen.getByRole('button', { name: '发布并进入世界' })); await user.click(screen.getByRole('button', { name: '发布并进入世界' }));
expect(screen.getByRole('dialog', { name: '发布作品' })).toBeTruthy(); expect(screen.getByRole('dialog', { name: '发布作品' })).toBeTruthy();
expect(screen.getByText('发布检查')).toBeTruthy(); expect(screen.getByText('发布检查').className).toContain('tracking-[0.18em]');
expect(screen.getByText('封面设置')).toBeTruthy(); expect(screen.getByText('封面设置').className).toContain('tracking-[0.18em]');
expect( expect(screen.getByText(//u)).toBeTruthy();
screen.getByText(//u),
).toBeTruthy();
}); });
test('agent result view keeps publish-enter action enabled when publish gate is clear', () => { test('agent result view keeps publish-enter action enabled when publish gate is clear', () => {
@@ -693,3 +695,35 @@ test('agent result view keeps publish-enter action enabled when publish gate is
}); });
expect((actionButton as HTMLButtonElement).disabled).toBe(false); expect((actionButton as HTMLButtonElement).disabled).toBe(false);
}); });
test('result view confirms full regeneration with unified dialog', async () => {
const user = userEvent.setup();
const handleRegenerate = vi.fn();
render(
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
onRegenerate={handleRegenerate}
/>,
);
await user.click(screen.getByRole('button', { name: '重新生成' }));
const dialog = screen.getByRole('dialog', { name: '重新生成' });
expect(screen.getByText(//u)).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '取消' }));
expect(handleRegenerate).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '重新生成' }));
await user.click(screen.getByRole('button', { name: '确认重新生成' }));
expect(handleRegenerate).toHaveBeenCalledTimes(1);
});

View File

@@ -1,8 +1,9 @@
import { Clock3, Hourglass } from 'lucide-react'; import { ArrowLeft, Clock3, Hourglass } from 'lucide-react';
import { motion } from 'motion/react';
import { useEffect, useId, useRef } from 'react'; import { useEffect, useId, useRef } from 'react';
import generationHeroVideo from '../../media/create_bg_video.mp4'; import generationHeroVideo from '../../media/create_bg_video.mp4';
import { PlatformIconButton } from './common/PlatformIconButton';
import { PlatformProgressBar } from './common/PlatformProgressBar';
const GENERATION_PROGRESS_RING_GAP_DEGREES = 90; const GENERATION_PROGRESS_RING_GAP_DEGREES = 90;
const GENERATION_PROGRESS_RING_BOTTOM_DEGREES = 90; const GENERATION_PROGRESS_RING_BOTTOM_DEGREES = 90;
@@ -35,6 +36,14 @@ type GenerationCurrentStepCardProps = {
progressValue: number; progressValue: number;
}; };
type GenerationHeaderBackButtonProps = {
label: string;
onClick: () => void;
disabled?: boolean;
disabledOpacity?: number;
className?: string;
};
function clampGenerationProgress(value: number) { function clampGenerationProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value))); return Math.max(0, Math.min(100, Math.round(value)));
} }
@@ -51,6 +60,34 @@ function buildGenerationRingMetrics(progressValue: number) {
}; };
} }
export function GenerationHeaderBackButton({
label,
onClick,
disabled = false,
disabledOpacity,
className,
}: GenerationHeaderBackButtonProps) {
return (
<PlatformIconButton
label={label}
title={label}
variant="darkMini"
onClick={onClick}
disabled={disabled}
className={[
'gap-2 rounded-full !border-transparent !bg-transparent px-0 py-2 text-xs font-black !text-[#171411] shadow-none hover:!bg-transparent hover:!text-[#171411] sm:text-sm',
className,
]
.filter(Boolean)
.join(' ')}
style={disabled && disabledOpacity != null ? { opacity: disabledOpacity } : undefined}
icon={<ArrowLeft className="h-5 w-5 shrink-0" strokeWidth={2.6} />}
>
<span className="break-keep">{label}</span>
</PlatformIconButton>
);
}
export function GenerationPageBackdrop() { export function GenerationPageBackdrop() {
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -64,8 +101,7 @@ export function GenerationPageBackdrop() {
video.muted = true; video.muted = true;
video.volume = 0; video.volume = 0;
const isJsdom = const isJsdom = window.navigator.userAgent.toLowerCase().includes('jsdom');
window.navigator.userAgent.toLowerCase().includes('jsdom');
const tryPlay = () => { const tryPlay = () => {
if (isJsdom) { if (isJsdom) {
return; return;
@@ -285,20 +321,14 @@ export function GenerationCurrentStepCard({
) : null} ) : null}
</div> </div>
</div> </div>
<div <PlatformProgressBar
className="mt-4 h-2.5 overflow-hidden rounded-full bg-[#f5eee8]" value={safeProgress}
role="progressbar" size="sm"
aria-label={`${label} 进度`} ariaLabel={`${label} 进度`}
aria-valuemin={0} className="mt-4 bg-[#f5eee8]"
aria-valuemax={100} fillClassName="bg-[linear-gradient(90deg,#ef7a1f_0%,#e25f18_64%,#f0b07e_100%)]"
aria-valuenow={safeProgress} fillStyle={{ transitionDuration: '450ms' }}
> />
<motion.div
className="h-full rounded-full bg-[linear-gradient(90deg,#ef7a1f_0%,#e25f18_64%,#f0b07e_100%)]"
animate={{ width: `${safeProgress}%` }}
transition={{ duration: 0.45, ease: 'easeOut' }}
/>
</div>
</div> </div>
); );
} }

View File

@@ -9,6 +9,8 @@ import {
getNineSliceStyle, getNineSliceStyle,
UI_CHROME, UI_CHROME,
} from '../uiAssets'; } from '../uiAssets';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
import { PlatformQuantityBadge } from './common/PlatformQuantityBadge';
import { PixelCloseButton } from './PixelCloseButton'; import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon'; import { PixelIcon } from './PixelIcon';
@@ -130,9 +132,7 @@ export function InventoryItemGrid({
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11" className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
/> />
</div> </div>
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white"> <PlatformQuantityBadge>{item.quantity}</PlatformQuantityBadge>
{item.quantity}
</div>
</button> </button>
); );
})} })}
@@ -185,7 +185,11 @@ export function InventoryItemDetailModal({
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5"> <div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
<PixelCloseButton onClick={onClose} label="关闭物品详情" className="top-4 sm:top-5" /> <PixelCloseButton
onClick={onClose}
label="关闭物品详情"
className="top-4 sm:top-5"
/>
<div <div
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`} className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
@@ -234,9 +238,9 @@ export function InventoryItemDetailModal({
</div> </div>
{footer != null ? ( {footer != null ? (
<div className="border-t border-white/10 px-4 py-3 sm:px-5"> <PlatformDarkModalFooter layout="content">
{footer} {footer}
</div> </PlatformDarkModalFooter>
) : null} ) : null}
</motion.div> </motion.div>
</motion.div> </motion.div>

View File

@@ -0,0 +1,165 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { RuntimeStoryForgeRecipeView } from '../../packages/shared/src/contracts/rpgRuntimeStoryState';
import { type Character, type InventoryItem, WorldType } from '../types';
import { InventoryPanel } from './InventoryPanel';
const inventoryItem: InventoryItem = {
id: 'training-token',
category: '材料',
name: '练习石',
quantity: 1,
rarity: 'common',
tags: [],
};
const documentItem: InventoryItem = {
id: 'thread-note',
category: '文书',
name: '潮汐证词',
quantity: 1,
rarity: 'common',
tags: ['document'],
description: '记录着潮汐线索。',
};
const forgeRecipe: RuntimeStoryForgeRecipeView = {
id: 'forge-tide-amulet',
name: '潮汐护符',
kind: 'forge',
description: '用于测试工坊需求状态。',
resultLabel: '潮汐护符',
currencyCost: 5,
currencyText: '5 贝币',
requirements: [
{
id: 'iron',
label: '铁矿',
quantity: 2,
owned: 2,
},
{
id: 'wood',
label: '木材',
quantity: 1,
owned: 0,
},
],
canCraft: false,
disabledReason: '材料不足',
action: {
functionId: 'craft',
actionText: '锻造',
enabled: false,
reason: '材料不足',
},
};
test('背包工坊材料需求状态复用暗色平台胶囊标签', () => {
render(
<InventoryPanel
playerCharacter={{} as Character}
worldType={WorldType.CUSTOM}
playerInventory={[inventoryItem]}
playerCurrency={0}
playerHp={10}
playerMaxHp={10}
playerMana={5}
playerMaxMana={5}
inBattle={false}
forgeRecipes={[forgeRecipe]}
onUseItem={vi.fn(async () => false)}
onEquipItem={vi.fn(async () => false)}
onCraftRecipe={vi.fn(async () => false)}
onDismantleItem={vi.fn(async () => false)}
onReforgeItem={vi.fn(async () => false)}
/>,
);
const metRequirement = screen.getByText('铁矿 2/2');
const missingRequirement = screen.getByText('木材 0/1');
const forgePanel = screen.getByText('工坊').closest('section');
const recipePanel = screen.getByText('潮汐护符').closest('section');
const forgeButton = screen.getByRole('button', { name: '锻造' });
expect(metRequirement.className).toContain('rounded-full');
expect(metRequirement.className).toContain('font-black');
expect(metRequirement.className).toContain('bg-emerald-500/10');
expect(missingRequirement.className).toContain('rounded-full');
expect(missingRequirement.className).toContain('font-black');
expect(missingRequirement.className).toContain('bg-black/20');
expect(missingRequirement.className).toContain('text-zinc-400');
expect(forgePanel?.className).toContain('border-white/10');
expect(forgePanel?.className).toContain('bg-black/25');
expect(recipePanel?.className).toContain('border-white/10');
expect(recipePanel?.className).toContain('bg-black/25');
expect(forgeButton.className).toContain('platform-action-button--editor-dark');
expect(forgeButton.className).toContain('rounded-lg');
expect(forgeButton.className).toContain('bg-emerald-400');
expect(forgeButton.className).toContain('disabled:bg-black/20');
});
test('背包文书和故事档案区块复用暗色 PlatformSubpanel chrome', () => {
render(
<InventoryPanel
playerCharacter={{} as Character}
worldType={WorldType.CUSTOM}
playerInventory={[documentItem]}
playerCurrency={0}
playerHp={10}
playerMaxHp={10}
playerMana={5}
playerMaxMana={5}
inBattle={false}
forgeRecipes={[]}
narrativeQaReport={{
generatedAt: '2026-06-10T00:00:00.000Z',
issues: [],
summary: '叙事链路稳定。',
}}
narrativeCodex={[
{
id: 'codex-tide',
title: '潮汐档案',
entries: [
{
id: 'entry-tide',
title: '旧港线索',
summary: '证词指向旧港。',
category: 'document',
relatedIds: [],
},
],
},
]}
onUseItem={vi.fn(async () => false)}
onEquipItem={vi.fn(async () => false)}
onCraftRecipe={vi.fn(async () => false)}
onDismantleItem={vi.fn(async () => false)}
onReforgeItem={vi.fn(async () => false)}
/>,
);
const documentPanel = screen.getByText('文书与证据').closest('section');
const documentButton = screen.getByRole('button', {
name: /潮汐证词/,
});
const storyPanel = screen.getByText('故事档案').closest('section');
const qaMessage = screen.getByText('QA叙事链路稳定。');
const codexPanel = screen.getByText('潮汐档案').closest('section');
expect(documentPanel?.className).toContain('border-white/10');
expect(documentPanel?.className).toContain('bg-black/25');
expect(documentButton.className).toContain('border-white/10');
expect(documentButton.className).toContain('bg-black/25');
expect(storyPanel?.className).toContain('border-white/10');
expect(storyPanel?.className).toContain('bg-black/25');
expect(qaMessage.className).toContain('platform-status-message');
expect(qaMessage.className).toContain('border-amber-300/15');
expect(qaMessage.className).toContain('bg-amber-500/10');
expect(codexPanel?.className).toContain('border-white/10');
expect(codexPanel?.className).toContain('bg-black/25');
});

View File

@@ -14,6 +14,10 @@ import {
NarrativeQaReport, NarrativeQaReport,
WorldType, WorldType,
} from '../types'; } from '../types';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { import {
InventoryItemDetailModal, InventoryItemDetailModal,
InventoryItemGrid, InventoryItemGrid,
@@ -83,7 +87,10 @@ export function InventoryPanel({
[playerCharacter, playerInventory, serverInventoryItems, worldType], [playerCharacter, playerInventory, serverInventoryItems, worldType],
); );
const documentItems = useMemo( const documentItems = useMemo(
() => inventoryItems.filter((item) => item.category === '文书' || item.tags.includes('document')), () =>
inventoryItems.filter(
(item) => item.category === '文书' || item.tags.includes('document'),
),
[inventoryItems], [inventoryItems],
); );
@@ -97,43 +104,65 @@ export function InventoryPanel({
/> />
{documentItems.length > 0 && ( {documentItems.length > 0 && (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4"> <PlatformSubpanel
surface="dark"
radius="sm"
padding="md"
className="mt-4"
>
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500"> <div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{documentItems.map((item) => ( {documentItems.map((item) => (
<button <PlatformSubpanel
as="button"
key={item.id} key={item.id}
type="button"
onClick={() => setSelectedItem(item)} onClick={() => setSelectedItem(item)}
className="w-full rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-left transition hover:border-white/15" surface="dark"
radius="xs"
padding="row"
className="w-full text-left transition hover:border-white/15"
> >
<div className="text-sm font-semibold text-white">{item.name}</div> <div className="text-sm font-semibold text-white">
{item.name}
</div>
<div className="mt-1 text-xs text-zinc-400"> <div className="mt-1 text-xs text-zinc-400">
{item.description || '记录着当前线程的阶段性线索。'} {item.description || '记录着当前线程的阶段性线索。'}
</div> </div>
</button> </PlatformSubpanel>
))} ))}
</div> </div>
</div> </PlatformSubpanel>
)} )}
{(narrativeCodex.length > 0 || narrativeQaReport) && ( {(narrativeCodex.length > 0 || narrativeQaReport) && (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4"> <PlatformSubpanel
surface="dark"
radius="sm"
padding="md"
className="mt-4"
>
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500"> <div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
</div> </div>
{narrativeQaReport && ( {narrativeQaReport && (
<div className="mb-3 rounded-xl border border-amber-400/18 bg-amber-500/8 px-3 py-2 text-xs text-amber-100/85"> <PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="mb-3"
>
QA{narrativeQaReport.summary} QA{narrativeQaReport.summary}
</div> </PlatformStatusMessage>
)} )}
<div className="space-y-3"> <div className="space-y-3">
{narrativeCodex.slice(0, 3).map((section) => ( {narrativeCodex.slice(0, 3).map((section) => (
<div <PlatformSubpanel
key={section.id} key={section.id}
className="rounded-xl border border-white/8 bg-black/20 p-3" surface="dark"
radius="xs"
padding="sm"
> >
<div className="text-sm font-semibold text-white"> <div className="text-sm font-semibold text-white">
{section.title} {section.title}
@@ -147,13 +176,18 @@ export function InventoryPanel({
</div> </div>
))} ))}
</div> </div>
</div> </PlatformSubpanel>
))} ))}
</div> </div>
</div> </PlatformSubpanel>
)} )}
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4"> <PlatformSubpanel
surface="dark"
radius="sm"
padding="md"
className="mt-4"
>
<div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500"> <div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500">
<span></span> <span></span>
<span className="text-emerald-200/80"> <span className="text-emerald-200/80">
@@ -162,9 +196,11 @@ export function InventoryPanel({
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{forgeRecipes.map((recipe) => ( {forgeRecipes.map((recipe) => (
<div <PlatformSubpanel
key={recipe.id} key={recipe.id}
className="rounded-xl border border-white/8 bg-black/20 p-3" surface="dark"
radius="xs"
padding="sm"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
@@ -181,8 +217,10 @@ export function InventoryPanel({
{recipe.currencyText} {recipe.currencyText}
</div> </div>
</div> </div>
<button <PlatformActionButton
type="button" surface="editorDark"
tone="success"
size="xxs"
disabled={ disabled={
!recipe.canCraft || !recipe.canCraft ||
!recipe.action.enabled || !recipe.action.enabled ||
@@ -197,33 +235,32 @@ export function InventoryPanel({
setSelectedItem(null); setSelectedItem(null);
} }
}} }}
className={`rounded-lg border px-3 py-1.5 text-xs transition ${ className="rounded-lg disabled:border-white/8 disabled:bg-black/20 disabled:text-zinc-500 disabled:opacity-100"
recipe.canCraft && recipe.action.enabled && !inBattle
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
: 'border-white/8 bg-black/20 text-zinc-500'
}`}
> >
{forgeActionKey === recipe.id {forgeActionKey === recipe.id
? '制作中...' ? '制作中...'
: recipe.kind === 'forge' : recipe.kind === 'forge'
? '锻造' ? '锻造'
: '合成'} : '合成'}
</button> </PlatformActionButton>
</div> </div>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{recipe.requirements.map((requirement) => ( {recipe.requirements.map((requirement) => {
<span const isRequirementMet =
key={`${recipe.id}-${requirement.id}`} requirement.owned >= requirement.quantity;
className={`rounded-full border px-2 py-1 text-[10px] ${
requirement.owned >= requirement.quantity return (
? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100' <PlatformPillBadge
: 'border-white/10 bg-black/20 text-zinc-400' key={`${recipe.id}-${requirement.id}`}
}`} tone={isRequirementMet ? 'darkEmerald' : 'darkNeutral'}
> size="xxs"
{requirement.label} {requirement.owned}/ className={`px-2 ${isRequirementMet ? '' : 'text-zinc-400'}`}
{requirement.quantity} >
</span> {requirement.label} {requirement.owned}/
))} {requirement.quantity}
</PlatformPillBadge>
);
})}
</div> </div>
{(!recipe.canCraft || !recipe.action.enabled) && {(!recipe.canCraft || !recipe.action.enabled) &&
(recipe.disabledReason || recipe.action.reason) && ( (recipe.disabledReason || recipe.action.reason) && (
@@ -231,10 +268,10 @@ export function InventoryPanel({
{recipe.disabledReason ?? recipe.action.reason} {recipe.disabledReason ?? recipe.action.reason}
</div> </div>
)} )}
</div> </PlatformSubpanel>
))} ))}
</div> </div>
</div> </PlatformSubpanel>
</div> </div>
<InventoryItemDetailModal <InventoryItemDetailModal

View File

@@ -0,0 +1,137 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import type { HTMLAttributes, ReactNode } from 'react';
import { expect, test, vi } from 'vitest';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import {
getConnectedScenePresets,
getWorldCampScenePreset,
} from '../data/scenePresets';
import { WorldType } from '../types';
import { MapModal } from './MapModal';
vi.mock('motion/react', () => ({
AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}</>,
motion: {
div: ({
animate: _animate,
children,
exit: _exit,
initial: _initial,
transition: _transition,
...props
}: HTMLAttributes<HTMLDivElement> & {
animate?: unknown;
exit?: unknown;
initial?: unknown;
transition?: unknown;
}) => <div {...props}>{children}</div>,
},
}));
test('目标场景确认面板复用暗色琥珀 PlatformSubpanel 和胶囊标签', () => {
const currentScene = getWorldCampScenePreset(WorldType.WUXIA);
if (!currentScene) {
throw new Error('测试需要武侠营地场景');
}
const destination = getConnectedScenePresets(
WorldType.WUXIA,
currentScene.id,
)[0];
if (!destination) {
throw new Error('测试需要至少一个相邻场景');
}
const connection = currentScene.connections.find(
(item) => item.sceneId === destination.id,
);
const destinationLabel = connection
? getCustomWorldSceneRelativePositionLabel(connection.relativePosition)
: '前方';
render(
<MapModal
isOpen
currentScenePreset={currentScene}
worldType={WorldType.WUXIA}
onClose={vi.fn()}
onTravelToScene={vi.fn()}
/>,
);
const destinationNameNode = screen.getAllByText(destination.name)[0];
if (!destinationNameNode) {
throw new Error('测试需要展示目标场景名称');
}
const destinationButton = destinationNameNode.closest('button');
if (!destinationButton) {
throw new Error('测试需要可点击的目标场景节点');
}
const mapNodeLabelBadge =
within(destinationButton).getByText(destinationLabel);
expect(mapNodeLabelBadge.className).toContain('bg-emerald-500/10');
expect(mapNodeLabelBadge.className).toContain('rounded-full');
fireEvent.click(destinationButton);
const panel = screen.getByTestId('map-target-scene-panel');
const currentSummary = screen.getByTestId('map-current-scene-summary');
const nextSummary = screen.getByTestId('map-next-scene-summary');
const footer = screen.getByTestId('map-travel-footer');
const labelBadge = within(panel).getByText(destinationLabel);
const cancelButton = screen.getByRole('button', { name: '取消' });
const confirmButton = screen.getByRole('button', { name: '确认前往' });
expect(panel.className).toContain('border-amber-300/18');
expect(panel.className).toContain('bg-amber-500/8');
expect(panel.className).toContain('rounded-xl');
expect(panel.className).toContain('p-4');
expect(labelBadge.className).toContain('rounded-full');
expect(labelBadge.className).toContain('bg-amber-500/10');
expect(currentSummary.className).toContain('border-white/10');
expect(currentSummary.className).toContain('bg-black/25');
expect(nextSummary.className).toContain('border-white/10');
expect(nextSummary.className).toContain('bg-black/25');
expect(footer.className).toContain('platform-dark-modal-footer');
expect(footer.className).toContain('border-t');
expect(cancelButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(cancelButton.className).toContain('bg-black/20');
expect(confirmButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(confirmButton.className).toContain('bg-amber-500/20');
});
test('地图右上关闭按钮复用共享像素关闭按钮能力', () => {
const currentScene = getWorldCampScenePreset(WorldType.WUXIA);
if (!currentScene) {
throw new Error('测试需要武侠营地场景');
}
const onClose = vi.fn();
render(
<MapModal
isOpen
currentScenePreset={currentScene}
worldType={WorldType.WUXIA}
onClose={onClose}
onTravelToScene={vi.fn()}
/>,
);
const closeButton = screen.getByRole('button', { name: '关闭地图' });
fireEvent.click(closeButton);
expect(closeButton.className).toContain('absolute');
expect(closeButton.className).toContain('right-4');
expect(closeButton.getAttribute('title')).toBe('关闭地图');
expect(onClose).toHaveBeenCalledTimes(1);
});

View File

@@ -6,6 +6,10 @@ import { getConnectedScenePresets } from '../data/scenePresets';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl'; import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
import { ScenePresetInfo, WorldType } from '../types'; import { ScenePresetInfo, WorldType } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PixelCloseButton } from './PixelCloseButton'; import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon'; import { PixelIcon } from './PixelIcon';
@@ -67,9 +71,13 @@ function MudMapRoom({
style={getNineSliceStyle(UI_CHROME.mapRoomCell)} style={getNineSliceStyle(UI_CHROME.mapRoomCell)}
> >
<div className="flex min-h-[3.25rem] flex-col items-center justify-center px-3 py-2 text-center"> <div className="flex min-h-[3.25rem] flex-col items-center justify-center px-3 py-2 text-center">
<div className="rounded-full border border-emerald-300/25 bg-emerald-500/10 px-2 py-0.5 text-[9px] tracking-[0.16em] text-emerald-100/85"> <PlatformPillBadge
tone="darkEmerald"
size="xxs"
className="tracking-[0.16em] text-emerald-100/85"
>
{label} {label}
</div> </PlatformPillBadge>
<div className={`mt-1 ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}> <div className={`mt-1 ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}>
{scene.name} {scene.name}
</div> </div>
@@ -387,49 +395,73 @@ export function MapModal({
</div> </div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-5"> <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-5">
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 px-4 py-4"> <PlatformSubpanel
as="div"
data-testid="map-target-scene-panel"
surface="darkAmber"
radius="xs"
padding="md"
>
<div className="text-[10px] tracking-[0.18em] text-amber-200/75"></div> <div className="text-[10px] tracking-[0.18em] text-amber-200/75"></div>
<div className="mt-2 text-base font-semibold text-white">{pendingScene.scene.name}</div> <div className="mt-2 text-base font-semibold text-white">{pendingScene.scene.name}</div>
<div className="mt-2 rounded-full border border-amber-300/20 bg-black/20 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-50"> <PlatformPillBadge
tone="darkAmber"
size="xxs"
className="mt-2 tracking-[0.18em]"
>
{pendingScene.label} {pendingScene.label}
</div> </PlatformPillBadge>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{pendingScene.scene.description}</div> <div className="mt-2 text-sm leading-relaxed text-zinc-300">{pendingScene.scene.description}</div>
{pendingScene.summary ? ( {pendingScene.summary ? (
<div className="mt-2 text-xs leading-6 text-zinc-400"> <div className="mt-2 text-xs leading-6 text-zinc-400">
{pendingScene.summary} {pendingScene.summary}
</div> </div>
) : null} ) : null}
</div> </PlatformSubpanel>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3"> <PlatformSubpanel
as="div"
data-testid="map-current-scene-summary"
surface="dark"
radius="xs"
padding="md"
>
<div className="text-[10px] tracking-[0.18em] text-zinc-500"></div> <div className="text-[10px] tracking-[0.18em] text-zinc-500"></div>
<div className="mt-2 text-sm font-semibold text-white">{currentScenePreset.name}</div> <div className="mt-2 text-sm font-semibold text-white">{currentScenePreset.name}</div>
</div> </PlatformSubpanel>
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3"> <PlatformSubpanel
as="div"
data-testid="map-next-scene-summary"
surface="dark"
radius="xs"
padding="md"
>
<div className="text-[10px] tracking-[0.18em] text-zinc-500"></div> <div className="text-[10px] tracking-[0.18em] text-zinc-500"></div>
<div className="mt-2 text-sm font-semibold text-white">{pendingScene.scene.name}</div> <div className="mt-2 text-sm font-semibold text-white">{pendingScene.scene.name}</div>
</div> </PlatformSubpanel>
</div> </div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setPendingScene(null)}
className="rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-xs text-zinc-200"
>
</button>
<button
type="button"
disabled={isTraveling || !canTravel}
onClick={confirmTravel}
className={`rounded-lg border px-3 py-2 text-xs ${isTraveling || !canTravel ? 'border-white/10 bg-black/20 text-zinc-500' : 'border-amber-400/30 bg-amber-500/20 text-amber-50'}`}
>
{isTraveling ? '切换中...' : canTravel ? '确认前往' : '当前不可切换'}
</button>
</div>
</div> </div>
<PlatformDarkModalFooter data-testid="map-travel-footer">
<PlatformActionButton
surface="editorDark"
tone="ghost"
size="xs"
onClick={() => setPendingScene(null)}
>
</PlatformActionButton>
<PlatformActionButton
surface="editorDark"
tone={isTraveling || !canTravel ? 'ghost' : 'warning'}
size="xs"
disabled={isTraveling || !canTravel}
onClick={confirmTravel}
>
{isTraveling ? '切换中...' : canTravel ? '确认前往' : '当前不可切换'}
</PlatformActionButton>
</PlatformDarkModalFooter>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}

View File

@@ -0,0 +1,322 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { HTMLAttributes, ReactNode } from 'react';
import { expect, test, vi } from 'vitest';
import type { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story';
import {
type Encounter,
type GameState,
type InventoryItem,
WorldType,
} from '../types';
import { NpcModals } from './NpcModals';
vi.mock('motion/react', () => ({
AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}</>,
motion: {
div: ({
animate: _animate,
children,
exit: _exit,
initial: _initial,
...props
}: HTMLAttributes<HTMLDivElement> & {
animate?: unknown;
exit?: unknown;
initial?: unknown;
}) => <div {...props}>{children}</div>,
},
}));
const encounter = {
id: 'npc-merchant',
kind: 'npc',
npcName: '潮市商人',
} as Encounter;
const tradeItem: InventoryItem = {
id: 'moon-shell',
category: '材料',
name: '月壳',
quantity: 3,
rarity: 'rare',
tags: [],
};
const giftItem: InventoryItem = {
id: 'rose-token',
category: '礼物',
name: '玫瑰信物',
quantity: 1,
rarity: 'rare',
tags: [],
};
function createNpcUi(): StoryGenerationNpcUi {
return {
tradeModal: {
encounter,
actionText: '交易',
introText: '商人压低声音提示你。',
mode: 'buy',
selectedNpcItemId: 'moon-shell',
selectedPlayerItemId: null,
selectedQuantity: 1,
},
giftModal: {
encounter,
actionText: '赠礼',
introText: '她更喜欢有纪念意义的礼物。',
selectedItemId: 'rose-token',
},
recruitModal: null,
setTradeMode: vi.fn(),
selectTradeNpcItem: vi.fn(),
selectTradePlayerItem: vi.fn(),
setTradeQuantity: vi.fn(),
closeTradeModal: vi.fn(),
confirmTrade: vi.fn(),
selectGiftItem: vi.fn(),
closeGiftModal: vi.fn(),
confirmGift: vi.fn(),
selectRecruitRelease: vi.fn(),
closeRecruitModal: vi.fn(),
confirmRecruit: vi.fn(),
};
}
function createEmptyNpcUi(): StoryGenerationNpcUi {
const ui = createNpcUi();
return {
...ui,
tradeModal: ui.tradeModal
? {
...ui.tradeModal,
selectedNpcItemId: null,
selectedPlayerItemId: null,
}
: null,
giftModal: ui.giftModal
? {
...ui.giftModal,
selectedItemId: null,
}
: null,
recruitModal: {
encounter,
actionText: '邀请同行',
introText: '同行名额已满,需要先让一人离队。',
selectedReleaseNpcId: null,
},
};
}
function createGameState(): GameState {
return {
worldType: WorldType.CUSTOM,
playerCurrency: 24,
runtimeNpcInteraction: {
npcId: 'npc-merchant',
npcName: '潮市商人',
playerCurrency: 24,
currencyName: '贝币',
trade: {
buyItems: [
{
itemId: 'moon-shell',
item: tradeItem,
mode: 'buy',
unitPrice: 5,
maxQuantity: 3,
canSubmit: true,
},
],
sellItems: [],
},
gift: {
items: [
{
itemId: 'rose-token',
item: giftItem,
affinityGain: 8,
canSubmit: true,
},
],
},
},
} as unknown as GameState;
}
function createEmptyGameState(): GameState {
const state = createGameState();
return {
...state,
companions: [],
runtimeNpcInteraction: state.runtimeNpcInteraction
? {
...state.runtimeNpcInteraction,
trade: {
buyItems: [],
sellItems: [],
},
gift: {
items: [],
},
}
: state.runtimeNpcInteraction,
} as GameState;
}
test('NPC 交易数量和赠礼好感复用暗色平台胶囊标签', () => {
render(<NpcModals gameState={createGameState()} npcUi={createNpcUi()} />);
const quantityBadge = screen.getByText('x3');
const affinityBadge = screen.getByText('好感 +8');
const buyModeCard = screen.getByRole('button', { name: '购买物品' });
const tradeItemCard = screen.getByRole('button', { name: /月壳/ });
const giftItemCard = screen.getByRole('button', { name: /玫瑰信物/ });
expect(quantityBadge.className).toContain('rounded-full');
expect(quantityBadge.className).toContain('font-black');
expect(quantityBadge.className).toContain('bg-black/20');
expect(affinityBadge.className).toContain('rounded-full');
expect(affinityBadge.className).toContain('font-black');
expect(affinityBadge.className).toContain('bg-rose-500/10');
expect(buyModeCard.className).toContain('platform-dark-option-card');
expect(buyModeCard.className).toContain('border-emerald-400/45');
expect(tradeItemCard.className).toContain('platform-dark-option-card');
expect(tradeItemCard.className).toContain('border-emerald-400/45');
expect(giftItemCard.className).toContain('platform-dark-option-card');
expect(giftItemCard.className).toContain('border-rose-400/60');
});
test('NPC 交易静态信息卡复用暗色 PlatformSubpanel chrome', () => {
render(<NpcModals gameState={createGameState()} npcUi={createNpcUi()} />);
[
'npc-trade-list-summary',
'npc-trade-detail-panel',
'npc-trade-quantity-stepper',
'npc-trade-total-panel',
].forEach((testId) => {
const panel = screen.getByTestId(testId);
expect(panel.className).toContain('border-white/10');
expect(panel.className).toContain('bg-black/25');
expect(panel.className).toContain('rounded-xl');
});
});
test('NPC 弹窗叙事提示复用暗色平台状态条', () => {
render(<NpcModals gameState={createGameState()} npcUi={createNpcUi()} />);
const tradeIntro = screen.getByText('商人压低声音提示你。');
const giftIntro = screen.getByText('她更喜欢有纪念意义的礼物。');
expect(tradeIntro.className).toContain('platform-status-message');
expect(tradeIntro.className).toContain('border-amber-300/15');
expect(tradeIntro.className).toContain('bg-amber-500/10');
expect(giftIntro.className).toContain('platform-status-message');
expect(giftIntro.className).toContain('border-rose-300/15');
expect(giftIntro.className).toContain('bg-rose-500/10');
});
test('NPC 交易详情静态属性复用暗色 PlatformSubpanel chrome', async () => {
const user = userEvent.setup();
render(<NpcModals gameState={createGameState()} npcUi={createNpcUi()} />);
await user.click(screen.getByRole('button', { name: /月壳/ }));
['不可装备', '不可即时使用', '标签:无'].forEach((text) => {
const panel = screen.getByText(text);
expect(panel.className).toContain('border-white/10');
expect(panel.className).toContain('bg-black/25');
expect(panel.className).toContain('rounded-xl');
});
});
test('NPC 弹窗空态复用暗色平台空态', () => {
render(
<NpcModals gameState={createEmptyGameState()} npcUi={createEmptyNpcUi()} />,
);
[
'对方暂时没有可出售的物品。',
'当前没有适合送出的礼物。',
'当前没有可替换的同行角色。',
].forEach((text) => {
const emptyState = screen.getByText(text);
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('border-dashed');
expect(emptyState.className).toContain('bg-black/20');
});
const recruitIntro = screen.getByText('同行名额已满,需要先让一人离队。');
const tradeDetailEmptyState = screen.getByText(
'请选择一件物品,右侧会显示数量、价格与详情。',
);
expect(recruitIntro.className).toContain('platform-status-message');
expect(recruitIntro.className).toContain('border-amber-300/15');
expect(tradeDetailEmptyState.className).toContain('platform-empty-state');
expect(tradeDetailEmptyState.className).toContain('border-dashed');
});
test('NPC 弹窗标准 dark footer CTA 复用 PlatformActionButton', async () => {
const user = userEvent.setup();
const { unmount } = render(
<NpcModals gameState={createEmptyGameState()} npcUi={createEmptyNpcUi()} />,
);
const tradeFooter = screen.getByTestId('npc-trade-footer');
const giftFooter = screen.getByTestId('npc-gift-footer');
const recruitFooter = screen.getByTestId('npc-recruit-footer');
const cancelButtons = screen.getAllByRole('button', { name: '取消' });
const tradeConfirmButton = screen.getByRole('button', { name: '确认购买' });
const giftConfirmButton = screen.getByRole('button', { name: '确认赠礼' });
const recruitConfirmButton = screen.getByRole('button', { name: '确认招募' });
const footerButtons = [
cancelButtons[0],
tradeConfirmButton,
cancelButtons[1],
giftConfirmButton,
cancelButtons[2],
recruitConfirmButton,
].filter((button): button is HTMLElement => Boolean(button));
expect(footerButtons).toHaveLength(6);
expect(tradeFooter.className).toContain('platform-dark-modal-footer');
expect(tradeFooter.className).toContain('border-t');
expect(giftFooter.className).toContain('platform-dark-modal-footer');
expect(giftFooter.className).toContain('pb-5');
expect(recruitFooter.className).toContain('platform-dark-modal-footer');
expect(recruitFooter.className).toContain('pb-5');
footerButtons.forEach((button) => {
expect(button.className).toContain('platform-action-button--editor-dark');
expect(button.className).toContain('rounded-2xl');
});
unmount();
render(<NpcModals gameState={createGameState()} npcUi={createNpcUi()} />);
await user.click(screen.getByRole('button', { name: /月壳/ }));
const tradeDetailFooter = screen.getByTestId('npc-trade-detail-footer');
const closeButton = screen.getByRole('button', { name: '关闭' });
expect(tradeDetailFooter.className).toContain('platform-dark-modal-footer');
expect(tradeDetailFooter.className).toContain('px-5');
expect(tradeDetailFooter.className).toContain('py-4');
expect(closeButton.className).toContain('platform-action-button--editor-dark');
expect(closeButton.className).toContain('rounded-2xl');
});

View File

@@ -1,23 +1,21 @@
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react'; import { type ReactNode, useState } from 'react';
import { getCharacterById } from '../data/characterPresets'; import { getCharacterById } from '../data/characterPresets';
import { import { formatCurrency, getInventoryItemValue } from '../data/economy';
formatCurrency,
getInventoryItemValue,
} from '../data/economy';
import { import {
getEquipmentSlotFromItem, getEquipmentSlotFromItem,
getEquipmentSlotLabel, getEquipmentSlotLabel,
} from '../data/equipmentEffects'; } from '../data/equipmentEffects';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects'; import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import { import {
buildInventoryItemDescription, buildInventoryItemDescription,
getInventoryTagLabels, getInventoryTagLabels,
} from '../data/itemPresentation'; } from '../data/itemPresentation';
import { import { getRarityLabel } from '../data/npcInteractions';
getRarityLabel,
} from '../data/npcInteractions';
import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story'; import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story';
import { import {
GameState, GameState,
@@ -25,7 +23,18 @@ import {
RuntimeNpcGiftItemView, RuntimeNpcGiftItemView,
RuntimeNpcTradeItemView, RuntimeNpcTradeItemView,
} from '../types'; } from '../types';
import { getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import {
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PixelCloseButton } from './PixelCloseButton'; import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon'; import { PixelIcon } from './PixelIcon';
@@ -43,6 +52,19 @@ function getItemVisualSrc(item: InventoryItem) {
return getInventoryItemVisualSrc(item); return getInventoryItemVisualSrc(item);
} }
function NpcModalEmptyState({ children }: { children: ReactNode }) {
return (
<PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
className="py-6"
>
{children}
</PlatformEmptyState>
);
}
function buildTradeUseEffectText( function buildTradeUseEffectText(
effect: ReturnType<typeof resolveInventoryItemUseEffect> | null, effect: ReturnType<typeof resolveInventoryItemUseEffect> | null,
) { ) {
@@ -71,30 +93,35 @@ function TradeItemRow({
onClick: () => void; onClick: () => void;
}) { }) {
return ( return (
<button <PlatformDarkOptionCard
type="button" selected={selected}
tone="emerald"
padding="md"
onClick={onClick} onClick={onClick}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition ${ className="w-full"
selected
? 'border-emerald-400/45 bg-emerald-500/10'
: 'border-white/8 bg-black/20 hover:border-white/15'
}`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35"> <div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35">
<PixelIcon src={getItemVisualSrc(item)} className="h-7 w-7" /> <PixelIcon src={getItemVisualSrc(item)} className="h-7 w-7" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{item.name ?? item.id}</div> <div className="truncate text-sm font-medium text-white">
{item.name ?? item.id}
</div>
<div className="mt-1 text-[10px] text-zinc-500"> <div className="mt-1 text-[10px] text-zinc-500">
{item.category} / {getRarityLabel(item.rarity)} / {unitPrice} {currencyName} {item.category} / {getRarityLabel(item.rarity)} / {unitPrice}{' '}
{currencyName}
</div> </div>
</div> </div>
<div className="rounded-full border border-white/10 bg-black/25 px-2 py-0.5 text-[10px] text-white"> <PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="px-2 py-0.5 text-white"
>
x{item.quantity} x{item.quantity}
</div> </PlatformPillBadge>
</div> </div>
</button> </PlatformDarkOptionCard>
); );
} }
@@ -109,9 +136,18 @@ function TradeQuantityStepper({
}) { }) {
const safeMax = Math.max(1, maxQuantity); const safeMax = Math.max(1, maxQuantity);
return ( return (
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2"> <PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="flex items-center justify-between"
data-testid="npc-trade-quantity-stepper"
>
<div> <div>
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500"></div> <div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-1 text-xs text-zinc-400"> {safeMax}</div> <div className="mt-1 text-xs text-zinc-400"> {safeMax}</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -127,7 +163,9 @@ function TradeQuantityStepper({
> >
- -
</button> </button>
<div className="min-w-[3rem] text-center text-sm font-semibold text-white">{quantity}</div> <div className="min-w-[3rem] text-center text-sm font-semibold text-white">
{quantity}
</div>
<button <button
type="button" type="button"
onClick={() => onChange(quantity + 1)} onClick={() => onChange(quantity + 1)}
@@ -141,7 +179,7 @@ function TradeQuantityStepper({
+ +
</button> </button>
</div> </div>
</div> </PlatformSubpanel>
); );
} }
@@ -151,51 +189,66 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
const currencyName = npcInteraction?.currencyName ?? '钱币'; const currencyName = npcInteraction?.currencyName ?? '钱币';
const tradeModal = npcUi.tradeModal; const tradeModal = npcUi.tradeModal;
const tradeMode = tradeModal?.mode ?? 'buy'; const tradeMode = tradeModal?.mode ?? 'buy';
const tradeItemViews: RuntimeNpcTradeItemView[] = tradeMode === 'buy' const tradeItemViews: RuntimeNpcTradeItemView[] =
? npcInteraction?.trade.buyItems ?? [] tradeMode === 'buy'
: npcInteraction?.trade.sellItems ?? []; ? (npcInteraction?.trade.buyItems ?? [])
: (npcInteraction?.trade.sellItems ?? []);
const activeTradeView = tradeModal const activeTradeView = tradeModal
? tradeItemViews.find(view => ? (tradeItemViews.find(
view.itemId === (tradeMode === 'buy' (view) =>
? tradeModal.selectedNpcItemId view.itemId ===
: tradeModal.selectedPlayerItemId), (tradeMode === 'buy'
) ?? null ? tradeModal.selectedNpcItemId
: tradeModal.selectedPlayerItemId),
) ?? null)
: null; : null;
const activeTradeItem = activeTradeView?.item ?? null; const activeTradeItem = activeTradeView?.item ?? null;
const activeTradeUnitPrice = activeTradeView?.unitPrice ?? 0; const activeTradeUnitPrice = activeTradeView?.unitPrice ?? 0;
const activeTradeMaxQuantity = activeTradeView?.maxQuantity ?? 0; const activeTradeMaxQuantity = activeTradeView?.maxQuantity ?? 0;
const activeTradeQuantity = tradeModal const activeTradeQuantity = tradeModal
? Math.max(1, Math.min(tradeModal.selectedQuantity, Math.max(1, activeTradeMaxQuantity))) ? Math.max(
1,
Math.min(
tradeModal.selectedQuantity,
Math.max(1, activeTradeMaxQuantity),
),
)
: 1; : 1;
const activeTradeTotalPrice = activeTradeUnitPrice * activeTradeQuantity; const activeTradeTotalPrice = activeTradeUnitPrice * activeTradeQuantity;
const canConfirmTrade = Boolean( const canConfirmTrade = Boolean(
activeTradeView && activeTradeView && activeTradeView.canSubmit && activeTradeQuantity >= 1,
activeTradeView.canSubmit &&
activeTradeQuantity >= 1,
); );
const tradeItemList = tradeItemViews; const tradeItemList = tradeItemViews;
const tradeDetailItem = tradeDetail const tradeDetailItem = tradeDetail
? (tradeDetail.source === 'buy' ? ((tradeDetail.source === 'buy'
? npcInteraction?.trade.buyItems ?? [] ? (npcInteraction?.trade.buyItems ?? [])
: npcInteraction?.trade.sellItems ?? []) : (npcInteraction?.trade.sellItems ?? [])
.find(view => view.itemId === tradeDetail.itemId)?.item ?? null ).find((view) => view.itemId === tradeDetail.itemId)?.item ?? null)
: null; : null;
const tradeDetailView = tradeDetail const tradeDetailView = tradeDetail
? (tradeDetail.source === 'buy' ? ((tradeDetail.source === 'buy'
? npcInteraction?.trade.buyItems ?? [] ? (npcInteraction?.trade.buyItems ?? [])
: npcInteraction?.trade.sellItems ?? []) : (npcInteraction?.trade.sellItems ?? [])
.find(view => view.itemId === tradeDetail.itemId) ?? null ).find((view) => view.itemId === tradeDetail.itemId) ?? null)
: null; : null;
const tradeDetailUseEffect = tradeDetailItem && gameState.playerCharacter const tradeDetailUseEffect =
? resolveInventoryItemUseEffect(tradeDetailItem, gameState.playerCharacter) tradeDetailItem && gameState.playerCharacter
? resolveInventoryItemUseEffect(
tradeDetailItem,
gameState.playerCharacter,
)
: null;
const tradeDetailEquipSlot = tradeDetailItem
? getEquipmentSlotFromItem(tradeDetailItem)
: null; : null;
const tradeDetailEquipSlot = tradeDetailItem ? getEquipmentSlotFromItem(tradeDetailItem) : null;
const tradeDetailEffectText = buildTradeUseEffectText(tradeDetailUseEffect); const tradeDetailEffectText = buildTradeUseEffectText(tradeDetailUseEffect);
const giftCandidates: RuntimeNpcGiftItemView[] = npcUi.giftModal const giftCandidates: RuntimeNpcGiftItemView[] = npcUi.giftModal
? npcInteraction?.gift.items ?? [] ? (npcInteraction?.gift.items ?? [])
: []; : [];
const activeGiftView = const activeGiftView =
giftCandidates.find(item => item.itemId === npcUi.giftModal?.selectedItemId) ?? null; giftCandidates.find(
(item) => item.itemId === npcUi.giftModal?.selectedItemId,
) ?? null;
const handleTradeItemClick = (view: RuntimeNpcTradeItemView) => { const handleTradeItemClick = (view: RuntimeNpcTradeItemView) => {
if (tradeMode === 'buy') { if (tradeMode === 'buy') {
@@ -224,13 +277,15 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
exit={{ opacity: 0, scale: 0.96, y: 8 }} exit={{ opacity: 0, scale: 0.96, y: 8 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-4xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]" className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-4xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)} style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4"> <div className="flex items-center justify-between border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-white"></div> <div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 text-xs text-zinc-500">
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} / {currencyName}{npcInteraction?.playerCurrency ?? gameState.playerCurrency} {npcInteraction?.npcName ?? tradeModal.encounter.npcName} /
{currencyName}
{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
</div> </div>
</div> </div>
<PixelCloseButton <PixelCloseButton
@@ -242,70 +297,88 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4"> <div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
{tradeModal.introText && ( {tradeModal.introText && (
<div className="mb-3 whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90"> <PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="mb-3 whitespace-pre-line leading-relaxed"
>
{tradeModal.introText} {tradeModal.introText}
</div> </PlatformStatusMessage>
)} )}
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]"> <div className="grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
<div className="min-h-0 space-y-3"> <div className="min-h-0 space-y-3">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<button <PlatformDarkOptionCard
type="button" selected={tradeMode === 'buy'}
tone="emerald"
onClick={() => npcUi.setTradeMode('buy')} onClick={() => npcUi.setTradeMode('buy')}
className={`rounded-xl border px-3 py-2 text-sm transition ${ className="text-center text-sm"
tradeMode === 'buy'
? 'border-emerald-400/45 bg-emerald-500/10 text-emerald-100'
: 'border-white/10 bg-black/20 text-zinc-300'
}`}
> >
</button> </PlatformDarkOptionCard>
<button <PlatformDarkOptionCard
type="button" selected={tradeMode === 'sell'}
tone="sky"
onClick={() => npcUi.setTradeMode('sell')} onClick={() => npcUi.setTradeMode('sell')}
className={`rounded-xl border px-3 py-2 text-sm transition ${ className="text-center text-sm"
tradeMode === 'sell'
? 'border-sky-400/45 bg-sky-500/10 text-sky-100'
: 'border-white/10 bg-black/20 text-zinc-300'
}`}
> >
</button> </PlatformDarkOptionCard>
</div> </div>
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"> <PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="flex items-center justify-between text-xs text-zinc-400"
data-testid="npc-trade-list-summary"
>
<span>{tradeMode === 'buy' ? '对方库存' : '你的背包'}</span> <span>{tradeMode === 'buy' ? '对方库存' : '你的背包'}</span>
<span>{tradeItemList.length} </span> <span>{tradeItemList.length} </span>
</div> </PlatformSubpanel>
<div className="max-h-[42vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide"> <div className="max-h-[42vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
{tradeItemList.length > 0 ? tradeItemList.map(view => ( {tradeItemList.length > 0 ? (
<div key={view.itemId}> tradeItemList.map((view) => (
<TradeItemRow <div key={view.itemId}>
item={view.item} <TradeItemRow
selected={tradeMode === 'buy' item={view.item}
? tradeModal.selectedNpcItemId === view.itemId selected={
: tradeModal.selectedPlayerItemId === view.itemId} tradeMode === 'buy'
unitPrice={view.unitPrice} ? tradeModal.selectedNpcItemId === view.itemId
currencyName={currencyName} : tradeModal.selectedPlayerItemId ===
onClick={() => handleTradeItemClick(view)} view.itemId
/> }
{!view.canSubmit && view.reason && ( unitPrice={view.unitPrice}
<div className="mt-1 px-1 text-[10px] text-rose-300"> currencyName={currencyName}
{view.reason} onClick={() => handleTradeItemClick(view)}
</div> />
)} {!view.canSubmit && view.reason && (
</div> <div className="mt-1 px-1 text-[10px] text-rose-300">
)) : ( {view.reason}
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500"> </div>
{tradeMode === 'buy' ? '对方暂时没有可出售的物品。' : '你当前没有可出售的物品。'} )}
</div> </div>
))
) : (
<NpcModalEmptyState>
{tradeMode === 'buy'
? '对方暂时没有可出售的物品。'
: '你当前没有可出售的物品。'}
</NpcModalEmptyState>
)} )}
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-xl border border-white/8 bg-black/20 p-3"> <PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
data-testid="npc-trade-detail-panel"
>
{activeTradeItem ? ( {activeTradeItem ? (
<div className="space-y-3"> <div className="space-y-3">
<TradeQuantityStepper <TradeQuantityStepper
@@ -314,49 +387,66 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
onChange={npcUi.setTradeQuantity} onChange={npcUi.setTradeQuantity}
/> />
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-sm text-zinc-200"> <PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="text-sm text-zinc-200"
data-testid="npc-trade-total-panel"
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span>{tradeMode === 'buy' ? '购买总价' : '出售总价'}</span> <span>
{tradeMode === 'buy' ? '购买总价' : '出售总价'}
</span>
<span className="font-semibold text-white"> <span className="font-semibold text-white">
{formatCurrency(activeTradeTotalPrice, gameState.worldType)} {formatCurrency(
activeTradeTotalPrice,
gameState.worldType,
)}
</span> </span>
</div> </div>
{!activeTradeView?.canSubmit && activeTradeView?.reason && ( {!activeTradeView?.canSubmit &&
<div className="mt-2 text-xs text-rose-300"> activeTradeView?.reason && (
{activeTradeView.reason} <div className="mt-2 text-xs text-rose-300">
</div> {activeTradeView.reason}
)} </div>
</div> )}
</PlatformSubpanel>
</div> </div>
) : ( ) : (
<div className="px-2 py-8 text-center text-sm text-zinc-500"> <PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
className="px-2 py-8 text-center"
>
</div> </PlatformEmptyState>
)} )}
</div> </PlatformSubpanel>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4"> <PlatformDarkModalFooter data-testid="npc-trade-footer">
<button <PlatformActionButton
type="button" surface="editorDark"
tone="secondary"
size="xs"
onClick={npcUi.closeTradeModal} onClick={npcUi.closeTradeModal}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
> >
</button> </PlatformActionButton>
<button <PlatformActionButton
type="button" surface="editorDark"
tone="primary"
size="xs"
disabled={!canConfirmTrade} disabled={!canConfirmTrade}
onClick={npcUi.confirmTrade} onClick={npcUi.confirmTrade}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canConfirmTrade ? 'text-white' : 'text-zinc-600'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
> >
{tradeMode === 'buy' ? '确认购买' : '确认出售'} {tradeMode === 'buy' ? '确认购买' : '确认出售'}
</button> </PlatformActionButton>
</div> </PlatformDarkModalFooter>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}
@@ -375,7 +465,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
exit={{ opacity: 0, scale: 0.96, y: 8 }} exit={{ opacity: 0, scale: 0.96, y: 8 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]" className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)} style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4"> <div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div> <div>
@@ -394,12 +484,18 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5"> <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/25"> <div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/25">
<PixelIcon src={getItemVisualSrc(tradeDetailItem)} className="h-12 w-12" /> <PixelIcon
src={getItemVisualSrc(tradeDetailItem)}
className="h-12 w-12"
/>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-base font-semibold text-white">{tradeDetailItem.name}</div> <div className="text-base font-semibold text-white">
{tradeDetailItem.name}
</div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 text-xs text-zinc-500">
{tradeDetailItem.category} / {getRarityLabel(tradeDetailItem.rarity)} {tradeDetailItem.category} /{' '}
{getRarityLabel(tradeDetailItem.rarity)}
</div> </div>
<div className="mt-2 space-y-1 text-sm text-zinc-300"> <div className="mt-2 space-y-1 text-sm text-zinc-300">
<div>: {tradeDetailItem.quantity}</div> <div>: {tradeDetailItem.quantity}</div>
@@ -414,38 +510,71 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
</div> </div>
<p className="text-sm leading-relaxed text-zinc-300"> <p className="text-sm leading-relaxed text-zinc-300">
{buildInventoryItemDescription(tradeDetailItem, tradeDetailUseEffect)} {buildInventoryItemDescription(
tradeDetailItem,
tradeDetailUseEffect,
)}
</p> </p>
<div className="grid grid-cols-2 gap-2 text-xs text-zinc-300"> <div className="grid grid-cols-2 gap-2 text-xs text-zinc-300">
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2"> <PlatformSubpanel
{tradeDetailEquipSlot ? `装备位:${getEquipmentSlotLabel(tradeDetailEquipSlot)}` : '不可装备'} as="div"
</div> surface="dark"
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2"> radius="xs"
{isInventoryItemUsable(tradeDetailItem) ? '可立即使用' : '不可即时使用'} padding="row"
</div> >
<div className="col-span-2 rounded-lg border border-white/8 bg-black/20 px-3 py-2"> {tradeDetailEquipSlot
{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') || '无'} ? `装备位:${getEquipmentSlotLabel(tradeDetailEquipSlot)}`
</div> : '不可装备'}
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
>
{isInventoryItemUsable(tradeDetailItem)
? '可立即使用'
: '不可即时使用'}
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="col-span-2"
>
{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') ||
'无'}
</PlatformSubpanel>
</div> </div>
{tradeDetailEffectText && ( {tradeDetailEffectText && (
<div className="rounded-lg border border-emerald-400/15 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-100"> <PlatformStatusMessage
tone="success"
surface="editorDark"
size="xs"
>
使{tradeDetailEffectText} 使{tradeDetailEffectText}
</div> </PlatformStatusMessage>
)} )}
<div className="flex justify-end">
<button
type="button"
onClick={() => setTradeDetail(null)}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
>
</button>
</div>
</div> </div>
<PlatformDarkModalFooter
padding="roomy"
data-testid="npc-trade-detail-footer"
>
<PlatformActionButton
surface="editorDark"
tone="secondary"
size="xs"
onClick={() => setTradeDetail(null)}
>
</PlatformActionButton>
</PlatformDarkModalFooter>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}
@@ -464,12 +593,14 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
exit={{ opacity: 0, scale: 0.96, y: 8 }} exit={{ opacity: 0, scale: 0.96, y: 8 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]" className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)} style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4"> <div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div> <div>
<div className="text-sm font-semibold text-white"></div> <div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500">{npcUi.giftModal.encounter.npcName}</div> <div className="mt-1 text-xs text-zinc-500">
{npcUi.giftModal.encounter.npcName}
</div>
</div> </div>
<PixelCloseButton <PixelCloseButton
onClick={npcUi.closeGiftModal} onClick={npcUi.closeGiftModal}
@@ -480,50 +611,88 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5"> <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
{npcUi.giftModal.introText && ( {npcUi.giftModal.introText && (
<div className="whitespace-pre-line rounded-xl border border-rose-300/15 bg-rose-500/10 px-3 py-2 text-xs leading-relaxed text-rose-50/90"> <PlatformStatusMessage
{npcUi.giftModal.introText} tone="error"
</div> surface="editorDark"
)} size="xs"
{giftCandidates.length > 0 ? giftCandidates.map(candidate => ( className="whitespace-pre-line leading-relaxed"
<button
key={candidate.itemId}
type="button"
onClick={() => npcUi.selectGiftItem(candidate.itemId)}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.giftModal?.selectedItemId === candidate.itemId ? 'border-rose-400/60 bg-rose-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
> >
<div className="flex items-center justify-between gap-3"> {npcUi.giftModal.introText}
<div className="flex items-center gap-3"> </PlatformStatusMessage>
<PixelIcon src={getItemVisualSrc(candidate.item)} className="h-8 w-8" /> )}
<div> {giftCandidates.length > 0 ? (
<div className="text-sm text-white">{candidate.item.name}</div> giftCandidates.map((candidate) => (
<div className="mt-1 text-[10px] text-zinc-500">{candidate.item.category} / {getRarityLabel(candidate.item.rarity)}</div> <PlatformDarkOptionCard
{!candidate.canSubmit && candidate.reason && ( key={candidate.itemId}
<div className="mt-1 text-[10px] text-rose-200/80"> selected={
{candidate.reason} npcUi.giftModal?.selectedItemId === candidate.itemId
}
tone="rose"
radius="sm"
onClick={() => npcUi.selectGiftItem(candidate.itemId)}
className="w-full"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<PixelIcon
src={getItemVisualSrc(candidate.item)}
className="h-8 w-8"
/>
<div>
<div className="text-sm text-white">
{candidate.item.name}
</div> </div>
)} <div className="mt-1 text-[10px] text-zinc-500">
{candidate.item.category} /{' '}
{getRarityLabel(candidate.item.rarity)}
</div>
{!candidate.canSubmit && candidate.reason && (
<div className="mt-1 text-[10px] text-rose-200/80">
{candidate.reason}
</div>
)}
</div>
</div> </div>
<PlatformPillBadge
tone="darkRose"
size="xxs"
className="px-2 py-0.5"
>
+{candidate.affinityGain}
</PlatformPillBadge>
</div> </div>
<div className="rounded-full border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-[10px] text-rose-100"> </PlatformDarkOptionCard>
+{candidate.affinityGain} ))
</div> ) : (
</div> <NpcModalEmptyState>
</button>
)) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
</div> </NpcModalEmptyState>
)} )}
</div> </div>
<div className="flex justify-end gap-3 px-5 pb-5"> <PlatformDarkModalFooter
<button type="button" onClick={npcUi.closeGiftModal} className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200" style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}> bordered={false}
padding="bottom"
data-testid="npc-gift-footer"
>
<PlatformActionButton
surface="editorDark"
tone="secondary"
size="xs"
onClick={npcUi.closeGiftModal}
>
</button> </PlatformActionButton>
<button type="button" disabled={!activeGiftView?.canSubmit} onClick={npcUi.confirmGift} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${activeGiftView?.canSubmit ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}> <PlatformActionButton
surface="editorDark"
tone="primary"
size="xs"
disabled={!activeGiftView?.canSubmit}
onClick={npcUi.confirmGift}
>
</button> </PlatformActionButton>
</div> </PlatformDarkModalFooter>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}
@@ -542,12 +711,16 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
exit={{ opacity: 0, scale: 0.96, y: 8 }} exit={{ opacity: 0, scale: 0.96, y: 8 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]" className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)} style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4"> <div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div> <div>
<div className="text-sm font-semibold text-white"></div> <div className="text-sm font-semibold text-white">
<div className="mt-1 text-xs text-zinc-500"></div>
</div>
<div className="mt-1 text-xs text-zinc-500">
</div>
</div> </div>
<PixelCloseButton <PixelCloseButton
onClick={npcUi.closeRecruitModal} onClick={npcUi.closeRecruitModal}
@@ -558,39 +731,70 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5"> <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
{npcUi.recruitModal.introText && ( {npcUi.recruitModal.introText && (
<div className="whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90"> <PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="whitespace-pre-line leading-relaxed"
>
{npcUi.recruitModal.introText} {npcUi.recruitModal.introText}
</div> </PlatformStatusMessage>
)} )}
{gameState.companions.length > 0 ? gameState.companions.map(companion => { {gameState.companions.length > 0 ? (
const character = getCharacterById(companion.characterId); gameState.companions.map((companion) => {
if (!character) return null; const character = getCharacterById(companion.characterId);
return ( if (!character) return null;
<button return (
key={companion.npcId} <PlatformDarkOptionCard
type="button" key={companion.npcId}
onClick={() => npcUi.selectRecruitRelease(companion.npcId)} selected={
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.recruitModal?.selectedReleaseNpcId === companion.npcId ? 'border-amber-400/60 bg-amber-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`} npcUi.recruitModal?.selectedReleaseNpcId ===
> companion.npcId
<div className="text-sm text-white">{character.name}</div> }
<div className="mt-1 text-[10px] text-zinc-500">{character.title}</div> tone="amber"
</button> radius="sm"
); onClick={() =>
}) : ( npcUi.selectRecruitRelease(companion.npcId)
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500"> }
className="w-full"
>
<div className="text-sm text-white">{character.name}</div>
<div className="mt-1 text-[10px] text-zinc-500">
{character.title}
</div>
</PlatformDarkOptionCard>
);
})
) : (
<NpcModalEmptyState>
</div> </NpcModalEmptyState>
)} )}
</div> </div>
<div className="flex justify-end gap-3 px-5 pb-5"> <PlatformDarkModalFooter
<button type="button" onClick={npcUi.closeRecruitModal} className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200" style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}> bordered={false}
padding="bottom"
data-testid="npc-recruit-footer"
>
<PlatformActionButton
surface="editorDark"
tone="secondary"
size="xs"
onClick={npcUi.closeRecruitModal}
>
</button> </PlatformActionButton>
<button type="button" disabled={!npcUi.recruitModal.selectedReleaseNpcId} onClick={npcUi.confirmRecruit} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.recruitModal.selectedReleaseNpcId ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}> <PlatformActionButton
surface="editorDark"
tone="primary"
size="xs"
disabled={!npcUi.recruitModal.selectedReleaseNpcId}
onClick={npcUi.confirmRecruit}
>
</button> </PlatformActionButton>
</div> </PlatformDarkModalFooter>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}

View File

@@ -1,6 +1,5 @@
import type { MouseEvent } from 'react';
import { CHROME_ICONS } from '../uiAssets'; import { CHROME_ICONS } from '../uiAssets';
import { PlatformModalCloseButton } from './common/PlatformModalCloseButton';
import { PixelIcon } from './PixelIcon'; import { PixelIcon } from './PixelIcon';
type PixelCloseButtonProps = { type PixelCloseButtonProps = {
@@ -12,7 +11,7 @@ type PixelCloseButtonProps = {
/** /**
* RPG 像素风弹窗右上关闭按钮。 * RPG 像素风弹窗右上关闭按钮。
* 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为 * 这里只保留 RPG 语义层封装,底层样式与行为统一复用共享 close button
*/ */
export function PixelCloseButton({ export function PixelCloseButton({
onClick, onClick,
@@ -20,26 +19,16 @@ export function PixelCloseButton({
placement = 'absolute', placement = 'absolute',
className = '', className = '',
}: PixelCloseButtonProps) { }: PixelCloseButtonProps) {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onClick();
};
const placementClassName =
placement === 'absolute'
? 'absolute right-4 top-3 sm:right-5 sm:top-4'
: 'relative shrink-0';
return ( return (
<button <PlatformModalCloseButton
type="button" label={label}
aria-label={label}
title={label} title={label}
onClick={handleClick} variant="pixel"
className={`${placementClassName} z-20 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 ${className}`.trim()} placement={placement}
> stopPropagation
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" /> onClick={() => onClick()}
</button> className={['z-20', className].filter(Boolean).join(' ')}
icon={<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />}
/>
); );
} }

View File

@@ -0,0 +1,128 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { createEmptyCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import {
CharacterDraftModal,
CustomWorldCreatorModal,
} from './SelectionCustomizationModals';
test('角色自定义错误提示复用暗色 PlatformStatusMessage chrome', () => {
render(
<CharacterDraftModal
isOpen
characterLabel="试剑客"
draftName="沈行"
draftBackstory="旧雨里走来的人。"
error="名字不能为空。"
onNameChange={vi.fn()}
onBackstoryChange={vi.fn()}
onClose={vi.fn()}
onConfirm={vi.fn()}
/>,
);
const errorMessage = screen.getByText('名字不能为空。');
const cancelButton = screen.getByRole('button', { name: '取消' });
const confirmButton = screen.getByRole('button', { name: '确认进入' });
const closeButton = screen.getByRole('button', { name: '关闭角色自定义' });
const currentCharacterPanel = screen.getByText('当前角色:试剑客');
const nameInput = screen.getByLabelText('角色名字');
const backstoryTextarea = screen.getByLabelText('背景补充');
const footer = screen.getByTestId('selection-modal-footer');
expect(errorMessage.className).toContain('platform-status-message');
expect(errorMessage.className).toContain('border-rose-300/15');
expect(errorMessage.className).toContain('bg-rose-500/10');
expect(errorMessage.className).toContain('text-rose-50/90');
expect(nameInput.className).toContain('platform-text-field--editor-dark');
expect(nameInput.className).toContain('focus:border-emerald-400/40');
expect(backstoryTextarea.className).toContain(
'platform-text-field--editor-dark',
);
expect(backstoryTextarea.className).toContain('resize-none');
expect(cancelButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(cancelButton.className).toContain('bg-white/5');
expect(confirmButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(confirmButton.className).toContain('bg-emerald-400');
expect(closeButton.className).toContain(
'platform-modal-close-button--editor-dark',
);
expect(footer.className).toContain('platform-dark-modal-footer');
expect(footer.className).toContain('border-t');
expect(currentCharacterPanel.className).toContain('border-white/10');
expect(currentCharacterPanel.className).toContain('bg-black/25');
});
test('自定义世界生成提示复用暗色状态条和平台进度条', () => {
render(
<CustomWorldCreatorModal
isOpen
draft="雾海边境。"
isGenerating
progress={42.4}
progressLabel="正在生成世界"
error="生成失败,请重试。"
onDraftChange={vi.fn()}
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
);
const progressMessage = screen
.getByText('正在生成世界')
.closest('.platform-status-message');
const errorMessage = screen.getByText('生成失败,请重试。');
const progressbar = screen.getByRole('progressbar', {
name: '自定义世界生成进度',
});
const generatingButton = screen.getByRole('button', { name: '生成中...' });
const draftTextarea = screen.getByPlaceholderText(
'例:一个被潮雾与失落列岛切碎的边境世界,旧盟约、沉船秘术与灯塔守望者纠缠在一起……',
);
expect(progressMessage?.className).toContain('border-sky-300/15');
expect(progressMessage?.className).toContain('bg-sky-500/10');
expect(errorMessage.className).toContain('platform-status-message');
expect(errorMessage.className).toContain('border-rose-300/15');
expect(progressbar.className).toContain('platform-progress-track');
expect(progressbar.getAttribute('aria-valuenow')).toBe('42');
expect(generatingButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(generatingButton.className).toContain('bg-sky-400');
expect(generatingButton.hasAttribute('disabled')).toBe(true);
expect(draftTextarea.className).toContain('platform-text-field--editor-dark');
expect(draftTextarea.className).toContain('focus:border-sky-400/40');
});
test('自定义世界生成模式选择复用暗色平台输入框', () => {
render(
<CustomWorldCreatorModal
isOpen
creatorIntent={{
...createEmptyCustomWorldCreatorIntent('card'),
rawSettingText: '雾海边境。',
}}
generationMode="fast"
isGenerating={false}
progress={0}
progressLabel=""
onCreatorIntentChange={vi.fn()}
onGenerationModeChange={vi.fn()}
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
);
const modeSelect = screen.getByLabelText('生成模式');
expect(modeSelect.className).toContain('platform-text-field--editor-dark');
expect(modeSelect.className).toContain('focus:border-sky-400/40');
});

View File

@@ -1,10 +1,19 @@
import { X } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { import type {
CustomWorldCreatorIntent, CustomWorldCreatorIntent,
CustomWorldGenerationMode, CustomWorldGenerationMode,
} from '../types'; } from '../types';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
import { PlatformModalCloseButton } from './common/PlatformModalCloseButton';
import { PlatformProgressBar } from './common/PlatformProgressBar';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import {
PlatformSelectField,
PlatformTextField,
} from './common/PlatformTextField';
type BaseModalProps = { type BaseModalProps = {
isOpen: boolean; isOpen: boolean;
@@ -28,21 +37,23 @@ function SelectionModal({
<div className="platform-modal-shell platform-remap-surface flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl shadow-[0_30px_80px_rgba(0,0,0,0.55)]"> <div className="platform-modal-shell platform-remap-surface flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4"> <div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-semibold text-white">{title}</div> <div className="text-base font-semibold text-white">{title}</div>
<button <PlatformModalCloseButton
type="button" label={`关闭${title}`}
variant="editorDark"
onClick={onClose} onClick={onClose}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white" />
>
<X className="h-4 w-4" />
</button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5"> <div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
{children} {children}
</div> </div>
{footer ? ( {footer ? (
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4"> <PlatformDarkModalFooter
wrap
padding="roomy"
data-testid="selection-modal-footer"
>
{footer} {footer}
</div> </PlatformDarkModalFooter>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -79,50 +90,71 @@ export function CharacterDraftModal(props: {
onClose={onClose} onClose={onClose}
footer={( footer={(
<> <>
<button <PlatformActionButton
type="button" surface="editorDark"
tone="secondary"
size="sm"
onClick={onClose} onClick={onClose}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white"
> >
</button> </PlatformActionButton>
<button <PlatformActionButton
type="button" surface="editorDark"
tone="success"
size="sm"
onClick={onConfirm} onClick={onConfirm}
className="rounded-2xl bg-emerald-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-emerald-300"
> >
</button> </PlatformActionButton>
</> </>
)} )}
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300"> <PlatformSubpanel
as="div"
surface="dark"
radius="md"
padding="row"
className="text-sm text-zinc-300"
>
{characterLabel} {characterLabel}
</div> </PlatformSubpanel>
<label className="block"> <label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200"></div> <div className="mb-2 text-sm font-medium text-zinc-200"></div>
<input <PlatformTextField
value={draftName} value={draftName}
onChange={(event) => onNameChange(event.target.value)} onChange={(event) => onNameChange(event.target.value)}
placeholder="输入一个更贴合这次旅程的称呼" placeholder="输入一个更贴合这次旅程的称呼"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-emerald-400/40" surface="editorDark"
tone="emerald"
density="roomy"
className="rounded-2xl"
/> />
</label> </label>
<label className="block"> <label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200"></div> <div className="mb-2 text-sm font-medium text-zinc-200"></div>
<textarea <PlatformTextField
variant="textarea"
value={draftBackstory} value={draftBackstory}
onChange={(event) => onBackstoryChange(event.target.value)} onChange={(event) => onBackstoryChange(event.target.value)}
rows={6} rows={6}
placeholder="可以补充这次开局想强调的身份、经历、执念或禁忌。" placeholder="可以补充这次开局想强调的身份、经历、执念或禁忌。"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-emerald-400/40" surface="editorDark"
tone="emerald"
size="md"
density="roomy"
className="rounded-2xl leading-7"
/> />
</label> </label>
{error ? ( {error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100"> <PlatformStatusMessage
tone="error"
surface="editorDark"
size="md"
className="rounded-2xl"
>
{error} {error}
</div> </PlatformStatusMessage>
) : null} ) : null}
</div> </div>
</SelectionModal> </SelectionModal>
@@ -199,22 +231,24 @@ export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
onClose={onClose} onClose={onClose}
footer={( footer={(
<> <>
<button <PlatformActionButton
type="button" surface="editorDark"
tone="secondary"
size="sm"
onClick={onClose} onClick={onClose}
disabled={isGenerating} disabled={isGenerating}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
> >
</button> </PlatformActionButton>
<button <PlatformActionButton
type="button" surface="editorDark"
tone="primary"
size="sm"
onClick={onSubmit} onClick={onSubmit}
disabled={isGenerating} disabled={isGenerating}
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-50"
> >
{isGenerating ? '生成中...' : '开始生成'} {isGenerating ? '生成中...' : '开始生成'}
</button> </PlatformActionButton>
</> </>
)} )}
> >
@@ -222,18 +256,21 @@ export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
{hasCreatorIntentProps(props) ? ( {hasCreatorIntentProps(props) ? (
<label className="block"> <label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200"></div> <div className="mb-2 text-sm font-medium text-zinc-200"></div>
<select <PlatformSelectField
value={props.generationMode} value={props.generationMode}
onChange={(event) => onChange={(event) =>
props.onGenerationModeChange( props.onGenerationModeChange(
event.target.value as CustomWorldGenerationMode, event.target.value as CustomWorldGenerationMode,
) )
} }
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-400/40" surface="editorDark"
tone="sky"
density="roomy"
className="rounded-2xl"
> >
<option value="fast"></option> <option value="fast"></option>
<option value="full"></option> <option value="full"></option>
</select> </PlatformSelectField>
</label> </label>
) : null} ) : null}
@@ -241,33 +278,49 @@ export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
</div> </div>
<textarea <PlatformTextField
variant="textarea"
value={draftText} value={draftText}
onChange={(event) => updateDraftText(event.target.value)} onChange={(event) => updateDraftText(event.target.value)}
rows={8} rows={8}
placeholder="例:一个被潮雾与失落列岛切碎的边境世界,旧盟约、沉船秘术与灯塔守望者纠缠在一起……" placeholder="例:一个被潮雾与失落列岛切碎的边境世界,旧盟约、沉船秘术与灯塔守望者纠缠在一起……"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40" surface="editorDark"
tone="sky"
size="md"
density="roomy"
className="rounded-2xl leading-7"
/> />
{isGenerating ? ( {isGenerating ? (
<div className="rounded-2xl border border-sky-300/15 bg-sky-500/10 px-4 py-3"> <PlatformStatusMessage
tone="info"
surface="editorDark"
size="md"
className="rounded-2xl"
>
<div className="mb-2 flex items-center justify-between text-xs tracking-[0.16em] text-sky-100/80"> <div className="mb-2 flex items-center justify-between text-xs tracking-[0.16em] text-sky-100/80">
<span>{progressLabel}</span> <span>{progressLabel}</span>
<span>{Math.max(0, Math.min(100, Math.round(progress)))}%</span> <span>{Math.max(0, Math.min(100, Math.round(progress)))}%</span>
</div> </div>
<div className="h-2 overflow-hidden rounded-full bg-white/10"> <PlatformProgressBar
<div value={progress}
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-cyan-200 transition-[width] duration-300" minVisibleValue={6}
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }} ariaLabel="自定义世界生成进度"
/> className="bg-white/10"
</div> fillClassName="bg-gradient-to-r from-sky-300 to-cyan-200"
</div> />
</PlatformStatusMessage>
) : null} ) : null}
{error ? ( {error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100"> <PlatformStatusMessage
tone="error"
surface="editorDark"
size="md"
className="rounded-2xl"
>
{error} {error}
</div> </PlatformStatusMessage>
) : null} ) : null}
</div> </div>
</SelectionModal> </SelectionModal>

View File

@@ -17,6 +17,7 @@ import {
SceneHostileNpc, SceneHostileNpc,
WorldType, WorldType,
} from '../types'; } from '../types';
import { PlatformActionButton } from './common/PlatformActionButton';
import { GameCanvas } from './GameCanvas'; import { GameCanvas } from './GameCanvas';
export interface SkillEffectPreviewProps { export interface SkillEffectPreviewProps {
@@ -227,15 +228,17 @@ export function SkillEffectPreview({
{mode === 'player' ? `受击对象:${sceneHostileNpcs[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`} {mode === 'player' ? `受击对象:${sceneHostileNpcs[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`}
</div> </div>
</div> </div>
<button <PlatformActionButton
type="button"
onClick={() => setRestartTick(value => value + 1)} onClick={() => setRestartTick(value => value + 1)}
disabled={!skill || isPlaying} disabled={!skill || isPlaying}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-xs text-zinc-200 transition hover:border-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-50" surface="editorDark"
tone="ghost"
size="xs"
className="min-h-0 rounded-lg bg-black/30 px-3 py-2 text-xs hover:border-white/20 disabled:opacity-50"
> >
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
<span>{isPlaying ? '播放中' : '重新预览'}</span> <span>{isPlaying ? '播放中' : '重新预览'}</span>
</button> </PlatformActionButton>
</div> </div>
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black"> <div className="overflow-hidden rounded-2xl border border-white/10 bg-black">

View File

@@ -32,6 +32,9 @@ function renderAccountModal(overrides?: {
riskBlocks?: AuthRiskBlockSummary[]; riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[]; sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[]; auditLogs?: AuthAuditLogEntry[];
loadingRiskBlocks?: boolean;
loadingSessions?: boolean;
loadingAuditLogs?: boolean;
onRevokeSession?: (session: AuthSessionSummary) => Promise<void>; onRevokeSession?: (session: AuthSessionSummary) => Promise<void>;
revokingSessionIds?: string[]; revokingSessionIds?: string[];
initialSection?: initialSection?:
@@ -52,9 +55,9 @@ function renderAccountModal(overrides?: {
riskBlocks={overrides?.riskBlocks ?? []} riskBlocks={overrides?.riskBlocks ?? []}
sessions={overrides?.sessions ?? []} sessions={overrides?.sessions ?? []}
auditLogs={overrides?.auditLogs ?? []} auditLogs={overrides?.auditLogs ?? []}
loadingRiskBlocks={false} loadingRiskBlocks={overrides?.loadingRiskBlocks ?? false}
loadingSessions={false} loadingSessions={overrides?.loadingSessions ?? false}
loadingAuditLogs={false} loadingAuditLogs={overrides?.loadingAuditLogs ?? false}
isHydratingSettings={false} isHydratingSettings={false}
isPersistingSettings={false} isPersistingSettings={false}
settingsError={null} settingsError={null}
@@ -98,6 +101,23 @@ function buildSession(
}; };
} }
function findNearestClassName(
element: HTMLElement,
classNamePart: string,
): HTMLElement | null {
let current: HTMLElement | null = element;
while (current) {
if (current.className.includes(classNamePart)) {
return current;
}
current = current.parentElement;
}
return null;
}
test('settings header uses a generic title instead of the phone number', () => { test('settings header uses a generic title instead of the phone number', () => {
renderAccountModal(); renderAccountModal();
@@ -119,6 +139,27 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.getByRole('button', { name: //u })).toBeTruthy(); expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull(); expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull(); expect(screen.queryByRole('button', { name: //u })).toBeNull();
const themeSettingsButton = screen.getByRole('button', { name: //u });
expect(themeSettingsButton.getAttribute('type')).toBe('button');
expect(themeSettingsButton.className).toContain('platform-subpanel');
expect(themeSettingsButton.className).toContain('rounded-[1.5rem]');
expect(themeSettingsButton.className).toContain('hover:bg-white');
});
test('appearance panel uses PlatformPillBadge for current theme status', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: //u }));
const appearanceDialog = screen.getByRole('dialog', { name: '主题设置' });
const themeStatusBadge = within(appearanceDialog).getByText('平台设置已同步');
expect(within(appearanceDialog).getByText('当前主题')).toBeTruthy();
expect(themeStatusBadge.className).toContain('rounded-full');
expect(themeStatusBadge.className).toContain('bg-white/72');
}); });
test('direct account entry does not render the settings shell as another dialog', () => { test('direct account entry does not render the settings shell as another dialog', () => {
@@ -159,6 +200,9 @@ test('account panel uses compact binding cards and keeps logout actions at the b
'[data-account-binding-card]', '[data-account-binding-card]',
); );
expect(compactCards).toHaveLength(2); expect(compactCards).toHaveLength(2);
expect(compactCards[0]?.className).toContain('platform-subpanel');
expect(compactCards[0]?.className).toContain('rounded-[1rem]');
expect(compactCards[0]?.className).toContain('px-3.5 py-3');
expect( expect(
within(compactCards[0] as HTMLElement).getByRole('button', { within(compactCards[0] as HTMLElement).getByRole('button', {
name: '更换手机号', name: '更换手机号',
@@ -218,8 +262,14 @@ test('account actions open in independent panels instead of inline expansion', a
const changePhoneDialog = screen.getByRole('dialog', { const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号', name: '绑定新手机号',
}); });
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy(); const phoneInput = within(changePhoneDialog).getByLabelText(
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy(); '新手机号',
) as HTMLInputElement;
const codeInput = within(changePhoneDialog).getByLabelText(
'验证码',
) as HTMLInputElement;
expect(phoneInput.className).toContain('platform-text-field');
expect(codeInput.className).toContain('platform-text-field');
}); });
test('nested settings panels keep back navigation without an extra close action', async () => { test('nested settings panels keep back navigation without an extra close action', async () => {
@@ -357,6 +407,18 @@ test('account panel includes merged security devices and audit sections', async
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy(); expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy(); expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
expect(within(accountDialog).getByText('登录成功')).toBeTruthy(); expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
const deviceRow = findNearestClassName(
within(accountDialog).getByText('iPhone 15 Pro'),
'bg-white/72',
);
const auditRow = findNearestClassName(
within(accountDialog).getByText('登录成功'),
'bg-white/72',
);
expect(deviceRow?.className).toContain('rounded-[1rem]');
expect(deviceRow?.className).toContain('px-4 py-3');
expect(auditRow?.className).toContain('rounded-[1rem]');
expect(auditRow?.className).toContain('px-4 py-3');
expect( expect(
within(accountDialog).getByRole('button', { name: '退出登录' }), within(accountDialog).getByRole('button', { name: '退出登录' }),
).toBeTruthy(); ).toBeTruthy();
@@ -375,6 +437,64 @@ test('legacy nested section requests now open the merged account panel', () => {
expect(within(accountDialog).getByText('操作记录')).toBeTruthy(); expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
}); });
test('account panel empty shells reuse PlatformEmptyState subpanel chrome', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const emptyStateMessages = [
'当前没有生效中的安全限制。',
'暂无可展示的登录设备。',
'暂无账号操作记录。',
];
for (const message of emptyStateMessages) {
const shell = findNearestClassName(
within(accountDialog).getByText(message),
'platform-empty-state',
);
expect(shell?.className).toContain('rounded-[1rem]');
expect(shell?.className).toContain('bg-white/74');
expect(shell?.className).toContain('px-4');
expect(shell?.className).toContain('py-3');
}
});
test('account panel loading shells reuse PlatformEmptyState subpanel chrome', async () => {
const user = userEvent.setup();
renderAccountModal({
loadingRiskBlocks: true,
loadingSessions: true,
loadingAuditLogs: true,
});
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const loadingMessages = [
'正在读取安全状态...',
'正在读取当前登录设备...',
'正在读取账号操作记录...',
];
for (const message of loadingMessages) {
const shell = findNearestClassName(
within(accountDialog).getByText(message),
'platform-empty-state',
);
expect(shell?.className).toContain('rounded-[1rem]');
expect(shell?.className).toContain('bg-white/74');
expect(shell?.className).toContain('px-4');
expect(shell?.className).toContain('py-3');
}
});
test('current merged session group hides kick action and shows count', async () => { test('current merged session group hides kick action and shows count', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -392,7 +512,14 @@ test('current merged session group hides kick action and shows count', async ()
await user.click(screen.getByRole('button', { name: /账号与安全/ })); await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' }); const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const sessionCountBadge = within(accountDialog).getByText('2 个会话');
const currentDeviceBadge = within(accountDialog).getByText('当前设备');
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy(); expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
expect(sessionCountBadge.className).toContain('rounded-full');
expect(sessionCountBadge.className).toContain('bg-white/72');
expect(currentDeviceBadge.className).toContain('rounded-full');
expect(currentDeviceBadge.className).toContain('border-emerald-200');
expect( expect(
within(accountDialog).queryByRole('button', { name: '踢下线' }), within(accountDialog).queryByRole('button', { name: '踢下线' }),
).toBeNull(); ).toBeNull();
@@ -419,8 +546,12 @@ test('remote merged session group can be revoked with loading state', async () =
const revokeButton = within(accountDialog).getByRole('button', { const revokeButton = within(accountDialog).getByRole('button', {
name: '处理中...', name: '处理中...',
}) as HTMLButtonElement; }) as HTMLButtonElement;
const loggedInBadge = within(accountDialog).getByText('已登录');
expect(revokeButton.disabled).toBe(true); expect(revokeButton.disabled).toBe(true);
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy(); expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
expect(loggedInBadge.className).toContain('rounded-full');
expect(loggedInBadge.className).toContain('border-emerald-200');
expect(onRevokeSession).not.toHaveBeenCalled(); expect(onRevokeSession).not.toHaveBeenCalled();
}); });

View File

@@ -15,8 +15,17 @@ import type {
AuthSessionSummary, AuthSessionSummary,
AuthUser, AuthUser,
} from '../../services/authService'; } from '../../services/authService';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
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 { PlatformTextField } from '../common/PlatformTextField';
import type { PlatformSettingsSection } from './AuthUiContext'; import type { PlatformSettingsSection } from './AuthUiContext';
import { CaptchaChallengeField } from './CaptchaChallengeField'; import { CaptchaChallengeField } from './CaptchaChallengeField';
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
type AccountModalProps = { type AccountModalProps = {
user: AuthUser; user: AuthUser;
@@ -130,10 +139,13 @@ function SettingsEntryCard({
onClick: (trigger: HTMLButtonElement) => void; onClick: (trigger: HTMLButtonElement) => void;
}) { }) {
return ( return (
<button <PlatformSubpanel
type="button" as="button"
interactive
radius="xl"
padding="md"
onClick={(event) => onClick(event.currentTarget)} onClick={(event) => onClick(event.currentTarget)}
className="platform-subpanel w-full rounded-[1.5rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)]" className="w-full hover:border-[var(--platform-surface-hover-border)]"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
@@ -151,7 +163,20 @@ function SettingsEntryCard({
<div className="mt-3 text-sm text-[var(--platform-text-base)]"> <div className="mt-3 text-sm text-[var(--platform-text-base)]">
{summary} {summary}
</div> </div>
</button> </PlatformSubpanel>
);
}
// 中文注释:账号安全子面板里的空态与轻量加载态共用同一层白底外壳,避免重复拼 flat subpanel 样式。
function AccountSubpanelState({ children }: { children: ReactNode }) {
return (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="py-3 text-center"
>
{children}
</PlatformEmptyState>
); );
} }
@@ -161,6 +186,7 @@ function OverlayPanel({
description, description,
action, action,
standalone = false, standalone = false,
dialog = true,
onBack, onBack,
onClose, onClose,
children, children,
@@ -170,6 +196,7 @@ function OverlayPanel({
description?: string; description?: string;
action?: ReactNode; action?: ReactNode;
standalone?: boolean; standalone?: boolean;
dialog?: boolean;
onBack?: () => void; onBack?: () => void;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
@@ -177,9 +204,9 @@ function OverlayPanel({
const panel = ( const panel = (
<div <div
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6" className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog" role={dialog ? 'dialog' : undefined}
aria-modal="true" aria-modal={dialog ? true : undefined}
aria-label={title} aria-label={dialog ? title : undefined}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }} style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
@@ -204,23 +231,27 @@ function OverlayPanel({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{action} {action}
{onBack ? ( {onBack ? (
<button <PlatformActionButton
type="button"
autoFocus autoFocus
className="platform-button platform-button--ghost min-h-0 gap-1.5 rounded-full px-3 py-1.5 text-xs" tone="ghost"
size="xs"
shape="pill"
className="min-h-0 gap-1.5 px-3 py-1.5"
onClick={onBack} onClick={onBack}
> >
<ArrowLeft className="h-3.5 w-3.5" /> <ArrowLeft className="h-3.5 w-3.5" />
</button> </PlatformActionButton>
) : ( ) : (
<button <PlatformActionButton
type="button" tone="ghost"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs" size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5"
onClick={onClose} onClick={onClose}
> >
</button> </PlatformActionButton>
)} )}
</div> </div>
</div> </div>
@@ -259,10 +290,13 @@ function ThemeOptionCard({
onClick: () => void; onClick: () => void;
}) { }) {
return ( return (
<button <PlatformSubpanel
type="button" as="button"
interactive
radius="xl"
padding="md"
onClick={onClick} onClick={onClick}
className={`platform-subpanel w-full rounded-[1.5rem] p-4 text-left transition ${ className={`w-full ${
active active
? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(112,57,30,0.14)]' ? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(112,57,30,0.14)]'
: 'hover:border-[var(--platform-surface-hover-border)]' : 'hover:border-[var(--platform-surface-hover-border)]'
@@ -275,7 +309,7 @@ function ThemeOptionCard({
<div className="mt-1 text-sm text-[var(--platform-text-base)]"> <div className="mt-1 text-sm text-[var(--platform-text-base)]">
{detail} {detail}
</div> </div>
</button> </PlatformSubpanel>
); );
} }
@@ -467,23 +501,37 @@ export function AccountModal({
}; };
return ( return (
<div <PlatformAuthModalShell
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[70] flex items-end justify-center overflow-hidden px-4 sm:items-center`} title={isDirectAccountMode ? '账号信息' : '设置与账号安全'}
style={{ platformTheme={platformTheme}
onClose={onClose}
closeLabel="关闭账号弹窗"
size="xl"
showHeader={false}
overlaySpacing="none"
zIndexClassName="z-[70]"
overlayClassName="!items-end !justify-center overflow-hidden !px-4 !py-0 sm:!items-center"
overlayStyle={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)', paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)', paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
}} }}
onClick={onClose} authCardClassName={isDirectAccountMode ? '' : undefined}
panelClassName={
isDirectAccountMode
? 'relative !max-w-3xl !rounded-none !bg-transparent !shadow-none'
: 'relative !h-[min(100%,calc(100vh-2rem))] !max-w-5xl !rounded-[28px] !p-5 sm:!p-6'
}
bodyClassName="!flex !min-h-0 !flex-1 !overflow-hidden !p-0"
panelStyle={{
maxHeight: ACCOUNT_MODAL_MAX_HEIGHT,
}}
> >
<div <div
className={ className={
isDirectAccountMode isDirectAccountMode
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden' ? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
: 'platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6' : 'relative flex h-full w-full min-h-0 flex-col overflow-hidden'
} }
role={isDirectAccountMode ? undefined : 'dialog'}
aria-modal={isDirectAccountMode ? undefined : true}
aria-label={isDirectAccountMode ? undefined : '设置与账号安全'}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }} style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
@@ -494,13 +542,15 @@ export function AccountModal({
</div> </div>
</div> </div>
<button <PlatformActionButton
type="button" tone="ghost"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs" size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5"
onClick={onClose} onClick={onClose}
> >
</button> </PlatformActionButton>
</div> </div>
) : null} ) : null}
@@ -552,7 +602,12 @@ export function AccountModal({
/> />
</div> </div>
<div className="platform-subpanel rounded-2xl px-4 py-4"> <PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-4 py-4"
>
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]"> <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
@@ -562,11 +617,15 @@ export function AccountModal({
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'} {platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div> </div>
</div> </div>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]"> <PlatformPillBadge
tone="neutral"
size="xs"
className="px-3 py-1"
>
{themeStatusText} {themeStatusText}
</span> </PlatformPillBadge>
</div> </div>
</div> </PlatformSubpanel>
</div> </div>
</OverlayPanel> </OverlayPanel>
) : null} ) : null}
@@ -575,28 +634,34 @@ export function AccountModal({
<OverlayPanel <OverlayPanel
title="账号信息" title="账号信息"
standalone={isDirectAccountMode} standalone={isDirectAccountMode}
dialog={!isDirectAccountMode}
onBack={isDirectAccountMode ? undefined : closeSectionPanel} onBack={isDirectAccountMode ? undefined : closeSectionPanel}
onClose={onClose} onClose={onClose}
> >
<div data-account-content className="flex min-h-0 flex-col gap-3"> <div data-account-content className="flex min-h-0 flex-col gap-3">
{accountNotice ? ( {accountNotice ? (
<div className="platform-banner platform-banner--success text-sm"> <PlatformStatusMessage tone="success" surface="profile">
{accountNotice} {accountNotice}
</div> </PlatformStatusMessage>
) : null} ) : null}
<div className="grid gap-2.5 sm:grid-cols-2"> <div className="grid gap-2.5 sm:grid-cols-2">
<div <PlatformSubpanel
as="div"
data-account-binding-card data-account-binding-card
className="platform-subpanel rounded-2xl px-3.5 py-3" radius="sm"
padding="none"
className="px-3.5 py-3"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]"> <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div> </div>
<button <PlatformActionButton
type="button" tone="ghost"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]" size="xs"
shape="pill"
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
onClick={(event) => { onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget; changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice(''); setAccountNotice('');
@@ -605,47 +670,59 @@ export function AccountModal({
}} }}
> >
</button> </PlatformActionButton>
</div> </div>
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]"> <div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundPhoneNumber} {boundPhoneNumber}
</div> </div>
</div> </PlatformSubpanel>
<div <PlatformSubpanel
as="div"
data-account-binding-card data-account-binding-card
className="platform-subpanel rounded-2xl px-3.5 py-3" radius="sm"
padding="none"
className="px-3.5 py-3"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]"> <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div> </div>
<button <PlatformActionButton
type="button" tone="ghost"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]" size="xs"
shape="pill"
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
onClick={() => { onClick={() => {
setAccountNotice('更换微信号功能暂未接入。'); setAccountNotice('更换微信号功能暂未接入。');
}} }}
> >
</button> </PlatformActionButton>
</div> </div>
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]"> <div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundWechatDisplayName} {boundWechatDisplayName}
</div> </div>
</div> </PlatformSubpanel>
</div> </div>
<div className="platform-subpanel rounded-2xl px-3.5 py-3"> <PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]"> <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div> </div>
</div> </div>
<button <PlatformActionButton
type="button" tone="ghost"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]" size="xs"
shape="pill"
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
onClick={(event) => { onClick={(event) => {
passwordTriggerRef.current = event.currentTarget; passwordTriggerRef.current = event.currentTarget;
setAccountNotice(''); setAccountNotice('');
@@ -654,38 +731,55 @@ export function AccountModal({
}} }}
> >
</button> </PlatformActionButton>
</div> </div>
</div> </PlatformSubpanel>
<div className="platform-subpanel rounded-2xl px-3.5 py-3"> <PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]"> <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div> </div>
</div> </div>
<button <PlatformActionButton
type="button" tone="ghost"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]" size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5 text-[11px]"
onClick={() => { onClick={() => {
void onRefreshRiskBlocks(); void onRefreshRiskBlocks();
}} }}
> >
</button> </PlatformActionButton>
</div> </div>
<div className="mt-3 grid gap-2.5"> <div className="mt-3 grid gap-2.5">
{loadingRiskBlocks ? ( <PlatformAsyncStatePanel
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]"> isLoading={loadingRiskBlocks}
... loadingState={
</div> <AccountSubpanelState>
) : riskBlocks.length > 0 ? ( ...
riskBlocks.map((block) => ( </AccountSubpanelState>
<div }
isEmpty={riskBlocks.length === 0}
emptyState={
<AccountSubpanelState>
</AccountSubpanelState>
}
>
{riskBlocks.map((block) => (
<PlatformStatusMessage
key={`${block.scopeType}:${block.expiresAt}`} key={`${block.scopeType}:${block.expiresAt}`}
className="platform-banner platform-banner--warning text-sm" tone="warning"
surface="profile"
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span>{block.title}</span> <span>{block.title}</span>
@@ -701,70 +795,95 @@ export function AccountModal({
<div className="mt-2 text-xs leading-5"> <div className="mt-2 text-xs leading-5">
{block.detail} {block.detail}
</div> </div>
<button <PlatformActionButton
type="button" tone="secondary"
className="platform-button platform-button--secondary mt-3 min-h-0 h-9 px-3 text-xs" size="xs"
className="mt-3 h-9 min-h-0 px-3"
onClick={() => { onClick={() => {
void onLiftRiskBlock(block.scopeType); void onLiftRiskBlock(block.scopeType);
}} }}
> >
</button> </PlatformActionButton>
</div> </PlatformStatusMessage>
)) ))}
) : ( </PlatformAsyncStatePanel>
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div> </div>
</div> </PlatformSubpanel>
<div className="platform-subpanel rounded-2xl px-3.5 py-3"> <PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]"> <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div> </div>
</div> </div>
<button <PlatformActionButton
type="button" tone="ghost"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]" size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5 text-[11px]"
onClick={() => { onClick={() => {
void onRefreshSessions(); void onRefreshSessions();
}} }}
> >
</button> </PlatformActionButton>
</div> </div>
<div className="mt-3 grid gap-2.5"> <div className="mt-3 grid gap-2.5">
{loadingSessions ? ( <PlatformAsyncStatePanel
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]"> isLoading={loadingSessions}
... loadingState={
</div> <AccountSubpanelState>
) : sessions.length > 0 ? ( ...
sessions.map((session) => { </AccountSubpanelState>
}
isEmpty={sessions.length === 0}
emptyState={
<AccountSubpanelState>
</AccountSubpanelState>
}
>
{sessions.map((session) => {
const isRevoking = revokingSessionIds.includes( const isRevoking = revokingSessionIds.includes(
session.sessionId, session.sessionId,
); );
return ( return (
<div <PlatformSubpanel
as="div"
key={session.sessionId} key={session.sessionId}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]" surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-base)]"
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span> <span>{session.clientLabel}</span>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{session.sessionCount > 1 ? ( {session.sessionCount > 1 ? (
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]"> <PlatformPillBadge
tone="neutral"
size="xs"
className="px-2.5 py-1 text-[10px]"
>
{session.sessionCount} {session.sessionCount}
</span> </PlatformPillBadge>
) : null} ) : null}
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]"> <PlatformPillBadge
tone="success"
size="xs"
className="px-2.5 py-1 text-[10px]"
>
{session.isCurrent ? '当前设备' : '已登录'} {session.isCurrent ? '当前设备' : '已登录'}
</span> </PlatformPillBadge>
</div> </div>
</div> </div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]"> <div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
@@ -779,56 +898,73 @@ export function AccountModal({
</div> </div>
) : null} ) : null}
{!session.isCurrent ? ( {!session.isCurrent ? (
<button <PlatformActionButton
type="button" tone="danger"
className="platform-button platform-button--danger mt-3 h-9 min-h-0 px-3 text-xs" size="xs"
className="mt-3 h-9 min-h-0 px-3"
disabled={isRevoking} disabled={isRevoking}
onClick={() => { onClick={() => {
void onRevokeSession(session); void onRevokeSession(session);
}} }}
> >
{isRevoking ? '处理中...' : '踢下线'} {isRevoking ? '处理中...' : '踢下线'}
</button> </PlatformActionButton>
) : null} ) : null}
</div> </PlatformSubpanel>
); );
}) })}
) : ( </PlatformAsyncStatePanel>
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div> </div>
</div> </PlatformSubpanel>
<div className="platform-subpanel rounded-2xl px-3.5 py-3"> <PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]"> <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div> </div>
</div> </div>
<button <PlatformActionButton
type="button" tone="ghost"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]" size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5 text-[11px]"
onClick={() => { onClick={() => {
void onRefreshAuditLogs(); void onRefreshAuditLogs();
}} }}
> >
</button> </PlatformActionButton>
</div> </div>
<div className="mt-3 grid gap-2.5"> <div className="mt-3 grid gap-2.5">
{loadingAuditLogs ? ( <PlatformAsyncStatePanel
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]"> isLoading={loadingAuditLogs}
... loadingState={
</div> <AccountSubpanelState>
) : auditLogs.length > 0 ? ( ...
auditLogs.map((log) => ( </AccountSubpanelState>
<div }
isEmpty={auditLogs.length === 0}
emptyState={
<AccountSubpanelState>
</AccountSubpanelState>
}
>
{auditLogs.map((log) => (
<PlatformSubpanel
as="div"
key={log.id} key={log.id}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]" surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-base)]"
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span>{log.title}</span> <span>{log.title}</span>
@@ -844,38 +980,38 @@ export function AccountModal({
IP{log.ipMasked} IP{log.ipMasked}
</div> </div>
) : null} ) : null}
</div> </PlatformSubpanel>
)) ))}
) : ( </PlatformAsyncStatePanel>
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div> </div>
</div> </PlatformSubpanel>
<div <div
data-account-actions data-account-actions
className="grid gap-2.5 pt-1 sm:grid-cols-2" className="grid gap-2.5 pt-1 sm:grid-cols-2"
> >
<button <PlatformActionButton
type="button" tone="ghost"
className="platform-button platform-button--ghost h-10 w-full text-sm" size="sm"
fullWidth
className="h-10"
onClick={() => { onClick={() => {
void onLogout(); void onLogout();
}} }}
> >
退 退
</button> </PlatformActionButton>
<button <PlatformActionButton
type="button" tone="danger"
className="platform-button platform-button--danger h-10 w-full text-sm" size="sm"
fullWidth
className="h-10"
onClick={() => { onClick={() => {
void onLogoutAll(); void onLogoutAll();
}} }}
> >
退 退
</button> </PlatformActionButton>
</div> </div>
</div> </div>
@@ -888,10 +1024,12 @@ export function AccountModal({
onClose={onClose} onClose={onClose}
> >
<div className="grid gap-3"> <div className="grid gap-3">
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
<input
className="platform-input h-11" </PlatformFieldLabel>
<PlatformTextField
className="h-11"
value={phone} value={phone}
inputMode="numeric" inputMode="numeric"
placeholder="13800000000" placeholder="13800000000"
@@ -899,22 +1037,25 @@ export function AccountModal({
/> />
</label> </label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<div className="flex gap-3"> <div className="flex gap-3">
<input <PlatformTextField
className="platform-input h-11 min-w-0 flex-1" className="h-11 min-w-0 flex-1"
value={code} value={code}
inputMode="numeric" inputMode="numeric"
placeholder="输入验证码" placeholder="输入验证码"
onChange={(event) => setCode(event.target.value)} onChange={(event) => setCode(event.target.value)}
/> />
<button <PlatformActionButton
type="button"
disabled={ disabled={
sendingCode || cooldownSeconds > 0 || !phone.trim() sendingCode || cooldownSeconds > 0 || !phone.trim()
} }
className="platform-button platform-button--secondary h-11 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55" tone="secondary"
size="md"
className="h-11 shrink-0"
onClick={() => { onClick={() => {
void (async () => { void (async () => {
setSendingCode(true); setSendingCode(true);
@@ -951,14 +1092,14 @@ export function AccountModal({
: cooldownSeconds > 0 : cooldownSeconds > 0
? `${cooldownSeconds}s` ? `${cooldownSeconds}s`
: '获取验证码'} : '获取验证码'}
</button> </PlatformActionButton>
</div> </div>
</label> </label>
{changePhoneHint ? ( {changePhoneHint ? (
<div className="platform-banner platform-banner--success text-sm"> <PlatformStatusMessage tone="success" surface="profile">
{changePhoneHint} {changePhoneHint}
</div> </PlatformStatusMessage>
) : null} ) : null}
<CaptchaChallengeField <CaptchaChallengeField
@@ -968,15 +1109,15 @@ export function AccountModal({
/> />
{changePhoneError ? ( {changePhoneError ? (
<div className="platform-banner platform-banner--danger text-sm"> <PlatformStatusMessage tone="error" surface="profile">
{changePhoneError} {changePhoneError}
</div> </PlatformStatusMessage>
) : null} ) : null}
<button <PlatformActionButton
type="button"
disabled={changingPhone || !phone.trim() || !code.trim()} disabled={changingPhone || !phone.trim() || !code.trim()}
className="platform-button platform-button--primary h-11 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-60" size="md"
className="h-11"
onClick={() => { onClick={() => {
void (async () => { void (async () => {
setChangingPhone(true); setChangingPhone(true);
@@ -998,7 +1139,7 @@ export function AccountModal({
}} }}
> >
{changingPhone ? '提交中...' : '确认更换手机号'} {changingPhone ? '提交中...' : '确认更换手机号'}
</button> </PlatformActionButton>
</div> </div>
</OverlayPanel> </OverlayPanel>
) : null} ) : null}
@@ -1012,10 +1153,12 @@ export function AccountModal({
onClose={onClose} onClose={onClose}
> >
<div className="grid gap-3"> <div className="grid gap-3">
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
<input
className="platform-input h-11" </PlatformFieldLabel>
<PlatformTextField
className="h-11"
value={currentPassword} value={currentPassword}
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
@@ -1025,10 +1168,12 @@ export function AccountModal({
} }
/> />
</label> </label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
<input
className="platform-input h-11" </PlatformFieldLabel>
<PlatformTextField
className="h-11"
value={newPassword} value={newPassword}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
@@ -1038,15 +1183,16 @@ export function AccountModal({
</label> </label>
{passwordError ? ( {passwordError ? (
<div className="platform-banner platform-banner--danger text-sm"> <PlatformStatusMessage tone="error" surface="profile">
{passwordError} {passwordError}
</div> </PlatformStatusMessage>
) : null} ) : null}
<button <PlatformActionButton
type="button"
disabled={changingPassword || !newPassword.trim()} disabled={changingPassword || !newPassword.trim()}
className="platform-button platform-button--primary h-11 w-full text-sm disabled:cursor-not-allowed disabled:opacity-60" size="md"
fullWidth
className="h-11"
onClick={() => { onClick={() => {
void (async () => { void (async () => {
setChangingPassword(true); setChangingPassword(true);
@@ -1068,13 +1214,13 @@ export function AccountModal({
}} }}
> >
{changingPassword ? '提交中...' : '确认修改密码'} {changingPassword ? '提交中...' : '确认修改密码'}
</button> </PlatformActionButton>
</div> </div>
</OverlayPanel> </OverlayPanel>
) : null} ) : null}
</OverlayPanel> </OverlayPanel>
) : null} ) : null}
</div> </div>
</div> </PlatformAuthModalShell>
); );
} }

View File

@@ -34,12 +34,20 @@ const authMocks = vi.hoisted(() => ({
consumeAuthCallbackResult: vi.fn(), consumeAuthCallbackResult: vi.fn(),
})); }));
vi.mock('../../services/apiClient', () => ({ vi.mock('../../services/apiClient', async () => {
AUTH_STATE_EVENT: 'genarrative-auth-state-changed', const actual =
ensureStoredAccessToken: authMocks.ensureStoredAccessToken, await vi.importActual<typeof import('../../services/apiClient')>(
getStoredAccessToken: authMocks.getStoredAccessToken, '../../services/apiClient',
refreshStoredAccessToken: authMocks.refreshStoredAccessToken, );
}));
return {
...actual,
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
getStoredAccessToken: authMocks.getStoredAccessToken,
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
};
});
vi.mock('../../services/authService', () => ({ vi.mock('../../services/authService', () => ({
authEntry: authMocks.authEntry, authEntry: authMocks.authEntry,
@@ -54,12 +62,14 @@ vi.mock('../../services/authService', () => ({
getCurrentAuthUser: authMocks.getCurrentAuthUser, getCurrentAuthUser: authMocks.getCurrentAuthUser,
getAuthSessions: authMocks.getAuthSessions, getAuthSessions: authMocks.getAuthSessions,
getCaptchaChallengeFromError: vi.fn(() => null), getCaptchaChallengeFromError: vi.fn(() => null),
isWechatMiniProgramWebViewRuntime: authMocks.isWechatMiniProgramWebViewRuntime, isWechatMiniProgramWebViewRuntime:
authMocks.isWechatMiniProgramWebViewRuntime,
liftAuthRiskBlock: vi.fn(), liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode, loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: authMocks.logoutAllAuthSessions, logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser, logoutAuthUser: authMocks.logoutAuthUser,
requestWechatMiniProgramPhoneLogin: authMocks.requestWechatMiniProgramPhoneLogin, requestWechatMiniProgramPhoneLogin:
authMocks.requestWechatMiniProgramPhoneLogin,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode, redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword, resetPassword: authMocks.resetPassword,
revokeAuthSessions: authMocks.revokeAuthSessions, revokeAuthSessions: authMocks.revokeAuthSessions,
@@ -406,8 +416,17 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(dialog).toBeTruthy(); expect(dialog).toBeTruthy();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull(); expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); const phoneInput = within(dialog).getByLabelText(
await user.type(within(dialog).getByLabelText('验证码'), '123456'); '手机号',
) as HTMLInputElement;
const codeInput = within(dialog).getByLabelText(
'验证码',
) as HTMLInputElement;
expect(phoneInput.className).toContain('platform-text-field');
expect(codeInput.className).toContain('platform-text-field');
await user.type(phoneInput, '13800000000');
await user.type(codeInput, '123456');
await acceptLegalConsent(user, dialog); await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' })); await user.click(within(dialog).getByRole('button', { name: '登录' }));
@@ -440,7 +459,9 @@ test('auth gate uses mini program auth bridge instead of opening login modal in
await user.click(await screen.findByRole('button', { name: '进入作品' })); await user.click(await screen.findByRole('button', { name: '进入作品' }));
await waitFor(() => { await waitFor(() => {
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(1); expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(
1,
);
}); });
expect(authMocks.startWechatLogin).not.toHaveBeenCalled(); expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
@@ -476,9 +497,7 @@ test('login modal requires first-time legal consent before sms login', async ()
await user.click( await user.click(
within(dialog).getByRole('button', { name: '《用户协议》' }), within(dialog).getByRole('button', { name: '《用户协议》' }),
); );
expect( expect(await screen.findByRole('dialog', { name: '用户协议' })).toBeTruthy();
await screen.findByRole('dialog', { name: '用户协议' }),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '我知道了' })); await user.click(screen.getByRole('button', { name: '我知道了' }));
expect(legalSwitch.getAttribute('aria-checked')).toBe('false'); expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
@@ -573,6 +592,8 @@ test('auth gate hides register entry and opens invite modal for new sms account'
await user.click(screen.getByRole('button', { name: '进入作品' })); await user.click(screen.getByRole('button', { name: '进入作品' }));
const dialog = await screen.findByRole('dialog', { name: '账号入口' }); const dialog = await screen.findByRole('dialog', { name: '账号入口' });
expect(dialog.className).toContain('platform-auth-card');
expect(dialog.className).toContain('platform-modal-shell');
expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull(); expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull();
expect(within(dialog).queryByLabelText('邀请码')).toBeNull(); expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
@@ -590,9 +611,13 @@ test('auth gate hides register entry and opens invite modal for new sms account'
const inviteDialog = await screen.findByRole('dialog', { const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码', name: '请填写邀请码',
}); });
expect( expect(inviteDialog.className).toContain('platform-auth-card');
(within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value, expect(inviteDialog.className).toContain('platform-modal-shell');
).toBe('SPRING2026'); const inviteCodeInput = within(inviteDialog).getByLabelText(
'邀请码',
) as HTMLInputElement;
expect(inviteCodeInput.value).toBe('SPRING2026');
expect(inviteCodeInput.className).toContain('platform-text-field');
expect( expect(
within(inviteDialog).getByRole('button', { name: '提交' }), within(inviteDialog).getByRole('button', { name: '提交' }),
).toBeTruthy(); ).toBeTruthy();
@@ -778,6 +803,8 @@ test('login modal resets draft state every time it is reopened', async () => {
await user.click(await screen.findByRole('button', { name: '进入作品' })); await user.click(await screen.findByRole('button', { name: '进入作品' }));
const firstDialog = screen.getByRole('dialog', { name: '账号入口' }); const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
expect(firstDialog.className).toContain('platform-auth-card');
expect(firstDialog.className).toContain('platform-modal-shell');
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000'); await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
await user.click( await user.click(
within(firstDialog).getByRole('button', { name: '获取验证码' }), within(firstDialog).getByRole('button', { name: '获取验证码' }),
@@ -790,7 +817,11 @@ test('login modal resets draft state every time it is reopened', async () => {
).toBeTruthy(); ).toBeTruthy();
await user.type(within(firstDialog).getByLabelText('验证码'), '123456'); await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' })); await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd'); const passwordInput = within(firstDialog).getByLabelText(
'密码',
) as HTMLInputElement;
expect(passwordInput.className).toContain('platform-text-field');
await user.type(passwordInput, 'passw0rd');
await user.click( await user.click(
within(firstDialog).getByRole('button', { name: '忘记密码' }), within(firstDialog).getByRole('button', { name: '忘记密码' }),
); );
@@ -849,6 +880,14 @@ test('auth gate separates sms and password login by tabs', async () => {
.getByRole('tab', { name: '短信登录' }) .getByRole('tab', { name: '短信登录' })
.getAttribute('aria-selected'), .getAttribute('aria-selected'),
).toBe('true'); ).toBe('true');
expect(
within(dialog)
.getByRole('tab', { name: '短信登录' })
.className.includes('h-12'),
).toBe(true);
expect(
within(dialog).getByRole('tablist', { name: '登录方式' }).className,
).toContain('bg-transparent');
expect(within(dialog).queryByLabelText('密码')).toBeNull(); expect(within(dialog).queryByLabelText('密码')).toBeNull();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' })); await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
@@ -903,7 +942,9 @@ test('auth gate revokes merged session group and refreshes sessions', async () =
const accountDialog = await screen.findByRole('dialog', { const accountDialog = await screen.findByRole('dialog', {
name: '账号信息', name: '账号信息',
}); });
await user.click(within(accountDialog).getByRole('button', { name: '踢下线' })); await user.click(
within(accountDialog).getByRole('button', { name: '踢下线' }),
);
await waitFor(() => { await waitFor(() => {
expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([ expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([
@@ -945,7 +986,10 @@ test('auth gate clears account state after password change', async () => {
const passwordDialog = await screen.findByRole('dialog', { const passwordDialog = await screen.findByRole('dialog', {
name: '修改登录密码', name: '修改登录密码',
}); });
await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1'); await user.type(
within(passwordDialog).getByLabelText('当前密码'),
'oldpass1',
);
await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1'); await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1');
await user.click( await user.click(
within(passwordDialog).getByRole('button', { name: '确认修改密码' }), within(passwordDialog).getByRole('button', { name: '确认修改密码' }),

View File

@@ -45,6 +45,7 @@ import {
setStoredLastLoginPhone, setStoredLastLoginPhone,
startWechatLogin, startWechatLogin,
} from '../../services/authService'; } from '../../services/authService';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { AccountModal } from './AccountModal'; import { AccountModal } from './AccountModal';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext'; import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen'; import { BindPhoneScreen } from './BindPhoneScreen';
@@ -757,15 +758,14 @@ export function AuthGate({ children }: AuthGateProps) {
<div className="mt-3 text-sm leading-6 text-[var(--platform-text-base)]"> <div className="mt-3 text-sm leading-6 text-[var(--platform-text-base)]">
{error || '账号恢复失败,请刷新页面后重试。'} {error || '账号恢复失败,请刷新页面后重试。'}
</div> </div>
<button <PlatformActionButton
type="button" className="mt-5"
className="platform-button platform-button--primary mt-5"
onClick={() => { onClick={() => {
window.location.reload(); window.location.reload();
}} }}
> >
</button> </PlatformActionButton>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,59 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { AuthUser } from '../../services/authService';
import { BindPhoneScreen } from './BindPhoneScreen';
const baseUser: AuthUser = {
id: 'user-1',
displayName: '微信旅人',
avatarUrl: null,
publicUserCode: 'user-bind-phone',
phoneNumberMasked: null,
loginMethod: 'wechat',
bindingStatus: 'pending_bind_phone',
wechatBound: true,
};
test('绑定手机号表单复用平台输入和字段标题', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<BindPhoneScreen
user={baseUser}
platformTheme="light"
sendingCode={false}
binding={false}
error=""
captchaChallenge={null}
onSendCode={vi.fn().mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
})}
onSubmit={onSubmit}
onLogout={vi.fn().mockResolvedValue(undefined)}
/>,
);
const phoneInput = screen.getByLabelText('手机号') as HTMLInputElement;
const codeInput = screen.getByLabelText('验证码') as HTMLInputElement;
expect(phoneInput.className).toContain('platform-text-field');
expect(codeInput.className).toContain('platform-text-field');
expect(screen.getByText('手机号').className).toContain(
'text-[var(--platform-text-strong)]',
);
expect(screen.getByText('当前登录身份:微信旅人').className).toContain(
'platform-subpanel',
);
await user.type(phoneInput, '13800000000');
await user.type(codeInput, '123456');
await user.click(screen.getByRole('button', { name: '绑定手机号并进入游戏' }));
expect(onSubmit).toHaveBeenCalledWith('13800000000', '123456');
});

View File

@@ -2,6 +2,11 @@ import { useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime'; import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService'; import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import { CaptchaChallengeField } from './CaptchaChallengeField'; import { CaptchaChallengeField } from './CaptchaChallengeField';
type BindPhoneScreenProps = { type BindPhoneScreenProps = {
@@ -74,9 +79,14 @@ export function BindPhoneScreen({
<p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]"> <p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
</p> </p>
<div className="platform-subpanel mt-8 rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]"> <PlatformSubpanel
as="div"
radius="sm"
padding="md"
className="mt-8 text-sm text-[var(--platform-text-base)]"
>
{user.displayName} {user.displayName}
</div> </PlatformSubpanel>
</div> </div>
<form <form
@@ -86,10 +96,11 @@ export function BindPhoneScreen({
void onSubmit(phone, code); void onSubmit(phone, code);
}} }}
> >
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
<input
className="platform-input" </PlatformFieldLabel>
<PlatformTextField
autoComplete="tel" autoComplete="tel"
inputMode="numeric" inputMode="numeric"
value={phone} value={phone}
@@ -98,20 +109,23 @@ export function BindPhoneScreen({
/> />
</label> </label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<div className="flex gap-3"> <div className="flex gap-3">
<input <PlatformTextField
className="platform-input min-w-0 flex-1" className="min-w-0 flex-1"
inputMode="numeric" inputMode="numeric"
value={code} value={code}
onChange={(event) => setCode(event.target.value)} onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码" placeholder="输入验证码"
/> />
<button <PlatformActionButton
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()} disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55" tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => { onClick={() => {
void (async () => { void (async () => {
try { try {
@@ -135,14 +149,14 @@ export function BindPhoneScreen({
: cooldownSeconds > 0 : cooldownSeconds > 0
? `${cooldownSeconds}s` ? `${cooldownSeconds}s`
: '获取验证码'} : '获取验证码'}
</button> </PlatformActionButton>
</div> </div>
</label> </label>
{hint ? ( {hint ? (
<div className="platform-banner platform-banner--success text-sm"> <PlatformStatusMessage tone="success" surface="profile">
{hint} {hint}
</div> </PlatformStatusMessage>
) : null} ) : null}
<CaptchaChallengeField <CaptchaChallengeField
@@ -152,28 +166,29 @@ export function BindPhoneScreen({
/> />
{error ? ( {error ? (
<div className="platform-banner platform-banner--danger text-sm"> <PlatformStatusMessage tone="error" surface="profile">
{error} {error}
</div> </PlatformStatusMessage>
) : null} ) : null}
<button <PlatformActionButton
type="submit" type="submit"
disabled={binding || !phone.trim() || !code.trim()} disabled={binding || !phone.trim() || !code.trim()}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60" size="lg"
> >
{binding ? '正在绑定...' : '绑定手机号并进入游戏'} {binding ? '正在绑定...' : '绑定手机号并进入游戏'}
</button> </PlatformActionButton>
<button <PlatformActionButton
type="button" tone="ghost"
className="platform-button platform-button--ghost h-11 px-4 text-sm" size="md"
className="h-11"
onClick={() => { onClick={() => {
void onLogout(); void onLogout();
}} }}
> >
</button> </PlatformActionButton>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,53 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { CaptchaChallengeField } from './CaptchaChallengeField';
const CAPTCHA_CHALLENGE = {
challengeId: 'captcha-1',
promptText: '请输入图中字符。',
imageDataUrl: 'data:image/png;base64,ZmFrZQ==',
expiresInSeconds: 120,
};
test('does not render without a captcha challenge', () => {
const { container } = render(
<CaptchaChallengeField
challenge={null}
answer=""
onAnswerChange={vi.fn()}
/>,
);
expect(container.firstChild).toBeNull();
});
test('reuses platform media frame and text field chrome', async () => {
const user = userEvent.setup();
const handleAnswerChange = vi.fn();
render(
<CaptchaChallengeField
challenge={CAPTCHA_CHALLENGE}
answer=""
onAnswerChange={handleAnswerChange}
/>,
);
const image = screen.getByAltText('图形验证码');
const imageFrame = image.closest('.platform-media-frame');
const input = screen.getByLabelText('图形验证码答案');
expect(screen.getByText('请输入图中字符。')).toBeTruthy();
expect(imageFrame?.className).toContain('platform-media-frame');
expect(imageFrame?.className).toContain('bg-white/68');
expect(input.className).toContain('border-[var(--platform-subpanel-border)]');
expect(input.className).toContain('h-11');
await user.type(input, '7');
expect(handleAnswerChange).toHaveBeenLastCalledWith('7');
});

View File

@@ -1,4 +1,7 @@
import type { AuthCaptchaChallenge } from '../../services/authService'; import type { AuthCaptchaChallenge } from '../../services/authService';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
type CaptchaChallengeFieldProps = { type CaptchaChallengeFieldProps = {
challenge: AuthCaptchaChallenge | null; challenge: AuthCaptchaChallenge | null;
@@ -16,19 +19,28 @@ export function CaptchaChallengeField({
} }
return ( return (
<div className="platform-banner platform-banner--info grid gap-3"> <PlatformStatusMessage
tone="info"
surface="profile"
className="grid gap-3 rounded-2xl"
>
<div className="text-sm leading-6">{challenge.promptText}</div> <div className="text-sm leading-6">{challenge.promptText}</div>
<img <PlatformMediaFrame
src={challenge.imageDataUrl} src={challenge.imageDataUrl}
alt="图形验证码" alt="图形验证码"
className="platform-subpanel h-14 w-40 rounded-2xl object-cover" fallbackLabel="图形验证码"
aspect="auto"
surface="soft"
className="h-14 w-40"
/> />
<input <PlatformTextField
className="platform-input h-11"
value={answer} value={answer}
aria-label="图形验证码答案"
placeholder="输入图形验证码" placeholder="输入图形验证码"
density="compact"
className="h-11"
onChange={(event) => onAnswerChange(event.target.value)} onChange={(event) => onAnswerChange(event.target.value)}
/> />
</div> </PlatformStatusMessage>
); );
} }

View File

@@ -1,4 +1,4 @@
import { Check, X } from 'lucide-react'; import { Check } from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react'; import { type ReactNode, useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime'; import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
@@ -7,9 +7,7 @@ import type {
AuthLoginMethod, AuthLoginMethod,
} from '../../services/authService'; } from '../../services/authService';
import { getStoredLastLoginPhone } from '../../services/authService'; import { getStoredLastLoginPhone } from '../../services/authService';
import { import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
isWechatMiniProgramWebViewRuntime,
} from '../../services/authService';
import { LegalDocumentModal } from '../common/LegalDocumentModal'; import { LegalDocumentModal } from '../common/LegalDocumentModal';
import { import {
getLegalDocument, getLegalDocument,
@@ -17,11 +15,23 @@ import {
persistLegalConsent, persistLegalConsent,
readStoredLegalConsent, readStoredLegalConsent,
} from '../common/legalDocuments'; } from '../common/legalDocuments';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { CaptchaChallengeField } from './CaptchaChallengeField'; import { CaptchaChallengeField } from './CaptchaChallengeField';
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
type SmsScene = 'login' | 'reset_password'; type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password'; type LoginTab = 'phone' | 'password';
const LOGIN_TAB_ITEMS: Array<{ id: LoginTab; label: string }> = [
{ id: 'phone', label: '短信登录' },
{ id: 'password', label: '密码登录' },
];
type LoginScreenProps = { type LoginScreenProps = {
isOpen: boolean; isOpen: boolean;
platformTheme: PlatformTheme; platformTheme: PlatformTheme;
@@ -181,81 +191,54 @@ export function LoginScreen({
return ( return (
<> <>
<div <PlatformAuthModalShell
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`} title={isResetPanelOpen ? '重置密码' : '账号入口'}
onClick={onClose} platformTheme={platformTheme}
onClose={onClose}
closeLabel="关闭登录弹窗"
panelClassName="!max-w-md"
> >
<div {isResetPanelOpen ? (
role="dialog" <PasswordResetPanel
aria-modal="true" phone={resetPhone}
aria-labelledby="auth-login-dialog-title" code={resetCode}
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]" password={resetPasswordValue}
onClick={(event) => event.stopPropagation()} sendingCode={sendingCode}
> loggingIn={loggingIn}
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4"> cooldownSeconds={resetCooldownSeconds}
<div error={error}
id="auth-login-dialog-title" onPhoneChange={setResetPhone}
className="text-lg font-semibold text-[var(--platform-text-strong)]" onCodeChange={setResetCode}
> onPasswordChange={setResetPasswordValue}
{isResetPanelOpen ? '重置密码' : '账号入口'} onBack={() => setIsResetPanelOpen(false)}
</div> onSendCode={async () => {
<button const result = await onSendCode(resetPhone, 'reset_password');
type="button" setResetCooldownSeconds(result.cooldownSeconds);
onClick={onClose} }}
className="platform-icon-button p-2" onSubmit={() =>
aria-label="关闭登录弹窗" onResetPassword(resetPhone, resetCode, resetPasswordValue)
> }
<X className="h-4 w-4" /> />
</button> ) : (
</div> <div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled ? (
{isResetPanelOpen ? ( <PlatformSegmentedTabs
<PasswordResetPanel items={
phone={resetPhone} passwordLoginEnabled
code={resetCode} ? LOGIN_TAB_ITEMS
password={resetPasswordValue} : LOGIN_TAB_ITEMS.slice(0, 1)
sendingCode={sendingCode} }
loggingIn={loggingIn} activeId={activeLoginTab}
cooldownSeconds={resetCooldownSeconds} onChange={setActiveLoginTab}
error={error} columns={passwordLoginEnabled ? 'two' : 'one'}
onPhoneChange={setResetPhone} frame="bare"
onCodeChange={setResetCode} surface="transparent"
onPasswordChange={setResetPasswordValue} tone="underline"
onBack={() => setIsResetPanelOpen(false)} size="tab"
onSendCode={async () => { semantics="tabs"
const result = await onSendCode(resetPhone, 'reset_password'); ariaLabel="登录方式"
setResetCooldownSeconds(result.cooldownSeconds); />
}} ) : null}
onSubmit={() =>
onResetPassword(resetPhone, resetCode, resetPasswordValue)
}
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled ? (
<div
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
}`}
role="tablist"
aria-label="登录方式"
>
<LoginTabButton
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
>
</LoginTabButton>
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
) : null}
</div>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? ( {passwordLoginEnabled && activeLoginTab === 'password' ? (
<form <form
@@ -273,10 +256,11 @@ export function LoginScreen({
void onPasswordSubmit(phone, password); void onPasswordSubmit(phone, password);
}} }}
> >
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
<input
className="platform-input" </PlatformFieldLabel>
<PlatformTextField
autoComplete="tel" autoComplete="tel"
inputMode="numeric" inputMode="numeric"
value={phone} value={phone}
@@ -284,10 +268,11 @@ export function LoginScreen({
placeholder="13800000000" placeholder="13800000000"
/> />
</label> </label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
<input
className="platform-input" </PlatformFieldLabel>
<PlatformTextField
autoComplete="current-password" autoComplete="current-password"
type="password" type="password"
value={password} value={password}
@@ -300,7 +285,7 @@ export function LoginScreen({
{legalConsentRow} {legalConsentRow}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <PlatformActionButton
type="submit" type="submit"
disabled={ disabled={
submitDisabled || submitDisabled ||
@@ -308,10 +293,10 @@ export function LoginScreen({
!password.trim() || !password.trim() ||
!legalConsentChecked !legalConsentChecked
} }
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60" size="lg"
> >
{loggingIn ? '登录中' : '登录'} {loggingIn ? '登录中' : '登录'}
</button> </PlatformActionButton>
<button <button
type="button" type="button"
className="self-end text-sm text-[var(--platform-accent)]" className="self-end text-sm text-[var(--platform-accent)]"
@@ -370,14 +355,17 @@ export function LoginScreen({
!phoneLoginEnabled && !phoneLoginEnabled &&
!wechatLoginEnabled && !wechatLoginEnabled &&
!miniProgramRuntime ? ( !miniProgramRuntime ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]"> <PlatformEmptyState
surface="subpanel"
size="compact"
className="px-4 py-4"
>
</div> </PlatformEmptyState>
) : null} ) : null}
</div> </div>
)} )}
</div> </PlatformAuthModalShell>
</div>
<LegalDocumentModal <LegalDocumentModal
document={activeLegalDocument} document={activeLegalDocument}
open={Boolean(activeLegalDocument)} open={Boolean(activeLegalDocument)}
@@ -439,13 +427,7 @@ function LegalConsentRow({
); );
} }
function LegalLink({ function LegalLink({ label, onClick }: { label: string; onClick: () => void }) {
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return ( return (
<button <button
type="button" type="button"
@@ -457,35 +439,6 @@ function LegalLink({
); );
} }
function LoginTabButton({
active,
children,
onClick,
}: {
active: boolean;
children: string;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
active
? 'text-[var(--platform-text-strong)]'
: 'text-[var(--platform-text-muted)]'
}`}
onClick={onClick}
>
<span>{children}</span>
{active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</button>
);
}
function PhoneCodeForm({ function PhoneCodeForm({
phone, phone,
code, code,
@@ -546,10 +499,11 @@ function PhoneCodeForm({
}} }}
> >
{showPhoneField ? ( {showPhoneField ? (
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
<input
className="platform-input" </PlatformFieldLabel>
<PlatformTextField
autoComplete="tel" autoComplete="tel"
inputMode="numeric" inputMode="numeric"
value={phone} value={phone}
@@ -559,20 +513,23 @@ function PhoneCodeForm({
</label> </label>
) : null} ) : null}
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<div className="flex gap-3"> <div className="flex gap-3">
<input <PlatformTextField
className="platform-input min-w-0 flex-1" className="min-w-0 flex-1"
inputMode="numeric" inputMode="numeric"
value={code} value={code}
onChange={(event) => onCodeChange(event.target.value)} onChange={(event) => onCodeChange(event.target.value)}
placeholder="输入验证码" placeholder="输入验证码"
/> />
<button <PlatformActionButton
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()} disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55" tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()} onClick={() => void onSendCode()}
> >
{sendingCode {sendingCode
@@ -580,7 +537,7 @@ function PhoneCodeForm({
: cooldownSeconds > 0 : cooldownSeconds > 0
? `${cooldownSeconds}s` ? `${cooldownSeconds}s`
: '获取验证码'} : '获取验证码'}
</button> </PlatformActionButton>
</div> </div>
</label> </label>
@@ -594,13 +551,9 @@ function PhoneCodeForm({
{error ? <ErrorBanner message={error} /> : null} {error ? <ErrorBanner message={error} /> : null}
{legalConsentNode} {legalConsentNode}
<button <PlatformActionButton type="submit" disabled={submitBlocked} size="lg">
type="submit"
disabled={submitBlocked}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '处理中' : submitLabel} {loggingIn ? '处理中' : submitLabel}
</button> </PlatformActionButton>
</form> </form>
); );
} }
@@ -642,10 +595,11 @@ function PasswordResetPanel({
void onSubmit(); void onSubmit();
}} }}
> >
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
<input
className="platform-input" </PlatformFieldLabel>
<PlatformTextField
autoComplete="tel" autoComplete="tel"
inputMode="numeric" inputMode="numeric"
value={phone} value={phone}
@@ -653,20 +607,23 @@ function PasswordResetPanel({
placeholder="13800000000" placeholder="13800000000"
/> />
</label> </label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<div className="flex gap-3"> <div className="flex gap-3">
<input <PlatformTextField
className="platform-input min-w-0 flex-1" className="min-w-0 flex-1"
inputMode="numeric" inputMode="numeric"
value={code} value={code}
onChange={(event) => onCodeChange(event.target.value)} onChange={(event) => onCodeChange(event.target.value)}
placeholder="输入验证码" placeholder="输入验证码"
/> />
<button <PlatformActionButton
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()} disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55" tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()} onClick={() => void onSendCode()}
> >
{sendingCode {sendingCode
@@ -674,13 +631,14 @@ function PasswordResetPanel({
: cooldownSeconds > 0 : cooldownSeconds > 0
? `${cooldownSeconds}s` ? `${cooldownSeconds}s`
: '获取验证码'} : '获取验证码'}
</button> </PlatformActionButton>
</div> </div>
</label> </label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2">
<span></span> <PlatformFieldLabel variant="form" className="mb-0">
<input
className="platform-input" </PlatformFieldLabel>
<PlatformTextField
autoComplete="new-password" autoComplete="new-password"
type="password" type="password"
value={password} value={password}
@@ -692,22 +650,18 @@ function PasswordResetPanel({
{error ? <ErrorBanner message={error} /> : null} {error ? <ErrorBanner message={error} /> : null}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <PlatformActionButton tone="secondary" size="lg" onClick={onBack}>
type="button"
className="platform-button platform-button--secondary h-12 px-4 text-base"
onClick={onBack}
>
</button> </PlatformActionButton>
<button <PlatformActionButton
type="submit" type="submit"
disabled={ disabled={
loggingIn || !phone.trim() || !code.trim() || !password.trim() loggingIn || !phone.trim() || !code.trim() || !password.trim()
} }
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60" size="lg"
> >
{loggingIn ? '处理中' : '重置密码'} {loggingIn ? '处理中' : '重置密码'}
</button> </PlatformActionButton>
</div> </div>
</form> </form>
); );
@@ -723,29 +677,29 @@ function WechatButton({
onClick: () => Promise<void>; onClick: () => Promise<void>;
}) { }) {
return ( return (
<button <PlatformActionButton
type="button"
disabled={loading || disabled} disabled={loading || disabled}
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60" tone="secondary"
size="lg"
onClick={() => void onClick()} onClick={() => void onClick()}
> >
{loading ? '跳转中' : '微信登录'} {loading ? '跳转中' : '微信登录'}
</button> </PlatformActionButton>
); );
} }
function ErrorBanner({ message }: { message: string }) { function ErrorBanner({ message }: { message: string }) {
return ( return (
<div className="platform-banner platform-banner--danger text-sm"> <PlatformStatusMessage tone="error" surface="profile">
{message} {message}
</div> </PlatformStatusMessage>
); );
} }
function SuccessBanner({ message }: { message: string }) { function SuccessBanner({ message }: { message: string }) {
return ( return (
<div className="platform-banner platform-banner--success text-sm"> <PlatformStatusMessage tone="success" surface="profile">
{message} {message}
</div> </PlatformStatusMessage>
); );
} }

View File

@@ -0,0 +1,85 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
test('renders auth modal shell with platform theme and auth card chrome', () => {
const onClose = vi.fn();
render(
<PlatformAuthModalShell
title="账号入口"
platformTheme="light"
onClose={onClose}
closeLabel="关闭登录弹窗"
panelClassName="!max-w-md"
>
<div></div>
</PlatformAuthModalShell>,
);
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');
expect(dialog.className).toContain('platform-modal-shell');
expect(dialog.className).toContain('platform-auth-card');
expect(dialog.className).toContain('!max-w-md');
expect(within(dialog).getByText('登录表单')).toBeTruthy();
fireEvent.click(dialog.parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
});
test('keeps escape disabled for auth flows', () => {
const onClose = vi.fn();
render(
<PlatformAuthModalShell
title="请填写邀请码"
platformTheme="dark"
onClose={onClose}
closeLabel="取消填写邀请码"
zIndexClassName="z-[130]"
>
<div></div>
</PlatformAuthModalShell>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '取消填写邀请码' })).toBeTruthy();
});
test('allows account shell callers to own overlay spacing and panel size', () => {
render(
<PlatformAuthModalShell
title="账号信息"
platformTheme="light"
onClose={vi.fn()}
closeLabel="关闭账号弹窗"
size="xl"
showHeader={false}
overlaySpacing="none"
overlayClassName="!items-end"
overlayStyle={{ paddingTop: '12px' }}
authCardClassName=""
panelClassName="!max-w-3xl !bg-transparent"
bodyClassName="!p-0"
>
<div></div>
</PlatformAuthModalShell>,
);
const dialog = screen.getByRole('dialog', { name: '账号信息' });
const overlay = dialog.parentElement as HTMLElement;
expect(overlay.className).toContain('platform-theme--light');
expect(overlay.className).not.toContain('!px-3');
expect(overlay.style.paddingTop).toBe('12px');
expect(dialog.className).toContain('!max-w-3xl');
expect(dialog.className).not.toContain('platform-auth-card');
expect(within(dialog).queryByRole('button', { name: '关闭账号弹窗' })).toBeNull();
});

View File

@@ -0,0 +1,80 @@
import type { CSSProperties, ReactNode } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import { UnifiedModal } from '../common/UnifiedModal';
type PlatformAuthModalShellProps = {
title: string;
platformTheme: PlatformTheme;
onClose: () => void;
children: ReactNode;
closeLabel: string;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
showHeader?: boolean;
overlaySpacing?: 'default' | 'none';
zIndexClassName?: string;
overlayClassName?: string;
overlayStyle?: CSSProperties;
authCardClassName?: string;
panelClassName?: string;
bodyClassName?: string;
panelStyle?: CSSProperties;
};
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
/**
* 认证入口弹窗共享壳层。
* 这里只统一主题遮罩、auth card、标题栏和关闭按钮登录 / 邀请码表单状态仍留在各自业务组件。
*/
export function PlatformAuthModalShell({
title,
platformTheme,
onClose,
children,
closeLabel,
size = 'sm',
showHeader = true,
overlaySpacing = 'default',
zIndexClassName = 'z-[120]',
overlayClassName,
overlayStyle,
authCardClassName = 'platform-auth-card !rounded-[2rem] sm:!rounded-[2rem]',
panelClassName,
bodyClassName = '!p-0',
panelStyle,
}: PlatformAuthModalShellProps) {
return (
<UnifiedModal
open
title={title}
onClose={onClose}
closeLabel={closeLabel}
closeVariant="platformIcon"
closeOnBackdrop
closeOnEscape={false}
portal={false}
size={size}
showHeader={showHeader}
zIndexClassName={zIndexClassName}
overlayClassName={joinClassNames(
`platform-theme platform-theme--${platformTheme} text-[var(--platform-text-strong)]`,
overlaySpacing === 'default' && '!px-3 !py-4 sm:!p-4',
overlayClassName,
)}
overlayStyle={overlayStyle}
panelClassName={joinClassNames(
authCardClassName,
panelClassName,
)}
headerClassName="!items-center !px-5 !py-4"
titleClassName="text-lg font-semibold text-[var(--platform-text-strong)]"
bodyClassName={bodyClassName}
panelStyle={panelStyle}
>
{children}
</UnifiedModal>
);
}

View File

@@ -1,7 +1,11 @@
import { X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime'; import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
type RegistrationInviteModalProps = { type RegistrationInviteModalProps = {
isOpen: boolean; isOpen: boolean;
@@ -45,71 +49,48 @@ export function RegistrationInviteModal({
} }
return ( return (
<div <PlatformAuthModalShell
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[130] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`} title="请填写邀请码"
onClick={onClose} platformTheme={platformTheme}
onClose={onClose}
closeLabel="取消填写邀请码"
zIndexClassName="z-[130]"
panelClassName="!max-w-sm"
> >
<div <form
role="dialog" className="flex flex-col gap-4 px-5 py-5"
aria-modal="true" onSubmit={(event) => {
aria-labelledby="registration-invite-dialog-title" event.preventDefault();
className="platform-auth-card w-full max-w-sm overflow-hidden rounded-[2rem]" if (!normalizedInviteCode) {
onClick={(event) => event.stopPropagation()} onClose();
return;
}
void onSubmit(normalizedInviteCode);
}}
> >
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4"> <label className="grid gap-2">
<div <PlatformFieldLabel variant="form" className="mb-0">
id="registration-invite-dialog-title"
className="text-lg font-semibold text-[var(--platform-text-strong)]" </PlatformFieldLabel>
> <PlatformTextField
autoComplete="off"
</div> value={inviteCode}
<button onChange={(event) => setInviteCode(event.target.value)}
type="button" placeholder="邀请码"
onClick={onClose} />
className="platform-icon-button p-2" </label>
aria-label="取消填写邀请码"
>
<X className="h-4 w-4" />
</button>
</div>
<form
className="flex flex-col gap-4 px-5 py-5"
onSubmit={(event) => {
event.preventDefault();
if (!normalizedInviteCode) {
onClose();
return;
}
void onSubmit(normalizedInviteCode); {error ? (
}} <PlatformStatusMessage tone="error" surface="profile">
> {error}
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> </PlatformStatusMessage>
<span></span> ) : null}
<input
className="platform-input"
autoComplete="off"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
placeholder="邀请码"
/>
</label>
{error ? ( <PlatformActionButton type="submit" disabled={submitting} size="lg">
<div className="platform-banner platform-banner--danger text-sm"> {submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
{error} </PlatformActionButton>
</div> </form>
) : null} </PlatformAuthModalShell>
<button
type="submit"
disabled={submitting}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
</button>
</form>
</div>
</div>
); );
} }

View File

@@ -11,10 +11,19 @@ describe('BarkBattleConfigEditor', () => {
const onPreview = vi.fn(); const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />); render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy(); expect(
expect(screen.getByText('轻配置')).toBeTruthy(); screen.getByRole('heading', { name: '汪汪声浪大作战' }),
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场'); ).toBeTruthy();
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal'); expect(screen.getByText('轻配置').className).toContain('rounded-full');
expect(screen.getByText('轻配置').className).toContain(
'border-emerald-200',
);
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe(
'我的声浪竞技场',
);
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe(
'normal',
);
expect(screen.queryByLabelText('资源 URL')).toBeNull(); expect(screen.queryByLabelText('资源 URL')).toBeNull();
expect(screen.queryByLabelText('玩家图片 URL')).toBeNull(); expect(screen.queryByLabelText('玩家图片 URL')).toBeNull();
expect(screen.queryByLabelText('对手图片 URL')).toBeNull(); expect(screen.queryByLabelText('对手图片 URL')).toBeNull();
@@ -27,7 +36,10 @@ describe('BarkBattleConfigEditor', () => {
await userEvent.clear(screen.getByLabelText('作品标题')); await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.type(screen.getByLabelText('作品标题'), '狗狗冠军杯'); await userEvent.type(screen.getByLabelText('作品标题'), '狗狗冠军杯');
await userEvent.clear(screen.getByLabelText('主题/场景描述')); await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.type(screen.getByLabelText('主题/场景描述'), '霓虹公园声浪擂台'); await userEvent.type(
screen.getByLabelText('主题/场景描述'),
'霓虹公园声浪擂台',
);
await userEvent.clear(screen.getByLabelText('玩家形象描述')); await userEvent.clear(screen.getByLabelText('玩家形象描述'));
await userEvent.type(screen.getByLabelText('玩家形象描述'), '红围巾柴犬'); await userEvent.type(screen.getByLabelText('玩家形象描述'), '红围巾柴犬');
await userEvent.clear(screen.getByLabelText('对手形象描述')); await userEvent.clear(screen.getByLabelText('对手形象描述'));
@@ -55,8 +67,10 @@ describe('BarkBattleConfigEditor', () => {
const onPreview = vi.fn(); const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />); render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
const defaultWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement) const defaultWords = (
.value.split(/\n+/u) screen.getByLabelText('拟声词') as HTMLTextAreaElement
).value
.split(/\n+/u)
.map((word) => word.trim()) .map((word) => word.trim())
.filter(Boolean); .filter(Boolean);
@@ -77,8 +91,10 @@ describe('BarkBattleConfigEditor', () => {
await userEvent.clear(screen.getByLabelText('对手形象描述')); await userEvent.clear(screen.getByLabelText('对手形象描述'));
await userEvent.type(screen.getByLabelText('对手形象描述'), '机器人拳手'); await userEvent.type(screen.getByLabelText('对手形象描述'), '机器人拳手');
const updatedWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement) const updatedWords = (
.value.split(/\n+/u) screen.getByLabelText('拟声词') as HTMLTextAreaElement
).value
.split(/\n+/u)
.map((word) => word.trim()) .map((word) => word.trim())
.filter(Boolean); .filter(Boolean);
expect(updatedWords).toEqual( expect(updatedWords).toEqual(
@@ -94,7 +110,10 @@ describe('BarkBattleConfigEditor', () => {
await userEvent.clear(screen.getByLabelText('拟声词')); await userEvent.clear(screen.getByLabelText('拟声词'));
await userEvent.type(screen.getByLabelText('拟声词'), '轰!\n破阵'); await userEvent.type(screen.getByLabelText('拟声词'), '轰!\n破阵');
await userEvent.clear(screen.getByLabelText('主题/场景描述')); await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.type(screen.getByLabelText('主题/场景描述'), '星舰机甲擂台'); await userEvent.type(
screen.getByLabelText('主题/场景描述'),
'星舰机甲擂台',
);
expect((screen.getByLabelText('拟声词') as HTMLTextAreaElement).value).toBe( expect((screen.getByLabelText('拟声词') as HTMLTextAreaElement).value).toBe(
'轰!\n破阵', '轰!\n破阵',
@@ -124,7 +143,9 @@ describe('BarkBattleConfigEditor', () => {
/>, />,
); );
expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull(); expect(
screen.queryByRole('heading', { name: '汪汪声浪大作战' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '返回' })).toBeNull(); expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy(); expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy();
expect(screen.getByText('外部错误')).toBeTruthy(); expect(screen.getByText('外部错误')).toBeTruthy();
@@ -144,7 +165,9 @@ describe('BarkBattleConfigEditor', () => {
const editor = screen.getByLabelText('汪汪声浪轻配置编辑器'); const editor = screen.getByLabelText('汪汪声浪轻配置编辑器');
expect(editor.className).toContain('overflow-visible'); expect(editor.className).toContain('overflow-visible');
expect(editor.className).toContain('lg:overflow-y-auto'); expect(editor.className).toContain('lg:overflow-y-auto');
expect(editor.className).not.toContain('overflow-y-auto overscroll-y-contain pr-0.5'); expect(editor.className).not.toContain(
'overflow-y-auto overscroll-y-contain pr-0.5',
);
const themeLabel = screen.getByText('主题/场景描述'); const themeLabel = screen.getByText('主题/场景描述');
expect(themeLabel.className).toContain('bg-rose-50'); expect(themeLabel.className).toContain('bg-rose-50');

View File

@@ -1,9 +1,18 @@
import { ArrowLeft, Loader2, Play } from 'lucide-react'; import { Loader2, Play } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle'; import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle'; import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle';
import { buildBarkBattleDefaultOnomatopoeia } from '../../games/bark-battle/application/BarkBattleConfig'; import { buildBarkBattleDefaultOnomatopoeia } from '../../games/bark-battle/application/BarkBattleConfig';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
PlatformSelectField,
PlatformTextField,
} from '../common/PlatformTextField';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard'; import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = { export type BarkBattleConfigEditorProps = {
@@ -15,15 +24,14 @@ export type BarkBattleConfigEditorProps = {
title?: string | null; title?: string | null;
}; };
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [ const DIFFICULTY_OPTIONS: Array<{
value: BarkBattleDifficultyPreset;
label: string;
}> = [
{ value: 'easy', label: '轻松' }, { value: 'easy', label: '轻松' },
{ value: 'normal', label: '标准' }, { value: 'normal', label: '标准' },
{ value: 'hard', label: '硬核' }, { value: 'hard', label: '硬核' },
]; ];
const FIELD_LABEL_CLASS =
'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]';
const ACCENT_FIELD_LABEL_CLASS =
'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm';
const DEFAULT_THEME_DESCRIPTION = '阳光草坪上的圆形声浪擂台'; const DEFAULT_THEME_DESCRIPTION = '阳光草坪上的圆形声浪擂台';
const DEFAULT_PLAYER_IMAGE_DESCRIPTION = '戴红色围巾的勇敢小狗'; const DEFAULT_PLAYER_IMAGE_DESCRIPTION = '戴红色围巾的勇敢小狗';
const DEFAULT_OPPONENT_IMAGE_DESCRIPTION = '戴蓝色头带的活力小狗'; const DEFAULT_OPPONENT_IMAGE_DESCRIPTION = '戴蓝色头带的活力小狗';
@@ -64,7 +72,8 @@ export function BarkBattleConfigEditor({
opponentImageDescription: DEFAULT_OPPONENT_IMAGE_DESCRIPTION, opponentImageDescription: DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
}), }),
); );
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal'); const [difficultyPreset, setDifficultyPreset] =
useState<BarkBattleDifficultyPreset>('normal');
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -143,17 +152,11 @@ export function BarkBattleConfigEditor({
> >
{showBackButton && onBack ? ( {showBackButton && onBack ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4"> <div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button <PlatformBackActionButton
type="button"
onClick={onBack} onClick={onBack}
disabled={isBusy} disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`} className="px-3"
> />
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div> </div>
) : null} ) : null}
@@ -164,9 +167,9 @@ export function BarkBattleConfigEditor({
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl"> <h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{headingTitle} {headingTitle}
</h1> </h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700"> <PlatformPillBadge tone="success" size="xs">
</span> </PlatformPillBadge>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -176,24 +179,29 @@ export function BarkBattleConfigEditor({
> >
<div className="flex flex-col gap-3 pr-0 lg:pr-1"> <div className="flex flex-col gap-3 pr-0 lg:pr-1">
<label className="block shrink-0"> <label className="block shrink-0">
<span className={FIELD_LABEL_CLASS}></span> <PlatformFieldLabel variant="pill"></PlatformFieldLabel>
<input <PlatformTextField
value={title} value={title}
disabled={isBusy} disabled={isBusy}
onChange={(event) => setTitle(event.target.value)} onChange={(event) => setTitle(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" size="lg"
density="roomy"
className="h-11 rounded-[1.05rem] py-0"
maxLength={40} maxLength={40}
aria-label="作品标题" aria-label="作品标题"
/> />
</label> </label>
<label className="block shrink-0"> <label className="block shrink-0">
<span className={FIELD_LABEL_CLASS}></span> <PlatformFieldLabel variant="pill"></PlatformFieldLabel>
<textarea <PlatformTextField
variant="textarea"
value={description} value={description}
disabled={isBusy} disabled={isBusy}
onChange={(event) => setDescription(event.target.value)} onChange={(event) => setDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" size="lg"
density="roomy"
className="h-[5.5rem] min-h-[5.5rem] rounded-[1.05rem] font-normal leading-6"
maxLength={160} maxLength={160}
placeholder="" placeholder=""
aria-label="简介" aria-label="简介"
@@ -202,8 +210,8 @@ export function BarkBattleConfigEditor({
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2"> <div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block"> <label className="block">
<span className={FIELD_LABEL_CLASS}></span> <PlatformFieldLabel variant="pill"></PlatformFieldLabel>
<select <PlatformSelectField
value={difficultyPreset} value={difficultyPreset}
disabled={isBusy} disabled={isBusy}
onChange={(event) => onChange={(event) =>
@@ -211,7 +219,9 @@ export function BarkBattleConfigEditor({
event.target.value as BarkBattleDifficultyPreset, event.target.value as BarkBattleDifficultyPreset,
) )
} }
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" size="sm"
density="roomy"
className="h-11 rounded-[1.05rem] py-0 font-black"
aria-label="难度预设" aria-label="难度预设"
> >
{DIFFICULTY_OPTIONS.map((option) => ( {DIFFICULTY_OPTIONS.map((option) => (
@@ -219,19 +229,23 @@ export function BarkBattleConfigEditor({
{option.label} {option.label}
</option> </option>
))} ))}
</select> </PlatformSelectField>
</label> </label>
</div> </div>
<label className="block shrink-0"> <label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}> <PlatformFieldLabel variant="accentPill">
/ /
</span> </PlatformFieldLabel>
<textarea <PlatformTextField
variant="textarea"
value={themeDescription} value={themeDescription}
disabled={isBusy} disabled={isBusy}
onChange={(event) => setThemeDescription(event.target.value)} onChange={(event) => setThemeDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" size="lg"
density="roomy"
tone="rose"
className="h-[5.5rem] min-h-[5.5rem] rounded-[1.05rem] font-normal leading-6"
maxLength={240} maxLength={240}
placeholder="" placeholder=""
aria-label="主题/场景描述" aria-label="主题/场景描述"
@@ -240,24 +254,40 @@ export function BarkBattleConfigEditor({
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2"> <div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block"> <label className="block">
<span className={FIELD_LABEL_CLASS}></span> <PlatformFieldLabel variant="pill">
<textarea
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={playerImageDescription} value={playerImageDescription}
disabled={isBusy} disabled={isBusy}
onChange={(event) => setPlayerImageDescription(event.target.value)} onChange={(event) =>
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" setPlayerImageDescription(event.target.value)
}
size="sm"
density="roomy"
tone="rose"
className="h-[5rem] min-h-[5rem] rounded-[1.05rem] leading-6"
maxLength={220} maxLength={220}
aria-label="玩家形象描述" aria-label="玩家形象描述"
/> />
</label> </label>
<label className="block"> <label className="block">
<span className={FIELD_LABEL_CLASS}></span> <PlatformFieldLabel variant="pill">
<textarea
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={opponentImageDescription} value={opponentImageDescription}
disabled={isBusy} disabled={isBusy}
onChange={(event) => setOpponentImageDescription(event.target.value)} onChange={(event) =>
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" setOpponentImageDescription(event.target.value)
}
size="sm"
density="roomy"
tone="rose"
className="h-[5rem] min-h-[5rem] rounded-[1.05rem] leading-6"
maxLength={220} maxLength={220}
aria-label="对手形象描述" aria-label="对手形象描述"
/> />
@@ -265,24 +295,35 @@ export function BarkBattleConfigEditor({
</div> </div>
<label className="block shrink-0"> <label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}></span> <PlatformFieldLabel variant="accentPill">
<textarea
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={onomatopoeiaText} value={onomatopoeiaText}
disabled={isBusy} disabled={isBusy}
onChange={(event) => { onChange={(event) => {
setIsOnomatopoeiaCustomized(true); setIsOnomatopoeiaCustomized(true);
setOnomatopoeiaText(event.target.value); setOnomatopoeiaText(event.target.value);
}} }}
className="h-[6.5rem] min-h-[6.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-black leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" size="sm"
density="roomy"
tone="rose"
className="h-[6.5rem] min-h-[6.5rem] rounded-[1.05rem] font-black leading-6"
maxLength={260} maxLength={260}
aria-label="拟声词" aria-label="拟声词"
/> />
</label> </label>
{visibleError ? ( {visibleError ? (
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6"> <PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="shrink-0 rounded-2xl"
>
{visibleError} {visibleError}
</div> </PlatformStatusMessage>
) : null} ) : null}
</div> </div>
@@ -291,21 +332,18 @@ export function BarkBattleConfigEditor({
</div> </div>
<div className="mt-4 flex shrink-0 flex-wrap justify-center gap-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:mt-4 lg:pb-[max(0.25rem,env(safe-area-inset-bottom))]"> <div className="mt-4 flex shrink-0 flex-wrap justify-center gap-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:mt-4 lg:pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button <PlatformActionButton
type="button"
disabled={isBusy} disabled={isBusy}
onClick={() => runValidatedAction(onPreview)} onClick={() => runValidatedAction(onPreview)}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`} className="min-h-10 gap-1.5 px-4 py-2 text-sm sm:min-h-11 sm:gap-2 sm:px-5"
> >
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2"> {isBusy ? (
{isBusy ? ( <Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" /> ) : (
) : ( <Play className="h-4 w-4" />
<Play className="h-4 w-4" /> )}
)} <span>{isBusy ? '处理中' : '生成草稿'}</span>
<span>{isBusy ? '处理中' : '生成草稿'}</span> </PlatformActionButton>
</span>
</button>
</div> </div>
</section> </section>
); );

View File

@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
@@ -84,31 +85,41 @@ describe('BarkBattleGeneratingView', () => {
'video[data-testid="generation-page-background-video"] source[type="video/mp4"]', 'video[data-testid="generation-page-background-video"] source[type="video/mp4"]',
), ),
).toBeTruthy(); ).toBeTruthy();
expect(screen.getByRole('button', { name: '返回编辑' }).className).toContain( expect(
'text-xs', screen.getByRole('button', { name: '返回编辑' }).className,
); ).toContain('text-xs');
expect(
screen.getByRole('button', { name: '返回编辑' }).className,
).toContain('bg-transparent');
expect(
screen.getByRole('button', { name: '返回编辑' }).className,
).toContain('gap-2');
expect(screen.getByText('生成中').className).toContain('text-[11px]'); expect(screen.getByText('生成中').className).toContain('text-[11px]');
expect(screen.getByText('生成中').className).toContain(
'border-[var(--platform-warm-border)]',
);
expect(screen.getByText('生成中').className).toContain(
'bg-[var(--platform-warm-bg)]',
);
expect(screen.getByText('当前步骤')).toBeTruthy(); expect(screen.getByText('当前步骤')).toBeTruthy();
expect(screen.getByText('当前步骤').className).toContain('text-[10px]'); expect(screen.getByText('当前步骤').className).toContain('text-[10px]');
expect(screen.getByTestId('generation-hero-wait-card').className).toContain( expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
'text-center', 'text-center',
); );
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain( expect(
'text-center', screen.getByTestId('generation-hero-elapsed-card').className,
); ).toContain('text-center');
expect(screen.getByTestId('generation-hero-wait-card').className).toContain( expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
'bg-white/58', 'bg-white/58',
); );
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
'bg-white/58',
);
expect( expect(
screen.getByTestId('generation-hero-wait-card').parentElement screen.getByTestId('generation-hero-elapsed-card').className,
?.className, ).toContain('bg-white/58');
expect(
screen.getByTestId('generation-hero-wait-card').parentElement?.className,
).toContain('mt-3'); ).toContain('mt-3');
expect( expect(
screen.getByTestId('generation-hero-wait-card').parentElement screen.getByTestId('generation-hero-wait-card').parentElement?.className,
?.className,
).toContain('px-0'); ).toContain('px-0');
expect(screen.getByText('预计等待').className).toContain('text-[9px]'); expect(screen.getByText('预计等待').className).toContain('text-[9px]');
expect(screen.getByText('已耗时').className).toContain('text-[9px]'); expect(screen.getByText('已耗时').className).toContain('text-[9px]');
@@ -122,33 +133,30 @@ describe('BarkBattleGeneratingView', () => {
expect(screen.getByText('1 秒')).toBeTruthy(); expect(screen.getByText('1 秒')).toBeTruthy();
expect(screen.queryByText('预计还需 3 分钟')).toBeNull(); expect(screen.queryByText('预计还需 3 分钟')).toBeNull();
expect(screen.queryByText('已耗时 1 秒')).toBeNull(); expect(screen.queryByText('已耗时 1 秒')).toBeNull();
expect(screen.getByTestId('generation-hero-progress-content').className).toContain( expect(
'justify-start', screen.getByTestId('generation-hero-progress-content').className,
); ).toContain('justify-start');
expect(screen.getByTestId('generation-hero-progress-content').className).toContain( expect(
'z-30', screen.getByTestId('generation-hero-progress-content').className,
); ).toContain('z-30');
expect(screen.getByTestId('generation-hero-progress-content').className).toContain( expect(
'pt-[2%]', screen.getByTestId('generation-hero-progress-content').className,
); ).toContain('pt-[2%]');
expect(screen.getByText('玩家形象')).toBeTruthy(); expect(screen.getByText('玩家形象')).toBeTruthy();
expect(screen.getByText('进行中 36%')).toBeTruthy(); expect(screen.getByText('进行中 36%')).toBeTruthy();
expect(screen.getByText('进行中 36%').className).toContain('text-[11px]'); expect(screen.getByText('进行中 36%').className).toContain('text-[11px]');
expect(screen.getByText('总进度').className).toContain('text-[9px]'); expect(screen.getByText('总进度').className).toContain('text-[9px]');
expect(screen.getByText('0%').className).toContain('text-[1.15rem]'); expect(screen.getByText('0%').className).toContain('text-[1.15rem]');
expect( expect(
screen screen.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className, .className,
).toContain('w-[min(400px,calc(100%_-_0.75rem))]'); ).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect( expect(
screen screen.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className, .className,
).toContain('max-w-full'); ).toContain('max-w-full');
expect( expect(
screen screen.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className, .className,
).toContain('aspect-square'); ).toContain('aspect-square');
expect( expect(
@@ -184,9 +192,9 @@ describe('BarkBattleGeneratingView', () => {
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe( expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
'svg', 'svg',
); );
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain( expect(
'z-0', screen.getByTestId('generation-hero-progress-ring').getAttribute('class'),
); ).toContain('z-0');
expect( expect(
screen screen
.getByTestId('generation-hero-progress-ring') .getByTestId('generation-hero-progress-ring')
@@ -218,8 +226,8 @@ describe('BarkBattleGeneratingView', () => {
.getAttribute('stroke-dasharray'), .getAttribute('stroke-dasharray'),
).toMatch(/^0\.00 1043\.\d{2}$/u); ).toMatch(/^0\.00 1043\.\d{2}$/u);
expect( expect(
screen.getByRole('progressbar', { name: '玩家形象 进度' }), screen.getByRole('progressbar', { name: '玩家形象 进度' }).className,
).toBeTruthy(); ).toContain('platform-progress-track');
expect( expect(
screen screen
.getByRole('progressbar', { name: '玩家形象 进度' }) .getByRole('progressbar', { name: '玩家形象 进度' })
@@ -478,4 +486,42 @@ describe('BarkBattleGeneratingView', () => {
); );
}); });
}); });
it('keeps the shared generation back button disabled state and click behavior', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
vi.mocked(generateAllBarkBattleImageAssets).mockReturnValue(
new Promise<BarkBattleImageGenerationBatchResult>(() => {}),
);
const { rerender } = render(
<BarkBattleGeneratingView
draft={draft}
isBusy
onBack={onBack}
onComplete={() => {}}
onError={() => {}}
/>,
);
const busyBackButton = screen.getByRole('button', { name: '返回编辑' });
expect(busyBackButton.getAttribute('disabled')).toBe('');
expect(busyBackButton.style.opacity).toBe('0.45');
await user.click(busyBackButton);
expect(onBack).not.toHaveBeenCalled();
rerender(
<BarkBattleGeneratingView
draft={draft}
isBusy={false}
onBack={onBack}
onComplete={() => {}}
onError={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '返回编辑' }));
expect(onBack).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -1,4 +1,3 @@
import { ArrowLeft } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle'; import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
@@ -12,8 +11,10 @@ import {
generateAllBarkBattleImageAssets, generateAllBarkBattleImageAssets,
updateBarkBattleDraftConfig, updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation'; } from '../../services/bark-battle-creation';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { import {
GenerationCurrentStepCard, GenerationCurrentStepCard,
GenerationHeaderBackButton,
GenerationPageBackdrop, GenerationPageBackdrop,
GenerationProgressHero, GenerationProgressHero,
} from '../GenerationProgressHero'; } from '../GenerationProgressHero';
@@ -191,7 +192,11 @@ export function BarkBattleGeneratingView({
(hasSlotAsset(previewDraft, currentStep.slot) ? 'ready' : 'generating')) (hasSlotAsset(previewDraft, currentStep.slot) ? 'ready' : 'generating'))
: 'generating'; : 'generating';
const currentStepProgress = const currentStepProgress =
currentStepStatus === 'ready' ? 100 : currentStepStatus === 'failed' ? 100 : 36; currentStepStatus === 'ready'
? 100
: currentStepStatus === 'failed'
? 100
: 36;
const currentStepLabel = currentStep?.label ?? '竞技素材'; const currentStepLabel = currentStep?.label ?? '竞技素材';
const currentStepStatusLabel = getSlotStatusLabel(currentStepStatus); const currentStepStatusLabel = getSlotStatusLabel(currentStepStatus);
@@ -336,7 +341,10 @@ export function BarkBattleGeneratingView({
onComplete(draft, true); onComplete(draft, true);
}) })
.finally(() => { .finally(() => {
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) { if (
activeBarkBattleGenerationTasks.get(startedDraftKey) ===
generationTask
) {
activeBarkBattleGenerationTasks.delete(startedDraftKey); activeBarkBattleGenerationTasks.delete(startedDraftKey);
} }
}); });
@@ -344,7 +352,9 @@ export function BarkBattleGeneratingView({
return () => { return () => {
cancelled = true; cancelled = true;
// 中文注释:离开生成页后不再全局复用同一 Promise避免悬挂生成任务导致再次进入时一直转圈。 // 中文注释:离开生成页后不再全局复用同一 Promise避免悬挂生成任务导致再次进入时一直转圈。
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) { if (
activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask
) {
activeBarkBattleGenerationTasks.delete(startedDraftKey); activeBarkBattleGenerationTasks.delete(startedDraftKey);
} }
if (startedDraftIdRef.current === startedDraftKey) { if (startedDraftIdRef.current === startedDraftKey) {
@@ -357,18 +367,19 @@ export function BarkBattleGeneratingView({
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5"> <div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
<GenerationPageBackdrop /> <GenerationPageBackdrop />
<div className="relative z-30 mx-auto mb-4 flex w-full max-w-[48rem] shrink-0 items-center justify-between gap-3 sm:mb-5"> <div className="relative z-30 mx-auto mb-4 flex w-full max-w-[48rem] shrink-0 items-center justify-between gap-3 sm:mb-5">
<button <GenerationHeaderBackButton
type="button" label="返回编辑"
onClick={onBack} onClick={onBack}
disabled={isBusy} disabled={isBusy}
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`} disabledOpacity={0.45}
/>
<PlatformPillBadge
tone="warning"
size="xs"
className="px-3 py-1.5 tracking-[0.08em] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs"
> >
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
<span className="break-keep"></span>
</button>
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
</span> </PlatformPillBadge>
</div> </div>
<div <div

View File

@@ -1,4 +1,7 @@
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle'; import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import { PlatformInfoBlock } from '../common/PlatformInfoBlock';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BarkBattlePreviewCardProps = { type BarkBattlePreviewCardProps = {
@@ -13,11 +16,19 @@ const DIFFICULTY_LABELS = {
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) { export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
return ( return (
<aside <PlatformSubpanel
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 max-lg:p-2 sm:p-4" as="aside"
padding="none"
className="flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 max-lg:p-2 sm:p-4"
aria-label="作品预览卡片" aria-label="作品预览卡片"
> >
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4"> <PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="flex min-h-0 flex-1 flex-col bg-white/76 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4"
>
<div <div
className="relative mb-2.5 grid min-h-[5.75rem] grid-cols-[1fr_auto_1fr] items-center gap-2 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-3 text-center text-2xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:mb-4 sm:min-h-[10rem] sm:gap-3 sm:px-4 sm:text-3xl" className="relative mb-2.5 grid min-h-[5.75rem] grid-cols-[1fr_auto_1fr] items-center gap-2 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-3 text-center text-2xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:mb-4 sm:min-h-[10rem] sm:gap-3 sm:px-4 sm:text-3xl"
data-testid="bark-battle-preview-stage" data-testid="bark-battle-preview-stage"
@@ -41,9 +52,13 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<span className="text-4xl sm:text-6xl">🐕</span> <span className="text-4xl sm:text-6xl">🐕</span>
)} )}
</span> </span>
<span className="relative rounded-full bg-white/70 px-2.5 py-0.5 text-xs font-black text-[var(--platform-text-strong)] sm:px-3 sm:py-1 sm:text-base"> <PlatformPillBadge
tone="neutral"
size="xs"
className="relative border-transparent bg-white/70 px-2.5 py-0.5 text-xs text-[var(--platform-text-strong)] sm:px-3 sm:py-1 sm:text-base"
>
VS VS
</span> </PlatformPillBadge>
<span className="relative grid place-items-center"> <span className="relative grid place-items-center">
{config.opponentCharacterImageSrc ? ( {config.opponentCharacterImageSrc ? (
<ResolvedAssetImage <ResolvedAssetImage
@@ -62,35 +77,23 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<p className="mt-1.5 min-h-0 text-xs font-semibold leading-5 text-[var(--platform-text-muted)] sm:mt-2 sm:min-h-[2.625rem] sm:text-sm sm:leading-6"> <p className="mt-1.5 min-h-0 text-xs font-semibold leading-5 text-[var(--platform-text-muted)] sm:mt-2 sm:min-h-[2.625rem] sm:text-sm sm:leading-6">
{config.description || '30 秒声浪拔河,喊出你的能量优势。'} {config.description || '30 秒声浪拔河,喊出你的能量优势。'}
</p> </p>
<dl className="mt-2.5 grid gap-1.5 text-xs sm:mt-4 sm:gap-2 sm:text-sm"> <div className="mt-2.5 grid gap-1.5 sm:mt-4 sm:gap-2">
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2"> <PlatformInfoBlock label="场景" variant="compactRow">
<dt className="text-[var(--platform-text-muted)]"></dt> {config.themeDescription || '声浪擂台'}
<dd className="font-black text-[var(--platform-text-strong)]"> </PlatformInfoBlock>
{config.themeDescription || '声浪擂台'} <PlatformInfoBlock label="形象" variant="compactRow">
</dd> {config.playerImageDescription || '玩家'}
</div> {' vs '}
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2"> {config.opponentImageDescription || '对手'}
<dt className="text-[var(--platform-text-muted)]"></dt> </PlatformInfoBlock>
<dd className="font-black text-[var(--platform-text-strong)]"> <PlatformInfoBlock label="难度" variant="compactRow">
{config.playerImageDescription || '玩家'} {DIFFICULTY_LABELS[config.difficultyPreset]}
{' vs '} </PlatformInfoBlock>
{config.opponentImageDescription || '对手'} <PlatformInfoBlock label="声浪" variant="compactRow">
</dd> {config.onomatopoeia?.slice(0, 3).join(' / ') || '炸场!'}
</div> </PlatformInfoBlock>
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2"> </div>
<dt className="text-[var(--platform-text-muted)]"></dt> </PlatformSubpanel>
<dd className="font-black text-[var(--platform-text-strong)]"> </PlatformSubpanel>
{DIFFICULTY_LABELS[config.difficultyPreset]}
</dd>
</div>
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{config.onomatopoeia?.slice(0, 3).join(' / ') || '炸场!'}
</dd>
</div>
</dl>
</div>
</aside>
); );
} }

View File

@@ -56,6 +56,8 @@ describe('BarkBattleResultView', () => {
/>, />,
); );
expect(screen.getByText('草稿').className).toContain('rounded-full');
expect(screen.getByText('草稿').className).toContain('border-emerald-200');
expect(screen.getByText('霓虹公园擂台')).toBeTruthy(); expect(screen.getByText('霓虹公园擂台')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' })); await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(draft); expect(onStartTestRun).toHaveBeenCalledWith(draft);
@@ -66,7 +68,7 @@ describe('BarkBattleResultView', () => {
}); });
it('uses compact mobile-first result layout classes', () => { it('uses compact mobile-first result layout classes', () => {
render( const { container } = render(
<BarkBattleResultView <BarkBattleResultView
draft={draft} draft={draft}
onBack={() => {}} onBack={() => {}}
@@ -76,13 +78,47 @@ describe('BarkBattleResultView', () => {
/>, />,
); );
expect(screen.getByRole('heading', { name: '汪汪冠军杯', level: 1 }).className).toContain( expect(
'text-2xl', screen.getByRole('heading', { name: '汪汪冠军杯', level: 1 }).className,
).toContain('text-2xl');
expect(screen.getByLabelText('作品预览卡片').className).toContain(
'platform-subpanel',
);
expect(screen.getByLabelText('作品预览卡片').className).toContain(
'max-lg:p-2',
); );
expect(screen.getByLabelText('作品预览卡片').className).toContain('max-lg:p-2');
expect(screen.getByTestId('bark-battle-preview-stage').className).toContain( expect(screen.getByTestId('bark-battle-preview-stage').className).toContain(
'min-h-[5.75rem]', 'min-h-[5.75rem]',
); );
const previewVersusBadge = screen.getByText('VS');
expect(previewVersusBadge.className).toContain('inline-flex');
expect(previewVersusBadge.className).toContain('rounded-full');
expect(previewVersusBadge.className).toContain('border-transparent');
expect(previewVersusBadge.className).toContain('bg-white/70');
expect(previewVersusBadge.className).toContain(
'text-[var(--platform-text-strong)]',
);
const previewSceneBlock = screen.getByText('场景').parentElement;
expect(previewSceneBlock?.className).toContain('bg-white/74');
expect(previewSceneBlock?.className).toContain('rounded-[0.85rem]');
expect(previewSceneBlock?.className).toContain('sm:px-3');
expect(screen.getByText('场景').className).toContain(
'text-[var(--platform-text-muted)]',
);
expect(
within(previewSceneBlock as HTMLElement).getByText('霓虹公园擂台')
.className,
).toContain('font-black');
expect(container.querySelectorAll('article.bg-white\\/72')).toHaveLength(3);
const draftSummaryPanel = screen.getByTestId(
'bark-battle-draft-summary-panel',
);
expect(draftSummaryPanel.className).toContain('bg-white/72');
expect(draftSummaryPanel.className).toContain('rounded-[1.25rem]');
expect(draftSummaryPanel.className).toContain('p-3');
expect(draftSummaryPanel.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
}); });
it('uploads replacement image assets into the selected slot', async () => { it('uploads replacement image assets into the selected slot', async () => {
@@ -137,7 +173,8 @@ describe('BarkBattleResultView', () => {
<BarkBattleResultView <BarkBattleResultView
draft={{ draft={{
...draft, ...draft,
playerCharacterImageSrc: 'generated-bark-battle-assets/player-character/very-long-object-key.png', playerCharacterImageSrc:
'generated-bark-battle-assets/player-character/very-long-object-key.png',
}} }}
onBack={() => {}} onBack={() => {}}
onDraftChange={() => {}} onDraftChange={() => {}}
@@ -146,7 +183,9 @@ describe('BarkBattleResultView', () => {
/>, />,
); );
const playerSlot = screen.getByRole('heading', { name: '玩家形象' }).closest('article'); const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy(); expect(playerSlot).toBeTruthy();
expect(within(playerSlot as HTMLElement).getByText('已替换')).toBeTruthy(); expect(within(playerSlot as HTMLElement).getByText('已替换')).toBeTruthy();
expect( expect(
@@ -154,7 +193,9 @@ describe('BarkBattleResultView', () => {
'generated-bark-battle-assets/player-character/very-long-object-key.png', 'generated-bark-battle-assets/player-character/very-long-object-key.png',
), ),
).toBeNull(); ).toBeNull();
expect(within(playerSlot as HTMLElement).queryByText(/objectKey|object key/i)).toBeNull(); expect(
within(playerSlot as HTMLElement).queryByText(/objectKey|object key/i),
).toBeNull();
}); });
it('keeps result assets to three image slots with per-slot regeneration only', async () => { it('keeps result assets to three image slots with per-slot regeneration only', async () => {
@@ -190,7 +231,9 @@ describe('BarkBattleResultView', () => {
.closest('article'); .closest('article');
expect(playerSlot).toBeTruthy(); expect(playerSlot).toBeTruthy();
await user.click( await user.click(
within(playerSlot as HTMLElement).getByRole('button', { name: '重新生成' }), within(playerSlot as HTMLElement).getByRole('button', {
name: '重新生成',
}),
); );
await waitFor(() => { await waitFor(() => {

View File

@@ -7,7 +7,13 @@ import {
RefreshCw, RefreshCw,
Upload, Upload,
} from 'lucide-react'; } from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useRef, useState } from 'react'; import {
type ChangeEvent,
type ReactNode,
useMemo,
useRef,
useState,
} from 'react';
import type { import type {
BarkBattleConfigEditorPayload, BarkBattleConfigEditorPayload,
@@ -18,6 +24,10 @@ import {
regenerateBarkBattleImageAsset, regenerateBarkBattleImageAsset,
uploadBarkBattleAsset, uploadBarkBattleAsset,
} from '../../services/bark-battle-creation'; } from '../../services/bark-battle-creation';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard'; import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
type BarkBattleResultViewProps = { type BarkBattleResultViewProps = {
@@ -36,7 +46,9 @@ const SLOT_LABELS = {
'ui-background': 'UI背景', 'ui-background': 'UI背景',
} satisfies Record<BarkBattleAssetSlot, string>; } satisfies Record<BarkBattleAssetSlot, string>;
function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorPayload { function mapDraftToConfig(
draft: BarkBattleDraftConfig,
): BarkBattleConfigEditorPayload {
return { return {
title: draft.title, title: draft.title,
description: draft.description, description: draft.description,
@@ -75,7 +87,10 @@ function applyAssetToDraft(
return { ...draft, updatedAt }; return { ...draft, updatedAt };
} }
function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) { function getSlotAssetSrc(
draft: BarkBattleDraftConfig,
slot: BarkBattleAssetSlot,
) {
if (slot === 'player-character') { if (slot === 'player-character') {
return draft.playerCharacterImageSrc ?? ''; return draft.playerCharacterImageSrc ?? '';
} }
@@ -100,16 +115,14 @@ function ResultActionButton({
tone?: 'primary' | 'secondary'; tone?: 'primary' | 'secondary';
}) { }) {
return ( return (
<button <PlatformActionButton
type="button"
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={`platform-button ${ tone={tone}
tone === 'primary' ? 'platform-button--primary' : 'platform-button--secondary' className="min-h-10 gap-2 text-sm sm:min-h-11"
} min-h-10 justify-center text-sm disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-11`}
> >
{children} {children}
</button> </PlatformActionButton>
); );
} }
@@ -177,7 +190,13 @@ function BarkBattleAssetSlotControl({
const isSlotBusy = isUploading || isRegenerating; const isSlotBusy = isUploading || isRegenerating;
return ( return (
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-3"> <PlatformSubpanel
as="article"
surface="flat"
radius="sm"
padding="none"
className="p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-3"
>
<div className="flex items-center justify-between gap-2 sm:gap-3"> <div className="flex items-center justify-between gap-2 sm:gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h3 className="m-0 text-xs font-black text-[var(--platform-text-strong)] sm:text-sm"> <h3 className="m-0 text-xs font-black text-[var(--platform-text-strong)] sm:text-sm">
@@ -202,26 +221,30 @@ function BarkBattleAssetSlotControl({
aria-label={`上传${SLOT_LABELS[slot]}文件`} aria-label={`上传${SLOT_LABELS[slot]}文件`}
onChange={handleUpload} onChange={handleUpload}
/> />
<button <PlatformActionButton
type="button"
disabled={disabled || isSlotBusy} disabled={disabled || isSlotBusy}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="platform-button platform-button--secondary min-h-8 justify-center rounded-full px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs" tone="secondary"
size="xs"
shape="pill"
className="min-h-8 gap-1.5 px-2.5 py-1 text-[11px] sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
> >
<Upload className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
</button> </PlatformActionButton>
<button <PlatformActionButton
type="button"
disabled={disabled || isSlotBusy} disabled={disabled || isSlotBusy}
onClick={handleRegenerate} onClick={handleRegenerate}
className="platform-button platform-button--secondary min-h-8 justify-center rounded-full px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs" tone="secondary"
size="xs"
shape="pill"
className="min-h-8 gap-1.5 px-2.5 py-1 text-[11px] sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
> >
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </PlatformActionButton>
</div> </div>
</article> </PlatformSubpanel>
); );
} }
@@ -243,31 +266,43 @@ export function BarkBattleResultView({
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-2 pb-2 pt-2 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4"> <div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-2 pb-2 pt-2 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
<div className="mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col"> <div className="mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 sm:mb-3 sm:gap-3"> <div className="mb-2 flex shrink-0 items-center justify-between gap-2 sm:mb-3 sm:gap-3">
<button <PlatformActionButton
type="button"
onClick={onBack} onClick={onBack}
disabled={isActionBusy} disabled={isActionBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isActionBusy ? 'opacity-45' : ''}`} tone="ghost"
size="xs"
className="min-h-0 gap-1.5 px-3 py-1.5 text-[11px]"
> >
<ArrowLeft className="h-3.5 w-3.5" /> <ArrowLeft className="h-3.5 w-3.5" />
</button> </PlatformActionButton>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-0.5 text-[11px] font-black text-emerald-700 sm:px-3 sm:py-1"> <PlatformPillBadge
tone="success"
size="xs"
className="sm:px-3 sm:py-1"
>
稿 稿
</span> </PlatformPillBadge>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5"> <div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
<section className="grid gap-2.5 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)] lg:gap-3"> <section className="grid gap-2.5 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)] lg:gap-3">
<div className="grid gap-2.5 lg:gap-3"> <div className="grid gap-2.5 lg:gap-3">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-4"> <PlatformSubpanel
as="div"
surface="flat"
radius="md"
padding="sm"
className="shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]"
data-testid="bark-battle-draft-summary-panel"
>
<div className="text-xs font-black text-[var(--platform-text-soft)] sm:text-sm"> <div className="text-xs font-black text-[var(--platform-text-soft)] sm:text-sm">
稿 稿
</div> </div>
<h1 className="m-0 mt-1 text-2xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:mt-2 sm:text-4xl lg:text-5xl"> <h1 className="m-0 mt-1 text-2xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:mt-2 sm:text-4xl lg:text-5xl">
{draft.title || '未命名声浪竞技场'} {draft.title || '未命名声浪竞技场'}
</h1> </h1>
</div> </PlatformSubpanel>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{( {(
[ [
@@ -294,9 +329,14 @@ export function BarkBattleResultView({
</section> </section>
{visibleError ? ( {visibleError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6"> <PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="mt-3 rounded-2xl"
>
{visibleError} {visibleError}
</div> </PlatformStatusMessage>
) : null} ) : null}
</div> </div>

View File

@@ -18,6 +18,23 @@ vi.mock('../ResolvedAssetImage', () => ({
}) => (src ? <img src={src} alt={alt} className={className} /> : null), }) => (src ? <img src={src} alt={alt} className={className} /> : null),
})); }));
function findNearestClassName(
element: HTMLElement,
classNamePart: string,
): HTMLElement | null {
let current: HTMLElement | null = element;
while (current) {
if (current.className.includes(classNamePart)) {
return current;
}
current = current.parentElement;
}
return null;
}
function createSession(): BigFishSessionSnapshotResponse { function createSession(): BigFishSessionSnapshotResponse {
return { return {
sessionId: 'big-fish-session-1', sessionId: 'big-fish-session-1',
@@ -140,6 +157,26 @@ function createSession(): BigFishSessionSnapshotResponse {
} }
describe('BigFishResultView', () => { describe('BigFishResultView', () => {
test('uses PlatformEmptyState chrome when draft is missing', () => {
render(
<BigFishResultView
session={{
...createSession(),
draft: null,
}}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
const emptyState = screen.getByText('还没有可编辑的玩法草稿');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('bg-white/74');
expect(emptyState.className).toContain('text-[var(--platform-text-base)]');
});
test('renders generated formal previews with accurate status copy', () => { test('renders generated formal previews with accurate status copy', () => {
render( render(
<BigFishResultView <BigFishResultView
@@ -151,8 +188,96 @@ describe('BigFishResultView', () => {
); );
expect(screen.getByText('主图 已生成')).toBeTruthy(); expect(screen.getByText('主图 已生成')).toBeTruthy();
expect(screen.getByAltText('荧潮幼体')).toBeTruthy(); const levelImage = screen.getByAltText('荧潮幼体');
expect(screen.getByAltText('深海谜境 场地背景')).toBeTruthy(); expect(levelImage).toBeTruthy();
const levelFrame = findNearestClassName(levelImage, 'relative');
expect(levelFrame?.className).toContain('aspect-square');
expect(levelFrame?.className).toContain('radial-gradient');
expect(levelFrame?.className).toContain('linear-gradient');
expect(levelFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
const backgroundImage = screen.getByAltText('深海谜境 场地背景');
expect(backgroundImage).toBeTruthy();
const backgroundFrame = findNearestClassName(backgroundImage, 'relative');
expect(backgroundFrame?.className).toContain('aspect-[9/16]');
expect(backgroundFrame?.className).toContain('radial-gradient');
expect(backgroundFrame?.className).toContain('linear-gradient');
expect(backgroundFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
expect(
findNearestClassName(screen.getByText('荧潮幼体'), 'platform-subpanel')
?.className,
).toContain('rounded-[1.5rem]');
for (const label of ['猎物 1', '威胁 2', '主图 已生成']) {
const badge = screen.getByText(label);
expect(badge.className).toContain('rounded-full');
expect(badge.className).toContain(
'bg-[var(--platform-subpanel-fill)]',
);
}
expect(
findNearestClassName(screen.getByText('场地背景'), 'platform-subpanel')
?.className,
).toContain('rounded-[1.5rem]');
expect(
findNearestClassName(screen.getByText('发布校验'), 'platform-subpanel')
?.className,
).toContain('rounded-[1.5rem]');
const blockerStatus = findNearestClassName(
screen.getByText('还缺少 2 个基础动作'),
'platform-status-message',
);
expect(blockerStatus?.className).toContain('platform-status-message');
expect(blockerStatus?.className).toContain(
'border-[var(--platform-warm-border)]',
);
expect(blockerStatus?.className).toContain(
'bg-[var(--platform-warm-bg)]',
);
for (const label of ['弱小逆袭', '深海谜境', '1 级']) {
const badge = screen
.getAllByText(label)
.find((element) => element.className.includes('inline-flex'));
if (!badge) {
throw new Error(`missing hero badge for ${label}`);
}
expect(badge.className).toContain('inline-flex');
expect(badge.className).toContain('rounded-full');
expect(badge.className).toContain('border-transparent');
}
});
test('uses platform pill badge for ready publish status', () => {
render(
<BigFishResultView
session={{
...createSession(),
publishReady: true,
assetCoverage: {
levelMainImageReadyCount: 1,
levelMotionReadyCount: 2,
backgroundReady: true,
requiredLevelCount: 1,
publishReady: true,
blockers: [],
},
}}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
const readyBadge = screen.getByText('已达到发布条件');
expect(readyBadge.tagName).toBe('SPAN');
expect(readyBadge.className).toContain('rounded-full');
expect(readyBadge.className).toContain('border-emerald-200');
expect(readyBadge.className).toContain('bg-emerald-50');
}); });
test('uses level descriptions as default prompt content in asset studio', () => { test('uses level descriptions as default prompt content in asset studio', () => {
@@ -166,8 +291,21 @@ describe('BigFishResultView', () => {
); );
fireEvent.click(screen.getByRole('button', { name: '主图' })); fireEvent.click(screen.getByRole('button', { name: '主图' }));
expect(screen.getByText('PROMPT').className).toContain('tracking-[0.18em]');
const studioPreviewFrame = findNearestClassName(
screen.getByAltText('Lv.1 主图工坊'),
'relative',
);
expect(studioPreviewFrame?.className).toContain('aspect-[9/5]');
expect(studioPreviewFrame?.className).toContain('border-dashed');
expect(studioPreviewFrame?.className).toContain('bg-cyan-50/40');
expect(studioPreviewFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
expect( expect(
screen.getByText('带有浅青色荧光纹路的小型鱼苗,轮廓圆润,呈现弱小但灵动的开局形象。'), screen.getByText(
'带有浅青色荧光纹路的小型鱼苗,轮廓圆润,呈现弱小但灵动的开局形象。',
),
).toBeTruthy(); ).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭' })); fireEvent.click(screen.getByRole('button', { name: '关闭' }));
@@ -183,6 +321,54 @@ describe('BigFishResultView', () => {
).toBeTruthy(); ).toBeTruthy();
}); });
test('uses PlatformActionButton chrome for white surface asset actions', () => {
render(
<BigFishResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
for (const actionName of ['主图', '待机', '移动', '生成背景']) {
const action = screen.getByRole('button', { name: actionName });
expect(action.className).toContain('platform-button');
expect(action.className).toContain('rounded-full');
}
fireEvent.click(screen.getByRole('button', { name: '主图' }));
for (const actionName of ['关闭', '生成并应用正式图']) {
const action = screen.getByRole('button', { name: actionName });
expect(action.className).toContain('platform-button');
expect(action.className).toContain('rounded-full');
}
});
test('reuses shared hero action chrome for top-level result actions', () => {
render(
<BigFishResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '返回' }).className).toContain(
'rounded-full',
);
expect(screen.getByRole('button', { name: '测试' }).className).toContain(
'platform-action-button--editor-dark',
);
expect(screen.getByRole('button', { name: '发布' }).className).toContain(
'platform-action-button--editor-dark',
);
});
test('shows publish failures in a dismissible modal', () => { test('shows publish failures in a dismissible modal', () => {
const onDismissError = vi.fn(); const onDismissError = vi.fn();
@@ -202,6 +388,13 @@ describe('BigFishResultView', () => {
expect( expect(
screen.getByText('big_fish 发布校验未通过:还缺少 16 个基础动作'), screen.getByText('big_fish 发布校验未通过:还缺少 16 个基础动作'),
).toBeTruthy(); ).toBeTruthy();
const iconBadge = screen.getByLabelText('发布失败提示');
expect(iconBadge.className).toContain(
'bg-[var(--platform-button-danger-fill)]',
);
expect(iconBadge.className).toContain(
'text-[var(--platform-button-danger-text)]',
);
fireEvent.click(screen.getByRole('button', { name: '知道了' })); fireEvent.click(screen.getByRole('button', { name: '知道了' }));
expect(onDismissError).toHaveBeenCalledTimes(1); expect(onDismissError).toHaveBeenCalledTimes(1);
@@ -234,6 +427,11 @@ describe('BigFishResultView', () => {
const publishedButton = screen.getByRole('button', { name: '已发布' }); const publishedButton = screen.getByRole('button', { name: '已发布' });
expect((publishedButton as HTMLButtonElement).disabled).toBe(true); expect((publishedButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getAllByText('已发布').length).toBeGreaterThan(0); expect(screen.getAllByText('已发布').length).toBeGreaterThan(0);
const publishedBadge = screen
.getAllByText('已发布')
.find((element) => element.tagName === 'SPAN');
expect(publishedBadge?.className).toContain('border-emerald-200');
expect(publishedBadge?.className).toContain('bg-emerald-50');
fireEvent.click(publishedButton); fireEvent.click(publishedButton);
expect(onExecuteAction).not.toHaveBeenCalled(); expect(onExecuteAction).not.toHaveBeenCalled();
}); });

View File

@@ -16,8 +16,16 @@ import type {
BigFishSessionSnapshotResponse, BigFishSessionSnapshotResponse,
ExecuteBigFishActionRequest, ExecuteBigFishActionRequest,
} from '../../../packages/shared/src/contracts/bigFish'; } from '../../../packages/shared/src/contracts/bigFish';
import { UnifiedModal } from '../common/UnifiedModal'; import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog';
import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
type BigFishAssetStudioTarget = type BigFishAssetStudioTarget =
| { | {
@@ -94,12 +102,7 @@ function buildStudioAssetPreview(
); );
} }
return buildLevelAssetPreview( return buildLevelAssetPreview(
findAssetSlot( findAssetSlot(slots, 'level_motion', target.level.level, target.motionKey),
slots,
'level_motion',
target.level.level,
target.motionKey,
),
); );
} }
@@ -168,44 +171,42 @@ function BigFishAssetStudioModal({
</div> </div>
</div> </div>
<div className="space-y-4 px-4 py-4"> <div className="space-y-4 px-4 py-4">
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4"> <PlatformSubpanel as="div" surface="flat">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]"> <PlatformFieldLabel variant="section">PROMPT</PlatformFieldLabel>
PROMPT
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-strong)]"> <div className="mt-2 text-sm leading-6 text-[var(--platform-text-strong)]">
{prompt} {prompt}
</div> </div>
</div> </PlatformSubpanel>
<div className="flex aspect-[9/5] items-center justify-center overflow-hidden rounded-[1.4rem] border border-dashed border-cyan-300/50 bg-cyan-50/40 text-sm text-[var(--platform-text-base)]"> <PlatformMediaFrame
{previewUrl ? ( src={previewUrl}
<ResolvedAssetImage alt={title}
src={previewUrl} fallbackLabel="AI 资产候选预览"
alt={title} aspect="wide"
className="h-full w-full object-cover" surface="none"
/> className="rounded-[1.4rem] border border-dashed border-cyan-300/50 bg-cyan-50/40"
) : ( fallbackClassName="tracking-normal text-[var(--platform-text-base)]"
'AI 资产候选预览' />
)}
</div>
</div> </div>
<div className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-4"> <div className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-4">
<button <PlatformActionButton
type="button"
onClick={onClose} onClick={onClose}
disabled={isBusy} disabled={isBusy}
className="rounded-full border border-[var(--platform-subpanel-border)] px-4 py-2 text-sm font-semibold text-[var(--platform-text-base)] disabled:opacity-45" tone="ghost"
shape="pill"
size="xs"
> >
</button> </PlatformActionButton>
<button <PlatformActionButton
type="button"
onClick={execute} onClick={execute}
disabled={isBusy} disabled={isBusy}
className="inline-flex items-center gap-2 rounded-full bg-cyan-600 px-4 py-2 text-sm font-bold text-white disabled:opacity-45" shape="pill"
size="xs"
className="gap-2"
> >
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null} {isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
</button> </PlatformActionButton>
</div> </div>
</div> </div>
</div> </div>
@@ -223,11 +224,7 @@ function BigFishLevelCard({
isBusy: boolean; isBusy: boolean;
onOpenStudio: (target: BigFishAssetStudioTarget) => void; onOpenStudio: (target: BigFishAssetStudioTarget) => void;
}) { }) {
const mainImageSlot = findAssetSlot( const mainImageSlot = findAssetSlot(slots, 'level_main_image', level.level);
slots,
'level_main_image',
level.level,
);
const idleSlot = findAssetSlot( const idleSlot = findAssetSlot(
slots, slots,
'level_motion', 'level_motion',
@@ -243,19 +240,23 @@ function BigFishLevelCard({
const previewUrl = buildLevelAssetPreview(mainImageSlot); const previewUrl = buildLevelAssetPreview(mainImageSlot);
return ( return (
<article className="overflow-hidden rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-white/78"> <PlatformSubpanel
as="article"
surface="flat"
radius="xl"
padding="none"
className="overflow-hidden bg-white/78"
>
<div className="flex gap-3 p-3"> <div className="flex gap-3 p-3">
<div className="flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-[1.15rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.28),transparent_68%),linear-gradient(145deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))] text-white"> <PlatformMediaFrame
{previewUrl ? ( src={previewUrl}
<ResolvedAssetImage alt={level.name}
src={previewUrl} fallbackLabel="关卡主图"
alt={level.name} fallbackContent={<Waves className="h-8 w-8 text-cyan-100/72" />}
className="h-full w-full object-cover" aspect="square"
/> surface="none"
) : ( className="h-24 w-24 shrink-0 rounded-[1.15rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.28),transparent_68%),linear-gradient(145deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))] text-white"
<Waves className="h-8 w-8 text-cyan-100/72" /> />
)}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div>
@@ -267,37 +268,48 @@ function BigFishLevelCard({
</div> </div>
</div> </div>
{level.isFinalLevel ? ( {level.isFinalLevel ? (
<span className="rounded-full bg-amber-100 px-2 py-1 text-xs font-bold text-amber-700"> <PlatformPillBadge
tone="warning"
size="xs"
className="px-2 py-1 text-xs font-bold"
>
</span> </PlatformPillBadge>
) : null} ) : null}
</div> </div>
<div className="mt-2 line-clamp-2 text-sm leading-5 text-[var(--platform-text-base)]"> <div className="mt-2 line-clamp-2 text-sm leading-5 text-[var(--platform-text-base)]">
{level.oneLineFantasy} {level.oneLineFantasy}
</div> </div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-[var(--platform-text-soft)]"> <div className="mt-3 flex flex-wrap gap-2">
<span> {level.preyWindow.join('/') || '-'}</span> <PlatformPillBadge tone="muted" size="xxs">
<span> {level.threatWindow.join('/') || '-'}</span> {level.preyWindow.join('/') || '-'}
<span> {assetReadyLabel(mainImageSlot)}</span> </PlatformPillBadge>
<span> <PlatformPillBadge tone="muted" size="xxs">
{[assetReadyLabel(idleSlot), assetReadyLabel(moveSlot)].join('/')} {level.threatWindow.join('/') || '-'}
</span> </PlatformPillBadge>
<PlatformPillBadge tone="muted" size="xxs">
{assetReadyLabel(mainImageSlot)}
</PlatformPillBadge>
<PlatformPillBadge tone="muted" size="xxs">
{' '}
{[assetReadyLabel(idleSlot), assetReadyLabel(moveSlot)].join('/')}
</PlatformPillBadge>
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-2 border-t border-[var(--platform-subpanel-border)] p-3"> <div className="grid grid-cols-3 gap-2 border-t border-[var(--platform-subpanel-border)] p-3">
<button <PlatformActionButton
type="button"
disabled={isBusy} disabled={isBusy}
onClick={() => { onClick={() => {
onOpenStudio({ kind: 'level_main_image', level }); onOpenStudio({ kind: 'level_main_image', level });
}} }}
className="rounded-full bg-cyan-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-45" shape="pill"
size="xs"
className="px-3"
> >
</button> </PlatformActionButton>
<button <PlatformActionButton
type="button"
disabled={isBusy} disabled={isBusy}
onClick={() => { onClick={() => {
onOpenStudio({ onOpenStudio({
@@ -306,12 +318,14 @@ function BigFishLevelCard({
motionKey: 'idle_float', motionKey: 'idle_float',
}); });
}} }}
className="rounded-full border border-[var(--platform-subpanel-border)] px-3 py-2 text-xs font-bold text-[var(--platform-text-base)] disabled:opacity-45" tone="ghost"
shape="pill"
size="xs"
className="px-3"
> >
</button> </PlatformActionButton>
<button <PlatformActionButton
type="button"
disabled={isBusy} disabled={isBusy}
onClick={() => { onClick={() => {
onOpenStudio({ onOpenStudio({
@@ -320,12 +334,15 @@ function BigFishLevelCard({
motionKey: 'move_swim', motionKey: 'move_swim',
}); });
}} }}
className="rounded-full border border-[var(--platform-subpanel-border)] px-3 py-2 text-xs font-bold text-[var(--platform-text-base)] disabled:opacity-45" tone="ghost"
shape="pill"
size="xs"
className="px-3"
> >
</button> </PlatformActionButton>
</div> </div>
</article> </PlatformSubpanel>
); );
} }
@@ -366,9 +383,13 @@ export function BigFishResultView({
if (!draft) { if (!draft) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]"> <PlatformEmptyState
surface="subpanel"
size="compact"
tone="base"
>
稿 稿
</div> </PlatformEmptyState>
</div> </div>
); );
} }
@@ -377,34 +398,39 @@ export function BigFishResultView({
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0"> <div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div className="platform-result-hero relative overflow-hidden rounded-[1.8rem] border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.2),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5"> <div className="platform-result-hero relative overflow-hidden rounded-[1.8rem] border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.2),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<button <PlatformIconButton
type="button"
onClick={onBack} onClick={onBack}
disabled={isBusy} disabled={isBusy}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45" label="返回"
> title="返回"
<ArrowLeft className="h-4 w-4" /> variant="darkMini"
</button> className="h-10 w-10 !border-white/16 !bg-white/10 !text-white/84 backdrop-blur hover:!bg-white/16 hover:!text-white"
icon={<ArrowLeft className="h-4 w-4" />}
/>
<div className="flex gap-2"> <div className="flex gap-2">
<button <PlatformActionButton
type="button"
disabled={isBusy} disabled={isBusy}
onClick={() => { onClick={() => {
onStartTestRun(); onStartTestRun();
}} }}
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45" surface="editorDark"
tone="secondary"
shape="pill"
className="!border-white/16 !bg-white/12 !text-white hover:!bg-white/18"
> >
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
</button> </PlatformActionButton>
<button <PlatformActionButton
type="button"
disabled={!canClickPublish} disabled={!canClickPublish}
onClick={() => { onClick={() => {
setIsPublishSubmitting(true); setIsPublishSubmitting(true);
onExecuteAction({ action: 'big_fish_publish_game' }); onExecuteAction({ action: 'big_fish_publish_game' });
}} }}
className="inline-flex items-center gap-2 rounded-full bg-cyan-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45" surface="editorDark"
tone="primary"
shape="pill"
className="!border-cyan-200/70 !bg-cyan-200 !text-slate-950 hover:!bg-cyan-100"
> >
{isPublishSubmitting && isBusy && !isPublished ? ( {isPublishSubmitting && isBusy && !isPublished ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -416,7 +442,7 @@ export function BigFishResultView({
: isPublishSubmitting && isBusy : isPublishSubmitting && isBusy
? '发布中' ? '发布中'
: '发布'} : '发布'}
</button> </PlatformActionButton>
</div> </div>
</div> </div>
<div className="mt-6"> <div className="mt-6">
@@ -428,15 +454,24 @@ export function BigFishResultView({
</div> </div>
</div> </div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-cyan-50/78"> <div className="mt-4 flex flex-wrap gap-2 text-xs text-cyan-50/78">
<span className="rounded-full bg-white/10 px-3 py-1"> <PlatformPillBadge
tone="lightOverlay"
className="border-transparent bg-white/10"
>
{draft.coreFun} {draft.coreFun}
</span> </PlatformPillBadge>
<span className="rounded-full bg-white/10 px-3 py-1"> <PlatformPillBadge
tone="lightOverlay"
className="border-transparent bg-white/10"
>
{draft.ecologyTheme} {draft.ecologyTheme}
</span> </PlatformPillBadge>
<span className="rounded-full bg-white/10 px-3 py-1"> <PlatformPillBadge
tone="lightOverlay"
className="border-transparent bg-white/10"
>
{draft.runtimeParams.levelCount} {draft.runtimeParams.levelCount}
</span> </PlatformPillBadge>
</div> </div>
</div> </div>
@@ -456,7 +491,12 @@ export function BigFishResultView({
</div> </div>
<aside className="min-h-0 space-y-3 overflow-y-auto"> <aside className="min-h-0 space-y-3 overflow-y-auto">
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4"> <PlatformSubpanel
as="section"
surface="flat"
radius="xl"
className="bg-[var(--platform-subpanel-fill)]"
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<div className="text-sm font-black text-[var(--platform-text-strong)]"> <div className="text-sm font-black text-[var(--platform-text-strong)]">
@@ -468,29 +508,36 @@ export function BigFishResultView({
</div> </div>
<ImagePlus className="h-5 w-5 text-cyan-600" /> <ImagePlus className="h-5 w-5 text-cyan-600" />
</div> </div>
<div className="mt-3 aspect-[9/16] overflow-hidden rounded-[1.2rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.2),transparent_62%),linear-gradient(180deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))]"> <PlatformMediaFrame
{backgroundPreviewUrl ? ( src={backgroundPreviewUrl}
<ResolvedAssetImage alt={`${draft.background.theme} 场地背景`}
src={backgroundPreviewUrl} fallbackLabel="场地背景"
alt={`${draft.background.theme} 场地背景`} fallbackContent={<span className="sr-only"></span>}
className="h-full w-full object-cover" aspect="portrait"
/> surface="none"
) : null} className="mt-3 rounded-[1.2rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.2),transparent_62%),linear-gradient(180deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))]"
</div> />
<button <PlatformActionButton
type="button"
disabled={isBusy} disabled={isBusy}
onClick={() => { onClick={() => {
setStudioTarget({ kind: 'stage_background' }); setStudioTarget({ kind: 'stage_background' });
}} }}
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-full bg-cyan-600 px-4 py-2 text-sm font-bold text-white disabled:opacity-45" shape="pill"
size="xs"
fullWidth
className="mt-3 gap-2"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
</button> </PlatformActionButton>
</div> </PlatformSubpanel>
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4"> <PlatformSubpanel
as="section"
surface="flat"
radius="xl"
className="bg-[var(--platform-subpanel-fill)]"
>
<div className="text-sm font-black text-[var(--platform-text-strong)]"> <div className="text-sm font-black text-[var(--platform-text-strong)]">
</div> </div>
@@ -504,25 +551,35 @@ export function BigFishResultView({
{session.assetCoverage.requiredLevelCount * 2} {session.assetCoverage.requiredLevelCount * 2}
</div> </div>
<div> <div>
{session.assetCoverage.backgroundReady ? '已完成' : '待生成'} {' '}
{session.assetCoverage.backgroundReady ? '已完成' : '待生成'}
</div> </div>
</div> </div>
{isPublished ? ( {isPublished ? (
<div className="mt-3 text-sm font-semibold text-emerald-600"> <div className="mt-3">
<PlatformPillBadge tone="success" size="sm">
</PlatformPillBadge>
</div> </div>
) : blockers.length > 0 ? ( ) : blockers.length > 0 ? (
<div className="mt-3 space-y-1 text-xs leading-5 text-amber-700"> <PlatformStatusMessage
tone="warning"
surface="platform"
size="xs"
className="mt-3 space-y-1 leading-5"
>
{blockers.slice(0, 4).map((blocker) => ( {blockers.slice(0, 4).map((blocker) => (
<div key={blocker}>{blocker}</div> <div key={blocker}>{blocker}</div>
))} ))}
</div> </PlatformStatusMessage>
) : ( ) : (
<div className="mt-3 text-sm font-semibold text-emerald-600"> <div className="mt-3">
<PlatformPillBadge tone="success" size="sm">
</PlatformPillBadge>
</div> </div>
)} )}
</div> </PlatformSubpanel>
</aside> </aside>
</div> </div>
@@ -562,37 +619,20 @@ function BigFishResultErrorModal({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<UnifiedModal <PlatformAcknowledgeStatusDialog
open status="error"
title="发布失败" title="发布失败"
description={message}
onClose={onClose} onClose={onClose}
closeOnBackdrop={false} icon={<Waves className="h-4 w-4" />}
showCloseButton={false} iconLabel="发布失败提示"
size="sm" iconClassName="mt-0.5 bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]"
actionClassName="border-slate-950 bg-slate-950 text-white"
zIndexClassName="z-[160]" zIndexClassName="z-[160]"
overlayClassName="bg-slate-950/58" overlayClassName="bg-slate-950/58"
panelClassName="border-red-100/80 bg-white text-slate-950 shadow-2xl" panelClassName="border-red-100/80 bg-white text-slate-950 shadow-2xl"
bodyClassName="p-5" bodyClassName="px-5 pb-5 pt-6 text-center"
footer={( />
<button
type="button"
onClick={onClose}
className="inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
>
</button>
)}
footerClassName="border-t-0 px-5 pb-5 pt-0"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
<Waves className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 text-sm leading-6 text-slate-600">
{message}
</div>
</div>
</UnifiedModal>
); );
} }

View File

@@ -1,9 +1,10 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import type { BigFishRuntimeSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish'; import type { BigFishRuntimeSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import * as clipboardService from '../../services/clipboard';
import { BigFishRuntimeShell } from './BigFishRuntimeShell'; import { BigFishRuntimeShell } from './BigFishRuntimeShell';
vi.mock('../ResolvedAssetImage', () => ({ vi.mock('../ResolvedAssetImage', () => ({
@@ -18,6 +19,10 @@ vi.mock('../ResolvedAssetImage', () => ({
}) => (src ? <img src={src} alt={alt} className={className} /> : null), }) => (src ? <img src={src} alt={alt} className={className} /> : null),
})); }));
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
function createRun( function createRun(
status: BigFishRuntimeSnapshotResponse['status'], status: BigFishRuntimeSnapshotResponse['status'],
): BigFishRuntimeSnapshotResponse { ): BigFishRuntimeSnapshotResponse {
@@ -48,6 +53,10 @@ function dispatchPointerEvent(
target.dispatchEvent(event); target.dispatchEvent(event);
} }
afterEach(() => {
vi.clearAllMocks();
});
describe('BigFishRuntimeShell', () => { describe('BigFishRuntimeShell', () => {
test('renders restart and exit actions after a failed run', () => { test('renders restart and exit actions after a failed run', () => {
const onBack = vi.fn(); const onBack = vi.fn();
@@ -107,6 +116,36 @@ describe('BigFishRuntimeShell', () => {
expect(screen.queryByRole('dialog', { name: '玩法规则' })).toBeNull(); expect(screen.queryByRole('dialog', { name: '玩法规则' })).toBeNull();
}); });
test('copies public work share text through unified feedback', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
render(
<BigFishRuntimeShell
run={createRun('running')}
shareTitle="深海追击"
sharePublicWorkCode="BF-001"
onBack={() => {}}
onSubmitInput={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '分享作品' }));
await waitFor(() => {
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
expect.stringContaining('邀请你来玩《深海追击》'),
);
});
const copiedText = vi.mocked(clipboardService.copyTextToClipboard).mock
.calls[0]?.[0];
expect(copiedText).toContain('作品号BF-001');
expect(copiedText).toContain('/runtime/big-fish?work=BF-001');
expect(
screen.getByRole('button', { name: '分享内容已复制' }),
).toBeTruthy();
});
test('keeps moving in the last sampled direction after drag ends', async () => { test('keeps moving in the last sampled direction after drag ends', async () => {
const onSubmitInput = vi.fn(); const onSubmitInput = vi.fn();

View File

@@ -1,5 +1,11 @@
import { ArrowLeft, CircleHelp, Loader2, RotateCcw, Share2 } from 'lucide-react'; import { ArrowLeft, CircleHelp, Loader2, RotateCcw, Share2 } from 'lucide-react';
import { type PointerEvent, useEffect, useRef, useState } from 'react'; import {
type PointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type { import type {
BigFishAssetSlotResponse, BigFishAssetSlotResponse,
@@ -8,8 +14,9 @@ import type {
SubmitBigFishInputRequest, SubmitBigFishInputRequest,
} from '../../../packages/shared/src/contracts/bigFish'; } from '../../../packages/shared/src/contracts/bigFish';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard'; import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { UnifiedModal } from '../common/UnifiedModal'; import { UnifiedModal } from '../common/UnifiedModal';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { ResolvedAssetImage } from '../ResolvedAssetImage';
type TouchOrigin = { type TouchOrigin = {
@@ -238,9 +245,7 @@ export function BigFishRuntimeShell({
const currentTouchRef = useRef<TouchSample | null>(null); const currentTouchRef = useRef<TouchSample | null>(null);
const lastTouchSampleRef = useRef<TouchSample | null>(null); const lastTouchSampleRef = useRef<TouchSample | null>(null);
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false); const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>( const { copyState: shareState, copyText: copyShareText } = useCopyFeedback();
'idle',
);
const [stick, setStick] = useState({ x: 0, y: 0 }); const [stick, setStick] = useState({ x: 0, y: 0 });
const stickRef = useRef(stick); const stickRef = useRef(stick);
@@ -248,6 +253,11 @@ export function BigFishRuntimeShell({
stickRef.current = stick; stickRef.current = stick;
}, [stick]); }, [stick]);
const submitDirection = useCallback((direction: SubmitBigFishInputRequest) => {
setStick(direction);
onSubmitInput(direction);
}, [onSubmitInput]);
useEffect(() => { useEffect(() => {
if (run?.status !== 'running') { if (run?.status !== 'running') {
return undefined; return undefined;
@@ -287,12 +297,7 @@ export function BigFishRuntimeShell({
return () => { return () => {
window.clearInterval(timer); window.clearInterval(timer);
}; };
}, [run?.status, touchOrigin]); }, [run?.status, submitDirection, touchOrigin]);
const submitDirection = (direction: SubmitBigFishInputRequest) => {
setStick(direction);
onSubmitInput(direction);
};
const sharePublicWork = () => { const sharePublicWork = () => {
const publicWorkCode = sharePublicWorkCode?.trim(); const publicWorkCode = sharePublicWorkCode?.trim();
if (!publicWorkCode) { if (!publicWorkCode) {
@@ -310,10 +315,7 @@ export function BigFishRuntimeShell({
const title = shareTitle?.trim() || '大鱼吃小鱼'; const title = shareTitle?.trim() || '大鱼吃小鱼';
const shareText = `邀请你来玩《${title}\n作品号${publicWorkCode}\n${shareUrl}`; const shareText = `邀请你来玩《${title}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => { void copyShareText(shareText);
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
}; };
const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => { const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => {
@@ -411,27 +413,17 @@ export function BigFishRuntimeShell({
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sharePublicWorkCode?.trim() ? ( {sharePublicWorkCode?.trim() ? (
<button <CopyFeedbackButton
type="button" state={shareState}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享作品'
}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
onClick={sharePublicWork} onClick={sharePublicWork}
idleLabel="分享作品"
copiedLabel="分享内容已复制"
failedLabel="分享内容复制失败"
idleIcon={<Share2 className="h-4 w-4" />}
copiedIcon={<Share2 className="h-4 w-4" />}
showLabel={false}
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur" className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur"
> />
<Share2 className="h-4 w-4" />
</button>
) : null} ) : null}
<button <button
type="button" type="button"

View File

@@ -0,0 +1,73 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { CopyCodeButton } from './CopyCodeButton';
test('renders public work code with default accessible copy label', () => {
render(
<CopyCodeButton
state="idle"
code="PZ-001"
className="code-chip"
codeClassName="code-value"
/>,
);
const button = screen.getByRole('button', { name: '复制作品号 PZ-001' });
expect(button.className).toContain('code-chip');
expect(button.getAttribute('title')).toBe('复制作品号');
expect(screen.getByText('作品号')).toBeTruthy();
expect(screen.getByText('PZ-001').className).toContain('code-value');
});
test('renders copied and failed suffixes without business-side fragments', () => {
const { rerender } = render(<CopyCodeButton state="copied" code="CW-001" />);
expect(screen.getByText('已复制')).toBeTruthy();
rerender(<CopyCodeButton state="failed" code="CW-001" />);
expect(screen.getByText('复制失败')).toBeTruthy();
});
test('supports compact code-only chips', () => {
render(
<CopyCodeButton
state="copied"
code="PZ-001"
codeLabel={null}
showIcon={false}
accessibleLabel="复制作品号 PZ-001"
title="复制作品号"
/>,
);
const button = screen.getByRole('button', { name: '复制作品号 PZ-001' });
expect(button.textContent).toBe('PZ-001已复制');
expect(button.querySelector('svg')).toBeNull();
expect(button.getAttribute('title')).toBe('复制作品号');
});
test('can opt into shared pill action chrome for short codes', () => {
render(
<CopyCodeButton
state="idle"
code="RPG-001"
actionAppearance="pill"
actionPillSize="xxs"
className="tracking-[0.18em]"
/>,
);
const button = screen.getByRole('button', { name: '复制作品号 RPG-001' });
expect(button.className).toContain('rounded-full');
expect(button.className).toContain('bg-white/72');
expect(button.className).toContain('text-[10px]');
expect(button.className).toContain('tracking-[0.18em]');
expect(button.className).not.toContain('platform-pill');
});

View File

@@ -0,0 +1,133 @@
import { Copy } from 'lucide-react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import {
CopyFeedbackButton,
type CopyFeedbackButtonActionAppearance,
} from './CopyFeedbackButton';
import type {
PlatformPillBadgeSize,
PlatformPillBadgeTone,
} from './platformPillBadgeModel';
import type { CopyFeedbackState } from './useCopyFeedback';
type CopyCodeButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
state: CopyFeedbackState;
code: string;
codeLabel?: ReactNode;
copiedSuffix?: ReactNode;
failedSuffix?: ReactNode;
idleIcon?: ReactNode;
copiedIcon?: ReactNode;
failedIcon?: ReactNode;
showIcon?: boolean;
labelClassName?: string;
codeClassName?: string;
suffixClassName?: string;
accessibleLabel?: string;
title?: string;
actionAppearance?: CopyFeedbackButtonActionAppearance;
actionPillTone?: PlatformPillBadgeTone;
actionPillSize?: PlatformPillBadgeSize;
};
function resolveCodeLabelText(codeLabel: ReactNode) {
return typeof codeLabel === 'string' ? codeLabel : '内容';
}
function renderCodeLabel({
code,
codeLabel,
codeClassName,
labelClassName,
suffix,
suffixClassName,
}: {
code: string;
codeLabel: ReactNode;
codeClassName?: string;
labelClassName?: string;
suffix?: ReactNode;
suffixClassName?: string;
}) {
return (
<>
{codeLabel ? <span className={labelClassName}>{codeLabel}</span> : null}
<span className={codeClassName}>{code}</span>
{suffix ? <span className={suffixClassName}>{suffix}</span> : null}
</>
);
}
/**
* 统一代码复制按钮。
* 用于作品号、用户号等短代码 chip收口三态文案和默认可访问名称。
*/
export function CopyCodeButton({
state,
code,
codeLabel = '作品号',
copiedSuffix = '已复制',
failedSuffix = '复制失败',
idleIcon = <Copy className="h-4 w-4" />,
copiedIcon,
failedIcon,
showIcon = true,
labelClassName,
codeClassName,
suffixClassName,
accessibleLabel,
title,
actionAppearance,
actionPillTone,
actionPillSize,
...buttonProps
}: CopyCodeButtonProps) {
const labelText = resolveCodeLabelText(codeLabel);
const defaultAccessibleLabel =
labelText === '内容' ? `复制 ${code}` : `复制${labelText} ${code}`;
const defaultTitle = labelText === '内容' ? '复制' : `复制${labelText}`;
return (
<CopyFeedbackButton
{...buttonProps}
state={state}
idleLabel={renderCodeLabel({
code,
codeLabel,
codeClassName,
labelClassName,
})}
copiedLabel={renderCodeLabel({
code,
codeLabel,
codeClassName,
labelClassName,
suffix: copiedSuffix,
suffixClassName,
})}
failedLabel={renderCodeLabel({
code,
codeLabel,
codeClassName,
labelClassName,
suffix: failedSuffix,
suffixClassName,
})}
idleIcon={idleIcon}
copiedIcon={copiedIcon ?? idleIcon}
failedIcon={failedIcon ?? idleIcon}
showIcon={showIcon}
actionAppearance={actionAppearance}
actionPillTone={actionPillTone}
actionPillSize={actionPillSize}
aria-label={
buttonProps['aria-label'] ?? accessibleLabel ?? defaultAccessibleLabel
}
title={title ?? defaultTitle}
/>
);
}

View File

@@ -0,0 +1,141 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { CopyFeedbackButton } from './CopyFeedbackButton';
test('renders idle copy label and icon by default', () => {
render(
<CopyFeedbackButton
state="idle"
idleLabel="分享"
className="platform-button"
/>,
);
const button = screen.getByRole('button', { name: '分享' });
expect(button.className).toContain('platform-button');
expect(within(button).getByText('分享')).toBeTruthy();
expect(button.querySelector('svg')).toBeTruthy();
});
test('switches copied and failed feedback labels', () => {
const { rerender } = render(
<CopyFeedbackButton
state="copied"
idleLabel="复制作品号"
copiedLabel="作品号已复制"
failedLabel="作品号复制失败"
/>,
);
expect(screen.getByRole('button', { name: '作品号已复制' })).toBeTruthy();
rerender(
<CopyFeedbackButton
state="failed"
idleLabel="复制作品号"
copiedLabel="作品号已复制"
failedLabel="作品号复制失败"
/>,
);
expect(screen.getByRole('button', { name: '作品号复制失败' })).toBeTruthy();
});
test('keeps custom accessible label for compact buttons', () => {
render(
<CopyFeedbackButton
state="copied"
idleLabel="作品号 PZ-001"
aria-label="复制作品号 PZ-001"
title="复制作品号"
showIcon={false}
/>,
);
const button = screen.getByRole('button', { name: '复制作品号 PZ-001' });
expect(button.textContent).toBe('已复制');
expect(button.getAttribute('title')).toBe('复制作品号');
});
test('supports icon-only buttons with feedback labels kept in accessibility', () => {
render(
<CopyFeedbackButton
state="copied"
idleLabel="分享作品"
copiedLabel="分享内容已复制"
showLabel={false}
className="icon-button"
/>,
);
const button = screen.getByRole('button', { name: '分享内容已复制' });
expect(button.textContent).toBe('');
expect(button.querySelector('svg')).toBeTruthy();
});
test('allows overriding accessible label without business-side state branches', () => {
render(
<CopyFeedbackButton
state="failed"
idleLabel="分享作品"
failedLabel="复制失败"
accessibleLabel="分享内容复制失败"
showLabel={false}
/>,
);
const button = screen.getByRole('button', {
name: '分享内容复制失败',
});
expect(button.getAttribute('title')).toBe('分享内容复制失败');
});
test('can opt into platform action button chrome', () => {
render(
<CopyFeedbackButton
state="idle"
idleLabel="复制报错"
actionSurface="platform"
actionShape="pill"
actionFullWidth
aria-label="复制错误详情"
title="复制错误详情"
/>,
);
const button = screen.getByRole('button', { name: '复制错误详情' });
expect(button.className).toContain('platform-button--primary');
expect(button.className).toContain('w-full');
expect(button.className).toContain('rounded-full');
expect(button.className).toContain('disabled:cursor-not-allowed');
expect(button.getAttribute('title')).toBe('复制错误详情');
expect(button.textContent).toContain('复制报错');
});
test('can opt into shared pill action chrome', () => {
render(
<CopyFeedbackButton
state="idle"
idleLabel="分享作品"
actionAppearance="pill"
actionPillSize="xxs"
className="tracking-[0.18em]"
/>,
);
const button = screen.getByRole('button', { name: '分享作品' });
expect(button.className).toContain('rounded-full');
expect(button.className).toContain('bg-white/72');
expect(button.className).toContain('text-[10px]');
expect(button.className).toContain('tracking-[0.18em]');
expect(button.className).not.toContain('platform-pill');
});

View File

@@ -0,0 +1,157 @@
import { Check, Copy } from 'lucide-react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import {
type PlatformActionButtonSize,
type PlatformActionButtonShape,
type PlatformActionButtonSurface,
type PlatformActionButtonTone,
} from './platformActionButtonModel';
import { PlatformActionButton } from './PlatformActionButton';
import {
getPlatformPillBadgeClassName,
type PlatformPillBadgeSize,
type PlatformPillBadgeTone,
} from './platformPillBadgeModel';
import type { CopyFeedbackState } from './useCopyFeedback';
export type CopyFeedbackButtonActionAppearance = 'plain' | 'pill';
type CopyFeedbackButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
state: CopyFeedbackState;
idleLabel: ReactNode;
copiedLabel?: ReactNode;
failedLabel?: ReactNode;
idleIcon?: ReactNode;
copiedIcon?: ReactNode;
failedIcon?: ReactNode;
showIcon?: boolean;
showLabel?: boolean;
labelClassName?: string;
accessibleLabel?: string;
actionSurface?: PlatformActionButtonSurface;
actionTone?: PlatformActionButtonTone;
actionSize?: PlatformActionButtonSize;
actionShape?: PlatformActionButtonShape;
actionFullWidth?: boolean;
actionAppearance?: CopyFeedbackButtonActionAppearance;
actionPillTone?: PlatformPillBadgeTone;
actionPillSize?: PlatformPillBadgeSize;
};
function resolveCopyFeedbackLabel(
state: CopyFeedbackState,
idleLabel: ReactNode,
copiedLabel: ReactNode,
failedLabel: ReactNode,
) {
if (state === 'copied') {
return copiedLabel;
}
if (state === 'failed') {
return failedLabel;
}
return idleLabel;
}
/**
* 统一复制反馈按钮。
* useCopyFeedback 负责复制状态,这里只收口按钮里的图标、文案和可访问名称。
*/
export function CopyFeedbackButton({
state,
idleLabel,
copiedLabel = '已复制',
failedLabel = '复制失败',
idleIcon = <Copy className="h-4 w-4" />,
copiedIcon = <Check className="h-4 w-4" />,
failedIcon,
showIcon = true,
showLabel = true,
labelClassName,
accessibleLabel: accessibleLabelOverride,
actionSurface,
actionTone = 'primary',
actionSize = 'sm',
actionShape = 'default',
actionFullWidth = false,
actionAppearance = 'plain',
actionPillTone = 'neutral',
actionPillSize = 'xs',
className,
'aria-label': ariaLabel,
title,
...buttonProps
}: CopyFeedbackButtonProps) {
const label = resolveCopyFeedbackLabel(
state,
idleLabel,
copiedLabel,
failedLabel,
);
const icon =
state === 'copied'
? copiedIcon
: state === 'failed'
? (failedIcon ?? idleIcon)
: idleIcon;
const accessibleLabel =
accessibleLabelOverride ??
(typeof label === 'string'
? label
: typeof idleLabel === 'string'
? idleLabel
: undefined);
const resolvedAriaLabel = ariaLabel ?? accessibleLabel;
const resolvedTitle =
title ?? (typeof accessibleLabel === 'string' ? accessibleLabel : undefined);
const content = (
<>
{showIcon ? icon : null}
{showLabel ? <span className={labelClassName}>{label}</span> : null}
</>
);
if (actionSurface) {
return (
<PlatformActionButton
surface={actionSurface}
tone={actionTone}
size={actionSize}
shape={actionShape}
fullWidth={actionFullWidth}
className={className}
{...buttonProps}
aria-label={resolvedAriaLabel}
title={resolvedTitle}
>
{content}
</PlatformActionButton>
);
}
return (
<button
type="button"
className={[
actionAppearance === 'pill'
? getPlatformPillBadgeClassName({
tone: actionPillTone,
size: actionPillSize,
})
: null,
className,
]
.filter(Boolean)
.join(' ')}
{...buttonProps}
aria-label={resolvedAriaLabel}
title={resolvedTitle}
>
{content}
</button>
);
}

View File

@@ -0,0 +1,39 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { CopyFeedbackMessage } from './CopyFeedbackMessage';
test('renders nothing while copy feedback is idle', () => {
const { container } = render(
<CopyFeedbackMessage state="idle" className="copy-toast" />,
);
expect(container.textContent).toBe('');
});
test('renders copied and failed feedback labels', () => {
const { rerender } = render(
<CopyFeedbackMessage
state="copied"
copiedLabel="分享内容已复制"
failedLabel="分享失败"
className="copy-toast"
/>,
);
const copied = screen.getByText('分享内容已复制');
expect(copied.className).toContain('copy-toast');
rerender(
<CopyFeedbackMessage
state="failed"
copiedLabel="分享内容已复制"
failedLabel="分享失败"
className="copy-toast"
/>,
);
expect(screen.getByText('分享失败')).toBeTruthy();
});

View File

@@ -0,0 +1,29 @@
import type { HTMLAttributes, ReactNode } from 'react';
import type { CopyFeedbackState } from './useCopyFeedback';
type CopyFeedbackMessageProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
state: CopyFeedbackState;
copiedLabel?: ReactNode;
failedLabel?: ReactNode;
};
/**
* 统一复制反馈提示。
* 非按钮区域只负责展示成功 / 失败,不在业务页重复写 copied / failed 分支。
*/
export function CopyFeedbackMessage({
state,
copiedLabel = '已复制',
failedLabel = '复制失败',
...divProps
}: CopyFeedbackMessageProps) {
if (state === 'idle') {
return null;
}
return <div {...divProps}>{state === 'copied' ? copiedLabel : failedLabel}</div>;
}

View File

@@ -4,10 +4,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import {
CreativeAudioInputPanel,
} from './CreativeAudioInputPanel';
import type { CreativeAudioAsset } from './creativeAudioFileAsset'; import type { CreativeAudioAsset } from './creativeAudioFileAsset';
import { CreativeAudioInputPanel } from './CreativeAudioInputPanel';
type TestAudioAsset = CreativeAudioAsset; type TestAudioAsset = CreativeAudioAsset;
@@ -37,16 +35,19 @@ function buildAsset(overrides: Partial<TestAudioAsset> = {}): TestAudioAsset {
} }
function renderPanel( function renderPanel(
overrides: Partial<ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>> = {}, overrides: Partial<
ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>
> = {},
) { ) {
const onAssetChange = vi.fn(); const onAssetChange = vi.fn();
const onError = vi.fn(); const onError = vi.fn();
const readFileAsAsset = vi.fn(async (file: File, source: 'uploaded' | 'recorded') => const readFileAsAsset = vi.fn(
buildAsset({ async (file: File, source: 'uploaded' | 'recorded') =>
audioSrc: `blob:${source}`, buildAsset({
source, audioSrc: `blob:${source}`,
prompt: file.name, source,
}), prompt: file.name,
}),
); );
const rendered = render( const rendered = render(
@@ -77,7 +78,16 @@ function getUploadInput() {
test('音频面板按需显示最长限制标签', () => { test('音频面板按需显示最长限制标签', () => {
renderPanel({ limitLabel: '最长 1 秒' }); renderPanel({ limitLabel: '最长 1 秒' });
expect(screen.getByText('最长 1 秒')).toBeTruthy(); const limitBadge = screen.getByText('最长 1 秒');
expect(limitBadge.className).toContain('rounded-full');
expect(limitBadge.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(limitBadge.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(limitBadge.className).toContain('text-[var(--platform-text-soft)]');
expect(limitBadge.className).toContain('px-2');
expect(limitBadge.className).toContain('py-1');
}); });
test('音频面板未传限制标签时不渲染限制提示', () => { test('音频面板未传限制标签时不渲染限制提示', () => {
@@ -239,7 +249,9 @@ test('录音停止后按 recorded 来源读取音频', async () => {
const { readFileAsAsset, onAssetChange } = renderPanel(); const { readFileAsAsset, onAssetChange } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' })); fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy()); await waitFor(() =>
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(),
);
fireEvent.click(screen.getByRole('button', { name: '停止' })); fireEvent.click(screen.getByRole('button', { name: '停止' }));
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1)); await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
@@ -275,7 +287,9 @@ test('录音保存失败时提示错误', async () => {
renderPanel({ readFileAsAsset, onError }); renderPanel({ readFileAsAsset, onError });
fireEvent.click(screen.getByRole('button', { name: '录音' })); fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy()); await waitFor(() =>
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(),
);
fireEvent.click(screen.getByRole('button', { name: '停止' })); fireEvent.click(screen.getByRole('button', { name: '停止' }));
await waitFor(() => await waitFor(() =>

View File

@@ -5,6 +5,9 @@ import {
type CreativeAudioAsset, type CreativeAudioAsset,
readCreativeAudioFileAsAsset, readCreativeAudioFileAsAsset,
} from './creativeAudioFileAsset'; } from './creativeAudioFileAsset';
import { PlatformActionButton } from './PlatformActionButton';
import { PlatformPillBadge } from './PlatformPillBadge';
import { PlatformSubpanel } from './PlatformSubpanel';
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = { type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
disabled?: boolean; disabled?: boolean;
@@ -93,91 +96,95 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
}; };
return ( return (
<section className="platform-subpanel rounded-[1.25rem] p-4"> <PlatformSubpanel
<div className="mb-3 flex items-center justify-between gap-3"> title={
<div className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<div className="text-sm font-black text-[var(--platform-text-strong)]"> <span>{title}</span>
{title}
</div>
{limitLabel ? ( {limitLabel ? (
<div className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-black text-[var(--platform-text-soft)]"> <PlatformPillBadge tone="muted" size="xs" className="px-2 py-1">
{limitLabel} {limitLabel}
</div> </PlatformPillBadge>
) : null} ) : null}
</div> </span>
{asset ? ( }
<button titleVariant="strong"
type="button" actions={
asset ? (
<PlatformActionButton
onClick={() => onAssetChange(null)} onClick={() => onAssetChange(null)}
disabled={disabled} disabled={disabled}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs" tone="ghost"
size="xs"
className="min-h-0"
> >
</button> </PlatformActionButton>
) : null} ) : null
</div> }
<div className="mt-3 flex flex-wrap items-center gap-2"> bodyClassName="mt-3 flex flex-wrap items-center gap-2"
<label >
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${ <PlatformActionButton
disabled ? 'pointer-events-none opacity-55' : '' asChild="label"
}`} tone="secondary"
> className={`min-h-10 cursor-pointer gap-2 px-3 ${
<Upload className="h-4 w-4" /> disabled ? 'pointer-events-none opacity-55' : ''
}`}
<input >
type="file" <Upload className="h-4 w-4" />
accept="audio/*"
disabled={disabled} <input
className="sr-only" type="file"
onChange={(event) => { accept="audio/*"
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
void readFileAsAsset(file, 'uploaded')
.then((nextAsset) => {
onError(null);
onAssetChange(nextAsset);
})
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}}
/>
</label>
<button
type="button"
disabled={disabled} disabled={disabled}
onClick={() => { className="sr-only"
if (isRecording) { onChange={(event) => {
stopRecording(); const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return; return;
} }
void startRecording(); void readFileAsAsset(file, 'uploaded')
.then((nextAsset) => {
onError(null);
onAssetChange(nextAsset);
})
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}} }}
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm" />
> </PlatformActionButton>
{isRecording ? ( <PlatformActionButton
<Pause className="h-4 w-4" /> disabled={disabled}
) : ( onClick={() => {
<Mic className="h-4 w-4" /> if (isRecording) {
)} stopRecording();
{isRecording ? '停止' : '录音'} return;
</button> }
{asset?.audioSrc ? ( void startRecording();
<audio controls src={asset.audioSrc} className="h-10 max-w-full" /> }}
tone="ghost"
className="min-h-10 gap-2 px-3"
>
{isRecording ? (
<Pause className="h-4 w-4" />
) : ( ) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]"> <Mic className="h-4 w-4" />
{asset ? '音效已选择' : defaultLabel}
</div>
)} )}
</div> {isRecording ? '停止' : '录音'}
</section> </PlatformActionButton>
{asset?.audioSrc ? (
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{asset ? '音效已选择' : defaultLabel}
</div>
)}
</PlatformSubpanel>
); );
} }

View File

@@ -40,6 +40,7 @@ test('creative image input panel handles reference uploads and preview', () => {
]} ]}
imageModelPicker={<div />} imageModelPicker={<div />}
submitLabel="生成" submitLabel="生成"
submitCostLabel="2泥点"
submitDisabled={false} submitDisabled={false}
labels={{ labels={{
imageField: '拼图画面', imageField: '拼图画面',
@@ -66,6 +67,24 @@ test('creative image input panel handles reference uploads and preview', () => {
const promptReferenceInput = screen.getByLabelText('上传参考图', { const promptReferenceInput = screen.getByLabelText('上传参考图', {
selector: 'input', selector: 'input',
}); });
const promptTextarea = screen.getByLabelText('画面描述');
const imageFieldLabel = screen.getByText('拼图画面');
const promptFieldLabel = screen.getByText('画面描述');
const emptyMainImageIconBadge = document.querySelector(
'[aria-hidden="true"]',
);
expect(imageFieldLabel.className).toContain('shrink-0');
expect(imageFieldLabel.className).toContain('text-sm font-black');
expect(promptFieldLabel.className).toContain('mb-0');
expect(promptFieldLabel.className).toContain('text-sm font-black');
expect(promptTextarea.className).toContain(
'border border-[var(--platform-subpanel-border)]',
);
expect(promptTextarea.className).toContain('rounded-[1.15rem]');
expect(promptTextarea.className).toContain('pb-14');
expect(emptyMainImageIconBadge?.className).toContain('h-14');
expect(emptyMainImageIconBadge?.className).toContain('rounded-full');
expect(emptyMainImageIconBadge?.className).toContain('bg-white/92');
expect((promptReferenceInput as HTMLInputElement).multiple).toBe(true); expect((promptReferenceInput as HTMLInputElement).multiple).toBe(true);
fireEvent.change(promptReferenceInput, { fireEvent.change(promptReferenceInput, {
@@ -77,27 +96,25 @@ test('creative image input panel handles reference uploads and preview', () => {
}, },
}); });
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith( expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith(
expect.arrayContaining([ expect.arrayContaining([expect.any(File), expect.any(File)]),
expect.any(File),
expect.any(File),
]),
); );
fireEvent.click( fireEvent.click(screen.getByRole('button', { name: '预览参考图 参考图 1' }));
screen.getByRole('button', { name: '预览参考图 参考图 1' }), expect(screen.getByRole('dialog', { name: '参考图 1' })).toBeTruthy();
);
expect(
screen.getByRole('dialog', { name: '参考图 1' }),
).toBeTruthy();
expect(screen.getByAltText('参考图预览')).toHaveProperty( expect(screen.getByAltText('参考图预览')).toHaveProperty(
'src', 'src',
expect.stringContaining('ref-1'), expect.stringContaining('ref-1'),
); );
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' })); fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
expect(screen.queryByRole('dialog', { name: '参考图 1' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '移除参考图 参考图 1' })); fireEvent.click(screen.getByRole('button', { name: '移除参考图 参考图 1' }));
expect(onPromptReferenceRemove).toHaveBeenCalledWith('ref-1'); expect(onPromptReferenceRemove).toHaveBeenCalledWith('ref-1');
fireEvent.click(screen.getByRole('button', { name: '生成' })); const costBadge = screen.getByText('2泥点');
expect(costBadge.className).toContain('rounded-full');
expect(costBadge.className).toContain('bg-white/24');
expect(costBadge.className).toContain('text-current');
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledTimes(1);
}); });
@@ -138,7 +155,9 @@ test('creative image input panel can opt out of filling the parent height', () =
const panel = container.querySelector('.creative-image-input-panel'); const panel = container.querySelector('.creative-image-input-panel');
const body = container.querySelector('.creative-image-input-panel__body'); const body = container.querySelector('.creative-image-input-panel__body');
const section = container.querySelector('.creative-image-input-panel__section'); const section = container.querySelector(
'.creative-image-input-panel__section',
);
expect(panel?.className).toContain('flex-none'); expect(panel?.className).toContain('flex-none');
expect(panel?.className).not.toContain('flex-1'); expect(panel?.className).not.toContain('flex-1');
expect(body?.className).toContain('flex-none'); expect(body?.className).toContain('flex-none');
@@ -183,7 +202,9 @@ test('creative image input panel fills the parent height by default', () => {
const panel = container.querySelector('.creative-image-input-panel'); const panel = container.querySelector('.creative-image-input-panel');
const body = container.querySelector('.creative-image-input-panel__body'); const body = container.querySelector('.creative-image-input-panel__body');
const section = container.querySelector('.creative-image-input-panel__section'); const section = container.querySelector(
'.creative-image-input-panel__section',
);
expect(panel?.className).toContain('flex-1'); expect(panel?.className).toContain('flex-1');
expect(panel?.className).not.toContain('flex-none'); expect(panel?.className).not.toContain('flex-none');
expect(body?.className).toContain('flex-1'); expect(body?.className).toContain('flex-1');
@@ -235,6 +256,54 @@ test('creative image input panel confirms before removing uploaded image', () =>
expect(onMainImageRemove).toHaveBeenCalledTimes(1); expect(onMainImageRemove).toHaveBeenCalledTimes(1);
}); });
test('creative image input panel closes reference preview on backdrop click', () => {
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[
{
id: 'ref-1',
label: '参考图 1',
imageSrc: 'data:image/png;base64,ref-1',
},
]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={() => {}}
onPromptReferenceRemove={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '预览参考图 参考图 1' }));
const dialog = screen.getByRole('dialog', { name: '参考图 1' });
fireEvent.click(dialog.parentElement as HTMLElement);
expect(screen.queryByRole('dialog', { name: '参考图 1' })).toBeNull();
});
test('creative image input panel supports a preview-only main image mode', () => { test('creative image input panel supports a preview-only main image mode', () => {
const onSubmit = vi.fn(); const onSubmit = vi.fn();
@@ -332,25 +401,76 @@ test('creative image input panel can preview the main image and keep upload on a
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' })); fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy(); expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(2); expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(
fireEvent.click( 2,
screen.getByRole('button', { name: '关闭关卡图片预览' }),
); );
fireEvent.click(screen.getByRole('button', { name: '关闭关卡图片预览' }));
expect(
screen.queryByRole('dialog', { name: '查看关卡图片' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '更换参考图' })); fireEvent.click(screen.getByRole('button', { name: '更换参考图' }));
expect(inputClickSpy).toHaveBeenCalledTimes(1); expect(inputClickSpy).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), { fireEvent.change(
target: { screen.getByLabelText('上传参考图', { selector: 'input' }),
files: [new File(['a'], 'level-reference.png', { type: 'image/png' })], {
target: {
files: [
new File(['a'], 'level-reference.png', { type: 'image/png' }),
],
},
}, },
}); );
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File)); expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
} finally { } finally {
inputClickSpy.mockRestore(); inputClickSpy.mockRestore();
} }
}); });
test('creative image input panel closes main image preview on backdrop click', () => {
render(
<CreativeImageInputPanel
mainImageClickMode="preview"
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
const dialog = screen.getByRole('dialog', { name: '查看关卡图片' });
fireEvent.click(dialog.parentElement as HTMLElement);
expect(screen.queryByRole('dialog', { name: '查看关卡图片' })).toBeNull();
});
test('creative image input panel can hide upload and history controls independently', () => { test('creative image input panel can hide upload and history controls independently', () => {
render( render(
<CreativeImageInputPanel <CreativeImageInputPanel
@@ -399,6 +519,55 @@ test('creative image input panel can hide upload and history controls independen
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull(); expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
}); });
test('creative image input panel uses floating icon button for history action', () => {
const onHistoryClick = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传描述参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
history: '选择历史图片',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onHistoryClick={onHistoryClick}
onSubmit={() => {}}
/>,
);
const historyButton = screen.getByRole('button', { name: '选择历史图片' });
expect(within(historyButton).getByText('历史')).toBeTruthy();
expect(historyButton.className).toContain('bg-white/94');
expect(historyButton.className).toContain('backdrop-blur');
expect(historyButton.className).toContain('gap-1.5');
fireEvent.click(historyButton);
expect(onHistoryClick).toHaveBeenCalledTimes(1);
});
test('creative image input panel does not show empty upload hint over a non-removable image', () => { test('creative image input panel does not show empty upload hint over a non-removable image', () => {
render( render(
<CreativeImageInputPanel <CreativeImageInputPanel
@@ -532,9 +701,7 @@ test('creative image input panel can upload prompt references while showing a ma
}, },
}); });
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([ expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([expect.any(File)]);
expect.any(File),
]);
expect( expect(
screen.getByRole('button', { name: '预览参考图 描述参考图 1' }), screen.getByRole('button', { name: '预览参考图 描述参考图 1' }),
).toBeTruthy(); ).toBeTruthy();

View File

@@ -1,14 +1,18 @@
import { import { History, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react';
History,
ImagePlus,
Loader2,
Sparkles,
Trash2,
X,
} from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react'; import { type ReactNode, useEffect, useRef, useState } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformActionButton } from './PlatformActionButton';
import { PlatformFieldLabel } from './PlatformFieldLabel';
import { PlatformIconBadge } from './PlatformIconBadge';
import { PlatformIconButton } from './PlatformIconButton';
import { PlatformPillBadge } from './PlatformPillBadge';
import { PlatformPillSwitch } from './PlatformPillSwitch';
import { PlatformStatusMessage } from './PlatformStatusMessage';
import { PlatformTextField } from './PlatformTextField';
import { PlatformUploadPreviewCard } from './PlatformUploadPreviewCard';
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
import { UnifiedModal } from './UnifiedModal';
export type CreativeImageInputReferenceImage = { export type CreativeImageInputReferenceImage = {
id: string; id: string;
@@ -131,7 +135,8 @@ export function CreativeImageInputPanel({
const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false); const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] = const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false); useState(false);
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw; const showPrompt =
mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
const shouldShowPromptReferences = const shouldShowPromptReferences =
canUploadPromptReferences ?? !uploadedImageSrc; canUploadPromptReferences ?? !uploadedImageSrc;
const promptReferenceUploadDisabled = const promptReferenceUploadDisabled =
@@ -201,9 +206,9 @@ export function CreativeImageInputPanel({
disabled ? 'opacity-55' : '' disabled ? 'opacity-55' : ''
}`} }`}
> >
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]"> <PlatformFieldLabel variant="form" className="shrink-0">
{labels.imageField} {labels.imageField}
</div> </PlatformFieldLabel>
<div className={imageFrameClassName}> <div className={imageFrameClassName}>
<div className={imageCardClassName}> <div className={imageCardClassName}>
{isMainImageUploadEnabled ? ( {isMainImageUploadEnabled ? (
@@ -258,76 +263,63 @@ export function CreativeImageInputPanel({
/> />
) : ( ) : (
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]"> <span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
<span className="flex h-14 w-14 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20"> <PlatformIconBadge
<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" /> icon={<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />}
</span> size="xl"
tone="soft"
className="border border-[var(--platform-subpanel-border)] bg-white/92 sm:h-20 sm:w-20"
/>
</span> </span>
)} )}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" /> <div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
{shouldShowMainImageUploadButton ? ( {shouldShowMainImageUploadButton ? (
<button <PlatformIconButton
type="button" variant="surfaceFloating"
label={labels.replaceImage}
title={labels.replaceImage}
disabled={disabled} disabled={disabled}
onClick={() => mainImageInputRef.current?.click()} onClick={() => mainImageInputRef.current?.click()}
className="absolute bottom-3 right-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55" icon={<ImagePlus className="h-4 w-4" />}
aria-label={labels.replaceImage} className="absolute bottom-3 right-3 z-10 h-10 w-10"
title={labels.replaceImage} />
>
<ImagePlus className="h-4 w-4" />
</button>
) : null} ) : null}
{shouldShowHistoryButton ? ( {shouldShowHistoryButton ? (
<button <PlatformIconButton
type="button" variant="surfaceFloating"
label={labels.history ?? '选择历史图片'}
title={labels.history ?? '选择历史图片'}
disabled={disabled} disabled={disabled}
onClick={onHistoryClick} onClick={onHistoryClick}
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] ${ icon={<History className="h-3.5 w-3.5" />}
disabled ? 'cursor-not-allowed opacity-55' : '' className="absolute right-3 top-3 z-10 gap-1.5 px-3 py-2 text-[11px] font-black"
}`}
aria-label={labels.history ?? '选择历史图片'}
title={labels.history ?? '选择历史图片'}
> >
<History className="h-3.5 w-3.5" />
<span></span> <span></span>
</button> </PlatformIconButton>
) : null} ) : null}
{canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? ( {canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? (
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur"> <PlatformPillSwitch
<span>AI重绘</span> label="AI重绘"
<input aria-label="AI重绘"
role="switch" checked={aiRedraw}
type="checkbox" disabled={disabled}
checked={aiRedraw} onChange={(event) =>
disabled={disabled} onAiRedrawChange(event.target.checked)
onChange={(event) => onAiRedrawChange(event.target.checked)} }
className="sr-only" className="absolute bottom-3 left-3 z-10"
aria-label="AI重绘" />
/>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition ${
aiRedraw ? 'bg-[var(--platform-accent)]' : 'bg-zinc-300'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
}`}
/>
</span>
</label>
) : null} ) : null}
{canEditMainImage && uploadedImageSrc && canRemoveMainImage ? ( {canEditMainImage &&
<button uploadedImageSrc &&
type="button" canRemoveMainImage ? (
<PlatformIconButton
variant="surfaceFloating"
label={labels.removeImage}
title={labels.removeImage}
disabled={disabled} disabled={disabled}
onClick={() => setIsRemoveImageConfirmOpen(true)} onClick={() => setIsRemoveImageConfirmOpen(true)}
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55" icon={<Trash2 className="h-4 w-4" />}
aria-label={labels.removeImage} className="absolute left-3 top-3 z-10 h-10 w-10"
title={labels.removeImage} />
>
<Trash2 className="h-4 w-4" />
</button>
) : isMainImageUploadEnabled && !uploadedImageSrc ? ( ) : isMainImageUploadEnabled && !uploadedImageSrc ? (
<label <label
htmlFor={mainImageInputId} htmlFor={mainImageInputId}
@@ -342,7 +334,9 @@ export function CreativeImageInputPanel({
) : null} ) : null}
</div> </div>
</div> </div>
{mainImageMeta ? <div className="mt-3 shrink-0">{mainImageMeta}</div> : null} {mainImageMeta ? (
<div className="mt-3 shrink-0">{mainImageMeta}</div>
) : null}
{imageLimitHint ? ( {imageLimitHint ? (
<div className="mt-2 shrink-0 text-center text-[11px] font-semibold text-[var(--platform-text-soft)]"> <div className="mt-2 shrink-0 text-center text-[11px] font-semibold text-[var(--platform-text-soft)]">
{imageLimitHint} {imageLimitHint}
@@ -352,88 +346,89 @@ export function CreativeImageInputPanel({
{showPrompt ? ( {showPrompt ? (
<div className="block shrink-0 lg:min-h-0"> <div className="block shrink-0 lg:min-h-0">
<label <label htmlFor={promptTextareaId} className="mb-2 block">
htmlFor={promptTextareaId} <PlatformFieldLabel variant="form" className="mb-0">
className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]" {promptLabel}
> </PlatformFieldLabel>
{promptLabel}
</label> </label>
<div className="relative"> <div className="relative">
<textarea <PlatformTextField
variant="textarea"
id={promptTextareaId} id={promptTextareaId}
value={prompt} value={prompt}
disabled={disabled} disabled={disabled}
rows={promptRows} rows={promptRows}
placeholder="" placeholder=""
onChange={(event) => onPromptChange(event.target.value)} onChange={(event) => onPromptChange(event.target.value)}
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]" size="lg"
density="roomy"
className="h-[6rem] min-h-[6rem] rounded-[1.15rem] pb-14 font-normal placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
aria-label={promptAriaLabel ?? promptLabel} aria-label={promptAriaLabel ?? promptLabel}
/> />
{imageModelPicker} {imageModelPicker}
{shouldShowPromptReferences && onPromptReferenceFilesSelect ? ( {shouldShowPromptReferences &&
<label onPromptReferenceFilesSelect ? (
className={`absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] hover:text-[var(--platform-accent)] ${ <PlatformIconButton
asChild="label"
variant="surfaceFloating"
label={labels.promptReferenceUpload}
title={labels.promptReferenceUpload}
icon={
<>
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept={mainImageAccept}
multiple
aria-label={labels.promptReferenceUpload}
disabled={promptReferenceUploadDisabled}
onChange={(event) => {
const files = Array.from(
event.currentTarget.files ?? [],
);
event.currentTarget.value = '';
if (files.length > 0) {
onPromptReferenceFilesSelect(files);
}
}}
className="sr-only"
/>
</>
}
className={`absolute bottom-3 right-3 z-10 h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)] ${
promptReferenceUploadDisabled promptReferenceUploadDisabled
? 'cursor-not-allowed opacity-55' ? 'cursor-not-allowed opacity-55'
: 'cursor-pointer' : 'cursor-pointer'
}`} }`}
aria-label={labels.promptReferenceUpload} />
title={labels.promptReferenceUpload}
>
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept={mainImageAccept}
multiple
aria-label={labels.promptReferenceUpload}
disabled={promptReferenceUploadDisabled}
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length > 0) {
onPromptReferenceFilesSelect(files);
}
}}
className="sr-only"
/>
</label>
) : null} ) : null}
</div> </div>
{shouldShowPromptReferences && promptReferenceImages.length > 0 ? ( {shouldShowPromptReferences &&
promptReferenceImages.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{promptReferenceImages.map((reference) => ( {promptReferenceImages.map((reference) => (
<div <PlatformUploadPreviewCard
key={reference.id} key={reference.id}
className="relative h-12 w-12 overflow-hidden rounded-[0.75rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-sm" imageSrc={reference.imageSrc}
> imageAlt=""
<button previewLabel={`预览参考图 ${reference.label}`}
type="button" removeLabel={`移除参考图 ${reference.label}`}
disabled={disabled} onPreview={() => setPreviewReferenceImage(reference)}
onClick={() => setPreviewReferenceImage(reference)} onRemove={
className="block h-full w-full" onPromptReferenceRemove
aria-label={`预览参考图 ${reference.label}`} ? () => onPromptReferenceRemove(reference.id)
title={reference.label} : undefined
> }
<ResolvedAssetImage disabled={disabled}
src={reference.imageSrc} resolveAsset
alt="" className="h-12 w-12 rounded-[0.75rem] bg-white/90 shadow-sm"
aria-hidden="true" previewButtonProps={{ title: reference.label }}
className="h-full w-full object-cover" removeButtonProps={{
/> title: '移除参考图',
</button> className:
{onPromptReferenceRemove ? ( 'right-0.5 top-0.5 bg-white/94 text-[var(--platform-text-strong)] shadow-sm hover:bg-white hover:text-[var(--platform-accent)]',
<button }}
type="button" />
disabled={disabled}
onClick={() => onPromptReferenceRemove(reference.id)}
className="absolute right-0.5 top-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/94 text-[var(--platform-text-strong)] shadow-sm transition hover:text-[var(--platform-accent)] disabled:opacity-55"
aria-label={`移除参考图 ${reference.label}`}
title="移除参考图"
>
<X className="h-3 w-3" />
</button>
) : null}
</div>
))} ))}
</div> </div>
) : null} ) : null}
@@ -443,14 +438,24 @@ export function CreativeImageInputPanel({
<div className="mt-2 shrink-0 space-y-3"> <div className="mt-2 shrink-0 space-y-3">
{inputError ? ( {inputError ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6"> <PlatformStatusMessage
tone="error"
surface="profile"
size="md"
className="rounded-2xl"
>
{inputError} {inputError}
</div> </PlatformStatusMessage>
) : null} ) : null}
{error ? ( {error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6"> <PlatformStatusMessage
tone="error"
surface="profile"
size="md"
className="rounded-2xl"
>
{error} {error}
</div> </PlatformStatusMessage>
) : null} ) : null}
</div> </div>
</section> </section>
@@ -458,11 +463,10 @@ export function CreativeImageInputPanel({
{showSubmitButton ? ( {showSubmitButton ? (
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3"> <div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button <PlatformActionButton
type="button"
disabled={disabled || submitDisabled} disabled={disabled || submitDisabled}
onClick={onSubmit} onClick={onSubmit}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${ className={`min-h-10 px-4 sm:min-h-11 sm:px-5 ${
submitDisabled ? 'cursor-not-allowed opacity-55' : '' submitDisabled ? 'cursor-not-allowed opacity-55' : ''
}`} }`}
> >
@@ -473,135 +477,89 @@ export function CreativeImageInputPanel({
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
<span>{submitLabel}</span> <span>{submitLabel}</span>
{submitCostLabel ? ( {submitCostLabel ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold"> <PlatformPillBadge
tone="lightOverlay"
size="xs"
className="px-2 font-bold"
>
{submitCostLabel} {submitCostLabel}
</span> </PlatformPillBadge>
) : null} ) : null}
</span> </span>
</button> </PlatformActionButton>
</div> </div>
) : null} ) : null}
{previewReferenceImage ? ( <UnifiedModal
<div open={Boolean(previewReferenceImage)}
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6" title={previewReferenceImage?.label ?? labels.promptReferencePreviewAlt}
onClick={() => setPreviewReferenceImage(null)} onClose={() => setPreviewReferenceImage(null)}
> closeLabel={labels.closePromptReferencePreview}
<div closeVariant="profileCompact"
role="dialog" size="lg"
aria-modal="true" zIndexClassName="z-[80]"
aria-labelledby="creative-image-reference-preview-title" overlayClassName="px-4 py-6"
className="platform-modal-shell platform-remap-surface w-full max-w-2xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]" panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
onClick={(event) => event.stopPropagation()} headerClassName="mb-3 items-center border-b-0 px-1 py-0"
> titleClassName="text-sm font-black"
<div className="mb-3 flex items-center justify-between gap-3 px-1"> bodyClassName="px-0 py-0"
<div >
id="creative-image-reference-preview-title" {previewReferenceImage ? (
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]" <div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
> <ResolvedAssetImage
{previewReferenceImage.label} src={previewReferenceImage.imageSrc}
</div> alt={labels.promptReferencePreviewAlt}
<button className="h-full max-h-[72vh] w-full object-contain"
type="button" />
aria-label={labels.closePromptReferencePreview}
onClick={() => setPreviewReferenceImage(null)}
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={previewReferenceImage.imageSrc}
alt={labels.promptReferencePreviewAlt}
className="h-full max-h-[72vh] w-full object-contain"
/>
</div>
</div> </div>
</div> ) : null}
) : null} </UnifiedModal>
{isMainImagePreviewOpen && uploadedImageSrc ? ( <UnifiedModal
<div open={isMainImagePreviewOpen && Boolean(uploadedImageSrc)}
className="platform-modal-backdrop fixed inset-0 z-[82] flex items-center justify-center px-4 py-6" title={labels.previewMainImage ?? uploadedImageAlt}
onClick={() => setIsMainImagePreviewOpen(false)} onClose={() => setIsMainImagePreviewOpen(false)}
> closeLabel={
<div labels.closeMainImagePreview ?? labels.closePromptReferencePreview
role="dialog" }
aria-modal="true" closeVariant="profileCompact"
aria-labelledby="creative-image-main-preview-title" size="xl"
className="platform-modal-shell platform-remap-surface w-full max-w-4xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]" zIndexClassName="z-[82]"
onClick={(event) => event.stopPropagation()} overlayClassName="px-4 py-6"
> panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
<div className="mb-3 flex items-center justify-between gap-3 px-1"> headerClassName="mb-3 items-center border-b-0 px-1 py-0"
<div titleClassName="text-sm font-black"
id="creative-image-main-preview-title" bodyClassName="px-0 py-0"
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]" >
> {uploadedImageSrc ? (
{labels.previewMainImage ?? uploadedImageAlt} <div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
</div> <ResolvedAssetImage
<button src={uploadedImageSrc}
type="button" refreshKey={uploadedImageRefreshKey}
aria-label={ alt={uploadedImageAlt}
labels.closeMainImagePreview ?? labels.closePromptReferencePreview className="h-full max-h-[82vh] w-full object-contain"
} />
onClick={() => setIsMainImagePreviewOpen(false)}
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={uploadedImageSrc}
refreshKey={uploadedImageRefreshKey}
alt={uploadedImageAlt}
className="h-full max-h-[82vh] w-full object-contain"
/>
</div>
</div> </div>
</div> ) : null}
) : null} </UnifiedModal>
{isRemoveImageConfirmOpen ? ( <UnifiedConfirmDialog
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"> open={isRemoveImageConfirmOpen}
<div title={labels.removeImageConfirmTitle}
role="dialog" description={labels.removeImageConfirmBody}
aria-modal="true" onClose={() => setIsRemoveImageConfirmOpen(false)}
aria-labelledby="creative-image-remove-confirm-title" confirmLabel="移除"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]" cancelLabel="取消"
> showCancel
<div onConfirm={() => {
id="creative-image-remove-confirm-title" onMainImageRemove();
className="text-base font-black text-[var(--platform-text-strong)]" setIsRemoveImageConfirmOpen(false);
> }}
{labels.removeImageConfirmTitle} size="sm"
</div> zIndexClassName="z-[80]"
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]"> overlayClassName="px-4 py-6"
{labels.removeImageConfirmBody} panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
</div> />
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsRemoveImageConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={() => {
onMainImageRemove();
setIsRemoveImageConfirmOpen(false);
}}
className="platform-button platform-button--primary justify-center"
>
</button>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }

View File

@@ -5,6 +5,7 @@ import type {
LegalDocument, LegalDocument,
LegalDocumentBlock, LegalDocumentBlock,
} from './legalDocuments'; } from './legalDocuments';
import { PlatformActionButton } from './PlatformActionButton';
import { UnifiedModal } from './UnifiedModal'; import { UnifiedModal } from './UnifiedModal';
type LegalDocumentModalProps = { type LegalDocumentModalProps = {
@@ -95,13 +96,14 @@ export function LegalDocumentModal({
bodyClassName="px-4 py-0 sm:px-5" bodyClassName="px-4 py-0 sm:px-5"
footerClassName="justify-stretch sm:justify-end" footerClassName="justify-stretch sm:justify-end"
footer={ footer={
<button <PlatformActionButton
type="button"
onClick={onClose} onClick={onClose}
className="platform-button platform-button--secondary min-h-0 w-full rounded-[0.9rem] px-4 py-2.5 text-sm sm:w-auto" tone="secondary"
fullWidth
className="min-h-0 rounded-[0.9rem] sm:w-auto"
> >
</button> </PlatformActionButton>
} }
> >
<div className="max-h-[min(64vh,34rem)] overflow-y-auto py-4 pr-1"> <div className="max-h-[min(64vh,34rem)] overflow-y-auto py-4 pr-1">

View File

@@ -0,0 +1,48 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformAcknowledgeStatusDialog } from './PlatformAcknowledgeStatusDialog';
test('renders a standard acknowledge action and closes through 知道了', () => {
const onClose = vi.fn();
render(
<PlatformAcknowledgeStatusDialog
status="error"
title="提示"
description="至少保留一个可扮演角色。"
onClose={onClose}
/>,
);
const action = screen.getByRole('button', { name: '知道了' });
expect(action.className).toContain('platform-button');
fireEvent.click(action);
expect(onClose).toHaveBeenCalledTimes(1);
});
test('supports custom action styling and header notice layout', () => {
render(
<PlatformAcknowledgeStatusDialog
status="error"
title="发布失败"
description="还缺少 16 个基础动作"
onClose={() => {}}
showHeader
showCloseButton
closeOnBackdrop
iconLabel="发布失败提示"
actionClassName="border-slate-950 bg-slate-950 text-white"
/>,
);
const action = screen.getByRole('button', { name: '知道了' });
const dialog = screen.getByRole('dialog', { name: '发布失败' });
expect(action.className).toContain('border-slate-950');
expect(action.className).toContain('bg-slate-950');
expect(dialog.querySelector('[aria-label="发布失败提示"]')).toBeTruthy();
});

View File

@@ -0,0 +1,111 @@
import type { ReactNode } from 'react';
import type {
PlatformActionButtonSize,
PlatformActionButtonSurface,
PlatformActionButtonTone,
} from './platformActionButtonModel';
import {
PlatformStatusDialog,
type PlatformStatusDialogStatus,
} from './PlatformStatusDialog';
type PlatformAcknowledgeStatusDialogProps = {
open?: boolean;
status: PlatformStatusDialogStatus;
title: string;
description?: ReactNode;
children?: ReactNode;
onClose: () => void;
actionLabel?: string;
actionDisabled?: boolean;
actionTone?: PlatformActionButtonTone;
actionSurface?: PlatformActionButtonSurface;
actionSize?: PlatformActionButtonSize;
actionFullWidth?: boolean;
actionClassName?: string;
showHeader?: boolean;
showBodyTitle?: boolean;
showCloseButton?: boolean;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
closeLabel?: string;
closeDisabled?: boolean;
zIndexClassName?: string;
overlayClassName?: string;
panelClassName?: string;
bodyClassName?: string;
iconClassName?: string;
icon?: ReactNode;
iconLabel?: string;
};
/**
* 平台已读确认状态弹窗。
* 统一承接“状态提示 + 知道了”这一类单按钮确认已读的弹窗语义。
*/
export function PlatformAcknowledgeStatusDialog({
open,
status,
title,
description,
children,
onClose,
actionLabel = '知道了',
actionDisabled = false,
actionTone,
actionSurface = 'platform',
actionSize,
actionFullWidth,
actionClassName,
showHeader,
showBodyTitle,
showCloseButton,
closeOnBackdrop,
closeOnEscape,
closeLabel,
closeDisabled,
zIndexClassName,
overlayClassName,
panelClassName,
bodyClassName,
iconClassName,
icon,
iconLabel,
}: PlatformAcknowledgeStatusDialogProps) {
return (
<PlatformStatusDialog
open={open}
status={status}
title={title}
description={description}
onClose={onClose}
showHeader={showHeader}
showBodyTitle={showBodyTitle}
showCloseButton={showCloseButton}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
closeLabel={closeLabel}
closeDisabled={closeDisabled}
zIndexClassName={zIndexClassName}
overlayClassName={overlayClassName}
panelClassName={panelClassName}
bodyClassName={bodyClassName}
iconClassName={iconClassName}
icon={icon}
iconLabel={iconLabel}
action={{
label: actionLabel,
onClick: onClose,
disabled: actionDisabled,
tone: actionTone,
surface: actionSurface,
size: actionSize,
fullWidth: actionFullWidth,
className: actionClassName,
}}
>
{children}
</PlatformStatusDialog>
);
}

View File

@@ -0,0 +1,111 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformActionButton } from './PlatformActionButton';
test('renders platform primary action button by default', () => {
render(<PlatformActionButton></PlatformActionButton>);
const button = screen.getByRole('button', { name: '确认' });
expect(button.className).toContain('platform-button');
expect(button.className).toContain('platform-button--primary');
expect(button.className).toContain('rounded-2xl');
expect(button.className).toContain('disabled:cursor-not-allowed');
});
test('supports profile primary button surface', () => {
render(
<PlatformActionButton surface="profile" fullWidth size="md">
</PlatformActionButton>,
);
const button = screen.getByRole('button', { name: '兑换' });
expect(button.className).toContain('platform-primary-button');
expect(button.className).toContain('w-full');
expect(button.className).toContain('py-3');
});
test('supports secondary and pill variants', () => {
render(
<PlatformActionButton tone="secondary" shape="pill" size="xs" align="start">
</PlatformActionButton>,
);
const button = screen.getByRole('button', { name: '重新加载' });
expect(button.className).toContain('platform-button--secondary');
expect(button.className).toContain('rounded-full');
expect(button.className).toContain('text-xs');
expect(button.className).toContain('justify-start');
expect(button.className).toContain('text-left');
});
test('supports label child chrome for file upload controls', () => {
render(
<PlatformActionButton asChild="label" tone="secondary" htmlFor="upload">
</PlatformActionButton>,
);
const label = screen.getByText('上传');
expect(label.tagName).toBe('LABEL');
expect(label.getAttribute('for')).toBe('upload');
expect(label.className).toContain('platform-button--secondary');
});
test('supports editor dark action surface', () => {
render(
<PlatformActionButton surface="editorDark" tone="warning" size="xxs">
</PlatformActionButton>,
);
const button = screen.getByRole('button', { name: '确认前往' });
expect(button.className).toContain('platform-action-button--editor-dark');
expect(button.className).toContain('border-amber-300/30');
expect(button.className).toContain('bg-amber-500/20');
expect(button.className).toContain('text-[10px]');
});
test('supports accent action tone', () => {
render(
<PlatformActionButton surface="editorDark" tone="accent" size="lg" fullWidth>
</PlatformActionButton>,
);
const button = screen.getByRole('button', { name: '生成' });
expect(button.className).toContain('platform-action-button--accent');
expect(button.className).toContain('bg-amber-200');
expect(button.className).toContain('text-slate-950');
expect(button.className).toContain('h-12');
expect(button.className).toContain('w-full');
});
test('supports accent soft action tone', () => {
render(
<PlatformActionButton
tone="accentSoft"
className="[--platform-action-accent:var(--platform-work-like-accent,#c7653d)]"
>
</PlatformActionButton>,
);
const button = screen.getByRole('button', { name: '点赞' });
expect(button.className).toContain('platform-action-button--accent-soft');
expect(button.className).toContain('[color:var(--platform-action-accent,#c7653d)]');
expect(button.className).toContain(
'[--platform-action-accent:var(--platform-work-like-accent,#c7653d)]',
);
});

View File

@@ -0,0 +1,95 @@
import type {
ButtonHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
} from 'react';
import {
getPlatformActionButtonClassName,
type PlatformActionButtonAlign,
type PlatformActionButtonShape,
type PlatformActionButtonSize,
type PlatformActionButtonSurface,
type PlatformActionButtonTone,
} from './platformActionButtonModel';
type PlatformActionButtonBaseProps = {
children: ReactNode;
tone?: PlatformActionButtonTone;
surface?: PlatformActionButtonSurface;
size?: PlatformActionButtonSize;
shape?: PlatformActionButtonShape;
align?: PlatformActionButtonAlign;
fullWidth?: boolean;
};
type PlatformActionButtonButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> &
PlatformActionButtonBaseProps & {
asChild?: false;
};
type PlatformActionButtonLabelProps = Omit<
LabelHTMLAttributes<HTMLLabelElement>,
'children'
> &
PlatformActionButtonBaseProps & {
asChild: 'label';
};
type PlatformActionButtonProps =
| PlatformActionButtonButtonProps
| PlatformActionButtonLabelProps;
/**
* 平台通用动作按钮。
* 收口平台与个人中心主动作按钮的样式族、尺寸、圆角和禁用态 class。
*/
export function PlatformActionButton({
children,
tone = 'primary',
surface = 'platform',
size = 'sm',
shape = 'default',
align = 'center',
fullWidth = false,
className,
asChild,
...buttonProps
}: PlatformActionButtonProps) {
const actionClassName = [
getPlatformActionButtonClassName({
surface,
tone,
size,
shape,
align,
fullWidth,
}),
className,
]
.filter(Boolean)
.join(' ');
if (asChild === 'label') {
return (
<label
{...(buttonProps as LabelHTMLAttributes<HTMLLabelElement>)}
className={actionClassName}
>
{children}
</label>
);
}
const { type = 'button', ...restButtonProps } =
buttonProps as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<button {...restButtonProps} type={type} className={actionClassName}>
{children}
</button>
);
}

View File

@@ -0,0 +1,264 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import {
PlatformAssetPickerCard,
PlatformAssetPickerGrid,
} from './PlatformAssetPickerCard';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => <img src={src ?? ''} alt={alt} className={className} />,
}));
test('renders historical asset thumbnail with title and subtitle', () => {
render(
<PlatformAssetPickerCard
imageSrc="/history/a.png"
imageAlt="历史图片"
assetTitle="封面图"
subtitle="2026-06-09 10:00"
aria-label="选择封面图"
/>,
);
const button = screen.getByRole('button', { name: '选择封面图' });
const image = screen.getByRole('img', { name: '历史图片' });
expect(button.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(button.className).toContain('hover:border-amber-300/70');
expect(image.getAttribute('src')).toBe('/history/a.png');
expect(screen.getByText('封面图')).toBeTruthy();
expect(screen.getByText('2026-06-09 10:00')).toBeTruthy();
});
test('keeps disabled picker card inert', () => {
const onClick = vi.fn();
render(
<PlatformAssetPickerCard
imageSrc="/history/a.png"
imageAlt=""
assetTitle="历史素材"
disabled
onClick={onClick}
/>,
);
const button = screen.getByRole('button', { name: '历史素材' });
fireEvent.click(button);
expect(button).toHaveProperty('disabled', true);
expect(button.className).toContain('cursor-not-allowed');
expect(onClick).not.toHaveBeenCalled();
});
test('supports local radius and body classes for dense pickers', () => {
render(
<PlatformAssetPickerCard
imageSrc="/history/a.png"
imageAlt="历史图片"
subtitle="刚刚"
cardRadiusClassName="rounded-[1.25rem]"
bodyClassName="px-4 py-3"
aria-label="选择历史图片"
/>,
);
const button = screen.getByRole('button', { name: '选择历史图片' });
expect(button.className).toContain('rounded-[1.25rem]');
expect(screen.getByText('刚刚').parentElement?.className).toContain('px-4');
});
test('supports local image shell classes for landscape assets', () => {
render(
<PlatformAssetPickerCard
imageSrc="/history/scene.png"
imageAlt="场景图"
imageShellClassName="aspect-[16/9]"
aria-label="选择场景图"
/>,
);
expect(
screen.getByRole('img', { name: '场景图' }).parentElement?.className,
).toContain('aspect-[16/9]');
});
test('renders selected picker cards with shared selected chrome', () => {
render(
<PlatformAssetPickerCard
imageSrc="/history/a.png"
imageAlt="历史图片"
assetTitle="已选择素材"
aria-label="选择历史图片"
selected
/>,
);
const button = screen.getByRole('button', { name: '选择历史图片' });
expect(button.className).toContain('ring-2');
expect(button.className).toContain('border-[var(--platform-warm-border)]');
});
test('renders shared loading and empty states for asset grids', () => {
const { rerender } = render(
<PlatformAssetPickerGrid
items={[]}
isLoading
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item: { id: string }) => item.id}
getImageSrc={(item) => item.id}
getImageAlt={() => ''}
onSelect={() => {}}
/>,
);
expect(screen.getByText('读取中...').className).toContain('border-dashed');
rerender(
<PlatformAssetPickerGrid
items={[]}
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item: { id: string }) => item.id}
getImageSrc={(item) => item.id}
getImageAlt={() => ''}
onSelect={() => {}}
/>,
);
expect(screen.getByText('暂无历史素材').className).toContain('border-dashed');
});
test('renders selectable asset grid cards with shared error chrome', () => {
const onSelect = vi.fn();
render(
<PlatformAssetPickerGrid
items={[
{
id: 'asset-1',
imageSrc: '/history/a.png',
title: '历史素材',
createdAt: '2026-06-09',
},
]}
error="历史素材读取失败。"
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item) => item.id}
getImageSrc={(item) => item.imageSrc}
getImageAlt={() => '历史素材'}
getTitle={(item) => item.title}
getSubtitle={(item) => item.createdAt}
getAriaLabel={(item) => `选择${item.title}`}
isSelected={(item) => item.id === 'asset-1'}
onSelect={onSelect}
gridClassName="grid grid-cols-1"
/>,
);
expect(screen.getByText('历史素材读取失败。').className).toContain(
'text-[var(--platform-button-danger-text)]',
);
fireEvent.click(screen.getByRole('button', { name: '选择历史素材' }));
expect(
screen.getByRole('button', { name: '选择历史素材' }).className,
).toContain('ring-2');
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: 'asset-1' }),
);
});
test('asset grid keeps error banner while loading state remains mutually exclusive with empty state', () => {
render(
<PlatformAssetPickerGrid
items={[]}
isLoading
error="历史素材读取失败。"
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item: { id: string }) => item.id}
getImageSrc={(item) => item.id}
getImageAlt={() => ''}
onSelect={() => {}}
/>,
);
expect(screen.getByText('历史素材读取失败。')).toBeTruthy();
expect(screen.getByText('读取中...')).toBeTruthy();
expect(screen.queryByText('暂无历史素材')).toBeNull();
});
test('supports dark editor surface with an in-card select affordance', () => {
const onSelect = vi.fn();
render(
<PlatformAssetPickerGrid
items={[
{
id: 'asset-dark-1',
imageSrc: '/history/dark.png',
title: '角色立绘',
createdAt: '2026-06-09',
},
]}
surface="editorDark"
selectLabel="使用"
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item) => item.id}
getImageSrc={(item) => item.imageSrc}
getImageAlt={(item) => item.title}
getTitle={(item) => item.title}
getSubtitle={(item) => item.createdAt}
onSelect={onSelect}
/>,
);
const button = screen.getByRole('button', { name: //u });
expect(button.className).toContain('bg-black/20');
expect(screen.getByText('使用').className).toContain('bg-sky-500/12');
fireEvent.click(button);
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: 'asset-dark-1' }),
);
});
test('uses dark empty-state chrome for editor asset grids', () => {
render(
<PlatformAssetPickerGrid
items={[]}
surface="editorDark"
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item: { id: string }) => item.id}
getImageSrc={(item) => item.id}
getImageAlt={() => ''}
onSelect={() => {}}
/>,
);
expect(screen.getByText('暂无历史素材').className).toContain('bg-black/20');
expect(screen.getByText('暂无历史素材').className).toContain('text-zinc-300');
});

View File

@@ -0,0 +1,322 @@
import type { ButtonHTMLAttributes, Key, ReactNode } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
import { PlatformEmptyState } from './PlatformEmptyState';
import { PlatformStatusMessage } from './PlatformStatusMessage';
type PlatformAssetPickerSurface = 'platform' | 'editorDark';
type PlatformAssetPickerCardProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
imageSrc: string;
imageAlt: string;
assetTitle?: ReactNode;
subtitle?: ReactNode;
surface?: PlatformAssetPickerSurface;
selectLabel?: ReactNode;
selected?: boolean;
cardRadiusClassName?: string;
imageShellClassName?: string;
imageClassName?: string;
bodyClassName?: string;
};
type PlatformAssetPickerGridProps<TItem> = {
items: readonly TItem[];
isLoading?: boolean;
error?: ReactNode;
loadingLabel: ReactNode;
emptyLabel: ReactNode;
disabled?: boolean;
getKey: (item: TItem) => Key;
getImageSrc: (item: TItem) => string;
getImageAlt: (item: TItem) => string;
getTitle?: (item: TItem) => ReactNode;
getSubtitle?: (item: TItem) => ReactNode;
getAriaLabel?: (item: TItem) => string;
isSelected?: (item: TItem) => boolean;
onSelect: (item: TItem) => void;
surface?: PlatformAssetPickerSurface;
selectLabel?: ReactNode;
gridClassName?: string;
emptyClassName?: string;
statusClassName?: string;
cardClassName?: string;
cardRadiusClassName?: string;
imageShellClassName?: string;
imageClassName?: string;
bodyClassName?: string;
};
const PLATFORM_ASSET_PICKER_CARD_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform:
'bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white',
editorDark:
'bg-black/20 text-left transition hover:border-sky-200/40 hover:bg-slate-900/70',
};
const PLATFORM_ASSET_PICKER_CARD_BORDER_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform: 'border-[var(--platform-subpanel-border)]',
editorDark: 'border-white/10',
};
const PLATFORM_ASSET_PICKER_CARD_SELECTED_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform:
'border-[var(--platform-warm-border)] ring-2 ring-[var(--platform-warm-bg)]',
editorDark: 'border-sky-200/50 ring-2 ring-sky-300/22',
};
const PLATFORM_ASSET_PICKER_IMAGE_SHELL_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform: 'bg-[var(--platform-subpanel-fill)]',
editorDark:
'bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.95),rgba(8,10,17,0.92))]',
};
const PLATFORM_ASSET_PICKER_TITLE_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform: 'text-[var(--platform-text-strong)]',
editorDark: 'text-zinc-100',
};
const PLATFORM_ASSET_PICKER_SUBTITLE_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform: 'text-[var(--platform-text-base)]',
editorDark: 'text-zinc-400',
};
const PLATFORM_ASSET_PICKER_SELECT_LABEL_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform:
'border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-strong)]',
editorDark:
'border-sky-300/22 bg-sky-500/12 text-sky-50 group-hover:border-sky-200/40 group-hover:text-white',
};
const PLATFORM_ASSET_PICKER_GRID_STATUS_SURFACE: Record<
PlatformAssetPickerSurface,
'platform' | 'tinted'
> = {
platform: 'platform',
editorDark: 'tinted',
};
const PLATFORM_ASSET_PICKER_GRID_EMPTY_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform: '',
editorDark:
'rounded-2xl border-white/8 bg-black/20 text-zinc-300 min-h-0 py-8',
};
/**
* 平台历史素材选择卡片。
* 统一承载历史图片 / 素材选择里的缩略图、禁用态和双行文案外观。
*/
export function PlatformAssetPickerCard({
imageSrc,
imageAlt,
assetTitle,
subtitle,
surface = 'platform',
selectLabel,
selected = false,
disabled,
className,
cardRadiusClassName = 'rounded-[1.1rem]',
imageShellClassName = 'aspect-square',
imageClassName,
bodyClassName,
...buttonProps
}: PlatformAssetPickerCardProps) {
return (
<button
{...buttonProps}
type="button"
disabled={disabled}
className={[
'group overflow-hidden border',
PLATFORM_ASSET_PICKER_CARD_CLASS[surface],
cardRadiusClassName,
selected
? PLATFORM_ASSET_PICKER_CARD_SELECTED_CLASS[surface]
: PLATFORM_ASSET_PICKER_CARD_BORDER_CLASS[surface],
disabled ? 'cursor-not-allowed opacity-55' : null,
className,
]
.filter(Boolean)
.join(' ')}
>
<div
className={[
'overflow-hidden',
imageShellClassName,
PLATFORM_ASSET_PICKER_IMAGE_SHELL_CLASS[surface],
].join(' ')}
>
<ResolvedAssetImage
src={imageSrc}
alt={imageAlt}
className={['h-full w-full object-cover', imageClassName]
.filter(Boolean)
.join(' ')}
/>
</div>
{assetTitle || subtitle || selectLabel ? (
<div className={bodyClassName ?? 'space-y-1 px-3 py-3'}>
{assetTitle ? (
<div
className={[
'truncate text-xs font-black',
PLATFORM_ASSET_PICKER_TITLE_CLASS[surface],
].join(' ')}
>
{assetTitle}
</div>
) : null}
{subtitle ? (
<div
className={[
'text-[11px] leading-4',
PLATFORM_ASSET_PICKER_SUBTITLE_CLASS[surface],
].join(' ')}
>
{subtitle}
</div>
) : null}
{selectLabel ? (
<div
className={[
'rounded-full border px-4 py-2 text-center text-sm font-semibold transition-colors',
PLATFORM_ASSET_PICKER_SELECT_LABEL_CLASS[surface],
].join(' ')}
>
{selectLabel}
</div>
) : null}
</div>
) : null}
</button>
);
}
/**
* 平台历史素材选择网格。
* 统一承载历史图片 / 素材选择里的错误、读取、空态、网格和卡片渲染节奏。
*/
export function PlatformAssetPickerGrid<TItem>({
items,
isLoading = false,
error = null,
loadingLabel,
emptyLabel,
disabled,
getKey,
getImageSrc,
getImageAlt,
getTitle,
getSubtitle,
getAriaLabel,
isSelected,
onSelect,
surface = 'platform',
selectLabel,
gridClassName = 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
emptyClassName,
statusClassName,
cardClassName,
cardRadiusClassName,
imageShellClassName,
imageClassName,
bodyClassName,
}: PlatformAssetPickerGridProps<TItem>) {
const sharedEmptyStateClassName = [
PLATFORM_ASSET_PICKER_GRID_EMPTY_CLASS[surface],
emptyClassName,
]
.filter(Boolean)
.join(' ');
return (
<>
{error ? (
<PlatformStatusMessage
tone="error"
surface={PLATFORM_ASSET_PICKER_GRID_STATUS_SURFACE[surface]}
size="md"
className={statusClassName}
>
{error}
</PlatformStatusMessage>
) : null}
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<PlatformEmptyState
surface="dashed"
size="panel"
className={sharedEmptyStateClassName}
>
{loadingLabel}
</PlatformEmptyState>
}
isEmpty={items.length <= 0}
emptyState={
<PlatformEmptyState
surface="dashed"
size="panel"
className={sharedEmptyStateClassName}
>
{emptyLabel}
</PlatformEmptyState>
}
>
{items.length > 0 ? (
<div className={gridClassName}>
{items.map((item) => (
<PlatformAssetPickerCard
key={getKey(item)}
disabled={disabled}
aria-label={getAriaLabel?.(item)}
onClick={() => onSelect(item)}
imageSrc={getImageSrc(item)}
imageAlt={getImageAlt(item)}
assetTitle={getTitle?.(item)}
subtitle={getSubtitle?.(item)}
surface={surface}
selectLabel={selectLabel}
selected={isSelected?.(item) ?? false}
className={cardClassName}
cardRadiusClassName={cardRadiusClassName}
imageShellClassName={imageShellClassName}
imageClassName={imageClassName}
bodyClassName={bodyClassName}
/>
))}
</div>
) : null}
</PlatformAsyncStatePanel>
</>
);
}

View File

@@ -0,0 +1,61 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
describe('PlatformAsyncStatePanel', () => {
test('prefers error state over loading and content', () => {
render(
<PlatformAsyncStatePanel
errorState={<div></div>}
isLoading
loadingState={<div></div>}
>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('出错了')).toBeTruthy();
expect(screen.queryByText('读取中')).toBeNull();
expect(screen.queryByText('内容')).toBeNull();
});
test('renders loading state before empty state', () => {
render(
<PlatformAsyncStatePanel
isLoading
isEmpty
loadingState={<div></div>}
emptyState={<div></div>}
>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('读取中')).toBeTruthy();
expect(screen.queryByText('暂无内容')).toBeNull();
});
test('renders empty state when requested', () => {
render(
<PlatformAsyncStatePanel isEmpty emptyState={<div></div>}>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('暂无内容')).toBeTruthy();
expect(screen.queryByText('内容')).toBeNull();
});
test('falls back to content when no async state is active', () => {
render(
<PlatformAsyncStatePanel>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('内容')).toBeTruthy();
});
});

View File

@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
type PlatformAsyncStatePanelProps = {
errorState?: ReactNode;
isLoading?: boolean;
loadingState?: ReactNode;
isEmpty?: boolean;
emptyState?: ReactNode;
children: ReactNode;
};
/**
* 平台异步状态面板骨架。
* 只负责在错误、读取、空态和内容之间切换,具体文案与外观继续交给调用方传入 slot。
*/
export function PlatformAsyncStatePanel({
errorState,
isLoading = false,
loadingState = null,
isEmpty = false,
emptyState = null,
children,
}: PlatformAsyncStatePanelProps) {
if (errorState !== undefined && errorState !== null) {
return <>{errorState}</>;
}
if (isLoading) {
return <>{loadingState}</>;
}
if (isEmpty) {
return <>{emptyState}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,35 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformBackActionButton } from './PlatformBackActionButton';
test('renders compact back action button by default', () => {
render(<PlatformBackActionButton />);
const button = screen.getByRole('button', { name: '返回' });
expect(button.className).toContain('platform-button--ghost');
expect(button.className).toContain('min-h-0');
expect(button.className).toContain('text-[11px]');
expect(button.className).toContain('gap-1.5');
expect(button.querySelector('svg')?.className.baseVal).toContain('h-3.5');
});
test('supports regular variant and editor dark surface', () => {
render(
<PlatformBackActionButton
label="返回编辑"
variant="regular"
surface="editorDark"
/>,
);
const button = screen.getByRole('button', { name: '返回编辑' });
expect(button.className).toContain('platform-action-button--editor-dark');
expect(button.className).toContain('text-sm');
expect(button.className).toContain('gap-2');
expect(button.querySelector('svg')?.className.baseVal).toContain('h-4');
});

View File

@@ -0,0 +1,58 @@
import type { ButtonHTMLAttributes } from 'react';
import { ArrowLeft } from 'lucide-react';
import { PlatformActionButton } from './PlatformActionButton';
import type { PlatformActionButtonSurface } from './platformActionButtonModel';
type PlatformBackActionButtonVariant = 'compact' | 'regular';
type PlatformBackActionButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
label?: string;
variant?: PlatformBackActionButtonVariant;
surface?: PlatformActionButtonSurface;
};
const VARIANT_CLASS: Record<PlatformBackActionButtonVariant, string> = {
compact: 'gap-1.5 py-1.5 text-[11px]',
regular: 'gap-2 py-2 text-sm',
};
const ICON_CLASS: Record<PlatformBackActionButtonVariant, string> = {
compact: 'h-3.5 w-3.5',
regular: 'h-4 w-4',
};
/**
* 平台轻量返回动作按钮。
* 统一结果页、工作台等白底场景里的“左箭头 + 返回文案”按钮骨架。
*/
export function PlatformBackActionButton({
label = '返回',
variant = 'compact',
surface = 'platform',
className,
...buttonProps
}: PlatformBackActionButtonProps) {
return (
<PlatformActionButton
{...buttonProps}
surface={surface}
tone="ghost"
size="xs"
className={[
'min-h-0 self-start',
VARIANT_CLASS[variant],
className,
]
.filter(Boolean)
.join(' ')}
>
<ArrowLeft className={ICON_CLASS[variant]} />
{label}
</PlatformActionButton>
);
}

View File

@@ -0,0 +1,68 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformDangerConfirmDialog } from './PlatformDangerConfirmDialog';
test('renders a standard danger confirmation with cancel and confirm actions', () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
render(
<PlatformDangerConfirmDialog
open
title="删除作品"
description="确认删除《潮雾列岛》吗?"
onClose={onClose}
onConfirm={onConfirm}
confirmLabel="确认删除"
>
</PlatformDangerConfirmDialog>,
);
const dialog = screen.getByRole('dialog', { name: '删除作品' });
expect(within(dialog).getByText('确认删除《潮雾列岛》吗?')).toBeTruthy();
expect(within(dialog).getByText('删除后不可恢复。')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test('forwards busy state and custom busy label for destructive actions', () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
render(
<PlatformDangerConfirmDialog
open
title="删除作品"
onClose={onClose}
onConfirm={onConfirm}
confirmLabel="确认删除"
busyConfirmLabel="删除中"
busy
closeOnBackdrop={false}
>
</PlatformDangerConfirmDialog>,
);
const dialog = screen.getByRole('dialog', { name: '删除作品' });
const confirmButton = within(dialog).getByRole('button', { name: '删除中' });
const cancelButton = within(dialog).getByRole('button', { name: '取消' });
expect((confirmButton as HTMLButtonElement).disabled).toBe(true);
expect((cancelButton as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(confirmButton);
fireEvent.click(cancelButton);
expect(onClose).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,78 @@
import type { ReactNode } from 'react';
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
type PlatformDangerConfirmDialogProps = {
open: boolean;
title: string;
onClose: () => void;
onConfirm?: () => void;
description?: ReactNode;
children?: ReactNode;
confirmLabel?: string;
busy?: boolean;
busyConfirmLabel?: string;
cancelLabel?: string;
closeOnBackdrop?: boolean;
showCloseButton?: boolean;
portal?: boolean;
size?: 'sm' | 'md';
variant?: 'platform' | 'pixel';
overlayClassName?: string;
panelClassName?: string;
footerClassName?: string;
confirmClassName?: string;
};
/**
* 平台危险确认弹窗。
* 统一承接需要“确认 / 取消 + 危险主动作”语义的标准弹窗壳层。
*/
export function PlatformDangerConfirmDialog({
open,
title,
onClose,
onConfirm,
description,
children,
confirmLabel = '确认',
busy = false,
busyConfirmLabel,
cancelLabel = '取消',
closeOnBackdrop = true,
showCloseButton = true,
portal = true,
size = 'sm',
variant = 'platform',
overlayClassName,
panelClassName,
footerClassName,
confirmClassName,
}: PlatformDangerConfirmDialogProps) {
return (
<UnifiedConfirmDialog
open={open}
title={title}
description={description}
onClose={onClose}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
busy={busy}
busyConfirmLabel={busyConfirmLabel}
cancelLabel={cancelLabel}
closeOnBackdrop={closeOnBackdrop}
showCloseButton={showCloseButton}
showCancel
confirmTone="danger"
portal={portal}
size={size}
variant={variant}
overlayClassName={overlayClassName}
panelClassName={panelClassName}
footerClassName={footerClassName}
confirmClassName={confirmClassName}
>
{children}
</UnifiedConfirmDialog>
);
}

View File

@@ -0,0 +1,65 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { PlatformDarkModalFooter } from './PlatformDarkModalFooter';
describe('PlatformDarkModalFooter', () => {
test('renders bordered action footer with shared action row chrome', () => {
render(
<PlatformDarkModalFooter data-testid="footer">
<button type="button"></button>
<button type="button"></button>
</PlatformDarkModalFooter>,
);
const footer = screen.getByTestId('footer');
const actions = footer.querySelector('.platform-dark-modal-footer__actions');
expect(footer.className).toContain('platform-dark-modal-footer');
expect(footer.className).toContain('border-t');
expect(footer.className).toContain('border-white/10');
expect(footer.className).toContain('px-4');
expect(footer.className).toContain('py-3');
expect(actions?.className).toContain('justify-end');
expect(actions?.className).toContain('gap-3');
});
test('supports unbordered bottom padding actions for modal footer tails', () => {
render(
<PlatformDarkModalFooter
bordered={false}
padding="bottom"
gap="sm"
data-testid="footer"
>
<button type="button"></button>
</PlatformDarkModalFooter>,
);
const footer = screen.getByTestId('footer');
const actions = footer.querySelector('.platform-dark-modal-footer__actions');
expect(footer.className).not.toContain('border-t');
expect(footer.className).toContain('px-5');
expect(footer.className).toContain('pb-5');
expect(actions?.className).toContain('gap-2');
});
test('supports content layout without wrapping children in an actions row', () => {
render(
<PlatformDarkModalFooter layout="content" data-testid="footer">
<div data-testid="content"></div>
</PlatformDarkModalFooter>,
);
const footer = screen.getByTestId('footer');
expect(screen.getByTestId('content')).toBeTruthy();
expect(
footer.querySelector('.platform-dark-modal-footer__actions'),
).toBeNull();
expect(footer.className).toContain('platform-dark-modal-footer');
});
});

View File

@@ -0,0 +1,86 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
type PlatformDarkModalFooterLayout = 'actions' | 'content';
type PlatformDarkModalFooterPadding = 'compact' | 'roomy' | 'bottom';
type PlatformDarkModalFooterGap = 'sm' | 'md';
type PlatformDarkModalFooterAlign = 'start' | 'center' | 'end' | 'between';
type PlatformDarkModalFooterProps = ComponentPropsWithoutRef<'div'> & {
children: ReactNode;
bordered?: boolean;
layout?: PlatformDarkModalFooterLayout;
padding?: PlatformDarkModalFooterPadding;
gap?: PlatformDarkModalFooterGap;
wrap?: boolean;
align?: PlatformDarkModalFooterAlign;
contentClassName?: string;
};
const PADDING_CLASS: Record<PlatformDarkModalFooterPadding, string> = {
compact: 'px-4 py-3 sm:px-5 sm:py-4',
roomy: 'px-5 py-4',
bottom: 'px-5 pb-5',
};
const GAP_CLASS: Record<PlatformDarkModalFooterGap, string> = {
sm: 'gap-2',
md: 'gap-3',
};
const ALIGN_CLASS: Record<PlatformDarkModalFooterAlign, string> = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
};
/**
* 暗色 / 像素弹层底部 footer 骨架。
* 统一承接 border、padding 和常见的动作按钮排布,避免业务页重复手写同一套 chrome。
*/
export function PlatformDarkModalFooter({
children,
bordered = true,
layout = 'actions',
padding = 'compact',
gap = 'md',
wrap = false,
align = 'end',
className,
contentClassName,
...props
}: PlatformDarkModalFooterProps) {
const frameClassName = [
'platform-dark-modal-footer',
bordered ? 'border-t border-white/10' : null,
PADDING_CLASS[padding],
className ?? null,
]
.filter(Boolean)
.join(' ');
if (layout === 'content') {
return (
<div className={frameClassName} {...props}>
{children}
</div>
);
}
const actionsClassName = [
'platform-dark-modal-footer__actions',
'flex items-center',
ALIGN_CLASS[align],
GAP_CLASS[gap],
wrap ? 'flex-wrap' : null,
contentClassName ?? null,
]
.filter(Boolean)
.join(' ');
return (
<div className={frameClassName} {...props}>
<div className={actionsClassName}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { PlatformDarkOptionCard } from './PlatformDarkOptionCard';
test('renders selected dark option card with tone classes', () => {
render(
<PlatformDarkOptionCard selected tone="rose" className="w-full">
</PlatformDarkOptionCard>,
);
const card = screen.getByRole('button', { name: '玫瑰信物' });
expect(card.className).toContain('platform-dark-option-card');
expect(card.className).toContain('border-rose-400/60');
expect(card.className).toContain('bg-rose-500/10');
expect(card.className).toContain('w-full');
});
test('renders idle dark option card and forwards button behavior', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(
<PlatformDarkOptionCard
selected={false}
radius="sm"
padding="md"
onClick={handleClick}
>
</PlatformDarkOptionCard>,
);
const card = screen.getByRole('button', { name: '月壳' });
expect(card.getAttribute('type')).toBe('button');
expect(card.className).toContain('border-white/8');
expect(card.className).toContain('hover:border-white/15');
expect(card.className).toContain('rounded-lg');
expect(card.className).toContain('py-2.5');
await user.click(card);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('supports emerald, sky, and amber selected tones', () => {
const { rerender } = render(
<PlatformDarkOptionCard selected tone="emerald">
</PlatformDarkOptionCard>,
);
expect(screen.getByRole('button', { name: '购买物品' }).className).toContain(
'border-emerald-400/45',
);
rerender(
<PlatformDarkOptionCard selected tone="sky">
</PlatformDarkOptionCard>,
);
expect(screen.getByRole('button', { name: '出售物品' }).className).toContain(
'border-sky-400/45',
);
rerender(
<PlatformDarkOptionCard selected tone="amber">
</PlatformDarkOptionCard>,
);
expect(screen.getByRole('button', { name: '调整同行' }).className).toContain(
'border-amber-400/60',
);
});
test('supports large radius and spacing for dark option grids', () => {
render(
<PlatformDarkOptionCard selected tone="sky" radius="lg" padding="lg">
</PlatformDarkOptionCard>,
);
const card = screen.getByRole('button', { name: '奔跑' });
expect(card.className).toContain('rounded-2xl');
expect(card.className).toContain('py-3');
});

View File

@@ -0,0 +1,82 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
type PlatformDarkOptionTone = 'emerald' | 'sky' | 'rose' | 'amber';
type PlatformDarkOptionRadius = 'sm' | 'md' | 'lg';
type PlatformDarkOptionPadding = 'sm' | 'md' | 'lg';
type PlatformDarkOptionCardProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
selected: boolean;
tone?: PlatformDarkOptionTone;
radius?: PlatformDarkOptionRadius;
padding?: PlatformDarkOptionPadding;
children: ReactNode;
};
const PLATFORM_DARK_OPTION_RADIUS_CLASS: Record<
PlatformDarkOptionRadius,
string
> = {
sm: 'rounded-lg',
md: 'rounded-xl',
lg: 'rounded-2xl',
};
const PLATFORM_DARK_OPTION_PADDING_CLASS: Record<
PlatformDarkOptionPadding,
string
> = {
sm: 'px-3 py-2',
md: 'px-3 py-2.5',
lg: 'px-3 py-3',
};
const PLATFORM_DARK_OPTION_SELECTED_CLASS: Record<
PlatformDarkOptionTone,
string
> = {
emerald: 'border-emerald-400/45 bg-emerald-500/10 text-emerald-100',
sky: 'border-sky-400/45 bg-sky-500/10 text-sky-100',
rose: 'border-rose-400/60 bg-rose-500/10 text-rose-100',
amber: 'border-amber-400/60 bg-amber-500/10 text-amber-100',
};
const PLATFORM_DARK_OPTION_IDLE_CLASS =
'border-white/8 bg-black/20 text-zinc-300 hover:border-white/15';
/**
* 暗色面板中的可选项按钮。
* 统一承接 selected / idle / hover / disabled 的暗色卡片外观。
*/
export function PlatformDarkOptionCard({
selected,
tone = 'emerald',
radius = 'md',
padding = 'sm',
children,
className,
type = 'button',
...buttonProps
}: PlatformDarkOptionCardProps) {
return (
<button
{...buttonProps}
type={type}
className={[
'platform-dark-option-card border text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20 disabled:cursor-not-allowed disabled:opacity-55',
PLATFORM_DARK_OPTION_RADIUS_CLASS[radius],
PLATFORM_DARK_OPTION_PADDING_CLASS[padding],
selected
? PLATFORM_DARK_OPTION_SELECTED_CLASS[tone]
: PLATFORM_DARK_OPTION_IDLE_CLASS,
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,52 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformDetailShareActions } from './PlatformDetailShareActions';
test('renders overlay detail share actions with copied share state', () => {
render(
<PlatformDetailShareActions
workCode="CW-001"
copyState="idle"
onCopyWorkCode={vi.fn()}
shareState="copied"
onShare={vi.fn()}
shareAriaLabel="分享作品 测试世界"
leading={<span></span>}
variant="overlay"
/>,
);
const codeButton = screen.getByRole('button', { name: '复制作品号 CW-001' });
const shareButton = screen.getByRole('button', { name: '分享作品 测试世界' });
expect(screen.getByText('已发布')).toBeTruthy();
expect(codeButton.className).toContain('bg-white/72');
expect(codeButton.className).toContain('tracking-[0.18em]');
expect(shareButton.className).toContain('bg-white/72');
expect(screen.getByText('已复制')).toBeTruthy();
});
test('renders solid detail share actions with compact work code chip', () => {
render(
<PlatformDetailShareActions
workCode="PZ-001"
copyState="idle"
onCopyWorkCode={vi.fn()}
shareState="idle"
onShare={vi.fn()}
shareAriaLabel="分享作品 拼图世界"
leading={<span></span>}
variant="solid"
/>,
);
const codeButton = screen.getByRole('button', { name: 'PZ-001' });
const shareButton = screen.getByRole('button', { name: '分享作品 拼图世界' });
expect(codeButton.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(shareButton.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(screen.getByText('已发布')).toBeTruthy();
});

View File

@@ -0,0 +1,143 @@
import { Copy, Share2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { CopyCodeButton } from './CopyCodeButton';
import { CopyFeedbackButton } from './CopyFeedbackButton';
import type { CopyFeedbackState } from './useCopyFeedback';
type PlatformDetailShareActionsProps = {
workCode?: string | null;
copyState: CopyFeedbackState;
onCopyWorkCode?: () => void;
shareState: CopyFeedbackState;
onShare?: () => void;
shareAriaLabel?: string;
shareTitle?: string;
leading?: ReactNode;
showCopyAction?: boolean;
showShareAction?: boolean;
variant?: 'overlay' | 'solid';
className?: string;
copyClassName?: string;
shareClassName?: string;
copyCodeLabel?: ReactNode;
copyAccessibleLabel?: string;
};
const VARIANT_COPY_CLASS = {
overlay: 'px-3 tracking-[0.18em]',
solid: '',
} as const;
const VARIANT_SHARE_CLASS = {
overlay: 'px-3 tracking-[0.18em]',
solid: '',
} as const;
const VARIANT_PILL_TONE = {
overlay: 'neutral',
solid: 'neutralSolid',
} as const;
const VARIANT_PILL_SIZE = {
overlay: 'xxs',
solid: 'sm',
} as const;
const VARIANT_ICON_CLASS = {
overlay: 'h-3 w-3',
solid: 'h-4 w-4',
} as const;
const VARIANT_SUFFIX_CLASS = {
overlay: 'text-xs',
solid: 'text-[11px]',
} as const;
function renderShareLabel(suffix: ReactNode | null, suffixClassName: string) {
return (
<>
<span></span>
{suffix ? <span className={suffixClassName}>{suffix}</span> : null}
</>
);
}
/**
* 详情页作品号 / 分享动作组合。
* 共享层只承接状态 badge 槽位、复制作品号和分享按钮这组稳定骨架。
*/
export function PlatformDetailShareActions({
workCode,
copyState,
onCopyWorkCode,
shareState,
onShare,
shareAriaLabel,
shareTitle = '分享作品',
leading,
showCopyAction = true,
showShareAction = true,
variant = 'overlay',
className,
copyClassName,
shareClassName,
copyCodeLabel,
copyAccessibleLabel,
}: PlatformDetailShareActionsProps) {
const canShowCopyAction = showCopyAction && Boolean(workCode);
const canShowShareAction = showShareAction && Boolean(workCode);
if (!leading && !canShowCopyAction && !canShowShareAction) {
return null;
}
const iconClassName = VARIANT_ICON_CLASS[variant];
const shareSuffixClassName = VARIANT_SUFFIX_CLASS[variant];
const resolvedCopyCodeLabel =
copyCodeLabel ?? (variant === 'solid' ? null : '作品号');
const resolvedCopyAccessibleLabel =
copyAccessibleLabel ?? (variant === 'solid' ? workCode ?? undefined : undefined);
return (
<div className={['flex flex-wrap items-center gap-2', className].filter(Boolean).join(' ')}>
{leading}
{canShowCopyAction ? (
<CopyCodeButton
state={copyState}
code={workCode ?? ''}
codeLabel={resolvedCopyCodeLabel}
accessibleLabel={resolvedCopyAccessibleLabel}
title="复制作品号"
onClick={onCopyWorkCode}
disabled={!onCopyWorkCode}
actionAppearance="pill"
actionPillTone={VARIANT_PILL_TONE[variant]}
actionPillSize={VARIANT_PILL_SIZE[variant]}
className={[VARIANT_COPY_CLASS[variant], copyClassName].filter(Boolean).join(' ')}
idleIcon={<Copy className={iconClassName} />}
copiedIcon={<Copy className={iconClassName} />}
suffixClassName={shareSuffixClassName}
/>
) : null}
{canShowShareAction ? (
<CopyFeedbackButton
state={shareState}
onClick={onShare}
disabled={!onShare}
actionAppearance="pill"
actionPillTone={VARIANT_PILL_TONE[variant]}
actionPillSize={VARIANT_PILL_SIZE[variant]}
className={[VARIANT_SHARE_CLASS[variant], shareClassName].filter(Boolean).join(' ')}
aria-label={shareAriaLabel}
title={shareTitle}
idleLabel={renderShareLabel(null, shareSuffixClassName)}
copiedLabel={renderShareLabel('已复制', shareSuffixClassName)}
failedLabel={renderShareLabel('复制失败', shareSuffixClassName)}
idleIcon={<Share2 className={iconClassName} />}
copiedIcon={<Share2 className={iconClassName} />}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,49 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformDetailTopbar } from './PlatformDetailTopbar';
test('renders pill back action with trailing slot', () => {
const onBack = vi.fn();
render(
<PlatformDetailTopbar
onBack={onBack}
className="grid-cols-[auto,minmax(0,1fr),auto]"
backButtonClassName="px-3"
trailing={<span></span>}
/>,
);
const button = screen.getByRole('button', { name: '返回' });
expect(button.className).toContain('platform-button--ghost');
expect(button.className).toContain('px-3');
expect(screen.getByText('已发布')).toBeTruthy();
fireEvent.click(button);
expect(onBack).toHaveBeenCalledTimes(1);
});
test('renders icon back action and centered title', () => {
render(
<PlatformDetailTopbar
onBack={vi.fn()}
backVariant="icon"
backButtonClassName="detail-icon-back"
title="详情"
titleClassName="detail-topbar-title"
trailing={<span className="invisible"></span>}
/>,
);
const button = screen.getByRole('button', { name: '返回' });
const title = screen.getByText('详情');
expect(button.className).toContain('platform-icon-button');
expect(button.className).toContain('detail-icon-back');
expect(title.className).toContain('detail-topbar-title');
});

View File

@@ -0,0 +1,89 @@
import { ArrowLeft } from 'lucide-react';
import type { ReactNode } from 'react';
import { PlatformBackActionButton } from './PlatformBackActionButton';
import { PlatformIconButton } from './PlatformIconButton';
type PlatformDetailTopbarProps = {
onBack: () => void;
title?: ReactNode;
trailing?: ReactNode;
backVariant?: 'icon' | 'pill';
backLabel?: string;
className?: string;
backButtonClassName?: string;
titleClassName?: string;
trailingClassName?: string;
};
/**
* 详情页顶部动作骨架。
* 只统一返回、标题和右侧动作槽位的布局,不吸收页面自己的标题文案或业务动作。
*/
export function PlatformDetailTopbar({
onBack,
title,
trailing,
backVariant = 'pill',
backLabel = '返回',
className,
backButtonClassName,
titleClassName,
trailingClassName,
}: PlatformDetailTopbarProps) {
const backAction =
backVariant === 'icon' ? (
<PlatformIconButton
label={backLabel}
title={backLabel}
className={backButtonClassName}
onClick={onBack}
icon={<ArrowLeft className="h-6 w-6" />}
/>
) : (
<PlatformBackActionButton
onClick={onBack}
label={backLabel}
className={backButtonClassName}
/>
);
return (
<div
className={[
'grid min-w-0 grid-cols-[auto,minmax(0,1fr),auto] items-center gap-3',
className,
]
.filter(Boolean)
.join(' ')}
>
<div className="min-w-0 justify-self-start">
{backAction}
</div>
{title ? (
<div
className={[
'min-w-0 text-center',
titleClassName,
]
.filter(Boolean)
.join(' ')}
>
{title}
</div>
) : (
<div aria-hidden="true" />
)}
<div
className={[
'min-w-0 justify-self-end',
trailingClassName,
]
.filter(Boolean)
.join(' ')}
>
{trailing}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformEmptyState } from './PlatformEmptyState';
test('renders compact soft platform empty state', () => {
render(<PlatformEmptyState></PlatformEmptyState>);
const emptyState = screen.getByText('暂无作品');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('platform-surface--soft');
expect(emptyState.className).toContain('rounded-[1.35rem]');
expect(emptyState.className).toContain('px-4');
});
test('supports dashed panel empty state for picker dialogs', () => {
render(
<PlatformEmptyState surface="dashed" size="panel" className="mt-2">
...
</PlatformEmptyState>,
);
const emptyState = screen.getByText('读取中...');
expect(emptyState.className).toContain('border-dashed');
expect(emptyState.className).toContain('min-h-[14rem]');
expect(emptyState.className).toContain('mt-2');
});
test('supports inline subpanel empty state for runtime panels', () => {
render(
<PlatformEmptyState surface="subpanel" size="inline">
</PlatformEmptyState>,
);
const emptyState = screen.getByText('暂无历史');
expect(emptyState.className).toContain('rounded-[1rem]');
expect(emptyState.className).toContain('bg-white/74');
expect(emptyState.className).toContain('py-5');
expect(emptyState.className).toContain('text-[var(--platform-text-soft)]');
});
test('allows explicit tone override', () => {
render(
<PlatformEmptyState surface="subpanel" size="inline" tone="base">
</PlatformEmptyState>,
);
const emptyState = screen.getByText('暂无属性');
expect(emptyState.className).toContain('text-[var(--platform-text-base)]');
});
test('supports dark editor dashed empty state', () => {
render(
<PlatformEmptyState surface="editorDark" size="compact" tone="soft">
</PlatformEmptyState>,
);
const emptyState = screen.getByText('还没有配置角色技能。');
expect(emptyState.className).toContain('border-dashed');
expect(emptyState.className).toContain('border-white/12');
expect(emptyState.className).toContain('bg-black/20');
expect(emptyState.className).toContain('text-[var(--platform-text-soft)]');
});

View File

@@ -0,0 +1,75 @@
import type { HTMLAttributes, ReactNode } from 'react';
type PlatformEmptyStateSurface = 'soft' | 'dashed' | 'subpanel' | 'editorDark';
type PlatformEmptyStateSize = 'compact' | 'panel' | 'inline';
type PlatformEmptyStateTone = 'base' | 'soft';
type PlatformEmptyStateProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
children: ReactNode;
surface?: PlatformEmptyStateSurface;
size?: PlatformEmptyStateSize;
tone?: PlatformEmptyStateTone;
};
const PLATFORM_EMPTY_STATE_SURFACE_CLASS: Record<
PlatformEmptyStateSurface,
string
> = {
soft: 'platform-surface platform-surface--soft rounded-[1.35rem]',
dashed:
'rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52',
subpanel:
'rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74',
editorDark: 'rounded-2xl border border-dashed border-white/12 bg-black/20',
};
const PLATFORM_EMPTY_STATE_SIZE_CLASS: Record<PlatformEmptyStateSize, string> =
{
compact: 'px-4 py-3 text-sm leading-6',
panel:
'flex min-h-[14rem] items-center justify-center px-6 text-center text-sm',
inline: 'px-4 py-5 text-center text-sm font-semibold',
};
const PLATFORM_EMPTY_STATE_TONE_CLASS: Record<PlatformEmptyStateTone, string> =
{
base: 'text-[var(--platform-text-base)]',
soft: 'text-[var(--platform-text-soft)]',
};
/**
* 平台通用空态和轻量加载态。
* 收口平台列表、作品架和素材选择弹窗中重复的空面板外观。
*/
export function PlatformEmptyState({
children,
surface = 'soft',
size = 'compact',
tone,
className,
...divProps
}: PlatformEmptyStateProps) {
const resolvedTone =
tone ?? (surface === 'subpanel' || size === 'inline' ? 'soft' : 'base');
return (
<div
{...divProps}
className={[
'min-w-0',
'platform-empty-state',
PLATFORM_EMPTY_STATE_SURFACE_CLASS[surface],
PLATFORM_EMPTY_STATE_SIZE_CLASS[size],
PLATFORM_EMPTY_STATE_TONE_CLASS[resolvedTone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,68 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformFieldLabel } from './PlatformFieldLabel';
test('renders compact field label by default', () => {
render(<PlatformFieldLabel></PlatformFieldLabel>);
const label = screen.getByText('作品名称');
expect(label.className).toContain('text-xs');
expect(label.className).toContain('text-[var(--platform-text-soft)]');
});
test('renders section label with tracking', () => {
render(<PlatformFieldLabel variant="section"></PlatformFieldLabel>);
const label = screen.getByText('作品信息');
expect(label.className).toContain('tracking-[0.18em]');
expect(label.className).toContain('font-bold');
});
test('renders form label and keeps local classes', () => {
render(
<PlatformFieldLabel variant="form" className="mt-1">
</PlatformFieldLabel>,
);
const label = screen.getByText('一句话创作');
expect(label.className).toContain('mb-2');
expect(label.className).toContain('font-black');
expect(label.className).toContain('mt-1');
});
test('renders inline form label without block spacing', () => {
render(
<PlatformFieldLabel variant="inlineForm" className="shrink-0">
</PlatformFieldLabel>,
);
const label = screen.getByText('关卡数');
expect(label.className).toContain('inline-flex');
expect(label.className).toContain('text-sm');
expect(label.className).toContain('font-bold');
expect(label.className).toContain('shrink-0');
expect(label.className).not.toContain('mb-2');
});
test('renders pill and accent pill labels', () => {
render(
<>
<PlatformFieldLabel variant="pill"></PlatformFieldLabel>
<PlatformFieldLabel variant="accentPill">
/
</PlatformFieldLabel>
</>,
);
expect(screen.getByText('作品标题').className).toContain('rounded-full');
expect(screen.getByText('主题/场景描述').className).toContain('bg-rose-50');
});

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
type PlatformFieldLabelVariant =
| 'field'
| 'section'
| 'form'
| 'inlineForm'
| 'pill'
| 'accentPill';
type PlatformFieldLabelProps = {
children: ReactNode;
variant?: PlatformFieldLabelVariant;
className?: string;
};
const PLATFORM_FIELD_LABEL_CLASS: Record<PlatformFieldLabelVariant, string> = {
field: 'text-xs font-bold text-[var(--platform-text-soft)]',
section:
'text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]',
form: 'mb-2 block text-sm font-black text-[var(--platform-text-strong)]',
inlineForm:
'inline-flex items-center text-sm font-bold text-[var(--platform-text-base)]',
pill: 'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]',
accentPill:
'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm',
};
/**
* 平台字段标签。
* 统一承接结果页和创作工作台内重复出现的字段标题视觉。
*/
export function PlatformFieldLabel({
children,
variant = 'field',
className,
}: PlatformFieldLabelProps) {
return (
<span
className={[PLATFORM_FIELD_LABEL_CLASS[variant], className]
.filter(Boolean)
.join(' ')}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,72 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformFilterToolbar } from './PlatformFilterToolbar';
const TAB_ITEMS = [
{ id: 'all', label: '全部' },
{ id: 'story', label: '剧情' },
] as const;
test('renders mobile platform filter toolbar with divider and prefixed sort label', () => {
const onOpenFilter = vi.fn();
const onTabChange = vi.fn();
const onToggleSort = vi.fn();
const { container } = render(
<PlatformFilterToolbar
filterLabel="筛选"
filterCount={12}
tabItems={TAB_ITEMS}
activeTabId="all"
sortLabel="最热"
layout="mobile"
onOpenFilter={onOpenFilter}
onTabChange={onTabChange}
onToggleSort={onToggleSort}
/>,
);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(container.querySelector('.platform-category-filter-divider')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '剧情' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onTabChange).toHaveBeenCalledWith('story');
expect(onToggleSort).toHaveBeenCalledTimes(1);
});
test('renders desktop platform filter toolbar with inline sort button', () => {
const onOpenFilter = vi.fn();
const { container } = render(
<PlatformFilterToolbar
filterLabel="剧情"
filterCount={3}
tabItems={TAB_ITEMS}
activeTabId="story"
sortLabel="最新"
layout="desktop"
onOpenFilter={onOpenFilter}
onTabChange={vi.fn()}
onToggleSort={vi.fn()}
/>,
);
const filterButton = container.querySelector(
'.platform-category-filter-button',
) as HTMLButtonElement | null;
const sortButton = screen.getByRole('button', { name: //u });
expect(filterButton).toBeTruthy();
expect(filterButton?.textContent).toContain('剧情');
expect(sortButton).toBeTruthy();
expect(sortButton.className).toContain('shrink-0');
expect(container.querySelector('.platform-category-filter-divider')).toBeNull();
fireEvent.click(filterButton!);
expect(onOpenFilter).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,108 @@
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
export interface PlatformFilterToolbarTabItem {
id: string;
label: string;
}
export interface PlatformFilterToolbarProps {
filterLabel: string;
filterCount: number;
tabItems: readonly PlatformFilterToolbarTabItem[];
activeTabId: string;
sortLabel: string;
layout: 'mobile' | 'desktop';
onOpenFilter: () => void;
onTabChange: (id: string) => void;
onToggleSort: () => void;
}
function buildToolbarTabItemClassName(active: boolean) {
return [
'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active ? 'platform-category-chip--active' : null,
]
.filter(Boolean)
.join(' ');
}
export function PlatformFilterToolbar({
filterLabel,
filterCount,
tabItems,
activeTabId,
sortLabel,
layout,
onOpenFilter,
onTabChange,
onToggleSort,
}: PlatformFilterToolbarProps) {
const isMobileLayout = layout === 'mobile';
return (
<>
<div
className={
isMobileLayout
? 'platform-category-filter-row'
: 'mb-4 flex min-w-0 items-center gap-2'
}
>
<button
type="button"
onClick={onOpenFilter}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>{filterLabel}</span>
<span className="platform-category-filter-button__count">
{filterCount}
</span>
</button>
{isMobileLayout ? (
<span className="platform-category-filter-divider" />
) : null}
<PlatformSegmentedTabs
items={tabItems}
activeId={activeTabId}
onChange={onTabChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
className={
isMobileLayout
? 'platform-category-chip-scroll min-w-0 flex-1'
: 'min-w-0 flex-1 pb-1'
}
itemClassName={(_, active) => buildToolbarTabItemClassName(active)}
/>
{!isMobileLayout ? (
<button
type="button"
onClick={onToggleSort}
className="platform-category-sort-button shrink-0"
>
<span>{sortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{isMobileLayout ? (
<button
type="button"
onClick={onToggleSort}
className="platform-category-sort-button"
>
<span>{sortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
) : null}
</>
);
}

View File

@@ -0,0 +1,126 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { Play } from 'lucide-react';
import { expect, test } from 'vitest';
import { PlatformIconBadge } from './PlatformIconBadge';
test('renders neutral circular icon badge by default', () => {
render(<PlatformIconBadge icon={<Play className="h-4 w-4" />} />);
const badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('platform-icon-badge');
expect(badge?.className).toContain('h-9');
expect(badge?.className).toContain('rounded-full');
expect(badge?.className).toContain('bg-[var(--platform-neutral-bg)]');
});
test('supports rounded medium icon badge with label', () => {
render(
<PlatformIconBadge
icon={<Play className="h-4 w-4" />}
label="继续"
size="md"
shape="rounded"
className="custom-badge"
/>,
);
const badge = screen.getByLabelText('继续');
expect(badge.className).toContain('h-11');
expect(badge.className).toContain('rounded-[0.85rem]');
expect(badge.className).toContain('custom-badge');
});
test('supports extra small soft icon badge', () => {
render(
<PlatformIconBadge
icon={<Play className="h-3.5 w-3.5" />}
size="xs"
tone="soft"
/>,
);
const badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-7');
expect(badge?.className).toContain('bg-white/82');
expect(badge?.className).toContain('shadow-sm');
});
test('supports larger creative icon badge tones', () => {
const { rerender } = render(
<PlatformIconBadge
icon={<Play className="h-4 w-4" />}
size="base"
tone="softBright"
/>,
);
let badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-10');
expect(badge?.className).toContain('bg-white/84');
rerender(
<PlatformIconBadge
icon={<Play className="h-5 w-5" />}
size="lg"
tone="hero"
/>,
);
badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-12');
expect(badge?.className).toContain('bg-white/18');
rerender(
<PlatformIconBadge
icon={<Play className="h-3.5 w-3.5" />}
size="xs"
tone="heroMuted"
/>,
);
badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-7');
expect(badge?.className).toContain('text-white/72');
rerender(
<PlatformIconBadge
icon={<Play className="h-6 w-6" />}
size="xl"
tone="success"
/>,
);
badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-14');
expect(badge?.className).toContain('bg-[var(--platform-success-bg)]');
rerender(
<PlatformIconBadge icon={<Play className="h-4 w-4" />} tone="danger" />,
);
badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('bg-[var(--platform-button-danger-fill)]');
expect(badge?.className).toContain(
'text-[var(--platform-button-danger-text)]',
);
});
test('supports extra large dark amber icon badge', () => {
render(
<PlatformIconBadge
icon={<Play className="h-8 w-8" />}
size="xxl"
shape="xl"
tone="darkAmber"
/>,
);
const badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-20');
expect(badge?.className).toContain('rounded-2xl');
expect(badge?.className).toContain('border-amber-400/30');
expect(badge?.className).toContain('bg-amber-500/15');
});

View File

@@ -0,0 +1,85 @@
import type { ReactNode } from 'react';
type PlatformIconBadgeSize = 'xs' | 'sm' | 'base' | 'md' | 'lg' | 'xl' | 'xxl';
type PlatformIconBadgeShape = 'circle' | 'rounded' | 'xl';
type PlatformIconBadgeTone =
| 'neutral'
| 'soft'
| 'softBright'
| 'hero'
| 'heroMuted'
| 'darkAmber'
| 'success'
| 'danger';
type PlatformIconBadgeProps = {
icon: ReactNode;
label?: string;
size?: PlatformIconBadgeSize;
shape?: PlatformIconBadgeShape;
tone?: PlatformIconBadgeTone;
className?: string;
};
const PLATFORM_ICON_BADGE_SIZE_CLASS: Record<PlatformIconBadgeSize, string> = {
xs: 'h-7 w-7',
sm: 'h-9 w-9',
base: 'h-10 w-10',
md: 'h-11 w-11',
lg: 'h-12 w-12',
xl: 'h-14 w-14',
xxl: 'h-20 w-20',
};
const PLATFORM_ICON_BADGE_SHAPE_CLASS: Record<PlatformIconBadgeShape, string> =
{
circle: 'rounded-full',
rounded: 'rounded-[0.85rem]',
xl: 'rounded-2xl',
};
const PLATFORM_ICON_BADGE_TONE_CLASS: Record<PlatformIconBadgeTone, string> = {
neutral:
'bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]',
soft: 'bg-white/82 text-[var(--platform-text-strong)] shadow-sm',
softBright: 'bg-white/84 text-[var(--platform-text-strong)] shadow-sm',
hero: 'bg-white/18 text-white',
heroMuted: 'bg-white/18 text-white/72',
darkAmber: 'border border-amber-400/30 bg-amber-500/15 text-amber-50',
success:
'bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]',
danger:
'bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
};
/**
* 平台中性图标徽章。
* 统一承接弹窗标题、列表项和小卡片里的非交互图标槽。
*/
export function PlatformIconBadge({
icon,
label,
size = 'sm',
shape = 'circle',
tone = 'neutral',
className,
}: PlatformIconBadgeProps) {
return (
<span
aria-label={label}
aria-hidden={label ? undefined : true}
className={[
'platform-icon-badge',
'grid shrink-0 place-items-center',
PLATFORM_ICON_BADGE_SIZE_CLASS[size],
PLATFORM_ICON_BADGE_SHAPE_CLASS[shape],
PLATFORM_ICON_BADGE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{icon}
</span>
);
}

Some files were not shown because too many files have changed in this diff Show More