e10-ebuilder-prototype 0.5.4 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -14
- package/dist/archive.d.ts +1 -1
- package/dist/archive.js +3 -0
- package/dist/capture.js +27 -1
- package/dist/common.d.ts +2 -1
- package/dist/common.js +24 -4
- package/dist/form-behavior-runtime.d.mts +1 -0
- package/dist/form-behavior-runtime.mjs +681 -0
- package/dist/form-behavior.d.ts +11 -0
- package/dist/form-behavior.js +169 -0
- package/dist/form-context.d.ts +4 -0
- package/dist/form-context.js +7 -5
- package/dist/form-generation.d.ts +24 -0
- package/dist/form-generation.js +367 -0
- package/dist/form-guidance.d.ts +3 -0
- package/dist/form-guidance.js +28 -0
- package/dist/form-runtime.mjs +11 -2
- package/dist/host-ledger.d.ts +23 -0
- package/dist/host-ledger.js +183 -0
- package/dist/host-watch.d.ts +102 -0
- package/dist/host-watch.js +112 -0
- package/dist/html-handoff.d.ts +1 -0
- package/dist/html-handoff.js +30 -2
- package/dist/html-inspect.d.ts +4 -1
- package/dist/html-inspect.js +48 -50
- package/dist/html-interact.d.ts +36 -0
- package/dist/html-interact.js +216 -0
- package/dist/html-review-budget.d.ts +9 -0
- package/dist/html-review-budget.js +47 -0
- package/dist/html.d.ts +86 -2
- package/dist/html.js +185 -26
- package/dist/index.js +166 -44
- package/dist/model.d.ts +2 -1
- package/dist/offline-render.d.ts +11 -0
- package/dist/offline-render.js +71 -0
- package/dist/offline-store.mjs +10 -0
- package/dist/runtime-support.d.mts +34 -0
- package/dist/runtime-support.mjs +67 -0
- package/dist/site.js +66 -51
- package/dist/store.d.ts +1 -0
- package/dist/store.js +23 -6
- package/dist/templates/form-guide.md +6 -20
- package/dist/templates/form-task-core.md +82 -0
- package/dist/templates/index.html +5 -3
- package/dist/templates/workflow-guide.md +4 -2
- package/dist/vendor/environment-auth.d.ts +1 -0
- package/dist/vendor/environment-auth.js +4 -4
- package/docs/PROTOCOL.md +378 -23
- package/package.json +1 -1
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// Serialized by the CLI. No imports, model calls, eval, remote APIs or generated executors.
|
|
2
|
+
export function installFormBehavior(contract) {
|
|
3
|
+
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
4
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
5
|
+
const fail = (code, message, source) => {
|
|
6
|
+
const e = new Error(message);
|
|
7
|
+
Object.assign(e, { code, source });
|
|
8
|
+
throw e;
|
|
9
|
+
};
|
|
10
|
+
const empty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && !v.length);
|
|
11
|
+
const text = (v) => (empty(v) ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
12
|
+
const fields = new Map(contract.fields.map((f) => [f.key, f]));
|
|
13
|
+
if (fields.size !== contract.fields.length)
|
|
14
|
+
fail('FIELD_IDENTITY', '字段身份重复');
|
|
15
|
+
const field = (key) => fields.get(key) || fail('FIELD_UNKNOWN', '未知字段', key);
|
|
16
|
+
const scalar = (v) => v === null || ['string', 'number', 'boolean'].includes(typeof v);
|
|
17
|
+
const decimal = (v) => {
|
|
18
|
+
const m = String(v).match(/^([+-]?)(\d+)(?:\.(\d+))?$/);
|
|
19
|
+
if (!m)
|
|
20
|
+
fail('FIELD_NUMBER', '请输入有效数字');
|
|
21
|
+
return {
|
|
22
|
+
n: BigInt((m[1] === '-' ? '-' : '') + m[2] + (m[3] || '')),
|
|
23
|
+
scale: (m[3] || '').length,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
const compare = (a, b, f) => {
|
|
27
|
+
if (empty(a) || empty(b))
|
|
28
|
+
return empty(a) === empty(b) ? 0 : empty(a) ? 1 : -1;
|
|
29
|
+
if (f.kind === 'number') {
|
|
30
|
+
const x = decimal(a), y = decimal(b), scale = Math.max(x.scale, y.scale);
|
|
31
|
+
const delta = x.n * 10n ** BigInt(scale - x.scale) - y.n * 10n ** BigInt(scale - y.scale);
|
|
32
|
+
return delta < 0n ? -1 : delta > 0n ? 1 : 0;
|
|
33
|
+
}
|
|
34
|
+
return text(a).localeCompare(text(b), 'zh-CN', { numeric: true });
|
|
35
|
+
};
|
|
36
|
+
const operators = [
|
|
37
|
+
'eq',
|
|
38
|
+
'ne',
|
|
39
|
+
'contains',
|
|
40
|
+
'notContains',
|
|
41
|
+
'in',
|
|
42
|
+
'notIn',
|
|
43
|
+
'gt',
|
|
44
|
+
'gte',
|
|
45
|
+
'lt',
|
|
46
|
+
'lte',
|
|
47
|
+
'between',
|
|
48
|
+
'empty',
|
|
49
|
+
'notEmpty',
|
|
50
|
+
];
|
|
51
|
+
const validateCondition = (c, depth = 0) => {
|
|
52
|
+
if (depth > 12 || !c || typeof c !== 'object' || Array.isArray(c))
|
|
53
|
+
fail('CONDITION_UNSUPPORTED', '条件结构无效');
|
|
54
|
+
if (c.all && c.any)
|
|
55
|
+
fail('CONDITION_UNSUPPORTED', '组合条件不能同时包含 all 和 any');
|
|
56
|
+
if (c.all || c.any) {
|
|
57
|
+
const values = c.all || c.any;
|
|
58
|
+
if (!Array.isArray(values) || !values.length || values.length > 100)
|
|
59
|
+
fail('CONDITION_UNSUPPORTED', '组合条件不能为空');
|
|
60
|
+
values.forEach((v) => validateCondition(v, depth + 1));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const f = field(c.field);
|
|
64
|
+
if (f.group || !operators.includes(c.op))
|
|
65
|
+
fail('CONDITION_UNSUPPORTED', '不支持该条件或明细字段条件', f.source);
|
|
66
|
+
if (['in', 'notIn', 'between'].includes(c.op) &&
|
|
67
|
+
(!Array.isArray(c.value) ||
|
|
68
|
+
!c.value.every(scalar) ||
|
|
69
|
+
(c.op === 'between' && c.value.length !== 2)))
|
|
70
|
+
fail('CONDITION_VALUE', '条件值必须是合法数组');
|
|
71
|
+
if (!['empty', 'notEmpty', 'in', 'notIn', 'between'].includes(c.op) && !scalar(c.value))
|
|
72
|
+
fail('CONDITION_VALUE', '条件值必须是基本值');
|
|
73
|
+
};
|
|
74
|
+
const matches = (row, c) => {
|
|
75
|
+
if (c.all)
|
|
76
|
+
return c.all.every((item) => matches(row, item));
|
|
77
|
+
if (c.any)
|
|
78
|
+
return c.any.some((item) => matches(row, item));
|
|
79
|
+
const f = field(c.field), value = row?.fields?.[f.id], expected = c.value;
|
|
80
|
+
switch (c.op) {
|
|
81
|
+
case 'empty':
|
|
82
|
+
return empty(value);
|
|
83
|
+
case 'notEmpty':
|
|
84
|
+
return !empty(value);
|
|
85
|
+
case 'eq':
|
|
86
|
+
return same(value, expected);
|
|
87
|
+
case 'ne':
|
|
88
|
+
return !same(value, expected);
|
|
89
|
+
case 'contains':
|
|
90
|
+
return text(value).toLocaleLowerCase().includes(text(expected).toLocaleLowerCase());
|
|
91
|
+
case 'notContains':
|
|
92
|
+
return !text(value).toLocaleLowerCase().includes(text(expected).toLocaleLowerCase());
|
|
93
|
+
case 'in':
|
|
94
|
+
return (Array.isArray(value) ? value : [value]).some((v) => expected.some((e) => same(v, e)));
|
|
95
|
+
case 'notIn':
|
|
96
|
+
return !(Array.isArray(value) ? value : [value]).some((v) => expected.some((e) => same(v, e)));
|
|
97
|
+
case 'between':
|
|
98
|
+
return (!empty(value) &&
|
|
99
|
+
compare(value, expected[0], f) >= 0 &&
|
|
100
|
+
compare(value, expected[1], f) <= 0);
|
|
101
|
+
default: {
|
|
102
|
+
const n = compare(value, expected, f);
|
|
103
|
+
return !empty(value) && { gt: n > 0, gte: n >= 0, lt: n < 0, lte: n <= 0 }[c.op];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const options = (f) => {
|
|
108
|
+
const raw = f.options?.$e10Options
|
|
109
|
+
? window.E10FormOptions.get(f.options.$e10Options)
|
|
110
|
+
: f.options;
|
|
111
|
+
const flatten = (items, ancestors = []) => (items || []).flatMap((o) => {
|
|
112
|
+
const value = o.value ?? o.id, label = String(o.label ?? o.name ?? o.content ?? value);
|
|
113
|
+
if (!scalar(value))
|
|
114
|
+
fail('OPTION_UNSUPPORTED', '选项缺少有效标识', f.source);
|
|
115
|
+
const path = [...ancestors, label];
|
|
116
|
+
return o.children?.length
|
|
117
|
+
? flatten(o.children, path)
|
|
118
|
+
: [{ value: String(value), label: path.join(' / ') }];
|
|
119
|
+
});
|
|
120
|
+
if (!Array.isArray(raw))
|
|
121
|
+
fail('OPTION_UNSUPPORTED', '选项格式未支持', f.source);
|
|
122
|
+
return flatten(raw);
|
|
123
|
+
};
|
|
124
|
+
const supported = new Set(['add', 'view', 'edit', 'save', 'saveNew', 'cancel', 'delete']);
|
|
125
|
+
async function create(initial, decisions = {}) {
|
|
126
|
+
if (!initial || initial.schema !== 1 || !Array.isArray(initial.records))
|
|
127
|
+
fail('STATE_INVALID', '初始记录格式无效');
|
|
128
|
+
if (new Set(initial.records.map((r) => r.id)).size !== initial.records.length)
|
|
129
|
+
fail('STATE_INVALID', '记录 ID 重复');
|
|
130
|
+
const store = window.E10FormStore;
|
|
131
|
+
let state, mode = 'list', draft = null, original = null, busy = false;
|
|
132
|
+
let querySpec = { page: 1, pageSize: 20 }, selected = [];
|
|
133
|
+
const entities = clone(decisions.entities || {}), conditions = clone(decisions.conditions || {}), actionDecisions = clone(decisions.actions || {});
|
|
134
|
+
const buttons = new Map(contract.buttons.map((b) => [b.key, clone(b)]));
|
|
135
|
+
const conditionSources = new Set(contract.buttons.flatMap((b) => [b.source, ...b.actions.map((a) => a.source)]));
|
|
136
|
+
for (const [source, c] of Object.entries(conditions)) {
|
|
137
|
+
if (!conditionSources.has(source))
|
|
138
|
+
fail('CONDITION_SOURCE', '条件没有对应配置来源', source);
|
|
139
|
+
validateCondition(c);
|
|
140
|
+
}
|
|
141
|
+
for (const key of Object.keys(entities)) {
|
|
142
|
+
if (field(key).kind !== 'entity')
|
|
143
|
+
fail('ENTITY_SOURCE', '实体选项必须绑定实体字段', key);
|
|
144
|
+
}
|
|
145
|
+
for (const [source, decision] of Object.entries(actionDecisions)) {
|
|
146
|
+
const action = [...buttons.values()]
|
|
147
|
+
.flatMap((b) => b.actions)
|
|
148
|
+
.find((a) => a.source === source);
|
|
149
|
+
if (!action || !decision || !supported.has(decision.op) || !decision.reason?.trim())
|
|
150
|
+
fail('ACTION_DECISION', '动作决策需要有效来源、已支持操作和解释', source);
|
|
151
|
+
if (action.op && action.op !== decision.op)
|
|
152
|
+
fail('ACTION_MISMATCH', '不能替换已确定的系统动作', source);
|
|
153
|
+
Object.assign(action, { op: decision.op, reason: decision.reason });
|
|
154
|
+
}
|
|
155
|
+
// Invalid decisions must fail before even initializing shared local records.
|
|
156
|
+
state = await store.load(initial);
|
|
157
|
+
const limitations = [
|
|
158
|
+
...contract.fields.flatMap((f) => f.issues.map((reason) => ({ source: f.source, reason }))),
|
|
159
|
+
...contract.scopes
|
|
160
|
+
.filter((s) => s.status !== 'ready')
|
|
161
|
+
.map((s) => ({ source: s.source, reason: s.status })),
|
|
162
|
+
...[...buttons.values()].flatMap((b) => b.actions
|
|
163
|
+
.filter((a) => a.enabled !== false && (!a.op || (a.conditionRequired && !conditions[a.source])))
|
|
164
|
+
.map((a) => ({ source: a.source, reason: 'unresolved-action-or-condition' }))),
|
|
165
|
+
...[...buttons.values()]
|
|
166
|
+
.filter((b) => b.conditionRequired && !conditions[b.source])
|
|
167
|
+
.map((b) => ({ source: b.source, reason: 'unresolved-button-condition' })),
|
|
168
|
+
];
|
|
169
|
+
window.__E10_FORM_BEHAVIOR__ = {
|
|
170
|
+
schema: 1,
|
|
171
|
+
menuId: contract.menuId,
|
|
172
|
+
initialized: true,
|
|
173
|
+
limitations,
|
|
174
|
+
decisions: {
|
|
175
|
+
actionSources: Object.keys(actionDecisions),
|
|
176
|
+
conditionSources: Object.keys(conditions),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const notify = () => {
|
|
180
|
+
if (decisions.onChange)
|
|
181
|
+
try {
|
|
182
|
+
decisions.onChange(snapshot());
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
console.error('FORM_VIEW_UPDATE_FAILED', error);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const snapshot = () => clone({ state, mode, draft, selected, busy, limitations });
|
|
189
|
+
const dirty = () => !!draft && !same(draft, original);
|
|
190
|
+
const choices = (f) => f.kind === 'entity'
|
|
191
|
+
? (entities[f.key] || []).map((o) => ({ value: String(o.value), label: String(o.label) }))
|
|
192
|
+
: options(f);
|
|
193
|
+
const writable = (f) => {
|
|
194
|
+
if (!draft || !['add', 'edit'].includes(mode))
|
|
195
|
+
fail('DRAFT_REQUIRED', '请先打开可编辑草稿');
|
|
196
|
+
if (f.readOnly ||
|
|
197
|
+
f.hidden ||
|
|
198
|
+
f.kind === 'unsupported' ||
|
|
199
|
+
f.issues.includes('dynamic-field-rule-requires-decision'))
|
|
200
|
+
fail('FIELD_READONLY', '此字段只读或包含尚未支持的动态规则', f.source);
|
|
201
|
+
};
|
|
202
|
+
const validateValue = (f, value) => {
|
|
203
|
+
if (empty(value))
|
|
204
|
+
return f.required ? '此字段必填' : undefined;
|
|
205
|
+
if (['text', 'textarea', 'tel', 'email'].includes(f.kind) && typeof value !== 'string')
|
|
206
|
+
return '请输入文本';
|
|
207
|
+
if (f.maxLength !== undefined && text(value).length > f.maxLength)
|
|
208
|
+
return `最多 ${f.maxLength} 个字符`;
|
|
209
|
+
if (f.kind === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
210
|
+
return '邮箱格式无效';
|
|
211
|
+
if (f.kind === 'number') {
|
|
212
|
+
try {
|
|
213
|
+
if (typeof value === 'number' &&
|
|
214
|
+
!Number.isSafeInteger(value) &&
|
|
215
|
+
Math.abs(value) >= Number.MAX_SAFE_INTEGER)
|
|
216
|
+
return '大数请使用字符串避免精度丢失';
|
|
217
|
+
const n = decimal(value);
|
|
218
|
+
if (f.precision !== undefined && n.scale > f.precision)
|
|
219
|
+
return `最多 ${f.precision} 位小数`;
|
|
220
|
+
if ((f.min !== undefined && compare(value, f.min, f) < 0) ||
|
|
221
|
+
(f.max !== undefined && compare(value, f.max, f) > 0))
|
|
222
|
+
return '数值超出配置范围';
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return '请输入有效数字';
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (f.kind === 'date') {
|
|
229
|
+
const v = String(value).replace(' ', 'T'), match = v.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/);
|
|
230
|
+
if (!match)
|
|
231
|
+
return '日期格式无效';
|
|
232
|
+
const [year, month, day, hour = 0, minute = 0, second = 0] = match
|
|
233
|
+
.slice(1)
|
|
234
|
+
.map((v) => (v === undefined ? 0 : Number(v)));
|
|
235
|
+
if (month < 1 ||
|
|
236
|
+
month > 12 ||
|
|
237
|
+
day < 1 ||
|
|
238
|
+
day > new Date(Date.UTC(year, month, 0)).getUTCDate() ||
|
|
239
|
+
hour > 23 ||
|
|
240
|
+
minute > 59 ||
|
|
241
|
+
second > 59)
|
|
242
|
+
return '日期范围无效';
|
|
243
|
+
}
|
|
244
|
+
if (['select', 'radio', 'multi', 'cascader', 'entity'].includes(f.kind)) {
|
|
245
|
+
const many = f.multiple || f.kind === 'multi';
|
|
246
|
+
if (many !== Array.isArray(value))
|
|
247
|
+
return many ? '此字段需要多选值' : '此字段需要单选值';
|
|
248
|
+
const allowed = choices(f).map((o) => o.value);
|
|
249
|
+
if ((many ? value : [value]).some((v) => typeof v !== 'string' || !allowed.includes(v)))
|
|
250
|
+
return '选择值不在已配置选项中';
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const set = (key, value, rowIndex) => {
|
|
254
|
+
if (busy)
|
|
255
|
+
fail('FORM_BUSY', '操作进行中,请稍后');
|
|
256
|
+
const f = field(key);
|
|
257
|
+
writable(f);
|
|
258
|
+
const target = f.group ? draft.details?.[f.group]?.[rowIndex]?.fields : draft.fields;
|
|
259
|
+
if (!target)
|
|
260
|
+
fail('DETAIL_ROW_UNKNOWN', '明细行不存在', f.source);
|
|
261
|
+
// Keep invalid input in the draft; saving validates and focuses its control.
|
|
262
|
+
target[f.id] = clone(value);
|
|
263
|
+
notify();
|
|
264
|
+
};
|
|
265
|
+
const validation = () => {
|
|
266
|
+
const errors = [];
|
|
267
|
+
for (const f of contract.fields) {
|
|
268
|
+
if (f.hidden)
|
|
269
|
+
continue;
|
|
270
|
+
const rows = f.group ? draft?.details?.[f.group] || [] : [draft];
|
|
271
|
+
rows.forEach((row, rowIndex) => {
|
|
272
|
+
if (f.kind === 'unsupported' && f.required && empty(row?.fields?.[f.id])) {
|
|
273
|
+
errors.push({
|
|
274
|
+
key: f.key,
|
|
275
|
+
rowIndex: f.group ? rowIndex : undefined,
|
|
276
|
+
message: '必填控件尚未支持,不能将缺值记录保存为完成',
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (f.readOnly || f.kind === 'unsupported')
|
|
281
|
+
return;
|
|
282
|
+
const message = validateValue(f, row?.fields?.[f.id]);
|
|
283
|
+
if (message)
|
|
284
|
+
errors.push({ key: f.key, rowIndex: f.group ? rowIndex : undefined, message });
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return errors;
|
|
288
|
+
};
|
|
289
|
+
const query = (spec = querySpec) => {
|
|
290
|
+
const filters = spec.filters || [], sort = spec.sort || [], pageSize = spec.pageSize ?? 20;
|
|
291
|
+
if (!Array.isArray(filters))
|
|
292
|
+
fail('QUERY_INVALID', 'filters 必须是条件数组,例如 [{field:"字段key",op:"eq",value:"值"}];无筛选用 []', 'filters');
|
|
293
|
+
if (!Array.isArray(sort))
|
|
294
|
+
fail('QUERY_INVALID', 'sort 必须是排序数组,例如 [{field:"字段key",direction:"asc"}];无排序用 []', 'sort');
|
|
295
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 500)
|
|
296
|
+
fail('QUERY_INVALID', 'pageSize 必须是 1..500 的整数;全量筛选结果使用 orderedIds,不传超大分页值', 'pageSize');
|
|
297
|
+
if (spec.page !== undefined && (!Number.isInteger(spec.page) || spec.page < 1))
|
|
298
|
+
fail('QUERY_INVALID', 'page 必须是从 1 开始的整数;更改筛选时传 page:1', 'page');
|
|
299
|
+
filters.forEach((c) => validateCondition(c));
|
|
300
|
+
for (const order of sort)
|
|
301
|
+
if (field(order.field).group || !['asc', 'desc'].includes(order.direction))
|
|
302
|
+
fail('SORT_INVALID', '排序字段或方向无效');
|
|
303
|
+
let rows = state.records.filter((r) => filters.every((f) => matches(r, f)));
|
|
304
|
+
rows = [...rows].sort((a, b) => {
|
|
305
|
+
for (const order of sort) {
|
|
306
|
+
const f = field(order.field), n = compare(a.fields[f.id], b.fields[f.id], f);
|
|
307
|
+
if (n)
|
|
308
|
+
return n * (order.direction === 'desc' ? -1 : 1);
|
|
309
|
+
}
|
|
310
|
+
return 0;
|
|
311
|
+
});
|
|
312
|
+
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize)), page = Math.min(spec.page || 1, pageCount);
|
|
313
|
+
selected = selected.filter((id) => rows.some((r) => r.id === id));
|
|
314
|
+
querySpec = clone({ ...spec, page, pageSize });
|
|
315
|
+
return clone({
|
|
316
|
+
records: rows.slice((page - 1) * pageSize, page * pageSize),
|
|
317
|
+
orderedIds: rows.map((r) => r.id),
|
|
318
|
+
total: rows.length,
|
|
319
|
+
page,
|
|
320
|
+
pageSize,
|
|
321
|
+
pageCount,
|
|
322
|
+
selected,
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
const confirmation = async (message) => (decisions.confirm ? await decisions.confirm(message) : window.confirm(message)) === true;
|
|
326
|
+
const fresh = () => ({
|
|
327
|
+
id: `demo-${crypto.randomUUID()}`,
|
|
328
|
+
fields: {},
|
|
329
|
+
details: Object.fromEntries([...new Set(contract.fields.filter((f) => f.group).map((f) => f.group))].map((group) => [
|
|
330
|
+
group,
|
|
331
|
+
[],
|
|
332
|
+
])),
|
|
333
|
+
});
|
|
334
|
+
const open = async (nextMode, id) => {
|
|
335
|
+
if (!['add', 'view', 'edit'].includes(nextMode))
|
|
336
|
+
fail('MODE_INVALID', '打开模式无效');
|
|
337
|
+
if (dirty() && !(await confirmation('放弃尚未保存的修改?')))
|
|
338
|
+
return false;
|
|
339
|
+
const row = nextMode === 'add' ? fresh() : state.records.find((r) => r.id === id);
|
|
340
|
+
if (!row)
|
|
341
|
+
fail('RECORD_MISSING', '记录不存在');
|
|
342
|
+
mode = nextMode;
|
|
343
|
+
draft = clone(row);
|
|
344
|
+
original = clone(row);
|
|
345
|
+
notify();
|
|
346
|
+
return true;
|
|
347
|
+
};
|
|
348
|
+
const commit = async (next, operation, recordIds) => {
|
|
349
|
+
next.logs ||= [];
|
|
350
|
+
next.logs.push({
|
|
351
|
+
id: crypto.randomUUID(),
|
|
352
|
+
operation,
|
|
353
|
+
recordIds,
|
|
354
|
+
at: new Date().toISOString(),
|
|
355
|
+
origin: 'local',
|
|
356
|
+
changes: operation === 'save' ? { before: original, after: draft } : undefined,
|
|
357
|
+
});
|
|
358
|
+
state = await store.compareSave(state, next);
|
|
359
|
+
query();
|
|
360
|
+
};
|
|
361
|
+
const save = async (again) => {
|
|
362
|
+
if (!draft || !['add', 'edit'].includes(mode))
|
|
363
|
+
fail('DRAFT_REQUIRED', '没有可保存的草稿');
|
|
364
|
+
const errors = validation();
|
|
365
|
+
if (errors.length) {
|
|
366
|
+
const target = [...document.querySelectorAll('[data-e10-field]')].find((el) => el.dataset.e10Field === errors[0].key);
|
|
367
|
+
target?.querySelector('input,select,textarea')?.focus();
|
|
368
|
+
const e = new Error(errors[0].message);
|
|
369
|
+
Object.assign(e, { code: 'FORM_VALIDATION', errors });
|
|
370
|
+
throw e;
|
|
371
|
+
}
|
|
372
|
+
const next = clone(state), index = next.records.findIndex((r) => r.id === draft.id);
|
|
373
|
+
if (mode === 'edit' && (index < 0 || !same(next.records[index], original)))
|
|
374
|
+
fail('FORM_CONFLICT', '原记录已变化,草稿已保留');
|
|
375
|
+
if (mode === 'add' && index >= 0)
|
|
376
|
+
fail('FORM_CONFLICT', '新记录 ID 已存在');
|
|
377
|
+
if (index < 0)
|
|
378
|
+
next.records.push(clone(draft));
|
|
379
|
+
else
|
|
380
|
+
next.records[index] = clone(draft);
|
|
381
|
+
await commit(next, 'save', [draft.id]);
|
|
382
|
+
original = clone(draft);
|
|
383
|
+
const savedId = draft.id;
|
|
384
|
+
if (again) {
|
|
385
|
+
mode = 'add';
|
|
386
|
+
draft = fresh();
|
|
387
|
+
original = clone(draft);
|
|
388
|
+
}
|
|
389
|
+
else
|
|
390
|
+
mode = 'view';
|
|
391
|
+
notify();
|
|
392
|
+
return { saved: true, recordId: savedId };
|
|
393
|
+
};
|
|
394
|
+
const preflight = (button, row) => {
|
|
395
|
+
if (!button.enabled || button.mode !== mode)
|
|
396
|
+
fail('BUTTON_UNAVAILABLE', '当前模式没有此按钮', button.source);
|
|
397
|
+
if (button.conditionRequired && !conditions[button.source])
|
|
398
|
+
fail('CONDITION_UNSUPPORTED', '按钮条件尚未映射', button.source);
|
|
399
|
+
if (conditions[button.source] && !matches(row, conditions[button.source]))
|
|
400
|
+
fail('CONDITION_FALSE', '当前记录不满足按钮条件', button.source);
|
|
401
|
+
const actions = button.actions.filter((a) => a.enabled !== false);
|
|
402
|
+
if (!actions.length)
|
|
403
|
+
fail('ACTION_UNSUPPORTED', '没有已配置动作', button.source);
|
|
404
|
+
for (const a of actions) {
|
|
405
|
+
if (a.enabled !== true ||
|
|
406
|
+
!supported.has(a.op) ||
|
|
407
|
+
(a.conditionRequired && !conditions[a.source]))
|
|
408
|
+
fail('ACTION_UNSUPPORTED', '动作或条件未支持,整条动作链未执行', a.source);
|
|
409
|
+
if (conditions[a.source] && !matches(row, conditions[a.source]))
|
|
410
|
+
fail('CONDITION_FALSE', '不满足动作条件,整条动作链未执行', a.source);
|
|
411
|
+
}
|
|
412
|
+
// Multiple writes and post-commit branching require an explicit transaction domain.
|
|
413
|
+
if (actions.length > 1)
|
|
414
|
+
fail('ACTION_CHAIN_UNSUPPORTED', '多步骤动作链仅展示配置说明,本轮不执行', button.source);
|
|
415
|
+
return actions;
|
|
416
|
+
};
|
|
417
|
+
const execute = async (key, params = {}) => {
|
|
418
|
+
if (busy)
|
|
419
|
+
fail('FORM_BUSY', '操作进行中,请勿重复提交');
|
|
420
|
+
const button = buttons.get(key);
|
|
421
|
+
if (!button)
|
|
422
|
+
fail('BUTTON_UNKNOWN', '按钮不属于本菜单或模式', key);
|
|
423
|
+
if (!params || typeof params !== 'object' || Array.isArray(params))
|
|
424
|
+
fail('ACTION_PARAMS', '动作参数无效');
|
|
425
|
+
const targetIds = params.ids || (params.recordId ? [params.recordId] : selected);
|
|
426
|
+
if (!Array.isArray(targetIds) || targetIds.some((id) => typeof id !== 'string'))
|
|
427
|
+
fail('ACTION_PARAMS', '操作目标必须为记录 ID 数组');
|
|
428
|
+
const targetRows = mode === 'list' && targetIds.length
|
|
429
|
+
? targetIds.map((id) => state.records.find((r) => r.id === id))
|
|
430
|
+
: [draft || state.records.find((r) => r.id === params.recordId)];
|
|
431
|
+
if (targetRows.some((row) => !row) && targetIds.length)
|
|
432
|
+
fail('RECORD_MISSING', '操作目标记录不存在');
|
|
433
|
+
const actions = preflight(button, targetRows[0]);
|
|
434
|
+
for (const row of targetRows.slice(1))
|
|
435
|
+
preflight(button, row);
|
|
436
|
+
busy = true;
|
|
437
|
+
try {
|
|
438
|
+
const results = [];
|
|
439
|
+
for (const action of actions) {
|
|
440
|
+
let result;
|
|
441
|
+
switch (action.op) {
|
|
442
|
+
case 'add':
|
|
443
|
+
case 'view':
|
|
444
|
+
case 'edit':
|
|
445
|
+
result = await open(action.op, params.recordId);
|
|
446
|
+
break;
|
|
447
|
+
case 'save':
|
|
448
|
+
case 'saveNew':
|
|
449
|
+
result = await save(action.op === 'saveNew');
|
|
450
|
+
break;
|
|
451
|
+
case 'cancel':
|
|
452
|
+
if (dirty() && !(await confirmation('放弃尚未保存的修改?')))
|
|
453
|
+
return { ok: false, cancelled: true };
|
|
454
|
+
mode = 'list';
|
|
455
|
+
draft = original = null;
|
|
456
|
+
result = true;
|
|
457
|
+
break;
|
|
458
|
+
case 'delete': {
|
|
459
|
+
const ids = params.ids || (params.recordId ? [params.recordId] : selected);
|
|
460
|
+
if (!Array.isArray(ids) ||
|
|
461
|
+
!ids.length ||
|
|
462
|
+
new Set(ids).size !== ids.length ||
|
|
463
|
+
ids.some((id) => !state.records.some((r) => r.id === id)))
|
|
464
|
+
fail('SELECTION_INVALID', '请选择有效记录');
|
|
465
|
+
if (!(await confirmation(`删除所选 ${ids.length} 条本地记录?`)))
|
|
466
|
+
return { ok: false, cancelled: true };
|
|
467
|
+
const next = clone(state);
|
|
468
|
+
next.records = next.records.filter((r) => !ids.includes(r.id));
|
|
469
|
+
await commit(next, 'delete', ids);
|
|
470
|
+
if (draft && ids.includes(draft.id)) {
|
|
471
|
+
draft = original = null;
|
|
472
|
+
mode = 'list';
|
|
473
|
+
}
|
|
474
|
+
result = true;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (result === false)
|
|
479
|
+
return { ok: false, cancelled: true };
|
|
480
|
+
results.push(result);
|
|
481
|
+
}
|
|
482
|
+
notify();
|
|
483
|
+
return { ok: true, results };
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
busy = false;
|
|
487
|
+
notify();
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const mount = (container, key, rowIndex) => {
|
|
491
|
+
const f = field(key);
|
|
492
|
+
let value = f.group
|
|
493
|
+
? draft?.details?.[f.group]?.[rowIndex]?.fields?.[f.id]
|
|
494
|
+
: draft?.fields?.[f.id];
|
|
495
|
+
container.replaceChildren();
|
|
496
|
+
container.dataset.e10Field = key;
|
|
497
|
+
const label = document.createElement('label'), caption = document.createElement('span'), error = document.createElement('small');
|
|
498
|
+
caption.textContent = f.label + (f.required ? ' *' : '');
|
|
499
|
+
label.append(caption);
|
|
500
|
+
container.append(label, error);
|
|
501
|
+
error.setAttribute('role', 'alert');
|
|
502
|
+
if (f.hidden) {
|
|
503
|
+
container.hidden = true;
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
container.hidden = false;
|
|
507
|
+
if (mode === 'view' ||
|
|
508
|
+
f.readOnly ||
|
|
509
|
+
f.kind === 'unsupported' ||
|
|
510
|
+
f.issues.includes('dynamic-field-rule-requires-decision')) {
|
|
511
|
+
const output = document.createElement('output');
|
|
512
|
+
output.textContent = format(key, value);
|
|
513
|
+
label.append(output);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const update = (v) => {
|
|
517
|
+
try {
|
|
518
|
+
set(key, v, rowIndex);
|
|
519
|
+
error.textContent = validateValue(f, v) || '';
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
error.textContent = e.message;
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
let input;
|
|
526
|
+
if (['select', 'radio', 'multi', 'cascader', 'entity'].includes(f.kind)) {
|
|
527
|
+
input = document.createElement('select');
|
|
528
|
+
input.multiple = f.multiple || f.kind === 'multi';
|
|
529
|
+
const populate = (search) => {
|
|
530
|
+
input.replaceChildren();
|
|
531
|
+
if (!input.multiple) {
|
|
532
|
+
const option = document.createElement('option');
|
|
533
|
+
option.value = '';
|
|
534
|
+
option.textContent = '请选择';
|
|
535
|
+
input.append(option);
|
|
536
|
+
}
|
|
537
|
+
for (const o of choices(f))
|
|
538
|
+
if (!search ||
|
|
539
|
+
o.label.includes(search) ||
|
|
540
|
+
(Array.isArray(value) ? value : [value]).includes(o.value)) {
|
|
541
|
+
const option = document.createElement('option');
|
|
542
|
+
option.value = o.value;
|
|
543
|
+
option.textContent = o.label;
|
|
544
|
+
option.selected = (Array.isArray(value) ? value : [value]).includes(o.value);
|
|
545
|
+
input.append(option);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
populate('');
|
|
549
|
+
if (f.kind === 'entity') {
|
|
550
|
+
const search = document.createElement('input');
|
|
551
|
+
search.type = 'search';
|
|
552
|
+
search.placeholder = '搜索';
|
|
553
|
+
search.setAttribute('aria-label', `搜索${f.label}`);
|
|
554
|
+
search.oninput = () => populate(search.value);
|
|
555
|
+
label.append(search);
|
|
556
|
+
}
|
|
557
|
+
input.onchange = () => {
|
|
558
|
+
value = input.multiple ? [...input.selectedOptions].map((o) => o.value) : input.value;
|
|
559
|
+
update(value);
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
input = document.createElement(f.kind === 'textarea' ? 'textarea' : 'input');
|
|
564
|
+
if (f.kind !== 'textarea')
|
|
565
|
+
input.type =
|
|
566
|
+
f.kind === 'date'
|
|
567
|
+
? f.format?.includes('HH')
|
|
568
|
+
? 'datetime-local'
|
|
569
|
+
: 'date'
|
|
570
|
+
: ['email', 'tel'].includes(f.kind)
|
|
571
|
+
? f.kind
|
|
572
|
+
: 'text';
|
|
573
|
+
if (f.kind === 'number')
|
|
574
|
+
input.inputMode = 'decimal';
|
|
575
|
+
input.value = f.kind === 'date' ? text(value).replace(' ', 'T') : text(value);
|
|
576
|
+
input.oninput = () => update(input.value);
|
|
577
|
+
}
|
|
578
|
+
input.setAttribute('aria-label', f.label);
|
|
579
|
+
label.append(input);
|
|
580
|
+
};
|
|
581
|
+
const format = (key, value) => {
|
|
582
|
+
const f = field(key);
|
|
583
|
+
if (empty(value))
|
|
584
|
+
return '—';
|
|
585
|
+
if (['select', 'radio', 'multi', 'cascader', 'entity'].includes(f.kind))
|
|
586
|
+
return (Array.isArray(value) ? value : [value])
|
|
587
|
+
.map((v) => choices(f).find((o) => o.value === String(v))?.label || text(v))
|
|
588
|
+
.join('、');
|
|
589
|
+
if (f.kind === 'number') {
|
|
590
|
+
const parts = String(value).split('.');
|
|
591
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
592
|
+
return parts.join('.');
|
|
593
|
+
}
|
|
594
|
+
return text(value);
|
|
595
|
+
};
|
|
596
|
+
return Object.freeze({
|
|
597
|
+
snapshot,
|
|
598
|
+
query,
|
|
599
|
+
async open(nextMode, id) {
|
|
600
|
+
if (busy)
|
|
601
|
+
fail('FORM_BUSY', '操作进行中');
|
|
602
|
+
busy = true;
|
|
603
|
+
try {
|
|
604
|
+
return await open(nextMode, id);
|
|
605
|
+
}
|
|
606
|
+
finally {
|
|
607
|
+
busy = false;
|
|
608
|
+
notify();
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
set,
|
|
612
|
+
mount,
|
|
613
|
+
format,
|
|
614
|
+
execute,
|
|
615
|
+
validate: validation,
|
|
616
|
+
dirty,
|
|
617
|
+
buttons: () => [...buttons.values()]
|
|
618
|
+
.filter((b) => b.mode === mode)
|
|
619
|
+
.map((b) => {
|
|
620
|
+
try {
|
|
621
|
+
preflight(b, draft);
|
|
622
|
+
return { ...clone(b), available: true };
|
|
623
|
+
}
|
|
624
|
+
catch (e) {
|
|
625
|
+
return { ...clone(b), available: false, reason: e.message };
|
|
626
|
+
}
|
|
627
|
+
}),
|
|
628
|
+
select(ids) {
|
|
629
|
+
const allowed = query().orderedIds;
|
|
630
|
+
if (!Array.isArray(ids) || ids.some((id) => !allowed.includes(id)))
|
|
631
|
+
fail('SELECTION_INVALID', '选择不在当前结果中');
|
|
632
|
+
selected = [...new Set(ids)];
|
|
633
|
+
return clone(selected);
|
|
634
|
+
},
|
|
635
|
+
addDetail(group) {
|
|
636
|
+
if (busy)
|
|
637
|
+
fail('FORM_BUSY', '操作进行中');
|
|
638
|
+
if (!draft ||
|
|
639
|
+
!['add', 'edit'].includes(mode) ||
|
|
640
|
+
!contract.fields.some((f) => f.group === group))
|
|
641
|
+
fail('DETAIL_GROUP', '明细分组或模式无效');
|
|
642
|
+
draft.details ||= {};
|
|
643
|
+
draft.details[group] ||= [];
|
|
644
|
+
draft.details[group].push({ id: `demo-${crypto.randomUUID()}`, fields: {} });
|
|
645
|
+
notify();
|
|
646
|
+
},
|
|
647
|
+
removeDetail(group, index) {
|
|
648
|
+
if (busy)
|
|
649
|
+
fail('FORM_BUSY', '操作进行中');
|
|
650
|
+
if (!draft ||
|
|
651
|
+
!['add', 'edit'].includes(mode) ||
|
|
652
|
+
!Number.isInteger(index) ||
|
|
653
|
+
index < 0 ||
|
|
654
|
+
!draft.details?.[group]?.[index])
|
|
655
|
+
fail('DETAIL_ROW_UNKNOWN', '明细行不存在');
|
|
656
|
+
draft.details[group].splice(index, 1);
|
|
657
|
+
notify();
|
|
658
|
+
},
|
|
659
|
+
async close() {
|
|
660
|
+
if (busy)
|
|
661
|
+
fail('FORM_BUSY', '操作进行中');
|
|
662
|
+
busy = true;
|
|
663
|
+
try {
|
|
664
|
+
if (dirty() && !(await confirmation('放弃尚未保存的修改?')))
|
|
665
|
+
return false;
|
|
666
|
+
mode = 'list';
|
|
667
|
+
draft = original = null;
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
busy = false;
|
|
672
|
+
notify();
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
Object.defineProperty(window, 'E10FormUI', {
|
|
678
|
+
value: Object.freeze({ contract: clone(contract), create }),
|
|
679
|
+
configurable: true,
|
|
680
|
+
});
|
|
681
|
+
}
|