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 */}
-
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 (
-
- {/* 触发按钮 */}
-
+
+ {/* ── 标题栏 ── */}
+
- {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({
- {/* 内容 */}
-
-
+ {/* 内容 + AI 助手(水平对齐) */}
+
+
-
{
- if (mode === "replace") {
- update("content", text);
- } else {
- update("content", form.content + "\n\n" + text);
- }
- }}
+ update("content", html)}
+ isFullscreen={false}
+ isMarkdown={isMarkdown}
+ onToggleFullscreen={enterFullscreen}
+ onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
+ onSelectionChange={setSelectedText}
/>
+ {errors.content && {errors.content}
}
+ {showUndo && (
+
+ 内容已替换
+
+
+
+ )}
-
update("content", html)}
- isFullscreen={false}
- isMarkdown={isMarkdown}
- onToggleFullscreen={enterFullscreen}
- onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
- />
- {errors.content && {errors.content}
}
+
+ {/* AI 助手面板 — 与编辑器水平对齐 */}
+
{/* 分类 + 阅读时间 */}
diff --git a/src/components/admin/RichEditor.tsx b/src/components/admin/RichEditor.tsx
index 5b93f5c..6150d98 100644
--- a/src/components/admin/RichEditor.tsx
+++ b/src/components/admin/RichEditor.tsx
@@ -43,6 +43,8 @@ interface RichEditorProps {
isMarkdown?: boolean;
onToggleFullscreen?: () => void;
onSwitchToMarkdown?: () => void;
+ /** 当选区变化时回调,传递选中的纯文本(无选区时为空字符串) */
+ onSelectionChange?: (selectedText: string) => void;
}
export default function RichEditor({
@@ -53,6 +55,7 @@ export default function RichEditor({
isMarkdown,
onToggleFullscreen,
onSwitchToMarkdown,
+ onSelectionChange,
}: RichEditorProps) {
const editor = useEditor({
extensions: [
@@ -75,9 +78,19 @@ export default function RichEditor({
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
+ onSelectionUpdate: ({ editor }) => {
+ if (!onSelectionChange) return;
+ const { from, to } = editor.state.selection;
+ if (from !== to) {
+ const text = editor.state.doc.textBetween(from, to);
+ onSelectionChange(text);
+ } else {
+ onSelectionChange("");
+ }
+ },
editorProps: {
attributes: {
- class: "prose-literary min-h-[300px] px-4 py-3 focus:outline-none",
+ class: "prose-literary min-h-[500px] px-4 py-3 focus:outline-none",
},
},
});
@@ -286,6 +299,7 @@ export default function RichEditor({
) : (