diff --git a/app/api/history/[id]/route.ts b/app/api/history/[id]/route.ts index c05c5b8..81ff9e0 100644 --- a/app/api/history/[id]/route.ts +++ b/app/api/history/[id]/route.ts @@ -1,21 +1,32 @@ import { NextResponse } from "next/server"; import { getHistoryUsername } from "@/lib/history/request-user"; -import { deleteHistoryEntry } from "@/lib/history/server-store"; +import { + deleteHistoryEntry, + historyStoreErrorMessage, +} 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 }); - } + try { + 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 }); - } + const { id } = await params; + const removed = await deleteHistoryEntry(username, id); + if (!removed) { + return NextResponse.json({ error: "记录不存在" }, { status: 404 }); + } - return NextResponse.json({ ok: true }); + return NextResponse.json({ ok: true }); + } catch (err) { + console.error("[history DELETE]", err); + return NextResponse.json( + { error: historyStoreErrorMessage(err) }, + { status: 500 }, + ); + } } diff --git a/app/api/history/route.ts b/app/api/history/route.ts index 81f78b2..2742e4e 100644 --- a/app/api/history/route.ts +++ b/app/api/history/route.ts @@ -2,48 +2,65 @@ import { NextResponse } from "next/server"; import { getHistoryUsername } from "@/lib/history/request-user"; import { addHistoryEntry, + historyStoreErrorMessage, 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 }); - } + try { + const username = await getHistoryUsername(); + if (!username) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } - const items = await listHistoryEntries(username); - return NextResponse.json({ items }); + const items = await listHistoryEntries(username); + return NextResponse.json({ items }); + } catch (err) { + console.error("[history GET]", err); + return NextResponse.json( + { error: historyStoreErrorMessage(err) }, + { status: 500 }, + ); + } } 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 }); + 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 }); + } catch (err) { + console.error("[history POST]", err); + return NextResponse.json( + { error: historyStoreErrorMessage(err) }, + { status: 500 }, + ); } - - 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/modes/bazi-form.tsx b/components/modes/bazi-form.tsx index 1359571..8976665 100644 --- a/components/modes/bazi-form.tsx +++ b/components/modes/bazi-form.tsx @@ -16,7 +16,7 @@ import RegionSelect, { } from "@/components/shared/region-select"; import { calculateBazi, type BaziChart } from "@/lib/calc/bazi"; import { streamAiCompletion } from "@/lib/ai/client-stream"; -import { saveHistoryEntry } from "@/lib/history/storage"; +import { saveHistoryEntrySafe } from "@/lib/history/storage"; export default function BaziForm() { const [date, setDate] = useState(todaySolarYmd()); @@ -29,6 +29,7 @@ export default function BaziForm() { const [chart, setChart] = useState(null); const [completion, setCompletion] = useState(""); const [error, setError] = useState(""); + const [warning, setWarning] = useState(""); const [isLoading, setIsLoading] = useState(false); const location = useRegionLocation(provinceCode, cityCode); @@ -77,6 +78,7 @@ export default function BaziForm() { } setError(""); + setWarning(""); setCompletion(""); setIsLoading(true); @@ -92,7 +94,7 @@ export default function BaziForm() { }, setCompletion, ); - await saveHistoryEntry({ + const saveResult = await saveHistoryEntrySafe({ mode: "bazi", title: "生辰八字解读", question, @@ -115,6 +117,9 @@ export default function BaziForm() { 四柱: `${activeChart.pillars.year.ganZhi} ${activeChart.pillars.month.ganZhi} ${activeChart.pillars.day.ganZhi} ${activeChart.pillars.time.ganZhi}`, }, }); + if (!saveResult.ok) { + setWarning(saveResult.error); + } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -137,6 +142,7 @@ export default function BaziForm() { isLoading={isLoading} onCompletion={handleAnalyze} error={error} + warning={warning} emptyHint="排盘后点击「AI 测算」获取解读" downloadFilename={completion ? "生辰八字解读.md" : undefined} downloadPreamble={downloadPreamble} diff --git a/components/modes/combined-form.tsx b/components/modes/combined-form.tsx index 6fbae7f..e13abe3 100644 --- a/components/modes/combined-form.tsx +++ b/components/modes/combined-form.tsx @@ -23,7 +23,7 @@ import RegionSelect, { import { calculateBazi, type BaziChart } from "@/lib/calc/bazi"; import type { GuaResult } from "@/lib/calc/hexagram"; import { streamAiCompletion } from "@/lib/ai/client-stream"; -import { saveHistoryEntry } from "@/lib/history/storage"; +import { saveHistoryEntrySafe } from "@/lib/history/storage"; export default function CombinedForm() { const [birthDate, setBirthDate] = useState(todaySolarYmd()); @@ -45,6 +45,7 @@ export default function CombinedForm() { const [chart, setChart] = useState(null); const [completion, setCompletion] = useState(""); const [error, setError] = useState(""); + const [warning, setWarning] = useState(""); const [isLoading, setIsLoading] = useState(false); const birthLocation = useRegionLocation(birthProvince, birthCity); @@ -112,6 +113,7 @@ export default function CombinedForm() { } setError(""); + setWarning(""); setCompletion(""); setIsLoading(true); @@ -155,7 +157,7 @@ export default function CombinedForm() { }, setCompletion, ); - await saveHistoryEntry({ + const saveResult = await saveHistoryEntrySafe({ mode: "combined", title: "综合测算解读", question, @@ -189,6 +191,9 @@ export default function CombinedForm() { 六爻: withHexagram && guaData ? guaData.result.guaTitle : "无", }, }); + if (!saveResult.ok) { + setWarning(saveResult.error); + } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -211,6 +216,7 @@ export default function CombinedForm() { isLoading={isLoading} onCompletion={handleAnalyze} error={error} + warning={warning} emptyHint="填写完整信息后,点击「综合测算」" downloadFilename={completion ? "综合测算解读.md" : undefined} downloadPreamble={downloadPreamble} diff --git a/components/modes/liuyao-form.tsx b/components/modes/liuyao-form.tsx index 1b58909..168f405 100644 --- a/components/modes/liuyao-form.tsx +++ b/components/modes/liuyao-form.tsx @@ -17,7 +17,7 @@ import RegionSelect, { useRegionLocation, } from "@/components/shared/region-select"; import { streamAiCompletion } from "@/lib/ai/client-stream"; -import { saveHistoryEntry } from "@/lib/history/storage"; +import { saveHistoryEntrySafe } from "@/lib/history/storage"; import type { GuaResult } from "@/lib/calc/hexagram"; import todayJson from "@/lib/data/today.json"; @@ -34,6 +34,7 @@ export default function LiuyaoForm() { const [guaData, setGuaData] = useState(null); const [completion, setCompletion] = useState(""); const [error, setError] = useState(""); + const [warning, setWarning] = useState(""); const [isLoading, setIsLoading] = useState(false); const location = useRegionLocation(provinceCode, cityCode); @@ -65,6 +66,7 @@ export default function LiuyaoForm() { } setError(""); + setWarning(""); setCompletion(""); setIsLoading(true); @@ -86,7 +88,7 @@ export default function LiuyaoForm() { }, setCompletion, ); - await saveHistoryEntry({ + const saveResult = await saveHistoryEntrySafe({ mode: "liuyao", title: guaData!.result.guaTitle, question, @@ -104,6 +106,9 @@ export default function LiuyaoForm() { 卦象: guaData!.result.guaTitle, }, }); + if (!saveResult.ok) { + setWarning(saveResult.error); + } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -122,6 +127,7 @@ export default function LiuyaoForm() { setGuaData(null); setCompletion(""); setError(""); + setWarning(""); setIsLoading(false); } @@ -129,6 +135,7 @@ export default function LiuyaoForm() { setGuaData(data); setCompletion(""); setError(""); + setWarning(""); } const downloadPreamble = @@ -145,6 +152,7 @@ export default function LiuyaoForm() { isLoading={isLoading} onCompletion={handleAnalyze} error={error} + warning={warning} emptyHint="完成起卦后,点击「AI 解读」" downloadFilename={ completion && guaData diff --git a/components/result-ai.tsx b/components/result-ai.tsx index 967df44..ad36407 100644 --- a/components/result-ai.tsx +++ b/components/result-ai.tsx @@ -11,6 +11,7 @@ function ResultAI({ isLoading, onCompletion, error, + warning, panel = false, emptyHint = "完成左侧填写并起卦/排盘后,点击「AI 解读」", downloadFilename, @@ -20,6 +21,7 @@ function ResultAI({ isLoading: boolean; onCompletion: () => void; error: string; + warning?: string; panel?: boolean; emptyHint?: string; downloadFilename?: string; @@ -94,9 +96,17 @@ function ResultAI({

{error}

) : completion ? ( - - {completion} - + <> + {warning && ( +
+

解读已完成,但历史未保存

+

{warning}

+
+ )} + + {completion} + + ) : isLoading ? (

正在等待 AI 响应...

) : ( diff --git a/lib/history/server-store.ts b/lib/history/server-store.ts index 6c38670..15388ae 100644 --- a/lib/history/server-store.ts +++ b/lib/history/server-store.ts @@ -15,6 +15,24 @@ function getDataDir(): string { return path.join(process.cwd(), "data", "history"); } +export function historyStoreErrorMessage(err: unknown): string { + if (!(err instanceof Error)) { + return "保存测算历史失败"; + } + const code = (err as NodeJS.ErrnoException).code; + const dir = getDataDir(); + if (code === "EACCES" || code === "EPERM") { + return `测算历史目录无写入权限(${dir}),请检查 HISTORY_DATA_DIR 或目录权限`; + } + if (code === "ENOENT") { + return `测算历史目录不存在(${dir}),请创建目录或配置 HISTORY_DATA_DIR`; + } + if (code === "ENOSPC") { + return "磁盘空间不足,无法保存测算历史"; + } + return `保存测算历史失败:${err.message}`; +} + function sanitizeUsername(username: string): string { const safe = username.replace(/[^a-zA-Z0-9_-]/g, "_"); return safe || "guest"; diff --git a/lib/history/storage.ts b/lib/history/storage.ts index 74beebc..148cc6c 100644 --- a/lib/history/storage.ts +++ b/lib/history/storage.ts @@ -34,6 +34,22 @@ export async function saveHistoryEntry( return data.entry; } +export async function saveHistoryEntrySafe( + entry: CalcHistoryCreate, +): Promise< + { ok: true; entry: CalcHistoryEntry } | { ok: false; error: string } +> { + try { + const saved = await saveHistoryEntry(entry); + return { ok: true, entry: saved }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + export async function deleteHistoryEntry(id: string): Promise { const res = await fetch(`/api/history/${encodeURIComponent(id)}`, { method: "DELETE",