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_URL=
|
||||
# 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 />;
|
||||
}
|
||||
+6
-3
@@ -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}
|
||||
<PwaDisplayMode />
|
||||
<PwaProvider />
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<PwaDisplayMode />
|
||||
<PwaProvider />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<Umami />
|
||||
</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 { 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() {
|
||||
融合周易智慧与人工智能
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{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>
|
||||
<HomeModules />
|
||||
</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";
|
||||
|
||||
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 (
|
||||
<Link
|
||||
href={href}
|
||||
href={target}
|
||||
className={`whitespace-nowrap text-sm transition-colors ${
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
@@ -34,6 +48,8 @@ function NavLink({ href, label }: { href: string; label: string }) {
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const { authEnabled, loggedIn, username, logout, loading } = useAuth();
|
||||
|
||||
return (
|
||||
<header className="relative z-10 border-b border-border/40 bg-card/70 py-3 shadow-sm backdrop-blur-md">
|
||||
<div
|
||||
@@ -43,13 +59,54 @@ export default function Header() {
|
||||
<TaijiIcon />
|
||||
<span className="font-medium tracking-wide">知命阁</span>
|
||||
</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 />
|
||||
{!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>
|
||||
<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) => (
|
||||
<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>
|
||||
</div>
|
||||
</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";
|
||||
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 (
|
||||
<ModeWorkspace
|
||||
aiPanel={
|
||||
@@ -108,6 +127,8 @@ export default function BaziForm() {
|
||||
onCompletion={handleAnalyze}
|
||||
error={error}
|
||||
emptyHint="排盘后点击「AI 测算」获取解读"
|
||||
downloadFilename={completion ? "生辰八字解读.md" : undefined}
|
||||
downloadPreamble={downloadPreamble}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<ModeWorkspace
|
||||
aiTitle="综合解读"
|
||||
@@ -162,6 +191,8 @@ export default function CombinedForm() {
|
||||
onCompletion={handleAnalyze}
|
||||
error={error}
|
||||
emptyHint="填写完整信息后,点击「综合测算」"
|
||||
downloadFilename={completion ? "综合测算解读.md" : undefined}
|
||||
downloadPreamble={downloadPreamble}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<ModeWorkspace
|
||||
aiPanel={
|
||||
@@ -122,6 +140,12 @@ export default function LiuyaoForm() {
|
||||
onCompletion={handleAnalyze}
|
||||
error={error}
|
||||
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 { 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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -93,10 +108,18 @@ function ResultAI({
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (completion || error) && (
|
||||
<Button onClick={onCompletion} size="sm" variant="outline" className="mt-4">
|
||||
<RotateCw size={16} className="mr-1" />
|
||||
重新生成
|
||||
</Button>
|
||||
<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" />
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: "综合测算",
|
||||
};
|
||||
@@ -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