refactor: 移除不再使用的配置文件与代码,优化用户界面

- 删除 config.json 和 requirements.txt 文件,移除 Python 版相关代码,简化项目结构。
- 更新 README.md,调整工具说明,强调仅保留 C# WinForms 版本。
- 在 MainForm.cs 中添加输入框的占位提示,提升用户体验。
- 更新 .gitignore,添加 debug.log 文件以记录运行时日志。
- 移除与 Python 版相关的示例 CSV 文件,确保数据源一致性。
This commit is contained in:
chenshoulin
2026-11-01 21:44:31 +08:00
parent d055b415fa
commit 8a6f9d55bc
14 changed files with 297 additions and 670 deletions
+3
View File
@@ -29,3 +29,6 @@ data/work_items.csv
Thumbs.db Thumbs.db
Desktop.ini Desktop.ini
.DS_Store .DS_Store
# 运行时日志(WebView2/crashpad 报错输出)
debug.log
+25 -58
View File
@@ -12,33 +12,25 @@ TFS 2013 工作项批量导入工具集,用于把 Excel/CSV 里的工时台账
| 迭代 | `2026年Sprint\2026年Sprint10` | | 迭代 | `2026年Sprint\2026年Sprint10` |
| 认证 | 域账号 NTLM(内网直连,不走代理) | | 认证 | 域账号 NTLM(内网直连,不走代理) |
## 两套工具,先用哪个 ## 工具一览
| | [TfsExcelImporter](TfsExcelImporter)(C# WinForms) | [tfs_import.py](tfs_import.py)(Python CLI) | 仓库只有一套工具:[TfsExcelImporter](TfsExcelImporter)(C# WinForms 桌面版),数据源为 `.xlsx`(默认模板随程序发布),图形界面预览后导入。
| --- | --- | --- |
| 通信协议 | TFS Client OM(VS/Team Explorer 同款内部 SOAP) | REST `/_apis/wit/*` |
| 适用 | **本环境可用**。该 TFS 2013 实例只有 `/_apis/projects` 通,`wit/fields`、`wit/classificationnodes`、`wit/wiql` 全部 404 | 服务器开放了 wit REST 路由时使用 |
| 数据源 | `.xlsx`(默认模板随程序发布) | `data/work_items.csv`(GBK/UTF-8 均可,需自备) |
| 界面 | 图形界面,预览后导入 | 命令行,`--dry-run` 先校验 |
Python 版在本环境会因 REST 路由 404 而失败,保留用于其它 TFS 实例或接口可用时的批量脚本化导入。 通信走 TFS Client OM(VS/Team Explorer 同款内部 SOAP):该 TFS 2013 实例只有 `/_apis/projects` 通,`wit/fields`、`wit/classificationnodes`、`wit/wiql` 全部 404,REST 路线走不通。原先另有一套 Python CLI(REST `/_apis/wit/*`)实现,因上述限制无法在本环境工作,已移除。
## 目录结构 ## 目录结构
``` ```
TFSImport/ TFSImport/
├── tfs_import.py # Python 命令行导入器(REST)
├── config.json # Python 版配置
├── requirements.txt # requests / requests-ntlm
├── data/ ├── data/
│ └── work_items.example.csv # 列结构与示例行(UTF-8 BOM);真实台账不入库 │ └── work_items.csv # 本地工时台账(含人员/项目,不入库)
├── templates/ ├── templates/
│ └── TFS导入模板.xlsx # 默认模板:构建时嵌入 exe,「导出Excel模板」原样导出 │ └── TFS导入模板.xlsx # 默认模板:构建时嵌入 exe,「导出Excel模板」原样导出
├── tools/ ├── tools/
│ ├── make_icon.py # 生成 app.ico(9 个尺寸帧,纯标准库 SDF 栅格化 + 手写 PNG/ICO) │ ├── make_icon.py # 生成 app.ico(9 个尺寸帧,纯标准库 SDF 栅格化 + 手写 PNG/ICO)
│ ├── pe_icon.py # 解析 exe 的 PE 图标资源,导出某帧 PNG(换图标后核对用) │ ├── pe_icon.py # 解析 exe 的 PE 图标资源,导出某帧 PNG(换图标后核对用)
│ └── win_screenshot.py # 冒烟截图:拉起 exe、PrintWindow 抓窗、导出标题栏图标(ctypes) │ └── win_screenshot.py # 冒烟截图:拉起 exe、PrintWindow 抓窗、导出标题栏图标(ctypes)
└── TfsExcelImporter/ # C# WinForms 桌面版(主用) └── TfsExcelImporter/ # C# WinForms 桌面版
├── TfsExcelImporter.csproj # net48 + ClosedXML + TFS ExtendedClient 14.* ├── TfsExcelImporter.csproj # net48 + ClosedXML + TFS ExtendedClient 14.*
├── FodyWeavers.xml # Costura 单文件打包配置(便携版) ├── FodyWeavers.xml # Costura 单文件打包配置(便携版)
├── NativeLibraryLoader.cs # 释放并预加载内嵌的 TFS native DLL ├── NativeLibraryLoader.cs # 释放并预加载内嵌的 TFS native DLL
@@ -56,7 +48,7 @@ TFSImport/
## 快速开始 ## 快速开始
### C# 桌面版(主用) ### 桌面版
```powershell ```powershell
dotnet build TfsExcelImporter.sln -c Release dotnet build TfsExcelImporter.sln -c Release
@@ -75,8 +67,8 @@ python tools/win_screenshot.py TfsExcelImporter\bin\Release\net48\TfsExcelImport
界面按 4 步排:顶部状态条常驻显示服务器/项目/类型与连接状态,四张卡片一次只展开一张(展开一张会把其它收成一行摘要),预览表格永远占满剩余高度。 界面按 4 步排:顶部状态条常驻显示服务器/项目/类型与连接状态,四张卡片一次只展开一张(展开一张会把其它收成一行摘要),预览表格永远占满剩余高度。
1. **步骤 1 连接**:填 URL、项目、工作项类型、认证方式与域账号,点「测试连接」——右上状态灯变绿即连通;内网保持「使用系统代理」**不勾选**。 1. **步骤 1 连接**:填 URL、项目、工作项类型与账号密码(认证固定 NTLM,用户名可写 `user` 或 `DOMAIN\user`),点「测试连接」——右上状态灯变绿即连通。
2. **步骤 2 选数据**:点「加载迭代列表」选目标迭代(也可手填完整路径);点「导出 Excel 模板」拿默认模板,或「浏览...」选已有 `.xlsx`。需要挂父项时填「父 PBI Id」(默认全部挂同一个;Excel 里加 `PBI Id` 列可逐行指定不同父项)。 2. **步骤 2 选数据**:点「加载迭代列表」选目标迭代(也可手填完整路径);点「导出 Excel 模板」拿默认模板,或「浏览...」选已有 `.xlsx`。需要挂父项时填「父 PBI Id」(默认全部挂同一个;Excel 里加 `PBI Id` 列可逐行指定不同父项);勾「跳过已存在标题」按「项目/类型/迭代 + 标题」去重。字段校验**不可绕过**,不合法时日志会直接告诉你改哪一列。
3. **步骤 3 预览核对**:选完文件自动读表,表格里逐行核对;缺迭代/文件路径无效时红字直接标在对应输入框下方,不再弹框。 3. **步骤 3 预览核对**:选完文件自动读表,表格里逐行核对;缺迭代/文件路径无效时红字直接标在对应输入框下方,不再弹框。
4. **步骤 4 导入**:底部操作条点「开始导入 TFS」,进度条 + `n/m` + 成功/跳过/失败三个计数实时更新,日志抽屉逐行输出 `OK/SKIP/FAIL` 与工作项 ID;跑完点「打开报告 CSV」看逐行结果。 4. **步骤 4 导入**:底部操作条点「开始导入 TFS」,进度条 + `n/m` + 成功/跳过/失败三个计数实时更新,日志抽屉逐行输出 `OK/SKIP/FAIL` 与工作项 ID;跑完点「打开报告 CSV」看逐行结果。
5. 历史数据 Area 不对、或漏挂父项时,展开底部「高级工具」用「按迭代修 Area」「补挂父项」批量纠正;Area 路径也在这一栏里填(以前只能手改 config.json)。 5. 历史数据 Area 不对、或漏挂父项时,展开底部「高级工具」用「按迭代修 Area」「补挂父项」批量纠正;Area 路径也在这一栏里填(以前只能手改 config.json)。
@@ -105,39 +97,27 @@ copy TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe dist\
前提:目标机需有 .NET Framework 4.8(Win10 1903+ / Win11 系统自带,一般无需额外安装)。`FodyWeavers.xml` 是 Costura 的打包配置,删掉它或用 `-p:FodyDisable=true` 构建即回到「exe + 一堆 DLL」的普通输出。 前提:目标机需有 .NET Framework 4.8(Win10 1903+ / Win11 系统自带,一般无需额外安装)。`FodyWeavers.xml` 是 Costura 的打包配置,删掉它或用 `-p:FodyDisable=true` 构建即回到「exe + 一堆 DLL」的普通输出。
### Python 版
```powershell
pip install -r requirements.txt
python tfs_import.py --dry-run # 只校验数据与字段映射,不写入
python tfs_import.py --list-fields # 列出服务器全部字段(显示名/引用名)
python tfs_import.py # 正式导入
python tfs_import.py --link-existing # 已导入的项补挂父链接(不新建)
python tfs_import.py --skip-existing-titles # 跳过服务器已有的同标题项
python tfs_import.py --input other.csv --limit 5
```
每次运行在 `data/import_report_<时间戳>.csv` 生成逐行结果报告(行号 / Title / 结果 / WorkItemId / 错误)。
## 配置说明 ## 配置说明
桌面版的配置落在 exe 同目录的 `config.json`(界面改动后自动写回);exe 目录不可写时回退到 `%AppData%\TfsExcelImporter\config.json`。 桌面版的配置落在 exe 同目录的 `config.json`(界面改动后自动写回);exe 目录不可写时回退到 `%AppData%\TfsExcelImporter\config.json`。
配置的语义、留空字段如何解析见 [tfs_import.py:49](tfs_import.py#L49) 与 [Config.cs](TfsExcelImporter/Config.cs),关键字段: 配置的语义、留空字段如何解析见 [Config.cs](TfsExcelImporter/Config.cs),关键字段:
| 键 | 说明 | | 键 | 说明 |
| --- | --- | | --- | --- |
| `tfs_url` / `project` / `work_item_type` | 目标集合、项目、工作项类型(`Task` 或 `Product Backlog Item`) | | `TfsUrl` / `Project` / `WorkItemType` | 目标集合、项目、工作项类型(`Task` 或 `Product Backlog Item`) |
| `iteration_path` | 可只写 `2026年Sprint\2026年Sprint10`,程序自动补项目名前缀 | | `IterationPath` | 可只写 `2026年Sprint\2026年Sprint10`,程序自动补项目名前缀 |
| `auth.mode` | `ntlm`(域账号,典型)/ `basic` / `default`(当前 Windows 凭据,仅桌面版) | | `AreaPath` | 区域路径;界面「高级工具」栏也可改 |
| `field_refs` | 语义名 → 字段引用名;自定义字段留空时按 `resolve_fields_by_display_name` 的显示名在服务器字段表里查(支持前缀模糊匹配,如 `Completed` → `Completed Work`) | | `Username` / `Password` | 认证固定 NTLM,用户名可写 `user` 或 `DOMAIN\user` |
| `parent_title` / `parent_id` | 父工作项,二选一;按标题匹配不到会直接报错并提示改用 `parent_id` | | `FinancialProjectRef` / `CompletedRef` / `LogDateRef` / `OriginalEstimateRef` | 自定义字段引用名;留空则按显示名在服务器字段表里查(支持前缀模糊匹配,如 `Completed` → `Completed Work`) |
| `bypass_rules` | 是否请求绕过字段校验规则;桌面版走 Client OM(14.x 无 `SaveFlags.BypassRules`),实际由服务器规则决定 | | `ParentPbiId` | 所有 Task 默认挂接的父 PBI Id;Excel 每行 `PBI Id` 列可覆盖 |
| `skip_existing_titles` | 同项目/同类型/同迭代下按标题去重 | | `SkipExistingTitles` | 同项目/同类型/同迭代下按标题去重 |
| `DefaultState` | Excel `State` 列留空时的默认值 |
| `LastExcelPath` | 上次选中的数据文件,界面回填用 |
> 配置里含明文密码,请勿提交到共享仓库;`config.json` 保持空值,凭据本地填写。 > 配置里含明文密码,请勿提交到共享仓库;`config.json` 保持空值,凭据本地填写。
## Excel / CSV 列 ## Excel 列
桌面版 Excel 模板 = 仓库里的 `templates\TFS导入模板.xlsx`,界面「导出Excel模板」直接复制它: 桌面版 Excel 模板 = 仓库里的 `templates\TFS导入模板.xlsx`,界面「导出Excel模板」直接复制它:
@@ -153,37 +133,24 @@ python tfs_import.py --input other.csv --limit 5
系统日期列(`Created Date` / `Closed Date`)已从模板移除:`System.CreatedDate` 由服务器按导入时间填写,`Microsoft.VSTS.Common.ClosedDate` 不再写入、保持为空。 系统日期列(`Created Date` / `Closed Date`)已从模板移除:`System.CreatedDate` 由服务器按导入时间填写,`Microsoft.VSTS.Common.ClosedDate` 不再写入、保持为空。
Python 版仍读 `data/work_items.example.csv` 的旧列(含 `CreatedDate`、`ClosedDate`、`Notes`),未随模板调整。 真实台账含人员与项目信息,不入库,放在 `data\work_items.csv`(已在 `.gitignore` 里)。
真实台账含人员与项目信息,不入库;本地准备数据文件:
```powershell
copy data\work_items.example.csv data\work_items.csv # Python 版默认读这个路径,已在 .gitignore 里
```
- 表头按名匹配,顺序不限,`AssignedTo` / `Assigned To` 等价;缺列按模板顺序兜底,但该列已被别的表头占用时不套用位置(旧版模板文件不会串列)。 - 表头按名匹配,顺序不限,`AssignedTo` / `Assigned To` 等价;缺列按模板顺序兜底,但该列已被别的表头占用时不套用位置(旧版模板文件不会串列)。
- 空 `Title` 的行直接跳过;`State` 为空时用 `default_state`;未识别的列(如 `Group`)忽略。 - 空 `Title` 的行直接跳过;`State` 为空时用 `DefaultState`;未识别的列(如 `Group`)忽略。
- 日期支持 `yyyy-MM-dd[ / ][ HH:mm[:ss]]` 等格式,Excel 日期序列号(如 `46300`)会被还原成日期。 - 日期支持 `yyyy-MM-dd[ / ][ HH:mm[:ss]]` 等格式,Excel 日期序列号(如 `46300`)会被还原成日期。
- CSV 从 Excel 另存时注意编码,脚本按 `utf-8-sig` → `gbk` 依次尝试。
## 环境坑点 ## 环境坑点
这些是实测结论,改代码前先看: 这些是实测结论,改代码前先看:
- **wit REST 路由全 404**:TFS 2013 RTM/低 Update 不带 `/_apis/wit/*`,`Classification.asmx?WSDL` 也被禁 → 桌面版整体走 Client OM(NuGet `Microsoft.TeamFoundationServer.ExtendedClient` 钉 `14.*`,对应 VS2015 客户端代际;15.x 不兼容 TFS 2013)。 - **wit REST 路由全 404**:TFS 2013 RTM/低 Update 不带 `/_apis/wit/*`,`Classification.asmx?WSDL` 也被禁 → 桌面版整体走 Client OM(NuGet `Microsoft.TeamFoundationServer.ExtendedClient` 钉 `14.*`,对应 VS2015 客户端代际;15.x 不兼容 TFS 2013)。
- **502 Bad Gateway**:TFS OM 内部用 `HttpWebRequest`,默认跟随 IE 系统代理,代理会拦内网 TFS。桌面版默认把 `WebRequest.DefaultWebProxy` 置空直连;确实要走代理时勾「使用系统代理」并带上 Windows 凭据过代理认证。 - **502 Bad Gateway**:TFS OM 内部用 `HttpWebRequest`,默认跟随 IE 系统代理,代理会拦内网 TFS。桌面版固定把 `WebRequest.DefaultWebProxy` 置空强制直连,界面不提供代理开关(本机若只能经代理上网,程序会直连失败)。
- **TF400276 / 关闭状态只读**:改已关闭工作项的 Area 会被拒;`FixAreaAsync` 走「重新打开 → 改 Area → 还原状态」的状态循环,中途失败回滚状态。 - **TF400276 / 关闭状态只读**:改已关闭工作项的 Area 会被拒;`FixAreaAsync` 走「重新打开 → 改 Area → 还原状态」的状态循环,中途失败回滚状态。
- **`System.CreatedDate` 写不进去**:OM 14.x 没有 `SaveFlags.BypassRules`(15.x 才加),该字段服务器托管;`Microsoft.VSTS.Common.ClosedDate` 在 `State=Done` 时按普通字段可尝试写入。模板已去掉日期列,所以导入不再走这条路——若某模板要求 `Done` 必填 Closed Date,需另加列或改回写日期。 - **`System.CreatedDate` 写不进去**:OM 14.x 没有 `SaveFlags.BypassRules`(15.x 才加),该字段服务器托管;`Microsoft.VSTS.Common.ClosedDate` 在 `State=Done` 时按普通字段可尝试写入。模板已去掉日期列,所以导入不再走这条路——若某模板要求 `Done` 必填 Closed Date,需另加列或改回写日期。
- **孤立 Task 在积压工作页不可见**:TFS 2013 的 backlog/任务板按「父项 → 子任务」组织,没挂父链接的 Task 只能在查询里看到,所以导入必须带 `parent_title`/`parent_id`。 - **字段校验不可绕过**:OM 14.x 没有 `SaveFlags.BypassRules`,旧界面上那个 `bypassRules` 勾选从未真正生效,已移除。校验失败时日志与 report CSV 直接给出「字段(原因) → 怎么改」,列表字段还会列出允许值,按提示改 Excel 对应列后重跑即可。
- **孤立 Task 在积压工作页不可见**:TFS 2013 的 backlog/任务板按「父项 → 子任务」组织,没挂父链接的 Task 只能在查询里看到,所以导入必须带父项(界面填父 PBI Id,或 Excel 里加 `PBI Id` 列)。
- **DNS 解析失败 / 超时**:多为 VPN 或网络断开,不是代码问题;桌面版会对这类异常附加排查提示。 - **DNS 解析失败 / 超时**:多为 VPN 或网络断开,不是代码问题;桌面版会对这类异常附加排查提示。
## 验证 ## 验证
改动 Python 版后至少跑: 桌面版改完,先用只含几行的 Excel 跑一次小批量导入(先删服务器已有同标题项或勾「跳过已存在标题」),再在积压工作页展开父项确认子任务可见;改动涉及界面布局或图标时,跑 `python tools/win_screenshot.py` 抓图核对。确认无误后再跑全量。
```powershell
python -m py_compile tfs_import.py
python tfs_import.py --dry-run
```
桌面版改完跑一次 `--limit` 级别的小批量导入(先删服务器已有同标题项或开启跳过),并在积压工作页展开父项确认子任务可见。
+4 -8
View File
@@ -18,17 +18,13 @@ namespace TfsExcelImporter
// 可选:区域路径 // 可选:区域路径
public string AreaPath { get; set; } = ""; public string AreaPath { get; set; } = "";
// ntlm | basic | default(当前 Windows 凭据) // 认证固定 NTLM:用户名可写 user,也可写 DOMAIN\user
public string AuthMode { get; set; } = "ntlm";
public string Username { get; set; } = ""; public string Username { get; set; } = "";
public string Password { get; set; } = ""; public string Password { get; set; } = "";
public string Domain { get; set; } = "";
// 是否走 IE 系统代理。内网 TFS 请保持 false(直连); // 旧版本有 BypassRules 开关,已移除:TFS 2013 对应的 Client OM 14.x 没有 SaveFlags.BypassRules
// 代理/网关常对 HttpClient 请求返回 502(浏览器例外名单直连,.NET 不一定继承) // (15.x 才加,而 15.x 不兼容 TFS 2013),勾选从未生效。旧 config.json 里的残留键由
public bool UseSystemProxy { get; set; } = false; // Newtonsoft 按默认 MissingMemberHandling.Ignore 忽略,不需要手工清理。
public bool BypassRules { get; set; } = true;
public bool SkipExistingTitles { get; set; } = true; public bool SkipExistingTitles { get; set; } = true;
public string DefaultState { get; set; } = "To Do"; public string DefaultState { get; set; } = "To Do";
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace TfsExcelImporter
{
/// <summary>
/// 输入框空态占位文字(cue banner)。net48 的 TextBox 没有 PlaceholderText,
/// 用原生 EM_SETCUEBANNER 实现;ComboBox(DropDown)要发给它内嵌的 Edit 子窗口,
/// 直接发给组合框本体无效。文字只在输入框为空时显示,与 Web 表单 placeholder 手感一致。
/// </summary>
internal static class CueBanner
{
private const int EM_SETCUEBANNER = 0x1501;
private const uint GW_CHILD = 5;
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, string lParam);
[DllImport("user32.dll")]
private static extern IntPtr GetWindow(IntPtr hWnd, uint cmd);
/// <summary>设置占位文字。控件句柄可能尚未创建,挂 HandleCreated 兜底;
/// wParam=1 让占位文字在聚焦时也显示(有真实输入后自然消失)。</summary>
public static void Set(Control c, string text)
{
if (c == null || string.IsNullOrEmpty(text)) return;
Action apply = () => Send(c, text);
if (c.IsHandleCreated) apply();
c.HandleCreated += (s, e) => apply();
}
private static void Send(Control c, string text)
{
IntPtr target = c.Handle;
if (c is ComboBox)
{
// DropDown 组合框的内嵌 Edit 子窗口:cue banner 只认它
target = GetWindow(c.Handle, GW_CHILD);
if (target == IntPtr.Zero) return;
}
SendMessage(target, EM_SETCUEBANNER, (IntPtr)1, text);
}
}
}
+5 -30
View File
@@ -35,7 +35,6 @@ namespace TfsExcelImporter
private void WireEvents() private void WireEvents()
{ {
_cmbAuth.SelectedIndexChanged += (s, e) => { UpdateAuthUi(); OnFieldChanged(); };
_btnTest.Click += async (s, e) => await TestConnectionAsync(); _btnTest.Click += async (s, e) => await TestConnectionAsync();
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync(); _btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
_btnBrowse.Click += (s, e) => BrowseExcel(); _btnBrowse.Click += (s, e) => BrowseExcel();
@@ -45,13 +44,11 @@ namespace TfsExcelImporter
_btnFixArea.Click += async (s, e) => await FixAreaAsync(); _btnFixArea.Click += async (s, e) => await FixAreaAsync();
_btnLinkParent.Click += async (s, e) => await LinkParentAsync(); _btnLinkParent.Click += async (s, e) => await LinkParentAsync();
foreach (var tb in new[] { _txtUrl, _txtProject, _txtType, _txtUser, _txtPwd, _txtDomain, foreach (var tb in new[] { _txtUrl, _txtProject, _txtType, _txtUser, _txtPwd,
_txtExcel, _txtParentPbi, _txtAreaPath }) _txtExcel, _txtParentPbi, _txtAreaPath })
tb.TextChanged += (s, e) => OnFieldChanged(); tb.TextChanged += (s, e) => OnFieldChanged();
_cmbIteration.TextChanged += (s, e) => OnFieldChanged(); _cmbIteration.TextChanged += (s, e) => OnFieldChanged();
_chkSkip.CheckedChanged += (s, e) => OnFieldChanged(); _chkSkip.CheckedChanged += (s, e) => OnFieldChanged();
_chkBypass.CheckedChanged += (s, e) => OnFieldChanged();
_chkProxy.CheckedChanged += (s, e) => OnFieldChanged();
FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } }; FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } };
} }
@@ -62,37 +59,19 @@ namespace TfsExcelImporter
RefreshSummaries(); RefreshSummaries();
} }
// ------------------------------------------------------------- 认证区联动
private void UpdateAuthUi()
{
bool needCreds = _cmbAuth.SelectedItem != null && _cmbAuth.SelectedItem.ToString() != "default";
_txtUser.Enabled = needCreds;
_txtPwd.Enabled = needCreds;
_txtDomain.Enabled = needCreds;
_fUser.Invalid = false;
_fPwd.Invalid = false;
_fDomain.Invalid = false;
}
// ------------------------------------------------------------- 配置互转 // ------------------------------------------------------------- 配置互转
private void LoadConfigToUi() private void LoadConfigToUi()
{ {
_txtUrl.Text = _cfg.TfsUrl; _txtUrl.Text = _cfg.TfsUrl;
_txtProject.Text = _cfg.Project; _txtProject.Text = _cfg.Project;
_txtType.Text = _cfg.WorkItemType; _txtType.Text = _cfg.WorkItemType;
_cmbAuth.SelectedItem = _cfg.AuthMode;
if (_cmbAuth.SelectedItem == null) _cmbAuth.SelectedItem = "ntlm";
_txtUser.Text = _cfg.Username; _txtUser.Text = _cfg.Username;
_txtPwd.Text = _cfg.Password; _txtPwd.Text = _cfg.Password;
_txtDomain.Text = _cfg.Domain;
_chkProxy.Checked = _cfg.UseSystemProxy;
_cmbIteration.Text = _cfg.IterationPath; _cmbIteration.Text = _cfg.IterationPath;
_txtExcel.Text = _cfg.LastExcelPath; _txtExcel.Text = _cfg.LastExcelPath;
_txtAreaPath.Text = _cfg.AreaPath; _txtAreaPath.Text = _cfg.AreaPath;
_chkSkip.Checked = _cfg.SkipExistingTitles; _chkSkip.Checked = _cfg.SkipExistingTitles;
_chkBypass.Checked = _cfg.BypassRules;
_txtParentPbi.Text = _cfg.ParentPbiId; _txtParentPbi.Text = _cfg.ParentPbiId;
UpdateAuthUi();
} }
private void SaveUiToConfig() private void SaveUiToConfig()
@@ -100,16 +79,12 @@ namespace TfsExcelImporter
_cfg.TfsUrl = _txtUrl.Text.Trim(); _cfg.TfsUrl = _txtUrl.Text.Trim();
_cfg.Project = _txtProject.Text.Trim(); _cfg.Project = _txtProject.Text.Trim();
_cfg.WorkItemType = _txtType.Text.Trim(); _cfg.WorkItemType = _txtType.Text.Trim();
_cfg.AuthMode = _cmbAuth.SelectedItem != null ? _cmbAuth.SelectedItem.ToString() : "ntlm";
_cfg.Username = _txtUser.Text.Trim(); _cfg.Username = _txtUser.Text.Trim();
_cfg.Password = _txtPwd.Text; _cfg.Password = _txtPwd.Text;
_cfg.Domain = _txtDomain.Text.Trim();
_cfg.UseSystemProxy = _chkProxy.Checked;
_cfg.IterationPath = _cmbIteration.Text.Trim(); _cfg.IterationPath = _cmbIteration.Text.Trim();
_cfg.LastExcelPath = _txtExcel.Text.Trim(); _cfg.LastExcelPath = _txtExcel.Text.Trim();
_cfg.AreaPath = _txtAreaPath.Text.Trim(); _cfg.AreaPath = _txtAreaPath.Text.Trim();
_cfg.SkipExistingTitles = _chkSkip.Checked; _cfg.SkipExistingTitles = _chkSkip.Checked;
_cfg.BypassRules = _chkBypass.Checked;
_cfg.ParentPbiId = _txtParentPbi.Text.Trim(); _cfg.ParentPbiId = _txtParentPbi.Text.Trim();
_cfg.Save(); _cfg.Save();
} }
@@ -168,7 +143,7 @@ namespace TfsExcelImporter
SetConnection("连接失败", ChipKind.Danger); SetConnection("连接失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn); SetBadgeOf(_cardConn, BadgeKind.Warn);
Log("连接失败: " + FirstLine(ex.Message)); Log("连接失败: " + FirstLine(ex.Message));
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。"); Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
_cardConn.Expanded = true; _cardConn.Expanded = true;
_cardConn.ApplyHeight(); _cardConn.ApplyHeight();
Relayout(); Relayout();
@@ -237,7 +212,7 @@ namespace TfsExcelImporter
SetBadgeOf(_cardConn, BadgeKind.Warn); SetBadgeOf(_cardConn, BadgeKind.Warn);
ShowError(_errIteration, _fIteration, _cardData, "迭代列表加载失败,可手填完整路径"); ShowError(_errIteration, _fIteration, _cardData, "迭代列表加载失败,可手填完整路径");
Log("加载迭代失败: " + FirstLine(ex.Message)); Log("加载迭代失败: " + FirstLine(ex.Message));
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。"); Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
} }
finally finally
{ {
@@ -551,7 +526,7 @@ namespace TfsExcelImporter
var pbiText = !string.IsNullOrWhiteSpace(row.PbiId) ? row.PbiId.Trim() : _cfg.ParentPbiId; var pbiText = !string.IsNullOrWhiteSpace(row.PbiId) ? row.PbiId.Trim() : _cfg.ParentPbiId;
int? parentPbi = int.TryParse(pbiText, out var pbiNum) ? pbiNum : (int?)null; int? parentPbi = int.TryParse(pbiText, out var pbiNum) ? pbiNum : (int?)null;
var id = await client.CreateWorkItemAsync(_cfg.Project, _cfg.WorkItemType, fields, _chkBypass.Checked, parentPbi); var id = await client.CreateWorkItemAsync(_cfg.Project, _cfg.WorkItemType, fields, parentPbi);
ok++; ok++;
report.AppendLine($"{line},\"{row.Title}\",OK,{id},"); report.AppendLine($"{line},\"{row.Title}\",OK,{id},");
Log($"[{i + 1}/{_rows.Count}] OK #{id} {row.Title}"); Log($"[{i + 1}/{_rows.Count}] OK #{id} {row.Title}");
@@ -574,7 +549,7 @@ namespace TfsExcelImporter
SetConnection("连接失败", ChipKind.Danger); SetConnection("连接失败", ChipKind.Danger);
SetRunBadge(BadgeKind.Warn); SetRunBadge(BadgeKind.Warn);
Log("导入中断: " + FirstLine(ex.Message)); Log("导入中断: " + FirstLine(ex.Message));
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。"); Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
} }
finally finally
{ {
+120 -29
View File
@@ -17,11 +17,8 @@ namespace TfsExcelImporter
private TextBox _txtUrl = new TextBox(); private TextBox _txtUrl = new TextBox();
private TextBox _txtProject = new TextBox(); private TextBox _txtProject = new TextBox();
private TextBox _txtType = new TextBox(); private TextBox _txtType = new TextBox();
private ComboBox _cmbAuth = new ComboBox();
private TextBox _txtUser = new TextBox(); private TextBox _txtUser = new TextBox();
private TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true }; private TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true };
private TextBox _txtDomain = new TextBox();
private CheckBox _chkProxy = new CheckBox();
private ComboBox _cmbIteration = new ComboBox(); private ComboBox _cmbIteration = new ComboBox();
private TextBox _txtExcel = new TextBox(); private TextBox _txtExcel = new TextBox();
@@ -31,7 +28,6 @@ namespace TfsExcelImporter
private Button _btnBrowse = new Button(); private Button _btnBrowse = new Button();
private Button _btnTemplate = new Button(); private Button _btnTemplate = new Button();
private CheckBox _chkSkip = new CheckBox(); private CheckBox _chkSkip = new CheckBox();
private CheckBox _chkBypass = new CheckBox();
private DataGridView _grid = new DataGridView(); private DataGridView _grid = new DataGridView();
private Button _btnPreview = new Button(); private Button _btnPreview = new Button();
@@ -52,9 +48,12 @@ namespace TfsExcelImporter
private Card _cardConn, _cardData, _cardPreview, _cardLog, _cardAdv; private Card _cardConn, _cardData, _cardPreview, _cardLog, _cardAdv;
private Surface _actionBar; private Surface _actionBar;
private StepBadge _badgeRun; private StepBadge _badgeRun;
private InputField _fUrl, _fProject, _fType, _fAuth, _fUser, _fPwd, _fDomain, private InputField _fUrl, _fProject, _fType, _fUser, _fPwd,
_fIteration, _fExcel, _fParent, _fArea; _fIteration, _fExcel, _fParent, _fArea;
private Label _errIteration, _errExcel, _errParent, _errArea; private Label _errIteration, _errExcel, _errParent, _errArea;
private Label[] _connRowCaptions; // 第 1 步第二行的标签(重排时要跟着一起摆)
private InputField[] _connRowFields; // 同排的四个输入框
private int _connRowY;
private EmptyState _lblEmpty; private EmptyState _lblEmpty;
private StatusChip _chipConn, _chipOk, _chipSkip, _chipFail; private StatusChip _chipConn, _chipOk, _chipSkip, _chipFail;
@@ -237,35 +236,94 @@ namespace TfsExcelImporter
private void BuildCardConn() private void BuildCardConn()
{ {
_cardConn = MakeCard(1, "连接 TFS 服务器", true, Theme.Px(44)); _cardConn = MakeCard(1, "连接 TFS 服务器", true, Theme.Px(44));
_cmbAuth.DropDownStyle = ComboBoxStyle.DropDownList;
_cmbAuth.Items.AddRange(new object[] { "ntlm", "basic", "default" });
_fUrl = NewField(_txtUrl, 620, true); _fUrl = NewField(_txtUrl, 620, true);
_fProject = NewField(_txtProject, 150, false); _fProject = NewField(_txtProject, 150, false);
_fType = NewField(_txtType, 180, false); _fType = NewField(_txtType, 150, false);
_fAuth = NewField(_cmbAuth, 170, false); _fUser = NewField(_txtUser, 150, false);
_fDomain = NewField(_txtDomain, 150, false); _fPwd = NewField(_txtPwd, 150, true);
_fUser = NewField(_txtUser, 180, false);
_fPwd = NewField(_txtPwd, 200, true); // 空态占位提示(cue banner):只在该输入框没填内容时显示
CueBanner.Set(_txtUrl, "如 http://server:8080/tfs/DefaultCollection");
CueBanner.Set(_txtProject, "如 Tools_Dev");
CueBanner.Set(_txtType, "如 Task");
CueBanner.Set(_txtUser, "留空用当前 Windows 身份");
CueBanner.Set(_txtPwd, "域账号密码(同上可选)");
_btnTest = NewButton("测试连接", BtnKind.Secondary, IconKind.Bolt, 130); _btnTest = NewButton("测试连接", BtnKind.Secondary, IconKind.Bolt, 130);
_chkProxy = NewCheck("使用系统代理", "内网 TFS 请保持关闭,勾选后常被代理拦成 502");
int y = _cardConn.HeaderH + Theme.S12; int y = _cardConn.HeaderH + Theme.S12;
// 三行表单共用一条字段左缘:标签列宽取各行最大值,同列字段宽度归一 y = Row(_cardConn, y, 42, new Lbl("服务器 URL", MaxLabelWidth("服务器 URL")), _fUrl);
int w1 = MaxLabelWidth("服务器 URL", "团队项目", "域");
int w2 = MaxLabelWidth("工作项类型", "用户");
int w3 = MaxLabelWidth("认证方式", "密码");
y = Row(_cardConn, y, 42, new Lbl("服务器 URL", w1), _fUrl);
Track(_fUrl, _cardConn, M, 0); Track(_fUrl, _cardConn, M, 0);
y = Row(_cardConn, y, 42, new Lbl("团队项目", w1), _fProject,
new Lbl("工作项类型", w2), _fType, new Lbl("认证方式", w3), _fAuth); // 第二行:项目 / 类型 / 用户 / 密码 + 测试连接同排,四个输入框等宽、整行铺满卡片内容区。
y = Row(_cardConn, y, 42, new Lbl("域", w1), _fDomain, // 标签自建并留引用——窗口变宽时要连标签一起重摆,Row() 建完就丢引用做不到。
new Lbl("用户", w2), _fUser, new Lbl("密码", w3), _fPwd, G, _btnTest); _connRowY = y;
y = Row(_cardConn, y, 26, _chkProxy); var captions = new[] { "团队项目", "工作项类型", "用户", "密码" };
_connRowCaptions = new Label[captions.Length];
_connRowFields = new[] { _fProject, _fType, _fUser, _fPwd };
for (int i = 0; i < captions.Length; i++)
{
// 标签宽度由布局按各自文字量给(不死板地共用一条定宽列):四个标签字数不同
// (2~5 字),共用定宽列会让「用户」「密码」和自己的输入框之间空出一大截。
var lbl = new Label
{
Text = captions[i],
Font = Theme.Body,
ForeColor = Theme.Ink2,
AutoSize = false, // 宽度每次重排时按文字量给
TextAlign = ContentAlignment.MiddleRight, // 文字右缘贴住输入框
BackColor = Theme.Card
};
_cardConn.Controls.Add(lbl);
_connRowCaptions[i] = lbl;
_connRowFields[i].AccessibleName = captions[i];
_cardConn.Controls.Add(_connRowFields[i]);
}
_cardConn.Controls.Add(_btnTest);
_layoutExtras.Add(LayoutConnRow);
y = _connRowY + Theme.Px(42);
_cardConn.BodyH = y - _cardConn.HeaderH + Theme.S8; _cardConn.BodyH = y - _cardConn.HeaderH + Theme.S8;
} }
/// <summary>
/// 第 1 步第二行排布:四个输入框同宽,整行(含「测试连接」)刚好铺满卡片内容区。
/// 每个标签只占自己文字的宽度,于是「文字 → 输入框」恒为 6px、「输入框 → 下一个标签」
/// 恒为 S12,四组间距完全一致;省下的宽度全给输入框,输入框因此比按定宽标签列算的更宽。
/// 每次重排按当期卡片宽度重算,缩放/拉伸下都不溢出、不留白。
/// </summary>
private void LayoutConnRow()
{
if (_connRowFields == null || _connRowCaptions == null) return;
int gap = Theme.S12; // 组与组之间
int lblGap = Theme.Px(6); // 标签文字 → 输入框(全行同一个值)
int n = _connRowFields.Length;
int avail = _cardConn.Width - M * 2;
// 每个标签只占自己文字宽度(+2px 防末字被裁),省下的宽度全部喂给输入框,
// 这样「文字→输入框」和「输入框→下一个标签」两处间距在四组上都是同一个值。
var lblW = new int[n];
int textSum = 0;
for (int i = 0; i < n; i++)
{
lblW[i] = Theme.TextWidth(_connRowCaptions[i].Text, Theme.Body) + Theme.Px(2);
textSum += lblW[i];
}
int boxW = (avail - textSum - n * lblGap - (n - 1) * gap - G - _btnTest.Width) / n;
boxW = Math.Max(Theme.Px(96), boxW);
int x = M;
for (int i = 0; i < n; i++)
{
_connRowCaptions[i].SetBounds(x, _connRowY, lblW[i], Theme.FieldH);
x += lblW[i] + lblGap;
_connRowFields[i].SetBounds(x, _connRowY, boxW, Theme.FieldH);
x += boxW + gap;
}
_btnTest.SetBounds(x - gap + G, _connRowY, _btnTest.Width, Theme.BtnH);
}
// --- 步骤 2:迭代与数据源 --- // --- 步骤 2:迭代与数据源 ---
private void BuildCardData() private void BuildCardData()
{ {
@@ -276,11 +334,14 @@ namespace TfsExcelImporter
_fExcel = NewField(_txtExcel, 500, true); _fExcel = NewField(_txtExcel, 500, true);
_fParent = NewField(_txtParentPbi, 150, true); _fParent = NewField(_txtParentPbi, 150, true);
CueBanner.Set(_cmbIteration, "选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1");
CueBanner.Set(_txtExcel, "选择 .xlsx 文件,或点右侧「浏览…」");
CueBanner.Set(_txtParentPbi, "如 219975");
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130); _btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130);
_btnBrowse = NewButton("浏览…", BtnKind.Secondary, IconKind.Folder, 92); _btnBrowse = NewButton("浏览…", BtnKind.Secondary, IconKind.Folder, 92);
_btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142); _btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142);
_chkSkip = NewCheck("跳过已存在标题", ""); _chkSkip = NewCheck("跳过已存在标题", "");
_chkBypass = NewCheck("bypassRules(绕过字段校验规则)", "");
// 三行字段对齐同一条左缘:标签列宽取最大值 // 三行字段对齐同一条左缘:标签列宽取最大值
int wLbl = MaxLabelWidth("目标迭代", "Excel 文件", "父 PBI Id"); int wLbl = MaxLabelWidth("目标迭代", "Excel 文件", "父 PBI Id");
@@ -292,8 +353,12 @@ namespace TfsExcelImporter
AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top); AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top);
AnchorRight(_btnBrowse, _cardData, _btnTemplate.Width + G + M, _fExcel.Top); AnchorRight(_btnBrowse, _cardData, _btnTemplate.Width + G + M, _fExcel.Top);
Track(_fExcel, _cardData, _btnTemplate.Width + _btnBrowse.Width + G * 2 + M, 0); Track(_fExcel, _cardData, _btnTemplate.Width + _btnBrowse.Width + G * 2 + M, 0);
y = Row(_cardData, y, 42, new Lbl("父 PBI Id", wLbl), _fParent, "Task 默认挂到该 backlog item;Excel 里加 PBI Id 列可逐行覆盖"); y = Row(_cardData, y, 42, new Lbl("父 PBI Id", wLbl), _fParent,
y = Row(_cardData, y, 24, _chkSkip, 2 * G, _chkBypass); new Hint("Task 默认挂到该 backlog item;Excel 里加 PBI Id 列可逐行覆盖"));
// 勾选项不再贴着标签起头:整体右移到上面三行输入框的左缘(跳过标签列)。
// 只有「去重」这一个开关;字段校验不可绕过(OM 14.x 无 BypassRules),失败时由
// TfsClient 输出可操作的修复提示。
y = Row(_cardData, y, 32, _fParent.Left - M, _chkSkip);
_errIteration = AddErrorUnder(_cardData, _fIteration); _errIteration = AddErrorUnder(_cardData, _fIteration);
_errExcel = AddErrorUnder(_cardData, _fExcel); _errExcel = AddErrorUnder(_cardData, _fExcel);
@@ -474,11 +539,13 @@ namespace TfsExcelImporter
_cardAdv = MakeCard(0, "高级工具", true, Theme.Px(36)); _cardAdv = MakeCard(0, "高级工具", true, Theme.Px(36));
_cardAdv.ShowBadge = false; _cardAdv.ShowBadge = false;
_fArea = NewField(_txtAreaPath, 420, true); _fArea = NewField(_txtAreaPath, 420, true);
CueBanner.Set(_txtAreaPath, "如 Tools_Dev\\导入工具,留空则沿用工作项现有 Area");
_btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158); _btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158);
_btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120); _btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120);
int y = _cardAdv.HeaderH + Theme.S12; int y = _cardAdv.HeaderH + Theme.S12;
y = Row(_cardAdv, y, 52, "Area 路径", _fArea, "改 Area 与补挂父项都按上面的「目标迭代」筛选已有工作项"); y = Row(_cardAdv, y, 52, "Area 路径", _fArea,
new Hint("改 Area 与补挂父项都按上面的「目标迭代」筛选已有工作项"));
y = Row(_cardAdv, y, 34, _btnFixArea, G, _btnLinkParent, G, "已关闭的工作项会被临时重新打开,完成后自动还原状态"); y = Row(_cardAdv, y, 34, _btnFixArea, G, _btnLinkParent, G, "已关闭的工作项会被临时重新打开,完成后自动还原状态");
_errArea = AddErrorUnder(_cardAdv, _fArea); _errArea = AddErrorUnder(_cardAdv, _fArea);
_cardAdv.BodyH = y - _cardAdv.HeaderH + Theme.S8; _cardAdv.BodyH = y - _cardAdv.HeaderH + Theme.S8;
@@ -494,6 +561,14 @@ namespace TfsExcelImporter
public Lbl(string text, int minWidth) { Text = text; MinWidth = minWidth; } public Lbl(string text, int minWidth) { Text = text; MinWidth = minWidth; }
} }
/// <summary>紧跟在字段后面的灰色说明(Caption 字号 + Ink3):非标签、不参与标签列宽计算。</summary>
private class Hint
{
public readonly string Text;
public Hint(string text) { Text = text; }
}
/// <summary>一组标签文字的最大实测宽度,用作对齐列宽。</summary> /// <summary>一组标签文字的最大实测宽度,用作对齐列宽。</summary>
private static int MaxLabelWidth(params string[] texts) private static int MaxLabelWidth(params string[] texts)
{ {
@@ -532,6 +607,23 @@ namespace TfsExcelImporter
if (item is int) { x += (int)item; continue; } if (item is int) { x += (int)item; continue; }
var hint = item as Hint;
if (hint != null)
{
var hl = new Label
{
Text = hint.Text,
Font = Theme.Caption,
ForeColor = Theme.Ink3,
AutoSize = true,
BackColor = Theme.Card
};
hl.SetBounds(x, y + (Theme.FieldH - hl.PreferredHeight) / 2, hl.PreferredWidth, hl.PreferredHeight);
card.Controls.Add(hl);
x += hl.Width + Theme.S12;
continue;
}
var c = item as Control; var c = item as Control;
if (c == null) continue; if (c == null) continue;
int top = y + Math.Max(0, (Theme.FieldH - c.Height) / 2); int top = y + Math.Max(0, (Theme.FieldH - c.Height) / 2);
@@ -793,8 +885,7 @@ namespace TfsExcelImporter
{ {
ShortHost(_txtUrl.Text.Trim()), ShortHost(_txtUrl.Text.Trim()),
_txtProject.Text.Trim(), _txtProject.Text.Trim(),
_txtType.Text.Trim(), _txtType.Text.Trim()
_cmbAuth.SelectedItem != null ? _cmbAuth.SelectedItem.ToString() : "ntlm"
})); }));
_cardData.SetSummary(string.Join(" · ", new[] _cardData.SetSummary(string.Join(" · ", new[]
{ {
+79 -27
View File
@@ -23,31 +23,18 @@ namespace TfsExcelImporter
public TfsClient(AppConfig cfg) public TfsClient(AppConfig cfg)
{ {
ICredentials cred; // 认证固定 NTLM:用户名可写 user(用当前登录域),也可写 DOMAIN\user 显式指定域。
var mode = (cfg.AuthMode ?? "ntlm").ToLowerInvariant(); var user = (cfg.Username ?? "").Trim();
if (mode == "default") var pwd = cfg.Password ?? "";
{ var sep = user.IndexOf('\\');
cred = CredentialCache.DefaultCredentials; ICredentials cred = sep > 0
} ? new NetworkCredential(user.Substring(sep + 1), pwd, user.Substring(0, sep))
else : new NetworkCredential(user, pwd);
{
var user = string.IsNullOrEmpty(cfg.Domain) ? cfg.Username : cfg.Domain + "\\" + cfg.Username;
cred = new NetworkCredential(user, cfg.Password ?? "");
}
_tpc = new TfsTeamProjectCollection(new Uri(cfg.TfsUrl), cred); _tpc = new TfsTeamProjectCollection(new Uri(cfg.TfsUrl), cred);
// TFS OM 内部用 HttpWebRequest 传输,默认跟随 IE 系统代理——实测代理会拦内网 TFS 返回 502。 // TFS OM 内部用 HttpWebRequest 传输,默认跟随 IE 系统代理——实测代理会拦内网 TFS 返回 502。
// 默认直连;勾选"使用系统代理"时保留系统代理并带 Windows 凭据过代理认证。 // 因此固定直连:把 DefaultWebProxy 置空,不读取本机系统代理设置。
if (!cfg.UseSystemProxy) WebRequest.DefaultWebProxy = new WebProxy();
{
WebRequest.DefaultWebProxy = new WebProxy(); // 空 WebProxy = 不走代理,直连
}
else
{
var p = WebRequest.DefaultWebProxy ?? new WebProxy();
p.Credentials = CredentialCache.DefaultCredentials;
WebRequest.DefaultWebProxy = p;
}
} }
/// <summary>WorkItemStore 惰性初始化(首次调用会下载字段/类型元数据,耗时数秒属正常)。</summary> /// <summary>WorkItemStore 惰性初始化(首次调用会下载字段/类型元数据,耗时数秒属正常)。</summary>
@@ -187,14 +174,16 @@ namespace TfsExcelImporter
/// <summary> /// <summary>
/// 创建工作项(OM Save)。返回新工作项 ID。 /// 创建工作项(OM Save)。返回新工作项 ID。
/// 说明:OM 14.x 的 SaveFlags 无 BypassRules(15.x 才加入,且 15.x 不兼容 TFS 2013)—— /// 说明:字段校验不可绕过——OM 14.x 的 SaveFlags 无 BypassRules(15.x 才加入,且 15.x 不兼容 TFS 2013),
/// 因此校验失败时直接把「哪个字段 / 什么原因 / 怎么改」写进异常(见 CheckInvalid),
/// 不做静默降级,避免产出违反模板规则的脏数据。
/// System.CreatedDate 为服务器管理的只读字段,无法写入(取导入时间); /// System.CreatedDate 为服务器管理的只读字段,无法写入(取导入时间);
/// Microsoft.VSTS.Common.ClosedDate 在 State=Done 时按普通规则通常可写:尝试写入, /// Microsoft.VSTS.Common.ClosedDate 在 State=Done 时按普通规则通常可写:尝试写入,
/// 校验或保存失败则清空该字段降级为普通保存(保证行导入成功)。 /// 校验或保存失败则清空该字段降级为普通保存(保证行导入成功)。
/// parentPbiId 非空时,通过 System.LinkTypes.Hierarchy(Reverse=Parent) 挂接到该 PBI。 /// parentPbiId 非空时,通过 System.LinkTypes.Hierarchy(Reverse=Parent) 挂接到该 PBI。
/// </summary> /// </summary>
public async Task<int> CreateWorkItemAsync(string project, string workItemType, public async Task<int> CreateWorkItemAsync(string project, string workItemType,
List<KeyValuePair<string, object>> fields, bool bypassRules, int? parentPbiId = null) List<KeyValuePair<string, object>> fields, int? parentPbiId = null)
{ {
return await Task.Run(() => return await Task.Run(() =>
{ {
@@ -269,14 +258,77 @@ namespace TfsExcelImporter
return null; return null;
} }
/// <summary>
/// 把 wi.Validate() 的结果整理成「字段(原因) → 怎么改」的单行提示。
/// 单行是硬要求:这段文字会写进 import_report.csv,换行会破坏 CSV 的行结构。
/// </summary>
private static string CheckInvalid(WorkItem wi) private static string CheckInvalid(WorkItem wi)
{ {
var invalid = wi.Validate(); var invalid = wi.Validate();
if (invalid.Count == 0) return null; if (invalid.Count == 0) return null;
var names = new StringBuilder(); var sb = new StringBuilder();
foreach (Field f in invalid) foreach (Field f in invalid)
names.Append(f.Name).Append("(").Append(FieldStatusText(f)).Append("); "); {
return names.ToString(); if (sb.Length > 0) sb.Append(";");
sb.Append(f.Name).Append('(').Append(FieldStatusText(f)).Append(") → ")
.Append(FixHint(f));
}
return sb.ToString();
}
/// <summary>
/// 按字段状态给出修复动作:导入失败时用户拿到的应该是「补哪一列、改成什么」,
/// 而不是只有一句「字段校验失败」。注意校验无法绕过(OM 14.x 无 BypassRules)。
/// </summary>
private static string FixHint(Field f)
{
switch (f.Status.ToString())
{
case "InvalidEmpty":
case "InvalidNull":
return "必填但没值:Excel 补上这一列,并确认字段映射是否对上" + AllowedHint(f);
case "InvalidTooLong":
return "值超长:截断到上限内(TFS 字符串字段默认上限 255 字符)";
case "InvalidType":
return "类型不匹配,该字段是 " + FieldTypeText(f) + ":检查单元格里是文本还是数字/日期";
case "InvalidValue":
case "InvalidList":
return "值不在允许范围" + AllowedHint(f);
case "InvalidFormat":
return "格式不对:日期写 yyyy-MM-dd,数字不要带单位或千分位";
case "InvalidComputed":
return "计算字段不可写:把这一列从 Excel 去掉";
case "InvalidNotNew":
return "只在新建时可设置:不要对已存在工作项改这一列";
default:
return "按服务器返回的规则修正该字段的值";
}
}
/// <summary>列出字段的允许值(列表字段 InvalidList 的主因),最多展开 8 个。</summary>
private static string AllowedHint(Field f)
{
try
{
var vals = new List<string>();
int total = 0;
foreach (object v in f.AllowedValues)
{
total++;
if (vals.Count < 8) vals.Add(Convert.ToString(v));
}
if (vals.Count == 0) return "";
return "(允许值: " + string.Join(" / ", vals.ToArray())
+ (total > vals.Count ? " 等 " + total + " 项" : "") + ")";
}
catch { return ""; }
}
/// <summary>字段类型(String / Integer / DateTime ...),用于提示类型不匹配。</summary>
private static string FieldTypeText(Field f)
{
try { return f.FieldDefinition.FieldType.ToString(); }
catch { return "未知类型"; }
} }
/// <summary>把 FieldStatus 翻译为可读原因(用字符串匹配,避免不同 OM 版本枚举成员差异)。</summary> /// <summary>把 FieldStatus 翻译为可读原因(用字符串匹配,避免不同 OM 版本枚举成员差异)。</summary>
-4
View File
@@ -4,12 +4,8 @@
"WorkItemType": "Task", "WorkItemType": "Task",
"IterationPath": "", "IterationPath": "",
"AreaPath": "Tools_Dev\\DataTechnologies", "AreaPath": "Tools_Dev\\DataTechnologies",
"AuthMode": "ntlm",
"Username": "", "Username": "",
"Password": "", "Password": "",
"Domain": "",
"UseSystemProxy": false,
"BypassRules": true,
"SkipExistingTitles": true, "SkipExistingTitles": true,
"DefaultState": "To Do", "DefaultState": "To Do",
"FinancialProjectRef": "", "FinancialProjectRef": "",
-36
View File
@@ -1,36 +0,0 @@
{
"tfs_url": "http://pdtfs01.dev.goldenstand.cn:8080/tfs/DefaultCollection",
"project": "Tools_Dev",
"team": "DataTechnologies",
"work_item_type": "Task",
"parent_title": "陈首林",
"parent_id": "",
"iteration_path": "2026年Sprint\\2026年Sprint10",
"area_path": "",
"auth": {
"mode": "ntlm",
"username": "chenshoulin",
"password": "Password72",
"domain": ""
},
"field_refs": {
"title": "System.Title",
"state": "System.State",
"assigned_to": "System.AssignedTo",
"financial_project": "",
"completed": "",
"log_date": "",
"created_date": "System.CreatedDate",
"closed_date": "Microsoft.VSTS.Common.ClosedDate",
"iteration_path": "System.IterationPath",
"area_path": "System.AreaPath"
},
"resolve_fields_by_display_name": {
"financial_project": "FinancialProject",
"completed": "Completed",
"log_date": "LogDate"
},
"bypass_rules": true,
"skip_existing_titles": true,
"default_state": "To Do"
}
-3
View File
@@ -1,3 +0,0 @@
Title,State,AssignedTo,FinancialProject,Completed,LogDate,CreatedDate,ClosedDate,PBI Id,Notes
20261012 - 示例:货载报告解析并上传凭证受托报告相关内容,To Do,张三,信托 - 上海信托,5,2026-10-12,2026-10-30 11:14,,,示例行,导入前请删除
20261009 示例:受托报告bug修复,Done,李四,信托 - 上海信托,8,2026-10-09,2026-10-10 18:20:00,2026-10-30 18:00:00,219975,PBI Id 列可逐行指定父项,留空则用配置里的 parent_title/parent_id
1 Title State AssignedTo FinancialProject Completed LogDate CreatedDate ClosedDate PBI Id Notes
2 20261012 - 示例:货载报告解析并上传凭证受托报告相关内容 To Do 张三 信托 - 上海信托 5 2026-10-12 2026-10-30 11:14 示例行,导入前请删除
3 20261009 示例:受托报告bug修复 Done 李四 信托 - 上海信托 8 2026-10-09 2026-10-10 18:20:00 2026-10-30 18:00:00 219975 PBI Id 列可逐行指定父项,留空则用配置里的 parent_title/parent_id
-6
View File
@@ -1,6 +0,0 @@
[1101/171941.143:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2)
[1101/173900.093:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2)
[1101/184906.941:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2)
[1101/191310.883:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2)
[1101/193634.522:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2)
[1101/195235.112:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2)
-2
View File
@@ -1,2 +0,0 @@
requests>=2.28
requests-ntlm>=1.2
-467
View File
@@ -1,467 +0,0 @@
#!/usr/bin/env python3
"""
TFS 2013 工作项批量导入工具
目标环境(来自截图):
服务器 : http://pdtfs01.dev.goldenstand.cn:8080/tfs/DefaultCollection
团队项目 : Tools_Dev
团队 : DataTechnologies
迭代路径 : 2026年Sprint\\2026年Sprint10 (URL: .../_backlogs/Iteration/2026%E5%B9%B4Sprint/2026%E5%B9%B4Sprint10)
数据源: data/work_items.csv(Title/State/AssignedTo/FinancialProject/Completed/LogDate/CreatedDate/ClosedDate)
字段映射: config.json -> field_refs;留空的自定义字段按显示名自动解析。
用法:
python tfs_import.py --dry-run # 只校验数据与字段,不写入
python tfs_import.py # 正式导入
python tfs_import.py --link-existing # 已导入的项补挂父链接(不新建)
python tfs_import.py --input other.csv # 指定数据文件
python tfs_import.py --skip-existing-titles # 跳过已存在同标题工作项
python tfs_import.py --list-fields # 列出服务器全部字段(显示名/引用名)
认证(config.json auth.mode):
ntlm : 域\\用户 + 密码(TFS 2013 典型配置)
basic : 服务器开了基本身份验证时使用
默认凭据: username 留空则尝试当前 Windows 凭据(仅 basic/ntlm 均需显式账号,TFS 2013 一般用 ntlm)
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
import time
from datetime import datetime
from pathlib import Path
try:
import requests
except ImportError:
print("缺少依赖 requests,请先: pip install -r requirements.txt", file=sys.stderr)
sys.exit(2)
BASE_DIR = Path(__file__).resolve().parent
API_VER = "1.0"
DATE_FMT_IN = ["%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M"]
# ---------------------------------------------------------------- 配置与数据
def load_config(path: Path) -> dict:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def load_rows(path: Path) -> list[dict]:
# 依次尝试 utf-8-sig / gbk,兼容新旧 CSV 编码
rows: list[dict] | None = None
last_err: Exception | None = None
for enc in ("utf-8-sig", "gbk"):
try:
with path.open("r", encoding=enc, newline="") as f:
reader = csv.DictReader(f)
rows = [r for r in reader if (r.get("Title") or "").strip()]
break
except UnicodeDecodeError as e:
last_err = e
if rows is None:
raise SystemExit(f"无法读取 CSV {path}(utf-8/gbk 均失败): {last_err}")
return rows
def parse_dt(value: str) -> str | None:
"""把 CSV 里的时间统一成 TFS 接受的 ISO 格式;空值返回 None。"""
value = (value or "").strip()
if not value:
return None
for fmt in DATE_FMT_IN:
try:
return datetime.strptime(value, fmt).strftime("%Y-%m-%dT%H:%M:%S")
except ValueError:
continue
raise ValueError(f"无法解析时间: {value!r}")
# ---------------------------------------------------------------- HTTP 会话
def build_session(cfg: dict) -> requests.Session:
auth = cfg.get("auth", {})
mode = (auth.get("mode") or "ntlm").lower()
sess = requests.Session()
sess.verify = False
sess.headers.update({"Accept": "application/json"})
user = auth.get("username") or ""
pwd = auth.get("password") or ""
domain = auth.get("domain") or ""
if mode == "ntlm":
from requests_ntlm import HttpNtlmAuth
if not user or not pwd:
raise SystemExit("ntlm 模式需要 config.json 里配置 auth.username / auth.password")
login = f"{domain}\\{user}" if domain else user
sess.auth = HttpNtlmAuth(login, pwd)
elif mode == "basic":
if not user or not pwd:
raise SystemExit("basic 模式需要 config.json 里配置 auth.username / auth.password")
sess.auth = (f"{domain}\\{user}" if domain else user, pwd)
else:
raise SystemExit(f"未知 auth.mode: {mode}")
return sess
def tfs_send(sess: requests.Session, method: str, url: str, *, retries: int = 3,
**kwargs) -> requests.Response:
"""带退避重试的请求:502/503/504 网关错误或网络异常时最多重试 retries 次。"""
kwargs.setdefault("timeout", 60)
resp: requests.Response | None = None
for attempt in range(1, retries + 1):
try:
resp = sess.request(method, url, **kwargs)
except requests.RequestException as e:
if attempt == retries:
raise
wait = 3 * attempt
print(f"[重试] {method.upper()} {url} 网络异常: {e};{wait}s 后第 {attempt + 1} 次尝试")
time.sleep(wait)
continue
if resp.status_code in (502, 503, 504) and attempt < retries:
wait = 3 * attempt
print(f"[重试] {method.upper()} {url} -> {resp.status_code};{wait}s 后第 {attempt + 1} 次尝试")
time.sleep(wait)
continue
return resp
raise RuntimeError(f"请求重试 {retries} 次后仍失败: {method.upper()} {url}")
def tfs_get(sess: requests.Session, url: str, params: dict | None = None) -> dict:
resp = tfs_send(sess, "get", url, params=params)
if resp.status_code >= 400:
raise RuntimeError(f"GET {url} -> {resp.status_code}: {resp.text[:500]}")
return resp.json()
def tfs_wiql(sess: requests.Session, url: str, wiql: str) -> dict:
resp = tfs_send(sess, "post", url, params={"api-version": API_VER}, json={"query": wiql})
if resp.status_code >= 400:
raise RuntimeError(f"WIQL 查询失败 {resp.status_code}: {resp.text[:500]}")
return resp.json()
# ---------------------------------------------------------------- 字段解析
def resolve_fields(sess: requests.Session, base: str, cfg: dict) -> dict:
"""返回 {语义名: 引用名}。优先用 config.field_refs 显式值,其次按显示名查服务器字段表。"""
refs = dict(cfg.get("field_refs") or {})
by_display = cfg.get("resolve_fields_by_display_name") or {}
need_lookup = [k for k, v in by_display.items() if not refs.get(k)]
if not need_lookup:
return refs
data = tfs_get(sess, f"{base}/_apis/wit/fields", params={"api-version": API_VER})
catalog = data.get("value", [])
lower_map = {}
for f in catalog:
lower_map.setdefault(f.get("name", "").strip().lower(), f.get("referenceName"))
for key in need_lookup:
display = by_display.get(key) or key
ref = lower_map.get(display.strip().lower())
if not ref:
# 宽松匹配: 显示名以配置词开头(如 Completed -> Completed Work)
for f in catalog:
name = f.get("name", "").strip().lower()
if name.startswith(display.strip().lower()):
ref = f.get("referenceName")
print(f"[字段] {key}: {display!r} 模糊匹配到 {f.get('name')!r} -> {ref}")
break
if not ref:
avail = ", ".join(sorted({f.get("name", "") for f in catalog if f.get("name")}))[:800]
raise SystemExit(
f"字段 {key} 的显示名 {display!r} 在服务器上找不到,请在 config.json 的 "
f"field_refs 里显式配置引用名。\n服务器字段(截断): {avail}"
)
refs[key] = ref
print(f"[字段] {key}: {display!r} -> {ref}")
return refs
# ---------------------------------------------------------------- 重复检测
def existing_titles(sess: requests.Session, base: str, project: str, iteration: str,
type_name: str) -> set[str]:
wiql = (
"SELECT [System.Title] FROM WorkItems "
f"WHERE [System.TeamProject] = '{project}' "
f"AND [System.WorkItemType] = '{type_name}' "
f"AND [System.IterationPath] = '{iteration}'"
)
data = tfs_wiql(sess, f"{base}/{project}/_apis/wit/wiql", wiql)
ids = [w["id"] for w in data.get("workItems", [])]
titles: set[str] = set()
for i in range(0, len(ids), 50):
chunk = ids[i:i + 50]
data = tfs_get(
sess,
f"{base}/_apis/wit/workitems",
params={"ids": ",".join(map(str, chunk)), "fields": "System.Title", "api-version": API_VER},
)
for item in data.get("value", []):
titles.add(item.get("fields", {}).get("System.Title", ""))
return titles
# ---------------------------------------------------------------- 构造与提交
def build_patch(row: dict, refs: dict, cfg: dict, iteration_full: str, area: str) -> list[dict]:
ops: list[dict] = []
def add(ref: str | None, value):
if not ref or value is None or value == "":
return
ops.append({"op": "add", "path": f"/fields/{ref}", "value": value})
add(refs["title"], row["Title"].strip())
add(refs["state"], (row.get("State") or cfg.get("default_state") or "To Do").strip())
add(refs["assigned_to"], (row.get("AssignedTo") or "").strip())
add(refs["iteration_path"], iteration_full)
if area:
add(refs["area_path"], area)
add(refs.get("financial_project"), (row.get("FinancialProject") or "").strip())
completed = (row.get("Completed") or "").strip()
if completed:
add(refs.get("completed"), float(completed) if "." in completed else int(completed))
log_date = parse_dt(row.get("LogDate"))
if log_date:
add(refs.get("log_date"), log_date)
# CreatedDate / ClosedDate 是系统字段,常规接口不可写;bypassRules=true 时部分模板可写。
created = parse_dt(row.get("CreatedDate"))
if created:
add(refs.get("created_date"), created)
closed = parse_dt(row.get("ClosedDate"))
if closed:
add(refs.get("closed_date"), closed)
return ops
def create_work_item(sess: requests.Session, base: str, project: str, type_name: str,
ops: list[dict], bypass_rules: bool) -> dict:
url = f"{base}/{project}/_apis/wit/workitems/${requests.utils.quote(type_name)}"
params = {"api-version": API_VER}
if bypass_rules:
params["bypassRules"] = "true"
headers = {"Content-Type": "application/json-patch+json"}
resp = tfs_send(sess, "patch", url, params=params, headers=headers,
data=json.dumps(ops, ensure_ascii=False))
if resp.status_code >= 400:
raise RuntimeError(f"PATCH {url} -> {resp.status_code}: {resp.text[:800]}")
return resp.json()
# ---------------------------------------------------------------- 父项挂接
def query_work_item_ids(sess: requests.Session, base: str, project: str,
type_name: str, iteration: str) -> list[int]:
wiql = (
"SELECT [System.Id] FROM WorkItems "
f"WHERE [System.TeamProject] = '{project}' "
f"AND [System.WorkItemType] = '{type_name}' "
f"AND [System.IterationPath] = '{iteration}'"
)
data = tfs_wiql(sess, f"{base}/{project}/_apis/wit/wiql", wiql)
return [w["id"] for w in data.get("workItems", [])]
def find_parent_id(sess: requests.Session, base: str, project: str, cfg: dict) -> int | None:
"""解析父工作项:优先 config.parent_id,其次按 parent_title 全项目精确匹配标题。"""
pid = str(cfg.get("parent_id") or "").strip()
if pid:
return int(pid)
title = (cfg.get("parent_title") or "").strip()
if not title:
return None
safe_title = title.replace("'", "''")
wiql = (
"SELECT [System.Id] FROM WorkItems "
f"WHERE [System.TeamProject] = '{project}' "
f"AND [System.Title] = '{safe_title}' "
"AND [System.WorkItemType] <> 'Task'"
)
data = tfs_wiql(sess, f"{base}/{project}/_apis/wit/wiql", wiql)
items = data.get("workItems", [])
if not items:
raise SystemExit(
f"按标题找不到父工作项: {title!r}\n"
f"请把 config.json 的 parent_title 改成积压工作里已有的条目标题,"
f"或直接填 parent_id。"
)
if len(items) > 1:
print(f"[父项] 警告: 标题 {title!r} 匹配到 {len(items)} 条,取最新创建的一条")
# 取 CreatedDate 最大(最新)的一条
return max(int(w["id"]) for w in items)
def task_has_parent(item: dict) -> bool:
for rel in item.get("relations") or []:
if rel.get("rel") == "System.LinkTypes.Hierarchy-Reverse":
return True
return False
def link_parent(sess: requests.Session, base: str, task_id: int, parent_id: int) -> None:
ops = [{
"op": "add",
"path": "/relations/-",
"value": {
"rel": "System.LinkTypes.Hierarchy-Reverse",
"url": f"{base}/_apis/wit/workitems/{parent_id}",
},
}]
url = f"{base}/_apis/wit/workitems/{task_id}"
resp = tfs_send(
sess, "patch", url,
params={"api-version": API_VER},
headers={"Content-Type": "application/json-patch+json"},
data=json.dumps(ops),
)
if resp.status_code >= 400:
raise RuntimeError(f"挂接 #{task_id} -> #{parent_id} 失败 {resp.status_code}: {resp.text[:500]}")
def link_existing_tasks(sess: requests.Session, base: str, project: str, cfg: dict,
iteration_full: str, parent_id: int) -> int:
"""把已导入的同类型/同迭代 Task 补挂到父项下;已有父的跳过。"""
type_name = cfg["work_item_type"]
ids = query_work_item_ids(sess, base, project, type_name, iteration_full)
print(f"[补挂] 找到 {type_name} {len(ids)} 条,父项 #{parent_id}")
linked = skipped = failed = 0
for tid in ids:
try:
data = tfs_get(sess, f"{base}/_apis/wit/workitems/{tid}", params={"api-version": API_VER})
if task_has_parent(data):
skipped += 1
continue
link_parent(sess, base, tid, parent_id)
linked += 1
print(f"[补挂] OK #{tid} -> #{parent_id}")
except Exception as e:
failed += 1
print(f"[补挂] FAIL #{tid} -> {e}")
print(f"[补挂] 汇总: 成功 {linked} / 已有父跳过 {skipped} / 失败 {failed}")
return 0 if not failed else 1
# ---------------------------------------------------------------- 主流程
def main() -> int:
ap = argparse.ArgumentParser(description="TFS 2013 工作项批量导入")
ap.add_argument("--config", default=str(BASE_DIR / "config.json"))
ap.add_argument("--input", default=str(BASE_DIR / "data" / "work_items.csv"))
ap.add_argument("--dry-run", action="store_true", help="只校验不写入")
ap.add_argument("--skip-existing-titles", action="store_true", help="跳过服务器已存在的同标题项")
ap.add_argument("--list-fields", action="store_true", help="列出服务器字段表后退出")
ap.add_argument("--link-existing", action="store_true",
help="把已导入的同类型/同迭代工作项补挂到父项下(不新建)")
ap.add_argument("--limit", type=int, default=0, help="只处理前 N 条(调试用)")
args = ap.parse_args()
cfg = load_config(Path(args.config))
base = cfg["tfs_url"].rstrip("/")
project = cfg["project"]
type_name = cfg["work_item_type"]
iteration_cfg = cfg.get("iteration_path") or ""
iteration_full = f"{project}\\{iteration_cfg}" if iteration_cfg and not iteration_cfg.startswith(project) else iteration_cfg
area = cfg.get("area_path") or ""
bypass_rules = bool(cfg.get("bypass_rules", True))
# --link-existing 不读数据文件,直接进补挂流程
if args.link_existing:
sess = build_session(cfg)
parent_id = find_parent_id(sess, base, project, cfg)
if not parent_id:
raise SystemExit("--link-existing 需要配置 parent_title 或 parent_id")
print(f"[父项] #{parent_id}")
print(f"[迭代] {iteration_full}")
return link_existing_tasks(sess, base, project, cfg, iteration_full, parent_id)
rows = load_rows(Path(args.input))
if args.limit:
rows = rows[: args.limit]
print(f"[数据] 共 {len(rows)} 行待导入: {args.input}")
sess = build_session(cfg)
if args.list_fields:
data = tfs_get(sess, f"{base}/_apis/wit/fields", params={"api-version": API_VER})
for f in sorted(data.get("value", []), key=lambda x: x.get("name", "")):
print(f"{f.get('referenceName', ''):<55} {f.get('name', '')}")
return 0
refs = resolve_fields(sess, base, cfg)
print(f"[迭代] {iteration_full}")
parent_id = find_parent_id(sess, base, project, cfg)
if parent_id:
print(f"[父项] #{parent_id}")
else:
print("[父项] 未配置 parent_title/parent_id,导入的项不挂父链接")
known: set[str] = set()
if args.skip_existing_titles or args.dry_run:
try:
known = existing_titles(sess, base, project, iteration_full, type_name)
print(f"[去重] 服务器同类型/同迭代已有 {len(known)} 条")
except Exception as e:
print(f"[去重] 查询失败(继续): {e}")
ok = fail = skipped = 0
report_path = BASE_DIR / "data" / f"import_report_{time.strftime('%Y%m%d_%H%M%S')}.csv"
with report_path.open("w", encoding="utf-8-sig", newline="") as rf:
writer = csv.writer(rf)
writer.writerow(["行号", "Title", "结果", "WorkItemId", "错误"])
for idx, row in enumerate(rows, 1):
title = row["Title"].strip()
try:
if args.skip_existing_titles and title in known:
skipped += 1
writer.writerow([idx, title, "SKIP", "", "标题已存在"])
print(f"[{idx}/{len(rows)}] SKIP {title}")
continue
ops = build_patch(row, refs, cfg, iteration_full, area)
if args.dry_run:
ok += 1
writer.writerow([idx, title, "DRY", "", json.dumps(ops, ensure_ascii=False)[:200]])
print(f"[{idx}/{len(rows)}] DRY {title} ({len(ops)} ops)")
continue
result = create_work_item(sess, base, project, type_name, ops, bypass_rules)
wid = result.get("id")
if parent_id and wid:
try:
link_parent(sess, base, int(wid), parent_id)
print(f"[挂接] #{wid} -> 父项 #{parent_id}")
except Exception as e:
print(f"[挂接] FAIL #{wid} -> {e}")
ok += 1
writer.writerow([idx, title, "OK", wid, ""])
print(f"[{idx}/{len(rows)}] OK #{wid} {title}")
except Exception as e:
fail += 1
writer.writerow([idx, title, "FAIL", "", str(e)[:500]])
print(f"[{idx}/{len(rows)}] FAIL {title} -> {e}")
print(f"\n[汇总] 成功 {ok} / 跳过 {skipped} / 失败 {fail};报告: {report_path}")
return 1 if fail else 0
if __name__ == "__main__":
sys.exit(main())
+16
View File
@@ -146,6 +146,22 @@ def crop_zoom(src_png_w, src_png_h, rgba, cw, ch, scale, path, ox=0, oy=0):
def main(): def main():
# 必须声明 DPI 感知:否则 GetWindowRect 返回的是被系统虚拟化(/缩放比)的坐标,
# 抓出来的图只有窗口左上角一块,高 DPI 下右/下必被裁掉。
# Win10 起用 shcore 的 per-monitor 感知最可靠,老系统退回 user32.SetProcessDPIAware。
aware = False
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2) # PROCESS_PER_MONITOR_DPI_AWARE
aware = True
except Exception:
pass
if not aware:
try:
aware = bool(u32.SetProcessDPIAware())
except Exception:
pass
print("dpi aware:", aware)
exe = sys.argv[1] exe = sys.argv[1]
out = sys.argv[2] out = sys.argv[2]
icon_dump = sys.argv[3] if len(sys.argv) > 3 else None icon_dump = sys.argv[3] if len(sys.argv) > 3 else None