紧急事件管理系统:流程画布 + 结构化复盘报告

4 阅读18分钟

📌 写给熟悉 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,平均恢复时间

前言

当线上出现严重故障或突发紧急事件时,团队需要专门的工具来协同处理、记录过程、复盘总结。普通任务系统太"轻",文档工具太"散"。

紧急事件管理的核心是两大能力:

  1. 事件处置:基于流程模板的结构化处置,流程可视化,节点录入记录
  2. 复盘报告:事件结束后生成结构化复盘,支持 AI 辅助

本文分享这套系统的架构设计与核心实现。

本文基于真实生产项目脱敏整理。


一、整体架构

🎓 小白课堂:紧急事件管理系统是啥?

想象你们公司线上服务器挂了,整个团队忙成一锅粥:

  • 谁负责研判问题?
  • 谁负责上报领导?
  • 谁负责修复?
  • 谁负责通知用户?
  • 事后怎么复盘?

这些如果靠微信群里喊,很容易乱,也留不下记录。

紧急事件管理系统就是专门干这个的:

  1. 有一个流程图,告诉你每一步该做什么(研判 → 上报 → 处置 → 恢复 → 复盘)
  2. 每个节点有表单,让你填当时的处置记录
  3. 事件结束后,自动生成结构化复盘报告

对应到 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 而不是用图片?

  1. 放大不模糊:因为是矢量的,怎么放大都清晰
  2. 可以交互:每个节点都是一个 DOM 元素,可以加点击、hover 等事件
  3. 动态生成:可以根据数据动态画出不同的流程图
  4. 文件小:纯文本,体积小

在 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>
  );
}

💡 动态表单的扩展性:
以后想加新的字段类型(比如"数字输入"、“开关”),
只需要:

  1. 在 type 联合类型里加一个新值
  2. 在 switch 里加一个 case

几行代码就搞定了,这就是"配置驱动"的好处。


四、复盘报告:结构化编辑 + Markdown 实时预览

🎓 小白课堂:什么是结构化复盘?为什么不用富文本?

传统的复盘报告就是写一篇 Word 文档,想到啥写啥。
但这样有几个问题:

  1. 不规范:每个人写的结构不一样,没法统计
  2. 难查找:想找"去年有多少次故障是因为配置错误",搜半天
  3. 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)**对比:

  • 节流 = 每隔一段时间执行一次(不管你操作多频繁)
  • 防抖 = 停下来之后才执行一次

自动保存用防抖更合适——等用户想好了、停手了,再保存。


五、设计亮点总结

架构层面

  1. 流程配置驱动:流程模板是数据,画布是渲染,表单是录入。改流程不改代码
  2. 结构化复盘:JSON 存数据,Markdown 做展示,既规范又灵活,还方便 AI 生成
  3. 节点级录入:每个节点独立配置字段,动态表单渲染,扩展性强
  4. SVG 自研画布:完全可控,状态、样式、交互都能定制

体验层面

  1. 可视化处置:流程图一眼看到处理进度,哪些节点完成了、哪些还没做
  2. 左右分栏预览:写复盘时左边填表单右边看效果,所见即所得
  3. 自动保存:不用担心丢失内容,后台默默存
  4. 流程目录:所有流程模板可视化浏览,找模板更直观

工程层面

  1. 布局算法独立:拓扑排序 + 按列排列,可单独测试和复用
  2. 字段类型可扩展:加新字段类型只需 switch 里加一个 case
  3. 报告章节可插拔:加新章节只需加一个 section 组件
  4. 画布组件独立:流程画布不依赖业务,可复用到其他场景

六、可扩展方向

  1. 实时协作:多人同时编辑事件记录,类似 Figma 的实时协同
  2. AI 辅助撰写:AI 自动提取时间线、生成根因分析、填写改进行动
  3. 流程模板编辑器:拖拽式可视化编辑流程模板
  4. 事件大盘:事件趋势图、MTTR 指标、分类统计报表
  5. 知识库关联:复盘报告自动沉淀到知识库,可搜索可引用

写在最后

紧急事件管理这个场景很有代表性——它不是简单的 CRUD,而是融合了可视化(流程图)、结构化录入(动态表单)、文档生成(Markdown)、AI 辅助等多种能力。

核心设计思路还是一句话:数据驱动,配置先行。流程是配置,节点字段是配置,报告章节是配置。当系统的各个部分都是由配置驱动时,扩展和维护就变得简单了——加东西改配置就行,不用动核心逻辑。

希望这篇文章对你做类似系统有帮助。有想法欢迎在评论区交流 👋


关于本文:基于真实生产项目脱敏整理,技术栈为 React 19 + TypeScript + Vite + Ant Design。文中代码为教学目的做了精简,实际项目中请根据具体需求调整。


📝 小白复习要点

看完这份文档,重点理解这些概念和技术:

概念一句话总结Vue 2 对应物
SVG用代码画的矢量图,放大不模糊、可交互直接在 template 里写 SVG 一样用
viewBoxSVG 的取景框,一行属性搞定缩放平移—(SVG 原生属性)
DAG(有向无环图)有方向、不能绕圈的图,流程图就是典型—(算法概念)
拓扑排序把 DAG 节点按先后顺序排成一列列—(算法概念)
动态表单用配置数据自动生成表单,不用手写每个表单v-for + 配置数组,思路一样
结构化数据用 JSON 存数据,规范、好统计、方便 AI 处理—(通用设计思想)
Markdown 生成把 JSON 数据拼成人能读的 Markdown 文本—(字符串处理)
防抖 debounce用户停下来才执行,适合自动保存Lodash 的 debounce 或自己写
配置驱动用数据配置控制 UI 和逻辑,加东西改配置就行—(通用设计思想)
5 Why 分析连续问 5 个为什么,找问题根本原因—(业务方法论)

如果你这些都理解了,恭喜你——你已经掌握了紧急事件管理系统的核心设计思路!🎉