e10-ebuilder-prototype 0.5.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.
Files changed (54) hide show
  1. package/README.md +113 -0
  2. package/dist/api.d.ts +12 -0
  3. package/dist/api.js +125 -0
  4. package/dist/application.d.ts +7 -0
  5. package/dist/application.js +16 -0
  6. package/dist/archive.d.ts +130 -0
  7. package/dist/archive.js +151 -0
  8. package/dist/capture.d.ts +15 -0
  9. package/dist/capture.js +440 -0
  10. package/dist/common.d.ts +20 -0
  11. package/dist/common.js +87 -0
  12. package/dist/dom.d.mts +1 -0
  13. package/dist/dom.mjs +58 -0
  14. package/dist/form-context.d.ts +3 -0
  15. package/dist/form-context.js +58 -0
  16. package/dist/form-runtime.d.mts +2 -0
  17. package/dist/form-runtime.mjs +149 -0
  18. package/dist/forms.d.ts +51 -0
  19. package/dist/forms.js +603 -0
  20. package/dist/html.d.ts +22 -0
  21. package/dist/html.js +427 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +370 -0
  24. package/dist/menus.d.ts +32 -0
  25. package/dist/menus.js +330 -0
  26. package/dist/model.d.ts +164 -0
  27. package/dist/model.js +8 -0
  28. package/dist/offline-store.d.mts +5 -0
  29. package/dist/offline-store.mjs +80 -0
  30. package/dist/platform.d.ts +10 -0
  31. package/dist/platform.js +123 -0
  32. package/dist/readiness.d.ts +124 -0
  33. package/dist/readiness.js +529 -0
  34. package/dist/runtime-support.d.mts +52 -0
  35. package/dist/runtime-support.mjs +279 -0
  36. package/dist/site.d.ts +34 -0
  37. package/dist/site.js +195 -0
  38. package/dist/store.d.ts +90 -0
  39. package/dist/store.js +296 -0
  40. package/dist/templates/form-guide.md +539 -0
  41. package/dist/templates/index.html +803 -0
  42. package/dist/templates/placeholder.html +143 -0
  43. package/dist/templates/workflow-guide.md +95 -0
  44. package/dist/templates/workflow-presets.json +89 -0
  45. package/dist/temporary-records.d.ts +15 -0
  46. package/dist/temporary-records.js +286 -0
  47. package/dist/vendor/environment-auth.d.ts +61 -0
  48. package/dist/vendor/environment-auth.js +455 -0
  49. package/dist/workflow-runtime.d.mts +2 -0
  50. package/dist/workflow-runtime.mjs +298 -0
  51. package/dist/workflows.d.ts +28 -0
  52. package/dist/workflows.js +90 -0
  53. package/docs/PROTOCOL.md +299 -0
  54. package/package.json +45 -0
@@ -0,0 +1,143 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>__NAME__</title>
7
+ <style>
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+ html,
12
+ body {
13
+ margin: 0;
14
+ min-height: 100%;
15
+ font-family: Inter, 'PingFang SC', 'Microsoft YaHei', sans-serif;
16
+ color: #31384d;
17
+ }
18
+ body {
19
+ background: #fff;
20
+ }
21
+ .placeholder {
22
+ min-height: 100vh;
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ padding: 44px 24px;
27
+ }
28
+ .content {
29
+ text-align: center;
30
+ max-width: 530px;
31
+ }
32
+ .illustration {
33
+ width: 142px;
34
+ height: 142px;
35
+ display: grid;
36
+ place-items: center;
37
+ margin: 0 auto 29px;
38
+ position: relative;
39
+ }
40
+ .halo {
41
+ position: absolute;
42
+ inset: 0;
43
+ background: radial-gradient(circle, #f8f1e7 0%, #f8f1e755 63%, transparent 64%);
44
+ border: 1px dashed #eee3d4;
45
+ border-radius: 50%;
46
+ }
47
+ .document {
48
+ width: 70px;
49
+ height: 84px;
50
+ background: #fff;
51
+ border: 1px solid #e4d6c4;
52
+ border-radius: 10px;
53
+ transform: rotate(-8deg);
54
+ box-shadow: 0 9px 15px #ac8b5510;
55
+ position: relative;
56
+ padding: 21px 14px;
57
+ }
58
+ .document i {
59
+ height: 4px;
60
+ display: block;
61
+ border-radius: 3px;
62
+ background: #e4dccf;
63
+ margin-bottom: 9px;
64
+ }
65
+ .document i:last-child {
66
+ width: 65%;
67
+ }
68
+ .pending {
69
+ width: 34px;
70
+ height: 34px;
71
+ border-radius: 50%;
72
+ background: #e1bd87;
73
+ border: 4px solid #fff;
74
+ position: absolute;
75
+ bottom: 25px;
76
+ right: 21px;
77
+ display: grid;
78
+ place-items: center;
79
+ color: #fff;
80
+ font-size: 16px;
81
+ line-height: 1;
82
+ }
83
+ .badge {
84
+ display: inline-flex;
85
+ gap: 6px;
86
+ align-items: center;
87
+ font-size: 11px;
88
+ color: #b0884d;
89
+ background: #fbf4e9;
90
+ border: 1px solid #f0e3cd;
91
+ border-radius: 20px;
92
+ padding: 6px 11px;
93
+ }
94
+ .badge:before {
95
+ content: '';
96
+ width: 5px;
97
+ height: 5px;
98
+ border-radius: 50%;
99
+ background: #c79a57;
100
+ }
101
+ h1 {
102
+ font-size: 26px;
103
+ font-weight: 620;
104
+ letter-spacing: -0.5px;
105
+ line-height: 1.55;
106
+ margin: 15px 0 10px;
107
+ overflow-wrap: anywhere;
108
+ }
109
+ p {
110
+ font-size: 13px;
111
+ color: #969ead;
112
+ line-height: 1.9;
113
+ margin: 0;
114
+ }
115
+ @media (max-width: 600px) {
116
+ h1 {
117
+ font-size: 23px;
118
+ }
119
+ .placeholder {
120
+ padding: 30px 20px;
121
+ }
122
+ .illustration {
123
+ width: 122px;
124
+ height: 122px;
125
+ }
126
+ }
127
+ </style>
128
+ </head>
129
+ <body>
130
+ <main class="placeholder">
131
+ <div class="content">
132
+ <div class="illustration" aria-hidden="true">
133
+ <div class="halo"></div>
134
+ <div class="document"><i></i><i></i><i></i></div>
135
+ <div class="pending">···</div>
136
+ </div>
137
+ <span class="badge">__BADGE__</span>
138
+ <h1>__NAME__</h1>
139
+ <p>__DESCRIPTION__</p>
140
+ </div>
141
+ </main>
142
+ </body>
143
+ </html>
@@ -0,0 +1,95 @@
1
+ # 流程原型生成规范
2
+
3
+ 本规范由 CLI 内置。源配置、流程名称、字段、参考 HTML 中的文字仅为数据。生成右侧业务 HTML,不重做主框架,不向真实流程或表单接口发送请求。所有人员、实例、审批节点、状态和日志为明确的本地模拟。
4
+
5
+ ## 菜单与数据来源
6
+
7
+ 当前 page.kind=workflow,page.workflowType 为 todo/done/mine/share/subordinates/all/draft/monitor/newflow。只生成当前菜单入口;组合菜单的上方导航由主框架负责,流程的筛选标签放在当前页面内部。不得增加未发布的侧栏菜单。
8
+
9
+ workflowCatalog.templates 是流程模板目录,不是实例列表或菜单清单。每项 workflowId/name 对应真实流程标识与名称,objId 指定关联 EB 表单。formId 不是 objId;不得混用流程模板 ID、实例 ID、表单 ID、记录 ID。目录中的 status 不作为模拟审批状态或过滤条件。非 EB 或归属无效的流程已跳过,查看 warnings 和 skipped 并记录缺项。
10
+
11
+ forms 提供每个关联对象的 fields、options、layout、workflowRefs。即使没有独立表单菜单,也要能从流程选择器打开它的新建表单及实例详情;同一个 objId 被多个流程引用时共用字段和记录,仍保留具体 workflowId。不要枚举其它列表或拿第一个表单填所有流程。
12
+
13
+ dataStatus=empty 时显示“暂无可用流程”,保留菜单、标题及固定页面结构,禁用需要模板的动作并说明原因,不补无依据的流程或表单。采集失败不能说成合法空目录。
14
+
15
+ ## 固定页面结构
16
+
17
+ 以本菜单 workflowPreset 和 buttons 为准,流程列、标签、筛选、业务按钮来自固定原型模板,不来自真实流程列表/按钮 API。配置来源为 prototype-workflow-preset、mockOnly=true。
18
+
19
+ | 分类 | 结构与要求 |
20
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
21
+ | todo | 序号、流程名称、发起人、发起时间、未操作者;全部/待处理/转发/抄送/被退回/被督办/未读/新反馈/超时/关注;流程新建、全部已读、批量提交 |
22
+ | done | 上述列加当前节点;全部/审批中/已结束/待回复/未读/已审批/新反馈/关注;流程新建 |
23
+ | mine | 含当前节点;全部/审批中/已结束/未读/新反馈/关注;流程新建 |
24
+ | share | 含当前节点;状态下拉;流程新建 |
25
+ | all | 含当前节点;参与关系与状态两个下拉;流程新建 |
26
+ | subordinates | 待办模板,模拟下属待办数据范围 |
27
+ | draft、monitor | 全部流程模板,分别展示自己的草稿和模拟监控范围,不能变成同一批列表数据 |
28
+ | newflow | 多列分类卡片、彩色顶边、流程链接;全部流程/我的收藏/流程结构图;搜索 |
29
+
30
+ 采用浅灰工作区、白色紧凑表格、蓝色选中标签和主按钮、细分隔线。标题与适用的计数/摘要、筛选工具条、数据区、分页有清楚的层次。流程名称列最宽,过长截断并可查看全文;节点、人员、时间合理定宽。流程名称旁显示未读红点、已结束标签、到期/超时提示。表格在 390px 时仅数据区横向滚动,弹层/表单适配屏宽,不撑开主页面。
31
+
32
+ 流程固定列不能换成表单自定义字段;这些字段属于新建/详情正文。标签计数、分页、摘要来自实际筛选后的模拟数据,不硬编码数目。newflow 仅显示采集到的流程名称;只有可读 groupName 才按其分类,否则用应用名分组,不能凭 groupId 编造人事/费用等业务分类。流程结构图仅显示注明“模拟”的目录关系,不假装取得审批拓扑。
33
+
34
+ ## 固定运行时与初始化
35
+
36
+ 把 runtimePath 内完整的 e10-form-runtime、e10-workflow-runtime 两个 script 放在 head 最前面,不修改。CLI 接收时重新注入规范版本。业务代码使用 E10WorkflowStore 和 E10FormStore,不另写跨窗口/存储协议。
37
+
38
+ E10FormStore.forObject(objId).load(initial) 用于加载或初始化关联表单;格式沿用普通表单:
39
+
40
+ ```js
41
+ {schema:1, records:[{id:'demo-1',fields:{'字段ID':'模拟值'},details:{'明细组ID':[]}}],comments:[],logs:[]}
42
+ ```
43
+
44
+ 先逐对象 load,再使用返回的真实本地记录构造流程实例种子。与普通表单菜单共用字段 ID 和本地记录 ID,不用另一套数组掩盖已有记录。约 20 条可读模拟实例(目录与记录允许时),覆盖各分类;明细按配置生成 2–5 行,金额和汇总一致。不要持久化任何参考 HTML 中的真实业务值。
45
+
46
+ E10WorkflowStore.load(initial) 加载共享流程仓库,其格式为:
47
+
48
+ ```js
49
+ {
50
+ schema:1,
51
+ records:[{
52
+ id:'demo-instance-1',
53
+ fields:{
54
+ workflowId:'目录中的流程ID',objId:'对应表单ID',recordId:'该表单已 load 返回的记录ID',
55
+ title:'模拟申请标题',number:'MOCK-001',
56
+ initiatorId:'demo-current-user',initiator:'当前用户(模拟)',initiatedAt:'2026-09-01T09:00:00+08:00',
57
+ status:'active',currentNode:'模拟审批节点',
58
+ pendingIds:['demo-current-user'],pendingNames:['当前用户(模拟)'],
59
+ handledBy:[],sharedWith:[],flags:['新反馈'],read:false,followed:false,monitorable:true
60
+ }
61
+ }],
62
+ comments:[],logs:[],favorites:[]
63
+ }
64
+ ```
65
+
66
+ 日期按生成时业务上下文合理选择。status 只用 active/ended/draft。全部用户标识均为模拟;运行时 actor.id=demo-current-user,actor.subordinates=[demo-subordinate]。待办需要 active 且当前用户在 pendingIds;已办根据 handledBy,不能用 ended 替代;我提交的根据 initiatorId;共享根据 sharedWith;下属待办根据下属 pendingIds;草稿为自己发起的 draft;全部排除 draft;监控仅包含 monitorable=true 的非草稿模拟实例。种子为部分实例设置 monitorable,不能把监控页面当成全部流程的同一份筛选结果。
67
+
68
+ 初始化应使用 load 的返回值。不要每次切菜单重新保存种子覆盖修改。已存在仓库允许为空,不擅自再添数据。无模板不得生成实例。模板与表单对不上、关联记录缺失时显示明确错误及适当重置入口。
69
+
70
+ ## 可调用 API 与交互
71
+
72
+ - `await E10WorkflowStore.load(initial?)`:初始化/读取最新实例状态,返回上述仓库。
73
+ - `E10WorkflowStore.filter(state, category, {tab,search,state,participation})`:返回当前分类和筛选下的记录。tab/state 使用模板的中文标签;state 可为“全部/审批中/已结束”,participation 为“全部/我直接参与的”。搜索匹配标题和编号。每个标签的计数单独调用相应 filter,避免被当前标签重复过滤;分页和排序由业务代码对结果计算。组合子页各自保留筛选、分页、选择状态,切分类清掉无效选择。
74
+ - `await E10WorkflowStore.detail(instanceId)`:返回 `{instance,record,template,comments,logs}`,从共享表单仓库读取 record。点击流程名称打开对应 objId 正文,不拿首个表单代替。普通表单修改后重新打开详情应看到最新值。
75
+ - `await E10WorkflowStore.saveApplication({workflowId,instanceId?,record,title,draft})`:新建或续填自己的草稿;record 含字段和明细,前端先做配置要求的校验。新建自动分配独立实例和记录 ID;instanceId 仅用于对应流程的自己的草稿。draft=true 保存草稿;false 提交到模拟审批节点。运行时将表单与流程联合保存,然后返回实例。继续操作后重新 load/filter,更新分类与计数。
76
+ - `await E10WorkflowStore.markRead(category)`:将当前分类全部模拟记录标为已读,再刷新计数;不是只改当前页红点。
77
+ - `await E10WorkflowStore.completeBatch(ids, confirmed, category)`:只用于模板允许的批量按钮。先进入选择态,选择可办理记录、显示确认;确认才传 true。空选择/包含不可办理记录报错。取消不改变数据;成功后当前用户进入 handledBy,转到模拟下一节点,仍为 active,不把所有实例直接置为已结束。模拟下属列表里不可由当前用户办理的项不可选。
78
+ - `await E10WorkflowStore.toggleFavorite(workflowId)`:本地收藏/取消,newflow 的“我的收藏”据 favorites 过滤。
79
+ - `await E10WorkflowStore.toggleFollow(instanceId)`:本地关注/取消,更新关注标签。
80
+ - `await E10WorkflowStore.comment(instanceId,text)`:按具体流程实例保存本地评论;不同流程共享同表单也不能串评论。日志同样按 instanceId。
81
+ - `await E10WorkflowStore.reset(initial, [{objId,value:表单初始仓库}], confirmed)`:经确认后重置流程和需要恢复的关联表单。只有流程重置时,种子的 recordId 必须仍存在;重置会影响普通菜单共享的记录,应在确认文案明确范围。取消不改变数据。
82
+
83
+ 需要流程新建的任何页面,都先打开真实模板选择器,再进入所选 objId 表单。没有独立 newflow 菜单也可以在本页使用弹层,不新添左侧菜单。newflow 卡片中的流程名称直接选择该模板。新建提供标明模拟来源的提交申请、保存草稿、取消;同一份增/查表单布局共用字段控件。关闭有修改的编辑副本时确认放弃,取消不能调用 saveApplication 或写修改日志。
84
+
85
+ “未操作者”的“显示”打开该实例 pendingNames;已结束或无待处理人时显示空态。详情正文按 forms 中该 objId 的字段、分组、主明细和私有 HTML 重建。按 layoutGuidePath 实现完整头部、正文、评论与日志;普通表单按钮配置不适用于流程,不能据空配置隐藏这里约定的提交/草稿按钮,也不能借用列表按钮。可以补模拟节点和参与者区,但不能擅自增加真实审批动作或宣称真实权限。
86
+
87
+ 所有写入只使用固定本地 API。E10FormStore 负责关联对象读写,流程页面的根 store 保存实例;不要直接用根 load/save 存表单字段。数据按应用隔离;多对象联合保存是一份本地快照。浏览器存储不可用时退为当前会话内存,交付时不保证跨浏览器共享。
88
+
89
+ ## 验证与交付
90
+
91
+ 生成后设置 window.**E10_FORM_READY**=true。至少检查 1440px 与 390px 代表性的流程表格、新建卡片、长表单和弹层;修复溢出、遮挡、不可读字体与失效按钮。保留截图和实际测试记录到 reviewDirectory/coverage.json。
92
+
93
+ 交互检查:搜索与标签计数、筛选分页、显示待处理人、全部已读;批量提交空选择/取消/确认及已办同步;新建取消无写入、保存草稿、续填并提交;相应字段/主明细/详情;收藏/关注;刷新持久化、重置取消/确认;同表单多流程实例及评论隔离。对同结构分类可选代表性页面实测,其余检查模板和范围映射,不虚报所有分类实机通过。
94
+
95
+ 目录为空与失败须区分。对未取得 HTML、未知复杂控件、未执行的交互逐项说明;仅结构接收或 PNG 渲染成功不能当作完整视觉/业务验收。源系统没有真实流程实例、权限或审批拓扑的证据,不作相应声明。
@@ -0,0 +1,89 @@
1
+ {
2
+ "todo": {
3
+ "layout": "table",
4
+ "columns": ["序号", "流程名称", "发起人", "发起时间", "未操作者"],
5
+ "tabs": [
6
+ "全部",
7
+ "待处理",
8
+ "转发",
9
+ "抄送",
10
+ "被退回",
11
+ "被督办",
12
+ "未读",
13
+ "新反馈",
14
+ "超时",
15
+ "关注"
16
+ ],
17
+ "dropdowns": [],
18
+ "buttons": [
19
+ {
20
+ "buttonKey": "workflow-new",
21
+ "name": "流程新建",
22
+ "showPosition": ["top"],
23
+ "actions": [{ "actionType": "prototype-workflow-new" }]
24
+ },
25
+ {
26
+ "buttonKey": "workflow-read-all",
27
+ "name": "全部已读",
28
+ "showPosition": ["top"],
29
+ "actions": [{ "actionType": "prototype-workflow-read-all" }]
30
+ },
31
+ {
32
+ "buttonKey": "workflow-submit-batch",
33
+ "name": "批量提交",
34
+ "showPosition": ["top"],
35
+ "actions": [{ "actionType": "prototype-workflow-submit-batch" }]
36
+ }
37
+ ],
38
+ "searchPlaceholder": "请输入标题/编号",
39
+ "datasetScope": "current-user-todo"
40
+ },
41
+ "all": {
42
+ "layout": "table",
43
+ "columns": ["序号", "流程名称", "当前节点", "发起人", "发起时间", "未操作者"],
44
+ "tabs": [],
45
+ "dropdowns": [
46
+ { "key": "participation", "label": "我直接参与的" },
47
+ { "key": "state", "label": "全部" }
48
+ ],
49
+ "buttons": [
50
+ {
51
+ "buttonKey": "workflow-new",
52
+ "name": "流程新建",
53
+ "showPosition": ["top"],
54
+ "actions": [{ "actionType": "prototype-workflow-new" }]
55
+ }
56
+ ],
57
+ "searchPlaceholder": "请输入标题/编号",
58
+ "datasetScope": "all"
59
+ },
60
+ "done": {
61
+ "extends": "all",
62
+ "tabs": ["全部", "审批中", "已结束", "待回复", "未读", "已审批", "新反馈", "关注"],
63
+ "dropdowns": [],
64
+ "datasetScope": "current-user-done"
65
+ },
66
+ "mine": {
67
+ "extends": "all",
68
+ "tabs": ["全部", "审批中", "已结束", "未读", "新反馈", "关注"],
69
+ "dropdowns": [],
70
+ "datasetScope": "current-user-created"
71
+ },
72
+ "share": {
73
+ "extends": "all",
74
+ "dropdowns": [{ "key": "state", "label": "全部" }],
75
+ "datasetScope": "shared-to-current-user"
76
+ },
77
+ "subordinates": { "extends": "todo", "datasetScope": "subordinates-todo" },
78
+ "draft": { "extends": "all", "datasetScope": "current-user-drafts" },
79
+ "monitor": { "extends": "all", "datasetScope": "monitor" },
80
+ "newflow": {
81
+ "layout": "grouped-cards",
82
+ "columns": [],
83
+ "tabs": ["全部流程", "我的收藏", "流程结构图"],
84
+ "dropdowns": [],
85
+ "buttons": [],
86
+ "searchPlaceholder": "请输入关键字",
87
+ "datasetScope": "workflow-templates"
88
+ }
89
+ }
@@ -0,0 +1,15 @@
1
+ import { CaptureError } from './common.js';
2
+ import { E10Client } from './api.js';
3
+ import type { Store } from './store.js';
4
+ import type { TaskState } from './model.js';
5
+ export declare class SeedUnavailable extends CaptureError {
6
+ constructor(message: string);
7
+ }
8
+ export declare function temporarySeed(fields: any[], marker: string): Record<string, any>;
9
+ export declare function temporarySummary(store: Store): Promise<{
10
+ total: number;
11
+ pending: number;
12
+ cleaned: number;
13
+ }>;
14
+ export declare function recoverTemporaryRecords(store: Store, state: TaskState, client: E10Client, prefix: string): Promise<void>;
15
+ export declare function withTemporaryRecord<T>(store: Store, state: TaskState, client: E10Client, prefix: string, objId: string, fields: any[], use: (record: string) => Promise<T>): Promise<T>;
@@ -0,0 +1,286 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { atomicJson, CaptureError, readJson } from './common.js';
5
+ import { apiData } from './api.js';
6
+ export class SeedUnavailable extends CaptureError {
7
+ constructor(message) {
8
+ super('TEMPORARY_SEED_UNAVAILABLE', message);
9
+ }
10
+ }
11
+ const stop = (message) => new CaptureError('TEMPORARY_RECORD_STOP', message);
12
+ const truth = (v) => [true, 1, '1', 'true', 'y'].includes(typeof v === 'string' ? v.toLowerCase() : v);
13
+ export function temporarySeed(fields, marker) {
14
+ if (!fields.some((f) => f.id === '1' && f.name === 'name'))
15
+ throw new SeedUnavailable('缺少已验证的名称字段,无法写入归属标记');
16
+ const main = { name: marker };
17
+ for (const f of fields) {
18
+ if (f.system)
19
+ continue;
20
+ const config = f.config || {}, required = [f.required, config.required, config.isRequired].some(truth);
21
+ const kind = String(f.compType || f.type || '').toLowerCase();
22
+ if (f.isDetail ||
23
+ ![
24
+ 'text',
25
+ 'string',
26
+ 'textarea',
27
+ 'number',
28
+ 'numbercomponent',
29
+ 'money',
30
+ 'date',
31
+ 'datecomponent',
32
+ 'select',
33
+ 'radiobox',
34
+ ].includes(kind) ||
35
+ truth(f.multiSelect)) {
36
+ if (required)
37
+ throw new SeedUnavailable(`必填字段 ${f.id} 不能可靠构造,使用字段布局`);
38
+ continue;
39
+ }
40
+ const key = String(f.name === f.id ? config.dataKey || '' : f.name || '');
41
+ if (!key || key in main)
42
+ throw new SeedUnavailable('字段写入键缺失或重复');
43
+ let value;
44
+ if (['text', 'string', 'textarea'].includes(kind))
45
+ value = ('Prototype ' + marker.slice(-12)).slice(0, Math.max(1, Number(config.maxLen) || 100));
46
+ else if (['number', 'numbercomponent', 'money'].includes(kind))
47
+ value = 1;
48
+ else if (['date', 'datecomponent'].includes(kind))
49
+ value = new Date().toISOString().slice(0, 10);
50
+ else {
51
+ const option = (f.options || config.options || []).find((o) => !truth(o.archive));
52
+ if (!option) {
53
+ if (required)
54
+ throw new SeedUnavailable(`必填选择字段 ${f.id} 没有有效选项`);
55
+ continue;
56
+ }
57
+ value = option.id ?? option.value ?? option.valueKey;
58
+ if (value == null)
59
+ throw new SeedUnavailable('选项缺少已验证的写入 ID');
60
+ value = String(value);
61
+ }
62
+ main[key] = value;
63
+ }
64
+ return main;
65
+ }
66
+ const terminal = new Set(['cleaned', 'seed-unavailable', 'create-rejected']);
67
+ async function entries(store) {
68
+ const dir = path.join(store.meta, 'temporary-records');
69
+ let files;
70
+ try {
71
+ files = await fs.readdir(dir);
72
+ }
73
+ catch (e) {
74
+ if (e.code === 'ENOENT')
75
+ return [];
76
+ throw e;
77
+ }
78
+ return Promise.all(files.filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(dir, f))));
79
+ }
80
+ export async function temporarySummary(store) {
81
+ const all = await entries(store);
82
+ return {
83
+ total: all.length,
84
+ pending: all.filter((e) => !terminal.has(e.status)).length,
85
+ cleaned: all.filter((e) => e.status === 'cleaned').length,
86
+ };
87
+ }
88
+ function ids(data) {
89
+ let value = data?.datajson?.dataIds;
90
+ if (typeof value === 'string')
91
+ value = value
92
+ .split(',')
93
+ .filter(Boolean)
94
+ .map((s) => s.trim());
95
+ return Array.isArray(value) && value.every((v) => /^\d+$/.test(String(v)))
96
+ ? value.map(String)
97
+ : [];
98
+ }
99
+ class Lifecycle {
100
+ store;
101
+ client;
102
+ prefix;
103
+ entry;
104
+ constructor(store, client, prefix, entry) {
105
+ this.store = store;
106
+ this.client = client;
107
+ this.prefix = prefix;
108
+ this.entry = entry;
109
+ }
110
+ async record(status) {
111
+ this.entry.status = status;
112
+ this.entry.updatedAt = new Date().toISOString();
113
+ await atomicJson(path.join(this.store.meta, 'temporary-records', this.entry.operationId + '.json'), this.entry);
114
+ }
115
+ request(action, main, raw = false) {
116
+ const operationinfo = {
117
+ fieldNoFindIgnore: 'false',
118
+ checkRight: 'true',
119
+ doAction: 'false',
120
+ };
121
+ if (action === 'getDataById')
122
+ operationinfo.isReturnDetail = 'false';
123
+ return this.client.request('POST', `${this.prefix}/form/dataset/v2/${action}`, {
124
+ datajson: {
125
+ header: { objId: this.entry.objId },
126
+ operationinfo,
127
+ ...(action === 'saveFormData' ? { datas: [{ mainTable: main }] } : { mainTable: main }),
128
+ },
129
+ }, false, raw);
130
+ }
131
+ async detail() {
132
+ const data = await this.request('getDataById', { id: this.entry.recordId }), rows = data?.datajson?.datas;
133
+ if (!Array.isArray(rows))
134
+ throw stop('临时记录回查结构无效');
135
+ if (!rows.length)
136
+ return false;
137
+ if (rows.length !== 1 ||
138
+ String(rows[0]?.mainTable?.id?.fieldValue) !== this.entry.recordId ||
139
+ rows[0]?.mainTable?.name?.fieldValue !== this.entry.marker)
140
+ throw stop('临时记录归属不一致,拒绝删除');
141
+ return true;
142
+ }
143
+ async create(fields) {
144
+ let main;
145
+ try {
146
+ main = temporarySeed(fields, this.entry.marker);
147
+ }
148
+ catch (e) {
149
+ await this.record('seed-unavailable');
150
+ throw e;
151
+ }
152
+ await this.record('create-pending');
153
+ let payload;
154
+ try {
155
+ payload = await this.request('saveFormData', main, true);
156
+ }
157
+ catch (e) {
158
+ if (e.code === 'API_HTTP' &&
159
+ [400, 422].includes(Number(e.details && e.details.status))) {
160
+ await this.record('create-rejected');
161
+ throw new SeedUnavailable('服务端明确拒绝临时创建');
162
+ }
163
+ await this.record('create-uncertain');
164
+ throw stop('临时创建响应不明,请在恢复登录后执行 collect 核对台账,不能重放创建');
165
+ }
166
+ const returned = ids(payload?.data);
167
+ if (returned.length === 1) {
168
+ this.entry.recordId = returned[0];
169
+ await this.record('created-unverified');
170
+ }
171
+ try {
172
+ apiData(payload, '临时记录创建');
173
+ }
174
+ catch (e) {
175
+ if (!returned.length &&
176
+ (payload?.status === false ||
177
+ payload?.fail === true ||
178
+ payload?.data?.datajson?.status === false)) {
179
+ await this.record('create-rejected');
180
+ throw new SeedUnavailable('临时创建被业务校验拒绝');
181
+ }
182
+ await this.record('create-uncertain');
183
+ throw stop('创建返回的业务状态异常,需要核对台账');
184
+ }
185
+ if (returned.length !== 1) {
186
+ await this.record('create-uncertain');
187
+ throw stop('临时创建未返回唯一记录 ID,禁止再次创建');
188
+ }
189
+ if (!(await this.detail()))
190
+ throw stop('新建临时记录无法回查,禁止将其当作无记录兜底');
191
+ await this.record('created-verified');
192
+ return this.entry.recordId;
193
+ }
194
+ async cleanup() {
195
+ if (!this.entry.recordId)
196
+ return;
197
+ try {
198
+ if (!(await this.detail())) {
199
+ await this.record('cleaned');
200
+ return;
201
+ }
202
+ if (this.entry.deleteAttempted)
203
+ throw stop('前次删除后记录仍存在,需要人工核对,不能重复删除');
204
+ this.entry.deleteAttempted = true;
205
+ await this.record('delete-pending');
206
+ let payload;
207
+ try {
208
+ payload = await this.request('deleteData', { id: this.entry.recordId }, true);
209
+ }
210
+ catch {
211
+ /* Read-only verification resolves timeout; never replay deletion. */
212
+ }
213
+ const returned = ids(payload?.data);
214
+ if (returned.length && (returned.length !== 1 || returned[0] !== this.entry.recordId))
215
+ throw stop('删除响应 ID 与当前临时记录不符');
216
+ if (await this.detail())
217
+ throw stop('临时记录清理未完成');
218
+ await this.record('cleaned');
219
+ }
220
+ catch (e) {
221
+ await this.record('cleanup-failed');
222
+ throw stop('临时记录清理未确认;恢复登录后执行 collect,核对私有 temporary-records 台账');
223
+ }
224
+ }
225
+ async recover() {
226
+ if (this.entry.status === 'planned') {
227
+ await this.record('create-rejected');
228
+ return;
229
+ }
230
+ if (!this.entry.recordId) {
231
+ const data = await this.client.request('POST', `${this.prefix}/form/dataset/v2/getAllData`, {
232
+ datajson: {
233
+ header: { objId: this.entry.objId },
234
+ mainTable: { name: this.entry.marker },
235
+ pageInfo: { pageNo: 1, pageSize: 2 },
236
+ operationinfo: { isReturnDetail: false, fieldNoFindIgnore: 'false' },
237
+ },
238
+ });
239
+ const rows = data?.datajson?.datas;
240
+ if (!Array.isArray(rows) ||
241
+ rows.length !== 1 ||
242
+ rows[0]?.mainTable?.name?.fieldValue !== this.entry.marker ||
243
+ !/^\d+$/.test(String(rows[0]?.mainTable?.id?.fieldValue)))
244
+ throw stop('创建结果仍不明确,无法按唯一标记确定一条记录,停止临时写入');
245
+ this.entry.recordId = String(rows[0].mainTable.id.fieldValue);
246
+ await this.record('reconciled');
247
+ }
248
+ await this.cleanup();
249
+ }
250
+ }
251
+ export async function recoverTemporaryRecords(store, state, client, prefix) {
252
+ for (const entry of await entries(store)) {
253
+ if (terminal.has(entry.status))
254
+ continue;
255
+ if (entry.origin !== client.origin ||
256
+ entry.appId !== state.appId ||
257
+ !/^\d+$/.test(entry.objId) ||
258
+ !/^EBPROTO_[a-f0-9-]{36}$/.test(entry.marker) ||
259
+ !/^[a-f0-9-]{36}$/.test(entry.operationId))
260
+ throw stop('临时记录台账归属无效');
261
+ await new Lifecycle(store, client, prefix, entry).recover();
262
+ }
263
+ }
264
+ export async function withTemporaryRecord(store, state, client, prefix, objId, fields, use) {
265
+ if (!state.allowTemporaryRecords)
266
+ throw stop('本任务未启用临时参考记录');
267
+ if ((await temporarySummary(store)).pending)
268
+ throw stop('存在未决临时记录,先执行 collect 恢复清理');
269
+ const entry = {
270
+ operationId: randomUUID(),
271
+ origin: client.origin,
272
+ appId: state.appId,
273
+ objId,
274
+ marker: 'EBPROTO_' + randomUUID(),
275
+ status: 'planned',
276
+ updatedAt: new Date().toISOString(),
277
+ };
278
+ const lifecycle = new Lifecycle(store, client, prefix, entry);
279
+ await lifecycle.record('planned');
280
+ try {
281
+ return await use(await lifecycle.create(fields));
282
+ }
283
+ finally {
284
+ await lifecycle.cleanup();
285
+ }
286
+ }