吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 2716|回复: 26
上一主题 下一主题
收起左侧

[Python 原创] 身份证批量识别并导出成表

[复制链接]
跳转到指定楼层
楼主
zgywqm 发表于 2025-11-26 11:59 回帖奖励
本帖最后由 hrh123 于 2025-11-26 22:26 编辑

在论坛上看到过一个相似的软件,找不到地址了 ,
自己写了一个利用py写了一个利用百度开放平台识别身份证正面、反面,提取身份证正面中各种信息并导出成exlex表格,支出批量识别和单张识别,识别结果可叠加,


附上代码,把代码上的核心换成你自己的就可使用
# ===================== 核心配置(用户需替换OCR密钥)=====================
APP_ID = "换成你自己的"       # 替换为你的百度OCR APP_ID
API_KEY = "换成你自己的"  # 替换为你的百度OCR API_KEY
SECRET_KEY = "换成你自己的"  # 替换为你的百度OCR SECRET_KEY


完整代码

import sys
import os
import re
import openpyxl
import traceback
from aip import AipOcr
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QPushButton, QLabel, QTableWidget, QTableWidgetItem, QMessageBox,
    QHeaderView, QProgressBar, QFileDialog, QMenu, QAction
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QMutex
from PyQt5.QtGui import QFont, QDragEnterEvent, QDropEvent, QClipboard

# ===================== 核心配置(用户需替换OCR密钥)=====================
APP_ID = "换成你自己的"       # 替换为你的百度OCR APP_ID
API_KEY = "换成你自己的"  # 替换为你的百度OCR API_KEY
SECRET_KEY = "换成你自己的"  # 替换为你的百度OCR SECRET_KEY

# 安全配置(避免闪退关键)
SUPPORTED_FORMATS = {'jpg', 'jpeg', 'png', 'bmp', 'gif', 'tiff', 'webp'}
MAX_IMAGE_SIZE = 10 * 1024 * 1024  # 最大支持10MB图片
MIN_IMAGE_SIZE = 1024  # 最小1KB

# 身份证特征关键词
BACK_SIDE_KEYWORDS = ['签发机关', '有效期限', '签发日期', '有效期至', '公安', '派出所']
FRONT_CORE_KEYWORDS = ['姓名', '公民身份号码', '出生', '性别', '民族', '住址']
# ======================================================================

# 全局异常捕获(防止闪退)
def except_hook(exc_type, exc_value, exc_traceback):
    error_msg = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
    QMessageBox.critical(None, "程序异常(已防止闪退)", f"错误详情:\n{error_msg[:500]}")
    sys.__excepthook__(exc_type, exc_value, exc_traceback)

sys.excepthook = except_hook

# 验证OCR密钥配置
if not all([APP_ID, API_KEY, SECRET_KEY]):
    QMessageBox.critical(None, "配置错误", "请先配置百度OCR密钥!\n步骤:\n1. 登录百度AI控制台(ai.baidu.com)\n2. 创建「身份证识别」应用\n3. 复制APP_ID、API_KEY、SECRET_KEY到代码中")
    sys.exit(1)

# 初始化百度OCR客户端(增加超时配置)
client = AipOcr(APP_ID, API_KEY, SECRET_KEY)
client.setConnectionTimeoutInMillis(5000)  # 连接超时5秒
client.setSocketTimeoutInMillis(10000)     # 读取超时10秒

def format_birthday(birthday_str):
    """格式化出生年月(增加异常捕获)"""
    try:
        if not birthday_str or str(birthday_str).strip() == '未知':
            return '未知'

        clean_str = re.sub(r'[^\d]', '', str(birthday_str).strip())
        if len(clean_str) != 8:
            return '格式错误'

        year, month, day = clean_str[:4], clean_str[4:6], clean_str[6:8]
        if 1 <= int(month) <= 12 and 1 <= int(day) <= 31:
            return f"{year}-{month}-{day}"
        return '日期无效'
    except Exception:
        return '格式错误'

def analyze_idcard_fields(words_result):
    """分析身份证字段(增强参数校验)"""
    try:
        if not isinstance(words_result, dict):
            return False, "识别结果格式异常"

        all_fields = list(words_result.keys())
        all_text = ' '.join([str(item.get('words', '')).strip() for item in words_result.values()])

        front_core_match = sum(1 for field in FRONT_CORE_KEYWORDS if field in all_fields)
        back_match = sum(1 for kw in BACK_SIDE_KEYWORDS if kw in all_fields or kw in all_text)
        has_required = '姓名' in all_fields and '公民身份号码' in all_fields

        if not has_required:
            return False, "缺少姓名或身份证号"
        if front_core_match >= 3:
            return True, f"含{front_core_match}个正面核心字段"

        return False, f"正面字段仅{front_core_match}个,背面元素{back_match}个"
    except Exception as e:
        return False, f"字段分析失败:{str(e)[:20]}"

class RecognizeThread(QThread):
    """识别线程(支持结果合并,线程安全)"""
    progress_signal = pyqtSignal(int, str)
    new_result_signal = pyqtSignal(list, int, int)  # 新识别的结果(而非全部)
    error_signal = pyqtSignal(str)

    def __init__(self, image_paths, global_result_list, global_mutex):
        super().__init__()
        self.image_paths = image_paths
        self.global_result_list = global_result_list  # 全局结果列表(共享)
        self.global_mutex = global_mutex  # 全局锁(保证去重安全)
        self.is_running = True
        self.local_mutex = QMutex()  # 本地锁

    def run(self):
        try:
            # 安全筛选有效图片
            valid_image_paths = []
            for path in self.image_paths:
                try:
                    if not isinstance(path, str) or not os.path.exists(path) or not os.path.isfile(path):
                        continue
                    ext = os.path.splitext(path)[1].lower()[1:]
                    if ext not in SUPPORTED_FORMATS:
                        continue
                    file_size = os.path.getsize(path)
                    if MIN_IMAGE_SIZE <= file_size <= MAX_IMAGE_SIZE:
                        valid_image_paths.append(path)
                except Exception:
                    continue

            total = len(valid_image_paths)
            if total == 0:
                self.error_signal.emit("未找到有效图片!\n支持:JPG/PNG/BMP/GIF/TIFF/WebP\n大小:1KB-10MB")
                return

            self.progress_signal.emit(0, f"已接收{total}张有效图片,开始识别...")
            new_results = []  # 本次识别的新结果(未去重)

            for idx, file_path in enumerate(valid_image_paths):
                # 线程安全判断是否继续运行
                self.local_mutex.lock()
                if not self.is_running:
                    self.local_mutex.unlock()
                    self.progress_signal.emit(0, "识别已取消")
                    return
                self.local_mutex.unlock()

                progress = int((idx + 1) / total * 100)
                filename = os.path.basename(file_path)
                self.progress_signal.emit(progress, f"正在识别第{idx+1}/{total}张:{filename}")

                # 安全读取图片文件
                try:
                    with open(file_path, 'rb') as f:
                        image_data = f.read()
                    if len(image_data) < MIN_IMAGE_SIZE or len(image_data) > MAX_IMAGE_SIZE:
                        self.progress_signal.emit(progress, f"跳过:{filename} - 大小超出限制")
                        continue
                except FileNotFoundError:
                    self.progress_signal.emit(progress, f"跳过:{filename} - 文件已删除")
                    continue
                except PermissionError:
                    self.progress_signal.emit(progress, f"跳过:{filename} - 无读取权限")
                    continue
                except Exception as e:
                    self.progress_signal.emit(progress, f"跳过:{filename} - 读取失败")
                    continue

                # 安全调用OCR接口
                try:
                    res = client.idcard(
                        image_data,
                        'front',
                        {
                            'detect_direction': 'true',
                            'detect_risk': 'false',
                            'accuracy': 'high'
                        }
                    )
                except Exception as e:
                    self.progress_signal.emit(progress, f"警告:{filename} - 接口调用超时")
                    continue

                # 安全处理OCR返回结果
                try:
                    if res.get("error_code"):
                        err_code = res["error_code"]
                        if err_code == 17:
                            self.error_signal.emit("OCR配额不足!请登录百度AI控制台充值或明日再试")
                            return
                        elif "side does not match" in str(res.get("error_msg", "")):
                            self.progress_signal.emit(progress, f"跳过:{filename} - 识别为身份证背面")
                        else:
                            self.progress_signal.emit(progress, f"跳过:{filename} - OCR错误[{err_code}]")
                        continue

                    words_result = res.get("words_result", {})
                    if not words_result:
                        self.progress_signal.emit(progress, f"跳过:{filename} - 未识别到文字")
                        continue

                    is_valid_front, judge_msg = analyze_idcard_fields(words_result)
                    if not is_valid_front:
                        self.progress_signal.emit(progress, f"跳过:{filename} - {judge_msg}")
                        continue

                    # 整理核心识别结果
                    id_info = {
                        '姓名': words_result.get('姓名', {}).get('words', '').strip() or '未知',
                        '身份证号码': words_result.get('公民身份号码', {}).get('words', '').strip() or '未知',
                        '性别': words_result.get('性别', {}).get('words', '').strip() or '未知',
                        '民族': words_result.get('民族', {}).get('words', '').strip() or '未知',
                        '出生年月': format_birthday(words_result.get('出生', {}).get('words', '未知')),
                        '住址': words_result.get('住址', {}).get('words', '').strip() or '未知'
                    }

                    # 二次校验必填字段
                    if id_info['姓名'] == '未知' or id_info['身份证号码'] == '未知':
                        self.progress_signal.emit(progress, f"跳过:{filename} - 核心字段缺失")
                        continue

                    # 身份证号格式校验
                    id_num = id_info['身份证号码']
                    if len(id_num) != 18 or not id_num[:17].isdigit() or not (id_num[-1].isdigit() or id_num[-1].upper() == 'X'):
                        self.progress_signal.emit(progress, f"跳过:{filename} - 身份证号格式错误")
                        continue

                    # 全局去重(线程安全)
                    self.global_mutex.lock()
                    exists = any(item['身份证号码'] == id_num for item in self.global_result_list)
                    if not exists:
                        self.global_result_list.append(id_info)
                        new_results.append(id_info)
                        self.progress_signal.emit(progress, f"成功:{id_info['姓名']} - {id_num[:6]}...(新增)")
                    else:
                        self.progress_signal.emit(progress, f"跳过:{filename} - 身份证号已存在(历史数据)")
                    self.global_mutex.unlock()

                except Exception as e:
                    self.progress_signal.emit(progress, f"跳过:{filename} - 结果处理失败")
                    continue

            # 发送本次新增的结果(用于UI追加显示)
            self.new_result_signal.emit(new_results, total, len(new_results))

        except Exception as e:
            self.error_signal.emit(f"线程异常:{str(e)[:50]}")

    def stop(self):
        """安全停止线程"""
        self.local_mutex.lock()
        self.is_running = False
        self.local_mutex.unlock()

class IDCardDragDropRecognizer(QMainWindow):
    """主窗口(支持合并结果+复制功能)"""
    def __init__(self):
        super().__init__()
        self.init_base_config()
        self.init_ui_components()
        self.init_runtime_vars()
        self.setAcceptDrops(True)

    def init_base_config(self):
        self.setWindowTitle("身份证识别1.0")
        self.setGeometry(100, 100, 1100, 600)
        self.setFont(QFont("Microsoft YaHei", 10))
        self.setMinimumSize(900, 500)

    def init_runtime_vars(self):
        self.global_result_list = []  # 全局结果列表(存储所有识别结果)
        self.global_mutex = QMutex()  # 全局结果锁(保证线程安全)
        self.recognize_thread = None
        self.dragging = False

    def init_ui_components(self):
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        main_layout = QVBoxLayout(central_widget)
        main_layout.setSpacing(15)
        main_layout.setContentsMargins(20, 20, 20, 20)

        # 标题
        title_label = QLabel("身份证识别工具")
        title_label.setFont(QFont("Microsoft YaHei", 14, QFont.Bold))
        title_label.setAlignment(Qt.AlignCenter)
        main_layout.addWidget(title_label)

        # 拖拽提示区域(更新功能提示)
        self.drag_label = QLabel("""
            <div style="text-align: center; color: #666; font-size: 11pt;">
                <p>📌 支持多次拖拽图片,结果自动合并(按身份证号去重)</p>
                <p>✅ 支持格式:JPG、PNG、BMP、GIF、TIFF、WebP</p>
                <p>📏 支持大小:1KB ~ 10MB(避免超大文件闪退)</p>
                <p>🔍 识别字段:姓名、身份证号、性别、民族、出生年月、住址</p>
                <p>📋 操作支持:右键复制选中单元格/行/所有数据(适配Excel粘贴)</p>
                <p>📊 累计识别:<span style="color: #2196F3;">0</span> 条有效信息</p>
            </div>
        """)
        self.drag_label.setStyleSheet("""
            border: 2px dashed #CCCCCC;
            border-radius: 8px;
            padding: 20px;
            background-color: #F9F9F9;
        """)
        self.drag_label.setWordWrap(True)
        main_layout.addWidget(self.drag_label)

        # 功能按钮区域
        btn_layout = QHBoxLayout()
        self.clear_btn = QPushButton("清空结果")
        self.clear_btn.setStyleSheet("""
            QPushButton {
                background-color: #FF9800;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 10px 24px;
                font-size: 11pt;
            }
            QPushButton:disabled {
                background-color: #E0E0E0;
                color: #999999;
            }
        """)
        self.clear_btn.clicked.connect(self.clear_results)
        self.clear_btn.setEnabled(False)

        self.cancel_btn = QPushButton("取消识别")
        self.cancel_btn.setStyleSheet("""
            QPushButton {
                background-color: #f44336;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 10px 24px;
                font-size: 11pt;
            }
            QPushButton:disabled {
                background-color: #E0E0E0;
                color: #999999;
            }
        """)
        self.cancel_btn.clicked.connect(self.cancel_recognition)
        self.cancel_btn.setEnabled(False)

        self.save_btn = QPushButton("导出Excel")
        self.save_btn.setStyleSheet("""
            QPushButton {
                background-color: #2196F3;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 10px 24px;
                font-size: 11pt;
            }
            QPushButton:disabled {
                background-color: #E0E0E0;
                color: #999999;
            }
        """)
        self.save_btn.clicked.connect(self.export_to_excel)
        self.save_btn.setEnabled(False)

        btn_layout.addWidget(self.clear_btn)
        btn_layout.addWidget(self.cancel_btn)
        btn_layout.addStretch(1)
        btn_layout.addWidget(self.save_btn)
        main_layout.addLayout(btn_layout)

        # 进度区域
        progress_layout = QVBoxLayout()
        self.progress_bar = QProgressBar()
        self.progress_bar.setAlignment(Qt.AlignCenter)
        self.progress_bar.setStyleSheet("""
            QProgressBar {
                border: 1px solid #CCCCCC;
                border-radius: 4px;
                text-align: center;
                height: 25px;
            }
            QProgressBar::chunk {
                background-color: #2196F3;
                border-radius: 2px;
            }
        """)

        self.status_label = QLabel("等待拖拽图片...(可多次拖拽,右键可复制结果)")
        self.status_label.setAlignment(Qt.AlignCenter)
        self.status_label.setStyleSheet("color: #666666;")
        self.status_label.setWordWrap(True)

        progress_layout.addWidget(self.progress_bar)
        progress_layout.addWidget(self.status_label)
        main_layout.addLayout(progress_layout)

        # 结果表格(启用右键菜单)
        self.table_columns = ["姓名", "身份证号码", "性别", "民族", "出生年月", "住址"]
        self.result_table = QTableWidget()
        self.result_table.setColumnCount(len(self.table_columns))
        self.result_table.setHorizontalHeaderLabels(self.table_columns)
        self.result_table.setStyleSheet("""
            QTableWidget {
                border: 1px solid #CCCCCC;
                gridline-color: #F0F0F0;
            }
            QHeaderView::section {
                background-color: #F5F5F5;
                border: none;
                border-bottom: 1px solid #CCCCCC;
                padding: 8px;
                font-weight: bold;
            }
            QTableWidget::item {
                padding: 8px;
            }
        """)

        # 启用右键菜单
        self.result_table.setContextMenuPolicy(Qt.CustomContextMenu)
        self.result_table.customContextMenuRequested.connect(self.show_table_context_menu)

        # 表头自适应配置
        header = self.result_table.horizontalHeader()
        for i in range(len(self.table_columns)-1):
            header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
        header.setSectionResizeMode(len(self.table_columns)-1, QHeaderView.Stretch)
        main_layout.addWidget(self.result_table, 1)

    # ===================== 右键菜单与复制功能 =====================
    def show_table_context_menu(self, pos):
        """显示表格右键菜单"""
        if self.result_table.rowCount() == 0:
            return  # 无数据时不显示菜单

        menu = QMenu()
        # 添加菜单选项
        copy_cell_action = QAction("复制选中单元格", self)
        copy_row_action = QAction("复制选中行", self)
        copy_all_action = QAction("复制所有数据", self)

        # 绑定功能
        copy_cell_action.triggered.connect(self.copy_selected_cells)
        copy_row_action.triggered.connect(self.copy_selected_rows)
        copy_all_action.triggered.connect(self.copy_all_data)

        # 添加到菜单
        menu.addAction(copy_cell_action)
        menu.addAction(copy_row_action)
        menu.addAction(copy_all_action)

        # 在鼠标位置显示菜单
        menu.exec_(self.result_table.mapToGlobal(pos))

    def copy_selected_cells(self):
        """复制选中的单元格(支持多个单元格)"""
        try:
            selected_items = self.result_table.selectedItems()
            if not selected_items:
                QMessageBox.warning(self, "提示", "未选中任何单元格!")
                return

            # 按行排序,同一行的单元格按列排序
            selected_items.sort(key=lambda x: (x.row(), x.column()))

            # 构建复制文本(单元格之间用制表符分隔,换行分隔不同行)
            copy_text = ""
            current_row = -1
            for item in selected_items:
                if item.row() != current_row:
                    if current_row != -1:
                        copy_text += "\n"  # 换行
                    current_row = item.row()
                else:
                    copy_text += "\t"  # 制表符分隔单元格
                # 处理住址字段的换行符
                cell_text = item.text().replace("\n", " ")
                copy_text += cell_text

            # 复制到剪贴板
            clipboard = QApplication.clipboard()
            clipboard.setText(copy_text)
            QMessageBox.information(self, "复制成功", f"已复制{len(selected_items)}个单元格数据到剪贴板!")
        except Exception as e:
            QMessageBox.warning(self, "复制失败", f"单元格复制错误:{str(e)}")

    def copy_selected_rows(self):
        """复制选中的整行(支持多行)"""
        try:
            selected_rows = list(set(item.row() for item in self.result_table.selectedItems()))
            if not selected_rows:
                QMessageBox.warning(self, "提示", "未选中任何行!")
                return
            selected_rows.sort()  # 按行号排序

            # 构建复制文本(表头+数据,制表符分隔列,换行分隔行)
            copy_text = "\t".join(self.table_columns) + "\n"  # 表头
            for row in selected_rows:
                row_data = []
                for col in range(len(self.table_columns)):
                    item = self.result_table.item(row, col)
                    cell_text = item.text().replace("\n", " ") if item else ""
                    row_data.append(cell_text)
                copy_text += "\t".join(row_data) + "\n"

            # 复制到剪贴板
            clipboard = QApplication.clipboard()
            clipboard.setText(copy_text.strip())  # 去除末尾换行
            QMessageBox.information(self, "复制成功", f"已复制{len(selected_rows)}行完整数据到剪贴板!")
        except Exception as e:
            QMessageBox.warning(self, "复制失败", f"行复制错误:{str(e)}")

    def copy_all_data(self):
        """复制所有数据(含表头)"""
        try:
            total_rows = self.result_table.rowCount()
            if total_rows == 0:
                QMessageBox.warning(self, "提示", "暂无数据可复制!")
                return

            # 构建复制文本(表头+所有行数据)
            copy_text = "\t".join(self.table_columns) + "\n"  # 表头
            for row in range(total_rows):
                row_data = []
                for col in range(len(self.table_columns)):
                    item = self.result_table.item(row, col)
                    cell_text = item.text().replace("\n", " ") if item else ""
                    row_data.append(cell_text)
                copy_text += "\t".join(row_data) + "\n"

            # 复制到剪贴板
            clipboard = QApplication.clipboard()
            clipboard.setText(copy_text.strip())
            QMessageBox.information(self, "复制成功", f"已复制所有{total_rows}行数据(含表头)到剪贴板!")
        except Exception as e:
            QMessageBox.warning(self, "复制失败", f"全量复制错误:{str(e)}")

    # ===================== 拖放功能 =====================
    def dragEnterEvent(self, event: QDragEnterEvent):
        try:
            if event.mimeData().hasUrls():
                event.acceptProposedAction()
                self.drag_label.setStyleSheet("""
                    border: 2px dashed #2196F3;
                    border-radius: 8px;
                    padding: 20px;
                    background-color: #E3F2FD;
                """)
                self.status_label.setText("松开鼠标开始识别...(结果将合并,右键可复制)")
                self.status_label.setStyleSheet("color: #2196F3;")
                self.dragging = True
        except Exception:
            event.ignore()

    def dragLeaveEvent(self, event):
        try:
            if self.dragging:
                self.drag_label.setStyleSheet("""
                    border: 2px dashed #CCCCCC;
                    border-radius: 8px;
                    padding: 20px;
                    background-color: #F9F9F9;
                """)
                self.update_drag_label_count()  # 更新累计数量显示
                self.status_label.setText("等待拖拽图片...(可多次拖拽,右键可复制结果)")
                self.status_label.setStyleSheet("color: #666666;")
                self.dragging = False
        except Exception:
            pass

    def dropEvent(self, event: QDropEvent):
        try:
            # 恢复样式
            self.drag_label.setStyleSheet("""
                border: 2px dashed #CCCCCC;
                border-radius: 8px;
                padding: 20px;
                background-color: #F9F9F9;
            """)
            self.update_drag_label_count()  # 更新累计数量显示
            self.dragging = False

            # 安全获取拖拽路径
            urls = event.mimeData().urls()
            image_paths = []
            for url in urls:
                try:
                    local_path = url.toLocalFile()
                    if os.path.isfile(local_path):
                        image_paths.append(local_path)
                except Exception:
                    continue

            if not image_paths:
                self.status_label.setText("未识别到有效文件!请拖拽图片文件(1KB-10MB)")
                self.status_label.setStyleSheet("color: #F44336;")
                return

            # 停止当前线程(安全退出)
            self.global_mutex.lock()
            if self.recognize_thread and self.recognize_thread.isRunning():
                self.recognize_thread.stop()
                self.recognize_thread.wait(2000)
            self.global_mutex.unlock()

            # 重置进度条,保持历史结果
            self.progress_bar.setValue(0)
            self.cancel_btn.setEnabled(True)
            self.save_btn.setEnabled(len(self.global_result_list) > 0)  # 有历史结果就启用导出
            self.clear_btn.setEnabled(len(self.global_result_list) > 0)  # 有历史结果就启用清空

            # 启动新线程(传入全局结果列表和锁)
            self.recognize_thread = RecognizeThread(
                image_paths,
                self.global_result_list,
                self.global_mutex
            )
            self.recognize_thread.progress_signal.connect(self.update_progress_status)
            self.recognize_thread.new_result_signal.connect(self.append_new_results)  # 追加新结果
            self.recognize_thread.error_signal.connect(self.show_error_message)
            self.recognize_thread.finished.connect(self.thread_finish_callback)
            self.recognize_thread.start()

        except Exception as e:
            QMessageBox.warning(self, "拖拽异常", f"拖拽处理失败:{str(e)}")

    # ===================== 结果处理 =====================
    def append_new_results(self, new_results, total_count, new_count):
        """追加新识别的结果到表格(不清空历史)"""
        try:
            current_row = self.result_table.rowCount()  # 获取当前表格行数
            self.result_table.setRowCount(current_row + len(new_results))  # 增加行数

            # 填充新结果
            for idx, info in enumerate(new_results):
                row_idx = current_row + idx
                data_values = [
                    info['姓名'], info['身份证号码'],
                    info['性别'], info['民族'], info['出生年月'], info['住址']
                ]
                for col_idx, value in enumerate(data_values):
                    table_item = QTableWidgetItem(str(value))
                    table_item.setTextAlignment(Qt.AlignCenter)
                    if col_idx == 5:  # 住址列
                        table_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter)
                    self.result_table.setItem(row_idx, col_idx, table_item)

            # 更新状态和按钮
            total_valid = len(self.global_result_list)
            self.status_label.setText(
                f"本次识别完成:共{total_count}张图片,新增{new_count}条有效信息,累计{total_valid}条(右键可复制)"
            )
            self.status_label.setStyleSheet("color: #4CAF50;")
            self.save_btn.setEnabled(total_valid > 0)
            self.clear_btn.setEnabled(total_valid > 0)
            self.update_drag_label_count()  # 更新拖拽提示区的累计数量
        except Exception as e:
            QMessageBox.warning(self, "显示异常", f"结果追加失败:{str(e)}")

    def clear_results(self):
        """清空所有结果(表格+全局列表)"""
        reply = QMessageBox.question(
            self, "确认清空", "是否清空所有识别结果?(不可恢复)",
            QMessageBox.Yes | QMessageBox.No, QMessageBox.No
        )
        if reply == QMessageBox.Yes:
            self.global_mutex.lock()
            self.global_result_list.clear()
            self.global_mutex.unlock()
            self.result_table.setRowCount(0)
            self.status_label.setText("已清空所有结果,等待拖拽图片...(右键可复制)")
            self.status_label.setStyleSheet("color: #666666;")
            self.save_btn.setEnabled(False)
            self.clear_btn.setEnabled(False)
            self.update_drag_label_count()  # 重置累计数量显示

    def update_drag_label_count(self):
        """更新拖拽提示区的累计结果数量"""
        total_valid = len(self.global_result_list)
        self.drag_label.setText(f"""
            <div style="text-align: center; color: #666; font-size: 11pt;">
                <p>📌 支持多次拖拽图片,结果自动合并(按身份证号去重)</p>
                <p>✅ 支持格式:JPG、PNG、BMP、GIF、TIFF、WebP</p>
                <p>📏 支持大小:1KB ~ 10MB(避免超大文件闪退)</p>
                <p>🔍 识别字段:姓名、身份证号、性别、民族、出生年月、住址</p>
                <p>📋 操作支持:右键复制选中单元格/行/所有数据(适配Excel粘贴)</p>
                <p>📊 累计识别:<span style="color: #2196F3;">{total_valid}</span> 条有效信息</p>
            </div>
        """)

    # ===================== 识别相关 =====================
    def cancel_recognition(self):
        self.global_mutex.lock()
        if self.recognize_thread and self.recognize_thread.isRunning():
            reply = QMessageBox.question(self, "确认取消", "是否取消当前识别任务?", QMessageBox.Yes | QMessageBox.No)
            if reply == QMessageBox.Yes:
                self.recognize_thread.stop()
                self.status_label.setText("正在取消识别...")
                self.cancel_btn.setEnabled(False)
        self.global_mutex.unlock()

    def update_progress_status(self, progress_value, status_text):
        try:
            self.progress_bar.setValue(progress_value)
            self.status_label.setText(status_text)

            if "成功:" in status_text:
                self.status_label.setStyleSheet("color: #4CAF50;")
            elif "跳过:" in status_text and "背面" in status_text:
                self.status_label.setStyleSheet("color: #9E9E9E;")
            elif "跳过:" in status_text:
                self.status_label.setStyleSheet("color: #FF9800;")
            elif "警告:" in status_text:
                self.status_label.setStyleSheet("color: #F44336;")
            else:
                self.status_label.setStyleSheet("color: #666666;")
        except Exception:
            pass

    def show_error_message(self, error_text):
        try:
            QMessageBox.critical(self, "操作错误", error_text)
            self.status_label.setText(f"错误:{error_text[:50]}...(右键可复制已有结果)")
            self.status_label.setStyleSheet("color: #F44336;")
        except Exception:
            pass

    def thread_finish_callback(self):
        self.global_mutex.lock()
        self.recognize_thread = None
        self.cancel_btn.setEnabled(False)
        # 确保导出和清空按钮状态正确
        total_valid = len(self.global_result_list)
        self.save_btn.setEnabled(total_valid > 0)
        self.clear_btn.setEnabled(total_valid > 0)
        self.global_mutex.unlock()

    # ===================== Excel导出 =====================
    def export_to_excel(self):
        self.global_mutex.lock()
        if not self.global_result_list:
            self.global_mutex.unlock()
            QMessageBox.warning(self, "提示", "暂无有效数据可导出!")
            return
        # 复制一份结果,避免导出过程中数据被修改
        export_data = self.global_result_list.copy()
        self.global_mutex.unlock()

        save_path, _ = QFileDialog.getSaveFileName(
            self, "保存Excel文件",
            os.path.join(os.getcwd(), "身份证核心字段合并结果.xlsx"),
            "Excel文件 (*.xlsx);;所有文件 (*.*)"
        )
        if not save_path:
            return

        if not save_path.endswith(".xlsx"):
            save_path += ".xlsx"

        try:
            # 验证写入权限
            with open(save_path, 'w') as f:
                pass
            os.remove(save_path)

            workbook = openpyxl.Workbook()
            worksheet = workbook.active
            worksheet.title = "身份证核心字段合并结果"

            header_font = openpyxl.styles.Font(bold=True, color="FFFFFF")
            header_fill = openpyxl.styles.PatternFill(start_color="2196F3", end_color="2196F3", fill_type="solid")
            center_alignment = openpyxl.styles.Alignment(horizontal='center', vertical='center')

            # 写入表头
            for col_idx, header_text in enumerate(self.table_columns, 1):
                cell = worksheet.cell(row=1, column=col_idx, value=header_text)
                cell.font = header_font
                cell.fill = header_fill
                cell.alignment = center_alignment

            # 写入所有合并后的结果
            for row_idx, info in enumerate(export_data, 2):
                worksheet.cell(row=row_idx, column=1, value=info['姓名']).alignment = center_alignment
                worksheet.cell(row=row_idx, column=2, value=info['身份证号码']).alignment = center_alignment
                worksheet.cell(row=row_idx, column=3, value=info['性别']).alignment = center_alignment
                worksheet.cell(row=row_idx, column=4, value=info['民族']).alignment = center_alignment
                worksheet.cell(row=row_idx, column=5, value=info['出生年月']).alignment = center_alignment

                addr_cell = worksheet.cell(row=row_idx, column=6, value=info['住址'])
                addr_cell.alignment = openpyxl.styles.Alignment(horizontal='left', vertical='center', wrap_text=True)

            # 调整列宽
            column_widths = [12, 20, 8, 8, 15, 45]
            for col_idx, width in enumerate(column_widths, 1):
                worksheet.column_dimensions[openpyxl.utils.get_column_letter(col_idx)].width = width

            # 调整行高
            worksheet.row_dimensions[1].height = 25
            for row_idx in range(2, len(export_data) + 2):
                worksheet.row_dimensions[row_idx].height = 35

            workbook.save(save_path)
            QMessageBox.information(self, "导出成功", f"合并结果已导出到:\n{save_path}\n共{len(export_data)}条有效信息")

            if QMessageBox.question(self, "打开文件", "是否立即打开Excel文件?", QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes:
                if sys.platform.startswith('win'):
                    os.startfile(save_path)
                elif sys.platform.startswith('darwin'):
                    os.system(f'open "{save_path}"')
                else:
                    os.system(f'xdg-open "{save_path}"')

        except PermissionError:
            QMessageBox.critical(self, "保存失败", "文件被占用或无写入权限!")
        except Exception as e:
            QMessageBox.critical(self, "导出失败", f"Excel导出错误:{str(e)}")

    # ===================== 窗口关闭 =====================
    def closeEvent(self, event):
        self.global_mutex.lock()
        if self.recognize_thread and self.recognize_thread.isRunning():
            self.recognize_thread.stop()
            self.recognize_thread.wait(1000)
        self.global_mutex.unlock()
        event.accept()

if __name__ == '__main__':
    # 高DPI适配
    if hasattr(Qt, 'AA_EnableHighDpiScaling'):
        QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
    if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
        QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)

    app = QApplication(sys.argv)
    app.setStyle("Fusion")
    main_window = IDCardDragDropRecognizer()
    main_window.show()
    sys.exit(app.exec_())

免费评分

参与人数 2吾爱币 +8 热心值 +1 收起 理由
DickyJordan + 1 我很赞同!
苏紫方璇 + 7 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
fy1230001 发表于 2025-11-27 09:16
优化了一下,加了个文件名的显示,识别出来后能对应相应文件名,再加ren批处理,批量修改大量未命名好的身份证图片
[HTML] 纯文本查看 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>智能身份证识别 Pro</title>
    <!-- Tailwind CSS -->
    <script src="https://cdn.tailwindcss.com"></script>
    <!-- Fonts -->
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
    <style>
        body {
            font-family: 'Inter', 'Noto Sans SC', sans-serif;
            background-color: #f5f5f7;
            color: #1d1d1f;
        }
        .glass-effect {
            background: rgba(255, 255, 255, 0.7);
            backdrop-filter: blur(20px);
            -webkit-backdrop-filter: blur(20px);
            border-bottom: 1px solid rgba(255, 255, 255, 0.3);
        }
        /* Custom Scrollbar */
        ::-webkit-scrollbar {
            width: 8px;
            height: 8px;
        }
        ::-webkit-scrollbar-track {
            background: transparent; 
        }
        ::-webkit-scrollbar-thumb {
            background: #cbd5e1; 
            border-radius: 4px;
        }
        ::-webkit-scrollbar-thumb:hover {
            background: #94a3b8; 
        }
        .animate-spin {
            animation: spin 1s linear infinite;
        }
        @keyframes spin {
            from { transform: rotate(0deg); }
            to { transform: rotate(360deg); }
        }
    </style>
<script type="importmap">
{
  "imports": {
    "react": "https://aistudiocdn.com/react@^19.2.0",
    "react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
    "react/": "https://aistudiocdn.com/react@^19.2.0/"
  }
}
</script>
</head>
<body class="min-h-screen selection:bg-blue-100">
 
    <!-- ================= MODALS ================= -->
 
    <!-- 1. Clear Confirmation Modal -->
    <div id="modal-clear" class="fixed inset-0 z-[110] hidden flex items-center justify-center p-4">
        <div class="absolute inset-0 bg-black/30 backdrop-blur-sm transition-opacity" onclick="closeModal('modal-clear')"></div>
        <div class="bg-white rounded-2xl shadow-xl w-full max-w-sm z-20 overflow-hidden transform transition-all scale-100 p-6 text-center">
            <div class="w-12 h-12 rounded-full bg-red-100 mx-auto flex items-center justify-center mb-4">
                <svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
            </div>
            <h3 class="text-lg font-bold text-gray-900 mb-2">确认清空?</h3>
            <p class="text-sm text-gray-500 mb-6">该操作将移除所有已上传的图片和识别结果,无法撤销。</p>
            <div class="flex space-x-3">
                <button onclick="closeModal('modal-clear')" class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-50 border border-gray-300 rounded-xl hover:bg-gray-100">取消</button>
                <button onclick="confirmClearData()" class="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-500 border border-transparent rounded-xl hover:bg-red-600 shadow-sm">确认清空</button>
            </div>
        </div>
    </div>
 
    <!-- 2. Settings Modal -->
    <div id="modal-settings" class="fixed inset-0 z-[100] hidden flex items-center justify-center p-4">
        <div class="absolute inset-0 bg-black/20 backdrop-blur-sm transition-opacity" onclick="closeModal('modal-settings')"></div>
        <div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl z-10 overflow-hidden transform transition-all scale-100 flex flex-col md:flex-row h-[520px]">
            <!-- Sidebar -->
            <div class="w-full md:w-1/3 bg-gray-50 border-r border-gray-100 p-4 space-y-2">
                <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4 px-2">服务商配置</h3>
                <button onclick="switchTab('zhipu')" id="tab-btn-zhipu" class="tab-btn w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all text-blue-600 bg-white shadow-sm ring-1 ring-black/5">智谱 AI (GLM-4V)</button>
                <button onclick="switchTab('qwen')" id="tab-btn-qwen" class="tab-btn w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all text-gray-600 hover:bg-gray-100">阿里云 (通义千问)</button>
                <button onclick="switchTab('baidu')" id="tab-btn-baidu" class="tab-btn w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all text-gray-600 hover:bg-gray-100">百度智能云 (OCR)</button>
            </div>
            <!-- Content -->
            <div class="flex-1 flex flex-col">
                <div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
                    <h3 class="text-lg font-semibold text-gray-900">API 配置</h3>
                    <button onclick="closeModal('modal-settings')" class="text-gray-400 hover:text-gray-600">
                        <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
                    </button>
                </div>
                <div class="flex-1 p-8 overflow-y-auto">
                    <!-- Zhipu Content -->
                    <div id="content-zhipu" class="tab-content block">
                        <p class="text-sm text-gray-600 mb-4 bg-gray-100 p-3 rounded-lg border border-gray-200">推荐。使用 glm-4v-flash 模型,目前免费且速度极快。</p>
                        <label class="block text-sm font-medium text-gray-900 mb-2">API Key</label>
                        <input type="password" id="input-zhipu" placeholder="请输入智谱 GLM-4 Key" class="block w-full rounded-xl border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-3 border bg-gray-50">
                        <div class="mt-6 bg-blue-50 rounded-xl p-4 border border-blue-100 flex justify-between items-center">
                            <h4 class="text-sm font-semibold text-blue-900">需要申请 Key?</h4>
                            <a href="https://bigmodel.cn/usercenter/proj-mgmt/apikeys" target="_blank" class="inline-flex items-center text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors">去官网控制台 &rarr;</a>
                        </div>
                    </div>
                    <!-- Qwen Content -->
                    <div id="content-qwen" class="tab-content hidden">
                        <p class="text-sm text-gray-600 mb-4 bg-gray-100 p-3 rounded-lg border border-gray-200">使用 qwen-vl 模型,识别能力强,有免费额度。</p>
                        <label class="block text-sm font-medium text-gray-900 mb-2">API Key</label>
                        <input type="password" id="input-qwen" placeholder="请输入阿里云 DashScope Key" class="block w-full rounded-xl border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-3 border bg-gray-50">
                        <div class="mt-6 bg-blue-50 rounded-xl p-4 border border-blue-100 flex justify-between items-center">
                            <h4 class="text-sm font-semibold text-blue-900">需要申请 Key?</h4>
                            <a href="https://dashscope.console.aliyun.com/apiKey" target="_blank" class="inline-flex items-center text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors">去官网控制台 &rarr;</a>
                        </div>
                    </div>
                    <!-- Baidu Content -->
                    <div id="content-baidu" class="tab-content hidden">
                        <p class="text-sm text-gray-600 mb-4 bg-gray-100 p-3 rounded-lg border border-gray-200">传统 OCR 强项。每月赠送约 1000 次免费额度,需配置 AK/SK。</p>
                        <div class="space-y-4">
                            <div>
                                <label class="block text-sm font-medium text-gray-900 mb-1">API Key (AK)</label>
                                <input type="text" id="input-baidu-ak" placeholder="请输入百度云 API Key" class="block w-full rounded-xl border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-3 border bg-gray-50">
                            </div>
                            <div>
                                <label class="block text-sm font-medium text-gray-900 mb-1">Secret Key (SK)</label>
                                <input type="password" id="input-baidu-sk" placeholder="请输入百度云 Secret Key" class="block w-full rounded-xl border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-3 border bg-gray-50">
                            </div>
                        </div>
                        <div class="mt-6 bg-blue-50 rounded-xl p-4 border border-blue-100 flex justify-between items-center">
                            <h4 class="text-sm font-semibold text-blue-900">需要申请 Key?</h4>
                            <a href="https://console.bce.baidu.com/ai/#/ai/ocr/overview/index" target="_blank" class="inline-flex items-center text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors">去官网控制台 &rarr;</a>
                        </div>
                    </div>
                </div>
                <div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end space-x-3">
                    <button onclick="closeModal('modal-settings')" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">取消</button>
                    <button onclick="saveApiKeys()" class="px-6 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 shadow-sm transition-all active:scale-95">保存配置</button>
                </div>
            </div>
        </div>
    </div>
 
    <!-- ================= NAVBAR ================= -->
    <div class="glass-effect sticky top-0 z-50">
        <div class="max-w-7xl mx-auto px-6 h-16 flex justify-between items-center">
            <div class="flex items-center space-x-3">
                <div class="bg-gradient-to-tr from-blue-600 to-indigo-500 text-white p-1.5 rounded-lg shadow-sm">
                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0c0 .883.393 1.627 1 2.18m4-2.18c.607.553 1 1.297 1 2.18" /></svg>
                </div>
                <div>
                    <h1 class="text-lg font-bold tracking-tight text-gray-900">身份证识别 Pro</h1>
                </div>
            </div>
            <div class="flex items-center space-x-4">
                <!-- Settings Button -->
                <button onclick="openSettings()" id="settings-trigger-btn" class="p-2 rounded-full transition-colors relative group text-gray-500 hover:bg-gray-100" title="配置 API">
                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
                    <span id="settings-badge" class="hidden absolute top-0 right-0 w-2.5 h-2.5 bg-red-500 border-2 border-white rounded-full animate-pulse"></span>
                </button>
 
                <!-- Engine Selector -->
                <div class="flex items-center space-x-2 bg-white border border-gray-200 rounded-lg p-1 shadow-sm">
                    <span class="text-xs font-medium text-gray-500 pl-2">引擎:</span>
                    <div class="relative">
                        <select id="provider-select" onchange="handleProviderChange()" class="appearance-none bg-transparent hover:bg-gray-50 text-gray-700 py-1 pl-2 pr-8 rounded-md text-sm font-semibold focus:outline-none cursor-pointer">
                            <option value="zhipu">智谱 AI</option>
                            <option value="qwen">通义千问</option>
                            <option value="baidu">百度 OCR</option>
                        </select>
                        <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-400">
                            <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" /></svg>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
 
    <!-- ================= MAIN CONTENT ================= -->
    <div class="max-w-7xl mx-auto px-6 py-8">
        <div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
             
            <!-- Left Panel -->
            <div class="lg:col-span-4 space-y-6">
                <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
                    <h2 class="text-xl font-semibold text-gray-900 mb-4">使用说明</h2>
                    <ul class="space-y-3 text-sm text-gray-600">
                        <li class="flex items-start">
                            <span class="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-bold mr-3 mt-0.5">1</span>
                            <span>点击右上角齿轮图标,选择服务商并配置 API Key。</span>
                        </li>
                        <li class="flex items-start">
                            <span class="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-bold mr-3 mt-0.5">2</span>
                            <span>拖拽身份证图片到右侧区域。支持 JPG, PNG 等。</span>
                        </li>
                        <li class="flex items-start">
                            <span class="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-bold mr-3 mt-0.5">3</span>
                            <span>导出为 Excel 格式,身份证号和日期已自动修正。</span>
                        </li>
                    </ul>
                    <div class="mt-6 pt-6 border-t border-gray-100">
                        <div class="flex justify-between items-center mb-1">
                            <span class="text-sm font-medium text-gray-500">累计识别成功</span>
                            <span class="text-2xl font-bold text-gray-900" id="success-count">0</span>
                        </div>
                        <div class="w-full bg-gray-100 rounded-full h-2">
                            <div id="progress-bar" class="bg-blue-600 h-2 rounded-full transition-all duration-500" style="width: 0%"></div>
                        </div>
                    </div>
                </div>
 
                <div class="flex flex-col gap-3">
                    <button id="btn-export" onclick="exportData()" disabled class="w-full flex items-center justify-center px-4 py-3 text-sm font-medium text-white bg-[#0071e3] rounded-xl hover:bg-[#0077ED] active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm">
                        <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
                        导出 Excel
                    </button>
                    <button id="btn-clear" onclick="requestClear()" disabled class="w-full px-4 py-3 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 hover:text-red-500 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer">
                        清空结果
                    </button>
                </div>
            </div>
 
            <!-- Right Panel -->
            <div class="lg:col-span-8">
                <!-- Dropzone -->
                <div id="dropzone" class="relative w-full h-64 rounded-2xl border-2 border-dashed border-gray-300 bg-white hover:border-blue-400 hover:bg-gray-50 transition-all duration-300 ease-in-out p-12 text-center flex flex-col justify-center items-center group mb-6">
                    <input type="file" id="file-input" multiple accept="image/png, image/jpeg, image/bmp, image/gif, image/webp, image/tiff" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-10">
                     
                    <div class="flex flex-col items-center justify-center space-y-5 pointer-events-none select-none">
                        <div id="dropzone-icon" class="p-5 rounded-full bg-gradient-to-br from-blue-50 to-blue-100 shadow-inner transition-transform duration-300">
                            <svg class="w-12 h-12 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
                        </div>
                        <div class="space-y-2">
                            <p id="dropzone-text" class="text-xl font-semibold text-gray-800">点击或拖拽上传身份证</p>
                            <p class="text-sm text-gray-500 max-w-xs mx-auto leading-relaxed">支持自动批量识别<br><span class="text-xs text-gray-400 mt-1 inline-block">单张图片限制 10MB 以内</span></p>
                        </div>
                        <div id="dropzone-error" class="hidden text-sm text-red-500 bg-red-50 px-3 py-1 rounded-md animate-pulse"></div>
                    </div>
                </div>
 
                <!-- Table -->
                <div id="results-container" class="w-full overflow-hidden bg-white rounded-2xl border border-gray-200 shadow-sm ring-1 ring-black/5 hidden">
                    <div class="overflow-x-auto">
                        <table class="w-full text-left border-collapse">
                            <thead>
                                <tr class="border-b border-gray-100 bg-gray-50/80 backdrop-blur">
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider w-20">序号</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">状态</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap min-w-[120px]">文件名</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">姓名</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">身份证号码</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">性别</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">民族</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">出生日期</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">住址</th>
                                </tr>
                            </thead>
                            <tbody id="results-body" class="divide-y divide-gray-100 bg-white">
                                <!-- Rows will be injected here -->
                            </tbody>
                        </table>
                    </div>
                    <div class="px-6 py-3 border-t border-gray-100 bg-gray-50/50 text-xs text-gray-400 flex justify-between items-center backdrop-blur">
                        <span id="table-footer-count">已加载 0 张图片</span>
                        <span class="opacity-75">右键单元格可复制</span>
                    </div>
                </div>
                 
                <!-- Empty State (Initial) -->
                <div id="empty-state" class="w-full h-40 flex flex-col items-center justify-center text-gray-400 bg-white/60 backdrop-blur rounded-2xl border border-dashed border-gray-300 mt-8 hidden">
                    <p>等待拖拽图片...</p>
                </div>
            </div>
        </div>
    </div>
 
    <!-- ================= JAVASCRIPT LOGIC ================= -->
    <script>
        // --- Global State ---
        let appData = []; // Stores { id, name, status, fileName, ... }
        let isProcessing = false;
        let apiKeys = {
            zhipu: '',
            qwen: '',
            baiduApiKey: '',
            baiduSecretKey: ''
        };
        let currentProvider = 'zhipu';
 
        // --- Init ---
        document.addEventListener('DOMContentLoaded', () => {
            // Load Keys
            const savedKeys = localStorage.getItem('ocr_api_keys');
            if (savedKeys) {
                try {
                    apiKeys = { ...apiKeys, ...JSON.parse(savedKeys) };
                } catch(e) {}
            }
            // Load Provider Selection
            const savedProvider = localStorage.getItem('ocr_provider');
            if(savedProvider) {
                currentProvider = savedProvider;
                document.getElementById('provider-select').value = currentProvider;
            }
             
            updateUIState();
            setupDragAndDrop();
        });
 
        // --- Helpers ---
        function generateId() { return Math.random().toString(36).substr(2, 9); }
        const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
         
        function fileToBase64(file) {
            return new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.onloadend = () => {
                    const base64String = reader.result;
                    const base64Data = base64String.split(',')[1];
                    resolve(base64Data);
                };
                reader.onerror = reject;
                reader.readAsDataURL(file);
            });
        }
 
        function parseJSONFromText(text) {
            try {
                const cleanText = text.replace(/```json/g, '').replace(/```/g, '').trim();
                const start = cleanText.indexOf('{');
                const end = cleanText.lastIndexOf('}');
                if (start !== -1 && end !== -1) {
                    return JSON.parse(cleanText.substring(start, end + 1));
                }
                return JSON.parse(cleanText);
            } catch (e) {
                throw new Error("模型返回的不是合法的 JSON 格式");
            }
        }
 
        // 强力日期标准化函数:转为 YYYY年M月D日 (如 1987年7月4日)
        function normalizeDate(dateStr) {
            if (!dateStr) return '';
             
            // Pattern 1: YYYY-MM-DD or YYYY.MM.DD or YYYY/MM/DD
            let match = dateStr.match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/);
            if (match) {
                return `${match[1]}年${parseInt(match[2], 10)}月${parseInt(match[3], 10)}日`;
            }
             
            // Pattern 2: YYYY年MM月DD日
            match = dateStr.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/);
            if (match) {
                 return `${match[1]}年${parseInt(match[2], 10)}月${parseInt(match[3], 10)}日`;
            }
 
             // Pattern 3: YYYYMMDD
            match = dateStr.match(/(\d{4})(\d{2})(\d{2})/);
            if (match) {
                 return `${match[1]}年${parseInt(match[2], 10)}月${parseInt(match[3], 10)}日`;
            }
 
            return dateStr;
        }
 
        // --- OCR Services ---
 
        // 1. Zhipu
        async function callZhipuAI(file, apiKey) {
            const base64 = await fileToBase64(file);
            const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
            const payload = {
                model: "glm-4v-flash",
                messages: [{
                    role: "user",
                    content: [
                        { type: "text", text: "请识别身份证图片。提取以下字段并以纯JSON格式返回,不要包含任何markdown标记: name, idNumber, gender, ethnicity, birthDate(格式YYYY年MM月DD日), address。" },
                        { type: "image_url", image_url: { url: `data:${file.type};base64,${base64}` } }
                    ]
                }],
                temperature: 0.1,
                top_p: 0.7
            };
 
            let retries = 5;
            let backoff = 3000;
            while (retries >= 0) {
                try {
                    const response = await fetch(url, {
                        method: "POST",
                        headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
                        body: JSON.stringify(payload)
                    });
                    if (response.status === 429) {
                        if (retries === 0) throw new Error("智谱 API 请求过于频繁,请稍后重试");
                        await wait(backoff);
                        retries--;
                        backoff *= 1.5;
                        continue;
                    }
                    if (!response.ok) throw new Error(`智谱 API 错误: ${response.statusText}`);
                    const data = await response.json();
                    return parseJSONFromText(data.choices[0].message.content);
                } catch (e) {
                    if (retries === 0) throw e;
                    if (e.message.includes("Failed to fetch")) throw new Error("网络错误或跨域限制");
                    retries--;
                }
            }
        }
 
        // 2. Qwen
        async function callQwenAI(file, apiKey) {
            const base64 = await fileToBase64(file);
            const url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
            const payload = {
                model: "qwen-vl-plus",
                input: {
                    messages: [{
                        role: "user",
                        content: [
                            { text: "提取身份证信息,返回JSON格式: name, idNumber, gender, ethnicity, birthDate, address" },
                            { image: `data:${file.type};base64,${base64}` }
                        ]
                    }]
                },
                parameters: { result_format: "message" }
            };
 
            let retries = 5;
            while (retries >= 0) {
                try {
                    const response = await fetch(url, {
                        method: "POST",
                        headers: {
                            "Content-Type": "application/json",
                            "Authorization": `Bearer ${apiKey}`,
                            "X-DashScope-WorkSpace": "id-card-ocr"
                        },
                        body: JSON.stringify(payload)
                    });
                    if (response.status === 429) {
                        if (retries === 0) throw new Error("Qwen API 限流");
                        await wait(2000);
                        retries--;
                        continue;
                    }
                    if (!response.ok) throw new Error("Qwen API 请求失败");
                    const data = await response.json();
                    const content = data.output.choices[0].message.content[0].text;
                    return parseJSONFromText(content);
                } catch (e) {
                    if (retries === 0) throw e;
                    retries--;
                }
            }
        }
 
        // 3. Baidu
        async function getBaiduToken(ak, sk) {
            const url = `[url]https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=[/url]${ak}&client_secret=${sk}`;
            try {
                // Note: Direct fetch might fail due to CORS in pure static HTML if Baidu console doesn't allow it. 
                const response = await fetch(url, { method: 'POST' });
                const data = await response.json();
                if (data.error) throw new Error(data.error_description);
                return data.access_token;
            } catch (e) {
                console.error(e);
                throw new Error("获取百度 Token 失败,可能是 AK/SK 错误或跨域限制");
            }
        }
 
        async function callBaiduOCR(file, ak, sk) {
            const token = await getBaiduToken(ak, sk);
            const base64 = await fileToBase64(file);
            const url = `[url]https://aip.baidubce.com/rest/2.0/ocr/v1/idcard?access_token=[/url]${token}`;
             
            const formData = new URLSearchParams();
            formData.append('image', base64);
            formData.append('id_card_side', 'front');
            formData.append('detect_risk', 'false');
 
            let retries = 3;
            while (retries >= 0) {
                try {
                    const response = await fetch(url, {
                        method: "POST",
                        headers: { "Content-Type": "application/x-www-form-urlencoded" },
                        body: formData
                    });
                    const data = await response.json();
                    if (data.error_code === 18) { // QPS Limit
                        if (retries === 0) throw new Error("百度 QPS 超限");
                        await wait(1000);
                        retries--;
                        continue;
                    }
                    if (data.error_code) throw new Error(data.error_msg);
                     
                    const words = data.words_result;
                    if (!words) throw new Error("未识别到信息");
                     
                    return {
                        name: words['姓名']?.words || "",
                        idNumber: words['公民身份号码']?.words || "",
                        gender: words['性别']?.words || "",
                        ethnicity: words['民族']?.words || "",
                        birthDate: words['出生']?.words || "",
                        address: words['住址']?.words || ""
                    };
                } catch(e) {
                    if(retries === 0) throw e;
                    retries--;
                }
            }
        }
 
        // --- Main Processing Logic ---
        async function processFiles(files) {
            if (!checkConfig()) {
                openSettings();
                return;
            }
            if (isProcessing) return;
            isProcessing = true;
            disableUI(true);
 
            // Add to data list
            const newEntries = Array.from(files).map(f => ({
                id: generateId(),
                fileObj: f, // Keep ref
                fileName: f.name,
                status: 'pending',
                name: '', idNumber: '', gender: '', ethnicity: '', birthDate: '', address: ''
            }));
            appData = [...newEntries, ...appData];
            renderTable();
            updateStats();
 
            // Process one by one (Concurrency = 1)
            for (let entry of newEntries) {
                // Update Status -> Processing
                const idx = appData.findIndex(d => d.id === entry.id);
                if(idx !== -1) {
                    appData[idx].status = 'processing';
                    renderRow(appData[idx], idx); 
                }
 
                try {
                    let result = {};
                    if (currentProvider === 'zhipu') {
                        result = await callZhipuAI(entry.fileObj, apiKeys.zhipu);
                    } else if (currentProvider === 'qwen') {
                        result = await callQwenAI(entry.fileObj, apiKeys.qwen);
                    } else if (currentProvider === 'baidu') {
                        result = await callBaiduOCR(entry.fileObj, apiKeys.baiduApiKey, apiKeys.baiduSecretKey);
                    }
 
                    // Success & Normalize Data
                    if(idx !== -1) {
                        appData[idx] = { 
                            ...appData[idx], 
                            ...result, 
                            birthDate: normalizeDate(result.birthDate), // Force normalization
                            status: 'success' 
                        };
                        renderRow(appData[idx], idx);
                    }
                } catch (e) {
                    // Error
                    if(idx !== -1) {
                        appData[idx].status = 'error';
                        appData[idx].errorMessage = e.message;
                        renderRow(appData[idx], idx);
                    }
                }
                updateStats(); // Update progress bar
            }
 
            isProcessing = false;
            disableUI(false);
            renderTable(); // Final refresh
        }
 
        // --- UI Functions ---
 
        function checkConfig() {
            if (currentProvider === 'baidu') return !!(apiKeys.baiduApiKey && apiKeys.baiduSecretKey);
            return !!apiKeys[currentProvider];
        }
 
        function updateUIState() {
            const hasKey = checkConfig();
            const badge = document.getElementById('settings-badge');
            const btn = document.getElementById('settings-trigger-btn');
             
            if(!hasKey) {
                badge.classList.remove('hidden');
                btn.classList.add('bg-blue-50', 'text-blue-600');
            } else {
                badge.classList.add('hidden');
                btn.classList.remove('bg-blue-50', 'text-blue-600');
            }
        }
 
        function handleProviderChange() {
            currentProvider = document.getElementById('provider-select').value;
            localStorage.setItem('ocr_provider', currentProvider);
            updateUIState();
        }
 
        function setupDragAndDrop() {
            const dz = document.getElementById('dropzone');
            const fileInput = document.getElementById('file-input');
            const errDiv = document.getElementById('dropzone-error');
 
            const validate = (files) => {
                const valid = [];
                let msg = null;
                const types = ['image/jpeg','image/png','image/bmp','image/gif','image/webp','image/tiff'];
                Array.from(files).forEach(f => {
                    if(!types.includes(f.type)) { msg = "不支持的文件格式"; return; }
                    if(f.size > 10*1024*1024) { msg = "文件超过10MB"; return; }
                    if(f.size < 1024) { msg = "文件太小"; return; }
                    valid.push(f);
                });
                if(msg) {
                    errDiv.innerText = msg;
                    errDiv.classList.remove('hidden');
                    setTimeout(() => errDiv.classList.add('hidden'), 3000);
                }
                return valid;
            };
 
            dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.classList.add('border-blue-500', 'bg-blue-50/50'); });
            dz.addEventListener('dragleave', (e) => { e.preventDefault(); dz.classList.remove('border-blue-500', 'bg-blue-50/50'); });
            dz.addEventListener('drop', (e) => {
                e.preventDefault();
                dz.classList.remove('border-blue-500', 'bg-blue-50/50');
                if(isProcessing) return;
                const files = validate(e.dataTransfer.files);
                if(files.length > 0) processFiles(files);
            });
 
            fileInput.addEventListener('change', (e) => {
                if(isProcessing) return;
                const files = validate(e.target.files);
                if(files.length > 0) processFiles(files);
                e.target.value = '';
            });
        }
 
        function renderTable() {
            const tbody = document.getElementById('results-body');
            const container = document.getElementById('results-container');
            const empty = document.getElementById('empty-state');
             
            if(appData.length === 0) {
                container.classList.add('hidden');
                empty.classList.remove('hidden'); 
            } else {
                container.classList.remove('hidden');
                empty.classList.add('hidden');
            }
             
            tbody.innerHTML = '';
            appData.forEach((item, index) => {
                tbody.appendChild(createRowElement(item, index));
            });
            document.getElementById('table-footer-count').innerText = `已加载 ${appData.length} 张图片`;
        }
 
        function createRowElement(item, index) {
            const tr = document.createElement('tr');
            tr.className = "hover:bg-blue-50/30 transition-colors duration-200 group";
            tr.id = `row-${item.id}`;
             
            let statusHtml = '';
            if(item.status === 'pending') statusHtml = `<div class="w-2 h-2 rounded-full bg-gray-300"></div>`;
            else if(item.status === 'processing') statusHtml = `<div class="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>`;
            else if(item.status === 'success') statusHtml = `<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" /></svg>`;
            else if(item.status === 'error') statusHtml = `<div class="group relative"><svg class="w-5 h-5 text-red-500 cursor-help" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><span class="absolute left-6 top-0 w-48 p-2 bg-black text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50 pointer-events-none">${item.errorMessage || '未知错误'}</span></div>`;
 
            const tdClass = "px-6 py-4 text-sm text-gray-600 whitespace-nowrap";
             
            tr.innerHTML = `
                <td class="px-6 py-4 text-xs text-gray-400 font-mono">${appData.length - index}</td>
                <td class="px-6 py-4 whitespace-nowrap">${statusHtml}</td>
                <td class="px-6 py-4 text-sm text-gray-500 font-medium whitespace-nowrap max-w-[120px] truncate" title="${item.fileName}">${item.fileName}</td>
                <td class="px-6 py-4 text-sm text-gray-900 font-medium whitespace-nowrap">${item.name || ''}</td>
                <td class="px-6 py-4 text-sm text-gray-600 font-mono whitespace-nowrap select-all selection:bg-blue-100">${item.idNumber || ''}</td>
                <td class="${tdClass}">${item.gender || ''}</td>
                <td class="${tdClass}">${item.ethnicity || ''}</td>
                <td class="${tdClass}">${item.birthDate || ''}</td>
                <td class="px-6 py-4 text-sm text-gray-600 whitespace-normal break-words min-w-[250px]">${item.address || ''}</td>
            `;
            return tr;
        }
 
        // Optimization: re-render specific row
        function renderRow(item, index) {
            const oldRow = document.getElementById(`row-${item.id}`);
            if(oldRow) {
                const newRow = createRowElement(item, index);
                oldRow.replaceWith(newRow);
            }
        }
 
        function updateStats() {
            const success = appData.filter(d => d.status === 'success').length;
            const total = appData.length;
            document.getElementById('success-count').innerText = success;
            const pct = total > 0 ? (success / total) * 100 : 0;
            document.getElementById('progress-bar').style.width = `${pct}%`;
 
            document.getElementById('btn-export').disabled = success === 0;
            document.getElementById('btn-clear').disabled = total === 0;
        }
 
        function disableUI(disabled) {
            document.getElementById('file-input').disabled = disabled;
            const dzText = document.getElementById('dropzone-text');
            dzText.innerText = disabled ? "正在处理中..." : "点击或拖拽上传身份证";
        }
 
        // --- Export ---
        function exportData() {
            const validData = appData.filter(d => d.status === 'success');
            if(validData.length === 0) return;
 
            const headers = ['文件名', '姓名', '身份证号码', '性别', '民族', '出生日期', '住址'];
            const BOM = '\uFEFF';
             
            const rows = validData.map(item => {
                const safe = (str) => `"${(str||'').replace(/"/g, '""')}"`;
                // Excel hack: \t to force text mode
                const safeId = `"\t${item.idNumber || ''}"`;
                // normalizeDate already called during processing, but double check
                const fmtDate = normalizeDate(item.birthDate);
                return [safe(item.fileName), safe(item.name), safeId, safe(item.gender), safe(item.ethnicity), safe(fmtDate), safe(item.address)].join(',');
            });
 
            const csvContent = BOM + headers.join(',') + '\n' + rows.join('\n');
            const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
            const url = URL.createObjectURL(blob);
            const link = document.createElement('a');
            link.href = url;
            const dateStr = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(/[\/\s:]/g, '');
            link.download = `身份证识别结果_${dateStr}.csv`;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
        }
 
        // --- Modals & Settings ---
        function openSettings() {
            // Load current input values
            document.getElementById('input-zhipu').value = apiKeys.zhipu;
            document.getElementById('input-qwen').value = apiKeys.qwen;
            document.getElementById('input-baidu-ak').value = apiKeys.baiduApiKey;
            document.getElementById('input-baidu-sk').value = apiKeys.baiduSecretKey;
             
            // Switch to current provider tab or default to zhipu
            switchTab(currentProvider);
            document.getElementById('modal-settings').classList.remove('hidden');
        }
 
        function switchTab(provider) {
            // Update Tab Styles
            ['zhipu', 'qwen', 'baidu'].forEach(p => {
                const btn = document.getElementById(`tab-btn-${p}`);
                const content = document.getElementById(`content-${p}`);
                if(p === provider) {
                    btn.className = "tab-btn w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all text-blue-600 bg-white shadow-sm ring-1 ring-black/5";
                    content.classList.remove('hidden');
                } else {
                    btn.className = "tab-btn w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all text-gray-600 hover:bg-gray-100";
                    content.classList.add('hidden');
                }
            });
            // Don't update global state until save
        }
 
        function saveApiKeys() {
            apiKeys.zhipu = document.getElementById('input-zhipu').value;
            apiKeys.qwen = document.getElementById('input-qwen').value;
            apiKeys.baiduApiKey = document.getElementById('input-baidu-ak').value;
            apiKeys.baiduSecretKey = document.getElementById('input-baidu-sk').value;
             
            localStorage.setItem('ocr_api_keys', JSON.stringify(apiKeys));
            updateUIState();
            closeModal('modal-settings');
        }
 
        function requestClear() {
            document.getElementById('modal-clear').classList.remove('hidden');
        }
 
        function confirmClearData() {
            appData = [];
            renderTable();
            updateStats();
            closeModal('modal-clear');
        }
 
        function closeModal(id) {
            document.getElementById(id).classList.add('hidden');
        }
 
    </script>
</body>
</html>

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
zgywqm + 1 + 1 这个还挺好

查看全部评分

推荐
 楼主| zgywqm 发表于 2025-11-26 16:19 |楼主

我用的是免费的OCR接口,识别次数限制的,做成成品的话一会就用完失效了 ·最好还是自己区申请一个自己用药好些

免费评分

参与人数 1吾爱币 +2 热心值 +1 收起 理由
fy1230001 + 2 + 1 谢谢@Thanks!

查看全部评分

推荐
alinwei 发表于 2025-11-26 16:10
纯html试试
[HTML] 纯文本查看 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>智能身份证识别 Pro</title>
    <!-- Tailwind CSS -->
    <script src="https://cdn.tailwindcss.com"></script>
    <!-- Fonts -->
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
    <style>
        body {
            font-family: 'Inter', 'Noto Sans SC', sans-serif;
            background-color: #f5f5f7;
            color: #1d1d1f;
        }
        .glass-effect {
            background: rgba(255, 255, 255, 0.7);
            backdrop-filter: blur(20px);
            -webkit-backdrop-filter: blur(20px);
            border-bottom: 1px solid rgba(255, 255, 255, 0.3);
        }
        /* Custom Scrollbar */
        ::-webkit-scrollbar {
            width: 8px;
            height: 8px;
        }
        ::-webkit-scrollbar-track {
            background: transparent; 
        }
        ::-webkit-scrollbar-thumb {
            background: #cbd5e1; 
            border-radius: 4px;
        }
        ::-webkit-scrollbar-thumb:hover {
            background: #94a3b8; 
        }
        .animate-spin {
            animation: spin 1s linear infinite;
        }
        @keyframes spin {
            from { transform: rotate(0deg); }
            to { transform: rotate(360deg); }
        }
    </style>
<script type="importmap">
{
  "imports": {
    "react": "https://aistudiocdn.com/react@^19.2.0",
    "react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
    "react/": "https://aistudiocdn.com/react@^19.2.0/"
  }
}
</script>
</head>
<body class="min-h-screen selection:bg-blue-100">

    <!-- ================= MODALS ================= -->

    <!-- 1. Clear Confirmation Modal -->
    <div id="modal-clear" class="fixed inset-0 z-[110] hidden flex items-center justify-center p-4">
        <div class="absolute inset-0 bg-black/30 backdrop-blur-sm transition-opacity" onclick="closeModal('modal-clear')"></div>
        <div class="bg-white rounded-2xl shadow-xl w-full max-w-sm z-20 overflow-hidden transform transition-all scale-100 p-6 text-center">
            <div class="w-12 h-12 rounded-full bg-red-100 mx-auto flex items-center justify-center mb-4">
                <svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
            </div>
            <h3 class="text-lg font-bold text-gray-900 mb-2">确认清空?</h3>
            <p class="text-sm text-gray-500 mb-6">该操作将移除所有已上传的图片和识别结果,无法撤销。</p>
            <div class="flex space-x-3">
                <button onclick="closeModal('modal-clear')" class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-50 border border-gray-300 rounded-xl hover:bg-gray-100">取消</button>
                <button onclick="confirmClearData()" class="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-500 border border-transparent rounded-xl hover:bg-red-600 shadow-sm">确认清空</button>
            </div>
        </div>
    </div>

    <!-- 2. Settings Modal -->
    <div id="modal-settings" class="fixed inset-0 z-[100] hidden flex items-center justify-center p-4">
        <div class="absolute inset-0 bg-black/20 backdrop-blur-sm transition-opacity" onclick="closeModal('modal-settings')"></div>
        <div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl z-10 overflow-hidden transform transition-all scale-100 flex flex-col md:flex-row h-[520px]">
            <!-- Sidebar -->
            <div class="w-full md:w-1/3 bg-gray-50 border-r border-gray-100 p-4 space-y-2">
                <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4 px-2">服务商配置</h3>
                <button onclick="switchTab('zhipu')" id="tab-btn-zhipu" class="tab-btn w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all text-blue-600 bg-white shadow-sm ring-1 ring-black/5">智谱 AI (GLM-4V)</button>
                <button onclick="switchTab('qwen')" id="tab-btn-qwen" class="tab-btn w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all text-gray-600 hover:bg-gray-100">阿里云 (通义千问)</button>
                <button onclick="switchTab('baidu')" id="tab-btn-baidu" class="tab-btn w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all text-gray-600 hover:bg-gray-100">百度智能云 (OCR)</button>
            </div>
            <!-- Content -->
            <div class="flex-1 flex flex-col">
                <div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
                    <h3 class="text-lg font-semibold text-gray-900">API 配置</h3>
                    <button onclick="closeModal('modal-settings')" class="text-gray-400 hover:text-gray-600">
                        <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
                    </button>
                </div>
                <div class="flex-1 p-8 overflow-y-auto">
                    <!-- Zhipu Content -->
                    <div id="content-zhipu" class="tab-content block">
                        <p class="text-sm text-gray-600 mb-4 bg-gray-100 p-3 rounded-lg border border-gray-200">推荐。使用 glm-4v-flash 模型,目前免费且速度极快。</p>
                        <label class="block text-sm font-medium text-gray-900 mb-2">API Key</label>
                        <input type="password" id="input-zhipu" placeholder="请输入智谱 GLM-4 Key" class="block w-full rounded-xl border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-3 border bg-gray-50">
                        <div class="mt-6 bg-blue-50 rounded-xl p-4 border border-blue-100 flex justify-between items-center">
                            <h4 class="text-sm font-semibold text-blue-900">需要申请 Key?</h4>
                            <a href="https://bigmodel.cn/usercenter/proj-mgmt/apikeys" target="_blank" class="inline-flex items-center text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors">去官网控制台 &rarr;</a>
                        </div>
                    </div>
                    <!-- Qwen Content -->
                    <div id="content-qwen" class="tab-content hidden">
                        <p class="text-sm text-gray-600 mb-4 bg-gray-100 p-3 rounded-lg border border-gray-200">使用 qwen-vl 模型,识别能力强,有免费额度。</p>
                        <label class="block text-sm font-medium text-gray-900 mb-2">API Key</label>
                        <input type="password" id="input-qwen" placeholder="请输入阿里云 DashScope Key" class="block w-full rounded-xl border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-3 border bg-gray-50">
                        <div class="mt-6 bg-blue-50 rounded-xl p-4 border border-blue-100 flex justify-between items-center">
                            <h4 class="text-sm font-semibold text-blue-900">需要申请 Key?</h4>
                            <a href="https://dashscope.console.aliyun.com/apiKey" target="_blank" class="inline-flex items-center text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors">去官网控制台 &rarr;</a>
                        </div>
                    </div>
                    <!-- Baidu Content -->
                    <div id="content-baidu" class="tab-content hidden">
                        <p class="text-sm text-gray-600 mb-4 bg-gray-100 p-3 rounded-lg border border-gray-200">传统 OCR 强项。每月赠送约 1000 次免费额度,需配置 AK/SK。</p>
                        <div class="space-y-4">
                            <div>
                                <label class="block text-sm font-medium text-gray-900 mb-1">API Key (AK)</label>
                                <input type="text" id="input-baidu-ak" placeholder="请输入百度云 API Key" class="block w-full rounded-xl border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-3 border bg-gray-50">
                            </div>
                            <div>
                                <label class="block text-sm font-medium text-gray-900 mb-1">Secret Key (SK)</label>
                                <input type="password" id="input-baidu-sk" placeholder="请输入百度云 Secret Key" class="block w-full rounded-xl border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-3 border bg-gray-50">
                            </div>
                        </div>
                        <div class="mt-6 bg-blue-50 rounded-xl p-4 border border-blue-100 flex justify-between items-center">
                            <h4 class="text-sm font-semibold text-blue-900">需要申请 Key?</h4>
                            <a href="https://console.bce.baidu.com/ai/#/ai/ocr/overview/index" target="_blank" class="inline-flex items-center text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors">去官网控制台 &rarr;</a>
                        </div>
                    </div>
                </div>
                <div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end space-x-3">
                    <button onclick="closeModal('modal-settings')" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">取消</button>
                    <button onclick="saveApiKeys()" class="px-6 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 shadow-sm transition-all active:scale-95">保存配置</button>
                </div>
            </div>
        </div>
    </div>

    <!-- ================= NAVBAR ================= -->
    <div class="glass-effect sticky top-0 z-50">
        <div class="max-w-7xl mx-auto px-6 h-16 flex justify-between items-center">
            <div class="flex items-center space-x-3">
                <div class="bg-gradient-to-tr from-blue-600 to-indigo-500 text-white p-1.5 rounded-lg shadow-sm">
                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0c0 .883.393 1.627 1 2.18m4-2.18c.607.553 1 1.297 1 2.18" /></svg>
                </div>
                <div>
                    <h1 class="text-lg font-bold tracking-tight text-gray-900">身份证识别 Pro</h1>
                </div>
            </div>
            <div class="flex items-center space-x-4">
                <!-- Settings Button -->
                <button onclick="openSettings()" id="settings-trigger-btn" class="p-2 rounded-full transition-colors relative group text-gray-500 hover:bg-gray-100" title="配置 API">
                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
                    <span id="settings-badge" class="hidden absolute top-0 right-0 w-2.5 h-2.5 bg-red-500 border-2 border-white rounded-full animate-pulse"></span>
                </button>

                <!-- Engine Selector -->
                <div class="flex items-center space-x-2 bg-white border border-gray-200 rounded-lg p-1 shadow-sm">
                    <span class="text-xs font-medium text-gray-500 pl-2">引擎:</span>
                    <div class="relative">
                        <select id="provider-select" onchange="handleProviderChange()" class="appearance-none bg-transparent hover:bg-gray-50 text-gray-700 py-1 pl-2 pr-8 rounded-md text-sm font-semibold focus:outline-none cursor-pointer">
                            <option value="zhipu">智谱 AI</option>
                            <option value="qwen">通义千问</option>
                            <option value="baidu">百度 OCR</option>
                        </select>
                        <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-400">
                            <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" /></svg>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <!-- ================= MAIN CONTENT ================= -->
    <div class="max-w-7xl mx-auto px-6 py-8">
        <div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
            
            <!-- Left Panel -->
            <div class="lg:col-span-4 space-y-6">
                <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
                    <h2 class="text-xl font-semibold text-gray-900 mb-4">使用说明</h2>
                    <ul class="space-y-3 text-sm text-gray-600">
                        <li class="flex items-start">
                            <span class="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-bold mr-3 mt-0.5">1</span>
                            <span>点击右上角齿轮图标,选择服务商并配置 API Key。</span>
                        </li>
                        <li class="flex items-start">
                            <span class="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-bold mr-3 mt-0.5">2</span>
                            <span>拖拽身份证图片到右侧区域。支持 JPG, PNG 等。</span>
                        </li>
                        <li class="flex items-start">
                            <span class="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-bold mr-3 mt-0.5">3</span>
                            <span>导出为 Excel 格式,身份证号和日期已自动修正。</span>
                        </li>
                    </ul>
                    <div class="mt-6 pt-6 border-t border-gray-100">
                        <div class="flex justify-between items-center mb-1">
                            <span class="text-sm font-medium text-gray-500">累计识别成功</span>
                            <span class="text-2xl font-bold text-gray-900" id="success-count">0</span>
                        </div>
                        <div class="w-full bg-gray-100 rounded-full h-2">
                            <div id="progress-bar" class="bg-blue-600 h-2 rounded-full transition-all duration-500" style="width: 0%"></div>
                        </div>
                    </div>
                </div>

                <div class="flex flex-col gap-3">
                    <button id="btn-export" onclick="exportData()" disabled class="w-full flex items-center justify-center px-4 py-3 text-sm font-medium text-white bg-[#0071e3] rounded-xl hover:bg-[#0077ED] active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm">
                        <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
                        导出 Excel
                    </button>
                    <button id="btn-clear" onclick="requestClear()" disabled class="w-full px-4 py-3 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 hover:text-red-500 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer">
                        清空结果
                    </button>
                </div>
            </div>

            <!-- Right Panel -->
            <div class="lg:col-span-8">
                <!-- Dropzone -->
                <div id="dropzone" class="relative w-full h-64 rounded-2xl border-2 border-dashed border-gray-300 bg-white hover:border-blue-400 hover:bg-gray-50 transition-all duration-300 ease-in-out p-12 text-center flex flex-col justify-center items-center group mb-6">
                    <input type="file" id="file-input" multiple accept="image/png, image/jpeg, image/bmp, image/gif, image/webp, image/tiff" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-10">
                    
                    <div class="flex flex-col items-center justify-center space-y-5 pointer-events-none select-none">
                        <div id="dropzone-icon" class="p-5 rounded-full bg-gradient-to-br from-blue-50 to-blue-100 shadow-inner transition-transform duration-300">
                            <svg class="w-12 h-12 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
                        </div>
                        <div class="space-y-2">
                            <p id="dropzone-text" class="text-xl font-semibold text-gray-800">点击或拖拽上传身份证</p>
                            <p class="text-sm text-gray-500 max-w-xs mx-auto leading-relaxed">支持自动批量识别<br><span class="text-xs text-gray-400 mt-1 inline-block">单张图片限制 10MB 以内</span></p>
                        </div>
                        <div id="dropzone-error" class="hidden text-sm text-red-500 bg-red-50 px-3 py-1 rounded-md animate-pulse"></div>
                    </div>
                </div>

                <!-- Table -->
                <div id="results-container" class="w-full overflow-hidden bg-white rounded-2xl border border-gray-200 shadow-sm ring-1 ring-black/5 hidden">
                    <div class="overflow-x-auto">
                        <table class="w-full text-left border-collapse">
                            <thead>
                                <tr class="border-b border-gray-100 bg-gray-50/80 backdrop-blur">
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider w-20">序号</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">状态</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">姓名</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">身份证号码</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">性别</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">民族</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">出生日期</th>
                                    <th class="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">住址</th>
                                </tr>
                            </thead>
                            <tbody id="results-body" class="divide-y divide-gray-100 bg-white">
                                <!-- Rows will be injected here -->
                            </tbody>
                        </table>
                    </div>
                    <div class="px-6 py-3 border-t border-gray-100 bg-gray-50/50 text-xs text-gray-400 flex justify-between items-center backdrop-blur">
                        <span id="table-footer-count">已加载 0 张图片</span>
                        <span class="opacity-75">右键单元格可复制</span>
                    </div>
                </div>
                
                <!-- Empty State (Initial) -->
                <div id="empty-state" class="w-full h-40 flex flex-col items-center justify-center text-gray-400 bg-white/60 backdrop-blur rounded-2xl border border-dashed border-gray-300 mt-8 hidden">
                    <p>等待拖拽图片...</p>
                </div>
            </div>
        </div>
    </div>

    <!-- ================= JAVASCRIPT LOGIC ================= -->
    <script>
        // --- Global State ---
        let appData = []; // Stores { id, name, status, ... }
        let isProcessing = false;
        let apiKeys = {
            zhipu: '',
            qwen: '',
            baiduApiKey: '',
            baiduSecretKey: ''
        };
        let currentProvider = 'zhipu';

        // --- Init ---
        document.addEventListener('DOMContentLoaded', () => {
            // Load Keys
            const savedKeys = localStorage.getItem('ocr_api_keys');
            if (savedKeys) {
                try {
                    apiKeys = { ...apiKeys, ...JSON.parse(savedKeys) };
                } catch(e) {}
            }
            // Load Provider Selection
            const savedProvider = localStorage.getItem('ocr_provider');
            if(savedProvider) {
                currentProvider = savedProvider;
                document.getElementById('provider-select').value = currentProvider;
            }
            
            updateUIState();
            setupDragAndDrop();
        });

        // --- Helpers ---
        function generateId() { return Math.random().toString(36).substr(2, 9); }
        const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
        
        function fileToBase64(file) {
            return new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.onloadend = () => {
                    const base64String = reader.result;
                    const base64Data = base64String.split(',')[1];
                    resolve(base64Data);
                };
                reader.onerror = reject;
                reader.readAsDataURL(file);
            });
        }

        function parseJSONFromText(text) {
            try {
                const cleanText = text.replace(/```json/g, '').replace(/```/g, '').trim();
                const start = cleanText.indexOf('{');
                const end = cleanText.lastIndexOf('}');
                if (start !== -1 && end !== -1) {
                    return JSON.parse(cleanText.substring(start, end + 1));
                }
                return JSON.parse(cleanText);
            } catch (e) {
                throw new Error("模型返回的不是合法的 JSON 格式");
            }
        }

        // 强力日期标准化函数:转为 YYYY年M月D日 (如 1987年7月4日)
        function normalizeDate(dateStr) {
            if (!dateStr) return '';
            
            // Pattern 1: YYYY-MM-DD or YYYY.MM.DD or YYYY/MM/DD
            let match = dateStr.match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/);
            if (match) {
                return `${match[1]}年${parseInt(match[2], 10)}月${parseInt(match[3], 10)}日`;
            }
            
            // Pattern 2: YYYY年MM月DD日
            match = dateStr.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/);
            if (match) {
                 return `${match[1]}年${parseInt(match[2], 10)}月${parseInt(match[3], 10)}日`;
            }

             // Pattern 3: YYYYMMDD
            match = dateStr.match(/(\d{4})(\d{2})(\d{2})/);
            if (match) {
                 return `${match[1]}年${parseInt(match[2], 10)}月${parseInt(match[3], 10)}日`;
            }

            return dateStr;
        }

        // --- OCR Services ---

        // 1. Zhipu
        async function callZhipuAI(file, apiKey) {
            const base64 = await fileToBase64(file);
            const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
            const payload = {
                model: "glm-4v-flash",
                messages: [{
                    role: "user",
                    content: [
                        { type: "text", text: "请识别身份证图片。提取以下字段并以纯JSON格式返回,不要包含任何markdown标记: name, idNumber, gender, ethnicity, birthDate(格式YYYY年MM月DD日), address。" },
                        { type: "image_url", image_url: { url: `data:${file.type};base64,${base64}` } }
                    ]
                }],
                temperature: 0.1,
                top_p: 0.7
            };

            let retries = 5;
            let backoff = 3000;
            while (retries >= 0) {
                try {
                    const response = await fetch(url, {
                        method: "POST",
                        headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
                        body: JSON.stringify(payload)
                    });
                    if (response.status === 429) {
                        if (retries === 0) throw new Error("智谱 API 请求过于频繁,请稍后重试");
                        await wait(backoff);
                        retries--;
                        backoff *= 1.5;
                        continue;
                    }
                    if (!response.ok) throw new Error(`智谱 API 错误: ${response.statusText}`);
                    const data = await response.json();
                    return parseJSONFromText(data.choices[0].message.content);
                } catch (e) {
                    if (retries === 0) throw e;
                    if (e.message.includes("Failed to fetch")) throw new Error("网络错误或跨域限制");
                    retries--;
                }
            }
        }

        // 2. Qwen
        async function callQwenAI(file, apiKey) {
            const base64 = await fileToBase64(file);
            const url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
            const payload = {
                model: "qwen-vl-plus",
                input: {
                    messages: [{
                        role: "user",
                        content: [
                            { text: "提取身份证信息,返回JSON格式: name, idNumber, gender, ethnicity, birthDate, address" },
                            { image: `data:${file.type};base64,${base64}` }
                        ]
                    }]
                },
                parameters: { result_format: "message" }
            };

            let retries = 5;
            while (retries >= 0) {
                try {
                    const response = await fetch(url, {
                        method: "POST",
                        headers: {
                            "Content-Type": "application/json",
                            "Authorization": `Bearer ${apiKey}`,
                            "X-DashScope-WorkSpace": "id-card-ocr"
                        },
                        body: JSON.stringify(payload)
                    });
                    if (response.status === 429) {
                        if (retries === 0) throw new Error("Qwen API 限流");
                        await wait(2000);
                        retries--;
                        continue;
                    }
                    if (!response.ok) throw new Error("Qwen API 请求失败");
                    const data = await response.json();
                    const content = data.output.choices[0].message.content[0].text;
                    return parseJSONFromText(content);
                } catch (e) {
                    if (retries === 0) throw e;
                    retries--;
                }
            }
        }

        // 3. Baidu
        async function getBaiduToken(ak, sk) {
            const url = `[url]https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=[/url]${ak}&client_secret=${sk}`;
            try {
                // Note: Direct fetch might fail due to CORS in pure static HTML if Baidu console doesn't allow it. 
                const response = await fetch(url, { method: 'POST' });
                const data = await response.json();
                if (data.error) throw new Error(data.error_description);
                return data.access_token;
            } catch (e) {
                console.error(e);
                throw new Error("获取百度 Token 失败,可能是 AK/SK 错误或跨域限制");
            }
        }

        async function callBaiduOCR(file, ak, sk) {
            const token = await getBaiduToken(ak, sk);
            const base64 = await fileToBase64(file);
            const url = `[url]https://aip.baidubce.com/rest/2.0/ocr/v1/idcard?access_token=[/url]${token}`;
            
            const formData = new URLSearchParams();
            formData.append('image', base64);
            formData.append('id_card_side', 'front');
            formData.append('detect_risk', 'false');

            let retries = 3;
            while (retries >= 0) {
                try {
                    const response = await fetch(url, {
                        method: "POST",
                        headers: { "Content-Type": "application/x-www-form-urlencoded" },
                        body: formData
                    });
                    const data = await response.json();
                    if (data.error_code === 18) { // QPS Limit
                        if (retries === 0) throw new Error("百度 QPS 超限");
                        await wait(1000);
                        retries--;
                        continue;
                    }
                    if (data.error_code) throw new Error(data.error_msg);
                    
                    const words = data.words_result;
                    if (!words) throw new Error("未识别到信息");
                    
                    return {
                        name: words['姓名']?.words || "",
                        idNumber: words['公民身份号码']?.words || "",
                        gender: words['性别']?.words || "",
                        ethnicity: words['民族']?.words || "",
                        birthDate: words['出生']?.words || "",
                        address: words['住址']?.words || ""
                    };
                } catch(e) {
                    if(retries === 0) throw e;
                    retries--;
                }
            }
        }

        // --- Main Processing Logic ---
        async function processFiles(files) {
            if (!checkConfig()) {
                openSettings();
                return;
            }
            if (isProcessing) return;
            isProcessing = true;
            disableUI(true);

            // Add to data list
            const newEntries = Array.from(files).map(f => ({
                id: generateId(),
                fileObj: f, // Keep ref
                fileName: f.name,
                status: 'pending',
                name: '', idNumber: '', gender: '', ethnicity: '', birthDate: '', address: ''
            }));
            appData = [...newEntries, ...appData];
            renderTable();
            updateStats();

            // Process one by one (Concurrency = 1)
            for (let entry of newEntries) {
                // Update Status -> Processing
                const idx = appData.findIndex(d => d.id === entry.id);
                if(idx !== -1) {
                    appData[idx].status = 'processing';
                    renderRow(appData[idx], idx); 
                }

                try {
                    let result = {};
                    if (currentProvider === 'zhipu') {
                        result = await callZhipuAI(entry.fileObj, apiKeys.zhipu);
                    } else if (currentProvider === 'qwen') {
                        result = await callQwenAI(entry.fileObj, apiKeys.qwen);
                    } else if (currentProvider === 'baidu') {
                        result = await callBaiduOCR(entry.fileObj, apiKeys.baiduApiKey, apiKeys.baiduSecretKey);
                    }

                    // Success & Normalize Data
                    if(idx !== -1) {
                        appData[idx] = { 
                            ...appData[idx], 
                            ...result, 
                            birthDate: normalizeDate(result.birthDate), // Force normalization
                            status: 'success' 
                        };
                        renderRow(appData[idx], idx);
                    }
                } catch (e) {
                    // Error
                    if(idx !== -1) {
                        appData[idx].status = 'error';
                        appData[idx].errorMessage = e.message;
                        renderRow(appData[idx], idx);
                    }
                }
                updateStats(); // Update progress bar
            }

            isProcessing = false;
            disableUI(false);
            renderTable(); // Final refresh
        }

        // --- UI Functions ---

        function checkConfig() {
            if (currentProvider === 'baidu') return !!(apiKeys.baiduApiKey && apiKeys.baiduSecretKey);
            return !!apiKeys[currentProvider];
        }

        function updateUIState() {
            const hasKey = checkConfig();
            const badge = document.getElementById('settings-badge');
            const btn = document.getElementById('settings-trigger-btn');
            
            if(!hasKey) {
                badge.classList.remove('hidden');
                btn.classList.add('bg-blue-50', 'text-blue-600');
            } else {
                badge.classList.add('hidden');
                btn.classList.remove('bg-blue-50', 'text-blue-600');
            }
        }

        function handleProviderChange() {
            currentProvider = document.getElementById('provider-select').value;
            localStorage.setItem('ocr_provider', currentProvider);
            updateUIState();
        }

        function setupDragAndDrop() {
            const dz = document.getElementById('dropzone');
            const fileInput = document.getElementById('file-input');
            const errDiv = document.getElementById('dropzone-error');

            const validate = (files) => {
                const valid = [];
                let msg = null;
                const types = ['image/jpeg','image/png','image/bmp','image/gif','image/webp','image/tiff'];
                Array.from(files).forEach(f => {
                    if(!types.includes(f.type)) { msg = "不支持的文件格式"; return; }
                    if(f.size > 10*1024*1024) { msg = "文件超过10MB"; return; }
                    if(f.size < 1024) { msg = "文件太小"; return; }
                    valid.push(f);
                });
                if(msg) {
                    errDiv.innerText = msg;
                    errDiv.classList.remove('hidden');
                    setTimeout(() => errDiv.classList.add('hidden'), 3000);
                }
                return valid;
            };

            dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.classList.add('border-blue-500', 'bg-blue-50/50'); });
            dz.addEventListener('dragleave', (e) => { e.preventDefault(); dz.classList.remove('border-blue-500', 'bg-blue-50/50'); });
            dz.addEventListener('drop', (e) => {
                e.preventDefault();
                dz.classList.remove('border-blue-500', 'bg-blue-50/50');
                if(isProcessing) return;
                const files = validate(e.dataTransfer.files);
                if(files.length > 0) processFiles(files);
            });

            fileInput.addEventListener('change', (e) => {
                if(isProcessing) return;
                const files = validate(e.target.files);
                if(files.length > 0) processFiles(files);
                e.target.value = '';
            });
        }

        function renderTable() {
            const tbody = document.getElementById('results-body');
            const container = document.getElementById('results-container');
            const empty = document.getElementById('empty-state');
            
            if(appData.length === 0) {
                container.classList.add('hidden');
                empty.classList.remove('hidden'); 
            } else {
                container.classList.remove('hidden');
                empty.classList.add('hidden');
            }
            
            tbody.innerHTML = '';
            appData.forEach((item, index) => {
                tbody.appendChild(createRowElement(item, index));
            });
            document.getElementById('table-footer-count').innerText = `已加载 ${appData.length} 张图片`;
        }

        function createRowElement(item, index) {
            const tr = document.createElement('tr');
            tr.className = "hover:bg-blue-50/30 transition-colors duration-200 group";
            tr.id = `row-${item.id}`;
            
            let statusHtml = '';
            if(item.status === 'pending') statusHtml = `<div class="w-2 h-2 rounded-full bg-gray-300"></div>`;
            else if(item.status === 'processing') statusHtml = `<div class="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>`;
            else if(item.status === 'success') statusHtml = `<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" /></svg>`;
            else if(item.status === 'error') statusHtml = `<div class="group relative"><svg class="w-5 h-5 text-red-500 cursor-help" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><span class="absolute left-6 top-0 w-48 p-2 bg-black text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50 pointer-events-none">${item.errorMessage || '未知错误'}</span></div>`;

            const tdClass = "px-6 py-4 text-sm text-gray-600 whitespace-nowrap";
            
            tr.innerHTML = `
                <td class="px-6 py-4 text-xs text-gray-400 font-mono">${appData.length - index}</td>
                <td class="px-6 py-4 whitespace-nowrap">${statusHtml}</td>
                <td class="px-6 py-4 text-sm text-gray-900 font-medium whitespace-nowrap">${item.name || ''}</td>
                <td class="px-6 py-4 text-sm text-gray-600 font-mono whitespace-nowrap select-all selection:bg-blue-100">${item.idNumber || ''}</td>
                <td class="${tdClass}">${item.gender || ''}</td>
                <td class="${tdClass}">${item.ethnicity || ''}</td>
                <td class="${tdClass}">${item.birthDate || ''}</td>
                <td class="px-6 py-4 text-sm text-gray-600 whitespace-normal break-words min-w-[250px]">${item.address || ''}</td>
            `;
            return tr;
        }

        // Optimization: re-render specific row
        function renderRow(item, index) {
            const oldRow = document.getElementById(`row-${item.id}`);
            if(oldRow) {
                const newRow = createRowElement(item, index);
                oldRow.replaceWith(newRow);
            }
        }

        function updateStats() {
            const success = appData.filter(d => d.status === 'success').length;
            const total = appData.length;
            document.getElementById('success-count').innerText = success;
            const pct = total > 0 ? (success / total) * 100 : 0;
            document.getElementById('progress-bar').style.width = `${pct}%`;

            document.getElementById('btn-export').disabled = success === 0;
            document.getElementById('btn-clear').disabled = total === 0;
        }

        function disableUI(disabled) {
            document.getElementById('file-input').disabled = disabled;
            const dzText = document.getElementById('dropzone-text');
            dzText.innerText = disabled ? "正在处理中..." : "点击或拖拽上传身份证";
        }

        // --- Export ---
        function exportData() {
            const validData = appData.filter(d => d.status === 'success');
            if(validData.length === 0) return;

            const headers = ['姓名', '身份证号码', '性别', '民族', '出生日期', '住址'];
            const BOM = '\uFEFF';
            
            const rows = validData.map(item => {
                const safe = (str) => `"${(str||'').replace(/"/g, '""')}"`;
                // Excel hack: \t to force text mode
                const safeId = `"\t${item.idNumber || ''}"`;
                // normalizeDate already called during processing, but double check
                const fmtDate = normalizeDate(item.birthDate);
                return [safe(item.name), safeId, safe(item.gender), safe(item.ethnicity), safe(fmtDate), safe(item.address)].join(',');
            });

            const csvContent = BOM + headers.join(',') + '\n' + rows.join('\n');
            const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
            const url = URL.createObjectURL(blob);
            const link = document.createElement('a');
            link.href = url;
            const dateStr = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(/[\/\s:]/g, '');
            link.download = `身份证识别结果_${dateStr}.csv`;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
        }

        // --- Modals & Settings ---
        function openSettings() {
            // Load current input values
            document.getElementById('input-zhipu').value = apiKeys.zhipu;
            document.getElementById('input-qwen').value = apiKeys.qwen;
            document.getElementById('input-baidu-ak').value = apiKeys.baiduApiKey;
            document.getElementById('input-baidu-sk').value = apiKeys.baiduSecretKey;
            
            // Switch to current provider tab or default to zhipu
            switchTab(currentProvider);
            document.getElementById('modal-settings').classList.remove('hidden');
        }

        function switchTab(provider) {
            // Update Tab Styles
            ['zhipu', 'qwen', 'baidu'].forEach(p => {
                const btn = document.getElementById(`tab-btn-${p}`);
                const content = document.getElementById(`content-${p}`);
                if(p === provider) {
                    btn.className = "tab-btn w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all text-blue-600 bg-white shadow-sm ring-1 ring-black/5";
                    content.classList.remove('hidden');
                } else {
                    btn.className = "tab-btn w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all text-gray-600 hover:bg-gray-100";
                    content.classList.add('hidden');
                }
            });
            // Don't update global state until save
        }

        function saveApiKeys() {
            apiKeys.zhipu = document.getElementById('input-zhipu').value;
            apiKeys.qwen = document.getElementById('input-qwen').value;
            apiKeys.baiduApiKey = document.getElementById('input-baidu-ak').value;
            apiKeys.baiduSecretKey = document.getElementById('input-baidu-sk').value;
            
            localStorage.setItem('ocr_api_keys', JSON.stringify(apiKeys));
            updateUIState();
            closeModal('modal-settings');
        }

        function requestClear() {
            document.getElementById('modal-clear').classList.remove('hidden');
        }

        function confirmClearData() {
            appData = [];
            renderTable();
            updateStats();
            closeModal('modal-clear');
        }

        function closeModal(id) {
            document.getElementById(id).classList.add('hidden');
        }

    </script>
</body>
</html>

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
3297813886 + 1 + 1 用心讨论,共获提升!

查看全部评分

推荐
苏紫方璇 发表于 2025-11-26 14:04
代码插入可以参考置顶帖
【公告】发帖代码插入以及添加链接教程(有福利)
https://www.52pojie.cn/thread-713042-1-1.html
(出处: 吾爱破解论坛)
3#
 楼主| zgywqm 发表于 2025-11-26 14:14 |楼主
苏紫方璇 发表于 2025-11-26 14:04
代码插入可以参考置顶帖
【公告】发帖代码插入以及添加链接教程(有福利)
https://www.52pojie.cn/threa ...

谢谢提醒已改
4#
Heky919 发表于 2025-11-26 14:38
大佬,能不能弄个安卓上能用的
5#
abcttud 发表于 2025-11-26 14:56
大佬有打包的吗
6#
Do_zh 发表于 2025-11-26 15:24
用接口信息全部都暴露了。还是自己本地搭建模型靠谱。
7#
kbno1 发表于 2025-11-26 15:37
超牛逼的大佬
8#
永恒帝 发表于 2025-11-26 15:47
看着很牛逼,怎样用,整理个软件直接打开用最好
9#
xing6xing 发表于 2025-11-26 16:06
手机可以用吗
10#
lmz320925 发表于 2025-11-26 16:09
请问有成品呀?
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-4-17 23:57

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表