i18-fe-automator-beta 2.1.9 → 2.1.10

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.
@@ -108,6 +108,68 @@ class CheckboxAllPrompt extends CheckboxPrompt {
108
108
  super.onInverseKey();
109
109
  this.syncAllChoice();
110
110
  }
111
+ }
112
+
113
+ /**
114
+ * 组联动式checkbox prompt:
115
+ * - 组开关选项标记 groupToggle: '<组名>',组成员选项标记 group: '<组名>'
116
+ * - 勾选/取消组开关:该组全部成员同步勾选/取消(可视化联动,而非静默取集合)
117
+ * - 切换组成员:组内全勾时开关自动点亮,任一取消则开关熄灭
118
+ * - 未标记 group 的选项为普通选项,不参与任何联动
119
+ */
120
+ class CheckboxGroupPrompt extends CheckboxPrompt {
121
+ getGroupToggles() {
122
+ return this.opt.choices.filter(
123
+ (choice) =>
124
+ choice.type !== "separator" && choice.groupToggle && !choice.disabled
125
+ );
126
+ }
127
+
128
+ getGroupMembers(groupId) {
129
+ return this.opt.choices.filter(
130
+ (choice) =>
131
+ choice.type !== "separator" &&
132
+ choice.group === groupId &&
133
+ !choice.disabled
134
+ );
135
+ }
136
+
137
+ // 根据各组勾选情况同步开关状态(组内全勾才亮)
138
+ syncGroupToggles() {
139
+ this.getGroupToggles().forEach((toggle) => {
140
+ const members = this.getGroupMembers(toggle.groupToggle);
141
+ if (members.length) {
142
+ toggle.checked = members.every((item) => item.checked);
143
+ }
144
+ });
145
+ }
146
+
147
+ // 覆写toggleChoice: 空格键和数字键选择都会经过这里,统一处理联动
148
+ toggleChoice(index) {
149
+ const choice = this.opt.choices.getChoice(index);
150
+ if (choice && choice.groupToggle) {
151
+ // 切换组开关:该组成员全部跟随
152
+ const checked = !choice.checked;
153
+ choice.checked = checked;
154
+ this.getGroupMembers(choice.groupToggle).forEach((item) => {
155
+ item.checked = checked;
156
+ });
157
+ } else {
158
+ super.toggleChoice(index);
159
+ this.syncGroupToggles();
160
+ }
161
+ }
162
+
163
+ // 按a全选/按i反选后,同步各组开关状态
164
+ onAllKey() {
165
+ super.onAllKey();
166
+ this.syncGroupToggles();
167
+ }
168
+
169
+ onInverseKey() {
170
+ super.onInverseKey();
171
+ this.syncGroupToggles();
172
+ }
111
173
  }
112
174
 
113
175
  function Loading() {
@@ -2290,15 +2352,46 @@ function fetchProjectStories(zentaosid, projectID) {
2290
2352
  // 拉项目任务列表(全部状态, 不含已删除; 供勾选时统计已建任务数 + 创建前后 diff 拿新任务ID)
2291
2353
  // 注1: 不用禅道 storyWebTasks/storyDevTasks 计数——软删除(回收站)的任务也计入, 数据不准(实测)
2292
2354
  // 注2: pager.recTotal 同样含回收站任务(返回数恒小于 recTotal 属正常), 翻页判定用 pageTotal
2293
- // 注3: 子任务不出现在任务列表(挂在父任务 children 下), 子任务统计须逐任务拉 task-view
2294
- function fetchProjectTasks(zentaosid, projectID) {
2295
- return zentaoRequest({
2355
+ // 注3: 子任务不出现在任务列表(挂在父任务 children 下), 且列表的 children 字段残缺(只带1个子任务, 实测),
2356
+ // 子任务统计须按 parent=-1 圈真父任务逐个拉 task-view
2357
+ // 注4: 任务超单页时按 pageTotal 循环翻页拉全, 某页失败降级黄字提示不阻塞(该页任务缺计数),
2358
+ // 翻页拉全后不再有"计数不全"提醒
2359
+ // 注5: 翻页路由格式(实测从任务列表页 pager 的 data-link-creator 提取):
2360
+ // project-task-<ID>-all-0--<recTotal>-<recPerPage>-<页码>;
2361
+ // 错误示范: project-task-<ID>-all-<页码> 禅道不认, 静默返回第1页 → 任务重复计数(踩坑实录)
2362
+ async function fetchProjectTasks(zentaosid, projectID) {
2363
+ const first = await zentaoRequest({
2296
2364
  route: `project-task-${projectID}-all`,
2297
2365
  zentaosid,
2298
- }).then((data) => ({
2299
- tasks: Object.values(data.tasks || {}),
2300
- pageTotal: Number(data.pager?.pageTotal || 1),
2301
- }));
2366
+ });
2367
+ const tasks = Object.values(first.tasks || {});
2368
+ const pager = first.pager || {};
2369
+ const pageTotal = Number(pager.pageTotal || 1);
2370
+ for (let page = 2; page <= pageTotal; page += 1) {
2371
+ // ora 的 text 是属性而非方法
2372
+ Loading$1.text = `拉取项目任务中(第${page}/${pageTotal}页)...`;
2373
+ try {
2374
+ const next = await zentaoRequest({
2375
+ route: `project-task-${projectID}-all-0--${pager.recTotal}-${pager.recPerPage}-${page}`,
2376
+ zentaosid,
2377
+ });
2378
+ tasks.push(...Object.values(next.tasks || {}));
2379
+ } catch (e) {
2380
+ // 会话失效上抛, 由 ensureZentaoSession 整体重登重试
2381
+ if (e?.isAuthError) throw e;
2382
+ console.log(
2383
+ chalk.yellow(`第${page}页任务拉取失败, 该页任务缺计数: ${e.message}`)
2384
+ );
2385
+ }
2386
+ }
2387
+ // 按任务ID去重兜底: 翻页路由若再被禅道静默忽略会返回重复数据, 重复计数直接污染前端/后端父任务数
2388
+ const seen = new Set();
2389
+ const unique = tasks.filter((t) => {
2390
+ if (seen.has(t.id)) return false;
2391
+ seen.add(t.id);
2392
+ return true;
2393
+ });
2394
+ return { tasks: unique, pageTotal };
2302
2395
  }
2303
2396
 
2304
2397
  // 拉单个任务详情(含children子任务映射, subtask命令与子任务统计用)
@@ -3213,7 +3306,8 @@ async function storyMain(action, cliOpts) {
3213
3306
  validateChoice(cliOpts.type, ["front", "back"], "--type");
3214
3307
  }
3215
3308
  const session = await ensureZentaoSession(cliOpts);
3216
- await session.request(async (zentaosid) => {
3309
+ // 具名回调: 交互模式一轮结束后可递归重入, 回到需求勾选列表(计数刷新); 一键模式单轮结束
3310
+ await session.request(async function taskRound(zentaosid) {
3217
3311
  // 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
3218
3312
  Loading$1.start("校验项目中...");
3219
3313
  const projectMap = await fetchProjectMap(zentaosid);
@@ -3237,22 +3331,14 @@ async function storyMain(action, cliOpts) {
3237
3331
  }
3238
3332
  // 每条需求的前后端已建任务计数(勾选时提示防重复建)
3239
3333
  // 自行从项目任务列表统计(禅道 storyWebTasks/storyDevTasks 会把回收站任务也计入, 实测不准)
3240
- Loading$1.start("拉取项目任务中...");
3334
+ Loading$1.start("拉取项目任务中(第1页)...");
3241
3335
  const { tasks: projectTasks, pageTotal } = await fetchProjectTasks(
3242
3336
  zentaosid,
3243
3337
  projectID
3244
3338
  );
3245
3339
  Loading$1.succeed(
3246
- `拉取项目任务成功(${projectTasks.length}条, 需求计数用)`
3340
+ `拉取项目任务成功(${projectTasks.length}条/${pageTotal}页, 需求计数用)`
3247
3341
  );
3248
- // 任务超单页时计数可能不全(禅道翻页路由不稳定, 只提示不翻页)
3249
- if (pageTotal > 1) {
3250
- console.log(
3251
- chalk.yellow(
3252
- `项目任务超过单页(${pageTotal}页), 已建计数可能不全`
3253
- )
3254
- );
3255
- }
3256
3342
  // 前端任务判定: type=frontend 或 名称【前端】前缀(修复前的旧数据 type 误传 devel)
3257
3343
  const isFrontTask = (t) =>
3258
3344
  t.type === "frontend" || String(t.name || "").startsWith("【前端】");
@@ -3261,15 +3347,15 @@ async function storyMain(action, cliOpts) {
3261
3347
  const webCount = (s) => tasksOf(s).filter(isFrontTask).length;
3262
3348
  const devCount = (s) =>
3263
3349
  tasksOf(s).filter((t) => t.type === "devel" && !isFrontTask(t)).length;
3264
- const totalCount = (s) => tasksOf(s).length;
3265
- // 子任务统计: 子任务不出现在任务列表(挂在父任务children下), 逐个拉详情;
3266
- // 只拉 工时>8 的主任务(只有这些才可能拆过子任务), 并发限流控制请求量
3267
- // 注: parent 字段三态(实测): 0=独立任务 | -1=自己是父任务(有子任务) | >0=挂在别人下的子任务
3268
- // 候选排除子任务即可(parent>0), -1 的父任务必须纳入(它才有children可统计)
3350
+ // 子任务统计: 子任务不出现在任务列表(挂在父任务children下), 逐个拉详情, 并发限流控制请求量
3351
+ // 注1: parent 字段三态(实测): 0=独立任务(无子任务) | -1=自己是父任务(有子任务) | >0=挂在别人下的子任务
3352
+ // 注2: 候选只拉 parent=-1 的真父任务即可——列表里 parent=0 的独立任务不会有 children(实测抽查),
3353
+ // 且列表 children 字段本身残缺(只带1个, 详情才全), 逐父任务拉 task-view 是唯一准确途径;
3354
+ // 此前按 estimate>8 圈候选会漏掉 ≤8h 却手动拆过子任务的父任务, 且请求量大(89个→15个, 6倍提速)
3269
3355
  const subCountMap = new Map(); // 任务ID -> 子任务数
3270
3356
  {
3271
3357
  const candidates = projectTasks.filter(
3272
- (t) => Number(t.estimate) > 8 && !(Number(t.parent) > 0)
3358
+ (t) => Number(t.parent) === -1
3273
3359
  );
3274
3360
  if (candidates.length) {
3275
3361
  Loading$1.start(`统计子任务 (0/${candidates.length})...`);
@@ -3302,6 +3388,8 @@ async function storyMain(action, cliOpts) {
3302
3388
  }
3303
3389
  // 先勾选需求: 一键(--type直通)自动取候选; 交互则列出项目全部需求
3304
3390
  let picked;
3391
+ // 交互勾了「一键前端」的标志(供后续跳过任务类型询问, 前端已隐含)
3392
+ let frontTodoPicked = false;
3305
3393
  if (cliOpts.type !== undefined) {
3306
3394
  const presetLabel = cliOpts.type === "front" ? "前端" : "后端";
3307
3395
  const hourOfPreset = (s) =>
@@ -3346,85 +3434,129 @@ async function storyMain(action, cliOpts) {
3346
3434
  ? Math.max(...frontTasks.map((t) => Number(t.estimate) || 0))
3347
3435
  : Number(s.webestimate);
3348
3436
  };
3437
+ // 后端子任务计数/基准工时: 与前端同构(父任务=devel类任务, 无父任务用需求estimate)
3438
+ const isBackTask = (t) => t.type === "devel" && !isFrontTask(t);
3439
+ const backSubsOf = (s) =>
3440
+ tasksOf(s)
3441
+ .filter(isBackTask)
3442
+ .reduce(
3443
+ (sum, t) => sum + (subCountMap.get(String(t.id)) || 0),
3444
+ 0
3445
+ );
3446
+ const backBaseHours = (s) => {
3447
+ const backTasks = tasksOf(s).filter(isBackTask);
3448
+ return backTasks.length
3449
+ ? Math.max(...backTasks.map((t) => Number(t.estimate) || 0))
3450
+ : Number(s.estimate);
3451
+ };
3452
+ // 一键前端: 批量选中"前端待办"(有前端工时且未建父任务, 或有父任务但子任务未满)
3453
+ const frontTodo = pj.stories.filter((s) => {
3454
+ if (!(Number(s.webestimate) > 0)) return false;
3455
+ if (webCount(s) === 0) return true;
3456
+ return (
3457
+ frontBaseHours(s) > 8 &&
3458
+ frontSubsOf(s) < Math.ceil(frontBaseHours(s) / 8)
3459
+ );
3460
+ });
3461
+ const FRONT_TODO = "__FRONT_TODO__";
3462
+ // 组联动式checkbox: 勾「一键前端」下方待办需求同步点亮/熄灭(可视化联动), 取消个别则开关熄灭
3463
+ inquirer.registerPrompt("checkbox-group", CheckboxGroupPrompt);
3349
3464
  ({ picked } = await inquirer.prompt([
3350
3465
  {
3351
- message: "请勾选要建任务的需求(空格选择, 回车确认)",
3466
+ message: "请勾选要建任务的需求(空格选择, 回车确认; 「一键前端」联动勾选前端待办)",
3352
3467
  name: "picked",
3353
- type: "checkbox",
3468
+ type: "checkbox-group",
3354
3469
  // 到列表边界停止, 不循环回第一条
3355
3470
  loop: false,
3356
- choices: [...pj.stories]
3357
- .sort((a, b) => {
3358
- // 待办优先级: 0=前端有工时且未建任务(子任务也必无, 全新建)
3359
- // 1=前端有任务但子任务未满(待补拆, 基准=父任务实际工时) 2=其余有前端工时 3=无前端工时
3360
- // 组内按需求ID从小到大
3361
- const rank = (s) => {
3362
- if (!(Number(s.webestimate) > 0)) return 3;
3363
- const base = frontBaseHours(s);
3364
- if (webCount(s) === 0) return 0;
3365
- return base > 8 && frontSubsOf(s) < Math.ceil(base / 8)
3366
- ? 1
3367
- : 2;
3368
- };
3369
- return (
3370
- rank(a) - rank(b) ||
3371
- Number(a.id) - Number(b.id)
3372
- );
3373
- })
3374
- .map((s, i) => {
3375
- // 子任务进度(仅前端>8h展示, 后端暂不展示): 已建/应建
3376
- // 应建 = ceil(基准工时/8), 基准=前端父任务实际工时(无父任务用webestimate)
3377
- // 未满已建数红色, 满则总数绿色; 任务计数后括号内联
3378
- const subProgress = (hours, count) => {
3379
- const total = Math.ceil(Number(hours) / 8);
3380
- return count >= total
3381
- ? `(${count}/${chalk.green(total)})`
3382
- : `(${chalk.red(count)}/${total})`;
3471
+ choices: [
3472
+ {
3473
+ // 无待办时置灰; groupToggle组开关, 联动所有group:front成员
3474
+ name: `一键前端(${frontTodo.length}条: 未建父任务/子任务未满)`,
3475
+ value: FRONT_TODO,
3476
+ short: "一键前端",
3477
+ groupToggle: "front",
3478
+ disabled: frontTodo.length ? false : "无前端待办",
3479
+ },
3480
+ ...[...pj.stories]
3481
+ .sort((a, b) => {
3482
+ // 待办优先级: 0=前端有工时且未建任务(子任务也必无, 全新建)
3483
+ // 1=前端有任务但子任务未满(待补拆, 基准=父任务实际工时) 2=其余有前端工时 3=无前端工时
3484
+ // 组内按需求ID从小到大
3485
+ const rank = (s) => {
3486
+ if (!(Number(s.webestimate) > 0)) return 3;
3487
+ const base = frontBaseHours(s);
3488
+ if (webCount(s) === 0) return 0;
3489
+ return base > 8 && frontSubsOf(s) < Math.ceil(base / 8)
3490
+ ? 1
3491
+ : 2;
3492
+ };
3493
+ return (
3494
+ rank(a) - rank(b) ||
3495
+ Number(a.id) - Number(b.id)
3496
+ );
3497
+ })
3498
+ .map((s, i) => {
3499
+ // 任务计数(前后端一排, " / "分隔): x个父任务 + 子任务进度(已建/应建)
3500
+ // 子任务进度仅基准工时>8h展示: 应建 = ceil(基准工时/8), 基准=该类父任务实际工时(无父任务用需求该类工时)
3501
+ // 未满应建数红色, 满则绿色; 工时与计数合并为一行(标题行内联), 无该类工时不输出该段
3502
+ const taskLine = (label, hours, count, baseHours, subs) => {
3503
+ let text = `${label}${hours}h,${count}个父任务`;
3504
+ if (baseHours > 8) {
3505
+ const total = Math.ceil(baseHours / 8);
3506
+ const progress =
3507
+ subs >= total
3508
+ ? `${subs}/${chalk.green(total)}`
3509
+ : `${subs}/${chalk.red(total)}`;
3510
+ text += `,${progress}子任务`;
3511
+ }
3512
+ return text;
3383
3513
  };
3384
- const frontSubs = frontSubsOf(s);
3385
- const base = frontBaseHours(s);
3386
- // 前端: 任务数(子任务进度); 后端: 任务数(暂不展示子任务)
3387
- const built = [
3514
+ const countParts = [
3388
3515
  Number(s.webestimate) > 0
3389
- ? `前端${webCount(s)}个${
3390
- base > 8 ? subProgress(base, frontSubs) : ""
3391
- }`
3516
+ ? taskLine("前端", s.webestimate, webCount(s), frontBaseHours(s), frontSubsOf(s))
3517
+ : "",
3518
+ Number(s.estimate) > 0
3519
+ ? taskLine("后端", s.estimate, devCount(s), backBaseHours(s), backSubsOf(s))
3392
3520
  : "",
3393
- Number(s.estimate) > 0 ? `后端${devCount(s)}个 任务` : "",
3394
- ]
3395
- .filter(Boolean)
3396
- .join("/");
3397
- // 父任务工时不足提示: 有前端父任务但工时 < 需求前端工时(黄字, 手建父任务漏填/填少)
3398
- const shortWarn =
3399
- webCount(s) > 0 && base < Number(s.webestimate)
3400
- ? `, ${chalk.yellow(`父任务${base}h<需求${s.webestimate}h, 不足!`)}`
3401
- : "";
3402
- // 第二行详情(已建/警告): 换行 + 缩进对齐, 与标题行区分
3403
- const detail = [built ? `已建${built}` : "", shortWarn.slice(2)]
3404
- .filter(Boolean)
3405
- .join(", ");
3521
+ ].filter(Boolean);
3522
+ // 父任务工时不足提示: 有前端父任务但工时 < 需求前端工时(黄字, 手建父任务漏填/填少), 追加同排末尾
3523
+ const frontBase = frontBaseHours(s);
3524
+ if (webCount(s) > 0 && frontBase < Number(s.webestimate)) {
3525
+ countParts.push(
3526
+ chalk.yellow(
3527
+ `父任务${frontBase}h<需求${s.webestimate}h, 不足!`
3528
+ )
3529
+ );
3530
+ }
3406
3531
  return {
3407
- name: `${i + 1}. #${s.id} ${s.title} (前端${
3408
- Number(s.webestimate) > 0 ? `${s.webestimate}h` : "-"
3409
- } / 后端${
3410
- Number(s.estimate) > 0 ? `${s.estimate}h` : "-"
3411
- })${detail ? `\n ${detail}` : ""}`,
3532
+ name: `${i + 1}. #${s.id} ${s.title} (${countParts.join(" / ")})`,
3412
3533
  value: s,
3413
- // 有工时但从未建过任何任务的需求默认勾选
3414
- checked: totalCount(s) === 0,
3534
+ // 前端待办成员: 受「一键前端」组开关联动勾选/取消
3535
+ ...(frontTodo.includes(s) ? { group: "front" } : {}),
3415
3536
  };
3416
3537
  }),
3538
+ ],
3417
3539
  },
3418
3540
  ]));
3541
+ // 勾了「一键前端」: 提交结果按实际勾选的需求算(联动已把待办项点亮, 哨兵值剔除), 类型固定前端
3542
+ if (picked.includes(FRONT_TODO)) {
3543
+ frontTodoPicked = true;
3544
+ picked = picked.filter((v) => v !== FRONT_TODO);
3545
+ console.log(
3546
+ chalk.blue(`一键前端: 已选中 ${picked.length} 条前端待办需求`)
3547
+ );
3548
+ }
3419
3549
  }
3420
3550
  if (!picked.length) {
3421
3551
  console.log(chalk.yellow("未勾选需求, 结束"));
3422
3552
  return;
3423
3553
  }
3424
- // 再选任务类型: --type 直通(一键场景) > 选完需求后交互询问
3554
+ // 再选任务类型: --type 直通(一键场景) > 一键前端固定前端 > 选完需求后交互询问
3425
3555
  let taskType;
3426
3556
  if (cliOpts.type !== undefined) {
3427
3557
  taskType = cliOpts.type;
3558
+ } else if (frontTodoPicked) {
3559
+ taskType = "front"; // 一键前端已隐含类型
3428
3560
  } else {
3429
3561
  ({ taskType } = await inquirer.prompt([
3430
3562
  {
@@ -3467,7 +3599,7 @@ async function storyMain(action, cliOpts) {
3467
3599
  : t.type === "devel" && !isFrontTask(t)
3468
3600
  );
3469
3601
  // 处理模式(选择即确认): --split-only > --split > 交互列表 > 一键默认
3470
- // parent=仅创建父任务 | parent-split=创建父任务+拆分子任务 | split=仅拆分(无父任务自动先建)
3602
+ // parent=仅创建父任务 | parent-split=拆分子任务(无父任务自动先建, ≤8h仅建父任务不拆) | split=仅拆分(--split-only专用, >8h才建父任务)
3471
3603
  let taskMode;
3472
3604
  if (cliOpts.splitOnly) {
3473
3605
  taskMode = "split";
@@ -3488,6 +3620,7 @@ async function storyMain(action, cliOpts) {
3488
3620
  },
3489
3621
  ]));
3490
3622
  } else {
3623
+ // 交互合并原"创建父任务+拆分子任务"与"仅拆分"为一项: 无父任务自动先建(≤8h仅建父任务), >8h拆分, 已有父任务复用
3491
3624
  ({ taskMode } = await inquirer.prompt([
3492
3625
  {
3493
3626
  message: `将处理 ${picked.length} 条【${typeLabel}】任务(截止 ${pj.project.end}), 请选择模式`,
@@ -3495,9 +3628,10 @@ async function storyMain(action, cliOpts) {
3495
3628
  type: "list",
3496
3629
  choices: [
3497
3630
  { name: "1. 创建父任务", value: "parent" },
3498
- { name: "2. 创建父任务+拆分子任务", value: "parent-split" },
3499
- { name: "3. 拆分子任务(无父任务自动先创建)", value: "split" },
3631
+ { name: "2. 创建子任务(无父任务自动先创建, ≤8h不拆)", value: "parent-split" },
3500
3632
  ],
3633
+ // 默认选中2(创建子任务)
3634
+ default: "parent-split",
3501
3635
  },
3502
3636
  ]));
3503
3637
  }
@@ -3654,7 +3788,22 @@ async function storyMain(action, cliOpts) {
3654
3788
  splitLogs.forEach((line) => console.log(line));
3655
3789
  }
3656
3790
  }
3657
- // 部分失败以非零退出码收尾, 供自动化流程感知重试
3791
+ // 交互模式: 一轮结束询问是否继续(默认继续), 继续则回到需求勾选列表(任务计数重新拉取)
3792
+ if (cliOpts.type === undefined) {
3793
+ const { goNext } = await inquirer.prompt([
3794
+ {
3795
+ message: "是否继续处理其他需求? (继续回到需求勾选列表)",
3796
+ name: "goNext",
3797
+ type: "confirm",
3798
+ default: true,
3799
+ },
3800
+ ]);
3801
+ if (goNext) {
3802
+ // 递归进入新一轮(会话失效仍由 session.request 兜底重登)
3803
+ return session.request(taskRound);
3804
+ }
3805
+ }
3806
+ // 部分失败以非零退出码收尾(一键模式, 或交互模式选择结束时), 供自动化流程感知重试
3658
3807
  if (failedStories.length) {
3659
3808
  console.log(
3660
3809
  chalk.red(
@@ -3809,7 +3958,8 @@ async function storyMain(action, cliOpts) {
3809
3958
  }
3810
3959
  const projectID = match[1];
3811
3960
  const session = await ensureZentaoSession(cliOpts);
3812
- await session.request(async (zentaosid) => {
3961
+ // 具名回调: 一轮关闭结束后可递归重入, 回到任务勾选列表(任务状态重新拉取)
3962
+ await session.request(async function closeRound(zentaosid) {
3813
3963
  // 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
3814
3964
  Loading$1.start("校验项目中...");
3815
3965
  const projectMap = await fetchProjectMap(zentaosid);
@@ -3880,14 +4030,7 @@ async function storyMain(action, cliOpts) {
3880
4030
  )
3881
4031
  );
3882
4032
  }
3883
- // 项目任务超单页时"由我完成"部分可能漏列(禅道翻页路由不稳定, 只提示不翻页)
3884
- if (projectAll.pageTotal > 1) {
3885
- console.log(
3886
- chalk.yellow(
3887
- `项目任务超过单页(${projectAll.pageTotal}页), 由我完成待关闭的任务可能漏列`
3888
- )
3889
- );
3890
- }
4033
+ // fetchProjectTasks 已翻页拉全, "由我完成"兜底不再漏列(单页失败时其黄字已提示)
3891
4034
  if (!tasks.length) {
3892
4035
  console.log(
3893
4036
  chalk.yellow("无指派给我的任务, 也无由我完成待关闭的任务, 结束")
@@ -3926,39 +4069,51 @@ async function storyMain(action, cliOpts) {
3926
4069
  Loading$1.succeed("拉取任务详情完成");
3927
4070
  }
3928
4071
  // 勾选要完成的任务(完成后自动关闭; 到列表边界停止, 不循环回第一条)
4072
+ // 顶部「一键关闭」与下方任务联动: 勾上=全选可选任务, 取消任一下方任务则自动熄灭(已关闭项不参与)
4073
+ inquirer.registerPrompt("checkbox-all", CheckboxAllPrompt);
4074
+ const closableCount = tasks.filter((t) => t.status !== "closed").length;
3929
4075
  const { picked } = await inquirer.prompt([
3930
4076
  {
3931
- message: "请勾选要处理的任务(空格选择, 回车确认; 未完成的先完成后关闭, 已完成的直接关闭, 已关闭的仅展示不可选)",
4077
+ message: "请勾选要处理的任务(空格选择, 回车确认; 未完成的先完成后关闭, 已完成的直接关闭, 已关闭的仅展示不可选, 关闭全部子任务后父任务自动关闭)",
3932
4078
  name: "picked",
3933
- type: "checkbox",
4079
+ type: "checkbox-all",
3934
4080
  loop: false,
3935
- choices: tasks.map((t) => {
3936
- const detail = details.get(String(t.id));
3937
- const children = Object.values(detail?.children || {});
3938
- const closedCount = children.filter(
3939
- (c) => c.status === "closed"
3940
- ).length;
3941
- // 第二行详情(子任务进度): 换行 + 缩进对齐, 与标题行区分
3942
- const childInfo = children.length
3943
- ? `\n 子任务${children.length}个(已关${closedCount}), 关闭全部子任务后父任务自动关闭`
3944
- : "";
3945
- return {
3946
- name: `#${t.id} [${TASK_STATUS_LABELS[t.status] || t.status}] ${t.name} (${t.estimate || 0}h)${childInfo}`,
3947
- value: t,
3948
- // 已关闭的仅展示, 不可勾选
3949
- disabled: t.status === "closed" ? "已关闭" : false,
3950
- };
3951
- }),
4081
+ choices: [
4082
+ {
4083
+ name: `一键关闭(共${closableCount}个可选任务)`,
4084
+ value: SELECT_ALL,
4085
+ short: "一键关闭",
4086
+ },
4087
+ ...tasks.map((t) => {
4088
+ const detail = details.get(String(t.id));
4089
+ const children = Object.values(detail?.children || {});
4090
+ const closedCount = children.filter(
4091
+ (c) => c.status === "closed"
4092
+ ).length;
4093
+ // 子任务进度并入工时括号内: 已关数/总数, 无子任务仅工时
4094
+ const childInfo = children.length
4095
+ ? `${t.estimate || 0}h,子任务${closedCount}/${children.length}`
4096
+ : `${t.estimate || 0}h`;
4097
+ return {
4098
+ name: `#${t.id} [${TASK_STATUS_LABELS[t.status] || t.status}] ${t.name} (${childInfo})`,
4099
+ value: t,
4100
+ // 已关闭的仅展示, 不可勾选
4101
+ disabled: t.status === "closed" ? "已关闭" : false,
4102
+ };
4103
+ }),
4104
+ ],
3952
4105
  },
3953
4106
  ]);
3954
- if (!picked.length) {
4107
+ // 「一键关闭」是哨兵值, 剔除后剩下的即勾选任务(联动勾上时下方已全选, 数组完整)
4108
+ const targets = picked.filter((v) => v !== SELECT_ALL);
4109
+ if (!targets.length) {
3955
4110
  console.log(chalk.yellow("未勾选任务, 结束"));
3956
4111
  return;
3957
4112
  }
3958
4113
  // 写操作确认(关闭属敏感操作, 二次确认防误关)
3959
4114
  const { ok } = await inquirer.prompt([
3960
4115
  {
3961
- message: `确认处理勾选的 ${picked.length} 个任务? (未完成的先完成后关闭; 有子任务的逐个关闭子任务, 全关后父任务自动关闭)`,
4116
+ message: `确认处理勾选的 ${targets.length} 个任务? (未完成的先完成后关闭; 有子任务的逐个关闭子任务, 全关后父任务自动关闭)`,
3962
4117
  name: "ok",
3963
4118
  type: "confirm",
3964
4119
  default: true,
@@ -3990,8 +4145,8 @@ async function storyMain(action, cliOpts) {
3990
4145
  let failedCount = 0;
3991
4146
  let done = 0;
3992
4147
  const logs = [];
3993
- Loading$1.start(`处理任务 (0/${picked.length})...`);
3994
- for (const task of picked) {
4148
+ Loading$1.start(`处理任务 (0/${targets.length})...`);
4149
+ for (const task of targets) {
3995
4150
  try {
3996
4151
  // 详情缺失(预拉阶段失败的)现拉, 防父任务被误判成叶子任务直接 finish
3997
4152
  const detail =
@@ -4057,12 +4212,25 @@ async function storyMain(action, cliOpts) {
4057
4212
  }
4058
4213
  done += 1;
4059
4214
  // ora 的 text 是属性而非方法
4060
- Loading$1.text = `处理任务 (${done}/${picked.length})...`;
4215
+ Loading$1.text = `处理任务 (${done}/${targets.length})...`;
4061
4216
  }
4062
4217
  Loading$1.succeed(
4063
- `处理完成: 成功 ${picked.length - failedCount} 条 / 失败 ${failedCount} 条`
4218
+ `处理完成: 成功 ${targets.length - failedCount} 条 / 失败 ${failedCount} 条`
4064
4219
  );
4065
4220
  logs.forEach((line) => console.log(line));
4221
+ // 一轮结束询问是否继续(默认继续), 继续则回到任务勾选列表(任务状态重新拉取)
4222
+ const { goNext } = await inquirer.prompt([
4223
+ {
4224
+ message: "是否继续处理其他任务? (继续回到任务勾选列表)",
4225
+ name: "goNext",
4226
+ type: "confirm",
4227
+ default: true,
4228
+ },
4229
+ ]);
4230
+ if (goNext) {
4231
+ // 递归进入新一轮(会话失效仍由 session.request 兜底重登)
4232
+ return session.request(closeRound);
4233
+ }
4066
4234
  // 部分失败以非零退出码收尾, 供自动化流程感知重试
4067
4235
  if (failedCount) {
4068
4236
  console.log(chalk.red(`失败${failedCount}个, 详情见上方黄字提示`));
@@ -86,6 +86,68 @@ class CheckboxAllPrompt extends CheckboxPrompt {
86
86
  super.onInverseKey();
87
87
  this.syncAllChoice();
88
88
  }
89
+ }
90
+
91
+ /**
92
+ * 组联动式checkbox prompt:
93
+ * - 组开关选项标记 groupToggle: '<组名>',组成员选项标记 group: '<组名>'
94
+ * - 勾选/取消组开关:该组全部成员同步勾选/取消(可视化联动,而非静默取集合)
95
+ * - 切换组成员:组内全勾时开关自动点亮,任一取消则开关熄灭
96
+ * - 未标记 group 的选项为普通选项,不参与任何联动
97
+ */
98
+ class CheckboxGroupPrompt extends CheckboxPrompt {
99
+ getGroupToggles() {
100
+ return this.opt.choices.filter(
101
+ (choice) =>
102
+ choice.type !== "separator" && choice.groupToggle && !choice.disabled
103
+ );
104
+ }
105
+
106
+ getGroupMembers(groupId) {
107
+ return this.opt.choices.filter(
108
+ (choice) =>
109
+ choice.type !== "separator" &&
110
+ choice.group === groupId &&
111
+ !choice.disabled
112
+ );
113
+ }
114
+
115
+ // 根据各组勾选情况同步开关状态(组内全勾才亮)
116
+ syncGroupToggles() {
117
+ this.getGroupToggles().forEach((toggle) => {
118
+ const members = this.getGroupMembers(toggle.groupToggle);
119
+ if (members.length) {
120
+ toggle.checked = members.every((item) => item.checked);
121
+ }
122
+ });
123
+ }
124
+
125
+ // 覆写toggleChoice: 空格键和数字键选择都会经过这里,统一处理联动
126
+ toggleChoice(index) {
127
+ const choice = this.opt.choices.getChoice(index);
128
+ if (choice && choice.groupToggle) {
129
+ // 切换组开关:该组成员全部跟随
130
+ const checked = !choice.checked;
131
+ choice.checked = checked;
132
+ this.getGroupMembers(choice.groupToggle).forEach((item) => {
133
+ item.checked = checked;
134
+ });
135
+ } else {
136
+ super.toggleChoice(index);
137
+ this.syncGroupToggles();
138
+ }
139
+ }
140
+
141
+ // 按a全选/按i反选后,同步各组开关状态
142
+ onAllKey() {
143
+ super.onAllKey();
144
+ this.syncGroupToggles();
145
+ }
146
+
147
+ onInverseKey() {
148
+ super.onInverseKey();
149
+ this.syncGroupToggles();
150
+ }
89
151
  }
90
152
 
91
153
  function Loading$1() {
@@ -2268,15 +2330,46 @@ function fetchProjectStories(zentaosid, projectID) {
2268
2330
  // 拉项目任务列表(全部状态, 不含已删除; 供勾选时统计已建任务数 + 创建前后 diff 拿新任务ID)
2269
2331
  // 注1: 不用禅道 storyWebTasks/storyDevTasks 计数——软删除(回收站)的任务也计入, 数据不准(实测)
2270
2332
  // 注2: pager.recTotal 同样含回收站任务(返回数恒小于 recTotal 属正常), 翻页判定用 pageTotal
2271
- // 注3: 子任务不出现在任务列表(挂在父任务 children 下), 子任务统计须逐任务拉 task-view
2272
- function fetchProjectTasks(zentaosid, projectID) {
2273
- return zentaoRequest({
2333
+ // 注3: 子任务不出现在任务列表(挂在父任务 children 下), 且列表的 children 字段残缺(只带1个子任务, 实测),
2334
+ // 子任务统计须按 parent=-1 圈真父任务逐个拉 task-view
2335
+ // 注4: 任务超单页时按 pageTotal 循环翻页拉全, 某页失败降级黄字提示不阻塞(该页任务缺计数),
2336
+ // 翻页拉全后不再有"计数不全"提醒
2337
+ // 注5: 翻页路由格式(实测从任务列表页 pager 的 data-link-creator 提取):
2338
+ // project-task-<ID>-all-0--<recTotal>-<recPerPage>-<页码>;
2339
+ // 错误示范: project-task-<ID>-all-<页码> 禅道不认, 静默返回第1页 → 任务重复计数(踩坑实录)
2340
+ async function fetchProjectTasks(zentaosid, projectID) {
2341
+ const first = await zentaoRequest({
2274
2342
  route: `project-task-${projectID}-all`,
2275
2343
  zentaosid,
2276
- }).then((data) => ({
2277
- tasks: Object.values(data.tasks || {}),
2278
- pageTotal: Number(data.pager?.pageTotal || 1),
2279
- }));
2344
+ });
2345
+ const tasks = Object.values(first.tasks || {});
2346
+ const pager = first.pager || {};
2347
+ const pageTotal = Number(pager.pageTotal || 1);
2348
+ for (let page = 2; page <= pageTotal; page += 1) {
2349
+ // ora 的 text 是属性而非方法
2350
+ Loading.text = `拉取项目任务中(第${page}/${pageTotal}页)...`;
2351
+ try {
2352
+ const next = await zentaoRequest({
2353
+ route: `project-task-${projectID}-all-0--${pager.recTotal}-${pager.recPerPage}-${page}`,
2354
+ zentaosid,
2355
+ });
2356
+ tasks.push(...Object.values(next.tasks || {}));
2357
+ } catch (e) {
2358
+ // 会话失效上抛, 由 ensureZentaoSession 整体重登重试
2359
+ if (e?.isAuthError) throw e;
2360
+ console.log(
2361
+ chalk.yellow(`第${page}页任务拉取失败, 该页任务缺计数: ${e.message}`)
2362
+ );
2363
+ }
2364
+ }
2365
+ // 按任务ID去重兜底: 翻页路由若再被禅道静默忽略会返回重复数据, 重复计数直接污染前端/后端父任务数
2366
+ const seen = new Set();
2367
+ const unique = tasks.filter((t) => {
2368
+ if (seen.has(t.id)) return false;
2369
+ seen.add(t.id);
2370
+ return true;
2371
+ });
2372
+ return { tasks: unique, pageTotal };
2280
2373
  }
2281
2374
 
2282
2375
  // 拉单个任务详情(含children子任务映射, subtask命令与子任务统计用)
@@ -3191,7 +3284,8 @@ async function storyMain(action, cliOpts) {
3191
3284
  validateChoice(cliOpts.type, ["front", "back"], "--type");
3192
3285
  }
3193
3286
  const session = await ensureZentaoSession(cliOpts);
3194
- await session.request(async (zentaosid) => {
3287
+ // 具名回调: 交互模式一轮结束后可递归重入, 回到需求勾选列表(计数刷新); 一键模式单轮结束
3288
+ await session.request(async function taskRound(zentaosid) {
3195
3289
  // 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
3196
3290
  Loading.start("校验项目中...");
3197
3291
  const projectMap = await fetchProjectMap(zentaosid);
@@ -3215,22 +3309,14 @@ async function storyMain(action, cliOpts) {
3215
3309
  }
3216
3310
  // 每条需求的前后端已建任务计数(勾选时提示防重复建)
3217
3311
  // 自行从项目任务列表统计(禅道 storyWebTasks/storyDevTasks 会把回收站任务也计入, 实测不准)
3218
- Loading.start("拉取项目任务中...");
3312
+ Loading.start("拉取项目任务中(第1页)...");
3219
3313
  const { tasks: projectTasks, pageTotal } = await fetchProjectTasks(
3220
3314
  zentaosid,
3221
3315
  projectID
3222
3316
  );
3223
3317
  Loading.succeed(
3224
- `拉取项目任务成功(${projectTasks.length}条, 需求计数用)`
3318
+ `拉取项目任务成功(${projectTasks.length}条/${pageTotal}页, 需求计数用)`
3225
3319
  );
3226
- // 任务超单页时计数可能不全(禅道翻页路由不稳定, 只提示不翻页)
3227
- if (pageTotal > 1) {
3228
- console.log(
3229
- chalk.yellow(
3230
- `项目任务超过单页(${pageTotal}页), 已建计数可能不全`
3231
- )
3232
- );
3233
- }
3234
3320
  // 前端任务判定: type=frontend 或 名称【前端】前缀(修复前的旧数据 type 误传 devel)
3235
3321
  const isFrontTask = (t) =>
3236
3322
  t.type === "frontend" || String(t.name || "").startsWith("【前端】");
@@ -3239,15 +3325,15 @@ async function storyMain(action, cliOpts) {
3239
3325
  const webCount = (s) => tasksOf(s).filter(isFrontTask).length;
3240
3326
  const devCount = (s) =>
3241
3327
  tasksOf(s).filter((t) => t.type === "devel" && !isFrontTask(t)).length;
3242
- const totalCount = (s) => tasksOf(s).length;
3243
- // 子任务统计: 子任务不出现在任务列表(挂在父任务children下), 逐个拉详情;
3244
- // 只拉 工时>8 的主任务(只有这些才可能拆过子任务), 并发限流控制请求量
3245
- // 注: parent 字段三态(实测): 0=独立任务 | -1=自己是父任务(有子任务) | >0=挂在别人下的子任务
3246
- // 候选排除子任务即可(parent>0), -1 的父任务必须纳入(它才有children可统计)
3328
+ // 子任务统计: 子任务不出现在任务列表(挂在父任务children下), 逐个拉详情, 并发限流控制请求量
3329
+ // 注1: parent 字段三态(实测): 0=独立任务(无子任务) | -1=自己是父任务(有子任务) | >0=挂在别人下的子任务
3330
+ // 注2: 候选只拉 parent=-1 的真父任务即可——列表里 parent=0 的独立任务不会有 children(实测抽查),
3331
+ // 且列表 children 字段本身残缺(只带1个, 详情才全), 逐父任务拉 task-view 是唯一准确途径;
3332
+ // 此前按 estimate>8 圈候选会漏掉 ≤8h 却手动拆过子任务的父任务, 且请求量大(89个→15个, 6倍提速)
3247
3333
  const subCountMap = new Map(); // 任务ID -> 子任务数
3248
3334
  {
3249
3335
  const candidates = projectTasks.filter(
3250
- (t) => Number(t.estimate) > 8 && !(Number(t.parent) > 0)
3336
+ (t) => Number(t.parent) === -1
3251
3337
  );
3252
3338
  if (candidates.length) {
3253
3339
  Loading.start(`统计子任务 (0/${candidates.length})...`);
@@ -3280,6 +3366,8 @@ async function storyMain(action, cliOpts) {
3280
3366
  }
3281
3367
  // 先勾选需求: 一键(--type直通)自动取候选; 交互则列出项目全部需求
3282
3368
  let picked;
3369
+ // 交互勾了「一键前端」的标志(供后续跳过任务类型询问, 前端已隐含)
3370
+ let frontTodoPicked = false;
3283
3371
  if (cliOpts.type !== undefined) {
3284
3372
  const presetLabel = cliOpts.type === "front" ? "前端" : "后端";
3285
3373
  const hourOfPreset = (s) =>
@@ -3324,85 +3412,129 @@ async function storyMain(action, cliOpts) {
3324
3412
  ? Math.max(...frontTasks.map((t) => Number(t.estimate) || 0))
3325
3413
  : Number(s.webestimate);
3326
3414
  };
3415
+ // 后端子任务计数/基准工时: 与前端同构(父任务=devel类任务, 无父任务用需求estimate)
3416
+ const isBackTask = (t) => t.type === "devel" && !isFrontTask(t);
3417
+ const backSubsOf = (s) =>
3418
+ tasksOf(s)
3419
+ .filter(isBackTask)
3420
+ .reduce(
3421
+ (sum, t) => sum + (subCountMap.get(String(t.id)) || 0),
3422
+ 0
3423
+ );
3424
+ const backBaseHours = (s) => {
3425
+ const backTasks = tasksOf(s).filter(isBackTask);
3426
+ return backTasks.length
3427
+ ? Math.max(...backTasks.map((t) => Number(t.estimate) || 0))
3428
+ : Number(s.estimate);
3429
+ };
3430
+ // 一键前端: 批量选中"前端待办"(有前端工时且未建父任务, 或有父任务但子任务未满)
3431
+ const frontTodo = pj.stories.filter((s) => {
3432
+ if (!(Number(s.webestimate) > 0)) return false;
3433
+ if (webCount(s) === 0) return true;
3434
+ return (
3435
+ frontBaseHours(s) > 8 &&
3436
+ frontSubsOf(s) < Math.ceil(frontBaseHours(s) / 8)
3437
+ );
3438
+ });
3439
+ const FRONT_TODO = "__FRONT_TODO__";
3440
+ // 组联动式checkbox: 勾「一键前端」下方待办需求同步点亮/熄灭(可视化联动), 取消个别则开关熄灭
3441
+ inquirer.registerPrompt("checkbox-group", CheckboxGroupPrompt);
3327
3442
  ({ picked } = await inquirer.prompt([
3328
3443
  {
3329
- message: "请勾选要建任务的需求(空格选择, 回车确认)",
3444
+ message: "请勾选要建任务的需求(空格选择, 回车确认; 「一键前端」联动勾选前端待办)",
3330
3445
  name: "picked",
3331
- type: "checkbox",
3446
+ type: "checkbox-group",
3332
3447
  // 到列表边界停止, 不循环回第一条
3333
3448
  loop: false,
3334
- choices: [...pj.stories]
3335
- .sort((a, b) => {
3336
- // 待办优先级: 0=前端有工时且未建任务(子任务也必无, 全新建)
3337
- // 1=前端有任务但子任务未满(待补拆, 基准=父任务实际工时) 2=其余有前端工时 3=无前端工时
3338
- // 组内按需求ID从小到大
3339
- const rank = (s) => {
3340
- if (!(Number(s.webestimate) > 0)) return 3;
3341
- const base = frontBaseHours(s);
3342
- if (webCount(s) === 0) return 0;
3343
- return base > 8 && frontSubsOf(s) < Math.ceil(base / 8)
3344
- ? 1
3345
- : 2;
3346
- };
3347
- return (
3348
- rank(a) - rank(b) ||
3349
- Number(a.id) - Number(b.id)
3350
- );
3351
- })
3352
- .map((s, i) => {
3353
- // 子任务进度(仅前端>8h展示, 后端暂不展示): 已建/应建
3354
- // 应建 = ceil(基准工时/8), 基准=前端父任务实际工时(无父任务用webestimate)
3355
- // 未满已建数红色, 满则总数绿色; 任务计数后括号内联
3356
- const subProgress = (hours, count) => {
3357
- const total = Math.ceil(Number(hours) / 8);
3358
- return count >= total
3359
- ? `(${count}/${chalk.green(total)})`
3360
- : `(${chalk.red(count)}/${total})`;
3449
+ choices: [
3450
+ {
3451
+ // 无待办时置灰; groupToggle组开关, 联动所有group:front成员
3452
+ name: `一键前端(${frontTodo.length}条: 未建父任务/子任务未满)`,
3453
+ value: FRONT_TODO,
3454
+ short: "一键前端",
3455
+ groupToggle: "front",
3456
+ disabled: frontTodo.length ? false : "无前端待办",
3457
+ },
3458
+ ...[...pj.stories]
3459
+ .sort((a, b) => {
3460
+ // 待办优先级: 0=前端有工时且未建任务(子任务也必无, 全新建)
3461
+ // 1=前端有任务但子任务未满(待补拆, 基准=父任务实际工时) 2=其余有前端工时 3=无前端工时
3462
+ // 组内按需求ID从小到大
3463
+ const rank = (s) => {
3464
+ if (!(Number(s.webestimate) > 0)) return 3;
3465
+ const base = frontBaseHours(s);
3466
+ if (webCount(s) === 0) return 0;
3467
+ return base > 8 && frontSubsOf(s) < Math.ceil(base / 8)
3468
+ ? 1
3469
+ : 2;
3470
+ };
3471
+ return (
3472
+ rank(a) - rank(b) ||
3473
+ Number(a.id) - Number(b.id)
3474
+ );
3475
+ })
3476
+ .map((s, i) => {
3477
+ // 任务计数(前后端一排, " / "分隔): x个父任务 + 子任务进度(已建/应建)
3478
+ // 子任务进度仅基准工时>8h展示: 应建 = ceil(基准工时/8), 基准=该类父任务实际工时(无父任务用需求该类工时)
3479
+ // 未满应建数红色, 满则绿色; 工时与计数合并为一行(标题行内联), 无该类工时不输出该段
3480
+ const taskLine = (label, hours, count, baseHours, subs) => {
3481
+ let text = `${label}${hours}h,${count}个父任务`;
3482
+ if (baseHours > 8) {
3483
+ const total = Math.ceil(baseHours / 8);
3484
+ const progress =
3485
+ subs >= total
3486
+ ? `${subs}/${chalk.green(total)}`
3487
+ : `${subs}/${chalk.red(total)}`;
3488
+ text += `,${progress}子任务`;
3489
+ }
3490
+ return text;
3361
3491
  };
3362
- const frontSubs = frontSubsOf(s);
3363
- const base = frontBaseHours(s);
3364
- // 前端: 任务数(子任务进度); 后端: 任务数(暂不展示子任务)
3365
- const built = [
3492
+ const countParts = [
3366
3493
  Number(s.webestimate) > 0
3367
- ? `前端${webCount(s)}个${
3368
- base > 8 ? subProgress(base, frontSubs) : ""
3369
- }`
3494
+ ? taskLine("前端", s.webestimate, webCount(s), frontBaseHours(s), frontSubsOf(s))
3495
+ : "",
3496
+ Number(s.estimate) > 0
3497
+ ? taskLine("后端", s.estimate, devCount(s), backBaseHours(s), backSubsOf(s))
3370
3498
  : "",
3371
- Number(s.estimate) > 0 ? `后端${devCount(s)}个 任务` : "",
3372
- ]
3373
- .filter(Boolean)
3374
- .join("/");
3375
- // 父任务工时不足提示: 有前端父任务但工时 < 需求前端工时(黄字, 手建父任务漏填/填少)
3376
- const shortWarn =
3377
- webCount(s) > 0 && base < Number(s.webestimate)
3378
- ? `, ${chalk.yellow(`父任务${base}h<需求${s.webestimate}h, 不足!`)}`
3379
- : "";
3380
- // 第二行详情(已建/警告): 换行 + 缩进对齐, 与标题行区分
3381
- const detail = [built ? `已建${built}` : "", shortWarn.slice(2)]
3382
- .filter(Boolean)
3383
- .join(", ");
3499
+ ].filter(Boolean);
3500
+ // 父任务工时不足提示: 有前端父任务但工时 < 需求前端工时(黄字, 手建父任务漏填/填少), 追加同排末尾
3501
+ const frontBase = frontBaseHours(s);
3502
+ if (webCount(s) > 0 && frontBase < Number(s.webestimate)) {
3503
+ countParts.push(
3504
+ chalk.yellow(
3505
+ `父任务${frontBase}h<需求${s.webestimate}h, 不足!`
3506
+ )
3507
+ );
3508
+ }
3384
3509
  return {
3385
- name: `${i + 1}. #${s.id} ${s.title} (前端${
3386
- Number(s.webestimate) > 0 ? `${s.webestimate}h` : "-"
3387
- } / 后端${
3388
- Number(s.estimate) > 0 ? `${s.estimate}h` : "-"
3389
- })${detail ? `\n ${detail}` : ""}`,
3510
+ name: `${i + 1}. #${s.id} ${s.title} (${countParts.join(" / ")})`,
3390
3511
  value: s,
3391
- // 有工时但从未建过任何任务的需求默认勾选
3392
- checked: totalCount(s) === 0,
3512
+ // 前端待办成员: 受「一键前端」组开关联动勾选/取消
3513
+ ...(frontTodo.includes(s) ? { group: "front" } : {}),
3393
3514
  };
3394
3515
  }),
3516
+ ],
3395
3517
  },
3396
3518
  ]));
3519
+ // 勾了「一键前端」: 提交结果按实际勾选的需求算(联动已把待办项点亮, 哨兵值剔除), 类型固定前端
3520
+ if (picked.includes(FRONT_TODO)) {
3521
+ frontTodoPicked = true;
3522
+ picked = picked.filter((v) => v !== FRONT_TODO);
3523
+ console.log(
3524
+ chalk.blue(`一键前端: 已选中 ${picked.length} 条前端待办需求`)
3525
+ );
3526
+ }
3397
3527
  }
3398
3528
  if (!picked.length) {
3399
3529
  console.log(chalk.yellow("未勾选需求, 结束"));
3400
3530
  return;
3401
3531
  }
3402
- // 再选任务类型: --type 直通(一键场景) > 选完需求后交互询问
3532
+ // 再选任务类型: --type 直通(一键场景) > 一键前端固定前端 > 选完需求后交互询问
3403
3533
  let taskType;
3404
3534
  if (cliOpts.type !== undefined) {
3405
3535
  taskType = cliOpts.type;
3536
+ } else if (frontTodoPicked) {
3537
+ taskType = "front"; // 一键前端已隐含类型
3406
3538
  } else {
3407
3539
  ({ taskType } = await inquirer.prompt([
3408
3540
  {
@@ -3445,7 +3577,7 @@ async function storyMain(action, cliOpts) {
3445
3577
  : t.type === "devel" && !isFrontTask(t)
3446
3578
  );
3447
3579
  // 处理模式(选择即确认): --split-only > --split > 交互列表 > 一键默认
3448
- // parent=仅创建父任务 | parent-split=创建父任务+拆分子任务 | split=仅拆分(无父任务自动先建)
3580
+ // parent=仅创建父任务 | parent-split=拆分子任务(无父任务自动先建, ≤8h仅建父任务不拆) | split=仅拆分(--split-only专用, >8h才建父任务)
3449
3581
  let taskMode;
3450
3582
  if (cliOpts.splitOnly) {
3451
3583
  taskMode = "split";
@@ -3466,6 +3598,7 @@ async function storyMain(action, cliOpts) {
3466
3598
  },
3467
3599
  ]));
3468
3600
  } else {
3601
+ // 交互合并原"创建父任务+拆分子任务"与"仅拆分"为一项: 无父任务自动先建(≤8h仅建父任务), >8h拆分, 已有父任务复用
3469
3602
  ({ taskMode } = await inquirer.prompt([
3470
3603
  {
3471
3604
  message: `将处理 ${picked.length} 条【${typeLabel}】任务(截止 ${pj.project.end}), 请选择模式`,
@@ -3473,9 +3606,10 @@ async function storyMain(action, cliOpts) {
3473
3606
  type: "list",
3474
3607
  choices: [
3475
3608
  { name: "1. 创建父任务", value: "parent" },
3476
- { name: "2. 创建父任务+拆分子任务", value: "parent-split" },
3477
- { name: "3. 拆分子任务(无父任务自动先创建)", value: "split" },
3609
+ { name: "2. 创建子任务(无父任务自动先创建, ≤8h不拆)", value: "parent-split" },
3478
3610
  ],
3611
+ // 默认选中2(创建子任务)
3612
+ default: "parent-split",
3479
3613
  },
3480
3614
  ]));
3481
3615
  }
@@ -3632,7 +3766,22 @@ async function storyMain(action, cliOpts) {
3632
3766
  splitLogs.forEach((line) => console.log(line));
3633
3767
  }
3634
3768
  }
3635
- // 部分失败以非零退出码收尾, 供自动化流程感知重试
3769
+ // 交互模式: 一轮结束询问是否继续(默认继续), 继续则回到需求勾选列表(任务计数重新拉取)
3770
+ if (cliOpts.type === undefined) {
3771
+ const { goNext } = await inquirer.prompt([
3772
+ {
3773
+ message: "是否继续处理其他需求? (继续回到需求勾选列表)",
3774
+ name: "goNext",
3775
+ type: "confirm",
3776
+ default: true,
3777
+ },
3778
+ ]);
3779
+ if (goNext) {
3780
+ // 递归进入新一轮(会话失效仍由 session.request 兜底重登)
3781
+ return session.request(taskRound);
3782
+ }
3783
+ }
3784
+ // 部分失败以非零退出码收尾(一键模式, 或交互模式选择结束时), 供自动化流程感知重试
3636
3785
  if (failedStories.length) {
3637
3786
  console.log(
3638
3787
  chalk.red(
@@ -3787,7 +3936,8 @@ async function storyMain(action, cliOpts) {
3787
3936
  }
3788
3937
  const projectID = match[1];
3789
3938
  const session = await ensureZentaoSession(cliOpts);
3790
- await session.request(async (zentaosid) => {
3939
+ // 具名回调: 一轮关闭结束后可递归重入, 回到任务勾选列表(任务状态重新拉取)
3940
+ await session.request(async function closeRound(zentaosid) {
3791
3941
  // 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
3792
3942
  Loading.start("校验项目中...");
3793
3943
  const projectMap = await fetchProjectMap(zentaosid);
@@ -3858,14 +4008,7 @@ async function storyMain(action, cliOpts) {
3858
4008
  )
3859
4009
  );
3860
4010
  }
3861
- // 项目任务超单页时"由我完成"部分可能漏列(禅道翻页路由不稳定, 只提示不翻页)
3862
- if (projectAll.pageTotal > 1) {
3863
- console.log(
3864
- chalk.yellow(
3865
- `项目任务超过单页(${projectAll.pageTotal}页), 由我完成待关闭的任务可能漏列`
3866
- )
3867
- );
3868
- }
4011
+ // fetchProjectTasks 已翻页拉全, "由我完成"兜底不再漏列(单页失败时其黄字已提示)
3869
4012
  if (!tasks.length) {
3870
4013
  console.log(
3871
4014
  chalk.yellow("无指派给我的任务, 也无由我完成待关闭的任务, 结束")
@@ -3904,39 +4047,51 @@ async function storyMain(action, cliOpts) {
3904
4047
  Loading.succeed("拉取任务详情完成");
3905
4048
  }
3906
4049
  // 勾选要完成的任务(完成后自动关闭; 到列表边界停止, 不循环回第一条)
4050
+ // 顶部「一键关闭」与下方任务联动: 勾上=全选可选任务, 取消任一下方任务则自动熄灭(已关闭项不参与)
4051
+ inquirer.registerPrompt("checkbox-all", CheckboxAllPrompt);
4052
+ const closableCount = tasks.filter((t) => t.status !== "closed").length;
3907
4053
  const { picked } = await inquirer.prompt([
3908
4054
  {
3909
- message: "请勾选要处理的任务(空格选择, 回车确认; 未完成的先完成后关闭, 已完成的直接关闭, 已关闭的仅展示不可选)",
4055
+ message: "请勾选要处理的任务(空格选择, 回车确认; 未完成的先完成后关闭, 已完成的直接关闭, 已关闭的仅展示不可选, 关闭全部子任务后父任务自动关闭)",
3910
4056
  name: "picked",
3911
- type: "checkbox",
4057
+ type: "checkbox-all",
3912
4058
  loop: false,
3913
- choices: tasks.map((t) => {
3914
- const detail = details.get(String(t.id));
3915
- const children = Object.values(detail?.children || {});
3916
- const closedCount = children.filter(
3917
- (c) => c.status === "closed"
3918
- ).length;
3919
- // 第二行详情(子任务进度): 换行 + 缩进对齐, 与标题行区分
3920
- const childInfo = children.length
3921
- ? `\n 子任务${children.length}个(已关${closedCount}), 关闭全部子任务后父任务自动关闭`
3922
- : "";
3923
- return {
3924
- name: `#${t.id} [${TASK_STATUS_LABELS[t.status] || t.status}] ${t.name} (${t.estimate || 0}h)${childInfo}`,
3925
- value: t,
3926
- // 已关闭的仅展示, 不可勾选
3927
- disabled: t.status === "closed" ? "已关闭" : false,
3928
- };
3929
- }),
4059
+ choices: [
4060
+ {
4061
+ name: `一键关闭(共${closableCount}个可选任务)`,
4062
+ value: SELECT_ALL,
4063
+ short: "一键关闭",
4064
+ },
4065
+ ...tasks.map((t) => {
4066
+ const detail = details.get(String(t.id));
4067
+ const children = Object.values(detail?.children || {});
4068
+ const closedCount = children.filter(
4069
+ (c) => c.status === "closed"
4070
+ ).length;
4071
+ // 子任务进度并入工时括号内: 已关数/总数, 无子任务仅工时
4072
+ const childInfo = children.length
4073
+ ? `${t.estimate || 0}h,子任务${closedCount}/${children.length}`
4074
+ : `${t.estimate || 0}h`;
4075
+ return {
4076
+ name: `#${t.id} [${TASK_STATUS_LABELS[t.status] || t.status}] ${t.name} (${childInfo})`,
4077
+ value: t,
4078
+ // 已关闭的仅展示, 不可勾选
4079
+ disabled: t.status === "closed" ? "已关闭" : false,
4080
+ };
4081
+ }),
4082
+ ],
3930
4083
  },
3931
4084
  ]);
3932
- if (!picked.length) {
4085
+ // 「一键关闭」是哨兵值, 剔除后剩下的即勾选任务(联动勾上时下方已全选, 数组完整)
4086
+ const targets = picked.filter((v) => v !== SELECT_ALL);
4087
+ if (!targets.length) {
3933
4088
  console.log(chalk.yellow("未勾选任务, 结束"));
3934
4089
  return;
3935
4090
  }
3936
4091
  // 写操作确认(关闭属敏感操作, 二次确认防误关)
3937
4092
  const { ok } = await inquirer.prompt([
3938
4093
  {
3939
- message: `确认处理勾选的 ${picked.length} 个任务? (未完成的先完成后关闭; 有子任务的逐个关闭子任务, 全关后父任务自动关闭)`,
4094
+ message: `确认处理勾选的 ${targets.length} 个任务? (未完成的先完成后关闭; 有子任务的逐个关闭子任务, 全关后父任务自动关闭)`,
3940
4095
  name: "ok",
3941
4096
  type: "confirm",
3942
4097
  default: true,
@@ -3968,8 +4123,8 @@ async function storyMain(action, cliOpts) {
3968
4123
  let failedCount = 0;
3969
4124
  let done = 0;
3970
4125
  const logs = [];
3971
- Loading.start(`处理任务 (0/${picked.length})...`);
3972
- for (const task of picked) {
4126
+ Loading.start(`处理任务 (0/${targets.length})...`);
4127
+ for (const task of targets) {
3973
4128
  try {
3974
4129
  // 详情缺失(预拉阶段失败的)现拉, 防父任务被误判成叶子任务直接 finish
3975
4130
  const detail =
@@ -4035,12 +4190,25 @@ async function storyMain(action, cliOpts) {
4035
4190
  }
4036
4191
  done += 1;
4037
4192
  // ora 的 text 是属性而非方法
4038
- Loading.text = `处理任务 (${done}/${picked.length})...`;
4193
+ Loading.text = `处理任务 (${done}/${targets.length})...`;
4039
4194
  }
4040
4195
  Loading.succeed(
4041
- `处理完成: 成功 ${picked.length - failedCount} 条 / 失败 ${failedCount} 条`
4196
+ `处理完成: 成功 ${targets.length - failedCount} 条 / 失败 ${failedCount} 条`
4042
4197
  );
4043
4198
  logs.forEach((line) => console.log(line));
4199
+ // 一轮结束询问是否继续(默认继续), 继续则回到任务勾选列表(任务状态重新拉取)
4200
+ const { goNext } = await inquirer.prompt([
4201
+ {
4202
+ message: "是否继续处理其他任务? (继续回到任务勾选列表)",
4203
+ name: "goNext",
4204
+ type: "confirm",
4205
+ default: true,
4206
+ },
4207
+ ]);
4208
+ if (goNext) {
4209
+ // 递归进入新一轮(会话失效仍由 session.request 兜底重登)
4210
+ return session.request(closeRound);
4211
+ }
4044
4212
  // 部分失败以非零退出码收尾, 供自动化流程感知重试
4045
4213
  if (failedCount) {
4046
4214
  console.log(chalk.red(`失败${failedCount}个, 详情见上方黄字提示`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18-fe-automator-beta",
3
- "version": "2.1.9",
3
+ "version": "2.1.10",
4
4
  "description": "i18-fe-automator-beta内测版本,只用于key: ''替换、检测中文规则等",
5
5
  "type": "module",
6
6
  "bin": {