bdip-office-assistant-cli 0.0.0-stage → 1.0.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 +37 -2
- package/dist/accounts.js +30 -0
- package/dist/args.js +36 -0
- package/dist/auth-token.js +778 -0
- package/dist/auth.js +290 -0
- package/dist/blog.js +562 -0
- package/dist/browser-links.js +223 -0
- package/dist/captcha-display.js +30 -0
- package/dist/cli.js +5479 -0
- package/dist/client.js +254 -0
- package/dist/config.js +170 -0
- package/dist/createcontext.js +41 -0
- package/dist/crypto.js +139 -0
- package/dist/cube.js +1771 -0
- package/dist/email.js +841 -0
- package/dist/field-preparation.js +185 -0
- package/dist/fileutil.js +20 -0
- package/dist/hrm.js +478 -0
- package/dist/keepalive.js +99 -0
- package/dist/kq.js +280 -0
- package/dist/links.js +34 -0
- package/dist/nodemap.js +242 -0
- package/dist/odoc.js +199 -0
- package/dist/office-tools.js +138 -0
- package/dist/pageopen.js +29 -0
- package/dist/parse.js +177 -0
- package/dist/password-input.js +84 -0
- package/dist/password-login.js +208 -0
- package/dist/querystate.js +38 -0
- package/dist/render.js +21 -0
- package/dist/report.js +50 -0
- package/dist/session-worker.js +54 -0
- package/dist/session.js +280 -0
- package/dist/submissionplan.js +45 -0
- package/dist/submissionstate.js +81 -0
- package/dist/subordinate.js +458 -0
- package/dist/supervise.js +152 -0
- package/dist/todoaskidea.js +54 -0
- package/dist/todobatch.js +172 -0
- package/dist/todocc.js +128 -0
- package/dist/todoforward.js +66 -0
- package/dist/todoreject.js +215 -0
- package/dist/todosubmit.js +325 -0
- package/dist/todotransfer.js +54 -0
- package/dist/util.js +25 -0
- package/dist/wfagent.js +538 -0
- package/dist/wfcreate.js +102 -0
- package/dist/wfform.js +169 -0
- package/dist/wfops.js +40 -0
- package/dist/wfreceiver.js +43 -0
- package/dist/wfstart.js +558 -0
- package/dist/wfsummary.js +89 -0
- package/dist/wfupload.js +78 -0
- package/dist/wfuser.js +96 -0
- package/dist/workflow-runtime.js +151 -0
- package/dist/workflow.js +570 -0
- package/dist/workplan.js +431 -0
- package/package.json +28 -6
package/dist/kq.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BDIP5 考勤(kq)打卡模块。
|
|
3
|
+
*
|
|
4
|
+
* 抓包+实跑确认的接口(测试环境,2026-08-12):
|
|
5
|
+
* POST /api/hrm/kq/attendanceButton/getButtons 页面加载预发:取 timeline 班次参数(无 body)
|
|
6
|
+
* POST /api/hrm/kq/attendanceButton/punchButton 打卡(带 timeline 班次参数 + signTime + encryptObj)
|
|
7
|
+
*
|
|
8
|
+
* signSecret 签名算法从前端 signFunc.js 逆向(webpack source map 虚拟路径,靠 Sources 复制源码):
|
|
9
|
+
* signSecret(str) = md5("wea" + str) + "ver"(小写;标准 MD5,但输入前缀加 "wea")
|
|
10
|
+
* encryptObj = [timestamp, userid, signSecret(userid + groupid)] = `${ts},${uid},${md5("wea"+uid+gid)+"ver"}`
|
|
11
|
+
* timestamp = Date.now()(现算,服务端不校验时效);signTime = 现算 HH:mm:ss
|
|
12
|
+
* groupid = 考勤组 id(222222600 默认考勤组=1;每用户可能不同,待校准,CLI 可配 --group-id)
|
|
13
|
+
*
|
|
14
|
+
* 走当前 baseUrl(单一环境),aiAgentToken 鉴权(服务端只校验 md5(uid+gid) 防篡改,不校验会话/时效)。
|
|
15
|
+
*/
|
|
16
|
+
import * as crypto from 'crypto';
|
|
17
|
+
import { createApiFromProfile } from './client.js';
|
|
18
|
+
import { buildRequestFormUrl } from './wfcreate.js';
|
|
19
|
+
// ───────────────── signSecret(前端 signFunc.js 逆向)─────────────────
|
|
20
|
+
/** md5 小写。 */
|
|
21
|
+
function md5Hex(s) {
|
|
22
|
+
return crypto.createHash('md5').update(s, 'utf8').digest('hex');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 考勤签名:signSecret(str) = md5("wea" + str) + "ver"(与前端 signFunc.js 一致)。
|
|
26
|
+
* 前端输入经 uTF8Encode,但纯 ASCII("wea"+uid+gid)无变化,故直接 md5。
|
|
27
|
+
*/
|
|
28
|
+
export function signSecret(str) {
|
|
29
|
+
return md5Hex(`wea${str}`) + 'ver';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 构造 encryptObj = `${timestamp},${userid},${signSecret(userid+groupid)}ver`。
|
|
33
|
+
* 与 DataList.js 的 `signData=[generateTimeStamp(), userid, signSecret(userid+groupid)]` 一致
|
|
34
|
+
* (数组 toString() = "ts,uid,signSecret结果",signSecret 已含 ver 后缀)。
|
|
35
|
+
*/
|
|
36
|
+
export function buildEncryptObj(userid, groupId, timestamp) {
|
|
37
|
+
const ts = (timestamp ?? Date.now()) + '';
|
|
38
|
+
return `${ts},${userid},${signSecret(`${userid}${groupId}`)}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 取打卡班次 timeline。POST /api/hrm/kq/attendanceButton/getButtons(无 body)。
|
|
42
|
+
* 返回 timeline[](每项=一个打卡时段,含全部班次参数)+ date/now/groupname。
|
|
43
|
+
* 不返回 encryptObj/groupid/signature(前端 JS 现算)。
|
|
44
|
+
*/
|
|
45
|
+
export async function getButtons(cfg) {
|
|
46
|
+
const api = await createApiFromProfile(cfg);
|
|
47
|
+
const r = await api.post('/api/hrm/kq/attendanceButton/getButtons', {});
|
|
48
|
+
const j = r.json || {};
|
|
49
|
+
const tlRaw = Array.isArray(j.timeline) ? j.timeline : [];
|
|
50
|
+
const timeline = tlRaw.map((t) => ({
|
|
51
|
+
date: s(t.date),
|
|
52
|
+
type: s(t.type),
|
|
53
|
+
belongtime: s(t.belongtime),
|
|
54
|
+
canSignTime: s(t.canSignTime),
|
|
55
|
+
across: s(t.across),
|
|
56
|
+
belongdate: s(t.belongdate),
|
|
57
|
+
datetime: s(t.datetime),
|
|
58
|
+
min: s(t.min),
|
|
59
|
+
signSectionEndTime: s(t.signSectionEndTime),
|
|
60
|
+
islastsign: s(t.islastsign),
|
|
61
|
+
signSectionTime: s(t.signSectionTime),
|
|
62
|
+
signSectionBeginTime: s(t.signSectionBeginTime),
|
|
63
|
+
isYellow: s(t.isYellow),
|
|
64
|
+
isPunchOpen: s(t.isPunchOpen),
|
|
65
|
+
isacross: s(t.isacross),
|
|
66
|
+
pre: s(t.pre),
|
|
67
|
+
signSection: s(t.signSection),
|
|
68
|
+
signbelongspan: s(t.signbelongspan),
|
|
69
|
+
active: s(t.active),
|
|
70
|
+
needSign: s(t.needSign),
|
|
71
|
+
workmins: s(t.workmins),
|
|
72
|
+
signbelong: s(t.signbelong),
|
|
73
|
+
reSign: s(t.reSign),
|
|
74
|
+
min_next: s(t.min_next),
|
|
75
|
+
signfrom: s(t.signfrom),
|
|
76
|
+
serialid: s(t.serialid),
|
|
77
|
+
signAcross: s(t.signAcross),
|
|
78
|
+
signAcross_next: s(t.signAcross_next),
|
|
79
|
+
time: s(t.time),
|
|
80
|
+
signTime: s(t.signTime),
|
|
81
|
+
position: s(t.position),
|
|
82
|
+
isfirstsign: s(t.isfirstsign),
|
|
83
|
+
}));
|
|
84
|
+
return {
|
|
85
|
+
date: s(j.date),
|
|
86
|
+
now: s(j.now),
|
|
87
|
+
groupname: s(j.groupname),
|
|
88
|
+
lastname: s(j.lastname),
|
|
89
|
+
timeline,
|
|
90
|
+
raw: j,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 解析本次允许操作的打卡时段。
|
|
95
|
+
*
|
|
96
|
+
* getButtons 是当天考勤页的状态,不是历史补签查询接口。历史日期若在
|
|
97
|
+
* timeline 中不存在,绝不能回退到同卡种的当天记录,否则一次失败的补卡会
|
|
98
|
+
* 变成对今天记录的真实写入。
|
|
99
|
+
*/
|
|
100
|
+
export function resolvePunchItem(buttons, opts) {
|
|
101
|
+
const targetDate = String(opts?.date ?? buttons?.date ?? '');
|
|
102
|
+
const type = String(opts?.type ?? '');
|
|
103
|
+
const currentDate = String(buttons?.date ?? '');
|
|
104
|
+
if (!currentDate)
|
|
105
|
+
throw new Error('考勤接口未返回当前日期,无法安全打卡');
|
|
106
|
+
if (targetDate !== currentDate) {
|
|
107
|
+
throw new Error(`当前考勤接口只支持当天(${currentDate})打卡;不能安全处理 ${targetDate || '未指定日期'} 的历史补卡,请在 OA 考勤补签流程办理`);
|
|
108
|
+
}
|
|
109
|
+
const item = (buttons?.timeline ?? []).find((t) => String(t.date) === targetDate && String(t.type) === type);
|
|
110
|
+
if (!item)
|
|
111
|
+
throw new Error(`未找到当天打卡时段(date=${targetDate}, type=${type}),已停止执行以避免误写其他记录`);
|
|
112
|
+
return { targetDate, item };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 打卡。链路:getButtons(取 timeline 班次参数)→ 生成 encryptObj → punchButton。
|
|
116
|
+
* - type=on 上班卡 / off 下班卡,按 date(默认今天)匹配 timeline 时段。
|
|
117
|
+
* - encryptObj = [Date.now(), userid, signSecret(userid+groupId)+"ver"](前端 signFunc.js 算法)。
|
|
118
|
+
* - signTime = 当前时刻 HH:mm:ss(或传入)。
|
|
119
|
+
* 服务端只校验 md5("wea"+uid+gid) 防篡改,不校验 timestamp/会话,token 鉴权可行。
|
|
120
|
+
*/
|
|
121
|
+
export async function punch(cfg, opts) {
|
|
122
|
+
const api = await createApiFromProfile(cfg);
|
|
123
|
+
const buttons = await getButtons(cfg);
|
|
124
|
+
const { item } = resolvePunchItem(buttons, opts);
|
|
125
|
+
// userid:优先传入;否则从 config.cookie 的 loginuuids 解析(token-login 写回 cookie 含用户 id)
|
|
126
|
+
const userid = opts.userid ?? parseUserIdFromCookie(cfg.cookie) ?? '';
|
|
127
|
+
if (!userid) {
|
|
128
|
+
throw new Error('未取到 userid:punch 需 --userid,或 config.cookie 须含 loginuuids(token-login 会写回)');
|
|
129
|
+
}
|
|
130
|
+
const groupId = opts.groupId ?? '1';
|
|
131
|
+
// punchButton 的 signTime 是本次打卡时刻,不是修改既有打卡时间的参数。
|
|
132
|
+
// 始终现算,避免调用方把历史或任意时间写入当天记录。
|
|
133
|
+
const signTime = nowHHmmss();
|
|
134
|
+
const encryptObj = buildEncryptObj(userid, groupId, opts.timestamp);
|
|
135
|
+
const body = {
|
|
136
|
+
date: item.date,
|
|
137
|
+
belongtime: item.belongtime,
|
|
138
|
+
type: item.type,
|
|
139
|
+
canSignTime: item.canSignTime,
|
|
140
|
+
across: item.across,
|
|
141
|
+
belongdate: item.belongdate,
|
|
142
|
+
datetime: item.datetime,
|
|
143
|
+
min: item.min,
|
|
144
|
+
islastsign: item.islastsign,
|
|
145
|
+
signSectionTime: item.signSectionTime,
|
|
146
|
+
signSectionBeginTime: item.signSectionBeginTime ?? '',
|
|
147
|
+
isYellow: item.isYellow,
|
|
148
|
+
isPunchOpen: item.isPunchOpen,
|
|
149
|
+
isacross: item.isacross,
|
|
150
|
+
pre: item.pre ?? '0',
|
|
151
|
+
signSection: item.signSection,
|
|
152
|
+
signbelongspan: item.signbelongspan ?? '今天',
|
|
153
|
+
active: item.active ?? '0',
|
|
154
|
+
needSign: item.needSign ?? '0',
|
|
155
|
+
workmins: item.workmins,
|
|
156
|
+
signbelong: item.signbelong ?? '今天',
|
|
157
|
+
reSign: item.reSign ?? '1',
|
|
158
|
+
min_next: item.min_next,
|
|
159
|
+
signfrom: item.signfrom ?? 'e9pc',
|
|
160
|
+
serialid: item.serialid,
|
|
161
|
+
signAcross: item.signAcross,
|
|
162
|
+
signAcross_next: item.signAcross_next,
|
|
163
|
+
time: item.time,
|
|
164
|
+
position: '',
|
|
165
|
+
longitude: '',
|
|
166
|
+
latitude: '',
|
|
167
|
+
sid: '',
|
|
168
|
+
mac: '',
|
|
169
|
+
deviceId: '',
|
|
170
|
+
deviceInfo: '',
|
|
171
|
+
signTime,
|
|
172
|
+
encryptObj,
|
|
173
|
+
...(item.isfirstsign !== undefined ? { isfirstsign: item.isfirstsign } : {}),
|
|
174
|
+
};
|
|
175
|
+
const r = await api.post('/api/hrm/kq/attendanceButton/punchButton', body);
|
|
176
|
+
const j = r.json || {};
|
|
177
|
+
return {
|
|
178
|
+
success: s(j.success),
|
|
179
|
+
message: s(j.message),
|
|
180
|
+
signtime: s(j.signtime),
|
|
181
|
+
signdate: s(j.signdate),
|
|
182
|
+
kqstatus: s(j.kqstatus),
|
|
183
|
+
status: s(j.status),
|
|
184
|
+
raw: j,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** 本人考勤按网页“我的考勤”读取,不使用 Cookie 组合推断账号。 */
|
|
188
|
+
export async function getKqCurrentUser(cfg) {
|
|
189
|
+
const api = await createApiFromProfile(cfg);
|
|
190
|
+
const r = await api.get('/api/hrm/login/getAccountList', {}, { forceCookie: true });
|
|
191
|
+
const current = r.json?.data?.accountlist?.filter(a => String(a.iscurrent) === '1');
|
|
192
|
+
if (!Array.isArray(current) || current.length !== 1)
|
|
193
|
+
throw new Error('未能核验当前考勤账号,请重新登录;不能将鉴权失败视为零考勤');
|
|
194
|
+
return String(current[0].userid);
|
|
195
|
+
}
|
|
196
|
+
async function readMyAttendance(cfg, opts, method) {
|
|
197
|
+
const current = await getKqCurrentUser(cfg);
|
|
198
|
+
if (opts.resourceId && String(opts.resourceId) !== current)
|
|
199
|
+
throw new Error('当前仅验证了本人“我的考勤”接口;指定他人查询尚未验证,不返回本人结果代替');
|
|
200
|
+
const type = String(opts.type ?? '2');
|
|
201
|
+
if (type !== '2') throw new Error('本人考勤年视图尚未验证,请使用 --month 查询月视图');
|
|
202
|
+
const typevalue = String(opts.typevalue ?? defaultKqTypeValue('2'));
|
|
203
|
+
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(typevalue)) throw new Error('月份必须为YYYY-MM');
|
|
204
|
+
const api = await createApiFromProfile(cfg);
|
|
205
|
+
const r = await api.post('/api/kq/myattendance/' + method,
|
|
206
|
+
{ loaddata: '1', type, typevalue }, { forceCookie: true });
|
|
207
|
+
if (String(r.json?.status) !== '1' || r.json?.api_status === false)
|
|
208
|
+
throw new Error('我的考勤读取失败或鉴权失效,不能归零:' + String(r.json?.msg ?? r.json?.message ?? '非预期响应'));
|
|
209
|
+
if (await getKqCurrentUser(cfg) !== current) throw new Error('查询期间账号发生变化,请重新查询');
|
|
210
|
+
return { j: r.json, type, typevalue, resourceId: current };
|
|
211
|
+
}
|
|
212
|
+
export function normalizeKqReport(j) {
|
|
213
|
+
const g1 = j.groupitem1;
|
|
214
|
+
if (!g1 || g1.value === undefined || !Array.isArray(g1.items) ||
|
|
215
|
+
!g1.items.some(x => x.id === 'leaveDays') || !g1.items.some(x => x.id === 'realWorkDays') ||
|
|
216
|
+
!Array.isArray(j.groupitem2?.items) || !Array.isArray(j.groupitem3?.items) ||
|
|
217
|
+
!Array.isArray(j.groupitem4?.items) || !Array.isArray(j.groupitem5?.items))
|
|
218
|
+
throw new Error('我的考勤统计结构不匹配,不能按零或全正常输出');
|
|
219
|
+
const flat = g => g.items.map(it => ({ id: s(it.id), title: s(it.title || it.name), value: s(it.value), type: s(it.type), unit: s(it.unit) }));
|
|
220
|
+
const attendance = flat(g1);
|
|
221
|
+
return {
|
|
222
|
+
attendance: { workDays: s(g1.value), unit: '天',
|
|
223
|
+
leaveDays: s(g1.items.find(x => x.id === 'leaveDays').value),
|
|
224
|
+
realWorkDays: s(g1.items.find(x => x.id === 'realWorkDays').value), items: attendance },
|
|
225
|
+
durations: flat(j.groupitem2), exceptions: flat(j.groupitem3),
|
|
226
|
+
leave: j.groupitem4.items.map(g => ({ title: s(g.title), unit: s(g.unit),
|
|
227
|
+
items: (g.item ?? []).filter(x => x && x.title).map(x => ({title: s(x.title), value: s(x.value), unit: s(x.unit)})) })),
|
|
228
|
+
leaveDurations: flat(j.groupitem5),
|
|
229
|
+
notes: j.groupitem5.items.length ? [] : ['服务器未返回请假明细'],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
export async function getKqReport(cfg, opts = {}) {
|
|
233
|
+
const {j, ...meta} = await readMyAttendance(cfg, opts, 'getHrmKQReportInfo');
|
|
234
|
+
return {...meta, ...normalizeKqReport(j), status: j.status, raw: j};
|
|
235
|
+
}
|
|
236
|
+
export function normalizeKqDays(j, baseUrl = '') {
|
|
237
|
+
const res = j.result;
|
|
238
|
+
if (!res || typeof res !== 'object' || !Object.keys(res).length)
|
|
239
|
+
throw new Error('我的考勤月明细为空或结构不匹配,不能宣称无异常');
|
|
240
|
+
return Object.values(res).sort((a,b) => String(a.date).localeCompare(String(b.date))).map(d => {
|
|
241
|
+
if (!d.date || !Array.isArray(d.types) || (d.signInfo !== undefined && !Array.isArray(d.signInfo)) || (d.signInfo === undefined && d.isWorkDay !== false) || !Array.isArray(d.workflow))
|
|
242
|
+
throw new Error('我的考勤每日明细结构不匹配');
|
|
243
|
+
const pick = title => s((d.signInfo ?? []).find(x => x.title === title)?.signTime);
|
|
244
|
+
return {date: s(d.date), types: d.types, signInfo: d.signInfo ?? [],
|
|
245
|
+
signIn: pick('上班打卡'), signOut: pick('下班打卡'), isWorkDay: d.isWorkDay,
|
|
246
|
+
workflow: d.workflow.map(w => ({...w,
|
|
247
|
+
url: w.requestId && baseUrl ? buildRequestFormUrl(baseUrl, w.requestId) : undefined}))};
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
export async function getKqMonthReport(cfg, opts = {}) {
|
|
251
|
+
const {j, ...meta} = await readMyAttendance(cfg, {...opts, type: '2'}, 'getHrmKQMonthReportInfo');
|
|
252
|
+
return {...meta, days: normalizeKqDays(j, cfg.baseUrl), status: j.status, raw: j};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 默认 typevalue:type=1 → "YYYY",type=2 → "YYYY-MM"(本地时区)。 */
|
|
256
|
+
function defaultKqTypeValue(type) {
|
|
257
|
+
const d = new Date();
|
|
258
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
259
|
+
return String(type) === '1' ? `${d.getFullYear()}` : `${d.getFullYear()}-${p(d.getMonth() + 1)}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ───────────────── 工具 ─────────────────
|
|
263
|
+
function s(v) {
|
|
264
|
+
if (v === undefined || v === null)
|
|
265
|
+
return '';
|
|
266
|
+
return String(v);
|
|
267
|
+
}
|
|
268
|
+
/** 从 cookie 串解析 loginuuids(当前登录用户 id)。token-login 写回 cookie 含此字段。 */
|
|
269
|
+
function parseUserIdFromCookie(cookie) {
|
|
270
|
+
if (!cookie)
|
|
271
|
+
return '';
|
|
272
|
+
const m = cookie.match(/loginuuids=([^;]+)/);
|
|
273
|
+
return m ? m[1].trim() : '';
|
|
274
|
+
}
|
|
275
|
+
/** 当前时刻 HH:mm:ss(本地时区)。 */
|
|
276
|
+
function nowHHmmss() {
|
|
277
|
+
const d = new Date();
|
|
278
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
279
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
280
|
+
}
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function baseUrl(base) {
|
|
2
|
+
return String(base ?? '').replace(/\/+$/, '');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function buildBlogMyUrl(base) {
|
|
6
|
+
return `${baseUrl(base)}/wui/index.html#/main/blog/myBlog?menuIds=392,393&menuPathIds=392,393`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildCubeCardUrl(base, { modeId, formId, billid, customid }) {
|
|
10
|
+
if ([modeId, formId, billid, customid].some((value) => value === undefined || value === null || value === ''))
|
|
11
|
+
return undefined;
|
|
12
|
+
return `${baseUrl(base)}/spa/cube/index.html#/main/cube/card?type=0&modeId=${modeId}&formId=${formId}&billid=${billid}&opentype=0&customid=${customid}&viewfrom=fromsearchlist`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildCubeSearchUrl(base, customid) {
|
|
16
|
+
if (customid === undefined || customid === null || customid === '')
|
|
17
|
+
return undefined;
|
|
18
|
+
return `${baseUrl(base)}/spa/cube/index.html#/main/cube/search?customid=${encodeURIComponent(String(customid))}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 把 BDIP5 菜单返回的相对地址补成可点击的绝对地址。 */
|
|
22
|
+
export function normalizeE9Url(base, value) {
|
|
23
|
+
const raw = String(value ?? '').trim();
|
|
24
|
+
if (!raw)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (/^https?:\/\//i.test(raw))
|
|
27
|
+
return raw;
|
|
28
|
+
const b = baseUrl(base);
|
|
29
|
+
if (raw.startsWith('#/'))
|
|
30
|
+
return `${b}/spa/cube/index.html${raw}`;
|
|
31
|
+
if (raw.startsWith('/main/cube/'))
|
|
32
|
+
return `${b}/spa/cube/index.html#${raw}`;
|
|
33
|
+
return `${b}/${raw.replace(/^\/+/, '')}`;
|
|
34
|
+
}
|
package/dist/nodemap.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 流程节点名解析:nodeid → nodename。
|
|
3
|
+
*
|
|
4
|
+
* 背景:待办/已办/我发起列表服务端**只给当前节点的 id**——行字段 `currentnodeid` 的值形如 "2882",
|
|
5
|
+
* 且行里没有节点名(实测 `currentnodeidspan` 也只是 id 本身,整行不含 currentnodename/currentnodespan)。
|
|
6
|
+
* 要显示名字只能按流程另取节点表。
|
|
7
|
+
*
|
|
8
|
+
* 接口(2026-10-14 实测 e9pc.e-cology.com.cn):
|
|
9
|
+
* POST /api/workflow/test/getNodeInfo body: { workflowId }
|
|
10
|
+
* → { nodeInfo: [{id, name, type}], wfInfo: {workflowName, version}, authority, tabInfo, ... }
|
|
11
|
+
* - 只需 workflowId;文档里的 requestid/userId/type 都非必需(逐个试过)。
|
|
12
|
+
* - ⚠️ 路径必须带 `/api` 前缀:`/workflow/test/getNodeInfo`(无前缀)返回登录页 HTML。
|
|
13
|
+
* - ⚠️ 接口挂在「流程测试」模块下,非所有部署/版本都有 → **一律 fail-soft**:
|
|
14
|
+
* 拿不到就返回 null,调用方退回显示节点 id,绝不让列表整体报错。
|
|
15
|
+
* - `type`:0=发起节点 / 1=审批节点 / 3=归档节点。
|
|
16
|
+
*
|
|
17
|
+
* 缓存:`~/.bdip/node_map_<envkey>.json`,按 workflowId 存,TTL 30 天
|
|
18
|
+
* (节点表几乎不变;响应带 version,流程改版后重新拉取自然覆盖)。
|
|
19
|
+
* - `E9_NODEMAP_CACHE_DIR` 覆盖缓存目录(测试隔离用,避免污染真实 ~/.e9)。
|
|
20
|
+
* - `E9_NO_NODE_NAME=1` 或 CLI `--no-node-name` 整体停用解析。
|
|
21
|
+
*/
|
|
22
|
+
import { createApiFromProfile } from './client.js';
|
|
23
|
+
import { endpointFor, profileNameFromBaseUrl } from './config.js';
|
|
24
|
+
import { stripHtml } from './parse.js';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
|
|
29
|
+
/** 节点表缓存 TTL(天)。 */
|
|
30
|
+
export const NODEMAP_TTL_MS = (Number(process.env.E9_NODEMAP_TTL_DAYS ?? '30') || 30) * 24 * 3600 * 1000;
|
|
31
|
+
/** 并发拉取上限(一次列表页通常 ≤10 个流程,全量拉取路径可能几十个,别一次打满)。 */
|
|
32
|
+
const FETCH_CHUNK = 6;
|
|
33
|
+
|
|
34
|
+
function str(v) {
|
|
35
|
+
if (v === undefined || v === null || v === '')
|
|
36
|
+
return undefined;
|
|
37
|
+
return String(v);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 缓存目录(测试可用 E9_NODEMAP_CACHE_DIR 覆盖)。 */
|
|
41
|
+
export function nodeMapCacheDir() {
|
|
42
|
+
return process.env.E9_NODEMAP_CACHE_DIR || path.join(os.homedir(), '.bdip');
|
|
43
|
+
}
|
|
44
|
+
/** 缓存文件路径:~/.bdip/node_map_<envkey>.json(按环境隔离,nodeid 不跨环境通用)。 */
|
|
45
|
+
export function nodeMapCachePath(cfg) {
|
|
46
|
+
const baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
47
|
+
return path.join(nodeMapCacheDir(), `node_map_${profileNameFromBaseUrl(baseUrl)}.json`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 是否停用节点名解析(E9_NO_NODE_NAME=1)。 */
|
|
51
|
+
export function isNodeNameDisabled() {
|
|
52
|
+
return process.env.E9_NO_NODE_NAME === '1';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 纯函数:把 getNodeInfo 响应归一化为 { workflowName, version, nodes:{id:name} }。
|
|
57
|
+
* 结构不符(登录页/空表/非 JSON)返回 null——调用方据此 fail-soft。
|
|
58
|
+
*/
|
|
59
|
+
export function parseNodeInfo(json) {
|
|
60
|
+
const list = json?.nodeInfo;
|
|
61
|
+
if (!Array.isArray(list))
|
|
62
|
+
return null;
|
|
63
|
+
const nodes = {};
|
|
64
|
+
for (const n of list) {
|
|
65
|
+
const id = str(n?.id);
|
|
66
|
+
if (!id)
|
|
67
|
+
continue;
|
|
68
|
+
nodes[id] = stripHtml(n?.name) || id;
|
|
69
|
+
}
|
|
70
|
+
if (Object.keys(nodes).length === 0)
|
|
71
|
+
return null;
|
|
72
|
+
return {
|
|
73
|
+
workflowName: str(json?.wfInfo?.workflowName),
|
|
74
|
+
version: str(json?.wfInfo?.version),
|
|
75
|
+
nodes,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 读缓存文件;坏文件/不存在返回 {}。 */
|
|
80
|
+
function readCache(cfg) {
|
|
81
|
+
try {
|
|
82
|
+
const p = nodeMapCachePath(cfg);
|
|
83
|
+
if (!fs.existsSync(p))
|
|
84
|
+
return {};
|
|
85
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
86
|
+
return raw && typeof raw === 'object' && raw.workflows ? raw.workflows : {};
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 写回缓存(单流程);写入失败不阻断(节点名只是锦上添花)。 */
|
|
94
|
+
function writeCache(cfg, workflowId, entry) {
|
|
95
|
+
try {
|
|
96
|
+
const p = nodeMapCachePath(cfg);
|
|
97
|
+
const workflows = readCache(cfg);
|
|
98
|
+
workflows[workflowId] = entry;
|
|
99
|
+
const baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
100
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
101
|
+
fs.writeFileSync(p, JSON.stringify({
|
|
102
|
+
envkey: profileNameFromBaseUrl(baseUrl),
|
|
103
|
+
baseUrl,
|
|
104
|
+
updatedAt: new Date().toISOString(),
|
|
105
|
+
workflows,
|
|
106
|
+
}, null, 2) + '\n', 'utf8');
|
|
107
|
+
}
|
|
108
|
+
catch { /* 忽略:缓存写失败不影响本次结果 */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isStale(entry) {
|
|
112
|
+
if (!entry || !entry.fetchedAt)
|
|
113
|
+
return true;
|
|
114
|
+
return Date.now() - new Date(entry.fetchedAt).getTime() > NODEMAP_TTL_MS;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 取某流程的节点表(带缓存)。任何失败返回 null(fail-soft,不抛)。
|
|
119
|
+
* @returns {Promise<{workflowName?:string, version?:string, nodes:Record<string,string>}|null>}
|
|
120
|
+
*/
|
|
121
|
+
export async function getNodeMap(cfg, workflowId, opts = {}) {
|
|
122
|
+
const wfId = str(workflowId);
|
|
123
|
+
if (!wfId)
|
|
124
|
+
return null;
|
|
125
|
+
const cached = readCache(cfg)[wfId];
|
|
126
|
+
if (!opts.refresh && cached && !isStale(cached) && cached.nodes)
|
|
127
|
+
return { workflowName: cached.workflowName, version: cached.version, nodes: cached.nodes };
|
|
128
|
+
const api = opts.api ?? await createApiFromProfile(cfg);
|
|
129
|
+
let json = null;
|
|
130
|
+
try {
|
|
131
|
+
const r = await api.post('/api/workflow/test/getNodeInfo', { workflowId: wfId });
|
|
132
|
+
json = r?.json ?? null;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
json = null; // 网络/鉴权异常同上:降级,不打断列表
|
|
136
|
+
}
|
|
137
|
+
const parsed = parseNodeInfo(json);
|
|
138
|
+
if (!parsed)
|
|
139
|
+
return null;
|
|
140
|
+
writeCache(cfg, wfId, { ...parsed, workflowId: wfId, fetchedAt: new Date().toISOString() });
|
|
141
|
+
return parsed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 列出**本地缓存**里所有流程的节点表(供「按节点名跨流程解析」用)。
|
|
146
|
+
* 缓存来自历次列表查询;从没查过的流程不在其中。
|
|
147
|
+
*/
|
|
148
|
+
export function listCachedNodeMaps(cfg) {
|
|
149
|
+
const workflows = readCache(cfg);
|
|
150
|
+
return Object.entries(workflows)
|
|
151
|
+
.filter(([, w]) => w && w.nodes && Object.keys(w.nodes).length)
|
|
152
|
+
.map(([workflowId, w]) => ({ workflowId: String(workflowId), workflowName: w.workflowName, nodes: w.nodes }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 按**节点名**解析节点 id(供 CLI `--node-name`)。
|
|
157
|
+
*
|
|
158
|
+
* 节点 id 是**流程内编号**(每个流程一套),故解析必须先限定流程范围:
|
|
159
|
+
* - 给了 `workflowId`:只在该流程的节点表内找(按需拉取,不依赖缓存);
|
|
160
|
+
* - 未给:在**本地缓存**的所有流程节点表内找(缓存来自历次列表查询)。
|
|
161
|
+
*
|
|
162
|
+
* 匹配规则:**先精确同名**;无精确才做模糊(互相包含)。多候选一律**全部返回**,
|
|
163
|
+
* 由调用方列给用户选——严禁自动取首条(本仓库硬规则)。
|
|
164
|
+
*
|
|
165
|
+
* @returns {Promise<{candidates:Array<{workflowId,workflowName,nodeId,nodeName,matchType}>,
|
|
166
|
+
* exact:Array, scoped:boolean, poolSize:number, unavailable:boolean}>}
|
|
167
|
+
* `unavailable`:限定了流程但节点表取不到(接口不存在/异常)——与"确实没有同名节点"要区分开。
|
|
168
|
+
*/
|
|
169
|
+
export async function resolveNodeByName(cfg, name, opts = {}) {
|
|
170
|
+
const want = String(name ?? '').trim();
|
|
171
|
+
const empty = { candidates: [], exact: [], scoped: Boolean(opts.workflowId), poolSize: 0, unavailable: false };
|
|
172
|
+
if (!want)
|
|
173
|
+
return empty;
|
|
174
|
+
let pool = [];
|
|
175
|
+
if (opts.workflowId) {
|
|
176
|
+
const m = await getNodeMap(cfg, opts.workflowId, opts);
|
|
177
|
+
if (!m)
|
|
178
|
+
return { ...empty, unavailable: true };
|
|
179
|
+
pool = [{ workflowId: String(opts.workflowId), workflowName: m.workflowName, nodes: m.nodes }];
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
pool = listCachedNodeMaps(cfg);
|
|
183
|
+
}
|
|
184
|
+
const candidates = [];
|
|
185
|
+
for (const w of pool) {
|
|
186
|
+
for (const [nodeId, nodeName] of Object.entries(w.nodes)) {
|
|
187
|
+
if (!nodeName)
|
|
188
|
+
continue;
|
|
189
|
+
const isExact = nodeName === want;
|
|
190
|
+
const isFuzzy = !isExact && (nodeName.includes(want) || want.includes(nodeName));
|
|
191
|
+
if (isExact || isFuzzy)
|
|
192
|
+
candidates.push({ workflowId: w.workflowId, workflowName: w.workflowName, nodeId, nodeName, matchType: isExact ? 'exact' : 'fuzzy' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
candidates,
|
|
197
|
+
exact: candidates.filter((c) => c.matchType === 'exact'),
|
|
198
|
+
scoped: Boolean(opts.workflowId),
|
|
199
|
+
poolSize: pool.length,
|
|
200
|
+
unavailable: false,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 从列表行取流程 id(normalizeRow 归一字段优先,回退原始行)。 */
|
|
205
|
+
function workflowIdOf(item) {
|
|
206
|
+
return str(item?.workflowId) ?? str(item?.raw?.workflowid) ?? str(item?.raw?.workflowId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 给列表行补 `nodeName`(就地写回)。返回补上的行数。
|
|
211
|
+
*
|
|
212
|
+
* - 行需带 `node`(节点 id);流程 id 取 `workflowId` 或 `raw.workflowid`。
|
|
213
|
+
* - 按流程去重,每个流程只请求一次;同一流程的多行共用一张表。
|
|
214
|
+
* - 任何失败都不抛:拿不到节点表的行保持原样(渲染层回退显示 id)。
|
|
215
|
+
*/
|
|
216
|
+
export async function annotateNodeNames(cfg, items, opts = {}) {
|
|
217
|
+
if (!Array.isArray(items) || items.length === 0)
|
|
218
|
+
return 0;
|
|
219
|
+
if (opts.disabled)
|
|
220
|
+
return 0;
|
|
221
|
+
// 只对「node 是纯数字 id」的行解析:服务端不同列表的 span 给的东西不一样——
|
|
222
|
+
// 待办/公文给的是**节点 id**(需要解析),流程代理办理记录给的是**节点名**(解析无从匹配,纯浪费请求)。
|
|
223
|
+
const rows = items.filter((it) => /^\d+$/.test(str(it?.node) ?? '') && workflowIdOf(it));
|
|
224
|
+
if (rows.length === 0)
|
|
225
|
+
return 0;
|
|
226
|
+
const wfIds = [...new Set(rows.map(workflowIdOf))];
|
|
227
|
+
const maps = new Map();
|
|
228
|
+
for (let i = 0; i < wfIds.length; i += FETCH_CHUNK) {
|
|
229
|
+
const chunk = wfIds.slice(i, i + FETCH_CHUNK);
|
|
230
|
+
const got = await Promise.all(chunk.map((w) => getNodeMap(cfg, w, opts)));
|
|
231
|
+
chunk.forEach((w, idx) => maps.set(w, got[idx]));
|
|
232
|
+
}
|
|
233
|
+
let filled = 0;
|
|
234
|
+
for (const it of rows) {
|
|
235
|
+
const name = maps.get(workflowIdOf(it))?.nodes?.[str(it.node)];
|
|
236
|
+
if (name) {
|
|
237
|
+
it.nodeName = name;
|
|
238
|
+
filled++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return filled;
|
|
242
|
+
}
|