- Monorepo: NestJS API (Fastify) + React web (antd/ProComponents) + shared packages - PostgreSQL/Prisma (unified resources, relations graph, audit, events, jobs) - Auth/RBAC/workspace isolation, credential vault (AES-256-GCM) - Servers/SSH, Docker discovery & lifecycle, Job Center - Websites & deployments (git provider credential clone, env, health verification) - Domains/DNS (Cloudflare adapter), certificates (TLS probe), monitoring - Automation (cron/event/webhook HMAC + notifications), AI Gateway, MCP (29 tools) - AI chat window (LLM function-calling + rules fallback, SSE streaming, approvals) - Notes: Vditor WYSIWYG markdown editor, non-blocking autosave - Tests: 35 unit/security tests; UI audit clean (desktop+mobile, deep links)
476 lines
17 KiB
TypeScript
476 lines
17 KiB
TypeScript
/**
|
||
* # 笔记(v3):BlockNote 编辑器(主流 Notion 风格开源编辑器)
|
||
* - 斜杠菜单(/)、选中浮动工具栏、拖拽手柄、任务列表 —— 主流交互开箱即用
|
||
* - Markdown 进出(blocksToMarkdownLossy / tryParseMarkdownToBlocks),存储为 markdown
|
||
* - **非阻塞保存**:保存请求后台执行(队列 + 防抖),切换笔记零卡顿
|
||
* - AI 助手:续写 / 总结 / 润色 / 翻译 / 大纲 / 起标题(结果以 markdown 回填)
|
||
*/
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { PageContainer } from "@ant-design/pro-components";
|
||
import {
|
||
App as AntApp,
|
||
Button,
|
||
Dropdown,
|
||
Empty,
|
||
Input,
|
||
List,
|
||
Popconfirm,
|
||
Space,
|
||
Spin,
|
||
Tag,
|
||
Tooltip,
|
||
Typography,
|
||
} from "antd";
|
||
import {
|
||
DeleteOutlined,
|
||
PlusOutlined,
|
||
RobotOutlined,
|
||
SearchOutlined,
|
||
StarFilled,
|
||
StarOutlined,
|
||
} from "@ant-design/icons";
|
||
import Vditor from "vditor";
|
||
import "vditor/dist/index.css";
|
||
import dayjs from "dayjs";
|
||
import { noteApi } from "../services/api";
|
||
import type { NoteListItem } from "../services/api";
|
||
import { useThemeStore } from "../stores/theme";
|
||
|
||
/** # AI 动作定义:replaces = true 整篇替换 / false 追加到末尾 / "title" 设标题 **/
|
||
const AI_ACTIONS: { key: string; label: string; replaces: boolean | "title" }[] = [
|
||
{ key: "continue", label: "✍️ 续写(追加到末尾)", replaces: false },
|
||
{ key: "summarize", label: "📌 总结要点", replaces: true },
|
||
{ key: "improve", label: "✨ 润色全文", replaces: true },
|
||
{ key: "translate_en", label: "🇬🇧 翻译成英文", replaces: true },
|
||
{ key: "translate_zh", label: "🇨🇳 翻译成中文", replaces: true },
|
||
{ key: "outline", label: "🗂️ 生成大纲", replaces: true },
|
||
{ key: "title", label: "🏷️ 起标题", replaces: "title" },
|
||
];
|
||
|
||
/** # 遗留 HTML 内容 → 纯文本(旧数据兼容) **/
|
||
function legacyToMarkdown(content: string): string {
|
||
const trimmed = (content ?? "").trim();
|
||
if (!trimmed) return "";
|
||
if (trimmed.startsWith("<")) {
|
||
return trimmed
|
||
.replace(/<\/(p|h[1-6]|li|div)>/gi, "\n\n")
|
||
.replace(/<li[^>]*>/gi, "- ")
|
||
.replace(/<[^>]+>/g, "")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim();
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
export default function NotesPage() {
|
||
const { message } = AntApp.useApp();
|
||
const queryClient = useQueryClient();
|
||
const { mode } = useThemeStore();
|
||
const dark = mode === "dark";
|
||
|
||
const [activeId, setActiveId] = useState<string | null>(null);
|
||
const [search, setSearch] = useState("");
|
||
const [title, setTitle] = useState("");
|
||
const [tags, setTags] = useState<string[]>([]);
|
||
const [pinned, setPinned] = useState(false);
|
||
const [saveState, setSaveState] = useState<"idle" | "pending" | "saving" | "saved">("idle");
|
||
const [aiRunning, setAiRunning] = useState<string | null>(null);
|
||
const [editorReady, setEditorReadyState] = useState(false);
|
||
/** # 编辑器就绪前暂存待加载内容 **/
|
||
const pendingContentRef = useRef<string | null>(null);
|
||
const composingRef = useRef(false);
|
||
|
||
/** # Vditor 编辑器实例 **/
|
||
const editorRef = useRef<Vditor | null>(null);
|
||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
/** # 非阻塞保存队列:防抖 + 后台执行 + 完成后再补发 **/
|
||
const pendingRef = useRef<{ id: string; dto: Parameters<typeof noteApi.update>[1] } | null>(null);
|
||
const inFlightRef = useRef(false);
|
||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const loadingRef = useRef(false);
|
||
|
||
const flush = useCallback(() => {
|
||
if (inFlightRef.current) return;
|
||
const job = pendingRef.current;
|
||
if (!job) return;
|
||
pendingRef.current = null;
|
||
inFlightRef.current = true;
|
||
setSaveState("saving");
|
||
noteApi
|
||
.update(job.id, job.dto)
|
||
.then(() => setSaveState("saved"))
|
||
.catch((e) => {
|
||
setSaveState("idle");
|
||
message.error(`保存失败:${e instanceof Error ? e.message : e}`);
|
||
})
|
||
.finally(() => {
|
||
inFlightRef.current = false;
|
||
if (pendingRef.current) flush();
|
||
});
|
||
}, [message]);
|
||
|
||
const queueSave = useCallback(
|
||
(immediate = false) => {
|
||
if (!activeId) return;
|
||
pendingRef.current = {
|
||
id: activeId,
|
||
dto: { title: title || "无标题", tags, pinned },
|
||
};
|
||
setSaveState("pending");
|
||
if (timerRef.current) clearTimeout(timerRef.current);
|
||
timerRef.current = setTimeout(flush, immediate ? 0 : 900);
|
||
},
|
||
[activeId, title, tags, pinned, flush],
|
||
);
|
||
|
||
/** # 编辑器内容变更(Vditor input 回调;排除程序化加载) **/
|
||
const onVditorInput = useCallback(() => {
|
||
if (loadingRef.current) return;
|
||
setSaveState("pending");
|
||
if (timerRef.current) clearTimeout(timerRef.current);
|
||
timerRef.current = setTimeout(flush, 900);
|
||
}, [flush]);
|
||
|
||
/** # 初始化 Vditor(主题切换时重建) **/
|
||
useEffect(() => {
|
||
if (!mountRef.current || editorRef.current) return;
|
||
const vditor = new Vditor(mountRef.current, {
|
||
mode: "wysiwyg",
|
||
theme: dark ? "dark" : "classic",
|
||
height: "52vh",
|
||
placeholder: "开始写点什么…(/ 唤起菜单;支持 Markdown 快捷输入)",
|
||
cache: { enable: false },
|
||
value: "",
|
||
after: () => {
|
||
setEditorReadyState(true);
|
||
if (pendingContentRef.current !== null) {
|
||
vditor.setValue(pendingContentRef.current);
|
||
pendingContentRef.current = null;
|
||
}
|
||
},
|
||
input: () => onVditorInput(),
|
||
counter: { enable: true },
|
||
toolbar: [
|
||
"headings", "bold", "italic", "strike", "|",
|
||
"list", "ordered-list", "check", "outdent", "indent", "|",
|
||
"quote", "code", "inline-code", "link", "table", "|",
|
||
"undo", "redo", "|", "edit-mode", "export",
|
||
],
|
||
});
|
||
editorRef.current = vditor;
|
||
return () => {
|
||
vditor.destroy();
|
||
editorRef.current = null;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [dark]);
|
||
|
||
/** # 笔记列表 **/
|
||
const { data: notes, isLoading: listLoading } = useQuery({
|
||
queryKey: ["notes", search],
|
||
queryFn: () => noteApi.list(search ? { q: search } : {}),
|
||
});
|
||
|
||
/** # 打开笔记:后台补发未存内容 + 立即加载(零等待) **/
|
||
const openNote = useCallback(
|
||
async (item: NoteListItem) => {
|
||
if (item.id === activeId) return;
|
||
// 未保存内容后台补发,不阻塞切换
|
||
if (pendingRef.current || saveState === "pending") queueSave(true);
|
||
loadingRef.current = true;
|
||
setActiveId(item.id);
|
||
setTitle(item.title);
|
||
setTags((item.tags ?? []) as string[]);
|
||
setPinned(item.pinned);
|
||
setSaveState("idle");
|
||
try {
|
||
const full = await noteApi.get(item.id);
|
||
setTitle(full.title);
|
||
setTags((full.tags ?? []) as string[]);
|
||
setPinned(full.pinned);
|
||
if (editorRef.current && editorReady) {
|
||
editorRef.current.setValue(legacyToMarkdown(full.content));
|
||
} else {
|
||
pendingContentRef.current = legacyToMarkdown(full.content);
|
||
}
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : "加载失败");
|
||
} finally {
|
||
loadingRef.current = false;
|
||
}
|
||
},
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[activeId, editorReady, saveState, queueSave],
|
||
);
|
||
|
||
/** # 新建笔记 **/
|
||
const createNote = async () => {
|
||
try {
|
||
const created = await noteApi.create({ title: "无标题", content: "", tags: [] });
|
||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||
setActiveId(created.id);
|
||
setTitle(created.title);
|
||
setTags([]);
|
||
setPinned(false);
|
||
if (editorRef.current && editorReady) editorRef.current.setValue("");
|
||
else pendingContentRef.current = "";
|
||
message.success("已创建");
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : "创建失败");
|
||
}
|
||
};
|
||
|
||
/** # 删除笔记 **/
|
||
const removeNote = async (id: string) => {
|
||
pendingRef.current = null;
|
||
await noteApi.delete(id);
|
||
if (activeId === id) {
|
||
setActiveId(null);
|
||
if (editorRef.current && editorReady) editorRef.current.setValue("");
|
||
setTitle("");
|
||
}
|
||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||
message.success("已删除");
|
||
};
|
||
|
||
/** # 切换置顶 **/
|
||
const togglePin = async () => {
|
||
if (!activeId) return;
|
||
const next = !pinned;
|
||
setPinned(next);
|
||
pendingRef.current = { id: activeId, dto: { pinned: next } };
|
||
flush();
|
||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||
};
|
||
|
||
/** # AI 动作 **/
|
||
const runAi = async (action: string, replaces: boolean | "title") => {
|
||
if (!activeId) return;
|
||
setAiRunning(action);
|
||
try {
|
||
const res = await noteApi.ai(activeId, { action });
|
||
if (replaces === "title") {
|
||
const t = res.result.replace(/^["'#\s]+|["'\s]+$/g, "").slice(0, 60);
|
||
setTitle(t);
|
||
pendingRef.current = { id: activeId!, dto: { title: t } };
|
||
flush();
|
||
message.success("标题已生成");
|
||
return;
|
||
}
|
||
if (editorRef.current && editorReady) {
|
||
if (replaces) editorRef.current.setValue(res.result);
|
||
else editorRef.current.insertValue("\n\n" + res.result);
|
||
}
|
||
queueSave(true);
|
||
message.success(replaces ? "已替换全文" : "已追加到末尾");
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : "AI 执行失败");
|
||
} finally {
|
||
setAiRunning(null);
|
||
}
|
||
};
|
||
|
||
const sidebarItems = notes ?? [];
|
||
|
||
return (
|
||
<PageContainer ghost>
|
||
<div style={{ display: "flex", gap: 16, minHeight: "calc(100vh - 160px)" }}>
|
||
{/* ── 左栏 ── */}
|
||
<div
|
||
style={{
|
||
width: 290,
|
||
flexShrink: 0,
|
||
border: "1px solid rgba(128,128,128,0.2)",
|
||
borderRadius: 8,
|
||
padding: 12,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
maxHeight: "calc(100vh - 160px)",
|
||
}}
|
||
>
|
||
<Space.Compact style={{ marginBottom: 8 }}>
|
||
<Input
|
||
prefix={<SearchOutlined />}
|
||
placeholder="搜索标题与正文"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
allowClear
|
||
/>
|
||
<Tooltip title="新建笔记">
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={createNote} />
|
||
</Tooltip>
|
||
</Space.Compact>
|
||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||
{listLoading ? (
|
||
<Spin style={{ display: "block", margin: "40px auto" }} />
|
||
) : sidebarItems.length === 0 ? (
|
||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有笔记" />
|
||
) : (
|
||
<List
|
||
size="small"
|
||
dataSource={sidebarItems}
|
||
renderItem={(item) => (
|
||
<List.Item
|
||
style={{
|
||
cursor: "pointer",
|
||
padding: "6px 8px",
|
||
borderRadius: 6,
|
||
background: item.id === activeId ? "rgba(79,70,229,0.12)" : undefined,
|
||
}}
|
||
onClick={() => void openNote(item)}
|
||
actions={[
|
||
<Popconfirm
|
||
key="del"
|
||
title="删除笔记?"
|
||
onConfirm={(e) => {
|
||
e?.stopPropagation();
|
||
void removeNote(item.id);
|
||
}}
|
||
onCancel={(e) => e?.stopPropagation()}
|
||
>
|
||
<Button type="text" size="small" icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
|
||
</Popconfirm>,
|
||
]}
|
||
>
|
||
<List.Item.Meta
|
||
title={
|
||
<Space size={4}>
|
||
{item.pinned && <StarFilled style={{ color: "#faad14", fontSize: 12 }} />}
|
||
<Typography.Text strong={item.id === activeId} ellipsis style={{ maxWidth: 170 }}>
|
||
{item.title}
|
||
</Typography.Text>
|
||
</Space>
|
||
}
|
||
description={
|
||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||
{dayjs(item.updatedAt).format("MM-DD HH:mm")}
|
||
{(item.tags ?? []).length > 0 ? ` · ${item.tags.join(" · ")}` : ""}
|
||
</Typography.Text>
|
||
}
|
||
/>
|
||
</List.Item>
|
||
)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 右栏:Vditor 所见即所得 Markdown 编辑器(容器常驻,Empty 覆盖层控制显隐) ── */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minWidth: 0,
|
||
border: "1px solid rgba(128,128,128,0.2)",
|
||
borderRadius: 8,
|
||
padding: 16,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
<Empty
|
||
style={{
|
||
margin: "auto",
|
||
display: activeId ? "none" : "block",
|
||
position: "absolute",
|
||
inset: 0,
|
||
zIndex: 5,
|
||
pointerEvents: "none",
|
||
}}
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description={
|
||
<span>
|
||
选择或新建一篇笔记
|
||
<br />
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
所见即所得 Markdown(Typora 式)· AI 助手续写/总结/润色/翻译
|
||
</Typography.Text>
|
||
</span>
|
||
}
|
||
/>
|
||
<div style={{ display: activeId ? "flex" : "none", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
|
||
<Input
|
||
variant="borderless"
|
||
placeholder="无标题"
|
||
value={title}
|
||
onChange={(e) => {
|
||
setTitle(e.target.value);
|
||
queueSave();
|
||
}}
|
||
style={{ fontSize: 22, fontWeight: 700, padding: 0 }}
|
||
/>
|
||
<Tooltip title={pinned ? "取消置顶" : "置顶"}>
|
||
<Button
|
||
type="text"
|
||
icon={pinned ? <StarFilled style={{ color: "#faad14" }} /> : <StarOutlined />}
|
||
onClick={togglePin}
|
||
/>
|
||
</Tooltip>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12, whiteSpace: "nowrap" }}>
|
||
{saveState === "saving" ? "保存中…" : saveState === "pending" ? "待保存" : saveState === "saved" ? "已保存" : ""}
|
||
</Typography.Text>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
|
||
<Input
|
||
size="small"
|
||
placeholder="+ 标签,回车添加"
|
||
style={{ width: 200 }}
|
||
onPressEnter={(e) => {
|
||
const v = (e.target as HTMLInputElement).value.trim();
|
||
if (v && !tags.includes(v)) {
|
||
setTags([...tags, v]);
|
||
queueSave();
|
||
}
|
||
(e.target as HTMLInputElement).value = "";
|
||
}}
|
||
/>
|
||
<Space style={{ flex: 1 }} wrap>
|
||
{tags.map((t) => (
|
||
<Tag
|
||
key={t}
|
||
closable
|
||
onClose={() => {
|
||
setTags(tags.filter((x) => x !== t));
|
||
queueSave();
|
||
}}
|
||
>
|
||
{t}
|
||
</Tag>
|
||
))}
|
||
</Space>
|
||
<Dropdown
|
||
menu={{
|
||
items: AI_ACTIONS.map((a) => ({ key: a.key, label: a.label })),
|
||
onClick: ({ key }) => {
|
||
const def = AI_ACTIONS.find((a) => a.key === key)!;
|
||
void runAi(def.key, def.replaces);
|
||
},
|
||
}}
|
||
>
|
||
<Button size="small" type="primary" ghost icon={<RobotOutlined />} loading={Boolean(aiRunning)}>
|
||
{aiRunning ? "AI 处理中…" : "AI 助手"}
|
||
</Button>
|
||
</Dropdown>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
|
||
<div
|
||
ref={mountRef}
|
||
onCompositionStart={() => (composingRef.current = true)}
|
||
onCompositionEnd={() => (composingRef.current = false)}
|
||
/>
|
||
</div>
|
||
<Typography.Text type="secondary" style={{ fontSize: 11, marginTop: 4 }}>
|
||
所见即所得 Markdown · 输入「/」唤起菜单 · 工具栏可切换标题级别 · 自动保存(后台执行,切换不卡顿)
|
||
</Typography.Text>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</PageContainer>
|
||
);
|
||
}
|