zentao-api 0.1.0 → 0.2.0-beta.2
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/LICENSE +2 -2
- package/README.md +221 -131
- package/dist/browser/zentao-api.global.js +2 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +1 -0
- package/dist/client/index.d.ts +39 -0
- package/dist/client/index.js +172 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +7 -8
- package/dist/misc/browser-global.d.ts +1 -0
- package/dist/misc/browser-global.js +8 -0
- package/dist/misc/environment.d.ts +8 -0
- package/dist/misc/environment.js +124 -0
- package/dist/misc/errors.d.ts +30 -0
- package/dist/misc/errors.js +40 -0
- package/dist/misc/global-options.d.ts +5 -0
- package/dist/misc/global-options.js +9 -0
- package/dist/modules/generated.d.ts +8 -0
- package/dist/modules/generated.js +4226 -0
- package/dist/modules/registry.d.ts +22 -0
- package/dist/modules/registry.js +142 -0
- package/dist/modules/resolve.d.ts +7 -0
- package/dist/modules/resolve.js +206 -0
- package/dist/profiles/index.d.ts +14 -0
- package/dist/profiles/index.js +205 -0
- package/dist/request/index.d.ts +7 -0
- package/dist/request/index.js +65 -0
- package/dist/types/index.d.ts +296 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/index.js +26 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/package.json +52 -76
- package/dist/types.d.ts +0 -70
- package/dist/utils.d.ts +0 -93
- package/dist/zentao-api.cjs.development.js +0 -3619
- package/dist/zentao-api.cjs.development.js.map +0 -1
- package/dist/zentao-api.cjs.production.min.js +0 -2
- package/dist/zentao-api.cjs.production.min.js.map +0 -1
- package/dist/zentao-api.esm.js +0 -3611
- package/dist/zentao-api.esm.js.map +0 -1
- package/dist/zentao-config.d.ts +0 -93
- package/dist/zentao-request-builder.d.ts +0 -120
- package/dist/zentao.d.ts +0 -175
- package/dist/zentao12.d.ts +0 -676
- package/src/index.ts +0 -5
- package/src/types.ts +0 -88
- package/src/utils.ts +0 -216
- package/src/zentao-config.ts +0 -150
- package/src/zentao-request-builder.ts +0 -227
- package/src/zentao.ts +0 -596
- package/src/zentao12.ts +0 -1272
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ModuleAction, ModuleDefinition } from '../types/index.js';
|
|
2
|
+
import { BUILTIN_MODULES } from './generated.js';
|
|
3
|
+
export { BUILTIN_MODULES };
|
|
4
|
+
export declare const MODULES: ModuleDefinition[];
|
|
5
|
+
export interface DefineModulesOptions {
|
|
6
|
+
/** 同名模块是否整体替换;默认合并模块定义和动作。 */
|
|
7
|
+
replace?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** 定义或扩展模块;同名模块默认合并动作,`replace` 为真时整体替换,未知模块追加。 */
|
|
10
|
+
export declare function defineModules(input: ModuleDefinition | ModuleDefinition[], options?: DefineModulesOptions): void;
|
|
11
|
+
/** 为已存在模块定义或覆盖动作;同名动作替换,未知动作追加。 */
|
|
12
|
+
export declare function defineModuleActions(moduleName: string, input: ModuleAction | ModuleAction[]): void;
|
|
13
|
+
/** 获取模块定义;模块不存在时抛出 {@link ZentaoError}。 */
|
|
14
|
+
export declare function getModule(moduleName: string): ModuleDefinition;
|
|
15
|
+
/** 获取指定模块动作;`ls` 会作为 `list` 的别名处理。 */
|
|
16
|
+
export declare function getModuleAction(moduleName: string, actionName: string): ModuleAction;
|
|
17
|
+
/** 返回当前运行时注册表中的所有模块名。 */
|
|
18
|
+
export declare function getModuleNames(): string[];
|
|
19
|
+
/** 判断模块名是否已注册,大小写不敏感。 */
|
|
20
|
+
export declare function isModuleName(moduleName: string): boolean;
|
|
21
|
+
/** @internal */
|
|
22
|
+
export declare function resetModuleDefinitions(): void;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { ZentaoError } from '../misc/errors.js';
|
|
2
|
+
import { asArray } from '../utils/index.js';
|
|
3
|
+
import { BUILTIN_MODULES } from './generated.js';
|
|
4
|
+
export { BUILTIN_MODULES };
|
|
5
|
+
export const MODULES = BUILTIN_MODULES;
|
|
6
|
+
// 运行时注册表使用内置定义的浅克隆,避免用户扩展污染生成文件导出的常量。
|
|
7
|
+
let modules = cloneModules(BUILTIN_MODULES);
|
|
8
|
+
let moduleMap = buildModuleMap(modules);
|
|
9
|
+
function cloneValue(value) {
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
return value.map(cloneValue);
|
|
12
|
+
}
|
|
13
|
+
if (value && typeof value === 'object') {
|
|
14
|
+
const result = {};
|
|
15
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
16
|
+
result[key] = cloneValue(nestedValue);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function cloneActions(source) {
|
|
23
|
+
return source.map((action) => cloneValue(action));
|
|
24
|
+
}
|
|
25
|
+
function cloneModules(source) {
|
|
26
|
+
return source.map((module) => ({
|
|
27
|
+
...cloneValue(module),
|
|
28
|
+
actions: cloneActions(module.actions),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
function findActionIndex(source, actionName) {
|
|
32
|
+
const key = actionName.toLowerCase();
|
|
33
|
+
return source.findIndex((action) => String(action.name).toLowerCase() === key);
|
|
34
|
+
}
|
|
35
|
+
function mergeActions(base, extension) {
|
|
36
|
+
const next = cloneActions(base);
|
|
37
|
+
for (const action of extension) {
|
|
38
|
+
const index = findActionIndex(next, String(action.name));
|
|
39
|
+
const clone = cloneValue(action);
|
|
40
|
+
if (index >= 0) {
|
|
41
|
+
next[index] = clone;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
next.push(clone);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return next;
|
|
48
|
+
}
|
|
49
|
+
function mergeModule(base, extension) {
|
|
50
|
+
return {
|
|
51
|
+
...base,
|
|
52
|
+
...extension,
|
|
53
|
+
actions: mergeActions(base.actions, extension.actions),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function buildModuleMap(source) {
|
|
57
|
+
return new Map(source.map((module) => [module.name.toLowerCase(), module]));
|
|
58
|
+
}
|
|
59
|
+
function rebuildMap() {
|
|
60
|
+
moduleMap = buildModuleMap(modules);
|
|
61
|
+
}
|
|
62
|
+
function validateModule(module) {
|
|
63
|
+
if (!module || typeof module.name !== 'string' || !Array.isArray(module.actions)) {
|
|
64
|
+
throw new ZentaoError('E_INVALID_MODULE_DEFINITION');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function validateAction(action) {
|
|
68
|
+
if (!action || typeof action.name !== 'string' || typeof action.path !== 'string' || typeof action.method !== 'string') {
|
|
69
|
+
throw new ZentaoError('E_INVALID_ACTION_DEFINITION');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** 定义或扩展模块;同名模块默认合并动作,`replace` 为真时整体替换,未知模块追加。 */
|
|
73
|
+
export function defineModules(input, options = {}) {
|
|
74
|
+
for (const module of asArray(input)) {
|
|
75
|
+
validateModule(module);
|
|
76
|
+
const key = module.name.toLowerCase();
|
|
77
|
+
const index = modules.findIndex((item) => item.name.toLowerCase() === key);
|
|
78
|
+
const next = { ...cloneValue(module), actions: cloneActions(module.actions) };
|
|
79
|
+
if (index >= 0) {
|
|
80
|
+
modules[index] = options.replace ? next : mergeModule(modules[index], module);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
modules.push(next);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
rebuildMap();
|
|
87
|
+
}
|
|
88
|
+
/** 为已存在模块定义或覆盖动作;同名动作替换,未知动作追加。 */
|
|
89
|
+
export function defineModuleActions(moduleName, input) {
|
|
90
|
+
const module = moduleMap.get(moduleName.toLowerCase());
|
|
91
|
+
if (!module) {
|
|
92
|
+
throw new ZentaoError('E_INVALID_MODULE', { module: moduleName });
|
|
93
|
+
}
|
|
94
|
+
for (const action of asArray(input)) {
|
|
95
|
+
validateAction(action);
|
|
96
|
+
const index = findActionIndex(module.actions, String(action.name));
|
|
97
|
+
const next = cloneValue(action);
|
|
98
|
+
// 同名动作替换,未知动作追加;不做深度合并,避免 schema/数组字段出现隐式规则。
|
|
99
|
+
if (index >= 0) {
|
|
100
|
+
module.actions[index] = next;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
module.actions.push(next);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** 获取模块定义;模块不存在时抛出 {@link ZentaoError}。 */
|
|
108
|
+
export function getModule(moduleName) {
|
|
109
|
+
const module = moduleMap.get(moduleName.toLowerCase());
|
|
110
|
+
if (!module) {
|
|
111
|
+
throw new ZentaoError('E_INVALID_MODULE', { module: moduleName });
|
|
112
|
+
}
|
|
113
|
+
return cloneModules([module])[0];
|
|
114
|
+
}
|
|
115
|
+
/** 获取指定模块动作;`ls` 会作为 `list` 的别名处理。 */
|
|
116
|
+
export function getModuleAction(moduleName, actionName) {
|
|
117
|
+
const module = getModule(moduleName);
|
|
118
|
+
const normalized = actionName === 'ls' ? 'list' : actionName;
|
|
119
|
+
const direct = module.actions.find((action) => String(action.name).toLowerCase() === normalized.toLowerCase());
|
|
120
|
+
if (direct)
|
|
121
|
+
return cloneValue(direct);
|
|
122
|
+
const crud = new Set(['list', 'get', 'create', 'update', 'delete']);
|
|
123
|
+
if (!crud.has(normalized)) {
|
|
124
|
+
const custom = module.actions.find((action) => action.type === 'action' && String(action.name).toLowerCase() === normalized.toLowerCase());
|
|
125
|
+
if (custom)
|
|
126
|
+
return cloneValue(custom);
|
|
127
|
+
}
|
|
128
|
+
throw new ZentaoError('E_INVALID_ACTION', { module: moduleName, action: actionName });
|
|
129
|
+
}
|
|
130
|
+
/** 返回当前运行时注册表中的所有模块名。 */
|
|
131
|
+
export function getModuleNames() {
|
|
132
|
+
return modules.map((module) => module.name);
|
|
133
|
+
}
|
|
134
|
+
/** 判断模块名是否已注册,大小写不敏感。 */
|
|
135
|
+
export function isModuleName(moduleName) {
|
|
136
|
+
return moduleMap.has(moduleName.toLowerCase());
|
|
137
|
+
}
|
|
138
|
+
/** @internal */
|
|
139
|
+
export function resetModuleDefinitions() {
|
|
140
|
+
modules = cloneModules(BUILTIN_MODULES);
|
|
141
|
+
rebuildMap();
|
|
142
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ListPagerInfo, ModuleAction, ModuleDefinition, ResolvedModuleCommand } from '../types/index.js';
|
|
2
|
+
/** 将模块名、动作名和调用参数解析为实际 API 请求路径、查询参数和请求体。 */
|
|
3
|
+
export declare function resolveModuleCommand(module: ModuleDefinition, actionName: string, params?: Record<string, unknown>): ResolvedModuleCommand;
|
|
4
|
+
/** 根据动作定义中的 resultGetter,从原始响应里提取业务数据。 */
|
|
5
|
+
export declare function extractResult(action: ModuleAction, response: Record<string, unknown>): unknown;
|
|
6
|
+
/** 根据动作定义中的 pagerGetter,从原始响应里提取分页信息。 */
|
|
7
|
+
export declare function extractPager(action: ModuleAction, response: Record<string, unknown>): ListPagerInfo | undefined;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { ZentaoError } from '../misc/errors.js';
|
|
2
|
+
import { getNestedValue, isRecord } from '../utils/index.js';
|
|
3
|
+
import { getModuleAction } from './registry.js';
|
|
4
|
+
const SCOPE_MAP = {
|
|
5
|
+
product: 'products',
|
|
6
|
+
project: 'projects',
|
|
7
|
+
execution: 'executions',
|
|
8
|
+
};
|
|
9
|
+
const SCOPE_KEY_ORDER = ['execution', 'project', 'product'];
|
|
10
|
+
/** 从调用参数中推断作用域列表路径,优先级为执行 > 项目 > 产品。 */
|
|
11
|
+
function pickScope(params) {
|
|
12
|
+
for (const key of SCOPE_KEY_ORDER) {
|
|
13
|
+
const value = params[key] ?? params[`${key}ID`];
|
|
14
|
+
if (value === undefined || value === null || value === '')
|
|
15
|
+
continue;
|
|
16
|
+
const numberValue = Number(value);
|
|
17
|
+
if (!Number.isNaN(numberValue)) {
|
|
18
|
+
return { scope: SCOPE_MAP[key], scopeID: numberValue };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
/** 替换动作路径模板中的 `{param}` 占位符。 */
|
|
24
|
+
function resolvePath(action, values) {
|
|
25
|
+
return action.path.replace(/\{(\w+)\}/g, (_, key) => {
|
|
26
|
+
const value = values[key];
|
|
27
|
+
if (value === undefined || value === '') {
|
|
28
|
+
throw new ZentaoError('E_MISSING_PARAM', { param: key });
|
|
29
|
+
}
|
|
30
|
+
return String(value);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** 支持用户通过 `params.data` 传入 JSON 字符串或对象作为请求体基础值。 */
|
|
34
|
+
function parseData(value) {
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (typeof value === 'string') {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(value);
|
|
40
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
/** 按 OpenAPI schema 的基础类型对参数做轻量转换。 */
|
|
49
|
+
function coerceValue(value, type) {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (type === 'number' || type === 'integer') {
|
|
53
|
+
const numberValue = Number(value);
|
|
54
|
+
return Number.isNaN(numberValue) ? value : numberValue;
|
|
55
|
+
}
|
|
56
|
+
if (type === 'boolean') {
|
|
57
|
+
if (typeof value === 'boolean')
|
|
58
|
+
return value;
|
|
59
|
+
if (typeof value === 'number') {
|
|
60
|
+
if (value === 1)
|
|
61
|
+
return true;
|
|
62
|
+
if (value === 0)
|
|
63
|
+
return false;
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
if (typeof value === 'string') {
|
|
67
|
+
const normalized = value.trim().toLowerCase();
|
|
68
|
+
if (['true', '1', 'yes', 'on'].includes(normalized))
|
|
69
|
+
return true;
|
|
70
|
+
if (['false', '0', 'no', 'off'].includes(normalized))
|
|
71
|
+
return false;
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
/** 将模块名、动作名和调用参数解析为实际 API 请求路径、查询参数和请求体。 */
|
|
79
|
+
export function resolveModuleCommand(module, actionName, params = {}) {
|
|
80
|
+
const action = getModuleAction(module.name, actionName);
|
|
81
|
+
const pathValues = {};
|
|
82
|
+
const pathParamNames = Object.keys(action.pathParams ?? {});
|
|
83
|
+
// 生成定义中的 scope 列表接口会统一成 /{scope}/{scopeID}/xxx。
|
|
84
|
+
if (pathParamNames.includes('scope')) {
|
|
85
|
+
const scope = pickScope(params);
|
|
86
|
+
if (!scope)
|
|
87
|
+
throw new ZentaoError('E_MISSING_PARAM', { param: 'product/project/execution' });
|
|
88
|
+
pathValues.scope = scope.scope;
|
|
89
|
+
pathValues.scopeID = scope.scopeID;
|
|
90
|
+
}
|
|
91
|
+
const idParamName = pathParamNames.find((key) => key.endsWith('ID') && key !== 'scopeID');
|
|
92
|
+
const idValue = params.id ?? params[`${module.name}ID`] ?? (idParamName ? params[idParamName] : undefined);
|
|
93
|
+
const id = idValue === undefined ? undefined : Number(idValue);
|
|
94
|
+
if (idParamName && id !== undefined && !Number.isNaN(id)) {
|
|
95
|
+
pathValues[idParamName] = id;
|
|
96
|
+
}
|
|
97
|
+
// pathParams 中未显式传值的参数,可从定义里的默认值或第一个可选项补齐。
|
|
98
|
+
for (const key of pathParamNames) {
|
|
99
|
+
if (key === 'scope' || key === 'scopeID' || pathValues[key] !== undefined)
|
|
100
|
+
continue;
|
|
101
|
+
const definition = action.pathParams?.[key];
|
|
102
|
+
const value = params[key];
|
|
103
|
+
if (value !== undefined) {
|
|
104
|
+
pathValues[key] = value;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (typeof definition === 'object') {
|
|
108
|
+
if (definition.defaultValue !== undefined) {
|
|
109
|
+
pathValues[key] = definition.defaultValue;
|
|
110
|
+
}
|
|
111
|
+
else if (definition.options?.[0]?.value !== undefined) {
|
|
112
|
+
pathValues[key] = definition.options[0].value;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (pathValues[key] === undefined) {
|
|
116
|
+
throw new ZentaoError('E_MISSING_PARAM', { param: key });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// 查询参数只从 action.params 中声明过的字段生成,避免把 body 字段误放到 URL 上。
|
|
120
|
+
const query = {};
|
|
121
|
+
for (const param of action.params ?? []) {
|
|
122
|
+
let value = params[param.name];
|
|
123
|
+
if (value === undefined && param.name === 'pageID') {
|
|
124
|
+
value = params.page;
|
|
125
|
+
}
|
|
126
|
+
if (value === undefined) {
|
|
127
|
+
value = param.defaultValue ?? param.options?.[0]?.value;
|
|
128
|
+
}
|
|
129
|
+
if (value === undefined && param.required) {
|
|
130
|
+
throw new ZentaoError('E_MISSING_PARAM', { param: param.name });
|
|
131
|
+
}
|
|
132
|
+
if (value !== undefined) {
|
|
133
|
+
query[param.name] = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
let data = parseData(params.data);
|
|
137
|
+
if (action.requestBody?.schema?.type === 'object') {
|
|
138
|
+
data = data ? { ...data } : {};
|
|
139
|
+
const schema = action.requestBody.schema;
|
|
140
|
+
const required = new Set(schema.required ?? []);
|
|
141
|
+
for (const [key, property] of Object.entries(schema.properties ?? {})) {
|
|
142
|
+
// body 字段优先级:params.data 中的字段 > 平铺 params 字段 > schema 默认值。
|
|
143
|
+
const hasDataValue = Object.prototype.hasOwnProperty.call(data, key);
|
|
144
|
+
let value = data[key] ?? params[key] ?? property.defaultValue;
|
|
145
|
+
if (value === undefined && (property.required || required.has(key))) {
|
|
146
|
+
throw new ZentaoError('E_MISSING_PARAM', { param: key });
|
|
147
|
+
}
|
|
148
|
+
value = coerceValue(value, property.type);
|
|
149
|
+
if (property.type === 'array' && value !== undefined && !Array.isArray(value)) {
|
|
150
|
+
if (typeof value === 'string') {
|
|
151
|
+
value = value.split(',');
|
|
152
|
+
}
|
|
153
|
+
else if (!hasDataValue || !isRecord(value)) {
|
|
154
|
+
value = [value];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (value !== undefined) {
|
|
158
|
+
data[key] = value;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
module: module.name,
|
|
164
|
+
action,
|
|
165
|
+
params,
|
|
166
|
+
path: resolvePath(action, pathValues),
|
|
167
|
+
query,
|
|
168
|
+
data,
|
|
169
|
+
id: id === undefined || Number.isNaN(id) ? undefined : id,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** 根据动作定义中的 resultGetter,从原始响应里提取业务数据。 */
|
|
173
|
+
export function extractResult(action, response) {
|
|
174
|
+
const getter = action.resultGetter;
|
|
175
|
+
if (!getter)
|
|
176
|
+
return response.data ?? response;
|
|
177
|
+
if (typeof getter === 'function')
|
|
178
|
+
return getter(response, {});
|
|
179
|
+
if (typeof getter === 'string')
|
|
180
|
+
return getNestedValue(response, getter);
|
|
181
|
+
const result = {};
|
|
182
|
+
for (const [targetKey, sourceKey] of Object.entries(getter)) {
|
|
183
|
+
result[targetKey] = response[sourceKey];
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
/** 根据动作定义中的 pagerGetter,从原始响应里提取分页信息。 */
|
|
188
|
+
export function extractPager(action, response) {
|
|
189
|
+
const getter = action.pagerGetter;
|
|
190
|
+
if (!getter)
|
|
191
|
+
return response.pager;
|
|
192
|
+
if (typeof getter === 'function')
|
|
193
|
+
return getter(response, {});
|
|
194
|
+
if (typeof getter === 'string')
|
|
195
|
+
return getNestedValue(response, getter);
|
|
196
|
+
const page = response[getter.pageID];
|
|
197
|
+
const recPerPage = response[getter.recPerPage];
|
|
198
|
+
const recTotal = response[getter.recTotal];
|
|
199
|
+
if (page === undefined || recPerPage === undefined || recTotal === undefined)
|
|
200
|
+
return undefined;
|
|
201
|
+
return {
|
|
202
|
+
pageID: Number(page),
|
|
203
|
+
recPerPage: Number(recPerPage),
|
|
204
|
+
recTotal: Number(recTotal),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ZentaoProfile, ZentaoProfileRecord } from '../types/index.js';
|
|
2
|
+
export declare const ZENTAO_PROFILES_STORAGE_KEY = "ZENTAO_PROFILES";
|
|
3
|
+
/** 根据 profile 的账号和禅道地址生成稳定 key。 */
|
|
4
|
+
export declare function getProfileKey(profile: Pick<ZentaoProfile, 'account' | 'server'>): string;
|
|
5
|
+
/** 列出所有保存的本地 profile。 */
|
|
6
|
+
export declare function getAllProfiles(): Promise<ZentaoProfileRecord[]>;
|
|
7
|
+
/** 获取指定 profile;不传 key 时返回上次使用的 profile。 */
|
|
8
|
+
export declare function getProfile(profileKey?: string): Promise<ZentaoProfileRecord | undefined>;
|
|
9
|
+
/** 添加或覆盖一个本地 profile,并把它设置为当前使用的 profile。 */
|
|
10
|
+
export declare function addProfile(profile: ZentaoProfile): Promise<ZentaoProfileRecord>;
|
|
11
|
+
/** 删除指定 profile;返回是否实际删除了记录。 */
|
|
12
|
+
export declare function deleteProfile(profileKey: string): Promise<boolean>;
|
|
13
|
+
/** 切换当前使用的 profile,并刷新最后使用时间;不传 key 时使用当前 profile。 */
|
|
14
|
+
export declare function switchProfile(profileKey?: string): Promise<ZentaoProfileRecord>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { ZentaoError } from '../misc/errors.js';
|
|
2
|
+
import { isNodeRuntime } from '../misc/environment.js';
|
|
3
|
+
import { normalizeSiteUrl } from '../utils/index.js';
|
|
4
|
+
export const ZENTAO_PROFILES_STORAGE_KEY = 'ZENTAO_PROFILES';
|
|
5
|
+
const PROFILE_FILE_PARTS = ['.config', 'zentao', 'zentao.json'];
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function cloneJson(value) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return value;
|
|
12
|
+
return JSON.parse(JSON.stringify(value));
|
|
13
|
+
}
|
|
14
|
+
function nowString() {
|
|
15
|
+
return new Date().toISOString();
|
|
16
|
+
}
|
|
17
|
+
async function importNodeModule(specifier) {
|
|
18
|
+
const dynamicImport = new Function('specifier', 'return import(specifier)');
|
|
19
|
+
return dynamicImport(specifier);
|
|
20
|
+
}
|
|
21
|
+
function getBrowserStorage() {
|
|
22
|
+
try {
|
|
23
|
+
return globalThis.localStorage;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function getProfileFilePath() {
|
|
30
|
+
const path = await importNodeModule('node:path');
|
|
31
|
+
const home = process.env.HOME
|
|
32
|
+
?? process.env.USERPROFILE
|
|
33
|
+
?? (await importNodeModule('node:os')).homedir();
|
|
34
|
+
if (!home) {
|
|
35
|
+
throw new ZentaoError('E_PROFILE_STORAGE_UNAVAILABLE');
|
|
36
|
+
}
|
|
37
|
+
return path.join(home, ...PROFILE_FILE_PARTS);
|
|
38
|
+
}
|
|
39
|
+
function profileKeyFromParts(account, server) {
|
|
40
|
+
const normalizedAccount = account.trim();
|
|
41
|
+
const normalizedServer = normalizeSiteUrl(server);
|
|
42
|
+
if (!normalizedAccount)
|
|
43
|
+
throw new ZentaoError('E_INVALID_PROFILE');
|
|
44
|
+
return `${normalizedAccount}@${normalizedServer}`;
|
|
45
|
+
}
|
|
46
|
+
function normalizeProfile(profile) {
|
|
47
|
+
if (!isRecord(profile) || typeof profile.server !== 'string' || typeof profile.account !== 'string' || typeof profile.token !== 'string') {
|
|
48
|
+
throw new ZentaoError('E_INVALID_PROFILE');
|
|
49
|
+
}
|
|
50
|
+
const token = profile.token.trim();
|
|
51
|
+
if (!token)
|
|
52
|
+
throw new ZentaoError('E_INVALID_PROFILE');
|
|
53
|
+
const copy = cloneJson(profile);
|
|
54
|
+
delete copy.key;
|
|
55
|
+
return {
|
|
56
|
+
...copy,
|
|
57
|
+
server: normalizeSiteUrl(profile.server),
|
|
58
|
+
account: profile.account.trim(),
|
|
59
|
+
token,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function normalizeStore(raw) {
|
|
63
|
+
if (!isRecord(raw))
|
|
64
|
+
return { profiles: [] };
|
|
65
|
+
const profiles = Array.isArray(raw.profiles)
|
|
66
|
+
? raw.profiles.flatMap((profile) => {
|
|
67
|
+
try {
|
|
68
|
+
return [normalizeProfile(profile)];
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
: [];
|
|
75
|
+
const currentProfile = typeof raw.currentProfile === 'string' ? raw.currentProfile : undefined;
|
|
76
|
+
return currentProfile ? { currentProfile, profiles } : { profiles };
|
|
77
|
+
}
|
|
78
|
+
function parseStore(text) {
|
|
79
|
+
try {
|
|
80
|
+
return normalizeStore(JSON.parse(text));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw new ZentaoError('E_PROFILE_STORAGE_INVALID', undefined, error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function readStore() {
|
|
87
|
+
if (isNodeRuntime()) {
|
|
88
|
+
const fs = await importNodeModule('node:fs/promises');
|
|
89
|
+
const file = await getProfileFilePath();
|
|
90
|
+
try {
|
|
91
|
+
return parseStore(await fs.readFile(file, 'utf8'));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code === 'ENOENT') {
|
|
95
|
+
return { profiles: [] };
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const storage = getBrowserStorage();
|
|
101
|
+
if (!storage) {
|
|
102
|
+
throw new ZentaoError('E_PROFILE_STORAGE_UNAVAILABLE');
|
|
103
|
+
}
|
|
104
|
+
const text = storage.getItem(ZENTAO_PROFILES_STORAGE_KEY);
|
|
105
|
+
return text ? parseStore(text) : { profiles: [] };
|
|
106
|
+
}
|
|
107
|
+
async function writeStore(store) {
|
|
108
|
+
const normalizedStore = normalizeStore(store);
|
|
109
|
+
const text = `${JSON.stringify(normalizedStore, null, 2)}\n`;
|
|
110
|
+
if (isNodeRuntime()) {
|
|
111
|
+
const fs = await importNodeModule('node:fs/promises');
|
|
112
|
+
const path = await importNodeModule('node:path');
|
|
113
|
+
const file = await getProfileFilePath();
|
|
114
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
115
|
+
await fs.writeFile(file, text, 'utf8');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const storage = getBrowserStorage();
|
|
119
|
+
if (!storage) {
|
|
120
|
+
throw new ZentaoError('E_PROFILE_STORAGE_UNAVAILABLE');
|
|
121
|
+
}
|
|
122
|
+
storage.setItem(ZENTAO_PROFILES_STORAGE_KEY, text);
|
|
123
|
+
}
|
|
124
|
+
function toRecord(profile) {
|
|
125
|
+
const normalized = normalizeProfile(profile);
|
|
126
|
+
return {
|
|
127
|
+
...cloneJson(normalized),
|
|
128
|
+
key: getProfileKey(normalized),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function findProfile(store, profileKey) {
|
|
132
|
+
return store.profiles.find((profile) => getProfileKey(profile) === profileKey);
|
|
133
|
+
}
|
|
134
|
+
function setFallbackCurrentProfile(store) {
|
|
135
|
+
if (!store.currentProfile || !findProfile(store, store.currentProfile)) {
|
|
136
|
+
const fallback = store.profiles.at(-1);
|
|
137
|
+
store.currentProfile = fallback ? getProfileKey(fallback) : undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** 根据 profile 的账号和禅道地址生成稳定 key。 */
|
|
141
|
+
export function getProfileKey(profile) {
|
|
142
|
+
return profileKeyFromParts(profile.account, profile.server);
|
|
143
|
+
}
|
|
144
|
+
/** 列出所有保存的本地 profile。 */
|
|
145
|
+
export async function getAllProfiles() {
|
|
146
|
+
const store = await readStore();
|
|
147
|
+
return store.profiles.map(toRecord);
|
|
148
|
+
}
|
|
149
|
+
/** 获取指定 profile;不传 key 时返回上次使用的 profile。 */
|
|
150
|
+
export async function getProfile(profileKey) {
|
|
151
|
+
const store = await readStore();
|
|
152
|
+
const key = profileKey ?? store.currentProfile;
|
|
153
|
+
if (!key)
|
|
154
|
+
return undefined;
|
|
155
|
+
const profile = findProfile(store, key);
|
|
156
|
+
return profile ? toRecord(profile) : undefined;
|
|
157
|
+
}
|
|
158
|
+
/** 添加或覆盖一个本地 profile,并把它设置为当前使用的 profile。 */
|
|
159
|
+
export async function addProfile(profile) {
|
|
160
|
+
const store = await readStore();
|
|
161
|
+
const timestamp = nowString();
|
|
162
|
+
const normalized = normalizeProfile({
|
|
163
|
+
...profile,
|
|
164
|
+
loginTime: profile.loginTime ?? timestamp,
|
|
165
|
+
lastUsedTime: profile.lastUsedTime ?? timestamp,
|
|
166
|
+
});
|
|
167
|
+
const profileKey = getProfileKey(normalized);
|
|
168
|
+
const index = store.profiles.findIndex((item) => getProfileKey(item) === profileKey);
|
|
169
|
+
if (index >= 0) {
|
|
170
|
+
store.profiles[index] = normalized;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
store.profiles.push(normalized);
|
|
174
|
+
}
|
|
175
|
+
store.currentProfile = profileKey;
|
|
176
|
+
await writeStore(store);
|
|
177
|
+
return toRecord(normalized);
|
|
178
|
+
}
|
|
179
|
+
/** 删除指定 profile;返回是否实际删除了记录。 */
|
|
180
|
+
export async function deleteProfile(profileKey) {
|
|
181
|
+
const store = await readStore();
|
|
182
|
+
const nextProfiles = store.profiles.filter((profile) => getProfileKey(profile) !== profileKey);
|
|
183
|
+
if (nextProfiles.length === store.profiles.length)
|
|
184
|
+
return false;
|
|
185
|
+
store.profiles = nextProfiles;
|
|
186
|
+
setFallbackCurrentProfile(store);
|
|
187
|
+
await writeStore(store);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
/** 切换当前使用的 profile,并刷新最后使用时间;不传 key 时使用当前 profile。 */
|
|
191
|
+
export async function switchProfile(profileKey) {
|
|
192
|
+
const store = await readStore();
|
|
193
|
+
const key = profileKey ?? store.currentProfile;
|
|
194
|
+
if (!key) {
|
|
195
|
+
throw new ZentaoError('E_NO_PROFILE');
|
|
196
|
+
}
|
|
197
|
+
const profile = findProfile(store, key);
|
|
198
|
+
if (!profile) {
|
|
199
|
+
throw new ZentaoError('E_PROFILE_NOT_FOUND', { profileKey: key });
|
|
200
|
+
}
|
|
201
|
+
profile.lastUsedTime = nowString();
|
|
202
|
+
store.currentProfile = key;
|
|
203
|
+
await writeStore(store);
|
|
204
|
+
return toRecord(profile);
|
|
205
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RequestOptions, ResponseData } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* 按模块动作名请求禅道 API。
|
|
4
|
+
*
|
|
5
|
+
* 选项优先级为:本次调用 options > 全局 options > 客户端默认值。
|
|
6
|
+
*/
|
|
7
|
+
export declare function request(name: `${string}/${string}`, params?: Record<string, unknown>, options?: RequestOptions): Promise<ResponseData>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ZentaoError } from '../misc/errors.js';
|
|
2
|
+
import { getGlobalOptions } from '../misc/global-options.js';
|
|
3
|
+
import { getModule } from '../modules/registry.js';
|
|
4
|
+
import { extractPager, extractResult, resolveModuleCommand } from '../modules/resolve.js';
|
|
5
|
+
/** 将 `moduleName/methodName` 形式的请求名拆成模块名和动作名。 */
|
|
6
|
+
function splitRequestName(name) {
|
|
7
|
+
const index = name.indexOf('/');
|
|
8
|
+
if (index <= 0 || index === name.length - 1) {
|
|
9
|
+
throw new ZentaoError('E_INVALID_REQUEST_NAME');
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
moduleName: name.slice(0, index),
|
|
13
|
+
actionName: name.slice(index + 1),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** 将禅道原始响应归一化为稳定的 ResponseData 结构。 */
|
|
17
|
+
function normalizeResponse(command, raw, limit) {
|
|
18
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
19
|
+
return { status: 'success', data: raw };
|
|
20
|
+
}
|
|
21
|
+
const record = raw;
|
|
22
|
+
const status = record.status === 'fail' ? 'fail' : 'success';
|
|
23
|
+
let data = extractResult(command.action, record);
|
|
24
|
+
const numericLimit = limit === undefined ? undefined : Number(limit);
|
|
25
|
+
if (Array.isArray(data) && numericLimit !== undefined && !Number.isNaN(numericLimit)) {
|
|
26
|
+
data = data.slice(0, numericLimit);
|
|
27
|
+
}
|
|
28
|
+
const pager = extractPager(command.action, record);
|
|
29
|
+
return {
|
|
30
|
+
status,
|
|
31
|
+
message: typeof record.message === 'string' ? record.message : undefined,
|
|
32
|
+
data,
|
|
33
|
+
pager: pager ? {
|
|
34
|
+
total: Number(pager.recTotal),
|
|
35
|
+
page: Number(pager.pageID),
|
|
36
|
+
recPerPage: Number(pager.recPerPage),
|
|
37
|
+
} : undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 按模块动作名请求禅道 API。
|
|
42
|
+
*
|
|
43
|
+
* 选项优先级为:本次调用 options > 全局 options > 客户端默认值。
|
|
44
|
+
*/
|
|
45
|
+
export async function request(name, params = {}, options = {}) {
|
|
46
|
+
const globals = getGlobalOptions();
|
|
47
|
+
const client = options.client ?? globals.client;
|
|
48
|
+
if (!client) {
|
|
49
|
+
throw new ZentaoError('E_NO_GLOBAL_CLIENT');
|
|
50
|
+
}
|
|
51
|
+
const { moduleName, actionName } = splitRequestName(name);
|
|
52
|
+
const module = getModule(moduleName);
|
|
53
|
+
// recPerPage 是最常用的列表参数,允许在全局或本次调用中统一覆盖。
|
|
54
|
+
const recPerPage = params.recPerPage ?? options.recPerPage ?? globals.recPerPage;
|
|
55
|
+
const mergedParams = recPerPage === undefined ? params : { ...params, recPerPage };
|
|
56
|
+
const command = resolveModuleCommand(module, actionName, mergedParams);
|
|
57
|
+
const raw = await client.request(command.path, {
|
|
58
|
+
method: String(command.action.method).toUpperCase(),
|
|
59
|
+
query: command.query,
|
|
60
|
+
body: command.data,
|
|
61
|
+
timeout: options.timeout ?? globals.timeout,
|
|
62
|
+
insecure: options.insecure ?? globals.insecure,
|
|
63
|
+
});
|
|
64
|
+
return normalizeResponse(command, raw, options.limit ?? globals.limit);
|
|
65
|
+
}
|