using System; using System.Collections.Generic; using System.Data; using System.Diagnostics; using System.Drawing; using System.IO; using System.IO.Compression; using System.Windows.Forms; using Microsoft.Win32; using Smart.CustomComponents; namespace 成套报价软件 { /// 主窗口 拆分文件:由 主窗口.cs 按模块拆出,同一 partial class。 public partial class MainForm { // ============================================================ // 母排页铜价刷新:从长江有色网(ccmn.cn)抓取 1#铜 均价和发布日期 // 数据接口:https://www.ccmn.cn/shop/historyData/getCjysAndCjysw // 返回 JSON 的 body.showPriceList 是 AES-ECB(PKCS7) 加密的 Base64, // 密钥=固定字符串 ccmnCjysw1455881(与首页 ccmn-index.js 一致)。 // 解密后是 [[{productSortName,avgPrice,publishDate,...},...],...], // 找 productSortName=="1#铜" 取 avgPrice 和 publishDate。 // ============================================================ /// 铜价数据接口地址。 private const string 铜价接口地址 = "https://www.ccmn.cn/shop/historyData/getCjysAndCjysw"; /// 首页 JS 里写死的 AES 密钥(ccmn-index.js: keys = "ccmnCjysw1455881")。 private const string 铜价AES密钥 = "ccmnCjysw1455881"; /// btn刷新 点击:异步抓取 1#铜 或 A00铝 均价和日期,填入 txt当前铜价 / txt更新日期。 /// 根据 rb铝排.Checked 决定抓取目标:铜排→1#铜,铝排→A00铝。 private async void btn刷新_Click(object sender, EventArgs e) { this.btn刷新.Enabled = false; string 原文本 = this.btn刷新.Text; this.btn刷新.Text = "刷新中…"; // 当前材质:铜排→"1#铜",铝排→"A00铝" bool 是铝排 = (this.rb铝排 != null && this.rb铝排.Checked); string 目标品种 = 是铝排 ? "A00铝" : "1#铜"; string 日志标签 = 是铝排 ? "[铝价刷新]" : "[铜价刷新]"; try { // ccmn.cn 强制 TLS 1.2,.NET 4.8 默认 SecurityProtocol 不含 Tls12, // 不显式设置会报"请求被中止:未能创建 SSL/TLS 安全通道"。 System.Net.ServicePointManager.SecurityProtocol = (System.Net.SecurityProtocolType)3072; // Tls12 = 0xC00 = 3072 string json; using (var wc = new System.Net.WebClient()) { wc.Encoding = System.Text.Encoding.UTF8; wc.Headers[System.Net.HttpRequestHeader.UserAgent] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; wc.Headers[System.Net.HttpRequestHeader.Referer] = "https://www.ccmn.cn/"; json = await wc.DownloadStringTaskAsync(铜价接口地址); } string showPriceList = 取JSON字段(json, "showPriceList"); if (string.IsNullOrEmpty(showPriceList)) { Log(日志标签 + " 未取到 showPriceList"); MessageBox.Show(this, "未获取到" + 目标品种 + "数据", "刷新失败", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } string plain = 铜价AES解密(showPriceList); // 解密后是 [[{...},{...}],[{...}]] 嵌套数组, // 用正则逐个对象提取(对象内部无嵌套大括号,可安全用 \{[^}]*\}) var 对象匹配 = System.Text.RegularExpressions.Regex.Matches(plain, @"\{[^}]*\}"); bool 找到 = false; foreach (System.Text.RegularExpressions.Match m in 对象匹配) { string obj = m.Value; string 名称 = 取JSON字段(obj, "productSortName"); if (名称 != 目标品种) continue; // avgPrice 是数字,单独用正则取 var 均价匹配 = System.Text.RegularExpressions.Regex.Match(obj, @"""avgPrice""\s*:\s*([0-9]+\.?[0-9]*)"); string 均价文本 = 均价匹配.Success ? 均价匹配.Groups[1].Value : ""; string 日期 = 取JSON字段(obj, "publishDate"); // 均价转整数(106150.00 → 106150) decimal 均价值; if (decimal.TryParse(均价文本, out 均价值)) { 均价文本 = ((long)均价值).ToString(); } this.txt当前铜价.Text = 均价文本; this.txt更新日期.Text = 日期; // ★ 价格缓存到对应材质的配置文件:调母排窗口直接读,不再发网络请求 Save母排价格缓存(是铝排, 均价文本, 日期); Log(日志标签 + " 成功: " + 目标品种 + " 均价=" + 均价文本 + " 日期=" + 日期); 找到 = true; break; } if (!找到) { Log(日志标签 + " 未找到 " + 目标品种); MessageBox.Show(this, "未找到 " + 目标品种 + " 的价格数据", "刷新失败", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } catch (Exception ex) { Log(日志标签 + " 异常: " + ex.Message); MessageBox.Show(this, "刷新失败: " + ex.Message, 目标品种 + "刷新", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { this.btn刷新.Enabled = true; this.btn刷新.Text = 原文本; } } /// /// 用 AES-ECB(PKCS7) 解密 ccmn 接口返回的 Base64 密文。 /// 密钥见 铜价AES密钥 常量(与首页 ccmn-index.js 的 decryptAES 一致)。 /// private static string 铜价AES解密(string 加密Base64) { byte[] 密钥 = System.Text.Encoding.UTF8.GetBytes(铜价AES密钥); byte[] 密文 = System.Convert.FromBase64String(加密Base64); using (var aes = System.Security.Cryptography.Aes.Create()) { aes.Mode = System.Security.Cryptography.CipherMode.ECB; aes.Padding = System.Security.Cryptography.PaddingMode.PKCS7; aes.Key = 密钥; using (var 解密器 = aes.CreateDecryptor()) { byte[] 明文 = 解密器.TransformFinalBlock(密文, 0, 密文.Length); return System.Text.Encoding.UTF8.GetString(明文); } } } // ============================================================ // 铜排参数(加工费等)持久化 —— 配置文件 shezhi\铜排参数.config.xml // ============================================================ /// /// 重算 txt铜价(元/Kg) = (txt当前铜价 + txt加工费) / 1000。 /// 当前铜价、加工费单位均为 元/吨,1 吨 = 1000 公斤,所以除以 1000 得 元/Kg。 /// 任一输入为空或非数字时不写入(保留 txt铜价 原值,避免覆盖用户手输)。 /// 结果四舍五入取整(不显示小数点)。 /// private void Recalc铜价PerKg() { decimal 铜价吨; if (!decimal.TryParse((this.txt当前铜价.Text ?? "").Trim(), out 铜价吨)) return; decimal 加工费吨; if (!decimal.TryParse((this.txt加工费.Text ?? "").Trim(), out 加工费吨)) return; decimal 铜价Kg = (铜价吨 + 加工费吨) / 1000m; this.txt铜价.Text = Math.Round(铜价Kg, 0, MidpointRounding.AwayFromZero).ToString("0"); } /// /// 重算 txt单价(元/米) 和 txt重量(kg/米)。 /// 每米重量 = 层数 × 宽度(mm) × 厚度(mm) × 密度(g/cm³) / 1000 /// 铜排 TMY:密度 8.9 g/cm³ /// 铝排 LMY:密度 2.7 g/cm³ /// 推导:1 米长度对应 1000mm,截面积=宽×厚 mm²,体积=宽×厚×1000 mm³=宽×厚 cm³, /// 重量=宽×厚×密度 g = 宽×厚×密度/1000 kg,再乘层数。 /// txt重量 = 每米重量(保留 3 位小数,如 0.445 kg/m)。 /// txt单价 = 铜价(元/Kg) × 每米重量(四舍五入保留 2 位小数)。 /// 任一输入为空/非数字时不写入(保留原值)。 /// private void Recalc单价PerMeter() { decimal 铜价Kg; if (!decimal.TryParse((this.txt铜价.Text ?? "").Trim(), out 铜价Kg)) return; decimal 层数; if (!decimal.TryParse((this.txt层数.Text ?? "").Trim(), out 层数)) return; decimal 宽度; if (!decimal.TryParse((this.txt宽度.Text ?? "").Trim(), out 宽度)) return; decimal 厚度; if (!decimal.TryParse((this.txt厚度.Text ?? "").Trim(), out 厚度)) return; // 材质密度:rb铜排(默认)= 8.9,rb铝排 = 2.7 // CheckedChanged 会在切换瞬间旧/新按钮都触发一次,以当前选中的为准 decimal 密度 = (this.rb铝排 != null && this.rb铝排.Checked) ? 2.7m : 8.9m; // 每米重量 = 层数 × 宽(mm) × 厚(mm) × 密度 / 1000 (kg/m) decimal 每米重量 = 层数 * 宽度 * 厚度 * 密度 / 1000m; // txt重量:每米重量保留 2 位小数(铜排小规格每米零点几 kg,2 位够用) this.txt重量.Text = Math.Round(每米重量, 2, MidpointRounding.AwayFromZero).ToString("0.##"); // txt单价:每米单价四舍五入保留 2 位小数(用户要求不再取整) decimal 单价每米 = 铜价Kg * 每米重量; this.txt单价.Text = Math.Round(单价每米, 2, MidpointRounding.AwayFromZero).ToString("0.00"); } /// /// 材质切换(铜排↔铝排)时统一处理: /// 1. 切换 lbl当前铜价 标签文本(当前铜价↔当前铝价) /// 2. 重新加载 advancedDataGridView8 规格表数据(铜排库/铝排库) /// 3. 清空价格/规格/层宽厚等文本框(避免残留旧材质数据) /// 4. 加载对应材质的加工费参数 /// 5. 自动刷新价格(铜价/铝价) /// private void 切换母排材质() { bool 是铝排 = (this.rb铝排 != null && this.rb铝排.Checked); // 1. 切换标签文本 this.lbl当前铜价.Text = 是铝排 ? "当前铝价(元/吨)" : "当前铜价(元/吨)"; // 2. 重新加载规格表(铜排库/铝排库) // ★ 不再用 ui.cs 的 LoadSqliteData_advancedDataGridView8()—— // 它写死了铜排库路径(advancedDataGridView8Table.db),切铝排也不换库, // 表格里永远显示 TMY 数据。这里按材质选库自己加载。 try { Load铜排规格表(是铝排); } catch (Exception ex) { Log("[材质切换] 加载规格表失败: " + ex.Message); } // 3. 清空相关文本框(价格/日期/规格/层宽厚/重量/单价) this.txt当前铜价.Text = ""; this.txt更新日期.Text = ""; this.txt铜价.Text = ""; this.txt规格.Text = ""; this.txt层数.Text = ""; this.txt宽度.Text = ""; this.txt厚度.Text = ""; this.txt重量.Text = ""; this.txt单价.Text = ""; // 4. 加载对应材质的加工费参数 try { Load铜排参数(); } catch (Exception ex) { Log("[材质切换] 加载参数失败: " + ex.Message); } // 5. 自动刷新价格(铜价/铝价)——异步,不阻塞 UI try { this.btn刷新_Click(this, EventArgs.Empty); } catch (Exception ex) { Log("[材质切换] 刷新价格失败: " + ex.Message); } } /// /// 按材质加载铜排规格表(advancedDataGridView8): /// 铜排 → sjk\advancedDataGridView8Table.db (TMY 数据) /// 铝排 → sjk\advancedDataGridView8Table_LMY.db (LMY 数据) /// 与 ui.cs 的 LoadSqliteData_advancedDataGridView8 同绑定方式 /// (AutoGenerateColumns=false + DataSource),只是路径按材质选。 /// private void Load铜排规格表(bool 是铝排) { string 文件名 = 是铝排 ? "advancedDataGridView8Table_LMY.db" : "advancedDataGridView8Table.db"; string dbPath = System.IO.Path.Combine( System.AppDomain.CurrentDomain.BaseDirectory, @"sjk\" + 文件名); if (!System.IO.File.Exists(dbPath)) { MessageBox.Show(this, "找不到数据库文件: " + dbPath, "铜排规格表", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var table = new DataTable(); using (var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + dbPath + ";Version=3;")) { conn.Open(); using (var adapter = new System.Data.SQLite.SQLiteDataAdapter( "SELECT * FROM advancedDataGridView8Table", conn)) { adapter.Fill(table); } } this.advancedDataGridView8.AutoGenerateColumns = false; this.advancedDataGridView8.DataSource = table; Log("[材质切换] 规格表已加载: " + 文件名 + " 行数=" + table.Rows.Count); } /// 母排参数配置文件路径(铜排/铝排各自独立,exe 同目录的 shezhi 子目录)。 /// 铜排→铜排参数.config.xml,铝排→铝排参数.config.xml。 private string Get母排参数配置路径() { bool 是铝排 = (this.rb铝排 != null && this.rb铝排.Checked); string 文件名 = 是铝排 ? "铝排参数.config.xml" : "铜排参数.config.xml"; return System.IO.Path.Combine( System.AppDomain.CurrentDomain.BaseDirectory, @"shezhi\" + 文件名); } /// 最近打开的项目配置文件路径(记录最后一次进入的项目编号)。 private static readonly string 最近项目配置路径 = System.IO.Path.Combine( System.AppDomain.CurrentDomain.BaseDirectory, @"shezhi\最近项目.config.xml"); /// 把项目编号保存为"最近打开的项目"。 private void SaveLastProject(string pid) { if (string.IsNullOrWhiteSpace(pid)) return; try { string dir = System.IO.Path.GetDirectoryName(最近项目配置路径); if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); var doc = new System.Xml.XmlDocument(); doc.AppendChild(doc.CreateXmlDeclaration("1.0", "utf-8", null)); var root = doc.CreateElement("最近项目"); doc.AppendChild(root); var node = doc.CreateElement("项目编号"); node.InnerText = pid; root.AppendChild(node); doc.Save(最近项目配置路径); } catch (Exception ex) { Log("[最近项目] 保存失败: " + ex.Message); } } /// 读取最近打开的项目编号;没有返回空串。 private static string LoadLastProject() { try { if (!System.IO.File.Exists(最近项目配置路径)) return ""; var doc = new System.Xml.XmlDocument(); doc.Load(最近项目配置路径); var node = doc.SelectSingleNode("/最近项目/项目编号"); return node == null ? "" : (node.InnerText ?? "").Trim(); } catch { return ""; } } /// 从配置文件加载母排参数(目前:加工费),填入对应文本框。 /// 铜排/铝排各有独立配置文件,由 Get母排参数配置路径() 决定。 private void Load铜排参数() { try { string 配置路径 = Get母排参数配置路径(); if (!System.IO.File.Exists(配置路径)) return; var doc = new System.Xml.XmlDocument(); doc.Load(配置路径); var node = doc.SelectSingleNode("/母排参数/加工费"); // 兼容旧配置文件(根节点为"铜排参数") if (node == null) node = doc.SelectSingleNode("/铜排参数/加工费"); if (node != null) this.txt加工费.Text = node.InnerText; } catch (Exception ex) { Log("[母排参数] 加载失败: " + ex.Message); } } // ============================================================ // 调母排窗口(xiangmu\调母排.cs)桥接:给子窗体取数据/回调刷新 // ============================================================ /// 当前打开的项目编号(给调母排窗口定位报价库)。 public string GetCurrentProjectId() { return _currentProjectId ?? ""; } /// /// 把内存中未保存的三表编辑(图号/箱号/元件,含缓存)强制落盘。 /// 调母排窗口打开前调用——它直接读数据库,必须先看到最新编辑。 /// public void FlushQuoteTablesToDb() { try { SaveAllToDatabaseSilent(); } catch (Exception ex) { Log("[调母排] 预落盘失败: " + ex.Message); } } /// 母排页当前价格文本(元/吨;铜排模式=1#铜,铝排模式=A00铝)。 public string Get母排当前价格文本() { return this.txt当前铜价 == null ? "" : (this.txt当前铜价.Text ?? ""); } /// 母排页当前材质:true=铝排,false=铜排。 public bool Get母排是铝排() { return this.rb铝排 != null && this.rb铝排.Checked; } /// 读取指定材质的加工费(直接读配置文件,不依赖当前材质状态)。 /// 铝=false→shezhi\铜排参数.config.xml;铝=true→shezhi\铝排参数.config.xml。 /// 读不到返回空串(调母排窗口显示空,用户可手填)。 public string Get母排加工费(bool 是铝排) { try { string 文件名 = 是铝排 ? "铝排参数.config.xml" : "铜排参数.config.xml"; string 路径 = System.IO.Path.Combine( System.AppDomain.CurrentDomain.BaseDirectory, @"shezhi\" + 文件名); if (!System.IO.File.Exists(路径)) return ""; var doc = new System.Xml.XmlDocument(); doc.Load(路径); var node = doc.SelectSingleNode("/母排参数/加工费"); if (node == null) node = doc.SelectSingleNode("/铜排参数/加工费"); return node == null ? "" : node.InnerText; } catch { return ""; } } /// /// 把抓到的金属价格缓存进对应材质的配置文件(铜排参数.config.xml / 铝排参数.config.xml)。 /// 在 加工费 节点旁增加 价格(元/吨) 和 价格日期 两个节点。 /// 读配置时按需取,没有就用主窗口当前文本框值。 /// private static void Save母排价格缓存(bool 是铝排, string 价格吨, string 日期) { try { string 文件名 = 是铝排 ? "铝排参数.config.xml" : "铜排参数.config.xml"; string 路径 = System.IO.Path.Combine( System.AppDomain.CurrentDomain.BaseDirectory, @"shezhi\" + 文件名); string dir = System.IO.Path.GetDirectoryName(路径); if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); var doc = new System.Xml.XmlDocument(); if (System.IO.File.Exists(路径)) doc.Load(路径); else { doc.AppendChild(doc.CreateXmlDeclaration("1.0", "utf-8", null)); doc.AppendChild(doc.CreateElement("母排参数")); } // 根节点兜底(老文件可能是别的根名) if (doc.DocumentElement == null) { doc.AppendChild(doc.CreateElement("母排参数")); } // 写/更新 价格(元/吨) var n价 = doc.SelectSingleNode("/母排参数/价格元每吨"); if (n价 == null) { n价 = doc.CreateElement("价格元每吨"); doc.DocumentElement.AppendChild(n价); } n价.InnerText = 价格吨 ?? ""; // 写/更新 价格日期 var n日 = doc.SelectSingleNode("/母排参数/价格日期"); if (n日 == null) { n日 = doc.CreateElement("价格日期"); doc.DocumentElement.AppendChild(n日); } n日.InnerText = 日期 ?? ""; doc.Save(路径); } catch { } } /// /// 读取缓存的金属价格(元/吨):返回 [价格, 日期]。 /// 铝=false→铜价;铝=true→铝价。没有缓存返回 ["",""]。 /// 给调母排窗口用——不发网络请求,直接用主窗口上次抓到的价。 /// public string[] Get母排价格缓存(bool 是铝排) { try { string 文件名 = 是铝排 ? "铝排参数.config.xml" : "铜排参数.config.xml"; string 路径 = System.IO.Path.Combine( System.AppDomain.CurrentDomain.BaseDirectory, @"shezhi\" + 文件名); if (!System.IO.File.Exists(路径)) return new string[] { "", "" }; var doc = new System.Xml.XmlDocument(); doc.Load(路径); var n价 = doc.SelectSingleNode("/母排参数/价格元每吨"); var n日 = doc.SelectSingleNode("/母排参数/价格日期"); return new string[] { n价 == null ? "" : n价.InnerText, n日 == null ? "" : n日.InnerText, }; } catch { return new string[] { "", "" }; } } /// /// 调母排窗口联网补抓到价格后,把缓存写进配置文件(公开包装)。 /// 与主窗口抓价时写的 Save母排价格缓存 同一文件同一节点。 /// public void Save母排价格缓存Pub(bool 是铝排, string 价格吨, string 日期) { Save母排价格缓存(是铝排, 价格吨, 日期); } /// /// 调母排窗口「更新到项目」后的回调: /// 清缓存 → 全量重算数据库派生列与汇总 → 重载报价页 → 刷新项目列表。 /// (与选型页 RefreshQuotePageAfterSelectionEdit 同套路) /// public void RefreshAfter母排调价() { try { if (string.IsNullOrWhiteSpace(_currentProjectId)) return; _isReloading = true; try { // 先把数据库里的箱号/图号/项目汇总按新元件价重算 RecalcAllFromDatabase(_currentProjectId); // 清缓存从库重载显示 _yuanjianCache.Clear(); _xianghaoCache.Clear(); _tuhaoTable = null; ReloadTuhaoByProject(_currentProjectId); // 刷新项目列表汇总列 ReloadProjectsFromDatabase(); Log("[调母排] 已重算并刷新报价页"); } finally { _isReloading = false; } } catch (Exception ex) { Log("[调母排] 刷新失败: " + ex.Message); } } /// 把母排参数(目前:加工费)保存到配置文件。Leave 失焦 / 关窗兜底均调用。 /// 铜排/铝排各有独立配置文件,由 Get母排参数配置路径() 决定。 private void Save铜排参数() { try { string 配置路径 = Get母排参数配置路径(); string dir = System.IO.Path.GetDirectoryName(配置路径); if (!System.IO.Directory.Exists(dir)) { System.IO.Directory.CreateDirectory(dir); } var doc = new System.Xml.XmlDocument(); doc.AppendChild(doc.CreateXmlDeclaration("1.0", "utf-8", null)); var root = doc.CreateElement("母排参数"); doc.AppendChild(root); var 加工费Node = doc.CreateElement("加工费"); 加工费Node.InnerText = this.txt加工费.Text ?? ""; root.AppendChild(加工费Node); doc.Save(配置路径); } catch (Exception ex) { Log("[母排参数] 保存失败: " + ex.Message); } } // ============================================================ // 铜排规格表(advancedDataGridView8)选中 → 解析规格填入文本框 // ============================================================ /// 铜排规格表选中变化:解析"规格"列文本,自动填入 txt层数/txt宽度/txt厚度。 private void advancedDataGridView8_SelectionChanged(object sender, EventArgs e) { // 多选拖动时跳过(避免 CurrentRow 抖动反复解析) if (this.advancedDataGridView8 != null && this.advancedDataGridView8.SelectedCells.Count > 1) return; DataGridViewRow row = GetFirstSelectedRow(this.advancedDataGridView8); if (row == null) return; string 规格 = GetCellValueAsString(row, "规格"); if (string.IsNullOrWhiteSpace(规格)) return; // 把选中行的规格文本同步到 txt规格(显示当前选中的规格) this.txt规格.Text = 规格; int 层数; string 宽度; string 厚度; if (!TryParse铜排规格(规格, out 层数, out 宽度, out 厚度)) return; this.txt层数.Text = 层数.ToString(); this.txt宽度.Text = 宽度; this.txt厚度.Text = 厚度; } /// /// 解析铜排规格文本,返回 层数/宽度/厚度 三个分量。 /// 规则(与 advancedDataGridView8Table 规格列一致): /// 无括号 "TMY-15×3" → 去掉类型前缀后 "15×3" → 层数=1, 宽度=15, 厚度=3 /// 有括号 "TMY-2(60×6)" → 去掉类型前缀后 "2(60×6)"→ 层数=2, 宽度=60, 厚度=6 /// 乘号支持 × x X * (全角/半角),宽度/厚度支持小数。 /// private static bool TryParse铜排规格(string 规格, out int 层数, out string 宽度, out string 厚度) { 层数 = 1; 宽度 = ""; 厚度 = ""; if (string.IsNullOrWhiteSpace(规格)) return false; string s = 规格.Trim(); // 去掉类型前缀:第一个 '-' 之前的部分(含 '-'),如 "TMY-" / "LMY-" int dash = s.IndexOf('-'); if (dash >= 0 && dash < s.Length - 1) { s = s.Substring(dash + 1).Trim(); } // 情况1:有括号 "层数(宽度×厚度)" var m = System.Text.RegularExpressions.Regex.Match( s, @"^(\d+)\s*\(\s*(\d+(?:\.\d+)?)\s*[×xX\*]\s*(\d+(?:\.\d+)?)\s*\)"); if (m.Success) { if (!int.TryParse(m.Groups[1].Value, out 层数)) 层数 = 1; 宽度 = m.Groups[2].Value; 厚度 = m.Groups[3].Value; return true; } // 情况2:无括号 "宽度×厚度" → 层数默认 1 m = System.Text.RegularExpressions.Regex.Match( s, @"^(\d+(?:\.\d+)?)\s*[×xX\*]\s*(\d+(?:\.\d+)?)"); if (m.Success) { 层数 = 1; 宽度 = m.Groups[1].Value; 厚度 = m.Groups[2].Value; return true; } return false; } // ============================================================ // 母排页 btn插入 / btn替换:把当前规格作为一行元件插入/替换到元件表 // 字段对应:元件名称=母排,规格=txt规格,单位=comboBox1,数量=txt长度,表价=txt单价,类型=母排 // ============================================================ /// /// 母排规格表中选好规格后,点[插入]把当前规格作为一行新元件插入到元件表(advancedDataGridView5)。 /// 新行字段: /// 序号 = 当前最大序号 + 1 /// 元件名称 = "母排" /// 规格 = txt规格.Text /// 单位 = comboBox1.Text /// 数量 = txt长度.Text /// 表价 = txt单价.Text(每米单价) /// 类型 = "母排" /// 其余派生列(采购单价/采购价/报出单价/报出价)按"采购系数=1,报出系数=1"和表价×数量算出。 /// private void btn插入_Click(object sender, EventArgs e) { if (_yuanjianTable == null || string.IsNullOrWhiteSpace(_currentProjectId)) { System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中打开一个项目并选择图号/箱号,再插入母排。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // ★ 该箱号还没建过元件表时自动建表(否则 NewRow 后写"序号"列会抛异常) if (!确保元件表存在()) { System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中打开一个项目并选择图号/箱号,再插入母排。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string 规格 = (this.txt规格.Text ?? "").Trim(); if (string.IsNullOrWhiteSpace(规格)) { System.Windows.Forms.MessageBox.Show(this, "规格为空,请先在铜排规格表中选择一行。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 解析表价/数量,缺省按 0 处理(避免异常,不阻断插入) decimal 表价 = TryParseDecimalOrZero(this.txt单价.Text); decimal 数量 = TryParseDecimalOrZero(this.txt长度.Text); string 单位 = (this.comboBox1.Text ?? "").Trim(); // 派生列(采购系数=1,报出系数=1):采购单价=表价×1;采购价=采购单价×数量;报出单价=采购单价×1;报出价=报出单价×数量 decimal 采购单价 = 表价; decimal 采购价 = 采购单价 * 数量; decimal 报出单价 = 采购单价; decimal 报出价 = 报出单价 * 数量; // ★ 批量插入(2026-10-01):多选箱号时对每个选中箱号执行同样的插入; // 单选=false 返回后走原逻辑 System.Action 插入动作 = delegate { // 提交正在编辑的单元格,确保元件表当前编辑态写入 DataTable this.advancedDataGridView5.EndEdit(); // ★ 记住选中行索引,用于"在选中行下面插入" int selectedIndex = GetSelectedRowIndex(this.advancedDataGridView5); var row = _yuanjianTable.NewRow(); row["序号"] = "1"; // InsertRowBelowSelected 会自动重编号 row["元件名称"] = "母排"; row["规格"] = 规格; row["单位"] = 单位; row["数量"] = (this.txt长度.Text ?? "").Trim(); row["表价"] = (double)表价; row["采购系数"] = "1"; row["采购单价"] = (double)采购单价; row["采购价"] = (double)采购价; row["报出系数"] = "1"; row["报出单价"] = (double)报出单价; row["报出价"] = (double)报出价; row["图纸规格"] = ""; row["品牌"] = ""; row["系列"] = ""; row["类型"] = "母排"; row["备注"] = ""; // ★ 插入到选中行下面(而不是末尾) InsertRowBelowSelected(_yuanjianTable, row, selectedIndex); // ★ 虚拟模式收尾:同步网格行数+全表保存,并重算聚合 元件表插行收尾(); 重算聚合(); // 滚动到新行 int idx = _yuanjianTable.Rows.IndexOf(row); if (idx >= 0) { try { this.advancedDataGridView5.FirstDisplayedScrollingRowIndex = idx; } catch { } } }; if (工具窗口批量插入(插入动作)) return; 插入动作(); Log("[母排插入] 已插入到元件表: 规格=" + 规格 + " 长度=" + 数量 + " 表价=" + 表价); } /// /// 母排[替换]:用当前规格覆盖元件表选中行的内容。 /// 仅当选中行的"类型"列值为"母排"时才允许替换; /// 如果"类型"为空或不是"母排",提示用户并跳过(不覆盖非母排元件)。 /// private void btn替换_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_currentProjectId)) { System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中打开一个项目并选择图号/箱号,再替换母排。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string 规格0 = (this.txt规格.Text ?? "").Trim(); if (string.IsNullOrWhiteSpace(规格0)) { System.Windows.Forms.MessageBox.Show(this, "规格为空,请先在铜排规格表中选择一行。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // ★ 选型页分支:选型页有选中行→目标=选型页选中行(判定用"有选中行"防焦点漂移) if (替换目标在选型页()) { 库替换走选型页("母排", 规格0, "", "", TryParseDecimalOrZero(this.txt单价.Text).ToString( System.Globalization.CultureInfo.InvariantCulture), "1", "母排"); return; } if (_yuanjianTable == null) { System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中选择图号/箱号。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } DataGridViewRow dgvRow = GetFirstSelectedRow(this.advancedDataGridView5); if (dgvRow == null) { System.Windows.Forms.MessageBox.Show(this, "请先在元件表中选择一行要替换的母排。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 类型判定:仅"母排"行才允许替换,空或其它类型跳过 string 当前类型 = GetCellValueAsString(dgvRow, "类型"); if (!string.Equals(当前类型, "母排", System.StringComparison.Ordinal)) { System.Windows.Forms.MessageBox.Show(this, "选中行的类型为「" + (string.IsNullOrWhiteSpace(当前类型) ? "(空)" : 当前类型) + "」,不是「母排」。只允许替换类型为母排的行。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string 规格 = (this.txt规格.Text ?? "").Trim(); if (string.IsNullOrWhiteSpace(规格)) { System.Windows.Forms.MessageBox.Show(this, "规格为空,请先在铜排规格表中选择一行。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } this.advancedDataGridView5.EndEdit(); decimal 表价 = TryParseDecimalOrZero(this.txt单价.Text); decimal 数量 = TryParseDecimalOrZero(this.txt长度.Text); string 单位 = (this.comboBox1.Text ?? "").Trim(); decimal 采购单价 = 表价; decimal 采购价 = 采购单价 * 数量; decimal 报出单价 = 采购单价; decimal 报出价 = 报出单价 * 数量; // 把选中行各列覆盖为当前母排规格的内容(序号保留原值) SetCellValue(dgvRow, "元件名称", "母排"); SetCellValue(dgvRow, "规格", 规格); SetCellValue(dgvRow, "单位", 单位); SetCellValue(dgvRow, "数量", (this.txt长度.Text ?? "").Trim()); SetCellValue(dgvRow, "表价", (double)表价); SetCellValue(dgvRow, "采购系数", "1"); SetCellValue(dgvRow, "采购单价", (double)采购单价); SetCellValue(dgvRow, "采购价", (double)采购价); SetCellValue(dgvRow, "报出系数", "1"); SetCellValue(dgvRow, "报出单价", (double)报出单价); SetCellValue(dgvRow, "报出价", (double)报出价); SetCellValue(dgvRow, "类型", "母排"); Log("[母排替换] 已替换元件表选中行: 规格=" + 规格 + " 长度=" + 数量 + " 表价=" + 表价); } /// /// 供 GCS 推荐铜排窗口调用:把选型出的铜排(规格+米数)插入元件表。 /// 表价按母排界面当前铜价自动计算每米单价: /// 每米单价 = 铜价(元/Kg) × 层数 × 宽度 × 厚度 × 密度 / 1000 /// 密度按规格前缀判断:LMY→铝排 2.7,其余(如 TMY)→铜排 8.9。 /// public bool InsertGcs母排(string 规格, double 米数) { if (_yuanjianTable == null || string.IsNullOrWhiteSpace(_currentProjectId)) { System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中打开一个项目并选择图号/箱号,再插入母排。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return false; } // ★ 该箱号还没建过元件表时自动建表(否则 NewRow 后写"序号"列会抛异常) if (!确保元件表存在()) { System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中打开一个项目并选择图号/箱号,再插入母排。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return false; } 规格 = (规格 ?? "").Trim(); if (string.IsNullOrWhiteSpace(规格)) { System.Windows.Forms.MessageBox.Show(this, "规格为空,无法插入母排。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return false; } // 解析规格得到层数/宽度/厚度 int 层数; string 宽度; string 厚度; if (!TryParse铜排规格(规格, out 层数, out 宽度, out 厚度)) { System.Windows.Forms.MessageBox.Show(this, "无法解析铜排规格:「" + 规格 + "」", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return false; } this.advancedDataGridView5.EndEdit(); // 计算每米单价(与母排界面 Recalc单价PerMeter 公式一致) decimal 铜价Kg = TryParseDecimalOrZero(this.txt铜价.Text); decimal 宽 = TryParseDecimalOrZero(宽度); decimal 厚 = TryParseDecimalOrZero(厚度); decimal 密度 = 规格.StartsWith("LMY", System.StringComparison.OrdinalIgnoreCase) ? 2.7m : 8.9m; decimal 每米重量 = 层数 * 宽 * 厚 * 密度 / 1000m; // ★保留 2 位小数(2026-09-26 用户报与母排页插入不一致:母排页 txt单价 保留 2 位, // 此处曾 Round 0 取整;统一为同款四舍五入 2 位,不再取整) decimal 表价 = Math.Round(铜价Kg * 每米重量, 2, MidpointRounding.AwayFromZero); decimal 数量 = (decimal)米数; decimal 采购单价 = 表价; decimal 采购价 = 采购单价 * 数量; decimal 报出单价 = 采购单价; decimal 报出价 = 报出单价 * 数量; // ★ 批量插入(2026-10-01):多选箱号时对每个选中箱号执行同样的插入; // 单选=false 返回后走原逻辑 System.Action 插入动作 = delegate { this.advancedDataGridView5.EndEdit(); int selectedIndex = GetSelectedRowIndex(this.advancedDataGridView5); var row = _yuanjianTable.NewRow(); row["序号"] = "1"; row["元件名称"] = "母排"; row["规格"] = 规格; row["单位"] = "米"; row["数量"] = 米数.ToString("0.##"); row["表价"] = (double)表价; row["采购系数"] = "1"; row["采购单价"] = (double)采购单价; row["采购价"] = (double)采购价; row["报出系数"] = "1"; row["报出单价"] = (double)报出单价; row["报出价"] = (double)报出价; row["图纸规格"] = ""; row["品牌"] = ""; row["系列"] = ""; row["类型"] = "母排"; row["备注"] = ""; InsertRowBelowSelected(_yuanjianTable, row, selectedIndex); // ★ 虚拟模式收尾:同步网格行数+全表保存,并重算聚合 元件表插行收尾(); 重算聚合(); int idx = _yuanjianTable.Rows.IndexOf(row); if (idx >= 0) { try { this.advancedDataGridView5.FirstDisplayedScrollingRowIndex = idx; } catch { } } }; if (工具窗口批量插入(插入动作)) return true; 插入动作(); Log("[GCS插入] 已插入母排到元件表: 规格=" + 规格 + " 米数=" + 米数 + " 表价=" + 表价); return true; } /// 把文本框值解析为 decimal;空/非数字返回 0。 private static decimal TryParseDecimalOrZero(string text) { decimal d; if (decimal.TryParse((text ?? "").Trim(), out d)) return d; return 0m; } /// 取元件表下一个序号 = 已有行的最大数值序号 + 1(空表返回 1)。 private static int GetNext元件序号(DataTable table) { if (table == null || table.Rows.Count == 0) return 1; int max = 0; foreach (DataRow r in table.Rows) { if (r.RowState == DataRowState.Deleted) continue; object v = r["序号"]; if (v == null || v == DBNull.Value) continue; int n; if (int.TryParse(v.ToString().Trim(), out n) && n > max) max = n; } return max + 1; } /// 把值写到 DataGridViewRow 的指定列(按 DataPropertyName 或 HeaderText 匹配)。 private void SetCellValue(DataGridViewRow row, string colName, object value) { if (row == null || row.DataGridView == null || string.IsNullOrEmpty(colName)) return; DataGridViewColumn col = FindColumn(row.DataGridView, colName); if (col == null) return; // ★ 虚拟模式:写内存表+刷新行(写 Cells.Value 无效) string realName = !string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : colName; 写单元格(row.DataGridView, row.Index, realName, value); } // ============================================================ // 备份/恢复 库数据(用户库/柜体库/方案库/收藏/企业信息) // ============================================================ /// 需要备份的文件列表(相对于 exe 目录)。 private static readonly string[] BackupFiles = new string[] { @"sjk\advancedDataGridView7Table.db", // 用户元件库 @"sjk\advancedDataGridView10Table.db", // 柜体库 @"sjk\fak.db", // 方案库 @"shezhi\元件库收藏.config.xml", // 元件库收藏 @"shezhi\企业信息.config.xml", // 企业信息 }; /// 备份:先选目标(云端/本地,可记住选择),再走对应流程。 private void btn备份_Click(object sender, EventArgs e) { var 目标 = 备份目标选择.询问(false); if (目标 == 备份目标.云端) { 备份库到云端(); return; } if (目标 == 备份目标.本地文件) { 备份到本地文件(); return; } } /// 原本地备份流程(从 btn备份_Click 抽出)。 private void 备份到本地文件() { try { string baseDir = AppDomain.CurrentDomain.BaseDirectory; // 弹保存对话框 string desktop = ""; try { desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); } catch { } string defaultName = "数据备份_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".zip"; using (var sfd = new SaveFileDialog { Title = "备份数据", Filter = "备份包 (*.zip)|*.zip", FileName = defaultName, RestoreDirectory = true, }) { if (!string.IsNullOrEmpty(desktop) && System.IO.Directory.Exists(desktop)) sfd.InitialDirectory = desktop; if (sfd.ShowDialog(this) != DialogResult.OK) return; string zipPath = sfd.FileName; // 临时目录 string tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "成套报价_备份_" + System.Guid.NewGuid().ToString("N")); System.IO.Directory.CreateDirectory(tempDir); int fileCount = 0; try { foreach (string relPath in BackupFiles) { string srcPath = System.IO.Path.Combine(baseDir, relPath); if (!System.IO.File.Exists(srcPath)) continue; // 在临时目录里保持相对路径结构(如 sjk\xxx.db, shezhi\xxx.xml) string dstPath = System.IO.Path.Combine(tempDir, relPath.Replace('\\', System.IO.Path.DirectorySeparatorChar)); string dstDir = System.IO.Path.GetDirectoryName(dstPath); if (!System.IO.Directory.Exists(dstDir)) System.IO.Directory.CreateDirectory(dstDir); // 备份 .db 文件用 SQLite Backup API(避免文件锁) if (relPath.EndsWith(".db", System.StringComparison.OrdinalIgnoreCase)) { BackupSQLiteDb(srcPath, dstPath); } else { System.IO.File.Copy(srcPath, dstPath, true); } fileCount++; Log("[备份] " + relPath + " OK"); } if (fileCount == 0) { MessageBox.Show(this, "没有找到可备份的数据文件。", "备份", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // 打包 if (System.IO.File.Exists(zipPath)) System.IO.File.Delete(zipPath); System.IO.Compression.ZipFile.CreateFromDirectory(tempDir, zipPath); MessageBox.Show(this, "备份成功!\n\n包含 " + fileCount + " 个文件\n保存到:" + zipPath, "备份", MessageBoxButtons.OK, MessageBoxIcon.Information); Log("[备份] 完成: " + fileCount + " 个文件 → " + zipPath); } finally { try { System.IO.Directory.Delete(tempDir, true); } catch { } } } } catch (Exception ex) { MessageBox.Show(this, "备份失败:\n" + ex.Message, "备份", MessageBoxButtons.OK, MessageBoxIcon.Error); Log("[备份异常] " + ex.Message); } } /// 恢复:先选来源(云端/本地,可记住选择),再走对应流程。 private void btn恢复_Click(object sender, EventArgs e) { var 目标 = 备份目标选择.询问(true); if (目标 == 备份目标.云端) { 从云端恢复库(); return; } if (目标 == 备份目标.本地文件) { 从本地文件恢复(); return; } } /// 原本地恢复流程(从 btn恢复_Click 抽出)。 private void 从本地文件恢复() { try { string baseDir = AppDomain.CurrentDomain.BaseDirectory; string desktop = ""; try { desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); } catch { } using (var ofd = new OpenFileDialog { Title = "选择备份文件恢复数据", Filter = "备份包 (*.zip)|*.zip", RestoreDirectory = true, }) { if (!string.IsNullOrEmpty(desktop) && System.IO.Directory.Exists(desktop)) ofd.InitialDirectory = desktop; if (ofd.ShowDialog(this) != DialogResult.OK) return; string zipPath = ofd.FileName; // 解压到临时目录 string tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "成套报价_恢复_" + System.Guid.NewGuid().ToString("N")); System.IO.Directory.CreateDirectory(tempDir); int fileCount = 0; try { System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, tempDir); // 确认恢复 DialogResult dr = MessageBox.Show(this, "恢复将覆盖当前的用户库/柜体库/方案库/收藏/企业信息数据!\n确定继续吗?", "恢复数据", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); if (dr != DialogResult.Yes) return; foreach (string relPath in BackupFiles) { // 在临时目录里找对应文件 string srcPath = System.IO.Path.Combine(tempDir, relPath.Replace('\\', System.IO.Path.DirectorySeparatorChar)); if (!System.IO.File.Exists(srcPath)) continue; string dstPath = System.IO.Path.Combine(baseDir, relPath.Replace('\\', System.IO.Path.DirectorySeparatorChar)); string dstDir = System.IO.Path.GetDirectoryName(dstPath); if (!System.IO.Directory.Exists(dstDir)) System.IO.Directory.CreateDirectory(dstDir); // .db 文件用 SQLite Backup API(避免文件锁) if (relPath.EndsWith(".db", System.StringComparison.OrdinalIgnoreCase)) { BackupSQLiteDb(srcPath, dstPath); } else { // xml 直接覆盖 System.IO.File.Copy(srcPath, dstPath, true); } fileCount++; Log("[恢复] " + relPath + " OK"); } if (fileCount == 0) { MessageBox.Show(this, "备份包里没有找到可恢复的数据文件。", "恢复", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } MessageBox.Show(this, "恢复成功!\n\n已恢复 " + fileCount + " 个文件\n请重新启动软件让数据生效。", "恢复", MessageBoxButtons.OK, MessageBoxIcon.Information); Log("[恢复] 完成: " + fileCount + " 个文件"); // 刷新内存中的表格数据 try { LoadUserLibDistinctNames(); } catch { } try { LoadGuiTiDistinctNames(); } catch { } try { ReloadFakData(); } catch { } } finally { try { System.IO.Directory.Delete(tempDir, true); } catch { } } } } catch (Exception ex) { MessageBox.Show(this, "恢复失败:\n" + ex.Message, "恢复", MessageBoxButtons.OK, MessageBoxIcon.Error); Log("[恢复异常] " + ex.Message); } } // ============================================================ // 自动备份:每天首次启动时自动 zip 全量数据到 backup\ 目录, // 保留最近 14 份,超出自动删除最旧的。全程静默,失败只记日志。 // ============================================================ // ============================================================ // 云端库备份/恢复(会员服务器,按登录手机号隔离;复用项目云接口) // ============================================================ /// 云端库备份的编号前缀(项目云接口按该前缀区分库条目)。 private const string 云库前缀 = "LIB_"; /// 收集全部可云备份的库(BackupFiles + 铜排/铝排库 + 方案库目录每个方案 db)。 /// 返回 (云端编号, 本地相对路径) 列表。 private System.Collections.Generic.List> 收集云端库清单() { var 出 = new System.Collections.Generic.List>(); 出.Add(new System.Collections.Generic.KeyValuePair(云库前缀 + "铜排库", @"sjk\advancedDataGridView8Table.db")); 出.Add(new System.Collections.Generic.KeyValuePair(云库前缀 + "铝排库", @"sjk\advancedDataGridView8Table_LMY.db")); foreach (string rel in BackupFiles) 出.Add(new System.Collections.Generic.KeyValuePair(云库前缀 + 编号自相对路径(rel), rel)); // 方案库目录:每个方案 db 单独一条 string 方案目录 = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "sjk", "方案库"); if (System.IO.Directory.Exists(方案目录)) { foreach (var f in System.IO.Directory.GetFiles(方案目录, "*.db")) 出.Add(new System.Collections.Generic.KeyValuePair( 云库前缀 + "方案_" + System.IO.Path.GetFileNameWithoutExtension(f), "sjk\\方案库\\" + System.IO.Path.GetFileName(f))); } return 出; } /// 相对路径→云端编号片段(sjk\fak.db → fak方案库(含公式表);其余去扩展名/换可读名)。 private static string 编号自相对路径(string relPath) { string s = relPath.Replace("sjk\\", "").Replace("shezhi\\", "").Replace('\\', '_'); if (s.EndsWith(".db", System.StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 3); if (s.EndsWith(".xml", System.StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 4); if (s == "fak") s = "fak方案库(含公式表)"; if (s == "advancedDataGridView7Table") s = "用户库"; if (s == "advancedDataGridView8Table") s = "铜排库"; if (s == "advancedDataGridView8Table_LMY") s = "铝排库"; if (s == "advancedDataGridView10Table") s = "柜体库"; if (s == "企业信息_config") s = "企业信息"; if (s == "元件库收藏_config") s = "元件库收藏"; return s; } private static string 库显示名(string 云编号) { return 云编号.StartsWith(云库前缀) ? 云编号.Substring(云库前缀.Length) : 云编号; } /// 备份全部库到云端(逐个上传;db 用 BackupSQLiteDb 防锁,xml 直接复制)。 private void 备份库到云端() { if (string.IsNullOrWhiteSpace(MainForm.当前登录手机号)) { MessageBox.Show(this, "请先登录会员,再备份到云端。", "备份到云端", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var 们 = 收集云端库清单(); int ok = 0, 序 = 0; foreach (var kv in 们) { 序++; SetStatus("云备份库 " + 序 + "/" + 们.Count + ": " + 库显示名(kv.Key) + " ..."); if (上传单个库到云端(MainForm.当前登录手机号, kv.Key, kv.Value)) ok++; } SetStatus(""); MessageBox.Show(this, "云端备份完成:成功 " + ok + "/" + 们.Count + " 个。", "备份到云端", MessageBoxButtons.OK, MessageBoxIcon.Information); Log("[云备份] 完成: " + ok + "/" + 们.Count); } /// 上传单个库到云端(meta 名称="显示名|原始路径",还原时定位用)。 private bool 上传单个库到云端(string 手机号, string 云编号, string 相对路径) { try { string 本地 = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, 相对路径); if (!System.IO.File.Exists(本地)) { Log("[云备份] 库不存在,跳过: " + 相对路径); return false; } string tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "成套报价_库传_" + System.Guid.NewGuid().ToString("N")); System.IO.Directory.CreateDirectory(tempDir); try { string 打包文件 = System.IO.Path.Combine(tempDir, System.IO.Path.GetFileName(相对路径)); if (相对路径.EndsWith(".db", System.StringComparison.OrdinalIgnoreCase)) BackupSQLiteDb(本地, 打包文件); else System.IO.File.Copy(本地, 打包文件, true); byte[] zip; using (var ms = new System.IO.MemoryStream()) { using (var ar = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, true)) ar.CreateEntryFromFile(打包文件, System.IO.Path.GetFileName(打包文件), System.IO.Compression.CompressionLevel.Optimal); zip = ms.ToArray(); } var 头 = new System.Text.StringBuilder(); 头.Append("{").Append("\"手机号\":\"").Append(EscapeJsonStringStatic(手机号)).Append("\",") .Append("\"编号\":\"").Append(EscapeJsonStringStatic(云编号)).Append("\",") .Append("\"名称\":\"").Append(EscapeJsonStringStatic(库显示名(云编号) + "|" + 相对路径)).Append("\",") .Append("\"时间\":\"").Append(DateTime.Now.ToString("yyyy-MM-dd")).Append("\"}"); string resp = 会员服务Host.PostBinary("/api/proj/upload", 头.ToString(), zip); bool ok = resp != null && resp.IndexOf("\"ok\":true", System.StringComparison.OrdinalIgnoreCase) >= 0; Log("[云备份] " + 云编号 + "(" + zip.Length + "B): " + resp); return ok; } finally { try { System.IO.Directory.Delete(tempDir, true); } catch { } } } catch (Exception ex) { Log("[云备份] 单库失败 " + 云编号 + ": " + ex.Message); return false; } } private class 云端库条目 { public string 编号, 显示名, 路径, 时间; public long 大小; } /// 从云端恢复库:列出 LIB_ 条目勾选,下载 zip 按原始路径覆盖(覆盖前 .bak)。 private void 从云端恢复库() { if (string.IsNullOrWhiteSpace(MainForm.当前登录手机号)) { MessageBox.Show(this, "请先登录会员,再从云端恢复。", "从云端恢复", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } try { string q = System.Uri.EscapeDataString("手机号") + "=" + System.Uri.EscapeDataString(MainForm.当前登录手机号.Trim()); string resp = 会员服务Host.Get("/api/proj/list?" + q); if (resp == null || resp.IndexOf("\"ok\":true", System.StringComparison.OrdinalIgnoreCase) < 0) { MessageBox.Show(this, "云端列表获取失败。", "从云端恢复", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var 条目 = 解析云端库条目(resp); if (条目.Count == 0) { MessageBox.Show(this, "云端还没有库备份。\n请先用\"备份→保存到云端\"上传一次。", "从云端恢复", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } using (var dlg = new System.Windows.Forms.Form { Text = "选择要恢复的库(勾选)", StartPosition = FormStartPosition.CenterParent, FormBorderStyle = FormBorderStyle.FixedDialog, MaximizeBox = false, MinimizeBox = false, ClientSize = new System.Drawing.Size(640, 400), Font = new System.Drawing.Font("宋体", 9F), }) { var lv = new System.Windows.Forms.ListView { Location = new System.Drawing.Point(12, 12), Size = new System.Drawing.Size(616, 330), View = System.Windows.Forms.View.Details, FullRowSelect = true, CheckBoxes = true, HideSelection = false, }; lv.Columns.Add("库", 190); lv.Columns.Add("备份时间", 90); lv.Columns.Add("大小", 80); lv.Columns.Add("原始路径", 240); foreach (var t in 条目) { var it = new System.Windows.Forms.ListViewItem(t.显示名); it.SubItems.Add(t.时间); it.SubItems.Add(t.大小 + "B"); it.SubItems.Add(t.路径); it.Tag = t; lv.Items.Add(it); } dlg.Controls.Add(lv); var btn全选 = new System.Windows.Forms.Button { Text = "全选", Location = new System.Drawing.Point(12, 356), Size = new System.Drawing.Size(75, 28) }; btn全选.Click += (s2, e2) => { foreach (System.Windows.Forms.ListViewItem it in lv.Items) it.Checked = true; }; dlg.Controls.Add(btn全选); var btnOk = new System.Windows.Forms.Button { Text = "还原勾选项", Location = new System.Drawing.Point(452, 356), Size = new System.Drawing.Size(100, 28) }; dlg.Controls.Add(btnOk); var btnCancel = new System.Windows.Forms.Button { Text = "取消", DialogResult = DialogResult.Cancel, Location = new System.Drawing.Point(560, 356), Size = new System.Drawing.Size(68, 28) }; dlg.Controls.Add(btnCancel); dlg.CancelButton = btnCancel; btnOk.Click += (s2, e2) => { dlg.DialogResult = DialogResult.OK; dlg.Close(); }; if (dlg.ShowDialog(this) != DialogResult.OK) return; var 勾 = new System.Collections.Generic.List<云端库条目>(); foreach (System.Windows.Forms.ListViewItem it in lv.Items) if (it.Checked) 勾.Add((云端库条目)it.Tag); if (勾.Count == 0) return; if (MessageBox.Show(this, "还原将覆盖本地的 " + 勾.Count + " 个库(同名库先存 .bak 再覆盖)!\n确定继续吗?还原后建议重启软件。", "从云端恢复", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2) != DialogResult.Yes) return; int okCount = 0; foreach (var t in 勾) { try { string q2 = System.Uri.EscapeDataString("手机号") + "=" + System.Uri.EscapeDataString(MainForm.当前登录手机号.Trim()) + "&" + System.Uri.EscapeDataString("编号") + "=" + System.Uri.EscapeDataString(t.编号); string zipPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "成套报价_库取_" + System.Guid.NewGuid().ToString("N") + ".zip"); try { 会员服务Host.DownloadToFile("/api/proj/download?" + q2, zipPath); string 目标 = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, t.路径); System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(目标)); if (System.IO.File.Exists(目标)) System.IO.File.Copy(目标, 目标 + ".bak", true); string 解压目录 = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "成套报价_库解_" + System.Guid.NewGuid().ToString("N")); System.IO.Directory.CreateDirectory(解压目录); try { System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, 解压目录); string[] 文件们 = System.IO.Directory.GetFiles(解压目录); if (文件们.Length > 0) { System.IO.File.Copy(文件们[0], 目标, true); okCount++; Log("[云恢复] " + t.编号 + " → " + t.路径); } } finally { try { System.IO.Directory.Delete(解压目录, true); } catch { } } } finally { try { System.IO.File.Delete(zipPath); } catch { } } } catch (Exception exOne) { Log("[云恢复] 单库失败 " + t.编号 + ": " + exOne.Message); } } MessageBox.Show(this, "云端恢复完成:成功 " + okCount + "/" + 勾.Count + " 个。\n请重新启动软件让数据生效。", "从云端恢复", MessageBoxButtons.OK, MessageBoxIcon.Information); Log("[云恢复] 完成: " + okCount + "/" + 勾.Count); try { LoadUserLibDistinctNames(); } catch { } try { LoadGuiTiDistinctNames(); } catch { } try { ReloadFakData(); } catch { } } } catch (Exception ex) { MessageBox.Show(this, "云端恢复失败:\n" + ex.Message, "从云端恢复", MessageBoxButtons.OK, MessageBoxIcon.Error); Log("[云恢复异常] " + ex.Message); } } /// 解析 /api/proj/list 响应,取 LIB_ 开头的条目(名称="显示名|路径")。 private System.Collections.Generic.List<云端库条目> 解析云端库条目(string json) { var 出 = new System.Collections.Generic.List<云端库条目>(); if (string.IsNullOrEmpty(json)) return 出; int i = json.IndexOf('['); if (i < 0) return 出; int 深 = 0, 对象起 = -1; for (; i < json.Length; i++) { char c = json[i]; if (c == '{') { if (深 == 0) 对象起 = i; 深++; } else if (c == '}') { 深--; if (深 == 0 && 对象起 >= 0) { string obj = json.Substring(对象起, i - 对象起 + 1); string 编号 = 取JSON串值(obj, "编号"); if (编号 != null && 编号.StartsWith(云库前缀, System.StringComparison.Ordinal)) { string 名称 = 取JSON串值(obj, "名称") ?? ""; string 显示名 = 名称, 路径 = ""; int 竖 = 名称.IndexOf('|'); if (竖 >= 0) { 显示名 = 名称.Substring(0, 竖); 路径 = 名称.Substring(竖 + 1); } long 大小 = 0; long.TryParse(取JSON串值(obj, "大小") ?? "0", out 大小); 出.Add(new 云端库条目 { 编号 = 编号, 显示名 = 显示名, 路径 = 路径, 时间 = 取JSON串值(obj, "时间") ?? "", 大小 = 大小 }); } 对象起 = -1; } } } return 出; } /// 从单个 JSON 对象文本取字符串字段(处理 \uXXXX 转义)。 private static string 取JSON串值(string obj, string key) { int k = obj.IndexOf("\"" + key + "\"", System.StringComparison.Ordinal); if (k < 0) return null; int 冒 = obj.IndexOf(':', k + key.Length + 2); if (冒 < 0) return null; int 起 = obj.IndexOf('"', 冒 + 1); if (起 < 0) return null; var sb = new System.Text.StringBuilder(); for (int i = 起 + 1; i < obj.Length; i++) { char c = obj[i]; if (c == '\\' && i + 1 < obj.Length) { char n = obj[++i]; if (n == 'u' && i + 4 < obj.Length) { sb.Append((char)System.Convert.ToInt32(obj.Substring(i + 1, 4), 16)); i += 4; } else sb.Append(n == 'n' ? '\n' : n); } else if (c == '"') break; else sb.Append(c); } return sb.ToString(); } private static string EscapeJsonStringStatic(string s) { if (s == null) return ""; return s.Replace("\\", "\\\\").Replace("\"", "\\\"") .Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t"); } /// 自动备份保留的份数(超过则删最旧)。 private const int AutoBackupKeepCount = 14; /// 自动备份标记文件:记录上次自动备份日期(yyyy-MM-dd),每天只备一次。 private string AutoBackupMarkPath { get { return System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"backup\last_auto_backup.txt"); } } /// /// 启动时检查是否需要自动备份: /// 当天没备过 → 静默把 全部数据(sjk\ 目录所有 db + shezhi\ 配置) 打成 zip /// 放到 backup\自动备份_yyyyMMdd_HHmmss.zip,然后清理超出保留数的旧包。 /// private void RunDailyAutoBackupIfNeeded() { try { string baseDir = AppDomain.CurrentDomain.BaseDirectory; string backupDir = System.IO.Path.Combine(baseDir, "backup"); if (!System.IO.Directory.Exists(backupDir)) System.IO.Directory.CreateDirectory(backupDir); // ① 当天已备过 → 跳过 string today = DateTime.Now.ToString("yyyy-MM-dd"); string markPath = AutoBackupMarkPath; if (System.IO.File.Exists(markPath)) { string last = System.IO.File.ReadAllText(markPath).Trim(); if (last == today) return; } // ② 收集要备份的文件(sjk\ 全部 + shezhi\ 全部,含子目录) var files = new System.Collections.Generic.List>(); // <相对路径, 绝对路径> foreach (string pattern in new string[] { @"sjk\*.db", @"sjk\bjb\*.db", @"shezhi\*.xml" }) { string[] hits = System.IO.Directory.GetFiles(baseDir, System.IO.Path.GetFileName(pattern), System.IO.SearchOption.TopDirectoryOnly); string subDir = System.IO.Path.GetDirectoryName(pattern); if (!string.IsNullOrEmpty(subDir) && subDir != ".") { string absSub = System.IO.Path.Combine(baseDir, subDir); if (System.IO.Directory.Exists(absSub)) hits = System.IO.Directory.GetFiles(absSub, System.IO.Path.GetFileName(pattern)); else hits = new string[0]; } foreach (string f in hits) files.Add(new System.Collections.Generic.KeyValuePair( f.Substring(baseDir.Length).TrimStart('\\', '/'), f)); } if (files.Count == 0) { Log("[自动备份] 没有找到可备份的数据文件,跳过"); return; } // ③ 复制到临时目录(.db 用 SQLite Backup API 避锁) string tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "成套报价_自动备份_" + System.Guid.NewGuid().ToString("N")); System.IO.Directory.CreateDirectory(tempDir); int okCount = 0; try { foreach (var kv in files) { try { string dstPath = System.IO.Path.Combine(tempDir, kv.Key.Replace('\\', System.IO.Path.DirectorySeparatorChar)); string dstDir = System.IO.Path.GetDirectoryName(dstPath); if (!System.IO.Directory.Exists(dstDir)) System.IO.Directory.CreateDirectory(dstDir); if (kv.Key.EndsWith(".db", System.StringComparison.OrdinalIgnoreCase)) BackupSQLiteDb(kv.Value, dstPath); else System.IO.File.Copy(kv.Value, dstPath, true); okCount++; } catch (Exception exFile) { Log("[自动备份] 单文件失败 " + kv.Key + ": " + exFile.Message); } } if (okCount == 0) return; // ④ 打包 string zipPath = System.IO.Path.Combine(backupDir, "自动备份_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".zip"); if (System.IO.File.Exists(zipPath)) System.IO.File.Delete(zipPath); System.IO.Compression.ZipFile.CreateFromDirectory(tempDir, zipPath); // ⑤ 写标记 + 清理超出保留数的旧包 System.IO.File.WriteAllText(markPath, today, new System.Text.UTF8Encoding(false)); CleanupOldAutoBackups(backupDir); Log("[自动备份] 完成: " + okCount + " 个文件 → " + zipPath); } finally { try { System.IO.Directory.Delete(tempDir, true); } catch { } } } catch (Exception ex) { // 自动备份失败绝不阻断启动 Log("[自动备份] 异常: " + ex.Message); } } /// 删除 backup\ 下超出保留数量的旧自动备份 zip(按文件名排序,名字含时间戳)。 private void CleanupOldAutoBackups(string backupDir) { try { var zips = new System.Collections.Generic.List( System.IO.Directory.GetFiles(backupDir, "自动备份_*.zip")); if (zips.Count <= AutoBackupKeepCount) return; zips.Sort(System.StringComparer.OrdinalIgnoreCase); // 文件名含 yyyyMMdd_HHmmss,字典序=时间序 int remove = zips.Count - AutoBackupKeepCount; for (int i = 0; i < remove; i++) { try { System.IO.File.Delete(zips[i]); Log("[自动备份] 清理旧备份: " + System.IO.Path.GetFileName(zips[i])); } catch { } } } catch { } } // ============================================================ // 箱体计算面板(tabPage14 / panel箱体计算) // ============================================================ // // 计算公式: // 面积(㎡) = (H×W×正视 + H×D×侧视 + W×D×俯视) / 1000000 // 其中 H/W/D 单位为 mm,正视/侧视/俯视为面数(整数) // 重量(KG) = 面积 × 板厚(mm) × 密度(g/cm³) / 1000 // 单台单价(元) = 面积 × 单价(元/㎡) × 报价系数 // // 用户只需输入:高(H)/宽(W)/深(D)/板厚/单价,其余自动计算。 // 密度默认 7.85 g/cm³(钢板),可修改。 // ============================================================ /// 箱体计算重入 guard,防止联动重算时反复触发 TextChanged。 private bool _is箱体Recalculating = false; /// 初始化箱体计算面板:填充下拉框默认项、挂接联动事件、首次计算。 private void Init箱体计算面板() { // ===== 下拉框默认项 ===== if (this.cbo箱体类型 != null && this.cbo箱体类型.Items.Count == 0) { this.cbo箱体类型.Items.Clear(); this.cbo箱体类型.Items.AddRange(new object[] { "GGD", "MNS", "GCK", "GCS", "KYN28", "XGN", "HXGN", "XL-21", "PGL", "DOMINO", "CUBIC", "8PT", "西门子" }); if (this.cbo箱体类型.Items.Count > 0) this.cbo箱体类型.SelectedIndex = 0; } if (this.cbo板材 != null && this.cbo板材.Items.Count == 0) { this.cbo板材.Items.Clear(); this.cbo板材.Items.AddRange(new object[] { "冷轧钢板", "热轧钢板", "镀锌钢板", "不锈钢", "覆铝锌板" }); if (this.cbo板材.Items.Count > 0) this.cbo板材.SelectedIndex = 0; } if (this.cbo板厚 != null && this.cbo板厚.Items.Count == 0) { this.cbo板厚.Items.Clear(); this.cbo板厚.Items.AddRange(new object[] { "1.0", "1.2", "1.5", "2.0", "2.5", "3.0", "4.0", "5.0" }); if (this.cbo板厚.Items.Count > 0) this.cbo板厚.SelectedIndex = 2; // 默认 1.5 } if (this.cbo型号 != null && this.cbo型号.Items.Count == 0) { this.cbo型号.Items.Clear(); this.cbo型号.Items.AddRange(new object[] { "标准型", "加强型", "豪华型", "经济型" }); if (this.cbo型号.Items.Count > 0) this.cbo型号.SelectedIndex = 0; } // ===== 尺寸输入框默认值(空时填 0,避免 Parse 失败) ===== if (this.cboHeightH != null && string.IsNullOrWhiteSpace(this.cboHeightH.Text)) this.cboHeightH.Text = "0"; if (this.cboWidthW != null && string.IsNullOrWhiteSpace(this.cboWidthW.Text)) this.cboWidthW.Text = "0"; if (this.cboDepthD != null && string.IsNullOrWhiteSpace(this.cboDepthD.Text)) this.cboDepthD.Text = "0"; // ===== 挂接联动事件:任一相关输入变化都触发全量重算 ===== // 尺寸 / 板厚 / 单价 / 密度 变化 → 重算 面积/重量/单台单价 if (this.cboHeightH != null) this.cboHeightH.TextChanged += (s, e) => Recalc箱体全部(); if (this.cboWidthW != null) this.cboWidthW.TextChanged += (s, e) => Recalc箱体全部(); if (this.cboDepthD != null) this.cboDepthD.TextChanged += (s, e) => Recalc箱体全部(); if (this.cbo板厚 != null) this.cbo板厚.TextChanged += (s, e) => Recalc箱体全部(); if (this.txt箱体单价 != null) this.txt箱体单价.TextChanged += (s, e) => Recalc单台单价(); if (this.txt密度 != null) this.txt密度.TextChanged += (s, e) => Recalc箱体重量(); // 面数变化 → 重算面积(进而重算重量/单价) if (this.nud正视 != null) this.nud正视.ValueChanged += (s, e) => Recalc箱体全部(); if (this.nud侧视 != null) this.nud侧视.ValueChanged += (s, e) => Recalc箱体全部(); if (this.nud俯视 != null) this.nud俯视.ValueChanged += (s, e) => Recalc箱体全部(); // 报价系数变化 → 重算单台单价 if (this.nud报价系数 != null) this.nud报价系数.ValueChanged += (s, e) => Recalc单台单价(); // 箱体类型/型号/板材 变化时刷新规格文本 // 注意:型号/板材/板厚 是否拼入规格,由它各自旁边的复选框(chk型号/chk板材/chk板厚)控制 if (this.cbo箱体类型 != null) this.cbo箱体类型.SelectedIndexChanged += (s, e) => Update箱体规格(); if (this.cbo型号 != null) this.cbo型号.SelectedIndexChanged += (s, e) => Update箱体规格(); // 板材变化时刷新规格文本 + 同步密度(不同材质密度不同) if (this.cbo板材 != null) this.cbo板材.SelectedIndexChanged += (s, e) => { Update箱体规格(); Sync密度By板材(); }; // 复选框状态切换 → 重新拼接规格(勾上才显示对应信息) if (this.chk型号 != null) this.chk型号.CheckedChanged += (s, e) => Update箱体规格(); if (this.chk板材 != null) this.chk板材.CheckedChanged += (s, e) => Update箱体规格(); if (this.chk板厚 != null) this.chk板厚.CheckedChanged += (s, e) => Update箱体规格(); // 尺寸/板厚 变化时也同步规格 if (this.cboHeightH != null) this.cboHeightH.TextChanged += (s, e) => Update箱体规格(); if (this.cboWidthW != null) this.cboWidthW.TextChanged += (s, e) => Update箱体规格(); if (this.cboDepthD != null) this.cboDepthD.TextChanged += (s, e) => Update箱体规格(); if (this.cbo板厚 != null) this.cbo板厚.TextChanged += (s, e) => Update箱体规格(); // ===== 工具栏按钮:插入/替换 ===== if (this.toolStripButton9 != null) this.toolStripButton9.Click += 箱体插入_Click; if (this.toolStripButton10 != null) this.toolStripButton10.Click += 箱体替换_Click; // 首次计算(初始化默认值后立即算一次) Recalc箱体全部(); Update箱体规格(); } /// /// 箱体面积重算: /// 面积(㎡) = (H×W×正视 + H×D×侧视 + W×D×俯视) / 1000000 /// private void Recalc箱体面积() { if (this.txt面积 == null) return; decimal H = TryParseDecimalOrZero(this.cboHeightH != null ? this.cboHeightH.Text : "0"); decimal W = TryParseDecimalOrZero(this.cboWidthW != null ? this.cboWidthW.Text : "0"); decimal D = TryParseDecimalOrZero(this.cboDepthD != null ? this.cboDepthD.Text : "0"); decimal front = this.nud正视 != null ? this.nud正视.Value : 0m; decimal side = this.nud侧视 != null ? this.nud侧视.Value : 0m; decimal top = this.nud俯视 != null ? this.nud俯视.Value : 0m; // (H*W*正视 + H*D*侧视 + W*D*俯视) / 1000000 decimal area = (H * W * front + H * D * side + W * D * top) / 1000000m; if (area < 0) area = 0m; _is箱体Recalculating = true; try { this.txt面积.Text = area.ToString("0.######"); } finally { _is箱体Recalculating = false; } } /// /// 箱体重量重算: /// 重量(KG) = 面积(㎡) × 板厚(mm) × 密度(g/cm³) / 1000 /// private void Recalc箱体重量() { if (this.txt箱体重量 == null) return; decimal area = TryParseDecimalOrZero(this.txt面积 != null ? this.txt面积.Text : "0"); decimal thick = TryParseDecimalOrZero(this.cbo板厚 != null ? this.cbo板厚.Text : "0"); decimal density = TryParseDecimalOrZero(this.txt密度 != null ? this.txt密度.Text : "0"); decimal weight = area * thick * density / 1000m; if (weight < 0) weight = 0m; _is箱体Recalculating = true; try { this.txt箱体重量.Text = weight.ToString("0.######"); } finally { _is箱体Recalculating = false; } } /// /// 单台单价重算: /// 单台单价(元) = 面积(㎡) × 单价(元/㎡) × 报价系数 /// private void Recalc单台单价() { if (this.txt单台单价 == null) return; if (_is箱体Recalculating) return; decimal area = TryParseDecimalOrZero(this.txt面积 != null ? this.txt面积.Text : "0"); decimal price = TryParseDecimalOrZero(this.txt箱体单价 != null ? this.txt箱体单价.Text : "0"); decimal factor = this.nud报价系数 != null ? this.nud报价系数.Value : 1m; decimal total = area * price * factor; if (total < 0) total = 0m; _is箱体Recalculating = true; try { this.txt单台单价.Text = total.ToString("0.##"); } finally { _is箱体Recalculating = false; } } /// 触发一次完整的箱体重算:面积 → 重量 + 单台单价。 private void Recalc箱体全部() { if (_is箱体Recalculating) return; _is箱体Recalculating = true; try { // 临时解除 guard,让三个方法各自能写入结果 _is箱体Recalculating = false; Recalc箱体面积(); _is箱体Recalculating = false; Recalc箱体重量(); _is箱体Recalculating = false; Recalc单台单价(); } finally { _is箱体Recalculating = false; } } /// /// 根据当前类型/板材/尺寸/板厚自动生成箱体规格文本。 /// 默认只显示尺寸(如 "700×900×280");其它信息(型号/板材/板厚) /// 必须勾选对应复选框(chk型号/chk板材/chk板厚)才会追加到规格。 /// private void Update箱体规格() { if (this.txt箱体规格 == null) return; string model = (this.cbo型号 != null ? (this.cbo型号.Text ?? "") : "").Trim(); string material = (this.cbo板材 != null ? (this.cbo板材.Text ?? "") : "").Trim(); string thick = (this.cbo板厚 != null ? (this.cbo板厚.Text ?? "") : "").Trim(); string H = (this.cboHeightH != null ? (this.cboHeightH.Text ?? "") : "").Trim(); string W = (this.cboWidthW != null ? (this.cboWidthW.Text ?? "") : "").Trim(); string D = (this.cboDepthD != null ? (this.cboDepthD.Text ?? "") : "").Trim(); // 复选框:勾上才把对应字段拼入规格(默认不勾 → 只显示尺寸) bool showModel = this.chk型号 != null && this.chk型号.Checked; bool showMaterial = this.chk板材 != null && this.chk板材.Checked; bool showThick = this.chk板厚 != null && this.chk板厚.Checked; System.Text.StringBuilder sb = new System.Text.StringBuilder(); // 尺寸始终显示,顺序:宽(W)×高(H)×深(D) sb.Append(W).Append("×").Append(H).Append("×").Append(D); // 勾选后才追加其它信息(空值跳过) if (showModel && !string.IsNullOrEmpty(model)) { sb.Append(" ").Append(model); } if (showMaterial && !string.IsNullOrEmpty(material)) { sb.Append(" ").Append(material); } if (showThick && !string.IsNullOrEmpty(thick)) { sb.Append(" 板厚").Append(thick); } string spec = sb.ToString().Trim(); // 多个连续空格合并为一个 while (spec.Contains(" ")) spec = spec.Replace(" ", " "); this.txt箱体规格.Text = spec; } /// /// 根据当前板材自动填入对应密度(g/cm³)。用户也可手动覆盖。 /// 冷轧/热轧/镀锌钢板 = 7.85,不锈钢 = 7.93,铝板 = 2.7。 /// 未匹配的板材保持当前密度不变。 /// private void Sync密度By板材() { if (this.txt密度 == null || this.cbo板材 == null) return; string material = (this.cbo板材.Text ?? "").Trim(); string density; if (string.IsNullOrEmpty(material)) return; else if (material.IndexOf("铝") >= 0) density = "2.7"; else if (material.IndexOf("不锈钢") >= 0) density = "7.93"; else if (material.IndexOf("钢") >= 0 || material.IndexOf("铁") >= 0 || material.IndexOf("镀锌") >= 0 || material.IndexOf("覆铝锌") >= 0) density = "7.85"; else return; // 未知板材,保持当前值 this.txt密度.Text = density; // 触发重量重算(已有 TextChanged 订阅) } /// /// 箱体[插入]:把当前箱体计算结果作为一行元件插入元件表(advancedDataGridView5)。 /// 需要先在报价页打开项目并选中图号/箱号,否则提示。 /// 映射到元件表字段: /// 元件名称 = "箱体" 规格 = 箱体规格 单位 = "台" 数量 = 1 /// 表价 = 单台单价 类型 = "箱体" 备注 = 备注 /// 品牌 = 生产厂商 采购系数/报出系数 = 1 /// private void 箱体插入_Click(object sender, EventArgs e) { if (_yuanjianTable == null || string.IsNullOrWhiteSpace(_currentProjectId)) { System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中打开一个项目并选择图号/箱号,再插入箱体。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // ★ 该箱号还没建过元件表时自动建表(否则 NewRow 后写"序号"列会抛异常) if (!确保元件表存在()) { System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中打开一个项目并选择图号/箱号,再插入箱体。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string spec = (this.txt箱体规格 != null ? (this.txt箱体规格.Text ?? "") : "").Trim(); if (string.IsNullOrWhiteSpace(spec)) { System.Windows.Forms.MessageBox.Show(this, "箱体规格为空,请先填写尺寸/类型等参数。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 单台单价作为表价 decimal danTaiPrice = TryParseDecimalOrZero(this.txt单台单价 != null ? this.txt单台单价.Text : "0"); // 数量默认 1 台 decimal count = 1m; string unit = "台"; string remark = (this.txt备注 != null ? (this.txt备注.Text ?? "") : "").Trim(); string brand = (this.txt箱体厂商 != null ? (this.txt箱体厂商.Text ?? "") : "").Trim(); // 派生列(采购系数=1,报出系数=1) decimal caiGouDanJia = danTaiPrice; decimal caiGouJia = caiGouDanJia * count; decimal baoChuDanJia = caiGouDanJia; decimal baoChuJia = baoChuDanJia * count; // ★ 批量插入(2026-10-01):多选箱号时对每个选中箱号执行同样的插入; // 单选=false 返回后走原逻辑 System.Action 插入动作 = delegate { // 提交正在编辑的单元格 this.advancedDataGridView5.EndEdit(); int selectedIndex = GetSelectedRowIndex(this.advancedDataGridView5); var row = _yuanjianTable.NewRow(); row["序号"] = "1"; // InsertRowBelowSelected 会自动重编号 row["元件名称"] = "箱体"; row["规格"] = spec; row["单位"] = unit; row["数量"] = (double)count; row["表价"] = (double)danTaiPrice; row["采购系数"] = "1"; row["采购单价"] = (double)caiGouDanJia; row["采购价"] = (double)caiGouJia; row["报出系数"] = "1"; row["报出单价"] = (double)baoChuDanJia; row["报出价"] = (double)baoChuJia; row["图纸规格"] = ""; row["品牌"] = brand; row["系列"] = ""; row["类型"] = "箱体"; row["备注"] = remark; InsertRowBelowSelected(_yuanjianTable, row, selectedIndex); // ★ 虚拟模式收尾:同步网格行数+全表保存,并重算聚合 元件表插行收尾(); 重算聚合(); // 滚动到新行 int idx = _yuanjianTable.Rows.IndexOf(row); if (idx >= 0) { try { this.advancedDataGridView5.FirstDisplayedScrollingRowIndex = idx; } catch { } } }; if (工具窗口批量插入(插入动作)) return; 插入动作(); Log("[箱体插入] 已插入到元件表: 规格=" + spec + " 单台单价=" + danTaiPrice); } /// /// 箱体[替换]:用当前箱体计算结果覆盖元件表选中行的内容。 /// 仅当选中行的"类型"列值为"箱体"时才允许替换; /// 类型为空或不是"箱体"时提示并跳过(不覆盖非箱体元件)。 /// private void 箱体替换_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_currentProjectId)) { System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中打开一个项目并选择图号/箱号,再替换箱体。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string spec0 = (this.txt箱体规格 != null ? (this.txt箱体规格.Text ?? "") : "").Trim(); if (string.IsNullOrWhiteSpace(spec0)) { System.Windows.Forms.MessageBox.Show(this, "箱体规格为空,请先填写尺寸/类型等参数。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // ★ 选型页分支:选型页有选中行→目标=选型页选中行(判定用"有选中行"防焦点漂移) if (替换目标在选型页()) { decimal 价0 = TryParseDecimalOrZero(this.txt单台单价 != null ? this.txt单台单价.Text : "0"); 库替换走选型页("箱体", spec0, "", "", 价0.ToString(System.Globalization.CultureInfo.InvariantCulture), "1", "箱体"); return; } if (_yuanjianTable == null) { System.Windows.Forms.MessageBox.Show(this, "请先在报价软件中选择图号/箱号。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } DataGridViewRow dgvRow = GetFirstSelectedRow(this.advancedDataGridView5); if (dgvRow == null) { System.Windows.Forms.MessageBox.Show(this, "请先在元件表中选择一行要替换的箱体。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 类型判定:仅"箱体"行才允许替换 string 当前类型 = GetCellValueAsString(dgvRow, "类型"); if (!string.Equals(当前类型, "箱体", System.StringComparison.Ordinal)) { System.Windows.Forms.MessageBox.Show(this, "选中行的类型为「" + (string.IsNullOrWhiteSpace(当前类型) ? "(空)" : 当前类型) + "」,不是「箱体」。只允许替换类型为箱体的行。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string spec = (this.txt箱体规格 != null ? (this.txt箱体规格.Text ?? "") : "").Trim(); if (string.IsNullOrWhiteSpace(spec)) { System.Windows.Forms.MessageBox.Show(this, "箱体规格为空,请先填写尺寸/类型等参数。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } this.advancedDataGridView5.EndEdit(); decimal danTaiPrice = TryParseDecimalOrZero(this.txt单台单价 != null ? this.txt单台单价.Text : "0"); decimal count = 1m; string unit = "台"; string remark = (this.txt备注 != null ? (this.txt备注.Text ?? "") : "").Trim(); string brand = (this.txt箱体厂商 != null ? (this.txt箱体厂商.Text ?? "") : "").Trim(); decimal caiGouDanJia = danTaiPrice; decimal caiGouJia = caiGouDanJia * count; decimal baoChuDanJia = caiGouDanJia; decimal baoChuJia = baoChuDanJia * count; // 序号保留原值,其余字段覆盖 SetCellValue(dgvRow, "元件名称", "箱体"); SetCellValue(dgvRow, "规格", spec); SetCellValue(dgvRow, "单位", unit); SetCellValue(dgvRow, "数量", (double)count); SetCellValue(dgvRow, "表价", (double)danTaiPrice); SetCellValue(dgvRow, "采购系数", "1"); SetCellValue(dgvRow, "采购单价", (double)caiGouDanJia); SetCellValue(dgvRow, "采购价", (double)caiGouJia); SetCellValue(dgvRow, "报出系数", "1"); SetCellValue(dgvRow, "报出单价", (double)baoChuDanJia); SetCellValue(dgvRow, "报出价", (double)baoChuJia); SetCellValue(dgvRow, "品牌", brand); SetCellValue(dgvRow, "类型", "箱体"); SetCellValue(dgvRow, "备注", remark); Log("[箱体替换] 已替换元件表选中行: 规格=" + spec + " 单台单价=" + danTaiPrice); } } // ============================================================ // 简单输入对话框(新建图号/箱号/元件时输入名称) // ============================================================ /// /// 一个极简的输入对话框:返回用户输入的字符串, /// 点击取消或关闭窗口返回 null。 /// internal static class InputDialog { public static string Show(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.MaximizeBox = false; form.MinimizeBox = false; form.ClientSize = new System.Drawing.Size(360, 120); var lbl = new Label(); lbl.Text = label; lbl.AutoSize = true; lbl.Location = new System.Drawing.Point(12, 15); var txt = new TextBox(); txt.Text = defaultValue ?? ""; txt.Location = new System.Drawing.Point(12, 40); txt.Size = new System.Drawing.Size(330, 22); txt.SelectAll(); var btnOk = new Button(); btnOk.Text = "确定"; btnOk.DialogResult = DialogResult.OK; btnOk.Location = new System.Drawing.Point(180, 80); btnOk.Size = new System.Drawing.Size(75, 28); var btnCancel = new Button(); btnCancel.Text = "取消"; btnCancel.DialogResult = DialogResult.Cancel; btnCancel.Location = new System.Drawing.Point(267, 80); btnCancel.Size = new System.Drawing.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; } return null; } } } }