Store calculation history on server with bazi input and chart snapshots.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {
|
||||
HISTORY_MAX_ITEMS,
|
||||
HISTORY_STORAGE_KEY,
|
||||
type CalcHistoryEntry,
|
||||
} from "@/lib/history/types";
|
||||
import type { CalcHistoryCreate, CalcHistoryEntry } from "@/lib/history/types";
|
||||
|
||||
export function loadHistory(): CalcHistoryEntry[] {
|
||||
if (typeof window === "undefined") {
|
||||
return [];
|
||||
}
|
||||
async function parseApiError(res: Response): Promise<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw) as CalcHistoryEntry[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
const data = (await res.json()) as { error?: string };
|
||||
return data.error ?? `请求失败 (${res.status})`;
|
||||
} catch {
|
||||
return [];
|
||||
return `请求失败 (${res.status})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHistoryEntry(
|
||||
entry: Omit<CalcHistoryEntry, "id" | "createdAt">,
|
||||
): CalcHistoryEntry {
|
||||
const full: CalcHistoryEntry = {
|
||||
...entry,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const list = [full, ...loadHistory()].slice(0, HISTORY_MAX_ITEMS);
|
||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(list));
|
||||
return full;
|
||||
export async function loadHistory(): Promise<CalcHistoryEntry[]> {
|
||||
const res = await fetch("/api/history", { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseApiError(res));
|
||||
}
|
||||
const data = (await res.json()) as { items: CalcHistoryEntry[] };
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export function deleteHistoryEntry(id: string): void {
|
||||
const list = loadHistory().filter((e) => e.id !== id);
|
||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(list));
|
||||
export async function saveHistoryEntry(
|
||||
entry: CalcHistoryCreate,
|
||||
): 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) {
|
||||
@@ -48,6 +54,33 @@ export function downloadMarkdown(content: string, filename: string) {
|
||||
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 {
|
||||
const lines = [
|
||||
`# ${entry.title}`,
|
||||
@@ -56,11 +89,20 @@ export function buildHistoryMarkdown(entry: CalcHistoryEntry): string {
|
||||
`- 时间:${new Date(entry.createdAt).toLocaleString("zh-CN")}`,
|
||||
`- 问事:${entry.question}`,
|
||||
"",
|
||||
...Object.entries(entry.meta).map(([k, v]) => `- ${k}:${v}`),
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
entry.completion,
|
||||
...formatBaziInputLines(entry),
|
||||
...formatBaziChartLines(entry),
|
||||
...Object.entries(entry.meta)
|
||||
.filter(([key]) => !["出生地域", "阳历生日", "农历", "性别"].includes(key))
|
||||
.map(([k, v]) => `- ${k}:${v}`),
|
||||
];
|
||||
|
||||
if (entry.hexagram) {
|
||||
lines.push(
|
||||
`- 卦象:${entry.hexagram.guaTitle}`,
|
||||
`- 卦辞:${entry.hexagram.guaResult}`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("", "---", "", entry.completion);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
+23
-1
@@ -1,5 +1,23 @@
|
||||
import type { BaziChart } from "@/lib/calc/bazi";
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
mode: CalcMode;
|
||||
@@ -8,10 +26,14 @@ export interface CalcHistoryEntry {
|
||||
summary: string;
|
||||
completion: string;
|
||||
meta: Record<string, string>;
|
||||
baziInput?: BaziHistoryInput;
|
||||
baziChart?: BaziChart;
|
||||
hexagram?: HexagramHistoryInput;
|
||||
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 MODE_LABELS: Record<CalcMode, string> = {
|
||||
|
||||
Reference in New Issue
Block a user