Merge remote-tracking branch 'origin/master' into codex/editor-asset-library
# Conflicts: # server-rs/crates/spacetime-client/src/lib.rs # server-rs/crates/spacetime-client/src/mapper.rs # server-rs/crates/spacetime-client/src/module_bindings.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CompanionRenderState,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type EquipmentLoadout,
|
||||
type GameState,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { AdventureEntityModal } from './AdventureEntityModal';
|
||||
@@ -87,6 +89,66 @@ function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayerCharacter(): Character {
|
||||
return {
|
||||
id: 'player-1',
|
||||
name: '潮刃客',
|
||||
title: '试剑者',
|
||||
description: '测试主角',
|
||||
backstory: '测试背景',
|
||||
personality: '冷静',
|
||||
avatar: '',
|
||||
portrait: '',
|
||||
assetFolder: '',
|
||||
assetVariant: '',
|
||||
attributes: {
|
||||
strength: 5,
|
||||
agility: 5,
|
||||
intelligence: 5,
|
||||
spirit: 5,
|
||||
},
|
||||
skills: [
|
||||
{
|
||||
id: 'tide-slash',
|
||||
name: '潮刃突进',
|
||||
animation: AnimationState.ATTACK,
|
||||
damage: 16,
|
||||
manaCost: 5,
|
||||
cooldownTurns: 2,
|
||||
range: 1,
|
||||
style: 'burst',
|
||||
buildBuffs: [
|
||||
{
|
||||
id: 'wet-mark',
|
||||
sourceType: 'skill',
|
||||
sourceId: 'tide-slash',
|
||||
name: '潮湿',
|
||||
tags: ['控制', '潮汐'],
|
||||
durationTurns: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createCompanionRenderState(
|
||||
character: Character,
|
||||
): CompanionRenderState {
|
||||
return {
|
||||
npcId: 'companion-1',
|
||||
character,
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
skillCooldowns: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
slot: 'upper',
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -169,3 +231,226 @@ test('NPC 背包物品空 id 会被规范成稳定渲染 id', () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('物品空态复用暗色 PlatformEmptyState chrome', () => {
|
||||
render(
|
||||
<AdventureEntityModal
|
||||
selection={{ kind: 'player' }}
|
||||
gameState={createGameState({
|
||||
playerCharacter: createPlayerCharacter(),
|
||||
playerInventory: [],
|
||||
})}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emptyState = screen.getByText('暂无物品');
|
||||
const attributeSection = screen.getByText('属性').closest('section');
|
||||
const itemSection = screen.getByText('物品').closest('section');
|
||||
|
||||
expect(emptyState.className).toContain('platform-empty-state');
|
||||
expect(emptyState.className).toContain('border-dashed');
|
||||
expect(emptyState.className).toContain('bg-black/20');
|
||||
expect(attributeSection?.className).toContain('border-white/10');
|
||||
expect(attributeSection?.className).toContain('bg-black/25');
|
||||
expect(itemSection?.className).toContain('border-white/10');
|
||||
expect(itemSection?.className).toContain('bg-black/25');
|
||||
|
||||
const levelPanel = screen.getByTestId('player-level-panel');
|
||||
|
||||
expect(levelPanel.className).toContain('border-amber-300/18');
|
||||
expect(levelPanel.className).toContain('bg-amber-500/8');
|
||||
expect(levelPanel.className).toContain('rounded-xl');
|
||||
});
|
||||
|
||||
test('最近回响纯展示小卡复用暗色 PlatformSubpanel chrome', () => {
|
||||
render(
|
||||
<AdventureEntityModal
|
||||
selection={{ kind: 'player' }}
|
||||
gameState={createGameState({
|
||||
playerCharacter: createPlayerCharacter(),
|
||||
playerInventory: [
|
||||
{
|
||||
id: 'echo-shell',
|
||||
category: '材料',
|
||||
name: '回声贝壳',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: [],
|
||||
runtimeMetadata: {
|
||||
origin: 'procedural',
|
||||
generationChannel: 'discovery',
|
||||
seedKey: 'echo-shell-seed',
|
||||
sourceReason: '测试最近回响载体',
|
||||
storyFingerprint: {
|
||||
visibleClue: '贝壳里仍有潮声回响',
|
||||
witnessMark: '潮痕',
|
||||
unresolvedQuestion: '潮声为何未散',
|
||||
currentAppearanceReason: '被最近回响唤醒',
|
||||
relatedThreadIds: [],
|
||||
relatedScarIds: [],
|
||||
reactionHooks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
currentScenePreset: {
|
||||
narrativeResidues: [
|
||||
{
|
||||
id: 'residue-1',
|
||||
title: '墙上残痕',
|
||||
visibleClue: '刻着潮汐暗号。',
|
||||
},
|
||||
],
|
||||
} as unknown as GameState['currentScenePreset'],
|
||||
storyEngineMemory: {
|
||||
chronicle: [
|
||||
{
|
||||
id: 'chronicle-1',
|
||||
title: '潮声编年',
|
||||
summary: '潮声把旧约刻回墙面。',
|
||||
},
|
||||
],
|
||||
recentCarrierIds: ['echo-shell'],
|
||||
consequenceLedger: [
|
||||
{
|
||||
id: 'consequence-1',
|
||||
title: '旧约后果',
|
||||
summary: '盟约开始反噬。',
|
||||
relatedIds: ['player-1'],
|
||||
},
|
||||
],
|
||||
} as unknown as GameState['storyEngineMemory'],
|
||||
})}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
[
|
||||
'recent-consequence-echo',
|
||||
'recent-chronicle-echo',
|
||||
'recent-carrier-echo',
|
||||
'recent-scene-residue-echo',
|
||||
].forEach((testId) => {
|
||||
const panel = screen.getByTestId(testId);
|
||||
|
||||
expect(panel.className).toContain('border-white/10');
|
||||
expect(panel.className).toContain('bg-black/25');
|
||||
expect(panel.className).toContain('rounded-xl');
|
||||
});
|
||||
});
|
||||
|
||||
test('私聊和队友收束复用暗色 tint PlatformSubpanel chrome', () => {
|
||||
const companionCharacter = createPlayerCharacter();
|
||||
|
||||
render(
|
||||
<AdventureEntityModal
|
||||
selection={{
|
||||
kind: 'companion',
|
||||
companion: createCompanionRenderState(companionCharacter),
|
||||
}}
|
||||
gameState={createGameState({
|
||||
companions: [
|
||||
{
|
||||
npcId: 'companion-1',
|
||||
characterId: companionCharacter.id,
|
||||
joinedAtAffinity: 100,
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
npcStates: {
|
||||
'companion-1': {
|
||||
affinity: 100,
|
||||
relationState: { affinity: 100, stance: 'bonded' },
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: true,
|
||||
revealedFacts: [],
|
||||
knownAttributeRumors: [],
|
||||
firstMeaningfulContactResolved: true,
|
||||
seenBackstoryChapterIds: [],
|
||||
},
|
||||
},
|
||||
storyEngineMemory: {
|
||||
companionResolutions: [
|
||||
{
|
||||
characterId: companionCharacter.id,
|
||||
resolutionType: 'bonded',
|
||||
summary: '潮声与同行者完成誓约。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
},
|
||||
],
|
||||
} as unknown as GameState['storyEngineMemory'],
|
||||
})}
|
||||
onClose={() => undefined}
|
||||
onOpenCharacterChat={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const privateChatPanel = screen.getByTestId('private-chat-panel');
|
||||
const companionResolutionEcho = screen.getByTestId(
|
||||
'companion-resolution-echo',
|
||||
);
|
||||
const privateChatButton = screen.getByRole('button', { name: '聊天' });
|
||||
|
||||
expect(privateChatPanel.className).toContain('border-sky-400/18');
|
||||
expect(privateChatPanel.className).toContain('bg-sky-500/8');
|
||||
expect(privateChatPanel.className).toContain('rounded-[1.35rem]');
|
||||
expect(companionResolutionEcho.className).toContain('border-emerald-400/18');
|
||||
expect(companionResolutionEcho.className).toContain('bg-emerald-500/8');
|
||||
expect(companionResolutionEcho.className).toContain('rounded-xl');
|
||||
expect(privateChatButton.className).toContain(
|
||||
'platform-action-button--editor-dark',
|
||||
);
|
||||
expect(privateChatButton.className).toContain('rounded-xl');
|
||||
expect(privateChatButton.className).toContain('bg-sky-400/15');
|
||||
expect(privateChatButton.className).toContain('disabled:bg-black/20');
|
||||
});
|
||||
|
||||
test('技能详情静态标签复用暗色 PlatformPillBadge chrome', () => {
|
||||
render(
|
||||
<AdventureEntityModal
|
||||
selection={{ kind: 'player' }}
|
||||
gameState={createGameState({
|
||||
playerCharacter: createPlayerCharacter(),
|
||||
})}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /潮刃突进/u }));
|
||||
|
||||
const skillPanel = screen
|
||||
.getByText('技能详情')
|
||||
.closest('.pixel-modal-shell') as HTMLElement;
|
||||
|
||||
const deliveryBadge = within(skillPanel).getAllByText('近战')[0]!;
|
||||
const styleBadge = within(skillPanel).getAllByText('爆发')[0]!;
|
||||
const buffSummaryBadge = within(skillPanel).getByText('附带 1 个状态标签');
|
||||
const buffBadge = within(skillPanel).getByText('潮湿 / 控制、潮汐 / 2 回合');
|
||||
const damagePanel = within(skillPanel)
|
||||
.getByText('伤害')
|
||||
.closest('section') as HTMLElement;
|
||||
const descriptionPanel = within(skillPanel)
|
||||
.getByText(/潮刃突进 属于爆发路线/u)
|
||||
.closest('section') as HTMLElement;
|
||||
const buffPanel = within(skillPanel)
|
||||
.getByText('附带状态标签')
|
||||
.closest('section') as HTMLElement;
|
||||
|
||||
expect(deliveryBadge.className).toContain('bg-white/6');
|
||||
expect(styleBadge.className).toContain('bg-sky-500/10');
|
||||
expect(buffSummaryBadge.className).toContain('bg-emerald-500/10');
|
||||
expect(buffBadge.className).toContain('rounded-full');
|
||||
expect(buffBadge.className).toContain('bg-sky-500/10');
|
||||
expect(damagePanel.className).toContain('bg-black/25');
|
||||
expect(damagePanel.className).toContain('border-white/10');
|
||||
expect(descriptionPanel.className).toContain('bg-black/25');
|
||||
expect(buffPanel.className).toContain('bg-black/25');
|
||||
});
|
||||
|
||||
@@ -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,10 @@ import {
|
||||
PlayerLevelProgress,
|
||||
StatusRow,
|
||||
} from './CharacterInfoShared';
|
||||
import { PlatformEmptyState } from './common/PlatformEmptyState';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
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 +131,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 +244,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 +766,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 +1089,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">
|
||||
@@ -1084,8 +1107,9 @@ export function AdventureEntityModal({
|
||||
: `好感达到 ${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 后解锁,当前 ${companionNpcState?.affinity ?? 0}。`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="ghost"
|
||||
disabled={
|
||||
!privateChatUnlocked || !onOpenCharacterChat
|
||||
}
|
||||
@@ -1099,18 +1123,14 @@ export function AdventureEntityModal({
|
||||
onClose();
|
||||
onOpenCharacterChat(companionChatTarget);
|
||||
}}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium transition-colors ${
|
||||
privateChatUnlocked && onOpenCharacterChat
|
||||
? 'border border-sky-300/40 bg-sky-400/15 text-sky-50 hover:bg-sky-400/22'
|
||||
: 'cursor-not-allowed border border-white/8 bg-black/20 text-zinc-500'
|
||||
}`}
|
||||
className="rounded-xl border-sky-300/40 bg-sky-400/15 text-sky-50 hover:bg-sky-400/22 disabled:cursor-not-allowed disabled:border-white/8 disabled:bg-black/20 disabled:text-zinc-500 disabled:opacity-100"
|
||||
>
|
||||
{privateChatUnlocked
|
||||
? '聊天'
|
||||
: `聊天(${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 解锁)`}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
@@ -1122,40 +1142,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 +1197,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 +1242,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 +1261,7 @@ export function AdventureEntityModal({
|
||||
normalizedPlayerProgression.xpToNextLevel
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<StatusRow
|
||||
@@ -1273,7 +1323,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 +1376,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 +1440,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 +1494,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 +1516,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>
|
||||
|
||||
30
src/components/AffinityStatusCard.test.tsx
Normal file
30
src/components/AffinityStatusCard.test.tsx
Normal 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');
|
||||
});
|
||||
@@ -2,8 +2,12 @@ import {
|
||||
AFFINITY_PROGRESS_MARKERS,
|
||||
AFFINITY_PROGRESS_MAX,
|
||||
AFFINITY_PROGRESS_MIN,
|
||||
type AffinityLevelId,
|
||||
getAffinityLevelMeta,
|
||||
} from '../data/affinityLevels';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import type { PlatformPillBadgeTone } from './common/platformPillBadgeModel';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
|
||||
type AffinityProgressMarker = (typeof AFFINITY_PROGRESS_MARKERS)[number];
|
||||
|
||||
@@ -45,6 +49,16 @@ function isMarkerReached(marker: AffinityProgressMarker, affinity: number) {
|
||||
return affinity >= marker.value;
|
||||
}
|
||||
|
||||
function getAffinityLevelBadgeTone(
|
||||
levelId: AffinityLevelId,
|
||||
): PlatformPillBadgeTone {
|
||||
if (levelId === 'hostile' || levelId === 'close') return 'darkRose';
|
||||
if (levelId === 'guarded') return 'darkSoft';
|
||||
if (levelId === 'friendly') return 'darkEmerald';
|
||||
if (levelId === 'trusted') return 'darkAmber';
|
||||
return 'darkSky';
|
||||
}
|
||||
|
||||
export function AffinityStatusCard({ affinity }: { affinity: number }) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
const nextLevel = getNextAffinityMarker(affinity);
|
||||
@@ -69,18 +83,20 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<PlatformSubpanel surface="dark" radius="xs" padding="sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
好感等级
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}
|
||||
<PlatformPillBadge
|
||||
tone={getAffinityLevelBadgeTone(currentLevel.id)}
|
||||
size="xxs"
|
||||
className="tracking-[0.16em]"
|
||||
>
|
||||
{currentLevel.label}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
当前好感 {affinity}
|
||||
</span>
|
||||
@@ -107,9 +123,14 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{currentLevel.description}
|
||||
</p>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
|
||||
<PlatformSubpanel
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="sm"
|
||||
className="sm:p-4"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
好感进度
|
||||
</div>
|
||||
@@ -215,7 +236,7 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
87
src/components/BackstoryArchive.test.tsx
Normal file
87
src/components/BackstoryArchive.test.tsx
Normal 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');
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import { PlatformEmptyState } from './common/PlatformEmptyState';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
|
||||
export type BackstoryUnlockedChapter = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -38,58 +42,71 @@ export function BackstoryArchive({
|
||||
</div>
|
||||
|
||||
{publicSummary ? (
|
||||
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3">
|
||||
<PlatformSubpanel surface="dark" radius="xs" padding="sm">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
公开印象
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-200">
|
||||
{publicSummary}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
) : null}
|
||||
|
||||
{unlockedChapters.map((chapter) => (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
key={`unlocked-backstory-${chapter.id}`}
|
||||
className="rounded-xl border border-amber-300/18 bg-amber-500/[0.06] px-4 py-3"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="sm"
|
||||
className="border-amber-300/18 bg-amber-500/[0.06]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{chapter.title}
|
||||
</div>
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-400/10 px-2 py-0.5 text-[10px] tracking-[0.14em] text-amber-100">
|
||||
<PlatformPillBadge
|
||||
tone="darkAmber"
|
||||
size="xxs"
|
||||
className="px-2 py-0.5 tracking-[0.14em]"
|
||||
>
|
||||
已解锁
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-200">
|
||||
{chapter.content}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
|
||||
{lockedChapters.map((chapter) => (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
key={`locked-backstory-${chapter.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-zinc-200">
|
||||
{chapter.title}
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] tracking-[0.14em] text-zinc-400">
|
||||
<PlatformPillBadge
|
||||
tone="darkNeutral"
|
||||
size="xxs"
|
||||
className="px-2 py-0.5 tracking-[0.14em]"
|
||||
>
|
||||
需好感 {chapter.affinityRequired}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
{chapter.teaser}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
|
||||
{!publicSummary && totalChapters === 0 ? (
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm text-zinc-500">
|
||||
<PlatformEmptyState surface="editorDark" size="compact" tone="soft">
|
||||
暂无可整理的背景线索。
|
||||
</div>
|
||||
</PlatformEmptyState>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
137
src/components/CharacterChatModal.test.tsx
Normal file
137
src/components/CharacterChatModal.test.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
|
||||
import type { Character } from '../types';
|
||||
import { CharacterChatModal } from './CharacterChatModal';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '冷静谨慎',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function createModalState(
|
||||
overrides: Partial<CharacterChatModalState> = {},
|
||||
): CharacterChatModalState {
|
||||
return {
|
||||
target: {
|
||||
character: createCharacter(),
|
||||
npcId: 'npc-hero',
|
||||
roleLabel: '队友',
|
||||
hp: 80,
|
||||
maxHp: 100,
|
||||
mana: 24,
|
||||
maxMana: 30,
|
||||
},
|
||||
draft: '',
|
||||
messages: [],
|
||||
suggestions: ['先问问线索'],
|
||||
summary: '',
|
||||
isSending: false,
|
||||
isLoadingSuggestions: false,
|
||||
error: '暂时无法生成回复。',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('角色聊天错误提示复用暗色 PlatformStatusMessage chrome', () => {
|
||||
render(
|
||||
<CharacterChatModal
|
||||
modal={createModalState()}
|
||||
onClose={vi.fn()}
|
||||
onDraftChange={vi.fn()}
|
||||
onUseSuggestion={vi.fn()}
|
||||
onRefreshSuggestions={vi.fn()}
|
||||
onSendDraft={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const errorMessage = screen.getByText('暂时无法生成回复。');
|
||||
|
||||
expect(errorMessage.className).toContain('platform-status-message');
|
||||
expect(errorMessage.className).toContain('border-amber-300/15');
|
||||
expect(errorMessage.className).toContain('bg-amber-500/10');
|
||||
expect(errorMessage.className).toContain('text-amber-50/90');
|
||||
});
|
||||
|
||||
test('角色聊天状态、空态和建议复用暗色 UI Kit chrome', () => {
|
||||
render(
|
||||
<CharacterChatModal
|
||||
modal={createModalState()}
|
||||
onClose={vi.fn()}
|
||||
onDraftChange={vi.fn()}
|
||||
onUseSuggestion={vi.fn()}
|
||||
onRefreshSuggestions={vi.fn()}
|
||||
onSendDraft={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hpStatus = screen.getByText('生命值 80 / 100');
|
||||
const summaryFallback = screen.getByText('你们还没有形成新的私下聊天总结。');
|
||||
const emptyHistory = screen.getByText(
|
||||
'这里会保留你和该角色的私下聊天记录。输入框支持自由发挥,上方三条文本可以帮你快速起句。',
|
||||
);
|
||||
const refreshButton = screen.getByRole('button', { name: '换一组' });
|
||||
const suggestionButton = screen.getByRole('button', { name: '先问问线索' });
|
||||
const draftTextarea = screen.getByPlaceholderText('对沈行说点什么...');
|
||||
|
||||
expect(hpStatus.className).toContain('border-white/10');
|
||||
expect(hpStatus.className).toContain('bg-black/25');
|
||||
expect(summaryFallback.className).toContain('border-white/10');
|
||||
expect(summaryFallback.className).toContain('bg-black/25');
|
||||
expect(emptyHistory.className).toContain('platform-empty-state');
|
||||
expect(emptyHistory.className).toContain('border-dashed');
|
||||
expect(refreshButton.className).toContain(
|
||||
'platform-action-button--editor-dark',
|
||||
);
|
||||
expect(refreshButton.className).toContain('text-[10px]');
|
||||
expect(suggestionButton.className).toContain('platform-dark-option-card');
|
||||
expect(suggestionButton.className).toContain('border-white/8');
|
||||
expect(draftTextarea.className).toContain('platform-text-field--editor-dark');
|
||||
expect(draftTextarea.className).toContain('focus:border-sky-300/35');
|
||||
});
|
||||
|
||||
test('角色聊天标题栏内联关闭按钮保持共享关闭行为', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<CharacterChatModal
|
||||
modal={createModalState()}
|
||||
onClose={onClose}
|
||||
onDraftChange={vi.fn()}
|
||||
onUseSuggestion={vi.fn()}
|
||||
onRefreshSuggestions={vi.fn()}
|
||||
onSendDraft={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: '关闭角色聊天' });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(closeButton.className).toContain('relative');
|
||||
expect(closeButton.className).toContain('shrink-0');
|
||||
expect(closeButton.getAttribute('title')).toBe('关闭角色聊天');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
119
src/components/CharacterDetailModal.test.tsx
Normal file
119
src/components/CharacterDetailModal.test.tsx
Normal 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]');
|
||||
}
|
||||
});
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
CharacterAttributeGrid,
|
||||
CharacterSkillsList,
|
||||
} from './CharacterInfoShared';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
|
||||
@@ -97,9 +99,12 @@ function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={`${item.slot}-${item.item}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
surface="dark"
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{item.slot}
|
||||
@@ -108,7 +113,7 @@ function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
|
||||
{item.item}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{item.rarity}</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -118,9 +123,12 @@ function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={`${item.category}-${item.name}-${item.quantity}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
surface="dark"
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{item.category}
|
||||
@@ -131,7 +139,7 @@ function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
数量 x{item.quantity}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -203,7 +211,9 @@ export function CharacterDetailModal({
|
||||
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||||
{character.visual ? (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)}
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
|
||||
character.visual,
|
||||
)}
|
||||
scale={2.08}
|
||||
/>
|
||||
) : (
|
||||
@@ -216,17 +226,25 @@ export function CharacterDetailModal({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">
|
||||
<PlatformPillBadge
|
||||
tone="darkSky"
|
||||
size="sm"
|
||||
className="mt-3 tracking-[0.18em]"
|
||||
>
|
||||
候选人
|
||||
</div>
|
||||
</PlatformPillBadge>
|
||||
<div className="mt-3 text-base font-bold text-white">
|
||||
{character.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
<span>{character.title}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
<PlatformPillBadge
|
||||
tone="darkNeutral"
|
||||
size="xxs"
|
||||
className="text-[9px] tracking-[0.18em]"
|
||||
>
|
||||
性别: {getGenderLabel(character.gender)}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.description}
|
||||
@@ -262,18 +280,28 @@ export function CharacterDetailModal({
|
||||
{opening && (
|
||||
<Section title="旅程">
|
||||
<div className="space-y-2 text-sm leading-relaxed text-zinc-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
原因
|
||||
</div>
|
||||
<div className="mt-1">{opening.reason}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
</PlatformSubpanel>
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
目标
|
||||
</div>
|
||||
<div className="mt-1">{opening.goal}</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
@@ -293,15 +321,27 @@ export function CharacterDetailModal({
|
||||
</Section>
|
||||
|
||||
<Section title="背景">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="sm"
|
||||
padding="md"
|
||||
className="text-sm leading-relaxed text-zinc-300"
|
||||
>
|
||||
{character.backstory}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</Section>
|
||||
|
||||
<Section title="性格">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="sm"
|
||||
padding="md"
|
||||
className="text-sm leading-relaxed text-zinc-300"
|
||||
>
|
||||
{character.personality}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,13 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BuildContributionRow } from '../data/buildDamage';
|
||||
import { AnimationState, type Character } from '../types';
|
||||
import {
|
||||
BuildContributionDetailPanel,
|
||||
CharacterIdentityBadges,
|
||||
CharacterSkillsList,
|
||||
MultiplierContributionList,
|
||||
PlayerLevelProgress,
|
||||
} from './CharacterInfoShared';
|
||||
|
||||
@@ -31,6 +34,19 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function findNearestClassName(element: HTMLElement, className: string) {
|
||||
let current: HTMLElement | null = element;
|
||||
|
||||
while (current) {
|
||||
if (current.className.includes(className)) {
|
||||
return current.className;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
test('CharacterSkillsList falls back to stable render ids when skill ids are empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSelectSkill = vi.fn();
|
||||
@@ -49,9 +65,16 @@ test('CharacterSkillsList falls back to stable render ids when skill ids are emp
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons[0]?.className).toContain('bg-black/25');
|
||||
expect(buttons[0]?.className).toContain('hover:border-sky-300/25');
|
||||
|
||||
await user.click(buttons[0]!);
|
||||
await user.click(buttons[1]!);
|
||||
|
||||
const deliveryBadge = screen.getAllByText('近战')[0]!;
|
||||
expect(deliveryBadge.className).toContain('rounded-full');
|
||||
expect(deliveryBadge.className).toContain('bg-white/6');
|
||||
|
||||
expect(handleSelectSkill).toHaveBeenNthCalledWith(1, 'skill-潮刃突进-0');
|
||||
expect(handleSelectSkill).toHaveBeenNthCalledWith(2, 'skill-雾行转位-1');
|
||||
|
||||
@@ -66,6 +89,27 @@ test('CharacterSkillsList falls back to stable render ids when skill ids are emp
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('CharacterSkillsList empty state reuses dark PlatformEmptyState chrome', () => {
|
||||
render(<CharacterSkillsList skills={[]} emptyText="暂未掌握技能" />);
|
||||
|
||||
const emptyState = screen.getByText('暂未掌握技能');
|
||||
|
||||
expect(emptyState.className).toContain('platform-empty-state');
|
||||
expect(emptyState.className).toContain('bg-black/20');
|
||||
expect(emptyState.className).toContain('border-dashed');
|
||||
});
|
||||
|
||||
test('CharacterSkillsList readonly cards reuse dark PlatformSubpanel chrome', () => {
|
||||
render(<CharacterSkillsList skills={[createSkill('潮刃突进', 'burst')]} />);
|
||||
|
||||
const skillCardClassName = findNearestClassName(
|
||||
screen.getByText('潮刃突进'),
|
||||
'bg-black/25',
|
||||
);
|
||||
|
||||
expect(skillCardClassName).toContain('border-white/5');
|
||||
});
|
||||
|
||||
test('CharacterIdentityBadges renders role and level chips together', () => {
|
||||
render(
|
||||
<CharacterIdentityBadges
|
||||
@@ -77,6 +121,98 @@ test('CharacterIdentityBadges renders role and level chips together', () => {
|
||||
|
||||
expect(screen.getByText('队长')).toBeTruthy();
|
||||
expect(screen.getByText('Lv.7')).toBeTruthy();
|
||||
expect(screen.getByText('队长').className).toContain('bg-amber-500/10');
|
||||
expect(screen.getByText('Lv.7').className).toContain('bg-black/20');
|
||||
});
|
||||
|
||||
test('MultiplierContributionList empty state reuses dark platform pill badge', () => {
|
||||
render(
|
||||
<MultiplierContributionList
|
||||
breakdown={{
|
||||
tags: [],
|
||||
baseTagCount: 0,
|
||||
buildDamageBonus: 0,
|
||||
buildDamageMultiplier: 1,
|
||||
rows: [],
|
||||
}}
|
||||
onSelectContribution={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panelClassName = findNearestClassName(
|
||||
screen.getByText('状态标签'),
|
||||
'bg-sky-500/8',
|
||||
);
|
||||
const emptyBadge = screen.getByText('当前还没有形成有效标签');
|
||||
|
||||
expect(panelClassName).toContain('border-sky-400/18');
|
||||
expect(panelClassName).toContain('rounded-xl');
|
||||
expect(emptyBadge.className).toContain('rounded-full');
|
||||
expect(emptyBadge.className).toContain('bg-black/20');
|
||||
});
|
||||
|
||||
test('BuildContributionDetailPanel reuses dark PlatformSubpanel chrome', () => {
|
||||
const row: BuildContributionRow = {
|
||||
label: '潮汐',
|
||||
source: 'character',
|
||||
fitScore: 0.72,
|
||||
sourceCoefficient: 1,
|
||||
bonusDelta: 0.12,
|
||||
attributeSimilarities: {},
|
||||
attributeWeights: {},
|
||||
attributeContributions: {},
|
||||
attributeModifierDeltas: { axis_a: 0.12 },
|
||||
};
|
||||
|
||||
render(
|
||||
<BuildContributionDetailPanel
|
||||
row={row}
|
||||
attributes={[
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
label: '武力',
|
||||
similarity: 0.8,
|
||||
weight: 1,
|
||||
value: 0.8,
|
||||
modifierDelta: 0.12,
|
||||
percent: 12,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const overviewPanel = screen.getByText('标签概览').closest('section');
|
||||
const attributePanel = screen.getByText('属性加成').closest('section');
|
||||
const attributeRow = screen.getByText('武力').closest('section');
|
||||
|
||||
expect(screen.getByText('潮汐')).toBeTruthy();
|
||||
expect(screen.getByText('总加成 +12.0%')).toBeTruthy();
|
||||
expect(screen.getByText('+12.0%')).toBeTruthy();
|
||||
expect(overviewPanel?.className).toContain('bg-black/25');
|
||||
expect(attributePanel?.className).toContain('bg-black/25');
|
||||
expect(attributeRow?.className).toContain('bg-black/25');
|
||||
});
|
||||
|
||||
test('BuildContributionDetailPanel empty state reuses dark PlatformEmptyState chrome', () => {
|
||||
const row: BuildContributionRow = {
|
||||
label: '潮汐',
|
||||
source: 'character',
|
||||
fitScore: 0.72,
|
||||
sourceCoefficient: 1,
|
||||
bonusDelta: 0.12,
|
||||
attributeSimilarities: {},
|
||||
attributeWeights: {},
|
||||
attributeContributions: {},
|
||||
attributeModifierDeltas: {},
|
||||
};
|
||||
|
||||
render(<BuildContributionDetailPanel row={row} attributes={[]} />);
|
||||
|
||||
const emptyState = screen.getByText('当前标签还没有可展示的属性适配明细。');
|
||||
|
||||
expect(emptyState.className).toContain('platform-empty-state');
|
||||
expect(emptyState.className).toContain('border-dashed');
|
||||
expect(emptyState.className).toContain('bg-black/20');
|
||||
});
|
||||
|
||||
test('PlayerLevelProgress renders xp progress details', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveRoleCombatStats } from '../data/attributeCombat';
|
||||
import { getAttributeSlotValue } from '../data/attributeResolver';
|
||||
import {
|
||||
type BuildContributionAttributeRow,
|
||||
type BuildDamageBreakdown,
|
||||
formatBuildContributionPercent,
|
||||
getBuildContributionQualityLabel,
|
||||
@@ -21,6 +22,10 @@ import {
|
||||
getSkillDeliveryLabel,
|
||||
getSkillStyleLabel,
|
||||
} from './CharacterInfoHelpers';
|
||||
import { PlatformEmptyState } from './common/PlatformEmptyState';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import type { PlatformPillBadgeTone } from './common/platformPillBadgeModel';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
|
||||
export function StatusRow({
|
||||
label,
|
||||
@@ -68,28 +73,34 @@ export function CharacterIdentityBadges({
|
||||
roleTone?: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc';
|
||||
className?: string;
|
||||
}) {
|
||||
const roleClass =
|
||||
const roleBadgeTone: PlatformPillBadgeTone =
|
||||
roleTone === 'amber'
|
||||
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
|
||||
? 'darkAmber'
|
||||
: roleTone === 'rose'
|
||||
? 'border-rose-300/20 bg-rose-500/10 text-rose-100'
|
||||
? 'darkRose'
|
||||
: roleTone === 'emerald'
|
||||
? 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
|
||||
? 'darkEmerald'
|
||||
: roleTone === 'zinc'
|
||||
? 'border-white/10 bg-black/20 text-zinc-200'
|
||||
: 'border-sky-300/20 bg-sky-500/10 text-sky-100';
|
||||
? 'darkNeutral'
|
||||
: 'darkSky';
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${roleClass}`}
|
||||
<PlatformPillBadge
|
||||
tone={roleBadgeTone}
|
||||
size="xxs"
|
||||
className="tracking-[0.16em]"
|
||||
>
|
||||
{roleLabel}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
{levelText ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] tracking-[0.16em] text-zinc-200">
|
||||
<PlatformPillBadge
|
||||
tone="darkNeutral"
|
||||
size="xxs"
|
||||
className="tracking-[0.16em]"
|
||||
>
|
||||
{levelText}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -112,10 +123,7 @@ export function PlayerLevelProgress({
|
||||
const ratio =
|
||||
safeXpToNextLevel <= 0
|
||||
? 1
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(1, safeCurrentLevelXp / safeXpToNextLevel),
|
||||
);
|
||||
: Math.max(0, Math.min(1, safeCurrentLevelXp / safeXpToNextLevel));
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -150,9 +158,9 @@ export function CharacterSkillsList({
|
||||
}) {
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500">
|
||||
<PlatformEmptyState surface="editorDark" size="compact" tone="soft">
|
||||
{emptyText}
|
||||
</div>
|
||||
</PlatformEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,9 +172,13 @@ export function CharacterSkillsList({
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-semibold text-white">{skill.name}</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
|
||||
<PlatformPillBadge
|
||||
tone="darkSoft"
|
||||
size="xxs"
|
||||
className="px-2 py-0.5"
|
||||
>
|
||||
{getSkillDeliveryLabel(skill)}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
|
||||
<div>伤害:{skill.damage}</div>
|
||||
@@ -182,24 +194,32 @@ export function CharacterSkillsList({
|
||||
|
||||
if (onSelectSkill) {
|
||||
return (
|
||||
<button
|
||||
<PlatformSubpanel
|
||||
as="button"
|
||||
key={skillRenderId}
|
||||
type="button"
|
||||
onClick={() => onSelectSkill(skillRenderId)}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="sm"
|
||||
className="text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={skillRenderId}
|
||||
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="sm"
|
||||
className="border-white/5 bg-black/20 text-sm text-zinc-300"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -220,7 +240,13 @@ export function MultiplierContributionList({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-3">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="darkSky"
|
||||
radius="xs"
|
||||
padding="sm"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 text-[10px] uppercase tracking-[0.16em] text-sky-100/80 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span>状态标签</span>
|
||||
<span className="text-[9px] leading-4 text-zinc-400 sm:text-[10px]">
|
||||
@@ -251,10 +277,91 @@ export function MultiplierContributionList({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
|
||||
<PlatformPillBadge tone="darkNeutral" size="xxs" className="px-2">
|
||||
当前还没有形成有效标签
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
)}
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色构筑标签详情面板。
|
||||
* 统一承接队伍面板和实体详情弹窗里的标签概览、属性加成与空明细外壳。
|
||||
*/
|
||||
export function BuildContributionDetailPanel({
|
||||
row,
|
||||
attributes,
|
||||
emptyText = '当前标签还没有可展示的属性适配明细。',
|
||||
}: {
|
||||
row: ContributionRow;
|
||||
attributes: BuildContributionAttributeRow[];
|
||||
emptyText?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<PlatformSubpanel
|
||||
surface="dark"
|
||||
radius="md"
|
||||
padding="md"
|
||||
className="px-4 py-4"
|
||||
style={getContributionVisualStyle(row.bonusDelta)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-current/70">
|
||||
标签概览
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold">{row.label}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-current/15 bg-black/25 px-3 py-2 text-right">
|
||||
<div className="text-[11px] tracking-[0.14em] text-current/70">
|
||||
{getBuildContributionQualityLabel(row.bonusDelta)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold">
|
||||
总加成 {formatBuildContributionPercent(row.bonusDelta)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
|
||||
<PlatformSubpanel surface="dark" radius="md" padding="md">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
属性加成
|
||||
</div>
|
||||
|
||||
{attributes.length > 0 ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{attributes.map((attribute) => (
|
||||
<PlatformSubpanel
|
||||
key={`${row.label}-${attribute.slotId}`}
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="sm"
|
||||
className="px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
||||
<span>{attribute.label}</span>
|
||||
<span className="font-semibold text-white">
|
||||
{formatBuildContributionPercent(attribute.modifierDelta)}
|
||||
</span>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<PlatformEmptyState
|
||||
surface="editorDark"
|
||||
size="compact"
|
||||
tone="soft"
|
||||
className="mt-4"
|
||||
>
|
||||
{emptyText}
|
||||
</PlatformEmptyState>
|
||||
)}
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
172
src/components/CharacterPanel.test.tsx
Normal file
172
src/components/CharacterPanel.test.tsx
Normal 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',
|
||||
);
|
||||
});
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
type BuildDamageBreakdown,
|
||||
formatBuildContributionPercent,
|
||||
getBuildContributionAttributeRows,
|
||||
getBuildContributionQualityLabel,
|
||||
getCompanionBuildDamageBreakdown,
|
||||
getPlayerBuildDamageBreakdown,
|
||||
} from '../data/buildDamage';
|
||||
@@ -51,10 +49,10 @@ import { BackstoryArchive } from './BackstoryArchive';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
getCharacterDetailSpriteStyle,
|
||||
getContributionVisualStyle,
|
||||
getGenderLabel,
|
||||
} from './CharacterInfoHelpers';
|
||||
import {
|
||||
BuildContributionDetailPanel,
|
||||
CharacterAttributeGrid,
|
||||
CharacterIdentityBadges,
|
||||
CharacterSkillsList,
|
||||
@@ -62,6 +60,8 @@ import {
|
||||
PlayerLevelProgress,
|
||||
StatusRow,
|
||||
} from './CharacterInfoShared';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
import type { GameCanvasEntitySelection } from './GameCanvas';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
@@ -417,16 +417,24 @@ export function CharacterPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-zinc-400">
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-zinc-200">
|
||||
<PlatformPillBadge
|
||||
tone="darkNeutral"
|
||||
size="xs"
|
||||
className="px-2 py-0.5 font-normal text-zinc-200"
|
||||
>
|
||||
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0}{' '}
|
||||
标签
|
||||
</span>
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-emerald-100">
|
||||
</PlatformPillBadge>
|
||||
<PlatformPillBadge
|
||||
tone="darkEmerald"
|
||||
size="xs"
|
||||
className="px-2 py-0.5 font-normal"
|
||||
>
|
||||
{'\u9002\u914d'} x
|
||||
{buildBreakdownByMemberId[
|
||||
member.id
|
||||
]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -473,72 +481,10 @@ export function CharacterPanel({
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 sm:p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="rounded-2xl border px-4 py-4"
|
||||
style={getContributionVisualStyle(
|
||||
selectedContributionRow.bonusDelta,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-current/70">
|
||||
标签概览
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold">
|
||||
{selectedContributionRow.label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-current/15 bg-black/25 px-3 py-2 text-right">
|
||||
<div className="text-[11px] tracking-[0.14em] text-current/70">
|
||||
{getBuildContributionQualityLabel(
|
||||
selectedContributionRow.bonusDelta,
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold">
|
||||
{'\u603b\u52a0\u6210'}{' '}
|
||||
{formatBuildContributionPercent(
|
||||
selectedContributionRow.bonusDelta,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
{'\u5c5e\u6027\u52a0\u6210'}
|
||||
</div>
|
||||
|
||||
{selectedContributionAttributes.length > 0 ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{selectedContributionAttributes.map((attribute) => (
|
||||
<div
|
||||
key={`${selectedContributionRow.label}-${attribute.slotId}`}
|
||||
className="rounded-xl border border-white/8 bg-black/25 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
||||
<span>{attribute.label}</span>
|
||||
<span className="font-semibold text-white">
|
||||
{formatBuildContributionPercent(
|
||||
attribute.modifierDelta,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-6 text-zinc-400">
|
||||
{
|
||||
'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<BuildContributionDetailPanel
|
||||
row={selectedContributionRow}
|
||||
attributes={selectedContributionAttributes}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
@@ -580,9 +526,13 @@ export function CharacterPanel({
|
||||
levelText={selectedMember.levelText}
|
||||
roleTone={selectedMember.isLeader ? 'amber' : 'sky'}
|
||||
/>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
<PlatformPillBadge
|
||||
tone="darkNeutral"
|
||||
size="xxs"
|
||||
className="px-2 py-0.5 text-[9px] font-normal text-zinc-200"
|
||||
>
|
||||
{getGenderLabel(selectedMember.character.gender)}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
</div>
|
||||
<PixelCloseButton
|
||||
@@ -639,7 +589,13 @@ export function CharacterPanel({
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{selectedMember.isLeader && (
|
||||
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="darkAmber"
|
||||
radius="xs"
|
||||
padding="sm"
|
||||
data-testid="character-panel-level-progress"
|
||||
>
|
||||
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
|
||||
等级
|
||||
</div>
|
||||
@@ -652,7 +608,7 @@ export function CharacterPanel({
|
||||
normalizedPlayerProgression.xpToNextLevel
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
)}
|
||||
<StatusRow
|
||||
label={resourceLabels.hp}
|
||||
@@ -670,7 +626,13 @@ export function CharacterPanel({
|
||||
<AffinityStatusCard affinity={selectedMemberAffinity} />
|
||||
)}
|
||||
{selectedMemberArcState && (
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-300">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="row"
|
||||
className="text-xs text-zinc-300"
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
个人线阶段
|
||||
</div>
|
||||
@@ -680,10 +642,17 @@ export function CharacterPanel({
|
||||
<div className="mt-1 text-[11px] text-sky-200/85">
|
||||
{selectedMemberArcState.arcTheme}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
)}
|
||||
{selectedMemberResolution && (
|
||||
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-zinc-300">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="darkEmerald"
|
||||
radius="xs"
|
||||
padding="row"
|
||||
className="text-xs"
|
||||
data-testid="character-panel-resolution"
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-emerald-200/80">
|
||||
收束状态
|
||||
</div>
|
||||
@@ -693,7 +662,7 @@ export function CharacterPanel({
|
||||
<div className="mt-1 text-[11px] text-emerald-100/85">
|
||||
{selectedMemberResolution.summary}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
)}
|
||||
{selectedMemberAffinity != null && (
|
||||
<BackstoryArchive
|
||||
@@ -735,9 +704,15 @@ export function CharacterPanel({
|
||||
<div className="mb-3 text-xs font-bold text-white">
|
||||
背景故事
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="md"
|
||||
className="text-sm leading-relaxed text-zinc-300"
|
||||
>
|
||||
{selectedMember.character.backstory}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -748,9 +723,15 @@ export function CharacterPanel({
|
||||
<div className="mb-3 text-xs font-bold text-white">
|
||||
性格
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="md"
|
||||
className="text-sm leading-relaxed text-zinc-300"
|
||||
>
|
||||
{selectedMember.character.personality}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -774,9 +755,13 @@ export function CharacterPanel({
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-zinc-300">
|
||||
{selectedEquipmentRows.map((item) => (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={item.key}
|
||||
className="flex items-center justify-between rounded-lg border border-white/5 bg-black/20 px-3 py-2"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="row"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PixelIcon
|
||||
@@ -790,10 +775,14 @@ export function CharacterPanel({
|
||||
<div>{item.itemLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
|
||||
<PlatformPillBadge
|
||||
tone="darkAmber"
|
||||
size="xxs"
|
||||
className="px-2 py-0.5 font-normal"
|
||||
>
|
||||
{item.rarityLabel}
|
||||
</span>
|
||||
</div>
|
||||
</PlatformPillBadge>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
128
src/components/CompanionCampModal.test.tsx
Normal file
128
src/components/CompanionCampModal.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { getCharacterById } from '../data/characterPresets';
|
||||
import type { CompanionState } from '../types';
|
||||
import { CompanionCampModal } from './CompanionCampModal';
|
||||
|
||||
function createCompanion(
|
||||
overrides: Partial<CompanionState> = {},
|
||||
): CompanionState {
|
||||
return {
|
||||
npcId: 'npc-archer',
|
||||
characterId: 'archer-hero',
|
||||
joinedAtAffinity: 36,
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 18,
|
||||
maxMana: 24,
|
||||
skillCooldowns: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('营地编组战斗中提示复用暗色 PlatformStatusMessage chrome', () => {
|
||||
render(
|
||||
<CompanionCampModal
|
||||
isOpen
|
||||
playerCharacter={null}
|
||||
companions={[]}
|
||||
roster={[]}
|
||||
inBattle
|
||||
onClose={vi.fn()}
|
||||
onBenchCompanion={vi.fn()}
|
||||
onActivateCompanion={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const warning = screen.getByText('战斗中无法调整编组。');
|
||||
|
||||
expect(warning.className).toContain('platform-status-message');
|
||||
expect(warning.className).toContain('border-amber-300/15');
|
||||
expect(warning.className).toContain('bg-amber-500/10');
|
||||
expect(warning.className).toContain('mb-3');
|
||||
|
||||
const currentSection = screen.getByText('当前队伍').closest('section');
|
||||
const reserveSection = screen.getByText('后备队伍').closest('section');
|
||||
const activeEmptyState = screen.getByText('当前没有已出战的同行者。');
|
||||
const reserveEmptyState = screen.getByText('当前还没有后备同行者。');
|
||||
const activeCountBadge = screen.getByText(/^出战 0\//);
|
||||
const campFooter = screen.getByText('营地气氛').closest(
|
||||
'.platform-dark-modal-footer',
|
||||
);
|
||||
|
||||
expect(currentSection?.className).toContain('bg-black/25');
|
||||
expect(reserveSection?.className).toContain('bg-black/25');
|
||||
expect(activeEmptyState.className).toContain('platform-empty-state');
|
||||
expect(reserveEmptyState.className).toContain('platform-empty-state');
|
||||
expect(activeCountBadge.className).toContain('rounded-full');
|
||||
expect(activeCountBadge.className).toContain('bg-black/20');
|
||||
expect(campFooter?.className).toContain('border-t');
|
||||
expect(campFooter?.className).toContain('px-5');
|
||||
});
|
||||
|
||||
test('营地编组同行者卡片和替换位按钮复用暗色公共组件', async () => {
|
||||
const user = userEvent.setup();
|
||||
const playerCharacter = getCharacterById('sword-princess');
|
||||
if (!playerCharacter) {
|
||||
throw new Error('测试需要剑姬角色预设');
|
||||
}
|
||||
|
||||
render(
|
||||
<CompanionCampModal
|
||||
isOpen
|
||||
playerCharacter={playerCharacter}
|
||||
companions={[createCompanion()]}
|
||||
roster={[
|
||||
createCompanion({
|
||||
npcId: 'npc-girl',
|
||||
characterId: 'girl-hero',
|
||||
joinedAtAffinity: 72,
|
||||
}),
|
||||
]}
|
||||
inBattle={false}
|
||||
onClose={vi.fn()}
|
||||
onBenchCompanion={vi.fn()}
|
||||
onActivateCompanion={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const activeCard = screen.getByTestId('active-companion-card-npc-archer');
|
||||
const reserveCard = screen.getByTestId('reserve-companion-card-npc-girl');
|
||||
const replacementButton = screen.getByRole('button', {
|
||||
name: '设为替换位',
|
||||
});
|
||||
const benchButton = screen.getByRole('button', { name: '转入后备' });
|
||||
const activateButton = screen.getByRole('button', { name: '编入队伍' });
|
||||
const hpBadge = within(activeCard).getByText('生命 42/50');
|
||||
const activePortrait = within(activeCard).getByRole('img');
|
||||
const reservePortrait = within(reserveCard).getByRole('img');
|
||||
const activePortraitFrame = activePortrait.closest('.platform-media-frame');
|
||||
const reservePortraitFrame = reservePortrait.closest('.platform-media-frame');
|
||||
|
||||
expect(activeCard.className).toContain('bg-black/25');
|
||||
expect(reserveCard.className).toContain('bg-black/25');
|
||||
expect(activePortraitFrame?.className).toContain('border-white/10');
|
||||
expect(activePortraitFrame?.className).toContain('radial-gradient');
|
||||
expect(reservePortraitFrame?.className).toContain('platform-media-frame');
|
||||
expect(activePortrait.className).toContain('scale-125');
|
||||
expect(replacementButton.className).toContain('platform-dark-option-card');
|
||||
expect(benchButton.className).toContain(
|
||||
'platform-action-button--editor-dark',
|
||||
);
|
||||
expect(benchButton.className).toContain('bg-white/5');
|
||||
expect(activateButton.className).toContain(
|
||||
'platform-action-button--editor-dark',
|
||||
);
|
||||
expect(activateButton.className).toContain('bg-emerald-400');
|
||||
expect(hpBadge.className).toContain('rounded-full');
|
||||
|
||||
await user.click(replacementButton);
|
||||
|
||||
expect(activeCard.className).toContain('border-sky-400/18');
|
||||
expect(activeCard.className).toContain('bg-sky-500/8');
|
||||
expect(replacementButton.className).toContain('border-sky-400/45');
|
||||
});
|
||||
@@ -5,8 +5,15 @@ import { getCharacterById } from '../data/characterPresets';
|
||||
import { MAX_COMPANIONS } from '../data/npcInteractions';
|
||||
import { Character, CompanionState } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
|
||||
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
|
||||
import { PlatformEmptyState } from './common/PlatformEmptyState';
|
||||
import { PlatformMediaFrame } from './common/PlatformMediaFrame';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
interface CompanionCampModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -26,9 +33,13 @@ type CompanionCardData = {
|
||||
|
||||
function StatusPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
|
||||
<PlatformPillBadge
|
||||
tone="darkNeutral"
|
||||
size="xxs"
|
||||
className="font-normal text-zinc-300"
|
||||
>
|
||||
{label} {value}
|
||||
</div>
|
||||
</PlatformPillBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,7 +160,13 @@ export function CompanionCampModal({
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[1.05fr_0.95fr] lg:overflow-hidden">
|
||||
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
|
||||
<PlatformSubpanel
|
||||
as="section"
|
||||
surface="dark"
|
||||
radius="sm"
|
||||
padding="md"
|
||||
className="lg:min-h-0 lg:overflow-y-auto"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">当前队伍</div>
|
||||
@@ -160,28 +177,39 @@ export function CompanionCampModal({
|
||||
<StatusPill label="出战" value={`${companions.length}/${MAX_COMPANIONS}`} />
|
||||
</div>
|
||||
{inBattle && (
|
||||
<div className="mb-3 rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
<PlatformStatusMessage
|
||||
tone="warning"
|
||||
surface="editorDark"
|
||||
size="xs"
|
||||
className="mb-3"
|
||||
>
|
||||
战斗中无法调整编组。
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{activeCompanionCards.length > 0 ? activeCompanionCards.map(({ companion, character }) => {
|
||||
const selectedForSwap = selectedSwapNpcId === companion.npcId;
|
||||
return (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={companion.npcId}
|
||||
className={`rounded-xl border px-3 py-3 ${selectedForSwap ? 'border-sky-400/40 bg-sky-500/10' : 'border-white/8 bg-black/20'}`}
|
||||
data-testid={`active-companion-card-${companion.npcId}`}
|
||||
surface={selectedForSwap ? 'darkSky' : 'dark'}
|
||||
radius="xs"
|
||||
padding="md"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||||
<ResolvedAssetImage
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
className="h-full w-full scale-125 object-contain"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
</div>
|
||||
<PlatformMediaFrame
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
fallbackLabel={character.name}
|
||||
aspect="square"
|
||||
surface="editorDark"
|
||||
className="h-16 w-16 shrink-0 rounded-xl"
|
||||
imageClassName="h-full w-full scale-125 object-contain"
|
||||
imageProps={{ style: { imageRendering: 'pixelated' } }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
|
||||
@@ -193,34 +221,48 @@ export function CompanionCampModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformDarkOptionCard
|
||||
disabled={inBattle}
|
||||
onClick={() => setSelectedSwapNpcId(companion.npcId)}
|
||||
className={`rounded-lg border px-3 py-2 text-xs ${selectedForSwap ? 'border-sky-400/30 bg-sky-500/15 text-sky-100' : 'border-white/10 bg-white/5 text-zinc-200'} ${inBattle ? 'opacity-50' : ''}`}
|
||||
selected={selectedForSwap}
|
||||
tone="sky"
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
设为替换位
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</PlatformDarkOptionCard>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
disabled={inBattle}
|
||||
onClick={() => onBenchCompanion(companion.npcId)}
|
||||
className={`rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-200 ${inBattle ? 'opacity-50' : ''}`}
|
||||
>
|
||||
转入后备
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
|
||||
<PlatformEmptyState
|
||||
surface="editorDark"
|
||||
size="inline"
|
||||
className="rounded-xl py-6 font-normal text-zinc-400"
|
||||
>
|
||||
当前没有已出战的同行者。
|
||||
</div>
|
||||
</PlatformEmptyState>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
|
||||
<PlatformSubpanel
|
||||
as="section"
|
||||
surface="dark"
|
||||
radius="sm"
|
||||
padding="md"
|
||||
className="lg:min-h-0 lg:overflow-y-auto"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">后备队伍</div>
|
||||
@@ -235,16 +277,25 @@ export function CompanionCampModal({
|
||||
{reserveCompanionCards.length > 0 ? reserveCompanionCards.map(({ companion, character }) => {
|
||||
const needsSwap = companions.length >= MAX_COMPANIONS;
|
||||
return (
|
||||
<div key={companion.npcId} className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={companion.npcId}
|
||||
data-testid={`reserve-companion-card-${companion.npcId}`}
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="md"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||||
<ResolvedAssetImage
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
className="h-full w-full scale-125 object-contain"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
</div>
|
||||
<PlatformMediaFrame
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
fallbackLabel={character.name}
|
||||
aspect="square"
|
||||
surface="editorDark"
|
||||
className="h-16 w-16 shrink-0 rounded-xl"
|
||||
imageClassName="h-full w-full scale-125 object-contain"
|
||||
imageProps={{ style: { imageRendering: 'pixelated' } }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
|
||||
@@ -255,42 +306,49 @@ export function CompanionCampModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone={inBattle || (needsSwap && !selectedSwapNpcId) ? 'ghost' : 'success'}
|
||||
size="xs"
|
||||
fullWidth
|
||||
disabled={inBattle || (needsSwap && !selectedSwapNpcId)}
|
||||
onClick={() => onActivateCompanion(companion.npcId, needsSwap ? selectedSwapNpcId : null)}
|
||||
className={`mt-3 w-full rounded-lg border px-3 py-2 text-xs ${
|
||||
inBattle || (needsSwap && !selectedSwapNpcId)
|
||||
? 'border-white/6 bg-black/20 text-zinc-500'
|
||||
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
|
||||
}`}
|
||||
className="mt-3"
|
||||
>
|
||||
{needsSwap ? '换入队伍' : '编入队伍'}
|
||||
</button>
|
||||
</div>
|
||||
</PlatformActionButton>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
|
||||
<PlatformEmptyState
|
||||
surface="editorDark"
|
||||
size="inline"
|
||||
className="rounded-xl py-6 font-normal text-zinc-400"
|
||||
>
|
||||
当前还没有后备同行者。
|
||||
</div>
|
||||
</PlatformEmptyState>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<PlatformDarkModalFooter layout="content" padding="roomy">
|
||||
<div className="mb-3 text-xs font-bold text-white">营地气氛</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{campMoments.map((moment, index) => (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={`camp-moment-${index}-${moment}`}
|
||||
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="md"
|
||||
className="text-sm leading-relaxed text-zinc-300"
|
||||
>
|
||||
{moment}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformDarkModalFooter>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -17,12 +17,24 @@ 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 { PlatformAcknowledgeStatusDialog } from './common/PlatformAcknowledgeStatusDialog';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { PlatformDangerConfirmDialog } from './common/PlatformDangerConfirmDialog';
|
||||
import { PlatformEmptyState } from './common/PlatformEmptyState';
|
||||
import { PlatformMediaFrame } from './common/PlatformMediaFrame';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformProgressBar } from './common/PlatformProgressBar';
|
||||
import { PlatformSegmentedTabs } from './common/PlatformSegmentedTabs';
|
||||
import { PlatformStatGrid } from './common/PlatformStatGrid';
|
||||
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
import { PlatformTextField } from './common/PlatformTextField';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
import { ResolvedAssetVideo } from './ResolvedAssetVideo';
|
||||
@@ -120,22 +132,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 +156,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 +188,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 +204,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 +217,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 +275,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 +299,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 +387,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 +432,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 +481,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 +520,7 @@ function CatalogCard({
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -584,6 +590,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 +671,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 +834,27 @@ export function CustomWorldEntityCatalog({
|
||||
1 +
|
||||
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
|
||||
} satisfies Record<ResultTab, number>;
|
||||
const resultTabItems = useMemo(
|
||||
() =>
|
||||
RESULT_TABS.map((tab) => ({
|
||||
id: tab.id,
|
||||
ariaLabel: `${tab.label} ${counts[tab.id]}`,
|
||||
label: (
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">{tab.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{counts[tab.id]}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
[counts],
|
||||
);
|
||||
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 +879,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 +909,68 @@ 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 || confirmState.kind === 'minimum-playable') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (confirmState.kind === 'delete-playable') {
|
||||
return {
|
||||
title: '删除角色',
|
||||
confirmLabel: '确认删除',
|
||||
body: `确认删除可扮演角色「${confirmState.name}」吗?`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: '批量删除',
|
||||
confirmLabel: '确认删除',
|
||||
body: `确认批量删除 ${confirmState.ids.length} 个${confirmState.label}吗?`,
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
@@ -914,22 +991,28 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
|
||||
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm xl:rounded-[1.75rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/70 xl:px-4 xl:py-3 xl:shadow-[0_16px_48px_rgba(112,57,30,0.08)]">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
|
||||
{RESULT_TABS.map((tab) => (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActiveTabChange(tab.id)}
|
||||
className={`platform-tab px-3 py-2 text-left text-sm xl:min-w-[5.25rem] xl:px-4 xl:py-2 ${activeTab === tab.id ? 'platform-tab--active' : ''}`}
|
||||
>
|
||||
<div className="font-semibold">{tab.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{counts[tab.id]}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<PlatformSegmentedTabs
|
||||
items={resultTabItems}
|
||||
activeId={activeTab}
|
||||
onChange={onActiveTabChange}
|
||||
layout="scroll"
|
||||
gap="md"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
tone="neutral"
|
||||
semantics="tabs"
|
||||
ariaLabel="世界实体目录"
|
||||
className="pb-1 xl:pb-0"
|
||||
itemClassName={(_, active) =>
|
||||
[
|
||||
'platform-tab shrink-0 !min-h-0 !rounded-full !px-3 !py-2 xl:min-w-[5.25rem] xl:!px-4 xl:!py-2',
|
||||
active ? 'platform-tab--active' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
/>
|
||||
|
||||
{activeTab !== 'world' ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center xl:gap-3">
|
||||
@@ -943,9 +1026,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 +1066,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 +1109,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 +1150,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 +1285,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 +1432,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 +1451,26 @@ export function CustomWorldEntityCatalog({
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{confirmDialogConfig ? (
|
||||
<PlatformDangerConfirmDialog
|
||||
open
|
||||
title={confirmDialogConfig.title}
|
||||
onClose={closeConfirmDialog}
|
||||
onConfirm={executeConfirmAction}
|
||||
confirmLabel={confirmDialogConfig.confirmLabel}
|
||||
>
|
||||
{confirmDialogConfig.body}
|
||||
</PlatformDangerConfirmDialog>
|
||||
) : null}
|
||||
{confirmState?.kind === 'minimum-playable' ? (
|
||||
<PlatformAcknowledgeStatusDialog
|
||||
status="error"
|
||||
title="无法删除"
|
||||
description="至少保留一个可扮演角色,才能正常进入自定义世界。"
|
||||
onClose={closeConfirmDialog}
|
||||
closeOnBackdrop={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
|
||||
import { CustomWorldGenerationView } from './CustomWorldGenerationView';
|
||||
@@ -100,26 +101,36 @@ describe('CustomWorldGenerationView', () => {
|
||||
'video[data-testid="generation-page-background-video"] source[type="video/mp4"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回创作中心' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '返回创作中心' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回创作中心' }).className,
|
||||
).toContain('text-xs');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回创作中心' }).className,
|
||||
).toContain('bg-transparent');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回创作中心' }).className,
|
||||
).toContain('gap-2');
|
||||
expect(screen.getByText('世界建设中')).toBeTruthy();
|
||||
expect(screen.getByText('世界建设中').className).toContain('text-xs');
|
||||
expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
|
||||
'text-center',
|
||||
expect(screen.getByText('世界建设中').className).toContain(
|
||||
'border-[var(--platform-warm-border)]',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
|
||||
'text-center',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
|
||||
'bg-white/58',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
|
||||
'bg-white/58',
|
||||
expect(screen.getByText('世界建设中').className).toContain(
|
||||
'bg-[var(--platform-warm-bg)]',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-wait-card').className,
|
||||
).toContain('text-center');
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-elapsed-card').className,
|
||||
).toContain('text-center');
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-wait-card').className,
|
||||
).toContain('bg-white/58');
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-elapsed-card').className,
|
||||
).toContain('bg-white/58');
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-wait-card').parentElement
|
||||
?.className,
|
||||
@@ -141,31 +152,25 @@ describe('CustomWorldGenerationView', () => {
|
||||
expect(screen.queryByText('预计还需 1 分 15 秒')).toBeNull();
|
||||
expect(screen.queryByText('已耗时 2 分 5 秒')).toBeNull();
|
||||
expect(screen.queryByText('计时')).toBeNull();
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'justify-start',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'z-30',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'pt-[2%]',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-progress-content').className,
|
||||
).toContain('justify-start');
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-progress-content').className,
|
||||
).toContain('z-30');
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-progress-content').className,
|
||||
).toContain('pt-[2%]');
|
||||
expect(screen.getByText('总进度').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('42%').className).toContain('text-[1.15rem]');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
.className,
|
||||
screen.getByRole('progressbar', { name: progressTitle }).className,
|
||||
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
.className,
|
||||
screen.getByRole('progressbar', { name: progressTitle }).className,
|
||||
).toContain('max-w-full');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
.className,
|
||||
screen.getByRole('progressbar', { name: progressTitle }).className,
|
||||
).toContain('aspect-square');
|
||||
expect(
|
||||
screen
|
||||
@@ -195,9 +200,11 @@ describe('CustomWorldGenerationView', () => {
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
||||
'svg',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain(
|
||||
'z-0',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring')
|
||||
.getAttribute('class'),
|
||||
).toContain('z-0');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring')
|
||||
@@ -250,8 +257,8 @@ describe('CustomWorldGenerationView', () => {
|
||||
?.className,
|
||||
).toContain('mt-5');
|
||||
expect(
|
||||
screen.getByRole('progressbar', { name: '编译草稿 进度' }),
|
||||
).toBeTruthy();
|
||||
screen.getByRole('progressbar', { name: '编译草稿 进度' }).className,
|
||||
).toContain('platform-progress-track');
|
||||
expect(screen.queryByText('收集设定')).toBeNull();
|
||||
expect(screen.queryByText('写回结果')).toBeNull();
|
||||
expect(screen.queryByText('当前批次')).toBeNull();
|
||||
@@ -289,4 +296,29 @@ describe('CustomWorldGenerationView', () => {
|
||||
expect(screen.queryByText('大鱼吃小鱼题材')).toBeNull();
|
||||
expect(screen.getByTestId('generation-page-background-video')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('keeps the shared generation back button click behavior', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldGenerationView
|
||||
settingText="大鱼吃小鱼题材"
|
||||
progress={createProgress()}
|
||||
isGenerating
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onEditSetting={() => {}}
|
||||
onRetry={() => {}}
|
||||
backLabel="返回创作中心"
|
||||
settingDescription={null}
|
||||
settingActionLabel={null}
|
||||
progressTitle="大鱼吃小鱼草稿生成进度"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import {
|
||||
GenerationCurrentStepCard,
|
||||
GenerationHeaderBackButton,
|
||||
GenerationPageBackdrop,
|
||||
GenerationProgressHero,
|
||||
} from './GenerationProgressHero';
|
||||
@@ -30,8 +31,16 @@ interface CustomWorldGenerationViewProps {
|
||||
idleBadgeLabel?: string;
|
||||
structuredEmptyText?: string;
|
||||
hideBatchModule?: boolean;
|
||||
queueStatus?: ExternalGenerationQueueStatus | null;
|
||||
}
|
||||
|
||||
export type ExternalGenerationQueueStatus = {
|
||||
currentStatus?: 'queued' | 'running' | 'completed' | 'failed' | null;
|
||||
currentProgress?: number | null;
|
||||
pendingCount?: number | null;
|
||||
runningCount?: number | null;
|
||||
};
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const safeMs = Math.max(0, Math.round(ms));
|
||||
const totalSeconds = Math.ceil(safeMs / 1000);
|
||||
@@ -84,6 +93,49 @@ function getStepStatusLabel(step: { status: string }) {
|
||||
return '待处理';
|
||||
}
|
||||
|
||||
function resolveQueueStatusLabel(
|
||||
status: ExternalGenerationQueueStatus['currentStatus'],
|
||||
) {
|
||||
if (status === 'queued') {
|
||||
return '排队中';
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
return '生成中';
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return '生成失败';
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
return '已完成';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasQueueStatus(status: ExternalGenerationQueueStatus | null | undefined) {
|
||||
return Boolean(
|
||||
status &&
|
||||
(status.currentStatus ||
|
||||
typeof status.pendingCount === 'number' ||
|
||||
typeof status.runningCount === 'number'),
|
||||
);
|
||||
}
|
||||
|
||||
function formatQueueCount(value: number | null | undefined) {
|
||||
return Math.max(0, Math.round(value ?? 0)).toString();
|
||||
}
|
||||
|
||||
function formatQueueProgress(value: number | null | undefined) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${Math.max(0, Math.min(100, Math.round(value)))}%`;
|
||||
}
|
||||
|
||||
function resolveCurrentGenerationStep(
|
||||
progress: CustomWorldGenerationProgress | null,
|
||||
) {
|
||||
@@ -110,6 +162,7 @@ export function CustomWorldGenerationView({
|
||||
activeBadgeLabel = '世界建设中',
|
||||
idleBadgeLabel = '等待操作',
|
||||
hideBatchModule = false,
|
||||
queueStatus = null,
|
||||
}: CustomWorldGenerationViewProps) {
|
||||
void hideBatchModule;
|
||||
const progressValue = getProgressPercentage(progress);
|
||||
@@ -117,7 +170,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
|
||||
@@ -129,24 +183,24 @@ export function CustomWorldGenerationView({
|
||||
: '校准中';
|
||||
const elapsedText =
|
||||
progress != null ? formatDuration(progress.elapsedMs) : '启动中';
|
||||
const queueStatusLabel = resolveQueueStatusLabel(
|
||||
queueStatus?.currentStatus ?? null,
|
||||
);
|
||||
const queueProgressText = formatQueueProgress(queueStatus?.currentProgress);
|
||||
const shouldShowQueueStatus = hasQueueStatus(queueStatus);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5"
|
||||
>
|
||||
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5">
|
||||
<GenerationPageBackdrop />
|
||||
<div className="relative z-30 mb-4 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm"
|
||||
<GenerationHeaderBackButton label={backLabel} onClick={onBack} />
|
||||
<PlatformPillBadge
|
||||
tone="warning"
|
||||
size="xs"
|
||||
className="px-3 py-1.5 tracking-[0.08em] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 shrink-0" strokeWidth={2.6} />
|
||||
<span className="break-keep">{backLabel}</span>
|
||||
</button>
|
||||
<div className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
|
||||
{isGenerating ? activeBadgeLabel : idleBadgeLabel}
|
||||
</div>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -170,23 +224,39 @@ export function CustomWorldGenerationView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{shouldShowQueueStatus ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-[1.25rem] border border-white/70 bg-white/72 px-3 py-2 text-xs font-semibold text-[#6b3a1d] shadow-[0_14px_34px_rgba(121,70,33,0.10)] backdrop-blur-md sm:px-4">
|
||||
{queueStatusLabel ? (
|
||||
<span className="rounded-full bg-[#fff4dc] px-2.5 py-1 text-[#8a4c1e]">
|
||||
{queueProgressText
|
||||
? `${queueStatusLabel} ${queueProgressText}`
|
||||
: queueStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span>排队 {formatQueueCount(queueStatus?.pendingCount)}</span>
|
||||
<span className="h-1 w-1 rounded-full bg-[#d4a15d]" />
|
||||
<span>生成 {formatQueueCount(queueStatus?.runningCount)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{!isGenerating ? (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
onClick={onRetry}
|
||||
className="platform-button platform-button--primary w-full sm:w-auto"
|
||||
fullWidth
|
||||
className="sm:w-auto"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
) : onInterrupt ? (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
tone="danger"
|
||||
shape="pill"
|
||||
onClick={onInterrupt}
|
||||
className="rounded-full border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-4 py-2 text-sm text-[var(--platform-button-danger-text)] transition-colors hover:text-[var(--platform-text-strong)]"
|
||||
className="transition-colors hover:text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{interruptLabel}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type CustomWorldNpcVisual,
|
||||
type CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
||||
import { HostileNpcAnimator } from './HostileNpcAnimator';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
@@ -282,9 +283,18 @@ function ActionButton({
|
||||
onClick: () => void;
|
||||
tone?: 'default' | 'sky';
|
||||
}) {
|
||||
const buttonTone = tone === 'sky' ? 'primary' : 'ghost';
|
||||
const visualClassName =
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:bg-sky-500/12 hover:text-white'
|
||||
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:bg-black/20 hover:text-white';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone={buttonTone}
|
||||
size="xs"
|
||||
shape="pill"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
@@ -292,14 +302,10 @@ function ActionButton({
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={onClick}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
|
||||
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white'
|
||||
}`}
|
||||
className={`text-sm font-semibold ${visualClassName}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Clock3, Hourglass } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { ArrowLeft, Clock3, Hourglass } from 'lucide-react';
|
||||
import { useEffect, useId, useRef } from 'react';
|
||||
|
||||
import generationHeroVideo from '../../media/create_bg_video.mp4';
|
||||
import { PlatformIconButton } from './common/PlatformIconButton';
|
||||
import { PlatformProgressBar } from './common/PlatformProgressBar';
|
||||
|
||||
const GENERATION_PROGRESS_RING_GAP_DEGREES = 90;
|
||||
const GENERATION_PROGRESS_RING_BOTTOM_DEGREES = 90;
|
||||
@@ -35,6 +36,14 @@ type GenerationCurrentStepCardProps = {
|
||||
progressValue: number;
|
||||
};
|
||||
|
||||
type GenerationHeaderBackButtonProps = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
disabledOpacity?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function clampGenerationProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
@@ -51,6 +60,34 @@ function buildGenerationRingMetrics(progressValue: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export function GenerationHeaderBackButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
disabledOpacity,
|
||||
className,
|
||||
}: GenerationHeaderBackButtonProps) {
|
||||
return (
|
||||
<PlatformIconButton
|
||||
label={label}
|
||||
title={label}
|
||||
variant="darkMini"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'gap-2 rounded-full !border-transparent !bg-transparent px-0 py-2 text-xs font-black !text-[#171411] shadow-none hover:!bg-transparent hover:!text-[#171411] sm:text-sm',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={disabled && disabledOpacity != null ? { opacity: disabledOpacity } : undefined}
|
||||
icon={<ArrowLeft className="h-5 w-5 shrink-0" strokeWidth={2.6} />}
|
||||
>
|
||||
<span className="break-keep">{label}</span>
|
||||
</PlatformIconButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function GenerationPageBackdrop() {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
@@ -64,8 +101,7 @@ export function GenerationPageBackdrop() {
|
||||
video.muted = true;
|
||||
video.volume = 0;
|
||||
|
||||
const isJsdom =
|
||||
window.navigator.userAgent.toLowerCase().includes('jsdom');
|
||||
const isJsdom = window.navigator.userAgent.toLowerCase().includes('jsdom');
|
||||
const tryPlay = () => {
|
||||
if (isJsdom) {
|
||||
return;
|
||||
@@ -285,20 +321,14 @@ export function GenerationCurrentStepCard({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-4 h-2.5 overflow-hidden rounded-full bg-[#f5eee8]"
|
||||
role="progressbar"
|
||||
aria-label={`${label} 进度`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={safeProgress}
|
||||
>
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,#ef7a1f_0%,#e25f18_64%,#f0b07e_100%)]"
|
||||
animate={{ width: `${safeProgress}%` }}
|
||||
transition={{ duration: 0.45, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
<PlatformProgressBar
|
||||
value={safeProgress}
|
||||
size="sm"
|
||||
ariaLabel={`${label} 进度`}
|
||||
className="mt-4 bg-[#f5eee8]"
|
||||
fillClassName="bg-[linear-gradient(90deg,#ef7a1f_0%,#e25f18_64%,#f0b07e_100%)]"
|
||||
fillStyle={{ transitionDuration: '450ms' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
|
||||
import { PlatformQuantityBadge } from './common/PlatformQuantityBadge';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
@@ -130,9 +132,7 @@ export function InventoryItemGrid({
|
||||
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
{item.quantity}
|
||||
</div>
|
||||
<PlatformQuantityBadge>{item.quantity}</PlatformQuantityBadge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -185,7 +185,11 @@ export function InventoryItemDetailModal({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
|
||||
<PixelCloseButton onClick={onClose} label="关闭物品详情" className="top-4 sm:top-5" />
|
||||
<PixelCloseButton
|
||||
onClick={onClose}
|
||||
label="关闭物品详情"
|
||||
className="top-4 sm:top-5"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
|
||||
@@ -234,9 +238,9 @@ export function InventoryItemDetailModal({
|
||||
</div>
|
||||
|
||||
{footer != null ? (
|
||||
<div className="border-t border-white/10 px-4 py-3 sm:px-5">
|
||||
<PlatformDarkModalFooter layout="content">
|
||||
{footer}
|
||||
</div>
|
||||
</PlatformDarkModalFooter>
|
||||
) : null}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
165
src/components/InventoryPanel.test.tsx
Normal file
165
src/components/InventoryPanel.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { RuntimeStoryForgeRecipeView } from '../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import { type Character, type InventoryItem, WorldType } from '../types';
|
||||
import { InventoryPanel } from './InventoryPanel';
|
||||
|
||||
const inventoryItem: InventoryItem = {
|
||||
id: 'training-token',
|
||||
category: '材料',
|
||||
name: '练习石',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const documentItem: InventoryItem = {
|
||||
id: 'thread-note',
|
||||
category: '文书',
|
||||
name: '潮汐证词',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: ['document'],
|
||||
description: '记录着潮汐线索。',
|
||||
};
|
||||
|
||||
const forgeRecipe: RuntimeStoryForgeRecipeView = {
|
||||
id: 'forge-tide-amulet',
|
||||
name: '潮汐护符',
|
||||
kind: 'forge',
|
||||
description: '用于测试工坊需求状态。',
|
||||
resultLabel: '潮汐护符',
|
||||
currencyCost: 5,
|
||||
currencyText: '5 贝币',
|
||||
requirements: [
|
||||
{
|
||||
id: 'iron',
|
||||
label: '铁矿',
|
||||
quantity: 2,
|
||||
owned: 2,
|
||||
},
|
||||
{
|
||||
id: 'wood',
|
||||
label: '木材',
|
||||
quantity: 1,
|
||||
owned: 0,
|
||||
},
|
||||
],
|
||||
canCraft: false,
|
||||
disabledReason: '材料不足',
|
||||
action: {
|
||||
functionId: 'craft',
|
||||
actionText: '锻造',
|
||||
enabled: false,
|
||||
reason: '材料不足',
|
||||
},
|
||||
};
|
||||
|
||||
test('背包工坊材料需求状态复用暗色平台胶囊标签', () => {
|
||||
render(
|
||||
<InventoryPanel
|
||||
playerCharacter={{} as Character}
|
||||
worldType={WorldType.CUSTOM}
|
||||
playerInventory={[inventoryItem]}
|
||||
playerCurrency={0}
|
||||
playerHp={10}
|
||||
playerMaxHp={10}
|
||||
playerMana={5}
|
||||
playerMaxMana={5}
|
||||
inBattle={false}
|
||||
forgeRecipes={[forgeRecipe]}
|
||||
onUseItem={vi.fn(async () => false)}
|
||||
onEquipItem={vi.fn(async () => false)}
|
||||
onCraftRecipe={vi.fn(async () => false)}
|
||||
onDismantleItem={vi.fn(async () => false)}
|
||||
onReforgeItem={vi.fn(async () => false)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const metRequirement = screen.getByText('铁矿 2/2');
|
||||
const missingRequirement = screen.getByText('木材 0/1');
|
||||
const forgePanel = screen.getByText('工坊').closest('section');
|
||||
const recipePanel = screen.getByText('潮汐护符').closest('section');
|
||||
const forgeButton = screen.getByRole('button', { name: '锻造' });
|
||||
|
||||
expect(metRequirement.className).toContain('rounded-full');
|
||||
expect(metRequirement.className).toContain('font-black');
|
||||
expect(metRequirement.className).toContain('bg-emerald-500/10');
|
||||
expect(missingRequirement.className).toContain('rounded-full');
|
||||
expect(missingRequirement.className).toContain('font-black');
|
||||
expect(missingRequirement.className).toContain('bg-black/20');
|
||||
expect(missingRequirement.className).toContain('text-zinc-400');
|
||||
expect(forgePanel?.className).toContain('border-white/10');
|
||||
expect(forgePanel?.className).toContain('bg-black/25');
|
||||
expect(recipePanel?.className).toContain('border-white/10');
|
||||
expect(recipePanel?.className).toContain('bg-black/25');
|
||||
expect(forgeButton.className).toContain('platform-action-button--editor-dark');
|
||||
expect(forgeButton.className).toContain('rounded-lg');
|
||||
expect(forgeButton.className).toContain('bg-emerald-400');
|
||||
expect(forgeButton.className).toContain('disabled:bg-black/20');
|
||||
});
|
||||
|
||||
test('背包文书和故事档案区块复用暗色 PlatformSubpanel chrome', () => {
|
||||
render(
|
||||
<InventoryPanel
|
||||
playerCharacter={{} as Character}
|
||||
worldType={WorldType.CUSTOM}
|
||||
playerInventory={[documentItem]}
|
||||
playerCurrency={0}
|
||||
playerHp={10}
|
||||
playerMaxHp={10}
|
||||
playerMana={5}
|
||||
playerMaxMana={5}
|
||||
inBattle={false}
|
||||
forgeRecipes={[]}
|
||||
narrativeQaReport={{
|
||||
generatedAt: '2026-06-10T00:00:00.000Z',
|
||||
issues: [],
|
||||
summary: '叙事链路稳定。',
|
||||
}}
|
||||
narrativeCodex={[
|
||||
{
|
||||
id: 'codex-tide',
|
||||
title: '潮汐档案',
|
||||
entries: [
|
||||
{
|
||||
id: 'entry-tide',
|
||||
title: '旧港线索',
|
||||
summary: '证词指向旧港。',
|
||||
category: 'document',
|
||||
relatedIds: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
onUseItem={vi.fn(async () => false)}
|
||||
onEquipItem={vi.fn(async () => false)}
|
||||
onCraftRecipe={vi.fn(async () => false)}
|
||||
onDismantleItem={vi.fn(async () => false)}
|
||||
onReforgeItem={vi.fn(async () => false)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const documentPanel = screen.getByText('文书与证据').closest('section');
|
||||
const documentButton = screen.getByRole('button', {
|
||||
name: /潮汐证词/,
|
||||
});
|
||||
const storyPanel = screen.getByText('故事档案').closest('section');
|
||||
const qaMessage = screen.getByText('QA:叙事链路稳定。');
|
||||
const codexPanel = screen.getByText('潮汐档案').closest('section');
|
||||
|
||||
expect(documentPanel?.className).toContain('border-white/10');
|
||||
expect(documentPanel?.className).toContain('bg-black/25');
|
||||
expect(documentButton.className).toContain('border-white/10');
|
||||
expect(documentButton.className).toContain('bg-black/25');
|
||||
expect(storyPanel?.className).toContain('border-white/10');
|
||||
expect(storyPanel?.className).toContain('bg-black/25');
|
||||
expect(qaMessage.className).toContain('platform-status-message');
|
||||
expect(qaMessage.className).toContain('border-amber-300/15');
|
||||
expect(qaMessage.className).toContain('bg-amber-500/10');
|
||||
expect(codexPanel?.className).toContain('border-white/10');
|
||||
expect(codexPanel?.className).toContain('bg-black/25');
|
||||
});
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
NarrativeQaReport,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
import {
|
||||
InventoryItemDetailModal,
|
||||
InventoryItemGrid,
|
||||
@@ -83,7 +87,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 +104,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 +176,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 +196,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">
|
||||
@@ -181,8 +217,10 @@ export function InventoryPanel({
|
||||
花费:{recipe.currencyText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="success"
|
||||
size="xxs"
|
||||
disabled={
|
||||
!recipe.canCraft ||
|
||||
!recipe.action.enabled ||
|
||||
@@ -197,33 +235,32 @@ export function InventoryPanel({
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs transition ${
|
||||
recipe.canCraft && recipe.action.enabled && !inBattle
|
||||
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
|
||||
: 'border-white/8 bg-black/20 text-zinc-500'
|
||||
}`}
|
||||
className="rounded-lg disabled:border-white/8 disabled:bg-black/20 disabled:text-zinc-500 disabled:opacity-100"
|
||||
>
|
||||
{forgeActionKey === recipe.id
|
||||
? '制作中...'
|
||||
: recipe.kind === 'forge'
|
||||
? '锻造'
|
||||
: '合成'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</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 +268,10 @@ export function InventoryPanel({
|
||||
{recipe.disabledReason ?? recipe.action.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
|
||||
<InventoryItemDetailModal
|
||||
|
||||
137
src/components/MapModal.test.tsx
Normal file
137
src/components/MapModal.test.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/* @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 footer = screen.getByTestId('map-travel-footer');
|
||||
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(footer.className).toContain('platform-dark-modal-footer');
|
||||
expect(footer.className).toContain('border-t');
|
||||
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');
|
||||
});
|
||||
|
||||
test('地图右上关闭按钮复用共享像素关闭按钮能力', () => {
|
||||
const currentScene = getWorldCampScenePreset(WorldType.WUXIA);
|
||||
if (!currentScene) {
|
||||
throw new Error('测试需要武侠营地场景');
|
||||
}
|
||||
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<MapModal
|
||||
isOpen
|
||||
currentScenePreset={currentScene}
|
||||
worldType={WorldType.WUXIA}
|
||||
onClose={onClose}
|
||||
onTravelToScene={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: '关闭地图' });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(closeButton.className).toContain('absolute');
|
||||
expect(closeButton.className).toContain('right-4');
|
||||
expect(closeButton.getAttribute('title')).toBe('关闭地图');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -6,6 +6,10 @@ 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 { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
@@ -67,9 +71,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,49 +395,73 @@ 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"
|
||||
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"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<PlatformDarkModalFooter data-testid="map-travel-footer">
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
onClick={() => setPendingScene(null)}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone={isTraveling || !canTravel ? 'ghost' : 'warning'}
|
||||
size="xs"
|
||||
disabled={isTraveling || !canTravel}
|
||||
onClick={confirmTravel}
|
||||
>
|
||||
{isTraveling ? '切换中...' : canTravel ? '确认前往' : '当前不可切换'}
|
||||
</PlatformActionButton>
|
||||
</PlatformDarkModalFooter>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
322
src/components/NpcModals.test.tsx
Normal file
322
src/components/NpcModals.test.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
/* @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('同行名额已满,需要先让一人离队。');
|
||||
const tradeDetailEmptyState = screen.getByText(
|
||||
'请选择一件物品,右侧会显示数量、价格与详情。',
|
||||
);
|
||||
|
||||
expect(recruitIntro.className).toContain('platform-status-message');
|
||||
expect(recruitIntro.className).toContain('border-amber-300/15');
|
||||
expect(tradeDetailEmptyState.className).toContain('platform-empty-state');
|
||||
expect(tradeDetailEmptyState.className).toContain('border-dashed');
|
||||
});
|
||||
|
||||
test('NPC 弹窗标准 dark footer CTA 复用 PlatformActionButton', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { unmount } = render(
|
||||
<NpcModals gameState={createEmptyGameState()} npcUi={createEmptyNpcUi()} />,
|
||||
);
|
||||
const tradeFooter = screen.getByTestId('npc-trade-footer');
|
||||
const giftFooter = screen.getByTestId('npc-gift-footer');
|
||||
const recruitFooter = screen.getByTestId('npc-recruit-footer');
|
||||
|
||||
const cancelButtons = screen.getAllByRole('button', { name: '取消' });
|
||||
const tradeConfirmButton = screen.getByRole('button', { name: '确认购买' });
|
||||
const giftConfirmButton = screen.getByRole('button', { name: '确认赠礼' });
|
||||
const recruitConfirmButton = screen.getByRole('button', { name: '确认招募' });
|
||||
const footerButtons = [
|
||||
cancelButtons[0],
|
||||
tradeConfirmButton,
|
||||
cancelButtons[1],
|
||||
giftConfirmButton,
|
||||
cancelButtons[2],
|
||||
recruitConfirmButton,
|
||||
].filter((button): button is HTMLElement => Boolean(button));
|
||||
|
||||
expect(footerButtons).toHaveLength(6);
|
||||
expect(tradeFooter.className).toContain('platform-dark-modal-footer');
|
||||
expect(tradeFooter.className).toContain('border-t');
|
||||
expect(giftFooter.className).toContain('platform-dark-modal-footer');
|
||||
expect(giftFooter.className).toContain('pb-5');
|
||||
expect(recruitFooter.className).toContain('platform-dark-modal-footer');
|
||||
expect(recruitFooter.className).toContain('pb-5');
|
||||
|
||||
footerButtons.forEach((button) => {
|
||||
expect(button.className).toContain('platform-action-button--editor-dark');
|
||||
expect(button.className).toContain('rounded-2xl');
|
||||
});
|
||||
|
||||
unmount();
|
||||
render(<NpcModals gameState={createGameState()} npcUi={createNpcUi()} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /月壳/ }));
|
||||
|
||||
const tradeDetailFooter = screen.getByTestId('npc-trade-detail-footer');
|
||||
const closeButton = screen.getByRole('button', { name: '关闭' });
|
||||
|
||||
expect(tradeDetailFooter.className).toContain('platform-dark-modal-footer');
|
||||
expect(tradeDetailFooter.className).toContain('px-5');
|
||||
expect(tradeDetailFooter.className).toContain('py-4');
|
||||
expect(closeButton.className).toContain('platform-action-button--editor-dark');
|
||||
expect(closeButton.className).toContain('rounded-2xl');
|
||||
});
|
||||
@@ -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,18 @@ import {
|
||||
RuntimeNpcGiftItemView,
|
||||
RuntimeNpcTradeItemView,
|
||||
} from '../types';
|
||||
import { getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
getInventoryItemVisualSrc,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
|
||||
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
|
||||
import { PlatformEmptyState } from './common/PlatformEmptyState';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
@@ -43,6 +52,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 +93,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 +136,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 +163,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 +179,7 @@ function TradeQuantityStepper({
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,51 +189,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 +277,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 +297,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,49 +387,66 @@ 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">
|
||||
<PlatformEmptyState
|
||||
surface="editorDark"
|
||||
size="compact"
|
||||
tone="soft"
|
||||
className="px-2 py-8 text-center"
|
||||
>
|
||||
请选择一件物品,右侧会显示数量、价格与详情。
|
||||
</div>
|
||||
</PlatformEmptyState>
|
||||
)}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformDarkModalFooter data-testid="npc-trade-footer">
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
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 })}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="primary"
|
||||
size="xs"
|
||||
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 })}
|
||||
>
|
||||
{tradeMode === 'buy' ? '确认购买' : '确认出售'}
|
||||
</button>
|
||||
</div>
|
||||
</PlatformActionButton>
|
||||
</PlatformDarkModalFooter>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -375,7 +465,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 +484,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,38 +510,71 @@ 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">
|
||||
<button
|
||||
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 })}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlatformDarkModalFooter
|
||||
padding="roomy"
|
||||
data-testid="npc-trade-detail-footer"
|
||||
>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
onClick={() => setTradeDetail(null)}
|
||||
>
|
||||
关闭
|
||||
</PlatformActionButton>
|
||||
</PlatformDarkModalFooter>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -464,12 +593,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,50 +611,88 @@ 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 })}>
|
||||
<PlatformDarkModalFooter
|
||||
bordered={false}
|
||||
padding="bottom"
|
||||
data-testid="npc-gift-footer"
|
||||
>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
onClick={npcUi.closeGiftModal}
|
||||
>
|
||||
取消
|
||||
</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 })}>
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="primary"
|
||||
size="xs"
|
||||
disabled={!activeGiftView?.canSubmit}
|
||||
onClick={npcUi.confirmGift}
|
||||
>
|
||||
确认赠礼
|
||||
</button>
|
||||
</div>
|
||||
</PlatformActionButton>
|
||||
</PlatformDarkModalFooter>
|
||||
</motion.div>
|
||||
</motion.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,39 +731,70 @@ 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 })}>
|
||||
<PlatformDarkModalFooter
|
||||
bordered={false}
|
||||
padding="bottom"
|
||||
data-testid="npc-recruit-footer"
|
||||
>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
onClick={npcUi.closeRecruitModal}
|
||||
>
|
||||
取消
|
||||
</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 })}>
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="primary"
|
||||
size="xs"
|
||||
disabled={!npcUi.recruitModal.selectedReleaseNpcId}
|
||||
onClick={npcUi.confirmRecruit}
|
||||
>
|
||||
确认招募
|
||||
</button>
|
||||
</div>
|
||||
</PlatformActionButton>
|
||||
</PlatformDarkModalFooter>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { CHROME_ICONS } from '../uiAssets';
|
||||
import { PlatformModalCloseButton } from './common/PlatformModalCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
type PixelCloseButtonProps = {
|
||||
@@ -12,7 +11,7 @@ type PixelCloseButtonProps = {
|
||||
|
||||
/**
|
||||
* RPG 像素风弹窗右上关闭按钮。
|
||||
* 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为。
|
||||
* 这里只保留 RPG 语义层封装,底层样式与行为统一复用共享 close button。
|
||||
*/
|
||||
export function PixelCloseButton({
|
||||
onClick,
|
||||
@@ -20,26 +19,16 @@ export function PixelCloseButton({
|
||||
placement = 'absolute',
|
||||
className = '',
|
||||
}: PixelCloseButtonProps) {
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
|
||||
const placementClassName =
|
||||
placement === 'absolute'
|
||||
? 'absolute right-4 top-3 sm:right-5 sm:top-4'
|
||||
: 'relative shrink-0';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
<PlatformModalCloseButton
|
||||
label={label}
|
||||
title={label}
|
||||
onClick={handleClick}
|
||||
className={`${placementClassName} z-20 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 ${className}`.trim()}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
variant="pixel"
|
||||
placement={placement}
|
||||
stopPropagation
|
||||
onClick={() => onClick()}
|
||||
className={['z-20', className].filter(Boolean).join(' ')}
|
||||
icon={<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
128
src/components/SelectionCustomizationModals.test.tsx
Normal file
128
src/components/SelectionCustomizationModals.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/* @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('背景补充');
|
||||
const footer = screen.getByTestId('selection-modal-footer');
|
||||
|
||||
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(footer.className).toContain('platform-dark-modal-footer');
|
||||
expect(footer.className).toContain('border-t');
|
||||
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');
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
import { X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldGenerationMode,
|
||||
} from '../types';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
|
||||
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,21 +37,23 @@ 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}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
<PlatformDarkModalFooter
|
||||
wrap
|
||||
padding="roomy"
|
||||
data-testid="selection-modal-footer"
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
</PlatformDarkModalFooter>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,50 +90,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 +231,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 +256,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 +278,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>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SceneHostileNpc,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { GameCanvas } from './GameCanvas';
|
||||
|
||||
export interface SkillEffectPreviewProps {
|
||||
@@ -227,15 +228,17 @@ export function SkillEffectPreview({
|
||||
{mode === 'player' ? `受击对象:${sceneHostileNpcs[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
onClick={() => setRestartTick(value => value + 1)}
|
||||
disabled={!skill || isPlaying}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-xs text-zinc-200 transition hover:border-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
surface="editorDark"
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
className="min-h-0 rounded-lg bg-black/30 px-3 py-2 text-xs hover:border-white/20 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span>{isPlaying ? '播放中' : '重新预览'}</span>
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black">
|
||||
|
||||
@@ -32,6 +32,9 @@ function renderAccountModal(overrides?: {
|
||||
riskBlocks?: AuthRiskBlockSummary[];
|
||||
sessions?: AuthSessionSummary[];
|
||||
auditLogs?: AuthAuditLogEntry[];
|
||||
loadingRiskBlocks?: boolean;
|
||||
loadingSessions?: boolean;
|
||||
loadingAuditLogs?: boolean;
|
||||
onRevokeSession?: (session: AuthSessionSummary) => Promise<void>;
|
||||
revokingSessionIds?: string[];
|
||||
initialSection?:
|
||||
@@ -52,9 +55,9 @@ function renderAccountModal(overrides?: {
|
||||
riskBlocks={overrides?.riskBlocks ?? []}
|
||||
sessions={overrides?.sessions ?? []}
|
||||
auditLogs={overrides?.auditLogs ?? []}
|
||||
loadingRiskBlocks={false}
|
||||
loadingSessions={false}
|
||||
loadingAuditLogs={false}
|
||||
loadingRiskBlocks={overrides?.loadingRiskBlocks ?? false}
|
||||
loadingSessions={overrides?.loadingSessions ?? false}
|
||||
loadingAuditLogs={overrides?.loadingAuditLogs ?? false}
|
||||
isHydratingSettings={false}
|
||||
isPersistingSettings={false}
|
||||
settingsError={null}
|
||||
@@ -98,6 +101,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 +139,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 +200,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: '更换手机号',
|
||||
@@ -218,8 +262,14 @@ test('account actions open in independent panels instead of inline expansion', a
|
||||
const changePhoneDialog = screen.getByRole('dialog', {
|
||||
name: '绑定新手机号',
|
||||
});
|
||||
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
|
||||
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
|
||||
const phoneInput = within(changePhoneDialog).getByLabelText(
|
||||
'新手机号',
|
||||
) as HTMLInputElement;
|
||||
const codeInput = within(changePhoneDialog).getByLabelText(
|
||||
'验证码',
|
||||
) as HTMLInputElement;
|
||||
expect(phoneInput.className).toContain('platform-text-field');
|
||||
expect(codeInput.className).toContain('platform-text-field');
|
||||
});
|
||||
|
||||
test('nested settings panels keep back navigation without an extra close action', async () => {
|
||||
@@ -357,6 +407,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();
|
||||
@@ -375,6 +437,64 @@ test('legacy nested section requests now open the merged account panel', () => {
|
||||
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('account panel empty shells reuse PlatformEmptyState subpanel chrome', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const emptyStateMessages = [
|
||||
'当前没有生效中的安全限制。',
|
||||
'暂无可展示的登录设备。',
|
||||
'暂无账号操作记录。',
|
||||
];
|
||||
|
||||
for (const message of emptyStateMessages) {
|
||||
const shell = findNearestClassName(
|
||||
within(accountDialog).getByText(message),
|
||||
'platform-empty-state',
|
||||
);
|
||||
|
||||
expect(shell?.className).toContain('rounded-[1rem]');
|
||||
expect(shell?.className).toContain('bg-white/74');
|
||||
expect(shell?.className).toContain('px-4');
|
||||
expect(shell?.className).toContain('py-3');
|
||||
}
|
||||
});
|
||||
|
||||
test('account panel loading shells reuse PlatformEmptyState subpanel chrome', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal({
|
||||
loadingRiskBlocks: true,
|
||||
loadingSessions: true,
|
||||
loadingAuditLogs: true,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const loadingMessages = [
|
||||
'正在读取安全状态...',
|
||||
'正在读取当前登录设备...',
|
||||
'正在读取账号操作记录...',
|
||||
];
|
||||
|
||||
for (const message of loadingMessages) {
|
||||
const shell = findNearestClassName(
|
||||
within(accountDialog).getByText(message),
|
||||
'platform-empty-state',
|
||||
);
|
||||
|
||||
expect(shell?.className).toContain('rounded-[1rem]');
|
||||
expect(shell?.className).toContain('bg-white/74');
|
||||
expect(shell?.className).toContain('px-4');
|
||||
expect(shell?.className).toContain('py-3');
|
||||
}
|
||||
});
|
||||
|
||||
test('current merged session group hides kick action and shows count', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -392,7 +512,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 +546,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();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,8 +15,17 @@ import type {
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type { PlatformSettingsSection } from './AuthUiContext';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
|
||||
|
||||
type AccountModalProps = {
|
||||
user: AuthUser;
|
||||
@@ -130,10 +139,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 +163,20 @@ function SettingsEntryCard({
|
||||
<div className="mt-3 text-sm text-[var(--platform-text-base)]">
|
||||
{summary}
|
||||
</div>
|
||||
</button>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
// 中文注释:账号安全子面板里的空态与轻量加载态共用同一层白底外壳,避免重复拼 flat subpanel 样式。
|
||||
function AccountSubpanelState({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<PlatformEmptyState
|
||||
surface="subpanel"
|
||||
size="compact"
|
||||
className="py-3 text-center"
|
||||
>
|
||||
{children}
|
||||
</PlatformEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,6 +186,7 @@ function OverlayPanel({
|
||||
description,
|
||||
action,
|
||||
standalone = false,
|
||||
dialog = true,
|
||||
onBack,
|
||||
onClose,
|
||||
children,
|
||||
@@ -170,6 +196,7 @@ function OverlayPanel({
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
standalone?: boolean;
|
||||
dialog?: boolean;
|
||||
onBack?: () => void;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
@@ -177,9 +204,9 @@ function OverlayPanel({
|
||||
const panel = (
|
||||
<div
|
||||
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
role={dialog ? 'dialog' : undefined}
|
||||
aria-modal={dialog ? true : undefined}
|
||||
aria-label={dialog ? title : undefined}
|
||||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -204,23 +231,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 +290,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 +309,7 @@ function ThemeOptionCard({
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
{detail}
|
||||
</div>
|
||||
</button>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -467,23 +501,37 @@ export function AccountModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[70] flex items-end justify-center overflow-hidden px-4 sm:items-center`}
|
||||
style={{
|
||||
<PlatformAuthModalShell
|
||||
title={isDirectAccountMode ? '账号信息' : '设置与账号安全'}
|
||||
platformTheme={platformTheme}
|
||||
onClose={onClose}
|
||||
closeLabel="关闭账号弹窗"
|
||||
size="xl"
|
||||
showHeader={false}
|
||||
overlaySpacing="none"
|
||||
zIndexClassName="z-[70]"
|
||||
overlayClassName="!items-end !justify-center overflow-hidden !px-4 !py-0 sm:!items-center"
|
||||
overlayStyle={{
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
|
||||
}}
|
||||
onClick={onClose}
|
||||
authCardClassName={isDirectAccountMode ? '' : undefined}
|
||||
panelClassName={
|
||||
isDirectAccountMode
|
||||
? 'relative !max-w-3xl !rounded-none !bg-transparent !shadow-none'
|
||||
: 'relative !h-[min(100%,calc(100vh-2rem))] !max-w-5xl !rounded-[28px] !p-5 sm:!p-6'
|
||||
}
|
||||
bodyClassName="!flex !min-h-0 !flex-1 !overflow-hidden !p-0"
|
||||
panelStyle={{
|
||||
maxHeight: ACCOUNT_MODAL_MAX_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isDirectAccountMode
|
||||
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
|
||||
: 'platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6'
|
||||
: 'relative flex h-full w-full min-h-0 flex-col overflow-hidden'
|
||||
}
|
||||
role={isDirectAccountMode ? undefined : 'dialog'}
|
||||
aria-modal={isDirectAccountMode ? undefined : true}
|
||||
aria-label={isDirectAccountMode ? undefined : '设置与账号安全'}
|
||||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -494,13 +542,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 +602,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 +617,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}
|
||||
@@ -575,28 +634,34 @@ export function AccountModal({
|
||||
<OverlayPanel
|
||||
title="账号信息"
|
||||
standalone={isDirectAccountMode}
|
||||
dialog={!isDirectAccountMode}
|
||||
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
|
||||
onClose={onClose}
|
||||
>
|
||||
<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 +670,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 +731,55 @@ 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)]">
|
||||
正在读取安全状态...
|
||||
</div>
|
||||
) : riskBlocks.length > 0 ? (
|
||||
riskBlocks.map((block) => (
|
||||
<div
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={loadingRiskBlocks}
|
||||
loadingState={
|
||||
<AccountSubpanelState>
|
||||
正在读取安全状态...
|
||||
</AccountSubpanelState>
|
||||
}
|
||||
isEmpty={riskBlocks.length === 0}
|
||||
emptyState={
|
||||
<AccountSubpanelState>
|
||||
当前没有生效中的安全限制。
|
||||
</AccountSubpanelState>
|
||||
}
|
||||
>
|
||||
{riskBlocks.map((block) => (
|
||||
<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,70 +795,95 @@ 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>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
当前没有生效中的安全限制。
|
||||
</div>
|
||||
)}
|
||||
</PlatformActionButton>
|
||||
</PlatformStatusMessage>
|
||||
))}
|
||||
</PlatformAsyncStatePanel>
|
||||
</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)]">
|
||||
正在读取当前登录设备...
|
||||
</div>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => {
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={loadingSessions}
|
||||
loadingState={
|
||||
<AccountSubpanelState>
|
||||
正在读取当前登录设备...
|
||||
</AccountSubpanelState>
|
||||
}
|
||||
isEmpty={sessions.length === 0}
|
||||
emptyState={
|
||||
<AccountSubpanelState>
|
||||
暂无可展示的登录设备。
|
||||
</AccountSubpanelState>
|
||||
}
|
||||
>
|
||||
{sessions.map((session) => {
|
||||
const isRevoking = revokingSessionIds.includes(
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
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 +898,73 @@ 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)]">
|
||||
暂无可展示的登录设备。
|
||||
</div>
|
||||
)}
|
||||
})}
|
||||
</PlatformAsyncStatePanel>
|
||||
</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)]">
|
||||
正在读取账号操作记录...
|
||||
</div>
|
||||
) : auditLogs.length > 0 ? (
|
||||
auditLogs.map((log) => (
|
||||
<div
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={loadingAuditLogs}
|
||||
loadingState={
|
||||
<AccountSubpanelState>
|
||||
正在读取账号操作记录...
|
||||
</AccountSubpanelState>
|
||||
}
|
||||
isEmpty={auditLogs.length === 0}
|
||||
emptyState={
|
||||
<AccountSubpanelState>
|
||||
暂无账号操作记录。
|
||||
</AccountSubpanelState>
|
||||
}
|
||||
>
|
||||
{auditLogs.map((log) => (
|
||||
<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 +980,38 @@ export function AccountModal({
|
||||
IP:{log.ipMasked}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
暂无账号操作记录。
|
||||
</div>
|
||||
)}
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
</PlatformAsyncStatePanel>
|
||||
</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>
|
||||
|
||||
@@ -888,10 +1024,12 @@ export function AccountModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>新手机号</span>
|
||||
<input
|
||||
className="platform-input h-11"
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
新手机号
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
className="h-11"
|
||||
value={phone}
|
||||
inputMode="numeric"
|
||||
placeholder="13800000000"
|
||||
@@ -899,22 +1037,25 @@ export function AccountModal({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
验证码
|
||||
</PlatformFieldLabel>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input h-11 min-w-0 flex-1"
|
||||
<PlatformTextField
|
||||
className="h-11 min-w-0 flex-1"
|
||||
value={code}
|
||||
inputMode="numeric"
|
||||
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 +1092,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 +1109,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 +1139,7 @@ export function AccountModal({
|
||||
}}
|
||||
>
|
||||
{changingPhone ? '提交中...' : '确认更换手机号'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
) : null}
|
||||
@@ -1012,10 +1153,12 @@ export function AccountModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>当前密码</span>
|
||||
<input
|
||||
className="platform-input h-11"
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
当前密码
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
className="h-11"
|
||||
value={currentPassword}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
@@ -1025,10 +1168,12 @@ export function AccountModal({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>新密码</span>
|
||||
<input
|
||||
className="platform-input h-11"
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
新密码
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
className="h-11"
|
||||
value={newPassword}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
@@ -1038,15 +1183,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,13 +1214,13 @@ export function AccountModal({
|
||||
}}
|
||||
>
|
||||
{changingPassword ? '提交中...' : '确认修改密码'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
) : null}
|
||||
</OverlayPanel>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformAuthModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,12 +34,20 @@ const authMocks = vi.hoisted(() => ({
|
||||
consumeAuthCallbackResult: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
|
||||
getStoredAccessToken: authMocks.getStoredAccessToken,
|
||||
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
|
||||
}));
|
||||
vi.mock('../../services/apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../services/apiClient')>(
|
||||
'../../services/apiClient',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
|
||||
getStoredAccessToken: authMocks.getStoredAccessToken,
|
||||
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
authEntry: authMocks.authEntry,
|
||||
@@ -54,12 +62,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,
|
||||
@@ -406,8 +416,17 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
const phoneInput = within(dialog).getByLabelText(
|
||||
'手机号',
|
||||
) as HTMLInputElement;
|
||||
const codeInput = within(dialog).getByLabelText(
|
||||
'验证码',
|
||||
) as HTMLInputElement;
|
||||
expect(phoneInput.className).toContain('platform-text-field');
|
||||
expect(codeInput.className).toContain('platform-text-field');
|
||||
|
||||
await user.type(phoneInput, '13800000000');
|
||||
await user.type(codeInput, '123456');
|
||||
await acceptLegalConsent(user, dialog);
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
@@ -440,7 +459,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 +497,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');
|
||||
|
||||
@@ -573,6 +592,8 @@ test('auth gate hides register entry and opens invite modal for new sms account'
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '进入作品' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
|
||||
expect(dialog.className).toContain('platform-auth-card');
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull();
|
||||
expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
@@ -590,9 +611,13 @@ test('auth gate hides register entry and opens invite modal for new sms account'
|
||||
const inviteDialog = await screen.findByRole('dialog', {
|
||||
name: '请填写邀请码',
|
||||
});
|
||||
expect(
|
||||
(within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value,
|
||||
).toBe('SPRING2026');
|
||||
expect(inviteDialog.className).toContain('platform-auth-card');
|
||||
expect(inviteDialog.className).toContain('platform-modal-shell');
|
||||
const inviteCodeInput = within(inviteDialog).getByLabelText(
|
||||
'邀请码',
|
||||
) as HTMLInputElement;
|
||||
expect(inviteCodeInput.value).toBe('SPRING2026');
|
||||
expect(inviteCodeInput.className).toContain('platform-text-field');
|
||||
expect(
|
||||
within(inviteDialog).getByRole('button', { name: '提交' }),
|
||||
).toBeTruthy();
|
||||
@@ -778,6 +803,8 @@ test('login modal resets draft state every time it is reopened', async () => {
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
expect(firstDialog.className).toContain('platform-auth-card');
|
||||
expect(firstDialog.className).toContain('platform-modal-shell');
|
||||
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.click(
|
||||
within(firstDialog).getByRole('button', { name: '获取验证码' }),
|
||||
@@ -790,7 +817,11 @@ test('login modal resets draft state every time it is reopened', async () => {
|
||||
).toBeTruthy();
|
||||
await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
|
||||
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd');
|
||||
const passwordInput = within(firstDialog).getByLabelText(
|
||||
'密码',
|
||||
) as HTMLInputElement;
|
||||
expect(passwordInput.className).toContain('platform-text-field');
|
||||
await user.type(passwordInput, 'passw0rd');
|
||||
await user.click(
|
||||
within(firstDialog).getByRole('button', { name: '忘记密码' }),
|
||||
);
|
||||
@@ -849,6 +880,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 +942,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 +986,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: '确认修改密码' }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
59
src/components/auth/BindPhoneScreen.test.tsx
Normal file
59
src/components/auth/BindPhoneScreen.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
|
||||
const baseUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
displayName: '微信旅人',
|
||||
avatarUrl: null,
|
||||
publicUserCode: 'user-bind-phone',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'wechat',
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
wechatBound: true,
|
||||
};
|
||||
|
||||
test('绑定手机号表单复用平台输入和字段标题', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<BindPhoneScreen
|
||||
user={baseUser}
|
||||
platformTheme="light"
|
||||
sendingCode={false}
|
||||
binding={false}
|
||||
error=""
|
||||
captchaChallenge={null}
|
||||
onSendCode={vi.fn().mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
})}
|
||||
onSubmit={onSubmit}
|
||||
onLogout={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const phoneInput = screen.getByLabelText('手机号') as HTMLInputElement;
|
||||
const codeInput = screen.getByLabelText('验证码') as HTMLInputElement;
|
||||
|
||||
expect(phoneInput.className).toContain('platform-text-field');
|
||||
expect(codeInput.className).toContain('platform-text-field');
|
||||
expect(screen.getByText('手机号').className).toContain(
|
||||
'text-[var(--platform-text-strong)]',
|
||||
);
|
||||
expect(screen.getByText('当前登录身份:微信旅人').className).toContain(
|
||||
'platform-subpanel',
|
||||
);
|
||||
|
||||
await user.type(phoneInput, '13800000000');
|
||||
await user.type(codeInput, '123456');
|
||||
await user.click(screen.getByRole('button', { name: '绑定手机号并进入游戏' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('13800000000', '123456');
|
||||
});
|
||||
@@ -2,6 +2,11 @@ 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 { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type BindPhoneScreenProps = {
|
||||
@@ -74,9 +79,14 @@ export function BindPhoneScreen({
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
|
||||
微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。
|
||||
</p>
|
||||
<div className="platform-subpanel mt-8 rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="md"
|
||||
className="mt-8 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
当前登录身份:{user.displayName}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
|
||||
<form
|
||||
@@ -86,10 +96,11 @@ export function BindPhoneScreen({
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
手机号
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
@@ -98,20 +109,23 @@ export function BindPhoneScreen({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
验证码
|
||||
</PlatformFieldLabel>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input min-w-0 flex-1"
|
||||
<PlatformTextField
|
||||
className="min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
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 +149,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 +166,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>
|
||||
|
||||
53
src/components/auth/CaptchaChallengeField.test.tsx
Normal file
53
src/components/auth/CaptchaChallengeField.test.tsx
Normal 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');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,23 @@ import {
|
||||
persistLegalConsent,
|
||||
readStoredLegalConsent,
|
||||
} from '../common/legalDocuments';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
|
||||
|
||||
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;
|
||||
@@ -181,81 +191,54 @@ export function LoginScreen({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={onClose}
|
||||
<PlatformAuthModalShell
|
||||
title={isResetPanelOpen ? '重置密码' : '账号入口'}
|
||||
platformTheme={platformTheme}
|
||||
onClose={onClose}
|
||||
closeLabel="关闭登录弹窗"
|
||||
panelClassName="!max-w-md"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-login-dialog-title"
|
||||
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div
|
||||
id="auth-login-dialog-title"
|
||||
className="text-lg font-semibold text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{isResetPanelOpen ? '重置密码' : '账号入口'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-icon-button p-2"
|
||||
aria-label="关闭登录弹窗"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isResetPanelOpen ? (
|
||||
<PasswordResetPanel
|
||||
phone={resetPhone}
|
||||
code={resetCode}
|
||||
password={resetPasswordValue}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
cooldownSeconds={resetCooldownSeconds}
|
||||
error={error}
|
||||
onPhoneChange={setResetPhone}
|
||||
onCodeChange={setResetCode}
|
||||
onPasswordChange={setResetPasswordValue}
|
||||
onBack={() => setIsResetPanelOpen(false)}
|
||||
onSendCode={async () => {
|
||||
const result = await onSendCode(resetPhone, 'reset_password');
|
||||
setResetCooldownSeconds(result.cooldownSeconds);
|
||||
}}
|
||||
onSubmit={() =>
|
||||
onResetPassword(resetPhone, resetCode, resetPasswordValue)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
) : null}
|
||||
{isResetPanelOpen ? (
|
||||
<PasswordResetPanel
|
||||
phone={resetPhone}
|
||||
code={resetCode}
|
||||
password={resetPasswordValue}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
cooldownSeconds={resetCooldownSeconds}
|
||||
error={error}
|
||||
onPhoneChange={setResetPhone}
|
||||
onCodeChange={setResetCode}
|
||||
onPasswordChange={setResetPasswordValue}
|
||||
onBack={() => setIsResetPanelOpen(false)}
|
||||
onSendCode={async () => {
|
||||
const result = await onSendCode(resetPhone, 'reset_password');
|
||||
setResetCooldownSeconds(result.cooldownSeconds);
|
||||
}}
|
||||
onSubmit={() =>
|
||||
onResetPassword(resetPhone, resetCode, resetPasswordValue)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 px-5 py-5">
|
||||
{phoneLoginEnabled ? (
|
||||
<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
|
||||
@@ -273,10 +256,11 @@ export function LoginScreen({
|
||||
void onPasswordSubmit(phone, password);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
手机号
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
@@ -284,10 +268,11 @@ export function LoginScreen({
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
密码
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -300,7 +285,7 @@ export function LoginScreen({
|
||||
{legalConsentRow}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
disabled={
|
||||
submitDisabled ||
|
||||
@@ -308,10 +293,10 @@ export function LoginScreen({
|
||||
!password.trim() ||
|
||||
!legalConsentChecked
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
size="lg"
|
||||
>
|
||||
{loggingIn ? '登录中' : '登录'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
<button
|
||||
type="button"
|
||||
className="self-end text-sm text-[var(--platform-accent)]"
|
||||
@@ -370,14 +355,17 @@ export function LoginScreen({
|
||||
!phoneLoginEnabled &&
|
||||
!wechatLoginEnabled &&
|
||||
!miniProgramRuntime ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
<PlatformEmptyState
|
||||
surface="subpanel"
|
||||
size="compact"
|
||||
className="px-4 py-4"
|
||||
>
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
</PlatformEmptyState>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PlatformAuthModalShell>
|
||||
<LegalDocumentModal
|
||||
document={activeLegalDocument}
|
||||
open={Boolean(activeLegalDocument)}
|
||||
@@ -439,13 +427,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 +439,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,
|
||||
@@ -546,10 +499,11 @@ function PhoneCodeForm({
|
||||
}}
|
||||
>
|
||||
{showPhoneField ? (
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
手机号
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
@@ -559,20 +513,23 @@ function PhoneCodeForm({
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
验证码
|
||||
</PlatformFieldLabel>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input min-w-0 flex-1"
|
||||
<PlatformTextField
|
||||
className="min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
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 +537,7 @@ function PhoneCodeForm({
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -594,13 +551,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>
|
||||
);
|
||||
}
|
||||
@@ -642,10 +595,11 @@ function PasswordResetPanel({
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
手机号
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
@@ -653,20 +607,23 @@ function PasswordResetPanel({
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
验证码
|
||||
</PlatformFieldLabel>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input min-w-0 flex-1"
|
||||
<PlatformTextField
|
||||
className="min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
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,13 +631,14 @@ function PasswordResetPanel({
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>新密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
新密码
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
autoComplete="new-password"
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -692,22 +650,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 +677,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>
|
||||
);
|
||||
}
|
||||
|
||||
85
src/components/auth/PlatformAuthModalShell.test.tsx
Normal file
85
src/components/auth/PlatformAuthModalShell.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
|
||||
|
||||
test('renders auth modal shell with platform theme and auth card chrome', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformAuthModalShell
|
||||
title="账号入口"
|
||||
platformTheme="light"
|
||||
onClose={onClose}
|
||||
closeLabel="关闭登录弹窗"
|
||||
panelClassName="!max-w-md"
|
||||
>
|
||||
<div>登录表单</div>
|
||||
</PlatformAuthModalShell>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(dialog.className).toContain('platform-auth-card');
|
||||
expect(dialog.className).toContain('!max-w-md');
|
||||
expect(within(dialog).getByText('登录表单')).toBeTruthy();
|
||||
|
||||
fireEvent.click(dialog.parentElement as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('keeps escape disabled for auth flows', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformAuthModalShell
|
||||
title="请填写邀请码"
|
||||
platformTheme="dark"
|
||||
onClose={onClose}
|
||||
closeLabel="取消填写邀请码"
|
||||
zIndexClassName="z-[130]"
|
||||
>
|
||||
<div>邀请码表单</div>
|
||||
</PlatformAuthModalShell>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: '取消填写邀请码' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('allows account shell callers to own overlay spacing and panel size', () => {
|
||||
render(
|
||||
<PlatformAuthModalShell
|
||||
title="账号信息"
|
||||
platformTheme="light"
|
||||
onClose={vi.fn()}
|
||||
closeLabel="关闭账号弹窗"
|
||||
size="xl"
|
||||
showHeader={false}
|
||||
overlaySpacing="none"
|
||||
overlayClassName="!items-end"
|
||||
overlayStyle={{ paddingTop: '12px' }}
|
||||
authCardClassName=""
|
||||
panelClassName="!max-w-3xl !bg-transparent"
|
||||
bodyClassName="!p-0"
|
||||
>
|
||||
<div>账号内容</div>
|
||||
</PlatformAuthModalShell>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const overlay = dialog.parentElement as HTMLElement;
|
||||
|
||||
expect(overlay.className).toContain('platform-theme--light');
|
||||
expect(overlay.className).not.toContain('!px-3');
|
||||
expect(overlay.style.paddingTop).toBe('12px');
|
||||
expect(dialog.className).toContain('!max-w-3xl');
|
||||
expect(dialog.className).not.toContain('platform-auth-card');
|
||||
expect(within(dialog).queryByRole('button', { name: '关闭账号弹窗' })).toBeNull();
|
||||
});
|
||||
80
src/components/auth/PlatformAuthModalShell.tsx
Normal file
80
src/components/auth/PlatformAuthModalShell.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
|
||||
type PlatformAuthModalShellProps = {
|
||||
title: string;
|
||||
platformTheme: PlatformTheme;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
closeLabel: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
|
||||
showHeader?: boolean;
|
||||
overlaySpacing?: 'default' | 'none';
|
||||
zIndexClassName?: string;
|
||||
overlayClassName?: string;
|
||||
overlayStyle?: CSSProperties;
|
||||
authCardClassName?: string;
|
||||
panelClassName?: string;
|
||||
bodyClassName?: string;
|
||||
panelStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证入口弹窗共享壳层。
|
||||
* 这里只统一主题遮罩、auth card、标题栏和关闭按钮,登录 / 邀请码表单状态仍留在各自业务组件。
|
||||
*/
|
||||
export function PlatformAuthModalShell({
|
||||
title,
|
||||
platformTheme,
|
||||
onClose,
|
||||
children,
|
||||
closeLabel,
|
||||
size = 'sm',
|
||||
showHeader = true,
|
||||
overlaySpacing = 'default',
|
||||
zIndexClassName = 'z-[120]',
|
||||
overlayClassName,
|
||||
overlayStyle,
|
||||
authCardClassName = 'platform-auth-card !rounded-[2rem] sm:!rounded-[2rem]',
|
||||
panelClassName,
|
||||
bodyClassName = '!p-0',
|
||||
panelStyle,
|
||||
}: PlatformAuthModalShellProps) {
|
||||
return (
|
||||
<UnifiedModal
|
||||
open
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
closeLabel={closeLabel}
|
||||
closeVariant="platformIcon"
|
||||
closeOnBackdrop
|
||||
closeOnEscape={false}
|
||||
portal={false}
|
||||
size={size}
|
||||
showHeader={showHeader}
|
||||
zIndexClassName={zIndexClassName}
|
||||
overlayClassName={joinClassNames(
|
||||
`platform-theme platform-theme--${platformTheme} text-[var(--platform-text-strong)]`,
|
||||
overlaySpacing === 'default' && '!px-3 !py-4 sm:!p-4',
|
||||
overlayClassName,
|
||||
)}
|
||||
overlayStyle={overlayStyle}
|
||||
panelClassName={joinClassNames(
|
||||
authCardClassName,
|
||||
panelClassName,
|
||||
)}
|
||||
headerClassName="!items-center !px-5 !py-4"
|
||||
titleClassName="text-lg font-semibold text-[var(--platform-text-strong)]"
|
||||
bodyClassName={bodyClassName}
|
||||
panelStyle={panelStyle}
|
||||
>
|
||||
{children}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
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 { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
|
||||
|
||||
type RegistrationInviteModalProps = {
|
||||
isOpen: boolean;
|
||||
@@ -45,71 +49,48 @@ export function RegistrationInviteModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[130] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={onClose}
|
||||
<PlatformAuthModalShell
|
||||
title="请填写邀请码"
|
||||
platformTheme={platformTheme}
|
||||
onClose={onClose}
|
||||
closeLabel="取消填写邀请码"
|
||||
zIndexClassName="z-[130]"
|
||||
panelClassName="!max-w-sm"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="registration-invite-dialog-title"
|
||||
className="platform-auth-card w-full max-w-sm overflow-hidden rounded-[2rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
<form
|
||||
className="flex flex-col gap-4 px-5 py-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!normalizedInviteCode) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
void onSubmit(normalizedInviteCode);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div
|
||||
id="registration-invite-dialog-title"
|
||||
className="text-lg font-semibold text-[var(--platform-text-strong)]"
|
||||
>
|
||||
请填写邀请码
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-icon-button p-2"
|
||||
aria-label="取消填写邀请码"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
className="flex flex-col gap-4 px-5 py-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!normalizedInviteCode) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
<label className="grid gap-2">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
邀请码
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
autoComplete="off"
|
||||
value={inviteCode}
|
||||
onChange={(event) => setInviteCode(event.target.value)}
|
||||
placeholder="邀请码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
void onSubmit(normalizedInviteCode);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>邀请码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="off"
|
||||
value={inviteCode}
|
||||
onChange={(event) => setInviteCode(event.target.value)}
|
||||
placeholder="邀请码"
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<PlatformStatusMessage tone="error" surface="profile">
|
||||
{error}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<PlatformActionButton type="submit" disabled={submitting} size="lg">
|
||||
{submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
|
||||
</PlatformActionButton>
|
||||
</form>
|
||||
</PlatformAuthModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { ArrowLeft, Loader2, Play } from 'lucide-react';
|
||||
import { Loader2, Play } from 'lucide-react';
|
||||
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 { PlatformBackActionButton } from '../common/PlatformBackActionButton';
|
||||
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 +24,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 +72,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 +152,11 @@ export function BarkBattleConfigEditor({
|
||||
>
|
||||
{showBackButton && onBack ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformBackActionButton
|
||||
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' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
className="px-3"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -164,9 +167,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 +179,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 +210,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 +219,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 +229,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 +254,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 +295,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 +332,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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -84,31 +85,41 @@ 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.getByRole('button', { name: '返回编辑' }).className,
|
||||
).toContain('bg-transparent');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回编辑' }).className,
|
||||
).toContain('gap-2');
|
||||
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 +133,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 +192,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 +226,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: '玩家形象 进度' })
|
||||
@@ -478,4 +486,42 @@ describe('BarkBattleGeneratingView', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the shared generation back button disabled state and click behavior', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
vi.mocked(generateAllBarkBattleImageAssets).mockReturnValue(
|
||||
new Promise<BarkBattleImageGenerationBatchResult>(() => {}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<BarkBattleGeneratingView
|
||||
draft={draft}
|
||||
isBusy
|
||||
onBack={onBack}
|
||||
onComplete={() => {}}
|
||||
onError={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const busyBackButton = screen.getByRole('button', { name: '返回编辑' });
|
||||
expect(busyBackButton.getAttribute('disabled')).toBe('');
|
||||
expect(busyBackButton.style.opacity).toBe('0.45');
|
||||
|
||||
await user.click(busyBackButton);
|
||||
expect(onBack).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<BarkBattleGeneratingView
|
||||
draft={draft}
|
||||
isBusy={false}
|
||||
onBack={onBack}
|
||||
onComplete={() => {}}
|
||||
onError={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回编辑' }));
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
@@ -12,8 +11,10 @@ import {
|
||||
generateAllBarkBattleImageAssets,
|
||||
updateBarkBattleDraftConfig,
|
||||
} from '../../services/bark-battle-creation';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import {
|
||||
GenerationCurrentStepCard,
|
||||
GenerationHeaderBackButton,
|
||||
GenerationPageBackdrop,
|
||||
GenerationProgressHero,
|
||||
} from '../GenerationProgressHero';
|
||||
@@ -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) {
|
||||
@@ -357,18 +367,19 @@ export function BarkBattleGeneratingView({
|
||||
<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 xl:px-6 xl:pb-5 xl:pt-5">
|
||||
<GenerationPageBackdrop />
|
||||
<div className="relative z-30 mx-auto mb-4 flex w-full max-w-[48rem] shrink-0 items-center justify-between gap-3 sm:mb-5">
|
||||
<button
|
||||
type="button"
|
||||
<GenerationHeaderBackButton
|
||||
label="返回编辑"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
|
||||
disabledOpacity={0.45}
|
||||
/>
|
||||
<PlatformPillBadge
|
||||
tone="warning"
|
||||
size="xs"
|
||||
className="px-3 py-1.5 tracking-[0.08em] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" 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">
|
||||
生成中
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -140,6 +157,26 @@ function createSession(): BigFishSessionSnapshotResponse {
|
||||
}
|
||||
|
||||
describe('BigFishResultView', () => {
|
||||
test('uses PlatformEmptyState chrome when draft is missing', () => {
|
||||
render(
|
||||
<BigFishResultView
|
||||
session={{
|
||||
...createSession(),
|
||||
draft: null,
|
||||
}}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emptyState = screen.getByText('还没有可编辑的玩法草稿');
|
||||
|
||||
expect(emptyState.className).toContain('platform-empty-state');
|
||||
expect(emptyState.className).toContain('bg-white/74');
|
||||
expect(emptyState.className).toContain('text-[var(--platform-text-base)]');
|
||||
});
|
||||
|
||||
test('renders generated formal previews with accurate status copy', () => {
|
||||
render(
|
||||
<BigFishResultView
|
||||
@@ -151,8 +188,96 @@ 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]');
|
||||
for (const label of ['猎物 1', '威胁 2', '主图 已生成']) {
|
||||
const badge = screen.getByText(label);
|
||||
|
||||
expect(badge.className).toContain('rounded-full');
|
||||
expect(badge.className).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]');
|
||||
const blockerStatus = findNearestClassName(
|
||||
screen.getByText('还缺少 2 个基础动作'),
|
||||
'platform-status-message',
|
||||
);
|
||||
expect(blockerStatus?.className).toContain('platform-status-message');
|
||||
expect(blockerStatus?.className).toContain(
|
||||
'border-[var(--platform-warm-border)]',
|
||||
);
|
||||
expect(blockerStatus?.className).toContain(
|
||||
'bg-[var(--platform-warm-bg)]',
|
||||
);
|
||||
for (const label of ['弱小逆袭', '深海谜境', '1 级']) {
|
||||
const badge = screen
|
||||
.getAllByText(label)
|
||||
.find((element) => element.className.includes('inline-flex'));
|
||||
|
||||
if (!badge) {
|
||||
throw new Error(`missing hero badge for ${label}`);
|
||||
}
|
||||
expect(badge.className).toContain('inline-flex');
|
||||
expect(badge.className).toContain('rounded-full');
|
||||
expect(badge.className).toContain('border-transparent');
|
||||
}
|
||||
});
|
||||
|
||||
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 +291,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 +321,54 @@ 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('reuses shared hero action chrome for top-level result actions', () => {
|
||||
render(
|
||||
<BigFishResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '返回' }).className).toContain(
|
||||
'rounded-full',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '测试' }).className).toContain(
|
||||
'platform-action-button--editor-dark',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '发布' }).className).toContain(
|
||||
'platform-action-button--editor-dark',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows publish failures in a dismissible modal', () => {
|
||||
const onDismissError = vi.fn();
|
||||
|
||||
@@ -202,6 +388,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 +427,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();
|
||||
});
|
||||
|
||||
@@ -16,8 +16,16 @@ import type {
|
||||
BigFishSessionSnapshotResponse,
|
||||
ExecuteBigFishActionRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
|
||||
type BigFishAssetStudioTarget =
|
||||
| {
|
||||
@@ -94,12 +102,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 +171,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 +224,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 +240,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,37 +268,48 @@ 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)]">
|
||||
{level.oneLineFantasy}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-[var(--platform-text-soft)]">
|
||||
<span>猎物 {level.preyWindow.join('/') || '-'}</span>
|
||||
<span>威胁 {level.threatWindow.join('/') || '-'}</span>
|
||||
<span>主图 {assetReadyLabel(mainImageSlot)}</span>
|
||||
<span>
|
||||
动作 {[assetReadyLabel(idleSlot), assetReadyLabel(moveSlot)].join('/')}
|
||||
</span>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<PlatformPillBadge tone="muted" size="xxs">
|
||||
猎物 {level.preyWindow.join('/') || '-'}
|
||||
</PlatformPillBadge>
|
||||
<PlatformPillBadge tone="muted" size="xxs">
|
||||
威胁 {level.threatWindow.join('/') || '-'}
|
||||
</PlatformPillBadge>
|
||||
<PlatformPillBadge tone="muted" size="xxs">
|
||||
主图 {assetReadyLabel(mainImageSlot)}
|
||||
</PlatformPillBadge>
|
||||
<PlatformPillBadge tone="muted" size="xxs">
|
||||
动作{' '}
|
||||
{[assetReadyLabel(idleSlot), assetReadyLabel(moveSlot)].join('/')}
|
||||
</PlatformPillBadge>
|
||||
</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 +318,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 +334,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 +383,13 @@ 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)]">
|
||||
<PlatformEmptyState
|
||||
surface="subpanel"
|
||||
size="compact"
|
||||
tone="base"
|
||||
>
|
||||
还没有可编辑的玩法草稿
|
||||
</div>
|
||||
</PlatformEmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -377,34 +398,39 @@ export function BigFishResultView({
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="platform-result-hero relative overflow-hidden rounded-[1.8rem] border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.2),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformIconButton
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
label="返回"
|
||||
title="返回"
|
||||
variant="darkMini"
|
||||
className="h-10 w-10 !border-white/16 !bg-white/10 !text-white/84 backdrop-blur hover:!bg-white/16 hover:!text-white"
|
||||
icon={<ArrowLeft className="h-4 w-4" />}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onStartTestRun();
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
|
||||
surface="editorDark"
|
||||
tone="secondary"
|
||||
shape="pill"
|
||||
className="!border-white/16 !bg-white/12 !text-white hover:!bg-white/18"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
disabled={!canClickPublish}
|
||||
onClick={() => {
|
||||
setIsPublishSubmitting(true);
|
||||
onExecuteAction({ action: 'big_fish_publish_game' });
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-cyan-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
surface="editorDark"
|
||||
tone="primary"
|
||||
shape="pill"
|
||||
className="!border-cyan-200/70 !bg-cyan-200 !text-slate-950 hover:!bg-cyan-100"
|
||||
>
|
||||
{isPublishSubmitting && isBusy && !isPublished ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -416,7 +442,7 @@ export function BigFishResultView({
|
||||
: isPublishSubmitting && isBusy
|
||||
? '发布中'
|
||||
: '发布'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
@@ -428,15 +454,24 @@ export function BigFishResultView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-cyan-50/78">
|
||||
<span className="rounded-full bg-white/10 px-3 py-1">
|
||||
<PlatformPillBadge
|
||||
tone="lightOverlay"
|
||||
className="border-transparent bg-white/10"
|
||||
>
|
||||
{draft.coreFun}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-3 py-1">
|
||||
</PlatformPillBadge>
|
||||
<PlatformPillBadge
|
||||
tone="lightOverlay"
|
||||
className="border-transparent bg-white/10"
|
||||
>
|
||||
{draft.ecologyTheme}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-3 py-1">
|
||||
</PlatformPillBadge>
|
||||
<PlatformPillBadge
|
||||
tone="lightOverlay"
|
||||
className="border-transparent bg-white/10"
|
||||
>
|
||||
{draft.runtimeParams.levelCount} 级
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -456,7 +491,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 +508,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,25 +551,35 @@ 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">
|
||||
<PlatformStatusMessage
|
||||
tone="warning"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="mt-3 space-y-1 leading-5"
|
||||
>
|
||||
{blockers.slice(0, 4).map((blocker) => (
|
||||
<div key={blocker}>{blocker}</div>
|
||||
))}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : (
|
||||
<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 +619,20 @@ function BigFishResultErrorModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnifiedModal
|
||||
open
|
||||
<PlatformAcknowledgeStatusDialog
|
||||
status="error"
|
||||
title="发布失败"
|
||||
description={message}
|
||||
onClose={onClose}
|
||||
closeOnBackdrop={false}
|
||||
showCloseButton={false}
|
||||
size="sm"
|
||||
icon={<Waves className="h-4 w-4" />}
|
||||
iconLabel="发布失败提示"
|
||||
iconClassName="mt-0.5 bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]"
|
||||
actionClassName="border-slate-950 bg-slate-950 text-white"
|
||||
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>
|
||||
<div className="min-w-0 flex-1 text-sm leading-6 text-slate-600">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
bodyClassName="px-5 pb-5 pt-6 text-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
73
src/components/common/CopyCodeButton.test.tsx
Normal file
73
src/components/common/CopyCodeButton.test.tsx
Normal 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');
|
||||
});
|
||||
133
src/components/common/CopyCodeButton.tsx
Normal file
133
src/components/common/CopyCodeButton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
141
src/components/common/CopyFeedbackButton.test.tsx
Normal file
141
src/components/common/CopyFeedbackButton.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/* @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"
|
||||
actionShape="pill"
|
||||
actionFullWidth
|
||||
aria-label="复制错误详情"
|
||||
title="复制错误详情"
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '复制错误详情' });
|
||||
|
||||
expect(button.className).toContain('platform-button--primary');
|
||||
expect(button.className).toContain('w-full');
|
||||
expect(button.className).toContain('rounded-full');
|
||||
expect(button.className).toContain('disabled:cursor-not-allowed');
|
||||
expect(button.getAttribute('title')).toBe('复制错误详情');
|
||||
expect(button.textContent).toContain('复制报错');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
157
src/components/common/CopyFeedbackButton.tsx
Normal file
157
src/components/common/CopyFeedbackButton.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
type PlatformActionButtonSize,
|
||||
type PlatformActionButtonShape,
|
||||
type PlatformActionButtonSurface,
|
||||
type PlatformActionButtonTone,
|
||||
} from './platformActionButtonModel';
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
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;
|
||||
actionShape?: PlatformActionButtonShape;
|
||||
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',
|
||||
actionShape = 'default',
|
||||
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);
|
||||
const resolvedAriaLabel = ariaLabel ?? accessibleLabel;
|
||||
const resolvedTitle =
|
||||
title ?? (typeof accessibleLabel === 'string' ? accessibleLabel : undefined);
|
||||
const content = (
|
||||
<>
|
||||
{showIcon ? icon : null}
|
||||
{showLabel ? <span className={labelClassName}>{label}</span> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (actionSurface) {
|
||||
return (
|
||||
<PlatformActionButton
|
||||
surface={actionSurface}
|
||||
tone={actionTone}
|
||||
size={actionSize}
|
||||
shape={actionShape}
|
||||
fullWidth={actionFullWidth}
|
||||
className={className}
|
||||
{...buttonProps}
|
||||
aria-label={resolvedAriaLabel}
|
||||
title={resolvedTitle}
|
||||
>
|
||||
{content}
|
||||
</PlatformActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
actionAppearance === 'pill'
|
||||
? getPlatformPillBadgeClassName({
|
||||
tone: actionPillTone,
|
||||
size: actionPillSize,
|
||||
})
|
||||
: null,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...buttonProps}
|
||||
aria-label={resolvedAriaLabel}
|
||||
title={resolvedTitle}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
39
src/components/common/CopyFeedbackMessage.test.tsx
Normal file
39
src/components/common/CopyFeedbackMessage.test.tsx
Normal 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();
|
||||
});
|
||||
29
src/components/common/CopyFeedbackMessage.tsx
Normal file
29
src/components/common/CopyFeedbackMessage.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,24 @@ test('creative image input panel handles reference uploads and preview', () => {
|
||||
const promptReferenceInput = screen.getByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
const promptTextarea = screen.getByLabelText('画面描述');
|
||||
const imageFieldLabel = screen.getByText('拼图画面');
|
||||
const promptFieldLabel = screen.getByText('画面描述');
|
||||
const emptyMainImageIconBadge = document.querySelector(
|
||||
'[aria-hidden="true"]',
|
||||
);
|
||||
expect(imageFieldLabel.className).toContain('shrink-0');
|
||||
expect(imageFieldLabel.className).toContain('text-sm font-black');
|
||||
expect(promptFieldLabel.className).toContain('mb-0');
|
||||
expect(promptFieldLabel.className).toContain('text-sm font-black');
|
||||
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,27 +96,25 @@ 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'),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
|
||||
expect(screen.queryByRole('dialog', { name: '参考图 1' })).toBeNull();
|
||||
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 +155,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 +202,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');
|
||||
@@ -235,6 +256,54 @@ test('creative image input panel confirms before removing uploaded image', () =>
|
||||
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel closes reference preview on backdrop click', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc=""
|
||||
uploadedImageAlt="拼图图片"
|
||||
mainImageInputId="image-upload-input"
|
||||
promptTextareaId="image-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[
|
||||
{
|
||||
id: 'ref-1',
|
||||
label: '参考图 1',
|
||||
imageSrc: 'data:image/png;base64,ref-1',
|
||||
},
|
||||
]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="生成"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '拼图画面',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onPromptReferenceFilesSelect={() => {}}
|
||||
onPromptReferenceRemove={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览参考图 参考图 1' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '参考图 1' });
|
||||
fireEvent.click(dialog.parentElement as HTMLElement);
|
||||
expect(screen.queryByRole('dialog', { name: '参考图 1' })).toBeNull();
|
||||
});
|
||||
|
||||
test('creative image input panel supports a preview-only main image mode', () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
@@ -332,25 +401,76 @@ 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: '关闭关卡图片预览' }));
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '查看关卡图片' }),
|
||||
).toBeNull();
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test('creative image input panel closes main image preview on backdrop click', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
mainImageClickMode="preview"
|
||||
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: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
closeMainImagePreview: '关闭关卡图片预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '查看关卡图片' });
|
||||
fireEvent.click(dialog.parentElement as HTMLElement);
|
||||
expect(screen.queryByRole('dialog', { name: '查看关卡图片' })).toBeNull();
|
||||
});
|
||||
|
||||
test('creative image input panel can hide upload and history controls independently', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
@@ -399,6 +519,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 +701,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();
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
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 { PlatformFieldLabel } from './PlatformFieldLabel';
|
||||
import { PlatformIconBadge } from './PlatformIconBadge';
|
||||
import { PlatformIconButton } from './PlatformIconButton';
|
||||
import { PlatformPillBadge } from './PlatformPillBadge';
|
||||
import { PlatformPillSwitch } from './PlatformPillSwitch';
|
||||
import { PlatformStatusMessage } from './PlatformStatusMessage';
|
||||
import { PlatformTextField } from './PlatformTextField';
|
||||
import { PlatformUploadPreviewCard } from './PlatformUploadPreviewCard';
|
||||
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
export type CreativeImageInputReferenceImage = {
|
||||
id: string;
|
||||
@@ -48,6 +52,7 @@ export type CreativeImageInputPanelProps = {
|
||||
uploadedImageSrc: string;
|
||||
uploadedImageAlt: string;
|
||||
uploadedImageRefreshKey?: string | number | null;
|
||||
mainImagePreviewZIndexClassName?: string;
|
||||
mainImageMeta?: ReactNode;
|
||||
mainImageInputId: string;
|
||||
mainImageAccept?: string;
|
||||
@@ -96,6 +101,7 @@ export function CreativeImageInputPanel({
|
||||
uploadedImageSrc,
|
||||
uploadedImageAlt,
|
||||
uploadedImageRefreshKey = null,
|
||||
mainImagePreviewZIndexClassName = 'z-[82]',
|
||||
mainImageMeta = null,
|
||||
mainImageInputId,
|
||||
mainImageAccept = DEFAULT_IMAGE_ACCEPT,
|
||||
@@ -131,7 +137,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 =
|
||||
@@ -201,9 +208,9 @@ export function CreativeImageInputPanel({
|
||||
disabled ? 'opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
<PlatformFieldLabel variant="form" className="shrink-0">
|
||||
{labels.imageField}
|
||||
</div>
|
||||
</PlatformFieldLabel>
|
||||
<div className={imageFrameClassName}>
|
||||
<div className={imageCardClassName}>
|
||||
{isMainImageUploadEnabled ? (
|
||||
@@ -258,76 +265,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 +336,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}
|
||||
@@ -352,88 +348,89 @@ export function CreativeImageInputPanel({
|
||||
|
||||
{showPrompt ? (
|
||||
<div className="block shrink-0 lg:min-h-0">
|
||||
<label
|
||||
htmlFor={promptTextareaId}
|
||||
className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{promptLabel}
|
||||
<label htmlFor={promptTextareaId} className="mb-2 block">
|
||||
<PlatformFieldLabel variant="form" className="mb-0">
|
||||
{promptLabel}
|
||||
</PlatformFieldLabel>
|
||||
</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 +440,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 +465,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,135 +479,89 @@ 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}
|
||||
|
||||
{previewReferenceImage ? (
|
||||
<div
|
||||
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"
|
||||
onClick={() => setPreviewReferenceImage(null)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="creative-image-reference-preview-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-2xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<div
|
||||
id="creative-image-reference-preview-title"
|
||||
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{previewReferenceImage.label}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={labels.closePromptReferencePreview}
|
||||
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>
|
||||
</div>
|
||||
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={previewReferenceImage.imageSrc}
|
||||
alt={labels.promptReferencePreviewAlt}
|
||||
className="h-full max-h-[72vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<UnifiedModal
|
||||
open={Boolean(previewReferenceImage)}
|
||||
title={previewReferenceImage?.label ?? labels.promptReferencePreviewAlt}
|
||||
onClose={() => setPreviewReferenceImage(null)}
|
||||
closeLabel={labels.closePromptReferencePreview}
|
||||
closeVariant="profileCompact"
|
||||
size="lg"
|
||||
zIndexClassName="z-[80]"
|
||||
overlayClassName="px-4 py-6"
|
||||
panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
headerClassName="mb-3 items-center border-b-0 px-1 py-0"
|
||||
titleClassName="text-sm font-black"
|
||||
bodyClassName="px-0 py-0"
|
||||
>
|
||||
{previewReferenceImage ? (
|
||||
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={previewReferenceImage.imageSrc}
|
||||
alt={labels.promptReferencePreviewAlt}
|
||||
className="h-full max-h-[72vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
|
||||
{isMainImagePreviewOpen && uploadedImageSrc ? (
|
||||
<div
|
||||
className="platform-modal-backdrop fixed inset-0 z-[82] flex items-center justify-center px-4 py-6"
|
||||
onClick={() => setIsMainImagePreviewOpen(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="creative-image-main-preview-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-4xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<div
|
||||
id="creative-image-main-preview-title"
|
||||
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{labels.previewMainImage ?? uploadedImageAlt}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
|
||||
}
|
||||
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>
|
||||
</div>
|
||||
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={uploadedImageSrc}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
alt={uploadedImageAlt}
|
||||
className="h-full max-h-[82vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<UnifiedModal
|
||||
open={isMainImagePreviewOpen && Boolean(uploadedImageSrc)}
|
||||
title={labels.previewMainImage ?? uploadedImageAlt}
|
||||
onClose={() => setIsMainImagePreviewOpen(false)}
|
||||
closeLabel={
|
||||
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
|
||||
}
|
||||
closeVariant="profileCompact"
|
||||
size="xl"
|
||||
zIndexClassName={mainImagePreviewZIndexClassName}
|
||||
overlayClassName="px-4 py-6"
|
||||
panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
headerClassName="mb-3 items-center border-b-0 px-1 py-0"
|
||||
titleClassName="text-sm font-black"
|
||||
bodyClassName="px-0 py-0"
|
||||
>
|
||||
{uploadedImageSrc ? (
|
||||
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={uploadedImageSrc}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
alt={uploadedImageAlt}
|
||||
className="h-full max-h-[82vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
|
||||
{isRemoveImageConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="creative-image-remove-confirm-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div
|
||||
id="creative-image-remove-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{labels.removeImageConfirmTitle}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{labels.removeImageConfirmBody}
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRemoveImageConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onMainImageRemove();
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
}}
|
||||
className="platform-button platform-button--primary justify-center"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<UnifiedConfirmDialog
|
||||
open={isRemoveImageConfirmOpen}
|
||||
title={labels.removeImageConfirmTitle}
|
||||
description={labels.removeImageConfirmBody}
|
||||
onClose={() => setIsRemoveImageConfirmOpen(false)}
|
||||
confirmLabel="移除"
|
||||
cancelLabel="取消"
|
||||
showCancel
|
||||
onConfirm={() => {
|
||||
onMainImageRemove();
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
}}
|
||||
size="sm"
|
||||
zIndexClassName="z-[80]"
|
||||
overlayClassName="px-4 py-6"
|
||||
panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformAcknowledgeStatusDialog } from './PlatformAcknowledgeStatusDialog';
|
||||
|
||||
test('renders a standard acknowledge action and closes through 知道了', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformAcknowledgeStatusDialog
|
||||
status="error"
|
||||
title="提示"
|
||||
description="至少保留一个可扮演角色。"
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const action = screen.getByRole('button', { name: '知道了' });
|
||||
|
||||
expect(action.className).toContain('platform-button');
|
||||
fireEvent.click(action);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('supports custom action styling and header notice layout', () => {
|
||||
render(
|
||||
<PlatformAcknowledgeStatusDialog
|
||||
status="error"
|
||||
title="发布失败"
|
||||
description="还缺少 16 个基础动作"
|
||||
onClose={() => {}}
|
||||
showHeader
|
||||
showCloseButton
|
||||
closeOnBackdrop
|
||||
iconLabel="发布失败提示"
|
||||
actionClassName="border-slate-950 bg-slate-950 text-white"
|
||||
/>,
|
||||
);
|
||||
|
||||
const action = screen.getByRole('button', { name: '知道了' });
|
||||
const dialog = screen.getByRole('dialog', { name: '发布失败' });
|
||||
|
||||
expect(action.className).toContain('border-slate-950');
|
||||
expect(action.className).toContain('bg-slate-950');
|
||||
expect(dialog.querySelector('[aria-label="发布失败提示"]')).toBeTruthy();
|
||||
});
|
||||
111
src/components/common/PlatformAcknowledgeStatusDialog.tsx
Normal file
111
src/components/common/PlatformAcknowledgeStatusDialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type {
|
||||
PlatformActionButtonSize,
|
||||
PlatformActionButtonSurface,
|
||||
PlatformActionButtonTone,
|
||||
} from './platformActionButtonModel';
|
||||
import {
|
||||
PlatformStatusDialog,
|
||||
type PlatformStatusDialogStatus,
|
||||
} from './PlatformStatusDialog';
|
||||
|
||||
type PlatformAcknowledgeStatusDialogProps = {
|
||||
open?: boolean;
|
||||
status: PlatformStatusDialogStatus;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
onClose: () => void;
|
||||
actionLabel?: string;
|
||||
actionDisabled?: boolean;
|
||||
actionTone?: PlatformActionButtonTone;
|
||||
actionSurface?: PlatformActionButtonSurface;
|
||||
actionSize?: PlatformActionButtonSize;
|
||||
actionFullWidth?: boolean;
|
||||
actionClassName?: string;
|
||||
showHeader?: boolean;
|
||||
showBodyTitle?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
closeOnBackdrop?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
closeLabel?: string;
|
||||
closeDisabled?: boolean;
|
||||
zIndexClassName?: string;
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
bodyClassName?: string;
|
||||
iconClassName?: string;
|
||||
icon?: ReactNode;
|
||||
iconLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台已读确认状态弹窗。
|
||||
* 统一承接“状态提示 + 知道了”这一类单按钮确认已读的弹窗语义。
|
||||
*/
|
||||
export function PlatformAcknowledgeStatusDialog({
|
||||
open,
|
||||
status,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
onClose,
|
||||
actionLabel = '知道了',
|
||||
actionDisabled = false,
|
||||
actionTone,
|
||||
actionSurface = 'platform',
|
||||
actionSize,
|
||||
actionFullWidth,
|
||||
actionClassName,
|
||||
showHeader,
|
||||
showBodyTitle,
|
||||
showCloseButton,
|
||||
closeOnBackdrop,
|
||||
closeOnEscape,
|
||||
closeLabel,
|
||||
closeDisabled,
|
||||
zIndexClassName,
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
bodyClassName,
|
||||
iconClassName,
|
||||
icon,
|
||||
iconLabel,
|
||||
}: PlatformAcknowledgeStatusDialogProps) {
|
||||
return (
|
||||
<PlatformStatusDialog
|
||||
open={open}
|
||||
status={status}
|
||||
title={title}
|
||||
description={description}
|
||||
onClose={onClose}
|
||||
showHeader={showHeader}
|
||||
showBodyTitle={showBodyTitle}
|
||||
showCloseButton={showCloseButton}
|
||||
closeOnBackdrop={closeOnBackdrop}
|
||||
closeOnEscape={closeOnEscape}
|
||||
closeLabel={closeLabel}
|
||||
closeDisabled={closeDisabled}
|
||||
zIndexClassName={zIndexClassName}
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
bodyClassName={bodyClassName}
|
||||
iconClassName={iconClassName}
|
||||
icon={icon}
|
||||
iconLabel={iconLabel}
|
||||
action={{
|
||||
label: actionLabel,
|
||||
onClick: onClose,
|
||||
disabled: actionDisabled,
|
||||
tone: actionTone,
|
||||
surface: actionSurface,
|
||||
size: actionSize,
|
||||
fullWidth: actionFullWidth,
|
||||
className: actionClassName,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PlatformStatusDialog>
|
||||
);
|
||||
}
|
||||
111
src/components/common/PlatformActionButton.test.tsx
Normal file
111
src/components/common/PlatformActionButton.test.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/* @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]');
|
||||
});
|
||||
|
||||
test('supports accent action tone', () => {
|
||||
render(
|
||||
<PlatformActionButton surface="editorDark" tone="accent" size="lg" fullWidth>
|
||||
生成
|
||||
</PlatformActionButton>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '生成' });
|
||||
|
||||
expect(button.className).toContain('platform-action-button--accent');
|
||||
expect(button.className).toContain('bg-amber-200');
|
||||
expect(button.className).toContain('text-slate-950');
|
||||
expect(button.className).toContain('h-12');
|
||||
expect(button.className).toContain('w-full');
|
||||
});
|
||||
|
||||
test('supports accent soft action tone', () => {
|
||||
render(
|
||||
<PlatformActionButton
|
||||
tone="accentSoft"
|
||||
className="[--platform-action-accent:var(--platform-work-like-accent,#c7653d)]"
|
||||
>
|
||||
点赞
|
||||
</PlatformActionButton>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '点赞' });
|
||||
|
||||
expect(button.className).toContain('platform-action-button--accent-soft');
|
||||
expect(button.className).toContain('[color:var(--platform-action-accent,#c7653d)]');
|
||||
expect(button.className).toContain(
|
||||
'[--platform-action-accent:var(--platform-work-like-accent,#c7653d)]',
|
||||
);
|
||||
});
|
||||
95
src/components/common/PlatformActionButton.tsx
Normal file
95
src/components/common/PlatformActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
src/components/common/PlatformAssetPickerCard.test.tsx
Normal file
264
src/components/common/PlatformAssetPickerCard.test.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
/* @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('asset grid keeps error banner while loading state remains mutually exclusive with empty state', () => {
|
||||
render(
|
||||
<PlatformAssetPickerGrid
|
||||
items={[]}
|
||||
isLoading
|
||||
error="历史素材读取失败。"
|
||||
loadingLabel="读取中..."
|
||||
emptyLabel="暂无历史素材"
|
||||
getKey={(item: { id: string }) => item.id}
|
||||
getImageSrc={(item) => item.id}
|
||||
getImageAlt={() => ''}
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('历史素材读取失败。')).toBeTruthy();
|
||||
expect(screen.getByText('读取中...')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无历史素材')).toBeNull();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
322
src/components/common/PlatformAssetPickerCard.tsx
Normal file
322
src/components/common/PlatformAssetPickerCard.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { ButtonHTMLAttributes, Key, ReactNode } from 'react';
|
||||
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
|
||||
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>) {
|
||||
const sharedEmptyStateClassName = [
|
||||
PLATFORM_ASSET_PICKER_GRID_EMPTY_CLASS[surface],
|
||||
emptyClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<>
|
||||
{error ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface={PLATFORM_ASSET_PICKER_GRID_STATUS_SURFACE[surface]}
|
||||
size="md"
|
||||
className={statusClassName}
|
||||
>
|
||||
{error}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<PlatformEmptyState
|
||||
surface="dashed"
|
||||
size="panel"
|
||||
className={sharedEmptyStateClassName}
|
||||
>
|
||||
{loadingLabel}
|
||||
</PlatformEmptyState>
|
||||
}
|
||||
isEmpty={items.length <= 0}
|
||||
emptyState={
|
||||
<PlatformEmptyState
|
||||
surface="dashed"
|
||||
size="panel"
|
||||
className={sharedEmptyStateClassName}
|
||||
>
|
||||
{emptyLabel}
|
||||
</PlatformEmptyState>
|
||||
}
|
||||
>
|
||||
{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}
|
||||
</PlatformAsyncStatePanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
src/components/common/PlatformAsyncStatePanel.test.tsx
Normal file
61
src/components/common/PlatformAsyncStatePanel.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
|
||||
|
||||
describe('PlatformAsyncStatePanel', () => {
|
||||
test('prefers error state over loading and content', () => {
|
||||
render(
|
||||
<PlatformAsyncStatePanel
|
||||
errorState={<div>出错了</div>}
|
||||
isLoading
|
||||
loadingState={<div>读取中</div>}
|
||||
>
|
||||
<div>内容</div>
|
||||
</PlatformAsyncStatePanel>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('出错了')).toBeTruthy();
|
||||
expect(screen.queryByText('读取中')).toBeNull();
|
||||
expect(screen.queryByText('内容')).toBeNull();
|
||||
});
|
||||
|
||||
test('renders loading state before empty state', () => {
|
||||
render(
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading
|
||||
isEmpty
|
||||
loadingState={<div>读取中</div>}
|
||||
emptyState={<div>暂无内容</div>}
|
||||
>
|
||||
<div>内容</div>
|
||||
</PlatformAsyncStatePanel>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('读取中')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无内容')).toBeNull();
|
||||
});
|
||||
|
||||
test('renders empty state when requested', () => {
|
||||
render(
|
||||
<PlatformAsyncStatePanel isEmpty emptyState={<div>暂无内容</div>}>
|
||||
<div>内容</div>
|
||||
</PlatformAsyncStatePanel>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('暂无内容')).toBeTruthy();
|
||||
expect(screen.queryByText('内容')).toBeNull();
|
||||
});
|
||||
|
||||
test('falls back to content when no async state is active', () => {
|
||||
render(
|
||||
<PlatformAsyncStatePanel>
|
||||
<div>内容</div>
|
||||
</PlatformAsyncStatePanel>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('内容')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
37
src/components/common/PlatformAsyncStatePanel.tsx
Normal file
37
src/components/common/PlatformAsyncStatePanel.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type PlatformAsyncStatePanelProps = {
|
||||
errorState?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
loadingState?: ReactNode;
|
||||
isEmpty?: boolean;
|
||||
emptyState?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台异步状态面板骨架。
|
||||
* 只负责在错误、读取、空态和内容之间切换,具体文案与外观继续交给调用方传入 slot。
|
||||
*/
|
||||
export function PlatformAsyncStatePanel({
|
||||
errorState,
|
||||
isLoading = false,
|
||||
loadingState = null,
|
||||
isEmpty = false,
|
||||
emptyState = null,
|
||||
children,
|
||||
}: PlatformAsyncStatePanelProps) {
|
||||
if (errorState !== undefined && errorState !== null) {
|
||||
return <>{errorState}</>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <>{loadingState}</>;
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return <>{emptyState}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
35
src/components/common/PlatformBackActionButton.test.tsx
Normal file
35
src/components/common/PlatformBackActionButton.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { PlatformBackActionButton } from './PlatformBackActionButton';
|
||||
|
||||
test('renders compact back action button by default', () => {
|
||||
render(<PlatformBackActionButton />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '返回' });
|
||||
|
||||
expect(button.className).toContain('platform-button--ghost');
|
||||
expect(button.className).toContain('min-h-0');
|
||||
expect(button.className).toContain('text-[11px]');
|
||||
expect(button.className).toContain('gap-1.5');
|
||||
expect(button.querySelector('svg')?.className.baseVal).toContain('h-3.5');
|
||||
});
|
||||
|
||||
test('supports regular variant and editor dark surface', () => {
|
||||
render(
|
||||
<PlatformBackActionButton
|
||||
label="返回编辑"
|
||||
variant="regular"
|
||||
surface="editorDark"
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '返回编辑' });
|
||||
|
||||
expect(button.className).toContain('platform-action-button--editor-dark');
|
||||
expect(button.className).toContain('text-sm');
|
||||
expect(button.className).toContain('gap-2');
|
||||
expect(button.querySelector('svg')?.className.baseVal).toContain('h-4');
|
||||
});
|
||||
58
src/components/common/PlatformBackActionButton.tsx
Normal file
58
src/components/common/PlatformBackActionButton.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import type { PlatformActionButtonSurface } from './platformActionButtonModel';
|
||||
|
||||
type PlatformBackActionButtonVariant = 'compact' | 'regular';
|
||||
|
||||
type PlatformBackActionButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children'
|
||||
> & {
|
||||
label?: string;
|
||||
variant?: PlatformBackActionButtonVariant;
|
||||
surface?: PlatformActionButtonSurface;
|
||||
};
|
||||
|
||||
const VARIANT_CLASS: Record<PlatformBackActionButtonVariant, string> = {
|
||||
compact: 'gap-1.5 py-1.5 text-[11px]',
|
||||
regular: 'gap-2 py-2 text-sm',
|
||||
};
|
||||
|
||||
const ICON_CLASS: Record<PlatformBackActionButtonVariant, string> = {
|
||||
compact: 'h-3.5 w-3.5',
|
||||
regular: 'h-4 w-4',
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台轻量返回动作按钮。
|
||||
* 统一结果页、工作台等白底场景里的“左箭头 + 返回文案”按钮骨架。
|
||||
*/
|
||||
export function PlatformBackActionButton({
|
||||
label = '返回',
|
||||
variant = 'compact',
|
||||
surface = 'platform',
|
||||
className,
|
||||
...buttonProps
|
||||
}: PlatformBackActionButtonProps) {
|
||||
return (
|
||||
<PlatformActionButton
|
||||
{...buttonProps}
|
||||
surface={surface}
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
className={[
|
||||
'min-h-0 self-start',
|
||||
VARIANT_CLASS[variant],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<ArrowLeft className={ICON_CLASS[variant]} />
|
||||
{label}
|
||||
</PlatformActionButton>
|
||||
);
|
||||
}
|
||||
68
src/components/common/PlatformDangerConfirmDialog.test.tsx
Normal file
68
src/components/common/PlatformDangerConfirmDialog.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformDangerConfirmDialog } from './PlatformDangerConfirmDialog';
|
||||
|
||||
test('renders a standard danger confirmation with cancel and confirm actions', () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformDangerConfirmDialog
|
||||
open
|
||||
title="删除作品"
|
||||
description="确认删除《潮雾列岛》吗?"
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel="确认删除"
|
||||
>
|
||||
删除后不可恢复。
|
||||
</PlatformDangerConfirmDialog>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '删除作品' });
|
||||
|
||||
expect(within(dialog).getByText('确认删除《潮雾列岛》吗?')).toBeTruthy();
|
||||
expect(within(dialog).getByText('删除后不可恢复。')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除' }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('forwards busy state and custom busy label for destructive actions', () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformDangerConfirmDialog
|
||||
open
|
||||
title="删除作品"
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel="确认删除"
|
||||
busyConfirmLabel="删除中"
|
||||
busy
|
||||
closeOnBackdrop={false}
|
||||
>
|
||||
正在删除。
|
||||
</PlatformDangerConfirmDialog>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '删除作品' });
|
||||
const confirmButton = within(dialog).getByRole('button', { name: '删除中' });
|
||||
const cancelButton = within(dialog).getByRole('button', { name: '取消' });
|
||||
|
||||
expect((confirmButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((cancelButton as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
fireEvent.click(confirmButton);
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
78
src/components/common/PlatformDangerConfirmDialog.tsx
Normal file
78
src/components/common/PlatformDangerConfirmDialog.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
|
||||
|
||||
type PlatformDangerConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onConfirm?: () => void;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
busy?: boolean;
|
||||
busyConfirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
closeOnBackdrop?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
portal?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
variant?: 'platform' | 'pixel';
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
footerClassName?: string;
|
||||
confirmClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台危险确认弹窗。
|
||||
* 统一承接需要“确认 / 取消 + 危险主动作”语义的标准弹窗壳层。
|
||||
*/
|
||||
export function PlatformDangerConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
onConfirm,
|
||||
description,
|
||||
children,
|
||||
confirmLabel = '确认',
|
||||
busy = false,
|
||||
busyConfirmLabel,
|
||||
cancelLabel = '取消',
|
||||
closeOnBackdrop = true,
|
||||
showCloseButton = true,
|
||||
portal = true,
|
||||
size = 'sm',
|
||||
variant = 'platform',
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
footerClassName,
|
||||
confirmClassName,
|
||||
}: PlatformDangerConfirmDialogProps) {
|
||||
return (
|
||||
<UnifiedConfirmDialog
|
||||
open={open}
|
||||
title={title}
|
||||
description={description}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel={confirmLabel}
|
||||
busy={busy}
|
||||
busyConfirmLabel={busyConfirmLabel}
|
||||
cancelLabel={cancelLabel}
|
||||
closeOnBackdrop={closeOnBackdrop}
|
||||
showCloseButton={showCloseButton}
|
||||
showCancel
|
||||
confirmTone="danger"
|
||||
portal={portal}
|
||||
size={size}
|
||||
variant={variant}
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
footerClassName={footerClassName}
|
||||
confirmClassName={confirmClassName}
|
||||
>
|
||||
{children}
|
||||
</UnifiedConfirmDialog>
|
||||
);
|
||||
}
|
||||
65
src/components/common/PlatformDarkModalFooter.test.tsx
Normal file
65
src/components/common/PlatformDarkModalFooter.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { PlatformDarkModalFooter } from './PlatformDarkModalFooter';
|
||||
|
||||
describe('PlatformDarkModalFooter', () => {
|
||||
test('renders bordered action footer with shared action row chrome', () => {
|
||||
render(
|
||||
<PlatformDarkModalFooter data-testid="footer">
|
||||
<button type="button">取消</button>
|
||||
<button type="button">确认</button>
|
||||
</PlatformDarkModalFooter>,
|
||||
);
|
||||
|
||||
const footer = screen.getByTestId('footer');
|
||||
const actions = footer.querySelector('.platform-dark-modal-footer__actions');
|
||||
|
||||
expect(footer.className).toContain('platform-dark-modal-footer');
|
||||
expect(footer.className).toContain('border-t');
|
||||
expect(footer.className).toContain('border-white/10');
|
||||
expect(footer.className).toContain('px-4');
|
||||
expect(footer.className).toContain('py-3');
|
||||
expect(actions?.className).toContain('justify-end');
|
||||
expect(actions?.className).toContain('gap-3');
|
||||
});
|
||||
|
||||
test('supports unbordered bottom padding actions for modal footer tails', () => {
|
||||
render(
|
||||
<PlatformDarkModalFooter
|
||||
bordered={false}
|
||||
padding="bottom"
|
||||
gap="sm"
|
||||
data-testid="footer"
|
||||
>
|
||||
<button type="button">关闭</button>
|
||||
</PlatformDarkModalFooter>,
|
||||
);
|
||||
|
||||
const footer = screen.getByTestId('footer');
|
||||
const actions = footer.querySelector('.platform-dark-modal-footer__actions');
|
||||
|
||||
expect(footer.className).not.toContain('border-t');
|
||||
expect(footer.className).toContain('px-5');
|
||||
expect(footer.className).toContain('pb-5');
|
||||
expect(actions?.className).toContain('gap-2');
|
||||
});
|
||||
|
||||
test('supports content layout without wrapping children in an actions row', () => {
|
||||
render(
|
||||
<PlatformDarkModalFooter layout="content" data-testid="footer">
|
||||
<div data-testid="content">自定义内容</div>
|
||||
</PlatformDarkModalFooter>,
|
||||
);
|
||||
|
||||
const footer = screen.getByTestId('footer');
|
||||
|
||||
expect(screen.getByTestId('content')).toBeTruthy();
|
||||
expect(
|
||||
footer.querySelector('.platform-dark-modal-footer__actions'),
|
||||
).toBeNull();
|
||||
expect(footer.className).toContain('platform-dark-modal-footer');
|
||||
});
|
||||
});
|
||||
86
src/components/common/PlatformDarkModalFooter.tsx
Normal file
86
src/components/common/PlatformDarkModalFooter.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
|
||||
type PlatformDarkModalFooterLayout = 'actions' | 'content';
|
||||
type PlatformDarkModalFooterPadding = 'compact' | 'roomy' | 'bottom';
|
||||
type PlatformDarkModalFooterGap = 'sm' | 'md';
|
||||
type PlatformDarkModalFooterAlign = 'start' | 'center' | 'end' | 'between';
|
||||
|
||||
type PlatformDarkModalFooterProps = ComponentPropsWithoutRef<'div'> & {
|
||||
children: ReactNode;
|
||||
bordered?: boolean;
|
||||
layout?: PlatformDarkModalFooterLayout;
|
||||
padding?: PlatformDarkModalFooterPadding;
|
||||
gap?: PlatformDarkModalFooterGap;
|
||||
wrap?: boolean;
|
||||
align?: PlatformDarkModalFooterAlign;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
const PADDING_CLASS: Record<PlatformDarkModalFooterPadding, string> = {
|
||||
compact: 'px-4 py-3 sm:px-5 sm:py-4',
|
||||
roomy: 'px-5 py-4',
|
||||
bottom: 'px-5 pb-5',
|
||||
};
|
||||
|
||||
const GAP_CLASS: Record<PlatformDarkModalFooterGap, string> = {
|
||||
sm: 'gap-2',
|
||||
md: 'gap-3',
|
||||
};
|
||||
|
||||
const ALIGN_CLASS: Record<PlatformDarkModalFooterAlign, string> = {
|
||||
start: 'justify-start',
|
||||
center: 'justify-center',
|
||||
end: 'justify-end',
|
||||
between: 'justify-between',
|
||||
};
|
||||
|
||||
/**
|
||||
* 暗色 / 像素弹层底部 footer 骨架。
|
||||
* 统一承接 border、padding 和常见的动作按钮排布,避免业务页重复手写同一套 chrome。
|
||||
*/
|
||||
export function PlatformDarkModalFooter({
|
||||
children,
|
||||
bordered = true,
|
||||
layout = 'actions',
|
||||
padding = 'compact',
|
||||
gap = 'md',
|
||||
wrap = false,
|
||||
align = 'end',
|
||||
className,
|
||||
contentClassName,
|
||||
...props
|
||||
}: PlatformDarkModalFooterProps) {
|
||||
const frameClassName = [
|
||||
'platform-dark-modal-footer',
|
||||
bordered ? 'border-t border-white/10' : null,
|
||||
PADDING_CLASS[padding],
|
||||
className ?? null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
if (layout === 'content') {
|
||||
return (
|
||||
<div className={frameClassName} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actionsClassName = [
|
||||
'platform-dark-modal-footer__actions',
|
||||
'flex items-center',
|
||||
ALIGN_CLASS[align],
|
||||
GAP_CLASS[gap],
|
||||
wrap ? 'flex-wrap' : null,
|
||||
contentClassName ?? null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div className={frameClassName} {...props}>
|
||||
<div className={actionsClassName}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/components/common/PlatformDarkOptionCard.test.tsx
Normal file
95
src/components/common/PlatformDarkOptionCard.test.tsx
Normal 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');
|
||||
});
|
||||
82
src/components/common/PlatformDarkOptionCard.tsx
Normal file
82
src/components/common/PlatformDarkOptionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/common/PlatformDetailShareActions.test.tsx
Normal file
52
src/components/common/PlatformDetailShareActions.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformDetailShareActions } from './PlatformDetailShareActions';
|
||||
|
||||
test('renders overlay detail share actions with copied share state', () => {
|
||||
render(
|
||||
<PlatformDetailShareActions
|
||||
workCode="CW-001"
|
||||
copyState="idle"
|
||||
onCopyWorkCode={vi.fn()}
|
||||
shareState="copied"
|
||||
onShare={vi.fn()}
|
||||
shareAriaLabel="分享作品 测试世界"
|
||||
leading={<span>已发布</span>}
|
||||
variant="overlay"
|
||||
/>,
|
||||
);
|
||||
|
||||
const codeButton = screen.getByRole('button', { name: '复制作品号 CW-001' });
|
||||
const shareButton = screen.getByRole('button', { name: '分享作品 测试世界' });
|
||||
|
||||
expect(screen.getByText('已发布')).toBeTruthy();
|
||||
expect(codeButton.className).toContain('bg-white/72');
|
||||
expect(codeButton.className).toContain('tracking-[0.18em]');
|
||||
expect(shareButton.className).toContain('bg-white/72');
|
||||
expect(screen.getByText('已复制')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders solid detail share actions with compact work code chip', () => {
|
||||
render(
|
||||
<PlatformDetailShareActions
|
||||
workCode="PZ-001"
|
||||
copyState="idle"
|
||||
onCopyWorkCode={vi.fn()}
|
||||
shareState="idle"
|
||||
onShare={vi.fn()}
|
||||
shareAriaLabel="分享作品 拼图世界"
|
||||
leading={<span>已发布</span>}
|
||||
variant="solid"
|
||||
/>,
|
||||
);
|
||||
|
||||
const codeButton = screen.getByRole('button', { name: 'PZ-001' });
|
||||
const shareButton = screen.getByRole('button', { name: '分享作品 拼图世界' });
|
||||
|
||||
expect(codeButton.className).toContain('bg-[var(--platform-neutral-bg)]');
|
||||
expect(shareButton.className).toContain('bg-[var(--platform-neutral-bg)]');
|
||||
expect(screen.getByText('已发布')).toBeTruthy();
|
||||
});
|
||||
143
src/components/common/PlatformDetailShareActions.tsx
Normal file
143
src/components/common/PlatformDetailShareActions.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Copy, Share2 } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { CopyCodeButton } from './CopyCodeButton';
|
||||
import { CopyFeedbackButton } from './CopyFeedbackButton';
|
||||
import type { CopyFeedbackState } from './useCopyFeedback';
|
||||
|
||||
type PlatformDetailShareActionsProps = {
|
||||
workCode?: string | null;
|
||||
copyState: CopyFeedbackState;
|
||||
onCopyWorkCode?: () => void;
|
||||
shareState: CopyFeedbackState;
|
||||
onShare?: () => void;
|
||||
shareAriaLabel?: string;
|
||||
shareTitle?: string;
|
||||
leading?: ReactNode;
|
||||
showCopyAction?: boolean;
|
||||
showShareAction?: boolean;
|
||||
variant?: 'overlay' | 'solid';
|
||||
className?: string;
|
||||
copyClassName?: string;
|
||||
shareClassName?: string;
|
||||
copyCodeLabel?: ReactNode;
|
||||
copyAccessibleLabel?: string;
|
||||
};
|
||||
|
||||
const VARIANT_COPY_CLASS = {
|
||||
overlay: 'px-3 tracking-[0.18em]',
|
||||
solid: '',
|
||||
} as const;
|
||||
|
||||
const VARIANT_SHARE_CLASS = {
|
||||
overlay: 'px-3 tracking-[0.18em]',
|
||||
solid: '',
|
||||
} as const;
|
||||
|
||||
const VARIANT_PILL_TONE = {
|
||||
overlay: 'neutral',
|
||||
solid: 'neutralSolid',
|
||||
} as const;
|
||||
|
||||
const VARIANT_PILL_SIZE = {
|
||||
overlay: 'xxs',
|
||||
solid: 'sm',
|
||||
} as const;
|
||||
|
||||
const VARIANT_ICON_CLASS = {
|
||||
overlay: 'h-3 w-3',
|
||||
solid: 'h-4 w-4',
|
||||
} as const;
|
||||
|
||||
const VARIANT_SUFFIX_CLASS = {
|
||||
overlay: 'text-xs',
|
||||
solid: 'text-[11px]',
|
||||
} as const;
|
||||
|
||||
function renderShareLabel(suffix: ReactNode | null, suffixClassName: string) {
|
||||
return (
|
||||
<>
|
||||
<span>分享作品</span>
|
||||
{suffix ? <span className={suffixClassName}>{suffix}</span> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页作品号 / 分享动作组合。
|
||||
* 共享层只承接状态 badge 槽位、复制作品号和分享按钮这组稳定骨架。
|
||||
*/
|
||||
export function PlatformDetailShareActions({
|
||||
workCode,
|
||||
copyState,
|
||||
onCopyWorkCode,
|
||||
shareState,
|
||||
onShare,
|
||||
shareAriaLabel,
|
||||
shareTitle = '分享作品',
|
||||
leading,
|
||||
showCopyAction = true,
|
||||
showShareAction = true,
|
||||
variant = 'overlay',
|
||||
className,
|
||||
copyClassName,
|
||||
shareClassName,
|
||||
copyCodeLabel,
|
||||
copyAccessibleLabel,
|
||||
}: PlatformDetailShareActionsProps) {
|
||||
const canShowCopyAction = showCopyAction && Boolean(workCode);
|
||||
const canShowShareAction = showShareAction && Boolean(workCode);
|
||||
|
||||
if (!leading && !canShowCopyAction && !canShowShareAction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconClassName = VARIANT_ICON_CLASS[variant];
|
||||
const shareSuffixClassName = VARIANT_SUFFIX_CLASS[variant];
|
||||
const resolvedCopyCodeLabel =
|
||||
copyCodeLabel ?? (variant === 'solid' ? null : '作品号');
|
||||
const resolvedCopyAccessibleLabel =
|
||||
copyAccessibleLabel ?? (variant === 'solid' ? workCode ?? undefined : undefined);
|
||||
|
||||
return (
|
||||
<div className={['flex flex-wrap items-center gap-2', className].filter(Boolean).join(' ')}>
|
||||
{leading}
|
||||
{canShowCopyAction ? (
|
||||
<CopyCodeButton
|
||||
state={copyState}
|
||||
code={workCode ?? ''}
|
||||
codeLabel={resolvedCopyCodeLabel}
|
||||
accessibleLabel={resolvedCopyAccessibleLabel}
|
||||
title="复制作品号"
|
||||
onClick={onCopyWorkCode}
|
||||
disabled={!onCopyWorkCode}
|
||||
actionAppearance="pill"
|
||||
actionPillTone={VARIANT_PILL_TONE[variant]}
|
||||
actionPillSize={VARIANT_PILL_SIZE[variant]}
|
||||
className={[VARIANT_COPY_CLASS[variant], copyClassName].filter(Boolean).join(' ')}
|
||||
idleIcon={<Copy className={iconClassName} />}
|
||||
copiedIcon={<Copy className={iconClassName} />}
|
||||
suffixClassName={shareSuffixClassName}
|
||||
/>
|
||||
) : null}
|
||||
{canShowShareAction ? (
|
||||
<CopyFeedbackButton
|
||||
state={shareState}
|
||||
onClick={onShare}
|
||||
disabled={!onShare}
|
||||
actionAppearance="pill"
|
||||
actionPillTone={VARIANT_PILL_TONE[variant]}
|
||||
actionPillSize={VARIANT_PILL_SIZE[variant]}
|
||||
className={[VARIANT_SHARE_CLASS[variant], shareClassName].filter(Boolean).join(' ')}
|
||||
aria-label={shareAriaLabel}
|
||||
title={shareTitle}
|
||||
idleLabel={renderShareLabel(null, shareSuffixClassName)}
|
||||
copiedLabel={renderShareLabel('已复制', shareSuffixClassName)}
|
||||
failedLabel={renderShareLabel('复制失败', shareSuffixClassName)}
|
||||
idleIcon={<Share2 className={iconClassName} />}
|
||||
copiedIcon={<Share2 className={iconClassName} />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/components/common/PlatformDetailTopbar.test.tsx
Normal file
49
src/components/common/PlatformDetailTopbar.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformDetailTopbar } from './PlatformDetailTopbar';
|
||||
|
||||
test('renders pill back action with trailing slot', () => {
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformDetailTopbar
|
||||
onBack={onBack}
|
||||
className="grid-cols-[auto,minmax(0,1fr),auto]"
|
||||
backButtonClassName="px-3"
|
||||
trailing={<span>已发布</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '返回' });
|
||||
|
||||
expect(button.className).toContain('platform-button--ghost');
|
||||
expect(button.className).toContain('px-3');
|
||||
expect(screen.getByText('已发布')).toBeTruthy();
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders icon back action and centered title', () => {
|
||||
render(
|
||||
<PlatformDetailTopbar
|
||||
onBack={vi.fn()}
|
||||
backVariant="icon"
|
||||
backButtonClassName="detail-icon-back"
|
||||
title="详情"
|
||||
titleClassName="detail-topbar-title"
|
||||
trailing={<span className="invisible">占位</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '返回' });
|
||||
const title = screen.getByText('详情');
|
||||
|
||||
expect(button.className).toContain('platform-icon-button');
|
||||
expect(button.className).toContain('detail-icon-back');
|
||||
expect(title.className).toContain('detail-topbar-title');
|
||||
});
|
||||
89
src/components/common/PlatformDetailTopbar.tsx
Normal file
89
src/components/common/PlatformDetailTopbar.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { PlatformBackActionButton } from './PlatformBackActionButton';
|
||||
import { PlatformIconButton } from './PlatformIconButton';
|
||||
|
||||
type PlatformDetailTopbarProps = {
|
||||
onBack: () => void;
|
||||
title?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
backVariant?: 'icon' | 'pill';
|
||||
backLabel?: string;
|
||||
className?: string;
|
||||
backButtonClassName?: string;
|
||||
titleClassName?: string;
|
||||
trailingClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 详情页顶部动作骨架。
|
||||
* 只统一返回、标题和右侧动作槽位的布局,不吸收页面自己的标题文案或业务动作。
|
||||
*/
|
||||
export function PlatformDetailTopbar({
|
||||
onBack,
|
||||
title,
|
||||
trailing,
|
||||
backVariant = 'pill',
|
||||
backLabel = '返回',
|
||||
className,
|
||||
backButtonClassName,
|
||||
titleClassName,
|
||||
trailingClassName,
|
||||
}: PlatformDetailTopbarProps) {
|
||||
const backAction =
|
||||
backVariant === 'icon' ? (
|
||||
<PlatformIconButton
|
||||
label={backLabel}
|
||||
title={backLabel}
|
||||
className={backButtonClassName}
|
||||
onClick={onBack}
|
||||
icon={<ArrowLeft className="h-6 w-6" />}
|
||||
/>
|
||||
) : (
|
||||
<PlatformBackActionButton
|
||||
onClick={onBack}
|
||||
label={backLabel}
|
||||
className={backButtonClassName}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'grid min-w-0 grid-cols-[auto,minmax(0,1fr),auto] items-center gap-3',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className="min-w-0 justify-self-start">
|
||||
{backAction}
|
||||
</div>
|
||||
{title ? (
|
||||
<div
|
||||
className={[
|
||||
'min-w-0 text-center',
|
||||
titleClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
) : (
|
||||
<div aria-hidden="true" />
|
||||
)}
|
||||
<div
|
||||
className={[
|
||||
'min-w-0 justify-self-end',
|
||||
trailingClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{trailing}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/common/PlatformEmptyState.test.tsx
Normal file
73
src/components/common/PlatformEmptyState.test.tsx
Normal 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)]');
|
||||
});
|
||||
75
src/components/common/PlatformEmptyState.tsx
Normal file
75
src/components/common/PlatformEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/components/common/PlatformFieldLabel.test.tsx
Normal file
68
src/components/common/PlatformFieldLabel.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/* @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 inline form label without block spacing', () => {
|
||||
render(
|
||||
<PlatformFieldLabel variant="inlineForm" className="shrink-0">
|
||||
关卡数
|
||||
</PlatformFieldLabel>,
|
||||
);
|
||||
|
||||
const label = screen.getByText('关卡数');
|
||||
|
||||
expect(label.className).toContain('inline-flex');
|
||||
expect(label.className).toContain('text-sm');
|
||||
expect(label.className).toContain('font-bold');
|
||||
expect(label.className).toContain('shrink-0');
|
||||
expect(label.className).not.toContain('mb-2');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
47
src/components/common/PlatformFieldLabel.tsx
Normal file
47
src/components/common/PlatformFieldLabel.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type PlatformFieldLabelVariant =
|
||||
| 'field'
|
||||
| 'section'
|
||||
| 'form'
|
||||
| 'inlineForm'
|
||||
| '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)]',
|
||||
inlineForm:
|
||||
'inline-flex items-center text-sm font-bold text-[var(--platform-text-base)]',
|
||||
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>
|
||||
);
|
||||
}
|
||||
72
src/components/common/PlatformFilterToolbar.test.tsx
Normal file
72
src/components/common/PlatformFilterToolbar.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformFilterToolbar } from './PlatformFilterToolbar';
|
||||
|
||||
const TAB_ITEMS = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'story', label: '剧情' },
|
||||
] as const;
|
||||
|
||||
test('renders mobile platform filter toolbar with divider and prefixed sort label', () => {
|
||||
const onOpenFilter = vi.fn();
|
||||
const onTabChange = vi.fn();
|
||||
const onToggleSort = vi.fn();
|
||||
const { container } = render(
|
||||
<PlatformFilterToolbar
|
||||
filterLabel="筛选"
|
||||
filterCount={12}
|
||||
tabItems={TAB_ITEMS}
|
||||
activeTabId="all"
|
||||
sortLabel="最热"
|
||||
layout="mobile"
|
||||
onOpenFilter={onOpenFilter}
|
||||
onTabChange={onTabChange}
|
||||
onToggleSort={onToggleSort}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /筛选/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /按最热排序/u })).toBeTruthy();
|
||||
expect(container.querySelector('.platform-category-filter-divider')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '剧情' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /按最热排序/u }));
|
||||
|
||||
expect(onTabChange).toHaveBeenCalledWith('story');
|
||||
expect(onToggleSort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders desktop platform filter toolbar with inline sort button', () => {
|
||||
const onOpenFilter = vi.fn();
|
||||
const { container } = render(
|
||||
<PlatformFilterToolbar
|
||||
filterLabel="剧情"
|
||||
filterCount={3}
|
||||
tabItems={TAB_ITEMS}
|
||||
activeTabId="story"
|
||||
sortLabel="最新"
|
||||
layout="desktop"
|
||||
onOpenFilter={onOpenFilter}
|
||||
onTabChange={vi.fn()}
|
||||
onToggleSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const filterButton = container.querySelector(
|
||||
'.platform-category-filter-button',
|
||||
) as HTMLButtonElement | null;
|
||||
const sortButton = screen.getByRole('button', { name: /最新/u });
|
||||
|
||||
expect(filterButton).toBeTruthy();
|
||||
expect(filterButton?.textContent).toContain('剧情');
|
||||
expect(sortButton).toBeTruthy();
|
||||
expect(sortButton.className).toContain('shrink-0');
|
||||
expect(container.querySelector('.platform-category-filter-divider')).toBeNull();
|
||||
|
||||
fireEvent.click(filterButton!);
|
||||
|
||||
expect(onOpenFilter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
108
src/components/common/PlatformFilterToolbar.tsx
Normal file
108
src/components/common/PlatformFilterToolbar.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
|
||||
|
||||
export interface PlatformFilterToolbarTabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PlatformFilterToolbarProps {
|
||||
filterLabel: string;
|
||||
filterCount: number;
|
||||
tabItems: readonly PlatformFilterToolbarTabItem[];
|
||||
activeTabId: string;
|
||||
sortLabel: string;
|
||||
layout: 'mobile' | 'desktop';
|
||||
onOpenFilter: () => void;
|
||||
onTabChange: (id: string) => void;
|
||||
onToggleSort: () => void;
|
||||
}
|
||||
|
||||
function buildToolbarTabItemClassName(active: boolean) {
|
||||
return [
|
||||
'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
|
||||
active ? 'platform-category-chip--active' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function PlatformFilterToolbar({
|
||||
filterLabel,
|
||||
filterCount,
|
||||
tabItems,
|
||||
activeTabId,
|
||||
sortLabel,
|
||||
layout,
|
||||
onOpenFilter,
|
||||
onTabChange,
|
||||
onToggleSort,
|
||||
}: PlatformFilterToolbarProps) {
|
||||
const isMobileLayout = layout === 'mobile';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
isMobileLayout
|
||||
? 'platform-category-filter-row'
|
||||
: 'mb-4 flex min-w-0 items-center gap-2'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenFilter}
|
||||
aria-haspopup="dialog"
|
||||
className="platform-category-filter-button"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span>{filterLabel}</span>
|
||||
<span className="platform-category-filter-button__count">
|
||||
{filterCount}
|
||||
</span>
|
||||
</button>
|
||||
{isMobileLayout ? (
|
||||
<span className="platform-category-filter-divider" />
|
||||
) : null}
|
||||
<PlatformSegmentedTabs
|
||||
items={tabItems}
|
||||
activeId={activeTabId}
|
||||
onChange={onTabChange}
|
||||
layout="scroll"
|
||||
gap="md"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
tone="neutral"
|
||||
className={
|
||||
isMobileLayout
|
||||
? 'platform-category-chip-scroll min-w-0 flex-1'
|
||||
: 'min-w-0 flex-1 pb-1'
|
||||
}
|
||||
itemClassName={(_, active) => buildToolbarTabItemClassName(active)}
|
||||
/>
|
||||
{!isMobileLayout ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSort}
|
||||
className="platform-category-sort-button shrink-0"
|
||||
>
|
||||
<span>{sortLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{isMobileLayout ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSort}
|
||||
className="platform-category-sort-button"
|
||||
>
|
||||
<span>按{sortLabel}排序</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
126
src/components/common/PlatformIconBadge.test.tsx
Normal file
126
src/components/common/PlatformIconBadge.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/* @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('platform-icon-badge');
|
||||
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');
|
||||
});
|
||||
85
src/components/common/PlatformIconBadge.tsx
Normal file
85
src/components/common/PlatformIconBadge.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
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={[
|
||||
'platform-icon-badge',
|
||||
'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>
|
||||
);
|
||||
}
|
||||
167
src/components/common/PlatformIconButton.test.tsx
Normal file
167
src/components/common/PlatformIconButton.test.tsx
Normal 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');
|
||||
});
|
||||
89
src/components/common/PlatformIconButton.tsx
Normal file
89
src/components/common/PlatformIconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/components/common/PlatformInfoBlock.test.tsx
Normal file
78
src/components/common/PlatformInfoBlock.test.tsx
Normal 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');
|
||||
});
|
||||
100
src/components/common/PlatformInfoBlock.tsx
Normal file
100
src/components/common/PlatformInfoBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user