people-reading/web/components/PalmWebApp.tsx
2026-05-12 20:50:15 +08:00

761 lines
25 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, ReactNode, useEffect, useMemo, useState } from "react";
import {
Dimension,
HandSide,
Quota,
Reading,
ReadingSummary,
ReadingType,
ReportData,
apiFetch,
ensureToken,
uploadFaceImage,
uploadPalmImage,
} from "@/lib/api";
const handText: Record<HandSide, string> = {
left: "左手",
right: "右手",
unknown: "不确定",
};
const statusText: Record<Reading["status"], string> = {
pending: "等待中",
processing: "生成中",
completed: "已完成",
failed: "失败",
};
const readingMeta: Record<ReadingType, { name: string; title: string; label: string; description: string }> = {
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<string, string>;
day_master?: string;
wuxing_balance?: Record<string, number>;
};
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<File | null>(null);
const [faceFile, setFaceFile] = useState<File | null>(null);
const [palmPreview, setPalmPreview] = useState("");
const [facePreview, setFacePreview] = useState("");
const [handSide, setHandSide] = useState<HandSide>("unknown");
const [baziForm, setBaziForm] = useState<BaziForm>(defaultBaziForm);
const [busyText, setBusyText] = useState("");
const [error, setError] = useState("");
const [readings, setReadings] = useState<ReadingSummary[]>([]);
const [quota, setQuota] = useState<Quota | null>(null);
const [activeReading, setActiveReading] = useState<Reading | null>(null);
const [activeView, setActiveView] = useState<View>(initialView);
const [activeJobId, setActiveJobId] = useState("");
const [activeJobType, setActiveJobType] = useState<ReadingType>("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<HTMLInputElement>, 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<ReadingSummary[]>("/readings");
setReadings(data);
}
async function loadQuota() {
const data = await apiFetch<Quota>("/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<Reading>("/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<Reading>("/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<Reading>(`/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<Reading>(`/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 (
<main className="shell">
<header className="app-topbar">
<button className="brand compact-button" onClick={() => navigate("home")}>
<span className="seal"></span>
<span>
<small>CYBER MISTER</small>
<strong></strong>
</span>
</button>
<nav className="desktop-nav" aria-label="主导航">
{views.map((view) => (
<button key={view} className={activeView === view ? "active" : ""} onClick={() => navigate(view)}>
{view === "home" ? "首页" : view === "archive" ? "档案" : readingMeta[view].name}
</button>
))}
</nav>
</header>
{hasActiveJob ? (
<button className="job-banner" onClick={() => activeJobId && openReading(activeJobId)}>
<span className="pulse-dot" />
<span>{readingMeta[activeJobType].name}</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">
{(["palm", "face", "bazi"] as ReadingType[]).map((type) => (
<button key={type} className="service-card" onClick={() => navigate(type)}>
<span></span>
<strong>{readingMeta[type].name}</strong>
<em>{readingMeta[type].title}</em>
<small>{readingMeta[type].description}</small>
</button>
))}
</div>
<HomeStats total={readings.length} completed={completedReadings} quota={quota} />
</section>
) : null}
{activeView === "palm" ? (
<ImageReadingForm
type="palm"
preview={palmPreview}
busyText={busyText}
ready={ready}
error={error}
quota={quota}
onPickFile={(event) => pickFile(event, "palm")}
onSubmit={() => startImageReading("palm")}
extra={
<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>
}
/>
) : null}
{activeView === "face" ? (
<ImageReadingForm
type="face"
preview={facePreview}
busyText={busyText}
ready={ready}
error={error}
quota={quota}
onPickFile={(event) => pickFile(event, "face")}
onSubmit={() => startImageReading("face")}
extra={null}
/>
) : null}
{activeView === "bazi" ? (
<BaziFormView
form={baziForm}
setForm={setBaziForm}
busyText={busyText}
ready={ready}
error={error}
quota={quota}
onSubmit={startBaziReading}
/>
) : 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={() => navigate("home")}></button>
</div>
<div className="report-list">
{readings.length ? (
readings.map((item) => (
<div key={item.id} className={`archive-item ${item.status === "failed" ? "is-failed" : ""}`}>
<button className="archive-open" onClick={() => openReading(item.id)}>
<span>
<strong>{readingMeta[item.reading_type].name}</strong>
<small>{formatDate(item.created_at)}</small>
{item.overall_summary ? <b>{item.overall_summary}</b> : null}
</span>
</button>
<div className="archive-actions">
<em>{statusText[item.status]}</em>
<button className="archive-delete" onClick={() => deleteReading(item.id)} aria-label={`删除${readingMeta[item.reading_type].name}报告`}>
</button>
</div>
</div>
))
) : (
<p className="empty"></p>
)}
</div>
{activeReading ? <ReportPanel reading={activeReading} onDelete={() => deleteReading(activeReading.id)} /> : null}
</section>
) : null}
<nav className="mobile-tabbar" aria-label="移动端导航">
{views.map((view) => (
<button key={view} className={activeView === view ? "active" : ""} onClick={() => navigate(view)}>
<span>{view === "home" ? "⌂" : view === "archive" ? "册" : readingMeta[view].name.slice(0, 1)}</span>
{view === "home" ? "首页" : view === "archive" ? "档案" : readingMeta[view].name}
</button>
))}
</nav>
</main>
);
}
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<HTMLInputElement>) => void;
onSubmit: () => void;
}) {
const isPalm = type === "palm";
return (
<section className="workspace">
<div className="upload-card">
<div className="section-label">{readingMeta[type].label}</div>
<h2>{isPalm ? "上传掌心照片" : "上传正脸照片"}</h2>
<p>{isPalm ? "掌心完整入镜、光线充足、纹路清晰。左右手不确定可以选“不确定”。" : "请上传单人正脸照,五官无遮挡,光线自然清晰。"}</p>
<label className={`drop-zone ${preview ? "has-preview" : ""}`}>
{preview ? (
<img src={preview} alt={isPalm ? "掌心照片预览" : "正脸照片预览"} />
) : (
<span>
<strong></strong>
<small> JPG / PNG / WEBP</small>
</span>
)}
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={onPickFile} />
</label>
{extra}
<button className="primary-action" disabled={!ready || Boolean(busyText) || quota?.remaining === 0} onClick={onSubmit}>
{busyText || (quota?.remaining === 0 ? "今日次数已用完" : "提交分析")}
</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>
</aside>
</section>
);
}
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 (
<section className="workspace">
<div className="upload-card">
<div className="section-label">BAZI READING</div>
<h2></h2>
<p></p>
<div className="form-grid">
<label className="field">
<span></span>
<input value={form.nickname} onChange={(event) => setForm({ ...form, nickname: event.target.value })} placeholder="可不填" />
</label>
<label className="field">
<span>/</span>
<select value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}>
<option value=""></option>
<option value="female"></option>
<option value="male"></option>
<option value="other"></option>
</select>
</label>
<label className="field">
<span></span>
<select value={form.calendar_type} onChange={(event) => setForm({ ...form, calendar_type: event.target.value as "solar" | "lunar" })}>
<option value="solar"></option>
<option value="lunar"></option>
</select>
</label>
{form.calendar_type === "lunar" ? (
<label className="checkbox-line">
<input type="checkbox" checked={form.is_leap_month} onChange={(event) => setForm({ ...form, is_leap_month: event.target.checked })} />
</label>
) : null}
<label className="field">
<span></span>
<input type="date" value={form.birth_date} onChange={(event) => setForm({ ...form, birth_date: event.target.value })} />
</label>
<label className="field">
<span></span>
<input type="time" value={form.birth_time} disabled={form.time_unknown} onChange={(event) => setForm({ ...form, birth_time: event.target.value })} />
</label>
<label className="checkbox-line">
<input type="checkbox" checked={form.time_unknown} onChange={(event) => setForm({ ...form, time_unknown: event.target.checked })} />
</label>
<label className="field field-wide">
<span></span>
<input value={form.birth_place} onChange={(event) => setForm({ ...form, birth_place: event.target.value })} placeholder="例如:广东深圳" />
</label>
</div>
<button className="primary-action" disabled={!ready || Boolean(busyText) || quota?.remaining === 0} onClick={onSubmit}>
{busyText || (quota?.remaining === 0 ? "今日次数已用完" : "提交排盘")}
</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>
</aside>
</section>
);
}
function HomeStats({ total, completed, quota }: { total: number; completed: number; quota: Quota | null }) {
return (
<div className="home-stats">
<div>
<p className="section-label">TODAY STATUS</p>
<h2></h2>
</div>
<div className="stats">
<Stat label="今日剩余" value={quota?.remaining ?? 0} />
<Stat label="每日上限" value={quota?.limit ?? 5} />
<Stat label="累计报告" value={total} />
<Stat label="已完成" value={completed} />
</div>
</div>
);
}
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 (
<section className="report-panel">
<h3>{statusText[reading.status]}</h3>
<p>{reading.error_message || "先生正在整理报告。"}</p>
<button className="delete-action" onClick={onDelete}></button>
</section>
);
}
return (
<section className="report-panel">
<div className="report-hero">
<div>
<p className="section-label">
{readingMeta[type].label}
{type === "palm" ? ` · ${handText[handSide]}` : ""}
</p>
<h3>{readingMeta[type].name}</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>
{type === "bazi" ? <BaziChartPanel chart={getBaziChart(reading)} /> : null}
<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 BaziChartPanel({ chart }: { chart: BaziChart | null }) {
if (!chart) return null;
const pillars = chart.pillars || {};
const wuxing = chart.wuxing_balance || {};
const pillarItems = [
["年柱", pillars.year],
["月柱", pillars.month],
["日柱", pillars.day],
["时柱", pillars.time],
];
return (
<section className="bazi-chart">
<div className="bazi-chart-head">
<div>
<p className="section-label">CHART</p>
<h4></h4>
</div>
<span>{chart.time_unknown ? "时辰不详" : chart.birth_time || "已记录时辰"}</span>
</div>
<div className="pillar-row">
{pillarItems.map(([label, value]) => (
<div className="pillar" key={label}>
<span>{label}</span>
<strong>{value || "-"}</strong>
</div>
))}
</div>
<div className="chart-facts">
<span>{chart.solar_date || "-"}</span>
<span>{chart.lunar_date || "-"}</span>
<span>{chart.day_master || "-"}</span>
{chart.birth_place ? <span>{chart.birth_place}</span> : null}
</div>
<div className="wuxing-bars">
{(["木", "火", "土", "金", "水"] as const).map((name) => {
const value = wuxing[name] || 0;
return (
<div className="wuxing-bar" key={name}>
<span>{name}</span>
<i style={{ width: `${Math.max(8, value * 14)}%` }} />
<em>{value}</em>
</div>
);
})}
</div>
</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 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 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 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));
}