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.
Files changed (87) hide show
  1. sqlseed_web/AGENTS.md +106 -0
  2. sqlseed_web/__init__.py +24 -0
  3. sqlseed_web/__main__.py +8 -0
  4. sqlseed_web/_application.py +205 -0
  5. sqlseed_web/ai_settings.py +290 -0
  6. sqlseed_web/api.py +1102 -0
  7. sqlseed_web/app.py +26 -0
  8. sqlseed_web/managed_worker.py +184 -0
  9. sqlseed_web/operation_errors.py +42 -0
  10. sqlseed_web/plugin_environment.py +231 -0
  11. sqlseed_web/plugin_management.py +322 -0
  12. sqlseed_web/plugin_process.py +131 -0
  13. sqlseed_web/runtime_lifecycle.py +130 -0
  14. sqlseed_web/runtime_session.py +97 -0
  15. sqlseed_web/settings_environment.py +368 -0
  16. sqlseed_web/sqlite_target.py +86 -0
  17. sqlseed_web/state.py +348 -0
  18. sqlseed_web/static/AGENTS.md +180 -0
  19. sqlseed_web/static/ai.css +57 -0
  20. sqlseed_web/static/configs.css +50 -0
  21. sqlseed_web/static/date-picker.css +230 -0
  22. sqlseed_web/static/disclosure.css +149 -0
  23. sqlseed_web/static/graph-clarity.css +103 -0
  24. sqlseed_web/static/index.html +29 -0
  25. sqlseed_web/static/js/api.js +228 -0
  26. sqlseed_web/static/js/app.js +97 -0
  27. sqlseed_web/static/js/dropdown.js +432 -0
  28. sqlseed_web/static/js/filepicker.js +203 -0
  29. sqlseed_web/static/js/genform.js +1066 -0
  30. sqlseed_web/static/js/labels.js +195 -0
  31. sqlseed_web/static/js/pages/browse.js +209 -0
  32. sqlseed_web/static/js/pages/configs.js +424 -0
  33. sqlseed_web/static/js/pages/connect.js +332 -0
  34. sqlseed_web/static/js/pages/heal.js +395 -0
  35. sqlseed_web/static/js/pages/meta.js +110 -0
  36. sqlseed_web/static/js/pages/runs.js +293 -0
  37. sqlseed_web/static/js/pages/settings.js +942 -0
  38. sqlseed_web/static/js/pages/wizard.js +751 -0
  39. sqlseed_web/static/js/pages/workbench.js +3123 -0
  40. sqlseed_web/static/js/tree.js +126 -0
  41. sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
  42. sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
  43. sqlseed_web/static/js/workbench/ai-stream.js +116 -0
  44. sqlseed_web/static/js/workbench/ai.js +888 -0
  45. sqlseed_web/static/js/workbench/connection.js +508 -0
  46. sqlseed_web/static/js/workbench/date-picker.js +445 -0
  47. sqlseed_web/static/js/workbench/dependency-view.js +119 -0
  48. sqlseed_web/static/js/workbench/editor.js +1236 -0
  49. sqlseed_web/static/js/workbench/focus.js +11 -0
  50. sqlseed_web/static/js/workbench/graph-layout.js +332 -0
  51. sqlseed_web/static/js/workbench/graph.js +970 -0
  52. sqlseed_web/static/js/workbench/guidance.js +29 -0
  53. sqlseed_web/static/js/workbench/model.js +124 -0
  54. sqlseed_web/static/js/workbench/plugin-management.js +512 -0
  55. sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
  56. sqlseed_web/static/js/workbench/preview.js +572 -0
  57. sqlseed_web/static/js/workbench/provider-guide.js +33 -0
  58. sqlseed_web/static/js/workbench/recovery.js +28 -0
  59. sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
  60. sqlseed_web/static/js/workbench/session.js +174 -0
  61. sqlseed_web/static/js/workbench/table-data.js +186 -0
  62. sqlseed_web/static/js/workbench/ui.js +262 -0
  63. sqlseed_web/static/navigation.css +92 -0
  64. sqlseed_web/static/preview.css +29 -0
  65. sqlseed_web/static/runs.css +53 -0
  66. sqlseed_web/static/scrollbars.css +42 -0
  67. sqlseed_web/static/settings.css +108 -0
  68. sqlseed_web/static/style.css +3382 -0
  69. sqlseed_web/static/table-data.css +27 -0
  70. sqlseed_web/static/workbench.css +509 -0
  71. sqlseed_web/supervised_plugins.py +173 -0
  72. sqlseed_web/supervisor.py +238 -0
  73. sqlseed_web/workbench.py +381 -0
  74. sqlseed_web/workbench_ai.py +887 -0
  75. sqlseed_web/workbench_ai_relations.py +285 -0
  76. sqlseed_web/workbench_ai_stream.py +172 -0
  77. sqlseed_web/workbench_data.py +163 -0
  78. sqlseed_web/workbench_execution.py +199 -0
  79. sqlseed_web/workbench_runtime.py +1218 -0
  80. sqlseed_web/workbench_schema.py +277 -0
  81. sqlseed_web/workbench_store.py +458 -0
  82. sqlseed_web/worker_control.py +192 -0
  83. sqlseed_web-0.2.4.dist-info/METADATA +105 -0
  84. sqlseed_web-0.2.4.dist-info/RECORD +87 -0
  85. sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
  86. sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
  87. sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
@@ -0,0 +1,1066 @@
1
+ // 列属性面板(参考工具 Step2 右栏)七段式布局:
2
+ // 字段名 + 类型副标题 → 生成器下拉 → 类型专属参数区 → 预览 + 刷新 →
3
+ // 通用区(NULL 百分比 / 唯一)→ 重置属性
4
+ // 布局顺序与「通用区按生成器裁剪」矩阵严格对照
5
+ // docs/superpowers/plans/generator_ui_reference.md(§1.3 裁剪矩阵 / §9 检查要点)。
6
+
7
+ import { h, clear, post, msg } from './api.js';
8
+ import { createDropdown } from './dropdown.js';
9
+ import { openFilePicker } from './filepicker.js';
10
+ import { genLabel, paramLabel, groupGenerators, PENDING_GROUP_HINT } from './labels.js';
11
+
12
+ // 参考工具:勾选「包含 NULL 值」后百分比框默认 5,且未勾选时处于禁用态(§9.5)。
13
+ const DEFAULT_PERCENT = 5;
14
+
15
+ // 通用区裁剪矩阵(§1.3)。
16
+ // 注意:参考工具 的「序列」在 sqlseed 里对应的是 `skip`(自增列由数据库生成,
17
+ // 不在用户可选的生成器下拉里),不是 `template`——template 可含随机片段
18
+ // (如 SKU-{random_string:4}-{sequence:03d}),NULL% 与唯一对它都有意义。
19
+ // 曾误按「序列 → 通用区全无」裁掉 template,导致这两项不可见也不可改。
20
+ // 所有可编辑的普通生成器均保留 NULL 设置;自增列已由只读面板处理。
21
+ // 词表类(枚举 / 文本)值域有限,「设置唯一」无意义;图像或二进制同样不提供。
22
+ const NO_UNIQUE_GENS = new Set(['text', 'choice', 'weighted_choice', 'bytes']);
23
+ // 图像或二进制没有例值预览区(参考工具 该面板无预览)。
24
+ const NO_PREVIEW_GENS = new Set(['bytes']);
25
+
26
+ // 参数控件形态:显式列举而非用 /min|max|length/ 之类的名字正则匹配——
27
+ // precision、start_year 曾因此被误渲染成文本框。
28
+ const NUMERIC_PARAMS = new Set(['min_value', 'max_value', 'min_length', 'max_length', 'precision', 'length', 'width', 'height', 'start_year', 'end_year', 'sequence_start', 'sequence_step', 'n', 'num_words', 'value']);
29
+ const TEXTAREA_PARAMS = new Set(['pattern', 'regex', 'template']);
30
+ const PARAM_PLACEHOLDERS = {
31
+ charset: '留空 = 默认字符集(字母/数字/空格/_/-)',
32
+ mask: '号码模板,如 1##-####-####(# 为随机数字)',
33
+ schema: '{"type":"object","properties":{"name":{"type":"string"}}}',
34
+ template: '支持 {sequence}、{random:3} 等占位符',
35
+ folder: '服务器本地文件夹路径,如 /Users/you/Pictures',
36
+ extensions: '逗号分隔,如: png,jpg,svg',
37
+ pattern: '[0-9]{11}',
38
+ regex: '[0-9]{11}',
39
+ start_year: '2000',
40
+ end_year: '2026',
41
+ min_length: '1',
42
+ max_length: '100'
43
+ };
44
+
45
+ // 图像格式:核心 _gen_bytes 仅识别 png / jpeg(jpeg 需 Pillow,否则回退 PNG),
46
+ // 用下拉代替自由文本输入,避免写入无效值。
47
+ const IMAGE_FORMATS = [{
48
+ value: 'png',
49
+ label: 'PNG'
50
+ }, {
51
+ value: 'jpeg',
52
+ label: 'JPEG(需安装 Pillow,否则回退 PNG)'
53
+ }];
54
+
55
+ // 日期时间三件套(参考工具 日期/时间/日期时间面板)。
56
+ // 参数在面板上的排列顺序:精确日期优先,年份作为兼容项靠后。
57
+ const PARAM_ORDER = ['start_date', 'end_date', 'all_day', 'start_time', 'end_time', 'weekdays', 'start_year', 'end_year'];
58
+ // 有了精确日期参数后,年份参数降级为「旧配置兼容回退」:核心仍接受
59
+ // start_year / end_year(旧 YAML 继续生效),但仅在 start_date / end_date 未提供时生效。
60
+ // 面板不再暴露它们——两套边界并排会让人不知道谁优先。
61
+ // 另外 pattern 的 regex 是 pattern 的别名(核心 effective = pattern || regex),
62
+ // weighted_choice 的 choices 是 [{value,weight}] 对象列表(「每行一个值」的
63
+ // textarea 无法编辑,其字符串数组形式核心会崩)——各只保留一个主键。
64
+ const HIDDEN_PARAMS = {
65
+ date: new Set(['start_year', 'end_year']),
66
+ datetime: new Set(['start_year', 'end_year']),
67
+ timestamp: new Set(['start_year', 'end_year']),
68
+ pattern: new Set(['regex']),
69
+ weighted_choice: new Set(['choices'])
70
+ };
71
+
72
+ // 日期类生成器:AI 回填的 YAML 经常整个省略 params(LLM 倾向不写可选字段,
73
+ // 实测 gemma4:31b-cloud 对 date/datetime 一律返回空 params),面板就会一片空白。
74
+ // 此时填入核心默认值——核心本来就这么跑,显示出来比留空更贴近 参考工具
75
+ // (其日期面板同样预填 2000-01-01 / 今天)。
76
+ const DATE_DEFAULT_GENS = new Set(['date', 'datetime', 'timestamp']);
77
+
78
+ // 星期:Monday=0 … Sunday=6(与核心 normalize_weekdays 一致)
79
+ const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
80
+
81
+ // 空参数必然让核心抛错的生成器(实测仅这两个):切换生成器后参数区还是空的,
82
+ // 400ms 防抖预览就会发一个注定失败的请求。与其闪一条「预览失败」,不如提示待填。
83
+ const REQUIRED_PARAMS = {
84
+ choice: ['choices'],
85
+ weighted_choice: ['weighted_choices']
86
+ };
87
+ const WEEKDAY_MODES = [{
88
+ value: 'all',
89
+ label: '全部'
90
+ }, {
91
+ value: 'workdays',
92
+ label: '工作日'
93
+ }, {
94
+ value: 'custom',
95
+ label: '自定义'
96
+ }];
97
+
98
+ /**
99
+ * @param {object} opts
100
+ * @param {string} opts.connId
101
+ * @param {object} opts.meta - /api/meta/generators 响应(names + params)
102
+ * @param {(table: string) => object[]} opts.foreignKeysOf - 数据库 schema 的外键关系
103
+ * @param {(table: string, col: string, cfg: object|null) => void} opts.onChange
104
+ * cfg 为该列的 ColumnConfig 形状(generator/params/null_ratio/constraints),
105
+ * null 表示跟随零配置推断。
106
+ */
107
+ export function createGenForm({
108
+ connId,
109
+ meta,
110
+ uniqueColumnsOf,
111
+ foreignKeysOf,
112
+ onChange
113
+ }) {
114
+ function generatorOptions() {
115
+ const genOpts = [];
116
+ for (const grp of groupGenerators(meta.names)) {
117
+ if (grp.pending) {
118
+ genOpts.push({
119
+ value: `__pending_${grp.title}`,
120
+ label: PENDING_GROUP_HINT,
121
+ group: grp.title,
122
+ disabled: true
123
+ });
124
+ continue;
125
+ }
126
+ for (const name of grp.names) {
127
+ genOpts.push({
128
+ value: name,
129
+ label: `${genLabel(name)}(${name})`,
130
+ group: grp.title
131
+ });
132
+ }
133
+ }
134
+ return genOpts;
135
+ }
136
+
137
+ const el = h('div', {
138
+ class: 'genform'
139
+ });
140
+ let current = null; // {table, col, colInfo, inferred, zeroConfig}
141
+ let form = {}; // {generator, params, null_ratio, unique, constraints, options}
142
+ let previewBox = null; // 当前预览容器(render() 时更新)
143
+ let previewTimer = null;
144
+ let paramsHolder = null; // 参数区容器(保留引用,不按 section 顺序查找)
145
+ let bytesModeState = null; // bytes 双模式的显式选择('image'|'folder'|null=按参数推导)
146
+ // 数据库唯一列查询回调(wizard 注入):table → Set<column>。缺省视为空集。
147
+ const uniqueColsOf = uniqueColumnsOf || (() => new Set());
148
+ const foreignKeys = () => current ? (foreignKeysOf?.(current.table) || []).filter(fk => fk.column === current.col) : [];
149
+ let nullPctInput = null; // NULL 百分比输入框(勾选框切换时联动禁用态)
150
+ // 本轮 render() 创建的 dropdown。render() 会整体重建 DOM,若不先 destroy,
151
+ // 旧的 scroll/mousedown 监听会残留在 document 上(面板开着被丢弃时)。
152
+ let dropdowns = [];
153
+ const paramErrors = new Map(); // 无效编辑草稿不写入 form.params,也不用于预览。
154
+
155
+ // 防抖自动预览:生成器/参数/NULL/唯一任一变化后 400ms 刷新例值。
156
+ // 之前只在选中列和手动「刷新」时预览,改参数后一直显示旧值(实测发现)。
157
+ function schedulePreview() {
158
+ if (previewTimer) clearTimeout(previewTimer);
159
+ previewTimer = setTimeout(() => {
160
+ previewTimer = null;
161
+ if (current && previewBox?.isConnected) doPreview(previewBox);
162
+ }, 400);
163
+ }
164
+ function cleanParams() {
165
+ const out = {};
166
+ for (const [k, v] of Object.entries(form.params)) {
167
+ if (v === undefined || v === null || v === '') continue;
168
+ out[k] = v;
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /** 组装单列 ColumnConfig(emit 与预览共用,避免两处规则漂移)。 */
174
+ function buildCfg() {
175
+ if (foreignKeys().length && !isDbGenerated()) {
176
+ return foreignKeyConfig();
177
+ }
178
+ // 派生列走 ColumnConfig 的 derived 模式(derive_from + expression),
179
+ // 与 generator 互斥——退化成 generator 会静默改写 AI 的配置,并在参与
180
+ // 跨列 CHECK 时因类型不匹配直接预览失败。
181
+ const cfg = {
182
+ ...form.options
183
+ };
184
+ if (form.derived) {
185
+ cfg.derive_from = form.derived;
186
+ if (form.expression) cfg.expression = form.expression;
187
+ } else {
188
+ cfg.generator = form.generator;
189
+ cfg.params = cleanParams();
190
+ }
191
+ const constraints = {
192
+ ...form.constraints
193
+ };
194
+ // form.null_ratio 是 0–100 百分比;核心 ColumnConfig.null_ratio 是 0–1
195
+ // 小数(le=1.0)——发送前必须除以 100,否则 preview/fill 直接 422。
196
+ // 数据库硬约束兜底:NOT NULL 强制不带 null_ratio;数据库唯一强制 unique。
197
+ if (form.null_ratio > 0 && !dbNotNull()) cfg.null_ratio = form.null_ratio / 100;
198
+ if (dbUnique() || !NO_UNIQUE_GENS.has(form.generator) && form.unique) {
199
+ constraints.unique = true;
200
+ } else {
201
+ delete constraints.unique;
202
+ }
203
+ if (Object.keys(constraints).length) cfg.constraints = constraints;
204
+ return cfg;
205
+ function foreignKeyConfig() {
206
+ // 由 schema 解析引用关系;不把运行时缓存的父表值或导入的普通生成器参数写回配置。
207
+ // 使用解析型生成器,保留核心对空父表、可空外键及自引用的处理。
208
+ const cfg = {
209
+ generator: 'foreign_key_or_integer',
210
+ params: {
211
+ ...form.params
212
+ }
213
+ };
214
+ if (form.null_ratio > 0 && !dbNotNull()) cfg.null_ratio = form.null_ratio / 100;
215
+ const constraints = {
216
+ ...form.constraints
217
+ };
218
+ // 复合主键成员并非单列唯一;只补充 schema 明确报告的单列唯一约束。
219
+ if (uniqueColsOf(current.table)?.has(current.col)) constraints.unique = true;
220
+ if (Object.keys(constraints).length) cfg.constraints = constraints;
221
+ return cfg;
222
+ }
223
+ }
224
+ function emit() {
225
+ if (!current || !onChange) return;
226
+ onChange(current.table, current.col, buildCfg());
227
+ }
228
+
229
+ /** 注册 dropdown 以便 render() 重建时统一 destroy(防监听器泄漏)。 */
230
+ function track(dd) {
231
+ dropdowns.push(dd);
232
+ return dd;
233
+ }
234
+ function render(preview = true) {
235
+ if (previewTimer) clearTimeout(previewTimer);
236
+ previewTimer = null;
237
+ previewBox = null;
238
+ paramsHolder = null;
239
+ nullPctInput = null;
240
+ paramErrors.clear();
241
+ // 旧 dropdown 的 scroll/mousedown 监听挂在 document 上,必须先注销再丢弃 DOM。
242
+ for (const dd of dropdowns) dd.destroy();
243
+ dropdowns = [];
244
+ clear(el);
245
+ if (!current) {
246
+ el.append(h('div', {
247
+ class: 'muted',
248
+ style: 'padding:24px'
249
+ }, '在左侧树中选择一列以配置生成器。'));
250
+ return;
251
+ }
252
+ const {
253
+ col,
254
+ colInfo
255
+ } = current;
256
+ el.append(h('div', {
257
+ class: 'genform-head'
258
+ }, h('div', {
259
+ class: 'genform-title'
260
+ }, col), h('div', {
261
+ class: 'muted'
262
+ }, `${colInfo.type}${colInfo.nullable ? '' : ' NOT NULL'}${colInfo.is_primary_key ? ' · PK' : ''}`)));
263
+
264
+ // 外键身份来自数据库,不能被 AI、导入配置或 NULL 比例编辑改成普通生成器。
265
+ if (foreignKeys().length && !isDbGenerated()) {
266
+ renderForeignKey(preview);
267
+ return;
268
+ }
269
+
270
+ // 派生列:没有生成器可配(与 derived 模式互斥),直接展示派生来源。
271
+ if (form.derived) {
272
+ return renderDerivedConfig();
273
+ }
274
+
275
+ // ② 自增主键(序列)列:值由数据库生成,sqlseed 跳过不生成(mapper 最高
276
+ // 优先级),任何用户配置都不会生效——整个属性面板锁定为只读,避免「能改
277
+ // 但改了没用」的误导。
278
+ if (isDbGenerated()) {
279
+ el.append(h('div', {
280
+ class: 'msg warn',
281
+ style: 'margin:8px 0'
282
+ }, '该列是自增主键(序列):值由数据库自动生成,sqlseed 跳过不生成,' + '无需也不可配置。'), h('div', {
283
+ class: 'genform-section'
284
+ }, formRow('预览', h('span', {
285
+ class: 'muted'
286
+ }, '由数据库自增生成,不预览'))));
287
+ return;
288
+ }
289
+
290
+ // ③ 生成器下拉:7 类分组(通用/个人/支付/商业/位置/产品/电脑)。
291
+ // 未实现的组(支付/产品)渲染为禁用占位项,等 P2 生成器就绪后自动可选。
292
+ const genOpts = generatorOptions();
293
+ const genSel = track(createDropdown({
294
+ value: form.generator,
295
+ options: genOpts,
296
+ width: '260px',
297
+ onChange: v => {
298
+ if (v === form.generator) return;
299
+ form.generator = v;
300
+ form.params = {};
301
+ // 原生方法覆盖属于原生成器,显式换生成器后不可继续覆盖新选择。
302
+ delete form.options.faker_method;
303
+ delete form.options.mimesis_method;
304
+ delete form.options.native_params;
305
+ bytesModeState = null;
306
+ applyDateDefaults();
307
+ // 参数、预览和通用区都依赖生成器,必须一起重建并清理旧下拉监听。
308
+ render(false);
309
+ emit();
310
+ schedulePreview();
311
+ }
312
+ }));
313
+ el.append(formRow('生成器', genSel.el));
314
+
315
+ // ③ 类型专属参数区
316
+ paramsHolder = h('div', {
317
+ class: 'genform-params'
318
+ });
319
+ el.append(paramsHolder);
320
+ renderParams();
321
+
322
+ // ④ 预览 + 刷新
323
+ if (NO_PREVIEW_GENS.has(form.generator)) {
324
+ previewBox = null; // 图像或二进制面板没有预览区
325
+ } else {
326
+ const previewOut = h('div', {
327
+ class: 'genform-preview'
328
+ });
329
+ previewBox = previewOut;
330
+ el.append(h('div', {
331
+ class: 'genform-section'
332
+ }, formRow('预览', h('div', {
333
+ class: 'row genform-inline'
334
+ }, previewOut, h('button', {
335
+ class: 'small',
336
+ onclick: () => doPreview(previewOut)
337
+ }, '刷新')))));
338
+ if (preview) doPreview(previewOut);
339
+ }
340
+
341
+ // ⑤ 通用区
342
+ renderCommon();
343
+
344
+ // ⑥ 重置属性(参考工具:面板底部独立按钮)
345
+ el.append(h('div', {
346
+ class: 'genform-section'
347
+ }, h('button', {
348
+ class: 'small',
349
+ onclick: reset
350
+ }, '重置属性')));
351
+ function renderDerivedConfig() {
352
+ const src = Array.isArray(form.derived) ? form.derived.join('、') : form.derived;
353
+ el.append(formRow('派生自', h('span', {}, src || '—')), form.expression ? formRow('表达式', h('span', {
354
+ class: 'mono'
355
+ }, form.expression)) : null, h('div', {
356
+ class: 'genform-section'
357
+ }, h('div', {
358
+ class: 'msg warn'
359
+ }, '该列由其它列派生(derive_from),与「生成器」互斥,属性面板不可编辑。' + '要改回普通生成器,请点下方「重置属性」。')));
360
+ // 预览仍可用(派生结果来自源列),直接渲染,跳过参数区与通用区。
361
+ const out = h('div', {
362
+ class: 'genform-preview'
363
+ });
364
+ previewBox = out;
365
+ el.append(h('div', {
366
+ class: 'genform-section'
367
+ }, formRow('预览', out)));
368
+ if (preview) doPreview(out);
369
+ el.append(h('div', {
370
+ class: 'genform-section'
371
+ }, h('button', {
372
+ class: 'small',
373
+ onclick: reset
374
+ }, '重置属性')));
375
+ }
376
+ }
377
+ function renderCommon() {
378
+ const pctInput = h('input', {
379
+ type: 'number',
380
+ class: 'num-input',
381
+ value: form.null_ratio || DEFAULT_PERCENT,
382
+ min: 0,
383
+ max: 100,
384
+ disabled: dbNotNull() || form.null_ratio <= 0,
385
+ oninput: e => {
386
+ form.null_ratio = +e.target.value;
387
+ emit();
388
+ schedulePreview();
389
+ }
390
+ });
391
+ nullPctInput = pctInput;
392
+ const notNull = dbNotNull();
393
+ const common = [
394
+ // 行标签即属性名(参考工具 同款两栏网格),控件只放勾选框本身。
395
+ formRow('包含 NULL 值', h('input', {
396
+ type: 'checkbox',
397
+ checked: form.null_ratio > 0 && !notNull,
398
+ disabled: notNull,
399
+ // 数据库 NOT NULL:不可配置(配了必 IntegrityError)
400
+ onchange: e => {
401
+ form.null_ratio = e.target.checked ? DEFAULT_PERCENT : 0;
402
+ renderNull();
403
+ emit();
404
+ schedulePreview();
405
+ }
406
+ })), formRow('百分比', pctInput)];
407
+ if (notNull) {
408
+ common.push(formRow('', h('span', {
409
+ class: 'muted'
410
+ }, '数据库约束:NOT NULL,不允许 NULL 值')));
411
+ }
412
+ // 数据库唯一约束优先于「该生成器隐藏设置唯一」的裁剪规则:
413
+ // 约束是硬性的,隐藏会让用户失去知情权(即使核心 unique_adjuster 会兜底)。
414
+ const dbUniq = dbUnique();
415
+ if (!NO_UNIQUE_GENS.has(form.generator) || dbUniq) {
416
+ common.push(formRow('设置唯一', h('input', {
417
+ type: 'checkbox',
418
+ checked: dbUniq || !!form.unique,
419
+ disabled: dbUniq,
420
+ onchange: e => {
421
+ form.unique = e.target.checked;
422
+ emit();
423
+ schedulePreview();
424
+ }
425
+ })));
426
+ if (dbUniq) {
427
+ common.push(formRow('', h('span', {
428
+ class: 'muted'
429
+ }, '数据库约束:UNIQUE,必须唯一')));
430
+ }
431
+ }
432
+ el.append(h('div', {
433
+ class: 'genform-section'
434
+ }, ...common));
435
+ }
436
+ function renderForeignKey(preview) {
437
+ const refs = foreignKeys();
438
+ const selfRef = refs.some(fk => fk.ref_table === current.table);
439
+ el.append(formRow('引用列', h('span', {
440
+ class: 'mono'
441
+ }, refs.map(fk => `${fk.ref_table}.${fk.ref_column}`).join('、'))), h('div', {
442
+ class: 'msg warn',
443
+ style: 'margin:8px 0'
444
+ }, '该列是外键,非空值从引用列中选取,不能切换为普通生成器。' + (selfRef ? '这是同一张表内的自引用关系。' : '生成数据时需先准备被引用表中的记录。')));
445
+ const out = h('div', {
446
+ class: 'genform-preview'
447
+ });
448
+ previewBox = out;
449
+ el.append(h('div', {
450
+ class: 'genform-section'
451
+ }, formRow('预览', h('div', {
452
+ class: 'row genform-inline'
453
+ }, out, h('button', {
454
+ class: 'small',
455
+ onclick: () => doPreview(out)
456
+ }, '刷新')))));
457
+ if (preview) doPreview(out);
458
+ if (!dbNotNull()) {
459
+ const pct = h('input', {
460
+ type: 'number',
461
+ class: 'num-input',
462
+ min: 0,
463
+ max: 100,
464
+ value: form.null_ratio > 0 ? form.null_ratio : '',
465
+ placeholder: '0',
466
+ disabled: form.null_ratio <= 0,
467
+ oninput: e => {
468
+ form.null_ratio = e.target.value === '' ? 0 : +e.target.value;
469
+ emit();
470
+ schedulePreview();
471
+ }
472
+ });
473
+ el.append(h('div', {
474
+ class: 'genform-section'
475
+ }, formRow('包含 NULL 值', h('input', {
476
+ type: 'checkbox',
477
+ checked: form.null_ratio > 0,
478
+ onchange: e => {
479
+ form.null_ratio = e.target.checked ? DEFAULT_PERCENT : 0;
480
+ pct.value = form.null_ratio > 0 ? form.null_ratio : '';
481
+ pct.disabled = form.null_ratio <= 0;
482
+ emit();
483
+ schedulePreview();
484
+ }
485
+ })), formRow('百分比', pct), h('div', {
486
+ class: 'muted'
487
+ }, selfRef ? '首次向空表生成数据时,会先留空再建立表内关联,最终空值比例受初始化过程影响。' : '引用列没有可用值时,可空外键会先生成 NULL;填充被引用表后才能建立关联。')));
488
+ } else {
489
+ el.append(h('div', {
490
+ class: 'genform-section muted'
491
+ }, '数据库约束:NOT NULL,不允许空值;引用列必须有可用记录。'));
492
+ }
493
+ el.append(h('div', {
494
+ class: 'genform-section'
495
+ }, h('button', {
496
+ class: 'small',
497
+ onclick: reset
498
+ }, '重置属性')));
499
+ }
500
+
501
+ /** NULL 勾选框联动:未勾选时百分比输入框禁用(参考工具 同款行为)。 */
502
+ function renderNull() {
503
+ if (nullPctInput) nullPctInput.disabled = dbNotNull() || form.null_ratio <= 0;
504
+ }
505
+
506
+ /**
507
+ * 参数别名归一:核心存在「同一参数的多种写法」,面板只保留主键——
508
+ * - pattern:pattern || regex 是别名 → 值搬到 pattern,删 regex
509
+ * - weighted_choice:choices(对象列表/字符串数组)与 weighted_choices(值:权重
510
+ * 字典)表达同一件事 → 搬到 weighted_choices(字符串数组按等权 1 处理)
511
+ * 只在加载时做;提交只发主键。核心继续接受旧写法(手写 YAML 向后兼容)。
512
+ */
513
+ function normalizeAliasParams() {
514
+ const p = form.params;
515
+ if (form.generator === 'pattern') {
516
+ if (!p.pattern && p.regex) p.pattern = p.regex;
517
+ delete p.regex;
518
+ }
519
+ if (form.generator === 'weighted_choice' && !p.weighted_choices) {
520
+ normalizeWeightedChoices();
521
+ }
522
+ function normalizeWeightedChoices() {
523
+ if (Array.isArray(p.choices) && p.choices.length) {
524
+ const obj = {};
525
+ for (const c of p.choices) {
526
+ if (c && typeof c === 'object') obj[c.value] = c.weight ?? 1;else obj[c] = 1; // 等权字符串
527
+ }
528
+ p.weighted_choices = obj;
529
+ }
530
+ delete p.choices;
531
+ }
532
+ }
533
+
534
+ /** 日期边界只在未提供任何边界时补默认值,保留 AI 给出的单边设置。 */
535
+ function applyDateDefaults() {
536
+ if (!DATE_DEFAULT_GENS.has(form.generator)) return;
537
+ const p = form.params;
538
+ const hasAny = p.start_date || p.end_date || p.start_year !== undefined || p.end_year !== undefined;
539
+ if (hasAny) return;
540
+ p.start_date = '2000-01-01';
541
+ p.end_date = `${new Date().getFullYear()}-12-31`;
542
+ }
543
+
544
+ /**
545
+ * 该列是否为显式 AUTOINCREMENT 主键——值由数据库生成,sqlseed 跳过。
546
+ * 判定用 colInfo 而非 inferred.generator_name:核心的 `skip` 还用于
547
+ * 「可空无默认值」「计算列」「隐式 INTEGER PK」等场景,而只有显式
548
+ * AUTOINCREMENT 主键是用户配置**无法覆盖**的(mapper L1a 优先级最高)。
549
+ */
550
+ function isDbGenerated() {
551
+ return !!(current?.colInfo?.is_primary_key && current?.colInfo?.is_autoincrement);
552
+ }
553
+ function renderParams() {
554
+ if (!paramsHolder) return;
555
+ clear(paramsHolder);
556
+ const paramNames = meta.params?.[form.generator] || [];
557
+ if (!paramNames.length) {
558
+ paramsHolder.append(formRow('', h('span', {
559
+ class: 'muted'
560
+ }, '该生成器无可配置参数')));
561
+ return;
562
+ }
563
+ // bytes(图像或二进制)双模式:参考工具 同款「图像生成器 / 从文件夹随机选择」
564
+ // 单选二选一,未选侧的参数行变暗禁用(仍保留已填值,但发送前会被剥掉)。
565
+ if (form.generator === 'bytes') {
566
+ paramsHolder.append(bytesModeRow());
567
+ // 两组参数都渲染,未选侧变暗禁用(参考工具 同款二选一联动)。
568
+ for (const m of ['image', 'folder']) {
569
+ for (const p of BYTES_MODE_PARAMS[m]) {
570
+ paramsHolder.append(bytesParamRow(p, m));
571
+ }
572
+ }
573
+ return;
574
+ }
575
+ // 按 参考工具 面板顺序排列;未列入 PARAM_ORDER 的参数保持签名原始顺序。
576
+ const hidden = HIDDEN_PARAMS[form.generator];
577
+ const ordered = [...paramNames].filter(p => !hidden?.has(p)).sort((a, b) => {
578
+ const ia = PARAM_ORDER.indexOf(a);
579
+ const ib = PARAM_ORDER.indexOf(b);
580
+ if (ia === -1 && ib === -1) return 0;
581
+ if (ia === -1) return 1;
582
+ if (ib === -1) return -1;
583
+ return ia - ib;
584
+ });
585
+ for (const p of ordered) {
586
+ paramsHolder.append(formRow(paramLabel(p), paramInput(p)));
587
+ }
588
+ }
589
+
590
+ // bytes 双模式参数分组(顺序即 参考工具 面板顺序)。
591
+ const BYTES_MODE_PARAMS = {
592
+ image: ['width', 'height', 'image_format'],
593
+ folder: ['folder', 'extensions']
594
+ };
595
+ const BYTES_MODE_LABELS = [{
596
+ value: 'image',
597
+ label: '图像生成器'
598
+ }, {
599
+ value: 'folder',
600
+ label: '从文件夹随机选择'
601
+ }];
602
+
603
+ /** bytes 当前模式:显式选择优先,未选过时回退到参数推导(folder 非空 → folder,
604
+ * 与核心 _gen_bytes 的 folder 优先判定一致)。 */
605
+ function bytesMode() {
606
+ return bytesModeState || (form.params.folder ? 'folder' : 'image');
607
+ }
608
+ function switchBytesMode(mode) {
609
+ if (mode === bytesMode()) return;
610
+ bytesModeState = mode;
611
+ if (mode === 'image') {
612
+ delete form.params.folder;
613
+ delete form.params.extensions;
614
+ } else {
615
+ delete form.params.width;
616
+ delete form.params.height;
617
+ delete form.params.image_format;
618
+ }
619
+ render(false);
620
+ emit();
621
+ schedulePreview();
622
+ }
623
+ function bytesModeRow() {
624
+ const mode = bytesMode();
625
+ const radios = h('div', {
626
+ class: 'genform-radios'
627
+ });
628
+ for (const m of BYTES_MODE_LABELS) {
629
+ radios.append(h('label', {
630
+ class: 'genform-check'
631
+ }, h('input', {
632
+ type: 'radio',
633
+ name: 'genform-bytes-mode',
634
+ checked: mode === m.value,
635
+ onchange: () => switchBytesMode(m.value)
636
+ }), m.label));
637
+ }
638
+ return formRow('模式', radios);
639
+ }
640
+
641
+ /** 渲染一行 bytes 参数;非当前模式的组加 .off(变暗 + 输入禁用)。 */
642
+ function bytesParamRow(name, groupMode) {
643
+ const row = formRow(paramLabel(name), paramInput(name));
644
+ if (groupMode !== bytesMode()) {
645
+ row.classList.add('off');
646
+ for (const node of row.querySelectorAll('input, button, textarea')) {
647
+ node.disabled = true;
648
+ }
649
+ }
650
+ return row;
651
+ }
652
+
653
+ /** 「一整天」勾选后禁用时间输入框(参考工具 同款联动)。 */
654
+ function syncTimeInputs() {
655
+ const off = form.params.all_day !== false;
656
+ for (const node of el.querySelectorAll('[data-time-param]')) node.disabled = off;
657
+ }
658
+
659
+ // ---- 数据库硬约束(schema 明确规定的 NOT NULL / UNIQUE)----
660
+ // 这两类约束用户在 UI 上配反了必然 IntegrityError,所以直接锁定控件:
661
+ // NOT NULL → NULL% 强制 0;数据库唯一 → 设置唯一强制开。
662
+
663
+ /** 该列是否被数据库约束为 NOT NULL(含主键)。 */
664
+ function dbNotNull() {
665
+ return !!current?.colInfo && current.colInfo.nullable === false;
666
+ }
667
+
668
+ /** 该列是否被数据库约束为唯一(主键 / 单列唯一索引)。 */
669
+ function dbUnique() {
670
+ if (current?.colInfo?.is_primary_key) return true;
671
+ const cols = uniqueColsOf(current?.table);
672
+ return !!(cols?.has && current?.col && cols.has(current.col));
673
+ }
674
+ function paramInput(name) {
675
+ const val = form.params[name] ?? '';
676
+ const commit = v => {
677
+ form.params[name] = v;
678
+ emit();
679
+ schedulePreview();
680
+ };
681
+ if (name === 'schema') {
682
+ return schemaInput();
683
+ }
684
+ if (name === 'choices' || name === 'weighted_choices') {
685
+ return choiceInput();
686
+ }
687
+ // 正则 / 模板:多行文本编辑区。
688
+ if (TEXTAREA_PARAMS.has(name)) {
689
+ return h('textarea', {
690
+ class: 'grow',
691
+ rows: name === 'template' ? '2' : '3',
692
+ spellcheck: 'false',
693
+ placeholder: PARAM_PLACEHOLDERS[name] || '',
694
+ oninput: e => commit(e.target.value)
695
+ }, String(val ?? ''));
696
+ }
697
+ // 日期 / 时间 / 星期:参考工具 日期、时间、日期时间三件套面板。
698
+ if (name === 'start_date' || name === 'end_date') {
699
+ return h('input', {
700
+ type: 'date',
701
+ class: 'grow',
702
+ value: val || '',
703
+ oninput: e => commit(e.target.value)
704
+ });
705
+ }
706
+ if (name === 'start_time' || name === 'end_time') {
707
+ return h('input', {
708
+ type: 'time',
709
+ class: 'grow',
710
+ value: val || '',
711
+ step: 1,
712
+ // step=1 让秒可选
713
+ 'data-time-param': name,
714
+ disabled: form.params.all_day !== false,
715
+ // 「一整天」勾选时不参与
716
+ oninput: e => commit(e.target.value)
717
+ });
718
+ }
719
+ if (name === 'all_day') {
720
+ return h('input', {
721
+ type: 'checkbox',
722
+ checked: form.params.all_day !== false,
723
+ // 参考工具 默认勾选「一整天」
724
+ onchange: e => {
725
+ commit(e.target.checked);
726
+ syncTimeInputs();
727
+ }
728
+ });
729
+ }
730
+ if (name === 'weekdays') {
731
+ return weekdayControl(commit);
732
+ }
733
+ if (NUMERIC_PARAMS.has(name)) {
734
+ return numericInput();
735
+ }
736
+ if (name === 'extensions') {
737
+ // 逗号分隔列表(如 png,jpg),发送前解析为数组
738
+ return h('input', {
739
+ class: 'grow',
740
+ value: Array.isArray(val) ? val.join(',') : val || '',
741
+ placeholder: PARAM_PLACEHOLDERS.extensions,
742
+ oninput: e => commit(e.target.value.split(',').map(s => s.trim()).filter(Boolean))
743
+ });
744
+ }
745
+ if (name === 'image_format') {
746
+ return track(createDropdown({
747
+ value: val || 'png',
748
+ options: IMAGE_FORMATS,
749
+ width: '240px',
750
+ onChange: v => commit(v)
751
+ })).el;
752
+ }
753
+ if (name === 'folder') {
754
+ // 目录走服务端浏览(浏览器不暴露绝对路径)——选完直接回填并触发预览。
755
+ const input = h('input', {
756
+ class: 'grow',
757
+ value: val || '',
758
+ placeholder: PARAM_PLACEHOLDERS.folder,
759
+ oninput: e => commit(e.target.value)
760
+ });
761
+ return h('div', {
762
+ class: 'genform-field'
763
+ }, input, h('button', {
764
+ class: 'small',
765
+ onclick: () => openFilePicker({
766
+ mode: 'dir',
767
+ startPath: val || undefined,
768
+ onPick: p => {
769
+ input.value = p;
770
+ commit(p);
771
+ }
772
+ })
773
+ }, '选择文件夹'));
774
+ }
775
+ return h('input', {
776
+ class: 'grow',
777
+ value: val,
778
+ placeholder: PARAM_PLACEHOLDERS[name] || '',
779
+ oninput: e => commit(e.target.value)
780
+ });
781
+ function schemaInput() {
782
+ const error = h('div', {
783
+ class: 'msg err',
784
+ role: 'alert',
785
+ hidden: true
786
+ });
787
+ const input = h('textarea', {
788
+ class: 'grow',
789
+ rows: '5',
790
+ spellcheck: 'false',
791
+ placeholder: PARAM_PLACEHOLDERS.schema,
792
+ oninput: e => {
793
+ let parsed;
794
+ try {
795
+ const text = e.target.value.trim();
796
+ if (text) {
797
+ parsed = JSON.parse(text);
798
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
799
+ throw new Error('JSON schema must be an object');
800
+ }
801
+ }
802
+ } catch {
803
+ const detail = '请输入有效的 JSON 对象;留空使用默认结构。';
804
+ paramErrors.set(name, detail);
805
+ error.textContent = detail;
806
+ error.hidden = false;
807
+ input.setAttribute('aria-invalid', 'true');
808
+ if (previewTimer) clearTimeout(previewTimer);
809
+ previewTimer = null;
810
+ return;
811
+ }
812
+ paramErrors.delete(name);
813
+ error.textContent = '';
814
+ error.hidden = true;
815
+ input.removeAttribute('aria-invalid');
816
+ commit(parsed);
817
+ }
818
+ }, typeof val === 'string' ? val : JSON.stringify(val, null, 2));
819
+ return h('div', {
820
+ class: 'genform-field-col'
821
+ }, input, error);
822
+ }
823
+ function choiceInput() {
824
+ // 参考工具 式:每行一个值;加权枚举支持每行「值:权重」。
825
+ let text;
826
+ if (Array.isArray(val)) {
827
+ text = val.join('\n');
828
+ } else if (val && typeof val === 'object') {
829
+ text = Object.entries(val).map(([k, w]) => `${k}:${w}`).join('\n');
830
+ } else {
831
+ text = typeof val === 'string' ? val : '';
832
+ }
833
+ return h('textarea', {
834
+ class: 'grow',
835
+ rows: '4',
836
+ spellcheck: 'false',
837
+ placeholder: name === 'choices' ? '每行一个值,如:\nengineer\nmanager' : '每行一个「值:权重」,如:\nactive:80\nsuspended:15',
838
+ oninput: e => {
839
+ const lines = e.target.value.split('\n').map(s => s.trim()).filter(Boolean);
840
+ if (name === 'choices') {
841
+ commit(lines);
842
+ } else {
843
+ const obj = {};
844
+ for (const line of lines) {
845
+ const idx = line.lastIndexOf(':');
846
+ if (idx > 0) obj[line.slice(0, idx)] = Number(line.slice(idx + 1)) || 0;else obj[line] = 1;
847
+ }
848
+ commit(obj);
849
+ }
850
+ }
851
+ }, text);
852
+ }
853
+ function numericInput() {
854
+ const attrs = {
855
+ type: 'number',
856
+ class: 'num-input',
857
+ value: val,
858
+ placeholder: PARAM_PLACEHOLDERS[name] || '',
859
+ oninput: e => commit(e.target.value === '' ? undefined : +e.target.value)
860
+ };
861
+ // 年份加合法区间(datetime.year 必须落在 1–9999)。没有边界时很容易
862
+ // 敲出 1396 这类荒谬年份——值本身合法,生成结果却完全跑偏。
863
+ if (name === 'start_year' || name === 'end_year') {
864
+ attrs.min = 1;
865
+ attrs.max = 9999;
866
+ }
867
+ return h('input', attrs);
868
+ }
869
+ }
870
+
871
+ /**
872
+ * 星期控件:全部 / 工作日 / 自定义(参考工具 三选一),选「自定义」时展开周几勾选组。
873
+ * 值形态:'all' | 'workdays' | [0…6](Monday=0,与核心 normalize_weekdays 一致)。
874
+ */
875
+ function weekdayControl(commit) {
876
+ const val = form.params.weekdays;
877
+ let mode;
878
+ if (val === 'workdays') {
879
+ mode = 'workdays';
880
+ } else if (Array.isArray(val)) {
881
+ mode = 'custom';
882
+ } else {
883
+ mode = 'all';
884
+ }
885
+ const radios = h('div', {
886
+ class: 'genform-radios'
887
+ });
888
+ const box = h('div', {
889
+ class: 'genform-weekdays'
890
+ });
891
+ for (const m of WEEKDAY_MODES) {
892
+ radios.append(h('label', {
893
+ class: 'genform-check'
894
+ }, h('input', {
895
+ type: 'radio',
896
+ name: 'genform-weekday-mode',
897
+ checked: mode === m.value,
898
+ onchange: () => {
899
+ commit(m.value === 'custom' ? [] : m.value);
900
+ box.style.display = m.value === 'custom' ? 'flex' : 'none';
901
+ }
902
+ }), m.label));
903
+ }
904
+ const days = new Set(Array.isArray(val) ? val : []);
905
+ for (let i = 0; i < 7; i++) {
906
+ box.append(h('label', {
907
+ class: 'genform-check'
908
+ }, h('input', {
909
+ type: 'checkbox',
910
+ checked: days.has(i),
911
+ onchange: e => {
912
+ const next = new Set(Array.isArray(form.params.weekdays) ? form.params.weekdays : []);
913
+ if (e.target.checked) next.add(i);else next.delete(i);
914
+ commit([...next].sort((x, y) => x - y));
915
+ }
916
+ }), WEEKDAY_LABELS[i]));
917
+ }
918
+ box.style.display = mode === 'custom' ? 'flex' : 'none';
919
+ return h('div', {
920
+ class: 'genform-field-col'
921
+ }, radios, box);
922
+ }
923
+ function formRow(labelText, control) {
924
+ return h('div', {
925
+ class: 'genform-row'
926
+ }, h('label', {
927
+ class: 'genform-label'
928
+ }, labelText ? `${labelText}:` : ''), control);
929
+ }
930
+
931
+ /** 该生成器缺哪个必填参数(无必填返回 null)。 */
932
+ function missingRequired() {
933
+ const req = REQUIRED_PARAMS[form.generator];
934
+ if (!req) return null;
935
+ const missing = req.filter(k => {
936
+ const v = form.params[k];
937
+ if (Array.isArray(v)) return v.length === 0;
938
+ if (v && typeof v === 'object') return Object.keys(v).length === 0;
939
+ return v === undefined || v === null || v === '';
940
+ });
941
+ return missing.length ? missing : null;
942
+ }
943
+ async function doPreview(out) {
944
+ if (!current) return;
945
+ if (paramErrors.size) {
946
+ clear(out);
947
+ out.append(msg('请先修正参数中的 JSON 格式错误。'));
948
+ return;
949
+ }
950
+ const missing = missingRequired();
951
+ if (missing) {
952
+ // 空参请求注定失败(如 choice 缺候选值),提示待填而不是闪一条报错。
953
+ clear(out);
954
+ out.append(h('span', {
955
+ class: 'muted'
956
+ }, `待填写:${missing.map(paramLabel).join('、')}`));
957
+ return;
958
+ }
959
+ clear(out);
960
+ out.append(h('span', {
961
+ class: 'muted'
962
+ }, '…'));
963
+ try {
964
+ // 预览必须带 NULL/唯一,否则勾选后预览永远显示不出空值(实测发现)。
965
+ const cfg = buildCfg();
966
+ const res = await post(`/api/connections/${connId}/preview`, {
967
+ table: current.table,
968
+ count: 3,
969
+ columns: {
970
+ [current.col]: cfg
971
+ }
972
+ });
973
+ clear(out);
974
+ const vals = res.rows.map(r => r[current.col]);
975
+ out.append(h('span', {
976
+ class: 'genform-preview-val'
977
+ }, vals.map(String).join('、') || '(空)'));
978
+ } catch (e) {
979
+ clear(out);
980
+ out.append(msg(`预览失败:${e.message}`));
981
+ }
982
+ }
983
+
984
+ /** 源列与派生列都回到零配置基线,丢弃导入或手动编辑后的设置。 */
985
+ function reset() {
986
+ if (!current) return;
987
+ bytesModeState = null;
988
+ form = fromInferred(current.zeroConfig ?? current.inferred);
989
+ normalizeAliasParams();
990
+ applyDateDefaults();
991
+ render();
992
+ emit();
993
+ }
994
+ function fromInferred(spec) {
995
+ // 保留面板未提供编辑器的 ColumnConfig 字段,参数编辑不应重写这些设置。
996
+ const options = {};
997
+ for (const key of ['provider', 'faker_method', 'mimesis_method', 'native_params']) {
998
+ if (spec?.[key] !== undefined && spec[key] !== null) options[key] = spec[key];
999
+ }
1000
+ const common = {
1001
+ options,
1002
+ constraints: {
1003
+ ...spec?.constraints
1004
+ },
1005
+ // 核心为 0–1,UI 为 0–100;保留小数,避免未编辑比例时发生精度损失。
1006
+ null_ratio: (spec?.null_ratio || 0) * 100,
1007
+ unique: !!spec?.constraints?.unique
1008
+ };
1009
+ if (foreignKeys().length && !isDbGenerated()) {
1010
+ const params = {};
1011
+ if (['random', 'coverage'].includes(spec?.params?.strategy)) params.strategy = spec.params.strategy;
1012
+ return {
1013
+ ...common,
1014
+ options: {},
1015
+ generator: 'foreign_key_or_integer',
1016
+ params
1017
+ };
1018
+ }
1019
+ // 派生列(AI 常为 shipped_at 一类配 derive_from)必须原样保留:ColumnConfig
1020
+ // 的 derived 模式与 generator 互斥,退化成 generator 会静默改写 AI 配置,
1021
+ // 且该列一旦参与跨列 CHECK 就因类型不匹配而预览失败。
1022
+ if (spec?.derive_from) {
1023
+ return {
1024
+ ...common,
1025
+ generator: '',
1026
+ derived: spec.derive_from,
1027
+ expression: spec.expression || '',
1028
+ params: {}
1029
+ };
1030
+ }
1031
+ if (!spec || spec.generator_name === 'skip' || spec.generator_name === 'foreign_key' || spec.generator_name === 'foreign_key_or_integer' || spec.generator_name === '__enrich__') {
1032
+ return {
1033
+ ...common,
1034
+ generator: 'string',
1035
+ params: {}
1036
+ };
1037
+ }
1038
+ return {
1039
+ ...common,
1040
+ generator: spec.generator_name || spec.generator || 'string',
1041
+ params: {
1042
+ ...spec.params
1043
+ }
1044
+ };
1045
+ }
1046
+ return {
1047
+ el,
1048
+ /** 选中一列:inferred 为当前配置,zeroConfig 为零配置推断的 GeneratorSpec。 */
1049
+ setColumn(table, col, colInfo, inferred, zeroConfig) {
1050
+ // zeroConfig 是该列的零配置推断结果,作为「重置属性」的回落基线
1051
+ // (inferred 可能已被 AI/加载的配置覆盖)。
1052
+ current = {
1053
+ table,
1054
+ col,
1055
+ colInfo,
1056
+ inferred,
1057
+ zeroConfig
1058
+ };
1059
+ bytesModeState = null;
1060
+ form = fromInferred(inferred);
1061
+ normalizeAliasParams();
1062
+ applyDateDefaults();
1063
+ render();
1064
+ }
1065
+ };
1066
+ }