Files
Genarrative/src/components/CharacterInfoShared.tsx
kdletters 84ded19f11 继续收口NPC弹窗与角色详情空态
统一 NPC 弹窗底部动作按钮与交易详情空态到共享组件
统一角色构筑详情空明细到 PlatformEmptyState
补充 PlatformUiKit 收口计划、共享决策记录与对应测试护栏
2026-06-11 02:39:49 +08:00

485 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolveRoleCombatStats } from '../data/attributeCombat';
import { getAttributeSlotValue } from '../data/attributeResolver';
import {
type BuildContributionAttributeRow,
type BuildDamageBreakdown,
formatBuildContributionPercent,
getBuildContributionQualityLabel,
} from '../data/buildDamage';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import type {
Character,
RoleAttributeProfile,
WorldAttributeSchema,
} from '../types';
import {
buildCharacterSkillRenderId,
type ContributionRow,
formatAttributeMetricValue,
getAttributeBonusPillClassName,
getAttributeEffectText,
getContributionVisualStyle,
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,
current,
max,
tone,
}: {
label: string;
current: number;
max: number;
tone: 'hp' | 'mp';
}) {
const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0));
const fillClass =
tone === 'hp'
? 'from-emerald-400 via-lime-300 to-emerald-200'
: 'from-sky-500 via-cyan-300 to-sky-100';
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
<span>{label}</span>
<span className="text-zinc-200">
{current} / {max}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
<div
className={`h-full bg-gradient-to-r ${fillClass}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
);
}
export function CharacterIdentityBadges({
roleLabel,
levelText = null,
roleTone = 'sky',
className = '',
}: {
roleLabel: string;
levelText?: string | null;
roleTone?: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc';
className?: string;
}) {
const roleBadgeTone: PlatformPillBadgeTone =
roleTone === 'amber'
? 'darkAmber'
: roleTone === 'rose'
? 'darkRose'
: roleTone === 'emerald'
? 'darkEmerald'
: roleTone === 'zinc'
? 'darkNeutral'
: 'darkSky';
return (
<div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}>
<PlatformPillBadge
tone={roleBadgeTone}
size="xxs"
className="tracking-[0.16em]"
>
{roleLabel}
</PlatformPillBadge>
{levelText ? (
<PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="tracking-[0.16em]"
>
{levelText}
</PlatformPillBadge>
) : null}
</div>
);
}
export function PlayerLevelProgress({
level,
currentLevelXp,
xpToNextLevel,
className = '',
}: {
level: number;
currentLevelXp: number;
xpToNextLevel: number;
className?: string;
}) {
const safeLevel = Math.max(1, Math.round(level));
const safeCurrentLevelXp = Math.max(0, Math.round(currentLevelXp));
const safeXpToNextLevel = Math.max(0, Math.round(xpToNextLevel));
const ratio =
safeXpToNextLevel <= 0
? 1
: Math.max(0, Math.min(1, safeCurrentLevelXp / safeXpToNextLevel));
return (
<div className={className}>
<div className="flex items-center justify-between gap-3 text-[11px]">
<div className="font-semibold text-amber-50">Lv.{safeLevel}</div>
<div className="text-zinc-400">
{safeXpToNextLevel > 0
? `${safeCurrentLevelXp}/${safeXpToNextLevel}`
: 'MAX'}
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.78),rgba(253,224,71,0.96))]"
style={{
width: ratio <= 0 ? '0%' : `${Math.max(6, ratio * 100)}%`,
}}
/>
</div>
</div>
);
}
export function CharacterSkillsList({
skills,
onSelectSkill,
emptyText = '暂无技能信息',
}: {
skills: Character['skills'];
onSelectSkill?: ((skillId: string) => void) | null;
emptyText?: string;
}) {
if (skills.length === 0) {
return (
<PlatformEmptyState surface="editorDark" size="compact" tone="soft">
{emptyText}
</PlatformEmptyState>
);
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{skills.map((skill, index) => {
const skillRenderId = buildCharacterSkillRenderId(skill, index);
const content = (
<>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div>
<PlatformPillBadge
tone="darkSoft"
size="xxs"
className="px-2 py-0.5"
>
{getSkillDeliveryLabel(skill)}
</PlatformPillBadge>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div>
<div>{skill.manaCost}</div>
<div>{skill.cooldownTurns}</div>
<div>{skill.range}</div>
</div>
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">
{getSkillStyleLabel(skill)}
</div>
</>
);
if (onSelectSkill) {
return (
<PlatformSubpanel
as="button"
key={skillRenderId}
type="button"
onClick={() => onSelectSkill(skillRenderId)}
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}
</PlatformSubpanel>
);
}
return (
<PlatformSubpanel
as="div"
key={skillRenderId}
surface="dark"
radius="xs"
padding="sm"
className="border-white/5 bg-black/20 text-sm text-zinc-300"
>
{content}
</PlatformSubpanel>
);
})}
</div>
);
}
export function MultiplierContributionList({
breakdown,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort(
(left, right) =>
right.bonusDelta - left.bonusDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
return (
<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]">
</span>
</div>
{sortedRows.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sortedRows.map((row) => (
<button
key={`formula-tag-${row.label}`}
type="button"
onClick={() => onSelectContribution(row)}
className="min-w-[5.2rem] rounded-xl border px-2.5 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5 sm:min-w-[6.25rem] sm:px-3"
style={getContributionVisualStyle(row.bonusDelta)}
title={`查看 ${row.label} 的标签效果`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{row.label}</span>
<span className="text-[11px] font-semibold tracking-[0.12em] text-current/80">
{getBuildContributionQualityLabel(row.bonusDelta)}
</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-current/70">
{formatBuildContributionPercent(row.bonusDelta)}
</div>
</button>
))}
</div>
) : (
<PlatformPillBadge tone="darkNeutral" size="xxs" className="px-2">
</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>
);
}
export function CharacterAttributeGrid({
attributeProfile,
attributeSchema,
buildBreakdown = null,
resourceLabels,
emptyText = '暂无属性信息',
gridClassName = 'grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2',
cardClassName = 'rounded-xl border border-white/8 bg-black/25 px-3 py-2',
}: {
attributeProfile: RoleAttributeProfile | null | undefined;
attributeSchema: WorldAttributeSchema;
buildBreakdown?: BuildDamageBreakdown | null;
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>;
emptyText?: string;
gridClassName?: string;
cardClassName?: string;
}) {
const attributeRows = attributeSchema.slots.map((slot) => ({
slot,
value: getAttributeSlotValue(attributeProfile, slot.slotId),
}));
const attributeBonusBySlot = Object.fromEntries(
attributeSchema.slots.map((slot) => [
slot.slotId,
Number(
(
buildBreakdown?.rows.reduce(
(sum, row) =>
sum + (row.attributeModifierDeltas?.[slot.slotId] ?? 0),
0,
) ?? 0
).toFixed(4),
),
]),
) as Record<string, number>;
const boostedAttributeProfile = attributeProfile
? {
...attributeProfile,
values: {
...(attributeProfile.values ?? {}),
...Object.fromEntries(
attributeSchema.slots.map((slot) => {
const baseValue = attributeProfile.values?.[slot.slotId] ?? 0;
const totalBonus = attributeBonusBySlot[slot.slotId] ?? 0;
return [
slot.slotId,
Number((baseValue * (1 + totalBonus)).toFixed(4)),
];
}),
),
},
}
: null;
const boostedCombatStats = boostedAttributeProfile
? resolveRoleCombatStats(boostedAttributeProfile)
: null;
const displayRows = attributeRows.map(({ slot, value }) => {
const totalBonus = attributeBonusBySlot[slot.slotId] ?? 0;
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
return {
slot,
baseValue: value,
boostedValue,
totalBonus,
effectText: boostedCombatStats
? getAttributeEffectText(
slot.slotId,
boostedCombatStats,
resourceLabels,
)
: '',
};
});
if (displayRows.length === 0) {
return <div className="text-sm text-zinc-500">{emptyText}</div>;
}
return (
<div className={gridClassName}>
{displayRows.map(
({ slot, baseValue, boostedValue, totalBonus, effectText }) => (
<div key={slot.slotId} className={cardClassName}>
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="text-xl font-bold text-white sm:text-2xl">
{formatAttributeMetricValue(boostedValue)}
</div>
</div>
<div className="flex flex-col items-start gap-1 text-left sm:shrink-0 sm:items-end sm:text-right">
<span
className={`max-w-full rounded-full border px-2 py-0.5 text-[10px] font-medium leading-4 ${getAttributeBonusPillClassName(totalBonus)}`}
>
{formatBuildContributionPercent(totalBonus)}
</span>
<div className="text-[10px] text-zinc-500">
{formatAttributeMetricValue(baseValue)}
</div>
</div>
</div>
{effectText ? (
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
{effectText}
</div>
) : null}
</div>
),
)}
</div>
);
}