feat: 增强便携版支持与配置管理
- 更新 .gitignore 文件,添加便携版分发目录和 Fody 生成的配置文件。 - 修改 README.md,详细说明便携版的构建与分发流程,强调单文件分发的优势。 - 在 Config.cs 中实现便携配置管理,优先使用 exe 同目录的 config.json,提升用户体验。 - 更新 ExcelService.cs,优化默认模板的导出逻辑,支持现场替换模板文件。 - 在 MainForm.cs 中加载应用图标,提升界面一致性。 - 调整 Program.cs,确保在单文件分发时正确加载 native DLL。
This commit is contained in:
@@ -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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- 读取数据
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<Costura />
|
||||
</Weavers>
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user