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,3123 @@
1
+ import { h, api, get, store, restoreConnection } from '../api.js';
2
+ import { genLabel, paramLabel } from '../labels.js';
3
+ import { createDropdown } from '../dropdown.js';
4
+ import { WorkbenchSession } from '../workbench/session.js';
5
+ import { openAIAssistant } from '../workbench/ai.js';
6
+ import { rememberAIHandoff, consumeAIHandoff, clearAIHandoff } from '../workbench/ai-handoff.js';
7
+ import { openDataPreview } from '../workbench/preview.js';
8
+ import { openTableData } from '../workbench/table-data.js';
9
+ import { fieldAIEligibility } from '../workbench/ai-eligibility.js';
10
+ import { remainingRun } from '../workbench/recovery.js';
11
+ import { providerGuide } from '../workbench/provider-guide.js';
12
+ import { nextStep, guideAIState } from '../workbench/guidance.js';
13
+ import { createRuleEditor } from '../workbench/editor.js';
14
+ import { createSchemaGraph } from '../workbench/graph.js';
15
+ import { button, icon, modal, download, valueText } from '../workbench/ui.js';
16
+
17
+ // The approved v8 workbench is the product surface. Models and HTTP contracts
18
+ // are reusable business logic; no legacy page, wizard or theme is mounted here.
19
+ const sessions = new Map();
20
+ const importedStructures = new Map();
21
+ const graphViews = new WeakMap();
22
+ const sidebarViews = new WeakMap();
23
+ const previewResults = new WeakMap();
24
+ const previewReturns = new WeakMap();
25
+ const pendingSchemas = new Map(),
26
+ pendingActions = new WeakMap(),
27
+ disabledBeforeBusy = new WeakMap();
28
+ const databaseActions = new Map([[save, '保存配置'], [showDependencies, '检查依赖'], [showPlan, '检查依赖'], [summary, '准备生成计划'], [refreshSchema, '读取数据库结构'], [configDocument, '读取配置文档']]);
29
+ let root,
30
+ session,
31
+ catalog,
32
+ notice,
33
+ status,
34
+ sidebar,
35
+ content,
36
+ graph,
37
+ active = 0,
38
+ openedModal;
39
+ let graphOwner = null,
40
+ modalIntent = 0;
41
+ let previewCount = 10,
42
+ tablePreview = null;
43
+ let guidanceAI = null,
44
+ guidanceRequest = 0,
45
+ guidanceCollapsed = false;
46
+ let providerMetadata = null,
47
+ providerRequest = 0;
48
+ try {
49
+ guidanceCollapsed = localStorage.getItem('sqlseed.workbench.guide.collapsed') === 'true';
50
+ } catch {/* Storage may be unavailable. */}
51
+ let fieldQuery = '',
52
+ columnName = '',
53
+ inspectorMode = 'fields',
54
+ selectedEdge = null;
55
+ const model = () => session.model;
56
+ const send = (path, body, method = 'POST') => api(path, {
57
+ method,
58
+ body: JSON.stringify(body)
59
+ });
60
+ // Lifecycle events update cached workbenches even while the configuration page is active.
61
+ window.addEventListener('sqlseed:draft-deleted', event => {
62
+ for (const savedSession of sessions.values()) {
63
+ if (savedSession.model.saved?.id === event.detail.id) {
64
+ savedSession.model.lifecycleVersion++;
65
+ savedSession.model.saved = null;
66
+ savedSession.model.savedEpoch = -1;
67
+ }
68
+ }
69
+ if (root?.isConnected && session) {
70
+ updateStatus();
71
+ }
72
+ });
73
+ window.addEventListener('sqlseed:draft-renamed', event => {
74
+ for (const savedSession of sessions.values()) {
75
+ if (savedSession.model.saved?.id === event.detail.id) {
76
+ savedSession.model.lifecycleVersion++;
77
+ savedSession.model.saved = {
78
+ ...savedSession.model.saved,
79
+ revision: event.detail.revision,
80
+ name: event.detail.name
81
+ };
82
+ savedSession.name = event.detail.name;
83
+ }
84
+ }
85
+ if (root?.isConnected && session) {
86
+ draw();
87
+ }
88
+ });
89
+ function ticket() {
90
+ const version = active,
91
+ current = session,
92
+ document = model(),
93
+ epoch = document.epoch,
94
+ lifecycle = document.lifecycleVersion;
95
+ return () => version === active && current === session && document === model() && epoch === document.epoch && lifecycle === document.lifecycleVersion;
96
+ }
97
+ function notify(text, error = false) {
98
+ if (notice?.isConnected) {
99
+ notice.textContent = text;
100
+ notice.className = `wb-notice${error ? ' wb-error' : ''}`;
101
+ }
102
+ const banner = root?.querySelector('.wb-operation-status');
103
+ if (banner && error) {
104
+ banner.hidden = false;
105
+ banner.textContent = text;
106
+ banner.className = 'wb-operation-status wb-error';
107
+ }
108
+ }
109
+ function modalTicket() {
110
+ const current = ticket(),
111
+ intent = ++modalIntent;
112
+ return () => current() && intent === modalIntent;
113
+ }
114
+ function action(fn, label = databaseActions.get(fn)) {
115
+ const listener = async (...args) => {
116
+ const version = active,
117
+ owner = session;
118
+ if (!owner) {
119
+ return;
120
+ }
121
+ let pending = pendingActions.get(owner);
122
+ if (!pending) {
123
+ pending = new Map();
124
+ pendingActions.set(owner, pending);
125
+ }
126
+ const key = label ? 'database' : fn;
127
+ if (pending.has(key)) {
128
+ return;
129
+ }
130
+ pending.set(key, label || '处理请求');
131
+ syncBusy();
132
+ try {
133
+ return await fn(...args);
134
+ } catch (error) {
135
+ if (version === active) {
136
+ notify(error.message, true);
137
+ }
138
+ } finally {
139
+ pending.delete(key);
140
+ if (owner === session) {
141
+ syncBusy();
142
+ }
143
+ }
144
+ };
145
+ listener.databaseAction = Boolean(label);
146
+ return listener;
147
+ }
148
+ function syncBusy() {
149
+ if (!root?.isConnected || !session) {
150
+ return;
151
+ }
152
+ const label = pendingActions.get(session)?.get('database');
153
+ const controls = [...root.querySelectorAll('[data-db-action]'), ...(openedModal?.el?.querySelectorAll('[data-db-action]') || [])];
154
+ for (const control of controls) {
155
+ if (label) {
156
+ if (!disabledBeforeBusy.has(control)) {
157
+ disabledBeforeBusy.set(control, control.disabled);
158
+ }
159
+ control.disabled = true;
160
+ control.setAttribute('aria-busy', 'true');
161
+ } else if (disabledBeforeBusy.has(control)) {
162
+ control.disabled = disabledBeforeBusy.get(control);
163
+ disabledBeforeBusy.delete(control);
164
+ control.removeAttribute('aria-busy');
165
+ }
166
+ }
167
+ const banner = root.querySelector('.wb-operation-status');
168
+ if (banner && label) {
169
+ banner.hidden = false;
170
+ banner.className = 'wb-operation-status';
171
+ banner.textContent = `正在${label},请稍候。期间可以查看和编辑字段。`;
172
+ } else if (banner && !banner.classList.contains('wb-error')) {
173
+ banner.hidden = true;
174
+ }
175
+ }
176
+ function readSchema(connId) {
177
+ if (!pendingSchemas.has(connId)) {
178
+ const request = get(`/api/workbench/connections/${encodeURIComponent(connId)}/schema`).finally(() => pendingSchemas.delete(connId));
179
+ pendingSchemas.set(connId, request);
180
+ }
181
+ return pendingSchemas.get(connId);
182
+ }
183
+ function closeComponents() {
184
+ tablePreview?.destroy();
185
+ tablePreview = null;
186
+ if (graph?.getView && graphOwner) {
187
+ graphViews.set(graphOwner, graph.getView());
188
+ }
189
+ graph?.destroy();
190
+ graph = null;
191
+ graphOwner = null;
192
+ }
193
+ function validNavigation() {
194
+ if (model().errors.size) {
195
+ notify('请先修正生成数量。', true);
196
+ return false;
197
+ }
198
+ return true;
199
+ }
200
+ function chooseTable(name, page = 'fields') {
201
+ if (!validNavigation()) {
202
+ return;
203
+ }
204
+ if (!model().schema.tables.some(t => t.name === name)) {
205
+ notify('这是其他 schema 的引用来源,仅展示结构。');
206
+ return;
207
+ }
208
+ closeComponents();
209
+ graphViews.delete(model());
210
+ model().view.imported = false;
211
+ model().selectTable(name, page);
212
+ fieldQuery = '';
213
+ columnName = '';
214
+ selectedEdge = null;
215
+ const rendered = drawBody();
216
+ sidebar.querySelector('.wb-table-entry.active')?.scrollIntoView({
217
+ block: 'nearest'
218
+ });
219
+ return rendered;
220
+ }
221
+ function locateGenerationSelection() {
222
+ const entry = [...sidebar.querySelectorAll('.wb-table-entry')].find(row => !row.hidden);
223
+ const input = entry?.querySelector('input[type="checkbox"]') || sidebar.querySelector('input[type="search"]');
224
+ input?.focus();
225
+ input?.scrollIntoView({
226
+ block: 'nearest'
227
+ });
228
+ }
229
+ function updateStatus() {
230
+ if (!status?.isConnected) {
231
+ return;
232
+ }
233
+ const m = model();
234
+ if (m.errors.size) {
235
+ status.textContent = '有待修正的输入';
236
+ } else if (m.dirty) {
237
+ status.textContent = '未保存';
238
+ } else {
239
+ status.textContent = `已保存 · v${m.saved?.revision || 1}`;
240
+ }
241
+ status.className = `draft-tag wb-state${m.errors.size ? ' wb-error' : ''}`;
242
+ const errors = m.check?.issues?.filter(issue => issue.severity === 'error') || [];
243
+ const count = root.querySelector('[data-dependency-count]');
244
+ if (count) {
245
+ count.textContent = `依赖检查${errors.length ? " · " + errors.length : ''}`;
246
+ }
247
+ root.querySelector('[data-selection-count]')?.replaceChildren(`${m.document.tables.length} / ${m.schema.tables.length}`);
248
+ const scope = sidebar?.querySelector('.scope-summary');
249
+ if (scope) {
250
+ const refs = referencedTables();
251
+ const dependencyActionLabel = () => {
252
+ if (errors.length) {
253
+ return `${errors.length} 条依赖待处理 →`;
254
+ } else if (m.check?.ok) {
255
+ return '依赖与规则检查通过 →';
256
+ } else if (m.document.tables.length) {
257
+ return '检查依赖与生成顺序 →';
258
+ } else {
259
+ return '尚无生成计划';
260
+ }
261
+ };
262
+ scope.replaceChildren(h('div', {}, `本次生成 ${m.document.tables.length} 张 · 仅引用 ${refs.size} 张`), h('button', {
263
+ class: `dependency-jump${errors.length ? ' needs-attention' : ' dependency-ok'}`,
264
+ 'data-db-action': '',
265
+ onclick: action(showDependencies)
266
+ }, dependencyActionLabel()));
267
+ }
268
+ updateProviderWarning();
269
+ updateGuidance();
270
+ syncBusy();
271
+ }
272
+ function referencedTables() {
273
+ const m = model(),
274
+ refs = new Set();
275
+ for (const edge of m.schema.edges) {
276
+ if (m.selected(edge.target) && !m.selected(edge.source)) {
277
+ refs.add(edge.source);
278
+ }
279
+ }
280
+ return refs;
281
+ }
282
+ const databaseLabel = () => model().schema.target_label.split(/[\\/]/).findLast(Boolean) || model().schema.target_label;
283
+ export function render() {
284
+ active++;
285
+ providerMetadata = null;
286
+ root = h('div', {
287
+ class: 'page wb-page'
288
+ }, h('p', {
289
+ class: 'empty wb-empty'
290
+ }, '正在读取数据库结构…'));
291
+ return root;
292
+ }
293
+ export async function mount() {
294
+ const version = active;
295
+ try {
296
+ if (!store.connId) {
297
+ await restoreConnection();
298
+ }
299
+ if (version !== active) {
300
+ return;
301
+ }
302
+ if (!store.connId) {
303
+ root.replaceChildren(h('section', {
304
+ class: 'wb-welcome'
305
+ }, icon('database'), h('h1', {}, '从数据库结构开始'), h('p', {}, '先连接一个数据库,读取表、字段和约束,再配置需要生成的数据。'), button('连接数据库', () => document.getElementById('connection-button')?.click(), {
306
+ primary: true,
307
+ glyph: 'database'
308
+ }), h('div', {
309
+ class: 'wb-welcome-steps'
310
+ }, h('span', {}, '01 选择生成范围'), h('span', {}, '02 调整字段规则'), h('span', {}, '03 检查并生成'))));
311
+ return;
312
+ }
313
+ const connId = store.connId;
314
+ const cached = sessions.get(connId);
315
+ let cachedSchemaNotice = '';
316
+ const schemaRequest = pendingSchemas.get(connId) || (pendingActions.get(cached)?.has('database') ? cached.model.schema : readSchema(connId));
317
+ const [schema, metadata] = await Promise.all([Promise.resolve(schemaRequest).catch(error => {
318
+ if (!cached || error.status !== 409 || error.detail?.code !== 'connection_busy') {
319
+ throw error;
320
+ }
321
+ cachedSchemaNotice = '当前连接有任务正在运行,暂时显示上次读取的缓存结构。可以查看和编辑配置,任务完成后请重新读取结构。';
322
+ return cached.model.schema;
323
+ }), get('/api/workbench/generators')]);
324
+ if (version !== active) {
325
+ return;
326
+ }
327
+ catalog = metadata;
328
+ adoptSchema(connId, schema);
329
+ const restoredAI = consumeAIHandoff({
330
+ model: session.model,
331
+ connId,
332
+ returnTo: location.hash
333
+ });
334
+ const query = new URLSearchParams(restoredAI ? '' : location.hash.split('?')[1] || '');
335
+ if (!(await restoreRequestedDocument(query, connId, schema)) || version !== active) {
336
+ return;
337
+ }
338
+ const previewOrigin = restoredAI?.previewOrigin;
339
+ restorePreviewReturn(previewOrigin);
340
+ draw({
341
+ autoPreview: !previewOrigin
342
+ });
343
+ loadGuidanceAI();
344
+ loadProviderStatus();
345
+ if (cachedSchemaNotice) {
346
+ notify(cachedSchemaNotice, true);
347
+ }
348
+ if (query.get('import')) {
349
+ await configDocument();
350
+ }
351
+ if (restoredAI && version === active) {
352
+ const {
353
+ previewOrigin,
354
+ ...aiState
355
+ } = restoredAI;
356
+ await openAI(aiState.scope, aiState, {
357
+ previewOrigin
358
+ });
359
+ }
360
+ } catch (error) {
361
+ if (version === active) {
362
+ root.replaceChildren(h('section', {
363
+ class: 'wb-welcome'
364
+ }, h('h2', {}, '无法打开工作台'), h('p', {
365
+ role: 'alert'
366
+ }, error.message), button('重试', mount), button('选择数据库', () => document.getElementById('connection-button')?.click())));
367
+ }
368
+ }
369
+ async function restoreRequestedDocument(query, connId, schema) {
370
+ if (query.get('draft') || query.get('run') || query.get('new')) {
371
+ if (session.model.dirty && (session.model.saved || session.model.epoch > 0)) {
372
+ await session.save();
373
+ }
374
+ if (version !== active) {
375
+ return false;
376
+ }
377
+ }
378
+ if (query.get('new')) {
379
+ session = new WorkbenchSession(connId, schema, send);
380
+ sessions.set(connId, session);
381
+ }
382
+ if (query.get('draft')) {
383
+ const draft = await get(`/api/workbench/drafts/${encodeURIComponent(query.get('draft'))}`);
384
+ if (version !== active) {
385
+ return false;
386
+ }
387
+ session.open(draft);
388
+ } else if (query.get('run')) {
389
+ const run = await get(`/api/workbench/runs/${encodeURIComponent(query.get('run'))}`);
390
+ if (version !== active) {
391
+ return false;
392
+ }
393
+ applyRunSnapshot(run);
394
+ }
395
+ return true;
396
+ function applyRunSnapshot(run) {
397
+ if (schema.target_key !== run.target_key) {
398
+ throw new Error('运行记录属于另一数据库,请先连接对应数据库后再打开快照。');
399
+ }
400
+ if (query.get('recover') === 'remaining') {
401
+ const recovery = remainingRun(run);
402
+ if (!recovery.ok) {
403
+ throw new Error(recovery.reason);
404
+ }
405
+ session.model.replaceDocument(recovery.document);
406
+ session.model.view.tableDrafts = recovery.tableDrafts;
407
+ session.model.selectTable(recovery.document.tables[0].name);
408
+ session.name = `${run.name || '运行配置'} · 剩余数据`;
409
+ } else {
410
+ session.model.replaceDocument(run.document);
411
+ session.name = `${run.name || '运行配置'} · 副本`;
412
+ }
413
+ session.model.saved = null;
414
+ }
415
+ }
416
+ }
417
+ function adoptSchema(connId, schema) {
418
+ session = sessions.get(connId);
419
+ if (!session) {
420
+ session = new WorkbenchSession(connId, schema, send);
421
+ sessions.set(connId, session);
422
+ } else {
423
+ if (session.model.schema.schema_hash !== schema.schema_hash) {
424
+ session.model.touch();
425
+ }
426
+ session.model.schema = schema;
427
+ }
428
+ }
429
+ function restorePreviewReturn(previewOrigin) {
430
+ if (previewOrigin?.scope === 'current' && session.model.view.page === 'preview' && session.model.view.table === previewOrigin.table) {
431
+ previewReturns.set(session.model, {
432
+ table: previewOrigin.table,
433
+ view: previewOrigin.view
434
+ });
435
+ }
436
+ }
437
+ export function unmount() {
438
+ active++;
439
+ closeComponents();
440
+ openedModal?.close();
441
+ openedModal = null;
442
+ }
443
+ function draw(options = {}) {
444
+ closeComponents();
445
+ status = h('span', {
446
+ class: 'draft-tag wb-state'
447
+ });
448
+ const checkButton = button('', action(showDependencies), {
449
+ glyph: 'check'
450
+ });
451
+ checkButton.append(h('span', {
452
+ 'data-dependency-count': ''
453
+ }, '依赖检查'));
454
+ sidebar = h('aside', {
455
+ class: 'sidebar wb-sidebar'
456
+ });
457
+ content = h('div', {
458
+ class: 'main wb-content'
459
+ });
460
+ root.replaceChildren(h('section', {
461
+ class: 'heading'
462
+ }, h('div', {
463
+ class: 'title-line'
464
+ }, h('h1', {}, button(session.name, renameConfig, {
465
+ plain: true,
466
+ class: 'title-button wb-config-name',
467
+ 'aria-label': '重命名生成配置',
468
+ title: session.name,
469
+ glyph: 'edit'
470
+ })), status), h('div', {
471
+ class: 'heading-actions',
472
+ role: 'group',
473
+ 'aria-label': '整份生成配置操作'
474
+ }, button('AI 配置助手', openAI, {
475
+ glyph: 'sparkles'
476
+ }), checkButton, button('生成数据', action(summary), {
477
+ glyph: 'database',
478
+ primary: true,
479
+ title: '检查配置并查看写入计划,确认后生成数据'
480
+ }))), h('p', {
481
+ class: 'wb-operation-status',
482
+ role: 'status',
483
+ 'aria-live': 'polite',
484
+ hidden: true
485
+ }), h('div', {
486
+ class: 'wb-config-context'
487
+ }, h('div', {
488
+ class: 'wb-config-tools',
489
+ role: 'group',
490
+ 'aria-label': '配置管理'
491
+ }, button('保存配置', action(save), {
492
+ glyph: 'save'
493
+ }), button('打开配置', action(openDrafts)), button('编辑 YAML', action(configDocument), {
494
+ glyph: 'code',
495
+ plain: true,
496
+ class: 'wb-config-document',
497
+ title: '直接编辑完整生成配置;应用后仍需检查和确认写入'
498
+ })), h('section', {
499
+ class: 'wb-generation-settings',
500
+ 'aria-label': '全局生成设置'
501
+ }, h('span', {
502
+ class: 'wb-settings-heading',
503
+ title: '作用于当前配置中的所有表'
504
+ }, icon('settings'), '全局'), button('', action(configSettings), {
505
+ plain: true,
506
+ class: 'wb-setting-tile',
507
+ 'aria-label': '设置数据生成引擎'
508
+ }), button('', action(configSettings), {
509
+ plain: true,
510
+ class: 'wb-setting-tile',
511
+ 'aria-label': '设置数据语言与地区'
512
+ })), h('div', {
513
+ class: 'wb-settings-note',
514
+ 'data-provider-warning': '',
515
+ role: 'status',
516
+ hidden: true
517
+ })), h('section', {
518
+ class: 'wb-next-step',
519
+ 'aria-label': '使用引导'
520
+ }), h('section', {
521
+ class: 'workspace wb-workspace',
522
+ 'aria-label': '生成配置工作台'
523
+ }, sidebar, content));
524
+ const tiles = root.querySelectorAll('.wb-setting-tile');
525
+ tiles[0].append(h('small', {}, '数据生成引擎'), h('strong', {}, model().document.provider || '自动'), h('span', {}, '修改 ›'));
526
+ tiles[1].append(h('small', {}, '数据语言与地区'), h('strong', {}, model().document.locale || 'en_US'), h('span', {}, '修改 ›'));
527
+ drawBody(options);
528
+ }
529
+ async function loadProviderStatus() {
530
+ const version = active,
531
+ owner = session,
532
+ request = ++providerRequest;
533
+ let response = null;
534
+ try {
535
+ response = await get('/api/meta/providers');
536
+ } catch {/* Preserve configuration when metadata is unavailable. */}
537
+ if (version !== active || owner !== session || request !== providerRequest) {
538
+ return;
539
+ }
540
+ providerMetadata = response;
541
+ updateProviderWarning();
542
+ }
543
+ function updateProviderWarning() {
544
+ const host = root?.querySelector('[data-provider-warning]');
545
+ if (!host || !session) {
546
+ return;
547
+ }
548
+ const m = model(),
549
+ fields = m.schema.tables.flatMap(table => (m.table(table.name).columns || []).filter(column => column.provider === 'mimesis' || column.mimesis_method || column.native_mimesis_method).map(column => ({
550
+ table,
551
+ column
552
+ })));
553
+ const selected = m.document.provider === 'mimesis';
554
+ const capability = providerMetadata?.statuses?.mimesis;
555
+ const unavailable = capability ? capability.available === false : providerMetadata?.available && !providerMetadata.available.includes('mimesis');
556
+ host.hidden = !(unavailable && (selected || fields.length));
557
+ if (host.hidden) {
558
+ host.replaceChildren();
559
+ return;
560
+ }
561
+ const broken = capability?.status === 'import_error';
562
+ const fieldNames = fields.slice(0, 3).map(({
563
+ table,
564
+ column
565
+ }) => `${table.name}.${column.name}`).join('、');
566
+ function fieldOverrideHint() {
567
+ if (fields.length) {
568
+ return [h('p', {}, `字段规则:${fieldNames}${fields.length > 3 ? "等 " + fields.length + " 个字段" : ''}。更换全局引擎会保留字段覆盖。`)];
569
+ } else {
570
+ return [];
571
+ }
572
+ }
573
+ host.replaceChildren(h('p', {
574
+ class: 'wb-error'
575
+ }, `Mimesis ${broken ? '加载异常' : '未安装'},使用此引擎的字段暂不能预览或生成。`), ...fieldOverrideHint(), button('管理插件', () => {
576
+ if (host.isConnected) {
577
+ location.hash = '#/settings?section=plugins';
578
+ }
579
+ }, {
580
+ small: true
581
+ }), button('更改引擎', () => {
582
+ if (!host.isConnected) {
583
+ return;
584
+ }
585
+ if (selected) {
586
+ return action(configSettings)();
587
+ }
588
+ const {
589
+ table,
590
+ column
591
+ } = fields[0],
592
+ schemaColumn = table.columns.find(value => value.name === column.name);
593
+ if (schemaColumn) {
594
+ openRule(table, schemaColumn);
595
+ }
596
+ }, {
597
+ small: true
598
+ }));
599
+ }
600
+ async function loadGuidanceAI() {
601
+ const version = active,
602
+ owner = session,
603
+ request = ++guidanceRequest;
604
+ let config = null;
605
+ try {
606
+ const response = await get('/api/workbench/ai/config');
607
+ config = {
608
+ available: response.available,
609
+ ready: response.ready,
610
+ availability_status: response.availability_status
611
+ };
612
+ } catch {/* Manual configuration stays available. */}
613
+ if (version !== active || owner !== session || request !== guidanceRequest) {
614
+ return;
615
+ }
616
+ guidanceAI = config;
617
+ updateGuidance();
618
+ syncBusy();
619
+ }
620
+ function updateGuidance() {
621
+ if (!root?.isConnected) {
622
+ return;
623
+ }
624
+ const host = root?.querySelector('.wb-next-step');
625
+ if (!host || !session) {
626
+ return;
627
+ }
628
+ const m = model(),
629
+ step = nextStep(m, previewResults.get(m)),
630
+ ai = guideAIState(guidanceAI);
631
+ host.hidden = !m.schema.tables.length || Boolean(m.view.imported);
632
+ const focused = host.contains?.(document.activeElement) ? document.activeElement?.dataset.guideAction : null;
633
+ const currentSelected = () => m.selected(m.view.table) ? m.view.table : m.document.tables[0]?.name || m.view.table;
634
+ const edit = () => {
635
+ if (m.errors.size) {
636
+ const input = content.querySelector('[aria-invalid="true"]') || content.querySelector('.count-setting input');
637
+ input?.focus();
638
+ input?.scrollIntoView({
639
+ block: 'nearest'
640
+ });
641
+ return;
642
+ }
643
+ chooseTable(currentSelected(), 'fields');
644
+ [...content.querySelectorAll('.wb-rule-button')].find(control => !control.disabled)?.focus();
645
+ };
646
+ const preview = () => m.document.tables.length > 1 ? refreshSamples() : chooseTable(currentSelected(), 'preview');
647
+ const handlers = {
648
+ select: locateGenerationSelection,
649
+ edit,
650
+ preview: action(preview),
651
+ check: action(showDependencies),
652
+ generate: action(summary)
653
+ };
654
+ const toggle = button(guidanceCollapsed ? '展开引导' : '收起引导', () => {
655
+ guidanceCollapsed = !guidanceCollapsed;
656
+ try {
657
+ localStorage.setItem('sqlseed.workbench.guide.collapsed', String(guidanceCollapsed));
658
+ } catch {/* Keep the in-memory preference. */}
659
+ updateGuidance();
660
+ syncBusy();
661
+ host.querySelector('[data-guide-action="toggle"]')?.focus();
662
+ }, {
663
+ plain: true,
664
+ small: true,
665
+ 'aria-expanded': String(!guidanceCollapsed),
666
+ 'aria-controls': 'wb-next-step-body',
667
+ 'data-guide-action': 'toggle'
668
+ });
669
+ const actions = h('div', {
670
+ class: 'wb-next-step-actions'
671
+ }, button(step.label, handlers[step.action], {
672
+ small: true,
673
+ 'data-guide-action': 'next',
674
+ ...(['preview', 'check', 'generate'].includes(step.action) ? {
675
+ 'data-db-action': ''
676
+ } : {})
677
+ }));
678
+ if (m.document.tables.length && step.action !== 'edit') {
679
+ actions.append(button('手动检查规则', edit, {
680
+ plain: true,
681
+ small: true,
682
+ 'data-guide-action': 'edit'
683
+ }));
684
+ }
685
+ const body = h('div', {
686
+ id: 'wb-next-step-body',
687
+ class: 'wb-next-step-body',
688
+ hidden: guidanceCollapsed
689
+ }, h('div', {
690
+ class: 'wb-next-step-main'
691
+ }, h('h3', {}, step.title), h('p', {}, step.body), actions), h('div', {
692
+ class: 'wb-next-step-ai'
693
+ }, h('span', {}, icon('sparkles'), h('strong', {}, 'AI 辅助配置')), h('small', {}, ai.status), button(ai.label, () => openAI(m.document.tables.length ? 'selected' : 'current'), {
694
+ small: true,
695
+ 'data-guide-action': 'ai'
696
+ })));
697
+ host.replaceChildren(h('div', {
698
+ class: 'wb-next-step-heading'
699
+ }, h('span', {}, step.scope), toggle), body);
700
+ if (focused) {
701
+ host.querySelector(`[data-guide-action="${focused}"]`)?.focus();
702
+ }
703
+ }
704
+ function needsAIDefaultPreflight(m) {
705
+ const mappings = m.document.custom_column_mappings;
706
+ const custom = Object.keys(mappings?.exact || {}).length || mappings?.pattern?.length;
707
+ return m.schema.tables.some(table => table.columns.some(column => column.default !== null && column.default !== undefined) && (custom || m.table(table.name).enrich));
708
+ }
709
+ async function openAI(initialScope = 'current', initialState = null, {
710
+ previewOrigin = null
711
+ } = {}) {
712
+ clearAIHandoff();
713
+ const current = modalTicket(),
714
+ m = model(),
715
+ version = active,
716
+ owner = session;
717
+ if (!validNavigation()) {
718
+ return;
719
+ }
720
+ const onReturn = previewOrigin ? previewReturnHandler(owner, m, previewOrigin) : null;
721
+ const intent = modalIntent;
722
+ let suppressReturn = false;
723
+ const returnToPreview = () => {
724
+ if (onReturn) {
725
+ Promise.resolve().then(() => {
726
+ if (!suppressReturn && intent === modalIntent) {
727
+ onReturn();
728
+ }
729
+ });
730
+ }
731
+ };
732
+ function goAISettings({
733
+ section,
734
+ context
735
+ }) {
736
+ if (!current()) {
737
+ return;
738
+ }
739
+ suppressReturn = true;
740
+ const returnTo = location.hash.startsWith('#/workbench') ? location.hash : '#/workbench';
741
+ rememberAIHandoff({
742
+ model: m,
743
+ connId: owner.connId,
744
+ returnTo,
745
+ context: previewOrigin ? {
746
+ ...context,
747
+ previewOrigin
748
+ } : context
749
+ });
750
+ location.hash = `#/settings?section=${section}`;
751
+ }
752
+ let defaultModes;
753
+ if (needsAIDefaultPreflight(m)) {
754
+ if (!(await resolveDefaultModes()) || !current()) {
755
+ return;
756
+ }
757
+ }
758
+ openedModal = openAIAssistant({
759
+ model: m,
760
+ defaultModes,
761
+ connId: session.connId,
762
+ isCurrent: current,
763
+ initialScope: typeof initialScope === 'string' ? initialScope : 'current',
764
+ initialState,
765
+ onSettings: goAISettings,
766
+ onClose: () => {
767
+ if (version === active && owner === session) {
768
+ loadGuidanceAI();
769
+ }
770
+ returnToPreview();
771
+ },
772
+ onApply: suggestions => {
773
+ if (!current()) {
774
+ throw new Error('配置已变化,请重新分析。');
775
+ }
776
+ for (const item of suggestions) {
777
+ if (!m.schema.tables.find(t => t.name === item.table)?.columns.some(c => c.name === item.column)) {
778
+ throw new Error('建议字段已失效,请刷新结构。');
779
+ }
780
+ }
781
+ m.applyPatches(suggestions);
782
+ m.aiApplied = {
783
+ epoch: m.epoch,
784
+ targets: suggestions.map(item => ({
785
+ table: item.table,
786
+ column: item.column
787
+ }))
788
+ };
789
+ if (!onReturn) {
790
+ drawBody();
791
+ } else {
792
+ updateStatus();
793
+ }
794
+ notify(`已应用 ${suggestions.length} 条 AI 建议。请预览并检查,确认效果后生成数据。`);
795
+ }
796
+ });
797
+ async function resolveDefaultModes() {
798
+ let cancelled = false;
799
+ const loading = openedModal = modal('AI 配置助手', {
800
+ onClose: () => {
801
+ cancelled = true;
802
+ if (!suppressReturn) {
803
+ returnToPreview();
804
+ }
805
+ }
806
+ });
807
+ loading.body.append(h('p', {
808
+ role: 'status'
809
+ }, '正在解析当前配置的字段生成方式…'));
810
+ loading.actions.append(button('取消', loading.close));
811
+ try {
812
+ const result = await send('/api/workbench/ai/eligibility', {
813
+ conn_id: owner.connId,
814
+ schema_hash: m.schema.schema_hash,
815
+ document: m.document,
816
+ table_drafts: Object.values(m.view.tableDrafts)
817
+ });
818
+ if (cancelled || !current()) {
819
+ loading.close();
820
+ return false;
821
+ }
822
+ if (result.schema_hash !== m.schema.schema_hash) {
823
+ throw new Error('数据库结构已变化,请刷新后重新打开 AI 助手。');
824
+ }
825
+ defaultModes = result.default_modes;
826
+ suppressReturn = true;
827
+ loading.close();
828
+ suppressReturn = false;
829
+ } catch (error) {
830
+ if (!cancelled && current()) {
831
+ const unavailable = error.status === 503 && error.detail?.code === 'ai_unavailable';
832
+ const aiUnavailableMessage = () => {
833
+ if (unavailable) {
834
+ return `AI 扩展${error.detail.availability_status === 'import_error' ? '加载异常' : '未安装'},规则建议与分析不可用。请前往插件与版本${error.detail.availability_status === 'import_error' ? '查看异常' : '安装扩展'}后返回。`;
835
+ } else {
836
+ return error.message;
837
+ }
838
+ };
839
+ loading.body.replaceChildren(h('p', {
840
+ role: 'alert'
841
+ }, aiUnavailableMessage()));
842
+ if (unavailable) {
843
+ loading.actions.append(button('前往插件设置', () => {
844
+ if (cancelled || !current()) {
845
+ return;
846
+ }
847
+ goAISettings({
848
+ section: 'plugins',
849
+ context: {
850
+ scope: typeof initialScope === 'string' ? initialScope : 'current',
851
+ currentTable: m.view.table,
852
+ businessContext: '',
853
+ ...initialState
854
+ }
855
+ });
856
+ loading.close();
857
+ }, {
858
+ primary: true
859
+ }));
860
+ }
861
+ } else {
862
+ loading.close();
863
+ }
864
+ return false;
865
+ }
866
+ return true;
867
+ }
868
+ }
869
+ function renameConfig() {
870
+ modalIntent++;
871
+ const current = ticket(),
872
+ dialog = openedModal = modal('重命名生成配置');
873
+ const input = h('input', {
874
+ 'aria-label': '配置名称',
875
+ value: session.name,
876
+ maxlength: 120
877
+ });
878
+ const error = h('p', {
879
+ class: 'wb-error',
880
+ role: 'alert'
881
+ });
882
+ dialog.body.append(h('label', {
883
+ class: 'control'
884
+ }, '配置名称', input), error);
885
+ dialog.actions.append(button('取消', dialog.close), button('确定', () => {
886
+ if (!current()) {
887
+ dialog.close();
888
+ return;
889
+ }
890
+ if (!input.value.trim()) {
891
+ error.textContent = '请填写配置名称';
892
+ return;
893
+ }
894
+ session.name = input.value.trim();
895
+ model().touch();
896
+ dialog.close();
897
+ draw();
898
+ }, {
899
+ primary: true
900
+ }));
901
+ input.focus();
902
+ }
903
+ function drawSidebar() {
904
+ const m = model(),
905
+ refs = referencedTables();
906
+ const view = sidebarViews.get(m) || {
907
+ structureOpen: false,
908
+ query: ''
909
+ };
910
+ sidebarViews.set(m, view);
911
+ const previousMenu = sidebar.querySelector('.wb-structure-menu');
912
+ if (previousMenu) {
913
+ view.structureOpen = previousMenu.open;
914
+ }
915
+ const scrollTop = sidebar.querySelector('.wb-table-list')?.scrollTop || 0;
916
+ function tableGenerationLabel(table) {
917
+ if (m.selected(table.name)) {
918
+ return `生成 ${m.table(table.name).count} 行`;
919
+ } else if (refs.has(table.name)) {
920
+ return '仅引用已有数据';
921
+ } else {
922
+ return '未加入生成';
923
+ }
924
+ }
925
+ sidebar.replaceChildren(h('div', {
926
+ class: 'source'
927
+ }, button(databaseLabel(), () => {
928
+ if (!validNavigation()) {
929
+ return;
930
+ }
931
+ closeComponents();
932
+ graphViews.delete(m);
933
+ m.view.imported = false;
934
+ m.view.page = 'graph';
935
+ m.view.graphMode = 'all';
936
+ selectedEdge = null;
937
+ drawBody();
938
+ }, {
939
+ glyph: 'database',
940
+ plain: true,
941
+ class: 'source-head wb-source',
942
+ title: '查看整库关系图'
943
+ }), h('div', {
944
+ class: 'source-note'
945
+ }, `${m.schema.dialect === 'sqlite' ? 'SQLite' : 'PostgreSQL'} · 已连接`, button('ⓘ', connectionInfo, {
946
+ plain: true,
947
+ class: 'source-info',
948
+ 'aria-label': '查看连接信息'
949
+ })), h('details', {
950
+ class: 'wb-structure-menu',
951
+ open: view.structureOpen,
952
+ ontoggle: event => {
953
+ view.structureOpen = event.currentTarget.open;
954
+ }
955
+ }, h('summary', {}, icon('schema'), '数据库结构操作'), h('div', {
956
+ class: 'wb-structure-commands',
957
+ role: 'group',
958
+ 'aria-label': '数据库结构操作'
959
+ }, h('small', {}, `${m.schema.tables.length} 张表 · ${m.schema.edges.length} 条外键`), button('重新读取结构', action(refreshSchema), {
960
+ small: true,
961
+ glyph: 'refresh',
962
+ title: '重新读取表、字段和外键;不生成样例或写入数据'
963
+ }), button('导出关系图 JSON', () => download('sqlseed-schema.json', JSON.stringify(structureSnapshot(), null, 2)), {
964
+ small: true,
965
+ glyph: 'download'
966
+ }), button('导入关系图 JSON', action(importStructure), {
967
+ small: true,
968
+ glyph: 'upload'
969
+ }), ...(importedStructures.has(session.connId) ? [button('查看已导入关系图', () => {
970
+ m.view.imported = true;
971
+ drawBody();
972
+ }, {
973
+ small: true,
974
+ glyph: 'schema'
975
+ })] : [])))), h('div', {
976
+ class: 'sidebar-label'
977
+ }, h('span', {}, '数据库表'), h('span', {
978
+ 'data-selection-count': ''
979
+ }, `${m.document.tables.length} / ${m.schema.tables.length}`)), h('div', {
980
+ class: 'selection-actions'
981
+ }, button('全选', () => {
982
+ if (!validNavigation()) {
983
+ return;
984
+ }
985
+ m.schema.tables.forEach(t => m.toggleTable(t.name, true));
986
+ drawBody();
987
+ }, {
988
+ small: true
989
+ }), button('清空选择', () => {
990
+ if (!validNavigation()) {
991
+ return;
992
+ }
993
+ m.schema.tables.forEach(t => m.toggleTable(t.name, false));
994
+ drawBody();
995
+ }, {
996
+ small: true
997
+ })), h('label', {
998
+ class: 'search wb-table-search'
999
+ }, icon('search'), h('input', {
1000
+ type: 'search',
1001
+ placeholder: '查找表',
1002
+ 'aria-label': '查找表',
1003
+ value: view.query,
1004
+ oninput: event => {
1005
+ view.query = event.target.value;
1006
+ filterTables();
1007
+ }
1008
+ })), h('div', {
1009
+ class: 'tables wb-table-list'
1010
+ }, ...m.schema.tables.map(table => h('div', {
1011
+ class: `table-entry wb-table-entry${m.view.table === table.name ? ' active' : ''}`,
1012
+ 'data-table': table.name
1013
+ }, h('input', {
1014
+ type: 'checkbox',
1015
+ checked: m.selected(table.name),
1016
+ 'aria-label': `生成 ${table.name}`,
1017
+ onchange: e => {
1018
+ if (!validNavigation()) {
1019
+ e.target.checked = m.selected(table.name);
1020
+ return;
1021
+ }
1022
+ m.toggleTable(table.name, e.target.checked);
1023
+ drawBody();
1024
+ }
1025
+ }), h('button', {
1026
+ class: 'table-button wb-table-name',
1027
+ title: `${table.name} 的字段规则`,
1028
+ onclick: () => chooseTable(table.name)
1029
+ }, h('span', {
1030
+ class: 'table-name'
1031
+ }, table.name), h('span', {
1032
+ class: 'count'
1033
+ }, tableGenerationLabel(table))), button('', () => chooseTable(table.name, 'graph'), {
1034
+ glyph: 'schema',
1035
+ plain: true,
1036
+ class: `table-graph-shortcut wb-table-graph${m.view.table === table.name && m.view.page === 'graph' ? ' active' : ''}`,
1037
+ title: `${table.name} 的完整依赖路径`,
1038
+ 'aria-label': `${table.name} 的依赖路径`,
1039
+ 'aria-pressed': String(m.view.table === table.name && m.view.page === 'graph')
1040
+ })))), ...(m.document.tables.length > 1 ? [button('预览已选表', action(refreshSamples), {
1041
+ glyph: 'fields',
1042
+ small: true,
1043
+ class: 'wb-batch-preview',
1044
+ 'data-db-action': '',
1045
+ title: `预览已勾选的 ${m.document.tables.length} 张表,不写入数据库`
1046
+ })] : []), h('div', {
1047
+ class: 'scope-summary',
1048
+ 'aria-live': 'polite'
1049
+ }));
1050
+ function filterTables() {
1051
+ for (const row of sidebar.querySelectorAll('.wb-table-entry')) {
1052
+ row.hidden = !row.dataset.table.toLowerCase().includes(view.query.trim().toLowerCase());
1053
+ }
1054
+ }
1055
+ filterTables();
1056
+ sidebar.querySelector('.wb-table-list').scrollTop = scrollTop;
1057
+ }
1058
+ function connectionInfo() {
1059
+ modalIntent++;
1060
+ const dialog = openedModal = modal('当前数据库');
1061
+ dialog.body.append(h('dl', {
1062
+ class: 'wb-key-values'
1063
+ }, h('dt', {}, '连接目标'), h('dd', {
1064
+ class: 'mono'
1065
+ }, model().schema.target_label), h('dt', {}, '数据库类型'), h('dd', {}, model().schema.dialect === 'sqlite' ? 'SQLite' : 'PostgreSQL'), h('dt', {}, '状态'), h('dd', {}, '已连接')));
1066
+ dialog.actions.append(button('切换数据库', () => {
1067
+ dialog.close();
1068
+ document.getElementById('connection-button')?.click();
1069
+ }), button('关闭', dialog.close, {
1070
+ primary: true
1071
+ }));
1072
+ }
1073
+ function viewCurrentData() {
1074
+ const current = modalTicket(),
1075
+ owner = session,
1076
+ m = model(),
1077
+ table = m.view.table;
1078
+ const viewer = openTableData({
1079
+ connId: owner.connId,
1080
+ table,
1081
+ targetKey: m.schema.target_key,
1082
+ targetLabel: m.schema.target_label,
1083
+ isCurrent: () => current() && session === owner && model() === m && m.view.table === table
1084
+ });
1085
+ openedModal = viewer.dialog;
1086
+ }
1087
+ function drawBody({
1088
+ autoPreview = true
1089
+ } = {}) {
1090
+ if (!root?.isConnected) {
1091
+ return;
1092
+ }
1093
+ closeComponents();
1094
+ drawSidebar();
1095
+ const m = model(),
1096
+ table = m.schema.tables.find(t => t.name === m.view.table);
1097
+ content.replaceChildren();
1098
+ const imported = importedStructures.get(session.connId);
1099
+ if (m.view.imported && imported) {
1100
+ drawImportedStructure(imported);
1101
+ appendStatus();
1102
+ updateStatus();
1103
+ return;
1104
+ }
1105
+ if (!table) {
1106
+ content.append(h('div', {
1107
+ class: 'empty'
1108
+ }, '当前数据库还没有可配置的表。'));
1109
+ appendStatus();
1110
+ updateStatus();
1111
+ return;
1112
+ }
1113
+ const count = h('input', {
1114
+ type: 'number',
1115
+ min: 1,
1116
+ step: 1,
1117
+ value: m.view.invalidCounts?.[table.name] ?? m.table(table.name).count,
1118
+ disabled: !m.selected(table.name),
1119
+ 'aria-label': `${table.name} 生成数量`,
1120
+ oninput: e => {
1121
+ const valid = m.setCount(table.name, e.target.value);
1122
+ m.view.invalidCounts ||= {};
1123
+ if (valid) {
1124
+ delete m.view.invalidCounts[table.name];
1125
+ } else {
1126
+ m.view.invalidCounts[table.name] = e.target.value;
1127
+ }
1128
+ e.target.setAttribute('aria-invalid', valid ? 'false' : 'true');
1129
+ if (m.view.page === 'preview') {
1130
+ tablePreview?.destroy();
1131
+ tablePreview = null;
1132
+ content.querySelector('.wb-table-preview')?.replaceChildren(h('p', {
1133
+ class: 'wb-preview-help'
1134
+ }, '生成数量已改变,请点击“预览数据”重新查看。'));
1135
+ }
1136
+ updateStatus();
1137
+ }
1138
+ });
1139
+ content.append(h('div', {
1140
+ class: 'table-heading'
1141
+ }, h('div', {
1142
+ class: 'table-title'
1143
+ }, h('h2', {}, table.name), h('span', {
1144
+ class: 'desc'
1145
+ }, `${table.columns.length} 个字段 · 已有 ${table.row_count} 行`), button('查看当前数据', viewCurrentData, {
1146
+ plain: true,
1147
+ small: true,
1148
+ class: 'wb-view-current-data',
1149
+ 'data-db-action': ''
1150
+ })), h('div', {
1151
+ class: 'table-actions'
1152
+ }, ...(!m.selected(table.name) ? [button('加入生成', () => {
1153
+ m.toggleTable(table.name, true);
1154
+ drawBody();
1155
+ }, {
1156
+ small: true
1157
+ })] : []), h('label', {
1158
+ class: 'count-setting'
1159
+ }, '生成数量', count, h('span', {}, '行')))));
1160
+ const pages = [['fields', '字段规则'], ['preview', '预览数据'], ['graph', '关系图']];
1161
+ const tabs = h('div', {
1162
+ class: 'tabs',
1163
+ role: 'tablist',
1164
+ 'aria-label': '当前表视图'
1165
+ });
1166
+ for (const [page, label] of pages) {
1167
+ tabs.append(button(label, () => {
1168
+ const rendered = chooseTable(m.view.table, page);
1169
+ root.querySelector(`#wb-table-tab-${page}`)?.focus();
1170
+ return rendered;
1171
+ }, {
1172
+ plain: true,
1173
+ role: 'tab',
1174
+ id: `wb-table-tab-${page}`,
1175
+ 'aria-controls': 'wb-table-panel',
1176
+ 'aria-selected': String(m.view.page === page),
1177
+ tabindex: m.view.page === page ? 0 : -1,
1178
+ class: m.view.page === page ? 'active' : '',
1179
+ onkeydown: event => {
1180
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
1181
+ return;
1182
+ }
1183
+ event.preventDefault();
1184
+ const buttons = [...tabs.children],
1185
+ index = buttons.indexOf(event.currentTarget);
1186
+ let next;
1187
+ if (event.key === 'Home') {
1188
+ next = 0;
1189
+ } else if (event.key === 'End') {
1190
+ next = buttons.length - 1;
1191
+ } else {
1192
+ next = (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
1193
+ }
1194
+ buttons.forEach((item, i) => item.setAttribute('tabindex', i === next ? '0' : '-1'));
1195
+ buttons[next].focus();
1196
+ }
1197
+ }));
1198
+ }
1199
+ const viewbar = h('div', {
1200
+ class: 'viewbar'
1201
+ }, tabs);
1202
+ content.append(viewbar);
1203
+ const panel = h('section', {
1204
+ id: 'wb-table-panel',
1205
+ role: 'tabpanel',
1206
+ 'aria-labelledby': `wb-table-tab-${m.view.page}`
1207
+ });
1208
+ content.append(panel);
1209
+ // Leaf renderers append to the same table panel; the page header stays stable.
1210
+ const host = content;
1211
+ content = panel;
1212
+ if (m.view.page === 'graph') {
1213
+ drawGraph(table);
1214
+ } else if (m.view.page === 'preview') {
1215
+ drawTablePreview(table);
1216
+ } else {
1217
+ drawFields(table, viewbar);
1218
+ }
1219
+ content = host;
1220
+ appendStatus();
1221
+ updateStatus();
1222
+ if (autoPreview && m.view.page === 'preview' && !tablePreview?.cached) {
1223
+ return tablePreview?.refresh();
1224
+ }
1225
+ }
1226
+ function appendStatus() {
1227
+ notice = h('span', {
1228
+ class: 'wb-notice',
1229
+ role: 'status',
1230
+ 'aria-live': 'polite'
1231
+ }, '规则调整后,可在当前表的“预览数据”中检查效果。');
1232
+ content.append(h('div', {
1233
+ class: 'statusbar'
1234
+ }, h('span', {}, h('i', {
1235
+ class: 'status-dot'
1236
+ }), notice), h('span', {}, '预览不写入数据库')));
1237
+ }
1238
+ function ruleDescription(table, column) {
1239
+ const rule = model().rule(table.name, column.name),
1240
+ generator = rule.generator || rule.generator_name;
1241
+ const fk = table.foreign_keys.find(key => key.columns.includes(column.name));
1242
+ const locked = column.is_autoincrement || column.is_computed;
1243
+ const allocated = locked || generator === 'skip';
1244
+ if (allocated) {
1245
+ const allocatedColumnLabel = () => {
1246
+ if (column.is_computed) {
1247
+ return '数据库计算';
1248
+ } else if (column.is_autoincrement || column.is_rowid_alias) {
1249
+ return '数据库自动分配';
1250
+ } else if (column.default != null) {
1251
+ return '数据库默认值';
1252
+ } else {
1253
+ return '使用 NULL(空值)';
1254
+ }
1255
+ };
1256
+ const allocatedColumnDetail = () => {
1257
+ if (column.is_autoincrement || column.is_rowid_alias) {
1258
+ return '追加数据,由数据库继续分配主键;保留已有 ID';
1259
+ } else if (column.is_computed) {
1260
+ return '由数据库表达式计算';
1261
+ } else if (column.default != null) {
1262
+ return `使用数据库默认值:${column.default}`;
1263
+ } else {
1264
+ return '省略该列,由数据库填入 NULL';
1265
+ }
1266
+ };
1267
+ return {
1268
+ rule,
1269
+ fk,
1270
+ locked,
1271
+ allocated,
1272
+ label: allocatedColumnLabel(),
1273
+ detail: allocatedColumnDetail()
1274
+ };
1275
+ }
1276
+ if (fk) {
1277
+ return {
1278
+ rule,
1279
+ fk,
1280
+ locked,
1281
+ label: `引用 ${fk.ref_table}.${fk.ref_columns.join(', ')}`,
1282
+ detail: rule.params?.strategy === 'coverage' ? '优先覆盖父表可用记录' : '从父表的可用记录中取值',
1283
+ glyph: 'link'
1284
+ };
1285
+ }
1286
+ if (rule.derive_from) {
1287
+ return {
1288
+ rule,
1289
+ locked,
1290
+ label: '根据其他字段推算',
1291
+ detail: `${Array.isArray(rule.derive_from) ? rule.derive_from.join(' / ') : rule.derive_from} · ${rule.expression || ''}`,
1292
+ glyph: 'derive'
1293
+ };
1294
+ }
1295
+ return generatorDescription();
1296
+ function generatorDescription() {
1297
+ const params = rule.params || {};
1298
+ let detail = Object.entries(params).filter(([key, value]) => !key.startsWith('_') && value != null).map(([key, value]) => `${paramLabel(key)} ${valueText(value)}`).join(',');
1299
+ if (params.min_value !== undefined || params.max_value !== undefined) {
1300
+ detail = `${params.min_value ?? '不限'}—${params.max_value ?? '不限'}${params.precision != null ? ",保留 " + params.precision + " 位小数" : ''}`;
1301
+ }
1302
+ if (params.choices) {
1303
+ detail = Array.isArray(params.choices) ? params.choices.map(valueText).join(' / ') : valueText(params.choices);
1304
+ }
1305
+ if (params.start_date || params.end_date) {
1306
+ detail = `${params.start_date || '不限'} — ${params.end_date || '不限'}`;
1307
+ }
1308
+ if (rule.null_ratio) {
1309
+ detail += `${detail ? ',' : ''}${Math.round(rule.null_ratio * 100)}% 为空`;
1310
+ }
1311
+ return {
1312
+ rule,
1313
+ locked,
1314
+ label: genLabel(generator || '自动匹配'),
1315
+ detail: detail || '使用生成器默认参数'
1316
+ };
1317
+ }
1318
+ }
1319
+ function sampleNode(value, allocated = false) {
1320
+ if (value === undefined) {
1321
+ const unavailableSampleLabel = () => {
1322
+ if (allocated) {
1323
+ if (allocated === true || allocated === '数据库自动分配') {
1324
+ return '自动分配';
1325
+ } else {
1326
+ return allocated;
1327
+ }
1328
+ } else {
1329
+ return '待预览';
1330
+ }
1331
+ };
1332
+ return h('span', {
1333
+ class: 'sample placeholder'
1334
+ }, unavailableSampleLabel());
1335
+ }
1336
+ if (value === null) {
1337
+ return h('span', {
1338
+ class: 'sample null'
1339
+ }, 'NULL');
1340
+ }
1341
+ const text = valueText(value),
1342
+ parts = /^(\d{4}-\d\d-\d\d)[T ](\d\d:\d\d:\d\d.*)$/.exec(text);
1343
+ if (parts) {
1344
+ return h('span', {
1345
+ class: 'sample timestamp',
1346
+ title: text
1347
+ }, h('span', {}, parts[1]), h('span', {
1348
+ class: 'sample-time'
1349
+ }, parts[2]));
1350
+ }
1351
+ return h('span', {
1352
+ class: 'sample',
1353
+ title: text
1354
+ }, text);
1355
+ }
1356
+ function drawFields(table, viewbar) {
1357
+ const m = model();
1358
+ const rows = h('tbody'),
1359
+ previewNote = h('div', {
1360
+ class: 'wb-preview-notice',
1361
+ role: 'status'
1362
+ });
1363
+ const search = h('input', {
1364
+ type: 'search',
1365
+ placeholder: '查找字段',
1366
+ 'aria-label': '查找字段',
1367
+ value: fieldQuery,
1368
+ oninput: e => {
1369
+ if (e.isComposing) {
1370
+ return;
1371
+ }
1372
+ fieldQuery = e.target.value;
1373
+ drawRows();
1374
+ },
1375
+ oncompositionend: e => {
1376
+ fieldQuery = e.target.value;
1377
+ drawRows();
1378
+ }
1379
+ });
1380
+ viewbar.append(h('label', {
1381
+ class: 'search'
1382
+ }, icon('search'), search));
1383
+ if (!m.selected(table.name)) {
1384
+ content.append(h('div', {
1385
+ class: 'view-only-note'
1386
+ }, '当前表未勾选,可查看和调整草稿;不会纳入本次生成。'));
1387
+ }
1388
+ content.append(h('div', {
1389
+ class: 'sample-guide'
1390
+ }, h('span', {}, '字段名查看结构,取值规则调整生成方式')), previewNote, h('div', {
1391
+ class: 'wb-rules-scroll',
1392
+ tabindex: 0,
1393
+ 'aria-label': `${table.name} 字段规则,可滚动查看`
1394
+ }, h('table', {
1395
+ class: 'field-table wb-data'
1396
+ }, h('colgroup', {}, h('col', {
1397
+ class: 'col-field'
1398
+ }), h('col', {
1399
+ class: 'col-rule'
1400
+ })), h('thead', {}, h('tr', {}, ...['字段', '取值规则'].map(text => h('th', {
1401
+ scope: 'col'
1402
+ }, text)))), rows)));
1403
+ function drawRows() {
1404
+ previewNote.replaceChildren(...(m.previewIssues || []).map(issue => h('p', {
1405
+ class: issue.severity === 'error' ? 'wb-error' : 'muted'
1406
+ }, `${issue.table || ''}${issue.column ? "." + issue.column : ''}:${issue.message}`)));
1407
+ rows.replaceChildren(...table.columns.filter(c => c.name.toLowerCase().includes(fieldQuery.toLowerCase())).map(column => {
1408
+ const info = ruleDescription(table, column);
1409
+ const ruleButton = h('button', {
1410
+ class: 'wb-rule-button',
1411
+ 'data-rule-column': column.name,
1412
+ onclick: () => openRule(table, column)
1413
+ }, h('span', {
1414
+ class: 'rule-line'
1415
+ }, ...(info.glyph ? [icon(info.glyph)] : []), info.label), h('span', {
1416
+ class: 'rule-meta'
1417
+ }, info.detail));
1418
+ return h('tr', {
1419
+ class: columnName === column.name ? 'selected' : ''
1420
+ }, h('td', {}, button(column.name, () => {
1421
+ columnName = column.name;
1422
+ for (const row of rows.children) {
1423
+ row.classList.toggle('selected', row.querySelector('.wb-field-name')?.textContent === column.name);
1424
+ }
1425
+ openRule(table, column, 'information');
1426
+ }, {
1427
+ plain: true,
1428
+ class: 'field-name wb-field-name'
1429
+ }), h('small', {
1430
+ class: 'field-type'
1431
+ }, `${column.type}${column.is_primary_key ? ' · 主键' : ''}${info.fk ? ' · 外键' : ''}${column.nullable ? ' · 可空' : ' · 不可空'}`)), h('td', {}, ruleButton));
1432
+ }));
1433
+ if (!rows.children.length) {
1434
+ rows.append(h('tr', {}, h('td', {
1435
+ colspan: 2,
1436
+ class: 'empty'
1437
+ }, '没有匹配的字段')));
1438
+ }
1439
+ }
1440
+ if (!table.columns.some(c => c.name === columnName)) {
1441
+ columnName = table.columns[0]?.name || '';
1442
+ }
1443
+ drawRows();
1444
+ }
1445
+ function openRule(table, column, initialTab = 'rule', {
1446
+ onReturn,
1447
+ previewOrigin = null
1448
+ } = {}) {
1449
+ const intent = ++modalIntent;
1450
+ const m = model(),
1451
+ epoch = m.epoch,
1452
+ version = active,
1453
+ current = ticket();
1454
+ let component,
1455
+ error = null,
1456
+ changed = false,
1457
+ pending;
1458
+ const dialog = openedModal = modal(column.name, {
1459
+ drawer: true,
1460
+ onClose: () => {
1461
+ component?.destroy();
1462
+ if (onReturn) {
1463
+ Promise.resolve().then(() => {
1464
+ if (intent === modalIntent) {
1465
+ onReturn();
1466
+ }
1467
+ });
1468
+ }
1469
+ }
1470
+ });
1471
+ const apply = button('应用规则', () => {
1472
+ if (error) {
1473
+ return;
1474
+ }
1475
+ if (version !== active || m !== model() || epoch !== m.epoch) {
1476
+ dialog.close();
1477
+ notify('配置已变化,请重新打开字段规则。', true);
1478
+ return;
1479
+ }
1480
+ if (changed) {
1481
+ m.setColumn(table.name, column.name, pending);
1482
+ }
1483
+ dialog.close();
1484
+ if (!onReturn) {
1485
+ drawBody();
1486
+ content.querySelectorAll('.wb-rule-button').forEach(node => {
1487
+ if (node.dataset.ruleColumn === column.name) {
1488
+ node.focus();
1489
+ }
1490
+ });
1491
+ } else {
1492
+ updateStatus();
1493
+ }
1494
+ notify(changed ? `${table.name}.${column.name} 的规则已应用,请在“预览数据”中检查效果。` : '规则未变更。');
1495
+ }, {
1496
+ primary: true
1497
+ });
1498
+ dialog.body.append(h('div', {
1499
+ class: 'drawer-subtitle'
1500
+ }, `${table.name}.${column.name} · ${column.type}`));
1501
+ const tabs = h('div', {
1502
+ class: 'wb-field-tabs',
1503
+ role: 'tablist',
1504
+ 'aria-label': '字段详情'
1505
+ });
1506
+ const information = h('section', {
1507
+ id: 'field-information',
1508
+ role: 'tabpanel',
1509
+ 'aria-labelledby': 'field-information-tab'
1510
+ });
1511
+ const rules = h('section', {
1512
+ id: 'field-rule',
1513
+ role: 'tabpanel',
1514
+ 'aria-labelledby': 'field-rule-tab'
1515
+ });
1516
+ const info = ruleDescription(table, column),
1517
+ metadata = h('dl', {
1518
+ class: 'wb-field-facts'
1519
+ });
1520
+ renderColumnInformation();
1521
+ const names = [['information', '字段信息'], ['rule', '取值规则']];
1522
+ let currentTab = initialTab;
1523
+ function activate(name, focus = false) {
1524
+ currentTab = name;
1525
+ information.hidden = name !== 'information';
1526
+ rules.hidden = name !== 'rule';
1527
+ [...tabs.children].forEach(tab => {
1528
+ const selected = tab.dataset.tab === name;
1529
+ tab.setAttribute('aria-selected', String(selected));
1530
+ tab.setAttribute('tabindex', selected ? '0' : '-1');
1531
+ if (selected && focus) {
1532
+ tab.focus();
1533
+ }
1534
+ });
1535
+ dialog.actions.replaceChildren(button('取消', dialog.close), name === 'information' ? button('编辑取值规则', () => activate('rule', true), {
1536
+ primary: true
1537
+ }) : apply);
1538
+ if (name === 'rule' && !component) {
1539
+ mountEditor();
1540
+ }
1541
+ }
1542
+ for (const [name, label] of names) {
1543
+ tabs.append(button(label, () => activate(name), {
1544
+ plain: true,
1545
+ id: `field-${name}-tab`,
1546
+ role: 'tab',
1547
+ 'data-tab': name,
1548
+ 'aria-controls': name === 'rule' ? 'field-rule' : 'field-information',
1549
+ onkeydown: event => {
1550
+ if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
1551
+ event.preventDefault();
1552
+ const ruleTabTarget = () => {
1553
+ if (event.key === 'Home') {
1554
+ return 'information';
1555
+ } else if (event.key === 'End') {
1556
+ return 'rule';
1557
+ } else if (currentTab === 'rule') {
1558
+ return 'information';
1559
+ } else {
1560
+ return 'rule';
1561
+ }
1562
+ };
1563
+ activate(ruleTabTarget(), true);
1564
+ }
1565
+ }
1566
+ }));
1567
+ }
1568
+ const eligibility = fieldAIEligibility(table, column, m.rule(table.name, column.name));
1569
+ if (!eligibility.eligible) {
1570
+ information.append(h('p', {
1571
+ class: 'wb-ai-protected'
1572
+ }, eligibility.reason));
1573
+ }
1574
+ const canAI = eligibility.eligible || needsAIDefaultPreflight(m) && eligibility.code === 'database_default';
1575
+ const aiHint = h('p', {
1576
+ class: 'muted',
1577
+ role: 'status',
1578
+ 'data-rule-ai-hint': ''
1579
+ });
1580
+ const ai = button('用 AI 调整', () => {
1581
+ if (!dialog.el.isConnected || !current() || error || changed || !canAI) {
1582
+ return;
1583
+ }
1584
+ return openAI('columns', {
1585
+ scope: 'columns',
1586
+ currentTable: table.name,
1587
+ columnSelection: {
1588
+ [table.name]: [column.name]
1589
+ }
1590
+ }, {
1591
+ previewOrigin
1592
+ });
1593
+ }, {
1594
+ small: true,
1595
+ glyph: 'sparkles',
1596
+ 'data-rule-ai': ''
1597
+ });
1598
+ function updateAI() {
1599
+ ai.disabled = Boolean(error || changed || !canAI || !current());
1600
+ if (error || changed) {
1601
+ aiHint.textContent = '请先应用或取消当前修改,再使用 AI 调整此字段。';
1602
+ } else if (canAI) {
1603
+ aiHint.textContent = '针对当前字段提出规则建议,审阅后再应用。';
1604
+ } else {
1605
+ aiHint.textContent = eligibility.reason;
1606
+ }
1607
+ }
1608
+ rules.append(h('div', {
1609
+ class: 'wb-field-rule-ai'
1610
+ }, ai, aiHint));
1611
+ updateAI();
1612
+ dialog.body.append(tabs, information, rules);
1613
+ function mountEditor() {
1614
+ component = createRuleEditor({
1615
+ table,
1616
+ column,
1617
+ rule: m.rule(table.name, column.name),
1618
+ baseline: table.mapping[column.name],
1619
+ catalog,
1620
+ onChange: rule => {
1621
+ pending = rule;
1622
+ changed = true;
1623
+ updateAI();
1624
+ },
1625
+ onValidity: message => {
1626
+ error = message;
1627
+ apply.disabled = !!message;
1628
+ updateAI();
1629
+ }
1630
+ });
1631
+ rules.append(component.el);
1632
+ }
1633
+ activate(initialTab);
1634
+ function renderColumnInformation() {
1635
+ for (const [label, value] of [['字段', column.name], ['数据类型', column.type], ['允许空值', column.nullable ? '是' : '否'], ['主键', column.is_primary_key ? '是' : '否'], ['数据库默认值', column.default ?? '未设置'], ['自动分配', column.is_autoincrement || column.is_rowid_alias ? '由数据库分配' : '否']]) {
1636
+ metadata.append(h('dt', {}, label), h('dd', {}, String(value)));
1637
+ }
1638
+ information.append(h('p', {
1639
+ class: 'muted'
1640
+ }, '读取自数据库的结构信息。生成规则不会修改表结构。'), metadata);
1641
+ if (info.fk) {
1642
+ information.append(h('h3', {}, '外键来源'), h('p', {
1643
+ class: 'mono'
1644
+ }, `${info.fk.ref_table}.${info.fk.ref_columns.join(', ')}`), h('p', {}, info.detail));
1645
+ }
1646
+ for (const constraint of table.unique_constraints || []) {
1647
+ if (constraint.columns.includes(column.name)) {
1648
+ information.append(h('h3', {}, constraint.columns.length === 1 ? '唯一约束' : '复合唯一约束'), h('p', {
1649
+ class: 'mono'
1650
+ }, constraint.columns.join(' + ')));
1651
+ }
1652
+ }
1653
+ if (table.checks?.length) {
1654
+ information.append(h('details', {
1655
+ class: 'wb-field-constraints'
1656
+ }, h('summary', {}, '所属表的 CHECK 约束'), h('p', {
1657
+ class: 'muted'
1658
+ }, '这些约束作用于整张表,可能涉及其他字段。'), ...table.checks.map(check => h('p', {
1659
+ class: 'mono'
1660
+ }, typeof check === 'string' ? check : check.sqltext || check.expression || JSON.stringify(check)))));
1661
+ }
1662
+ }
1663
+ }
1664
+ function drawGraph(table) {
1665
+ const m = model(),
1666
+ panel = h('aside', {
1667
+ class: 'graph-inspector wb-inspector'
1668
+ }),
1669
+ area = h('div', {
1670
+ class: 'graph-workspace wb-graph-workspace'
1671
+ });
1672
+ const refs = referencedTables();
1673
+ const graphSchema = {
1674
+ ...m.schema,
1675
+ nodes: m.schema.nodes.map(node => ({
1676
+ ...node,
1677
+ selected: m.selected(node.id),
1678
+ referenced: refs.has(node.id),
1679
+ count: m.table(node.id).count
1680
+ }))
1681
+ };
1682
+ const inspectorBody = h('div', {
1683
+ class: 'inspector-body wb-inspector-body'
1684
+ });
1685
+ graphOwner = m;
1686
+ graph = createSchemaGraph({
1687
+ schema: graphSchema,
1688
+ focus: table.name,
1689
+ mode: m.view.graphMode || 'all',
1690
+ pathMode: m.view.pathMode || 'complete',
1691
+ initialView: graphViews.get(m),
1692
+ issues: m.check?.issues || [],
1693
+ onSelect: name => {
1694
+ if (!validNavigation()) {
1695
+ return false;
1696
+ }
1697
+ const next = m.schema.tables.find(t => t.name === name);
1698
+ if (!next) {
1699
+ notify('外部引用来源,仅展示结构。');
1700
+ return;
1701
+ }
1702
+ table = next;
1703
+ m.view.table = name;
1704
+ selectedEdge = null;
1705
+ drawSidebar();
1706
+ updateStatus();
1707
+ inspect();
1708
+ // Count/actions belong to the focused table: rebuild its context through
1709
+ // the same page path on next table navigation, not through selection.
1710
+ syncTableHeading(table);
1711
+ sidebar.querySelector('.wb-table-entry.active')?.scrollIntoView({
1712
+ block: 'nearest'
1713
+ });
1714
+ },
1715
+ onEdge: edge => {
1716
+ selectedEdge = edge;
1717
+ inspect();
1718
+ },
1719
+ onViewChange: view => {
1720
+ graphViews.set(m, view);
1721
+ m.view.graphMode = view.mode;
1722
+ m.view.pathMode = view.pathMode;
1723
+ },
1724
+ onExpand: expanded => area.classList.toggle('expanded', expanded)
1725
+ });
1726
+ const section = h('section', {
1727
+ class: 'panel-content database-graph wb-graph-section'
1728
+ });
1729
+ if (graph.toolbar) {
1730
+ section.append(graph.toolbar);
1731
+ }
1732
+ area.append(graph.el, panel);
1733
+ section.append(area);
1734
+ content.append(section);
1735
+ function inspect() {
1736
+ panel.replaceChildren(h('div', {
1737
+ class: 'inspector-tabs'
1738
+ }, button('字段规则', () => {
1739
+ inspectorMode = 'fields';
1740
+ selectedEdge = null;
1741
+ inspect();
1742
+ }, {
1743
+ plain: true,
1744
+ class: inspectorMode === 'fields' ? 'active' : ''
1745
+ }), button('依赖检查', () => {
1746
+ inspectorMode = 'dependencies';
1747
+ selectedEdge = null;
1748
+ inspect();
1749
+ }, {
1750
+ plain: true,
1751
+ class: inspectorMode === 'dependencies' ? 'active' : ''
1752
+ })), h('div', {
1753
+ class: 'inspector-actions'
1754
+ }, h('div', {
1755
+ class: 'inspector-table-title'
1756
+ }, h('strong', {
1757
+ class: 'mono'
1758
+ }, table.name), h('small', {}, `${table.columns.length} 个字段`)), button('查看字段规则', () => chooseTable(table.name), {
1759
+ small: true
1760
+ })), inspectorBody);
1761
+ if (selectedEdge) {
1762
+ inspectorBody.replaceChildren(h('h3', {}, '外键引用'), h('div', {
1763
+ class: 'wb-edge-mapping'
1764
+ }, ...selectedEdge.sourceColumns.map((source, i) => h('div', {}, h('code', {}, `${selectedEdge.source}.${source}`), h('span', {}, '↓'), h('code', {}, `${selectedEdge.target}.${selectedEdge.targetColumns[i]}`)))), h('p', {
1765
+ class: 'muted'
1766
+ }, '箭头从父表指向引用它的子表。成组字段共同构成同一条外键。'), button('定位引用字段', () => {
1767
+ const target = m.schema.tables.find(t => t.name === selectedEdge.target);
1768
+ const column = target?.columns.find(c => c.name === selectedEdge.targetColumns[0]);
1769
+ if (column) {
1770
+ openRule(target, column);
1771
+ }
1772
+ }, {
1773
+ small: true
1774
+ }));
1775
+ } else if (inspectorMode === 'dependencies') {
1776
+ renderDependencies(inspectorBody, name => {
1777
+ if (!validNavigation()) {
1778
+ return;
1779
+ }
1780
+ const next = m.schema.tables.find(t => t.name === name);
1781
+ if (!next) {
1782
+ return;
1783
+ }
1784
+ table = next;
1785
+ m.view.table = name;
1786
+ graph.focusTable?.(name);
1787
+ drawSidebar();
1788
+ syncTableHeading(table);
1789
+ updateStatus();
1790
+ inspect();
1791
+ sidebar.querySelector('.wb-table-entry.active')?.scrollIntoView({
1792
+ block: 'nearest'
1793
+ });
1794
+ }, table.name);
1795
+ } else {
1796
+ inspectorBody.replaceChildren(h('p', {
1797
+ class: 'muted'
1798
+ }, '选择字段调整生成器与参数。'), ...table.columns.map(column => button('', () => openRule(table, column), {
1799
+ plain: true,
1800
+ class: 'graph-field inspector-field wb-field-card',
1801
+ title: `调整 ${table.name}.${column.name}`
1802
+ })));
1803
+ }
1804
+ if (!selectedEdge && inspectorMode === 'fields') {
1805
+ [...inspectorBody.querySelectorAll('.wb-field-card')].forEach((card, i) => {
1806
+ const column = table.columns[i],
1807
+ info = ruleDescription(table, column);
1808
+ function columnSourceLabel() {
1809
+ if (info.allocated) {
1810
+ return '数据库处理';
1811
+ } else if (info.fk) {
1812
+ return '引用';
1813
+ } else if (info.rule.derive_from) {
1814
+ return '派生';
1815
+ } else {
1816
+ return '生成器';
1817
+ }
1818
+ }
1819
+ card.append(h('div', {
1820
+ class: 'graph-field-title field-card-heading'
1821
+ }, h('strong', {
1822
+ class: 'mono'
1823
+ }, column.name), h('small', {}, columnSourceLabel())), h('span', {}, info.label), sampleNode(m.samples[table.name]?.[0]?.[column.name], info.allocated ? info.label : false));
1824
+ });
1825
+ }
1826
+ }
1827
+ inspect();
1828
+ }
1829
+ function syncTableHeading(table) {
1830
+ const header = content.querySelector('.table-heading');
1831
+ if (!header) {
1832
+ return;
1833
+ }
1834
+ // Reuse the exact controls without keeping handlers bound to the previous node.
1835
+ header.querySelector('h2').textContent = table.name;
1836
+ header.querySelector('.desc').textContent = `${table.columns.length} 个字段 · 已有 ${table.row_count} 行`;
1837
+ const count = h('input', {
1838
+ type: 'number',
1839
+ min: 1,
1840
+ step: 1,
1841
+ disabled: !model().selected(table.name),
1842
+ value: model().table(table.name).count,
1843
+ 'aria-label': `${table.name} 生成数量`,
1844
+ oninput: e => {
1845
+ const m = model(),
1846
+ valid = m.setCount(table.name, e.target.value);
1847
+ m.view.invalidCounts ||= {};
1848
+ if (valid) {
1849
+ delete m.view.invalidCounts[table.name];
1850
+ } else {
1851
+ m.view.invalidCounts[table.name] = e.target.value;
1852
+ }
1853
+ e.target.setAttribute('aria-invalid', valid ? 'false' : 'true');
1854
+ updateStatus();
1855
+ }
1856
+ });
1857
+ header.querySelector('.table-actions').replaceChildren(...(!model().selected(table.name) ? [button('加入生成', () => {
1858
+ model().toggleTable(table.name, true);
1859
+ drawBody();
1860
+ }, {
1861
+ small: true
1862
+ })] : []), h('label', {
1863
+ class: 'count-setting'
1864
+ }, '生成数量', count, h('span', {}, '行')));
1865
+ const tabButtons = content.querySelector('.tabs').querySelectorAll('button');
1866
+ tabButtons[0].onclick = () => chooseTable(table.name);
1867
+ }
1868
+ function renderDependencies(out, locate = name => {
1869
+ inspectorMode = 'dependencies';
1870
+ chooseTable(name, 'graph');
1871
+ }, focus = null, onUpdate = null) {
1872
+ const m = model(),
1873
+ result = m.check;
1874
+ const edges = [...m.schema.edges];
1875
+ for (const association of m.document.associations || []) {
1876
+ for (const target of association.target_tables || []) {
1877
+ edges.push({
1878
+ source: association.source_table,
1879
+ target,
1880
+ sourceColumns: [association.source_column || association.column_name],
1881
+ targetColumns: [association.column_name]
1882
+ });
1883
+ }
1884
+ }
1885
+ const related = relatedTables();
1886
+ const executable = executableTables();
1887
+ const upstreamEdges = edges.filter(edge => related.has(edge.target) && related.has(edge.source));
1888
+ const relevantIssues = (result?.issues || []).filter(issue => !focus || !issue.table || executable.has(issue.table));
1889
+ const sourceCards = upstreamEdges.map(edge => {
1890
+ const parent = m.schema.tables.find(t => t.name === edge.source);
1891
+ const evidence = result?.sources?.find(source => source.table === edge.target && source.source_table === edge.source && source.column === (edge.targetColumns || []).join(','));
1892
+ const selected = m.selected(edge.source),
1893
+ rows = evidence?.row_count ?? parent?.row_count;
1894
+ let explanation;
1895
+ if (focus && !executable.has(edge.target)) {
1896
+ explanation = '结构上的间接上游;本次引用中间表已有数据,此关系不要求先生成该来源。';
1897
+ } else if (selected) {
1898
+ explanation = evidence?.has_values === false ? '先生成父表,再从生成后的有效主键取值。' : '父表也在本次生成范围,按依赖顺序先生成。';
1899
+ } else if (evidence?.has_values) {
1900
+ explanation = `已有 ${rows} 行 · 仅引用,不新增。已检查存在可用引用值,因此无需勾选父表。`;
1901
+ } else if (evidence?.has_values === false) {
1902
+ explanation = evidence.nullable ? '无可用引用值;此外键允许 NULL。' : '无可用引用值;请将父表加入生成范围。';
1903
+ } else {
1904
+ explanation = `${rows == null ? '行数未知' : "已有 " + rows + " 行"} · ${selected ? '本次生成' : '仅引用'};是否有可用引用值以检查结果为准。`;
1905
+ }
1906
+ return h('article', {
1907
+ class: 'wb-source-card'
1908
+ }, h('strong', {
1909
+ class: 'mono'
1910
+ }, `${edge.source}.${(edge.sourceColumns || []).join(' + ')} → ${edge.target}.${(edge.targetColumns || []).join(' + ')}`), h('p', {}, explanation), button(`查看 ${edge.source}`, () => locate(edge.source), {
1911
+ small: true
1912
+ }));
1913
+ });
1914
+ const previousSources = out.querySelector('.wb-dependency-sources');
1915
+ function dependencySourceCards() {
1916
+ if (sourceCards.length) {
1917
+ return sourceCards;
1918
+ } else {
1919
+ return [h('p', {
1920
+ class: 'muted'
1921
+ }, focus ? '当前表没有上游外键或关联依赖。' : '所选表没有外部引用依赖。')];
1922
+ }
1923
+ }
1924
+ const sources = h('details', {
1925
+ class: 'wb-dependency-sources',
1926
+ open: previousSources ? previousSources.open : Boolean(focus)
1927
+ }, h('summary', {}, focus ? `${focus} · 全部上游来源(${sourceCards.length})` : `引用来源明细(${sourceCards.length})`), h('p', {
1928
+ class: 'muted'
1929
+ }, focus ? '此处展示本表依赖的完整上游链。图中的下游表示受本表影响的表,不是本表的生成前置条件。' : '查看来源字段映射、已有数据与生成范围之间的关系。'), ...dependencySourceCards());
1930
+ out.replaceChildren(h('h3', {}, focus ? `${focus} · 依赖检查` : '检查所选表的规则与依赖'));
1931
+ if (focus && !m.selected(focus)) {
1932
+ out.append(h('p', {
1933
+ class: 'muted'
1934
+ }, '当前表未纳入本次检查范围。这里仅解释结构;加入生成后再检查规则与引用来源。'), sources);
1935
+ return;
1936
+ }
1937
+ if (!result) {
1938
+ out.append(h('p', {
1939
+ class: 'muted'
1940
+ }, '尚未检查当前配置。'), button('开始检查', action(async () => {
1941
+ if (await check(false)) {
1942
+ drawBody();
1943
+ }
1944
+ }, '检查依赖'), {
1945
+ glyph: 'check'
1946
+ }), sources);
1947
+ syncBusy();
1948
+ return;
1949
+ }
1950
+ const blockers = relevantIssues.filter(issue => issue.severity === 'error'),
1951
+ reminders = relevantIssues.filter(issue => issue.severity !== 'error');
1952
+ function dependencySummary() {
1953
+ if (blockers.length) {
1954
+ return '当前范围有待处理的问题。';
1955
+ } else if (result.ok) {
1956
+ return '引用来源与规则检查通过。';
1957
+ } else {
1958
+ return '整个计划仍有待处理项,请查看完整检查。';
1959
+ }
1960
+ }
1961
+ out.append(h('section', {
1962
+ class: `wb-dependency-summary ${blockers.length || !result.ok ? 'wb-dependency-blocked' : 'wb-dependency-passed'}`,
1963
+ role: 'status'
1964
+ }, h('strong', {}, dependencySummary()), h('p', {}, `${blockers.length} 项阻断 · ${reminders.length} 项提醒`)));
1965
+ const issueCard = issue => h('article', {
1966
+ class: `dependency-card ${issue.severity}`
1967
+ }, h('strong', {}, issue.table || '生成配置'), h('p', {}, issue.message), ...(issue.code === 'missing_parent_source' && issue.source_table && m.schema.tables.some(t => t.name === issue.source_table) && !m.selected(issue.source_table) ? [button(`加入 ${issue.source_table}(${m.table(issue.source_table).count} 行)`, action(async () => {
1968
+ m.toggleTable(issue.source_table, true);
1969
+ const stillCurrent = ticket();
1970
+ if (await check(false)) {
1971
+ if (!stillCurrent()) {
1972
+ return;
1973
+ }
1974
+ drawBody();
1975
+ if (out.isConnected) {
1976
+ renderDependencies(out, locate, focus, onUpdate);
1977
+ }
1978
+ onUpdate?.();
1979
+ }
1980
+ }, '检查依赖'), {
1981
+ small: true
1982
+ })] : []), ...(issue.table ? [button(`定位 ${issue.column || issue.table}`, () => {
1983
+ locate(issue.table);
1984
+ const table = m.schema.tables.find(t => t.name === issue.table),
1985
+ column = table?.columns.find(c => c.name === issue.column);
1986
+ if (column) {
1987
+ openRule(table, column);
1988
+ }
1989
+ }, {
1990
+ small: true
1991
+ })] : []));
1992
+ if (blockers.length) {
1993
+ out.append(h('section', {
1994
+ class: 'wb-dependency-issues',
1995
+ 'aria-label': '需先处理的问题'
1996
+ }, h('h4', {}, '需先处理的问题'), ...blockers.map(issueCard)));
1997
+ }
1998
+ if (reminders.length) {
1999
+ out.append(h('section', {
2000
+ class: 'wb-dependency-issues',
2001
+ 'aria-label': '提醒与说明'
2002
+ }, h('h4', {}, '提醒与说明'), ...reminders.map(issueCard)));
2003
+ }
2004
+ out.append(sources);
2005
+ const layers = (result.layers || []).map(names => names.filter(name => !focus || executable.has(name))).filter(names => names.length);
2006
+ function dependencyOrderTitle() {
2007
+ if (!result.ok) {
2008
+ return '依赖分组参考';
2009
+ } else if (focus) {
2010
+ return '本表相关生成顺序';
2011
+ } else {
2012
+ return '所选表生成顺序';
2013
+ }
2014
+ }
2015
+ function dependencyOrderHint() {
2016
+ if (!result.ok) {
2017
+ return '尚未形成可执行计划;以下分组可能不包含循环依赖中的表。请先处理阻断项,再重新检查。';
2018
+ } else if (focus) {
2019
+ return '仅列本表及上游中已勾选的表;其他表在整个计划中查看。未勾选的来源使用已有数据。';
2020
+ } else {
2021
+ return '同组表示没有先后依赖;执行仍逐表进行。';
2022
+ }
2023
+ }
2024
+ out.append(h('div', {
2025
+ class: 'execution-heading'
2026
+ }, h('h3', {}, dependencyOrderTitle()), button('整个计划 ↗', action(showPlan), {
2027
+ plain: true,
2028
+ class: 'text-button'
2029
+ })), h('ol', {
2030
+ class: 'execution-sequence'
2031
+ }, ...layers.map((names, i) => h('li', {}, h('span', {
2032
+ class: 'execution-step'
2033
+ }, i + 1), h('div', {
2034
+ class: 'execution-group'
2035
+ }, h('small', {}, `第 ${i + 1} 组 · ${names.length} 张表`), h('div', {
2036
+ class: 'execution-tables'
2037
+ }, ...names.map(name => button(name, () => locate(name), {
2038
+ plain: true,
2039
+ class: 'mono execution-table'
2040
+ }))))))), h('p', {
2041
+ class: 'muted'
2042
+ }, dependencyOrderHint()));
2043
+ syncBusy();
2044
+ function relatedTables() {
2045
+ const related = new Set(focus ? [focus] : m.document.tables.map(t => t.name));
2046
+ for (const name of related) {
2047
+ for (const edge of edges) {
2048
+ if (edge.target === name) {
2049
+ related.add(edge.source);
2050
+ }
2051
+ }
2052
+ }
2053
+ return related;
2054
+ }
2055
+ function executableTables() {
2056
+ const executable = new Set(focus ? [focus] : m.document.tables.map(t => t.name));
2057
+ for (const name of executable) {
2058
+ for (const edge of edges) {
2059
+ if (edge.target === name && m.selected(edge.source)) {
2060
+ executable.add(edge.source);
2061
+ }
2062
+ }
2063
+ }
2064
+ return executable;
2065
+ }
2066
+ }
2067
+ async function showPlan() {
2068
+ return showDependencies();
2069
+ }
2070
+ async function showDependencies() {
2071
+ const current = modalTicket();
2072
+ if (!model().document.tables.length) {
2073
+ const dialog = openedModal = modal('尚未选择生成表');
2074
+ dialog.body.append(h('p', {}, '请在左侧勾选至少一张要生成数据的表,再检查规则与依赖。'));
2075
+ dialog.actions.append(button('关闭', dialog.close), button('选择生成表', () => {
2076
+ if (!current()) {
2077
+ dialog.close();
2078
+ return;
2079
+ }
2080
+ dialog.close();
2081
+ locateGenerationSelection();
2082
+ }, {
2083
+ primary: true
2084
+ }));
2085
+ return;
2086
+ }
2087
+ if (!(await check(false)) || !current()) {
2088
+ return;
2089
+ }
2090
+ const dialog = openedModal = modal('整个计划 · 依赖检查', {
2091
+ wide: true
2092
+ });
2093
+ const generate = button('生成数据', () => {
2094
+ if (!model().check?.ok) {
2095
+ return;
2096
+ }
2097
+ dialog.close();
2098
+ action(summary)();
2099
+ }, {
2100
+ primary: true,
2101
+ disabled: !model().check?.ok
2102
+ });
2103
+ renderDependencies(dialog.body, name => {
2104
+ dialog.close();
2105
+ inspectorMode = 'dependencies';
2106
+ chooseTable(name, 'graph');
2107
+ }, null, () => {
2108
+ generate.disabled = !model().check?.ok;
2109
+ });
2110
+ dialog.actions.append(button('关闭', dialog.close), generate);
2111
+ }
2112
+ function previewTables(m) {
2113
+ return m.schema.tables.map(item => ({
2114
+ ...item,
2115
+ count: m.table(item.name).count,
2116
+ ruleSummaries: Object.fromEntries(item.columns.map(column => {
2117
+ const {
2118
+ label,
2119
+ detail
2120
+ } = ruleDescription(item, column);
2121
+ return [column.name, {
2122
+ label,
2123
+ detail
2124
+ }];
2125
+ })),
2126
+ omittedColumns: Object.fromEntries(item.columns.flatMap(column => {
2127
+ const info = ruleDescription(item, column);
2128
+ return info.allocated ? [[column.name, info.label]] : [];
2129
+ }))
2130
+ }));
2131
+ }
2132
+ function drawTablePreview(table) {
2133
+ const current = ticket(),
2134
+ owner = session,
2135
+ m = model(),
2136
+ epoch = m.epoch,
2137
+ lifecycle = m.lifecycleVersion;
2138
+ const acceptsResult = () => owner.model === m && m.epoch === epoch && m.lifecycleVersion === lifecycle;
2139
+ const container = h('section', {
2140
+ class: 'wb-table-preview',
2141
+ 'aria-label': `${table.name} 预览数据`
2142
+ });
2143
+ content.append(container);
2144
+ const cached = previewResults.get(m)?.get(table.name);
2145
+ const returned = previewReturns.get(m);
2146
+ previewReturns.delete(m);
2147
+ const resume = returned?.table === table.name ? returned.view : null;
2148
+ const initialResult = resume?.result || (cached?.epoch === m.epoch && cached.count === previewCount ? cached.result : null);
2149
+ const component = openDataPreview({
2150
+ container,
2151
+ fixedScope: true,
2152
+ tables: previewTables(m),
2153
+ currentTable: table.name,
2154
+ selectedTables: m.document.tables.map(item => item.name),
2155
+ initialCount: previewCount,
2156
+ initialResult,
2157
+ initialView: resume,
2158
+ initialStale: Boolean(resume?.stale),
2159
+ onColumnAction: (action, context) => editPreviewColumn(owner, m, 'current', action, context),
2160
+ isCurrent: () => current() && m.view.page === 'preview' && m.view.table === table.name,
2161
+ guard: task => action(task, '预览当前表'),
2162
+ generate: async ({
2163
+ count
2164
+ }) => {
2165
+ const result = await owner.previewTable(table.name, count);
2166
+ if (result && acceptsResult()) {
2167
+ cachePreview(m, [table.name], count, result);
2168
+ // A user may leave and re-enter this table while the one shared request
2169
+ // is running. Publish into that current view without another request.
2170
+ if (root?.isConnected && session === owner && model() === m && tablePreview && tablePreview !== component && m.view.page === 'preview' && m.view.table === table.name) {
2171
+ drawBody();
2172
+ }
2173
+ }
2174
+ return result;
2175
+ },
2176
+ onOptionsChange: ({
2177
+ count
2178
+ }) => {
2179
+ previewCount = count;
2180
+ },
2181
+ onResult: (result, {
2182
+ count
2183
+ }) => {
2184
+ if (current()) {
2185
+ cachePreview(m, [table.name], count, result);
2186
+ updateStatus();
2187
+ }
2188
+ },
2189
+ onError: error => {
2190
+ if (current()) {
2191
+ notify(error.message, true);
2192
+ }
2193
+ }
2194
+ });
2195
+ tablePreview = component;
2196
+ tablePreview.cached = Boolean(initialResult);
2197
+ }
2198
+ function cachePreview(m, names, count, result) {
2199
+ const cache = previewResults.get(m) || new Map();
2200
+ previewResults.set(m, cache);
2201
+ for (const name of names) {
2202
+ cache.set(name, {
2203
+ epoch: m.epoch,
2204
+ count,
2205
+ result
2206
+ });
2207
+ }
2208
+ }
2209
+ function previewReturnHandler(owner, m, {
2210
+ scope,
2211
+ table,
2212
+ column,
2213
+ view: sourceView
2214
+ }) {
2215
+ const version = active,
2216
+ epoch = m.epoch,
2217
+ lifecycle = m.lifecycleVersion,
2218
+ schemaHash = m.schema.schema_hash;
2219
+ return () => {
2220
+ if (!root?.isConnected || version !== active || owner !== session || m !== model() || lifecycle !== m.lifecycleVersion || schemaHash !== m.schema.schema_hash) {
2221
+ return;
2222
+ }
2223
+ const view = {
2224
+ ...sourceView,
2225
+ stale: sourceView.stale || epoch !== m.epoch
2226
+ };
2227
+ if (scope === 'selected') {
2228
+ return refreshSamples(view);
2229
+ }
2230
+ if (m.view.page !== 'preview' || m.view.table !== table) {
2231
+ return;
2232
+ }
2233
+ previewReturns.set(m, {
2234
+ table,
2235
+ view
2236
+ });
2237
+ drawBody();
2238
+ [...content.querySelectorAll('[data-preview-column]')].find(item => item.dataset.previewColumn === column && item.dataset.previewEntry === (sourceView.columnAction || 'information'))?.focus({
2239
+ preventScroll: true
2240
+ });
2241
+ };
2242
+ }
2243
+ function editPreviewColumn(owner, m, scope, action, context) {
2244
+ if (owner !== session || m !== model() || !validNavigation()) {
2245
+ return;
2246
+ }
2247
+ const table = m.schema.tables.find(item => item.name === context.table),
2248
+ column = table?.columns.find(item => item.name === context.column);
2249
+ if (!column) {
2250
+ return;
2251
+ }
2252
+ const origin = {
2253
+ scope,
2254
+ ...context
2255
+ };
2256
+ openRule(table, column, action === 'information' ? 'information' : 'rule', {
2257
+ onReturn: previewReturnHandler(owner, m, origin),
2258
+ previewOrigin: origin
2259
+ });
2260
+ }
2261
+ async function refreshSamples(resume = null) {
2262
+ resume = resume?.result ? resume : null;
2263
+ const current = modalTicket(),
2264
+ owner = session,
2265
+ m = model(),
2266
+ table = m.view.table,
2267
+ epoch = m.epoch,
2268
+ lifecycle = m.lifecycleVersion;
2269
+ const names = m.document.tables.map(item => item.name);
2270
+ const preview = openDataPreview({
2271
+ tables: previewTables(m),
2272
+ currentTable: table,
2273
+ selectedTables: m.document.tables.map(item => item.name),
2274
+ initialScope: 'selected',
2275
+ fixedScope: true,
2276
+ initialCount: previewCount,
2277
+ isCurrent: current,
2278
+ initialResult: resume?.result,
2279
+ initialView: resume,
2280
+ initialStale: Boolean(resume?.stale),
2281
+ onColumnAction: (action, context) => editPreviewColumn(owner, m, 'selected', action, context),
2282
+ guard: task => action(task, '预览已选表'),
2283
+ generate: async ({
2284
+ count
2285
+ }) => {
2286
+ const result = await owner.check(true, count);
2287
+ if (result && owner.model === m && m.epoch === epoch && m.lifecycleVersion === lifecycle) {
2288
+ cachePreview(m, names, count, result);
2289
+ if (!preview.dialog.el.isConnected && root?.isConnected && session === owner && model() === m && tablePreview && m.view.page === 'preview' && names.includes(m.view.table)) {
2290
+ drawBody();
2291
+ }
2292
+ }
2293
+ return result;
2294
+ },
2295
+ onOptionsChange: ({
2296
+ count
2297
+ }) => {
2298
+ previewCount = count;
2299
+ },
2300
+ onResult: (result, {
2301
+ count
2302
+ }) => {
2303
+ if (current()) {
2304
+ cachePreview(m, m.document.tables.map(item => item.name), count, result);
2305
+ drawBody();
2306
+ notify(previewMessage(result, '已选表'), !result.ok);
2307
+ }
2308
+ },
2309
+ onError: error => {
2310
+ if (current()) {
2311
+ notify(error.message, true);
2312
+ }
2313
+ }
2314
+ });
2315
+ openedModal = preview.dialog;
2316
+ if (resume) {
2317
+ [...preview.dialog.body.querySelectorAll('[data-preview-column]')].find(item => item.dataset.previewColumn === resume.column && item.dataset.previewEntry === (resume.columnAction || 'information'))?.focus({
2318
+ preventScroll: true
2319
+ });
2320
+ return;
2321
+ }
2322
+ await preview.refresh();
2323
+ }
2324
+ function previewMessage(result, scope) {
2325
+ if (!result.ok) {
2326
+ return '样例有待处理项,请查看字段说明或依赖检查。';
2327
+ }
2328
+ if (result.preview_complete === false) {
2329
+ return '仅更新可预览的部分样例;完整关联样例需等待父表有可用记录。';
2330
+ }
2331
+ return `${scope}样例已更新,数据库未写入。`;
2332
+ }
2333
+ function structureSnapshot() {
2334
+ const s = model().schema;
2335
+ return {
2336
+ format: 'sqlseed-schema-graph',
2337
+ version: 1,
2338
+ label: s.target_label,
2339
+ title: s.target_label,
2340
+ nodes: s.nodes,
2341
+ edges: s.edges
2342
+ };
2343
+ }
2344
+ async function configSettings() {
2345
+ const current = modalTicket();
2346
+ const [providers, locales] = await Promise.all([get('/api/meta/providers'), get('/api/meta/locales')]);
2347
+ if (!current()) {
2348
+ return;
2349
+ }
2350
+ providerRequest++;
2351
+ providerMetadata = providers;
2352
+ updateProviderWarning();
2353
+ const draft = structuredClone(model().document),
2354
+ controls = [];
2355
+ const available = new Set(providers.available || []);
2356
+ const dialog = openedModal = modal('全局生成设置', {
2357
+ onClose: () => controls.forEach(c => c.destroy())
2358
+ });
2359
+ const guide = h('section', {
2360
+ class: 'wb-provider-guide',
2361
+ 'aria-live': 'polite',
2362
+ 'aria-label': '当前引擎特点'
2363
+ });
2364
+ const apply = button('应用设置', () => {
2365
+ if (!current()) {
2366
+ dialog.close();
2367
+ return;
2368
+ }
2369
+ if (!available.has(draft.provider)) {
2370
+ return;
2371
+ }
2372
+ model().document = draft;
2373
+ model().touch();
2374
+ dialog.close();
2375
+ draw();
2376
+ }, {
2377
+ primary: true
2378
+ });
2379
+ function availabilityLabel(value) {
2380
+ if (providers.statuses?.[value]?.status === 'import_error') {
2381
+ return '加载异常';
2382
+ }
2383
+ if (!available.has(value)) {
2384
+ return value === 'mimesis' ? '未安装' : '不可用';
2385
+ }
2386
+ return value === 'base' ? '内置可用' : '已安装';
2387
+ }
2388
+ function showGuide() {
2389
+ const description = providerGuide(draft.provider, draft.locale);
2390
+ const installed = available.has(draft.provider);
2391
+ function providerRequirementHint() {
2392
+ if (installed && draft.provider === 'faker') {
2393
+ return ' · 随 sqlseed 安装';
2394
+ } else if (installed && draft.provider === 'mimesis') {
2395
+ return ' · 可选依赖';
2396
+ } else {
2397
+ return '';
2398
+ }
2399
+ }
2400
+ const status = availabilityLabel(draft.provider) + providerRequirementHint();
2401
+ apply.disabled = !installed;
2402
+ function providerUnavailableHint() {
2403
+ if (!installed) {
2404
+ return h('p', {
2405
+ class: 'wb-error'
2406
+ }, providerUnavailableMessage());
2407
+ } else {
2408
+ return null;
2409
+ }
2410
+ }
2411
+ function providerUnavailableMessage() {
2412
+ if (providers.statuses?.[draft.provider]?.status === 'import_error') {
2413
+ return `${description.title} 已安装但加载异常,暂不能应用。`;
2414
+ }
2415
+ if (draft.provider === 'mimesis') {
2416
+ return '当前环境未安装 Mimesis,暂不能应用。';
2417
+ }
2418
+ return '当前 Web 服务未提供此引擎,请检查安装环境。';
2419
+ }
2420
+ function providerManagementHint() {
2421
+ if (!installed && draft.provider === 'mimesis') {
2422
+ return h('p', {
2423
+ class: 'wb-provider-example'
2424
+ }, h('a', {
2425
+ href: '#/settings?section=plugins',
2426
+ onclick: dialog.close
2427
+ }, '管理插件'), h('br'), h('small', {}, providers.statuses?.mimesis?.status === 'import_error' ? '请在插件与版本中查看异常信息,处理后返回。' : '在插件与版本中安装后,返回选择此引擎。'));
2428
+ } else {
2429
+ return null;
2430
+ }
2431
+ }
2432
+ guide.replaceChildren(...[h('h3', {}, description.title, h('small', {}, status)), h('p', {}, description.summary), providerUnavailableHint(), providerManagementHint(), h('p', {
2433
+ class: 'wb-provider-limit'
2434
+ }, description.limits[0] || ''), h('details', {}, h('summary', {}, '格式示例与详细说明'), h('ul', {}, ...description.features.map(feature => h('li', {}, feature))), h('div', {
2435
+ class: 'wb-provider-example'
2436
+ }, ...description.examples.map(example => h('p', {}, h('span', {}, `${example.label}:`), h('code', {}, example.value)))), h('small', {
2437
+ class: 'muted'
2438
+ }, description.exampleNote), ...description.limits.slice(1).map(limit => h('p', {}, limit)), ...description.sources.map(source => h('a', {
2439
+ href: source.url,
2440
+ target: '_blank',
2441
+ rel: 'noopener noreferrer'
2442
+ }, source.label)))].filter(Boolean));
2443
+ }
2444
+ // Unavailable engines may be inspected locally, but cannot be applied.
2445
+ // Keep the document's engine in the list so reopening never implies a fallback.
2446
+ const choices = [...new Set(['base', 'faker', 'mimesis', ...available, draft.provider].filter(Boolean))];
2447
+ const provider = createDropdown({
2448
+ label: '数据生成引擎',
2449
+ options: choices.map(value => {
2450
+ const description = providerGuide(value, draft.locale);
2451
+ return {
2452
+ value,
2453
+ label: `${description.title} · ${description.choice} · ${availabilityLabel(value)}`
2454
+ };
2455
+ }),
2456
+ value: draft.provider,
2457
+ onChange: value => {
2458
+ draft.provider = value;
2459
+ showGuide();
2460
+ }
2461
+ });
2462
+ const locale = createDropdown({
2463
+ label: '数据语言与地区',
2464
+ options: locales.locales.map(item => ({
2465
+ value: item.code,
2466
+ label: item.label
2467
+ })),
2468
+ value: draft.locale,
2469
+ onChange: value => {
2470
+ draft.locale = value;
2471
+ showGuide();
2472
+ }
2473
+ });
2474
+ controls.push(provider, locale);
2475
+ dialog.body.append(h('p', {
2476
+ class: 'muted'
2477
+ }, '应用于当前配置中的所有表;已有表级、字段级覆盖保留,检查时会提示不兼容的引擎设置。'), h('label', {
2478
+ class: 'control'
2479
+ }, '数据生成引擎', provider.el), guide, h('label', {
2480
+ class: 'control'
2481
+ }, '数据语言与地区', locale.el), h('p', {
2482
+ class: 'wb-settings-note'
2483
+ }, '影响生成内容的语言与格式,不改变界面语言。'));
2484
+ showGuide();
2485
+ dialog.actions.append(button('取消', dialog.close), apply);
2486
+ }
2487
+ async function save() {
2488
+ const current = ticket();
2489
+ if (!session.name.trim()) {
2490
+ throw new Error('请填写配置名称');
2491
+ }
2492
+ const saved = await session.save();
2493
+ if (current()) {
2494
+ updateStatus();
2495
+ notify(`配置已保存 · v${saved.revision}`);
2496
+ }
2497
+ return saved;
2498
+ }
2499
+ async function check(preview = false) {
2500
+ const current = ticket();
2501
+ notify(preview ? '正在生成样例,不写入数据库…' : '正在检查结构、规则与依赖…');
2502
+ const result = await session.check(preview);
2503
+ if (!current()) {
2504
+ return null;
2505
+ }
2506
+ if (!result) {
2507
+ notify('配置已变化,旧的检查结果已丢弃,请重新检查。');
2508
+ return null;
2509
+ }
2510
+ function validationResultMessage() {
2511
+ if (result.ok) {
2512
+ if (preview) {
2513
+ return '样例已更新;数据库未写入。';
2514
+ } else {
2515
+ return '依赖与规则检查通过。';
2516
+ }
2517
+ } else {
2518
+ return `发现 ${result.issues?.length || 1} 个待处理项,请查看依赖检查。`;
2519
+ }
2520
+ }
2521
+ notify(validationResultMessage(), !result.ok);
2522
+ updateStatus();
2523
+ return result;
2524
+ }
2525
+ async function refreshSchema() {
2526
+ if (!validNavigation()) {
2527
+ return;
2528
+ }
2529
+ const current = session,
2530
+ version = active;
2531
+ const schema = await readSchema(current.connId);
2532
+ if (version !== active) {
2533
+ return;
2534
+ }
2535
+ const changedSchema = schema.schema_hash !== model().schema.schema_hash;
2536
+ model().schema = schema;
2537
+ model().touch();
2538
+ drawBody();
2539
+ notify(changedSchema ? '数据库结构已变化,请检查保留的规则后重新保存。' : '已重新读取结构与行数。');
2540
+ }
2541
+ async function openDrafts() {
2542
+ const current = modalTicket();
2543
+ const response = await get(`/api/workbench/drafts?conn_id=${encodeURIComponent(session.connId)}`);
2544
+ if (!current()) {
2545
+ return;
2546
+ }
2547
+ const drafts = Array.isArray(response) ? response : response.drafts || [];
2548
+ const dialog = openedModal = modal('已保存的配置');
2549
+ let opening = false;
2550
+ dialog.body.append(h('p', {
2551
+ class: 'wb-muted'
2552
+ }, '仅列出当前数据库的配置。打开前会保存当前未保存的修改。'));
2553
+ if (!drafts.length) {
2554
+ dialog.body.append(h('p', {}, '尚未保存配置。'));
2555
+ }
2556
+ for (const draft of drafts) {
2557
+ dialog.body.append(button('', action(async () => {
2558
+ if (opening) {
2559
+ return;
2560
+ }
2561
+ opening = true;
2562
+ const stillCurrent = ticket();
2563
+ try {
2564
+ if (model().dirty && (model().document.tables.length || Object.keys(model().view.tableDrafts).length)) {
2565
+ await save();
2566
+ }
2567
+ if (!stillCurrent() || !dialog.body.isConnected) {
2568
+ return;
2569
+ }
2570
+ const full = await get(`/api/workbench/drafts/${encodeURIComponent(draft.id)}`);
2571
+ if (!stillCurrent() || !dialog.body.isConnected) {
2572
+ return;
2573
+ }
2574
+ session.open(full);
2575
+ dialog.close();
2576
+ draw();
2577
+ } finally {
2578
+ opening = false;
2579
+ }
2580
+ }), {
2581
+ class: 'wb-draft-card'
2582
+ }));
2583
+ }
2584
+ dialog.actions.append(button('配置管理', () => {
2585
+ dialog.close();
2586
+ location.hash = '#/configs';
2587
+ }, {
2588
+ glyph: 'settings'
2589
+ }));
2590
+ [...dialog.body.querySelectorAll('.wb-draft-card')].forEach((card, i) => card.append(h('strong', {}, drafts[i].name), h('small', {}, `v${drafts[i].revision} · ${new Date(drafts[i].updated_at * 1000).toLocaleString('zh-CN', {
2591
+ hour12: false
2592
+ })}`)));
2593
+ }
2594
+ async function configDocument() {
2595
+ const stillCurrent = modalTicket(),
2596
+ current = session;
2597
+ const exported = await send('/api/workbench/export', {
2598
+ conn_id: current.connId,
2599
+ document: model().payload(session.name).document
2600
+ });
2601
+ if (!stillCurrent()) {
2602
+ return;
2603
+ }
2604
+ let formatControl;
2605
+ const dialog = openedModal = modal('编辑 YAML', {
2606
+ wide: true,
2607
+ onClose: () => formatControl?.destroy()
2608
+ });
2609
+ const text = h('textarea', {
2610
+ class: 'wb-code',
2611
+ rows: 20,
2612
+ spellcheck: false,
2613
+ 'aria-label': 'YAML 或 JSON 配置',
2614
+ value: exported.yaml
2615
+ });
2616
+ const error = h('p', {
2617
+ class: 'wb-error',
2618
+ role: 'alert'
2619
+ });
2620
+ let textVersion = 0,
2621
+ requestVersion = 0;
2622
+ text.addEventListener('input', () => {
2623
+ textVersion++;
2624
+ });
2625
+ dialog.body.append(h('p', {
2626
+ class: 'muted'
2627
+ }, '默认使用 YAML,也可读取或粘贴 JSON。应用后更新生成配置;写入数据库仍需单独确认。'), text, error);
2628
+ if (exported.credentials_omitted) {
2629
+ dialog.body.append(h('p', {
2630
+ class: 'muted'
2631
+ }, '导出内容省略连接凭据,单独执行时需补充连接信息。'));
2632
+ }
2633
+ const file = h('input', {
2634
+ type: 'file',
2635
+ accept: '.yaml,.yml,.json',
2636
+ hidden: true,
2637
+ onchange: async e => {
2638
+ const selected = e.target.files?.[0];
2639
+ if (!selected) {
2640
+ return;
2641
+ }
2642
+ if (selected.size > 2 * 1024 * 1024) {
2643
+ error.textContent = '配置文件不得超过 2 MiB';
2644
+ return;
2645
+ }
2646
+ const version = textVersion,
2647
+ value = await selected.text();
2648
+ if (dialog.body.isConnected && version === textVersion) {
2649
+ text.value = value;
2650
+ textVersion++;
2651
+ }
2652
+ }
2653
+ });
2654
+ async function parseVisible() {
2655
+ const applyCurrent = ticket(),
2656
+ raw = text.value,
2657
+ inputVersion = textVersion,
2658
+ request = ++requestVersion;
2659
+ const parsed = await send('/api/workbench/parse', {
2660
+ conn_id: current.connId,
2661
+ text: raw
2662
+ });
2663
+ if (!applyCurrent() || !dialog.body.isConnected || request !== requestVersion) {
2664
+ return null;
2665
+ }
2666
+ if (raw !== text.value || inputVersion !== textVersion) {
2667
+ error.textContent = '文本已变化,请重新应用或下载当前内容。';
2668
+ return null;
2669
+ }
2670
+ return {
2671
+ document: parsed.document,
2672
+ current: () => applyCurrent() && dialog.body.isConnected && request === requestVersion && raw === text.value && inputVersion === textVersion
2673
+ };
2674
+ }
2675
+ async function downloadCurrent(format) {
2676
+ try {
2677
+ const parsed = await parseVisible();
2678
+ if (!parsed) {
2679
+ return;
2680
+ }
2681
+ const output = await send('/api/workbench/export', {
2682
+ conn_id: current.connId,
2683
+ document: parsed.document
2684
+ });
2685
+ if (!parsed.current()) {
2686
+ return;
2687
+ }
2688
+ const exportedDocument = () => {
2689
+ if (format === 'json') {
2690
+ if (typeof output.json === 'string') {
2691
+ return output.json;
2692
+ } else {
2693
+ return JSON.stringify(output.json, null, 2);
2694
+ }
2695
+ } else {
2696
+ return output.yaml;
2697
+ }
2698
+ };
2699
+ download(`sqlseed.${format}`, exportedDocument(), format === 'json' ? 'application/json' : 'application/yaml');
2700
+ } catch (e) {
2701
+ if (dialog.body.isConnected) {
2702
+ error.textContent = e.message;
2703
+ }
2704
+ }
2705
+ }
2706
+ let format = 'yaml';
2707
+ formatControl = createDropdown({
2708
+ label: '下载格式',
2709
+ value: format,
2710
+ options: [{
2711
+ value: 'yaml',
2712
+ label: 'YAML'
2713
+ }, {
2714
+ value: 'json',
2715
+ label: 'JSON'
2716
+ }],
2717
+ onChange: value => {
2718
+ format = value;
2719
+ }
2720
+ });
2721
+ const toolbar = h('div', {
2722
+ class: 'wb-document-toolbar',
2723
+ role: 'group',
2724
+ 'aria-label': '配置文件工具'
2725
+ }, file, button('读取文件', () => file.click(), {
2726
+ glyph: 'upload'
2727
+ }), h('label', {
2728
+ class: 'wb-document-format'
2729
+ }, '下载格式', formatControl.el), button('下载配置', action(() => downloadCurrent(format), '导出配置'), {
2730
+ glyph: 'download'
2731
+ }));
2732
+ dialog.body.insertBefore(toolbar, text);
2733
+ dialog.actions.append(button('取消', dialog.close), button('应用配置', action(async () => {
2734
+ try {
2735
+ const parsed = await parseVisible();
2736
+ if (!parsed) {
2737
+ return;
2738
+ }
2739
+ current.model.replaceDocument(parsed.document);
2740
+ dialog.close();
2741
+ draw();
2742
+ notify('配置已应用,请检查并保存。');
2743
+ } catch (e) {
2744
+ if (dialog.body.isConnected) {
2745
+ error.textContent = e.message;
2746
+ }
2747
+ }
2748
+ }, '检查配置文档'), {
2749
+ primary: true
2750
+ }));
2751
+ }
2752
+ async function importStructure() {
2753
+ const current = modalTicket();
2754
+ const dialog = openedModal = modal('导入关系图 JSON', {
2755
+ wide: true
2756
+ });
2757
+ const example = {
2758
+ format: 'sqlseed-schema-graph',
2759
+ version: 1,
2760
+ title: '数据库关系图',
2761
+ nodes: [{
2762
+ id: 'users'
2763
+ }, {
2764
+ id: 'orders'
2765
+ }],
2766
+ edges: [{
2767
+ id: 'orders_user_id',
2768
+ source: 'users',
2769
+ target: 'orders',
2770
+ sourceColumns: ['id'],
2771
+ targetColumns: ['user_id'],
2772
+ nullable: false
2773
+ }]
2774
+ };
2775
+ const text = h('textarea', {
2776
+ class: 'wb-code',
2777
+ rows: 10,
2778
+ 'aria-label': '关系图 JSON',
2779
+ placeholder: '粘贴关系图 JSON,或选择文件'
2780
+ });
2781
+ const error = h('p', {
2782
+ class: 'wb-error',
2783
+ role: 'alert'
2784
+ });
2785
+ const file = h('input', {
2786
+ type: 'file',
2787
+ accept: '.json',
2788
+ hidden: true,
2789
+ onchange: async e => {
2790
+ const selected = e.target.files?.[0];
2791
+ if (!selected) {
2792
+ return;
2793
+ }
2794
+ if (selected.size > 1024 * 1024) {
2795
+ error.textContent = '关系图文件不得超过 1 MiB';
2796
+ return;
2797
+ }
2798
+ const value = await selected.text();
2799
+ if (dialog.body.isConnected) {
2800
+ text.value = value;
2801
+ }
2802
+ }
2803
+ });
2804
+ dialog.body.append(h('p', {}, '导入后只读浏览表与外键关系,不会创建或修改数据库。生成配置继续绑定当前数据库。'), h('details', {}, h('summary', {}, '格式说明'), h('p', {}, 'nodes 是表列表;edges 中 source 为父表、target 为子表,sourceColumns 和 targetColumns 按位置一一对应。复合外键放在同一条边内。'), h('pre', {
2805
+ class: 'wb-import-help'
2806
+ }, JSON.stringify(example, null, 2))), text, error);
2807
+ dialog.actions.append(file, button('下载格式模板', () => download('sqlseed-schema-template.json', JSON.stringify(example, null, 2))), button('选择 JSON 文件', () => file.click()), button('导入关系图', () => {
2808
+ try {
2809
+ if (!current()) {
2810
+ dialog.close();
2811
+ return;
2812
+ }
2813
+ if (new Blob([text.value]).size > 1024 * 1024) {
2814
+ throw new Error('关系图文件不得超过 1 MiB');
2815
+ }
2816
+ const schema = JSON.parse(text.value);
2817
+ if (schema.version !== undefined && schema.version !== 1 || schema.format !== undefined && schema.format !== 'sqlseed-schema-graph') {
2818
+ throw new Error('不支持此关系图格式或版本,请参考格式模板。');
2819
+ }
2820
+ if (!Array.isArray(schema.nodes) || !Array.isArray(schema.edges) || !schema.nodes.length || schema.nodes.length > 200 || schema.edges.length > 1000) {
2821
+ throw new Error('需要 nodes 和 edges,支持 1—200 张表 / 最多 1000 条关系。');
2822
+ }
2823
+ const ids = new Set();
2824
+ for (const node of schema.nodes) {
2825
+ if (typeof node?.id !== 'string' || !node.id.trim() || ids.has(node.id)) {
2826
+ throw new Error('每张表需要非空且唯一的 id。');
2827
+ }
2828
+ ids.add(node.id);
2829
+ }
2830
+ const edgeIds = new Set();
2831
+ schema.edges = schema.edges.map((edge, i) => {
2832
+ if (!edge || !ids.has(edge.source) || !ids.has(edge.target)) {
2833
+ throw new Error('关系的 source / target 必须对应 nodes 中的表 id。');
2834
+ }
2835
+ const sourceColumns = edge.sourceColumns ?? [],
2836
+ targetColumns = edge.targetColumns ?? [];
2837
+ if (!Array.isArray(sourceColumns) || !Array.isArray(targetColumns) || sourceColumns.length !== targetColumns.length || [...sourceColumns, ...targetColumns].some(col => typeof col !== 'string' || !col.trim())) {
2838
+ throw new Error('外键列必须是长度相同的字符串数组。');
2839
+ }
2840
+ const id = edge.id || `relation-${i + 1}`;
2841
+ if (typeof id !== 'string' || edgeIds.has(id)) {
2842
+ throw new Error('每条关系需要唯一的 id。');
2843
+ }
2844
+ edgeIds.add(id);
2845
+ return {
2846
+ ...edge,
2847
+ id,
2848
+ sourceColumns,
2849
+ targetColumns
2850
+ };
2851
+ });
2852
+ if (typeof schema.title !== 'string') {
2853
+ schema.title = typeof schema.label === 'string' ? schema.label : '导入的关系图';
2854
+ }
2855
+ importedStructures.set(session.connId, schema);
2856
+ model().view.imported = true;
2857
+ dialog.close();
2858
+ drawBody();
2859
+ } catch (e) {
2860
+ error.textContent = e.message;
2861
+ }
2862
+ }, {
2863
+ primary: true
2864
+ }));
2865
+ }
2866
+ function drawImportedStructure(schema) {
2867
+ const section = h('section', {
2868
+ class: 'panel-content database-graph wb-imported-structure'
2869
+ });
2870
+ const inspect = h('div', {
2871
+ class: 'wb-edge-mapping',
2872
+ hidden: true
2873
+ });
2874
+ section.append(h('div', {
2875
+ class: 'wb-imported-note'
2876
+ }, h('div', {}, h('h3', {}, schema.title), h('span', {}, '只读关系图 · 与当前生成配置分开浏览')), button('返回当前数据库', () => {
2877
+ model().view.imported = false;
2878
+ drawBody();
2879
+ }, {
2880
+ small: true
2881
+ })));
2882
+ graphOwner = null;
2883
+ graph = createSchemaGraph({
2884
+ schema: {
2885
+ ...schema,
2886
+ tables: []
2887
+ },
2888
+ mode: 'all',
2889
+ onSelect: () => {},
2890
+ onEdge: edge => {
2891
+ inspect.hidden = false;
2892
+ inspect.replaceChildren(h('strong', {}, `${edge.source} → ${edge.target}`), ...edge.sourceColumns.map((column, i) => h('code', {}, `${column} → ${edge.targetColumns[i]}`)));
2893
+ }
2894
+ });
2895
+ if (graph.toolbar) {
2896
+ section.append(graph.toolbar);
2897
+ }
2898
+ section.append(graph.el, inspect);
2899
+ content.append(section);
2900
+ }
2901
+ async function summary() {
2902
+ const stillCurrent = modalTicket();
2903
+ if (!model().document.tables.length) {
2904
+ throw new Error('请先勾选要生成的表');
2905
+ }
2906
+ if (model().dirty) {
2907
+ await save();
2908
+ }
2909
+ if (!stillCurrent()) {
2910
+ return;
2911
+ }
2912
+ const result = await check(false);
2913
+ if (!result || !stillCurrent()) {
2914
+ return;
2915
+ }
2916
+ const current = session,
2917
+ m = model(),
2918
+ epoch = m.epoch,
2919
+ version = active;
2920
+ let sequence = 0,
2921
+ plan = null,
2922
+ busy = false,
2923
+ planning = false,
2924
+ execution = {
2925
+ mode: 'append',
2926
+ reset_identity: false
2927
+ };
2928
+ const dialog = openedModal = modal('确认生成数据', {
2929
+ wide: true,
2930
+ onClose: () => {
2931
+ sequence++;
2932
+ }
2933
+ });
2934
+ const isCurrent = () => version === active && current === session && m === model() && m.epoch === epoch && dialog.body.isConnected;
2935
+ const planInfo = h('div', {
2936
+ class: 'wb-execution-plan',
2937
+ 'aria-live': 'polite'
2938
+ });
2939
+ const reset = h('input', {
2940
+ type: 'checkbox',
2941
+ checked: false,
2942
+ disabled: true,
2943
+ 'aria-label': '重置自增计数',
2944
+ onchange: () => {
2945
+ if (planning || busy) {
2946
+ return;
2947
+ }
2948
+ execution.reset_identity = reset.checked;
2949
+ return inspectExecution();
2950
+ }
2951
+ });
2952
+ const append = h('input', {
2953
+ type: 'radio',
2954
+ name: 'execution-mode',
2955
+ value: 'append',
2956
+ checked: true,
2957
+ 'aria-label': '追加数据',
2958
+ onchange: () => {
2959
+ if (busy) {
2960
+ return;
2961
+ }
2962
+ execution = {
2963
+ mode: 'append',
2964
+ reset_identity: false
2965
+ };
2966
+ reset.checked = false;
2967
+ reset.disabled = true;
2968
+ return inspectExecution();
2969
+ }
2970
+ });
2971
+ const replace = h('input', {
2972
+ type: 'radio',
2973
+ name: 'execution-mode',
2974
+ value: 'replace_selected',
2975
+ disabled: m.schema.dialect !== 'sqlite',
2976
+ 'aria-label': '清空所选表后生成',
2977
+ onchange: () => {
2978
+ if (planning || busy) {
2979
+ return;
2980
+ }
2981
+ execution.mode = 'replace_selected';
2982
+ reset.disabled = false;
2983
+ return inspectExecution();
2984
+ }
2985
+ });
2986
+ function databaseDialectLabel() {
2987
+ if (m.schema.dialect === 'sqlite') {
2988
+ return 'SQLite';
2989
+ } else if (m.schema.dialect === 'postgresql') {
2990
+ return 'PostgreSQL';
2991
+ } else {
2992
+ return m.schema.dialect;
2993
+ }
2994
+ }
2995
+ dialog.body.append(h('section', {
2996
+ class: 'wb-write-target',
2997
+ 'aria-label': '写入目标'
2998
+ }, h('div', {}, h('strong', {}, '写入目标'), h('span', {
2999
+ class: 'wb-muted'
3000
+ }, databaseDialectLabel())), h('p', {
3001
+ class: 'mono'
3002
+ }, m.schema.target_label), h('small', {
3003
+ class: 'wb-muted'
3004
+ }, m.schema.dialect === 'sqlite' ? '当前连接的数据库位置;由运行 Web 的设备访问。' : '当前连接的数据库地址;连接凭据已隐藏。')), h('p', {
3005
+ class: 'wb-muted'
3006
+ }, `${current.name} · v${m.saved.revision}`), h('fieldset', {
3007
+ class: 'wb-write-strategy'
3008
+ }, h('legend', {}, '已有数据处理'), h('label', {}, append, h('span', {}, h('strong', {}, '追加数据'), h('small', {}, '保留已有记录,由数据库继续分配 ID。'))), h('label', {}, replace, h('span', {}, h('strong', {}, '清空所选表后生成'), h('small', {}, '删除下列所选表的现有记录,再生成新数据。'))), ...(m.schema.dialect !== 'sqlite' ? [h('p', {
3009
+ class: 'muted'
3010
+ }, 'PostgreSQL 暂未开放清空模式;当前仅支持追加数据。')] : []), h('label', {
3011
+ class: 'wb-reset-identity'
3012
+ }, reset, h('span', {}, '重置自增计数', h('small', {}, '仅清空后可选。SQLite AUTOINCREMENT 从 1 重新分配;普通整数主键在空表中通常也从 1 开始。')))), h('div', {
3013
+ class: 'wb-summary-total'
3014
+ }, h('strong', {}, m.document.tables.reduce((sum, t) => sum + t.count, 0).toLocaleString()), ' 行本次生成 / ', m.document.tables.length, ' 张表'), h('ol', {
3015
+ class: 'wb-summary-plan'
3016
+ }, ...(result.order || []).map(name => h('li', {}, button(name, () => {
3017
+ dialog.close();
3018
+ inspectorMode = 'dependencies';
3019
+ chooseTable(name, 'graph');
3020
+ }, {
3021
+ plain: true,
3022
+ class: 'mono execution-table'
3023
+ }), h('span', {}, `${m.table(name).count} 行`)))), ...(result.issues || []).map(issue => h('p', {
3024
+ class: issue.severity === 'error' ? 'wb-error' : 'wb-muted'
3025
+ }, `${issue.table || ''} ${issue.message}`)), planInfo);
3026
+ const submit = button('写入数据库', async () => {
3027
+ if (!isCurrent() || busy || planning || !m.canRun() || execution.mode === 'replace_selected' && !plan?.ok) {
3028
+ return;
3029
+ }
3030
+ busy = true;
3031
+ submit.disabled = true;
3032
+ setStrategyBusy(true);
3033
+ try {
3034
+ const run = await current.run(execution.mode === 'append' ? undefined : execution, plan?.plan_hash);
3035
+ if (isCurrent()) {
3036
+ dialog.close();
3037
+ location.hash = `#/runs?id=${encodeURIComponent(run.id)}`;
3038
+ }
3039
+ } catch (error) {
3040
+ if (isCurrent()) {
3041
+ planInfo.append(h('p', {
3042
+ class: 'wb-error',
3043
+ role: 'alert'
3044
+ }, error.message));
3045
+ busy = false;
3046
+ setStrategyBusy(false);
3047
+ submit.disabled = execution.mode === 'replace_selected';
3048
+ if (execution.mode === 'replace_selected') {
3049
+ plan = null;
3050
+ planInfo.append(button('重新核对计划', inspectExecution));
3051
+ }
3052
+ }
3053
+ }
3054
+ }, {
3055
+ primary: true,
3056
+ disabled: !m.canRun()
3057
+ });
3058
+ dialog.actions.append(button('返回调整', dialog.close), submit);
3059
+ function appendPlan() {
3060
+ planInfo.replaceChildren(h('p', {
3061
+ class: 'wb-muted'
3062
+ }, '向现有数据追加,不清空表;自动主键由数据库继续分配。按此顺序逐表写入。若中途失败,后续表停止;已经提交的数据保留,实际数量可在运行记录中查看。'));
3063
+ }
3064
+ function setStrategyBusy(value) {
3065
+ append.disabled = busy;
3066
+ replace.disabled = value || m.schema.dialect !== 'sqlite';
3067
+ reset.disabled = value || execution.mode !== 'replace_selected' || !plan?.reset_identity_supported;
3068
+ planInfo.setAttribute('aria-busy', String(value));
3069
+ }
3070
+ async function inspectExecution() {
3071
+ if (busy || planning && execution.mode !== 'append') {
3072
+ return;
3073
+ }
3074
+ const request = ++sequence;
3075
+ plan = null;
3076
+ submit.disabled = true;
3077
+ if (execution.mode === 'append') {
3078
+ appendPlan();
3079
+ submit.textContent = '写入数据库';
3080
+ submit.disabled = planning || !m.canRun();
3081
+ setStrategyBusy(planning);
3082
+ return;
3083
+ }
3084
+ submit.textContent = '清空并生成';
3085
+ planInfo.replaceChildren(h('p', {}, '正在核对清空范围、外键与事务能力…'));
3086
+ planning = true;
3087
+ setStrategyBusy(true);
3088
+ try {
3089
+ const response = await current.executionPlan({
3090
+ ...execution
3091
+ });
3092
+ if (!isCurrent() || request !== sequence) {
3093
+ return;
3094
+ }
3095
+ plan = response;
3096
+ const tables = plan.clear_tables || [];
3097
+ planInfo.replaceChildren(h('h3', {}, `将清空 ${tables.length} 张表 · ${tables.reduce((sum, t) => sum + t.row_count, 0).toLocaleString()} 行现有记录`), h('ul', {}, ...tables.map(table => h('li', {}, `${table.name}:${table.row_count} 行`))), ...(plan.issues || []).map(issue => h('p', {
3098
+ class: issue.severity === 'error' ? 'wb-error' : 'muted',
3099
+ role: issue.severity === 'error' ? 'alert' : 'status'
3100
+ }, issue.message)), h('p', {}, plan.atomic ? '清空与本次生成在同一事务中完成。失败将回滚本次操作,保留原有数据。' : '此模式不具备整体回滚能力。'), h('p', {
3101
+ class: 'muted'
3102
+ }, execution.reset_identity ? '已请求重置所选表的自增计数。' : '保留 AUTOINCREMENT 计数;普通整数主键按数据库空表规则分配。'));
3103
+ submit.disabled = !plan.ok || !plan.atomic || !m.canRun();
3104
+ reset.disabled = !plan.reset_identity_supported;
3105
+ } catch (error) {
3106
+ if (isCurrent() && request === sequence) {
3107
+ planInfo.replaceChildren(h('p', {
3108
+ class: 'wb-error',
3109
+ role: 'alert'
3110
+ }, error.message));
3111
+ }
3112
+ } finally {
3113
+ planning = false;
3114
+ if (isCurrent()) {
3115
+ setStrategyBusy(false);
3116
+ if (execution.mode === 'append') {
3117
+ submit.disabled = !m.canRun();
3118
+ }
3119
+ }
3120
+ }
3121
+ }
3122
+ appendPlan();
3123
+ }