Merge remote-tracking branch 'origin/master' into codex/external-generation-worker-scaling
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -232,6 +232,14 @@
|
||||
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。
|
||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 内嵌泥点确认弹窗必须自带平台主题作用域
|
||||
|
||||
- 现象:拼图 / 抓大鹅统一创作页点击生成后,“确认消耗泥点”弹窗正文和按钮存在,但弹窗面板背景透明,只剩遮罩和文字。
|
||||
- 原因:`PlatformMudPointConfirmDialog` 作为二级确认常以 `portal={false}` 内嵌到工作台局部 DOM,局部节点不一定继承 `.platform-theme`;`platform-modal-shell` 依赖 `--platform-modal-fill` 等主题变量,变量缺失时面板底色解析为空。
|
||||
- 处理:共享泥点确认弹窗默认在 overlay 上带 `platform-theme platform-theme--<theme>`、`platform-modal-backdrop` 和实色遮罩,在 panel 上带 `platform-modal-shell platform-remap-surface`;单按钮状态弹窗也要有默认 light 主题,避免未来独立调用复现。
|
||||
- 验证:浏览器触发 `/creation/puzzle` 与 `/creation/match3d` 的泥点确认弹窗,检查 overlay 最近主题 class 存在、`--platform-modal-fill` 有值且面板为实底;聚焦测试覆盖默认 overlay / panel class。
|
||||
- 关联:`src/components/common/PlatformMudPointConfirmDialog.tsx`、`src/components/common/PlatformStatusDialog.tsx`、`src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx`、`src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx`。
|
||||
|
||||
## 玩法入口分类字段缺失要前端兜底
|
||||
|
||||
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
|
||||
|
||||
@@ -85,6 +85,8 @@ RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/compon
|
||||
|
||||
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。
|
||||
|
||||
平台 UI Kit 的提示 / 确认弹窗收口到 `src/components/common/UnifiedConfirmDialog.tsx`,复制反馈收口到 `src/components/common/useCopyFeedback.ts`、`src/components/common/CopyFeedbackButton.tsx`、`src/components/common/CopyCodeButton.tsx` 与 `src/components/common/CopyFeedbackMessage.tsx`,基础状态提示收口到 `src/components/common/PlatformStatusMessage.tsx`,运行态短错误 / 成功 / 反馈 toast 收口到 `src/components/common/PlatformRuntimeStatusToast.tsx`,平台空态 / 轻量加载态收口到 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮收口到 `src/components/common/PlatformActionButton.tsx`,平台白底子面板 / 小型列表卡片收口到 `src/components/common/PlatformSubpanel.tsx`,平台输入框 / 文本域收口到 `src/components/common/PlatformTextField.tsx`,平台字段标题收口到 `src/components/common/PlatformFieldLabel.tsx`,平台媒体预览框收口到 `src/components/common/PlatformMediaFrame.tsx`,平台胶囊状态标签收口到 `src/components/common/PlatformPillBadge.tsx`,平台 / 个人中心弹窗关闭按钮收口到 `src/components/common/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module,规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md)。
|
||||
|
||||
平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。
|
||||
|
||||
小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]');
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type CustomWorldNpcVisual,
|
||||
type CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
||||
import { HostileNpcAnimator } from './HostileNpcAnimator';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
@@ -282,9 +283,18 @@ function ActionButton({
|
||||
onClick: () => void;
|
||||
tone?: 'default' | 'sky';
|
||||
}) {
|
||||
const buttonTone = tone === 'sky' ? 'primary' : 'ghost';
|
||||
const visualClassName =
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:bg-sky-500/12 hover:text-white'
|
||||
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:bg-black/20 hover:text-white';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone={buttonTone}
|
||||
size="xs"
|
||||
shape="pill"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
@@ -292,14 +302,10 @@ function ActionButton({
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={onClick}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
|
||||
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white'
|
||||
}`}
|
||||
className={`text-sm font-semibold ${visualClassName}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user