@aiao/rxdb-model 0.0.19 → 0.0.26

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 (181) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +43 -6
  3. package/dist/cn.d.ts +19 -0
  4. package/dist/cn.d.ts.map +1 -0
  5. package/dist/entity-capabilities.d.ts +31 -0
  6. package/dist/entity-capabilities.d.ts.map +1 -0
  7. package/dist/entity-detail/detail-tabs.d.ts +13 -2
  8. package/dist/entity-detail/detail-tabs.d.ts.map +1 -1
  9. package/dist/entity-detail/index.d.ts +5 -0
  10. package/dist/entity-detail/index.d.ts.map +1 -1
  11. package/dist/entity-detail/interfaces.d.ts +29 -1
  12. package/dist/entity-detail/interfaces.d.ts.map +1 -1
  13. package/dist/entity-field.utils.d.ts +60 -2
  14. package/dist/entity-field.utils.d.ts.map +1 -1
  15. package/dist/entity-form/form-data.d.ts +22 -1
  16. package/dist/entity-form/form-data.d.ts.map +1 -1
  17. package/dist/entity-form/form-fields.d.ts +25 -2
  18. package/dist/entity-form/form-fields.d.ts.map +1 -1
  19. package/dist/entity-form/form-validation.d.ts +20 -2
  20. package/dist/entity-form/form-validation.d.ts.map +1 -1
  21. package/dist/entity-form/index.d.ts +5 -0
  22. package/dist/entity-form/index.d.ts.map +1 -1
  23. package/dist/entity-form/interfaces.d.ts +65 -1
  24. package/dist/entity-form/interfaces.d.ts.map +1 -1
  25. package/dist/entity-list/manual-order-list.d.ts +99 -0
  26. package/dist/entity-list/manual-order-list.d.ts.map +1 -0
  27. package/dist/entity-table/columns/build-editable-columns.d.ts +26 -4
  28. package/dist/entity-table/columns/build-editable-columns.d.ts.map +1 -1
  29. package/dist/entity-table/columns/column-utils.d.ts +78 -12
  30. package/dist/entity-table/columns/column-utils.d.ts.map +1 -1
  31. package/dist/entity-table/editors/color-editor.d.ts +23 -0
  32. package/dist/entity-table/editors/color-editor.d.ts.map +1 -0
  33. package/dist/entity-table/editors/date-editor.d.ts +6 -1
  34. package/dist/entity-table/editors/date-editor.d.ts.map +1 -1
  35. package/dist/entity-table/editors/enum-editor.d.ts +11 -1
  36. package/dist/entity-table/editors/enum-editor.d.ts.map +1 -1
  37. package/dist/entity-table/editors/global-overlay-editor.d.ts +27 -4
  38. package/dist/entity-table/editors/global-overlay-editor.d.ts.map +1 -1
  39. package/dist/entity-table/editors/icon-list-editor.d.ts +13 -1
  40. package/dist/entity-table/editors/icon-list-editor.d.ts.map +1 -1
  41. package/dist/entity-table/editors/json-editor.d.ts +6 -1
  42. package/dist/entity-table/editors/json-editor.d.ts.map +1 -1
  43. package/dist/entity-table/editors/key-value-editor.d.ts +12 -2
  44. package/dist/entity-table/editors/key-value-editor.d.ts.map +1 -1
  45. package/dist/entity-table/editors/lucide-svg.d.ts +3 -2
  46. package/dist/entity-table/editors/lucide-svg.d.ts.map +1 -1
  47. package/dist/entity-table/editors/multiselect-editor.d.ts +24 -6
  48. package/dist/entity-table/editors/multiselect-editor.d.ts.map +1 -1
  49. package/dist/entity-table/editors/number-editor.d.ts +33 -3
  50. package/dist/entity-table/editors/number-editor.d.ts.map +1 -1
  51. package/dist/entity-table/editors/relation-editor.d.ts +14 -2
  52. package/dist/entity-table/editors/relation-editor.d.ts.map +1 -1
  53. package/dist/entity-table/editors/safe-list-editor.d.ts +1 -0
  54. package/dist/entity-table/editors/safe-list-editor.d.ts.map +1 -1
  55. package/dist/entity-table/editors/tags-editor.d.ts +12 -1
  56. package/dist/entity-table/editors/tags-editor.d.ts.map +1 -1
  57. package/dist/entity-table/editors/text-format-editor.d.ts +32 -0
  58. package/dist/entity-table/editors/text-format-editor.d.ts.map +1 -0
  59. package/dist/entity-table/editors/uuid-editor.d.ts +14 -1
  60. package/dist/entity-table/editors/uuid-editor.d.ts.map +1 -1
  61. package/dist/entity-table/index.d.ts +13 -3
  62. package/dist/entity-table/index.d.ts.map +1 -1
  63. package/dist/entity-table/interfaces.d.ts +13 -0
  64. package/dist/entity-table/interfaces.d.ts.map +1 -1
  65. package/dist/entity-table/vtable/table-clipboard.d.ts +31 -2
  66. package/dist/entity-table/vtable/table-clipboard.d.ts.map +1 -1
  67. package/dist/entity-table/vtable/table-factory.d.ts +25 -5
  68. package/dist/entity-table/vtable/table-factory.d.ts.map +1 -1
  69. package/dist/entity-table/vtable/table-keyboard.d.ts +18 -1
  70. package/dist/entity-table/vtable/table-keyboard.d.ts.map +1 -1
  71. package/dist/entity-table/vtable/table-operations.d.ts +44 -3
  72. package/dist/entity-table/vtable/table-operations.d.ts.map +1 -1
  73. package/dist/entity-table/vtable/table-row-move.d.ts +88 -0
  74. package/dist/entity-table/vtable/table-row-move.d.ts.map +1 -0
  75. package/dist/entity-table/vtable/table-theme.d.ts +47 -2
  76. package/dist/entity-table/vtable/table-theme.d.ts.map +1 -1
  77. package/dist/entity-table/vtable/table-tooltip.d.ts +7 -1
  78. package/dist/entity-table/vtable/table-tooltip.d.ts.map +1 -1
  79. package/dist/entity-table/vtable/vtable-compat.d.ts +33 -5
  80. package/dist/entity-table/vtable/vtable-compat.d.ts.map +1 -1
  81. package/dist/entity-value.utils.d.ts +50 -2
  82. package/dist/entity-value.utils.d.ts.map +1 -1
  83. package/dist/index.d.ts +9 -0
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +4392 -64471
  86. package/dist/query-builder/models/config.interface.d.ts +2 -2
  87. package/dist/query-builder/models/operator.interface.d.ts +6 -2
  88. package/dist/query-builder/models/operator.interface.d.ts.map +1 -1
  89. package/dist/query-builder/models/query-builder-state.d.ts +31 -2
  90. package/dist/query-builder/models/query-builder-state.d.ts.map +1 -1
  91. package/dist/query-builder/models/validation.interface.d.ts +2 -2
  92. package/dist/query-builder/services/operator-registry.service.d.ts +13 -3
  93. package/dist/query-builder/services/operator-registry.service.d.ts.map +1 -1
  94. package/dist/query-builder/services/query-builder.service.d.ts +16 -6
  95. package/dist/query-builder/services/query-builder.service.d.ts.map +1 -1
  96. package/dist/query-builder/services/query-converter.service.d.ts +10 -2
  97. package/dist/query-builder/services/query-converter.service.d.ts.map +1 -1
  98. package/dist/query-builder/services/schema-parser.service.d.ts +10 -2
  99. package/dist/query-builder/services/schema-parser.service.d.ts.map +1 -1
  100. package/dist/query-builder/services/validation.service.d.ts +34 -4
  101. package/dist/query-builder/services/validation.service.d.ts.map +1 -1
  102. package/dist/query-builder/utils/date.d.ts +12 -0
  103. package/dist/query-builder/utils/date.d.ts.map +1 -1
  104. package/dist/query-builder/utils/field-tree.d.ts +7 -1
  105. package/dist/query-builder/utils/field-tree.d.ts.map +1 -1
  106. package/dist/query-builder/utils/id.d.ts +2 -0
  107. package/dist/query-builder/utils/id.d.ts.map +1 -1
  108. package/dist/query-builder/utils/metadata-field-extractor.d.ts +15 -3
  109. package/dist/query-builder/utils/metadata-field-extractor.d.ts.map +1 -1
  110. package/dist/query-builder/utils/popover-position.d.ts +11 -0
  111. package/dist/query-builder/utils/popover-position.d.ts.map +1 -1
  112. package/dist/query-builder/utils/schema-factory.d.ts +7 -3
  113. package/dist/query-builder/utils/structural-equal.d.ts +4 -0
  114. package/dist/query-builder/utils/structural-equal.d.ts.map +1 -1
  115. package/dist/query-builder/utils/value-input.d.ts +21 -1
  116. package/dist/query-builder/utils/value-input.d.ts.map +1 -1
  117. package/dist/structural-equal.d.ts +5 -1
  118. package/dist/structural-equal.d.ts.map +1 -1
  119. package/package.json +34 -10
  120. package/src/cn.ts +21 -0
  121. package/src/entity-capabilities.ts +37 -0
  122. package/src/entity-detail/detail-tabs.ts +48 -0
  123. package/src/entity-detail/index.ts +7 -0
  124. package/src/entity-detail/interfaces.ts +55 -0
  125. package/src/entity-field.utils.ts +205 -0
  126. package/src/entity-form/form-data.ts +83 -0
  127. package/src/entity-form/form-fields.ts +48 -0
  128. package/src/entity-form/form-validation.ts +34 -0
  129. package/src/entity-form/index.ts +9 -0
  130. package/src/entity-form/interfaces.ts +116 -0
  131. package/src/entity-list/manual-order-list.ts +159 -0
  132. package/src/entity-table/columns/build-editable-columns.ts +184 -0
  133. package/src/entity-table/columns/column-utils.ts +657 -0
  134. package/src/entity-table/editors/color-editor.ts +239 -0
  135. package/src/entity-table/editors/date-editor.ts +169 -0
  136. package/src/entity-table/editors/enum-editor.ts +167 -0
  137. package/src/entity-table/editors/global-overlay-editor.ts +277 -0
  138. package/src/entity-table/editors/icon-list-editor.ts +130 -0
  139. package/src/entity-table/editors/json-editor.ts +212 -0
  140. package/src/entity-table/editors/key-value-editor.ts +595 -0
  141. package/src/entity-table/editors/lucide-svg.ts +78 -0
  142. package/src/entity-table/editors/multiselect-editor.ts +171 -0
  143. package/src/entity-table/editors/number-editor.ts +322 -0
  144. package/src/entity-table/editors/relation-editor.ts +316 -0
  145. package/src/entity-table/editors/safe-list-editor.ts +17 -0
  146. package/src/entity-table/editors/tags-editor.ts +239 -0
  147. package/src/entity-table/editors/text-format-editor.ts +220 -0
  148. package/src/entity-table/editors/uuid-editor.ts +198 -0
  149. package/src/entity-table/index.ts +96 -0
  150. package/src/entity-table/interfaces.ts +72 -0
  151. package/src/entity-table/vtable/table-clipboard.ts +419 -0
  152. package/src/entity-table/vtable/table-factory.ts +117 -0
  153. package/src/entity-table/vtable/table-keyboard.ts +89 -0
  154. package/src/entity-table/vtable/table-operations.ts +123 -0
  155. package/src/entity-table/vtable/table-row-move.ts +122 -0
  156. package/src/entity-table/vtable/table-theme.ts +181 -0
  157. package/src/entity-table/vtable/table-tooltip.ts +70 -0
  158. package/src/entity-table/vtable/vtable-compat.ts +84 -0
  159. package/src/entity-value.utils.ts +456 -0
  160. package/src/index.ts +57 -0
  161. package/src/query-builder/models/config.interface.ts +31 -0
  162. package/src/query-builder/models/index.ts +8 -0
  163. package/src/query-builder/models/operator.interface.ts +191 -0
  164. package/src/query-builder/models/query-builder-state.ts +222 -0
  165. package/src/query-builder/models/validation.interface.ts +184 -0
  166. package/src/query-builder/services/index.ts +9 -0
  167. package/src/query-builder/services/operator-registry.service.ts +243 -0
  168. package/src/query-builder/services/query-builder.service.ts +568 -0
  169. package/src/query-builder/services/query-converter.service.ts +153 -0
  170. package/src/query-builder/services/schema-parser.service.ts +309 -0
  171. package/src/query-builder/services/validation.service.ts +390 -0
  172. package/src/query-builder/utils/date.ts +35 -0
  173. package/src/query-builder/utils/field-tree.ts +400 -0
  174. package/src/query-builder/utils/id.ts +15 -0
  175. package/src/query-builder/utils/index.ts +12 -0
  176. package/src/query-builder/utils/metadata-field-extractor.ts +460 -0
  177. package/src/query-builder/utils/popover-position.ts +59 -0
  178. package/src/query-builder/utils/schema-factory.ts +75 -0
  179. package/src/query-builder/utils/structural-equal.ts +5 -0
  180. package/src/query-builder/utils/value-input.ts +105 -0
  181. package/src/structural-equal.ts +49 -0
@@ -0,0 +1,595 @@
1
+ import type { EditContext, IEditor } from '@visactor/vtable-editors';
2
+ import type { KVSchemaEntry } from '../columns/column-utils.js';
3
+ import { DAISY_COLORS, positionOverlayPanel, refocusTable, scheduleEditorSetup } from './global-overlay-editor.js';
4
+
5
+ type TableLike = { getCellOriginValue(col: number, row: number): unknown } | null;
6
+
7
+ interface SchemaRow {
8
+ keySelect: HTMLInputElement | HTMLSelectElement;
9
+ valueEl: HTMLInputElement | HTMLSelectElement;
10
+ valueContainer: HTMLDivElement;
11
+ hintEl: HTMLDivElement;
12
+ rowEl: HTMLDivElement;
13
+ }
14
+
15
+ /**
16
+ * KeyValue (Record<string, unknown>) editor — Schema mode only
17
+ *
18
+ * - Key is always a dropdown derived from schema definition
19
+ * - Value input type adapts to the key's schema type
20
+ * - Per-row inline error hints; global summary shown at bottom
21
+ * - Ctrl+Enter / confirm button: validate & commit
22
+ * - Escape / cancel button: revert to original value
23
+ */
24
+ export class KeyValueEditor implements IEditor<unknown> {
25
+ #value: Record<string, unknown> = {};
26
+ #panel: HTMLDivElement | null = null;
27
+ #schemaRows: SchemaRow[] = [];
28
+ #rowsContainer: HTMLDivElement | null = null;
29
+ #errorEl: HTMLDivElement | null = null;
30
+ #addBtn: HTMLButtonElement | null = null;
31
+ #endEdit: (() => void) | null = null;
32
+ #abortController: AbortController | null = null;
33
+ #initialValue: Record<string, unknown> = {};
34
+ #schema: Record<string, KVSchemaEntry> = {};
35
+
36
+ /** VTable 编辑器类型标识(注册到 VTable 的编辑器名) */
37
+ readonly editorType = 'key-value-editor';
38
+
39
+ /**
40
+ * 创建 KeyValue 编辑器
41
+ *
42
+ * @param schema 可选的键 Schema 定义;不传或为空时进入自由模式
43
+ */
44
+ constructor(schema?: Record<string, KVSchemaEntry> | null) {
45
+ this.#schema = schema ?? {};
46
+ }
47
+
48
+ /** 打开编辑器:构建键值对编辑浮层并按初始值 / Schema 填充行 */
49
+ onStart(ctx: EditContext<unknown>): void {
50
+ const container = ctx.container as HTMLElement;
51
+ this.#endEdit = () => {
52
+ refocusTable(container);
53
+ ctx.endEdit();
54
+ };
55
+ const origin = (ctx.table as TableLike)?.getCellOriginValue(ctx.col, ctx.row) ?? ctx.value;
56
+
57
+ const initial = this.#parseInitial(origin);
58
+ this.#initialValue = initial;
59
+ this.#value = { ...initial };
60
+ this.#schemaRows = [];
61
+ this.#abortController = new AbortController();
62
+
63
+ const panel = document.createElement('div');
64
+ panel.style.cssText = [
65
+ 'position:fixed',
66
+ 'z-index:9999',
67
+ 'padding:8px',
68
+ 'width:380px',
69
+ `background:${DAISY_COLORS.bg}`,
70
+ `border:1px solid ${DAISY_COLORS.border}`,
71
+ 'border-radius:8px',
72
+ 'box-shadow:0 4px 16px rgba(0,0,0,.18)',
73
+ 'display:flex',
74
+ 'flex-direction:column',
75
+ 'gap:6px'
76
+ ].join(';');
77
+
78
+ // column header
79
+ const header = document.createElement('div');
80
+ header.style.cssText = [
81
+ 'display:grid',
82
+ 'grid-template-columns:1fr 1fr 28px',
83
+ 'gap:4px',
84
+ 'padding:0 2px',
85
+ 'font-size:12px',
86
+ 'font-weight:600',
87
+ 'opacity:0.6',
88
+ `color:${DAISY_COLORS.text}`
89
+ ].join(';');
90
+ for (const text of ['Key', 'Value', '']) {
91
+ const cell = document.createElement('div');
92
+ cell.textContent = text;
93
+ header.appendChild(cell);
94
+ }
95
+
96
+ // rows container
97
+ const rowsContainer = document.createElement('div');
98
+ rowsContainer.style.cssText = [
99
+ 'display:flex',
100
+ 'flex-direction:column',
101
+ 'gap:4px',
102
+ 'max-height:280px',
103
+ 'overflow-y:auto'
104
+ ].join(';');
105
+ this.#rowsContainer = rowsContainer;
106
+
107
+ // global error hint
108
+ const errorEl = document.createElement('div');
109
+ errorEl.style.cssText = `font-size:12px;color:${DAISY_COLORS.error};min-height:16px;word-break:break-all`;
110
+ this.#errorEl = errorEl;
111
+
112
+ // footer
113
+ const footer = document.createElement('div');
114
+ footer.style.cssText = 'display:flex;justify-content:space-between;align-items:center;gap:4px';
115
+
116
+ const addBtn = this.#makeBtn('+ \u6dfb\u52a0\u884c', false);
117
+ this.#addBtn = addBtn;
118
+ addBtn.addEventListener('mousedown', e => {
119
+ e.preventDefault();
120
+ e.stopPropagation();
121
+ this.#appendSchemaRow();
122
+ this.#refreshAllKeySelects();
123
+ this.#updateAddBtn();
124
+ });
125
+
126
+ const btnGroup = document.createElement('div');
127
+ btnGroup.style.cssText = 'display:flex;gap:4px';
128
+
129
+ const confirmBtn = this.#makeBtn('\u2713 \u786e\u8ba4', true);
130
+ confirmBtn.addEventListener('mousedown', e => {
131
+ e.preventDefault();
132
+ e.stopPropagation();
133
+ if (this.#commit()) this.#endEdit?.();
134
+ });
135
+
136
+ const cancelBtn = this.#makeBtn('\u2717 \u53d6\u6d88', false);
137
+ cancelBtn.addEventListener('mousedown', e => {
138
+ e.preventDefault();
139
+ e.stopPropagation();
140
+ this.#value = this.#initialValue;
141
+ this.#endEdit?.();
142
+ });
143
+
144
+ btnGroup.appendChild(confirmBtn);
145
+ btnGroup.appendChild(cancelBtn);
146
+ footer.appendChild(addBtn);
147
+ footer.appendChild(btnGroup);
148
+
149
+ panel.appendChild(header);
150
+ panel.appendChild(rowsContainer);
151
+ panel.appendChild(errorEl);
152
+ panel.appendChild(footer);
153
+ document.body.appendChild(panel);
154
+ this.#panel = panel;
155
+
156
+ // fill rows: schema mode uses schema key order; free-form mode uses initial data keys
157
+ const schemaKeys = Object.keys(this.#schema);
158
+ if (schemaKeys.length > 0) {
159
+ for (const k of schemaKeys) {
160
+ if (k in initial) {
161
+ const rawVal = initial[k];
162
+ const strVal = rawVal == null ? '' : String(rawVal);
163
+ this.#appendSchemaRow(k, strVal);
164
+ }
165
+ }
166
+ } else {
167
+ for (const [k, v] of Object.entries(initial)) {
168
+ this.#appendSchemaRow(k, v == null ? '' : String(v));
169
+ }
170
+ }
171
+ if (this.#schemaRows.length === 0) this.#appendSchemaRow();
172
+ this.#refreshAllKeySelects();
173
+ this.#updateAddBtn();
174
+
175
+ this.#positionPanel(panel, ctx.referencePosition.rect, ctx.container as HTMLElement, 340);
176
+
177
+ panel.addEventListener('keydown', e => {
178
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
179
+ e.preventDefault();
180
+ e.stopPropagation();
181
+ if (this.#commit()) this.#endEdit?.();
182
+ } else if (e.key === 'Escape') {
183
+ e.preventDefault();
184
+ e.stopPropagation();
185
+ this.#value = this.#initialValue;
186
+ this.#endEdit?.();
187
+ }
188
+ });
189
+
190
+ const outsideHandler = (e: MouseEvent) => {
191
+ if (!panel.contains(e.target as Node)) {
192
+ this.#commit();
193
+ this.#cleanup();
194
+ this.#endEdit?.();
195
+ }
196
+ };
197
+ scheduleEditorSetup(() => {
198
+ if (!this.#abortController) return;
199
+ document.addEventListener('mousedown', outsideHandler, { capture: true, signal: this.#abortController.signal });
200
+ this.#schemaRows[0]?.keySelect.focus();
201
+ });
202
+ }
203
+
204
+ /** 返回当前编辑的键值对象 */
205
+ getValue(): Record<string, unknown> {
206
+ return this.#value;
207
+ }
208
+
209
+ /** 结束编辑:中止未决监听并清理浮层 */
210
+ onEnd(): void {
211
+ this.#cleanup();
212
+ }
213
+
214
+ /** 判断目标元素是否属于编辑器浮层 */
215
+ isEditorElement(el: HTMLElement | EventTarget): boolean {
216
+ return !!this.#panel?.contains(el as Node);
217
+ }
218
+
219
+ // ── private ───────────────────────────────────────────────────────────────
220
+
221
+ #parseInitial(origin: unknown): Record<string, unknown> {
222
+ if (origin == null || origin === '') return {};
223
+ if (typeof origin === 'object' && !Array.isArray(origin)) {
224
+ return { ...(origin as Record<string, unknown>) };
225
+ }
226
+ if (typeof origin === 'string') {
227
+ try {
228
+ const parsed = JSON.parse(origin) as unknown;
229
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
230
+ return { ...(parsed as Record<string, unknown>) };
231
+ }
232
+ } catch {
233
+ /* ignore */
234
+ }
235
+ }
236
+ return {};
237
+ }
238
+
239
+ #makeBtn(text: string, primary: boolean): HTMLButtonElement {
240
+ const btn = document.createElement('button');
241
+ btn.type = 'button';
242
+ btn.textContent = text;
243
+ btn.style.cssText = [
244
+ 'font-size:12px',
245
+ `color:${DAISY_COLORS.text}`,
246
+ `background:${primary ? DAISY_COLORS.selectedBg : 'none'}`,
247
+ `border:1px solid ${DAISY_COLORS.border}`,
248
+ 'border-radius:4px',
249
+ 'cursor:pointer',
250
+ 'padding:2px 10px',
251
+ 'white-space:nowrap'
252
+ ].join(';');
253
+ return btn;
254
+ }
255
+
256
+ #appendSchemaRow(key?: string, value?: string): void {
257
+ const container = this.#rowsContainer;
258
+ if (!container) return;
259
+
260
+ const schema = this.#schema;
261
+ const allKeys = Object.keys(schema);
262
+ const isFreeForm = allKeys.length === 0;
263
+
264
+ const usedKeys = this.#getUsedSchemaKeys();
265
+ const initialKey = key ?? (isFreeForm ? '' : (allKeys.find(k => !usedKeys.has(k)) ?? allKeys[0] ?? ''));
266
+
267
+ const fieldStyle = [
268
+ 'min-width:0',
269
+ 'width:100%',
270
+ 'box-sizing:border-box',
271
+ `background:${DAISY_COLORS.bg}`,
272
+ `color:${DAISY_COLORS.text}`,
273
+ `border:1px solid ${DAISY_COLORS.border}`,
274
+ 'border-radius:4px',
275
+ 'padding:3px 6px',
276
+ 'font-size:12px',
277
+ 'outline:none'
278
+ ].join(';');
279
+
280
+ const rowEl = document.createElement('div');
281
+ rowEl.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 28px;gap:4px;align-items:start';
282
+
283
+ // key element: text input (free-form) or dropdown (schema)
284
+ let keySelect: HTMLInputElement | HTMLSelectElement;
285
+ if (isFreeForm) {
286
+ const inp = document.createElement('input');
287
+ inp.type = 'text';
288
+ inp.value = initialKey;
289
+ inp.placeholder = 'Key';
290
+ inp.style.cssText = fieldStyle;
291
+ keySelect = inp;
292
+ } else {
293
+ const sel = document.createElement('select');
294
+ sel.style.cssText = fieldStyle;
295
+ for (const [k, entry] of Object.entries(schema)) {
296
+ const opt = document.createElement('option');
297
+ opt.value = k;
298
+ opt.textContent = entry.label ?? k;
299
+ if (k === initialKey) opt.selected = true;
300
+ sel.appendChild(opt);
301
+ }
302
+ keySelect = sel;
303
+ }
304
+
305
+ // value container (value input + hint below)
306
+ const valueContainer = document.createElement('div');
307
+ valueContainer.style.cssText = 'min-width:0;display:flex;flex-direction:column;gap:2px';
308
+
309
+ const initEntry = isFreeForm ? undefined : schema[initialKey];
310
+ const nullable = initEntry?.nullable ?? true;
311
+ const initValEl = this.#buildValueEl(initEntry?.type, value ?? '', nullable);
312
+ initValEl.style.cssText = fieldStyle;
313
+
314
+ const hintEl = document.createElement('div');
315
+ hintEl.style.cssText = `font-size:11px;color:${DAISY_COLORS.error};min-height:0;display:none;word-break:break-all`;
316
+
317
+ valueContainer.appendChild(initValEl);
318
+ valueContainer.appendChild(hintEl);
319
+
320
+ const delBtn = document.createElement('button');
321
+ delBtn.type = 'button';
322
+ delBtn.textContent = '\u00d7';
323
+ delBtn.title = '\u5220\u9664\u884c';
324
+ delBtn.style.cssText = [
325
+ 'width:22px',
326
+ 'height:22px',
327
+ 'flex-shrink:0',
328
+ 'margin-top:3px',
329
+ 'background:none',
330
+ `border:1px solid ${DAISY_COLORS.border}`,
331
+ 'border-radius:4px',
332
+ 'cursor:pointer',
333
+ 'font-size:14px',
334
+ 'line-height:1',
335
+ 'padding:0',
336
+ `color:${DAISY_COLORS.text}`,
337
+ 'display:flex',
338
+ 'align-items:center',
339
+ 'justify-content:center'
340
+ ].join(';');
341
+
342
+ const row: SchemaRow = { keySelect, valueEl: initValEl, valueContainer, hintEl, rowEl };
343
+ this.#schemaRows.push(row);
344
+
345
+ const clearValidationState = () => {
346
+ this.#clearRowHint(row);
347
+ this.#clearGlobalError();
348
+ };
349
+
350
+ keySelect.addEventListener('change', () => {
351
+ if (isFreeForm) return;
352
+ const newKey = keySelect.value;
353
+ const entry = schema[newKey];
354
+ const nbl = entry?.nullable ?? true;
355
+ const newValEl = this.#buildValueEl(entry?.type, '', nbl);
356
+ newValEl.style.cssText = fieldStyle;
357
+ valueContainer.replaceChild(newValEl, row.valueEl);
358
+ row.valueEl = newValEl;
359
+ clearValidationState();
360
+ this.#refreshAllKeySelects();
361
+ });
362
+
363
+ if (keySelect instanceof HTMLInputElement) {
364
+ keySelect.addEventListener('input', clearValidationState);
365
+ }
366
+
367
+ initValEl.addEventListener('input', () => {
368
+ clearValidationState();
369
+ });
370
+
371
+ delBtn.addEventListener('mousedown', e => {
372
+ e.preventDefault();
373
+ e.stopPropagation();
374
+ const idx = this.#schemaRows.indexOf(row);
375
+ if (idx >= 0) {
376
+ this.#schemaRows.splice(idx, 1);
377
+ rowEl.remove();
378
+ this.#refreshAllKeySelects();
379
+ this.#clearGlobalError();
380
+ this.#updateAddBtn();
381
+ }
382
+ });
383
+
384
+ rowEl.appendChild(keySelect);
385
+ rowEl.appendChild(valueContainer);
386
+ rowEl.appendChild(delBtn);
387
+ container.appendChild(rowEl);
388
+ container.scrollTop = container.scrollHeight;
389
+ }
390
+
391
+ #buildValueEl(type?: string, value?: string, nullable?: boolean): HTMLInputElement | HTMLSelectElement {
392
+ const v = value ?? '';
393
+ if (type === 'boolean') {
394
+ const sel = document.createElement('select');
395
+ if (nullable) {
396
+ const opt = document.createElement('option');
397
+ opt.value = '';
398
+ opt.textContent = '(\u672a\u8bbe\u7f6e)';
399
+ if (v === '') opt.selected = true;
400
+ sel.appendChild(opt);
401
+ }
402
+ for (const [val, label] of [
403
+ ['true', '\u662f (true)'],
404
+ ['false', '\u5426 (false)']
405
+ ] as const) {
406
+ const opt = document.createElement('option');
407
+ opt.value = val;
408
+ opt.textContent = label;
409
+ if (val === v) opt.selected = true;
410
+ sel.appendChild(opt);
411
+ }
412
+ return sel;
413
+ }
414
+
415
+ const inp = document.createElement('input');
416
+ if (type === 'date') {
417
+ inp.type = 'datetime-local';
418
+ if (v) {
419
+ try {
420
+ const d = new Date(v);
421
+ if (!isNaN(d.getTime())) {
422
+ inp.value = new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
423
+ } else {
424
+ inp.value = v;
425
+ }
426
+ } catch {
427
+ inp.value = v;
428
+ }
429
+ }
430
+ } else {
431
+ inp.type = type === 'number' || type === 'integer' ? 'number' : 'text';
432
+ if (type === 'integer') inp.step = '1';
433
+ inp.value = v;
434
+ inp.placeholder =
435
+ type === 'number' ? '\u5982 3.14\u3001-2'
436
+ : type === 'integer' ? '\u5982 42\u3001-7'
437
+ : '\u8bf7\u8f93\u5165\u503c';
438
+ }
439
+ return inp;
440
+ }
441
+
442
+ #validateRow(row: SchemaRow): string {
443
+ const key = row.keySelect.value;
444
+ const entry = this.#schema[key];
445
+ const raw = row.valueEl.value.trim();
446
+ const label = entry?.label ?? key;
447
+
448
+ if (entry?.required && raw === '') return `\u300c${label}\u300d\u4e3a\u5fc5\u586b\u9879`;
449
+ if (raw === '') return '';
450
+
451
+ const type = entry?.type;
452
+ if (type === 'number' && isNaN(Number(raw)))
453
+ return `\u300c${label}\u300d\u8bf7\u8f93\u5165\u6709\u6548\u6570\u5b57\uff08\u5982 3.14\u3001-2\uff09`;
454
+ if (type === 'integer' && !/^-?\d+$/.test(raw))
455
+ return `\u300c${label}\u300d\u8bf7\u8f93\u5165\u6574\u6570\uff08\u5982 42\u3001-7\uff09`;
456
+ if (type === 'date' && isNaN(new Date(row.valueEl.value).getTime()))
457
+ return `\u300c${label}\u300d\u8bf7\u8f93\u5165\u6709\u6548\u65e5\u671f`;
458
+ return '';
459
+ }
460
+
461
+ #getUsedSchemaKeys(): Set<string> {
462
+ const used = new Set<string>();
463
+ for (const r of this.#schemaRows) {
464
+ if (r.keySelect.value) used.add(r.keySelect.value);
465
+ }
466
+ return used;
467
+ }
468
+
469
+ #getDuplicateKeys(): Set<string> {
470
+ const counts = new Map<string, number>();
471
+ for (const row of this.#schemaRows) {
472
+ const key = row.keySelect.value;
473
+ if (!key) continue;
474
+ counts.set(key, (counts.get(key) ?? 0) + 1);
475
+ }
476
+
477
+ const duplicates = new Set<string>();
478
+ counts.forEach((count, key) => {
479
+ if (count > 1) duplicates.add(key);
480
+ });
481
+ return duplicates;
482
+ }
483
+
484
+ #refreshAllKeySelects(): void {
485
+ for (let i = 0; i < this.#schemaRows.length; i++) {
486
+ const row = this.#schemaRows[i];
487
+ if (!(row.keySelect instanceof HTMLSelectElement)) continue;
488
+ const currentKey = row.keySelect.value;
489
+ const others = new Set<string>();
490
+ for (let j = 0; j < this.#schemaRows.length; j++) {
491
+ if (j !== i) others.add(this.#schemaRows[j].keySelect.value);
492
+ }
493
+ for (const opt of Array.from(row.keySelect.options)) {
494
+ opt.disabled = opt.value !== currentKey && others.has(opt.value);
495
+ }
496
+ }
497
+ }
498
+
499
+ #updateAddBtn(): void {
500
+ if (!this.#addBtn) return;
501
+ const allKeys = Object.keys(this.#schema);
502
+ if (allKeys.length === 0) {
503
+ this.#addBtn.disabled = false;
504
+ this.#addBtn.style.opacity = '1';
505
+ this.#addBtn.style.cursor = 'pointer';
506
+ return;
507
+ }
508
+ const allUsed = this.#getUsedSchemaKeys().size >= allKeys.length;
509
+ this.#addBtn.disabled = allUsed;
510
+ this.#addBtn.style.opacity = allUsed ? '0.4' : '1';
511
+ this.#addBtn.style.cursor = allUsed ? 'not-allowed' : 'pointer';
512
+ }
513
+
514
+ #clearRowHint(row: SchemaRow): void {
515
+ row.hintEl.style.display = 'none';
516
+ row.hintEl.textContent = '';
517
+ row.valueEl.style.borderColor = DAISY_COLORS.border;
518
+ }
519
+
520
+ #clearGlobalError(): void {
521
+ if (this.#errorEl) this.#errorEl.textContent = '';
522
+ }
523
+
524
+ #validate(): boolean {
525
+ const errors: string[] = [];
526
+ const duplicateKeys = this.#getDuplicateKeys();
527
+ for (const row of this.#schemaRows) {
528
+ const key = row.keySelect.value;
529
+ const msg = key && duplicateKeys.has(key) ? `键「${key}」重复` : this.#validateRow(row);
530
+ if (msg) {
531
+ errors.push(msg);
532
+ row.hintEl.textContent = msg;
533
+ row.hintEl.style.display = 'block';
534
+ row.valueEl.style.borderColor = DAISY_COLORS.error;
535
+ } else {
536
+ this.#clearRowHint(row);
537
+ }
538
+ }
539
+ if (errors.length > 0 && this.#errorEl) {
540
+ const extra = errors.length - 1;
541
+ this.#errorEl.textContent =
542
+ extra > 0 ? `\u26a0 ${errors[0]}\uff08\u53ca\u5176\u4ed6 ${extra} \u5904\uff09` : `\u26a0 ${errors[0]}`;
543
+ return false;
544
+ }
545
+ this.#clearGlobalError();
546
+ return true;
547
+ }
548
+
549
+ #commit(): boolean {
550
+ if (!this.#validate()) return false;
551
+ const result: Record<string, unknown> = {};
552
+ for (const row of this.#schemaRows) {
553
+ const k = row.keySelect.value;
554
+ if (!k) continue;
555
+ const entry = this.#schema[k];
556
+ const raw = row.valueEl.value;
557
+ if (entry?.type === 'boolean') {
558
+ result[k] = raw === '' ? null : raw === 'true';
559
+ } else if (entry?.type === 'number') {
560
+ result[k] = raw !== '' ? Number(raw) : null;
561
+ } else if (entry?.type === 'integer') {
562
+ result[k] = raw !== '' ? parseInt(raw, 10) : null;
563
+ } else if (entry?.type === 'date') {
564
+ result[k] = raw !== '' ? new Date(raw).toISOString() : null;
565
+ } else {
566
+ result[k] =
567
+ raw !== '' ? raw
568
+ : entry?.nullable !== false ? null
569
+ : '';
570
+ }
571
+ }
572
+ this.#value = result;
573
+ return true;
574
+ }
575
+
576
+ #positionPanel(
577
+ panel: HTMLElement,
578
+ rect: { left: number; top: number; width?: number; height?: number },
579
+ container: HTMLElement,
580
+ estimatedHeight: number
581
+ ): void {
582
+ positionOverlayPanel(panel, rect, container, estimatedHeight, { maxRight: 400 });
583
+ }
584
+
585
+ #cleanup(): void {
586
+ this.#abortController?.abort();
587
+ this.#abortController = null;
588
+ this.#panel?.remove();
589
+ this.#panel = null;
590
+ this.#rowsContainer = null;
591
+ this.#errorEl = null;
592
+ this.#addBtn = null;
593
+ this.#schemaRows = [];
594
+ }
595
+ }
@@ -0,0 +1,78 @@
1
+ /** Lucide 图标数据 — [tagName, attributes] 元组数组 */
2
+ export type IconData = readonly (readonly [string, Record<string, unknown>])[];
3
+
4
+ /** 将 camelCase 属性名转为 kebab-case */
5
+ function camelToKebab(s: string): string {
6
+ return s.replace(/([A-Z])/g, m => `-${m.toLowerCase()}`);
7
+ }
8
+
9
+ const SAFE_COLOR = /^[#\w(),.\-\s/%]+$/;
10
+
11
+ const SAFE_SVG_TAGS = new Set([
12
+ 'circle',
13
+ 'ellipse',
14
+ 'line',
15
+ 'path',
16
+ 'polygon',
17
+ 'polyline',
18
+ 'rect',
19
+ 'g',
20
+ 'defs',
21
+ 'clipPath',
22
+ 'mask',
23
+ 'use',
24
+ 'text',
25
+ 'tspan',
26
+ 'linearGradient',
27
+ 'radialGradient',
28
+ 'stop',
29
+ 'filter',
30
+ 'feBlend',
31
+ 'feColorMatrix',
32
+ 'feFlood',
33
+ 'feGaussianBlur',
34
+ 'feOffset'
35
+ ]);
36
+
37
+ const SVG_NS = 'http://www.w3.org/2000/svg' as const;
38
+
39
+ /**
40
+ * 校验图标数据中的属性名:只接受普通 SVG 呈现属性,
41
+ * 拒绝事件处理(on*)、URL(href)与样式属性,以及含注入字符的非法名称。
42
+ */
43
+ function isSafeAttrName(name: string): boolean {
44
+ return /^[a-zA-Z][a-zA-Z0-9-]*$/.test(name) && !/^on/i.test(name) && name !== 'href' && name !== 'style';
45
+ }
46
+
47
+ /**
48
+ * 将 `LucideIconData` 转为内联 SVG 元素(DOM API 构造,免疫标记注入)
49
+ *
50
+ * @param icon - lucide 图标数据
51
+ * @param color - stroke 颜色(不合法时回退 currentColor)
52
+ * @param size - 宽高像素,默认 13
53
+ * @returns 内联 SVG 元素
54
+ */
55
+ export function lucideToSvgElement(icon: IconData, color: string, size = 13): SVGSVGElement {
56
+ const svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement;
57
+ svg.setAttribute('viewBox', '0 0 24 24');
58
+ svg.setAttribute('width', String(size));
59
+ svg.setAttribute('height', String(size));
60
+ svg.setAttribute('fill', 'none');
61
+ svg.setAttribute('stroke', SAFE_COLOR.test(color) ? color : 'currentColor');
62
+ svg.setAttribute('stroke-width', '2');
63
+ svg.setAttribute('stroke-linecap', 'round');
64
+ svg.setAttribute('stroke-linejoin', 'round');
65
+ svg.setAttribute('style', 'flex-shrink:0;display:block');
66
+ for (const [tag, attrs] of icon) {
67
+ if (!SAFE_SVG_TAGS.has(tag)) continue;
68
+ const child = document.createElementNS(SVG_NS, tag);
69
+ for (const [k, v] of Object.entries(attrs)) {
70
+ if (k === 'key') continue;
71
+ const name = camelToKebab(k);
72
+ if (!isSafeAttrName(name)) continue;
73
+ child.setAttribute(name, String(v));
74
+ }
75
+ svg.appendChild(child);
76
+ }
77
+ return svg;
78
+ }