合并 master 到跳一跳分支

合入 master 最新平台公共组件与后端更新

保留跳一跳长按蓄力与视觉顶面判定规则

解决跳一跳运行态导入和决策日志冲突
This commit is contained in:
2026-06-12 23:07:01 +08:00
420 changed files with 44979 additions and 13695 deletions
+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>
);
}
+57 -23
View File
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
@@ -204,8 +204,7 @@ const baseProfile = {
'玩家以返乡守灯人继承者身份切入,首夜就撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict:
'守潮盟与沉钟会争夺航路解释权,有人借假航灯持续清洗整片群岛的旧证据,玩家回港当夜就被卷进禁航区封锁。',
keyRelationships:
'玩家与沈砺旧友互疑,沈砺掌握沉船夜的关键视角。',
keyRelationships: '玩家与沈砺旧友互疑,沈砺掌握沉船夜的关键视角。',
hiddenLines:
'沉钟异动和旧案灭口是同一条线,表面看像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements:
@@ -324,7 +323,8 @@ function ResultViewRehydratingHarness() {
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
const user = userEvent.setup();
let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null;
let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null =
null;
mockedRpgCreationAssetClient.generatePlayableNpc.mockImplementation(
() =>
new Promise<CustomWorldPlayableNpc>((resolve) => {
@@ -385,7 +385,8 @@ test('world tab generates opening cg only after manual click and writes it back
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
id: 'opening-cg-1',
status: 'ready',
storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png',
storyboardImageSrc:
'/generated-custom-world-scenes/world/opening/storyboard.png',
storyboardAssetId: 'storyboard-1',
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
videoAssetId: 'video-1',
@@ -407,9 +408,9 @@ test('world tab generates opening cg only after manual click and writes it back
await user.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(mockedRpgCreationAssetClient.generateOpeningCg).toHaveBeenCalledTimes(
1,
);
expect(
mockedRpgCreationAssetClient.generateOpeningCg,
).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(
@@ -425,7 +426,8 @@ test('world tab keeps opening cg visible after parent rehydrates normalized prof
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
id: 'opening-cg-1',
status: 'ready',
storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png',
storyboardImageSrc:
'/generated-custom-world-scenes/world/opening/storyboard.png',
storyboardAssetId: 'storyboard-1',
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
videoAssetId: 'video-1',
@@ -521,14 +523,18 @@ test('landmark tab previews every generated act image while keeping chapter deta
);
expect(
(screen.getByRole('img', {
name: '沉钟栈桥-潮声逼近',
}) as HTMLImageElement).getAttribute('src'),
(
screen.getByRole('img', {
name: '沉钟栈桥-潮声逼近',
}) as HTMLImageElement
).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-1.png');
expect(
(screen.getByRole('img', {
name: '沉钟栈桥-钟楼回响',
}) as HTMLImageElement).getAttribute('src'),
(
screen.getByRole('img', {
name: '沉钟栈桥-钟楼回响',
}) as HTMLImageElement
).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-2.png');
});
@@ -580,9 +586,7 @@ test('agent result view shows error when entity generation returns no new profil
await user.click(screen.getByRole('button', { name: /场景角色/u }));
await user.click(screen.getByRole('button', { name: '新增场景角色' }));
expect(
await screen.findByText(/结果页未收到新增内容/u),
).toBeTruthy();
expect(await screen.findByText(/结果页未收到新增内容/u)).toBeTruthy();
});
test('agent result view keeps publish-enter action clickable and hides sticky publish hints', () => {
@@ -652,11 +656,9 @@ test('agent result view opens publish blocker dialog only when user clicks publi
await user.click(screen.getByRole('button', { name: '发布并进入世界' }));
expect(screen.getByRole('dialog', { name: '发布作品' })).toBeTruthy();
expect(screen.getByText('发布检查')).toBeTruthy();
expect(screen.getByText('封面设置')).toBeTruthy();
expect(
screen.getByText(/仍有角色缺少正式主图或动作资产/u),
).toBeTruthy();
expect(screen.getByText('发布检查').className).toContain('tracking-[0.18em]');
expect(screen.getByText('封面设置').className).toContain('tracking-[0.18em]');
expect(screen.getByText(/仍有角色缺少正式主图或动作资产/u)).toBeTruthy();
});
test('agent result view keeps publish-enter action enabled when publish gate is clear', () => {
@@ -693,3 +695,35 @@ test('agent result view keeps publish-enter action enabled when publish gate is
});
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
});
test('result view confirms full regeneration with unified dialog', async () => {
const user = userEvent.setup();
const handleRegenerate = vi.fn();
render(
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
onRegenerate={handleRegenerate}
/>,
);
await user.click(screen.getByRole('button', { name: '重新生成' }));
const dialog = screen.getByRole('dialog', { name: '重新生成' });
expect(screen.getByText(/确认重新生成“潮雾群岛”吗/u)).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '取消' }));
expect(handleRegenerate).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '重新生成' }));
await user.click(screen.getByRole('button', { name: '确认重新生成' }));
expect(handleRegenerate).toHaveBeenCalledTimes(1);
});
+48 -18
View File
@@ -1,8 +1,9 @@
import { Clock3, Hourglass } from 'lucide-react';
import { motion } from 'motion/react';
import { ArrowLeft, Clock3, Hourglass } from 'lucide-react';
import { useEffect, useId, useRef } from 'react';
import generationHeroVideo from '../../media/create_bg_video.mp4';
import { PlatformIconButton } from './common/PlatformIconButton';
import { PlatformProgressBar } from './common/PlatformProgressBar';
const GENERATION_PROGRESS_RING_GAP_DEGREES = 90;
const GENERATION_PROGRESS_RING_BOTTOM_DEGREES = 90;
@@ -35,6 +36,14 @@ type GenerationCurrentStepCardProps = {
progressValue: number;
};
type GenerationHeaderBackButtonProps = {
label: string;
onClick: () => void;
disabled?: boolean;
disabledOpacity?: number;
className?: string;
};
function clampGenerationProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
@@ -51,6 +60,34 @@ function buildGenerationRingMetrics(progressValue: number) {
};
}
export function GenerationHeaderBackButton({
label,
onClick,
disabled = false,
disabledOpacity,
className,
}: GenerationHeaderBackButtonProps) {
return (
<PlatformIconButton
label={label}
title={label}
variant="darkMini"
onClick={onClick}
disabled={disabled}
className={[
'gap-2 rounded-full !border-transparent !bg-transparent px-0 py-2 text-xs font-black !text-[#171411] shadow-none hover:!bg-transparent hover:!text-[#171411] sm:text-sm',
className,
]
.filter(Boolean)
.join(' ')}
style={disabled && disabledOpacity != null ? { opacity: disabledOpacity } : undefined}
icon={<ArrowLeft className="h-5 w-5 shrink-0" strokeWidth={2.6} />}
>
<span className="break-keep">{label}</span>
</PlatformIconButton>
);
}
export function GenerationPageBackdrop() {
const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -64,8 +101,7 @@ export function GenerationPageBackdrop() {
video.muted = true;
video.volume = 0;
const isJsdom =
window.navigator.userAgent.toLowerCase().includes('jsdom');
const isJsdom = window.navigator.userAgent.toLowerCase().includes('jsdom');
const tryPlay = () => {
if (isJsdom) {
return;
@@ -285,20 +321,14 @@ export function GenerationCurrentStepCard({
) : null}
</div>
</div>
<div
className="mt-4 h-2.5 overflow-hidden rounded-full bg-[#f5eee8]"
role="progressbar"
aria-label={`${label} 进度`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={safeProgress}
>
<motion.div
className="h-full rounded-full bg-[linear-gradient(90deg,#ef7a1f_0%,#e25f18_64%,#f0b07e_100%)]"
animate={{ width: `${safeProgress}%` }}
transition={{ duration: 0.45, ease: 'easeOut' }}
/>
</div>
<PlatformProgressBar
value={safeProgress}
size="sm"
ariaLabel={`${label} 进度`}
className="mt-4 bg-[#f5eee8]"
fillClassName="bg-[linear-gradient(90deg,#ef7a1f_0%,#e25f18_64%,#f0b07e_100%)]"
fillStyle={{ transitionDuration: '450ms' }}
/>
</div>
);
}
+10 -6
View File
@@ -9,6 +9,8 @@ import {
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
import { PlatformQuantityBadge } from './common/PlatformQuantityBadge';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
@@ -130,9 +132,7 @@ export function InventoryItemGrid({
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
/>
</div>
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
{item.quantity}
</div>
<PlatformQuantityBadge>{item.quantity}</PlatformQuantityBadge>
</button>
);
})}
@@ -185,7 +185,11 @@ export function InventoryItemDetailModal({
onClick={(event) => event.stopPropagation()}
>
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
<PixelCloseButton onClick={onClose} label="关闭物品详情" className="top-4 sm:top-5" />
<PixelCloseButton
onClick={onClose}
label="关闭物品详情"
className="top-4 sm:top-5"
/>
<div
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
@@ -234,9 +238,9 @@ export function InventoryItemDetailModal({
</div>
{footer != null ? (
<div className="border-t border-white/10 px-4 py-3 sm:px-5">
<PlatformDarkModalFooter layout="content">
{footer}
</div>
</PlatformDarkModalFooter>
) : null}
</motion.div>
</motion.div>
+165
View File
@@ -0,0 +1,165 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { RuntimeStoryForgeRecipeView } from '../../packages/shared/src/contracts/rpgRuntimeStoryState';
import { type Character, type InventoryItem, WorldType } from '../types';
import { InventoryPanel } from './InventoryPanel';
const inventoryItem: InventoryItem = {
id: 'training-token',
category: '材料',
name: '练习石',
quantity: 1,
rarity: 'common',
tags: [],
};
const documentItem: InventoryItem = {
id: 'thread-note',
category: '文书',
name: '潮汐证词',
quantity: 1,
rarity: 'common',
tags: ['document'],
description: '记录着潮汐线索。',
};
const forgeRecipe: RuntimeStoryForgeRecipeView = {
id: 'forge-tide-amulet',
name: '潮汐护符',
kind: 'forge',
description: '用于测试工坊需求状态。',
resultLabel: '潮汐护符',
currencyCost: 5,
currencyText: '5 贝币',
requirements: [
{
id: 'iron',
label: '铁矿',
quantity: 2,
owned: 2,
},
{
id: 'wood',
label: '木材',
quantity: 1,
owned: 0,
},
],
canCraft: false,
disabledReason: '材料不足',
action: {
functionId: 'craft',
actionText: '锻造',
enabled: false,
reason: '材料不足',
},
};
test('背包工坊材料需求状态复用暗色平台胶囊标签', () => {
render(
<InventoryPanel
playerCharacter={{} as Character}
worldType={WorldType.CUSTOM}
playerInventory={[inventoryItem]}
playerCurrency={0}
playerHp={10}
playerMaxHp={10}
playerMana={5}
playerMaxMana={5}
inBattle={false}
forgeRecipes={[forgeRecipe]}
onUseItem={vi.fn(async () => false)}
onEquipItem={vi.fn(async () => false)}
onCraftRecipe={vi.fn(async () => false)}
onDismantleItem={vi.fn(async () => false)}
onReforgeItem={vi.fn(async () => false)}
/>,
);
const metRequirement = screen.getByText('铁矿 2/2');
const missingRequirement = screen.getByText('木材 0/1');
const forgePanel = screen.getByText('工坊').closest('section');
const recipePanel = screen.getByText('潮汐护符').closest('section');
const forgeButton = screen.getByRole('button', { name: '锻造' });
expect(metRequirement.className).toContain('rounded-full');
expect(metRequirement.className).toContain('font-black');
expect(metRequirement.className).toContain('bg-emerald-500/10');
expect(missingRequirement.className).toContain('rounded-full');
expect(missingRequirement.className).toContain('font-black');
expect(missingRequirement.className).toContain('bg-black/20');
expect(missingRequirement.className).toContain('text-zinc-400');
expect(forgePanel?.className).toContain('border-white/10');
expect(forgePanel?.className).toContain('bg-black/25');
expect(recipePanel?.className).toContain('border-white/10');
expect(recipePanel?.className).toContain('bg-black/25');
expect(forgeButton.className).toContain('platform-action-button--editor-dark');
expect(forgeButton.className).toContain('rounded-lg');
expect(forgeButton.className).toContain('bg-emerald-400');
expect(forgeButton.className).toContain('disabled:bg-black/20');
});
test('背包文书和故事档案区块复用暗色 PlatformSubpanel chrome', () => {
render(
<InventoryPanel
playerCharacter={{} as Character}
worldType={WorldType.CUSTOM}
playerInventory={[documentItem]}
playerCurrency={0}
playerHp={10}
playerMaxHp={10}
playerMana={5}
playerMaxMana={5}
inBattle={false}
forgeRecipes={[]}
narrativeQaReport={{
generatedAt: '2026-06-10T00:00:00.000Z',
issues: [],
summary: '叙事链路稳定。',
}}
narrativeCodex={[
{
id: 'codex-tide',
title: '潮汐档案',
entries: [
{
id: 'entry-tide',
title: '旧港线索',
summary: '证词指向旧港。',
category: 'document',
relatedIds: [],
},
],
},
]}
onUseItem={vi.fn(async () => false)}
onEquipItem={vi.fn(async () => false)}
onCraftRecipe={vi.fn(async () => false)}
onDismantleItem={vi.fn(async () => false)}
onReforgeItem={vi.fn(async () => false)}
/>,
);
const documentPanel = screen.getByText('文书与证据').closest('section');
const documentButton = screen.getByRole('button', {
name: /潮汐证词/,
});
const storyPanel = screen.getByText('故事档案').closest('section');
const qaMessage = screen.getByText('QA:叙事链路稳定。');
const codexPanel = screen.getByText('潮汐档案').closest('section');
expect(documentPanel?.className).toContain('border-white/10');
expect(documentPanel?.className).toContain('bg-black/25');
expect(documentButton.className).toContain('border-white/10');
expect(documentButton.className).toContain('bg-black/25');
expect(storyPanel?.className).toContain('border-white/10');
expect(storyPanel?.className).toContain('bg-black/25');
expect(qaMessage.className).toContain('platform-status-message');
expect(qaMessage.className).toContain('border-amber-300/15');
expect(qaMessage.className).toContain('bg-amber-500/10');
expect(codexPanel?.className).toContain('border-white/10');
expect(codexPanel?.className).toContain('bg-black/25');
});

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