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
+4 -8
View File
@@ -18,17 +18,13 @@ namespace TfsExcelImporter
// 可选:区域路径
public string AreaPath { get; set; } = "";
// ntlm | basic | default(当前 Windows 凭据)
public string AuthMode { get; set; } = "ntlm";
// 认证固定 NTLM:用户名可写 user,也可写 DOMAIN\user
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string Domain { get; set; } = "";
// 是否走 IE 系统代理。内网 TFS 请保持 false(直连);
// 代理/网关常对 HttpClient 请求返回 502(浏览器例外名单直连,.NET 不一定继承)
public bool UseSystemProxy { get; set; } = false;
public bool BypassRules { get; set; } = true;
// 旧版本有 BypassRules 开关,已移除:TFS 2013 对应的 Client OM 14.x 没有 SaveFlags.BypassRules
// (15.x 才加,而 15.x 不兼容 TFS 2013),勾选从未生效。旧 config.json 里的残留键由
// Newtonsoft 按默认 MissingMemberHandling.Ignore 忽略,不需要手工清理。
public bool SkipExistingTitles { get; set; } = true;
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()
{
_cmbAuth.SelectedIndexChanged += (s, e) => { UpdateAuthUi(); OnFieldChanged(); };
_btnTest.Click += async (s, e) => await TestConnectionAsync();
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
_btnBrowse.Click += (s, e) => BrowseExcel();
@@ -45,13 +44,11 @@ namespace TfsExcelImporter
_btnFixArea.Click += async (s, e) => await FixAreaAsync();
_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 })
tb.TextChanged += (s, e) => OnFieldChanged();
_cmbIteration.TextChanged += (s, e) => OnFieldChanged();
_chkSkip.CheckedChanged += (s, e) => OnFieldChanged();
_chkBypass.CheckedChanged += (s, e) => OnFieldChanged();
_chkProxy.CheckedChanged += (s, e) => OnFieldChanged();
FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } };
}
@@ -62,37 +59,19 @@ namespace TfsExcelImporter
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()
{
_txtUrl.Text = _cfg.TfsUrl;
_txtProject.Text = _cfg.Project;
_txtType.Text = _cfg.WorkItemType;
_cmbAuth.SelectedItem = _cfg.AuthMode;
if (_cmbAuth.SelectedItem == null) _cmbAuth.SelectedItem = "ntlm";
_txtUser.Text = _cfg.Username;
_txtPwd.Text = _cfg.Password;
_txtDomain.Text = _cfg.Domain;
_chkProxy.Checked = _cfg.UseSystemProxy;
_cmbIteration.Text = _cfg.IterationPath;
_txtExcel.Text = _cfg.LastExcelPath;
_txtAreaPath.Text = _cfg.AreaPath;
_chkSkip.Checked = _cfg.SkipExistingTitles;
_chkBypass.Checked = _cfg.BypassRules;
_txtParentPbi.Text = _cfg.ParentPbiId;
UpdateAuthUi();
}
private void SaveUiToConfig()
@@ -100,16 +79,12 @@ namespace TfsExcelImporter
_cfg.TfsUrl = _txtUrl.Text.Trim();
_cfg.Project = _txtProject.Text.Trim();
_cfg.WorkItemType = _txtType.Text.Trim();
_cfg.AuthMode = _cmbAuth.SelectedItem != null ? _cmbAuth.SelectedItem.ToString() : "ntlm";
_cfg.Username = _txtUser.Text.Trim();
_cfg.Password = _txtPwd.Text;
_cfg.Domain = _txtDomain.Text.Trim();
_cfg.UseSystemProxy = _chkProxy.Checked;
_cfg.IterationPath = _cmbIteration.Text.Trim();
_cfg.LastExcelPath = _txtExcel.Text.Trim();
_cfg.AreaPath = _txtAreaPath.Text.Trim();
_cfg.SkipExistingTitles = _chkSkip.Checked;
_cfg.BypassRules = _chkBypass.Checked;
_cfg.ParentPbiId = _txtParentPbi.Text.Trim();
_cfg.Save();
}
@@ -168,7 +143,7 @@ namespace TfsExcelImporter
SetConnection("连接失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
Log("连接失败: " + FirstLine(ex.Message));
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。");
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
_cardConn.Expanded = true;
_cardConn.ApplyHeight();
Relayout();
@@ -237,7 +212,7 @@ namespace TfsExcelImporter
SetBadgeOf(_cardConn, BadgeKind.Warn);
ShowError(_errIteration, _fIteration, _cardData, "迭代列表加载失败,可手填完整路径");
Log("加载迭代失败: " + FirstLine(ex.Message));
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。");
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
}
finally
{
@@ -551,7 +526,7 @@ namespace TfsExcelImporter
var pbiText = !string.IsNullOrWhiteSpace(row.PbiId) ? row.PbiId.Trim() : _cfg.ParentPbiId;
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++;
report.AppendLine($"{line},\"{row.Title}\",OK,{id},");
Log($"[{i + 1}/{_rows.Count}] OK #{id} {row.Title}");
@@ -574,7 +549,7 @@ namespace TfsExcelImporter
SetConnection("连接失败", ChipKind.Danger);
SetRunBadge(BadgeKind.Warn);
Log("导入中断: " + FirstLine(ex.Message));
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。");
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
}
finally
{
+120 -29
View File
@@ -17,11 +17,8 @@ namespace TfsExcelImporter
private TextBox _txtUrl = new TextBox();
private TextBox _txtProject = new TextBox();
private TextBox _txtType = new TextBox();
private ComboBox _cmbAuth = new ComboBox();
private TextBox _txtUser = new TextBox();
private TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true };
private TextBox _txtDomain = new TextBox();
private CheckBox _chkProxy = new CheckBox();
private ComboBox _cmbIteration = new ComboBox();
private TextBox _txtExcel = new TextBox();
@@ -31,7 +28,6 @@ namespace TfsExcelImporter
private Button _btnBrowse = new Button();
private Button _btnTemplate = new Button();
private CheckBox _chkSkip = new CheckBox();
private CheckBox _chkBypass = new CheckBox();
private DataGridView _grid = new DataGridView();
private Button _btnPreview = new Button();
@@ -52,9 +48,12 @@ namespace TfsExcelImporter
private Card _cardConn, _cardData, _cardPreview, _cardLog, _cardAdv;
private Surface _actionBar;
private StepBadge _badgeRun;
private InputField _fUrl, _fProject, _fType, _fAuth, _fUser, _fPwd, _fDomain,
private InputField _fUrl, _fProject, _fType, _fUser, _fPwd,
_fIteration, _fExcel, _fParent, _fArea;
private Label _errIteration, _errExcel, _errParent, _errArea;
private Label[] _connRowCaptions; // 第 1 步第二行的标签(重排时要跟着一起摆)
private InputField[] _connRowFields; // 同排的四个输入框
private int _connRowY;
private EmptyState _lblEmpty;
private StatusChip _chipConn, _chipOk, _chipSkip, _chipFail;
@@ -237,35 +236,94 @@ namespace TfsExcelImporter
private void BuildCardConn()
{
_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);
_fProject = NewField(_txtProject, 150, false);
_fType = NewField(_txtType, 180, false);
_fAuth = NewField(_cmbAuth, 170, false);
_fDomain = NewField(_txtDomain, 150, false);
_fUser = NewField(_txtUser, 180, false);
_fPwd = NewField(_txtPwd, 200, true);
_fType = NewField(_txtType, 150, false);
_fUser = NewField(_txtUser, 150, false);
_fPwd = NewField(_txtPwd, 150, 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);
_chkProxy = NewCheck("使用系统代理", "内网 TFS 请保持关闭,勾选后常被代理拦成 502");
int y = _cardConn.HeaderH + Theme.S12;
// 三行表单共用一条字段左缘:标签列宽取各行最大值,同列字段宽度归一
int w1 = MaxLabelWidth("服务器 URL", "团队项目", "域");
int w2 = MaxLabelWidth("工作项类型", "用户");
int w3 = MaxLabelWidth("认证方式", "密码");
y = Row(_cardConn, y, 42, new Lbl("服务器 URL", w1), _fUrl);
y = Row(_cardConn, y, 42, new Lbl("服务器 URL", MaxLabelWidth("服务器 URL")), _fUrl);
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,
new Lbl("用户", w2), _fUser, new Lbl("密码", w3), _fPwd, G, _btnTest);
y = Row(_cardConn, y, 26, _chkProxy);
// 第二行:项目 / 类型 / 用户 / 密码 + 测试连接同排,四个输入框等宽、整行铺满卡片内容区。
// 标签自建并留引用——窗口变宽时要连标签一起重摆,Row() 建完就丢引用做不到。
_connRowY = y;
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;
}
/// <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:迭代与数据源 ---
private void BuildCardData()
{
@@ -276,11 +334,14 @@ namespace TfsExcelImporter
_fExcel = NewField(_txtExcel, 500, 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);
_btnBrowse = NewButton("浏览…", BtnKind.Secondary, IconKind.Folder, 92);
_btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142);
_chkSkip = NewCheck("跳过已存在标题", "");
_chkBypass = NewCheck("bypassRules(绕过字段校验规则)", "");
// 三行字段对齐同一条左缘:标签列宽取最大值
int wLbl = MaxLabelWidth("目标迭代", "Excel 文件", "父 PBI Id");
@@ -292,8 +353,12 @@ namespace TfsExcelImporter
AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top);
AnchorRight(_btnBrowse, _cardData, _btnTemplate.Width + G + M, _fExcel.Top);
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, 24, _chkSkip, 2 * G, _chkBypass);
y = Row(_cardData, y, 42, new Lbl("父 PBI Id", wLbl), _fParent,
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);
_errExcel = AddErrorUnder(_cardData, _fExcel);
@@ -474,11 +539,13 @@ namespace TfsExcelImporter
_cardAdv = MakeCard(0, "高级工具", true, Theme.Px(36));
_cardAdv.ShowBadge = false;
_fArea = NewField(_txtAreaPath, 420, true);
CueBanner.Set(_txtAreaPath, "如 Tools_Dev\\导入工具,留空则沿用工作项现有 Area");
_btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158);
_btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120);
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, "已关闭的工作项会被临时重新打开,完成后自动还原状态");
_errArea = AddErrorUnder(_cardAdv, _fArea);
_cardAdv.BodyH = y - _cardAdv.HeaderH + Theme.S8;
@@ -494,6 +561,14 @@ namespace TfsExcelImporter
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>
private static int MaxLabelWidth(params string[] texts)
{
@@ -532,6 +607,23 @@ namespace TfsExcelImporter
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;
if (c == null) continue;
int top = y + Math.Max(0, (Theme.FieldH - c.Height) / 2);
@@ -793,8 +885,7 @@ namespace TfsExcelImporter
{
ShortHost(_txtUrl.Text.Trim()),
_txtProject.Text.Trim(),
_txtType.Text.Trim(),
_cmbAuth.SelectedItem != null ? _cmbAuth.SelectedItem.ToString() : "ntlm"
_txtType.Text.Trim()
}));
_cardData.SetSummary(string.Join(" · ", new[]
{
+79 -27
View File
@@ -23,31 +23,18 @@ namespace TfsExcelImporter
public TfsClient(AppConfig cfg)
{
ICredentials cred;
var mode = (cfg.AuthMode ?? "ntlm").ToLowerInvariant();
if (mode == "default")
{
cred = CredentialCache.DefaultCredentials;
}
else
{
var user = string.IsNullOrEmpty(cfg.Domain) ? cfg.Username : cfg.Domain + "\\" + cfg.Username;
cred = new NetworkCredential(user, cfg.Password ?? "");
}
// 认证固定 NTLM:用户名可写 user(用当前登录域),也可写 DOMAIN\user 显式指定域。
var user = (cfg.Username ?? "").Trim();
var pwd = cfg.Password ?? "";
var sep = user.IndexOf('\\');
ICredentials cred = sep > 0
? new NetworkCredential(user.Substring(sep + 1), pwd, user.Substring(0, sep))
: new NetworkCredential(user, pwd);
_tpc = new TfsTeamProjectCollection(new Uri(cfg.TfsUrl), cred);
// TFS OM 内部用 HttpWebRequest 传输,默认跟随 IE 系统代理——实测代理会拦内网 TFS 返回 502。
// 默认直连;勾选"使用系统代理"时保留系统代理并带 Windows 凭据过代理认证。
if (!cfg.UseSystemProxy)
{
WebRequest.DefaultWebProxy = new WebProxy(); // 空 WebProxy = 不走代理,直连
}
else
{
var p = WebRequest.DefaultWebProxy ?? new WebProxy();
p.Credentials = CredentialCache.DefaultCredentials;
WebRequest.DefaultWebProxy = p;
}
// 因此固定直连:把 DefaultWebProxy 置空,不读取本机系统代理设置。
WebRequest.DefaultWebProxy = new WebProxy();
}
/// <summary>WorkItemStore 惰性初始化(首次调用会下载字段/类型元数据,耗时数秒属正常)。</summary>
@@ -187,14 +174,16 @@ namespace TfsExcelImporter
/// <summary>
/// 创建工作项(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 为服务器管理的只读字段,无法写入(取导入时间);
/// Microsoft.VSTS.Common.ClosedDate 在 State=Done 时按普通规则通常可写:尝试写入,
/// 校验或保存失败则清空该字段降级为普通保存(保证行导入成功)。
/// parentPbiId 非空时,通过 System.LinkTypes.Hierarchy(Reverse=Parent) 挂接到该 PBI。
/// </summary>
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(() =>
{
@@ -269,14 +258,77 @@ namespace TfsExcelImporter
return null;
}
/// <summary>
/// 把 wi.Validate() 的结果整理成「字段(原因) → 怎么改」的单行提示。
/// 单行是硬要求:这段文字会写进 import_report.csv,换行会破坏 CSV 的行结构。
/// </summary>
private static string CheckInvalid(WorkItem wi)
{
var invalid = wi.Validate();
if (invalid.Count == 0) return null;
var names = new StringBuilder();
var sb = new StringBuilder();
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>
-4
View File
@@ -4,12 +4,8 @@
"WorkItemType": "Task",
"IterationPath": "",
"AreaPath": "Tools_Dev\\DataTechnologies",
"AuthMode": "ntlm",
"Username": "",
"Password": "",
"Domain": "",
"UseSystemProxy": false,
"BypassRules": true,
"SkipExistingTitles": true,
"DefaultState": "To Do",
"FinancialProjectRef": "",