diff --git a/.env.example b/.env.example index 4e1f1f1..330f984 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,9 @@ NODE_ENV=production # UMAMI_ID= # UMAMI_URL= # UMAMI_DOMAINS= + +# 登录认证(必填,用于六爻/八字/综合测算与 AI 解读) +AUTH_USERNAME= +AUTH_PASSWORD= +# 会话签名密钥,请使用 32 位以上随机字符串 +AUTH_SESSION_SECRET= diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..5ede30f --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { + isAuthEnabled, + verifyCredentials, +} from "@/lib/auth/config"; +import { + createSessionToken, + SESSION_COOKIE, + SESSION_MAX_AGE_SEC, +} from "@/lib/auth/session"; + +export async function POST(req: Request) { + if (!isAuthEnabled()) { + return NextResponse.json({ ok: true, authEnabled: false }); + } + + let body: { username?: string; password?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "请求格式错误" }, { status: 400 }); + } + + const username = body.username?.trim() ?? ""; + const password = body.password ?? ""; + + if (!verifyCredentials(username, password)) { + return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 }); + } + + const token = await createSessionToken(username); + const res = NextResponse.json({ ok: true, username }); + res.cookies.set(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: SESSION_MAX_AGE_SEC, + }); + return res; +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..2079302 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { SESSION_COOKIE } from "@/lib/auth/session"; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + res.cookies.set(SESSION_COOKIE, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); + return res; +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..52089c8 --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { isAuthEnabled } from "@/lib/auth/config"; +import { getSessionUsername, SESSION_COOKIE } from "@/lib/auth/session"; + +export async function GET() { + if (!isAuthEnabled()) { + return NextResponse.json({ authEnabled: false, loggedIn: true }); + } + + const token = (await cookies()).get(SESSION_COOKIE)?.value; + const username = await getSessionUsername(token); + + return NextResponse.json({ + authEnabled: true, + loggedIn: !!username, + username: username ?? undefined, + }); +} diff --git a/app/history/page.tsx b/app/history/page.tsx new file mode 100644 index 0000000..60640f7 --- /dev/null +++ b/app/history/page.tsx @@ -0,0 +1,5 @@ +import HistoryPageClient from "@/components/history/history-page"; + +export default function HistoryPage() { + return ; +} diff --git a/app/layout.tsx b/app/layout.tsx index fc47d96..35aae08 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import React from "react"; import Umami from "@/components/umami"; import PwaProvider from "@/components/pwa/pwa-provider"; import PwaDisplayMode from "@/components/pwa/pwa-display-mode"; +import { AuthProvider } from "@/components/auth/auth-provider"; import { ThemeProvider } from "next-themes"; export const metadata: Metadata = { @@ -53,9 +54,11 @@ export default function RootLayout({ defaultTheme="system" disableTransitionOnChange > - {children} - - + + {children} + + + diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..18fcdb8 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from "react"; +import PageShell from "@/components/page-shell"; +import LoginForm from "@/components/auth/login-form"; + +export default function LoginPage() { + return ( + +
+

登录知命阁

+

+ 测算功能需登录后使用 +

+
+ 加载中…

}> + +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 2a35f8f..3e0a1ee 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,39 +1,6 @@ -import Link from "next/link"; import PageShell from "@/components/page-shell"; -import { BookOpen, BrainCircuit, Compass, Sparkles } from "lucide-react"; -import { ZenCard } from "@/components/ui/zen-card"; import { TaijiIcon } from "@/components/svg/taiji"; - -const MODULES = [ - { - href: "/learn", - title: "易经学习", - description: "64 卦卡片择读,繁体精简与简体图文", - icon: BookOpen, - accent: "from-amber-500/10 to-transparent", - }, - { - href: "/liuyao", - title: "六爻算卦", - description: "三钱法起卦,卦辞 AI 智能解读", - icon: Sparkles, - accent: "from-stone-500/10 to-transparent", - }, - { - href: "/bazi", - title: "生辰八字", - description: "四柱排盘,十神大运,AI 命理解读", - icon: BrainCircuit, - accent: "from-zinc-500/10 to-transparent", - }, - { - href: "/combined", - title: "综合测算", - description: "天时地利人和,六爻可选", - icon: Compass, - accent: "from-neutral-500/10 to-transparent", - }, -]; +import HomeModules from "@/components/home/home-modules"; export default function Home() { return ( @@ -45,25 +12,7 @@ export default function Home() { 融合周易智慧与人工智能

-
- {MODULES.map(({ href, title, description, icon: Icon, accent }) => ( - - -
- - - - {title} -
-

- {description} -

-
- - ))} -
+ ); } diff --git a/components/auth/auth-provider.tsx b/components/auth/auth-provider.tsx new file mode 100644 index 0000000..bd3843d --- /dev/null +++ b/components/auth/auth-provider.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +interface AuthState { + loading: boolean; + authEnabled: boolean; + loggedIn: boolean; + username?: string; + refresh: () => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [loading, setLoading] = useState(true); + const [authEnabled, setAuthEnabled] = useState(false); + const [loggedIn, setLoggedIn] = useState(true); + const [username, setUsername] = useState(); + + const refresh = useCallback(async () => { + try { + const res = await fetch("/api/auth/me", { cache: "no-store" }); + const data = await res.json(); + setAuthEnabled(!!data.authEnabled); + setLoggedIn(!!data.loggedIn); + setUsername(data.username); + } catch { + setAuthEnabled(false); + setLoggedIn(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const logout = useCallback(async () => { + await fetch("/api/auth/logout", { method: "POST" }); + await refresh(); + window.location.href = "/"; + }, [refresh]); + + const value = useMemo( + () => ({ loading, authEnabled, loggedIn, username, refresh, logout }), + [loading, authEnabled, loggedIn, username, refresh, logout], + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within AuthProvider"); + } + return ctx; +} + +export function useRequireAuthForPath(path: string): boolean { + const { authEnabled, loggedIn } = useAuth(); + if (!authEnabled) { + return true; + } + const protectedPaths = ["/liuyao", "/bazi", "/combined", "/history"]; + if (!protectedPaths.some((p) => path === p || path.startsWith(`${p}/`))) { + return true; + } + return loggedIn; +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx new file mode 100644 index 0000000..e14787a --- /dev/null +++ b/components/auth/login-form.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/components/auth/auth-provider"; + +export default function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { refresh } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error ?? "登录失败"); + return; + } + await refresh(); + const next = searchParams.get("next") || "/liuyao"; + router.push(next); + router.refresh(); + } catch { + setError("网络错误,请稍后重试"); + } finally { + setLoading(false); + } + } + + return ( +
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + +

+ 登录后可使用六爻算卦、生辰八字、综合测算与测算历史 +

+
+ ); +} diff --git a/components/header.tsx b/components/header.tsx index 9654e1f..0880b2d 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -1,27 +1,41 @@ "use client"; + import Link from "next/link"; import { usePathname } from "next/navigation"; +import { History, LogIn, LogOut } from "lucide-react"; import { TaijiIcon } from "@/components/svg/taiji"; import { ModeToggle } from "@/components/mode-toggle"; import { SITE_WIDTH_INNER } from "@/components/layout/site-width"; +import { useAuth } from "@/components/auth/auth-provider"; +import { Button } from "@/components/ui/button"; const NAV_ITEMS = [ - { href: "/learn", label: "易经学习" }, - { href: "/liuyao", label: "六爻算卦" }, - { href: "/bazi", label: "生辰八字" }, - { href: "/combined", label: "综合测算" }, -]; + { href: "/learn", label: "易经学习", protected: false }, + { href: "/liuyao", label: "六爻算卦", protected: true }, + { href: "/bazi", label: "生辰八字", protected: true }, + { href: "/combined", label: "综合测算", protected: true }, +] as const; -function NavLink({ href, label }: { href: string; label: string }) { +function NavLink({ + href, + label, + needLogin, +}: { + href: string; + label: string; + needLogin: boolean; +}) { const pathname = usePathname(); const active = href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`); + const target = needLogin ? `/login?next=${encodeURIComponent(href)}` : href; + return (
知命阁 -
+
+ {!loading && authEnabled && loggedIn && ( + + + + )} + {!loading && authEnabled && ( + loggedIn ? ( + + ) : ( + + + + ) + )}
diff --git a/components/history/history-page.tsx b/components/history/history-page.tsx new file mode 100644 index 0000000..ee5816c --- /dev/null +++ b/components/history/history-page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Download, Trash2 } from "lucide-react"; +import PageShell from "@/components/page-shell"; +import { Button } from "@/components/ui/button"; +import { ZenCard } from "@/components/ui/zen-card"; +import Markdown from "react-markdown"; +import { + buildHistoryMarkdown, + deleteHistoryEntry, + downloadMarkdown, + loadHistory, +} from "@/lib/history/storage"; +import { MODE_LABELS, type CalcHistoryEntry } from "@/lib/history/types"; + +export default function HistoryPageClient() { + const [items, setItems] = useState([]); + const [activeId, setActiveId] = useState(null); + + useEffect(() => { + const list = loadHistory(); + setItems(list); + setActiveId(list[0]?.id ?? null); + }, []); + + const active = items.find((e) => e.id === activeId) ?? null; + + function handleDelete(id: string) { + deleteHistoryEntry(id); + const next = loadHistory(); + setItems(next); + if (activeId === id) { + setActiveId(next[0]?.id ?? null); + } + } + + return ( + +
+

测算历史

+

+ 本机浏览器保存,清除缓存后可能丢失 +

+
+ + {items.length === 0 ? ( + +

暂无测算记录

+ + 去六爻算卦 + +
+ ) : ( +
+
+ {items.map((item) => ( + + ))} +
+ + {active && ( + +

问事:{active.question}

+ {active.summary && ( +

{active.summary}

+ )} +
+ + +
+
+ + {active.completion} + +
+
+ )} +
+ )} +
+ ); +} diff --git a/components/home/home-modules.tsx b/components/home/home-modules.tsx new file mode 100644 index 0000000..d963bd2 --- /dev/null +++ b/components/home/home-modules.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Link from "next/link"; +import { BookOpen, BrainCircuit, Compass, Lock, Sparkles } from "lucide-react"; +import { ZenCard } from "@/components/ui/zen-card"; +import { useAuth } from "@/components/auth/auth-provider"; + +const MODULES = [ + { + href: "/learn", + title: "易经学习", + description: "64 卦卡片择读,繁体精简与简体图文", + icon: BookOpen, + accent: "from-amber-500/10 to-transparent", + protected: false, + }, + { + href: "/liuyao", + title: "六爻算卦", + description: "三钱法起卦,卦辞 AI 智能解读", + icon: Sparkles, + accent: "from-stone-500/10 to-transparent", + protected: true, + }, + { + href: "/bazi", + title: "生辰八字", + description: "四柱排盘,十神大运,AI 命理解读", + icon: BrainCircuit, + accent: "from-zinc-500/10 to-transparent", + protected: true, + }, + { + href: "/combined", + title: "综合测算", + description: "天时地利人和,六爻可选", + icon: Compass, + accent: "from-neutral-500/10 to-transparent", + protected: true, + }, +] as const; + +export default function HomeModules() { + const { authEnabled, loggedIn } = useAuth(); + + return ( +
+ {MODULES.map(({ href, title, description, icon: Icon, accent, protected: locked }) => { + const needLogin = locked && authEnabled && !loggedIn; + const targetHref = needLogin ? `/login?next=${encodeURIComponent(href)}` : href; + + return ( + + + {needLogin && ( + + + + )} +
+ + + + {title} +
+

+ {description} + {needLogin && "(登录后可用)"} +

+
+ + ); + })} +
+ ); +} diff --git a/components/modes/bazi-form.tsx b/components/modes/bazi-form.tsx index 861fe9f..cb58dfc 100644 --- a/components/modes/bazi-form.tsx +++ b/components/modes/bazi-form.tsx @@ -16,6 +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"; export default function BaziForm() { const [date, setDate] = useState(todaySolarYmd()); @@ -80,7 +81,7 @@ export default function BaziForm() { setIsLoading(true); try { - await streamAiCompletion( + const text = await streamAiCompletion( { mode: "bazi", payload: { @@ -91,6 +92,18 @@ export default function BaziForm() { }, setCompletion, ); + saveHistoryEntry({ + mode: "bazi", + title: "生辰八字解读", + question, + summary: activeChart.lunarDate, + completion: text, + meta: { + 出生地域: location!.name, + 阳历生日: `${input.date} ${input.time}`, + 农历: activeChart.lunarDate, + }, + }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -98,6 +111,12 @@ export default function BaziForm() { } } + const activeChartPreview = chart; + const downloadPreamble = + completion && location + ? `# 生辰八字 AI 解读\n\n- 问事:${question}\n- 出生地域:${location.name}\n- 阳历:${date} ${unknownHour ? "时辰不详" : time}\n${activeChartPreview ? `- 农历:${activeChartPreview.lunarDate}\n` : ""}` + : undefined; + return ( } > diff --git a/components/modes/combined-form.tsx b/components/modes/combined-form.tsx index 04e9e8d..814ed5a 100644 --- a/components/modes/combined-form.tsx +++ b/components/modes/combined-form.tsx @@ -23,6 +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"; export default function CombinedForm() { const [birthDate, setBirthDate] = useState(todaySolarYmd()); @@ -114,8 +115,18 @@ export default function CombinedForm() { setCompletion(""); setIsLoading(true); + const activeChart = + chart ?? + calculateBazi({ + date: birthDate, + time: unknownHour ? "12:00" : birthTime, + gender, + longitude: birthLocation!.longitude, + unknownHour, + }); + try { - await streamAiCompletion( + const text = await streamAiCompletion( { mode: "combined", payload: { @@ -144,6 +155,19 @@ export default function CombinedForm() { }, setCompletion, ); + saveHistoryEntry({ + mode: "combined", + title: "综合测算解读", + question, + summary: activeChart.lunarDate, + completion: text, + meta: { + 出生地域: birthLocation!.name, + 当前地域: currentLocation!.name, + 测算时间: `${calcDate} ${calcTime}`, + 六爻: withHexagram && guaData ? guaData.result.guaTitle : "无", + }, + }); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -151,6 +175,11 @@ export default function CombinedForm() { } } + const downloadPreamble = + completion && birthLocation && currentLocation + ? `# 综合测算 AI 解读\n\n- 问事:${question}\n- 出生地域:${birthLocation.name}\n- 当前地域:${currentLocation.name}\n- 测算时间:${calcDate} ${calcTime}\n${chart ? `- 农历:${chart.lunarDate}\n` : ""}${withHexagram && guaData ? `- 六爻:${guaData.result.guaTitle}\n` : ""}` + : undefined; + return ( } > diff --git a/components/modes/liuyao-form.tsx b/components/modes/liuyao-form.tsx index 1b65e3a..15cdbe8 100644 --- a/components/modes/liuyao-form.tsx +++ b/components/modes/liuyao-form.tsx @@ -17,6 +17,7 @@ import RegionSelect, { useRegionLocation, } from "@/components/shared/region-select"; import { streamAiCompletion } from "@/lib/ai/client-stream"; +import { saveHistoryEntry } from "@/lib/history/storage"; import type { GuaResult } from "@/lib/calc/hexagram"; import todayJson from "@/lib/data/today.json"; @@ -68,7 +69,7 @@ export default function LiuyaoForm() { setIsLoading(true); try { - await streamAiCompletion( + const text = await streamAiCompletion( { mode: "liuyao", payload: { @@ -85,6 +86,18 @@ export default function LiuyaoForm() { }, setCompletion, ); + saveHistoryEntry({ + mode: "liuyao", + title: guaData!.result.guaTitle, + question, + summary: guaData!.result.guaResult, + completion: text, + meta: { + 地域: location!.name, + 起卦时间: `${calcDate} ${calcTime}`, + 卦象: guaData!.result.guaTitle, + }, + }); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -112,6 +125,11 @@ export default function LiuyaoForm() { setError(""); } + const downloadPreamble = + guaData && location + ? `# 六爻算卦 AI 解读\n\n- 问事:${question}\n- 地域:${location.name}\n- 时间:${calcDate} ${calcTime}\n- 卦象:${guaData.result.guaTitle}\n` + : undefined; + return ( ]/g, "-")}.md` + : undefined + } + downloadPreamble={downloadPreamble} /> } > diff --git a/components/result-ai.tsx b/components/result-ai.tsx index 1a2a49a..967df44 100644 --- a/components/result-ai.tsx +++ b/components/result-ai.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useRef, useState } from "react"; -import { RotateCw } from "lucide-react"; +import { Download, RotateCw } from "lucide-react"; import Markdown from "react-markdown"; import { Button } from "@/components/ui/button"; import { TaijiIcon } from "@/components/svg/taiji"; +import { downloadMarkdown } from "@/lib/history/storage"; import { cn } from "@/lib/utils"; function ResultAI({ @@ -12,6 +13,8 @@ function ResultAI({ error, panel = false, emptyHint = "完成左侧填写并起卦/排盘后,点击「AI 解读」", + downloadFilename, + downloadPreamble, }: { completion: string; isLoading: boolean; @@ -19,6 +22,8 @@ function ResultAI({ error: string; panel?: boolean; emptyHint?: string; + downloadFilename?: string; + downloadPreamble?: string; }) { const scrollRef = useRef(null); const [autoScroll, setAutoScroll] = useState(false); @@ -44,6 +49,16 @@ function ResultAI({ } } + function handleDownload() { + if (!completion || !downloadFilename) { + return; + } + const body = downloadPreamble + ? `${downloadPreamble}\n\n---\n\n${completion}` + : completion; + downloadMarkdown(body, downloadFilename); + } + return (
)} {!isLoading && (completion || error) && ( - +
+ {completion && downloadFilename && ( + + )} + +
)}
diff --git a/lib/ai/client-stream.ts b/lib/ai/client-stream.ts index 11cc916..5fb1a46 100644 --- a/lib/ai/client-stream.ts +++ b/lib/ai/client-stream.ts @@ -20,7 +20,7 @@ function parseApiError(text: string, status: number): string { export async function streamAiCompletion( body: AiRequestBody, onUpdate: (text: string) => void, -): Promise { +): Promise { 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); } diff --git a/lib/auth/config.ts b/lib/auth/config.ts new file mode 100644 index 0000000..0f05b29 --- /dev/null +++ b/lib/auth/config.ts @@ -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(); +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts new file mode 100644 index 0000000..2626b3e --- /dev/null +++ b/lib/auth/session.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }; diff --git a/lib/history/storage.ts b/lib/history/storage.ts new file mode 100644 index 0000000..691a722 --- /dev/null +++ b/lib/history/storage.ts @@ -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 { + 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"); +} diff --git a/lib/history/types.ts b/lib/history/types.ts new file mode 100644 index 0000000..0afd123 --- /dev/null +++ b/lib/history/types.ts @@ -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; + createdAt: string; +} + +export const HISTORY_STORAGE_KEY = "zhimingge-calc-history"; +export const HISTORY_MAX_ITEMS = 100; + +export const MODE_LABELS: Record = { + liuyao: "六爻算卦", + bazi: "生辰八字", + combined: "综合测算", +}; diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..b5ba353 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { isAuthEnabled } from "@/lib/auth/config"; +import { SESSION_COOKIE, verifySessionToken } from "@/lib/auth/session"; + +const PROTECTED_PREFIXES = ["/liuyao", "/bazi", "/combined", "/history"]; + +export async function middleware(request: NextRequest) { + if (!isAuthEnabled()) { + return NextResponse.next(); + } + + const { pathname } = request.nextUrl; + const needsAuth = + PROTECTED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`)) || + pathname.startsWith("/api/ai"); + + if (!needsAuth) { + return NextResponse.next(); + } + + const token = request.cookies.get(SESSION_COOKIE)?.value; + if (token && (await verifySessionToken(token))) { + return NextResponse.next(); + } + + if (pathname.startsWith("/api/")) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("next", pathname); + return NextResponse.redirect(loginUrl); +} + +export const config = { + matcher: ["/liuyao/:path*", "/bazi/:path*", "/combined/:path*", "/history/:path*", "/api/ai"], +};