7256 lines
378 KiB
C#
7256 lines
378 KiB
C#
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)
|
||
{
|
||
// ★ A2(2026-10-01):统一收敛到 数据库连接.串(一处管理),本方法保留签名做转发,调用方零改动
|
||
return 数据库连接.串(dbPath);
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
|
||
}
|
||
}
|