i18-fe-automator-beta 2.1.3 → 2.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commonjs/index.js +2682 -225
- package/dist/esm/index.mjs +2682 -225
- package/package.json +1 -1
package/dist/commonjs/index.js
CHANGED
|
@@ -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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 = [];
|
|
@@ -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
|
|
742
|
+
// 1.登录(优先复用缓存token, 失效自动重登)
|
|
743
|
+
let auth;
|
|
641
744
|
try {
|
|
642
|
-
|
|
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
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
1438
|
+
let auth;
|
|
1309
1439
|
try {
|
|
1310
|
-
|
|
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
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
|
1524
|
+
let toAuth;
|
|
1391
1525
|
try {
|
|
1392
|
-
|
|
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
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
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
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
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
|
|
1642
|
+
let auth;
|
|
1501
1643
|
try {
|
|
1502
|
-
|
|
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
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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);
|
|
@@ -1566,7 +1710,9 @@ async function addSassPermission(cliOpts = {}) {
|
|
|
1566
1710
|
}
|
|
1567
1711
|
let appConfig;
|
|
1568
1712
|
try {
|
|
1569
|
-
appConfig = await
|
|
1713
|
+
appConfig = await auth.request((t) =>
|
|
1714
|
+
getApplicationConfig({ appId, env, token: t })
|
|
1715
|
+
);
|
|
1570
1716
|
} catch (e) {
|
|
1571
1717
|
console.log(chalk.red(`获取应用配置失败: ${e.message || e}`));
|
|
1572
1718
|
process.exit(1);
|
|
@@ -1626,7 +1772,9 @@ async function addSassPermission(cliOpts = {}) {
|
|
|
1626
1772
|
}
|
|
1627
1773
|
if (confirmed) {
|
|
1628
1774
|
try {
|
|
1629
|
-
await
|
|
1775
|
+
await auth.request((t) =>
|
|
1776
|
+
addApplicationButtonConfig({ data, env, token: t })
|
|
1777
|
+
);
|
|
1630
1778
|
console.log(
|
|
1631
1779
|
chalk.green(
|
|
1632
1780
|
`新增按钮权限成功: ${appName} - ${data.name} (${data.code})`
|
|
@@ -1653,80 +1801,2357 @@ async function addSassPermission(cliOpts = {}) {
|
|
|
1653
1801
|
}
|
|
1654
1802
|
}
|
|
1655
1803
|
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
const
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
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
|
+
// 任务状态中文标签(close 命令展示用)
|
|
1813
|
+
const TASK_STATUS_LABELS = {
|
|
1814
|
+
wait: "未开始",
|
|
1815
|
+
doing: "进行中",
|
|
1816
|
+
pause: "已暂停",
|
|
1817
|
+
done: "已完成",
|
|
1818
|
+
closed: "已关闭",
|
|
1819
|
+
cancel: "已取消",
|
|
1820
|
+
};
|
|
1821
|
+
|
|
1822
|
+
// 确保 .cache 目录存在
|
|
1823
|
+
function ensureCacheDir() {
|
|
1824
|
+
const cacheDir = getRunCliPath({ root: true }) + "/.cache";
|
|
1825
|
+
if (!isExistPath(cacheDir)) {
|
|
1826
|
+
fs.mkdirSync(cacheDir);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// 读取禅道凭据/会话缓存(不存在/损坏返回 {})
|
|
1831
|
+
function readZentaoCache() {
|
|
1832
|
+
const cacheFilePath =
|
|
1833
|
+
getRunCliPath({ root: true }) + "/.cache/" + CACHE_KEY;
|
|
1834
|
+
if (!isExistPath(cacheFilePath)) {
|
|
1835
|
+
return {};
|
|
1836
|
+
}
|
|
1837
|
+
try {
|
|
1838
|
+
return JSON.parse(readCache(CACHE_KEY).toString("utf8")) || {};
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
return {};
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// 写禅道缓存(合并写: resolveSecretQuestions 会整文件回写凭据字段, 非合并会抹掉 zentaosid)
|
|
1845
|
+
function writeZentaoCache(obj) {
|
|
1846
|
+
ensureCacheDir();
|
|
1847
|
+
writeCache(
|
|
1848
|
+
CACHE_KEY,
|
|
1849
|
+
JSON.stringify({ ...readZentaoCache(), ...obj })
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* 原样移植自禅道 12.3.1 all.js, 勿改动逻辑(服务端密码强度校验用)
|
|
1855
|
+
*/
|
|
1856
|
+
function computePasswordStrength(password) {
|
|
1857
|
+
if (password.length === 0) return 0;
|
|
1858
|
+
let strength = 0;
|
|
1859
|
+
const length = password.length;
|
|
1860
|
+
let distinct = "";
|
|
1861
|
+
const kinds = {};
|
|
1862
|
+
for (let i = 0; i < length; i++) {
|
|
1863
|
+
const code = password.charCodeAt(i);
|
|
1864
|
+
if (code >= 48 && code <= 57) kinds[2] = 2; // 数字
|
|
1865
|
+
else if (code >= 65 && code <= 90) kinds[1] = 2; // 大写字母
|
|
1866
|
+
else if (code >= 97 && code <= 122) kinds[0] = 1; // 小写字母
|
|
1867
|
+
else kinds[3] = 3; // 特殊字符
|
|
1868
|
+
if (distinct.indexOf(password[i]) === -1) distinct += password[i];
|
|
1869
|
+
}
|
|
1870
|
+
if (distinct.length > 4) strength += distinct.length - 4;
|
|
1871
|
+
let sum = 0;
|
|
1872
|
+
let count = 0;
|
|
1873
|
+
for (const key in kinds) {
|
|
1874
|
+
count += 1;
|
|
1875
|
+
sum += kinds[key];
|
|
1876
|
+
}
|
|
1877
|
+
strength += sum + 2 * (count - 1);
|
|
1878
|
+
if (length < 6 && strength >= 10) strength = 9;
|
|
1879
|
+
strength = strength > 29 ? 29 : strength;
|
|
1880
|
+
return Math.floor(strength / 10);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* 禅道 .json 路由请求(响应信封: {status, data: "<再编码JSON字符串>", md5})
|
|
1885
|
+
* 会话过期判定(实测): 禅道返回 200 + JS 跳转页(非302), content-type 不可靠(正常响应也是 text/html),
|
|
1886
|
+
* 用 body 解析失败 / 含 user-login 判定
|
|
1887
|
+
*/
|
|
1888
|
+
function zentaoRequest({ route, zentaosid, qs }) {
|
|
1889
|
+
return new Promise((resolve, reject) => {
|
|
1890
|
+
request(
|
|
1891
|
+
{
|
|
1892
|
+
url: `${ZENTAO_BASE}/${route}.json`,
|
|
1893
|
+
method: "GET",
|
|
1894
|
+
headers: {
|
|
1895
|
+
Cookie: `zentaosid=${zentaosid}`,
|
|
1896
|
+
// ajax 标识, 禅道据此返回 JSON
|
|
1897
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
1898
|
+
},
|
|
1899
|
+
...(qs ? { qs } : {}),
|
|
1900
|
+
},
|
|
1901
|
+
(error, response, body) => {
|
|
1902
|
+
if (error) return reject(error);
|
|
1903
|
+
const text = String(body).replace(/^\uFEFF/, "");
|
|
1904
|
+
if (/^\s*<html/i.test(text)) {
|
|
1905
|
+
// 实测过期返回 200 + JS跳转页(<html><meta...><script>self.location='/zentao/user-login-...')
|
|
1906
|
+
// 不用 includes("user-login"): 防需求正文含该字样误判
|
|
1907
|
+
return reject(
|
|
1908
|
+
Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
let envelope;
|
|
1912
|
+
try {
|
|
1913
|
+
envelope = JSON.parse(text);
|
|
1914
|
+
} catch (e) {
|
|
1915
|
+
return reject(
|
|
1916
|
+
Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
if (envelope.status !== "success") {
|
|
1920
|
+
return reject(
|
|
1921
|
+
new Error(
|
|
1922
|
+
envelope.message || `禅道接口返回失败: ${envelope.status}`
|
|
1923
|
+
)
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1926
|
+
// 响应信封 data 是再编码的 JSON 字符串, 需二次 parse
|
|
1927
|
+
let data = envelope.data;
|
|
1928
|
+
if (typeof data === "string") {
|
|
1929
|
+
try {
|
|
1930
|
+
data = JSON.parse(data);
|
|
1931
|
+
} catch (e) {
|
|
1932
|
+
/* data 本身就是普通值 */
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
resolve(data);
|
|
1936
|
+
}
|
|
1937
|
+
);
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* 禅道 POST 请求(写操作, 实测 task-create 等创建类接口, 12.3.1):
|
|
1943
|
+
* - 响应信封是 {result: 'success'|'fail', message, locate}, 与 GET 的 {status, data} 不同
|
|
1944
|
+
* - message 校验失败时是对象(如 {estStarted: [『预计开始』不能为空。]}), 统一格式化为文本
|
|
1945
|
+
* - form 字段名必须对齐表单真实 name(如 assignedTo[]): 实测传多余字段(mailto/after/hiddenwin)
|
|
1946
|
+
* 或 desc 含 HTML 标签时, 会出现"返回保存成功但未落库"或空响应, 只传验证过的最小字段集
|
|
1947
|
+
* - 非JSON响应分类(实测禅道部分写接口成功也返回 JS跳转页而非JSON信封):
|
|
1948
|
+
* alert脚本=业务拦截(提取文案作真实错误) | 脚本location跳转: 跳登录页=会话失效, 跳其他页(如 task-view-x)=操作成功 | 其余带响应片段报错
|
|
1949
|
+
*/
|
|
1950
|
+
function zentaoPost({ route, zentaosid, form }) {
|
|
1951
|
+
return new Promise((resolve, reject) => {
|
|
1952
|
+
request(
|
|
1953
|
+
{
|
|
1954
|
+
url: `${ZENTAO_BASE}/${route}.json`,
|
|
1955
|
+
method: "POST",
|
|
1956
|
+
form,
|
|
1957
|
+
headers: {
|
|
1958
|
+
Cookie: `zentaosid=${zentaosid}`,
|
|
1959
|
+
// 不带此头返回 HTML 而非 JSON
|
|
1960
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
1961
|
+
// 模拟页面表单提交来源(实测带上更接近浏览器行为)
|
|
1962
|
+
Referer: `${ZENTAO_BASE}/${route}.html`,
|
|
1963
|
+
},
|
|
1964
|
+
},
|
|
1965
|
+
(error, _response, body) => {
|
|
1966
|
+
if (error) return reject(error);
|
|
1967
|
+
const text = String(body).replace(/^\uFEFF/, "");
|
|
1968
|
+
// 先尝试按JSON信封解析(<html开头的响应跳过解析, 走下方非JSON分类)
|
|
1969
|
+
let envelope = null;
|
|
1970
|
+
if (!/^\s*<html/i.test(text)) {
|
|
1971
|
+
try {
|
|
1972
|
+
envelope = JSON.parse(text);
|
|
1973
|
+
} catch (e) {
|
|
1974
|
+
/* 非JSON, 走下方非JSON分类 */
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
if (envelope && typeof envelope === "object") {
|
|
1978
|
+
if (envelope.result === "success") {
|
|
1979
|
+
return resolve(envelope);
|
|
1980
|
+
}
|
|
1981
|
+
const message = envelope.message;
|
|
1982
|
+
const detail =
|
|
1983
|
+
typeof message === "string"
|
|
1984
|
+
? message
|
|
1985
|
+
: Object.values(message || {})
|
|
1986
|
+
.map((v) => (Array.isArray(v) ? v.join("; ") : String(v)))
|
|
1987
|
+
.join("; ");
|
|
1988
|
+
return reject(new Error(detail || `禅道接口返回失败: ${JSON.stringify(envelope)}`));
|
|
1989
|
+
}
|
|
1990
|
+
// 非JSON响应分类:
|
|
1991
|
+
// 1) alert脚本: 业务规则拦截(如字段校验失败"本次消耗必须为数字"), 提取alert文案作为真实错误
|
|
1992
|
+
const alertMatch = text.match(/alert\((['"])([\s\S]*?)\1\)/);
|
|
1993
|
+
if (alertMatch) {
|
|
1994
|
+
return reject(new Error(alertMatch[2]));
|
|
1995
|
+
}
|
|
1996
|
+
// 2) 脚本location跳转: 跳登录页=会话失效; 跳其他页(如 task-view-x)=操作成功(旧式 die(js::locate) 成功响应)
|
|
1997
|
+
const locateMatch = text.match(
|
|
1998
|
+
/(?:parent|self|window|top)\.location(?:\.href)?\s*=\s*['"]([^'"]+)['"]/
|
|
1999
|
+
);
|
|
2000
|
+
if (locateMatch) {
|
|
2001
|
+
if (/user-login/.test(locateMatch[1])) {
|
|
2002
|
+
return reject(
|
|
2003
|
+
Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
return resolve({ result: "success", locate: locateMatch[1] });
|
|
2007
|
+
}
|
|
2008
|
+
// 3) 兜底登录页字样(无脚本跳转的登录页响应, POST响应不含用户数据可安全判定)
|
|
2009
|
+
if (/user-login/.test(text)) {
|
|
2010
|
+
return reject(
|
|
2011
|
+
Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
// 4) 其余异常: 带响应片段便于定位
|
|
2015
|
+
return reject(
|
|
2016
|
+
new Error(`禅道POST响应异常(非JSON): ${text.slice(0, 200)}`)
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
);
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
/**
|
|
2024
|
+
* 禅道三步登录(实测协议, 12.3.1):
|
|
2025
|
+
* 1. GET 登录页建立会话(下发 zentaosid, 登录前后不变) + 提取 verifyRand(每会话不同)
|
|
2026
|
+
* 2. 计算 password = md5(md5(明文) + verifyRand)
|
|
2027
|
+
* 3. POST 七字段表单(带 X-Requested-With 才返回 JSON), 成功判定 result === 'success'
|
|
2028
|
+
*/
|
|
2029
|
+
function zentaoLogin({ account, password }) {
|
|
2030
|
+
Loading$1.start("登录中...");
|
|
2031
|
+
// 第1步: GET 登录页, 提取 zentaosid + verifyRand
|
|
2032
|
+
const pre = new Promise((resolve, reject) => {
|
|
2033
|
+
request(
|
|
2034
|
+
{ url: `${ZENTAO_BASE}/user-login.html`, method: "GET" },
|
|
2035
|
+
(error, response, body) => {
|
|
2036
|
+
if (error) return reject(error);
|
|
2037
|
+
const setCookies = response.headers["set-cookie"] || [];
|
|
2038
|
+
const sidMatch = setCookies
|
|
2039
|
+
.join(";")
|
|
2040
|
+
.match(/zentaosid=([^;]+)/);
|
|
2041
|
+
const randMatch = String(body).match(
|
|
2042
|
+
/name='verifyRand'[^>]*value='(\d+)'/
|
|
2043
|
+
);
|
|
2044
|
+
if (!sidMatch || !randMatch) {
|
|
2045
|
+
return reject(new Error("解析登录页失败(未找到 zentaosid/verifyRand)"));
|
|
2046
|
+
}
|
|
2047
|
+
resolve({ zentaosid: sidMatch[1], verifyRand: randMatch[1] });
|
|
2048
|
+
}
|
|
2049
|
+
);
|
|
2050
|
+
});
|
|
2051
|
+
// 第2/3步: 计算哈希并 POST(POST 须带会话 cookie, verifyRand 与会话绑定)
|
|
2052
|
+
return pre.then(async ({ zentaosid, verifyRand }) => {
|
|
2053
|
+
const result = await new Promise((resolve, reject) => {
|
|
2054
|
+
request(
|
|
2055
|
+
{
|
|
2056
|
+
url: `${ZENTAO_BASE}/user-login.html`,
|
|
2057
|
+
method: "POST",
|
|
2058
|
+
form: {
|
|
2059
|
+
account,
|
|
2060
|
+
password: md5$1(md5$1(password) + verifyRand),
|
|
2061
|
+
pww: password, // 服务端强度校验用, 原样上送
|
|
2062
|
+
passwordStrength: computePasswordStrength(password),
|
|
2063
|
+
referer: "",
|
|
2064
|
+
verifyRand,
|
|
2065
|
+
keepLogin: "0",
|
|
2066
|
+
},
|
|
2067
|
+
headers: {
|
|
2068
|
+
Cookie: `zentaosid=${zentaosid}`,
|
|
2069
|
+
// 不带此头返回 HTML 而非 JSON
|
|
2070
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
2071
|
+
},
|
|
2072
|
+
},
|
|
2073
|
+
(error, _response, body) => {
|
|
2074
|
+
if (error) return reject(error);
|
|
2075
|
+
try {
|
|
2076
|
+
resolve(JSON.parse(String(body).replace(/^\uFEFF/, "")));
|
|
2077
|
+
} catch (e) {
|
|
2078
|
+
reject(new Error("登录响应解析失败"));
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
);
|
|
2082
|
+
});
|
|
2083
|
+
if (result.result === "success") {
|
|
2084
|
+
Loading$1.succeed("登录成功");
|
|
2085
|
+
// zentaosid 在 GET 那步就下发且登录后不变, 成功判定看 result
|
|
2086
|
+
writeZentaoCache({ account, password, zentaosid });
|
|
2087
|
+
return zentaosid;
|
|
2088
|
+
}
|
|
2089
|
+
Loading$1.fail("登录失败");
|
|
2090
|
+
throw new Error(`登录失败: ${result.message || "请检查账号密码"}`);
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// 凭据解析(CLI参数 > --cache缓存 > 交互), login 与无会话时的 plan/detail 共用
|
|
2095
|
+
function askZentaoCredentials(cliOpts) {
|
|
2096
|
+
return resolveSecretQuestions({
|
|
2097
|
+
questions: [
|
|
2098
|
+
{
|
|
2099
|
+
message: "请输入禅道账号",
|
|
2100
|
+
name: "account",
|
|
2101
|
+
type: "input",
|
|
2102
|
+
validate: (val) => Boolean(val) || "请输入禅道账号",
|
|
2103
|
+
},
|
|
2104
|
+
{
|
|
2105
|
+
message: "请输入禅道密码",
|
|
2106
|
+
name: "password",
|
|
2107
|
+
type: "password",
|
|
2108
|
+
mask: "*",
|
|
2109
|
+
validate: (val) => Boolean(val) || "请输入禅道密码",
|
|
2110
|
+
},
|
|
2111
|
+
],
|
|
2112
|
+
cliValues: {
|
|
2113
|
+
account: cliOpts.username,
|
|
2114
|
+
password: cliOpts.password,
|
|
2115
|
+
},
|
|
2116
|
+
useCache: Boolean(cliOpts.cache),
|
|
2117
|
+
cacheKey: CACHE_KEY,
|
|
2118
|
+
}).then((secret) => {
|
|
2119
|
+
// resolveSecretQuestions 会整文件覆盖回写缓存(只剩凭据字段, 发生在登录之前),
|
|
2120
|
+
// 合并写补回 zentaosid 等字段, 防止登录失败时已缓存的有效会话被抹掉
|
|
2121
|
+
writeZentaoCache(secret);
|
|
2122
|
+
return secret;
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/**
|
|
2127
|
+
* 会话管理(参考 createAuth): 优先缓存 zentaosid, isAuthError 时用凭据自动重登并重试一次
|
|
2128
|
+
* (重登即重走三步——旧会话已失效, 必须新 GET 拿新 verifyRand)
|
|
2129
|
+
*/
|
|
2130
|
+
async function ensureZentaoSession(cliOpts) {
|
|
2131
|
+
const cache = readZentaoCache();
|
|
2132
|
+
// 重登凭据优先级: CLI 参数 > 缓存
|
|
2133
|
+
const credentials = {
|
|
2134
|
+
account: cliOpts.username || cache.account,
|
|
2135
|
+
password: cliOpts.password || cache.password,
|
|
2136
|
+
};
|
|
2137
|
+
let zentaosid = cache.zentaosid;
|
|
2138
|
+
if (!zentaosid) {
|
|
2139
|
+
const secret = await askZentaoCredentials(cliOpts);
|
|
2140
|
+
zentaosid = await zentaoLogin(secret);
|
|
2141
|
+
}
|
|
2142
|
+
let relogging = null; // 并发重登去重
|
|
2143
|
+
return {
|
|
2144
|
+
async request(fn) {
|
|
2145
|
+
try {
|
|
2146
|
+
return await fn(zentaosid);
|
|
2147
|
+
} catch (e) {
|
|
2148
|
+
if (!e?.isAuthError) throw e;
|
|
2149
|
+
if (!credentials.account || !credentials.password) {
|
|
2150
|
+
console.log(
|
|
2151
|
+
chalk.red(
|
|
2152
|
+
"禅道会话已失效且无缓存凭据, 请先执行: fe-it-beta story login"
|
|
2153
|
+
)
|
|
2154
|
+
);
|
|
2155
|
+
process.exit(1);
|
|
2156
|
+
}
|
|
2157
|
+
if (!relogging) {
|
|
2158
|
+
relogging = (async () => {
|
|
2159
|
+
console.log(chalk.yellow("禅道会话已失效, 自动重新登录..."));
|
|
2160
|
+
zentaosid = await zentaoLogin(credentials);
|
|
2161
|
+
})().catch((e) => {
|
|
2162
|
+
// 重登失败复位, 同进程后续请求仍可再次触发重登
|
|
2163
|
+
relogging = null;
|
|
2164
|
+
throw e;
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
await relogging;
|
|
2168
|
+
relogging = null;
|
|
2169
|
+
// 重试不再捕获, 再失败直接抛出, 避免无限循环
|
|
2170
|
+
return await fn(zentaosid);
|
|
2171
|
+
}
|
|
2172
|
+
},
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// 拉产品映射: product-all.json -> data.products = { 产品ID: 产品名 } (实测104个)
|
|
2177
|
+
function fetchProductMap(zentaosid) {
|
|
2178
|
+
return zentaoRequest({ route: "product-all", zentaosid }).then(
|
|
2179
|
+
(data) => data.products || {}
|
|
2180
|
+
);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// 校验产品ID并返回 {id, name}, 同时存入缓存历史(最近使用的排最前, 去重)
|
|
2184
|
+
async function resolveProduct(zentaosid, productID) {
|
|
2185
|
+
productID = String(productID).trim();
|
|
2186
|
+
Loading$1.start("校验产品中...");
|
|
2187
|
+
const products = await fetchProductMap(zentaosid);
|
|
2188
|
+
const name = products[productID];
|
|
2189
|
+
if (!name) {
|
|
2190
|
+
Loading$1.fail("产品不存在");
|
|
2191
|
+
throw new Error(`产品ID ${productID} 不存在, 请检查后重试`);
|
|
2192
|
+
}
|
|
2193
|
+
Loading$1.succeed(`产品: #${productID} ${name}`);
|
|
2194
|
+
const history = readZentaoCache().products || [];
|
|
2195
|
+
const rest = history.filter((p) => p.id !== productID);
|
|
2196
|
+
writeZentaoCache({ products: [{ id: productID, name }, ...rest] });
|
|
2197
|
+
return { id: productID, name };
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// 产品选择(plan/pull共用): --product参数直通 > 历史缓存选择/手动输入
|
|
2201
|
+
async function selectProduct(zentaosid, cliOpts) {
|
|
2202
|
+
const cliProduct = cliOpts.product && String(cliOpts.product).trim();
|
|
2203
|
+
if (cliProduct) {
|
|
2204
|
+
return resolveProduct(zentaosid, cliProduct);
|
|
2205
|
+
}
|
|
2206
|
+
const history = readZentaoCache().products || [];
|
|
2207
|
+
if (history.length) {
|
|
2208
|
+
const { choice } = await inquirer.prompt([
|
|
2209
|
+
{
|
|
2210
|
+
message: "请选择产品",
|
|
2211
|
+
name: "choice",
|
|
2212
|
+
type: "rawlist",
|
|
2213
|
+
choices: [
|
|
2214
|
+
...history.map((p) => ({
|
|
2215
|
+
name: `#${p.id} ${p.name}`,
|
|
2216
|
+
value: p.id,
|
|
2217
|
+
})),
|
|
2218
|
+
{ name: "输入其他产品ID...", value: "__INPUT__" },
|
|
2219
|
+
],
|
|
2220
|
+
},
|
|
2221
|
+
]);
|
|
2222
|
+
if (choice !== "__INPUT__") {
|
|
2223
|
+
const cached = history.find((p) => p.id === choice);
|
|
2224
|
+
return cached; // 历史产品名称已缓存, 直接用
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
const { id } = await inquirer.prompt([
|
|
2228
|
+
{
|
|
2229
|
+
message: "请输入产品ID",
|
|
2230
|
+
name: "id",
|
|
2231
|
+
type: "input",
|
|
2232
|
+
validate: (val) =>
|
|
2233
|
+
/^\d+$/.test(String(val).trim()) || "请输入数字产品ID",
|
|
2234
|
+
},
|
|
2235
|
+
]);
|
|
2236
|
+
return resolveProduct(zentaosid, id);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
// 拉计划列表第1页(默认20条, begin倒序最新在前, 用户指定只取1页)
|
|
2240
|
+
function fetchPlanList(zentaosid, productID) {
|
|
2241
|
+
return zentaoRequest({
|
|
2242
|
+
route: `productplan-browse-${productID}`,
|
|
2243
|
+
zentaosid,
|
|
2244
|
+
}).then((data) => Object.values(data.plans || {}));
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// 拉计划详情: plan + planStories(全量无分页) + products(产品ID->名称映射)
|
|
2248
|
+
function fetchPlanDetail(zentaosid, planID) {
|
|
2249
|
+
return zentaoRequest({
|
|
2250
|
+
route: `productplan-view-${planID}`,
|
|
2251
|
+
zentaosid,
|
|
2252
|
+
}).then((data) => ({
|
|
2253
|
+
plan: data.plan,
|
|
2254
|
+
stories: Object.values(data.planStories || {}),
|
|
2255
|
+
products: data.products || {},
|
|
2256
|
+
}));
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// 拉需求详情: story(全字段, spec/verify 为HTML) + users(用户名映射)
|
|
2260
|
+
function fetchStoryDetail(zentaosid, storyID) {
|
|
2261
|
+
return zentaoRequest({ route: `story-view-${storyID}`, zentaosid }).then(
|
|
2262
|
+
(data) => ({
|
|
2263
|
+
story: data.story,
|
|
2264
|
+
users: data.users || {},
|
|
2265
|
+
})
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
// 拉全量项目映射: project-all.json -> data.projects = { 项目ID: 项目名 } (实测2884个, task入参校验用)
|
|
2270
|
+
function fetchProjectMap(zentaosid) {
|
|
2271
|
+
return zentaoRequest({ route: "project-all", zentaosid }).then(
|
|
2272
|
+
(data) => data.projects || {}
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// 拉项目需求列表: project(项目信息) + stories(需求)
|
|
2277
|
+
function fetchProjectStories(zentaosid, projectID) {
|
|
2278
|
+
return zentaoRequest({
|
|
2279
|
+
route: `project-story-${projectID}`,
|
|
2280
|
+
zentaosid,
|
|
2281
|
+
}).then((data) => ({
|
|
2282
|
+
project: data.project,
|
|
2283
|
+
stories: Object.values(data.stories || {}),
|
|
2284
|
+
}));
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// 拉项目任务列表(全部状态, 不含已删除; 供勾选时统计已建任务数 + 创建前后 diff 拿新任务ID)
|
|
2288
|
+
// 注1: 不用禅道 storyWebTasks/storyDevTasks 计数——软删除(回收站)的任务也计入, 数据不准(实测)
|
|
2289
|
+
// 注2: pager.recTotal 同样含回收站任务(返回数恒小于 recTotal 属正常), 翻页判定用 pageTotal
|
|
2290
|
+
// 注3: 子任务不出现在任务列表(挂在父任务 children 下), 子任务统计须逐任务拉 task-view
|
|
2291
|
+
function fetchProjectTasks(zentaosid, projectID) {
|
|
2292
|
+
return zentaoRequest({
|
|
2293
|
+
route: `project-task-${projectID}-all`,
|
|
2294
|
+
zentaosid,
|
|
2295
|
+
}).then((data) => ({
|
|
2296
|
+
tasks: Object.values(data.tasks || {}),
|
|
2297
|
+
pageTotal: Number(data.pager?.pageTotal || 1),
|
|
2298
|
+
}));
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// 拉单个任务详情(含children子任务映射, subtask命令与子任务统计用)
|
|
2302
|
+
function fetchTaskDetail(zentaosid, taskID) {
|
|
2303
|
+
return zentaoRequest({ route: `task-view-${taskID}`, zentaosid }).then(
|
|
2304
|
+
(data) => data.task
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
// 拉项目中指派给登录账号的任务列表(myinvolved页, close命令用)
|
|
2309
|
+
// 注: 与任务列表一致, 子任务不出现在列表(挂父任务children下), 父任务需逐个拉详情拿子任务
|
|
2310
|
+
function fetchMyInvolvedTasks(zentaosid, projectID) {
|
|
2311
|
+
return zentaoRequest({
|
|
2312
|
+
route: `project-task-${projectID}-myinvolved`,
|
|
2313
|
+
zentaosid,
|
|
2314
|
+
}).then((data) => ({
|
|
2315
|
+
tasks: Object.values(data.tasks || {}),
|
|
2316
|
+
pageTotal: Number(data.pager?.pageTotal || 1),
|
|
2317
|
+
}));
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/**
|
|
2321
|
+
* 在项目下为需求创建任务(实测协议, 12.3.1): POST task-create-{projectID}-{storyID}.json
|
|
2322
|
+
* - estStarted/deadline 必填(缺了返回 fail + 字段错误提示)
|
|
2323
|
+
* - 指派字段名是 assignedTo[](数组形式)
|
|
2324
|
+
* - desc 用纯文本(实测含 HTML 标签时会出现"返回保存成功但未落库")
|
|
2325
|
+
* - type 值(表单选项实测): frontend=前端开发, devel=后端开发
|
|
2326
|
+
* - 成功响应 {result:'success'} 不含新任务ID, 由调用方重拉任务列表 diff 获取
|
|
2327
|
+
*/
|
|
2328
|
+
function createZentaoTask({ zentaosid, projectID, story, name, type, assignedTo, estimate, estStarted, deadline }) {
|
|
2329
|
+
return zentaoPost({
|
|
2330
|
+
route: `task-create-${projectID}-${story.id}`,
|
|
2331
|
+
zentaosid,
|
|
2332
|
+
form: {
|
|
2333
|
+
module: "0",
|
|
2334
|
+
"assignedTo[]": assignedTo,
|
|
2335
|
+
name,
|
|
2336
|
+
story: story.id,
|
|
2337
|
+
type,
|
|
2338
|
+
pri: String(story.pri || 3),
|
|
2339
|
+
estimate: String(estimate),
|
|
2340
|
+
// 纯文本描述: 附需求url便于任务页跳回
|
|
2341
|
+
desc: `需求: ${storyUrl(story.id)}`,
|
|
2342
|
+
estStarted,
|
|
2343
|
+
deadline,
|
|
2344
|
+
uid: String(Date.now()),
|
|
2345
|
+
},
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
// 拼任务url(创建成功后输出)
|
|
2350
|
+
function taskUrl(taskID) {
|
|
2351
|
+
return `${ZENTAO_BASE}/task-view-${taskID}.html`;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/**
|
|
2355
|
+
* 子任务拆分方案(用户确认规则):
|
|
2356
|
+
* - 工时>8 才拆; 每个子任务 8h, 最后一个补余数(如 20h -> 8/8/4)
|
|
2357
|
+
* - 后端: 名称-1、名称-2...(序号从1递增)
|
|
2358
|
+
* - 前端: 前端开发 = ceil(N/2) 个排前(整8h), 联调 = floor(N/2) 个在后(余数落最后);
|
|
2359
|
+
* 同类>1个带序号(前端开发1..n), 单个不带(16h -> 名称-前端开发 / 名称-联调)
|
|
2360
|
+
* - 实测样例: 16h->1+1, 24h->2+1, 40h->3+2, 48h->3+3
|
|
2361
|
+
*/
|
|
2362
|
+
function splitTaskPlan(name, hours, taskType) {
|
|
2363
|
+
const total = Number(hours);
|
|
2364
|
+
const count = Math.ceil(total / 8);
|
|
2365
|
+
const lastEstimate = String(total - 8 * (count - 1));
|
|
2366
|
+
if (taskType === "back") {
|
|
2367
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
2368
|
+
name: `${name}-${i + 1}`,
|
|
2369
|
+
estimate: i === count - 1 ? lastEstimate : "8",
|
|
2370
|
+
}));
|
|
2371
|
+
}
|
|
2372
|
+
const devCount = Math.ceil(count / 2);
|
|
2373
|
+
const qaCount = count - devCount;
|
|
2374
|
+
const subs = [];
|
|
2375
|
+
for (let i = 0; i < devCount; i++) {
|
|
2376
|
+
subs.push({
|
|
2377
|
+
name: `${name}-前端开发${devCount > 1 ? i + 1 : ""}`,
|
|
2378
|
+
// 前端开发整8h(排前), 余数由最后一个联调兜
|
|
2379
|
+
estimate: "8",
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
for (let i = 0; i < qaCount; i++) {
|
|
2383
|
+
subs.push({
|
|
2384
|
+
name: `${name}-联调${qaCount > 1 ? i + 1 : ""}`,
|
|
2385
|
+
estimate: i === qaCount - 1 ? lastEstimate : "8",
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
return subs;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/**
|
|
2392
|
+
* 批量创建子任务(实测表单结构, 12.3.1): POST task-batchCreate-{projectID}-{storyID}-0-{parentTaskID}.json
|
|
2393
|
+
* - 数组字段: module[i]/parent[i]/story[i]/name[i]/type[i]/assignedTo[i]/estimate[i]/estStarted[i]/deadline[i]/pri[i]
|
|
2394
|
+
* - 指派字段名是 assignedTo[i](非 assignedTo[][i], 与单任务创建的 assignedTo[] 不同)
|
|
2395
|
+
* - 只传验证过的最小字段集(对齐 task-create 的实测教训: 多余字段致空响应/假成功)
|
|
2396
|
+
*/
|
|
2397
|
+
function createZentaoSubtasks({ zentaosid, projectID, story, parentTaskID, type, assignedTo, estStarted, deadline, subs }) {
|
|
2398
|
+
const form = {};
|
|
2399
|
+
subs.forEach((sub, i) => {
|
|
2400
|
+
form[`module[${i}]`] = "0";
|
|
2401
|
+
form[`parent[${i}]`] = String(parentTaskID);
|
|
2402
|
+
form[`story[${i}]`] = String(story.id);
|
|
2403
|
+
form[`name[${i}]`] = sub.name;
|
|
2404
|
+
form[`type[${i}]`] = type;
|
|
2405
|
+
form[`assignedTo[${i}]`] = assignedTo;
|
|
2406
|
+
form[`estimate[${i}]`] = String(sub.estimate);
|
|
2407
|
+
form[`estStarted[${i}]`] = estStarted;
|
|
2408
|
+
form[`deadline[${i}]`] = deadline;
|
|
2409
|
+
form[`pri[${i}]`] = String(story.pri || 3);
|
|
2410
|
+
});
|
|
2411
|
+
return zentaoPost({
|
|
2412
|
+
route: `task-batchCreate-${projectID}-${story.id}-0-${parentTaskID}`,
|
|
2413
|
+
zentaosid,
|
|
2414
|
+
form,
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
/**
|
|
2419
|
+
* 完成任务: POST task-finish-{taskID}.json (close 命令用)
|
|
2420
|
+
* - "本次消耗"字段名实测为 currentConsumed(本次增量, 服务端自动累计到总消耗);
|
|
2421
|
+
* 传 consumed 会报 ""本次消耗"必须为数字"(该字段没上送被校验为空)
|
|
2422
|
+
* - 完成人/指派人字段不传: 禅道服务端完成任务时强制置 finishedBy=当前登录人、
|
|
2423
|
+
* assignedTo='closed'(页面"指派给"列显示 Closed), 传了也会被覆盖, 不如不传
|
|
2424
|
+
* - 完成后状态置 done; 已 done 的无需 finish 直接 close
|
|
2425
|
+
*/
|
|
2426
|
+
function finishZentaoTask({ zentaosid, taskID, consumed }) {
|
|
2427
|
+
return zentaoPost({
|
|
2428
|
+
route: `task-finish-${taskID}`,
|
|
2429
|
+
zentaosid,
|
|
2430
|
+
form: {
|
|
2431
|
+
currentConsumed: String(Number(consumed) || 0),
|
|
2432
|
+
comment: "",
|
|
2433
|
+
uid: String(Date.now()),
|
|
2434
|
+
},
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// 关闭任务: POST task-close-{taskID}.json, 只传 comment 最小字段集(close 命令用)
|
|
2439
|
+
function closeZentaoTask({ zentaosid, taskID }) {
|
|
2440
|
+
return zentaoPost({
|
|
2441
|
+
route: `task-close-${taskID}`,
|
|
2442
|
+
zentaosid,
|
|
2443
|
+
form: {
|
|
2444
|
+
comment: "",
|
|
2445
|
+
uid: String(Date.now()),
|
|
2446
|
+
},
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// 本地时区当天日期(YYYY-MM-DD, 任务estStarted用; toISOString是UTC会差8小时)
|
|
2451
|
+
function localToday() {
|
|
2452
|
+
const d = new Date();
|
|
2453
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// 拼需求url(输出给 story detail 用)
|
|
2457
|
+
function storyUrl(storyID) {
|
|
2458
|
+
return `${ZENTAO_BASE}/story-view-${storyID}.html`;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
// 禅道域名(从ZENTAO_BASE派生, 图片等静态资源挂根路径)
|
|
2462
|
+
const ZENTAO_ORIGIN = ZENTAO_BASE.replace(/\/zentao$/, "");
|
|
2463
|
+
|
|
2464
|
+
// 需求HTML里的相对路径转绝对url(如 /zentao/file-read-xxx.png)
|
|
2465
|
+
function absoluteZentaoUrl(src) {
|
|
2466
|
+
const s = String(src || "");
|
|
2467
|
+
if (/^https?:\/\//i.test(s)) return s;
|
|
2468
|
+
return `${ZENTAO_ORIGIN}${s.startsWith("/") ? "" : "/"}${s}`;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// 表格块转行文本: 每个<tr>一行, 单元格间 " | " 分隔, 单元格内换行/段落折叠为单空格
|
|
2472
|
+
// (保住字段映射表的列对应关系; 不支持嵌套表格, 禅道需求里无此结构)
|
|
2473
|
+
function tableToText(tableHtml) {
|
|
2474
|
+
const rows = tableHtml.match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
|
2475
|
+
return rows
|
|
2476
|
+
.map((row) =>
|
|
2477
|
+
(row.match(/<t[dh][^>]*>[\s\S]*?<\/t[dh]>/gi) || [])
|
|
2478
|
+
.map((cell) =>
|
|
2479
|
+
cell
|
|
2480
|
+
.replace(/<br\s*\/?>/gi, " ")
|
|
2481
|
+
.replace(/<[^>]+>/g, " ")
|
|
2482
|
+
.replace(/ /gi, " ")
|
|
2483
|
+
.replace(/</gi, "<")
|
|
2484
|
+
.replace(/>/gi, ">")
|
|
2485
|
+
.replace(/"/gi, '"')
|
|
2486
|
+
.replace(/&/gi, "&")
|
|
2487
|
+
.replace(/\s+/g, " ")
|
|
2488
|
+
.trim()
|
|
2489
|
+
)
|
|
2490
|
+
.join(" | ")
|
|
2491
|
+
)
|
|
2492
|
+
.filter(Boolean)
|
|
2493
|
+
.join("\n");
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// HTML转纯文本(spec/verify 为HTML, detail与pull共用)
|
|
2497
|
+
// imgHref: img标签src的转换函数(传了则img转markdown图片语法引用完整url——实测file-read无需登录态; 不传则丢弃img)
|
|
2498
|
+
function htmlToText(html, imgHref) {
|
|
2499
|
+
let text = String(html || "");
|
|
2500
|
+
if (imgHref) {
|
|
2501
|
+
// img转markdown图片(先于去标签处理, 防图片信息丢失)
|
|
2502
|
+
text = text.replace(
|
|
2503
|
+
/<img[^>]*\bsrc=['"]([^'"]+)['"][^>]*\/?>/gi,
|
|
2504
|
+
(_m, src) => `\n})\n`
|
|
2505
|
+
);
|
|
2506
|
+
} else {
|
|
2507
|
+
text = text.replace(/<img[^>]*>/gi, "");
|
|
2508
|
+
}
|
|
2509
|
+
// 表格块整块转行文本(先于通用去标签): 单元格内<p>/<br>折叠为空格, 防" | "落进换行中间
|
|
2510
|
+
text = text.replace(
|
|
2511
|
+
/<table[\s\S]*?<\/table>/gi,
|
|
2512
|
+
(m) => "\n" + tableToText(m) + "\n"
|
|
2513
|
+
);
|
|
2514
|
+
return text
|
|
2515
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
2516
|
+
.replace(/<\/p>/gi, "\n\n")
|
|
2517
|
+
.replace(/<[^>]+>/g, "")
|
|
2518
|
+
.replace(/ /gi, " ")
|
|
2519
|
+
.replace(/</gi, "<")
|
|
2520
|
+
.replace(/>/gi, ">")
|
|
2521
|
+
.replace(/"/gi, '"')
|
|
2522
|
+
.replace(/&/gi, "&")
|
|
2523
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
2524
|
+
.trim();
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
// 需求涉及的项目名(供AI定位要改的代码仓), 跟随导出范围:
|
|
2528
|
+
// front=只前端pool截取(fe-xxx-vue->xxx) | back=只后端pool原名 | all=全部
|
|
2529
|
+
function storyProjectsOf(story, scope) {
|
|
2530
|
+
return parsePoolList(story.poollist)
|
|
2531
|
+
.map((pool) => {
|
|
2532
|
+
const frontProject = frontProjectOf(pool);
|
|
2533
|
+
if (scope === "front") return frontProject; // 非前端pool返回null被过滤
|
|
2534
|
+
if (scope === "back") return frontProject ? null : pool; // 后端pool原名
|
|
2535
|
+
return frontProject || pool;
|
|
2536
|
+
})
|
|
2537
|
+
.filter(Boolean);
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// 任务目录名: {storyID}-{title截断50 + 过滤文件系统非法字符(含首尾空格与点)}
|
|
2541
|
+
function taskDirOf(story) {
|
|
2542
|
+
const title = String(story.title || "")
|
|
2543
|
+
.slice(0, 50)
|
|
2544
|
+
.replace(/[\\/:*?"<>|]/g, "")
|
|
2545
|
+
.trim()
|
|
2546
|
+
.replace(/^[.\s]+|[.\s]+$/g, "");
|
|
2547
|
+
return `${story.id}-${title}`;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// 需求目录名: .zentao-<计划名>(多计划互不混目录); 计划名过滤文件系统非法字符, 空则退回计划id
|
|
2551
|
+
function zentaoDirNameOf(plan) {
|
|
2552
|
+
const name = String((plan && plan.title) || "")
|
|
2553
|
+
.slice(0, 50)
|
|
2554
|
+
.replace(/[\\/:*?"<>|]/g, "")
|
|
2555
|
+
.trim()
|
|
2556
|
+
.replace(/^[.\s]+|[.\s]+$/g, "");
|
|
2557
|
+
return `.zentao-${name || (plan && plan.id) || "untitled"}`;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// 下载禅道静态资源(图片), encoding:null 取Buffer; 带会话Cookie(实测file-read无需登录态, 带上更稳)
|
|
2561
|
+
function fetchZentaoImage(url, zentaosid) {
|
|
2562
|
+
return new Promise((resolve, reject) => {
|
|
2563
|
+
request(
|
|
2564
|
+
{
|
|
2565
|
+
url,
|
|
2566
|
+
method: "GET",
|
|
2567
|
+
encoding: null,
|
|
2568
|
+
headers: { Cookie: `zentaosid=${zentaosid}` },
|
|
2569
|
+
},
|
|
2570
|
+
(error, response, body) => {
|
|
2571
|
+
if (error) return reject(error);
|
|
2572
|
+
if (response.statusCode !== 200) {
|
|
2573
|
+
return reject(new Error(`HTTP ${response.statusCode}`));
|
|
2574
|
+
}
|
|
2575
|
+
resolve(body);
|
|
2576
|
+
}
|
|
2577
|
+
);
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
// 提取HTML里全部img的src(与htmlToText的img处理用同一正则)
|
|
2582
|
+
function extractImgSrcs(html) {
|
|
2583
|
+
const srcs = [];
|
|
2584
|
+
String(html || "").replace(
|
|
2585
|
+
/<img[^>]*\bsrc=['"]([^'"]+)['"][^>]*\/?>/gi,
|
|
2586
|
+
(_m, src) => {
|
|
2587
|
+
srcs.push(src);
|
|
2588
|
+
return _m;
|
|
2589
|
+
}
|
|
2590
|
+
);
|
|
2591
|
+
return srcs;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// 图片本地文件名: 取url的basename, 去query, 过滤路径穿越与文件系统非法字符
|
|
2595
|
+
function imageFileNameOf(src) {
|
|
2596
|
+
const base =
|
|
2597
|
+
String(src).split("?")[0].split("/").pop().replace(/[\\/:*?"<>|]/g, "_");
|
|
2598
|
+
return base && !/^\.+$/.test(base) ? base : "image";
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
/**
|
|
2602
|
+
* 需求图片本地化: 提取 spec/verify 的 img src, 并发限流(复用PULL_CONCURRENCY worker池)
|
|
2603
|
+
* 下载到 <zentaoDir>/tasks/<taskDir>/images/(同名taskDir已由调用方先删除, 全量重下),
|
|
2604
|
+
* 返回 Map<storyID, Map<src, 'images/<文件名>'>>(下载失败的src不进映射, task.md引用降级远程url)
|
|
2605
|
+
*/
|
|
2606
|
+
async function downloadStoryImages({ stories, storyDetails, zentaosid, zentaoDir }) {
|
|
2607
|
+
const tasksRoot = path.join(zentaoDir, "tasks");
|
|
2608
|
+
// 平铺任务(与拉详情同模式的并发限流): {story, src}
|
|
2609
|
+
const jobs = [];
|
|
2610
|
+
for (const story of stories) {
|
|
2611
|
+
const detail = storyDetails.get(String(story.id));
|
|
2612
|
+
if (!detail) continue;
|
|
2613
|
+
const srcs = extractImgSrcs(detail.story.spec).concat(
|
|
2614
|
+
extractImgSrcs(detail.story.verify)
|
|
2615
|
+
);
|
|
2616
|
+
for (const src of srcs) jobs.push({ story, src });
|
|
2617
|
+
}
|
|
2618
|
+
const result = new Map();
|
|
2619
|
+
const setMapping = (storyID, src, ref) => {
|
|
2620
|
+
let mapping = result.get(String(storyID));
|
|
2621
|
+
if (!mapping) {
|
|
2622
|
+
mapping = new Map();
|
|
2623
|
+
result.set(String(storyID), mapping);
|
|
2624
|
+
}
|
|
2625
|
+
mapping.set(src, ref);
|
|
2626
|
+
};
|
|
2627
|
+
let index = 0;
|
|
2628
|
+
let imgDone = 0;
|
|
2629
|
+
const failedLogs = [];
|
|
2630
|
+
if (jobs.length) {
|
|
2631
|
+
Loading$1.start(`下载需求图片 (0/${jobs.length})...`);
|
|
2632
|
+
}
|
|
2633
|
+
const worker = async () => {
|
|
2634
|
+
while (index < jobs.length) {
|
|
2635
|
+
const { story, src } = jobs[index];
|
|
2636
|
+
index += 1;
|
|
2637
|
+
const fileName = imageFileNameOf(src);
|
|
2638
|
+
const target = path.join(tasksRoot, taskDirOf(story), "images", fileName);
|
|
2639
|
+
try {
|
|
2640
|
+
const buf = await fetchZentaoImage(absoluteZentaoUrl(src), zentaosid);
|
|
2641
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
2642
|
+
fs.writeFileSync(target, buf);
|
|
2643
|
+
setMapping(story.id, src, `images/${fileName}`);
|
|
2644
|
+
} catch (e) {
|
|
2645
|
+
// 失败提示延后到spinner结束统一输出(避免与spinner交错)
|
|
2646
|
+
failedLogs.push(
|
|
2647
|
+
`需求 ${story.id} 图片下载失败(${src}): ${e.message}, 该图保留远程链接`
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
imgDone += 1;
|
|
2651
|
+
// ora 的 text 是属性而非方法
|
|
2652
|
+
Loading$1.text = `下载需求图片 (${imgDone}/${jobs.length})...`;
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2655
|
+
await Promise.all(
|
|
2656
|
+
Array.from({ length: PULL_CONCURRENCY }, () => worker())
|
|
2657
|
+
);
|
|
2658
|
+
if (jobs.length) {
|
|
2659
|
+
Loading$1.succeed(
|
|
2660
|
+
`下载需求图片完成(${jobs.length - failedLogs.length}/${jobs.length})`
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
failedLogs.forEach((line) => console.log(chalk.yellow(line)));
|
|
2664
|
+
return result;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// 解析poollist为pool数组(实测为逗号分隔字符串, 值可能含制表符)
|
|
2668
|
+
function parsePoolList(poollist) {
|
|
2669
|
+
return String(poollist || "")
|
|
2670
|
+
.split(/[,\t\n]/)
|
|
2671
|
+
.map((p) => p.trim())
|
|
2672
|
+
.filter(Boolean);
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
// 前端pool转项目名: fe-cost-order-pc-vue -> cost-order-pc (前端pool均为fe-开头-vue结尾)
|
|
2676
|
+
function frontProjectOf(pool) {
|
|
2677
|
+
if (/^fe-.+-vue$/.test(pool)) {
|
|
2678
|
+
return pool.replace(/^fe-/, "").replace(/-vue$/, "");
|
|
2679
|
+
}
|
|
2680
|
+
return null;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// 汇总需求的pool项目名(按类型): 前端=fe-xxx-vue截取xxx, 后端=非fe-开头pool原名不截取
|
|
2684
|
+
// type: 'front'取有前端工时需求的前端pool | 'back'取有后端工时需求的后端pool
|
|
2685
|
+
function collectPoolProjects(stories, type) {
|
|
2686
|
+
const projects = new Set();
|
|
2687
|
+
stories.forEach((s) => {
|
|
2688
|
+
const isFront = Number(s.webestimate) > 0;
|
|
2689
|
+
const isBack = Number(s.estimate) > 0;
|
|
2690
|
+
if (type === "front" && !isFront) return;
|
|
2691
|
+
if (type === "back" && !isBack) return;
|
|
2692
|
+
parsePoolList(s.poollist).forEach((pool) => {
|
|
2693
|
+
if (type === "front") {
|
|
2694
|
+
const project = frontProjectOf(pool);
|
|
2695
|
+
if (project) projects.add(project);
|
|
2696
|
+
} else if (!/^fe-/.test(pool)) {
|
|
2697
|
+
projects.add(pool); // 后端pool: 原名不截取
|
|
2698
|
+
}
|
|
2699
|
+
});
|
|
2700
|
+
});
|
|
2701
|
+
return [...projects];
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// 生成vscode/trae多项目工作区文件(.code-workspace为二者通用格式)
|
|
2705
|
+
// 项目路径默认按"各项目平级clone"假设取 ../<项目名>(相对需求目录.zentao-<计划名>/), 可打开后自行调整
|
|
2706
|
+
function writeCodeWorkspace(zentaoDir, planTitle, frontProjects, backProjects) {
|
|
2707
|
+
const zentaoDirName = path.basename(zentaoDir);
|
|
2708
|
+
const folders = [
|
|
2709
|
+
// 第一项为需求目录.zentao-<计划名>(含tasks下的manifest与task.md, 相对workspace文件位置即自身)
|
|
2710
|
+
{ name: zentaoDirName, path: "." },
|
|
2711
|
+
];
|
|
2712
|
+
frontProjects.forEach((name) =>
|
|
2713
|
+
folders.push({ name, path: `../${name}` })
|
|
2714
|
+
);
|
|
2715
|
+
backProjects.forEach((name) =>
|
|
2716
|
+
folders.push({ name, path: `../${name}` })
|
|
2717
|
+
);
|
|
2718
|
+
// 计划title含 / 等非法字符, 过滤后作文件名
|
|
2719
|
+
const fileName =
|
|
2720
|
+
String(planTitle || "")
|
|
2721
|
+
.replace(/[\\/:*?"<>|]/g, "")
|
|
2722
|
+
.trim() + ".code-workspace";
|
|
2723
|
+
const wsPath = path.join(zentaoDir, fileName);
|
|
2724
|
+
fs.writeFileSync(
|
|
2725
|
+
wsPath,
|
|
2726
|
+
// terminal.integrated.cwd 相对第一个 folder(需求目录) 解析: ".." 即调度中心目录, 打开工作区直接 claude 启动主会话
|
|
2727
|
+
// zentao.projectRoot 记录 CLI 运行目录(=需求目录父目录=调度中心), 主会话组装 projectPaths 直接取用, 不依赖启动目录推断
|
|
2728
|
+
// zentao.dir 记录需求目录名(.zentao-<计划名>), 主会话组装 workflow 的 zentaoDir 传参直接取用
|
|
2729
|
+
JSON.stringify(
|
|
2730
|
+
{
|
|
2731
|
+
folders,
|
|
2732
|
+
settings: {
|
|
2733
|
+
"terminal.integrated.cwd": "..",
|
|
2734
|
+
"zentao.projectRoot": process.cwd().replace(/\\/g, "/"),
|
|
2735
|
+
"zentao.dir": zentaoDirName,
|
|
2736
|
+
},
|
|
2737
|
+
},
|
|
2738
|
+
null,
|
|
2739
|
+
2
|
|
2740
|
+
),
|
|
2741
|
+
"utf8"
|
|
2742
|
+
);
|
|
2743
|
+
console.log(chalk.green(`工作区: ${wsPath} (vscode/trae可直接打开)`));
|
|
2744
|
+
const parts = [];
|
|
2745
|
+
if (frontProjects.length) parts.push(`前端: ${frontProjects.join(", ")}`);
|
|
2746
|
+
if (backProjects.length) parts.push(`后端: ${backProjects.join(", ")}`);
|
|
2747
|
+
if (parts.length) {
|
|
2748
|
+
console.log(chalk.green(`涉及项目 ${parts.join(" | ")} (路径按平级目录假设, 可自行调整)`));
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
/**
|
|
2753
|
+
* plan --export 产出需求目录 .zentao-<计划名>/(产出契约见 .trae/documents/story-command-zentao.md 第4节):
|
|
2754
|
+
* tasks/manifest.json(对象结构: 顶部projects汇总全部涉及项目 + stories数组合并去重, 同storyID覆盖title/taskDir/projects, 按storyID数值排序)
|
|
2755
|
+
* + 每条需求 tasks/<taskDir>/task.md(同名taskDir已由 exportPlanStories 先删除, 此处全新写入)
|
|
2756
|
+
* plan.md 与 codex-*.md 是 workflow 的产物(重拉同名taskDir会连带删除, 确认关卡后不要再重导)
|
|
2757
|
+
*/
|
|
2758
|
+
function writeZentaoTasks({ plan, scope, stories, storyDetails, products, failed, storyImages, zentaoDir }) {
|
|
2759
|
+
const tasksDir = path.join(zentaoDir, "tasks");
|
|
2760
|
+
fs.mkdirSync(tasksDir, { recursive: true });
|
|
2761
|
+
|
|
2762
|
+
// manifest 合并去重(位于 tasks/ 下, 是 tasks 目录的需求索引)
|
|
2763
|
+
// 结构: { projects: [全部需求涉及项目的汇总], stories: [...] }; 兼容读取旧版纯数组格式(迁移为stories, 旧条目无projects字段)
|
|
2764
|
+
const manifestPath = path.join(tasksDir, "manifest.json");
|
|
2765
|
+
let manifestStories = [];
|
|
2766
|
+
if (isExistPath(manifestPath)) {
|
|
2767
|
+
try {
|
|
2768
|
+
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
2769
|
+
manifestStories = Array.isArray(parsed)
|
|
2770
|
+
? parsed
|
|
2771
|
+
: (parsed && parsed.stories) || [];
|
|
2772
|
+
} catch (error) {
|
|
2773
|
+
manifestStories = [];
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
const manifestMap = new Map(
|
|
2777
|
+
manifestStories.map((item) => [String(item.storyID), item])
|
|
2778
|
+
);
|
|
2779
|
+
|
|
2780
|
+
let added = 0;
|
|
2781
|
+
stories.forEach((story) => {
|
|
2782
|
+
// 拉取失败的条目跳过: 不写task.md(防"拉取失败"伪装成"禅道没填"产出空壳), 也不进manifest
|
|
2783
|
+
if (failed.has(String(story.id))) {
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
const taskDir = taskDirOf(story);
|
|
2787
|
+
manifestMap.set(String(story.id), {
|
|
2788
|
+
storyID: String(story.id),
|
|
2789
|
+
title: story.title,
|
|
2790
|
+
taskDir,
|
|
2791
|
+
// 涉及项目(跟随导出范围, 与task.md frontmatter的projects同源), AI据此定位要改的代码仓
|
|
2792
|
+
projects: storyProjectsOf(story, scope),
|
|
2793
|
+
});
|
|
2794
|
+
const taskPath = path.join(tasksDir, taskDir, "task.md");
|
|
2795
|
+
const detail = storyDetails.get(String(story.id));
|
|
2796
|
+
// 图片引用: 已本地化(images/相对路径, 与task.md同目录可直接Read)优先, 下载失败的降级远程url
|
|
2797
|
+
const imgMap =
|
|
2798
|
+
(storyImages && storyImages.get(String(story.id))) || new Map();
|
|
2799
|
+
const imgHref = (src) => imgMap.get(src) || absoluteZentaoUrl(src);
|
|
2800
|
+
const spec = detail ? htmlToText(detail.story.spec, imgHref) : "";
|
|
2801
|
+
const verify = detail ? htmlToText(detail.story.verify, imgHref) : "";
|
|
2802
|
+
const content = [
|
|
2803
|
+
"---",
|
|
2804
|
+
`storyID: "${story.id}"`,
|
|
2805
|
+
// JSON.stringify转义: title含半角冒号/引号等时防frontmatter(YAML)断裂
|
|
2806
|
+
`title: ${JSON.stringify(story.title)}`,
|
|
2807
|
+
`product: ${JSON.stringify(products[story.product] || story.product || "")}`,
|
|
2808
|
+
// plan 用选中计划title(story.planTitle 挂多计划时是拼接串)
|
|
2809
|
+
`plan: ${JSON.stringify(plan.title || "")}`,
|
|
2810
|
+
// 涉及项目(跟随导出范围: front只前端/back只后端/all全部), AI据此定位要改的代码仓
|
|
2811
|
+
`projects: ${JSON.stringify(storyProjectsOf(story, scope).join(", "))}`,
|
|
2812
|
+
// JSON.stringify统一转义: 防frontmatter(YAML)断裂(source含冒号, 与其他字段风格一致)
|
|
2813
|
+
`source: ${JSON.stringify(storyUrl(story.id))}`,
|
|
2814
|
+
"---",
|
|
2815
|
+
"",
|
|
2816
|
+
`# 需求:${story.title}`,
|
|
2817
|
+
"",
|
|
2818
|
+
"## 正文",
|
|
2819
|
+
"",
|
|
2820
|
+
spec || "(禅道未填写描述)",
|
|
2821
|
+
// 验收内容并入正文, 不设独立区块; 为空时不输出
|
|
2822
|
+
verify ? `\n验收标准:\n${verify}` : "",
|
|
2823
|
+
"",
|
|
2824
|
+
].join("\n");
|
|
2825
|
+
fs.mkdirSync(path.join(tasksDir, taskDir), { recursive: true });
|
|
2826
|
+
fs.writeFileSync(taskPath, content, "utf8");
|
|
2827
|
+
added += 1;
|
|
2828
|
+
});
|
|
2829
|
+
|
|
2830
|
+
const merged = Array.from(manifestMap.values()).sort(
|
|
2831
|
+
(a, b) => Number(a.storyID) - Number(b.storyID)
|
|
2832
|
+
);
|
|
2833
|
+
// 顶部汇总全部需求涉及的项目(各条目projects并集去重排序; 旧格式迁移条目可能无该字段, 忽略)
|
|
2834
|
+
const allProjects = [
|
|
2835
|
+
...new Set(merged.flatMap((item) => item.projects || [])),
|
|
2836
|
+
].sort();
|
|
2837
|
+
fs.writeFileSync(
|
|
2838
|
+
manifestPath,
|
|
2839
|
+
JSON.stringify({ projects: allProjects, stories: merged }, null, 2),
|
|
2840
|
+
"utf8"
|
|
2841
|
+
);
|
|
2842
|
+
console.log(
|
|
2843
|
+
chalk.green(
|
|
2844
|
+
`产出完成: 写入 ${added} 条 / 失败 ${failed.size} 条`
|
|
2845
|
+
)
|
|
2846
|
+
);
|
|
2847
|
+
console.log(chalk.green(`输出目录: ${zentaoDir}`));
|
|
2848
|
+
// 部分失败以非零退出码收尾, 供自动化流程感知重试
|
|
2849
|
+
if (failed.size > 0) {
|
|
2850
|
+
console.log(
|
|
2851
|
+
chalk.red(`失败${failed.size}条(详情见上方黄字提示), 已跳过不产出, 可重跑 story plan --export 补齐`)
|
|
2852
|
+
);
|
|
2853
|
+
process.exit(1);
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// 计划选取交互: 关键词过滤 > 命中1条自动选中 > rawlist选取
|
|
2858
|
+
async function selectPlan(plans, keyword) {
|
|
2859
|
+
if (keyword && String(keyword).trim()) {
|
|
2860
|
+
const kw = String(keyword).trim().toLowerCase();
|
|
2861
|
+
const filtered = plans.filter((p) =>
|
|
2862
|
+
String(p.title).toLowerCase().includes(kw)
|
|
2863
|
+
);
|
|
2864
|
+
if (!filtered.length) {
|
|
2865
|
+
console.log(chalk.red("未找到匹配的计划"));
|
|
2866
|
+
process.exit(1);
|
|
2867
|
+
}
|
|
2868
|
+
plans = filtered;
|
|
2869
|
+
}
|
|
2870
|
+
if (plans.length === 1) {
|
|
2871
|
+
return plans[0];
|
|
2872
|
+
}
|
|
2873
|
+
const { plan } = await inquirer.prompt([
|
|
2874
|
+
{
|
|
2875
|
+
message: "请选择计划",
|
|
2876
|
+
name: "plan",
|
|
2877
|
+
type: "rawlist",
|
|
2878
|
+
choices: plans.map((p) => ({
|
|
2879
|
+
name: `#${p.id} ${p.title} [${p.begin} ~ ${p.end}] (${
|
|
2880
|
+
p.stories ?? 0
|
|
2881
|
+
}条需求)`,
|
|
2882
|
+
value: p,
|
|
2883
|
+
})),
|
|
2884
|
+
},
|
|
2885
|
+
]);
|
|
2886
|
+
return plan;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// 拉取计划列表(会话由调用方保证有效), 按begin倒序展示(最新计划在前, id降序兜底)
|
|
2890
|
+
async function loadPlanList(zentaosid, productID) {
|
|
2891
|
+
Loading$1.start("拉取计划列表中...");
|
|
2892
|
+
const plans = await fetchPlanList(zentaosid, productID);
|
|
2893
|
+
Loading$1.succeed(`拉取计划列表成功(${plans.length}条)`);
|
|
2894
|
+
plans.sort(
|
|
2895
|
+
(a, b) =>
|
|
2896
|
+
String(b.begin || "").localeCompare(String(a.begin || "")) ||
|
|
2897
|
+
Number(b.id) - Number(a.id)
|
|
2898
|
+
);
|
|
2899
|
+
return plans;
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// plan动作: 选中计划后展示计划信息 + 关联需求列表(ID供detail用)
|
|
2903
|
+
async function showPlanDetail(zentaosid, selected) {
|
|
2904
|
+
Loading$1.start("拉取计划详情中...");
|
|
2905
|
+
const { plan, stories } = await fetchPlanDetail(zentaosid, selected.id);
|
|
2906
|
+
Loading$1.succeed("拉取计划详情成功");
|
|
2907
|
+
console.log(chalk.bold("\n计划信息"));
|
|
2908
|
+
console.table([
|
|
2909
|
+
{
|
|
2910
|
+
ID: plan.id,
|
|
2911
|
+
名称: plan.title,
|
|
2912
|
+
起止时间: `${plan.begin} ~ ${plan.end}`,
|
|
2913
|
+
描述: htmlToText(plan.desc) || "-",
|
|
2914
|
+
},
|
|
2915
|
+
]);
|
|
2916
|
+
// 工时统计(基于全量需求, 非仅截断展示的)
|
|
2917
|
+
const totalWeb = stories.reduce(
|
|
2918
|
+
(sum, s) => sum + (Number(s.webestimate) > 0 ? Number(s.webestimate) : 0),
|
|
2919
|
+
0
|
|
2920
|
+
);
|
|
2921
|
+
const totalEst = stories.reduce(
|
|
2922
|
+
(sum, s) => sum + (Number(s.estimate) > 0 ? Number(s.estimate) : 0),
|
|
2923
|
+
0
|
|
2924
|
+
);
|
|
2925
|
+
const frontCount = stories.filter((s) => Number(s.webestimate) > 0).length;
|
|
2926
|
+
const backCount = stories.filter((s) => Number(s.estimate) > 0).length;
|
|
2927
|
+
console.log(
|
|
2928
|
+
chalk.bold(
|
|
2929
|
+
`\n关联需求列表(共${stories.length}条, 前端${frontCount}条${totalWeb}h, 后端${backCount}条${totalEst}h)`
|
|
2930
|
+
)
|
|
2931
|
+
);
|
|
2932
|
+
const displayStories = stories.slice(0, PLAN_STORY_TABLE_LIMIT);
|
|
2933
|
+
if (stories.length > PLAN_STORY_TABLE_LIMIT) {
|
|
2934
|
+
console.log(
|
|
2935
|
+
chalk.yellow(
|
|
2936
|
+
`需求较多(${stories.length}条), 仅展示前${PLAN_STORY_TABLE_LIMIT}条, 完整列表可用 story plan --export 产出`
|
|
2937
|
+
)
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
console.table(
|
|
2941
|
+
displayStories.map((s) => {
|
|
2942
|
+
const isFront = Number(s.webestimate) > 0;
|
|
2943
|
+
const hasFrontPool = parsePoolList(s.poollist).some((p) =>
|
|
2944
|
+
frontProjectOf(p)
|
|
2945
|
+
);
|
|
2946
|
+
return {
|
|
2947
|
+
ID: s.id,
|
|
2948
|
+
标题: s.title,
|
|
2949
|
+
前端工时: isFront ? s.webestimate : "-",
|
|
2950
|
+
后端工时: Number(s.estimate) > 0 ? s.estimate : "-",
|
|
2951
|
+
poollist: s.poollist || "-",
|
|
2952
|
+
// 有前端工时但poollist无fe-开头的前端pool => 标记❌(console.table单元格不支持着色)
|
|
2953
|
+
前端pool: isFront ? (hasFrontPool ? "✓" : "❌") : "-",
|
|
2954
|
+
};
|
|
2955
|
+
})
|
|
2956
|
+
);
|
|
2957
|
+
console.log(
|
|
2958
|
+
chalk.blue("提示: ID 供 fe-it-beta story detail <需求ID> 使用(也可传需求url)")
|
|
2959
|
+
);
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
// 导出动作(plan收尾交互使用): 按范围过滤需求, 拉详情并产出 .zentao-<计划名>/ + 工作区
|
|
2963
|
+
// scope: front=有前端工时的需求 | back=有后端工时的需求 | all=全部
|
|
2964
|
+
async function exportPlanStories(zentaosid, selected, scope) {
|
|
2965
|
+
const scopeLabel =
|
|
2966
|
+
scope === "front" ? "前端" : scope === "back" ? "后端" : "全部";
|
|
2967
|
+
Loading$1.start("拉取计划详情中...");
|
|
2968
|
+
const { plan, stories: allStories, products } = await fetchPlanDetail(
|
|
2969
|
+
zentaosid,
|
|
2970
|
+
selected.id
|
|
2971
|
+
);
|
|
2972
|
+
// 按导出范围过滤需求
|
|
2973
|
+
const stories = allStories.filter((s) => {
|
|
2974
|
+
if (scope === "front") return Number(s.webestimate) > 0;
|
|
2975
|
+
if (scope === "back") return Number(s.estimate) > 0;
|
|
2976
|
+
return true;
|
|
2977
|
+
});
|
|
2978
|
+
Loading$1.succeed(
|
|
2979
|
+
`拉取计划详情成功(${scopeLabel}${stories.length}/${allStories.length}条)`
|
|
2980
|
+
);
|
|
2981
|
+
if (!stories.length) {
|
|
2982
|
+
console.log(chalk.red(`该计划无${scopeLabel}需求`));
|
|
2983
|
+
process.exit(1);
|
|
2984
|
+
}
|
|
2985
|
+
// 并行限流(并发5)逐条拉取 spec/verify
|
|
2986
|
+
const storyDetails = new Map();
|
|
2987
|
+
const failed = new Map(); // storyID -> 错误信息
|
|
2988
|
+
let done = 0;
|
|
2989
|
+
let index = 0;
|
|
2990
|
+
Loading$1.start(`拉取需求详情 (0/${stories.length})...`);
|
|
2991
|
+
const worker = async () => {
|
|
2992
|
+
while (index < stories.length) {
|
|
2993
|
+
const story = stories[index];
|
|
2994
|
+
index += 1;
|
|
2995
|
+
try {
|
|
2996
|
+
const detail = await fetchStoryDetail(zentaosid, story.id);
|
|
2997
|
+
storyDetails.set(String(story.id), detail);
|
|
2998
|
+
} catch (e) {
|
|
2999
|
+
// 会话失效上抛, 由 ensureZentaoSession 整体重登重试
|
|
3000
|
+
if (e?.isAuthError) throw e;
|
|
3001
|
+
failed.set(String(story.id), e.message || String(e));
|
|
3002
|
+
console.log(chalk.yellow(`需求 ${story.id} 详情拉取失败: ${e.message}`));
|
|
3003
|
+
}
|
|
3004
|
+
done += 1;
|
|
3005
|
+
// ora 的 text 是属性而非方法
|
|
3006
|
+
Loading$1.text = `拉取需求详情 (${done}/${stories.length})...`;
|
|
3007
|
+
}
|
|
3008
|
+
};
|
|
3009
|
+
await Promise.all(
|
|
3010
|
+
Array.from({ length: PULL_CONCURRENCY }, () => worker())
|
|
3011
|
+
);
|
|
3012
|
+
Loading$1.succeed(`拉取需求详情完成(${done}/${stories.length})`);
|
|
3013
|
+
// 需求目录: .zentao-<计划名>(多计划互不混目录)
|
|
3014
|
+
const zentaoDir = path.join(process.cwd(), zentaoDirNameOf(plan));
|
|
3015
|
+
// 同名taskDir直接删除重导(重拉=全新快照, 连带清掉旧images与旧plan/codex报告; 失败条目保留旧数据)
|
|
3016
|
+
stories.forEach((story) => {
|
|
3017
|
+
if (failed.has(String(story.id))) return;
|
|
3018
|
+
const dir = path.join(zentaoDir, "tasks", taskDirOf(story));
|
|
3019
|
+
if (isExistPath(dir)) {
|
|
3020
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
3021
|
+
console.log(chalk.yellow(`同名任务目录已删除重导: ${taskDirOf(story)}`));
|
|
3022
|
+
}
|
|
3023
|
+
});
|
|
3024
|
+
// 图片本地化到 tasks/<taskDir>/images/(目录已删全量重下, 失败黄字不阻塞, 引用降级远程url)
|
|
3025
|
+
const storyImages = await downloadStoryImages({
|
|
3026
|
+
stories,
|
|
3027
|
+
storyDetails,
|
|
3028
|
+
zentaosid,
|
|
3029
|
+
zentaoDir,
|
|
3030
|
+
});
|
|
3031
|
+
writeZentaoTasks({
|
|
3032
|
+
plan,
|
|
3033
|
+
scope,
|
|
3034
|
+
stories,
|
|
3035
|
+
storyDetails,
|
|
3036
|
+
products,
|
|
3037
|
+
failed,
|
|
3038
|
+
storyImages,
|
|
3039
|
+
zentaoDir,
|
|
3040
|
+
});
|
|
3041
|
+
// 汇总范围内需求的项目(前端fe-xxx-vue截取 / 后端pool原名), 生成多项目工作区
|
|
3042
|
+
const frontProjects =
|
|
3043
|
+
scope === "front" || scope === "all"
|
|
3044
|
+
? collectPoolProjects(stories, "front")
|
|
3045
|
+
: [];
|
|
3046
|
+
const backProjects =
|
|
3047
|
+
scope === "back" || scope === "all"
|
|
3048
|
+
? collectPoolProjects(stories, "back")
|
|
3049
|
+
: [];
|
|
3050
|
+
if (frontProjects.length || backProjects.length) {
|
|
3051
|
+
writeCodeWorkspace(zentaoDir, plan.title, frontProjects, backProjects);
|
|
3052
|
+
} else {
|
|
3053
|
+
console.log(chalk.yellow("本范围无关联项目, 跳过工作区生成"));
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
/**
|
|
3058
|
+
* story 主入口
|
|
3059
|
+
* @param action login | plan | detail | task | subtask | close
|
|
3060
|
+
* @param cliOpts 合并后的命令参数(name/username/password/cache/export...)
|
|
3061
|
+
*/
|
|
3062
|
+
async function storyMain(action, cliOpts) {
|
|
3063
|
+
validateChoice(
|
|
3064
|
+
action,
|
|
3065
|
+
["login", "plan", "detail", "task", "subtask", "close"],
|
|
3066
|
+
"story <action>"
|
|
3067
|
+
);
|
|
3068
|
+
if (action === "login") {
|
|
3069
|
+
const secret = await askZentaoCredentials(cliOpts);
|
|
3070
|
+
await zentaoLogin(secret);
|
|
3071
|
+
console.log(chalk.green("登录成功, zentaosid 已缓存"));
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3074
|
+
if (action === "detail") {
|
|
3075
|
+
// 入参 = 需求url(完整url/相对路径/纯ID)
|
|
3076
|
+
const input = String(cliOpts.name || "").trim();
|
|
3077
|
+
const match =
|
|
3078
|
+
input.match(/story-view-(\d+)/) ||
|
|
3079
|
+
(/^\d+$/.test(input) ? [input, input] : null);
|
|
3080
|
+
if (!match) {
|
|
3081
|
+
console.log(
|
|
3082
|
+
chalk.red(
|
|
3083
|
+
"请传入需求url(从 story plan 输出的需求列表复制), 如: fe-it-beta story detail https://zentao.hongxinshop.com/zentao/story-view-70026.html"
|
|
3084
|
+
)
|
|
3085
|
+
);
|
|
3086
|
+
process.exit(1);
|
|
3087
|
+
}
|
|
3088
|
+
const storyID = match[1];
|
|
3089
|
+
const session = await ensureZentaoSession(cliOpts);
|
|
3090
|
+
await session.request(async (zentaosid) => {
|
|
3091
|
+
Loading$1.start("拉取需求详情中...");
|
|
3092
|
+
const { story, users } = await fetchStoryDetail(zentaosid, storyID);
|
|
3093
|
+
Loading$1.succeed("拉取需求详情成功");
|
|
3094
|
+
console.log(chalk.bold(`\n需求详情 #${story.id}`));
|
|
3095
|
+
const rows = [
|
|
3096
|
+
["ID", story.id],
|
|
3097
|
+
["标题", story.title],
|
|
3098
|
+
["产品ID", story.product],
|
|
3099
|
+
["模块ID", story.module],
|
|
3100
|
+
["状态", story.status],
|
|
3101
|
+
["阶段", story.stage],
|
|
3102
|
+
["优先级", story.pri],
|
|
3103
|
+
["指派给", users[story.assignedTo] || story.assignedTo],
|
|
3104
|
+
["创建人", users[story.openedBy] || story.openedBy],
|
|
3105
|
+
["预估工时", story.estimate],
|
|
3106
|
+
["url", storyUrl(story.id)],
|
|
3107
|
+
];
|
|
3108
|
+
rows.forEach(([label, value]) =>
|
|
3109
|
+
console.log(chalk.gray(String(label).padEnd(6, " ")) + ": " + value)
|
|
3110
|
+
);
|
|
3111
|
+
console.log(chalk.bold("\n需求描述"));
|
|
3112
|
+
// 图片以markdown引用完整url输出(可复制到浏览器查看)
|
|
3113
|
+
console.log(htmlToText(story.spec, absoluteZentaoUrl) || "-");
|
|
3114
|
+
console.log(chalk.bold("\n验收标准"));
|
|
3115
|
+
console.log(htmlToText(story.verify, absoluteZentaoUrl) || "-");
|
|
3116
|
+
});
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
if (action === "task") {
|
|
3120
|
+
// 入参 = 项目ID(完整url/相对路径/纯ID), 如 project-story-2915.html 里的 2915
|
|
3121
|
+
const input = String(cliOpts.name || "").trim();
|
|
3122
|
+
const match =
|
|
3123
|
+
input.match(/project-story-(\d+)/) ||
|
|
3124
|
+
(/^\d+$/.test(input) ? [input, input] : null);
|
|
3125
|
+
if (!match) {
|
|
3126
|
+
console.log(
|
|
3127
|
+
chalk.red(
|
|
3128
|
+
"请传入项目ID(从禅道项目需求页url复制), 如: fe-it-beta story task 2915 或 fe-it-beta story task https://zentao.hongxinshop.com/zentao/project-story-2915.html"
|
|
3129
|
+
)
|
|
3130
|
+
);
|
|
3131
|
+
process.exit(1);
|
|
3132
|
+
}
|
|
3133
|
+
const projectID = match[1];
|
|
3134
|
+
// --type 校验前置, 非法值在网络请求前就红字退出
|
|
3135
|
+
if (cliOpts.type !== undefined) {
|
|
3136
|
+
validateChoice(cliOpts.type, ["front", "back"], "--type");
|
|
3137
|
+
}
|
|
3138
|
+
const session = await ensureZentaoSession(cliOpts);
|
|
3139
|
+
await session.request(async (zentaosid) => {
|
|
3140
|
+
// 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
|
|
3141
|
+
Loading$1.start("校验项目中...");
|
|
3142
|
+
const projectMap = await fetchProjectMap(zentaosid);
|
|
3143
|
+
if (!projectMap[projectID]) {
|
|
3144
|
+
Loading$1.fail("项目不存在");
|
|
3145
|
+
throw new Error(`项目ID ${projectID} 不存在, 请检查后重试`);
|
|
3146
|
+
}
|
|
3147
|
+
Loading$1.succeed(`项目: #${projectID} ${projectMap[projectID]}`);
|
|
3148
|
+
Loading$1.start("拉取项目需求中...");
|
|
3149
|
+
const pj = await fetchProjectStories(zentaosid, projectID);
|
|
3150
|
+
if (!pj.project || !pj.project.name) {
|
|
3151
|
+
Loading$1.fail("项目不存在");
|
|
3152
|
+
throw new Error(`项目ID ${projectID} 不存在, 请检查后重试`);
|
|
3153
|
+
}
|
|
3154
|
+
Loading$1.succeed(
|
|
3155
|
+
`项目: #${projectID} ${pj.project.name} [${pj.project.begin} ~ ${pj.project.end}] (需求${pj.stories.length}条)`
|
|
3156
|
+
);
|
|
3157
|
+
if (!pj.stories.length) {
|
|
3158
|
+
console.log(chalk.red("该项目无关联需求"));
|
|
3159
|
+
process.exit(1);
|
|
3160
|
+
}
|
|
3161
|
+
// 每条需求的前后端已建任务计数(勾选时提示防重复建)
|
|
3162
|
+
// 自行从项目任务列表统计(禅道 storyWebTasks/storyDevTasks 会把回收站任务也计入, 实测不准)
|
|
3163
|
+
Loading$1.start("拉取项目任务中...");
|
|
3164
|
+
const { tasks: projectTasks, pageTotal } = await fetchProjectTasks(
|
|
3165
|
+
zentaosid,
|
|
3166
|
+
projectID
|
|
3167
|
+
);
|
|
3168
|
+
Loading$1.succeed(
|
|
3169
|
+
`拉取项目任务成功(${projectTasks.length}条, 需求计数用)`
|
|
3170
|
+
);
|
|
3171
|
+
// 任务超单页时计数可能不全(禅道翻页路由不稳定, 只提示不翻页)
|
|
3172
|
+
if (pageTotal > 1) {
|
|
3173
|
+
console.log(
|
|
3174
|
+
chalk.yellow(
|
|
3175
|
+
`项目任务超过单页(${pageTotal}页), 已建计数可能不全`
|
|
3176
|
+
)
|
|
3177
|
+
);
|
|
3178
|
+
}
|
|
3179
|
+
// 前端任务判定: type=frontend 或 名称【前端】前缀(修复前的旧数据 type 误传 devel)
|
|
3180
|
+
const isFrontTask = (t) =>
|
|
3181
|
+
t.type === "frontend" || String(t.name || "").startsWith("【前端】");
|
|
3182
|
+
const tasksOf = (s) =>
|
|
3183
|
+
projectTasks.filter((t) => String(t.story) === String(s.id));
|
|
3184
|
+
const webCount = (s) => tasksOf(s).filter(isFrontTask).length;
|
|
3185
|
+
const devCount = (s) =>
|
|
3186
|
+
tasksOf(s).filter((t) => t.type === "devel" && !isFrontTask(t)).length;
|
|
3187
|
+
const totalCount = (s) => tasksOf(s).length;
|
|
3188
|
+
// 子任务统计: 子任务不出现在任务列表(挂在父任务children下), 逐个拉详情;
|
|
3189
|
+
// 只拉 工时>8 的主任务(只有这些才可能拆过子任务), 并发限流控制请求量
|
|
3190
|
+
// 注: parent 字段三态(实测): 0=独立任务 | -1=自己是父任务(有子任务) | >0=挂在别人下的子任务
|
|
3191
|
+
// 候选排除子任务即可(parent>0), -1 的父任务必须纳入(它才有children可统计)
|
|
3192
|
+
const subCountMap = new Map(); // 任务ID -> 子任务数
|
|
3193
|
+
{
|
|
3194
|
+
const candidates = projectTasks.filter(
|
|
3195
|
+
(t) => Number(t.estimate) > 8 && !(Number(t.parent) > 0)
|
|
3196
|
+
);
|
|
3197
|
+
if (candidates.length) {
|
|
3198
|
+
Loading$1.start(`统计子任务 (0/${candidates.length})...`);
|
|
3199
|
+
}
|
|
3200
|
+
let subDone = 0;
|
|
3201
|
+
let index = 0;
|
|
3202
|
+
const worker = async () => {
|
|
3203
|
+
while (index < candidates.length) {
|
|
3204
|
+
const t = candidates[index];
|
|
3205
|
+
index += 1;
|
|
3206
|
+
try {
|
|
3207
|
+
const detail = await fetchTaskDetail(zentaosid, t.id);
|
|
3208
|
+
const n = Object.keys(detail?.children || {}).length;
|
|
3209
|
+
if (n) subCountMap.set(String(t.id), n);
|
|
3210
|
+
} catch (e) {
|
|
3211
|
+
if (e?.isAuthError) throw e;
|
|
3212
|
+
// 子任务统计失败不阻塞主流程(勾选列表仍可用, 只是少了子任务数)
|
|
3213
|
+
}
|
|
3214
|
+
subDone += 1;
|
|
3215
|
+
// ora 的 text 是属性而非方法
|
|
3216
|
+
Loading$1.text = `统计子任务 (${subDone}/${candidates.length})...`;
|
|
3217
|
+
}
|
|
3218
|
+
};
|
|
3219
|
+
await Promise.all(
|
|
3220
|
+
Array.from({ length: PULL_CONCURRENCY }, () => worker())
|
|
3221
|
+
);
|
|
3222
|
+
if (candidates.length) {
|
|
3223
|
+
Loading$1.succeed("统计子任务完成");
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
// 先勾选需求: 一键(--type直通)自动取候选; 交互则列出项目全部需求
|
|
3227
|
+
let picked;
|
|
3228
|
+
if (cliOpts.type !== undefined) {
|
|
3229
|
+
const presetLabel = cliOpts.type === "front" ? "前端" : "后端";
|
|
3230
|
+
const hourOfPreset = (s) =>
|
|
3231
|
+
Number(cliOpts.type === "front" ? s.webestimate : s.estimate);
|
|
3232
|
+
if (cliOpts.splitOnly) {
|
|
3233
|
+
// 仅拆分模式: 取>8h的需求(有父任务按剩余工时拆, 无父任务自动先建)
|
|
3234
|
+
picked = pj.stories.filter((s) => hourOfPreset(s) > 8);
|
|
3235
|
+
if (!picked.length) {
|
|
3236
|
+
console.log(
|
|
3237
|
+
chalk.yellow(`无超过8h的${presetLabel}需求, 无可拆分项, 结束`)
|
|
3238
|
+
);
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
} else {
|
|
3242
|
+
// 建任务模式: 有该类型工时且未建过的需求
|
|
3243
|
+
picked = pj.stories.filter(
|
|
3244
|
+
(s) =>
|
|
3245
|
+
hourOfPreset(s) > 0 &&
|
|
3246
|
+
(cliOpts.type === "front" ? webCount(s) : devCount(s)) === 0
|
|
3247
|
+
);
|
|
3248
|
+
if (!picked.length) {
|
|
3249
|
+
console.log(
|
|
3250
|
+
chalk.yellow(`候选需求均已建过${presetLabel}任务, 无可创建项, 结束`)
|
|
3251
|
+
);
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
} else {
|
|
3256
|
+
// 前端子任务计数(排序与展示共用)
|
|
3257
|
+
const frontSubsOf = (s) =>
|
|
3258
|
+
tasksOf(s)
|
|
3259
|
+
.filter(isFrontTask)
|
|
3260
|
+
.reduce(
|
|
3261
|
+
(sum, t) => sum + (subCountMap.get(String(t.id)) || 0),
|
|
3262
|
+
0
|
|
3263
|
+
);
|
|
3264
|
+
// 前端拆分基准工时: 有父任务用父任务实际工时(最大前端任务的工时), 无则用需求webestimate
|
|
3265
|
+
// (手建父任务工时可能与需求webestimate不一致, 应建数按实际父任务算才准)
|
|
3266
|
+
const frontBaseHours = (s) => {
|
|
3267
|
+
const frontTasks = tasksOf(s).filter(isFrontTask);
|
|
3268
|
+
return frontTasks.length
|
|
3269
|
+
? Math.max(...frontTasks.map((t) => Number(t.estimate) || 0))
|
|
3270
|
+
: Number(s.webestimate);
|
|
3271
|
+
};
|
|
3272
|
+
({ picked } = await inquirer.prompt([
|
|
3273
|
+
{
|
|
3274
|
+
message: "请勾选要建任务的需求(空格选择, 回车确认)",
|
|
3275
|
+
name: "picked",
|
|
3276
|
+
type: "checkbox",
|
|
3277
|
+
// 到列表边界停止, 不循环回第一条
|
|
3278
|
+
loop: false,
|
|
3279
|
+
choices: [...pj.stories]
|
|
3280
|
+
.sort((a, b) => {
|
|
3281
|
+
// 待办优先级: 0=前端有工时且未建任务(子任务也必无, 全新建)
|
|
3282
|
+
// 1=前端有任务但子任务未满(待补拆, 基准=父任务实际工时) 2=其余有前端工时 3=无前端工时
|
|
3283
|
+
// 组内按需求ID从小到大
|
|
3284
|
+
const rank = (s) => {
|
|
3285
|
+
if (!(Number(s.webestimate) > 0)) return 3;
|
|
3286
|
+
const base = frontBaseHours(s);
|
|
3287
|
+
if (webCount(s) === 0) return 0;
|
|
3288
|
+
return base > 8 && frontSubsOf(s) < Math.ceil(base / 8)
|
|
3289
|
+
? 1
|
|
3290
|
+
: 2;
|
|
3291
|
+
};
|
|
3292
|
+
return (
|
|
3293
|
+
rank(a) - rank(b) ||
|
|
3294
|
+
Number(a.id) - Number(b.id)
|
|
3295
|
+
);
|
|
3296
|
+
})
|
|
3297
|
+
.map((s, i) => {
|
|
3298
|
+
// 子任务进度(仅前端>8h展示, 后端暂不展示): 已建/应建
|
|
3299
|
+
// 应建 = ceil(基准工时/8), 基准=前端父任务实际工时(无父任务用webestimate)
|
|
3300
|
+
// 未满已建数红色, 满则总数绿色; 任务计数后括号内联
|
|
3301
|
+
const subProgress = (hours, count) => {
|
|
3302
|
+
const total = Math.ceil(Number(hours) / 8);
|
|
3303
|
+
return count >= total
|
|
3304
|
+
? `(${count}/${chalk.green(total)})`
|
|
3305
|
+
: `(${chalk.red(count)}/${total})`;
|
|
3306
|
+
};
|
|
3307
|
+
const frontSubs = frontSubsOf(s);
|
|
3308
|
+
const base = frontBaseHours(s);
|
|
3309
|
+
// 前端: 任务数(子任务进度); 后端: 任务数(暂不展示子任务)
|
|
3310
|
+
const built = [
|
|
3311
|
+
Number(s.webestimate) > 0
|
|
3312
|
+
? `前端${webCount(s)}个${
|
|
3313
|
+
base > 8 ? subProgress(base, frontSubs) : ""
|
|
3314
|
+
}`
|
|
3315
|
+
: "",
|
|
3316
|
+
Number(s.estimate) > 0 ? `后端${devCount(s)}个 任务` : "",
|
|
3317
|
+
]
|
|
3318
|
+
.filter(Boolean)
|
|
3319
|
+
.join("/");
|
|
3320
|
+
// 父任务工时不足提示: 有前端父任务但工时 < 需求前端工时(黄字, 手建父任务漏填/填少)
|
|
3321
|
+
const shortWarn =
|
|
3322
|
+
webCount(s) > 0 && base < Number(s.webestimate)
|
|
3323
|
+
? `, ${chalk.yellow(`父任务${base}h<需求${s.webestimate}h, 不足!`)}`
|
|
3324
|
+
: "";
|
|
3325
|
+
// 第二行详情(已建/警告): 换行 + 缩进对齐, 与标题行区分
|
|
3326
|
+
const detail = [built ? `已建${built}` : "", shortWarn.slice(2)]
|
|
3327
|
+
.filter(Boolean)
|
|
3328
|
+
.join(", ");
|
|
3329
|
+
return {
|
|
3330
|
+
name: `${i + 1}. #${s.id} ${s.title} (前端${
|
|
3331
|
+
Number(s.webestimate) > 0 ? `${s.webestimate}h` : "-"
|
|
3332
|
+
} / 后端${
|
|
3333
|
+
Number(s.estimate) > 0 ? `${s.estimate}h` : "-"
|
|
3334
|
+
})${detail ? `\n ${detail}` : ""}`,
|
|
3335
|
+
value: s,
|
|
3336
|
+
// 有工时但从未建过任何任务的需求默认勾选
|
|
3337
|
+
checked: totalCount(s) === 0,
|
|
3338
|
+
};
|
|
3339
|
+
}),
|
|
3340
|
+
},
|
|
3341
|
+
]));
|
|
3342
|
+
}
|
|
3343
|
+
if (!picked.length) {
|
|
3344
|
+
console.log(chalk.yellow("未勾选需求, 结束"));
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
// 再选任务类型: --type 直通(一键场景) > 选完需求后交互询问
|
|
3348
|
+
let taskType;
|
|
3349
|
+
if (cliOpts.type !== undefined) {
|
|
3350
|
+
taskType = cliOpts.type;
|
|
3351
|
+
} else {
|
|
3352
|
+
({ taskType } = await inquirer.prompt([
|
|
3353
|
+
{
|
|
3354
|
+
message: "请选择任务类型",
|
|
3355
|
+
name: "taskType",
|
|
3356
|
+
type: "list",
|
|
3357
|
+
choices: [
|
|
3358
|
+
{ name: "前端任务", value: "front" },
|
|
3359
|
+
{ name: "后端任务", value: "back" },
|
|
3360
|
+
],
|
|
3361
|
+
},
|
|
3362
|
+
]));
|
|
3363
|
+
}
|
|
3364
|
+
const typeLabel = taskType === "front" ? "前端" : "后端";
|
|
3365
|
+
// 勾选项中无该类型工时的跳过(黄字提示)
|
|
3366
|
+
const hourOf = (s) =>
|
|
3367
|
+
Number(taskType === "front" ? s.webestimate : s.estimate);
|
|
3368
|
+
const noHour = picked.filter((s) => hourOf(s) <= 0);
|
|
3369
|
+
if (noHour.length) {
|
|
3370
|
+
console.log(
|
|
3371
|
+
chalk.yellow(
|
|
3372
|
+
`需求 ${noHour.map((s) => s.id).join(", ")} 无${typeLabel}工时, 跳过不建`
|
|
3373
|
+
)
|
|
3374
|
+
);
|
|
3375
|
+
}
|
|
3376
|
+
picked = picked.filter((s) => hourOf(s) > 0);
|
|
3377
|
+
if (!picked.length) {
|
|
3378
|
+
console.log(chalk.red(`勾选的需求均无${typeLabel}工时`));
|
|
3379
|
+
process.exit(1);
|
|
3380
|
+
}
|
|
3381
|
+
// 指派: 前端->webuser / 后端->devuser, 为空 fallback 当前登录账号
|
|
3382
|
+
const account = readZentaoCache().account;
|
|
3383
|
+
const assigneeOf = (s) =>
|
|
3384
|
+
(taskType === "front" ? s.webuser : s.devuser) || account;
|
|
3385
|
+
// 现有父任务(pick中已建该类任务的): 拆分类模式复用, 不重复建
|
|
3386
|
+
const existingParentOf = (s) =>
|
|
3387
|
+
tasksOf(s).find((t) =>
|
|
3388
|
+
taskType === "front"
|
|
3389
|
+
? isFrontTask(t)
|
|
3390
|
+
: t.type === "devel" && !isFrontTask(t)
|
|
3391
|
+
);
|
|
3392
|
+
// 处理模式(选择即确认): --split-only > --split > 交互列表 > 一键默认
|
|
3393
|
+
// parent=仅创建父任务 | parent-split=创建父任务+拆分子任务 | split=仅拆分(无父任务自动先建)
|
|
3394
|
+
let taskMode;
|
|
3395
|
+
if (cliOpts.splitOnly) {
|
|
3396
|
+
taskMode = "split";
|
|
3397
|
+
} else if (cliOpts.split) {
|
|
3398
|
+
taskMode = "parent-split";
|
|
3399
|
+
} else if (cliOpts.type !== undefined) {
|
|
3400
|
+
taskMode = "parent"; // 一键默认仅建父任务
|
|
3401
|
+
} else if (picked.every((s) => existingParentOf(s))) {
|
|
3402
|
+
// 全部已有父任务: 无需再建父任务, 仅展示补拆选项(剩余工时拆满, 部分已拆的补齐)
|
|
3403
|
+
({ taskMode } = await inquirer.prompt([
|
|
3404
|
+
{
|
|
3405
|
+
message: `将处理 ${picked.length} 条【${typeLabel}】任务(截止 ${pj.project.end}), 请选择模式`,
|
|
3406
|
+
name: "taskMode",
|
|
3407
|
+
type: "list",
|
|
3408
|
+
choices: [
|
|
3409
|
+
{ name: "1. 拆分子任务(按剩余工时补满)", value: "split" },
|
|
3410
|
+
],
|
|
3411
|
+
},
|
|
3412
|
+
]));
|
|
3413
|
+
} else {
|
|
3414
|
+
({ taskMode } = await inquirer.prompt([
|
|
3415
|
+
{
|
|
3416
|
+
message: `将处理 ${picked.length} 条【${typeLabel}】任务(截止 ${pj.project.end}), 请选择模式`,
|
|
3417
|
+
name: "taskMode",
|
|
3418
|
+
type: "list",
|
|
3419
|
+
choices: [
|
|
3420
|
+
{ name: "1. 创建父任务", value: "parent" },
|
|
3421
|
+
{ name: "2. 创建父任务+拆分子任务", value: "parent-split" },
|
|
3422
|
+
{ name: "3. 拆分子任务(无父任务自动先创建)", value: "split" },
|
|
3423
|
+
],
|
|
3424
|
+
},
|
|
3425
|
+
]));
|
|
3426
|
+
}
|
|
3427
|
+
// 需创建父任务的需求:
|
|
3428
|
+
// parent=全部勾选 | parent-split=无父任务的 | split=无父任务且>8h(仅为拆分而建)
|
|
3429
|
+
const toCreate =
|
|
3430
|
+
taskMode === "parent"
|
|
3431
|
+
? picked
|
|
3432
|
+
: picked.filter(
|
|
3433
|
+
(s) =>
|
|
3434
|
+
!existingParentOf(s) &&
|
|
3435
|
+
(taskMode === "parent-split" || hourOf(s) > 8)
|
|
3436
|
+
);
|
|
3437
|
+
// 创建前任务基线(创建后 diff 出新任务ID)
|
|
3438
|
+
Loading$1.start("同步任务基线中...");
|
|
3439
|
+
const baseline = new Set(
|
|
3440
|
+
(await fetchProjectTasks(zentaosid, projectID)).tasks.map((t) =>
|
|
3441
|
+
String(t.id)
|
|
3442
|
+
)
|
|
3443
|
+
);
|
|
3444
|
+
Loading$1.succeed("任务基线就绪");
|
|
3445
|
+
// 逐条创建(串行, 避免并发触发禅道限流)
|
|
3446
|
+
let done = 0;
|
|
3447
|
+
const failedStories = [];
|
|
3448
|
+
if (toCreate.length) {
|
|
3449
|
+
Loading$1.start(`创建任务 (0/${toCreate.length})...`);
|
|
3450
|
+
for (const s of toCreate) {
|
|
3451
|
+
try {
|
|
3452
|
+
await createZentaoTask({
|
|
3453
|
+
zentaosid,
|
|
3454
|
+
projectID,
|
|
3455
|
+
story: s,
|
|
3456
|
+
name: `【${typeLabel}】${s.title}`,
|
|
3457
|
+
// 任务类型(表单选项实测): frontend=前端开发, devel=后端开发
|
|
3458
|
+
type: taskType === "front" ? "frontend" : "devel",
|
|
3459
|
+
assignedTo: assigneeOf(s),
|
|
3460
|
+
estimate: taskType === "front" ? s.webestimate : s.estimate,
|
|
3461
|
+
estStarted: localToday(),
|
|
3462
|
+
deadline: pj.project.end,
|
|
3463
|
+
});
|
|
3464
|
+
} catch (e) {
|
|
3465
|
+
// 会话失效上抛, 由 ensureZentaoSession 整体重登重试
|
|
3466
|
+
if (e?.isAuthError) throw e;
|
|
3467
|
+
failedStories.push(s.id);
|
|
3468
|
+
console.log(
|
|
3469
|
+
chalk.yellow(`需求 ${s.id} 创建失败: ${e.message || String(e)}`)
|
|
3470
|
+
);
|
|
3471
|
+
}
|
|
3472
|
+
done += 1;
|
|
3473
|
+
// ora 的 text 是属性而非方法
|
|
3474
|
+
Loading$1.text = `创建任务 (${done}/${toCreate.length})...`;
|
|
3475
|
+
}
|
|
3476
|
+
Loading$1.succeed(
|
|
3477
|
+
`创建完成: 成功 ${toCreate.length - failedStories.length} 条 / 失败 ${failedStories.length} 条`
|
|
3478
|
+
);
|
|
3479
|
+
}
|
|
3480
|
+
// 重拉任务列表 diff 出新任务, 输出任务url
|
|
3481
|
+
const pickedIDs = new Set(picked.map((s) => String(s.id)));
|
|
3482
|
+
Loading$1.start("刷新任务列表中...");
|
|
3483
|
+
const newTasks = (await fetchProjectTasks(zentaosid, projectID)).tasks.filter(
|
|
3484
|
+
(t) => !baseline.has(String(t.id)) && pickedIDs.has(String(t.story))
|
|
3485
|
+
);
|
|
3486
|
+
Loading$1.succeed(`刷新完成(新任务 ${newTasks.length} 条)`);
|
|
3487
|
+
newTasks.forEach((t) =>
|
|
3488
|
+
console.log(
|
|
3489
|
+
chalk.green(
|
|
3490
|
+
`任务 #${t.id} ${t.name} -> ${t.assignedTo} (${t.estimate}h)`
|
|
3491
|
+
),
|
|
3492
|
+
`\n ${taskUrl(t.id)}`
|
|
3493
|
+
)
|
|
3494
|
+
);
|
|
3495
|
+
// 拆分子任务(parent-split/split 模式, 仅>8h): 父任务 = 新建优先, 否则用已有(按剩余工时)
|
|
3496
|
+
if (taskMode !== "parent") {
|
|
3497
|
+
const newByStory = new Map(
|
|
3498
|
+
newTasks.map((t) => [String(t.story), t])
|
|
3499
|
+
);
|
|
3500
|
+
// 拆分候选(仅>8h); 执行期间只显示spinner进度, 结果收集到splitLogs结束后统一输出
|
|
3501
|
+
const splitCandidates = picked.filter((s) => hourOf(s) > 8);
|
|
3502
|
+
const splitLogs = [];
|
|
3503
|
+
let splitDone = 0;
|
|
3504
|
+
if (splitCandidates.length) {
|
|
3505
|
+
Loading$1.start(`拆分子任务 (0/${splitCandidates.length})...`);
|
|
3506
|
+
}
|
|
3507
|
+
for (const s of splitCandidates) {
|
|
3508
|
+
let parent = null;
|
|
3509
|
+
try {
|
|
3510
|
+
const created = newByStory.get(String(s.id));
|
|
3511
|
+
parent = created || existingParentOf(s);
|
|
3512
|
+
if (!parent) {
|
|
3513
|
+
// 创建失败等场景, 无父任务可挂, 跳过
|
|
3514
|
+
} else if (String(parent.deadline || pj.project.end) <= localToday()) {
|
|
3515
|
+
// 截止日期须晚于今天(禅道校验 截止>开始, 旧任务截止已过期的跳过)
|
|
3516
|
+
splitLogs.push(
|
|
3517
|
+
chalk.yellow(
|
|
3518
|
+
`任务 #${parent.id} ${parent.name} 截止日期 ${parent.deadline || pj.project.end} 不晚于今天, 跳过拆分(需先调整父任务截止日期)`
|
|
3519
|
+
)
|
|
3520
|
+
);
|
|
3521
|
+
} else {
|
|
3522
|
+
// 剩余工时: 新建父任务=全部工时; 已有父任务=工时-已有子任务和(拉详情算)
|
|
3523
|
+
let remaining = hourOf(s);
|
|
3524
|
+
if (!created) {
|
|
3525
|
+
const detail = await fetchTaskDetail(zentaosid, parent.id);
|
|
3526
|
+
const childHours = Object.values(detail?.children || {}).reduce(
|
|
3527
|
+
(sum, c) => sum + Number(c.estimate || 0),
|
|
3528
|
+
0
|
|
3529
|
+
);
|
|
3530
|
+
remaining = Number(parent.estimate) - childHours;
|
|
3531
|
+
}
|
|
3532
|
+
if (!(remaining > 8)) {
|
|
3533
|
+
splitLogs.push(
|
|
3534
|
+
chalk.yellow(
|
|
3535
|
+
`任务 #${parent.id} ${parent.name} 剩余工时 ${remaining}h 未超过 8h, 跳过拆分`
|
|
3536
|
+
)
|
|
3537
|
+
);
|
|
3538
|
+
} else {
|
|
3539
|
+
const subs = splitTaskPlan(parent.name, remaining, taskType);
|
|
3540
|
+
await createZentaoSubtasks({
|
|
3541
|
+
zentaosid,
|
|
3542
|
+
projectID,
|
|
3543
|
+
story: s,
|
|
3544
|
+
parentTaskID: parent.id,
|
|
3545
|
+
type: taskType === "front" ? "frontend" : "devel",
|
|
3546
|
+
assignedTo: parent.assignedTo,
|
|
3547
|
+
estStarted: localToday(),
|
|
3548
|
+
// 截止日期跟随父任务(新建父任务即为项目结束日期)
|
|
3549
|
+
deadline: parent.deadline || pj.project.end,
|
|
3550
|
+
subs,
|
|
3551
|
+
});
|
|
3552
|
+
splitLogs.push(
|
|
3553
|
+
chalk.green(
|
|
3554
|
+
`子任务 ${subs.length} 个已创建(父任务 #${parent.id}): ${subs
|
|
3555
|
+
.map((sub) => `${sub.name}(${sub.estimate}h)`)
|
|
3556
|
+
.join(", ")}`
|
|
3557
|
+
)
|
|
3558
|
+
);
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
} catch (e) {
|
|
3562
|
+
// 会话失效上抛, 由 ensureZentaoSession 整体重登重试
|
|
3563
|
+
if (e?.isAuthError) throw e;
|
|
3564
|
+
splitLogs.push(
|
|
3565
|
+
chalk.yellow(
|
|
3566
|
+
`任务 #${parent?.id || s.id} 子任务创建失败: ${e.message || String(e)}`
|
|
3567
|
+
)
|
|
3568
|
+
);
|
|
3569
|
+
} finally {
|
|
3570
|
+
splitDone += 1;
|
|
3571
|
+
// ora 的 text 是属性而非方法
|
|
3572
|
+
Loading$1.text = `拆分子任务 (${splitDone}/${splitCandidates.length})...`;
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
if (splitCandidates.length) {
|
|
3576
|
+
Loading$1.succeed("拆分子任务完成");
|
|
3577
|
+
splitLogs.forEach((line) => console.log(line));
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
// 部分失败以非零退出码收尾, 供自动化流程感知重试
|
|
3581
|
+
if (failedStories.length) {
|
|
3582
|
+
console.log(
|
|
3583
|
+
chalk.red(
|
|
3584
|
+
`失败${failedStories.length}条(需求ID: ${failedStories.join(", ")}), 详情见上方黄字提示`
|
|
3585
|
+
)
|
|
3586
|
+
);
|
|
3587
|
+
process.exit(1);
|
|
3588
|
+
}
|
|
3589
|
+
});
|
|
3590
|
+
return;
|
|
3591
|
+
}
|
|
3592
|
+
if (action === "subtask") {
|
|
3593
|
+
// 入参 = 父任务ID(完整url/相对路径/纯ID), 如 task-view-218901.html 里的 218901
|
|
3594
|
+
const input = String(cliOpts.name || "").trim();
|
|
3595
|
+
const match =
|
|
3596
|
+
input.match(/task-view-(\d+)/) ||
|
|
3597
|
+
(/^\d+$/.test(input) ? [input, input] : null);
|
|
3598
|
+
if (!match) {
|
|
3599
|
+
console.log(
|
|
3600
|
+
chalk.red(
|
|
3601
|
+
"请传入父任务ID(从禅道任务页url复制), 如: fe-it-beta story subtask 218901 或 fe-it-beta story subtask https://zentao.hongxinshop.com/zentao/task-view-218901.html"
|
|
3602
|
+
)
|
|
3603
|
+
);
|
|
3604
|
+
process.exit(1);
|
|
3605
|
+
}
|
|
3606
|
+
const parentID = match[1];
|
|
3607
|
+
const session = await ensureZentaoSession(cliOpts);
|
|
3608
|
+
await session.request(async (zentaosid) => {
|
|
3609
|
+
Loading$1.start("拉取父任务详情中...");
|
|
3610
|
+
const parent = await fetchTaskDetail(zentaosid, parentID);
|
|
3611
|
+
if (!parent || !parent.id) {
|
|
3612
|
+
Loading$1.fail("任务不存在");
|
|
3613
|
+
throw new Error(`任务ID ${parentID} 不存在, 请检查后重试`);
|
|
3614
|
+
}
|
|
3615
|
+
const typeLabel = parent.type === "frontend" ? "前端" : "后端";
|
|
3616
|
+
const taskType = parent.type === "frontend" ? "front" : "back";
|
|
3617
|
+
const totalHours = Number(parent.estimate);
|
|
3618
|
+
// 已有子任务(工时和), 按剩余工时拆
|
|
3619
|
+
const children = Object.values(parent.children || {});
|
|
3620
|
+
const childHours = children.reduce(
|
|
3621
|
+
(sum, c) => sum + Number(c.estimate || 0),
|
|
3622
|
+
0
|
|
3623
|
+
);
|
|
3624
|
+
const remainHours = totalHours - childHours;
|
|
3625
|
+
Loading$1.succeed(
|
|
3626
|
+
`父任务 #${parent.id} ${parent.name} [${typeLabel}] ${totalHours}h -> ${parent.assignedTo} (截止 ${parent.deadline})`
|
|
3627
|
+
);
|
|
3628
|
+
if (children.length) {
|
|
3629
|
+
console.log(
|
|
3630
|
+
chalk.yellow(
|
|
3631
|
+
`已有子任务 ${children.length} 个(共${childHours}h): ${children
|
|
3632
|
+
.map((c) => `#${c.id} ${c.name}(${c.estimate}h)`)
|
|
3633
|
+
.join(", ")}`
|
|
3634
|
+
)
|
|
3635
|
+
);
|
|
3636
|
+
}
|
|
3637
|
+
if (!(remainHours > 8)) {
|
|
3638
|
+
console.log(
|
|
3639
|
+
chalk.yellow(
|
|
3640
|
+
`剩余工时 ${remainHours}h 未超过 8h, 无需拆分${
|
|
3641
|
+
remainHours < 0 ? "(子任务工时已超父任务)" : ""
|
|
3642
|
+
}`
|
|
3643
|
+
)
|
|
3644
|
+
);
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
// 截止日期须晚于预计开始(今天): 父任务截止已过期时禅道会校验失败, 交互输入子任务新截止日期
|
|
3648
|
+
let deadline = parent.deadline;
|
|
3649
|
+
if (String(deadline) <= localToday()) {
|
|
3650
|
+
console.log(
|
|
3651
|
+
chalk.yellow(
|
|
3652
|
+
`父任务截止日期 ${deadline} 已不晚于今天, 子任务截止日期需重新指定`
|
|
3653
|
+
)
|
|
3654
|
+
);
|
|
3655
|
+
const after7 = new Date();
|
|
3656
|
+
after7.setDate(after7.getDate() + 7);
|
|
3657
|
+
const defaultDeadline = `${after7.getFullYear()}-${String(
|
|
3658
|
+
after7.getMonth() + 1
|
|
3659
|
+
).padStart(2, "0")}-${String(after7.getDate()).padStart(2, "0")}`;
|
|
3660
|
+
({ deadline } = await inquirer.prompt([
|
|
3661
|
+
{
|
|
3662
|
+
message: "请输入子任务截止日期(YYYY-MM-DD)",
|
|
3663
|
+
name: "deadline",
|
|
3664
|
+
type: "input",
|
|
3665
|
+
default: defaultDeadline,
|
|
3666
|
+
validate: (val) =>
|
|
3667
|
+
(/^\d{4}-\d{2}-\d{2}$/.test(String(val).trim()) &&
|
|
3668
|
+
String(val).trim() > localToday()) ||
|
|
3669
|
+
"请输入晚于今天的日期, 格式 YYYY-MM-DD",
|
|
3670
|
+
},
|
|
3671
|
+
]));
|
|
3672
|
+
deadline = String(deadline).trim();
|
|
3673
|
+
}
|
|
3674
|
+
// 拆分方案(按剩余工时, 名称对齐父任务名)
|
|
3675
|
+
const subs = splitTaskPlan(parent.name, remainHours, taskType);
|
|
3676
|
+
console.log(chalk.bold(`\n拆分方案(${subs.length} 个子任务, 共${remainHours}h, 截止 ${deadline}):`));
|
|
3677
|
+
subs.forEach((sub) =>
|
|
3678
|
+
console.log(` ${sub.name} (${sub.estimate}h) -> ${parent.assignedTo}`)
|
|
3679
|
+
);
|
|
3680
|
+
// --split 直通跳过确认(一键场景), 否则询问
|
|
3681
|
+
if (!cliOpts.split) {
|
|
3682
|
+
const { ok } = await inquirer.prompt([
|
|
3683
|
+
{
|
|
3684
|
+
message: "确认创建以上子任务?",
|
|
3685
|
+
name: "ok",
|
|
3686
|
+
type: "confirm",
|
|
3687
|
+
default: true,
|
|
3688
|
+
},
|
|
3689
|
+
]);
|
|
3690
|
+
if (!ok) {
|
|
3691
|
+
console.log(chalk.yellow("已取消"));
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
Loading$1.start("创建子任务中...");
|
|
3696
|
+
await createZentaoSubtasks({
|
|
3697
|
+
zentaosid,
|
|
3698
|
+
projectID: parent.project,
|
|
3699
|
+
story: { id: parent.story, pri: parent.pri },
|
|
3700
|
+
parentTaskID: parent.id,
|
|
3701
|
+
type: parent.type,
|
|
3702
|
+
assignedTo: parent.assignedTo,
|
|
3703
|
+
estStarted: localToday(),
|
|
3704
|
+
deadline,
|
|
3705
|
+
subs,
|
|
3706
|
+
});
|
|
3707
|
+
Loading$1.succeed(`子任务 ${subs.length} 个创建成功`);
|
|
3708
|
+
console.log(
|
|
3709
|
+
chalk.green(
|
|
3710
|
+
`子任务 ${subs.length} 个已创建(父任务 #${parent.id}): ${subs
|
|
3711
|
+
.map((sub) => `${sub.name}(${sub.estimate}h)`)
|
|
3712
|
+
.join(", ")}`
|
|
3713
|
+
)
|
|
3714
|
+
);
|
|
3715
|
+
console.log(chalk.green(`查看: ${taskUrl(parent.id)}`));
|
|
3716
|
+
});
|
|
3717
|
+
return;
|
|
3718
|
+
}
|
|
3719
|
+
if (action === "close") {
|
|
3720
|
+
// 入参 = 项目ID(完整url/相对路径/纯ID), 如 project-task-2915-myinvolved.html 里的 2915
|
|
3721
|
+
const input = String(cliOpts.name || "").trim();
|
|
3722
|
+
const match =
|
|
3723
|
+
input.match(/project-task-(\d+)/) ||
|
|
3724
|
+
(/^\d+$/.test(input) ? [input, input] : null);
|
|
3725
|
+
if (!match) {
|
|
3726
|
+
console.log(
|
|
3727
|
+
chalk.red(
|
|
3728
|
+
"请传入项目ID(从禅道任务列表url复制), 如: fe-it-beta story close 2915 或 fe-it-beta story close https://zentao.hongxinshop.com/zentao/project-task-2915-myinvolved.html"
|
|
3729
|
+
)
|
|
3730
|
+
);
|
|
3731
|
+
process.exit(1);
|
|
3732
|
+
}
|
|
3733
|
+
const projectID = match[1];
|
|
3734
|
+
const session = await ensureZentaoSession(cliOpts);
|
|
3735
|
+
await session.request(async (zentaosid) => {
|
|
3736
|
+
// 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
|
|
3737
|
+
Loading$1.start("校验项目中...");
|
|
3738
|
+
const projectMap = await fetchProjectMap(zentaosid);
|
|
3739
|
+
if (!projectMap[projectID]) {
|
|
3740
|
+
Loading$1.fail("项目不存在");
|
|
3741
|
+
throw new Error(`项目ID ${projectID} 不存在, 请检查后重试`);
|
|
3742
|
+
}
|
|
3743
|
+
Loading$1.succeed(`项目: #${projectID} ${projectMap[projectID]}`);
|
|
3744
|
+
const account = readZentaoCache().account;
|
|
3745
|
+
// 任务来源(两路取并集):
|
|
3746
|
+
// 1) myinvolved: 指派给登录账号的任务(完成的主入口)
|
|
3747
|
+
// 2) 项目任务列表中"由我完成未关闭"的——完成任务后禅道把指派转回创建人, 会从
|
|
3748
|
+
// myinvolved消失, 但仍需关闭, 须从项目任务列表按 finishedBy 兜回来
|
|
3749
|
+
Loading$1.start("拉取我的任务中...");
|
|
3750
|
+
const involved = await fetchMyInvolvedTasks(zentaosid, projectID);
|
|
3751
|
+
// 项目任务列表(由我完成的任务兜底来源); 失败降级不阻塞(少一路来源), 提示延后到spinner结束统一输出
|
|
3752
|
+
Loading$1.text = "拉取项目任务列表中...";
|
|
3753
|
+
let projectAll = { tasks: [], pageTotal: 1 };
|
|
3754
|
+
let projectAllError = null;
|
|
3755
|
+
try {
|
|
3756
|
+
projectAll = await fetchProjectTasks(zentaosid, projectID);
|
|
3757
|
+
} catch (e) {
|
|
3758
|
+
// 会话失效上抛, 由 ensureZentaoSession 整体重登重试; 其余降级不阻塞(少一路来源)
|
|
3759
|
+
if (e?.isAuthError) throw e;
|
|
3760
|
+
projectAllError = e;
|
|
3761
|
+
}
|
|
3762
|
+
const seen = new Set();
|
|
3763
|
+
const tasks = [];
|
|
3764
|
+
// 指派给我的(过滤已关闭/已取消)
|
|
3765
|
+
involved.tasks
|
|
3766
|
+
.filter(
|
|
3767
|
+
(t) =>
|
|
3768
|
+
t.assignedTo === account && !["closed", "cancel"].includes(t.status)
|
|
3769
|
+
)
|
|
3770
|
+
.forEach((t) => {
|
|
3771
|
+
if (!seen.has(String(t.id))) {
|
|
3772
|
+
seen.add(String(t.id));
|
|
3773
|
+
tasks.push(t);
|
|
3774
|
+
}
|
|
3775
|
+
});
|
|
3776
|
+
// 由我完成的(完成后禅道把指派置'closed'离开myinvolved, 统一从项目任务列表按 finishedBy 兜回):
|
|
3777
|
+
// 已完成待关闭的可勾选关闭; 已关闭的也进列表仅展示(不可选)
|
|
3778
|
+
projectAll.tasks
|
|
3779
|
+
.filter((t) => t.finishedBy === account && t.status !== "cancel")
|
|
3780
|
+
.forEach((t) => {
|
|
3781
|
+
if (!seen.has(String(t.id))) {
|
|
3782
|
+
seen.add(String(t.id));
|
|
3783
|
+
tasks.push(t);
|
|
3784
|
+
}
|
|
3785
|
+
});
|
|
3786
|
+
// 已完成待关闭的排最前(优先处理), 未完成的其后, 已关闭的最后(仅展示不可选), 组内按任务ID升序
|
|
3787
|
+
const statusRank = (t) =>
|
|
3788
|
+
t.status === "done" ? 0 : t.status === "closed" ? 2 : 1;
|
|
3789
|
+
tasks.sort(
|
|
3790
|
+
(a, b) => statusRank(a) - statusRank(b) || Number(a.id) - Number(b.id)
|
|
3791
|
+
);
|
|
3792
|
+
const donePending = tasks.filter((t) => t.status === "done").length;
|
|
3793
|
+
const closedShown = tasks.filter((t) => t.status === "closed").length;
|
|
3794
|
+
Loading$1.succeed(
|
|
3795
|
+
`拉取成功(未完成 ${tasks.length - donePending - closedShown} 条, 待关闭 ${donePending} 条${
|
|
3796
|
+
closedShown ? `, 已关闭 ${closedShown} 条(仅展示)` : ""
|
|
3797
|
+
})`
|
|
3798
|
+
);
|
|
3799
|
+
if (projectAllError) {
|
|
3800
|
+
console.log(
|
|
3801
|
+
chalk.yellow(
|
|
3802
|
+
`项目任务列表拉取失败, 由我完成的任务将不显示: ${projectAllError.message || String(projectAllError)}`
|
|
3803
|
+
)
|
|
3804
|
+
);
|
|
3805
|
+
}
|
|
3806
|
+
// 项目任务超单页时"由我完成"部分可能漏列(禅道翻页路由不稳定, 只提示不翻页)
|
|
3807
|
+
if (projectAll.pageTotal > 1) {
|
|
3808
|
+
console.log(
|
|
3809
|
+
chalk.yellow(
|
|
3810
|
+
`项目任务超过单页(${projectAll.pageTotal}页), 由我完成待关闭的任务可能漏列`
|
|
3811
|
+
)
|
|
3812
|
+
);
|
|
3813
|
+
}
|
|
3814
|
+
if (!tasks.length) {
|
|
3815
|
+
console.log(
|
|
3816
|
+
chalk.yellow("无指派给我的任务, 也无由我完成待关闭的任务, 结束")
|
|
3817
|
+
);
|
|
3818
|
+
return;
|
|
3819
|
+
}
|
|
3820
|
+
// 逐任务拉详情拿子任务(父任务的子任务不出现在任务列表, 展示与关闭都用)
|
|
3821
|
+
const details = new Map(); // 任务ID -> 任务详情
|
|
3822
|
+
const detailTargets = tasks.filter((t) => t.status !== "closed");
|
|
3823
|
+
if (detailTargets.length) {
|
|
3824
|
+
Loading$1.start(`拉取任务详情 (0/${detailTargets.length})...`);
|
|
3825
|
+
}
|
|
3826
|
+
let detailDone = 0;
|
|
3827
|
+
let index = 0;
|
|
3828
|
+
const detailWorker = async () => {
|
|
3829
|
+
while (index < tasks.length) {
|
|
3830
|
+
const t = tasks[index];
|
|
3831
|
+
index += 1;
|
|
3832
|
+
// 已关闭的仅展示不可选, 不参与处理, 跳过详情拉取省请求
|
|
3833
|
+
if (t.status === "closed") continue;
|
|
3834
|
+
try {
|
|
3835
|
+
details.set(String(t.id), await fetchTaskDetail(zentaosid, t.id));
|
|
3836
|
+
} catch (e) {
|
|
3837
|
+
if (e?.isAuthError) throw e;
|
|
3838
|
+
// 详情拉取失败不阻塞(该项关闭时再拉, 展示少了子任务数而已)
|
|
3839
|
+
}
|
|
3840
|
+
detailDone += 1;
|
|
3841
|
+
// ora 的 text 是属性而非方法
|
|
3842
|
+
Loading$1.text = `拉取任务详情 (${detailDone}/${detailTargets.length})...`;
|
|
3843
|
+
}
|
|
3844
|
+
};
|
|
3845
|
+
await Promise.all(
|
|
3846
|
+
Array.from({ length: PULL_CONCURRENCY }, () => detailWorker())
|
|
3847
|
+
);
|
|
3848
|
+
if (detailTargets.length) {
|
|
3849
|
+
Loading$1.succeed("拉取任务详情完成");
|
|
3850
|
+
}
|
|
3851
|
+
// 勾选要完成的任务(完成后自动关闭; 到列表边界停止, 不循环回第一条)
|
|
3852
|
+
const { picked } = await inquirer.prompt([
|
|
3853
|
+
{
|
|
3854
|
+
message: "请勾选要处理的任务(空格选择, 回车确认; 未完成的先完成后关闭, 已完成的直接关闭, 已关闭的仅展示不可选)",
|
|
3855
|
+
name: "picked",
|
|
3856
|
+
type: "checkbox",
|
|
3857
|
+
loop: false,
|
|
3858
|
+
choices: tasks.map((t) => {
|
|
3859
|
+
const detail = details.get(String(t.id));
|
|
3860
|
+
const children = Object.values(detail?.children || {});
|
|
3861
|
+
const closedCount = children.filter(
|
|
3862
|
+
(c) => c.status === "closed"
|
|
3863
|
+
).length;
|
|
3864
|
+
// 第二行详情(子任务进度): 换行 + 缩进对齐, 与标题行区分
|
|
3865
|
+
const childInfo = children.length
|
|
3866
|
+
? `\n 子任务${children.length}个(已关${closedCount}), 关闭全部子任务后父任务自动关闭`
|
|
3867
|
+
: "";
|
|
3868
|
+
return {
|
|
3869
|
+
name: `#${t.id} [${TASK_STATUS_LABELS[t.status] || t.status}] ${t.name} (${t.estimate || 0}h)${childInfo}`,
|
|
3870
|
+
value: t,
|
|
3871
|
+
// 已关闭的仅展示, 不可勾选
|
|
3872
|
+
disabled: t.status === "closed" ? "已关闭" : false,
|
|
3873
|
+
};
|
|
3874
|
+
}),
|
|
3875
|
+
},
|
|
3876
|
+
]);
|
|
3877
|
+
if (!picked.length) {
|
|
3878
|
+
console.log(chalk.yellow("未勾选任务, 结束"));
|
|
3879
|
+
return;
|
|
3880
|
+
}
|
|
3881
|
+
// 写操作确认(关闭属敏感操作, 二次确认防误关)
|
|
3882
|
+
const { ok } = await inquirer.prompt([
|
|
3883
|
+
{
|
|
3884
|
+
message: `确认处理勾选的 ${picked.length} 个任务? (未完成的先完成后关闭; 有子任务的逐个关闭子任务, 全关后父任务自动关闭)`,
|
|
3885
|
+
name: "ok",
|
|
3886
|
+
type: "confirm",
|
|
3887
|
+
default: true,
|
|
3888
|
+
},
|
|
3889
|
+
]);
|
|
3890
|
+
if (!ok) {
|
|
3891
|
+
console.log(chalk.yellow("已取消"));
|
|
3892
|
+
return;
|
|
3893
|
+
}
|
|
3894
|
+
// 单任务完成并关闭(叶子任务): 已 done 直接关, 否则先 finish 再 close
|
|
3895
|
+
// 结果进 logs(执行期间只显示spinner进度, 结束后统一输出, 避免与spinner交错)
|
|
3896
|
+
const finishAndClose = async (task, logs) => {
|
|
3897
|
+
if (task.status === "done") {
|
|
3898
|
+
await closeZentaoTask({ zentaosid, taskID: task.id });
|
|
3899
|
+
logs.push(
|
|
3900
|
+
chalk.green(`任务 #${task.id} ${task.name} 已关闭(原状态已完成)`)
|
|
3901
|
+
);
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
// 本次消耗取任务预估(currentConsumed 为增量, 服务端自动累计总消耗)
|
|
3905
|
+
await finishZentaoTask({
|
|
3906
|
+
zentaosid,
|
|
3907
|
+
taskID: task.id,
|
|
3908
|
+
consumed: task.estimate,
|
|
3909
|
+
});
|
|
3910
|
+
await closeZentaoTask({ zentaosid, taskID: task.id });
|
|
3911
|
+
logs.push(chalk.green(`任务 #${task.id} ${task.name} 已完成并关闭`));
|
|
3912
|
+
};
|
|
3913
|
+
let failedCount = 0;
|
|
3914
|
+
let done = 0;
|
|
3915
|
+
const logs = [];
|
|
3916
|
+
Loading$1.start(`处理任务 (0/${picked.length})...`);
|
|
3917
|
+
for (const task of picked) {
|
|
3918
|
+
try {
|
|
3919
|
+
// 详情缺失(预拉阶段失败的)现拉, 防父任务被误判成叶子任务直接 finish
|
|
3920
|
+
const detail =
|
|
3921
|
+
details.get(String(task.id)) ||
|
|
3922
|
+
(await fetchTaskDetail(zentaosid, task.id));
|
|
3923
|
+
const children = Object.values(detail?.children || {});
|
|
3924
|
+
if (children.length) {
|
|
3925
|
+
// 父任务: 逐个完成并关闭子任务, 全关后禅道自动关闭父任务
|
|
3926
|
+
for (const child of children) {
|
|
3927
|
+
if (child.status === "closed") {
|
|
3928
|
+
logs.push(
|
|
3929
|
+
chalk.yellow(
|
|
3930
|
+
` 子任务 #${child.id} ${child.name} 已是关闭状态, 跳过`
|
|
3931
|
+
)
|
|
3932
|
+
);
|
|
3933
|
+
continue;
|
|
3934
|
+
}
|
|
3935
|
+
if (child.status === "done") {
|
|
3936
|
+
await closeZentaoTask({ zentaosid, taskID: child.id });
|
|
3937
|
+
logs.push(
|
|
3938
|
+
chalk.green(
|
|
3939
|
+
` 子任务 #${child.id} ${child.name} 已关闭(原状态已完成)`
|
|
3940
|
+
)
|
|
3941
|
+
);
|
|
3942
|
+
} else {
|
|
3943
|
+
await finishZentaoTask({
|
|
3944
|
+
zentaosid,
|
|
3945
|
+
taskID: child.id,
|
|
3946
|
+
consumed: child.estimate,
|
|
3947
|
+
});
|
|
3948
|
+
await closeZentaoTask({ zentaosid, taskID: child.id });
|
|
3949
|
+
logs.push(
|
|
3950
|
+
chalk.green(` 子任务 #${child.id} ${child.name} 已完成并关闭`)
|
|
3951
|
+
);
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
// 核验父任务最终状态(子任务全关后禅道自动关父任务)
|
|
3955
|
+
const after = await fetchTaskDetail(zentaosid, task.id);
|
|
3956
|
+
if (after?.status === "closed") {
|
|
3957
|
+
logs.push(
|
|
3958
|
+
chalk.green(
|
|
3959
|
+
`父任务 #${task.id} ${task.name} 已自动关闭(子任务全部关闭)`
|
|
3960
|
+
)
|
|
3961
|
+
);
|
|
3962
|
+
} else {
|
|
3963
|
+
logs.push(
|
|
3964
|
+
chalk.yellow(
|
|
3965
|
+
`父任务 #${task.id} ${task.name} 状态 ${after?.status || "?"}, 未自动关闭, 请到禅道手动处理: ${taskUrl(task.id)}`
|
|
3966
|
+
)
|
|
3967
|
+
);
|
|
3968
|
+
}
|
|
3969
|
+
} else {
|
|
3970
|
+
// 叶子任务: 直接完成并关闭
|
|
3971
|
+
await finishAndClose(task, logs);
|
|
3972
|
+
}
|
|
3973
|
+
} catch (e) {
|
|
3974
|
+
// 会话失效上抛, 由 ensureZentaoSession 整体重登重试
|
|
3975
|
+
if (e?.isAuthError) throw e;
|
|
3976
|
+
failedCount += 1;
|
|
3977
|
+
logs.push(
|
|
3978
|
+
chalk.yellow(`任务 #${task.id} ${task.name} 处理失败: ${e.message || String(e)}`)
|
|
3979
|
+
);
|
|
3980
|
+
}
|
|
3981
|
+
done += 1;
|
|
3982
|
+
// ora 的 text 是属性而非方法
|
|
3983
|
+
Loading$1.text = `处理任务 (${done}/${picked.length})...`;
|
|
3984
|
+
}
|
|
3985
|
+
Loading$1.succeed(
|
|
3986
|
+
`处理完成: 成功 ${picked.length - failedCount} 条 / 失败 ${failedCount} 条`
|
|
3987
|
+
);
|
|
3988
|
+
logs.forEach((line) => console.log(line));
|
|
3989
|
+
// 部分失败以非零退出码收尾, 供自动化流程感知重试
|
|
3990
|
+
if (failedCount) {
|
|
3991
|
+
console.log(chalk.red(`失败${failedCount}个, 详情见上方黄字提示`));
|
|
3992
|
+
process.exit(1);
|
|
3993
|
+
}
|
|
3994
|
+
});
|
|
3995
|
+
return;
|
|
3996
|
+
}
|
|
3997
|
+
// plan: 产品选择 -> 计划选取 -> 展示 -> 导出(--export直通一键场景, 不传则收尾交互)
|
|
3998
|
+
const session = await ensureZentaoSession(cliOpts);
|
|
3999
|
+
await session.request(async (zentaosid) => {
|
|
4000
|
+
const product = await selectProduct(zentaosid, cliOpts);
|
|
4001
|
+
const plans = await loadPlanList(zentaosid, product.id);
|
|
4002
|
+
const selected = await selectPlan(plans, cliOpts.name);
|
|
4003
|
+
await showPlanDetail(zentaosid, selected);
|
|
4004
|
+
// 导出范围: --export参数直通(一键场景) > 收尾交互(默认前端)
|
|
4005
|
+
let exportChoice;
|
|
4006
|
+
if (cliOpts.export !== undefined) {
|
|
4007
|
+
validateChoice(cliOpts.export, ["front", "back", "all"], "--export");
|
|
4008
|
+
exportChoice = cliOpts.export;
|
|
4009
|
+
} else {
|
|
4010
|
+
({ exportChoice } = await inquirer.prompt([
|
|
4011
|
+
{
|
|
4012
|
+
message: "是否导出需求文件(按计划生成 .zentao-<计划名>目录)?",
|
|
4013
|
+
name: "exportChoice",
|
|
4014
|
+
type: "list",
|
|
4015
|
+
choices: [
|
|
4016
|
+
{ name: "导出: 前端需求", value: "front" }, // 默认第一项
|
|
4017
|
+
{ name: "导出: 后端需求", value: "back" },
|
|
4018
|
+
{ name: "导出: 全部需求", value: "all" },
|
|
4019
|
+
{ name: "否", value: "none" },
|
|
4020
|
+
],
|
|
4021
|
+
},
|
|
4022
|
+
]));
|
|
4023
|
+
}
|
|
4024
|
+
if (exportChoice !== "none") {
|
|
4025
|
+
await exportPlanStories(zentaosid, selected, exportChoice);
|
|
4026
|
+
}
|
|
4027
|
+
});
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
/**
|
|
4031
|
+
* 命令注册(index.js 只登记调用, 命令定义全部收在本模块内)
|
|
4032
|
+
* @param program commander program 实例
|
|
4033
|
+
* @param helpers { markHandled, addHelpExample }
|
|
4034
|
+
*/
|
|
4035
|
+
function registerStoryCommand(program, { markHandled, addHelpExample }) {
|
|
4036
|
+
program
|
|
4037
|
+
.command("story <action> [name]")
|
|
4038
|
+
.description(
|
|
4039
|
+
"禅道需求: login 登录 | plan 选计划看需求列表并可导出(按计划生成需求目录+工作区) | detail 看需求详情 | task 勾选项目需求在线建任务 | subtask 给已建任务单独拆子任务 | close 完成并关闭指派给我的任务"
|
|
4040
|
+
)
|
|
4041
|
+
// 子命令的 option 独立于 program 级: story login --cache 中 --cache 出现在子命令位, 须在子命令上重复注册
|
|
4042
|
+
.option("--username <name>", "禅道账号(story命令用)")
|
|
4043
|
+
.option("--password <pwd>", "禅道密码(story命令用, 日常建议用--cache)")
|
|
4044
|
+
.option("--cache", "凭据静默走缓存")
|
|
4045
|
+
.option("--product <id>", "禅道产品ID(plan用, 不传则交互选择)")
|
|
4046
|
+
.option("--export <scope>", "plan导出范围: front|back|all(传了跳过收尾询问直接导出)")
|
|
4047
|
+
.option("--type <t>", "task任务类型: front|back(传了跳过类型询问与确认, 一键创建)")
|
|
4048
|
+
.option("--split", "task时创建父任务并自动拆分子任务(>8h, 每个8h末个补余); subtask时跳过确认直接拆")
|
|
4049
|
+
.option("--split-only", "task时仅拆分子任务(无父任务的>8h需求自动先建父任务再拆)")
|
|
4050
|
+
.action((action, name, options) => {
|
|
4051
|
+
markHandled(); // 同步置位, 防止 index.js 落入默认翻译分支
|
|
4052
|
+
storyMain(action, { ...program.opts(), ...options, name }).catch(
|
|
4053
|
+
(error) => {
|
|
4054
|
+
// 兜底停掉可能挂着的spinner + 红字报错 + 非零退出码(供自动化判断成败)
|
|
4055
|
+
Loading$1.fail("执行失败");
|
|
4056
|
+
console.error(chalk.red(error.message || String(error)));
|
|
4057
|
+
process.exit(1);
|
|
4058
|
+
}
|
|
4059
|
+
);
|
|
4060
|
+
});
|
|
4061
|
+
// help示例经 helpers 注入 index.js 的 helpExamples, 自动参与列对齐
|
|
4062
|
+
addHelpExample("fe-it-beta story login", "禅道登录, 缓存zentaosid");
|
|
4063
|
+
addHelpExample("fe-it-beta story plan --product 87", "选计划看列表, 交互选导出");
|
|
4064
|
+
addHelpExample("fe-it-beta story detail 需求ID", "按需求ID查看详情");
|
|
4065
|
+
addHelpExample("fe-it-beta story task 项目ID", "选项目勾选需求, 交互建禅道任务");
|
|
4066
|
+
addHelpExample(
|
|
4067
|
+
"fe-it-beta story task 2915 --type front --split --cache",
|
|
4068
|
+
"一键创建前端任务并自动拆分子任务"
|
|
4069
|
+
);
|
|
4070
|
+
addHelpExample("fe-it-beta story subtask 父任务ID", "给已建任务单独拆子任务(按剩余工时)");
|
|
4071
|
+
addHelpExample(
|
|
4072
|
+
"fe-it-beta story close 2915",
|
|
4073
|
+
"完成并关闭项目中指派给我的任务(子任务全关父任务自动关闭)"
|
|
4074
|
+
);
|
|
4075
|
+
addHelpExample(
|
|
4076
|
+
"fe-it-beta story plan --product 87 --export front --cache",
|
|
4077
|
+
"一键导出前端需求并生成多项目工作区"
|
|
4078
|
+
);
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
/**
|
|
4082
|
+
* @fileoverview 不要单独中文
|
|
4083
|
+
* @author ypf
|
|
4084
|
+
*/
|
|
4085
|
+
//------------------------------------------------------------------------------
|
|
4086
|
+
// Rule Definition
|
|
4087
|
+
//------------------------------------------------------------------------------
|
|
4088
|
+
|
|
4089
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
4090
|
+
|
|
4091
|
+
// 判断字符串是否是中文
|
|
4092
|
+
const isChinese = (str) => {
|
|
4093
|
+
return /[\u4e00-\u9fa5]+/.test(str);
|
|
4094
|
+
};
|
|
4095
|
+
//去除特殊字符,包含空格
|
|
4096
|
+
function trimSpecial(string = "", formatter) {
|
|
4097
|
+
// const pattern =
|
|
4098
|
+
// /[`~!@#$^\-&*()=|{}':;',\\\[\]\.<>\/?~!@#¥……&*()——|{}【】';:""'。,、?\s]/g;
|
|
4099
|
+
// return string.replace(pattern, "");
|
|
4100
|
+
// console.log(string);
|
|
4101
|
+
// 获取开头空白符的位置
|
|
4102
|
+
const startIdx = string.search(/\S/) - 1;
|
|
4103
|
+
// 获取结尾空白符的位置
|
|
4104
|
+
const endIdx = string.search(/\S\s*$/) + 1;
|
|
4105
|
+
// 获取开头和结尾的字符串
|
|
4106
|
+
const startStr = string.slice(0, startIdx + 1);
|
|
4107
|
+
const endStr = string.slice(endIdx);
|
|
4108
|
+
// 获取中间的字符串
|
|
4109
|
+
const middle = string.slice(startIdx + 1, endIdx);
|
|
4110
|
+
// 取出中间字符串的换行符
|
|
4111
|
+
const middleStr = middle.replace(/\n/g, "");
|
|
4112
|
+
return startStr + formatter(middleStr) + endStr;
|
|
4113
|
+
}
|
|
4114
|
+
// 判断当前节点是否已经翻译过
|
|
4115
|
+
function isTranslate(node) {
|
|
4116
|
+
if (
|
|
4117
|
+
node.parent?.parent?.parent?.type === "CallExpression" &&
|
|
4118
|
+
node.parent?.parent?.parent?.callee?.name === "$hxt"
|
|
4119
|
+
) {
|
|
4120
|
+
return true;
|
|
4121
|
+
}
|
|
4122
|
+
// console不翻译
|
|
4123
|
+
if (
|
|
4124
|
+
node.parent.type === "CallExpression" &&
|
|
4125
|
+
node.parent.callee?.object?.name === "console"
|
|
4126
|
+
) {
|
|
4127
|
+
return true;
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
return false;
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
// 空key
|
|
4134
|
+
const emptyKeyRules = (context) => {
|
|
4135
|
+
return {
|
|
4136
|
+
CallExpression(node) {
|
|
4137
|
+
if (node.callee.name === "$hxt") {
|
|
4138
|
+
const properties = node.arguments[0]?.properties || [];
|
|
4139
|
+
// 如果属性是key且值为空
|
|
4140
|
+
const result = properties.some((item) => {
|
|
4141
|
+
// 去除key空格
|
|
4142
|
+
const key = item.key.name.replace(/\s/g, "");
|
|
4143
|
+
if (key === "key") {
|
|
4144
|
+
// 去除value空格
|
|
4145
|
+
// value是模版字符串
|
|
4146
|
+
let value = "";
|
|
4147
|
+
if (item.value.type === "TemplateLiteral") {
|
|
4148
|
+
value = item.value.quasis[0].value.raw.replace(/\s/g, "");
|
|
4149
|
+
} else if (item.value.type === "Literal") {
|
|
4150
|
+
value = item.value.value.replace(/\s/g, "");
|
|
4151
|
+
}
|
|
4152
|
+
if (value === "") {
|
|
4153
|
+
return true;
|
|
4154
|
+
}
|
|
1730
4155
|
}
|
|
1731
4156
|
});
|
|
1732
4157
|
if (result) {
|
|
@@ -2821,9 +5246,9 @@ async function customColumnModule (lintc, fix, rule, parentRule, cliOpts = {}) {
|
|
|
2821
5246
|
}
|
|
2822
5247
|
|
|
2823
5248
|
async function getColumn(moduleArr = [], dynamicsArr = [], parentRule) {
|
|
2824
|
-
let
|
|
5249
|
+
let auth;
|
|
2825
5250
|
try {
|
|
2826
|
-
|
|
5251
|
+
auth = await createAuth({
|
|
2827
5252
|
env: "sit",
|
|
2828
5253
|
username: "superAdmin",
|
|
2829
5254
|
password: "admin1",
|
|
@@ -2833,16 +5258,36 @@ async function getColumn(moduleArr = [], dynamicsArr = [], parentRule) {
|
|
|
2833
5258
|
return;
|
|
2834
5259
|
}
|
|
2835
5260
|
const requestArr = moduleArr.map(({ module, path }) =>
|
|
2836
|
-
request
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
5261
|
+
auth.request((t) =>
|
|
5262
|
+
request$1({
|
|
5263
|
+
url: `http://sit-hxjf.hongxinshop.com/api-item/api/customizedColumns/find`,
|
|
5264
|
+
method: "POST",
|
|
5265
|
+
json: true,
|
|
5266
|
+
body: { module },
|
|
5267
|
+
headers: {
|
|
5268
|
+
Authorization: `Bearer ${t}`,
|
|
5269
|
+
"x-request-vaildate": "open",
|
|
5270
|
+
},
|
|
5271
|
+
})
|
|
5272
|
+
.then((body) => {
|
|
5273
|
+
if (isAuthError(null, body)) {
|
|
5274
|
+
throw Object.assign(new Error("登录态已失效"), {
|
|
5275
|
+
isAuthError: true,
|
|
5276
|
+
});
|
|
5277
|
+
}
|
|
5278
|
+
return body;
|
|
5279
|
+
})
|
|
5280
|
+
.catch((e) => {
|
|
5281
|
+
if (e.isAuthError) throw e;
|
|
5282
|
+
// request-promise 对非2xx reject StatusCodeError(含 response/body)
|
|
5283
|
+
if (isAuthError(e.response, e.response?.body)) {
|
|
5284
|
+
throw Object.assign(new Error("登录态已失效"), {
|
|
5285
|
+
isAuthError: true,
|
|
5286
|
+
});
|
|
5287
|
+
}
|
|
5288
|
+
throw e;
|
|
5289
|
+
})
|
|
5290
|
+
)
|
|
2846
5291
|
);
|
|
2847
5292
|
Promise.allSettled(requestArr).then(async (res) => {
|
|
2848
5293
|
const tableData = [];
|
|
@@ -3088,6 +5533,16 @@ const helpExamples = [
|
|
|
3088
5533
|
],
|
|
3089
5534
|
["fe-it-beta -excel --file export.xlsx --lang vi --cache", ""],
|
|
3090
5535
|
];
|
|
5536
|
+
|
|
5537
|
+
// ===== 分支子命令注册(命令定义在各分支模块内, 此处只登记) =====
|
|
5538
|
+
// 新增分支模块时: 模块导出 registerXxxCommand(program, helpers) 并在此追加一行
|
|
5539
|
+
let storyHandled = false;
|
|
5540
|
+
registerStoryCommand(commander.program, {
|
|
5541
|
+
// 同步置位标记, 用于下方分发互斥(子命令调用后不再落入默认翻译分支)
|
|
5542
|
+
markHandled: () => (storyHandled = true),
|
|
5543
|
+
addHelpExample: (cmd, desc) => helpExamples.push([cmd, desc]),
|
|
5544
|
+
});
|
|
5545
|
+
|
|
3091
5546
|
commander.program.addHelpText(
|
|
3092
5547
|
"after",
|
|
3093
5548
|
"\n" +
|
|
@@ -3103,6 +5558,8 @@ commander.program.addHelpText(
|
|
|
3103
5558
|
})
|
|
3104
5559
|
.join("\n")
|
|
3105
5560
|
);
|
|
5561
|
+
// 占位action: 阻止commander在注册子命令后, 无参调用直接输出help退出(保持落入下方默认翻译分支)
|
|
5562
|
+
commander.program.action(() => {});
|
|
3106
5563
|
commander.program.parse(process.argv);
|
|
3107
5564
|
|
|
3108
5565
|
// 判断命令参数
|
|
@@ -3138,7 +5595,7 @@ const rules = [
|
|
|
3138
5595
|
],
|
|
3139
5596
|
},
|
|
3140
5597
|
];
|
|
3141
|
-
if (sass) {
|
|
5598
|
+
if (storyHandled) ; else if (sass) {
|
|
3142
5599
|
sassMain(cliOpts).catch((error) => console.error(error));
|
|
3143
5600
|
} else if (upload) {
|
|
3144
5601
|
upload$1(cliOpts).catch((error) => console.error(error));
|