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;
}
}
}
}