Trae Assistant commited on
Commit
836c795
·
0 Parent(s):

Enhance: Robust import, default data, and error handling

Browse files
Files changed (4) hide show
  1. Dockerfile +14 -0
  2. README.md +32 -0
  3. app.py +45 -0
  4. templates/index.html +460 -0
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY . /app
6
+
7
+ RUN pip install --no-cache-dir flask gunicorn
8
+
9
+ # Create a non-root user for security (required by HF Spaces)
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV PATH="/home/user/.local/bin:$PATH"
13
+
14
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 风险矩阵工坊 (Risk Matrix Studio)
3
+ emoji: 🛡️
4
+ colorFrom: red
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: 可视化风险评估矩阵工具
9
+ ---
10
+
11
+ # 风险矩阵工坊 (Risk Matrix Studio)
12
+
13
+ 一个用于商业和项目管理的风险评估可视化工具。通过标准的概率-影响矩阵(Probability-Impact Matrix)来识别、评估和可视化风险。
14
+
15
+ ## 功能特点
16
+
17
+ - **可视化矩阵**: 自动生成的 5x5 热力图矩阵。
18
+ - **风险管理**: 添加、编辑和删除风险条目。
19
+ - **自动评分**: 根据“可能性”和“影响程度”自动计算风险分值。
20
+ - **数据持久化**: 数据保存在浏览器本地 (LocalStorage)。
21
+ - **导入/导出**: 支持 JSON 格式的数据导入导出。
22
+ - **图片导出**: 一键将矩阵导出为 PNG 图片。
23
+ - **中文界面**: 全中文界面,操作直观。
24
+
25
+ ## 部署
26
+
27
+ 本项目配置为 Docker 部署,可直接在 Hugging Face Spaces 上运行。
28
+
29
+ ```bash
30
+ docker build -t risk-matrix-studio .
31
+ docker run -p 7860:7860 risk-matrix-studio
32
+ ```
app.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, jsonify, request, send_file
2
+ import os
3
+ import json
4
+ from datetime import datetime
5
+
6
+ app = Flask(__name__)
7
+ app.secret_key = os.urandom(24)
8
+
9
+ # Ensure templates directory exists
10
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
11
+ TEMPLATES_DIR = os.path.join(BASE_DIR, 'templates')
12
+
13
+ @app.route('/')
14
+ def index():
15
+ return render_template('index.html')
16
+
17
+ @app.route('/health')
18
+ def health():
19
+ return jsonify({"status": "healthy", "timestamp": datetime.now().isoformat()})
20
+
21
+ @app.errorhandler(404)
22
+ def page_not_found(e):
23
+ # If API request, return JSON
24
+ if request.path.startswith('/api/'):
25
+ return jsonify(error="Resource not found"), 404
26
+ # Otherwise return a simple error page or redirect to index
27
+ # For a SPA-like app, often we just want to show a friendly message
28
+ return render_template('index.html'), 404
29
+
30
+ @app.errorhandler(500)
31
+ def internal_server_error(e):
32
+ return jsonify(error="Internal Server Error", message=str(e)), 500
33
+
34
+ @app.errorhandler(Exception)
35
+ def handle_exception(e):
36
+ # Pass through HTTP errors
37
+ if hasattr(e, "code"):
38
+ return e
39
+ # Handle non-HTTP errors
40
+ return jsonify(error="Unexpected Error", message=str(e)), 500
41
+
42
+
43
+ if __name__ == '__main__':
44
+ port = int(os.environ.get('PORT', 7860))
45
+ app.run(host='0.0.0.0', port=port)
templates/index.html ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>风险矩阵工坊 | Risk Matrix Studio</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://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
10
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
11
+ <style>
12
+ body {
13
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
14
+ background-color: #f3f4f6;
15
+ }
16
+ .matrix-cell {
17
+ transition: all 0.2s;
18
+ }
19
+ .matrix-cell:hover {
20
+ transform: scale(1.02);
21
+ z-index: 10;
22
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
23
+ }
24
+ .risk-dot {
25
+ transition: all 0.2s;
26
+ }
27
+ .risk-dot:hover {
28
+ transform: scale(1.2);
29
+ }
30
+ /* Custom Scrollbar */
31
+ ::-webkit-scrollbar {
32
+ width: 8px;
33
+ height: 8px;
34
+ }
35
+ ::-webkit-scrollbar-track {
36
+ background: #f1f1f1;
37
+ }
38
+ ::-webkit-scrollbar-thumb {
39
+ background: #c1c1c1;
40
+ border-radius: 4px;
41
+ }
42
+ ::-webkit-scrollbar-thumb:hover {
43
+ background: #a8a8a8;
44
+ }
45
+ [v-cloak] { display: none; }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <div id="app" v-cloak class="min-h-screen flex flex-col">
50
+ <!-- Header -->
51
+ <header class="bg-white shadow-sm z-20">
52
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex justify-between items-center">
53
+ <div class="flex items-center space-x-3">
54
+ <div class="w-10 h-10 bg-red-600 rounded-lg flex items-center justify-center text-white text-xl font-bold shadow-md">
55
+ <i class="fa-solid fa-shield-halved"></i>
56
+ </div>
57
+ <h1 class="text-xl font-bold text-gray-900 tracking-tight">风险矩阵工坊 <span class="text-xs font-normal text-gray-500 ml-2">Risk Matrix Studio</span></h1>
58
+ </div>
59
+ <div class="flex items-center space-x-3">
60
+ <button @click="exportData" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium transition-colors">
61
+ <i class="fa-solid fa-download mr-1"></i> 导出数据
62
+ </button>
63
+ <label class="cursor-pointer text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium transition-colors">
64
+ <i class="fa-solid fa-upload mr-1"></i> 导入数据
65
+ <input type="file" @change="importData" class="hidden" accept=".json">
66
+ </label>
67
+ <button @click="exportImage" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium shadow-sm transition-colors flex items-center">
68
+ <i class="fa-solid fa-camera mr-2"></i> 导出图片
69
+ </button>
70
+ </div>
71
+ </div>
72
+ </header>
73
+
74
+ <!-- Main Content -->
75
+ <main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
76
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-8 h-full">
77
+
78
+ <!-- Left Column: Matrix Visualization -->
79
+ <div class="lg:col-span-7 flex flex-col space-y-4">
80
+ <div class="bg-white rounded-xl shadow-lg p-6 border border-gray-100" id="matrix-capture">
81
+ <div class="flex justify-between items-center mb-6">
82
+ <h2 class="text-lg font-bold text-gray-800">风险矩阵热力图</h2>
83
+ <div class="flex space-x-4 text-xs text-gray-500">
84
+ <div class="flex items-center"><span class="w-3 h-3 rounded-full bg-green-200 mr-1"></span> 低风险</div>
85
+ <div class="flex items-center"><span class="w-3 h-3 rounded-full bg-yellow-200 mr-1"></span> 中风险</div>
86
+ <div class="flex items-center"><span class="w-3 h-3 rounded-full bg-red-200 mr-1"></span> 高风险</div>
87
+ </div>
88
+ </div>
89
+
90
+ <!-- Matrix Grid -->
91
+ <div class="relative pb-8 pl-8">
92
+ <!-- Y Axis Label -->
93
+ <div class="absolute left-0 top-0 bottom-8 w-6 flex items-center justify-center">
94
+ <span class="transform -rotate-90 whitespace-nowrap text-sm font-bold text-gray-600 tracking-wider">可能性 (Probability)</span>
95
+ </div>
96
+
97
+ <!-- Grid Container -->
98
+ <div class="grid grid-cols-5 gap-1 border-2 border-gray-300 bg-gray-300">
99
+ <!-- Cells generated by v-for -->
100
+ <!-- We iterate rows 5 down to 1 (Probability) -->
101
+ <template v-for="prob in [5, 4, 3, 2, 1]" :key="'row-'+prob">
102
+ <!-- Iterate cols 1 to 5 (Impact) -->
103
+ <div v-for="imp in [1, 2, 3, 4, 5]" :key="'cell-'+prob+'-'+imp"
104
+ class="matrix-cell aspect-square relative p-2 flex flex-wrap content-start gap-1 overflow-hidden"
105
+ :class="getCellColor(prob, imp)">
106
+
107
+ <!-- Risk Dots in this cell -->
108
+ <div v-for="risk in getRisksInCell(prob, imp)" :key="risk.id"
109
+ class="risk-dot w-6 h-6 rounded-full bg-white border-2 border-gray-700 flex items-center justify-center text-xs font-bold shadow-sm cursor-pointer hover:bg-gray-100"
110
+ :title="risk.name"
111
+ @click="editRisk(risk)">
112
+ ${ getRiskIndex(risk) }
113
+ </div>
114
+ </div>
115
+ </template>
116
+ </div>
117
+
118
+ <!-- X Axis Label -->
119
+ <div class="absolute bottom-0 left-8 right-0 h-6 flex items-center justify-center pt-2">
120
+ <span class="text-sm font-bold text-gray-600 tracking-wider">影响程度 (Impact)</span>
121
+ </div>
122
+
123
+ <!-- Axis Ticks (Optional visual aid) -->
124
+ <div class="absolute left-6 top-0 bottom-8 flex flex-col justify-between py-4 text-xs text-gray-400 font-mono">
125
+ <span>5</span><span>4</span><span>3</span><span>2</span><span>1</span>
126
+ </div>
127
+ <div class="absolute bottom-6 left-8 right-0 flex justify-between px-4 text-xs text-gray-400 font-mono">
128
+ <span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ <!-- Statistics -->
134
+ <div class="grid grid-cols-3 gap-4">
135
+ <div class="bg-white rounded-lg p-4 shadow-sm border border-gray-100 text-center">
136
+ <div class="text-2xl font-bold text-gray-800">${ risks.length }</div>
137
+ <div class="text-xs text-gray-500 uppercase">总风险数</div>
138
+ </div>
139
+ <div class="bg-white rounded-lg p-4 shadow-sm border border-gray-100 text-center">
140
+ <div class="text-2xl font-bold text-red-600">${ highRiskCount }</div>
141
+ <div class="text-xs text-gray-500 uppercase">高危风险 (15-25)</div>
142
+ </div>
143
+ <div class="bg-white rounded-lg p-4 shadow-sm border border-gray-100 text-center">
144
+ <div class="text-2xl font-bold text-green-600">${ lowRiskCount }</div>
145
+ <div class="text-xs text-gray-500 uppercase">低危风险 (1-4)</div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Right Column: Risk Management -->
151
+ <div class="lg:col-span-5 flex flex-col h-[calc(100vh-8rem)]">
152
+ <!-- Add/Edit Form -->
153
+ <div class="bg-white rounded-xl shadow-lg p-6 mb-4 border border-gray-100">
154
+ <h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center">
155
+ <i class="fa-solid fa-pen-to-square mr-2 text-red-500"></i>
156
+ ${ isEditing ? '编辑风险' : '添加新风险' }
157
+ </h3>
158
+ <div class="space-y-4">
159
+ <div>
160
+ <label class="block text-sm font-medium text-gray-700 mb-1">风险名称</label>
161
+ <input v-model="currentRisk.name" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-red-500 focus:border-red-500" placeholder="例如:服务器宕机">
162
+ </div>
163
+
164
+ <div class="grid grid-cols-2 gap-4">
165
+ <div>
166
+ <label class="block text-sm font-medium text-gray-700 mb-1">可能性 (1-5)</label>
167
+ <select v-model.number="currentRisk.probability" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-red-500 focus:border-red-500">
168
+ <option v-for="n in 5" :value="n">${ n }</option>
169
+ </select>
170
+ </div>
171
+ <div>
172
+ <label class="block text-sm font-medium text-gray-700 mb-1">影响程度 (1-5)</label>
173
+ <select v-model.number="currentRisk.impact" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-red-500 focus:border-red-500">
174
+ <option v-for="n in 5" :value="n">${ n }</option>
175
+ </select>
176
+ </div>
177
+ </div>
178
+
179
+ <div class="flex space-x-2 pt-2">
180
+ <button @click="saveRisk" class="flex-1 bg-gray-900 hover:bg-gray-800 text-white py-2 rounded-md font-medium transition-colors">
181
+ ${ isEditing ? '更新' : '添加' }
182
+ </button>
183
+ <button v-if="isEditing" @click="cancelEdit" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors">
184
+ 取消
185
+ </button>
186
+ </div>
187
+ </div>
188
+ </div>
189
+
190
+ <!-- Risk List -->
191
+ <div class="bg-white rounded-xl shadow-lg flex-1 overflow-hidden border border-gray-100 flex flex-col">
192
+ <div class="p-4 border-b border-gray-100 bg-gray-50">
193
+ <h3 class="font-bold text-gray-800">风险列表</h3>
194
+ </div>
195
+ <div class="overflow-y-auto p-4 space-y-3 flex-1">
196
+ <div v-if="risks.length === 0" class="text-center text-gray-400 py-8">
197
+ <i class="fa-regular fa-folder-open text-3xl mb-2"></i>
198
+ <p>暂无风险记录</p>
199
+ </div>
200
+
201
+ <div v-for="(risk, index) in sortedRisks" :key="risk.id"
202
+ class="group bg-white border border-gray-200 rounded-lg p-3 hover:shadow-md transition-all flex items-center justify-between">
203
+ <div class="flex items-center space-x-3 overflow-hidden">
204
+ <div class="flex-shrink-0 w-6 h-6 rounded-full bg-gray-100 text-gray-600 flex items-center justify-center text-xs font-bold border border-gray-300">
205
+ ${ index + 1 }
206
+ </div>
207
+ <div class="min-w-0">
208
+ <div class="font-medium text-gray-900 truncate" :title="risk.name">${ risk.name }</div>
209
+ <div class="text-xs text-gray-500 flex items-center space-x-2">
210
+ <span>P:${ risk.probability }</span>
211
+ <span>I:${ risk.impact }</span>
212
+ <span :class="getScoreColorClass(risk.probability * risk.impact)">
213
+ Score: ${ risk.probability * risk.impact }
214
+ </span>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ <div class="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
219
+ <button @click="editRisk(risk)" class="text-blue-600 hover:text-blue-800 p-1">
220
+ <i class="fa-solid fa-pen"></i>
221
+ </button>
222
+ <button @click="deleteRisk(risk.id)" class="text-red-600 hover:text-red-800 p-1">
223
+ <i class="fa-solid fa-trash"></i>
224
+ </button>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ </div>
231
+ </main>
232
+
233
+ <!-- Toast Notification -->
234
+ <div v-if="notification.show" class="fixed bottom-4 right-4 bg-gray-800 text-white px-6 py-3 rounded-lg shadow-xl z-50 transform transition-all duration-300 flex items-center">
235
+ <i class="fa-solid fa-circle-check text-green-400 mr-2"></i>
236
+ ${ notification.message }
237
+ </div>
238
+ </div>
239
+
240
+ <script>
241
+ const { createApp, ref, computed, onMounted, watch } = Vue;
242
+
243
+ createApp({
244
+ delimiters: ['${', '}'],
245
+ setup() {
246
+ const risks = ref([]);
247
+ const currentRisk = ref({
248
+ id: null,
249
+ name: '',
250
+ probability: 3,
251
+ impact: 3
252
+ });
253
+ const isEditing = ref(false);
254
+ const notification = ref({ show: false, message: '' });
255
+
256
+ // Initialize with some demo data if empty
257
+ onMounted(() => {
258
+ const saved = localStorage.getItem('risk-matrix-data');
259
+ if (saved) {
260
+ try {
261
+ risks.value = JSON.parse(saved);
262
+ } catch(e) {
263
+ console.error('Failed to load data', e);
264
+ }
265
+ } else {
266
+ // Demo Data
267
+ risks.value = [
268
+ { id: Date.now() + 1, name: '核心开发人员离职', probability: 2, impact: 5 },
269
+ { id: Date.now() + 2, name: '第三方API变更', probability: 4, impact: 3 },
270
+ { id: Date.now() + 3, name: '服务器负载过高', probability: 3, impact: 4 },
271
+ { id: Date.now() + 4, name: 'UI设计延期', probability: 3, impact: 2 },
272
+ ];
273
+ }
274
+ });
275
+
276
+ watch(risks, (newVal) => {
277
+ localStorage.setItem('risk-matrix-data', JSON.stringify(newVal));
278
+ }, { deep: true });
279
+
280
+ const highRiskCount = computed(() => risks.value.filter(r => (r.probability * r.impact) >= 15).length);
281
+ const lowRiskCount = computed(() => risks.value.filter(r => (r.probability * r.impact) < 5).length);
282
+
283
+ // Sort risks by score (descending)
284
+ const sortedRisks = computed(() => {
285
+ return [...risks.value].sort((a, b) => (b.probability * b.impact) - (a.probability * a.impact));
286
+ });
287
+
288
+ const getRiskIndex = (risk) => {
289
+ return sortedRisks.value.findIndex(r => r.id === risk.id) + 1;
290
+ };
291
+
292
+ const getRisksInCell = (prob, imp) => {
293
+ return risks.value.filter(r => r.probability === prob && r.impact === imp);
294
+ };
295
+
296
+ const getCellColor = (prob, imp) => {
297
+ const score = prob * imp;
298
+ if (score >= 15) return 'bg-red-200 border-red-300'; // High
299
+ if (score >= 8) return 'bg-yellow-100 border-yellow-200'; // Medium
300
+ return 'bg-green-100 border-green-200'; // Low
301
+ };
302
+
303
+ const getScoreColorClass = (score) => {
304
+ if (score >= 15) return 'text-red-600 font-bold';
305
+ if (score >= 8) return 'text-yellow-600 font-bold';
306
+ return 'text-green-600 font-bold';
307
+ };
308
+
309
+ const saveRisk = () => {
310
+ if (!currentRisk.value.name.trim()) {
311
+ showToast('请输入风险名称');
312
+ return;
313
+ }
314
+
315
+ if (isEditing.value) {
316
+ const index = risks.value.findIndex(r => r.id === currentRisk.value.id);
317
+ if (index !== -1) {
318
+ risks.value[index] = { ...currentRisk.value };
319
+ showToast('风险已更新');
320
+ }
321
+ } else {
322
+ risks.value.push({
323
+ ...currentRisk.value,
324
+ id: Date.now()
325
+ });
326
+ showToast('新风险已添加');
327
+ }
328
+ resetForm();
329
+ };
330
+
331
+ const editRisk = (risk) => {
332
+ currentRisk.value = { ...risk };
333
+ isEditing.value = true;
334
+ };
335
+
336
+ const deleteRisk = (id) => {
337
+ if (confirm('确定要删除这个风险条目吗?')) {
338
+ risks.value = risks.value.filter(r => r.id !== id);
339
+ if (isEditing.value && currentRisk.value.id === id) {
340
+ resetForm();
341
+ }
342
+ showToast('风险已删除');
343
+ }
344
+ };
345
+
346
+ const cancelEdit = () => {
347
+ resetForm();
348
+ };
349
+
350
+ const resetForm = () => {
351
+ currentRisk.value = { id: null, name: '', probability: 3, impact: 3 };
352
+ isEditing.value = false;
353
+ };
354
+
355
+ const showToast = (msg) => {
356
+ notification.value = { show: true, message: msg };
357
+ setTimeout(() => {
358
+ notification.value.show = false;
359
+ }, 3000);
360
+ };
361
+
362
+ const exportData = () => {
363
+ const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(risks.value));
364
+ const downloadAnchorNode = document.createElement('a');
365
+ downloadAnchorNode.setAttribute("href", dataStr);
366
+ downloadAnchorNode.setAttribute("download", "risk_matrix_data.json");
367
+ document.body.appendChild(downloadAnchorNode);
368
+ downloadAnchorNode.click();
369
+ downloadAnchorNode.remove();
370
+ };
371
+
372
+ const importData = (event) => {
373
+ const file = event.target.files[0];
374
+ if (!file) return;
375
+
376
+ // Robust size check (Limit to 5MB)
377
+ const MAX_SIZE = 5 * 1024 * 1024; // 5MB
378
+ if (file.size > MAX_SIZE) {
379
+ showToast("文件过大 (超过 5MB)");
380
+ event.target.value = '';
381
+ return;
382
+ }
383
+
384
+ const reader = new FileReader();
385
+ reader.onload = (e) => {
386
+ try {
387
+ const result = e.target.result;
388
+
389
+ // Basic binary check (look for null bytes in the first 1000 chars)
390
+ // This is a heuristic, as valid JSON shouldn't contain null bytes
391
+ if (result.indexOf('\0') !== -1) {
392
+ throw new Error("Detected binary content");
393
+ }
394
+
395
+ const data = JSON.parse(result);
396
+ if (Array.isArray(data)) {
397
+ // Validate item structure
398
+ const isValid = data.every(item =>
399
+ item.name &&
400
+ typeof item.probability === 'number' &&
401
+ typeof item.impact === 'number'
402
+ );
403
+
404
+ if (isValid) {
405
+ risks.value = data;
406
+ showToast('数据导入成功');
407
+ } else {
408
+ alert('数据格式不正确:缺少必要字段');
409
+ }
410
+ } else {
411
+ alert('无效的数据格式:必须是数组');
412
+ }
413
+ } catch (err) {
414
+ console.error(err);
415
+ alert('无法解析文件:可能是二进制文件或格式错误');
416
+ }
417
+ };
418
+ reader.onerror = () => {
419
+ alert('读取文件出错');
420
+ };
421
+ reader.readAsText(file);
422
+ // Reset input
423
+ event.target.value = '';
424
+ };
425
+
426
+ const exportImage = () => {
427
+ const el = document.getElementById('matrix-capture');
428
+ html2canvas(el, { scale: 2 }).then(canvas => {
429
+ const link = document.createElement('a');
430
+ link.download = 'risk-matrix.png';
431
+ link.href = canvas.toDataURL();
432
+ link.click();
433
+ });
434
+ };
435
+
436
+ return {
437
+ risks,
438
+ currentRisk,
439
+ isEditing,
440
+ notification,
441
+ highRiskCount,
442
+ lowRiskCount,
443
+ sortedRisks,
444
+ getRiskIndex,
445
+ getRisksInCell,
446
+ getCellColor,
447
+ getScoreColorClass,
448
+ saveRisk,
449
+ editRisk,
450
+ deleteRisk,
451
+ cancelEdit,
452
+ exportData,
453
+ importData,
454
+ exportImage
455
+ };
456
+ }
457
+ }).mount('#app');
458
+ </script>
459
+ </body>
460
+ </html>