i18-fe-automator-beta 2.1.2 → 2.1.4

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.
@@ -11,9 +11,9 @@ var CheckboxPrompt = require('inquirer/lib/prompts/checkbox.js');
11
11
  var md5$1 = require('js-md5');
12
12
  var CryptoJS = require('crypto-js');
13
13
  var ora = require('ora');
14
- var xlsx = require('node-xlsx');
15
14
  var url = require('url');
16
15
  var path = require('path');
16
+ var xlsx = require('node-xlsx');
17
17
  var crypto = require('crypto');
18
18
  var querystring = require('querystring');
19
19
  var uuid = require('uuid');
@@ -114,45 +114,169 @@ function Loading() {
114
114
  }
115
115
  var Loading$1 = new Loading().spinner;
116
116
 
117
- //加密
118
- function encrypt(word, keyStr) {
119
- keyStr = keyStr ? keyStr : "abcdefgabcdefg12";
120
- var key = CryptoJS.enc.Utf8.parse(keyStr); //Latin1 w8m31+Yy/Nw6thPsMpO5fg==
121
- var srcs = CryptoJS.enc.Utf8.parse(word);
122
- var encrypted = CryptoJS.AES.encrypt(srcs, key, {
123
- mode: CryptoJS.mode.ECB,
124
- padding: CryptoJS.pad.Pkcs7,
125
- });
126
- return encrypted.toString();
117
+ // 获取命令行所在的目录
118
+ function getRunCliPath({ directory = false, root = false } = {}) {
119
+ const __filename = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.js', document.baseURI).href)));
120
+ const __dirname = path.dirname(__filename);
121
+ if (root) {
122
+ return path.resolve(__dirname, "../");
123
+ }
124
+ return directory ? __dirname : __filename;
127
125
  }
128
126
 
129
- function login(data) {
130
- data.password = encrypt(md5$1(data.password));
131
- const env=data.env;
132
- Loading$1.start(`${env}: 登录中...`);
133
- return new Promise((resolve, reject) => {
134
- request(
135
- {
136
- url: `https://${
137
- env === "pro" ? "" : `${env}-`
138
- }hxjf.hongxinshop.com/sys/login`,
139
- method: "POST",
140
- json: true,
141
- body: data,
142
- },
143
- function (error, response, body) {
144
- const res = body || {};
145
- if (!error && res.code == 200) {
146
- Loading$1.succeed(`${env}: 登录成功`);
147
- const access_token = res.data.access_token;
148
- resolve(access_token);
149
- } else {
150
- Loading$1.fail(`${env}: 登录失败`);
151
- reject(error || res.msg || "未知错误");
152
- }
153
- }
154
- );
155
- });
127
+ // 获取package.json
128
+ function getPackageJson() {
129
+ const packageJson = JSON.parse(
130
+ fs.readFileSync(path.resolve(getRunCliPath({ root: true }), "../package.json"))
131
+ );
132
+ return packageJson;
133
+ }
134
+ // 判断是否存在该路径
135
+ function isExistPath(path) {
136
+ return fs.existsSync(path);
137
+ }
138
+
139
+ // 写入缓存
140
+ function writeCache(key, value) {
141
+ fs.writeFileSync(getRunCliPath({ root: true }) + "/.cache/" + key, value);
142
+ }
143
+ // 读取缓存
144
+ function readCache(key) {
145
+ return fs.readFileSync(getRunCliPath({ root: true }) + "/.cache/" + key);
146
+ }
147
+
148
+ //加密
149
+ function encrypt(word, keyStr) {
150
+ keyStr = keyStr ? keyStr : "abcdefgabcdefg12";
151
+ var key = CryptoJS.enc.Utf8.parse(keyStr); //Latin1 w8m31+Yy/Nw6thPsMpO5fg==
152
+ var srcs = CryptoJS.enc.Utf8.parse(word);
153
+ var encrypted = CryptoJS.AES.encrypt(srcs, key, {
154
+ mode: CryptoJS.mode.ECB,
155
+ padding: CryptoJS.pad.Pkcs7,
156
+ });
157
+ return encrypted.toString();
158
+ }
159
+
160
+ // 鉴权失败判定: HTTP 401 或响应体 code 401 (集中一处, 后端标识变化只需改这里)
161
+ function isAuthError(response, res) {
162
+ return response?.statusCode === 401 || res?.code === 401;
163
+ }
164
+
165
+ // 确保 .cache 目录存在(-sass/-lintc 硬编码账号不走凭据询问, 目录可能不存在)
166
+ function ensureCacheDir$2() {
167
+ const cacheDir = getRunCliPath({ root: true }) + "/.cache";
168
+ if (!isExistPath(cacheDir)) {
169
+ fs.mkdirSync(cacheDir);
170
+ }
171
+ }
172
+
173
+ // 读取整个 token 缓存 map(格式: { "env#username": token }), 文件不存在/损坏时返回 {}
174
+ function readTokenMap() {
175
+ const cacheFilePath = getRunCliPath({ root: true }) + "/.cache/token";
176
+ if (!isExistPath(cacheFilePath)) {
177
+ return {};
178
+ }
179
+ try {
180
+ return JSON.parse(readCache("token").toString("utf8")) || {};
181
+ } catch (error) {
182
+ return {};
183
+ }
184
+ }
185
+
186
+ // 读取指定环境+账号的缓存 token
187
+ function readTokenCache(env, username) {
188
+ return readTokenMap()[`${env}#${username}`] || null;
189
+ }
190
+
191
+ // 写入 token 缓存(按 env#username 维度隔离, 避免不同账号/环境串 token)
192
+ function writeTokenCache(env, username, token) {
193
+ ensureCacheDir$2();
194
+ const map = readTokenMap();
195
+ map[`${env}#${username}`] = token;
196
+ writeCache("token", JSON.stringify(map));
197
+ }
198
+
199
+ // 清除指定环境+账号的缓存 token
200
+ function clearTokenCache(env, username) {
201
+ const cacheFilePath = getRunCliPath({ root: true }) + "/.cache/token";
202
+ if (!isExistPath(cacheFilePath)) {
203
+ return;
204
+ }
205
+ const map = readTokenMap();
206
+ delete map[`${env}#${username}`];
207
+ writeCache("token", JSON.stringify(map));
208
+ }
209
+
210
+ function login(data) {
211
+ data.password = encrypt(md5$1(data.password));
212
+ const env = data.env;
213
+ Loading$1.start(`${env}: 登录中...`);
214
+ return new Promise((resolve, reject) => {
215
+ request(
216
+ {
217
+ url: `https://${
218
+ env === "pro" ? "" : `${env}-`
219
+ }hxjf.hongxinshop.com/sys/login`,
220
+ method: "POST",
221
+ json: true,
222
+ body: data,
223
+ },
224
+ function (error, response, body) {
225
+ const res = body || {};
226
+ if (!error && res.code == 200) {
227
+ Loading$1.succeed(`${env}: 登录成功`);
228
+ const access_token = res.data.access_token;
229
+ // 登录成功后缓存 token, 后续执行复用免登录
230
+ writeTokenCache(env, data.username, access_token);
231
+ resolve(access_token);
232
+ } else {
233
+ Loading$1.fail(`${env}: 登录失败`);
234
+ reject(error || res.msg || "未知错误");
235
+ }
236
+ }
237
+ );
238
+ });
239
+ }
240
+
241
+ /**
242
+ * 创建登录态管理器: 优先复用缓存 token, 鉴权失败时自动重登并重试一次
243
+ * @param credentials { env, username, password }
244
+ * @returns {{ request(fn): Promise }} fn 接收当前 token; 若 fn reject 的错误带 isAuthError 标记, 自动重登后用新 token 重试
245
+ */
246
+ async function createAuth({ env, username, password }) {
247
+ let token = readTokenCache(env, username);
248
+ if (token) {
249
+ console.log(chalk.gray(`${env}: 复用缓存登录态`));
250
+ } else {
251
+ token = await login({ env, username, password }); // login 内部已写缓存
252
+ }
253
+ let relogging = null; // 并发重登去重(如 Promise.all 批量请求同时 401)
254
+ return {
255
+ async request(fn) {
256
+ try {
257
+ return await fn(token);
258
+ } catch (e) {
259
+ if (!e?.isAuthError) throw e;
260
+ if (!relogging) {
261
+ relogging = (async () => {
262
+ clearTokenCache(env, username);
263
+ console.log(
264
+ chalk.yellow(`${env}: 登录态已失效, 自动重新登录...`)
265
+ );
266
+ token = await login({ env, username, password });
267
+ })().catch((e) => {
268
+ // 重登失败复位, 同进程后续请求仍可再次触发重登
269
+ relogging = null;
270
+ throw e;
271
+ });
272
+ }
273
+ await relogging;
274
+ relogging = null;
275
+ // 重试不再捕获, 再失败直接抛出, 避免无限循环
276
+ return await fn(token);
277
+ }
278
+ },
279
+ };
156
280
  }
157
281
 
158
282
  function importExcel({ uploadFilePath, token, env }) {
@@ -184,6 +308,11 @@ function importExcel({ uploadFilePath, token, env }) {
184
308
  if (!error && res.code == 200) {
185
309
  Loading$1.succeed(`${env}: 导入文件成功`);
186
310
  resolve(true);
311
+ } else if (isAuthError(response, res)) {
312
+ Loading$1.fail(`${env}: 登录态已失效`);
313
+ reject(
314
+ Object.assign(new Error("登录态已失效"), { isAuthError: true })
315
+ );
187
316
  } else {
188
317
  Loading$1.fail(`${env}: 导入文件失败`);
189
318
  reject(
@@ -262,6 +391,11 @@ function uploadExcel({ data, token, env }) {
262
391
  if (!error && res.code == 200) {
263
392
  Loading$1.succeed(`${env}: 导入项目成功`);
264
393
  resolve(true);
394
+ } else if (isAuthError(response, res)) {
395
+ Loading$1.fail(`${env}: 登录态已失效`);
396
+ reject(
397
+ Object.assign(new Error("登录态已失效"), { isAuthError: true })
398
+ );
265
399
  } else {
266
400
  Loading$1.fail(`${env}: 导入项目失败`);
267
401
  reject(error || res.msg || res.message || "未知错误");
@@ -271,39 +405,8 @@ function uploadExcel({ data, token, env }) {
271
405
  });
272
406
  }
273
407
 
274
- // 获取命令行所在的目录
275
- function getRunCliPath({ directory = false, root = false } = {}) {
276
- const __filename = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.js', document.baseURI).href)));
277
- const __dirname = path.dirname(__filename);
278
- if (root) {
279
- return path.resolve(__dirname, "../");
280
- }
281
- return directory ? __dirname : __filename;
282
- }
283
-
284
- // 获取package.json
285
- function getPackageJson() {
286
- const packageJson = JSON.parse(
287
- fs.readFileSync(path.resolve(getRunCliPath({ root: true }), "../package.json"))
288
- );
289
- return packageJson;
290
- }
291
- // 判断是否存在该路径
292
- function isExistPath(path) {
293
- return fs.existsSync(path);
294
- }
295
-
296
- // 写入缓存
297
- function writeCache(key, value) {
298
- fs.writeFileSync(getRunCliPath({ root: true }) + "/.cache/" + key, value);
299
- }
300
- // 读取缓存
301
- function readCache(key) {
302
- return fs.readFileSync(getRunCliPath({ root: true }) + "/.cache/" + key);
303
- }
304
-
305
408
  // 确保 .cache 目录存在
306
- function ensureCacheDir() {
409
+ function ensureCacheDir$1() {
307
410
  const cacheDir = getRunCliPath({ root: true }) + "/.cache";
308
411
  if (!isExistPath(cacheDir)) {
309
412
  fs.mkdirSync(cacheDir);
@@ -363,7 +466,7 @@ async function resolveSecretQuestions({
363
466
  cacheKey,
364
467
  extraCache = {},
365
468
  }) {
366
- ensureCacheDir();
469
+ ensureCacheDir$1();
367
470
  const cacheSecret = readSecretCache(cacheKey);
368
471
  const resolved = {};
369
472
  const pendingQuestions = [];
@@ -403,8 +506,8 @@ const sleep = (time) =>
403
506
  setTimeout(resolve, time);
404
507
  });
405
508
  // 上传翻译包(支持 --env/--files/--username/--password/--cache 一键, 参数齐全时跳过确认/选择)
406
- async function upload$1(cliOpts = {}) {
407
- // 空字符串视为未传(与askOrUse语义一致); 兼容交互菜单的to-前缀值(如 to-mit,sit,uat)
509
+ async function upload$1(cliOpts = {}) {
510
+ // 空字符串视为未传(与askOrUse语义一致); 兼容交互菜单的to-前缀值(如 to-mit,sit,uat)
408
511
  const cliEnv = (cliOpts.env || undefined)?.replace(/^to-/, "");
409
512
  const cliFiles = cliOpts.files || undefined;
410
513
  const cliUsername = cliOpts.username || undefined;
@@ -636,10 +739,10 @@ async function upload$1(cliOpts = {}) {
636
739
  // 环境外层循环(每个环境只登录一次),文件内层循环批量上传
637
740
  const failList = [];
638
741
  for (const env of envList) {
639
- // 1.登录
640
- let token;
742
+ // 1.登录(优先复用缓存token, 失效自动重登)
743
+ let auth;
641
744
  try {
642
- token = await login({
745
+ auth = await createAuth({
643
746
  env,
644
747
  username,
645
748
  password,
@@ -655,20 +758,24 @@ async function upload$1(cliOpts = {}) {
655
758
  // 2.读取excel
656
759
  const projectList = await readExcel({ uploadFilePath });
657
760
  // 3.导入excel
658
- await importExcel({
659
- uploadFilePath,
660
- token,
661
- env,
662
- });
761
+ await auth.request((t) =>
762
+ importExcel({
763
+ uploadFilePath,
764
+ token: t,
765
+ env,
766
+ })
767
+ );
663
768
  await sleep(2000);
664
769
  // 4.上传
665
- await uploadExcel({
666
- data: {
667
- projectList,
668
- },
669
- token,
670
- env,
671
- });
770
+ await auth.request((t) =>
771
+ uploadExcel({
772
+ data: {
773
+ projectList,
774
+ },
775
+ token: t,
776
+ env,
777
+ })
778
+ );
672
779
  console.log(
673
780
  `🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀${env} ${excelFileName} 成功`
674
781
  );
@@ -1086,6 +1193,10 @@ function getApplicationList({ appName, env, token }) {
1086
1193
  };
1087
1194
  })
1088
1195
  );
1196
+ } else if (isAuthError(response, res)) {
1197
+ reject(
1198
+ Object.assign(new Error("登录态已失效"), { isAuthError: true })
1199
+ );
1089
1200
  } else {
1090
1201
  const errorData = error || JSON.stringify(res.data);
1091
1202
  reject(errorData);
@@ -1116,6 +1227,11 @@ function getApplicationConfig({ appId, env, token }) {
1116
1227
  if (!error && res.code == 200) {
1117
1228
  Loading$1.succeed(`获取应用配置成功`);
1118
1229
  resolve(res.data);
1230
+ } else if (isAuthError(response, res)) {
1231
+ Loading$1.fail(`登录态已失效`);
1232
+ reject(
1233
+ Object.assign(new Error("登录态已失效"), { isAuthError: true })
1234
+ );
1119
1235
  } else {
1120
1236
  Loading$1.fail(`获取应用配置失败`);
1121
1237
  const errorData = error || JSON.stringify(res.data);
@@ -1147,6 +1263,11 @@ function getApplicationButtonConfig({ appId, env, token }) {
1147
1263
  if (!error && res.code == 200) {
1148
1264
  Loading$1.succeed(`获取${env}环境按钮权限配置成功`);
1149
1265
  resolve(res.data);
1266
+ } else if (isAuthError(response, res)) {
1267
+ Loading$1.fail(`登录态已失效`);
1268
+ reject(
1269
+ Object.assign(new Error("登录态已失效"), { isAuthError: true })
1270
+ );
1150
1271
  } else {
1151
1272
  Loading$1.fail(`获取${env}环境按钮权限配置失败`);
1152
1273
  const errorData = error || JSON.stringify(res.data);
@@ -1176,6 +1297,10 @@ function deleteApplicationButtonConfig({ data, env, token }) {
1176
1297
  const res = body;
1177
1298
  if (!error && res.code == 200) {
1178
1299
  resolve(res.data);
1300
+ } else if (isAuthError(response, res)) {
1301
+ reject(
1302
+ Object.assign(new Error("登录态已失效"), { isAuthError: true })
1303
+ );
1179
1304
  } else {
1180
1305
  const errorData = error || JSON.stringify(res.data);
1181
1306
  reject(errorData);
@@ -1206,6 +1331,11 @@ function addApplicationButtonConfig({ data, env, token }) {
1206
1331
  if (!error && res.code == 200) {
1207
1332
  Loading$1.succeed(`${data.code}`);
1208
1333
  resolve(true);
1334
+ } else if (isAuthError(response, res)) {
1335
+ Loading$1.fail(`登录态已失效`);
1336
+ reject(
1337
+ Object.assign(new Error("登录态已失效"), { isAuthError: true })
1338
+ );
1209
1339
  } else {
1210
1340
  Loading$1.fail(`${data.code}`);
1211
1341
  const errorData = error || JSON.stringify(res.data);
@@ -1305,9 +1435,9 @@ async function syncSassFlow(cliOpts) {
1305
1435
  type: "list",
1306
1436
  choices: envList.filter((item) => item !== from),
1307
1437
  });
1308
- let token;
1438
+ let auth;
1309
1439
  try {
1310
- token = await login({
1440
+ auth = await createAuth({
1311
1441
  env: from,
1312
1442
  username: "superAdmin",
1313
1443
  password: "admin1",
@@ -1329,11 +1459,13 @@ async function syncSassFlow(cliOpts) {
1329
1459
  });
1330
1460
  let applicationList;
1331
1461
  try {
1332
- applicationList = await getApplicationList({
1333
- env: from,
1334
- token,
1335
- appName: inputAppName,
1336
- });
1462
+ applicationList = await auth.request((t) =>
1463
+ getApplicationList({
1464
+ env: from,
1465
+ token: t,
1466
+ appName: inputAppName,
1467
+ })
1468
+ );
1337
1469
  } catch (e) {
1338
1470
  console.log(chalk.red(`查询${from}环境应用列表失败: ${e.message || e}`));
1339
1471
  process.exit(1);
@@ -1371,11 +1503,13 @@ async function syncSassFlow(cliOpts) {
1371
1503
  const appId = applicationList.find((item) => item.name === appName).id;
1372
1504
  let fromButtonList;
1373
1505
  try {
1374
- fromButtonList = await getApplicationButtonConfig({
1375
- appId,
1376
- env: from,
1377
- token,
1378
- });
1506
+ fromButtonList = await auth.request((t) =>
1507
+ getApplicationButtonConfig({
1508
+ appId,
1509
+ env: from,
1510
+ token: t,
1511
+ })
1512
+ );
1379
1513
  } catch (e) {
1380
1514
  console.log(
1381
1515
  chalk.red(`获取${from}环境按钮权限配置失败: ${e.message || e}`)
@@ -1387,9 +1521,9 @@ async function syncSassFlow(cliOpts) {
1387
1521
  process.exit(1);
1388
1522
  }
1389
1523
  console.log(`开始同步到${to}🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀`);
1390
- let toToken;
1524
+ let toAuth;
1391
1525
  try {
1392
- toToken = await login({
1526
+ toAuth = await createAuth({
1393
1527
  env: to,
1394
1528
  username: "superAdmin",
1395
1529
  password: "admin1",
@@ -1400,11 +1534,13 @@ async function syncSassFlow(cliOpts) {
1400
1534
  }
1401
1535
  let toApplicationList;
1402
1536
  try {
1403
- toApplicationList = await getApplicationList({
1404
- env: to,
1405
- token: toToken,
1406
- appName,
1407
- });
1537
+ toApplicationList = await toAuth.request((t) =>
1538
+ getApplicationList({
1539
+ env: to,
1540
+ token: t,
1541
+ appName,
1542
+ })
1543
+ );
1408
1544
  } catch (e) {
1409
1545
  console.log(chalk.red(`查询${to}环境应用列表失败: ${e.message || e}`));
1410
1546
  process.exit(1);
@@ -1416,11 +1552,13 @@ async function syncSassFlow(cliOpts) {
1416
1552
  const toAppId = toApplicationList[0].id;
1417
1553
  let toButtonList;
1418
1554
  try {
1419
- toButtonList = await getApplicationButtonConfig({
1420
- appId: toAppId,
1421
- env: to,
1422
- token: toToken,
1423
- });
1555
+ toButtonList = await toAuth.request((t) =>
1556
+ getApplicationButtonConfig({
1557
+ appId: toAppId,
1558
+ env: to,
1559
+ token: t,
1560
+ })
1561
+ );
1424
1562
  } catch (e) {
1425
1563
  console.log(chalk.red(`获取${to}环境按钮权限配置失败: ${e.message || e}`));
1426
1564
  process.exit(1);
@@ -1428,14 +1566,16 @@ async function syncSassFlow(cliOpts) {
1428
1566
  try {
1429
1567
  if (toButtonList.length) {
1430
1568
  const deleteButtonPromiseList = toButtonList.map((item) => {
1431
- return deleteApplicationButtonConfig({
1432
- data: {
1433
- id: item.id,
1434
- ver: item.ver,
1435
- },
1436
- env: to,
1437
- token: toToken,
1438
- });
1569
+ return toAuth.request((t) =>
1570
+ deleteApplicationButtonConfig({
1571
+ data: {
1572
+ id: item.id,
1573
+ ver: item.ver,
1574
+ },
1575
+ env: to,
1576
+ token: t,
1577
+ })
1578
+ );
1439
1579
  });
1440
1580
  Loading$1.start(`删除${to}环境按钮权限配置中...`);
1441
1581
  await Promise.all(deleteButtonPromiseList);
@@ -1449,17 +1589,19 @@ async function syncSassFlow(cliOpts) {
1449
1589
  try {
1450
1590
  for (const item of fromButtonList) {
1451
1591
  const { code, name, visitConf } = item;
1452
- await addApplicationButtonConfig({
1453
- env: to,
1454
- token: toToken,
1455
- data: {
1456
- code1: code.split(":")[0],
1457
- code,
1458
- name,
1459
- visitConf,
1460
- ascriptionApp: toAppId,
1461
- },
1462
- });
1592
+ await toAuth.request((t) =>
1593
+ addApplicationButtonConfig({
1594
+ env: to,
1595
+ token: t,
1596
+ data: {
1597
+ code1: code.split(":")[0],
1598
+ code,
1599
+ name,
1600
+ visitConf,
1601
+ ascriptionApp: toAppId,
1602
+ },
1603
+ })
1604
+ );
1463
1605
  }
1464
1606
  console.log(chalk.green("同步成功"));
1465
1607
  } catch (e) {
@@ -1497,9 +1639,9 @@ async function addSassPermission(cliOpts = {}) {
1497
1639
  type: "list",
1498
1640
  choices: envList,
1499
1641
  });
1500
- let token;
1642
+ let auth;
1501
1643
  try {
1502
- token = await login({
1644
+ auth = await createAuth({
1503
1645
  env,
1504
1646
  username: "superAdmin",
1505
1647
  password: "admin1",
@@ -1521,11 +1663,13 @@ async function addSassPermission(cliOpts = {}) {
1521
1663
  });
1522
1664
  let applicationList;
1523
1665
  try {
1524
- applicationList = await getApplicationList({
1525
- env,
1526
- token,
1527
- appName: inputAppName,
1528
- });
1666
+ applicationList = await auth.request((t) =>
1667
+ getApplicationList({
1668
+ env,
1669
+ token: t,
1670
+ appName: inputAppName,
1671
+ })
1672
+ );
1529
1673
  } catch (e) {
1530
1674
  console.log(chalk.red(`查询${env}环境应用列表失败: ${e.message || e}`));
1531
1675
  process.exit(1);
@@ -1564,93 +1708,1923 @@ async function addSassPermission(cliOpts = {}) {
1564
1708
  appName = res.appName;
1565
1709
  }
1566
1710
  }
1567
- let appConfig;
1568
- try {
1569
- appConfig = await getApplicationConfig({ appId, env, token });
1570
- } catch (e) {
1571
- console.log(chalk.red(`获取应用配置失败: ${e.message || e}`));
1711
+ let appConfig;
1712
+ try {
1713
+ appConfig = await auth.request((t) =>
1714
+ getApplicationConfig({ appId, env, token: t })
1715
+ );
1716
+ } catch (e) {
1717
+ console.log(chalk.red(`获取应用配置失败: ${e.message || e}`));
1718
+ process.exit(1);
1719
+ }
1720
+ // CLI传入的权限名称/编码仅第一圈消费, 后续圈次走交互输入
1721
+ let cliName = cliPermissionName;
1722
+ let cliCode = cliPermissionCode;
1723
+ let goOn = true;
1724
+ while (goOn) {
1725
+ const permissionName = await askOrUse(cliName, {
1726
+ message: "请输入权限名称",
1727
+ name: "permissionName",
1728
+ type: "input",
1729
+ validate: function (value) {
1730
+ if (value) {
1731
+ return true;
1732
+ }
1733
+ return "请输入权限名称";
1734
+ },
1735
+ });
1736
+ const permissionCode = await askOrUse(cliCode, {
1737
+ message: "请输入权限编码",
1738
+ name: "permissionCode",
1739
+ type: "input",
1740
+ validate: function (value) {
1741
+ if (!value) {
1742
+ return "请输入权限编码";
1743
+ }
1744
+ if (value.includes(":")) {
1745
+ return "权限编码不允许包含 :";
1746
+ }
1747
+ return true;
1748
+ },
1749
+ });
1750
+ cliName = undefined;
1751
+ cliCode = undefined;
1752
+ const data = {
1753
+ code1: appConfig.code,
1754
+ name: permissionName,
1755
+ visitConf: permissionCode,
1756
+ ascriptionApp: appConfig.id,
1757
+ code: `${appConfig.code}:${permissionCode}`,
1758
+ };
1759
+ let confirmed = true;
1760
+ if (!oneClick) {
1761
+ const { confirmAdd } = await inquirer.prompt([
1762
+ {
1763
+ message: `确认新增按钮权限? ${chalk.cyan(
1764
+ `${data.name} (${data.code})`
1765
+ )}`,
1766
+ name: "confirmAdd",
1767
+ type: "confirm",
1768
+ default: true,
1769
+ },
1770
+ ]);
1771
+ confirmed = confirmAdd;
1772
+ }
1773
+ if (confirmed) {
1774
+ try {
1775
+ await auth.request((t) =>
1776
+ addApplicationButtonConfig({ data, env, token: t })
1777
+ );
1778
+ console.log(
1779
+ chalk.green(
1780
+ `新增按钮权限成功: ${appName} - ${data.name} (${data.code})`
1781
+ )
1782
+ );
1783
+ } catch (e) {
1784
+ console.log(chalk.red(`新增按钮权限失败: ${e.message || e}`));
1785
+ process.exit(1);
1786
+ }
1787
+ }
1788
+ if (oneClick) {
1789
+ goOn = false;
1790
+ } else {
1791
+ const { continueAdd } = await inquirer.prompt([
1792
+ {
1793
+ message: "是否继续为该应用添加按钮权限?",
1794
+ name: "continueAdd",
1795
+ type: "confirm",
1796
+ default: false,
1797
+ },
1798
+ ]);
1799
+ goOn = continueAdd;
1800
+ }
1801
+ }
1802
+ }
1803
+
1804
+ // 禅道基础地址/缓存键(联调不符只改这里)
1805
+ const ZENTAO_BASE = "https://zentao.hongxinshop.com/zentao";
1806
+ const CACHE_KEY = "zentao"; // .cache/zentao: { account, password, zentaosid, products: [{id, name}] }
1807
+
1808
+ // 需求列表展示与pull并发的上限
1809
+ const PLAN_STORY_TABLE_LIMIT = 50;
1810
+ const PULL_CONCURRENCY = 5;
1811
+
1812
+ // 确保 .cache 目录存在
1813
+ function ensureCacheDir() {
1814
+ const cacheDir = getRunCliPath({ root: true }) + "/.cache";
1815
+ if (!isExistPath(cacheDir)) {
1816
+ fs.mkdirSync(cacheDir);
1817
+ }
1818
+ }
1819
+
1820
+ // 读取禅道凭据/会话缓存(不存在/损坏返回 {})
1821
+ function readZentaoCache() {
1822
+ const cacheFilePath =
1823
+ getRunCliPath({ root: true }) + "/.cache/" + CACHE_KEY;
1824
+ if (!isExistPath(cacheFilePath)) {
1825
+ return {};
1826
+ }
1827
+ try {
1828
+ return JSON.parse(readCache(CACHE_KEY).toString("utf8")) || {};
1829
+ } catch (error) {
1830
+ return {};
1831
+ }
1832
+ }
1833
+
1834
+ // 写禅道缓存(合并写: resolveSecretQuestions 会整文件回写凭据字段, 非合并会抹掉 zentaosid)
1835
+ function writeZentaoCache(obj) {
1836
+ ensureCacheDir();
1837
+ writeCache(
1838
+ CACHE_KEY,
1839
+ JSON.stringify({ ...readZentaoCache(), ...obj })
1840
+ );
1841
+ }
1842
+
1843
+ /**
1844
+ * 原样移植自禅道 12.3.1 all.js, 勿改动逻辑(服务端密码强度校验用)
1845
+ */
1846
+ function computePasswordStrength(password) {
1847
+ if (password.length === 0) return 0;
1848
+ let strength = 0;
1849
+ const length = password.length;
1850
+ let distinct = "";
1851
+ const kinds = {};
1852
+ for (let i = 0; i < length; i++) {
1853
+ const code = password.charCodeAt(i);
1854
+ if (code >= 48 && code <= 57) kinds[2] = 2; // 数字
1855
+ else if (code >= 65 && code <= 90) kinds[1] = 2; // 大写字母
1856
+ else if (code >= 97 && code <= 122) kinds[0] = 1; // 小写字母
1857
+ else kinds[3] = 3; // 特殊字符
1858
+ if (distinct.indexOf(password[i]) === -1) distinct += password[i];
1859
+ }
1860
+ if (distinct.length > 4) strength += distinct.length - 4;
1861
+ let sum = 0;
1862
+ let count = 0;
1863
+ for (const key in kinds) {
1864
+ count += 1;
1865
+ sum += kinds[key];
1866
+ }
1867
+ strength += sum + 2 * (count - 1);
1868
+ if (length < 6 && strength >= 10) strength = 9;
1869
+ strength = strength > 29 ? 29 : strength;
1870
+ return Math.floor(strength / 10);
1871
+ }
1872
+
1873
+ /**
1874
+ * 禅道 .json 路由请求(响应信封: {status, data: "<再编码JSON字符串>", md5})
1875
+ * 会话过期判定(实测): 禅道返回 200 + JS 跳转页(非302), content-type 不可靠(正常响应也是 text/html),
1876
+ * 用 body 解析失败 / 含 user-login 判定
1877
+ */
1878
+ function zentaoRequest({ route, zentaosid, qs }) {
1879
+ return new Promise((resolve, reject) => {
1880
+ request(
1881
+ {
1882
+ url: `${ZENTAO_BASE}/${route}.json`,
1883
+ method: "GET",
1884
+ headers: {
1885
+ Cookie: `zentaosid=${zentaosid}`,
1886
+ // ajax 标识, 禅道据此返回 JSON
1887
+ "X-Requested-With": "XMLHttpRequest",
1888
+ },
1889
+ ...(qs ? { qs } : {}),
1890
+ },
1891
+ (error, response, body) => {
1892
+ if (error) return reject(error);
1893
+ const text = String(body).replace(/^\uFEFF/, "");
1894
+ if (/^\s*<html/i.test(text)) {
1895
+ // 实测过期返回 200 + JS跳转页(<html><meta...><script>self.location='/zentao/user-login-...')
1896
+ // 不用 includes("user-login"): 防需求正文含该字样误判
1897
+ return reject(
1898
+ Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
1899
+ );
1900
+ }
1901
+ let envelope;
1902
+ try {
1903
+ envelope = JSON.parse(text);
1904
+ } catch (e) {
1905
+ return reject(
1906
+ Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
1907
+ );
1908
+ }
1909
+ if (envelope.status !== "success") {
1910
+ return reject(
1911
+ new Error(
1912
+ envelope.message || `禅道接口返回失败: ${envelope.status}`
1913
+ )
1914
+ );
1915
+ }
1916
+ // 响应信封 data 是再编码的 JSON 字符串, 需二次 parse
1917
+ let data = envelope.data;
1918
+ if (typeof data === "string") {
1919
+ try {
1920
+ data = JSON.parse(data);
1921
+ } catch (e) {
1922
+ /* data 本身就是普通值 */
1923
+ }
1924
+ }
1925
+ resolve(data);
1926
+ }
1927
+ );
1928
+ });
1929
+ }
1930
+
1931
+ /**
1932
+ * 禅道 POST 请求(写操作, 实测 task-create 等创建类接口, 12.3.1):
1933
+ * - 响应信封是 {result: 'success'|'fail', message, locate}, 与 GET 的 {status, data} 不同
1934
+ * - message 校验失败时是对象(如 {estStarted: [『预计开始』不能为空。]}), 统一格式化为文本
1935
+ * - form 字段名必须对齐表单真实 name(如 assignedTo[]): 实测传多余字段(mailto/after/hiddenwin)
1936
+ * 或 desc 含 HTML 标签时, 会出现"返回保存成功但未落库"或空响应, 只传验证过的最小字段集
1937
+ */
1938
+ function zentaoPost({ route, zentaosid, form }) {
1939
+ return new Promise((resolve, reject) => {
1940
+ request(
1941
+ {
1942
+ url: `${ZENTAO_BASE}/${route}.json`,
1943
+ method: "POST",
1944
+ form,
1945
+ headers: {
1946
+ Cookie: `zentaosid=${zentaosid}`,
1947
+ // 不带此头返回 HTML 而非 JSON
1948
+ "X-Requested-With": "XMLHttpRequest",
1949
+ // 模拟页面表单提交来源(实测带上更接近浏览器行为)
1950
+ Referer: `${ZENTAO_BASE}/${route}.html`,
1951
+ },
1952
+ },
1953
+ (error, _response, body) => {
1954
+ if (error) return reject(error);
1955
+ const text = String(body).replace(/^\uFEFF/, "");
1956
+ if (/^\s*<html/i.test(text)) {
1957
+ return reject(
1958
+ Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
1959
+ );
1960
+ }
1961
+ let envelope;
1962
+ try {
1963
+ envelope = JSON.parse(text);
1964
+ } catch (e) {
1965
+ return reject(
1966
+ Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
1967
+ );
1968
+ }
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)}`));
1980
+ }
1981
+ );
1982
+ });
1983
+ }
1984
+
1985
+ /**
1986
+ * 禅道三步登录(实测协议, 12.3.1):
1987
+ * 1. GET 登录页建立会话(下发 zentaosid, 登录前后不变) + 提取 verifyRand(每会话不同)
1988
+ * 2. 计算 password = md5(md5(明文) + verifyRand)
1989
+ * 3. POST 七字段表单(带 X-Requested-With 才返回 JSON), 成功判定 result === 'success'
1990
+ */
1991
+ function zentaoLogin({ account, password }) {
1992
+ Loading$1.start("登录中...");
1993
+ // 第1步: GET 登录页, 提取 zentaosid + verifyRand
1994
+ const pre = new Promise((resolve, reject) => {
1995
+ request(
1996
+ { url: `${ZENTAO_BASE}/user-login.html`, method: "GET" },
1997
+ (error, response, body) => {
1998
+ if (error) return reject(error);
1999
+ const setCookies = response.headers["set-cookie"] || [];
2000
+ const sidMatch = setCookies
2001
+ .join(";")
2002
+ .match(/zentaosid=([^;]+)/);
2003
+ const randMatch = String(body).match(
2004
+ /name='verifyRand'[^>]*value='(\d+)'/
2005
+ );
2006
+ if (!sidMatch || !randMatch) {
2007
+ return reject(new Error("解析登录页失败(未找到 zentaosid/verifyRand)"));
2008
+ }
2009
+ resolve({ zentaosid: sidMatch[1], verifyRand: randMatch[1] });
2010
+ }
2011
+ );
2012
+ });
2013
+ // 第2/3步: 计算哈希并 POST(POST 须带会话 cookie, verifyRand 与会话绑定)
2014
+ return pre.then(async ({ zentaosid, verifyRand }) => {
2015
+ const result = await new Promise((resolve, reject) => {
2016
+ request(
2017
+ {
2018
+ url: `${ZENTAO_BASE}/user-login.html`,
2019
+ method: "POST",
2020
+ form: {
2021
+ account,
2022
+ password: md5$1(md5$1(password) + verifyRand),
2023
+ pww: password, // 服务端强度校验用, 原样上送
2024
+ passwordStrength: computePasswordStrength(password),
2025
+ referer: "",
2026
+ verifyRand,
2027
+ keepLogin: "0",
2028
+ },
2029
+ headers: {
2030
+ Cookie: `zentaosid=${zentaosid}`,
2031
+ // 不带此头返回 HTML 而非 JSON
2032
+ "X-Requested-With": "XMLHttpRequest",
2033
+ },
2034
+ },
2035
+ (error, _response, body) => {
2036
+ if (error) return reject(error);
2037
+ try {
2038
+ resolve(JSON.parse(String(body).replace(/^\uFEFF/, "")));
2039
+ } catch (e) {
2040
+ reject(new Error("登录响应解析失败"));
2041
+ }
2042
+ }
2043
+ );
2044
+ });
2045
+ if (result.result === "success") {
2046
+ Loading$1.succeed("登录成功");
2047
+ // zentaosid 在 GET 那步就下发且登录后不变, 成功判定看 result
2048
+ writeZentaoCache({ account, password, zentaosid });
2049
+ return zentaosid;
2050
+ }
2051
+ Loading$1.fail("登录失败");
2052
+ throw new Error(`登录失败: ${result.message || "请检查账号密码"}`);
2053
+ });
2054
+ }
2055
+
2056
+ // 凭据解析(CLI参数 > --cache缓存 > 交互), login 与无会话时的 plan/detail 共用
2057
+ function askZentaoCredentials(cliOpts) {
2058
+ return resolveSecretQuestions({
2059
+ questions: [
2060
+ {
2061
+ message: "请输入禅道账号",
2062
+ name: "account",
2063
+ type: "input",
2064
+ validate: (val) => Boolean(val) || "请输入禅道账号",
2065
+ },
2066
+ {
2067
+ message: "请输入禅道密码",
2068
+ name: "password",
2069
+ type: "password",
2070
+ mask: "*",
2071
+ validate: (val) => Boolean(val) || "请输入禅道密码",
2072
+ },
2073
+ ],
2074
+ cliValues: {
2075
+ account: cliOpts.username,
2076
+ password: cliOpts.password,
2077
+ },
2078
+ useCache: Boolean(cliOpts.cache),
2079
+ cacheKey: CACHE_KEY,
2080
+ }).then((secret) => {
2081
+ // resolveSecretQuestions 会整文件覆盖回写缓存(只剩凭据字段, 发生在登录之前),
2082
+ // 合并写补回 zentaosid 等字段, 防止登录失败时已缓存的有效会话被抹掉
2083
+ writeZentaoCache(secret);
2084
+ return secret;
2085
+ });
2086
+ }
2087
+
2088
+ /**
2089
+ * 会话管理(参考 createAuth): 优先缓存 zentaosid, isAuthError 时用凭据自动重登并重试一次
2090
+ * (重登即重走三步——旧会话已失效, 必须新 GET 拿新 verifyRand)
2091
+ */
2092
+ async function ensureZentaoSession(cliOpts) {
2093
+ const cache = readZentaoCache();
2094
+ // 重登凭据优先级: CLI 参数 > 缓存
2095
+ const credentials = {
2096
+ account: cliOpts.username || cache.account,
2097
+ password: cliOpts.password || cache.password,
2098
+ };
2099
+ let zentaosid = cache.zentaosid;
2100
+ if (!zentaosid) {
2101
+ const secret = await askZentaoCredentials(cliOpts);
2102
+ zentaosid = await zentaoLogin(secret);
2103
+ }
2104
+ let relogging = null; // 并发重登去重
2105
+ return {
2106
+ async request(fn) {
2107
+ try {
2108
+ return await fn(zentaosid);
2109
+ } catch (e) {
2110
+ if (!e?.isAuthError) throw e;
2111
+ if (!credentials.account || !credentials.password) {
2112
+ console.log(
2113
+ chalk.red(
2114
+ "禅道会话已失效且无缓存凭据, 请先执行: fe-it-beta story login"
2115
+ )
2116
+ );
2117
+ process.exit(1);
2118
+ }
2119
+ if (!relogging) {
2120
+ relogging = (async () => {
2121
+ console.log(chalk.yellow("禅道会话已失效, 自动重新登录..."));
2122
+ zentaosid = await zentaoLogin(credentials);
2123
+ })().catch((e) => {
2124
+ // 重登失败复位, 同进程后续请求仍可再次触发重登
2125
+ relogging = null;
2126
+ throw e;
2127
+ });
2128
+ }
2129
+ await relogging;
2130
+ relogging = null;
2131
+ // 重试不再捕获, 再失败直接抛出, 避免无限循环
2132
+ return await fn(zentaosid);
2133
+ }
2134
+ },
2135
+ };
2136
+ }
2137
+
2138
+ // 拉产品映射: product-all.json -> data.products = { 产品ID: 产品名 } (实测104个)
2139
+ function fetchProductMap(zentaosid) {
2140
+ return zentaoRequest({ route: "product-all", zentaosid }).then(
2141
+ (data) => data.products || {}
2142
+ );
2143
+ }
2144
+
2145
+ // 校验产品ID并返回 {id, name}, 同时存入缓存历史(最近使用的排最前, 去重)
2146
+ async function resolveProduct(zentaosid, productID) {
2147
+ productID = String(productID).trim();
2148
+ Loading$1.start("校验产品中...");
2149
+ const products = await fetchProductMap(zentaosid);
2150
+ const name = products[productID];
2151
+ if (!name) {
2152
+ Loading$1.fail("产品不存在");
2153
+ throw new Error(`产品ID ${productID} 不存在, 请检查后重试`);
2154
+ }
2155
+ Loading$1.succeed(`产品: #${productID} ${name}`);
2156
+ const history = readZentaoCache().products || [];
2157
+ const rest = history.filter((p) => p.id !== productID);
2158
+ writeZentaoCache({ products: [{ id: productID, name }, ...rest] });
2159
+ return { id: productID, name };
2160
+ }
2161
+
2162
+ // 产品选择(plan/pull共用): --product参数直通 > 历史缓存选择/手动输入
2163
+ async function selectProduct(zentaosid, cliOpts) {
2164
+ const cliProduct = cliOpts.product && String(cliOpts.product).trim();
2165
+ if (cliProduct) {
2166
+ return resolveProduct(zentaosid, cliProduct);
2167
+ }
2168
+ const history = readZentaoCache().products || [];
2169
+ if (history.length) {
2170
+ const { choice } = await inquirer.prompt([
2171
+ {
2172
+ message: "请选择产品",
2173
+ name: "choice",
2174
+ type: "rawlist",
2175
+ choices: [
2176
+ ...history.map((p) => ({
2177
+ name: `#${p.id} ${p.name}`,
2178
+ value: p.id,
2179
+ })),
2180
+ { name: "输入其他产品ID...", value: "__INPUT__" },
2181
+ ],
2182
+ },
2183
+ ]);
2184
+ if (choice !== "__INPUT__") {
2185
+ const cached = history.find((p) => p.id === choice);
2186
+ return cached; // 历史产品名称已缓存, 直接用
2187
+ }
2188
+ }
2189
+ const { id } = await inquirer.prompt([
2190
+ {
2191
+ message: "请输入产品ID",
2192
+ name: "id",
2193
+ type: "input",
2194
+ validate: (val) =>
2195
+ /^\d+$/.test(String(val).trim()) || "请输入数字产品ID",
2196
+ },
2197
+ ]);
2198
+ return resolveProduct(zentaosid, id);
2199
+ }
2200
+
2201
+ // 拉计划列表第1页(默认20条, begin倒序最新在前, 用户指定只取1页)
2202
+ function fetchPlanList(zentaosid, productID) {
2203
+ return zentaoRequest({
2204
+ route: `productplan-browse-${productID}`,
2205
+ zentaosid,
2206
+ }).then((data) => Object.values(data.plans || {}));
2207
+ }
2208
+
2209
+ // 拉计划详情: plan + planStories(全量无分页) + products(产品ID->名称映射)
2210
+ function fetchPlanDetail(zentaosid, planID) {
2211
+ return zentaoRequest({
2212
+ route: `productplan-view-${planID}`,
2213
+ zentaosid,
2214
+ }).then((data) => ({
2215
+ plan: data.plan,
2216
+ stories: Object.values(data.planStories || {}),
2217
+ products: data.products || {},
2218
+ }));
2219
+ }
2220
+
2221
+ // 拉需求详情: story(全字段, spec/verify 为HTML) + users(用户名映射)
2222
+ function fetchStoryDetail(zentaosid, storyID) {
2223
+ return zentaoRequest({ route: `story-view-${storyID}`, zentaosid }).then(
2224
+ (data) => ({
2225
+ story: data.story,
2226
+ users: data.users || {},
2227
+ })
2228
+ );
2229
+ }
2230
+
2231
+ // 拉全量项目映射: project-all.json -> data.projects = { 项目ID: 项目名 } (实测2884个, task入参校验用)
2232
+ function fetchProjectMap(zentaosid) {
2233
+ return zentaoRequest({ route: "project-all", zentaosid }).then(
2234
+ (data) => data.projects || {}
2235
+ );
2236
+ }
2237
+
2238
+ // 拉项目需求列表: project(项目信息) + stories(需求)
2239
+ function fetchProjectStories(zentaosid, projectID) {
2240
+ return zentaoRequest({
2241
+ route: `project-story-${projectID}`,
2242
+ zentaosid,
2243
+ }).then((data) => ({
2244
+ project: data.project,
2245
+ stories: Object.values(data.stories || {}),
2246
+ }));
2247
+ }
2248
+
2249
+ // 拉项目任务列表(全部状态, 不含已删除; 供勾选时统计已建任务数 + 创建前后 diff 拿新任务ID)
2250
+ // 注1: 不用禅道 storyWebTasks/storyDevTasks 计数——软删除(回收站)的任务也计入, 数据不准(实测)
2251
+ // 注2: pager.recTotal 同样含回收站任务(返回数恒小于 recTotal 属正常), 翻页判定用 pageTotal
2252
+ // 注3: 子任务不出现在任务列表(挂在父任务 children 下), 子任务统计须逐任务拉 task-view
2253
+ function fetchProjectTasks(zentaosid, projectID) {
2254
+ return zentaoRequest({
2255
+ route: `project-task-${projectID}-all`,
2256
+ zentaosid,
2257
+ }).then((data) => ({
2258
+ tasks: Object.values(data.tasks || {}),
2259
+ pageTotal: Number(data.pager?.pageTotal || 1),
2260
+ }));
2261
+ }
2262
+
2263
+ // 拉单个任务详情(含children子任务映射, subtask命令与子任务统计用)
2264
+ function fetchTaskDetail(zentaosid, taskID) {
2265
+ return zentaoRequest({ route: `task-view-${taskID}`, zentaosid }).then(
2266
+ (data) => data.task
2267
+ );
2268
+ }
2269
+
2270
+ /**
2271
+ * 在项目下为需求创建任务(实测协议, 12.3.1): POST task-create-{projectID}-{storyID}.json
2272
+ * - estStarted/deadline 必填(缺了返回 fail + 字段错误提示)
2273
+ * - 指派字段名是 assignedTo[](数组形式)
2274
+ * - desc 用纯文本(实测含 HTML 标签时会出现"返回保存成功但未落库")
2275
+ * - type 值(表单选项实测): frontend=前端开发, devel=后端开发
2276
+ * - 成功响应 {result:'success'} 不含新任务ID, 由调用方重拉任务列表 diff 获取
2277
+ */
2278
+ function createZentaoTask({ zentaosid, projectID, story, name, type, assignedTo, estimate, estStarted, deadline }) {
2279
+ return zentaoPost({
2280
+ route: `task-create-${projectID}-${story.id}`,
2281
+ zentaosid,
2282
+ form: {
2283
+ module: "0",
2284
+ "assignedTo[]": assignedTo,
2285
+ name,
2286
+ story: story.id,
2287
+ type,
2288
+ pri: String(story.pri || 3),
2289
+ estimate: String(estimate),
2290
+ // 纯文本描述: 附需求url便于任务页跳回
2291
+ desc: `需求: ${storyUrl(story.id)}`,
2292
+ estStarted,
2293
+ deadline,
2294
+ uid: String(Date.now()),
2295
+ },
2296
+ });
2297
+ }
2298
+
2299
+ // 拼任务url(创建成功后输出)
2300
+ function taskUrl(taskID) {
2301
+ return `${ZENTAO_BASE}/task-view-${taskID}.html`;
2302
+ }
2303
+
2304
+ /**
2305
+ * 子任务拆分方案(用户确认规则):
2306
+ * - 工时>8 才拆; 每个子任务 8h, 最后一个补余数(如 20h -> 8/8/4)
2307
+ * - 后端: 名称-1、名称-2...(序号从1递增)
2308
+ * - 前端: 前端开发 = ceil(N/2) 个排前(整8h), 联调 = floor(N/2) 个在后(余数落最后);
2309
+ * 同类>1个带序号(前端开发1..n), 单个不带(16h -> 名称-前端开发 / 名称-联调)
2310
+ * - 实测样例: 16h->1+1, 24h->2+1, 40h->3+2, 48h->3+3
2311
+ */
2312
+ function splitTaskPlan(name, hours, taskType) {
2313
+ const total = Number(hours);
2314
+ const count = Math.ceil(total / 8);
2315
+ const lastEstimate = String(total - 8 * (count - 1));
2316
+ if (taskType === "back") {
2317
+ return Array.from({ length: count }, (_, i) => ({
2318
+ name: `${name}-${i + 1}`,
2319
+ estimate: i === count - 1 ? lastEstimate : "8",
2320
+ }));
2321
+ }
2322
+ const devCount = Math.ceil(count / 2);
2323
+ const qaCount = count - devCount;
2324
+ const subs = [];
2325
+ for (let i = 0; i < devCount; i++) {
2326
+ subs.push({
2327
+ name: `${name}-前端开发${devCount > 1 ? i + 1 : ""}`,
2328
+ // 前端开发整8h(排前), 余数由最后一个联调兜
2329
+ estimate: "8",
2330
+ });
2331
+ }
2332
+ for (let i = 0; i < qaCount; i++) {
2333
+ subs.push({
2334
+ name: `${name}-联调${qaCount > 1 ? i + 1 : ""}`,
2335
+ estimate: i === qaCount - 1 ? lastEstimate : "8",
2336
+ });
2337
+ }
2338
+ return subs;
2339
+ }
2340
+
2341
+ /**
2342
+ * 批量创建子任务(实测表单结构, 12.3.1): POST task-batchCreate-{projectID}-{storyID}-0-{parentTaskID}.json
2343
+ * - 数组字段: module[i]/parent[i]/story[i]/name[i]/type[i]/assignedTo[i]/estimate[i]/estStarted[i]/deadline[i]/pri[i]
2344
+ * - 指派字段名是 assignedTo[i](非 assignedTo[][i], 与单任务创建的 assignedTo[] 不同)
2345
+ * - 只传验证过的最小字段集(对齐 task-create 的实测教训: 多余字段致空响应/假成功)
2346
+ */
2347
+ function createZentaoSubtasks({ zentaosid, projectID, story, parentTaskID, type, assignedTo, estStarted, deadline, subs }) {
2348
+ const form = {};
2349
+ subs.forEach((sub, i) => {
2350
+ form[`module[${i}]`] = "0";
2351
+ form[`parent[${i}]`] = String(parentTaskID);
2352
+ form[`story[${i}]`] = String(story.id);
2353
+ form[`name[${i}]`] = sub.name;
2354
+ form[`type[${i}]`] = type;
2355
+ form[`assignedTo[${i}]`] = assignedTo;
2356
+ form[`estimate[${i}]`] = String(sub.estimate);
2357
+ form[`estStarted[${i}]`] = estStarted;
2358
+ form[`deadline[${i}]`] = deadline;
2359
+ form[`pri[${i}]`] = String(story.pri || 3);
2360
+ });
2361
+ return zentaoPost({
2362
+ route: `task-batchCreate-${projectID}-${story.id}-0-${parentTaskID}`,
2363
+ zentaosid,
2364
+ form,
2365
+ });
2366
+ }
2367
+
2368
+ // 本地时区当天日期(YYYY-MM-DD, 任务estStarted用; toISOString是UTC会差8小时)
2369
+ function localToday() {
2370
+ const d = new Date();
2371
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
2372
+ }
2373
+
2374
+ // 拼需求url(输出给 story detail 用)
2375
+ function storyUrl(storyID) {
2376
+ return `${ZENTAO_BASE}/story-view-${storyID}.html`;
2377
+ }
2378
+
2379
+ // 禅道域名(从ZENTAO_BASE派生, 图片等静态资源挂根路径)
2380
+ const ZENTAO_ORIGIN = ZENTAO_BASE.replace(/\/zentao$/, "");
2381
+
2382
+ // 需求HTML里的相对路径转绝对url(如 /zentao/file-read-xxx.png)
2383
+ function absoluteZentaoUrl(src) {
2384
+ const s = String(src || "");
2385
+ if (/^https?:\/\//i.test(s)) return s;
2386
+ return `${ZENTAO_ORIGIN}${s.startsWith("/") ? "" : "/"}${s}`;
2387
+ }
2388
+
2389
+ // 表格块转行文本: 每个<tr>一行, 单元格间 " | " 分隔, 单元格内换行/段落折叠为单空格
2390
+ // (保住字段映射表的列对应关系; 不支持嵌套表格, 禅道需求里无此结构)
2391
+ function tableToText(tableHtml) {
2392
+ const rows = tableHtml.match(/<tr[\s\S]*?<\/tr>/gi) || [];
2393
+ return rows
2394
+ .map((row) =>
2395
+ (row.match(/<t[dh][^>]*>[\s\S]*?<\/t[dh]>/gi) || [])
2396
+ .map((cell) =>
2397
+ cell
2398
+ .replace(/<br\s*\/?>/gi, " ")
2399
+ .replace(/<[^>]+>/g, " ")
2400
+ .replace(/&nbsp;/gi, " ")
2401
+ .replace(/&lt;/gi, "<")
2402
+ .replace(/&gt;/gi, ">")
2403
+ .replace(/&quot;/gi, '"')
2404
+ .replace(/&amp;/gi, "&")
2405
+ .replace(/\s+/g, " ")
2406
+ .trim()
2407
+ )
2408
+ .join(" | ")
2409
+ )
2410
+ .filter(Boolean)
2411
+ .join("\n");
2412
+ }
2413
+
2414
+ // HTML转纯文本(spec/verify 为HTML, detail与pull共用)
2415
+ // imgHref: img标签src的转换函数(传了则img转markdown图片语法引用完整url——实测file-read无需登录态; 不传则丢弃img)
2416
+ function htmlToText(html, imgHref) {
2417
+ let text = String(html || "");
2418
+ if (imgHref) {
2419
+ // img转markdown图片(先于去标签处理, 防图片信息丢失)
2420
+ text = text.replace(
2421
+ /<img[^>]*\bsrc=['"]([^'"]+)['"][^>]*\/?>/gi,
2422
+ (_m, src) => `\n![图片](${imgHref(src)})\n`
2423
+ );
2424
+ } else {
2425
+ text = text.replace(/<img[^>]*>/gi, "");
2426
+ }
2427
+ // 表格块整块转行文本(先于通用去标签): 单元格内<p>/<br>折叠为空格, 防" | "落进换行中间
2428
+ text = text.replace(
2429
+ /<table[\s\S]*?<\/table>/gi,
2430
+ (m) => "\n" + tableToText(m) + "\n"
2431
+ );
2432
+ return text
2433
+ .replace(/<br\s*\/?>/gi, "\n")
2434
+ .replace(/<\/p>/gi, "\n\n")
2435
+ .replace(/<[^>]+>/g, "")
2436
+ .replace(/&nbsp;/gi, " ")
2437
+ .replace(/&lt;/gi, "<")
2438
+ .replace(/&gt;/gi, ">")
2439
+ .replace(/&quot;/gi, '"')
2440
+ .replace(/&amp;/gi, "&")
2441
+ .replace(/\n{3,}/g, "\n\n")
2442
+ .trim();
2443
+ }
2444
+
2445
+ // 需求涉及的项目名(供AI定位要改的代码仓), 跟随导出范围:
2446
+ // front=只前端pool截取(fe-xxx-vue->xxx) | back=只后端pool原名 | all=全部
2447
+ function storyProjectsOf(story, scope) {
2448
+ return parsePoolList(story.poollist)
2449
+ .map((pool) => {
2450
+ const frontProject = frontProjectOf(pool);
2451
+ if (scope === "front") return frontProject; // 非前端pool返回null被过滤
2452
+ if (scope === "back") return frontProject ? null : pool; // 后端pool原名
2453
+ return frontProject || pool;
2454
+ })
2455
+ .filter(Boolean);
2456
+ }
2457
+
2458
+ // 任务目录名: {storyID}-{title截断50 + 过滤文件系统非法字符(含首尾空格与点)}
2459
+ function taskDirOf(story) {
2460
+ const title = String(story.title || "")
2461
+ .slice(0, 50)
2462
+ .replace(/[\\/:*?"<>|]/g, "")
2463
+ .trim()
2464
+ .replace(/^[.\s]+|[.\s]+$/g, "");
2465
+ return `${story.id}-${title}`;
2466
+ }
2467
+
2468
+ // 需求目录名: .zentao-<计划名>(多计划互不混目录); 计划名过滤文件系统非法字符, 空则退回计划id
2469
+ function zentaoDirNameOf(plan) {
2470
+ const name = String((plan && plan.title) || "")
2471
+ .slice(0, 50)
2472
+ .replace(/[\\/:*?"<>|]/g, "")
2473
+ .trim()
2474
+ .replace(/^[.\s]+|[.\s]+$/g, "");
2475
+ return `.zentao-${name || (plan && plan.id) || "untitled"}`;
2476
+ }
2477
+
2478
+ // 下载禅道静态资源(图片), encoding:null 取Buffer; 带会话Cookie(实测file-read无需登录态, 带上更稳)
2479
+ function fetchZentaoImage(url, zentaosid) {
2480
+ return new Promise((resolve, reject) => {
2481
+ request(
2482
+ {
2483
+ url,
2484
+ method: "GET",
2485
+ encoding: null,
2486
+ headers: { Cookie: `zentaosid=${zentaosid}` },
2487
+ },
2488
+ (error, response, body) => {
2489
+ if (error) return reject(error);
2490
+ if (response.statusCode !== 200) {
2491
+ return reject(new Error(`HTTP ${response.statusCode}`));
2492
+ }
2493
+ resolve(body);
2494
+ }
2495
+ );
2496
+ });
2497
+ }
2498
+
2499
+ // 提取HTML里全部img的src(与htmlToText的img处理用同一正则)
2500
+ function extractImgSrcs(html) {
2501
+ const srcs = [];
2502
+ String(html || "").replace(
2503
+ /<img[^>]*\bsrc=['"]([^'"]+)['"][^>]*\/?>/gi,
2504
+ (_m, src) => {
2505
+ srcs.push(src);
2506
+ return _m;
2507
+ }
2508
+ );
2509
+ return srcs;
2510
+ }
2511
+
2512
+ // 图片本地文件名: 取url的basename, 去query, 过滤路径穿越与文件系统非法字符
2513
+ function imageFileNameOf(src) {
2514
+ const base =
2515
+ String(src).split("?")[0].split("/").pop().replace(/[\\/:*?"<>|]/g, "_");
2516
+ return base && !/^\.+$/.test(base) ? base : "image";
2517
+ }
2518
+
2519
+ /**
2520
+ * 需求图片本地化: 提取 spec/verify 的 img src, 并发限流(复用PULL_CONCURRENCY worker池)
2521
+ * 下载到 <zentaoDir>/tasks/<taskDir>/images/(同名taskDir已由调用方先删除, 全量重下),
2522
+ * 返回 Map<storyID, Map<src, 'images/<文件名>'>>(下载失败的src不进映射, task.md引用降级远程url)
2523
+ */
2524
+ async function downloadStoryImages({ stories, storyDetails, zentaosid, zentaoDir }) {
2525
+ const tasksRoot = path.join(zentaoDir, "tasks");
2526
+ // 平铺任务(与拉详情同模式的并发限流): {story, src}
2527
+ const jobs = [];
2528
+ for (const story of stories) {
2529
+ const detail = storyDetails.get(String(story.id));
2530
+ if (!detail) continue;
2531
+ const srcs = extractImgSrcs(detail.story.spec).concat(
2532
+ extractImgSrcs(detail.story.verify)
2533
+ );
2534
+ for (const src of srcs) jobs.push({ story, src });
2535
+ }
2536
+ const result = new Map();
2537
+ const setMapping = (storyID, src, ref) => {
2538
+ let mapping = result.get(String(storyID));
2539
+ if (!mapping) {
2540
+ mapping = new Map();
2541
+ result.set(String(storyID), mapping);
2542
+ }
2543
+ mapping.set(src, ref);
2544
+ };
2545
+ let index = 0;
2546
+ const worker = async () => {
2547
+ while (index < jobs.length) {
2548
+ const { story, src } = jobs[index];
2549
+ index += 1;
2550
+ const fileName = imageFileNameOf(src);
2551
+ const target = path.join(tasksRoot, taskDirOf(story), "images", fileName);
2552
+ try {
2553
+ const buf = await fetchZentaoImage(absoluteZentaoUrl(src), zentaosid);
2554
+ fs.mkdirSync(path.dirname(target), { recursive: true });
2555
+ fs.writeFileSync(target, buf);
2556
+ setMapping(story.id, src, `images/${fileName}`);
2557
+ } catch (e) {
2558
+ console.log(
2559
+ chalk.yellow(`需求 ${story.id} 图片下载失败(${src}): ${e.message}, 该图保留远程链接`)
2560
+ );
2561
+ }
2562
+ }
2563
+ };
2564
+ await Promise.all(
2565
+ Array.from({ length: PULL_CONCURRENCY }, () => worker())
2566
+ );
2567
+ return result;
2568
+ }
2569
+
2570
+ // 解析poollist为pool数组(实测为逗号分隔字符串, 值可能含制表符)
2571
+ function parsePoolList(poollist) {
2572
+ return String(poollist || "")
2573
+ .split(/[,\t\n]/)
2574
+ .map((p) => p.trim())
2575
+ .filter(Boolean);
2576
+ }
2577
+
2578
+ // 前端pool转项目名: fe-cost-order-pc-vue -> cost-order-pc (前端pool均为fe-开头-vue结尾)
2579
+ function frontProjectOf(pool) {
2580
+ if (/^fe-.+-vue$/.test(pool)) {
2581
+ return pool.replace(/^fe-/, "").replace(/-vue$/, "");
2582
+ }
2583
+ return null;
2584
+ }
2585
+
2586
+ // 汇总需求的pool项目名(按类型): 前端=fe-xxx-vue截取xxx, 后端=非fe-开头pool原名不截取
2587
+ // type: 'front'取有前端工时需求的前端pool | 'back'取有后端工时需求的后端pool
2588
+ function collectPoolProjects(stories, type) {
2589
+ const projects = new Set();
2590
+ stories.forEach((s) => {
2591
+ const isFront = Number(s.webestimate) > 0;
2592
+ const isBack = Number(s.estimate) > 0;
2593
+ if (type === "front" && !isFront) return;
2594
+ if (type === "back" && !isBack) return;
2595
+ parsePoolList(s.poollist).forEach((pool) => {
2596
+ if (type === "front") {
2597
+ const project = frontProjectOf(pool);
2598
+ if (project) projects.add(project);
2599
+ } else if (!/^fe-/.test(pool)) {
2600
+ projects.add(pool); // 后端pool: 原名不截取
2601
+ }
2602
+ });
2603
+ });
2604
+ return [...projects];
2605
+ }
2606
+
2607
+ // 生成vscode/trae多项目工作区文件(.code-workspace为二者通用格式)
2608
+ // 项目路径默认按"各项目平级clone"假设取 ../<项目名>(相对需求目录.zentao-<计划名>/), 可打开后自行调整
2609
+ function writeCodeWorkspace(zentaoDir, planTitle, frontProjects, backProjects) {
2610
+ const zentaoDirName = path.basename(zentaoDir);
2611
+ const folders = [
2612
+ // 第一项为需求目录.zentao-<计划名>(含tasks下的manifest与task.md, 相对workspace文件位置即自身)
2613
+ { name: zentaoDirName, path: "." },
2614
+ ];
2615
+ frontProjects.forEach((name) =>
2616
+ folders.push({ name, path: `../${name}` })
2617
+ );
2618
+ backProjects.forEach((name) =>
2619
+ folders.push({ name, path: `../${name}` })
2620
+ );
2621
+ // 计划title含 / 等非法字符, 过滤后作文件名
2622
+ const fileName =
2623
+ String(planTitle || "")
2624
+ .replace(/[\\/:*?"<>|]/g, "")
2625
+ .trim() + ".code-workspace";
2626
+ const wsPath = path.join(zentaoDir, fileName);
2627
+ fs.writeFileSync(
2628
+ wsPath,
2629
+ // terminal.integrated.cwd 相对第一个 folder(需求目录) 解析: ".." 即调度中心目录, 打开工作区直接 claude 启动主会话
2630
+ // zentao.projectRoot 记录 CLI 运行目录(=需求目录父目录=调度中心), 主会话组装 projectPaths 直接取用, 不依赖启动目录推断
2631
+ // zentao.dir 记录需求目录名(.zentao-<计划名>), 主会话组装 workflow 的 zentaoDir 传参直接取用
2632
+ JSON.stringify(
2633
+ {
2634
+ folders,
2635
+ settings: {
2636
+ "terminal.integrated.cwd": "..",
2637
+ "zentao.projectRoot": process.cwd().replace(/\\/g, "/"),
2638
+ "zentao.dir": zentaoDirName,
2639
+ },
2640
+ },
2641
+ null,
2642
+ 2
2643
+ ),
2644
+ "utf8"
2645
+ );
2646
+ console.log(chalk.green(`工作区: ${wsPath} (vscode/trae可直接打开)`));
2647
+ const parts = [];
2648
+ if (frontProjects.length) parts.push(`前端: ${frontProjects.join(", ")}`);
2649
+ if (backProjects.length) parts.push(`后端: ${backProjects.join(", ")}`);
2650
+ if (parts.length) {
2651
+ console.log(chalk.green(`涉及项目 ${parts.join(" | ")} (路径按平级目录假设, 可自行调整)`));
2652
+ }
2653
+ }
2654
+
2655
+ /**
2656
+ * plan --export 产出需求目录 .zentao-<计划名>/(产出契约见 .trae/documents/story-command-zentao.md 第4节):
2657
+ * tasks/manifest.json(对象结构: 顶部projects汇总全部涉及项目 + stories数组合并去重, 同storyID覆盖title/taskDir/projects, 按storyID数值排序)
2658
+ * + 每条需求 tasks/<taskDir>/task.md(同名taskDir已由 exportPlanStories 先删除, 此处全新写入)
2659
+ * plan.md 与 codex-*.md 是 workflow 的产物(重拉同名taskDir会连带删除, 确认关卡后不要再重导)
2660
+ */
2661
+ function writeZentaoTasks({ plan, scope, stories, storyDetails, products, failed, storyImages, zentaoDir }) {
2662
+ const tasksDir = path.join(zentaoDir, "tasks");
2663
+ fs.mkdirSync(tasksDir, { recursive: true });
2664
+
2665
+ // manifest 合并去重(位于 tasks/ 下, 是 tasks 目录的需求索引)
2666
+ // 结构: { projects: [全部需求涉及项目的汇总], stories: [...] }; 兼容读取旧版纯数组格式(迁移为stories, 旧条目无projects字段)
2667
+ const manifestPath = path.join(tasksDir, "manifest.json");
2668
+ let manifestStories = [];
2669
+ if (isExistPath(manifestPath)) {
2670
+ try {
2671
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
2672
+ manifestStories = Array.isArray(parsed)
2673
+ ? parsed
2674
+ : (parsed && parsed.stories) || [];
2675
+ } catch (error) {
2676
+ manifestStories = [];
2677
+ }
2678
+ }
2679
+ const manifestMap = new Map(
2680
+ manifestStories.map((item) => [String(item.storyID), item])
2681
+ );
2682
+
2683
+ let added = 0;
2684
+ stories.forEach((story) => {
2685
+ // 拉取失败的条目跳过: 不写task.md(防"拉取失败"伪装成"禅道没填"产出空壳), 也不进manifest
2686
+ if (failed.has(String(story.id))) {
2687
+ return;
2688
+ }
2689
+ const taskDir = taskDirOf(story);
2690
+ manifestMap.set(String(story.id), {
2691
+ storyID: String(story.id),
2692
+ title: story.title,
2693
+ taskDir,
2694
+ // 涉及项目(跟随导出范围, 与task.md frontmatter的projects同源), AI据此定位要改的代码仓
2695
+ projects: storyProjectsOf(story, scope),
2696
+ });
2697
+ const taskPath = path.join(tasksDir, taskDir, "task.md");
2698
+ const detail = storyDetails.get(String(story.id));
2699
+ // 图片引用: 已本地化(images/相对路径, 与task.md同目录可直接Read)优先, 下载失败的降级远程url
2700
+ const imgMap =
2701
+ (storyImages && storyImages.get(String(story.id))) || new Map();
2702
+ const imgHref = (src) => imgMap.get(src) || absoluteZentaoUrl(src);
2703
+ const spec = detail ? htmlToText(detail.story.spec, imgHref) : "";
2704
+ const verify = detail ? htmlToText(detail.story.verify, imgHref) : "";
2705
+ const content = [
2706
+ "---",
2707
+ `storyID: "${story.id}"`,
2708
+ // JSON.stringify转义: title含半角冒号/引号等时防frontmatter(YAML)断裂
2709
+ `title: ${JSON.stringify(story.title)}`,
2710
+ `product: ${JSON.stringify(products[story.product] || story.product || "")}`,
2711
+ // plan 用选中计划title(story.planTitle 挂多计划时是拼接串)
2712
+ `plan: ${JSON.stringify(plan.title || "")}`,
2713
+ // 涉及项目(跟随导出范围: front只前端/back只后端/all全部), AI据此定位要改的代码仓
2714
+ `projects: ${JSON.stringify(storyProjectsOf(story, scope).join(", "))}`,
2715
+ // JSON.stringify统一转义: 防frontmatter(YAML)断裂(source含冒号, 与其他字段风格一致)
2716
+ `source: ${JSON.stringify(storyUrl(story.id))}`,
2717
+ "---",
2718
+ "",
2719
+ `# 需求:${story.title}`,
2720
+ "",
2721
+ "## 正文",
2722
+ "",
2723
+ spec || "(禅道未填写描述)",
2724
+ // 验收内容并入正文, 不设独立区块; 为空时不输出
2725
+ verify ? `\n验收标准:\n${verify}` : "",
2726
+ "",
2727
+ ].join("\n");
2728
+ fs.mkdirSync(path.join(tasksDir, taskDir), { recursive: true });
2729
+ fs.writeFileSync(taskPath, content, "utf8");
2730
+ added += 1;
2731
+ });
2732
+
2733
+ const merged = Array.from(manifestMap.values()).sort(
2734
+ (a, b) => Number(a.storyID) - Number(b.storyID)
2735
+ );
2736
+ // 顶部汇总全部需求涉及的项目(各条目projects并集去重排序; 旧格式迁移条目可能无该字段, 忽略)
2737
+ const allProjects = [
2738
+ ...new Set(merged.flatMap((item) => item.projects || [])),
2739
+ ].sort();
2740
+ fs.writeFileSync(
2741
+ manifestPath,
2742
+ JSON.stringify({ projects: allProjects, stories: merged }, null, 2),
2743
+ "utf8"
2744
+ );
2745
+ console.log(
2746
+ chalk.green(
2747
+ `产出完成: 写入 ${added} 条 / 失败 ${failed.size} 条`
2748
+ )
2749
+ );
2750
+ console.log(chalk.green(`输出目录: ${zentaoDir}`));
2751
+ // 部分失败以非零退出码收尾, 供自动化流程感知重试
2752
+ if (failed.size > 0) {
2753
+ console.log(
2754
+ chalk.red(`失败${failed.size}条(详情见上方黄字提示), 已跳过不产出, 可重跑 story plan --export 补齐`)
2755
+ );
2756
+ process.exit(1);
2757
+ }
2758
+ }
2759
+
2760
+ // 计划选取交互: 关键词过滤 > 命中1条自动选中 > rawlist选取
2761
+ async function selectPlan(plans, keyword) {
2762
+ if (keyword && String(keyword).trim()) {
2763
+ const kw = String(keyword).trim().toLowerCase();
2764
+ const filtered = plans.filter((p) =>
2765
+ String(p.title).toLowerCase().includes(kw)
2766
+ );
2767
+ if (!filtered.length) {
2768
+ console.log(chalk.red("未找到匹配的计划"));
2769
+ process.exit(1);
2770
+ }
2771
+ plans = filtered;
2772
+ }
2773
+ if (plans.length === 1) {
2774
+ return plans[0];
2775
+ }
2776
+ const { plan } = await inquirer.prompt([
2777
+ {
2778
+ message: "请选择计划",
2779
+ name: "plan",
2780
+ type: "rawlist",
2781
+ choices: plans.map((p) => ({
2782
+ name: `#${p.id} ${p.title} [${p.begin} ~ ${p.end}] (${
2783
+ p.stories ?? 0
2784
+ }条需求)`,
2785
+ value: p,
2786
+ })),
2787
+ },
2788
+ ]);
2789
+ return plan;
2790
+ }
2791
+
2792
+ // 拉取计划列表(会话由调用方保证有效), 按begin倒序展示(最新计划在前, id降序兜底)
2793
+ async function loadPlanList(zentaosid, productID) {
2794
+ Loading$1.start("拉取计划列表中...");
2795
+ const plans = await fetchPlanList(zentaosid, productID);
2796
+ Loading$1.succeed(`拉取计划列表成功(${plans.length}条)`);
2797
+ plans.sort(
2798
+ (a, b) =>
2799
+ String(b.begin || "").localeCompare(String(a.begin || "")) ||
2800
+ Number(b.id) - Number(a.id)
2801
+ );
2802
+ return plans;
2803
+ }
2804
+
2805
+ // plan动作: 选中计划后展示计划信息 + 关联需求列表(ID供detail用)
2806
+ async function showPlanDetail(zentaosid, selected) {
2807
+ Loading$1.start("拉取计划详情中...");
2808
+ const { plan, stories } = await fetchPlanDetail(zentaosid, selected.id);
2809
+ Loading$1.succeed("拉取计划详情成功");
2810
+ console.log(chalk.bold("\n计划信息"));
2811
+ console.table([
2812
+ {
2813
+ ID: plan.id,
2814
+ 名称: plan.title,
2815
+ 起止时间: `${plan.begin} ~ ${plan.end}`,
2816
+ 描述: htmlToText(plan.desc) || "-",
2817
+ },
2818
+ ]);
2819
+ // 工时统计(基于全量需求, 非仅截断展示的)
2820
+ const totalWeb = stories.reduce(
2821
+ (sum, s) => sum + (Number(s.webestimate) > 0 ? Number(s.webestimate) : 0),
2822
+ 0
2823
+ );
2824
+ const totalEst = stories.reduce(
2825
+ (sum, s) => sum + (Number(s.estimate) > 0 ? Number(s.estimate) : 0),
2826
+ 0
2827
+ );
2828
+ const frontCount = stories.filter((s) => Number(s.webestimate) > 0).length;
2829
+ const backCount = stories.filter((s) => Number(s.estimate) > 0).length;
2830
+ console.log(
2831
+ chalk.bold(
2832
+ `\n关联需求列表(共${stories.length}条, 前端${frontCount}条${totalWeb}h, 后端${backCount}条${totalEst}h)`
2833
+ )
2834
+ );
2835
+ const displayStories = stories.slice(0, PLAN_STORY_TABLE_LIMIT);
2836
+ if (stories.length > PLAN_STORY_TABLE_LIMIT) {
2837
+ console.log(
2838
+ chalk.yellow(
2839
+ `需求较多(${stories.length}条), 仅展示前${PLAN_STORY_TABLE_LIMIT}条, 完整列表可用 story plan --export 产出`
2840
+ )
2841
+ );
2842
+ }
2843
+ console.table(
2844
+ displayStories.map((s) => {
2845
+ const isFront = Number(s.webestimate) > 0;
2846
+ const hasFrontPool = parsePoolList(s.poollist).some((p) =>
2847
+ frontProjectOf(p)
2848
+ );
2849
+ return {
2850
+ ID: s.id,
2851
+ 标题: s.title,
2852
+ 前端工时: isFront ? s.webestimate : "-",
2853
+ 后端工时: Number(s.estimate) > 0 ? s.estimate : "-",
2854
+ poollist: s.poollist || "-",
2855
+ // 有前端工时但poollist无fe-开头的前端pool => 标记❌(console.table单元格不支持着色)
2856
+ 前端pool: isFront ? (hasFrontPool ? "✓" : "❌") : "-",
2857
+ };
2858
+ })
2859
+ );
2860
+ console.log(
2861
+ chalk.blue("提示: ID 供 fe-it-beta story detail <需求ID> 使用(也可传需求url)")
2862
+ );
2863
+ }
2864
+
2865
+ // 导出动作(plan收尾交互使用): 按范围过滤需求, 拉详情并产出 .zentao-<计划名>/ + 工作区
2866
+ // scope: front=有前端工时的需求 | back=有后端工时的需求 | all=全部
2867
+ async function exportPlanStories(zentaosid, selected, scope) {
2868
+ const scopeLabel =
2869
+ scope === "front" ? "前端" : scope === "back" ? "后端" : "全部";
2870
+ Loading$1.start("拉取计划详情中...");
2871
+ const { plan, stories: allStories, products } = await fetchPlanDetail(
2872
+ zentaosid,
2873
+ selected.id
2874
+ );
2875
+ // 按导出范围过滤需求
2876
+ const stories = allStories.filter((s) => {
2877
+ if (scope === "front") return Number(s.webestimate) > 0;
2878
+ if (scope === "back") return Number(s.estimate) > 0;
2879
+ return true;
2880
+ });
2881
+ Loading$1.succeed(
2882
+ `拉取计划详情成功(${scopeLabel}${stories.length}/${allStories.length}条)`
2883
+ );
2884
+ if (!stories.length) {
2885
+ console.log(chalk.red(`该计划无${scopeLabel}需求`));
1572
2886
  process.exit(1);
1573
2887
  }
1574
- // CLI传入的权限名称/编码仅第一圈消费, 后续圈次走交互输入
1575
- let cliName = cliPermissionName;
1576
- let cliCode = cliPermissionCode;
1577
- let goOn = true;
1578
- while (goOn) {
1579
- const permissionName = await askOrUse(cliName, {
1580
- message: "请输入权限名称",
1581
- name: "permissionName",
1582
- type: "input",
1583
- validate: function (value) {
1584
- if (value) {
1585
- return true;
1586
- }
1587
- return "请输入权限名称";
1588
- },
2888
+ // 并行限流(并发5)逐条拉取 spec/verify
2889
+ const storyDetails = new Map();
2890
+ const failed = new Map(); // storyID -> 错误信息
2891
+ let done = 0;
2892
+ let index = 0;
2893
+ Loading$1.start(`拉取需求详情 (0/${stories.length})...`);
2894
+ const worker = async () => {
2895
+ while (index < stories.length) {
2896
+ const story = stories[index];
2897
+ index += 1;
2898
+ try {
2899
+ const detail = await fetchStoryDetail(zentaosid, story.id);
2900
+ storyDetails.set(String(story.id), detail);
2901
+ } catch (e) {
2902
+ // 会话失效上抛, 由 ensureZentaoSession 整体重登重试
2903
+ if (e?.isAuthError) throw e;
2904
+ failed.set(String(story.id), e.message || String(e));
2905
+ console.log(chalk.yellow(`需求 ${story.id} 详情拉取失败: ${e.message}`));
2906
+ }
2907
+ done += 1;
2908
+ // ora 的 text 是属性而非方法
2909
+ Loading$1.text = `拉取需求详情 (${done}/${stories.length})...`;
2910
+ }
2911
+ };
2912
+ await Promise.all(
2913
+ Array.from({ length: PULL_CONCURRENCY }, () => worker())
2914
+ );
2915
+ Loading$1.succeed(`拉取需求详情完成(${done}/${stories.length})`);
2916
+ // 需求目录: .zentao-<计划名>(多计划互不混目录)
2917
+ const zentaoDir = path.join(process.cwd(), zentaoDirNameOf(plan));
2918
+ // 同名taskDir直接删除重导(重拉=全新快照, 连带清掉旧images与旧plan/codex报告; 失败条目保留旧数据)
2919
+ stories.forEach((story) => {
2920
+ if (failed.has(String(story.id))) return;
2921
+ const dir = path.join(zentaoDir, "tasks", taskDirOf(story));
2922
+ if (isExistPath(dir)) {
2923
+ fs.rmSync(dir, { recursive: true, force: true });
2924
+ console.log(chalk.yellow(`同名任务目录已删除重导: ${taskDirOf(story)}`));
2925
+ }
2926
+ });
2927
+ // 图片本地化到 tasks/<taskDir>/images/(目录已删全量重下, 失败黄字不阻塞, 引用降级远程url)
2928
+ const storyImages = await downloadStoryImages({
2929
+ stories,
2930
+ storyDetails,
2931
+ zentaosid,
2932
+ zentaoDir,
2933
+ });
2934
+ writeZentaoTasks({
2935
+ plan,
2936
+ scope,
2937
+ stories,
2938
+ storyDetails,
2939
+ products,
2940
+ failed,
2941
+ storyImages,
2942
+ zentaoDir,
2943
+ });
2944
+ // 汇总范围内需求的项目(前端fe-xxx-vue截取 / 后端pool原名), 生成多项目工作区
2945
+ const frontProjects =
2946
+ scope === "front" || scope === "all"
2947
+ ? collectPoolProjects(stories, "front")
2948
+ : [];
2949
+ const backProjects =
2950
+ scope === "back" || scope === "all"
2951
+ ? collectPoolProjects(stories, "back")
2952
+ : [];
2953
+ if (frontProjects.length || backProjects.length) {
2954
+ writeCodeWorkspace(zentaoDir, plan.title, frontProjects, backProjects);
2955
+ } else {
2956
+ console.log(chalk.yellow("本范围无关联项目, 跳过工作区生成"));
2957
+ }
2958
+ }
2959
+
2960
+ /**
2961
+ * story 主入口
2962
+ * @param action login | plan | detail | task | subtask
2963
+ * @param cliOpts 合并后的命令参数(name/username/password/cache/export...)
2964
+ */
2965
+ async function storyMain(action, cliOpts) {
2966
+ validateChoice(
2967
+ action,
2968
+ ["login", "plan", "detail", "task", "subtask"],
2969
+ "story <action>"
2970
+ );
2971
+ if (action === "login") {
2972
+ const secret = await askZentaoCredentials(cliOpts);
2973
+ await zentaoLogin(secret);
2974
+ console.log(chalk.green("登录成功, zentaosid 已缓存"));
2975
+ return;
2976
+ }
2977
+ if (action === "detail") {
2978
+ // 入参 = 需求url(完整url/相对路径/纯ID)
2979
+ const input = String(cliOpts.name || "").trim();
2980
+ const match =
2981
+ input.match(/story-view-(\d+)/) ||
2982
+ (/^\d+$/.test(input) ? [input, input] : null);
2983
+ if (!match) {
2984
+ console.log(
2985
+ chalk.red(
2986
+ "请传入需求url(从 story plan 输出的需求列表复制), 如: fe-it-beta story detail https://zentao.hongxinshop.com/zentao/story-view-70026.html"
2987
+ )
2988
+ );
2989
+ process.exit(1);
2990
+ }
2991
+ const storyID = match[1];
2992
+ const session = await ensureZentaoSession(cliOpts);
2993
+ await session.request(async (zentaosid) => {
2994
+ Loading$1.start("拉取需求详情中...");
2995
+ const { story, users } = await fetchStoryDetail(zentaosid, storyID);
2996
+ Loading$1.succeed("拉取需求详情成功");
2997
+ console.log(chalk.bold(`\n需求详情 #${story.id}`));
2998
+ const rows = [
2999
+ ["ID", story.id],
3000
+ ["标题", story.title],
3001
+ ["产品ID", story.product],
3002
+ ["模块ID", story.module],
3003
+ ["状态", story.status],
3004
+ ["阶段", story.stage],
3005
+ ["优先级", story.pri],
3006
+ ["指派给", users[story.assignedTo] || story.assignedTo],
3007
+ ["创建人", users[story.openedBy] || story.openedBy],
3008
+ ["预估工时", story.estimate],
3009
+ ["url", storyUrl(story.id)],
3010
+ ];
3011
+ rows.forEach(([label, value]) =>
3012
+ console.log(chalk.gray(String(label).padEnd(6, " ")) + ": " + value)
3013
+ );
3014
+ console.log(chalk.bold("\n需求描述"));
3015
+ // 图片以markdown引用完整url输出(可复制到浏览器查看)
3016
+ console.log(htmlToText(story.spec, absoluteZentaoUrl) || "-");
3017
+ console.log(chalk.bold("\n验收标准"));
3018
+ console.log(htmlToText(story.verify, absoluteZentaoUrl) || "-");
1589
3019
  });
1590
- const permissionCode = await askOrUse(cliCode, {
1591
- message: "请输入权限编码",
1592
- name: "permissionCode",
1593
- type: "input",
1594
- validate: function (value) {
1595
- if (!value) {
1596
- return "请输入权限编码";
3020
+ return;
3021
+ }
3022
+ if (action === "task") {
3023
+ // 入参 = 项目ID(完整url/相对路径/纯ID), 如 project-story-2915.html 里的 2915
3024
+ const input = String(cliOpts.name || "").trim();
3025
+ const match =
3026
+ input.match(/project-story-(\d+)/) ||
3027
+ (/^\d+$/.test(input) ? [input, input] : null);
3028
+ if (!match) {
3029
+ console.log(
3030
+ chalk.red(
3031
+ "请传入项目ID(从禅道项目需求页url复制), 如: fe-it-beta story task 2915 或 fe-it-beta story task https://zentao.hongxinshop.com/zentao/project-story-2915.html"
3032
+ )
3033
+ );
3034
+ process.exit(1);
3035
+ }
3036
+ const projectID = match[1];
3037
+ // --type 校验前置, 非法值在网络请求前就红字退出
3038
+ if (cliOpts.type !== undefined) {
3039
+ validateChoice(cliOpts.type, ["front", "back"], "--type");
3040
+ }
3041
+ const session = await ensureZentaoSession(cliOpts);
3042
+ await session.request(async (zentaosid) => {
3043
+ // 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
3044
+ Loading$1.start("校验项目中...");
3045
+ const projectMap = await fetchProjectMap(zentaosid);
3046
+ if (!projectMap[projectID]) {
3047
+ Loading$1.fail("项目不存在");
3048
+ throw new Error(`项目ID ${projectID} 不存在, 请检查后重试`);
3049
+ }
3050
+ Loading$1.succeed(`项目: #${projectID} ${projectMap[projectID]}`);
3051
+ Loading$1.start("拉取项目需求中...");
3052
+ const pj = await fetchProjectStories(zentaosid, projectID);
3053
+ if (!pj.project || !pj.project.name) {
3054
+ Loading$1.fail("项目不存在");
3055
+ throw new Error(`项目ID ${projectID} 不存在, 请检查后重试`);
3056
+ }
3057
+ Loading$1.succeed(
3058
+ `项目: #${projectID} ${pj.project.name} [${pj.project.begin} ~ ${pj.project.end}] (需求${pj.stories.length}条)`
3059
+ );
3060
+ if (!pj.stories.length) {
3061
+ console.log(chalk.red("该项目无关联需求"));
3062
+ process.exit(1);
3063
+ }
3064
+ // 每条需求的前后端已建任务计数(勾选时提示防重复建)
3065
+ // 自行从项目任务列表统计(禅道 storyWebTasks/storyDevTasks 会把回收站任务也计入, 实测不准)
3066
+ Loading$1.start("拉取项目任务中...");
3067
+ const { tasks: projectTasks, pageTotal } = await fetchProjectTasks(
3068
+ zentaosid,
3069
+ projectID
3070
+ );
3071
+ Loading$1.succeed(
3072
+ `拉取项目任务成功(${projectTasks.length}条, 需求计数用)`
3073
+ );
3074
+ // 任务超单页时计数可能不全(禅道翻页路由不稳定, 只提示不翻页)
3075
+ if (pageTotal > 1) {
3076
+ console.log(
3077
+ chalk.yellow(
3078
+ `项目任务超过单页(${pageTotal}页), 已建计数可能不全`
3079
+ )
3080
+ );
3081
+ }
3082
+ // 前端任务判定: type=frontend 或 名称【前端】前缀(修复前的旧数据 type 误传 devel)
3083
+ const isFrontTask = (t) =>
3084
+ t.type === "frontend" || String(t.name || "").startsWith("【前端】");
3085
+ const tasksOf = (s) =>
3086
+ projectTasks.filter((t) => String(t.story) === String(s.id));
3087
+ const webCount = (s) => tasksOf(s).filter(isFrontTask).length;
3088
+ const devCount = (s) =>
3089
+ tasksOf(s).filter((t) => t.type === "devel" && !isFrontTask(t)).length;
3090
+ const totalCount = (s) => tasksOf(s).length;
3091
+ // 子任务统计: 子任务不出现在任务列表(挂在父任务children下), 逐个拉详情;
3092
+ // 只拉 工时>8 的主任务(只有这些才可能拆过子任务), 并发限流控制请求量
3093
+ const subCountMap = new Map(); // 任务ID -> 子任务数
3094
+ {
3095
+ const candidates = projectTasks.filter(
3096
+ (t) => Number(t.estimate) > 8 && !Number(t.parent)
3097
+ );
3098
+ let index = 0;
3099
+ const worker = async () => {
3100
+ while (index < candidates.length) {
3101
+ const t = candidates[index];
3102
+ index += 1;
3103
+ try {
3104
+ const detail = await fetchTaskDetail(zentaosid, t.id);
3105
+ const n = Object.keys(detail?.children || {}).length;
3106
+ if (n) subCountMap.set(String(t.id), n);
3107
+ } catch (e) {
3108
+ if (e?.isAuthError) throw e;
3109
+ // 子任务统计失败不阻塞主流程(勾选列表仍可用, 只是少了子任务数)
3110
+ }
3111
+ }
3112
+ };
3113
+ await Promise.all(
3114
+ Array.from({ length: PULL_CONCURRENCY }, () => worker())
3115
+ );
3116
+ }
3117
+ // 先勾选需求: 一键(--type直通)自动取候选; 交互则列出项目全部需求
3118
+ let picked;
3119
+ if (cliOpts.type !== undefined) {
3120
+ const presetLabel = cliOpts.type === "front" ? "前端" : "后端";
3121
+ const hourOfPreset = (s) =>
3122
+ Number(cliOpts.type === "front" ? s.webestimate : s.estimate);
3123
+ if (cliOpts.splitOnly) {
3124
+ // 仅拆分模式: 取>8h的需求(有父任务按剩余工时拆, 无父任务自动先建)
3125
+ picked = pj.stories.filter((s) => hourOfPreset(s) > 8);
3126
+ if (!picked.length) {
3127
+ console.log(
3128
+ chalk.yellow(`无超过8h的${presetLabel}需求, 无可拆分项, 结束`)
3129
+ );
3130
+ return;
3131
+ }
3132
+ } else {
3133
+ // 建任务模式: 有该类型工时且未建过的需求
3134
+ picked = pj.stories.filter(
3135
+ (s) =>
3136
+ hourOfPreset(s) > 0 &&
3137
+ (cliOpts.type === "front" ? webCount(s) : devCount(s)) === 0
3138
+ );
3139
+ if (!picked.length) {
3140
+ console.log(
3141
+ chalk.yellow(`候选需求均已建过${presetLabel}任务, 无可创建项, 结束`)
3142
+ );
3143
+ return;
3144
+ }
1597
3145
  }
1598
- if (value.includes(":")) {
1599
- return "权限编码不允许包含 :";
3146
+ } else {
3147
+ // 前端子任务计数(排序与展示共用)
3148
+ const frontSubsOf = (s) =>
3149
+ tasksOf(s)
3150
+ .filter(isFrontTask)
3151
+ .reduce(
3152
+ (sum, t) => sum + (subCountMap.get(String(t.id)) || 0),
3153
+ 0
3154
+ );
3155
+ // 前端拆分基准工时: 有父任务用父任务实际工时(最大前端任务的工时), 无则用需求webestimate
3156
+ // (手建父任务工时可能与需求webestimate不一致, 应建数按实际父任务算才准)
3157
+ const frontBaseHours = (s) => {
3158
+ const frontTasks = tasksOf(s).filter(isFrontTask);
3159
+ return frontTasks.length
3160
+ ? Math.max(...frontTasks.map((t) => Number(t.estimate) || 0))
3161
+ : Number(s.webestimate);
3162
+ };
3163
+ ({ picked } = await inquirer.prompt([
3164
+ {
3165
+ message: "请勾选要建任务的需求(空格选择, 回车确认)",
3166
+ name: "picked",
3167
+ type: "checkbox",
3168
+ // 到列表边界停止, 不循环回第一条
3169
+ loop: false,
3170
+ choices: [...pj.stories]
3171
+ .sort((a, b) => {
3172
+ // 待办优先级: 0=前端有工时且未建任务(子任务也必无, 全新建)
3173
+ // 1=前端有任务但子任务未满(待补拆, 基准=父任务实际工时) 2=其余有前端工时 3=无前端工时
3174
+ // 组内按需求ID从小到大
3175
+ const rank = (s) => {
3176
+ if (!(Number(s.webestimate) > 0)) return 3;
3177
+ const base = frontBaseHours(s);
3178
+ if (webCount(s) === 0) return 0;
3179
+ return base > 8 && frontSubsOf(s) < Math.ceil(base / 8)
3180
+ ? 1
3181
+ : 2;
3182
+ };
3183
+ return (
3184
+ rank(a) - rank(b) ||
3185
+ Number(a.id) - Number(b.id)
3186
+ );
3187
+ })
3188
+ .map((s, i) => {
3189
+ // 子任务进度(仅前端>8h展示, 后端暂不展示): 已建/应建
3190
+ // 应建 = ceil(基准工时/8), 基准=前端父任务实际工时(无父任务用webestimate)
3191
+ // 未满已建数红色, 满则总数绿色; 任务计数后括号内联
3192
+ const subProgress = (hours, count) => {
3193
+ const total = Math.ceil(Number(hours) / 8);
3194
+ return count >= total
3195
+ ? `(${count}/${chalk.green(total)})`
3196
+ : `(${chalk.red(count)}/${total})`;
3197
+ };
3198
+ const frontSubs = frontSubsOf(s);
3199
+ const base = frontBaseHours(s);
3200
+ // 前端: 任务数(子任务进度); 后端: 任务数(暂不展示子任务)
3201
+ const built = [
3202
+ Number(s.webestimate) > 0
3203
+ ? `前端${webCount(s)}个${
3204
+ base > 8 ? subProgress(base, frontSubs) : ""
3205
+ }`
3206
+ : "",
3207
+ Number(s.estimate) > 0 ? `后端${devCount(s)}个 任务` : "",
3208
+ ]
3209
+ .filter(Boolean)
3210
+ .join("/");
3211
+ // 父任务工时不足提示: 有前端父任务但工时 < 需求前端工时(黄字, 手建父任务漏填/填少)
3212
+ const shortWarn =
3213
+ webCount(s) > 0 && base < Number(s.webestimate)
3214
+ ? `, ${chalk.yellow(`父任务${base}h<需求${s.webestimate}h, 不足!`)}`
3215
+ : "";
3216
+ // 第二行详情(已建/警告): 换行 + 缩进对齐, 与标题行区分
3217
+ const detail = [built ? `已建${built}` : "", shortWarn.slice(2)]
3218
+ .filter(Boolean)
3219
+ .join(", ");
3220
+ return {
3221
+ name: `${i + 1}. #${s.id} ${s.title} (前端${
3222
+ Number(s.webestimate) > 0 ? `${s.webestimate}h` : "-"
3223
+ } / 后端${
3224
+ Number(s.estimate) > 0 ? `${s.estimate}h` : "-"
3225
+ })${detail ? `\n ${detail}` : ""}`,
3226
+ value: s,
3227
+ // 有工时但从未建过任何任务的需求默认勾选
3228
+ checked: totalCount(s) === 0,
3229
+ };
3230
+ }),
3231
+ },
3232
+ ]));
3233
+ }
3234
+ if (!picked.length) {
3235
+ console.log(chalk.yellow("未勾选需求, 结束"));
3236
+ return;
3237
+ }
3238
+ // 再选任务类型: --type 直通(一键场景) > 选完需求后交互询问
3239
+ let taskType;
3240
+ if (cliOpts.type !== undefined) {
3241
+ taskType = cliOpts.type;
3242
+ } else {
3243
+ ({ taskType } = await inquirer.prompt([
3244
+ {
3245
+ message: "请选择任务类型",
3246
+ name: "taskType",
3247
+ type: "list",
3248
+ choices: [
3249
+ { name: "前端任务", value: "front" },
3250
+ { name: "后端任务", value: "back" },
3251
+ ],
3252
+ },
3253
+ ]));
3254
+ }
3255
+ const typeLabel = taskType === "front" ? "前端" : "后端";
3256
+ // 勾选项中无该类型工时的跳过(黄字提示)
3257
+ const hourOf = (s) =>
3258
+ Number(taskType === "front" ? s.webestimate : s.estimate);
3259
+ const noHour = picked.filter((s) => hourOf(s) <= 0);
3260
+ if (noHour.length) {
3261
+ console.log(
3262
+ chalk.yellow(
3263
+ `需求 ${noHour.map((s) => s.id).join(", ")} 无${typeLabel}工时, 跳过不建`
3264
+ )
3265
+ );
3266
+ }
3267
+ picked = picked.filter((s) => hourOf(s) > 0);
3268
+ if (!picked.length) {
3269
+ console.log(chalk.red(`勾选的需求均无${typeLabel}工时`));
3270
+ process.exit(1);
3271
+ }
3272
+ // 指派: 前端->webuser / 后端->devuser, 为空 fallback 当前登录账号
3273
+ const account = readZentaoCache().account;
3274
+ const assigneeOf = (s) =>
3275
+ (taskType === "front" ? s.webuser : s.devuser) || account;
3276
+ // 处理模式(选择即确认): --split-only > --split > 交互列表 > 一键默认
3277
+ // parent=仅创建父任务 | parent-split=创建父任务+拆分子任务 | split=仅拆分(无父任务自动先建)
3278
+ let taskMode;
3279
+ if (cliOpts.splitOnly) {
3280
+ taskMode = "split";
3281
+ } else if (cliOpts.split) {
3282
+ taskMode = "parent-split";
3283
+ } else if (cliOpts.type !== undefined) {
3284
+ taskMode = "parent"; // 一键默认仅建父任务
3285
+ } else {
3286
+ ({ taskMode } = await inquirer.prompt([
3287
+ {
3288
+ message: `将处理 ${picked.length} 条【${typeLabel}】任务(截止 ${pj.project.end}), 请选择模式`,
3289
+ name: "taskMode",
3290
+ type: "list",
3291
+ choices: [
3292
+ { name: "1. 创建父任务", value: "parent" },
3293
+ { name: "2. 创建父任务+拆分子任务", value: "parent-split" },
3294
+ { name: "3. 拆分子任务(无父任务自动先创建)", value: "split" },
3295
+ ],
3296
+ },
3297
+ ]));
3298
+ }
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
+ // 需创建父任务的需求:
3307
+ // parent=全部勾选 | parent-split=无父任务的 | split=无父任务且>8h(仅为拆分而建)
3308
+ const toCreate =
3309
+ taskMode === "parent"
3310
+ ? picked
3311
+ : picked.filter(
3312
+ (s) =>
3313
+ !existingParentOf(s) &&
3314
+ (taskMode === "parent-split" || hourOf(s) > 8)
3315
+ );
3316
+ // 创建前任务基线(创建后 diff 出新任务ID)
3317
+ const baseline = new Set(
3318
+ (await fetchProjectTasks(zentaosid, projectID)).tasks.map((t) =>
3319
+ String(t.id)
3320
+ )
3321
+ );
3322
+ // 逐条创建(串行, 避免并发触发禅道限流)
3323
+ let done = 0;
3324
+ const failedStories = [];
3325
+ if (toCreate.length) {
3326
+ Loading$1.start(`创建任务 (0/${toCreate.length})...`);
3327
+ for (const s of toCreate) {
3328
+ try {
3329
+ await createZentaoTask({
3330
+ zentaosid,
3331
+ projectID,
3332
+ story: s,
3333
+ name: `【${typeLabel}】${s.title}`,
3334
+ // 任务类型(表单选项实测): frontend=前端开发, devel=后端开发
3335
+ type: taskType === "front" ? "frontend" : "devel",
3336
+ assignedTo: assigneeOf(s),
3337
+ estimate: taskType === "front" ? s.webestimate : s.estimate,
3338
+ estStarted: localToday(),
3339
+ deadline: pj.project.end,
3340
+ });
3341
+ } catch (e) {
3342
+ // 会话失效上抛, 由 ensureZentaoSession 整体重登重试
3343
+ if (e?.isAuthError) throw e;
3344
+ failedStories.push(s.id);
3345
+ console.log(
3346
+ chalk.yellow(`需求 ${s.id} 创建失败: ${e.message || String(e)}`)
3347
+ );
3348
+ }
3349
+ done += 1;
3350
+ // ora 的 text 是属性而非方法
3351
+ Loading$1.text = `创建任务 (${done}/${toCreate.length})...`;
1600
3352
  }
1601
- return true;
1602
- },
1603
- });
1604
- cliName = undefined;
1605
- cliCode = undefined;
1606
- const data = {
1607
- code1: appConfig.code,
1608
- name: permissionName,
1609
- visitConf: permissionCode,
1610
- ascriptionApp: appConfig.id,
1611
- code: `${appConfig.code}:${permissionCode}`,
1612
- };
1613
- let confirmed = true;
1614
- if (!oneClick) {
1615
- const { confirmAdd } = await inquirer.prompt([
1616
- {
1617
- message: `确认新增按钮权限? ${chalk.cyan(
1618
- `${data.name} (${data.code})`
1619
- )}`,
1620
- name: "confirmAdd",
1621
- type: "confirm",
1622
- default: true,
1623
- },
1624
- ]);
1625
- confirmed = confirmAdd;
1626
- }
1627
- if (confirmed) {
1628
- try {
1629
- await addApplicationButtonConfig({ data, env, token });
3353
+ Loading$1.succeed(
3354
+ `创建完成: 成功 ${toCreate.length - failedStories.length} 条 / 失败 ${failedStories.length} 条`
3355
+ );
3356
+ }
3357
+ // 重拉任务列表 diff 出新任务, 输出任务url
3358
+ const pickedIDs = new Set(picked.map((s) => String(s.id)));
3359
+ const newTasks = (await fetchProjectTasks(zentaosid, projectID)).tasks.filter(
3360
+ (t) => !baseline.has(String(t.id)) && pickedIDs.has(String(t.story))
3361
+ );
3362
+ newTasks.forEach((t) =>
1630
3363
  console.log(
1631
3364
  chalk.green(
1632
- `新增按钮权限成功: ${appName} - ${data.name} (${data.code})`
3365
+ `任务 #${t.id} ${t.name} -> ${t.assignedTo} (${t.estimate}h)`
3366
+ ),
3367
+ `\n ${taskUrl(t.id)}`
3368
+ )
3369
+ );
3370
+ // 拆分子任务(parent-split/split 模式, 仅>8h): 父任务 = 新建优先, 否则用已有(按剩余工时)
3371
+ if (taskMode !== "parent") {
3372
+ const newByStory = new Map(
3373
+ newTasks.map((t) => [String(t.story), t])
3374
+ );
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);
3409
+ 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
+ );
3429
+ } catch (e) {
3430
+ // 会话失效上抛, 由 ensureZentaoSession 整体重登重试
3431
+ if (e?.isAuthError) throw e;
3432
+ console.log(
3433
+ chalk.yellow(
3434
+ `任务 ${parent.id} 子任务创建失败: ${e.message || String(e)}`
3435
+ )
3436
+ );
3437
+ }
3438
+ }
3439
+ }
3440
+ // 部分失败以非零退出码收尾, 供自动化流程感知重试
3441
+ if (failedStories.length) {
3442
+ console.log(
3443
+ chalk.red(
3444
+ `失败${failedStories.length}条(需求ID: ${failedStories.join(", ")}), 详情见上方黄字提示`
1633
3445
  )
1634
3446
  );
1635
- } catch (e) {
1636
- console.log(chalk.red(`新增按钮权限失败: ${e.message || e}`));
1637
3447
  process.exit(1);
1638
3448
  }
3449
+ });
3450
+ return;
3451
+ }
3452
+ if (action === "subtask") {
3453
+ // 入参 = 父任务ID(完整url/相对路径/纯ID), 如 task-view-218901.html 里的 218901
3454
+ const input = String(cliOpts.name || "").trim();
3455
+ const match =
3456
+ input.match(/task-view-(\d+)/) ||
3457
+ (/^\d+$/.test(input) ? [input, input] : null);
3458
+ if (!match) {
3459
+ console.log(
3460
+ chalk.red(
3461
+ "请传入父任务ID(从禅道任务页url复制), 如: fe-it-beta story subtask 218901 或 fe-it-beta story subtask https://zentao.hongxinshop.com/zentao/task-view-218901.html"
3462
+ )
3463
+ );
3464
+ process.exit(1);
1639
3465
  }
1640
- if (oneClick) {
1641
- goOn = false;
3466
+ const parentID = match[1];
3467
+ const session = await ensureZentaoSession(cliOpts);
3468
+ await session.request(async (zentaosid) => {
3469
+ Loading$1.start("拉取父任务详情中...");
3470
+ const parent = await fetchTaskDetail(zentaosid, parentID);
3471
+ if (!parent || !parent.id) {
3472
+ Loading$1.fail("任务不存在");
3473
+ throw new Error(`任务ID ${parentID} 不存在, 请检查后重试`);
3474
+ }
3475
+ const typeLabel = parent.type === "frontend" ? "前端" : "后端";
3476
+ const taskType = parent.type === "frontend" ? "front" : "back";
3477
+ const totalHours = Number(parent.estimate);
3478
+ // 已有子任务(工时和), 按剩余工时拆
3479
+ const children = Object.values(parent.children || {});
3480
+ const childHours = children.reduce(
3481
+ (sum, c) => sum + Number(c.estimate || 0),
3482
+ 0
3483
+ );
3484
+ const remainHours = totalHours - childHours;
3485
+ Loading$1.succeed(
3486
+ `父任务 #${parent.id} ${parent.name} [${typeLabel}] ${totalHours}h -> ${parent.assignedTo} (截止 ${parent.deadline})`
3487
+ );
3488
+ if (children.length) {
3489
+ console.log(
3490
+ chalk.yellow(
3491
+ `已有子任务 ${children.length} 个(共${childHours}h): ${children
3492
+ .map((c) => `#${c.id} ${c.name}(${c.estimate}h)`)
3493
+ .join(", ")}`
3494
+ )
3495
+ );
3496
+ }
3497
+ if (!(remainHours > 8)) {
3498
+ console.log(
3499
+ chalk.yellow(
3500
+ `剩余工时 ${remainHours}h 未超过 8h, 无需拆分${
3501
+ remainHours < 0 ? "(子任务工时已超父任务)" : ""
3502
+ }`
3503
+ )
3504
+ );
3505
+ return;
3506
+ }
3507
+ // 拆分方案(按剩余工时, 名称对齐父任务名)
3508
+ const subs = splitTaskPlan(parent.name, remainHours, taskType);
3509
+ console.log(chalk.bold(`\n拆分方案(${subs.length} 个子任务, 共${remainHours}h):`));
3510
+ subs.forEach((sub) =>
3511
+ console.log(` ${sub.name} (${sub.estimate}h) -> ${parent.assignedTo}`)
3512
+ );
3513
+ // --split 直通跳过确认(一键场景), 否则询问
3514
+ if (!cliOpts.split) {
3515
+ const { ok } = await inquirer.prompt([
3516
+ {
3517
+ message: "确认创建以上子任务?",
3518
+ name: "ok",
3519
+ type: "confirm",
3520
+ default: true,
3521
+ },
3522
+ ]);
3523
+ if (!ok) {
3524
+ console.log(chalk.yellow("已取消"));
3525
+ return;
3526
+ }
3527
+ }
3528
+ await createZentaoSubtasks({
3529
+ zentaosid,
3530
+ projectID: parent.project,
3531
+ story: { id: parent.story, pri: parent.pri },
3532
+ parentTaskID: parent.id,
3533
+ type: parent.type,
3534
+ assignedTo: parent.assignedTo,
3535
+ estStarted: localToday(),
3536
+ deadline: parent.deadline,
3537
+ subs,
3538
+ });
3539
+ console.log(
3540
+ chalk.green(
3541
+ `子任务 ${subs.length} 个已创建(父任务 #${parent.id}): ${subs
3542
+ .map((sub) => `${sub.name}(${sub.estimate}h)`)
3543
+ .join(", ")}`
3544
+ )
3545
+ );
3546
+ console.log(chalk.green(`查看: ${taskUrl(parent.id)}`));
3547
+ });
3548
+ return;
3549
+ }
3550
+ // plan: 产品选择 -> 计划选取 -> 展示 -> 导出(--export直通一键场景, 不传则收尾交互)
3551
+ const session = await ensureZentaoSession(cliOpts);
3552
+ await session.request(async (zentaosid) => {
3553
+ const product = await selectProduct(zentaosid, cliOpts);
3554
+ const plans = await loadPlanList(zentaosid, product.id);
3555
+ const selected = await selectPlan(plans, cliOpts.name);
3556
+ await showPlanDetail(zentaosid, selected);
3557
+ // 导出范围: --export参数直通(一键场景) > 收尾交互(默认前端)
3558
+ let exportChoice;
3559
+ if (cliOpts.export !== undefined) {
3560
+ validateChoice(cliOpts.export, ["front", "back", "all"], "--export");
3561
+ exportChoice = cliOpts.export;
1642
3562
  } else {
1643
- const { continueAdd } = await inquirer.prompt([
3563
+ ({ exportChoice } = await inquirer.prompt([
1644
3564
  {
1645
- message: "是否继续为该应用添加按钮权限?",
1646
- name: "continueAdd",
1647
- type: "confirm",
1648
- default: false,
3565
+ message: "是否导出需求文件(按计划生成 .zentao-<计划名>目录)?",
3566
+ name: "exportChoice",
3567
+ type: "list",
3568
+ choices: [
3569
+ { name: "导出: 前端需求", value: "front" }, // 默认第一项
3570
+ { name: "导出: 后端需求", value: "back" },
3571
+ { name: "导出: 全部需求", value: "all" },
3572
+ { name: "否", value: "none" },
3573
+ ],
1649
3574
  },
1650
- ]);
1651
- goOn = continueAdd;
3575
+ ]));
1652
3576
  }
1653
- }
3577
+ if (exportChoice !== "none") {
3578
+ await exportPlanStories(zentaosid, selected, exportChoice);
3579
+ }
3580
+ });
3581
+ }
3582
+
3583
+ /**
3584
+ * 命令注册(index.js 只登记调用, 命令定义全部收在本模块内)
3585
+ * @param program commander program 实例
3586
+ * @param helpers { markHandled, addHelpExample }
3587
+ */
3588
+ function registerStoryCommand(program, { markHandled, addHelpExample }) {
3589
+ program
3590
+ .command("story <action> [name]")
3591
+ .description(
3592
+ "禅道需求: login 登录 | plan 选计划看需求列表并可导出(按计划生成需求目录+工作区) | detail 看需求详情 | task 勾选项目需求在线建任务 | subtask 给已建任务单独拆子任务"
3593
+ )
3594
+ // 子命令的 option 独立于 program 级: story login --cache 中 --cache 出现在子命令位, 须在子命令上重复注册
3595
+ .option("--username <name>", "禅道账号(story命令用)")
3596
+ .option("--password <pwd>", "禅道密码(story命令用, 日常建议用--cache)")
3597
+ .option("--cache", "凭据静默走缓存")
3598
+ .option("--product <id>", "禅道产品ID(plan用, 不传则交互选择)")
3599
+ .option("--export <scope>", "plan导出范围: front|back|all(传了跳过收尾询问直接导出)")
3600
+ .option("--type <t>", "task任务类型: front|back(传了跳过类型询问与确认, 一键创建)")
3601
+ .option("--split", "task时创建父任务并自动拆分子任务(>8h, 每个8h末个补余); subtask时跳过确认直接拆")
3602
+ .option("--split-only", "task时仅拆分子任务(无父任务的>8h需求自动先建父任务再拆)")
3603
+ .action((action, name, options) => {
3604
+ markHandled(); // 同步置位, 防止 index.js 落入默认翻译分支
3605
+ storyMain(action, { ...program.opts(), ...options, name }).catch(
3606
+ (error) => {
3607
+ // 兜底停掉可能挂着的spinner + 红字报错 + 非零退出码(供自动化判断成败)
3608
+ Loading$1.fail("执行失败");
3609
+ console.error(chalk.red(error.message || String(error)));
3610
+ process.exit(1);
3611
+ }
3612
+ );
3613
+ });
3614
+ // help示例经 helpers 注入 index.js 的 helpExamples, 自动参与列对齐
3615
+ addHelpExample("fe-it-beta story login", "禅道登录, 缓存zentaosid");
3616
+ addHelpExample("fe-it-beta story plan --product 87", "选计划看列表, 交互选导出");
3617
+ addHelpExample("fe-it-beta story detail 需求ID", "按需求ID查看详情");
3618
+ addHelpExample("fe-it-beta story task 项目ID", "选项目勾选需求, 交互建禅道任务");
3619
+ addHelpExample(
3620
+ "fe-it-beta story task 2915 --type front --split --cache",
3621
+ "一键创建前端任务并自动拆分子任务"
3622
+ );
3623
+ addHelpExample("fe-it-beta story subtask 父任务ID", "给已建任务单独拆子任务(按剩余工时)");
3624
+ addHelpExample(
3625
+ "fe-it-beta story plan --product 87 --export front --cache",
3626
+ "一键导出前端需求并生成多项目工作区"
3627
+ );
1654
3628
  }
1655
3629
 
1656
3630
  /**
@@ -2821,9 +4795,9 @@ async function customColumnModule (lintc, fix, rule, parentRule, cliOpts = {}) {
2821
4795
  }
2822
4796
 
2823
4797
  async function getColumn(moduleArr = [], dynamicsArr = [], parentRule) {
2824
- let token;
4798
+ let auth;
2825
4799
  try {
2826
- token = await login({
4800
+ auth = await createAuth({
2827
4801
  env: "sit",
2828
4802
  username: "superAdmin",
2829
4803
  password: "admin1",
@@ -2833,16 +4807,36 @@ async function getColumn(moduleArr = [], dynamicsArr = [], parentRule) {
2833
4807
  return;
2834
4808
  }
2835
4809
  const requestArr = moduleArr.map(({ module, path }) =>
2836
- request$1({
2837
- url: `http://sit-hxjf.hongxinshop.com/api-item/api/customizedColumns/find`,
2838
- method: "POST",
2839
- json: true,
2840
- body: { module },
2841
- headers: {
2842
- Authorization: `Bearer ${token}`,
2843
- "x-request-vaildate": "open",
2844
- },
2845
- })
4810
+ auth.request((t) =>
4811
+ request$1({
4812
+ url: `http://sit-hxjf.hongxinshop.com/api-item/api/customizedColumns/find`,
4813
+ method: "POST",
4814
+ json: true,
4815
+ body: { module },
4816
+ headers: {
4817
+ Authorization: `Bearer ${t}`,
4818
+ "x-request-vaildate": "open",
4819
+ },
4820
+ })
4821
+ .then((body) => {
4822
+ if (isAuthError(null, body)) {
4823
+ throw Object.assign(new Error("登录态已失效"), {
4824
+ isAuthError: true,
4825
+ });
4826
+ }
4827
+ return body;
4828
+ })
4829
+ .catch((e) => {
4830
+ if (e.isAuthError) throw e;
4831
+ // request-promise 对非2xx reject StatusCodeError(含 response/body)
4832
+ if (isAuthError(e.response, e.response?.body)) {
4833
+ throw Object.assign(new Error("登录态已失效"), {
4834
+ isAuthError: true,
4835
+ });
4836
+ }
4837
+ throw e;
4838
+ })
4839
+ )
2846
4840
  );
2847
4841
  Promise.allSettled(requestArr).then(async (res) => {
2848
4842
  const tableData = [];
@@ -3088,6 +5082,16 @@ const helpExamples = [
3088
5082
  ],
3089
5083
  ["fe-it-beta -excel --file export.xlsx --lang vi --cache", ""],
3090
5084
  ];
5085
+
5086
+ // ===== 分支子命令注册(命令定义在各分支模块内, 此处只登记) =====
5087
+ // 新增分支模块时: 模块导出 registerXxxCommand(program, helpers) 并在此追加一行
5088
+ let storyHandled = false;
5089
+ registerStoryCommand(commander.program, {
5090
+ // 同步置位标记, 用于下方分发互斥(子命令调用后不再落入默认翻译分支)
5091
+ markHandled: () => (storyHandled = true),
5092
+ addHelpExample: (cmd, desc) => helpExamples.push([cmd, desc]),
5093
+ });
5094
+
3091
5095
  commander.program.addHelpText(
3092
5096
  "after",
3093
5097
  "\n" +
@@ -3103,6 +5107,8 @@ commander.program.addHelpText(
3103
5107
  })
3104
5108
  .join("\n")
3105
5109
  );
5110
+ // 占位action: 阻止commander在注册子命令后, 无参调用直接输出help退出(保持落入下方默认翻译分支)
5111
+ commander.program.action(() => {});
3106
5112
  commander.program.parse(process.argv);
3107
5113
 
3108
5114
  // 判断命令参数
@@ -3138,7 +5144,7 @@ const rules = [
3138
5144
  ],
3139
5145
  },
3140
5146
  ];
3141
- if (sass) {
5147
+ if (storyHandled) ; else if (sass) {
3142
5148
  sassMain(cliOpts).catch((error) => console.error(error));
3143
5149
  } else if (upload) {
3144
5150
  upload$1(cliOpts).catch((error) => console.error(error));
@@ -3431,6 +5437,52 @@ function translateUtil(appid, key, projectName, isLintFix, isMergeDecision) {
3431
5437
  // isMergeDecision: true/false为预决定值(一键执行), null表示仍需交互询问
3432
5438
  let isMerge = isMergeDecision;
3433
5439
  if (isMerge === null || isMerge === undefined) {
5440
+ // 询问前预览当前export.xlsx(最近更新时间+内容), 便于判断覆盖还是合并
5441
+ if (fs.existsSync("./export.xlsx")) {
5442
+ const stat = fs.statSync("./export.xlsx");
5443
+ const pad = (n) => String(n).padStart(2, "0");
5444
+ const mtimeStr = `${stat.mtime.getFullYear()}-${pad(stat.mtime.getMonth() + 1)}-${pad(stat.mtime.getDate())} ${pad(stat.mtime.getHours())}:${pad(stat.mtime.getMinutes())}:${pad(stat.mtime.getSeconds())}`;
5445
+ console.log(chalk.green(`export.xlsx 最近更新: ${mtimeStr}`));
5446
+ try {
5447
+ const rows = xlsx.parse(fs.readFileSync("./export.xlsx"))[0].data;
5448
+ const headerRow = rows[0] || [];
5449
+ const dataRows = rows.slice(1);
5450
+ const PREVIEW_LIMIT = 20;
5451
+ // 数据不超过2倍上限时全量展示, 避免头尾重叠导致重复打印
5452
+ const previewRows =
5453
+ dataRows.length <= PREVIEW_LIMIT * 2
5454
+ ? dataRows
5455
+ : [...dataRows.slice(0, PREVIEW_LIMIT), ...dataRows.slice(-PREVIEW_LIMIT)];
5456
+ console.log(
5457
+ chalk.green(
5458
+ dataRows.length <= PREVIEW_LIMIT * 2
5459
+ ? `当前内容预览(共${dataRows.length}条数据):`
5460
+ : `当前内容预览(共${dataRows.length}条数据, 展示前${PREVIEW_LIMIT}条与倒数${PREVIEW_LIMIT}条):`
5461
+ )
5462
+ );
5463
+ if (dataRows.length) {
5464
+ // 仅展示编码与中文值两列, 避免全列表格(11列)在终端超宽; 列序以标题行为准, 找不到时退回默认列位(编码=3, 中文值=6)
5465
+ const colIdx = (title, fallback) => {
5466
+ const i = headerRow.indexOf(title);
5467
+ return i < 0 ? fallback : i;
5468
+ };
5469
+ const codeIdx = colIdx("编码", 3);
5470
+ const zhIdx = colIdx("中文值", 6);
5471
+ console.table(
5472
+ previewRows.map((row) => ({
5473
+ 编码: row[codeIdx],
5474
+ 中文值: row[zhIdx],
5475
+ }))
5476
+ );
5477
+ } else {
5478
+ console.log(chalk.yellow("当前文件无数据(仅标题或空文件)"));
5479
+ }
5480
+ } catch (error) {
5481
+ console.log(chalk.yellow("export.xlsx 解析失败, 选择合并将按覆盖处理"));
5482
+ }
5483
+ } else {
5484
+ console.log(chalk.yellow("export.xlsx 不存在, 本次将直接新建"));
5485
+ }
3434
5486
  const res = await inquirer.prompt([
3435
5487
  {
3436
5488
  message: `是否合并至export.xlsx,默认${chalk.red("否,直接覆盖")}`,