weaver-e9-assistant-cli 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blog.js +37 -7
- package/dist/cli.js +221 -24
- package/dist/cube.js +31 -0
- package/dist/hrm.js +448 -0
- package/dist/parse.js +15 -1
- package/dist/subordinate.js +433 -419
- package/dist/wfstart.js +34 -2
- package/dist/wfuser.js +8 -9
- package/dist/workflow.js +133 -24
- package/package.json +2 -2
package/dist/hrm.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组织人事(HRM)—— 人事搜索:按搜索条件查员工,返回人事档案 + 详情卡片超链接。
|
|
3
|
+
*
|
|
4
|
+
* 链路(用户抓包实证,HAR:capture/人事抓包.har):
|
|
5
|
+
* 1) GET /api/hrm/search/getHrmSearchMoudleList → tabs(全部人员 default_3 / 同部门 default_1 / 我的下属 default_2)
|
|
6
|
+
* 2) GET /api/hrm/search/getHrmSearchCondition?mouldid=X → 搜索条件 label→domkey(default_3=23 个 / default_1=21 / default_2=22)
|
|
7
|
+
* 3) POST /api/hrm/search/getHrmSearchResult body{tabkey,showAllLevel,virtualtype,...} → sessionkey(恒顶层)
|
|
8
|
+
* 4) POST /api/ec/dev/table/datas body{dataKey=sessionkey,current,sortParams} → columns(32 列) + datas
|
|
9
|
+
* 5) POST /api/ec/dev/table/counts body{dataKey} → {count:N, status:true}
|
|
10
|
+
* 6) GET /api/hrm/base/getHrmSearchTree 组织树(type=0 顶层分部 / type=1 分部→子分部+部门 / type=2 部门→子部门)
|
|
11
|
+
*
|
|
12
|
+
* 详情卡片链接(真机验证通过):
|
|
13
|
+
* {base}/spa/hrm/index_mobx.html#/main/hrm/card/cardInfo/<hrmresource.id>
|
|
14
|
+
* resourceId 取自 randomField0/randomFieldId(与 id 列同值,但 id 列标题是「是否在线」,不作为主键)。
|
|
15
|
+
*
|
|
16
|
+
* 越权防护(与全局一致,见 cube/subordinate):
|
|
17
|
+
* 按人条件(姓名/直接上级/助理)由调用方 assertNamesInScope 校验——目标必须 ∈ {本人 + 全部层级下属};
|
|
18
|
+
* E9_SCOPE_ENFORCE=1 时才硬拦截,默认停用(不拦截,代码路径保留)。
|
|
19
|
+
* 部门/分部是组织结构过滤,不涉及具体个人,不做按人校验。
|
|
20
|
+
*/
|
|
21
|
+
import { createApiFromProfile } from './client.js';
|
|
22
|
+
import { extractDataKey, extractRows, extractColumns, extractTotal, pickByPaths, stripHtml } from './parse.js';
|
|
23
|
+
|
|
24
|
+
// ─── Tab 定义 ────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/** 人事搜索 Tab:中文名/别名 → tabkey(抓包实证 getHrmSearchMoudleList)。 */
|
|
27
|
+
export const HRM_TABS = {
|
|
28
|
+
'全部人员': 'default_3',
|
|
29
|
+
'全部': 'default_3',
|
|
30
|
+
'all': 'default_3',
|
|
31
|
+
'同部门': 'default_1',
|
|
32
|
+
'dept': 'default_1',
|
|
33
|
+
'我的下属': 'default_2',
|
|
34
|
+
'下属': 'default_2',
|
|
35
|
+
'subordinate': 'default_2',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Tab 名/别名/tabkey → tabkey;未知原样透传(交服务端判定),空则默认全部人员。 */
|
|
39
|
+
export function resolveHrmTab(tab) {
|
|
40
|
+
const t = String(tab ?? '').trim();
|
|
41
|
+
if (HRM_TABS[t])
|
|
42
|
+
return HRM_TABS[t];
|
|
43
|
+
return t || 'default_3';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── 按人条件判定(越权校验用) ──────────────────────────
|
|
47
|
+
|
|
48
|
+
/** 明确排除的非人字段:联系方式/日期时间/计数单位(与 cube.js 一致)。 */
|
|
49
|
+
const HRM_NON_PERSON_LABEL = /电话|手机|邮箱|号码|传真|日期|时间|区间|人数|人次|人天/;
|
|
50
|
+
/** 明确的人员白名单(人事域:姓名/直接上级/助理 + 通用称谓)。 */
|
|
51
|
+
const HRM_PERSON_LABEL = /^(姓名|直接上级|直属上级|助理|上级|下属|人员|联系人|领导)$/u;
|
|
52
|
+
/** 人员后缀判据:X人/X人员/X员/X者(报销人/值班人员/采购员…)。 */
|
|
53
|
+
const HRM_PERSON_SUFFIX = /[人者员]$/u;
|
|
54
|
+
|
|
55
|
+
/** 该条件 label 是否「按人过滤」(fail-closed:宁可多拦)。 */
|
|
56
|
+
export function isHrmPersonCondition(label) {
|
|
57
|
+
if (typeof label !== 'string' || !label.trim())
|
|
58
|
+
return false;
|
|
59
|
+
const l = label.trim();
|
|
60
|
+
if (HRM_NON_PERSON_LABEL.test(l))
|
|
61
|
+
return false;
|
|
62
|
+
if (HRM_PERSON_SUFFIX.test(l))
|
|
63
|
+
return true;
|
|
64
|
+
return HRM_PERSON_LABEL.test(l);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── 链路 1/2:Tab 与条件定义 ────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** 人事搜索 Tab 列表。GET /api/hrm/search/getHrmSearchMoudleList。 */
|
|
70
|
+
export async function getHrmTabs(cfg, opts = {}) {
|
|
71
|
+
const api = await createApiFromProfile(cfg);
|
|
72
|
+
const r = await api.get('/api/hrm/search/getHrmSearchMoudleList', { virtualtype: opts.virtualtype ?? '' });
|
|
73
|
+
const j = r?.json ?? {};
|
|
74
|
+
const tabs = (j.tabs || []).map((t) => ({
|
|
75
|
+
typeid: t.typeid,
|
|
76
|
+
title: t.title,
|
|
77
|
+
editable: !!t.editable,
|
|
78
|
+
}));
|
|
79
|
+
return { tabs, raw: j };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 指定 Tab 的搜索条件定义。GET /api/hrm/search/getHrmSearchCondition?mouldid=<tabkey>。 */
|
|
83
|
+
export async function getHrmConditions(cfg, { mouldid = 'default_3' } = {}) {
|
|
84
|
+
const api = await createApiFromProfile(cfg);
|
|
85
|
+
const r = await api.get('/api/hrm/search/getHrmSearchCondition', { mouldid: String(mouldid) });
|
|
86
|
+
const j = r?.json ?? {};
|
|
87
|
+
const groups = (j.condition || []).map((g) => ({
|
|
88
|
+
title: g.title,
|
|
89
|
+
items: (g.items || []).map((it) => ({
|
|
90
|
+
label: it.label,
|
|
91
|
+
domkey: Array.isArray(it.domkey) ? it.domkey[0] : (typeof it.domkey === 'string' ? it.domkey : null),
|
|
92
|
+
conditionType: it.conditionType,
|
|
93
|
+
})).filter((it) => it.label),
|
|
94
|
+
})).filter((g) => g.items.length);
|
|
95
|
+
// 扁平 items(供 label→domkey 映射 / 按人分类)
|
|
96
|
+
const items = groups.flatMap((g) => g.items);
|
|
97
|
+
return { groups, items, raw: j };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── 条件解析(--condition "岗位=经理;性别=男") ──────────
|
|
101
|
+
|
|
102
|
+
/** 条件定义 domkey → 真实搜索参数:部门/分部是 BROWSER 占位 domkey(department/subcompany),
|
|
103
|
+
* 搜索接口实际认 departmentid/subcompanyid1(2026-09-20 e9pc 抓包实证 subcompanyid1=80、departmentid=850)。 */
|
|
104
|
+
export const HRM_ORG_DOMKEY_REMAP = { department: 'departmentid', subcompany: 'subcompanyid1' };
|
|
105
|
+
|
|
106
|
+
/** BROWSER 人字段 domkey:值须为 hrmresource id(=userid),不认姓名文本(2026-09-20 e9pc 实测)。
|
|
107
|
+
* 姓名(INPUT)认姓名文本,不在此列。 */
|
|
108
|
+
export const HRM_BROWSER_PERSON_DOMKEYS = new Set(['manager', 'assistant']);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* ★E9 实测校准(2026-09-20 e9pc):直接把 manager/助理 的姓名文本传给搜索接口会 0 结果,
|
|
112
|
+
* 必须先经 assertNamesInScope 解析为 uid(=hrmresource id)再回填。本函数按位把解析结果
|
|
113
|
+
* 替换到 BROWSER 人字段值上(姓名 INPUT 字段如 resourcename 保持姓名文本不动)。
|
|
114
|
+
*
|
|
115
|
+
* @param conditionValues resolveHrmCondition 产出的 {domkey:value}(domkey 已归一为搜索接口认的键)
|
|
116
|
+
* @param personConditions resolveHrmCondition 产出的按人条件 [{label, domkey, value}](与 resolvedIds 按位对应)
|
|
117
|
+
* @param resolvedIds assertNamesInScope 的返回值:单个 uid 或 uid 数组
|
|
118
|
+
* @param browserDomkeys 需替换为 id 的 BROWSER 人字段 domkey 集合(默认 manager/assistant)
|
|
119
|
+
* @returns 新 conditionValues(不修改入参)
|
|
120
|
+
*/
|
|
121
|
+
export function applyResolvedPersonIds(conditionValues, personConditions, resolvedIds, browserDomkeys = HRM_BROWSER_PERSON_DOMKEYS) {
|
|
122
|
+
const values = { ...(conditionValues ?? {}) };
|
|
123
|
+
const idArr = Array.isArray(resolvedIds) ? resolvedIds : [resolvedIds];
|
|
124
|
+
const idOf = new Map();
|
|
125
|
+
(personConditions ?? []).forEach((p, i) => {
|
|
126
|
+
if (p && idArr[i] != null && String(idArr[i]).trim())
|
|
127
|
+
idOf.set(p.value, String(idArr[i]));
|
|
128
|
+
});
|
|
129
|
+
for (const [k, v] of Object.entries(values))
|
|
130
|
+
if (browserDomkeys.has(k) && idOf.has(v))
|
|
131
|
+
values[k] = idOf.get(v);
|
|
132
|
+
return values;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 解析条件串:分号分段、每段取首个 = 为键值。 */
|
|
136
|
+
export function parseHrmConditionPairs(spec) {
|
|
137
|
+
const pairs = [];
|
|
138
|
+
for (const seg of String(spec ?? '').split(';')) {
|
|
139
|
+
const kv = seg.trim();
|
|
140
|
+
if (!kv)
|
|
141
|
+
continue;
|
|
142
|
+
const i = kv.indexOf('=');
|
|
143
|
+
if (i > 0)
|
|
144
|
+
pairs.push([kv.slice(0, i).trim(), kv.slice(i + 1).trim()]);
|
|
145
|
+
}
|
|
146
|
+
return pairs;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* ★越权防护 人员条件分类(纯函数,便于单测)。
|
|
151
|
+
* condItems:getHrmConditions 返回的扁平条件项 [{label, domkey, conditionType}]。
|
|
152
|
+
* rawPairs:[[键,值],...](键可能是 domkey 或中文 label)。
|
|
153
|
+
* 返回 [{label, domkey, value}]——命中按人白名单的键值对。
|
|
154
|
+
*/
|
|
155
|
+
export function classifyHrmPersonConditions(condItems, rawPairs) {
|
|
156
|
+
if (!Array.isArray(rawPairs) || rawPairs.length === 0)
|
|
157
|
+
return [];
|
|
158
|
+
const domToLabel = {};
|
|
159
|
+
for (const it of condItems || [])
|
|
160
|
+
if (it.label && it.domkey && !(it.domkey in domToLabel))
|
|
161
|
+
domToLabel[it.domkey] = it.label;
|
|
162
|
+
const labels = new Set((condItems || []).map((it) => it.label).filter(Boolean));
|
|
163
|
+
const personConditions = [];
|
|
164
|
+
for (const [k, v] of rawPairs) {
|
|
165
|
+
const key = String(k ?? '').trim();
|
|
166
|
+
const val = String(v ?? '').trim();
|
|
167
|
+
if (!key || !val)
|
|
168
|
+
continue;
|
|
169
|
+
// con_/dom_/field/col 前缀键剥前缀反查(dom_manager → manager → 直接上级),堵前缀直传绕过按人校验。
|
|
170
|
+
const prefix = /^(con|dom|field|col)[-_]?/i.exec(key);
|
|
171
|
+
const plain = prefix ? key.slice(prefix[0].length) : key;
|
|
172
|
+
const label = domToLabel[plain] ?? domToLabel[key] ?? (labels.has(key) ? key : (labels.has(plain) ? plain : null));
|
|
173
|
+
// 后缀启发式直接判按键名(即使条件定义缺失也能拦「报销人=王五」);
|
|
174
|
+
// domkey 依赖 domToLabel 反查;两者皆无则落到 unverifiablePerson 由调用方 fail-closed 拒绝。
|
|
175
|
+
if (isHrmPersonCondition(label ?? key))
|
|
176
|
+
personConditions.push({ label: label ?? key, domkey: key, value: val });
|
|
177
|
+
}
|
|
178
|
+
return personConditions;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* ★新 条件字符串 → conditionValues 映射。
|
|
183
|
+
*
|
|
184
|
+
* 输入 `--condition` 原始串,自动识别(E9 hrm 搜索接口原生认 plain domkey,如 manager=李四):
|
|
185
|
+
* - 键以 con_/dom_/field/col 开头 → 剥前缀后是已知 domkey 则用 plain domkey(dom_manager→manager),
|
|
186
|
+
* 否则原样透传(向后兼容,交 unverifiablePerson fail-closed 兜底);
|
|
187
|
+
* - plain domkey(在条件定义中)→ 原样直传(E9 原生形式);
|
|
188
|
+
* - 其它 → 中文 label,用 getHrmConditions 的 label→domkey 自动转换;
|
|
189
|
+
* 未匹配时返回 unresolved(含候选 label),由调用方报错提示,不静默放空。
|
|
190
|
+
* ★越权防护(1.1.x):只要传了条件就取一次条件定义——含 domkey 直传也要反查 label 分类。
|
|
191
|
+
* 返回 { conditionValues, unresolved:[{label,candidates}], unverifiablePerson, personConditions }。
|
|
192
|
+
*/
|
|
193
|
+
export async function resolveHrmCondition(cfg, { mouldid = 'default_3', condition = '' } = {}) {
|
|
194
|
+
const rawStr = String(condition ?? '');
|
|
195
|
+
const pairs = parseHrmConditionPairs(rawStr);
|
|
196
|
+
const conditionValues = {};
|
|
197
|
+
if (pairs.length === 0)
|
|
198
|
+
return { conditionValues, unresolved: [], unverifiablePerson: false, personConditions: [] };
|
|
199
|
+
let condItems = [];
|
|
200
|
+
let condFetched = false;
|
|
201
|
+
try {
|
|
202
|
+
const cond = await getHrmConditions(cfg, { mouldid });
|
|
203
|
+
condItems = cond.items;
|
|
204
|
+
condFetched = true;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// fail-closed:定义读不到时 domkey 无法反查 → 下方 unverifiablePerson 拒绝;中文 label 按名称判定不受影响
|
|
208
|
+
if (pairs.some(([k]) => /^(con|dom|field|col)[-_]?/i.test(String(k))))
|
|
209
|
+
process.stderr.write('⚠ 无法读取人事搜索条件定义:domkey 类条件将被拒绝(无法确认是否按人过滤),中文字段名条件按名称判定。\n');
|
|
210
|
+
}
|
|
211
|
+
const labelToDom = {};
|
|
212
|
+
for (const it of condItems)
|
|
213
|
+
if (it.label && it.domkey && !(it.label in labelToDom))
|
|
214
|
+
labelToDom[it.label] = it.domkey;
|
|
215
|
+
const knownDom = new Set(condItems.map((it) => it.domkey).filter(Boolean));
|
|
216
|
+
const unresolved = [];
|
|
217
|
+
for (const [k, v] of pairs) {
|
|
218
|
+
const key = String(k).trim();
|
|
219
|
+
if (!key)
|
|
220
|
+
continue;
|
|
221
|
+
const prefix = /^(con|dom|field|col)[-_]?/i.exec(key);
|
|
222
|
+
if (prefix) {
|
|
223
|
+
// 剥前缀后用 E9 认的 plain domkey(如 dom_manager→manager);未知前缀键原样透传
|
|
224
|
+
const plain = key.slice(prefix[0].length);
|
|
225
|
+
conditionValues[knownDom.has(plain) ? plain : key] = v;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (labelToDom[key] !== undefined) {
|
|
229
|
+
conditionValues[labelToDom[key]] = v;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (knownDom.has(key)) {
|
|
233
|
+
conditionValues[key] = v; // plain domkey 直传(E9 hrm 搜索接口原生认的形式)
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!condFetched) {
|
|
237
|
+
// 条件定义接口失败(网络抖动/无权限):按原样透传,不阻断(用户可能直接给了 domkey)——但已打越权警告
|
|
238
|
+
conditionValues[key] = v;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const candidates = Object.keys(labelToDom).filter((l) => l.includes(key) || key.includes(l)).slice(0, 8);
|
|
242
|
+
unresolved.push({ label: key, candidates });
|
|
243
|
+
}
|
|
244
|
+
// ★越权防护(fail-closed):无法确认是否按人过滤的条件 → 调用方硬拒绝。
|
|
245
|
+
// 前缀键剥前缀后仍不在条件定义中 / 定义读取失败 → 无法确认;中文 label 与已知 plain domkey 可经分类判定。
|
|
246
|
+
let unverifiablePerson = false;
|
|
247
|
+
for (const [k, v] of pairs) {
|
|
248
|
+
const key = String(k).trim();
|
|
249
|
+
if (!key || !String(v ?? '').trim())
|
|
250
|
+
continue;
|
|
251
|
+
const prefix = /^(con|dom|field|col)[-_]?/i.exec(key);
|
|
252
|
+
if (prefix) {
|
|
253
|
+
const plain = key.slice(prefix[0].length);
|
|
254
|
+
if (!knownDom.has(plain))
|
|
255
|
+
unverifiablePerson = true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const personConditions = classifyHrmPersonConditions(condItems, pairs);
|
|
259
|
+
// ★E9 实测校准(2026-09-20,e9pc 抓包):条件定义里 部门→department / 分部→subcompany 只是 UI 占位 domkey,
|
|
260
|
+
// 真实搜索接口认 departmentid / subcompanyid1(HAR 实证 subcompanyid1=80、departmentid=850)。
|
|
261
|
+
// 重映射这两个条件键为真实搜索参数;名称值由调用方 resolveHrmOrgValue 解析为 id。
|
|
262
|
+
for (const [src, dst] of Object.entries(HRM_ORG_DOMKEY_REMAP))
|
|
263
|
+
if (src in conditionValues && !(dst in conditionValues)) {
|
|
264
|
+
conditionValues[dst] = conditionValues[src];
|
|
265
|
+
delete conditionValues[src];
|
|
266
|
+
}
|
|
267
|
+
return { conditionValues, unresolved, unverifiablePerson, personConditions };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ─── 链路 3/4/5:搜索 → sessionkey → 表数据 → 总数 ───────
|
|
271
|
+
|
|
272
|
+
/** 发起人事搜索。POST /api/hrm/search/getHrmSearchResult,body 表单编码,返回 sessionkey(恒顶层)。 */
|
|
273
|
+
export async function hrmSearch(cfg, { tabkey = 'default_3', values = {}, showAllLevel = false } = {}) {
|
|
274
|
+
const api = await createApiFromProfile(cfg);
|
|
275
|
+
const body = {
|
|
276
|
+
tabkey: String(tabkey),
|
|
277
|
+
showAllLevel: showAllLevel ? '1' : '0',
|
|
278
|
+
virtualtype: '',
|
|
279
|
+
...(values ?? {}),
|
|
280
|
+
};
|
|
281
|
+
let r = await api.post('/api/hrm/search/getHrmSearchResult', body);
|
|
282
|
+
let sessionkey = extractDataKey(r?.json);
|
|
283
|
+
if (!sessionkey && cfg.cookie) {
|
|
284
|
+
// token no-op 逃生:200 但无 sessionkey 且配了 cookie → 强制 cookie 重试一次
|
|
285
|
+
r = await api.post('/api/hrm/search/getHrmSearchResult', body, { forceCookie: true });
|
|
286
|
+
sessionkey = extractDataKey(r?.json);
|
|
287
|
+
}
|
|
288
|
+
if (!sessionkey) {
|
|
289
|
+
throw new Error('未能从 getHrmSearchResult 响应中提取 sessionkey。\n' + `响应(前300字): ${truncate(r?.text)}`);
|
|
290
|
+
}
|
|
291
|
+
return { sessionkey, raw: r?.json ?? null };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 拉一页人事搜索结果。POST /api/ec/dev/table/datas。 */
|
|
295
|
+
export async function fetchHrmSearchTable(cfg, sessionkey, page) {
|
|
296
|
+
const api = await createApiFromProfile(cfg);
|
|
297
|
+
const r = await api.post('/api/ec/dev/table/datas', {
|
|
298
|
+
dataKey: sessionkey,
|
|
299
|
+
current: String(page),
|
|
300
|
+
sortParams: '[]',
|
|
301
|
+
});
|
|
302
|
+
const j = r?.json ?? {};
|
|
303
|
+
return { rows: extractRows(j), columns: extractColumns(j), raw: j };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 人事搜索总数。POST /api/ec/dev/table/counts。 */
|
|
307
|
+
export async function fetchHrmSearchCount(cfg, sessionkey) {
|
|
308
|
+
const api = await createApiFromProfile(cfg);
|
|
309
|
+
const r = await api.post('/api/ec/dev/table/counts', { dataKey: sessionkey });
|
|
310
|
+
return { total: extractTotal(r?.json), raw: r?.json ?? null };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── 行归一化 + 详情卡片链接 ─────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 找人事行 resourceId:randomField0/randomFieldId(抓包三值与 id 列同值;
|
|
317
|
+
* 但 id 列标题是「是否在线」,不作为主键,避免语义混用)。
|
|
318
|
+
*/
|
|
319
|
+
function pickHrmRowId(row) {
|
|
320
|
+
for (const key of ['randomField0', 'randomFieldId']) {
|
|
321
|
+
const raw = pickByPaths(row, [key]);
|
|
322
|
+
if (raw != null && String(raw).trim())
|
|
323
|
+
return stripHtml(raw);
|
|
324
|
+
}
|
|
325
|
+
return '';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 归一化一行人事档案:id(resourceId) + name + 全部字段(<field>span 优先去 HTML)。 */
|
|
329
|
+
export function normalizeHrmRow(row, columns) {
|
|
330
|
+
if (!row || typeof row !== 'object')
|
|
331
|
+
return {};
|
|
332
|
+
const get = (k) => pickByPaths(row, [k]);
|
|
333
|
+
const id = pickHrmRowId(row);
|
|
334
|
+
const name = stripHtml(get('lastnamespan') ?? get('lastname'));
|
|
335
|
+
const fields = (columns || []).map((c) => ({
|
|
336
|
+
dataIndex: c.dataIndex,
|
|
337
|
+
title: c.title ?? c.dataIndex,
|
|
338
|
+
value: stripHtml(get(`${c.dataIndex}span`) ?? get(c.dataIndex)),
|
|
339
|
+
})).filter((f) => f.value !== '');
|
|
340
|
+
return { id, name, fields, raw: row };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 详情卡片 URL(真机验证通过):{base}/spa/hrm/index_mobx.html#/main/hrm/card/cardInfo/<resourceId> */
|
|
344
|
+
export function buildHrmCardUrl(baseUrl, resourceId) {
|
|
345
|
+
if (resourceId == null || String(resourceId).trim() === '')
|
|
346
|
+
return '';
|
|
347
|
+
const b = String(baseUrl ?? '').replace(/\/+$/, '');
|
|
348
|
+
return `${b}/spa/hrm/index_mobx.html#/main/hrm/card/cardInfo/${String(resourceId).trim()}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── 组织树(--dept/--subcompany 按名定位) ───────────────
|
|
352
|
+
|
|
353
|
+
/** 组织树节点拉取。GET /api/hrm/base/getHrmSearchTree。
|
|
354
|
+
* param 说明(抓包实证):type=0&id=0 → 顶层分部(type 1);type=1&id=<分部> → 子分部+部门;type=2&id=<部门> → 子部门。 */
|
|
355
|
+
export async function getHrmSearchTree(cfg, opts = {}) {
|
|
356
|
+
const api = await createApiFromProfile(cfg);
|
|
357
|
+
const params = {
|
|
358
|
+
keyword: opts.keyword ?? '',
|
|
359
|
+
virtualCompanyid: opts.virtualCompanyid ?? '1',
|
|
360
|
+
isLoadSubDepartment: opts.isLoadSubDepartment ?? '1',
|
|
361
|
+
};
|
|
362
|
+
if (opts.type != null)
|
|
363
|
+
params.type = String(opts.type);
|
|
364
|
+
if (opts.id != null)
|
|
365
|
+
params.id = String(opts.id);
|
|
366
|
+
if (opts.isVirtual != null)
|
|
367
|
+
params.isVirtual = String(opts.isVirtual);
|
|
368
|
+
const r = await api.get('/api/hrm/base/getHrmSearchTree', params);
|
|
369
|
+
const j = r?.json ?? {};
|
|
370
|
+
const companys = (j.companys || []).map((c) => ({
|
|
371
|
+
id: String(c.companyid ?? ''),
|
|
372
|
+
name: c.name,
|
|
373
|
+
isVirtual: String(c.isVirtual ?? ''),
|
|
374
|
+
isParent: !!c.isParent,
|
|
375
|
+
}));
|
|
376
|
+
const datas = (j.datas || []).map((d) => ({
|
|
377
|
+
id: String(d.id ?? ''),
|
|
378
|
+
pid: String(d.pid ?? ''),
|
|
379
|
+
name: d.name,
|
|
380
|
+
isParent: !!d.isParent,
|
|
381
|
+
type: String(d.type ?? ''),
|
|
382
|
+
isVirtual: String(d.isVirtual ?? ''),
|
|
383
|
+
}));
|
|
384
|
+
return { companys, datas, raw: j };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 在组织树中按名定位节点(BFS,逐层拉取)。
|
|
389
|
+
* matchType:限定节点类型('1'=分部 / '2'=部门;null=任意)。
|
|
390
|
+
* 返回匹配节点 {id, name, type, pid} 或 null;maxDepth 防失控。
|
|
391
|
+
*/
|
|
392
|
+
export async function findHrmOrgNode(cfg, keyword, { matchType = null, maxDepth = 6 } = {}) {
|
|
393
|
+
const kw = String(keyword ?? '').trim();
|
|
394
|
+
if (!kw)
|
|
395
|
+
return null;
|
|
396
|
+
const top = await getHrmSearchTree(cfg, { type: '0', id: '0', isVirtual: '0' });
|
|
397
|
+
const queue = top.datas.map((d) => ({ ...d, depth: 0 }));
|
|
398
|
+
while (queue.length) {
|
|
399
|
+
const node = queue.shift();
|
|
400
|
+
if (node.name === kw && (!matchType || node.type === matchType))
|
|
401
|
+
return node;
|
|
402
|
+
if (!node.isParent || node.depth >= maxDepth)
|
|
403
|
+
continue;
|
|
404
|
+
try {
|
|
405
|
+
const children = await getHrmSearchTree(cfg, { type: node.type, id: node.id, isVirtual: node.isVirtual });
|
|
406
|
+
for (const c of children.datas)
|
|
407
|
+
queue.push({ ...c, depth: node.depth + 1 });
|
|
408
|
+
}
|
|
409
|
+
catch { /* 子树拉取失败跳过该分支(同名节点仍可能在后继队列命中) */ }
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ─── 编排:完整搜索 ──────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 人事搜索编排:tab + 条件 → sessionkey → 分页数据 + 总数。
|
|
418
|
+
* opts: { tabkey, values:{domkey:value}, showAllLevel, page }。
|
|
419
|
+
*/
|
|
420
|
+
export async function hrmList(cfg, opts = {}) {
|
|
421
|
+
const tabkey = opts.tabkey ?? 'default_3';
|
|
422
|
+
const page = Number(opts.page ?? '1') || 1;
|
|
423
|
+
const values = { ...(opts.values ?? {}) };
|
|
424
|
+
const { sessionkey, raw: searchRaw } = await hrmSearch(cfg, { tabkey, values, showAllLevel: !!opts.showAllLevel });
|
|
425
|
+
const data = await fetchHrmSearchTable(cfg, sessionkey, page);
|
|
426
|
+
const count = await fetchHrmSearchCount(cfg, sessionkey);
|
|
427
|
+
const total = count.total ?? null;
|
|
428
|
+
const items = data.rows.map((row) => normalizeHrmRow(row, data.columns));
|
|
429
|
+
return {
|
|
430
|
+
tabkey,
|
|
431
|
+
page,
|
|
432
|
+
pageSize: data.rows.length || null,
|
|
433
|
+
total,
|
|
434
|
+
count: items.length,
|
|
435
|
+
sessionkey,
|
|
436
|
+
columns: data.columns,
|
|
437
|
+
items,
|
|
438
|
+
raw: { search: searchRaw, datas: data.raw, counts: count.raw },
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ─── 工具 ────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
function truncate(s, n = 300) {
|
|
445
|
+
if (s == null)
|
|
446
|
+
return '(空)';
|
|
447
|
+
return String(s).length > n ? String(s).slice(0, n) + '…(已截断)' : String(s);
|
|
448
|
+
}
|
package/dist/parse.js
CHANGED
|
@@ -113,6 +113,20 @@ export function stripHtml(s) {
|
|
|
113
113
|
.replace(/'/g, "'");
|
|
114
114
|
return str.trim();
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* 从 requestnamespan 提取可见标题。
|
|
118
|
+
* OA 把标题放进第一个 `<a>`(模板前缀如「[退回]」也在 `<a>` 内),把「我的关注」等状态标签
|
|
119
|
+
* 以独立 `<div class='wf_attention_div_btn'>` 追加在 `<a>` 之后——整体去标签会把标签文字并进标题
|
|
120
|
+
* (实测「我的关注」页签的标题被渲染成「采购审批4-... 我的关注」)。
|
|
121
|
+
* 故优先只取第一个 `<a>` 的文本;无 `<a>` 时回退整体去标签。
|
|
122
|
+
*/
|
|
123
|
+
function extractTitleFromNamespan(html) {
|
|
124
|
+
const s = String(html ?? '');
|
|
125
|
+
if (!s)
|
|
126
|
+
return '';
|
|
127
|
+
const m = s.match(/<a\b[^>]*>([\s\S]*?)<\/a>/i);
|
|
128
|
+
return stripHtml(m ? m[1] : s);
|
|
129
|
+
}
|
|
116
130
|
const LEVEL_LABEL = {
|
|
117
131
|
'0': '一般',
|
|
118
132
|
'1': '重要',
|
|
@@ -131,7 +145,7 @@ export function normalizeRow(row, columns) {
|
|
|
131
145
|
const spanOr = (dataIndex, extraKeys = []) => stripHtml(get([`${dataIndex}span`, dataIndex, ...extraKeys]));
|
|
132
146
|
// requestnamespan 是 OA 已按管理员模板渲染的可见标题;原始 requestname 仅作回退。
|
|
133
147
|
const rawTitle = stringify(get(['requestname', 'requestName']));
|
|
134
|
-
const title =
|
|
148
|
+
const title = extractTitleFromNamespan(get(['requestnamespan'])) || rawTitle;
|
|
135
149
|
const levelRaw = stringify(get(['requestlevel', 'level'])) ?? '';
|
|
136
150
|
const level = levelRaw ? (LEVEL_LABEL[levelRaw] ?? levelRaw) : undefined;
|
|
137
151
|
const creatorIdRaw = get(['createrid', 'creatorid', 'creatorId', 'creater']);
|