📌 写给熟悉 Vue2.3 的你:
这篇文档讲的是「紧急事件管理系统」的完整实现。里面有 SVG 流程图、动态表单渲染、Markdown 生成等比较有意思的内容。
碰到 React 新概念、TypeScript 新语法,我都会用 Vue 类比、用大白话给你讲明白。
🌍 英文词汇小词典(先混个脸熟):
- urgent — 紧急的
- canvas — 画布
- flow / workflow — 流程 / 工作流
- node — 节点
- edge — 边(节点之间的连线)
- DAG — Directed Acyclic Graph,有向无环图(就是一个流程图,不能绕圈)
- topological — 拓扑的(一种排序算法)
- SVG — Scalable Vector Graphics,可缩放矢量图(用 XML 代码画的图,放大不模糊)
- Bezier curve — 贝塞尔曲线(一种平滑的曲线)
- viewBox — SVG 的视口属性(用来实现缩放和平移)
- postmortem / review — 复盘 / 事后回顾
- RCA — Root Cause Analysis,根本原因分析
- MTTR — Mean Time To Recovery,平均恢复时间
前言
当线上出现严重故障或突发紧急事件时,团队需要专门的工具来协同处理、记录过程、复盘总结。普通任务系统太"轻",文档工具太"散"。
紧急事件管理的核心是两大能力:
- 事件处置:基于流程模板的结构化处置,流程可视化,节点录入记录
- 复盘报告:事件结束后生成结构化复盘,支持 AI 辅助
本文分享这套系统的架构设计与核心实现。
本文基于真实生产项目脱敏整理。
一、整体架构
🎓 小白课堂:紧急事件管理系统是啥?
想象你们公司线上服务器挂了,整个团队忙成一锅粥:
- 谁负责研判问题?
- 谁负责上报领导?
- 谁负责修复?
- 谁负责通知用户?
- 事后怎么复盘?
这些如果靠微信群里喊,很容易乱,也留不下记录。
紧急事件管理系统就是专门干这个的:
- 有一个流程图,告诉你每一步该做什么(研判 → 上报 → 处置 → 恢复 → 复盘)
- 每个节点有表单,让你填当时的处置记录
- 事件结束后,自动生成结构化复盘报告
对应到 Vue 项目里,就是一个相对复杂的业务模块,包含 SVG 图形渲染、动态表单、Markdown 预览等能力。
功能清单
| 模块 | 核心功能 |
|---|---|
| 事件列表 | 按标题/状态/等级/时间筛选,支持导出 |
| 创建事件 | 填标题 + 选择流程模板 |
| 事件详情 | 流程画布 + 节点记录录入 + 关单/重开 |
| 流程画布 | SVG 自绘流程图,缩放平移,点击录入记录 |
| 复盘报告 | 左右分栏:结构化编辑 + Markdown 预览,自动保存 |
| 流程目录 | 浏览所有流程模板的可视化结构 |
核心数据模型
紧急事件 (UrgentEvent)
├── 基本信息:标题/编号/状态(处理中/已关闭)/等级
├── 流程模板 (FlowTemplate)
│ ├── 节点 (FlowNode):研判/上报/处置/恢复/复盘
│ ├── 连线 (FlowEdge):节点流转关系
│ └── 分支 (FlowBranch):条件分支
├── 处置记录 (EventRecord)
│ ├── 所属节点 ID
│ ├── 记录内容(结构化字段,由节点配置决定)
│ └── 记录人 / 记录时间
└── 复盘报告 (EventReport)
├── report_json(结构化 JSON)
└── 8 大章节:封面/概述/时间线/根因/影响/补救/改进/预防
💡 大白话理解:
- 事件 = 一次故障/紧急情况(比如"2024年1月15日支付系统宕机")
- 流程模板 = 处置流程的"剧本"(先研判、再上报、然后处置……)
- 节点 = 流程里的每一步("研判"是一个节点,"上报"是另一个)
- 处置记录 = 每个节点你填的内容(比如研判结果是什么、什么时候上报的)
- 复盘报告 = 事件结束后写的总结文档
二、流程画布:SVG 自绘流程图
🎓 小白课堂:什么是 SVG?为什么用 SVG 画流程图?
SVG(Scalable Vector Graphics,可缩放矢量图)是一种用 XML 代码描述的图形格式。
你可以把它理解成"用代码画的图"——不是像素点,而是数学描述的形状。为什么用 SVG 而不是用图片?
- 放大不模糊:因为是矢量的,怎么放大都清晰
- 可以交互:每个节点都是一个 DOM 元素,可以加点击、hover 等事件
- 动态生成:可以根据数据动态画出不同的流程图
- 文件小:纯文本,体积小
在 Vue 项目里,你也可以直接在模板里写 SVG,跟 React 是一样的——
因为 SVG 本身就是 HTML 家族的一员。
流程画布是最核心的组件。我们没用第三方流程图库,而是用 SVG 自绘——因为需要在节点上显示记录状态、支持点击录入、分支高亮等定制化需求。
画布分层
SVG 画布
├── 背景层:网格线
├── 连线层:贝塞尔曲线路径
├── 节点层:矩形节点,含图标/标题/状态/记录数
├── 分支层:菱形分支节点
└── 交互层:缩放平移、点击选中、hover 效果
💡 分层设计:
就像 Photoshop 的图层一样——底层是背景,然后是连线,然后是节点。
好处是:改某一层不会影响其他层,逻辑更清晰。
自动布局算法
流程是有向无环图(DAG),用拓扑排序计算每列节点,再按列排列:
🎓 小白课堂:有向无环图(DAG)和拓扑排序是什么?
有向无环图(DAG = Directed Acyclic Graph):
- 有向 = 箭头有方向(A → B 表示从 A 到 B,不能反过来)
- 无环 = 不能绕圈(A→B→C→A 就是环,不允许)
- 图 = 节点 + 连线
流程图就是典型的 DAG——你从起点出发,一步步往前走,不会绕回已经走过的节点。
拓扑排序(Topological Sort):
把 DAG 里的节点按"先后顺序"排成一列列。
比如:
- 第一列:起点(没有任何箭头指向它)
- 第二列:起点的下一级
- 第三列:再下一级
- …
生活例子:穿衣服的顺序。
内裤 → 裤子 → 鞋子,这就是一个拓扑排序。
你不能先穿鞋子再穿裤子😂
// src/components/flow-editor/flow-layout.ts
// 👆 流程布局算法:给定节点和连线,算出每个节点的 x/y 坐标
// 👇 布局后的节点类型
interface LayoutNode { id: string; x: number; y: number; width: number; height: number; }
// 👆 interface = 接口,TypeScript 里用来定义"对象长什么样"
// 对应 Vue 里你定义 props 的类型,或者后端返回数据的类型
// 👇 主函数:计算流程布局
export function computeFlowLayout(nodes, edges, options = {}) {
// 👆 三个参数:
// nodes = 节点数组
// edges = 连线数组
// options = 配置选项(可选,默认空对象)
// ── 解构配置选项 ─────────────────────────────
const { nodeWidth = 160, nodeHeight = 56, hGap = 80, vGap = 24 } = options;
// 👆 解构赋值 + 默认值:
// nodeWidth = 节点宽度,默认 160 像素
// nodeHeight = 节点高度,默认 56 像素
// hGap = 水平间距(列与列之间),默认 80
// vGap = 垂直间距(行与行之间),默认 24
// ── 第一步:拓扑排序,算出每个节点在第几列 ─────
const levels = computeTopologicalLevels(nodes, edges);
// 👆 调用下面的辅助函数,得到一个二维数组:
// levels[0] = 第一列的节点 ID 数组
// levels[1] = 第二列的节点 ID 数组
// ...
// ── 第二步:按列排列,计算每个节点的 x/y ────────
const nodeMap = new Map<string, LayoutNode>();
// 👆 Map 是 JS 的字典数据结构(键值对)
// 这里 key 是节点 ID,value 是布局后的节点信息
// 对应 Vue 里你可能用一个普通对象当字典
let maxHeight = 0;
// 记录整个画布的最大高度
levels.forEach((levelNodes, colIndex) => {
// 👆 遍历每一列
// levelNodes = 这一列的节点 ID 数组
// colIndex = 第几列(0 开始)
const x = colIndex * (nodeWidth + hGap);
// 👆 这一列的 x 坐标 = 列索引 × (节点宽 + 水平间距)
levelNodes.forEach((nodeId, rowIndex) => {
// 👆 遍历这一列的每个节点
// nodeId = 节点 ID
// rowIndex = 第几行(0 开始)
nodeMap.set(nodeId, {
id: nodeId, x, y: rowIndex * (nodeHeight + vGap),
// 👆 y 坐标 = 行索引 × (节点高 + 垂直间距)
width: nodeWidth, height: nodeHeight,
});
});
// 更新最大高度
maxHeight = Math.max(maxHeight,
levelNodes.length * nodeHeight + (levelNodes.length - 1) * vGap);
// 👆 这一列的高度 = 节点数 × 节点高 + (节点数-1) × 间距
// 取所有列中最高的那个作为画布总高度
});
// ── 计算画布总宽度 ────────────────────────────
const width = levels.length * nodeWidth + (levels.length - 1) * hGap;
// 👆 总宽 = 列数 × 节点宽 + (列数-1) × 水平间距
return { nodes: nodeMap, width, height: maxHeight };
// 👆 返回三个东西:
// nodes = Map 结构的节点布局信息
// width = 画布总宽
// height = 画布总高
}
// ─────────────────────────────────────────────
// 辅助函数:拓扑排序,计算每个节点所在的层级(列)
// ─────────────────────────────────────────────
function computeTopologicalLevels(nodes, edges): string[][] {
// 👆 返回值是 string[][],也就是"字符串数组的数组"(二维数组)
// ── 初始化:入度表 + 邻接表 ────────────────────
const inDegree = new Map();
// 👆 入度表:每个节点有多少条箭头指向它
// 入度 = in-degree(进来的箭头数量)
const adjacency = new Map();
// 👆 邻接表:每个节点指向哪些节点(邻居列表)
// adjacency = 邻接、相邻
nodes.forEach(n => { inDegree.set(n.id, 0); adjacency.set(n.id, []); });
// 👆 遍历所有节点,初始化:
// 入度先设为 0
// 邻接表先设为空数组
edges.forEach(e => {
// 👆 遍历所有连线
adjacency.get(e.source)?.push(e.target);
// 👆 在源节点的邻接表里,加入目标节点
// e.source = 连线起点,e.target = 连线终点
// ?. = 可选链,如果 source 不存在就不执行
inDegree.set(e.target, (inDegree.get(e.target) || 0) + 1);
// 👆 目标节点的入度 +1
// inDegree.get(e.target) || 0 = 如果取不到就用 0
});
// ── Kahn 算法:按层遍历 ───────────────────────
const levels: string[][] = [];
// 存结果的二维数组
const queue: string[] = [];
// 队列:存当前层入度为 0 的节点(可以"出发"的节点)
// 先找出所有入度为 0 的节点(起点)
inDegree.forEach((d, id) => { if (d === 0) queue.push(id); });
// 👆 遍历入度表,把入度为 0 的节点加入队列
// 只要队列不为空,就继续处理
while (queue.length > 0) {
const level: string[] = [];
// 当前这一层的节点
const size = queue.length;
// 👆 当前队列的大小 = 当前层有多少个节点
// 先记下来,因为后面会往队列里加新节点
// 处理当前层的所有节点
for (let i = 0; i < size; i++) {
const node = queue.shift()!;
// 👆 从队列头部取出一个节点
// ! = 非空断言,告诉 TypeScript:我保证它不是 undefined
level.push(node);
// 加入当前层
// 遍历这个节点的所有邻居(它指向的节点)
adjacency.get(node)?.forEach(target => {
const nd = (inDegree.get(target) || 0) - 1;
// 👆 邻居的入度 -1(因为当前节点"处理完了")
inDegree.set(target, nd);
if (nd === 0) queue.push(target);
// 👆 如果邻居的入度变成 0 了,加入队列,下一层处理
});
}
levels.push(level);
// 把当前层加入结果
}
return levels;
}
// 💡 拓扑排序的生活例子:
// 想象你要穿衣服:
// - 内裤(入度0)→ 裤子(入度1,依赖内裤)→ 鞋子(入度1,依赖裤子)
// - 衬衣(入度0)→ 外套(入度1,依赖衬衣)
//
// 第一层(入度0):内裤、衬衣
// 第二层:裤子、外套
// 第三层:鞋子
//
// levels = [
// ['内裤', '衬衣'],
// ['裤子', '外套'],
// ['鞋子']
// ]
节点组件
每个节点显示:状态颜色 + 图标 + 标题 + 已记录数/总字段数。
// src/components/flow-editor/flow-node.tsx
// 👆 流程节点组件:单个节点的渲染
// 👇 组件定义
export function FlowNode({ node, nodeData, recordCount, isSelected, onClick }) {
// 👆 解构参数:
// node = 布局信息(x/y/width/height)
// nodeData = 节点的业务数据(标签、图标、状态等)
// recordCount = 已经填了多少条记录
// isSelected = 是否被选中
// onClick = 点击回调
// ── 根据状态获取颜色 ─────────────────────────
const statusColor = getNodeStatusColor(nodeData.status);
// 👆 这是一个工具函数(文件里没贴出来),
// 根据状态(未开始/进行中/已完成)返回对应的颜色
// 比如:未开始=灰色,进行中=蓝色,已完成=绿色
return (
// 👆 SVG 的 g 元素 = "组"(group),把多个图形元素打包在一起
// transform = 变换,translate(x, y) 就是平移到指定坐标
<g transform={`translate(${node.x}, ${node.y})`} onClick={onClick} style={{ cursor: 'pointer' }}>
{/* 👆 cursor: pointer = 鼠标移上去变手型,表示可以点 */}
{/* 节点主体:矩形 */}
<rect width={node.width} height={node.height} rx={8}
// 👆 rx = 水平圆角半径(8 像素 → 圆角矩形)
fill={isSelected ? '#e6f4ff' : '#fff'}
// 👆 填充色:选中时淡蓝,否则白色
stroke={statusColor} strokeWidth={isSelected ? 2 : 1} />
// 👆 stroke = 边框颜色(用状态色)
// strokeWidth = 边框粗细:选中时 2px,否则 1px
{/* 左边的彩色竖条(状态指示条) */}
<rect x={0} y={0} width={4} height={node.height} fill={statusColor} rx={8} />
{/* 👆 宽 4px 的小矩形,贴在节点左边
用状态色填充,一眼就能看出节点状态 */}
{/* 图标 */}
<text x={20} y={24} fontSize={16}>{nodeData.icon}</text>
{/* 👆 用文本显示图标(比如 emoji 或 iconfont 字符)
x=20, y=24 是文字的位置(y 是文字基线的位置,不是顶部) */}
{/* 节点标题 */}
<text x={44} y={24} fontSize={13} fontWeight={500} fill="#1f1f1f">
{nodeData.label}
</text>
{/* 副标题:已记录 X / 总字段数 Y */}
<text x={44} y={42} fontSize={11} fill="#8c8c8c">
已记录 {recordCount} / {nodeData.fields?.length || 0}
{/* 👆 fields?.length = 可选链,fields 不存在就返回 undefined
|| 0 = 如果是 undefined/null,就用 0 兜底 */}
</text>
</g>
);
}
缩放与平移
用 SVG 的 viewBox 属性实现,支持滚轮缩放(0.25x - 2x)、拖拽平移。
🎓 小白课堂:SVG 的 viewBox 是什么?
viewBox 是 SVG 最强大的属性之一,它定义了"SVG 的可视区域"。
想象你拿着一个取景框(viewBox)看一幅画(SVG 内容):
- 取景框变小 → 看到的内容变少,但东西看起来变大(放大)
- 取景框变大 → 看到的内容变多,但东西看起来变小(缩小)
- 取景框移动 → 看到画的不同部分(平移)
viewBox 的格式是
viewBox="x y width height":
x, y= 取景框左上角的坐标(平移用)width, height= 取景框的宽高(缩放用)所以:
- 缩放 = 改变 viewBox 的 width 和 height
- 平移 = 改变 viewBox 的 x 和 y
是不是很巧妙?一行属性就搞定了缩放和平移!
设计取舍:自研 SVG vs 第三方库(如 React Flow)。自研好处是完全可控、包体小;代价是需要自己处理缩放、平移、连线、布局。对于流程相对固定、展示大于编辑的场景,自研是值得的。
三、处置记录:节点级结构化录入
🎓 小白课堂:什么是动态表单?为什么需要?
流程里每个节点要填的内容都不一样:
- "研判"节点可能要填:问题描述、影响范围、严重等级
- "上报"节点可能要填:上报时间、上报对象、对方反馈
- "处置"节点可能要填:处置方案、操作人、开始时间
如果每个节点都写一个表单组件,那有 10 个节点就要写 10 个组件,累死了。
动态表单就是:用配置数据描述表单长什么样(有哪些字段、什么类型),
然后用一个通用组件根据配置自动渲染表单。对应 Vue 里的做法也是一样的——写一个配置数组,然后用 v-for 循环渲染表单项。
很多低代码平台就是这么干的。
每个流程节点配置了需要录入的字段(文本、选择、日期、人员、附件等)。点击节点,右侧面板显示录入表单。
节点字段配置
// 👇 单个字段的配置
interface NodeFieldConfig {
key: string;
// 字段名(表单的 name,提交数据时的 key)
label: string;
// 字段标签(显示给用户看的文字)
type: 'text' | 'textarea' | 'select' | 'multiselect' | 'date' | 'user' | 'file';
// 👆 字段类型,只能是这 7 种之一
// 联合类型(Union Type),就像 Vue 里 props 的 validator 校验
required?: boolean;
// 是否必填(可选属性,带 ?)
options?: { label: string; value: string }[];
// 选项(select / multiselect 类型用)
// 每个选项有 label(显示文字)和 value(实际值)
}
// 👇 整个节点的配置
interface FlowNodeConfig {
id: string;
// 节点 ID
label: string;
// 节点名称
icon: string;
// 图标
type: 'normal' | 'branch' | 'start' | 'end';
// 节点类型:普通 / 分支 / 起点 / 终点
fields: NodeFieldConfig[];
// 这个节点下的所有字段配置(数组)
}
动态表单渲染
// src/pages/urgent-event/detail/components/node-record-panel.tsx
// 👆 节点记录面板:点击节点后,右侧显示的录入表单
import { Form, Input, Select, DatePicker, Upload, Button } from 'antd';
// 👆 从 antd 引入各种表单组件(原代码里没写,实际需要引入)
// 👇 组件定义
export function NodeRecordPanel({ nodeConfig, record, onSubmit }) {
// 👆 参数:
// nodeConfig = 节点配置(决定表单长什么样)
// record = 已有的记录(用来回填表单)
// onSubmit = 提交回调
const [form] = Form.useForm();
// 👆 Ant Design 的 Form Hook,创建 form 实例
// 对应 Vue 里的 $refs.form
// ── 核心:根据字段类型渲染不同的表单控件 ────────
const renderField = (field: NodeFieldConfig) => {
// 👆 接收一个字段配置,返回对应的 JSX
// 公共的 Form.Item 属性
const props = {
name: field.key,
// 字段名
label: field.label,
// 标签文字
rules: field.required ? [{ required: true }] : [],
// 👆 如果必填,加一条必填校验规则
// 三元表达式:条件 ? 真值 : 假值
// 对应 Vue 里 :rules="field.required ? [...] : []"
};
// 根据类型 switch,返回不同的表单控件
switch (field.type) {
case 'text':
// 文本输入框
return <Form.Item {...props}><Input /></Form.Item>;
// 👆 {...props} = 展开运算符,把 props 对象的所有属性展开到组件上
// 对应 Vue 里的 v-bind="props"
case 'textarea':
// 多行文本框
return <Form.Item {...props}><Input.TextArea rows={3} /></Form.Item>;
case 'select':
// 下拉选择(单选)
return <Form.Item {...props}><Select options={field.options} /></Form.Item>;
case 'multiselect':
// 下拉选择(多选)
return <Form.Item {...props}><Select mode="multiple" options={field.options} /></Form.Item>;
case 'date':
// 日期选择器
return <Form.Item {...props}><DatePicker style={{ width: '100%' }} /></Form.Item>;
case 'user':
// 人员选择器(自定义组件)
return <Form.Item {...props}><UserSelect /></Form.Item>;
case 'file':
// 文件上传
return <Form.Item {...props}><Upload>上传附件</Upload></Form.Item>;
default:
return null;
// 未知类型,不渲染
}
};
// ── 渲染整个面板 ─────────────────────────────
return (
<div className="node-record-panel">
{/* 面板标题 */}
<div className="panel-title">{nodeConfig.label}</div>
{/* 表单 */}
<Form form={form} layout="vertical" initialValues={record?.data}>
{/* 👆 layout="vertical" = 标签在输入框上面(垂直布局)
initialValues = 初始值(回填已有记录)
record?.data = 可选链:record 存在就取 data,不存在就是 undefined */}
{/* 遍历所有字段配置,逐一渲染 */}
{nodeConfig.fields.map(f => <div key={f.key}>{renderField(f)}</div>)}
{/* 👆 map 对应 Vue 的 v-for
key 对应 Vue 的 :key */}
{/* 提交按钮 */}
<Form.Item>
<Button type="primary" onClick={() => form.validateFields().then(onSubmit)}>
{/* 👆 点击按钮 → 先校验表单 → 校验通过后调用 onSubmit
validateFields() 返回 Promise,校验通过 resolve,失败 reject */}
保存记录
</Button>
</Form.Item>
</Form>
</div>
);
}
💡 动态表单的扩展性:
以后想加新的字段类型(比如"数字输入"、“开关”),
只需要:
- 在
type联合类型里加一个新值- 在 switch 里加一个 case
几行代码就搞定了,这就是"配置驱动"的好处。
四、复盘报告:结构化编辑 + Markdown 实时预览
🎓 小白课堂:什么是结构化复盘?为什么不用富文本?
传统的复盘报告就是写一篇 Word 文档,想到啥写啥。
但这样有几个问题:
- 不规范:每个人写的结构不一样,没法统计
- 难查找:想找"去年有多少次故障是因为配置错误",搜半天
- AI 难处理:AI 很难从自由文本里精准提取信息
结构化复盘就是:把复盘报告分成固定的几个章节(概述、时间线、根因分析、改进措施……),
每个章节用表单填写,数据以 JSON 格式存储。
然后根据 JSON 数据自动生成 Markdown 格式的报告给人看。这样既规范(好统计、好分析),又能正常阅读(Markdown 渲染)。
就像你用问卷调查收集数据,而不是让大家写小作文。
事件结束后写复盘报告。我们不用富文本,而是用结构化表单 + Markdown 预览——复盘结构相对固定,表单填写更规范,也方便 AI 生成和数据统计。
报告结构(8 大章节)
| 章节 | 标题 | 内容类型 |
|---|---|---|
| cover | 封面/元信息 | 键值对(事件类型、报告周期、编制人) |
| overview | 一、事件概述 | 表格 + 结论(发生时间/类型/范围/等级) |
| timeline | 二、事件时间线 | 表格行(时间 + 描述) |
| rootCause | 三、根本原因分析 | 5 Why 分析表 + RCA 分类 + 结论 |
| impact | 四、影响评估 | 5 维度(用户/业务/系统/声誉/SLA) |
| remediation | 五、补救与恢复措施 | 表格(措施/执行人/时间/状态) |
| improvements | 六、改进行动项 | 表格(行动/负责人/截止/优先级) |
| prevention | 七、预防机制建议 | 表格(措施/说明/预期效果) |
📌 5 Why 分析是什么?
就是连续问 5 个"为什么",直到找到问题的根本原因。
比如:
- 为什么服务器挂了?→ 因为内存满了
- 为什么内存满了?→ 因为有内存泄漏
- 为什么有内存泄漏?→ 因为某个缓存没清理
- 为什么缓存没清理?→ 因为定时任务没跑
- 为什么定时任务没跑?→ 因为服务器时区配错了 ← 根本原因
左右分栏布局
左侧结构化编辑表单,右侧 Markdown 实时预览,左右联动(点击章节跳转)。
💡 这个布局模式很经典——
左边编辑、右边预览,类似 Typora / Markdown 编辑器的"分屏模式"。
在 Vue 里实现的话也是类似的:左边一个表单组件,右边一个 Markdown 渲染组件,
用 data 里的 reportData 驱动两边同步。
Markdown 生成
左侧表单数据变化时,实时生成 Markdown 渲染到右侧:
// src/pages/urgent-event/report/markdown.ts
// 👆 Markdown 生成工具:把结构化的报告数据转成 Markdown 字符串
// 👇 主函数:报告数据 → Markdown 字符串
export function reportToMarkdown(report: ReportBody): string {
// 👆 report 参数是 ReportBody 类型(结构化的报告数据)
// 返回值是字符串(Markdown 格式的文本)
const lines: string[] = [];
// 👆 用一个数组存每一行,最后用 join('\n') 拼起来
// 比直接字符串拼接性能好,也更清晰
// ── 封面 ────────────────────────────────────
lines.push(`# ${report.cover.title || '事件复盘报告'}`, '');
// 👆 一级标题:报告标题,如果没有就用默认文字
// 后面加一个空字符串 '',就是空一行
lines.push(`- 事件类型:${report.cover.event_type || ''}`);
lines.push(`- 报告周期:${report.cover.period || ''}`);
lines.push('');
// ── 一、事件概述 ─────────────────────────────
lines.push('## 一、事件概述', '');
// 👆 二级标题
lines.push('| 项目 | 内容 |');
lines.push('|------|------|');
// 👆 Markdown 表格的表头和分隔线
lines.push(`| 发生时间 | ${report.overview.start_time || ''} |`);
lines.push(`| 恢复时间 | ${report.overview.recover_time || ''} |`);
lines.push('');
// ── 二、事件时间线 ───────────────────────────
lines.push('## 二、事件时间线', '');
report.timeline.forEach((item, i) => {
// 👆 遍历时间线数组,每一项生成一行
lines.push(`${i + 1}. **${item.time}** — ${item.description}`);
// 👆 Markdown 有序列表:1. xxx 2. xxx
// **...** = 粗体
});
lines.push('');
// ── 三、根本原因分析 ─────────────────────────
lines.push('## 三、根本原因分析', '');
lines.push('### 5 Why 分析', '');
// 三级标题
report.rootCause.why_levels.forEach((level, i) => {
lines.push(`**第 ${i + 1} 层** — ${level.question}`);
lines.push(`> ${level.answer}`, '');
// 👆 > 开头的是 Markdown 引用(块引用),看起来像引用块
});
lines.push(`**根因结论**:${report.rootCause.conclusion}`, '');
// ... 其他章节类似
// (影响评估、补救措施、改进行动项、预防机制……)
return lines.join('\n');
// 👆 所有行用换行符拼接起来,就是完整的 Markdown 文本了
}
💡 为什么用数组 push 再 join,而不是直接字符串相加?
- 性能更好:字符串是不可变的,每次
+都会创建新字符串- 代码更清晰:每一行一目了然
- 想在哪插一行就在哪插一行
当然,字符串少的时候无所谓,这是个习惯问题。
自动保存
2 秒防抖自动保存,离开页面提示未保存更改。
🎓 小白课堂:防抖(debounce)是什么?
防抖 = 等用户停下来不操作了,再执行。
生活例子:电梯门。你一直按开门键,电梯门一直开着不会关。
等你最后一次按完,过了几秒钟没人按了,门才关上。自动保存的防抖:
- 用户打字 → 重新计时
- 用户停了 2 秒 → 触发保存
- 这样就不会每打一个字就存一次(太频繁,服务器压力大)
和之前讲的**节流(throttle)**对比:
- 节流 = 每隔一段时间执行一次(不管你操作多频繁)
- 防抖 = 停下来之后才执行一次
自动保存用防抖更合适——等用户想好了、停手了,再保存。
五、设计亮点总结
架构层面
- 流程配置驱动:流程模板是数据,画布是渲染,表单是录入。改流程不改代码
- 结构化复盘:JSON 存数据,Markdown 做展示,既规范又灵活,还方便 AI 生成
- 节点级录入:每个节点独立配置字段,动态表单渲染,扩展性强
- SVG 自研画布:完全可控,状态、样式、交互都能定制
体验层面
- 可视化处置:流程图一眼看到处理进度,哪些节点完成了、哪些还没做
- 左右分栏预览:写复盘时左边填表单右边看效果,所见即所得
- 自动保存:不用担心丢失内容,后台默默存
- 流程目录:所有流程模板可视化浏览,找模板更直观
工程层面
- 布局算法独立:拓扑排序 + 按列排列,可单独测试和复用
- 字段类型可扩展:加新字段类型只需 switch 里加一个 case
- 报告章节可插拔:加新章节只需加一个 section 组件
- 画布组件独立:流程画布不依赖业务,可复用到其他场景
六、可扩展方向
- 实时协作:多人同时编辑事件记录,类似 Figma 的实时协同
- AI 辅助撰写:AI 自动提取时间线、生成根因分析、填写改进行动
- 流程模板编辑器:拖拽式可视化编辑流程模板
- 事件大盘:事件趋势图、MTTR 指标、分类统计报表
- 知识库关联:复盘报告自动沉淀到知识库,可搜索可引用
写在最后
紧急事件管理这个场景很有代表性——它不是简单的 CRUD,而是融合了可视化(流程图)、结构化录入(动态表单)、文档生成(Markdown)、AI 辅助等多种能力。
核心设计思路还是一句话:数据驱动,配置先行。流程是配置,节点字段是配置,报告章节是配置。当系统的各个部分都是由配置驱动时,扩展和维护就变得简单了——加东西改配置就行,不用动核心逻辑。
希望这篇文章对你做类似系统有帮助。有想法欢迎在评论区交流 👋
关于本文:基于真实生产项目脱敏整理,技术栈为 React 19 + TypeScript + Vite + Ant Design。文中代码为教学目的做了精简,实际项目中请根据具体需求调整。
📝 小白复习要点
看完这份文档,重点理解这些概念和技术:
| 概念 | 一句话总结 | Vue 2 对应物 |
|---|---|---|
| SVG | 用代码画的矢量图,放大不模糊、可交互 | 直接在 template 里写 SVG 一样用 |
| viewBox | SVG 的取景框,一行属性搞定缩放平移 | —(SVG 原生属性) |
| DAG(有向无环图) | 有方向、不能绕圈的图,流程图就是典型 | —(算法概念) |
| 拓扑排序 | 把 DAG 节点按先后顺序排成一列列 | —(算法概念) |
| 动态表单 | 用配置数据自动生成表单,不用手写每个表单 | v-for + 配置数组,思路一样 |
| 结构化数据 | 用 JSON 存数据,规范、好统计、方便 AI 处理 | —(通用设计思想) |
| Markdown 生成 | 把 JSON 数据拼成人能读的 Markdown 文本 | —(字符串处理) |
| 防抖 debounce | 用户停下来才执行,适合自动保存 | Lodash 的 debounce 或自己写 |
| 配置驱动 | 用数据配置控制 UI 和逻辑,加东西改配置就行 | —(通用设计思想) |
| 5 Why 分析 | 连续问 5 个为什么,找问题根本原因 | —(业务方法论) |
如果你这些都理解了,恭喜你——你已经掌握了紧急事件管理系统的核心设计思路!🎉