sqlseed-web 0.2.4__py3-none-any.whl
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.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
// Workbench column rules. Local drafts remain editable when validation fails;
|
|
2
|
+
// only complete input shapes are emitted. The server checks generator semantics.
|
|
3
|
+
import { h } from '../api.js';
|
|
4
|
+
import { createDropdown } from '../dropdown.js';
|
|
5
|
+
import { genLabel, paramLabel, genGuide } from '../labels.js';
|
|
6
|
+
import { createDatePicker } from './date-picker.js';
|
|
7
|
+
const copy = value => structuredClone(value);
|
|
8
|
+
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
function normalizeRule(value, name) {
|
|
10
|
+
const rule = copy(value || {});
|
|
11
|
+
rule.name = name;
|
|
12
|
+
if (rule.generator === undefined && rule.generator_name !== undefined) {
|
|
13
|
+
rule.generator = rule.generator_name;
|
|
14
|
+
}
|
|
15
|
+
if (rule.faker_method === undefined && rule.native_faker_method !== undefined) {
|
|
16
|
+
rule.faker_method = rule.native_faker_method;
|
|
17
|
+
}
|
|
18
|
+
if (rule.mimesis_method === undefined && rule.native_mimesis_method !== undefined) {
|
|
19
|
+
rule.mimesis_method = rule.native_mimesis_method;
|
|
20
|
+
}
|
|
21
|
+
delete rule.generator_name;
|
|
22
|
+
delete rule.native_faker_method;
|
|
23
|
+
delete rule.native_mimesis_method;
|
|
24
|
+
// GeneratorSpec uses None for an absent native override; ColumnConfig's
|
|
25
|
+
// native_params is a dictionary and cannot accept that serialized null.
|
|
26
|
+
if (rule.native_params === null) {
|
|
27
|
+
delete rule.native_params;
|
|
28
|
+
}
|
|
29
|
+
return rule;
|
|
30
|
+
}
|
|
31
|
+
function withoutNative(rule) {
|
|
32
|
+
delete rule.faker_method;
|
|
33
|
+
delete rule.mimesis_method;
|
|
34
|
+
delete rule.native_params;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// This is a catalogue filter, not database validation. Unknown/union outputs
|
|
38
|
+
// remain available, and the user can inspect the complete provider catalogue.
|
|
39
|
+
function compatibleOutput(column, entry) {
|
|
40
|
+
const output = entry.output_type;
|
|
41
|
+
if (!output || output === 'json' || output === 'null') {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const type = String(column.type || '').toUpperCase();
|
|
45
|
+
if (/JSON/.test(type)) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (/BLOB|BINARY|BYTEA/.test(type)) {
|
|
49
|
+
return output === 'bytes';
|
|
50
|
+
}
|
|
51
|
+
if (/BOOL/.test(type)) {
|
|
52
|
+
return output === 'boolean';
|
|
53
|
+
}
|
|
54
|
+
if (/INT/.test(type)) {
|
|
55
|
+
return ['integer', 'boolean'].includes(output);
|
|
56
|
+
}
|
|
57
|
+
if (/NUMERIC|DECIMAL|FLOAT|DOUBLE|REAL|MONEY/.test(type)) {
|
|
58
|
+
return ['integer', 'number', 'boolean'].includes(output);
|
|
59
|
+
}
|
|
60
|
+
if (/TIMESTAMP|DATETIME/.test(type)) {
|
|
61
|
+
return ['datetime', 'string'].includes(output);
|
|
62
|
+
}
|
|
63
|
+
if (/DATE/.test(type)) {
|
|
64
|
+
return ['date', 'datetime', 'string'].includes(output);
|
|
65
|
+
}
|
|
66
|
+
if (/TIME/.test(type)) {
|
|
67
|
+
return ['time', 'string'].includes(output);
|
|
68
|
+
}
|
|
69
|
+
if (/CHAR|TEXT|CLOB|UUID|ENUM/.test(type)) {
|
|
70
|
+
return ['string', 'date', 'datetime', 'time'].includes(output);
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Create one column editor; callers retain it while onValidity reports an error. */
|
|
76
|
+
export function createRuleEditor({
|
|
77
|
+
table,
|
|
78
|
+
column,
|
|
79
|
+
rule,
|
|
80
|
+
baseline,
|
|
81
|
+
catalog,
|
|
82
|
+
draft,
|
|
83
|
+
onChange,
|
|
84
|
+
onValidity
|
|
85
|
+
}) {
|
|
86
|
+
const el = h('div', {
|
|
87
|
+
class: 'rule-editor wb-rule-editor'
|
|
88
|
+
});
|
|
89
|
+
const base = normalizeRule(baseline, column.name);
|
|
90
|
+
const entries = new Map((catalog.entries || []).map(entry => [entry.id, entry]));
|
|
91
|
+
const foreignKeys = (table.foreign_keys || []).filter(key => key.columns.includes(column.name));
|
|
92
|
+
const readonly = Boolean(column.is_computed || column.is_primary_key && column.is_autoincrement);
|
|
93
|
+
const uniqueBySchema = table.primary_key?.length === 1 && table.primary_key[0] === column.name || (table.unique_constraints || []).some(key => key.columns.length === 1 && key.columns[0] === column.name);
|
|
94
|
+
const hasDefault = column.default !== undefined && column.default !== null;
|
|
95
|
+
const canSkip = column.default !== undefined && column.default !== null || column.nullable || column.is_rowid_alias;
|
|
96
|
+
let current = normalizeRule(draft?.current ?? draft?.config ?? rule ?? baseline, column.name);
|
|
97
|
+
let mode = !foreignKeys.length && ['source', 'derived', 'skip'].includes(draft?.mode) ? draft.mode : modeOf(current);
|
|
98
|
+
let lastValid = copy(draft?.config ?? null);
|
|
99
|
+
let restoredValues = copy(draft?.invalidValues || {});
|
|
100
|
+
let disposed = false;
|
|
101
|
+
let dropdowns = [];
|
|
102
|
+
const datePickers = new Map();
|
|
103
|
+
const dateTextDrafts = new Set();
|
|
104
|
+
let summary;
|
|
105
|
+
let constraintsInput;
|
|
106
|
+
let uniqueInput;
|
|
107
|
+
const errors = new Map();
|
|
108
|
+
const modeDrafts = new Map();
|
|
109
|
+
function modeOf(value) {
|
|
110
|
+
if (foreignKeys.length) {
|
|
111
|
+
return 'foreign_key';
|
|
112
|
+
}
|
|
113
|
+
if (value.derive_from || value.expression) {
|
|
114
|
+
return 'derived';
|
|
115
|
+
}
|
|
116
|
+
return value.generator === 'skip' && canSkip ? 'skip' : 'source';
|
|
117
|
+
}
|
|
118
|
+
function recommendationScore(entry) {
|
|
119
|
+
const name = column.name.toLowerCase(),
|
|
120
|
+
type = String(column.type || '').toUpperCase();
|
|
121
|
+
const scalar = scalarGenerator();
|
|
122
|
+
const semantic = semanticGenerator();
|
|
123
|
+
return (compatibleOutput(column, entry) ? 100 : 0) + (entry.id === semantic ? 40 : 0) + (entry.id === base.generator ? 20 : 0) + (entry.id === current.generator ? 10 : 0) + (entry.id === scalar ? 8 : 0);
|
|
124
|
+
function scalarGenerator() {
|
|
125
|
+
let scalar;
|
|
126
|
+
if (/BLOB|BINARY|BYTEA/.test(type)) {
|
|
127
|
+
scalar = 'bytes';
|
|
128
|
+
} else if (/JSON/.test(type)) {
|
|
129
|
+
scalar = 'json';
|
|
130
|
+
} else if (/BOOL/.test(type)) {
|
|
131
|
+
scalar = 'boolean';
|
|
132
|
+
} else if (/INT/.test(type)) {
|
|
133
|
+
scalar = 'integer';
|
|
134
|
+
} else if (/REAL|FLOAT|NUMERIC|DECIMAL|DOUBLE|MONEY/.test(type)) {
|
|
135
|
+
scalar = 'float';
|
|
136
|
+
} else if (/DATETIME|TIMESTAMP/.test(type)) {
|
|
137
|
+
scalar = 'datetime';
|
|
138
|
+
} else if (/DATE/.test(type)) {
|
|
139
|
+
scalar = 'date';
|
|
140
|
+
} else if (/TIME/.test(type)) {
|
|
141
|
+
scalar = 'time';
|
|
142
|
+
} else {
|
|
143
|
+
scalar = 'string';
|
|
144
|
+
}
|
|
145
|
+
return scalar;
|
|
146
|
+
}
|
|
147
|
+
function semanticGenerator() {
|
|
148
|
+
let semantic;
|
|
149
|
+
if (/(^|_)(sku|code|no|number)$/.test(name)) {
|
|
150
|
+
semantic = 'template';
|
|
151
|
+
} else if (/(^|_)(status|category|product_name)$/.test(name)) {
|
|
152
|
+
semantic = 'choice';
|
|
153
|
+
} else if (/email/.test(name)) {
|
|
154
|
+
semantic = 'email';
|
|
155
|
+
} else if (/phone|mobile/.test(name)) {
|
|
156
|
+
semantic = 'phone';
|
|
157
|
+
} else if (/city/.test(name)) {
|
|
158
|
+
semantic = 'city';
|
|
159
|
+
} else if (/(^|_)(price|amount|rate)$/.test(name)) {
|
|
160
|
+
semantic = 'float';
|
|
161
|
+
} else if (/(^|_)(quantity|stock|age|count)$/.test(name)) {
|
|
162
|
+
semantic = 'integer';
|
|
163
|
+
} else {
|
|
164
|
+
semantic = null;
|
|
165
|
+
}
|
|
166
|
+
return semantic;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function recommendedEntries() {
|
|
170
|
+
return [...entries.values()].filter(entry => compatibleOutput(column, entry)).sort((left, right) => recommendationScore(right) - recommendationScore(left));
|
|
171
|
+
}
|
|
172
|
+
function skipLabel() {
|
|
173
|
+
if (column.is_rowid_alias) {
|
|
174
|
+
return '数据库自动分配 ID';
|
|
175
|
+
}
|
|
176
|
+
return hasDefault ? '使用数据库默认值' : '使用 NULL(空值)';
|
|
177
|
+
}
|
|
178
|
+
function skipDescription() {
|
|
179
|
+
if (column.is_rowid_alias || column.is_autoincrement) {
|
|
180
|
+
return 'ID 由数据库根据现有数据和序列状态分配。追加生成不会重新从 1 开始,也不会重置或覆盖已有主键。';
|
|
181
|
+
}
|
|
182
|
+
if (hasDefault) {
|
|
183
|
+
return `本列不提供生成值,数据库使用已定义的默认值:${String(column.default)}。可切换为生成器来自定义内容。`;
|
|
184
|
+
}
|
|
185
|
+
return '此列没有数据库默认值,省略后写入 NULL(空值)。需要真实内容时,请切换为生成器。';
|
|
186
|
+
}
|
|
187
|
+
function applyDerivedMode() {
|
|
188
|
+
delete current.generator;
|
|
189
|
+
delete current.params;
|
|
190
|
+
withoutNative(current);
|
|
191
|
+
}
|
|
192
|
+
function applyMode() {
|
|
193
|
+
if (mode === 'derived') {
|
|
194
|
+
applyDerivedMode();
|
|
195
|
+
} else {
|
|
196
|
+
applyGeneratedMode();
|
|
197
|
+
}
|
|
198
|
+
if (!column.nullable) {
|
|
199
|
+
delete current.null_ratio;
|
|
200
|
+
}
|
|
201
|
+
if (uniqueBySchema) {
|
|
202
|
+
current.constraints = {
|
|
203
|
+
...current.constraints,
|
|
204
|
+
unique: true
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function applyGeneratedMode() {
|
|
208
|
+
delete current.derive_from;
|
|
209
|
+
delete current.expression;
|
|
210
|
+
if (mode === 'foreign_key') {
|
|
211
|
+
current.generator = 'foreign_key_or_integer';
|
|
212
|
+
current.params = ['random', 'coverage'].includes(current.params?.strategy) ? {
|
|
213
|
+
strategy: current.params.strategy
|
|
214
|
+
} : {};
|
|
215
|
+
withoutNative(current);
|
|
216
|
+
} else if (mode === 'skip') {
|
|
217
|
+
current.generator = 'skip';
|
|
218
|
+
current.params = {};
|
|
219
|
+
withoutNative(current);
|
|
220
|
+
} else {
|
|
221
|
+
if (!current.generator || current.generator === 'skip') {
|
|
222
|
+
current.generator = entries.has(base.generator) ? base.generator : recommendedEntries()[0]?.id || '';
|
|
223
|
+
}
|
|
224
|
+
current.params ||= {};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function report() {
|
|
229
|
+
const message = errors.values().next().value || null;
|
|
230
|
+
if (!message) {
|
|
231
|
+
lastValid = copy(current);
|
|
232
|
+
}
|
|
233
|
+
if (summary) {
|
|
234
|
+
summary.textContent = message || '';
|
|
235
|
+
}
|
|
236
|
+
if (!disposed) {
|
|
237
|
+
onValidity?.(message);
|
|
238
|
+
}
|
|
239
|
+
return message;
|
|
240
|
+
}
|
|
241
|
+
function emit() {
|
|
242
|
+
if (disposed) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
applyMode();
|
|
246
|
+
if (!report()) {
|
|
247
|
+
onChange?.(copy(current));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function mark(field, control, message) {
|
|
251
|
+
if (message) {
|
|
252
|
+
errors.set(field, message);
|
|
253
|
+
control.setAttribute('aria-invalid', 'true');
|
|
254
|
+
} else {
|
|
255
|
+
errors.delete(field);
|
|
256
|
+
control.removeAttribute('aria-invalid');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function row(label, control, hint) {
|
|
260
|
+
const caption = h('span', {
|
|
261
|
+
class: 'editor-control-label'
|
|
262
|
+
}, label);
|
|
263
|
+
const wrapper = h('div', {
|
|
264
|
+
class: 'control wb-editor-row'
|
|
265
|
+
});
|
|
266
|
+
if (['INPUT', 'TEXTAREA'].includes(control.tagName)) {
|
|
267
|
+
wrapper.append(h('label', {}, caption, control));
|
|
268
|
+
} else {
|
|
269
|
+
// Composite controls already contain their own labels. Wrapping them in
|
|
270
|
+
// another label would make clicking one weekday activate another input.
|
|
271
|
+
control.setAttribute('role', 'group');
|
|
272
|
+
control.setAttribute('aria-label', label);
|
|
273
|
+
control.querySelector('.dropdown-btn')?.setAttribute('aria-label', label);
|
|
274
|
+
wrapper.append(caption, control);
|
|
275
|
+
}
|
|
276
|
+
if (hint) {
|
|
277
|
+
wrapper.append(h('small', {
|
|
278
|
+
class: 'editor-note muted'
|
|
279
|
+
}, hint));
|
|
280
|
+
}
|
|
281
|
+
return wrapper;
|
|
282
|
+
}
|
|
283
|
+
function dropdown(field, value, options, change) {
|
|
284
|
+
const control = createDropdown({
|
|
285
|
+
value,
|
|
286
|
+
options,
|
|
287
|
+
onChange: change
|
|
288
|
+
});
|
|
289
|
+
control.el.dataset.field = field;
|
|
290
|
+
dropdowns.push(control);
|
|
291
|
+
return control.el;
|
|
292
|
+
}
|
|
293
|
+
function textControl(field, value, validate, apply, {
|
|
294
|
+
type = 'text',
|
|
295
|
+
...options
|
|
296
|
+
} = {}) {
|
|
297
|
+
return validatedControl('input', field, value, validate, apply, {
|
|
298
|
+
type,
|
|
299
|
+
...options
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function multilineControl(field, value, validate, apply, options = {}) {
|
|
303
|
+
return validatedControl('textarea', field, value, validate, apply, {
|
|
304
|
+
rows: '4',
|
|
305
|
+
spellcheck: 'false',
|
|
306
|
+
...options
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function validatedControl(tagName, field, value, validate, apply, {
|
|
310
|
+
disabled = false,
|
|
311
|
+
placeholder = '',
|
|
312
|
+
...attributes
|
|
313
|
+
} = {}) {
|
|
314
|
+
const control = h(tagName, {
|
|
315
|
+
'data-field': field,
|
|
316
|
+
disabled,
|
|
317
|
+
value: Object.hasOwn(restoredValues, field) ? restoredValues[field] : value ?? '',
|
|
318
|
+
placeholder,
|
|
319
|
+
...attributes
|
|
320
|
+
});
|
|
321
|
+
delete restoredValues[field];
|
|
322
|
+
const check = commit => {
|
|
323
|
+
try {
|
|
324
|
+
const parsed = validate(control.value);
|
|
325
|
+
mark(field, control, null);
|
|
326
|
+
if (commit) {
|
|
327
|
+
apply(parsed);
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
mark(field, control, error.message);
|
|
331
|
+
}
|
|
332
|
+
if (commit) {
|
|
333
|
+
emit();
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
control.addEventListener('input', () => check(true));
|
|
337
|
+
check(false);
|
|
338
|
+
return control;
|
|
339
|
+
}
|
|
340
|
+
function parseParam(parameter, raw) {
|
|
341
|
+
const label = paramLabel(parameter.name);
|
|
342
|
+
if (!raw.trim()) {
|
|
343
|
+
if (parameter.required) {
|
|
344
|
+
throw new Error(`${label} 为必填参数。`);
|
|
345
|
+
}
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
if (parameter.type === 'integer' || parameter.type === 'number') {
|
|
349
|
+
const value = Number(raw);
|
|
350
|
+
if (!Number.isFinite(value) || parameter.type === 'integer' && !Number.isInteger(value)) {
|
|
351
|
+
throw new Error(`${label} 必须是${parameter.type === 'integer' ? '整数' : '有效数字'}。`);
|
|
352
|
+
}
|
|
353
|
+
return value;
|
|
354
|
+
}
|
|
355
|
+
if (['object', 'array', 'json'].includes(parameter.type)) {
|
|
356
|
+
return parseJsonParameter();
|
|
357
|
+
}
|
|
358
|
+
return raw;
|
|
359
|
+
function parseJsonParameter() {
|
|
360
|
+
let value;
|
|
361
|
+
try {
|
|
362
|
+
value = JSON.parse(raw);
|
|
363
|
+
} catch {
|
|
364
|
+
throw new Error(`${label} 需要有效 JSON。`);
|
|
365
|
+
}
|
|
366
|
+
if (parameter.type === 'object' && !isObject(value)) {
|
|
367
|
+
throw new Error(`${label} 需要 JSON 对象。`);
|
|
368
|
+
}
|
|
369
|
+
if (parameter.type === 'array' && !Array.isArray(value)) {
|
|
370
|
+
throw new Error(`${label} 需要 JSON 数组。`);
|
|
371
|
+
}
|
|
372
|
+
return value;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function dateDefault(name) {
|
|
376
|
+
if (name === 'start_date') {
|
|
377
|
+
return `${String(current.params.start_year ?? 2000).padStart(4, '0')}-01-01`;
|
|
378
|
+
}
|
|
379
|
+
if (name === 'end_date') {
|
|
380
|
+
return `${String(current.params.end_year ?? new Date().getFullYear()).padStart(4, '0')}-12-31`;
|
|
381
|
+
}
|
|
382
|
+
return name === 'start_time' ? '00:00:00' : '23:59:59';
|
|
383
|
+
}
|
|
384
|
+
function calendarControl(parameter) {
|
|
385
|
+
const name = parameter.name,
|
|
386
|
+
isTime = name.endsWith('_time');
|
|
387
|
+
const raw = current.params[name] ?? dateDefault(name);
|
|
388
|
+
if (!isTime) {
|
|
389
|
+
let ready = false;
|
|
390
|
+
if (Object.hasOwn(restoredValues, name)) {
|
|
391
|
+
dateTextDrafts.add(name);
|
|
392
|
+
}
|
|
393
|
+
const picker = createDatePicker({
|
|
394
|
+
label: paramLabel(name),
|
|
395
|
+
value: Object.hasOwn(restoredValues, name) ? restoredValues[name] : String(raw),
|
|
396
|
+
onValidity: (message, input) => {
|
|
397
|
+
mark(name, input, message);
|
|
398
|
+
if (ready) {
|
|
399
|
+
report();
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
onChange: value => {
|
|
403
|
+
dateTextDrafts.delete(name);
|
|
404
|
+
if (value === undefined) {
|
|
405
|
+
delete current.params[name];
|
|
406
|
+
} else {
|
|
407
|
+
current.params[name] = value;
|
|
408
|
+
}
|
|
409
|
+
emit();
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
picker.input.dataset.field = name;
|
|
413
|
+
picker.input.addEventListener('input', () => dateTextDrafts.add(name));
|
|
414
|
+
delete restoredValues[name];
|
|
415
|
+
datePickers.set(name, picker);
|
|
416
|
+
ready = true;
|
|
417
|
+
return picker.el;
|
|
418
|
+
}
|
|
419
|
+
const valid = value => {
|
|
420
|
+
if (!value.trim()) {
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
if (!/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(value)) {
|
|
424
|
+
throw new Error('时间格式应为 HH:MM 或 HH:MM:SS。');
|
|
425
|
+
}
|
|
426
|
+
return value;
|
|
427
|
+
};
|
|
428
|
+
// Keep invalid imported time text visible instead of erasing it.
|
|
429
|
+
let malformed = false;
|
|
430
|
+
try {
|
|
431
|
+
valid(String(raw));
|
|
432
|
+
} catch {
|
|
433
|
+
malformed = true;
|
|
434
|
+
}
|
|
435
|
+
const input = textControl(name, String(raw), valid, value => {
|
|
436
|
+
if (value === undefined) {
|
|
437
|
+
delete current.params[name];
|
|
438
|
+
} else {
|
|
439
|
+
current.params[name] = value;
|
|
440
|
+
}
|
|
441
|
+
}, {
|
|
442
|
+
type: malformed ? 'text' : 'time',
|
|
443
|
+
placeholder: 'HH:MM:SS',
|
|
444
|
+
disabled: current.params.all_day ?? true
|
|
445
|
+
});
|
|
446
|
+
input.setAttribute('step', '1');
|
|
447
|
+
return input;
|
|
448
|
+
}
|
|
449
|
+
function weekdayControl() {
|
|
450
|
+
const value = current.params.weekdays ?? 'all';
|
|
451
|
+
let custom;
|
|
452
|
+
if (Array.isArray(value)) {
|
|
453
|
+
custom = [...value];
|
|
454
|
+
} else if (value === 'workdays') {
|
|
455
|
+
custom = [0, 1, 2, 3, 4];
|
|
456
|
+
} else if (value === 'weekend') {
|
|
457
|
+
custom = [5, 6];
|
|
458
|
+
} else {
|
|
459
|
+
custom = [0, 1, 2, 3, 4, 5, 6];
|
|
460
|
+
}
|
|
461
|
+
let selectedMode;
|
|
462
|
+
if (Array.isArray(value)) {
|
|
463
|
+
selectedMode = 'custom';
|
|
464
|
+
} else if (['all', 'workdays', 'weekend'].includes(value)) {
|
|
465
|
+
selectedMode = value;
|
|
466
|
+
} else {
|
|
467
|
+
selectedMode = 'raw';
|
|
468
|
+
}
|
|
469
|
+
const wrap = h('div', {
|
|
470
|
+
class: 'wb-weekday-control'
|
|
471
|
+
}),
|
|
472
|
+
days = h('div', {
|
|
473
|
+
class: 'wb-weekday-options'
|
|
474
|
+
});
|
|
475
|
+
const updateDay = (day, checked) => {
|
|
476
|
+
custom = custom.filter(item => item !== day);
|
|
477
|
+
if (checked) {
|
|
478
|
+
custom.push(day);
|
|
479
|
+
}
|
|
480
|
+
custom.sort((left, right) => left - right);
|
|
481
|
+
if (!custom.length) {
|
|
482
|
+
errors.set('weekdays', '自定义星期至少选择一天。');
|
|
483
|
+
} else {
|
|
484
|
+
errors.delete('weekdays');
|
|
485
|
+
current.params.weekdays = [...custom];
|
|
486
|
+
}
|
|
487
|
+
emit();
|
|
488
|
+
};
|
|
489
|
+
const renderDays = () => {
|
|
490
|
+
days.hidden = selectedMode !== 'custom';
|
|
491
|
+
days.replaceChildren(...['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map((label, day) => h('label', {}, h('input', {
|
|
492
|
+
type: 'checkbox',
|
|
493
|
+
'data-field': `weekday-${day}`,
|
|
494
|
+
checked: custom.includes(day),
|
|
495
|
+
onchange: event => updateDay(day, event.target.checked)
|
|
496
|
+
}), label)));
|
|
497
|
+
};
|
|
498
|
+
const choices = [{
|
|
499
|
+
value: 'all',
|
|
500
|
+
label: '每天'
|
|
501
|
+
}, {
|
|
502
|
+
value: 'workdays',
|
|
503
|
+
label: '工作日(周一至周五)'
|
|
504
|
+
}, {
|
|
505
|
+
value: 'weekend',
|
|
506
|
+
label: '周末'
|
|
507
|
+
}, {
|
|
508
|
+
value: 'custom',
|
|
509
|
+
label: '自定义星期'
|
|
510
|
+
}];
|
|
511
|
+
if (selectedMode === 'raw') {
|
|
512
|
+
choices.push({
|
|
513
|
+
value: 'raw',
|
|
514
|
+
label: `已导入:${String(value)}`
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
wrap.append(dropdown('weekdays', selectedMode, choices, next => {
|
|
518
|
+
selectedMode = next;
|
|
519
|
+
errors.delete('weekdays');
|
|
520
|
+
if (next === 'workdays') {
|
|
521
|
+
custom = [0, 1, 2, 3, 4];
|
|
522
|
+
}
|
|
523
|
+
if (next === 'weekend') {
|
|
524
|
+
custom = [5, 6];
|
|
525
|
+
}
|
|
526
|
+
if (next === 'all') {
|
|
527
|
+
custom = [0, 1, 2, 3, 4, 5, 6];
|
|
528
|
+
}
|
|
529
|
+
if (next === 'custom') {
|
|
530
|
+
current.params.weekdays = [...custom];
|
|
531
|
+
} else {
|
|
532
|
+
current.params.weekdays = next;
|
|
533
|
+
}
|
|
534
|
+
renderDays();
|
|
535
|
+
emit();
|
|
536
|
+
}), days);
|
|
537
|
+
renderDays();
|
|
538
|
+
return wrap;
|
|
539
|
+
}
|
|
540
|
+
function charsetControl() {
|
|
541
|
+
const aliases = {
|
|
542
|
+
alphanum: 'alphanumeric',
|
|
543
|
+
letters_digits: 'alphanumeric',
|
|
544
|
+
ascii_letters_digits: 'alphanumeric',
|
|
545
|
+
letters: 'alpha',
|
|
546
|
+
ascii_letters: 'alpha',
|
|
547
|
+
numeric: 'digits',
|
|
548
|
+
numbers: 'digits'
|
|
549
|
+
};
|
|
550
|
+
const value = current.params.charset;
|
|
551
|
+
let mode;
|
|
552
|
+
if (value == null) {
|
|
553
|
+
mode = 'default';
|
|
554
|
+
} else {
|
|
555
|
+
mode = aliases[value] || (['alphanumeric', 'alpha', 'digits'].includes(value) ? value : 'custom');
|
|
556
|
+
}
|
|
557
|
+
const custom = textControl('charset', mode === 'custom' ? value : '', raw => {
|
|
558
|
+
if (mode === 'custom' && !raw) {
|
|
559
|
+
throw new Error('请至少填写一个允许出现的字符。');
|
|
560
|
+
}
|
|
561
|
+
return raw;
|
|
562
|
+
}, raw => {
|
|
563
|
+
if (mode === 'custom') {
|
|
564
|
+
current.params.charset = raw;
|
|
565
|
+
}
|
|
566
|
+
}, {
|
|
567
|
+
placeholder: '例如 ABCDEF0123456789 或 甲乙丙丁'
|
|
568
|
+
});
|
|
569
|
+
custom.setAttribute('aria-label', '自定义字符集');
|
|
570
|
+
custom.hidden = mode !== 'custom';
|
|
571
|
+
return h('div', {}, dropdown('charset-preset', mode, [{
|
|
572
|
+
value: 'default',
|
|
573
|
+
label: '默认:字母、数字、空格、下划线、连字符'
|
|
574
|
+
}, {
|
|
575
|
+
value: 'alphanumeric',
|
|
576
|
+
label: '字母和数字(A–Z / a–z / 0–9)'
|
|
577
|
+
}, {
|
|
578
|
+
value: 'alpha',
|
|
579
|
+
label: '仅英文字母'
|
|
580
|
+
}, {
|
|
581
|
+
value: 'digits',
|
|
582
|
+
label: '仅数字'
|
|
583
|
+
}, {
|
|
584
|
+
value: 'custom',
|
|
585
|
+
label: '自定义字符'
|
|
586
|
+
}], next => {
|
|
587
|
+
mode = next;
|
|
588
|
+
errors.delete('charset');
|
|
589
|
+
custom.removeAttribute('aria-invalid');
|
|
590
|
+
custom.hidden = next !== 'custom';
|
|
591
|
+
if (next === 'default') {
|
|
592
|
+
delete current.params.charset;
|
|
593
|
+
} else if (next === 'custom') {
|
|
594
|
+
if (!custom.value) {
|
|
595
|
+
custom.value = 'ABCDEF0123456789';
|
|
596
|
+
}
|
|
597
|
+
current.params.charset = custom.value;
|
|
598
|
+
} else {
|
|
599
|
+
current.params.charset = next;
|
|
600
|
+
}
|
|
601
|
+
emit();
|
|
602
|
+
}), custom);
|
|
603
|
+
}
|
|
604
|
+
function parameterControl(parameter) {
|
|
605
|
+
const value = current.params[parameter.name];
|
|
606
|
+
if (['start_date', 'end_date', 'start_time', 'end_time'].includes(parameter.name)) {
|
|
607
|
+
return calendarControl(parameter);
|
|
608
|
+
}
|
|
609
|
+
if (parameter.name === 'weekdays') {
|
|
610
|
+
return weekdayControl();
|
|
611
|
+
}
|
|
612
|
+
if (parameter.name === 'charset') {
|
|
613
|
+
return charsetControl();
|
|
614
|
+
}
|
|
615
|
+
const write = parsed => {
|
|
616
|
+
if (parsed === undefined) {
|
|
617
|
+
delete current.params[parameter.name];
|
|
618
|
+
} else {
|
|
619
|
+
current.params[parameter.name] = parsed;
|
|
620
|
+
}
|
|
621
|
+
if (['start_year', 'end_year'].includes(parameter.name)) {
|
|
622
|
+
const dateField = parameter.name.replace('_year', '_date');
|
|
623
|
+
const input = el.querySelector(`[data-field="${dateField}"]`);
|
|
624
|
+
if (input && current.params[dateField] == null && !dateTextDrafts.has(dateField)) {
|
|
625
|
+
datePickers.get(dateField)?.setValue(dateDefault(dateField));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
if (parameter.choices?.length) {
|
|
630
|
+
return dropdown(parameter.name, JSON.stringify(value ?? parameter.default), parameter.choices.map(choice => ({
|
|
631
|
+
value: JSON.stringify(choice),
|
|
632
|
+
label: String(choice)
|
|
633
|
+
})), selected => {
|
|
634
|
+
write(JSON.parse(selected));
|
|
635
|
+
emit();
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
if (parameter.type === 'boolean') {
|
|
639
|
+
return h('input', {
|
|
640
|
+
type: 'checkbox',
|
|
641
|
+
'data-field': parameter.name,
|
|
642
|
+
checked: value ?? parameter.default ?? false,
|
|
643
|
+
onchange: event => {
|
|
644
|
+
write(event.target.checked);
|
|
645
|
+
if (parameter.name === 'all_day') {
|
|
646
|
+
for (const name of ['start_time', 'end_time']) {
|
|
647
|
+
const input = el.querySelector(`[data-field="${name}"]`);
|
|
648
|
+
if (input) {
|
|
649
|
+
input.disabled = event.target.checked;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
emit();
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
const json = ['object', 'array', 'json'].includes(parameter.type);
|
|
658
|
+
let raw;
|
|
659
|
+
if (value === undefined || value === null) {
|
|
660
|
+
raw = '';
|
|
661
|
+
} else if (json) {
|
|
662
|
+
raw = JSON.stringify(value, null, 2);
|
|
663
|
+
} else {
|
|
664
|
+
raw = String(value);
|
|
665
|
+
}
|
|
666
|
+
let defaultHint;
|
|
667
|
+
if (parameter.required) {
|
|
668
|
+
defaultHint = '请填写必填参数';
|
|
669
|
+
} else if (parameter.default == null) {
|
|
670
|
+
defaultHint = '留空使用生成器默认值';
|
|
671
|
+
} else {
|
|
672
|
+
defaultHint = `默认:${JSON.stringify(parameter.default)}`;
|
|
673
|
+
}
|
|
674
|
+
const createControl = json ? multilineControl : textControl;
|
|
675
|
+
const options = {
|
|
676
|
+
placeholder: defaultHint
|
|
677
|
+
};
|
|
678
|
+
if (!json) {
|
|
679
|
+
options.type = ['integer', 'number'].includes(parameter.type) ? 'number' : 'text';
|
|
680
|
+
}
|
|
681
|
+
return createControl(parameter.name, raw, text => parseParam(parameter, text), write, options);
|
|
682
|
+
}
|
|
683
|
+
function renderSource() {
|
|
684
|
+
const entry = entries.get(current.generator);
|
|
685
|
+
const labelFor = item => genLabel(item.id) === item.id ? item.label || item.id : genLabel(item.id);
|
|
686
|
+
const chooser = h('div', {
|
|
687
|
+
class: 'generator-chooser',
|
|
688
|
+
'data-field': 'generator'
|
|
689
|
+
});
|
|
690
|
+
const options = h('div', {
|
|
691
|
+
class: 'generator-options',
|
|
692
|
+
role: 'group',
|
|
693
|
+
'aria-label': '可用生成器'
|
|
694
|
+
});
|
|
695
|
+
const search = h('input', {
|
|
696
|
+
type: 'search',
|
|
697
|
+
'data-field': 'generator-search',
|
|
698
|
+
placeholder: '查找生成器',
|
|
699
|
+
'aria-label': '查找生成器',
|
|
700
|
+
autocomplete: 'off'
|
|
701
|
+
});
|
|
702
|
+
const all = h('input', {
|
|
703
|
+
type: 'checkbox',
|
|
704
|
+
'data-field': 'generator-show-all'
|
|
705
|
+
});
|
|
706
|
+
let composing = false,
|
|
707
|
+
pickerOpen = false;
|
|
708
|
+
const renderOptions = () => {
|
|
709
|
+
if (disposed) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const query = search.value.trim().toLocaleLowerCase();
|
|
713
|
+
const available = [...entries.values()].filter(item => (all.checked || item.id === current.generator || compatibleOutput(column, item)) && `${labelFor(item)} ${item.id} ${genGuide(item.id).purpose}`.toLocaleLowerCase().includes(query)).sort((left, right) => recommendationScore(right) - recommendationScore(left));
|
|
714
|
+
options.replaceChildren(...available.map(item => h('button', {
|
|
715
|
+
type: 'button',
|
|
716
|
+
'data-generator': item.id,
|
|
717
|
+
'aria-pressed': String(item.id === current.generator),
|
|
718
|
+
onclick: () => {
|
|
719
|
+
if (disposed) {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
preserveSharedInvalid();
|
|
723
|
+
current.generator = item.id;
|
|
724
|
+
current.params = {};
|
|
725
|
+
withoutNative(current);
|
|
726
|
+
render();
|
|
727
|
+
emit();
|
|
728
|
+
}
|
|
729
|
+
}, h('span', {}, labelFor(item)), h('small', {}, item.id), h('span', {
|
|
730
|
+
class: 'wb-generator-purpose'
|
|
731
|
+
}, genGuide(item.id).purpose), genGuide(item.id).example ? h('small', {
|
|
732
|
+
class: 'wb-generator-example'
|
|
733
|
+
}, `示例:${genGuide(item.id).example}`) : null)));
|
|
734
|
+
if (!available.length) {
|
|
735
|
+
options.append(h('p', {
|
|
736
|
+
class: 'editor-note'
|
|
737
|
+
}, '没有匹配的生成器。'));
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
search.oncompositionstart = () => {
|
|
741
|
+
composing = true;
|
|
742
|
+
};
|
|
743
|
+
search.oncompositionend = () => {
|
|
744
|
+
composing = false;
|
|
745
|
+
renderOptions();
|
|
746
|
+
};
|
|
747
|
+
search.oninput = event => {
|
|
748
|
+
if (!composing && !event.isComposing) {
|
|
749
|
+
renderOptions();
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
all.onchange = renderOptions;
|
|
753
|
+
const picker = h('div', {
|
|
754
|
+
class: 'generator-picker',
|
|
755
|
+
hidden: true
|
|
756
|
+
}, search, h('label', {
|
|
757
|
+
class: 'generator-all'
|
|
758
|
+
}, all, '显示全部生成器'), options, h('p', {
|
|
759
|
+
class: 'editor-note'
|
|
760
|
+
}, '按字段类型推荐;完整兼容性与约束由检查配置验证。'));
|
|
761
|
+
const toggle = h('button', {
|
|
762
|
+
type: 'button',
|
|
763
|
+
class: 'btn small',
|
|
764
|
+
'data-generator-toggle': '',
|
|
765
|
+
'aria-expanded': 'false',
|
|
766
|
+
onclick: () => {
|
|
767
|
+
pickerOpen = !pickerOpen;
|
|
768
|
+
picker.hidden = !pickerOpen;
|
|
769
|
+
toggle.setAttribute('aria-expanded', String(pickerOpen));
|
|
770
|
+
if (pickerOpen) {
|
|
771
|
+
renderOptions();
|
|
772
|
+
search.focus();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}, entry ? labelFor(entry) : '选择生成器', h('small', {}, '更换'));
|
|
776
|
+
chooser.append(h('div', {
|
|
777
|
+
class: 'generator-heading'
|
|
778
|
+
}, h('span', {}, '生成器'), toggle), picker);
|
|
779
|
+
el.append(chooser);
|
|
780
|
+
if (!entry) {
|
|
781
|
+
errors.set('generator', `生成器 ${current.generator || '(未指定)'} 不在当前可用列表中。`);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const guide = genGuide(entry.id);
|
|
785
|
+
el.append(h('div', {
|
|
786
|
+
class: 'wb-generator-guide'
|
|
787
|
+
}, h('p', {
|
|
788
|
+
class: 'editor-note wb-editor-description'
|
|
789
|
+
}, guide.purpose), guide.example ? h('small', {
|
|
790
|
+
class: 'wb-generator-example'
|
|
791
|
+
}, `格式示例:${guide.example}。实际样例由当前引擎和规则生成。`) : null));
|
|
792
|
+
const params = h('div', {
|
|
793
|
+
class: 'rule-parameters wb-editor-params'
|
|
794
|
+
});
|
|
795
|
+
const years = [];
|
|
796
|
+
if (entry.params.some(parameter => parameter.name === 'start_date')) {
|
|
797
|
+
params.append(h('div', {
|
|
798
|
+
class: 'wb-param-presets'
|
|
799
|
+
}, h('span', {}, '日期范围'), h('button', {
|
|
800
|
+
type: 'button',
|
|
801
|
+
class: 'btn small',
|
|
802
|
+
'data-date-preset': 'this-year',
|
|
803
|
+
onclick: () => {
|
|
804
|
+
const year = new Date().getFullYear();
|
|
805
|
+
current.params.start_date = `${year}-01-01`;
|
|
806
|
+
current.params.end_date = `${year}-12-31`;
|
|
807
|
+
delete current.params.start_year;
|
|
808
|
+
delete current.params.end_year;
|
|
809
|
+
preserveDatePresetInvalid();
|
|
810
|
+
render();
|
|
811
|
+
emit();
|
|
812
|
+
}
|
|
813
|
+
}, '本年'), h('button', {
|
|
814
|
+
type: 'button',
|
|
815
|
+
class: 'btn small',
|
|
816
|
+
'data-date-preset': 'today',
|
|
817
|
+
onclick: () => {
|
|
818
|
+
const now = new Date();
|
|
819
|
+
const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
820
|
+
current.params.start_date = day;
|
|
821
|
+
current.params.end_date = day;
|
|
822
|
+
delete current.params.start_year;
|
|
823
|
+
delete current.params.end_year;
|
|
824
|
+
preserveDatePresetInvalid();
|
|
825
|
+
render();
|
|
826
|
+
emit();
|
|
827
|
+
}
|
|
828
|
+
}, '今天')));
|
|
829
|
+
}
|
|
830
|
+
for (const parameter of entry.params) {
|
|
831
|
+
if (['start_year', 'end_year'].includes(parameter.name)) {
|
|
832
|
+
years.push(parameter);
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
params.append(row(paramLabel(parameter.name) + (parameter.required ? ' *' : ''), parameterControl(parameter), parameterHint(parameter)));
|
|
836
|
+
}
|
|
837
|
+
if (years.length) {
|
|
838
|
+
const legacy = h('details', {
|
|
839
|
+
class: 'wb-editor-year-fallback'
|
|
840
|
+
}, h('summary', {}, '年份兼容设置'), h('p', {
|
|
841
|
+
class: 'editor-note'
|
|
842
|
+
}, '仅在对应日期留空时生效;已导入的年份配置会保留。'));
|
|
843
|
+
for (const parameter of years) {
|
|
844
|
+
legacy.append(row(paramLabel(parameter.name), parameterControl(parameter), parameterHint(parameter)));
|
|
845
|
+
}
|
|
846
|
+
params.append(legacy);
|
|
847
|
+
}
|
|
848
|
+
if (!entry.params.length) {
|
|
849
|
+
params.append(h('p', {
|
|
850
|
+
class: 'muted'
|
|
851
|
+
}, '此生成器没有可配置参数。'));
|
|
852
|
+
}
|
|
853
|
+
el.append(params);
|
|
854
|
+
}
|
|
855
|
+
function renderDerived() {
|
|
856
|
+
const source = Array.isArray(current.derive_from) ? current.derive_from.join(', ') : current.derive_from || '';
|
|
857
|
+
const names = new Set(table.columns.map(item => item.name));
|
|
858
|
+
el.append(row('来源字段', textControl('derive_from', source, raw => {
|
|
859
|
+
const selected = raw.split(',').map(name => name.trim()).filter(Boolean);
|
|
860
|
+
if (!selected.length) {
|
|
861
|
+
throw new Error('派生列需要至少一个来源字段。');
|
|
862
|
+
}
|
|
863
|
+
const missing = selected.find(name => !names.has(name));
|
|
864
|
+
if (missing) {
|
|
865
|
+
throw new Error(`来源字段 ${missing} 不存在。`);
|
|
866
|
+
}
|
|
867
|
+
if (selected.includes(column.name)) {
|
|
868
|
+
throw new Error('派生列不能引用自身。');
|
|
869
|
+
}
|
|
870
|
+
return selected.length === 1 && !Array.isArray(current.derive_from) ? selected[0] : selected;
|
|
871
|
+
}, parsed => {
|
|
872
|
+
current.derive_from = parsed;
|
|
873
|
+
}), '多个字段以英文逗号分隔。'));
|
|
874
|
+
el.append(row('派生表达式', multilineControl('expression', current.expression || '', raw => {
|
|
875
|
+
if (!raw.trim()) {
|
|
876
|
+
throw new Error('请填写派生表达式。');
|
|
877
|
+
}
|
|
878
|
+
return raw;
|
|
879
|
+
}, parsed => {
|
|
880
|
+
current.expression = parsed;
|
|
881
|
+
}), '表达式可用 value 或 row["字段名"];完整语法由检查配置验证。'));
|
|
882
|
+
}
|
|
883
|
+
function renderForeignKey() {
|
|
884
|
+
for (const key of foreignKeys) {
|
|
885
|
+
const source = `${key.ref_schema ? key.ref_schema + '.' : ''}${key.ref_table} (${key.ref_columns.join(', ')})`;
|
|
886
|
+
el.append(h('p', {
|
|
887
|
+
class: 'drawer-help wb-editor-fk'
|
|
888
|
+
}, `引用来源:${source}`));
|
|
889
|
+
if (key.columns.length > 1) {
|
|
890
|
+
el.append(h('p', {
|
|
891
|
+
class: 'muted'
|
|
892
|
+
}, `复合外键 (${key.columns.join(', ')}) 作为同一组引用,由数据库关系规则共同处理。`));
|
|
893
|
+
}
|
|
894
|
+
if (key.ref_table === table.name && column.nullable) {
|
|
895
|
+
el.append(h('p', {
|
|
896
|
+
class: 'muted'
|
|
897
|
+
}, '此列为自引用;空表初始化时 NULL 比例可能受引用顺序影响。'));
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
el.append(row('采样方式', dropdown('strategy', current.params.strategy || 'random', [{
|
|
901
|
+
value: 'random',
|
|
902
|
+
label: '随机采样'
|
|
903
|
+
}, {
|
|
904
|
+
value: 'coverage',
|
|
905
|
+
label: '覆盖父表值'
|
|
906
|
+
}], selected => {
|
|
907
|
+
current.params.strategy = selected;
|
|
908
|
+
emit();
|
|
909
|
+
})));
|
|
910
|
+
}
|
|
911
|
+
function renderNullOptions() {
|
|
912
|
+
// Nullability belongs to the database schema. Field information already
|
|
913
|
+
// exposes NOT NULL; there is no editable NULL option for those columns.
|
|
914
|
+
if (!column.nullable) {
|
|
915
|
+
delete restoredValues.null_ratio;
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const ratio = h('input', {
|
|
919
|
+
type: 'number',
|
|
920
|
+
'data-field': 'null_ratio',
|
|
921
|
+
min: '0',
|
|
922
|
+
max: '100',
|
|
923
|
+
step: 'any',
|
|
924
|
+
value: String((current.null_ratio || 0) * 100),
|
|
925
|
+
disabled: !current.null_ratio
|
|
926
|
+
});
|
|
927
|
+
const percentageRow = row('NULL 百分比', ratio);
|
|
928
|
+
percentageRow.hidden = ratio.disabled;
|
|
929
|
+
const nullable = h('input', {
|
|
930
|
+
type: 'checkbox',
|
|
931
|
+
'data-field': 'nullable',
|
|
932
|
+
checked: Boolean(current.null_ratio),
|
|
933
|
+
onchange: event => {
|
|
934
|
+
ratio.disabled = !event.target.checked;
|
|
935
|
+
percentageRow.hidden = ratio.disabled;
|
|
936
|
+
if (event.target.checked) {
|
|
937
|
+
current.null_ratio = current.null_ratio || 0.05;
|
|
938
|
+
ratio.value = String(current.null_ratio * 100);
|
|
939
|
+
} else {
|
|
940
|
+
delete current.null_ratio;
|
|
941
|
+
}
|
|
942
|
+
mark('null_ratio', ratio, null);
|
|
943
|
+
emit();
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
const checkRatio = commit => {
|
|
947
|
+
const value = Number(ratio.value);
|
|
948
|
+
if (!ratio.value.trim() || !Number.isFinite(value) || value < 0 || value > 100) {
|
|
949
|
+
mark('null_ratio', ratio, 'NULL 百分比必须在 0 到 100 之间。');
|
|
950
|
+
} else {
|
|
951
|
+
mark('null_ratio', ratio, null);
|
|
952
|
+
if (commit) {
|
|
953
|
+
current.null_ratio = value / 100;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (commit) {
|
|
957
|
+
emit();
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
ratio.addEventListener('input', () => checkRatio(true));
|
|
961
|
+
if (Object.hasOwn(restoredValues, 'null_ratio')) {
|
|
962
|
+
ratio.value = restoredValues.null_ratio;
|
|
963
|
+
checkRatio(false);
|
|
964
|
+
}
|
|
965
|
+
delete restoredValues.null_ratio;
|
|
966
|
+
if (current.null_ratio !== undefined && (!Number.isFinite(current.null_ratio) || current.null_ratio < 0 || current.null_ratio > 1)) {
|
|
967
|
+
mark('null_ratio', ratio, 'NULL 百分比必须在 0 到 100 之间。');
|
|
968
|
+
}
|
|
969
|
+
// A reopened invalid draft must stay reachable, including one whose last
|
|
970
|
+
// valid percentage was zero. Hiding it would leave Apply blocked forever.
|
|
971
|
+
if (errors.has('null_ratio')) {
|
|
972
|
+
nullable.checked = true;
|
|
973
|
+
ratio.disabled = false;
|
|
974
|
+
percentageRow.hidden = false;
|
|
975
|
+
}
|
|
976
|
+
el.append(row('包含 NULL 值', nullable), percentageRow);
|
|
977
|
+
}
|
|
978
|
+
function renderCommon() {
|
|
979
|
+
renderNullOptions();
|
|
980
|
+
if (mode !== 'foreign_key') {
|
|
981
|
+
uniqueInput = h('input', {
|
|
982
|
+
type: 'checkbox',
|
|
983
|
+
'data-field': 'unique',
|
|
984
|
+
checked: Boolean(uniqueBySchema || current.constraints?.unique),
|
|
985
|
+
disabled: Boolean(uniqueBySchema),
|
|
986
|
+
onchange: event => {
|
|
987
|
+
current.constraints = {
|
|
988
|
+
...current.constraints,
|
|
989
|
+
unique: Boolean(uniqueBySchema || event.target.checked)
|
|
990
|
+
};
|
|
991
|
+
if (constraintsInput && !errors.has('constraints')) {
|
|
992
|
+
constraintsInput.value = JSON.stringify(current.constraints, null, 2);
|
|
993
|
+
}
|
|
994
|
+
emit();
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
el.append(row('设置唯一', uniqueInput, uniqueBySchema ? '数据库单列唯一约束已锁定。' : '复合主键或复合唯一约束不表示本列单独唯一。'));
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function renderAdvanced() {
|
|
1001
|
+
const advanced = h('details', {
|
|
1002
|
+
class: 'column-constraints wb-editor-advanced'
|
|
1003
|
+
}, h('summary', {}, '高级配置(通常无需调整)'), h('p', {
|
|
1004
|
+
class: 'editor-note'
|
|
1005
|
+
}, '普通生成规则继承全局数据生成引擎与语言。这里保留原生方法、额外约束和导入的高级参数;请在应用后检查配置。'));
|
|
1006
|
+
const objectField = (field, label, value, update) => {
|
|
1007
|
+
const parameter = {
|
|
1008
|
+
name: label,
|
|
1009
|
+
type: 'object',
|
|
1010
|
+
required: false
|
|
1011
|
+
};
|
|
1012
|
+
const input = multilineControl(field, value == null ? '' : JSON.stringify(value, null, 2), raw => parseParam(parameter, raw), update, {
|
|
1013
|
+
placeholder: 'JSON 对象;留空使用默认值'
|
|
1014
|
+
});
|
|
1015
|
+
advanced.append(row(label, input));
|
|
1016
|
+
return input;
|
|
1017
|
+
};
|
|
1018
|
+
constraintsInput = objectField('constraints', '约束', current.constraints, value => {
|
|
1019
|
+
if (value === undefined) {
|
|
1020
|
+
delete current.constraints;
|
|
1021
|
+
} else {
|
|
1022
|
+
current.constraints = value;
|
|
1023
|
+
}
|
|
1024
|
+
if (uniqueBySchema) {
|
|
1025
|
+
current.constraints = {
|
|
1026
|
+
...current.constraints,
|
|
1027
|
+
unique: true
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
if (uniqueInput) {
|
|
1031
|
+
uniqueInput.checked = Boolean(current.constraints?.unique);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
if (mode === 'source') {
|
|
1035
|
+
for (const [field, label] of [['provider', '字段生成引擎'], ['faker_method', 'Faker 原生方法'], ['mimesis_method', 'Mimesis 原生方法']]) {
|
|
1036
|
+
const nativeFieldPlaceholder = () => {
|
|
1037
|
+
if (field === 'provider') {
|
|
1038
|
+
return '留空继承全局,或填写与全局相同的引擎';
|
|
1039
|
+
} else if (field === 'faker_method') {
|
|
1040
|
+
return '例如 email、random_int';
|
|
1041
|
+
} else {
|
|
1042
|
+
return '例如 person.full_name、numeric.integer';
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
advanced.append(row(label, textControl(field, current[field] || '', raw => raw.trim() || undefined, value => {
|
|
1046
|
+
if (value === undefined) {
|
|
1047
|
+
delete current[field];
|
|
1048
|
+
} else {
|
|
1049
|
+
current[field] = value;
|
|
1050
|
+
}
|
|
1051
|
+
}, {
|
|
1052
|
+
placeholder: nativeFieldPlaceholder()
|
|
1053
|
+
}), field === 'provider' ? '当前仅支持继承全局或填写与全局相同的引擎;其他值会保留,但检查会阻止生成。' : '原生方法会优先于普通生成器执行,请按对应引擎的方法签名填写。'));
|
|
1054
|
+
}
|
|
1055
|
+
objectField('native_params', '原生方法参数', current.native_params, value => {
|
|
1056
|
+
if (value === undefined) {
|
|
1057
|
+
delete current.native_params;
|
|
1058
|
+
} else {
|
|
1059
|
+
current.native_params = value;
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
el.append(advanced);
|
|
1064
|
+
}
|
|
1065
|
+
function preserveInvalidField(field) {
|
|
1066
|
+
const input = [...el.querySelectorAll('[data-field]')].find(control => control.dataset.field === field);
|
|
1067
|
+
if (input && ['INPUT', 'TEXTAREA'].includes(input.tagName)) {
|
|
1068
|
+
restoredValues[field] = input.value;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
function preserveDatePresetInvalid() {
|
|
1072
|
+
for (const field of errors.keys()) {
|
|
1073
|
+
if (['start_date', 'end_date', 'start_year', 'end_year'].includes(field)) {
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
preserveInvalidField(field);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
function preserveSharedInvalid() {
|
|
1080
|
+
for (const field of ['constraints', 'null_ratio', 'provider']) {
|
|
1081
|
+
if (!errors.has(field)) {
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
preserveInvalidField(field);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
function render() {
|
|
1088
|
+
dropdowns.forEach(control => control.destroy());
|
|
1089
|
+
dropdowns = [];
|
|
1090
|
+
datePickers.forEach(control => control.destroy());
|
|
1091
|
+
datePickers.clear();
|
|
1092
|
+
dateTextDrafts.clear();
|
|
1093
|
+
errors.clear();
|
|
1094
|
+
constraintsInput = null;
|
|
1095
|
+
uniqueInput = null;
|
|
1096
|
+
applyMode();
|
|
1097
|
+
el.replaceChildren(h('div', {
|
|
1098
|
+
class: 'rule-heading wb-editor-heading'
|
|
1099
|
+
}, h('strong', {
|
|
1100
|
+
class: 'mono'
|
|
1101
|
+
}, column.name), h('span', {
|
|
1102
|
+
class: 'muted'
|
|
1103
|
+
}, column.type || '')));
|
|
1104
|
+
summary = h('p', {
|
|
1105
|
+
class: 'editor-error wb-editor-error',
|
|
1106
|
+
role: 'alert'
|
|
1107
|
+
});
|
|
1108
|
+
el.append(summary);
|
|
1109
|
+
if (readonly) {
|
|
1110
|
+
el.append(h('p', {
|
|
1111
|
+
class: 'muted'
|
|
1112
|
+
}, column.is_computed ? '数据库计算列,由数据库自动计算,规则只读。' : '数据库自增主键,规则只读。' + skipDescription()));
|
|
1113
|
+
report();
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (mode !== 'foreign_key') {
|
|
1117
|
+
const modes = [{
|
|
1118
|
+
value: 'source',
|
|
1119
|
+
label: '生成器'
|
|
1120
|
+
}, {
|
|
1121
|
+
value: 'derived',
|
|
1122
|
+
label: '派生表达式'
|
|
1123
|
+
}];
|
|
1124
|
+
if (canSkip) {
|
|
1125
|
+
modes.push({
|
|
1126
|
+
value: 'skip',
|
|
1127
|
+
label: skipLabel()
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
el.append(row('生成方式', dropdown('mode', mode, modes, next => {
|
|
1131
|
+
preserveSharedInvalid();
|
|
1132
|
+
modeDrafts.set(mode, copy(current));
|
|
1133
|
+
const previous = current;
|
|
1134
|
+
current = copy(modeDrafts.get(next) || current);
|
|
1135
|
+
// Shared options follow the latest edit when returning to another mode.
|
|
1136
|
+
for (const key of ['constraints', 'null_ratio', 'provider']) {
|
|
1137
|
+
if (previous[key] === undefined) {
|
|
1138
|
+
delete current[key];
|
|
1139
|
+
} else {
|
|
1140
|
+
current[key] = copy(previous[key]);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
mode = next;
|
|
1144
|
+
render();
|
|
1145
|
+
emit();
|
|
1146
|
+
})));
|
|
1147
|
+
}
|
|
1148
|
+
if (mode === 'source') {
|
|
1149
|
+
renderSource();
|
|
1150
|
+
} else if (mode === 'derived') {
|
|
1151
|
+
renderDerived();
|
|
1152
|
+
} else if (mode === 'foreign_key') {
|
|
1153
|
+
renderForeignKey();
|
|
1154
|
+
} else {
|
|
1155
|
+
el.append(h('p', {
|
|
1156
|
+
class: 'drawer-help wb-editor-default'
|
|
1157
|
+
}, skipDescription()));
|
|
1158
|
+
}
|
|
1159
|
+
if (mode !== 'skip') {
|
|
1160
|
+
renderCommon();
|
|
1161
|
+
}
|
|
1162
|
+
renderAdvanced();
|
|
1163
|
+
restoredValues = {};
|
|
1164
|
+
el.append(h('button', {
|
|
1165
|
+
type: 'button',
|
|
1166
|
+
class: 'btn small editor-reset wb-editor-reset',
|
|
1167
|
+
onclick: () => {
|
|
1168
|
+
current = copy(base);
|
|
1169
|
+
mode = modeOf(current);
|
|
1170
|
+
modeDrafts.clear();
|
|
1171
|
+
restoredValues = {};
|
|
1172
|
+
render();
|
|
1173
|
+
if (!report()) {
|
|
1174
|
+
onChange?.(null);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}, '重置为推断规则'));
|
|
1178
|
+
report();
|
|
1179
|
+
}
|
|
1180
|
+
render();
|
|
1181
|
+
return {
|
|
1182
|
+
el,
|
|
1183
|
+
getDraft() {
|
|
1184
|
+
const invalidValues = {};
|
|
1185
|
+
for (const field of errors.keys()) {
|
|
1186
|
+
const input = [...el.querySelectorAll('[data-field]')].find(control => control.dataset.field === field);
|
|
1187
|
+
if (input && ['INPUT', 'TEXTAREA'].includes(input.tagName)) {
|
|
1188
|
+
invalidValues[field] = input.value;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return {
|
|
1192
|
+
config: copy(lastValid),
|
|
1193
|
+
current: copy(current),
|
|
1194
|
+
mode,
|
|
1195
|
+
invalidValues
|
|
1196
|
+
};
|
|
1197
|
+
},
|
|
1198
|
+
destroy() {
|
|
1199
|
+
disposed = true;
|
|
1200
|
+
dropdowns.forEach(control => control.destroy());
|
|
1201
|
+
dropdowns = [];
|
|
1202
|
+
datePickers.forEach(control => control.destroy());
|
|
1203
|
+
datePickers.clear();
|
|
1204
|
+
dateTextDrafts.clear();
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
function parameterHint(parameter) {
|
|
1209
|
+
const hints = {
|
|
1210
|
+
start_date: '格式 YYYY-MM-DD;显示当前生效边界。留空沿用年份范围。',
|
|
1211
|
+
end_date: '格式 YYYY-MM-DD;包含结束日期。',
|
|
1212
|
+
start_time: '格式 HH:MM:SS;取消“一整天”后生效。',
|
|
1213
|
+
end_time: '格式 HH:MM:SS;包含结束时间。',
|
|
1214
|
+
all_day: '勾选后可生成 00:00:00–23:59:59 内的时间;取消勾选可指定开始和结束时间。',
|
|
1215
|
+
weekdays: '按星期限制日期;工作日指周一至周五,不排除法定节假日。',
|
|
1216
|
+
charset: '候选字符决定结果中允许出现的字符,不是 UTF-8 等编码名称。',
|
|
1217
|
+
choices: '填写 JSON 数组,例如 ["pending", "paid", "shipped"];数值可写 [1, 2, 3]。',
|
|
1218
|
+
weighted_choices: '填写值和权重组成的 JSON 对象,例如 {"普通": 80, "VIP": 20}。',
|
|
1219
|
+
template: '例如 SKU-{sequence:04d};sequence 表示序号,04d 表示补齐 4 位。',
|
|
1220
|
+
sequence_start: '本次生成从此序号开始;不会读取或重置数据库 ID。',
|
|
1221
|
+
pattern: '例如 [A-Z]{3}[0-9]{4};按此正则生成匹配的字符串。',
|
|
1222
|
+
regex: '正则表达式,例如 [A-Z]{3}[0-9]{4}。',
|
|
1223
|
+
mask: '例如 1##########;# 用随机数字替换。',
|
|
1224
|
+
schema: 'JSON Schema 对象,例如 {"type":"object","properties":{"active":{"type":"boolean"}}}。'
|
|
1225
|
+
};
|
|
1226
|
+
function defaultParameterHint() {
|
|
1227
|
+
if (parameter.required) {
|
|
1228
|
+
return '必填参数。';
|
|
1229
|
+
} else if (parameter.default == null) {
|
|
1230
|
+
return '可选;留空由生成器决定。';
|
|
1231
|
+
} else {
|
|
1232
|
+
return `留空使用默认值:${String(parameter.default)}。`;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
return hints[parameter.name] || defaultParameterHint();
|
|
1236
|
+
}
|