feat: 增强便携版支持与配置管理

- 更新 .gitignore 文件,添加便携版分发目录和 Fody 生成的配置文件。
- 修改 README.md,详细说明便携版的构建与分发流程,强调单文件分发的优势。
- 在 Config.cs 中实现便携配置管理,优先使用 exe 同目录的 config.json,提升用户体验。
- 更新 ExcelService.cs,优化默认模板的导出逻辑,支持现场替换模板文件。
- 在 MainForm.cs 中加载应用图标,提升界面一致性。
- 调整 Program.cs,确保在单文件分发时正确加载 native DLL。
This commit is contained in:
chenshoulin
2026-11-01 20:17:56 +08:00
parent afed57047a
commit d055b415fa
17 changed files with 965 additions and 20 deletions
+46
View File
@@ -0,0 +1,46 @@
using System;
using System.Drawing;
using System.IO;
using System.Reflection;
using System.Windows.Forms;
namespace TfsExcelImporter
{
/// <summary>
/// 应用图标。exe 的 Win32 图标资源由 csproj 的 ApplicationIcon 提供,
/// 这里负责窗口标题栏/任务栏用的 Form.Icon:从内嵌的 app.ico(9 个尺寸帧)
/// 直接构造,系统按 DPI 取 16/32/48;取不到再退回提取 exe 自身图标。
/// </summary>
internal static class AppIcon
{
private static Icon _cached;
private static bool _tried;
public static Icon Load()
{
if (_tried) return _cached;
_tried = true;
try
{
using (Stream s = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("app.ico"))
{
if (s != null) _cached = new Icon(s);
}
}
catch
{
// 内嵌 ICO 解析失败(例如只认 BMP 帧的极端情况):忽略,走下面的兜底
_cached = null;
}
if (_cached == null)
{
try { _cached = Icon.ExtractAssociatedIcon(Application.ExecutablePath); }
catch { }
}
return _cached;
}
}
}
+44 -2
View File
@@ -44,11 +44,39 @@ namespace TfsExcelImporter
// 父 Product Backlog Item:所有 Task 默认挂接的 PBI Id(如 219975);Excel 每行 PBI Id 列可覆盖
public string ParentPbiId { get; set; } = "";
private static string ConfigPath
// 便携优先:配置写在 exe 同目录(拷走 exe 即可带走配置);
// exe 目录只读(如放在 Program Files)时回退到 %AppData%\TfsExcelImporter。
private static string PortableConfigPath
{
get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.json"); }
}
private static string RoamingConfigPath
{
get
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"TfsExcelImporter", "config.json");
}
}
private static string ConfigPath
{
get
{
if (File.Exists(PortableConfigPath)) return PortableConfigPath;
if (File.Exists(RoamingConfigPath)) return RoamingConfigPath;
return PortableConfigPath;
}
}
/// <summary>当前实际使用的配置文件路径(界面提示用)。</summary>
public static string ConfigLocation
{
get { return ConfigPath; }
}
public static AppConfig Load()
{
try
@@ -68,14 +96,28 @@ namespace TfsExcelImporter
public void Save()
{
var json = JsonConvert.SerializeObject(this, Formatting.Indented);
try
{
File.WriteAllText(ConfigPath, JsonConvert.SerializeObject(this, Formatting.Indented));
File.WriteAllText(ConfigPath, json);
return;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine("写入 config.json 失败: " + ex.Message);
}
// exe 目录不可写(只读介质 / Program Files)时落到用户配置目录
try
{
var fallback = RoamingConfigPath;
Directory.CreateDirectory(Path.GetDirectoryName(fallback));
File.WriteAllText(fallback, json);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine("写入用户配置目录失败: " + ex.Message);
}
}
}
}
+26 -5
View File
@@ -22,7 +22,8 @@ namespace TfsExcelImporter
/// <summary>
/// Excel 读取与默认模板。模板内容(列宽、示例行、样式)只在
/// templates\TFS导入模板.xlsx 里维护,随构建复制到 exe 目录后原样导出。
/// templates\TFS导入模板.xlsx 里维护,构建时作为嵌入资源打进 exe;
/// 若 exe 同目录存在同名文件则优先用它(现场可替换模板而不用重编译)。
/// 列: Title | State | Assigned To | FinancialProject | Completed | LogDate | PBI Id | Description
/// </summary>
public static class ExcelService
@@ -44,13 +45,33 @@ namespace TfsExcelImporter
// --------------------------------------------------------- 默认模板
/// <summary>把随程序发布的默认模板原样复制到目标路径。</summary>
/// <summary>
/// 把默认模板原样复制到目标路径。优先用 exe 同目录的同名文件(可由现场替换),
/// 没有则从 exe 内嵌资源释放——单文件便携分发时无需附带模板。
/// </summary>
public static void CopyTemplate(string path)
{
var src = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName);
if (!File.Exists(src))
throw new FileNotFoundException("未找到默认模板文件: " + src, src);
File.Copy(src, path, true);
if (File.Exists(src))
{
File.Copy(src, path, true);
return;
}
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);
using (var input = asm.GetManifestResourceStream(name))
{
if (input == null) throw new FileNotFoundException("无法读取内嵌模板资源: " + name, src);
using (var output = File.Create(path))
input.CopyTo(output);
}
}
// --------------------------------------------------------- 读取数据
+3
View File
@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura />
</Weavers>
+4 -4
View File
@@ -23,6 +23,7 @@ namespace TfsExcelImporter
using (var g = Graphics.FromHwnd(IntPtr.Zero))
Theme.Scale = g.DpiX / 96f;
_cfg = AppConfig.Load();
Icon = AppIcon.Load();
BuildUi();
LoadConfigToUi();
WireEvents();
@@ -306,7 +307,7 @@ namespace TfsExcelImporter
标题 = r.Title,
状态 = r.State,
指派 = r.AssignedTo,
财务项目 = r.FinancialProject,
项目 = r.FinancialProject,
完成量 = r.Completed,
日期 = r.LogDate,
父PBI = r.PbiId,
@@ -588,10 +589,9 @@ namespace TfsExcelImporter
}
catch (Exception ex) { Log("写报告失败: " + FirstLine(ex.Message)); }
_pbar.Value = _pbar.Maximum;
// 成功/跳过/失败的分布已在右侧三个胶囊里,这里只补胶囊没有的「总数」,明细仍写日志
_lblStatus.Text = $"完成 {ok + skip + fail}/{_rows.Count}";
var summary = $"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}";
_lblStatus.Text = summary;
Log(summary);
Log($"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}");
SetChips(ok, skip, fail, false);
RefreshSummaries();
if (fail == 0 && ok > 0) OpenDetail(_cardLog);
+24 -3
View File
@@ -341,7 +341,8 @@ namespace TfsExcelImporter
// 透明背景会让父容器改画自己的 BackColor(灰),这里直接给成操作条的白
_badgeRun = new StepBadge { BackColor = Theme.Card };
_btnPreview = NewButton("预览数据", BtnKind.Secondary, IconKind.Table, 120);
_btnImport = NewButton("开始导入 TFS", BtnKind.Primary, IconKind.ArrowUp, 176);
// 148 已够图标+文字(实际量出约 130),省下的宽度留给右侧状态文字与进度条
_btnImport = NewButton("开始导入 TFS", BtnKind.Primary, IconKind.ArrowUp, 148);
// 主/次按钮同高(层级靠主色表达,不再用 38px 拔高),避免操作条里两个按钮大小不齐
_pbar = new ProgressTrack
{
@@ -390,13 +391,33 @@ namespace TfsExcelImporter
_chipFail.SetBounds(_actionBar.Width - _chipFail.Width - M - Theme.Px(2), cy, _chipFail.Width, _chipFail.Height);
_chipSkip.SetBounds(_chipFail.Left - G - _chipSkip.Width, cy, _chipSkip.Width, _chipSkip.Height);
_chipOk.SetBounds(_chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height);
int statW = Theme.Px(160);
_lblStatus.SetBounds(_chipOk.Left - statW - G, 0, statW, _actionBar.Height);
// 状态文字按最长常规文案实测给宽,且只给一行的高度:
// 宽度够了不折行,万一更长的文案也只省略尾巴,不会把计数拆到第二行。
int statW = StatusLabelWidth();
int statH = Theme.Caption.Height + Theme.Px(4);
_lblStatus.SetBounds(_chipOk.Left - statW - G, (_actionBar.Height - statH) / 2, statW, statH);
int left = _btnImport.Right + Theme.S16;
int barW = Math.Max(Theme.Px(40), _lblStatus.Left - G - left);
_pbar.SetBounds(left, (_actionBar.Height - _pbar.Height) / 2, barW, _pbar.Height);
}
/// <summary>
/// 操作条右侧状态文字的最大显示宽度:取各操作最长文案的实测宽度(160~280 之间夹取),
/// 保证常规状态既不折行、也不省略尾部计数。更长的文案(如「Area 修正完成…(详见日志)」)
/// 只省略尾巴——它们的数字在开头,不受影响。
/// </summary>
private static int StatusLabelWidth()
{
string[] samples =
{
"完成 999/999", // 导入结束:只报总数,分布交给右侧胶囊
"补挂完成: 挂接 99 / 已有父跳过 99 / 失败 0" // 补挂父项:最长的一条常规状态
};
int w = 0;
foreach (var s in samples) w = Math.Max(w, Theme.TextWidth(s, Theme.Caption));
return Math.Min(Theme.Px(280), Math.Max(Theme.Px(160), w + Theme.Px(8)));
}
// --- 日志抽屉 ---
private void BuildCardLog()
{
+83
View File
@@ -0,0 +1,83 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
namespace TfsExcelImporter
{
/// <summary>
/// TFS Client OM 除托管程序集外还依赖两个纯 win32 DLL
/// (Microsoft.WITDataStore32/64.dll)。托管程序集由 Costura 内嵌并从内存加载,
/// native DLL 只能落盘后 LoadLibrary,所以单文件分发时:
/// 启动阶段把内嵌的 native DLL 释放到 %TEMP%\TfsExcelImporter\native\{内容hash},
/// 再 LoadLibrary 预加载 —— 之后 TFS 内部的 DllImport 会直接命中已加载模块。
/// 目录名带 DLL 内容 hash,升级后不会残留旧版本。
/// exe 同目录存在同名 DLL(开发机 bin 目录、绿色解压版)时不做任何处理。
/// </summary>
internal static class NativeLibraryLoader
{
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "LoadLibraryW")]
private static extern IntPtr LoadLibrary(string path);
public static void EnsureLoaded()
{
var fileName = Environment.Is64BitProcess
? "Microsoft.WITDataStore64.dll"
: "Microsoft.WITDataStore32.dll";
if (File.Exists(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileName)))
return;
try
{
var asm = typeof(NativeLibraryLoader).Assembly;
var resourceName = "native." + fileName;
using (var stream = asm.GetManifestResourceStream(resourceName))
{
if (stream == null)
{
Debug.WriteLine("未找到内嵌 native 资源: " + resourceName);
return;
}
byte[] bytes;
using (var buffer = new MemoryStream())
{
stream.CopyTo(buffer);
bytes = buffer.ToArray();
}
var target = Path.Combine(NativeDir(bytes), fileName);
Directory.CreateDirectory(Path.GetDirectoryName(target));
if (!File.Exists(target))
{
try { File.WriteAllBytes(target, bytes); }
catch (IOException)
{
// 同 hash 目录已被另一实例写住,内容等价,直接用
if (!File.Exists(target)) throw;
}
}
if (LoadLibrary(target) == IntPtr.Zero)
Debug.WriteLine("LoadLibrary 失败(" + Marshal.GetLastWin32Error() + "): " + target);
}
}
catch (Exception ex)
{
// 释放失败不阻断启动:TFS 连接时会自行报 DllNotFound,提示仍是可读的
Debug.WriteLine("释放 native DLL 失败: " + ex.Message);
}
}
private static string NativeDir(byte[] bytes)
{
byte[] hash;
using (var sha = SHA256.Create())
hash = sha.ComputeHash(bytes);
var stamp = BitConverter.ToString(hash, 0, 4).Replace("-", "");
return Path.Combine(Path.GetTempPath(), "TfsExcelImporter", "native", stamp);
}
}
}
+3
View File
@@ -8,6 +8,9 @@ namespace TfsExcelImporter
[STAThread]
private static void Main()
{
// 单文件分发时 native DLL 从内嵌资源释放并预加载(TFS Client OM 需要)
NativeLibraryLoader.EnsureLoaded();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new MainForm());
+26 -4
View File
@@ -11,6 +11,10 @@
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<!-- System DPI 感知:界面像素尺寸按 Theme.Px() 换算,见 app.manifest -->
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- exe 图标(资源管理器/任务栏/快捷方式)。多尺寸 ICO 由 tools/make_icon.py 生成 -->
<ApplicationIcon>app.ico</ApplicationIcon>
<!-- 便携单文件:Costura 把所有依赖 DLL 嵌入 exe,运行时自动解析 -->
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
@@ -22,16 +26,34 @@
<PackageReference Include="Microsoft.TeamFoundationServer.ExtendedClient" Version="14.*" />
<!-- 无需安装 VS 也可用 dotnet build 编译 net48 -->
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<!-- 单文件打包:把所有引用 DLL(含 native WITDataStore)嵌进 exe -->
<PackageReference Include="Fody" Version="6.8.2" PrivateAssets="all" />
<PackageReference Include="Costura.Fody" Version="5.7.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Update="config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- 默认导入模板:随 exe 一起发布,「导出Excel模板」原样复制这份文件 -->
<Content Include="..\templates\TFS导入模板.xlsx" Link="TFS导入模板.xlsx">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!--
图标同时以「托管资源」嵌一份:窗口标题栏/任务栏用 Form.Icon,
从内嵌 ICO 取可以得到全部 9 个尺寸帧;若走 Icon.ExtractAssociatedIcon
只能拿到系统默认那一档,高 DPI 下会糊。
-->
<EmbeddedResource Include="app.ico" LogicalName="app.ico" />
<!-- 默认导入模板:嵌入 exe,「导出Excel模板」时释放出来;exe 同目录存在同名文件时优先用它(便于现场替换模板) -->
<EmbeddedResource Include="..\templates\TFS导入模板.xlsx" Link="TFS导入模板.xlsx" />
<!--
TFS Client OM 的 native DLL(NuGet 包 lib\native\{amd64,x86})。
Costura 只嵌托管程序集,这两个是纯 win32 DLL,单独嵌入,
启动时由 NativeLibraryLoader 释放到临时目录并预加载。
-->
<EmbeddedResource Include="$(NuGetPackageRoot)microsoft.teamfoundationserver.extendedclient\*\lib\native\amd64\Microsoft.WITDataStore64.dll">
<LogicalName>native.Microsoft.WITDataStore64.dll</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="$(NuGetPackageRoot)microsoft.teamfoundationserver.extendedclient\*\lib\native\x86\Microsoft.WITDataStore32.dll">
<LogicalName>native.Microsoft.WITDataStore32.dll</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB