1ad25e30f8
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
243 lines
9.3 KiB
TypeScript
243 lines
9.3 KiB
TypeScript
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];
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function getNextAffinityMarker(affinity: number) {
|
|
const currentLevel = getAffinityLevelMeta(affinity);
|
|
if (currentLevel.nextAffinity == null) return null;
|
|
|
|
return (
|
|
AFFINITY_PROGRESS_MARKERS.find(
|
|
(marker) => marker.value === currentLevel.nextAffinity,
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
function getAffinityProgressRatio(value: number) {
|
|
return clamp(
|
|
(value - AFFINITY_PROGRESS_MIN) /
|
|
(AFFINITY_PROGRESS_MAX - AFFINITY_PROGRESS_MIN),
|
|
0,
|
|
1,
|
|
);
|
|
}
|
|
|
|
function getAnchorTransform(ratio: number) {
|
|
if (ratio <= 0.02) return 'translateX(0)';
|
|
if (ratio >= 0.98) return 'translateX(-100%)';
|
|
return 'translateX(-50%)';
|
|
}
|
|
|
|
function isMarkerReached(marker: AffinityProgressMarker, affinity: number) {
|
|
if (marker.value < 0) {
|
|
return affinity < 0;
|
|
}
|
|
|
|
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);
|
|
const currentRatio = getAffinityProgressRatio(affinity);
|
|
const zeroRatio = getAffinityProgressRatio(0);
|
|
const activeMarkerValue =
|
|
currentLevel.minAffinity <= AFFINITY_PROGRESS_MIN
|
|
? AFFINITY_PROGRESS_MIN
|
|
: currentLevel.minAffinity;
|
|
const fillLeftRatio = Math.min(currentRatio, zeroRatio);
|
|
const fillWidthRatio = Math.abs(currentRatio - zeroRatio);
|
|
const fillWidthPercent =
|
|
fillWidthRatio > 0 ? `${Math.max(fillWidthRatio * 100, 1)}%` : '0%';
|
|
const currentPointerTone =
|
|
affinity < 0
|
|
? 'border-rose-100/90 bg-rose-300 shadow-[0_0_16px_rgba(251,113,133,0.45)]'
|
|
: 'border-sky-50/90 bg-sky-300 shadow-[0_0_18px_rgba(125,211,252,0.35)]';
|
|
const fillGradient =
|
|
affinity < 0
|
|
? 'linear-gradient(90deg, rgba(251,113,133,0.92) 0%, rgba(253,164,175,0.98) 100%)'
|
|
: 'linear-gradient(90deg, rgba(125,211,252,0.92) 0%, rgba(251,191,36,0.94) 60%, rgba(251,113,133,0.96) 100%)';
|
|
|
|
return (
|
|
<div className="space-y-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">
|
|
<PlatformPillBadge
|
|
tone={getAffinityLevelBadgeTone(currentLevel.id)}
|
|
size="xxs"
|
|
className="tracking-[0.16em]"
|
|
>
|
|
{currentLevel.label}
|
|
</PlatformPillBadge>
|
|
<span className="text-sm font-semibold text-white">
|
|
当前好感 {affinity}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-right text-[10px] tracking-[0.16em] text-zinc-500">
|
|
{nextLevel ? (
|
|
<>
|
|
<div>下一节点</div>
|
|
<div className="mt-1 text-zinc-200">
|
|
{nextLevel.label} · {nextLevel.value}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="text-zinc-200">已达最高节点</div>
|
|
<div className="mt-1">继续提升可稳固关系优势</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
|
{currentLevel.description}
|
|
</p>
|
|
</PlatformSubpanel>
|
|
|
|
<PlatformSubpanel
|
|
surface="dark"
|
|
radius="xs"
|
|
padding="sm"
|
|
className="sm:p-4"
|
|
>
|
|
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
|
好感进度
|
|
</div>
|
|
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">
|
|
0 是战斗分界线,低于 0
|
|
会直接进入对战;其余节点表示进入对应阶段所需的最低好感。
|
|
</div>
|
|
|
|
<div className="relative mt-5 pb-12 pt-1 sm:pb-14">
|
|
<div className="absolute left-0 right-0 top-[1.02rem] h-2 rounded-full border border-white/8 bg-gradient-to-b from-white/[0.08] via-white/[0.03] to-black/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" />
|
|
<div
|
|
className="absolute left-0 top-[1.02rem] h-2 rounded-l-full bg-gradient-to-r from-rose-500/18 via-rose-400/10 to-transparent"
|
|
style={{ width: `${zeroRatio * 100}%` }}
|
|
/>
|
|
<div
|
|
className="absolute top-[1.02rem] h-2 rounded-r-full bg-gradient-to-r from-sky-400/10 via-amber-300/10 to-rose-400/16"
|
|
style={{
|
|
left: `${zeroRatio * 100}%`,
|
|
width: `${(1 - zeroRatio) * 100}%`,
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute top-[0.55rem] h-5 w-px bg-white/20"
|
|
style={{ left: `${zeroRatio * 100}%` }}
|
|
/>
|
|
<div
|
|
className="absolute top-[1.02rem] h-2 rounded-full shadow-[0_0_16px_rgba(251,191,36,0.16)]"
|
|
style={{
|
|
left: `${fillLeftRatio * 100}%`,
|
|
width: fillWidthPercent,
|
|
background: fillGradient,
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
className="absolute top-[0.76rem] h-3.5 w-3.5 rounded-full border-2"
|
|
style={{
|
|
left: `${currentRatio * 100}%`,
|
|
transform: getAnchorTransform(currentRatio),
|
|
}}
|
|
>
|
|
<div
|
|
className={`h-full w-full rounded-full border ${currentPointerTone}`}
|
|
/>
|
|
</div>
|
|
|
|
{AFFINITY_PROGRESS_MARKERS.map((marker) => {
|
|
const markerRatio = getAffinityProgressRatio(marker.value);
|
|
const isReached = isMarkerReached(marker, affinity);
|
|
const isActive = marker.value === activeMarkerValue;
|
|
|
|
return (
|
|
<div
|
|
key={`affinity-marker-${marker.value}`}
|
|
className="absolute top-0"
|
|
style={{
|
|
left: `${markerRatio * 100}%`,
|
|
transform: getAnchorTransform(markerRatio),
|
|
}}
|
|
>
|
|
<div className="flex w-12 flex-col items-center text-center sm:w-16">
|
|
<div className="relative flex h-9 items-end justify-center sm:h-10">
|
|
{isActive ? (
|
|
<div
|
|
className={`absolute bottom-0 h-6 w-3 rounded-full blur-[5px] sm:h-7 sm:w-3.5 ${
|
|
marker.value < 0 ? 'bg-rose-300/20' : 'bg-sky-300/18'
|
|
}`}
|
|
/>
|
|
) : null}
|
|
<div
|
|
className={`relative rounded-full border transition-all duration-300 ${
|
|
isActive
|
|
? marker.value < 0
|
|
? 'h-7 w-2 border-rose-100/75 bg-gradient-to-b from-rose-100 via-rose-300 to-rose-500 shadow-[0_0_14px_rgba(251,113,133,0.28)] sm:h-8'
|
|
: 'h-7 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-8'
|
|
: isReached
|
|
? marker.value < 0
|
|
? 'h-5 w-1.5 border-rose-100/45 bg-gradient-to-b from-rose-200 via-rose-300 to-rose-500 shadow-[0_0_10px_rgba(251,113,133,0.22)] sm:h-6'
|
|
: 'h-5 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-6'
|
|
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5'
|
|
}`}
|
|
>
|
|
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${
|
|
isActive || isReached ? 'text-zinc-100' : 'text-zinc-500'
|
|
}`}
|
|
>
|
|
{marker.label}
|
|
</div>
|
|
<div
|
|
className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${
|
|
isActive || isReached ? 'text-zinc-300' : 'text-zinc-600'
|
|
}`}
|
|
>
|
|
{marker.value}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</PlatformSubpanel>
|
|
</div>
|
|
);
|
|
}
|