"use client"; import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react"; import { Dimension, HandSide, Quota, Reading, ReadingSummary, ReadingType, ReportData, apiFetch, ensureToken, uploadFaceImage, uploadPalmImage, } from "@/lib/api"; const handText: Record = { left: "左手", right: "右手", unknown: "不确定", }; const statusText: Record = { pending: "等待中", processing: "生成中", completed: "已完成", failed: "失败", }; const readingMeta: Record = { palm: { name: "手相", title: "掌心解读", label: "PALM READING", description: "上传掌心照片,生成生活、学习、事业与关系提醒。", }, face: { name: "面相", title: "气色与五官", label: "FACE READING", description: "上传单人正脸照,观察脸型、五官、气色与表达状态。", }, bazi: { name: "八字", title: "生辰格局", label: "BAZI READING", description: "填写出生信息,排出四柱后生成生活化八字报告。", }, }; type View = "home" | ReadingType | "archive"; const views: View[] = ["home", "palm", "face", "bazi", "archive"]; type BaziForm = { nickname: string; gender: string; calendar_type: "solar" | "lunar"; is_leap_month: boolean; birth_date: string; birth_time: string; time_unknown: boolean; birth_place: string; }; type BaziChart = { solar_date?: string; lunar_date?: string; birth_time?: string | null; time_unknown?: boolean; birth_place?: string | null; pillars?: Record; day_master?: string; wuxing_balance?: Record; }; const wuxingNames = ["木", "火", "土", "金", "水"] as const; type WuxingName = (typeof wuxingNames)[number]; const stemWuxing: Record = { 甲: "木", 乙: "木", 丙: "火", 丁: "火", 戊: "土", 己: "土", 庚: "金", 辛: "金", 壬: "水", 癸: "水", }; const branchWuxing: Record = { 子: "水", 丑: "土", 寅: "木", 卯: "木", 辰: "土", 巳: "火", 午: "火", 未: "土", 申: "金", 酉: "金", 戌: "土", 亥: "水", }; const defaultBaziForm: BaziForm = { nickname: "", gender: "", calendar_type: "solar", is_leap_month: false, birth_date: "", birth_time: "12:00", time_unknown: false, birth_place: "", }; export default function PalmWebApp({ initialView = "home" }: { initialView?: View }) { const [ready, setReady] = useState(false); const [palmFile, setPalmFile] = useState(null); const [faceFile, setFaceFile] = useState(null); const [palmPreview, setPalmPreview] = useState(""); const [facePreview, setFacePreview] = useState(""); const [handSide, setHandSide] = useState("unknown"); const [baziForm, setBaziForm] = useState(defaultBaziForm); const [busyText, setBusyText] = useState(""); const [error, setError] = useState(""); const [readings, setReadings] = useState([]); const [quota, setQuota] = useState(null); const [activeReading, setActiveReading] = useState(null); const [activeView, setActiveView] = useState(initialView); const [activeJobId, setActiveJobId] = useState(""); const [activeJobType, setActiveJobType] = useState("palm"); useEffect(() => { setActiveView(readViewFromPath(initialView)); const onPopState = () => setActiveView(readViewFromPath(initialView)); window.addEventListener("popstate", onPopState); ensureToken() .then(() => Promise.all([loadReadings(), loadQuota()])) .then(() => setReady(true)) .catch((err) => { setError(err.message || "初始化失败"); setReady(true); }); return () => window.removeEventListener("popstate", onPopState); }, [initialView]); useEffect(() => { return () => { if (palmPreview) URL.revokeObjectURL(palmPreview); if (facePreview) URL.revokeObjectURL(facePreview); }; }, [palmPreview, facePreview]); const completedReadings = readings.filter((item) => item.status === "completed").length; const hasActiveJob = Boolean(activeJobId); function pickFile(event: ChangeEvent, type: "palm" | "face") { const selected = event.target.files?.[0]; if (!selected) return; setError(""); const url = URL.createObjectURL(selected); if (type === "palm") { if (palmPreview) URL.revokeObjectURL(palmPreview); setPalmFile(selected); setPalmPreview(url); } else { if (facePreview) URL.revokeObjectURL(facePreview); setFaceFile(selected); setFacePreview(url); } } async function loadReadings() { const data = await apiFetch("/readings"); setReadings(data); } async function loadQuota() { const data = await apiFetch("/readings/quota"); setQuota(data); } async function startImageReading(type: "palm" | "face") { if (quota && quota.remaining <= 0) { setError("今日 5 次解读机会已用完,请明天 0 点后再来。"); return; } const file = type === "palm" ? palmFile : faceFile; if (!file) { setError(type === "palm" ? "请先选择一张清晰的掌心照片。" : "请先选择一张清晰的单人正脸照片。"); return; } setBusyText(type === "palm" ? "正在接入掌纹照片..." : "正在接入正脸照片..."); setError(""); try { const upload = type === "palm" ? await uploadPalmImage(file) : await uploadFaceImage(file); const reading = await apiFetch("/readings", { method: "POST", body: JSON.stringify({ reading_type: type, image_id: upload.image_id, input_data: type === "palm" ? { hand_side: handSide } : { photo_style: "front_single" }, }), }); afterTaskCreated(reading); if (type === "palm") { setPalmFile(null); setPalmPreview(""); } else { setFaceFile(null); setFacePreview(""); } } catch (err) { setError(err instanceof Error ? err.message : "提交失败"); setBusyText(""); } } async function startBaziReading() { if (quota && quota.remaining <= 0) { setError("今日 5 次解读机会已用完,请明天 0 点后再来。"); return; } if (!baziForm.birth_date) { setError("请先填写出生日期。"); return; } if (!baziForm.time_unknown && !baziForm.birth_time) { setError("请选择出生时间,或勾选“不确定时辰”。"); return; } setBusyText("正在排出四柱..."); setError(""); try { const reading = await apiFetch("/readings", { method: "POST", body: JSON.stringify({ reading_type: "bazi", input_data: { ...baziForm, nickname: baziForm.nickname || null, gender: baziForm.gender || null, birth_place: baziForm.birth_place || null, }, }), }); afterTaskCreated(reading); } catch (err) { setError(err instanceof Error ? err.message : "提交失败"); setBusyText(""); } } function afterTaskCreated(reading: Reading) { setActiveReading(reading); setActiveJobId(reading.id); setActiveJobType(reading.reading_type); setBusyText(""); void pollReading(reading.id) .then(() => loadReadings()) .catch((err) => setError(err instanceof Error ? err.message : "报告生成失败")) .finally(() => setActiveJobId("")); void loadReadings(); void loadQuota(); } async function pollReading(readingId: string) { for (let i = 0; i < 80; i += 1) { const reading = await apiFetch(`/readings/${readingId}`); setActiveReading(reading); if (reading.status === "completed") return reading; if (reading.status === "failed") throw new Error(reading.error_message || "报告生成失败"); await sleep(2200); } throw new Error("生成时间较长,请稍后在档案中查看。"); } async function openReading(readingId: string) { setError(""); const reading = await apiFetch(`/readings/${readingId}`); setActiveReading(reading); navigate("archive"); window.scrollTo({ top: 0, behavior: "smooth" }); } async function deleteReading(readingId: string) { await apiFetch(`/readings/${readingId}`, { method: "DELETE" }); if (activeReading?.id === readingId) setActiveReading(null); await loadReadings(); } function navigate(view: View) { setError(""); setActiveView(view); window.history.pushState(null, "", pathForView(view)); if (view !== "archive") { setActiveReading(null); } window.scrollTo({ top: 0, behavior: "smooth" }); } return (
{hasActiveJob ? ( ) : null} {activeView === "home" ? (

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

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

从手相、面相到八字,每一次解读都更贴近生活、学习、事业与关系。

{(["palm", "face", "bazi"] as ReadingType[]).map((type) => ( ))}
) : null} {activeView === "palm" ? ( pickFile(event, "palm")} onSubmit={() => startImageReading("palm")} extra={
{(["left", "right", "unknown"] as HandSide[]).map((side) => ( ))}
} /> ) : null} {activeView === "face" ? ( pickFile(event, "face")} onSubmit={() => startImageReading("face")} extra={null} /> ) : null} {activeView === "bazi" ? ( ) : null} {activeView === "archive" ? (

MISTER ARCHIVE

个人玄学档案

{readings.length ? ( readings.map((item) => (
{statusText[item.status]}
)) ) : (

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

)}
{activeReading ? deleteReading(activeReading.id)} /> : null}
) : null}
); } function ImageReadingForm({ type, preview, busyText, ready, error, quota, extra, onPickFile, onSubmit, }: { type: "palm" | "face"; preview: string; busyText: string; ready: boolean; error: string; quota: Quota | null; extra: ReactNode; onPickFile: (event: ChangeEvent) => void; onSubmit: () => void; }) { const isPalm = type === "palm"; return (
{readingMeta[type].label}

{isPalm ? "上传掌心照片" : "上传正脸照片"}

{isPalm ? "掌心完整入镜、光线充足、纹路清晰。左右手不确定可以选“不确定”。" : "请上传单人正脸照,五官无遮挡,光线自然清晰。"}

{extra} {error ?

{error}

: null}
); } function BaziFormView({ form, setForm, busyText, ready, error, quota, onSubmit, }: { form: BaziForm; setForm: (form: BaziForm) => void; busyText: string; ready: boolean; error: string; quota: Quota | null; onSubmit: () => void; }) { return (
BAZI READING

填写生辰信息

输入出生日期与时间,赛博先生会先排出四柱,再把格局翻译成贴近日常的提醒。

{form.calendar_type === "lunar" ? ( ) : null}
{error ?

{error}

: null}
); } function HomeStats({ total, completed, quota }: { total: number; completed: number; quota: Quota | null }) { return (

TODAY STATUS

今日解读状态

); } function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () => void }) { const data = reading.report_data; const score = useMemo(() => getScore(data), [data]); const type = reading.reading_type; const handSide = (reading.input_data?.hand_side as HandSide | undefined) || "unknown"; if (!data) { return (

{statusText[reading.status]}

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

); } return (

{readingMeta[type].label} {type === "palm" ? ` · ${handText[handSide]}` : ""}

赛博先生{readingMeta[type].name}报告

{data.overall_summary}

{score} /100
{data.lucky_keywords.map((word) => ( {word} ))}
{type === "bazi" ? : null}
{data.dimensions.map((dimension, index) => ( ))}

{data.disclaimer}

); } function BaziChartPanel({ chart }: { chart: BaziChart | null }) { if (!chart) return null; const pillars = chart.pillars || {}; const wuxing = getWuxingBalance(chart); const pillarItems = [ ["年柱", pillars.year], ["月柱", pillars.month], ["日柱", pillars.day], ["时柱", pillars.time], ]; return (

CHART

排盘核心

{chart.time_unknown ? "时辰不详" : chart.birth_time || "已记录时辰"}
{pillarItems.map(([label, value]) => (
{label} {value || "-"}
))}
公历:{chart.solar_date || "-"} 农历:{chart.lunar_date || "-"} 日主:{chart.day_master || "-"} {chart.birth_place ? 出生地:{chart.birth_place} : null}
{wuxingNames.map((name) => { const value = wuxing[name] || 0; return (
{name} {value}
); })}
); } 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 getBaziChart(reading: Reading): BaziChart | null { const chart = reading.input_data?.chart; if (!chart || typeof chart !== "object" || Array.isArray(chart)) return null; return chart as BaziChart; } function getWuxingBalance(chart: BaziChart): Record { const existing = normalizeWuxingBalance(chart.wuxing_balance); if (wuxingNames.some((name) => existing[name] > 0)) { return existing; } return countWuxingFromPillars(chart.pillars || {}); } function normalizeWuxingBalance(value?: Record): Record { return wuxingNames.reduce( (result, name) => { result[name] = Number(value?.[name] || 0); return result; }, { 木: 0, 火: 0, 土: 0, 金: 0, 水: 0 } as Record, ); } function countWuxingFromPillars(pillars: Record): Record { const counts = normalizeWuxingBalance(); Object.values(pillars).forEach((pillar) => { if (!pillar || pillar === "时辰不详") return; Array.from(pillar).forEach((char) => { const element = stemWuxing[char] || branchWuxing[char]; if (element) counts[element] += 1; }); }); return counts; } 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 readViewFromPath(fallback: View): View { if (typeof window === "undefined") return "home"; const segment = window.location.pathname.replace(/^\/+/, "").split("/")[0] || "home"; return views.includes(segment as View) ? (segment as View) : fallback; } function pathForView(view: View) { return view === "home" ? "/" : `/${view}`; } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); }