From 123a5cce6d32eb86cdb22b1a63068797cd81b138 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 13 Jun 2026 09:57:16 +0800 Subject: [PATCH] Store calculation history on server with bazi input and chart snapshots. Co-authored-by: Cursor --- .env.example | 3 + .gitignore | 3 + Dockerfile | 4 +- app/api/history/[id]/route.ts | 21 ++++++ app/api/history/route.ts | 49 ++++++++++++ components/history/history-page.tsx | 83 ++++++++++++++++++--- components/modes/bazi-form.tsx | 15 +++- components/modes/combined-form.tsx | 23 +++++- components/modes/liuyao-form.tsx | 8 +- docker-compose.yml | 3 + docs/DOCKER.md | 8 ++ lib/history/request-user.ts | 12 +++ lib/history/server-store.ts | 90 ++++++++++++++++++++++ lib/history/storage.ts | 112 +++++++++++++++++++--------- lib/history/types.ts | 24 +++++- middleware.ts | 13 +++- scripts/docker-deploy.sh | 2 + 17 files changed, 419 insertions(+), 54 deletions(-) create mode 100644 app/api/history/[id]/route.ts create mode 100644 app/api/history/route.ts create mode 100644 lib/history/request-user.ts create mode 100644 lib/history/server-store.ts diff --git a/.env.example b/.env.example index 330f984..0ffa33b 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,6 @@ AUTH_USERNAME= AUTH_PASSWORD= # 会话签名密钥,请使用 32 位以上随机字符串 AUTH_SESSION_SECRET= + +# 测算历史存储目录(Docker 默认 /app/data/history,本地 dev 可留空) +# HISTORY_DATA_DIR= diff --git a/.gitignore b/.gitignore index 8a60d49..7151cd2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ yarn-error.log* # local env files .env*.local +# server-side history data +/data/ + # pm2 logs /logs diff --git a/Dockerfile b/Dockerfile index 0df6609..b32741e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,9 @@ COPY --from=builder /app/public ./public # 卦辞 Markdown(运行时读取) COPY --from=builder /app/content ./content -RUN mkdir -p public && chown -R nextjs:nodejs /app +RUN mkdir -p /app/data/history \ + && chown -R nextjs:nodejs /app/data \ + && chown -R nextjs:nodejs /app USER nextjs EXPOSE 3130 diff --git a/app/api/history/[id]/route.ts b/app/api/history/[id]/route.ts new file mode 100644 index 0000000..c05c5b8 --- /dev/null +++ b/app/api/history/[id]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getHistoryUsername } from "@/lib/history/request-user"; +import { deleteHistoryEntry } from "@/lib/history/server-store"; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const username = await getHistoryUsername(); + if (!username) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const { id } = await params; + const removed = await deleteHistoryEntry(username, id); + if (!removed) { + return NextResponse.json({ error: "记录不存在" }, { status: 404 }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/history/route.ts b/app/api/history/route.ts new file mode 100644 index 0000000..81f78b2 --- /dev/null +++ b/app/api/history/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { getHistoryUsername } from "@/lib/history/request-user"; +import { + addHistoryEntry, + listHistoryEntries, +} from "@/lib/history/server-store"; +import type { CalcHistoryCreate } from "@/lib/history/types"; + +export async function GET() { + const username = await getHistoryUsername(); + if (!username) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const items = await listHistoryEntries(username); + return NextResponse.json({ items }); +} + +export async function POST(req: Request) { + const username = await getHistoryUsername(); + if (!username) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + let body: CalcHistoryCreate; + try { + body = (await req.json()) as CalcHistoryCreate; + } catch { + return NextResponse.json({ error: "请求格式错误" }, { status: 400 }); + } + + if (!body.mode || !body.title || !body.question || !body.completion) { + return NextResponse.json({ error: "缺少必填字段" }, { status: 400 }); + } + + const entry = await addHistoryEntry(username, { + mode: body.mode, + title: body.title, + question: body.question, + summary: body.summary ?? "", + completion: body.completion, + meta: body.meta ?? {}, + baziInput: body.baziInput, + baziChart: body.baziChart, + hexagram: body.hexagram, + }); + + return NextResponse.json({ entry }); +} diff --git a/components/history/history-page.tsx b/components/history/history-page.tsx index ee5816c..5711310 100644 --- a/components/history/history-page.tsx +++ b/components/history/history-page.tsx @@ -6,6 +6,7 @@ import { Download, Trash2 } from "lucide-react"; import PageShell from "@/components/page-shell"; import { Button } from "@/components/ui/button"; import { ZenCard } from "@/components/ui/zen-card"; +import BaziChartDisplay from "@/components/modes/bazi-chart"; import Markdown from "react-markdown"; import { buildHistoryMarkdown, @@ -18,21 +19,46 @@ import { MODE_LABELS, type CalcHistoryEntry } from "@/lib/history/types"; export default function HistoryPageClient() { const [items, setItems] = useState([]); const [activeId, setActiveId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); useEffect(() => { - const list = loadHistory(); - setItems(list); - setActiveId(list[0]?.id ?? null); + let cancelled = false; + (async () => { + try { + const list = await loadHistory(); + if (cancelled) { + return; + } + setItems(list); + setActiveId(list[0]?.id ?? null); + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; }, []); const active = items.find((e) => e.id === activeId) ?? null; - function handleDelete(id: string) { - deleteHistoryEntry(id); - const next = loadHistory(); - setItems(next); - if (activeId === id) { - setActiveId(next[0]?.id ?? null); + async function handleDelete(id: string) { + try { + await deleteHistoryEntry(id); + const next = items.filter((item) => item.id !== id); + setItems(next); + if (activeId === id) { + setActiveId(next[0]?.id ?? null); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); } } @@ -41,11 +67,17 @@ export default function HistoryPageClient() {

测算历史

- 本机浏览器保存,清除缓存后可能丢失 + 保存在服务器,按登录账号区分

- {items.length === 0 ? ( + {loading ? ( + + 加载中… + + ) : error ? ( + {error} + ) : items.length === 0 ? (

暂无测算记录

@@ -70,6 +102,14 @@ export default function HistoryPageClient() {

{item.question}

+ {item.baziChart && ( +

+ {item.baziChart.pillars.year.ganZhi}{" "} + {item.baziChart.pillars.month.ganZhi}{" "} + {item.baziChart.pillars.day.ganZhi}{" "} + {item.baziChart.pillars.time.ganZhi} +

+ )}

{MODE_LABELS[item.mode]} ·{" "} {new Date(item.createdAt).toLocaleString("zh-CN")} @@ -87,6 +127,27 @@ export default function HistoryPageClient() { {active.summary && (

{active.summary}

)} + {active.baziInput && ( +
+

+ 出生:{active.baziInput.birthPlaceName} ·{" "} + {active.baziInput.date}{" "} + {active.baziInput.unknownHour + ? "时辰不详" + : active.baziInput.time}{" "} + · {active.baziInput.gender === "male" ? "男" : "女"} +

+
+ )} + {active.baziChart && } + {active.hexagram && ( +
+

{active.hexagram.guaTitle}

+

+ {active.hexagram.guaResult} +

+
+ )}