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",
|
"gsap": "^3.15.0",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
|
"marked": "^18.0.5",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prisma": "5",
|
"prisma": "5",
|
||||||
|
|||||||
Generated
+10
@@ -50,6 +50,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.21.0
|
specifier: ^1.21.0
|
||||||
version: 1.21.0(react@19.2.4)
|
version: 1.21.0(react@19.2.4)
|
||||||
|
marked:
|
||||||
|
specifier: ^18.0.5
|
||||||
|
version: 18.0.5
|
||||||
next:
|
next:
|
||||||
specifier: 16.2.9
|
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)
|
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:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
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:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -5777,6 +5785,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@18.0.5: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
|
|||||||
+19
-13
@@ -3,24 +3,24 @@ import GsapReveal from "@/components/GsapReveal";
|
|||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "关于",
|
title: "关于",
|
||||||
description: "关于胡旭和这个博客",
|
description: "关于我和这个博客",
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeline = [
|
const timeline = [
|
||||||
{
|
{
|
||||||
year: "2026",
|
year: "2026",
|
||||||
title: "开始写博客",
|
title: "AI 图像生成",
|
||||||
desc: "用 Next.js 搭建个人博客,记录技术与生活",
|
desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
year: "2025",
|
year: "2025",
|
||||||
title: "sui_lightbox 项目",
|
title: "lightbox 项目",
|
||||||
desc: "开发3D灯箱模拟平台,探索标识行业的数字化可能",
|
desc: "开发3D灯箱模拟平台,探索标识行业的数字化可能",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
year: "2024",
|
year: "2024",
|
||||||
title: "AI 图像生成",
|
title: "开始学习AI",
|
||||||
desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境",
|
desc: "从大模型到Dify,探索AI在开发中的应用",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
year: "2023",
|
year: "2023",
|
||||||
@@ -29,8 +29,13 @@ const timeline = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
year: "2022",
|
year: "2022",
|
||||||
title: "前端开发",
|
title: "搭建博客",
|
||||||
desc: "从后端转向全栈,React + TypeScript 成为主力技术栈",
|
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"
|
className="space-y-6 font-body text-base text-ink-light leading-relaxed"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
你好,我是<span className="text-ink font-medium">胡旭</span>
|
你好,我是<span className="text-ink font-medium">Sui</span>
|
||||||
,一个来自安徽六安的前端开发者。
|
,一个00后废柴青年兼前端开发者。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
我对 AI 图像生成、Web 3D
|
我对 AI 图像生成、Web 3D
|
||||||
@@ -69,7 +74,7 @@ export default function AboutPage() {
|
|||||||
当你试图把一个想法写下来的时候,你会发现自己对它的理解远比想象中要浅。
|
当你试图把一个想法写下来的时候,你会发现自己对它的理解远比想象中要浅。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-ink-muted">
|
<p className="text-ink-muted">
|
||||||
如果你有任何想法或合作意向,欢迎通过以下方式联系我。
|
如果你有任何想法或意见,欢迎通过以下方式联系我。
|
||||||
</p>
|
</p>
|
||||||
</GsapReveal>
|
</GsapReveal>
|
||||||
|
|
||||||
@@ -204,8 +209,9 @@ export default function AboutPage() {
|
|||||||
这个博客使用 <span className="text-terracotta">Next.js 16</span>{" "}
|
这个博客使用 <span className="text-terracotta">Next.js 16</span>{" "}
|
||||||
构建,样式基于{" "}
|
构建,样式基于{" "}
|
||||||
<span className="text-terracotta">Tailwind CSS 4</span>
|
<span className="text-terracotta">Tailwind CSS 4</span>
|
||||||
,数据存储在本地 SQLite 数据库中。字体使用了 Noto Serif SC(宋体)与
|
,数据存储在本地 SQLite 数据库中。字体使用了 Noto Serif
|
||||||
Cormorant Garamond 的组合,追求一种接近纸质杂志的阅读体验。
|
SC(宋体)与 Cormorant Garamond
|
||||||
|
的组合,追求一种接近纸质杂志的阅读体验。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</GsapReveal>
|
</GsapReveal>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function PostsPage() {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [counts, setCounts] = useState({ all: 0, published: 0, draft: 0 });
|
const [counts, setCounts] = useState({ all: 0, published: 0, draft: 0 });
|
||||||
const pageSize = 20;
|
const pageSize = 10;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// 搜索 debounce:300ms 后才更新 debouncedSearch
|
// 搜索 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 type { PublicPost } from "@/lib/store";
|
||||||
import { formatDate, readingTimeLabel } from "@/lib/utils";
|
import { formatDate, readingTimeLabel } from "@/lib/utils";
|
||||||
import { useGsapAnimation } from "./useGsapAnimation";
|
import { useGsapAnimation } from "./useGsapAnimation";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ export default function BlogList({ posts }: BlogListProps) {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const activeCategory = searchParams.get("category") || "";
|
const activeCategory = searchParams.get("category") || "";
|
||||||
const activeTag = searchParams.get("tag") || "";
|
const activeTag = searchParams.get("tag") || "";
|
||||||
|
const page = Number(searchParams.get("page")) || 1;
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
const headerRef = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
|
const headerRef = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
|
||||||
if (isReduced) return;
|
if (isReduced) return;
|
||||||
@@ -66,6 +69,13 @@ export default function BlogList({ posts }: BlogListProps) {
|
|||||||
});
|
});
|
||||||
}, [posts, activeCategory, activeTag]);
|
}, [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) {
|
function setFilter(key: "category" | "tag", value: string) {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
if (value) params.set(key, value);
|
if (value) params.set(key, value);
|
||||||
@@ -73,6 +83,15 @@ export default function BlogList({ posts }: BlogListProps) {
|
|||||||
// 切换一个维度时清除另一个,避免组合空结果困惑
|
// 切换一个维度时清除另一个,避免组合空结果困惑
|
||||||
if (key === "category") params.delete("tag");
|
if (key === "category") params.delete("tag");
|
||||||
if (key === "tag") params.delete("category");
|
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();
|
const qs = params.toString();
|
||||||
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
|
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
|
||||||
}
|
}
|
||||||
@@ -136,12 +155,12 @@ export default function BlogList({ posts }: BlogListProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Post list */}
|
{/* Post list */}
|
||||||
{filtered.length > 0 ? (
|
{paged.length > 0 ? (
|
||||||
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-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">
|
<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">
|
<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">
|
<div className="shrink-0 md:w-36 md:pt-1">
|
||||||
<time className="font-sans text-sm text-ink-muted tabular-nums">
|
<time className="font-sans text-sm text-ink-muted tabular-nums">
|
||||||
{formatDate(post.date)}
|
{formatDate(post.date)}
|
||||||
@@ -168,6 +187,12 @@ export default function BlogList({ posts }: BlogListProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<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" />
|
<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>
|
<p className="font-sans text-sm text-ink-muted">该筛选条件下暂无文章。</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,18 @@ export default function PostContent({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 */}
|
{/* Decorative divider */}
|
||||||
<div className="max-w-2xl mx-auto mb-14">
|
<div className="max-w-2xl mx-auto mb-14">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<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";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
const RichEditor = dynamic(() => import("./RichEditor"), { ssr: false });
|
const RichEditor = dynamic(() => import("./RichEditor"), { ssr: false });
|
||||||
|
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), { ssr: false });
|
||||||
|
import AiAssistant from "./AiAssistant";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -50,6 +52,7 @@ export default function PostForm({
|
|||||||
slug: initialData?.slug ?? "",
|
slug: initialData?.slug ?? "",
|
||||||
excerpt: initialData?.excerpt ?? "",
|
excerpt: initialData?.excerpt ?? "",
|
||||||
content: initialData?.content ?? "",
|
content: initialData?.content ?? "",
|
||||||
|
coverImage: initialData?.coverImage ?? "",
|
||||||
category: initialData?.category ?? "",
|
category: initialData?.category ?? "",
|
||||||
tags: initialData?.tags ?? ([] as string[]),
|
tags: initialData?.tags ?? ([] as string[]),
|
||||||
readingTime: initialData?.readingTime ?? 5,
|
readingTime: initialData?.readingTime ?? 5,
|
||||||
@@ -63,6 +66,17 @@ export default function PostForm({
|
|||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const originalRef = useRef(JSON.stringify(form));
|
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(() => {
|
useEffect(() => {
|
||||||
if (!dirty) return;
|
if (!dirty) return;
|
||||||
@@ -80,6 +94,83 @@ export default function PostForm({
|
|||||||
}
|
}
|
||||||
}, [form]);
|
}, [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 {
|
function validate(): boolean {
|
||||||
const errs: Record<string, string> = {};
|
const errs: Record<string, string> = {};
|
||||||
if (!form.title.trim()) errs.title = "请输入标题";
|
if (!form.title.trim()) errs.title = "请输入标题";
|
||||||
@@ -99,6 +190,9 @@ export default function PostForm({
|
|||||||
try {
|
try {
|
||||||
const slug = form.slug || autoSlug(form.title);
|
const slug = form.slug || autoSlug(form.title);
|
||||||
await onSubmit({ ...form, slug });
|
await onSubmit({ ...form, slug });
|
||||||
|
// 保存成功,清除草稿
|
||||||
|
localStorage.removeItem(autoSaveKey);
|
||||||
|
setDirty(false);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -125,8 +219,52 @@ export default function PostForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 全屏专注模式 ──
|
||||||
|
if (isFullscreen) {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<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 ref={formRef} onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* 标题 */}
|
{/* 标题 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="post-title">标题</Label>
|
<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>}
|
{errors.excerpt && <p className="text-xs text-red-600">{errors.excerpt}</p>}
|
||||||
</div>
|
</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">
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Label>内容</Label>
|
<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
|
<RichEditor
|
||||||
value={form.content}
|
value={form.content}
|
||||||
onChange={(html) => update("content", html)}
|
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>}
|
{errors.content && <p className="text-xs text-red-600">{errors.content}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -270,14 +446,25 @@ export default function PostForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex items-center gap-3 pt-4">
|
||||||
<Button type="submit" disabled={submitting}>
|
<Button type="submit" disabled={submitting}>
|
||||||
{submitting ? "保存中..." : mode === "create" ? "保存" : "保存修改"}
|
{submitting ? "保存中..." : mode === "create" ? "保存" : "保存修改"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷键提示 */}
|
||||||
|
<p className="font-sans text-xs text-muted-foreground/60">
|
||||||
|
快捷键:Ctrl+S 保存 · Ctrl+Enter 保存
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Placeholder from "@tiptap/extension-placeholder";
|
|||||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||||
import { common, createLowlight } from "lowlight";
|
import { common, createLowlight } from "lowlight";
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
Italic,
|
Italic,
|
||||||
@@ -25,20 +26,33 @@ import {
|
|||||||
Minus,
|
Minus,
|
||||||
Undo2,
|
Undo2,
|
||||||
Redo2,
|
Redo2,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), { ssr: false });
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
|
|
||||||
interface RichEditorProps {
|
interface RichEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (html: string) => void;
|
onChange: (html: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
isFullscreen?: boolean;
|
||||||
|
isMarkdown?: boolean;
|
||||||
|
onToggleFullscreen?: () => void;
|
||||||
|
onSwitchToMarkdown?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RichEditor({
|
export default function RichEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "开始写文章...",
|
placeholder = "开始写文章...",
|
||||||
|
isFullscreen,
|
||||||
|
isMarkdown,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onSwitchToMarkdown,
|
||||||
}: RichEditorProps) {
|
}: RichEditorProps) {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -63,8 +77,7 @@ export default function RichEditor({
|
|||||||
},
|
},
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class:
|
class: "prose-literary min-h-[300px] px-4 py-3 focus:outline-none",
|
||||||
"prose-literary min-h-[300px] px-4 py-3 focus:outline-none",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -90,17 +103,18 @@ export default function RichEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFs = !!isFullscreen;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
<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 px-2 py-1.5 border-b border-border bg-muted/30">
|
<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
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("heading", { level: 1 })}
|
pressed={editor.isActive("heading", { level: 1 })}
|
||||||
onPressedChange={() =>
|
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
disabled={!!isMarkdown}
|
||||||
}
|
|
||||||
aria-label="标题 1"
|
aria-label="标题 1"
|
||||||
>
|
>
|
||||||
<Heading1 className="h-4 w-4" />
|
<Heading1 className="h-4 w-4" />
|
||||||
@@ -108,9 +122,8 @@ export default function RichEditor({
|
|||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("heading", { level: 2 })}
|
pressed={editor.isActive("heading", { level: 2 })}
|
||||||
onPressedChange={() =>
|
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
disabled={!!isMarkdown}
|
||||||
}
|
|
||||||
aria-label="标题 2"
|
aria-label="标题 2"
|
||||||
>
|
>
|
||||||
<Heading2 className="h-4 w-4" />
|
<Heading2 className="h-4 w-4" />
|
||||||
@@ -118,9 +131,8 @@ export default function RichEditor({
|
|||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("heading", { level: 3 })}
|
pressed={editor.isActive("heading", { level: 3 })}
|
||||||
onPressedChange={() =>
|
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
disabled={!!isMarkdown}
|
||||||
}
|
|
||||||
aria-label="标题 3"
|
aria-label="标题 3"
|
||||||
>
|
>
|
||||||
<Heading3 className="h-4 w-4" />
|
<Heading3 className="h-4 w-4" />
|
||||||
@@ -132,6 +144,7 @@ export default function RichEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("bold")}
|
pressed={editor.isActive("bold")}
|
||||||
onPressedChange={() => editor.chain().focus().toggleBold().run()}
|
onPressedChange={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
aria-label="粗体"
|
aria-label="粗体"
|
||||||
>
|
>
|
||||||
<Bold className="h-4 w-4" />
|
<Bold className="h-4 w-4" />
|
||||||
@@ -140,6 +153,7 @@ export default function RichEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("italic")}
|
pressed={editor.isActive("italic")}
|
||||||
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
|
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
aria-label="斜体"
|
aria-label="斜体"
|
||||||
>
|
>
|
||||||
<Italic className="h-4 w-4" />
|
<Italic className="h-4 w-4" />
|
||||||
@@ -148,6 +162,7 @@ export default function RichEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("strike")}
|
pressed={editor.isActive("strike")}
|
||||||
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
|
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
aria-label="删除线"
|
aria-label="删除线"
|
||||||
>
|
>
|
||||||
<Strikethrough className="h-4 w-4" />
|
<Strikethrough className="h-4 w-4" />
|
||||||
@@ -156,6 +171,7 @@ export default function RichEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("code")}
|
pressed={editor.isActive("code")}
|
||||||
onPressedChange={() => editor.chain().focus().toggleCode().run()}
|
onPressedChange={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
aria-label="行内代码"
|
aria-label="行内代码"
|
||||||
>
|
>
|
||||||
<Code className="h-4 w-4" />
|
<Code className="h-4 w-4" />
|
||||||
@@ -166,9 +182,8 @@ export default function RichEditor({
|
|||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("blockquote")}
|
pressed={editor.isActive("blockquote")}
|
||||||
onPressedChange={() =>
|
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
editor.chain().focus().toggleBlockquote().run()
|
disabled={!!isMarkdown}
|
||||||
}
|
|
||||||
aria-label="引用"
|
aria-label="引用"
|
||||||
>
|
>
|
||||||
<Quote className="h-4 w-4" />
|
<Quote className="h-4 w-4" />
|
||||||
@@ -176,9 +191,8 @@ export default function RichEditor({
|
|||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("bulletList")}
|
pressed={editor.isActive("bulletList")}
|
||||||
onPressedChange={() =>
|
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
editor.chain().focus().toggleBulletList().run()
|
disabled={!!isMarkdown}
|
||||||
}
|
|
||||||
aria-label="无序列表"
|
aria-label="无序列表"
|
||||||
>
|
>
|
||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
@@ -186,9 +200,8 @@ export default function RichEditor({
|
|||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("orderedList")}
|
pressed={editor.isActive("orderedList")}
|
||||||
onPressedChange={() =>
|
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
editor.chain().focus().toggleOrderedList().run()
|
disabled={!!isMarkdown}
|
||||||
}
|
|
||||||
aria-label="有序列表"
|
aria-label="有序列表"
|
||||||
>
|
>
|
||||||
<ListOrdered className="h-4 w-4" />
|
<ListOrdered className="h-4 w-4" />
|
||||||
@@ -199,29 +212,22 @@ export default function RichEditor({
|
|||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
pressed={editor.isActive("codeBlock")}
|
pressed={editor.isActive("codeBlock")}
|
||||||
onPressedChange={() =>
|
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
editor.chain().focus().toggleCodeBlock().run()
|
disabled={!!isMarkdown}
|
||||||
}
|
|
||||||
aria-label="代码块"
|
aria-label="代码块"
|
||||||
>
|
>
|
||||||
<CodeSquare className="h-4 w-4" />
|
<CodeSquare className="h-4 w-4" />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
<Toggle
|
<Toggle size="sm" onPressedChange={addLink} disabled={!!isMarkdown} aria-label="链接">
|
||||||
size="sm"
|
|
||||||
pressed={editor.isActive("link")}
|
|
||||||
onPressedChange={addLink}
|
|
||||||
aria-label="链接"
|
|
||||||
>
|
|
||||||
<LinkIcon className="h-4 w-4" />
|
<LinkIcon className="h-4 w-4" />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
<Toggle size="sm" onPressedChange={addImage} aria-label="图片">
|
<Toggle size="sm" onPressedChange={addImage} disabled={!!isMarkdown} aria-label="图片">
|
||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
onPressedChange={() =>
|
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
|
||||||
editor.chain().focus().setHorizontalRule().run()
|
disabled={!!isMarkdown}
|
||||||
}
|
|
||||||
aria-label="分割线"
|
aria-label="分割线"
|
||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
@@ -229,11 +235,10 @@ export default function RichEditor({
|
|||||||
|
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
|
|
||||||
{/* 撤销/重做 */}
|
|
||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
onPressedChange={() => editor.chain().focus().undo().run()}
|
onPressedChange={() => editor.chain().focus().undo().run()}
|
||||||
disabled={!editor.can().undo()}
|
disabled={!!isMarkdown || !editor.can().undo()}
|
||||||
aria-label="撤销"
|
aria-label="撤销"
|
||||||
>
|
>
|
||||||
<Undo2 className="h-4 w-4" />
|
<Undo2 className="h-4 w-4" />
|
||||||
@@ -241,15 +246,51 @@ export default function RichEditor({
|
|||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
onPressedChange={() => editor.chain().focus().redo().run()}
|
onPressedChange={() => editor.chain().focus().redo().run()}
|
||||||
disabled={!editor.can().redo()}
|
disabled={!!isMarkdown || !editor.can().redo()}
|
||||||
aria-label="重做"
|
aria-label="重做"
|
||||||
>
|
>
|
||||||
<Redo2 className="h-4 w-4" />
|
<Redo2 className="h-4 w-4" />
|
||||||
</Toggle>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 编辑区域 */}
|
{/* 内容区域 — 根据模式切换 */}
|
||||||
|
<div className={isFs ? "flex-1 overflow-auto" : ""}>
|
||||||
|
{isMarkdown ? (
|
||||||
|
<MarkdownEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user