people-reading/web/components/PalmWebApp.tsx
2026-05-12 11:31:04 +08:00

419 lines
14 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: "失败",
};
const services = [
{
id: "palm",
name: "手相",
title: "掌心解读",
description: "上传掌心照片,生成生活、学习、事业与关系提醒。",
status: "已开放",
},
{
id: "face",
name: "面相",
title: "气色与五官",
description: "未来支持面部特征与状态观察,适合做日常运势入口。",
status: "规划中",
},
{
id: "bazi",
name: "八字",
title: "生辰格局",
description: "未来支持出生信息推演,沉淀长期个人档案。",
status: "规划中",
},
];
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);
const [activeView, setActiveView] = useState<"home" | "palm" | "archive">("home");
const [activeJobId, setActiveJobId] = useState("");
useEffect(() => {
ensureToken()
.then(() => Promise.all([loadReports()]))
.then(() => setReady(true))
.catch((err) => {
setError(err.message || "初始化失败");
setReady(true);
});
}, []);
useEffect(() => {
return () => {
if (preview) URL.revokeObjectURL(preview);
};
}, [preview]);
const completedReports = reports.filter((item) => item.status === "completed").length;
const latestReport = reports[0];
const hasActiveJob = Boolean(activeJobId);
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);
const report = await apiFetch<Report>("/reports", {
method: "POST",
body: JSON.stringify({ image_id: upload.image_id, hand_side: handSide }),
});
setActiveReport(report);
setActiveJobId(report.id);
setBusyText("");
setFile(null);
setPreview("");
void pollReport(report.id)
.then(() => loadReports())
.catch((err) => setError(err instanceof Error ? err.message : "报告生成失败"))
.finally(() => setActiveJobId(""));
await loadReports();
} catch (err) {
setError(err instanceof Error ? err.message : "生成失败");
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">
<header className="app-topbar">
<button className="brand compact-button" onClick={() => setActiveView("home")}>
<span className="seal"></span>
<span>
<small>CYBER MISTER</small>
<strong></strong>
</span>
</button>
<nav className="desktop-nav" aria-label="主导航">
<button className={activeView === "home" ? "active" : ""} onClick={() => setActiveView("home")}></button>
<button className={activeView === "palm" ? "active" : ""} onClick={() => setActiveView("palm")}></button>
<button className={activeView === "archive" ? "active" : ""} onClick={() => setActiveView("archive")}></button>
</nav>
</header>
{hasActiveJob ? (
<button className="job-banner" onClick={() => activeJobId && openReport(activeJobId)}>
<span className="pulse-dot" />
<span></span>
<strong></strong>
</button>
) : null}
{activeView === "home" ? (
<section className="home-screen">
<div className="hero-copy">
<p className="eyebrow">AI · · </p>
<h1></h1>
<p></p>
</div>
<div className="service-grid">
{services.map((service) => (
<button
key={service.id}
className={`service-card ${service.id !== "palm" ? "muted" : ""}`}
onClick={() => service.id === "palm" && setActiveView("palm")}
>
<span>{service.status}</span>
<strong>{service.name}</strong>
<em>{service.title}</em>
<small>{service.description}</small>
</button>
))}
</div>
<div className="quick-row">
<button className="primary-action" onClick={() => setActiveView("palm")}></button>
<button className="ghost-action" onClick={() => setActiveView("archive")}>
{latestReport ? "查看最近档案" : "查看档案"}
</button>
</div>
</section>
) : null}
{activeView === "palm" ? (
<section className="workspace">
<div className="upload-card">
<div className="section-label">PALM READING</div>
<h2></h2>
<p>线</p>
<label className={`drop-zone ${preview ? "has-preview" : ""}`}>
{preview ? (
<img src={preview} alt="掌心照片预览" />
) : (
<span>
<strong></strong>
<small> JPG / PNG / WEBP</small>
</span>
)}
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={onPickFile} />
</label>
<div className="segmented" aria-label="选择左右手">
{(["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>
<aside className="side-panel">
<div className="mini-card">
<div className="section-label"></div>
<h3></h3>
<p></p>
</div>
<div className="mini-card">
<div className="stats">
<Stat label="累计报告" value={reports.length} />
<Stat label="已完成" value={completedReports} />
</div>
</div>
</aside>
</section>
) : null}
{activeView === "archive" ? (
<section className="archive-screen">
<div className="archive-head">
<div>
<p className="section-label">MISTER ARCHIVE</p>
<h2></h2>
</div>
<button className="ghost-action" onClick={() => setActiveView("palm")}></button>
</div>
<div className="report-list">
{reports.length ? (
reports.map((item) => (
<button key={item.id} className="archive-item" onClick={() => openReport(item.id)}>
<span>
<strong></strong>
<small>{formatDate(item.created_at)}</small>
{item.overall_summary ? <b>{item.overall_summary}</b> : null}
</span>
<em>{statusText[item.status]}</em>
</button>
))
) : (
<p className="empty"></p>
)}
</div>
</section>
) : null}
{activeReport ? (
<ReportPanel
report={activeReport}
onDelete={() => deleteReport(activeReport.id)}
/>
) : null}
<nav className="mobile-tabbar" aria-label="移动端导航">
<button className={activeView === "home" ? "active" : ""} onClick={() => setActiveView("home")}>
<span></span>
</button>
<button className={activeView === "palm" ? "active" : ""} onClick={() => setActiveView("palm")}>
<span></span>
</button>
<button className={activeView === "archive" ? "active" : ""} onClick={() => setActiveView("archive")}>
<span></span>
</button>
</nav>
</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));
}