diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index ed244c48..3d37d65b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -63,6 +63,415 @@ - 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并确认旧 `/api/creation//*`、历史 `/api/runtime//agent/*` 与公开 runtime 路由外部契约不变。 - 关联文档:`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 / 账户中心里的同类轻量导航行优先直接复用共享行骨架,不再回退成原生 ` + - + ) : null} @@ -1122,40 +1142,54 @@ export function AdventureEntityModal({
{selectedCompanionResolution && ( -
+ 队友收束: {selectedCompanionResolution.resolutionType} ·{' '} {selectedCompanionResolution.summary} -
+ )} {relatedConsequences.length > 0 && (
{relatedConsequences.map((record, index) => ( -
{record.title} {':'} {record.summary} -
+ ))}
)} {recentChronicleEntries.length > 0 && (
{recentChronicleEntries.map((entry, index) => ( -
{entry.title} @@ -1163,31 +1197,41 @@ export function AdventureEntityModal({
{entry.summary}
-
+ ))}
)} {recentCarrierEchoes.length > 0 && ( -
+ 载体回响:{recentCarrierEchoes.join(';')} -
+ )} {sceneResidues.length > 0 && (
{sceneResidues.map((residue, index) => ( -
{residue.title} {':'} {residue.visibleClue} -
+ ))}
)} @@ -1198,7 +1242,13 @@ export function AdventureEntityModal({
{selection.kind === 'player' ? ( -
+
等级
@@ -1211,7 +1261,7 @@ export function AdventureEntityModal({ normalizedPlayerProgression.xpToNextLevel } /> -
+ ) : null}
setSelectedItemId(item.id)} /> ) : ( -
暂无物品
+ + 暂无物品 + )}
@@ -1320,70 +1376,10 @@ export function AdventureEntityModal({
-
-
-
-
-
-
- 标签概览 -
-
- {selectedContributionRow.label} -
-
-
-
- {getBuildContributionQualityLabel( - selectedContributionRow.bonusDelta, - )} -
-
- 总加成{' '} - {formatBuildContributionPercent( - selectedContributionRow.bonusDelta, - )} -
-
-
-
-
- -
-
- 属性加成 -
- - {selectedContributionAttributes.length > 0 ? ( -
- {selectedContributionAttributes.map((attribute) => ( -
-
- {attribute.label} - - {formatBuildContributionPercent( - attribute.modifierDelta, - )} - -
-
- ))} -
- ) : ( -
- 当前标签还没有可展示的属性适配明细。 -
- )} -
-
+
@@ -1444,63 +1440,52 @@ export function AdventureEntityModal({ } /> ) : ( -
+ {detailCharacter ? '当前未进入具体世界,暂时无法恢复技能预览。' : '该 NPC 当前没有独立技能演示模型,先展示真实技能数据。'} -
+ )}
- + {getSkillDeliveryLabel(selectedSkill)} - - + + {getSkillStyleLabel(selectedSkill)} - + {selectedSkill.buildBuffs?.length ? ( - + 附带 {selectedSkill.buildBuffs.length} 个状态标签 - + ) : null}
-
-
- 伤害 -
-
- {selectedSkill.damage} -
-
-
-
- 法力 -
-
- {selectedSkill.manaCost} -
-
-
-
- 冷却 -
-
- {selectedSkill.cooldownTurns} -
-
-
-
- 距离 -
-
- {selectedSkill.range} -
-
+ + + +
-
+ {selectedSkill.name} 属于{getSkillStyleLabel(selectedSkill)} 路线,通常以{getSkillDeliveryLabel(selectedSkill)}方式出手, 造成 {selectedSkill.damage} 点伤害,消耗{' '} @@ -1509,16 +1494,21 @@ export function AdventureEntityModal({ {selectedSkill.effects?.length ? ` 该技能还会触发 ${selectedSkill.effects.length} 段战斗特效。` : ''} -
+ {selectedSkill.buildBuffs?.length ? ( -
+
附带状态标签
{selectedSkill.buildBuffs.map((buff, index) => ( - {buff.name} / {buff.tags.join('、')} /{' '} {buff.durationTurns} 回合 - + ))}
-
+ ) : null} diff --git a/src/components/AffinityStatusCard.test.tsx b/src/components/AffinityStatusCard.test.tsx new file mode 100644 index 00000000..87681ae2 --- /dev/null +++ b/src/components/AffinityStatusCard.test.tsx @@ -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(); + + 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(); + + 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'); +}); diff --git a/src/components/AffinityStatusCard.tsx b/src/components/AffinityStatusCard.tsx index 3a48ed80..5a1da1be 100644 --- a/src/components/AffinityStatusCard.tsx +++ b/src/components/AffinityStatusCard.tsx @@ -2,8 +2,12 @@ import { AFFINITY_PROGRESS_MARKERS, AFFINITY_PROGRESS_MAX, AFFINITY_PROGRESS_MIN, + type AffinityLevelId, getAffinityLevelMeta, } 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]; @@ -45,6 +49,16 @@ function isMarkerReached(marker: AffinityProgressMarker, affinity: number) { 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 }) { const currentLevel = getAffinityLevelMeta(affinity); const nextLevel = getNextAffinityMarker(affinity); @@ -69,18 +83,20 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) { return (
-
+
好感等级
- {currentLevel.label} - + 当前好感 {affinity} @@ -107,9 +123,14 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {

{currentLevel.description}

-
+ -
+
好感进度
@@ -215,7 +236,7 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) { ); })}
-
+
); } diff --git a/src/components/BackstoryArchive.test.tsx b/src/components/BackstoryArchive.test.tsx new file mode 100644 index 00000000..bbab5588 --- /dev/null +++ b/src/components/BackstoryArchive.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + const emptyState = screen.getByText('暂无可整理的背景线索。'); + + expect(emptyState.className).toContain('platform-empty-state'); + expect(emptyState.className).toContain('border-dashed'); + expect(emptyState.className).toContain('bg-black/20'); +}); diff --git a/src/components/BackstoryArchive.tsx b/src/components/BackstoryArchive.tsx index a53d3ae4..8e8b5885 100644 --- a/src/components/BackstoryArchive.tsx +++ b/src/components/BackstoryArchive.tsx @@ -1,3 +1,7 @@ +import { PlatformEmptyState } from './common/PlatformEmptyState'; +import { PlatformPillBadge } from './common/PlatformPillBadge'; +import { PlatformSubpanel } from './common/PlatformSubpanel'; + export type BackstoryUnlockedChapter = { id: string; title: string; @@ -38,58 +42,71 @@ export function BackstoryArchive({
{publicSummary ? ( -
+
公开印象
{publicSummary}
-
+ ) : null} {unlockedChapters.map((chapter) => ( -
{chapter.title}
- + 已解锁 - +
{chapter.content}
-
+ ))} {lockedChapters.map((chapter) => ( -
{chapter.title}
- + 需好感 {chapter.affinityRequired} - +
{chapter.teaser}
-
+ ))} {!publicSummary && totalChapters === 0 ? ( -
+ 暂无可整理的背景线索。 -
+ ) : null}
); diff --git a/src/components/CharacterChatModal.test.tsx b/src/components/CharacterChatModal.test.tsx new file mode 100644 index 00000000..087c7360 --- /dev/null +++ b/src/components/CharacterChatModal.test.tsx @@ -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 { + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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); +}); diff --git a/src/components/CharacterChatModal.tsx b/src/components/CharacterChatModal.tsx index 101fe663..81a9a2e3 100644 --- a/src/components/CharacterChatModal.tsx +++ b/src/components/CharacterChatModal.tsx @@ -3,6 +3,12 @@ import { useEffect, useRef } from 'react'; import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; 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'; interface CharacterChatModalProps { @@ -68,23 +74,45 @@ export function CharacterChatModal({
角色状态
-
+ 生命值 {modal.target.hp} / {modal.target.maxHp} -
-
+ + 内力 {modal.target.mana} / {modal.target.maxMana} -
-
+ + {modal.target.character.personality} -
+
聊天总结
-
+ {modal.summary || '你们还没有形成新的私下聊天总结。'} -
+
@@ -115,51 +143,57 @@ export function CharacterChatModal({ )) ) : ( -
+ 这里会保留你和该角色的私下聊天记录。输入框支持自由发挥,上方三条文本可以帮你快速起句。 -
+ )}
帮你回复
- +
{modal.suggestions.map((suggestion, index) => ( - + ))}
{modal.error && ( -
+ {modal.error} -
+ )}
-