From 77b72392a39a77ae85feff50726b985d423a1692 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 6 Jan 2026 15:40:38 +0800 Subject: [PATCH] first commit --- .gitignore | 6 + check_drivers.py | 51 ++++++ config.py | 58 +++++++ db_utils.py | 36 +++++ init_full_sync.py | 83 ++++++++++ requirements.txt | 1 + run_incremental_sync.py | 222 +++++++++++++++++++++++++ vba.txt | 350 ++++++++++++++++++++++++++++++++++++++++ vbareplace.py | 96 +++++++++++ 9 files changed, 903 insertions(+) create mode 100644 .gitignore create mode 100644 check_drivers.py create mode 100644 config.py create mode 100644 db_utils.py create mode 100644 init_full_sync.py create mode 100644 requirements.txt create mode 100644 run_incremental_sync.py create mode 100644 vba.txt create mode 100644 vbareplace.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a8af98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +.venv +build +dist +log +*.spec diff --git a/check_drivers.py b/check_drivers.py new file mode 100644 index 0000000..86d82af --- /dev/null +++ b/check_drivers.py @@ -0,0 +1,51 @@ +import pyodbc + +def check_drivers(): + print("正在扫描系统 ODBC 驱动...\n") + try: + drivers = pyodbc.drivers() + except Exception as e: + print(f"❌ 错误: 无法读取驱动列表。原因: {e}") + return + + if not drivers: + print("❌ 未找到任何 ODBC 驱动。") + return + + print(f"✅ 共发现 {len(drivers)} 个驱动:") + print("=" * 50) + for d in drivers: + print(f" • {d}") + print("=" * 50) + + print("\n[配置建议]") + + # --- 1. 寻找 SQL Server 驱动 --- + # 优先级:ODBC Driver 18 > 17 > 13 > Native Client > SQL Server (旧) + sql_candidates = [d for d in drivers if "SQL Server" in d] + best_sql = None + + # 简单的版本优先级判断 + if any("ODBC Driver 18" in d for d in sql_candidates): + best_sql = "ODBC Driver 18 for SQL Server" + elif any("ODBC Driver 17" in d for d in sql_candidates): + best_sql = "ODBC Driver 17 for SQL Server" + elif any("ODBC Driver 13" in d for d in sql_candidates): + best_sql = "ODBC Driver 13 for SQL Server" + elif sql_candidates: + best_sql = sql_candidates[0] # 随便取一个其他的 + + if best_sql: + print(f"👉 SQL Server 驱动 (填入 config.py): {{{best_sql}}}") + else: + print("⚠️ 未找到 SQL Server 驱动,请下载安装 'ODBC Driver 17 for SQL Server'") + + # --- 2. 寻找 Access 驱动 --- + acc_candidates = [d for d in drivers if "Access Driver" in d] + if acc_candidates: + print(f"👉 Access 驱动 (填入 config.py): {{{acc_candidates[0]}}}") + else: + print("⚠️ 未找到 Access 驱动,请下载安装 'Access Database Engine'") + +if __name__ == "__main__": + check_drivers() \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..7737738 --- /dev/null +++ b/config.py @@ -0,0 +1,58 @@ +# config.py + +# ================= 数据库连接配置 ================= +SQL_SERVER_CONN = { + 'driver': '{ODBC Driver 18 for SQL Server}', + 'server': '192.168.110.114', + 'database': 'CompanyDB', + 'uid': 'peng', + 'pwd': 'Cqbld123456.' +} + +# Access 驱动配置 (根据你的 Office 版本选择 mdb 或 accdb) +ACCESS_DRIVER = "{Microsoft Access Driver (*.mdb, *.accdb)}" + +# ================= 日志表配置 ================= +# 根据你提供的表结构配置列名 +LOG_TABLE_CONFIG = { + 'schema': 'dbo', + 'table_name': 'TableChangeLog', + # 下面定义字段名,方便代码引用,防止硬编码 + 'col_log_id': 'LogID', + 'col_table_name': 'TableName', + 'col_record_id': 'RecordID', + 'col_address': 'TableAddress', # 使用 TableAddress 作为文件路径来源 + 'col_synced': 'Synced' +} + +# ================= 同步映射配置 (核心重构) ================= +# 结构: { "Access文件绝对路径": { "Access表名": { SQL目标配置 } } } +# 这样即使不同文件有相同的表名,也能区分开 +SYNC_MAPPING = { + # 第一个 Access 文件 + r"\\192.168.110.114\生产进度表\2025年数据\生产合同数据.accdb": { + "26年压力表合同数据": { + "target_schema": "productionContractData", + "target_table": "26年压力表合同数据", # 映射到 A 表 + "pk_col": "ID" + }, + "26年温度计合同数据": { + "target_schema": "productionContractData", + "target_table": "26年温度计合同数据", + "pk_col": "ID" + } + }, + + # # 第二个 Access 文件 (可能有同名的 UserInfo 表) + # r"D:\Data\ProjectB\backup.mdb": { + # "UserInfo": { + # "target_schema": "dbo", + # "target_table": "ProjB_UserInfo", # 映射到 B 表 + # "pk_col": "ID" + # } + # } +} + +# ================= 运行参数 ================= +POLL_INTERVAL = 60 # 轮询间隔(秒) +BATCH_SIZE = 1000 # 批量处理大小 \ No newline at end of file diff --git a/db_utils.py b/db_utils.py new file mode 100644 index 0000000..c6d15eb --- /dev/null +++ b/db_utils.py @@ -0,0 +1,36 @@ +# db_utils.py +import pyodbc +import config + +def get_sql_conn(): + """获取 SQL Server 连接""" + c = config.SQL_SERVER_CONN + # 显式添加 TrustServerCertificate=yes 以兼容 ODBC Driver 18+ + conn_str = ( + f"DRIVER={c['driver']};SERVER={c['server']};" + f"DATABASE={c['database']};UID={c['uid']};PWD={c['pwd']};" + "Encrypt=yes;TrustServerCertificate=yes;" + ) + return pyodbc.connect(conn_str) + +def get_access_conn(file_path): + """获取 Access 连接""" + conn_str = f"DRIVER={config.ACCESS_DRIVER};DBQ={file_path};" + return pyodbc.connect(conn_str) + +def fmt_table(schema, table): + """格式化 SQL Server 表名 [schema].[table]""" + return f"[{schema}].[{table}]" + +def get_columns(cursor, table_name): + """获取 Access 表的列名""" + # Access 查询表名加 [] + cursor.execute(f"SELECT TOP 1 * FROM [{table_name}]") + return [column[0] for column in cursor.description] + +def generate_insert_sql(target_schema, target_table, columns): + """生成带架构的 INSERT 语句""" + full_table_name = fmt_table(target_schema, target_table) + col_str = ",".join([f"[{col}]" for col in columns]) + placeholders = ",".join(["?"] * len(columns)) + return f"INSERT INTO {full_table_name} ({col_str}) VALUES ({placeholders})" \ No newline at end of file diff --git a/init_full_sync.py b/init_full_sync.py new file mode 100644 index 0000000..490653b --- /dev/null +++ b/init_full_sync.py @@ -0,0 +1,83 @@ +# init_full_sync.py +import pyodbc +import config +import db_utils +import time +import os + +def run_full_sync(): + print("=== 开始全量初始化同步 ===") + print("注意:将基于 config.SYNC_MAPPING 中的文件路径进行处理") + + sql_conn = db_utils.get_sql_conn() + sql_cursor = sql_conn.cursor() + sql_cursor.fast_executemany = True + + # 第一层循环:遍历配置文件中的所有文件路径 + for acc_path, tables_map in config.SYNC_MAPPING.items(): + if not os.path.exists(acc_path): + print(f"⚠️ [跳过] 文件不存在: {acc_path}") + continue + + print(f"\n📂 正在处理文件: {acc_path}") + + try: + acc_conn = db_utils.get_access_conn(acc_path) + acc_cursor = acc_conn.cursor() + + # 第二层循环:遍历该文件下的所有表映射 + for acc_table, target_config in tables_map.items(): + target_schema = target_config['target_schema'] + target_table = target_config['target_table'] + full_target_name = db_utils.fmt_table(target_schema, target_table) + + print(f" 👉 同步表: [{acc_table}] -> {full_target_name}") + + try: + # 1. 检查 Access 表是否存在 + acc_cursor.execute(f"SELECT TOP 1 * FROM [{acc_table}]") + + # 2. 获取列结构 + columns = [col[0] for col in acc_cursor.description] + insert_sql = db_utils.generate_insert_sql(target_schema, target_table, columns) + + # 3. TRUNCATE 目标表 + sql_cursor.execute(f"TRUNCATE TABLE {full_target_name}") + + # 4. 开启 IDENTITY_INSERT (尝试) + try: sql_cursor.execute(f"SET IDENTITY_INSERT {full_target_name} ON") + except: pass + + # 5. 传输数据 + print(f" 正在读取并写入数据...") + acc_cursor.execute(f"SELECT * FROM [{acc_table}]") + + total_rows = 0 + while True: + rows = acc_cursor.fetchmany(config.BATCH_SIZE) + if not rows: break + sql_cursor.executemany(insert_sql, rows) + total_rows += len(rows) + print(f" 已写入 {total_rows} 行...", end='\r') + + # 6. 关闭 IDENTITY_INSERT + try: sql_cursor.execute(f"SET IDENTITY_INSERT {full_target_name} OFF") + except: pass + + sql_conn.commit() + print(f"\n ✅ 完成。") + + except Exception as tbl_err: + print(f" ❌ 表级错误: {tbl_err}") + sql_conn.rollback() + + acc_conn.close() + + except Exception as file_err: + print(f" ❌ 文件级错误: {file_err}") + + sql_conn.close() + print("\n=== 全量同步结束 ===") + +if __name__ == "__main__": + run_full_sync() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..57ad573 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyodbc>=5.0.0 \ No newline at end of file diff --git a/run_incremental_sync.py b/run_incremental_sync.py new file mode 100644 index 0000000..cc0bea0 --- /dev/null +++ b/run_incremental_sync.py @@ -0,0 +1,222 @@ +import time +import os +import sys +import pyodbc +import config +import db_utils +import logging +from logging.handlers import TimedRotatingFileHandler +from collections import defaultdict + +# ================= 日志系统配置 (新增) ================= + +def setup_logger(): + """ + 配置双向日志系统:控制台输出 + 按天轮转的文件日志 + """ + # 1. 确保日志目录存在 + log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'log') + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + # 2. 创建 Logger + logger = logging.getLogger("SyncService") + logger.setLevel(logging.INFO) + logger.handlers = [] # 清除旧句柄,防止重复打印 + + # 3. 文件处理器 (按天轮转) + # filename: 正在写入的日志名 + # when='midnight': 每天午夜轮转 + # interval=1: 间隔1天 + # backupCount=30: 保留最近30个文件 + log_file_path = os.path.join(log_dir, 'sync_service.log') + file_handler = TimedRotatingFileHandler( + log_file_path, when='midnight', interval=1, backupCount=30, encoding='utf-8' + ) + file_handler.suffix = "%Y-%m-%d" # 轮转后的文件名后缀 + # 文件日志格式:[时间] [级别] 消息 + file_fmt = logging.Formatter('%(asctime)s - [%(levelname)s] - %(message)s') + file_handler.setFormatter(file_fmt) + + # 4. 控制台处理器 (屏幕输出) + console_handler = logging.StreamHandler(sys.stdout) + # 控制台日志格式:[时间] 消息 (保持简洁) + console_fmt = logging.Formatter('%(asctime)s - %(message)s', datefmt='%H:%M:%S') + console_handler.setFormatter(console_fmt) + + # 5. 添加处理器 + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + return logger + +# 初始化全局 Logger +logger = setup_logger() + +# ================= 辅助函数 ================= + +def clean_access_path(raw_addr): + """清洗 TableAddress 字段""" + if not raw_addr: return "" + s = str(raw_addr).strip() + if s.upper().startswith(";DATABASE="): return s[10:] + if s.upper().startswith("LOCAL:"): return s[6:] + return s + +def get_norm_path(path): + """获取标准化路径""" + if not path: return "" + return os.path.normpath(path).lower() + +# ================= 主逻辑 ================= + +def process_logs(): + sql_conn = db_utils.get_sql_conn() + sql_cursor = sql_conn.cursor() + sql_cursor.fast_executemany = True + + cols = config.LOG_TABLE_CONFIG + + # --- 0. 预处理配置映射 --- + normalized_mapping = {} + for cfg_path, cfg_tables in config.SYNC_MAPPING.items(): + norm_key = get_norm_path(cfg_path) + normalized_mapping[norm_key] = { + 'original_path': cfg_path, + 'tables': cfg_tables + } + + try: + log_full_name = db_utils.fmt_table(cols['schema'], cols['table_name']) + + # --- 1. 读取日志 --- + query_log = f""" + SELECT TOP 1000 + {cols['col_log_id']}, + {cols['col_table_name']}, + {cols['col_record_id']}, + {cols['col_address']} + FROM {log_full_name} + WHERE {cols['col_synced']} = 0 + AND TableType = 'LINKED_ACCESS' + ORDER BY {cols['col_log_id']} ASC + """ + sql_cursor.execute(query_log) + logs = sql_cursor.fetchall() + + if not logs: + return False + + logger.info(f"📥 收到 {len(logs)} 条链接表变更日志...") + + # --- 2. 任务分组 --- + tasks = defaultdict(lambda: defaultdict(lambda: {'record_ids': set(), 'log_ids': []})) + + for row in logs: + log_id, acc_table, record_id, raw_address = row + + clean_path = clean_access_path(raw_address) + norm_lookup_key = get_norm_path(clean_path) + + # 校验配置 + if norm_lookup_key not in normalized_mapping: + # 记录一条警告日志,但不刷屏 + logger.warning(f"忽略未配置的文件路径: {clean_path}") + continue + + mapping_node = normalized_mapping[norm_lookup_key] + + if acc_table not in mapping_node['tables']: + logger.warning(f"忽略未配置的表: {acc_table} (在文件 {os.path.basename(clean_path)} 中)") + continue + + tasks[clean_path][acc_table]['record_ids'].add(record_id) + tasks[clean_path][acc_table]['log_ids'].append(log_id) + + # --- 3. 执行同步循环 --- + for file_path, tables_data in tasks.items(): + + if not os.path.exists(file_path): + logger.error(f"无法访问文件: {file_path}") + continue + + try: + acc_conn = db_utils.get_access_conn(file_path) + acc_cursor = acc_conn.cursor() + except Exception as conn_err: + logger.error(f"连接 Access 失败 [{file_path}]: {conn_err}") + continue + + norm_key = get_norm_path(file_path) + config_node = normalized_mapping[norm_key] + + for acc_table, data in tables_data.items(): + record_ids = list(data['record_ids']) + log_ids = data['log_ids'] + + target_conf = config_node['tables'][acc_table] + target_schema = target_conf['target_schema'] + target_table = target_conf['target_table'] + pk_col = target_conf['pk_col'] + target_full_name = db_utils.fmt_table(target_schema, target_table) + + try: + ids_placeholders = ','.join(['?'] * len(record_ids)) + + # A. Access 读取 + acc_sql = f"SELECT * FROM [{acc_table}] WHERE [{pk_col}] IN ({ids_placeholders})" + acc_cursor.execute(acc_sql, record_ids) + new_rows = acc_cursor.fetchall() + acc_cols = [col[0] for col in acc_cursor.description] + + # B. SQL Server 写入 + del_sql = f"DELETE FROM {target_full_name} WHERE [{pk_col}] IN ({ids_placeholders})" + sql_cursor.execute(del_sql, record_ids) + + if new_rows: + insert_sql = db_utils.generate_insert_sql(target_schema, target_table, acc_cols) + try: sql_cursor.execute(f"SET IDENTITY_INSERT {target_full_name} ON") + except: pass + sql_cursor.executemany(insert_sql, new_rows) + try: sql_cursor.execute(f"SET IDENTITY_INSERT {target_full_name} OFF") + except: pass + + # C. 更新日志状态 + log_placeholders = ','.join(['?'] * len(log_ids)) + update_log_sql = f""" + UPDATE {log_full_name} + SET {cols['col_synced']} = 1 + WHERE {cols['col_log_id']} IN ({log_placeholders}) + """ + sql_cursor.execute(update_log_sql, log_ids) + + sql_conn.commit() + logger.info(f"✅ 同步成功: {target_table} 更新 {len(record_ids)} 条 (来源: {os.path.basename(file_path)})") + + except Exception as tbl_err: + logger.error(f"处理表 {acc_table} 异常: {tbl_err}") + sql_conn.rollback() + + acc_conn.close() + + return True + + except Exception as e: + logger.critical(f"全局严重异常: {e}") + return False + finally: + try: sql_conn.close() + except: pass + +if __name__ == "__main__": + logger.info("=== 增量同步服务已启动 (日志模式: log/sync_service.log) ===") + while True: + try: + has_work = process_logs() + time.sleep(0.5 if has_work else config.POLL_INTERVAL) + except KeyboardInterrupt: + logger.info("收到停止指令,服务正在关闭...") + break + except Exception as e: + logger.critical(f"主循环崩溃: {e}") + time.sleep(5) # 防止死循环刷屏 \ No newline at end of file diff --git a/vba.txt b/vba.txt new file mode 100644 index 0000000..93703c2 --- /dev/null +++ b/vba.txt @@ -0,0 +1,350 @@ +Option Compare Database +Option Explicit + +' === 模块级变量 === +Private m_IsNewRecord As Boolean +Private m_DeletedIDs As New Collection + +' === 安全的字符串转义 === +Private Function SafeSQL(txt As String) As String + SafeSQL = Replace(txt, "'", "''") ' SQL标准转义 +End Function + +' === 获取窗体数据源表名 (修复版) === +Private Function GetFormTableName() As String + On Error Resume Next + Dim tableName As String + + ' 方法 1: 【最推荐】直接检查主键字段的来源表 + ' 既然你的查询里有 "缺件记录.ID",那么 ID 字段的 SourceTable 属性就是 "缺件记录" + ' 这比解析字符串准确得多,也不怕查询改名 + tableName = Me.Recordset.Fields("ID").SourceTable + + ' 如果方法1成功,直接返回 + If Len(tableName) > 0 Then + GetFormTableName = tableName + Exit Function + End If + + ' 方法 2: 检查窗体的 UniqueTable 属性 + ' Access 经常在属性表中设置这个值来指定多表查询中哪个表可更新 + tableName = Me.UniqueTable + If Len(tableName) > 0 Then + GetFormTableName = tableName + Exit Function + End If + + ' 方法 3: (保底方案) 解析 RecordSource 字符串 + ' 只有当上面都失败时,才尝试去解析 SQL + Dim recordSource As String + recordSource = Trim(Me.recordSource & "") + + ' 如果是保存的查询名(不含SELECT),需要先获取查询的SQL + If InStr(UCase(recordSource), "SELECT") = 0 Then + ' 检查是否为查询对象 + Dim qdf As DAO.QueryDef + Set qdf = CurrentDb.QueryDefs(recordSource) + If Not qdf Is Nothing Then + recordSource = qdf.sql ' 获取查询背后的真实 SQL + Else + ' 既不是SELECT又不是查询,那只能是直接的表名了 + GetFormTableName = recordSource + Exit Function + End If + Set qdf = Nothing + End If + + ' 解析 SQL: 提取 FROM 之后、JOIN 之前的第一个词 + Dim fromPos As Long + fromPos = InStr(1, UCase(recordSource), " FROM ", vbTextCompare) + + If fromPos > 0 Then + Dim tempStr As String + tempStr = Mid(recordSource, fromPos + 6) ' 跳过 " FROM " + tempStr = Trim(tempStr) + + ' 截断点:遇到 JOIN, WHERE, ORDER BY, GROUP BY 或逗号时停止 + Dim stopChars As Variant + Dim i As Integer, minPos As Long, p As Long + stopChars = Array(" LEFT ", " RIGHT ", " INNER ", " OUTER ", " JOIN ", " WHERE ", " ORDER ", " GROUP ", ",") + + minPos = Len(tempStr) + 1 + + For i = LBound(stopChars) To UBound(stopChars) + p = InStr(1, UCase(tempStr), stopChars(i), vbTextCompare) + If p > 0 And p < minPos Then minPos = p + Next i + + tableName = Trim(Left(tempStr, minPos - 1)) + + ' 清理括号 + tableName = Replace(tableName, "[", "") + tableName = Replace(tableName, "]", "") + End If + + ' 默认值 + If Len(tableName) = 0 Then tableName = "UnknownTable" + + GetFormTableName = tableName + On Error GoTo 0 +End Function + +' === 获取表地址(连接字符串或本地路径) === +Private Function GetTableAddress(tableName As String) As String + On Error Resume Next + Dim db As DAO.Database + Dim tdf As DAO.TableDef + Dim address As String + + Set db = CurrentDb + Set tdf = db.TableDefs(tableName) + + If Not tdf Is Nothing Then + If Len(Trim(tdf.Connect & "")) > 0 Then + ' 链接表:返回连接字符串 + address = tdf.Connect + Else + ' 本地表:返回当前数据库路径 + address = "LOCAL:" & CurrentDb.Name + End If + Else + address = "UNKNOWN" + End If + + ' 清理过长的连接字符串(可选) + If Len(address) > 500 Then + address = Left(address, 500) & "..." + End If + + GetTableAddress = address + + Set tdf = Nothing + Set db = Nothing + On Error GoTo 0 +End Function + +' === 获取表类型 === +Private Function GetTableType(tableName As String) As String + On Error Resume Next + Dim db As DAO.Database + Dim tdf As DAO.TableDef + Dim connectStr As String + Dim tableType As String + + Set db = CurrentDb + Set tdf = db.TableDefs(tableName) + + If Not tdf Is Nothing Then + connectStr = UCase(Trim(tdf.Connect & "")) + + If Len(connectStr) = 0 Then + ' 本地表 + tableType = "LOCAL" + ElseIf InStr(connectStr, "ODBC;") > 0 Then + ' ODBC链接表 + If InStr(connectStr, "SQL SERVER") > 0 Then + tableType = "LINKED_SQLSERVER" + ElseIf InStr(connectStr, "MYSQL") > 0 Then + tableType = "LINKED_MYSQL" + ElseIf InStr(connectStr, "ORACLE") > 0 Then + tableType = "LINKED_ORACLE" + Else + tableType = "LINKED_ODBC" + End If + ElseIf InStr(connectStr, "MS ACCESS") > 0 Or InStr(connectStr, ".ACCDB") > 0 Or InStr(connectStr, ".MDB") > 0 Then + ' Access链接表 + tableType = "LINKED_ACCESS" + ElseIf InStr(connectStr, "EXCEL") > 0 Or InStr(connectStr, ".XLS") > 0 Then + ' Excel链接表 + tableType = "LINKED_EXCEL" + ElseIf InStr(connectStr, "TEXT;") > 0 Or InStr(connectStr, ".TXT") > 0 Or InStr(connectStr, ".CSV") > 0 Then + ' 文本/CSV链接表 + tableType = "LINKED_TEXT" + Else + ' 其他类型链接表 + tableType = "LINKED_OTHER" + End If + Else + tableType = "UNKNOWN" + End If + + GetTableType = tableType + + Set tdf = Nothing + Set db = Nothing + On Error GoTo 0 +End Function + +' === 获取本机IP地址(带超时保护) === +Private Function GetLocalIPAddress() As String + On Error Resume Next + Dim objWMI As Object + Dim colItems As Object + Dim objItem As Object + Dim ip As String + Dim startTime As Double + + startTime = Timer + Set objWMI = GetObject("winmgmts:\\.\root\cimv2") + + ' 超时保护:WMI查询最多等待2秒 + If Timer - startTime > 2 Then + GetLocalIPAddress = "127.0.0.1" + Exit Function + End If + + Set colItems = objWMI.ExecQuery("SELECT IPAddress FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True") + + For Each objItem In colItems + If Not IsNull(objItem.ipAddress) Then + ip = objItem.ipAddress(0) + If InStr(ip, ":") = 0 Then + GetLocalIPAddress = ip + Exit Function + End If + End If + Next + + GetLocalIPAddress = "127.0.0.1" + On Error GoTo 0 +End Function + +' === 异步日志写入(不阻塞用户操作) === +Private Sub WriteLog(actionType As String, recordID As String) + Dim sql As String + Dim ipAddr As String + Dim remoteTable As String + Dim localTable As String + Dim tableAddr As String + Dim tableType As String + Dim computerName As String ' 新增:计算机名称变量 + ' --- 配置区域 --- + remoteTable = "dbo_TableChangeLog" + ' ---------------- + + ' 【关键1】立即返回,不等待耗时操作 + On Error Resume Next + + ' 【新增】动态获取窗体数据源表名 + localTable = GetFormTableName() + + ' 【新增】动态获取表地址 + tableAddr = GetTableAddress(localTable) + + ' 【新增】动态获取表类型 + tableType = GetTableType(localTable) + + ' 快速验证:表是否存在 + If IsNull(DLookup("Name", "MSysObjects", "Name='" & SafeSQL(remoteTable) & "'")) Then + Debug.Print "Log Warning: 表 " & remoteTable & " 不存在,跳过日志" + Exit Sub + End If + + ' 【关键2】防止空值和特殊字符 + If Len(Trim(recordID & "")) = 0 Then recordID = "NULL" + recordID = SafeSQL(recordID) + + ' 【关键3】快速获取IP(带缓存) + Static cachedIP As String + If cachedIP = "" Then cachedIP = GetLocalIPAddress() + ipAddr = cachedIP + + ' 获取当前电脑的【计算机名称】,替代原有的 Windows登录用户名 + computerName = Environ$("COMPUTERNAME") + + + ' 【关键4】参数化查询(如果支持)或安全拼接 + ' 注意:不写 ChangeDate 字段,让 SQL Server 的 DEFAULT GETDATE() 自动填充 + sql = "INSERT INTO " & remoteTable & " " & _ + "(TableName, TableAddress, TableType, RecordID, ActionType, IPAddress, UserName, FilePath) " & _ + "VALUES ('" & SafeSQL(localTable) & "', " & _ + "'" & SafeSQL(tableAddr) & "', " & _ + "'" & SafeSQL(tableType) & "', " & _ + "'" & recordID & "', " & _ + "'" & SafeSQL(actionType) & "', " & _ + "'" & SafeSQL(ipAddr) & "', " & _ + "'" & SafeSQL(computerName) & "', " & _ + "'" & SafeSQL(CurrentDb.Name) & "')" + + ' 【关键5】静默执行,绝不影响用户 + CurrentDb.Execute sql ' 移除 dbFailOnError! + + If Err.Number <> 0 Then + ' 仅记录到立即窗口,不弹窗 + Debug.Print Now & " - Log Failed: " & Err.Description & " | SQL: " & sql + ' 可选:写入本地备份表 + Call WriteLocalBackupLog(localTable, recordID, actionType) + End If + + On Error GoTo 0 +End Sub + +' === 本地备份日志(防止远程失败) === +Private Sub WriteLocalBackupLog(tblName As String, recID As String, actType As String) + On Error Resume Next + ' Access 本地表使用 Now() 函数 + CurrentDb.Execute "INSERT INTO LocalLogBackup (TableName, RecordID, ActionType, LogTime) " & _ + "VALUES ('" & SafeSQL(tblName) & "', '" & SafeSQL(recID) & "', '" & SafeSQL(actType) & "', Now())" + On Error GoTo 0 +End Sub + +' ============================== +' 窗体事件逻辑(增强版) +' ============================== + +' 1. 保存前状态判断 +Private Sub Form_BeforeUpdate(Cancel As Integer) + On Error Resume Next ' 【保护】防止日志逻辑影响保存 + m_IsNewRecord = Me.NewRecord + On Error GoTo 0 +End Sub + +' 2. 修改保存后 +Private Sub Form_AfterUpdate() + On Error Resume Next + If Not m_IsNewRecord Then + Call WriteLog("UPDATE", Nz(Me.ID.Value, "")) + End If + On Error GoTo 0 +End Sub + +' 3. 新增确认后 +Private Sub Form_AfterInsert() + On Error Resume Next + Call WriteLog("INSERT", Nz(Me.ID.Value, "")) + m_IsNewRecord = False + On Error GoTo 0 +End Sub + +' 4. 删除开始(收集ID) +Private Sub Form_Delete(Cancel As Integer) + On Error Resume Next + If Not IsNull(Me.ID.Value) Then + m_DeletedIDs.Add CStr(Me.ID.Value) + End If + On Error GoTo 0 +End Sub + +' 5. 删除确认后(批量写入) +Private Sub Form_AfterDelConfirm(Status As Integer) + On Error Resume Next + Dim vID As Variant + + If Status = acDeleteOK Then + For Each vID In m_DeletedIDs + Call WriteLog("DELETE", CStr(vID)) + Next + End If + + ' 清理集合 + Set m_DeletedIDs = Nothing + Set m_DeletedIDs = New Collection + On Error GoTo 0 +End Sub + +' === 窗体关闭时清理(可选) === +Private Sub Form_Unload(Cancel As Integer) + On Error Resume Next + Set m_DeletedIDs = Nothing + On Error GoTo 0 +End Sub diff --git a/vbareplace.py b/vbareplace.py new file mode 100644 index 0000000..db79582 --- /dev/null +++ b/vbareplace.py @@ -0,0 +1,96 @@ +import os +import sys +import win32com.client +import tkinter as tk +from tkinter import messagebox + +# 隐藏 tkinter 主窗口 +root = tk.Tk() +root.withdraw() + +def main(): + # 检查是否通过拖拽传入文件 + if len(sys.argv) != 2: + messagebox.showerror("错误", "请将一个 Access 数据库文件拖拽到此 EXE 上运行!") + return + + access_file = sys.argv[1] + + # 检查文件是否存在且是 Access 文件 + if not os.path.exists(access_file): + messagebox.showerror("错误", f"文件不存在:\n{access_file}") + return + + ext = os.path.splitext(access_file)[1].lower() + if ext not in ['.accdb', '.mdb']: + messagebox.showwarning("警告", "这不是一个 Access 数据库文件(.accdb 或 .mdb)\n将尝试继续,但可能失败。") + + # 获取脚本所在目录(EXE 同目录) + if getattr(sys, 'frozen', False): + script_dir = os.path.dirname(sys.executable) + else: + script_dir = os.path.dirname(os.path.abspath(__file)) + + vba_txt_file = os.path.join(script_dir, "vba.txt") + + if not os.path.exists(vba_txt_file): + messagebox.showerror("错误", f"未找到 vba.txt 文件!\n请确保 vba.txt 与 EXE 在同一目录。\n路径: {vba_txt_file}") + return + + # 读取新 VBA 代码 + try: + with open(vba_txt_file, "r", encoding="utf-8") as f: + new_code = f.read() + except Exception as e: + messagebox.showerror("错误", f"读取 vba.txt 失败:\n{e}") + return + + try: + # 启动 Access + access = win32com.client.Dispatch("Access.Application") + access.Visible = False # 后台运行,不显示窗口 + + # 打开数据库 + access.OpenCurrentDatabase(access_file) + + all_forms = access.CurrentProject.AllForms + form_count = all_forms.Count + + if form_count == 0: + messagebox.showinfo("完成", "数据库中没有窗体,无需替换。") + access.CloseCurrentDatabase() + access.Quit() + return + + replaced = 0 + for i in range(form_count): + form_name = all_forms.Item(i).Name + + try: + access.DoCmd.OpenForm(form_name, 1) # acDesign = 1 + form = access.Forms(form_name) + module = form.Module + + line_count = module.CountOfLines + if line_count > 0: + module.DeleteLines(1, line_count) + + if new_code.strip(): + module.InsertLines(1, new_code) + + access.DoCmd.Close(0, form_name, 1) # acForm=0, acSaveYes=1 + replaced += 1 + except Exception as e: + access.DoCmd.Close(0, form_name, 0) # 尝试关闭,避免卡住 + print(f"处理窗体 {form_name} 时出错: {e}") + + access.CloseCurrentDatabase() + access.Quit() + + messagebox.showinfo("成功", f"操作完成!\n\n已处理 {form_count} 个窗体,成功替换 {replaced} 个。\n\n文件已保存: {os.path.basename(access_file)}") + + except Exception as e: + messagebox.showerror("运行错误", f"操作失败:\n{e}\n\n请检查是否已启用 VBA 项目访问信任。") + +if __name__ == "__main__": + main() \ No newline at end of file