feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等

This commit is contained in:
2026-10-09 18:53:38 +08:00
parent 5598e1cbb7
commit da72843c70
64 changed files with 7047 additions and 248 deletions

View File

@@ -0,0 +1,331 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, CodeXml, Eye, EyeOff, Trash2 } from "lucide-react";
import { apiCreateSnippet, apiUpdateSnippet, apiDeleteSnippet, fetchSnippetLanguages, type SnippetInput, type SnippetVisibility } from "@/lib/api";
import { toast } from "@/lib/toast";
import { snippetPath, snippetsPath, type UrlStyle } from "@/lib/urlStyle";
import Modal from "@/components/Modal";
import SnippetLangSelect from "@/components/SnippetLangSelect";
import { hasAuthCookieHint } from "@/lib/cookies";
import { useSiteBrand } from "@/components/SiteBrandProvider";
const TITLE_MAX = 120;
const CODE_MAX = 64 * 1024; // 64KB
const DESC_MAX = 8 * 1024; // 8KB
const VISIBILITY_OPTIONS: { value: SnippetVisibility; label: string; desc: string }[] = [
{ value: "private", label: "私有", desc: "仅自己可见" },
{ value: "public", label: "公开", desc: "所有人可见,出现在广场" },
];
export default function SnippetEditor({
mode,
snippetId,
initialTitle = "",
initialLanguage = "plaintext",
initialDescription = "",
initialCode = "",
initialVisibility = "private",
}: {
mode: "create" | "edit";
snippetId?: number;
initialTitle?: string;
initialLanguage?: string;
initialDescription?: string;
initialCode?: string;
initialVisibility?: SnippetVisibility;
}) {
const router = useRouter();
const { url_style: urlStyle } = useSiteBrand();
const [title, setTitle] = useState(initialTitle);
const [language, setLanguage] = useState(initialLanguage);
const [description, setDescription] = useState(initialDescription);
const [code, setCode] = useState(initialCode);
const [visibility, setVisibility] = useState<SnippetVisibility>(initialVisibility);
const [languages, setLanguages] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const codeRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (!hasAuthCookieHint()) {
router.push("/login?redirect=/snippets/new");
return;
}
fetchSnippetLanguages()
.then((res) => setLanguages(res.languages))
.catch(() => {});
}, [router]);
// Tab 键缩进
const handleCodeKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Tab") {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart;
const end = el.selectionEnd;
const newValue = code.substring(0, start) + " " + code.substring(end);
setCode(newValue);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 2;
});
}
};
const codeBytes = new TextEncoder().encode(code).length;
const descBytes = new TextEncoder().encode(description).length;
const canSubmit =
!loading &&
title.trim().length > 0 &&
code.trim().length > 0 &&
codeBytes <= CODE_MAX &&
descBytes <= DESC_MAX;
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!canSubmit) return;
setLoading(true);
const input: SnippetInput = {
title: title.trim(),
language,
description: description.trim(),
code: code.trim(),
visibility,
};
try {
if (mode === "create") {
const res = await apiCreateSnippet(input);
toast("创建成功", "ok");
router.push(snippetPath(urlStyle, res.snippet.id));
} else if (snippetId) {
const res = await apiUpdateSnippet(snippetId, input);
toast("已保存", "ok");
router.push(snippetPath(urlStyle, res.snippet.id));
}
} catch (err) {
toast(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!snippetId || deleting) return;
setDeleting(true);
try {
await apiDeleteSnippet(snippetId);
toast("已删除", "ok");
router.push(snippetsPath(urlStyle));
} catch (err) {
toast(err instanceof Error ? err.message : "删除失败");
} finally {
setDeleting(false);
setDeleteOpen(false);
}
};
return (
<form onSubmit={handleSubmit} className="j13-compose-stage is-studio">
<header className="j13-compose-toolbar">
<div className="j13-compose-toolbar-lead">
<button
type="button"
onClick={() => router.back()}
className="j13-compose-back"
aria-label="返回"
data-tip="返回"
>
<ArrowLeft size={18} strokeWidth={2.2} />
</button>
<div className="j13-compose-toolbar-copy min-w-0">
<h1 className="j13-compose-toolbar-title">
{mode === "create" ? "新建片段" : "编辑片段"}
</h1>
<p className="j13-compose-toolbar-sub">
{mode === "create" ? "保存一段可复用的代码" : "修改片段内容"}
</p>
</div>
</div>
<div className="j13-compose-toolbar-actions">
{mode === "edit" && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
className="btn btn-line btn-sm"
style={{ color: "var(--danger)" }}
>
<Trash2 size={14} /> 删除
</button>
)}
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary min-w-[5.5rem] h-9 px-4 text-[13.5px] font-semibold"
>
{loading ? "保存中…" : mode === "create" ? "创建" : "保存"}
</button>
</div>
</header>
<div className="j13-compose-studio">
<div className="j13-compose-studio-canvas">
{/* 标题 */}
<div className="j13-compose-title-row">
<label className="sr-only" htmlFor="snippet-title">
标题
</label>
<input
id="snippet-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value.slice(0, TITLE_MAX))}
required
maxLength={TITLE_MAX}
placeholder="片段标题"
className="j13-compose-title"
disabled={loading}
/>
<span className="j13-compose-title-count tabular-nums">
{title.length}/{TITLE_MAX}
</span>
</div>
{/* 语言 + 可见性 */}
<div className="flex flex-wrap items-center gap-3 px-4 sm:px-6 py-3">
<div className="flex items-center gap-2">
<CodeXml size={14} style={{ color: "var(--ink-3)" }} />
<SnippetLangSelect
value={language}
onChange={setLanguage}
languages={languages}
allowAll={false}
variant="editor"
ariaLabel="编程语言"
disabled={loading}
/>
</div>
<div className="flex items-center gap-1 rounded-full p-0.5" style={{ background: "var(--panel-2)" }}>
{VISIBILITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setVisibility(opt.value)}
className={`px-3 py-1 rounded-full text-[12px] font-medium transition-colors ${
visibility === opt.value ? "" : "hover:bg-[var(--panel)]"
}`}
style={
visibility === opt.value
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { color: "var(--ink-2)" }
}
disabled={loading}
>
{opt.value === "public" ? <Eye size={12} className="inline mr-1" /> : <EyeOff size={12} className="inline mr-1" />}
{opt.label}
</button>
))}
</div>
<span className="meta text-[11.5px]">
{VISIBILITY_OPTIONS.find((o) => o.value === visibility)?.desc}
</span>
</div>
{/* 代码编辑区 */}
<div className="j13-compose-canvas">
<label className="sr-only" htmlFor="snippet-code">
代码
</label>
<textarea
ref={codeRef}
id="snippet-code"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={handleCodeKeyDown}
placeholder="粘贴或输入代码…"
className="w-full min-h-[320px] px-4 py-3 text-[13.5px] font-mono resize-y"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
color: "var(--ink)",
borderRadius: "var(--r)",
tabSize: 2,
}}
disabled={loading}
/>
<div className="mt-1 flex justify-end">
<span
className="meta text-[11px] tabular-nums"
style={codeBytes > CODE_MAX ? { color: "var(--danger)", fontWeight: 600 } : undefined}
>
{codeBytes > 1024 ? `${(codeBytes / 1024).toFixed(1)} KB` : `${codeBytes} B`} / 64 KB
</span>
</div>
</div>
{/* 描述 */}
<div className="px-4 sm:px-6 pb-6">
<label className="field-label" htmlFor="snippet-desc">
描述(可选)
</label>
<textarea
id="snippet-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="用 Markdown 描述这段代码的用途、用法或注意事项…"
rows={4}
className="w-full rounded-lg px-4 py-3 text-[13.5px]"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
color: "var(--ink)",
}}
disabled={loading}
/>
<div className="mt-1 flex justify-end">
<span
className="meta text-[11px] tabular-nums"
style={descBytes > DESC_MAX ? { color: "var(--danger)", fontWeight: 600 } : undefined}
>
{descBytes > 1024 ? `${(descBytes / 1024).toFixed(1)} KB` : `${descBytes} B`} / 8 KB
</span>
</div>
</div>
</div>
</div>
<Modal
open={deleteOpen}
title="删除片段"
description="删除后不可恢复,确定要删除这个代码片段吗?"
onClose={() => setDeleteOpen(false)}
dismissOnOverlay
maxWidthClass="max-w-sm"
footer={
<>
<button type="button" className="btn btn-line btn-sm" onClick={() => setDeleteOpen(false)}>
取消
</button>
<button
type="button"
className="btn btn-primary btn-sm"
style={{ background: "var(--danger)", borderColor: "var(--danger)" }}
onClick={handleDelete}
disabled={deleting}
>
{deleting ? "删除中…" : "确认删除"}
</button>
</>
}
>
<p className="text-[13.5px]" style={{ color: "var(--ink-2)" }}>
此操作不可撤销。
</p>
</Modal>
</form>
);
}