822 lines
27 KiB
TypeScript
822 lines
27 KiB
TypeScript
"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 wuxingNames = ["木", "火", "土", "金", "水"] as const;
|
||
type WuxingName = (typeof wuxingNames)[number];
|
||
|
||
const stemWuxing: Record<string, WuxingName> = {
|
||
甲: "木",
|
||
乙: "木",
|
||
丙: "火",
|
||
丁: "火",
|
||
戊: "土",
|
||
己: "土",
|
||
庚: "金",
|
||
辛: "金",
|
||
壬: "水",
|
||
癸: "水",
|
||
};
|
||
|
||
const branchWuxing: Record<string, WuxingName> = {
|
||
子: "水",
|
||
丑: "土",
|
||
寅: "木",
|
||
卯: "木",
|
||
辰: "土",
|
||
巳: "火",
|
||
午: "火",
|
||
未: "土",
|
||
申: "金",
|
||
酉: "金",
|
||
戌: "土",
|
||
亥: "水",
|
||
};
|
||
|
||
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 = getWuxingBalance(chart);
|
||
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">
|
||
{wuxingNames.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 getWuxingBalance(chart: BaziChart): Record<WuxingName, number> {
|
||
const existing = normalizeWuxingBalance(chart.wuxing_balance);
|
||
if (wuxingNames.some((name) => existing[name] > 0)) {
|
||
return existing;
|
||
}
|
||
return countWuxingFromPillars(chart.pillars || {});
|
||
}
|
||
|
||
function normalizeWuxingBalance(value?: Record<string, number>): Record<WuxingName, number> {
|
||
return wuxingNames.reduce(
|
||
(result, name) => {
|
||
result[name] = Number(value?.[name] || 0);
|
||
return result;
|
||
},
|
||
{ 木: 0, 火: 0, 土: 0, 金: 0, 水: 0 } as Record<WuxingName, number>,
|
||
);
|
||
}
|
||
|
||
function countWuxingFromPillars(pillars: Record<string, string>): Record<WuxingName, number> {
|
||
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 (
|
||
<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));
|
||
}
|