feat: AI 写作助手重构 — 右侧面板、选中文本、撤销、生成摘要/标题
- AiAssistant 改为右侧粘性面板,按文本处理/翻译/智能生成分组 - 支持选中文本局部处理(未选中时处理全文) - 替换/追加内容后支持撤销恢复 - 新增「生成标题」「生成摘要」按钮,结果可一键填入表单 - 编辑器最小高度从 300px 提高到 500px - admin layout 去除 max-w-5xl 限制,充分利用宽屏空间 - 添加 .env.example 模板,.gitignore 放行 - package.json 添加 @tailwindcss/oxide-win32-x64-msvc optionalDependencies(兼容 Windows)
This commit is contained in:
@@ -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"
|
||||||
@@ -38,6 +38,7 @@ prisma/dev.db-shm
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
Generated
+4
@@ -86,6 +86,10 @@ importers:
|
|||||||
zod:
|
zod:
|
||||||
specifier: ^4.4.3
|
specifier: ^4.4.3
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@tailwindcss/oxide-win32-x64-msvc':
|
||||||
|
specifier: 4.3.1
|
||||||
|
version: 4.3.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
{/* Main */}
|
{/* Main */}
|
||||||
<div className="flex-1 overflow-auto md:pt-0 pt-12">
|
<div className="flex-1 overflow-auto md:pt-0 pt-12">
|
||||||
<div className="p-6 md:p-8 max-w-5xl">
|
<div className="p-6 md:p-8">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,36 +11,35 @@ import {
|
|||||||
Languages,
|
Languages,
|
||||||
FileText,
|
FileText,
|
||||||
Bug,
|
Bug,
|
||||||
X,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
|
Heading,
|
||||||
|
Send,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface AiAssistantProps {
|
interface AiAssistantProps {
|
||||||
/** 当前编辑器的纯文本内容 */
|
|
||||||
content: string;
|
content: string;
|
||||||
/** 将 AI 结果插入/替换到编辑器 */
|
selectedText?: string;
|
||||||
onInsert: (text: string, mode: "replace" | "append") => void;
|
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";
|
type Action = "polish" | "expand" | "shorten" | "continue" | "translate_en" | "translate_zh" | "summarize" | "fix_grammar";
|
||||||
|
|
||||||
const ACTIONS: { key: Action; label: string; icon: React.ReactNode; needSelection?: boolean }[] = [
|
export default function AiAssistant({
|
||||||
{ key: "polish", label: "润色", icon: <Wand2 className="w-3.5 h-3.5" /> },
|
content,
|
||||||
{ key: "expand", label: "扩写", icon: <Maximize2 className="w-3.5 h-3.5" /> },
|
selectedText,
|
||||||
{ key: "shorten", label: "精简", icon: <Minimize2 className="w-3.5 h-3.5" /> },
|
onInsert,
|
||||||
{ key: "continue", label: "续写", icon: <ArrowRight className="w-3.5 h-3.5" /> },
|
onGenerateExcerpt,
|
||||||
{ key: "fix_grammar", label: "纠错", icon: <Bug className="w-3.5 h-3.5" /> },
|
onGenerateTitle,
|
||||||
{ key: "translate_en", label: "中→英", icon: <Languages className="w-3.5 h-3.5" /> },
|
}: AiAssistantProps) {
|
||||||
{ key: "translate_zh", label: "英→中", icon: <Languages className="w-3.5 h-3.5" /> },
|
|
||||||
{ key: "summarize", label: "摘要", icon: <FileText className="w-3.5 h-3.5" /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [result, setResult] = useState("");
|
const [result, setResult] = useState("");
|
||||||
|
const [resultLabel, setResultLabel] = useState("");
|
||||||
|
const [generateTarget, setGenerateTarget] = useState<"title" | "excerpt" | null>(null);
|
||||||
const [customPrompt, setCustomPrompt] = useState("");
|
const [customPrompt, setCustomPrompt] = useState("");
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
@@ -49,19 +48,28 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
|
|||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setGenerateTarget(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function runAction(action?: Action) {
|
function getEffectiveText(): string {
|
||||||
const text = content.replace(/<[^>]+>/g, "").trim();
|
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") {
|
if (!text && action !== "summarize") {
|
||||||
setResult("请先输入文章内容");
|
setResult("请先输入文章内容或选中文字");
|
||||||
setOpen(true);
|
setResultLabel("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setResult("");
|
setResult("");
|
||||||
setOpen(true);
|
setResultLabel(label || "");
|
||||||
|
setGenerateTarget(null);
|
||||||
abortRef.current = new AbortController();
|
abortRef.current = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +91,6 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取 SSE 流
|
|
||||||
const reader = res.body?.getReader();
|
const reader = res.body?.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let accumulated = "";
|
let accumulated = "";
|
||||||
@@ -92,9 +99,7 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
|
|||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
// 解析 SSE 数据
|
|
||||||
for (const line of chunk.split("\n")) {
|
for (const line of chunk.split("\n")) {
|
||||||
if (line.startsWith("data: ")) {
|
if (line.startsWith("data: ")) {
|
||||||
const data = line.slice(6);
|
const data = line.slice(6);
|
||||||
@@ -106,9 +111,7 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
|
|||||||
accumulated += delta;
|
accumulated += delta;
|
||||||
setResult(accumulated);
|
setResult(accumulated);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch { /* ignore */ }
|
||||||
// 忽略解析错误
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +125,93 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
abortRef.current = null;
|
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") {
|
function handleInsert(mode: "replace" | "append") {
|
||||||
onInsert(result, mode);
|
onInsert(result, mode);
|
||||||
setResult("");
|
setResult("");
|
||||||
setOpen(false);
|
setResultLabel("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="sticky top-6 space-y-5">
|
||||||
{/* 触发按钮 */}
|
{/* ── 标题栏 ── */}
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||||
onClick={() => setOpen((v) => !v)}
|
<Sparkles className="w-3.5 h-3.5 text-primary" />
|
||||||
className="flex items-center gap-1.5 font-sans text-xs text-muted-foreground hover:text-primary transition-colors"
|
</div>
|
||||||
>
|
<span className="font-sans text-sm font-semibold text-foreground">AI 写作助手</span>
|
||||||
<Sparkles className="w-3.5 h-3.5" />
|
</div>
|
||||||
AI 辅助写作
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
{/* ── 选中提示 ── */}
|
||||||
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
|
{hasSelection && (
|
||||||
{/* 关闭按钮 */}
|
<div className="rounded-lg bg-primary/5 border border-primary/20 px-3 py-2">
|
||||||
<div className="flex items-center justify-between">
|
<p className="font-sans text-xs text-primary/70">
|
||||||
<span className="font-sans text-xs text-muted-foreground">选择操作或输入自定义指令</span>
|
📌 已选中 <span className="font-medium">{selectedText.length}</span> 字,AI 将只处理选中内容
|
||||||
<button type="button" onClick={() => { setOpen(false); stopGeneration(); }} className="text-muted-foreground hover:text-foreground">
|
</p>
|
||||||
<X className="w-4 h-4" />
|
</div>
|
||||||
</button>
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮组 */}
|
{/* ── 文本处理 ── */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="space-y-2">
|
||||||
{ACTIONS.map((a) => (
|
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">文本处理</p>
|
||||||
<button
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
key={a.key}
|
{[
|
||||||
type="button"
|
{ key: "polish" as Action, label: "润色", icon: <Wand2 className="w-3 h-3" /> },
|
||||||
disabled={loading}
|
{ key: "expand" as Action, label: "扩写", icon: <Maximize2 className="w-3 h-3" /> },
|
||||||
onClick={() => runAction(a.key)}
|
{ key: "shorten" as Action, label: "精简", icon: <Minimize2 className="w-3 h-3" /> },
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 disabled:opacity-50 transition-colors"
|
{ key: "continue" as Action, label: "续写", icon: <ArrowRight className="w-3 h-3" /> },
|
||||||
>
|
{ key: "fix_grammar" as Action, label: "纠错", icon: <Bug className="w-3 h-3" /> },
|
||||||
{a.icon}
|
{ key: "summarize" as Action, label: "摘要", icon: <FileText className="w-3 h-3" /> },
|
||||||
{a.label}
|
].map((a) => (
|
||||||
</button>
|
<button
|
||||||
))}
|
key={a.key}
|
||||||
</div>
|
type="button"
|
||||||
|
|
||||||
{/* 自定义指令 */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
value={customPrompt}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
onClick={() => runAction(a.key, a.label)}
|
||||||
<Button size="sm" variant="outline" disabled={loading || !customPrompt.trim()} onClick={() => runAction()} className="h-8 px-3 text-xs">
|
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]"
|
||||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
>
|
||||||
</Button>
|
{a.icon}
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 翻译 ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">翻译</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{[
|
||||||
|
{ key: "translate_en" as Action, label: "中 → 英", icon: <Languages className="w-3 h-3" /> },
|
||||||
|
{ key: "translate_zh" as Action, label: "英 → 中", icon: <Languages className="w-3 h-3" /> },
|
||||||
|
].map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.key}
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 智能生成 ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">智能生成</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => runGenerate("title")}
|
||||||
|
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]"
|
||||||
|
>
|
||||||
|
<Heading className="w-3 h-3" />
|
||||||
|
生成标题
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => runGenerate("excerpt")}
|
||||||
|
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]"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
生成摘要
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 自定义指令 ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
value={customPrompt}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading || !customPrompt.trim()}
|
||||||
|
onClick={() => runAction(undefined, "自定义指令")}
|
||||||
|
className="h-8 w-8 p-0 shrink-0"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 结果区 ── */}
|
||||||
|
{(result || loading) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 分割线 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
<span className="font-sans text-[10px] text-muted-foreground/50 shrink-0">
|
||||||
|
{loading ? "生成中..." : "结果"}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 结果 */}
|
{/* 结果标签 */}
|
||||||
{result && (
|
{resultLabel && !loading && result && (
|
||||||
<div className="space-y-2">
|
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">{resultLabel}</p>
|
||||||
<div className="relative rounded-lg border border-border bg-muted/30 p-3 max-h-60 overflow-auto">
|
)}
|
||||||
<pre className="font-sans text-sm text-foreground whitespace-pre-wrap leading-relaxed">{result}</pre>
|
|
||||||
{loading && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={stopGeneration}
|
|
||||||
className="absolute top-2 right-2 px-2 py-0.5 rounded text-xs bg-red-600 text-white hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
停止
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 结果内容 */}
|
||||||
{!loading && result && (
|
<div className="relative rounded-lg border border-border bg-muted/20 p-3 max-h-80 overflow-auto">
|
||||||
<div className="flex gap-2">
|
{loading && !result && (
|
||||||
<button
|
<div className="flex items-center gap-2 py-2">
|
||||||
type="button"
|
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||||
onClick={handleCopy}
|
<span className="font-sans text-sm text-muted-foreground">AI 正在思考...</span>
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 transition-colors"
|
</div>
|
||||||
>
|
)}
|
||||||
{copied ? <Check className="w-3.5 h-3.5 text-green-600" /> : <Copy className="w-3.5 h-3.5" />}
|
{result && (
|
||||||
{copied ? "已复制" : "复制"}
|
<div className="font-sans text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||||
</button>
|
{result}
|
||||||
|
{loading && <span className="inline-block w-1.5 h-4 bg-primary ml-0.5 animate-pulse align-middle" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && result && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stopGeneration}
|
||||||
|
className="absolute top-2 right-2 px-2 py-0.5 rounded text-[10px] font-medium bg-red-600 text-white hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
{!loading && result && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{generateTarget ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={applyGenerated}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 h-9 rounded-lg bg-primary text-primary-foreground font-sans text-xs font-medium hover:bg-primary/90 transition-colors active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<Send className="w-3 h-3" />
|
||||||
|
应用{generateTarget === "title" ? "标题" : "摘要"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleInsert("replace")}
|
onClick={() => handleInsert("replace")}
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-primary text-primary-foreground font-sans text-xs hover:bg-primary/90 transition-colors"
|
className="w-full flex items-center justify-center gap-1.5 h-9 rounded-lg bg-primary text-primary-foreground font-sans text-xs font-medium hover:bg-primary/90 transition-colors active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
替换内容
|
替换内容
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleInsert("append")}
|
type="button"
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 transition-colors"
|
onClick={() => handleInsert("append")}
|
||||||
>
|
className="flex items-center justify-center gap-1 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 transition-colors"
|
||||||
追加到末尾
|
>
|
||||||
</button>
|
追加到末尾
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="flex items-center justify-center gap-1 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3 h-3 text-green-600" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{copied ? "已复制" : "复制"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── 空状态 ── */}
|
||||||
|
{!result && !loading && (
|
||||||
|
<p className="font-sans text-xs text-muted-foreground/40 leading-relaxed text-center py-4">
|
||||||
|
选中文字可以局部处理<br />
|
||||||
|
或直接点击上方按钮处理全文
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface MarkdownEditorProps {
|
|||||||
value: string; // HTML from parent
|
value: string; // HTML from parent
|
||||||
onChange: (html: string) => void;
|
onChange: (html: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** 当选区变化时回调,传递选中的纯文本(无选区时为空字符串) */
|
||||||
|
onSelectionChange?: (selectedText: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 简易 HTML → Markdown 转换(仅处理常见标签) */
|
/** 简易 HTML → Markdown 转换(仅处理常见标签) */
|
||||||
@@ -46,6 +48,7 @@ export default function MarkdownEditor({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "使用 Markdown 语法写作...",
|
placeholder = "使用 Markdown 语法写作...",
|
||||||
|
onSelectionChange,
|
||||||
}: MarkdownEditorProps) {
|
}: MarkdownEditorProps) {
|
||||||
const [markdown, setMarkdown] = useState(() => htmlToMarkdown(value));
|
const [markdown, setMarkdown] = useState(() => htmlToMarkdown(value));
|
||||||
|
|
||||||
@@ -87,6 +90,12 @@ export default function MarkdownEditor({
|
|||||||
<textarea
|
<textarea
|
||||||
value={markdown}
|
value={markdown}
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
onSelect={(e) => {
|
||||||
|
if (!onSelectionChange) return;
|
||||||
|
const ta = e.currentTarget;
|
||||||
|
const selected = ta.value.substring(ta.selectionStart, ta.selectionEnd);
|
||||||
|
onSelectionChange(selected);
|
||||||
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="flex-1 min-h-0 px-4 py-3 bg-transparent font-mono text-sm text-foreground resize-none focus:outline-none leading-relaxed"
|
className="flex-1 min-h-0 px-4 py-3 bg-transparent font-mono text-sm text-foreground resize-none focus:outline-none leading-relaxed"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export default function PostForm({
|
|||||||
const [isMarkdown, setIsMarkdown] = useState(false);
|
const [isMarkdown, setIsMarkdown] = useState(false);
|
||||||
const fullscreenRef = useRef<HTMLDivElement>(null);
|
const fullscreenRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 编辑器选中的文本
|
||||||
|
const [selectedText, setSelectedText] = useState("");
|
||||||
|
|
||||||
|
// 撤销机制
|
||||||
|
const [showUndo, setShowUndo] = useState(false);
|
||||||
|
const previousContentRef = useRef("");
|
||||||
|
|
||||||
// 自动保存
|
// 自动保存
|
||||||
const autoSaveKey = `draft:${mode === "edit" ? initialData?.id ?? "edit" : "new"}`;
|
const autoSaveKey = `draft:${mode === "edit" ? initialData?.id ?? "edit" : "new"}`;
|
||||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
@@ -339,30 +346,70 @@ export default function PostForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 内容 */}
|
{/* 内容 + AI 助手(水平对齐) */}
|
||||||
<div className="space-y-1.5">
|
<div className="flex gap-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
<Label>内容</Label>
|
<Label>内容</Label>
|
||||||
<AiAssistant
|
<RichEditor
|
||||||
content={form.content}
|
value={form.content}
|
||||||
onInsert={(text, mode) => {
|
onChange={(html) => update("content", html)}
|
||||||
if (mode === "replace") {
|
isFullscreen={false}
|
||||||
update("content", text);
|
isMarkdown={isMarkdown}
|
||||||
} else {
|
onToggleFullscreen={enterFullscreen}
|
||||||
update("content", form.content + "\n\n" + text);
|
onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
|
||||||
}
|
onSelectionChange={setSelectedText}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
{errors.content && <p className="text-xs text-red-600">{errors.content}</p>}
|
||||||
|
{showUndo && (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 rounded-lg border border-primary/30 bg-primary/5">
|
||||||
|
<span className="font-sans text-xs text-primary/80">内容已替换</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
update("content", previousContentRef.current);
|
||||||
|
setShowUndo(false);
|
||||||
|
}}
|
||||||
|
className="font-sans text-xs text-primary font-medium hover:underline"
|
||||||
|
>
|
||||||
|
撤销
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUndo(false)}
|
||||||
|
className="font-sans text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<RichEditor
|
|
||||||
value={form.content}
|
{/* AI 助手面板 — 与编辑器水平对齐 */}
|
||||||
onChange={(html) => update("content", html)}
|
<aside className="hidden lg:block w-72 shrink-0">
|
||||||
isFullscreen={false}
|
<div className="pt-6">
|
||||||
isMarkdown={isMarkdown}
|
<AiAssistant
|
||||||
onToggleFullscreen={enterFullscreen}
|
content={form.content}
|
||||||
onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
|
selectedText={selectedText}
|
||||||
/>
|
onInsert={(text, mode) => {
|
||||||
{errors.content && <p className="text-xs text-red-600">{errors.content}</p>}
|
if (mode === "replace") {
|
||||||
|
previousContentRef.current = form.content;
|
||||||
|
setShowUndo(true);
|
||||||
|
update("content", text);
|
||||||
|
} else {
|
||||||
|
previousContentRef.current = form.content;
|
||||||
|
setShowUndo(true);
|
||||||
|
update("content", form.content + "\n\n" + text);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onGenerateExcerpt={(text) => {
|
||||||
|
update("excerpt", text);
|
||||||
|
}}
|
||||||
|
onGenerateTitle={(text) => {
|
||||||
|
update("title", text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分类 + 阅读时间 */}
|
{/* 分类 + 阅读时间 */}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ interface RichEditorProps {
|
|||||||
isMarkdown?: boolean;
|
isMarkdown?: boolean;
|
||||||
onToggleFullscreen?: () => void;
|
onToggleFullscreen?: () => void;
|
||||||
onSwitchToMarkdown?: () => void;
|
onSwitchToMarkdown?: () => void;
|
||||||
|
/** 当选区变化时回调,传递选中的纯文本(无选区时为空字符串) */
|
||||||
|
onSelectionChange?: (selectedText: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RichEditor({
|
export default function RichEditor({
|
||||||
@@ -53,6 +55,7 @@ export default function RichEditor({
|
|||||||
isMarkdown,
|
isMarkdown,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
onSwitchToMarkdown,
|
onSwitchToMarkdown,
|
||||||
|
onSelectionChange,
|
||||||
}: RichEditorProps) {
|
}: RichEditorProps) {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -75,9 +78,19 @@ export default function RichEditor({
|
|||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
onChange(editor.getHTML());
|
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: {
|
editorProps: {
|
||||||
attributes: {
|
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({
|
|||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
|||||||
Reference in New Issue
Block a user