Files
devos/apps/web/src/pages/notes.tsx
T
phptits 0d6cd37579 DevOS v1 + Phase 2: unified resource model, infra ops, website deployment, AI chat ops
- 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)
2026-09-10 06:35:55 +00:00

476 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 笔记(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>
);
}