Add login gate, calculation history, and AI markdown download.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-13 09:39:38 +08:00
parent abf78cbbb5
commit 462bec2739
23 changed files with 878 additions and 74 deletions
+3 -1
View File
@@ -20,7 +20,7 @@ function parseApiError(text: string, status: number): string {
export async function streamAiCompletion(
body: AiRequestBody,
onUpdate: (text: string) => void,
): Promise<void> {
): Promise<string> {
const res = await fetch("/api/ai", {
method: "POST",
cache: "no-store",
@@ -55,4 +55,6 @@ export async function streamAiCompletion(
if (!text.trim()) {
throw new Error("AI 返回内容为空,请检查模型配置或稍后重试");
}
return decodeHexByteEscapes(text);
}
+41
View File
@@ -0,0 +1,41 @@
export const SESSION_COOKIE = "zhimingge_session";
export const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7;
export function isAuthEnabled(): boolean {
return !!(
process.env.AUTH_USERNAME?.trim() &&
process.env.AUTH_PASSWORD?.trim() &&
process.env.AUTH_SESSION_SECRET?.trim()
);
}
export function getAuthUsername(): string {
const username = process.env.AUTH_USERNAME?.trim();
if (!username) {
throw new Error("未配置 AUTH_USERNAME");
}
return username;
}
export function getAuthPassword(): string {
const password = process.env.AUTH_PASSWORD?.trim();
if (!password) {
throw new Error("未配置 AUTH_PASSWORD");
}
return password;
}
export function getAuthSessionSecret(): string {
const secret = process.env.AUTH_SESSION_SECRET?.trim();
if (!secret) {
throw new Error("未配置 AUTH_SESSION_SECRET");
}
return secret;
}
export function verifyCredentials(username: string, password: string): boolean {
if (!isAuthEnabled()) {
return true;
}
return username === getAuthUsername() && password === getAuthPassword();
}
+66
View File
@@ -0,0 +1,66 @@
import {
getAuthSessionSecret,
isAuthEnabled,
SESSION_COOKIE,
SESSION_MAX_AGE_SEC,
} from "@/lib/auth/config";
const encoder = new TextEncoder();
async function hmacHex(payload: string, secret: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function createSessionToken(username: string): Promise<string> {
const exp = Date.now() + SESSION_MAX_AGE_SEC * 1000;
const payload = `${exp}:${username}`;
const sig = await hmacHex(payload, getAuthSessionSecret());
return `${payload}:${sig}`;
}
export async function verifySessionToken(token: string): Promise<boolean> {
if (!isAuthEnabled()) {
return true;
}
const parts = token.split(":");
if (parts.length < 3) {
return false;
}
const sig = parts.pop()!;
const username = parts.pop()!;
const exp = Number(parts.join(":"));
if (!Number.isFinite(exp) || exp < Date.now()) {
return false;
}
const payload = `${exp}:${username}`;
const expected = await hmacHex(payload, getAuthSessionSecret());
return sig === expected;
}
export async function getSessionUsername(
token: string | undefined,
): Promise<string | null> {
if (!isAuthEnabled()) {
return "guest";
}
if (!token || !(await verifySessionToken(token))) {
return null;
}
const parts = token.split(":");
if (parts.length < 3) {
return null;
}
return parts[parts.length - 2] ?? null;
}
export { SESSION_COOKIE, SESSION_MAX_AGE_SEC };
+66
View File
@@ -0,0 +1,66 @@
import {
HISTORY_MAX_ITEMS,
HISTORY_STORAGE_KEY,
type CalcHistoryEntry,
} from "@/lib/history/types";
export function loadHistory(): CalcHistoryEntry[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = localStorage.getItem(HISTORY_STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as CalcHistoryEntry[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
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 function deleteHistoryEntry(id: string): void {
const list = loadHistory().filter((e) => e.id !== id);
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(list));
}
export function downloadMarkdown(content: string, filename: string) {
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename.endsWith(".md") ? filename : `${filename}.md`;
a.click();
URL.revokeObjectURL(url);
}
export function buildHistoryMarkdown(entry: CalcHistoryEntry): string {
const lines = [
`# ${entry.title}`,
"",
`- 类型:${entry.mode}`,
`- 时间:${new Date(entry.createdAt).toLocaleString("zh-CN")}`,
`- 问事:${entry.question}`,
"",
...Object.entries(entry.meta).map(([k, v]) => `- ${k}${v}`),
"",
"---",
"",
entry.completion,
];
return lines.join("\n");
}
+21
View File
@@ -0,0 +1,21 @@
export type CalcMode = "liuyao" | "bazi" | "combined";
export interface CalcHistoryEntry {
id: string;
mode: CalcMode;
title: string;
question: string;
summary: string;
completion: string;
meta: Record<string, string>;
createdAt: string;
}
export const HISTORY_STORAGE_KEY = "zhimingge-calc-history";
export const HISTORY_MAX_ITEMS = 100;
export const MODE_LABELS: Record<CalcMode, string> = {
liuyao: "六爻算卦",
bazi: "生辰八字",
combined: "综合测算",
};