说明

这是一个流程驱动的故障记录助手,它主要用于指导用户按照预设的步骤流程(存储在一个名为 flow.xlsx 的 Excel 文件中)记录故障处理过程,并自动将记录内容和截图整理成一份格式化的 Word 文档 (.docx)。

代码

以下是完整的代码,还需要一个流程表格flow.xlsx,即可正常运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507

"""
- 使用 mss 截图和自定义 Tkinter 窗口实现稳定的区域截图功能。
- 截图功能已修复双屏显示和不透明背景问题。
- 日志文件命名和路径:项目名称_时间,保存到 '故障记录' 目录下。
- 截图保存到 '故障记录/截图记录',命名为 步骤名称_时间.jpg。
"""

import os
import json
import datetime
import tempfile
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
# 已移除 import platform 和 import subprocess
from openpyxl import load_workbook
from docx import Document
from docx.shared import Inches
from PIL import Image, ImageTk
import mss

# ---------- 配置 ----------
EXCEL_FILENAME = "flow.xlsx"
PROGRESS_FILE = "progress.json"
LOG_PREFIX = "fault_log_"
MAX_IMG_PIXEL = 1400

# 新的目录配置
FAULT_LOG_DIR = "故障记录"
SCREENSHOT_DIR = os.path.join(FAULT_LOG_DIR, "截图记录")
# -------------------------

# ------------------ 工具方法 ------------------
def load_flow_from_excel(path):
wb = load_workbook(path)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
headers = [h.strip() if h else "" for h in rows[0]]
idx = {h: i for i, h in enumerate(headers)}

flow = {}
order = []
for r in rows[1:]:
if not r or not r[idx.get("step_id",0)]:
continue
sid = str(r[idx["step_id"]]).strip()
flow[sid] = {
"id": sid,
"type": (r[idx.get("type","")] or "").strip(),
"name": (r[idx.get("name","")] or "").strip(),
"next": (r[idx.get("next","")] or "").strip(),
"yes_next": (r[idx.get("yes_next","")] or "").strip(),
"no_next": (r[idx.get("no_next","")] or "").strip(),
"role": (r[idx.get("role","")] or "").strip(),
"remark": (r[idx.get("remark","")] or "").strip(),
}
order.append(sid)
return flow, order

def find_start(flow, order):
for sid in order:
if flow[sid]["type"] == "start":
return sid
return order[0]

def save_progress(progress):
with open(PROGRESS_FILE, "w", encoding="utf-8") as f:
json.dump(progress, f, ensure_ascii=False, indent=2)

def load_progress():
if os.path.exists(PROGRESS_FILE):
try:
with open(PROGRESS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except:
return {}
return {}

def save_failed_step(step_record, error_msg):
data = {
"time": datetime.datetime.now().isoformat(),
"error": error_msg,
"step": step_record
}
with open("lost_records.json", "a", encoding="utf-8") as f:
f.write(json.dumps(data, ensure_ascii=False) + "\n")

def create_new_doc(project_name):
os.makedirs(FAULT_LOG_DIR, exist_ok=True) # 确保目录存在

doc = Document()
doc.add_heading("故障记录", level=1)
doc.add_paragraph(f"项目名称:{project_name}")
doc.add_paragraph(f"创建时间:{datetime.datetime.now().isoformat()}")

timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = os.path.join(FAULT_LOG_DIR, f"{project_name}_{timestamp}.docx")

doc.save(filename)
return doc, filename

def embed_image(doc, img_path):
try:
if not os.path.exists(img_path):
return False, "文件不存在"

im = Image.open(img_path)
if im.mode in ("RGBA","P"):
im = im.convert("RGB")

w, h = im.size
if max(w,h) > MAX_IMG_PIXEL:
r = MAX_IMG_PIXEL / max(w,h)
im.thumbnail((int(w*r), int(h*r)))

# 使用临时文件保存缩放后的图片以便插入
fd, tmp_path = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
im.save(tmp_path, "JPEG", quality=85)

doc.add_picture(tmp_path, width=Inches(6))
os.remove(tmp_path)
return True, ""
except Exception as e:
return False, str(e)

def insert_step(doc, rec):
doc.add_heading(f"步骤 {rec['step_id']}: {rec['name']}", level=2)
doc.add_paragraph(f"角色: {rec.get('role','')}")
doc.add_paragraph(f"开始: {rec.get('start_time','')}")
doc.add_paragraph(f"结束: {rec.get('end_time','')}")
doc.add_paragraph("备注:")
doc.add_paragraph(rec.get("remark",""))

for p in rec.get("screenshots", []):
ok, info = embed_image(doc, p)
if not ok:
doc.add_paragraph(f"插入失败: {p} ({info})")
doc.add_page_break()

# ------------------ 区域选择工具类 (修复版) ------------------
class ScreenShooter(tk.Toplevel):
"""
使用 mss 截图和 Tkinter UI 实现区域选择。
修复双屏问题:截取所有屏幕,并以不透明的方式显示全景图。
"""
def __init__(self, master=None):

# 1. 使用 mss 截取所有屏幕,获取一个包含所有显示器的“全景”图像
with mss.mss() as sct:
monitors = sct.monitors

# 计算全景图的边界:包含所有屏幕的最小矩形
# monitors[0] 是一个包含所有屏幕的虚拟 monitor 字典
monitor = monitors[0]

sct_img = sct.grab(monitor)
self.full_image = Image.frombytes("RGB", sct_img.size, sct_img.rgb)
self.offset_x = monitor["left"]
self.offset_y = monitor["top"]

super().__init__(master)
self.master.withdraw() # 隐藏主窗口

# 2. 设置窗口属性:全屏,不使用透明度 (-alpha)
self.overrideredirect(True)

# 使用 mss 提供的虚拟 monitor 尺寸设置窗口大小,确保覆盖所有屏幕
total_width = monitor["width"]
total_height = monitor["height"]

# 将窗口定位到所有屏幕区域的左上角 (通常是 0, 0 或负坐标)
self.geometry(f"{total_width}x{total_height}+{self.offset_x}+{self.offset_y}")

# 3. Canvas 用于显示全景图和绘制选择框
self.canvas = tk.Canvas(self, cursor="cross", highlightthickness=0, bg='grey10')
self.canvas.pack(fill=tk.BOTH, expand=tk.YES)

self.canvas_image = ImageTk.PhotoImage(self.full_image)
# 在 Canvas 上显示全景图片
self.canvas.create_image(0, 0, image=self.canvas_image, anchor=tk.NW)

self.start_x = None
self.start_y = None
self.rect = None
self.captured_area = None

self.canvas.bind("<ButtonPress-1>", self.on_button_press)
self.canvas.bind("<B1-Motion>", self.on_mouse_drag)
self.canvas.bind("<ButtonRelease-1>", self.on_button_release)
self.bind("<Escape>", self.on_cancel)

def on_button_press(self, event):
# Tkinter 事件坐标 (相对于 Canvas)
self.start_x = event.x
self.start_y = event.y
if self.rect:
self.canvas.delete(self.rect)
# 绘制选择框
self.rect = self.canvas.create_rectangle(self.start_x, self.start_y, self.start_x, self.start_y, outline='red', width=2)

def on_mouse_drag(self, event):
self.canvas.coords(self.rect, self.start_x, self.start_y, event.x, event.y)

def on_button_release(self, event):
x1, y1, x2, y2 = self.canvas.coords(self.rect)

# 确保坐标顺序正确 (左上角, 右下角)
left = int(min(x1, x2))
top = int(min(y1, y2))
right = int(max(x1, x2))
bottom = int(max(y1, y2))

# 从全景图像中裁剪出区域 (PIL crop 方法需要整数)
if right > left and bottom > top:
self.captured_area = self.full_image.crop((left, top, right, bottom))

self.destroy() # 关闭截图窗口
self.master.deiconify() # 恢复主窗口

def on_cancel(self, event=None):
self.captured_area = None
self.destroy()
self.master.deiconify()

def get_screenshot(self):
# 阻塞直到窗口关闭
self.master.wait_window(self)
return self.captured_area
# ---------------------------------------------------


# ------------------ GUI 主体 ------------------
class App:
def __init__(self, root, flow, order):
self.root = root
self.flow = flow
self.order = order

self.progress = load_progress()
self.step_records = self.progress.get("step_records", {})

self.current_step = self.progress.get("current_step") or find_start(flow, order)

self.project_name = self.progress.get("project_name", "")
if not self.project_name:
self.get_project_name()

self.doc = None
self.log_path = None

self.build_ui()
self.show_step(self.current_step)

# ---------------- UI ----------------
def build_ui(self):
self.root.title("流程故障记录助手(修复版)")
self.root.geometry("900x580")

frame = ttk.Frame(self.root, padding=10)
frame.pack(fill="both", expand=True)

self.lbl_project = ttk.Label(frame, text=f"项目: {self.project_name}", font=("Arial",12))
self.lbl_project.pack(anchor="w", pady=(0, 5))

self.lbl_title = ttk.Label(frame, text="", font=("Arial",16,"bold"))
self.lbl_title.pack(anchor="w")

self.lbl_role = ttk.Label(frame, text="", font=("Arial",11))
self.lbl_role.pack(anchor="w", pady=(0,10))

ttk.Label(frame, text="备注:").pack(anchor="w")
self.txt_remark = tk.Text(frame, height=5)
self.txt_remark.pack(fill="x")

ssf = ttk.Frame(frame)
ssf.pack(fill="x", pady=8)
ttk.Label(ssf, text="截图:").pack(side="left")

ttk.Button(ssf, text="手动添加截图", command=self.add_screenshot_file).pack(side="left", padx=6)
ttk.Button(ssf, text="自动区域截图 (mss)", command=self.capture_area).pack(side="left", padx=6)

# 已移除:调用 Windows 自带截图工具的按钮

self.lst_ss = tk.Listbox(frame, height=4)
self.lst_ss.pack(fill="x")

btnf = ttk.Frame(frame)
btnf.pack(fill="x", pady=15)

ttk.Button(btnf, text="上一步", command=self.go_prev).pack(side="left")

self.btn_next = ttk.Button(btnf, text="下一步", command=self.go_next)
self.btn_yes = ttk.Button(btnf, text="是", command=lambda: self.choose("yes"))
self.btn_no = ttk.Button(btnf, text="否", command=lambda: self.choose("no"))

ttk.Button(btnf, text="导出全文日志", command=self.export_full).pack(side="right")
ttk.Button(btnf, text="保存并退出", command=self.save_exit).pack(side="right", padx=6)

ttk.Label(frame, text="已记录步骤:").pack(anchor="w")
self.lst_history = tk.Listbox(frame, height=8)
self.lst_history.pack(fill="both", expand=True)

# ---------------- 逻辑 ----------------

def get_project_name(self):
name = simpledialog.askstring("项目名称", "请输入项目名称:", initialvalue=self.project_name)
if name:
self.project_name = name.strip()
self.progress["project_name"] = self.project_name
save_progress(self.progress)
else:
self.project_name = self.project_name or "未命名项目"
self.progress["project_name"] = self.project_name
save_progress(self.progress)

def show_step(self, sid):
step = self.flow[sid]
self.current_step = sid

rec = self.step_records.setdefault(sid, {
"step_id": sid,
"name": step["name"],
"role": step["role"],
"start_time": datetime.datetime.now().isoformat(),
"end_time": "",
"remark": "",
"screenshots": [],
"exported": False
})

self.lbl_project.config(text=f"项目: {self.project_name}")
self.lbl_title.config(text=f"{sid}: {step['name']}")
self.lbl_role.config(text=f"角色: {step['role']}")

self.txt_remark.delete("1.0", tk.END)
self.txt_remark.insert("1.0", rec["remark"])

self.lst_ss.delete(0, tk.END)
for p in rec["screenshots"]:
self.lst_ss.insert(tk.END, p)

if step["type"] == "decision":
self.btn_next.pack_forget()
self.btn_yes.pack(side="left", padx=6)
self.btn_no.pack(side="left", padx=6)
else:
self.btn_yes.pack_forget()
self.btn_no.pack_forget()
self.btn_next.pack(side="left", padx=6)

self.refresh_history()

self.progress["current_step"] = sid
self.progress["step_records"] = self.step_records
save_progress(self.progress)

def refresh_history(self):
self.lst_history.delete(0, tk.END)
items = list(self.step_records.values())
try:
items.sort(key=lambda x: x["start_time"])
except:
pass
for r in items:
mark = "✓" if r.get("exported") else ""
self.lst_history.insert(tk.END, f"{r['step_id']} {mark} {r['start_time']}")

# ---- 操作动作 ----

def add_screenshot_file(self):
p = filedialog.askopenfilename(title="选择截图")
if not p:
return
r = self.step_records[self.current_step]
r["screenshots"].append(p)
r["exported"] = False
self.lst_ss.insert(tk.END, p)
save_progress(self.progress)

def capture_area(self):
# 启动自定义区域选择工具 (mss + Tkinter)
try:
shooter = ScreenShooter(self.root)
screenshot_img = shooter.get_screenshot()
except Exception as e:
messagebox.showerror("截图失败", f"无法进行区域截图。请确保 mss 和 Pillow 已安装且环境支持。\n错误信息: {e}")
return

# 检查截图结果是否为空(用户可能按Esc取消了)
if screenshot_img and screenshot_img.size != (0, 0):
# 1. 保存截图文件
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
step_name = self.flow[self.current_step]["name"].replace(":", "_").replace(" ", "_")
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = os.path.join(SCREENSHOT_DIR, f"{step_name}_{timestamp}.jpg")

try:
# 截图结果是 PIL Image 对象,直接调用 save 方法
screenshot_img.save(filename, "JPEG", quality=85)
except Exception as e:
messagebox.showerror("保存失败", f"截图保存到 {SCREENSHOT_DIR} 失败。\n错误信息: {e}")
return

# 2. 添加到记录列表
r = self.step_records[self.current_step]
r["screenshots"].append(filename)
r["exported"] = False
self.lst_ss.insert(tk.END, filename)
save_progress(self.progress)
messagebox.showinfo("截图完成", f"区域截图已保存并添加到步骤:\n{filename}")
else:
messagebox.showinfo("取消", "已取消区域截图。")

# 已移除:def call_win_snipping_tool(self): ...

def finish_current(self):
r = self.step_records[self.current_step]
r["end_time"] = datetime.datetime.now().isoformat()
r["remark"] = self.txt_remark.get("1.0", tk.END).strip()
r["exported"] = False
save_progress(self.progress)

# 第一次写入时创建 Word
if self.doc is None:
if not self.project_name:
self.get_project_name()
self.doc, self.log_path = create_new_doc(self.project_name)

# 写入 Word,若失败保存到 lost_records.json
try:
insert_step(self.doc, r)
self.doc.save(self.log_path)
r["exported"] = True
save_progress(self.progress)
except Exception as e:
save_failed_step(r, str(e))
# 仅警告,不阻塞流程
# messagebox.showwarning("Word 写入失败", f"步骤日志写入 Word 失败,已保存到 lost_records.json。\n错误信息: {e}")


def go_next(self):
self.finish_current()
nxt = self.flow[self.current_step]["next"]
if not nxt:
messagebox.showinfo("提示","已到最后一步")
return
self.show_step(nxt)

def choose(self, op):
self.finish_current()
step = self.flow[self.current_step]
nxt = step["yes_next"] if op == "yes" else step["no_next"]
if not nxt:
messagebox.showinfo("提示","分支未配置")
return
self.show_step(nxt)

def go_prev(self):
keys = list(self.step_records.keys())
try:
idx = keys.index(self.current_step)
if idx > 0:
self.show_step(keys[idx-1])
except:
pass

def export_full(self):
if not self.project_name:
self.get_project_name()

doc, path = create_new_doc(self.project_name)

# 确保当前步骤的备注已保存
self.finish_current()

items = list(self.step_records.values())
items.sort(key=lambda x: x["start_time"])

for r in items:
# 重新插入所有步骤,确保日志完整
insert_step(doc, r)

doc.save(path)
messagebox.showinfo("完成", f"日志已导出到 {path}")

def save_exit(self):
self.finish_current() # 保存当前步骤
save_progress(self.progress)
self.root.quit()


# ------------------ MAIN ------------------
def main():
if not os.path.exists(EXCEL_FILENAME):
messagebox.showerror("错误", f"找不到流程表 {EXCEL_FILENAME}")
return

flow, order = load_flow_from_excel(EXCEL_FILENAME)

root = tk.Tk()
App(root, flow, order)
root.mainloop()


if __name__ == "__main__":
main()

表格

示例数据

step_id type name next yes_next no_next role remark
S001 start 开始 S002 客户
S002 normal 发现故障 S003 工程师
S003 decision 是否能够定位 S005 S004 工程师
S004 normal 升级处理 S007 二线
S005 normal 工程师常规排查 S006 工程师 .

要做的:

  • 只需要维护这个表格
  • 流程变化时只需要改表格,不需要改代码