weaver-e9-assistant-cli 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/dist/args.js +36 -0
- package/dist/auth-token.js +690 -0
- package/dist/auth.js +187 -0
- package/dist/blog.js +469 -0
- package/dist/cli.js +4041 -0
- package/dist/client.js +199 -0
- package/dist/config.js +124 -0
- package/dist/createcontext.js +41 -0
- package/dist/crypto.js +139 -0
- package/dist/cube.js +1633 -0
- package/dist/email.js +838 -0
- package/dist/fileutil.js +20 -0
- package/dist/kq.js +204 -0
- package/dist/links.js +34 -0
- package/dist/odoc.js +194 -0
- package/dist/pageopen.js +29 -0
- package/dist/parse.js +159 -0
- package/dist/querystate.js +38 -0
- package/dist/render.js +21 -0
- package/dist/report.js +50 -0
- package/dist/submissionplan.js +43 -0
- package/dist/submissionstate.js +81 -0
- package/dist/todoaskidea.js +54 -0
- package/dist/todobatch.js +154 -0
- package/dist/todocc.js +128 -0
- package/dist/todoforward.js +66 -0
- package/dist/todoreject.js +215 -0
- package/dist/todosubmit.js +277 -0
- package/dist/todotransfer.js +54 -0
- package/dist/util.js +25 -0
- package/dist/wfcreate.js +102 -0
- package/dist/wfform.js +194 -0
- package/dist/wfops.js +40 -0
- package/dist/wfreceiver.js +43 -0
- package/dist/wfstart.js +717 -0
- package/dist/wfsummary.js +89 -0
- package/dist/wfupload.js +78 -0
- package/dist/wfuser.js +60 -0
- package/dist/workflow.js +461 -0
- package/dist/workplan.js +394 -0
- package/package.json +25 -0
package/dist/cube.js
ADDED
|
@@ -0,0 +1,1633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E9 建模中心(Cube)自定义列表/台账模块。
|
|
3
|
+
*
|
|
4
|
+
* 原有能力(0.4.0-7):getCubeInit / getCubeCondition / listCube / exportCubeExcel
|
|
5
|
+
* 本次拓展(建模优化):getSearchColumnInfo / listCubeExcel / getCubeCellColor /
|
|
6
|
+
* getCubeCountData / getCubeGroupData / getExpCardConfig / getModeBrowserOptions
|
|
7
|
+
* (来源:台账基础操作.har + 新建.har 逆向)
|
|
8
|
+
*
|
|
9
|
+
* 实跑确认的接口(测试环境,customid=1001 董事会议题-未开):
|
|
10
|
+
* GET /api/cube/search/init 列表初始化
|
|
11
|
+
* GET /api/cube/search/condition 查询条件定义
|
|
12
|
+
* GET /api/cube/search/getSearchColumnInfo 字段下拉选项全表(★新)
|
|
13
|
+
* POST /api/cube/search/getList 列表入口 → dataKey(响应 datas 字段)
|
|
14
|
+
* POST /api/cube/search/getCountData 聚合统计(★新)
|
|
15
|
+
* POST /api/cube/search/getGroupData 分组统计(★新)
|
|
16
|
+
* GET /api/cube/excelsearch/columns Excel 视图列定义(★新)
|
|
17
|
+
* POST /api/cube/excelsearch/getDataAndTime Excel 视图数据+时间(★新)
|
|
18
|
+
* POST /api/cube/excelsearch/columnWidth 列宽(UI 状态,CLI 不接)
|
|
19
|
+
* POST /api/cube/excelsearch/getCellColor 单元格颜色(★新)
|
|
20
|
+
* POST /api/cube/mode/mode/doExpCardConfig 卡片导出配置(★新)
|
|
21
|
+
* POST /api/cube/search/doExcelExpost 发起 Excel 导出 → key
|
|
22
|
+
* GET /api/cube/search/getExcelExpProgress 轮询导出进度
|
|
23
|
+
* GET /weaver/weaver.formmode.data.FileDownload 下载文件流
|
|
24
|
+
* GET /api/public/browser/data/<dt>?isFromMode=1&modeId=&fieldid= 建模浏览框选项(★新,新建记录选人/部门用)
|
|
25
|
+
*
|
|
26
|
+
* ID 映射(init 返回):customid ↔ modeId ↔ formId ↔ appid。
|
|
27
|
+
* 注意:getList 的 menuIds/menuPathIds 在 displayType=excel 模式下需要(HAR 用 3,-17,随台账菜单而定),
|
|
28
|
+
* 普通 list 模式可不传;Excel 视图函数的 menuIds 由调用方通过 opts.menuIds 传入(可从前台菜单接口取)。
|
|
29
|
+
*/
|
|
30
|
+
import { createApiFromProfile, e9Request } from './client.js';
|
|
31
|
+
import { endpointFor, profileNameFromBaseUrl } from './config.js';
|
|
32
|
+
import { pickByPaths, stripHtml, extractColumns, extractRows, extractTotal } from './parse.js';
|
|
33
|
+
import { sleep } from './util.js';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import os from 'node:os';
|
|
37
|
+
import { inflateRawSync } from 'node:zlib';
|
|
38
|
+
import { assertExcelLike, nextAvailablePath } from './fileutil.js';
|
|
39
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
40
|
+
/**
|
|
41
|
+
* 列表初始化。GET /api/cube/search/init?customid=<id>&guid=search。
|
|
42
|
+
* 返回 {customId, modeId, formId, pageSize, name, appid, raw}。
|
|
43
|
+
*/
|
|
44
|
+
export async function getCubeInit(cfg, opts = {}) {
|
|
45
|
+
const api = await createApiFromProfile(cfg);
|
|
46
|
+
const r = await api.get('/api/cube/search/init', { customid: String(opts.customid), guid: 'search' });
|
|
47
|
+
const j = r.json || {};
|
|
48
|
+
return {
|
|
49
|
+
customId: stringify(j.customId) ?? String(opts.customid),
|
|
50
|
+
modeId: stringify(j.modeId),
|
|
51
|
+
formId: stringify(j.formId),
|
|
52
|
+
pageSize: typeof j.pageSize === 'number' ? j.pageSize : Number(j.pageSize ?? 10) || 10,
|
|
53
|
+
name: stringify(pickByPaths(j, ['top.name', 'name', 'windowTitle'])),
|
|
54
|
+
appid: stringify(j.appid),
|
|
55
|
+
raw: j,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 查询条件定义。GET /api/cube/search/condition?customid=<id>&guid=search。
|
|
60
|
+
* 返回 items:[{label, domkey, type, isQuickSearch, options:[{key,showname}]}]。
|
|
61
|
+
*/
|
|
62
|
+
export async function getCubeCondition(cfg, opts = {}) {
|
|
63
|
+
const api = await createApiFromProfile(cfg);
|
|
64
|
+
const r = await api.get('/api/cube/search/condition', { customid: String(opts.customid), guid: 'search' });
|
|
65
|
+
const j = r.json || {};
|
|
66
|
+
const groups = pickByPaths(j, ['conditionInfo', 'data.conditionInfo']) ?? [];
|
|
67
|
+
const items = (Array.isArray(groups) ? groups : [])
|
|
68
|
+
.flatMap((g) => Array.isArray(g?.items) ? g.items : [])
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.map((it) => ({
|
|
71
|
+
label: stringify(it.label),
|
|
72
|
+
domkey: Array.isArray(it.domkey) ? it.domkey.map(stringify).filter(Boolean) : (stringify(it.domkey) ? [stringify(it.domkey)] : []),
|
|
73
|
+
type: stringify(it.conditionType) ?? stringify(it.type),
|
|
74
|
+
isQuickSearch: !!pickByPaths(it, ['isQuickSearch', 'otherParams.quickSearch']),
|
|
75
|
+
options: (Array.isArray(it.options) ? it.options : [])
|
|
76
|
+
.map((o) => ({ key: stringify(o.key), showname: stringify(o.showname) }))
|
|
77
|
+
.filter((o) => o.showname || o.key),
|
|
78
|
+
}))
|
|
79
|
+
.filter((it) => it.label);
|
|
80
|
+
return { items, raw: j };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* ★新 条件字符串 → conditionValues 映射(P2-3,0.4.8)。
|
|
84
|
+
*
|
|
85
|
+
* 输入 `--condition` 原始串(`k=v;...` 分号分隔),自动识别:
|
|
86
|
+
* - 键以 `con_`/`dom_`/`field`/`col` 开头 → 视为合法 domkey,原样透传(向后兼容)。
|
|
87
|
+
* - 其它 → 视为中文 label,用 getCubeCondition 的 label→domkey 映射自动转换;
|
|
88
|
+
* 未匹配时返回 unresolved(含候选 label 列表),由调用方报错提示,不静默放空。
|
|
89
|
+
* 返回 { conditionValues, unresolved:[{label, candidates:[...]}] }。
|
|
90
|
+
*/
|
|
91
|
+
export async function resolveCubeCondition(cfg, opts = {}) {
|
|
92
|
+
const customid = String(opts.customid);
|
|
93
|
+
const rawStr = opts.condition ?? '';
|
|
94
|
+
const conditionValues = {};
|
|
95
|
+
const pairs = [];
|
|
96
|
+
if (rawStr)
|
|
97
|
+
for (const kv of String(rawStr).split(';')) {
|
|
98
|
+
const i = kv.indexOf('=');
|
|
99
|
+
if (i > 0)
|
|
100
|
+
pairs.push([kv.slice(0, i).trim(), kv.slice(i + 1).trim()]);
|
|
101
|
+
}
|
|
102
|
+
if (pairs.length === 0)
|
|
103
|
+
return { conditionValues, unresolved: [] };
|
|
104
|
+
// 判断是否含中文 label 键
|
|
105
|
+
const needsLabelMap = pairs.some(([k]) => !/^(con|dom|field|col)[-_]?/i.test(k));
|
|
106
|
+
let labelToDom = {};
|
|
107
|
+
let mappingFailed = false;
|
|
108
|
+
if (needsLabelMap) {
|
|
109
|
+
try {
|
|
110
|
+
const cond = await getCubeCondition(cfg, { customid });
|
|
111
|
+
for (const it of cond.items)
|
|
112
|
+
for (const dk of (it.domkey ?? []))
|
|
113
|
+
if (it.label && !(it.label in labelToDom))
|
|
114
|
+
labelToDom[it.label] = dk;
|
|
115
|
+
}
|
|
116
|
+
catch { mappingFailed = true; }
|
|
117
|
+
}
|
|
118
|
+
const unresolved = [];
|
|
119
|
+
for (const [k, v] of pairs) {
|
|
120
|
+
if (/^(con|dom|field|col)[-_]?/i.test(k)) {
|
|
121
|
+
conditionValues[k] = v;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const domkey = labelToDom[k];
|
|
125
|
+
if (domkey) {
|
|
126
|
+
conditionValues[domkey] = v;
|
|
127
|
+
}
|
|
128
|
+
else if (mappingFailed) {
|
|
129
|
+
// 映射接口本身失败(网络抖动/无权限):按原样透传,不阻断(用户可能直接给了 domkey)
|
|
130
|
+
conditionValues[k] = v;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// 映射成功但该 label 无对应 domkey:收集候选 label(包含关系),由调用方报错提示
|
|
134
|
+
const candidates = Object.keys(labelToDom).filter((l) => l.includes(k) || k.includes(l)).slice(0, 8);
|
|
135
|
+
unresolved.push({ label: k, candidates });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { conditionValues, unresolved };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* ★新 字段下拉选项全表。GET /api/cube/search/getSearchColumnInfo?customid=<id>。
|
|
142
|
+
* 返回 optionsMap:{ "<字段id>": [{key, showname}] }。
|
|
143
|
+
* 比 getCubeCondition 更全——condition 只给列表上方筛选项,本接口给台账所有字段(含状态/类型等)的下拉可选值。
|
|
144
|
+
* 实跑 1001:7974=状态 → 未开0/待开1/已开2/已结3…
|
|
145
|
+
*/
|
|
146
|
+
export async function getSearchColumnInfo(cfg, opts = {}) {
|
|
147
|
+
const api = await createApiFromProfile(cfg);
|
|
148
|
+
const r = await api.get('/api/cube/search/getSearchColumnInfo', { customid: String(opts.customid) });
|
|
149
|
+
const j = r.json || {};
|
|
150
|
+
const om = pickByPaths(j, ['optionsMap', 'data.optionsMap']) ?? {};
|
|
151
|
+
const optionsMap = {};
|
|
152
|
+
for (const [fid, list] of Object.entries(om)) {
|
|
153
|
+
if (!Array.isArray(list))
|
|
154
|
+
continue;
|
|
155
|
+
const opts2 = list
|
|
156
|
+
.map((o) => ({ key: stringify(o.key), showname: stringify(o.showname) }))
|
|
157
|
+
.filter((o) => o.showname != null || o.key != null);
|
|
158
|
+
if (opts2.length)
|
|
159
|
+
optionsMap[fid] = opts2;
|
|
160
|
+
}
|
|
161
|
+
return { limitValue: pickByPaths(j, ['limitValue', 'data.limitValue']), optionsMap, raw: j };
|
|
162
|
+
}
|
|
163
|
+
/** 从 getList 响应提取 dataKey(`datas` 字段为 32 位 hex 字符串)。 */
|
|
164
|
+
function extractCubeDataKey(resp) {
|
|
165
|
+
const direct = pickByPaths(resp, ['datas', 'data.datas', 'dataKey', 'sessionkey', 'sessionKey']);
|
|
166
|
+
if (typeof direct === 'string' && /^[0-9a-fA-F]{16,}$/.test(direct.trim()))
|
|
167
|
+
return direct.trim();
|
|
168
|
+
const scanned = scanHex(resp);
|
|
169
|
+
return scanned;
|
|
170
|
+
}
|
|
171
|
+
function scanHex(obj) {
|
|
172
|
+
if (obj == null)
|
|
173
|
+
return null;
|
|
174
|
+
if (typeof obj === 'string') {
|
|
175
|
+
const t = obj.trim();
|
|
176
|
+
if (/^[0-9a-fA-F]{16,}$/.test(t))
|
|
177
|
+
return t;
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (Array.isArray(obj)) {
|
|
181
|
+
for (const it of obj) {
|
|
182
|
+
const r = scanHex(it);
|
|
183
|
+
if (r)
|
|
184
|
+
return r;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (typeof obj === 'object') {
|
|
189
|
+
for (const v of Object.values(obj)) {
|
|
190
|
+
const r = scanHex(v);
|
|
191
|
+
if (r)
|
|
192
|
+
return r;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* 建模列表(普通视图)。链路:init → getList(取 dataKey) → table/datas(列+行) → table/counts(总数)。
|
|
199
|
+
* 行规范化:按 columns 的 title 输出可读值(优先 `<field>span` 去 HTML,其次原值)。
|
|
200
|
+
*/
|
|
201
|
+
export async function listCube(cfg, opts = {}) {
|
|
202
|
+
const api = await createApiFromProfile(cfg);
|
|
203
|
+
const customid = String(opts.customid);
|
|
204
|
+
const init = await getCubeInit(cfg, { customid });
|
|
205
|
+
const modeId = opts.modeId ?? init.modeId ?? '';
|
|
206
|
+
const formId = opts.formId ?? init.formId ?? '';
|
|
207
|
+
const pageSize = opts.pageSize ?? init.pageSize ?? 10;
|
|
208
|
+
const page = opts.page ?? 1;
|
|
209
|
+
const listBody = {
|
|
210
|
+
customid,
|
|
211
|
+
guid: 'search',
|
|
212
|
+
modeId,
|
|
213
|
+
formId,
|
|
214
|
+
current: String(page),
|
|
215
|
+
pageSize: String(pageSize),
|
|
216
|
+
isotype: '0',
|
|
217
|
+
displayType: 'list',
|
|
218
|
+
type: 'list',
|
|
219
|
+
};
|
|
220
|
+
if (opts.conditionValues && typeof opts.conditionValues === 'object') {
|
|
221
|
+
for (const [k, v] of Object.entries(opts.conditionValues))
|
|
222
|
+
listBody[k] = String(v);
|
|
223
|
+
}
|
|
224
|
+
if (opts.keyword)
|
|
225
|
+
listBody.quickSearchValue = opts.keyword;
|
|
226
|
+
const r1 = await api.post('/api/cube/search/getList', listBody);
|
|
227
|
+
const dataKey = extractCubeDataKey(r1.json);
|
|
228
|
+
if (!dataKey)
|
|
229
|
+
throw new Error(`cube list 未拿到 dataKey,getList 响应: ${(r1.text ?? '').slice(0, 200)}`);
|
|
230
|
+
const r2 = await api.post('/api/ec/dev/table/datas', {
|
|
231
|
+
dataKey,
|
|
232
|
+
current: String(page),
|
|
233
|
+
pageSize: String(pageSize),
|
|
234
|
+
sortParams: '[]',
|
|
235
|
+
});
|
|
236
|
+
const columns = extractColumns(r2.json);
|
|
237
|
+
let rows = extractRows(r2.json);
|
|
238
|
+
if (opts.keyword && rows.length) {
|
|
239
|
+
const kw = String(opts.keyword).toLowerCase();
|
|
240
|
+
rows = rows.filter((row) => JSON.stringify(row).toLowerCase().includes(kw));
|
|
241
|
+
}
|
|
242
|
+
const items = rows.map((row) => normalizeCubeRow(row, columns)).filter((it) => it.id || it.fields.length);
|
|
243
|
+
let total = null;
|
|
244
|
+
let countsResp = null;
|
|
245
|
+
try {
|
|
246
|
+
const r3 = await api.post('/api/ec/dev/table/counts', { dataKey });
|
|
247
|
+
countsResp = r3.json;
|
|
248
|
+
total = extractTotal(r3.json);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// counts 失败不阻断
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
customid,
|
|
255
|
+
name: init.name,
|
|
256
|
+
modeId,
|
|
257
|
+
formId,
|
|
258
|
+
dataKey,
|
|
259
|
+
page,
|
|
260
|
+
pageSize,
|
|
261
|
+
total,
|
|
262
|
+
count: items.length,
|
|
263
|
+
columns,
|
|
264
|
+
items,
|
|
265
|
+
raw: { init: init.raw, getList: r1.json, datas: r2.json, counts: countsResp },
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* ★新 Excel 视图取数。链路:getList(displayType=excel 取 dataKey) → excelsearch/columns(列定义含 editable) →
|
|
270
|
+
* excelsearch/getDataAndTime(数据+时间)。
|
|
271
|
+
* 比 listCube 多:列的 editable(可编辑标记)/htmltype/dbtype,用于判断哪些字段可在台账里直接编辑。
|
|
272
|
+
* menuIds:Excel 模式需要(HAR 用 3,-17,随台账菜单而定),由调用方传入。
|
|
273
|
+
*/
|
|
274
|
+
export async function listCubeExcel(cfg, opts = {}) {
|
|
275
|
+
const api = await createApiFromProfile(cfg);
|
|
276
|
+
const customid = String(opts.customid);
|
|
277
|
+
const init = await getCubeInit(cfg, { customid });
|
|
278
|
+
const modeId = opts.modeId ?? init.modeId ?? '';
|
|
279
|
+
const menuIds = opts.menuIds ?? '';
|
|
280
|
+
const menuPathIds = opts.menuPathIds ?? menuIds;
|
|
281
|
+
const page = opts.page ?? 1;
|
|
282
|
+
const listBody = {
|
|
283
|
+
customid,
|
|
284
|
+
menuIds,
|
|
285
|
+
menuPathIds,
|
|
286
|
+
guid: 'search',
|
|
287
|
+
displayType: 'excel',
|
|
288
|
+
groupValue: 'all',
|
|
289
|
+
isTempSearch: '',
|
|
290
|
+
windowWidth: '1912',
|
|
291
|
+
isTreeType: '',
|
|
292
|
+
};
|
|
293
|
+
const r1 = await api.post('/api/cube/search/getList', listBody);
|
|
294
|
+
const dataKey = extractCubeDataKey(r1.json);
|
|
295
|
+
if (!dataKey)
|
|
296
|
+
throw new Error(`cube excel list 未拿到 dataKey,getList 响应: ${(r1.text ?? '').slice(0, 200)}`);
|
|
297
|
+
// Excel 列定义(含 editable/htmltype/dbtype/column/text)
|
|
298
|
+
const rCols = await api.get('/api/cube/excelsearch/columns', {
|
|
299
|
+
customid, menuIds, menuPathIds, guid: 'search', displayType: 'excel',
|
|
300
|
+
});
|
|
301
|
+
const jCols = rCols.json || {};
|
|
302
|
+
const resultList = Array.isArray(jCols.resultList) ? jCols.resultList : [];
|
|
303
|
+
const columns = resultList.map((c) => ({
|
|
304
|
+
dataIndex: stringify(c.column) ?? stringify(c.id),
|
|
305
|
+
title: stringify(c.text),
|
|
306
|
+
htmltype: stringify(c.htmltype),
|
|
307
|
+
dbtype: stringify(c.dbtype),
|
|
308
|
+
editable: stringify(c.editable) === '1',
|
|
309
|
+
width: c.width,
|
|
310
|
+
}));
|
|
311
|
+
// Excel 数据行
|
|
312
|
+
const rData = await api.post('/api/cube/excelsearch/getDataAndTime', {
|
|
313
|
+
dataKey,
|
|
314
|
+
current: String(page),
|
|
315
|
+
sortParams: '',
|
|
316
|
+
customid, menuIds, menuPathIds, guid: 'search', displayType: 'excel',
|
|
317
|
+
});
|
|
318
|
+
const jData = rData.json?.data ?? rData.json ?? {};
|
|
319
|
+
const dataColumns = Array.isArray(jData.columns) ? jData.columns : [];
|
|
320
|
+
const rows = Array.isArray(jData.datas) ? jData.datas : [];
|
|
321
|
+
const items = rows.map((row) => normalizeCubeRow(row, dataColumns)).filter((it) => it.id || it.fields.length);
|
|
322
|
+
let total = null;
|
|
323
|
+
try {
|
|
324
|
+
const r3 = await api.post('/api/ec/dev/table/counts', { dataKey });
|
|
325
|
+
total = extractTotal(r3.json);
|
|
326
|
+
}
|
|
327
|
+
catch { /* 失败不阻断 */ }
|
|
328
|
+
return {
|
|
329
|
+
customid,
|
|
330
|
+
name: init.name,
|
|
331
|
+
modeId,
|
|
332
|
+
dataKey,
|
|
333
|
+
page,
|
|
334
|
+
total,
|
|
335
|
+
count: items.length,
|
|
336
|
+
columns, // Excel 列(含 editable 可编辑标记)
|
|
337
|
+
items,
|
|
338
|
+
time: stringify(pickByPaths(rData.json, ['time', 'data.time'])),
|
|
339
|
+
raw: { getList: r1.json, columns: jCols, data: jData },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* ★新 单元格颜色。POST /api/cube/excelsearch/getCellColor。
|
|
344
|
+
* 取指定记录的单元格颜色(台账按状态标记行/单元格颜色,如未开=红、已开=绿)。
|
|
345
|
+
* randomIds: [{randomFieldId:<记录id>, randomFieldSubKeyd_id:'1'}]
|
|
346
|
+
*/
|
|
347
|
+
export async function getCubeCellColor(cfg, opts = {}) {
|
|
348
|
+
const api = await createApiFromProfile(cfg);
|
|
349
|
+
const customid = String(opts.customid);
|
|
350
|
+
const menuIds = opts.menuIds ?? '';
|
|
351
|
+
const menuPathIds = opts.menuPathIds ?? menuIds;
|
|
352
|
+
const recordIds = (opts.recordIds ?? []).map((id) => ({ randomFieldId: String(id), randomFieldSubKeyd_id: '1' }));
|
|
353
|
+
const r = await api.post('/api/cube/excelsearch/getCellColor', {
|
|
354
|
+
customid, menuIds, menuPathIds, guid: 'search', displayType: 'excel',
|
|
355
|
+
randomIds: JSON.stringify(recordIds),
|
|
356
|
+
});
|
|
357
|
+
const j = r.json || {};
|
|
358
|
+
return { colors: j.color ?? j.data?.color ?? [], time: stringify(j.time), raw: j };
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* ★新 聚合统计。POST /api/cube/search/getCountData。
|
|
362
|
+
* 返回 countData(按配置的聚合维度统计,如按状态/类型计数)。
|
|
363
|
+
*/
|
|
364
|
+
export async function getCubeCountData(cfg, opts = {}) {
|
|
365
|
+
const api = await createApiFromProfile(cfg);
|
|
366
|
+
const customid = String(opts.customid);
|
|
367
|
+
const menuIds = opts.menuIds ?? '';
|
|
368
|
+
const menuPathIds = opts.menuPathIds ?? menuIds;
|
|
369
|
+
const r = await api.post('/api/cube/search/getCountData', {
|
|
370
|
+
customid, menuIds, menuPathIds, guid: 'search', displayType: 'excel',
|
|
371
|
+
groupValue: opts.groupValue ?? 'all',
|
|
372
|
+
isTempSearch: '', windowWidth: '1912', isTreeType: '',
|
|
373
|
+
...(opts.isfromTab != null ? { isfromTab: String(opts.isfromTab) } : {}),
|
|
374
|
+
...(opts.isfromRight != null ? { isfromRight: String(opts.isfromRight) } : {}),
|
|
375
|
+
});
|
|
376
|
+
const j = r.json || {};
|
|
377
|
+
return { countData: j.countData ?? j.data?.countData ?? [], raw: j };
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* ★新 分组统计。POST /api/cube/search/getGroupData。
|
|
381
|
+
* 返回分组数据(按指定字段分组计数,用于台账分组视图)。
|
|
382
|
+
*/
|
|
383
|
+
export async function getCubeGroupData(cfg, opts = {}) {
|
|
384
|
+
const api = await createApiFromProfile(cfg);
|
|
385
|
+
const customid = String(opts.customid);
|
|
386
|
+
const menuIds = opts.menuIds ?? '';
|
|
387
|
+
const menuPathIds = opts.menuPathIds ?? menuIds;
|
|
388
|
+
const r = await api.post('/api/cube/search/getGroupData', {
|
|
389
|
+
customid, menuIds, menuPathIds, guid: 'search', displayType: 'excel',
|
|
390
|
+
groupValue: opts.groupValue ?? 'all',
|
|
391
|
+
isTempSearch: '', windowWidth: '1912', isTreeType: '',
|
|
392
|
+
});
|
|
393
|
+
const j = r.json || {};
|
|
394
|
+
return { groupData: j.data ?? j.datas ?? j, raw: j };
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* ★新 卡片导出配置。POST /api/cube/mode/mode/doExpCardConfig(action=getBacthExpList)。
|
|
398
|
+
* 返回可导出的格式(EXCEL列表文件等)、导出卡片名、是否可导出。
|
|
399
|
+
* 用于台账「导出单条卡片/批量卡片」前的配置探查。
|
|
400
|
+
*/
|
|
401
|
+
export async function getExpCardConfig(cfg, opts = {}) {
|
|
402
|
+
const api = await createApiFromProfile(cfg);
|
|
403
|
+
const customid = String(opts.customid);
|
|
404
|
+
const modeid = String(opts.modeId ?? opts.modeid ?? '');
|
|
405
|
+
const r = await api.post('/api/cube/mode/mode/doExpCardConfig', {
|
|
406
|
+
action: 'getBacthExpList',
|
|
407
|
+
customid,
|
|
408
|
+
modeid,
|
|
409
|
+
});
|
|
410
|
+
const j = r.json || {};
|
|
411
|
+
return {
|
|
412
|
+
isConfig: j.isConfig,
|
|
413
|
+
canExp: pickByPaths(j, ['canExpObj.canExp', 'canExp']),
|
|
414
|
+
batchExpList: j.BacthExplist ?? j.batchExplist ?? [],
|
|
415
|
+
expCardName: pickByPaths(j, ['expcardname.showname', 'expcardname']),
|
|
416
|
+
fields: j.feilds ?? j.fields ?? [],
|
|
417
|
+
raw: j,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* cube 树浏览框(detailtype=256)取数:递归展开树,收集所有 canClick 节点。
|
|
422
|
+
* 流程域(wfform getBrowserOptions) 与 建模域(getModeBrowserOptions) 共用本函数,
|
|
423
|
+
* 仅 base 里带的域参数不同(流程 fromModule=workflow/wfid/billid/requestid;建模 isFromMode=1/modeId)。
|
|
424
|
+
*
|
|
425
|
+
* 接口 GET /api/public/browser/data/256:
|
|
426
|
+
* 根:treeid+cube_treeid+treetype=256+checkStrictly=false + 域参数 + pageSize/current/min/max
|
|
427
|
+
* → datas:[{id,name,isParent,type,canClick}],根节点 pid="0_0"
|
|
428
|
+
* 子:追加 type=2&id=<父节点id>&level=<层>&alllevel=1 → 同结构;靠 isParent 判断是否继续展开。
|
|
429
|
+
* treeid = cube_treeid = 字段 fielddbtype;treetype = 256。
|
|
430
|
+
* 返回 [{id, name, path}](path 含祖先名,便于重名消歧)。
|
|
431
|
+
*/
|
|
432
|
+
export async function fetchCubeTreeBrowserOptions(api, base, opts = {}) {
|
|
433
|
+
const maxNodes = Number(opts.maxNodes ?? 300);
|
|
434
|
+
const maxDepth = Number(opts.maxDepth ?? 8);
|
|
435
|
+
const pageSize = String(opts.pageSize ?? 20);
|
|
436
|
+
const endpoint = '/api/public/browser/data/256';
|
|
437
|
+
const out = [];
|
|
438
|
+
const r0 = await api.get(endpoint, {
|
|
439
|
+
...base, pageSize, current: '1', min: '1', max: pageSize,
|
|
440
|
+
alllevel: '1', virtualtype: '1', status: '8', cmd: opts.cmd ?? 'newly',
|
|
441
|
+
__random__: String(Date.now()),
|
|
442
|
+
});
|
|
443
|
+
const roots = Array.isArray(r0.json?.datas) ? r0.json.datas : [];
|
|
444
|
+
async function walk(nodes, parentPath, level) {
|
|
445
|
+
for (const n of nodes) {
|
|
446
|
+
if (out.length >= maxNodes)
|
|
447
|
+
return;
|
|
448
|
+
const nm = stripHtml(String(n.name ?? n.title ?? n.id ?? ''));
|
|
449
|
+
const path = parentPath ? `${parentPath} / ${nm}` : nm;
|
|
450
|
+
if ((n.canClick === true || String(n.canClick) === 'true') && n.id != null) {
|
|
451
|
+
out.push({ id: String(n.id), name: nm || String(n.id), path });
|
|
452
|
+
}
|
|
453
|
+
if ((n.isParent === true || String(n.isParent) === 'true') && level < maxDepth) {
|
|
454
|
+
try {
|
|
455
|
+
const rc = await api.get(endpoint, {
|
|
456
|
+
...base, alllevel: '1', type: '2', id: String(n.id),
|
|
457
|
+
isVirtual: '', level: String(level), __random__: String(Date.now()),
|
|
458
|
+
});
|
|
459
|
+
const kids = Array.isArray(rc.json?.datas) ? rc.json.datas : [];
|
|
460
|
+
if (kids.length)
|
|
461
|
+
await walk(kids, path, level + 1);
|
|
462
|
+
}
|
|
463
|
+
catch { /* 单节点展开失败不阻断 */ }
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
await walk(roots, '', 0);
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* ★新 建模浏览框选项。GET /api/public/browser/data/<detailtype>?isFromMode=1&modeId=&fieldid=...
|
|
472
|
+
* 新建台账记录时,选人员/部门/流程等浏览框字段的候选项。
|
|
473
|
+
* 与流程域 wfform.js getBrowserOptions 区别:建模用 isFromMode=1&modeId=&fieldid=(非 wfid/billid/fromModule=workflow)。
|
|
474
|
+
* detailtype:1=人员 17=多选人员 167=部门(树形,type=3)… 来自台账字段定义的 detailtype。
|
|
475
|
+
* detailtype=256(cube 树浏览框):走 fetchCubeTreeBrowserOptions 递归取树,treeid=fielddbtype。
|
|
476
|
+
*/
|
|
477
|
+
export async function getModeBrowserOptions(cfg, opts = {}) {
|
|
478
|
+
const api = await createApiFromProfile(cfg);
|
|
479
|
+
const detailtype = String(opts.detailtype);
|
|
480
|
+
// ★ cube 树浏览框(256):走递归取树,不走普通分页;treeid = fielddbtype
|
|
481
|
+
if (detailtype === '256') {
|
|
482
|
+
const treeid = String(opts.fielddbtype ?? '');
|
|
483
|
+
if (!treeid)
|
|
484
|
+
throw new Error('detailtype=256(cube 树浏览框)需 fielddbtype 作 treeid:由 card-fields 提供,或 --field-db-type 指定');
|
|
485
|
+
const base = {
|
|
486
|
+
isFromMode: '1', modeId: String(opts.modeId ?? ''), fieldid: String(opts.fieldId ?? ''),
|
|
487
|
+
isbill: '1', viewtype: '0', secretLevel: '', companyId: '1', checkStrictly: 'false',
|
|
488
|
+
treeid, cube_treeid: treeid, treetype: '256',
|
|
489
|
+
};
|
|
490
|
+
const items = await fetchCubeTreeBrowserOptions(api, base, { pageSize: opts.pageSize, maxNodes: opts.maxNodes });
|
|
491
|
+
return { detailtype, modeId: opts.modeId, fieldId: opts.fieldId, items, total: items.length, raw: null };
|
|
492
|
+
}
|
|
493
|
+
const pageSize = String(opts.pageSize ?? 10);
|
|
494
|
+
const q = {
|
|
495
|
+
pageSize,
|
|
496
|
+
current: '1', min: '1', max: pageSize,
|
|
497
|
+
alllevel: '1', virtualtype: '1', status: '8',
|
|
498
|
+
isFromMode: '1',
|
|
499
|
+
modeId: String(opts.modeId ?? ''),
|
|
500
|
+
fieldid: String(opts.fieldId ?? ''),
|
|
501
|
+
isbill: '1', viewtype: '0',
|
|
502
|
+
secretLevel: '',
|
|
503
|
+
cmd: opts.cmd ?? 'newly',
|
|
504
|
+
__random__: String(Date.now()),
|
|
505
|
+
};
|
|
506
|
+
const r = await api.get(`/api/public/browser/data/${detailtype}`, q);
|
|
507
|
+
const j = r.json || {};
|
|
508
|
+
let flatDatas = [];
|
|
509
|
+
if (Array.isArray(j?.datas))
|
|
510
|
+
flatDatas = j.datas;
|
|
511
|
+
else if (j?.datas && typeof j.datas === 'object')
|
|
512
|
+
flatDatas = flattenTree(j.datas);
|
|
513
|
+
const cols = Array.isArray(j?.columns) ? j.columns : [];
|
|
514
|
+
let nameKey, pkKey;
|
|
515
|
+
if (cols.length > 0) {
|
|
516
|
+
const showCol = cols.find((c) => String(c.showType) === '1')
|
|
517
|
+
?? cols.find((c) => /名称|名字|name/i.test(String(c.title ?? '')));
|
|
518
|
+
nameKey = showCol?.dataIndex ?? 'lastname';
|
|
519
|
+
const pkCol = cols.find((c) => c.isPrimarykey === 'true' || c.isPrimarykey === true);
|
|
520
|
+
pkKey = pkCol?.dataIndex;
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
nameKey = flatDatas.length > 0 && flatDatas[0].lastname != null ? 'lastname' : 'name';
|
|
524
|
+
pkKey = null;
|
|
525
|
+
}
|
|
526
|
+
const items = flatDatas.filter((d) => d && d.id != null).map((d) => {
|
|
527
|
+
const pk = pkKey && d[pkKey] != null && String(d[pkKey]) !== '' ? String(d[pkKey]) : String(d.id);
|
|
528
|
+
const name = stripHtml(String(d[nameKey] ?? d.name ?? d.id));
|
|
529
|
+
return { id: pk, name: name || String(d.id) };
|
|
530
|
+
}).filter((o) => o.id);
|
|
531
|
+
return { detailtype, modeId: opts.modeId, fieldId: opts.fieldId, items, total: j?.count, raw: j };
|
|
532
|
+
}
|
|
533
|
+
/** 递归展平树形浏览数据(部门组织架构等),只取可点击节点。 */
|
|
534
|
+
function flattenTree(node) {
|
|
535
|
+
const result = [];
|
|
536
|
+
if (!node || typeof node !== 'object')
|
|
537
|
+
return result;
|
|
538
|
+
if (node.canClick === true || String(node.canClick) === 'true' || node.type === '2' || node.type === 2) {
|
|
539
|
+
if (node.id != null)
|
|
540
|
+
result.push(node);
|
|
541
|
+
}
|
|
542
|
+
if (Array.isArray(node.subs))
|
|
543
|
+
for (const sub of node.subs)
|
|
544
|
+
result.push(...flattenTree(sub));
|
|
545
|
+
if (Array.isArray(node.children))
|
|
546
|
+
for (const sub of node.children)
|
|
547
|
+
result.push(...flattenTree(sub));
|
|
548
|
+
return result;
|
|
549
|
+
}
|
|
550
|
+
/** 把一行建模数据映射成 {id, name, fields:[{title, dataIndex, value}]}。 */
|
|
551
|
+
export function normalizeCubeRow(row, columns = []) {
|
|
552
|
+
if (!row || typeof row !== 'object')
|
|
553
|
+
return { id: undefined, name: undefined, fields: [] };
|
|
554
|
+
const get = (keys) => pickByPaths(row, keys);
|
|
555
|
+
const spanOr = (dataIndex, extra = []) => stripHtml(get([`${dataIndex}span`, dataIndex, ...extra]));
|
|
556
|
+
const fields = columns
|
|
557
|
+
.filter((c) => c.dataIndex && c.dataIndex !== 'randomFieldId')
|
|
558
|
+
.map((c) => ({
|
|
559
|
+
dataIndex: c.dataIndex,
|
|
560
|
+
title: c.title || c.dataIndex,
|
|
561
|
+
value: spanOr(c.dataIndex) ?? '',
|
|
562
|
+
}));
|
|
563
|
+
const id = stringify(get(['id', 'randomFieldId']));
|
|
564
|
+
const nameCol = fields.find((f) => f.dataIndex !== 'id');
|
|
565
|
+
return {
|
|
566
|
+
id,
|
|
567
|
+
name: nameCol?.value ?? '',
|
|
568
|
+
fields,
|
|
569
|
+
raw: row,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* ★新 按 billids 反查台账记录(P0-1/P0-2/P1-2,0.4.8)。
|
|
574
|
+
*
|
|
575
|
+
* 用途:delete dry-run 显示记录名、edit dry-run 显示改前值、view 命令显示完整值。
|
|
576
|
+
* 原理:循环调 listCube(pageSize 放大到 100),按行 `it.id` 精确匹配目标 billids。
|
|
577
|
+
* 返回 { rows:[{id,name,fields}], missing:[未找到的 billids] }。
|
|
578
|
+
* 找不到的记录(无权限/不存在/不在当前筛选视图)列入 missing,由调用方提示,不静默跳过。
|
|
579
|
+
*/
|
|
580
|
+
export async function fetchCubeRowsByIds(cfg, opts = {}) {
|
|
581
|
+
const customid = String(opts.customid);
|
|
582
|
+
const billids = (Array.isArray(opts.billids) ? opts.billids : String(opts.billids ?? '').split(','))
|
|
583
|
+
.map((s) => String(s).trim())
|
|
584
|
+
.filter(Boolean);
|
|
585
|
+
const targets = new Set(billids);
|
|
586
|
+
const rows = [];
|
|
587
|
+
const foundIds = new Set();
|
|
588
|
+
const pageSize = opts.pageSize ?? 100;
|
|
589
|
+
const maxPages = 20; // 2000 条封顶,防失控
|
|
590
|
+
for (let page = 1; page <= maxPages && foundIds.size < targets.size; page++) {
|
|
591
|
+
const r = await listCube(cfg, { customid, page, pageSize, modeId: opts.modeId, formId: opts.formId });
|
|
592
|
+
for (const it of r.items) {
|
|
593
|
+
const id = String(it.id ?? '');
|
|
594
|
+
if (!id || foundIds.has(id) || !targets.has(id))
|
|
595
|
+
continue;
|
|
596
|
+
foundIds.add(id);
|
|
597
|
+
rows.push({ id, name: it.name ?? '', fields: it.fields, raw: it.raw });
|
|
598
|
+
}
|
|
599
|
+
// 已找全目标;或本页已到末页(行数不足 pageSize)→ 停
|
|
600
|
+
if (foundIds.size >= targets.size || r.items.length < pageSize)
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
const missing = billids.filter((b) => !foundIds.has(b));
|
|
604
|
+
return { rows, missing };
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Excel 导出。链路:init→getList(取 dataKey) → doExcelExpost(取 key) → 轮询 getExcelExpProgress → FileDownload 下载。
|
|
608
|
+
* dry-run(!opts.yes)只打印 doExcelExpost 请求体与拟下载 URL,不真发起。
|
|
609
|
+
* 注:modeCustomid **必须 base64**(前端 WeaTools.Base64.encode,1001→"MTAwMQ==")。
|
|
610
|
+
* 0.4.8 实测(.26 环境):明文 modeCustomid → 服务端返回空壳 xlsx(有行无单元格);
|
|
611
|
+
* base64 才返回真实数据。默认 base64,opts.modeCustomidPlain=true 才用明文(逃生口)。
|
|
612
|
+
*/
|
|
613
|
+
export async function exportCubeExcel(cfg, opts = {}) {
|
|
614
|
+
const api = await createApiFromProfile(cfg);
|
|
615
|
+
const customid = String(opts.customid);
|
|
616
|
+
const init = await getCubeInit(cfg, { customid });
|
|
617
|
+
const modeId = opts.modeId ?? init.modeId ?? '';
|
|
618
|
+
const formId = opts.formId ?? init.formId ?? '';
|
|
619
|
+
const filename = opts.filename || `${init.name ?? 'cube'}_${customid}.xlsx`;
|
|
620
|
+
const listBody = {
|
|
621
|
+
customid,
|
|
622
|
+
guid: 'search',
|
|
623
|
+
modeId,
|
|
624
|
+
formId,
|
|
625
|
+
current: '1',
|
|
626
|
+
pageSize: '10',
|
|
627
|
+
isotype: '0',
|
|
628
|
+
displayType: 'list',
|
|
629
|
+
type: 'list',
|
|
630
|
+
};
|
|
631
|
+
// P2-1:透传筛选条件(与 listCube 一致),支持 --condition / --keyword
|
|
632
|
+
if (opts.conditionValues && typeof opts.conditionValues === 'object') {
|
|
633
|
+
for (const [k, v] of Object.entries(opts.conditionValues))
|
|
634
|
+
listBody[k] = String(v);
|
|
635
|
+
}
|
|
636
|
+
if (opts.keyword)
|
|
637
|
+
listBody.quickSearchValue = opts.keyword;
|
|
638
|
+
const r1 = await api.post('/api/cube/search/getList', listBody);
|
|
639
|
+
const dataKey = extractCubeDataKey(r1.json);
|
|
640
|
+
if (!dataKey)
|
|
641
|
+
throw new Error(`cube export 未拿到 dataKey,getList 响应: ${(r1.text ?? '').slice(0, 200)}`);
|
|
642
|
+
const rCount = await api.post('/api/ec/dev/table/counts', { dataKey });
|
|
643
|
+
const sourceTotal = extractTotal(rCount.json) ?? extractTotal(r1.json) ?? extractRows(r1.json).length;
|
|
644
|
+
// modeCustomid:必须 base64(前端 WeaTools.Base64.encode(customId),1001→"MTAwMQ==")。
|
|
645
|
+
// 实测(0.4.8,.26 环境 customid=1001/4503):明文 modeCustomid 服务端返回空壳 xlsx(有行无单元格);
|
|
646
|
+
// base64 才返回真实数据(26 单元格 vs 0)。故默认 base64,明文仅作逃生口(--plain)。
|
|
647
|
+
const modeCustomid = opts.modeCustomidPlain
|
|
648
|
+
? customid
|
|
649
|
+
: Buffer.from(customid).toString('base64');
|
|
650
|
+
const expBody = {
|
|
651
|
+
comefrom: 'frommode',
|
|
652
|
+
modeCustomid,
|
|
653
|
+
dataKey,
|
|
654
|
+
excelName: filename,
|
|
655
|
+
isexptree: 'false',
|
|
656
|
+
showOrder: 'all',
|
|
657
|
+
iscustomsearch: '1',
|
|
658
|
+
modeid: modeId,
|
|
659
|
+
...(opts.isSelectGroup != null ? { isSelectGroup: String(opts.isSelectGroup) } : {}),
|
|
660
|
+
...(opts.exptype ? { exptype: opts.exptype } : {}),
|
|
661
|
+
};
|
|
662
|
+
if (!opts.yes) {
|
|
663
|
+
return { dataKey, filename, downloaded: false, expBody, raw: { getList: r1.json } };
|
|
664
|
+
}
|
|
665
|
+
const rExp = await api.post('/api/cube/search/doExcelExpost', expBody);
|
|
666
|
+
const key = pickByPaths(rExp.json, ['key', 'data.key', 'exportkey', 'data.exportkey', 'datas', 'data.datas']);
|
|
667
|
+
if (!key || !/^[0-9a-fA-F]{16,}$/.test(String(key).trim())) {
|
|
668
|
+
throw new Error(`cube export 未拿到导出 key,doExcelExpost 响应: ${(rExp.text ?? '').slice(0, 300)}`);
|
|
669
|
+
}
|
|
670
|
+
const expKey = String(key).trim();
|
|
671
|
+
// 轮询进度,最多 ~60s
|
|
672
|
+
let progress = null;
|
|
673
|
+
for (let i = 0; i < 60; i++) {
|
|
674
|
+
await sleep(1000);
|
|
675
|
+
const rp = await api.get('/api/cube/search/getExcelExpProgress', { key: expKey });
|
|
676
|
+
progress = rp.json;
|
|
677
|
+
const done = pickByPaths(rp.json, ['isFinish', 'data.isFinish', 'finished', 'data.finished', 'status']);
|
|
678
|
+
if (String(done) === 'true' || String(done) === '1' || pickByPaths(rp.json, ['fileExists', 'data.fileExists']) === true) {
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
const pct = pickByPaths(rp.json, ['progress', 'data.progress', 'percent', 'data.percent']);
|
|
682
|
+
if (pct != null)
|
|
683
|
+
process.stderr.write(`\r导出进度: ${pct}`);
|
|
684
|
+
}
|
|
685
|
+
if (progress)
|
|
686
|
+
process.stderr.write('\n');
|
|
687
|
+
// 下载文件流(/weaver/ servlet,走 cookie 头集合)
|
|
688
|
+
const baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
689
|
+
const cookie = endpointFor(cfg).cookie;
|
|
690
|
+
const dlUrl = `${baseUrl}/weaver/weaver.formmode.data.FileDownload?type=listExcelOut&key=${expKey}&filename=${encodeURIComponent(filename)}`;
|
|
691
|
+
const headers = {
|
|
692
|
+
'User-Agent': UA,
|
|
693
|
+
Referer: `${baseUrl}/spa/cube/index.html`,
|
|
694
|
+
Accept: '*/*',
|
|
695
|
+
};
|
|
696
|
+
if (cookie)
|
|
697
|
+
headers['Cookie'] = cookie;
|
|
698
|
+
if (baseUrl.startsWith('http://'))
|
|
699
|
+
headers['X-Requested-With'] = 'XMLHttpRequest';
|
|
700
|
+
const outPath = opts.outDir ? path.resolve(opts.outDir, filename) : path.resolve(process.cwd(), filename);
|
|
701
|
+
const res = await fetch(dlUrl, { headers });
|
|
702
|
+
if (!res.ok)
|
|
703
|
+
throw new Error(`下载失败: HTTP ${res.status} ${res.statusText}`);
|
|
704
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
705
|
+
const validation = inspectXlsxBuffer(buf, sourceTotal);
|
|
706
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
707
|
+
fs.writeFileSync(outPath, buf);
|
|
708
|
+
return {
|
|
709
|
+
key: expKey,
|
|
710
|
+
filename,
|
|
711
|
+
outPath,
|
|
712
|
+
downloaded: true,
|
|
713
|
+
size: buf.length,
|
|
714
|
+
sourceTotal,
|
|
715
|
+
validation,
|
|
716
|
+
raw: { getList: r1.json, counts: rCount.json, doExcelExpost: rExp.json, progress },
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** 不依赖第三方库的最小 xlsx 结构检查;在落盘前拒绝 HTML、空响应和空壳工作簿。 */
|
|
721
|
+
export function inspectXlsxBuffer(buf, sourceTotal = null) {
|
|
722
|
+
if (!Buffer.isBuffer(buf) || buf.length < 64 || buf.readUInt32LE(0) !== 0x04034b50)
|
|
723
|
+
throw new Error('导出响应不是有效的 xlsx(zip) 文件,已拒绝保存。');
|
|
724
|
+
let eocd = -1;
|
|
725
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
|
|
726
|
+
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
727
|
+
}
|
|
728
|
+
if (eocd < 0)
|
|
729
|
+
throw new Error('xlsx 缺少 ZIP 中央目录,文件不完整,已拒绝保存。');
|
|
730
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
731
|
+
let offset = buf.readUInt32LE(eocd + 16);
|
|
732
|
+
const entries = [];
|
|
733
|
+
for (let i = 0; i < count; i++) {
|
|
734
|
+
if (buf.readUInt32LE(offset) !== 0x02014b50)
|
|
735
|
+
throw new Error('xlsx ZIP 中央目录损坏,已拒绝保存。');
|
|
736
|
+
const method = buf.readUInt16LE(offset + 10);
|
|
737
|
+
const compressedSize = buf.readUInt32LE(offset + 20);
|
|
738
|
+
const nameLen = buf.readUInt16LE(offset + 28);
|
|
739
|
+
const extraLen = buf.readUInt16LE(offset + 30);
|
|
740
|
+
const commentLen = buf.readUInt16LE(offset + 32);
|
|
741
|
+
const localOffset = buf.readUInt32LE(offset + 42);
|
|
742
|
+
const name = buf.subarray(offset + 46, offset + 46 + nameLen).toString('utf8');
|
|
743
|
+
entries.push({ name, method, compressedSize, localOffset });
|
|
744
|
+
offset += 46 + nameLen + extraLen + commentLen;
|
|
745
|
+
}
|
|
746
|
+
const names = new Set(entries.map((entry) => entry.name));
|
|
747
|
+
if (!names.has('[Content_Types].xml') || !names.has('xl/workbook.xml'))
|
|
748
|
+
throw new Error('xlsx 缺少工作簿核心结构,已拒绝保存。');
|
|
749
|
+
const sheets = entries.filter((entry) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(entry.name));
|
|
750
|
+
if (!sheets.length)
|
|
751
|
+
throw new Error('xlsx 中没有工作表,已拒绝保存。');
|
|
752
|
+
let cells = 0;
|
|
753
|
+
let rows = 0;
|
|
754
|
+
for (const entry of sheets) {
|
|
755
|
+
const local = entry.localOffset;
|
|
756
|
+
if (buf.readUInt32LE(local) !== 0x04034b50)
|
|
757
|
+
throw new Error(`xlsx 工作表 ${entry.name} 数据损坏。`);
|
|
758
|
+
const nameLen = buf.readUInt16LE(local + 26);
|
|
759
|
+
const extraLen = buf.readUInt16LE(local + 28);
|
|
760
|
+
const start = local + 30 + nameLen + extraLen;
|
|
761
|
+
const packed = buf.subarray(start, start + entry.compressedSize);
|
|
762
|
+
const xml = (entry.method === 0 ? packed : entry.method === 8 ? inflateRawSync(packed) : Buffer.alloc(0)).toString('utf8');
|
|
763
|
+
if (!xml)
|
|
764
|
+
throw new Error(`xlsx 工作表 ${entry.name} 使用了不支持的压缩格式。`);
|
|
765
|
+
cells += (xml.match(/<c(?:\s|\/?>)/g) || []).length;
|
|
766
|
+
rows += (xml.match(/<row(?:\s|\/?>)/g) || []).length;
|
|
767
|
+
}
|
|
768
|
+
if (cells === 0)
|
|
769
|
+
throw new Error('导出的 xlsx 是空壳(工作表中没有单元格),已拒绝保存。');
|
|
770
|
+
if (Number(sourceTotal) > 0 && rows < 2)
|
|
771
|
+
throw new Error(`源列表有 ${sourceTotal} 条数据,但导出的 xlsx 没有数据行,已拒绝保存。`);
|
|
772
|
+
return { valid: true, worksheetCount: sheets.length, rowCount: rows, cellCount: cells, sourceTotal: sourceTotal == null ? null : Number(sourceTotal) };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ─── Excel 批量导入(抓包:batchimport_excel 完整链路)────────────
|
|
776
|
+
export async function downloadCubeImportTemplate(cfg, opts = {}) {
|
|
777
|
+
const customid = String(opts.customid ?? '');
|
|
778
|
+
if (!customid)
|
|
779
|
+
throw new Error('cube import-template 需要 --customid。');
|
|
780
|
+
const init = await getCubeInit(cfg, { customid });
|
|
781
|
+
const modeId = String(opts.modeId ?? init.modeId ?? '');
|
|
782
|
+
if (!modeId)
|
|
783
|
+
throw new Error(`台账 ${customid} 未解析到 modeId。`);
|
|
784
|
+
const ep = endpointFor(cfg);
|
|
785
|
+
const baseUrl = ep.baseUrl.replace(/\/+$/, '');
|
|
786
|
+
const response = await fetch(`${baseUrl}/weaver/weaver.formmode.data.FileDownload?type=batchimport_excel&modeid=${encodeURIComponent(modeId)}`, {
|
|
787
|
+
headers: { Cookie: ep.cookie || '', Referer: `${baseUrl}/spa/cube/index.html`, 'User-Agent': UA, Accept: '*/*' },
|
|
788
|
+
signal: AbortSignal.timeout(30000),
|
|
789
|
+
});
|
|
790
|
+
if (!response.ok)
|
|
791
|
+
throw new Error(`台账导入模板下载失败:HTTP ${response.status}`);
|
|
792
|
+
const disposition = response.headers.get('content-disposition') || '';
|
|
793
|
+
const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
794
|
+
const plainName = disposition.match(/filename="?([^";]+)"?/i)?.[1];
|
|
795
|
+
let serverFilename = opts.filename || init.name || `cube_${customid}.xlsx`;
|
|
796
|
+
try { serverFilename = encodedName ? decodeURIComponent(encodedName) : (plainName || serverFilename); } catch { /* 保留回退名 */ }
|
|
797
|
+
if (!/\.xlsx?$/i.test(serverFilename)) serverFilename += '.xlsx';
|
|
798
|
+
const outDir = path.resolve(opts.outDir || path.join(os.homedir(), 'Downloads'));
|
|
799
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
800
|
+
const validation = assertExcelLike(buf, '台账导入模板');
|
|
801
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
802
|
+
const outPath = nextAvailablePath(outDir, serverFilename);
|
|
803
|
+
fs.writeFileSync(outPath, buf);
|
|
804
|
+
let requiredFields = [];
|
|
805
|
+
try {
|
|
806
|
+
const fields = await getCardFields(cfg, { modeId, formId: init.formId, type: '1' });
|
|
807
|
+
requiredFields = (fields.fields || []).filter((field) => field.ismandatory === true || String(field.ismandatory) === '1').map((field) => field.label || field.fieldname).filter(Boolean);
|
|
808
|
+
}
|
|
809
|
+
catch { /* 必填字段获取失败不影响模板下载,但返回空数组,不能猜 */ }
|
|
810
|
+
return {
|
|
811
|
+
downloaded: true, outPath, size: buf.length, validation, customid, modeId,
|
|
812
|
+
requiredFields,
|
|
813
|
+
requiredFieldsKnown: requiredFields.length > 0,
|
|
814
|
+
fillRules: ['只能修改数据单元格值', '不得修改表头、格式、批注、必填提示、数据验证、公式、隐藏状态及工作表结构'],
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export function parseCubeImportStatus(json) {
|
|
819
|
+
const status = json?.impStatus ?? json?.data?.impStatus ?? json?.data ?? json ?? {};
|
|
820
|
+
const message = stripHtml(status.msg ?? status.message ?? status.errmsg ?? json?.message ?? '').replace(/\s+/g, ' ').trim();
|
|
821
|
+
const done = String(status.status ?? '') === '-1' || Number(status.step) >= 5;
|
|
822
|
+
const success = done && String(status.s ?? '') === '1' && String(status.errorflag ?? '0') === '0';
|
|
823
|
+
const counts = [...message.matchAll(/(成功|失败)[^0-9]*(\d+)条/g)].reduce((acc, match) => ({ ...acc, [match[1] === '成功' ? 'successCount' : 'failureCount']: Number(match[2]) }), {});
|
|
824
|
+
return { done, success, message, successCount: counts.successCount ?? null, failureCount: counts.failureCount ?? null, raw: json };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export async function importCubeExcel(cfg, opts = {}) {
|
|
828
|
+
const customid = String(opts.customid ?? '');
|
|
829
|
+
const filePath = path.resolve(String(opts.file ?? ''));
|
|
830
|
+
if (!customid)
|
|
831
|
+
throw new Error('cube import 需要 --customid。');
|
|
832
|
+
if (!opts.yes)
|
|
833
|
+
return { imported: false, requiresConfirmation: true, operation: 'cube-import', customid, filePath };
|
|
834
|
+
if (!opts.file || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile())
|
|
835
|
+
throw new Error(`找不到台账导入文件:${filePath}`);
|
|
836
|
+
if (!/\.xlsx?$/i.test(filePath))
|
|
837
|
+
throw new Error('台账导入文件必须是 .xls 或 .xlsx。');
|
|
838
|
+
const init = await getCubeInit(cfg, { customid });
|
|
839
|
+
const modeId = String(opts.modeId ?? init.modeId ?? '');
|
|
840
|
+
if (!modeId)
|
|
841
|
+
throw new Error(`台账 ${customid} 未解析到 modeId。`);
|
|
842
|
+
const ep = endpointFor(cfg);
|
|
843
|
+
const tempkey = String(Date.now());
|
|
844
|
+
const check = await e9Request(ep, { method: 'GET', path: '/api/cube/mode/mode/getBatchImportInfo', query: { operation: 'checkImp', modeid: modeId, isSinglePage: '1', customid } });
|
|
845
|
+
const token = pickByPaths(check.json, ['token', 'data.token']);
|
|
846
|
+
if (!check.ok || !token)
|
|
847
|
+
throw new Error(`台账导入初始化失败:${(check.text || '').slice(0, 300)}`);
|
|
848
|
+
const lock = await e9Request(ep, { method: 'POST', path: '/api/cube/mode/mode/lockUpload', body: { modeid: modeId, tempkey } });
|
|
849
|
+
if (!lock.ok || lock.json?.status === false)
|
|
850
|
+
throw new Error(`台账导入锁定失败:${(lock.text || '').slice(0, 300)}`);
|
|
851
|
+
const form = new FormData();
|
|
852
|
+
const filename = path.basename(filePath);
|
|
853
|
+
form.append('name', filename);
|
|
854
|
+
form.append('modeid', modeId);
|
|
855
|
+
form.append('secretLevel', String(opts.secretLevel ?? '4'));
|
|
856
|
+
form.append('ts', String(Date.now()));
|
|
857
|
+
form.append('file', new Blob([fs.readFileSync(filePath)], { type: /\.xlsx$/i.test(filePath) ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : 'application/vnd.ms-excel' }), filename);
|
|
858
|
+
const upload = await fetch(`${ep.baseUrl.replace(/\/+$/, '')}/api/cube/mode/mode/uploadTempFile`, {
|
|
859
|
+
method: 'POST', headers: { Cookie: ep.cookie || '', Referer: `${ep.baseUrl}/spa/cube/index.html`, 'User-Agent': UA, Accept: '*/*' }, body: form, signal: AbortSignal.timeout(60000),
|
|
860
|
+
});
|
|
861
|
+
const uploadJson = await upload.json().catch(() => null);
|
|
862
|
+
const excelid = pickByPaths(uploadJson, ['data.fileid', 'fileid']);
|
|
863
|
+
if (!upload.ok || !excelid)
|
|
864
|
+
throw new Error(`台账文件上传失败:HTTP ${upload.status} ${JSON.stringify(uploadJson)?.slice(0, 300)}`);
|
|
865
|
+
const href = `modeid=${modeId}&customid=${customid}&_key=${Math.random().toString(36).slice(2, 8)}`;
|
|
866
|
+
const start = await e9Request(ep, { method: 'POST', path: '/api/cube/mode/mode/saveBatchImportInfo', body: {
|
|
867
|
+
modeid: modeId, href, excelid: String(excelid), importtype: opts.importtype ?? '1',
|
|
868
|
+
updateadddata: opts.updateadddata ?? '', keyfield: opts.keyfield ?? '', sourcetype: '0', tempkey,
|
|
869
|
+
conditionfield: opts.conditionfield ?? '', isprintlog: opts.isprintlog ?? '0',
|
|
870
|
+
isImportedWithIgnoringError: opts.ignoreErrors ?? '0', operation: 'startImport', token: String(token),
|
|
871
|
+
} });
|
|
872
|
+
if (!start.ok || start.json?.status === false)
|
|
873
|
+
throw new Error(`台账导入任务启动失败:${(start.text || '').slice(0, 300)}`);
|
|
874
|
+
let result = null;
|
|
875
|
+
const maxPolls = Number(opts.maxPolls ?? 120);
|
|
876
|
+
for (let i = 0; i < maxPolls; i++) {
|
|
877
|
+
await sleep(Number(opts.pollIntervalMs ?? 1000));
|
|
878
|
+
const poll = await e9Request(ep, { method: 'GET', path: '/api/cube/mode/mode/getBatchImportInfo', query: { modeid: modeId, tempkey, operation: 'getImpStatus' } });
|
|
879
|
+
result = parseCubeImportStatus(poll.json);
|
|
880
|
+
if (result.done)
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
if (!result?.done)
|
|
884
|
+
throw new Error('台账导入状态轮询超时,不能判定成功;请在 E9 导入日志中核验,勿重复提交。');
|
|
885
|
+
return { imported: result.success, customid, modeId, filePath, excelid: String(excelid), tempkey, ...result };
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* ===== CRUD 写操作(来源:保存及保存新建.har + 删除.har)=====
|
|
889
|
+
* 建模台账的新建/编辑/删除记录能力,补全只读域的写操作缺口。
|
|
890
|
+
*/
|
|
891
|
+
/**
|
|
892
|
+
* 卡片字段定义 + 布局。GET mainFields + layoutBase。
|
|
893
|
+
* mainFields:字段 label/htmltype(1文本2多行3浏览4附件5选择框6复选)/fieldname/fieldtype。
|
|
894
|
+
* layoutBase:字段的 isedit/isview/ismandatory/fieldid + layoutid(提交体需要)。
|
|
895
|
+
* billid 空 + type=1 = 新建态;billid=id + type=0 = 查看态;type=1+billid=id = 编辑态。
|
|
896
|
+
*/
|
|
897
|
+
/**
|
|
898
|
+
* ===== CRUD 写操作(cookie 鉴权)=====
|
|
899
|
+
* 建模 doSubmit 要求 layoutBase 拿 token + doSubmit 提交全程同一 cookie session
|
|
900
|
+
* (token 鉴权会 repeat)。故卡片接口全部走 cookie(e9Request),不走 token。
|
|
901
|
+
* 完整链路:checkCardRight→getModeBase→mainFields→layoutBase→rightMenus→
|
|
902
|
+
* cubeDataInput(trigger联动)→checkCardDataChange→cubeVerifyRepeat→checkCardDataChange(iscreate)→doSubmit。
|
|
903
|
+
* JSONStr 须含全部字段(mainFields/layoutBase 的所有 fieldid,未填给空),缺字段会 repeat。
|
|
904
|
+
* 附件字段先 docUpload 拿 fileid 填进 JSONStr。
|
|
905
|
+
*/
|
|
906
|
+
export async function getCardFields(cfg, opts = {}) {
|
|
907
|
+
const ep = endpointFor(cfg);
|
|
908
|
+
const modeId = String(opts.modeId);
|
|
909
|
+
const formId = String(opts.formId);
|
|
910
|
+
const billid = opts.billid ? String(opts.billid) : '';
|
|
911
|
+
const type = opts.type ?? (billid ? '0' : '1');
|
|
912
|
+
// 卡片会话标识:_key(随机6)/uuid(真随机32hex)/t(时间戳),前端打开卡片时生成,所有卡片接口共用
|
|
913
|
+
const t = String(Date.now());
|
|
914
|
+
const _key = Math.random().toString(36).slice(2, 8);
|
|
915
|
+
const uuid = crypto.randomUUID().replace(/-/g, '').toUpperCase();
|
|
916
|
+
const q = { type: String(type), modeId, formId, guid: 'card', t, _key, uuid, billid };
|
|
917
|
+
const rMain = await e9Request(ep, { method: 'GET', path: '/api/cube/new/card/mainFields', query: q });
|
|
918
|
+
const rLay = await e9Request(ep, { method: 'GET', path: '/api/cube/new/card/layoutBase', query: { ...q, modedatastatus: '0' } });
|
|
919
|
+
const jMain = rMain.json ?? {};
|
|
920
|
+
const jLay = rLay.json ?? {};
|
|
921
|
+
// fail-loud:cookie 过期 / 无权限 → 报错而非静默返空。
|
|
922
|
+
// card 接口要求有效 WUI cookie 会话:cookie 过期返“登录信息超时”(002);token 路径返 noRight(见校准要点)。
|
|
923
|
+
const _authFail = (j) => j && j.status === false && (j.errorCode === '002' || String(j.msg ?? '').includes('登录信息超时'));
|
|
924
|
+
const _noRight = (j) => j && (j.noRight === true || String(j.errorCode ?? '') === 'no-right');
|
|
925
|
+
if (_authFail(jMain) || _authFail(jLay)) {
|
|
926
|
+
throw new Error('卡片接口返回“登录信息超时”,cookie 可能已过期。请运行 `e9 auth token-login` 刷新 cookie 后重试。');
|
|
927
|
+
}
|
|
928
|
+
if (_noRight(jMain) || _noRight(jLay)) {
|
|
929
|
+
throw new Error('当前账号对该台账卡片无权限(noRight)。请换有权限账号或联系管理员开通该台账的卡片权限。');
|
|
930
|
+
}
|
|
931
|
+
const rawFields = pickByPaths(jMain, ['fields', 'data.fields']) ?? {};
|
|
932
|
+
const layoutFields = pickByPaths(jLay, ['fields', 'data.fields']) ?? {};
|
|
933
|
+
const layoutid = stringify(pickByPaths(jLay, ['layoutid', 'data.layoutid']));
|
|
934
|
+
const cardToken = stringify(pickByPaths(jLay, ['token', 'data.token']));
|
|
935
|
+
const fields = [];
|
|
936
|
+
for (const [fk, f] of Object.entries(rawFields)) {
|
|
937
|
+
const id = fk.replace(/^field/, '');
|
|
938
|
+
const lf = layoutFields[fk] ?? layoutFields[`field${id}`] ?? {};
|
|
939
|
+
fields.push({
|
|
940
|
+
id, label: stringify(f.fieldlabel), name: stringify(f.fieldname),
|
|
941
|
+
htmltype: stringify(f.fieldhtmltype), fieldtype: stringify(f.fieldtype),
|
|
942
|
+
fielddbtype: stringify(f.fielddbtype),
|
|
943
|
+
isdetail: f.isdetail,
|
|
944
|
+
isedit: stringify(lf.isedit) === '1',
|
|
945
|
+
isview: stringify(lf.isview) === '1',
|
|
946
|
+
ismandatory: stringify(lf.ismandatory) === '1',
|
|
947
|
+
// 查看态(billid)mainFields 通常带字段当前值:兼容 fieldvalue(对象/标量)/ value
|
|
948
|
+
value: fieldValueOf(f),
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
return { modeId, formId, billid, type, layoutid, cardToken, _key, uuid, fields };
|
|
952
|
+
}
|
|
953
|
+
/** 从 mainFields 的单个字段对象里提取当前值(兼容 fieldvalue 对象/标量、value、showvalue)。 */
|
|
954
|
+
function fieldValueOf(f) {
|
|
955
|
+
if (!f || typeof f !== 'object')
|
|
956
|
+
return undefined;
|
|
957
|
+
const raw = f.fieldvalue !== undefined ? f.fieldvalue : (f.value !== undefined ? f.value : f.showvalue);
|
|
958
|
+
if (raw === undefined || raw === null)
|
|
959
|
+
return undefined;
|
|
960
|
+
if (typeof raw === 'object') {
|
|
961
|
+
if (raw.value !== undefined && raw.value !== null)
|
|
962
|
+
return stringify(raw.value);
|
|
963
|
+
if (raw.showvalue !== undefined && raw.showvalue !== null)
|
|
964
|
+
return stringify(raw.showvalue);
|
|
965
|
+
if (raw.showname !== undefined && raw.showname !== null)
|
|
966
|
+
return stringify(raw.showname);
|
|
967
|
+
return undefined;
|
|
968
|
+
}
|
|
969
|
+
return stringify(raw);
|
|
970
|
+
}
|
|
971
|
+
/** 上传附件(cookie)。POST /api/formmode/card/docUpload(multipart)→ fileid。 */
|
|
972
|
+
async function uploadModeFile(ep, cookie, filePath) {
|
|
973
|
+
const buf = fs.readFileSync(filePath);
|
|
974
|
+
const filename = path.basename(filePath);
|
|
975
|
+
const fd = new FormData();
|
|
976
|
+
fd.append('name', filename);
|
|
977
|
+
fd.append('secretLevel', '');
|
|
978
|
+
fd.append('ts', String(Date.now()));
|
|
979
|
+
fd.append('file', new Blob([buf]), filename);
|
|
980
|
+
const base = ep.baseUrl.replace(/\/+$/, '');
|
|
981
|
+
const r = await fetch(`${base}/api/formmode/card/docUpload`, {
|
|
982
|
+
method: 'POST',
|
|
983
|
+
headers: { Cookie: cookie, 'X-Requested-With': 'XMLHttpRequest', Referer: `${base}/spa/cube/index.html` },
|
|
984
|
+
body: fd,
|
|
985
|
+
});
|
|
986
|
+
const j = await r.json();
|
|
987
|
+
return { fileid: String(j?.data?.fileid ?? ''), imagefileid: String(j?.data?.imagefileid ?? '') };
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* 建模卡片字段联动解析(0.4.2 修复 #3)。
|
|
991
|
+
*
|
|
992
|
+
* 背景:原 submitCubeCard 对 cubeDataInput 传空 trigger '{}',未解析响应,导致建模域联动不生效
|
|
993
|
+
* (如选经办人后经办部门不自动带出)。流程域 wfstart.js 用 reqDataInputResult 解析 changeValue;
|
|
994
|
+
* 建模域对应接口为 POST /api/cube/new/card/cubeDataInput,trigger 字段携带已填触发字段值。
|
|
995
|
+
*
|
|
996
|
+
* 本函数:
|
|
997
|
+
* 1. 从已填 values 构造 trigger JSON(把所有已填字段以 {field<id>: value} 形式打包);
|
|
998
|
+
* 2. POST cubeDataInput(cookie,同卡片 session);
|
|
999
|
+
* 3. 解析响应中的 changeValue/assignInfo(键名以实跑为准),把关联字段自动填值并入 values,
|
|
1000
|
+
* **不覆盖用户已填的值**;
|
|
1001
|
+
* 4. 返回 { trigger, values(已并入联动), raw, applied }。
|
|
1002
|
+
*
|
|
1003
|
+
* ⚠ 待校准点(无建模卡片联动 HAR):
|
|
1004
|
+
* - trigger 的精确 JSON 结构(这里用 {field<id>:value} 全量打包,服务端按配置取所需);
|
|
1005
|
+
* - 响应中 changeValue 的键名(这里兼容 changeValue / assignInfo_* 多种);
|
|
1006
|
+
* - 联动配置来源(建模卡片字段定义里是否含 linkageId/relatefields,目前未消费,靠服务端按
|
|
1007
|
+
* modeId/formId 自行匹配触发字段)。实跑后用 --raw 校准 trigger 结构与响应键名再细化。
|
|
1008
|
+
* 失败不阻断提交(catch 返回原 values)。
|
|
1009
|
+
*/
|
|
1010
|
+
async function resolveCubeLinkage(ep, { modeId, formId, _key, uuid, layoutid, fields, values }) {
|
|
1011
|
+
// 收集已填字段,构造 trigger(全量打包,服务端按联动配置自取触发字段)
|
|
1012
|
+
const trigObj = {};
|
|
1013
|
+
for (const f of fields) {
|
|
1014
|
+
const id = String(f.id);
|
|
1015
|
+
const v = values[id] ?? values[`field${id}`];
|
|
1016
|
+
if (v !== undefined && v !== null && v !== '')
|
|
1017
|
+
trigObj[`field${id}`] = String(v);
|
|
1018
|
+
}
|
|
1019
|
+
const trigger = JSON.stringify(trigObj);
|
|
1020
|
+
let applied = {};
|
|
1021
|
+
let raw = null;
|
|
1022
|
+
try {
|
|
1023
|
+
const r = await e9Request(ep, {
|
|
1024
|
+
method: 'POST', path: '/api/cube/new/card/cubeDataInput',
|
|
1025
|
+
body: { type: '1', modeId, formId, _key, guid: 'card', uuid, layoutid, modedatastatus: '', trigger },
|
|
1026
|
+
});
|
|
1027
|
+
raw = r.json ?? {};
|
|
1028
|
+
// 兼容多种响应键名:changeValue / data.changeValue / assignInfo_*.changeValue
|
|
1029
|
+
const cv = pickByPaths(raw, ['changeValue', 'data.changeValue', 'data.changeValue.changeValue'])
|
|
1030
|
+
?? scanChangeValue(raw);
|
|
1031
|
+
if (cv && typeof cv === 'object') {
|
|
1032
|
+
for (const [key, val] of Object.entries(cv)) {
|
|
1033
|
+
if (!val || typeof val !== 'object')
|
|
1034
|
+
continue;
|
|
1035
|
+
const targetId = String(key).replace(/^field/, '');
|
|
1036
|
+
const cur = values[targetId] ?? values[`field${targetId}`];
|
|
1037
|
+
if (cur !== undefined && cur !== null && cur !== '')
|
|
1038
|
+
continue; // 用户已填不覆盖
|
|
1039
|
+
const fv = val.value !== undefined ? String(val.value) : '';
|
|
1040
|
+
if (fv === '')
|
|
1041
|
+
continue;
|
|
1042
|
+
values[targetId] = fv; // 并入(JSONStr 构造时按 field<id> 取)
|
|
1043
|
+
applied[targetId] = { value: fv, specialobj: Array.isArray(val.specialobj) ? val.specialobj : undefined };
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
// 联动失败不阻断,返回原 trigger + 空 applied
|
|
1049
|
+
}
|
|
1050
|
+
return { trigger, values, applied, raw };
|
|
1051
|
+
}
|
|
1052
|
+
/** 在响应里递归找第一个形如 changeValue 的对象(键名固定为 changeValue)。 */
|
|
1053
|
+
function scanChangeValue(obj) {
|
|
1054
|
+
if (obj == null)
|
|
1055
|
+
return null;
|
|
1056
|
+
if (typeof obj === 'object' && !Array.isArray(obj)) {
|
|
1057
|
+
if (obj.changeValue && typeof obj.changeValue === 'object')
|
|
1058
|
+
return obj.changeValue;
|
|
1059
|
+
for (const v of Object.values(obj)) {
|
|
1060
|
+
const r = scanChangeValue(v);
|
|
1061
|
+
if (r)
|
|
1062
|
+
return r;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* 新建/编辑台账记录(cookie 鉴权 + 完整前置链路 + 附件 + 全字段 JSONStr)。
|
|
1069
|
+
* billid 空=新建(返回新 billid);billid=id=编辑。
|
|
1070
|
+
* opts: { modeId, formId, billid?, values:{id:value}, files:[{fieldId,filePath}], trigger?, pageexpandid?, cf? }
|
|
1071
|
+
* ⚠ 必须全程 cookie 鉴权(layoutBase + doSubmit 同 session),否则 doSubmit 返 repeat。
|
|
1072
|
+
*/
|
|
1073
|
+
export async function submitCubeCard(cfg, opts = {}) {
|
|
1074
|
+
const ep = endpointFor(cfg);
|
|
1075
|
+
const cookie = ep.cookie;
|
|
1076
|
+
const modeId = String(opts.modeId);
|
|
1077
|
+
const formId = String(opts.formId);
|
|
1078
|
+
const billid = opts.billid ? String(opts.billid) : '';
|
|
1079
|
+
const iscreate = billid ? '0' : '1';
|
|
1080
|
+
const cf = opts.cf ?? await getCardFields(cfg, { modeId, formId, billid });
|
|
1081
|
+
const { layoutid, cardToken, _key, uuid, fields } = cf;
|
|
1082
|
+
const t = String(Date.now());
|
|
1083
|
+
const cq = (o = {}) => ({ type: '1', modeId, formId, guid: 'card', t, _key, uuid, billid, ...o });
|
|
1084
|
+
// ① 卡片打开链路(cookie,同 session)
|
|
1085
|
+
await e9Request(ep, { method: 'GET', path: '/api/cube/new/card/checkCardRight', query: cq() });
|
|
1086
|
+
await e9Request(ep, { method: 'GET', path: '/api/cube/new/card/getModeBase', query: cq() });
|
|
1087
|
+
const rMenus = await e9Request(ep, { method: 'GET', path: '/api/cube/new/card/rightMenus', query: cq({ layoutid, modedatastatus: '' }) });
|
|
1088
|
+
const menus = rMenus.json?.rightMenus ?? [];
|
|
1089
|
+
const pageexpandid = String(opts.pageexpandid ?? menus.find((m) => m.name === '保存')?.key ?? layoutid);
|
|
1090
|
+
// ② 附件上传 → fileid,并入 values
|
|
1091
|
+
const values = { ...(opts.values ?? {}) };
|
|
1092
|
+
for (const f of (opts.files ?? [])) {
|
|
1093
|
+
const up = await uploadModeFile(ep, cookie, f.filePath);
|
|
1094
|
+
values[f.fieldId] = up.fileid;
|
|
1095
|
+
}
|
|
1096
|
+
// ③ 字段联动解析 + cubeDataInput(修复 #3:原传空 trigger 且不解析响应)
|
|
1097
|
+
// - opts.trigger 未给:调 resolveCubeLinkage(内部 POST cubeDataInput + 解析 changeValue 回填关联字段,不覆盖用户已填)
|
|
1098
|
+
// - opts.trigger 给定:直接用该 trigger 调 cubeDataInput(链路保留)
|
|
1099
|
+
if (opts.trigger === undefined) {
|
|
1100
|
+
const lk = await resolveCubeLinkage(ep, { modeId, formId, _key, uuid, layoutid, fields, values });
|
|
1101
|
+
for (const fid of Object.keys(lk.applied ?? {})) {
|
|
1102
|
+
const info = lk.applied[fid];
|
|
1103
|
+
const so = info.specialobj?.[0];
|
|
1104
|
+
console.log(` 🔗 建模联动: → field${fid}=${so?.name ?? info.value} (id=${info.value})`);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
else {
|
|
1108
|
+
await e9Request(ep, { method: 'POST', path: '/api/cube/new/card/cubeDataInput', body: { type: '1', modeId, formId, _key, guid: 'card', uuid, layoutid, modedatastatus: '', trigger: opts.trigger } });
|
|
1109
|
+
}
|
|
1110
|
+
const cdt = nowStr();
|
|
1111
|
+
await e9Request(ep, { method: 'POST', path: '/api/cube/new/card/checkCardDataChange', body: { billid, type: '1', modeId, formId, _key, guid: 'card', changeDataTime: cdt } });
|
|
1112
|
+
await e9Request(ep, { method: 'POST', path: '/api/cube/new/card/cubeVerifyRepeat', body: { billid, type: '1', modeId, formId, _key, guid: 'card', verifys: '{}' } });
|
|
1113
|
+
await e9Request(ep, { method: 'POST', path: '/api/cube/new/card/checkCardDataChange', body: { billid, type: '1', modeId, formId, _key, guid: 'card', changeDataTime: cdt, iscreate } });
|
|
1114
|
+
// ④ JSONStr 全字段(cf.fields 所有 id,values 覆盖,未填给空)
|
|
1115
|
+
const jsonObj = {};
|
|
1116
|
+
for (const f of fields)
|
|
1117
|
+
jsonObj[`field${f.id}`] = String(values[f.id] ?? values[`field${f.id}`] ?? '');
|
|
1118
|
+
// ⑤ doSubmit(cookie,同 session)
|
|
1119
|
+
const body = {
|
|
1120
|
+
billid, type: '1', modeId, formId, _key, guid: 'card', token: cardToken,
|
|
1121
|
+
layoutid, isFormMode: '1', iscreate, src: 'submit', currentLayoutId: layoutid,
|
|
1122
|
+
pageexpandid, JSONStr: JSON.stringify(jsonObj), btntype: '', issystemflag: '1', oldmodedatastatus: '0',
|
|
1123
|
+
};
|
|
1124
|
+
const r = await e9Request(ep, { method: 'POST', path: '/api/cube/new/card/doSubmit', body });
|
|
1125
|
+
const j = r.json ?? {};
|
|
1126
|
+
return { ok: j.status === '1' || j.status === 1, billid: stringify(j.billid) ?? billid, iscreate, raw: j };
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* ★新 新建/编辑 dry-run 联动预览(P1-1,0.4.8)。
|
|
1130
|
+
*
|
|
1131
|
+
* 目的:让 create/edit 的 dry-run 与真实提交**完全一致**——真实提交会调 resolveCubeLinkage
|
|
1132
|
+
* 回填联动字段,而旧 dry-run 只展示用户传入 values,导致"确认的字段集 ≠ 最终提交字段集"。
|
|
1133
|
+
*
|
|
1134
|
+
* 行为:getCardFields → 附件路径解析(不实际上传,仅记录待传清单)→ resolveCubeLinkage(真实 POST
|
|
1135
|
+
* cubeDataInput,解析 changeValue 回填联动字段,不覆盖用户已填)→ 返回最终 values + 回填明细。
|
|
1136
|
+
* **不调 doSubmit / 不上传附件**,无真实写副作用。
|
|
1137
|
+
*
|
|
1138
|
+
* 返回 { modeId, formId, billid, layoutid, fields, finalValues, linked: [{fieldId,label,value}], files }。
|
|
1139
|
+
*/
|
|
1140
|
+
export async function previewCubeCard(cfg, opts = {}) {
|
|
1141
|
+
const ep = endpointFor(cfg);
|
|
1142
|
+
const modeId = String(opts.modeId);
|
|
1143
|
+
const formId = String(opts.formId);
|
|
1144
|
+
const billid = opts.billid ? String(opts.billid) : '';
|
|
1145
|
+
const cf = opts.cf ?? await getCardFields(cfg, { modeId, formId, billid });
|
|
1146
|
+
const { layoutid, _key, uuid, fields } = cf;
|
|
1147
|
+
const values = { ...(opts.values ?? {}) };
|
|
1148
|
+
// 附件:只记录清单(dry-run 不上传),预览提示"--yes 时上传"
|
|
1149
|
+
const files = (opts.files ?? []).map((f) => ({ fieldId: String(f.fieldId), filePath: f.filePath }));
|
|
1150
|
+
// 联动解析(真实接口、无写副作用;失败不阻断——resolveCubeLinkage 内部 catch 返回原 values)
|
|
1151
|
+
let linked = [];
|
|
1152
|
+
if (opts.trigger === undefined) {
|
|
1153
|
+
const lk = await resolveCubeLinkage(ep, { modeId, formId, _key, uuid, layoutid, fields, values });
|
|
1154
|
+
linked = Object.entries(lk.applied ?? {}).map(([fid, info]) => {
|
|
1155
|
+
const f = fields.find((x) => String(x.id) === String(fid));
|
|
1156
|
+
const so = info.specialobj?.[0];
|
|
1157
|
+
return { fieldId: String(fid), label: f?.label ?? `field${fid}`, value: so?.name ?? info.value };
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
return { modeId, formId, billid, layoutid, fields, finalValues: values, linked, files };
|
|
1161
|
+
}
|
|
1162
|
+
/** 删除台账记录(cookie)。GET /api/cube/expand/deleteData?operate=DeleteAction&billids=。 */
|
|
1163
|
+
export async function deleteCubeData(cfg, opts = {}) {
|
|
1164
|
+
const ep = endpointFor(cfg);
|
|
1165
|
+
const r = await e9Request(ep, {
|
|
1166
|
+
method: 'GET', path: '/api/cube/expand/deleteData',
|
|
1167
|
+
query: { customid: String(opts.customid), guid: 'search', operate: 'DeleteAction', billids: Array.isArray(opts.billids) ? opts.billids.join(',') : String(opts.billids ?? ''), pageexpandid: String(opts.pageexpandid ?? ''), modeId: String(opts.modeId ?? '') },
|
|
1168
|
+
});
|
|
1169
|
+
const j = r.json ?? {};
|
|
1170
|
+
return { ok: j.status === '1' || j.status === 1, message: stringify(j.message), raw: j };
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* 打印台账卡片(有头浏览器打开卡片页面,用户自己点打印)。
|
|
1174
|
+
* E9 打印是 window.print(前端 JS 生成打印预览含时间页眉),无法纯接口/page.pdf 复刻,
|
|
1175
|
+
* 故直接弹出卡片详情页让用户自己点「打印」按钮(弹浏览器打印对话框,可另存 PDF)。
|
|
1176
|
+
* 链路:有头浏览器加载 storageState(免登录)→ 导航卡片 → 保留浏览器 5 分钟供用户操作;
|
|
1177
|
+
* storageState 过期 → 弹登录页登录 → 保存 storageState → 导航卡片。
|
|
1178
|
+
* opts: { customid, billid, modeId, formId }
|
|
1179
|
+
*/
|
|
1180
|
+
export async function printCubePdf(cfg, opts = {}) {
|
|
1181
|
+
const baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
1182
|
+
const profile = profileNameFromBaseUrl(baseUrl);
|
|
1183
|
+
const os = await import('node:os');
|
|
1184
|
+
const p = await import('node:path');
|
|
1185
|
+
const fsMod = await import('node:fs');
|
|
1186
|
+
const cfgDir = p.join(os.homedir(), '.e9');
|
|
1187
|
+
const storagePath = p.join(cfgDir, `storageState_${profile}.json`);
|
|
1188
|
+
const customid = String(opts.customid), billid = String(opts.billid);
|
|
1189
|
+
const modeId = String(opts.modeId), formId = String(opts.formId);
|
|
1190
|
+
const cardUrl = `${baseUrl}/spa/cube/index.html#/main/cube/card?modeId=${modeId}&formId=${formId}&billid=${billid}&customid=${customid}&type=0`;
|
|
1191
|
+
let chromium;
|
|
1192
|
+
try {
|
|
1193
|
+
chromium = (await import('playwright')).chromium;
|
|
1194
|
+
}
|
|
1195
|
+
catch {
|
|
1196
|
+
throw new Error('playwright 未安装,打印需要它:npm install playwright');
|
|
1197
|
+
}
|
|
1198
|
+
const EDGE = 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe';
|
|
1199
|
+
// 有头浏览器(用户可见,自己点打印)
|
|
1200
|
+
const browser = await chromium.launch({ executablePath: EDGE, headless: false });
|
|
1201
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
1202
|
+
// 加载 storageState(免登录)
|
|
1203
|
+
if (fsMod.existsSync(storagePath)) {
|
|
1204
|
+
try {
|
|
1205
|
+
await ctx.addCookies(JSON.parse(fsMod.readFileSync(storagePath, 'utf8')).cookies);
|
|
1206
|
+
}
|
|
1207
|
+
catch { }
|
|
1208
|
+
}
|
|
1209
|
+
const page = await ctx.newPage();
|
|
1210
|
+
await page.goto(cardUrl, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => console.log('nav:', e.message));
|
|
1211
|
+
await page.waitForTimeout(8000);
|
|
1212
|
+
// 检查是否重定向登录页(storageState 失效)
|
|
1213
|
+
const text = await page.evaluate(() => document.body.innerText.slice(0, 200));
|
|
1214
|
+
if (/请使用e-mobile|登录名|登录密码/.test(text)) {
|
|
1215
|
+
console.log('storageState 过期,请在浏览器登录...');
|
|
1216
|
+
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => { });
|
|
1217
|
+
await page.waitForURL(/#\/main\//, { timeout: 600000 }).catch(() => console.log('等待登录超时'));
|
|
1218
|
+
await ctx.storageState({ path: storagePath }).catch(() => { });
|
|
1219
|
+
await page.goto(cardUrl, { waitUntil: 'networkidle', timeout: 60000 });
|
|
1220
|
+
await page.waitForTimeout(8000);
|
|
1221
|
+
}
|
|
1222
|
+
console.log(`已在浏览器打开合同详情页(customid=${customid} billid=${billid}),请点击页面「打印」按钮,在弹出的打印对话框中选「另存为 PDF」。浏览器保留 5 分钟供操作。`);
|
|
1223
|
+
await page.waitForTimeout(300000);
|
|
1224
|
+
await browser.close();
|
|
1225
|
+
return { ok: true, mode: 'browser' };
|
|
1226
|
+
}
|
|
1227
|
+
/** 当前时间 YYYY-MM-DD HH:mm:ss(doSubmit 的 changeDataTime 用)。 */
|
|
1228
|
+
function nowStr() {
|
|
1229
|
+
const d = new Date();
|
|
1230
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
1231
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
1232
|
+
}
|
|
1233
|
+
function stringify(v) {
|
|
1234
|
+
if (v === undefined || v === null || v === '')
|
|
1235
|
+
return undefined;
|
|
1236
|
+
return String(v);
|
|
1237
|
+
}
|
|
1238
|
+
/* =========================================================================
|
|
1239
|
+
* 台账发现与查询(0.4.0-14 新增)
|
|
1240
|
+
*
|
|
1241
|
+
* 缺口背景:原 cube 命令全部要求 --customid 已知,无"列全部台账/按名找"能力;
|
|
1242
|
+
* cube.md 文档虽写了"调菜单接口找台账",但未落地成命令。
|
|
1243
|
+
*
|
|
1244
|
+
* 三条路径(查询时按 3→1→2 顺序):
|
|
1245
|
+
* 1. 目录接口(主源):GET /api/ai/cube/mode/getCubeSearchList → sessionkey
|
|
1246
|
+
* → POST /api/ec/dev/table/datas(pageSize=100 翻页)→ /counts。
|
|
1247
|
+
* 返回建模台账后台注册表全量(id=customid + list_name + mode_id + form_id + app_name),
|
|
1248
|
+
* 比菜单全(菜单只含挂出的,目录含未挂出的)。/api/ai 命名空间 = E9 专为 AI 暴露的发现接口。
|
|
1249
|
+
* 2. 菜单接口(低权限账号 / 目录接口失败时兜底):GET /api/portal/menu/getFrontEndMenu
|
|
1250
|
+
* 递归扫菜单树找 linkAddress 含 /main/cube/search?customid=N 的节点。
|
|
1251
|
+
* 只给挂出的台账(目录的子集),无 mode_id/form_id。
|
|
1252
|
+
* 3. 本地缓存(首查即预热全量):~/.e9/cube_catalog_<envkey>.json,按环境隔离
|
|
1253
|
+
* (customid 不跨环境通用),TTL 7 天,--refresh 强制重拉、--no-cache 实时查。
|
|
1254
|
+
*
|
|
1255
|
+
* 设计要点:
|
|
1256
|
+
* - 缓存"全量预热"而非"按查询懒存":一次目录调用拿全 → 后续按名查找全本地毫秒级。
|
|
1257
|
+
* - 按名匹配必须列候选消歧("合同"相关台账 30+ 个、近似名多),不得自动取首条。
|
|
1258
|
+
* - 目录里 mode_id 为空的是只读查询视图,card-fields/create/print 等写操作跑不了。
|
|
1259
|
+
* ========================================================================= */
|
|
1260
|
+
/** 目录缓存 TTL(天)。 */
|
|
1261
|
+
const CUBE_CATALOG_TTL_DAYS = 7;
|
|
1262
|
+
/** 目录缓存文件路径:~/.e9/cube_catalog_<envkey>.json(按环境隔离)。 */
|
|
1263
|
+
export function cubeCachePath(cfg) {
|
|
1264
|
+
const baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
1265
|
+
const envkey = profileNameFromBaseUrl(baseUrl);
|
|
1266
|
+
return path.join(os.homedir(), '.e9', `cube_catalog_${envkey}.json`);
|
|
1267
|
+
}
|
|
1268
|
+
/** 读缓存;返回 {entries, fetchedAt, source, baseUrl} 或 null。 */
|
|
1269
|
+
function readCubeCache(cfg) {
|
|
1270
|
+
const p = cubeCachePath(cfg);
|
|
1271
|
+
try {
|
|
1272
|
+
if (!fs.existsSync(p))
|
|
1273
|
+
return null;
|
|
1274
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
1275
|
+
if (!raw || !Array.isArray(raw.entries))
|
|
1276
|
+
return null;
|
|
1277
|
+
return raw;
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/** 写缓存(全量预热)。写入失败不阻断查询。 */
|
|
1284
|
+
function writeCubeCache(cfg, entries, source) {
|
|
1285
|
+
const p = cubeCachePath(cfg);
|
|
1286
|
+
try {
|
|
1287
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1288
|
+
const baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
1289
|
+
const raw = {
|
|
1290
|
+
envkey: profileNameFromBaseUrl(baseUrl),
|
|
1291
|
+
baseUrl,
|
|
1292
|
+
fetchedAt: new Date().toISOString(),
|
|
1293
|
+
source,
|
|
1294
|
+
count: entries.length,
|
|
1295
|
+
entries,
|
|
1296
|
+
};
|
|
1297
|
+
fs.writeFileSync(p, JSON.stringify(raw, null, 2) + '\n', 'utf8');
|
|
1298
|
+
}
|
|
1299
|
+
catch (e) {
|
|
1300
|
+
console.error('[cube cache] 写入失败:', e.message);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
/** 缓存是否过期(超 TTL 或无 fetchedAt)。 */
|
|
1304
|
+
function isCacheStale(cache) {
|
|
1305
|
+
if (!cache || !cache.fetchedAt)
|
|
1306
|
+
return true;
|
|
1307
|
+
const ageMs = Date.now() - new Date(cache.fetchedAt).getTime();
|
|
1308
|
+
return ageMs > CUBE_CATALOG_TTL_DAYS * 24 * 3600 * 1000;
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* 路径1:调目录接口拉全量台账。
|
|
1312
|
+
* GET /api/ai/cube/mode/getCubeSearchList → sessionkey
|
|
1313
|
+
* → POST /api/ec/dev/table/datas(pageSize=100 翻页)→ /counts。
|
|
1314
|
+
* 返回 {entries:[{id,list_name,list_desc,mode_id,form_id,app_name,app_id,mode_name,table_name}], total, source:'catalog'}。
|
|
1315
|
+
*/
|
|
1316
|
+
export async function fetchCubeCatalog(cfg) {
|
|
1317
|
+
const api = await createApiFromProfile(cfg);
|
|
1318
|
+
const r1 = await api.get('/api/ai/cube/mode/getCubeSearchList');
|
|
1319
|
+
const j1 = r1.json || {};
|
|
1320
|
+
const sessionkey = pickByPaths(j1, ['data.sessionkey', 'sessionkey', 'data.sessionKey']);
|
|
1321
|
+
if (!sessionkey)
|
|
1322
|
+
throw new Error(`目录接口未返回 sessionkey:${(r1.text || '').slice(0, 200)}`);
|
|
1323
|
+
const entries = [];
|
|
1324
|
+
let page = 1;
|
|
1325
|
+
let total = null;
|
|
1326
|
+
while (page < 50) { // 上限 50 页 = 5000 条,足够
|
|
1327
|
+
const r = await api.post('/api/ec/dev/table/datas', {
|
|
1328
|
+
dataKey: sessionkey, current: String(page), pageSize: '100', sortParams: '[]',
|
|
1329
|
+
});
|
|
1330
|
+
const rows = extractRows(r.json);
|
|
1331
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
1332
|
+
break;
|
|
1333
|
+
for (const row of rows) {
|
|
1334
|
+
const e = {
|
|
1335
|
+
id: stringify(row.id) ?? stringify(row.randomFieldId),
|
|
1336
|
+
list_name: stringify(row.list_name) ?? stringify(row.list_namespan),
|
|
1337
|
+
list_desc: stringify(row.list_desc),
|
|
1338
|
+
mode_id: stringify(row.mode_id),
|
|
1339
|
+
form_id: stringify(row.form_id),
|
|
1340
|
+
app_name: stringify(row.app_name) ?? stringify(row.app_namespan),
|
|
1341
|
+
app_id: stringify(row.app_id),
|
|
1342
|
+
mode_name: stringify(row.mode_name) ?? stringify(row.mode_namespan),
|
|
1343
|
+
table_name: stringify(row.table_name),
|
|
1344
|
+
};
|
|
1345
|
+
if (e.id)
|
|
1346
|
+
entries.push(e);
|
|
1347
|
+
}
|
|
1348
|
+
if (rows.length < 100)
|
|
1349
|
+
break;
|
|
1350
|
+
page++;
|
|
1351
|
+
}
|
|
1352
|
+
try {
|
|
1353
|
+
const r3 = await api.post('/api/ec/dev/table/counts', { dataKey: sessionkey });
|
|
1354
|
+
total = extractTotal(r3.json);
|
|
1355
|
+
}
|
|
1356
|
+
catch { /* counts 失败不阻断 */ }
|
|
1357
|
+
return { entries, total: total ?? entries.length, source: 'catalog' };
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* 拉门户菜单树,建「菜单名索引」:customid → 菜单挂载点 [{name, path}]。
|
|
1361
|
+
* GET /api/portal/menu/getFrontEndMenu?withPortal=1&isLoadAll=1 → 递归扫菜单树,两类挂载点:
|
|
1362
|
+
* ① 普通台账菜单:linkAddress 含 /main/cube/search?customid=N
|
|
1363
|
+
* ② cube 树形菜单:linkAddress 含 /main/cube/tree?id=<treeId>,其叶子台账挂在 cube 树节点数据中,
|
|
1364
|
+
* 需另调 /api/cube/tree/datas(递归 pid)+ /api/cube/tree/href(节点→customid)解析后并入 byId。
|
|
1365
|
+
* 一个台账可被多菜单挂载,byId[id] 是数组。best-effort:失败返空,不阻断查询。
|
|
1366
|
+
*
|
|
1367
|
+
* 解决:目录接口(getCubeSearchList)只给台账 list_name,不给门户菜单节点名;用户按前台菜单名找台账,
|
|
1368
|
+
* 菜单名可能与台账名不符(如菜单「tree测试」挂 cube 树→台账 1354/1355/1356)。建索引合并进 entry.menus,find 即可按菜单名搜。
|
|
1369
|
+
*/
|
|
1370
|
+
export async function fetchMenuMounts(cfg) {
|
|
1371
|
+
const api = await createApiFromProfile(cfg);
|
|
1372
|
+
const r = await api.get('/api/portal/menu/getFrontEndMenu', { withPortal: '1', isLoadAll: '1' });
|
|
1373
|
+
if (!r.ok)
|
|
1374
|
+
return { byId: {}, menuNodes: [], count: 0, error: `菜单接口失败: ${r.status} ${(r.text || '').slice(0, 150)}` };
|
|
1375
|
+
const j = r.json;
|
|
1376
|
+
const byId = {};
|
|
1377
|
+
const menuNodes = []; // ★ #7 所有有名字+链接的菜单节点(台账/树/CRM/SPA 全收),供 e9 search 统一搜
|
|
1378
|
+
const treeMenus = []; // { treeId, menuName, menuPath }
|
|
1379
|
+
const stack = [];
|
|
1380
|
+
function walk(node) {
|
|
1381
|
+
if (!node || typeof node !== 'object')
|
|
1382
|
+
return;
|
|
1383
|
+
const name = String(node.fname || node.name || node.menuname || node.title || '');
|
|
1384
|
+
if (name)
|
|
1385
|
+
stack.push(name);
|
|
1386
|
+
const link = String(node.linkAddress || node.linkaddress || node.url || '');
|
|
1387
|
+
const m = link.match(/\/main\/cube\/search\?(?:[^#]*&)?customid=(\d+)/);
|
|
1388
|
+
// ★ #7 收录所有菜单节点(不只 cube 台账菜单),让"客户监控"这类 CRM/SPA 菜单也能按名搜到
|
|
1389
|
+
if (name && link) {
|
|
1390
|
+
menuNodes.push({ name, path: stack.join(' / '), url: link, customid: m ? m[1] : undefined });
|
|
1391
|
+
}
|
|
1392
|
+
// ① 普通台账菜单挂载 → byId
|
|
1393
|
+
if (m) {
|
|
1394
|
+
const id = m[1];
|
|
1395
|
+
const path = stack.join(' / ');
|
|
1396
|
+
(byId[id] = byId[id] || []).push({ name, path, url: link });
|
|
1397
|
+
}
|
|
1398
|
+
// ② cube 树形菜单:url /main/cube/tree?id=<treeId>,叶子台账待解析
|
|
1399
|
+
const tm = link.match(/\/main\/cube\/tree\?(?:[^#]*&)?id=(\d+)/);
|
|
1400
|
+
if (tm) {
|
|
1401
|
+
treeMenus.push({ treeId: tm[1], menuName: name, menuPath: stack.join(' / ') });
|
|
1402
|
+
}
|
|
1403
|
+
for (const k of ['children', 'child', 'subMenus', 'submenus', 'menuList', 'menus', 'data', 'list']) {
|
|
1404
|
+
const v = node[k];
|
|
1405
|
+
if (Array.isArray(v))
|
|
1406
|
+
v.forEach(walk);
|
|
1407
|
+
}
|
|
1408
|
+
if (name)
|
|
1409
|
+
stack.pop();
|
|
1410
|
+
}
|
|
1411
|
+
walk(j);
|
|
1412
|
+
if (treeMenus.length === 0 && Object.keys(byId).length === 0 && Array.isArray(j))
|
|
1413
|
+
j.forEach(walk);
|
|
1414
|
+
// 解析 cube 树形菜单 → 叶子台账 customid,并入 byId
|
|
1415
|
+
for (const tm of treeMenus) {
|
|
1416
|
+
try {
|
|
1417
|
+
await resolveCubeTreeMenu(api, tm.treeId, tm.menuPath, byId);
|
|
1418
|
+
}
|
|
1419
|
+
catch { /* 单棵树解析失败不阻断 */ }
|
|
1420
|
+
}
|
|
1421
|
+
return { byId, menuNodes, count: Object.keys(byId).length };
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* 解析 cube 树形菜单(portal 菜单节点 url /main/cube/tree?id=<treeId>)的叶子台账。
|
|
1425
|
+
* GET /api/cube/tree/datas?id=<treeId>&guid=tree(根,&pid=<domid> 取子)→ [{domid,name,haschild}]
|
|
1426
|
+
* GET /api/cube/tree/href?id=<treeId>&guid=tree&pid=<domid> → {href:".../main/cube/search?customid=N..."}
|
|
1427
|
+
* 对每个树节点取 href 解析 customid,并入 byId(path = 菜单路径 / 树节点名链)。
|
|
1428
|
+
* maxTreeNodes/maxTreeDepth 防止大树失控;domid 去重防环。
|
|
1429
|
+
*/
|
|
1430
|
+
async function resolveCubeTreeMenu(api, treeId, menuPath, byId) {
|
|
1431
|
+
const maxNodes = 200;
|
|
1432
|
+
const maxDepth = 6;
|
|
1433
|
+
const seen = new Set();
|
|
1434
|
+
let n = 0;
|
|
1435
|
+
async function expand(nodes, parentPath, depth) {
|
|
1436
|
+
for (const node of nodes) {
|
|
1437
|
+
if (n >= maxNodes)
|
|
1438
|
+
return;
|
|
1439
|
+
const nm = String(node.name || node.title || '');
|
|
1440
|
+
const fullPath = parentPath ? `${parentPath} / ${nm}` : (menuPath ? `${menuPath} / ${nm}` : nm);
|
|
1441
|
+
// href → customid(每个节点都可能指向一个台账)
|
|
1442
|
+
try {
|
|
1443
|
+
const rh = await api.get('/api/cube/tree/href', { id: treeId, guid: 'tree', pid: node.domid, isRefreshTree: '0' });
|
|
1444
|
+
const href = rh.json?.href || '';
|
|
1445
|
+
const cm = href.match(/\/main\/cube\/search\?(?:[^#]*&)?customid=(\d+)/);
|
|
1446
|
+
if (cm) {
|
|
1447
|
+
(byId[cm[1]] = byId[cm[1]] || []).push({ name: nm, path: fullPath, url: href });
|
|
1448
|
+
n++;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
catch { /* 单节点 href 失败跳过 */ }
|
|
1452
|
+
// 递归子节点
|
|
1453
|
+
if (node.haschild && depth < maxDepth && !seen.has(node.domid)) {
|
|
1454
|
+
seen.add(node.domid);
|
|
1455
|
+
try {
|
|
1456
|
+
const rc = await api.get('/api/cube/tree/datas', { id: treeId, guid: 'tree', pid: node.domid });
|
|
1457
|
+
const kids = Array.isArray(rc.json?.datas) ? rc.json.datas : [];
|
|
1458
|
+
if (kids.length)
|
|
1459
|
+
await expand(kids, fullPath, depth + 1);
|
|
1460
|
+
}
|
|
1461
|
+
catch { /* 子节点展开失败跳过 */ }
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
const r0 = await api.get('/api/cube/tree/datas', { id: treeId, guid: 'tree' });
|
|
1466
|
+
const roots = Array.isArray(r0.json?.datas) ? r0.json.datas : [];
|
|
1467
|
+
if (roots.length)
|
|
1468
|
+
await expand(roots, '', 0);
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* 路径2:调菜单接口兜底(低权限账号 / 目录接口失败时)。
|
|
1472
|
+
* 复用 fetchMenuMounts:把菜单挂载点转成台账 entry(list_name 取菜单节点名,
|
|
1473
|
+
* 因菜单兜底时台账实际名未知;menus 保留全部挂载点,listCubeMenus 第4步据此跳过重复请求)。
|
|
1474
|
+
* 返回 {entries:[{id,list_name,app_name,menuPath,menus}], source:'menu'}(无 mode_id/form_id)。
|
|
1475
|
+
*/
|
|
1476
|
+
export async function fetchCubeMenusFromMenu(cfg) {
|
|
1477
|
+
const mm = await fetchMenuMounts(cfg);
|
|
1478
|
+
if (mm.error)
|
|
1479
|
+
throw new Error(mm.error);
|
|
1480
|
+
const entries = [];
|
|
1481
|
+
for (const [id, mounts] of Object.entries(mm.byId)) {
|
|
1482
|
+
const first = mounts[0];
|
|
1483
|
+
entries.push({
|
|
1484
|
+
id,
|
|
1485
|
+
list_name: first.name,
|
|
1486
|
+
list_desc: '',
|
|
1487
|
+
mode_id: '',
|
|
1488
|
+
form_id: '',
|
|
1489
|
+
app_name: first.path.split(' / ').slice(0, -1).join('/') || '',
|
|
1490
|
+
menuPath: first.path,
|
|
1491
|
+
menus: mounts,
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
return { entries, source: 'menu' };
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* 台账目录发现(主入口):缓存优先 → 目录接口(路径1)→ 菜单兜底(路径2)→ 菜单名索引(第4步)。
|
|
1498
|
+
* opts: { refresh?:bool, noCache?:bool }
|
|
1499
|
+
* 返回 { entries, source, count, fetchedAt, fromCache, total?, error? }。
|
|
1500
|
+
* - 缓存命中且未过期 → 直接返回(fromCache:true,menus 已随缓存持久化)。
|
|
1501
|
+
* - refresh/noCache 或缓存过期 → 拉目录;目录空/失败 → 菜单兜底;命中即合并菜单名索引后回写缓存。
|
|
1502
|
+
* - 第4步菜单名索引:catalog 来源的 entry 缺菜单节点名,best-effort 拉门户菜单树补 menus 字段;
|
|
1503
|
+
* menu 来源已自带 menus,跳过避免重复请求。
|
|
1504
|
+
*/
|
|
1505
|
+
export async function listCubeMenus(cfg, opts = {}) {
|
|
1506
|
+
// 1. 缓存命中
|
|
1507
|
+
if (!opts.refresh && !opts.noCache) {
|
|
1508
|
+
const cache = readCubeCache(cfg);
|
|
1509
|
+
if (cache && !isCacheStale(cache) && Array.isArray(cache.entries) && cache.entries.length > 0) {
|
|
1510
|
+
return { entries: cache.entries, source: cache.source || 'catalog', count: cache.entries.length, fetchedAt: cache.fetchedAt, fromCache: true };
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
// 2. 路径1:目录接口(主源,台账 list_name)
|
|
1514
|
+
let entries = [];
|
|
1515
|
+
let source = 'none';
|
|
1516
|
+
let total = null;
|
|
1517
|
+
let error = null;
|
|
1518
|
+
try {
|
|
1519
|
+
const catalog = await fetchCubeCatalog(cfg);
|
|
1520
|
+
if (catalog.entries && catalog.entries.length > 0) {
|
|
1521
|
+
entries = catalog.entries;
|
|
1522
|
+
source = 'catalog';
|
|
1523
|
+
total = catalog.total;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
catch (e) {
|
|
1527
|
+
error = e.message;
|
|
1528
|
+
}
|
|
1529
|
+
// 3. 路径2:菜单兜底(目录空/失败时;其 entry 已自带 menus)
|
|
1530
|
+
if (entries.length === 0) {
|
|
1531
|
+
try {
|
|
1532
|
+
const menu = await fetchCubeMenusFromMenu(cfg);
|
|
1533
|
+
if (menu.entries && menu.entries.length > 0) {
|
|
1534
|
+
entries = menu.entries;
|
|
1535
|
+
source = 'menu';
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
catch (e) {
|
|
1539
|
+
error = [error, e.message].filter(Boolean).join(';');
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
if (entries.length === 0) {
|
|
1543
|
+
return { entries: [], source: 'none', count: 0, fetchedAt: null, fromCache: false, error: error || '目录与菜单均返回空,账号可能无台账权限' };
|
|
1544
|
+
}
|
|
1545
|
+
// 4. ★菜单名索引:catalog 来源缺菜单节点名,best-effort 拉门户菜单树补 menus(menu 来源已自带,跳过避免重复请求)
|
|
1546
|
+
if (source !== 'menu') {
|
|
1547
|
+
try {
|
|
1548
|
+
const mm = await fetchMenuMounts(cfg);
|
|
1549
|
+
if (mm && mm.byId) {
|
|
1550
|
+
for (const e of entries) {
|
|
1551
|
+
e.menus = mm.byId[e.id] || [];
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
catch { /* 菜单名索引失败不阻断,entries 照常返回(无 menus) */ }
|
|
1556
|
+
}
|
|
1557
|
+
// 5. 回写缓存 + 返回
|
|
1558
|
+
writeCubeCache(cfg, entries, source);
|
|
1559
|
+
return { entries, source, count: entries.length, fetchedAt: new Date().toISOString(), fromCache: false, total };
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* 按名模糊匹配台账(列候选,按相关度排序,不自动取首条)。
|
|
1563
|
+
* 匹配源:台账 list_name 为主、mode_name/app_name 为辅;菜单名索引(entry.menus)另路匹配——
|
|
1564
|
+
* 菜单节点名可能与台账名不符(如菜单「评级项」挂台账「供应商分类定义-评级项定义」),按菜单名也能搜到。
|
|
1565
|
+
* 返回候选数组,每项含 score 与 matchNote(台账:精确/前缀/包含/名称被包含/模块·应用;菜单:菜单名·精确/前缀/包含、菜单路径·包含)。
|
|
1566
|
+
*/
|
|
1567
|
+
export function findCubeByName(entries, query) {
|
|
1568
|
+
const q = String(query || '').trim().toLowerCase();
|
|
1569
|
+
if (!q || !Array.isArray(entries))
|
|
1570
|
+
return [];
|
|
1571
|
+
const qNorm = q.replace(/台账$/, '').trim() || q;
|
|
1572
|
+
const cands = [];
|
|
1573
|
+
for (const e of entries) {
|
|
1574
|
+
const name = String(e.list_name || '').trim().toLowerCase();
|
|
1575
|
+
const mode = String(e.mode_name || '').trim().toLowerCase();
|
|
1576
|
+
const app = String(e.app_name || '').trim().toLowerCase();
|
|
1577
|
+
let score = 0;
|
|
1578
|
+
let note = '';
|
|
1579
|
+
// 台账名 / 模块·应用 匹配
|
|
1580
|
+
if (name === q || name === qNorm) {
|
|
1581
|
+
score = 100;
|
|
1582
|
+
note = '精确';
|
|
1583
|
+
}
|
|
1584
|
+
else if (name.startsWith(qNorm)) {
|
|
1585
|
+
score = 80;
|
|
1586
|
+
note = '前缀';
|
|
1587
|
+
}
|
|
1588
|
+
else if (name.includes(qNorm)) {
|
|
1589
|
+
score = 60;
|
|
1590
|
+
note = '包含';
|
|
1591
|
+
}
|
|
1592
|
+
else if (qNorm.includes(name)) {
|
|
1593
|
+
score = 40;
|
|
1594
|
+
note = '名称被包含';
|
|
1595
|
+
}
|
|
1596
|
+
else if ((mode && mode.includes(qNorm)) || (app && app.includes(qNorm))) {
|
|
1597
|
+
score = 20;
|
|
1598
|
+
note = '模块·应用';
|
|
1599
|
+
}
|
|
1600
|
+
// ★菜单名 / 菜单路径 匹配(用户按门户菜单名找台账:菜单节点名可能与台账名不同)
|
|
1601
|
+
const menus = Array.isArray(e.menus) ? e.menus : [];
|
|
1602
|
+
for (const mu of menus) {
|
|
1603
|
+
const mname = String(mu.name || '').trim().toLowerCase();
|
|
1604
|
+
const mpath = String(mu.path || '').trim().toLowerCase();
|
|
1605
|
+
let ms = 0;
|
|
1606
|
+
let mn = '';
|
|
1607
|
+
if (mname && (mname === q || mname === qNorm)) {
|
|
1608
|
+
ms = 90;
|
|
1609
|
+
mn = '菜单名·精确';
|
|
1610
|
+
}
|
|
1611
|
+
else if (mname && mname.startsWith(qNorm)) {
|
|
1612
|
+
ms = 70;
|
|
1613
|
+
mn = '菜单名·前缀';
|
|
1614
|
+
}
|
|
1615
|
+
else if (mname && mname.includes(qNorm)) {
|
|
1616
|
+
ms = 50;
|
|
1617
|
+
mn = '菜单名·包含';
|
|
1618
|
+
}
|
|
1619
|
+
else if (mpath && mpath.includes(qNorm)) {
|
|
1620
|
+
ms = 30;
|
|
1621
|
+
mn = '菜单路径·包含';
|
|
1622
|
+
}
|
|
1623
|
+
if (ms > score) {
|
|
1624
|
+
score = ms;
|
|
1625
|
+
note = mn;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
if (score > 0)
|
|
1629
|
+
cands.push({ ...e, score, matchNote: note });
|
|
1630
|
+
}
|
|
1631
|
+
cands.sort((a, b) => b.score - a.score);
|
|
1632
|
+
return cands;
|
|
1633
|
+
}
|