收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user