feat: 写作体验优化 + 封面图 + AI辅助写作 + 博客分页
写作体验优化: - 自动保存草稿到 localStorage(debounce 2s,刷新不丢内容) - 浏览器原生全屏专注写作模式 - Markdown 编辑模式(左右分栏,实时预览) - 快捷键:Ctrl+S / Ctrl+Enter 保存,ESC 退出全屏 封面图功能: - PostForm 新增封面图 URL 输入 + 实时预览 - BlogList 文章卡片显示封面缩略图 - PostContent 文章详情页显示封面大图 AI 辅助写作: - OpenAI 兼容接口,SSE 流式返回 - 8 种预设操作:润色、扩写、精简、续写、纠错、翻译、摘要 - 自定义指令输入,结果可复制/替换/追加 其他: - 后台文章列表改为一页10篇 - 前台 /blog 页面添加分页功能(一页10篇)
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"gsap": "^3.15.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^1.21.0",
|
||||
"marked": "^18.0.5",
|
||||
"next": "16.2.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"prisma": "5",
|
||||
|
||||
Generated
+10
@@ -50,6 +50,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^1.21.0
|
||||
version: 1.21.0(react@19.2.4)
|
||||
marked:
|
||||
specifier: ^18.0.5
|
||||
version: 18.0.5
|
||||
next:
|
||||
specifier: 16.2.9
|
||||
version: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -2464,6 +2467,11 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
marked@18.0.5:
|
||||
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5777,6 +5785,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
marked@18.0.5: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
+19
-13
@@ -3,24 +3,24 @@ import GsapReveal from "@/components/GsapReveal";
|
||||
|
||||
export const metadata = {
|
||||
title: "关于",
|
||||
description: "关于胡旭和这个博客",
|
||||
description: "关于我和这个博客",
|
||||
};
|
||||
|
||||
const timeline = [
|
||||
{
|
||||
year: "2026",
|
||||
title: "开始写博客",
|
||||
desc: "用 Next.js 搭建个人博客,记录技术与生活",
|
||||
title: "AI 图像生成",
|
||||
desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境",
|
||||
},
|
||||
{
|
||||
year: "2025",
|
||||
title: "sui_lightbox 项目",
|
||||
title: "lightbox 项目",
|
||||
desc: "开发3D灯箱模拟平台,探索标识行业的数字化可能",
|
||||
},
|
||||
{
|
||||
year: "2024",
|
||||
title: "AI 图像生成",
|
||||
desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境",
|
||||
title: "开始学习AI",
|
||||
desc: "从大模型到Dify,探索AI在开发中的应用",
|
||||
},
|
||||
{
|
||||
year: "2023",
|
||||
@@ -29,8 +29,13 @@ const timeline = [
|
||||
},
|
||||
{
|
||||
year: "2022",
|
||||
title: "前端开发",
|
||||
desc: "从后端转向全栈,React + TypeScript 成为主力技术栈",
|
||||
title: "搭建博客",
|
||||
desc: "用 React 搭建个人博客1.0,记录技术与生活",
|
||||
},
|
||||
{
|
||||
year: "2021",
|
||||
title: "入行前端",
|
||||
desc: "从学校走出来,开始在互联网公司做前端开发",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -55,8 +60,8 @@ export default function AboutPage() {
|
||||
className="space-y-6 font-body text-base text-ink-light leading-relaxed"
|
||||
>
|
||||
<p>
|
||||
你好,我是<span className="text-ink font-medium">胡旭</span>
|
||||
,一个来自安徽六安的前端开发者。
|
||||
你好,我是<span className="text-ink font-medium">Sui</span>
|
||||
,一个00后废柴青年兼前端开发者。
|
||||
</p>
|
||||
<p>
|
||||
我对 AI 图像生成、Web 3D
|
||||
@@ -69,7 +74,7 @@ export default function AboutPage() {
|
||||
当你试图把一个想法写下来的时候,你会发现自己对它的理解远比想象中要浅。
|
||||
</p>
|
||||
<p className="text-ink-muted">
|
||||
如果你有任何想法或合作意向,欢迎通过以下方式联系我。
|
||||
如果你有任何想法或意见,欢迎通过以下方式联系我。
|
||||
</p>
|
||||
</GsapReveal>
|
||||
|
||||
@@ -204,8 +209,9 @@ export default function AboutPage() {
|
||||
这个博客使用 <span className="text-terracotta">Next.js 16</span>{" "}
|
||||
构建,样式基于{" "}
|
||||
<span className="text-terracotta">Tailwind CSS 4</span>
|
||||
,数据存储在本地 SQLite 数据库中。字体使用了 Noto Serif SC(宋体)与
|
||||
Cormorant Garamond 的组合,追求一种接近纸质杂志的阅读体验。
|
||||
,数据存储在本地 SQLite 数据库中。字体使用了 Noto Serif
|
||||
SC(宋体)与 Cormorant Garamond
|
||||
的组合,追求一种接近纸质杂志的阅读体验。
|
||||
</p>
|
||||
</div>
|
||||
</GsapReveal>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function PostsPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [counts, setCounts] = useState({ all: 0, published: 0, draft: 0 });
|
||||
const pageSize = 20;
|
||||
const pageSize = 10;
|
||||
const { toast } = useToast();
|
||||
|
||||
// 搜索 debounce:300ms 后才更新 debouncedSearch
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireAuth } from "@/lib/http";
|
||||
|
||||
const BASE_URL = process.env.AI_BASE_URL || "https://api.openai.com/v1";
|
||||
const API_KEY = process.env.AI_API_KEY || "";
|
||||
const MODEL = process.env.AI_MODEL || "gpt-4o-mini";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const deny = await requireAuth();
|
||||
if (deny) return deny;
|
||||
|
||||
if (!API_KEY) {
|
||||
return new Response(JSON.stringify({ error: "未配置 AI_API_KEY" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let body: { prompt: string; selectedText?: string; action?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "请求格式错误" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { prompt, selectedText, action } = body;
|
||||
|
||||
// 构建系统提示
|
||||
const systemPrompt = `你是一个中文博客写作助手。请用简洁、自然的中文回复。
|
||||
- 保持原文风格和语气
|
||||
- 不要添加多余的解释或前缀
|
||||
- 直接输出结果文本`;
|
||||
|
||||
// 根据 action 构建用户提示
|
||||
let userPrompt = prompt;
|
||||
if (action && selectedText) {
|
||||
const actionMap: Record<string, string> = {
|
||||
polish: `请润色以下文字,使其更流畅、更优美,保持原意:\n\n${selectedText}`,
|
||||
expand: `请扩写以下文字,补充更多细节和论述,保持风格一致:\n\n${selectedText}`,
|
||||
shorten: `请精简以下文字,保留核心意思,去除冗余:\n\n${selectedText}`,
|
||||
continue: `请根据以下内容自然地续写下去,保持风格和语气一致:\n\n${selectedText}`,
|
||||
translate_en: `请将以下中文翻译为英文,保持自然流畅:\n\n${selectedText}`,
|
||||
translate_zh: `请将以下英文翻译为中文,保持自然流畅:\n\n${selectedText}`,
|
||||
summarize: `请为以下文章写一段简短的摘要(2-3句话):\n\n${selectedText}`,
|
||||
fix_grammar: `请修正以下文字中的语法错误和错别字,保持原意:\n\n${selectedText}`,
|
||||
};
|
||||
userPrompt = actionMap[action] || `请处理以下文字:\n\n${selectedText}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
stream: true,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return new Response(JSON.stringify({ error: `AI 请求失败: ${err}` }), {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// 转发 SSE 流
|
||||
return new Response(res.body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `AI 请求异常: ${err instanceof Error ? err.message : "未知错误"}` }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import type { PublicPost } from "@/lib/store";
|
||||
import { formatDate, readingTimeLabel } from "@/lib/utils";
|
||||
import { useGsapAnimation } from "./useGsapAnimation";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
@@ -20,6 +21,8 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const activeCategory = searchParams.get("category") || "";
|
||||
const activeTag = searchParams.get("tag") || "";
|
||||
const page = Number(searchParams.get("page")) || 1;
|
||||
const pageSize = 10;
|
||||
|
||||
const headerRef = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
|
||||
if (isReduced) return;
|
||||
@@ -66,6 +69,13 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
});
|
||||
}, [posts, activeCategory, activeTag]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const paged = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, currentPage]);
|
||||
|
||||
function setFilter(key: "category" | "tag", value: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) params.set(key, value);
|
||||
@@ -73,6 +83,15 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
// 切换一个维度时清除另一个,避免组合空结果困惑
|
||||
if (key === "category") params.delete("tag");
|
||||
if (key === "tag") params.delete("category");
|
||||
params.delete("page"); // 切换筛选时重置分页
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
|
||||
}
|
||||
|
||||
function setPage(p: number) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (p > 1) params.set("page", String(p));
|
||||
else params.delete("page");
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
|
||||
}
|
||||
@@ -136,12 +155,12 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
</div>
|
||||
|
||||
{/* Post list */}
|
||||
{filtered.length > 0 ? (
|
||||
{paged.length > 0 ? (
|
||||
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-0">
|
||||
{filtered.map((post) => (
|
||||
{paged.map((post) => (
|
||||
<Link key={post.slug} href={`/posts/${post.slug}`} className="blog-list-item group block">
|
||||
<article className="relative py-8 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-xl transition-all duration-300">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-8">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-6">
|
||||
<div className="shrink-0 md:w-36 md:pt-1">
|
||||
<time className="font-sans text-sm text-ink-muted tabular-nums">
|
||||
{formatDate(post.date)}
|
||||
@@ -168,6 +187,12 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{post.coverImage && (
|
||||
<div className="hidden md:block shrink-0 w-28 h-20 rounded-lg overflow-hidden bg-parchment-deep">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={post.coverImage} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden md:flex shrink-0 items-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 pt-2">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-terracotta">
|
||||
<path d="M7 12h10M13 8l4 4-4 4" />
|
||||
@@ -184,6 +209,39 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
<p className="font-sans text-sm text-ink-muted">该筛选条件下暂无文章。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-12 font-sans text-sm">
|
||||
<button
|
||||
onClick={() => setPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className="p-2 rounded-lg text-ink-muted hover:text-terracotta disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`w-8 h-8 rounded-lg transition-colors ${
|
||||
p === currentPage
|
||||
? "bg-ink text-cream"
|
||||
: "text-ink-muted hover:text-terracotta hover:bg-cream"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="p-2 rounded-lg text-ink-muted hover:text-terracotta disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,18 @@ export default function PostContent({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Cover image */}
|
||||
{post.coverImage && (
|
||||
<div className="max-w-3xl mx-auto mb-14">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
className="w-full rounded-2xl object-cover max-h-[480px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decorative divider */}
|
||||
<div className="max-w-2xl mx-auto mb-14">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sparkles,
|
||||
Wand2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
ArrowRight,
|
||||
Languages,
|
||||
FileText,
|
||||
Bug,
|
||||
X,
|
||||
Loader2,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
interface AiAssistantProps {
|
||||
/** 当前编辑器的纯文本内容 */
|
||||
content: string;
|
||||
/** 将 AI 结果插入/替换到编辑器 */
|
||||
onInsert: (text: string, mode: "replace" | "append") => 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: <Wand2 className="w-3.5 h-3.5" /> },
|
||||
{ key: "expand", label: "扩写", icon: <Maximize2 className="w-3.5 h-3.5" /> },
|
||||
{ key: "shorten", label: "精简", icon: <Minimize2 className="w-3.5 h-3.5" /> },
|
||||
{ key: "continue", label: "续写", icon: <ArrowRight className="w-3.5 h-3.5" /> },
|
||||
{ key: "fix_grammar", label: "纠错", icon: <Bug className="w-3.5 h-3.5" /> },
|
||||
{ key: "translate_en", label: "中→英", icon: <Languages className="w-3.5 h-3.5" /> },
|
||||
{ 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 [result, setResult] = useState("");
|
||||
const [customPrompt, setCustomPrompt] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
async function runAction(action?: Action) {
|
||||
const text = content.replace(/<[^>]+>/g, "").trim();
|
||||
if (!text && action !== "summarize") {
|
||||
setResult("请先输入文章内容");
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setResult("");
|
||||
setOpen(true);
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: customPrompt || undefined,
|
||||
selectedText: text || undefined,
|
||||
action,
|
||||
}),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
setResult(`错误:${err.error || "请求失败"}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取 SSE 流
|
||||
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 });
|
||||
// 解析 SSE 数据
|
||||
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 {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
function handleInsert(mode: "replace" | "append") {
|
||||
onInsert(result, mode);
|
||||
setResult("");
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 触发按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 font-sans text-xs text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
AI 辅助写作
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
|
||||
{/* 关闭按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-sans text-xs text-muted-foreground">选择操作或输入自定义指令</span>
|
||||
<button type="button" onClick={() => { setOpen(false); stopGeneration(); }} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮组 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ACTIONS.map((a) => (
|
||||
<button
|
||||
key={a.key}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => runAction(a.key)}
|
||||
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"
|
||||
>
|
||||
{a.icon}
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 自定义指令 */}
|
||||
<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}
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={loading || !customPrompt.trim()} onClick={() => runAction()} className="h-8 px-3 text-xs">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 结果 */}
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<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="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
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"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-600" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? "已复制" : "复制"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
替换内容
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInsert("append")}
|
||||
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"
|
||||
>
|
||||
追加到末尾
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { marked } from "marked";
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
value: string; // HTML from parent
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** 简易 HTML → Markdown 转换(仅处理常见标签) */
|
||||
function htmlToMarkdown(html: string): string {
|
||||
if (!html.trim()) return "";
|
||||
let md = html;
|
||||
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, "# $1\n\n");
|
||||
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, "## $1\n\n");
|
||||
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, "### $1\n\n");
|
||||
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, "**$1**");
|
||||
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, "**$1**");
|
||||
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, "*$1*");
|
||||
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, "*$1*");
|
||||
md = md.replace(/<s[^>]*>(.*?)<\/s>/gi, "~~$1~~");
|
||||
md = md.replace(/<strike[^>]*>(.*?)<\/strike>/gi, "~~$1~~");
|
||||
md = md.replace(/<del[^>]*>(.*?)<\/del>/gi, "~~$1~~");
|
||||
md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, "`$1`");
|
||||
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)");
|
||||
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, "");
|
||||
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*\/?>/gi, "");
|
||||
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, (_, content) =>
|
||||
content.trim().split("\n").map((l: string) => `> ${l.trim()}`).join("\n") + "\n\n"
|
||||
);
|
||||
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, "- $1\n");
|
||||
md = md.replace(/<\/?[uo]l[^>]*>/gi, "\n");
|
||||
md = md.replace(/<hr[^>]*\/?>/gi, "\n---\n\n");
|
||||
md = md.replace(/<p[^>]*>(.*?)<\/p>/gis, "$1\n\n");
|
||||
md = md.replace(/<br[^>]*\/?>/gi, "\n");
|
||||
md = md.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, "```\n$1\n```\n\n");
|
||||
md = md.replace(/<[^>]+>/g, "");
|
||||
md = md.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"');
|
||||
md = md.replace(/\n{3,}/g, "\n\n");
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
export default function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "使用 Markdown 语法写作...",
|
||||
}: MarkdownEditorProps) {
|
||||
const [markdown, setMarkdown] = useState(() => htmlToMarkdown(value));
|
||||
|
||||
// 外部 value 变化时(如恢复草稿),同步到 markdown
|
||||
useEffect(() => {
|
||||
const converted = htmlToMarkdown(value);
|
||||
// 只在内容真正不同时更新,避免光标跳动
|
||||
if (converted !== markdown && marked.parse(markdown) !== value) {
|
||||
setMarkdown(converted);
|
||||
}
|
||||
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Markdown → HTML,同步给父组件
|
||||
const html = useMemo(() => {
|
||||
try {
|
||||
return marked.parse(markdown) as string;
|
||||
} catch {
|
||||
return markdown;
|
||||
}
|
||||
}, [markdown]);
|
||||
|
||||
function handleChange(md: string) {
|
||||
setMarkdown(md);
|
||||
try {
|
||||
const newHtml = marked.parse(md) as string;
|
||||
onChange(newHtml);
|
||||
} catch {
|
||||
onChange(md);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-border h-full">
|
||||
{/* 编辑区 */}
|
||||
<div className="flex flex-col">
|
||||
<div className="px-3 py-1.5 border-b border-border bg-muted/30">
|
||||
<span className="font-sans text-xs text-muted-foreground">Markdown</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={markdown}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
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"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预览区 */}
|
||||
<div className="flex flex-col">
|
||||
<div className="px-3 py-1.5 border-b border-border bg-muted/30">
|
||||
<span className="font-sans text-xs text-muted-foreground">预览</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-h-0 px-4 py-3 overflow-auto prose-literary"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { Switch } from "@/components/ui/switch";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const RichEditor = dynamic(() => import("./RichEditor"), { ssr: false });
|
||||
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), { ssr: false });
|
||||
import AiAssistant from "./AiAssistant";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -50,6 +52,7 @@ export default function PostForm({
|
||||
slug: initialData?.slug ?? "",
|
||||
excerpt: initialData?.excerpt ?? "",
|
||||
content: initialData?.content ?? "",
|
||||
coverImage: initialData?.coverImage ?? "",
|
||||
category: initialData?.category ?? "",
|
||||
tags: initialData?.tags ?? ([] as string[]),
|
||||
readingTime: initialData?.readingTime ?? 5,
|
||||
@@ -63,6 +66,17 @@ export default function PostForm({
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const originalRef = useRef(JSON.stringify(form));
|
||||
|
||||
// 全屏 / Markdown 模式
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMarkdown, setIsMarkdown] = useState(false);
|
||||
const fullscreenRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动保存
|
||||
const autoSaveKey = `draft:${mode === "edit" ? initialData?.id ?? "edit" : "new"}`;
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
// 离开确认
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
@@ -80,6 +94,83 @@ export default function PostForm({
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
// ── 自动保存到 localStorage(debounce 2s) ──
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(autoSaveKey, JSON.stringify(form));
|
||||
setLastSaved(new Date());
|
||||
} catch { /* quota exceeded, ignore */ }
|
||||
}, 2000);
|
||||
return () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
};
|
||||
}, [form, dirty, autoSaveKey]);
|
||||
|
||||
// ── 页面加载时恢复草稿 ──
|
||||
useEffect(() => {
|
||||
if (mode === "edit") return; // 编辑模式不自动恢复,避免覆盖已有内容
|
||||
try {
|
||||
const saved = localStorage.getItem(autoSaveKey);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
// 只恢复有实际内容的草稿
|
||||
if (parsed.title || parsed.content) {
|
||||
setForm((prev) => ({ ...prev, ...parsed }));
|
||||
setLastSaved(new Date());
|
||||
}
|
||||
}
|
||||
} catch { /* corrupt data, ignore */ }
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── 快捷键 ──
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const isMod = e.metaKey || e.ctrlKey;
|
||||
// Ctrl/Cmd + S → 保存
|
||||
if (isMod && e.key === "s") {
|
||||
e.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
}
|
||||
// Ctrl/Cmd + Enter → 保存
|
||||
if (isMod && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// ── 浏览器原生全屏 API ──
|
||||
function enterFullscreen() {
|
||||
setIsFullscreen(true);
|
||||
// 等 DOM 更新后再调用原生全屏
|
||||
requestAnimationFrame(() => {
|
||||
fullscreenRef.current?.requestFullscreen?.().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function exitFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
|
||||
// 监听浏览器全屏变化(用户按 Esc 或 F11 退出时同步状态)
|
||||
useEffect(() => {
|
||||
function onFullscreenChange() {
|
||||
if (!document.fullscreenElement && isFullscreen) {
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||
}, [isFullscreen]);
|
||||
|
||||
function validate(): boolean {
|
||||
const errs: Record<string, string> = {};
|
||||
if (!form.title.trim()) errs.title = "请输入标题";
|
||||
@@ -99,6 +190,9 @@ export default function PostForm({
|
||||
try {
|
||||
const slug = form.slug || autoSlug(form.title);
|
||||
await onSubmit({ ...form, slug });
|
||||
// 保存成功,清除草稿
|
||||
localStorage.removeItem(autoSaveKey);
|
||||
setDirty(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -125,8 +219,52 @@ export default function PostForm({
|
||||
}
|
||||
}
|
||||
|
||||
// ── 全屏专注模式 ──
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div ref={fullscreenRef} className="fixed inset-0 z-[100] bg-background flex flex-col">
|
||||
{/* 顶部极简栏 */}
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b border-border/50 shrink-0">
|
||||
<span className="font-display text-base text-foreground truncate flex-1 opacity-60">
|
||||
{form.title || "未命名文章"}
|
||||
</span>
|
||||
<span className="font-sans text-xs text-muted-foreground tabular-nums">
|
||||
{form.content.replace(/<[^>]+>/g, "").length} 字
|
||||
</span>
|
||||
{lastSaved && (
|
||||
<span className="font-sans text-xs text-muted-foreground tabular-nums">
|
||||
{dirty ? "未保存" : "已保存"} {lastSaved.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitFullscreen}
|
||||
className="font-sans text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded"
|
||||
>
|
||||
退出全屏 Esc
|
||||
</button>
|
||||
</div>
|
||||
{/* 编辑器主体 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="w-full h-full px-6 flex flex-col">
|
||||
<RichEditor
|
||||
value={form.content}
|
||||
onChange={(html) => update("content", html)}
|
||||
isFullscreen
|
||||
isMarkdown={isMarkdown}
|
||||
onToggleFullscreen={exitFullscreen}
|
||||
onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 隐藏的 form 用于快捷键提交 */}
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 标题 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="post-title">标题</Label>
|
||||
@@ -179,12 +317,50 @@ export default function PostForm({
|
||||
{errors.excerpt && <p className="text-xs text-red-600">{errors.excerpt}</p>}
|
||||
</div>
|
||||
|
||||
{/* 封面图 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="post-cover">封面图(可选,输入图片地址)</Label>
|
||||
<Input
|
||||
id="post-cover"
|
||||
value={form.coverImage}
|
||||
onChange={(e) => update("coverImage", e.target.value)}
|
||||
placeholder="https://example.com/cover.jpg"
|
||||
/>
|
||||
{form.coverImage && (
|
||||
<div className="mt-2 relative w-full max-w-sm aspect-video rounded-lg overflow-hidden border border-border bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={form.coverImage}
|
||||
alt="封面预览"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>内容</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>内容</Label>
|
||||
<AiAssistant
|
||||
content={form.content}
|
||||
onInsert={(text, mode) => {
|
||||
if (mode === "replace") {
|
||||
update("content", text);
|
||||
} else {
|
||||
update("content", form.content + "\n\n" + text);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<RichEditor
|
||||
value={form.content}
|
||||
onChange={(html) => update("content", html)}
|
||||
isFullscreen={false}
|
||||
isMarkdown={isMarkdown}
|
||||
onToggleFullscreen={enterFullscreen}
|
||||
onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
|
||||
/>
|
||||
{errors.content && <p className="text-xs text-red-600">{errors.content}</p>}
|
||||
</div>
|
||||
@@ -270,14 +446,25 @@ export default function PostForm({
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<div className="flex items-center gap-3 pt-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? "保存中..." : mode === "create" ? "保存" : "保存修改"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
{lastSaved && (
|
||||
<span className="font-sans text-xs text-muted-foreground">
|
||||
{dirty ? "未保存" : "已保存"} · {lastSaved.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 快捷键提示 */}
|
||||
<p className="font-sans text-xs text-muted-foreground/60">
|
||||
快捷键:Ctrl+S 保存 · Ctrl+Enter 保存
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Placeholder from "@tiptap/extension-placeholder";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
@@ -25,20 +26,33 @@ import {
|
||||
Minus,
|
||||
Undo2,
|
||||
Redo2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), { ssr: false });
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
interface RichEditorProps {
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
isFullscreen?: boolean;
|
||||
isMarkdown?: boolean;
|
||||
onToggleFullscreen?: () => void;
|
||||
onSwitchToMarkdown?: () => void;
|
||||
}
|
||||
|
||||
export default function RichEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "开始写文章...",
|
||||
isFullscreen,
|
||||
isMarkdown,
|
||||
onToggleFullscreen,
|
||||
onSwitchToMarkdown,
|
||||
}: RichEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -63,8 +77,7 @@ export default function RichEditor({
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose-literary min-h-[300px] px-4 py-3 focus:outline-none",
|
||||
class: "prose-literary min-h-[300px] px-4 py-3 focus:outline-none",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -90,17 +103,18 @@ export default function RichEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const isFs = !!isFullscreen;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-border bg-muted/30">
|
||||
{/* 格式 */}
|
||||
<div className={isFs ? "flex flex-col h-full bg-transparent" : "rounded-xl border border-border bg-card overflow-hidden"}>
|
||||
{/* 工具栏 — 始终显示 */}
|
||||
<div className={`flex flex-wrap items-center gap-0.5 border-b border-border/50 shrink-0 ${isFs ? "px-4 py-2.5" : "px-2 py-1.5 bg-muted/30"}`}>
|
||||
{/* 富文本格式按钮 — Markdown 模式下禁用 */}
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("heading", { level: 1 })}
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="标题 1"
|
||||
>
|
||||
<Heading1 className="h-4 w-4" />
|
||||
@@ -108,9 +122,8 @@ export default function RichEditor({
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("heading", { level: 2 })}
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="标题 2"
|
||||
>
|
||||
<Heading2 className="h-4 w-4" />
|
||||
@@ -118,9 +131,8 @@ export default function RichEditor({
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("heading", { level: 3 })}
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="标题 3"
|
||||
>
|
||||
<Heading3 className="h-4 w-4" />
|
||||
@@ -132,6 +144,7 @@ export default function RichEditor({
|
||||
size="sm"
|
||||
pressed={editor.isActive("bold")}
|
||||
onPressedChange={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="粗体"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
@@ -140,6 +153,7 @@ export default function RichEditor({
|
||||
size="sm"
|
||||
pressed={editor.isActive("italic")}
|
||||
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="斜体"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
@@ -148,6 +162,7 @@ export default function RichEditor({
|
||||
size="sm"
|
||||
pressed={editor.isActive("strike")}
|
||||
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="删除线"
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
@@ -156,6 +171,7 @@ export default function RichEditor({
|
||||
size="sm"
|
||||
pressed={editor.isActive("code")}
|
||||
onPressedChange={() => editor.chain().focus().toggleCode().run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="行内代码"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
@@ -166,9 +182,8 @@ export default function RichEditor({
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("blockquote")}
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().toggleBlockquote().run()
|
||||
}
|
||||
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="引用"
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
@@ -176,9 +191,8 @@ export default function RichEditor({
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("bulletList")}
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().toggleBulletList().run()
|
||||
}
|
||||
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="无序列表"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
@@ -186,9 +200,8 @@ export default function RichEditor({
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("orderedList")}
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="有序列表"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
@@ -199,29 +212,22 @@ export default function RichEditor({
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("codeBlock")}
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().toggleCodeBlock().run()
|
||||
}
|
||||
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="代码块"
|
||||
>
|
||||
<CodeSquare className="h-4 w-4" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("link")}
|
||||
onPressedChange={addLink}
|
||||
aria-label="链接"
|
||||
>
|
||||
<Toggle size="sm" onPressedChange={addLink} disabled={!!isMarkdown} aria-label="链接">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</Toggle>
|
||||
<Toggle size="sm" onPressedChange={addImage} aria-label="图片">
|
||||
<Toggle size="sm" onPressedChange={addImage} disabled={!!isMarkdown} aria-label="图片">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().setHorizontalRule().run()
|
||||
}
|
||||
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
disabled={!!isMarkdown}
|
||||
aria-label="分割线"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
@@ -229,11 +235,10 @@ export default function RichEditor({
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* 撤销/重做 */}
|
||||
<Toggle
|
||||
size="sm"
|
||||
onPressedChange={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
disabled={!!isMarkdown || !editor.can().undo()}
|
||||
aria-label="撤销"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
@@ -241,15 +246,51 @@ export default function RichEditor({
|
||||
<Toggle
|
||||
size="sm"
|
||||
onPressedChange={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
disabled={!!isMarkdown || !editor.can().redo()}
|
||||
aria-label="重做"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Toggle>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Markdown 切换 */}
|
||||
{onSwitchToMarkdown && (
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={!!isMarkdown}
|
||||
onPressedChange={onSwitchToMarkdown}
|
||||
aria-label={isMarkdown ? "切换到富文本" : "Markdown 模式"}
|
||||
title={isMarkdown ? "切换到富文本" : "Markdown 模式"}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Toggle>
|
||||
)}
|
||||
|
||||
{/* 全屏切换 */}
|
||||
{onToggleFullscreen && (
|
||||
<Toggle
|
||||
size="sm"
|
||||
onPressedChange={onToggleFullscreen}
|
||||
aria-label={isFullscreen ? "退出全屏" : "全屏写作"}
|
||||
title={isFullscreen ? "退出全屏" : "全屏写作"}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Toggle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑区域 */}
|
||||
<EditorContent editor={editor} />
|
||||
{/* 内容区域 — 根据模式切换 */}
|
||||
<div className={isFs ? "flex-1 overflow-auto" : ""}>
|
||||
{isMarkdown ? (
|
||||
<MarkdownEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : (
|
||||
<EditorContent editor={editor} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user