Files
ctbjrj/成套报价软件/主窗口.cs
T

7257 lines
378 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 成套报价软件
{
/// <summary>
/// 主窗口 的交互逻辑(事件处理)。
/// UI 布局定义见 主窗口.ui.cs。
/// </summary>
public partial class MainForm
{
// ============================================================
// 数据库路径与表名常量
// ============================================================
/// <summary>项目数据库文件相对路径(与 主窗口.ui.cs 中保持一致)。</summary>
private const string ProjectsDbPath = @"sjk\advancedDataGridView6Table.db";
/// <summary>项目数据库表名(与 主窗口.ui.cs 中保持一致)。</summary>
private const string ProjectsTableName = "advancedDataGridView6Table";
/// <summary>报价数据库所在目录(每个项目一个独立 db 文件)。</summary>
private const string QuoteDbDir = @"sjk\bjb";
/// <summary>图号表名(对应 advancedDataGridView9)。</summary>
private const string TuhaoTableName = "advancedDataGridView9Table";
/// <summary>箱号表名(对应 advancedDataGridView4)。</summary>
private const string XianghaoTableName = "advancedDataGridView4Table";
/// <summary>元件表名(对应 advancedDataGridView5)。</summary>
private const string YuanjianTableName = "advancedDataGridView5Table";
/// <summary>报价库里的项目信息表名(主存储:8 个项目信息字段)。</summary>
private const string ProjectInfoTableName = "项目信息";
// ============================================================
// 当前层级选中状态(联动核心)
// ============================================================
/// <summary>当前选中的项目编号(advancedDataGridView6 的行)。</summary>
private string _currentProjectId = "";
/// <summary>当前选中的图号(advancedDataGridView9 的行)。</summary>
private string _currentTuhao = "";
/// <summary>当前选中的箱号(advancedDataGridView4 的行)。</summary>
private string _currentXianghao = "";
/// <summary>联动刷新的重入 guard,防止 SelectionChanged 互相触发导致死循环。</summary>
private bool _isReloading = false;
/// <summary>公式重算的重入 guard,防止重算改值时再次触发 CellValueChanged 形成死循环。</summary>
private bool _isRecalculating = false;
/// <summary>tabControl1/tabControl2 同名标签同步的 guard,防止两个 SelectedIndexChanged 互相反向触发死循环。</summary>
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<string, DataTable> _xianghaoCache =
new System.Collections.Generic.Dictionary<string, DataTable>();
// key = 图号 + "\n" + 箱号, value = 该箱号的元件表(内存态)
private System.Collections.Generic.Dictionary<string, DataTable> _yuanjianCache =
new System.Collections.Generic.Dictionary<string, DataTable>();
// ============================================================
// 右键菜单组件(运行时创建,不在设计器 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元件)
/// <summary>
/// 重写 OnLoad:它在所有 Load 事件订阅者之前执行
/// (包括 ui.cs 设计器注入的 LoadAllSqliteData)。
/// 在此确保项目库文件存在 —— 若被删过(项目已清空),则建一个空库,
/// 这样 ui.cs 的 LoadSqliteData_advancedDataGridView6 里的
/// File.Exists 检查通过、查空表,不会弹“找不到数据库文件”。
/// 不修改 ui.cs,所有逻辑都在事件代码里。
/// </summary>
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 { }
}
/// <summary>
/// 确保项目数据库与项目表存在(为空表),不存在则自动创建。
/// 启动时(兜底 ui.cs 的 File.Exists 检查)和“新建项目”写入时都会调用。
/// 表结构必须与 ui.cs LoadSqliteData_advancedDataGridView6 查询的表名一致。
/// </summary>
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[] {
"成套厂名称", "成套厂地址", "成套厂联系人", "成套厂联系电话"
});
}
}
/// <summary>
/// 查询项目表(advancedDataGridView6Table)的所有列名。
/// 用于 INSERT 时过滤掉 values 里项目表不存在的字段(如价格格式化配置),
/// 避免“table has no column named xxx”错误。
/// </summary>
private static System.Collections.Generic.List<string> GetProjectTableColumns(System.Data.SQLite.SQLiteConnection conn)
{
var cols = new System.Collections.Generic.List<string>();
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;
}
/// <summary>
/// 幂等地为指定表补列:遍历需要补的列名,在 PRAGMA table_info 结果里查不到才 ALTER TABLE ADD COLUMN。
/// 用于老库结构升级(例如新增成套厂 4 列)。
/// </summary>
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<string>(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();
}
}
}
}
/// <summary>构造函数:初始化 UI 并绑定事件。</summary>
// 构造函数已迁至 MainForm.cs
// ============================================================
// 选型页(tabPage10):汇总所有元件表 → advancedDataGridView1
// 选中行 → 该元件所在箱号明细 → advancedDataGridView12
// ============================================================
/// <summary>选型页加载中标志,防止 SelectionChanged 联动死循环。</summary>
private bool _isLoadingSelectionPage = false;
/// <summary>选型页汇总表的内存数据(全列)。修改后回报价表用。</summary>
private DataTable _selectionSummaryTable = null;
/// <summary>选型页明细表内存数据(选中汇总行后填充)。</summary>
private DataTable _selectionDetailTable = null;
/// <summary>选型页加载范围:当前项目 / 当前图号。空=未初始化。</summary>
private string _selectionScope = "";
/// <summary>选型页汇总表是否有修改(true 时切页才需要同步)。</summary>
private bool _selectionSummaryDirty = false;
/// <summary>用户最后主动点选的表格:0=选型页元件汇总 1=选型页箱号明细 2=报价页元件表。
/// ★ 替换目标以它为准——明细表在汇总行选中/替换后会重载并自动带选中行,
/// "明细优先"的老规则会被这个自动选中劫持:用户点的是元件汇总的行,
/// 实际却替换了明细表里的行(实测日志:同一产品有的行能换、有的行被拒的元凶)。
/// ★ 2=报价页元件表:选型页表格残留的选中行会抢走报价页替换的目标
/// (实测:报价页元件表选行点替换,却被选型页分支拦截"替换不了")。
/// 只记用户真实点击(SelectionChanged 且非加载态),程序性重绑/恢复不盖章。</summary>
private int _选型页最后点选表 = 0;
/// <summary>网页替换连发防抖:同一载荷 1 秒内只处理第一次。
/// 按钮一次点击多监听器/快速连点会连发多条替换消息(日志实测 0.8 秒 4 连发)。</summary>
private string _网页替换防抖键 = "";
private DateTime _网页替换防抖时刻 = DateTime.MinValue;
/// <summary>初始化选型页:挂事件 + comboBox5 初始化选项。</summary>
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); }
}
/// <summary>
/// 加载选型汇总数据到 advancedDataGridView1。
/// 数据来源:当前项目所有图号下所有箱号的所有元件表(或仅当前图号)。
/// 合并规则:元件名称 + 规格 + 表价 完全相同 → 合并为一行,数量累加。
/// </summary>
/// <summary>汇总加载模式:进入选型页/切范围=合并同类项;网页替换/插入后的刷新=不合并。
/// (用户要求:只有打开选型页时才做一次合并,平时刷新保持逐行)</summary>
private bool _selectionMergeMode = true;
/// <summary>明细延迟加载是否已排队(合并连续触发的 SelectionChanged,只执行最后一次)。</summary>
private bool _selectionDetailPending = false;
/// <summary>
/// 把明细加载推迟到当前鼠标/键盘消息处理完之后执行,并合并重复触发。
/// 避免在 DataGridView 的 MouseDown→MouseUp 选择流程中间做数据库查询+重建明细表
/// (会干扰原生选择状态机,导致编辑完点击其他单元格时莫名多选一片)。
/// </summary>
private void 延迟加载选型明细()
{
if (_selectionDetailPending) return;
_selectionDetailPending = true;
try
{
BeginInvoke((Action)(() =>
{
_selectionDetailPending = false;
if (IsDisposed || Disposing) return;
if (_isLoadingSelectionPage) return;
LoadSelectionDetail();
}));
}
catch
{
_selectionDetailPending = false;
LoadSelectionDetail(); // 句柄不可用时退化为同步加载
}
}
/// <summary>有价格行高亮事件是否已挂(只挂一次)。</summary>
private bool _selectionPriceHighlightWired = false;
private void LoadSelectionSummary()
{
LoadSelectionSummary(_selectionMergeMode);
}
/// <param name="合并同类项">true=名称+规格+图纸规格+类型+表价 相同合并(数量累加);
/// false=逐行平铺不合并(网页替换/插入后的即时刷新用)。</param>
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<DataRow>();
using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath)))
{
conn.Open();
// 拉图号列表(图号表所有行)——★ 读"内部ID"列(不是"图号"显示名列),
// 表索引里存的是内部ID,用内部ID查才能正确关联元件表
var tuhaoList = new System.Collections.Generic.List<string>();
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<string, int>(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;
}
}
/// <summary>
/// 汇总表选中行变化 → 加载该行元件所在的箱号明细(advancedDataGridView12)。
/// 明细列与 advancedDataGridView12 列定义对齐,新增"图号"列也填上。
/// </summary>
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<string, string>(System.StringComparer.Ordinal);
// ★ 真隔离(2026-09-29):箱号显示名缓存(图号+箱号键 → 箱号表.箱号列)——
// 元件索引"箱号"字段新数据存内部ID GUID,明细"箱号"列要显示显示名,
// 同一箱号的多行元件只查一次库
var 箱名缓存 = new System.Collections.Generic.Dictionary<string, string>(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;
}
}
/// <summary>★ 真隔离(2026-09-29):把元件索引"箱号"键(新数据=内部ID GUID,老数据=显示名)
/// 解析成箱号显示名(查箱号表 WHERE 内部ID=@键;查不到=老数据键本身,原样返回)。
/// 结果进缓存(含未命中)防重复查库。</summary>
private static string 取箱号显示名(System.Data.SQLite.SQLiteConnection conn, string 图号, string 箱号键,
System.Collections.Generic.Dictionary<string, string> 缓存)
{
string 键 = (图号 ?? "") + "\u0001" + (箱号键 ?? "");
string 已有;
if (缓存.TryGetValue(键, out 已有)) return 已有;
string 结果 = (箱号键 ?? "");
try
{
if (!string.IsNullOrEmpty(图号) && !string.IsNullOrEmpty(箱号键))
{
// 找该图号的箱号表(可能多张),按内部ID查显示名
var 箱表们 = new System.Collections.Generic.List<string>();
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 结果;
}
/// <summary>查某 图号+箱号 对应的图纸名(扒图建箱时写入箱号表「图纸」列的当前图纸名,去扩展名)。
/// 一个图号可能有多张箱号表,逐张按箱号找第一行非空「图纸」;结果进缓存(含空值)防重复查库。
/// 主窗口手工建的箱号没写图纸名 → 返回空串(列显示空白,与现状一致)。</summary>
private static string 取箱号图纸(System.Data.SQLite.SQLiteConnection conn, string 图号, string 箱号,
System.Collections.Generic.Dictionary<string, string> 缓存)
{
string 键 = (图号 ?? "") + "\u0001" + (箱号 ?? "");
string 已有;
if (缓存.TryGetValue(键, out 已有)) return 已有;
string 结果 = "";
try
{
if (!string.IsNullOrEmpty(图号))
{
var 箱表们 = new System.Collections.Generic.List<string>();
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 结果;
}
/// <summary>
/// 选型页替换后的原地更新:不重载整个汇总表(全表重绑必丢选中),只把界面上
/// 被替换的那一行(汇总选中行)的单元格值直接改掉——表格结构/绑定/选中全不动。
/// 数量保持原值(替换不动数量);派生列按新值重算。
/// </summary>
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("[选型页] 原地更新了 " + 更新行数 + " 行(同行身份拆多行的全部刷新)");
}
/// <summary>
/// 选型页替换(网页"替换"按钮在选型页上的分支):
/// · 明细表(adgv12)有选中行 → 只替换那一条(按 图号+箱号+元件名称+规格 定位);
/// · 否则汇总表(adgv1)有选中行 → 替换该 元件名称+规格 的全部实例(跨图号跨箱号)。
/// 两者都直接写库(UPDATE,数量列不动),返回改动处数。
/// </summary>
/// <summary>选型页是否有选中的行(汇总或明细)——用作"替换走选型页"的判定。
/// ★ 不能用 SelectedTab==选型页 判定:用户点右侧库窗口(用户库/柜体库/母排/箱体)
/// 的替换按钮时,焦点已漂到该库窗口,SelectedTab 不再是选型页,但用户的意图
/// 仍是替换选型页里选中的那行。"选型页有选中行"才是真实意图信号。</summary>
private bool 选型页有选中行()
{
try
{
var 汇 = GetFirstSelectedRow(this.advancedDataGridView1);
if (汇 != null && !汇.IsNewRow) return true;
var 明 = GetFirstSelectedRow(this.advancedDataGridView12);
if (明 != null && !明.IsNewRow) return true;
}
catch { }
return false;
}
/// <summary>替换目标是否应为选型页(网页/库替换的统一分支判定):
/// 用户最后点选的是报价页元件表(=2) → 一律走报价页分支;
/// 否则选型页表格里有选中行 → 走选型页分支。
/// ★ 只看"选型页有选中行"会把选型页残留的选中行当成目标——
/// 用户切到报价页点元件表的行点替换,目标却被抢走(实测"元件表替换不了")。</summary>
private bool 替换目标在选型页()
{
if (_选型页最后点选表 == 2) return false; // 最后点的是报价页元件表
return 选型页有选中行();
}
/// <summary>
/// 库(用户库/柜体库/母排/箱体)替换的选型页统一入口:
/// 目标=选型页汇总/明细选中行(与网页替换同规则),替换后原地更新选中行,不刷新整表不丢选中。
/// 返回 true=已处理(调用方直接 return)。
/// </summary>
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<string[]>();
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<string>();
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;
}
}
/// <summary>汇总表单元格修改后重算派生列(单价/采购价/报出价)。</summary>
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); }
}
/// <summary>
/// 第一步:把选型页的元件级修改(表价/系数等)UPDATE 到数据库。
/// 只写元件表,不重算箱号/图号聚合(留给切回报价页时再做)。
/// </summary>
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<string>();
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<string, System.Collections.Generic.HashSet<string>>();
foreach (string rt in tblNames)
{
var 集 = new System.Collections.Generic.HashSet<string>();
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);
}
}
/// <summary>
/// 第二步:切回报价页(tabPage4)时调用。
/// 顺序:① 先 RecalcAllFromDatabase 把数据库的箱号/图号/项目汇总重算为新值
/// ② 再清空缓存 + Reload(读最新值到内存 + 显示)
/// </summary>
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:启动本地代理 + 加载网页
// ============================================================
/// <summary>本地代理子进程实例,null 表示未启动或已退出。</summary>
private System.Diagnostics.Process _proxyProcess;
/// <summary>node 服务存活监控定时器(30 秒一查,进程意外退出自动重启,页面不断)。</summary>
private System.Windows.Forms.Timer _proxyWatchTimer;
private string _proxyNodeExe; // 监控重启用:node 路径
private string _proxyScript; // 监控重启用:server.cjs 路径
private int _proxyRestartCount; // 本次运行期重启次数(超限停止监控防死循环)
/// <summary>WebView 页面锁定轮询定时器(每秒检测路径偏离),关窗时停掉。</summary>
private System.Windows.Forms.Timer _webViewLockTimer;
/// <summary>代理监听端口(proxy_server.py 里写死为 8080)。</summary>
private const int 代理端口 = 8080;
/// <summary>元件库选型页面(众企选型 dqb-1688 本地克隆页,由 元件库网页\server.cjs 在
/// 本机 8080 伺服:页面静态=dqb\ 克隆,数据接口=/api8k 反代线上;hash 路由。
/// 插入/替换按钮由 dqb\local-features.js 注入,直接经 chrome.webview 发
/// {action:'insert'/'replace',name,price,brand,xishu,xilie} 给 WebView21_WebMessageReceived。</summary>
private const string 代理地址 = "http://127.0.0.1:8080/apiopen/#/";
/// <summary>启动 node 服务存活监控:30 秒一查,进程退出且非主程序关闭则自动重启(上限 20 次防死循环)。</summary>
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();
}
/// <summary>
/// 启动价格查询:拉起 proxy_server.py,等端口就绪后导航 webView21。
/// 失败不抛异常,只在 diag.log 记录,避免影响主程序启动。
/// </summary>
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<ts.length;i++){var t=(ts[i].textContent||'').replace(/^\\s+|\\s+$/g,'');" +
" if(t==='相似系列'||t==='优选供应商'||t==='个人中心'||t==='设置')ts[i].style.display='none';}" +
" var ws=document.querySelectorAll('.el-dialog__wrapper');" +
" for(var j=0;j<ws.length;j++){if(ws[j].querySelector('.ad-dialog'))ws[j].style.display='none';}};" +
" 隐(); setInterval(隐,500);" +
" try{new MutationObserver(function(){隐();}).observe(document.body||document.documentElement,{childList:true,subtree:true});}catch(e4){}})();" +
// ② 桥:把 {status:'ok',params:{val,isAdd}} 翻译成现有协议发给 C#
" var 发=window.chrome&&window.chrome.webview&&window.chrome.webview.postMessage; if(!发)return;" +
" var 原生=window.postMessage.bind(window);" +
" window.postMessage=function(m){ try{" +
" if(m&&m.status==='ok'&&m.params&&m.params.val!=null){" +
" var v=(typeof m.params.val==='string')?JSON.parse(m.params.val):m.params.val;" +
" if(v&&v.F_Model!=null){" +
// 折扣兜底:替换钮构造的数据不带折扣字段时读页面当前本体折扣框(#body_disc)
" var 折=v.F_Purchase_Discount!=null?parseFloat(v.F_Purchase_Discount):(function(){var d=parseFloat((document.getElementById('body_disc')||{}).value);return isNaN(d)?100:d;})()/100;" +
// 系列名优先读页面系列输入框(#zb_celectLists,如"NXM系列塑壳断路器";占位/空时回退品类名 F_Name)
" var 系名='';try{var se=document.getElementById('zb_celectLists');var sv=se?String(se.value||'').replace(/^\\s+|\\s+$/g,''):'';if(sv&&sv.indexOf('系列')>=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 += 延迟初始化;
}
/// <summary>
/// 根据当前是否处于"报价"标签页(tabPage4)或"选型"标签页(tabPage10),同步启用/禁用网页上的
/// "插入元件"/"替换选中" 按钮。其他页时按钮禁用并灰化。
/// (选型页也启用:AI 在元件库页面点插入时用户常停在选型页看数据)
/// </summary>
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"}
// ============================================================
/// <summary>接收网页 postMessage,解析后调用插入/替换。</summary>
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); }
}
/// <summary>从简单 JSON 字符串中取字符串字段值(支持 "key":"value" 及常见转义)。</summary>
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("\\/", "/");
}
/// <summary>
/// 清理系列选择框值中的"XX系列"前缀,保留后面的产品类型描述。
/// 例如 "CM5系列塑料外壳式断路器" → "塑料外壳式断路器";
/// "NSX系列框架断路器" → "框架断路器";
/// "DZ47系列微型断路器" → "微型断路器"。
/// 若不含"系列"字样,则原样返回。
/// </summary>
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
// 结构: <favorites><fav 公司="XX" 系列="YY"/></favorites>
// ============================================================
/// <summary>收藏配置文件路径。</summary>
private string 收藏配置路径
{
get { return System.IO.Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, @"shezhi\元件库收藏.config.xml"); }
}
/// <summary>初始化收藏功能:加载配置、绑定事件。</summary>
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); }
}
/// <summary>从配置文件加载收藏数据,填充 comboBox6。</summary>
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<string>();
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); }
}
/// <summary>comboBox6 选择公司时,刷新 comboBox7 的系列列表。</summary>
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 { }
}
/// <summary>comboBox7 选择系列时,通过 JS 在网页中选择对应的公司和系列。</summary>
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<a.length;i++){"
+ " var t=a[i].getAttribute('title')||'';"
+ " var x=(a[i].textContent||'').trim();"
+ " if(t.indexOf('" + company.Replace("'", "\\'") + "')>=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<sw.length;i++){try{sw[i].click();}catch(e){}}"
+ " result='expanded '+sw.length+' nodes';"
+ "}"
+ "result";
var r2 = await this.webView21.CoreWebView2.ExecuteScriptAsync(js2);
Log("[收藏] 第一次展开: " + r2);
// 等1秒让子节点渲染
await System.Threading.Tasks.Task.Delay(1000);
// 第3步:按名字在 zTree 数据里找到系列节点(s=系列ID)与厂家节点(f=厂家ID),
// 直调页面官方 API 选中系列并验证(内部 expandNode+selectNode+点击+防覆盖重试 5 次,
// 与页面搜索跳转同款路径)。★ 老 DOM 模拟点击会被 jprice 异步重载覆盖
// (NH40 案例:点完 1 秒被刷回默认系列,重点也每次被吞)。
string js3 = "(function(){"
+ "var SERIES='" + xilie.Replace("'", "\\'") + "';"
+ "var COMPANY='" + company.Replace("'", "\\'") + "';"
+ "var zt=null,ztf=null;"
+ "try{if(window.$dq&&$dq.fn&&$dq.fn.zTree){"
+ "zt=$dq.fn.zTree.getZTreeObj('classtreeele')||$dq.fn.zTree.getZTreeObj('classtreeele_dq123_price');"
+ "ztf=$dq.fn.zTree.getZTreeObj('factorytreeele')||$dq.fn.zTree.getZTreeObj('factorytreeele_dq123_price');"
+ "}}catch(e){}"
+ "if(!zt)return 'no zt';"
+ "var sid=null,sname='';"
// ★ 叶子判断照页面同款(price_standalone.html:1027): !n.isParent && n.s(真值,s=0 是 falsy 不算系列)
// 旧写法只查 n.s!=null,会匹配到父/占位节点拿 sid=0 → API 恒选默认第一个系列
+ "var fS=function(ns){for(var i=0;i<ns.length;i++){var n=ns[i];"
+ "if(!n.isParent&&n.s&&(n.name||'').indexOf(SERIES)>=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<ns.length;i++){var n=ns[i];if(n.f!=null&&(n.name||'').indexOf(COMPANY)>=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); }
}
/// <summary>收藏按钮:从网页获取当前厂家和系列,保存到配置文件。</summary>
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, "收藏");
}
}
/// <summary>保存一条收藏记录到配置文件(自动去重)。</summary>
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),右键删除单条收藏
// ============================================================
/// <summary>清理收藏按钮:弹出对话框,用两个增强表格展示并可删除收藏。</summary>
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<string> 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, "清理收藏");
}
}
/// <summary>构建公司去重表格(对应 comboBox6 的内容)。</summary>
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<string> seen = new HashSet<string>();
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;
}
/// <summary>构建全部收藏表格(公司+系列两列)。</summary>
private DataTable BuildFavoriteTable()
{
return BuildFavoriteTable(null);
}
/// <summary>构建系列表;company=null 显示全部(公司+系列两列),company 有值则只显示该公司的系列(只返回系列一列)。</summary>
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;
}
/// <summary>删除指定公司的所有收藏。</summary>
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<System.Xml.XmlNode> toRemove = new List<System.Xml.XmlNode>();
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); }
}
/// <summary>删除单条收藏(公司+系列)。</summary>
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); }
}
/// <summary>把网页选中的元件作为新行插入到元件表(advancedDataGridView5)。</summary>
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;
}
}
/// <summary>用网页选中的元件信息替换元件表当前选中行(保留原数量/报出系数)。</summary>
/// <summary>在内存表里按 _rowid_ 列找行(精确定位刚 INSERT 的行,不赌排序);找不到返回 null。
/// _rowid_ 经 SQLite DataAdapter.Fill 后 CLR 类型不固定(long/decimal/double 都可能),
/// 用 Convert.ToInt64 容错匹配,不能用 "is long" 强判(类型不符会永远找不到)。</summary>
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 + " " + 名称 + " 价格=" + 价格 + " 数量=" + 数量 + " 品牌=" + 品牌 + " 系数=" + 采购系数);
}
/// <summary>该箱号还没建过元件表(_yuanjianTable 为 null 或 TableName 空)时自动建空表并重载。
/// 各工具窗口(母排/GCS/箱体/用户库/柜体库)插入元件前调用;
/// 返回 false=未选图号/箱号或建表失败(调用方应提示并中止)。
/// 建表块抄方案库插入元件(LookupTableByIndex/CreateFakYuanjianTable/表索引),不带占位行。</summary>
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;
}
}
/// <summary>内存元件表插行后的收尾(虚拟模式):同步网格行数 + 全表保存到库 + 重绘。
/// 各工具窗口(母排/GCS/箱体计算/方案库/用户库/柜体库/网页)插入元件后必须调用,
/// 否则虚拟模式网格 RowCount 不变新行不显示,且新行无 _rowid_ 不走直写、重启即丢。</summary>
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();
}
/// <summary>重算当前选中箱号/图号行的聚合(插入/替换元件后调用,避免触发联动死循环)。</summary>
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;
}
}
/// <summary>
/// 异步检测 WebView 当前路径,如果偏离 /price 则强制整页刷新回 /price。
/// 必须在 UI 线程调用(ExecuteScriptAsync 要求 UI 线程)。
/// </summary>
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);
}
}
/// <summary>
/// 关闭 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。
/// </summary>
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);
}
/// <summary>静默执行一个命令行程序并等待退出(用于 taskkill/powershell,不弹窗)。</summary>
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);
}
}
/// <summary>
/// 按命令行特征清理残留的价格代理(python)与本程序的 WebView2 浏览器进程。
/// 关窗时兜底(含WebView2=true 全量) + 启动时自愈(false 只清 python,
/// 不碰 WebView2——启动瞬间自己的 WebView2 还没起,避免竞态误杀)。
/// 只杀:命令行含 proxy_server.py 的 python/pythonw;用户数据目录为
/// "成套报价软件.exe.WebView2" 的 msedgewebview2。不误杀其它程序的 python/WebView2。
/// </summary>
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);
}
}
/// <summary>快速探测本机端口是否被占用(连接成功=占用)。用于启动自愈的捷径判定。</summary>
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;
}
/// <summary>
/// 从 ExecuteScriptAsync 返回的 JSON 字符串中简单提取字符串字段的值。
/// 例如 {"path":"/price"} 提取 "path" 返回 "/price"。
/// 非字符串字段(true/false/数字)原样返回。不做严格 JSON 解析,避免依赖。
/// </summary>
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 ""; }
}
// ============================================================
// 性能优化:开启双缓冲(消除多选拖动时的重绘闪烁)
// ============================================================
/// <summary>
/// 用反射开启 DataGridView 的 DoubleBuffered(protected 属性,设计器够不到)。
/// ui.cs 已经自带优化版选中效果(HashSet 缓存),不需要我们重写绘制逻辑。
/// 这里只补做双缓冲:重绘先画到内存再一次性贴屏,消除闪烁和撕裂。
/// </summary>
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 { }
}
/// <summary>
/// 递归禁用所有 DataGridView 的行高拖动调整(行高数值保持各表现状,只锁"可调")。
/// 覆盖主窗体与停靠窗口里的全部表格。
/// </summary>
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);
}
}
/// <summary>虚拟模式:由 dgv 反查它当前的内存数据表。</summary>
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;
}
/// <summary>
/// ★ 虚拟模式统一写通道:写内存 DataTable 行 + 刷新该行显示。
/// 虚拟模式下写 Cells.Value 无效,所有对三表的单元格写入都必须走这里。
/// </summary>
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 { }
}
/// <summary>
/// 按 DataPropertyName 把指定列设为 ReadOnly(不可双击编辑)。
/// 公式列(由代码计算填充)和序号列(由代码自动编号)不允许用户改,
/// 只有用户输入列(图号/箱号/数量/表价/系数等)允许编辑。
/// </summary>
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<string>(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;
}
}
}
// ============================================================
// 辅助:路径与连接字符串
// ============================================================
/// <summary>当前日志日期(yyyy-MM-dd)。跨天时滚动到新文件并清老日志。</summary>
private static string _logDate = "";
/// <summary>
/// 诊断日志:写到 exe 同目录 logs\diag_yyyy-MM-dd.log,按天滚动;
/// 跨天首次写入时自动清理 7 天前的旧日志。兼容旧 diag.log。
/// </summary>
private void Log(string msg)
{
写日志(msg);
}
/// <summary>静态日志入口(项目云等无实例上下文的类用;线程安全:File.AppendAllText 原子追加)。</summary>
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 { }
}
/// <summary>删除 logs\ 下 7 天前的 diag_*.log,并删除根目录超过 5MB 的旧 diag.log。</summary>
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;";
}
/// <summary>
/// 根据项目编号生成该项目专属的报价数据库绝对路径。
/// 每个项目一个独立 db 文件,放在 sjk\bjb\ 目录下,
/// 文件名格式:项目<项目编号>.db。
/// 文件名里的非法字符会被替换成下划线。
/// </summary>
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);
}
/// <summary>
/// 确保指定项目的报价数据库存在;不存在则创建,
/// 并在里面建立 图号/箱号/元件 三张空表(结构与原模板一致)。
/// 对已存在的老库(没有"项目信息"表)也会补建,确保结构完整。
/// </summary>
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>();
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);
}
}
/// <summary>
/// 老库迁移辅助:把"显示名列"的值复制到"内部ID列"(仅对内部ID为空的行)。
/// 用于图号表(图号→内部ID)和箱号表(箱号→内部ID)的迁移。
/// </summary>
/// <param name="conn">已打开的连接</param>
/// <param name="quotedTableName">表名(已含双引号)</param>
/// <param name="displayCol">显示名列名(如"图号"/"箱号")</param>
/// <param name="idCol">内部ID列名(如"内部ID")</param>
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 { } // 迁移失败不阻断(列可能不存在等)
}
/// <summary>
/// ★ A1(2026-09-30):给项目库里【所有元件表】幂等补"内部ID"列并为空值行回填 GUID。
/// 统一定位体系:三表全部用内部ID定位(直写 UPDATE/回填),rowid 从定位链退役。
/// 在 EnsureQuoteDbExists 末尾调用(主窗口+扒图窗口共用,internal static)。
/// </summary>
internal static void MigrateYuanjianInternalId(System.Data.SQLite.SQLiteConnection conn)
{
try
{
var yjTables = new System.Collections.Generic.List<string>();
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<long>();
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 { }
}
/// <summary>
/// ★ A5(2026-10-01):数据库健康自检——进项目/建库时自动执行(幂等,毫秒级):
/// 1) 断链索引:表索引登记的"实际表名"在库里不存在 → 删除该索引行
/// (断链会让查表永远返回空,数据看起来"消失";删掉索引行不删任何数据表)
/// 2) 孤儿表:库里的 xh_*/yj_* 表没有索引登记 → 只记日志,不自动删(数据安全)
/// 3) 序号重复:箱号表/元件表"序号"列有重复 → 按 rowid 顺序重排 1..N
/// (重复序号导致加载排序不稳定、行序漂移)
/// 内部ID缺失的回填由 MigrateInternalIdColumn/MigrateYuanjianInternalId 负责,此处不重复。
/// </summary>
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<string>(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<long>();
var indexedTables = new System.Collections.Generic.HashSet<string>(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<long>();
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);
}
}
// ============================================================
// 元件表:每个箱号一张独立表(表名=箱号原名,双引号括起支持中文)
// ============================================================
/// <summary>
/// 根据箱号生成对应的元件表名(用双引号括起,支持中文表名)。
/// 例:箱号"新箱号1" → 表名 '"新箱号1"'。
/// 每个箱号的元件数据完全隔离在各自的表里,彻底避免串数据。
/// </summary>
private static string GetYuanjianTableName(string xianghao)
{
if (string.IsNullOrWhiteSpace(xianghao)) return "\"元件表\"";
// 双引号内的双引号需翻倍转义(实际箱号不会含双引号,这里防御性处理)
string safe = xianghao.Replace("\"", "\"\"");
return "\"" + safe + "\"";
}
/// <summary>
/// 确保指定箱号的元件表存在;不存在则创建(列结构沿用模板,去掉图号/箱号列,
/// 因为表名本身已隔离箱号)。
/// </summary>
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 会自然报错或返回空
}
}
/// <summary>返回指定箱号的独立元件表里的行数+1(用于新建元件的默认序号)。</summary>
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";
}
}
// ============================================================
// 箱号表:每个图号一张独立表(表名=图号原名,双引号括起支持中文)
// ============================================================
/// <summary>
/// 根据图号生成对应的箱号表名(用双引号括起,支持中文表名)。
/// 例:图号"新图号1" → 表名 '"新图号1"'。
/// 每个图号的箱号数据完全隔离在各自的表里,彻底避免串数据。
/// </summary>
private static string GetXianghaoTableName(string tuhao)
{
if (string.IsNullOrWhiteSpace(tuhao)) return "\"图号箱号表\"";
string safe = tuhao.Replace("\"", "\"\"");
return "\"" + safe + "\"";
}
/// <summary>
/// 确保指定图号的箱号表存在;不存在则创建(列结构沿用模板,去掉图号列)。
/// </summary>
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
{
// 建表失败不阻断
}
}
/// <summary>返回指定图号的独立箱号表里的行数+1(用于新建箱号的默认序号)。</summary>
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做表名,索引表记录 图号/箱号 → 真实表名 的映射
// 彻底避免"用名称做表名"导致的重名冲突和状态不同步(串数据根因)
// ============================================================
/// <summary>索引表名常量。</summary>
private const string IndexTableName = "表索引";
/// <summary>
/// 重新编号指定表的序号列,使其连续(1、2、3...)。
/// 按当前行顺序(数据库返回顺序)重新分配序号。
/// 用于删除中间行后,让剩余行的序号自动连续。
/// </summary>
/// <param name="projectId">项目编号</param>
/// <param name="quotedTableName">表名(已含双引号,如 "xh_1" 或 advancedDataGridView9Table)</param>
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<long>();
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 { }
}
/// <summary>
/// 生成下一个唯一表ID(前缀+自增序号)。
/// 前缀:xh=箱号表,yj=元件表。序号取索引表里该前缀已有最大值+1。
/// </summary>
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";
}
}
/// <summary>往索引表插一条记录。</summary>
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 { }
}
/// <summary>从索引表删除记录(按类型+图号+箱号)。</summary>
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 { }
}
/// <summary>查索引表:图号 → 该图号的箱号表真实表名(找不到返回空)。</summary>
private string GetXianghaoTableByIndex(string projectId, string tuhao)
{
return LookupTableByIndex(projectId, "箱号", tuhao, "");
}
/// <summary>查索引表:图号+箱号 → 该箱号的元件表真实表名(找不到返回空)。
/// ★ 真隔离(2026-09-29):双键兼容——
/// 1) 先按传入的 xianghao(可能是内部ID GUID,也可能是旧数据显示名)直接查
/// 2) 若未命中,查箱号表把 xianghao(显示名)转成 内部ID 再查(向后兼容老数据)
/// </summary>
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 "";
}
/// <summary>
/// ★ 真隔离(2026-09-29):按图号+箱号显示名查箱号表的"内部ID"列(用于把显示名转成内部ID)。
/// 找不到返回空。表索引里箱号表索引的"箱号"字段为空字符串,所以箱号表通过 GetXianghaoTableByIndex 拿。
/// </summary>
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 ""; }
}
/// <summary>ResolveXianghaoInternalId 复用连接版本。</summary>
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 ""; }
}
/// <summary>查索引表的通用实现,返回真实表名(找不到返回空)。</summary>
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 "";
}
}
/// <summary>
/// 查索引表(复用已有连接版本,用于循环/批量场景,避免每次开关连接)。
/// 调用方负责保证 conn 已 Open 且指向正确的报价库。
/// </summary>
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 "";
}
}
/// <summary>查索引表:图号+箱号 → 真实表名(复用连接版本)。
/// ★ 真隔离(2026-09-29):双键兼容——先按 xianghao 直接查,
/// 未命中则查箱号表把显示名转成 内部ID 再查(向后兼容老数据)。
/// </summary>
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 "";
}
/// <summary>查索引表:图号 → 箱号表真实表名(复用连接版本)。</summary>
private string GetXianghaoTableByIndex(string tuhao,
System.Data.SQLite.SQLiteConnection conn)
{
return LookupTableByIndex("箱号", tuhao, "", conn);
}
/// <summary>
/// 用户改图号名后,更新项目库表索引里的图号名(箱号表索引 + 所有元件表索引)。
/// </summary>
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); }
}
/// <summary>
/// 用户改箱号名后,更新项目库表索引里的箱号名(元件表索引)。
/// </summary>
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;
/// <summary>CAD扒图(button11):在文档区打开 CAD 扒图工作区(报价三表+网页CAD查看器)。
/// 打开时功能区自动跳到「CAD扒图」页签(与项目/报价窗口同款联动)。</summary>
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 扒图工作区");
}
/// <summary>PDF扒图(button34):在文档区打开 PDF 扒图工作区(报价三表+PDF查看器)。
/// 打开时功能区自动跳到「PDF扒图」页签(与项目/报价窗口同款联动)。</summary>
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 扒图工作区");
}
/// <summary>把功能区切到指定页签(找不到静默跳过;同步 _lastRibbonActiveIndex 防 MouseUp 正向联动抖动)。</summary>
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); }
}
/// <summary>
/// 检查成套报价 CAD 插件是否已安装。
/// 三级检测:①HKLM 注册表 CADCJ 节点 ②acaddoc.lsp CADCJ 加载逻辑 ③cadcj.dll 存在性
/// </summary>
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;
}
}
/// <summary>
/// 方式1:遍历 HKLM 注册表检测 CADCJ 节点。
/// </summary>
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;
}
/// <summary>
/// 方式2:检测 AutoCAD Support 目录下 acaddoc.lsp 是否包含 CADCJ 加载逻辑。
/// 这是插件实际的加载机制,独立于注册表存在。
/// </summary>
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<string> 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;
}
/// <summary>
/// 获取所有已安装 AutoCAD 版本的 Support 目录路径。
/// </summary>
private List<string> GetAllAutoCadSupportDirs()
{
var dirs = new List<string>();
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;
}
/// <summary>
/// 启动已安装的 AutoCAD。
/// 优先启动 CADCJ 插件已注册的版本;否则启动第一个找到的 AutoCAD。
/// </summary>
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;
}
}
/// <summary>
/// 遍历注册表查找 AutoCAD 的 acad.exe 路径。
/// </summary>
/// <param name="requireCadcj">true=只返回已注册 CADCJ 插件的版本;false=返回任意已安装版本</param>
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企业信息)——打开企业信息设置窗口
// ============================================================
/// <summary>
/// 打开「企业信息」窗口。该窗口自带加载/保存逻辑(配置文件在 shezhi\企业信息.config.xml),
/// 关闭后下次「新建项目」会自动读取最新值。
/// </summary>
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);
}
}
}
// ============================================================
// 报价库「项目信息」表读写(项目信息主存储)
// ============================================================
/// <summary>报价库项目信息表的 16 个字段(顺序无关,仅用于 INSERT 时拼列名)。</summary>
private static readonly string[] ProjectInfoColumns =
{
"项目编号", "项目名称", "客户名称", "客户地址",
"联系人", "联系电话", "项目日期", "备注",
// 成套厂信息(4 个字段):新建项目时从企业信息带入,用户可改写,随项目一起保存
"成套厂名称", "成套厂地址", "成套厂联系人", "成套厂联系电话",
// 价格显示格式化(4 个字段):新建项目/修改项目设置,随项目一起保存
"单台小数位", "单台取整方式", "项目小数位", "项目取整方式"
};
/// <summary>
/// 把项目信息(8 个字段)写入报价库的「项目信息」表。
/// 先按项目编号 DELETE 旧行,再 INSERT,避免重复(等价于 INSERT OR REPLACE)。
/// 报价库/表不存在会先 EnsureQuoteDbExists 补建。
/// </summary>
private void InsertProjectInfoToQuoteDb(string projectId, Dictionary<string, string> 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<string>();
var paras = new List<string>();
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();
}
}
}
/// <summary>
/// 把修改后的项目信息字段 UPDATE 到报价库的「项目信息」表(WHERE 项目编号=@id)。
/// 报价库/表不存在会先 EnsureQuoteDbExists 补建。无旧行时退化为 INSERT。
/// </summary>
private void UpdateProjectInfoToQuoteDb(string projectId, Dictionary<string, string> 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<string>();
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);
}
}
}
/// <summary>
/// 从报价库的「项目信息」表读取指定项目的 8 个信息字段。
/// 库不存在/表不存在/无数据 → 返回空字典(调用方按需填空显示)。
/// </summary>
private Dictionary<string, string> GetProjectInfoFromQuoteDb(string projectId)
{
var info = new Dictionary<string, string>();
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<string>(
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<string, string> 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<string>(
GetProjectTableColumns(conn), System.StringComparer.OrdinalIgnoreCase);
var colNames = new List<string>();
var paramNames = new List<string>();
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:报价菜单页)
// ============================================================
/// <summary>项目菜单页 tabPage1 上的"修改项目"按钮(无论当前在哪个页面都可用)。</summary>
private void button2_Click(object sender, EventArgs e)
{
OpenModifyProjectDialog();
}
/// <summary>报价菜单页 tabPage2 上的"修改项目"按钮(仅在报价页已打开且选中项目时可用)。</summary>
/// <summary>打开"修改项目"对话框,根据 advancedDataGridView6 当前选中行预填字段。
/// 确定后把改动 UPDATE 回项目数据库,并刷新项目表。
/// </summary>
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<string, string>();
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 全量重算,数据自洽。
// ============================================================
/// <summary>
/// 生成下一个项目编号(yyyyMMddNNN):
/// 查项目表里当天前缀的最大 3 位序号 +1,与「新建项目」对话框规则一致。
/// </summary>
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");
}
/// <summary>
/// 弹一个简单的文本输入对话框(标题+提示+默认值)。
/// 返回用户输入;点取消返回 null。
/// </summary>
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;
}
}
/// <summary>复制项目(button5):把选中项目整库复制为新项目。</summary>
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, string>();
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<string, string>(info);
newInfo["项目编号"] = newPid;
newInfo["项目名称"] = newName;
newInfo["项目日期"] = DateTime.Now.ToString("yyyy-MM-dd");
InsertProjectInfoToQuoteDb(newPid, newInfo);
// 8) 项目表追加一行(汇总列由进入项目时重算,先清空避免误导)
var rowVals = new Dictionary<string, string>();
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);
}
}
/// <summary>把 advancedDataGridView6 当前行的列值收集到字典(键=列名)。</summary>
private void CollectRowValues(DataGridViewRow row, Dictionary<string, string> 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();
}
}
/// <summary>按项目编号在 advancedDataGridView6 中查找对应行(找不到返回 null)。</summary>
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;
}
/// <summary>按项目编号在 advancedDataGridView6 中重新选中对应行。</summary>
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 { }
}
/// <summary>把修改后的字段 UPDATE 到项目数据库(索引+汇总缓存),并同步到报价库项目信息表。</summary>
private int UpdateProjectToDatabase(string projectId, Dictionary<string, string> 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<string>(
GetProjectTableColumns(conn), System.StringComparer.OrdinalIgnoreCase);
var setList = new List<string>();
var filteredValues = new System.Collections.Generic.Dictionary<string, string>();
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 互相对应:项目/报价/扒图/...)
// ============================================================
/// <summary>tabControl2 切换 → 把 tabControl1 也切到同名页(按 Text 匹配)。</summary>
private void SyncTab1ToTab2ByText()
{
// 停靠面板版:顶部选项卡(菜单)与文档窗口不再互相同步,此方法保留为空。
Log("[TabSync] SyncTab1ToTab2ByText: 停靠版无需同步,跳过");
}
/// <summary>tabControl1 切换 → 把 tabControl2 也切到同名页(按 Text 匹配)。</summary>
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;
}
/// <summary>从项目信息字典取值,取不到返回空字符串。</summary>
private static string GetInfoOrDefault(Dictionary<string, string> 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);
}
}
/// <summary>
/// 把 comboBox2 的文本映射到 advancedDataGridView6 数据源的列名。
/// 支持简写/全称/手输容错。
/// </summary>
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 "";
}
/// <summary>
/// 转义 DataView RowFilter 中 LIKE 模式的特殊字符。
/// 用户输入的 % _ ' [ 会被当成普通字符处理。
/// </summary>
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<string>();
var seenIndex = new System.Collections.Generic.HashSet<int>();
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<string>();
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 "";
}
/// <summary>
/// 从表格行的底层数据源(DataRowView)读取列值,不依赖表格列是否已绑定显示。
/// 用于读取"内部ID"等隐藏列(这些列在 DataTable 里有,但 DataGridView 没有对应显示列)。
/// </summary>
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);
}
/// <summary>
/// 进入项目核心流程:切换到报价页 + 加载该项目的报价数据。
/// 由"双击项目行"和"切到 tabPage2 时自动恢复最近项目"两个入口共用。
/// 调用前 _currentProjectId 必须已设为新项目编号,且上一个项目的缓存已被清理。
/// </summary>
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);
}
/// <summary>
/// 自动恢复最近打开过的项目(从 shezhi\最近项目.config.xml 读)。
/// 触发场景:切到报价页但当前没有任何项目被打开。
/// 前提:项目必须仍存在于项目表(被删过的项目不会恢复)。
/// </summary>
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);
}
}
/// <summary>检查项目编号是否存在于项目表(advancedDataGridView6Table)。</summary>
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)
// ============================================================
/// <summary>打开当前项目的独立报价库,加载图号数据,刷新 advancedDataGridView9。</summary>
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 ? "<NULL>" : 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 置空(防整表保存误覆盖)+ 每行带 _来源表名_/_来源图号_
// 内部列;编辑回写由直写队列按行级来源表名定位(入队直写库 已适配)。
// ============================================================
/// <summary>多图号合并加载箱号(图号表多选时调用):按图号顺序拼接各图号的箱号表。</summary>
private void ReloadXianghaoByTuhaos(string projectId,
System.Collections.Generic.List<string> 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;
}
}
/// <summary>多箱号合并加载元件(箱号表多选时调用):pairs = [来源图号, 箱号内部ID, 箱号显示名]。
/// ★ 合并视图序号列显示"箱号-N"(如 AH4-1),用户可分辨每行归属。</summary>
private void ReloadYuanjianByXianghaos(string projectId,
System.Collections.Generic.List<string[]> 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 的改动写回当前项目的独立报价库
// ============================================================
/// <summary>
/// 把内存 DataTable 的改动回写到指定项目的独立报价库中的指定表。
/// 由于 SQLite 表没有 PRIMARY KEY,SQLiteCommandBuilder 无法生成 UPDATE/DELETE,
/// 这里采用"按筛选范围删除旧行 + 重新插入内存所有行"的方式,简单可靠。
/// </summary>
/// <param name="force">true=无条件保存所有内存行(用于新建/删除前确保编辑不丢失);
/// false=只在有改动时保存(用于 Reload 开头,避免用旧内存覆盖刚 Insert 的新行)。</param>
/// <param name="whereClause">删除时的 WHERE 条件(不含 WHERE 关键字);为空则清空整表。</param>
/// <param name="whereParams">WHERE 条件对应的参数字典;无参数传 null。</param>
private void SaveTableToDatabase(DataTable table, string projectId, string tableName,
string whereClause, Dictionary<string, string> 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<string>(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();
}
}
/// <summary>把列名列表转成参数名列表(前面加 @)。</summary>
private static List<string> ConvertAllToParams(List<string> colNames)
{
var result = new List<string>(colNames.Count);
foreach (string c in colNames)
{
result.Add("@" + c);
}
return result;
}
/// <summary>把当前图号表的改动回写当前项目的库(整表替换)。</summary>
private void SaveTuhaoTableIfDirty()
{
SaveTableToDatabase(_tuhaoTable, _currentProjectId, TuhaoTableName, null, null);
}
/// <summary>
/// 把当前箱号表的改动回写数据库。
/// 用内存 DataTable.TableName 记录的真实表名保存(不再用易抖动的 _currentTuhao)。
/// </summary>
private void SaveXianghaoTableIfDirty()
{
if (_xianghaoTable == null || string.IsNullOrWhiteSpace(_xianghaoTable.TableName)) return;
SaveTableToDatabase(_xianghaoTable, _currentProjectId,
"\"" + _xianghaoTable.TableName + "\"", null, null);
}
/// <summary>
/// 把当前元件表的改动回写数据库。
/// 用内存 DataTable.TableName 记录的真实表名保存(不再用易抖动的 _currentXianghao)。
/// </summary>
private void SaveYuanjianTableIfDirty()
{
if (_yuanjianTable == null || string.IsNullOrWhiteSpace(_yuanjianTable.TableName)) return;
SaveTableToDatabase(_yuanjianTable, _currentProjectId,
"\"" + _yuanjianTable.TableName + "\"", null, null);
}
/// <summary>
/// 统一保存当前项目的三个报价表(元件 → 箱号 → 图号,自底向上)。
/// 在新建/删除任何报价数据前调用,确保用户之前的编辑都立即持久化。
/// </summary>
/// <param name="force">
/// true(默认,历史行为):无条件全表覆盖,即使 GetChanges 返回 null 也写,
/// 防止"AcceptChanges 已标记为干净但实际没写盘"导致表价丢失。
/// false(性能优化用):仅当 GetChanges != null 才写。
/// 操作前的预保存用 false——若用户没编辑就没必要全表覆盖;
/// 后续 RecalcAllAfterReload 结尾会用 force 行为再存一次重算结果。
/// </param>
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
{
// 保存失败不阻断新建/删除操作
}
}
/// <summary>
/// 把当前项目的所有改动(图号表 + 所有缓存的箱号表/元件表)统一写库,并重算项目汇总。
/// 这是「保存按钮」和「离开报价页触发保存」共用的入口,出错会弹 MessageBox。
/// </summary>
private void SaveAllToDatabase()
{
try
{
SaveAllToDatabaseSilent();
}
catch (Exception ex)
{
Log("SaveAllToDatabase 异常: " + ex.Message);
MessageBox.Show(this, "保存失败:\n" + ex.Message, "保存",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
/// <summary>
/// 同上,但出错不弹窗(用于切项目/关窗等不该弹窗的场景)。
/// 保存内容:① 当前图号表 ② 所有缓存的箱号表 ③ 所有缓存的元件表 ④ 重算项目汇总并写库。
/// </summary>
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); }
// (旧的"存三表+缓存+全量重算+刷列表"保存链已随直写库架构退役删除)
}
/// <summary>窗体关闭时:把当前项目的所有改动(含缓存)统一回写数据库。</summary>
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 结束");
}
/// <summary>
/// 关窗时预处理所有增强表格(ADGV):解绑数据源 + 清列,
/// 让后续 Form.Dispose 走到 Columns.Clear() 时集合是空的,
/// 绕开 .NET 4.8 DataGridView.Dispose 的索引越界缺陷。
/// 单个表格失败不影响其他表格,全部吞异常(此时数据已保存完)。
/// </summary>
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();
}
/// <summary>★ G1-2(2026-10-01):功能区按钮点击统一入口(Designer 绑定)。
/// 原为空方法,真路由靠 MainForm 构造里手工二次绑定的 ribbonStrip1_ButtonClick——
/// 双处理器结构已废弃,现在直接转调统一路由。</summary>
void RibbonStrip1ButtonClick(object sender, RibbonButtonEventArgs e)
{
ribbonStrip1_ButtonClick(sender, e);
}
}
}