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