113 lines
3.7 KiB
TypeScript
113 lines
3.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import type { EventStore } from '../../engine/eventStore';
|
||
import type { JevClient } from '../../services/jev';
|
||
import { BADGE_MAP } from './badges';
|
||
import { scoreProfile } from '../../engine/scoring';
|
||
import type { DimensionScore } from '../../engine/scoring';
|
||
export type { DimensionScore } from '../../engine/scoring';
|
||
import { ShareCard } from './ShareCard';
|
||
|
||
interface Props {
|
||
store: EventStore;
|
||
jev: JevClient;
|
||
onReplay: () => void;
|
||
}
|
||
|
||
/** Act 1 结束后的画像报告页:Jev 一次调用并行问 7 个维度 score */
|
||
export function ReportPage({ store, jev, onReplay }: Props) {
|
||
const [scores, setScores] = useState<DimensionScore[] | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [retry, setRetry] = useState(0);
|
||
const [playerName, setPlayerName] = useState('');
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const events = await store.list();
|
||
// 最近一次身份登记的称呼(旧数据缺 name 字段时回退空串 → 只显示编号)
|
||
const name = [...events].reverse().find((e) => e.kind === 'profile')?.profile?.name ?? '';
|
||
if (!cancelled) setPlayerName(name);
|
||
const result = await scoreProfile(events, jev);
|
||
if (cancelled) return;
|
||
setScores(result);
|
||
} catch (e) {
|
||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [store, jev, retry]);
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="report">
|
||
<h1>画像报告生成失败</h1>
|
||
<p>{error}</p>
|
||
<button
|
||
className="primary"
|
||
onClick={() => {
|
||
setError(null);
|
||
setScores(null);
|
||
setRetry((r) => r + 1);
|
||
}}
|
||
>
|
||
画像生成失败,点此重试
|
||
</button>
|
||
<button className="secondary" onClick={onReplay}>
|
||
回到剧情
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!scores) {
|
||
return (
|
||
<div className="report">
|
||
<h1>档案馆正在整理你的记忆……</h1>
|
||
<p>Jev 正在评估 7 个维度。</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 勋章 = 置信度最高的维度
|
||
const top = [...scores].sort((a, b) => b.confidence - a.confidence)[0];
|
||
const badge = BADGE_MAP[top.id];
|
||
|
||
return (
|
||
<div className="report">
|
||
<h1>人格画像报告 · Act 1</h1>
|
||
<p className="hint">C-2049{playerName ? `「${playerName}」` : ''}的档案</p>
|
||
<section className="badge-banner">
|
||
<div className="badge-medal">勋</div>
|
||
<div>
|
||
<h2>{badge.title}</h2>
|
||
<p>{badge.flavor}</p>
|
||
<p className="badge-note">
|
||
(取自实测置信度最高的维度:{top.label},置信度 {Math.round(top.confidence * 100)}%)
|
||
</p>
|
||
</div>
|
||
</section>
|
||
<section className="dimension-list">
|
||
{scores.map((s) => (
|
||
<div key={s.id} className="dimension-row">
|
||
<span className="dimension-label">{s.label}</span>
|
||
<div className="dimension-bar">
|
||
<div className="dimension-fill" style={{ width: `${(s.score / s.max) * 100}%` }} />
|
||
</div>
|
||
<span className="dimension-value">
|
||
{s.score.toFixed(1)}/{s.max} · 置信度 {Math.round(s.confidence * 100)}%
|
||
{s.confidence < 0.6 && ' · 证据不足'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</section>
|
||
<ShareCard badgeDimension={top.id} scores={scores} playerName={playerName} />
|
||
<button className="secondary" onClick={onReplay}>
|
||
重玩 Act 1(新轨迹,历史保留)
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|