收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions

View File

@@ -1,13 +1,15 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import {
AnimationState,
type Character,
type CompanionRenderState,
type Encounter,
type GameState,
type EquipmentLoadout,
type GameState,
WorldType,
} from '../types';
import { AdventureEntityModal } from './AdventureEntityModal';
@@ -87,6 +89,66 @@ function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
};
}
function createPlayerCharacter(): Character {
return {
id: 'player-1',
name: '潮刃客',
title: '试剑者',
description: '测试主角',
backstory: '测试背景',
personality: '冷静',
avatar: '',
portrait: '',
assetFolder: '',
assetVariant: '',
attributes: {
strength: 5,
agility: 5,
intelligence: 5,
spirit: 5,
},
skills: [
{
id: 'tide-slash',
name: '潮刃突进',
animation: AnimationState.ATTACK,
damage: 16,
manaCost: 5,
cooldownTurns: 2,
range: 1,
style: 'burst',
buildBuffs: [
{
id: 'wet-mark',
sourceType: 'skill',
sourceId: 'tide-slash',
name: '潮湿',
tags: ['控制', '潮汐'],
durationTurns: 2,
},
],
},
],
adventureOpenings: {},
};
}
function createCompanionRenderState(
character: Character,
): CompanionRenderState {
return {
npcId: 'companion-1',
character,
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
skillCooldowns: {},
animationState: AnimationState.IDLE,
slot: 'upper',
};
}
afterEach(() => {
vi.restoreAllMocks();
});
@@ -169,3 +231,219 @@ 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',
);
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');
});
test('技能详情静态标签复用暗色 PlatformPillBadge chrome', () => {
render(
<AdventureEntityModal
selection={{ kind: 'player' }}
gameState={createGameState({
playerCharacter: createPlayerCharacter(),
})}
onClose={() => undefined}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
const skillPanel = screen
.getByText('技能详情')
.closest('.pixel-modal-shell') as HTMLElement;
const deliveryBadge = within(skillPanel).getAllByText('近战')[0]!;
const styleBadge = within(skillPanel).getAllByText('爆发')[0]!;
const buffSummaryBadge = within(skillPanel).getByText('附带 1 个状态标签');
const buffBadge = within(skillPanel).getByText('潮湿 / 控制、潮汐 / 2 回合');
const damagePanel = within(skillPanel)
.getByText('伤害')
.closest('section') as HTMLElement;
const descriptionPanel = within(skillPanel)
.getByText(/ 线/u)
.closest('section') as HTMLElement;
const buffPanel = within(skillPanel)
.getByText('附带状态标签')
.closest('section') as HTMLElement;
expect(deliveryBadge.className).toContain('bg-white/6');
expect(styleBadge.className).toContain('bg-sky-500/10');
expect(buffSummaryBadge.className).toContain('bg-emerald-500/10');
expect(buffBadge.className).toContain('rounded-full');
expect(buffBadge.className).toContain('bg-sky-500/10');
expect(damagePanel.className).toContain('bg-black/25');
expect(damagePanel.className).toContain('border-white/10');
expect(descriptionPanel.className).toContain('bg-black/25');
expect(buffPanel.className).toContain('bg-black/25');
});

View File

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

View File

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

View File

@@ -2,8 +2,12 @@ import {
AFFINITY_PROGRESS_MARKERS,
AFFINITY_PROGRESS_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>
);
}

View File

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

View File

@@ -1,3 +1,7 @@
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
export type BackstoryUnlockedChapter = {
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>
);

View File

@@ -0,0 +1,112 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
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');
});

View File

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

View File

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

View File

@@ -36,6 +36,8 @@ import {
CharacterAttributeGrid,
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>

View File

@@ -4,10 +4,13 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { BuildContributionRow } from '../data/buildDamage';
import { AnimationState, type Character } from '../types';
import {
BuildContributionDetailPanel,
CharacterIdentityBadges,
CharacterSkillsList,
MultiplierContributionList,
PlayerLevelProgress,
} from './CharacterInfoShared';
@@ -31,6 +34,19 @@ afterEach(() => {
vi.restoreAllMocks();
});
function findNearestClassName(element: HTMLElement, className: string) {
let current: HTMLElement | null = element;
while (current) {
if (current.className.includes(className)) {
return current.className;
}
current = current.parentElement;
}
return '';
}
test('CharacterSkillsList falls back to stable render ids when skill ids are empty', async () => {
const user = userEvent.setup();
const handleSelectSkill = vi.fn();
@@ -49,9 +65,16 @@ test('CharacterSkillsList falls back to stable render ids when skill ids are emp
);
const buttons = screen.getAllByRole('button');
expect(buttons[0]?.className).toContain('bg-black/25');
expect(buttons[0]?.className).toContain('hover:border-sky-300/25');
await user.click(buttons[0]!);
await user.click(buttons[1]!);
const deliveryBadge = screen.getAllByText('近战')[0]!;
expect(deliveryBadge.className).toContain('rounded-full');
expect(deliveryBadge.className).toContain('bg-white/6');
expect(handleSelectSkill).toHaveBeenNthCalledWith(1, 'skill-潮刃突进-0');
expect(handleSelectSkill).toHaveBeenNthCalledWith(2, 'skill-雾行转位-1');
@@ -66,6 +89,27 @@ test('CharacterSkillsList falls back to stable render ids when skill ids are emp
expect(duplicateKeyCalls).toHaveLength(0);
});
test('CharacterSkillsList empty state reuses dark PlatformEmptyState chrome', () => {
render(<CharacterSkillsList skills={[]} emptyText="暂未掌握技能" />);
const emptyState = screen.getByText('暂未掌握技能');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('bg-black/20');
expect(emptyState.className).toContain('border-dashed');
});
test('CharacterSkillsList readonly cards reuse dark PlatformSubpanel chrome', () => {
render(<CharacterSkillsList skills={[createSkill('潮刃突进', 'burst')]} />);
const skillCardClassName = findNearestClassName(
screen.getByText('潮刃突进'),
'bg-black/25',
);
expect(skillCardClassName).toContain('border-white/5');
});
test('CharacterIdentityBadges renders role and level chips together', () => {
render(
<CharacterIdentityBadges
@@ -77,6 +121,76 @@ 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('PlayerLevelProgress renders xp progress details', () => {

View File

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

View File

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

View File

@@ -7,9 +7,7 @@ import {
} from '../data/attributeResolver';
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>

View File

@@ -0,0 +1,123 @@
/* @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\//);
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');
});
test('营地编组同行者卡片和替换位按钮复用暗色公共组件', async () => {
const user = userEvent.setup();
const playerCharacter = getCharacterById('sword-princess');
if (!playerCharacter) {
throw new Error('测试需要剑姬角色预设');
}
render(
<CompanionCampModal
isOpen
playerCharacter={playerCharacter}
companions={[createCompanion()]}
roster={[
createCompanion({
npcId: 'npc-girl',
characterId: 'girl-hero',
joinedAtAffinity: 72,
}),
]}
inBattle={false}
onClose={vi.fn()}
onBenchCompanion={vi.fn()}
onActivateCompanion={vi.fn()}
/>,
);
const activeCard = screen.getByTestId('active-companion-card-npc-archer');
const reserveCard = screen.getByTestId('reserve-companion-card-npc-girl');
const replacementButton = screen.getByRole('button', {
name: '设为替换位',
});
const benchButton = screen.getByRole('button', { name: '转入后备' });
const activateButton = screen.getByRole('button', { name: '编入队伍' });
const hpBadge = within(activeCard).getByText('生命 42/50');
const activePortrait = within(activeCard).getByRole('img');
const reservePortrait = within(reserveCard).getByRole('img');
const activePortraitFrame = activePortrait.closest('.platform-media-frame');
const reservePortraitFrame = reservePortrait.closest('.platform-media-frame');
expect(activeCard.className).toContain('bg-black/25');
expect(reserveCard.className).toContain('bg-black/25');
expect(activePortraitFrame?.className).toContain('border-white/10');
expect(activePortraitFrame?.className).toContain('radial-gradient');
expect(reservePortraitFrame?.className).toContain('platform-media-frame');
expect(activePortrait.className).toContain('scale-125');
expect(replacementButton.className).toContain('platform-dark-option-card');
expect(benchButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(benchButton.className).toContain('bg-white/5');
expect(activateButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(activateButton.className).toContain('bg-emerald-400');
expect(hpBadge.className).toContain('rounded-full');
await user.click(replacementButton);
expect(activeCard.className).toContain('border-sky-400/18');
expect(activeCard.className).toContain('bg-sky-500/8');
expect(replacementButton.className).toContain('border-sky-400/45');
});

View File

@@ -5,8 +5,14 @@ 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 { 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 +32,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 +159,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 +176,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 +220,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 +276,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,39 +305,46 @@ 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">
<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>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -100,26 +100,30 @@ 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.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 +145,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 +193,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 +250,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();

View File

@@ -2,6 +2,8 @@ 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,
GenerationPageBackdrop,
@@ -117,7 +119,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,9 +134,7 @@ 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
@@ -144,9 +145,13 @@ export function CustomWorldGenerationView({
<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">
<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"
>
{isGenerating ? activeBadgeLabel : idleBadgeLabel}
</div>
</PlatformPillBadge>
</div>
<div
@@ -172,21 +177,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>

View File

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

View File

@@ -1,8 +1,8 @@
import { Clock3, Hourglass } from 'lucide-react';
import { motion } from 'motion/react';
import { useEffect, useId, useRef } from 'react';
import generationHeroVideo from '../../media/create_bg_video.mp4';
import { PlatformProgressBar } from './common/PlatformProgressBar';
const GENERATION_PROGRESS_RING_GAP_DEGREES = 90;
const GENERATION_PROGRESS_RING_BOTTOM_DEGREES = 90;
@@ -64,8 +64,7 @@ export function GenerationPageBackdrop() {
video.muted = true;
video.volume = 0;
const isJsdom =
window.navigator.userAgent.toLowerCase().includes('jsdom');
const isJsdom = window.navigator.userAgent.toLowerCase().includes('jsdom');
const tryPlay = () => {
if (isJsdom) {
return;
@@ -285,20 +284,14 @@ export function GenerationCurrentStepCard({
) : null}
</div>
</div>
<div
className="mt-4 h-2.5 overflow-hidden rounded-full bg-[#f5eee8]"
role="progressbar"
aria-label={`${label} 进度`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={safeProgress}
>
<motion.div
className="h-full rounded-full bg-[linear-gradient(90deg,#ef7a1f_0%,#e25f18_64%,#f0b07e_100%)]"
animate={{ width: `${safeProgress}%` }}
transition={{ duration: 0.45, ease: 'easeOut' }}
/>
</div>
<PlatformProgressBar
value={safeProgress}
size="sm"
ariaLabel={`${label} 进度`}
className="mt-4 bg-[#f5eee8]"
fillClassName="bg-[linear-gradient(90deg,#ef7a1f_0%,#e25f18_64%,#f0b07e_100%)]"
fillStyle={{ transitionDuration: '450ms' }}
/>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,6 +98,23 @@ function buildSession(
};
}
function findNearestClassName(
element: HTMLElement,
classNamePart: string,
): HTMLElement | null {
let current: HTMLElement | null = element;
while (current) {
if (current.className.includes(classNamePart)) {
return current;
}
current = current.parentElement;
}
return null;
}
test('settings header uses a generic title instead of the phone number', () => {
renderAccountModal();
@@ -119,6 +136,27 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
const themeSettingsButton = screen.getByRole('button', { name: //u });
expect(themeSettingsButton.getAttribute('type')).toBe('button');
expect(themeSettingsButton.className).toContain('platform-subpanel');
expect(themeSettingsButton.className).toContain('rounded-[1.5rem]');
expect(themeSettingsButton.className).toContain('hover:bg-white');
});
test('appearance panel uses PlatformPillBadge for current theme status', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: //u }));
const appearanceDialog = screen.getByRole('dialog', { name: '主题设置' });
const themeStatusBadge = within(appearanceDialog).getByText('平台设置已同步');
expect(within(appearanceDialog).getByText('当前主题')).toBeTruthy();
expect(themeStatusBadge.className).toContain('rounded-full');
expect(themeStatusBadge.className).toContain('bg-white/72');
});
test('direct account entry does not render the settings shell as another dialog', () => {
@@ -159,6 +197,9 @@ test('account panel uses compact binding cards and keeps logout actions at the b
'[data-account-binding-card]',
);
expect(compactCards).toHaveLength(2);
expect(compactCards[0]?.className).toContain('platform-subpanel');
expect(compactCards[0]?.className).toContain('rounded-[1rem]');
expect(compactCards[0]?.className).toContain('px-3.5 py-3');
expect(
within(compactCards[0] as HTMLElement).getByRole('button', {
name: '更换手机号',
@@ -357,6 +398,18 @@ test('account panel includes merged security devices and audit sections', async
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
const deviceRow = findNearestClassName(
within(accountDialog).getByText('iPhone 15 Pro'),
'bg-white/72',
);
const auditRow = findNearestClassName(
within(accountDialog).getByText('登录成功'),
'bg-white/72',
);
expect(deviceRow?.className).toContain('rounded-[1rem]');
expect(deviceRow?.className).toContain('px-4 py-3');
expect(auditRow?.className).toContain('rounded-[1rem]');
expect(auditRow?.className).toContain('px-4 py-3');
expect(
within(accountDialog).getByRole('button', { name: '退出登录' }),
).toBeTruthy();
@@ -392,7 +445,14 @@ test('current merged session group hides kick action and shows count', async ()
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const sessionCountBadge = within(accountDialog).getByText('2 个会话');
const currentDeviceBadge = within(accountDialog).getByText('当前设备');
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
expect(sessionCountBadge.className).toContain('rounded-full');
expect(sessionCountBadge.className).toContain('bg-white/72');
expect(currentDeviceBadge.className).toContain('rounded-full');
expect(currentDeviceBadge.className).toContain('border-emerald-200');
expect(
within(accountDialog).queryByRole('button', { name: '踢下线' }),
).toBeNull();
@@ -419,8 +479,12 @@ test('remote merged session group can be revoked with loading state', async () =
const revokeButton = within(accountDialog).getByRole('button', {
name: '处理中...',
}) as HTMLButtonElement;
const loggedInBadge = within(accountDialog).getByText('已登录');
expect(revokeButton.disabled).toBe(true);
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
expect(loggedInBadge.className).toContain('rounded-full');
expect(loggedInBadge.className).toContain('border-emerald-200');
expect(onRevokeSession).not.toHaveBeenCalled();
});

View File

@@ -15,6 +15,10 @@ import type {
AuthSessionSummary,
AuthUser,
} from '../../services/authService';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import type { PlatformSettingsSection } from './AuthUiContext';
import { CaptchaChallengeField } from './CaptchaChallengeField';
@@ -130,10 +134,13 @@ function SettingsEntryCard({
onClick: (trigger: HTMLButtonElement) => void;
}) {
return (
<button
type="button"
<PlatformSubpanel
as="button"
interactive
radius="xl"
padding="md"
onClick={(event) => onClick(event.currentTarget)}
className="platform-subpanel w-full rounded-[1.5rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)]"
className="w-full hover:border-[var(--platform-surface-hover-border)]"
>
<div className="flex items-start justify-between gap-3">
<div>
@@ -151,7 +158,7 @@ function SettingsEntryCard({
<div className="mt-3 text-sm text-[var(--platform-text-base)]">
{summary}
</div>
</button>
</PlatformSubpanel>
);
}
@@ -204,23 +211,27 @@ function OverlayPanel({
<div className="flex items-center gap-2">
{action}
{onBack ? (
<button
type="button"
<PlatformActionButton
autoFocus
className="platform-button platform-button--ghost min-h-0 gap-1.5 rounded-full px-3 py-1.5 text-xs"
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 gap-1.5 px-3 py-1.5"
onClick={onBack}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
</PlatformActionButton>
) : (
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5"
onClick={onClose}
>
</button>
</PlatformActionButton>
)}
</div>
</div>
@@ -259,10 +270,13 @@ function ThemeOptionCard({
onClick: () => void;
}) {
return (
<button
type="button"
<PlatformSubpanel
as="button"
interactive
radius="xl"
padding="md"
onClick={onClick}
className={`platform-subpanel w-full rounded-[1.5rem] p-4 text-left transition ${
className={`w-full ${
active
? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(112,57,30,0.14)]'
: 'hover:border-[var(--platform-surface-hover-border)]'
@@ -275,7 +289,7 @@ function ThemeOptionCard({
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{detail}
</div>
</button>
</PlatformSubpanel>
);
}
@@ -494,13 +508,15 @@ export function AccountModal({
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5"
onClick={onClose}
>
</button>
</PlatformActionButton>
</div>
) : null}
@@ -552,7 +568,12 @@ export function AccountModal({
/>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-4 py-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
@@ -562,11 +583,15 @@ export function AccountModal({
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div>
</div>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]">
<PlatformPillBadge
tone="neutral"
size="xs"
className="px-3 py-1"
>
{themeStatusText}
</span>
</PlatformPillBadge>
</div>
</div>
</PlatformSubpanel>
</div>
</OverlayPanel>
) : null}
@@ -580,23 +605,28 @@ export function AccountModal({
>
<div data-account-content className="flex min-h-0 flex-col gap-3">
{accountNotice ? (
<div className="platform-banner platform-banner--success text-sm">
<PlatformStatusMessage tone="success" surface="profile">
{accountNotice}
</div>
</PlatformStatusMessage>
) : null}
<div className="grid gap-2.5 sm:grid-cols-2">
<div
<PlatformSubpanel
as="div"
data-account-binding-card
className="platform-subpanel rounded-2xl px-3.5 py-3"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
@@ -605,47 +635,59 @@ export function AccountModal({
}}
>
</button>
</PlatformActionButton>
</div>
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundPhoneNumber}
</div>
</div>
</PlatformSubpanel>
<div
<PlatformSubpanel
as="div"
data-account-binding-card
className="platform-subpanel rounded-2xl px-3.5 py-3"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
onClick={() => {
setAccountNotice('更换微信号功能暂未接入。');
}}
>
</button>
</PlatformActionButton>
</div>
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundWechatDisplayName}
</div>
</div>
</PlatformSubpanel>
</div>
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<button
type="button"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
onClick={(event) => {
passwordTriggerRef.current = event.currentTarget;
setAccountNotice('');
@@ -654,38 +696,52 @@ export function AccountModal({
}}
>
</button>
</PlatformActionButton>
</div>
</div>
</PlatformSubpanel>
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
</button>
</PlatformActionButton>
</div>
<div className="mt-3 grid gap-2.5">
{loadingRiskBlocks ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
>
...
</div>
</PlatformSubpanel>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<div
<PlatformStatusMessage
key={`${block.scopeType}:${block.expiresAt}`}
className="platform-banner platform-banner--warning text-sm"
tone="warning"
surface="profile"
>
<div className="flex items-center justify-between gap-3">
<span>{block.title}</span>
@@ -701,48 +757,68 @@ export function AccountModal({
<div className="mt-2 text-xs leading-5">
{block.detail}
</div>
<button
type="button"
className="platform-button platform-button--secondary mt-3 min-h-0 h-9 px-3 text-xs"
<PlatformActionButton
tone="secondary"
size="xs"
className="mt-3 h-9 min-h-0 px-3"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
</button>
</div>
</PlatformActionButton>
</PlatformStatusMessage>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
>
</div>
</PlatformSubpanel>
)}
</div>
</div>
</PlatformSubpanel>
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshSessions();
}}
>
</button>
</PlatformActionButton>
</div>
<div className="mt-3 grid gap-2.5">
{loadingSessions ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
>
...
</div>
</PlatformSubpanel>
) : sessions.length > 0 ? (
sessions.map((session) => {
const isRevoking = revokingSessionIds.includes(
@@ -750,21 +826,33 @@ export function AccountModal({
);
return (
<div
<PlatformSubpanel
as="div"
key={session.sessionId}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<div className="flex shrink-0 items-center gap-2">
{session.sessionCount > 1 ? (
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
<PlatformPillBadge
tone="neutral"
size="xs"
className="px-2.5 py-1 text-[10px]"
>
{session.sessionCount}
</span>
</PlatformPillBadge>
) : null}
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
<PlatformPillBadge
tone="success"
size="xs"
className="px-2.5 py-1 text-[10px]"
>
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</PlatformPillBadge>
</div>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
@@ -779,56 +867,80 @@ export function AccountModal({
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 h-9 min-h-0 px-3 text-xs"
<PlatformActionButton
tone="danger"
size="xs"
className="mt-3 h-9 min-h-0 px-3"
disabled={isRevoking}
onClick={() => {
void onRevokeSession(session);
}}
>
{isRevoking ? '处理中...' : '踢下线'}
</button>
</PlatformActionButton>
) : null}
</div>
</PlatformSubpanel>
);
})
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
>
</div>
</PlatformSubpanel>
)}
</div>
</div>
</PlatformSubpanel>
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshAuditLogs();
}}
>
</button>
</PlatformActionButton>
</div>
<div className="mt-3 grid gap-2.5">
{loadingAuditLogs ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
>
...
</div>
</PlatformSubpanel>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
<PlatformSubpanel
as="div"
key={log.id}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{log.title}</span>
@@ -844,38 +956,48 @@ export function AccountModal({
IP{log.ipMasked}
</div>
) : null}
</div>
</PlatformSubpanel>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
>
</div>
</PlatformSubpanel>
)}
</div>
</div>
</PlatformSubpanel>
<div
data-account-actions
className="grid gap-2.5 pt-1 sm:grid-cols-2"
>
<button
type="button"
className="platform-button platform-button--ghost h-10 w-full text-sm"
<PlatformActionButton
tone="ghost"
size="sm"
fullWidth
className="h-10"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-10 w-full text-sm"
</PlatformActionButton>
<PlatformActionButton
tone="danger"
size="sm"
fullWidth
className="h-10"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</PlatformActionButton>
</div>
</div>
@@ -909,12 +1031,13 @@ export function AccountModal({
placeholder="输入验证码"
onChange={(event) => setCode(event.target.value)}
/>
<button
type="button"
<PlatformActionButton
disabled={
sendingCode || cooldownSeconds > 0 || !phone.trim()
}
className="platform-button platform-button--secondary h-11 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
tone="secondary"
size="md"
className="h-11 shrink-0"
onClick={() => {
void (async () => {
setSendingCode(true);
@@ -951,14 +1074,14 @@ export function AccountModal({
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</PlatformActionButton>
</div>
</label>
{changePhoneHint ? (
<div className="platform-banner platform-banner--success text-sm">
<PlatformStatusMessage tone="success" surface="profile">
{changePhoneHint}
</div>
</PlatformStatusMessage>
) : null}
<CaptchaChallengeField
@@ -968,15 +1091,15 @@ export function AccountModal({
/>
{changePhoneError ? (
<div className="platform-banner platform-banner--danger text-sm">
<PlatformStatusMessage tone="error" surface="profile">
{changePhoneError}
</div>
</PlatformStatusMessage>
) : null}
<button
type="button"
<PlatformActionButton
disabled={changingPhone || !phone.trim() || !code.trim()}
className="platform-button platform-button--primary h-11 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-60"
size="md"
className="h-11"
onClick={() => {
void (async () => {
setChangingPhone(true);
@@ -998,7 +1121,7 @@ export function AccountModal({
}}
>
{changingPhone ? '提交中...' : '确认更换手机号'}
</button>
</PlatformActionButton>
</div>
</OverlayPanel>
) : null}
@@ -1038,15 +1161,16 @@ export function AccountModal({
</label>
{passwordError ? (
<div className="platform-banner platform-banner--danger text-sm">
<PlatformStatusMessage tone="error" surface="profile">
{passwordError}
</div>
</PlatformStatusMessage>
) : null}
<button
type="button"
<PlatformActionButton
disabled={changingPassword || !newPassword.trim()}
className="platform-button platform-button--primary h-11 w-full text-sm disabled:cursor-not-allowed disabled:opacity-60"
size="md"
fullWidth
className="h-11"
onClick={() => {
void (async () => {
setChangingPassword(true);
@@ -1068,7 +1192,7 @@ export function AccountModal({
}}
>
{changingPassword ? '提交中...' : '确认修改密码'}
</button>
</PlatformActionButton>
</div>
</OverlayPanel>
) : null}

View File

@@ -54,12 +54,14 @@ vi.mock('../../services/authService', () => ({
getCurrentAuthUser: authMocks.getCurrentAuthUser,
getAuthSessions: authMocks.getAuthSessions,
getCaptchaChallengeFromError: vi.fn(() => null),
isWechatMiniProgramWebViewRuntime: authMocks.isWechatMiniProgramWebViewRuntime,
isWechatMiniProgramWebViewRuntime:
authMocks.isWechatMiniProgramWebViewRuntime,
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser,
requestWechatMiniProgramPhoneLogin: authMocks.requestWechatMiniProgramPhoneLogin,
requestWechatMiniProgramPhoneLogin:
authMocks.requestWechatMiniProgramPhoneLogin,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword,
revokeAuthSessions: authMocks.revokeAuthSessions,
@@ -440,7 +442,9 @@ test('auth gate uses mini program auth bridge instead of opening login modal in
await user.click(await screen.findByRole('button', { name: '进入作品' }));
await waitFor(() => {
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(1);
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(
1,
);
});
expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
@@ -476,9 +480,7 @@ test('login modal requires first-time legal consent before sms login', async ()
await user.click(
within(dialog).getByRole('button', { name: '《用户协议》' }),
);
expect(
await screen.findByRole('dialog', { name: '用户协议' }),
).toBeTruthy();
expect(await screen.findByRole('dialog', { name: '用户协议' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '我知道了' }));
expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
@@ -849,6 +851,14 @@ test('auth gate separates sms and password login by tabs', async () => {
.getByRole('tab', { name: '短信登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(
within(dialog)
.getByRole('tab', { name: '短信登录' })
.className.includes('h-12'),
).toBe(true);
expect(
within(dialog).getByRole('tablist', { name: '登录方式' }).className,
).toContain('bg-transparent');
expect(within(dialog).queryByLabelText('密码')).toBeNull();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
@@ -903,7 +913,9 @@ test('auth gate revokes merged session group and refreshes sessions', async () =
const accountDialog = await screen.findByRole('dialog', {
name: '账号信息',
});
await user.click(within(accountDialog).getByRole('button', { name: '踢下线' }));
await user.click(
within(accountDialog).getByRole('button', { name: '踢下线' }),
);
await waitFor(() => {
expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([
@@ -945,7 +957,10 @@ test('auth gate clears account state after password change', async () => {
const passwordDialog = await screen.findByRole('dialog', {
name: '修改登录密码',
});
await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1');
await user.type(
within(passwordDialog).getByLabelText('当前密码'),
'oldpass1',
);
await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1');
await user.click(
within(passwordDialog).getByRole('button', { name: '确认修改密码' }),

View File

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

View File

@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type BindPhoneScreenProps = {
@@ -108,10 +110,11 @@ export function BindPhoneScreen({
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
<PlatformActionButton
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => {
void (async () => {
try {
@@ -135,14 +138,14 @@ export function BindPhoneScreen({
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</PlatformActionButton>
</div>
</label>
{hint ? (
<div className="platform-banner platform-banner--success text-sm">
<PlatformStatusMessage tone="success" surface="profile">
{hint}
</div>
</PlatformStatusMessage>
) : null}
<CaptchaChallengeField
@@ -152,28 +155,29 @@ export function BindPhoneScreen({
/>
{error ? (
<div className="platform-banner platform-banner--danger text-sm">
<PlatformStatusMessage tone="error" surface="profile">
{error}
</div>
</PlatformStatusMessage>
) : null}
<button
<PlatformActionButton
type="submit"
disabled={binding || !phone.trim() || !code.trim()}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
size="lg"
>
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
</button>
</PlatformActionButton>
<button
type="button"
className="platform-button platform-button--ghost h-11 px-4 text-sm"
<PlatformActionButton
tone="ghost"
size="md"
className="h-11"
onClick={() => {
void onLogout();
}}
>
</button>
</PlatformActionButton>
</form>
</div>
</div>

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Check, X } from 'lucide-react';
import { Check } from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
@@ -7,9 +7,7 @@ import type {
AuthLoginMethod,
} from '../../services/authService';
import { getStoredLastLoginPhone } from '../../services/authService';
import {
isWechatMiniProgramWebViewRuntime,
} from '../../services/authService';
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
@@ -17,11 +15,21 @@ import {
persistLegalConsent,
readStoredLegalConsent,
} from '../common/legalDocuments';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password';
const LOGIN_TAB_ITEMS: Array<{ id: LoginTab; label: string }> = [
{ id: 'phone', label: '短信登录' },
{ id: 'password', label: '密码登录' },
];
type LoginScreenProps = {
isOpen: boolean;
platformTheme: PlatformTheme;
@@ -199,14 +207,12 @@ export function LoginScreen({
>
{isResetPanelOpen ? '重置密码' : '账号入口'}
</div>
<button
type="button"
<PlatformModalCloseButton
onClick={onClose}
className="platform-icon-button p-2"
aria-label="关闭登录弹窗"
>
<X className="h-4 w-4" />
</button>
label="关闭登录弹窗"
variant="platformIcon"
className="p-2"
/>
</div>
{isResetPanelOpen ? (
@@ -233,147 +239,146 @@ export function LoginScreen({
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled ? (
<div
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
}`}
role="tablist"
aria-label="登录方式"
>
<LoginTabButton
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
>
</LoginTabButton>
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
) : null}
</div>
<PlatformSegmentedTabs
items={
passwordLoginEnabled
? LOGIN_TAB_ITEMS
: LOGIN_TAB_ITEMS.slice(0, 1)
}
activeId={activeLoginTab}
onChange={setActiveLoginTab}
columns={passwordLoginEnabled ? 'two' : 'one'}
frame="bare"
surface="transparent"
tone="underline"
size="tab"
semantics="tabs"
ariaLabel="登录方式"
/>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
) {
return;
}
void onPasswordSubmit(phone, password);
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
{error ? <ErrorBanner message={error} /> : null}
{legalConsentRow}
<div className="flex flex-col gap-2">
<button
type="submit"
disabled={
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
) {
return;
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '登录中' : '登录'}
</button>
<button
type="button"
className="self-end text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
</div>
void onPasswordSubmit(phone, password);
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
{wechatLoginEnabled && !miniProgramRuntime ? (
<WechatButton
loading={wechatLoading}
disabled={submitDisabled}
onClick={onStartWechatLogin}
/>
) : null}
</form>
) : null}
{error ? <ErrorBanner message={error} /> : null}
{legalConsentRow}
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
<PhoneCodeForm
phone={phone}
code={code}
captchaAnswer={captchaAnswer}
captchaChallenge={captchaChallenge}
cooldownSeconds={cooldownSeconds}
sendingCode={sendingCode}
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="登录"
enabled={phoneLoginEnabled}
legalConsentChecked={legalConsentChecked}
legalConsentNode={legalConsentRow}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
onSendCode={async () => {
setHint('');
const result = await onSendCode(phone, 'login', {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
}}
onSubmit={() => onPhoneSubmit(phone, code)}
/>
) : null}
<div className="flex flex-col gap-2">
<PlatformActionButton
type="submit"
disabled={
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
}
size="lg"
>
{loggingIn ? '登录中' : '登录'}
</PlatformActionButton>
<button
type="button"
className="self-end text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
</div>
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled &&
!miniProgramRuntime ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
</div>
) : null}
{wechatLoginEnabled && !miniProgramRuntime ? (
<WechatButton
loading={wechatLoading}
disabled={submitDisabled}
onClick={onStartWechatLogin}
/>
) : null}
</form>
) : null}
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
<PhoneCodeForm
phone={phone}
code={code}
captchaAnswer={captchaAnswer}
captchaChallenge={captchaChallenge}
cooldownSeconds={cooldownSeconds}
sendingCode={sendingCode}
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="登录"
enabled={phoneLoginEnabled}
legalConsentChecked={legalConsentChecked}
legalConsentNode={legalConsentRow}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
onSendCode={async () => {
setHint('');
const result = await onSendCode(phone, 'login', {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
}}
onSubmit={() => onPhoneSubmit(phone, code)}
/>
) : null}
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled &&
!miniProgramRuntime ? (
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-4 py-4 text-sm text-[var(--platform-text-base)]"
>
</PlatformSubpanel>
) : null}
</div>
)}
</div>
@@ -439,13 +444,7 @@ function LegalConsentRow({
);
}
function LegalLink({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
function LegalLink({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
@@ -457,35 +456,6 @@ function LegalLink({
);
}
function LoginTabButton({
active,
children,
onClick,
}: {
active: boolean;
children: string;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
active
? 'text-[var(--platform-text-strong)]'
: 'text-[var(--platform-text-muted)]'
}`}
onClick={onClick}
>
<span>{children}</span>
{active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</button>
);
}
function PhoneCodeForm({
phone,
code,
@@ -569,10 +539,11 @@ function PhoneCodeForm({
onChange={(event) => onCodeChange(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
<PlatformActionButton
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()}
>
{sendingCode
@@ -580,7 +551,7 @@ function PhoneCodeForm({
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</PlatformActionButton>
</div>
</label>
@@ -594,13 +565,9 @@ function PhoneCodeForm({
{error ? <ErrorBanner message={error} /> : null}
{legalConsentNode}
<button
type="submit"
disabled={submitBlocked}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
<PlatformActionButton type="submit" disabled={submitBlocked} size="lg">
{loggingIn ? '处理中' : submitLabel}
</button>
</PlatformActionButton>
</form>
);
}
@@ -663,10 +630,11 @@ function PasswordResetPanel({
onChange={(event) => onCodeChange(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
<PlatformActionButton
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()}
>
{sendingCode
@@ -674,7 +642,7 @@ function PasswordResetPanel({
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</PlatformActionButton>
</div>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
@@ -692,22 +660,18 @@ function PasswordResetPanel({
{error ? <ErrorBanner message={error} /> : null}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="platform-button platform-button--secondary h-12 px-4 text-base"
onClick={onBack}
>
<PlatformActionButton tone="secondary" size="lg" onClick={onBack}>
</button>
<button
</PlatformActionButton>
<PlatformActionButton
type="submit"
disabled={
loggingIn || !phone.trim() || !code.trim() || !password.trim()
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
size="lg"
>
{loggingIn ? '处理中' : '重置密码'}
</button>
</PlatformActionButton>
</div>
</form>
);
@@ -723,29 +687,29 @@ function WechatButton({
onClick: () => Promise<void>;
}) {
return (
<button
type="button"
<PlatformActionButton
disabled={loading || disabled}
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
tone="secondary"
size="lg"
onClick={() => void onClick()}
>
{loading ? '跳转中' : '微信登录'}
</button>
</PlatformActionButton>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="platform-banner platform-banner--danger text-sm">
<PlatformStatusMessage tone="error" surface="profile">
{message}
</div>
</PlatformStatusMessage>
);
}
function SuccessBanner({ message }: { message: string }) {
return (
<div className="platform-banner platform-banner--success text-sm">
<PlatformStatusMessage tone="success" surface="profile">
{message}
</div>
</PlatformStatusMessage>
);
}

View File

@@ -1,7 +1,9 @@
import { X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
type RegistrationInviteModalProps = {
isOpen: boolean;
@@ -63,14 +65,12 @@ export function RegistrationInviteModal({
>
</div>
<button
type="button"
<PlatformModalCloseButton
onClick={onClose}
className="platform-icon-button p-2"
aria-label="取消填写邀请码"
>
<X className="h-4 w-4" />
</button>
label="取消填写邀请码"
variant="platformIcon"
className="p-2"
/>
</div>
<form
className="flex flex-col gap-4 px-5 py-5"
@@ -96,18 +96,18 @@ export function RegistrationInviteModal({
</label>
{error ? (
<div className="platform-banner platform-banner--danger text-sm">
<PlatformStatusMessage tone="error" surface="profile">
{error}
</div>
</PlatformStatusMessage>
) : null}
<button
<PlatformActionButton
type="submit"
disabled={submitting}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
size="lg"
>
{submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
</button>
</PlatformActionButton>
</form>
</div>
</div>

View File

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

View File

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

View File

@@ -84,31 +84,35 @@ describe('BarkBattleGeneratingView', () => {
'video[data-testid="generation-page-background-video"] source[type="video/mp4"]',
),
).toBeTruthy();
expect(screen.getByRole('button', { name: '返回编辑' }).className).toContain(
'text-xs',
);
expect(
screen.getByRole('button', { name: '返回编辑' }).className,
).toContain('text-xs');
expect(screen.getByText('生成中').className).toContain('text-[11px]');
expect(screen.getByText('生成中').className).toContain(
'border-[var(--platform-warm-border)]',
);
expect(screen.getByText('生成中').className).toContain(
'bg-[var(--platform-warm-bg)]',
);
expect(screen.getByText('当前步骤')).toBeTruthy();
expect(screen.getByText('当前步骤').className).toContain('text-[10px]');
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-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,
screen.getByTestId('generation-hero-elapsed-card').className,
).toContain('bg-white/58');
expect(
screen.getByTestId('generation-hero-wait-card').parentElement?.className,
).toContain('mt-3');
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
screen.getByTestId('generation-hero-wait-card').parentElement?.className,
).toContain('px-0');
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
@@ -122,33 +126,30 @@ describe('BarkBattleGeneratingView', () => {
expect(screen.getByText('1 秒')).toBeTruthy();
expect(screen.queryByText('预计还需 3 分钟')).toBeNull();
expect(screen.queryByText('已耗时 1 秒')).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('玩家形象')).toBeTruthy();
expect(screen.getByText('进行中 36%')).toBeTruthy();
expect(screen.getByText('进行中 36%').className).toContain('text-[11px]');
expect(screen.getByText('总进度').className).toContain('text-[9px]');
expect(screen.getByText('0%').className).toContain('text-[1.15rem]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
screen.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
screen.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('max-w-full');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
screen.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('aspect-square');
expect(
@@ -184,9 +185,9 @@ describe('BarkBattleGeneratingView', () => {
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')
@@ -218,8 +219,8 @@ describe('BarkBattleGeneratingView', () => {
.getAttribute('stroke-dasharray'),
).toMatch(/^0\.00 1043\.\d{2}$/u);
expect(
screen.getByRole('progressbar', { name: '玩家形象 进度' }),
).toBeTruthy();
screen.getByRole('progressbar', { name: '玩家形象 进度' }).className,
).toContain('platform-progress-track');
expect(
screen
.getByRole('progressbar', { name: '玩家形象 进度' })

View File

@@ -12,6 +12,7 @@ import {
generateAllBarkBattleImageAssets,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import {
GenerationCurrentStepCard,
GenerationPageBackdrop,
@@ -191,7 +192,11 @@ export function BarkBattleGeneratingView({
(hasSlotAsset(previewDraft, currentStep.slot) ? 'ready' : 'generating'))
: 'generating';
const currentStepProgress =
currentStepStatus === 'ready' ? 100 : currentStepStatus === 'failed' ? 100 : 36;
currentStepStatus === 'ready'
? 100
: currentStepStatus === 'failed'
? 100
: 36;
const currentStepLabel = currentStep?.label ?? '竞技素材';
const currentStepStatusLabel = getSlotStatusLabel(currentStepStatus);
@@ -336,7 +341,10 @@ export function BarkBattleGeneratingView({
onComplete(draft, true);
})
.finally(() => {
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) {
if (
activeBarkBattleGenerationTasks.get(startedDraftKey) ===
generationTask
) {
activeBarkBattleGenerationTasks.delete(startedDraftKey);
}
});
@@ -344,7 +352,9 @@ export function BarkBattleGeneratingView({
return () => {
cancelled = true;
// 中文注释:离开生成页后不再全局复用同一 Promise避免悬挂生成任务导致再次进入时一直转圈。
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) {
if (
activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask
) {
activeBarkBattleGenerationTasks.delete(startedDraftKey);
}
if (startedDraftIdRef.current === startedDraftKey) {
@@ -366,9 +376,13 @@ export function BarkBattleGeneratingView({
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
<span className="break-keep"></span>
</button>
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
<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"
>
</span>
</PlatformPillBadge>
</div>
<div

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,23 @@ vi.mock('../ResolvedAssetImage', () => ({
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
function findNearestClassName(
element: HTMLElement,
classNamePart: string,
): HTMLElement | null {
let current: HTMLElement | null = element;
while (current) {
if (current.className.includes(classNamePart)) {
return current;
}
current = current.parentElement;
}
return null;
}
function createSession(): BigFishSessionSnapshotResponse {
return {
sessionId: 'big-fish-session-1',
@@ -151,8 +168,65 @@ describe('BigFishResultView', () => {
);
expect(screen.getByText('主图 已生成')).toBeTruthy();
expect(screen.getByAltText('荧潮幼体')).toBeTruthy();
expect(screen.getByAltText('深海谜境 场地背景')).toBeTruthy();
const levelImage = screen.getByAltText('荧潮幼体');
expect(levelImage).toBeTruthy();
const levelFrame = findNearestClassName(levelImage, 'relative');
expect(levelFrame?.className).toContain('aspect-square');
expect(levelFrame?.className).toContain('radial-gradient');
expect(levelFrame?.className).toContain('linear-gradient');
expect(levelFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
const backgroundImage = screen.getByAltText('深海谜境 场地背景');
expect(backgroundImage).toBeTruthy();
const backgroundFrame = findNearestClassName(backgroundImage, 'relative');
expect(backgroundFrame?.className).toContain('aspect-[9/16]');
expect(backgroundFrame?.className).toContain('radial-gradient');
expect(backgroundFrame?.className).toContain('linear-gradient');
expect(backgroundFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
expect(
findNearestClassName(screen.getByText('荧潮幼体'), 'platform-subpanel')
?.className,
).toContain('rounded-[1.5rem]');
expect(
findNearestClassName(screen.getByText('场地背景'), 'platform-subpanel')
?.className,
).toContain('rounded-[1.5rem]');
expect(
findNearestClassName(screen.getByText('发布校验'), 'platform-subpanel')
?.className,
).toContain('rounded-[1.5rem]');
});
test('uses platform pill badge for ready publish status', () => {
render(
<BigFishResultView
session={{
...createSession(),
publishReady: true,
assetCoverage: {
levelMainImageReadyCount: 1,
levelMotionReadyCount: 2,
backgroundReady: true,
requiredLevelCount: 1,
publishReady: true,
blockers: [],
},
}}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
const readyBadge = screen.getByText('已达到发布条件');
expect(readyBadge.tagName).toBe('SPAN');
expect(readyBadge.className).toContain('rounded-full');
expect(readyBadge.className).toContain('border-emerald-200');
expect(readyBadge.className).toContain('bg-emerald-50');
});
test('uses level descriptions as default prompt content in asset studio', () => {
@@ -166,8 +240,21 @@ describe('BigFishResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '主图' }));
expect(screen.getByText('PROMPT').className).toContain('tracking-[0.18em]');
const studioPreviewFrame = findNearestClassName(
screen.getByAltText('Lv.1 主图工坊'),
'relative',
);
expect(studioPreviewFrame?.className).toContain('aspect-[9/5]');
expect(studioPreviewFrame?.className).toContain('border-dashed');
expect(studioPreviewFrame?.className).toContain('bg-cyan-50/40');
expect(studioPreviewFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
expect(
screen.getByText('带有浅青色荧光纹路的小型鱼苗,轮廓圆润,呈现弱小但灵动的开局形象。'),
screen.getByText(
'带有浅青色荧光纹路的小型鱼苗,轮廓圆润,呈现弱小但灵动的开局形象。',
),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
@@ -183,6 +270,33 @@ describe('BigFishResultView', () => {
).toBeTruthy();
});
test('uses PlatformActionButton chrome for white surface asset actions', () => {
render(
<BigFishResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
for (const actionName of ['主图', '待机', '移动', '生成背景']) {
const action = screen.getByRole('button', { name: actionName });
expect(action.className).toContain('platform-button');
expect(action.className).toContain('rounded-full');
}
fireEvent.click(screen.getByRole('button', { name: '主图' }));
for (const actionName of ['关闭', '生成并应用正式图']) {
const action = screen.getByRole('button', { name: actionName });
expect(action.className).toContain('platform-button');
expect(action.className).toContain('rounded-full');
}
});
test('shows publish failures in a dismissible modal', () => {
const onDismissError = vi.fn();
@@ -202,6 +316,13 @@ describe('BigFishResultView', () => {
expect(
screen.getByText('big_fish 发布校验未通过:还缺少 16 个基础动作'),
).toBeTruthy();
const iconBadge = screen.getByLabelText('发布失败提示');
expect(iconBadge.className).toContain(
'bg-[var(--platform-button-danger-fill)]',
);
expect(iconBadge.className).toContain(
'text-[var(--platform-button-danger-text)]',
);
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
expect(onDismissError).toHaveBeenCalledTimes(1);
@@ -234,6 +355,11 @@ describe('BigFishResultView', () => {
const publishedButton = screen.getByRole('button', { name: '已发布' });
expect((publishedButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getAllByText('已发布').length).toBeGreaterThan(0);
const publishedBadge = screen
.getAllByText('已发布')
.find((element) => element.tagName === 'SPAN');
expect(publishedBadge?.className).toContain('border-emerald-200');
expect(publishedBadge?.className).toContain('bg-emerald-50');
fireEvent.click(publishedButton);
expect(onExecuteAction).not.toHaveBeenCalled();
});

View File

@@ -16,8 +16,13 @@ import type {
BigFishSessionSnapshotResponse,
ExecuteBigFishActionRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { UnifiedModal } from '../common/UnifiedModal';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog';
type BigFishAssetStudioTarget =
| {
@@ -94,12 +99,7 @@ function buildStudioAssetPreview(
);
}
return buildLevelAssetPreview(
findAssetSlot(
slots,
'level_motion',
target.level.level,
target.motionKey,
),
findAssetSlot(slots, 'level_motion', target.level.level, target.motionKey),
);
}
@@ -168,44 +168,42 @@ function BigFishAssetStudioModal({
</div>
</div>
<div className="space-y-4 px-4 py-4">
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
PROMPT
</div>
<PlatformSubpanel as="div" surface="flat">
<PlatformFieldLabel variant="section">PROMPT</PlatformFieldLabel>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-strong)]">
{prompt}
</div>
</div>
<div className="flex aspect-[9/5] items-center justify-center overflow-hidden rounded-[1.4rem] border border-dashed border-cyan-300/50 bg-cyan-50/40 text-sm text-[var(--platform-text-base)]">
{previewUrl ? (
<ResolvedAssetImage
src={previewUrl}
alt={title}
className="h-full w-full object-cover"
/>
) : (
'AI 资产候选预览'
)}
</div>
</PlatformSubpanel>
<PlatformMediaFrame
src={previewUrl}
alt={title}
fallbackLabel="AI 资产候选预览"
aspect="wide"
surface="none"
className="rounded-[1.4rem] border border-dashed border-cyan-300/50 bg-cyan-50/40"
fallbackClassName="tracking-normal text-[var(--platform-text-base)]"
/>
</div>
<div className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-4">
<button
type="button"
<PlatformActionButton
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-[var(--platform-subpanel-border)] px-4 py-2 text-sm font-semibold text-[var(--platform-text-base)] disabled:opacity-45"
tone="ghost"
shape="pill"
size="xs"
>
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
onClick={execute}
disabled={isBusy}
className="inline-flex items-center gap-2 rounded-full bg-cyan-600 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
shape="pill"
size="xs"
className="gap-2"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
</button>
</PlatformActionButton>
</div>
</div>
</div>
@@ -223,11 +221,7 @@ function BigFishLevelCard({
isBusy: boolean;
onOpenStudio: (target: BigFishAssetStudioTarget) => void;
}) {
const mainImageSlot = findAssetSlot(
slots,
'level_main_image',
level.level,
);
const mainImageSlot = findAssetSlot(slots, 'level_main_image', level.level);
const idleSlot = findAssetSlot(
slots,
'level_motion',
@@ -243,19 +237,23 @@ function BigFishLevelCard({
const previewUrl = buildLevelAssetPreview(mainImageSlot);
return (
<article className="overflow-hidden rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-white/78">
<PlatformSubpanel
as="article"
surface="flat"
radius="xl"
padding="none"
className="overflow-hidden bg-white/78"
>
<div className="flex gap-3 p-3">
<div className="flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-[1.15rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.28),transparent_68%),linear-gradient(145deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))] text-white">
{previewUrl ? (
<ResolvedAssetImage
src={previewUrl}
alt={level.name}
className="h-full w-full object-cover"
/>
) : (
<Waves className="h-8 w-8 text-cyan-100/72" />
)}
</div>
<PlatformMediaFrame
src={previewUrl}
alt={level.name}
fallbackLabel="关卡主图"
fallbackContent={<Waves className="h-8 w-8 text-cyan-100/72" />}
aspect="square"
surface="none"
className="h-24 w-24 shrink-0 rounded-[1.15rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.28),transparent_68%),linear-gradient(145deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))] text-white"
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div>
@@ -267,9 +265,13 @@ function BigFishLevelCard({
</div>
</div>
{level.isFinalLevel ? (
<span className="rounded-full bg-amber-100 px-2 py-1 text-xs font-bold text-amber-700">
<PlatformPillBadge
tone="warning"
size="xs"
className="px-2 py-1 text-xs font-bold"
>
</span>
</PlatformPillBadge>
) : null}
</div>
<div className="mt-2 line-clamp-2 text-sm leading-5 text-[var(--platform-text-base)]">
@@ -280,24 +282,25 @@ function BigFishLevelCard({
<span> {level.threatWindow.join('/') || '-'}</span>
<span> {assetReadyLabel(mainImageSlot)}</span>
<span>
{[assetReadyLabel(idleSlot), assetReadyLabel(moveSlot)].join('/')}
{' '}
{[assetReadyLabel(idleSlot), assetReadyLabel(moveSlot)].join('/')}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 border-t border-[var(--platform-subpanel-border)] p-3">
<button
type="button"
<PlatformActionButton
disabled={isBusy}
onClick={() => {
onOpenStudio({ kind: 'level_main_image', level });
}}
className="rounded-full bg-cyan-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-45"
shape="pill"
size="xs"
className="px-3"
>
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
disabled={isBusy}
onClick={() => {
onOpenStudio({
@@ -306,12 +309,14 @@ function BigFishLevelCard({
motionKey: 'idle_float',
});
}}
className="rounded-full border border-[var(--platform-subpanel-border)] px-3 py-2 text-xs font-bold text-[var(--platform-text-base)] disabled:opacity-45"
tone="ghost"
shape="pill"
size="xs"
className="px-3"
>
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
disabled={isBusy}
onClick={() => {
onOpenStudio({
@@ -320,12 +325,15 @@ function BigFishLevelCard({
motionKey: 'move_swim',
});
}}
className="rounded-full border border-[var(--platform-subpanel-border)] px-3 py-2 text-xs font-bold text-[var(--platform-text-base)] disabled:opacity-45"
tone="ghost"
shape="pill"
size="xs"
className="px-3"
>
</button>
</PlatformActionButton>
</div>
</article>
</PlatformSubpanel>
);
}
@@ -366,9 +374,14 @@ export function BigFishResultView({
if (!draft) {
return (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-5 py-4 text-sm text-[var(--platform-text-base)]"
>
稿
</div>
</PlatformSubpanel>
</div>
);
}
@@ -456,7 +469,12 @@ export function BigFishResultView({
</div>
<aside className="min-h-0 space-y-3 overflow-y-auto">
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<PlatformSubpanel
as="section"
surface="flat"
radius="xl"
className="bg-[var(--platform-subpanel-fill)]"
>
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
@@ -468,29 +486,36 @@ export function BigFishResultView({
</div>
<ImagePlus className="h-5 w-5 text-cyan-600" />
</div>
<div className="mt-3 aspect-[9/16] overflow-hidden rounded-[1.2rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.2),transparent_62%),linear-gradient(180deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))]">
{backgroundPreviewUrl ? (
<ResolvedAssetImage
src={backgroundPreviewUrl}
alt={`${draft.background.theme} 场地背景`}
className="h-full w-full object-cover"
/>
) : null}
</div>
<button
type="button"
<PlatformMediaFrame
src={backgroundPreviewUrl}
alt={`${draft.background.theme} 场地背景`}
fallbackLabel="场地背景"
fallbackContent={<span className="sr-only"></span>}
aspect="portrait"
surface="none"
className="mt-3 rounded-[1.2rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.2),transparent_62%),linear-gradient(180deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))]"
/>
<PlatformActionButton
disabled={isBusy}
onClick={() => {
setStudioTarget({ kind: 'stage_background' });
}}
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-full bg-cyan-600 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
shape="pill"
size="xs"
fullWidth
className="mt-3 gap-2"
>
<Sparkles className="h-4 w-4" />
</button>
</div>
</PlatformActionButton>
</PlatformSubpanel>
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<PlatformSubpanel
as="section"
surface="flat"
radius="xl"
className="bg-[var(--platform-subpanel-fill)]"
>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
@@ -504,12 +529,15 @@ export function BigFishResultView({
{session.assetCoverage.requiredLevelCount * 2}
</div>
<div>
{session.assetCoverage.backgroundReady ? '已完成' : '待生成'}
{' '}
{session.assetCoverage.backgroundReady ? '已完成' : '待生成'}
</div>
</div>
{isPublished ? (
<div className="mt-3 text-sm font-semibold text-emerald-600">
<div className="mt-3">
<PlatformPillBadge tone="success" size="sm">
</PlatformPillBadge>
</div>
) : blockers.length > 0 ? (
<div className="mt-3 space-y-1 text-xs leading-5 text-amber-700">
@@ -518,11 +546,13 @@ export function BigFishResultView({
))}
</div>
) : (
<div className="mt-3 text-sm font-semibold text-emerald-600">
<div className="mt-3">
<PlatformPillBadge tone="success" size="sm">
</PlatformPillBadge>
</div>
)}
</div>
</PlatformSubpanel>
</aside>
</div>
@@ -562,37 +592,32 @@ function BigFishResultErrorModal({
onClose: () => void;
}) {
return (
<UnifiedModal
<UnifiedConfirmDialog
open
title="发布失败"
onClose={onClose}
closeOnBackdrop={false}
showCloseButton={false}
confirmLabel="知道了"
confirmClassName="w-full justify-center border-slate-950 bg-slate-950 text-white"
size="sm"
zIndexClassName="z-[160]"
overlayClassName="bg-slate-950/58"
panelClassName="border-red-100/80 bg-white text-slate-950 shadow-2xl"
bodyClassName="p-5"
footer={(
<button
type="button"
onClick={onClose}
className="inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
>
</button>
)}
footerClassName="border-t-0 px-5 pb-5 pt-0"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
<Waves className="h-4 w-4" />
</div>
<PlatformIconBadge
icon={<Waves className="h-4 w-4" />}
label="发布失败提示"
tone="danger"
className="mt-0.5"
/>
<div className="min-w-0 flex-1 text-sm leading-6 text-slate-600">
{message}
</div>
</div>
</UnifiedModal>
</UnifiedConfirmDialog>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,167 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformIconButton } from './PlatformIconButton';
test('renders platform icon button with accessible label and icon chrome', () => {
render(
<PlatformIconButton
label="关闭"
icon={<span aria-hidden="true">×</span>}
/>,
);
const button = screen.getByRole('button', { name: '关闭' });
expect(button.className).toContain('platform-icon-button');
expect(button.getAttribute('type')).toBe('button');
expect(button.textContent).toBe('×');
});
test('keeps local class names and explicit title', () => {
render(
<PlatformIconButton
label="发送"
title="发送"
icon={<span aria-hidden="true"></span>}
className="h-11 w-11"
/>,
);
const button = screen.getByRole('button', { name: '发送' });
expect(button.className).toContain('h-11');
expect(button.getAttribute('title')).toBe('发送');
});
test('supports floating surface icon action chrome', () => {
render(
<PlatformIconButton
label="更换图片"
title="更换图片"
variant="surfaceFloating"
icon={<span aria-hidden="true">+</span>}
className="h-10 w-10"
/>,
);
const button = screen.getByRole('button', { name: '更换图片' });
expect(button.className).toContain('bg-white/94');
expect(button.className).toContain('backdrop-blur');
expect(button.className).toContain('h-10');
expect(button.className).not.toContain('platform-icon-button');
});
test('supports visible short label on floating surface actions', () => {
render(
<PlatformIconButton
label="选择历史图片"
title="选择历史图片"
variant="surfaceFloating"
icon={<span aria-hidden="true"></span>}
className="gap-1.5 px-3"
>
<span></span>
</PlatformIconButton>,
);
const button = screen.getByRole('button', { name: '选择历史图片' });
expect(button.textContent).toContain('历史');
expect(button.className).toContain('bg-white/94');
expect(button.className).toContain('gap-1.5');
expect(button.className).toContain('px-3');
});
test('supports dark mini icon action chrome', () => {
render(
<PlatformIconButton
label="字段说明"
variant="darkMini"
icon={<span aria-hidden="true">?</span>}
className="h-4 w-4 text-[10px]"
/>,
);
const button = screen.getByRole('button', { name: '字段说明' });
expect(button.className).toContain('bg-black/55');
expect(button.className).toContain('border-white/16');
expect(button.className).toContain('hover:bg-black/70');
expect(button.className).toContain('h-4');
expect(button.textContent).toBe('?');
});
test('supports label child chrome for icon upload controls', () => {
const { container } = render(
<>
<PlatformIconButton
asChild="label"
htmlFor="reference-image"
label="上传参考图"
title="上传参考图"
icon={<span aria-hidden="true" />}
className="h-9 w-9 cursor-pointer"
/>
<input id="reference-image" type="file" />
</>,
);
const input = screen.getByLabelText('上传参考图');
const label = container.querySelector('label[for="reference-image"]');
expect(input.getAttribute('type')).toBe('file');
expect(label?.tagName).toBe('LABEL');
expect(label?.getAttribute('for')).toBe('reference-image');
expect(label?.className).toContain('platform-icon-button');
expect(label?.className).toContain('cursor-pointer');
expect(label?.getAttribute('title')).toBe('上传参考图');
});
test('supports floating surface label upload controls', () => {
const { container } = render(
<PlatformIconButton
asChild="label"
label="上传参考图"
variant="surfaceFloating"
title="上传参考图"
icon={
<>
<span aria-hidden="true">+</span>
<input type="file" />
</>
}
className="h-8 w-8 cursor-pointer"
/>,
);
const label = container.querySelector('label');
const input = label?.querySelector('input');
expect(label?.textContent).toContain('上传参考图');
expect(input?.getAttribute('type')).toBe('file');
expect(label?.className).toContain('bg-white/94');
expect(label?.className).toContain('cursor-pointer');
});
test('keeps nested file input associated with the label name', () => {
render(
<PlatformIconButton
asChild="label"
label="上传参考图"
icon={
<>
<span aria-hidden="true" />
<input type="file" />
</>
}
/>,
);
const input = screen.getByLabelText('上传参考图', { selector: 'input' });
expect(input.getAttribute('type')).toBe('file');
});

View File

@@ -0,0 +1,89 @@
import type {
ButtonHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
} from 'react';
type PlatformIconButtonBaseProps = {
label: string;
icon: ReactNode;
children?: ReactNode;
variant?: 'platformIcon' | 'surfaceFloating' | 'darkMini';
};
type PlatformIconButtonButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'aria-label' | 'children'
> &
PlatformIconButtonBaseProps & {
asChild?: false;
};
type PlatformIconButtonLabelProps = Omit<
LabelHTMLAttributes<HTMLLabelElement>,
'aria-label' | 'children'
> &
PlatformIconButtonBaseProps & {
asChild: 'label';
};
type PlatformIconButtonProps =
| PlatformIconButtonButtonProps
| PlatformIconButtonLabelProps;
/**
* 平台通用图标动作按钮。
* 统一承接纯图标动作、图标上传 label 和带短标签的浮动图标动作。
*/
export function PlatformIconButton({
label,
icon,
children,
variant = 'platformIcon',
title,
className,
asChild,
...actionProps
}: PlatformIconButtonProps) {
const variantClassName = {
platformIcon: 'platform-icon-button',
surfaceFloating:
'inline-flex items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55',
darkMini:
'inline-flex items-center justify-center rounded-full border border-white/16 bg-black/55 text-white transition-colors hover:bg-black/70 disabled:cursor-not-allowed disabled:opacity-55',
}[variant];
const actionClassName = [variantClassName, className]
.filter(Boolean)
.join(' ');
if (asChild === 'label') {
return (
<label
{...(actionProps as LabelHTMLAttributes<HTMLLabelElement>)}
title={title}
className={actionClassName}
>
<span className="sr-only">{label}</span>
{icon}
{children}
</label>
);
}
const { type = 'button', ...buttonProps } =
actionProps as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<button
{...buttonProps}
type={type}
aria-label={label}
title={title}
className={actionClassName}
>
{icon}
{children}
</button>
);
}

View File

@@ -0,0 +1,78 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformInfoBlock } from './PlatformInfoBlock';
test('renders platform info block label and value chrome', () => {
render(<PlatformInfoBlock label="来源">稿</PlatformInfoBlock>);
const label = screen.getByText('来源');
const value = screen.getByText('拼图草稿');
const block = label.closest('div')?.parentElement;
expect(block?.className).toContain('bg-white/72');
expect(block?.className).toContain('rounded-[1rem]');
expect(value.className).toContain('font-semibold');
expect(value.className).toContain('leading-5');
});
test('supports multiline value layout and class overrides', () => {
render(
<PlatformInfoBlock
label="错误"
multiline
className="custom-block"
labelClassName="custom-label"
valueClassName="custom-value"
>
{'\n'}
</PlatformInfoBlock>,
);
const value = screen.getByText(//u);
const block = value.closest('div')?.parentElement;
expect(block?.className).toContain('custom-block');
expect(screen.getByText('错误').className).toContain('custom-label');
expect(value.className).toContain('whitespace-pre-wrap');
expect(value.className).toContain('leading-6');
expect(value.className).toContain('custom-value');
});
test('supports plain multiline value without a visible label', () => {
render(
<PlatformInfoBlock multiline className="rounded-[1.25rem]">
</PlatformInfoBlock>,
);
const value = screen.getByText('分享正文');
const block = value.closest('div')?.parentElement;
expect(block?.className).toContain('bg-white/72');
expect(block?.className).toContain('rounded-[1.25rem]');
expect(value.className.split(/\s+/u)).not.toContain('mt-1');
expect(value.className).toContain('whitespace-pre-wrap');
});
test('supports compact row variant for dense preview metadata', () => {
render(
<PlatformInfoBlock label="场景" variant="compactRow">
</PlatformInfoBlock>,
);
const label = screen.getByText('场景');
const value = screen.getByText('霓虹公园擂台');
const block = label.closest('div')?.parentElement;
expect(block?.className).toContain('bg-white/74');
expect(block?.className).toContain('rounded-[0.85rem]');
expect(block?.className).toContain('sm:px-3');
expect(label.className).toContain('text-[var(--platform-text-muted)]');
expect(value.className).toContain('text-right');
expect(value.className).toContain('font-black');
expect(value.className.split(/\s+/u)).not.toContain('mt-1');
});

View File

@@ -0,0 +1,100 @@
import type { ReactNode } from 'react';
type PlatformInfoBlockVariant = 'default' | 'compactRow';
type PlatformInfoBlockProps = {
label?: ReactNode;
children: ReactNode;
variant?: PlatformInfoBlockVariant;
multiline?: boolean;
className?: string;
labelClassName?: string;
valueClassName?: string;
};
const PLATFORM_INFO_BLOCK_CLASS: Record<PlatformInfoBlockVariant, string> = {
default:
'rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2',
compactRow:
'flex justify-between gap-2 rounded-[0.85rem] border-0 bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2',
};
const PLATFORM_INFO_BLOCK_LABEL_CLASS: Record<
PlatformInfoBlockVariant,
string
> = {
default: 'text-xs font-bold text-[var(--platform-text-soft)]',
compactRow: 'text-xs font-bold text-[var(--platform-text-muted)] sm:text-sm',
};
function getValueClassName({
hasLabel,
multiline,
variant,
valueClassName,
}: {
hasLabel: boolean;
multiline: boolean;
variant: PlatformInfoBlockVariant;
valueClassName?: string;
}) {
if (variant === 'compactRow') {
return [
'break-words text-right text-xs font-black leading-5 text-[var(--platform-text-strong)] sm:text-sm',
valueClassName,
]
.filter(Boolean)
.join(' ');
}
return [
'break-words text-sm text-[var(--platform-text-strong)]',
hasLabel ? 'mt-1' : null,
multiline ? 'whitespace-pre-wrap leading-6' : 'font-semibold leading-5',
valueClassName,
]
.filter(Boolean)
.join(' ');
}
/**
* 平台信息展示块。
* 统一承接弹窗和详情页中“短标签 + 白底内容”的只读信息 chrome。
*/
export function PlatformInfoBlock({
label,
children,
variant = 'default',
multiline = false,
className,
labelClassName,
valueClassName,
}: PlatformInfoBlockProps) {
return (
<div
className={[PLATFORM_INFO_BLOCK_CLASS[variant], className]
.filter(Boolean)
.join(' ')}
>
{label ? (
<div
className={[PLATFORM_INFO_BLOCK_LABEL_CLASS[variant], labelClassName]
.filter(Boolean)
.join(' ')}
>
{label}
</div>
) : null}
<div
className={getValueClassName({
hasLabel: Boolean(label),
multiline,
variant,
valueClassName,
})}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,295 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { createRef, type ImgHTMLAttributes } from 'react';
import { expect, test, vi } from 'vitest';
import { PlatformMediaFrame } from './PlatformMediaFrame';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
fallbackSrc,
alt,
className,
refreshKey,
...imageProps
}: {
src?: string;
fallbackSrc?: string;
alt: string;
className?: string;
refreshKey?: string | number | null;
} & ImgHTMLAttributes<HTMLImageElement>) => (
<img
{...imageProps}
src={src ?? fallbackSrc}
data-fallback-src={fallbackSrc}
data-refresh-key={refreshKey ?? undefined}
alt={alt}
className={className}
/>
),
}));
test('renders warm media frame with image and landscape aspect', () => {
render(
<PlatformMediaFrame
src="/scene.png"
alt="潮灯居"
fallbackLabel="场景"
aspect="landscape"
/>,
);
const image = screen.getByRole('img', { name: '潮灯居' });
const frame = image.closest('div');
expect(frame?.className).toContain('platform-media-frame');
expect(frame?.className).toContain('aspect-[16/9]');
expect(frame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(frame?.className).toContain('radial-gradient');
expect(image.className).toContain('object-cover');
});
test('renders fallback label when no image source is available', () => {
render(
<PlatformMediaFrame
alt="角色"
fallbackLabel="角色"
aspect="square"
surface="plain"
/>,
);
const fallback = screen.getByText('角色');
const frame = fallback.closest('div.relative');
expect(frame?.className).toContain('aspect-square');
expect(frame?.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(fallback.className).toContain('tracking-[0.18em]');
});
test('supports custom fallback content', () => {
render(
<PlatformMediaFrame
alt="封面图"
fallbackLabel="封面图占位"
fallbackContent={<span data-testid="fallback-icon"></span>}
aspect="standard"
surface="plain"
fallbackShellClassName="bg-rainbow"
fallbackClassName="tracking-normal"
/>,
);
const fallbackIcon = screen.getByTestId('fallback-icon');
const fallback = fallbackIcon.closest('div');
const frame = fallbackIcon.closest('div.relative');
expect(fallbackIcon.textContent).toBe('图标');
expect(screen.queryByText('封面图占位')).toBeNull();
expect(fallback?.className).toContain('bg-rainbow');
expect(fallback?.className).toContain('tracking-normal');
expect(frame?.className).toContain('aspect-[4/3]');
});
test('supports soft media frame surface', () => {
render(
<PlatformMediaFrame
src="/template-preview.webp"
alt="创意模板"
fallbackLabel="创意模板"
aspect="landscape"
surface="soft"
/>,
);
const image = screen.getByRole('img', { name: '创意模板' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-[16/9]');
expect(frame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(frame?.className).toContain('bg-white/68');
});
test('supports bright media frame surface', () => {
render(
<PlatformMediaFrame
src="/match3d/item.png"
alt="物品素材"
fallbackLabel="物品素材"
aspect="square"
surface="bright"
/>,
);
const image = screen.getByRole('img', { name: '物品素材' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-square');
expect(frame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(frame?.className).toContain('bg-white/82');
});
test('passes div attributes to the media frame container', () => {
render(
<PlatformMediaFrame
src="/match3d/item.png"
alt="物品素材"
fallbackLabel="物品素材"
surface="bright"
data-testid="match3d-preview-frame"
aria-label="物品素材预览"
/>,
);
const frame = screen.getByTestId('match3d-preview-frame');
expect(frame.getAttribute('aria-label')).toBe('物品素材预览');
expect(frame.className).toContain('bg-white/82');
});
test('supports none surface for nested interactive shells', () => {
render(
<PlatformMediaFrame
src="/match3d/thumb.png"
alt="缩略图"
fallbackLabel="缩略图"
aspect="square"
surface="none"
className="rounded-[0.65rem]"
/>,
);
const image = screen.getByRole('img', { name: '缩略图' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-square');
expect(frame?.className).toContain('rounded-[0.65rem]');
expect(frame?.className).not.toContain('border-[');
expect(frame?.className).not.toContain('bg-white');
});
test('supports editor dark surface, fallback source and overlay', () => {
render(
<PlatformMediaFrame
src=""
fallbackSrc="/fallback.png"
alt="第1幕"
fallbackLabel="第1幕"
surface="editorDark"
loading="lazy"
overlayInteractive
previewOverlay={<span></span>}
/>,
);
const image = screen.getByRole('img', { name: '第1幕' });
const frame = image.closest('div');
const overlay = screen.getByText('覆盖层').parentElement;
expect(image.getAttribute('src')).toBe('/fallback.png');
expect(frame?.className).toContain('border-white/10');
expect(frame?.className).toContain('rgba(19,24,39,0.95)');
expect(overlay?.className).toContain('pointer-events-auto');
});
test('supports standard aspect, bare surface and refresh key', () => {
render(
<PlatformMediaFrame
src="/puzzle/level.png"
alt="雨夜猫街"
fallbackLabel="暂无正式图"
aspect="standard"
surface="bare"
refreshKey="session-1:level-1"
/>,
);
const image = screen.getByRole('img', { name: '雨夜猫街' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-[4/3]');
expect(frame?.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(frame?.className).not.toContain('border');
expect(image.getAttribute('data-refresh-key')).toBe('session-1:level-1');
});
test('supports portrait aspect for vertical preview assets', () => {
render(
<PlatformMediaFrame
src="/puzzle-clear/board-background.png"
alt="场地底图"
fallbackLabel="底图"
aspect="portrait"
surface="bare"
className="rounded-none bg-white/80"
/>,
);
const image = screen.getByRole('img', { name: '场地底图' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-[9/16]');
expect(frame?.className).toContain('bg-white/80');
expect(frame?.className).toContain('rounded-none');
});
test('supports wide aspect for broad preview assets', () => {
render(
<PlatformMediaFrame
src="/big-fish/asset-preview.png"
alt="大鱼素材候选"
fallbackLabel="AI 资产候选预览"
aspect="wide"
surface="plain"
/>,
);
const image = screen.getByRole('img', { name: '大鱼素材候选' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-[9/5]');
expect(frame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
});
test('supports auto aspect, image props and container refs', () => {
const frameRef = createRef<HTMLDivElement>();
render(
<PlatformMediaFrame
ref={frameRef}
src="/cover-upload.png"
alt="封面裁剪预览"
fallbackLabel="封面"
aspect="auto"
surface="none"
imageClassName="absolute max-w-none object-fill"
imageProps={{
draggable: false,
style: { left: '-10%', width: '120%' },
}}
data-testid="cover-crop-frame"
/>,
);
const frame = screen.getByTestId('cover-crop-frame');
const image = screen.getByRole('img', { name: '封面裁剪预览' });
expect(frameRef.current).toBe(frame);
expect(frame.className).toContain('platform-media-frame');
expect(frame.className).not.toContain('aspect-[');
expect(frame.className).not.toContain('aspect-square');
expect(image.getAttribute('draggable')).toBe('false');
expect(image.getAttribute('style')).toContain('left: -10%');
expect(image.className).toContain('absolute max-w-none object-fill');
});

View File

@@ -0,0 +1,163 @@
import {
forwardRef,
type HTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PlatformMediaFrameAspect =
| 'auto'
| 'square'
| 'standard'
| 'landscape'
| 'wide'
| 'portrait'
| 'video';
type PlatformMediaFrameSurface =
| 'warm'
| 'editorDark'
| 'plain'
| 'soft'
| 'bright'
| 'none'
| 'bare';
type PlatformMediaFrameProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children' | 'className'
> & {
src?: string | null;
fallbackSrc?: string | null;
alt: string;
fallbackLabel: string;
aspect?: PlatformMediaFrameAspect;
surface?: PlatformMediaFrameSurface;
loading?: 'eager' | 'lazy';
refreshKey?: string | number | null;
imageClassName?: string;
imageProps?: Omit<
ImgHTMLAttributes<HTMLImageElement>,
'alt' | 'className' | 'loading' | 'src'
>;
className?: string;
fallbackClassName?: string;
fallbackShellClassName?: string;
fallbackContent?: ReactNode;
children?: ReactNode;
previewOverlay?: ReactNode;
overlayInteractive?: boolean;
};
const PLATFORM_MEDIA_FRAME_ASPECT_CLASS: Record<
PlatformMediaFrameAspect,
string
> = {
auto: '',
square: 'aspect-square',
standard: 'aspect-[4/3]',
landscape: 'aspect-[16/9]',
wide: 'aspect-[9/5]',
portrait: 'aspect-[9/16]',
video: 'aspect-video',
};
const PLATFORM_MEDIA_FRAME_SURFACE_CLASS: Record<
PlatformMediaFrameSurface,
string
> = {
warm: 'border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(204,117,76,0.9),rgba(223,127,64,0.82))]',
editorDark:
'border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.95),rgba(8,10,17,0.92))]',
plain:
'border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]',
soft: 'border border-[var(--platform-subpanel-border)] bg-white/68',
bright: 'border border-[var(--platform-subpanel-border)] bg-white/82',
none: '',
bare: 'bg-[var(--platform-subpanel-fill)]',
};
/**
* 平台媒体预览框。
* 统一承接图片预览、固定比例、fallback 文案和可选 overlay。
*/
export const PlatformMediaFrame = forwardRef<
HTMLDivElement,
PlatformMediaFrameProps
>(function PlatformMediaFrame(
{
src,
fallbackSrc,
alt,
fallbackLabel,
aspect = 'square',
surface = 'warm',
loading,
refreshKey,
imageClassName = 'h-full w-full object-cover',
imageProps,
className,
fallbackClassName,
fallbackShellClassName,
fallbackContent,
children,
previewOverlay,
overlayInteractive = false,
...containerProps
},
ref,
) {
const imageSrc = src?.trim() || fallbackSrc?.trim() || '';
const hasOverlay = Boolean(children) || Boolean(previewOverlay);
return (
<div
{...containerProps}
ref={ref}
className={[
'platform-media-frame relative overflow-hidden rounded-2xl',
PLATFORM_MEDIA_FRAME_SURFACE_CLASS[surface],
PLATFORM_MEDIA_FRAME_ASPECT_CLASS[aspect],
className,
]
.filter(Boolean)
.join(' ')}
>
{imageSrc ? (
<ResolvedAssetImage
{...imageProps}
src={src?.trim() || undefined}
fallbackSrc={fallbackSrc?.trim() || undefined}
alt={alt}
loading={loading}
refreshKey={refreshKey}
className={imageClassName}
/>
) : (
<div
className={[
'flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400',
fallbackShellClassName,
fallbackClassName,
]
.filter(Boolean)
.join(' ')}
>
{fallbackContent ?? fallbackLabel}
</div>
)}
{hasOverlay ? (
<div
className={[
overlayInteractive ? 'pointer-events-auto' : 'pointer-events-none',
'absolute inset-0',
].join(' ')}
>
{previewOverlay}
{children}
</div>
) : null}
</div>
);
});

View File

@@ -0,0 +1,96 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformMediaTileGrid } from './PlatformMediaTileGrid';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
refreshKey,
}: {
src?: string | null;
alt?: string;
className?: string;
refreshKey?: string | number | null;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
data-refresh-key={refreshKey ?? undefined}
/>
) : null,
}));
test('renders soft square tile grid with media frames', () => {
const { container } = render(
<PlatformMediaTileGrid
columns="five"
aspect="square"
surface="soft"
tileSurface="slate"
imageClassName="h-full w-full object-contain"
items={[
{
id: 'tile-1',
src: '/tiles/tile-1.png',
alt: '地块 1',
refreshKey: 'asset-tile-1',
testId: 'tile-preview-1',
},
{
id: 'tile-2',
fallbackLabel: '地块',
fallbackContent: <span data-testid="fallback-dot" />,
},
]}
/>,
);
const grid = container.querySelector('.platform-media-tile-grid');
const image = screen.getByRole('img', { name: '地块 1' });
const fallbackDot = screen.getByTestId('fallback-dot');
const itemFrames = container.querySelectorAll(
'.platform-media-tile-grid__item',
);
expect(grid?.className).toContain('grid-cols-5');
expect(grid?.className).toContain('aspect-[1/1]');
expect(grid?.className).toContain('bg-white/78');
expect(image.className).toContain('object-contain');
expect(image.getAttribute('data-refresh-key')).toBe('asset-tile-1');
expect(image.closest('[data-testid="tile-preview-1"]')).toBe(
screen.getByTestId('tile-preview-1'),
);
expect(itemFrames).toHaveLength(2);
expect(itemFrames[0]?.className).toContain('bg-slate-50');
expect(itemFrames[0]?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
expect(fallbackDot.closest('.platform-media-tile-grid__item')).toBeTruthy();
});
test('renders six-column white tile grid by default', () => {
const { container } = render(
<PlatformMediaTileGrid
items={[
{ id: 'card-1', src: '/cards/card-1.png', fallbackLabel: '卡片' },
]}
/>,
);
const grid = container.querySelector('.platform-media-tile-grid');
const item = container.querySelector('.platform-media-tile-grid__item');
expect(grid?.className).toContain('grid-cols-6');
expect(grid?.className).toContain('gap-1.5');
expect(item?.className).toContain('bg-white');
expect(item?.className).toContain('shadow-sm');
expect(item?.className).toContain('rounded-[0.45rem]');
expect(item?.className).not.toContain('bg-[var(--platform-subpanel-fill)]');
});

View File

@@ -0,0 +1,126 @@
import type { ReactNode } from 'react';
import { PlatformMediaFrame } from './PlatformMediaFrame';
type PlatformMediaTileGridColumns = 'five' | 'six';
type PlatformMediaTileGridGap = 'xs' | 'sm';
type PlatformMediaTileGridAspect = 'auto' | 'square';
type PlatformMediaTileSurface = 'white' | 'slate' | 'bare';
type PlatformMediaTileGridSurface = 'none' | 'soft';
export type PlatformMediaTileGridItem = {
id: string;
src?: string | null;
alt?: string;
refreshKey?: string | number | null;
fallbackLabel?: string;
fallbackContent?: ReactNode;
testId?: string;
className?: string;
imageClassName?: string;
fallbackClassName?: string;
};
type PlatformMediaTileGridProps = {
items: PlatformMediaTileGridItem[];
columns?: PlatformMediaTileGridColumns;
gap?: PlatformMediaTileGridGap;
aspect?: PlatformMediaTileGridAspect;
surface?: PlatformMediaTileGridSurface;
tileSurface?: PlatformMediaTileSurface;
fallbackLabel?: string;
imageClassName?: string;
fallbackClassName?: string;
className?: string;
tileClassName?: string;
};
const PLATFORM_MEDIA_TILE_GRID_COLUMNS_CLASS: Record<
PlatformMediaTileGridColumns,
string
> = {
five: 'grid-cols-5',
six: 'grid-cols-6',
};
const PLATFORM_MEDIA_TILE_GRID_GAP_CLASS: Record<
PlatformMediaTileGridGap,
string
> = {
sm: 'gap-1.5',
xs: 'gap-1',
};
const PLATFORM_MEDIA_TILE_GRID_SURFACE_CLASS: Record<
PlatformMediaTileGridSurface,
string
> = {
none: '',
soft: 'bg-white/78 p-2',
};
const PLATFORM_MEDIA_TILE_SURFACE_CLASS: Record<
PlatformMediaTileSurface,
string
> = {
bare: 'border border-white/80',
slate: 'border border-white/80 bg-slate-50',
white: 'border border-white/80 bg-white shadow-sm',
};
/**
* 平台媒体缩略格网格。
* 统一承接结果页里同尺寸素材 tile 的网格、圆角、边框和图片/fallback 框。
*/
export function PlatformMediaTileGrid({
items,
columns = 'six',
gap = 'sm',
aspect = 'auto',
surface = 'none',
tileSurface = 'white',
fallbackLabel = '素材',
imageClassName = 'h-full w-full object-cover',
fallbackClassName = 'tracking-normal text-[var(--platform-text-soft)]',
className,
tileClassName,
}: PlatformMediaTileGridProps) {
return (
<div
className={[
'platform-media-tile-grid grid',
PLATFORM_MEDIA_TILE_GRID_COLUMNS_CLASS[columns],
PLATFORM_MEDIA_TILE_GRID_GAP_CLASS[gap],
PLATFORM_MEDIA_TILE_GRID_SURFACE_CLASS[surface],
aspect === 'square' ? 'aspect-[1/1]' : '',
className,
]
.filter(Boolean)
.join(' ')}
>
{items.map((item) => (
<PlatformMediaFrame
key={item.id}
src={item.src}
alt={item.alt ?? ''}
refreshKey={item.refreshKey}
fallbackLabel={item.fallbackLabel ?? fallbackLabel}
fallbackContent={item.fallbackContent}
aspect="square"
surface="none"
data-testid={item.testId}
imageClassName={item.imageClassName ?? imageClassName}
fallbackClassName={item.fallbackClassName ?? fallbackClassName}
className={[
'platform-media-tile-grid__item min-h-0 rounded-[0.45rem]',
PLATFORM_MEDIA_TILE_SURFACE_CLASS[tileSurface],
tileClassName,
item.className,
]
.filter(Boolean)
.join(' ')}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,89 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
test('renders profile modal close button with shared chrome', () => {
render(<PlatformModalCloseButton label="关闭账户充值" onClick={() => {}} />);
const button = screen.getByRole('button', { name: '关闭账户充值' });
expect(button.className).toContain('platform-modal-close');
expect(button.className).toContain('h-9');
expect(button.querySelector('svg')).toBeTruthy();
});
test('supports floating close button and custom icon', () => {
render(
<PlatformModalCloseButton
label="关闭泥点账单"
variant="floating"
icon={<span aria-hidden="true">×</span>}
/>,
);
const button = screen.getByRole('button', { name: '关闭泥点账单' });
expect(button.className).toContain('absolute');
expect(button.className).toContain('right-3');
expect(button.textContent).toBe('×');
});
test('supports compact profile icon button', () => {
render(
<PlatformModalCloseButton
label="关闭昵称修改"
variant="profileCompact"
icon="×"
/>,
);
const button = screen.getByRole('button', { name: '关闭昵称修改' });
expect(button.className).toContain('platform-profile-icon-button');
expect(button.className).toContain('h-8');
expect(button.textContent).toBe('×');
});
test('supports plain floating close button', () => {
render(
<PlatformModalCloseButton
label="关闭邀请好友"
variant="floatingPlain"
icon="×"
/>,
);
const button = screen.getByRole('button', { name: '关闭邀请好友' });
expect(button.className).toContain('absolute');
expect(button.className).toContain('top-2');
expect(button.className).not.toContain('shadow-sm');
});
test('supports platform icon close button', () => {
render(
<PlatformModalCloseButton label="关闭素材图片" variant="platformIcon" />,
);
const button = screen.getByRole('button', { name: '关闭素材图片' });
expect(button.className).toContain('platform-icon-button');
expect(button.querySelector('svg')).toBeTruthy();
});
test('supports editor dark close button', () => {
render(
<PlatformModalCloseButton label="关闭角色自定义" variant="editorDark" />,
);
const button = screen.getByRole('button', { name: '关闭角色自定义' });
expect(button.className).toContain(
'platform-modal-close-button--editor-dark',
);
expect(button.className).toContain('border-white/10');
expect(button.className).toContain('bg-white/5');
});

View File

@@ -0,0 +1,65 @@
import { X } from 'lucide-react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
type PlatformModalCloseButtonVariant =
| 'profile'
| 'profileCompact'
| 'floating'
| 'floatingPlain'
| 'platformIcon'
| 'editorDark';
type PlatformModalCloseButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
label: string;
variant?: PlatformModalCloseButtonVariant;
icon?: ReactNode;
};
const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record<
PlatformModalCloseButtonVariant,
string
> = {
profile:
'platform-modal-close flex h-9 w-9 items-center justify-center rounded-full',
profileCompact:
'platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full',
floating:
'absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm',
floatingPlain:
'absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]',
platformIcon: 'platform-icon-button',
editorDark:
'platform-modal-close-button--editor-dark rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white',
};
/**
* 平台弹窗关闭按钮。
* 收口个人中心和平台浮层里重复的关闭 aria、尺寸和视觉样式。
*/
export function PlatformModalCloseButton({
label,
variant = 'profile',
icon = <X className="h-4 w-4" />,
className,
type = 'button',
...buttonProps
}: PlatformModalCloseButtonProps) {
return (
<button
{...buttonProps}
type={type}
aria-label={label}
className={[
PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT[variant],
className,
]
.filter(Boolean)
.join(' ')}
>
{icon}
</button>
);
}

View File

@@ -0,0 +1,53 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformOverlayBadge } from './PlatformOverlayBadge';
test('renders a light top-left overlay badge by default', () => {
render(<PlatformOverlayBadge>1</PlatformOverlayBadge>);
const badge = screen.getByText('第1幕');
expect(badge.className).toContain('absolute');
expect(badge.className).toContain('left-3');
expect(badge.className).toContain('top-3');
expect(badge.className).toContain('bg-white/88');
expect(badge.className).toContain('tracking-[0.18em]');
});
test('supports alternate placement and custom class', () => {
render(
<PlatformOverlayBadge placement="bottomRight" className="custom-overlay">
</PlatformOverlayBadge>,
);
const badge = screen.getByText('已选择');
expect(badge.className).toContain('bottom-3');
expect(badge.className).toContain('right-3');
expect(badge.className).toContain('custom-overlay');
});
test('supports compact muted tight overlay badge', () => {
render(
<PlatformOverlayBadge
placement="topRight"
offset="tight"
tone="muted"
size="compact"
>
</PlatformOverlayBadge>,
);
const badge = screen.getByText('占位图');
expect(badge.className).toContain('right-2');
expect(badge.className).toContain('top-2');
expect(badge.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(badge.className).toContain('px-2');
expect(badge.className).toContain('tracking-normal');
});

View File

@@ -0,0 +1,87 @@
import type { HTMLAttributes, ReactNode } from 'react';
type PlatformOverlayBadgePlacement =
| 'topLeft'
| 'topRight'
| 'bottomLeft'
| 'bottomRight';
type PlatformOverlayBadgeOffset = 'default' | 'tight';
type PlatformOverlayBadgeSize = 'default' | 'compact';
type PlatformOverlayBadgeTone = 'light' | 'muted';
type PlatformOverlayBadgeProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children'
> & {
children: ReactNode;
placement?: PlatformOverlayBadgePlacement;
offset?: PlatformOverlayBadgeOffset;
size?: PlatformOverlayBadgeSize;
tone?: PlatformOverlayBadgeTone;
};
const PLATFORM_OVERLAY_BADGE_PLACEMENT_CLASS: Record<
PlatformOverlayBadgeOffset,
Record<PlatformOverlayBadgePlacement, string>
> = {
default: {
topLeft: 'left-3 top-3',
topRight: 'right-3 top-3',
bottomLeft: 'bottom-3 left-3',
bottomRight: 'bottom-3 right-3',
},
tight: {
topLeft: 'left-2 top-2',
topRight: 'right-2 top-2',
bottomLeft: 'bottom-2 left-2',
bottomRight: 'bottom-2 right-2',
},
};
const PLATFORM_OVERLAY_BADGE_SIZE_CLASS: Record<
PlatformOverlayBadgeSize,
string
> = {
default: 'px-3 py-1 tracking-[0.18em] shadow-[0_8px_20px_rgba(0,0,0,0.22)]',
compact: 'px-2 py-0.5 tracking-normal shadow-sm',
};
const PLATFORM_OVERLAY_BADGE_TONE_CLASS: Record<
PlatformOverlayBadgeTone,
string
> = {
light: 'border-white/40 bg-white/88 text-zinc-900',
muted:
'border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]',
};
/**
* 平台媒体悬浮标签。
* 统一承接预览图、素材图和舞台画面上的非交互短标签。
*/
export function PlatformOverlayBadge({
children,
placement = 'topLeft',
offset = 'default',
size = 'default',
tone = 'light',
className,
...spanProps
}: PlatformOverlayBadgeProps) {
return (
<span
{...spanProps}
className={[
'absolute rounded-full border text-[10px] font-bold',
PLATFORM_OVERLAY_BADGE_PLACEMENT_CLASS[offset][placement],
PLATFORM_OVERLAY_BADGE_SIZE_CLASS[size],
PLATFORM_OVERLAY_BADGE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,148 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { Tag } from 'lucide-react';
import { expect, test } from 'vitest';
import { PlatformPillBadge } from './PlatformPillBadge';
import { getPlatformPillBadgeClassName } from './platformPillBadgeModel';
test('renders neutral platform pill badge by default', () => {
render(<PlatformPillBadge>稿</PlatformPillBadge>);
const badge = screen.getByText('草稿');
expect(badge.className).toContain('rounded-full');
expect(badge.className).toContain('border-[var(--platform-subpanel-border)]');
expect(badge.className).toContain('bg-white/72');
});
test('supports muted platform pill badge', () => {
render(<PlatformPillBadge tone="muted"></PlatformPillBadge>);
const badge = screen.getByText('选择');
expect(badge.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(badge.className).toContain('text-[var(--platform-text-soft)]');
});
test('supports solid neutral status pill badge', () => {
render(<PlatformPillBadge tone="neutralSolid"></PlatformPillBadge>);
const badge = screen.getByText('开启');
expect(badge.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(badge.className).toContain('text-[var(--platform-neutral-text)]');
});
test('supports light overlay pill badge for nested action metadata', () => {
render(<PlatformPillBadge tone="lightOverlay">2</PlatformPillBadge>);
const badge = screen.getByText('2泥点');
expect(badge.className).toContain('border-white/30');
expect(badge.className).toContain('bg-white/24');
expect(badge.className).toContain('text-current');
});
test('supports success tone, compact size, icon and custom class', () => {
render(
<PlatformPillBadge
tone="success"
size="xs"
icon={<Tag aria-hidden="true" className="h-3 w-3" />}
className="ml-2"
>
</PlatformPillBadge>,
);
const badge = screen.getByText('已发布');
expect(badge.className).toContain('border-emerald-200');
expect(badge.className).toContain('text-[11px]');
expect(badge.className).toContain('ml-2');
expect(badge.querySelector('svg')).toBeTruthy();
});
test('supports extra compact size for dense catalog chips', () => {
render(
<PlatformPillBadge tone="warning" size="xxs">
</PlatformPillBadge>,
);
const badge = screen.getByText('新');
expect(badge.className).toContain('px-2.5');
expect(badge.className).toContain('py-1');
expect(badge.className).toContain('text-[10px]');
});
test('shares badge chrome with interactive pill actions', () => {
const className = getPlatformPillBadgeClassName({
tone: 'neutral',
size: 'xs',
className: 'tracking-[0.18em]',
});
expect(className).toContain('inline-flex');
expect(className).toContain('rounded-full');
expect(className).toContain('bg-white/72');
expect(className).toContain('text-[11px]');
expect(className).toContain('tracking-[0.18em]');
});
test('supports warning, danger and cool tones', () => {
const { rerender } = render(
<PlatformPillBadge tone="warning"></PlatformPillBadge>,
);
expect(screen.getByText('待确认').className).toContain(
'border-[var(--platform-warm-border)]',
);
rerender(<PlatformPillBadge tone="danger"></PlatformPillBadge>);
expect(screen.getByText('失败').className).toContain(
'border-[var(--platform-button-danger-border)]',
);
rerender(<PlatformPillBadge tone="cool"></PlatformPillBadge>);
expect(screen.getByText('处理中').className).toContain(
'border-[var(--platform-cool-border)]',
);
});
test('supports dark RPG badge tones', () => {
const { rerender } = render(
<PlatformPillBadge tone="darkSky"></PlatformPillBadge>,
);
expect(screen.getByText('爆发').className).toContain('bg-sky-500/10');
rerender(<PlatformPillBadge tone="darkEmerald"></PlatformPillBadge>);
expect(screen.getByText('状态').className).toContain('text-emerald-100');
rerender(<PlatformPillBadge tone="darkNeutral">Lv.7</PlatformPillBadge>);
expect(screen.getByText('Lv.7').className).toContain('bg-black/20');
rerender(<PlatformPillBadge tone="darkAmber"></PlatformPillBadge>);
expect(screen.getByText('队长').className).toContain('text-amber-100');
rerender(<PlatformPillBadge tone="darkRose"></PlatformPillBadge>);
expect(screen.getByText('敌对').className).toContain('bg-rose-500/10');
});
test('supports profile tones for personal center chips', () => {
render(<PlatformPillBadge tone="profile">80</PlatformPillBadge>);
const badge = screen.getByText('80泥点');
expect(badge.className).toContain('border-rose-100');
expect(badge.className).toContain('bg-rose-50');
expect(badge.className).toContain('text-zinc-600');
render(<PlatformPillBadge tone="profileAccent">RPG</PlatformPillBadge>);
expect(screen.getByText('RPG').className).toContain('text-[#ff4056]');
});

View File

@@ -0,0 +1,40 @@
import type { HTMLAttributes, ReactNode } from 'react';
import {
getPlatformPillBadgeClassName,
type PlatformPillBadgeSize,
type PlatformPillBadgeTone,
} from './platformPillBadgeModel';
type PlatformPillBadgeProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children'
> & {
tone?: PlatformPillBadgeTone;
size?: PlatformPillBadgeSize;
icon?: ReactNode;
children: ReactNode;
};
/**
* 平台胶囊状态标签。
* 统一承接结果页、作品卡和配置摘要里的小型状态 / 标签 chip。
*/
export function PlatformPillBadge({
tone = 'neutral',
size = 'sm',
icon,
children,
className,
...spanProps
}: PlatformPillBadgeProps) {
return (
<span
{...spanProps}
className={getPlatformPillBadgeClassName({ tone, size, className })}
>
{icon}
{children}
</span>
);
}

View File

@@ -0,0 +1,81 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformPillSwitch } from './PlatformPillSwitch';
test('renders checked pill switch with platform chrome', () => {
render(
<PlatformPillSwitch
label="AI重绘"
aria-label="AI重绘"
checked
onChange={() => {}}
/>,
);
const switchInput = screen.getByRole('switch', { name: 'AI重绘' });
expect(switchInput).toHaveProperty('checked', true);
expect(screen.getByText('AI重绘').closest('label')?.className).toContain(
'bg-white/94',
);
expect(screen.getByText('AI重绘').closest('label')?.className).toContain(
'backdrop-blur',
);
});
test('calls onChange when toggled', () => {
const onChange = vi.fn();
render(
<PlatformPillSwitch
label="AI重绘"
aria-label="AI重绘"
checked={false}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByRole('switch', { name: 'AI重绘' }));
expect(onChange).toHaveBeenCalledTimes(1);
});
test('keeps disabled switch inert and styled as disabled', () => {
const onChange = vi.fn();
render(
<PlatformPillSwitch
label="AI重绘"
aria-label="AI重绘"
checked={false}
disabled
onChange={onChange}
/>,
);
const switchInput = screen.getByRole('switch', { name: 'AI重绘' });
expect(switchInput).toHaveProperty('disabled', true);
expect(screen.getByText('AI重绘').closest('label')?.className).toContain(
'cursor-not-allowed',
);
});
test('keeps local placement classes', () => {
render(
<PlatformPillSwitch
label="AI重绘"
aria-label="AI重绘"
checked={false}
className="absolute bottom-3 left-3"
onChange={() => {}}
/>,
);
expect(screen.getByText('AI重绘').closest('label')?.className).toContain(
'bottom-3',
);
});

View File

@@ -0,0 +1,56 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
type PlatformPillSwitchProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'children' | 'type'
> & {
label: ReactNode;
};
/**
* 平台胶囊开关。
* 统一承载图片面板中类似 AI 重绘的 label + switch 语义和视觉。
*/
export function PlatformPillSwitch({
label,
checked,
disabled,
className,
...inputProps
}: PlatformPillSwitchProps) {
return (
<label
className={[
'inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur',
disabled ? 'cursor-not-allowed opacity-55' : null,
className,
]
.filter(Boolean)
.join(' ')}
>
<span>{label}</span>
<input
{...inputProps}
role="switch"
type="checkbox"
checked={checked}
disabled={disabled}
className="sr-only"
/>
<span
aria-hidden="true"
className={[
'relative h-5 w-9 rounded-full transition',
checked ? 'bg-[var(--platform-accent)]' : 'bg-zinc-300',
].join(' ')}
>
<span
className={[
'absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition',
checked ? 'left-[1.125rem]' : 'left-0.5',
].join(' ')}
/>
</span>
</label>
);
}

View File

@@ -0,0 +1,110 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformProgressBar } from './PlatformProgressBar';
test('renders shared progressbar chrome and value', () => {
render(
<PlatformProgressBar
value={42}
minVisibleValue={8}
size="sm"
className="mt-3"
fillClassName="bg-emerald-300"
/>,
);
const progressbar = screen.getByRole('progressbar');
const fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-valuenow')).toBe('42');
expect(progressbar.className).toContain('platform-progress-track');
expect(progressbar.className).toContain('h-2.5');
expect(progressbar.className).toContain('mt-3');
expect(fill?.className).toContain('bg-emerald-300');
expect(fill?.style.width).toBe('42%');
});
test('keeps zero progress visually empty and clamps invalid values', () => {
const { rerender } = render(<PlatformProgressBar value={0} />);
let progressbar = screen.getByRole('progressbar');
let fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
expect(fill?.style.width).toBe('0%');
rerender(<PlatformProgressBar value={Number.NaN} />);
progressbar = screen.getByRole('progressbar');
fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
expect(fill?.style.width).toBe('0%');
rerender(<PlatformProgressBar value={130} />);
progressbar = screen.getByRole('progressbar');
fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-valuenow')).toBe('100');
expect(fill?.style.width).toBe('100%');
});
test('supports labelled progressbar and local fill style', () => {
render(
<>
<div id="progress-label"></div>
<PlatformProgressBar
value={3}
minVisibleValue={6}
labelledBy="progress-label"
size="md"
fillStyle={{ backgroundColor: 'red' }}
/>
</>,
);
const progressbar = screen.getByRole('progressbar');
const fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-labelledby')).toBe('progress-label');
expect(progressbar.className).toContain('h-3');
expect(fill?.style.width).toBe('6%');
expect(fill?.style.backgroundColor).toBe('red');
});
test('supports aria label and overlay content', () => {
render(
<PlatformProgressBar value={64} ariaLabel="画面生成进度" size="lg">
<span> 270 </span>
</PlatformProgressBar>,
);
const progressbar = screen.getByRole('progressbar', {
name: '画面生成进度',
});
expect(progressbar.getAttribute('aria-valuenow')).toBe('64');
expect(progressbar.className).toContain('relative');
expect(progressbar.className).toContain('h-12');
expect(screen.getByText('预计剩余 270 秒')).toBeTruthy();
});
test('omits current value for indeterminate progress', () => {
render(
<PlatformProgressBar
value={66}
ariaLabel="生成中"
indeterminate
fillClassName="animate-pulse"
/>,
);
const progressbar = screen.getByRole('progressbar', { name: '生成中' });
const fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-valuenow')).toBeNull();
expect(fill?.className).toContain('animate-pulse');
expect(fill?.style.width).toBe('66%');
});

View File

@@ -0,0 +1,90 @@
import type { CSSProperties, ReactNode } from 'react';
type PlatformProgressBarSize = 'xs' | 'sm' | 'md' | 'lg';
type PlatformProgressBarProps = {
value: number;
minVisibleValue?: number;
size?: PlatformProgressBarSize;
ariaLabel?: string;
labelledBy?: string;
indeterminate?: boolean;
className?: string;
fillClassName?: string;
fillStyle?: CSSProperties;
trackStyle?: CSSProperties;
children?: ReactNode;
};
const PLATFORM_PROGRESS_BAR_SIZE_CLASS: Record<
PlatformProgressBarSize,
string
> = {
xs: 'h-2',
sm: 'h-2.5',
md: 'h-3',
lg: 'h-12',
};
function clampProgressValue(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(100, Math.max(0, Math.round(value)));
}
/**
* 平台通用进度条。
* 统一承接 progressbar 语义、platform-progress-track 壳和填充宽度计算。
*/
export function PlatformProgressBar({
value,
minVisibleValue = 0,
size = 'xs',
ariaLabel,
labelledBy,
indeterminate = false,
className,
fillClassName,
fillStyle,
trackStyle,
children,
}: PlatformProgressBarProps) {
const progress = clampProgressValue(value);
const visibleProgress =
progress <= 0 ? 0 : Math.max(minVisibleValue, progress);
return (
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : progress}
aria-label={ariaLabel}
aria-labelledby={labelledBy}
className={[
'platform-progress-track relative overflow-hidden rounded-full',
PLATFORM_PROGRESS_BAR_SIZE_CLASS[size],
className,
]
.filter(Boolean)
.join(' ')}
style={trackStyle}
>
<div
className={[
'h-full rounded-full transition-[width] duration-300',
fillClassName,
]
.filter(Boolean)
.join(' ')}
style={{
width: `${visibleProgress}%`,
...fillStyle,
}}
/>
{children}
</div>
);
}

View File

@@ -0,0 +1,31 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformQuantityBadge } from './PlatformQuantityBadge';
test('renders a dark bottom-right quantity badge by default', () => {
render(<PlatformQuantityBadge>3</PlatformQuantityBadge>);
const badge = screen.getByText('3');
expect(badge.className).toContain('absolute');
expect(badge.className).toContain('bottom-1');
expect(badge.className).toContain('right-1');
expect(badge.className).toContain('bg-black/70');
expect(badge.className).toContain('text-[10px]');
});
test('supports custom class names', () => {
render(
<PlatformQuantityBadge className="custom-quantity">
12
</PlatformQuantityBadge>,
);
const badge = screen.getByText('12');
expect(badge.className).toContain('rounded-full');
expect(badge.className).toContain('custom-quantity');
});

View File

@@ -0,0 +1,55 @@
import type { HTMLAttributes, ReactNode } from 'react';
type PlatformQuantityBadgePlacement = 'bottomRight';
type PlatformQuantityBadgeTone = 'dark';
type PlatformQuantityBadgeProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children'
> & {
children: ReactNode;
placement?: PlatformQuantityBadgePlacement;
tone?: PlatformQuantityBadgeTone;
};
const PLATFORM_QUANTITY_BADGE_PLACEMENT_CLASS: Record<
PlatformQuantityBadgePlacement,
string
> = {
bottomRight: 'bottom-1 right-1',
};
const PLATFORM_QUANTITY_BADGE_TONE_CLASS: Record<
PlatformQuantityBadgeTone,
string
> = {
dark: 'border-black/35 bg-black/70 text-white',
};
/**
* 平台物品数量角标。
* 统一承接物品格、奖励格等缩略图右下角的数量显示。
*/
export function PlatformQuantityBadge({
children,
placement = 'bottomRight',
tone = 'dark',
className,
...spanProps
}: PlatformQuantityBadgeProps) {
return (
<span
{...spanProps}
className={[
'absolute rounded-full border px-1.5 py-0.5 text-[10px] font-semibold',
PLATFORM_QUANTITY_BADGE_PLACEMENT_CLASS[placement],
PLATFORM_QUANTITY_BADGE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,164 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
const ITEMS = [
{ id: 'work', label: '作品信息' },
{ id: 'levels', label: '拼图关卡' },
] as const;
test('renders platform segmented tabs with pressed state', () => {
const onChange = vi.fn();
render(
<PlatformSegmentedTabs
items={ITEMS}
activeId="work"
onChange={onChange}
className="mb-3"
/>,
);
const workTab = screen.getByRole('button', { name: '作品信息' });
const levelsTab = screen.getByRole('button', { name: '拼图关卡' });
expect(workTab.getAttribute('aria-pressed')).toBe('true');
expect(levelsTab.getAttribute('aria-pressed')).toBe('false');
expect(workTab.className).toContain('bg-white');
expect(levelsTab.className).toContain('hover:bg-white/60');
expect(workTab.closest('div')?.className).toContain('grid-cols-2');
expect(workTab.closest('div')?.className).toContain('mb-3');
fireEvent.click(levelsTab);
expect(onChange).toHaveBeenCalledWith('levels');
});
test('supports compact responsive columns and truncated labels', () => {
render(
<PlatformSegmentedTabs
items={[
{ id: 'profile', label: '作品' },
{ id: 'world', label: '世界' },
{ id: 'opening', label: '开场' },
]}
activeId="profile"
onChange={vi.fn()}
columns="threeToSix"
gap="sm"
radius="md"
size="compact"
truncateLabels
/>,
);
const profileTab = screen.getByRole('button', { name: '作品' });
const label = profileTab.querySelector('span');
expect(profileTab.closest('div')?.className).toContain('grid-cols-3');
expect(profileTab.closest('div')?.className).toContain('sm:grid-cols-6');
expect(profileTab.closest('div')?.className).toContain('gap-1');
expect(profileTab.className).toContain('rounded-[0.8rem]');
expect(profileTab.className).toContain('text-xs');
expect(label?.className).toContain('truncate');
});
test('respects disabled items and custom item classes', () => {
const onChange = vi.fn();
render(
<PlatformSegmentedTabs
items={[
{ id: 'single', label: '单关卡' },
{ id: 'multi', label: '多关卡', disabled: true },
]}
activeId="single"
onChange={onChange}
itemClassName={(item, active) =>
item.id === 'single' && active ? 'data-active-token' : null
}
/>,
);
const singleTab = screen.getByRole('button', { name: '单关卡' });
const multiTab = screen.getByRole('button', { name: '多关卡' });
expect(singleTab.className).toContain('data-active-token');
expect(multiTab).toHaveProperty('disabled', true);
fireEvent.click(multiTab);
expect(onChange).not.toHaveBeenCalled();
});
test('supports bare four-column choice tabs with tone variants', () => {
render(
<PlatformSegmentedTabs
items={[
{ id: 'easy', label: '轻松' },
{ id: 'standard', label: '标准' },
{ id: 'advanced', label: '进阶' },
{ id: 'hardcore', label: '硬核' },
]}
activeId="advanced"
onChange={vi.fn()}
columns="four"
size="choice"
surface="transparent"
tone="rose"
frame="bare"
/>,
);
const advancedTab = screen.getByRole('button', { name: '进阶' });
const standardTab = screen.getByRole('button', { name: '标准' });
const tabGrid = advancedTab.closest('div');
expect(tabGrid?.className).toContain('grid-cols-4');
expect(tabGrid?.className).toContain('border-0');
expect(tabGrid?.className).toContain('p-0');
expect(tabGrid?.className).toContain('bg-transparent');
expect(advancedTab.className).toContain('rounded-[0.9rem]');
expect(advancedTab.className).toContain('bg-[linear-gradient');
expect(standardTab.className).toContain('bg-white/76');
});
test('supports auth-style tab semantics with underline tone', () => {
const onChange = vi.fn();
render(
<PlatformSegmentedTabs
items={[
{ id: 'phone', label: '短信登录' },
{ id: 'password', label: '密码登录' },
]}
activeId="phone"
onChange={onChange}
columns="one"
frame="bare"
surface="transparent"
tone="underline"
size="tab"
semantics="tabs"
ariaLabel="登录方式"
/>,
);
const tablist = screen.getByRole('tablist', { name: '登录方式' });
const phoneTab = screen.getByRole('tab', { name: '短信登录' });
const passwordTab = screen.getByRole('tab', { name: '密码登录' });
expect(tablist.className).toContain('grid-cols-1');
expect(phoneTab.getAttribute('aria-selected')).toBe('true');
expect(phoneTab.getAttribute('aria-pressed')).toBeNull();
expect(phoneTab.className).toContain('h-12');
expect(phoneTab.querySelector('span.absolute')).toBeTruthy();
expect(passwordTab.getAttribute('aria-selected')).toBe('false');
fireEvent.click(passwordTab);
expect(onChange).toHaveBeenCalledWith('password');
});

View File

@@ -0,0 +1,238 @@
import type { ReactNode } from 'react';
type PlatformSegmentedTabsColumns =
| 'one'
| 'two'
| 'three'
| 'four'
| 'threeToSix';
type PlatformSegmentedTabsGap = 'sm' | 'md';
type PlatformSegmentedTabsRadius = 'md' | 'lg' | 'xl';
type PlatformSegmentedTabsSize = 'sm' | 'md' | 'compact' | 'choice' | 'tab';
type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent';
type PlatformSegmentedTabsTone = 'neutral' | 'warm' | 'rose' | 'underline';
type PlatformSegmentedTabsFrame = 'panel' | 'bare';
type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
export type PlatformSegmentedTabItem<TId extends string> = {
id: TId;
label: ReactNode;
ariaLabel?: string;
disabled?: boolean;
};
type PlatformSegmentedTabsProps<TId extends string> = {
items: readonly PlatformSegmentedTabItem<TId>[];
activeId: TId;
onChange: (id: TId) => void;
columns?: PlatformSegmentedTabsColumns;
gap?: PlatformSegmentedTabsGap;
radius?: PlatformSegmentedTabsRadius;
size?: PlatformSegmentedTabsSize;
surface?: PlatformSegmentedTabsSurface;
tone?: PlatformSegmentedTabsTone;
frame?: PlatformSegmentedTabsFrame;
semantics?: PlatformSegmentedTabsSemantics;
ariaLabel?: string;
truncateLabels?: boolean;
disabled?: boolean;
className?: string;
itemClassName?:
| string
| ((item: PlatformSegmentedTabItem<TId>, active: boolean) => string | null);
};
const PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS: Record<
PlatformSegmentedTabsColumns,
string
> = {
one: 'grid-cols-1',
two: 'grid-cols-2',
three: 'grid-cols-3',
four: 'grid-cols-4',
threeToSix: 'grid-cols-3 sm:grid-cols-6',
};
const PLATFORM_SEGMENTED_TABS_GAP_CLASS: Record<
PlatformSegmentedTabsGap,
string
> = {
sm: 'gap-1',
md: 'gap-2',
};
const PLATFORM_SEGMENTED_TABS_RADIUS_CLASS: Record<
PlatformSegmentedTabsRadius,
string
> = {
md: 'rounded-[1rem]',
lg: 'rounded-[1.1rem]',
xl: 'rounded-[1.25rem]',
};
const PLATFORM_SEGMENTED_TABS_SURFACE_CLASS: Record<
PlatformSegmentedTabsSurface,
string
> = {
default: 'bg-white/62',
soft: 'bg-white/58',
transparent: 'bg-transparent',
};
const PLATFORM_SEGMENTED_TABS_FRAME_CLASS: Record<
PlatformSegmentedTabsFrame,
string
> = {
panel: 'border border-[var(--platform-subpanel-border)] p-1',
bare: 'border-0 p-0',
};
const PLATFORM_SEGMENTED_TABS_ITEM_SIZE_CLASS: Record<
PlatformSegmentedTabsSize,
string
> = {
sm: 'min-h-10 rounded-[0.9rem] px-3 text-sm font-bold',
md: 'min-h-10 rounded-[1rem] px-3 text-sm font-bold',
compact: 'min-h-10 rounded-[0.8rem] px-2 text-xs font-black sm:text-sm',
choice: 'min-h-10 rounded-[0.9rem] px-1.5 py-2 text-center',
tab: 'relative h-12 rounded-none px-2 text-base font-semibold sm:text-lg',
};
const PLATFORM_SEGMENTED_TABS_TONE_CLASS: Record<
PlatformSegmentedTabsTone,
{ active: string; idle: string }
> = {
neutral: {
active: 'bg-white text-[var(--platform-text-strong)] shadow-sm',
idle: 'text-[var(--platform-text-base)] hover:bg-white/60',
},
warm: {
active:
'bg-[var(--platform-warm-bg)] text-[var(--platform-text-strong)] shadow-[inset_0_0_0_1px_rgba(204,117,76,0.18)]',
idle: 'text-[var(--platform-text-base)] hover:bg-white/58',
},
rose: {
active:
'border border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]',
idle: 'border border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-[var(--platform-surface-hover-border)] hover:bg-white',
},
underline: {
active: 'text-[var(--platform-text-strong)]',
idle: 'text-[var(--platform-text-muted)] hover:text-[var(--platform-text-base)]',
},
};
function resolveItemClassName<TId extends string>({
active,
disabled,
item,
itemClassName,
size,
tone,
}: {
active: boolean;
disabled: boolean;
item: PlatformSegmentedTabItem<TId>;
itemClassName?: PlatformSegmentedTabsProps<TId>['itemClassName'];
size: PlatformSegmentedTabsSize;
tone: PlatformSegmentedTabsTone;
}) {
const extraClassName =
typeof itemClassName === 'function'
? itemClassName(item, active)
: itemClassName;
return [
'min-w-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)]',
PLATFORM_SEGMENTED_TABS_ITEM_SIZE_CLASS[size],
active
? PLATFORM_SEGMENTED_TABS_TONE_CLASS[tone].active
: PLATFORM_SEGMENTED_TABS_TONE_CLASS[tone].idle,
disabled ? 'cursor-not-allowed opacity-55 hover:bg-transparent' : null,
extraClassName,
]
.filter(Boolean)
.join(' ');
}
/**
* 平台白底分段选择控件。
* 统一承接结果页和轻量弹窗内重复的 tab / segment button chrome。
*/
export function PlatformSegmentedTabs<TId extends string>({
items,
activeId,
onChange,
columns = 'two',
gap = 'md',
radius = 'xl',
size = 'md',
surface = 'default',
tone = 'neutral',
frame = 'panel',
semantics = 'segment',
ariaLabel,
truncateLabels = false,
disabled = false,
className,
itemClassName,
}: PlatformSegmentedTabsProps<TId>) {
return (
<div
role={semantics === 'tabs' ? 'tablist' : undefined}
aria-label={semantics === 'tabs' ? ariaLabel : undefined}
className={[
'grid',
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns],
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
PLATFORM_SEGMENTED_TABS_RADIUS_CLASS[radius],
PLATFORM_SEGMENTED_TABS_SURFACE_CLASS[surface],
className,
]
.filter(Boolean)
.join(' ')}
>
{items.map((item) => {
const active = activeId === item.id;
const itemDisabled = disabled || Boolean(item.disabled);
return (
<button
key={item.id}
type="button"
role={semantics === 'tabs' ? 'tab' : undefined}
aria-label={item.ariaLabel}
aria-selected={semantics === 'tabs' ? active : undefined}
aria-pressed={semantics === 'segment' ? active : undefined}
disabled={itemDisabled}
onClick={() => {
if (itemDisabled) {
return;
}
onChange(item.id);
}}
className={resolveItemClassName({
active,
disabled: itemDisabled,
item,
itemClassName,
size,
tone,
})}
>
<>
{truncateLabels ? (
<span className="block truncate">{item.label}</span>
) : (
item.label
)}
{tone === 'underline' && active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,46 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformSlotBadge } from './PlatformSlotBadge';
test('renders an inactive compact slot badge by default', () => {
render(<PlatformSlotBadge>2</PlatformSlotBadge>);
const badge = screen.getByText('2');
expect(badge.className).toContain('h-5');
expect(badge.className).toContain('min-w-5');
expect(badge.className).toContain('rounded-full');
expect(badge.className).toContain('bg-zinc-900');
});
test('supports active slot badge and custom class', () => {
render(
<PlatformSlotBadge tone="active" className="custom-slot">
</PlatformSlotBadge>,
);
const badge = screen.getByText('主');
expect(badge.className).toContain('bg-sky-300');
expect(badge.className).toContain('text-slate-950');
expect(badge.className).toContain('custom-slot');
});
test('supports soft medium step badge', () => {
render(
<PlatformSlotBadge tone="soft" size="md">
1
</PlatformSlotBadge>,
);
const badge = screen.getByText('1');
expect(badge.className).toContain('h-6');
expect(badge.className).toContain('min-w-6');
expect(badge.className).toContain('bg-white/82');
expect(badge.className).toContain('shadow-sm');
});

View File

@@ -0,0 +1,52 @@
import type { HTMLAttributes, ReactNode } from 'react';
type PlatformSlotBadgeTone = 'active' | 'inactive' | 'soft';
type PlatformSlotBadgeSize = 'sm' | 'md';
type PlatformSlotBadgeProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children'
> & {
children: ReactNode;
size?: PlatformSlotBadgeSize;
tone?: PlatformSlotBadgeTone;
};
const PLATFORM_SLOT_BADGE_TONE_CLASS: Record<PlatformSlotBadgeTone, string> = {
active: 'border-sky-100/70 bg-sky-300 text-slate-950',
inactive: 'border-zinc-400/70 bg-zinc-900 text-white',
soft: 'border-transparent bg-white/82 text-[var(--platform-text-strong)] shadow-sm',
};
const PLATFORM_SLOT_BADGE_SIZE_CLASS: Record<PlatformSlotBadgeSize, string> = {
sm: 'h-5 min-w-5 px-1 text-[10px]',
md: 'h-6 min-w-6 px-1.5 text-xs',
};
/**
* 平台紧凑槽位编号徽标。
* 统一承接角色槽、步骤槽等复合按钮内部的序号 / 主位标记。
*/
export function PlatformSlotBadge({
children,
size = 'sm',
tone = 'inactive',
className,
...spanProps
}: PlatformSlotBadgeProps) {
return (
<span
{...spanProps}
className={[
'flex items-center justify-center rounded-full border font-black leading-none',
PLATFORM_SLOT_BADGE_SIZE_CLASS[size],
PLATFORM_SLOT_BADGE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,74 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformStatGrid } from './PlatformStatGrid';
test('renders platform stat cards with value-first layout', () => {
render(
<PlatformStatGrid
items={[
{ label: '图案组', value: 35 },
{ label: '卡片', value: 95 },
{ label: '状态', value: 'ready' },
]}
/>,
);
const value = screen.getByText('35');
const label = screen.getByText('图案组');
const grid = value.closest('div')?.parentElement?.parentElement;
const card = value.parentElement;
expect(grid?.className).toContain('grid-cols-3');
expect(grid?.className).toContain('text-center');
expect(card?.className).toContain('bg-white/76');
expect(value.className).toContain('text-lg');
expect(label.className).toContain('tracking-[0.14em]');
});
test('supports label-first plain cards and responsive four-column grid', () => {
render(
<PlatformStatGrid
items={[
{ label: '需要消除', value: '12 次' },
{ label: '总物品数', value: '36 件' },
]}
columns="twoToFour"
order="labelFirst"
surface="plain"
itemClassName={(item) =>
item.label === '总物品数' ? 'total-item-card' : null
}
/>,
);
const label = screen.getByText('需要消除');
const value = screen.getByText('12 次');
const grid = label.closest('div')?.parentElement?.parentElement;
const totalCard = screen.getByText('总物品数').parentElement;
expect(grid?.className).toContain('grid-cols-2');
expect(grid?.className).toContain('sm:grid-cols-4');
expect(label.nextElementSibling).toBe(value);
expect(totalCard?.className).toContain('border');
expect(totalCard?.className).toContain('total-item-card');
});
test('supports compact stat chips without labels', () => {
render(
<PlatformStatGrid
items={[{ value: '6 个' }, { value: '可发布' }]}
columns="two"
density="compact"
/>,
);
const chip = screen.getByText('6 个');
const card = chip.parentElement;
expect(card?.className).toContain('py-2');
expect(chip.className).toContain('text-sm');
expect(screen.queryByText('状态')).toBeNull();
});

View File

@@ -0,0 +1,158 @@
import type { ReactNode } from 'react';
type PlatformStatGridColumns = 'two' | 'three' | 'four' | 'twoToFour';
type PlatformStatGridDensity = 'compact' | 'default';
type PlatformStatGridOrder = 'valueFirst' | 'labelFirst';
type PlatformStatGridSurface = 'soft' | 'plain';
type PlatformStatGridTextAlign = 'left' | 'center';
export type PlatformStatGridItem = {
label?: ReactNode;
value: ReactNode;
key?: string;
};
type PlatformStatGridProps = {
items: readonly PlatformStatGridItem[];
columns?: PlatformStatGridColumns;
density?: PlatformStatGridDensity;
order?: PlatformStatGridOrder;
surface?: PlatformStatGridSurface;
textAlign?: PlatformStatGridTextAlign;
className?: string;
itemClassName?:
| string
| ((item: PlatformStatGridItem, index: number) => string | null);
};
const PLATFORM_STAT_GRID_COLUMNS_CLASS: Record<
PlatformStatGridColumns,
string
> = {
two: 'grid-cols-2',
three: 'grid-cols-3',
four: 'grid-cols-4',
twoToFour: 'grid-cols-2 sm:grid-cols-4',
};
const PLATFORM_STAT_GRID_DENSITY_CLASS: Record<
PlatformStatGridDensity,
{ item: string; value: string; label: string }
> = {
compact: {
item: 'rounded-[1rem] px-2 py-2',
value: 'text-sm font-black',
label: 'mt-1 text-[0.68rem] font-bold tracking-[0.14em]',
},
default: {
item: 'rounded-[1rem] px-3 py-3',
value: 'text-lg font-black',
label: 'mt-1 text-[11px] font-bold tracking-[0.14em]',
},
};
const PLATFORM_STAT_GRID_SURFACE_CLASS: Record<
PlatformStatGridSurface,
string
> = {
soft: 'bg-white/76',
plain: 'border border-[var(--platform-subpanel-border)] bg-white/68',
};
const PLATFORM_STAT_GRID_TEXT_ALIGN_CLASS: Record<
PlatformStatGridTextAlign,
string
> = {
left: 'text-left',
center: 'text-center',
};
/**
* 平台统计小卡网格。
* 统一承接结果页里的“数值 / 标签”或轻量状态 chip 布局。
*/
export function PlatformStatGrid({
items,
columns = 'three',
density = 'default',
order = 'valueFirst',
surface = 'soft',
textAlign = 'center',
className,
itemClassName,
}: PlatformStatGridProps) {
const densityClass = PLATFORM_STAT_GRID_DENSITY_CLASS[density];
return (
<div
className={[
'grid gap-2',
PLATFORM_STAT_GRID_COLUMNS_CLASS[columns],
PLATFORM_STAT_GRID_TEXT_ALIGN_CLASS[textAlign],
className,
]
.filter(Boolean)
.join(' ')}
>
{items.map((item, index) => {
const extraClassName =
typeof itemClassName === 'function'
? itemClassName(item, index)
: itemClassName;
const key =
item.key ??
(typeof item.label === 'string'
? item.label
: typeof item.value === 'string'
? item.value
: index);
const valueNode = (
<div
className={[
densityClass.value,
'text-[var(--platform-text-strong)]',
].join(' ')}
>
{item.value}
</div>
);
const labelNode = item.label ? (
<div
className={[
densityClass.label,
'text-[var(--platform-text-soft)]',
].join(' ')}
>
{item.label}
</div>
) : null;
return (
<div
key={key}
className={[
densityClass.item,
PLATFORM_STAT_GRID_SURFACE_CLASS[surface],
extraClassName,
]
.filter(Boolean)
.join(' ')}
>
{order === 'labelFirst' ? (
<>
{labelNode}
{valueNode}
</>
) : (
<>
{valueNode}
{labelNode}
</>
)}
</div>
);
})}
</div>
);
}

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