i18-fe-automator-beta 2.1.4 → 2.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1809,6 +1809,16 @@ const CACHE_KEY = "zentao"; // .cache/zentao: { account, password, zentaosid, pr
1809
1809
  const PLAN_STORY_TABLE_LIMIT = 50;
1810
1810
  const PULL_CONCURRENCY = 5;
1811
1811
 
1812
+ // 任务状态中文标签(close 命令展示用)
1813
+ const TASK_STATUS_LABELS = {
1814
+ wait: "未开始",
1815
+ doing: "进行中",
1816
+ pause: "已暂停",
1817
+ done: "已完成",
1818
+ closed: "已关闭",
1819
+ cancel: "已取消",
1820
+ };
1821
+
1812
1822
  // 确保 .cache 目录存在
1813
1823
  function ensureCacheDir() {
1814
1824
  const cacheDir = getRunCliPath({ root: true }) + "/.cache";
@@ -1934,6 +1944,8 @@ function zentaoRequest({ route, zentaosid, qs }) {
1934
1944
  * - message 校验失败时是对象(如 {estStarted: [『预计开始』不能为空。]}), 统一格式化为文本
1935
1945
  * - form 字段名必须对齐表单真实 name(如 assignedTo[]): 实测传多余字段(mailto/after/hiddenwin)
1936
1946
  * 或 desc 含 HTML 标签时, 会出现"返回保存成功但未落库"或空响应, 只传验证过的最小字段集
1947
+ * - 非JSON响应分类(实测禅道部分写接口成功也返回 JS跳转页而非JSON信封):
1948
+ * alert脚本=业务拦截(提取文案作真实错误) | 脚本location跳转: 跳登录页=会话失效, 跳其他页(如 task-view-x)=操作成功 | 其余带响应片段报错
1937
1949
  */
1938
1950
  function zentaoPost({ route, zentaosid, form }) {
1939
1951
  return new Promise((resolve, reject) => {
@@ -1953,30 +1965,56 @@ function zentaoPost({ route, zentaosid, form }) {
1953
1965
  (error, _response, body) => {
1954
1966
  if (error) return reject(error);
1955
1967
  const text = String(body).replace(/^\uFEFF/, "");
1956
- if (/^\s*<html/i.test(text)) {
1957
- return reject(
1958
- Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
1959
- );
1968
+ // 先尝试按JSON信封解析(<html开头的响应跳过解析, 走下方非JSON分类)
1969
+ let envelope = null;
1970
+ if (!/^\s*<html/i.test(text)) {
1971
+ try {
1972
+ envelope = JSON.parse(text);
1973
+ } catch (e) {
1974
+ /* 非JSON, 走下方非JSON分类 */
1975
+ }
1960
1976
  }
1961
- let envelope;
1962
- try {
1963
- envelope = JSON.parse(text);
1964
- } catch (e) {
1977
+ if (envelope && typeof envelope === "object") {
1978
+ if (envelope.result === "success") {
1979
+ return resolve(envelope);
1980
+ }
1981
+ const message = envelope.message;
1982
+ const detail =
1983
+ typeof message === "string"
1984
+ ? message
1985
+ : Object.values(message || {})
1986
+ .map((v) => (Array.isArray(v) ? v.join("; ") : String(v)))
1987
+ .join("; ");
1988
+ return reject(new Error(detail || `禅道接口返回失败: ${JSON.stringify(envelope)}`));
1989
+ }
1990
+ // 非JSON响应分类:
1991
+ // 1) alert脚本: 业务规则拦截(如字段校验失败"本次消耗必须为数字"), 提取alert文案作为真实错误
1992
+ const alertMatch = text.match(/alert\((['"])([\s\S]*?)\1\)/);
1993
+ if (alertMatch) {
1994
+ return reject(new Error(alertMatch[2]));
1995
+ }
1996
+ // 2) 脚本location跳转: 跳登录页=会话失效; 跳其他页(如 task-view-x)=操作成功(旧式 die(js::locate) 成功响应)
1997
+ const locateMatch = text.match(
1998
+ /(?:parent|self|window|top)\.location(?:\.href)?\s*=\s*['"]([^'"]+)['"]/
1999
+ );
2000
+ if (locateMatch) {
2001
+ if (/user-login/.test(locateMatch[1])) {
2002
+ return reject(
2003
+ Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
2004
+ );
2005
+ }
2006
+ return resolve({ result: "success", locate: locateMatch[1] });
2007
+ }
2008
+ // 3) 兜底登录页字样(无脚本跳转的登录页响应, POST响应不含用户数据可安全判定)
2009
+ if (/user-login/.test(text)) {
1965
2010
  return reject(
1966
2011
  Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
1967
2012
  );
1968
2013
  }
1969
- if (envelope.result === "success") {
1970
- return resolve(envelope);
1971
- }
1972
- const message = envelope.message;
1973
- const detail =
1974
- typeof message === "string"
1975
- ? message
1976
- : Object.values(message || {})
1977
- .map((v) => (Array.isArray(v) ? v.join("; ") : String(v)))
1978
- .join("; ");
1979
- return reject(new Error(detail || `禅道接口返回失败: ${JSON.stringify(envelope)}`));
2014
+ // 4) 其余异常: 带响应片段便于定位
2015
+ return reject(
2016
+ new Error(`禅道POST响应异常(非JSON): ${text.slice(0, 200)}`)
2017
+ );
1980
2018
  }
1981
2019
  );
1982
2020
  });
@@ -2267,6 +2305,18 @@ function fetchTaskDetail(zentaosid, taskID) {
2267
2305
  );
2268
2306
  }
2269
2307
 
2308
+ // 拉项目中指派给登录账号的任务列表(myinvolved页, close命令用)
2309
+ // 注: 与任务列表一致, 子任务不出现在列表(挂父任务children下), 父任务需逐个拉详情拿子任务
2310
+ function fetchMyInvolvedTasks(zentaosid, projectID) {
2311
+ return zentaoRequest({
2312
+ route: `project-task-${projectID}-myinvolved`,
2313
+ zentaosid,
2314
+ }).then((data) => ({
2315
+ tasks: Object.values(data.tasks || {}),
2316
+ pageTotal: Number(data.pager?.pageTotal || 1),
2317
+ }));
2318
+ }
2319
+
2270
2320
  /**
2271
2321
  * 在项目下为需求创建任务(实测协议, 12.3.1): POST task-create-{projectID}-{storyID}.json
2272
2322
  * - estStarted/deadline 必填(缺了返回 fail + 字段错误提示)
@@ -2365,6 +2415,38 @@ function createZentaoSubtasks({ zentaosid, projectID, story, parentTaskID, type,
2365
2415
  });
2366
2416
  }
2367
2417
 
2418
+ /**
2419
+ * 完成任务: POST task-finish-{taskID}.json (close 命令用)
2420
+ * - "本次消耗"字段名实测为 currentConsumed(本次增量, 服务端自动累计到总消耗);
2421
+ * 传 consumed 会报 ""本次消耗"必须为数字"(该字段没上送被校验为空)
2422
+ * - 完成人/指派人字段不传: 禅道服务端完成任务时强制置 finishedBy=当前登录人、
2423
+ * assignedTo='closed'(页面"指派给"列显示 Closed), 传了也会被覆盖, 不如不传
2424
+ * - 完成后状态置 done; 已 done 的无需 finish 直接 close
2425
+ */
2426
+ function finishZentaoTask({ zentaosid, taskID, consumed }) {
2427
+ return zentaoPost({
2428
+ route: `task-finish-${taskID}`,
2429
+ zentaosid,
2430
+ form: {
2431
+ currentConsumed: String(Number(consumed) || 0),
2432
+ comment: "",
2433
+ uid: String(Date.now()),
2434
+ },
2435
+ });
2436
+ }
2437
+
2438
+ // 关闭任务: POST task-close-{taskID}.json, 只传 comment 最小字段集(close 命令用)
2439
+ function closeZentaoTask({ zentaosid, taskID }) {
2440
+ return zentaoPost({
2441
+ route: `task-close-${taskID}`,
2442
+ zentaosid,
2443
+ form: {
2444
+ comment: "",
2445
+ uid: String(Date.now()),
2446
+ },
2447
+ });
2448
+ }
2449
+
2368
2450
  // 本地时区当天日期(YYYY-MM-DD, 任务estStarted用; toISOString是UTC会差8小时)
2369
2451
  function localToday() {
2370
2452
  const d = new Date();
@@ -2543,6 +2625,11 @@ async function downloadStoryImages({ stories, storyDetails, zentaosid, zentaoDir
2543
2625
  mapping.set(src, ref);
2544
2626
  };
2545
2627
  let index = 0;
2628
+ let imgDone = 0;
2629
+ const failedLogs = [];
2630
+ if (jobs.length) {
2631
+ Loading$1.start(`下载需求图片 (0/${jobs.length})...`);
2632
+ }
2546
2633
  const worker = async () => {
2547
2634
  while (index < jobs.length) {
2548
2635
  const { story, src } = jobs[index];
@@ -2555,15 +2642,25 @@ async function downloadStoryImages({ stories, storyDetails, zentaosid, zentaoDir
2555
2642
  fs.writeFileSync(target, buf);
2556
2643
  setMapping(story.id, src, `images/${fileName}`);
2557
2644
  } catch (e) {
2558
- console.log(
2559
- chalk.yellow(`需求 ${story.id} 图片下载失败(${src}): ${e.message}, 该图保留远程链接`)
2645
+ // 失败提示延后到spinner结束统一输出(避免与spinner交错)
2646
+ failedLogs.push(
2647
+ `需求 ${story.id} 图片下载失败(${src}): ${e.message}, 该图保留远程链接`
2560
2648
  );
2561
2649
  }
2650
+ imgDone += 1;
2651
+ // ora 的 text 是属性而非方法
2652
+ Loading$1.text = `下载需求图片 (${imgDone}/${jobs.length})...`;
2562
2653
  }
2563
2654
  };
2564
2655
  await Promise.all(
2565
2656
  Array.from({ length: PULL_CONCURRENCY }, () => worker())
2566
2657
  );
2658
+ if (jobs.length) {
2659
+ Loading$1.succeed(
2660
+ `下载需求图片完成(${jobs.length - failedLogs.length}/${jobs.length})`
2661
+ );
2662
+ }
2663
+ failedLogs.forEach((line) => console.log(chalk.yellow(line)));
2567
2664
  return result;
2568
2665
  }
2569
2666
 
@@ -2959,13 +3056,13 @@ async function exportPlanStories(zentaosid, selected, scope) {
2959
3056
 
2960
3057
  /**
2961
3058
  * story 主入口
2962
- * @param action login | plan | detail | task | subtask
3059
+ * @param action login | plan | detail | task | subtask | close
2963
3060
  * @param cliOpts 合并后的命令参数(name/username/password/cache/export...)
2964
3061
  */
2965
3062
  async function storyMain(action, cliOpts) {
2966
3063
  validateChoice(
2967
3064
  action,
2968
- ["login", "plan", "detail", "task", "subtask"],
3065
+ ["login", "plan", "detail", "task", "subtask", "close"],
2969
3066
  "story <action>"
2970
3067
  );
2971
3068
  if (action === "login") {
@@ -3090,11 +3187,17 @@ async function storyMain(action, cliOpts) {
3090
3187
  const totalCount = (s) => tasksOf(s).length;
3091
3188
  // 子任务统计: 子任务不出现在任务列表(挂在父任务children下), 逐个拉详情;
3092
3189
  // 只拉 工时>8 的主任务(只有这些才可能拆过子任务), 并发限流控制请求量
3190
+ // 注: parent 字段三态(实测): 0=独立任务 | -1=自己是父任务(有子任务) | >0=挂在别人下的子任务
3191
+ // 候选排除子任务即可(parent>0), -1 的父任务必须纳入(它才有children可统计)
3093
3192
  const subCountMap = new Map(); // 任务ID -> 子任务数
3094
3193
  {
3095
3194
  const candidates = projectTasks.filter(
3096
- (t) => Number(t.estimate) > 8 && !Number(t.parent)
3195
+ (t) => Number(t.estimate) > 8 && !(Number(t.parent) > 0)
3097
3196
  );
3197
+ if (candidates.length) {
3198
+ Loading$1.start(`统计子任务 (0/${candidates.length})...`);
3199
+ }
3200
+ let subDone = 0;
3098
3201
  let index = 0;
3099
3202
  const worker = async () => {
3100
3203
  while (index < candidates.length) {
@@ -3108,11 +3211,17 @@ async function storyMain(action, cliOpts) {
3108
3211
  if (e?.isAuthError) throw e;
3109
3212
  // 子任务统计失败不阻塞主流程(勾选列表仍可用, 只是少了子任务数)
3110
3213
  }
3214
+ subDone += 1;
3215
+ // ora 的 text 是属性而非方法
3216
+ Loading$1.text = `统计子任务 (${subDone}/${candidates.length})...`;
3111
3217
  }
3112
3218
  };
3113
3219
  await Promise.all(
3114
3220
  Array.from({ length: PULL_CONCURRENCY }, () => worker())
3115
3221
  );
3222
+ if (candidates.length) {
3223
+ Loading$1.succeed("统计子任务完成");
3224
+ }
3116
3225
  }
3117
3226
  // 先勾选需求: 一键(--type直通)自动取候选; 交互则列出项目全部需求
3118
3227
  let picked;
@@ -3273,6 +3382,13 @@ async function storyMain(action, cliOpts) {
3273
3382
  const account = readZentaoCache().account;
3274
3383
  const assigneeOf = (s) =>
3275
3384
  (taskType === "front" ? s.webuser : s.devuser) || account;
3385
+ // 现有父任务(pick中已建该类任务的): 拆分类模式复用, 不重复建
3386
+ const existingParentOf = (s) =>
3387
+ tasksOf(s).find((t) =>
3388
+ taskType === "front"
3389
+ ? isFrontTask(t)
3390
+ : t.type === "devel" && !isFrontTask(t)
3391
+ );
3276
3392
  // 处理模式(选择即确认): --split-only > --split > 交互列表 > 一键默认
3277
3393
  // parent=仅创建父任务 | parent-split=创建父任务+拆分子任务 | split=仅拆分(无父任务自动先建)
3278
3394
  let taskMode;
@@ -3282,6 +3398,18 @@ async function storyMain(action, cliOpts) {
3282
3398
  taskMode = "parent-split";
3283
3399
  } else if (cliOpts.type !== undefined) {
3284
3400
  taskMode = "parent"; // 一键默认仅建父任务
3401
+ } else if (picked.every((s) => existingParentOf(s))) {
3402
+ // 全部已有父任务: 无需再建父任务, 仅展示补拆选项(剩余工时拆满, 部分已拆的补齐)
3403
+ ({ taskMode } = await inquirer.prompt([
3404
+ {
3405
+ message: `将处理 ${picked.length} 条【${typeLabel}】任务(截止 ${pj.project.end}), 请选择模式`,
3406
+ name: "taskMode",
3407
+ type: "list",
3408
+ choices: [
3409
+ { name: "1. 拆分子任务(按剩余工时补满)", value: "split" },
3410
+ ],
3411
+ },
3412
+ ]));
3285
3413
  } else {
3286
3414
  ({ taskMode } = await inquirer.prompt([
3287
3415
  {
@@ -3296,13 +3424,6 @@ async function storyMain(action, cliOpts) {
3296
3424
  },
3297
3425
  ]));
3298
3426
  }
3299
- // 现有父任务(pick中已建该类任务的): 拆分类模式复用, 不重复建
3300
- const existingParentOf = (s) =>
3301
- tasksOf(s).find((t) =>
3302
- taskType === "front"
3303
- ? isFrontTask(t)
3304
- : t.type === "devel" && !isFrontTask(t)
3305
- );
3306
3427
  // 需创建父任务的需求:
3307
3428
  // parent=全部勾选 | parent-split=无父任务的 | split=无父任务且>8h(仅为拆分而建)
3308
3429
  const toCreate =
@@ -3314,11 +3435,13 @@ async function storyMain(action, cliOpts) {
3314
3435
  (taskMode === "parent-split" || hourOf(s) > 8)
3315
3436
  );
3316
3437
  // 创建前任务基线(创建后 diff 出新任务ID)
3438
+ Loading$1.start("同步任务基线中...");
3317
3439
  const baseline = new Set(
3318
3440
  (await fetchProjectTasks(zentaosid, projectID)).tasks.map((t) =>
3319
3441
  String(t.id)
3320
3442
  )
3321
3443
  );
3444
+ Loading$1.succeed("任务基线就绪");
3322
3445
  // 逐条创建(串行, 避免并发触发禅道限流)
3323
3446
  let done = 0;
3324
3447
  const failedStories = [];
@@ -3356,9 +3479,11 @@ async function storyMain(action, cliOpts) {
3356
3479
  }
3357
3480
  // 重拉任务列表 diff 出新任务, 输出任务url
3358
3481
  const pickedIDs = new Set(picked.map((s) => String(s.id)));
3482
+ Loading$1.start("刷新任务列表中...");
3359
3483
  const newTasks = (await fetchProjectTasks(zentaosid, projectID)).tasks.filter(
3360
3484
  (t) => !baseline.has(String(t.id)) && pickedIDs.has(String(t.story))
3361
3485
  );
3486
+ Loading$1.succeed(`刷新完成(新任务 ${newTasks.length} 条)`);
3362
3487
  newTasks.forEach((t) =>
3363
3488
  console.log(
3364
3489
  chalk.green(
@@ -3372,70 +3497,85 @@ async function storyMain(action, cliOpts) {
3372
3497
  const newByStory = new Map(
3373
3498
  newTasks.map((t) => [String(t.story), t])
3374
3499
  );
3375
- for (const s of picked) {
3376
- if (!(hourOf(s) > 8)) continue; // <=8h 无可拆, 跳过
3377
- const created = newByStory.get(String(s.id));
3378
- const parent = created || existingParentOf(s);
3379
- if (!parent) continue; // 创建失败等场景
3380
- // 截止日期须晚于今天(禅道校验 截止>开始, 旧任务截止已过期的跳过)
3381
- const deadline = parent.deadline || pj.project.end;
3382
- if (String(deadline) <= localToday()) {
3383
- console.log(
3384
- chalk.yellow(
3385
- `任务 #${parent.id} ${parent.name} 截止日期 ${deadline} 不晚于今天, 跳过拆分(需先调整父任务截止日期)`
3386
- )
3387
- );
3388
- continue;
3389
- }
3390
- // 剩余工时: 新建父任务=全部工时; 已有父任务=工时-已有子任务和(拉详情算)
3391
- let remaining = hourOf(s);
3392
- if (!created) {
3393
- const detail = await fetchTaskDetail(zentaosid, parent.id);
3394
- const childHours = Object.values(detail?.children || {}).reduce(
3395
- (sum, c) => sum + Number(c.estimate || 0),
3396
- 0
3397
- );
3398
- remaining = Number(parent.estimate) - childHours;
3399
- }
3400
- if (!(remaining > 8)) {
3401
- console.log(
3402
- chalk.yellow(
3403
- `任务 #${parent.id} ${parent.name} 剩余工时 ${remaining}h 未超过 8h, 跳过拆分`
3404
- )
3405
- );
3406
- continue;
3407
- }
3408
- const subs = splitTaskPlan(parent.name, remaining, taskType);
3500
+ // 拆分候选(仅>8h); 执行期间只显示spinner进度, 结果收集到splitLogs结束后统一输出
3501
+ const splitCandidates = picked.filter((s) => hourOf(s) > 8);
3502
+ const splitLogs = [];
3503
+ let splitDone = 0;
3504
+ if (splitCandidates.length) {
3505
+ Loading$1.start(`拆分子任务 (0/${splitCandidates.length})...`);
3506
+ }
3507
+ for (const s of splitCandidates) {
3508
+ let parent = null;
3409
3509
  try {
3410
- await createZentaoSubtasks({
3411
- zentaosid,
3412
- projectID,
3413
- story: s,
3414
- parentTaskID: parent.id,
3415
- type: taskType === "front" ? "frontend" : "devel",
3416
- assignedTo: parent.assignedTo,
3417
- estStarted: localToday(),
3418
- // 截止日期跟随父任务(新建父任务即为项目结束日期)
3419
- deadline: parent.deadline || pj.project.end,
3420
- subs,
3421
- });
3422
- console.log(
3423
- chalk.green(
3424
- `子任务 ${subs.length} 个已创建(父任务 #${parent.id}): ${subs
3425
- .map((sub) => `${sub.name}(${sub.estimate}h)`)
3426
- .join(", ")}`
3427
- )
3428
- );
3510
+ const created = newByStory.get(String(s.id));
3511
+ parent = created || existingParentOf(s);
3512
+ if (!parent) {
3513
+ // 创建失败等场景, 无父任务可挂, 跳过
3514
+ } else if (String(parent.deadline || pj.project.end) <= localToday()) {
3515
+ // 截止日期须晚于今天(禅道校验 截止>开始, 旧任务截止已过期的跳过)
3516
+ splitLogs.push(
3517
+ chalk.yellow(
3518
+ `任务 #${parent.id} ${parent.name} 截止日期 ${parent.deadline || pj.project.end} 不晚于今天, 跳过拆分(需先调整父任务截止日期)`
3519
+ )
3520
+ );
3521
+ } else {
3522
+ // 剩余工时: 新建父任务=全部工时; 已有父任务=工时-已有子任务和(拉详情算)
3523
+ let remaining = hourOf(s);
3524
+ if (!created) {
3525
+ const detail = await fetchTaskDetail(zentaosid, parent.id);
3526
+ const childHours = Object.values(detail?.children || {}).reduce(
3527
+ (sum, c) => sum + Number(c.estimate || 0),
3528
+ 0
3529
+ );
3530
+ remaining = Number(parent.estimate) - childHours;
3531
+ }
3532
+ if (!(remaining > 8)) {
3533
+ splitLogs.push(
3534
+ chalk.yellow(
3535
+ `任务 #${parent.id} ${parent.name} 剩余工时 ${remaining}h 未超过 8h, 跳过拆分`
3536
+ )
3537
+ );
3538
+ } else {
3539
+ const subs = splitTaskPlan(parent.name, remaining, taskType);
3540
+ await createZentaoSubtasks({
3541
+ zentaosid,
3542
+ projectID,
3543
+ story: s,
3544
+ parentTaskID: parent.id,
3545
+ type: taskType === "front" ? "frontend" : "devel",
3546
+ assignedTo: parent.assignedTo,
3547
+ estStarted: localToday(),
3548
+ // 截止日期跟随父任务(新建父任务即为项目结束日期)
3549
+ deadline: parent.deadline || pj.project.end,
3550
+ subs,
3551
+ });
3552
+ splitLogs.push(
3553
+ chalk.green(
3554
+ `子任务 ${subs.length} 个已创建(父任务 #${parent.id}): ${subs
3555
+ .map((sub) => `${sub.name}(${sub.estimate}h)`)
3556
+ .join(", ")}`
3557
+ )
3558
+ );
3559
+ }
3560
+ }
3429
3561
  } catch (e) {
3430
3562
  // 会话失效上抛, 由 ensureZentaoSession 整体重登重试
3431
3563
  if (e?.isAuthError) throw e;
3432
- console.log(
3564
+ splitLogs.push(
3433
3565
  chalk.yellow(
3434
- `任务 ${parent.id} 子任务创建失败: ${e.message || String(e)}`
3566
+ `任务 #${parent?.id || s.id} 子任务创建失败: ${e.message || String(e)}`
3435
3567
  )
3436
3568
  );
3569
+ } finally {
3570
+ splitDone += 1;
3571
+ // ora 的 text 是属性而非方法
3572
+ Loading$1.text = `拆分子任务 (${splitDone}/${splitCandidates.length})...`;
3437
3573
  }
3438
3574
  }
3575
+ if (splitCandidates.length) {
3576
+ Loading$1.succeed("拆分子任务完成");
3577
+ splitLogs.forEach((line) => console.log(line));
3578
+ }
3439
3579
  }
3440
3580
  // 部分失败以非零退出码收尾, 供自动化流程感知重试
3441
3581
  if (failedStories.length) {
@@ -3504,9 +3644,36 @@ async function storyMain(action, cliOpts) {
3504
3644
  );
3505
3645
  return;
3506
3646
  }
3647
+ // 截止日期须晚于预计开始(今天): 父任务截止已过期时禅道会校验失败, 交互输入子任务新截止日期
3648
+ let deadline = parent.deadline;
3649
+ if (String(deadline) <= localToday()) {
3650
+ console.log(
3651
+ chalk.yellow(
3652
+ `父任务截止日期 ${deadline} 已不晚于今天, 子任务截止日期需重新指定`
3653
+ )
3654
+ );
3655
+ const after7 = new Date();
3656
+ after7.setDate(after7.getDate() + 7);
3657
+ const defaultDeadline = `${after7.getFullYear()}-${String(
3658
+ after7.getMonth() + 1
3659
+ ).padStart(2, "0")}-${String(after7.getDate()).padStart(2, "0")}`;
3660
+ ({ deadline } = await inquirer.prompt([
3661
+ {
3662
+ message: "请输入子任务截止日期(YYYY-MM-DD)",
3663
+ name: "deadline",
3664
+ type: "input",
3665
+ default: defaultDeadline,
3666
+ validate: (val) =>
3667
+ (/^\d{4}-\d{2}-\d{2}$/.test(String(val).trim()) &&
3668
+ String(val).trim() > localToday()) ||
3669
+ "请输入晚于今天的日期, 格式 YYYY-MM-DD",
3670
+ },
3671
+ ]));
3672
+ deadline = String(deadline).trim();
3673
+ }
3507
3674
  // 拆分方案(按剩余工时, 名称对齐父任务名)
3508
3675
  const subs = splitTaskPlan(parent.name, remainHours, taskType);
3509
- console.log(chalk.bold(`\n拆分方案(${subs.length} 个子任务, 共${remainHours}h):`));
3676
+ console.log(chalk.bold(`\n拆分方案(${subs.length} 个子任务, 共${remainHours}h, 截止 ${deadline}):`));
3510
3677
  subs.forEach((sub) =>
3511
3678
  console.log(` ${sub.name} (${sub.estimate}h) -> ${parent.assignedTo}`)
3512
3679
  );
@@ -3525,6 +3692,7 @@ async function storyMain(action, cliOpts) {
3525
3692
  return;
3526
3693
  }
3527
3694
  }
3695
+ Loading$1.start("创建子任务中...");
3528
3696
  await createZentaoSubtasks({
3529
3697
  zentaosid,
3530
3698
  projectID: parent.project,
@@ -3533,9 +3701,10 @@ async function storyMain(action, cliOpts) {
3533
3701
  type: parent.type,
3534
3702
  assignedTo: parent.assignedTo,
3535
3703
  estStarted: localToday(),
3536
- deadline: parent.deadline,
3704
+ deadline,
3537
3705
  subs,
3538
3706
  });
3707
+ Loading$1.succeed(`子任务 ${subs.length} 个创建成功`);
3539
3708
  console.log(
3540
3709
  chalk.green(
3541
3710
  `子任务 ${subs.length} 个已创建(父任务 #${parent.id}): ${subs
@@ -3547,6 +3716,284 @@ async function storyMain(action, cliOpts) {
3547
3716
  });
3548
3717
  return;
3549
3718
  }
3719
+ if (action === "close") {
3720
+ // 入参 = 项目ID(完整url/相对路径/纯ID), 如 project-task-2915-myinvolved.html 里的 2915
3721
+ const input = String(cliOpts.name || "").trim();
3722
+ const match =
3723
+ input.match(/project-task-(\d+)/) ||
3724
+ (/^\d+$/.test(input) ? [input, input] : null);
3725
+ if (!match) {
3726
+ console.log(
3727
+ chalk.red(
3728
+ "请传入项目ID(从禅道任务列表url复制), 如: fe-it-beta story close 2915 或 fe-it-beta story close https://zentao.hongxinshop.com/zentao/project-task-2915-myinvolved.html"
3729
+ )
3730
+ );
3731
+ process.exit(1);
3732
+ }
3733
+ const projectID = match[1];
3734
+ const session = await ensureZentaoSession(cliOpts);
3735
+ await session.request(async (zentaosid) => {
3736
+ // 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
3737
+ Loading$1.start("校验项目中...");
3738
+ const projectMap = await fetchProjectMap(zentaosid);
3739
+ if (!projectMap[projectID]) {
3740
+ Loading$1.fail("项目不存在");
3741
+ throw new Error(`项目ID ${projectID} 不存在, 请检查后重试`);
3742
+ }
3743
+ Loading$1.succeed(`项目: #${projectID} ${projectMap[projectID]}`);
3744
+ const account = readZentaoCache().account;
3745
+ // 任务来源(两路取并集):
3746
+ // 1) myinvolved: 指派给登录账号的任务(完成的主入口)
3747
+ // 2) 项目任务列表中"由我完成未关闭"的——完成任务后禅道把指派转回创建人, 会从
3748
+ // myinvolved消失, 但仍需关闭, 须从项目任务列表按 finishedBy 兜回来
3749
+ Loading$1.start("拉取我的任务中...");
3750
+ const involved = await fetchMyInvolvedTasks(zentaosid, projectID);
3751
+ // 项目任务列表(由我完成的任务兜底来源); 失败降级不阻塞(少一路来源), 提示延后到spinner结束统一输出
3752
+ Loading$1.text = "拉取项目任务列表中...";
3753
+ let projectAll = { tasks: [], pageTotal: 1 };
3754
+ let projectAllError = null;
3755
+ try {
3756
+ projectAll = await fetchProjectTasks(zentaosid, projectID);
3757
+ } catch (e) {
3758
+ // 会话失效上抛, 由 ensureZentaoSession 整体重登重试; 其余降级不阻塞(少一路来源)
3759
+ if (e?.isAuthError) throw e;
3760
+ projectAllError = e;
3761
+ }
3762
+ const seen = new Set();
3763
+ const tasks = [];
3764
+ // 指派给我的(过滤已关闭/已取消)
3765
+ involved.tasks
3766
+ .filter(
3767
+ (t) =>
3768
+ t.assignedTo === account && !["closed", "cancel"].includes(t.status)
3769
+ )
3770
+ .forEach((t) => {
3771
+ if (!seen.has(String(t.id))) {
3772
+ seen.add(String(t.id));
3773
+ tasks.push(t);
3774
+ }
3775
+ });
3776
+ // 由我完成的(完成后禅道把指派置'closed'离开myinvolved, 统一从项目任务列表按 finishedBy 兜回):
3777
+ // 已完成待关闭的可勾选关闭; 已关闭的也进列表仅展示(不可选)
3778
+ projectAll.tasks
3779
+ .filter((t) => t.finishedBy === account && t.status !== "cancel")
3780
+ .forEach((t) => {
3781
+ if (!seen.has(String(t.id))) {
3782
+ seen.add(String(t.id));
3783
+ tasks.push(t);
3784
+ }
3785
+ });
3786
+ // 已完成待关闭的排最前(优先处理), 未完成的其后, 已关闭的最后(仅展示不可选), 组内按任务ID升序
3787
+ const statusRank = (t) =>
3788
+ t.status === "done" ? 0 : t.status === "closed" ? 2 : 1;
3789
+ tasks.sort(
3790
+ (a, b) => statusRank(a) - statusRank(b) || Number(a.id) - Number(b.id)
3791
+ );
3792
+ const donePending = tasks.filter((t) => t.status === "done").length;
3793
+ const closedShown = tasks.filter((t) => t.status === "closed").length;
3794
+ Loading$1.succeed(
3795
+ `拉取成功(未完成 ${tasks.length - donePending - closedShown} 条, 待关闭 ${donePending} 条${
3796
+ closedShown ? `, 已关闭 ${closedShown} 条(仅展示)` : ""
3797
+ })`
3798
+ );
3799
+ if (projectAllError) {
3800
+ console.log(
3801
+ chalk.yellow(
3802
+ `项目任务列表拉取失败, 由我完成的任务将不显示: ${projectAllError.message || String(projectAllError)}`
3803
+ )
3804
+ );
3805
+ }
3806
+ // 项目任务超单页时"由我完成"部分可能漏列(禅道翻页路由不稳定, 只提示不翻页)
3807
+ if (projectAll.pageTotal > 1) {
3808
+ console.log(
3809
+ chalk.yellow(
3810
+ `项目任务超过单页(${projectAll.pageTotal}页), 由我完成待关闭的任务可能漏列`
3811
+ )
3812
+ );
3813
+ }
3814
+ if (!tasks.length) {
3815
+ console.log(
3816
+ chalk.yellow("无指派给我的任务, 也无由我完成待关闭的任务, 结束")
3817
+ );
3818
+ return;
3819
+ }
3820
+ // 逐任务拉详情拿子任务(父任务的子任务不出现在任务列表, 展示与关闭都用)
3821
+ const details = new Map(); // 任务ID -> 任务详情
3822
+ const detailTargets = tasks.filter((t) => t.status !== "closed");
3823
+ if (detailTargets.length) {
3824
+ Loading$1.start(`拉取任务详情 (0/${detailTargets.length})...`);
3825
+ }
3826
+ let detailDone = 0;
3827
+ let index = 0;
3828
+ const detailWorker = async () => {
3829
+ while (index < tasks.length) {
3830
+ const t = tasks[index];
3831
+ index += 1;
3832
+ // 已关闭的仅展示不可选, 不参与处理, 跳过详情拉取省请求
3833
+ if (t.status === "closed") continue;
3834
+ try {
3835
+ details.set(String(t.id), await fetchTaskDetail(zentaosid, t.id));
3836
+ } catch (e) {
3837
+ if (e?.isAuthError) throw e;
3838
+ // 详情拉取失败不阻塞(该项关闭时再拉, 展示少了子任务数而已)
3839
+ }
3840
+ detailDone += 1;
3841
+ // ora 的 text 是属性而非方法
3842
+ Loading$1.text = `拉取任务详情 (${detailDone}/${detailTargets.length})...`;
3843
+ }
3844
+ };
3845
+ await Promise.all(
3846
+ Array.from({ length: PULL_CONCURRENCY }, () => detailWorker())
3847
+ );
3848
+ if (detailTargets.length) {
3849
+ Loading$1.succeed("拉取任务详情完成");
3850
+ }
3851
+ // 勾选要完成的任务(完成后自动关闭; 到列表边界停止, 不循环回第一条)
3852
+ const { picked } = await inquirer.prompt([
3853
+ {
3854
+ message: "请勾选要处理的任务(空格选择, 回车确认; 未完成的先完成后关闭, 已完成的直接关闭, 已关闭的仅展示不可选)",
3855
+ name: "picked",
3856
+ type: "checkbox",
3857
+ loop: false,
3858
+ choices: tasks.map((t) => {
3859
+ const detail = details.get(String(t.id));
3860
+ const children = Object.values(detail?.children || {});
3861
+ const closedCount = children.filter(
3862
+ (c) => c.status === "closed"
3863
+ ).length;
3864
+ // 第二行详情(子任务进度): 换行 + 缩进对齐, 与标题行区分
3865
+ const childInfo = children.length
3866
+ ? `\n 子任务${children.length}个(已关${closedCount}), 关闭全部子任务后父任务自动关闭`
3867
+ : "";
3868
+ return {
3869
+ name: `#${t.id} [${TASK_STATUS_LABELS[t.status] || t.status}] ${t.name} (${t.estimate || 0}h)${childInfo}`,
3870
+ value: t,
3871
+ // 已关闭的仅展示, 不可勾选
3872
+ disabled: t.status === "closed" ? "已关闭" : false,
3873
+ };
3874
+ }),
3875
+ },
3876
+ ]);
3877
+ if (!picked.length) {
3878
+ console.log(chalk.yellow("未勾选任务, 结束"));
3879
+ return;
3880
+ }
3881
+ // 写操作确认(关闭属敏感操作, 二次确认防误关)
3882
+ const { ok } = await inquirer.prompt([
3883
+ {
3884
+ message: `确认处理勾选的 ${picked.length} 个任务? (未完成的先完成后关闭; 有子任务的逐个关闭子任务, 全关后父任务自动关闭)`,
3885
+ name: "ok",
3886
+ type: "confirm",
3887
+ default: true,
3888
+ },
3889
+ ]);
3890
+ if (!ok) {
3891
+ console.log(chalk.yellow("已取消"));
3892
+ return;
3893
+ }
3894
+ // 单任务完成并关闭(叶子任务): 已 done 直接关, 否则先 finish 再 close
3895
+ // 结果进 logs(执行期间只显示spinner进度, 结束后统一输出, 避免与spinner交错)
3896
+ const finishAndClose = async (task, logs) => {
3897
+ if (task.status === "done") {
3898
+ await closeZentaoTask({ zentaosid, taskID: task.id });
3899
+ logs.push(
3900
+ chalk.green(`任务 #${task.id} ${task.name} 已关闭(原状态已完成)`)
3901
+ );
3902
+ return;
3903
+ }
3904
+ // 本次消耗取任务预估(currentConsumed 为增量, 服务端自动累计总消耗)
3905
+ await finishZentaoTask({
3906
+ zentaosid,
3907
+ taskID: task.id,
3908
+ consumed: task.estimate,
3909
+ });
3910
+ await closeZentaoTask({ zentaosid, taskID: task.id });
3911
+ logs.push(chalk.green(`任务 #${task.id} ${task.name} 已完成并关闭`));
3912
+ };
3913
+ let failedCount = 0;
3914
+ let done = 0;
3915
+ const logs = [];
3916
+ Loading$1.start(`处理任务 (0/${picked.length})...`);
3917
+ for (const task of picked) {
3918
+ try {
3919
+ // 详情缺失(预拉阶段失败的)现拉, 防父任务被误判成叶子任务直接 finish
3920
+ const detail =
3921
+ details.get(String(task.id)) ||
3922
+ (await fetchTaskDetail(zentaosid, task.id));
3923
+ const children = Object.values(detail?.children || {});
3924
+ if (children.length) {
3925
+ // 父任务: 逐个完成并关闭子任务, 全关后禅道自动关闭父任务
3926
+ for (const child of children) {
3927
+ if (child.status === "closed") {
3928
+ logs.push(
3929
+ chalk.yellow(
3930
+ ` 子任务 #${child.id} ${child.name} 已是关闭状态, 跳过`
3931
+ )
3932
+ );
3933
+ continue;
3934
+ }
3935
+ if (child.status === "done") {
3936
+ await closeZentaoTask({ zentaosid, taskID: child.id });
3937
+ logs.push(
3938
+ chalk.green(
3939
+ ` 子任务 #${child.id} ${child.name} 已关闭(原状态已完成)`
3940
+ )
3941
+ );
3942
+ } else {
3943
+ await finishZentaoTask({
3944
+ zentaosid,
3945
+ taskID: child.id,
3946
+ consumed: child.estimate,
3947
+ });
3948
+ await closeZentaoTask({ zentaosid, taskID: child.id });
3949
+ logs.push(
3950
+ chalk.green(` 子任务 #${child.id} ${child.name} 已完成并关闭`)
3951
+ );
3952
+ }
3953
+ }
3954
+ // 核验父任务最终状态(子任务全关后禅道自动关父任务)
3955
+ const after = await fetchTaskDetail(zentaosid, task.id);
3956
+ if (after?.status === "closed") {
3957
+ logs.push(
3958
+ chalk.green(
3959
+ `父任务 #${task.id} ${task.name} 已自动关闭(子任务全部关闭)`
3960
+ )
3961
+ );
3962
+ } else {
3963
+ logs.push(
3964
+ chalk.yellow(
3965
+ `父任务 #${task.id} ${task.name} 状态 ${after?.status || "?"}, 未自动关闭, 请到禅道手动处理: ${taskUrl(task.id)}`
3966
+ )
3967
+ );
3968
+ }
3969
+ } else {
3970
+ // 叶子任务: 直接完成并关闭
3971
+ await finishAndClose(task, logs);
3972
+ }
3973
+ } catch (e) {
3974
+ // 会话失效上抛, 由 ensureZentaoSession 整体重登重试
3975
+ if (e?.isAuthError) throw e;
3976
+ failedCount += 1;
3977
+ logs.push(
3978
+ chalk.yellow(`任务 #${task.id} ${task.name} 处理失败: ${e.message || String(e)}`)
3979
+ );
3980
+ }
3981
+ done += 1;
3982
+ // ora 的 text 是属性而非方法
3983
+ Loading$1.text = `处理任务 (${done}/${picked.length})...`;
3984
+ }
3985
+ Loading$1.succeed(
3986
+ `处理完成: 成功 ${picked.length - failedCount} 条 / 失败 ${failedCount} 条`
3987
+ );
3988
+ logs.forEach((line) => console.log(line));
3989
+ // 部分失败以非零退出码收尾, 供自动化流程感知重试
3990
+ if (failedCount) {
3991
+ console.log(chalk.red(`失败${failedCount}个, 详情见上方黄字提示`));
3992
+ process.exit(1);
3993
+ }
3994
+ });
3995
+ return;
3996
+ }
3550
3997
  // plan: 产品选择 -> 计划选取 -> 展示 -> 导出(--export直通一键场景, 不传则收尾交互)
3551
3998
  const session = await ensureZentaoSession(cliOpts);
3552
3999
  await session.request(async (zentaosid) => {
@@ -3589,7 +4036,7 @@ function registerStoryCommand(program, { markHandled, addHelpExample }) {
3589
4036
  program
3590
4037
  .command("story <action> [name]")
3591
4038
  .description(
3592
- "禅道需求: login 登录 | plan 选计划看需求列表并可导出(按计划生成需求目录+工作区) | detail 看需求详情 | task 勾选项目需求在线建任务 | subtask 给已建任务单独拆子任务"
4039
+ "禅道需求: login 登录 | plan 选计划看需求列表并可导出(按计划生成需求目录+工作区) | detail 看需求详情 | task 勾选项目需求在线建任务 | subtask 给已建任务单独拆子任务 | close 完成并关闭指派给我的任务"
3593
4040
  )
3594
4041
  // 子命令的 option 独立于 program 级: story login --cache 中 --cache 出现在子命令位, 须在子命令上重复注册
3595
4042
  .option("--username <name>", "禅道账号(story命令用)")
@@ -3621,6 +4068,10 @@ function registerStoryCommand(program, { markHandled, addHelpExample }) {
3621
4068
  "一键创建前端任务并自动拆分子任务"
3622
4069
  );
3623
4070
  addHelpExample("fe-it-beta story subtask 父任务ID", "给已建任务单独拆子任务(按剩余工时)");
4071
+ addHelpExample(
4072
+ "fe-it-beta story close 2915",
4073
+ "完成并关闭项目中指派给我的任务(子任务全关父任务自动关闭)"
4074
+ );
3624
4075
  addHelpExample(
3625
4076
  "fe-it-beta story plan --product 87 --export front --cache",
3626
4077
  "一键导出前端需求并生成多项目工作区"