@dreamor/atlas-cli 0.7.0
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/README.md +84 -0
- package/bin/atlas.js +5 -0
- package/dist/adapters/atlas/auth/index.js +2 -0
- package/dist/adapters/atlas/auth/login.js +107 -0
- package/dist/adapters/atlas/auth/session.js +154 -0
- package/dist/adapters/atlas/cli.js +502 -0
- package/dist/adapters/atlas/commands/_output_schema.js +100 -0
- package/dist/adapters/atlas/commands/actual/_logic.js +41 -0
- package/dist/adapters/atlas/commands/actual/index.js +117 -0
- package/dist/adapters/atlas/commands/auth.js +1 -0
- package/dist/adapters/atlas/commands/baseline/index.js +122 -0
- package/dist/adapters/atlas/commands/compare/_logic.js +39 -0
- package/dist/adapters/atlas/commands/compare/index.js +72 -0
- package/dist/adapters/atlas/commands/exec.js +58 -0
- package/dist/adapters/atlas/commands/project/index.js +179 -0
- package/dist/adapters/atlas/commands/schema.js +30 -0
- package/dist/adapters/atlas/commands/suggest.js +56 -0
- package/dist/adapters/atlas/commands/update.js +106 -0
- package/dist/adapters/atlas/daemon/index.js +64 -0
- package/dist/adapters/atlas/dict/index.js +41 -0
- package/dist/adapters/atlas/http/client.js +151 -0
- package/dist/adapters/atlas/http/index.js +1 -0
- package/dist/adapters/atlas/schema/actual.js +16 -0
- package/dist/adapters/atlas/schema/baseline.js +34 -0
- package/dist/adapters/atlas/schema/department.js +11 -0
- package/dist/adapters/atlas/schema/index.js +4 -0
- package/dist/adapters/atlas/schema/project.js +13 -0
- package/dist/adapters/atlas/util/constants.js +4 -0
- package/dist/adapters/atlas/util/env.js +8 -0
- package/dist/adapters/atlas/util/errors.js +45 -0
- package/dist/adapters/atlas/util/helpers.js +17 -0
- package/dist/adapters/atlas/util/months.js +41 -0
- package/dist/adapters/atlas/util/output-limit.js +20 -0
- package/dist/adapters/atlas/util/output.js +67 -0
- package/dist/adapters/atlas/util/paths.js +40 -0
- package/dist/adapters/atlas/util/secure-fs.js +41 -0
- package/dist/adapters/atlas/util/time.js +17 -0
- package/dist/adapters/atlas/util/version.js +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createClient } from '../../http/client.js';
|
|
2
|
+
import { isJsonMode, jsonOk, log } from '../../util/output.js';
|
|
3
|
+
import { enforceOutputLimit } from '../../util/output-limit.js';
|
|
4
|
+
import { ConfigError, SessionExpiredError } from '../../util/errors.js';
|
|
5
|
+
import { expandMonths } from '../../util/months.js';
|
|
6
|
+
import { resolveSecureExportPath, secureWriteFile } from '../../util/secure-fs.js';
|
|
7
|
+
import { annotateWithMonth, aggregateByAxis, filterByStaff, } from './_logic.js';
|
|
8
|
+
function getProjectId(opts) {
|
|
9
|
+
const pid = opts.projectId ?? process.env.BANMA_PROJECT_ID ?? '';
|
|
10
|
+
if (!pid)
|
|
11
|
+
throw new ConfigError('请指定 --project-id 或设置 BANMA_PROJECT_ID 环境变量');
|
|
12
|
+
return pid;
|
|
13
|
+
}
|
|
14
|
+
async function fetchActual(pid, month) {
|
|
15
|
+
const client = createClient();
|
|
16
|
+
return client.post('/yuntu-service/manpower/weekly/summaryByTeam.json', { projectId: pid, month });
|
|
17
|
+
}
|
|
18
|
+
export async function showCmd(staffId, opts) {
|
|
19
|
+
const pid = getProjectId(opts);
|
|
20
|
+
const m = opts.month ?? expandMonths()[0];
|
|
21
|
+
const data = await fetchActual(pid, m);
|
|
22
|
+
const filtered = filterByStaff(data, staffId);
|
|
23
|
+
if (opts.json || isJsonMode()) {
|
|
24
|
+
jsonOk({ staffId, month: m, personnel: filtered });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (filtered.length === 0) {
|
|
28
|
+
log(`${staffId} 在 ${m} 无实际工时数据`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const p of filtered) {
|
|
32
|
+
log(` ${p.staffName} (${p.staffId}): ${p.manpower.toFixed(2)} 人月`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function monthCmd(opts) {
|
|
36
|
+
const pid = getProjectId(opts);
|
|
37
|
+
const months = opts.month ? [opts.month] : expandMonths(opts.from, opts.to);
|
|
38
|
+
const all = [];
|
|
39
|
+
const failedMonths = [];
|
|
40
|
+
for (const m of months) {
|
|
41
|
+
try {
|
|
42
|
+
const data = await fetchActual(pid, m);
|
|
43
|
+
all.push(...annotateWithMonth(data, m));
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (e instanceof SessionExpiredError)
|
|
47
|
+
throw e;
|
|
48
|
+
failedMonths.push({ month: m, error: e instanceof Error ? e.message : String(e) });
|
|
49
|
+
log(`月 ${m} 拉取失败: ${e instanceof Error ? e.message : e}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (opts.json || isJsonMode()) {
|
|
53
|
+
jsonOk({ projectId: pid, entries: all }, { failedMonths });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (all.length === 0) {
|
|
57
|
+
log('无实际工时数据');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const p of all) {
|
|
61
|
+
log(` ${p.staffName}: ${p.manpower.toFixed(2)} (${p.role ?? ''})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function summaryCmd(opts) {
|
|
65
|
+
const pid = getProjectId(opts);
|
|
66
|
+
const months = opts.month ? [opts.month] : expandMonths(opts.from, opts.to);
|
|
67
|
+
const all = [];
|
|
68
|
+
const failedMonths = [];
|
|
69
|
+
for (const m of months) {
|
|
70
|
+
try {
|
|
71
|
+
all.push(...annotateWithMonth(await fetchActual(pid, m), m));
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e instanceof SessionExpiredError)
|
|
75
|
+
throw e;
|
|
76
|
+
failedMonths.push({ month: m, error: e instanceof Error ? e.message : String(e) });
|
|
77
|
+
log(`月 ${m} 拉取失败: ${e instanceof Error ? e.message : e}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const axis = (opts.by ?? 'month');
|
|
81
|
+
const rows = aggregateByAxis(all, axis);
|
|
82
|
+
if (opts.json || isJsonMode()) {
|
|
83
|
+
jsonOk({ rows }, { failedMonths });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const r of rows) {
|
|
87
|
+
log(` ${r[axis]}: ${r.manpower.toFixed(2)} 人月`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function exportCmd(opts) {
|
|
91
|
+
const pid = getProjectId(opts);
|
|
92
|
+
const months = expandMonths(opts.from, opts.to);
|
|
93
|
+
const rows = [];
|
|
94
|
+
const failedMonths = [];
|
|
95
|
+
for (const m of months) {
|
|
96
|
+
try {
|
|
97
|
+
const data = await fetchActual(pid, m);
|
|
98
|
+
rows.push(...annotateWithMonth(data, m));
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
if (e instanceof SessionExpiredError)
|
|
102
|
+
throw e;
|
|
103
|
+
failedMonths.push({ month: m, error: e instanceof Error ? e.message : String(e) });
|
|
104
|
+
log(`月 ${m} 拉取失败: ${e instanceof Error ? e.message : e}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const content = opts.format === 'csv'
|
|
108
|
+
? (await import('papaparse')).default.unparse(rows)
|
|
109
|
+
: JSON.stringify(rows, null, 2);
|
|
110
|
+
enforceOutputLimit(content);
|
|
111
|
+
const safePath = resolveSecureExportPath(opts.out);
|
|
112
|
+
await secureWriteFile(safePath, content);
|
|
113
|
+
log(`已导出 ${rows.length} 条记录`);
|
|
114
|
+
if (opts.json || isJsonMode()) {
|
|
115
|
+
jsonOk({ exported: rows.length, format: opts.format, out: safePath }, { failedMonths });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { loginCmd as authLoginCmd, statusCmd as authStatusCmd } from '../auth/index.js';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createClient } from '../../http/client.js';
|
|
2
|
+
import { isJsonMode, jsonOk, log } from '../../util/output.js';
|
|
3
|
+
import { enforceOutputLimit } from '../../util/output-limit.js';
|
|
4
|
+
import { ConfigError } from '../../util/errors.js';
|
|
5
|
+
import { expandMonths } from '../../util/months.js';
|
|
6
|
+
import { monthTsToKey } from '../../util/time.js';
|
|
7
|
+
import { resolveSecureExportPath, secureWriteFile } from '../../util/secure-fs.js';
|
|
8
|
+
function getProjectId(opts) {
|
|
9
|
+
const pid = opts.projectId ?? process.env.BANMA_PROJECT_ID ?? '';
|
|
10
|
+
if (!pid)
|
|
11
|
+
throw new ConfigError('请指定 --project-id 或设置 BANMA_PROJECT_ID 环境变量');
|
|
12
|
+
return pid;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 月基线 API 返回 data 为数组,每项含 linePlanMonthDetailList
|
|
16
|
+
*/
|
|
17
|
+
async function fetchBaselineMonth(pid, month) {
|
|
18
|
+
const client = createClient();
|
|
19
|
+
const body = { projectId: pid, month };
|
|
20
|
+
return client.post('/yuntu-service/line/plan/month/select.json', body);
|
|
21
|
+
}
|
|
22
|
+
/** 强化的类型守卫(C6):检查 month 为有限 number,manpower 可选 */
|
|
23
|
+
function isMonthKey(v) {
|
|
24
|
+
return typeof v === 'object' && v !== null
|
|
25
|
+
&& typeof v.month === 'number'
|
|
26
|
+
&& Number.isFinite(v.month);
|
|
27
|
+
}
|
|
28
|
+
export async function monthCmd(opts) {
|
|
29
|
+
const pid = getProjectId(opts);
|
|
30
|
+
const months = opts.month ? [opts.month] : expandMonths(opts.from, opts.to);
|
|
31
|
+
const allDetails = [];
|
|
32
|
+
for (const m of months) {
|
|
33
|
+
const data = await fetchBaselineMonth(pid, m);
|
|
34
|
+
// data 是数组,每项含 linePlanMonthDetailList
|
|
35
|
+
for (const item of (data ?? [])) {
|
|
36
|
+
const details = (item.linePlanMonthDetailList ?? []);
|
|
37
|
+
for (const d of details) {
|
|
38
|
+
if (isMonthKey(d)) {
|
|
39
|
+
allDetails.push({
|
|
40
|
+
month: d.month ? monthTsToKey(d.month) : m,
|
|
41
|
+
manpower: d.manpower ?? 0,
|
|
42
|
+
role: item.role,
|
|
43
|
+
departmentName: item.departmentName,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// 聚合
|
|
50
|
+
const byMonth = new Map();
|
|
51
|
+
for (const d of allDetails) {
|
|
52
|
+
byMonth.set(d.month, (byMonth.get(d.month) ?? 0) + d.manpower);
|
|
53
|
+
}
|
|
54
|
+
const monthKeys = [...byMonth.keys()].sort();
|
|
55
|
+
const total = monthKeys.reduce((s, m) => s + (byMonth.get(m) ?? 0), 0);
|
|
56
|
+
if (opts.json || isJsonMode()) {
|
|
57
|
+
jsonOk({ projectId: pid, months: monthKeys, entries: allDetails, totalManpower: Math.round(total * 100) / 100 });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
log(`项目 ${pid} 基线人力:`);
|
|
61
|
+
for (const m of monthKeys) {
|
|
62
|
+
log(` ${m}: ${(byMonth.get(m) ?? 0).toFixed(2)} 人月`);
|
|
63
|
+
}
|
|
64
|
+
log(` 合计: ${total.toFixed(2)} 人月`);
|
|
65
|
+
}
|
|
66
|
+
export async function summaryCmd(opts) {
|
|
67
|
+
const pid = getProjectId(opts);
|
|
68
|
+
const months = opts.month ? [opts.month] : expandMonths(opts.from, opts.to);
|
|
69
|
+
const allDetails = [];
|
|
70
|
+
for (const m of months) {
|
|
71
|
+
const data = await fetchBaselineMonth(pid, m);
|
|
72
|
+
for (const item of (data ?? [])) {
|
|
73
|
+
for (const d of (item.linePlanMonthDetailList ?? [])) {
|
|
74
|
+
if (isMonthKey(d)) {
|
|
75
|
+
allDetails.push({ month: monthTsToKey(d.month), manpower: d.manpower ?? 0, role: item.role, departmentName: item.departmentName });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const axis = opts.by ?? 'month';
|
|
81
|
+
const groups = new Map();
|
|
82
|
+
for (const d of allDetails) {
|
|
83
|
+
const k = axis === 'month' ? d.month : axis === 'department' ? (d.departmentName ?? '其他') : (d.role ?? '其他');
|
|
84
|
+
groups.set(k, (groups.get(k) ?? 0) + d.manpower);
|
|
85
|
+
}
|
|
86
|
+
const rows = [...groups.entries()].map(([k, v]) => ({ [axis]: k, manpower: Math.round(v * 100) / 100 }));
|
|
87
|
+
if (opts.json || isJsonMode()) {
|
|
88
|
+
jsonOk({ rows });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
log(`汇总:`);
|
|
92
|
+
for (const r of rows) {
|
|
93
|
+
log(` ${String(r[axis])}: ${r.manpower.toFixed(2)} 人月`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function exportCmd(opts) {
|
|
97
|
+
const pid = getProjectId(opts);
|
|
98
|
+
const months = expandMonths(opts.from, opts.to);
|
|
99
|
+
const rows = [];
|
|
100
|
+
for (const m of months) {
|
|
101
|
+
const data = await fetchBaselineMonth(pid, m);
|
|
102
|
+
for (const item of (data ?? [])) {
|
|
103
|
+
for (const d of (item.linePlanMonthDetailList ?? [])) {
|
|
104
|
+
if (isMonthKey(d)) {
|
|
105
|
+
rows.push({ projectId: pid, month: monthTsToKey(d.month), role: item.role ?? '', manpower: d.manpower ?? 0 });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const content = opts.format === 'csv'
|
|
111
|
+
? (await import('papaparse')).default.unparse(rows)
|
|
112
|
+
: JSON.stringify(rows, null, 2);
|
|
113
|
+
enforceOutputLimit(content);
|
|
114
|
+
const safePath = resolveSecureExportPath(opts.out);
|
|
115
|
+
await secureWriteFile(safePath, content);
|
|
116
|
+
log(`已导出 ${rows.length} 条记录`);
|
|
117
|
+
if (opts.json || isJsonMode()) {
|
|
118
|
+
jsonOk({ exported: rows.length, format: opts.format, out: safePath });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// 注:baseline fill / import(服务端写操作)已移除,CLI 聚焦查询与导出。
|
|
122
|
+
// 未来按需恢复时,同步接通 ATLAS_SANDBOX dry-run 护卫与导出/导入字节护栏。
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compare 命令的纯逻辑层。
|
|
3
|
+
*
|
|
4
|
+
* 与命令解析层(index.ts)分离:这里不做 IO、不调 API、不输出,
|
|
5
|
+
* 只做数据变换(聚合、阈值过滤)。
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* 按轴分组聚合 manpower。
|
|
9
|
+
* - month → 直接使用 month 字段
|
|
10
|
+
* - department → departmentName ?? '其他'
|
|
11
|
+
* - role → role ?? '其他'
|
|
12
|
+
*/
|
|
13
|
+
export function groupByAxis(entries, axis) {
|
|
14
|
+
const map = new Map();
|
|
15
|
+
for (const e of entries) {
|
|
16
|
+
const k = axis === 'department' ? (e.departmentName ?? '其他')
|
|
17
|
+
: axis === 'role' ? (e.role ?? '其他')
|
|
18
|
+
: e.month;
|
|
19
|
+
map.set(k, (map.get(k) ?? 0) + e.manpower);
|
|
20
|
+
}
|
|
21
|
+
return map;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 合并基线 map + 实际 map → CompareRow 数组。
|
|
25
|
+
* 所有 axis key 去重排序,每个 key 生成一条 { [axis], baselineManpower, actualManpower, diff }。
|
|
26
|
+
*/
|
|
27
|
+
export function mergeBaselineActual(blMap, acMap, axis, threshold) {
|
|
28
|
+
const keys = [...new Set([...blMap.keys(), ...acMap.keys()])].sort();
|
|
29
|
+
let rows = keys.map((k) => ({
|
|
30
|
+
[axis]: k,
|
|
31
|
+
baselineManpower: Math.round((blMap.get(k) ?? 0) * 100) / 100,
|
|
32
|
+
actualManpower: Math.round((acMap.get(k) ?? 0) * 100) / 100,
|
|
33
|
+
diff: Math.round(((acMap.get(k) ?? 0) - (blMap.get(k) ?? 0)) * 100) / 100,
|
|
34
|
+
}));
|
|
35
|
+
if (threshold > 0) {
|
|
36
|
+
rows = rows.filter((r) => Math.abs(r.diff) >= threshold);
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createClient } from '../../http/client.js';
|
|
2
|
+
import { isJsonMode, jsonOk, log } from '../../util/output.js';
|
|
3
|
+
import { ConfigError, SessionExpiredError } from '../../util/errors.js';
|
|
4
|
+
import { expandMonths } from '../../util/months.js';
|
|
5
|
+
import { monthTsToKey } from '../../util/time.js';
|
|
6
|
+
import { groupByAxis, mergeBaselineActual, } from './_logic.js';
|
|
7
|
+
function getProjectId(opts) {
|
|
8
|
+
const pid = opts.projectId ?? process.env.BANMA_PROJECT_ID ?? '';
|
|
9
|
+
if (!pid)
|
|
10
|
+
throw new ConfigError('请指定 --project-id 或设置 BANMA_PROJECT_ID 环境变量');
|
|
11
|
+
return pid;
|
|
12
|
+
}
|
|
13
|
+
export async function compareCmd(opts) {
|
|
14
|
+
const pid = getProjectId(opts);
|
|
15
|
+
const months = opts.month ? [opts.month] : expandMonths(opts.from, opts.to);
|
|
16
|
+
const allBl = [];
|
|
17
|
+
const allAc = [];
|
|
18
|
+
const client = createClient();
|
|
19
|
+
const failedMonths = [];
|
|
20
|
+
for (const m of months) {
|
|
21
|
+
try {
|
|
22
|
+
const blData = await client.post('/yuntu-service/line/plan/month/select.json', { projectId: pid, month: m });
|
|
23
|
+
for (const item of (blData ?? [])) {
|
|
24
|
+
const details = (item.linePlanMonthDetailList ?? []);
|
|
25
|
+
for (const d of details) {
|
|
26
|
+
if (d && typeof d === 'object') {
|
|
27
|
+
const monthVal = d.month;
|
|
28
|
+
const monthKey = typeof monthVal === 'number'
|
|
29
|
+
? monthTsToKey(monthVal)
|
|
30
|
+
: m;
|
|
31
|
+
allBl.push({ month: monthKey, manpower: Number(d.manpower ?? 0), role: item.role, departmentName: item.departmentName });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (e instanceof SessionExpiredError)
|
|
38
|
+
throw e;
|
|
39
|
+
failedMonths.push({ month: m, error: `基线拉取失败: ${e instanceof Error ? e.message : String(e)}` });
|
|
40
|
+
}
|
|
41
|
+
// 指数退避:避免连续触发限流(B2)
|
|
42
|
+
try {
|
|
43
|
+
const acData = await client.post('/yuntu-service/manpower/weekly/summaryByTeam.json', { projectId: pid, month: m });
|
|
44
|
+
for (const p of acData ?? []) {
|
|
45
|
+
allAc.push({ month: m, manpower: p.manpower ?? 0, role: p.role, departmentName: p.departmentName });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
if (e instanceof SessionExpiredError)
|
|
50
|
+
throw e;
|
|
51
|
+
failedMonths.push({ month: m, error: `实际工时拉取失败: ${e instanceof Error ? e.message : String(e)}` });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const axis = (opts.by ?? 'month');
|
|
55
|
+
const blMap = groupByAxis(allBl, axis);
|
|
56
|
+
const acMap = groupByAxis(allAc, axis);
|
|
57
|
+
// C4: 阈值参数校验
|
|
58
|
+
const threshold = Number(opts.threshold ?? '0');
|
|
59
|
+
if (!Number.isFinite(threshold) || threshold < 0)
|
|
60
|
+
throw new ConfigError(`--threshold 必须为非负数,实际: "${opts.threshold}"`);
|
|
61
|
+
const rows = mergeBaselineActual(blMap, acMap, axis, threshold);
|
|
62
|
+
if (opts.json || isJsonMode()) {
|
|
63
|
+
jsonOk({ rows }, { failedMonths });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
log(`项目 ${pid} 对比:${axis === 'month' ? '按月份' : axis === 'department' ? '按部门' : '按角色'}`);
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
const flag = opts.flagOverrun && r.actualManpower > r.baselineManpower ? ' ⚠️' : '';
|
|
69
|
+
log(` ${String(r[axis])}: 基线 ${r.baselineManpower} / 实际 ${r.actualManpower} / 差异 ${r.diff > 0 ? '+' : ''}${r.diff}${flag}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// monthTsToKey 已从 util/time.ts 导入
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { isJsonMode, jsonOk, log } from '../util/output.js';
|
|
4
|
+
import { ConfigError } from '../util/errors.js';
|
|
5
|
+
const ExecStepSchema = z.object({
|
|
6
|
+
command: z.string().regex(/^atlas( [a-z\-]+)+$/, 'command 必须是 atlas 子命令,如 "atlas auth login"'),
|
|
7
|
+
args: z.array(z.string()).optional(),
|
|
8
|
+
});
|
|
9
|
+
const ExecPlanSchema = z.object({
|
|
10
|
+
steps: z.array(ExecStepSchema).max(100, '步骤数不能超过 100'),
|
|
11
|
+
});
|
|
12
|
+
export async function execCmd(opts) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = await readFile(opts.planFile, 'utf-8');
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
throw new ConfigError(`无法读取计划文件: ${e instanceof Error ? e.message : String(e)}`);
|
|
19
|
+
}
|
|
20
|
+
let plan;
|
|
21
|
+
try {
|
|
22
|
+
plan = ExecPlanSchema.parse(JSON.parse(raw));
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof z.ZodError) {
|
|
26
|
+
throw new ConfigError(`计划文件校验失败: ${e.errors.map(ee => `${ee.path.join('.')}: ${ee.message}`).join('; ')}`);
|
|
27
|
+
}
|
|
28
|
+
throw new ConfigError(`计划文件格式错误: ${e instanceof Error ? e.message : String(e)}`);
|
|
29
|
+
}
|
|
30
|
+
log(`执行计划: ${plan.steps.length} 个步骤`);
|
|
31
|
+
const results = [];
|
|
32
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
33
|
+
const step = plan.steps[i];
|
|
34
|
+
log(`[${i + 1}/${plan.steps.length}] ${step.command}`);
|
|
35
|
+
try {
|
|
36
|
+
// 执行时始终用 spawn('atlas', [...subcmd, ...args]) 数组形式,禁止字符串 shell
|
|
37
|
+
// 当前阶段只注册不执行,未来实装时遵循上述原则
|
|
38
|
+
results.push({ step: i + 1, command: step.command, status: 'registered' });
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
results.push({
|
|
42
|
+
step: i + 1,
|
|
43
|
+
command: step.command,
|
|
44
|
+
status: `failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
45
|
+
});
|
|
46
|
+
if (opts.json || isJsonMode()) {
|
|
47
|
+
jsonOk({ results }, { completed: i, total: plan.steps.length });
|
|
48
|
+
}
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (opts.json || isJsonMode()) {
|
|
53
|
+
jsonOk({ results });
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
log('计划执行完毕');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createClient } from '../../http/client.js';
|
|
2
|
+
import { isJsonMode, jsonOk, log, table } from '../../util/output.js';
|
|
3
|
+
import { getBanmaProjectId } from '../../util/env.js';
|
|
4
|
+
import { AtlasError, ConfigError } from '../../util/errors.js';
|
|
5
|
+
import { readFile, unlink } from 'fs/promises';
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
import { getLinkFile, getCacheFile, getCacheDir, getAtlasHome } from '../../util/paths.js';
|
|
8
|
+
import { secureMkdir, secureWriteFile } from '../../util/secure-fs.js';
|
|
9
|
+
const LINK_FILE = getLinkFile();
|
|
10
|
+
const PROJECT_CACHE_FILE = getCacheFile('projects');
|
|
11
|
+
async function readLink() {
|
|
12
|
+
try {
|
|
13
|
+
if (!existsSync(LINK_FILE))
|
|
14
|
+
return null;
|
|
15
|
+
return JSON.parse(await readFile(LINK_FILE, 'utf-8'));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function writeLink(link) {
|
|
22
|
+
await secureMkdir(getAtlasHome(), { recursive: true });
|
|
23
|
+
await secureWriteFile(LINK_FILE, JSON.stringify(link, null, 2));
|
|
24
|
+
}
|
|
25
|
+
async function removeLink() {
|
|
26
|
+
try {
|
|
27
|
+
await unlink(LINK_FILE);
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
}
|
|
31
|
+
function getProjectName(p) {
|
|
32
|
+
return p.name ?? p.projectName ?? String(p.id);
|
|
33
|
+
}
|
|
34
|
+
/** 统一把 id 转成 string,API 可能返回数字 */
|
|
35
|
+
function getProjectId(p) {
|
|
36
|
+
return String(p.id);
|
|
37
|
+
}
|
|
38
|
+
async function fetchProjectsFromApi(refresh) {
|
|
39
|
+
if (!refresh) {
|
|
40
|
+
try {
|
|
41
|
+
if (existsSync(PROJECT_CACHE_FILE)) {
|
|
42
|
+
return JSON.parse(await readFile(PROJECT_CACHE_FILE, 'utf-8'));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch { }
|
|
46
|
+
}
|
|
47
|
+
const client = createClient();
|
|
48
|
+
// API 直接返回数组: [{id, name}, ...]
|
|
49
|
+
const data = await client.post('/yuntu-service/project/selectHasPermisValidProject.json', {});
|
|
50
|
+
const projects = (data ?? []);
|
|
51
|
+
await secureMkdir(getCacheDir(), { recursive: true });
|
|
52
|
+
await secureWriteFile(PROJECT_CACHE_FILE, JSON.stringify(projects, null, 2));
|
|
53
|
+
return projects;
|
|
54
|
+
}
|
|
55
|
+
function resolveProjectId(projects, query) {
|
|
56
|
+
const exact = projects.find((p) => getProjectId(p) === query || getProjectName(p) === query);
|
|
57
|
+
if (exact)
|
|
58
|
+
return { id: getProjectId(exact), name: getProjectName(exact) };
|
|
59
|
+
const sub = projects.filter((p) => getProjectId(p).includes(query) || getProjectName(p).includes(query));
|
|
60
|
+
if (sub.length === 1)
|
|
61
|
+
return { id: getProjectId(sub[0]), name: getProjectName(sub[0]) };
|
|
62
|
+
if (sub.length > 1)
|
|
63
|
+
throw new AtlasError(`项目匹配歧义: "${query}" 匹配了 ${sub.length} 个项目`, 'AMBIGUOUS_PROJECT');
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
export async function findCmd(kind, query, opts) {
|
|
67
|
+
const client = createClient();
|
|
68
|
+
let results = [];
|
|
69
|
+
if (kind === 'project') {
|
|
70
|
+
const projects = (await fetchProjectsFromApi(opts.refresh)) ?? [];
|
|
71
|
+
results = projects
|
|
72
|
+
.filter((p) => getProjectId(p).includes(query) || getProjectName(p).includes(query))
|
|
73
|
+
.map((p) => ({ id: getProjectId(p), name: getProjectName(p), kind: 'project' }));
|
|
74
|
+
}
|
|
75
|
+
else if (kind === 'department') {
|
|
76
|
+
const data = await client.post('/yuntu-service/department/tree/select.json', {});
|
|
77
|
+
// 需要确认实际响应格式
|
|
78
|
+
const nodes = Array.isArray(data) ? data : [];
|
|
79
|
+
const flatten = (items) => {
|
|
80
|
+
const r = [];
|
|
81
|
+
for (const n of items) {
|
|
82
|
+
if (n.name.includes(query))
|
|
83
|
+
r.push({ id: n.id, name: n.name, kind: 'department' });
|
|
84
|
+
if (n.children)
|
|
85
|
+
r.push(...flatten(n.children));
|
|
86
|
+
}
|
|
87
|
+
return r;
|
|
88
|
+
};
|
|
89
|
+
results = flatten(nodes);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const dictData = await client.post('/yuntu-service/dictionary/select.json', { dictType: kind });
|
|
93
|
+
const entries = Array.isArray(dictData) ? dictData : [];
|
|
94
|
+
results = entries
|
|
95
|
+
.filter((e) => (e.attrName ?? '').includes(query))
|
|
96
|
+
.map((e) => ({ id: e.attrValue ?? '', name: e.attrName ?? '', kind }));
|
|
97
|
+
}
|
|
98
|
+
const limit = parseInt(opts.limit ?? '20', 10);
|
|
99
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
100
|
+
throw new ConfigError(`--limit 必须为正整数,实际: "${opts.limit}"`);
|
|
101
|
+
results = results.slice(0, limit);
|
|
102
|
+
if (opts.json || isJsonMode()) {
|
|
103
|
+
jsonOk(results, { count: results.length, kind, query });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
log(`找到 ${results.length} 个${kind}:`);
|
|
107
|
+
table(results);
|
|
108
|
+
}
|
|
109
|
+
export async function projectsCmd(opts) {
|
|
110
|
+
const rootData = (await fetchProjectsFromApi(opts.refresh)) ?? [];
|
|
111
|
+
const projects = rootData;
|
|
112
|
+
const formatted = projects.map((p) => ({ id: getProjectId(p), name: getProjectName(p) }));
|
|
113
|
+
if (opts.json || isJsonMode()) {
|
|
114
|
+
jsonOk(formatted);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
log(`你有 ${formatted.length} 个项目的权限:`);
|
|
118
|
+
table(formatted);
|
|
119
|
+
}
|
|
120
|
+
export async function linkCmd(project, opts) {
|
|
121
|
+
const projects = await fetchProjectsFromApi(opts.refreshProjects);
|
|
122
|
+
const resolved = resolveProjectId(projects, project);
|
|
123
|
+
if (!resolved)
|
|
124
|
+
throw new AtlasError(`未找到项目: "${project}"`, 'PROJECT_NOT_FOUND');
|
|
125
|
+
const link = { projectId: resolved.id, projectName: resolved.name, linkedAt: new Date().toISOString() };
|
|
126
|
+
if (opts.dryRun) {
|
|
127
|
+
log(`[dry-run] 将会绑定项目: ${resolved.name} (${resolved.id})`);
|
|
128
|
+
if (opts.json || isJsonMode()) {
|
|
129
|
+
jsonOk({ dryRun: true, link });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
await writeLink(link);
|
|
135
|
+
log(`已绑定项目: ${resolved.name} (${resolved.id})`);
|
|
136
|
+
if (opts.json || isJsonMode()) {
|
|
137
|
+
jsonOk(link);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export async function linkStatusCmd(opts) {
|
|
141
|
+
const link = await readLink();
|
|
142
|
+
const envProjectId = getBanmaProjectId();
|
|
143
|
+
if (opts.json || isJsonMode()) {
|
|
144
|
+
jsonOk({ linked: link ?? null, envProjectId: envProjectId ?? null });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (link) {
|
|
148
|
+
log(`当前绑定项目: ${link.projectName} (${link.projectId})\n绑定时间: ${link.linkedAt}`);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
log('未绑定项目。使用 atlas link <project> 绑定');
|
|
152
|
+
}
|
|
153
|
+
if (envProjectId)
|
|
154
|
+
log(`环境变量 BANMA_PROJECT_ID=${envProjectId}`);
|
|
155
|
+
}
|
|
156
|
+
export async function unlinkCmd(opts) {
|
|
157
|
+
const link = await readLink();
|
|
158
|
+
if (!link) {
|
|
159
|
+
log('当前未绑定项目');
|
|
160
|
+
if (opts.json || isJsonMode()) {
|
|
161
|
+
jsonOk({ unlinked: false });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (opts.dryRun) {
|
|
167
|
+
log(`[dry-run] 将会清除项目绑定: ${link.projectName}`);
|
|
168
|
+
if (opts.json || isJsonMode()) {
|
|
169
|
+
jsonOk({ dryRun: true, link });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await removeLink();
|
|
175
|
+
log(`已清除项目绑定: ${link.projectName}`);
|
|
176
|
+
if (opts.json || isJsonMode()) {
|
|
177
|
+
jsonOk({ unlinked: true, projectName: link.projectName });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { isJsonMode, jsonOk, log } from '../util/output.js';
|
|
2
|
+
import { NotImplementedError } from '../util/errors.js';
|
|
3
|
+
import { emitDescribe } from '../cli.js';
|
|
4
|
+
export async function schemaExportCmd(_opts) {
|
|
5
|
+
// 后续待实装:调用 dictionary/selectAll 和 department/tree 接口拉取真实数据
|
|
6
|
+
throw new NotImplementedError('schema export 暂未完整实现。当前返回空字典,待后续拉取部门树 + 字典值后补全');
|
|
7
|
+
}
|
|
8
|
+
export function schemaCommandsCmd(program, opts) {
|
|
9
|
+
const commands = [];
|
|
10
|
+
program.commands.forEach((cmd) => {
|
|
11
|
+
commands.push(cmd.name());
|
|
12
|
+
if (cmd.commands.length > 0) {
|
|
13
|
+
cmd.commands.forEach((sub) => {
|
|
14
|
+
commands.push(`${cmd.name()} ${sub.name()}`);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
if (opts.describe) {
|
|
19
|
+
program.commands.forEach((cmd) => {
|
|
20
|
+
emitDescribe(cmd);
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (opts.json || isJsonMode()) {
|
|
25
|
+
jsonOk({ commands });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
log('可用命令:');
|
|
29
|
+
commands.sort().forEach((c) => log(` ${c}`));
|
|
30
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isJsonMode, jsonOk, log } from '../util/output.js';
|
|
2
|
+
const RULES = [
|
|
3
|
+
{ pattern: /登录|login|sso|auth/i, command: 'atlas auth login', description: 'SSO 登录', weight: 10 },
|
|
4
|
+
{ pattern: /状态|status|会话/i, command: 'atlas auth status', description: '查看会话状态', weight: 10 },
|
|
5
|
+
{ pattern: /项目.*列表|列出.*项目|projects/i, command: 'atlas projects', description: '列出所有项目', weight: 10 },
|
|
6
|
+
{ pattern: /绑定|link/i, command: 'atlas link <project>', description: '绑定项目', weight: 10 },
|
|
7
|
+
{ pattern: /解绑|unlink/i, command: 'atlas unlink', description: '解绑项目', weight: 10 },
|
|
8
|
+
{ pattern: /搜索|找|find|查询项目/i, command: 'atlas find project <query>', description: '搜索项目', weight: 10 },
|
|
9
|
+
{ pattern: /基线|计划|baseline/i, command: 'atlas baseline month', description: '查看基线人力', weight: 5 },
|
|
10
|
+
{ pattern: /基线.*汇总/i, command: 'atlas baseline summary', description: '基线汇总', weight: 5 },
|
|
11
|
+
{ pattern: /基线.*导出/i, command: 'atlas baseline export', description: '导出基线', weight: 5 },
|
|
12
|
+
{ pattern: /实际|actual|工时/i, command: 'atlas actual month', description: '查看实际工时', weight: 5 },
|
|
13
|
+
{ pattern: /实际.*汇总/i, command: 'atlas actual summary', description: '实际工时汇总', weight: 5 },
|
|
14
|
+
{ pattern: /实际.*导出/i, command: 'atlas actual export', description: '导出实际工时', weight: 5 },
|
|
15
|
+
{ pattern: /实际.*人员|人员.*明细/i, command: 'atlas actual show <staffId>', description: '查看人员实际工时', weight: 5 },
|
|
16
|
+
{ pattern: /对比|比较|compare|差异/i, command: 'atlas compare', description: '基线 vs 实际对比', weight: 10 },
|
|
17
|
+
{ pattern: /升级|更新|update/i, command: 'atlas update', description: '升级到最新版本', weight: 10 },
|
|
18
|
+
];
|
|
19
|
+
export function suggestCmd(query, opts) {
|
|
20
|
+
const suggestions = [];
|
|
21
|
+
for (const rule of RULES) {
|
|
22
|
+
if (rule.pattern.test(query)) {
|
|
23
|
+
suggestions.push({
|
|
24
|
+
command: rule.command,
|
|
25
|
+
description: rule.description,
|
|
26
|
+
score: rule.weight,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Simple fuzzy score: more pattern matches = higher score
|
|
31
|
+
const queryLower = query.toLowerCase();
|
|
32
|
+
for (const s of suggestions) {
|
|
33
|
+
const cmdLower = s.command.toLowerCase();
|
|
34
|
+
let score = 0;
|
|
35
|
+
for (const word of queryLower.split(/\s+/)) {
|
|
36
|
+
if (cmdLower.includes(word))
|
|
37
|
+
score += 0.5;
|
|
38
|
+
}
|
|
39
|
+
s.score += score;
|
|
40
|
+
}
|
|
41
|
+
suggestions.sort((a, b) => b.score - a.score);
|
|
42
|
+
if (opts.json || isJsonMode()) {
|
|
43
|
+
jsonOk({ query, suggestions: suggestions.slice(0, 5) });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (suggestions.length === 0) {
|
|
47
|
+
log(`未找到匹配的 atlas 命令: "${query}"`);
|
|
48
|
+
log('提示: 试试 atlas --help 查看所有命令');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
log(`自然语言查询: "${query}"`);
|
|
52
|
+
log('建议命令:');
|
|
53
|
+
for (const s of suggestions.slice(0, 5)) {
|
|
54
|
+
log(` ${s.command} — ${s.description}`);
|
|
55
|
+
}
|
|
56
|
+
}
|