using System; using System.Collections.Generic; using System.Data; using System.Diagnostics; using System.Drawing; using System.IO; using System.Windows.Forms; using Microsoft.Win32; using Smart.CustomComponents; namespace 成套报价软件 { /// /// 主窗口 的交互逻辑(事件处理)。 /// UI 布局定义见 主窗口.ui.cs。 /// public partial class MainForm { // ============================================================ // 数据库路径与表名常量 // ============================================================ /// 项目数据库文件相对路径(与 主窗口.ui.cs 中保持一致)。 private const string ProjectsDbPath = @"sjk\advancedDataGridView6Table.db"; /// 项目数据库表名(与 主窗口.ui.cs 中保持一致)。 private const string ProjectsTableName = "advancedDataGridView6Table"; /// 报价数据库所在目录(每个项目一个独立 db 文件)。 private const string QuoteDbDir = @"sjk\bjb"; /// 图号表名(对应 advancedDataGridView9)。 private const string TuhaoTableName = "advancedDataGridView9Table"; /// 箱号表名(对应 advancedDataGridView4)。 private const string XianghaoTableName = "advancedDataGridView4Table"; /// 元件表名(对应 advancedDataGridView5)。 private const string YuanjianTableName = "advancedDataGridView5Table"; /// 报价库里的项目信息表名(主存储:8 个项目信息字段)。 private const string ProjectInfoTableName = "项目信息"; // ============================================================ // 当前层级选中状态(联动核心) // ============================================================ /// 当前选中的项目编号(advancedDataGridView6 的行)。 private string _currentProjectId = ""; /// 当前选中的图号(advancedDataGridView9 的行)。 private string _currentTuhao = ""; /// 当前选中的箱号(advancedDataGridView4 的行)。 private string _currentXianghao = ""; /// 联动刷新的重入 guard,防止 SelectionChanged 互相触发导致死循环。 private bool _isReloading = false; /// 公式重算的重入 guard,防止重算改值时再次触发 CellValueChanged 形成死循环。 private bool _isRecalculating = false; /// tabControl1/tabControl2 同名标签同步的 guard,防止两个 SelectedIndexChanged 互相反向触发死循环。 private bool _isTabSyncing = false; // ----- 价格显示格式化配置(每个项目独立,随项目信息表加载)----- // 这 4 个值由「新建项目/修改项目」对话框设置,存到报价库的「项目信息」表, // 进入项目(双击项目行)时读取。仅影响报价页价格列的显示,不改变数据库原始精度。 private string _单台小数位文本 = "保留2位小数"; // 默认 2 位,与历史行为兼容 private string _单台取整方式 = "不取整"; private string _项目小数位文本 = "保留2位小数"; private string _项目取整方式 = "不取整"; // ----- 当前各表的内存数据(用于关闭/切换时回写数据库)----- // 绑定到 ADGV9/4/5 的 DataTable 引用;切换/关闭时用它把改动写回 SQLite。 private DataTable _tuhaoTable; private DataTable _xianghaoTable; private DataTable _yuanjianTable; // ----- 内存缓存:切换图号/箱号时不丢编辑,点保存才写库 ----- // 图号表每个项目只有一张,沿用 _tuhaoTable,不缓存。 // key = 图号, value = 该图号的箱号表(内存态,可能含未保存编辑) private System.Collections.Generic.Dictionary _xianghaoCache = new System.Collections.Generic.Dictionary(); // key = 图号 + "\n" + 箱号, value = 该箱号的元件表(内存态) private System.Collections.Generic.Dictionary _yuanjianCache = new System.Collections.Generic.Dictionary(); // ============================================================ // 右键菜单组件(运行时创建,不在设计器 XML 里) // ============================================================ private ContextMenuStrip _menuTuhao; // 图号右键菜单(advancedDataGridView9) private ContextMenuStrip _menuXianghao; // 箱号右键菜单(advancedDataGridView4) private ContextMenuStrip _menuYuanjian; // 元件右键菜单(advancedDataGridView5) // 方案页面(tabPage13)三个表格的右键菜单 private ContextMenuStrip _menuFakTuhao; // 图号右键菜单(advancedDataGridView图号) private ContextMenuStrip _menuFakXianghao; // 箱号右键菜单(advancedDataGridView箱号) private ContextMenuStrip _menuFakYuanjian; // 元件右键菜单(advancedDataGridView元件) /// /// 重写 OnLoad:它在所有 Load 事件订阅者之前执行 /// (包括 ui.cs 设计器注入的 LoadAllSqliteData)。 /// 在此确保项目库文件存在 —— 若被删过(项目已清空),则建一个空库, /// 这样 ui.cs 的 LoadSqliteData_advancedDataGridView6 里的 /// File.Exists 检查通过、查空表,不会弹“找不到数据库文件”。 /// 不修改 ui.cs,所有逻辑都在事件代码里。 /// protected override void OnLoad(EventArgs e) { try { EnsureProjectDbExists(GetDbFullPath(ProjectsDbPath)); } catch { // 失败不阻断启动;最坏情况下 ui.cs 会弹窗,但不会崩 } base.OnLoad(e); try { // 从配置文件恢复铜排参数(加工费等) Load铜排参数(); } catch { // 配置加载失败不阻断启动 } try { // 启动时自动刷新铜价(异步抓取,不阻塞 UI;失败由 btn刷新_Click 内部弹窗/记日志) this.btn刷新_Click(this, EventArgs.Empty); } catch { // 自动刷新失败不阻断启动 } try { // 每天首次启动自动备份全部数据到 backup\(静默,失败只记日志) RunDailyAutoBackupIfNeeded(); } catch { // 自动备份失败不阻断启动 } // ★ 检测命令行参数:如果有 zip 文件路径(CAD插件导出联动),自动导入并打开项目 try { string[] args = Environment.GetCommandLineArgs(); if (args != null && args.Length > 1) { string zipPath = args[1]; if (System.IO.File.Exists(zipPath) && zipPath.EndsWith(".zip", System.StringComparison.OrdinalIgnoreCase)) { // 延迟到窗口完全显示后再导入 this.BeginInvoke(new System.Action(() => { try { string newPid = ImportProjectFromZip(zipPath); if (!string.IsNullOrEmpty(newPid)) { // 自动打开该项目 _currentProjectId = newPid; EnterProjectCore(newPid); Log("[CAD联动] 已自动导入并打开项目: " + newPid); // 尝试删除临时 zip try { System.IO.File.Delete(zipPath); } catch { } } } catch (Exception ex) { Log("[CAD联动] 自动导入异常: " + ex.Message); } })); } } } catch { } } /// /// 确保项目数据库与项目表存在(为空表),不存在则自动创建。 /// 启动时(兜底 ui.cs 的 File.Exists 检查)和“新建项目”写入时都会调用。 /// 表结构必须与 ui.cs LoadSqliteData_advancedDataGridView6 查询的表名一致。 /// private void EnsureProjectDbExists(string dbPath) { string dir = System.IO.Path.GetDirectoryName(dbPath); if (!System.IO.Directory.Exists(dir)) { System.IO.Directory.CreateDirectory(dir); } using (var conn = new System.Data.SQLite.SQLiteConnection( "Data Source=" + dbPath + ";Version=3;")) { 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(); } // 老库迁移:已存在的项目表可能没有成套厂 4 列,这里幂等地补列(PRAGMA table_info 检测) EnsureColumns(conn, ProjectsTableName, new[] { "成套厂名称", "成套厂地址", "成套厂联系人", "成套厂联系电话" }); } } /// /// 查询项目表(advancedDataGridView6Table)的所有列名。 /// 用于 INSERT 时过滤掉 values 里项目表不存在的字段(如价格格式化配置), /// 避免“table has no column named xxx”错误。 /// private static System.Collections.Generic.List GetProjectTableColumns(System.Data.SQLite.SQLiteConnection conn) { var cols = new System.Collections.Generic.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 { // 查询失败返回空列表,调用方会得到 affected=0,不阻断流程 } return cols; } /// /// 幂等地为指定表补列:遍历需要补的列名,在 PRAGMA table_info 结果里查不到才 ALTER TABLE ADD COLUMN。 /// 用于老库结构升级(例如新增成套厂 4 列)。 /// private static void EnsureColumns(System.Data.SQLite.SQLiteConnection conn, string tableName, string[] columns) { if (columns == null || columns.Length == 0) return; // 取已有列名集合 var existing = new System.Collections.Generic.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(); } } } } /// 构造函数:初始化 UI 并绑定事件。 // 构造函数已迁至 MainForm.cs // ============================================================ // 选型页(tabPage10):汇总所有元件表 → advancedDataGridView1 // 选中行 → 该元件所在箱号明细 → advancedDataGridView12 // ============================================================ /// 选型页加载中标志,防止 SelectionChanged 联动死循环。 private bool _isLoadingSelectionPage = false; /// 选型页汇总表的内存数据(全列)。修改后回报价表用。 private DataTable _selectionSummaryTable = null; /// 选型页明细表内存数据(选中汇总行后填充)。 private DataTable _selectionDetailTable = null; /// 选型页加载范围:当前项目 / 当前图号。空=未初始化。 private string _selectionScope = ""; /// 选型页汇总表是否有修改(true 时切页才需要同步)。 private bool _selectionSummaryDirty = false; /// 用户最后主动点选的表格:0=选型页元件汇总 1=选型页箱号明细 2=报价页元件表。 /// ★ 替换目标以它为准——明细表在汇总行选中/替换后会重载并自动带选中行, /// "明细优先"的老规则会被这个自动选中劫持:用户点的是元件汇总的行, /// 实际却替换了明细表里的行(实测日志:同一产品有的行能换、有的行被拒的元凶)。 /// ★ 2=报价页元件表:选型页表格残留的选中行会抢走报价页替换的目标 /// (实测:报价页元件表选行点替换,却被选型页分支拦截"替换不了")。 /// 只记用户真实点击(SelectionChanged 且非加载态),程序性重绑/恢复不盖章。 private int _选型页最后点选表 = 0; /// 网页替换连发防抖:同一载荷 1 秒内只处理第一次。 /// 按钮一次点击多监听器/快速连点会连发多条替换消息(日志实测 0.8 秒 4 连发)。 private string _网页替换防抖键 = ""; private DateTime _网页替换防抖时刻 = DateTime.MinValue; /// 初始化选型页:挂事件 + comboBox5 初始化选项。 private void InitSelectionPage() { try { // comboBox5:选项 = 当前项目 / 当前图号,默认"当前项目" if (this.comboBox5 != null) { this.comboBox5.Items.Clear(); this.comboBox5.Items.AddRange(new object[] { "当前项目", "当前图号" }); this.comboBox5.SelectedIndex = 0; this.comboBox5.SelectedIndexChanged += (s, e) => { if (_isLoadingSelectionPage) return; _selectionMergeMode = true; // 切范围=合并一次(用户要求) LoadSelectionSummary(true); }; } // advancedDataGridView12 运行时补一个"图号"列(显示元件所在图号) if (this.advancedDataGridView12 != null && !this.advancedDataGridView12.Columns.Contains("图号")) { var colTH = new System.Windows.Forms.DataGridViewTextBoxColumn(); colTH.Name = "图号"; colTH.HeaderText = "图号"; colTH.DataPropertyName = "图号"; colTH.Width = 100; colTH.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable; // 放在最前面(序号之后) this.advancedDataGridView12.Columns.Insert(1, colTH); } // ★ "图纸"列保持在最后(用户要求),给足宽度;显示扒图建箱时写入的图纸名。 // 配套修滚动:明细表在设计器里被设了 Enabled=false(禁用的表格不响应鼠标, // 滚动条用不了,超出屏幕的列看不到)——这里启用 + 显式横向滚动条。 if (this.advancedDataGridView12 != null) { try { this.advancedDataGridView12.Enabled = true; this.advancedDataGridView12.ScrollBars = System.Windows.Forms.ScrollBars.Both; } catch { } foreach (System.Windows.Forms.DataGridViewColumn colTZ in this.advancedDataGridView12.Columns) { if (colTZ.DataPropertyName == "图纸") { colTZ.Width = 140; colTZ.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable; break; } } } // ★ advancedDataGridView12 设置:整个表只读,只允许"箱号"列编辑 // (用户可在 UI 上修改箱号单元格文字,但不会保存到数据库——纯展示) if (this.advancedDataGridView12 != null) { // 整个表先设为只读 this.advancedDataGridView12.ReadOnly = true; // 然后单独把"箱号"列设为可编辑(覆盖表的 ReadOnly) if (this.advancedDataGridView12.Columns.Contains("箱号")) { this.advancedDataGridView12.Columns["箱号"].ReadOnly = false; } // 订阅 CellValueChanged:用户改了箱号后,把值还原回去(实现"能改但不保存") this.advancedDataGridView12.CellValueChanged += (s, e) => { if (_isLoadingSelectionPage) return; if (e.RowIndex < 0) return; if (e.ColumnIndex < 0) return; var col = this.advancedDataGridView12.Columns[e.ColumnIndex]; if (col == null) return; // 只对"箱号"列做还原 if (col.Name != "箱号") return; // 还原:从底层数据源(_selectionDetailTable)读回原值 if (_selectionDetailTable != null && e.RowIndex < _selectionDetailTable.Rows.Count) { object origVal = _selectionDetailTable.Rows[e.RowIndex]["箱号"]; this.advancedDataGridView12.Rows[e.RowIndex].Cells[e.ColumnIndex].Value = origVal; } }; } // 汇总表选中行变化 → 加载明细 + 盖"最后点选"章(汇总) if (this.advancedDataGridView1 != null) { this.advancedDataGridView1.SelectionChanged += (s, e) => { if (_isLoadingSelectionPage) return; _选型页最后点选表 = 0; // ★ 明细加载推迟到鼠标消息处理完之后——同步执行会在 // MouseDown→MouseUp 流程中间做数据库查询+重建明细表, // 把原生选择状态机拉长,编辑完点击其他单元格时 // 微小鼠标移动被误判为拖拽框选(莫名多选一片单元格) 延迟加载选型明细(); }; } // 明细表选中变化 → 盖"最后点选"章(明细) if (this.advancedDataGridView12 != null) { this.advancedDataGridView12.SelectionChanged += (s, e) => { if (_isLoadingSelectionPage) return; _选型页最后点选表 = 1; }; } // 报价页元件表选中变化 → 盖"最后点选"章(元件表=2)—— // 用户在报价页点元件表的行,网页/库替换就必须作用于元件表, // 不能被选型页表格残留的选中行抢走目标 if (this.advancedDataGridView5 != null) { this.advancedDataGridView5.SelectionChanged += (s, e) => { if (_isReloading) return; _选型页最后点选表 = 2; }; } // 切到选型页时自动加载;切到报价页时重算并刷新显示 this.tabControl2.SelectedIndexChanged += (s, e) => { if (_isLoadingSelectionPage) return; if (this.tabControl2 == null) return; if (this.tabControl2.SelectedTab == this.tabPage10) { // 切到选型页:合并一次(用户要求:只有打开选型页时才合并) _selectionMergeMode = true; LoadSelectionSummary(true); } else if (this.tabControl2.PreviousSelectedTab == this.tabPage10) { // ★ 停靠版修正:仅"选型页 → 其他页"时同步(与旧版 TabControl 行为一致)。 // 停靠面板任意窗口切回报价都会触发本事件;若不限来源, // 母排/GCS 窗口刚插入、尚未落库的行会被下面的强制重载冲掉。 // 离开选型页:如有修改,先把元件级数据写入数据库 if (this._selectionSummaryDirty) { SyncSelectionChangesToDb(); } // 切到报价页(无论是否有修改,都强制重算刷新一次, // 因为可能之前从选型页切到其他页时已经写过库,但还没重算聚合) if (this.tabControl2.SelectedTab == this.tabPage4) { RefreshQuotePageAfterSelectionEdit(); } // ★ 切到其他库窗口(用户库/柜体库/母排/箱体)时:只重算报价页聚合数据, // 不动选型页的内存表与选中——用户点库窗口"替换"仍要作用于选型页选中行 else { try { 重算聚合(); } catch { } } } }; // 关闭窗口前同步选型页修改 this.FormClosing += (s, e) => { if (this._selectionSummaryDirty) SyncSelectionChangesToDb(); }; // 汇总表单元格编辑后,标记改动并重算派生列 if (this.advancedDataGridView1 != null) { // ★ 图纸规格列挪到"规格"左边紧挨着(用户要求:方便对比图纸规格与实际规格) try { int 规格位 = -1; foreach (System.Windows.Forms.DataGridViewColumn col找 in this.advancedDataGridView1.Columns) if (col找.DataPropertyName == "规格") { 规格位 = col找.DisplayIndex; break; } if (规格位 >= 0) { foreach (System.Windows.Forms.DataGridViewColumn colTZ in this.advancedDataGridView1.Columns) if (colTZ.DataPropertyName == "图纸规格") { if (colTZ.DisplayIndex > 规格位) colTZ.DisplayIndex = 规格位; // 插到规格位,规格顺移到右侧 else colTZ.DisplayIndex = 规格位 - 1 < 0 ? 0 : 规格位 - 1; // 已在左侧时紧贴规格 break; } } } catch { } // ★ 数量列禁止任何方式的编辑(用户要求):列只读 + 事件双保险还原。 // ★★ 必须按 DataPropertyName 匹配——设计器列的 Name 是 // "dataGridViewTextBoxColumnN" 这种自动名,"数量"只是绑定字段, // 之前按 Columns.Contains("数量")/col.Name=="数量" 找永远找不到, // 三道锁从来没生效过(实测双击数量仍可编辑)。 foreach (System.Windows.Forms.DataGridViewColumn col锁 in this.advancedDataGridView1.Columns) if (col锁.DataPropertyName == "数量") col锁.ReadOnly = true; this.advancedDataGridView1.CellBeginEdit += (s, e) => { var col = this.advancedDataGridView1.Columns[e.ColumnIndex]; if (col != null && col.DataPropertyName == "数量") e.Cancel = true; }; this.advancedDataGridView1.CellValueChanged += (s, e) => { if (_isLoadingSelectionPage) return; if (e.RowIndex < 0) return; var col = this.advancedDataGridView1.Columns[e.ColumnIndex]; if (col != null && col.DataPropertyName == "数量") { // 双保险:数量被程序性改动时还原为数据库值(数量只读,只能靠报价页改) if (_selectionSummaryTable != null && e.RowIndex < _selectionSummaryTable.Rows.Count) { // 不标记 dirty,不重算——数量列不参与选型页编辑 return; } } RecalcSelectionRow(e.RowIndex); this._selectionSummaryDirty = true; // 标记有修改,切页时需要同步 }; } } catch (Exception ex) { Log("[选型页初始化失败] " + ex.Message); } } /// /// 加载选型汇总数据到 advancedDataGridView1。 /// 数据来源:当前项目所有图号下所有箱号的所有元件表(或仅当前图号)。 /// 合并规则:元件名称 + 规格 + 表价 完全相同 → 合并为一行,数量累加。 /// /// 汇总加载模式:进入选型页/切范围=合并同类项;网页替换/插入后的刷新=不合并。 /// (用户要求:只有打开选型页时才做一次合并,平时刷新保持逐行) private bool _selectionMergeMode = true; /// 明细延迟加载是否已排队(合并连续触发的 SelectionChanged,只执行最后一次)。 private bool _selectionDetailPending = false; /// /// 把明细加载推迟到当前鼠标/键盘消息处理完之后执行,并合并重复触发。 /// 避免在 DataGridView 的 MouseDown→MouseUp 选择流程中间做数据库查询+重建明细表 /// (会干扰原生选择状态机,导致编辑完点击其他单元格时莫名多选一片)。 /// private void 延迟加载选型明细() { if (_selectionDetailPending) return; _selectionDetailPending = true; try { BeginInvoke((Action)(() => { _selectionDetailPending = false; if (IsDisposed || Disposing) return; if (_isLoadingSelectionPage) return; LoadSelectionDetail(); })); } catch { _selectionDetailPending = false; LoadSelectionDetail(); // 句柄不可用时退化为同步加载 } } /// 有价格行高亮事件是否已挂(只挂一次)。 private bool _selectionPriceHighlightWired = false; private void LoadSelectionSummary() { LoadSelectionSummary(_selectionMergeMode); } /// true=名称+规格+图纸规格+类型+表价 相同合并(数量累加); /// false=逐行平铺不合并(网页替换/插入后的即时刷新用)。 private void LoadSelectionSummary(bool 合并同类项) { _isLoadingSelectionPage = true; try { // 当前范围 _selectionScope = (this.comboBox5 != null && this.comboBox5.SelectedItem != null) ? this.comboBox5.SelectedItem.ToString() : "当前项目"; if (string.IsNullOrWhiteSpace(_currentProjectId)) { // 无项目,清空 if (this.advancedDataGridView1 != null) this.advancedDataGridView1.DataSource = null; if (this.advancedDataGridView12 != null) this.advancedDataGridView12.DataSource = null; _selectionSummaryTable = null; _selectionDetailTable = null; return; } string dbPath = GetQuoteDbPath(_currentProjectId); if (!System.IO.File.Exists(dbPath)) { if (this.advancedDataGridView1 != null) this.advancedDataGridView1.DataSource = null; _selectionSummaryTable = null; return; } // 按图号遍历:图号表 → 每个图号的所有元件表(由表索引"类型='元件'"列出) // 一个图号有多个箱号,每个箱号一张元件表 var allRows = new System.Collections.Generic.List(); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // 拉图号列表(图号表所有行)——★ 读"内部ID"列(不是"图号"显示名列), // 表索引里存的是内部ID,用内部ID查才能正确关联元件表 var tuhaoList = new System.Collections.Generic.List(); string sqlTuhao = "SELECT 内部ID FROM " + TuhaoTableName + ";"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sqlTuhao, conn)) using (var rd = cmd.ExecuteReader()) { while (rd.Read()) { object o = rd["内部ID"]; if (o != null && o != DBNull.Value) { string s = o.ToString(); if (!string.IsNullOrEmpty(s) && !tuhaoList.Contains(s)) tuhaoList.Add(s); } } } // 当前图号模式:只查 _currentTuhao if (_selectionScope == "当前图号" && !string.IsNullOrWhiteSpace(_currentTuhao)) { tuhaoList.RemoveAll(t => t != _currentTuhao); } // 列出该图号下所有"元件"索引记录(图号 + 箱号 + 实际表名) foreach (string th in tuhaoList) { string sqlIdx = "SELECT 箱号, 实际表名 FROM 表索引 WHERE 类型='元件' AND 图号=@th;"; using (var cmdIdx = new System.Data.SQLite.SQLiteCommand(sqlIdx, conn)) { cmdIdx.Parameters.AddWithValue("@th", th); using (var rd = cmdIdx.ExecuteReader()) { while (rd.Read()) { string xh = rd["箱号"] == null || rd["箱号"] == DBNull.Value ? "" : rd["箱号"].ToString(); string realTbl = rd["实际表名"] == null || rd["实际表名"] == DBNull.Value ? "" : rd["实际表名"].ToString(); if (string.IsNullOrEmpty(realTbl)) continue; // 读该元件表所有行 string sqlRead = "SELECT * FROM \"" + realTbl.Replace("\"", "") + "\";"; using (var cmdRead = new System.Data.SQLite.SQLiteCommand(sqlRead, conn)) using (var adapter = new System.Data.SQLite.SQLiteDataAdapter(cmdRead)) { var tmp = new DataTable(); adapter.Fill(tmp); // 给每行补上 图号/箱号(用于明细回链) if (!tmp.Columns.Contains("图号")) tmp.Columns.Add("图号", typeof(string)); if (!tmp.Columns.Contains("箱号")) tmp.Columns.Add("箱号", typeof(string)); foreach (DataRow r in tmp.Rows) { r["图号"] = th; r["箱号"] = xh; } foreach (DataRow rr in tmp.Rows) allRows.Add(rr); } } } } } } // 合并:按 元件名称+规格+表价 聚合(数量累加) var merged = new DataTable(); merged.Columns.Add("序号", typeof(string)); merged.Columns.Add("元件名称", typeof(string)); merged.Columns.Add("规格", typeof(string)); merged.Columns.Add("单位", typeof(string)); merged.Columns.Add("数量", typeof(double)); merged.Columns.Add("表价", typeof(double)); merged.Columns.Add("采购单价", typeof(double)); merged.Columns.Add("采购系数", typeof(double)); merged.Columns.Add("采购价", typeof(double)); merged.Columns.Add("报出单价", typeof(double)); merged.Columns.Add("报出系数", typeof(double)); merged.Columns.Add("报出价", typeof(double)); merged.Columns.Add("图纸规格", typeof(string)); merged.Columns.Add("品牌", typeof(string)); merged.Columns.Add("系列", typeof(string)); merged.Columns.Add("类型", typeof(string)); merged.Columns.Add("备注", typeof(string)); var keyIndex = new System.Collections.Generic.Dictionary(StringComparer.Ordinal); int seq = 1; foreach (var r in allRows) { string name = (r.Table.Columns.Contains("元件名称") ? (r["元件名称"] ?? "").ToString().Trim() : ""); string spec = (r.Table.Columns.Contains("规格") ? (r["规格"] ?? "").ToString().Trim() : ""); string leixing = (r.Table.Columns.Contains("类型") ? (r["类型"] ?? "").ToString().Trim() : ""); double biao = 0.0; if (r.Table.Columns.Contains("表价")) { double.TryParse((r["表价"] ?? "").ToString(), out biao); } // ★ 合并 key 加上"类型"与"图纸规格":类型不同(箱体/母排/普通元件)、 // 图纸规格不同的行绝不合并(用户要求比对图纸规格) string tuzhi = (r.Table.Columns.Contains("图纸规格") ? (r["图纸规格"] ?? "").ToString().Trim() : ""); string key = name + "\u0001" + spec + "\u0001" + leixing + "\u0001" + tuzhi + "\u0001" + biao.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture); if (!合并同类项) key = "\u0002row" + (merged.Rows.Count + 1); // 不合并模式:每行独立键 int rowIdx; if (keyIndex.TryGetValue(key, out rowIdx)) { // 累加数量 double curCnt = 0.0; object curCntVal = merged.Rows[rowIdx]["数量"]; double.TryParse((curCntVal ?? "").ToString(), out curCnt); double addCnt = 1.0; if (r.Table.Columns.Contains("数量")) double.TryParse((r["数量"] ?? "").ToString(), out addCnt); merged.Rows[rowIdx]["数量"] = curCnt + addCnt; } else { var newRow = merged.NewRow(); newRow["序号"] = seq.ToString(); seq++; newRow["元件名称"] = name; newRow["规格"] = spec; newRow["单位"] = r.Table.Columns.Contains("单位") ? (r["单位"] ?? "").ToString() : ""; newRow["图纸规格"] = tuzhi; double cnt = 1.0; if (r.Table.Columns.Contains("数量")) double.TryParse((r["数量"] ?? "").ToString(), out cnt); newRow["数量"] = cnt; newRow["表价"] = biao; double caigouXishu = 1.0; if (r.Table.Columns.Contains("采购系数")) double.TryParse((r["采购系数"] ?? "").ToString(), out caigouXishu); newRow["采购系数"] = caigouXishu; double baochuXishu = 1.0; if (r.Table.Columns.Contains("报出系数")) double.TryParse((r["报出系数"] ?? "").ToString(), out baochuXishu); newRow["报出系数"] = baochuXishu; newRow["采购单价"] = biao * caigouXishu; newRow["采购价"] = biao * caigouXishu * cnt; newRow["报出单价"] = biao * baochuXishu; newRow["报出价"] = biao * baochuXishu * cnt; newRow["图纸规格"] = tuzhi; newRow["品牌"] = r.Table.Columns.Contains("品牌") ? (r["品牌"] ?? "").ToString() : ""; newRow["系列"] = r.Table.Columns.Contains("系列") ? (r["系列"] ?? "").ToString() : ""; newRow["类型"] = r.Table.Columns.Contains("类型") ? (r["类型"] ?? "").ToString() : ""; newRow["备注"] = r.Table.Columns.Contains("备注") ? (r["备注"] ?? "").ToString() : ""; merged.Rows.Add(newRow); keyIndex[key] = merged.Rows.Count - 1; } } _selectionSummaryTable = merged; if (this.advancedDataGridView1 != null) { this.advancedDataGridView1.AutoGenerateColumns = false; // ★ 防乱跳:重绑前记住 CurrentCell 位置,重绑后恢复——用户点击瞬间若有 // 程序性刷新(替换/同步),焦点不再弹到别格 int 旧行 = -1, 旧列 = 0; try { if (this.advancedDataGridView1.CurrentCell != null) { 旧行 = this.advancedDataGridView1.CurrentCell.RowIndex; 旧列 = this.advancedDataGridView1.CurrentCell.ColumnIndex; } } catch { } this.advancedDataGridView1.DataSource = _selectionSummaryTable; ApplyPriceFormat(this.advancedDataGridView1, _单台小数位文本); // ★ 不默认选中任何行:重绑后 DataGridView 会自动选中第 0 行——用户切走再切回, // 点库替换/网页替换会误中第 0 行(实测"一下替换26处")。清掉默认选中, // 用户必须自己点选哪行,替换目标永远是用户明确选中的行。 // (但程序性刷新且用户原本有焦点时,恢复原位——不丢也不乱跳) if (旧行 >= 0 && 旧行 < this.advancedDataGridView1.Rows.Count) this.advancedDataGridView1.CurrentCell = this.advancedDataGridView1.Rows[旧行].Cells[旧列]; else { this.advancedDataGridView1.ClearSelection(); this.advancedDataGridView1.CurrentCell = null; } } // ★ 有价格行整行高亮(表价>0 → 淡黄;不加任何工具提示) // 用 CellFormatting 事件按需绘制:①选中单元格不卡(不用逐行改 Style,大数据量时 // 逐行赋值会引发整表重绘卡顿);②刷新/替换后颜色自动跟随数据,无需手动清理 try { if (this.advancedDataGridView1 != null && !_selectionPriceHighlightWired) { _selectionPriceHighlightWired = true; this.advancedDataGridView1.CellFormatting += (s2, e2) => { try { if (e2.RowIndex < 0 || _selectionSummaryTable == null || e2.RowIndex >= _selectionSummaryTable.Rows.Count) return; double b; double.TryParse((_selectionSummaryTable.Rows[e2.RowIndex]["表价"] ?? "").ToString(), out b); if (b > 0 && e2.CellStyle.BackColor == System.Drawing.SystemColors.Window) e2.CellStyle.BackColor = System.Drawing.Color.FromArgb(0xFF, 0xF8, 0xDC); } catch { } }; } } catch { } // 清空明细 _selectionDetailTable = null; if (this.advancedDataGridView12 != null) this.advancedDataGridView12.DataSource = _selectionDetailTable; } catch (Exception ex) { Log("[选型页加载汇总失败] " + ex.Message); } finally { _isLoadingSelectionPage = false; } } /// /// 汇总表选中行变化 → 加载该行元件所在的箱号明细(advancedDataGridView12)。 /// 明细列与 advancedDataGridView12 列定义对齐,新增"图号"列也填上。 /// private void LoadSelectionDetail() { _isLoadingSelectionPage = true; try { if (this.advancedDataGridView1 == null || this.advancedDataGridView12 == null) return; if (this.advancedDataGridView1.CurrentRow == null || this.advancedDataGridView1.CurrentRow.Index < 0) { _selectionDetailTable = null; this.advancedDataGridView12.DataSource = null; return; } int idx = this.advancedDataGridView1.CurrentRow.Index; if (_selectionSummaryTable == null || idx >= _selectionSummaryTable.Rows.Count) { _selectionDetailTable = null; this.advancedDataGridView12.DataSource = null; return; } DataRow selRow = _selectionSummaryTable.Rows[idx]; string selName = (selRow["元件名称"] ?? "").ToString().Trim(); string selSpec = (selRow["规格"] ?? "").ToString().Trim(); double selBiao = 0.0; double.TryParse((selRow["表价"] ?? "").ToString(), out selBiao); // 重查数据库,找该元件出现在哪些图号+箱号 var detail = new DataTable(); detail.Columns.Add("序号", typeof(string)); detail.Columns.Add("图号", typeof(string)); detail.Columns.Add("箱号", typeof(string)); detail.Columns.Add("型号", typeof(string)); detail.Columns.Add("规格", typeof(string)); detail.Columns.Add("单位", typeof(string)); detail.Columns.Add("数量", typeof(double)); detail.Columns.Add("单台成本", typeof(double)); detail.Columns.Add("成本价", typeof(double)); detail.Columns.Add("单台报出", typeof(double)); detail.Columns.Add("报出价", typeof(double)); detail.Columns.Add("利润", typeof(double)); detail.Columns.Add("利润率", typeof(double)); detail.Columns.Add("毛利率", typeof(double)); detail.Columns.Add("箱柜类型", typeof(string)); detail.Columns.Add("备注", typeof(string)); detail.Columns.Add("图纸", typeof(string)); // 隐藏列:元件表 rowid——明细选中行替换时按它精确锁定那一条物理行, // 同名同规的其他行绝不被波及(每行隔离,数据不互通) detail.Columns.Add("行ID", typeof(long)); if (!string.IsNullOrWhiteSpace(_currentProjectId)) { string dbPath = GetQuoteDbPath(_currentProjectId); if (System.IO.File.Exists(dbPath)) { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // 该范围(当前项目/当前图号)下,所有元件表里 图号+箱号+元件名称+规格+表价 匹配的行 string scopeFilter = ""; if (_selectionScope == "当前图号" && !string.IsNullOrWhiteSpace(_currentTuhao)) scopeFilter = " AND 图号=@th "; string sqlIdx = "SELECT 图号, 箱号, 实际表名 FROM 表索引 WHERE 类型='元件'" + scopeFilter + ";"; using (var cmdIdx = new System.Data.SQLite.SQLiteCommand(sqlIdx, conn)) { if (_selectionScope == "当前图号" && !string.IsNullOrWhiteSpace(_currentTuhao)) cmdIdx.Parameters.AddWithValue("@th", _currentTuhao); using (var rd = cmdIdx.ExecuteReader()) { int seq = 1; // 图纸名缓存(图号+箱号 → 箱号表.图纸列):同一箱号的多行元件只查一次库 var 图纸缓存 = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); // ★ 真隔离(2026-09-29):箱号显示名缓存(图号+箱号键 → 箱号表.箱号列)—— // 元件索引"箱号"字段新数据存内部ID GUID,明细"箱号"列要显示显示名, // 同一箱号的多行元件只查一次库 var 箱名缓存 = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); while (rd.Read()) { string th = (rd["图号"] ?? "").ToString(); string xh = (rd["箱号"] ?? "").ToString(); string realTbl = (rd["实际表名"] ?? "").ToString(); if (string.IsNullOrEmpty(realTbl)) continue; // ★ 真隔离:GUID 键 → 箱号显示名(老数据键=显示名,解析不到就原样返回) string xhDisplay = 取箱号显示名(conn, th, xh, 箱名缓存); // 查该元件表里匹配的行(先 Fill 到 DataTable,再用它处理列存在性) // ★ WHERE 用"裁剪后比对"——库里行值带首尾空格/全角空格/NULL 时, // 拿汇总表 Trim 过的显示值精确等值会查不出行(与选型页替换同病根) // ★ 带 rowid AS _行ID_:明细行替换按它精确锁定单条物理行 string sqlMatch = "SELECT rowid AS _行ID_, * FROM \"" + realTbl.Replace("\"", "") + "\" " + "WHERE TRIM(IFNULL(元件名称,''), ' '||char(9)||char(10)||char(13)||char(12288))=@n " + "AND TRIM(IFNULL(规格,''), ' '||char(9)||char(10)||char(13)||char(12288))=@s;"; using (var cmdMatch = new System.Data.SQLite.SQLiteCommand(sqlMatch, conn)) { cmdMatch.Parameters.AddWithValue("@n", selName); cmdMatch.Parameters.AddWithValue("@s", selSpec); using (var adapterMatch = new System.Data.SQLite.SQLiteDataAdapter(cmdMatch)) { var matchTbl = new DataTable(); adapterMatch.Fill(matchTbl); foreach (DataRow r in matchTbl.Rows) { double biao = 0.0, caigou = 1.0, baochu = 1.0, cnt = 1.0; double.TryParse((r["表价"] ?? "").ToString(), out biao); if (matchTbl.Columns.Contains("采购系数")) double.TryParse((r["采购系数"] ?? "").ToString(), out caigou); if (matchTbl.Columns.Contains("报出系数")) double.TryParse((r["报出系数"] ?? "").ToString(), out baochu); if (matchTbl.Columns.Contains("数量")) double.TryParse((r["数量"] ?? "").ToString(), out cnt); var nr = detail.NewRow(); nr["序号"] = seq.ToString(); seq++; nr["图号"] = th; // ★ 真隔离:明细"箱号"列显示显示名(元件索引里是 GUID 键) nr["箱号"] = xhDisplay; // ★ 型号=该行元件名称(替换功能靠它定位行;之前恒空导致 // 明细选中行替换报"未找到匹配的元件行");填值也 Trim, // 库里带空格的行不再把空格带进明细定位链 nr["型号"] = matchTbl.Columns.Contains("元件名称") ? (r["元件名称"] ?? "").ToString().Trim() : ""; // 隐藏列:该行的元件表 rowid(明细单条替换的精确身份) try { nr["行ID"] = Convert.ToInt64(r["_行ID_"]); } catch { nr["行ID"] = 0L; } nr["规格"] = selSpec; nr["单位"] = matchTbl.Columns.Contains("单位") ? (r["单位"] ?? "").ToString() : ""; nr["数量"] = cnt; nr["单台成本"] = biao * caigou; nr["成本价"] = biao * caigou * cnt; nr["单台报出"] = biao * baochu; nr["报出价"] = biao * baochu * cnt; nr["利润"] = (biao * baochu * cnt) - (biao * caigou * cnt); double baojia = biao * baochu * cnt; double chengben = biao * caigou * cnt; // ★ 口径(2026-09-29):利润率保持小数;毛利率存百分数值(×100,保留2位) nr["利润率"] = baojia > 0 ? (baojia - chengben) / baojia : 0; nr["毛利率"] = baojia > 0 ? Math.Round((baojia - chengben) / baojia * 100.0, 2) : 0; nr["箱柜类型"] = ""; nr["备注"] = matchTbl.Columns.Contains("备注") ? (r["备注"] ?? "").ToString() : ""; // ★ 图纸列填真值:扒图建箱时把"当前打开的图纸名(去扩展名)" // 写进了箱号表的「图纸」列——按 图号+箱号 去查出来显示, // 用户就能看出每个元件来自哪张图纸(之前恒空串看不见) nr["图纸"] = 取箱号图纸(conn, th, xh, 图纸缓存); detail.Rows.Add(nr); } } } } } } } } } _selectionDetailTable = detail; this.advancedDataGridView12.AutoGenerateColumns = false; // ★ 防乱跳:已绑定同结构表时不再整表重绑(重绑会重置焦点/CurrentCell, // 用户点着明细单元格时视觉上"跳到别的单元格")——先 EndEdit+保持 CurrentCell, // 换 DataSource 后恢复原位 try { int 旧行 = -1, 旧列 = 0; if (this.advancedDataGridView12.CurrentCell != null) { 旧行 = this.advancedDataGridView12.CurrentCell.RowIndex; 旧列 = this.advancedDataGridView12.CurrentCell.ColumnIndex; } this.advancedDataGridView12.DataSource = _selectionDetailTable; if (旧行 >= 0 && 旧行 < this.advancedDataGridView12.Rows.Count) this.advancedDataGridView12.CurrentCell = this.advancedDataGridView12.Rows[旧行].Cells[旧列]; } catch { this.advancedDataGridView12.DataSource = _selectionDetailTable; } ApplyPriceFormat(this.advancedDataGridView12, _单台小数位文本); } catch (Exception ex) { Log("[选型页加载明细失败] " + ex.Message); } finally { _isLoadingSelectionPage = false; } } /// ★ 真隔离(2026-09-29):把元件索引"箱号"键(新数据=内部ID GUID,老数据=显示名) /// 解析成箱号显示名(查箱号表 WHERE 内部ID=@键;查不到=老数据键本身,原样返回)。 /// 结果进缓存(含未命中)防重复查库。 private static string 取箱号显示名(System.Data.SQLite.SQLiteConnection conn, string 图号, string 箱号键, System.Collections.Generic.Dictionary 缓存) { string 键 = (图号 ?? "") + "\u0001" + (箱号键 ?? ""); string 已有; if (缓存.TryGetValue(键, out 已有)) return 已有; string 结果 = (箱号键 ?? ""); try { if (!string.IsNullOrEmpty(图号) && !string.IsNullOrEmpty(箱号键)) { // 找该图号的箱号表(可能多张),按内部ID查显示名 var 箱表们 = new System.Collections.Generic.List(); using (var cmdT = new System.Data.SQLite.SQLiteCommand( "SELECT 实际表名 FROM 表索引 WHERE 类型='箱号' AND 图号=@th;", conn)) { cmdT.Parameters.AddWithValue("@th", 图号); using (var rdT = cmdT.ExecuteReader()) while (rdT.Read()) { string tn = (rdT["实际表名"] ?? "").ToString(); if (!string.IsNullOrEmpty(tn)) 箱表们.Add(tn); } } foreach (string tn in 箱表们) { string tbl = "\"" + tn.Replace("\"", "") + "\""; using (var cmdD = new System.Data.SQLite.SQLiteCommand( "SELECT 箱号 FROM " + tbl + " WHERE IFNULL(内部ID,'')=@xh LIMIT 1;", conn)) { cmdD.Parameters.AddWithValue("@xh", 箱号键); object o = cmdD.ExecuteScalar(); if (o != null && o != DBNull.Value && o.ToString().Trim() != "") { 结果 = o.ToString().Trim(); break; } } } } } catch { } 缓存[键] = 结果; return 结果; } /// 查某 图号+箱号 对应的图纸名(扒图建箱时写入箱号表「图纸」列的当前图纸名,去扩展名)。 /// 一个图号可能有多张箱号表,逐张按箱号找第一行非空「图纸」;结果进缓存(含空值)防重复查库。 /// 主窗口手工建的箱号没写图纸名 → 返回空串(列显示空白,与现状一致)。 private static string 取箱号图纸(System.Data.SQLite.SQLiteConnection conn, string 图号, string 箱号, System.Collections.Generic.Dictionary 缓存) { string 键 = (图号 ?? "") + "\u0001" + (箱号 ?? ""); string 已有; if (缓存.TryGetValue(键, out 已有)) return 已有; string 结果 = ""; try { if (!string.IsNullOrEmpty(图号)) { var 箱表们 = new System.Collections.Generic.List(); using (var cmdT = new System.Data.SQLite.SQLiteCommand( "SELECT 实际表名 FROM 表索引 WHERE 类型='箱号' AND 图号=@th;", conn)) { cmdT.Parameters.AddWithValue("@th", 图号); using (var rdT = cmdT.ExecuteReader()) while (rdT.Read()) { string tn = (rdT["实际表名"] ?? "").ToString(); if (!string.IsNullOrEmpty(tn)) 箱表们.Add(tn); } } foreach (string tn in 箱表们) { string tbl = "\"" + tn.Replace("\"", "") + "\""; // ★ 真隔离(2026-09-29):双键兼容——xh 可能是显示名(老数据元件索引) // 或内部ID GUID(新数据元件索引),用 OR 同时匹配箱号列和内部ID列 using (var cmdD = new System.Data.SQLite.SQLiteCommand( "SELECT 图纸 FROM " + tbl + " WHERE (TRIM(IFNULL(箱号,''))=@xh OR IFNULL(内部ID,'')=@xh) AND IFNULL(TRIM(图纸),'')!='' LIMIT 1;", conn)) { cmdD.Parameters.AddWithValue("@xh", (箱号 ?? "").Trim()); object o = cmdD.ExecuteScalar(); if (o != null && o != DBNull.Value && o.ToString().Trim() != "") { 结果 = o.ToString().Trim(); break; } } } } } catch { } 缓存[键] = 结果; return 结果; } /// /// 选型页替换后的原地更新:不重载整个汇总表(全表重绑必丢选中),只把界面上 /// 被替换的那一行(汇总选中行)的单元格值直接改掉——表格结构/绑定/选中全不动。 /// 数量保持原值(替换不动数量);派生列按新值重算。 /// private void 原地更新选型汇总行(string 名称, decimal 价格, string 品牌, string 采购系数, string 系列名, string 品名, bool 用明细, int 记行, string 记明细名称, string 记明细规格) { var 汇总 = this.advancedDataGridView1; if (汇总 == null || _selectionSummaryTable == null) return; decimal 系数 = 1m; decimal.TryParse(采购系数, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out 系数); if (系数 == 0m) 系数 = 1m; string 新名称 = string.IsNullOrWhiteSpace(品名) ? 清理系列字样(系列名) : 品名.Trim(); double biao = (double)价格, xs = (double)系数; // ★ 匹配键 = 选中汇总行的【完整旧身份】:名称+规格+图纸规格+类型+表价 五键全等 // (与写库 UPDATE 的行隔离键一致)。同名称+规格但表价/图纸不同的行绝不互相波及 // ——"替换一个,其他同名行全变"即老版只按名称+规格匹配造成的数据互通。 // ★ 明细模式:只替换了单条物理实例(rowid),汇总聚合行的数量归属已部分变化, // 整行改写成新产品反而误导——不改写,明细表由调用方重载显示真实状态。 if (用明细) return; DataRow 旧行 = null; try { var 汇 = GetFirstSelectedRow(汇总); if (汇 != null && !汇.IsNewRow) { var dv = 汇.DataBoundItem as DataRowView; if (dv != null) 旧行 = dv.Row; } } catch { } if (旧行 == null && 记行 >= 0 && 记行 < _selectionSummaryTable.Rows.Count) 旧行 = _selectionSummaryTable.Rows[记行]; if (旧行 == null) return; string 匹配名 = (旧行["元件名称"] ?? "").ToString().Trim(); string 匹配规 = (旧行["规格"] ?? "").ToString().Trim(); string 匹配图 = (旧行["图纸规格"] ?? "").ToString().Trim(); string 匹配类 = (旧行["类型"] ?? "").ToString().Trim(); double 匹配表价 = 0.0; double.TryParse((旧行["表价"] ?? "").ToString(), out 匹配表价); bool 旧加载态 = _isLoadingSelectionPage; _isLoadingSelectionPage = true; // 程序性写入:跳过 CellValueChanged 的重算/标脏 int 更新行数 = 0; try { for (int i = 0; i < _selectionSummaryTable.Rows.Count; i++) { var dr = _selectionSummaryTable.Rows[i]; if ((dr["元件名称"] ?? "").ToString().Trim() != 匹配名 || (dr["规格"] ?? "").ToString().Trim() != 匹配规 || (dr["图纸规格"] ?? "").ToString().Trim() != 匹配图 || (dr["类型"] ?? "").ToString().Trim() != 匹配类) continue; double 行表价 = 0.0; double.TryParse((dr["表价"] ?? "").ToString(), out 行表价); if (System.Math.Abs(行表价 - 匹配表价) > 0.000001) continue; // ① 数据层:原地改内存表的值(不动 DataSource,选中不丢)。 // 每行用自己的数量重算派生列——行与行完全隔离,互不波及。 double 数量 = 0.0; double.TryParse((dr["数量"] ?? "").ToString(), out 数量); dr["元件名称"] = 新名称; dr["规格"] = 名称 ?? ""; dr["系列"] = 系列名 ?? ""; dr["品牌"] = 品牌 ?? ""; dr["表价"] = biao; dr["采购系数"] = xs; dr["采购单价"] = biao * xs; dr["采购价"] = biao * xs * 数量; dr["报出单价"] = biao * xs; // 报出系数保持原值逻辑简化为与采购一致(与汇总重载口径相同) dr["报出价"] = biao * xs * 数量; 更新行数++; // ② 界面层:把该行新值直接写进网格单元格。增强表格对 DataRow 值变更的 // 自动重画不及时(InvalidateRow 也压不住),而 cell.Value 赋值走网格标准 // 编辑管线——立即失效重绘并写穿回绑定行。DataBoundItem 恒等定位网格行, // 排序/过滤下也不会错行;行号兜底(默认无排序时同序)。 int 网格行 = -1; try { for (int g = 0; g < 汇总.Rows.Count; g++) { var dv = 汇总.Rows[g].DataBoundItem as DataRowView; if (dv != null && dv.Row == dr) { 网格行 = g; break; } } } catch { } if (网格行 < 0 && i < 汇总.Rows.Count) 网格行 = i; if (网格行 >= 0 && 网格行 < 汇总.Rows.Count) { try { foreach (System.Windows.Forms.DataGridViewColumn col in 汇总.Columns) { string key = !string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : col.Name; if (string.IsNullOrEmpty(key) || !_selectionSummaryTable.Columns.Contains(key)) continue; 汇总.Rows[网格行].Cells[col.Index].Value = dr[key]; } } catch { } try { 汇总.InvalidateRow(网格行); } catch { } } // 行状态归位:数据库已由 UPDATE 写入同样值,清掉本行"已修改"状态—— // 否则后续切页同步会拿旧的 Original 值去匹配,0 命中导致用户编辑不落库 try { dr.AcceptChanges(); } catch { } } } catch { } finally { _isLoadingSelectionPage = 旧加载态; } if (更新行数 > 1) Log("[选型页] 原地更新了 " + 更新行数 + " 行(同行身份拆多行的全部刷新)"); } /// /// 选型页替换(网页"替换"按钮在选型页上的分支): /// · 明细表(adgv12)有选中行 → 只替换那一条(按 图号+箱号+元件名称+规格 定位); /// · 否则汇总表(adgv1)有选中行 → 替换该 元件名称+规格 的全部实例(跨图号跨箱号)。 /// 两者都直接写库(UPDATE,数量列不动),返回改动处数。 /// /// 选型页是否有选中的行(汇总或明细)——用作"替换走选型页"的判定。 /// ★ 不能用 SelectedTab==选型页 判定:用户点右侧库窗口(用户库/柜体库/母排/箱体) /// 的替换按钮时,焦点已漂到该库窗口,SelectedTab 不再是选型页,但用户的意图 /// 仍是替换选型页里选中的那行。"选型页有选中行"才是真实意图信号。 private bool 选型页有选中行() { try { var 汇 = GetFirstSelectedRow(this.advancedDataGridView1); if (汇 != null && !汇.IsNewRow) return true; var 明 = GetFirstSelectedRow(this.advancedDataGridView12); if (明 != null && !明.IsNewRow) return true; } catch { } return false; } /// 替换目标是否应为选型页(网页/库替换的统一分支判定): /// 用户最后点选的是报价页元件表(=2) → 一律走报价页分支; /// 否则选型页表格里有选中行 → 走选型页分支。 /// ★ 只看"选型页有选中行"会把选型页残留的选中行当成目标—— /// 用户切到报价页点元件表的行点替换,目标却被抢走(实测"元件表替换不了")。 private bool 替换目标在选型页() { if (_选型页最后点选表 == 2) return false; // 最后点的是报价页元件表 return 选型页有选中行(); } /// /// 库(用户库/柜体库/母排/箱体)替换的选型页统一入口: /// 目标=选型页汇总/明细选中行(与网页替换同规则),替换后原地更新选中行,不刷新整表不丢选中。 /// 返回 true=已处理(调用方直接 return)。 /// private void 库替换走选型页(string 元件名称, string 规格, string 系列名, string 品牌, string 表价文本, string 系数文本, string 库名) { // 定位选型页选中行:以"最后点选的表"为准(明细表重载会自动带选中行, // 不能"明细优先",否则点汇总行的替换被明细行劫持) bool 用明细 = false; int 记行 = -1; string 记明细名称 = null, 记明细规格 = null; Action 取明细 = delegate { var 明 = GetFirstSelectedRow(this.advancedDataGridView12); if (明 == null || 明.IsNewRow || _selectionDetailTable == null || 明.Index >= _selectionDetailTable.Rows.Count || !_selectionDetailTable.Columns.Contains("型号")) return; 用明细 = true; 记行 = 明.Index; var mdr = _selectionDetailTable.Rows[明.Index]; 记明细名称 = (mdr["型号"] ?? "").ToString().Trim(); 记明细规格 = (mdr["规格"] ?? "").ToString().Trim(); }; if (_选型页最后点选表 == 1) 取明细(); if (!用明细) { var 汇 = GetFirstSelectedRow(this.advancedDataGridView1); if (汇 != null && !汇.IsNewRow) { 记行 = 汇.Index; } } if (!用明细 && 记行 < 0) 取明细(); // 兜底:汇总无选中而明细有 if (记行 < 0 && 记明细名称 == null) { MessageBox.Show(this, "请先在选型页的表格里选中要替换的那一行(点一下该行任意格)。", 库名 + "替换", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } decimal 表价 = 0m, 系数 = 1m; decimal.TryParse(表价文本, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out 表价); decimal.TryParse(系数文本, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out 系数); if (系数 == 0m) 系数 = 1m; // 写库(选型页替换:明细行=单条;汇总行=全部实例;数量不动) int 改动 = 选型页替换(规格, 表价, 品牌, 系数.ToString(System.Globalization.CultureInfo.InvariantCulture), 系列名, 元件名称); if (改动 > 0) { // 原地更新选中行(不刷新整表,选中不丢) try { 原地更新选型汇总行(规格, 表价, 品牌, 系数.ToString(System.Globalization.CultureInfo.InvariantCulture), 系列名, 元件名称, 用明细, 记行, 记明细名称, 记明细规格); } catch { } try { LoadSelectionDetail(); } catch { } 重算聚合(); Log("[" + 库名 + "] 选型页替换完成(原地更新): " + 改动 + " 处 ← " + 元件名称 + " / " + 规格); } else { MessageBox.Show(this, "未找到匹配的元件行,替换未执行。", 库名 + "替换", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private int 选型页替换(string 规格, decimal 表价, string 品牌, string 采购系数, string 系列名, string 品名) { try { string pid = _currentProjectId; string dbPath = GetQuoteDbPath(pid); if (!System.IO.File.Exists(dbPath)) return 0; decimal 系数 = 1m; decimal.TryParse(采购系数, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out 系数); if (系数 == 0m) 系数 = 1m; string 元件名称 = string.IsNullOrWhiteSpace(品名) ? 清理系列字样(系列名) : 品名.Trim(); // 1) 定位目标:以"最后点选的表"为准(明细行=单条,汇总行=全部实例)。 // 不能"明细优先"——明细表重载后自动带选中行,会劫持用户点汇总行的意图。 // ★ 明细表的元件名列叫"型号"(LoadSelectionDetail 建列),汇总表才叫"元件名称" // ★ 网格行→数据行用 DataBoundItem 恒等映射(排序/过滤下也对),行号索引只做兜底 string 目标名称 = null, 目标规格 = null, 目标图号 = null, 目标箱号 = null; long 目标行ID = 0; // 明细模式:该行的元件表 rowid(精确单条) string 目标图纸 = null, 目标类型 = null; // 汇总模式:行身份键(图纸规格/类型) double 目标表价 = 0.0; // 汇总模式:行身份键(旧表价,数值比对) Action 取明细目标 = delegate { var 明细行 = GetFirstSelectedRow(this.advancedDataGridView12); if (明细行 == null || 明细行.IsNewRow || _selectionDetailTable == null || !_selectionDetailTable.Columns.Contains("型号")) return; var dv = 明细行.DataBoundItem as DataRowView; DataRow dr = dv != null ? dv.Row : (明细行.Index < _selectionDetailTable.Rows.Count ? _selectionDetailTable.Rows[明细行.Index] : null); if (dr == null) return; 目标名称 = (dr["型号"] ?? "").ToString().Trim(); 目标规格 = (dr["规格"] ?? "").ToString().Trim(); 目标图号 = (dr["图号"] ?? "").ToString().Trim(); 目标箱号 = (dr["箱号"] ?? "").ToString().Trim(); if (_selectionDetailTable.Columns.Contains("行ID")) { try { 目标行ID = Convert.ToInt64(dr["行ID"]); } catch { 目标行ID = 0; } } }; Action 取汇总目标 = delegate { var 汇总行 = GetFirstSelectedRow(this.advancedDataGridView1); if (汇总行 == null || 汇总行.IsNewRow || _selectionSummaryTable == null) return; var dv = 汇总行.DataBoundItem as DataRowView; DataRow dr = dv != null ? dv.Row : (汇总行.Index < _selectionSummaryTable.Rows.Count ? _selectionSummaryTable.Rows[汇总行.Index] : null); if (dr == null) return; 目标名称 = (dr["元件名称"] ?? "").ToString().Trim(); 目标规格 = (dr["规格"] ?? "").ToString().Trim(); 目标图纸 = (dr["图纸规格"] ?? "").ToString().Trim(); 目标类型 = (dr["类型"] ?? "").ToString().Trim(); double.TryParse((dr["表价"] ?? "").ToString(), out 目标表价); }; if (_选型页最后点选表 == 1) { 取明细目标(); if (目标名称 == null && 目标规格 == null) 取汇总目标(); } else { 取汇总目标(); if (目标名称 == null && 目标规格 == null && GetFirstSelectedRow(this.advancedDataGridView12) != null) 取明细目标(); } if (string.IsNullOrEmpty(目标名称) && string.IsNullOrEmpty(目标规格)) return 0; // 2) 遍历表索引找元件表,UPDATE 匹配行(数量不动) // ★ 行隔离(用户方案):替换只作用于选中行的【完整身份】—— // · 汇总行:名称+规格+图纸规格+类型+表价 五键全等(合并键)才命中。 // 之前只按名称+规格,大量行替换成同一元件后,再换其中一行会把 // 全项目同名同规的行一起换掉("替换一个,其他全变")。 // · 明细行:直接按 rowid 精确锁定那一条物理行。 // ★ 名称/规格两侧裁剪比对(库里行值可能带首尾空格/全角空格/NULL, // 拿界面 Trim 过的显示值精确等值会永远 0 行命中)。SET 写入干净新值自愈。 const string 裁剪名 = "TRIM(IFNULL(元件名称,''), ' '||char(9)||char(10)||char(13)||char(12288))"; const string 裁剪规 = "TRIM(IFNULL(规格,''), ' '||char(9)||char(10)||char(13)||char(12288))"; const string 裁剪图 = "TRIM(IFNULL(图纸规格,''), ' '||char(9)||char(10)||char(13)||char(12288))"; const string 裁剪类 = "TRIM(IFNULL(类型,''), ' '||char(9)||char(10)||char(13)||char(12288))"; int 改动 = 0; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sqlIdx = "SELECT 图号, 箱号, 实际表名 FROM 表索引 WHERE 类型='元件';"; var 表们 = new System.Collections.Generic.List(); using (var cmdIdx = new System.Data.SQLite.SQLiteCommand(sqlIdx, conn)) using (var rd = cmdIdx.ExecuteReader()) while (rd.Read()) 表们.Add(new[] { rd["图号"].ToString(), rd["箱号"].ToString(), rd["实际表名"].ToString() }); foreach (var t in 表们) { // 明细模式:只动 图号+箱号 都匹配的那张表 if (目标图号 != null && (t[0].Trim() != 目标图号 || t[1].Trim() != 目标箱号)) continue; string tbl = "\"" + t[2].Replace("\"", "") + "\""; // 该表有哪些列(条件拼装用;缺关键列的表按规则跳过) var 列集 = new System.Collections.Generic.HashSet(); try { using (var cmdP = new System.Data.SQLite.SQLiteCommand("PRAGMA table_info(" + tbl + ");", conn)) using (var rdP = cmdP.ExecuteReader()) while (rdP.Read()) 列集.Add((rdP["name"] ?? "").ToString()); } catch { } if (!列集.Contains("元件名称") || !列集.Contains("规格")) continue; string where; if (目标行ID > 0) { // 明细模式:rowid 精确锁定单条物理行 where = " WHERE rowid=@rid"; } else if (目标图号 == null) { // 汇总模式:五键全等(名称+规格+图纸规格+类型+表价) if (目标图纸 != "" && !列集.Contains("图纸规格")) continue; if (目标类型 != "" && !列集.Contains("类型")) continue; if (目标表价 != 0.0 && !列集.Contains("表价")) continue; where = " WHERE " + 裁剪名 + "=@n AND " + 裁剪规 + "=@s"; if (列集.Contains("图纸规格")) where += " AND " + 裁剪图 + "=@tz"; if (列集.Contains("类型")) where += " AND " + 裁剪类 + "=@lx"; if (列集.Contains("表价")) where += " AND ABS(IFNULL(表价,0)-@bj0)<0.000001"; } else { // 明细模式(无行ID的兜底):图号+箱号已锁定表,表内按名称+规格 if (string.IsNullOrEmpty(目标名称) && !string.IsNullOrEmpty(目标规格)) where = " WHERE " + 裁剪规 + "=@s"; else where = " WHERE " + 裁剪名 + "=@n AND " + 裁剪规 + "=@s"; } // ★ 单表失败不连坐:某表缺列/异常只记日志跳过,不让整批返回 0 try { using (var cmd = new System.Data.SQLite.SQLiteCommand( "UPDATE " + tbl + " SET 元件名称=@nn, 规格=@ns, 系列=@xl, 品牌=@pp, 表价=@bj, 采购系数=@xs" + where + ";", conn)) { if (where.IndexOf("@rid") >= 0) cmd.Parameters.AddWithValue("@rid", 目标行ID); if (where.Contains("=@n")) cmd.Parameters.AddWithValue("@n", 目标名称); if (where.Contains("=@s")) cmd.Parameters.AddWithValue("@s", 目标规格); if (where.Contains("=@tz")) cmd.Parameters.AddWithValue("@tz", 目标图纸 ?? ""); if (where.Contains("=@lx")) cmd.Parameters.AddWithValue("@lx", 目标类型 ?? ""); if (where.Contains("@bj0")) cmd.Parameters.AddWithValue("@bj0", 目标表价); cmd.Parameters.AddWithValue("@nn", 元件名称 ?? ""); cmd.Parameters.AddWithValue("@ns", 规格 ?? ""); cmd.Parameters.AddWithValue("@xl", 系列名 ?? ""); cmd.Parameters.AddWithValue("@pp", 品牌 ?? ""); cmd.Parameters.AddWithValue("@bj", (double)表价); cmd.Parameters.AddWithValue("@xs", 系数.ToString(System.Globalization.CultureInfo.InvariantCulture)); 改动 += cmd.ExecuteNonQuery(); } } catch (Exception ex) { Log("[选型页替换] 跳过表 " + t[2] + " : " + ex.Message); } } } if (改动 == 0) Log("[选型页替换] 0 处命中 : 名称=[" + 目标名称 + "] 规格=[" + 目标规格 + "] 图纸=[" + 目标图纸 + "] 类型=[" + 目标类型 + "] 表价=" + 目标表价 + (目标行ID > 0 ? " rowid=" + 目标行ID : "") + " 来源=" + (目标图号 != null ? ("明细 " + 目标图号 + "/" + 目标箱号) : "汇总")); return 改动; } catch (Exception ex) { Log("[选型页替换异常] " + ex.Message); return 0; } } /// 汇总表单元格修改后重算派生列(单价/采购价/报出价)。 private void RecalcSelectionRow(int rowIndex) { if (_selectionSummaryTable == null || rowIndex < 0 || rowIndex >= _selectionSummaryTable.Rows.Count) return; try { DataRow r = _selectionSummaryTable.Rows[rowIndex]; double biao = 0.0, caigou = 1.0, baochu = 1.0, cnt = 0.0; double.TryParse((r["表价"] ?? "").ToString(), out biao); double.TryParse((r["采购系数"] ?? "").ToString(), out caigou); double.TryParse((r["报出系数"] ?? "").ToString(), out baochu); double.TryParse((r["数量"] ?? "").ToString(), out cnt); // 系数默认为 1(空值时) if (caigou == 0) caigou = 1; if (baochu == 0) baochu = 1; r["采购单价"] = biao * caigou; r["采购价"] = biao * caigou * cnt; r["报出单价"] = biao * baochu; r["报出价"] = biao * baochu * cnt; r.EndEdit(); _selectionSummaryTable.AcceptChanges(); } catch (Exception ex) { Log("[选型页重算失败] " + ex.Message); } } /// /// 第一步:把选型页的元件级修改(表价/系数等)UPDATE 到数据库。 /// 只写元件表,不重算箱号/图号聚合(留给切回报价页时再做)。 /// private void SyncSelectionChangesToDb() { try { if (_selectionSummaryTable == null || _selectionSummaryTable.Rows.Count == 0) { _selectionSummaryDirty = false; return; } if (string.IsNullOrWhiteSpace(_currentProjectId)) { _selectionSummaryDirty = false; return; } string dbPath = GetQuoteDbPath(_currentProjectId); if (!System.IO.File.Exists(dbPath)) { _selectionSummaryDirty = false; return; } // ★ 先把所有元件表名收集到列表(避免 reader 占用连接时无法执行 UPDATE) var tblNames = new System.Collections.Generic.List(); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sqlList = "SELECT 实际表名 FROM 表索引 WHERE 类型='元件';"; using (var cmdList = new System.Data.SQLite.SQLiteCommand(sqlList, conn)) using (var rdList = cmdList.ExecuteReader()) { while (rdList.Read()) { string realTbl = (rdList["实际表名"] ?? "").ToString(); if (!string.IsNullOrEmpty(realTbl)) tblNames.Add(realTbl); } } } // ★ 用独立连接执行 UPDATE(reader 已关闭,不会冲突) using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // 每张元件表的列集(行隔离条件拼装用:缺列的表按规则跳过) var 表列集 = new System.Collections.Generic.Dictionary>(); foreach (string rt in tblNames) { var 集 = new System.Collections.Generic.HashSet(); try { using (var cmdP = new System.Data.SQLite.SQLiteCommand( "PRAGMA table_info(\"" + rt.Replace("\"", "") + "\");", conn)) using (var rdP = cmdP.ExecuteReader()) while (rdP.Read()) 集.Add((rdP["name"] ?? "").ToString()); } catch { } 表列集[rt] = 集; } using (var tran = conn.BeginTransaction()) { foreach (DataRow sr in _selectionSummaryTable.Rows) { string name = (sr["元件名称"] ?? "").ToString().Trim(); string spec = (sr["规格"] ?? "").ToString().Trim(); if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(spec)) continue; double biao = 0.0, caigou = 1.0, baochu = 1.0; double.TryParse((sr["表价"] ?? "").ToString(), out biao); double.TryParse((sr["采购系数"] ?? "").ToString(), out caigou); double.TryParse((sr["报出系数"] ?? "").ToString(), out baochu); string brand = (sr["品牌"] ?? "").ToString(); string xilie = (sr["系列"] ?? "").ToString(); string leixing = (sr["类型"] ?? "").ToString(); string beizhu = (sr["备注"] ?? "").ToString(); string tuzhi = (sr["图纸规格"] ?? "").ToString(); string danwei = (sr["单位"] ?? "").ToString(); // ★ 行隔离键取 Original(加载时的旧值)——SET 写的是新值, // WHERE 必须匹配库里的旧值才能命中要改的那几行 string 老图 = tuzhi.Trim(), 老类 = leixing.Trim(); double 老表价 = 0.0; try { 老图 = (sr["图纸规格", DataRowVersion.Original] ?? "").ToString().Trim(); } catch { } try { 老类 = (sr["类型", DataRowVersion.Original] ?? "").ToString().Trim(); } catch { } try { double.TryParse((sr["表价", DataRowVersion.Original] ?? "").ToString(), out 老表价); } catch { } // 遍历所有元件表:按该行的完整身份(名称+规格+图纸规格+类型+表价) // 隔离更新——同名称+规格的其他行(不同表价/图纸)绝不被波及 // ★ 派生列用 SQL 表达式让 SQLite 按每行自己的数量重算 foreach (string realTbl in tblNames) { var 列集 = 表列集.ContainsKey(realTbl) ? 表列集[realTbl] : null; if (列集 == null || !列集.Contains("元件名称") || !列集.Contains("规格")) continue; if (老图 != "" && !列集.Contains("图纸规格")) continue; if (老类 != "" && !列集.Contains("类型")) continue; if (老表价 != 0.0 && !列集.Contains("表价")) continue; string tbl = "\"" + realTbl.Replace("\"", "") + "\""; const string 裁剪集 = "' '||char(9)||char(10)||char(13)||char(12288)"; string condTz = 列集.Contains("图纸规格") ? " AND TRIM(IFNULL(图纸规格,'')," + 裁剪集 + ")=@tzo" : ""; string condLx = 列集.Contains("类型") ? " AND TRIM(IFNULL(类型,'')," + 裁剪集 + ")=@lxo" : ""; string condBj = 列集.Contains("表价") ? " AND ABS(IFNULL(表价,0)-@bjo)<0.000001" : ""; string sqlUpdFull = "UPDATE " + tbl + " SET " + "表价=@bj, " + "采购系数=@cgx, 报出系数=@bcx, " + "品牌=@pp, 系列=@xl, 类型=@lx, 备注=@bz, 图纸规格=@tz, 单位=@dw, " + "采购单价 = @bj * @cgx, " + "采购价 = (@bj * @cgx) * CAST(数量 AS REAL), " + "报出单价 = @bj * @cgx * @bcx, " + "报出价 = ((@bj * @cgx) * @bcx) * CAST(数量 AS REAL) " + "WHERE TRIM(IFNULL(元件名称,'')," + 裁剪集 + ")=@n " + "AND TRIM(IFNULL(规格,'')," + 裁剪集 + ")=@s" + condTz + condLx + condBj + ";"; using (var cmdUpd = new System.Data.SQLite.SQLiteCommand(sqlUpdFull, conn, tran)) { cmdUpd.Parameters.AddWithValue("@bj", biao); cmdUpd.Parameters.AddWithValue("@cgx", caigou); cmdUpd.Parameters.AddWithValue("@bcx", baochu); cmdUpd.Parameters.AddWithValue("@pp", brand); cmdUpd.Parameters.AddWithValue("@xl", xilie); cmdUpd.Parameters.AddWithValue("@lx", leixing); cmdUpd.Parameters.AddWithValue("@bz", beizhu); cmdUpd.Parameters.AddWithValue("@tz", tuzhi); cmdUpd.Parameters.AddWithValue("@dw", danwei); cmdUpd.Parameters.AddWithValue("@n", name); cmdUpd.Parameters.AddWithValue("@s", spec); if (condTz != "") cmdUpd.Parameters.AddWithValue("@tzo", 老图); if (condLx != "") cmdUpd.Parameters.AddWithValue("@lxo", 老类); if (condBj != "") cmdUpd.Parameters.AddWithValue("@bjo", 老表价); cmdUpd.ExecuteNonQuery(); } } } tran.Commit(); } } _selectionSummaryDirty = false; // 元件级数据已写入数据库,清掉脏标记 Log("[选型页] 已把 " + _selectionSummaryTable.Rows.Count + " 条元件修改写入数据库"); } catch (Exception ex) { Log("[选型页同步元件修改失败] " + ex.Message); } } /// /// 第二步:切回报价页(tabPage4)时调用。 /// 顺序:① 先 RecalcAllFromDatabase 把数据库的箱号/图号/项目汇总重算为新值 /// ② 再清空缓存 + Reload(读最新值到内存 + 显示) /// private void RefreshQuotePageAfterSelectionEdit() { try { if (string.IsNullOrWhiteSpace(_currentProjectId)) return; // ★ 直写库+触发器:元件写库时聚合已自动更新,这里只补跨库项目汇总 计算引擎.刷新项目汇总(GetQuoteDbPath(_currentProjectId), _currentProjectId, TuhaoTableName); // ★ 步骤②:清空所有内存缓存,从数据库 Reload 最新值到内存 + 刷新显示 _yuanjianCache.Clear(); _xianghaoCache.Clear(); _tuhaoTable = null; // ReloadTuhaoByProject 内部联动 ReloadXianghaoByTuhao + ReloadYuanjianByXianghao // 此时数据库已是重算后的新值,内存显示也是新值 ReloadTuhaoByProject(_currentProjectId); // 刷新项目列表显示(让项目汇总列也反映新值) ReloadProjectsFromDatabase(); Log("[选型页] 已重算箱号/图号/项目汇总并刷新报价页"); } catch (Exception ex) { Log("[选型页重算报价页失败] " + ex.Message); } } // ============================================================ // 价格查询 WebView:启动本地代理 + 加载网页 // ============================================================ /// 本地代理子进程实例,null 表示未启动或已退出。 private System.Diagnostics.Process _proxyProcess; /// node 服务存活监控定时器(30 秒一查,进程意外退出自动重启,页面不断)。 private System.Windows.Forms.Timer _proxyWatchTimer; private string _proxyNodeExe; // 监控重启用:node 路径 private string _proxyScript; // 监控重启用:server.cjs 路径 private int _proxyRestartCount; // 本次运行期重启次数(超限停止监控防死循环) /// WebView 页面锁定轮询定时器(每秒检测路径偏离),关窗时停掉。 private System.Windows.Forms.Timer _webViewLockTimer; /// 代理监听端口(proxy_server.py 里写死为 8080)。 private const int 代理端口 = 8080; /// 元件库选型页面(众企选型 dqb-1688 本地克隆页,由 元件库网页\server.cjs 在 /// 本机 8080 伺服:页面静态=dqb\ 克隆,数据接口=/api8k 反代线上;hash 路由。 /// 插入/替换按钮由 dqb\local-features.js 注入,直接经 chrome.webview 发 /// {action:'insert'/'replace',name,price,brand,xishu,xilie} 给 WebView21_WebMessageReceived。 private const string 代理地址 = "http://127.0.0.1:8080/apiopen/#/"; /// 启动 node 服务存活监控:30 秒一查,进程退出且非主程序关闭则自动重启(上限 20 次防死循环)。 private void 启动服务监控() { if (_proxyWatchTimer != null) return; // 已在监控 _proxyWatchTimer = new System.Windows.Forms.Timer { Interval = 30000 }; _proxyWatchTimer.Tick += (s, e) => { try { bool 死了 = _proxyProcess == null || _proxyProcess.HasExited; bool 页面需要 = this.webView21 != null && !this.webView21.IsDisposed; if (!死了 || !页面需要) return; if (_proxyRestartCount >= 20) { _proxyWatchTimer.Stop(); Log("[WebView] node 服务重启超 20 次,停止监控(请检查 元件库网页\\ 完整性)"); return; } _proxyRestartCount++; var psi = new System.Diagnostics.ProcessStartInfo { FileName = _proxyNodeExe, Arguments = "\"" + _proxyScript + "\"", WorkingDirectory = System.IO.Path.GetDirectoryName(_proxyScript), UseShellExecute = false, CreateNoWindow = true, WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden, }; _proxyProcess = System.Diagnostics.Process.Start(psi); Log("[WebView] node 服务意外退出,已重启 PID=" + (_proxyProcess == null ? "?" : _proxyProcess.Id.ToString()) + " (第 " + _proxyRestartCount + " 次)"); } catch (Exception ex) { Log("[WebView] 服务监控异常: " + ex.Message); } }; _proxyWatchTimer.Start(); } /// /// 启动价格查询:拉起 proxy_server.py,等端口就绪后导航 webView21。 /// 失败不抛异常,只在 diag.log 记录,避免影响主程序启动。 /// private void StartPriceWebView() { // ★ 启动自愈:上次若被强杀(任务管理器/编译脚本 Stop-Process),FormClosing 没跑, // proxy 会残留并占着 8080 端口,之后每次启动又起新进程,越积越多。 // 捷径:8080 空闲说明上次干净退出,跳过 PowerShell 清理(省 1~2 秒启动时间); // 被占用才按特征清残留 python(此时不碰 WebView2,避免误杀自己刚要起的浏览器进程)。 if (端口被占用(代理端口)) { Log("[WebView] 检测到 " + 代理端口 + " 端口被占用,清理上次残留的代理进程..."); 清理代理与WebView进程(false); } // 注:WebView2 浏览器子进程有意保留不杀(退出时也不杀)——下次启动搭已活进程组的车, // 内核初始化 0.5 秒(vs 全冷拉起 11 秒);重启电脑自然清空。 string baseDir = AppDomain.CurrentDomain.BaseDirectory; // proxy_server.py 只用 Python 标准库 // ★ 2026-09-12 元件库网页改造为 node 纯本地静态服务(数据=旧代理缓存平移到 元件库网页\ 目录): // 不再依赖 python/联网。node.exe 复用 cadbt 分发的那份(安装布局 exe旁\cadbt\node\,开发机回退)。 string webRoot = System.IO.Path.Combine(baseDir, "元件库网页"); string nodeExe = System.IO.Path.Combine(baseDir, "cadbt", "node", "node.exe"); if (!System.IO.File.Exists(nodeExe)) // 开发机回退:bin\Debug 上三级 = 仓库根(安装布局上走不到此分支) nodeExe = System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, @"..\..\..\cadbt\cadbt\bin\Debug\node\node.exe")); string script = System.IO.Path.Combine(webRoot, "server.cjs"); // 路径不存在直接放弃(避免弹错误框,只记日志) if (!System.IO.File.Exists(nodeExe)) { Log("[WebView] 未找到 node.exe: " + nodeExe); return; } if (!System.IO.File.Exists(script)) { Log("[WebView] 未找到 元件库网页\\server.cjs: " + script); return; } Log("[WebView] 使用 node: " + nodeExe); _proxyNodeExe = nodeExe; // 存给存活监控重启用 _proxyScript = script; try { var psi = new System.Diagnostics.ProcessStartInfo { FileName = nodeExe, Arguments = "\"" + script + "\"", WorkingDirectory = webRoot, UseShellExecute = false, CreateNoWindow = true, // 不弹黑色控制台窗口 WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden, RedirectStandardError = true, // 捕获 stderr,方便排查启动失败 }; _proxyProcess = System.Diagnostics.Process.Start(psi); Log("[WebView] 元件库静态服务已启动 PID=" + (_proxyProcess == null ? "?" : _proxyProcess.Id.ToString())); 启动服务监控(); } catch (Exception ex) { Log("[WebView] 启动 元件库静态服务 失败: " + ex.Message); return; } // ★ 元件库现为本地克隆页(127.0.0.1:8080,由上面的 node 静态服务伺服): // WebView 初始化(Ensure/桥/导航)与 node 启动并行;导航前等端口就绪(见下)。 // 但 StartPriceWebView 在构造早期被调,此时停靠窗口(w4)尚未创建(webView21 别名=null; // 本地页时代端口等待的 1~2 秒恰好掩盖了此时序)——挂到窗体 Load 后执行,构造完毕控件齐全。 Log("[WebView] WebView 初始化挂到窗体 Load(与 node 启动并行): " + 代理地址); EventHandler 延迟初始化 = null; 延迟初始化 = (s2, e2) => { try { this.Load -= 延迟初始化; } catch { } try { this.Invoke((Action)(async () => { try { // WebView2 必须先 EnsureCoreWebView2Async,完成后再 Navigate await this.webView21.EnsureCoreWebView2Async(); Log("[WebView] 步骤A:内核就绪 CoreWebView2=" + (this.webView21.CoreWebView2 != null ? "有" : "null")); // ★ 不再启动时 ClearBrowsingDataAsync:无参全量清缓存浏览器进程要跑约 10 秒, // 页面导航被卡在它后面(实测每次启动 WebView2 段固定耗 10s,程序 10 来秒不可用)。 // 防缓存改由下方导航 URL 加 _cb 时间戳参数实现,效果相同、零成本。 // ★ 订阅网页消息:接收选型页面里"插入/替换元件"按钮发来的数据 this.webView21.WebMessageReceived += WebView21_WebMessageReceived; // ★ 注入桥接脚本:dhub 嵌入页(dx24test)的插入/替换按钮用 window.parent.postMessage 发 // {status:'ok',params:{val:元件JSON,isAdd}}——WebView2 顶层窗口 parent===window, // 消息会落回页面自己没人收。桥把它翻译成现有协议 // (action/name/price/brand/xishu/xilie,字段映射 F_Model/F_Price/F_Manufacturer_Name/ // F_Purchase_Discount/F_Name)经 chrome.webview 发给 C#,接收端零改动: // name=型号 price=合计表价 xishu=综合折扣(如0.3,C# 算 采购单价=price×xishu=页面显示价) try { string 桥 = "(function(){ if(window.__dhub桥)return; window.__dhub桥=true;" + // ① 显示页面自带的替换按钮(#ReplaceExcelID,页面默认隐藏;强显后其自带的 // mousedown(Ye 构造数据)+click(发 isAdd:false 消息)逻辑直接可用) " function 加样式(){var h=document.head||document.documentElement;if(!h)return setTimeout(加样式,50);" + " if(document.getElementById('__showReplace'))return;" + " var s=document.createElement('style');s.id='__showReplace';" + " s.textContent='#ReplaceExcelID{display:inline-block !important;visibility:visible !important;}';" + " h.appendChild(s);} 加样式();" + // ③ 隐藏众企宝(apiopen)页面元素:顶部轮播广告条(.el-carousel)/优选公司块 // (.preferred-title/.preferred-content)/指定页签(按文本匹配)/广告弹窗 // (.ad-dialog 所在 el-dialog__wrapper 整层,含半透明遮罩)。 // 每 500ms 兜底重跑 + MutationObserver 即时响应,刷新/重渲染后不再露出。 // 仅注入到本软件 WebView,网站本身不受影响 " (function(){var 隐=function(){var h=document.head||document.documentElement;if(!h)return;" + " if(!document.getElementById('__hideEls')){var st=document.createElement('style');st.id='__hideEls';" + " st.textContent='.el-carousel,.preferred-title,.preferred-content{display:none !important;}';h.appendChild(st);" + " if(window.chrome&&window.chrome.webview&&window.chrome.webview.postMessage)" + " window.chrome.webview.postMessage(JSON.stringify({action:'log',msg:'隐藏脚本已生效'}));}" + " var ts=document.querySelectorAll('.tabs-item');" + " for(var i=0;i=0)系名=sv;}catch(e2){}" + " 发(JSON.stringify({action:(m.params.isAdd===false)?'replace':'insert'," + " name:String(v.F_Model),price:String(v.F_Price!=null?v.F_Price:'')," + " brand:String(v.F_Manufacturer_Name||'')," + " xishu:String(折),xilie:String(系名||v.F_Name||'')})); } }" + "}catch(e){} return 原生.apply(null,arguments); }; })();"; var cwv = this.webView21.CoreWebView2; if (cwv == null) { Log("[WebView] !! CoreWebView2 为 null,桥未注入"); } else { await cwv.AddScriptToExecuteOnDocumentCreatedAsync(桥); Log("[WebView] 步骤B:桥接脚本已注入(含替换按钮显示+协议翻译)"); } } catch (Exception ex) { Log("[WebView] 桥接脚本注入失败: " + ex); } // ★ 在浏览器内核层面锁定页面:阻止跳转到 /priceapp /m/price 等 HTML 页面 // 注意:API 请求(/dian/ /dx2/ /api8k/ 等)必须放行,否则页面加载不了数据 this.webView21.CoreWebView2.NavigationStarting += (sender, args) => { try { var uri = new System.Uri(args.Uri); if (uri.Host != "localhost" || uri.Port != 代理端口) { return; // 非本地代理,放行 } var p = uri.AbsolutePath; // 只拦截 /priceapp 和 /m/price(不拦 /price,否则自己拦自己) if (p == "/priceapp" || p.StartsWith("/priceapp/") || p == "/m/price" || p.StartsWith("/m/price/")) { Log("[WebView] 拦截页面跳转: " + args.Uri + " → 强制回 /price"); args.Cancel = true; this.webView21.CoreWebView2.Navigate(代理地址); } } catch { } }; // 额外保险:SourceChanged this.webView21.SourceChanged += (s, e) => { try { var uri = this.webView21.Source; if (uri != null && uri.Host == "localhost" && uri.Port == 代理端口) { var p = uri.AbsolutePath; if (p == "/priceapp" || p.StartsWith("/priceapp/") || p == "/m/price" || p.StartsWith("/m/price/")) { Log("[WebView] SourceChanged 异常: " + uri + " → 强制回 /price"); this.webView21.CoreWebView2.Navigate(代理地址); } } // 页面切换后,按当前报价页状态同步插入/替换按钮可用性 SyncQuoteButtonsState(); } catch { } }; // ★ 终极保险:C# 定时器每秒轮询页面 URL // Vue Router 的 pushState/replaceState 不触发 NavigationStarting/SourceChanged, // 只能从 C# 端主动 ExecuteScriptAsync 读取 location.pathname 并强制纠正。 var 锁定定时器 = new System.Windows.Forms.Timer(); 锁定定时器.Interval = 1000; // 每秒检查一次(太快会在页面加载中误判) var 跳过次数 = 5; // 页面刚加载的前 5 秒跳过检测(等 Vue 渲染完成) // ★ 把定时器提到字段,关窗时停掉(否则关窗瞬间 Tick 还会打一次 // ExecuteScriptAsync,await 恢复时 WebView 已销毁 → 异常弹窗闪现) _webViewLockTimer = 锁定定时器; 锁定定时器.Tick += (s, e) => { try { if (this.IsDisposed || this.Disposing) return; if (this.webView21 == null || this.webView21.CoreWebView2 == null) return; // 页面刚加载的前几秒跳过(Vue 还没初始化,ExecuteScriptAsync 会返回 null) if (跳过次数 > 0) { 跳过次数--; if (跳过次数 == 0) { Log("[Timer] 跳过期结束,开始检测"); } return; } Log("[Timer] Tick 执行检测"); // ★ 直接在 UI 线程 async/await(不用 ContinueWith,它在 WinForms 里调度有问题) 检测并纠正路径(); } catch (Exception ex) { Log("[Timer] 异常: " + ex.Message); } }; 锁定定时器.Start(); // _cb 时间戳:每次启动强制取最新 HTML(替代已移除的启动清缓存); // ★ 带 # 锚点(hash 路由)的地址,_cb 必须插到 # 之前,否则会被当成 // 路由参数(#/?_cb=...)破坏 SPA 路由 Log("[WebView] 步骤C:准备导航"); // ★ 页面在本地 8080:node 服务与本初始化并行启动,导航前必须等端口就绪 // (抢先导航=连接拒绝白屏)。最多等 8 秒,超时也导航(让页面错误可见)。 DateTime 端口死线 = DateTime.Now.AddSeconds(8); while (DateTime.Now < 端口死线 && !端口被占用(代理端口)) await System.Threading.Tasks.Task.Delay(200); Log("[WebView] 端口" + 代理端口 + (端口被占用(代理端口) ? "已就绪" : "等待超时(仍导航)") + ",导航开始"); string 导航地址 = 代理地址; int 锚点 = 导航地址.IndexOf('#'); string cb参数 = (代理地址.IndexOf('?') >= 0 ? "&" : "?") + "_cb=" + DateTime.Now.Ticks; if (锚点 >= 0) 导航地址 = 导航地址.Substring(0, 锚点) + cb参数 + 导航地址.Substring(锚点); else 导航地址 += cb参数; this.webView21.CoreWebView2.Navigate(导航地址); Log("[WebView] 步骤D:导航已发出"); } catch (Exception ex) { // async void 块的兜底:任何一步炸了也要记录(NRE 曾在 17:56:58 断在注入前,无行号) Log("[WebView] !! 初始化块异常: " + ex); } })); } catch (Exception ex) { Log("[WebView] 导航失败: " + ex.Message); } }; this.Load += 延迟初始化; } /// /// 根据当前是否处于"报价"标签页(tabPage4)或"选型"标签页(tabPage10),同步启用/禁用网页上的 /// "插入元件"/"替换选中" 按钮。其他页时按钮禁用并灰化。 /// (选型页也启用:AI 在元件库页面点插入时用户常停在选型页看数据) /// private void SyncQuoteButtonsState() { bool onQuotePage = this.tabControl2 != null && this.tabControl2.SelectedTab == this.tabPage4; bool onSelectPage = this.tabControl2 != null && this.tabControl2.SelectedTab == this.tabPage10; // ★ 报价页:插入+替换都启用;选型页:只启用替换(插入禁用,用户要求) bool enable = onQuotePage || onSelectPage; string mode = onSelectPage && !onQuotePage ? "select" : "quote"; try { if (this.webView21 != null && this.webView21.CoreWebView2 != null) { string js = "if(window.__setQuoteButtonsEnabled){window.__setQuoteButtonsEnabled(" + (enable ? "true" : "false") + ",'" + mode + "');}"; this.webView21.CoreWebView2.ExecuteScriptAsync(js); } } catch (Exception ex) { Log("[WebView] SyncQuoteButtonsState 异常: " + ex.Message); } } // ============================================================ // WebView2 网页消息处理:接收 price_standalone.html 里 // "插入元件"/"替换选中" 按钮通过 chrome.webview.postMessage 发来的数据, // 写入元件表(advancedDataGridView5)。 // 消息格式: {"action":"insert"|"replace","name":"元件名称","price":"719.25","brand":"品牌","xishu":"1"} // ============================================================ /// 接收网页 postMessage,解析后调用插入/替换。 private void WebView21_WebMessageReceived(object sender, Microsoft.Web.WebView2.Core.CoreWebView2WebMessageReceivedEventArgs e) { try { string msg = e.TryGetWebMessageAsString(); if (string.IsNullOrEmpty(msg)) return; string action = 取JSON字段(msg, "action"); string name = 取JSON字段(msg, "name"); string priceStr = 取JSON字段(msg, "price"); string brand = 取JSON字段(msg, "brand"); string xishu = 取JSON字段(msg, "xishu"); string xilie = 取JSON字段(msg, "xilie"); // 数量:选型页插入/替换按钮旁的数量输入框(1~9999),缺省 1 string shuliangStr = 取JSON字段(msg, "shuliang"); decimal shuliang = 1m; decimal.TryParse(shuliangStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out shuliang); if (shuliang < 1m) shuliang = 1m; if (shuliang > 9999m) shuliang = 9999m; // 品名:选型页价格区显示的"品名:XXX"行,作元件名称列的值;缺省空(回退系列名推算) string pinming = 取JSON字段(msg, "pinming"); decimal price = 0m; System.Globalization.NumberStyles ns = System.Globalization.NumberStyles.AllowDecimalPoint | System.Globalization.NumberStyles.AllowLeadingSign | System.Globalization.NumberStyles.AllowThousands; decimal.TryParse(priceStr, ns, System.Globalization.CultureInfo.InvariantCulture, out price); Log("[WebView] 收到元件操作: action=" + action + " name=" + name + " price=" + price + " 数量=" + shuliang + " brand=" + brand + " xishu=" + xishu + " xilie=" + xilie); if (string.IsNullOrEmpty(action)) return; // 网页脚本上报调试日志(如隐藏脚本生效通知),写 diag.log 即可 if (action == "log") { Log("[网页] " + 取JSON字段(msg, "msg")); return; } // 网页加载/刷新后主动询问当前是否在报价页,C# 立即回复 if (action == "queryState") { this.Invoke((Action)(() => { try { SyncQuoteButtonsState(); } catch (Exception ex) { Log("[WebView] queryState 回复异常: " + ex.Message); } })); return; } // 操作 UI 必须回到 UI 线程 this.Invoke((Action)(() => { try { if (action == "insert") 插入元件到报价表(name, price, brand, xishu, xilie, shuliang, pinming); else if (action == "replace") { // ★ 连发防抖:网页按钮一次点击可能连发多条替换消息(多监听器/快速连点, // 日志实测 0.8 秒 4 连发,每条各触发一次完整替换)。同一载荷 1 秒内 // 只处理第一次——用户要在 1 秒内换两行必须先点另一行再点替换,做不到。 string 防抖键 = name + "|" + price.ToString(System.Globalization.CultureInfo.InvariantCulture) + "|" + brand + "|" + xishu + "|" + xilie + "|" + shuliang.ToString(System.Globalization.CultureInfo.InvariantCulture) + "|" + pinming; if (防抖键 == _网页替换防抖键 && (DateTime.Now - _网页替换防抖时刻).TotalMilliseconds < 1000) { Log("[WebView] 替换连发防抖: 忽略 1 秒内重复消息"); return; } _网页替换防抖键 = 防抖键; _网页替换防抖时刻 = DateTime.Now; 替换选中元件(name, price, brand, xishu, xilie, shuliang, pinming); } } catch (Exception ex) { Log("[WebView] 元件操作异常: " + ex.Message); } })); } catch (Exception ex) { Log("[WebView] WebMessageReceived 异常: " + ex.Message); } } /// 从简单 JSON 字符串中取字符串字段值(支持 "key":"value" 及常见转义)。 private static string 取JSON字段(string json, string key) { if (string.IsNullOrEmpty(json)) return ""; var m = System.Text.RegularExpressions.Regex.Match(json, "\"" + System.Text.RegularExpressions.Regex.Escape(key) + "\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\""); if (!m.Success) return ""; string v = m.Groups[1].Value; return v.Replace("\\\"", "\"").Replace("\\\\", "\\").Replace("\\/", "/"); } /// /// 清理系列选择框值中的"XX系列"前缀,保留后面的产品类型描述。 /// 例如 "CM5系列塑料外壳式断路器" → "塑料外壳式断路器"; /// "NSX系列框架断路器" → "框架断路器"; /// "DZ47系列微型断路器" → "微型断路器"。 /// 若不含"系列"字样,则原样返回。 /// private static string 清理系列字样(string 系列名) { if (string.IsNullOrWhiteSpace(系列名)) return ""; string s = 系列名.Trim(); // 去掉"请选择"提示 if (s.IndexOf("请选择") >= 0) return ""; // 去掉 "型号系列" 前缀(型号为"系列"前的所有字符),保留后面的产品类型描述 // 例如 "CM5系列塑料外壳式断路器" → "塑料外壳式断路器" int idx = s.IndexOf("系列"); if (idx >= 0) { s = s.Substring(idx + 2).Trim(); } return s; } // ============================================================ // 元件库收藏功能 // comboBox6 = 公司(厂家), comboBox7 = 系列, button6 = 收藏按钮 // 数据持久化: shezhi\元件库收藏.config.xml // 结构: // ============================================================ /// 收藏配置文件路径。 private string 收藏配置路径 { get { return System.IO.Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, @"shezhi\元件库收藏.config.xml"); } } /// 初始化收藏功能:加载配置、绑定事件。 private void InitFavorites() { try { // 先加载数据,再绑定事件(避免加载时触发 SelectedIndexChanged 干扰网页) LoadFavorites(); // 绑定收藏按钮 this.button6.Click += button6_Click; // 绑定清理收藏按钮 this.button12.Click += button12_Click; // 绑定 comboBox6 选公司 → 刷新 comboBox7 的系列列表 this.comboBox6.SelectedIndexChanged += comboBox6_SelectedIndexChanged; // 绑定 comboBox7 选系列 → 在网页中跳转 this.comboBox7.SelectedIndexChanged += comboBox7_SelectedIndexChanged; Log("[收藏] 初始化完成"); } catch (Exception ex) { Log("[收藏] 初始化异常: " + ex.Message); } } /// 从配置文件加载收藏数据,填充 comboBox6。 private void LoadFavorites() { try { this.comboBox6.Items.Clear(); this.comboBox7.Items.Clear(); if (!System.IO.File.Exists(收藏配置路径)) return; var doc = new System.Xml.XmlDocument(); doc.Load(收藏配置路径); var companies = new System.Collections.Generic.HashSet(); foreach (System.Xml.XmlNode node in doc.SelectNodes("//fav")) { string c = node.Attributes["公司"] != null ? node.Attributes["公司"].Value : ""; if (!string.IsNullOrWhiteSpace(c)) companies.Add(c); } foreach (string c in companies) { this.comboBox6.Items.Add(c); } } catch (Exception ex) { Log("[收藏] 加载异常: " + ex.Message); } } /// comboBox6 选择公司时,刷新 comboBox7 的系列列表。 private void comboBox6_SelectedIndexChanged(object sender, EventArgs e) { try { this.comboBox7.Items.Clear(); string company = this.comboBox6.Text.Trim(); if (string.IsNullOrEmpty(company) || !System.IO.File.Exists(收藏配置路径)) return; var doc = new System.Xml.XmlDocument(); doc.Load(收藏配置路径); foreach (System.Xml.XmlNode node in doc.SelectNodes("//fav[@公司='" + company + "']")) { string s = node.Attributes["系列"] != null ? node.Attributes["系列"].Value : ""; if (!string.IsNullOrWhiteSpace(s)) this.comboBox7.Items.Add(s); } } catch { } } /// comboBox7 选择系列时,通过 JS 在网页中选择对应的公司和系列。 private async void comboBox7_SelectedIndexChanged(object sender, EventArgs e) { try { string company = this.comboBox6.Text.Trim(); string xilie = this.comboBox7.Text.Trim(); if (string.IsNullOrEmpty(xilie)) return; if (this.webView21 == null || this.webView21.CoreWebView2 == null) return; Log("[收藏] 开始跳转: 公司=" + company + " 系列=" + xilie); // 第1步:点击厂家节点 // ★ 页面 DOM 只有带 _dq123_price 后缀的 ID(双写兜底抄 price_standalone.html:1645) string js1 = "var tree=document.getElementById('factorytreeele')||document.getElementById('factorytreeele_dq123_price');" + "var result='no factory tree';" + "if(tree){" + " var a=tree.getElementsByTagName('a');" + " result='factory not found in '+a.length;" + " for(var i=0;i=0||x.indexOf('" + company.Replace("'", "\\'") + "')>=0){" + " a[i].click();" + " result='factory clicked: '+x;" + " break;" + " }" + " }" + "}" + "result"; var r1 = await this.webView21.CoreWebView2.ExecuteScriptAsync(js1); Log("[收藏] 厂家切换: " + r1); // 等2秒让厂家数据加载 await System.Threading.Tasks.Task.Delay(2000); // 第2步:展开系列树 string js2 = "var tree=document.getElementById('classtreeele')||document.getElementById('classtreeele_dq123_price');" + "var result='no class tree';" + "if(tree){" + " var sw=tree.querySelectorAll('span[class*=\"switch\"][class*=\"close\"]');" + " for(var i=0;i=0){sid=n.s;sname=n.name;return true;}" + "if(n.children&&fS(n.children))return true;}return false;};" + "fS(zt.getNodes());" + "if(sid==null)return 'ztree no series';" + "var fid=null;" + "if(ztf){var fF=function(ns){for(var i=0;i=0){fid=n.f;return true;}if(n.children&&fF(n.children))return true;}return false;};fF(ztf.getNodes());}" + "if(!(window.__standalone&&window.__standalone.选中系列并验证))return 'no api';" + "window.__standalone.选中系列并验证(fid,sid,'',0);" + "return 'api s='+sid+' f='+fid+' name='+sname;" + "})()"; // ★ 大厂家的系列树是渐进渲染的(如正泰数百个系列分批出现): // 单次查找时 zTree 数据可能还没含目标系列,循环重试直到调上 API 或超时(~10 秒)。 string r3 = ""; bool clicked = false; for (int attempt = 1; attempt <= 16; attempt++) { r3 = await this.webView21.CoreWebView2.ExecuteScriptAsync(js3); if (r3 != null && r3.Contains("api s=")) { Log("[收藏] 系列跳转(第" + attempt + "次): " + r3); clicked = true; break; } if (r3 != null && r3.Contains("no api")) { Log("[收藏] " + r3); break; } // 页面无官方API,重试无意义 if (attempt < 16) await System.Threading.Tasks.Task.Delay(600); } if (!clicked) Log("[收藏] 系列跳转结果(未命中): " + r3); // ★ 防覆盖外部验证:页面 API 内部重试也可能被更长时序的异步重载压过, // 每秒检查系列下拉值,不再是目标系列就重新调一次 API,最多 5 次。 if (clicked) { string jsVerify = "var inp=document.querySelector('input[id^=classSelele]');(inp?(inp.value||''):'')"; for (int v = 1; v <= 5; v++) { await System.Threading.Tasks.Task.Delay(1000); var rv = await this.webView21.CoreWebView2.ExecuteScriptAsync(jsVerify); string cur = (rv ?? "").Trim('"'); if (cur.Contains(xilie)) { Log("[收藏] 系列选中确认(第" + v + "次检查): " + cur); break; } Log("[收藏] 系列被覆盖(实际=" + cur + "),第" + v + "次重点"); r3 = await this.webView21.CoreWebView2.ExecuteScriptAsync(js3); if (r3 == null || !r3.Contains("api s=")) { Log("[收藏] 重点未命中: " + r3); break; } } } } catch (Exception ex) { Log("[收藏] 跳转异常: " + ex.Message); } } /// 收藏按钮:从网页获取当前厂家和系列,保存到配置文件。 private async void button6_Click(object sender, EventArgs e) { try { if (this.webView21 == null || this.webView21.CoreWebView2 == null) { MessageBox.Show(this, "网页未加载,请稍后再试。", "收藏"); return; } // 通过 JS 获取网页中当前选中的厂家和系列 string js = "var f='',c='';" + "var cls=document.querySelector('input.classSel')||document.getElementById('classSelele');" + "if(cls)c=cls.value||'';" + "var fac=document.querySelector('input.factorySel')||document.getElementById('factorySelele');" + "if(fac)f=fac.value||'';" + "if(!f||f.indexOf('请选择')>=0){var fn=document.getElementById('FactName')||document.getElementById('FactName_dq123_price');if(fn)f=(fn.textContent||'').trim();}" + "if(!c||c.indexOf('请选择')>=0){var s=document.querySelector('.curSelectedNode');if(s)c=(s.textContent||'').trim();}" + "if(f.indexOf('请选择')>=0)f='';" + "if(c.indexOf('请选择')>=0)c='';" + "JSON.stringify({factory:f,classs:c})"; var result = await this.webView21.CoreWebView2.ExecuteScriptAsync(js); Log("[收藏] JS返回: " + result); // ExecuteScriptAsync 返回的 JSON 字符串被额外包了一层引号,需要先去掉外层引号 // 例如: "\"{\\\"factory\\\":\\\"XX\\\"}\"" → 先去掉外层引号 → "{\"factory\":\"XX\"}" if (result != null && result.Length >= 2 && result.StartsWith("\"") && result.EndsWith("\"")) result = result.Substring(1, result.Length - 2).Replace("\\\"", "\"").Replace("\\\\", "\\"); string factory = 取JSON字段(result, "factory"); string classs = 取JSON字段(result, "classs"); // 系列必须选中,厂家可选(有些产品只有一个厂家) if (string.IsNullOrWhiteSpace(classs)) { MessageBox.Show(this, "请先在网页中选择系列,再点收藏。", "收藏"); return; } if (string.IsNullOrWhiteSpace(factory)) factory = "(默认厂家)"; // 保存到配置文件 SaveFavorite(factory.Trim(), classs.Trim(), ""); // 刷新下拉框 LoadFavorites(); // 选中新收藏的项 if (this.comboBox6.Items.Contains(factory.Trim())) this.comboBox6.SelectedItem = factory.Trim(); else this.comboBox6.Text = factory.Trim(); if (this.comboBox7.Items.Contains(classs.Trim())) this.comboBox7.SelectedItem = classs.Trim(); else this.comboBox7.Text = classs.Trim(); MessageBox.Show(this, "已收藏: " + factory.Trim() + " - " + classs.Trim(), "收藏成功"); } catch (Exception ex) { Log("[收藏] 收藏异常: " + ex.Message); MessageBox.Show(this, "收藏失败: " + ex.Message, "收藏"); } } /// 保存一条收藏记录到配置文件(自动去重)。 private void SaveFavorite(string company, string xilie, string sid) { try { var doc = new System.Xml.XmlDocument(); if (System.IO.File.Exists(收藏配置路径)) { doc.Load(收藏配置路径); // 去重:检查是否已存在 var existing = doc.SelectSingleNode("//fav[@公司='" + company + "' and @系列='" + xilie + "']"); if (existing != null) return; // 已存在,不重复添加 } else { // 创建新文件 var decl = doc.CreateXmlDeclaration("1.0", "utf-8", null); doc.AppendChild(decl); var root = doc.CreateElement("favorites"); doc.AppendChild(root); } var favNode = doc.CreateElement("fav"); favNode.SetAttribute("公司", company); favNode.SetAttribute("系列", xilie); if (!string.IsNullOrWhiteSpace(sid)) favNode.SetAttribute("系列ID", sid); doc.DocumentElement.AppendChild(favNode); // 确保目录存在 string dir = System.IO.Path.GetDirectoryName(收藏配置路径); if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); doc.Save(收藏配置路径); Log("[收藏] 已保存: " + company + " - " + xilie + " ID=" + sid); } catch (Exception ex) { Log("[收藏] 保存异常: " + ex.Message); } } // ============================================================ // 清理收藏功能(button12) // 弹出对话框,用两个增强表格管理收藏: // dgv1 = 公司列表(对应 comboBox6),右键删除将清空该公司所有收藏 // dgv2 = 系列列表(对应 comboBox7),右键删除单条收藏 // ============================================================ /// 清理收藏按钮:弹出对话框,用两个增强表格展示并可删除收藏。 private void button12_Click(object sender, EventArgs e) { try { // 创建对话框 Form dlg = new Form(); dlg.Text = "清理收藏"; dlg.StartPosition = FormStartPosition.CenterParent; dlg.Size = new Size(820, 580); dlg.MinimumSize = new Size(640, 480); dlg.FormBorderStyle = FormBorderStyle.Sizable; dlg.BackColor = SystemColors.Control; dlg.ShowInTaskbar = false; dlg.ShowIcon = false; // ===== 标题1:公司列表 ===== Label lbl1 = new Label(); lbl1.Text = "公司列表(对应 comboBox6):右键删除将清空该公司的全部系列收藏"; lbl1.Location = new Point(12, 10); lbl1.Size = new Size(796, 20); lbl1.AutoSize = false; lbl1.ForeColor = SystemColors.ControlText; lbl1.BackColor = SystemColors.Control; dlg.Controls.Add(lbl1); // ===== dgv1:公司表(对应 comboBox6) ===== // 注意:dgv1 的事件里要刷新 dgv2,所以先把 dgv2 声明为 null,后面再赋值 EnhancedDataGridView dgv2 = null; EnhancedDataGridView dgv1 = new EnhancedDataGridView(); dgv1.Name = "dgvCompany"; dgv1.Location = new Point(12, 33); dgv1.Size = new Size(796, 220); dgv1.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; dgv1.AllowUserToAddRows = false; dgv1.AllowUserToDeleteRows = false; dgv1.AllowUserToResizeRows = false; dgv1.ReadOnly = true; dgv1.RowHeadersVisible = false; dgv1.SelectionMode = DataGridViewSelectionMode.FullRowSelect; dgv1.MultiSelect = false; dgv1.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; dgv1.BackgroundColor = SystemColors.Window; dgv1.BorderStyle = BorderStyle.FixedSingle; DataGridViewTextBoxColumn colCompany = new DataGridViewTextBoxColumn(); colCompany.Name = "公司"; colCompany.HeaderText = "公司"; colCompany.DataPropertyName = "公司"; colCompany.SortMode = DataGridViewColumnSortMode.NotSortable; dgv1.Columns.Add(colCompany); dgv1.DataSource = BuildCompanyTable(); // 工具委托:读取 dgv1 当前选中公司名(未选中返回 null) Func GetSelectedCompany = delegate() { if (dgv1.CurrentRow == null) return null; string val = Convert.ToString(dgv1.CurrentRow.Cells["公司"].Value); return string.IsNullOrWhiteSpace(val) ? null : val; }; // 工具委托:刷新 dgv2——根据当前选中公司过滤系列;未选中公司则显示空表(系列列) Action RefreshDgv2BySelection = delegate() { string sel = GetSelectedCompany(); DataTable dt = new DataTable(); dt.Columns.Add("系列", typeof(string)); if (!string.IsNullOrEmpty(sel)) { foreach (DataRow r in BuildFavoriteTable(sel).Rows) dt.ImportRow(r); } // 重新设置列头与数据源,防止列漂移 dgv2.Columns.Clear(); DataGridViewTextBoxColumn colS2 = new DataGridViewTextBoxColumn(); colS2.Name = "系列"; colS2.HeaderText = "系列"; colS2.DataPropertyName = "系列"; colS2.SortMode = DataGridViewColumnSortMode.NotSortable; dgv2.Columns.Add(colS2); dgv2.DataSource = dt; }; // dgv1 选中行改变 → 联动刷新 dgv2 dgv1.SelectionChanged += (s, ev) => { try { if (dgv2 != null) RefreshDgv2BySelection(); } catch { } }; // 右键点击时先选中该行,再弹菜单 dgv1.CellMouseDown += (s, ev) => { if (ev.Button == MouseButtons.Right && ev.RowIndex >= 0) { dgv1.ClearSelection(); dgv1.Rows[ev.RowIndex].Selected = true; dgv1.CurrentCell = dgv1.Rows[ev.RowIndex].Cells[0]; } }; // dgv1 右键菜单 - 删除此公司全部收藏 ContextMenuStrip menu1 = new ContextMenuStrip(); ToolStripMenuItem item1 = new ToolStripMenuItem("删除此公司(及其所有系列收藏)"); item1.Click += (s, ev) => { if (dgv1.CurrentRow == null) return; string company = Convert.ToString(dgv1.CurrentRow.Cells["公司"].Value); if (string.IsNullOrEmpty(company)) return; if (MessageBox.Show(dlg, "确认删除公司「" + company + "」及其所有系列收藏?", "确认删除", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return; DeleteFavoriteByCompany(company); dgv1.DataSource = BuildCompanyTable(); // 删除后自动选中第一行(若有),dgv2 会被 SelectionChanged 自动刷新 if (dgv1.Rows.Count > 0) { dgv1.ClearSelection(); dgv1.Rows[0].Selected = true; dgv1.CurrentCell = dgv1.Rows[0].Cells[0]; } else { // 没有公司了,dgv2 清空 if (dgv2 != null) RefreshDgv2BySelection(); } }; menu1.Items.Add(item1); dgv1.ContextMenuStrip = menu1; dlg.Controls.Add(dgv1); // ===== 标题2:系列列表 ===== Label lbl2 = new Label(); lbl2.Text = "系列列表(对应 comboBox7):先在上方选中公司,此处仅显示该公司系列(右键删除单条)"; lbl2.Location = new Point(12, 260); lbl2.Size = new Size(796, 20); lbl2.AutoSize = false; lbl2.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; lbl2.ForeColor = SystemColors.ControlText; lbl2.BackColor = SystemColors.Control; dlg.Controls.Add(lbl2); // ===== dgv2:系列表(对应 comboBox7) —— 仅显示当前选中公司的系列(一列:系列) ===== dgv2 = new EnhancedDataGridView(); dgv2.Name = "dgvSeries"; dgv2.Location = new Point(12, 283); dgv2.Size = new Size(796, 220); dgv2.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom; dgv2.AllowUserToAddRows = false; dgv2.AllowUserToDeleteRows = false; dgv2.AllowUserToResizeRows = false; dgv2.ReadOnly = true; dgv2.RowHeadersVisible = false; dgv2.SelectionMode = DataGridViewSelectionMode.FullRowSelect; dgv2.MultiSelect = false; dgv2.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; dgv2.BackgroundColor = SystemColors.Window; dgv2.BorderStyle = BorderStyle.FixedSingle; // 初始填充:空系列表(未选中公司时为空) RefreshDgv2BySelection(); // 右键点击时先选中该行,再弹菜单 dgv2.CellMouseDown += (s, ev) => { if (ev.Button == MouseButtons.Right && ev.RowIndex >= 0) { dgv2.ClearSelection(); dgv2.Rows[ev.RowIndex].Selected = true; dgv2.CurrentCell = dgv2.Rows[ev.RowIndex].Cells[0]; } }; // dgv2 右键菜单 - 删除单条收藏 ContextMenuStrip menu2 = new ContextMenuStrip(); ToolStripMenuItem item2 = new ToolStripMenuItem("删除此条收藏"); item2.Click += (s, ev) => { if (dgv2.CurrentRow == null) return; string company = GetSelectedCompany(); string xilie = Convert.ToString(dgv2.CurrentRow.Cells["系列"].Value); if (string.IsNullOrEmpty(company) || string.IsNullOrEmpty(xilie)) return; if (MessageBox.Show(dlg, "确认删除收藏「" + company + " - " + xilie + "」?", "确认删除", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return; DeleteFavoriteOne(company, xilie); // 单条删除:公司列表不变,只刷新系列表(保持当前公司过滤) RefreshDgv2BySelection(); // 同时刷新 dgv1(公司可能因为删除最后一条系列而整体消失,但一般不去重删,保留也没问题) // 如果该公司最后一条系列被删,公司表中它还在,但系列表空了,这种情况可以保留公司行; // 若希望一起消失,打开下一行注释: // string prev = GetSelectedCompany(); dgv1.DataSource = BuildCompanyTable(); 选中 prev(若还存在) }; menu2.Items.Add(item2); dgv2.ContextMenuStrip = menu2; dlg.Controls.Add(dgv2); // 如果一开始就有公司,默认选中第一行(会触发 SelectionChanged 刷新 dgv2) if (dgv1.Rows.Count > 0) { dgv1.ClearSelection(); dgv1.Rows[0].Selected = true; dgv1.CurrentCell = dgv1.Rows[0].Cells[0]; } // ===== 底部关闭按钮 ===== Button btnClose = new Button(); btnClose.Text = "关闭"; btnClose.DialogResult = DialogResult.Cancel; btnClose.Location = new Point(717, 515); btnClose.Size = new Size(91, 28); btnClose.Anchor = AnchorStyles.Bottom | AnchorStyles.Right; dlg.Controls.Add(btnClose); dlg.CancelButton = btnClose; // 显示对话框,关闭后刷新主窗口下拉 dlg.ShowDialog(this); // 释放对话框资源 dgv1.Dispose(); dgv2.Dispose(); dlg.Dispose(); // 刷新主窗口的 comboBox6/comboBox7 LoadFavorites(); } catch (Exception ex) { Log("[收藏] 清理窗口异常: " + ex.Message); MessageBox.Show(this, "打开清理窗口失败: " + ex.Message, "清理收藏"); } } /// 构建公司去重表格(对应 comboBox6 的内容)。 private DataTable BuildCompanyTable() { DataTable dt = new DataTable(); dt.Columns.Add("公司", typeof(string)); if (!File.Exists(收藏配置路径)) return dt; try { System.Xml.XmlDocument doc = new System.Xml.XmlDocument(); doc.Load(收藏配置路径); HashSet seen = new HashSet(); foreach (System.Xml.XmlNode node in doc.SelectNodes("//fav")) { string c = node.Attributes["公司"] != null ? node.Attributes["公司"].Value : ""; if (!string.IsNullOrWhiteSpace(c) && seen.Add(c)) dt.Rows.Add(c); } } catch (Exception ex) { Log("[收藏] 构建公司表异常: " + ex.Message); } return dt; } /// 构建全部收藏表格(公司+系列两列)。 private DataTable BuildFavoriteTable() { return BuildFavoriteTable(null); } /// 构建系列表;company=null 显示全部(公司+系列两列),company 有值则只显示该公司的系列(只返回系列一列)。 private DataTable BuildFavoriteTable(string company) { DataTable dt = new DataTable(); bool filterByCompany = !string.IsNullOrEmpty(company); if (filterByCompany) { dt.Columns.Add("系列", typeof(string)); } else { dt.Columns.Add("公司", typeof(string)); dt.Columns.Add("系列", typeof(string)); } if (!File.Exists(收藏配置路径)) return dt; try { System.Xml.XmlDocument doc = new System.Xml.XmlDocument(); doc.Load(收藏配置路径); foreach (System.Xml.XmlNode node in doc.SelectNodes("//fav")) { string c = node.Attributes["公司"] != null ? node.Attributes["公司"].Value : ""; string s = node.Attributes["系列"] != null ? node.Attributes["系列"].Value : ""; if (string.IsNullOrWhiteSpace(s)) continue; if (filterByCompany) { if (c == company) dt.Rows.Add(s); } else { dt.Rows.Add(c, s); } } } catch (Exception ex) { Log("[收藏] 构建系列表异常: " + ex.Message); } return dt; } /// 删除指定公司的所有收藏。 private void DeleteFavoriteByCompany(string company) { try { if (!File.Exists(收藏配置路径)) return; System.Xml.XmlDocument doc = new System.Xml.XmlDocument(); doc.Load(收藏配置路径); System.Xml.XmlNodeList nodes = doc.SelectNodes("//fav[@公司='" + company + "']"); List toRemove = new List(); foreach (System.Xml.XmlNode n in nodes) toRemove.Add(n); foreach (System.Xml.XmlNode n in toRemove) n.ParentNode.RemoveChild(n); doc.Save(收藏配置路径); Log("[收藏] 已删除公司: " + company + " (共 " + toRemove.Count + " 条)"); } catch (Exception ex) { Log("[收藏] 删除公司异常: " + ex.Message); } } /// 删除单条收藏(公司+系列)。 private void DeleteFavoriteOne(string company, string xilie) { try { if (!File.Exists(收藏配置路径)) return; System.Xml.XmlDocument doc = new System.Xml.XmlDocument(); doc.Load(收藏配置路径); System.Xml.XmlNode node = doc.SelectSingleNode("//fav[@公司='" + company + "' and @系列='" + xilie + "']"); if (node != null) { node.ParentNode.RemoveChild(node); doc.Save(收藏配置路径); Log("[收藏] 已删除: " + company + " - " + xilie); } } catch (Exception ex) { Log("[收藏] 删除单条异常: " + ex.Message); } } /// 把网页选中的元件作为新行插入到元件表(advancedDataGridView5)。 private void 插入元件到报价表(string 名称, decimal 价格, string 品牌, string 采购系数, string 系列名, decimal 数量 = 1m, string 品名 = null) { // ★ 选型页禁用插入(用户要求:插入只在报价页,选型页只支持替换) if (this.tabControl2 != null && this.tabControl2.SelectedTab == this.tabPage10) { Log("[WebView] 插入被拒: 选型页不支持插入(仅替换)"); System.Windows.Forms.MessageBox.Show(this, "选型页不支持插入,请切到\"报价\"页再插入。\n(选型页上只能使用\"替换\")", "提示"); return; } if (string.IsNullOrWhiteSpace(_currentProjectId)) { System.Windows.Forms.MessageBox.Show("请先在报价软件中打开一个项目并选择图号/箱号,再插入元件。", "提示"); return; } decimal 系数 = 1m; decimal.TryParse(采购系数, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out 系数); if (系数 == 0m) 系数 = 1m; if (数量 < 1m) 数量 = 1m; // 网页数量框 min=1;异常值兜底为 1 // 派生列按 Excel 公式重算(数量默认1,报出系数默认1): decimal 采购单价 = 价格 * 系数; decimal 采购价 = 采购单价 * 1m; decimal 报出单价 = 采购单价 * 1m; decimal 报出价 = 报出单价 * 1m; // 元件名称:优先用网页显示的"品名:XXX"行;没有时回退用系列名去掉"XX系列"前缀 string 元件名称 = string.IsNullOrWhiteSpace(品名) ? 清理系列字样(系列名) : 品名.Trim(); // 元件表不存在时(包括:从未建表 _yuanjianTable==null,或虽有空 DataTable 但 TableName 为空表示无真实库表): // 先通过 InsertYuanjian 创建表+插入行到数据库,再reload,再更新为网页数据 // ★ 关键:ReloadYuanjianByXianghao 在没有元件表时会创建一个"无列空 DataTable"并赋给 _yuanjianTable // (TableName=""),此时不能走"已存在"分支直接 NewRow(会因无列抛 ArgumentException 被 WebView catch 吞掉) Log("[WebView] 插入前状态: _yuanjianTable=" + (_yuanjianTable == null ? "null" : ("TableName=[" + _yuanjianTable.TableName + "] Cols=" + _yuanjianTable.Columns.Count + " Rows=" + _yuanjianTable.Rows.Count)) + " 项目=[" + _currentProjectId + "] 图号=[" + _currentTuhao + "] 箱号=[" + _currentXianghao + "]"); // ★ 批量插入分发(2026-10-01,用户决策):多选箱号时弹确认—— // [全部插入]每个选中箱号各插一条(序号独立) / [取消]不插入 // null=单选→继续走下面原逻辑;非null=已批量完成(或取消)→到此为止 // ★ 防御(2026-10-01):异常单独包住(记日志+提示),降级走原逻辑,不再静默吞 try { var 批量结果 = 批量插入到选中箱号((th, xh) => InsertYuanjianFull(_currentProjectId, th, xh, 元件名称, 名称 ?? "", 价格, 品牌 ?? "", 系数.ToString(System.Globalization.CultureInfo.InvariantCulture), 系列名 ?? "", 数量)); if (批量结果 != null) { try { LoadSelectionSummary(false); } catch { } // 插入改了元件数据,选型页刷新 return; } } catch (Exception exBatch) { Log("[批量插入] WebView分发异常(降级为单箱号插入): " + exBatch); System.Windows.Forms.MessageBox.Show(this, "批量插入功能异常,已改为只插入当前箱号。\n异常信息:\n" + exBatch.Message, "批量插入", MessageBoxButtons.OK, MessageBoxIcon.Warning); } if (_yuanjianTable == null || string.IsNullOrEmpty(_yuanjianTable.TableName)) { if (string.IsNullOrWhiteSpace(_currentTuhao) || string.IsNullOrWhiteSpace(_currentXianghao)) { System.Windows.Forms.MessageBox.Show("请先选择图号和箱号,再插入元件。", "提示"); return; } // ★ 与"右键新建元件"保持一致的保护模式: // 1) _isReloading 包住整个流程,挡住 ReloadYuanjianByXianghao 重绑 DataSource // 引发的 SelectionChanged/CCellValueChanged 联动(否则会冲掉刚插入的数据或切走箱号) // 2) 记住当前图号/箱号,finally 恢复(防止 ADGV 选中抖动) // 3) 操作前预保存,操作后 RecalcAllAfterReload 重算聚合 string rememberTuhao = _currentTuhao; string rememberXianghao = _currentXianghao; try { _isReloading = true; SaveAllQuoteTablesIfDirty(false); // ★ 直接 INSERT 完整字段行(规格/表价/品牌/系数/数量全带上),不用"空行再覆盖" long newRowId0 = InsertYuanjianFull(_currentProjectId, _currentTuhao, _currentXianghao, 元件名称, 名称 ?? "", 价格, 品牌 ?? "", 系数.ToString(System.Globalization.CultureInfo.InvariantCulture), 系列名 ?? "", 数量); // 清除缓存并重新加载(从数据库读取,确保DataGridView正确显示) string yjKey = _currentTuhao + "\n" + _currentXianghao; _yuanjianCache.Remove(yjKey); ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); if (_yuanjianTable == null || _yuanjianTable.Rows.Count == 0) { System.Windows.Forms.MessageBox.Show("元件表创建失败,无法插入元件。", "提示"); return; } try { this.advancedDataGridView5.FirstDisplayedScrollingRowIndex = 0; } catch { } // 新建后重算当前层并刷新显示(箱号行/图号行/项目表聚合) RecalcAllAfterReload(); ApplyPriceFormat(this.advancedDataGridView5, _单台小数位文本); // ★ 新建表插入同样刷新选型页(不合并,逐行可见) try { LoadSelectionSummary(false); } catch { } Log("[WebView] 已插入元件(新建表): " + 名称 + " 价格=" + 价格 + " 品牌=" + 品牌 + " 系数=" + 采购系数); return; } catch (Exception ex) { Log("[WebView] 新建表插入元件异常: " + ex.Message); System.Windows.Forms.MessageBox.Show("插入元件失败:\n" + ex.Message, "提示"); return; } finally { // 恢复图号/箱号选中,再解除 guard(顺序与 MenuYuanjian_New_Click 一致) _currentTuhao = rememberTuhao; _currentXianghao = rememberXianghao; try { SelectRowByValue(this.advancedDataGridView4, "内部ID", rememberXianghao); SelectRowByValue(this.advancedDataGridView9, "内部ID", rememberTuhao); } catch { } _isReloading = false; } } // 元件表已存在:与"右键新建元件"(MenuYuanjian_New_Click)同链路—— // 库 INSERT 完整字段行→清缓存重读→移到选中行下面→重算持久化。 // ★ 网页插入直接写齐 规格/表价/品牌/系数/系列,不再"空行+按rowid覆盖"(那链路易丢值)。 this.advancedDataGridView5.EndEdit(); int selectedIndex = GetSelectedRowIndex(this.advancedDataGridView5); string memTuhao = _currentTuhao; string memXianghao = _currentXianghao; try { _isReloading = true; SaveAllQuoteTablesIfDirty(false); long newRowId = InsertYuanjianFull(_currentProjectId, _currentTuhao, _currentXianghao, 元件名称, 名称 ?? "", 价格, 品牌 ?? "", 系数.ToString(System.Globalization.CultureInfo.InvariantCulture), 系列名 ?? "", 数量); if (newRowId <= 0) { Log("[WebView] 插入元件失败: 无 rowid"); return; } string yjKey = _currentTuhao + "\n" + _currentXianghao; _yuanjianCache.Remove(yjKey); ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); if (_yuanjianTable == null || _yuanjianTable.Rows.Count == 0) { System.Windows.Forms.MessageBox.Show("元件插入失败。", "提示"); return; } // ★ 插入到选中行下面(而不是末尾);内部会保存到库并滚动定位 MoveLastRowBelowSelected(this.advancedDataGridView5, _yuanjianTable, selectedIndex, "\"" + _yuanjianTable.TableName + "\"", _currentProjectId); // 重算当前层并刷新显示(结尾 force 全表保存,网页数据随之持久化) RecalcAllAfterReload(); ApplyPriceFormat(this.advancedDataGridView5, _单台小数位文本); // ★ 插入改了元件数据,选型页刷新(不合并,逐行可见) try { LoadSelectionSummary(false); } catch { } Log("[WebView] 已插入元件: " + 名称 + " 价格=" + 价格 + " 品牌=" + 品牌 + " 系数=" + 采购系数); } catch (Exception ex) { Log("[WebView] 插入元件异常: " + ex.Message); System.Windows.Forms.MessageBox.Show("插入元件失败:\n" + ex.Message, "提示"); } finally { // 恢复图号/箱号选中,再解除 guard(顺序与 MenuYuanjian_New_Click 一致) _currentTuhao = memTuhao; _currentXianghao = memXianghao; try { SelectRowByValue(this.advancedDataGridView4, "内部ID", memXianghao); SelectRowByValue(this.advancedDataGridView9, "内部ID", memTuhao); } catch { } _isReloading = false; } } /// 用网页选中的元件信息替换元件表当前选中行(保留原数量/报出系数)。 /// 在内存表里按 _rowid_ 列找行(精确定位刚 INSERT 的行,不赌排序);找不到返回 null。 /// _rowid_ 经 SQLite DataAdapter.Fill 后 CLR 类型不固定(long/decimal/double 都可能), /// 用 Convert.ToInt64 容错匹配,不能用 "is long" 强判(类型不符会永远找不到)。 private static DataRow 按_rowid_找行(DataTable table, long rowid) { if (table == null || rowid <= 0 || !table.Columns.Contains("_rowid_")) return null; foreach (DataRow r in table.Rows) { if (r["_rowid_"] == null || r["_rowid_"] == DBNull.Value) continue; try { if (Convert.ToInt64(r["_rowid_"]) == rowid) return r; } catch { } } return null; } private void 替换选中元件(string 名称, decimal 价格, string 品牌, string 采购系数, string 系列名, decimal 数量 = 1m, string 品名 = null) { if (string.IsNullOrWhiteSpace(_currentProjectId)) { Log("[WebView] 替换被拒: 未打开项目"); System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中打开一个项目。", "提示"); return; } // ★ 选型页分支:选型页汇总/明细有选中行→目标=选型页选中行(与网页替换同规则), // 替换后原地更新不丢选中。判定用"有选中行"而非 SelectedTab==选型页—— // 点库窗口按钮时焦点漂移会让 SelectedTab 判定失效(实测 20:07 用户库替换走错分支) bool 在选型页 = 替换目标在选型页(); if (在选型页) { // ★ 替换前记录用户的选中位置:以"最后点选的表"为准(用户点的是哪个表, // 替换目标就是哪个表的选中行)。不能用"明细优先"——明细表在汇总行 // 选中/替换后重载会自动带选中行,老规则会被它劫持(点汇总换的却是明细行)。 bool 用明细 = false; int 记行 = -1, 记列 = 0; string 记明细名称 = null, 记明细规格 = null; try { if (_选型页最后点选表 == 1) { var 明 = GetFirstSelectedRow(this.advancedDataGridView12); if (明 != null && !明.IsNewRow && _selectionDetailTable != null && 明.Index < _selectionDetailTable.Rows.Count && _selectionDetailTable.Columns.Contains("型号")) { 用明细 = true; 记行 = 明.Index; var mdr = _selectionDetailTable.Rows[明.Index]; 记明细名称 = (mdr["型号"] ?? "").ToString().Trim(); 记明细规格 = (mdr["规格"] ?? "").ToString().Trim(); } } if (!用明细) { var 汇 = GetFirstSelectedRow(this.advancedDataGridView1); if (汇 != null && !汇.IsNewRow) { 记行 = 汇.Index; } } // 兜底:点选章是汇总但汇总无选中而明细有(极少见)——仍按明细 if (!用明细 && 记行 < 0) { var 明 = GetFirstSelectedRow(this.advancedDataGridView12); if (明 != null && !明.IsNewRow && _selectionDetailTable != null && 明.Index < _selectionDetailTable.Rows.Count && _selectionDetailTable.Columns.Contains("型号")) { 用明细 = true; 记行 = 明.Index; var mdr = _selectionDetailTable.Rows[明.Index]; 记明细名称 = (mdr["型号"] ?? "").ToString().Trim(); 记明细规格 = (mdr["规格"] ?? "").ToString().Trim(); } } var 焦点表 = 用明细 ? this.advancedDataGridView12 : this.advancedDataGridView1; if (焦点表 != null && 焦点表.CurrentCell != null) 记列 = 焦点表.CurrentCell.ColumnIndex; } catch { } int 改动 = 选型页替换(名称, 价格, 品牌, 采购系数, 系列名, 品名); if (改动 > 0) { // ★ 终极方案(用户拍板):不刷新整个表——全表重绑必丢选中,事后怎么补都会被 // 联动链清掉。改为【原地更新被替换的那一行单元格】:表格结构不动、绑定不动, // 选中天然保留。只刷明细表(明细无选中要求)。 try { 原地更新选型汇总行(名称, 价格, 品牌, 采购系数, 系列名, 品名, 用明细, 记行, 记明细名称, 记明细规格); } catch { } // 明细表整表刷新(用户焦点在汇总表,明细重绑不影响选中) try { LoadSelectionDetail(); } catch { } 重算聚合(); Log("[WebView] 选型页替换完成(原地更新) : " + 改动 + " 处 ← " + 名称); } else { Log("[WebView] 选型页替换被拒: 无选中行或无匹配"); // ★ 区分两种情况:真没选行 vs 选了行但库里已无匹配(刚替换过/已换成别的) bool 曾选中 = 记行 >= 0 || (记明细名称 ?? "").Length > 0; System.Windows.Forms.MessageBox.Show(this, 曾选中 ? "选中行的元件在项目里已没有可替换的匹配行(可能刚刚已替换过)。\n如需继续替换其他元件,请选中其他行。" : "请先在选型页的表格里选中要替换的那一行(点一下该行任意格),再点网页\"替换\"。", "提示"); } return; } if (_yuanjianTable == null) { Log("[WebView] 替换被拒: 未选图号/箱号"); System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中选择图号/箱号。", "提示"); return; } var dgvRow = GetFirstSelectedRow(this.advancedDataGridView5); if (dgvRow == null || dgvRow.IsNewRow) { Log("[WebView] 替换被拒: 元件表无选中行 (当前页=" + (this.tabControl2 != null && this.tabControl2.SelectedTab != null ? this.tabControl2.SelectedTab.Name : "?") + ")"); System.Windows.Forms.MessageBox.Show(this, "请先在元件表(报价页下方第三个表)中选中要替换的行,再点网页\"替换\"。", "提示"); return; } this.advancedDataGridView5.EndEdit(); decimal 系数 = 1m; decimal.TryParse(采购系数, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out 系数); if (系数 == 0m) 系数 = 1m; if (数量 < 1m) 数量 = 1m; // 网页数量框 min=1;异常值兜底为 1 // 元件名称:优先用网页显示的"品名:XXX"行;没有时回退用系列名去掉"XX系列"前缀 string 元件名称 = string.IsNullOrWhiteSpace(品名) ? 清理系列字样(系列名) : 品名.Trim(); // ★ 虚拟模式:写内存表+刷新行 写单元格(this.advancedDataGridView5, dgvRow.Index, "元件名称", 元件名称); 写单元格(this.advancedDataGridView5, dgvRow.Index, "规格", 名称 ?? ""); 写单元格(this.advancedDataGridView5, dgvRow.Index, "系列", 系列名 ?? ""); 写单元格(this.advancedDataGridView5, dgvRow.Index, "品牌", 品牌 ?? ""); 写单元格(this.advancedDataGridView5, dgvRow.Index, "表价", (double)价格); 写单元格(this.advancedDataGridView5, dgvRow.Index, "采购系数", 系数.ToString(System.Globalization.CultureInfo.InvariantCulture)); // 数量取网页数量框的值(替换也一并带入,报出系数保留原值) 写单元格(this.advancedDataGridView5, dgvRow.Index, "数量", (double)数量); // 与插入元件保持一致:替换时也把"类型"列设为"元件" 写单元格(this.advancedDataGridView5, dgvRow.Index, "类型", "元件"); // 重算该行派生列(采购价/报出价按新数量重算) RecalcYuanjianRow(dgvRow); // 重算箱号/图号聚合 重算聚合(); // ★ 替换改了元件数据,选型页刷新(不合并——保持逐行;进入选型页时会按新数据重新合并一次) try { LoadSelectionSummary(false); } catch (Exception exSel) { Log("[WebView] 替换后刷选型页异常: " + exSel.Message); } Log("[WebView] 已替换选中元件: 行" + dgvRow.Index + " " + 名称 + " 价格=" + 价格 + " 数量=" + 数量 + " 品牌=" + 品牌 + " 系数=" + 采购系数); } /// 该箱号还没建过元件表(_yuanjianTable 为 null 或 TableName 空)时自动建空表并重载。 /// 各工具窗口(母排/GCS/箱体/用户库/柜体库)插入元件前调用; /// 返回 false=未选图号/箱号或建表失败(调用方应提示并中止)。 /// 建表块抄方案库插入元件(LookupTableByIndex/CreateFakYuanjianTable/表索引),不带占位行。 private bool 确保元件表存在() { if (_yuanjianTable != null && !string.IsNullOrEmpty(_yuanjianTable.TableName)) return true; if (string.IsNullOrWhiteSpace(_currentProjectId) || string.IsNullOrWhiteSpace(_currentTuhao) || string.IsNullOrWhiteSpace(_currentXianghao)) return false; try { bool wasReloading = _isReloading; _isReloading = true; try { using (var yc = new System.Data.SQLite.SQLiteConnection(GetConnString(GetQuoteDbPath(_currentProjectId)))) { yc.Open(); string yTbl = LookupTableByIndex("元件", _currentTuhao, _currentXianghao, yc); if (string.IsNullOrEmpty(yTbl)) { using (var ytx = yc.BeginTransaction()) { int seq2 = GetFakMaxTableSeq(yc, ytx, "yj"); yTbl = "yj_" + (seq2 + 1).ToString(); CreateFakYuanjianTable(yTbl, yc, ytx); using (var ic = new System.Data.SQLite.SQLiteCommand( "INSERT INTO 表索引 (类型, 图号, 箱号, 实际表名) VALUES ('元件',@th,@xh,@t);", yc, ytx)) { ic.Parameters.AddWithValue("@th", _currentTuhao); ic.Parameters.AddWithValue("@xh", _currentXianghao); ic.Parameters.AddWithValue("@t", yTbl); ic.ExecuteNonQuery(); } ytx.Commit(); } } } string yjKey = _currentTuhao + "\n" + _currentXianghao; _yuanjianCache.Remove(yjKey); ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); } finally { _isReloading = wasReloading; } return _yuanjianTable != null && !string.IsNullOrEmpty(_yuanjianTable.TableName); } catch (Exception ex) { Log("[确保元件表] 建表失败: " + ex.Message); return false; } } /// 内存元件表插行后的收尾(虚拟模式):同步网格行数 + 全表保存到库 + 重绘。 /// 各工具窗口(母排/GCS/箱体计算/方案库/用户库/柜体库/网页)插入元件后必须调用, /// 否则虚拟模式网格 RowCount 不变新行不显示,且新行无 _rowid_ 不走直写、重启即丢。 private void 元件表插行收尾() { if (_yuanjianTable == null) return; this.advancedDataGridView5.RowCount = _yuanjianTable.Rows.Count; if (!string.IsNullOrEmpty(_yuanjianTable.TableName) && !string.IsNullOrWhiteSpace(_currentProjectId)) SaveTableToDatabase(_yuanjianTable, _currentProjectId, "\"" + _yuanjianTable.TableName + "\"", null, null, true); this.advancedDataGridView5.Invalidate(); } /// 重算当前选中箱号/图号行的聚合(插入/替换元件后调用,避免触发联动死循环)。 private void 重算聚合() { _isRecalculating = true; _isReloading = true; try { var xhRow = GetFirstSelectedRow(this.advancedDataGridView4); if (xhRow != null) RecalcXianghaoRowFromYuanjian(xhRow); var thRow = GetFirstSelectedRow(this.advancedDataGridView9); if (thRow != null) RecalcTuhaoRowFromXianghao(thRow); } finally { _isRecalculating = false; _isReloading = false; } } /// /// 异步检测 WebView 当前路径,如果偏离 /price 则强制整页刷新回 /price。 /// 必须在 UI 线程调用(ExecuteScriptAsync 要求 UI 线程)。 /// private async void 检测并纠正路径() { try { // ★ 窗体销毁中/已销毁 直接退出(await 恢复点可能落在窗体销毁后, // 再碰 webView21 会抛 ObjectDisposedException → WinForms 全局异常弹窗闪现) if (this.IsDisposed || this.Disposing) return; if (this.webView21 == null || this.webView21.CoreWebView2 == null) return; var js代码 = "(function(){" + " var info = {path: location.pathname, hash: location.hash};" + " var vue实例 = null;" + " for (var i = 0; i < document.body.children.length; i++) {" + " var c = document.body.children[i];" + " if (c.__vue__) { vue实例 = c.__vue__; break; }" + " }" + " info.vue存在 = !!vue实例;" + " if (vue实例 && vue实例.$router) {" + " info.router锁定 = !!vue实例.$router._locked;" + " info.当前路由 = vue实例.$router.currentRoute ? vue实例.$router.currentRoute.path : '?';" + " }" + " return JSON.stringify(info);" + "})()"; var json = await this.webView21.CoreWebView2.ExecuteScriptAsync(js代码); // ★ await 恢复点:期间窗体可能已被关闭销毁,后续不能再碰任何控件 if (this.IsDisposed || this.Disposing) return; // null/空/"null" 表示页面还在加载中,跳过 if (string.IsNullOrEmpty(json) || json == "null" || json == "\"null\"") { return; } // ExecuteScriptAsync 返回的是 JSON 编码的字符串,去掉外层引号和转义 if (json.Length >= 2 && json.StartsWith("\"") && json.EndsWith("\"")) { json = json.Substring(1, json.Length - 2) .Replace("\\\"", "\"") .Replace("\\\\", "\\") .Replace("\\/", "/"); } var path = 简单提取字段(json, "path"); var hash = 简单提取字段(json, "hash"); // path 为空说明提取失败或页面异常,跳过 if (string.IsNullOrEmpty(path)) { return; } Log("[轮询] path=" + path + " hash=" + hash); // 检查路径是否偏离 /standalone(独立选型页面,无 Vue Router) if (path.IndexOf("/priceapp", System.StringComparison.OrdinalIgnoreCase) >= 0 || path.IndexOf("/m/price", System.StringComparison.OrdinalIgnoreCase) >= 0 || hash.IndexOf("/priceapp", System.StringComparison.OrdinalIgnoreCase) >= 0) { Log("[WebView] 检测到偏离: path=" + path + " → 强制刷新回 /price"); await this.webView21.CoreWebView2.ExecuteScriptAsync("window.location.replace('/price');"); } } catch (Exception ex) { Log("[轮询] 异常: " + ex.Message); } } /// /// 关闭 proxy_server.py 子进程及本程序的 WebView2 浏览器进程(主窗口关闭时调用)。 /// 三层清理: /// 1) taskkill /T /F 杀记录的 proxy PID 整棵进程树(.NET4.8 的 Kill() 不杀子树, /// python spawn 的子进程会漏); /// 2) PowerShell 按命令行特征兜底:杀所有命令行含 proxy_server.py 的 python /// (程序被强杀/崩溃后 FormClosing 不会执行,旧实例泄漏并占着 8080 端口, /// 之后每次启动又起新进程,越积越多); /// 3) 同规则杀本程序专属的 msedgewebview2(用户数据目录"成套报价软件.exe.WebView2"), /// 不按进程名裸杀,避免误杀系统里其它应用的 WebView2。 /// private void StopProxyServer() { if (_proxyProcess != null) { try { int proxyId = _proxyProcess.Id; if (!_proxyProcess.HasExited) { RunShell("taskkill", "/PID " + proxyId + " /T /F"); Log("[WebView] proxy_server 已停止 PID=" + proxyId); } } catch (Exception ex) { Log("[WebView] 停止 proxy_server 失败: " + ex.Message); } _proxyProcess = null; } // ★ WebView2 浏览器子进程有意不杀(2026-09-13):保留热进程组,下次启动搭车内核初始化 0.5 秒 // (杀干净则全冷拉起要 11 秒);重启电脑自然清空。只清 python/node 代理特征进程。 清理代理与WebView进程(false); } /// 静默执行一个命令行程序并等待退出(用于 taskkill/powershell,不弹窗)。 private static void RunShell(string exe, string args) { var psi = new System.Diagnostics.ProcessStartInfo { FileName = exe, Arguments = args, UseShellExecute = false, CreateNoWindow = true, WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden }; using (var p = System.Diagnostics.Process.Start(psi)) { if (p != null) p.WaitForExit(8000); } } /// /// 按命令行特征清理残留的价格代理(python)与本程序的 WebView2 浏览器进程。 /// 关窗时兜底(含WebView2=true 全量) + 启动时自愈(false 只清 python, /// 不碰 WebView2——启动瞬间自己的 WebView2 还没起,避免竞态误杀)。 /// 只杀:命令行含 proxy_server.py 的 python/pythonw;用户数据目录为 /// "成套报价软件.exe.WebView2" 的 msedgewebview2。不误杀其它程序的 python/WebView2。 /// private void 清理代理与WebView进程(bool 含WebView2) { try { // -EncodedCommand 传 Base64(UTF-16LE),规避引号/中文转义问题, // 命令行里也不出现明文关键字,不会匹配到 powershell 自身 string wvRule = 含WebView2 ? " -or ($_.Name -eq 'msedgewebview2.exe' -and $_.CommandLine -like '*成套报价软件.exe.WebView2*')" : ""; string ps = "$n=0; " + "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='python.exe' OR Name='pythonw.exe'" + (含WebView2 ? " OR Name='msedgewebview2.exe'" : "") + "\" | " + "Where-Object { $_.ProcessId -ne $pid -and ( " + "$_.CommandLine -like '*元件库网页*server.cjs*' -or $_.CommandLine -like '*proxy_server.py*'" + wvRule + " ) } | " + "ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $n++ } catch {} }; " + "Write-Output $n"; string encoded = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(ps)); var psi = new System.Diagnostics.ProcessStartInfo { FileName = "powershell", Arguments = "-NoProfile -ExecutionPolicy Bypass -EncodedCommand " + encoded, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true }; using (var p = System.Diagnostics.Process.Start(psi)) { string cnt = ""; if (p != null) { cnt = p.StandardOutput.ReadToEnd().Trim(); p.WaitForExit(8000); } int n; if (int.TryParse(cnt, out n) && n > 0) Log("[WebView] 清理残留进程 " + n + " 个" + (含WebView2 ? "(代理python+本程序WebView2)" : "(代理python)")); } } catch (Exception ex) { Log("[WebView] 清理残留进程失败: " + ex.Message); } } /// 快速探测本机端口是否被占用(连接成功=占用)。用于启动自愈的捷径判定。 private static bool 端口被占用(int port) { try { using (var client = new System.Net.Sockets.TcpClient()) { var ar = client.BeginConnect("127.0.0.1", port, null, null); bool ok = ar.AsyncWaitHandle.WaitOne(400); if (ok && client.Connected) { client.EndConnect(ar); return true; } } } catch { } return false; } /// /// 从 ExecuteScriptAsync 返回的 JSON 字符串中简单提取字符串字段的值。 /// 例如 {"path":"/price"} 提取 "path" 返回 "/price"。 /// 非字符串字段(true/false/数字)原样返回。不做严格 JSON 解析,避免依赖。 /// private static string 简单提取字段(string json, string 字段名) { try { // 找 "字段名":"value" 或 "字段名":value string key = "\"" + 字段名 + "\":"; int idx = json.IndexOf(key, System.StringComparison.Ordinal); if (idx < 0) return ""; int valStart = idx + key.Length; // 跳过空白 while (valStart < json.Length && (json[valStart] == ' ' || json[valStart] == '\t')) valStart++; if (valStart >= json.Length) return ""; // 字符串值 if (json[valStart] == '"') { int valEnd = json.IndexOf('"', valStart + 1); if (valEnd < 0) return ""; return json.Substring(valStart + 1, valEnd - valStart - 1); } // 非字符串(true/false/null/数字),读到逗号或 } int end = valStart; while (end < json.Length && json[end] != ',' && json[end] != '}' && json[end] != ' ') end++; return json.Substring(valStart, end - valStart); } catch { return ""; } } // ============================================================ // 性能优化:开启双缓冲(消除多选拖动时的重绘闪烁) // ============================================================ /// /// 用反射开启 DataGridView 的 DoubleBuffered(protected 属性,设计器够不到)。 /// ui.cs 已经自带优化版选中效果(HashSet 缓存),不需要我们重写绘制逻辑。 /// 这里只补做双缓冲:重绘先画到内存再一次性贴屏,消除闪烁和撕裂。 /// private void EnableDoubleBuffered(System.Windows.Forms.DataGridView dgv) { if (dgv == null) return; try { typeof(System.Windows.Forms.Control).GetMethod("set_DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .Invoke(dgv, new object[] { true }); } catch { } } /// /// 递归禁用所有 DataGridView 的行高拖动调整(行高数值保持各表现状,只锁"可调")。 /// 覆盖主窗体与停靠窗口里的全部表格。 /// private void 禁止行高调整(System.Windows.Forms.Control parent) { foreach (System.Windows.Forms.Control c in parent.Controls) { var dgv = c as System.Windows.Forms.DataGridView; if (dgv != null) { dgv.AllowUserToResizeRows = false; // 列标题行的高度也锁定(不可拖动调整) dgv.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.DisableResizing; } 禁止行高调整(c); } } /// 虚拟模式:由 dgv 反查它当前的内存数据表。 private System.Data.DataTable 取dgv数据表(System.Windows.Forms.DataGridView dgv) { if (dgv == this.advancedDataGridView9) return _tuhaoTable; if (dgv == this.advancedDataGridView4) return _xianghaoTable; if (dgv == this.advancedDataGridView5) return _yuanjianTable; return null; } /// /// ★ 虚拟模式统一写通道:写内存 DataTable 行 + 刷新该行显示。 /// 虚拟模式下写 Cells.Value 无效,所有对三表的单元格写入都必须走这里。 /// private void 写单元格(System.Windows.Forms.DataGridView dgv, int rowIndex, string colName, object value) { var table = 取dgv数据表(dgv); if (table == null || rowIndex < 0 || rowIndex >= table.Rows.Count) return; if (!table.Columns.Contains(colName)) return; table.Rows[rowIndex][colName] = (value == null) ? DBNull.Value : value; // ★ 同步入队直写库:重算出的派生列(采购单价/利润/聚合等)必须随批落库—— // 只写内存的话,300ms 后直写 Timer 的"读回回填缓存"会从库里把旧值读回来 // 覆盖内存新值,界面上的价格就"弹回原值"了。入队内部自带 rowid/表校验。 入队直写库(dgv, rowIndex, colName, value); try { dgv.InvalidateRow(rowIndex); } catch { } } /// /// 按 DataPropertyName 把指定列设为 ReadOnly(不可双击编辑)。 /// 公式列(由代码计算填充)和序号列(由代码自动编号)不允许用户改, /// 只有用户输入列(图号/箱号/数量/表价/系数等)允许编辑。 /// private void ApplyReadOnlyColumns(System.Windows.Forms.DataGridView dgv, string[] colNames) { if (dgv == null || colNames == null) return; // 把要设只读的列名放进 HashSet,O(1) 查询 var readOnlySet = new System.Collections.Generic.HashSet(colNames); foreach (System.Windows.Forms.DataGridViewColumn col in dgv.Columns) { if (col == null) continue; // 按 DataPropertyName 匹配(ui.cs 里每列都设了 DataPropertyName = 列名) string name = col.DataPropertyName ?? ""; if (readOnlySet.Contains(name)) { col.ReadOnly = true; } } } // ============================================================ // 辅助:路径与连接字符串 // ============================================================ /// 当前日志日期(yyyy-MM-dd)。跨天时滚动到新文件并清老日志。 private static string _logDate = ""; /// /// 诊断日志:写到 exe 同目录 logs\diag_yyyy-MM-dd.log,按天滚动; /// 跨天首次写入时自动清理 7 天前的旧日志。兼容旧 diag.log。 /// private void Log(string msg) { 写日志(msg); } /// 静态日志入口(项目云等无实例上下文的类用;线程安全:File.AppendAllText 原子追加)。 public static void 写日志(string msg) { try { string baseDir = AppDomain.CurrentDomain.BaseDirectory; string today = DateTime.Now.ToString("yyyy-MM-dd"); // 跨天滚动:切新文件 + 清理旧日志(只做一次) if (_logDate != today) { _logDate = today; CleanupOldLogs(baseDir); } string logDir = System.IO.Path.Combine(baseDir, "logs"); if (!System.IO.Directory.Exists(logDir)) System.IO.Directory.CreateDirectory(logDir); string logPath = System.IO.Path.Combine(logDir, "diag_" + today + ".log"); System.IO.File.AppendAllText(logPath, DateTime.Now.ToString("HH:mm:ss.fff") + " " + msg + "\r\n"); } catch { } } /// 删除 logs\ 下 7 天前的 diag_*.log,并删除根目录超过 5MB 的旧 diag.log。 private static void CleanupOldLogs(string baseDir) { try { string logDir = System.IO.Path.Combine(baseDir, "logs"); if (System.IO.Directory.Exists(logDir)) { DateTime limit = DateTime.Now.AddDays(-7); foreach (string f in System.IO.Directory.GetFiles(logDir, "diag_*.log")) { try { string name = System.IO.Path.GetFileNameWithoutExtension(f); // diag_2026-08-16 string datePart = name.StartsWith("diag_") ? name.Substring(5) : ""; DateTime dt; if (System.DateTime.TryParseExact(datePart, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out dt) && dt < limit) { System.IO.File.Delete(f); } } catch { } } } // 旧的根目录 diag.log(历史遗留,30MB):超 5MB 直接清空 try { string old = System.IO.Path.Combine(baseDir, "diag.log"); if (System.IO.File.Exists(old)) { var fi = new System.IO.FileInfo(old); if (fi.Length > 5 * 1024 * 1024) System.IO.File.WriteAllText(old, ""); } } catch { } } catch { } } private string GetDbFullPath(string relativePath) { return System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, relativePath); } private string GetConnString(string dbPath) { // ★ B4(2026-10-01):加 BusyTimeout=5000——直写队列/全库重算/扒图窗口并发访问同一库时, // 遇锁默认 0 等待直接抛 database is locked;加超时后 SQLite 自动等待重试(方案窗口早已同款) return "Data Source=" + dbPath + ";Version=3;Journal Mode=WAL;Synchronous=Normal;Pooling=True;BusyTimeout=5000;"; } /// /// 根据项目编号生成该项目专属的报价数据库绝对路径。 /// 每个项目一个独立 db 文件,放在 sjk\bjb\ 目录下, /// 文件名格式:项目<项目编号>.db。 /// 文件名里的非法字符会被替换成下划线。 /// private string GetQuoteDbPath(string projectId) { // 清理文件名中的非法字符 string safeName = projectId; char[] invalidChars = System.IO.Path.GetInvalidFileNameChars(); foreach (char c in invalidChars) { safeName = safeName.Replace(c, '_'); } string relPath = QuoteDbDir + @"\项目" + safeName + ".db"; return GetDbFullPath(relPath); } /// /// 确保指定项目的报价数据库存在;不存在则创建, /// 并在里面建立 图号/箱号/元件 三张空表(结构与原模板一致)。 /// 对已存在的老库(没有"项目信息"表)也会补建,确保结构完整。 /// private void EnsureQuoteDbExists(string projectId) { if (string.IsNullOrWhiteSpace(projectId)) return; string dbPath = GetQuoteDbPath(projectId); bool isNewDb = !System.IO.File.Exists(dbPath); // 确保目录存在 string dir = System.IO.Path.GetDirectoryName(dbPath); if (!System.IO.Directory.Exists(dir)) { System.IO.Directory.CreateDirectory(dir); } // 打开/创建数据库文件,确保图号表 + 表索引表 + 项目信息表都存在 // (箱号表/元件表改为按ID独立建表,记录在表索引里,见 InsertXianghao/InsertYuanjian) using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // 图号表:用户输入列用 TEXT,公式计算列用 REAL(数值类型,Format 才能生效) // ★ 内部ID:创建时的图号名,永远不变。表索引和 _currentTuhao 用它做关联, // 用户改图号名只改"图号"列(显示),不改"内部ID"列,彻底避免重名串数据。 string sqlTuhao = "CREATE TABLE IF NOT EXISTS " + TuhaoTableName + " (" + " 序号 TEXT, 图号 TEXT, 图名 TEXT, 台数 TEXT, 箱柜数量 REAL," + " 单价 REAL, 总价 REAL, 成本总价 REAL, 毛利 REAL, 毛利率 REAL," + " 内部ID TEXT" + ");"; // 表索引:记录每个箱号表/元件表的真实ID表名,避免用易重复的名称做表名 // ★ "图号"/"箱号"字段存的是内部ID(创建时的原名,永远不变), // 用户改名不影响表索引,杜绝"图号+箱号"重复导致串数据。 string sqlIndex = "CREATE TABLE IF NOT EXISTS 表索引 (" + " 类型 TEXT, 图号 TEXT, 箱号 TEXT, 实际表名 TEXT" + ");"; // 项目信息表:项目信息的唯一主存储(基础 8 字段 + 成套厂 4 字段 + 价格格式化 4 字段) string sqlProjectInfo = "CREATE TABLE IF NOT EXISTS " + ProjectInfoTableName + " (" + " 项目编号 TEXT, 项目名称 TEXT, 客户名称 TEXT, 客户地址 TEXT," + " 联系人 TEXT, 联系电话 TEXT, 项目日期 TEXT, 备注 TEXT," + // 成套厂信息(4 个字段):随项目保存到报价库项目信息表,供项目窗口调用 " 成套厂名称 TEXT, 成套厂地址 TEXT, 成套厂联系人 TEXT, 成套厂联系电话 TEXT," + // 价格显示格式化(4 个字段):控制报价页价格列的小数位 + 取整方式,仅影响显示 " 单台小数位 TEXT, 单台取整方式 TEXT, 项目小数位 TEXT, 项目取整方式 TEXT" + ");"; foreach (string sql in new[] { sqlTuhao, sqlIndex, sqlProjectInfo }) { using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.ExecuteNonQuery(); } } // 老库迁移:已存在的「项目信息」表可能没有成套厂 4 列 + 价格格式化 4 列,这里幂等地补列 EnsureColumns(conn, ProjectInfoTableName, new[] { "成套厂名称", "成套厂地址", "成套厂联系人", "成套厂联系电话", "单台小数位", "单台取整方式", "项目小数位", "项目取整方式" }); // ★ 老库迁移:图号表补"内部ID"列,并把当前"图号"列值复制过去 // (老库里表索引"图号"字段 = 当前图号名,因为老代码改名时同步更新了表索引, // 所以内部ID = 当前图号名 即可保持一致) EnsureColumns(conn, TuhaoTableName, new[] { "内部ID" }); MigrateInternalIdColumn(conn, TuhaoTableName, "图号", "内部ID"); // ★ 老库迁移:所有箱号表(xh_N)补"内部ID"列,并把当前"箱号"列值复制过去 // 遍历表索引拿到所有"箱号"类型记录的实际表名,逐个迁移 var xhTables = new System.Collections.Generic.List(); string selXhTbl = "SELECT DISTINCT 实际表名 FROM 表索引 WHERE 类型='箱号';"; using (var selCmd = new System.Data.SQLite.SQLiteCommand(selXhTbl, 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) { // xhTbl 是纯表名(如 xh_1),建表时用双引号括起,迁移时也要括起 string quotedXhTbl = "\"" + xhTbl + "\""; EnsureColumns(conn, quotedXhTbl, new[] { "内部ID" }); MigrateInternalIdColumn(conn, quotedXhTbl, "箱号", "内部ID"); } // ★ A1(2026-09-30):所有元件表(yj_N)补"内部ID"列并回填 GUID—— // 三表统一定位体系,直写 UPDATE/回填全部按内部ID,rowid 从定位链退役 MigrateYuanjianInternalId(conn); // ★ A5(2026-10-01):数据库健康自检(断链索引/孤儿表/序号重复,幂等) 数据库健康自检(conn); } } /// /// 老库迁移辅助:把"显示名列"的值复制到"内部ID列"(仅对内部ID为空的行)。 /// 用于图号表(图号→内部ID)和箱号表(箱号→内部ID)的迁移。 /// /// 已打开的连接 /// 表名(已含双引号) /// 显示名列名(如"图号"/"箱号") /// 内部ID列名(如"内部ID") private static void MigrateInternalIdColumn(System.Data.SQLite.SQLiteConnection conn, string quotedTableName, string displayCol, string idCol) { try { // 只对"内部ID为空"的行补值,避免覆盖已迁移的数据 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 { } // 迁移失败不阻断(列可能不存在等) } /// /// ★ A1(2026-09-30):给项目库里【所有元件表】幂等补"内部ID"列并为空值行回填 GUID。 /// 统一定位体系:三表全部用内部ID定位(直写 UPDATE/回填),rowid 从定位链退役。 /// 在 EnsureQuoteDbExists 末尾调用(主窗口+扒图窗口共用,internal static)。 /// internal static void MigrateYuanjianInternalId(System.Data.SQLite.SQLiteConnection conn) { try { var yjTables = new System.Collections.Generic.List(); using (var sel = new System.Data.SQLite.SQLiteCommand( "SELECT DISTINCT 实际表名 FROM 表索引 WHERE 类型='元件';", conn)) using (var rd = sel.ExecuteReader()) { while (rd.Read()) { string t = rd.IsDBNull(0) ? "" : rd.GetString(0); if (!string.IsNullOrWhiteSpace(t)) yjTables.Add(t); } } foreach (string t in yjTables) { string q = "\"" + t.Replace("\"", "") + "\""; EnsureColumns(conn, q, new[] { "内部ID" }); try { // 空值行回填 GUID(已有值的不覆盖;SQLite 无 GUID 函数,逐行生成) var empties = new System.Collections.Generic.List(); using (var sel2 = new System.Data.SQLite.SQLiteCommand( "SELECT rowid FROM " + q + " WHERE 内部ID IS NULL OR 内部ID = '';", conn)) using (var rd2 = sel2.ExecuteReader()) { while (rd2.Read()) empties.Add(rd2.GetInt64(0)); } if (empties.Count == 0) continue; using (var tx = conn.BeginTransaction()) using (var upd = new System.Data.SQLite.SQLiteCommand( "UPDATE " + q + " SET 内部ID = @g WHERE rowid = @r;", conn, tx)) { upd.Parameters.Add("@g", System.Data.DbType.String); upd.Parameters.Add("@r", System.Data.DbType.Int64); upd.Prepare(); foreach (long rid in empties) { upd.Parameters["@g"].Value = System.Guid.NewGuid().ToString("N"); upd.Parameters["@r"].Value = rid; upd.ExecuteNonQuery(); } tx.Commit(); } } catch { /* 单表失败不影响其他表 */ } } } catch { } } /// /// ★ A5(2026-10-01):数据库健康自检——进项目/建库时自动执行(幂等,毫秒级): /// 1) 断链索引:表索引登记的"实际表名"在库里不存在 → 删除该索引行 /// (断链会让查表永远返回空,数据看起来"消失";删掉索引行不删任何数据表) /// 2) 孤儿表:库里的 xh_*/yj_* 表没有索引登记 → 只记日志,不自动删(数据安全) /// 3) 序号重复:箱号表/元件表"序号"列有重复 → 按 rowid 顺序重排 1..N /// (重复序号导致加载排序不稳定、行序漂移) /// 内部ID缺失的回填由 MigrateInternalIdColumn/MigrateYuanjianInternalId 负责,此处不重复。 /// internal static void 数据库健康自检(System.Data.SQLite.SQLiteConnection conn) { try { // ---- 0.5) ★ B1(2026-10-01):表索引建复合索引(幂等)—— // 此前 类型+图号+箱号 查询全表扫描,全库重算时一次重算几十图号×几十箱号全是扫全表 try { using (var idx = new System.Data.SQLite.SQLiteCommand( "CREATE INDEX IF NOT EXISTS idx_表索引_类型图号箱号 ON 表索引(类型, 图号, 箱号);", conn)) { idx.ExecuteNonQuery(); } } catch { /* 老库/权限异常不阻断自检 */ } // ---- 0.6) ★ B3(2026-10-01):数据表补"内部ID"索引(幂等)——直写/重算按内部ID定位不再全表扫描 try { 确保数据表性能索引(conn); } catch { } // ---- 0) 库里现有的全部表 ---- var dbTables = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); using (var sel = new System.Data.SQLite.SQLiteCommand( "SELECT name FROM sqlite_master WHERE type='table';", conn)) using (var rd = sel.ExecuteReader()) { while (rd.Read()) { string n = rd.IsDBNull(0) ? "" : rd.GetString(0); if (!string.IsNullOrWhiteSpace(n)) dbTables.Add(n); } } // ---- 1) 断链索引:登记的表不存在 → 删索引行 ---- var brokenRowIds = new System.Collections.Generic.List(); var indexedTables = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); using (var sel = new System.Data.SQLite.SQLiteCommand( "SELECT rowid, 类型, 实际表名 FROM 表索引;", conn)) using (var rd = sel.ExecuteReader()) { while (rd.Read()) { long rid = rd.GetInt64(0); string type = rd.IsDBNull(1) ? "" : rd.GetString(1); string tbl = rd.IsDBNull(2) ? "" : rd.GetString(2).Trim().Trim('"'); if (string.IsNullOrWhiteSpace(tbl) || !dbTables.Contains(tbl)) { brokenRowIds.Add(rid); 写日志("[健康自检] 断链索引(类型=" + type + " 表=" + tbl + " 不存在,已删索引行) rowid=" + rid); } else if (type == "箱号" || type == "元件") { indexedTables.Add(tbl); } } } if (brokenRowIds.Count > 0) { using (var del = new System.Data.SQLite.SQLiteCommand( "DELETE FROM 表索引 WHERE rowid=@r;", conn)) { del.Parameters.Add("@r", System.Data.DbType.Int64); del.Prepare(); foreach (long rid in brokenRowIds) { del.Parameters["@r"].Value = rid; del.ExecuteNonQuery(); } } } // ---- 2) 孤儿表:xh_*/yj_* 无索引登记 → 只记日志 ---- foreach (string t in dbTables) { if ((t.StartsWith("xh_", StringComparison.OrdinalIgnoreCase) || t.StartsWith("yj_", StringComparison.OrdinalIgnoreCase)) && !indexedTables.Contains(t)) { 写日志("[健康自检] 孤儿表(无索引登记,未自动删除): " + t); } } // ---- 3) 序号重复:箱号表/元件表按 rowid 顺序重排 1..N ---- foreach (string t in indexedTables) { try { string q = "\"" + t.Replace("\"", "") + "\""; // 表里得有"序号"列才检查 bool hasCol = false; using (var chk = new System.Data.SQLite.SQLiteCommand( "SELECT COUNT(*) FROM pragma_table_info(" + q + ") WHERE name='序号';", conn)) { hasCol = Convert.ToInt64(chk.ExecuteScalar()) > 0; } if (!hasCol) continue; long dupGroups = 0; using (var dup = new System.Data.SQLite.SQLiteCommand( "SELECT COUNT(*) FROM (SELECT 序号 FROM " + q + " GROUP BY 序号 HAVING COUNT(*) > 1);", conn)) { dupGroups = Convert.ToInt64(dup.ExecuteScalar()); } if (dupGroups <= 0) continue; // 有重复 → 按 rowid 顺序重排(行顺序不变,只改序号值) var rowIds = new System.Collections.Generic.List(); using (var sel = new System.Data.SQLite.SQLiteCommand( "SELECT rowid FROM " + q + " ORDER BY rowid;", conn)) using (var rd = sel.ExecuteReader()) { while (rd.Read()) rowIds.Add(rd.GetInt64(0)); } using (var tx = conn.BeginTransaction()) using (var upd = new System.Data.SQLite.SQLiteCommand( "UPDATE " + q + " SET 序号=@s WHERE rowid=@r;", conn, tx)) { upd.Parameters.Add("@s", System.Data.DbType.String); upd.Parameters.Add("@r", System.Data.DbType.Int64); upd.Prepare(); int seq = 0; foreach (long rid in rowIds) { seq++; upd.Parameters["@s"].Value = seq.ToString(); upd.Parameters["@r"].Value = rid; upd.ExecuteNonQuery(); } tx.Commit(); } 写日志("[健康自检] 表[" + t + "] 序号重复组=" + dupGroups + ",已按原行序重排 1.." + rowIds.Count); } catch (Exception exT) { 写日志("[健康自检] 表[" + t + "] 序号检查异常: " + exT.Message); } } } catch (Exception ex) { 写日志("[健康自检] 异常: " + ex.Message); } } // ============================================================ // 元件表:每个箱号一张独立表(表名=箱号原名,双引号括起支持中文) // ============================================================ /// /// 根据箱号生成对应的元件表名(用双引号括起,支持中文表名)。 /// 例:箱号"新箱号1" → 表名 '"新箱号1"'。 /// 每个箱号的元件数据完全隔离在各自的表里,彻底避免串数据。 /// private static string GetYuanjianTableName(string xianghao) { if (string.IsNullOrWhiteSpace(xianghao)) return "\"元件表\""; // 双引号内的双引号需翻倍转义(实际箱号不会含双引号,这里防御性处理) string safe = xianghao.Replace("\"", "\"\""); return "\"" + safe + "\""; } /// /// 确保指定箱号的元件表存在;不存在则创建(列结构沿用模板,去掉图号/箱号列, /// 因为表名本身已隔离箱号)。 /// private void EnsureYuanjianTableExists(string projectId, string xianghao) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(xianghao)) return; string dbPath = GetQuoteDbPath(projectId); if (!System.IO.File.Exists(dbPath)) return; string tblName = GetYuanjianTableName(xianghao); 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", xianghao); object o = chk.ExecuteScalar(); if (o != null) return; // 表已存在 } // 建表:用户输入列用 TEXT,公式计算列(含表价)用 REAL(数值类型,Format 才能生效) string sql = "CREATE TABLE " + tblName + " (" + " 序号 TEXT, 元件名称 TEXT, 规格 TEXT, 单位 TEXT, 数量 TEXT," + " 表价 REAL, 采购单价 REAL, 采购系数 TEXT, 采购价 REAL," + " 报出单价 REAL, 报出系数 TEXT, 报出价 REAL," + " 图纸规格 TEXT, 品牌 TEXT, 系列 TEXT, 类型 TEXT, 备注 TEXT" + ");"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.ExecuteNonQuery(); } } } catch { // 建表失败不阻断,后续 SQL 会自然报错或返回空 } } /// 返回指定箱号的独立元件表里的行数+1(用于新建元件的默认序号)。 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 (!System.IO.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"; } } // ============================================================ // 箱号表:每个图号一张独立表(表名=图号原名,双引号括起支持中文) // ============================================================ /// /// 根据图号生成对应的箱号表名(用双引号括起,支持中文表名)。 /// 例:图号"新图号1" → 表名 '"新图号1"'。 /// 每个图号的箱号数据完全隔离在各自的表里,彻底避免串数据。 /// private static string GetXianghaoTableName(string tuhao) { if (string.IsNullOrWhiteSpace(tuhao)) return "\"图号箱号表\""; string safe = tuhao.Replace("\"", "\"\""); return "\"" + safe + "\""; } /// /// 确保指定图号的箱号表存在;不存在则创建(列结构沿用模板,去掉图号列)。 /// private void EnsureXianghaoTableExists(string projectId, string tuhao) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao)) return; string dbPath = GetQuoteDbPath(projectId); if (!System.IO.File.Exists(dbPath)) return; string tblName = GetXianghaoTableName(tuhao); 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", tuhao); object o = chk.ExecuteScalar(); if (o != null) return; // 表已存在 } // 建表:用户输入列用 TEXT,公式计算列用 REAL(数值类型,Format 才能生效) string sql = "CREATE TABLE " + tblName + " (" + " 序号 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 { // 建表失败不阻断 } } /// 返回指定图号的独立箱号表里的行数+1(用于新建箱号的默认序号)。 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 (!System.IO.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"; } } // ============================================================ // 表索引:用唯一ID做表名,索引表记录 图号/箱号 → 真实表名 的映射 // 彻底避免"用名称做表名"导致的重名冲突和状态不同步(串数据根因) // ============================================================ /// 索引表名常量。 private const string IndexTableName = "表索引"; /// /// 重新编号指定表的序号列,使其连续(1、2、3...)。 /// 按当前行顺序(数据库返回顺序)重新分配序号。 /// 用于删除中间行后,让剩余行的序号自动连续。 /// /// 项目编号 /// 表名(已含双引号,如 "xh_1" 或 advancedDataGridView9Table) private void RenumberTable(string projectId, string quotedTableName) { if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(quotedTableName)) return; string dbPath = GetQuoteDbPath(projectId); if (!System.IO.File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // ★ 费用行置底(2026-10-01):有"类型"列的表(元件表)非费用行先编 1..N,费用行排最后续号; // 无"类型"列的表(箱号表等)保持原 rowid 顺序编号 bool 有类型列 = false; using (var chk = new System.Data.SQLite.SQLiteCommand( "SELECT COUNT(*) FROM pragma_table_info(" + quotedTableName + ") WHERE name='类型';", conn)) { 有类型列 = Convert.ToInt64(chk.ExecuteScalar()) > 0; } string selSql = 有类型列 ? "SELECT rowid FROM " + quotedTableName + " ORDER BY (IFNULL(类型,'')='费用'), rowid;" : "SELECT rowid FROM " + quotedTableName + " ORDER BY rowid;"; var rowids = new System.Collections.Generic.List(); using (var sel = new System.Data.SQLite.SQLiteCommand(selSql, conn)) { using (var reader = sel.ExecuteReader()) { while (reader.Read()) { rowids.Add(reader.GetInt64(0)); } } } // 逐行 UPDATE 序号为 1、2、3... 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 { } } /// /// 生成下一个唯一表ID(前缀+自增序号)。 /// 前缀:xh=箱号表,yj=元件表。序号取索引表里该前缀已有最大值+1。 /// private string GetNextTableId(string projectId, string prefix) { if (string.IsNullOrWhiteSpace(projectId)) return prefix + "_1"; string dbPath = GetQuoteDbPath(projectId); if (!System.IO.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); // 解析 xh_3 → 3 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 (!System.IO.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 (!System.IO.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):双键兼容—— /// 1) 先按传入的 xianghao(可能是内部ID GUID,也可能是旧数据显示名)直接查 /// 2) 若未命中,查箱号表把 xianghao(显示名)转成 内部ID 再查(向后兼容老数据) /// private string GetYuanjianTableByIndex(string projectId, string tuhao, string xianghao) { if (string.IsNullOrWhiteSpace(xianghao)) return ""; // 1) 直接查(命中内部ID GUID 或老数据显示名) string realTbl = LookupTableByIndex(projectId, "元件", tuhao, xianghao); if (!string.IsNullOrEmpty(realTbl)) return realTbl; // 2) 显示名 → 内部ID 兜底(老数据元件索引按显示名登记,新数据按 GUID,这里处理显示名分支) string xhIdByName = ResolveXianghaoInternalId(projectId, tuhao, xianghao); if (!string.IsNullOrEmpty(xhIdByName) && xhIdByName != xianghao) return LookupTableByIndex(projectId, "元件", tuhao, xhIdByName); return ""; } /// /// ★ 真隔离(2026-09-29):按图号+箱号显示名查箱号表的"内部ID"列(用于把显示名转成内部ID)。 /// 找不到返回空。表索引里箱号表索引的"箱号"字段为空字符串,所以箱号表通过 GetXianghaoTableByIndex 拿。 /// 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 (!System.IO.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 ""; // 箱号表的"内部ID"列可能不存在(老数据),EnsureColumns 兜底 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 (!System.IO.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 ""; } } /// /// 查索引表(复用已有连接版本,用于循环/批量场景,避免每次开关连接)。 /// 调用方负责保证 conn 已 Open 且指向正确的报价库。 /// 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 ""; } } /// 查索引表:图号+箱号 → 真实表名(复用连接版本)。 /// ★ 真隔离(2026-09-29):双键兼容——先按 xianghao 直接查, /// 未命中则查箱号表把显示名转成 内部ID 再查(向后兼容老数据)。 /// private string GetYuanjianTableByIndex(string tuhao, string xianghao, System.Data.SQLite.SQLiteConnection conn) { if (string.IsNullOrWhiteSpace(xianghao)) return ""; // 1) 直接查(命中内部ID GUID 或老数据显示名) string realTbl = LookupTableByIndex("元件", tuhao, xianghao, conn); if (!string.IsNullOrEmpty(realTbl)) return realTbl; // 2) 显示名 → 内部ID 兜底 string xhIdByName = ResolveXianghaoInternalId(tuhao, xianghao, conn); if (!string.IsNullOrEmpty(xhIdByName) && xhIdByName != xianghao) return LookupTableByIndex("元件", tuhao, xhIdByName, conn); return ""; } /// 查索引表:图号 → 箱号表真实表名(复用连接版本)。 private string GetXianghaoTableByIndex(string tuhao, System.Data.SQLite.SQLiteConnection conn) { return LookupTableByIndex("箱号", tuhao, "", conn); } /// /// 用户改图号名后,更新项目库表索引里的图号名(箱号表索引 + 所有元件表索引)。 /// private void UpdateTuhaoIndex(string projectId, string oldTuhao, string newTuhao) { if (string.IsNullOrWhiteSpace(projectId)) return; if (string.IsNullOrWhiteSpace(oldTuhao) || string.IsNullOrWhiteSpace(newTuhao)) return; if (oldTuhao == newTuhao) return; string dbPath = GetQuoteDbPath(projectId); if (!System.IO.File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var tx = conn.BeginTransaction()) { // 更新表索引里所有该图号的记录(箱号表 + 元件表) string sql = "UPDATE 表索引 SET 图号 = @new WHERE 图号 = @old;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn, tx)) { cmd.Parameters.AddWithValue("@new", newTuhao); cmd.Parameters.AddWithValue("@old", oldTuhao); int affected = cmd.ExecuteNonQuery(); Log("[改名同步] 图号索引更新: " + oldTuhao + " → " + newTuhao + " (" + affected + " 条)"); } tx.Commit(); } } } catch (Exception ex) { Log("[改名同步] 图号索引更新失败: " + ex.Message); } } /// /// 用户改箱号名后,更新项目库表索引里的箱号名(元件表索引)。 /// private void UpdateXianghaoIndex(string projectId, string tuhao, string oldXianghao, string newXianghao) { if (string.IsNullOrWhiteSpace(projectId)) return; if (string.IsNullOrWhiteSpace(tuhao)) return; if (string.IsNullOrWhiteSpace(oldXianghao) || string.IsNullOrWhiteSpace(newXianghao)) return; if (oldXianghao == newXianghao) return; string dbPath = GetQuoteDbPath(projectId); if (!System.IO.File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var tx = conn.BeginTransaction()) { // 更新表索引里该图号+箱号的元件表记录 string sql = "UPDATE 表索引 SET 箱号 = @new WHERE 图号 = @th AND 箱号 = @old AND 类型 = '元件';"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn, tx)) { cmd.Parameters.AddWithValue("@new", newXianghao); cmd.Parameters.AddWithValue("@th", tuhao); cmd.Parameters.AddWithValue("@old", oldXianghao); int affected = cmd.ExecuteNonQuery(); Log("[改名同步] 箱号索引更新: " + tuhao + " / " + oldXianghao + " → " + newXianghao + " (" + affected + " 条)"); } tx.Commit(); } } } catch (Exception ex) { Log("[改名同步] 箱号索引更新失败: " + ex.Message); } } // ============================================================ // CAD/PDF 扒图(button11/button34)——独立程序完整窗口原样搬入 // 的文档区停靠工作区(左报价三表 Form3 + 右查看器,内部 Smart // DockSurface 可拖拽布局,扒图/合并/定位/导出功能与独立版一致) // ============================================================ // 扒图工作区实例(懒创建;IsDisposed 后重建,支持关了再开) private Cad扒图窗口 _cadWin; private Pdf扒图窗口 _pdfWin; /// CAD扒图(button11):在文档区打开 CAD 扒图工作区(报价三表+网页CAD查看器)。 /// 打开时功能区自动跳到「CAD扒图」页签(与项目/报价窗口同款联动)。 private void button11_Click(object sender, EventArgs e) { if (_cadWin == null || _cadWin.IsDisposed) _cadWin = new Cad扒图窗口(); _cadWin.Show(this.dockSurface1, WeifenLuo.WinFormsUI.Docking.DockState.Document); _cadWin.Activate(); 跳转功能区页签("CAD扒图"); Log("[CAD扒图] 已在文档区打开 CAD 扒图工作区"); } /// PDF扒图(button34):在文档区打开 PDF 扒图工作区(报价三表+PDF查看器)。 /// 打开时功能区自动跳到「PDF扒图」页签(与项目/报价窗口同款联动)。 private void button34_Click(object sender, EventArgs e) { if (_pdfWin == null || _pdfWin.IsDisposed) _pdfWin = new Pdf扒图窗口(); _pdfWin.Show(this.dockSurface1, WeifenLuo.WinFormsUI.Docking.DockState.Document); _pdfWin.Activate(); 跳转功能区页签("PDF扒图"); Log("[PDF扒图] 已在文档区打开 PDF 扒图工作区"); } /// 把功能区切到指定页签(找不到静默跳过;同步 _lastRibbonActiveIndex 防 MouseUp 正向联动抖动)。 private void 跳转功能区页签(string 页签名) { try { int idx = 功能区页索引(页签名); if (idx >= 0 && this.ribbonStrip1 != null && this.ribbonStrip1.ActiveTabIndex != idx) { this.ribbonStrip1.ActiveTabIndex = idx; _lastRibbonActiveIndex = idx; Log("[TabSync] 扒图窗口打开→功能区页签: [" + 页签名 + "]"); } } catch (Exception ex) { Log("[TabSync] 跳转功能区页签异常: " + ex.Message); } } /// /// 检查成套报价 CAD 插件是否已安装。 /// 三级检测:①HKLM 注册表 CADCJ 节点 ②acaddoc.lsp CADCJ 加载逻辑 ③cadcj.dll 存在性 /// private bool IsCadcjInstalled() { try { Log("[CAD检测] 开始检测 CAD 插件安装状态..."); // --- 方法1:HKLM 注册表检测 --- if (IsCadcjInstalledViaRegistry()) { Log("[CAD检测] 注册表检测:已找到 CADCJ 插件"); return true; } Log("[CAD检测] 注册表检测:未找到 CADCJ 节点"); // --- 方法2:acaddoc.lsp 检测(安装脚本实际使用的加载机制) --- if (IsCadcjInstalledViaLsp()) { Log("[CAD检测] acaddoc.lsp 检测:已找到 CADCJ 加载逻辑"); return true; } Log("[CAD检测] acaddoc.lsp 检测:未找到 CADCJ 加载逻辑"); Log("[CAD检测] 综合结论:CAD 插件未安装"); return false; } catch (Exception ex) { Log("[CAD检测] 检测异常: " + ex.Message); return false; } } /// /// 方式1:遍历 HKLM 注册表检测 CADCJ 节点。 /// private bool IsCadcjInstalledViaRegistry() { string[] regRoots = new string[] { @"SOFTWARE\Autodesk\AutoCAD", @"SOFTWARE\WOW6432Node\Autodesk\AutoCAD" }; foreach (string root in regRoots) { using (RegistryKey rk = Registry.LocalMachine.OpenSubKey(root, writable: false)) { if (rk == null) { Log("[CAD检测] 注册表项不存在: " + root); continue; } string[] verNames = rk.GetSubKeyNames(); Log("[CAD检测] " + root + " 下有 " + verNames.Length + " 个子项"); foreach (string verName in verNames) { if (!System.Text.RegularExpressions.Regex.IsMatch(verName, @"^R\d+\.0$")) continue; using (RegistryKey verKey = rk.OpenSubKey(verName, writable: false)) { if (verKey == null) continue; foreach (string langName in verKey.GetSubKeyNames()) { if (!System.Text.RegularExpressions.Regex.IsMatch(langName, @"^ACAD-\d+:\d+$")) continue; string cadcjSubPath = langName + @"\Apps\CADCJ"; using (RegistryKey cadcjKey = verKey.OpenSubKey(cadcjSubPath, writable: false)) { if (cadcjKey == null) continue; // 找到了 CADCJ 节点 object module = cadcjKey.GetValue("MODULE"); string dllPath = module != null ? module.ToString() : ""; bool dllExists = !string.IsNullOrEmpty(dllPath) && File.Exists(dllPath); Log("[CAD检测] 找到 CADCJ 节点: " + root + @"\" + verName + @"\" + langName + " | MODULE=" + dllPath + " | DLL存在=" + dllExists); // 只要 CADCJ 节点存在就算已安装(不强制 DLL 文件存在) return true; } } } } } } return false; } /// /// 方式2:检测 AutoCAD Support 目录下 acaddoc.lsp 是否包含 CADCJ 加载逻辑。 /// 这是插件实际的加载机制,独立于注册表存在。 /// private bool IsCadcjInstalledViaLsp() { // 先获取 cadcj.dll 所在目录 string appDir = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); string cadcjDir = Path.Combine(appDir, "cadcj"); string dllPath = Path.Combine(cadcjDir, "cadcj.dll"); string dllEscaped = dllPath.Replace(@"\", @"\\"); // 获取所有已安装的 AutoCAD 版本的 Support 目录 List supportDirs = GetAllAutoCadSupportDirs(); Log("[CAD检测] acaddoc.lsp:找到 " + supportDirs.Count + " 个 Support 目录"); foreach (string supDir in supportDirs) { string lspPath = Path.Combine(supDir, "acaddoc.lsp"); if (!File.Exists(lspPath)) { Log("[CAD检测] " + supDir + "\\acaddoc.lsp 不存在"); continue; } try { string content = File.ReadAllText(lspPath); // 检查是否包含 CADCJ 标记或加载逻辑 if (content.Contains("CADCJ") || content.Contains("cadcj")) { Log("[CAD检测] 找到 CADCJ 加载逻辑: " + lspPath); return true; } Log("[CAD检测] " + lspPath + " 不含 CADCJ 内容"); } catch (Exception ex) { Log("[CAD检测] 读取 " + lspPath + " 失败: " + ex.Message); } } return false; } /// /// 获取所有已安装 AutoCAD 版本的 Support 目录路径。 /// private List GetAllAutoCadSupportDirs() { var dirs = new List(); string[] regRoots = new string[] { @"SOFTWARE\Autodesk\AutoCAD", @"SOFTWARE\WOW6432Node\Autodesk\AutoCAD" }; foreach (string root in regRoots) { using (RegistryKey rk = Registry.LocalMachine.OpenSubKey(root, writable: false)) { if (rk == null) continue; foreach (string verName in rk.GetSubKeyNames()) { if (!System.Text.RegularExpressions.Regex.IsMatch(verName, @"^R\d+\.0$")) continue; using (RegistryKey verKey = rk.OpenSubKey(verName, writable: false)) { if (verKey == null) continue; foreach (string langName in verKey.GetSubKeyNames()) { if (!System.Text.RegularExpressions.Regex.IsMatch(langName, @"^ACAD-\d+:\d+$")) continue; using (RegistryKey langKey = verKey.OpenSubKey(langName, writable: false)) { if (langKey == null) continue; object loc = langKey.GetValue("AcadLocation"); if (loc == null) continue; string cadDir = Path.Combine(loc.ToString(), "Support"); if (Directory.Exists(cadDir) && !dirs.Contains(cadDir)) dirs.Add(cadDir); // 漫游目录(%APPDATA%\Autodesk\<版本名>\Rxx.0\chs\Support) try { string cadDirName = Path.GetFileName(loc.ToString()); string roamingSup = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Autodesk", cadDirName, verName, "chs", "Support"); if (Directory.Exists(roamingSup) && !dirs.Contains(roamingSup)) dirs.Add(roamingSup); } catch { } } } } } } } // 回退:常见 Support 路径 string[] fallback = new string[] { @"C:\Program Files\Autodesk\AutoCAD 2018\Support", @"C:\Program Files\Autodesk\AutoCAD 2018 - Simplified Chinese\Support", @"C:\Program Files\Autodesk\AutoCAD 2020\Support", @"C:\Program Files\Autodesk\AutoCAD 2022\Support", @"C:\Program Files\Autodesk\AutoCAD 2024\Support", @"C:\Program Files\Autodesk\AutoCAD 2025\Support" }; foreach (string f in fallback) { if (Directory.Exists(f) && !dirs.Contains(f)) dirs.Add(f); } return dirs; } /// /// 启动已安装的 AutoCAD。 /// 优先启动 CADCJ 插件已注册的版本;否则启动第一个找到的 AutoCAD。 /// private bool TryStartAutoCAD() { try { // 1) 优先找有 CADCJ 插件注册的 AutoCAD 版本 string exe = FindAutoCADExe(true); // 2) 回退:找任何已安装的 AutoCAD if (string.IsNullOrEmpty(exe)) exe = FindAutoCADExe(false); if (string.IsNullOrEmpty(exe)) return false; Process.Start(new ProcessStartInfo { FileName = exe, WorkingDirectory = Path.GetDirectoryName(exe) }); return true; } catch { return false; } } /// /// 遍历注册表查找 AutoCAD 的 acad.exe 路径。 /// /// true=只返回已注册 CADCJ 插件的版本;false=返回任意已安装版本 private string FindAutoCADExe(bool requireCadcj) { string[] regRoots = new string[] { @"SOFTWARE\Autodesk\AutoCAD", @"SOFTWARE\WOW6432Node\Autodesk\AutoCAD" }; foreach (string root in regRoots) { using (RegistryKey rk = Registry.LocalMachine.OpenSubKey(root, writable: false)) { if (rk == null) continue; // 遍历 Rxx.0 版本(如 R22.0=2018, R23.0=2020, R24.0=2022) foreach (string verName in rk.GetSubKeyNames()) { if (!System.Text.RegularExpressions.Regex.IsMatch(verName, @"^R\d+\.0$")) continue; using (RegistryKey verKey = rk.OpenSubKey(verName, writable: false)) { if (verKey == null) continue; // 遍历语言变体(如 ACAD-1001:804) foreach (string langName in verKey.GetSubKeyNames()) { if (!System.Text.RegularExpressions.Regex.IsMatch(langName, @"^ACAD-\d+:\d+$")) continue; if (requireCadcj) { // 检查此版本是否注册了 CADCJ 插件 string cadcjPath = langName + @"\Apps\CADCJ"; using (RegistryKey cadcjKey = verKey.OpenSubKey(cadcjPath, writable: false)) { if (cadcjKey == null) continue; object module = cadcjKey.GetValue("MODULE"); if (module == null) continue; string dll = module.ToString(); if (string.IsNullOrEmpty(dll) || !File.Exists(dll)) continue; } } // 获取 acad.exe 路径 using (RegistryKey langKey = verKey.OpenSubKey(langName, writable: false)) { if (langKey == null) continue; object loc = langKey.GetValue("AcadLocation"); if (loc == null) continue; string candidate = Path.Combine(loc.ToString(), "acad.exe"); if (File.Exists(candidate)) return candidate; } } } } } } // 回退:常见安装路径(AutoCAD 2018-2024 默认目录) if (!requireCadcj) { string[] fallbackPaths = new string[] { @"C:\Program Files\Autodesk\AutoCAD 2018\acad.exe", @"C:\Program Files\Autodesk\AutoCAD 2018 - Simplified Chinese\acad.exe", @"C:\Program Files\Autodesk\AutoCAD 2020\acad.exe", @"C:\Program Files\Autodesk\AutoCAD 2022\acad.exe", @"C:\Program Files\Autodesk\AutoCAD 2024\acad.exe", @"C:\Program Files\Autodesk\AutoCAD 2025\acad.exe" }; foreach (string p in fallbackPaths) { if (File.Exists(p)) return p; } } return null; } // ============================================================ // 企业信息(btn企业信息)——打开企业信息设置窗口 // ============================================================ /// /// 打开「企业信息」窗口。该窗口自带加载/保存逻辑(配置文件在 shezhi\企业信息.config.xml), /// 关闭后下次「新建项目」会自动读取最新值。 /// private void btn企业信息_Click(object sender, EventArgs e) { using (var dlg = new 企业信息()) { dlg.ShowDialog(this); } } // ============================================================ // 甲方报表(button16)——打开甲方报表导出窗口 // ============================================================ private void button16_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_currentProjectId)) { MessageBox.Show(this, "请先选择一个项目。", "甲方报表", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } using (var dlg = new 甲方报表(_currentProjectId)) { dlg.ShowDialog(this); } } // ============================================================ // 导入报价(button18)——打开导入报价窗口,从甲方报价表 xlsx 导入到当前项目 // ============================================================ private void button18_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_currentProjectId)) { MessageBox.Show(this, "请先在项目表中双击选择一个项目,再导入报价。", "导入报价", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 导入前先保存当前三个表的所有编辑,避免用户编辑丢失 SaveAllQuoteTablesIfDirty(false); string dbPath = GetQuoteDbPath(_currentProjectId); using (var dlg = new 导入报价(_currentProjectId, dbPath)) { if (dlg.ShowDialog(this) == DialogResult.OK) { // 导入成功:清空全部缓存+内存表(防止旧数据覆盖库),然后 // 走"重新打开项目"完整刷新(重算汇总+全表重载+选中联动) _isReloading = true; try { _xianghaoCache.Clear(); _yuanjianCache.Clear(); if (_tuhaoTable != null) { _tuhaoTable.Clear(); _tuhaoTable.AcceptChanges(); } if (_xianghaoTable != null) { _xianghaoTable.Clear(); _xianghaoTable.AcceptChanges(); } if (_yuanjianTable != null) { _yuanjianTable.Clear(); _yuanjianTable.AcceptChanges(); } } finally { _isReloading = false; } // ★ 完整刷新:与方案插入同款,保证报价页立即显示导入的图号/箱号/元件+重算汇总 try { EnterProjectCore(_currentProjectId); } catch (Exception exEnter) { Log("[导入报价] 完整刷新异常: " + exEnter.Message); } } } } // ============================================================ // 调系数(button13)——打开调系数窗口,批量修改元件表的采购/报出系数 // ============================================================ private void button13_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_currentProjectId)) { MessageBox.Show(this, "请先选择一个项目。", "调系数", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 打开前先保存所有未保存编辑,避免后续清缓存导致丢失 SaveAllToDatabase(); using (var dlg = new 调系数(_currentProjectId)) { dlg.ShowDialog(this); } // 调系数窗口直接 UPDATE 数据库,主窗口的 _yuanjianCache 是旧的, // 必须清掉缓存,ReloadYuanjianByXianghao 才会从数据库重新加载最新数据 _yuanjianCache.Clear(); if (!string.IsNullOrWhiteSpace(_currentProjectId) && !string.IsNullOrWhiteSpace(_currentTuhao) && !string.IsNullOrWhiteSpace(_currentXianghao)) { ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); } } // ============================================================ // 新建项目(button1)——保留原有功能 // ============================================================ private void button1_Click(object sender, EventArgs e) { string dbPath = GetDbFullPath(ProjectsDbPath); using (var dlg = new 新建项目(dbPath, ProjectsTableName)) { if (dlg.ShowDialog(this) != DialogResult.OK) { return; } var values = dlg.GetInputValues(); if (values == null || values.Count == 0) { return; } // ★ 残留库文件防线:删除过的项目同一天再新建会重新生成相同编号—— // 若旧库文件没删干净残留在磁盘上,建库会直接"复用"旧文件, // 旧图号/箱号/元件数据全部复活。这里在写入前检查: // 编号对应的库文件已存在且不属于任何列表项目 → 先彻底删掉; // 属于现有项目 → 拒绝新建。 string 新编号 = values.ContainsKey("项目编号") ? (values["项目编号"] ?? "").ToString().Trim() : ""; if (!string.IsNullOrWhiteSpace(新编号)) { string 残留库 = GetQuoteDbPath(新编号); if (System.IO.File.Exists(残留库)) { if (ProjectExists(新编号)) { MessageBox.Show(this, "项目编号 [" + 新编号 + "] 已被现有项目占用,无法新建。", "新建项目", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } try { CascadeDeleteByProject(新编号); Log("[新建项目] 已清理编号 " + 新编号 + " 的残留库文件: " + 残留库); } catch (Exception exDel) { MessageBox.Show(this, "检测到编号 [" + 新编号 + "] 的残留数据库文件,但删除失败:\n" + exDel.Message + "\n请关闭占用它的程序后重新新建。", "新建项目", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } } } try { int affected = InsertProjectToDatabase(values); if (affected <= 0) { MessageBox.Show(this, "未写入任何字段,请检查对话框输入。", "新建项目", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } ReloadProjectsFromDatabase(); } catch (Exception ex) { MessageBox.Show(this, "保存项目失败:\n" + ex.Message, "新建项目", MessageBoxButtons.OK, MessageBoxIcon.Error); } } } // ============================================================ // 报价库「项目信息」表读写(项目信息主存储) // ============================================================ /// 报价库项目信息表的 16 个字段(顺序无关,仅用于 INSERT 时拼列名)。 private static readonly string[] ProjectInfoColumns = { "项目编号", "项目名称", "客户名称", "客户地址", "联系人", "联系电话", "项目日期", "备注", // 成套厂信息(4 个字段):新建项目时从企业信息带入,用户可改写,随项目一起保存 "成套厂名称", "成套厂地址", "成套厂联系人", "成套厂联系电话", // 价格显示格式化(4 个字段):新建项目/修改项目设置,随项目一起保存 "单台小数位", "单台取整方式", "项目小数位", "项目取整方式" }; /// /// 把项目信息(8 个字段)写入报价库的「项目信息」表。 /// 先按项目编号 DELETE 旧行,再 INSERT,避免重复(等价于 INSERT OR REPLACE)。 /// 报价库/表不存在会先 EnsureQuoteDbExists 补建。 /// 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(); } // 拼 INSERT,只写 values 里实际提供的字段(values 可能不含全部 8 个) 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(); } } } /// /// 把修改后的项目信息字段 UPDATE 到报价库的「项目信息」表(WHERE 项目编号=@id)。 /// 报价库/表不存在会先 EnsureQuoteDbExists 补建。无旧行时退化为 INSERT。 /// private void UpdateProjectInfoToQuoteDb(string projectId, Dictionary values) { if (string.IsNullOrWhiteSpace(projectId) || values == null || values.Count == 0) return; // 确保报价库和项目信息表存在 EnsureQuoteDbExists(projectId); string dbPath = GetQuoteDbPath(projectId); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // 只 UPDATE values 里实际提供的字段 var setList = new List(); foreach (string c in ProjectInfoColumns) { if (values.ContainsKey(c)) { setList.Add(c + " = @" + c); } } if (setList.Count == 0) return; string sql = "UPDATE " + ProjectInfoTableName + " SET " + string.Join(", ", setList.ToArray()) + " WHERE 项目编号 = @id;"; int affected; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { foreach (string c in ProjectInfoColumns) { if (values.ContainsKey(c)) { cmd.Parameters.AddWithValue("@" + c, values[c] ?? ""); } } cmd.Parameters.AddWithValue("@id", projectId); affected = cmd.ExecuteNonQuery(); } // 旧行不存在(老库刚补建表,还没有这个项目的信息)→ INSERT 一条 if (affected <= 0) { InsertProjectInfoToQuoteDb(projectId, values); } } } /// /// 从报价库的「项目信息」表读取指定项目的 8 个信息字段。 /// 库不存在/表不存在/无数据 → 返回空字典(调用方按需填空显示)。 /// private Dictionary GetProjectInfoFromQuoteDb(string projectId) { var info = new Dictionary(); if (string.IsNullOrWhiteSpace(projectId)) return info; string dbPath = GetQuoteDbPath(projectId); if (!System.IO.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); object o = chk.ExecuteScalar(); if (o == 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 System.Collections.Generic.HashSet( System.StringComparer.OrdinalIgnoreCase); for (int i = 0; i < reader.FieldCount; i++) { existCols.Add(reader.GetName(i)); } // 按 ProjectInfoColumns 清单读,列不存在就跳过(留空) 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 int InsertProjectToDatabase(Dictionary values) { if (values == null || values.Count == 0) { return 0; } string dbPath = GetDbFullPath(ProjectsDbPath); if (!System.IO.File.Exists(dbPath)) { // 项目库被删过(项目全部清空),现在第一次新建项目时按需创建空库。 EnsureProjectDbExists(dbPath); if (!System.IO.File.Exists(dbPath)) { throw new System.IO.FileNotFoundException( "无法创建项目数据库文件: " + dbPath, dbPath); } } int affected; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // 项目库索引行:只写项目表实际存在的列(项目表只存基础字段, // 其余如价格格式化配置只写到报价库的「项目信息」表,避免 INSERT 报错)。 var allowed = new System.Collections.Generic.HashSet( GetProjectTableColumns(conn), System.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; } // ============================================================ // 修改项目(button2:项目菜单页 / button10:报价菜单页) // ============================================================ /// 项目菜单页 tabPage1 上的"修改项目"按钮(无论当前在哪个页面都可用)。 private void button2_Click(object sender, EventArgs e) { OpenModifyProjectDialog(); } /// 报价菜单页 tabPage2 上的"修改项目"按钮(仅在报价页已打开且选中项目时可用)。 /// 打开"修改项目"对话框,根据 advancedDataGridView6 当前选中行预填字段。 /// 确定后把改动 UPDATE 回项目数据库,并刷新项目表。 /// private void OpenModifyProjectDialog() { // 优先使用 advancedDataGridView6 的当前选中行定位项目 DataGridViewRow row = GetFirstSelectedProjectRow(); if (row == null) { // 报价菜单页 button10 在没有项目选中时也允许打开, // 此时用 _currentProjectId(双击项目行后记录的)作为兜底 if (!string.IsNullOrWhiteSpace(_currentProjectId)) { // 尝试在 advancedDataGridView6 中按 _currentProjectId 定位行 row = FindProjectRowById(_currentProjectId); } } 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; } // 从当前行收集现有字段值,用于预填对话框 var existing = new Dictionary(); CollectRowValues(row, existing); // 价格格式化(4 个字段)、成套厂信息(4 个字段)等存在报价库的「项目信息」表里, // 项目表格(advancedDataGridView6)里没有这些列,需要从报价库补充读取。 var quoteInfo = GetProjectInfoFromQuoteDb(projectId); if (quoteInfo != null && quoteInfo.Count > 0) { foreach (var kv in quoteInfo) { // 只补项目表格里没有的字段,避免覆盖表格行已有的值 if (!existing.ContainsKey(kv.Key)) { existing[kv.Key] = kv.Value; } } } string dbPath = GetDbFullPath(ProjectsDbPath); using (var dlg = new 修改项目(dbPath, ProjectsTableName, projectId, existing)) { if (dlg.ShowDialog(this) != DialogResult.OK) { return; } var values = dlg.GetInputValues(); if (values == null || values.Count == 0) { return; } try { int affected = UpdateProjectToDatabase(projectId, values); if (affected <= 0) { MessageBox.Show(this, "未更新任何记录,请检查项目是否存在。", "修改项目", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 记录当前项目编号,保留在原选中位置 _currentProjectId = projectId; ReloadProjectsFromDatabase(); RestoreProjectSelection(projectId); // ★ 重新读取价格格式化配置(用户可能改了小数位/取整方式)并应用到所有表格 LoadPriceFormatConfig(projectId); } catch (Exception ex) { MessageBox.Show(this, "修改项目失败:\n" + ex.Message, "修改项目", MessageBoxButtons.OK, MessageBoxIcon.Error); } } } // ============================================================ // 复制项目(button5):选中项目 → 整库(图号/箱号/元件/项目信息)复制 → // 生成新项目编号 → 项目名称加"-副本" → 追加到项目表。 // 复制用 BackupSQLiteDb(SQL 层复制,避开文件锁),公式列照搬, // 进入新项目时会触发 RecalcAllFromDatabase 全量重算,数据自洽。 // ============================================================ /// /// 生成下一个项目编号(yyyyMMddNNN): /// 查项目表里当天前缀的最大 3 位序号 +1,与「新建项目」对话框规则一致。 /// private string GenerateNextProjectId() { string prefix = DateTime.Now.ToString("yyyyMMdd"); int next = 1; try { string dbPath = GetDbFullPath(ProjectsDbPath); if (System.IO.File.Exists(dbPath)) { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "SELECT MAX(CAST(SUBSTR(项目编号, 9) AS INTEGER)) FROM " + ProjectsTableName + " WHERE SUBSTR(项目编号, 1, 8) = @p;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@p", prefix); object o = cmd.ExecuteScalar(); if (o != null && o != DBNull.Value) { int max; if (int.TryParse(o.ToString(), out max) && max > 0) next = max + 1; } } } } } catch { } return prefix + next.ToString("000"); } /// /// 弹一个简单的文本输入对话框(标题+提示+默认值)。 /// 返回用户输入;点取消返回 null。 /// private static string ShowSimpleInputDialog(IWin32Window owner, string title, string label, string defaultValue) { using (var form = new Form()) { form.Text = title; form.FormBorderStyle = FormBorderStyle.FixedDialog; form.StartPosition = FormStartPosition.CenterParent; form.MinimizeBox = false; form.MaximizeBox = false; form.ClientSize = new Size(360, 120); var lbl = new Label(); lbl.Text = label; lbl.AutoSize = true; lbl.Location = new Point(12, 15); var txt = new TextBox(); txt.Text = defaultValue == null ? "" : defaultValue; txt.Location = new Point(12, 40); txt.Size = new Size(330, 22); txt.SelectAll(); var btnOk = new Button(); btnOk.Text = "确定"; btnOk.DialogResult = DialogResult.OK; btnOk.Location = new Point(180, 80); btnOk.Size = new Size(75, 28); var btnCancel = new Button(); btnCancel.Text = "取消"; btnCancel.DialogResult = DialogResult.Cancel; btnCancel.Location = new Point(267, 80); btnCancel.Size = new Size(75, 28); form.Controls.AddRange(new Control[] { lbl, txt, btnOk, btnCancel }); form.AcceptButton = btnOk; form.CancelButton = btnCancel; if (form.ShowDialog(owner) == DialogResult.OK) { return txt.Text.Trim(); } return null; } } /// 复制项目(button5):把选中项目整库复制为新项目。 private void button5_Click(object sender, EventArgs e) { // 1) 取选中项目 DataGridViewRow row = GetFirstSelectedRow(this.advancedDataGridView6); string srcPid; if (row != null) { srcPid = GetCellValueAsString(row, "项目编号"); } else { srcPid = _currentProjectId; // 没选中行时用当前打开的项目兜底 } if (string.IsNullOrWhiteSpace(srcPid)) { MessageBox.Show(this, "请先在项目列表中选择要复制的项目。", "复制项目", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string srcDb = GetQuoteDbPath(srcPid); if (!System.IO.File.Exists(srcDb)) { MessageBox.Show(this, "源项目数据库不存在:\n" + srcDb, "复制项目", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // 2) 读取源项目元数据(项目表行 + 报价库项目信息表) var meta = ReadProjectMetaData(srcPid); if (meta == null) { MessageBox.Show(this, "读取项目信息失败,无法复制。", "复制项目", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var info = GetProjectInfoFromQuoteDb(srcPid); // 项目信息表(含成套厂/价格格式等全部字段) if (info == null) info = new Dictionary(); string srcName = meta.ContainsKey("项目名称") ? (meta["项目名称"] ?? "").ToString() : ""; // 3) 弹简单输入框:默认新名称 = 原名-副本 string defaultName = string.IsNullOrWhiteSpace(srcName) ? (srcPid + "-副本") : (srcName + "-副本"); string newName = ShowSimpleInputDialog(this, "复制项目", "新项目名称:", defaultName); if (string.IsNullOrWhiteSpace(newName)) return; // 取消 try { // 4) 生成新编号,确保报价库文件不撞名 string newPid = GenerateNextProjectId(); string newDb = GetQuoteDbPath(newPid); while (System.IO.File.Exists(newDb)) { // 极端情况:编号已存在(比如手工建过),再取下一个 string p = GenerateNextProjectId(); // 同一天内已 +1,循环兜底 if (p == newPid) break; newPid = p; newDb = GetQuoteDbPath(newPid); } // 5) 复制前先把源项目内存里的未保存编辑落盘(保证副本拿到最新数据) SaveAllQuoteTablesIfDirty(false); // 6) 整库复制(SQL 层,避开文件锁):图号/箱号/元件/表索引/项目信息全带走 BackupSQLiteDb(srcDb, newDb); // 7) 改写新库的「项目信息」表:新编号 + 新名称 var newInfo = new Dictionary(info); newInfo["项目编号"] = newPid; newInfo["项目名称"] = newName; newInfo["项目日期"] = DateTime.Now.ToString("yyyy-MM-dd"); InsertProjectInfoToQuoteDb(newPid, newInfo); // 8) 项目表追加一行(汇总列由进入项目时重算,先清空避免误导) var rowVals = new Dictionary(); rowVals["项目编号"] = newPid; rowVals["项目名称"] = newName; foreach (string col in new string[] { "客户名称", "客户地址", "联系人", "联系电话", "备注" }) { if (meta.ContainsKey(col)) rowVals[col] = (meta[col] ?? "").ToString(); } rowVals["项目日期"] = DateTime.Now.ToString("yyyy-MM-dd"); InsertProjectToDatabase(rowVals); // 9) 刷新列表并选中新项目 ReloadProjectsFromDatabase(); RestoreProjectSelection(newPid); Log("[复制项目] " + srcPid + " → " + newPid + " 名称=" + newName); MessageBox.Show(this, "复制成功!\n\n新项目编号: " + newPid + "\n新项目名称: " + newName, "复制项目", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { Log("[复制项目] 失败: " + ex.Message); MessageBox.Show(this, "复制项目失败:\n" + ex.Message, "复制项目", MessageBoxButtons.OK, MessageBoxIcon.Error); } } /// 把 advancedDataGridView6 当前行的列值收集到字典(键=列名)。 private void CollectRowValues(DataGridViewRow row, Dictionary dict) { if (row == null || row.DataGridView == null) return; foreach (DataGridViewColumn col in row.DataGridView.Columns) { if (col == null) continue; string key = !string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : (!string.IsNullOrEmpty(col.HeaderText) ? col.HeaderText : col.Name); if (string.IsNullOrEmpty(key)) continue; object v = row.Cells[col.Index].Value; dict[key] = v == null ? "" : v.ToString().Trim(); } } /// 按项目编号在 advancedDataGridView6 中查找对应行(找不到返回 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; string pid = GetCellValueAsString(r, "项目编号"); if (pid == projectId) return r; } return null; } /// 按项目编号在 advancedDataGridView6 中重新选中对应行。 private void RestoreProjectSelection(string projectId) { DataGridViewRow row = FindProjectRowById(projectId); if (row == null) return; try { this.advancedDataGridView6.ClearSelection(); row.Selected = true; this.advancedDataGridView6.CurrentCell = row.Cells[0]; } catch { } } /// 把修改后的字段 UPDATE 到项目数据库(索引+汇总缓存),并同步到报价库项目信息表。 private int UpdateProjectToDatabase(string projectId, Dictionary values) { if (string.IsNullOrWhiteSpace(projectId) || values == null || values.Count == 0) { return 0; } string dbPath = GetDbFullPath(ProjectsDbPath); if (!System.IO.File.Exists(dbPath)) { throw new System.IO.FileNotFoundException( "找不到项目数据库文件: " + dbPath, dbPath); } int affected; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // 只 UPDATE 项目表实际存在的列(过滤掉价格格式化等只属于报价库的字段) var allowed = new System.Collections.Generic.HashSet( GetProjectTableColumns(conn), System.StringComparer.OrdinalIgnoreCase); var setList = new List(); var filteredValues = new System.Collections.Generic.Dictionary(); foreach (var kv in values) { if (allowed.Contains(kv.Key)) { setList.Add(kv.Key + " = @" + kv.Key); filteredValues[kv.Key] = kv.Value; } } if (setList.Count == 0) { affected = 0; } else { string sql = "UPDATE " + ProjectsTableName + " SET " + string.Join(", ", setList.ToArray()) + " WHERE 项目编号 = @id;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { foreach (var kv in filteredValues) { cmd.Parameters.AddWithValue("@" + kv.Key, kv.Value); } cmd.Parameters.AddWithValue("@id", projectId); affected = cmd.ExecuteNonQuery(); } } } // 项目信息主存储同步到报价库的「项目信息」表 UpdateProjectInfoToQuoteDb(projectId, values); return affected; } // ★ G1-3(2026-10-01):UpdateButton10State 已删——button10 是幽灵按钮(旧工具栏迁移遗留, // 功能区早已无此按钮,FindRibbonButton 永远命中 null 空转),修改项目统一走 button2。 // ============================================================ // tabControl1 ↔ tabControl2 同名标签页双向联动 // (两个 TabControl 的标签页 Text 互相对应:项目/报价/扒图/...) // ============================================================ /// tabControl2 切换 → 把 tabControl1 也切到同名页(按 Text 匹配)。 private void SyncTab1ToTab2ByText() { // 停靠面板版:顶部选项卡(菜单)与文档窗口不再互相同步,此方法保留为空。 Log("[TabSync] SyncTab1ToTab2ByText: 停靠版无需同步,跳过"); } /// tabControl1 切换 → 把 tabControl2 也切到同名页(按 Text 匹配)。 private void SyncTab2ToTab1ByText() { // 停靠面板版:顶部选项卡(菜单)与文档窗口不再互相同步,此方法保留为空。 Log("[TabSync] SyncTab2ToTab1ByText: 停靠版无需同步,跳过"); } private void ReloadProjectsFromDatabase() { string dbPath = GetDbFullPath(ProjectsDbPath); if (!System.IO.File.Exists(dbPath)) { return; } // 先从项目库索引读「项目编号 + 5 个汇总缓存列」 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); } } // 组装最终显示表(13 列,列名与 ui.cs 的 DataPropertyName 严格一致) 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)); // 对每个项目:8 个信息列从报价库读,5 个汇总列从索引读 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, "备注"); // 5 个汇总缓存列从项目库索引读 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); } // ★ 排障:打印读到的汇总值(定位"项目页利润/利润率显示 0/空"是读库问题还是显示问题) try { foreach (DataRow dr in table.Rows) Log("[项目页] 绑定: 项目=[" + dr["项目编号"] + "] 利润=[" + dr["利润"] + "] 利润率=[" + dr["利润率"] + "] 报出总价=[" + dr["报出总价"] + "]"); } catch { } this.advancedDataGridView6.AutoGenerateColumns = false; // 老库兼容:公式列从 string 转为 double,DefaultCellStyle.Format 才能生效 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 ?? "") : ""; } // ============================================================ // 项目表搜索(button28) // - comboBox2 选择搜索条件(项目名称/项目编号/客户名称) // - textBox1 输入关键词 // - 通过 DataTable.DefaultView.RowFilter 筛选 advancedDataGridView6 // 只显示匹配的行;关键词为空则恢复显示全部 // ============================================================ private void button28_Click(object sender, EventArgs e) { // 1) 取搜索条件(comboBox2 支持手输,优先用 Text) string field = this.comboBox2.Text == null ? "" : this.comboBox2.Text.Trim(); // 兼容用户手输的近似写法,统一映射到 DataTable 列名 string colName = MapSearchFieldToColumn(field); if (string.IsNullOrEmpty(colName)) { MessageBox.Show(this, "请先在搜索条件下拉框中选择有效的搜索字段(项目名称 / 项目编号 / 客户名称)。", "查找", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 2) 取搜索关键词 string keyword = this.textBox1.Text == null ? "" : this.textBox1.Text.Trim(); // 3) 取当前 DataSource(DataTable),没有数据直接提示 DataTable table = this.advancedDataGridView6.DataSource as DataTable; if (table == null) { MessageBox.Show(this, "项目表暂无数据,无法搜索。", "查找", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 4) 关键词为空:清除筛选,恢复显示全部 if (keyword.Length == 0) { if (table.DefaultView.RowFilter.Length > 0) { table.DefaultView.RowFilter = ""; } return; } // 5) 转义 LIKE 中的特殊字符,避免用户输入 % _ ' [ 等导致语法错或误匹配 string escaped = EscapeLikeValue(keyword); // 6) 应用 RowFilter(不区分大小写的包含匹配) try { table.DefaultView.RowFilter = "[" + colName + "] LIKE '%" + escaped + "%'"; } catch (System.Data.SyntaxErrorException ex) { MessageBox.Show(this, "搜索条件解析失败,请检查输入的关键词。\n详情:" + ex.Message, "查找", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // 7) 没有匹配行时给个提示(筛选已经生效,只是结果为空) if (table.DefaultView.Count == 0) { MessageBox.Show(this, "没有找到 " + field + " 包含 “" + keyword + "” 的项目。", "查找", MessageBoxButtons.OK, MessageBoxIcon.Information); } } /// /// 把 comboBox2 的文本映射到 advancedDataGridView6 数据源的列名。 /// 支持简写/全称/手输容错。 /// 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 ""; } /// /// 转义 DataView RowFilter 中 LIKE 模式的特殊字符。 /// 用户输入的 % _ ' [ 会被当成普通字符处理。 /// private static string EscapeLikeValue(string value) { if (string.IsNullOrEmpty(value)) return ""; // 1) 单引号 → 两个单引号 // 2) % _ [ ] 用方括号包裹转义(LIKE 模式专用转义方式) 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(); } // ============================================================ // 删除项目(button3)——保留原有功能,并级联删除报价库里的子数据 // ============================================================ private void button3_Click(object sender, EventArgs e) { // ★ 支持多选删除:收集所有选中的项目行 DataGridView dgv = this.advancedDataGridView6; if (dgv == null || dgv.Rows == null || dgv.Rows.Count == 0) { MessageBox.Show(this, "项目表格为空,没有可删除的项目。", "删除项目", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 收集选中的项目编号(从 SelectedRows + CurrentRow 去重) var projectIds = new System.Collections.Generic.List(); var seenIndex = new System.Collections.Generic.HashSet(); foreach (DataGridViewRow r in dgv.SelectedRows) { if (r == null || r.IsNewRow) continue; if (seenIndex.Contains(r.Index)) continue; seenIndex.Add(r.Index); string pid = GetCellValueAsString(r, "项目编号"); if (!string.IsNullOrWhiteSpace(pid) && !projectIds.Contains(pid)) projectIds.Add(pid); } // 如果没选中行,尝试 CurrentRow if (projectIds.Count == 0 && dgv.CurrentRow != null && !dgv.CurrentRow.IsNewRow) { string pid = GetCellValueAsString(dgv.CurrentRow, "项目编号"); if (!string.IsNullOrWhiteSpace(pid)) projectIds.Add(pid); } if (projectIds.Count == 0) { MessageBox.Show(this, "请先在项目表格中选择要删除的项目(可按 Ctrl/Shift 多选)。", "删除项目", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 确认删除 string confirmMsg; if (projectIds.Count == 1) { confirmMsg = "确定要删除项目 [" + projectIds[0] + "] 吗?\n" + "该项目下的所有图号/箱号/元件数据也会一并删除!\n此操作不可恢复。"; } else { confirmMsg = "确定要删除选中的 " + projectIds.Count + " 个项目吗?\n" + "这些项目下的所有图号/箱号/元件数据也会一并删除!\n此操作不可恢复。\n\n" + "项目编号:\n" + string.Join("\n", projectIds.ToArray()); } DialogResult dr = MessageBox.Show(this, confirmMsg, "删除项目", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); if (dr != DialogResult.Yes) return; try { int okCount = 0; var 失败们 = new System.Collections.Generic.List(); foreach (string pid in projectIds) { try { // ★ 先删库文件(必须成功——失败即中止该项目,列表行保留: // 若行删了文件残留,同编号新建项目会"复活"旧数据), // 文件删掉后再删列表行 CascadeDeleteByProject(pid); int affected = DeleteProjectFromDatabase(pid); if (affected > 0) okCount++; } catch (Exception exOne) { Log("[删除项目] 单个删除失败 [" + pid + "]: " + exOne.Message); 失败们.Add(pid + ": " + exOne.Message); } } // 清空当前状态,刷新所有表格 _currentProjectId = ""; _currentTuhao = ""; _currentXianghao = ""; _xianghaoCache.Clear(); _yuanjianCache.Clear(); _tuhaoTable = null; ReloadProjectsFromDatabase(); ReloadTuhaoByProject(""); // ★ 删除失败必须让用户看见(文件没删干净=残留隐患),绝不静默 if (失败们.Count > 0) { MessageBox.Show(this, "以下项目的库文件删除失败(可能被占用,列表行已保留,可重试删除):\n" + string.Join("\n", 失败们.ToArray()), "删除项目", MessageBoxButtons.OK, MessageBoxIcon.Warning); } if (okCount > 0) { string doneMsg = (projectIds.Count == 1) ? "已删除项目:" + projectIds[0] : "已删除 " + okCount + " / " + projectIds.Count + " 个项目"; MessageBox.Show(this, doneMsg, "删除项目", MessageBoxButtons.OK, MessageBoxIcon.Information); } else if (失败们.Count == 0) { MessageBox.Show(this, "未能删除任何项目(数据库中可能已不存在)。", "删除项目", MessageBoxButtons.OK, MessageBoxIcon.Information); } } catch (Exception ex) { MessageBox.Show(this, "删除项目失败:\n" + ex.Message, "删除项目", MessageBoxButtons.OK, MessageBoxIcon.Error); } } 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) continue; if (r.IsNewRow) continue; return r; } if (dgv.CurrentRow != null && !dgv.CurrentRow.IsNewRow) { return dgv.CurrentRow; } return null; } 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 ""; } /// /// 从表格行的底层数据源(DataRowView)读取列值,不依赖表格列是否已绑定显示。 /// 用于读取"内部ID"等隐藏列(这些列在 DataTable 里有,但 DataGridView 没有对应显示列)。 /// private string GetBoundValueAsString(DataGridViewRow row, string colName) { if (row == null || string.IsNullOrEmpty(colName)) return ""; // ★ 虚拟模式:无 DataBoundItem,按行号从内存表取值 var table = 取dgv数据表(row.DataGridView); if (table != null && row.Index >= 0 && row.Index < table.Rows.Count && table.Columns.Contains(colName)) { object v = table.Rows[row.Index][colName]; return v == null || v == DBNull.Value ? "" : v.ToString().Trim(); } try { System.Data.DataRowView drv = row.DataBoundItem as System.Data.DataRowView; if (drv != null && drv.Row != null && drv.Row.Table != null && drv.Row.Table.Columns.Contains(colName)) { object v = drv.Row[colName]; return v == null || v == DBNull.Value ? "" : v.ToString().Trim(); } } catch { } return ""; } private int DeleteProjectFromDatabase(string projectId) { if (string.IsNullOrWhiteSpace(projectId)) { return 0; } string dbPath = GetDbFullPath(ProjectsDbPath); if (!System.IO.File.Exists(dbPath)) { throw new System.IO.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(); } } } // ============================================================ // 双击 advancedDataGridView6 跳转到报价页 // - tabControl1.tabPage2(顶部菜单栏“报价”) // - tabControl2.tabPage4(主区域报价页) // ============================================================ private void advancedDataGridView6_CellDoubleClick(object sender, DataGridViewCellEventArgs e) { Log("双击项目行: RowIndex=" + e.RowIndex + " (双击事件已触发)"); if (e.RowIndex < 0) return; // 取当前行的项目编号 DataGridViewRow row = this.advancedDataGridView6.Rows[e.RowIndex]; string pid = GetCellValueAsString(row, "项目编号"); Log(" 取到项目编号=[" + pid + "]"); SetStatus("打开项目: " + GetCellValueAsString(row, "项目名称")); if (string.IsNullOrWhiteSpace(pid)) { return; } // ★ 切项目前:先把当前项目的所有改动(图号表 + 缓存的箱号表/元件表)回写库, // 然后清空缓存。这步必须在改 _currentProjectId 之前完成。 // 不调 RecalcAllFromDatabase(避免覆盖刚保存的明细),只写库 + 清缓存。 if (!string.IsNullOrWhiteSpace(_currentProjectId) && _currentProjectId != pid) { Log(" 切换项目:从 [" + _currentProjectId + "] 切到 [" + pid + "],先保存上一个"); try { // 只存明细,不调 RecalcAllFromDatabase(它会在新项目上跑) 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 (Exception ex) { Log(" 切项目保存异常: " + ex.Message); } _xianghaoCache.Clear(); _yuanjianCache.Clear(); _tuhaoTable = null; } _currentProjectId = pid; // 进入项目核心流程(切到报价页 + 加载数据) EnterProjectCore(pid); } /// /// 进入项目核心流程:切换到报价页 + 加载该项目的报价数据。 /// 由"双击项目行"和"切到 tabPage2 时自动恢复最近项目"两个入口共用。 /// 调用前 _currentProjectId 必须已设为新项目编号,且上一个项目的缓存已被清理。 /// private void EnterProjectCore(string pid) { Log("[EnterProjectCore] 开始进入项目 [" + pid + "], _isTabSyncing=" + _isTabSyncing); // ★ 公式触发器自检:按公式表重建该项目库全部触发器(幂等;老库补装/公式变更生效) EnsureFormulaTriggersQuiet(pid); // 切换顶部功能区到"报价"页签 int 报价页idx = 功能区页索引("报价"); if (报价页idx >= 0) { this.ribbonStrip1.ActiveTabIndex = 报价页idx; _lastRibbonActiveIndex = 报价页idx; } Log("[EnterProjectCore] 已设功能区活动页=报价"); // 切换主区域到报价页(tabPage4) this.tabControl2.SelectedTab = this.tabPage4; Log("[EnterProjectCore] 已设 tabControl2.SelectedTab = tabPage4"); // 确保该项目的独立报价数据库存在(不存在则自动创建空库) EnsureQuoteDbExists(pid); Log(" 独立库路径=" + GetQuoteDbPath(pid)); // 加载该项目的价格显示格式化配置(从「项目信息」表读 4 个字段) LoadPriceFormatConfig(pid); // 进入项目前先全量重算一遍所有图号/箱号的派生列(直接写库), // 解决"只重算当前选中行,没点过的箱号/图号毛利永远是空"的问题。 RecalcAllFromDatabase(pid); // 联动刷新图号表(箱号/元件表会跟着图号表联动) ReloadTuhaoByProject(pid); // 报价表数据加载完后,设公式列的 Format + 转换值类型 ApplyPriceFormat(this.advancedDataGridView9, _单台小数位文本); ApplyPriceFormat(this.advancedDataGridView4, _单台小数位文本); ApplyPriceFormat(this.advancedDataGridView5, _单台小数位文本); // 项目表也重新应用(因为可能修改了配置) ApplyPriceFormat(this.advancedDataGridView6, _项目小数位文本); // 记录最近打开的项目,供下次启动/切到报价页时自动恢复 SaveLastProject(pid); } /// /// 自动恢复最近打开过的项目(从 shezhi\最近项目.config.xml 读)。 /// 触发场景:切到报价页但当前没有任何项目被打开。 /// 前提:项目必须仍存在于项目表(被删过的项目不会恢复)。 /// private void AutoEnterLastProject() { try { string lastPid = LoadLastProject(); if (string.IsNullOrWhiteSpace(lastPid)) { Log("[最近项目] 配置为空,跳过自动恢复"); return; } // 验证项目是否仍存在于项目表(可能已被删除) if (!ProjectExists(lastPid)) { Log("[最近项目] 项目 [" + lastPid + "] 已不存在,跳过自动恢复"); return; } Log("[最近项目] 自动恢复项目 [" + lastPid + "]"); _currentProjectId = lastPid; EnterProjectCore(lastPid); // 在项目表里高亮选中这一行(便于用户看到当前是哪个项目) RestoreProjectSelection(lastPid); } catch (Exception ex) { Log("[最近项目] 自动恢复异常: " + ex.Message); } } /// 检查项目编号是否存在于项目表(advancedDataGridView6Table)。 private bool ProjectExists(string pid) { if (string.IsNullOrWhiteSpace(pid)) return false; try { string dbPath = GetDbFullPath(ProjectsDbPath); if (!System.IO.File.Exists(dbPath)) return false; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "SELECT COUNT(*) FROM " + ProjectsTableName + " WHERE 项目编号 = @pid;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@pid", pid); object v = cmd.ExecuteScalar(); int n; return v != null && int.TryParse(v.ToString(), out n) && n > 0; } } } catch (Exception ex) { Log("[最近项目] ProjectExists 检查异常: " + ex.Message); return false; } } // ============================================================ // 联动刷新:按项目编号加载图号(advancedDataGridView9) // ============================================================ /// 打开当前项目的独立报价库,加载图号数据,刷新 advancedDataGridView9。 private void ReloadTuhaoByProject(string projectId) { _swReload.Restart(); // 注意:不在开头检查 _isReloading —— 因为新建/删除图号的处理器会在 // _isReloading=true 状态下调用本方法,如果 return 掉图号表就不刷新了。 // 本方法自己管理 guard(下面设 true,finally 设回原值),靠外层状态记忆恢复。 // // ★ 本方法不做"切项目前的保存"——保存会擦掉刚 INSERT/DELETE 的新行/删行。 // 切项目的保存在双击项目(advancedDataGridView6_CellDoubleClick)里单独处理。 // 新建/删除图号后调用本方法时,不应该保存(此时内存图号表是旧的,会覆盖库)。 bool outerGuard = _isReloading; _isReloading = true; try { // 无项目选中时清空 if (string.IsNullOrWhiteSpace(projectId)) { this.advancedDataGridView9.RowCount = 0; _tuhaoTable = null; _currentTuhao = ""; ReloadXianghaoByTuhao("", ""); return; } string dbPath = GetQuoteDbPath(projectId); if (!System.IO.File.Exists(dbPath)) { this.advancedDataGridView9.RowCount = 0; _tuhaoTable = null; _currentTuhao = ""; ReloadXianghaoByTuhao("", ""); return; } // 序号是TEXT类型,字符串排序会让"10"排在"2"前面,所以用CAST按数字排序 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); } } } // ★ 图号表也必须记录真实表名:直写队列用 TableName 生成 UPDATE, // 为空会把图号派生列(.总价/.利润等)静默丢弃,项目页只能读到旧汇总。 table.TableName = TuhaoTableName; // 老库兼容:公式列从 TEXT(string) 转为 REAL(double),DefaultCellStyle.Format 才能生效 table = ConvertFormulaColumnsToDouble(table); // ★ 先换内存表引用再设 RowCount:供数通道(CellValueNeeded)读的是 _tuhaoTable, // 顺序反了会让新行数配旧表数据;行数相同时 RowCount 是 no-op,必须 Invalidate 强制重绘重新供数 _tuhaoTable = table; // 记下当前表引用,供关闭/切换时回写 this.advancedDataGridView9.RowCount = table.Rows.Count; this.advancedDataGridView9.Invalidate(); Log("[性能] ReloadTuhao 耗时 " + _swReload.ElapsedMilliseconds + "ms"); // ★ DataSource 绑定后 DefaultCellStyle.Format/ValueType 会被重置,必须重新应用格式化 ApplyPriceFormat(this.advancedDataGridView9, _单台小数位文本); // 联动刷新箱号表:选中第一个图号(如果有) _currentTuhao = ""; if (this.advancedDataGridView9.Rows.Count > 0) { // 用 SetCurrentCell 完整选中第一行(清空+选中+设光标,避免横跳) SetCurrentCell(this.advancedDataGridView9, 0); // ★ 从"内部ID"列读取(不是"图号"列),用户改图号名不影响内部ID _currentTuhao = GetBoundValueAsString( this.advancedDataGridView9.Rows[0], "内部ID"); } Log(" ReloadTuhao 完成: 行数=" + this.advancedDataGridView9.Rows.Count + " _currentTuhao=[" + _currentTuhao + "]"); // ★ 诊断:记录每行的图号列值,排查"图号列变空" if (_tuhaoTable != null && _tuhaoTable.Columns.Contains("图号")) { for (int i = 0; i < _tuhaoTable.Rows.Count; i++) { object v = _tuhaoTable.Rows[i]["图号"]; Log(" 图号表行" + i + ": 序号=[" + _tuhaoTable.Rows[i]["序号"] + "] 图号=[" + (v == null || v == DBNull.Value ? "" : v.ToString()) + "]"); } } ReloadXianghaoByTuhao(_currentProjectId, _currentTuhao); } finally { _isReloading = outerGuard; // 恢复外层 guard 状态 } } // ============================================================ // 联动刷新:按 项目编号+图号 加载箱号(advancedDataGridView4) // ============================================================ private void ReloadXianghaoByTuhao(string projectId, string tuhao) { _swReload.Restart(); // 本方法全程包 _isReloading,防止换内容时的 ClearSelection/重绑 触发横跳。 // (无论从 ReloadTuhaoByProject 联动调,还是从 SelectionChanged 直接调,都受保护) bool outerGuard = _isReloading; // 记住外层是否已设 guard _isReloading = true; try { // 切换图号前,只提交当前箱号表的编辑到内存(不写库,留给保存按钮/离页触发) if (this.advancedDataGridView4 != null) this.advancedDataGridView4.EndEdit(); // 无项目或无图号选中时清空 if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao) || !System.IO.File.Exists(GetQuoteDbPath(projectId))) { this.advancedDataGridView4.RowCount = 0; _xianghaoTable = null; _currentXianghao = ""; ReloadYuanjianByXianghao("", "", ""); return; } string dbPath = GetQuoteDbPath(projectId); // 查索引表拿该图号的箱号表真实表名;没有就生成ID建表+登记索引 string realTbl = GetXianghaoTableByIndex(projectId, tuhao); if (string.IsNullOrEmpty(realTbl)) { realTbl = GetNextTableId(projectId, "xh"); CreateXianghaoTable(projectId, realTbl); AddTableIndex(projectId, "箱号", tuhao, "", realTbl); } // 优先用内存缓存(切图号/切箱号不丢编辑,点保存才写库) DataTable table; if (_xianghaoCache.ContainsKey(tuhao)) { table = _xianghaoCache[tuhao]; } else { // 缓存未命中:从库读一次,以后切回该图号直接用缓存 // 序号是TEXT类型,字符串排序会让"10"排在"2"前面,所以用CAST按数字排序 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(); // 老库结构兼容:老项目的箱号表可能没有"箱名"列,读取前幂等补列, // 保证 DataTable 里有该列(表格绑定与保存的全列 INSERT 都依赖它) 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); } } } // 老库兼容:公式列从 TEXT(string) 转为 REAL(double),DefaultCellStyle.Format 才能生效 table = ConvertFormulaColumnsToDouble(table); table.TableName = realTbl; // 记住自己属于哪张表,保存时用它 _xianghaoCache[tuhao] = table; } // ★ 先换内存表引用再设 RowCount:供数通道(CellValueNeeded)读的是 _xianghaoTable, // 顺序反了会让新行数配旧表数据;行数相同时(如副本间切换,都是7行) RowCount 是 no-op, // 必须 Invalidate 强制重绘重新供数,否则箱号表显示的还是上一个图号的数据 _xianghaoTable = table; // 记下当前表引用,供切换/关闭时回写 this.advancedDataGridView4.RowCount = table.Rows.Count; this.advancedDataGridView4.Invalidate(); Log("[性能] ReloadXianghao 耗时 " + _swReload.ElapsedMilliseconds + "ms"); // ★ DataSource 绑定后 DefaultCellStyle.Format/ValueType 会被重置,必须重新应用格式化 ApplyPriceFormat(this.advancedDataGridView4, _单台小数位文本); // 联动刷新元件表:选中第一个箱号(如果有) _currentXianghao = ""; if (this.advancedDataGridView4.Rows.Count > 0) { // 用 SetCurrentCell 完整选中第一行(清空+选中+设光标三步, // 保证 SelectedRows 和 CurrentCell 状态一致,避免横跳) SetCurrentCell(this.advancedDataGridView4, 0); // ★ 从"内部ID"列读取(不是"箱号"列),用户改箱号名不影响内部ID _currentXianghao = GetBoundValueAsString( this.advancedDataGridView4.Rows[0], "内部ID"); } Log(" ReloadXianghao 完成: projectId=[" + projectId + "] 图号=[" + tuhao + "] 行数=" + this.advancedDataGridView4.Rows.Count + " _currentXianghao=[" + _currentXianghao + "]"); ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, _currentXianghao); } finally { _isReloading = outerGuard; // 恢复外层 guard 状态 } } // ============================================================ // 联动刷新:按 项目编号+图号+箱号 加载元件(advancedDataGridView5) // ============================================================ private void ReloadYuanjianByXianghao(string projectId, string tuhao, string xianghao) { _swReload.Restart(); // 切换箱号前,只提交当前元件表的编辑到内存(不写库,留给保存按钮/离页触发) if (this.advancedDataGridView5 != null) this.advancedDataGridView5.EndEdit(); // 无项目/图号/箱号时清空 if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(tuhao) || string.IsNullOrWhiteSpace(xianghao) || !System.IO.File.Exists(GetQuoteDbPath(projectId))) { this.advancedDataGridView5.RowCount = 0; _yuanjianTable = null; return; } // 查索引表拿该箱号的元件表真实表名;没有就空表(还没新建过元件) string realTbl = GetYuanjianTableByIndex(projectId, tuhao, xianghao); string dbPath = GetQuoteDbPath(projectId); // 优先用内存缓存(切图号/切箱号不丢编辑,点保存才写库) string cacheKey = tuhao + "\n" + xianghao; DataTable table; if (_yuanjianCache.ContainsKey(cacheKey)) { table = _yuanjianCache[cacheKey]; } else { table = new DataTable(); if (!string.IsNullOrEmpty(realTbl)) { // 序号是TEXT类型,字符串排序会让"10"排在"2"前面,所以用CAST按数字排序 // ★ 费用行置底(2026-10-01):类型='费用'的行永远排在普通元件之后,不受序号影响 string sql = "SELECT rowid AS \"_rowid_\", * FROM \"" + realTbl + "\" ORDER BY (IFNULL(类型,'')='费用'), CAST(序号 AS INTEGER);"; 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); } } } // 老库兼容:公式列从 TEXT(string) 转为 REAL(double),DefaultCellStyle.Format 才能生效 table = ConvertFormulaColumnsToDouble(table); } table.TableName = realTbl; // 记住自己属于哪张表(可能为空,表示无元件表) _yuanjianCache[cacheKey] = table; } _yuanjianTable = table; // 记下当前表引用,供切换/关闭时回写 this.advancedDataGridView5.RowCount = table.Rows.Count; this.advancedDataGridView5.Invalidate(); // ★ 虚拟模式:Format 只需设置一次;数值化输出在 CellValueNeeded 读通道处理 ApplyPriceFormat(this.advancedDataGridView5, _单台小数位文本); Log("[性能] ReloadYuanjian 耗时 " + _swReload.ElapsedMilliseconds + "ms (含取缓存/查库/转换)"); } // ============================================================ // ★ 多选联动(2026-09-30):图号多选 → 箱号表显示多图号箱号合集; // 箱号多选 → 元件表显示多箱号元件合集。 // 合并表 TableName 置空(防整表保存误覆盖)+ 每行带 _来源表名_/_来源图号_ // 内部列;编辑回写由直写队列按行级来源表名定位(入队直写库 已适配)。 // ============================================================ /// 多图号合并加载箱号(图号表多选时调用):按图号顺序拼接各图号的箱号表。 private void ReloadXianghaoByTuhaos(string projectId, System.Collections.Generic.List tuhaos) { _swReload.Restart(); bool outerGuard = _isReloading; _isReloading = true; try { if (this.advancedDataGridView4 != null) this.advancedDataGridView4.EndEdit(); if (string.IsNullOrWhiteSpace(projectId) || tuhaos == null || tuhaos.Count == 0 || !System.IO.File.Exists(GetQuoteDbPath(projectId))) { this.advancedDataGridView4.RowCount = 0; _xianghaoTable = null; _currentXianghao = ""; ReloadYuanjianByXianghao("", "", ""); return; } string dbPath = GetQuoteDbPath(projectId); DataTable merged = null; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); foreach (string th in tuhaos) { if (string.IsNullOrWhiteSpace(th)) continue; // 多选查看语义:查不到箱号表的图号直接跳过(不像单选版那样自动建表) string realTbl = LookupTableByIndex("箱号", th, "", conn); if (string.IsNullOrEmpty(realTbl)) continue; EnsureColumns(conn, "\"" + realTbl + "\"", new[] { "箱名", "内部ID" }); var one = new DataTable(); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT rowid AS \"_rowid_\", * FROM \"" + realTbl + "\" ORDER BY CAST(序号 AS INTEGER);", conn)) using (var ad = new System.Data.SQLite.SQLiteDataAdapter(cmd)) { ad.Fill(one); } if (merged == null) { merged = one.Clone(); merged.Columns.Add("_来源表名_", typeof(string)); merged.Columns.Add("_来源图号_", typeof(string)); } else { // ★ 列并集(2026-10-01):后面的表可能带前面表没有的列(如扒图建的表带 // "框Handle/文本Handle"),模板缺列时 NewRow 赋值会抛"列'X'不属于表"—— // 补齐缺列即可,已有行该列自动为 DBNull。 foreach (DataColumn c in one.Columns) if (!merged.Columns.Contains(c.ColumnName)) merged.Columns.Add(c.ColumnName, c.DataType); } foreach (DataRow r in one.Rows) { var nr = merged.NewRow(); foreach (DataColumn c in one.Columns) nr[c.ColumnName] = r[c.ColumnName]; nr["_来源表名_"] = realTbl; nr["_来源图号_"] = th; merged.Rows.Add(nr); } } } if (merged == null) merged = new DataTable(); merged = ConvertFormulaColumnsToDouble(merged); merged.TableName = ""; // ★ 合并表:禁止整表保存(编辑走直写队列行级回写) _xianghaoTable = merged; this.advancedDataGridView4.RowCount = merged.Rows.Count; this.advancedDataGridView4.Invalidate(); ApplyPriceFormat(this.advancedDataGridView4, _单台小数位文本); // 选中第一行 → 元件联动(按第一行的来源图号 + 内部ID) _currentXianghao = ""; if (merged.Rows.Count > 0) { SetCurrentCell(this.advancedDataGridView4, 0); _currentXianghao = Convert.ToString(merged.Rows[0]["内部ID"]); string th0 = Convert.ToString(merged.Rows[0]["_来源图号_"]); ReloadYuanjianByXianghao(projectId, th0, _currentXianghao); } else { ReloadYuanjianByXianghao("", "", ""); } Log("[多选联动] 箱号合并视图: 图号数=" + tuhaos.Count + " 箱号行数=" + merged.Rows.Count + " 耗时 " + _swReload.ElapsedMilliseconds + "ms"); } finally { _isReloading = outerGuard; } } /// 多箱号合并加载元件(箱号表多选时调用):pairs = [来源图号, 箱号内部ID, 箱号显示名]。 /// ★ 合并视图序号列显示"箱号-N"(如 AH4-1),用户可分辨每行归属。 private void ReloadYuanjianByXianghaos(string projectId, System.Collections.Generic.List pairs) { _swReload.Restart(); bool outerGuard = _isReloading; _isReloading = true; try { if (this.advancedDataGridView5 != null) this.advancedDataGridView5.EndEdit(); if (string.IsNullOrWhiteSpace(projectId) || pairs == null || pairs.Count == 0 || !System.IO.File.Exists(GetQuoteDbPath(projectId))) { this.advancedDataGridView5.RowCount = 0; _yuanjianTable = null; return; } string dbPath = GetQuoteDbPath(projectId); DataTable merged = null; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); foreach (var kv in pairs) { string thSrc = kv[0], xhId = kv.Length > 1 ? kv[1] : "", xhName = kv.Length > 2 ? kv[2] : ""; if (string.IsNullOrWhiteSpace(xhId)) continue; // 双键兼容(内部ID GUID 优先,老数据显示名兜底) string realTbl = GetYuanjianTableByIndex(thSrc, xhId, conn); if (string.IsNullOrEmpty(realTbl)) continue; // ★ 费用行置底配套:老元件表可能没有"类型"列,幂等补列 EnsureColumns(conn, "\"" + realTbl + "\"", new[] { "类型" }); var one = new DataTable(); using (var cmd = new System.Data.SQLite.SQLiteCommand( // ★ 费用行置底(2026-10-01):每箱号段内费用行排段尾 "SELECT rowid AS \"_rowid_\", * FROM \"" + realTbl + "\" ORDER BY (IFNULL(类型,'')='费用'), CAST(序号 AS INTEGER);", conn)) using (var ad = new System.Data.SQLite.SQLiteDataAdapter(cmd)) { ad.Fill(one); } if (merged == null) { merged = one.Clone(); merged.Columns.Add("_来源表名_", typeof(string)); merged.Columns.Add("_归属箱号_", typeof(string)); merged.Columns.Add("_段内序号_", typeof(int)); } else { // ★ 列并集(2026-10-01):后面的表可能带前面表没有的列(如扒图建的表带 // "框Handle/文本Handle"),模板缺列时 NewRow 赋值会抛"列'X'不属于表"—— // 补齐缺列即可,已有行该列自动为 DBNull。 foreach (DataColumn c in one.Columns) if (!merged.Columns.Contains(c.ColumnName)) merged.Columns.Add(c.ColumnName, c.DataType); } int seqInXh = 0; foreach (DataRow r in one.Rows) { seqInXh++; var nr = merged.NewRow(); foreach (DataColumn c in one.Columns) nr[c.ColumnName] = r[c.ColumnName]; nr["_来源表名_"] = realTbl; nr["_归属箱号_"] = xhName; nr["_段内序号_"] = seqInXh; merged.Rows.Add(nr); } } } if (merged == null) merged = new DataTable(); merged = ConvertFormulaColumnsToDouble(merged); merged.TableName = ""; // ★ 合并表:禁止整表保存(编辑走直写队列行级回写) _yuanjianTable = merged; this.advancedDataGridView5.RowCount = merged.Rows.Count; this.advancedDataGridView5.Invalidate(); ApplyPriceFormat(this.advancedDataGridView5, _单台小数位文本); Log("[多选联动] 元件合并视图: 箱号数=" + pairs.Count + " 元件行数=" + merged.Rows.Count + " 耗时 " + _swReload.ElapsedMilliseconds + "ms"); } finally { _isReloading = outerGuard; } } // ============================================================ // 数据库回写:把内存 DataTable 的改动写回当前项目的独立报价库 // ============================================================ /// /// 把内存 DataTable 的改动回写到指定项目的独立报价库中的指定表。 /// 由于 SQLite 表没有 PRIMARY KEY,SQLiteCommandBuilder 无法生成 UPDATE/DELETE, /// 这里采用"按筛选范围删除旧行 + 重新插入内存所有行"的方式,简单可靠。 /// /// true=无条件保存所有内存行(用于新建/删除前确保编辑不丢失); /// false=只在有改动时保存(用于 Reload 开头,避免用旧内存覆盖刚 Insert 的新行)。 /// 删除时的 WHERE 条件(不含 WHERE 关键字);为空则清空整表。 /// WHERE 条件对应的参数字典;无参数传 null。 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 (!System.IO.File.Exists(dbPath)) return; // 必须先结束当前单元格的编辑,否则最后一笔编辑还没提交到 DataTable 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(); } // ★ 保存策略: // force=true(新建/删除前调用):无条件保存内存所有行,保证用户编辑不丢失。 // 因为之前某次 AcceptChanges 可能已把改动标记为干净,GetChanges 会返回 null, // 但实际数据(如表价)还没存库,导致数据丢失。 // force=false(Reload 开头调用):只在有改动时保存,避免用不含新行的旧内存表 // 覆盖刚 Insert 的新行。 // 两种模式都要求内存表有行才保存(空表不保存,避免覆盖)。 if (table.Rows.Count == 0) return; if (!force && table.GetChanges() == null) return; // 诊断日志:记录保存时的删除范围和内存行数,排查数据丢失/重复 Log("SaveTableToDatabase: table=" + tableName + " 内存行数=" + table.Rows.Count + " force=" + force + " whereClause=" + (whereClause ?? "(整表)") + " _currentTuhao=[" + _currentTuhao + "] _currentXianghao=[" + _currentXianghao + "]"); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // ★ 性能:整个 DELETE+INSERT 序列包进一个事务。 // SQLite 默认每条 INSERT 自动提交(每次磁盘同步),N 行保存 = N 次磁盘 sync, // 比单事务批量慢 10-100 倍。包事务后只在 Commit 时同步一次。 using (var tx = conn.BeginTransaction()) { // 1) 删除旧数据(按筛选范围) 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); } } int delRows = delCmd.ExecuteNonQuery(); Log(" 删除影响行数=" + delRows); } // 2) 把内存中所有行重新插入 if (table.Rows.Count > 0) { // 收集要插入的列名(只取表里实际存在的列) var colNames = new List(table.Columns.Count); foreach (DataColumn c in table.Columns) { // ★ 必须跳过 "_rowid_":它是 SELECT rowid AS "_rowid_" 带来的行定位别名, // 并不是表里的真实业务列。若把它拼进 INSERT,新行该值为空 → ToString 成 "" → // 给 rowid 赋空字符串,SQLite 无法把 '' 转成整数,直接抛 "datatype mismatch"。 // rowid 应由 SQLite 在 INSERT 时自动分配。 if (c.ColumnName == "_rowid_") continue; colNames.Add(c.ColumnName); } string insertSql = "INSERT INTO " + tableName + " (" + string.Join(", ", colNames.ToArray()) + ") VALUES (" + string.Join(", ", ConvertAllToParams(colNames)) + ");"; // ★ 性能:INSERT 命令在循环外创建并 Prepare 一次,循环内只改参数值。 // 原来每行都 new SQLiteCommand,频繁分配对象 + 解析 SQL。 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 { /* Prepare 失败不影响正确性 */ } int insRowCount = 0; 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(); insRowCount++; // ★ 诊断:记录每行的关键列值(图号/箱号/序号),排查"图号列保存后变空" if (table.Columns.Contains("图号")) Log(" INSERT行" + insRowCount + ": 序号=[" + row["序号"] + "] 图号=[" + row["图号"] + "] RowState=" + row.RowState); } Log(" INSERT完成,共插入" + insRowCount + "行"); } } tx.Commit(); } // ★ 整表重建(DELETE 全表 + 按内存顺序 INSERT,且不再显式写 rowid)后, // SQLite 自动分配的 rowid 恰好是 1..N(与插入顺序一致)。这里把内存 "_rowid_" // 按同样顺序回填,使内存行定位值与库完全一致: // - 旧行直写库(UPDATE ... WHERE rowid=@rid)不会因为前面插过新行而错位; // - 新行也立即获得 rowid(之前新行 _rowid_ 为空,直写库会跳过,只能靠下次 Reload)。 // 仅整表保存(whereClause 为空)时 rowid 才连续从 1 开始;带 WHERE 的部分保存不回填。 if (string.IsNullOrWhiteSpace(whereClause) && table.Columns.Contains("_rowid_")) { long 新rid = 0; foreach (DataRow r in table.Rows) { if (r.RowState == DataRowState.Deleted) continue; 新rid++; r["_rowid_"] = 新rid; } } table.AcceptChanges(); } } /// 把列名列表转成参数名列表(前面加 @)。 private static List ConvertAllToParams(List colNames) { var result = new List(colNames.Count); foreach (string c in colNames) { result.Add("@" + c); } return result; } /// 把当前图号表的改动回写当前项目的库(整表替换)。 private void SaveTuhaoTableIfDirty() { SaveTableToDatabase(_tuhaoTable, _currentProjectId, TuhaoTableName, null, null); } /// /// 把当前箱号表的改动回写数据库。 /// 用内存 DataTable.TableName 记录的真实表名保存(不再用易抖动的 _currentTuhao)。 /// private void SaveXianghaoTableIfDirty() { if (_xianghaoTable == null || string.IsNullOrWhiteSpace(_xianghaoTable.TableName)) return; SaveTableToDatabase(_xianghaoTable, _currentProjectId, "\"" + _xianghaoTable.TableName + "\"", null, null); } /// /// 把当前元件表的改动回写数据库。 /// 用内存 DataTable.TableName 记录的真实表名保存(不再用易抖动的 _currentXianghao)。 /// private void SaveYuanjianTableIfDirty() { if (_yuanjianTable == null || string.IsNullOrWhiteSpace(_yuanjianTable.TableName)) return; SaveTableToDatabase(_yuanjianTable, _currentProjectId, "\"" + _yuanjianTable.TableName + "\"", null, null); } /// /// 统一保存当前项目的三个报价表(元件 → 箱号 → 图号,自底向上)。 /// 在新建/删除任何报价数据前调用,确保用户之前的编辑都立即持久化。 /// /// /// true(默认,历史行为):无条件全表覆盖,即使 GetChanges 返回 null 也写, /// 防止"AcceptChanges 已标记为干净但实际没写盘"导致表价丢失。 /// false(性能优化用):仅当 GetChanges != null 才写。 /// 操作前的预保存用 false——若用户没编辑就没必要全表覆盖; /// 后续 RecalcAllAfterReload 结尾会用 force 行为再存一次重算结果。 /// private void SaveAllQuoteTablesIfDirty(bool force = true) { // ★ 直写库架构退役:编辑已实时 UPDATE 库(触发器自动计算),无"未保存"数据。 // 仅保留 EndEdit——把正在编辑的单元格提交进 CellValuePushed(排队直写库)。 // (旧的"元件→箱号→图号三层回写"保存链已随架构退役删除) 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(); } catch { // 保存失败不阻断新建/删除操作 } } /// /// 把当前项目的所有改动(图号表 + 所有缓存的箱号表/元件表)统一写库,并重算项目汇总。 /// 这是「保存按钮」和「离开报价页触发保存」共用的入口,出错会弹 MessageBox。 /// private void SaveAllToDatabase() { try { SaveAllToDatabaseSilent(); } catch (Exception ex) { Log("SaveAllToDatabase 异常: " + ex.Message); MessageBox.Show(this, "保存失败:\n" + ex.Message, "保存", MessageBoxButtons.OK, MessageBoxIcon.Error); } } /// /// 同上,但出错不弹窗(用于切项目/关窗等不该弹窗的场景)。 /// 保存内容:① 当前图号表 ② 所有缓存的箱号表 ③ 所有缓存的元件表 ④ 重算项目汇总并写库。 /// 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 { 计算引擎.刷新项目汇总(GetQuoteDbPath(_currentProjectId), _currentProjectId, TuhaoTableName); } catch (Exception exSummary) { Log("刷新项目汇总异常: " + exSummary.Message); } // (旧的"存三表+缓存+全量重算+刷列表"保存链已随直写库架构退役删除) } /// 窗体关闭时:把当前项目的所有改动(含缓存)统一回写数据库。 private void 主窗口_FormClosing(object sender, FormClosingEventArgs e) { Log("[关窗] FormClosing 开始 CloseReason=" + e.CloseReason); // ★ 停掉由本程序拉起的会员服务 node 进程(用户自己开的不动) try { 会员服务Host.Shutdown(); } catch { } // ★ 停掉 AI助手本地接口(HttpListener) try { 停止AI助手接口(); } catch { } // ★ AI引擎随软件退出而停止(静默脚本,未运行则跳过;启动器场景有实例守卫不会双停) try { OpenClawHost.停止引擎(); } catch { } // ★ 先停掉 WebView 页面锁定轮询定时器: // 关窗瞬间 Tick 若再触发 ExecuteScriptAsync,await 恢复时 WebView 已销毁, // 会抛异常产生"闪一下就消失"的弹窗。 try { if (_webViewLockTimer != null) { _webViewLockTimer.Stop(); _webViewLockTimer.Dispose(); _webViewLockTimer = null; Log("[关窗] WebView 轮询定时器已停止"); } } catch { } try { // 用统一保存方法(连缓存一起存),出错不弹窗(关窗不该弹) SaveAllToDatabaseSilent(); Log("[关窗] 保存完成"); } catch (Exception exSave) { // 关闭时的保存失败不阻断退出 Log("[关窗] 保存异常: " + exSave.Message); } try { // ★ 直写队列强制落库:SaveAllToDatabaseSilent 的 EndEdit 只是把正在编辑的 // 单元格推进 300ms 防抖队列,窗体关闭后 Timer 不再触发,残留编辑会永久 // 丢失(丢数据主因)。这里同步等待队列全部写进 SQLite。 直写队列强制落库(); Log("[关窗] 直写队列已落库"); } catch (Exception exFlush) { Log("[关窗] 直写队列落库异常: " + exFlush.Message); } try { // 关窗时兜底保存铜排参数(加工费等),防止 Leave 未触发(如直接点关闭) Save铜排参数(); Log("[关窗] 铜排参数保存完成"); } catch (Exception exTong) { // 关闭时的保存失败不阻断退出 Log("[关窗] 铜排参数异常: " + exTong.Message); } // ★ 根治关窗弹窗:数据都保存完后,解绑数据源并预先释放所有 ADGV 表格。 // .NET Framework 4.8 的 DataGridView.Dispose→Columns.Clear() 内部有索引越界 // 缺陷(列集合被筛选/排序动过之后),随 Form.Dispose 触发时会抛 // ArgumentOutOfRangeException → WinForms 全局异常弹窗闪现。 // 趁窗体还活着时提前解绑 DataSource + Clear 列,Dispose 时列集合已干净,不再触发。 try { DetachAllGridsForDispose(); Log("[关窗] ADGV 预释放完成"); } catch (Exception exGrid) { Log("[关窗] ADGV 预释放异常(忽略): " + exGrid.Message); } // ★ 显式释放 WebView2 控件:让浏览器进程(msedgewebview2)随窗口正常退出, // 不依赖进程结束时的隐式清理;StopProxyServer(另一订阅)会再兜底杀残留 try { if (this.webView21 != null) { this.webView21.Dispose(); Log("[关窗] WebView2 已释放"); } } catch (Exception exWv) { Log("[关窗] WebView2 释放异常(忽略): " + exWv.Message); } Log("[关窗] FormClosing 结束"); } /// /// 关窗时预处理所有增强表格(ADGV):解绑数据源 + 清列, /// 让后续 Form.Dispose 走到 Columns.Clear() 时集合是空的, /// 绕开 .NET 4.8 DataGridView.Dispose 的索引越界缺陷。 /// 单个表格失败不影响其他表格,全部吞异常(此时数据已保存完)。 /// private void DetachAllGridsForDispose() { var grids = new Smart.CustomComponents.EnhancedDataGridView[] { this.advancedDataGridView6, // 项目表 this.advancedDataGridView9, // 图号表 this.advancedDataGridView4, // 箱号表 this.advancedDataGridView5, // 元件表 this.advancedDataGridView1, // 选型汇总 this.advancedDataGridView12, // 箱号明细(选型页) this.advancedDataGridView7, // 用户库 this.advancedDataGridView10, // 柜体库 this.advancedDataGridView8, // 铜排规格 this.advancedDataGridView图号, // 方案库图号 this.advancedDataGridView箱号, // 方案库箱号 this.advancedDataGridView元件, // 方案库元件 }; foreach (var g in grids) { try { if (g == null) continue; g.EndEdit(); g.DataSource = null; // 解绑数据源(断开与内存 DataTable 的关联) g.Columns.Clear(); // 提前清列,Dispose 时集合已空 g.Dispose(); // 主动释放,不等 Form.Dispose } catch (Exception exG) { // 单个表格失败不影响退出 Log("[关窗] 预释放单表失败 " + (g == null ? "(null)" : g.Name) + ": " + exG.Message); } } // 内存表引用也断开(避免 Dispose 过程再被事件触碰) _tuhaoTable = null; _xianghaoTable = null; _yuanjianTable = null; _xianghaoCache.Clear(); _yuanjianCache.Clear(); } /// ★ G1-2(2026-10-01):功能区按钮点击统一入口(Designer 绑定)。 /// 原为空方法,真路由靠 MainForm 构造里手工二次绑定的 ribbonStrip1_ButtonClick—— /// 双处理器结构已废弃,现在直接转调统一路由。 void RibbonStrip1ButtonClick(object sender, RibbonButtonEventArgs e) { ribbonStrip1_ButtonClick(sender, e); } } }