301 lines
9.3 KiB
TypeScript
301 lines
9.3 KiB
TypeScript
"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));
|
||
}
|