Add login gate, calculation history, and AI markdown download.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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: "综合测算",
|
||||
};
|
||||
Reference in New Issue
Block a user