update
This commit is contained in:
parent
4397369e35
commit
2256ded518
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user