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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-13 09:39:38 +08:00
parent abf78cbbb5
commit 462bec2739
23 changed files with 878 additions and 74 deletions
+80
View File
@@ -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;
}
+76
View File
@@ -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
View File
@@ -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>
+124
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+22 -1
View File
@@ -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}
/>
}
>
+32 -1
View File
@@ -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}
/>
}
>
+25 -1
View File
@@ -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}
/>
}
>
+28 -5
View File
@@ -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>