Spaces:
Sleeping
Sleeping
Trae Assistant commited on
Commit ·
7bb2782
0
Parent(s):
Initial commit: Incident Postmortem Pro with 5-Whys, Timeline, and robust features
Browse files- Dockerfile +15 -0
- README.md +55 -0
- app.py +176 -0
- requirements.txt +1 -0
- 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 |
+
立即创建 →
|
| 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>
|