Add login gate, calculation history, and AI markdown download.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,3 +24,9 @@ NODE_ENV=production
|
|||||||
# UMAMI_ID=
|
# UMAMI_ID=
|
||||||
# UMAMI_URL=
|
# UMAMI_URL=
|
||||||
# UMAMI_DOMAINS=
|
# UMAMI_DOMAINS=
|
||||||
|
|
||||||
|
# 登录认证(必填,用于六爻/八字/综合测算与 AI 解读)
|
||||||
|
AUTH_USERNAME=
|
||||||
|
AUTH_PASSWORD=
|
||||||
|
# 会话签名密钥,请使用 32 位以上随机字符串
|
||||||
|
AUTH_SESSION_SECRET=
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import HistoryPageClient from "@/components/history/history-page";
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
return <HistoryPageClient />;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import React from "react";
|
|||||||
import Umami from "@/components/umami";
|
import Umami from "@/components/umami";
|
||||||
import PwaProvider from "@/components/pwa/pwa-provider";
|
import PwaProvider from "@/components/pwa/pwa-provider";
|
||||||
import PwaDisplayMode from "@/components/pwa/pwa-display-mode";
|
import PwaDisplayMode from "@/components/pwa/pwa-display-mode";
|
||||||
|
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -53,9 +54,11 @@ export default function RootLayout({
|
|||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
<PwaDisplayMode />
|
<PwaDisplayMode />
|
||||||
<PwaProvider />
|
<PwaProvider />
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<Umami />
|
<Umami />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<PageShell width="narrow" className="py-12">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide">登录知命阁</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
测算功能需登录后使用
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Suspense fallback={<p className="text-center text-sm">加载中…</p>}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-53
@@ -1,39 +1,6 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import PageShell from "@/components/page-shell";
|
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";
|
import { TaijiIcon } from "@/components/svg/taiji";
|
||||||
|
import HomeModules from "@/components/home/home-modules";
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
@@ -45,25 +12,7 @@ export default function Home() {
|
|||||||
融合周易智慧与人工智能
|
融合周易智慧与人工智能
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<HomeModules />
|
||||||
{MODULES.map(({ href, title, description, icon: Icon, accent }) => (
|
|
||||||
<Link key={href} href={href} className="group block">
|
|
||||||
<ZenCard
|
|
||||||
className={`h-full bg-gradient-to-br ${accent} transition-all duration-300 group-hover:-translate-y-0.5 group-hover:shadow-md`}
|
|
||||||
>
|
|
||||||
<div className="mb-3 flex items-center gap-3">
|
|
||||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-border/60 bg-background/80">
|
|
||||||
<Icon size={20} className="text-primary/80" />
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-medium tracking-wide">{title}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</ZenCard>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(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<string>();
|
||||||
|
|
||||||
|
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<form onSubmit={handleSubmit} className="mx-auto w-full max-w-sm space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">用户名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "登录中…" : "登录"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
登录后可使用六爻算卦、生辰八字、综合测算与测算历史
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
+66
-9
@@ -1,27 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { History, LogIn, LogOut } from "lucide-react";
|
||||||
import { TaijiIcon } from "@/components/svg/taiji";
|
import { TaijiIcon } from "@/components/svg/taiji";
|
||||||
import { ModeToggle } from "@/components/mode-toggle";
|
import { ModeToggle } from "@/components/mode-toggle";
|
||||||
import { SITE_WIDTH_INNER } from "@/components/layout/site-width";
|
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 = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/learn", label: "易经学习" },
|
{ href: "/learn", label: "易经学习", protected: false },
|
||||||
{ href: "/liuyao", label: "六爻算卦" },
|
{ href: "/liuyao", label: "六爻算卦", protected: true },
|
||||||
{ href: "/bazi", label: "生辰八字" },
|
{ href: "/bazi", label: "生辰八字", protected: true },
|
||||||
{ href: "/combined", label: "综合测算" },
|
{ 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 pathname = usePathname();
|
||||||
const active =
|
const active =
|
||||||
href === "/"
|
href === "/"
|
||||||
? pathname === "/"
|
? pathname === "/"
|
||||||
: pathname === href || pathname.startsWith(`${href}/`);
|
: pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
|
||||||
|
const target = needLogin ? `/login?next=${encodeURIComponent(href)}` : href;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={target}
|
||||||
className={`whitespace-nowrap text-sm transition-colors ${
|
className={`whitespace-nowrap text-sm transition-colors ${
|
||||||
active
|
active
|
||||||
? "font-medium text-foreground"
|
? "font-medium text-foreground"
|
||||||
@@ -34,6 +48,8 @@ function NavLink({ href, label }: { href: string; label: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
|
const { authEnabled, loggedIn, username, logout, loading } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="relative z-10 border-b border-border/40 bg-card/70 py-3 shadow-sm backdrop-blur-md">
|
<header className="relative z-10 border-b border-border/40 bg-card/70 py-3 shadow-sm backdrop-blur-md">
|
||||||
<div
|
<div
|
||||||
@@ -43,13 +59,54 @@ export default function Header() {
|
|||||||
<TaijiIcon />
|
<TaijiIcon />
|
||||||
<span className="font-medium tracking-wide">知命阁</span>
|
<span className="font-medium tracking-wide">知命阁</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="justify-self-end sm:order-last">
|
<div className="flex items-center justify-self-end gap-2 sm:order-last">
|
||||||
|
{!loading && authEnabled && loggedIn && (
|
||||||
|
<Link
|
||||||
|
href="/history"
|
||||||
|
className="hidden text-muted-foreground hover:text-foreground sm:inline-flex"
|
||||||
|
title="测算历史"
|
||||||
|
>
|
||||||
|
<History size={18} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
|
{!loading && authEnabled && (
|
||||||
|
loggedIn ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 gap-1 px-2 text-xs"
|
||||||
|
onClick={() => logout()}
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
<span className="hidden sm:inline">{username ?? "退出"}</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Link href="/login">
|
||||||
|
<Button variant="ghost" size="sm" className="h-9 gap-1 px-2 text-xs">
|
||||||
|
<LogIn size={16} />
|
||||||
|
<span className="hidden sm:inline">登录</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<nav className="col-span-2 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 sm:order-none sm:col-span-1 sm:flex-1">
|
<nav className="col-span-2 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 sm:order-none sm:col-span-1 sm:flex-1">
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<NavLink key={item.href} {...item} />
|
<NavLink
|
||||||
|
key={item.href}
|
||||||
|
{...item}
|
||||||
|
needLogin={item.protected && authEnabled && !loggedIn}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
{!loading && authEnabled && loggedIn && (
|
||||||
|
<Link
|
||||||
|
href="/history"
|
||||||
|
className="whitespace-nowrap text-sm text-muted-foreground hover:text-foreground sm:hidden"
|
||||||
|
>
|
||||||
|
测算历史
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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<CalcHistoryEntry[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(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 (
|
||||||
|
<PageShell className="py-8">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide">测算历史</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
本机浏览器保存,清除缓存后可能丢失
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<ZenCard className="text-center text-sm text-muted-foreground">
|
||||||
|
<p>暂无测算记录</p>
|
||||||
|
<Link href="/liuyao" className="mt-3 inline-block text-primary underline">
|
||||||
|
去六爻算卦
|
||||||
|
</Link>
|
||||||
|
</ZenCard>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,280px)_1fr]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveId(item.id)}
|
||||||
|
className={`w-full rounded-xl border px-4 py-3 text-left text-sm transition ${
|
||||||
|
activeId === item.id
|
||||||
|
? "border-primary/40 bg-primary/5"
|
||||||
|
: "border-border/60 bg-card/80 hover:border-primary/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{item.title}</p>
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{item.question}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{MODE_LABELS[item.mode]} ·{" "}
|
||||||
|
{new Date(item.createdAt).toLocaleString("zh-CN")}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<ZenCard
|
||||||
|
title={active.title}
|
||||||
|
subtitle={`${MODE_LABELS[active.mode]} · ${new Date(active.createdAt).toLocaleString("zh-CN")}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">问事:{active.question}</p>
|
||||||
|
{active.summary && (
|
||||||
|
<p className="text-sm text-muted-foreground">{active.summary}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
downloadMarkdown(
|
||||||
|
buildHistoryMarkdown(active),
|
||||||
|
`${active.title}.md`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download size={14} className="mr-1" />
|
||||||
|
下载 Markdown
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleDelete(active.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} className="mr-1" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto rounded-md border bg-background/50 p-4">
|
||||||
|
<Markdown className="prose max-w-none text-sm dark:prose-invert">
|
||||||
|
{active.completion}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
</ZenCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{MODULES.map(({ href, title, description, icon: Icon, accent, protected: locked }) => {
|
||||||
|
const needLogin = locked && authEnabled && !loggedIn;
|
||||||
|
const targetHref = needLogin ? `/login?next=${encodeURIComponent(href)}` : href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={href} href={targetHref} className="group block">
|
||||||
|
<ZenCard
|
||||||
|
className={`relative h-full bg-gradient-to-br ${accent} transition-all duration-300 group-hover:-translate-y-0.5 group-hover:shadow-md ${needLogin ? "opacity-90" : ""}`}
|
||||||
|
>
|
||||||
|
{needLogin && (
|
||||||
|
<span className="absolute right-3 top-3 text-muted-foreground">
|
||||||
|
<Lock size={16} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="mb-3 flex items-center gap-3">
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-border/60 bg-background/80">
|
||||||
|
<Icon size={20} className="text-primary/80" />
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-medium tracking-wide">{title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
{needLogin && "(登录后可用)"}
|
||||||
|
</p>
|
||||||
|
</ZenCard>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import RegionSelect, {
|
|||||||
} from "@/components/shared/region-select";
|
} from "@/components/shared/region-select";
|
||||||
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
||||||
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
||||||
|
import { saveHistoryEntry } from "@/lib/history/storage";
|
||||||
|
|
||||||
export default function BaziForm() {
|
export default function BaziForm() {
|
||||||
const [date, setDate] = useState(todaySolarYmd());
|
const [date, setDate] = useState(todaySolarYmd());
|
||||||
@@ -80,7 +81,7 @@ export default function BaziForm() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await streamAiCompletion(
|
const text = await streamAiCompletion(
|
||||||
{
|
{
|
||||||
mode: "bazi",
|
mode: "bazi",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -91,6 +92,18 @@ export default function BaziForm() {
|
|||||||
},
|
},
|
||||||
setCompletion,
|
setCompletion,
|
||||||
);
|
);
|
||||||
|
saveHistoryEntry({
|
||||||
|
mode: "bazi",
|
||||||
|
title: "生辰八字解读",
|
||||||
|
question,
|
||||||
|
summary: activeChart.lunarDate,
|
||||||
|
completion: text,
|
||||||
|
meta: {
|
||||||
|
出生地域: location!.name,
|
||||||
|
阳历生日: `${input.date} ${input.time}`,
|
||||||
|
农历: activeChart.lunarDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<ModeWorkspace
|
<ModeWorkspace
|
||||||
aiPanel={
|
aiPanel={
|
||||||
@@ -108,6 +127,8 @@ export default function BaziForm() {
|
|||||||
onCompletion={handleAnalyze}
|
onCompletion={handleAnalyze}
|
||||||
error={error}
|
error={error}
|
||||||
emptyHint="排盘后点击「AI 测算」获取解读"
|
emptyHint="排盘后点击「AI 测算」获取解读"
|
||||||
|
downloadFilename={completion ? "生辰八字解读.md" : undefined}
|
||||||
|
downloadPreamble={downloadPreamble}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import RegionSelect, {
|
|||||||
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
||||||
import type { GuaResult } from "@/lib/calc/hexagram";
|
import type { GuaResult } from "@/lib/calc/hexagram";
|
||||||
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
||||||
|
import { saveHistoryEntry } from "@/lib/history/storage";
|
||||||
|
|
||||||
export default function CombinedForm() {
|
export default function CombinedForm() {
|
||||||
const [birthDate, setBirthDate] = useState(todaySolarYmd());
|
const [birthDate, setBirthDate] = useState(todaySolarYmd());
|
||||||
@@ -114,8 +115,18 @@ export default function CombinedForm() {
|
|||||||
setCompletion("");
|
setCompletion("");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const activeChart =
|
||||||
|
chart ??
|
||||||
|
calculateBazi({
|
||||||
|
date: birthDate,
|
||||||
|
time: unknownHour ? "12:00" : birthTime,
|
||||||
|
gender,
|
||||||
|
longitude: birthLocation!.longitude,
|
||||||
|
unknownHour,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await streamAiCompletion(
|
const text = await streamAiCompletion(
|
||||||
{
|
{
|
||||||
mode: "combined",
|
mode: "combined",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -144,6 +155,19 @@ export default function CombinedForm() {
|
|||||||
},
|
},
|
||||||
setCompletion,
|
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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<ModeWorkspace
|
<ModeWorkspace
|
||||||
aiTitle="综合解读"
|
aiTitle="综合解读"
|
||||||
@@ -162,6 +191,8 @@ export default function CombinedForm() {
|
|||||||
onCompletion={handleAnalyze}
|
onCompletion={handleAnalyze}
|
||||||
error={error}
|
error={error}
|
||||||
emptyHint="填写完整信息后,点击「综合测算」"
|
emptyHint="填写完整信息后,点击「综合测算」"
|
||||||
|
downloadFilename={completion ? "综合测算解读.md" : undefined}
|
||||||
|
downloadPreamble={downloadPreamble}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import RegionSelect, {
|
|||||||
useRegionLocation,
|
useRegionLocation,
|
||||||
} from "@/components/shared/region-select";
|
} from "@/components/shared/region-select";
|
||||||
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
||||||
|
import { saveHistoryEntry } from "@/lib/history/storage";
|
||||||
import type { GuaResult } from "@/lib/calc/hexagram";
|
import type { GuaResult } from "@/lib/calc/hexagram";
|
||||||
import todayJson from "@/lib/data/today.json";
|
import todayJson from "@/lib/data/today.json";
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ export default function LiuyaoForm() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await streamAiCompletion(
|
const text = await streamAiCompletion(
|
||||||
{
|
{
|
||||||
mode: "liuyao",
|
mode: "liuyao",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -85,6 +86,18 @@ export default function LiuyaoForm() {
|
|||||||
},
|
},
|
||||||
setCompletion,
|
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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -112,6 +125,11 @@ export default function LiuyaoForm() {
|
|||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const downloadPreamble =
|
||||||
|
guaData && location
|
||||||
|
? `# 六爻算卦 AI 解读\n\n- 问事:${question}\n- 地域:${location.name}\n- 时间:${calcDate} ${calcTime}\n- 卦象:${guaData.result.guaTitle}\n`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModeWorkspace
|
<ModeWorkspace
|
||||||
aiPanel={
|
aiPanel={
|
||||||
@@ -122,6 +140,12 @@ export default function LiuyaoForm() {
|
|||||||
onCompletion={handleAnalyze}
|
onCompletion={handleAnalyze}
|
||||||
error={error}
|
error={error}
|
||||||
emptyHint="完成起卦后,点击「AI 解读」"
|
emptyHint="完成起卦后,点击「AI 解读」"
|
||||||
|
downloadFilename={
|
||||||
|
completion && guaData
|
||||||
|
? `六爻-${guaData.result.guaTitle.replace(/[/\\?%*:|"<>]/g, "-")}.md`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
downloadPreamble={downloadPreamble}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { RotateCw } from "lucide-react";
|
import { Download, RotateCw } from "lucide-react";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TaijiIcon } from "@/components/svg/taiji";
|
import { TaijiIcon } from "@/components/svg/taiji";
|
||||||
|
import { downloadMarkdown } from "@/lib/history/storage";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function ResultAI({
|
function ResultAI({
|
||||||
@@ -12,6 +13,8 @@ function ResultAI({
|
|||||||
error,
|
error,
|
||||||
panel = false,
|
panel = false,
|
||||||
emptyHint = "完成左侧填写并起卦/排盘后,点击「AI 解读」",
|
emptyHint = "完成左侧填写并起卦/排盘后,点击「AI 解读」",
|
||||||
|
downloadFilename,
|
||||||
|
downloadPreamble,
|
||||||
}: {
|
}: {
|
||||||
completion: string;
|
completion: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -19,6 +22,8 @@ function ResultAI({
|
|||||||
error: string;
|
error: string;
|
||||||
panel?: boolean;
|
panel?: boolean;
|
||||||
emptyHint?: string;
|
emptyHint?: string;
|
||||||
|
downloadFilename?: string;
|
||||||
|
downloadPreamble?: string;
|
||||||
}) {
|
}) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [autoScroll, setAutoScroll] = useState(false);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -93,10 +108,18 @@ function ResultAI({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && (completion || error) && (
|
{!isLoading && (completion || error) && (
|
||||||
<Button onClick={onCompletion} size="sm" variant="outline" className="mt-4">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{completion && downloadFilename && (
|
||||||
|
<Button size="sm" variant="outline" onClick={handleDownload}>
|
||||||
|
<Download size={16} className="mr-1" />
|
||||||
|
下载 Markdown
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onCompletion} size="sm" variant="outline">
|
||||||
<RotateCw size={16} className="mr-1" />
|
<RotateCw size={16} className="mr-1" />
|
||||||
重新生成
|
重新生成
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function parseApiError(text: string, status: number): string {
|
|||||||
export async function streamAiCompletion(
|
export async function streamAiCompletion(
|
||||||
body: AiRequestBody,
|
body: AiRequestBody,
|
||||||
onUpdate: (text: string) => void,
|
onUpdate: (text: string) => void,
|
||||||
): Promise<void> {
|
): Promise<string> {
|
||||||
const res = await fetch("/api/ai", {
|
const res = await fetch("/api/ai", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -55,4 +55,6 @@ export async function streamAiCompletion(
|
|||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
throw new Error("AI 返回内容为空,请检查模型配置或稍后重试");
|
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: "综合测算",
|
||||||
|
};
|
||||||
@@ -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"],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user