419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
"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));
|
||
}
|