收口前端平台组件库能力

新增 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

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