using System; using System.Collections.Generic; using System.Data; using System.Drawing; using System.IO; using System.Linq; using System.Windows.Forms; using Microsoft.Win32; using Zuby.ADGV; namespace MyApp { /// /// Form3 的交互逻辑(CAD 2018 插件报价窗口)。 /// 功能与主项目报价页面一致:项目表 + 图号/箱号/元件三级联动 + 公式重算 + 右键菜单 + 项目导出。 /// UI 布局定义见 Form3.ui.cs。数据库独立存放在 cadcj\sjk\ 目录。 /// public partial class Form3 { // ============================================================ // 数据库路径与表名常量(独立于主项目,存放在 cadcj\sjk\ 下) // ============================================================ private const string ProjectsDbPath = @"sjk\advancedDataGridView6Table.db"; private const string ProjectsTableName = "advancedDataGridView6Table"; private const string QuoteDbDir = @"sjk\bjb"; private const string TuhaoTableName = "advancedDataGridView9Table"; private const string ProjectInfoTableName = "项目信息"; private const string IndexTableName = "表索引"; private const string CadRegPath = @"SOFTWARE\Autodesk\AutoCAD\R22.0\ACAD-1001:804\Apps\CADCJ"; /// /// 插件所在目录(cadcj)。优先从注册表读取 CAD 自动加载的 DLL 路径(最可靠), /// 然后尝试 CodeBase / Location,最后兜底 BaseDirectory(可能是 CAD 目录,会报错)。 /// private static readonly string _baseDir = GetPluginBaseDir(); private static string GetPluginBaseDir() { // ★ 方案0(2026-09-10 主程序托管版):数据根固定为 cadbt 子目录(与独立 cadbt.exe 共用同一套 sjk) // 安装布局 {app}\cadbt\;开发机回退 主程序bin\Debug 上两级\cadbt\cadbt\bin\Debug try { var appDir = AppDomain.CurrentDomain.BaseDirectory; var cand1 = System.IO.Path.Combine(appDir, "cadbt"); if (System.IO.Directory.Exists(cand1)) { WriteDebugLog("[GetPluginBaseDir] cadbt子目录成功: " + cand1); return cand1; } var cand2 = System.IO.Path.GetFullPath(System.IO.Path.Combine(appDir, @"..\..\cadbt\cadbt\bin\Debug")); if (System.IO.Directory.Exists(cand2)) { WriteDebugLog("[GetPluginBaseDir] 开发机回退成功: " + cand2); return cand2; } } catch (Exception ex) { WriteDebugLog("[GetPluginBaseDir] 托管目录解析异常: " + ex.Message); } // ★ 方案2:CodeBase(不受 shadow copy 影响) try { var codeBase = System.Reflection.Assembly.GetExecutingAssembly().CodeBase; WriteDebugLog("[GetPluginBaseDir] CodeBase=" + codeBase); if (!string.IsNullOrEmpty(codeBase)) { var localPath = new Uri(codeBase).LocalPath; var dir = Path.GetDirectoryName(localPath); if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) { WriteDebugLog("[GetPluginBaseDir] CodeBase方案成功: " + dir); return dir; } } } catch (Exception ex) { WriteDebugLog("[GetPluginBaseDir] CodeBase方案异常: " + ex.Message); } // ★ 方案3:Location try { var loc = System.Reflection.Assembly.GetExecutingAssembly().Location; WriteDebugLog("[GetPluginBaseDir] Location=" + loc); if (!string.IsNullOrEmpty(loc)) { var dir = Path.GetDirectoryName(loc); if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) { WriteDebugLog("[GetPluginBaseDir] Location方案成功: " + dir); return dir; } } } catch (Exception ex) { WriteDebugLog("[GetPluginBaseDir] Location方案异常: " + ex.Message); } // ★ 方案4(最后兜底):BaseDirectory(CAD 目录,数据库会找不到) var fallback = AppDomain.CurrentDomain.BaseDirectory; WriteDebugLog("[GetPluginBaseDir] ⚠ 兜底BaseDirectory=" + fallback + " (数据库会找不到!)"); return fallback; } private static void WriteDebugLog(string msg) { try { var logDir = Path.Combine( Path.GetDirectoryName( new Uri(System.Reflection.Assembly.GetExecutingAssembly().CodeBase).LocalPath), "sjk"); if (!Directory.Exists(logDir)) logDir = Path.GetTempPath(); var logPath = Path.Combine(logDir, "form3_path_debug.log"); File.AppendAllText(logPath, "[" + DateTime.Now.ToString("HH:mm:ss") + "] " + msg + Environment.NewLine); } catch { } } // ============================================================ // 当前层级选中状态(联动核心) // ============================================================ private string _currentProjectId = ""; private string _currentTuhao = ""; private string _currentXianghao = ""; private bool _isReloading = false; private bool _isRecalculating = false; private DateTime _lastRecalcTime = DateTime.MinValue; // 价格显示格式化配置(每个项目独立,随项目信息表加载) private string _单台小数位文本 = "保留2位小数"; private string _单台取整方式 = "不取整"; private string _项目小数位文本 = "保留2位小数"; private string _项目取整方式 = "不取整"; // 当前各表的内存数据(用于关闭/切换时回写数据库) private DataTable _tuhaoTable; private DataTable _xianghaoTable; private DataTable _yuanjianTable; // 内存缓存:切换图号/箱号时不丢编辑,点保存才写库 private Dictionary _xianghaoCache = new Dictionary(); private Dictionary _yuanjianCache = new Dictionary(); // 右键菜单组件(运行时创建,不在设计器 XML 里) private ContextMenuStrip _menuTuhao; private ContextMenuStrip _menuXianghao; private ContextMenuStrip _menuYuanjian; private static readonly string[] ProjectInfoColumns = { "项目编号", "项目名称", "客户名称", "客户地址", "联系人", "联系电话", "项目日期", "备注", "成套厂名称", "成套厂地址", "成套厂联系人", "成套厂联系电话", "单台小数位", "单台取整方式", "项目小数位", "项目取整方式" }; /// 公式计算列集合(只包含程序算出来的价格列)。 /// ★ 口径(2026-09-29):毛利率(百分数值)入集合获得小数位格式化; /// 利润率(小数)不入集合,保持原样显示。 private static readonly HashSet s_公式列集合 = new HashSet(StringComparer.OrdinalIgnoreCase) { "表价", "采购单价", "采购价", "报出单价", "报出价", "单台成本", "成本价", "单台报出", "利润", "毛利率", "单价", "总价", "成本总价", "毛利", "成本总价", "报出总价", "利润" }; /// 构造函数:初始化 UI 并绑定事件。 public Form3() { InitializeComponent(); // ★ ADGV 右键多选保持(消息钩子版):右键点已选中的行不清空多选集合。 // 原做法是项目内子类 AdgvKeepMulti 覆写 WndProc——但"设计器+项目内自定义类型" // 在 SharpDevelop 设计时解析是死结,故改回 DLL 类型+NativeWindow 钩子(语义一致)。 _keepMultiHooks = new System.Collections.Generic.List(); HookKeepMulti(advancedDataGridView6); HookKeepMulti(advancedDataGridView9); HookKeepMulti(advancedDataGridView4); HookKeepMulti(advancedDataGridView5); // ★ 行头选择(2026-09-30):扒图窗口全部表格改 RowHeaderSelect // (点行头=选中整行,Ctrl/Shift+点行头=多选整行,Excel 风格) 成套报价软件.MainForm.ApplyRowHeaderSelectAll(this); // ★ 立即创建数据库(不依赖 Load 事件,UserControl 嵌入 PaletteSet 时 Load 可能延迟) try { Log("[Form3] _baseDir = " + _baseDir); Log("[Form3] ProjectsDbPath = " + GetDbFullPath(ProjectsDbPath)); EnsureProjectDbExists(GetDbFullPath(ProjectsDbPath)); Log("[Form3] 数据库创建/验证成功"); } catch (System.Exception ex) { Log("[Form3] 数据库创建失败: " + ex.Message); } // ★ 诊断:后续初始化步骤逐步打点(定位面板空白问题) try { Log("[Form3.init] 开始双缓冲设置"); EnableDoubleBuffered(this.advancedDataGridView6); EnableDoubleBuffered(this.advancedDataGridView9); EnableDoubleBuffered(this.advancedDataGridView4); EnableDoubleBuffered(this.advancedDataGridView5); Log("[Form3.init] 双缓冲完成"); ApplyReadOnlyColumns(this.advancedDataGridView6, new string[] { "项目编号", "项目名称", "客户名称", "客户地址", "联系人", "联系电话", "项目日期", "箱柜台数", "成本总价", "报出总价", "利润", "利润率", "备注" }); ApplyReadOnlyColumns(this.advancedDataGridView9, new string[] { "序号", "箱柜数量", "单价", "总价", "成本总价", "毛利", "毛利率" }); ApplyReadOnlyColumns(this.advancedDataGridView4, new string[] { "序号", "单台成本", "成本价", "单台报出", "报出价", "利润", "利润率", "毛利率" }); ApplyReadOnlyColumns(this.advancedDataGridView5, new string[] { "序号", "采购单价", "采购价", "报出单价", "报出价" }); Log("[Form3.init] 只读列完成"); // 工具栏按钮事件 this.toolStripButton1.Click += toolStripButton1_Click; this.toolStripButton5.Click += toolStripButton5_Click; this.toolStripButton6.Click += toolStripButton6_Click; this.toolStripButton2.Click += (s, e) => SendCadPaCommand("CADPATU"); this.toolStripButton3.Click += (s, e) => SendCadPaCommand("CADPAXH"); this.toolStripButton4.Click += (s, e) => SendCadPaCommand("CADPAYJ"); this.toolStripButton7.Click += (s, e) => ShowDeepSeekSettings(); // ★ 工具栏按钮已搬到主窗口功能区(CAD扒图页签),原工具栏隐藏(保留代码可随时恢复) this.toolStrip1.Visible = false; Log("[Form3.init] 工具栏事件完成"); this.button28.Click += button28_Click; this.advancedDataGridView6.CellDoubleClick += advancedDataGridView6_CellDoubleClick; this.advancedDataGridView9.SelectionChanged += advancedDataGridView9_SelectionChanged; this.advancedDataGridView4.SelectionChanged += advancedDataGridView4_SelectionChanged; this.advancedDataGridView5.SelectionChanged += advancedDataGridView5_SelectionChanged; this.advancedDataGridView9.CellClick += Grid_CellClick_Zoom; this.advancedDataGridView4.CellClick += Grid_CellClick_Zoom; this.advancedDataGridView5.CellClick += Grid_CellClick_Zoom; this.advancedDataGridView5.CellValueChanged += Yuanjian_CellValueChanged; this.advancedDataGridView4.CurrentCellDirtyStateChanged += Grid_DirtyStateChanged; this.advancedDataGridView4.CellValueChanged += Xianghao_CellValueChanged; this.advancedDataGridView9.CurrentCellDirtyStateChanged += Grid_DirtyStateChanged; this.advancedDataGridView9.CellValueChanged += Tuhao_CellValueChanged; this.advancedDataGridView5.CellEndEdit += Grid_CellEndEdit; this.advancedDataGridView4.CellEndEdit += Grid_CellEndEdit; this.advancedDataGridView9.CellEndEdit += Grid_CellEndEdit; this.advancedDataGridView5.DataError += (s, e) => { e.Cancel = true; }; this.advancedDataGridView4.DataError += (s, e) => { e.Cancel = true; }; this.advancedDataGridView9.DataError += (s, e) => { e.Cancel = true; }; this.advancedDataGridView6.DataError += (s, e) => { e.Cancel = true; }; this.advancedDataGridView9.MouseDown += Grid_MouseDown_KeepMultiSelect; this.advancedDataGridView4.MouseDown += Grid_MouseDown_KeepMultiSelect; this.advancedDataGridView5.MouseDown += Grid_MouseDown_KeepMultiSelect; this.advancedDataGridView9.KeyDown += Grid_KeyDown_Delete; this.advancedDataGridView4.KeyDown += Grid_KeyDown_Delete; this.advancedDataGridView5.KeyDown += Grid_KeyDown_Delete; Log("[Form3.init] 表格事件完成"); BuildContextMenus(); Log("[Form3.init] 右键菜单完成"); this.Disposed += Form3_Disposed; this.Load += (s, e) => { try { EnsureProjectDbExists(GetDbFullPath(ProjectsDbPath)); ReloadProjectsFromDatabase(); ApplyPriceFormat(this.advancedDataGridView6, _项目小数位文本); this.advancedDataGridView9.DataSource = null; this.advancedDataGridView4.DataSource = null; this.advancedDataGridView5.DataSource = null; Log("[Form3.Load] 初始化完成"); } catch (System.Exception ex) { Log("[Form3.Load] 初始化异常: " + ex.Message); } try { // 网页 CAD 桥:订阅实体删除事件(用户在网页里删框时联动删行) 成套报价软件.CadBridge.EntityErased -= Database_ObjectErased; 成套报价软件.CadBridge.EntityErased += Database_ObjectErased; // 网页 CAD 桥:订阅扒图结果事件(框选完成立即插入表格) 成套报价软件.CadBridge.PaPicked -= OnPaTextsPicked; 成套报价软件.CadBridge.PaPicked += OnPaTextsPicked; // 网页 CAD 桥:F1/F2 元件合并(焦点在网页时按键由网页转发) 成套报价软件.CadBridge.F1Requested -= OnF1FromBridge; 成套报价软件.CadBridge.F1Requested += OnF1FromBridge; 成套报价软件.CadBridge.F2Requested -= OnF2FromBridge; 成套报价软件.CadBridge.F2Requested += OnF2FromBridge; 成套报价软件.CadBridge.F3Requested -= OnF3FromBridge; 成套报价软件.CadBridge.F3Requested += OnF3FromBridge; } catch (System.Exception ex) { Log("[Form3.Load] 订阅CadBridge事件失败: " + ex.Message); } }; Log("[Form3.init] 构造函数全部完成 ✓"); } catch (System.Exception ex) { Log("[Form3.init] ⚠ 初始化异常: " + ex.ToString()); } } /// /// ★ 网页 CAD 中实体被删除时触发。如果删除的是 __CADCJ_PA_FRAME 图层上的扒图框, /// 通过 Handle 查找当前项目下的图号表/箱号表/元件表对应行,同步删除。 /// private void Database_ObjectErased(string handle) { try { if (string.IsNullOrWhiteSpace(handle)) return; // ★ 如果正在通过表格删除(_isReloading=true),跳过网页 CAD 事件触发的删除 // 避免表格删除和 CAD 事件删除互相干扰导致蒙版/数据不一致 if (_isReloading) return; Log("[ObjectErased] 触发! Handle=" + handle); this.BeginInvoke((Action)(() => { try { // 再次检查(异步执行时状态可能已变化) if (_isReloading) { Log("[ObjectErased] _isReloading=true,跳过"); return; } DeleteRowsByFrameHandle(handle); } catch (System.Exception ex) { Log("[ObjectErased.BeginInvoke] 异常: " + ex.Message); } })); } catch (System.Exception ex) { Log("[ObjectErased] 异常: " + ex.Message); } } /// /// 根据框Handle在当前项目的图号表/箱号表/元件表中删除对应数据行。 /// private void DeleteRowsByFrameHandle(string frameHandle) { if (string.IsNullOrWhiteSpace(frameHandle)) return; if (string.IsNullOrWhiteSpace(_currentProjectId)) { Log("[DeleteRowsByFrameHandle] _currentProjectId 为空,放弃"); return; } _isReloading = true; try { string dbPath = GetQuoteDbPath(_currentProjectId); if (!File.Exists(dbPath)) { Log("[DeleteRowsByFrameHandle] 数据库不存在: " + dbPath); return; } bool deleted = false; string deletedTuhao = null; string deletedXianghao = null; // ★ 收集级联删除的框Handle(图号→箱号→元件,删除数据时同步删蒙版) var cascadeFrames = new List(); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // 1) 图号表 using (var sel = new System.Data.SQLite.SQLiteCommand( "SELECT 图号 FROM " + TuhaoTableName + " WHERE 框Handle=@fh;", conn)) { sel.Parameters.AddWithValue("@fh", frameHandle); using (var reader = sel.ExecuteReader()) { if (reader.Read()) { deletedTuhao = reader.IsDBNull(0) ? "" : reader.GetString(0); Log("[DeleteRowsByFrameHandle] 命中图号表: " + deletedTuhao); } } } if (!string.IsNullOrWhiteSpace(deletedTuhao)) { // ★ 删除图号前,先收集该图号下所有箱号和元件的框Handle(级联删蒙版) CollectFrameHandlesForTuhao(_currentProjectId, deletedTuhao, cascadeFrames); DeleteTuhao(_currentProjectId, deletedTuhao); deleted = true; } if (!deleted) { // 2) 箱号表:通过表索引找到所有箱号表,逐个查 List allXhTables = new List(); using (var selIdx = new System.Data.SQLite.SQLiteCommand( "SELECT 实际表名, 图号 FROM 表索引 WHERE 类型='箱号';", conn)) using (var rIdx = selIdx.ExecuteReader()) { while (rIdx.Read()) { string realTbl = rIdx.IsDBNull(0) ? "" : rIdx.GetString(0); string th = rIdx.IsDBNull(1) ? "" : rIdx.GetString(1); if (!string.IsNullOrWhiteSpace(realTbl)) allXhTables.Add(realTbl + "|" + th); } } Log("[DeleteRowsByFrameHandle] 箱号表数量: " + allXhTables.Count); foreach (string info in allXhTables) { string[] parts = info.Split('|'); string realTbl = parts[0]; string th = parts.Length > 1 ? parts[1] : ""; using (var sel = new System.Data.SQLite.SQLiteCommand( "SELECT 箱号 FROM \"" + realTbl + "\" WHERE 框Handle=@fh;", conn)) { sel.Parameters.AddWithValue("@fh", frameHandle); using (var reader = sel.ExecuteReader()) { if (reader.Read()) { deletedTuhao = th; deletedXianghao = reader.IsDBNull(0) ? "" : reader.GetString(0); Log("[DeleteRowsByFrameHandle] 命中箱号表: 图号=" + th + " 箱号=" + deletedXianghao); break; } } } if (!string.IsNullOrWhiteSpace(deletedXianghao)) break; } if (!string.IsNullOrWhiteSpace(deletedXianghao)) { // ★ 删除箱号前,先收集该箱号下元件表的框Handle(级联删蒙版) CollectFrameHandlesForXianghao(_currentProjectId, deletedTuhao, deletedXianghao, cascadeFrames); DeleteXianghao(_currentProjectId, deletedTuhao, deletedXianghao); deleted = true; } } if (!deleted) { // 3) 元件表:通过表索引找到所有元件表,逐个查 List allYjTables = new List(); using (var selIdx = new System.Data.SQLite.SQLiteCommand( "SELECT 实际表名, 图号, 箱号 FROM 表索引 WHERE 类型='元件';", conn)) using (var rIdx = selIdx.ExecuteReader()) { while (rIdx.Read()) { string realTbl = rIdx.IsDBNull(0) ? "" : rIdx.GetString(0); string th = rIdx.IsDBNull(1) ? "" : rIdx.GetString(1); string xh = rIdx.IsDBNull(2) ? "" : rIdx.GetString(2); if (!string.IsNullOrWhiteSpace(realTbl)) allYjTables.Add(realTbl + "|" + th + "|" + xh); } } Log("[DeleteRowsByFrameHandle] 元件表数量: " + allYjTables.Count + " 查找Handle=" + frameHandle); foreach (string info in allYjTables) { string[] parts = info.Split('|'); string realTbl = parts[0]; string th = parts.Length > 1 ? parts[1] : ""; string xh = parts.Length > 2 ? parts[2] : ""; // ★ 先列出该表所有的框Handle,排查是否真的写进去了 using (var selAll = new System.Data.SQLite.SQLiteCommand( "SELECT 序号, 框Handle FROM \"" + realTbl + "\";", conn)) using (var rAll = selAll.ExecuteReader()) { while (rAll.Read()) { string s = rAll.IsDBNull(0) ? "" : rAll.GetString(0); string h = rAll.IsDBNull(1) ? "" : rAll.GetString(1); Log(" [元件表 " + realTbl + "] 序号=" + s + " 框Handle=[" + h + "]"); } } using (var sel = new System.Data.SQLite.SQLiteCommand( "SELECT COUNT(*) FROM \"" + realTbl + "\" WHERE 框Handle=@fh;", conn)) { sel.Parameters.AddWithValue("@fh", frameHandle); object cnt = sel.ExecuteScalar(); int matchCount = (cnt != null && cnt != DBNull.Value) ? Convert.ToInt32(cnt) : 0; if (matchCount > 0) { deletedTuhao = th; deletedXianghao = xh; Log("[DeleteRowsByFrameHandle] 命中元件表: 图号=" + th + " 箱号=" + xh + " 匹配行数=" + matchCount); // ★ 直接用框Handle 删除所有匹配行(解决重复行问题) using (var delCmd = new System.Data.SQLite.SQLiteCommand( "DELETE FROM \"" + realTbl + "\" WHERE 框Handle=@fh;", conn)) { delCmd.Parameters.AddWithValue("@fh", frameHandle); int affected = delCmd.ExecuteNonQuery(); Log("[DeleteRowsByFrameHandle] DELETE 完成,影响行数=" + affected); } deleted = true; break; } } } } } if (deleted) { Log("[DeleteRowsByFrameHandle] 删除成功: Handle=" + frameHandle + " cascadeFrames=" + cascadeFrames.Count); _xianghaoCache.Clear(); _yuanjianCache.Clear(); // 重新编号(直接操作数据库) if (!string.IsNullOrWhiteSpace(deletedTuhao) && !string.IsNullOrWhiteSpace(deletedXianghao)) { string yjTbl = GetYuanjianTableByIndex(_currentProjectId, deletedTuhao, deletedXianghao); if (!string.IsNullOrEmpty(yjTbl)) RenumberTable(_currentProjectId, "\"" + yjTbl + "\""); string xhTbl = GetXianghaoTableByIndex(_currentProjectId, deletedTuhao); if (!string.IsNullOrEmpty(xhTbl)) RenumberTable(_currentProjectId, "\"" + xhTbl + "\""); } else if (!string.IsNullOrWhiteSpace(deletedTuhao)) { string xhTbl = GetXianghaoTableByIndex(_currentProjectId, deletedTuhao); if (!string.IsNullOrEmpty(xhTbl)) RenumberTable(_currentProjectId, "\"" + xhTbl + "\""); } // ★ 直接在数据库层面重算(不经过内存表,不触发回写) try { RecalcAllFromDatabase(_currentProjectId); } catch { } // ★ 从数据库重新加载到 UI(覆盖内存表,确保 UI 与数据库一致) if (!string.IsNullOrWhiteSpace(deletedTuhao) && !string.IsNullOrWhiteSpace(deletedXianghao)) { if (_currentTuhao == deletedTuhao) { ReloadXianghaoByTuhao(_currentProjectId, deletedTuhao); if (_currentXianghao == deletedXianghao) ReloadYuanjianByXianghao(_currentProjectId, deletedTuhao, deletedXianghao); } } else if (!string.IsNullOrWhiteSpace(deletedTuhao)) { ReloadTuhaoByProject(_currentProjectId); if (_currentTuhao == deletedTuhao) ReloadXianghaoByTuhao(_currentProjectId, deletedTuhao); } // ★ 删除级联的框实体(图号删除时,其下箱号和元件的蒙版也要删除) Log("[DeleteRowsByFrameHandle] cascadeFrames 数量: " + cascadeFrames.Count + " 当前Handle=" + frameHandle); if (cascadeFrames.Count > 0) { // ★ 收集后一次性批量删除(排除当前正在删除的 frameHandle,已由 CAD 端删除) var toDel = new List(); foreach (string h in cascadeFrames) { if (h != frameHandle) toDel.Add(h); } if (toDel.Count > 0) { Log("[DeleteRowsByFrameHandle] 级联删除框Handle数: " + toDel.Count); DeleteFramesByHandles(toDel); } SaveCurrentDwg(); Log("[DeleteRowsByFrameHandle] 级联删除完成"); } } } finally { _isReloading = false; } } /// 重写 OnLoad:在所有 Load 事件订阅者之前确保项目库文件存在。 protected override void OnLoad(EventArgs e) { try { EnsureProjectDbExists(GetDbFullPath(ProjectsDbPath)); } catch { } base.OnLoad(e); } // ============================================================ // 通用辅助方法 // ============================================================ private void Log(string msg) { try { string logPath = Path.Combine(_baseDir, "diag.log"); File.AppendAllText(logPath, DateTime.Now.ToString("HH:mm:ss.fff") + " " + msg + "\r\n"); } catch { } } private string GetDbFullPath(string relativePath) { return Path.Combine(_baseDir, relativePath); } private string GetConnString(string dbPath) { // ★ busy_timeout=3000:数据库被 CAD 或其他进程短暂占用时等待重试而非卡死; // Pooling 关闭,避免连接池长期持有文件句柄 return "Data Source=" + dbPath + ";Version=3;busy_timeout=3000;"; } private string GetQuoteDbPath(string projectId) { string safeName = projectId; char[] invalidChars = Path.GetInvalidFileNameChars(); foreach (char c in invalidChars) safeName = safeName.Replace(c, '_'); return GetDbFullPath(QuoteDbDir + @"\项目" + safeName + ".db"); } private static decimal ParseDecimal(object v) { if (v == null || v == DBNull.Value) return 0m; decimal d; if (decimal.TryParse(v.ToString().Trim(), out d)) return d; return 0m; } private static DataGridViewColumn FindColumn(DataGridView dgv, string name) { if (dgv == null || dgv.Columns == null) return null; foreach (DataGridViewColumn c in dgv.Columns) { if (c == null) continue; if (c.DataPropertyName == name || c.HeaderText == name || c.Name == name) return c; } return null; } private static string GetCellString(DataGridViewRow row, string name) { if (row == null || row.DataGridView == null) return ""; DataGridViewColumn col = FindColumn(row.DataGridView, name); if (col == null) return ""; object v = row.Cells[col.Index].Value; return v == null ? "" : v.ToString().Trim(); } private static decimal GetCellDecimal(DataGridViewRow row, string name) { return ParseDecimal(GetCellString(row, name)); } private static string GetCellValueAsString(DataGridViewRow row, string colName) { if (row == null || row.Cells == null || string.IsNullOrEmpty(colName)) return ""; DataGridView grid = row.DataGridView; if (grid != null) { foreach (DataGridViewColumn col in grid.Columns) { if (col == null) continue; string key = !string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : (!string.IsNullOrEmpty(col.HeaderText) ? col.HeaderText : col.Name); if (key == colName) { object v = row.Cells[col.Index].Value; return v == null ? "" : v.ToString().Trim(); } } } return ""; } private static void SetCell(DataGridViewRow row, string colName, decimal value) { if (row == null || row.DataGridView == null) return; DataGridViewColumn col = FindColumn(row.DataGridView, colName); if (col == null) return; row.Cells[col.Index].Value = value == 0m ? 0d : (double)value; } private static DataGridViewRow GetFirstSelectedRow(DataGridView dgv) { if (dgv == null || dgv.Rows == null || dgv.Rows.Count == 0) return null; foreach (DataGridViewRow r in dgv.SelectedRows) { if (r == null || r.IsNewRow) continue; return r; } if (dgv.CurrentRow != null && !dgv.CurrentRow.IsNewRow) return dgv.CurrentRow; return null; } private static List GetAllSelectedRows(DataGridView dgv) { var result = new List(); if (dgv == null || dgv.Rows == null || dgv.Rows.Count == 0) return result; var seenRows = new HashSet(); if (dgv.SelectedRows != null) { foreach (DataGridViewRow r in dgv.SelectedRows) { if (r == null || r.IsNewRow) continue; if (seenRows.Add(r.Index)) result.Add(r); } } // ★ 总是通过 SelectedCells 补充收集(不再限制 result.Count == 0) // ADGV 多选时 SelectedRows 可能不完整,SelectedCells 能覆盖所有选中行 if (dgv.SelectedCells != null) { foreach (DataGridViewCell c in dgv.SelectedCells) { if (c == null || c.RowIndex < 0) continue; DataGridViewRow r = dgv.Rows[c.RowIndex]; if (r == null || r.IsNewRow) continue; if (seenRows.Add(r.Index)) result.Add(r); } } // 兜底:如果以上都没收集到,取当前行 if (result.Count == 0 && dgv.CurrentRow != null && !dgv.CurrentRow.IsNewRow) { result.Add(dgv.CurrentRow); } return result; } private DataGridViewRow GetFirstSelectedProjectRow() { DataGridView dgv = this.advancedDataGridView6; if (dgv == null || dgv.Rows == null || dgv.Rows.Count == 0) return null; foreach (DataGridViewRow r in dgv.SelectedRows) { if (r == null || r.IsNewRow) continue; return r; } if (dgv.CurrentRow != null && !dgv.CurrentRow.IsNewRow) return dgv.CurrentRow; return null; } private DataGridViewRow FindProjectRowById(string projectId) { if (string.IsNullOrWhiteSpace(projectId)) return null; DataGridView dgv = this.advancedDataGridView6; if (dgv == null || dgv.Rows == null) return null; foreach (DataGridViewRow r in dgv.Rows) { if (r == null || r.IsNewRow) continue; if (GetCellValueAsString(r, "项目编号") == projectId) return r; } return null; } private static void SetCurrentCell(DataGridView dgv, int rowIndex, int colIndex = 0) { if (dgv == null || dgv.Rows == null || rowIndex < 0 || rowIndex >= dgv.Rows.Count) return; try { if (dgv.Rows[rowIndex].IsNewRow) return; if (colIndex < 0 || colIndex >= dgv.Columns.Count) colIndex = 0; dgv.ClearSelection(); dgv.Rows[rowIndex].Selected = true; dgv.CurrentCell = dgv.Rows[rowIndex].Cells[colIndex]; } catch { } } private void SelectRowByValue(DataGridView dgv, string colName, string value) { if (dgv == null || dgv.Rows == null || string.IsNullOrEmpty(value)) return; try { _isReloading = true; foreach (DataGridViewRow r in dgv.Rows) { if (r == null || r.IsNewRow) continue; if (GetCellValueAsString(r, colName) == value) { dgv.ClearSelection(); r.Selected = true; dgv.CurrentCell = r.Cells[0]; return; } } } catch { } finally { _isReloading = false; } } private static int GetSelectedRowIndex(DataGridView dgv) { if (dgv == null) return -1; if (dgv.CurrentRow == null || dgv.CurrentRow.IsNewRow) return -1; return dgv.CurrentRow.Index; } private static string MakeSafeFileName(string s) { if (string.IsNullOrEmpty(s)) return "未命名"; string r = s; char[] invalid = Path.GetInvalidFileNameChars(); foreach (char c in invalid) r = r.Replace(c, '_'); return r; } private static string EscapeJsonString(string s) { if (s == null) return ""; return s.Replace("\\", "\\\\").Replace("\"", "\\\"") .Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t"); } private string ShowInputDialog(string title, string prompt, string defaultValue) { using (var dlg = new Form()) { dlg.Text = title; dlg.StartPosition = FormStartPosition.CenterParent; dlg.FormBorderStyle = FormBorderStyle.FixedDialog; dlg.MinimizeBox = false; dlg.MaximizeBox = false; dlg.Width = 340; dlg.Height = 170; var lbl = new Label(); lbl.Text = prompt; lbl.Left = 12; lbl.Top = 15; lbl.Width = 300; lbl.Height = 30; dlg.Controls.Add(lbl); var txt = new TextBox(); txt.Text = defaultValue; txt.Left = 12; txt.Top = 55; txt.Width = 300; dlg.Controls.Add(txt); var btnOk = new Button(); btnOk.Text = "确定"; btnOk.Left = 210; btnOk.Top = 92; btnOk.Width = 90; btnOk.DialogResult = DialogResult.OK; dlg.Controls.Add(btnOk); var btnCancel = new Button(); btnCancel.Text = "取消"; btnCancel.Left = 108; btnCancel.Top = 92; btnCancel.Width = 90; btnCancel.DialogResult = DialogResult.Cancel; dlg.Controls.Add(btnCancel); dlg.AcceptButton = btnOk; dlg.CancelButton = btnCancel; if (dlg.ShowDialog(this) == DialogResult.OK) return txt.Text; return ""; } } /// 开启 DataGridView 的双缓冲(消除闪烁)。 private static void EnableDoubleBuffered(DataGridView dgv) { if (dgv == null) return; try { typeof(DataGridView).InvokeMember("DoubleBuffered", System.Reflection.BindingFlags.SetProperty | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, null, dgv, new object[] { true }); } catch { } } /// 按列名设置只读(运行时按 DataPropertyName/HeaderText 查找)。 private static void ApplyReadOnlyColumns(DataGridView dgv, string[] colNames) { if (dgv == null || colNames == null) return; foreach (string name in colNames) { DataGridViewColumn col = FindColumn(dgv, name); if (col != null) col.ReadOnly = true; } } // ============================================================ // 价格显示格式化 // ============================================================ /// 把"保留2位小数"/"保留整数"等转成 .NET 格式字符串(F0/F2)。 private static string ToFormatString(string 小数位文本) { int n = 解析小数位(小数位文本); if (n < 0) n = 2; return "F" + n.ToString(); } private static int 解析小数位(string text) { if (string.IsNullOrWhiteSpace(text)) return 2; string t = text.Trim(); if (t.Contains("整数")) return 0; if (t.Contains("1位")) return 1; if (t.Contains("2位")) return 2; if (t.Contains("3位")) return 3; if (t.Contains("4位")) return 4; return 2; } private void ApplyPriceFormat(DataGridView dgv, string 小数位文本) { if (dgv == null || dgv.Columns == null) return; string fmt = ToFormatString(小数位文本); foreach (DataGridViewColumn col in dgv.Columns) { if (col == null) continue; string name = !string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : col.HeaderText; if (string.IsNullOrEmpty(name)) continue; if (s_公式列集合.Contains(name)) { // ★ 毛利率带 % 显示(2026-09-29):值已是百分数值,用字面量 % 格式 if (string.Equals(name, "毛利率", StringComparison.OrdinalIgnoreCase)) col.DefaultCellStyle.Format = "0.##\"%\""; else col.DefaultCellStyle.Format = fmt; } } } /// 老库兼容:把 DataTable 中公式列的类型从 string 转为 double。 private static DataTable ConvertFormulaColumnsToDouble(DataTable table) { if (table == null || table.Columns.Count == 0) return table; bool needsConvert = false; foreach (DataColumn dc in table.Columns) { if (s_公式列集合.Contains(dc.ColumnName) && dc.DataType == typeof(string)) { needsConvert = true; break; } } if (!needsConvert) return table; var newTable = new DataTable(); foreach (DataColumn dc in table.Columns) { Type t = s_公式列集合.Contains(dc.ColumnName) ? typeof(double) : dc.DataType; newTable.Columns.Add(dc.ColumnName, t); } foreach (DataRow row in table.Rows) { var newRow = newTable.NewRow(); foreach (DataColumn dc in table.Columns) { if (s_公式列集合.Contains(dc.ColumnName)) { object v = row[dc]; if (v == null || v == DBNull.Value) newRow[dc.ColumnName] = 0d; else if (v is double) newRow[dc.ColumnName] = v; else { double d; newRow[dc.ColumnName] = double.TryParse(v.ToString().Trim(), out d) ? d : 0d; } } else newRow[dc.ColumnName] = row[dc]; } newTable.Rows.Add(newRow); } newTable.TableName = table.TableName; return newTable; } // ============================================================ // 数据库结构维护 // ============================================================ private void EnsureProjectDbExists(string dbPath) { string dir = Path.GetDirectoryName(dbPath); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "CREATE TABLE IF NOT EXISTS " + ProjectsTableName + " (" + " 项目编号 TEXT, 项目名称 TEXT, 客户名称 TEXT, 客户地址 TEXT," + " 联系人 TEXT, 联系电话 TEXT, 项目日期 TEXT, 箱柜台数 REAL," + " 成本总价 REAL, 报出总价 REAL, 利润 REAL, 利润率 REAL, 备注 TEXT" + ");"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) cmd.ExecuteNonQuery(); EnsureColumns(conn, ProjectsTableName, new[] { "成套厂名称", "成套厂地址", "成套厂联系人", "成套厂联系电话" }); } } private static void EnsureColumns(System.Data.SQLite.SQLiteConnection conn, string tableName, string[] columns) { if (columns == null || columns.Length == 0) return; var existing = new HashSet(StringComparer.OrdinalIgnoreCase); using (var infoCmd = new System.Data.SQLite.SQLiteCommand("PRAGMA table_info(" + tableName + ");", conn)) using (var reader = infoCmd.ExecuteReader()) { while (reader.Read()) { object nameObj = reader["name"]; if (nameObj != null && nameObj != DBNull.Value) existing.Add(nameObj.ToString()); } } foreach (string col in columns) { if (!existing.Contains(col)) { string sql = "ALTER TABLE " + tableName + " ADD COLUMN " + col + " TEXT;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) cmd.ExecuteNonQuery(); } } } private static List GetProjectTableColumns(System.Data.SQLite.SQLiteConnection conn) { var cols = new List(); try { using (var infoCmd = new System.Data.SQLite.SQLiteCommand("PRAGMA table_info(" + ProjectsTableName + ");", conn)) using (var reader = infoCmd.ExecuteReader()) { while (reader.Read()) { object nameObj = reader["name"]; if (nameObj != null && nameObj != DBNull.Value) cols.Add(nameObj.ToString()); } } } catch { } return cols; } private void EnsureQuoteDbExists(string projectId) { if (string.IsNullOrWhiteSpace(projectId)) return; string dbPath = GetQuoteDbPath(projectId); string dir = Path.GetDirectoryName(dbPath); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sqlTuhao = "CREATE TABLE IF NOT EXISTS " + TuhaoTableName + " (" + " 序号 TEXT, 图号 TEXT, 图名 TEXT, 台数 TEXT, 箱柜数量 REAL," + " 单价 REAL, 总价 REAL, 成本总价 REAL, 毛利 REAL, 毛利率 REAL," + " 框Handle TEXT, 文本Handle TEXT" + ");"; string sqlIndex = "CREATE TABLE IF NOT EXISTS 表索引 (" + " 类型 TEXT, 图号 TEXT, 箱号 TEXT, 实际表名 TEXT" + ");"; string sqlProjectInfo = "CREATE TABLE IF NOT EXISTS " + ProjectInfoTableName + " (" + " 项目编号 TEXT, 项目名称 TEXT, 客户名称 TEXT, 客户地址 TEXT," + " 联系人 TEXT, 联系电话 TEXT, 项目日期 TEXT, 备注 TEXT," + " 成套厂名称 TEXT, 成套厂地址 TEXT, 成套厂联系人 TEXT, 成套厂联系电话 TEXT," + " 单台小数位 TEXT, 单台取整方式 TEXT, 项目小数位 TEXT, 项目取整方式 TEXT" + ");"; foreach (string sql in new[] { sqlTuhao, sqlIndex, sqlProjectInfo }) { using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) cmd.ExecuteNonQuery(); } EnsureColumns(conn, ProjectInfoTableName, new[] { "成套厂名称", "成套厂地址", "成套厂联系人", "成套厂联系电话", "单台小数位", "单台取整方式", "项目小数位", "项目取整方式" }); // ★ 确保旧库的图号表也有"框Handle"/"文本Handle"列 EnsureColumns(conn, TuhaoTableName, new[] { "框Handle", "文本Handle" }); // ★ 真隔离(2026-09-29):老库迁移——图号表补"内部ID"列并回填(与主窗口同款), // 所有箱号表(xh_N)也补"内部ID"列并回填显示名,保证旧数据按内部ID 也能定位 EnsureColumns(conn, TuhaoTableName, new[] { "内部ID" }); MigrateInternalIdColumn(conn, TuhaoTableName, "图号", "内部ID"); var xhTables = new System.Collections.Generic.List(); using (var selCmd = new System.Data.SQLite.SQLiteCommand( "SELECT DISTINCT 实际表名 FROM 表索引 WHERE 类型='箱号';", conn)) using (var selReader = selCmd.ExecuteReader()) { while (selReader.Read()) { string t = selReader.IsDBNull(0) ? "" : selReader.GetString(0); if (!string.IsNullOrWhiteSpace(t)) xhTables.Add(t); } } foreach (string xhTbl in xhTables) { string quotedXhTbl = "\"" + xhTbl.Replace("\"", "") + "\""; EnsureColumns(conn, quotedXhTbl, new[] { "内部ID" }); MigrateInternalIdColumn(conn, quotedXhTbl, "箱号", "内部ID"); } // ★ A1(2026-09-30):所有元件表补"内部ID"列并回填 GUID(三表统一定位体系) 成套报价软件.MainForm.MigrateYuanjianInternalId(conn); // ★ A5(2026-10-01):数据库健康自检(断链索引/孤儿表/序号重复,幂等) 成套报价软件.MainForm.数据库健康自检(conn); } } /// 老库迁移辅助:把"显示名列"的值复制到"内部ID列"(仅对内部ID为空的行)。 private static void MigrateInternalIdColumn(System.Data.SQLite.SQLiteConnection conn, string quotedTableName, string displayCol, string idCol) { try { string sql = "UPDATE " + quotedTableName + " SET " + idCol + " = " + displayCol + " WHERE " + idCol + " IS NULL OR " + idCol + " = '';"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.ExecuteNonQuery(); } } catch { } } // ---------- 箱号表(每个图号一张独立表,表名=图号原名,双引号括起)---------- private static string GetXianghaoTableName(string tuhao) { if (string.IsNullOrWhiteSpace(tuhao)) return "\"图号箱号表\""; return "\"" + tuhao.Replace("\"", "\"\"") + "\""; } private void EnsureXianghaoTableExists(string projectId, string tuhao) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao)) return; // 先查表索引,已有则跳过 string existing = GetXianghaoTableByIndex(projectId, tuhao); if (!string.IsNullOrEmpty(existing)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; // 用唯一ID做表名,避免图号名含特殊字符 string realTbl = GetNextTableId(projectId, "箱号"); try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "CREATE TABLE \"" + realTbl + "\" (" + " 序号 TEXT, 箱号 TEXT, 型号 TEXT, 规格 TEXT, 单位 TEXT, 数量 TEXT," + " 单台成本 REAL, 成本价 REAL, 单台报出 REAL, 报出价 REAL," + " 利润 REAL, 利润率 REAL, 毛利率 REAL, 箱柜类型 TEXT, 备注 TEXT, 图纸 TEXT," + " 框Handle TEXT, 文本Handle TEXT" + ");"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) cmd.ExecuteNonQuery(); } // ★ 注册到表索引,后续 GetXianghaoTableByIndex 才能查到 AddTableIndex(projectId, "箱号", tuhao, "", realTbl); } catch { } } private string GetXianghaoCount(string projectId, string tuhao) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao)) return "1"; string realTbl = GetXianghaoTableByIndex(projectId, tuhao); if (string.IsNullOrEmpty(realTbl)) return "1"; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return "1"; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "SELECT COUNT(*) FROM \"" + realTbl + "\";"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { int n = 0; object o = cmd.ExecuteScalar(); if (o != null && o != DBNull.Value) n = Convert.ToInt32(o); return (n + 1).ToString(); } } } catch { return "1"; } } // ---------- 元件计数(用于新建元件命名"新元件N")---------- private string GetYuanjianCount(string projectId, string tuhao, string xianghao) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(xianghao)) return "1"; // 查索引表拿该箱号元件表的真实表名 string realTbl = GetYuanjianTableByIndex(projectId, tuhao, xianghao); if (string.IsNullOrEmpty(realTbl)) return "1"; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return "1"; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "SELECT COUNT(*) FROM \"" + realTbl + "\";"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { int n = 0; object o = cmd.ExecuteScalar(); if (o != null && o != DBNull.Value) n = Convert.ToInt32(o); return (n + 1).ToString(); } } } catch { return "1"; } } // ---------- 元件表(每个箱号一张独立表)---------- private static string GetYuanjianTableName(string xianghao) { if (string.IsNullOrWhiteSpace(xianghao)) return "\"元件表\""; return "\"" + xianghao.Replace("\"", "\"\"") + "\""; } private void EnsureYuanjianTableExists(string projectId, string tuhao, string xianghao) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao) || string.IsNullOrWhiteSpace(xianghao)) return; // ★ 真隔离(2026-09-29):用箱号行的内部ID(GUID)查/登记元件表索引, // 不再用显示名,避免同图号下同名扒图箱号共用同一元件表导致串数据。 // 流程:InsertXianghao 已先写入箱号行(内部ID=新 GUID),这里查箱号表拿该 GUID。 string xhInternalId = ResolveXianghaoInternalId(projectId, tuhao, xianghao); if (string.IsNullOrEmpty(xhInternalId)) xhInternalId = xianghao; // 兜底:旧数据或异常时按显示名 // 先查表索引,已有则跳过(双键兼容:先按内部ID,未命中按显示名) string existing = GetYuanjianTableByIndex(projectId, tuhao, xhInternalId); if (!string.IsNullOrEmpty(existing)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; // 用唯一ID做表名 string realTbl = GetNextTableId(projectId, "元件"); try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "CREATE TABLE \"" + realTbl + "\" (" + " 序号 TEXT, 元件名称 TEXT, 规格 TEXT, 单位 TEXT, 数量 TEXT," + " 表价 REAL, 采购单价 REAL, 采购系数 TEXT, 采购价 REAL," + " 报出单价 REAL, 报出系数 TEXT, 报出价 REAL," + " 图纸规格 TEXT, 品牌 TEXT, 系列 TEXT, 类型 TEXT, 备注 TEXT," + " 框Handle TEXT, 文本Handle TEXT" + ");"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) cmd.ExecuteNonQuery(); } // ★ 注册到表索引(类型=元件,图号,箱号=内部ID) — 用 GUID 作箱号键 AddTableIndex(projectId, "元件", tuhao, xhInternalId, realTbl); } catch { } } // ---------- 表索引(用唯一ID做表名,索引表记录 图号/箱号 → 真实表名)---------- private string GetNextTableId(string projectId, string prefix) { if (string.IsNullOrWhiteSpace(projectId)) return prefix + "_1"; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return prefix + "_1"; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "SELECT 实际表名 FROM 表索引 WHERE 实际表名 LIKE @p;"; int maxN = 0; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@p", prefix + "_%"); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { string t = reader.IsDBNull(0) ? "" : reader.GetString(0); string numPart = t.StartsWith(prefix + "_") ? t.Substring(prefix.Length + 1) : ""; int n; if (int.TryParse(numPart, out n) && n > maxN) maxN = n; } } } return prefix + "_" + (maxN + 1).ToString(); } } catch { return prefix + "_1"; } } private void AddTableIndex(string projectId, string type, string tuhao, string xianghao, string realTableName) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(realTableName)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "INSERT INTO 表索引 (类型, 图号, 箱号, 实际表名) VALUES (@t, @th, @xh, @real);"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@t", type); cmd.Parameters.AddWithValue("@th", tuhao ?? ""); cmd.Parameters.AddWithValue("@xh", xianghao ?? ""); cmd.Parameters.AddWithValue("@real", realTableName); cmd.ExecuteNonQuery(); } } } catch { } } private void RemoveTableIndex(string projectId, string type, string tuhao, string xianghao) { if (string.IsNullOrWhiteSpace(projectId)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "DELETE FROM 表索引 WHERE 类型=@t AND 图号=@th AND 箱号=@xh;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@t", type); cmd.Parameters.AddWithValue("@th", tuhao ?? ""); cmd.Parameters.AddWithValue("@xh", xianghao ?? ""); cmd.ExecuteNonQuery(); } } } catch { } } private string GetXianghaoTableByIndex(string projectId, string tuhao) { return LookupTableByIndex(projectId, "箱号", tuhao, ""); } /// 查索引表:图号+箱号 → 该箱号的元件表真实表名(找不到返回空)。 /// ★ 真隔离(2026-09-29):双键兼容——先按 xianghao 直接查, /// 未命中则查箱号表把显示名转成 内部ID 再查(向后兼容老数据)。 /// private string GetYuanjianTableByIndex(string projectId, string tuhao, string xianghao) { if (string.IsNullOrWhiteSpace(xianghao)) return ""; string realTbl = LookupTableByIndex(projectId, "元件", tuhao, xianghao); if (!string.IsNullOrEmpty(realTbl)) return realTbl; string xhIdByName = ResolveXianghaoInternalId(projectId, tuhao, xianghao); if (!string.IsNullOrEmpty(xhIdByName) && xhIdByName != xianghao) return LookupTableByIndex(projectId, "元件", tuhao, xhIdByName); return ""; } /// ★ 真隔离(2026-09-29):按图号+箱号显示名查箱号表的"内部ID"列。 private string ResolveXianghaoInternalId(string projectId, string tuhao, string xianghaoDisplayName) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao) || string.IsNullOrWhiteSpace(xianghaoDisplayName)) return ""; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return ""; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); return ResolveXianghaoInternalId(tuhao, xianghaoDisplayName, conn); } } catch { return ""; } } /// ResolveXianghaoInternalId 复用连接版本。 private string ResolveXianghaoInternalId(string tuhao, string xianghaoDisplayName, System.Data.SQLite.SQLiteConnection conn) { if (conn == null || string.IsNullOrWhiteSpace(tuhao) || string.IsNullOrWhiteSpace(xianghaoDisplayName)) return ""; try { string xhTbl = LookupTableByIndex("箱号", tuhao, "", conn); if (string.IsNullOrEmpty(xhTbl)) return ""; EnsureColumns(conn, "\"" + xhTbl + "\"", new[] { "内部ID" }); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 内部ID FROM \"" + xhTbl.Replace("\"", "") + "\" WHERE 箱号=@xh LIMIT 1;", conn)) { cmd.Parameters.AddWithValue("@xh", xianghaoDisplayName); object o = cmd.ExecuteScalar(); return (o == null || o == DBNull.Value) ? "" : o.ToString(); } } catch { return ""; } } private string LookupTableByIndex(string projectId, string type, string tuhao, string xianghao) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao)) return ""; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return ""; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); return LookupTableByIndex(type, tuhao, xianghao, conn); } } catch { return ""; } } private string LookupTableByIndex(string type, string tuhao, string xianghao, System.Data.SQLite.SQLiteConnection conn) { if (conn == null || string.IsNullOrWhiteSpace(tuhao)) return ""; try { string sql = "SELECT 实际表名 FROM 表索引 WHERE 类型=@t AND 图号=@th AND 箱号=@xh LIMIT 1;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@t", type); cmd.Parameters.AddWithValue("@th", tuhao); cmd.Parameters.AddWithValue("@xh", xianghao ?? ""); object o = cmd.ExecuteScalar(); return (o == null || o == DBNull.Value) ? "" : o.ToString(); } } catch { return ""; } } private string GetXianghaoTableByIndex(string tuhao, System.Data.SQLite.SQLiteConnection conn) { return LookupTableByIndex("箱号", tuhao, "", conn); } /// 查索引表:图号+箱号 → 真实表名(复用连接版本)。 /// ★ 真隔离(2026-09-29):双键兼容——先按 xianghao 直接查, /// 未命中则查箱号表把显示名转成 内部ID 再查(向后兼容老数据)。 /// private string GetYuanjianTableByIndex(string tuhao, string xianghao, System.Data.SQLite.SQLiteConnection conn) { if (string.IsNullOrWhiteSpace(xianghao)) return ""; string realTbl = LookupTableByIndex("元件", tuhao, xianghao, conn); if (!string.IsNullOrEmpty(realTbl)) return realTbl; string xhIdByName = ResolveXianghaoInternalId(tuhao, xianghao, conn); if (!string.IsNullOrEmpty(xhIdByName) && xhIdByName != xianghao) return LookupTableByIndex("元件", tuhao, xhIdByName, conn); return ""; } private void CreateXianghaoTable(string projectId, string realTableName) { string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "CREATE TABLE \"" + realTableName + "\" (" + " 序号 TEXT, 箱号 TEXT, 箱名 TEXT, 型号 TEXT, 规格 TEXT, 单位 TEXT, 数量 TEXT," + " 单台成本 REAL, 成本价 REAL, 单台报出 REAL, 报出价 REAL," + " 利润 REAL, 利润率 REAL, 毛利率 REAL, 箱柜类型 TEXT, 备注 TEXT, 图纸 TEXT" + ");"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) cmd.ExecuteNonQuery(); } } catch { } } private void RenumberTable(string projectId, string quotedTableName) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(quotedTableName)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string selSql = "SELECT rowid FROM " + quotedTableName + " ORDER BY rowid;"; var rowids = new List(); using (var sel = new System.Data.SQLite.SQLiteCommand(selSql, conn)) using (var reader = sel.ExecuteReader()) { while (reader.Read()) rowids.Add(reader.GetInt64(0)); } int newSeq = 1; foreach (long rowid in rowids) { string updSql = "UPDATE " + quotedTableName + " SET 序号 = @seq WHERE rowid = @rid;"; using (var upd = new System.Data.SQLite.SQLiteCommand(updSql, conn)) { upd.Parameters.AddWithValue("@seq", newSeq.ToString()); upd.Parameters.AddWithValue("@rid", rowid); upd.ExecuteNonQuery(); } newSeq++; } } } catch { } } // ============================================================ // 报价库「项目信息」表读写 // ============================================================ private void InsertProjectInfoToQuoteDb(string projectId, Dictionary values) { if (string.IsNullOrWhiteSpace(projectId) || values == null) return; EnsureQuoteDbExists(projectId); string dbPath = GetQuoteDbPath(projectId); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string delSql = "DELETE FROM " + ProjectInfoTableName + " WHERE 项目编号 = @id;"; using (var del = new System.Data.SQLite.SQLiteCommand(delSql, conn)) { del.Parameters.AddWithValue("@id", projectId); del.ExecuteNonQuery(); } var cols = new List(); var paras = new List(); foreach (string c in ProjectInfoColumns) { if (values.ContainsKey(c)) { cols.Add(c); paras.Add("@" + c); } } if (cols.Count == 0) return; string sql = "INSERT INTO " + ProjectInfoTableName + " (" + string.Join(", ", cols.ToArray()) + ") VALUES (" + string.Join(", ", paras.ToArray()) + ");"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { foreach (string c in cols) cmd.Parameters.AddWithValue("@" + c, values[c] ?? ""); cmd.ExecuteNonQuery(); } } } private Dictionary GetProjectInfoFromQuoteDb(string projectId) { var info = new Dictionary(); if (string.IsNullOrWhiteSpace(projectId)) return info; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return info; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string chkSql = "SELECT name FROM sqlite_master WHERE type='table' AND name=@n;"; using (var chk = new System.Data.SQLite.SQLiteCommand(chkSql, conn)) { chk.Parameters.AddWithValue("@n", ProjectInfoTableName); if (chk.ExecuteScalar() == null) return info; } string sql = "SELECT * FROM " + ProjectInfoTableName + " WHERE 项目编号 = @id LIMIT 1;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@id", projectId); using (var reader = cmd.ExecuteReader()) { if (reader.Read()) { var existCols = new HashSet(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < reader.FieldCount; i++) existCols.Add(reader.GetName(i)); foreach (string c in ProjectInfoColumns) { if (!existCols.Contains(c)) continue; object v = reader[c]; info[c] = (v == null || v == DBNull.Value) ? "" : v.ToString(); } } } } } } catch { } return info; } private void LoadPriceFormatConfig(string projectId) { _单台小数位文本 = "保留2位小数"; _单台取整方式 = "不取整"; _项目小数位文本 = "保留2位小数"; _项目取整方式 = "不取整"; try { var info = GetProjectInfoFromQuoteDb(projectId); if (info == null || info.Count == 0) return; string v; if (info.TryGetValue("单台小数位", out v) && !string.IsNullOrWhiteSpace(v)) _单台小数位文本 = v; if (info.TryGetValue("单台取整方式", out v) && !string.IsNullOrWhiteSpace(v)) _单台取整方式 = v; if (info.TryGetValue("项目小数位", out v) && !string.IsNullOrWhiteSpace(v)) _项目小数位文本 = v; if (info.TryGetValue("项目取整方式", out v) && !string.IsNullOrWhiteSpace(v)) _项目取整方式 = v; } catch { } } // ============================================================ // 项目库 CRUD // ============================================================ private int InsertProjectToDatabase(Dictionary values) { if (values == null || values.Count == 0) return 0; string dbPath = GetDbFullPath(ProjectsDbPath); if (!File.Exists(dbPath)) { EnsureProjectDbExists(dbPath); if (!File.Exists(dbPath)) throw new FileNotFoundException("无法创建项目数据库文件: " + dbPath, dbPath); } int affected; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); var allowed = new HashSet(GetProjectTableColumns(conn), StringComparer.OrdinalIgnoreCase); var colNames = new List(); var paramNames = new List(); foreach (string col in values.Keys) { if (allowed.Contains(col)) { colNames.Add(col); paramNames.Add("@" + col); } } if (colNames.Count == 0) { affected = 0; } else { string sql = "INSERT INTO " + ProjectsTableName + " (" + string.Join(", ", colNames.ToArray()) + ") VALUES (" + string.Join(", ", paramNames.ToArray()) + ");"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { foreach (string col in colNames) cmd.Parameters.AddWithValue("@" + col, values[col]); affected = cmd.ExecuteNonQuery(); } } } string projectId; values.TryGetValue("项目编号", out projectId); if (!string.IsNullOrWhiteSpace(projectId)) InsertProjectInfoToQuoteDb(projectId, values); return affected; } private int DeleteProjectFromDatabase(string projectId) { if (string.IsNullOrWhiteSpace(projectId)) return 0; string dbPath = GetDbFullPath(ProjectsDbPath); if (!File.Exists(dbPath)) throw new FileNotFoundException("找不到项目数据库文件: " + dbPath, dbPath); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "DELETE FROM " + ProjectsTableName + " WHERE 项目编号 = @id;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@id", projectId); return cmd.ExecuteNonQuery(); } } } private void CascadeDeleteByProject(string projectId) { if (string.IsNullOrWhiteSpace(projectId)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; // ★ 必须删掉(方案库删除的已验证做法):主窗口的池化连接(Pooling=True) // 会持有该文件句柄——ClearAllPools(全进程)后重试 3 次,仍失败抛异常, // 绝不静默吞掉(残留文件会被同编号新建项目"复活"旧数据)。 Exception lastErr = null; for (int attempt = 1; attempt <= 3; attempt++) { try { System.Data.SQLite.SQLiteConnection.ClearAllPools(); GC.Collect(); GC.WaitForPendingFinalizers(); System.Threading.Thread.Sleep(50 * attempt); File.Delete(dbPath); foreach (string ext in new[] { "-wal", "-shm" }) { string p = dbPath + ext; if (File.Exists(p)) { try { File.Delete(p); } catch { } } } return; } catch (Exception ex) { lastErr = ex; } } throw new Exception("项目数据库文件删除失败(可能被其他程序占用,请关闭后重试):\n" + dbPath + "\n" + lastErr.Message); } private List> ReadAllProjectsFromDatabase() { var list = new List>(); try { string dbPath = GetDbFullPath(ProjectsDbPath); if (!File.Exists(dbPath)) return list; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT * FROM " + ProjectsTableName + " ORDER BY rowid;", conn)) using (var rd = cmd.ExecuteReader()) { while (rd.Read()) { var dict = new Dictionary(StringComparer.Ordinal); for (int i = 0; i < rd.FieldCount; i++) { object v = rd.GetValue(i); if (v == null || v == DBNull.Value) v = ""; dict[rd.GetName(i)] = v; } list.Add(dict); } } } } catch { } return list; } private Dictionary ReadProjectMetaData(string pid) { var dict = new Dictionary(StringComparer.Ordinal); try { string dbPath = GetDbFullPath(ProjectsDbPath); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT * FROM " + ProjectsTableName + " WHERE 项目编号 = @p LIMIT 1;", conn)) { cmd.Parameters.AddWithValue("@p", pid); using (var rd = cmd.ExecuteReader()) { if (!rd.Read()) return null; for (int i = 0; i < rd.FieldCount; i++) { object v = rd.GetValue(i); if (v == null || v == DBNull.Value) v = ""; dict[rd.GetName(i)] = v; } } } } } catch { return null; } return dict; } // ============================================================ // 图号/箱号/元件 CRUD // ============================================================ private string GetNextSeqNumber(string tableName, string nameCol, string projectId, string tuhao = null) { if (string.IsNullOrWhiteSpace(projectId)) return "1"; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return "1"; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql; if (tuhao == null) { sql = "SELECT COUNT(*) FROM " + tableName + ";"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { int n = 0; object o = cmd.ExecuteScalar(); if (o != null && o != DBNull.Value) n = Convert.ToInt32(o); return (n + 1).ToString(); } } else { sql = "SELECT COUNT(*) FROM " + tableName + " WHERE 图号 = @th;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@th", tuhao); int n = 0; object o = cmd.ExecuteScalar(); if (o != null && o != DBNull.Value) n = Convert.ToInt32(o); return (n + 1).ToString(); } } } } catch { return "1"; } } // ============================================================ // 扒图号/扒箱号/扒元件:CAD 文本选择交互 // ============================================================ // ---------- 主窗口功能区(CAD扒图页签)按钮的调用入口(转发到原工具栏逻辑) ---------- public void 功能区_导出数据() { toolStripButton1_Click(null, null); } public void 功能区_新建项目() { toolStripButton5_Click(null, null); } public void 功能区_删除项目() { toolStripButton6_Click(null, null); } public void 功能区_扒图号() { SendCadPaCommand("CADPATU"); } public void 功能区_扒箱号() { SendCadPaCommand("CADPAXH"); } public void 功能区_扒元件() { SendCadPaCommand("CADPAYJ"); } public void 功能区_设置() { ShowDeepSeekSettings(); } /// /// 向 CAD 发送扒数据命令,触发框选交互。 /// ★ 关键:先发 ESC 取消当前可能正在进行的扒图循环,再发新命令 /// 这样用户从"扒图号"循环里点"扒箱号"也能立即切换 /// private void SendCadPaCommand(string cmd) { try { if (!成套报价软件.CadBridge.IsViewerReady) { MessageBox.Show(this, "网页 CAD 尚未就绪,请先在文档区打开图纸。", "扒数据", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // 转发到网页 CAD 桥(patu/paxh/payj/pacl 命令) 成套报价软件.CadBridge.SendPaCommand(cmd); } catch (Exception ex) { MessageBox.Show(this, "发送扒图命令失败:\n" + ex.Message, "扒数据", MessageBoxButtons.OK, MessageBoxIcon.Error); } } /// /// CAD 选中文本后的回调:把选中的文本批量插入到对应表格。 /// 由 CadPlugin.ExecutePaCommand 在 UI 线程调用。 /// public void OnPaTextsPicked(PaMode mode, List results) { if (results == null || results.Count == 0) return; if (string.IsNullOrWhiteSpace(_currentProjectId)) { MessageBox.Show(this, "请先在项目表中双击选择一个项目,再扒数据。", "扒数据", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } switch (mode) { case PaMode.Tuhao: PaTuhaoInsert(results); break; case PaMode.Xianghao: PaXianghaoInsert(results); break; case PaMode.Yuanjian: PaYuanjianInsert(results); break; } } /// 扒图号:把选中文本作为图号批量插入到当前项目,同时保存框Handle。 /// ★ 重复扒图:如果图号已存在,删除旧框和数据库行(不新增)。 private void PaTuhaoInsert(List results) { string lastInsertedTuhao = null; // ★ 记录最后插入的图号,用于自动选中联动 try { _isReloading = true; // 先 AcceptChanges,避免 SaveAllQuoteTablesIfDirty 把旧内存表回写 if (_tuhaoTable != null) _tuhaoTable.AcceptChanges(); if (_xianghaoTable != null) _xianghaoTable.AcceptChanges(); if (_yuanjianTable != null) _yuanjianTable.AcceptChanges(); // ★ 新增框架Handle列表: PaTextSelector刚画的框,如果是重复,需要删除这些新框 var newFramesToErase = new List(); int deletedCount = 0; int inserted = 0; foreach (var r in results) { if (string.IsNullOrWhiteSpace(r.Text)) continue; string clean = r.Text.Trim(); // ★ 按"同一个文本实体"判断重复:查该文本实体Handle是否已入库。 // 不同位置的相同内容文本 Handle 不同 → 不算重复,正常插入。 string oldFrame = GetTuhaoFrameHandleByTextHandle(_currentProjectId, clean, r.TextHandle); if (!string.IsNullOrEmpty(oldFrame)) { // ★ 重复(同一个文本再次选中):删除旧框+数据库行+新框 DeleteTuhao(_currentProjectId, clean); DeleteFrameByHandle(oldFrame); if (!string.IsNullOrWhiteSpace(r.FrameHandle)) newFramesToErase.Add(r.FrameHandle); deletedCount++; } else { InsertTuhao(_currentProjectId, clean, r.FrameHandle, r.TextHandle); lastInsertedTuhao = clean; // ★ 记录最后插入的图号 inserted++; } } // ★ 删除重复图号对应的新框(PaTextSelector已经画出来了,但不需要保留)——批量一次删 DeleteFramesByHandles(newFramesToErase); _xianghaoCache.Clear(); _yuanjianCache.Clear(); // 直接在数据库层面重算(不经过内存回写) try { RecalcAllFromDatabase(_currentProjectId); } catch { } ReloadTuhaoByProject(_currentProjectId); // 保存 try { if (this.advancedDataGridView9 != null) this.advancedDataGridView9.EndEdit(); if (_tuhaoTable != null) { SaveTableToDatabase(_tuhaoTable, _currentProjectId, TuhaoTableName, null, null, true); _tuhaoTable.AcceptChanges(); } } catch { } SaveCurrentDwg(); if (deletedCount > 0 && inserted == 0) { Log("[PaTuhaoInsert] 删除了 " + deletedCount + " 条重复图号"); } } catch (Exception ex) { MessageBox.Show(this, "扒图号失败:\n" + ex.Message, "扒图号", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _isReloading = false; // ★ 扒完图号后自动选中刚插入的图号行,联动刷新箱号表 if (!string.IsNullOrWhiteSpace(lastInsertedTuhao)) { _currentTuhao = lastInsertedTuhao; SelectRowByValue(this.advancedDataGridView9, "图号", lastInsertedTuhao); // 手动联动刷新箱号表(SelectRowByValue 在 _isReloading 下不触发 SelectionChanged) ReloadXianghaoByTuhao(_currentProjectId, lastInsertedTuhao); } } } /// 扒箱号:把选中文本作为箱号批量插入到当前图号,同时保存框Handle。 /// ★ 重复扒图:如果箱号已存在,删除旧框和数据库行(不新增)。 private void PaXianghaoInsert(List results) { string lastInsertedXianghao = null; // ★ 记录最后插入的箱号,用于自动选中联动 if (string.IsNullOrWhiteSpace(_currentTuhao)) { MessageBox.Show(this, "请先在图号表中选择一个图号,再扒箱号。", "扒箱号", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } try { _isReloading = true; // 先 AcceptChanges,避免 SaveAllQuoteTablesIfDirty 把旧内存表回写 if (_tuhaoTable != null) _tuhaoTable.AcceptChanges(); if (_xianghaoTable != null) _xianghaoTable.AcceptChanges(); if (_yuanjianTable != null) _yuanjianTable.AcceptChanges(); var newFramesToErase = new List(); int deletedCount = 0; int inserted = 0; foreach (var r in results) { if (string.IsNullOrWhiteSpace(r.Text)) continue; string clean = r.Text.Trim(); // ★ 按"同一个文本实体"判断重复(不同位置的相同内容文本不算重复) string oldFrame = GetXianghaoFrameHandleByTextHandle(_currentProjectId, _currentTuhao, clean, r.TextHandle); if (!string.IsNullOrEmpty(oldFrame)) { // ★ 重复(同一个文本再次选中):删除旧框+数据库行+新框 DeleteXianghao(_currentProjectId, _currentTuhao, clean); DeleteFrameByHandle(oldFrame); if (!string.IsNullOrWhiteSpace(r.FrameHandle)) newFramesToErase.Add(r.FrameHandle); deletedCount++; } else { // ★ 图纸列=当前打开的图纸名(网页 CAD 随批上报,去扩展名) InsertXianghao(_currentProjectId, _currentTuhao, clean, r.FrameHandle, r.TextHandle, 成套报价软件.CadBridge.CurrentDrawingName); lastInsertedXianghao = clean; // ★ 记录最后插入的箱号 inserted++; } } DeleteFramesByHandles(newFramesToErase); _xianghaoCache.Clear(); _yuanjianCache.Clear(); try { RecalcAllFromDatabase(_currentProjectId); } catch { } ReloadXianghaoByTuhao(_currentProjectId, _currentTuhao); try { if (this.advancedDataGridView4 != null) this.advancedDataGridView4.EndEdit(); if (_xianghaoTable != null && !string.IsNullOrWhiteSpace(_xianghaoTable.TableName)) { SaveTableToDatabase(_xianghaoTable, _currentProjectId, "\"" + _xianghaoTable.TableName + "\"", null, null, true); _xianghaoTable.AcceptChanges(); } } catch { } SaveCurrentDwg(); if (deletedCount > 0 && inserted == 0) { Log("[PaXianghaoInsert] 删除了 " + deletedCount + " 条重复箱号"); } } catch (Exception ex) { MessageBox.Show(this, "扒箱号失败:\n" + ex.Message, "扒箱号", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _isReloading = false; // ★ 扒完箱号后自动选中刚插入的箱号行,联动刷新元件表 if (!string.IsNullOrWhiteSpace(lastInsertedXianghao)) { _currentXianghao = lastInsertedXianghao; SelectRowByValue(this.advancedDataGridView4, "箱号", lastInsertedXianghao); // 手动联动刷新元件表(SelectRowByValue 在 _isReloading 下不触发 SelectionChanged) ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, lastInsertedXianghao); } } } /// 扒元件:把选中文本作为【规格】批量插入到当前箱号(写到规格列),同时保存框Handle。 /// ★ 关键:在单个事务里按 results 顺序依次插入,保证序号连续。 /// ★ 重复扒图:如果规格已存在,删除旧框+数据库行(不新增)。 private void PaYuanjianInsert(List results) { if (string.IsNullOrWhiteSpace(_currentXianghao)) { MessageBox.Show(this, "请先在箱号表中选择一个箱号,再扒元件。", "扒元件", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } try { _isReloading = true; // ★ 关键:先 AcceptChanges 清掉内存表的改动标记,避免 SaveAllQuoteTablesIfDirty // 用旧的内存数据覆盖数据库 if (_yuanjianTable != null) _yuanjianTable.AcceptChanges(); if (_xianghaoTable != null) _xianghaoTable.AcceptChanges(); if (_tuhaoTable != null) _tuhaoTable.AcceptChanges(); EnsureQuoteDbExists(_currentProjectId); string dbPath = GetQuoteDbPath(_currentProjectId); EnsureYuanjianTableExists(_currentProjectId, _currentTuhao, _currentXianghao); string tblName = GetYuanjianTableByIndex(_currentProjectId, _currentTuhao, _currentXianghao); if (string.IsNullOrEmpty(tblName)) { MessageBox.Show("元件表创建失败(表索引未注册),图号=" + _currentTuhao + " 箱号=" + _currentXianghao); return; } // ★ 确保旧元件表也有"框Handle"/"文本Handle"/"图纸规格"列 using (var connEns = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { connEns.Open(); EnsureColumns(connEns, "\"" + tblName + "\"", new[] { "框Handle", "文本Handle", "图纸规格" }); } // 过滤掉空文本,保留原始顺序(同时保留框Handle+文本Handle) var cleanResults = new List(); foreach (var r in results) { if (!string.IsNullOrWhiteSpace(r.Text)) { cleanResults.Add(new PaTextResult { Text = r.Text.Trim(), FrameHandle = r.FrameHandle ?? "", TextHandle = r.TextHandle ?? "" }); } } if (cleanResults.Count == 0) return; // ★ 判断是否是逆向合并模式(所有result共用同一个框Handle且数量>1) bool isMergeMode = cleanResults.Count > 1; string mergeHandle = cleanResults[0].FrameHandle; if (isMergeMode) { foreach (var r in cleanResults) { if (r.FrameHandle != mergeHandle) { isMergeMode = false; break; } } } // ★ 新增:重复检测辅助集合 // newFramesToErase: PaTextSelector刚画的框,如果是重复项,这些框要删除 var newFramesToErase = new List(); // oldFrameHandlesToDelete: 数据库里匹配到的旧框,要删除 var oldFrameHandlesToDelete = new List(); // toInsert: 不重复、需要插入的项 var toInsert = new List(); // ★ 本地匹配失败的规格(收集后交给 DeepSeek AI 兜底识别) var aiFailedSpecs = new List(); int deletedCount = 0; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); if (isMergeMode) { // ★ 合并模式:所有文本按图纸阅读顺序拼成一个规格 // (PaTextSelector 已按列分组、列内从上到下排好序,保持此顺序 // 与库中自然书写顺序一致,如 HD13BX-1500/31 + 1500A) // 文本Handle串与文本一一对应拼接(同一批文本Handle集合相同即重复)。 var orderedTexts = new List(); var orderedHandles = new List(); foreach (var r in cleanResults) { orderedTexts.Add(r.Text); orderedHandles.Add(r.TextHandle ?? ""); } string mergedSpec = string.Join(" ", orderedTexts.ToArray().Where(x => x.Trim().Length > 0)); // ★ Handle串排序后拼接(Handle集合相同即同一批文本,与顺序无关) var sortedHandles = new List(orderedHandles); sortedHandles.Sort(StringComparer.Ordinal); string mergedTextHandle = string.Join("|", sortedHandles.ToArray()); // ★ 1. 查"同一批文本实体"的合并规格是否已存在 // (文本Handle拼接串相同 = 同一批文本再次被选中,才是真重复) var matchFrames = new List(); using (var selCmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM \"" + tblName + "\" WHERE 规格=@spec AND 文本Handle=@th;", conn)) { selCmd.Parameters.AddWithValue("@spec", mergedSpec); selCmd.Parameters.AddWithValue("@th", mergedTextHandle); using (var reader = selCmd.ExecuteReader()) { while (reader.Read()) { object v = reader["框Handle"]; if (v != null && v != DBNull.Value) { string s = v.ToString(); if (!string.IsNullOrWhiteSpace(s) && !matchFrames.Contains(s)) matchFrames.Add(s); } } } } // ★ 2. 再查:本批每个文本实体是否已作为"独立元件"入库(按文本Handle精确匹配) var perTextFrames = new List(); var perTextSpecs = new List(); foreach (var r in cleanResults) { List fhs = GetYuanjianFrameHandlesByTextHandle( _currentProjectId, _currentTuhao, _currentXianghao, r.Text, r.TextHandle); foreach (string fh in fhs) { if (!perTextFrames.Contains(fh)) perTextFrames.Add(fh); } if (fhs.Count > 0 && !perTextSpecs.Contains(r.Text)) perTextSpecs.Add(r.Text); } // ★ 只要匹配到(1.同一批文本的合并规格 或 2.同一文本实体的独立元件) → 执行删除 bool hitMergeMatch = matchFrames.Count > 0; bool hitPerTextMatch = perTextSpecs.Count > 0; if (hitMergeMatch || hitPerTextMatch) { // ★ 删除:同一批文本的合并规格匹配的行(文本Handle+规格双条件) if (hitMergeMatch) { using (var delCmd = new System.Data.SQLite.SQLiteCommand( "DELETE FROM \"" + tblName + "\" WHERE 规格=@spec AND 文本Handle=@th;", conn)) { delCmd.Parameters.AddWithValue("@spec", mergedSpec); delCmd.Parameters.AddWithValue("@th", mergedTextHandle); int aff = delCmd.ExecuteNonQuery(); deletedCount += aff; } foreach (string fh in matchFrames) if (!oldFrameHandlesToDelete.Contains(fh)) oldFrameHandlesToDelete.Add(fh); } // ★ 删除:同一文本实体的独立元件行(按文本Handle删除) if (hitPerTextMatch) { foreach (var r in cleanResults) { if (string.IsNullOrWhiteSpace(r.TextHandle)) continue; using (var selCmd2 = new System.Data.SQLite.SQLiteCommand( "SELECT COUNT(*) FROM \"" + tblName + "\" WHERE 文本Handle=@th;", conn)) { selCmd2.Parameters.AddWithValue("@th", r.TextHandle); object cnt = selCmd2.ExecuteScalar(); int n = (cnt != null && cnt != DBNull.Value) ? Convert.ToInt32(cnt) : 0; if (n > 0) { using (var delCmd = new System.Data.SQLite.SQLiteCommand( "DELETE FROM \"" + tblName + "\" WHERE 文本Handle=@th;", conn)) { delCmd.Parameters.AddWithValue("@th", r.TextHandle); int aff = delCmd.ExecuteNonQuery(); deletedCount += aff; } } } } foreach (string fh in perTextFrames) if (!oldFrameHandlesToDelete.Contains(fh)) oldFrameHandlesToDelete.Add(fh); } // ★ 删除 PaTextSelector 刚画的新合并框 if (!string.IsNullOrWhiteSpace(mergeHandle)) if (!newFramesToErase.Contains(mergeHandle)) newFramesToErase.Add(mergeHandle); Log("[PaYuanjianInsert] 合并模式:删除重复项 mergedMatch=" + hitMergeMatch + " perTextMatch=" + hitPerTextMatch + ", 删除行数=" + deletedCount + ", 删除旧框数=" + oldFrameHandlesToDelete.Count); } else { // ★ 没有匹配:新增一行(合并规格) toInsert.Add(new PaTextResult { Text = mergedSpec, FrameHandle = mergeHandle, TextHandle = mergedTextHandle }); } } else { // ★ 正向模式:每个文本一条记录,按"同一个文本实体"判断是否重复 foreach (var r in cleanResults) { // ★ 按文本Handle精确匹配:同一文本实体才删除, // 不同位置的相同内容文本(Handle不同)正常插入 List matchFrames = GetYuanjianFrameHandlesByTextHandle( _currentProjectId, _currentTuhao, _currentXianghao, r.Text, r.TextHandle); if (matchFrames.Count > 0) { // 重复:按文本Handle删除数据库行 + 旧框 + 新画的框 using (var delCmd = new System.Data.SQLite.SQLiteCommand( "DELETE FROM \"" + tblName + "\" WHERE 文本Handle=@th;", conn)) { delCmd.Parameters.AddWithValue("@th", r.TextHandle); int aff = delCmd.ExecuteNonQuery(); deletedCount += aff; } foreach (string fh in matchFrames) oldFrameHandlesToDelete.Add(fh); if (!string.IsNullOrWhiteSpace(r.FrameHandle)) newFramesToErase.Add(r.FrameHandle); } else { toInsert.Add(r); } } } // ★ 写入需要新增的项 if (toInsert.Count > 0) { // 先取当前最大序号(用MAX函数) int startSeq = 0; using (var cntCmd = new System.Data.SQLite.SQLiteCommand( "SELECT COALESCE(MAX(CAST(序号 AS INTEGER)), 0) FROM \"" + tblName + "\";", conn)) { object o = cntCmd.ExecuteScalar(); if (o != null && o != DBNull.Value) startSeq = Convert.ToInt32(o); } // ★ 图纸规格=识别出的原图文本(与规格列同值,主程序惯例:规格可后续替换,图纸规格留图纸原文) string sql = "INSERT INTO \"" + tblName + "\"" + " (序号, 元件名称, 规格, 图纸规格, 单位, 数量, 采购系数, 报出系数," + " 表价, 采购单价, 采购价, 报出单价, 报出价, 类型, 框Handle, 文本Handle) " + "VALUES (@seq, @name, @spec, @tzg, @unit, @shuliang, @caigou, @baochu, 0, 0, 0, 0, 0, '元件', @fh, @th);"; for (int i = 0; i < toInsert.Count; i++) { // ★ 自动匹配元件名称(用户库CSV模糊匹配,匹配不上为空) string matchedName = MatchSpecName(toInsert[i].Text) ?? ""; if (matchedName.Length == 0) aiFailedSpecs.Add(toInsert[i].Text); using (var insCmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { insCmd.Parameters.AddWithValue("@seq", (startSeq + i + 1).ToString()); insCmd.Parameters.AddWithValue("@name", matchedName); insCmd.Parameters.AddWithValue("@spec", toInsert[i].Text); insCmd.Parameters.AddWithValue("@tzg", toInsert[i].Text); insCmd.Parameters.AddWithValue("@unit", "只"); insCmd.Parameters.AddWithValue("@shuliang", "1"); insCmd.Parameters.AddWithValue("@caigou", "1"); insCmd.Parameters.AddWithValue("@baochu", "1"); insCmd.Parameters.AddWithValue("@fh", toInsert[i].FrameHandle ?? ""); insCmd.Parameters.AddWithValue("@th", toInsert[i].TextHandle ?? ""); insCmd.ExecuteNonQuery(); } } } } // ★ 删除旧框和重复新框——合并成一次批量删除(单事务+单次刷新) var framesToErase = new List(oldFrameHandlesToDelete); framesToErase.AddRange(newFramesToErase); DeleteFramesByHandles(framesToErase); // ★ 重新编号(删除后序号可能不连续) RenumberTable(_currentProjectId, "\"" + tblName + "\""); _yuanjianCache.Clear(); _xianghaoCache.Clear(); // 直接在数据库层面重算(不经过内存回写) try { RecalcAllFromDatabase(_currentProjectId); } catch { } ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); // ★ 收集新插入元件的框Handle(finally 里再统一选中, // 避免被后续 SaveTable/AcceptChanges/表格刷新清掉选中) foreach (var r in toInsert) if (!string.IsNullOrWhiteSpace(r.FrameHandle)) _pendingSelectYuanjianHandles.Add(r.FrameHandle); // 保存 try { if (this.advancedDataGridView5 != null) this.advancedDataGridView5.EndEdit(); if (_yuanjianTable != null) { SaveTableToDatabase(_yuanjianTable, _currentProjectId, "\"" + _yuanjianTable.TableName + "\"", null, null, true); _yuanjianTable.AcceptChanges(); } } catch { } SaveCurrentDwg(); if (deletedCount > 0) { Log("[PaYuanjianInsert] 删除了 " + deletedCount + " 条重复元件(匹配框:" + oldFrameHandlesToDelete.Count + ")"); } // ★ DeepSeek AI 兜底:本地未匹配到名称的规格,异步提交 AI 识别并回填 if (aiFailedSpecs.Count > 0) { Log("[PaYuanjianInsert] " + aiFailedSpecs.Count + " 条本地未匹配,提交AI识别"); AiFillMissingNames(aiFailedSpecs, tblName); } } catch (Exception ex) { MessageBox.Show(this, "扒元件失败:\n" + ex.Message, "扒元件", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _isReloading = false; // ★ 扒到的元件行保持选中(供 F1 合并):延迟两拍执行, // 等保存/刷新/绑定事件链全部走完,否则选中会被冲掉 if (_pendingSelectYuanjianHandles.Count > 0) { var snapshot = new List(_pendingSelectYuanjianHandles); _pendingSelectYuanjianHandles.Clear(); BeginInvoke((Action)(() => { BeginInvoke((Action)(() => { SelectYuanjianRowsByFrameHandles(snapshot); Log("[PaYuanjianInsert] 已选中 " + snapshot.Count + " 条新元件行(供F1合并)"); })); })); } } } /// 查询图号是否已存在(避免重复插入)。 private bool TuhaoExists(string projectId, string tuhao) { return !string.IsNullOrEmpty(GetTuhaoFrameHandle(projectId, tuhao)); } /// /// ★ 按"同一个文本实体"判断重复:查该文本实体Handle是否已入库。 /// 命中返回旧框Handle(真重复);未命中返回 null(不同位置的相同内容文本,正常插入)。 /// private string GetTuhaoFrameHandleByTextHandle(string projectId, string tuhao, string textHandle) { // 旧数据没有文本Handle → 按旧逻辑(文本内容)判断,保持旧行为 if (string.IsNullOrWhiteSpace(textHandle)) return GetTuhaoFrameHandle(projectId, tuhao); try { string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return null; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // ★ 先确保旧图号表有"文本Handle"列,避免查询报错 EnsureColumns(conn, TuhaoTableName, new[] { "文本Handle" }); // ★ 双条件:同一文本实体(Handle精确匹配) 或 旧数据行(无文本Handle,按内容兜底) using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM " + TuhaoTableName + " WHERE 文本Handle=@th OR (图号=@t AND (文本Handle IS NULL OR 文本Handle='')) LIMIT 1;", conn)) { cmd.Parameters.AddWithValue("@th", textHandle); cmd.Parameters.AddWithValue("@t", tuhao); using (var rdr = cmd.ExecuteReader()) { if (rdr.Read()) { object v = rdr["框Handle"]; if (v != null && v != DBNull.Value) { string s = v.ToString(); if (!string.IsNullOrWhiteSpace(s)) return s; } } } } } } catch { } return null; } /// 查询指定图号的框Handle(不存在返回null)。 private string GetTuhaoFrameHandle(string projectId, string tuhao) { try { string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return null; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM " + TuhaoTableName + " WHERE 图号=@t LIMIT 1;", conn)) { cmd.Parameters.AddWithValue("@t", tuhao); using (var rdr = cmd.ExecuteReader()) { if (rdr.Read()) { object v = rdr["框Handle"]; if (v != null && v != DBNull.Value) { string s = v.ToString(); if (!string.IsNullOrWhiteSpace(s)) return s; } } } } } } catch { } return null; } /// 查询箱号是否已存在(避免重复插入)。 private bool XianghaoExists(string projectId, string tuhao, string xianghao) { return !string.IsNullOrEmpty(GetXianghaoFrameHandle(projectId, tuhao, xianghao)); } /// /// ★ 按"同一个文本实体"判断重复:查该文本实体Handle是否已入库。 /// 命中返回旧框Handle(真重复);未命中返回 null(不同位置的相同内容文本,正常插入)。 /// private string GetXianghaoFrameHandleByTextHandle(string projectId, string tuhao, string xianghao, string textHandle) { // 旧数据没有文本Handle → 按旧逻辑(文本内容)判断,保持旧行为 if (string.IsNullOrWhiteSpace(textHandle)) return GetXianghaoFrameHandle(projectId, tuhao, xianghao); try { string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return null; string tableName = GetQuoteTableName(projectId, tuhao); if (string.IsNullOrEmpty(tableName)) return null; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // ★ 先确保旧箱号表有"文本Handle"列,避免查询报错 EnsureColumns(conn, "\"" + tableName + "\"", new[] { "文本Handle" }); // ★ 双条件:同一文本实体(Handle精确匹配) 或 旧数据行(无文本Handle,按内容兜底) using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM \"" + tableName + "\"" + " WHERE 文本Handle=@th OR (箱号=@x AND (文本Handle IS NULL OR 文本Handle='')) LIMIT 1;", conn)) { cmd.Parameters.AddWithValue("@th", textHandle); cmd.Parameters.AddWithValue("@x", xianghao); using (var rdr = cmd.ExecuteReader()) { if (rdr.Read()) { object v = rdr["框Handle"]; if (v != null && v != DBNull.Value) { string s = v.ToString(); if (!string.IsNullOrWhiteSpace(s)) return s; } } } } } } catch { } return null; } /// 查询指定箱号的框Handle(不存在返回null)。 private string GetXianghaoFrameHandle(string projectId, string tuhao, string xianghao) { try { string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return null; string tableName = GetQuoteTableName(projectId, tuhao); if (string.IsNullOrEmpty(tableName)) return null; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM \"" + tableName + "\" WHERE 箱号=@x LIMIT 1;", conn)) { cmd.Parameters.AddWithValue("@x", xianghao); using (var rdr = cmd.ExecuteReader()) { if (rdr.Read()) { object v = rdr["框Handle"]; if (v != null && v != DBNull.Value) { string s = v.ToString(); if (!string.IsNullOrWhiteSpace(s)) return s; } } } } } } catch { } return null; } /// 查询指定元件规格的所有框Handle(可能有多个重复行,返回List)。 private List GetYuanjianFrameHandles(string projectId, string tuhao, string xianghao, string spec) { var list = new List(); try { string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return list; string tableName = GetYuanjianTableByIndex(projectId, tuhao, xianghao); if (string.IsNullOrEmpty(tableName)) return list; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM \"" + tableName + "\" WHERE 规格=@s;", conn)) { cmd.Parameters.AddWithValue("@s", spec); using (var rdr = cmd.ExecuteReader()) { while (rdr.Read()) { object v = rdr["框Handle"]; if (v != null && v != DBNull.Value) { string s = v.ToString(); if (!string.IsNullOrWhiteSpace(s)) list.Add(s); } } } } } } catch { } return list; } /// /// ★ 按"同一个文本实体"判断重复:查该文本实体Handle在元件表中是否已入库。 /// 命中返回旧框Handle列表(真重复);未命中返回空列表(不同位置的相同内容文本,正常插入)。 /// private List GetYuanjianFrameHandlesByTextHandle(string projectId, string tuhao, string xianghao, string spec, string textHandle) { var list = new List(); // 旧数据没有文本Handle → 按旧逻辑(文本内容)判断,保持旧行为 if (string.IsNullOrWhiteSpace(textHandle)) return GetYuanjianFrameHandles(projectId, tuhao, xianghao, spec); try { string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return list; string tableName = GetYuanjianTableByIndex(projectId, tuhao, xianghao); if (string.IsNullOrEmpty(tableName)) return list; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // ★ 先确保旧元件表有"文本Handle"列,避免查询报错 EnsureColumns(conn, "\"" + tableName + "\"", new[] { "文本Handle" }); // ★ 双条件:同一文本实体(Handle精确匹配) 或 旧数据行(无文本Handle,按规格兜底) using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM \"" + tableName + "\"" + " WHERE 文本Handle=@th OR (规格=@s AND (文本Handle IS NULL OR 文本Handle=''));", conn)) { cmd.Parameters.AddWithValue("@th", textHandle); cmd.Parameters.AddWithValue("@s", spec); using (var rdr = cmd.ExecuteReader()) { while (rdr.Read()) { object v = rdr["框Handle"]; if (v != null && v != DBNull.Value) { string s = v.ToString(); if (!string.IsNullOrWhiteSpace(s)) list.Add(s); } } } } } } catch { } return list; } // ============================================================ // 扒元件自动匹配元件名称(用户库CSV模糊匹配) // · 扫描插件上级目录/自身目录下最新的 用户库_*.csv(GBK编码) // · 匹配策略:L1归一化相等 → L2包含(60%长度) → L3编辑距离≥0.70 // · 硬保护:模糊匹配时数字序列必须完全一致(63A 不会配到 100A) // ============================================================ /// 用户库条目:规格 → 元件名称 private class SpecNameEntry { public string Name; public string NormSpec; // 归一化后的规格 public string[] Numbers; // 规格中的数字序列(模糊匹配保护用) public string Skeleton; // 字母骨架(去数字/分隔符/尾部单位,如 DZ47-4P/C80A → DZPC) } private static List _specNameLib = null; private static string _specLibCsvPath = null; private static DateTime _specLibCsvTime = DateTime.MinValue; /// /// 确保用户库CSV已加载(取最新修改时间的 用户库_*.csv, /// 文件时间戳变化时自动重新加载;文件被 Excel 占用时用共享读)。 /// private static void EnsureUserLibCsvLoaded() { try { // 候选目录:上级目录(成套报价软件根目录) + 插件目录自身 var dirs = new List(); try { var parent = Path.GetDirectoryName(_baseDir); if (!string.IsNullOrEmpty(parent) && Directory.Exists(parent)) dirs.Add(parent); } catch { } dirs.Add(_baseDir); string best = null; DateTime bestTime = DateTime.MinValue; foreach (string d in dirs) { try { string[] files = Directory.GetFiles(d, "用户库*.csv"); foreach (string f in files) { DateTime t = File.GetLastWriteTime(f); if (best == null || t > bestTime) { best = f; bestTime = t; } } } catch { } } if (best == null) return; // 没找到CSV,保持空库 // 时间戳没变且已加载 → 不重复加载 if (_specNameLib != null && string.Equals(best, _specLibCsvPath, StringComparison.OrdinalIgnoreCase) && bestTime == _specLibCsvTime) return; var lib = new List(); var prefixRules = new List(); // ★ 共享读:文件被 Excel 打开也能读 using (var fs = new FileStream(best, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (var sr = new StreamReader(fs, DetectCsvEncoding(fs))) { string line; bool first = true; while ((line = sr.ReadLine()) != null) { if (string.IsNullOrWhiteSpace(line)) continue; string[] cols = SplitCsvLine(line); if (cols.Length < 2) continue; string name = cols[0].Trim(); string spec = cols[1].Trim(); if (first) { first = false; // 跳过表头 if (name == "元件名称") continue; } if (name.Length == 0 || spec.Length == 0) continue; // ★ "元件类型"列 = "前缀" 的行是型号前缀规则(不是库条目) string elemType = cols.Length >= 10 ? cols[9].Trim() : ""; if (elemType == "前缀") { string pfx = spec.ToUpperInvariant(); if (pfx.Length >= 2) prefixRules.Add(new[] { pfx, name }); continue; } string normSpec = NormalizeSpec(spec); lib.Add(new SpecNameEntry { Name = name, NormSpec = normSpec, // ★ 必须用原始 spec 提取数字(保留空格/符号作数字边界); // 用 normSpec 会导致 3300 800 粘连成 3300800,数字序列错位 Numbers = ExtractNumbers(spec), Skeleton = ExtractSkeleton(spec) }); } } _specNameLib = lib; _prefixRules = prefixRules; _specLibCsvPath = best; _specLibCsvTime = bestTime; WriteDebugLog("[用户库匹配] 加载CSV: " + best + " 条数=" + lib.Count + " 前缀规则=" + prefixRules.Count); } catch (Exception ex) { WriteDebugLog("[用户库匹配] 加载CSV异常: " + ex.Message); } } /// 检测CSV编码:有UTF-8 BOM用UTF8,否则按GBK(主程序导出编码)。 private static System.Text.Encoding DetectCsvEncoding(FileStream fs) { try { long pos = fs.Position; int b1 = fs.ReadByte(); int b2 = fs.ReadByte(); int b3 = fs.ReadByte(); fs.Position = pos; if (b1 == 0xEF && b2 == 0xBB && b3 == 0xBF) return System.Text.Encoding.UTF8; } catch { } return System.Text.Encoding.GetEncoding("GBK"); } /// 按CSV规则拆分一行(支持双引号包裹和转义"")。 private static string[] SplitCsvLine(string line) { var parts = new List(); var sb = new System.Text.StringBuilder(); bool inQuotes = false; for (int i = 0; i < line.Length; i++) { char c = line[i]; if (c == '"') { if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') { sb.Append('"'); i++; } else inQuotes = !inQuotes; } else if (c == ',' && !inQuotes) { parts.Add(sb.ToString()); sb.Length = 0; } else sb.Append(c); } parts.Add(sb.ToString()); return parts.ToArray(); } /// 规格归一化:全角→半角、统一大写、去空白和分隔符号(保留字母/数字/中文)。 private static string NormalizeSpec(string s) { if (string.IsNullOrEmpty(s)) return ""; var sb = new System.Text.StringBuilder(s.Length); foreach (char c0 in s) { char c = c0; // 全角→半角 if (c >= 0xFF01 && c <= 0xFF5E) c = (char)(c - 0xFEE0); else if (c == 0x3000) c = ' '; if (c == ' ' || c == '\t') continue; if (IsSpecSeparator(c)) continue; sb.Append(char.ToUpperInvariant(c)); } return sb.ToString(); } /// 规格中的分隔符号(归一化时删除;数字提取时作为数字段边界)。 private static bool IsSpecSeparator(char c) { return c == '-' || c == '/' || c == '*' || c == '×' || c == '·' || c == '.' || c == '、' || c == ',' || c == ',' || c == '(' || c == ')' || c == '(' || c == ')' || c == '#' || c == '+' || c == '_' || c == ':' || c == ':' || c == '~' || c == '~'; } /// /// 轻量归一化(保留分隔符和空格):全角→半角、统一大写。 /// ★ 用于数字序列提取:分隔符和空格是数字段的天然边界, /// 先删分隔符会导致 HYM1-63/3300 的 63 和 3300 粘连成 633300; /// 删空格会导致 3300 800 粘连成 3300800(与 3300,800 数字序列不一致)。 /// private static string NormalizeKeepSeparators(string s) { if (string.IsNullOrEmpty(s)) return ""; var sb = new System.Text.StringBuilder(s.Length); foreach (char c0 in s) { char c = c0; if (c >= 0xFF01 && c <= 0xFF5E) c = (char)(c - 0xFEE0); else if (c == 0x3000) c = ' '; if (c == '\t') c = ' '; // 制表符当空格 sb.Append(char.ToUpperInvariant(c)); } return sb.ToString(); } /// /// 提取规格中的所有数字序列(在保留分隔符的版本上提取, /// 如 HYM1-63/3300 63A → ["1","63","3300","63"])。 /// private static string[] ExtractNumbers(string spec) { var list = new List(); if (string.IsNullOrEmpty(spec)) return list.ToArray(); string light = NormalizeKeepSeparators(spec); var sb = new System.Text.StringBuilder(); foreach (char c in light) { if (c >= '0' && c <= '9') sb.Append(c); else if (sb.Length > 0) { list.Add(sb.ToString()); sb.Length = 0; } } if (sb.Length > 0) list.Add(sb.ToString()); return list.ToArray(); } /// 两个数字序列是否完全一致。 private static bool NumbersEqual(string[] a, string[] b) { if (a == null || b == null) return false; if (a.Length != b.Length) return false; for (int i = 0; i < a.Length; i++) if (a[i] != b[i]) return false; return true; } /// /// 数字序列兼容(子序列检查):短方的数字按顺序都能在长方中找到即通过。 /// ★ 用于 L2 包含关系:一方文本是另一方的一部分,数字自然也可能少 /// (如图纸只写 HD13BX-1500/31,库里是 HD13BX-1500/31 1500A)。 /// ★ 依然防误配:63A vs 100A 时 63 不在对方序列对应位置,拒绝。 /// private static bool NumbersCompatible(string[] a, string[] b) { if (a == null || b == null) return false; string[] shorter = a.Length <= b.Length ? a : b; string[] longer = a.Length <= b.Length ? b : a; if (longer.Length == 0) return shorter.Length == 0; int i = 0; foreach (string n in longer) { if (i < shorter.Length && n == shorter[i]) i++; } return i == shorter.Length; } /// /// 数字序列严格相等 + 两种容错(L3 模糊匹配用): /// 1.粘连拆分:图纸连写导致数字粘连(如 0.66+1500 提取出 661500),拆成两段后对上即通过; /// 2.壳架容错:图纸把额定电流写进型号段(如 HYW1-2500/3P 2500A,2500出现两次), /// 库里型号段是壳架等级(HYW1-3200),删掉图纸型号段那个数字后能对上即通过。 /// ★ 仅当该数字在图纸中出现≥2次才允许删,220V/380V 这类无重复差异依然拒绝。 /// private static bool NumbersEqualEx(string[] a, string[] b) { if (a == null || b == null) return false; if (NumbersEqual(a, b)) return true; // 容错1:粘连拆分 if (TrySplitAndCompatible(a, b)) return true; if (TrySplitAndCompatible(b, a)) return true; // 容错2:壳架(删图纸中重复出现的电流数字之一) if (TryDropFrameCurrent(a, b)) return true; if (TryDropFrameCurrent(b, a)) return true; return false; } /// /// 壳架容错:seq 中存在出现≥2次的数字 X,删掉其中一次后作为子序列能对上 other。 /// 典型:图纸 HYW1-2500/3P 2500A(2500×2) vs 库 HYW1-3200/3P 2500A(3200×1,2500×1)。 /// private static bool TryDropFrameCurrent(string[] seq, string[] other) { for (int i = 0; i < seq.Length; i++) { string s = seq[i]; if (s == null || s.Length < 2) continue; // 该数字必须在 seq 中出现≥2次(型号段+电流段重复) int cnt = 0; foreach (string n in seq) if (n == s) cnt++; if (cnt < 2) continue; // 删掉第 i 个,其余保持顺序 var list = new List(seq.Length - 1); for (int k = 0; k < seq.Length; k++) if (k != i) list.Add(seq[k]); if (NumbersCompatible(list.ToArray(), other)) return true; } return false; } /// 把 seq 中某个数字串拆成两段,拆分后作为子序列能对上 other 即返回 true。 private static bool TrySplitAndCompatible(string[] seq, string[] other) { for (int i = 0; i < seq.Length; i++) { string s = seq[i]; if (s == null || s.Length < 2) continue; for (int cut = 1; cut < s.Length; cut++) { var list = new List(seq.Length + 1); for (int k = 0; k < seq.Length; k++) { if (k == i) { list.Add(s.Substring(0, cut)); list.Add(s.Substring(cut)); } else list.Add(seq[k]); } if (NumbersCompatible(list.ToArray(), other)) return true; } } return false; } /// /// 提取字母骨架:去数字、去分隔符、去尾部单位词(A/V/kA 等)。 /// ★ 用于图纸省略壳架等级的场景:DZ47-4P/C80A 与 DZ47-100 4P C80 /// 骨架同为 DZPC,配合数字子序列保护可安全匹配。 /// private static string ExtractSkeleton(string spec) { if (string.IsNullOrEmpty(spec)) return ""; string light = NormalizeKeepSeparators(spec); var sb = new System.Text.StringBuilder(); for (int i = 0; i < light.Length; i++) { char c = light[i]; if (c >= 'A' && c <= 'Z') { // 单位词必须紧跟在数字后面才有效(如 80A、450V); // 6L2-V 的 V 前面是分隔符,属于型号的一部分,不能截断 bool prevIsDigit = i > 0 && light[i - 1] >= '0' && light[i - 1] <= '9'; if (prevIsDigit && IsTailUnit(light.Substring(i))) break; sb.Append(c); } // 数字、分隔符、中文等其他字符全部跳过 } return sb.ToString(); } /// 判断剩余部分是否为纯尾部单位词(如 "A"、"80A" 里的 A、"0~450V" 里的 V)。 private static bool IsTailUnit(string rest) { if (string.IsNullOrEmpty(rest)) return false; string r = rest.Trim(); if (r.Length > 2) return false; // 单位最多2字符(HZ/KA/KW/VA) return r == "A" || r == "V" || r == "W" || r == "VA" || r == "HZ" || r == "KA" || r == "KW"; } /// Levenshtein 编辑距离(滚动数组实现)。 private static int LevenshteinDistance(string a, string b) { if (string.IsNullOrEmpty(a)) return string.IsNullOrEmpty(b) ? 0 : b.Length; if (string.IsNullOrEmpty(b)) return a.Length; int n = a.Length, m = b.Length; int[] prev = new int[m + 1]; int[] cur = new int[m + 1]; for (int j = 0; j <= m; j++) prev[j] = j; for (int i = 1; i <= n; i++) { cur[0] = i; for (int j = 1; j <= m; j++) { int cost = (a[i - 1] == b[j - 1]) ? 0 : 1; int v1 = prev[j] + 1; int v2 = cur[j - 1] + 1; int v3 = prev[j - 1] + cost; cur[j] = Math.Min(v1, Math.Min(v2, v3)); } int[] tmp = prev; prev = cur; cur = tmp; } return prev[m]; } /// /// 规格匹配元件名称(模糊): /// L1 归一化后完全相等 → 直接命中; /// L2 包含关系(短方长度≥长方60%,数字序列一致); /// L3 编辑距离相似度≥0.70(数字序列必须完全一致,唯一最高分才命中)。 /// 都不中返回 null(不硬猜)。 /// /// /// 粘连清洗:扒图可能把相邻文本粘连(如 "0mAVigiC65N-...")。 /// 若文本开头是"数字+单位(mA/A/V/W)"且后面紧跟大写字母开头的型号, /// 从型号处截取;否则原样返回。 /// private static string CleanLeadingNoise(string spec) { if (string.IsNullOrEmpty(spec)) return spec; string s = spec.Trim(); // ★ 先剥"数量×"前缀:如 "3xLMZ1-0.5" → "LMZ1-0.5"、"2×AD16" → "AD16" // (图纸常在型号前写元件数量,3x=3只) if (s.Length > 2) { char c0 = s[0]; if (c0 >= '0' && c0 <= '9') { int i = 0; while (i < s.Length && s[i] >= '0' && s[i] <= '9') i++; if (i < s.Length && (s[i] == 'x' || s[i] == 'X' || s[i] == '×' || s[i] == '*')) { int after = i + 1; if (after < s.Length && s[after] >= 'A' && s[after] <= 'Z') return s.Substring(after); } } } // 找开头最长的"数字(可含小数点)+单位"模式 int j = 0; while (j < s.Length && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.')) j++; if (j == 0 || j >= s.Length) return spec; // 开头不是数字 // 单位段(1~3个字母) int u = j; while (u < s.Length && u - j <= 3 && s[u] >= 'A' && s[u] <= 'z') u++; if (u == j || u >= s.Length) return spec; // 没有单位或后面没内容 string unit = s.Substring(j, u - j).ToUpperInvariant(); if (unit != "MA" && unit != "A" && unit != "V" && unit != "W" && unit != "KW" && unit != "HZ") return spec; // 单位后必须紧跟大写字母(型号开头) char next = s[u]; if (next < 'A' || next > 'Z') return spec; return s.Substring(u); // 从型号处截取 } // ★ 匹配结果缓存:同一规格文本(一批框选里重复文本很常见)只完整跑一次 L0-L5。 // L3 会按库逐条算编辑距离(库大时是 CPU 热点),缓存能直接砍掉重复计算。 private static readonly System.Collections.Generic.Dictionary _specMatchCache = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); private static readonly object _specMatchCacheLock = new object(); /// 规格匹配元件名称(带结果缓存):相同规格文本第二次起直接返回缓存结果。 private static string MatchSpecName(string spec) { if (string.IsNullOrWhiteSpace(spec)) return null; string cached; lock (_specMatchCacheLock) { if (_specMatchCache.TryGetValue(spec, out cached)) return cached; } string result = MatchSpecNameCore(spec); lock (_specMatchCacheLock) { // 上限保护:超过2万条清空重来(规格文本总量有限,防内存无限增长) if (_specMatchCache.Count > 20000) _specMatchCache.Clear(); _specMatchCache[spec] = result; } return result; } private static string MatchSpecNameCore(string spec) { if (string.IsNullOrWhiteSpace(spec)) return null; // ★ 粘连清洗:扒图可能把相邻文本粘连(如 "0mAVigiC65N-...")。 // 若文本前部含"数字+单位(mA/A/V)"后跟已知型号,从型号处截取 spec = CleanLeadingNoise(spec); EnsureUserLibCsvLoaded(); if (_specNameLib == null || _specNameLib.Count == 0) return null; string norm = NormalizeSpec(spec); if (norm.Length == 0) return null; string[] nums = ExtractNumbers(spec); string result = null; string via = "none"; // L0: 型号前缀规则(本地权威,最优先,覆盖国标型号命名体系) string ruleName = MatchByPrefixRule(spec); if (ruleName != null) { WriteDebugLog("[用户库匹配] 「" + spec + "」 → " + ruleName + " via L0规则"); return ruleName; } // L0.5: JB/T 2930 国标类组代号(官方定义:前2位字母=品类代号) string gbName = MatchByGbCode(spec); if (gbName != null) { WriteDebugLog("[用户库匹配] 「" + spec + "」 → " + gbName + " via L0.5国标"); return gbName; } // L1: 归一化后完全相等 foreach (var e in _specNameLib) { if (e.NormSpec == norm) { result = e.Name; via = "L1相等"; break; } } // L2: 包含关系(如图纸只写型号主体,库里有完整版) if (result == null) { foreach (var e in _specNameLib) { if (e.NormSpec.Length == 0) continue; bool contains = e.NormSpec.Contains(norm) || norm.Contains(e.NormSpec); if (!contains) continue; int shortLen = Math.Min(e.NormSpec.Length, norm.Length); int longLen = Math.Max(e.NormSpec.Length, norm.Length); if (longLen == 0 || (double)shortLen / longLen < 0.6) continue; if (!NumbersCompatible(nums, e.Numbers)) continue; // ★ 数字子序列兼容(允许少,不允许错) result = e.Name; via = "L2包含"; break; } } // L3: 编辑距离模糊匹配(字母/符号容错,数字绝对不放过) if (result == null) { string bestName = null; double bestScore = 0.0; int bestCount = 0; foreach (var e in _specNameLib) { if (e.NormSpec.Length == 0) continue; if (!NumbersEqualEx(nums, e.Numbers)) continue; // ★ 数字一致(含粘连拆分容错) // ★ 长度差剪枝:编辑距离 ≥ 长度差,长度差已超过 0.30×maxLen 时 // score 必然 < 0.70,直接跳过(编辑距离是 O(m×n),库里条目多时全算是卡顿热点) int maxLen = Math.Max(norm.Length, e.NormSpec.Length); if (Math.Abs(norm.Length - e.NormSpec.Length) * 10 > maxLen * 3) continue; int dist = LevenshteinDistance(norm, e.NormSpec); double score = 1.0 - (double)dist / maxLen; if (score >= 0.70) { if (score > bestScore + 0.0001) { bestScore = score; bestName = e.Name; bestCount = 1; } else if (Math.Abs(score - bestScore) <= 0.0001 && bestName != e.Name) bestCount++; } } if (bestCount == 1 && bestName != null) { result = bestName; via = "L3模糊"; } } // L4: 字母骨架匹配(图纸省略壳架等级的场景) // DZ47-4P/C80A 与 DZ47-100 4P C80:骨架同为 DZPC, // 且图纸数字 [47,4,80] 是库数字 [47,100,4,80] 的子序列 → 命中 if (result == null) { string skel = ExtractSkeleton(spec); if (skel.Length >= 3) { string bestName = null; int bestLen = int.MaxValue; foreach (var e in _specNameLib) { if (e.Skeleton == null || e.Skeleton.Length < 3) continue; if (e.Skeleton != skel) continue; if (!NumbersCompatible(nums, e.Numbers)) continue; // ★ 数字子序列保护 // 多条同骨架时取数字数量最接近的 int diff = Math.Abs(nums.Length - e.Numbers.Length); if (diff < bestLen) { bestLen = diff; bestName = e.Name; } } if (bestName != null) { result = bestName; via = "L4骨架"; } } } // L5: 大电流启发式(最后兜底) // 框架级特征:数字≥1600 + 3P/4P + 型号含W(万能式标记) → 万能式/智能断路器 if (result == null) { string sUp = spec.Trim().ToUpperInvariant(); bool hasBig = false; foreach (string n in nums) { long v; if (long.TryParse(n, out v) && v >= 1600 && v <= 6300) { hasBig = true; break; } } bool hasPole = sUp.Contains("/3P") || sUp.Contains("/4P") || sUp.Contains(" 3P") || sUp.Contains(" 4P") || sUp.EndsWith("3P") || sUp.EndsWith("4P"); bool hasW = sUp.IndexOf('W') >= 0; if (hasBig && hasPole && hasW) { // 含 I/E(智能型标记,如 HUWI/CW1I) → 智能断路器 bool hasSmart = sUp.IndexOf('I') >= 0 || sUp.IndexOf('E') >= 0; result = hasSmart ? "智能断路器" : "万能式断路器"; via = "L5大电流启发"; } } // ★ 匹配日志:输入/归一化/数字序列/结果,失败可从日志直接定位原因 WriteDebugLog("[用户库匹配] 「" + spec + "」 norm=" + norm + " nums=[" + string.Join(",", nums) + "] → " + (result == null ? "(未匹配)" : result + " via " + via)); return result; } // ============================================================ // DeepSeek AI 智能匹配(本地匹配失败后的兜底) // · 设置窗口填 API Key,存插件目录 deepseek.json // · 扒元件后本地未命中的规格批量提交 AI 识别元器件名称 // · 结果缓存到 deepseek_cache.json(同规格只问一次) // · AI 结果回填元件表"元件名称"列 // ============================================================ private string DeepSeekConfigPath { get { return GetDbFullPath("deepseek.json"); } } private string DeepSeekCachePath { get { return GetDbFullPath("deepseek_cache.json"); } } /// DeepSeek 设置(加载/保存 JSON) private class DeepSeekConfig { public string ApiKey = ""; public string BaseUrl = "https://api.deepseek.com"; public string Model = "deepseek-v4-flash"; // ★ 支持联网搜索的模型 public bool Enabled = false; public bool WebSearch = true; // ★ 联网搜索(罕见型号也能查) } private DeepSeekConfig _dsConfig; private System.Collections.Hashtable _dsCache; private DeepSeekConfig LoadDsConfig() { if (_dsConfig != null) return _dsConfig; var cfg = new DeepSeekConfig(); try { if (File.Exists(DeepSeekConfigPath)) { string json = File.ReadAllText(DeepSeekConfigPath, System.Text.Encoding.UTF8); var map = SimpleJsonParse(json); if (map.ContainsKey("ApiKey")) cfg.ApiKey = (string)map["ApiKey"] ?? ""; if (map.ContainsKey("BaseUrl")) cfg.BaseUrl = (string)map["BaseUrl"] ?? "https://api.deepseek.com"; if (map.ContainsKey("Model")) cfg.Model = (string)map["Model"] ?? "deepseek-v4-flash"; // ★ 布尔值无引号,解析出的 val 可能带换行/空格前缀,比较前必须 Trim if (map.ContainsKey("Enabled")) cfg.Enabled = ((string)map["Enabled"] ?? "").Trim() == "true"; if (map.ContainsKey("WebSearch")) cfg.WebSearch = ((string)map["WebSearch"] ?? "").Trim() != "false"; // ★ 旧模型(deepseek-chat 不支持联网)自动迁移到 v4 if (cfg.Model == "deepseek-chat" || cfg.Model == "deepseek-reasoner") cfg.Model = "deepseek-v4-flash"; } } catch { } _dsConfig = cfg; return cfg; } private void SaveDsConfig(DeepSeekConfig cfg) { try { string json = "{\n" + " \"ApiKey\": \"" + (cfg.ApiKey ?? "").Replace("\"", "\\\"") + "\",\n" + " \"BaseUrl\": \"" + (cfg.BaseUrl ?? "").Replace("\"", "\\\"") + "\",\n" + " \"Model\": \"" + (cfg.Model ?? "").Replace("\"", "\\\"") + "\",\n" + " \"Enabled\": " + (cfg.Enabled ? "true" : "false") + ",\n" + " \"WebSearch\": " + (cfg.WebSearch ? "true" : "false") + "\n" + "}"; File.WriteAllText(DeepSeekConfigPath, json, System.Text.Encoding.UTF8); _dsConfig = cfg; } catch (Exception ex) { Log("[DeepSeek配置] 保存失败: " + ex.Message); } } /// AI 匹配缓存:规格 → 元件名称(未命中/失败也缓存,避免反复请求) private System.Collections.Hashtable LoadDsCache() { if (_dsCache != null) return _dsCache; var ht = new System.Collections.Hashtable(); try { if (File.Exists(DeepSeekCachePath)) { string json = File.ReadAllText(DeepSeekCachePath, System.Text.Encoding.UTF8); var map = SimpleJsonParse(json); foreach (System.Collections.DictionaryEntry kv in map) ht[kv.Key.ToString()] = (kv.Value ?? "").ToString(); } } catch { } _dsCache = ht; return ht; } private void SaveDsCache() { if (_dsCache == null) return; try { var sb = new System.Text.StringBuilder("{\n"); bool first = true; foreach (System.Collections.DictionaryEntry kv in _dsCache) { if (!first) sb.Append(",\n"); sb.Append(" \"" + kv.Key.ToString().Replace("\"", "\\\"") + "\": \"" + kv.Value.ToString().Replace("\"", "\\\"") + "\""); first = false; } sb.Append("\n}"); File.WriteAllText(DeepSeekCachePath, sb.ToString(), System.Text.Encoding.UTF8); } catch (Exception ex) { Log("[DeepSeek缓存] 保存失败: " + ex.Message); } } /// 极简 JSON 解析(扁平 key:value 结构,支持字符串/数字/布尔值) private static System.Collections.Hashtable SimpleJsonParse(string json) { var ht = new System.Collections.Hashtable(); if (string.IsNullOrEmpty(json)) return ht; int depth = 0; bool inStr = false; var key = new System.Text.StringBuilder(); var val = new System.Text.StringBuilder(); bool inKey = true; for (int i = 0; i < json.Length; i++) { char c = json[i]; if (inStr) { if (c == '\\' && i + 1 < json.Length) { val.Append(json[i + 1]); i++; continue; } if (c == '"') { inStr = false; continue; } (inKey ? key : val).Append(c); continue; } if (c == '"') { inStr = true; continue; } if (c == '{' || c == '[') { depth++; continue; } if (c == '}' || c == ']') { // ★ 闭合括号收掉最后一个键值对(布尔值结尾无逗号) if (depth == 1 && key.Length > 0) { ht[key.ToString()] = val.ToString().Trim(); key.Length = 0; val.Length = 0; inKey = true; } depth--; continue; } if (c == ':' && depth == 1) { inKey = false; val.Length = 0; continue; } if (c == ',' && depth == 1) { if (key.Length > 0) ht[key.ToString()] = val.ToString().Trim(); key.Length = 0; val.Length = 0; inKey = true; continue; } // ★ 无引号值(true/false/数字)必须累积,否则解析成空 if (!inKey && !char.IsWhiteSpace(c)) val.Append(c); } if (key.Length > 0) ht[key.ToString()] = val.ToString().Trim(); return ht; } // ============================================================ // 电器型号前缀规则(与用户库共用同一个 CSV 数据文件) // ★ 用户库 CSV 中"元件类型"列 = "前缀" 的行即为前缀规则: // 隔离开关,HGL,只,1,0,1,1,规则,0,前缀 // (元件名称列=名称,规格列=型号前缀) // ★ Excel 里直接编辑用户库即可增删规则,随库自动重载 // ★ 规则结果与 AI 冲突时,以规则为准(根治 AI 误判) // ============================================================ private static List _prefixRules = new List(); /// 内置最小规则集(仅当用户库无任何前缀行时兜底)。 private static readonly string[,] s_BuiltinPrefixRules = { { "HGL", "隔离开关" }, { "DZ47", "小型断路器" }, { "NM1", "塑壳断路器" }, { "CJX2", "交流接触器" }, { "RT16", "熔断器" }, { "LMZ", "电流互感器" }, }; // ============================================================ // ★ JB/T 2930 国标类组代号表(低压电器型号编制方法的官方定义) // 国标型号前2位字母=品类代号:第1位类别+第2位组别。 // 在前缀规则(L0)之后、CSV 库(L1)之前执行,覆盖所有国标型号。 // ============================================================ private static readonly System.Collections.Hashtable s_GbClassCodes = new System.Collections.Hashtable(StringComparer.OrdinalIgnoreCase) { // H 刀开关/隔离开关族 { "HD", "隔离开关" }, // 单投刀开关 { "HS", "隔离开关" }, // 双投刀开关(转换) { "HR", "隔离开关" }, // 熔断器式刀开关 { "HH", "隔离开关" }, // 封闭式负荷开关 { "HK", "隔离开关" }, // 开启式负荷开关(胶盖闸) { "HZ", "转换开关" }, // 组合开关 { "HG", "隔离开关" }, // 熔断器式隔离器(另:HG1/HG13) // R 熔断器族 { "RC", "熔断器" }, // 插入式 { "RL", "熔断器" }, // 螺旋式(RL6/RL8) { "RM", "熔断器" }, // 密封管式 { "RS", "快速熔断器" }, // 快速(RS0/RS3) { "RT", "熔断器" }, // 有填料管式(RT0/RT16/RT18) { "RX", "熔断器" }, // 限流 { "RTO", "熔断器" }, // D 断路器族 { "DW", "万能式断路器" }, // 框架式(DW15/DW45) { "DZ", "塑壳断路器" }, // 装置式/塑料外壳(DZ15/DZ20) { "DX", "小型断路器" }, { "DM", "塑壳断路器" }, { "DC", "断路器" }, // C 接触器族 { "CJ", "交流接触器" }, // 交流(CJ20/CJX) { "CZ", "直流接触器" }, // 直流 { "CG", "接触器" }, // 高压 { "CP", "接触器" }, // 中频 { "CS", "接触器" }, // 时间 // Q 起动器族 { "QC", "电磁起动器" }, { "QS", "手动起动器" }, { "QX", "星三角起动器" }, { "QJ", "自耦减压起动器" }, { "QT", "起动器" }, { "QU", "起动器" }, // J 控制继电器族 { "JL", "电流继电器" }, { "JR", "热过载继电器" }, { "JS", "时间继电器" }, { "JT", "通用继电器" }, { "JW", "温度继电器" }, { "JZ", "中间继电器" }, { "JX", "继电器" }, // L 主令电器族 { "LA", "按钮" }, // 按钮(LA38/LA39) { "LK", "主令控制器" }, { "LT", "主令开关" }, { "LW", "转换开关" }, { "LX", "行程开关" }, { "LU", "足踏开关" }, { "LS", "主令开关" }, // Z 电阻/终端族 { "ZB", "电阻器" }, { "ZG", "电阻器" }, { "ZC", "电阻器" }, // B 变阻器族 { "BL", "励磁变阻器" }, { "BP", "频敏变阻器" }, { "BQ", "起动变阻器" }, { "BT", "变阻器" }, { "BC", "旋臂变阻器" }, // M 电磁铁族 { "MQ", "牵引电磁铁" }, { "MZ", "制动电磁铁" }, { "MW", "起重电磁铁" }, // A 其他族 { "AD", "指示灯" }, // 信号灯(AD11/AD16 习惯用) { "AN", "按钮" }, // P 仪用互感器(行业习惯,P 不在 JB/T2930 主表,单列) { "LQG", "电流互感器" }, // 变压器(控制变压器 BK 不在 JB/T2930,行业通用) { "BK", "控制变压器" }, }; /// /// ★ 国标类组代号解析:按 JB/T 2930 定义,取型号前2位字母查类组表。 /// 仅当:①前缀规则未命中 ②开头≥2位字母 ③类组表命中 时返回名称。 /// 数字开头/单字母/进口品牌型号不适用(返回 null 交给后续层)。 /// private static string MatchByGbCode(string spec) { if (string.IsNullOrWhiteSpace(spec)) return null; string s = spec.Trim(); // 前2位必须是字母(国标类组代号为2位拼音) if (s.Length < 3) return null; if (!((s[0] >= 'A' && s[0] <= 'Z') || (s[0] >= 'a' && s[0] <= 'z'))) return null; if (!((s[1] >= 'A' && s[1] <= 'Z') || (s[1] >= 'a' && s[1] <= 'z'))) return null; // 第3位应是数字或分隔符(确认是类组代号+设计序号结构,排除品牌词) if (s.Length > 2) { char c2 = s[2]; bool ok = (c2 >= '0' && c2 <= '9') || c2 == '-' || c2 == ' ' || c2 == '/'; if (!ok) return null; } string code = s.Substring(0, 2).ToUpperInvariant(); object v = s_GbClassCodes[code]; return v as string; } /// /// 按型号前缀规则查元器件名称。 /// ★ 规则来自用户库 CSV"元件类型=前缀"的行(EnsureUserLibCsvLoaded 时提取), /// 规则前缀直接与规格开头比对(支持字母+数字混合前缀,如 DZ47/CDM3/CJX2), /// 最长前缀优先(7→2字符)。 /// 返回 null 表示无规则。 /// private static string MatchByPrefixRule(string spec) { if (string.IsNullOrWhiteSpace(spec)) return null; string s = spec.Trim().ToUpperInvariant(); if (s.Length < 2) return null; var rules = _prefixRules; if (rules.Count == 0) { // 用户库无前缀行 → 内置兜底 for (int i = 0; i < s_BuiltinPrefixRules.GetLength(0); i++) rules.Add(new[] { s_BuiltinPrefixRules[i, 0], s_BuiltinPrefixRules[i, 1] }); _prefixRules = rules; } for (int plen = 7; plen >= 2; plen--) { if (s.Length < plen) continue; string head = s.Substring(0, plen); foreach (var rule in rules) { if (rule[0].Length != plen || rule[0] != head) continue; return rule[1]; } } return null; } /// 打开 DeepSeek 设置窗口(运行时创建,不改 ui.cs) private void ShowDeepSeekSettings() { var cfg = LoadDsConfig(); var form = new System.Windows.Forms.Form(); form.Text = "DeepSeek AI 匹配设置"; form.Size = new System.Drawing.Size(420, 260); form.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; form.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; form.MaximizeBox = false; form.MinimizeBox = false; var lblKey = new System.Windows.Forms.Label { Text = "API Key:", Location = new System.Drawing.Point(15, 18), AutoSize = true }; var txtKey = new System.Windows.Forms.TextBox { Location = new System.Drawing.Point(80, 15), Size = new System.Drawing.Size(310, 23), Text = cfg.ApiKey, UseSystemPasswordChar = true }; var chkShow = new System.Windows.Forms.CheckBox { Text = "显示密钥", Location = new System.Drawing.Point(80, 44), AutoSize = true }; chkShow.CheckedChanged += (s2, e2) => txtKey.UseSystemPasswordChar = !chkShow.Checked; var lblModel = new System.Windows.Forms.Label { Text = "模型:", Location = new System.Drawing.Point(15, 78), AutoSize = true }; var cboModel = new System.Windows.Forms.ComboBox { Location = new System.Drawing.Point(80, 74), Size = new System.Drawing.Size(180, 23), DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList }; cboModel.Items.Add("deepseek-v4-flash"); cboModel.Items.Add("deepseek-v4-pro"); cboModel.Items.Add("deepseek-chat"); cboModel.SelectedItem = string.IsNullOrEmpty(cfg.Model) ? "deepseek-v4-flash" : cfg.Model; var chkEnable = new System.Windows.Forms.CheckBox { Text = "启用 AI 匹配(本地匹配失败后自动提交 DeepSeek 识别)", Location = new System.Drawing.Point(15, 108), AutoSize = true, Checked = cfg.Enabled }; var lblTip = new System.Windows.Forms.Label { Text = "提示:AI 结果会缓存到本地,相同规格只请求一次。\nAPI Key 从 platform.deepseek.com 获取。", Location = new System.Drawing.Point(15, 138), AutoSize = true, ForeColor = System.Drawing.Color.Gray }; var btnClear = new System.Windows.Forms.Button { Text = "清空AI缓存", Location = new System.Drawing.Point(80, 178), Size = new System.Drawing.Size(100, 28) }; btnClear.Click += (s2, e2) => { try { if (File.Exists(DeepSeekCachePath)) File.Delete(DeepSeekCachePath); _dsCache = null; } catch { } MessageBox.Show(form, "AI 缓存已清空。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); }; var btnOk = new System.Windows.Forms.Button { Text = "保存", Location = new System.Drawing.Point(240, 178), Size = new System.Drawing.Size(75, 28) }; var btnCancel = new System.Windows.Forms.Button { Text = "取消", Location = new System.Drawing.Point(325, 178), Size = new System.Drawing.Size(75, 28) }; btnOk.Click += (s2, e2) => { cfg.ApiKey = txtKey.Text.Trim(); cfg.Model = cboModel.SelectedItem == null ? "deepseek-v4-flash" : cboModel.SelectedItem.ToString(); cfg.Enabled = chkEnable.Checked && cfg.ApiKey.Length > 0; SaveDsConfig(cfg); form.Close(); }; btnCancel.Click += (s2, e2) => form.Close(); form.Controls.AddRange(new System.Windows.Forms.Control[] { lblKey, txtKey, chkShow, lblModel, cboModel, chkEnable, lblTip, btnClear, btnOk, btnCancel }); form.ShowDialog(this); } /// /// 调 DeepSeek API 批量识别规格对应的元器件名称。 /// 返回 规格→名称 的哈希表(失败的规格不在结果里)。 /// private System.Collections.Hashtable CallDeepSeekMatch(List specs) { var result = new System.Collections.Hashtable(); var cfg = LoadDsConfig(); if (!cfg.Enabled || string.IsNullOrWhiteSpace(cfg.ApiKey)) return result; // 构造提问:证据链推理法(查厂商→确认型号→按厂商定义归类) var sb = new System.Text.StringBuilder(); sb.Append("你是低压电器型号识别专家,精通 JB/T 2930《低压电器产品型号编制方法》。为每个规格型号返回【元器件名称】(中文标准品类词)。\n\n"); sb.Append("【推理方法】(对每个型号按证据链三步走,不要直接猜):\n"); sb.Append("第1步·识别厂商:从型号前缀判断是哪家厂(如 HUWI→沪工/环宇系、NM→正泰、CDM→德力西、CM→常熟、CW→常熟框架、SZRM→上海人民、XBK→湘湖/新宝凯、QAM→七仪);\n"); sb.Append("第2步·确认系列:该厂商的此系列号是否存在、属于什么产品线(用你的产品知识回忆厂商型录:如 HUWI=沪工智能型万能式、YLEM=某厂智能框架、BM3=塑壳);\n"); sb.Append("第3步·按厂商定义归类:厂商型录里该系列挂哪个品类就答什么,不要凭字母表面猜。\n\n"); sb.Append("【辅助规律】(第2步无记忆时用):\n"); sb.Append("· 国标类组:前2位字母=品类(DW万能断路器/DZ塑壳/CJ交流接触器/JR热继/LA按钮/HD-HS-HR隔离开关/RT熔断器);\n"); sb.Append("· 结构特征:电流≥1600A+3P/4P+含W → 框架级(万能式/智能断路器);壳架≤630A+3P → 塑壳;63A以下+1P/2P → 微型;\n"); sb.Append("· 派生代号:L/E=漏电或电子式,I=智能型,Z=直流/智能,N=可逆;\n"); sb.Append("· 进口系列:施耐德(NSX/iC65/Vigi/LC1/LR)、ABB(S/SH)、西门子(3RT/5SL)、伊顿(IS)。\n\n"); sb.Append("【输出格式】(严格遵守):\n"); sb.Append("只返回纯 JSON 对象 {\"规格原文\":\"元器件名称\",...},不要任何解释、思考过程或其他文字;\n"); sb.Append("每个规格都必须给出判断:按证据链推理,无法确认厂商和系列时按结构特征推断,实在无据可依才给空字符串;\n"); sb.Append("名称用标准品类词:万能式断路器/智能断路器/塑壳断路器/小型断路器/漏电断路器/交流接触器/直流接触器/热过载继电器/时间继电器/中间继电器/电流互感器/电压互感器/熔断器/快速熔断器/隔离开关/负荷开关/转换开关/按钮/指示灯/行程开关/双电源/电磁起动器/自耦减压起动器/控制变压器/干式变压器/电涌保护器/电容器/电能表/软启动器/变频器等。\n"); sb.Append("规格列表:\n"); for (int i = 0; i < specs.Count; i++) sb.Append((i + 1) + ". " + specs[i] + "\n"); try { // ★ 纯AI知识模式:chat/completions(不走联网搜索,快且稳) // deepseek-v4 系列是思考模型:正式回答在 content,思考过程在 reasoning_content; // 有时 content 空、答案藏在 reasoning_content 末尾 → 两处都提取 string body = "{" + "\"model\":\"" + cfg.Model + "\"," + "\"messages\":[" + "{\"role\":\"user\",\"content\":\"" + JsonEscape(sb.ToString()) + "\"}" + "]," + "\"temperature\":0.1," + "\"max_tokens\":3000" + "}"; string url = (cfg.BaseUrl ?? "https://api.deepseek.com").TrimEnd('/') + "/chat/completions"; var req = (System.Net.HttpWebRequest)System.Net.WebRequest.Create(url); req.Method = "POST"; req.ContentType = "application/json"; req.Headers.Add("Authorization", "Bearer " + cfg.ApiKey); req.Timeout = 60000; // 60秒 req.ReadWriteTimeout = 60000; req.Proxy = null; // ★ 不走系统代理(防代理环境卡死) byte[] data = System.Text.Encoding.UTF8.GetBytes(body); req.ContentLength = data.Length; Log("[DeepSeek] 发起请求: " + specs.Count + " 条规格(AI知识模式), url=" + url); using (var rs = req.GetRequestStream()) rs.Write(data, 0, data.Length); string respText; using (var resp = (System.Net.HttpWebResponse)req.GetResponse()) using (var sr = new StreamReader(resp.GetResponseStream(), System.Text.Encoding.UTF8)) respText = sr.ReadToEnd(); Log("[DeepSeek] 响应长度: " + respText.Length + " 字符"); // 从响应提取 content(手工解析,取 choices[0].message.content) string content = ExtractJsonContent(respText); if (string.IsNullOrEmpty(content)) { Log("[DeepSeek] 响应解析失败: " + (respText.Length > 400 ? respText.Substring(0, 400) : respText)); return result; } // ★ 诊断:AI原始输出(排查"返回0条"问题,只打前400字符) Log("[DeepSeek] AI输出内容: " + (content.Length > 400 ? content.Substring(0, 400) + "..." : content)); // content 里是 {"规格":"名称",...} // ★ 思考模型(deepseek-v4)的 content 可能为空,答案在 reasoning_content 末尾: // 思维链最后一般会写出最终 JSON,从 reasoning_content 里找 {...} 提取 var map = SimpleJsonParse(content); if (map.Count == 0) { // 从 reasoning_content 提取最后一个完整 JSON 对象 int rIdx = respText.IndexOf("\"reasoning_content\":\"", System.StringComparison.Ordinal); if (rIdx >= 0) { string reasoning = ExtractEscapedString(respText, rIdx + 21); int braceStart = reasoning.LastIndexOf('{'); if (braceStart >= 0) { // 从最后一个 { 找到配对的 } int depth2 = 0; int end = -1; for (int k = braceStart; k < reasoning.Length; k++) { if (reasoning[k] == '{') depth2++; else if (reasoning[k] == '}') { depth2--; if (depth2 == 0) { end = k; break; } } } if (end > braceStart) { string jsonPart = reasoning.Substring(braceStart, end - braceStart + 1); map = SimpleJsonParse(jsonPart); Log("[DeepSeek] 从思维链提取到 " + map.Count + " 条结果"); } } } } foreach (System.Collections.DictionaryEntry kv in map) { string key = kv.Key.ToString(); string name = (kv.Value ?? "").ToString().Trim(); if (name.Length == 0) continue; // ★ 防线:AI 结果先过本地前缀规则校验,冲突时以规则为准 // (根治 AI 偶发误判,如把 HGL 隔离开关答成熔断器) string rule = MatchByPrefixRule(key); if (rule != null && rule != name) { Log("[DeepSeek校验] 「" + key + "」 AI答[" + name + "] 与规则冲突,已改用[" + rule + "]"); name = rule; } result[key] = name; } Log("[DeepSeek] 识别 " + specs.Count + " 条,返回 " + result.Count + " 条结果"); } catch (Exception ex) { Log("[DeepSeek] 请求失败: " + ex.Message); } return result; } /// /// 从 API 响应中提取模型输出文本。 /// 兼容两种格式: /// · chat/completions: choices[0].message.content("content":"...") /// · responses API: output[].content[].text("text":"...")或顶层"output_text" /// private static string ExtractJsonContent(string respText) { try { // 方式1:responses API 的 "output_text":"..." int idx = respText.IndexOf("\"output_text\":\"", System.StringComparison.Ordinal); if (idx >= 0) { string s = ExtractEscapedString(respText, idx + 15); if (!string.IsNullOrEmpty(s)) return s; } // 方式2:responses API 的输出项文本 "text":"..."(跳过 summary/reasoning, // 直接找最后一个大 text——输出 message 的正文通常最大) int best = -1, bestLen = 0; int pos = 0; while (true) { int t = respText.IndexOf("\"text\":\"", pos, System.StringComparison.Ordinal); if (t < 0) break; // 取这个字符串的长度估算 int len = EstimateEscapedLen(respText, t + 8); if (len > bestLen) { bestLen = len; best = t; } pos = t + 8; } if (best >= 0) { string s = ExtractEscapedString(respText, best + 8); if (!string.IsNullOrEmpty(s)) return s; } // 方式3:chat 的 "content":"..." idx = respText.IndexOf("\"content\":\"", System.StringComparison.Ordinal); if (idx >= 0) { return ExtractEscapedString(respText, idx + 11); } return null; } catch { return null; } } /// 从 start 位置读取一个 JSON 转义字符串(处理 \n \" \\ \uXXXX)。 private static string ExtractEscapedString(string text, int start) { var sb = new System.Text.StringBuilder(); int i = start; while (i < text.Length) { char c = text[i]; if (c == '\\' && i + 1 < text.Length) { char n = text[i + 1]; if (n == 'n') sb.Append('\n'); else if (n == 't') sb.Append('\t'); else if (n == 'r') sb.Append('\r'); else if (n == '"') sb.Append('"'); else if (n == '\\') sb.Append('\\'); else if (n == '/' ) sb.Append('/'); else if (n == 'u' && i + 5 < text.Length) { int code = Convert.ToInt32(text.Substring(i + 2, 4), 16); sb.Append((char)code); i += 4; } else sb.Append(n); i += 2; continue; } if (c == '"') break; sb.Append(c); i++; } return sb.ToString(); } /// 估算 start 位置转义字符串的长度(到未转义引号为止)。 private static int EstimateEscapedLen(string text, int start) { int i = start, len = 0; while (i < text.Length) { char c = text[i]; if (c == '\\' && i + 1 < text.Length) { i += 2; len += 2; continue; } if (c == '"') break; i++; len++; } return len; } private static string JsonEscape(string s) { if (string.IsNullOrEmpty(s)) return ""; var sb = new System.Text.StringBuilder(); foreach (char c in s) { if (c == '"') sb.Append("\\\""); else if (c == '\\') sb.Append("\\\\"); else if (c == '\n') sb.Append("\\n"); else if (c == '\t') sb.Append("\\t"); else if (c == '\r') sb.Append(""); else sb.Append(c); } return sb.ToString(); } /// /// 扒元件插入后:对本地匹配失败的规格异步调用 DeepSeek 补名称, /// 完成后回填数据库 + 刷新表格(UI 线程)。 /// ★ 单飞+排队:上一个 AI 批次未完成时,后续规格累积到待办队列, /// 完成后一并提交(防并发互踩/请求堆积)。 /// private static readonly object _aiLock = new object(); private static bool _aiRunning = false; private static List _aiPendingSpecs = new List(); private static string _aiPendingTbl = null; private void AiFillMissingNames(List failedSpecs, string tblName) { if (failedSpecs == null || failedSpecs.Count == 0) return; var cfg = LoadDsConfig(); if (!cfg.Enabled || string.IsNullOrWhiteSpace(cfg.ApiKey)) return; lock (_aiLock) { // 并发防抖:AI 批次进行中 → 累积到待办,由运行中的批次完成后接力 if (_aiRunning) { foreach (string sp in failedSpecs) if (!_aiPendingSpecs.Contains(sp)) _aiPendingSpecs.Add(sp); if (_aiPendingTbl == null) _aiPendingTbl = tblName; Log("[AI队列] 批次进行中," + failedSpecs.Count + " 条已加入待办(累计 " + _aiPendingSpecs.Count + ")"); return; } _aiRunning = true; _aiPendingTbl = tblName; } RunAiBatch(failedSpecs, tblName); } /// 执行一个 AI 批次(完成后处理待办队列)。 private void RunAiBatch(List specs, string tblName) { var bgw = new System.ComponentModel.BackgroundWorker(); bgw.DoWork += (s, e) => { try { // 先查缓存,未缓存的才提交 AI var cache = LoadDsCache(); var toAsk = new List(); var localHits = new System.Collections.Hashtable(); lock (_aiLock) { foreach (string sp in specs) { if (cache.ContainsKey(sp)) { string cached = (cache[sp] ?? "").ToString(); // ★ 规则有结果但缓存为空(AI曾未识别) → 用规则(补漏) string rule = MatchByPrefixRule(sp); if (rule != null && rule.Length > 0 && rule != cached) { Log("[缓存校验] 「" + sp + "」 缓存[" + cached + "]与规则冲突,已纠正为[" + rule + "]"); cached = rule; cache[sp] = rule; } if (cached.Length > 0) localHits[sp] = cached; } else if (!toAsk.Contains(sp)) toAsk.Add(sp); } } var ai = new System.Collections.Hashtable(); if (toAsk.Count > 0) { ai = CallDeepSeekMatch(toAsk); // 写缓存(包括未命中的,避免反复问) lock (_aiLock) { foreach (string sp in toAsk) cache[sp] = ai.Contains(sp) ? ai[sp].ToString() : ""; SaveDsCache(); } } // 合并结果 var final = new System.Collections.Hashtable(); foreach (System.Collections.DictionaryEntry kv in localHits) final[kv.Key] = kv.Value; foreach (System.Collections.DictionaryEntry kv in ai) final[kv.Key] = kv.Value; e.Result = final; } catch (Exception ex) { Log("[AI回填] 后台异常: " + ex); e.Result = new System.Collections.Hashtable(); } }; bgw.RunWorkerCompleted += (s, e) => { // ★ 无论成败都处理:先取走待办队列并释放单飞标记 List pending; string pendingTbl; lock (_aiLock) { pending = new List(_aiPendingSpecs); pendingTbl = _aiPendingTbl; _aiPendingSpecs = new List(); _aiPendingTbl = null; _aiRunning = false; } try { var final = e.Result as System.Collections.Hashtable; if (final == null || final.Count == 0) { if (pending.Count > 0) StartNextAiBatch(pending, pendingTbl); return; } int updated = 0; string dbPath = GetQuoteDbPath(_currentProjectId); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var tx = conn.BeginTransaction()) { foreach (System.Collections.DictionaryEntry kv in final) { using (var cmd = new System.Data.SQLite.SQLiteCommand( "UPDATE \"" + tblName + "\" SET 元件名称=@name WHERE 规格=@spec AND (元件名称 IS NULL OR 元件名称='');", conn)) { cmd.Parameters.AddWithValue("@name", kv.Value.ToString()); cmd.Parameters.AddWithValue("@spec", kv.Key.ToString()); updated += cmd.ExecuteNonQuery(); } } tx.Commit(); } } if (updated > 0) { Log("[AI回填] 已回填 " + updated + " 条元件名称"); _yuanjianCache.Clear(); ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); } // ★ AI 识别成功的结果追加进用户库 CSV(库越用越全, // 下次同规格本地直接命中,不再消耗 AI) AppendAiResultsToUserLibCsv(final); } catch (Exception ex) { Log("[AI回填] 回填异常: " + ex.Message); } // ★ 接力:待办队列有货 → 继续下一个批次 if (pending.Count > 0) StartNextAiBatch(pending, pendingTbl); }; bgw.RunWorkerAsync(); } /// 启动下一个 AI 批次(带单飞保护)。 private void StartNextAiBatch(List specs, string tblName) { lock (_aiLock) { if (_aiRunning) { foreach (string sp in specs) if (!_aiPendingSpecs.Contains(sp)) _aiPendingSpecs.Add(sp); return; } _aiRunning = true; _aiPendingTbl = tblName; } Log("[AI队列] 接力下一批 " + specs.Count + " 条"); RunAiBatch(specs, tblName); } /// /// 把 AI 识别结果(规格→名称)追加到最新用户库 CSV(GBK 编码), /// 并同步更新内存库 _specNameLib(当前会话立即生效)。 /// 已存在的规格跳过;每条占一行:名称,规格,只,1,0,1,1,AI识别,0,元件 /// private void AppendAiResultsToUserLibCsv(System.Collections.Hashtable aiResults) { try { if (aiResults == null || aiResults.Count == 0) return; // 找最新 CSV(与 EnsureUserLibCsvLoaded 同规则:上级目录+插件目录取最新) string best = null; DateTime bestTime = DateTime.MinValue; var dirs = new List(); try { var parent = Path.GetDirectoryName(_baseDir); if (!string.IsNullOrEmpty(parent) && Directory.Exists(parent)) dirs.Add(parent); } catch { } dirs.Add(_baseDir); foreach (string d in dirs) { try { foreach (string f in Directory.GetFiles(d, "用户库*.csv")) { DateTime t = File.GetLastWriteTime(f); if (best == null || t > bestTime) { best = f; bestTime = t; } } } catch { } } if (best == null) { Log("[AI入库] 未找到用户库CSV,跳过"); return; } // ★ 共享读写:文件可能被 Excel 打开 var gbk = System.Text.Encoding.GetEncoding("GBK"); var existing = new HashSet(StringComparer.OrdinalIgnoreCase); var fs = System.IO.File.Open(best, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using (var sr = new StreamReader(fs, gbk)) { string line; while ((line = sr.ReadLine()) != null) { if (string.IsNullOrWhiteSpace(line)) continue; int comma = line.IndexOf(','); if (comma > 0) existing.Add(line.Substring(comma + 1).Trim()); } } // 组装新增行 var newLines = new List(); foreach (System.Collections.DictionaryEntry kv in aiResults) { string spec = kv.Key.ToString().Trim(); string name = kv.Value.ToString().Trim(); if (spec.Length == 0 || name.Length == 0) continue; if (existing.Contains(spec)) continue; newLines.Add(name + "," + spec + ",只,1,0,1,1,AI识别,0,元件"); existing.Add(spec); // 防同批重复 } if (newLines.Count == 0) { Log("[AI入库] 全部已存在,无新增"); return; } // 追加写(共享读写防 Excel 锁;失败重试3次——扒图进程可能正读CSV) bool written = false; for (int attempt = 0; attempt < 3 && !written; attempt++) { try { using (var fs2 = new FileStream(best, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) using (var sw = new StreamWriter(fs2, gbk)) { foreach (string nl in newLines) sw.WriteLine(nl); } written = true; } catch (System.IO.IOException) { System.Threading.Thread.Sleep(300); // 等 CSV 读取方释放 } } if (!written) Log("[AI入库] 写入失败(文件被占用),跳过本次(下次扒图会再尝试)"); // ★ 同步内存库:当前会话的本地匹配立即可用 try { EnsureUserLibCsvLoaded(); if (_specNameLib != null) { foreach (string nl in newLines) { string[] cols = nl.Split(','); if (cols.Length >= 2) _specNameLib.Add(new SpecNameEntry { Name = cols[0], NormSpec = NormalizeSpec(cols[1]), Numbers = ExtractNumbers(cols[1]), Skeleton = ExtractSkeleton(cols[1]) }); } } } catch { } Log("[AI入库] 新增 " + newLines.Count + " 条到 " + Path.GetFileName(best)); } catch (Exception ex) { Log("[AI入库] 失败: " + ex.Message); } } /// 根据项目ID和图号获取箱号表名(查"表索引"表)。 private string GetQuoteTableName(string projectId, string tuhao) { try { string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return ""; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 表名 FROM " + IndexTableName + " WHERE 图号=@t LIMIT 1;", conn)) { cmd.Parameters.AddWithValue("@t", tuhao); object o = cmd.ExecuteScalar(); return o == null ? "" : o.ToString(); } } } catch { return ""; } } private void InsertTuhao(string projectId, string tuhao, string frameHandle, string textHandle) { if (string.IsNullOrWhiteSpace(projectId)) return; EnsureQuoteDbExists(projectId); string dbPath = GetQuoteDbPath(projectId); // ★ 内部ID=创建时的图号名(与主窗口同款约定,改名不影响归属/删除) using (var connEns = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { connEns.Open(); EnsureColumns(connEns, TuhaoTableName, new[] { "内部ID" }); } string sql = "INSERT INTO " + TuhaoTableName + " (序号, 图号, 内部ID, 台数, 箱柜数量, 单价, 总价, 成本总价, 毛利, 毛利率, 框Handle, 文本Handle) " + "VALUES (@seq, @th, @nid, @taishu, 0, 0, 0, 0, 0, 0, @frameHandle, @textHandle);"; string seq = GetNextSeqNumber(TuhaoTableName, "图号", projectId); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@seq", seq); cmd.Parameters.AddWithValue("@th", tuhao); // ★ 真隔离修正(2026-09-30):图号内部ID=显示名(与箱号/元件表索引的图号键一致,不能用 GUID) cmd.Parameters.AddWithValue("@nid", tuhao); cmd.Parameters.AddWithValue("@taishu", "1"); cmd.Parameters.AddWithValue("@frameHandle", frameHandle ?? ""); cmd.Parameters.AddWithValue("@textHandle", textHandle ?? ""); cmd.ExecuteNonQuery(); } } } private void DeleteTuhao(string projectId, string tuhao) { if (string.IsNullOrWhiteSpace(projectId)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // ★ 双键(2026-09-21 残留根治):先查该图号的内部ID,显示名+内部ID两种键 // 都清——老数据按显示名登记、主窗口按内部ID登记,单一键必留孤儿 string thId = ""; using (var selId = new System.Data.SQLite.SQLiteCommand( "SELECT 内部ID FROM " + TuhaoTableName + " WHERE 图号 = @th LIMIT 1;", conn)) { selId.Parameters.AddWithValue("@th", tuhao); using (var rd = selId.ExecuteReader()) { if (rd.Read() && !rd.IsDBNull(0)) thId = rd.GetString(0); } } var tablesToDrop = new List(); string selIdx = "SELECT 实际表名 FROM 表索引 WHERE 图号 = @k;"; foreach (string key in new[] { tuhao, thId }) { if (string.IsNullOrWhiteSpace(key)) continue; using (var sel = new System.Data.SQLite.SQLiteCommand(selIdx, conn)) { sel.Parameters.AddWithValue("@k", key); using (var reader = sel.ExecuteReader()) { while (reader.Read()) { string realTbl = reader.IsDBNull(0) ? "" : reader.GetString(0); if (!string.IsNullOrWhiteSpace(realTbl) && !tablesToDrop.Contains(realTbl)) tablesToDrop.Add(realTbl); } } } } foreach (string realTbl in tablesToDrop) { using (var dropCmd = new System.Data.SQLite.SQLiteCommand( "DROP TABLE IF EXISTS \"" + realTbl + "\";", conn)) dropCmd.ExecuteNonQuery(); } using (var cmd = new System.Data.SQLite.SQLiteCommand( "DELETE FROM " + TuhaoTableName + " WHERE 图号 = @th OR 内部ID = @nid;", conn)) { cmd.Parameters.AddWithValue("@th", tuhao); cmd.Parameters.AddWithValue("@nid", thId.Length > 0 ? thId : tuhao); cmd.ExecuteNonQuery(); } using (var cmd = new System.Data.SQLite.SQLiteCommand( "DELETE FROM 表索引 WHERE 图号 = @th OR 图号 = @nid;", conn)) { cmd.Parameters.AddWithValue("@th", tuhao); cmd.Parameters.AddWithValue("@nid", thId.Length > 0 ? thId : tuhao); cmd.ExecuteNonQuery(); } } } private void InsertXianghao(string projectId, string tuhao, string xianghao, string frameHandle, string textHandle, string drawingName = null) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao)) return; EnsureQuoteDbExists(projectId); string dbPath = GetQuoteDbPath(projectId); string tblName = GetXianghaoTableByIndex(projectId, tuhao); if (string.IsNullOrEmpty(tblName)) { tblName = GetNextTableId(projectId, "xh"); CreateXianghaoTable(projectId, tblName); AddTableIndex(projectId, "箱号", tuhao, "", tblName); } // ★ 确保旧箱号表也有"箱名"/"图纸"/"框Handle"/"文本Handle"/"内部ID"列 using (var connEns = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { connEns.Open(); EnsureColumns(connEns, "\"" + tblName + "\"", new[] { "箱名", "图纸", "框Handle", "文本Handle", "内部ID" }); } string seq = GetXianghaoCount(projectId, tuhao); // drawingName=扒图时当前打开的图纸名(去扩展名),写入「图纸」列;右键新建等其它调用不传=空 string sql = "INSERT INTO \"" + tblName + "\"" + " (序号, 箱号, 内部ID, 箱名, 单位, 数量, 图纸," + " 单台成本, 成本价, 单台报出, 报出价, 利润, 利润率, 毛利率, 框Handle, 文本Handle) " + "VALUES (@seq, @xh, @nid, @xm, @unit, @shuliang, @tz, 0, 0, 0, 0, 0, 0, 0, @frameHandle, @textHandle);"; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@seq", seq); cmd.Parameters.AddWithValue("@xh", xianghao); // ★ 真隔离(2026-09-29):内部ID 用新 GUID,不再等于显示名, // 杜绝同名箱号实例共用同一元件表索引(根治串数据) cmd.Parameters.AddWithValue("@nid", System.Guid.NewGuid().ToString("N")); cmd.Parameters.AddWithValue("@xm", ""); // 箱名默认空,由用户在表格里填写 cmd.Parameters.AddWithValue("@unit", "台"); cmd.Parameters.AddWithValue("@shuliang", "1"); cmd.Parameters.AddWithValue("@tz", drawingName ?? ""); cmd.Parameters.AddWithValue("@frameHandle", frameHandle ?? ""); cmd.Parameters.AddWithValue("@textHandle", textHandle ?? ""); cmd.ExecuteNonQuery(); } } } private void DeleteXianghao(string projectId, string tuhao, string xianghao) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; string yjTbl = GetYuanjianTableByIndex(projectId, tuhao, xianghao); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string xhTbl = GetXianghaoTableByIndex(projectId, tuhao); // ★ 双键(2026-09-21 残留根治):查内部ID,显示名+内部ID两种组合都清, // 单一键匹配不到另一套登记的元件表/索引行必留孤儿(AI 清单会读到) string thId = ""; using (var selId = new System.Data.SQLite.SQLiteCommand( "SELECT 内部ID FROM " + TuhaoTableName + " WHERE 图号 = @th LIMIT 1;", conn)) { selId.Parameters.AddWithValue("@th", tuhao); using (var rd = selId.ExecuteReader()) { if (rd.Read() && !rd.IsDBNull(0)) thId = rd.GetString(0); } } string xhId = ""; if (!string.IsNullOrEmpty(xhTbl)) { using (var selId = new System.Data.SQLite.SQLiteCommand( "SELECT 内部ID FROM \"" + xhTbl + "\" WHERE 箱号 = @xh LIMIT 1;", conn)) { selId.Parameters.AddWithValue("@xh", xianghao); using (var rd = selId.ExecuteReader()) { if (rd.Read() && !rd.IsDBNull(0)) xhId = rd.GetString(0); } } } // 元件表:两套键的索引行都找出来 DROP var yjDrop = new List(); string selYj = "SELECT 实际表名 FROM 表索引 WHERE 类型='元件' AND 图号=@t AND 箱号=@x;"; var keys = new[] { new[] { tuhao, xianghao }, thId.Length > 0 && xhId.Length > 0 ? new[] { thId, xhId } : null }; foreach (var k in keys) { if (k == null) continue; using (var sel = new System.Data.SQLite.SQLiteCommand(selYj, conn)) { sel.Parameters.AddWithValue("@t", k[0]); sel.Parameters.AddWithValue("@x", k[1]); using (var rd = sel.ExecuteReader()) { while (rd.Read()) { string t = rd.IsDBNull(0) ? "" : rd.GetString(0); if (!string.IsNullOrWhiteSpace(t) && !yjDrop.Contains(t)) yjDrop.Add(t); } } } } foreach (string t in yjDrop) { using (var dropCmd = new System.Data.SQLite.SQLiteCommand( "DROP TABLE IF EXISTS \"" + t + "\";", conn)) dropCmd.ExecuteNonQuery(); } if (!string.IsNullOrEmpty(yjTbl)) { using (var cmd = new System.Data.SQLite.SQLiteCommand( "DROP TABLE IF EXISTS \"" + yjTbl + "\";", conn)) cmd.ExecuteNonQuery(); } if (!string.IsNullOrEmpty(xhTbl)) { using (var cmd = new System.Data.SQLite.SQLiteCommand( "DELETE FROM \"" + xhTbl + "\" WHERE 箱号 = @xh OR 内部ID = @nid;", conn)) { cmd.Parameters.AddWithValue("@xh", xianghao); cmd.Parameters.AddWithValue("@nid", xhId.Length > 0 ? xhId : xianghao); cmd.ExecuteNonQuery(); } } // 元件索引行:两套键都清 using (var cmd = new System.Data.SQLite.SQLiteCommand( "DELETE FROM 表索引 WHERE 类型='元件' AND ((图号=@t AND 箱号=@x) OR (图号=@t2 AND 箱号=@x2));", conn)) { cmd.Parameters.AddWithValue("@t", tuhao); cmd.Parameters.AddWithValue("@x", xianghao); cmd.Parameters.AddWithValue("@t2", thId.Length > 0 ? thId : tuhao); cmd.Parameters.AddWithValue("@x2", xhId.Length > 0 ? xhId : xianghao); cmd.ExecuteNonQuery(); } } } private void InsertYuanjian(string projectId, string tuhao, string xianghao, string name) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao) || string.IsNullOrWhiteSpace(xianghao)) return; EnsureQuoteDbExists(projectId); string dbPath = GetQuoteDbPath(projectId); EnsureYuanjianTableExists(projectId, tuhao, xianghao); string tblName = GetYuanjianTableByIndex(projectId, tuhao, xianghao); if (string.IsNullOrEmpty(tblName)) { System.Windows.Forms.MessageBox.Show("元件表创建失败(表索引未注册),图号=" + tuhao + " 箱号=" + xianghao); return; } string seq; using (var conn0 = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn0.Open(); using (var c = new System.Data.SQLite.SQLiteCommand( "SELECT COUNT(*) FROM \"" + tblName + "\";", conn0)) { int n = 0; object o = c.ExecuteScalar(); if (o != null && o != DBNull.Value) n = Convert.ToInt32(o); seq = (n + 1).ToString(); } } string sql = "INSERT INTO \"" + tblName + "\"" + " (序号, 元件名称, 单位, 数量, 采购系数, 报出系数," + " 表价, 采购单价, 采购价, 报出单价, 报出价, 类型) " + "VALUES (@seq, @name, @unit, @shuliang, @caigou, @baochu, 0, 0, 0, 0, 0, '元件');"; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@seq", seq); cmd.Parameters.AddWithValue("@name", name); cmd.Parameters.AddWithValue("@unit", "只"); cmd.Parameters.AddWithValue("@shuliang", "1"); cmd.Parameters.AddWithValue("@caigou", "1"); cmd.Parameters.AddWithValue("@baochu", "1"); cmd.ExecuteNonQuery(); } } } /// 扒元件专用:把文本写到【规格】列(元件名称留空),同时写入框Handle。 private void InsertYuanjianSpec(string projectId, string tuhao, string xianghao, string spec, string frameHandle) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao) || string.IsNullOrWhiteSpace(xianghao)) return; EnsureQuoteDbExists(projectId); string dbPath = GetQuoteDbPath(projectId); EnsureYuanjianTableExists(projectId, tuhao, xianghao); string tblName = GetYuanjianTableByIndex(projectId, tuhao, xianghao); if (string.IsNullOrEmpty(tblName)) { System.Windows.Forms.MessageBox.Show("元件表创建失败(表索引未注册),图号=" + tuhao + " 箱号=" + xianghao); return; } string seq; using (var conn0 = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn0.Open(); using (var c = new System.Data.SQLite.SQLiteCommand( "SELECT COUNT(*) FROM \"" + tblName + "\";", conn0)) { int n = 0; object o = c.ExecuteScalar(); if (o != null && o != DBNull.Value) n = Convert.ToInt32(o); seq = (n + 1).ToString(); } } // ★ 写入"规格"列 + 框Handle string sql = "INSERT INTO \"" + tblName + "\"" + " (序号, 元件名称, 规格, 单位, 数量, 采购系数, 报出系数," + " 表价, 采购单价, 采购价, 报出单价, 报出价, 类型, 框Handle) " + "VALUES (@seq, '', @spec, @unit, @shuliang, @caigou, @baochu, 0, 0, 0, 0, 0, '元件', @frameHandle);"; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@seq", seq); cmd.Parameters.AddWithValue("@spec", spec); cmd.Parameters.AddWithValue("@unit", "只"); cmd.Parameters.AddWithValue("@shuliang", "1"); cmd.Parameters.AddWithValue("@caigou", "1"); cmd.Parameters.AddWithValue("@baochu", "1"); cmd.Parameters.AddWithValue("@frameHandle", frameHandle ?? ""); cmd.ExecuteNonQuery(); } } } private void DeleteYuanjian(string projectId, string tuhao, string xianghao, string seq) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(xianghao)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; string realTbl = GetYuanjianTableByIndex(projectId, tuhao, xianghao); if (string.IsNullOrEmpty(realTbl)) return; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand( "DELETE FROM \"" + realTbl + "\" WHERE 序号 = @seq;", conn)) { cmd.Parameters.AddWithValue("@seq", seq); cmd.ExecuteNonQuery(); } } } // ============================================================ // 数据库回写:把内存 DataTable 的改动写回报价库 // ============================================================ private void SaveTableToDatabase(DataTable table, string projectId, string tableName, string whereClause, Dictionary whereParams, bool force = false) { if (table == null || string.IsNullOrWhiteSpace(projectId)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; DataGridView dgv = null; if (table == _tuhaoTable) dgv = this.advancedDataGridView9; else if (table == _xianghaoTable) dgv = this.advancedDataGridView4; else if (table == _yuanjianTable) dgv = this.advancedDataGridView5; if (dgv != null) dgv.EndEdit(); if (table.Rows.Count == 0) return; if (!force && table.GetChanges() == null) return; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var tx = conn.BeginTransaction()) { // ★ 保留式同步(替代整表 DELETE+INSERT):库里有而内存没有的行才删, // 内存已有的行按 rowid UPDATE——不再盲目清表,消除对其他窗口 // (主程序实时直写)写入的整表级覆盖。 // 兼容:无 _rowid_ 列(旧快照)时退回原整表重写。 bool 保留式 = table.Columns.Contains("_rowid_"); if (保留式) { // 1) 内存表有效行 rowid 集合(排除新增未回填的行) var 内存rowid = new List(); foreach (DataRow row in table.Rows) { if (row.RowState == DataRowState.Deleted) continue; object rid = row["_rowid_"]; if (rid == null || rid == DBNull.Value) continue; long id; try { id = Convert.ToInt64(rid); } catch { continue; } if (id > 0) 内存rowid.Add(id); } // 2) 删库里多出来的行(rowid 不在内存集合中) string 删列 = "_rowid_ NOT IN (" + (内存rowid.Count > 0 ? string.Join(",", 内存rowid.ConvertAll(x => x.ToString()).ToArray()) : "0") + ")"; string delSql2 = "DELETE FROM " + tableName + (string.IsNullOrWhiteSpace(whereClause) ? "" : " WHERE " + whereClause) + (string.IsNullOrWhiteSpace(whereClause) ? " WHERE " : " AND ") + 删列 + ";"; using (var delCmd2 = new System.Data.SQLite.SQLiteCommand(delSql2, conn)) { if (whereParams != null) foreach (var kv in whereParams) delCmd2.Parameters.AddWithValue(kv.Key, kv.Value); delCmd2.ExecuteNonQuery(); } // 3) 内存已有 rowid 的行 → 按 rowid UPDATE 全字段 var colNamesU = new List(table.Columns.Count); foreach (DataColumn c in table.Columns) if (c.ColumnName != "_rowid_") colNamesU.Add(c.ColumnName); var setList = new List(colNamesU.Count); foreach (string c in colNamesU) setList.Add("\"" + c + "\"=@v_" + c); string updSql = "UPDATE " + tableName + " SET " + string.Join(", ", setList.ToArray()) + " WHERE _rowid_=@rid;"; using (var updCmd = new System.Data.SQLite.SQLiteCommand(updSql, conn)) { foreach (string c in colNamesU) updCmd.Parameters.Add("@v_" + c, System.Data.DbType.String); updCmd.Parameters.Add("@rid", System.Data.DbType.Int64); try { updCmd.Prepare(); } catch { } foreach (DataRow row in table.Rows) { if (row.RowState == DataRowState.Deleted) continue; object rid = row["_rowid_"]; if (rid == null || rid == DBNull.Value) continue; long id; try { id = Convert.ToInt64(rid); } catch { continue; } if (id <= 0) continue; foreach (string c in colNamesU) { object v = row[c]; updCmd.Parameters["@v_" + c].Value = (v == null || v == DBNull.Value) ? "" : v.ToString(); } updCmd.Parameters["@rid"].Value = id; updCmd.ExecuteNonQuery(); } } // 4) 内存新增的行(无 rowid) → INSERT,并把新 rowid 回填内存(后续编辑可单行 UPDATE) var colNamesI = new List(table.Columns.Count); foreach (DataColumn c in table.Columns) if (c.ColumnName != "_rowid_") colNamesI.Add(c.ColumnName); var insParam = new List(colNamesI.Count); foreach (string c in colNamesI) insParam.Add("@" + c); string insSql = "INSERT INTO " + tableName + " (" + string.Join(", ", colNamesI.ToArray()) + ") VALUES (" + string.Join(", ", insParam.ToArray()) + ");"; using (var insCmd = new System.Data.SQLite.SQLiteCommand(insSql, conn)) { foreach (string c in colNamesI) insCmd.Parameters.Add("@" + c, System.Data.DbType.String); try { insCmd.Prepare(); } catch { } foreach (DataRow row in table.Rows) { if (row.RowState == DataRowState.Deleted) continue; object rid = row["_rowid_"]; if (rid != null && rid != DBNull.Value) { long 已有; try { 已有 = Convert.ToInt64(rid); } catch { 已有 = 0; } if (已有 > 0) continue; // 已 UPDATE 过 } foreach (string c in colNamesI) { object v = row[c]; insCmd.Parameters["@" + c].Value = (v == null || v == DBNull.Value) ? "" : v.ToString(); } insCmd.ExecuteNonQuery(); // 回填新 rowid using (var idCmd = new System.Data.SQLite.SQLiteCommand("SELECT last_insert_rowid();", conn)) { object o = idCmd.ExecuteScalar(); if (o != null && o != DBNull.Value) row["_rowid_"] = Convert.ToInt64(o); } } } tx.Commit(); } else { // —— 旧路径:整表重写(无 _rowid_ 列的兜底,行为与历史一致) —— string delSql = "DELETE FROM " + tableName; if (!string.IsNullOrWhiteSpace(whereClause)) delSql += " WHERE " + whereClause; delSql += ";"; using (var delCmd = new System.Data.SQLite.SQLiteCommand(delSql, conn)) { if (whereParams != null) { foreach (var kv in whereParams) delCmd.Parameters.AddWithValue(kv.Key, kv.Value); } delCmd.ExecuteNonQuery(); } if (table.Rows.Count > 0) { var colNames = new List(table.Columns.Count); foreach (DataColumn c in table.Columns) colNames.Add(c.ColumnName); var paramList = new List(colNames.Count); foreach (string c in colNames) paramList.Add("@" + c); string insertSql = "INSERT INTO " + tableName + " (" + string.Join(", ", colNames.ToArray()) + ") VALUES (" + string.Join(", ", paramList.ToArray()) + ");"; using (var insCmd = new System.Data.SQLite.SQLiteCommand(insertSql, conn)) { foreach (string col in colNames) insCmd.Parameters.Add("@" + col, System.Data.DbType.String); try { insCmd.Prepare(); } catch { } foreach (DataRow row in table.Rows) { if (row.RowState == DataRowState.Deleted) continue; foreach (string col in colNames) { object v = row[col]; insCmd.Parameters["@" + col].Value = (v == null || v == DBNull.Value) ? "" : v.ToString(); } insCmd.ExecuteNonQuery(); } } } tx.Commit(); } } table.AcceptChanges(); } } private void SaveTuhaoTableIfDirty() { SaveTableToDatabase(_tuhaoTable, _currentProjectId, TuhaoTableName, null, null); } private void SaveXianghaoTableIfDirty() { if (_xianghaoTable == null || string.IsNullOrWhiteSpace(_xianghaoTable.TableName)) return; SaveTableToDatabase(_xianghaoTable, _currentProjectId, "\"" + _xianghaoTable.TableName + "\"", null, null); } private void SaveYuanjianTableIfDirty() { if (_yuanjianTable == null || string.IsNullOrWhiteSpace(_yuanjianTable.TableName)) return; SaveTableToDatabase(_yuanjianTable, _currentProjectId, "\"" + _yuanjianTable.TableName + "\"", null, null); } private void SaveAllQuoteTablesIfDirty(bool force = true) { if (string.IsNullOrWhiteSpace(_currentProjectId)) return; try { if (this.advancedDataGridView5 != null) this.advancedDataGridView5.EndEdit(); if (this.advancedDataGridView4 != null) this.advancedDataGridView4.EndEdit(); if (this.advancedDataGridView9 != null) this.advancedDataGridView9.EndEdit(); if (_yuanjianTable != null && !string.IsNullOrWhiteSpace(_yuanjianTable.TableName)) SaveTableToDatabase(_yuanjianTable, _currentProjectId, "\"" + _yuanjianTable.TableName + "\"", null, null, force); if (_xianghaoTable != null && !string.IsNullOrWhiteSpace(_xianghaoTable.TableName)) SaveTableToDatabase(_xianghaoTable, _currentProjectId, "\"" + _xianghaoTable.TableName + "\"", null, null, force); if (_tuhaoTable != null) SaveTableToDatabase(_tuhaoTable, _currentProjectId, TuhaoTableName, null, null, force); } catch { } } private void SaveAllToDatabaseSilent() { if (string.IsNullOrWhiteSpace(_currentProjectId)) return; if (this.advancedDataGridView5 != null) this.advancedDataGridView5.EndEdit(); if (this.advancedDataGridView4 != null) this.advancedDataGridView4.EndEdit(); if (this.advancedDataGridView9 != null) this.advancedDataGridView9.EndEdit(); try { if (_tuhaoTable != null) SaveTableToDatabase(_tuhaoTable, _currentProjectId, TuhaoTableName, null, null, true); foreach (var kv in _xianghaoCache) if (kv.Value != null && !string.IsNullOrWhiteSpace(kv.Value.TableName)) SaveTableToDatabase(kv.Value, _currentProjectId, "\"" + kv.Value.TableName + "\"", null, null, true); foreach (var kv in _yuanjianCache) if (kv.Value != null && !string.IsNullOrWhiteSpace(kv.Value.TableName)) SaveTableToDatabase(kv.Value, _currentProjectId, "\"" + kv.Value.TableName + "\"", null, null, true); RecalcAllFromDatabase(_currentProjectId); ReloadProjectsFromDatabase(); } catch { } } // ============================================================ // 联动刷新:项目 → 图号 → 箱号 → 元件 // ============================================================ private void ReloadProjectsFromDatabase() { string dbPath = GetDbFullPath(ProjectsDbPath); if (!File.Exists(dbPath)) return; var indexTable = new DataTable(); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "SELECT 项目编号, 箱柜台数, 成本总价, 报出总价, 利润, 利润率 FROM " + ProjectsTableName + " ORDER BY rowid;"; using (var adapter = new System.Data.SQLite.SQLiteDataAdapter(sql, conn)) adapter.Fill(indexTable); } var table = new DataTable(); table.Columns.Add("项目编号", typeof(string)); table.Columns.Add("项目名称", typeof(string)); table.Columns.Add("客户名称", typeof(string)); table.Columns.Add("客户地址", typeof(string)); table.Columns.Add("联系人", typeof(string)); table.Columns.Add("联系电话", typeof(string)); table.Columns.Add("项目日期", typeof(string)); table.Columns.Add("箱柜台数", typeof(string)); table.Columns.Add("成本总价", typeof(string)); table.Columns.Add("报出总价", typeof(string)); table.Columns.Add("利润", typeof(string)); table.Columns.Add("利润率", typeof(string)); table.Columns.Add("备注", typeof(string)); foreach (DataRow idxRow in indexTable.Rows) { string projectId = idxRow["项目编号"] == null ? "" : idxRow["项目编号"].ToString().Trim(); if (string.IsNullOrEmpty(projectId)) continue; var info = GetProjectInfoFromQuoteDb(projectId); var row = table.NewRow(); row["项目编号"] = projectId; row["项目名称"] = GetInfoOrDefault(info, "项目名称"); row["客户名称"] = GetInfoOrDefault(info, "客户名称"); row["客户地址"] = GetInfoOrDefault(info, "客户地址"); row["联系人"] = GetInfoOrDefault(info, "联系人"); row["联系电话"] = GetInfoOrDefault(info, "联系电话"); row["项目日期"] = GetInfoOrDefault(info, "项目日期"); row["备注"] = GetInfoOrDefault(info, "备注"); row["箱柜台数"] = idxRow["箱柜台数"] == null ? "" : idxRow["箱柜台数"].ToString(); row["成本总价"] = idxRow["成本总价"] == null ? "" : idxRow["成本总价"].ToString(); row["报出总价"] = idxRow["报出总价"] == null ? "" : idxRow["报出总价"].ToString(); row["利润"] = idxRow["利润"] == null ? "" : idxRow["利润"].ToString(); row["利润率"] = idxRow["利润率"] == null ? "" : idxRow["利润率"].ToString(); table.Rows.Add(row); } this.advancedDataGridView6.AutoGenerateColumns = false; table = ConvertFormulaColumnsToDouble(table); this.advancedDataGridView6.DataSource = table; } private static string GetInfoOrDefault(Dictionary info, string key) { if (info == null) return ""; string v; return info.TryGetValue(key, out v) ? (v ?? "") : ""; } private void ReloadTuhaoByProject(string projectId) { bool outerGuard = _isReloading; _isReloading = true; try { if (string.IsNullOrWhiteSpace(projectId) || !File.Exists(GetQuoteDbPath(projectId))) { this.advancedDataGridView9.DataSource = null; _tuhaoTable = null; _currentTuhao = ""; ReloadXianghaoByTuhao("", ""); return; } string dbPath = GetQuoteDbPath(projectId); string sql = "SELECT rowid AS _rowid_, * FROM " + TuhaoTableName + " ORDER BY CAST(序号 AS INTEGER);"; var table = new DataTable(); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) using (var adapter = new System.Data.SQLite.SQLiteDataAdapter(cmd)) adapter.Fill(table); } table = ConvertFormulaColumnsToDouble(table); this.advancedDataGridView9.AutoGenerateColumns = false; this.advancedDataGridView9.DataSource = table; _tuhaoTable = table; ApplyPriceFormat(this.advancedDataGridView9, _单台小数位文本); _currentTuhao = ""; if (this.advancedDataGridView9.Rows.Count > 0) { SetCurrentCell(this.advancedDataGridView9, 0); _currentTuhao = GetCellValueAsString(this.advancedDataGridView9.Rows[0], "图号"); } ReloadXianghaoByTuhao(_currentProjectId, _currentTuhao); } finally { _isReloading = outerGuard; } } private void ReloadXianghaoByTuhao(string projectId, string tuhao) { bool outerGuard = _isReloading; _isReloading = true; try { if (this.advancedDataGridView4 != null) this.advancedDataGridView4.EndEdit(); if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao) || !File.Exists(GetQuoteDbPath(projectId))) { this.advancedDataGridView4.DataSource = null; _xianghaoTable = null; _currentXianghao = ""; ReloadYuanjianByXianghao("", "", ""); return; } string dbPath = GetQuoteDbPath(projectId); string realTbl = GetXianghaoTableByIndex(projectId, tuhao); if (string.IsNullOrEmpty(realTbl)) { realTbl = GetNextTableId(projectId, "xh"); CreateXianghaoTable(projectId, realTbl); AddTableIndex(projectId, "箱号", tuhao, "", realTbl); } DataTable table; // ★ 禁用缓存命中:每次都从数据库重新读取,确保删除/变更能及时反映到UI if (_xianghaoCache.ContainsKey(tuhao)) _xianghaoCache.Remove(tuhao); { string sql = "SELECT rowid AS _rowid_, * FROM \"" + realTbl + "\" ORDER BY CAST(序号 AS INTEGER);"; table = new DataTable(); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // ★ 老库结构兼容:箱号表可能还没有"箱名"列,读取前幂等补列(表格绑定依赖该列) EnsureColumns(conn, "\"" + realTbl + "\"", new[] { "箱名" }); using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) using (var adapter = new System.Data.SQLite.SQLiteDataAdapter(cmd)) adapter.Fill(table); } table = ConvertFormulaColumnsToDouble(table); table.TableName = realTbl; _xianghaoCache[tuhao] = table; } this.advancedDataGridView4.AutoGenerateColumns = false; this.advancedDataGridView4.DataSource = table; _xianghaoTable = table; ApplyPriceFormat(this.advancedDataGridView4, _单台小数位文本); _currentXianghao = ""; if (this.advancedDataGridView4.Rows.Count > 0) { SetCurrentCell(this.advancedDataGridView4, 0); _currentXianghao = GetCellValueAsString(this.advancedDataGridView4.Rows[0], "箱号"); } ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); } finally { _isReloading = outerGuard; } } private void ReloadYuanjianByXianghao(string projectId, string tuhao, string xianghao) { if (this.advancedDataGridView5 != null) this.advancedDataGridView5.EndEdit(); if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao) || string.IsNullOrWhiteSpace(xianghao) || !File.Exists(GetQuoteDbPath(projectId))) { this.advancedDataGridView5.DataSource = null; _yuanjianTable = null; return; } string realTbl = GetYuanjianTableByIndex(projectId, tuhao, xianghao); string dbPath = GetQuoteDbPath(projectId); string cacheKey = tuhao + "\n" + xianghao; DataTable table; // ★ 禁用缓存命中:每次都从数据库重新读取,确保删除/变更能及时反映到UI if (_yuanjianCache.ContainsKey(cacheKey)) _yuanjianCache.Remove(cacheKey); { table = new DataTable(); if (!string.IsNullOrEmpty(realTbl)) { string sql = "SELECT rowid AS _rowid_, * FROM \"" + realTbl + "\" ORDER BY CAST(序号 AS INTEGER);"; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) using (var adapter = new System.Data.SQLite.SQLiteDataAdapter(cmd)) adapter.Fill(table); } table = ConvertFormulaColumnsToDouble(table); } table.TableName = realTbl; _yuanjianCache[cacheKey] = table; } this.advancedDataGridView5.AutoGenerateColumns = false; this.advancedDataGridView5.DataSource = table; _yuanjianTable = table; ApplyPriceFormat(this.advancedDataGridView5, _单台小数位文本); } // ============================================================ // 公式重算 // ============================================================ private void RecalcYuanjianRow(DataGridViewRow row) { if (row == null) return; decimal biaoJia = GetCellDecimal(row, "表价"); decimal caiGouXiShu = GetCellDecimal(row, "采购系数"); decimal baoChuXiShu = GetCellDecimal(row, "报出系数"); decimal shuLiang = GetCellDecimal(row, "数量"); // ★ A2(2026-09-30):公式统一调 报价公式(与主窗口单一来源) decimal caiGouDanJia, caiGouJia, baoChuDanJia, baoChuJia; 成套报价软件.报价公式.元件(biaoJia, caiGouXiShu, baoChuXiShu, shuLiang, out caiGouDanJia, out caiGouJia, out baoChuDanJia, out baoChuJia); SetCell(row, "采购单价", caiGouDanJia); SetCell(row, "采购价", caiGouJia); SetCell(row, "报出单价", baoChuDanJia); SetCell(row, "报出价", baoChuJia); } private void RecalcXianghaoRowFromYuanjian(DataGridViewRow xianghaoRow) { if (xianghaoRow == null) return; decimal sumCaiGouJia = 0m; decimal sumBaoChuJia = 0m; DataGridView dgv5 = this.advancedDataGridView5; if (dgv5 != null && dgv5.Rows != null) { foreach (DataGridViewRow yj in dgv5.Rows) { if (yj == null || yj.IsNewRow) continue; sumCaiGouJia += GetCellDecimal(yj, "采购价"); sumBaoChuJia += GetCellDecimal(yj, "报出价"); } } decimal shuLiang = GetCellDecimal(xianghaoRow, "数量"); // ★ A2(2026-09-30):公式统一调 报价公式(利润率小数/毛利率百分数值) decimal danTaiChengBen, chengBenJia, danTaiBaoChu, baoChuJia, liRun, liRunLv, maoLiLv; 成套报价软件.报价公式.箱号(sumCaiGouJia, sumBaoChuJia, shuLiang, out danTaiChengBen, out chengBenJia, out danTaiBaoChu, out baoChuJia, out liRun, out liRunLv, out maoLiLv); SetCell(xianghaoRow, "单台成本", danTaiChengBen); SetCell(xianghaoRow, "成本价", chengBenJia); SetCell(xianghaoRow, "单台报出", danTaiBaoChu); SetCell(xianghaoRow, "报出价", baoChuJia); SetCell(xianghaoRow, "利润", liRun); SetCell(xianghaoRow, "利润率", liRunLv); SetCell(xianghaoRow, "毛利率", maoLiLv); } private void RecalcTuhaoRowFromXianghao(DataGridViewRow tuhaoRow) { if (tuhaoRow == null) return; decimal sumShuLiang = 0m; decimal sumBaoChuJia = 0m; decimal sumChengBenJia = 0m; DataGridView dgv4 = this.advancedDataGridView4; if (dgv4 != null && dgv4.Rows != null) { foreach (DataGridViewRow xh in dgv4.Rows) { if (xh == null || xh.IsNewRow) continue; sumShuLiang += GetCellDecimal(xh, "数量"); sumBaoChuJia += GetCellDecimal(xh, "报出价"); sumChengBenJia += GetCellDecimal(xh, "成本价"); } } decimal taiShu = GetCellDecimal(tuhaoRow, "台数"); // ★ A2(2026-09-30):公式统一调 报价公式(总价=单价×台数) decimal xiangGuiShuLiang, danJia, zongJia, chengBenZongJia, maoLi, maoLiLv; 成套报价软件.报价公式.图号(sumShuLiang, sumBaoChuJia, sumChengBenJia, taiShu, out xiangGuiShuLiang, out danJia, out zongJia, out chengBenZongJia, out maoLi, out maoLiLv); SetCell(tuhaoRow, "箱柜数量", xiangGuiShuLiang); SetCell(tuhaoRow, "单价", danJia); SetCell(tuhaoRow, "总价", zongJia); SetCell(tuhaoRow, "成本总价", chengBenZongJia); SetCell(tuhaoRow, "毛利", maoLi); SetCell(tuhaoRow, "毛利率", maoLiLv); RecalcProjectRowFromTuhao(); } private void RecalcProjectRowFromTuhao() { if (string.IsNullOrWhiteSpace(_currentProjectId)) return; decimal sumXiangGuiShuLiang = 0m; decimal sumChengBenZongJia = 0m; decimal sumZongJia = 0m; DataGridView dgv9 = this.advancedDataGridView9; if (dgv9 != null && dgv9.Rows != null) { foreach (DataGridViewRow th in dgv9.Rows) { if (th == null || th.IsNewRow) continue; sumXiangGuiShuLiang += GetCellDecimal(th, "箱柜数量"); sumChengBenZongJia += GetCellDecimal(th, "成本总价"); sumZongJia += GetCellDecimal(th, "总价"); } } decimal xiangGuiTaiShu = sumXiangGuiShuLiang; decimal baoChuZongJia = sumZongJia; decimal liRun = baoChuZongJia - sumChengBenZongJia; // ★ 口径(2026-09-29):项目利润率存小数(保留2位,0.05=5%) decimal liRunLv = sumChengBenZongJia == 0m ? 0m : Math.Round(liRun / sumChengBenZongJia, 2); DataGridViewRow projRow = FindProjectRowById(_currentProjectId); if (projRow != null) { SetCell(projRow, "箱柜台数", xiangGuiTaiShu); SetCell(projRow, "成本总价", sumChengBenZongJia); SetCell(projRow, "报出总价", baoChuZongJia); SetCell(projRow, "利润", liRun); SetCell(projRow, "利润率", liRunLv); } } private void RecalcAllAfterReload() { if (string.IsNullOrWhiteSpace(_currentProjectId)) return; _isRecalculating = true; _isReloading = true; try { DataGridView dgv5 = this.advancedDataGridView5; if (dgv5 != null && dgv5.Rows != null) { foreach (DataGridViewRow yj in dgv5.Rows) { if (yj == null || yj.IsNewRow) continue; RecalcYuanjianRow(yj); } } var xhRow = GetFirstSelectedRow(this.advancedDataGridView4); if (xhRow != null) RecalcXianghaoRowFromYuanjian(xhRow); var thRow = GetFirstSelectedRow(this.advancedDataGridView9); if (thRow != null) RecalcTuhaoRowFromXianghao(thRow); SaveYuanjianTableIfDirty(); SaveXianghaoTableIfDirty(); SaveTuhaoTableIfDirty(); if (_yuanjianTable != null) _yuanjianTable.AcceptChanges(); if (_xianghaoTable != null) _xianghaoTable.AcceptChanges(); if (_tuhaoTable != null) _tuhaoTable.AcceptChanges(); } finally { _isRecalculating = false; _lastRecalcTime = DateTime.Now; _isReloading = false; } } // ---------- 全量数据库重算 ---------- private class TuhaoCalcRow { public long RowId; public decimal TaiShu; public string Tuhao; } private class XianghaoCalcRow { public long RowId; public decimal ShuLiang; public string Xianghao; } private void RecalcAllFromDatabase(string projectId) { if (string.IsNullOrWhiteSpace(projectId)) projectId = _currentProjectId; if (string.IsNullOrWhiteSpace(projectId)) return; string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); var tuhaoRows = new List(); string selTuhao = "SELECT rowid, 台数, 图号 FROM " + TuhaoTableName + " ORDER BY rowid;"; using (var sel = new System.Data.SQLite.SQLiteCommand(selTuhao, conn)) using (var rdr = sel.ExecuteReader()) { while (rdr.Read()) { var t = new TuhaoCalcRow(); t.RowId = rdr.GetInt64(0); t.TaiShu = ParseDecimal(rdr.IsDBNull(1) ? "" : rdr.GetValue(1)); t.Tuhao = rdr.IsDBNull(2) ? "" : rdr.GetValue(2).ToString().Trim(); tuhaoRows.Add(t); } } decimal sumProjXiangGui = 0m; decimal sumProjChengBen = 0m; decimal sumProjZongJia = 0m; foreach (var t in tuhaoRows) { if (string.IsNullOrWhiteSpace(t.Tuhao)) continue; try { string xhTable = GetXianghaoTableByIndex(t.Tuhao, conn); if (string.IsNullOrEmpty(xhTable)) { UpdateTuhaoCalc(conn, t.RowId, 0m, 0m, 0m, 0m, 0m, 0m); continue; } string xhQuoted = "\"" + xhTable + "\""; decimal sumThXiangGuiShuLiang = 0m; decimal sumThBaoChuJia = 0m; decimal sumThChengBenJia = 0m; var xhRows = new List(); string selXh = "SELECT rowid, 数量, 箱号 FROM " + xhQuoted + " ORDER BY rowid;"; using (var sel = new System.Data.SQLite.SQLiteCommand(selXh, conn)) using (var rdr = sel.ExecuteReader()) { while (rdr.Read()) { var x = new XianghaoCalcRow(); x.RowId = rdr.GetInt64(0); x.ShuLiang = ParseDecimal(rdr.IsDBNull(1) ? "" : rdr.GetValue(1)); x.Xianghao = rdr.IsDBNull(2) ? "" : rdr.GetValue(2).ToString(); xhRows.Add(x); } } foreach (var x in xhRows) { try { sumThXiangGuiShuLiang += x.ShuLiang; string yjTable = GetYuanjianTableByIndex(t.Tuhao, x.Xianghao, conn); decimal sumCaiGou = 0m; decimal sumBaoChu = 0m; if (!string.IsNullOrEmpty(yjTable)) { string yjQuoted = "\"" + yjTable + "\""; string selYj = "SELECT rowid, 表价, 采购系数, 报出系数, 数量 FROM " + yjQuoted + " ORDER BY rowid;"; using (var sel = new System.Data.SQLite.SQLiteCommand(selYj, conn)) using (var sr = sel.ExecuteReader()) { while (sr.Read()) { long yjRid = sr.GetInt64(0); decimal yjBiaoJia = ParseDecimal(sr.IsDBNull(1) ? "" : sr.GetValue(1)); decimal yjCgXs = ParseDecimal(sr.IsDBNull(2) ? "" : sr.GetValue(2)); decimal yjBcXs = ParseDecimal(sr.IsDBNull(3) ? "" : sr.GetValue(3)); decimal yjSl = ParseDecimal(sr.IsDBNull(4) ? "" : sr.GetValue(4)); // ★ A2(2026-09-30):公式统一调 报价公式 decimal yjCaiGouDanJia, yjCaiGouJia, yjBaoChuDanJia, yjBaoChuJia; 成套报价软件.报价公式.元件(yjBiaoJia, yjCgXs, yjBcXs, yjSl, out yjCaiGouDanJia, out yjCaiGouJia, out yjBaoChuDanJia, out yjBaoChuJia); sumCaiGou += yjCaiGouJia; sumBaoChu += yjBaoChuJia; UpdateYuanjianCalc(conn, yjQuoted, yjRid, yjCaiGouDanJia, yjCaiGouJia, yjBaoChuDanJia, yjBaoChuJia); } } } // ★ A2(2026-09-30):公式统一调 报价公式(利润率小数/毛利率百分数值) decimal danTaiChengBen, chengBenJia, danTaiBaoChu, baoChuJia, xhLiRun, xhLiRunLv, xhMaoLiLv; 成套报价软件.报价公式.箱号(sumCaiGou, sumBaoChu, x.ShuLiang, out danTaiChengBen, out chengBenJia, out danTaiBaoChu, out baoChuJia, out xhLiRun, out xhLiRunLv, out xhMaoLiLv); sumThBaoChuJia += baoChuJia; sumThChengBenJia += chengBenJia; UpdateXianghaoCalc(conn, xhQuoted, x.RowId, danTaiChengBen, chengBenJia, danTaiBaoChu, baoChuJia, xhLiRun, xhLiRunLv, xhMaoLiLv); } catch { } } // 图号行聚合 ★ A2(2026-09-30):公式统一调 报价公式(总价=单价×台数) decimal xiangGuiShuLiang, danJia, zongJia, chengBenZongJia, maoLi, maoLiLv; 成套报价软件.报价公式.图号(sumThXiangGuiShuLiang, sumThBaoChuJia, sumThChengBenJia, t.TaiShu, out xiangGuiShuLiang, out danJia, out zongJia, out chengBenZongJia, out maoLi, out maoLiLv); sumProjXiangGui += xiangGuiShuLiang; sumProjChengBen += chengBenZongJia; sumProjZongJia += zongJia; UpdateTuhaoCalc(conn, t.RowId, xiangGuiShuLiang, danJia, zongJia, chengBenZongJia, maoLi, maoLiLv); } catch { } } decimal projLiRun = sumProjZongJia - sumProjChengBen; // ★ 口径(2026-09-29):项目利润率存小数(保留2位,0.05=5%) decimal projLiRunLv = sumProjChengBen == 0m ? 0m : Math.Round(projLiRun / sumProjChengBen, 2); UpdateProjectQuoteTotalsById(projectId, sumProjXiangGui, sumProjChengBen, sumProjZongJia, projLiRun, projLiRunLv); } } catch { } } private static void UpdateYuanjianCalc(System.Data.SQLite.SQLiteConnection conn, string yjQuoted, long rowId, decimal caiGouDanJia, decimal caiGouJia, decimal baoChuDanJia, decimal baoChuJia) { string sql = "UPDATE " + yjQuoted + " SET " + "采购单价=@a, 采购价=@b, 报出单价=@c, 报出价=@d WHERE rowid=@rid;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@a", caiGouDanJia == 0m ? (object)DBNull.Value : (object)caiGouDanJia); cmd.Parameters.AddWithValue("@b", caiGouJia == 0m ? (object)DBNull.Value : (object)caiGouJia); cmd.Parameters.AddWithValue("@c", baoChuDanJia == 0m ? (object)DBNull.Value : (object)baoChuDanJia); cmd.Parameters.AddWithValue("@d", baoChuJia == 0m ? (object)DBNull.Value : (object)baoChuJia); cmd.Parameters.AddWithValue("@rid", rowId); cmd.ExecuteNonQuery(); } } private static void UpdateXianghaoCalc(System.Data.SQLite.SQLiteConnection conn, string xhQuoted, long rowId, decimal danTaiChengBen, decimal chengBenJia, decimal danTaiBaoChu, decimal baoChuJia, decimal liRun, decimal liRunLv, decimal maoLiLv) { string sql = "UPDATE " + xhQuoted + " SET " + "单台成本=@a, 成本价=@b, 单台报出=@c, 报出价=@d, " + "利润=@e, 利润率=@f, 毛利率=@g WHERE rowid=@rid;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@a", danTaiChengBen == 0m ? (object)DBNull.Value : (object)danTaiChengBen); cmd.Parameters.AddWithValue("@b", chengBenJia == 0m ? (object)DBNull.Value : (object)chengBenJia); cmd.Parameters.AddWithValue("@c", danTaiBaoChu == 0m ? (object)DBNull.Value : (object)danTaiBaoChu); cmd.Parameters.AddWithValue("@d", baoChuJia == 0m ? (object)DBNull.Value : (object)baoChuJia); cmd.Parameters.AddWithValue("@e", liRun == 0m ? (object)DBNull.Value : (object)liRun); cmd.Parameters.AddWithValue("@f", liRunLv.ToString()); cmd.Parameters.AddWithValue("@g", maoLiLv.ToString()); cmd.Parameters.AddWithValue("@rid", rowId); cmd.ExecuteNonQuery(); } } private static void UpdateTuhaoCalc(System.Data.SQLite.SQLiteConnection conn, long rowId, decimal xiangGuiShuLiang, decimal danJia, decimal zongJia, decimal chengBenZongJia, decimal maoLi, decimal maoLiLv) { string sql = "UPDATE " + TuhaoTableName + " SET " + "箱柜数量=@a, 单价=@b, 总价=@c, 成本总价=@d, 毛利=@e, 毛利率=@f " + "WHERE rowid=@rid;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@a", xiangGuiShuLiang == 0m ? (object)DBNull.Value : (object)xiangGuiShuLiang); cmd.Parameters.AddWithValue("@b", danJia == 0m ? (object)DBNull.Value : (object)danJia); cmd.Parameters.AddWithValue("@c", zongJia == 0m ? (object)DBNull.Value : (object)zongJia); cmd.Parameters.AddWithValue("@d", chengBenZongJia == 0m ? (object)DBNull.Value : (object)chengBenZongJia); cmd.Parameters.AddWithValue("@e", maoLi == 0m ? (object)DBNull.Value : (object)maoLi); cmd.Parameters.AddWithValue("@f", maoLiLv.ToString()); cmd.Parameters.AddWithValue("@rid", rowId); cmd.ExecuteNonQuery(); } } private void UpdateProjectQuoteTotalsById(string projectId, decimal xiangGuiTaiShu, decimal chengBenZongJia, decimal baoChuZongJia, decimal liRun, decimal liRunLv) { if (string.IsNullOrWhiteSpace(projectId)) return; string dbPath = GetDbFullPath(ProjectsDbPath); if (!File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "UPDATE " + ProjectsTableName + " SET " + "箱柜台数=@xgts, 成本总价=@cbzj, 报出总价=@bczj, " + "利润=@lr, 利润率=@lrl WHERE 项目编号=@id;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@xgts", xiangGuiTaiShu == 0m ? (object)DBNull.Value : (object)xiangGuiTaiShu); cmd.Parameters.AddWithValue("@cbzj", chengBenZongJia == 0m ? (object)DBNull.Value : (object)chengBenZongJia); cmd.Parameters.AddWithValue("@bczj", baoChuZongJia == 0m ? (object)DBNull.Value : (object)baoChuZongJia); cmd.Parameters.AddWithValue("@lr", liRun == 0m ? (object)DBNull.Value : (object)liRun); cmd.Parameters.AddWithValue("@lrl", liRunLv.ToString()); cmd.Parameters.AddWithValue("@id", projectId); cmd.ExecuteNonQuery(); } } } catch { } } // ============================================================ // 事件:CellValueChanged / DirtyStateChanged / CellEndEdit // ============================================================ private void Grid_DirtyStateChanged(object sender, EventArgs e) { if (_isRecalculating || _isReloading) return; var msSinceRecalc = (DateTime.Now - _lastRecalcTime).TotalMilliseconds; if (msSinceRecalc < 2000) return; var dgv = sender as DataGridView; if (dgv != null && dgv.IsCurrentCellDirty) dgv.CommitEdit(DataGridViewDataErrorContexts.Commit); } private void Yuanjian_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (_isRecalculating || _isReloading) return; if (e.RowIndex < 0) return; var dgv = this.advancedDataGridView5; if (dgv == null || e.RowIndex >= dgv.Rows.Count) return; var row = dgv.Rows[e.RowIndex]; if (row == null || row.IsNewRow) return; _isRecalculating = true; try { RecalcYuanjianRow(row); var xhRow = GetFirstSelectedRow(this.advancedDataGridView4); if (xhRow != null) { RecalcXianghaoRowFromYuanjian(xhRow); var thRow = GetFirstSelectedRow(this.advancedDataGridView9); if (thRow != null) RecalcTuhaoRowFromXianghao(thRow); } } finally { _isRecalculating = false; _lastRecalcTime = DateTime.Now; } } private void Xianghao_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (_isRecalculating || _isReloading) return; if (e.RowIndex < 0) return; var dgv = this.advancedDataGridView4; if (dgv == null || e.RowIndex >= dgv.Rows.Count) return; var row = dgv.Rows[e.RowIndex]; if (row == null || row.IsNewRow) return; // ★ 箱号重名自动加后缀(同表其他行已有该箱号):改值+刷新,不弹窗 if (_xianghaoTable != null && e.ColumnIndex >= 0) { var col = dgv.Columns[e.ColumnIndex]; string cn = col == null ? "" : (!string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : col.HeaderText); if (cn == "箱号") { var 新 = 重名自动后缀(_xianghaoTable, row, "箱号", row.Cells[e.ColumnIndex].Value); if (!Equals(新, row.Cells[e.ColumnIndex].Value)) { _isRecalculating = true; try { row.Cells[e.ColumnIndex].Value = 新; } finally { _isRecalculating = false; } } } } _isRecalculating = true; try { var thRow = GetFirstSelectedRow(this.advancedDataGridView9); if (thRow != null) RecalcTuhaoRowFromXianghao(thRow); } finally { _isRecalculating = false; _lastRecalcTime = DateTime.Now; } } private void Tuhao_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (_isRecalculating || _isReloading) return; if (e.RowIndex < 0) return; var dgv = this.advancedDataGridView9; if (dgv == null || e.RowIndex >= dgv.Rows.Count) return; var row = dgv.Rows[e.RowIndex]; if (row == null || row.IsNewRow) return; DataGridViewColumn col = e.ColumnIndex >= 0 ? dgv.Columns[e.ColumnIndex] : null; string colName = col == null ? "" : (!string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : col.HeaderText); // ★ 图号重名自动加后缀(同表其他行已有该图号):改值+刷新,不弹窗 if (colName == "图号" && _tuhaoTable != null) { var 新 = 重名自动后缀(_tuhaoTable, row, "图号", row.Cells[e.ColumnIndex].Value); if (!Equals(新, row.Cells[e.ColumnIndex].Value)) { _isRecalculating = true; try { row.Cells[e.ColumnIndex].Value = 新; } finally { _isRecalculating = false; } } } if (colName != "台数") return; _isRecalculating = true; try { RecalcTuhaoRowFromXianghao(row); } finally { _isRecalculating = false; _lastRecalcTime = DateTime.Now; } } /// 图号/箱号重名自动后缀:值与 DataTable 其他行(除 自身行 对应行)重复时返回 "名(2)"/"名(3)"…。 private static object 重名自动后缀(System.Data.DataTable table, DataGridViewRow 自身行, string colName, object 新值) { if (新值 == null || 新值 == DBNull.Value) return 新值; string v = 新值.ToString().Trim(); if (v.Length == 0) return 新值; int 自身Index = 自身行.Index; bool 撞 = false; foreach (System.Data.DataRow r in table.Rows) { if (r.RowState == System.Data.DataRowState.Deleted) continue; if (table.Rows.IndexOf(r) == 自身Index) continue; if (string.Equals((r[colName] == null || r[colName] == DBNull.Value) ? "" : r[colName].ToString().Trim(), v, StringComparison.Ordinal)) { 撞 = true; break; } } if (!撞) return 新值; for (int n = 2; n < 1000; n++) { string 候 = v + "(" + n + ")"; bool 再撞 = false; foreach (System.Data.DataRow r in table.Rows) { if (r.RowState == System.Data.DataRowState.Deleted) continue; if (string.Equals((r[colName] == null || r[colName] == DBNull.Value) ? "" : r[colName].ToString().Trim(), 候, StringComparison.Ordinal)) { 再撞 = true; break; } } if (!再撞) return 候; } return 新值; } /// CellEndEdit:把公式列的字符串值转为 double,让 DefaultCellStyle.Format 生效。 private void Grid_CellEndEdit(object sender, DataGridViewCellEventArgs e) { var dgv = sender as DataGridView; if (dgv == null || e.ColumnIndex < 0 || e.ColumnIndex >= dgv.Columns.Count) return; string dp = dgv.Columns[e.ColumnIndex].DataPropertyName; string cn = dgv.Columns[e.ColumnIndex].HeaderText; string colKey = !string.IsNullOrEmpty(dp) ? dp : cn; // ★ 实时落库:每编辑完一格立即按 rowid 单行 UPDATE(改哪行写哪行,不整表回写) 单格实时落库(dgv, e.RowIndex, colKey); if (!s_公式列集合.Contains(colKey)) return; if (e.RowIndex < 0 || e.RowIndex >= dgv.Rows.Count) return; object v = dgv.Rows[e.RowIndex].Cells[e.ColumnIndex].Value; if (v == null || v is double) return; string str = v.ToString().Trim(); if (string.IsNullOrEmpty(str)) return; double d; if (double.TryParse(str, out d)) { dgv.Rows[e.RowIndex].Cells[e.ColumnIndex].Value = d; // 公式列转 double 后同样实时落库 单格实时落库(dgv, e.RowIndex, colKey); } } /// 单格实时落库:按内存表该行的 _rowid_ 对库单行 UPDATE(毫秒级,不整表回写)。 /// 无 _rowid_(新插入未回填)的行跳过——由切层/关闭时的整表保存兜底。 /// 派生列(采购单价/采购价/报出单价/报出价)跳过——重算链会连同主列一起写入。 private void 单格实时落库(DataGridView dgv, int rowIndex, string colKey) { try { if (dgv == null || rowIndex < 0 || rowIndex >= dgv.Rows.Count) return; if (string.IsNullOrEmpty(colKey) || s_公式列集合.Contains(colKey)) return; if (colKey == "序号" || colKey == "_rowid_") return; System.Data.DataTable table = dgv.DataSource as System.Data.DataTable; if (table == null || string.IsNullOrWhiteSpace(table.TableName)) return; if (!table.Columns.Contains("_rowid_") || !table.Columns.Contains(colKey)) return; if (rowIndex >= table.Rows.Count) return; object rid = table.Rows[rowIndex]["_rowid_"]; if (rid == null || rid == DBNull.Value) return; long rowid; try { rowid = Convert.ToInt64(rid); } catch { return; } if (rowid <= 0) return; object val = table.Rows[rowIndex][colKey]; string dbPath = GetQuoteDbPath(_currentProjectId); if (!File.Exists(dbPath)) return; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand( "UPDATE \"" + table.TableName.Replace("\"", "") + "\" SET \"" + colKey.Replace("\"", "") + "\"=@v WHERE rowid=@r;", conn)) { cmd.Parameters.AddWithValue("@v", (val == null || val == DBNull.Value) ? "" : val.ToString()); cmd.Parameters.AddWithValue("@r", rowid); cmd.ExecuteNonQuery(); } } } catch (Exception exCell) { Log("单格实时落库失败(忽略,整表保存兜底): " + exCell.Message); } } // ============================================================ // SelectionChanged 联动 // ============================================================ private void advancedDataGridView9_SelectionChanged(object sender, EventArgs e) { if (_isReloading) return; if (this.advancedDataGridView9 != null && this.advancedDataGridView9.SelectedCells.Count > 1) return; DataGridViewRow row = GetFirstSelectedRow(this.advancedDataGridView9); if (row == null) return; string tuhao = GetCellValueAsString(row, "图号"); if (tuhao == _currentTuhao) return; // ★ 切图号前先落库当前层(箱号+元件):切层 Reload 换 DataSource,未保存的内存编辑直接丢 try { if (this.advancedDataGridView4 != null) this.advancedDataGridView4.EndEdit(); if (this.advancedDataGridView5 != null) this.advancedDataGridView5.EndEdit(); if (_xianghaoTable != null && !string.IsNullOrWhiteSpace(_xianghaoTable.TableName)) { SaveTableToDatabase(_xianghaoTable, _currentProjectId, "\"" + _xianghaoTable.TableName + "\"", null, null, true); _xianghaoTable.AcceptChanges(); } if (_yuanjianTable != null && !string.IsNullOrWhiteSpace(_yuanjianTable.TableName)) { SaveTableToDatabase(_yuanjianTable, _currentProjectId, "\"" + _yuanjianTable.TableName + "\"", null, null, true); _yuanjianTable.AcceptChanges(); } } catch (Exception exSaveLayer) { Log("切图号前保存失败(忽略): " + exSaveLayer.Message); } _currentTuhao = tuhao; ReloadXianghaoByTuhao(_currentProjectId, _currentTuhao); } private void advancedDataGridView4_SelectionChanged(object sender, EventArgs e) { if (_isReloading) return; if (this.advancedDataGridView4 != null && this.advancedDataGridView4.SelectedCells.Count > 1) return; DataGridViewRow row = GetFirstSelectedRow(this.advancedDataGridView4); if (row == null) return; string xh = GetCellValueAsString(row, "箱号"); if (xh == _currentXianghao) return; // ★ 切箱号前先落库当前元件层(切层 Reload 换 DataSource,未保存编辑直接丢) try { if (this.advancedDataGridView5 != null) this.advancedDataGridView5.EndEdit(); if (_yuanjianTable != null && !string.IsNullOrWhiteSpace(_yuanjianTable.TableName)) { SaveTableToDatabase(_yuanjianTable, _currentProjectId, "\"" + _yuanjianTable.TableName + "\"", null, null, true); _yuanjianTable.AcceptChanges(); } } catch (Exception exSaveLayer) { Log("切箱号前保存失败(忽略): " + exSaveLayer.Message); } _currentXianghao = xh; ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); // ★ 切箱号 → 显示该箱号的组虚线框(只画框不跳转) 显示箱号组虚线框(false); } private void advancedDataGridView5_SelectionChanged(object sender, EventArgs e) { } // ============================================================ // ★ 点击表格行 → 定位到对应的扒图框(缩放居中) // 读取数据行的"框Handle"列,用 Handle 反查 CAD 实体,缩放到实体范围 // ============================================================ private void Grid_CellClick_Zoom(object sender, DataGridViewCellEventArgs e) { if (_isReloading) return; if (e.RowIndex < 0) return; DataGridView dgv = sender as DataGridView; if (dgv == null) return; DataGridViewRow row = dgv.Rows[e.RowIndex]; if (row == null || row.IsNewRow) return; // ★ 直接从 DataRow 读取"框Handle"(不依赖 DataGridView 列映射) string handle = ""; var drv = row.DataBoundItem as DataRowView; if (drv != null && drv.Row != null && drv.Row.Table.Columns.Contains("框Handle")) { object v = drv.Row["框Handle"]; if (v != null && v != DBNull.Value) handle = v.ToString().Trim(); } if (!string.IsNullOrWhiteSpace(handle) && dgv == this.advancedDataGridView9) { ZoomToFrameHandle(handle); // 图号行:维持原单框定位 } // ★ 箱号组虚线框:点箱号表/元件表行 → 跳转缩放到"装下整个组框" // + 点中的框黄色高亮;点图号表行 → 只清组框 if (dgv == this.advancedDataGridView4 || dgv == this.advancedDataGridView5) 显示箱号组虚线框(true, handle); else if (dgv == this.advancedDataGridView9) 成套报价软件.CadBridge.ClearGroupFrames(); } // ============================================================ // ★ 箱号组虚线框:点箱号表/元件表行 → 网页 CAD 画一个橙色虚线矩形, // 把该箱号的箱号图框 + 它元件表全部元件图框 求并集圈在一起 // ============================================================ /// 显示当前 图号+箱号 的组虚线框(查库收集全部框Handle)。 /// 跳转=true:画框同时缩放到装下整个组框(点箱号/元件行跳转); /// focusHandle=点中的那个框:网页端对它画黄色高亮框。 private void 显示箱号组虚线框(bool 跳转, string focusHandle = null) { try { if (string.IsNullOrWhiteSpace(_currentProjectId) || string.IsNullOrWhiteSpace(_currentTuhao) || string.IsNullOrWhiteSpace(_currentXianghao)) return; var handles = new System.Collections.Generic.List(); // 箱号表该行的框Handle(组框包含箱号图框) string xhTbl = GetXianghaoTableByIndex(_currentProjectId, _currentTuhao); if (!string.IsNullOrEmpty(xhTbl)) CollectFrameHandles(xhTbl, "箱号", _currentXianghao, handles); // 元件表全部行的框Handle string yjTbl = GetYuanjianTableByIndex(_currentProjectId, _currentTuhao, _currentXianghao); if (!string.IsNullOrEmpty(yjTbl)) CollectFrameHandles(yjTbl, null, null, handles); if (handles.Count > 0) { if (跳转) 成套报价软件.CadBridge.ZoomToGroupFrames(handles, focusHandle); else 成套报价软件.CadBridge.SetGroupFrames(handles); } } catch (Exception exGrp) { Log("组虚线框显示失败(忽略): " + exGrp.Message); } } /// 从指定元件/箱号表收集框Handle:matchCol/matchVal 非空时只收匹配行,否则收全部非空行。 private void CollectFrameHandles(string realTbl, string matchCol, string matchVal, System.Collections.Generic.List into) { try { string dbPath = GetQuoteDbPath(_currentProjectId); if (!File.Exists(dbPath)) return; string where = matchCol != null ? " WHERE TRIM(" + matchCol + ")=@mv" : ""; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM \"" + realTbl.Replace("\"", "") + "\"" + where + ";", conn)) { if (matchCol != null) cmd.Parameters.AddWithValue("@mv", (matchVal ?? "").Trim()); using (var rd = cmd.ExecuteReader()) { while (rd.Read()) { string h = rd["框Handle"] == null || rd["框Handle"] == DBNull.Value ? "" : rd["框Handle"].ToString().Trim(); if (!string.IsNullOrEmpty(h) && !into.Contains(h)) into.Add(h); } } } } } catch (Exception exC) { Log("收集框Handle失败(" + realTbl + ",忽略): " + exC.Message); } } /// /// 根据框 Handle 字符串定位网页 CAD 视图:缩放居中到框的位置并高亮。 /// Handle 是扒图时绘制的彩色矩形框的句柄(十六进制字符串), /// 与网页端实体 objectId 一致,通过 CadBridge 转发给网页 CAD 执行。 /// private static void ZoomToFrameHandle(string handle) { if (string.IsNullOrWhiteSpace(handle)) return; try { 成套报价软件.CadBridge.ZoomToFrameHandle(handle); } catch { } } /// /// ★ 网页 CAD 会话内的扒图框由网页端持有,无需像 AutoCAD 版那样 /// 定期 SaveAs 整个 DWG(原为防止 CAD 关闭时框丢失)。保留空实现 /// 以兼容调用点。 /// private static DateTime _lastDwgSaveTime = DateTime.MinValue; // ★ 扒元件后待选中的行(框Handle),finally 延迟执行选中 private List _pendingSelectYuanjianHandles = new List(); private static void SaveCurrentDwg() { // 网页版:扒图框保存在网页 CAD 会话中,无需保存 DWG 文件。 } /// /// ★ 删除指定 Handle 对应的 CAD 框实体(单个,转发到批量方法)。 /// private void DeleteFrameByHandle(string handle) { DeleteFramesByHandles(new string[] { handle }); } /// /// ★ 批量删除多个 Handle 对应的框:打包成一条消息发给网页 CAD, /// 网页端逐个 erase 后只 regen 一次(保持原版"批量一次刷新"的优化)。 /// private void DeleteFramesByHandles(System.Collections.Generic.IEnumerable handles) { var list = new List(); if (handles != null) foreach (string h in handles) if (!string.IsNullOrWhiteSpace(h)) list.Add(h); if (list.Count == 0) { Log("[DeleteFramesByHandles] 无有效 handle,跳过"); return; } try { 成套报价软件.CadBridge.DeleteFramesByHandles(list); Log("[DeleteFramesByHandles] 已发送网页删除请求: 请求=" + list.Count); } catch (System.Exception ex) { Log("[DeleteFramesByHandles] 异常: " + ex.Message); } } /// /// ★ 收集指定图号下所有箱号表和元件表的框Handle(级联删框用)。 /// 不包含图号表自身的框(图号行框由调用方从内存表收集)。 /// private void CollectFrameHandlesForTuhao(string projectId, string tuhao, List handles) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao)) return; try { string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) { Log("[CollectFrameHandlesForTuhao] 数据库不存在: " + dbPath); return; } int beforeCount = handles.Count; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // 箱号表 string xhTbl = GetXianghaoTableByIndex(projectId, tuhao); Log("[CollectFrameHandlesForTuhao] 图号=" + tuhao + " 箱号表名=" + (xhTbl ?? "(null)")); if (!string.IsNullOrEmpty(xhTbl) && TableExists(conn, xhTbl)) { using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle, 箱号 FROM \"" + xhTbl + "\";", conn)) using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { string h = reader.IsDBNull(0) ? "" : reader.GetString(0); if (!string.IsNullOrWhiteSpace(h)) handles.Add(h); string xh = reader.IsDBNull(1) ? "" : reader.GetString(1); Log("[CollectFrameHandlesForTuhao] 箱号=" + xh + " 框Handle=" + h); // 查该箱号的元件表 if (!string.IsNullOrWhiteSpace(xh)) { string yjTbl = GetYuanjianTableByIndex(projectId, tuhao, xh); if (!string.IsNullOrEmpty(yjTbl) && TableExists(conn, yjTbl)) { using (var yjCmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM \"" + yjTbl + "\";", conn)) using (var yjReader = yjCmd.ExecuteReader()) while (yjReader.Read()) { string yh = yjReader.IsDBNull(0) ? "" : yjReader.GetString(0); if (!string.IsNullOrWhiteSpace(yh)) handles.Add(yh); Log("[CollectFrameHandlesForTuhao] 元件框Handle=" + yh); } } } } } } } Log("[CollectFrameHandlesForTuhao] 新增框Handle数: " + (handles.Count - beforeCount)); } catch (Exception ex) { Log("[CollectFrameHandlesForTuhao] 异常: " + ex.Message); } } /// /// ★ 收集指定箱号下元件表的框Handle(级联删框用)。 /// 不包含箱号表自身的框(箱号行框由调用方从内存表收集)。 /// private void CollectFrameHandlesForXianghao(string projectId, string tuhao, string xianghao, List handles) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao) || string.IsNullOrWhiteSpace(xianghao)) return; try { string dbPath = GetQuoteDbPath(projectId); if (!File.Exists(dbPath)) { Log("[CollectFrameHandlesForXianghao] 数据库不存在: " + dbPath); return; } int beforeCount = handles.Count; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // ★ 先收集箱号表自身该箱号的框Handle(之前遗漏,导致删箱号时蒙版不删) string xhTbl = GetXianghaoTableByIndex(projectId, tuhao); Log("[CollectFrameHandlesForXianghao] 图号=" + tuhao + " 箱号=" + xianghao + " 箱号表名=" + (xhTbl ?? "(null)")); if (!string.IsNullOrEmpty(xhTbl) && TableExists(conn, xhTbl)) { using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM \"" + xhTbl + "\" WHERE 箱号=@xh;", conn)) { cmd.Parameters.AddWithValue("@xh", xianghao); using (var reader = cmd.ExecuteReader()) while (reader.Read()) { string h = reader.IsDBNull(0) ? "" : reader.GetString(0); if (!string.IsNullOrWhiteSpace(h)) handles.Add(h); Log("[CollectFrameHandlesForXianghao] 箱号自身框Handle=" + h); } } } // ★ 再收集该箱号下元件表的框Handle string yjTbl = GetYuanjianTableByIndex(projectId, tuhao, xianghao); Log("[CollectFrameHandlesForXianghao] 元件表名=" + (yjTbl ?? "(null)")); if (!string.IsNullOrEmpty(yjTbl) && TableExists(conn, yjTbl)) { using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 框Handle FROM \"" + yjTbl + "\";", conn)) using (var reader = cmd.ExecuteReader()) while (reader.Read()) { string h = reader.IsDBNull(0) ? "" : reader.GetString(0); if (!string.IsNullOrWhiteSpace(h)) handles.Add(h); Log("[CollectFrameHandlesForXianghao] 元件框Handle=" + h); } } } Log("[CollectFrameHandlesForXianghao] 新增框Handle数: " + (handles.Count - beforeCount)); } catch (Exception ex) { Log("[CollectFrameHandlesForXianghao] 异常: " + ex.Message); } } /// 检查表是否存在(SQLite)。 private static bool TableExists(System.Data.SQLite.SQLiteConnection conn, string tableName) { if (conn == null || string.IsNullOrWhiteSpace(tableName)) return false; try { string name = tableName.Trim().Trim('"'); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT name FROM sqlite_master WHERE type='table' AND name=@n;", conn)) { cmd.Parameters.AddWithValue("@n", name); object o = cmd.ExecuteScalar(); return o != null && o != DBNull.Value; } } catch { return false; } } // ============================================================ // 双击项目行 → 进入项目 // ============================================================ private void advancedDataGridView6_CellDoubleClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0) return; DataGridViewRow row = this.advancedDataGridView6.Rows[e.RowIndex]; string pid = GetCellValueAsString(row, "项目编号"); if (string.IsNullOrWhiteSpace(pid)) return; // 切项目前:先把当前项目的所有改动回写库,然后清空缓存 if (!string.IsNullOrWhiteSpace(_currentProjectId) && _currentProjectId != pid) { try { if (this.advancedDataGridView5 != null) this.advancedDataGridView5.EndEdit(); if (this.advancedDataGridView4 != null) this.advancedDataGridView4.EndEdit(); if (this.advancedDataGridView9 != null) this.advancedDataGridView9.EndEdit(); if (_tuhaoTable != null) SaveTableToDatabase(_tuhaoTable, _currentProjectId, TuhaoTableName, null, null, true); foreach (var kv in _xianghaoCache) if (kv.Value != null && !string.IsNullOrWhiteSpace(kv.Value.TableName)) SaveTableToDatabase(kv.Value, _currentProjectId, "\"" + kv.Value.TableName + "\"", null, null, true); foreach (var kv in _yuanjianCache) if (kv.Value != null && !string.IsNullOrWhiteSpace(kv.Value.TableName)) SaveTableToDatabase(kv.Value, _currentProjectId, "\"" + kv.Value.TableName + "\"", null, null, true); } catch { } _xianghaoCache.Clear(); _yuanjianCache.Clear(); _tuhaoTable = null; } _currentProjectId = pid; EnterProjectCore(pid); } private void EnterProjectCore(string pid) { // 切换主区域到报价页(tabPage4) this.tabControl2.SelectedTab = this.tabPage4; EnsureQuoteDbExists(pid); LoadPriceFormatConfig(pid); RecalcAllFromDatabase(pid); ReloadTuhaoByProject(pid); ApplyPriceFormat(this.advancedDataGridView9, _单台小数位文本); ApplyPriceFormat(this.advancedDataGridView4, _单台小数位文本); ApplyPriceFormat(this.advancedDataGridView5, _单台小数位文本); ApplyPriceFormat(this.advancedDataGridView6, _项目小数位文本); } // ============================================================ // ★ 右键点击时保持多行选中 // WinForms 默认行为:右键点击未选中行 → 清除多选,只选一行 // 修正:右键点击已选中行 → 保持多选;点击未选中行 → 正常切换 // ============================================================ private void Grid_MouseDown_KeepMultiSelect(object sender, MouseEventArgs e) { if (e.Button != MouseButtons.Right) return; var dgv = sender as DataGridView; if (dgv == null) return; var hit = dgv.HitTest(e.X, e.Y); if (hit.Type != DataGridViewHitTestType.Cell && hit.Type != DataGridViewHitTestType.RowHeader) return; if (hit.RowIndex < 0) return; // 如果右键点击的行已经在选中行中,不做任何操作(保持多选) // 如果不在选中行中,让默认行为生效(选中该行) foreach (DataGridViewRow r in dgv.SelectedRows) { if (r.Index == hit.RowIndex) return; } // 选中行中没有包含右键点击的行 → 手动选中该行 dgv.ClearSelection(); dgv.Rows[hit.RowIndex].Selected = true; dgv.CurrentCell = dgv.Rows[hit.RowIndex].Cells[0]; } // ============================================================ // ★ 键盘 Delete 键批量删除选中行 // 图号表→MenuTuhao_Delete_Click;箱号表→MenuXianghao_Delete_Click;元件表→MenuYuanjian_Delete_Click // ============================================================ // F1 连按计时(1 秒内连按判定) private DateTime _lastF1Time = DateTime.MinValue; /// F1:元件两两配对合并;F2:元件三个一组配对合并。 protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if ((keyData & Keys.KeyCode) == Keys.F1 && this.advancedDataGridView5 != null) { try { RunF1Merge(); } catch (Exception ex) { Log("[F1] " + ex.Message); } return true; } if ((keyData & Keys.KeyCode) == Keys.F2 && this.advancedDataGridView5 != null) { try { MergeYuanjianByGroup(3); } catch (Exception ex) { Log("[F2] " + ex.Message); } return true; } if ((keyData & Keys.KeyCode) == Keys.F3 && this.advancedDataGridView5 != null) { try { MergeYuanjianByGroup(4); } catch (Exception ex) { Log("[F3] " + ex.Message); } return true; } return base.ProcessCmdKey(ref msg, keyData); } // ★ 合并防抖时间戳(MergeYuanjianByGroup 统一闸门用) private DateTime _lastFKeyTime = DateTime.MinValue; /// 统一 F1 入口(宿主按键/网页转发共用)。 private void RunF1Merge() { MergeAdjacentYuanjianByF1(); } private void Grid_KeyDown_Delete(object sender, KeyEventArgs e) { if (e.KeyCode != Keys.Delete) return; var dgv = sender as DataGridView; if (dgv == null) return; if (dgv.SelectedRows.Count == 0 && dgv.SelectedCells.Count == 0) return; if (dgv.IsCurrentCellInEditMode) return; // 编辑模式下不拦截 Delete e.SuppressKeyPress = true; e.Handled = true; if (dgv == this.advancedDataGridView9) MenuTuhao_Delete_Click(null, null); else if (dgv == this.advancedDataGridView4) MenuXianghao_Delete_Click(null, null); else if (dgv == this.advancedDataGridView5) MenuYuanjian_Delete_Click(null, null); } // ============================================================ // 右键菜单:创建 + 行移动 // ============================================================ private void BuildContextMenus() { // 图号菜单(advancedDataGridView9) _menuTuhao = new ContextMenuStrip(); _menuTuhao.Items.Add("新建图号", null, MenuTuhao_New_Click); _menuTuhao.Items.Add("删除图号", null, MenuTuhao_Delete_Click); _menuTuhao.Items.Add(new ToolStripSeparator()); _menuTuhao.Items.Add("置顶", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView9, RowMoveAction.Top)); _menuTuhao.Items.Add("向上移动", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView9, RowMoveAction.Up)); _menuTuhao.Items.Add("向下移动", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView9, RowMoveAction.Down)); _menuTuhao.Items.Add("置底", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView9, RowMoveAction.Bottom)); _menuTuhao.Items.Add("移动到...", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView9, RowMoveAction.To)); this.advancedDataGridView9.ContextMenuStrip = _menuTuhao; // 箱号菜单(advancedDataGridView4) _menuXianghao = new ContextMenuStrip(); _menuXianghao.Items.Add("新建箱号", null, MenuXianghao_New_Click); _menuXianghao.Items.Add("删除箱号", null, MenuXianghao_Delete_Click); _menuXianghao.Items.Add(new ToolStripSeparator()); _menuXianghao.Items.Add("置顶", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView4, RowMoveAction.Top)); _menuXianghao.Items.Add("向上移动", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView4, RowMoveAction.Up)); _menuXianghao.Items.Add("向下移动", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView4, RowMoveAction.Down)); _menuXianghao.Items.Add("置底", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView4, RowMoveAction.Bottom)); _menuXianghao.Items.Add("移动到...", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView4, RowMoveAction.To)); this.advancedDataGridView4.ContextMenuStrip = _menuXianghao; // 元件菜单(advancedDataGridView5) _menuYuanjian = new ContextMenuStrip(); _menuYuanjian.Items.Add("新建元件", null, MenuYuanjian_New_Click); _menuYuanjian.Items.Add("删除元件", null, MenuYuanjian_Delete_Click); _menuYuanjian.Items.Add("合并元件", null, MenuYuanjian_Merge_Click); _menuYuanjian.Items.Add(new ToolStripSeparator()); _menuYuanjian.Items.Add("置顶", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView5, RowMoveAction.Top)); _menuYuanjian.Items.Add("向上移动", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView5, RowMoveAction.Up)); _menuYuanjian.Items.Add("向下移动", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView5, RowMoveAction.Down)); _menuYuanjian.Items.Add("置底", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView5, RowMoveAction.Bottom)); _menuYuanjian.Items.Add("移动到...", null, (s, e) => MoveSelectedRowInGrid(this.advancedDataGridView5, RowMoveAction.To)); this.advancedDataGridView5.ContextMenuStrip = _menuYuanjian; } private enum RowMoveAction { Top, Up, Down, Bottom, To } private void MoveSelectedRowInGrid(DataGridView dgv, RowMoveAction action) { if (dgv == null || string.IsNullOrWhiteSpace(_currentProjectId)) return; DataTable table = null; System.Action reloadAction = null; if (dgv == this.advancedDataGridView5) { table = _yuanjianTable; reloadAction = () => ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); } else if (dgv == this.advancedDataGridView4) { table = _xianghaoTable; reloadAction = () => ReloadXianghaoByTuhao(_currentProjectId, _currentTuhao); } else if (dgv == this.advancedDataGridView9) { table = _tuhaoTable; reloadAction = () => ReloadTuhaoByProject(_currentProjectId); } if (table == null || table.Rows.Count == 0) { MessageBox.Show(this, "没有数据可移动。", "移动行", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var dgvRow = GetFirstSelectedRow(dgv); if (dgvRow == null) { MessageBox.Show(this, "请先选择要移动的行。", "移动行", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } int dataCount = dgv.Rows.Count; if (dgv.AllowUserToAddRows) dataCount--; if (dataCount <= 0) return; int oldIndex = dgvRow.Index; if (oldIndex >= dataCount) return; string rememberTuhao = _currentTuhao; string rememberXianghao = _currentXianghao; int newIndex = -1; _isReloading = true; try { dgv.EndEdit(); newIndex = oldIndex; switch (action) { case RowMoveAction.Top: newIndex = 0; break; case RowMoveAction.Up: newIndex = Math.Max(0, oldIndex - 1); break; case RowMoveAction.Down: newIndex = Math.Min(dataCount - 1, oldIndex + 1); break; case RowMoveAction.Bottom: newIndex = dataCount - 1; break; case RowMoveAction.To: string input = ShowInputDialog("移动到", "请输入目标行号(1-" + dataCount + "):", (oldIndex + 1).ToString()); if (string.IsNullOrEmpty(input)) { newIndex = -1; return; } int target; if (!int.TryParse(input.Trim(), out target)) { MessageBox.Show(this, "请输入有效数字。", "移动到", MessageBoxButtons.OK, MessageBoxIcon.Warning); newIndex = -1; return; } newIndex = Math.Max(0, Math.Min(dataCount - 1, target - 1)); break; } if (newIndex == oldIndex) { newIndex = -1; return; } DataRow rowToMove = table.Rows[oldIndex]; object[] savedValues = rowToMove.ItemArray; table.Rows.Remove(rowToMove); DataRow newRow = table.NewRow(); newRow.ItemArray = savedValues; table.Rows.InsertAt(newRow, newIndex); if (table.Columns.Contains("序号")) { for (int i = 0; i < table.Rows.Count; i++) table.Rows[i]["序号"] = (i + 1).ToString(); } if (dgv == this.advancedDataGridView9) SaveTableToDatabase(table, _currentProjectId, TuhaoTableName, null, null, true); else SaveTableToDatabase(table, _currentProjectId, "\"" + table.TableName + "\"", null, null, true); } catch (Exception ex) { MessageBox.Show(this, "移动行失败:\n" + ex.Message, "移动行", MessageBoxButtons.OK, MessageBoxIcon.Error); newIndex = -1; } finally { _isReloading = false; if (newIndex >= 0) { if (dgv == this.advancedDataGridView9) SelectRowByValue(this.advancedDataGridView9, "图号", rememberTuhao); else if (dgv == this.advancedDataGridView4) SelectRowByValue(this.advancedDataGridView4, "箱号", rememberXianghao); else if (dgv == this.advancedDataGridView5) SelectRowByValue(this.advancedDataGridView5, "序号", (newIndex + 1).ToString()); } } } private void MoveLastRowBelowSelected(DataGridView dgv, DataTable table, int afterIndex, string dbTableName, string projectId) { if (dgv == null || table == null || afterIndex < 0 || table.Rows.Count < 2) return; int lastIndex = table.Rows.Count - 1; int targetIndex = afterIndex + 1; if (targetIndex >= lastIndex) return; object[] savedValues = table.Rows[lastIndex].ItemArray; table.Rows.RemoveAt(lastIndex); DataRow newRow = table.NewRow(); newRow.ItemArray = savedValues; table.Rows.InsertAt(newRow, targetIndex); if (table.Columns.Contains("序号")) { for (int i = 0; i < table.Rows.Count; i++) table.Rows[i]["序号"] = (i + 1).ToString(); } SaveTableToDatabase(table, projectId, dbTableName, null, null, true); try { if (targetIndex < dgv.Rows.Count) dgv.FirstDisplayedScrollingRowIndex = targetIndex; } catch { } } // ---------- 图号菜单事件 ---------- private void MenuTuhao_New_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_currentProjectId)) { MessageBox.Show(this, "请先在项目表中双击选择一个项目,再新建图号。", "新建图号", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string newTuhao = "新图号" + GetNextSeqNumber(TuhaoTableName, "图号", _currentProjectId); int selectedIndex = GetSelectedRowIndex(this.advancedDataGridView9); try { _isReloading = true; SaveAllQuoteTablesIfDirty(false); InsertTuhao(_currentProjectId, newTuhao, "", ""); _xianghaoCache.Clear(); _yuanjianCache.Clear(); ReloadTuhaoByProject(_currentProjectId); MoveLastRowBelowSelected(this.advancedDataGridView9, _tuhaoTable, selectedIndex, TuhaoTableName, _currentProjectId); RecalcAllAfterReload(); } catch (Exception ex) { MessageBox.Show(this, "新建图号失败:\n" + ex.Message, "新建图号", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _currentTuhao = newTuhao; SelectRowByValue(this.advancedDataGridView9, "图号", newTuhao); _isReloading = false; ReloadXianghaoByTuhao(_currentProjectId, newTuhao); } } private void MenuTuhao_Delete_Click(object sender, EventArgs e) { var rows = GetAllSelectedRows(this.advancedDataGridView9); if (rows == null || rows.Count == 0) { MessageBox.Show(this, "请先选择要删除的图号。", "删除图号", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var tuhaos = new List(); foreach (var r in rows) { string th = GetCellValueAsString(r, "图号"); if (!string.IsNullOrWhiteSpace(th) && !tuhaos.Contains(th)) tuhaos.Add(th); } if (tuhaos.Count == 0) { MessageBox.Show(this, "选中行没有图号,无法删除。", "删除图号", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // ★ 收集选中图号行自己的框Handle var frameHandles = new List(); foreach (var r in rows) { var drv = r.DataBoundItem as DataRowView; if (drv != null && drv.Row != null && drv.Row.Table.Columns.Contains("框Handle")) { object v = drv.Row["框Handle"]; if (v != null && v != DBNull.Value) { string h = v.ToString().Trim(); if (!string.IsNullOrWhiteSpace(h)) frameHandles.Add(h); } } } // ★ 收集每个图号下所有箱号和元件的框Handle(级联删框) foreach (string tuhao in tuhaos) { CollectFrameHandlesForTuhao(_currentProjectId, tuhao, frameHandles); } Log("[MenuTuhao_Delete] 删除图号: " + string.Join(",", tuhaos) + " 收集到框Handle数: " + frameHandles.Count); try { _isReloading = true; SaveAllQuoteTablesIfDirty(false); foreach (string tuhao in tuhaos) { DeleteTuhao(_currentProjectId, tuhao); if (!string.IsNullOrEmpty(tuhao)) { _xianghaoCache.Remove(tuhao); var keysToRemove = new List(); foreach (var key in _yuanjianCache.Keys) if (key.StartsWith(tuhao + "\n")) keysToRemove.Add(key); foreach (var key in keysToRemove) _yuanjianCache.Remove(key); } } RenumberTable(_currentProjectId, TuhaoTableName); ReloadTuhaoByProject(_currentProjectId); RecalcAllAfterReload(); // ★ 删除所有收集到的框实体(去重) var tuhaoDistinct = new HashSet(frameHandles); foreach (string h in tuhaoDistinct) { Log("[MenuTuhao_Delete] 准备删除框Handle: " + h); DeleteFrameByHandle(h); } Log("[MenuTuhao_Delete] 框实体删除完成,共 " + tuhaoDistinct.Count + " 个"); // ★ 保存DWG,确保删框操作持久化 SaveCurrentDwg(); } catch (Exception ex) { MessageBox.Show(this, "删除图号失败:\n" + ex.Message, "删除图号", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _isReloading = false; } } // ---------- 箱号菜单事件 ---------- private void MenuXianghao_New_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_currentProjectId) || string.IsNullOrWhiteSpace(_currentTuhao)) { MessageBox.Show(this, "请先选择图号,再新建箱号。", "新建箱号", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string rememberTuhao = _currentTuhao; int selectedIndex = GetSelectedRowIndex(this.advancedDataGridView4); string newXh = "新箱号" + GetXianghaoCount(_currentProjectId, _currentTuhao); try { _isReloading = true; SaveAllQuoteTablesIfDirty(false); // ★ 图纸列=当前打开的 CAD 图纸名(查看器开过图就有值,去扩展名) InsertXianghao(_currentProjectId, _currentTuhao, newXh, "", "", 成套报价软件.CadBridge.CurrentDrawingName); if (!string.IsNullOrEmpty(rememberTuhao)) _xianghaoCache.Remove(rememberTuhao); ReloadXianghaoByTuhao(_currentProjectId, _currentTuhao); if (_xianghaoTable != null) MoveLastRowBelowSelected(this.advancedDataGridView4, _xianghaoTable, selectedIndex, "\"" + _xianghaoTable.TableName + "\"", _currentProjectId); RecalcAllAfterReload(); } catch (Exception ex) { MessageBox.Show(this, "新建箱号失败:\n" + ex.Message, "新建箱号", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _currentTuhao = rememberTuhao; _currentXianghao = newXh; SelectRowByValue(this.advancedDataGridView9, "图号", rememberTuhao); SelectRowByValue(this.advancedDataGridView4, "箱号", newXh); _isReloading = false; ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, newXh); } } private void MenuXianghao_Delete_Click(object sender, EventArgs e) { var rows = GetAllSelectedRows(this.advancedDataGridView4); if (rows == null || rows.Count == 0) { MessageBox.Show(this, "请先选择要删除的箱号。", "删除箱号", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var xhs = new List(); foreach (var r in rows) { string xh = GetCellValueAsString(r, "箱号"); if (!string.IsNullOrWhiteSpace(xh) && !xhs.Contains(xh)) xhs.Add(xh); } if (xhs.Count == 0) { MessageBox.Show(this, "选中行没有箱号,无法删除。", "删除箱号", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // ★ 收集选中箱号行自己的框Handle var frameHandles = new List(); foreach (var r in rows) { var drv = r.DataBoundItem as DataRowView; if (drv != null && drv.Row != null && drv.Row.Table.Columns.Contains("框Handle")) { object v = drv.Row["框Handle"]; if (v != null && v != DBNull.Value) { string h = v.ToString().Trim(); if (!string.IsNullOrWhiteSpace(h)) frameHandles.Add(h); } } } // ★ 收集每个箱号下元件表的框Handle(级联删框) foreach (string xh in xhs) { CollectFrameHandlesForXianghao(_currentProjectId, _currentTuhao, xh, frameHandles); } Log("[MenuXianghao_Delete] 删除箱号: " + string.Join(",", xhs) + " 图号=" + _currentTuhao + " 收集到框Handle数: " + frameHandles.Count); string rememberTuhao = _currentTuhao; try { _isReloading = true; SaveAllQuoteTablesIfDirty(false); foreach (string xh in xhs) { DeleteXianghao(_currentProjectId, _currentTuhao, xh); string yjKey = _currentTuhao + "\n" + xh; _yuanjianCache.Remove(yjKey); } string xhTbl = GetXianghaoTableByIndex(_currentProjectId, _currentTuhao); if (!string.IsNullOrEmpty(xhTbl)) RenumberTable(_currentProjectId, "\"" + xhTbl + "\""); if (!string.IsNullOrEmpty(_currentTuhao)) _xianghaoCache.Remove(_currentTuhao); Log("[MenuXianghao_Delete] 开始 ReloadXianghaoByTuhao..."); ReloadXianghaoByTuhao(_currentProjectId, _currentTuhao); Log("[MenuXianghao_Delete] ReloadXianghaoByTuhao 完成,开始删除框实体..."); RecalcAllAfterReload(); // ★ 删除所有收集到的框实体(去重,避免重复删除) var distinctHandles = new HashSet(frameHandles); foreach (string h in distinctHandles) { Log("[MenuXianghao_Delete] 准备删除框Handle: " + h); DeleteFrameByHandle(h); } Log("[MenuXianghao_Delete] 框实体删除完成,共 " + distinctHandles.Count + " 个"); // ★ 保存DWG,确保删框操作持久化 SaveCurrentDwg(); } catch (Exception ex) { Log("[MenuXianghao_Delete] 异常: " + ex.Message + "\n" + ex.StackTrace); MessageBox.Show(this, "删除箱号失败:\n" + ex.Message, "删除箱号", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _currentTuhao = rememberTuhao; SelectRowByValue(this.advancedDataGridView9, "图号", rememberTuhao); _isReloading = false; } } // ---------- 元件菜单事件 ---------- private void MenuYuanjian_New_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_currentProjectId) || string.IsNullOrWhiteSpace(_currentTuhao) || string.IsNullOrWhiteSpace(_currentXianghao)) { MessageBox.Show(this, "请先选择箱号,再新建元件。", "新建元件", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string rememberTuhao = _currentTuhao; string rememberXianghao = _currentXianghao; int selectedIndex = GetSelectedRowIndex(this.advancedDataGridView5); string newName = "新元件" + GetYuanjianCount(_currentProjectId, _currentTuhao, _currentXianghao); try { _isReloading = true; SaveAllQuoteTablesIfDirty(false); InsertYuanjian(_currentProjectId, _currentTuhao, _currentXianghao, newName); string cacheKey = _currentTuhao + "\n" + _currentXianghao; _yuanjianCache.Remove(cacheKey); ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); if (_yuanjianTable != null) MoveLastRowBelowSelected(this.advancedDataGridView5, _yuanjianTable, selectedIndex, "\"" + _yuanjianTable.TableName + "\"", _currentProjectId); RecalcAllAfterReload(); } catch (Exception ex) { MessageBox.Show(this, "新建元件失败:\n" + ex.Message, "新建元件", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _currentTuhao = rememberTuhao; _currentXianghao = rememberXianghao; SelectRowByValue(this.advancedDataGridView9, "图号", rememberTuhao); SelectRowByValue(this.advancedDataGridView4, "箱号", rememberXianghao); _isReloading = false; } } private void MenuYuanjian_Delete_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_currentProjectId) || string.IsNullOrWhiteSpace(_currentTuhao) || string.IsNullOrWhiteSpace(_currentXianghao)) { MessageBox.Show(this, "请先选择箱号,再删除元件。", "删除元件", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var rows = GetAllSelectedRows(this.advancedDataGridView5); if (rows == null || rows.Count == 0) { MessageBox.Show(this, "请先选择要删除的元件。", "删除元件", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var seqs = new List(); var frameHandles = new List(); foreach (var r in rows) { string seq = GetCellValueAsString(r, "序号"); if (!string.IsNullOrWhiteSpace(seq) && !seqs.Contains(seq)) seqs.Add(seq); // ★ 收集元件行的框Handle var drv = r.DataBoundItem as DataRowView; if (drv != null && drv.Row != null && drv.Row.Table.Columns.Contains("框Handle")) { object v = drv.Row["框Handle"]; if (v != null && v != DBNull.Value) { string h = v.ToString().Trim(); if (!string.IsNullOrWhiteSpace(h)) frameHandles.Add(h); } } } if (seqs.Count == 0) { MessageBox.Show(this, "选中行没有序号,无法删除。", "删除元件", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } string rememberTuhao = _currentTuhao; string rememberXianghao = _currentXianghao; try { _isReloading = true; SaveAllQuoteTablesIfDirty(false); foreach (string seq in seqs) DeleteYuanjian(_currentProjectId, _currentTuhao, _currentXianghao, seq); string realTbl = GetYuanjianTableByIndex(_currentProjectId, _currentTuhao, _currentXianghao); if (!string.IsNullOrEmpty(realTbl)) RenumberTable(_currentProjectId, "\"" + realTbl + "\""); string cacheKey = _currentTuhao + "\n" + _currentXianghao; _yuanjianCache.Remove(cacheKey); ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); RecalcAllAfterReload(); // ★ 删除所有收集到的框实体(去重) foreach (string h in new HashSet(frameHandles)) DeleteFrameByHandle(h); // ★ 保存DWG,确保删框操作持久化 SaveCurrentDwg(); } catch (Exception ex) { MessageBox.Show(this, "删除元件失败:\n" + ex.Message, "删除元件", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _currentTuhao = rememberTuhao; _currentXianghao = rememberXianghao; SelectRowByValue(this.advancedDataGridView9, "图号", rememberTuhao); SelectRowByValue(this.advancedDataGridView4, "箱号", rememberXianghao); _isReloading = false; } } // ============================================================ // 合并元件:把选中的多行元件合并为一行 // · 元件名称:所有非空名称直接拼接(去重,不加符号) // · 规格:所有非空规格直接拼接(去重,不加符号) // · 数量:保持为 1(合并后是一个元件,数量不变) // · 其他列(单位/系数/价格):取第一行 // · 删除被合并的其他行,重新编号,保存刷新 // ============================================================ /// 按框Handle集合选中元件表中的行(多选,滚动到第一行)。 private void SelectYuanjianRowsByFrameHandles(List frameHandles) { var dgv = this.advancedDataGridView5; if (dgv == null || frameHandles == null || frameHandles.Count == 0) return; try { dgv.MultiSelect = true; if (dgv.SelectionMode != DataGridViewSelectionMode.RowHeaderSelect && dgv.SelectionMode != DataGridViewSelectionMode.FullRowSelect) { dgv.SelectionMode = DataGridViewSelectionMode.FullRowSelect; } dgv.ClearSelection(); DataGridViewRow first = null; foreach (DataGridViewRow row in dgv.Rows) { var drv = row.DataBoundItem as DataRowView; if (drv == null || drv.Row == null) continue; if (!drv.Row.Table.Columns.Contains("框Handle")) continue; object v = drv.Row["框Handle"]; string h = v != null && v != DBNull.Value ? v.ToString().Trim() : ""; if (!string.IsNullOrWhiteSpace(h) && frameHandles.Contains(h)) { row.Selected = true; if (first == null) first = row; } } if (first != null && first.DataGridView != null) first.DataGridView.FirstDisplayedScrollingRowIndex = Math.Max(0, first.Index); } catch (Exception ex) { Log("[SelectYuanjianRows] " + ex.Message); } } /// 网页转发的 F1:转到统一入口(焦点在网页时 WinForms 收不到按键)。 private void OnF1FromBridge() { try { RunF1Merge(); } catch (Exception ex) { Log("[F1桥接] " + ex.Message); } } /// 网页转发的 F2:三个一组配对合并。 private void OnF2FromBridge() { try { MergeYuanjianByGroup(3); } catch (Exception ex) { Log("[F2桥接] " + ex.Message); } } /// 网页转发的 F3:四个一组配对合并。 private void OnF3FromBridge() { try { MergeYuanjianByGroup(4); } catch (Exception ex) { Log("[F3桥接] " + ex.Message); } } /// /// F1:合并元件表中相邻的两个元件(按序号顺序)。 /// · 选中 ≥2 行 → 合并前两行 /// · 选中 1 行 → 与它的下一行合并 /// · 未选中 → 合并第 1、2 行 /// 合并时网页 CAD 里的蒙版同步合并(保留行蒙版扩大到并集)。 /// private void MergeAdjacentYuanjianByF1() { MergeYuanjianByGroup(2); } /// 按 groupSize 分组合并选中元件(F1=两两,F2=三个一组)。 private void MergeYuanjianByGroup(int groupSize) { // ★ 防抖(300ms):所有触发路径(按键/桥/过滤器)的统一闸门, // 防止一次按键双跑两遍合并(第二遍因选中已清而弹误提示) if ((DateTime.Now - _lastFKeyTime).TotalMilliseconds < 300) return; _lastFKeyTime = DateTime.Now; var dgv = this.advancedDataGridView5; if (dgv == null) return; var rows = GetAllSelectedRows(dgv); if (rows == null || rows.Count < groupSize) { MessageBox.Show(this, "请先选中至少 " + groupSize + " 行元件(扒元件后会自动全选)。", "合并元件", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // ★ F1 = 两两配对批量合并(1+2、3+4、5+6...),一次处理完所有选中行; // 只想合并单独一对时,只选中那一对两行即可。 // 合并会刷新表格使行对象失效,所以先记下配对的框Handle,再用 Handle 逐对定位。 var orderedHandles = new List(); foreach (var r in rows) { orderedHandles.Add(GetFrameHandleOfRow(r)); } int groups = orderedHandles.Count / groupSize; int merged = 0; for (int i = 0; i < groups; i++) { var groupRows = new List(); var groupHandles = new List(); for (int k = 0; k < groupSize; k++) { string h = orderedHandles[i * groupSize + k]; if (string.IsNullOrWhiteSpace(h)) { groupRows = null; break; } var rowK = FindRowByFrameHandle(dgv, h); if (rowK == null) { groupRows = null; break; } groupRows.Add(rowK); groupHandles.Add(h); } if (groupRows == null) continue; // 组内若含重复行(同句柄)跳过 if (new HashSet(groupHandles).Count != groupSize) continue; MergeYuanjianRows(groupRows); merged++; } Log("[F1批量] " + groupSize + " 个一组配对合并完成: " + merged + "/" + groups + " 组"); } /// 读行的框Handle:优先 DataRow("框Handle"可能不在显示列,Cells 读不到)。 private static string GetFrameHandleOfRow(DataGridViewRow row) { if (row == null) return ""; var drv = row.DataBoundItem as DataRowView; if (drv != null && drv.Row != null && drv.Row.Table.Columns.Contains("框Handle")) { object v = drv.Row["框Handle"]; return v == null || v == DBNull.Value ? "" : v.ToString().Trim(); } return GetCellValueAsString(row, "框Handle"); } /// 按框Handle在元件表中找行(找不到返回 null)。 private DataGridViewRow FindRowByFrameHandle(DataGridView dgv, string frameHandle) { if (string.IsNullOrWhiteSpace(frameHandle)) return null; foreach (DataGridViewRow row in dgv.Rows) { if (row == null || row.IsNewRow) continue; string h = GetFrameHandleOfRow(row); if (!string.IsNullOrWhiteSpace(h) && h == frameHandle) return row; } return null; } /// 合并指定行(按序号排序):第一行为合并目标,其余删除;蒙版同步合并。 private void MergeYuanjianRows(List rows) { // 按序号排序(保证合并顺序与表格顺序一致) rows.Sort((a, b) => { int ia = 0, ib = 0; int.TryParse(GetCellValueAsString(a, "序号"), out ia); int.TryParse(GetCellValueAsString(b, "序号"), out ib); return ia.CompareTo(ib); }); // 收集要合并的内容(直接拼接,不加任何符号) var names = new List(); var specs = new List(); var drawingSpecs = new List(); foreach (var r in rows) { string nm = GetCellValueAsString(r, "元件名称"); if (!string.IsNullOrWhiteSpace(nm) && !names.Contains(nm)) names.Add(nm); string sp = GetCellValueAsString(r, "规格"); if (!string.IsNullOrWhiteSpace(sp) && !specs.Contains(sp)) specs.Add(sp); // ★ 图纸规格同步收集(旧数据该列为空时用规格文本兜底); // 不去重——合并后保留每条原文拼接(用户要求,规格列的去重是原设计不动) string dzg = GetCellValueAsString(r, "图纸规格"); if (string.IsNullOrWhiteSpace(dzg)) dzg = sp; if (!string.IsNullOrWhiteSpace(dzg)) drawingSpecs.Add(dzg); } string rememberTuhao = _currentTuhao; string rememberXianghao = _currentXianghao; string keepHandle = GetFrameHandleOfRow(rows[0]); var absorbedHandles = new List(); for (int i = 1; i < rows.Count; i++) { string h = GetFrameHandleOfRow(rows[i]); if (!string.IsNullOrWhiteSpace(h)) absorbedHandles.Add(h); } try { _isReloading = true; SaveAllQuoteTablesIfDirty(false); if (_yuanjianTable == null || _yuanjianTable.Rows.Count == 0) { MessageBox.Show(this, "元件表为空,无法合并。", "合并元件", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // 取出选中行对应的 DataRow var dataRows = new List(); foreach (var dgvRow in rows) { var drv = dgvRow.DataBoundItem as DataRowView; if (drv != null && drv.Row != null) dataRows.Add(drv.Row); } if (dataRows.Count < 2) { MessageBox.Show(this, "无法获取选中行的数据,请重试。", "合并元件", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // 第一行作为合并目标,更新名称/规格(直接拼接,不加任何符号) // 数量保持为 1(合并后是一个元件) DataRow target = dataRows[0]; if (_yuanjianTable.Columns.Contains("元件名称")) target["元件名称"] = string.Join(" ", names.Where(x => x.Trim().Length > 0).ToArray()); if (_yuanjianTable.Columns.Contains("规格")) target["规格"] = string.Join(" ", specs.Where(x => x.Trim().Length > 0).ToArray()); // ★ 合并:图纸规格同步拼接被合并各行的型号原文(与规格列同步) if (_yuanjianTable.Columns.Contains("图纸规格")) target["图纸规格"] = string.Join(" ", drawingSpecs.Where(x => x.Trim().Length > 0).ToArray()); if (_yuanjianTable.Columns.Contains("数量")) target["数量"] = "1"; // 删除其他被合并的行 for (int i = 1; i < dataRows.Count; i++) { dataRows[i].Delete(); } // 保存到数据库 string realTbl = GetYuanjianTableByIndex(_currentProjectId, _currentTuhao, _currentXianghao); if (!string.IsNullOrEmpty(realTbl)) { SaveTableToDatabase(_yuanjianTable, _currentProjectId, "\"" + realTbl + "\"", null, null, true); _yuanjianTable.AcceptChanges(); RenumberTable(_currentProjectId, "\"" + realTbl + "\""); } // ★ 网页 CAD 蒙版同步合并:保留行蒙版扩大到并集,被合并行蒙版移除 if (!string.IsNullOrWhiteSpace(keepHandle) && absorbedHandles.Count > 0) { 成套报价软件.CadBridge.MergeMasks(keepHandle, absorbedHandles); Log("[MergeYuanjianRows] 蒙版合并: 保留=" + keepHandle); } // 刷新缓存与表格 string cacheKey = _currentTuhao + "\n" + _currentXianghao; _yuanjianCache.Remove(cacheKey); ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); RecalcAllAfterReload(); } catch (Exception ex) { MessageBox.Show(this, "合并元件失败:\n" + ex.Message, "合并元件", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _currentTuhao = rememberTuhao; _currentXianghao = rememberXianghao; _isReloading = false; // ★ 合并后的行保持选中(连续按 F1 可继续与下一行合并): // 延迟到刷新事件链走完再选,否则会被清掉 if (!string.IsNullOrWhiteSpace(keepHandle)) { var kh = keepHandle; BeginInvoke((Action)(() => { BeginInvoke((Action)(() => { SelectYuanjianRowsByFrameHandles(new List { kh }); })); })); } } } private void MenuYuanjian_Merge_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_currentProjectId) || string.IsNullOrWhiteSpace(_currentTuhao) || string.IsNullOrWhiteSpace(_currentXianghao)) { MessageBox.Show(this, "请先选择箱号,再合并元件。", "合并元件", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var rows = GetAllSelectedRows(this.advancedDataGridView5); if (rows == null || rows.Count < 2) { MessageBox.Show(this, "请先选择至少两行要合并的元件(按住 Ctrl/Shift 多选)。", "合并元件", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 按序号排序(保证合并顺序与表格顺序一致) rows.Sort((a, b) => { int ia = 0, ib = 0; int.TryParse(GetCellValueAsString(a, "序号"), out ia); int.TryParse(GetCellValueAsString(b, "序号"), out ib); return ia.CompareTo(ib); }); // 收集要合并的内容(直接拼接,不加任何符号) var names = new List(); var specs = new List(); var drawingSpecs = new List(); foreach (var r in rows) { string nm = GetCellValueAsString(r, "元件名称"); if (!string.IsNullOrWhiteSpace(nm) && !names.Contains(nm)) names.Add(nm); string sp = GetCellValueAsString(r, "规格"); if (!string.IsNullOrWhiteSpace(sp) && !specs.Contains(sp)) specs.Add(sp); // ★ 图纸规格同步收集(旧数据该列为空时用规格文本兜底); // 不去重——合并后保留每条原文拼接(用户要求,规格列的去重是原设计不动) string dzg = GetCellValueAsString(r, "图纸规格"); if (string.IsNullOrWhiteSpace(dzg)) dzg = sp; if (!string.IsNullOrWhiteSpace(dzg)) drawingSpecs.Add(dzg); } string rememberTuhao = _currentTuhao; string rememberXianghao = _currentXianghao; try { _isReloading = true; SaveAllQuoteTablesIfDirty(false); if (_yuanjianTable == null || _yuanjianTable.Rows.Count == 0) { MessageBox.Show(this, "元件表为空,无法合并。", "合并元件", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // 取出选中行对应的 DataRow var dataRows = new List(); foreach (var dgvRow in rows) { var drv = dgvRow.DataBoundItem as DataRowView; if (drv != null && drv.Row != null) dataRows.Add(drv.Row); } if (dataRows.Count < 2) { MessageBox.Show(this, "无法获取选中行的数据,请重试。", "合并元件", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // 第一行作为合并目标,更新名称/规格(直接拼接,不加符号) // 数量保持为 1(合并后是一个元件) DataRow target = dataRows[0]; if (_yuanjianTable.Columns.Contains("元件名称")) target["元件名称"] = string.Join(" ", names.Where(x => x.Trim().Length > 0).ToArray()); if (_yuanjianTable.Columns.Contains("规格")) target["规格"] = string.Join(" ", specs.Where(x => x.Trim().Length > 0).ToArray()); // ★ 合并:图纸规格同步拼接被合并各行的型号原文(与规格列同步) if (_yuanjianTable.Columns.Contains("图纸规格")) target["图纸规格"] = string.Join(" ", drawingSpecs.Where(x => x.Trim().Length > 0).ToArray()); if (_yuanjianTable.Columns.Contains("数量")) target["数量"] = "1"; // ★ 收集被合并行(第2行起)的框Handle,合并后删除这些框 var mergedFrameHandles = new List(); if (_yuanjianTable.Columns.Contains("框Handle")) { for (int i = 1; i < dataRows.Count; i++) { object v = dataRows[i]["框Handle"]; if (v != null && v != DBNull.Value) { string h = v.ToString().Trim(); if (!string.IsNullOrWhiteSpace(h)) mergedFrameHandles.Add(h); } } } // 删除其他被合并的行 for (int i = 1; i < dataRows.Count; i++) { dataRows[i].Delete(); } // 保存到数据库 string realTbl = GetYuanjianTableByIndex(_currentProjectId, _currentTuhao, _currentXianghao); if (!string.IsNullOrEmpty(realTbl)) { SaveTableToDatabase(_yuanjianTable, _currentProjectId, "\"" + realTbl + "\"", null, null, true); _yuanjianTable.AcceptChanges(); RenumberTable(_currentProjectId, "\"" + realTbl + "\""); } // ★ 删除被合并行的框实体 foreach (string h in mergedFrameHandles) DeleteFrameByHandle(h); // ★ 保存DWG,确保删框操作持久化 SaveCurrentDwg(); // 刷新缓存与表格 string cacheKey = _currentTuhao + "\n" + _currentXianghao; _yuanjianCache.Remove(cacheKey); ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); RecalcAllAfterReload(); } catch (Exception ex) { MessageBox.Show(this, "合并元件失败:\n" + ex.Message, "合并元件", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _currentTuhao = rememberTuhao; _currentXianghao = rememberXianghao; SelectRowByValue(this.advancedDataGridView9, "图号", rememberTuhao); SelectRowByValue(this.advancedDataGridView4, "箱号", rememberXianghao); _isReloading = false; } } // ============================================================ // 工具栏:新建项目 / 删除项目 / 导出数据 // ============================================================ private void toolStripButton5_Click(object sender, EventArgs e) { // 用自定义对话框收集项目信息字段(无主项目的"新建项目"窗体可用) var fields = ShowNewProjectDialog(); if (fields == null) return; try { int affected = InsertProjectToDatabase(fields); if (affected <= 0) { MessageBox.Show(this, "未写入任何字段,请检查输入。", "新建项目", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } ReloadProjectsFromDatabase(); ApplyPriceFormat(this.advancedDataGridView6, _项目小数位文本); } catch (Exception ex) { MessageBox.Show(this, "保存项目失败:\n" + ex.Message, "新建项目", MessageBoxButtons.OK, MessageBoxIcon.Error); } } /// 弹出新建项目窗口,收集项目信息 + 价格格式化配置。 private Dictionary ShowNewProjectDialog() { string dbPath = GetDbFullPath(ProjectsDbPath); using (var dlg = new 新建项目(dbPath, ProjectsTableName)) { if (dlg.ShowDialog(this) != DialogResult.OK) return null; return dlg.GetInputValues(); } } private void toolStripButton6_Click(object sender, EventArgs e) { DataGridViewRow row = GetFirstSelectedProjectRow(); if (row == null) { MessageBox.Show(this, "请先在项目表格中选择要删除的项目。", "删除项目", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string projectId = GetCellValueAsString(row, "项目编号"); if (string.IsNullOrWhiteSpace(projectId)) { MessageBox.Show(this, "选中行没有“项目编号”,无法定位要删除的数据库记录。", "删除项目", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } DialogResult dr = MessageBox.Show(this, "确定要删除项目编号为 “" + projectId + "” 的项目吗?\n" + "该项目下的所有图号/箱号/元件数据也会一并删除!\n此操作不可恢复。", "删除项目", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); if (dr != DialogResult.Yes) return; try { int affected = DeleteProjectFromDatabase(projectId); CascadeDeleteByProject(projectId); _currentProjectId = ""; _currentTuhao = ""; _currentXianghao = ""; _xianghaoCache.Clear(); _yuanjianCache.Clear(); _tuhaoTable = null; ReloadProjectsFromDatabase(); ReloadTuhaoByProject(""); } catch (Exception ex) { MessageBox.Show(this, "删除项目失败:\n" + ex.Message, "删除项目", MessageBoxButtons.OK, MessageBoxIcon.Error); } } // ---------- 项目表查找(button28)---------- private void button28_Click(object sender, EventArgs e) { string field = this.comboBox2.Text == null ? "" : this.comboBox2.Text.Trim(); string colName = MapSearchFieldToColumn(field); if (string.IsNullOrEmpty(colName)) { MessageBox.Show(this, "请先在搜索条件下拉框中选择有效的搜索字段(项目名称 / 项目编号 / 客户名称)。", "查找", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string keyword = this.textBox1.Text == null ? "" : this.textBox1.Text.Trim(); DataTable table = this.advancedDataGridView6.DataSource as DataTable; if (table == null) { MessageBox.Show(this, "项目表暂无数据,无法搜索。", "查找", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } if (keyword.Length == 0) { if (table.DefaultView.RowFilter.Length > 0) table.DefaultView.RowFilter = ""; return; } string escaped = EscapeLikeValue(keyword); try { table.DefaultView.RowFilter = "[" + colName + "] LIKE '%" + escaped + "%'"; } catch (System.Data.SyntaxErrorException ex) { MessageBox.Show(this, "搜索条件解析失败,请检查输入的关键词。\n详情:" + ex.Message, "查找", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } if (table.DefaultView.Count == 0) { MessageBox.Show(this, "没有找到 " + field + " 包含 “" + keyword + "” 的项目。", "查找", MessageBoxButtons.OK, MessageBoxIcon.Information); } } private static string MapSearchFieldToColumn(string field) { if (string.IsNullOrEmpty(field)) return ""; string f = field.Replace(" ", "").Trim(); if (f == "项目名称" || f == "名称") return "项目名称"; if (f == "项目编号" || f == "编号") return "项目编号"; if (f == "客户名称" || f == "客户") return "客户名称"; return ""; } private static string EscapeLikeValue(string value) { if (string.IsNullOrEmpty(value)) return ""; var sb = new System.Text.StringBuilder(value.Length * 2); foreach (char c in value) { if (c == '\'') sb.Append("''"); else if (c == '%' || c == '_' || c == '[' || c == ']') sb.Append('[').Append(c).Append(']'); else sb.Append(c); } return sb.ToString(); } // ---------- 导出数据(toolStripButton1)---------- private void toolStripButton1_Click(object sender, EventArgs e) { string pid = ""; try { var projects = ReadAllProjectsFromDatabase(); if (projects == null || projects.Count == 0) { MessageBox.Show(this, "项目列表为空,无项目可导出。", "导出项目", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } if (projects.Count == 1) { pid = projects[0].ContainsKey("项目编号") ? (projects[0]["项目编号"] ?? "").ToString() : ""; } else { pid = PickProjectDialog(projects); if (string.IsNullOrWhiteSpace(pid)) return; } if (string.IsNullOrWhiteSpace(pid)) { MessageBox.Show(this, "未能确定要导出的项目编号。", "导出项目", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // 先保存当前项目的所有改动 SaveAllToDatabaseSilent(); string dbPath = GetQuoteDbPath(pid); if (!File.Exists(dbPath)) { MessageBox.Show(this, "项目数据库文件不存在:\n" + dbPath, "导出项目", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var meta = ReadProjectMetaData(pid); if (meta == null) { MessageBox.Show(this, "项目元数据读取失败,项目编号:" + pid, "导出项目", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } string projName = meta.ContainsKey("项目名称") ? (meta["项目名称"] ?? "").ToString() : ""; string safeName = MakeSafeFileName(projName + "_" + pid); string defaultFileName = safeName + "_" + DateTime.Now.ToString("yyyyMMdd") + ".zip"; string initialDir = ""; try { string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); if (!string.IsNullOrEmpty(desktop) && Directory.Exists(desktop)) initialDir = desktop; } catch { } if (string.IsNullOrEmpty(initialDir)) { try { string docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); if (!string.IsNullOrEmpty(docs) && Directory.Exists(docs)) initialDir = docs; } catch { } } // ★ 打包到临时 zip(不弹保存对话框,直接联动主窗口) string tempZip = Path.Combine(Path.GetTempPath(), "cadcj_导出_" + Guid.NewGuid().ToString("N") + ".zip"); string tempDir = Path.Combine(Path.GetTempPath(), "cadcj_导出_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); try { // 复制 db(用 SQL ATTACH 绕开文件锁,兜底 File.Copy) string dstDb = Path.Combine(tempDir, "项目" + MakeSafeFileName(pid) + ".db"); BackupSQLiteDb(dbPath, dstDb); // 写 meta.json var sb = new System.Text.StringBuilder(); sb.Append("{\n \"项目编号\": \""); sb.Append(EscapeJsonString(pid)); sb.Append("\",\n \"字段\": {"); bool firstKv = true; foreach (var kv in meta) { if (firstKv) firstKv = false; else sb.Append(","); sb.Append("\n \""); sb.Append(EscapeJsonString(kv.Key)); sb.Append("\": \""); sb.Append(EscapeJsonString((kv.Value ?? "").ToString())); sb.Append("\""); } sb.Append("\n }\n}"); File.WriteAllText(Path.Combine(tempDir, "meta.json"), sb.ToString(), new System.Text.UTF8Encoding(false)); // 打包 zip if (File.Exists(tempZip)) File.Delete(tempZip); System.IO.Compression.ZipFile.CreateFromDirectory(tempDir, tempZip); // ★ 查找主窗口 exe(cadcj 目录的上级目录) string mainExePath = null; try { string pluginDir = Path.GetDirectoryName( new System.Uri(System.Reflection.Assembly.GetExecutingAssembly().CodeBase).LocalPath); // ★ 主程序托管后 assembly 目录=主程序目录,exe 在同级,先试同级 string candidateSameDir = Path.Combine(pluginDir, "成套报价软件.exe"); if (File.Exists(candidateSameDir)) mainExePath = candidateSameDir; if (string.IsNullOrEmpty(mainExePath)) { string parentDir = Path.GetFullPath(Path.Combine(pluginDir, "..")); string candidate = Path.Combine(parentDir, "成套报价软件.exe"); if (File.Exists(candidate)) mainExePath = candidate; } } catch { } if (!string.IsNullOrEmpty(mainExePath)) { // ★ 启动主窗口并传递 zip 路径,主窗口自动导入并打开项目 var psi = new System.Diagnostics.ProcessStartInfo { FileName = mainExePath, Arguments = "\"" + tempZip + "\"", UseShellExecute = true, }; System.Diagnostics.Process.Start(psi); MessageBox.Show(this, "已发送到主窗口!\n\n项目:" + projName + " (" + pid + ")\n主程序已打开该项目(若主程序未开会自动启动)。", "导出项目", MessageBoxButtons.OK, MessageBoxIcon.Information); } else { // ★ 找不到主窗口 exe,退回到保存对话框模式 using (var sfd = new SaveFileDialog { Title = "导出项目", Filter = "项目包 (*.zip)|*.zip", FileName = defaultFileName, RestoreDirectory = true, }) { if (!string.IsNullOrEmpty(initialDir)) sfd.InitialDirectory = initialDir; if (sfd.ShowDialog(this) != DialogResult.OK) return; string zipPath = sfd.FileName; File.Copy(tempZip, zipPath, true); MessageBox.Show(this, "导出成功!\n\n项目:" + projName + " (" + pid + ")\n文件:" + zipPath + "\n\n(未找到主窗口程序,已保存为zip文件,可手动导入)", "导出项目", MessageBoxButtons.OK, MessageBoxIcon.Information); } } } finally { try { Directory.Delete(tempDir, true); } catch { } // 临时 zip 不立即删除(主窗口可能正在读取) } } catch (Exception ex) { MessageBox.Show(this, "导出失败:\n" + ex.Message, "导出项目", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private System.Collections.Generic.List _keepMultiHooks; private void HookKeepMulti(System.Windows.Forms.DataGridView dgv) { if (dgv == null) return; var hook = new AdgvMsgHook(dgv); _keepMultiHooks.Add(hook); // 存字段防 GC } /// /// ADGV 右键多选保持钩子(NativeWindow):吞掉"右键点已选中行"的 WM_RBUTTONDOWN, /// 多选集合不被清空;右键菜单由 WM_CONTEXTMENU 正常弹出(与原 AdgvKeepMulti 子类语义一致)。 /// private class AdgvMsgHook : System.Windows.Forms.NativeWindow { private const int WM_RBUTTONDOWN = 0x0204; private readonly System.Windows.Forms.DataGridView _dgv; public AdgvMsgHook(System.Windows.Forms.DataGridView dgv) { _dgv = dgv; dgv.HandleCreated += (s, e) => AssignHandle(dgv.Handle); dgv.HandleDestroyed += (s, e) => ReleaseHandle(); if (dgv.IsHandleCreated) AssignHandle(dgv.Handle); } protected override void WndProc(ref System.Windows.Forms.Message m) { if (m.Msg == WM_RBUTTONDOWN && _dgv != null && _dgv.MultiSelect) { try { int lp = m.LParam.ToInt32(); var pos = new System.Drawing.Point(lp & 0xFFFF, (lp >> 16) & 0xFFFF); var hit = _dgv.HitTest(pos.X, pos.Y); if (hit.RowIndex >= 0 && _dgv.SelectedRows != null) { foreach (System.Windows.Forms.DataGridViewRow r in _dgv.SelectedRows) { if (r != null && r.Index == hit.RowIndex) return; // ★ 吞掉:ADGV 不清多选 } } } catch { /* 异常时退回默认行为 */ } } base.WndProc(ref m); } } /// 用 SQL ATTACH 复制 SQLite 库,兜底 File.Copy。 private void BackupSQLiteDb(string srcDbPath, string dstDbPath) { try { if (File.Exists(dstDbPath)) File.Delete(dstDbPath); } catch { } Exception sqlErr = null; try { using (var srcConn = new System.Data.SQLite.SQLiteConnection( "Data Source=" + srcDbPath + ";Version=3;Pooling=False;ReadOnly=True;")) { srcConn.Open(); using (var att = new System.Data.SQLite.SQLiteCommand("ATTACH DATABASE @path AS dst;", srcConn)) { att.Parameters.AddWithValue("@path", dstDbPath); att.ExecuteNonQuery(); } string selAll = "SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';"; var tables = new List>(); using (var sel = new System.Data.SQLite.SQLiteCommand(selAll, srcConn)) using (var rd = sel.ExecuteReader()) { while (rd.Read()) { string tn = rd.IsDBNull(0) ? "" : rd.GetString(0); string sql = rd.IsDBNull(1) ? "" : rd.GetString(1); if (!string.IsNullOrEmpty(tn)) tables.Add(new KeyValuePair(tn, sql)); } } foreach (var kv in tables) { string tn = kv.Key; string sql = kv.Value; if (!string.IsNullOrEmpty(sql)) { string dstSql = sql.Replace("CREATE TABLE " + tn, "CREATE TABLE dst." + tn); using (var c = new System.Data.SQLite.SQLiteCommand(dstSql, srcConn)) c.ExecuteNonQuery(); } using (var c = new System.Data.SQLite.SQLiteCommand( "INSERT INTO dst.\"" + tn + "\" SELECT * FROM main.\"" + tn + "\";", srcConn)) c.ExecuteNonQuery(); } using (var det = new System.Data.SQLite.SQLiteCommand("DETACH DATABASE dst;", srcConn)) det.ExecuteNonQuery(); return; } } catch (Exception ex) { sqlErr = ex; } // 兜底:File.Copy try { System.Data.SQLite.SQLiteConnection.ClearAllPools(); } catch { } try { if (File.Exists(dstDbPath)) File.Delete(dstDbPath); } catch { } File.Copy(srcDbPath, dstDbPath, true); } /// 弹出一个简单对话框,让用户从项目列表中选一个项目,返回项目编号。 private string PickProjectDialog(List> projects) { try { using (var dlg = new Form { Text = "选择要导出的项目", StartPosition = FormStartPosition.CenterParent, FormBorderStyle = FormBorderStyle.FixedDialog, MaximizeBox = false, MinimizeBox = false, ClientSize = new Size(520, 360), }) { var lbl = new Label { Text = "请选择要导出的项目:", Location = new Point(12, 12), Size = new Size(496, 23), TextAlign = ContentAlignment.MiddleLeft, }; dlg.Controls.Add(lbl); var lv = new ListView { Location = new Point(12, 40), Size = new Size(496, 260), View = View.Details, FullRowSelect = true, MultiSelect = false, HideSelection = false, }; lv.Columns.Add("项目编号", 100); lv.Columns.Add("项目名称", 160); lv.Columns.Add("客户名称", 130); lv.Columns.Add("项目日期", 90); foreach (var p in projects) { string bh = p.ContainsKey("项目编号") ? (p["项目编号"] ?? "").ToString() : ""; string mc = p.ContainsKey("项目名称") ? (p["项目名称"] ?? "").ToString() : ""; string kh = p.ContainsKey("客户名称") ? (p["客户名称"] ?? "").ToString() : ""; string rq = p.ContainsKey("项目日期") ? (p["项目日期"] ?? "").ToString() : ""; var item = new ListViewItem(bh); item.SubItems.Add(mc); item.SubItems.Add(kh); item.SubItems.Add(rq); lv.Items.Add(item); } if (lv.Items.Count > 0) lv.Items[0].Selected = true; dlg.Controls.Add(lv); var btnOk = new Button { Text = "导出选中项", DialogResult = DialogResult.OK, Location = new Point(330, 310), Size = new Size(85, 28), }; dlg.Controls.Add(btnOk); var btnCancel = new Button { Text = "取消", DialogResult = DialogResult.Cancel, Location = new Point(425, 310), Size = new Size(75, 28), }; dlg.Controls.Add(btnCancel); dlg.AcceptButton = btnOk; dlg.CancelButton = btnCancel; if (dlg.ShowDialog(this) == DialogResult.OK && lv.SelectedItems.Count > 0) return lv.SelectedItems[0].Text; return ""; } } catch { return ""; } } // ============================================================ // 窗体关闭:回写当前项目的所有改动 // ============================================================ private void Form3_Disposed(object sender, EventArgs e) { // ★ 窗体销毁是最后的保存机会:失败重试一次并写日志(原来静默吞掉=丢数据无迹可查) try { SaveAllToDatabaseSilent(); } catch (Exception ex1) { try { System.Threading.Thread.Sleep(200); SaveAllToDatabaseSilent(); WriteDebugLog("[扒图Cad] 销毁保存第一次失败,重试成功: " + ex1.Message); } catch (Exception ex2) { WriteDebugLog("[扒图Cad] 销毁保存两次失败,数据可能丢失: " + ex2.Message); } } // ★ 取消 CadBridge 事件订阅,避免事件泄漏 try { 成套报价软件.CadBridge.EntityErased -= Database_ObjectErased; 成套报价软件.CadBridge.PaPicked -= OnPaTextsPicked; 成套报价软件.CadBridge.F1Requested -= OnF1FromBridge; 成套报价软件.CadBridge.F2Requested -= OnF2FromBridge; 成套报价软件.CadBridge.F3Requested -= OnF3FromBridge; } catch { } } } }