收口前端平台组件库能力

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

View File

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