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:
phptits
2026-09-10 06:35:55 +00:00
commit 0d6cd37579
209 changed files with 22783 additions and 0 deletions
+475
View File
@@ -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>
);
}