"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: "失败", }; 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); 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) { 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); setBusyText("先生正在解读掌纹..."); const report = await apiFetch("/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(`/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 (

CYBER MISTER

赛博先生

AI 手相报告 · 娱乐占卜 · 自我反思

把掌心里的线索,翻译成更贴近日常的提醒。

上传一张清晰掌心照片,生成面向生活、学习、事业与关系的手相报告。高级东方玄学的仪式感,配上现代 AI 的分析速度。

01 · 请先生看掌

上传掌心照片

掌心完整入镜、光线充足、纹路清晰。左右手不知道也没关系。

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

{error}

: null}
02 · 解读档案
{reports.length ? ( reports.slice(0, 6).map((item) => ( )) ) : (

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

)}
{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)); }