176 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|