feat: 增强数据导入功能与用户界面优化

- 更新 README.md,强调 Area 路径为必填项,并增加加载 Area 列表的功能,确保用户在导入时不会遗漏必要信息。
- 修改 Config.cs 和 config.json,调整 SkipExistingTitles 默认值为 false,以便用户首次导入时能看到真实冲突和报错。
- 在 MainForm.cs 中添加加载 Area 列表的按钮,优化用户交互体验。
- 更新 ExcelService.cs,增强模板导出逻辑,确保用户能够更方便地使用自定义模板。
- 调整界面布局,确保 Area 路径与迭代路径的选择更加直观,提升整体用户体验。
This commit is contained in:
chenshoulin
2026-11-02 10:40:52 +08:00
parent ffc210f827
commit 14f5e32262
7 changed files with 409 additions and 91 deletions
+110 -15
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using ClosedXML.Excel;
namespace TfsExcelImporter
@@ -45,35 +46,129 @@ namespace TfsExcelImporter
// --------------------------------------------------------- 默认模板
/// <summary>
/// 把默认模板原样复制到目标路径。优先用 exe 同目录的同名文件(可由现场替换),
/// 没有则从 exe 内嵌资源释放——单文件便携分发时无需附带模板。
/// </summary>
public static void CopyTemplate(string path)
/// <summary>exe 同目录的模板路径(现场可替换,存在时优先于内嵌模板)。</summary>
public static string LocalTemplatePath
{
var src = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName);
if (File.Exists(src))
get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName); }
}
/// <summary>
/// exe 同目录的模板是否还是构建时那份(与内嵌模板内容一致)。
/// 现场替换过、或被填过数据都算"不一致",导出前应由调用方确认来源。
/// </summary>
public static bool LocalTemplateIsBuiltIn()
{
var local = LocalTemplatePath;
if (!File.Exists(local)) return true;
try
{
File.Copy(src, path, true);
return;
var embed = ReadEmbeddedTemplate();
return embed != null && SameDigest(embed, File.ReadAllBytes(local));
}
catch { return false; }
}
/// <summary>
/// 把默认模板原样写到目标路径,返回模板来源描述(用于日志)。
/// 优先用 exe 同目录的同名文件(现场可替换),forceEmbedded 或该文件不存在时用内嵌资源。
///
/// 模板源只有这两个确定来源,绝不按"相近文件名"去猜;目标就是调用方选定的那一个路径。
/// 写出后再比对一次内容,避免"报成功但文件不是模板"这类静默错。
/// </summary>
public static string CopyTemplate(string path, bool forceEmbedded = false)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("导出路径为空", nameof(path));
var dst = Path.GetFullPath(path);
var src = LocalTemplatePath;
if (!forceEmbedded && File.Exists(src))
{
var srcFull = Path.GetFullPath(src);
// 目标就是源本身:File.Copy 会抛"源和目标是同一个文件",先给出可读提示
if (string.Equals(srcFull, dst, StringComparison.OrdinalIgnoreCase))
throw new IOException(
"导出路径与模板源文件相同(" + srcFull + "),请换一个文件名或目录。");
File.Copy(srcFull, dst, true);
AssertSameContent(srcFull, dst);
return "exe 同目录 " + TemplateFileName;
}
var embed = ReadEmbeddedTemplate();
if (embed == null)
throw new FileNotFoundException(
"未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src);
// 先写临时文件再替换:中途失败不会把目标位置已有的文件写坏
var tmp = dst + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllBytes(tmp, embed);
File.Copy(tmp, dst, true);
AssertSameContent(tmp, dst);
}
finally
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
}
return "exe 内嵌模板";
}
private static byte[] ReadEmbeddedTemplate()
{
var asm = typeof(ExcelService).Assembly;
// 嵌入名取决于 Link 名:默认 <RootNamespace>.<文件名>
var name = asm.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith(TemplateFileName, StringComparison.OrdinalIgnoreCase));
if (name == null)
throw new FileNotFoundException(
"未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src);
if (name == null) return null;
using (var input = asm.GetManifestResourceStream(name))
{
if (input == null) throw new FileNotFoundException("无法读取内嵌模板资源: " + name, src);
using (var output = File.Create(path))
input.CopyTo(output);
if (input == null) return null;
using (var buffer = new MemoryStream())
{
input.CopyTo(buffer);
return buffer.ToArray();
}
}
}
/// <summary>写出后比对内容,避免"导出成功但文件是旧的/被别的数据占了"这类静默错。</summary>
private static void AssertSameContent(string expected, string actual)
{
var a = new FileInfo(expected);
var b = new FileInfo(actual);
if (!b.Exists || b.Length != a.Length || !SameDigest(expected, actual))
throw new IOException("模板写出后校验不一致,目标文件内容与模板不同:" + actual);
}
private static bool SameDigest(string x, string y)
{
return SameDigest(File.ReadAllBytes(x), File.ReadAllBytes(y));
}
private static bool SameDigest(byte[] x, byte[] y)
{
if (x == null || y == null || x.Length != y.Length) return false;
using (var sha = SHA256.Create())
{
byte[] hx = sha.ComputeHash(x);
byte[] hy = sha.ComputeHash(y);
for (int i = 0; i < hx.Length; i++)
if (hx[i] != hy[i]) return false;
return true;
}
}
/// <summary>数据行数(Title 非空);文件被占用/损坏时返回 -1,调用方据此区分"读不出"与"空表"。</summary>
public static int CountDataRows(string path)
{
try { return ReadRows(path).Count; }
catch { return -1; }
}
// --------------------------------------------------------- 读取数据
/// <summary>读取 Excel(或同格式 CSV 由用户另存为 xlsx)数据行,跳过空标题行。</summary>