Files
jev-match-game/src/ui/report/ReportPage.tsx
T

176 lines
6.9 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import type { EventStore } from '../../engine/eventStore';
import type { JevClient } from '../../services/jev';
import { assessRelationships } from '../../engine/scoring';
import type { RelationshipArea } from '../../engine/scoring';
import { AREA_BADGE_MAP, DEFAULT_BADGE } from './badges';
import { ShareCard } from './ShareCard';
interface Props {
store: EventStore;
jev: JevClient;
onReplay: () => void;
/** 删除全部个人数据(ADR-0004 数据删除权):purge + 清空进度 + 回到同意门,由 App 编排 */
onPurge: () => Promise<void>;
}
/**
* 季终相处线索报告:Jev 逐条核对剧情情境,只提出"待本人核对"的候选线索,
* 不打人格分数、不做优劣排名(season-design §7:多类别相处方式不能被压成一维分值)。
*/
export function ReportPage({ store, jev, onReplay, onPurge }: Props) {
const [areas, setAreas] = useState<RelationshipArea[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [retry, setRetry] = useState(0);
const [playerName, setPlayerName] = useState('');
const purgeDialogRef = useRef<HTMLDialogElement>(null);
const [purging, setPurging] = useState(false);
const [purgeError, setPurgeError] = useState<string | null>(null);
async function confirmPurge() {
setPurging(true);
setPurgeError(null);
try {
// 成功后 App 把应用切回同意门,本页随之卸载
await onPurge();
} catch (e) {
setPurgeError(e instanceof Error ? e.message : String(e));
purgeDialogRef.current?.close();
} finally {
setPurging(false);
}
}
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 assessRelationships(events, jev);
if (cancelled) return;
setAreas(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);
setAreas(null);
setRetry((r) => r + 1);
}}
>
生成失败,点此重试
</button>
<button className="secondary" onClick={onReplay}>
回到剧情
</button>
</div>
);
}
if (!areas) {
return (
<div className="report">
<h1>档案馆正在整理你的相处线索……</h1>
<p>正逐条核对剧情情境(本地 Mock 模式不生成伪随机判断)。</p>
</div>
);
}
// 勋章 = 候选线索证据最多的领域;全无证据时给默认称号
const signal = (a: RelationshipArea) => a.candidates.reduce((n, c) => n + c.pointIds.length, 0);
const top = [...areas].sort((a, b) => signal(b) - signal(a))[0];
const badge = signal(top) > 0 ? (AREA_BADGE_MAP[top.id] ?? DEFAULT_BADGE) : DEFAULT_BADGE;
return (
<div className="report">
<h1>相处线索报告 · 苍梧疑冢</h1>
<p className="hint">来自剧情情境的初步线索,不是心理诊断、人格优劣排名或匹配成功保证。真实择偶条件须由你另行确认,目前未开放匹配。</p>
<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>
{signal(top) > 0 && (
<p className="badge-note">
(取自「{top.label}」领域的候选线索,共 {signal(top)} 条情境支持——待你本人核对)
</p>
)}
</div>
</section>
<section className="dimension-list">
{areas.map((a) => (
<div key={a.id} className="dimension-row">
<span className="dimension-label">{a.label}</span>
<span className="dimension-value">
{a.status === '证据不足'
? `剧情情境不足,暂不推断(已采集 ${a.pointIds.length} 条相关情境)`
: a.candidates.length
? a.candidates.map((c) => `「${c.label}」`).join('、') + ` —— 待本人核对`
: '情境已够,暂未出现明确倾向'}
</span>
</div>
))}
</section>
<ShareCard badgeArea={signal(top) > 0 ? top.id : null} areas={areas} playerName={playerName} />
<button className="secondary" onClick={onReplay}>
重玩第一季(新轨迹,历史保留)
</button>
<section style={{ marginTop: 48, borderTop: '1px solid rgba(201,219,203,.2)', paddingTop: 24 }}>
<h2 style={{ fontSize: 18, color: '#f1eee3' }}>数据与隐私</h2>
<p className="hint" style={{ maxWidth: 560 }}>
剧情进度与行为记录保存在本机;配置在线分析服务时,相关选择与讲述文本会发送至该服务用于线索核对。删除会清空本机记录,不代表撤回已经完成的远程处理。私人幻境路径和结尾心愿不参与画像。
</p>
<button className="secondary" onClick={() => purgeDialogRef.current?.showModal()}>
删除全部数据
</button>
{purgeError && <p className="scene-error">删除失败:{purgeError}</p>}
<dialog
ref={purgeDialogRef}
aria-labelledby="purge-title"
style={{
background: 'rgba(13,25,23,.98)',
color: '#e6eae0',
border: '1px solid rgba(201,219,203,.34)',
borderRadius: 3,
padding: '24px 28px',
maxWidth: 480,
}}
>
<h3 id="purge-title" style={{ marginTop: 0 }}>
确定删除全部数据?
</h3>
<p style={{ lineHeight: 1.8 }}>
此操作不可恢复,所有剧情进度、相处线索、讲述内容将被永久删除,且不提供导出留档。
删除后应用将回到初始的告知与同意页面,如同首次安装。
</p>
<div style={{ display: 'flex', gap: 12, marginTop: 20, flexWrap: 'wrap' }}>
<button className="secondary" disabled={purging} onClick={confirmPurge}>
{purging ? '正在删除……' : '确认永久删除'}
</button>
<button className="secondary" disabled={purging} onClick={() => purgeDialogRef.current?.close()}>
取消
</button>
</div>
</dialog>
</section>
</div>
);
}