- 新增 app.manifest 文件以实现系统 DPI 感知,确保界面在不同缩放设置下正常显示。 - 更新 MainForm.cs 和 Theme.cs,调整控件布局和样式以适应 DPI 变化。 - 修改 README.md,增加关于 DPI 感知的说明。 - 新增 MainFormUi.cs,负责界面布局与状态呈现,优化用户体验。 - 新增 debug.log 文件以记录错误信息,便于调试和问题追踪。
602 lines
27 KiB
C#
602 lines
27 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Drawing;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Threading.Tasks;
|
||
using System.Windows.Forms;
|
||
|
||
namespace TfsExcelImporter
|
||
{
|
||
/// <summary>
|
||
/// 导入流程与 TFS/Excel 调用。界面摆放与状态呈现见 MainFormUi.cs,自绘控件见 Theme.cs。
|
||
/// </summary>
|
||
public partial class MainForm : Form
|
||
{
|
||
private AppConfig _cfg;
|
||
private List<WorkItemRow> _rows = new List<WorkItemRow>();
|
||
|
||
public MainForm()
|
||
{
|
||
// System DPI 感知(见 app.manifest):先定缩放系数,再按它摆控件
|
||
using (var g = Graphics.FromHwnd(IntPtr.Zero))
|
||
Theme.Scale = g.DpiX / 96f;
|
||
_cfg = AppConfig.Load();
|
||
BuildUi();
|
||
LoadConfigToUi();
|
||
WireEvents();
|
||
RefreshRibbon();
|
||
RefreshSummaries();
|
||
SetConnection("连接未验证", ChipKind.Neutral);
|
||
UpdateEmptyState();
|
||
}
|
||
|
||
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();
|
||
_btnTemplate.Click += (s, e) => ExportTemplate();
|
||
_btnPreview.Click += (s, e) => PreviewData();
|
||
_btnImport.Click += async (s, e) => await ImportAsync();
|
||
_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,
|
||
_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 { } };
|
||
}
|
||
|
||
private void OnFieldChanged()
|
||
{
|
||
RefreshRibbon();
|
||
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()
|
||
{
|
||
_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();
|
||
}
|
||
|
||
// ------------------------------------------------------------- 日志
|
||
private void Log(string msg)
|
||
{
|
||
Append(msg, LogView.Classify(msg));
|
||
}
|
||
|
||
private static string FirstLine(string text)
|
||
{
|
||
if (string.IsNullOrEmpty(text)) return "";
|
||
var i = text.IndexOf('\n');
|
||
return i < 0 ? text : text.Substring(0, i).Trim();
|
||
}
|
||
|
||
// ------------------------------------------------------------- 连接测试
|
||
private async Task TestConnectionAsync()
|
||
{
|
||
SaveUiToConfig();
|
||
if (string.IsNullOrWhiteSpace(_cfg.TfsUrl))
|
||
{
|
||
SetConnection("URL 未填", ChipKind.Danger);
|
||
SetBadgeOf(_cardConn, BadgeKind.Warn);
|
||
Append("请先填写服务器 URL", LogLevel.Warn);
|
||
_txtUrl.Focus();
|
||
return;
|
||
}
|
||
|
||
SetConnection("连接中…", ChipKind.Info);
|
||
SetBadgeOf(_cardConn, BadgeKind.Active);
|
||
_btnTest.Enabled = false;
|
||
try
|
||
{
|
||
using (var client = new TfsClient(_cfg))
|
||
{
|
||
Log("测试连接...");
|
||
Log(await client.TestConnectionAsync());
|
||
try
|
||
{
|
||
var fm = await client.GetFieldMapAsync();
|
||
Log($"工作项字段元数据可用({fm.Count} 个字段)");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("警告: 字段元数据获取失败,导入可能受影响: " + FirstLine(ex.Message));
|
||
}
|
||
}
|
||
SetConnection("已连接", ChipKind.Ok);
|
||
SetBadgeOf(_cardConn, BadgeKind.Done);
|
||
OpenDetail(_cardData);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetConnection("连接失败", ChipKind.Danger);
|
||
SetBadgeOf(_cardConn, BadgeKind.Warn);
|
||
Log("连接失败: " + FirstLine(ex.Message));
|
||
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。");
|
||
_cardConn.Expanded = true;
|
||
_cardConn.ApplyHeight();
|
||
Relayout();
|
||
}
|
||
finally
|
||
{
|
||
_btnTest.Enabled = true;
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------- 迭代加载
|
||
private async Task LoadIterationsAsync()
|
||
{
|
||
SaveUiToConfig();
|
||
if (string.IsNullOrWhiteSpace(_cfg.TfsUrl))
|
||
{
|
||
Append("请先填写服务器 URL", LogLevel.Warn);
|
||
OpenDetail(_cardConn);
|
||
return;
|
||
}
|
||
SetBusy(true);
|
||
SetConnection("读取迭代中…", ChipKind.Info);
|
||
try
|
||
{
|
||
using (var client = new TfsClient(_cfg))
|
||
{
|
||
Log("测试连接...");
|
||
Log(await client.TestConnectionAsync());
|
||
// 提前探测导入所需字段元数据,避免导入时才发现取不到
|
||
try
|
||
{
|
||
var fm = await client.GetFieldMapAsync();
|
||
Log($"工作项字段元数据可用({fm.Count} 个字段)");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("警告: 字段元数据获取失败,导入可能受影响: " + FirstLine(ex.Message));
|
||
}
|
||
var paths = await client.GetIterationPathsAsync(_cfg.Project, Log);
|
||
var keep = _cmbIteration.Text;
|
||
_cmbIteration.Items.Clear();
|
||
foreach (var p in paths) _cmbIteration.Items.Add(p);
|
||
if (_cmbIteration.Items.Count > 0)
|
||
{
|
||
int match = -1;
|
||
for (int i = 0; i < _cmbIteration.Items.Count; i++)
|
||
{
|
||
if (string.Equals(_cmbIteration.Items[i].ToString(), keep, StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(_cmbIteration.Items[i].ToString(), _cfg.IterationPath, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
match = i;
|
||
break;
|
||
}
|
||
}
|
||
_cmbIteration.SelectedIndex = match >= 0 ? match : 0;
|
||
}
|
||
Log($"已加载 {_cmbIteration.Items.Count} 个迭代路径");
|
||
SetConnection("已连接", ChipKind.Ok);
|
||
SetBadgeOf(_cardConn, BadgeKind.Done);
|
||
ShowError(_errIteration, _fIteration, _cardData, "");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetConnection("连接失败", ChipKind.Danger);
|
||
SetBadgeOf(_cardConn, BadgeKind.Warn);
|
||
ShowError(_errIteration, _fIteration, _cardData, "迭代列表加载失败,可手填完整路径");
|
||
Log("加载迭代失败: " + FirstLine(ex.Message));
|
||
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。");
|
||
}
|
||
finally
|
||
{
|
||
SetBusy(false);
|
||
OnFieldChanged();
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------- Excel
|
||
private void BrowseExcel()
|
||
{
|
||
using (var dlg = new OpenFileDialog())
|
||
{
|
||
dlg.Filter = "Excel 文件 (*.xlsx;*.xlsm)|*.xlsx;*.xlsm|所有文件 (*.*)|*.*";
|
||
dlg.Title = "选择要导入的 Excel 文件";
|
||
if (!string.IsNullOrEmpty(_txtExcel.Text))
|
||
{
|
||
try { dlg.InitialDirectory = Path.GetDirectoryName(_txtExcel.Text); } catch { }
|
||
}
|
||
if (dlg.ShowDialog(this) != DialogResult.OK) return;
|
||
_txtExcel.Text = dlg.FileName;
|
||
SaveUiToConfig();
|
||
PreviewData();
|
||
}
|
||
}
|
||
|
||
private void ExportTemplate()
|
||
{
|
||
using (var dlg = new SaveFileDialog())
|
||
{
|
||
dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx";
|
||
dlg.FileName = ExcelService.TemplateFileName;
|
||
dlg.Title = "导出默认模板";
|
||
if (dlg.ShowDialog(this) != DialogResult.OK) return;
|
||
try
|
||
{
|
||
ExcelService.CopyTemplate(dlg.FileName);
|
||
Log("模板已导出: " + dlg.FileName);
|
||
_txtExcel.Text = dlg.FileName;
|
||
SaveUiToConfig();
|
||
PreviewData();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("导出模板失败: " + FirstLine(ex.Message));
|
||
ShowError(_errExcel, _fExcel, _cardData, "导出模板失败:" + FirstLine(ex.Message));
|
||
}
|
||
}
|
||
}
|
||
|
||
private void PreviewData()
|
||
{
|
||
var path = _txtExcel.Text.Trim();
|
||
if (string.IsNullOrEmpty(path) || !File.Exists(path))
|
||
{
|
||
ShowError(_errExcel, _fExcel, _cardData, "请选择有效的 Excel 文件(.xlsx)");
|
||
Append("预览未执行:Excel 文件不存在", LogLevel.Warn);
|
||
return;
|
||
}
|
||
try
|
||
{
|
||
_rows = ExcelService.ReadRows(path);
|
||
_grid.DataSource = null;
|
||
_grid.DataSource = _rows.Select((r, i) => new
|
||
{
|
||
行号 = i + 1,
|
||
标题 = r.Title,
|
||
状态 = r.State,
|
||
指派 = r.AssignedTo,
|
||
财务项目 = r.FinancialProject,
|
||
完成量 = r.Completed,
|
||
日期 = r.LogDate,
|
||
父PBI = r.PbiId,
|
||
说明 = r.Description
|
||
}).ToList();
|
||
ShowError(_errExcel, _fExcel, _cardData, "");
|
||
StyleGridColumns();
|
||
_lblStatus.Text = $"已读取 {_rows.Count} 行";
|
||
Log($"读取 {path} 共 {_rows.Count} 行");
|
||
if (_rows.Count == 0) Log("警告: 没有可导入的数据行(Title 为空的行会被忽略)");
|
||
SetBadgeOf(_cardData, BadgeKind.Done);
|
||
SetBadgeOf(_cardPreview, _rows.Count > 0 ? BadgeKind.Done : BadgeKind.Warn);
|
||
UpdateEmptyState();
|
||
RefreshSummaries();
|
||
SaveUiToConfig();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("读取 Excel 失败: " + FirstLine(ex.Message));
|
||
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
|
||
UpdateEmptyState();
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------- Area 修正
|
||
private async Task FixAreaAsync()
|
||
{
|
||
SaveUiToConfig();
|
||
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
|
||
{
|
||
ShowError(_errIteration, _fIteration, _cardData, "修 Area 需要先选择目标 Iteration");
|
||
Append("按迭代修 Area 取消:目标迭代未选", LogLevel.Warn);
|
||
return;
|
||
}
|
||
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
|
||
{
|
||
ShowError(_errArea, _fArea, _cardAdv, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
|
||
OpenDetail(_cardAdv);
|
||
_txtAreaPath.Focus();
|
||
return;
|
||
}
|
||
var confirm = MessageBox.Show(this,
|
||
$"将把 迭代「{_cfg.IterationPath}」下 类型={_cfg.WorkItemType} 且 Area≠{_cfg.AreaPath} 的工作项\nArea 改为 {_cfg.AreaPath}。\n\n注意:已关闭(如 Done)的工作项会被临时重新打开,改完 Area 后自动还原状态。\n\n确认执行?",
|
||
"确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
||
if (confirm != DialogResult.Yes) return;
|
||
|
||
SetBusy(true);
|
||
OpenDetail(_cardLog);
|
||
try
|
||
{
|
||
using (var client = new TfsClient(_cfg))
|
||
{
|
||
var r = await client.FixAreaAsync(_cfg.Project, _cfg.IterationPath, _cfg.WorkItemType, _cfg.AreaPath, Log);
|
||
var summary = r.Item2 > 0
|
||
? $"Area 修正完成:成功 {r.Item1} 条,失败 {r.Item2} 条(详见日志)"
|
||
: $"Area 修正完成,共 {r.Item1} 条";
|
||
_lblStatus.Text = summary;
|
||
Log(summary);
|
||
ShowError(_errArea, _fArea, _cardAdv, "");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("Area 修正失败: " + FirstLine(ex.Message));
|
||
}
|
||
finally
|
||
{
|
||
SetBusy(false);
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------- 补挂父项
|
||
private async Task LinkParentAsync()
|
||
{
|
||
SaveUiToConfig();
|
||
int parentId;
|
||
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
|
||
{
|
||
ShowError(_errIteration, _fIteration, _cardData, "补挂父项需要先选择目标 Iteration");
|
||
Append("补挂父项取消:目标迭代未选", LogLevel.Warn);
|
||
return;
|
||
}
|
||
if (!int.TryParse(_cfg.ParentPbiId, out parentId))
|
||
{
|
||
ShowError(_errParent, _fParent, _cardData, "父 PBI Id 需为纯数字,例如 219975");
|
||
_txtParentPbi.Focus();
|
||
return;
|
||
}
|
||
var confirm = MessageBox.Show(this,
|
||
$"将把 迭代「{_cfg.IterationPath}」下 类型={_cfg.WorkItemType} 且尚无父项的工作项\n补挂为 #{parentId} 的子项。\n\n已有父项的自动跳过;已关闭的工作项会临时重新打开,挂完自动还原状态。\n\n确认执行?",
|
||
"确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
||
if (confirm != DialogResult.Yes) return;
|
||
|
||
SetBusy(true);
|
||
OpenDetail(_cardLog);
|
||
try
|
||
{
|
||
using (var client = new TfsClient(_cfg))
|
||
{
|
||
var r = await client.LinkExistingAsync(_cfg.Project, _cfg.IterationPath,
|
||
_cfg.WorkItemType, parentId, Log);
|
||
var summary = $"补挂完成: 挂接 {r.Item1} / 已有父跳过 {r.Item2} / 失败 {r.Item3}";
|
||
_lblStatus.Text = summary;
|
||
Log(summary);
|
||
Log("挂接后在积压工作页展开父项即可看到子项。");
|
||
ShowError(_errParent, _fParent, _cardData, "");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("补挂父项失败: " + FirstLine(ex.Message));
|
||
}
|
||
finally
|
||
{
|
||
SetBusy(false);
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------- 导入
|
||
private async Task ImportAsync()
|
||
{
|
||
SaveUiToConfig();
|
||
|
||
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
|
||
{
|
||
ShowError(_errIteration, _fIteration, _cardData, "请先选择目标 Iteration(可点「加载迭代列表」,也可手填完整路径)");
|
||
Append("导入取消:目标迭代未选", LogLevel.Warn);
|
||
return;
|
||
}
|
||
var path = _txtExcel.Text.Trim();
|
||
if (string.IsNullOrEmpty(path) || !File.Exists(path))
|
||
{
|
||
ShowError(_errExcel, _fExcel, _cardData, "请选择有效的 Excel 文件(.xlsx)");
|
||
Append("导入取消:Excel 文件不存在", LogLevel.Warn);
|
||
return;
|
||
}
|
||
if (_rows.Count == 0)
|
||
{
|
||
try { _rows = ExcelService.ReadRows(path); }
|
||
catch (Exception ex)
|
||
{
|
||
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
|
||
Log("读取 Excel 失败: " + FirstLine(ex.Message));
|
||
return;
|
||
}
|
||
}
|
||
if (_rows.Count == 0)
|
||
{
|
||
ShowError(_errExcel, _fExcel, _cardData, "Excel 中没有可导入的数据行");
|
||
Append("导入取消:没有可导入的数据行", LogLevel.Warn);
|
||
return;
|
||
}
|
||
|
||
ShowError(_errIteration, _fIteration, _cardData, "");
|
||
ShowError(_errExcel, _fExcel, _cardData, "");
|
||
SetRunBadge(BadgeKind.Active);
|
||
SetBusy(true);
|
||
OpenDetail(_cardLog);
|
||
_pbar.Maximum = _rows.Count;
|
||
_pbar.Value = 0;
|
||
int ok = 0, skip = 0, fail = 0;
|
||
var report = new StringBuilder();
|
||
report.AppendLine("行号,Title,结果,WorkItemId,错误");
|
||
|
||
try
|
||
{
|
||
using (var client = new TfsClient(_cfg))
|
||
{
|
||
Log("测试连接...");
|
||
Log(await client.TestConnectionAsync());
|
||
SetConnection("已连接", ChipKind.Ok);
|
||
SetBadgeOf(_cardConn, BadgeKind.Done);
|
||
|
||
// 字段解析
|
||
var fieldMap = await client.GetFieldMapAsync();
|
||
var titleRef = "System.Title";
|
||
var stateRef = "System.State";
|
||
var assignedRef = "System.AssignedTo";
|
||
var iterationRef = "System.IterationPath";
|
||
var areaRef = "System.AreaPath";
|
||
|
||
var finRef = TfsClient.ResolveRef(fieldMap, "FinancialProject", _cfg.FinancialProjectRef);
|
||
var compRef = TfsClient.ResolveRef(fieldMap, "Completed", _cfg.CompletedRef);
|
||
var logRef = TfsClient.ResolveRef(fieldMap, "LogDate", _cfg.LogDateRef);
|
||
var origEstRef = TfsClient.ResolveRef(fieldMap, "Original Estimate", _cfg.OriginalEstimateRef);
|
||
Log($"字段: FinancialProject->{finRef ?? "(未找到)"} Completed->{compRef ?? "(未找到)"} LogDate->{logRef ?? "(未找到)"} OriginalEstimate->{origEstRef ?? "(未找到)"}");
|
||
Log($"父PBI: '{(string.IsNullOrWhiteSpace(_cfg.ParentPbiId) ? "(未填)" : _cfg.ParentPbiId)}'——Excel 每行可用 PBI Id 列覆盖");
|
||
|
||
HashSet<string> existing = null;
|
||
if (_chkSkip.Checked)
|
||
{
|
||
Log("查询已有工作项标题...");
|
||
existing = await client.GetExistingTitlesAsync(_cfg.Project, _cfg.IterationPath, _cfg.WorkItemType);
|
||
Log($"服务器已有 {existing.Count} 条");
|
||
}
|
||
|
||
for (int i = 0; i < _rows.Count; i++)
|
||
{
|
||
var row = _rows[i];
|
||
var line = i + 2; // Excel 实际行号(含表头)
|
||
try
|
||
{
|
||
if (existing != null && existing.Contains(row.Title))
|
||
{
|
||
skip++;
|
||
report.AppendLine($"{line},\"{row.Title}\",SKIP,,标题已存在");
|
||
Log($"[{i + 1}/{_rows.Count}] SKIP {row.Title}");
|
||
continue;
|
||
}
|
||
|
||
var fields = new List<KeyValuePair<string, object>>
|
||
{
|
||
new KeyValuePair<string, object>(titleRef, row.Title),
|
||
new KeyValuePair<string, object>(stateRef,
|
||
string.IsNullOrWhiteSpace(row.State) ? _cfg.DefaultState : row.State.Trim()),
|
||
new KeyValuePair<string, object>(iterationRef, _cfg.IterationPath)
|
||
};
|
||
if (!string.IsNullOrWhiteSpace(row.AssignedTo))
|
||
fields.Add(new KeyValuePair<string, object>(assignedRef, row.AssignedTo.Trim()));
|
||
if (!string.IsNullOrWhiteSpace(_cfg.AreaPath))
|
||
fields.Add(new KeyValuePair<string, object>(areaRef, _cfg.AreaPath.Trim()));
|
||
if (!string.IsNullOrWhiteSpace(row.FinancialProject) && finRef != null)
|
||
fields.Add(new KeyValuePair<string, object>(finRef, row.FinancialProject.Trim()));
|
||
|
||
var completed = ExcelService.ToNumber(row.Completed);
|
||
if (completed != null && compRef != null)
|
||
fields.Add(new KeyValuePair<string, object>(compRef, completed));
|
||
|
||
// Original Estimate 默认取 Completed 的值
|
||
if (completed != null && origEstRef != null)
|
||
fields.Add(new KeyValuePair<string, object>(origEstRef, completed));
|
||
|
||
if (!string.IsNullOrWhiteSpace(row.Description))
|
||
fields.Add(new KeyValuePair<string, object>("System.Description", row.Description.Trim()));
|
||
|
||
var logDate = ExcelService.ToIsoDateTime(row.LogDate);
|
||
if (logDate != null && logRef != null)
|
||
fields.Add(new KeyValuePair<string, object>(logRef, logDate));
|
||
|
||
// 父 PBI:行级 PBI Id 列优先,其次界面默认值
|
||
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);
|
||
ok++;
|
||
report.AppendLine($"{line},\"{row.Title}\",OK,{id},");
|
||
Log($"[{i + 1}/{_rows.Count}] OK #{id} {row.Title}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
fail++;
|
||
report.AppendLine($"{line},\"{row.Title}\",FAIL,,\"{ex.Message.Replace("\"", "'")}\"");
|
||
Log($"[{i + 1}/{_rows.Count}] FAIL {row.Title} -> {ex.Message}");
|
||
}
|
||
_pbar.Value = Math.Min(i + 1, _pbar.Maximum);
|
||
_lblStatus.Text = $"{i + 1}/{_rows.Count}";
|
||
SetChips(ok, skip, fail, false);
|
||
}
|
||
SetRunBadge(fail > 0 ? BadgeKind.Warn : BadgeKind.Done);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetConnection("连接失败", ChipKind.Danger);
|
||
SetRunBadge(BadgeKind.Warn);
|
||
Log("导入中断: " + FirstLine(ex.Message));
|
||
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。");
|
||
}
|
||
finally
|
||
{
|
||
SetBusy(false);
|
||
try
|
||
{
|
||
var reportPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
|
||
"import_report_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv");
|
||
File.WriteAllText(reportPath, report.ToString(), Encoding.UTF8);
|
||
_lastReportPath = reportPath;
|
||
Log("报告已保存: " + reportPath);
|
||
}
|
||
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);
|
||
SetChips(ok, skip, fail, false);
|
||
RefreshSummaries();
|
||
if (fail == 0 && ok > 0) OpenDetail(_cardLog);
|
||
}
|
||
}
|
||
}
|
||
}
|