This commit is contained in:
aaron 2026-05-12 11:31:04 +08:00
parent 4397369e35
commit 2256ded518
2 changed files with 673 additions and 324 deletions

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,30 @@ const statusText: Record<Report["status"], string> = {
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);
@ -34,6 +58,8 @@ export default function PalmWebApp() {
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()
@ -45,7 +71,15 @@ export default function PalmWebApp() {
});
}, []);
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];
@ -70,16 +104,22 @@ export default function PalmWebApp() {
setError("");
try {
const upload = await uploadPalmImage(file);
setBusyText("先生正在解读掌纹...");
const report = await apiFetch<Report>("/reports", {
method: "POST",
body: JSON.stringify({ image_id: upload.image_id, hand_side: handSide }),
});
await pollReport(report.id);
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 : "生成失败");
} finally {
setBusyText("");
}
}
@ -110,64 +150,127 @@ export default function PalmWebApp() {
return (
<main className="shell">
<section className="hero">
<div className="brand">
<span className="seal"></span>
<div>
<p className="kicker">CYBER MISTER</p>
<h1></h1>
<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>
<div className="hero-copy">
<p className="eyebrow">AI · · </p>
<h2>线</h2>
<p>
AI
</p>
</div>
<div className="hero-orbit" aria-hidden="true">
<div className="palm-mark"></div>
</div>
</section>
<section className="workspace">
<div className="upload-card">
<div className="section-label">01 · </div>
<h3></h3>
<p>线</p>
<label className="drop-zone">
{preview ? <img src={preview} alt="掌心照片预览" /> : <span></span>}
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={onPickFile} />
</label>
<div className="segmented">
{(["left", "right", "unknown"] as HandSide[]).map((side) => (
<button key={side} className={handSide === side ? "active" : ""} onClick={() => setHandSide(side)}>
{handText[side]}
<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>
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={startReport}>
{busyText || "生成手相报告"}
</button>
{error ? <p className="error">{error}</p> : null}
</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}
<div className="archive-card">
<div className="section-label">02 · </div>
<div className="stats">
<Stat label="累计报告" value={reports.length} />
<Stat label="已完成" value={completedReports} />
{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.slice(0, 6).map((item) => (
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>
@ -176,8 +279,8 @@ export default function PalmWebApp() {
<p className="empty"></p>
)}
</div>
</div>
</section>
</section>
) : null}
{activeReport ? (
<ReportPanel
@ -185,6 +288,21 @@ export default function PalmWebApp() {
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>
);
}