diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2dbf8c3 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# ── 数据库(SQLite 本地路径)── +DATABASE_URL="file:./prisma/dev.db" + +# ── 认证 ── +# 会话签名密钥,请改为随机字符串(生产环境务必修改) +SESSION_SECRET="change-me-to-a-random-string" +# 后台登录密码 +ADMIN_PASSWORD="asui2026" + +# ── AI 写作助手(可选)── +# 不配置 AI_API_KEY 时,AI 功能不可用,但博客其他功能正常 +AI_BASE_URL="https://api.openai.com/v1" +AI_API_KEY="" +AI_MODEL="gpt-4o-mini" + +# ── 站点 URL(用于 SEO metadata、OG 标签等)── +NEXT_PUBLIC_SITE_URL="http://localhost:3000" diff --git a/.gitignore b/.gitignore index 5a63031..dcd688d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ prisma/dev.db-shm # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/package.json b/package.json index 7af279d..7c178c8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,9 @@ "tw-animate-css": "^1.4.0", "zod": "^4.4.3" }, + "optionalDependencies": { + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecda145..60d5d0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,10 @@ importers: zod: specifier: ^4.4.3 version: 4.4.3 + optionalDependencies: + '@tailwindcss/oxide-win32-x64-msvc': + specifier: 4.3.1 + version: 4.3.1 devDependencies: '@tailwindcss/postcss': specifier: ^4 diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 3dc4bd4..a7de3fe 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -135,7 +135,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) {/* Main */}
-
+
{children}
diff --git a/src/components/admin/AiAssistant.tsx b/src/components/admin/AiAssistant.tsx index 3f071b5..a140f85 100644 --- a/src/components/admin/AiAssistant.tsx +++ b/src/components/admin/AiAssistant.tsx @@ -11,36 +11,35 @@ import { Languages, FileText, Bug, - X, Loader2, Copy, Check, + Heading, + Send, + RefreshCw, } from "lucide-react"; interface AiAssistantProps { - /** 当前编辑器的纯文本内容 */ content: string; - /** 将 AI 结果插入/替换到编辑器 */ + selectedText?: string; onInsert: (text: string, mode: "replace" | "append") => void; + onGenerateExcerpt?: (text: string) => void; + onGenerateTitle?: (text: string) => void; } type Action = "polish" | "expand" | "shorten" | "continue" | "translate_en" | "translate_zh" | "summarize" | "fix_grammar"; -const ACTIONS: { key: Action; label: string; icon: React.ReactNode; needSelection?: boolean }[] = [ - { key: "polish", label: "润色", icon: }, - { key: "expand", label: "扩写", icon: }, - { key: "shorten", label: "精简", icon: }, - { key: "continue", label: "续写", icon: }, - { key: "fix_grammar", label: "纠错", icon: }, - { key: "translate_en", label: "中→英", icon: }, - { key: "translate_zh", label: "英→中", icon: }, - { key: "summarize", label: "摘要", icon: }, -]; - -export default function AiAssistant({ content, onInsert }: AiAssistantProps) { - const [open, setOpen] = useState(false); +export default function AiAssistant({ + content, + selectedText, + onInsert, + onGenerateExcerpt, + onGenerateTitle, +}: AiAssistantProps) { const [loading, setLoading] = useState(false); const [result, setResult] = useState(""); + const [resultLabel, setResultLabel] = useState(""); + const [generateTarget, setGenerateTarget] = useState<"title" | "excerpt" | null>(null); const [customPrompt, setCustomPrompt] = useState(""); const [copied, setCopied] = useState(false); const abortRef = useRef(null); @@ -49,19 +48,28 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) { abortRef.current?.abort(); abortRef.current = null; setLoading(false); + setGenerateTarget(null); }, []); - async function runAction(action?: Action) { - const text = content.replace(/<[^>]+>/g, "").trim(); + function getEffectiveText(): string { + if (selectedText?.trim()) return selectedText.trim(); + return content.replace(/<[^>]+>/g, "").trim(); + } + + const hasSelection = !!(selectedText?.trim()); + + async function runAction(action?: Action, label?: string) { + const text = getEffectiveText(); if (!text && action !== "summarize") { - setResult("请先输入文章内容"); - setOpen(true); + setResult("请先输入文章内容或选中文字"); + setResultLabel(""); return; } setLoading(true); setResult(""); - setOpen(true); + setResultLabel(label || ""); + setGenerateTarget(null); abortRef.current = new AbortController(); try { @@ -83,7 +91,6 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) { return; } - // 读取 SSE 流 const reader = res.body?.getReader(); const decoder = new TextDecoder(); let accumulated = ""; @@ -92,9 +99,7 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) { while (true) { const { done, value } = await reader.read(); if (done) break; - const chunk = decoder.decode(value, { stream: true }); - // 解析 SSE 数据 for (const line of chunk.split("\n")) { if (line.startsWith("data: ")) { const data = line.slice(6); @@ -106,9 +111,7 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) { accumulated += delta; setResult(accumulated); } - } catch { - // 忽略解析错误 - } + } catch { /* ignore */ } } } } @@ -122,6 +125,93 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) { } finally { setLoading(false); abortRef.current = null; + setGenerateTarget(null); + } + } + + async function runGenerate(target: "title" | "excerpt") { + const text = content.replace(/<[^>]+>/g, "").trim(); + if (!text) { + setResult("请先输入文章内容"); + setResultLabel(""); + return; + } + + setLoading(true); + setResult(""); + setResultLabel(target === "title" ? "生成标题" : "生成摘要"); + setGenerateTarget(target); + abortRef.current = new AbortController(); + + const promptText = target === "title" + ? `请为以下文章生成一个简洁的中文标题(不超过30字),直接输出标题,不要引号:\n\n${text}` + : `请为以下文章写一段摘要(2-3句话,不超过200字),直接输出摘要:\n\n${text}`; + + try { + const res = await fetch("/api/ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: promptText, selectedText: text }), + signal: abortRef.current.signal, + }); + + if (!res.ok) { + const err = await res.json(); + setResult(`错误:${err.error || "请求失败"}`); + setLoading(false); + setGenerateTarget(null); + return; + } + + const reader = res.body?.getReader(); + const decoder = new TextDecoder(); + let accumulated = ""; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + for (const line of chunk.split("\n")) { + if (line.startsWith("data: ")) { + const data = line.slice(6); + if (data === "[DONE]") continue; + try { + const parsed = JSON.parse(data); + const delta = parsed.choices?.[0]?.delta?.content; + if (delta) { + accumulated += delta; + setResult(accumulated); + } + } catch { /* ignore */ } + } + } + } + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + setResult((prev) => prev + "\n\n[已停止]"); + } else { + setResult(`错误:${err instanceof Error ? err.message : "未知错误"}`); + } + } finally { + setLoading(false); + abortRef.current = null; + setGenerateTarget(null); + } + } + + function applyGenerated() { + if (generateTarget === "title" && onGenerateTitle && result) { + onGenerateTitle(result); + setResult(""); + setResultLabel(""); + setGenerateTarget(null); + } else if (generateTarget === "excerpt" && onGenerateExcerpt && result) { + onGenerateExcerpt(result); + setResult(""); + setResultLabel(""); + setGenerateTarget(null); } } @@ -134,109 +224,221 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) { function handleInsert(mode: "replace" | "append") { onInsert(result, mode); setResult(""); - setOpen(false); + setResultLabel(""); } return ( -
- {/* 触发按钮 */} - +
+ {/* ── 标题栏 ── */} +
+
+ +
+ AI 写作助手 +
- {open && ( -
- {/* 关闭按钮 */} -
- 选择操作或输入自定义指令 - -
+ {/* ── 选中提示 ── */} + {hasSelection && ( +
+

+ 📌 已选中 {selectedText.length} 字,AI 将只处理选中内容 +

+
+ )} - {/* 操作按钮组 */} -
- {ACTIONS.map((a) => ( - - ))} -
- - {/* 自定义指令 */} -
- setCustomPrompt(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); runAction(); } }} - placeholder="或输入自定义指令..." - className="flex-1 h-8 px-3 rounded-lg border border-border bg-transparent font-sans text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + {/* ── 文本处理 ── */} +
+

文本处理

+
+ {[ + { key: "polish" as Action, label: "润色", icon: }, + { key: "expand" as Action, label: "扩写", icon: }, + { key: "shorten" as Action, label: "精简", icon: }, + { key: "continue" as Action, label: "续写", icon: }, + { key: "fix_grammar" as Action, label: "纠错", icon: }, + { key: "summarize" as Action, label: "摘要", icon: }, + ].map((a) => ( + + onClick={() => runAction(a.key, a.label)} + className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]" + > + {a.icon} + {a.label} + + ))} +
+
+ + {/* ── 翻译 ── */} +
+

翻译

+
+ {[ + { key: "translate_en" as Action, label: "中 → 英", icon: }, + { key: "translate_zh" as Action, label: "英 → 中", icon: }, + ].map((a) => ( + + ))} +
+
+ + {/* ── 智能生成 ── */} +
+

智能生成

+
+ + +
+
+ + {/* ── 自定义指令 ── */} +
+
+ setCustomPrompt(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); runAction(undefined, "自定义指令"); } + }} + placeholder="输入自定义指令..." + className="flex-1 h-8 px-3 rounded-lg border border-border bg-transparent font-sans text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring" + disabled={loading} + /> + +
+
+ + {/* ── 结果区 ── */} + {(result || loading) && ( +
+ {/* 分割线 */} +
+
+ + {loading ? "生成中..." : "结果"} + +
- {/* 结果 */} - {result && ( -
-
-
{result}
- {loading && ( - - )} -
+ {/* 结果标签 */} + {resultLabel && !loading && result && ( +

{resultLabel}

+ )} - {/* 操作按钮 */} - {!loading && result && ( -
- + {/* 结果内容 */} +
+ {loading && !result && ( +
+ + AI 正在思考... +
+ )} + {result && ( +
+ {result} + {loading && } +
+ )} + {loading && result && ( + + )} +
+ + {/* 操作按钮 */} + {!loading && result && ( +
+ {generateTarget ? ( + + ) : ( +
- +
+ + +
)}
)}
)} + + {/* ── 空状态 ── */} + {!result && !loading && ( +

+ 选中文字可以局部处理
+ 或直接点击上方按钮处理全文 +

+ )}
); } diff --git a/src/components/admin/MarkdownEditor.tsx b/src/components/admin/MarkdownEditor.tsx index 310296a..5df646b 100644 --- a/src/components/admin/MarkdownEditor.tsx +++ b/src/components/admin/MarkdownEditor.tsx @@ -7,6 +7,8 @@ interface MarkdownEditorProps { value: string; // HTML from parent onChange: (html: string) => void; placeholder?: string; + /** 当选区变化时回调,传递选中的纯文本(无选区时为空字符串) */ + onSelectionChange?: (selectedText: string) => void; } /** 简易 HTML → Markdown 转换(仅处理常见标签) */ @@ -46,6 +48,7 @@ export default function MarkdownEditor({ value, onChange, placeholder = "使用 Markdown 语法写作...", + onSelectionChange, }: MarkdownEditorProps) { const [markdown, setMarkdown] = useState(() => htmlToMarkdown(value)); @@ -87,6 +90,12 @@ export default function MarkdownEditor({