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)
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* # 笔记(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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user