- README.md: 修改数据源描述,明确默认模板随程序发布,并更新操作流程中的按钮名称。 - ExcelService.cs: 重命名模板生成方法为 CopyTemplate,简化模板导出逻辑。 - MainForm.cs: 更新按钮文本和事件处理,改为导出默认模板。 - Config.cs 和 config.json: 添加 OriginalEstimateRef 字段以支持新功能。 - 删除不再使用的 build_template.ps1 脚本及相关模板部件。
668 lines
32 KiB
C#
668 lines
32 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
|
||
{
|
||
public class MainForm : Form
|
||
{
|
||
// ------------------------------------------------------------- 控件
|
||
private readonly TextBox _txtUrl = new TextBox();
|
||
private readonly TextBox _txtProject = new TextBox();
|
||
private readonly TextBox _txtType = new TextBox();
|
||
private readonly ComboBox _cmbAuth = new ComboBox();
|
||
private readonly TextBox _txtUser = new TextBox();
|
||
private readonly TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true };
|
||
private readonly TextBox _txtDomain = new TextBox();
|
||
private readonly CheckBox _chkProxy = new CheckBox();
|
||
|
||
private readonly ComboBox _cmbIteration = new ComboBox();
|
||
private readonly Button _btnLoadIterations = new Button();
|
||
private readonly TextBox _txtExcel = new TextBox();
|
||
private readonly Button _btnBrowse = new Button();
|
||
private readonly Button _btnTemplate = new Button();
|
||
private readonly CheckBox _chkSkip = new CheckBox();
|
||
private readonly CheckBox _chkBypass = new CheckBox();
|
||
private readonly TextBox _txtParentPbi = new TextBox();
|
||
|
||
private readonly DataGridView _grid = new DataGridView();
|
||
private readonly Button _btnPreview = new Button();
|
||
private readonly Button _btnImport = new Button();
|
||
private readonly Button _btnFixArea = new Button();
|
||
private readonly Button _btnLinkParent = new Button();
|
||
private readonly TextBox _txtLog = new TextBox();
|
||
private readonly Label _lblStatus = new Label();
|
||
|
||
private AppConfig _cfg;
|
||
private List<WorkItemRow> _rows = new List<WorkItemRow>();
|
||
|
||
public MainForm()
|
||
{
|
||
_cfg = AppConfig.Load();
|
||
BuildUi();
|
||
LoadConfigToUi();
|
||
}
|
||
|
||
// ------------------------------------------------------------- UI 构建
|
||
private void BuildUi()
|
||
{
|
||
Text = "TFS 2013 工作项 Excel 导入工具";
|
||
ClientSize = new Size(1060, 720);
|
||
MinimumSize = new Size(960, 640);
|
||
StartPosition = FormStartPosition.CenterScreen;
|
||
Font = new Font("Microsoft YaHei UI", 9F);
|
||
|
||
int y = 12, left = 12, lblW = 84;
|
||
|
||
// --- 服务器配置区 ---
|
||
var grpServer = new GroupBox
|
||
{
|
||
Text = "TFS 连接",
|
||
Location = new Point(left, y),
|
||
Size = new Size(1036, 120),
|
||
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
|
||
};
|
||
Controls.Add(grpServer);
|
||
|
||
AddLabel(grpServer, "服务器URL:", 12, 24, lblW);
|
||
_txtUrl.Location = new Point(100, 22); _txtUrl.Size = new Size(460, 27);
|
||
_txtUrl.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
|
||
grpServer.Controls.Add(_txtUrl);
|
||
|
||
_chkProxy.Text = "使用系统代理";
|
||
_chkProxy.Location = new Point(564, 24); _chkProxy.Size = new Size(110, 24);
|
||
_chkProxy.Anchor = AnchorStyles.Top | AnchorStyles.Right;
|
||
_chkProxy.Checked = false; // 内网 TFS 默认直连
|
||
grpServer.Controls.Add(_chkProxy);
|
||
|
||
AddLabel(grpServer, "团队项目:", 680, 24, lblW);
|
||
_txtProject.Location = new Point(768, 22); _txtProject.Size = new Size(150, 27);
|
||
_txtProject.Anchor = AnchorStyles.Top | AnchorStyles.Right;
|
||
grpServer.Controls.Add(_txtProject);
|
||
|
||
AddLabel(grpServer, "工作项类型:", 12, 58, lblW);
|
||
_txtType.Location = new Point(100, 56); _txtType.Size = new Size(220, 27);
|
||
grpServer.Controls.Add(_txtType);
|
||
|
||
AddLabel(grpServer, "认证方式:", 340, 58, lblW);
|
||
_cmbAuth.Location = new Point(428, 56); _cmbAuth.Size = new Size(110, 27);
|
||
_cmbAuth.DropDownStyle = ComboBoxStyle.DropDownList;
|
||
_cmbAuth.Items.AddRange(new object[] { "ntlm", "basic", "default" });
|
||
_cmbAuth.SelectedIndexChanged += (s, e) => UpdateAuthUi();
|
||
grpServer.Controls.Add(_cmbAuth);
|
||
|
||
AddLabel(grpServer, "用户:", 556, 58, 44);
|
||
_txtUser.Location = new Point(604, 56); _txtUser.Size = new Size(110, 27);
|
||
grpServer.Controls.Add(_txtUser);
|
||
|
||
AddLabel(grpServer, "密码:", 726, 58, 44);
|
||
_txtPwd.Location = new Point(774, 56); _txtPwd.Size = new Size(110, 27);
|
||
grpServer.Controls.Add(_txtPwd);
|
||
|
||
AddLabel(grpServer, "域:", 896, 58, 28);
|
||
_txtDomain.Location = new Point(928, 56); _txtDomain.Size = new Size(94, 27);
|
||
_txtDomain.Anchor = AnchorStyles.Top | AnchorStyles.Right;
|
||
grpServer.Controls.Add(_txtDomain);
|
||
|
||
y += 128;
|
||
|
||
// --- 导入选区 ---
|
||
var grpImport = new GroupBox
|
||
{
|
||
Text = "导入选区",
|
||
Location = new Point(left, y),
|
||
Size = new Size(1036, 120),
|
||
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
|
||
};
|
||
Controls.Add(grpImport);
|
||
|
||
AddLabel(grpImport, "目标Iteration:", 12, 26, 96);
|
||
_cmbIteration.Location = new Point(112, 23); _cmbIteration.Size = new Size(560, 27);
|
||
_cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填
|
||
_cmbIteration.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
|
||
grpImport.Controls.Add(_cmbIteration);
|
||
|
||
_btnLoadIterations.Text = "加载迭代列表";
|
||
_btnLoadIterations.Location = new Point(682, 22); _btnLoadIterations.Size = new Size(110, 29);
|
||
_btnLoadIterations.Anchor = AnchorStyles.Top | AnchorStyles.Right;
|
||
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
|
||
grpImport.Controls.Add(_btnLoadIterations);
|
||
|
||
AddLabel(grpImport, "Excel 文件:", 12, 62, 96);
|
||
_txtExcel.Location = new Point(112, 59); _txtExcel.Size = new Size(560, 27);
|
||
_txtExcel.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
|
||
grpImport.Controls.Add(_txtExcel);
|
||
|
||
_btnBrowse.Text = "浏览...";
|
||
_btnBrowse.Location = new Point(682, 58); _btnBrowse.Size = new Size(70, 29);
|
||
_btnBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
|
||
_btnBrowse.Click += (s, e) => BrowseExcel();
|
||
grpImport.Controls.Add(_btnBrowse);
|
||
|
||
_btnTemplate.Text = "导出Excel模板";
|
||
_btnTemplate.Location = new Point(760, 58); _btnTemplate.Size = new Size(110, 29);
|
||
_btnTemplate.Anchor = AnchorStyles.Top | AnchorStyles.Right;
|
||
_btnTemplate.Click += (s, e) => ExportTemplate();
|
||
grpImport.Controls.Add(_btnTemplate);
|
||
|
||
_chkSkip.Text = "跳过已存在标题";
|
||
_chkSkip.Location = new Point(886, 26); _chkSkip.Size = new Size(130, 24);
|
||
_chkSkip.Anchor = AnchorStyles.Top | AnchorStyles.Right;
|
||
grpImport.Controls.Add(_chkSkip);
|
||
|
||
_chkBypass.Text = "bypassRules(绕过字段规则)";
|
||
_chkBypass.Location = new Point(886, 60); _chkBypass.Size = new Size(150, 24);
|
||
_chkBypass.Anchor = AnchorStyles.Top | AnchorStyles.Right;
|
||
grpImport.Controls.Add(_chkBypass);
|
||
|
||
// 父 PBI:全部 Task 默认挂接的 Product Backlog Item(Excel 每行 PBI Id 列可覆盖)
|
||
AddLabel(grpImport, "父PBI Id:", 12, 92, 96);
|
||
_txtParentPbi.Location = new Point(112, 89); _txtParentPbi.Size = new Size(200, 27);
|
||
grpImport.Controls.Add(_txtParentPbi);
|
||
var lblPbiHint = new Label
|
||
{
|
||
Text = "Task 将挂接为该 Product Backlog Item 的子项;Excel 加 PBI Id 列可逐行指定不同父项",
|
||
Location = new Point(320, 92), Size = new Size(560, 22),
|
||
TextAlign = ContentAlignment.MiddleLeft,
|
||
ForeColor = SystemColors.GrayText
|
||
};
|
||
grpImport.Controls.Add(lblPbiHint);
|
||
|
||
y += 128;
|
||
|
||
// --- 预览表格 ---
|
||
_grid.Location = new Point(left, y);
|
||
_grid.Size = new Size(1036, 300);
|
||
_grid.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom;
|
||
_grid.ReadOnly = true;
|
||
_grid.AllowUserToAddRows = false;
|
||
_grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells;
|
||
_grid.RowHeadersVisible = false;
|
||
_grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
|
||
Controls.Add(_grid);
|
||
y += 308;
|
||
|
||
// --- 操作按钮 ---
|
||
_btnPreview.Text = "预览数据";
|
||
_btnPreview.Location = new Point(left, y); _btnPreview.Size = new Size(110, 32);
|
||
_btnPreview.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
|
||
_btnPreview.Click += (s, e) => PreviewData();
|
||
Controls.Add(_btnPreview);
|
||
|
||
_btnImport.Text = "开始导入 TFS";
|
||
_btnImport.Location = new Point(left + 122, y); _btnImport.Size = new Size(130, 32);
|
||
_btnImport.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
|
||
_btnImport.BackColor = Color.FromArgb(0, 120, 215);
|
||
_btnImport.ForeColor = Color.White;
|
||
_btnImport.Click += async (s, e) => await ImportAsync();
|
||
Controls.Add(_btnImport);
|
||
|
||
_btnFixArea.Text = "按迭代修Area";
|
||
_btnFixArea.Location = new Point(left + 264, y); _btnFixArea.Size = new Size(130, 32);
|
||
_btnFixArea.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
|
||
_btnFixArea.Click += async (s, e) => await FixAreaAsync();
|
||
Controls.Add(_btnFixArea);
|
||
|
||
_btnLinkParent.Text = "补挂父项";
|
||
_btnLinkParent.Location = new Point(left + 406, y); _btnLinkParent.Size = new Size(130, 32);
|
||
_btnLinkParent.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
|
||
_btnLinkParent.Click += async (s, e) => await LinkParentAsync();
|
||
Controls.Add(_btnLinkParent);
|
||
|
||
_lblStatus.Text = "就绪";
|
||
_lblStatus.Location = new Point(left + 548, y + 6); _lblStatus.Size = new Size(400, 22);
|
||
_lblStatus.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
|
||
Controls.Add(_lblStatus);
|
||
y += 40;
|
||
|
||
// --- 日志 ---
|
||
_txtLog.Location = new Point(left, y);
|
||
_txtLog.Size = new Size(1036, 120);
|
||
_txtLog.Anchor = AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||
_txtLog.Multiline = true;
|
||
_txtLog.ScrollBars = ScrollBars.Vertical;
|
||
_txtLog.ReadOnly = true;
|
||
Controls.Add(_txtLog);
|
||
}
|
||
|
||
private static void AddLabel(Control parent, string text, int x, int y, int w)
|
||
{
|
||
var lbl = new Label { Text = text, Location = new Point(x, y), Size = new Size(w, 22), TextAlign = ContentAlignment.MiddleLeft };
|
||
parent.Controls.Add(lbl);
|
||
}
|
||
|
||
private void UpdateAuthUi()
|
||
{
|
||
bool needCreds = _cmbAuth.SelectedItem != null && _cmbAuth.SelectedItem.ToString() != "default";
|
||
_txtUser.Enabled = needCreds;
|
||
_txtPwd.Enabled = needCreds;
|
||
_txtDomain.Enabled = needCreds;
|
||
}
|
||
|
||
// ------------------------------------------------------------- 配置互转
|
||
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;
|
||
_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.SkipExistingTitles = _chkSkip.Checked;
|
||
_cfg.BypassRules = _chkBypass.Checked;
|
||
_cfg.ParentPbiId = _txtParentPbi.Text.Trim();
|
||
_cfg.Save();
|
||
}
|
||
|
||
// ------------------------------------------------------------- 日志
|
||
private void Log(string msg)
|
||
{
|
||
if (InvokeRequired) { BeginInvoke(new Action(() => Log(msg))); return; }
|
||
_txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
|
||
}
|
||
|
||
// ------------------------------------------------------------- 迭代加载
|
||
private async Task LoadIterationsAsync()
|
||
{
|
||
SaveUiToConfig();
|
||
_btnLoadIterations.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("⚠ 字段接口(_apis/wit/fields)不可用,导入可能受影响: " + ex.Message.Split('\n')[0]);
|
||
}
|
||
var paths = await client.GetIterationPathsAsync(_cfg.Project, Log);
|
||
_cmbIteration.Items.Clear();
|
||
foreach (var p in paths) _cmbIteration.Items.Add(p);
|
||
if (_cmbIteration.Items.Count > 0 && string.IsNullOrWhiteSpace(_cmbIteration.Text))
|
||
_cmbIteration.SelectedIndex = 0;
|
||
// 优先选中配置中的迭代
|
||
for (int i = 0; i < _cmbIteration.Items.Count; i++)
|
||
{
|
||
if (string.Equals(_cmbIteration.Items[i].ToString(), _cfg.IterationPath,
|
||
StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_cmbIteration.SelectedIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
Log($"已加载 {_cmbIteration.Items.Count} 个迭代路径");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("加载迭代失败: " + ex.Message);
|
||
MessageBox.Show(this, "加载迭代失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
}
|
||
finally
|
||
{
|
||
_btnLoadIterations.Enabled = true;
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------- 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);
|
||
MessageBox.Show(this, "模板已导出:\n" + dlg.FileName, "完成",
|
||
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("导出模板失败: " + ex.Message);
|
||
MessageBox.Show(this, "导出模板失败:\n" + ex.Message, "错误",
|
||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
}
|
||
}
|
||
}
|
||
|
||
private void PreviewData()
|
||
{
|
||
var path = _txtExcel.Text.Trim();
|
||
if (string.IsNullOrEmpty(path) || !File.Exists(path))
|
||
{
|
||
MessageBox.Show(this, "请先选择有效的 Excel 文件", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
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,
|
||
LogDate = r.LogDate,
|
||
r.PbiId,
|
||
r.Description
|
||
}).ToList();
|
||
_lblStatus.Text = $"已读取 {_rows.Count} 行";
|
||
Log($"读取 {path} 共 {_rows.Count} 行");
|
||
SaveUiToConfig();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("读取 Excel 失败: " + ex.Message);
|
||
MessageBox.Show(this, "读取 Excel 失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------- Area 修正
|
||
private async Task FixAreaAsync()
|
||
{
|
||
SaveUiToConfig();
|
||
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
|
||
{
|
||
MessageBox.Show(this, "请先选择目标 Iteration", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
return;
|
||
}
|
||
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
|
||
{
|
||
MessageBox.Show(this, "请先在 config.json 中设置 AreaPath(如 Tools_Dev\\DataTechnologies)", "提示",
|
||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
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;
|
||
|
||
_btnFixArea.Enabled = false;
|
||
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);
|
||
MessageBox.Show(this, summary, r.Item2 > 0 ? "部分完成" : "完成",
|
||
MessageBoxButtons.OK, r.Item2 > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("Area 修正失败: " + ex.Message);
|
||
MessageBox.Show(this, "Area 修正失败:\n" + ex.Message, "错误",
|
||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
}
|
||
finally
|
||
{
|
||
_btnFixArea.Enabled = true;
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------- 补挂父项
|
||
private async Task LinkParentAsync()
|
||
{
|
||
SaveUiToConfig();
|
||
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
|
||
{
|
||
MessageBox.Show(this, "请先选择目标 Iteration", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
return;
|
||
}
|
||
if (!int.TryParse(_cfg.ParentPbiId, out var parentId))
|
||
{
|
||
MessageBox.Show(this, "请先在「父PBI Id」输入框填入父工作项 Id(纯数字)", "提示",
|
||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
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;
|
||
|
||
_btnLinkParent.Enabled = false;
|
||
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);
|
||
MessageBox.Show(this, summary + "\n挂接后在积压工作页展开父项即可看到子项。",
|
||
r.Item3 > 0 ? "部分完成" : "完成", MessageBoxButtons.OK,
|
||
r.Item3 > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("补挂父项失败: " + ex.Message);
|
||
MessageBox.Show(this, "补挂父项失败:\n" + ex.Message, "错误",
|
||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
}
|
||
finally
|
||
{
|
||
_btnLinkParent.Enabled = true;
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------- 导入
|
||
private async Task ImportAsync()
|
||
{
|
||
SaveUiToConfig();
|
||
|
||
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
|
||
{
|
||
MessageBox.Show(this, "请先选择目标 Iteration(可点击“加载迭代列表”)", "提示",
|
||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
return;
|
||
}
|
||
var path = _txtExcel.Text.Trim();
|
||
if (string.IsNullOrEmpty(path) || !File.Exists(path))
|
||
{
|
||
MessageBox.Show(this, "请先选择有效的 Excel 文件", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
return;
|
||
}
|
||
if (_rows.Count == 0)
|
||
{
|
||
try { _rows = ExcelService.ReadRows(path); }
|
||
catch (Exception ex)
|
||
{
|
||
MessageBox.Show(this, "读取 Excel 失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
return;
|
||
}
|
||
}
|
||
if (_rows.Count == 0)
|
||
{
|
||
MessageBox.Show(this, "Excel 中没有可导入的数据行", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
return;
|
||
}
|
||
|
||
_btnImport.Enabled = false;
|
||
_btnPreview.Enabled = false;
|
||
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());
|
||
|
||
// 字段解析
|
||
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}");
|
||
}
|
||
_lblStatus.Text = $"导入中 {i + 1}/{_rows.Count}...";
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log("导入中断: " + ex.Message);
|
||
MessageBox.Show(this, "导入中断:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
}
|
||
finally
|
||
{
|
||
_btnImport.Enabled = true;
|
||
_btnPreview.Enabled = true;
|
||
try
|
||
{
|
||
var reportPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
|
||
"import_report_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv");
|
||
File.WriteAllText(reportPath, report.ToString(), Encoding.UTF8);
|
||
Log($"报告已保存: {reportPath}");
|
||
}
|
||
catch (Exception ex) { Log("写报告失败: " + ex.Message); }
|
||
_lblStatus.Text = $"完成: 成功 {ok} / 跳过 {skip} / 失败 {fail}";
|
||
Log($"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}");
|
||
MessageBox.Show(this, $"导入完成。\n成功 {ok} / 跳过 {skip} / 失败 {fail}\n详见日志与报告 CSV。",
|
||
"完成", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||
}
|
||
}
|
||
}
|
||
}
|