"use client"; import { ChangeEvent, useEffect, useMemo, useState } from "react"; import { Dimension, HandSide, Report, ReportData, ReportSummary, apiFetch, ensureToken, uploadPalmImage, } from "@/lib/api"; const handText: Record = { left: "左手", right: "右手", unknown: "不确定", }; const statusText: Record = { 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(null); const [preview, setPreview] = useState(""); const [handSide, setHandSide] = useState("unknown"); const [busyText, setBusyText] = useState(""); const [error, setError] = useState(""); const [reports, setReports] = useState([]); const [activeReport, setActiveReport] = useState(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) { 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("/reports"); setReports(data); } async function startReport() { if (!file) { setError("请先选择一张清晰的掌心照片。"); return; } setBusyText("正在接入掌纹照片..."); setError(""); try { const upload = await uploadPalmImage(file); const report = await apiFetch("/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(`/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(`/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 (
{hasActiveJob ? ( ) : null} {activeView === "home" ? (

AI 玄学档案 · 娱乐占卜 · 自我反思

把日常困惑,交给先生慢慢看。

从手相开始,逐步扩展到面相、八字与个人长期档案。每一次解读都更贴近生活、学习、事业与关系。

{services.map((service) => ( ))}
) : null} {activeView === "palm" ? (
PALM READING

上传掌心照片

掌心完整入镜、光线充足、纹路清晰。左右手不确定可以选“不确定”。

{(["left", "right", "unknown"] as HandSide[]).map((side) => ( ))}
{error ?

{error}

: null}
) : null} {activeView === "archive" ? (

MISTER ARCHIVE

个人玄学档案

{reports.length ? ( reports.map((item) => ( )) ) : (

暂无档案。生成第一份报告后会出现在这里。

)}
) : null} {activeReport ? ( deleteReport(activeReport.id)} /> ) : null}
); } function ReportPanel({ report, onDelete, }: { report: Report; onDelete: () => void; }) { const data = report.report_data; const score = useMemo(() => getScore(data), [data]); if (!data) { return (

{statusText[report.status]}

{report.error_message || "先生正在整理报告。"}

); } return (

PALM REPORT · {handText[report.hand_side]}

赛博先生手相报告

{data.overall_summary}

{score} /100
{data.lucky_keywords.map((word) => ( {word} ))}
{data.dimensions.map((dimension, index) => ( ))}

{data.disclaimer}

); } function DimensionCard({ dimension, index }: { dimension: Dimension; index: number }) { return (
0{index + 1} {dimension.name} {Math.round(dimension.confidence * 100)}%

{dimension.interpretation}

{dimension.observations.map((item) => ( {item} ))}
先生建议:{dimension.advice}
); } function SummaryList({ title, items }: { title: string; items: string[] }) { return (

{title}

{items.map((item) => (

{item}

))}
); } function Stat({ label, value }: { label: string; value: number }) { return (
{value} {label}
); } 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)); }