("/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" ? "请先选择一张清晰的掌心照片。" : "请先选择一张清晰的单人正脸照片。");
@@ -170,6 +191,10 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
}
async function startBaziReading() {
+ if (quota && quota.remaining <= 0) {
+ setError("今日 5 次解读机会已用完,请明天 0 点后再来。");
+ return;
+ }
if (!baziForm.birth_date) {
setError("请先填写出生日期。");
return;
@@ -210,6 +235,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
.catch((err) => setError(err instanceof Error ? err.message : "报告生成失败"))
.finally(() => setActiveJobId(""));
void loadReadings();
+ void loadQuota();
}
async function pollReading(readingId: string) {
@@ -293,12 +319,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
))}
-
-
-
-
+
) : null}
@@ -309,6 +330,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
busyText={busyText}
ready={ready}
error={error}
+ quota={quota}
onPickFile={(event) => pickFile(event, "palm")}
onSubmit={() => startImageReading("palm")}
extra={
@@ -320,7 +342,6 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
))}
}
- stats={}
/>
) : null}
@@ -331,10 +352,10 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
busyText={busyText}
ready={ready}
error={error}
+ quota={quota}
onPickFile={(event) => pickFile(event, "face")}
onSubmit={() => startImageReading("face")}
extra={null}
- stats={}
/>
) : null}
@@ -345,8 +366,8 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
busyText={busyText}
ready={ready}
error={error}
+ quota={quota}
onSubmit={startBaziReading}
- stats={}
/>
) : null}
@@ -362,14 +383,21 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
{readings.length ? (
readings.map((item) => (
-
@@ -445,7 +473,6 @@ function ImageReadingForm({
提交后可离开等待
提交成功后,系统会在后台生成报告。你可以继续看档案,完成后自动刷新。
- {stats}
);
@@ -457,7 +484,7 @@ function BaziFormView({
busyText,
ready,
error,
- stats,
+ quota,
onSubmit,
}: {
form: BaziForm;
@@ -465,7 +492,7 @@ function BaziFormView({
busyText: string;
ready: boolean;
error: string;
- stats: ReactNode;
+ quota: Quota | null;
onSubmit: () => void;
}) {
return (
@@ -473,7 +500,7 @@ function BaziFormView({
BAZI READING
填写生辰信息
-
第一版按标准专业排盘,不做真太阳时校正。出生地会用于报告语境,不用于经度校正。
+
输入出生日期与时间,赛博先生会先排出四柱,再把格局翻译成贴近日常的提醒。
-
- {busyText || "提交排盘"}
+
+ {busyText || (quota?.remaining === 0 ? "今日次数已用完" : "提交排盘")}
{error ? {error}
: null}
@@ -532,17 +559,24 @@ function BaziFormView({
先排盘,再解读
后端会先计算四柱、五行和十神线索,再让赛博先生生成更接地气的生活化报告。
- {stats}
);
}
-function ReadingStats({ total, completed }: { total: number; completed: number }) {
+function HomeStats({ total, completed, quota }: { total: number; completed: number; quota: Quota | null }) {
return (
-
-
-
+
+
+
TODAY STATUS
+
今日解读状态
+
+
+
+
+
+
+
);
}
@@ -557,6 +591,7 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
{statusText[reading.status]}
{reading.error_message || "先生正在整理报告。"}
+ 删除这份报告
);
}
@@ -584,6 +619,8 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
))}
+ {type === "bazi" ? : null}
+
{data.dimensions.map((dimension, index) => (
@@ -602,6 +639,56 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
);
}
+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 (
+
+
+
+
{chart.time_unknown ? "时辰不详" : chart.birth_time || "已记录时辰"}
+
+
+ {pillarItems.map(([label, value]) => (
+
+ {label}
+ {value || "-"}
+
+ ))}
+
+
+ 公历:{chart.solar_date || "-"}
+ 农历:{chart.lunar_date || "-"}
+ 日主:{chart.day_master || "-"}
+ {chart.birth_place ? 出生地:{chart.birth_place} : null}
+
+
+ {(["木", "火", "土", "金", "水"] as const).map((name) => {
+ const value = wuxing[name] || 0;
+ return (
+
+ {name}
+
+ {value}
+
+ );
+ })}
+
+
+ );
+}
+
function DimensionCard({ dimension, index }: { dimension: Dimension; index: number }) {
return (
@@ -621,6 +708,12 @@ function DimensionCard({ dimension, index }: { dimension: Dimension; index: numb
);
}
+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 (
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 8b51813..f185fb4 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -48,6 +48,13 @@ export type ReadingSummary = {
overall_summary?: string | null;
};
+export type Quota = {
+ limit: number;
+ used: number;
+ remaining: number;
+ reset_at: string;
+};
+
export type Report = Reading & { hand_side?: HandSide };
export type ReportSummary = ReadingSummary & { hand_side?: HandSide };
diff --git a/web/public/icon.svg b/web/public/icon.svg
new file mode 100644
index 0000000..410c6e0
--- /dev/null
+++ b/web/public/icon.svg
@@ -0,0 +1,7 @@
+
diff --git a/web/public/share-card.png b/web/public/share-card.png
new file mode 100644
index 0000000..a13dfb2
Binary files /dev/null and b/web/public/share-card.png differ
diff --git a/web/public/share-card.svg b/web/public/share-card.svg
new file mode 100644
index 0000000..d71f03c
--- /dev/null
+++ b/web/public/share-card.svg
@@ -0,0 +1,25 @@
+