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
File diff suppressed because one or more lines are too long
+8
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"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。
- 关联:`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(...)` 里直接炸掉。
+2
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)。
平台 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)。
小游戏 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)。
File diff suppressed because one or more lines are too long
+287 -2
View File
@@ -1,13 +1,15 @@
/* @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 {
AnimationState,
type Character,
type CompanionRenderState,
type Encounter,
type GameState,
type EquipmentLoadout,
type GameState,
WorldType,
} from '../types';
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(() => {
vi.restoreAllMocks();
});
@@ -169,3 +231,226 @@ test('NPC 背包物品空 id 会被规范成稳定渲染 id', () => {
),
).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');
});
File diff suppressed because it is too large Load Diff
@@ -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');
});
+28 -7
View File
@@ -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 (
<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>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}
<PlatformPillBadge
tone={getAffinityLevelBadgeTone(currentLevel.id)}
size="xxs"
className="tracking-[0.16em]"
>
{currentLevel.label}
</span>
</PlatformPillBadge>
<span className="text-sm font-semibold text-white">
{affinity}
</span>
@@ -107,9 +123,14 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
{currentLevel.description}
</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>
@@ -215,7 +236,7 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {
);
})}
</div>
</div>
</PlatformSubpanel>
</div>
);
}
+87
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');
});
+31 -14
View File
@@ -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({
</div>
{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>
<div className="mt-2 text-sm leading-relaxed text-zinc-200">
{publicSummary}
</div>
</div>
</PlatformSubpanel>
) : null}
{unlockedChapters.map((chapter) => (
<div
<PlatformSubpanel
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="text-sm font-semibold text-white">
{chapter.title}
</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 className="mt-2 text-sm leading-relaxed text-zinc-200">
{chapter.content}
</div>
</div>
</PlatformSubpanel>
))}
{lockedChapters.map((chapter) => (
<div
<PlatformSubpanel
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="text-sm font-semibold text-zinc-200">
{chapter.title}
</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}
</span>
</PlatformPillBadge>
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-500">
{chapter.teaser}
</div>
</div>
</PlatformSubpanel>
))}
{!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}
</div>
);
+137
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);
});
+69 -30
View File
@@ -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({
<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="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}
</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2">
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
>
{modal.target.mana} / {modal.target.maxMana}
</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2 text-xs leading-relaxed text-zinc-400">
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="text-xs leading-relaxed text-zinc-400"
>
{modal.target.character.personality}
</div>
</PlatformSubpanel>
</div>
</div>
<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="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 || '你们还没有形成新的私下聊天总结。'}
</div>
</PlatformSubpanel>
</div>
</div>
@@ -115,51 +143,57 @@ export function CharacterChatModal({
</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 className="mt-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold text-white"></div>
<button
type="button"
<PlatformActionButton
surface="editorDark"
tone="ghost"
size="xxs"
shape="pill"
onClick={onRefreshSuggestions}
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 ? '生成中...' : '换一组'}
</button>
</PlatformActionButton>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{modal.suggestions.map((suggestion, index) => (
<button
<PlatformDarkOptionCard
key={`${suggestion}-${index}`}
type="button"
onClick={() => onUseSuggestion(suggestion)}
disabled={modal.isSending}
className={`rounded-xl border px-3 py-2 text-left text-xs leading-relaxed transition ${
modal.isSending
? 'border-white/8 bg-black/20 text-zinc-600'
: 'border-white/8 bg-black/20 text-zinc-200 hover:border-sky-300/30 hover:bg-sky-500/10 hover:text-white'
}`}
selected={false}
tone="sky"
radius="md"
padding="sm"
className="text-xs leading-relaxed"
>
{suggestion}
</button>
</PlatformDarkOptionCard>
))}
</div>
{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}
</div>
</PlatformStatusMessage>
)}
<form
@@ -169,13 +203,18 @@ export function CharacterChatModal({
onSendDraft();
}}
>
<textarea
<PlatformTextField
variant="textarea"
value={modal.draft}
onChange={event => onDraftChange(event.target.value)}
placeholder={`${modal.target.character.name}说点什么...`}
disabled={modal.isSending}
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">
<button
@@ -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]');
}
});
+59 -19
View File
@@ -36,6 +36,8 @@ import {
CharacterAttributeGrid,
CharacterSkillsList,
} from './CharacterInfoShared';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelCloseButton } from './PixelCloseButton';
@@ -97,9 +99,12 @@ function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
return (
<div className="grid gap-2 sm:grid-cols-3">
{items.map((item) => (
<div
<PlatformSubpanel
as="div"
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">
{item.slot}
@@ -108,7 +113,7 @@ function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
{item.item}
</div>
<div className="mt-1 text-xs text-zinc-400">{item.rarity}</div>
</div>
</PlatformSubpanel>
))}
</div>
);
@@ -118,9 +123,12 @@ function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
return (
<div className="grid gap-2 sm:grid-cols-2">
{items.map((item) => (
<div
<PlatformSubpanel
as="div"
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">
{item.category}
@@ -131,7 +139,7 @@ function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
<div className="mt-1 text-xs text-zinc-400">
x{item.quantity}
</div>
</div>
</PlatformSubpanel>
))}
</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">
{character.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)}
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
character.visual,
)}
scale={2.08}
/>
) : (
@@ -216,17 +226,25 @@ export function CharacterDetailModal({
/>
)}
</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">
{character.name}
</div>
<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 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)}
</span>
</PlatformPillBadge>
</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
{character.description}
@@ -262,18 +280,28 @@ export function CharacterDetailModal({
{opening && (
<Section title="旅程">
<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>
<div className="mt-1">{opening.reason}</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="sm"
padding="sm"
>
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1">{opening.goal}</div>
</div>
</PlatformSubpanel>
</div>
</Section>
)}
@@ -293,15 +321,27 @@ export function CharacterDetailModal({
</Section>
<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}
</div>
</PlatformSubpanel>
</Section>
<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}
</div>
</PlatformSubpanel>
</Section>
</div>
</div>
+136
View File
@@ -4,10 +4,13 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { BuildContributionRow } from '../data/buildDamage';
import { AnimationState, type Character } from '../types';
import {
BuildContributionDetailPanel,
CharacterIdentityBadges,
CharacterSkillsList,
MultiplierContributionList,
PlayerLevelProgress,
} from './CharacterInfoShared';
@@ -31,6 +34,19 @@ afterEach(() => {
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 () => {
const user = userEvent.setup();
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');
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[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(2, 'skill-雾行转位-1');
@@ -66,6 +89,27 @@ test('CharacterSkillsList falls back to stable render ids when skill ids are emp
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', () => {
render(
<CharacterIdentityBadges
@@ -77,6 +121,98 @@ test('CharacterIdentityBadges renders role and level chips together', () => {
expect(screen.getByText('队长')).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', () => {
+135 -28
View File
@@ -1,6 +1,7 @@
import { resolveRoleCombatStats } from '../data/attributeCombat';
import { getAttributeSlotValue } from '../data/attributeResolver';
import {
type BuildContributionAttributeRow,
type BuildDamageBreakdown,
formatBuildContributionPercent,
getBuildContributionQualityLabel,
@@ -21,6 +22,10 @@ import {
getSkillDeliveryLabel,
getSkillStyleLabel,
} 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({
label,
@@ -68,28 +73,34 @@ export function CharacterIdentityBadges({
roleTone?: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc';
className?: string;
}) {
const roleClass =
const roleBadgeTone: PlatformPillBadgeTone =
roleTone === 'amber'
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
? 'darkAmber'
: roleTone === 'rose'
? 'border-rose-300/20 bg-rose-500/10 text-rose-100'
? 'darkRose'
: roleTone === 'emerald'
? 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
? 'darkEmerald'
: roleTone === 'zinc'
? 'border-white/10 bg-black/20 text-zinc-200'
: 'border-sky-300/20 bg-sky-500/10 text-sky-100';
? 'darkNeutral'
: 'darkSky';
return (
<div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}>
<span
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${roleClass}`}
<PlatformPillBadge
tone={roleBadgeTone}
size="xxs"
className="tracking-[0.16em]"
>
{roleLabel}
</span>
</PlatformPillBadge>
{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}
</span>
</PlatformPillBadge>
) : null}
</div>
);
@@ -112,10 +123,7 @@ export function PlayerLevelProgress({
const ratio =
safeXpToNextLevel <= 0
? 1
: Math.max(
0,
Math.min(1, safeCurrentLevelXp / safeXpToNextLevel),
);
: Math.max(0, Math.min(1, safeCurrentLevelXp / safeXpToNextLevel));
return (
<div className={className}>
@@ -150,9 +158,9 @@ export function CharacterSkillsList({
}) {
if (skills.length === 0) {
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}
</div>
</PlatformEmptyState>
);
}
@@ -164,9 +172,13 @@ export function CharacterSkillsList({
<>
<div className="flex items-center justify-between gap-2">
<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)}
</span>
</PlatformPillBadge>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div>
@@ -182,24 +194,32 @@ export function CharacterSkillsList({
if (onSelectSkill) {
return (
<button
<PlatformSubpanel
as="button"
key={skillRenderId}
type="button"
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}
</button>
</PlatformSubpanel>
);
}
return (
<div
<PlatformSubpanel
as="div"
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}
</div>
</PlatformSubpanel>
);
})}
</div>
@@ -220,7 +240,13 @@ export function MultiplierContributionList({
);
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">
<span></span>
<span className="text-[9px] leading-4 text-zinc-400 sm:text-[10px]">
@@ -251,10 +277,91 @@ export function MultiplierContributionList({
))}
</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>
);
}
+172
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',
);
});
+79 -90
View File
@@ -7,9 +7,7 @@ import {
} from '../data/attributeResolver';
import {
type BuildDamageBreakdown,
formatBuildContributionPercent,
getBuildContributionAttributeRows,
getBuildContributionQualityLabel,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
} from '../data/buildDamage';
@@ -51,10 +49,10 @@ import { BackstoryArchive } from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator';
import {
getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getGenderLabel,
} from './CharacterInfoHelpers';
import {
BuildContributionDetailPanel,
CharacterAttributeGrid,
CharacterIdentityBadges,
CharacterSkillsList,
@@ -62,6 +60,8 @@ import {
PlayerLevelProgress,
StatusRow,
} from './CharacterInfoShared';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelCloseButton } from './PixelCloseButton';
@@ -417,16 +417,24 @@ export function CharacterPanel({
/>
</div>
<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}{' '}
</span>
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-emerald-100">
</PlatformPillBadge>
<PlatformPillBadge
tone="darkEmerald"
size="xs"
className="px-2 py-0.5 font-normal"
>
{'\u9002\u914d'} x
{buildBreakdownByMemberId[
member.id
]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
</span>
</PlatformPillBadge>
</div>
</div>
</div>
@@ -473,72 +481,10 @@ export function CharacterPanel({
</div>
<div className="overflow-y-auto p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<div className="space-y-4">
<div
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>
<BuildContributionDetailPanel
row={selectedContributionRow}
attributes={selectedContributionAttributes}
/>
</div>
</motion.div>
</motion.div>
@@ -580,9 +526,13 @@ export function CharacterPanel({
levelText={selectedMember.levelText}
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)}
</span>
</PlatformPillBadge>
</div>
</div>
<PixelCloseButton
@@ -639,7 +589,13 @@ export function CharacterPanel({
</div>
<div className="space-y-3">
{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>
@@ -652,7 +608,7 @@ export function CharacterPanel({
normalizedPlayerProgression.xpToNextLevel
}
/>
</div>
</PlatformSubpanel>
)}
<StatusRow
label={resourceLabels.hp}
@@ -670,7 +626,13 @@ export function CharacterPanel({
<AffinityStatusCard affinity={selectedMemberAffinity} />
)}
{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>
@@ -680,10 +642,17 @@ export function CharacterPanel({
<div className="mt-1 text-[11px] text-sky-200/85">
{selectedMemberArcState.arcTheme}
</div>
</div>
</PlatformSubpanel>
)}
{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>
@@ -693,7 +662,7 @@ export function CharacterPanel({
<div className="mt-1 text-[11px] text-emerald-100/85">
{selectedMemberResolution.summary}
</div>
</div>
</PlatformSubpanel>
)}
{selectedMemberAffinity != null && (
<BackstoryArchive
@@ -735,9 +704,15 @@ export function CharacterPanel({
<div className="mb-3 text-xs font-bold text-white">
</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}
</div>
</PlatformSubpanel>
</div>
)}
@@ -748,9 +723,15 @@ export function CharacterPanel({
<div className="mb-3 text-xs font-bold text-white">
</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}
</div>
</PlatformSubpanel>
</div>
<div
@@ -774,9 +755,13 @@ export function CharacterPanel({
</div>
<div className="space-y-2 text-sm text-zinc-300">
{selectedEquipmentRows.map((item) => (
<div
<PlatformSubpanel
as="div"
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">
<PixelIcon
@@ -790,10 +775,14 @@ export function CharacterPanel({
<div>{item.itemLabel}</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}
</span>
</div>
</PlatformPillBadge>
</PlatformSubpanel>
))}
</div>
</div>
+128
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');
});
+113 -55
View File
@@ -5,8 +5,15 @@ import { getCharacterById } from '../data/characterPresets';
import { MAX_COMPANIONS } from '../data/npcInteractions';
import { Character, CompanionState } from '../types';
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 { ResolvedAssetImage } from './ResolvedAssetImage';
interface CompanionCampModalProps {
isOpen: boolean;
@@ -26,9 +33,13 @@ type CompanionCardData = {
function StatusPill({ label, value }: { label: string; value: string }) {
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}
</div>
</PlatformPillBadge>
);
}
@@ -149,7 +160,13 @@ export function CompanionCampModal({
</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">
<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>
<div className="text-xs font-bold text-white"></div>
@@ -160,28 +177,39 @@ export function CompanionCampModal({
<StatusPill label="出战" value={`${companions.length}/${MAX_COMPANIONS}`} />
</div>
{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">
{activeCompanionCards.length > 0 ? activeCompanionCards.map(({ companion, character }) => {
const selectedForSwap = selectedSwapNpcId === companion.npcId;
return (
<div
<PlatformSubpanel
as="div"
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 h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
<ResolvedAssetImage
src={character.portrait}
alt={character.name}
className="h-full w-full scale-125 object-contain"
style={{ imageRendering: 'pixelated' }}
/>
</div>
<PlatformMediaFrame
src={character.portrait}
alt={character.name}
fallbackLabel={character.name}
aspect="square"
surface="editorDark"
className="h-16 w-16 shrink-0 rounded-xl"
imageClassName="h-full w-full scale-125 object-contain"
imageProps={{ style: { imageRendering: 'pixelated' } }}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{character.name}</div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
@@ -193,34 +221,48 @@ export function CompanionCampModal({
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
<PlatformDarkOptionCard
disabled={inBattle}
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>
<button
type="button"
</PlatformDarkOptionCard>
<PlatformActionButton
surface="editorDark"
tone="secondary"
size="xs"
disabled={inBattle}
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>
</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>
</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>
<div className="text-xs font-bold text-white"></div>
@@ -235,16 +277,25 @@ export function CompanionCampModal({
{reserveCompanionCards.length > 0 ? reserveCompanionCards.map(({ companion, character }) => {
const needsSwap = companions.length >= MAX_COMPANIONS;
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 h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
<ResolvedAssetImage
src={character.portrait}
alt={character.name}
className="h-full w-full scale-125 object-contain"
style={{ imageRendering: 'pixelated' }}
/>
</div>
<PlatformMediaFrame
src={character.portrait}
alt={character.name}
fallbackLabel={character.name}
aspect="square"
surface="editorDark"
className="h-16 w-16 shrink-0 rounded-xl"
imageClassName="h-full w-full scale-125 object-contain"
imageProps={{ style: { imageRendering: 'pixelated' } }}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{character.name}</div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
@@ -255,42 +306,49 @@ export function CompanionCampModal({
</div>
</div>
</div>
<button
type="button"
<PlatformActionButton
surface="editorDark"
tone={inBattle || (needsSwap && !selectedSwapNpcId) ? 'ghost' : 'success'}
size="xs"
fullWidth
disabled={inBattle || (needsSwap && !selectedSwapNpcId)}
onClick={() => onActivateCompanion(companion.npcId, needsSwap ? selectedSwapNpcId : null)}
className={`mt-3 w-full rounded-lg border px-3 py-2 text-xs ${
inBattle || (needsSwap && !selectedSwapNpcId)
? 'border-white/6 bg-black/20 text-zinc-500'
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
}`}
className="mt-3"
>
{needsSwap ? '换入队伍' : '编入队伍'}
</button>
</div>
</PlatformActionButton>
</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>
</section>
</PlatformSubpanel>
</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="grid gap-3 md:grid-cols-3">
{campMoments.map((moment, index) => (
<div
<PlatformSubpanel
as="div"
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}
</div>
</PlatformSubpanel>
))}
</div>
</div>
</PlatformDarkModalFooter>
</motion.div>
</motion.div>
)}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
/* @vitest-environment jsdom */
import userEvent from '@testing-library/user-event';
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 { CustomWorldGenerationView } from './CustomWorldGenerationView';
@@ -100,26 +101,36 @@ describe('CustomWorldGenerationView', () => {
'video[data-testid="generation-page-background-video"] source[type="video/mp4"]',
),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '返回创作中心' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '返回创作中心' })).toBeTruthy();
expect(
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('世界建设中')).toBeTruthy();
expect(screen.getByText('世界建设中').className).toContain('text-xs');
expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
'text-center',
expect(screen.getByText('世界建设中').className).toContain(
'border-[var(--platform-warm-border)]',
);
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(screen.getByText('世界建设中').className).toContain(
'bg-[var(--platform-warm-bg)]',
);
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(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
@@ -141,31 +152,25 @@ describe('CustomWorldGenerationView', () => {
expect(screen.queryByText('预计还需 1 分 15 秒')).toBeNull();
expect(screen.queryByText('已耗时 2 分 5 秒')).toBeNull();
expect(screen.queryByText('计时')).toBeNull();
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
'justify-start',
);
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
'z-30',
);
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
'pt-[2%]',
);
expect(
screen.getByTestId('generation-hero-progress-content').className,
).toContain('justify-start');
expect(
screen.getByTestId('generation-hero-progress-content').className,
).toContain('z-30');
expect(
screen.getByTestId('generation-hero-progress-content').className,
).toContain('pt-[2%]');
expect(screen.getByText('总进度').className).toContain('text-[9px]');
expect(screen.getByText('42%').className).toContain('text-[1.15rem]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
screen.getByRole('progressbar', { name: progressTitle }).className,
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
screen.getByRole('progressbar', { name: progressTitle }).className,
).toContain('max-w-full');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
screen.getByRole('progressbar', { name: progressTitle }).className,
).toContain('aspect-square');
expect(
screen
@@ -195,9 +200,11 @@ describe('CustomWorldGenerationView', () => {
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
'svg',
);
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain(
'z-0',
);
expect(
screen
.getByTestId('generation-hero-progress-ring')
.getAttribute('class'),
).toContain('z-0');
expect(
screen
.getByTestId('generation-hero-progress-ring')
@@ -250,8 +257,8 @@ describe('CustomWorldGenerationView', () => {
?.className,
).toContain('mt-5');
expect(
screen.getByRole('progressbar', { name: '编译草稿 进度' }),
).toBeTruthy();
screen.getByRole('progressbar', { name: '编译草稿 进度' }).className,
).toContain('platform-progress-track');
expect(screen.queryByText('收集设定')).toBeNull();
expect(screen.queryByText('写回结果')).toBeNull();
expect(screen.queryByText('当前批次')).toBeNull();
@@ -289,4 +296,29 @@ describe('CustomWorldGenerationView', () => {
expect(screen.queryByText('大鱼吃小鱼题材')).toBeNull();
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);
});
});
+21 -23
View File
@@ -1,9 +1,10 @@
import { ArrowLeft } from 'lucide-react';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import {
GenerationCurrentStepCard,
GenerationHeaderBackButton,
GenerationPageBackdrop,
GenerationProgressHero,
} from './GenerationProgressHero';
@@ -117,7 +118,8 @@ export function CustomWorldGenerationView({
const currentStepProgress = currentStep
? getStepProgressPercentage(currentStep)
: progressValue;
const currentStepLabel = currentStep?.label ?? progress?.phaseLabel ?? '准备生成';
const currentStepLabel =
currentStep?.label ?? progress?.phaseLabel ?? '准备生成';
const currentStepStatusLabel = currentStep
? getStepStatusLabel(currentStep)
: isGenerating
@@ -131,22 +133,17 @@ export function CustomWorldGenerationView({
progress != null ? formatDuration(progress.elapsedMs) : '启动中';
return (
<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"
>
<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">
<GenerationPageBackdrop />
<div className="relative z-30 mb-4 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-5">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm"
<GenerationHeaderBackButton label={backLabel} onClick={onBack} />
<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 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}
</div>
</PlatformPillBadge>
</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">
{!isGenerating ? (
<button
type="button"
<PlatformActionButton
onClick={onRetry}
className="platform-button platform-button--primary w-full sm:w-auto"
fullWidth
className="sm:w-auto"
>
{retryLabel}
</button>
</PlatformActionButton>
) : onInterrupt ? (
<button
type="button"
<PlatformActionButton
tone="danger"
shape="pill"
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}
</button>
</PlatformActionButton>
) : null}
</div>
</section>
+14 -8
View File
@@ -28,6 +28,7 @@ import {
type CustomWorldNpcVisual,
type CustomWorldProfile,
} from '../types';
import { PlatformActionButton } from './common/PlatformActionButton';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import { HostileNpcAnimator } from './HostileNpcAnimator';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
@@ -282,9 +283,18 @@ function ActionButton({
onClick: () => void;
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 (
<button
type="button"
<PlatformActionButton
surface="editorDark"
tone={buttonTone}
size="xs"
shape="pill"
onPointerDown={(event) => {
event.stopPropagation();
}}
@@ -292,14 +302,10 @@ function ActionButton({
event.stopPropagation();
}}
onClick={onClick}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${
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'
}`}
className={`text-sm font-semibold ${visualClassName}`}
>
{label}
</button>
</PlatformActionButton>
);
}

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