people-reading/web/components/PalmWebApp.tsx
2026-05-11 23:26:11 +08:00

301 lines
9.3 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.

"use client";
import { ChangeEvent, useEffect, useMemo, useState } from "react";
import {
Dimension,
HandSide,
Report,
ReportData,
ReportSummary,
apiFetch,
ensureToken,
uploadPalmImage,
} from "@/lib/api";
const handText: Record<HandSide, string> = {
left: "左手",
right: "右手",
unknown: "不确定",
};
const statusText: Record<Report["status"], string> = {
pending: "等待中",
processing: "生成中",
completed: "已完成",
failed: "失败",
};
export default function PalmWebApp() {
const [ready, setReady] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState("");
const [handSide, setHandSide] = useState<HandSide>("unknown");
const [busyText, setBusyText] = useState("");
const [error, setError] = useState("");
const [reports, setReports] = useState<ReportSummary[]>([]);
const [activeReport, setActiveReport] = useState<Report | null>(null);
useEffect(() => {
ensureToken()
.then(() => Promise.all([loadReports()]))
.then(() => setReady(true))
.catch((err) => {
setError(err.message || "初始化失败");
setReady(true);
});
}, []);
const completedReports = reports.filter((item) => item.status === "completed").length;
function onPickFile(event: ChangeEvent<HTMLInputElement>) {
const selected = event.target.files?.[0];
if (!selected) return;
setFile(selected);
setError("");
if (preview) URL.revokeObjectURL(preview);
setPreview(URL.createObjectURL(selected));
}
async function loadReports() {
const data = await apiFetch<ReportSummary[]>("/reports");
setReports(data);
}
async function startReport() {
if (!file) {
setError("请先选择一张清晰的掌心照片。");
return;
}
setBusyText("正在接入掌纹照片...");
setError("");
try {
const upload = await uploadPalmImage(file);
setBusyText("先生正在解读掌纹...");
const report = await apiFetch<Report>("/reports", {
method: "POST",
body: JSON.stringify({ image_id: upload.image_id, hand_side: handSide }),
});
await pollReport(report.id);
await loadReports();
} catch (err) {
setError(err instanceof Error ? err.message : "生成失败");
} finally {
setBusyText("");
}
}
async function pollReport(reportId: string) {
for (let i = 0; i < 80; i += 1) {
const report = await apiFetch<Report>(`/reports/${reportId}`);
setActiveReport(report);
if (report.status === "completed") return report;
if (report.status === "failed") throw new Error(report.error_message || "报告生成失败");
await sleep(2200);
}
throw new Error("生成时间较长,请稍后在档案中查看。");
}
async function openReport(reportId: string) {
setError("");
const report = await apiFetch<Report>(`/reports/${reportId}`);
setActiveReport(report);
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function deleteReport(reportId: string) {
await apiFetch(`/reports/${reportId}`, { method: "DELETE" });
if (activeReport?.id === reportId) setActiveReport(null);
await loadReports();
}
return (
<main className="shell">
<section className="hero">
<div className="brand">
<span className="seal"></span>
<div>
<p className="kicker">CYBER MISTER</p>
<h1></h1>
</div>
</div>
<div className="hero-copy">
<p className="eyebrow">AI · · </p>
<h2>线</h2>
<p>
AI
</p>
</div>
<div className="hero-orbit" aria-hidden="true">
<div className="palm-mark"></div>
</div>
</section>
<section className="workspace">
<div className="upload-card">
<div className="section-label">01 · </div>
<h3></h3>
<p>线</p>
<label className="drop-zone">
{preview ? <img src={preview} alt="掌心照片预览" /> : <span></span>}
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={onPickFile} />
</label>
<div className="segmented">
{(["left", "right", "unknown"] as HandSide[]).map((side) => (
<button key={side} className={handSide === side ? "active" : ""} onClick={() => setHandSide(side)}>
{handText[side]}
</button>
))}
</div>
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={startReport}>
{busyText || "生成手相报告"}
</button>
{error ? <p className="error">{error}</p> : null}
</div>
<div className="archive-card">
<div className="section-label">02 · </div>
<div className="stats">
<Stat label="累计报告" value={reports.length} />
<Stat label="已完成" value={completedReports} />
</div>
<div className="report-list">
{reports.length ? (
reports.slice(0, 6).map((item) => (
<button key={item.id} className="archive-item" onClick={() => openReport(item.id)}>
<span>
<strong></strong>
<small>{formatDate(item.created_at)}</small>
</span>
<em>{statusText[item.status]}</em>
</button>
))
) : (
<p className="empty"></p>
)}
</div>
</div>
</section>
{activeReport ? (
<ReportPanel
report={activeReport}
onDelete={() => deleteReport(activeReport.id)}
/>
) : null}
</main>
);
}
function ReportPanel({
report,
onDelete,
}: {
report: Report;
onDelete: () => void;
}) {
const data = report.report_data;
const score = useMemo(() => getScore(data), [data]);
if (!data) {
return (
<section className="report-panel">
<h3>{statusText[report.status]}</h3>
<p>{report.error_message || "先生正在整理报告。"}</p>
</section>
);
}
return (
<section className="report-panel">
<div className="report-hero">
<div>
<p className="section-label">PALM REPORT · {handText[report.hand_side]}</p>
<h3></h3>
<p>{data.overall_summary}</p>
</div>
<div className="score">
<strong>{score}</strong>
<span>/100</span>
</div>
</div>
<div className="keywords">
{data.lucky_keywords.map((word) => (
<span key={word}>{word}</span>
))}
</div>
<div className="dimension-grid">
{data.dimensions.map((dimension, index) => (
<DimensionCard key={`${dimension.name}-${index}`} dimension={dimension} index={index} />
))}
</div>
<div className="summary-grid">
<SummaryList title="优势倾向" items={data.strengths} />
<SummaryList title="需要留意" items={data.challenges} />
<SummaryList title="近期建议" items={data.suggestions} />
</div>
<p className="disclaimer">{data.disclaimer}</p>
<button className="delete-action" onClick={onDelete}></button>
</section>
);
}
function DimensionCard({ dimension, index }: { dimension: Dimension; index: number }) {
return (
<article className="dimension-card">
<div className="dimension-head">
<span>0{index + 1}</span>
<strong>{dimension.name}</strong>
<em>{Math.round(dimension.confidence * 100)}%</em>
</div>
<p>{dimension.interpretation}</p>
<div className="observations">
{dimension.observations.map((item) => (
<span key={item}>{item}</span>
))}
</div>
<div className="advice">{dimension.advice}</div>
</article>
);
}
function SummaryList({ title, items }: { title: string; items: string[] }) {
return (
<article className="summary-list">
<h4>{title}</h4>
{items.map((item) => (
<p key={item}>{item}</p>
))}
</article>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="stat">
<strong>{value}</strong>
<span>{label}</span>
</div>
);
}
function getScore(data?: ReportData | null) {
if (!data?.dimensions?.length) return 68;
const average = data.dimensions.reduce((sum, item) => sum + item.confidence, 0) / data.dimensions.length;
const quality = data.quality_check?.confidence || 0.7;
return Math.max(60, Math.min(96, Math.round((average * 0.65 + quality * 0.35) * 100)));
}
function formatDate(value: string) {
return value.replace("T", " ").slice(0, 16);
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}