Store calculation history on server with bazi input and chart snapshots.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,3 +30,6 @@ AUTH_USERNAME=
|
|||||||
AUTH_PASSWORD=
|
AUTH_PASSWORD=
|
||||||
# 会话签名密钥,请使用 32 位以上随机字符串
|
# 会话签名密钥,请使用 32 位以上随机字符串
|
||||||
AUTH_SESSION_SECRET=
|
AUTH_SESSION_SECRET=
|
||||||
|
|
||||||
|
# 测算历史存储目录(Docker 默认 /app/data/history,本地 dev 可留空)
|
||||||
|
# HISTORY_DATA_DIR=
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
# server-side history data
|
||||||
|
/data/
|
||||||
|
|
||||||
# pm2 logs
|
# pm2 logs
|
||||||
/logs
|
/logs
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -31,7 +31,9 @@ COPY --from=builder /app/public ./public
|
|||||||
# 卦辞 Markdown(运行时读取)
|
# 卦辞 Markdown(运行时读取)
|
||||||
COPY --from=builder /app/content ./content
|
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
|
USER nextjs
|
||||||
EXPOSE 3130
|
EXPOSE 3130
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Download, Trash2 } from "lucide-react";
|
|||||||
import PageShell from "@/components/page-shell";
|
import PageShell from "@/components/page-shell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ZenCard } from "@/components/ui/zen-card";
|
import { ZenCard } from "@/components/ui/zen-card";
|
||||||
|
import BaziChartDisplay from "@/components/modes/bazi-chart";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import {
|
import {
|
||||||
buildHistoryMarkdown,
|
buildHistoryMarkdown,
|
||||||
@@ -18,21 +19,46 @@ import { MODE_LABELS, type CalcHistoryEntry } from "@/lib/history/types";
|
|||||||
export default function HistoryPageClient() {
|
export default function HistoryPageClient() {
|
||||||
const [items, setItems] = useState<CalcHistoryEntry[]>([]);
|
const [items, setItems] = useState<CalcHistoryEntry[]>([]);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const list = loadHistory();
|
let cancelled = false;
|
||||||
setItems(list);
|
(async () => {
|
||||||
setActiveId(list[0]?.id ?? null);
|
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;
|
const active = items.find((e) => e.id === activeId) ?? null;
|
||||||
|
|
||||||
function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
deleteHistoryEntry(id);
|
try {
|
||||||
const next = loadHistory();
|
await deleteHistoryEntry(id);
|
||||||
setItems(next);
|
const next = items.filter((item) => item.id !== id);
|
||||||
if (activeId === id) {
|
setItems(next);
|
||||||
setActiveId(next[0]?.id ?? null);
|
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() {
|
|||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
<h1 className="text-2xl font-bold tracking-wide">测算历史</h1>
|
<h1 className="text-2xl font-bold tracking-wide">测算历史</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
本机浏览器保存,清除缓存后可能丢失
|
保存在服务器,按登录账号区分
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{loading ? (
|
||||||
|
<ZenCard className="text-center text-sm text-muted-foreground">
|
||||||
|
加载中…
|
||||||
|
</ZenCard>
|
||||||
|
) : error ? (
|
||||||
|
<ZenCard className="text-center text-sm text-destructive">{error}</ZenCard>
|
||||||
|
) : items.length === 0 ? (
|
||||||
<ZenCard className="text-center text-sm text-muted-foreground">
|
<ZenCard className="text-center text-sm text-muted-foreground">
|
||||||
<p>暂无测算记录</p>
|
<p>暂无测算记录</p>
|
||||||
<Link href="/liuyao" className="mt-3 inline-block text-primary underline">
|
<Link href="/liuyao" className="mt-3 inline-block text-primary underline">
|
||||||
@@ -70,6 +102,14 @@ export default function HistoryPageClient() {
|
|||||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||||
{item.question}
|
{item.question}
|
||||||
</p>
|
</p>
|
||||||
|
{item.baziChart && (
|
||||||
|
<p className="mt-1 font-mono text-xs text-muted-foreground">
|
||||||
|
{item.baziChart.pillars.year.ganZhi}{" "}
|
||||||
|
{item.baziChart.pillars.month.ganZhi}{" "}
|
||||||
|
{item.baziChart.pillars.day.ganZhi}{" "}
|
||||||
|
{item.baziChart.pillars.time.ganZhi}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{MODE_LABELS[item.mode]} ·{" "}
|
{MODE_LABELS[item.mode]} ·{" "}
|
||||||
{new Date(item.createdAt).toLocaleString("zh-CN")}
|
{new Date(item.createdAt).toLocaleString("zh-CN")}
|
||||||
@@ -87,6 +127,27 @@ export default function HistoryPageClient() {
|
|||||||
{active.summary && (
|
{active.summary && (
|
||||||
<p className="text-sm text-muted-foreground">{active.summary}</p>
|
<p className="text-sm text-muted-foreground">{active.summary}</p>
|
||||||
)}
|
)}
|
||||||
|
{active.baziInput && (
|
||||||
|
<div className="rounded-md border bg-background/50 p-3 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
出生:{active.baziInput.birthPlaceName} ·{" "}
|
||||||
|
{active.baziInput.date}{" "}
|
||||||
|
{active.baziInput.unknownHour
|
||||||
|
? "时辰不详"
|
||||||
|
: active.baziInput.time}{" "}
|
||||||
|
· {active.baziInput.gender === "male" ? "男" : "女"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{active.baziChart && <BaziChartDisplay chart={active.baziChart} />}
|
||||||
|
{active.hexagram && (
|
||||||
|
<div className="rounded-md border bg-background/50 p-3 text-sm">
|
||||||
|
<p className="font-medium">{active.hexagram.guaTitle}</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{active.hexagram.guaResult}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -92,16 +92,27 @@ export default function BaziForm() {
|
|||||||
},
|
},
|
||||||
setCompletion,
|
setCompletion,
|
||||||
);
|
);
|
||||||
saveHistoryEntry({
|
await saveHistoryEntry({
|
||||||
mode: "bazi",
|
mode: "bazi",
|
||||||
title: "生辰八字解读",
|
title: "生辰八字解读",
|
||||||
question,
|
question,
|
||||||
summary: activeChart.lunarDate,
|
summary: activeChart.lunarDate,
|
||||||
completion: text,
|
completion: text,
|
||||||
|
baziInput: {
|
||||||
|
date: input.date,
|
||||||
|
time: input.time,
|
||||||
|
gender: input.gender,
|
||||||
|
longitude: input.longitude,
|
||||||
|
unknownHour: !!input.unknownHour,
|
||||||
|
birthPlaceName: location!.name,
|
||||||
|
},
|
||||||
|
baziChart: activeChart,
|
||||||
meta: {
|
meta: {
|
||||||
出生地域: location!.name,
|
出生地域: location!.name,
|
||||||
阳历生日: `${input.date} ${input.time}`,
|
阳历生日: `${input.date} ${unknownHour ? "时辰不详" : input.time}`,
|
||||||
农历: activeChart.lunarDate,
|
农历: activeChart.lunarDate,
|
||||||
|
性别: gender === "male" ? "男" : "女",
|
||||||
|
四柱: `${activeChart.pillars.year.ganZhi} ${activeChart.pillars.month.ganZhi} ${activeChart.pillars.day.ganZhi} ${activeChart.pillars.time.ganZhi}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -155,16 +155,37 @@ export default function CombinedForm() {
|
|||||||
},
|
},
|
||||||
setCompletion,
|
setCompletion,
|
||||||
);
|
);
|
||||||
saveHistoryEntry({
|
await saveHistoryEntry({
|
||||||
mode: "combined",
|
mode: "combined",
|
||||||
title: "综合测算解读",
|
title: "综合测算解读",
|
||||||
question,
|
question,
|
||||||
summary: activeChart.lunarDate,
|
summary: activeChart.lunarDate,
|
||||||
completion: text,
|
completion: text,
|
||||||
|
baziInput: {
|
||||||
|
date: birthDate,
|
||||||
|
time: unknownHour ? "12:00" : birthTime,
|
||||||
|
gender,
|
||||||
|
longitude: birthLocation!.longitude,
|
||||||
|
unknownHour,
|
||||||
|
birthPlaceName: birthLocation!.name,
|
||||||
|
},
|
||||||
|
baziChart: activeChart,
|
||||||
|
hexagram:
|
||||||
|
withHexagram && guaData
|
||||||
|
? {
|
||||||
|
guaMark: guaData.result.guaMark,
|
||||||
|
guaTitle: guaData.result.guaTitle,
|
||||||
|
guaResult: guaData.result.guaResult,
|
||||||
|
guaChange: guaData.result.guaChange,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
meta: {
|
meta: {
|
||||||
出生地域: birthLocation!.name,
|
出生地域: birthLocation!.name,
|
||||||
当前地域: currentLocation!.name,
|
当前地域: currentLocation!.name,
|
||||||
测算时间: `${calcDate} ${calcTime}`,
|
测算时间: `${calcDate} ${calcTime}`,
|
||||||
|
农历: activeChart.lunarDate,
|
||||||
|
性别: gender === "male" ? "男" : "女",
|
||||||
|
四柱: `${activeChart.pillars.year.ganZhi} ${activeChart.pillars.month.ganZhi} ${activeChart.pillars.day.ganZhi} ${activeChart.pillars.time.ganZhi}`,
|
||||||
六爻: withHexagram && guaData ? guaData.result.guaTitle : "无",
|
六爻: withHexagram && guaData ? guaData.result.guaTitle : "无",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,12 +86,18 @@ export default function LiuyaoForm() {
|
|||||||
},
|
},
|
||||||
setCompletion,
|
setCompletion,
|
||||||
);
|
);
|
||||||
saveHistoryEntry({
|
await saveHistoryEntry({
|
||||||
mode: "liuyao",
|
mode: "liuyao",
|
||||||
title: guaData!.result.guaTitle,
|
title: guaData!.result.guaTitle,
|
||||||
question,
|
question,
|
||||||
summary: guaData!.result.guaResult,
|
summary: guaData!.result.guaResult,
|
||||||
completion: text,
|
completion: text,
|
||||||
|
hexagram: {
|
||||||
|
guaMark: guaData!.result.guaMark,
|
||||||
|
guaTitle: guaData!.result.guaTitle,
|
||||||
|
guaResult: guaData!.result.guaResult,
|
||||||
|
guaChange: guaData!.result.guaChange,
|
||||||
|
},
|
||||||
meta: {
|
meta: {
|
||||||
地域: location!.name,
|
地域: location!.name,
|
||||||
起卦时间: `${calcDate} ${calcTime}`,
|
起卦时间: `${calcDate} ${calcTime}`,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3130"
|
PORT: "3130"
|
||||||
HOSTNAME: "0.0.0.0"
|
HOSTNAME: "0.0.0.0"
|
||||||
|
HISTORY_DATA_DIR: /app/data/history
|
||||||
|
volumes:
|
||||||
|
- ./data/history:/app/data/history
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ cp .env.example .env.local
|
|||||||
nano .env.local
|
nano .env.local
|
||||||
chmod 600 .env.local
|
chmod 600 .env.local
|
||||||
|
|
||||||
|
# 测算历史持久化目录(挂载到容器)
|
||||||
|
mkdir -p data/history
|
||||||
|
chmod 755 data/history
|
||||||
|
|
||||||
# 构建并启动
|
# 构建并启动
|
||||||
docker compose build
|
docker compose build
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -109,10 +113,13 @@ pm2 restart zhimingge
|
|||||||
| `AUTH_USERNAME` | 否* | 登录用户名;与下面两项同时配置后启用登录 |
|
| `AUTH_USERNAME` | 否* | 登录用户名;与下面两项同时配置后启用登录 |
|
||||||
| `AUTH_PASSWORD` | 否* | 登录密码 |
|
| `AUTH_PASSWORD` | 否* | 登录密码 |
|
||||||
| `AUTH_SESSION_SECRET` | 否* | 会话签名密钥,建议 32 位以上随机字符串 |
|
| `AUTH_SESSION_SECRET` | 否* | 会话签名密钥,建议 32 位以上随机字符串 |
|
||||||
|
| `HISTORY_DATA_DIR` | 否 | 测算历史 JSON 存储目录;Docker 默认 `/app/data/history` |
|
||||||
| `PORT` | 否 | 容器内 3130 |
|
| `PORT` | 否 | 容器内 3130 |
|
||||||
|
|
||||||
\* 三项认证变量**同时**填写时,六爻/八字/综合测算与 AI 解读需登录;任一项留空则关闭登录限制。
|
\* 三项认证变量**同时**填写时,六爻/八字/综合测算与 AI 解读需登录;任一项留空则关闭登录限制。
|
||||||
|
|
||||||
|
测算历史保存在服务器 `data/history/`(Compose 挂载到容器),按登录用户名分文件;八字/综合测算会保存出生信息与四柱排盘。
|
||||||
|
|
||||||
验证容器内是否生效(示例):
|
验证容器内是否生效(示例):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -262,6 +269,7 @@ docker compose up -d --build
|
|||||||
| AI 失败 | 检查 `.env.local` 中 `OPENAI_API_KEY`;`docker exec zhimingge printenv OPENAI_API_KEY` |
|
| AI 失败 | 检查 `.env.local` 中 `OPENAI_API_KEY`;`docker exec zhimingge printenv OPENAI_API_KEY` |
|
||||||
| 页面 AI 空白、curl 本地正常 | Nginx/宝塔未关缓冲或未反代域名,见 [BAOTA.md](./BAOTA.md) |
|
| 页面 AI 空白、curl 本地正常 | Nginx/宝塔未关缓冲或未反代域名,见 [BAOTA.md](./BAOTA.md) |
|
||||||
| 卦辞 404 | 确认镜像内 `/app/content/zhouyi/docs` 存在 |
|
| 卦辞 404 | 确认镜像内 `/app/content/zhouyi/docs` 存在 |
|
||||||
|
| 测算历史保存失败 | 确认 `data/history` 存在且容器可写:`mkdir -p data/history && chmod 777 data/history`(或 `chown 1001:1001`) |
|
||||||
| 磁盘满 / 镜像过多 | `bash scripts/docker-prune.sh`,见 [镜像清理](#镜像清理) |
|
| 磁盘满 / 镜像过多 | `bash scripts/docker-prune.sh`,见 [镜像清理](#镜像清理) |
|
||||||
|
|
||||||
构建在镜像内完成,**无需**在宿主机单独 `npm install` / `npm run build`。
|
构建在镜像内完成,**无需**在宿主机单独 `npm install` / `npm run build`。
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { isAuthEnabled } from "@/lib/auth/config";
|
||||||
|
import { getSessionUsername, SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export async function getHistoryUsername(): Promise<string | null> {
|
||||||
|
if (!isAuthEnabled()) {
|
||||||
|
return "guest";
|
||||||
|
}
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||||
|
return getSessionUsername(token);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import {
|
||||||
|
HISTORY_MAX_ITEMS,
|
||||||
|
type CalcHistoryCreate,
|
||||||
|
type CalcHistoryEntry,
|
||||||
|
} from "@/lib/history/types";
|
||||||
|
|
||||||
|
function getDataDir(): string {
|
||||||
|
const configured = process.env.HISTORY_DATA_DIR?.trim();
|
||||||
|
if (configured) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return path.join(process.cwd(), "data", "history");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeUsername(username: string): string {
|
||||||
|
const safe = username.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
return safe || "guest";
|
||||||
|
}
|
||||||
|
|
||||||
|
function userFile(username: string): string {
|
||||||
|
return path.join(getDataDir(), `${sanitizeUsername(username)}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(): Promise<void> {
|
||||||
|
await fs.mkdir(getDataDir(), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUserHistory(username: string): Promise<CalcHistoryEntry[]> {
|
||||||
|
await ensureDir();
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(userFile(username), "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as CalcHistoryEntry[];
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeUserHistory(
|
||||||
|
username: string,
|
||||||
|
entries: CalcHistoryEntry[],
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureDir();
|
||||||
|
const file = userFile(username);
|
||||||
|
const tmp = `${file}.tmp`;
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(entries, null, 2), "utf-8");
|
||||||
|
await fs.rename(tmp, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listHistoryEntries(
|
||||||
|
username: string,
|
||||||
|
): Promise<CalcHistoryEntry[]> {
|
||||||
|
return readUserHistory(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addHistoryEntry(
|
||||||
|
username: string,
|
||||||
|
entry: CalcHistoryCreate,
|
||||||
|
): Promise<CalcHistoryEntry> {
|
||||||
|
const full: CalcHistoryEntry = {
|
||||||
|
...entry,
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const list = [full, ...(await readUserHistory(username))].slice(
|
||||||
|
0,
|
||||||
|
HISTORY_MAX_ITEMS,
|
||||||
|
);
|
||||||
|
await writeUserHistory(username, list);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHistoryEntry(
|
||||||
|
username: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const current = await readUserHistory(username);
|
||||||
|
const next = current.filter((entry) => entry.id !== id);
|
||||||
|
if (next.length === current.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await writeUserHistory(username, next);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
+77
-35
@@ -1,41 +1,47 @@
|
|||||||
import {
|
import type { CalcHistoryCreate, CalcHistoryEntry } from "@/lib/history/types";
|
||||||
HISTORY_MAX_ITEMS,
|
|
||||||
HISTORY_STORAGE_KEY,
|
|
||||||
type CalcHistoryEntry,
|
|
||||||
} from "@/lib/history/types";
|
|
||||||
|
|
||||||
export function loadHistory(): CalcHistoryEntry[] {
|
async function parseApiError(res: Response): Promise<string> {
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(HISTORY_STORAGE_KEY);
|
const data = (await res.json()) as { error?: string };
|
||||||
if (!raw) {
|
return data.error ?? `请求失败 (${res.status})`;
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const parsed = JSON.parse(raw) as CalcHistoryEntry[];
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return `请求失败 (${res.status})`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveHistoryEntry(
|
export async function loadHistory(): Promise<CalcHistoryEntry[]> {
|
||||||
entry: Omit<CalcHistoryEntry, "id" | "createdAt">,
|
const res = await fetch("/api/history", { cache: "no-store" });
|
||||||
): CalcHistoryEntry {
|
if (!res.ok) {
|
||||||
const full: CalcHistoryEntry = {
|
throw new Error(await parseApiError(res));
|
||||||
...entry,
|
}
|
||||||
id: crypto.randomUUID(),
|
const data = (await res.json()) as { items: CalcHistoryEntry[] };
|
||||||
createdAt: new Date().toISOString(),
|
return data.items ?? [];
|
||||||
};
|
|
||||||
const list = [full, ...loadHistory()].slice(0, HISTORY_MAX_ITEMS);
|
|
||||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(list));
|
|
||||||
return full;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteHistoryEntry(id: string): void {
|
export async function saveHistoryEntry(
|
||||||
const list = loadHistory().filter((e) => e.id !== id);
|
entry: CalcHistoryCreate,
|
||||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(list));
|
): Promise<CalcHistoryEntry> {
|
||||||
|
const res = await fetch("/api/history", {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(entry),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await parseApiError(res));
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { entry: CalcHistoryEntry };
|
||||||
|
return data.entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHistoryEntry(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api/history/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await parseApiError(res));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadMarkdown(content: string, filename: string) {
|
export function downloadMarkdown(content: string, filename: string) {
|
||||||
@@ -48,6 +54,33 @@ export function downloadMarkdown(content: string, filename: string) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBaziInputLines(entry: CalcHistoryEntry): string[] {
|
||||||
|
if (!entry.baziInput) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { baziInput } = entry;
|
||||||
|
const gender = baziInput.gender === "male" ? "男" : "女";
|
||||||
|
const hour = baziInput.unknownHour ? "时辰不详" : baziInput.time;
|
||||||
|
return [
|
||||||
|
`- 出生地域:${baziInput.birthPlaceName}`,
|
||||||
|
`- 阳历生日:${baziInput.date} ${hour}`,
|
||||||
|
`- 性别:${gender}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBaziChartLines(entry: CalcHistoryEntry): string[] {
|
||||||
|
if (!entry.baziChart) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { baziChart } = entry;
|
||||||
|
const { pillars } = baziChart;
|
||||||
|
return [
|
||||||
|
`- 农历:${baziChart.lunarDate}`,
|
||||||
|
`- 四柱:${pillars.year.ganZhi} ${pillars.month.ganZhi} ${pillars.day.ganZhi} ${pillars.time.ganZhi}`,
|
||||||
|
`- 真太阳时:${baziChart.trueSolarTime}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function buildHistoryMarkdown(entry: CalcHistoryEntry): string {
|
export function buildHistoryMarkdown(entry: CalcHistoryEntry): string {
|
||||||
const lines = [
|
const lines = [
|
||||||
`# ${entry.title}`,
|
`# ${entry.title}`,
|
||||||
@@ -56,11 +89,20 @@ export function buildHistoryMarkdown(entry: CalcHistoryEntry): string {
|
|||||||
`- 时间:${new Date(entry.createdAt).toLocaleString("zh-CN")}`,
|
`- 时间:${new Date(entry.createdAt).toLocaleString("zh-CN")}`,
|
||||||
`- 问事:${entry.question}`,
|
`- 问事:${entry.question}`,
|
||||||
"",
|
"",
|
||||||
...Object.entries(entry.meta).map(([k, v]) => `- ${k}:${v}`),
|
...formatBaziInputLines(entry),
|
||||||
"",
|
...formatBaziChartLines(entry),
|
||||||
"---",
|
...Object.entries(entry.meta)
|
||||||
"",
|
.filter(([key]) => !["出生地域", "阳历生日", "农历", "性别"].includes(key))
|
||||||
entry.completion,
|
.map(([k, v]) => `- ${k}:${v}`),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (entry.hexagram) {
|
||||||
|
lines.push(
|
||||||
|
`- 卦象:${entry.hexagram.guaTitle}`,
|
||||||
|
`- 卦辞:${entry.hexagram.guaResult}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("", "---", "", entry.completion);
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -1,5 +1,23 @@
|
|||||||
|
import type { BaziChart } from "@/lib/calc/bazi";
|
||||||
|
|
||||||
export type CalcMode = "liuyao" | "bazi" | "combined";
|
export type CalcMode = "liuyao" | "bazi" | "combined";
|
||||||
|
|
||||||
|
export interface BaziHistoryInput {
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
gender: "male" | "female";
|
||||||
|
longitude: number;
|
||||||
|
unknownHour: boolean;
|
||||||
|
birthPlaceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HexagramHistoryInput {
|
||||||
|
guaMark: string;
|
||||||
|
guaTitle: string;
|
||||||
|
guaResult: string;
|
||||||
|
guaChange: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CalcHistoryEntry {
|
export interface CalcHistoryEntry {
|
||||||
id: string;
|
id: string;
|
||||||
mode: CalcMode;
|
mode: CalcMode;
|
||||||
@@ -8,10 +26,14 @@ export interface CalcHistoryEntry {
|
|||||||
summary: string;
|
summary: string;
|
||||||
completion: string;
|
completion: string;
|
||||||
meta: Record<string, string>;
|
meta: Record<string, string>;
|
||||||
|
baziInput?: BaziHistoryInput;
|
||||||
|
baziChart?: BaziChart;
|
||||||
|
hexagram?: HexagramHistoryInput;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HISTORY_STORAGE_KEY = "zhimingge-calc-history";
|
export type CalcHistoryCreate = Omit<CalcHistoryEntry, "id" | "createdAt">;
|
||||||
|
|
||||||
export const HISTORY_MAX_ITEMS = 100;
|
export const HISTORY_MAX_ITEMS = 100;
|
||||||
|
|
||||||
export const MODE_LABELS: Record<CalcMode, string> = {
|
export const MODE_LABELS: Record<CalcMode, string> = {
|
||||||
|
|||||||
+11
-2
@@ -13,7 +13,8 @@ export async function middleware(request: NextRequest) {
|
|||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
const needsAuth =
|
const needsAuth =
|
||||||
PROTECTED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`)) ||
|
PROTECTED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`)) ||
|
||||||
pathname.startsWith("/api/ai");
|
pathname.startsWith("/api/ai") ||
|
||||||
|
pathname.startsWith("/api/history");
|
||||||
|
|
||||||
if (!needsAuth) {
|
if (!needsAuth) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
@@ -34,5 +35,13 @@ export async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/liuyao/:path*", "/bazi/:path*", "/combined/:path*", "/history/:path*", "/api/ai"],
|
matcher: [
|
||||||
|
"/liuyao/:path*",
|
||||||
|
"/bazi/:path*",
|
||||||
|
"/combined/:path*",
|
||||||
|
"/history/:path*",
|
||||||
|
"/api/ai",
|
||||||
|
"/api/history",
|
||||||
|
"/api/history/:path*",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ if [[ ! -f .env.local ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
mkdir -p data/history
|
||||||
|
|
||||||
echo "==> 当前 commit: $(git rev-parse --short HEAD)"
|
echo "==> 当前 commit: $(git rev-parse --short HEAD)"
|
||||||
echo "==> 拉取最新代码..."
|
echo "==> 拉取最新代码..."
|
||||||
git pull origin main
|
git pull origin main
|
||||||
|
|||||||
Reference in New Issue
Block a user