Files
ctbjrj/成套报价软件/MainForm.母排与备份.cs

2129 lines
105 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.IO.Compression;
using System.Windows.Forms;
using Microsoft.Win32;
using Smart.CustomComponents;
namespace 成套报价软件
{
/// <summary>主窗口 拆分文件:由 主窗口.cs 按模块拆出,同一 partial class。</summary>
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。
// ============================================================
/// <summary>铜价数据接口地址。</summary>
private const string 铜价接口地址 = "https://www.ccmn.cn/shop/historyData/getCjysAndCjysw";
/// <summary>首页 JS 里写死的 AES 密钥(ccmn-index.js: keys = "ccmnCjysw1455881")。</summary>
private const string 铜价AES密钥 = "ccmnCjysw1455881";
/// <summary>btn刷新 点击:异步抓取 1#铜 或 A00铝 均价和日期,填入 txt当前铜价 / txt更新日期。
/// 根据 rb铝排.Checked 决定抓取目标:铜排→1#铜,铝排→A00铝。</summary>
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 = 原文本;
}
}
/// <summary>
/// 用 AES-ECB(PKCS7) 解密 ccmn 接口返回的 Base64 密文。
/// 密钥见 铜价AES密钥 常量(与首页 ccmn-index.js 的 decryptAES 一致)。
/// </summary>
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
// ============================================================
/// <summary>
/// 重算 txt铜价(元/Kg) = (txt当前铜价 + txt加工费) / 1000。
/// 当前铜价、加工费单位均为 元/吨,1 吨 = 1000 公斤,所以除以 1000 得 元/Kg。
/// 任一输入为空或非数字时不写入(保留 txt铜价 原值,避免覆盖用户手输)。
/// 结果四舍五入取整(不显示小数点)。
/// </summary>
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");
}
/// <summary>
/// 重算 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 位小数)。
/// 任一输入为空/非数字时不写入(保留原值)。
/// </summary>
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");
}
/// <summary>
/// 材质切换(铜排↔铝排)时统一处理:
/// 1. 切换 lbl当前铜价 标签文本(当前铜价↔当前铝价)
/// 2. 重新加载 advancedDataGridView8 规格表数据(铜排库/铝排库)
/// 3. 清空价格/规格/层宽厚等文本框(避免残留旧材质数据)
/// 4. 加载对应材质的加工费参数
/// 5. 自动刷新价格(铜价/铝价)
/// </summary>
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); }
}
/// <summary>
/// 按材质加载铜排规格表(advancedDataGridView8):
/// 铜排 → sjk\advancedDataGridView8Table.db (TMY 数据)
/// 铝排 → sjk\advancedDataGridView8Table_LMY.db (LMY 数据)
/// 与 ui.cs 的 LoadSqliteData_advancedDataGridView8 同绑定方式
/// (AutoGenerateColumns=false + DataSource),只是路径按材质选。
/// </summary>
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);
}
/// <summary>母排参数配置文件路径(铜排/铝排各自独立,exe 同目录的 shezhi 子目录)。
/// 铜排→铜排参数.config.xml,铝排→铝排参数.config.xml。</summary>
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\" + 文件名);
}
/// <summary>最近打开的项目配置文件路径(记录最后一次进入的项目编号)。</summary>
private static readonly string 最近项目配置路径 = System.IO.Path.Combine(
System.AppDomain.CurrentDomain.BaseDirectory, @"shezhi\最近项目.config.xml");
/// <summary>把项目编号保存为"最近打开的项目"。</summary>
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); }
}
/// <summary>读取最近打开的项目编号;没有返回空串。</summary>
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 ""; }
}
/// <summary>从配置文件加载母排参数(目前:加工费),填入对应文本框。
/// 铜排/铝排各有独立配置文件,由 Get母排参数配置路径() 决定。</summary>
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)桥接:给子窗体取数据/回调刷新
// ============================================================
/// <summary>当前打开的项目编号(给调母排窗口定位报价库)。</summary>
public string GetCurrentProjectId()
{
return _currentProjectId ?? "";
}
/// <summary>
/// 把内存中未保存的三表编辑(图号/箱号/元件,含缓存)强制落盘。
/// 调母排窗口打开前调用——它直接读数据库,必须先看到最新编辑。
/// </summary>
public void FlushQuoteTablesToDb()
{
try { SaveAllToDatabaseSilent(); }
catch (Exception ex) { Log("[调母排] 预落盘失败: " + ex.Message); }
}
/// <summary>母排页当前价格文本(元/吨;铜排模式=1#铜,铝排模式=A00铝)。</summary>
public string Get母排当前价格文本()
{
return this.txt当前铜价 == null ? "" : (this.txt当前铜价.Text ?? "");
}
/// <summary>母排页当前材质:true=铝排,false=铜排。</summary>
public bool Get母排是铝排()
{
return this.rb铝排 != null && this.rb铝排.Checked;
}
/// <summary>读取指定材质的加工费(直接读配置文件,不依赖当前材质状态)。
/// 铝=false→shezhi\铜排参数.config.xml;铝=true→shezhi\铝排参数.config.xml。
/// 读不到返回空串(调母排窗口显示空,用户可手填)。</summary>
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 ""; }
}
/// <summary>
/// 把抓到的金属价格缓存进对应材质的配置文件(铜排参数.config.xml / 铝排参数.config.xml)。
/// 在 加工费 节点旁增加 价格(元/吨) 和 价格日期 两个节点。
/// 读配置时按需取,没有就用主窗口当前文本框值。
/// </summary>
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 { }
}
/// <summary>
/// 读取缓存的金属价格(元/吨):返回 [价格, 日期]。
/// 铝=false→铜价;铝=true→铝价。没有缓存返回 ["",""]。
/// 给调母排窗口用——不发网络请求,直接用主窗口上次抓到的价。
/// </summary>
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[] { "", "" }; }
}
/// <summary>
/// 调母排窗口联网补抓到价格后,把缓存写进配置文件(公开包装)。
/// 与主窗口抓价时写的 Save母排价格缓存 同一文件同一节点。
/// </summary>
public void Save母排价格缓存Pub(bool 是铝排, string 价格吨, string 日期)
{
Save母排价格缓存(是铝排, 价格吨, 日期);
}
/// <summary>
/// 调母排窗口「更新到项目」后的回调:
/// 清缓存 → 全量重算数据库派生列与汇总 → 重载报价页 → 刷新项目列表。
/// (与选型页 RefreshQuotePageAfterSelectionEdit 同套路)
/// </summary>
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);
}
}
/// <summary>把母排参数(目前:加工费)保存到配置文件。Leave 失焦 / 关窗兜底均调用。
/// 铜排/铝排各有独立配置文件,由 Get母排参数配置路径() 决定。</summary>
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)选中 → 解析规格填入文本框
// ============================================================
/// <summary>铜排规格表选中变化:解析"规格"列文本,自动填入 txt层数/txt宽度/txt厚度。</summary>
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 = 厚度;
}
/// <summary>
/// 解析铜排规格文本,返回 层数/宽度/厚度 三个分量。
/// 规则(与 advancedDataGridView8Table 规格列一致):
/// 无括号 "TMY-15×3" → 去掉类型前缀后 "15×3" → 层数=1, 宽度=15, 厚度=3
/// 有括号 "TMY-2(60×6)" → 去掉类型前缀后 "2(60×6)"→ 层数=2, 宽度=60, 厚度=6
/// 乘号支持 × x X * (全角/半角),宽度/厚度支持小数。
/// </summary>
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单价,类型=母排
// ============================================================
/// <summary>
/// 母排规格表中选好规格后,点[插入]把当前规格作为一行新元件插入到元件表(advancedDataGridView5)。
/// 新行字段:
/// 序号 = 当前最大序号 + 1
/// 元件名称 = "母排"
/// 规格 = txt规格.Text
/// 单位 = comboBox1.Text
/// 数量 = txt长度.Text
/// 表价 = txt单价.Text(每米单价)
/// 类型 = "母排"
/// 其余派生列(采购单价/采购价/报出单价/报出价)按"采购系数=1,报出系数=1"和表价×数量算出。
/// </summary>
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("[母排插入] 已插入到元件表: 规格=" + 规格 + " 长度=" + 数量 + " 表价=" + 表价);
}
/// <summary>
/// 母排[替换]:用当前规格覆盖元件表选中行的内容。
/// 仅当选中行的"类型"列值为"母排"时才允许替换;
/// 如果"类型"为空或不是"母排",提示用户并跳过(不覆盖非母排元件)。
/// </summary>
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("[母排替换] 已替换元件表选中行: 规格=" + 规格 + " 长度=" + 数量 + " 表价=" + 表价);
}
/// <summary>
/// 供 GCS 推荐铜排窗口调用:把选型出的铜排(规格+米数)插入元件表。
/// 表价按母排界面当前铜价自动计算每米单价:
/// 每米单价 = 铜价(元/Kg) × 层数 × 宽度 × 厚度 × 密度 / 1000
/// 密度按规格前缀判断:LMY→铝排 2.7,其余(如 TMY)→铜排 8.9。
/// </summary>
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;
}
/// <summary>把文本框值解析为 decimal;空/非数字返回 0。</summary>
private static decimal TryParseDecimalOrZero(string text)
{
decimal d;
if (decimal.TryParse((text ?? "").Trim(), out d)) return d;
return 0m;
}
/// <summary>取元件表下一个序号 = 已有行的最大数值序号 + 1(空表返回 1)。</summary>
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;
}
/// <summary>把值写到 DataGridViewRow 的指定列(按 DataPropertyName 或 HeaderText 匹配)。</summary>
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);
}
// ============================================================
// 备份/恢复 库数据(用户库/柜体库/方案库/收藏/企业信息)
// ============================================================
/// <summary>需要备份的文件列表(相对于 exe 目录)。</summary>
private static readonly string[] BackupFiles = new string[]
{
@"sjk\advancedDataGridView7Table.db", // 用户元件库
@"sjk\advancedDataGridView10Table.db", // 柜体库
@"sjk\fak.db", // 方案库
@"shezhi\元件库收藏.config.xml", // 元件库收藏
@"shezhi\企业信息.config.xml", // 企业信息
};
/// <summary>备份:先选目标(云端/本地,可记住选择),再走对应流程。</summary>
private void btn备份_Click(object sender, EventArgs e)
{
var 目标 = 备份目标选择.询问(false);
if (目标 == 备份目标.云端) { 备份库到云端(); return; }
if (目标 == 备份目标.本地文件) { 备份到本地文件(); return; }
}
/// <summary>原本地备份流程(从 btn备份_Click 抽出)。</summary>
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);
}
}
/// <summary>恢复:先选来源(云端/本地,可记住选择),再走对应流程。</summary>
private void btn恢复_Click(object sender, EventArgs e)
{
var 目标 = 备份目标选择.询问(true);
if (目标 == 备份目标.云端) { 从云端恢复库(); return; }
if (目标 == 备份目标.本地文件) { 从本地文件恢复(); return; }
}
/// <summary>原本地恢复流程(从 btn恢复_Click 抽出)。</summary>
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 份,超出自动删除最旧的。全程静默,失败只记日志。
// ============================================================
// ============================================================
// 云端库备份/恢复(会员服务器,按登录手机号隔离;复用项目云接口)
// ============================================================
/// <summary>云端库备份的编号前缀(项目云接口按该前缀区分库条目)。</summary>
private const string 云库前缀 = "LIB_";
/// <summary>收集全部可云备份的库(BackupFiles + 铜排/铝排库 + 方案库目录每个方案 db)。
/// 返回 (云端编号, 本地相对路径) 列表。</summary>
private System.Collections.Generic.List<System.Collections.Generic.KeyValuePair<string, string>> 收集云端库清单()
{
var 出 = new System.Collections.Generic.List<System.Collections.Generic.KeyValuePair<string, string>>();
出.Add(new System.Collections.Generic.KeyValuePair<string, string>(云库前缀 + "铜排库", @"sjk\advancedDataGridView8Table.db"));
出.Add(new System.Collections.Generic.KeyValuePair<string, string>(云库前缀 + "铝排库", @"sjk\advancedDataGridView8Table_LMY.db"));
foreach (string rel in BackupFiles)
出.Add(new System.Collections.Generic.KeyValuePair<string, string>(云库前缀 + 编号自相对路径(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<string, string>(
云库前缀 + "方案_" + System.IO.Path.GetFileNameWithoutExtension(f),
"sjk\\方案库\\" + System.IO.Path.GetFileName(f)));
}
return 出;
}
/// <summary>相对路径→云端编号片段(sjk\fak.db → fak方案库(含公式表);其余去扩展名/换可读名)。</summary>
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) : 云编号; }
/// <summary>备份全部库到云端(逐个上传;db 用 BackupSQLiteDb 防锁,xml 直接复制)。</summary>
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);
}
/// <summary>上传单个库到云端(meta 名称="显示名|原始路径",还原时定位用)。</summary>
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 大小; }
/// <summary>从云端恢复库:列出 LIB_ 条目勾选,下载 zip 按原始路径覆盖(覆盖前 .bak)。</summary>
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);
}
}
/// <summary>解析 /api/proj/list 响应,取 LIB_ 开头的条目(名称="显示名|路径")。</summary>
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 出;
}
/// <summary>从单个 JSON 对象文本取字符串字段(处理 \uXXXX 转义)。</summary>
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");
}
/// <summary>自动备份保留的份数(超过则删最旧)。</summary>
private const int AutoBackupKeepCount = 14;
/// <summary>自动备份标记文件:记录上次自动备份日期(yyyy-MM-dd),每天只备一次。</summary>
private string AutoBackupMarkPath
{
get { return System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"backup\last_auto_backup.txt"); }
}
/// <summary>
/// 启动时检查是否需要自动备份:
/// 当天没备过 → 静默把 全部数据(sjk\ 目录所有 db + shezhi\ 配置) 打成 zip
/// 放到 backup\自动备份_yyyyMMdd_HHmmss.zip,然后清理超出保留数的旧包。
/// </summary>
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<System.Collections.Generic.KeyValuePair<string, string>>(); // <相对路径, 绝对路径>
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<string, string>(
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);
}
}
/// <summary>删除 backup\ 下超出保留数量的旧自动备份 zip(按文件名排序,名字含时间戳)。</summary>
private void CleanupOldAutoBackups(string backupDir)
{
try
{
var zips = new System.Collections.Generic.List<string>(
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³(钢板),可修改。
// ============================================================
/// <summary>箱体计算重入 guard,防止联动重算时反复触发 TextChanged。</summary>
private bool _is箱体Recalculating = false;
/// <summary>初始化箱体计算面板:填充下拉框默认项、挂接联动事件、首次计算。</summary>
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箱体规格();
}
/// <summary>
/// 箱体面积重算:
/// 面积(㎡) = (H×W×正视 + H×D×侧视 + W×D×俯视) / 1000000
/// </summary>
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; }
}
/// <summary>
/// 箱体重量重算:
/// 重量(KG) = 面积(㎡) × 板厚(mm) × 密度(g/cm³) / 1000
/// </summary>
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; }
}
/// <summary>
/// 单台单价重算:
/// 单台单价(元) = 面积(㎡) × 单价(元/㎡) × 报价系数
/// </summary>
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; }
}
/// <summary>触发一次完整的箱体重算:面积 → 重量 + 单台单价。</summary>
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;
}
}
/// <summary>
/// 根据当前类型/板材/尺寸/板厚自动生成箱体规格文本。
/// 默认只显示尺寸(如 "700×900×280");其它信息(型号/板材/板厚)
/// 必须勾选对应复选框(chk型号/chk板材/chk板厚)才会追加到规格。
/// </summary>
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;
}
/// <summary>
/// 根据当前板材自动填入对应密度(g/cm³)。用户也可手动覆盖。
/// 冷轧/热轧/镀锌钢板 = 7.85,不锈钢 = 7.93,铝板 = 2.7。
/// 未匹配的板材保持当前密度不变。
/// </summary>
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 订阅)
}
/// <summary>
/// 箱体[插入]:把当前箱体计算结果作为一行元件插入元件表(advancedDataGridView5)。
/// 需要先在报价页打开项目并选中图号/箱号,否则提示。
/// 映射到元件表字段:
/// 元件名称 = "箱体" 规格 = 箱体规格 单位 = "台" 数量 = 1
/// 表价 = 单台单价 类型 = "箱体" 备注 = 备注
/// 品牌 = 生产厂商 采购系数/报出系数 = 1
/// </summary>
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);
}
/// <summary>
/// 箱体[替换]:用当前箱体计算结果覆盖元件表选中行的内容。
/// 仅当选中行的"类型"列值为"箱体"时才允许替换;
/// 类型为空或不是"箱体"时提示并跳过(不覆盖非箱体元件)。
/// </summary>
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);
}
}
// ============================================================
// 简单输入对话框(新建图号/箱号/元件时输入名称)
// ============================================================
/// <summary>
/// 一个极简的输入对话框:返回用户输入的字符串,
/// 点击取消或关闭窗口返回 null。
/// </summary>
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;
}
}
}
}