feat: 添加桌面版应用和心跳监控

新增功能:
- src/desktop.py: PyQt6 桌面应用,支持 Droidcam 摄像头
  - 实时视频预览 + 绿色扫描框叠加
  - 空格键快速拍照识别
  - 批量记录管理和 Excel 导出
- heartbeat.py: 服务心跳监控,自动重启 Streamlit
- requirements-desktop.txt: 桌面版专用依赖

Web 版优化:
- src/app.py: 自定义摄像头组件,扫描框叠加到视频流

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-12 12:23:31 +00:00
parent 647a04d132
commit 35d05d4701
4 changed files with 620 additions and 46 deletions

53
heartbeat.py Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""心跳程序 - 保持服务活跃"""
import sys
import time
import subprocess
import requests
from datetime import datetime
# 禁用输出缓冲
sys.stdout.reconfigure(line_buffering=True)
def log(msg):
print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}", flush=True)
def check_streamlit():
"""检查 Streamlit 服务"""
try:
r = requests.get("http://localhost:8501", timeout=5)
return r.status_code == 200
except:
return False
def restart_streamlit():
"""重启 Streamlit"""
subprocess.run(["pkill", "-f", "streamlit run"], capture_output=True)
time.sleep(2)
subprocess.Popen(
["streamlit", "run", "src/app.py", "--server.port", "8501", "--server.address", "0.0.0.0"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
print(f"[{datetime.now():%H:%M:%S}] Streamlit 已重启")
def main():
log("心跳程序启动")
while True:
if not check_streamlit():
log("Streamlit 无响应,正在重启...")
restart_streamlit()
time.sleep(10)
else:
log("✓ 服务正常")
time.sleep(60) # 每分钟检查一次
if __name__ == "__main__":
main()

8
requirements-desktop.txt Normal file
View File

@@ -0,0 +1,8 @@
# 桌面版依赖(本地电脑安装)
paddleocr>=2.6,<3
paddlepaddle>=2.5,<3
pandas
openpyxl
pydantic
PyQt6
opencv-python

View File

@@ -1,14 +1,30 @@
import os import os
import tempfile import tempfile
import base64
import pandas as pd import pandas as pd
import streamlit as st import streamlit as st
import streamlit.components.v1 as components
from paddleocr import PaddleOCR from paddleocr import PaddleOCR
from processor import extract_info, save_to_excel from processor import extract_info, save_to_excel
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
st.set_page_config(page_title="信封信息提取系统", page_icon="📮", layout="wide") st.set_page_config(
st.title("📮 信封信息提取系统") page_title="信封信息提取系统",
page_icon="📮",
layout="centered",
initial_sidebar_state="collapsed",
)
st.markdown("""
<style>
.stApp { max-width: 100%; }
.stButton>button { width: 100%; height: 3em; font-size: 1.2em; }
.stDownloadButton>button { width: 100%; height: 3em; }
</style>
""", unsafe_allow_html=True)
st.title("📮 信封信息提取")
@st.cache_resource @st.cache_resource
@@ -19,10 +35,10 @@ def load_ocr():
ocr = load_ocr() ocr = load_ocr()
def process_image(image_file): def process_image(image_data):
"""处理单张图片""" """处理图片数据"""
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
tmp.write(image_file.getvalue()) tmp.write(image_data)
tmp_path = tmp.name tmp_path = tmp.name
try: try:
@@ -37,52 +53,195 @@ def process_image(image_file):
os.unlink(tmp_path) os.unlink(tmp_path)
# 文件上传 # 自定义摄像头组件,带叠加扫描框
uploaded_files = st.file_uploader( CAMERA_COMPONENT = """
"上传信封图片(支持批量)", <div id="camera-container" style="position:relative; width:100%; max-width:500px; margin:0 auto;">
type=["jpg", "jpeg", "png", "bmp"], <video id="video" autoplay playsinline style="width:100%; border-radius:10px; background:#000;"></video>
accept_multiple_files=True,
)
if uploaded_files: <!-- 扫描框叠加层 -->
all_records = [] <div id="overlay" style="
position: absolute;
top: 8%;
left: 50%;
transform: translateX(-50%);
width: 88%;
height: 70%;
border: 3px solid #00ff00;
box-sizing: border-box;
pointer-events: none;
">
<!-- 四角 -->
<div style="position:absolute; top:-3px; left:-3px; width:20px; height:20px; border-top:4px solid #00ff00; border-left:4px solid #00ff00;"></div>
<div style="position:absolute; top:-3px; right:-3px; width:20px; height:20px; border-top:4px solid #00ff00; border-right:4px solid #00ff00;"></div>
<div style="position:absolute; bottom:-3px; left:-3px; width:20px; height:20px; border-bottom:4px solid #00ff00; border-left:4px solid #00ff00;"></div>
<div style="position:absolute; bottom:-3px; right:-3px; width:20px; height:20px; border-bottom:4px solid #00ff00; border-right:4px solid #00ff00;"></div>
progress = st.progress(0) <!-- 字段提示:邮编(左上)、地址(中间)、联系人+电话(底部) -->
status = st.empty() <div style="position:absolute; top:8px; left:10px; color:rgba(255,255,255,0.6); font-size:12px;">邮编</div>
<div style="position:absolute; top:35%; left:10px; right:10px; color:rgba(255,255,255,0.6); font-size:12px; border-bottom:1px dashed rgba(255,255,255,0.3); padding-bottom:30%;">地址</div>
<div style="position:absolute; bottom:8px; left:10px; color:rgba(255,255,255,0.6); font-size:12px;">联系人</div>
<div style="position:absolute; bottom:8px; right:10px; color:rgba(255,255,255,0.6); font-size:12px;">电话</div>
</div>
for i, file in enumerate(uploaded_files): <!-- 编号提示在框外底部 -->
status.text(f"正在处理: {file.name}") <div style="position:absolute; bottom:18%; left:50%; transform:translateX(-50%); color:rgba(255,255,255,0.6); font-size:11px;">
record, raw_texts = process_image(file) ↑ 编号在此处 ↑
record["文件名"] = file.name </div>
all_records.append(record)
progress.progress((i + 1) / len(uploaded_files))
status.text("处理完成!") <canvas id="canvas" style="display:none;"></canvas>
# 显示结果表格 <p id="hint" style="text-align:center; color:#666; margin:10px 0; font-size:14px;">
df = pd.DataFrame(all_records) 📌 将信封背面对齐绿色框,编号对准底部
cols = ["文件名", "编号", "邮编", "地址", "联系人/单位名", "电话"] </p>
df = df.reindex(columns=cols)
st.subheader("📋 提取结果") <button id="capture-btn" onclick="capturePhoto()" style="
st.dataframe(df, use_container_width=True) width: 100%;
padding: 15px;
font-size: 18px;
background: #ff4b4b;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
margin-top: 10px;
">📷 拍照识别</button>
</div>
# 下载按钮 <script>
output_path = tempfile.mktemp(suffix=".xlsx") const video = document.getElementById('video');
df.to_excel(output_path, index=False) const canvas = document.getElementById('canvas');
with open(output_path, "rb") as f: const hint = document.getElementById('hint');
st.download_button(
label="📥 下载 Excel",
data=f,
file_name="信封提取结果.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
os.unlink(output_path)
# 预览图片和识别详情 // 启动后置摄像头
with st.expander("🔍 查看识别详情"): async function startCamera() {
cols = st.columns(min(3, len(uploaded_files))) try {
for i, file in enumerate(uploaded_files): const stream = await navigator.mediaDevices.getUserMedia({
with cols[i % 3]: video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
st.image(file, caption=file.name, use_container_width=True) });
st.json(all_records[i]) video.srcObject = stream;
} catch (err) {
hint.textContent = '❌ 无法访问摄像头: ' + err.message;
console.error(err);
}
}
function capturePhoto() {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
// 发送到 Streamlit
window.parent.postMessage({
type: 'streamlit:setComponentValue',
value: dataUrl
}, '*');
hint.textContent = '✅ 已拍照,正在识别...';
document.getElementById('capture-btn').disabled = true;
}
startCamera();
</script>
"""
# 初始化 session state
if "records" not in st.session_state:
st.session_state.records = []
# 输入方式选择
tab_camera, tab_upload = st.tabs(["📷 拍照扫描", "📁 上传图片"])
with tab_camera:
# 使用自定义摄像头组件
photo_data = components.html(CAMERA_COMPONENT, height=550)
# 检查是否有拍照数据
if "captured_image" not in st.session_state:
st.session_state.captured_image = None
# 文件上传作为备用用于接收JS传来的数据
uploaded_photo = st.file_uploader(
"或直接上传照片",
type=["jpg", "jpeg", "png"],
key="camera_upload",
label_visibility="collapsed"
)
if uploaded_photo:
with st.spinner("识别中..."):
record, raw_texts = process_image(uploaded_photo.getvalue())
st.success("✅ 识别完成!")
col1, col2 = st.columns(2)
with col1:
st.image(uploaded_photo, caption="拍摄图片", use_container_width=True)
with col2:
st.metric("邮编", record.get("邮编", "-"))
st.metric("电话", record.get("电话", "-"))
st.metric("联系人", record.get("联系人/单位名", "-"))
st.text_area("地址", record.get("地址", ""), disabled=True, height=68)
st.text_input("编号", record.get("编号", ""), disabled=True)
if st.button("✅ 添加到列表", type="primary", key="add_camera"):
record["来源"] = "拍照"
st.session_state.records.append(record)
st.success(f"已添加!当前共 {len(st.session_state.records)} 条记录")
st.rerun()
with tab_upload:
uploaded_files = st.file_uploader(
"选择图片文件",
type=["jpg", "jpeg", "png", "bmp"],
accept_multiple_files=True,
label_visibility="collapsed",
)
if uploaded_files:
if st.button("🚀 开始识别", type="primary"):
progress = st.progress(0)
for i, file in enumerate(uploaded_files):
with st.spinner(f"处理 {file.name}..."):
record, _ = process_image(file.getvalue())
record["来源"] = file.name
st.session_state.records.append(record)
progress.progress((i + 1) / len(uploaded_files))
st.success(f"完成!已添加 {len(uploaded_files)} 条记录")
st.rerun()
# 显示已收集的记录
st.divider()
st.subheader(f"📋 已收集 {len(st.session_state.records)} 条记录")
if st.session_state.records:
df = pd.DataFrame(st.session_state.records)
cols = ["来源", "编号", "邮编", "地址", "联系人/单位名", "电话"]
df = df.reindex(columns=[c for c in cols if c in df.columns])
st.dataframe(df, use_container_width=True, hide_index=True)
col1, col2 = st.columns(2)
with col1:
output_path = tempfile.mktemp(suffix=".xlsx")
df.to_excel(output_path, index=False)
with open(output_path, "rb") as f:
st.download_button(
"📥 下载 Excel",
data=f,
file_name="信封提取结果.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
os.unlink(output_path)
with col2:
if st.button("🗑️ 清空列表"):
st.session_state.records = []
st.rerun()
else:
st.info("👆 使用上方拍照或上传功能添加记录")

354
src/desktop.py Normal file
View File

@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
信封信息提取系统 - 桌面版
使用 Droidcam 将手机作为摄像头,实时预览并识别信封信息
"""
import os
import sys
import cv2
import tempfile
import pandas as pd
from datetime import datetime
from pathlib import Path
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QTableWidget, QTableWidgetItem, QComboBox,
QFileDialog, QMessageBox, QGroupBox, QSplitter, QHeaderView,
QStatusBar, QProgressBar
)
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
from PyQt6.QtGui import QImage, QPixmap, QFont, QAction
from paddleocr import PaddleOCR
from processor import extract_info
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
class OCRWorker(QThread):
"""OCR 识别线程"""
finished = pyqtSignal(dict, list)
error = pyqtSignal(str)
def __init__(self, ocr, image_path):
super().__init__()
self.ocr = ocr
self.image_path = image_path
def run(self):
try:
result = self.ocr.ocr(self.image_path, cls=False)
ocr_texts = []
if result and result[0]:
for line in result[0]:
if line and len(line) >= 2:
ocr_texts.append(line[1][0])
record = extract_info(ocr_texts)
self.finished.emit(record, ocr_texts)
except Exception as e:
self.error.emit(str(e))
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("📮 信封信息提取系统")
self.setMinimumSize(1200, 700)
# 初始化 OCR
self.statusBar().showMessage("正在加载 OCR 模型...")
QApplication.processEvents()
self.ocr = PaddleOCR(use_textline_orientation=True, lang="ch", show_log=False)
self.statusBar().showMessage("OCR 模型加载完成")
# 摄像头
self.cap = None
self.timer = QTimer()
self.timer.timeout.connect(self.update_frame)
# 数据
self.records = []
self.init_ui()
self.load_cameras()
def init_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QHBoxLayout(central)
# 左侧:摄像头预览
left_panel = QGroupBox("📷 摄像头预览")
left_layout = QVBoxLayout(left_panel)
# 摄像头选择
cam_layout = QHBoxLayout()
cam_layout.addWidget(QLabel("摄像头:"))
self.cam_combo = QComboBox()
self.cam_combo.setMinimumWidth(200)
cam_layout.addWidget(self.cam_combo)
self.btn_refresh = QPushButton("🔄 刷新")
self.btn_refresh.clicked.connect(self.load_cameras)
cam_layout.addWidget(self.btn_refresh)
self.btn_connect = QPushButton("▶ 连接")
self.btn_connect.clicked.connect(self.toggle_camera)
cam_layout.addWidget(self.btn_connect)
cam_layout.addStretch()
left_layout.addLayout(cam_layout)
# 视频画面
self.video_label = QLabel()
self.video_label.setMinimumSize(640, 480)
self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.video_label.setStyleSheet("background-color: #1a1a1a; border: 2px solid #333; border-radius: 8px;")
self.video_label.setText("点击「连接」启动摄像头\n\n支持 Droidcam / Iriun 等虚拟摄像头")
left_layout.addWidget(self.video_label)
# 拍照按钮
self.btn_capture = QPushButton("📸 拍照识别 (空格键)")
self.btn_capture.setMinimumHeight(50)
self.btn_capture.setFont(QFont("", 14))
self.btn_capture.setStyleSheet("background-color: #ff4b4b; color: white; border-radius: 8px;")
self.btn_capture.clicked.connect(self.capture_and_recognize)
self.btn_capture.setEnabled(False)
left_layout.addWidget(self.btn_capture)
# 右侧:结果列表
right_panel = QGroupBox(f"📋 已识别记录 (0)")
self.right_panel = right_panel
right_layout = QVBoxLayout(right_panel)
# 表格
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["编号", "邮编", "地址", "联系人", "电话"])
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
right_layout.addWidget(self.table)
# 操作按钮
btn_layout = QHBoxLayout()
self.btn_delete = QPushButton("🗑 删除选中")
self.btn_delete.clicked.connect(self.delete_selected)
btn_layout.addWidget(self.btn_delete)
self.btn_clear = QPushButton("🧹 清空全部")
self.btn_clear.clicked.connect(self.clear_all)
btn_layout.addWidget(self.btn_clear)
self.btn_export = QPushButton("📥 导出 Excel")
self.btn_export.setStyleSheet("background-color: #4CAF50; color: white;")
self.btn_export.clicked.connect(self.export_excel)
btn_layout.addWidget(self.btn_export)
right_layout.addLayout(btn_layout)
# 分割器
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(left_panel)
splitter.addWidget(right_panel)
splitter.setSizes([600, 500])
layout.addWidget(splitter)
# 快捷键
self.shortcut_capture = QAction(self)
self.shortcut_capture.setShortcut("Space")
self.shortcut_capture.triggered.connect(self.capture_and_recognize)
self.addAction(self.shortcut_capture)
def load_cameras(self):
"""扫描可用摄像头"""
self.cam_combo.clear()
for i in range(10):
cap = cv2.VideoCapture(i)
if cap.isOpened():
ret, _ = cap.read()
if ret:
self.cam_combo.addItem(f"摄像头 {i}", i)
cap.release()
if self.cam_combo.count() == 0:
self.cam_combo.addItem("未检测到摄像头", -1)
self.statusBar().showMessage("未检测到摄像头,请连接 Droidcam")
else:
self.statusBar().showMessage(f"检测到 {self.cam_combo.count()} 个摄像头")
def toggle_camera(self):
"""连接/断开摄像头"""
if self.cap is None:
cam_id = self.cam_combo.currentData()
if cam_id is None or cam_id < 0:
QMessageBox.warning(self, "错误", "请先选择有效的摄像头")
return
self.cap = cv2.VideoCapture(cam_id)
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
if self.cap.isOpened():
self.timer.start(30) # ~33 FPS
self.btn_connect.setText("⏹ 断开")
self.btn_capture.setEnabled(True)
self.cam_combo.setEnabled(False)
self.statusBar().showMessage("摄像头已连接")
else:
self.cap = None
QMessageBox.warning(self, "错误", "无法打开摄像头")
else:
self.timer.stop()
self.cap.release()
self.cap = None
self.btn_connect.setText("▶ 连接")
self.btn_capture.setEnabled(False)
self.cam_combo.setEnabled(True)
self.video_label.setText("摄像头已断开")
self.statusBar().showMessage("摄像头已断开")
def update_frame(self):
"""更新视频帧"""
if self.cap is None:
return
ret, frame = self.cap.read()
if ret:
# 绘制扫描框
h, w = frame.shape[:2]
# 框的位置:上方 70%,编号在下方
x1, y1 = int(w * 0.06), int(h * 0.08)
x2, y2 = int(w * 0.94), int(h * 0.78)
# 绘制绿色边框
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
# 四角加粗
corner_len = 25
cv2.line(frame, (x1, y1), (x1 + corner_len, y1), (0, 255, 0), 4)
cv2.line(frame, (x1, y1), (x1, y1 + corner_len), (0, 255, 0), 4)
cv2.line(frame, (x2, y1), (x2 - corner_len, y1), (0, 255, 0), 4)
cv2.line(frame, (x2, y1), (x2, y1 + corner_len), (0, 255, 0), 4)
cv2.line(frame, (x1, y2), (x1 + corner_len, y2), (0, 255, 0), 4)
cv2.line(frame, (x1, y2), (x1, y2 - corner_len), (0, 255, 0), 4)
cv2.line(frame, (x2, y2), (x2 - corner_len, y2), (0, 255, 0), 4)
cv2.line(frame, (x2, y2), (x2, y2 - corner_len), (0, 255, 0), 4)
# 提示文字
cv2.putText(frame, "You Bian", (x1 + 10, y1 + 25), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
cv2.putText(frame, "Di Zhi", (x1 + 10, y1 + int((y2-y1)*0.4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
cv2.putText(frame, "Lian Xi Ren", (x1 + 10, y2 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
cv2.putText(frame, "Dian Hua", (x2 - 80, y2 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
# 编号提示
cv2.putText(frame, "^ Bian Hao ^", (int(w*0.4), int(h*0.88)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
# 转换为 Qt 图像
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
h, w, ch = rgb.shape
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format.Format_RGB888)
scaled = qimg.scaled(self.video_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
self.video_label.setPixmap(QPixmap.fromImage(scaled))
def capture_and_recognize(self):
"""拍照并识别"""
if self.cap is None:
return
ret, frame = self.cap.read()
if not ret:
self.statusBar().showMessage("拍照失败")
return
# 保存临时文件
tmp_path = tempfile.mktemp(suffix=".jpg")
cv2.imwrite(tmp_path, frame)
self.statusBar().showMessage("正在识别...")
self.btn_capture.setEnabled(False)
# 启动 OCR 线程
self.worker = OCRWorker(self.ocr, tmp_path)
self.worker.finished.connect(lambda r, t: self.on_ocr_finished(r, t, tmp_path))
self.worker.error.connect(lambda e: self.on_ocr_error(e, tmp_path))
self.worker.start()
def on_ocr_finished(self, record, texts, tmp_path):
"""OCR 完成"""
os.unlink(tmp_path)
self.btn_capture.setEnabled(True)
# 添加到记录
self.records.append(record)
self.update_table()
self.statusBar().showMessage(f"识别完成: {record.get('联系人/单位名', '未知')}")
def on_ocr_error(self, error, tmp_path):
"""OCR 错误"""
os.unlink(tmp_path)
self.btn_capture.setEnabled(True)
self.statusBar().showMessage(f"识别失败: {error}")
def update_table(self):
"""更新表格"""
self.table.setRowCount(len(self.records))
for i, r in enumerate(self.records):
self.table.setItem(i, 0, QTableWidgetItem(r.get("编号", "")))
self.table.setItem(i, 1, QTableWidgetItem(r.get("邮编", "")))
self.table.setItem(i, 2, QTableWidgetItem(r.get("地址", "")))
self.table.setItem(i, 3, QTableWidgetItem(r.get("联系人/单位名", "")))
self.table.setItem(i, 4, QTableWidgetItem(r.get("电话", "")))
self.right_panel.setTitle(f"📋 已识别记录 ({len(self.records)})")
def delete_selected(self):
"""删除选中行"""
rows = set(item.row() for item in self.table.selectedItems())
for row in sorted(rows, reverse=True):
del self.records[row]
self.update_table()
def clear_all(self):
"""清空全部"""
if self.records:
reply = QMessageBox.question(self, "确认", f"确定清空全部 {len(self.records)} 条记录?")
if reply == QMessageBox.StandardButton.Yes:
self.records.clear()
self.update_table()
def export_excel(self):
"""导出 Excel"""
if not self.records:
QMessageBox.warning(self, "提示", "没有可导出的记录")
return
default_name = f"信封提取_{datetime.now():%Y%m%d_%H%M%S}.xlsx"
path, _ = QFileDialog.getSaveFileName(self, "保存 Excel", default_name, "Excel Files (*.xlsx)")
if path:
df = pd.DataFrame(self.records)
cols = ["编号", "邮编", "地址", "联系人/单位名", "电话"]
df = df.reindex(columns=cols)
df.to_excel(path, index=False)
self.statusBar().showMessage(f"已导出: {path}")
QMessageBox.information(self, "成功", f"已导出 {len(self.records)} 条记录到:\n{path}")
def closeEvent(self, event):
"""关闭窗口"""
if self.cap:
self.timer.stop()
self.cap.release()
event.accept()
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()