Trae Assistant commited on
Commit
7bb2782
·
0 Parent(s):

Initial commit: Incident Postmortem Pro with 5-Whys, Timeline, and robust features

Browse files
Files changed (5) hide show
  1. Dockerfile +15 -0
  2. README.md +55 -0
  3. app.py +176 -0
  4. requirements.txt +1 -0
  5. templates/index.html +534 -0
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create data directory
11
+ RUN mkdir -p data && chmod 777 data
12
+
13
+ EXPOSE 7860
14
+
15
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 事故复盘大师
3
+ emoji: 🚨
4
+ colorFrom: red
5
+ colorTo: slate
6
+ sdk: docker
7
+ app_port: 7860
8
+ short_description: 专业的生产事故复盘与根因分析工具
9
+ ---
10
+
11
+ # 事故复盘大师 (Incident Postmortem Pro)
12
+
13
+ 一个专业的生产事故复盘与根因分析工具,帮助技术团队将每一次故障转化为宝贵的经验资产。
14
+
15
+ ## ✨ 核心功能
16
+
17
+ * **事故管理**:记录事故级别(P0-P4)、发生时间、负责人。
18
+ * **交互式时间线 (Timeline)**:精确还原事故发生、响应、定位、修复的全过程。
19
+ * **5 Whys 根因分析**:内置 5 Whys 引导式思考模版,挖掘深层原因。
20
+ * **改进措施追踪 (Action Items)**:记录后续优化任务、负责人及状态。
21
+ * **Markdown 报告导出**:一键生成结构化的复盘报告,方便分享到 Wiki 或文档中心。
22
+ * **Tech/Dark UI**:专为开发者设计的深色模式界面。
23
+
24
+ ## 🚀 快速开始
25
+
26
+ ### Docker 部署
27
+
28
+ ```bash
29
+ docker build -t incident-postmortem-pro .
30
+ docker run -p 7860:7860 -v $(pwd)/data:/app/data incident-postmortem-pro
31
+ ```
32
+
33
+ ### 本地开发
34
+
35
+ ```bash
36
+ pip install flask
37
+ python app.py
38
+ ```
39
+
40
+ 访问 http://localhost:7860 即可使用。
41
+
42
+ ## 💡 使用场景
43
+
44
+ * **SRE/运维团队**:标准化事故复盘流程。
45
+ * **研发团队**:进行技术复盘和根因挖掘。
46
+ * **项目经理**:追踪事故后的改进措施落地。
47
+
48
+ ## 🛠️ 技术栈
49
+
50
+ * **Backend**: Flask (Python)
51
+ * **Frontend**: Vue 3 + Tailwind CSS (Single File Component)
52
+ * **Data**: JSON (Local Storage)
53
+
54
+ ---
55
+ Made with ❤️ by Trae
app.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import uuid
4
+ from flask import Flask, render_template, request, jsonify, send_from_directory
5
+
6
+ app = Flask(__name__)
7
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB limit for safety
8
+
9
+ # 配置
10
+ DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
11
+ DATA_FILE = os.path.join(DATA_DIR, 'postmortems.json')
12
+
13
+ # 确保数据目录存在
14
+ os.makedirs(DATA_DIR, exist_ok=True)
15
+
16
+ def load_data():
17
+ if not os.path.exists(DATA_FILE):
18
+ # 初始化默认数据
19
+ default_data = [
20
+ {
21
+ "id": "demo-001",
22
+ "title": "生产环境数据库连接池耗尽导致服务不可用",
23
+ "date": "2023-10-24",
24
+ "severity": "P0",
25
+ "owner": "张三",
26
+ "created_at": "2023-10-24T10:00:00Z",
27
+ "summary": "在双十一预热活动期间,主数据库连接数突然飙升至 100%,导致订单服务无法连接数据库,所有下单请求失败。持续时间约 15 分钟。",
28
+ "impact": "造成约 5000 单交易失败,预估 GMV 损失 100 万。用户投诉激增。",
29
+ "timeline": [
30
+ {"time": "20:00", "description": "流量开始激增,达到平时峰值的 3 倍", "type": "detection"},
31
+ {"time": "20:05", "description": "监控系统报警,DB CPU 飙升至 90%", "type": "detection"},
32
+ {"time": "20:08", "description": "DBA 介入排查,发现活动连接数打满", "type": "investigation"},
33
+ {"time": "20:12", "description": "紧急扩容 Read Replica,并临时调大 Max Connections", "type": "fix"},
34
+ {"time": "20:15", "description": "服务逐步恢复正常", "type": "verification"}
35
+ ],
36
+ "root_cause": {
37
+ "whys": [
38
+ "数据库连接数打满,拒绝新连接",
39
+ "后端服务实例扩容后,每个实例的连接池配置未做相应调整",
40
+ "微服务配置中心下发的连接池参数是静态的,未随实例数动态计算",
41
+ "在进行压力测试时,只测试了单实例性能,未测试全链路大规模并发下的 DB 瓶颈",
42
+ "缺乏对数据库总连接数的全局管控机制"
43
+ ],
44
+ "conclusion": "缺乏全局的数据库连接治理机制,且压测覆盖不全。"
45
+ },
46
+ "action_items": [
47
+ {"task": "实施数据库连接代理 (Proxy) 层", "owner": "李四", "deadline": "2023-11-01", "status": "in_progress"},
48
+ {"task": "优化全链路压测模型,包含 DB 极限场景", "owner": "王五", "deadline": "2023-11-15", "status": "pending"}
49
+ ]
50
+ },
51
+ {
52
+ "id": "demo-002",
53
+ "title": "支付回调接口验签逻辑缺陷",
54
+ "date": "2023-09-15",
55
+ "severity": "P2",
56
+ "owner": "赵六",
57
+ "created_at": "2023-09-15T14:00:00Z",
58
+ "summary": "收到用户反馈支付成功但订单状态未更新。排查发现部分特殊字符导致签名验证失败。",
59
+ "impact": "约 20 笔订单卡单,需人工补单。",
60
+ "timeline": [
61
+ {"time": "14:00", "description": "客服收到用户反馈", "type": "detection"},
62
+ {"time": "14:30", "description": "定位到日志中存在大量验签失败错误", "type": "investigation"},
63
+ {"time": "15:00", "description": "修复验签逻辑中的字符编码处理问题", "type": "fix"}
64
+ ],
65
+ "root_cause": {
66
+ "whys": [
67
+ "签名验证失败",
68
+ "第三方支付回调参数中包含特殊 Emoji 字符",
69
+ "验签逻辑未正确处理 UTF-8 编码",
70
+ "开发测试阶段未覆盖特殊字符场景",
71
+ ""
72
+ ],
73
+ "conclusion": "编码规范执行不到位,测试用例缺失。"
74
+ },
75
+ "action_items": [
76
+ {"task": "修复代码并补充单元测试", "owner": "赵六", "deadline": "2023-09-16", "status": "done"}
77
+ ]
78
+ }
79
+ ]
80
+ save_data(default_data)
81
+ return default_data
82
+ try:
83
+ with open(DATA_FILE, 'r', encoding='utf-8') as f:
84
+ return json.load(f)
85
+ except Exception:
86
+ return []
87
+
88
+ def save_data(data):
89
+ with open(DATA_FILE, 'w', encoding='utf-8') as f:
90
+ json.dump(data, f, ensure_ascii=False, indent=2)
91
+
92
+ @app.route('/')
93
+ def index():
94
+ return render_template('index.html')
95
+
96
+ @app.route('/api/postmortems', methods=['GET'])
97
+ def get_postmortems():
98
+ data = load_data()
99
+ # 按最后修改时间或创建时间排序(���里简单按列表顺序倒序)
100
+ return jsonify(data[::-1])
101
+
102
+ @app.route('/api/postmortems', methods=['POST'])
103
+ def create_postmortem():
104
+ data = load_data()
105
+ new_item = request.json
106
+
107
+ # 基础验证与默认值
108
+ if not new_item.get('id'):
109
+ new_item['id'] = str(uuid.uuid4())
110
+
111
+ new_item['created_at'] = new_item.get('created_at', '')
112
+ new_item['updated_at'] = new_item.get('updated_at', '')
113
+
114
+ data.append(new_item)
115
+ save_data(data)
116
+ return jsonify(new_item), 201
117
+
118
+ @app.route('/api/postmortems/<item_id>', methods=['PUT'])
119
+ def update_postmortem(item_id):
120
+ data = load_data()
121
+ updated_item = request.json
122
+
123
+ for i, item in enumerate(data):
124
+ if item['id'] == item_id:
125
+ data[i] = updated_item
126
+ save_data(data)
127
+ return jsonify(updated_item)
128
+
129
+ return jsonify({'error': 'Not found'}), 404
130
+
131
+ @app.route('/api/postmortems/<item_id>', methods=['DELETE'])
132
+ def delete_postmortem(item_id):
133
+ data = load_data()
134
+ data = [item for item in data if item['id'] != item_id]
135
+ save_data(data)
136
+ return jsonify({'success': True})
137
+
138
+ @app.route('/api/export/<item_id>', methods=['GET'])
139
+ def export_markdown(item_id):
140
+ data = load_data()
141
+ item = next((i for i in data if i['id'] == item_id), None)
142
+ if not item:
143
+ return "Not Found", 404
144
+
145
+ # 生成 Markdown 内容
146
+ md = f"# 事故复盘: {item.get('title', '无标题')}\n\n"
147
+ md += f"**日期**: {item.get('date', '')} | **级别**: {item.get('severity', '')} | **负责人**: {item.get('owner', '')}\n\n"
148
+
149
+ md += "## 1. 事故摘要 (Summary)\n"
150
+ md += f"{item.get('summary', '无')}\n\n"
151
+
152
+ md += "## 2. 影响范围 (Impact)\n"
153
+ md += f"{item.get('impact', '无')}\n\n"
154
+
155
+ md += "## 3. 时间线 (Timeline)\n"
156
+ for t in item.get('timeline', []):
157
+ md += f"- **{t.get('time', '')}**: {t.get('description', '')} ({t.get('type', '')})\n"
158
+ md += "\n"
159
+
160
+ md += "## 4. 根因分析 (5 Whys)\n"
161
+ whys = item.get('root_cause', {}).get('whys', [])
162
+ for idx, why in enumerate(whys):
163
+ if why:
164
+ md += f"{idx+1}. Why? {why}\n"
165
+ md += f"\n**结论**: {item.get('root_cause', {}).get('conclusion', '')}\n\n"
166
+
167
+ md += "## 5. 改进措施 (Action Items)\n"
168
+ md += "| 任务 | 负责人 | 截止日期 | 状态 |\n"
169
+ md += "| --- | --- | --- | --- |\n"
170
+ for action in item.get('action_items', []):
171
+ md += f"| {action.get('task', '')} | {action.get('owner', '')} | {action.get('deadline', '')} | {action.get('status', '')} |\n"
172
+
173
+ return jsonify({'markdown': md})
174
+
175
+ if __name__ == '__main__':
176
+ app.run(host='0.0.0.0', port=7860, debug=True)
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ flask
templates/index.html ADDED
@@ -0,0 +1,534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>事故复盘大师 (Incident Postmortem Pro)</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.2/marked.min.js"></script>
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
11
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
12
+ <style>
13
+ body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
14
+ .glass-panel {
15
+ background: rgba(30, 41, 59, 0.7);
16
+ backdrop-filter: blur(10px);
17
+ border: 1px solid rgba(255, 255, 255, 0.1);
18
+ }
19
+ .glass-panel:hover {
20
+ border-color: rgba(59, 130, 246, 0.5);
21
+ background: rgba(30, 41, 59, 0.9);
22
+ }
23
+ .timeline-line {
24
+ position: absolute;
25
+ left: 15px;
26
+ top: 10px;
27
+ bottom: 0;
28
+ width: 2px;
29
+ background: #475569;
30
+ z-index: 0;
31
+ }
32
+ /* Custom Scrollbar */
33
+ ::-webkit-scrollbar { width: 8px; }
34
+ ::-webkit-scrollbar-track { background: #1e293b; }
35
+ ::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
36
+ ::-webkit-scrollbar-thumb:hover { background: #64748b; }
37
+
38
+ [v-cloak] { display: none; }
39
+ </style>
40
+ <script>
41
+ tailwind.config = {
42
+ darkMode: 'class',
43
+ theme: {
44
+ extend: {
45
+ colors: {
46
+ dark: {
47
+ bg: '#0f172a',
48
+ surface: '#1e293b',
49
+ border: '#334155'
50
+ },
51
+ brand: {
52
+ 500: '#3b82f6',
53
+ 600: '#2563eb'
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ </script>
60
+ </head>
61
+ <body class="bg-dark-bg text-gray-200 min-h-screen">
62
+ <div id="app" class="flex flex-col h-screen overflow-hidden" v-cloak>
63
+ <!-- Header -->
64
+ <header class="h-16 border-b border-dark-border bg-dark-surface flex items-center justify-between px-6 shrink-0 z-20 shadow-md">
65
+ <div class="flex items-center gap-3">
66
+ <div class="w-8 h-8 rounded bg-brand-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
67
+ <i class="fa-solid fa-file-medical-alt text-white"></i>
68
+ </div>
69
+ <h1 class="font-bold text-xl tracking-tight">Incident Postmortem Pro <span class="text-xs font-normal text-gray-400 ml-2 border border-gray-600 px-2 py-0.5 rounded-full">事故复盘大师</span></h1>
70
+ </div>
71
+ <div class="flex gap-4">
72
+ <button v-if="currentView === 'editor'" @click="saveCurrent(true)" class="text-sm text-gray-400 hover:text-white transition flex items-center gap-1">
73
+ <i class="fa-solid fa-save"></i> 保存
74
+ </button>
75
+ <button v-if="currentView === 'editor'" @click="backToList" class="text-sm text-gray-400 hover:text-white transition flex items-center gap-1">
76
+ <i class="fa-solid fa-arrow-left"></i> 返回列表
77
+ </button>
78
+ </div>
79
+ </header>
80
+
81
+ <!-- Main Content -->
82
+ <main class="flex-1 overflow-hidden flex relative">
83
+
84
+ <!-- List View -->
85
+ <div v-if="currentView === 'list'" class="w-full h-full overflow-y-auto p-8">
86
+ <div class="max-w-6xl mx-auto">
87
+ <div class="flex justify-between items-center mb-8">
88
+ <div>
89
+ <h2 class="text-2xl font-bold text-white mb-2">复盘报告库</h2>
90
+ <p class="text-gray-400">记录每一次事故,转化为团队的经验资产。</p>
91
+ </div>
92
+ <button @click="createNew" class="bg-brand-600 hover:bg-brand-500 text-white px-6 py-3 rounded-lg font-medium transition shadow-lg shadow-blue-900/20 flex items-center gap-2 transform hover:-translate-y-0.5">
93
+ <i class="fa-solid fa-plus"></i> 新建复盘
94
+ </button>
95
+ </div>
96
+
97
+ <!-- Cards Grid -->
98
+ <div v-if="postmortems.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
99
+ <div v-for="pm in postmortems" :key="pm.id" @click="editPostmortem(pm)"
100
+ class="glass-panel rounded-xl p-6 cursor-pointer transition-all duration-300 group relative overflow-hidden shadow-lg">
101
+ <div class="absolute top-0 left-0 w-1 h-full" :class="getSeverityColor(pm.severity)"></div>
102
+ <div class="flex justify-between items-start mb-4">
103
+ <span class="px-2 py-1 rounded text-xs font-bold uppercase tracking-wider bg-opacity-20"
104
+ :class="getSeverityBadgeClass(pm.severity)">
105
+ ${ pm.severity }
106
+ </span>
107
+ <span class="text-xs text-gray-500 font-mono">${ pm.date }</span>
108
+ </div>
109
+ <h3 class="text-lg font-bold text-white mb-2 line-clamp-2 group-hover:text-brand-400 transition">${ pm.title || '未命名事故' }</h3>
110
+ <p class="text-sm text-gray-400 line-clamp-3 mb-4 h-12">${ pm.summary || '暂无摘要...' }</p>
111
+
112
+ <div class="flex items-center justify-between border-t border-gray-700 pt-4 mt-auto">
113
+ <div class="flex items-center gap-2">
114
+ <div class="w-6 h-6 rounded-full bg-gray-700 flex items-center justify-center text-xs text-gray-300 ring-2 ring-gray-800">
115
+ ${ (pm.owner || 'U')[0].toUpperCase() }
116
+ </div>
117
+ <span class="text-xs text-gray-400">${ pm.owner || '未分配' }</span>
118
+ </div>
119
+ <button @click.stop="deletePostmortem(pm.id)" class="text-gray-600 hover:text-red-400 transition px-2 py-1 rounded hover:bg-white/5">
120
+ <i class="fa-solid fa-trash"></i>
121
+ </button>
122
+ </div>
123
+ </div>
124
+ </div>
125
+
126
+ <!-- Empty State -->
127
+ <div v-else class="flex flex-col items-center justify-center h-96 text-center border-2 border-dashed border-gray-700 rounded-2xl bg-white/5">
128
+ <div class="w-16 h-16 bg-gray-800 rounded-full flex items-center justify-center mb-4 animate-pulse">
129
+ <i class="fa-solid fa-clipboard-check text-2xl text-gray-500"></i>
130
+ </div>
131
+ <h3 class="text-xl font-medium text-white mb-2">暂无复盘记录</h3>
132
+ <p class="text-gray-500 max-w-md mb-6">好的团队不是不犯错,而是不重复犯错。开始记录你的第一个事故复盘吧。</p>
133
+ <button @click="createNew" class="text-brand-400 hover:text-brand-300 font-medium hover:underline">
134
+ 立即创建 &rarr;
135
+ </button>
136
+ </div>
137
+ </div>
138
+ </div>
139
+
140
+ <!-- Editor View -->
141
+ <div v-else class="w-full h-full flex bg-dark-bg">
142
+ <!-- Sidebar Navigation -->
143
+ <aside class="w-64 border-r border-dark-border bg-dark-surface flex flex-col shrink-0">
144
+ <nav class="flex-1 p-4 space-y-2">
145
+ <a href="#section-basic" class="block px-4 py-2 rounded text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition flex items-center gap-3">
146
+ <i class="fa-solid fa-circle-info w-4 text-center"></i> 基本信息
147
+ </a>
148
+ <a href="#section-summary" class="block px-4 py-2 rounded text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition flex items-center gap-3">
149
+ <i class="fa-solid fa-align-left w-4 text-center"></i> 事故摘要
150
+ </a>
151
+ <a href="#section-timeline" class="block px-4 py-2 rounded text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition flex items-center gap-3">
152
+ <i class="fa-solid fa-clock w-4 text-center"></i> 时间线
153
+ </a>
154
+ <a href="#section-rootcause" class="block px-4 py-2 rounded text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition flex items-center gap-3">
155
+ <i class="fa-solid fa-magnifying-glass w-4 text-center"></i> 根因分析
156
+ </a>
157
+ <a href="#section-action" class="block px-4 py-2 rounded text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition flex items-center gap-3">
158
+ <i class="fa-solid fa-list-check w-4 text-center"></i> 改进措施
159
+ </a>
160
+ </nav>
161
+ <div class="p-4 border-t border-dark-border">
162
+ <button @click="showExportModal = true" class="w-full py-2 bg-gray-700 hover:bg-gray-600 text-white rounded transition flex items-center justify-center gap-2">
163
+ <i class="fa-solid fa-download"></i> 导出报告
164
+ </button>
165
+ </div>
166
+ </aside>
167
+
168
+ <!-- Editor Content -->
169
+ <div class="flex-1 overflow-y-auto p-8 scroll-smooth" id="editor-container">
170
+ <div class="max-w-4xl mx-auto space-y-12 pb-20">
171
+
172
+ <!-- Basic Info -->
173
+ <section id="section-basic" class="space-y-6">
174
+ <input v-model="current.title" type="text" placeholder="输入事故标题 (例如: 支付网关响应超时)"
175
+ class="w-full bg-transparent text-3xl font-bold text-white placeholder-gray-600 border-none focus:ring-0 px-0">
176
+
177
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
178
+ <div>
179
+ <label class="block text-xs font-medium text-gray-500 uppercase mb-1">发生日期</label>
180
+ <input v-model="current.date" type="date" class="w-full bg-dark-surface border border-gray-700 rounded px-3 py-2 text-white focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500">
181
+ </div>
182
+ <div>
183
+ <label class="block text-xs font-medium text-gray-500 uppercase mb-1">事故级别</label>
184
+ <select v-model="current.severity" class="w-full bg-dark-surface border border-gray-700 rounded px-3 py-2 text-white focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500">
185
+ <option value="P0">P0 - 极其严重 (全站不可用)</option>
186
+ <option value="P1">P1 - 严重 (核心功能不可用)</option>
187
+ <option value="P2">P2 - 高 (部分功能受损)</option>
188
+ <option value="P3">P3 - 中 (非核心问题)</option>
189
+ <option value="P4">P4 - 低 (轻微影响)</option>
190
+ </select>
191
+ </div>
192
+ <div>
193
+ <label class="block text-xs font-medium text-gray-500 uppercase mb-1">负责人</label>
194
+ <input v-model="current.owner" type="text" class="w-full bg-dark-surface border border-gray-700 rounded px-3 py-2 text-white focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" placeholder="输入姓名">
195
+ </div>
196
+ </div>
197
+ </section>
198
+
199
+ <!-- Summary -->
200
+ <section id="section-summary">
201
+ <h3 class="text-xl font-bold text-brand-400 mb-4 flex items-center gap-2 border-b border-gray-800 pb-2">
202
+ <i class="fa-solid fa-align-left"></i> 事故摘要 & 影响
203
+ </h3>
204
+ <div class="space-y-6">
205
+ <div>
206
+ <label class="block text-sm text-gray-400 mb-2 font-medium">发生了什么?(Summary)</label>
207
+ <textarea v-model="current.summary" rows="4" class="w-full bg-dark-surface border border-gray-700 rounded p-4 text-white focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 leading-relaxed" placeholder="简要描述事故现象..."></textarea>
208
+ </div>
209
+ <div>
210
+ <label class="block text-sm text-gray-400 mb-2 font-medium">造成了什么影响?(Impact)</label>
211
+ <textarea v-model="current.impact" rows="3" class="w-full bg-dark-surface border border-gray-700 rounded p-4 text-white focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 leading-relaxed" placeholder="例如:影响 5000 名用户下单,造成约 10 万元 GMV 损失..."></textarea>
212
+ </div>
213
+ </div>
214
+ </section>
215
+
216
+ <!-- Timeline -->
217
+ <section id="section-timeline">
218
+ <h3 class="text-xl font-bold text-brand-400 mb-4 flex items-center gap-2 border-b border-gray-800 pb-2">
219
+ <i class="fa-solid fa-clock-rotate-left"></i> 时间线回顾
220
+ </h3>
221
+ <div class="bg-dark-surface border border-gray-700 rounded-xl p-6 relative shadow-inner">
222
+ <div class="timeline-line"></div>
223
+ <div class="space-y-6 relative z-10">
224
+ <div v-for="(item, index) in current.timeline" :key="index" class="flex gap-4 group">
225
+ <div class="mt-1 w-8 h-8 rounded-full border-2 border-gray-600 bg-dark-bg flex items-center justify-center shrink-0 z-10 transition group-hover:border-brand-500 shadow-sm">
226
+ <i class="fa-solid fa-circle text-[8px] text-gray-400 group-hover:text-brand-500"></i>
227
+ </div>
228
+ <div class="flex-1 grid grid-cols-12 gap-4">
229
+ <div class="col-span-3">
230
+ <input v-model="item.time" type="time" class="w-full bg-transparent border-b border-gray-700 focus:border-brand-500 text-sm text-gray-300 py-1 focus:outline-none font-mono">
231
+ </div>
232
+ <div class="col-span-7">
233
+ <input v-model="item.description" type="text" placeholder="描述发生的事情..." class="w-full bg-transparent border-b border-gray-700 focus:border-brand-500 text-sm text-white py-1 focus:outline-none">
234
+ </div>
235
+ <div class="col-span-2 flex items-center gap-2">
236
+ <select v-model="item.type" class="bg-transparent text-xs text-gray-400 border-none focus:ring-0 cursor-pointer hover:text-white">
237
+ <option value="detection">发现</option>
238
+ <option value="investigation">排查</option>
239
+ <option value="fix">修复</option>
240
+ <option value="verification">验证</option>
241
+ </select>
242
+ <button @click="removeTimelineItem(index)" class="text-gray-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition px-2">
243
+ <i class="fa-solid fa-times"></i>
244
+ </button>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ <button @click="addTimelineItem" class="mt-6 ml-12 text-sm text-brand-400 hover:text-brand-300 flex items-center gap-2 transition hover:underline">
250
+ <i class="fa-solid fa-plus-circle"></i> 添加时间节点
251
+ </button>
252
+ </div>
253
+ </section>
254
+
255
+ <!-- Root Cause Analysis -->
256
+ <section id="section-rootcause">
257
+ <h3 class="text-xl font-bold text-brand-400 mb-4 flex items-center gap-2 border-b border-gray-800 pb-2">
258
+ <i class="fa-solid fa-magnifying-glass-arrow-right"></i> 根因分析 (5 Whys)
259
+ </h3>
260
+ <div class="space-y-4">
261
+ <div v-for="(why, index) in current.root_cause.whys" :key="index" class="flex gap-4 items-start">
262
+ <div class="w-24 shrink-0 text-right pt-3 font-bold text-gray-500 font-mono">Why #${index + 1}?</div>
263
+ <div class="flex-1 relative">
264
+ <input v-model="current.root_cause.whys[index]" type="text"
265
+ :placeholder="index === 0 ? '为什么会发生这个问题?' : '为什么会出现上面这个原因?'"
266
+ class="w-full bg-dark-surface border border-gray-700 rounded px-4 py-2 text-white focus:border-brand-500 focus:outline-none transition focus:ring-1 focus:ring-brand-500">
267
+ <i v-if="index < 4" class="fa-solid fa-arrow-down absolute -bottom-5 left-8 text-gray-700 text-xs"></i>
268
+ </div>
269
+ </div>
270
+ <div class="flex gap-4 mt-8 pt-6 border-t border-gray-800">
271
+ <div class="w-24 shrink-0 text-right pt-2 font-bold text-brand-500">结论</div>
272
+ <div class="flex-1">
273
+ <textarea v-model="current.root_cause.conclusion" rows="2" placeholder="总结根本原因..."
274
+ class="w-full bg-brand-900/20 border border-brand-900/50 rounded px-4 py-2 text-brand-100 focus:border-brand-500 focus:outline-none placeholder-brand-300/30"></textarea>
275
+ </div>
276
+ </div>
277
+ </div>
278
+ </section>
279
+
280
+ <!-- Action Items -->
281
+ <section id="section-action">
282
+ <h3 class="text-xl font-bold text-brand-400 mb-4 flex items-center gap-2 border-b border-gray-800 pb-2">
283
+ <i class="fa-solid fa-list-check"></i> 改进措施 (Action Items)
284
+ </h3>
285
+ <div class="bg-dark-surface border border-gray-700 rounded-xl overflow-hidden shadow-sm">
286
+ <table class="w-full text-left text-sm text-gray-400">
287
+ <thead class="bg-gray-800/50 text-gray-200 uppercase text-xs">
288
+ <tr>
289
+ <th class="px-6 py-3">任务内容</th>
290
+ <th class="px-6 py-3 w-32">负责人</th>
291
+ <th class="px-6 py-3 w-32">截止日期</th>
292
+ <th class="px-6 py-3 w-32">状态</th>
293
+ <th class="px-6 py-3 w-10"></th>
294
+ </tr>
295
+ </thead>
296
+ <tbody class="divide-y divide-gray-700">
297
+ <tr v-for="(action, index) in current.action_items" :key="index" class="group hover:bg-white/5 transition">
298
+ <td class="px-6 py-2">
299
+ <input v-model="action.task" type="text" placeholder="需要做什么..." class="w-full bg-transparent border-none focus:ring-0 text-white placeholder-gray-600">
300
+ </td>
301
+ <td class="px-6 py-2">
302
+ <input v-model="action.owner" type="text" placeholder="谁负责" class="w-full bg-transparent border-none focus:ring-0 text-white placeholder-gray-600">
303
+ </td>
304
+ <td class="px-6 py-2">
305
+ <input v-model="action.deadline" type="date" class="w-full bg-transparent border-none focus:ring-0 text-white text-xs">
306
+ </td>
307
+ <td class="px-6 py-2">
308
+ <select v-model="action.status" class="bg-transparent border-none focus:ring-0 text-xs font-medium" :class="getStatusColor(action.status)">
309
+ <option value="pending" class="text-gray-500">待处理</option>
310
+ <option value="in_progress" class="text-blue-400">进行中</option>
311
+ <option value="done" class="text-green-400">已完成</option>
312
+ </select>
313
+ </td>
314
+ <td class="px-6 py-2 text-right">
315
+ <button @click="removeActionItem(index)" class="text-gray-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition px-2">
316
+ <i class="fa-solid fa-times"></i>
317
+ </button>
318
+ </td>
319
+ </tr>
320
+ </tbody>
321
+ </table>
322
+ <div class="p-3 bg-gray-800/30 border-t border-gray-700">
323
+ <button @click="addActionItem" class="text-sm text-brand-400 hover:text-brand-300 font-medium px-3 py-1 flex items-center gap-1 transition hover:underline">
324
+ <i class="fa-solid fa-plus"></i> 添加任务
325
+ </button>
326
+ </div>
327
+ </div>
328
+ </section>
329
+
330
+ </div>
331
+ </div>
332
+ </div>
333
+ </main>
334
+
335
+ <!-- Export Modal -->
336
+ <div v-if="showExportModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" @click="showExportModal = false">
337
+ <div class="bg-dark-surface border border-gray-700 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col" @click.stop>
338
+ <div class="p-6 border-b border-gray-700 flex justify-between items-center">
339
+ <h3 class="text-xl font-bold text-white">导出报告</h3>
340
+ <button @click="showExportModal = false" class="text-gray-400 hover:text-white transition"><i class="fa-solid fa-times"></i></button>
341
+ </div>
342
+ <div class="p-6 overflow-y-auto bg-gray-900">
343
+ <div class="prose prose-invert max-w-none" v-html="previewMarkdown"></div>
344
+ </div>
345
+ <div class="p-6 border-t border-gray-700 flex justify-end gap-4 bg-dark-surface">
346
+ <button @click="copyMarkdown" class="px-4 py-2 text-gray-300 hover:text-white hover:bg-gray-700 rounded transition flex items-center gap-2">
347
+ <i class="fa-solid fa-copy"></i> 复制 Markdown
348
+ </button>
349
+ <!-- Future: Export Image functionality could be added here -->
350
+ <button @click="showExportModal = false" class="px-6 py-2 bg-brand-600 hover:bg-brand-500 text-white rounded font-medium transition shadow-lg shadow-blue-900/30">
351
+ 关闭
352
+ </button>
353
+ </div>
354
+ </div>
355
+ </div>
356
+ </div>
357
+
358
+ <script>
359
+ const { createApp, ref, computed, onMounted, watch } = Vue;
360
+
361
+ createApp({
362
+ delimiters: ['${', '}'],
363
+ setup() {
364
+ const currentView = ref('list');
365
+ const postmortems = ref([]);
366
+ const showExportModal = ref(false);
367
+ const previewMarkdownContent = ref('');
368
+
369
+ const emptyPostmortem = {
370
+ id: null,
371
+ title: '',
372
+ date: new Date().toISOString().split('T')[0],
373
+ severity: 'P2',
374
+ owner: '',
375
+ summary: '',
376
+ impact: '',
377
+ timeline: [
378
+ { time: '10:00', description: '监控系统发出高延迟警报', type: 'detection' }
379
+ ],
380
+ root_cause: {
381
+ whys: ['', '', '', '', ''],
382
+ conclusion: ''
383
+ },
384
+ action_items: [
385
+ { task: '', owner: '', deadline: '', status: 'pending' }
386
+ ]
387
+ };
388
+
389
+ const current = ref(JSON.parse(JSON.stringify(emptyPostmortem)));
390
+
391
+ // Fetch Data
392
+ const fetchPostmortems = async () => {
393
+ try {
394
+ const res = await fetch('/api/postmortems');
395
+ postmortems.value = await res.json();
396
+ } catch (e) {
397
+ console.error(e);
398
+ }
399
+ };
400
+
401
+ onMounted(fetchPostmortems);
402
+
403
+ // Actions
404
+ const createNew = () => {
405
+ current.value = JSON.parse(JSON.stringify(emptyPostmortem));
406
+ currentView.value = 'editor';
407
+ };
408
+
409
+ const editPostmortem = (pm) => {
410
+ // Deep copy to avoid modifying list directly
411
+ current.value = JSON.parse(JSON.stringify(pm));
412
+ // Ensure structure integrity (in case old data is missing fields)
413
+ if (!current.value.timeline) current.value.timeline = [];
414
+ if (!current.value.root_cause) current.value.root_cause = { whys: ['', '', '', '', ''], conclusion: '' };
415
+ if (!current.value.action_items) current.value.action_items = [];
416
+ currentView.value = 'editor';
417
+ };
418
+
419
+ const saveCurrent = async (stayInEditor = false) => {
420
+ const method = current.value.id ? 'PUT' : 'POST';
421
+ const url = current.value.id ? `/api/postmortems/${current.value.id}` : '/api/postmortems';
422
+
423
+ try {
424
+ const res = await fetch(url, {
425
+ method,
426
+ headers: { 'Content-Type': 'application/json' },
427
+ body: JSON.stringify(current.value)
428
+ });
429
+ const saved = await res.json();
430
+ current.value.id = saved.id; // Update ID if new
431
+
432
+ await fetchPostmortems();
433
+
434
+ if (!stayInEditor) {
435
+ currentView.value = 'list';
436
+ } else {
437
+ // Show toast or feedback?
438
+ // alert('已保存'); // 简单提示
439
+ }
440
+ } catch (e) {
441
+ alert('保存失败');
442
+ }
443
+ };
444
+
445
+ const backToList = async () => {
446
+ await saveCurrent(false); // Auto save on back
447
+ };
448
+
449
+ const deletePostmortem = async (id) => {
450
+ if (!confirm('确定要删除这个复盘记录吗?')) return;
451
+ await fetch(`/api/postmortems/${id}`, { method: 'DELETE' });
452
+ await fetchPostmortems();
453
+ };
454
+
455
+ // Helper methods
456
+ const addTimelineItem = () => {
457
+ current.value.timeline.push({ time: '', description: '', type: 'investigation' });
458
+ };
459
+ const removeTimelineItem = (index) => {
460
+ current.value.timeline.splice(index, 1);
461
+ };
462
+ const addActionItem = () => {
463
+ current.value.action_items.push({ task: '', owner: '', deadline: '', status: 'pending' });
464
+ };
465
+ const removeActionItem = (index) => {
466
+ current.value.action_items.splice(index, 1);
467
+ };
468
+
469
+ // Styling helpers
470
+ const getSeverityColor = (sev) => {
471
+ const map = { 'P0': 'bg-red-500', 'P1': 'bg-orange-500', 'P2': 'bg-yellow-500', 'P3': 'bg-blue-500', 'P4': 'bg-gray-500' };
472
+ return map[sev] || 'bg-gray-500';
473
+ };
474
+ const getSeverityBadgeClass = (sev) => {
475
+ const map = {
476
+ 'P0': 'bg-red-500 text-red-500',
477
+ 'P1': 'bg-orange-500 text-orange-500',
478
+ 'P2': 'bg-yellow-500 text-yellow-500',
479
+ 'P3': 'bg-blue-500 text-blue-500',
480
+ 'P4': 'bg-gray-500 text-gray-500'
481
+ };
482
+ return map[sev] || 'bg-gray-500 text-gray-500';
483
+ };
484
+ const getStatusColor = (status) => {
485
+ return { 'pending': 'text-gray-400', 'in_progress': 'text-blue-400', 'done': 'text-green-400' }[status];
486
+ };
487
+
488
+ // Export
489
+ const previewMarkdown = computed(() => {
490
+ if (!showExportModal.value) return '';
491
+ // Generate MD on the fly for preview
492
+ let md = `# ${current.value.title || '无标题'}\n\n`;
493
+ md += `> **日期**: ${current.value.date} | **级别**: ${current.value.severity} | **负责人**: ${current.value.owner}\n\n`;
494
+
495
+ md += `## 1. 事故摘要\n${current.value.summary}\n\n`;
496
+ md += `## 2. 影响范围\n${current.value.impact}\n\n`;
497
+
498
+ md += `## 3. 时间线\n`;
499
+ current.value.timeline.forEach(t => {
500
+ md += `- **${t.time}** (${t.type}): ${t.description}\n`;
501
+ });
502
+
503
+ md += `\n## 4. 根因分析 (5 Whys)\n`;
504
+ current.value.root_cause.whys.forEach((w, i) => {
505
+ if(w) md += `${i+1}. Why? ${w}\n`;
506
+ });
507
+ md += `\n**结论**: ${current.value.root_cause.conclusion}\n\n`;
508
+
509
+ md += `## 5. 改进措施\n`;
510
+ md += `| 任务 | 负责人 | 状态 |\n|---|---|---|\n`;
511
+ current.value.action_items.forEach(a => {
512
+ md += `| ${a.task} | ${a.owner} | ${a.status} |\n`;
513
+ });
514
+
515
+ previewMarkdownContent.value = md;
516
+ return marked.parse(md);
517
+ });
518
+
519
+ const copyMarkdown = () => {
520
+ navigator.clipboard.writeText(previewMarkdownContent.value);
521
+ alert('已复制到剪贴板');
522
+ };
523
+
524
+ return {
525
+ currentView, postmortems, current, showExportModal, previewMarkdown,
526
+ createNew, editPostmortem, backToList, saveCurrent, deletePostmortem,
527
+ addTimelineItem, removeTimelineItem, addActionItem, removeActionItem,
528
+ getSeverityColor, getSeverityBadgeClass, getStatusColor, copyMarkdown
529
+ };
530
+ }
531
+ }).mount('#app');
532
+ </script>
533
+ </body>
534
+ </html>