bdip-office-assistant-cli 0.0.0-stage → 1.0.1

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.
Files changed (63) hide show
  1. package/README.md +37 -2
  2. package/dist/accounts.js +30 -0
  3. package/dist/args.js +36 -0
  4. package/dist/auth-token.js +783 -0
  5. package/dist/auth.js +331 -0
  6. package/dist/blog.js +562 -0
  7. package/dist/browser-links.js +224 -0
  8. package/dist/captcha-display.js +30 -0
  9. package/dist/cli.js +5533 -0
  10. package/dist/client.js +254 -0
  11. package/dist/config.js +170 -0
  12. package/dist/createcontext.js +41 -0
  13. package/dist/crypto.js +139 -0
  14. package/dist/cube.js +1771 -0
  15. package/dist/documents.js +98 -0
  16. package/dist/email.js +841 -0
  17. package/dist/field-preparation.js +237 -0
  18. package/dist/fileutil.js +20 -0
  19. package/dist/formula-runtime.js +55 -0
  20. package/dist/hrm.js +478 -0
  21. package/dist/keepalive.js +99 -0
  22. package/dist/kq.js +280 -0
  23. package/dist/linkage-calculations.js +68 -0
  24. package/dist/links.js +34 -0
  25. package/dist/meetings.js +172 -0
  26. package/dist/nodemap.js +242 -0
  27. package/dist/odoc.js +199 -0
  28. package/dist/office-tools.js +155 -0
  29. package/dist/pageopen.js +29 -0
  30. package/dist/parse.js +177 -0
  31. package/dist/password-input.js +84 -0
  32. package/dist/password-login.js +208 -0
  33. package/dist/person-selection.js +81 -0
  34. package/dist/querystate.js +38 -0
  35. package/dist/render.js +21 -0
  36. package/dist/report.js +50 -0
  37. package/dist/session-worker.js +54 -0
  38. package/dist/session.js +280 -0
  39. package/dist/submissionplan.js +45 -0
  40. package/dist/submissionstate.js +81 -0
  41. package/dist/subordinate.js +458 -0
  42. package/dist/supervise.js +152 -0
  43. package/dist/todoaskidea.js +54 -0
  44. package/dist/todobatch.js +172 -0
  45. package/dist/todocc.js +128 -0
  46. package/dist/todoforward.js +66 -0
  47. package/dist/todoreject.js +215 -0
  48. package/dist/todosubmit.js +325 -0
  49. package/dist/todotransfer.js +54 -0
  50. package/dist/util.js +25 -0
  51. package/dist/wfagent.js +538 -0
  52. package/dist/wfcreate.js +102 -0
  53. package/dist/wfform.js +169 -0
  54. package/dist/wfops.js +40 -0
  55. package/dist/wfreceiver.js +43 -0
  56. package/dist/wfstart.js +558 -0
  57. package/dist/wfsummary.js +89 -0
  58. package/dist/wfupload.js +83 -0
  59. package/dist/wfuser.js +96 -0
  60. package/dist/workflow-runtime.js +177 -0
  61. package/dist/workflow.js +570 -0
  62. package/dist/workplan.js +431 -0
  63. package/package.json +29 -6
@@ -0,0 +1,242 @@
1
+ /**
2
+ * 流程节点名解析:nodeid → nodename。
3
+ *
4
+ * 背景:待办/已办/我发起列表服务端**只给当前节点的 id**——行字段 `currentnodeid` 的值形如 "2882",
5
+ * 且行里没有节点名(实测 `currentnodeidspan` 也只是 id 本身,整行不含 currentnodename/currentnodespan)。
6
+ * 要显示名字只能按流程另取节点表。
7
+ *
8
+ * 接口(2026-10-14 实测 e9pc.e-cology.com.cn):
9
+ * POST /api/workflow/test/getNodeInfo body: { workflowId }
10
+ * → { nodeInfo: [{id, name, type}], wfInfo: {workflowName, version}, authority, tabInfo, ... }
11
+ * - 只需 workflowId;文档里的 requestid/userId/type 都非必需(逐个试过)。
12
+ * - ⚠️ 路径必须带 `/api` 前缀:`/workflow/test/getNodeInfo`(无前缀)返回登录页 HTML。
13
+ * - ⚠️ 接口挂在「流程测试」模块下,非所有部署/版本都有 → **一律 fail-soft**:
14
+ * 拿不到就返回 null,调用方退回显示节点 id,绝不让列表整体报错。
15
+ * - `type`:0=发起节点 / 1=审批节点 / 3=归档节点。
16
+ *
17
+ * 缓存:`~/.bdip/node_map_<envkey>.json`,按 workflowId 存,TTL 30 天
18
+ * (节点表几乎不变;响应带 version,流程改版后重新拉取自然覆盖)。
19
+ * - `E9_NODEMAP_CACHE_DIR` 覆盖缓存目录(测试隔离用,避免污染真实 ~/.e9)。
20
+ * - `E9_NO_NODE_NAME=1` 或 CLI `--no-node-name` 整体停用解析。
21
+ */
22
+ import { createApiFromProfile } from './client.js';
23
+ import { endpointFor, profileNameFromBaseUrl } from './config.js';
24
+ import { stripHtml } from './parse.js';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import os from 'node:os';
28
+
29
+ /** 节点表缓存 TTL(天)。 */
30
+ export const NODEMAP_TTL_MS = (Number(process.env.E9_NODEMAP_TTL_DAYS ?? '30') || 30) * 24 * 3600 * 1000;
31
+ /** 并发拉取上限(一次列表页通常 ≤10 个流程,全量拉取路径可能几十个,别一次打满)。 */
32
+ const FETCH_CHUNK = 6;
33
+
34
+ function str(v) {
35
+ if (v === undefined || v === null || v === '')
36
+ return undefined;
37
+ return String(v);
38
+ }
39
+
40
+ /** 缓存目录(测试可用 E9_NODEMAP_CACHE_DIR 覆盖)。 */
41
+ export function nodeMapCacheDir() {
42
+ return process.env.E9_NODEMAP_CACHE_DIR || path.join(os.homedir(), '.bdip');
43
+ }
44
+ /** 缓存文件路径:~/.bdip/node_map_<envkey>.json(按环境隔离,nodeid 不跨环境通用)。 */
45
+ export function nodeMapCachePath(cfg) {
46
+ const baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
47
+ return path.join(nodeMapCacheDir(), `node_map_${profileNameFromBaseUrl(baseUrl)}.json`);
48
+ }
49
+
50
+ /** 是否停用节点名解析(E9_NO_NODE_NAME=1)。 */
51
+ export function isNodeNameDisabled() {
52
+ return process.env.E9_NO_NODE_NAME === '1';
53
+ }
54
+
55
+ /**
56
+ * 纯函数:把 getNodeInfo 响应归一化为 { workflowName, version, nodes:{id:name} }。
57
+ * 结构不符(登录页/空表/非 JSON)返回 null——调用方据此 fail-soft。
58
+ */
59
+ export function parseNodeInfo(json) {
60
+ const list = json?.nodeInfo;
61
+ if (!Array.isArray(list))
62
+ return null;
63
+ const nodes = {};
64
+ for (const n of list) {
65
+ const id = str(n?.id);
66
+ if (!id)
67
+ continue;
68
+ nodes[id] = stripHtml(n?.name) || id;
69
+ }
70
+ if (Object.keys(nodes).length === 0)
71
+ return null;
72
+ return {
73
+ workflowName: str(json?.wfInfo?.workflowName),
74
+ version: str(json?.wfInfo?.version),
75
+ nodes,
76
+ };
77
+ }
78
+
79
+ /** 读缓存文件;坏文件/不存在返回 {}。 */
80
+ function readCache(cfg) {
81
+ try {
82
+ const p = nodeMapCachePath(cfg);
83
+ if (!fs.existsSync(p))
84
+ return {};
85
+ const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
86
+ return raw && typeof raw === 'object' && raw.workflows ? raw.workflows : {};
87
+ }
88
+ catch {
89
+ return {};
90
+ }
91
+ }
92
+
93
+ /** 写回缓存(单流程);写入失败不阻断(节点名只是锦上添花)。 */
94
+ function writeCache(cfg, workflowId, entry) {
95
+ try {
96
+ const p = nodeMapCachePath(cfg);
97
+ const workflows = readCache(cfg);
98
+ workflows[workflowId] = entry;
99
+ const baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
100
+ fs.mkdirSync(path.dirname(p), { recursive: true });
101
+ fs.writeFileSync(p, JSON.stringify({
102
+ envkey: profileNameFromBaseUrl(baseUrl),
103
+ baseUrl,
104
+ updatedAt: new Date().toISOString(),
105
+ workflows,
106
+ }, null, 2) + '\n', 'utf8');
107
+ }
108
+ catch { /* 忽略:缓存写失败不影响本次结果 */ }
109
+ }
110
+
111
+ function isStale(entry) {
112
+ if (!entry || !entry.fetchedAt)
113
+ return true;
114
+ return Date.now() - new Date(entry.fetchedAt).getTime() > NODEMAP_TTL_MS;
115
+ }
116
+
117
+ /**
118
+ * 取某流程的节点表(带缓存)。任何失败返回 null(fail-soft,不抛)。
119
+ * @returns {Promise<{workflowName?:string, version?:string, nodes:Record<string,string>}|null>}
120
+ */
121
+ export async function getNodeMap(cfg, workflowId, opts = {}) {
122
+ const wfId = str(workflowId);
123
+ if (!wfId)
124
+ return null;
125
+ const cached = readCache(cfg)[wfId];
126
+ if (!opts.refresh && cached && !isStale(cached) && cached.nodes)
127
+ return { workflowName: cached.workflowName, version: cached.version, nodes: cached.nodes };
128
+ const api = opts.api ?? await createApiFromProfile(cfg);
129
+ let json = null;
130
+ try {
131
+ const r = await api.post('/api/workflow/test/getNodeInfo', { workflowId: wfId });
132
+ json = r?.json ?? null;
133
+ }
134
+ catch {
135
+ json = null; // 网络/鉴权异常同上:降级,不打断列表
136
+ }
137
+ const parsed = parseNodeInfo(json);
138
+ if (!parsed)
139
+ return null;
140
+ writeCache(cfg, wfId, { ...parsed, workflowId: wfId, fetchedAt: new Date().toISOString() });
141
+ return parsed;
142
+ }
143
+
144
+ /**
145
+ * 列出**本地缓存**里所有流程的节点表(供「按节点名跨流程解析」用)。
146
+ * 缓存来自历次列表查询;从没查过的流程不在其中。
147
+ */
148
+ export function listCachedNodeMaps(cfg) {
149
+ const workflows = readCache(cfg);
150
+ return Object.entries(workflows)
151
+ .filter(([, w]) => w && w.nodes && Object.keys(w.nodes).length)
152
+ .map(([workflowId, w]) => ({ workflowId: String(workflowId), workflowName: w.workflowName, nodes: w.nodes }));
153
+ }
154
+
155
+ /**
156
+ * 按**节点名**解析节点 id(供 CLI `--node-name`)。
157
+ *
158
+ * 节点 id 是**流程内编号**(每个流程一套),故解析必须先限定流程范围:
159
+ * - 给了 `workflowId`:只在该流程的节点表内找(按需拉取,不依赖缓存);
160
+ * - 未给:在**本地缓存**的所有流程节点表内找(缓存来自历次列表查询)。
161
+ *
162
+ * 匹配规则:**先精确同名**;无精确才做模糊(互相包含)。多候选一律**全部返回**,
163
+ * 由调用方列给用户选——严禁自动取首条(本仓库硬规则)。
164
+ *
165
+ * @returns {Promise<{candidates:Array<{workflowId,workflowName,nodeId,nodeName,matchType}>,
166
+ * exact:Array, scoped:boolean, poolSize:number, unavailable:boolean}>}
167
+ * `unavailable`:限定了流程但节点表取不到(接口不存在/异常)——与"确实没有同名节点"要区分开。
168
+ */
169
+ export async function resolveNodeByName(cfg, name, opts = {}) {
170
+ const want = String(name ?? '').trim();
171
+ const empty = { candidates: [], exact: [], scoped: Boolean(opts.workflowId), poolSize: 0, unavailable: false };
172
+ if (!want)
173
+ return empty;
174
+ let pool = [];
175
+ if (opts.workflowId) {
176
+ const m = await getNodeMap(cfg, opts.workflowId, opts);
177
+ if (!m)
178
+ return { ...empty, unavailable: true };
179
+ pool = [{ workflowId: String(opts.workflowId), workflowName: m.workflowName, nodes: m.nodes }];
180
+ }
181
+ else {
182
+ pool = listCachedNodeMaps(cfg);
183
+ }
184
+ const candidates = [];
185
+ for (const w of pool) {
186
+ for (const [nodeId, nodeName] of Object.entries(w.nodes)) {
187
+ if (!nodeName)
188
+ continue;
189
+ const isExact = nodeName === want;
190
+ const isFuzzy = !isExact && (nodeName.includes(want) || want.includes(nodeName));
191
+ if (isExact || isFuzzy)
192
+ candidates.push({ workflowId: w.workflowId, workflowName: w.workflowName, nodeId, nodeName, matchType: isExact ? 'exact' : 'fuzzy' });
193
+ }
194
+ }
195
+ return {
196
+ candidates,
197
+ exact: candidates.filter((c) => c.matchType === 'exact'),
198
+ scoped: Boolean(opts.workflowId),
199
+ poolSize: pool.length,
200
+ unavailable: false,
201
+ };
202
+ }
203
+
204
+ /** 从列表行取流程 id(normalizeRow 归一字段优先,回退原始行)。 */
205
+ function workflowIdOf(item) {
206
+ return str(item?.workflowId) ?? str(item?.raw?.workflowid) ?? str(item?.raw?.workflowId);
207
+ }
208
+
209
+ /**
210
+ * 给列表行补 `nodeName`(就地写回)。返回补上的行数。
211
+ *
212
+ * - 行需带 `node`(节点 id);流程 id 取 `workflowId` 或 `raw.workflowid`。
213
+ * - 按流程去重,每个流程只请求一次;同一流程的多行共用一张表。
214
+ * - 任何失败都不抛:拿不到节点表的行保持原样(渲染层回退显示 id)。
215
+ */
216
+ export async function annotateNodeNames(cfg, items, opts = {}) {
217
+ if (!Array.isArray(items) || items.length === 0)
218
+ return 0;
219
+ if (opts.disabled)
220
+ return 0;
221
+ // 只对「node 是纯数字 id」的行解析:服务端不同列表的 span 给的东西不一样——
222
+ // 待办/公文给的是**节点 id**(需要解析),流程代理办理记录给的是**节点名**(解析无从匹配,纯浪费请求)。
223
+ const rows = items.filter((it) => /^\d+$/.test(str(it?.node) ?? '') && workflowIdOf(it));
224
+ if (rows.length === 0)
225
+ return 0;
226
+ const wfIds = [...new Set(rows.map(workflowIdOf))];
227
+ const maps = new Map();
228
+ for (let i = 0; i < wfIds.length; i += FETCH_CHUNK) {
229
+ const chunk = wfIds.slice(i, i + FETCH_CHUNK);
230
+ const got = await Promise.all(chunk.map((w) => getNodeMap(cfg, w, opts)));
231
+ chunk.forEach((w, idx) => maps.set(w, got[idx]));
232
+ }
233
+ let filled = 0;
234
+ for (const it of rows) {
235
+ const name = maps.get(workflowIdOf(it))?.nodes?.[str(it.node)];
236
+ if (name) {
237
+ it.nodeName = name;
238
+ filled++;
239
+ }
240
+ }
241
+ return filled;
242
+ }
package/dist/odoc.js ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * BDIP5 公文(odoc)模块。
3
+ *
4
+ * 抓包+实跑确认的接口(测试环境):
5
+ * POST /api/odoc/odocCustomPage/getCustomPageList 公文待办/待阅列表入口(拿 dataKey,customListId/customTabId 定列表与 tab)
6
+ * POST /api/ec/dev/table/datas 取列+行(同待办 table 链路第 2 步)
7
+ * POST /api/ec/dev/table/counts 取总数(同待办 table 链路第 3 步)
8
+ * POST /api/odoc/odocTodoList/getTodoCount 待办/待阅计数(发文/收文/签报,按 officalType 分)
9
+ * POST /api/odoc/odocCenter/createOfficalList 发文/收文流程列表(officalType=1 发文拟稿 / 2 收文登记,结构同 create 列表)
10
+ * POST /api/odoc/odocCenter/getCenterInfo 公文中心首页两个新建入口(发文拟稿/收文登记)
11
+ * POST /api/odoc/odocCenter/getCountByOdocType 按公文种类计数
12
+ * POST /api/odoc/odocCenter/getCountByTopicType 按主题分类计数
13
+ *
14
+ * 走当前 baseUrl(单一环境,所有功能同地址),aiAgentToken 鉴权(e9pc 不支持 token 时回退 cookie)。
15
+ */
16
+ import { createApiFromProfile } from './client.js';
17
+ import { pickByPaths, stripHtml, extractDataKey, extractColumns, extractRows, extractTotal } from './parse.js';
18
+ const DATERANGE_EMPTY = '0,,'; // type=0(不过滤), from 空, to 空
19
+ /**
20
+ * 公文待办/待阅列表。3 步 table 链路(POST,form-urlencoded),同待办/workplan-search:
21
+ * 1. /api/odoc/odocCustomPage/getCustomPageList(过滤项 + customListId/customTabId)→ dataKey(sessionkey 字段)
22
+ * 2. /api/ec/dev/table/datas(dataKey + current + sortParams=[])→ columns + 行
23
+ * 3. /api/ec/dev/table/counts(dataKey)→ 总数(失败不阻断)
24
+ * 行字段:原值 + `<字段>span` 渲染值(去 HTML)。
25
+ */
26
+ export async function listOdocTodo(cfg, opts = {}) {
27
+ const api = await createApiFromProfile(cfg);
28
+ const filters = {
29
+ btnType: opts.btnType ?? '1',
30
+ readstatus: opts.readStatus ?? '',
31
+ odocSubject: opts.odocSubject ?? '',
32
+ sendnode: '',
33
+ topictype: opts.topicType ?? '',
34
+ secretlevel: opts.secretLevel ?? '',
35
+ send_unit: opts.sendUnit ?? '',
36
+ issueduserid: opts.issuedUserid ?? '',
37
+ writtendateselect: opts.writtenDateSelect ?? DATERANGE_EMPTY,
38
+ ownerid: opts.ownerId ?? '',
39
+ createdateselect: opts.createDateSelect ?? DATERANGE_EMPTY,
40
+ lastmodifydateselect: opts.lastModifyDateSelect ?? DATERANGE_EMPTY,
41
+ dealinguserid: opts.dealingUserid ?? '',
42
+ undealeduserid: opts.undealedUserid ?? '',
43
+ docabstract: opts.docAbstract ?? '',
44
+ opendateselect: opts.openDateSelect ?? DATERANGE_EMPTY,
45
+ deadlineselect: opts.deadlineSelect ?? DATERANGE_EMPTY,
46
+ receivenum: opts.receiveNum ?? '',
47
+ receivedateselect: opts.receiveDateSelect ?? DATERANGE_EMPTY,
48
+ workflowid: opts.workflowId ?? '',
49
+ requestname: opts.requestName ?? '',
50
+ dispatchnum: opts.dispatchNum ?? '',
51
+ urgencydegree: opts.urgencyDegree ?? '',
52
+ receive_unit: opts.receiveUnit ?? '',
53
+ issueddateselect: opts.issuedDateSelect ?? DATERANGE_EMPTY,
54
+ departmentid: opts.departmentId ?? '',
55
+ odoctype: opts.odocType ?? '',
56
+ customListId: opts.customListId ?? '-12',
57
+ customTabId: opts.customTabId ?? '-24',
58
+ };
59
+ const r1 = await api.post('/api/odoc/odocCustomPage/getCustomPageList', filters);
60
+ const dataKey = extractDataKey(r1.json);
61
+ if (!dataKey) {
62
+ throw new Error(`odoc list 未拿到 dataKey,getCustomPageList 响应: ${(r1.text ?? '').slice(0, 200)}`);
63
+ }
64
+ const page = opts.page ?? 1;
65
+ const r2 = await api.post('/api/ec/dev/table/datas', {
66
+ dataKey,
67
+ current: String(page),
68
+ sortParams: '[]',
69
+ // table/datas 认 pageSize(同待办链路):显式 10 条/页,避免服务端默认 30 条/页导致
70
+ // --page N 按 30 条跳页、本页第 11~30 条看不到的错位。
71
+ pageSize: opts.pageSize ?? '10',
72
+ });
73
+ const columns = extractColumns(r2.json);
74
+ const rows = extractRows(r2.json);
75
+ const items = rows.map((r) => normalizeOdocRow(r)).filter((it) => it.requestId || it.requestName);
76
+ const pageSize = pickByPaths(r2.json, ['pageSize', 'data.pageSize']);
77
+ let total = null;
78
+ let countsResp = null;
79
+ try {
80
+ const r3 = await api.post('/api/ec/dev/table/counts', { dataKey });
81
+ countsResp = r3.json;
82
+ total = extractTotal(r3.json);
83
+ }
84
+ catch {
85
+ // counts 失败不阻断
86
+ }
87
+ return {
88
+ dataKey,
89
+ page,
90
+ total,
91
+ pageSize: typeof pageSize === 'number' ? pageSize : null,
92
+ columns,
93
+ items,
94
+ raw: { getCustomPageList: r1.json, datas: r2.json, counts: countsResp },
95
+ };
96
+ }
97
+ /** 解析公文待办表行(原值 + span 渲染值)。 */
98
+ export function normalizeOdocRow(row) {
99
+ if (!row || typeof row !== 'object')
100
+ return {};
101
+ const get = (keys) => pickByPaths(row, keys);
102
+ const spanOr = (dataIndex, extraKeys = []) => stripHtml(get([`${dataIndex}span`, dataIndex, ...extraKeys]));
103
+ return {
104
+ requestId: stringify(get(['requestid', 'randomField2'])),
105
+ requestName: stripHtml(get(['requestnamespan'])) || stringify(get(['requestname'])),
106
+ odocType: stringify(get(['odoctype'])),
107
+ odocTypeName: stripHtml(get(['odoctypespan'])),
108
+ issuedNum: stringify(get(['issued_num', 'issued_numspan'])),
109
+ receiveNum: stripHtml(get(['receivenumspan'])) || stringify(get(['receivenum'])),
110
+ topicType: stringify(get(['topictype'])),
111
+ urgencyDegree: stringify(get(['urgencydegree'])),
112
+ secretLevel: stripHtml(get(['secretlevelspan'])) || stringify(get(['secretlevel'])),
113
+ issuedUnit: stringify(get(['issued_unit'])),
114
+ receiveUnit: stripHtml(get(['receiveunitspan'])) || stringify(get(['receiveunit'])),
115
+ issuedUserid: stripHtml(get(['issued_useridspan'])) || stringify(get(['issued_userid'])),
116
+ writtenDate: stripHtml(get(['written_datespan'])) || stringify(get(['written_date'])),
117
+ issuedDate: stringify(get(['issued_date'])),
118
+ createrId: stripHtml(get(['createridspan'])) || stringify(get(['createrid', 'creater'])),
119
+ createDate: stringify(get(['createdate'])),
120
+ sendDepartment: stringify(get(['senddepartment'])),
121
+ lastOperateDate: stringify(get(['lastoperatedate'])),
122
+ // ⚠️ BDIP5 公文列表这两列的**字段名与内容是反的**(2026-10-14 实测 wf=552 发文):
123
+ // currentnodeidspan → "拟稿"(公文环节名,是标签不是 id)
124
+ // currentnodespan → "2868"(真正的**节点 id**,用 getNodeInfo 可解析出节点名「拟稿发起」)
125
+ // 故不再沿用误导性的原名,改为语义正确的 stageName / node;nodeName 由 nodemap 回填。
126
+ stageName: stripHtml(get(['currentnodeidspan'])) || stringify(get(['currentnodeid'])),
127
+ node: stripHtml(get(['currentnodespan'])) || stringify(get(['currentnode'])),
128
+ nodeName: undefined,
129
+ zbr: stringify(get(['zbr'])),
130
+ wbr: stringify(get(['wbr'])),
131
+ docAbstract: stripHtml(get(['docabstractspan'])) || stringify(get(['docabstract'])),
132
+ deadline: stripHtml(get(['deadlinespan'])) || stringify(get(['deadline'])),
133
+ openDate: stripHtml(get(['opendatespan'])) || stringify(get(['opendate'])),
134
+ receiveDate: stripHtml(get(['receivedatespan'])) || stringify(get(['receivedate'])),
135
+ operateDate: stripHtml(get(['operatedatespan'])) || stringify(get(['operatedate'])),
136
+ workflowId: stringify(get(['workflowid', 'randomField0'])),
137
+ raw: row,
138
+ };
139
+ }
140
+ /**
141
+ * 公文待办/待阅计数。POST /api/odoc/odocTodoList/getTodoCount。
142
+ * - view=false(默认):待办计数(iscomplete=0)
143
+ * - view=true:待阅计数(isview=0)
144
+ * 返回 datalist:[{officalType, title, count}](发文/收文/签报)。
145
+ */
146
+ export async function getOdocTodoCount(cfg, opts = {}) {
147
+ const api = await createApiFromProfile(cfg);
148
+ const body = opts.view
149
+ ? { isview: '0', isNew: 'false' }
150
+ : { iscomplete: '0', isNew: 'false' };
151
+ const r = await api.post('/api/odoc/odocTodoList/getTodoCount', body);
152
+ const j = r.json || {};
153
+ const arr = pickByPaths(j, ['datalist', 'data.datalist']) ?? [];
154
+ const items = (Array.isArray(arr) ? arr : [])
155
+ .map((d) => ({
156
+ officalType: stringify(d?.officalType) ?? '',
157
+ title: stringify(d?.title) ?? '',
158
+ count: typeof d?.count === 'number' ? d.count : Number(d?.count ?? 0),
159
+ }))
160
+ .filter((it) => it.title);
161
+ return { view: !!opts.view, items, raw: j };
162
+ }
163
+ /**
164
+ * 发文/收文流程列表。POST /api/odoc/odocCenter/createOfficalList。
165
+ * - officalType=1(默认):发文拟稿
166
+ * - officalType=2:收文登记
167
+ * 返回 datas[{typeName, id, wfCount, wfbeans[]}],结构同 create 列表,
168
+ * 故"新建公文"可复用 create start(wfstart.ts)的发起链路。
169
+ */
170
+ export async function listOdocCreate(cfg, opts = {}) {
171
+ const api = await createApiFromProfile(cfg);
172
+ const officalType = opts.officalType ?? '1';
173
+ const r = await api.post('/api/odoc/odocCenter/createOfficalList', { officalType });
174
+ const j = r.json || {};
175
+ const arr = pickByPaths(j, ['datas', 'data.datas']) ?? [];
176
+ const toplabel = stringify(pickByPaths(j, ['createwflabels.toplabel']));
177
+ const categories = (Array.isArray(arr) ? arr : [])
178
+ .map((d) => ({
179
+ id: stringify(d?.id),
180
+ name: stringify(d?.typeName),
181
+ wfCount: typeof d?.wfCount === 'number' ? d.wfCount : Number(d?.wfCount ?? 0),
182
+ workflows: (Array.isArray(d?.wfbeans) ? d.wfbeans : [])
183
+ .map((w) => ({
184
+ id: stringify(w?.id),
185
+ name: stringify(w?.name),
186
+ typeId: stringify(w?.typeId),
187
+ spell: stringify(w?.spell),
188
+ letter: stringify(w?.letter),
189
+ }))
190
+ .filter((w) => w.id || w.name),
191
+ }))
192
+ .filter((c) => c.workflows.length > 0);
193
+ return { officalType, toplabel, categories, raw: j };
194
+ }
195
+ function stringify(v) {
196
+ if (v === undefined || v === null || v === '')
197
+ return undefined;
198
+ return String(v);
199
+ }
@@ -0,0 +1,155 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+ import {pathToFileURL, fileURLToPath} from 'node:url';
5
+
6
+ export async function accounts(api) {
7
+ const r = await api.get('/api/hrm/login/getAccountList', {__random__: Date.now()}, {forceCookie: true});
8
+ const rows = r.json?.data?.accountlist;
9
+ if (!Array.isArray(rows) || !rows.length) throw Error('未取得关联账号,请检查登录状态');
10
+ const items = rows.map(a => ({id: String(a.userid), name: a.username,
11
+ divisionId: a.subcompanyid == null ? null : String(a.subcompanyid),
12
+ division: a.subcompanyname || '所属分部未返回(请核实)', department: a.deptname || '',
13
+ current: String(a.iscurrent) === '1'}));
14
+ if (items.filter(a => a.current).length !== 1) throw Error('未能确认当前账号');
15
+ return items;
16
+ }
17
+
18
+ export async function shift(api, id) {
19
+ const before = await accounts(api);
20
+ if (!before.some(a => a.id === id)) throw Error('目标不在关联账号列表中');
21
+ if (!before.find(a => a.id === id).current) {
22
+ await api.post('/api/hrm/login/identityShift', {shiftid: id}, {forceCookie: true});
23
+ }
24
+ const after = await accounts(api);
25
+ if (!after.find(a => a.id === id)?.current) throw Error('切换未验证成功,停止后续业务操作');
26
+ return after;
27
+ }
28
+
29
+ export async function main(argv = process.argv.slice(2)) {
30
+ const [command, ...rawArgs] = argv;
31
+ const scopeIndex = rawArgs.indexOf('--person-scopes');
32
+ if (scopeIndex >= 0 && !rawArgs[scopeIndex + 1]) throw Error('--person-scopes 需要 JSON 文件路径');
33
+ const args = scopeIndex < 0 ? rawArgs : rawArgs.filter((_, i) => i !== scopeIndex && i !== scopeIndex + 1);
34
+ const personScopes = scopeIndex < 0 ? undefined : JSON.parse(fs.readFileSync(rawArgs[scopeIndex + 1], 'utf8'));
35
+ if (!command || command === 'help') {
36
+ console.log('meeting fields|view|tasks|preview|run|verify | doc directories|list|init|upload | accounts | after-login | switch <账号ID> | default-set <账号ID> | default-clear | draft-preview <流程ID> <值文件> <标题> <计划文件> [明细文件] [--person-scopes <JSON文件>] | draft-save <计划文件> | draft-verify <计划文件> | draft-submit <计划文件> --confirm | draft-update-preview <流程ID> <值文件> <标题> <新计划文件> <明细文件或-> <原requestId> | browser-options <流程ID> <字段ID> [上下文文件] | adapter-register <流程ID> <脚本文件> <映射文件>');
37
+ return;
38
+ }
39
+ const dist = path.dirname(fileURLToPath(import.meta.url));
40
+ const load = name => import(pathToFileURL(path.join(dist, name + '.js')).href);
41
+ const {loadConfig} = await load('config');
42
+ const {createApiFromProfile} = await load('client');
43
+ const cfg = loadConfig(), api = await createApiFromProfile(cfg);
44
+ const list = await accounts(api);
45
+ const accountId = list.find(a => a.current).id;
46
+ // 独立偏好文件,避免工具写配置时丢失;按环境和关联账号组隔离。
47
+ const prefFile = path.join(path.dirname(cfg._configPath), 'office-project-defaults.json');
48
+ const scope = createHash('sha256').update(cfg.baseUrl + '\n' + list.map(a => a.id).sort().join(',')).digest('hex');
49
+ const prefs = fs.existsSync(prefFile) ? JSON.parse(fs.readFileSync(prefFile, 'utf8')) : {};
50
+ if (['accounts', 'after-login', 'switch', 'default-set', 'default-clear'].includes(command)) {
51
+ let result = list;
52
+ if (command === 'default-set') {
53
+ if (!list.some(a => a.id === args[0])) throw Error('默认账号必须属于当前关联账号列表');
54
+ prefs[scope] = args[0];
55
+ fs.writeFileSync(prefFile, JSON.stringify(prefs, null, 2));
56
+ }
57
+ if (command === 'default-clear') {
58
+ delete prefs[scope];
59
+ fs.writeFileSync(prefFile, JSON.stringify(prefs, null, 2));
60
+ }
61
+ if (command === 'switch') result = await shift(api, args[0]);
62
+ if (command === 'after-login' && prefs[scope]) result = await shift(api, prefs[scope]);
63
+ console.log(JSON.stringify({accounts: result, defaultAccountId: prefs[scope] || null,
64
+ prompt: prefs[scope] ? '已核验默认项目;是否需要切换其他项目?可以更改或取消默认项目。' : '是否切换项目(次账号)?可以设置默认项目,以后登录自动切换。'}, null, 2));
65
+ return;
66
+ }
67
+ if (command === 'meeting') {
68
+ const {meetingMain} = await load('meetings');
69
+ console.log(JSON.stringify(await meetingMain(api, args, accountId, cfg.baseUrl), null, 2));
70
+ return;
71
+ }
72
+ if (command === 'doc') {
73
+ const {documentMain} = await load('documents');
74
+ console.log(JSON.stringify(await documentMain(api, args, accountId, cfg), null, 2));
75
+ return;
76
+ }
77
+ const runtime = await load('workflow-runtime');
78
+ const preparation = await load('field-preparation');
79
+ const getForm = async (workflowId, requestId) => {
80
+ const r = await api.post('/api/workflow/reqform/loadForm', {workflowid:workflowId, iscreate:requestId?'0':'1', requestid:String(requestId??'-1')}, {forceCookie:true});
81
+ if(r.json?.params?.verifyRight!==true || String(r.json.params.currentUserid)!==accountId) throw Error('表单权限或账号不符');
82
+ return r.json;
83
+ };
84
+ if (command === 'browser-options') {
85
+ const [workflowId,fieldId,contextFile]=args;
86
+ const context=contextFile?JSON.parse(fs.readFileSync(contextFile,'utf8')):{};
87
+ context.baseUrl = cfg.baseUrl;
88
+ const form=await getForm(workflowId,context.requestId);
89
+ const adapter=await runtime.loadAdapter(cfg,workflowId,form);
90
+ if(adapter?.browserContext) Object.assign(context,await adapter.browserContext({form,fieldId,context,mapping:adapter.mapping}));
91
+ console.log(JSON.stringify(await runtime.queryBrowser(api,form,fieldId,context),null,2));return;
92
+ }
93
+ if (command === 'adapter-register') {
94
+ const [workflowId,source,mappingFile]=args;
95
+ runtime.registerAdapter(cfg,workflowId,await getForm(workflowId),source,JSON.parse(fs.readFileSync(mappingFile,'utf8')));
96
+ console.log('已登记本机适配;不随升级包分发');return;
97
+ }
98
+ if (command === 'draft-preview' || command === 'draft-update-preview') {
99
+ const [workflowId, valuesFile, title, planFile, detailsFile, requestId] = args;
100
+ if(!planFile || !title || fs.existsSync(planFile)) throw Error('参数不全或计划已存在');
101
+ if(command==='draft-update-preview' && !requestId)throw Error('更新原稿必须提供requestId');
102
+ const form=await getForm(workflowId,requestId);
103
+ const plan={workflowId,requestName:title,values:JSON.parse(fs.readFileSync(valuesFile,'utf8')),details:detailsFile&&detailsFile!=='-'?JSON.parse(fs.readFileSync(detailsFile,'utf8')):[],baseUrl:cfg.baseUrl,accountId,requestId};
104
+ if (personScopes) plan.personScopes = personScopes;
105
+ if(plan.values.values) { const input=plan.values; plan.values=input.values;plan.browserNames=input.browserNames??{};plan.detailBrowserNames=input.detailBrowserNames??{}; }
106
+ const prepared=await preparation.prepareFields(cfg,plan,{api,form});
107
+ plan.expected=prepared.expected;
108
+ plan.prepared={...prepared,body:Object.fromEntries(Object.entries(prepared.body).filter(([k])=>!/token|signature|UUID/i.test(k)))};
109
+ fs.writeFileSync(planFile,JSON.stringify(plan,null,2),{flag:'wx'});
110
+ console.log(JSON.stringify({state:'local-preview-only',...plan},null,2));return;
111
+ }
112
+ if(!['draft-save','draft-verify','draft-submit'].includes(command))throw Error('未知命令');
113
+ const planFile=args[0],receiptFile=planFile+'.receipt.json';
114
+ const plan=JSON.parse(fs.readFileSync(planFile,'utf8'));
115
+ if(plan.baseUrl!==cfg.baseUrl || plan.accountId!==accountId || !plan.expected)throw Error('计划环境/账号不符或旧版计划,请重新预览,不重复新建');
116
+ let receipt=fs.existsSync(receiptFile)?JSON.parse(fs.readFileSync(receiptFile,'utf8')):null;
117
+ const action=command==='draft-submit'?'submit':'save';
118
+ if(command==='draft-submit' && args[1]!=='--confirm')throw Error('正式提交需在完整预览后明确确认,使用 --confirm');
119
+ if(command==='draft-save'||command==='draft-submit') {
120
+ if(receipt)throw Error('已有保存回执,禁止重复执行,请先核验');
121
+ const form=await getForm(plan.workflowId,plan.requestId);
122
+ if(!plan.prepared)throw Error('旧计划缺少统一准备快照,请重新预览');
123
+ const prepared=plan.prepared;
124
+ await preparation.checkAdapter(cfg,plan,form);
125
+ const tables=await runtime.detailTables(api,form,plan.workflowId,plan.requestId);
126
+ preparation.checkBaseline(prepared,form,tables);
127
+ const body=preparation.actionBody(prepared,form,action);
128
+ const menu=await api.post('/api/workflow/reqform/rightMenu',{...form.params,...form.submitParams,isaffirmance:'0',isaffirmanceSrc:''},{forceCookie:true});
129
+ if(!menu.json?.rightMenus?.some(m=>m.systemMenuType===(action==='save'?'SAVE':'SUBMIT')))throw Error('没有保存权限');
130
+ receipt={state:'executing',action,requestId:plan.requestId};fs.writeFileSync(receiptFile,JSON.stringify(receipt),{flag:'wx'});
131
+ const saved=await api.post('/api/workflow/reqform/requestOperation',body,{forceCookie:true});
132
+ Object.assign(receipt,preparation.classifyWrite(saved,plan.requestId));
133
+ fs.writeFileSync(receiptFile,JSON.stringify(receipt,null,2));
134
+ if(receipt.state==='failed')throw Error('保存明确失败:'+JSON.stringify(receipt.messageInfo));
135
+ if(receipt.state==='uncertain')throw Error('保存结果不明,请核验,禁止重复新建');
136
+ }
137
+ if(!receipt?.requestId)throw Error('结果不明,禁止重复新建,请到我的请求核验');
138
+ const form=await getForm(plan.workflowId,receipt.requestId);
139
+ const tables=await runtime.detailTables(api,form,plan.workflowId,receipt.requestId);
140
+ if(receipt.state==='failed')throw Error('上次保存明确失败,不能将原稿回读冒充保存成功');
141
+ const problems=preparation.verifyAction(plan.expected,form,tables,receipt.action??'save');
142
+ const current=(await accounts(api)).find(a=>a.current);
143
+ if(current.id!==plan.accountId)problems.push('账号变化');
144
+ receipt.state=problems.length?'verification-required':receipt.action==='submit'?'submitted-verified':'draft-verified';receipt.problems=problems;
145
+ fs.writeFileSync(receiptFile,JSON.stringify(receipt,null,2));
146
+ const {buildRequestFormUrl}=await load('wfcreate');
147
+ // The business receipt is already durable; link issuance must never replay the write.
148
+ const {resolveBrowserLink} = await load('browser-links');
149
+ const link = await resolveBrowserLink(cfg, buildRequestFormUrl(cfg.baseUrl,receipt.requestId,plan.workflowId));
150
+ console.log(JSON.stringify({...receipt,...link},null,2));
151
+ if(problems.length)process.exitCode=1;
152
+ }
153
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
154
+ main().catch(e => {console.error(e.message); process.exitCode = 1;});
155
+ }
@@ -0,0 +1,29 @@
1
+ import { endpointFor } from './config.js';
2
+
3
+ const PAGE_MAP = {
4
+ center: '/wui/index.html#/main/workflow/index',
5
+ todo: '/wui/index.html#/main/workflow/doing',
6
+ done: '/wui/index.html#/main/workflow/done',
7
+ mine: '/wui/index.html#/main/workflow/mine',
8
+ draft: '/wui/index.html#/main/workflow/draft',
9
+ all: '/wui/index.html#/main/workflow/all',
10
+ newflow: '/spa/workflow/static4form/index.html#/main/workflow/req?iscreate=1',
11
+ monitor: '/wui/index.html#/main/workflow/monitor',
12
+ };
13
+
14
+ export function pageUrl(cfg, page, requestId) {
15
+ const base = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
16
+ if (page === 'view') {
17
+ if (!requestId)
18
+ throw new Error('打开流程详情需要 --request-id <请求 ID>。');
19
+ return `${base}/spa/workflow/static4form/index.html#/main/workflow/req?iscreate=0&requestid=${encodeURIComponent(requestId)}`;
20
+ }
21
+ const path = PAGE_MAP[page];
22
+ if (!path)
23
+ throw new Error(`不支持的页面“${page}”。可选:${Object.keys(PAGE_MAP).join('、')}、view。`);
24
+ return base + path;
25
+ }
26
+
27
+ export function availablePages() {
28
+ return [...Object.keys(PAGE_MAP), 'view'];
29
+ }