@svadmin/ui 0.93.0 → 0.95.0

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.
@@ -23,27 +23,51 @@
23
23
  fields?: FieldDefinition[];
24
24
  filters?: Filter[];
25
25
  logicalOperator?: 'and' | 'or';
26
+ /** 宿主服务端只支持部分操作符时,限制编辑器可选项;默认沿用字段全量操作符。 */
27
+ allowedOperators?: readonly CrudOperator[];
28
+ /** 宿主只支持单一组合逻辑时(如扁平 AND 查询),限制逻辑选择;默认 AND/OR 都可用。 */
29
+ allowedLogicalOperators?: readonly ('and' | 'or')[];
30
+ /** 宿主不支持嵌套条件组时关闭新增入口;已保存的嵌套组仍会按白名单校验。 */
31
+ allowGroups?: boolean;
26
32
  disabled?: boolean;
27
33
  class?: string;
28
34
  onApply?: (filters: Filter[]) => void;
29
35
  onReset?: () => void;
30
36
  onInvalid?: (issues: FilterEditorIssue[]) => void;
31
37
  }
32
- let { fields = [], filters = $bindable([]), logicalOperator = $bindable('and'), disabled = false,
38
+ let { fields = [], filters = $bindable([]), logicalOperator = $bindable('and'), allowedOperators = undefined,
39
+ allowedLogicalOperators = ['and', 'or'], allowGroups = true, disabled = false,
33
40
  class: className, onApply, onReset, onInvalid }: Props = $props();
34
41
  const uid = $props.id();
35
42
  const i18n = useTranslation();
36
43
  const chinese = $derived(i18n.locale.startsWith('zh'));
37
44
  const availableFields = $derived(fields.filter((field) => field.filterable !== false));
45
+ const logicalOperators = $derived<readonly ('and' | 'or')[]>(
46
+ allowedLogicalOperators.length > 0 ? allowedLogicalOperators : ['and'],
47
+ );
48
+ const activeLogicalOperator = $derived<'and' | 'or'>(
49
+ logicalOperators.includes(logicalOperator) ? logicalOperator : (logicalOperators[0] ?? 'and'),
50
+ );
38
51
  // 金额、评分等数字字段沿用 core 的输入语义;树编译器统一按 number 验证。
39
52
  const editorFields = $derived(fields.map(field => isNumericField(field) ? { ...field, type: 'number' as const } : field));
53
+ function operatorsFor(field: FieldDefinition | undefined): CrudOperator[] {
54
+ const operators = filterOperators(field);
55
+ if (!allowedOperators) return operators;
56
+ const allowed = new Set(allowedOperators);
57
+ return operators.filter((operator) => allowed.has(operator));
58
+ }
59
+ function defaultOperator(field: FieldDefinition | undefined): CrudOperator | undefined {
60
+ const operators = operatorsFor(field);
61
+ if (operators.includes('contains')) return 'contains';
62
+ return operators[0];
63
+ }
40
64
  let root = $state<FilterGroupNode>({ kind: 'group', id: 'root', operator: 'and', children: [], wrapped: false });
41
65
  let loadIssues = $state<FilterEditorIssue[]>([]);
42
66
  let attempted = $state(false);
43
67
  let nextId = 0;
44
68
  let lastInputFilters: Filter[] | undefined;
45
69
  let preserved = $state.raw<ReadonlyMap<string, Filter>>(new Map());
46
- const compilation = $derived(compileFilterTree({ ...root, operator: logicalOperator }, editorFields, preserved));
70
+ const compilation = $derived(compileFilterTree({ ...root, operator: activeLogicalOperator }, editorFields, preserved));
47
71
  const issues = $derived(loadIssues.length ? loadIssues : attempted && !compilation.ok ? compilation.issues : []);
48
72
  function countNodes(node: FilterNode): number { return 1 + (node.kind === 'group' ? node.children.reduce((sum, child) => sum + countNodes(child), 0) : 0); }
49
73
  const nodeCount = $derived(countNodes(root));
@@ -54,6 +78,19 @@
54
78
  : { field: node.field, operator: node.operator, value: Array.isArray(node.value) ? [...node.value] : node.value };
55
79
  }
56
80
 
81
+ function unsupportedLogic(node: FilterNode, top = false): FilterIssueCode | undefined {
82
+ if (node.kind === 'rule') return undefined;
83
+ if (!top && !allowGroups) return 'invalid-filter';
84
+ // 顶层扁平 filters 的 AND 是查询语义,不是用户选择的组合逻辑。
85
+ if (!(top && !node.wrapped) && !logicalOperators.includes(node.operator)) return 'logic';
86
+ for (const child of node.children) {
87
+ if (child.kind !== 'group') continue;
88
+ const issue = unsupportedLogic(child);
89
+ if (issue) return issue;
90
+ }
91
+ return undefined;
92
+ }
93
+
57
94
  function cannotEdit(node: FilterRuleNode): boolean {
58
95
  return !compileFilterTree({ kind: 'group', id: 'probe', operator: 'and', children: [node] }, editorFields).ok;
59
96
  }
@@ -71,12 +108,17 @@
71
108
  lastInputFilters = input;
72
109
  const parsed = readFilterTree(input);
73
110
  const saved = new Map<string, Filter>();
74
- if (parsed.ok) {
111
+ const logicIssue = parsed.ok ? unsupportedLogic(parsed.root, true) : undefined;
112
+ if (parsed.ok && !logicIssue) {
75
113
  root = parsed.root;
76
114
  remember(root, saved);
77
115
  loadIssues = [];
78
- if (input.length) logicalOperator = root.operator;
79
- } else {
116
+ if (input.length) logicalOperator = logicalOperators.includes(root.operator)
117
+ ? root.operator : (logicalOperators[0] ?? 'and');
118
+ } else if (parsed.ok && logicIssue) {
119
+ root = { kind: 'group', id: 'root', operator: 'and', children: [], wrapped: false };
120
+ loadIssues = [{ path: 'root', code: logicIssue }];
121
+ } else if (!parsed.ok) {
80
122
  root = { kind: 'group', id: 'root', operator: 'and', children: [], wrapped: false };
81
123
  loadIssues = parsed.issues;
82
124
  }
@@ -99,6 +141,7 @@
99
141
  limit: ['条件数量或深度超过限制', 'Filter count or depth exceeds the limit'],
100
142
  'unknown-field': ['字段不可用于筛选', 'Field is unavailable for filtering'],
101
143
  operator: ['操作符不适用于此字段', 'Operator is not supported for this field'],
144
+ logic: ['组合逻辑不受支持,原条件未被修改', 'Unsupported group logic; the original query is unchanged'],
102
145
  value: ['请输入有效的类型化值', 'Enter a valid typed value'],
103
146
  'empty-group': ['条件组不能为空', 'A filter group cannot be empty'],
104
147
  };
@@ -108,12 +151,14 @@
108
151
  if (disabled || loadIssues.length || full) return;
109
152
  const field = availableFields[0];
110
153
  if (!field) return;
154
+ const operator = defaultOperator(editorFields.find(item => item.key === field.key));
155
+ if (!operator) return;
111
156
  group.children = [...group.children, { kind: 'rule', id: `draft-${++nextId}`, field: field.key,
112
- operator: filterOperators(editorFields.find(item => item.key === field.key)).includes('contains') ? 'contains' : 'eq', value: undefined }];
157
+ operator, value: undefined }];
113
158
  }
114
159
 
115
160
  function appendGroup(group: FilterGroupNode): void {
116
- if (disabled || loadIssues.length || full) return;
161
+ if (!allowGroups || disabled || loadIssues.length || full) return;
117
162
  group.children = [...group.children, { kind: 'group', id: `draft-${++nextId}`, operator: 'and', children: [] }];
118
163
  }
119
164
 
@@ -148,8 +193,10 @@
148
193
 
149
194
  function changeField(rule: FilterRuleNode, key: string): void {
150
195
  if (disabled || rule.readonly) return;
196
+ const operator = defaultOperator(editorFields.find(field => field.key === key));
197
+ if (!operator) return;
151
198
  rule.field = key;
152
- rule.operator = filterOperators(editorFields.find(field => field.key === key)).includes('contains') ? 'contains' : 'eq';
199
+ rule.operator = operator;
153
200
  rule.value = undefined;
154
201
  }
155
202
 
@@ -179,21 +226,25 @@
179
226
  <div class="group-actions">
180
227
  <label for={`${uid}-${group.id}-logic`}>{chinese ? '逻辑' : 'Logic'}</label>
181
228
  <Select id={`${uid}-${group.id}-logic`} aria-label={chinese ? '组合逻辑' : 'Group logic'}
182
- value={top ? logicalOperator : group.operator}
229
+ value={top ? activeLogicalOperator : group.operator}
230
+ disabled={disabled || logicalOperators.length <= 1}
183
231
  onchange={(event: Event) => {
184
232
  const target = event.currentTarget;
185
233
  if (disabled || !(target instanceof HTMLSelectElement)) return;
186
234
  const next = target.value === 'or' ? 'or' : 'and';
235
+ if (!logicalOperators.includes(next)) return;
187
236
  if (top) logicalOperator = next; else group.operator = next;
188
237
  }}>
189
- <option value="and">AND</option><option value="or">OR</option>
238
+ {#each logicalOperators as operator (operator)}<option value={operator}>{operator.toUpperCase()}</option>{/each}
190
239
  </Select>
191
240
  <Button type="button" size="sm" variant="outline" disabled={full || depth >= FILTER_EDITOR_LIMITS.depth || !availableFields.length}
192
241
  data-testid={top ? 'filter-builder-add-rule' : 'filter-group-add-rule'} onclick={() => appendRule(group)}>
193
242
  <Plus size={14} aria-hidden="true" />{i18n.t('common.addRule')}
194
243
  </Button>
195
- <Button type="button" size="sm" variant="ghost" disabled={full || depth >= FILTER_EDITOR_LIMITS.depth - 1}
196
- data-testid="filter-builder-add-group" onclick={() => appendGroup(group)}>{chinese ? '添加条件组' : 'Add group'}</Button>
244
+ {#if allowGroups}
245
+ <Button type="button" size="sm" variant="ghost" disabled={full || depth >= FILTER_EDITOR_LIMITS.depth - 1}
246
+ data-testid="filter-builder-add-group" onclick={() => appendGroup(group)}>{chinese ? '添加条件组' : 'Add group'}</Button>
247
+ {/if}
197
248
  </div>
198
249
  {#each group.children as node (node.id)}
199
250
  {#if node.kind === 'group'}
@@ -203,7 +254,7 @@
203
254
  </div>
204
255
  {:else}
205
256
  {@const field = availableFields.find((item) => item.key === node.field)}
206
- {@const allowed = filterOperators(editorFields.find(item => item.key === node.field))}
257
+ {@const allowed = operatorsFor(editorFields.find(item => item.key === node.field))}
207
258
  {@const collection = isCollectionOperator(node.operator)}
208
259
  {@const invalid = issues.some((issue) => issue.path === node.id)}
209
260
  {@const describedby = issues.flatMap((issue, index) => issue.path === node.id ? [`${uid}-issue-${index}`] : []).join(' ') || undefined}
@@ -260,6 +311,10 @@
260
311
  {:else if !collection && isNumericField(field)}
261
312
  <Input id={`${uid}-${node.id}-value`} type="number" step="any" aria-invalid={invalid} aria-describedby={describedby} value={typeof node.value === 'number' ? node.value : undefined}
262
313
  oninput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
314
+ {:else if !collection && (field?.type === 'date' || field?.type === 'time' || field?.type === 'datetime')}
315
+ <Input id={`${uid}-${node.id}-value`} type={field.type === 'datetime' ? 'datetime-local' : field.type}
316
+ aria-invalid={invalid} aria-describedby={describedby} value={valueText(node.value)}
317
+ oninput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
263
318
  {:else}
264
319
  <Input id={`${uid}-${node.id}-value`} type="text" aria-invalid={invalid} aria-describedby={describedby} value={valueText(node.value)} placeholder={collection ? '[1, 2]' : ''}
265
320
  oninput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
@@ -10,6 +10,12 @@ interface Props {
10
10
  fields?: FieldDefinition[];
11
11
  filters?: Filter[];
12
12
  logicalOperator?: 'and' | 'or';
13
+ /** 宿主服务端只支持部分操作符时,限制编辑器可选项;默认沿用字段全量操作符。 */
14
+ allowedOperators?: readonly CrudOperator[];
15
+ /** 宿主只支持单一组合逻辑时(如扁平 AND 查询),限制逻辑选择;默认 AND/OR 都可用。 */
16
+ allowedLogicalOperators?: readonly ('and' | 'or')[];
17
+ /** 宿主不支持嵌套条件组时关闭新增入口;已保存的嵌套组仍会按白名单校验。 */
18
+ allowGroups?: boolean;
13
19
  disabled?: boolean;
14
20
  class?: string;
15
21
  onApply?: (filters: Filter[]) => void;
@@ -13,8 +13,10 @@
13
13
  icon?: Component<{ class?: string }>;
14
14
  href?: string;
15
15
  onclick?: (e: MouseEvent) => void;
16
- variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
16
+ variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary' | 'primary';
17
17
  disabled?: boolean;
18
+ /** 受控禁用原因;禁用项通过 tooltip/标题与菜单内说明呈现,不改变命令语义。 */
19
+ disabledReason?: string;
18
20
  title?: string;
19
21
  danger?: boolean;
20
22
  hidden?: boolean;
@@ -56,8 +58,14 @@
56
58
  let initialMenuFocus: 'first' | 'last' = 'first';
57
59
  let restoreFocusOnClose = false;
58
60
 
59
- function resolvedVariant(action: RowActionItem): RowActionItem['variant'] {
60
- return action.variant ?? (action.danger ? 'destructive' : 'ghost');
61
+ function resolvedVariant(action: RowActionItem): Exclude<NonNullable<RowActionItem['variant']>, 'primary'> {
62
+ if (action.variant === 'primary') return 'default';
63
+ return (action.variant ?? (action.danger ? 'destructive' : 'ghost')) as Exclude<NonNullable<RowActionItem['variant']>, 'primary'>;
64
+ }
65
+
66
+ function actionTitle(action: RowActionItem): string {
67
+ const base = action.title || action.label;
68
+ return action.disabled && action.disabledReason ? `${base}:${action.disabledReason}` : base;
61
69
  }
62
70
 
63
71
  function handleOverflowAction(action: RowActionItem, event: MouseEvent): void {
@@ -149,7 +157,8 @@
149
157
  size="sm"
150
158
  href={action.href}
151
159
  disabled={action.disabled}
152
- title={action.title || action.label}
160
+ disabledReason={action.disabled ? (action.disabledReason ?? '') : ''}
161
+ title={actionTitle(action)}
153
162
  class="svadmin-u-ed8a5df7b2fb svadmin-u-d5eab218aa34 svadmin-u-359090c2d529"
154
163
  >
155
164
  {#if action.icon}
@@ -164,7 +173,8 @@
164
173
  size="sm"
165
174
  onclick={action.onclick}
166
175
  disabled={action.disabled}
167
- title={action.title || action.label}
176
+ disabledReason={action.disabled ? (action.disabledReason ?? '') : ''}
177
+ title={actionTitle(action)}
168
178
  class="svadmin-u-ed8a5df7b2fb svadmin-u-d5eab218aa34 svadmin-u-359090c2d529"
169
179
  >
170
180
  {#if action.icon}
@@ -193,9 +203,11 @@
193
203
  {...definedOptions({ "variant": resolvedVariant(action) })}
194
204
  size="sm"
195
205
  disabled={action.disabled}
206
+ disabledReason={action.disabled ? (action.disabledReason ?? '') : ''}
196
207
  onclick={(event) => handleOverflowAction(action, event)}
197
208
  role="menuitem"
198
209
  tabindex={-1}
210
+ title={actionTitle(action)}
199
211
  class="svadmin-u-b8f0a08ece1e svadmin-u-6da6a3c3f741 svadmin-u-4b5cc19bfdfc svadmin-u-421ac2be5045 svadmin-u-d5eab218aa34 svadmin-u-ec0091ee009b svadmin-u-fc7473ca09eb svadmin-u-8ecebc9f80e6"
200
212
  >
201
213
  {#if action.icon}
@@ -211,12 +223,18 @@
211
223
  destructive={action.danger || action.variant === 'destructive'}
212
224
  role="menuitem"
213
225
  tabindex={-1}
226
+ title={actionTitle(action)}
214
227
  >
215
228
  {#if action.icon}
216
229
  {@const Icon = action.icon}
217
230
  <Icon class="svadmin-u-783b0d9d1e2c" />
218
231
  {/if}
219
- {action.label}
232
+ <span class="row-action-label">
233
+ <span>{action.label}</span>
234
+ {#if action.disabled && action.disabledReason}
235
+ <span class="row-action-reason">{action.disabledReason}</span>
236
+ {/if}
237
+ </span>
220
238
  </DropdownMenu.Item>
221
239
  {/if}
222
240
  {/each}
@@ -227,3 +245,9 @@
227
245
  </DropdownMenu.Root>
228
246
  {/if}
229
247
  </div>
248
+
249
+ <style>
250
+ .row-action-label { display: inline-flex; flex-direction: column; align-items: flex-start; gap: .125rem; min-width: 0; }
251
+ .row-action-label > span:first-child { overflow: hidden; text-overflow: ellipsis; }
252
+ .row-action-reason { font-size: .75em; line-height: 1.3; opacity: .72; white-space: normal; }
253
+ </style>
@@ -8,8 +8,10 @@ export type RowActionItem = {
8
8
  }>;
9
9
  href?: string;
10
10
  onclick?: (e: MouseEvent) => void;
11
- variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
11
+ variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary' | 'primary';
12
12
  disabled?: boolean;
13
+ /** 受控禁用原因;禁用项通过 tooltip/标题与菜单内说明呈现,不改变命令语义。 */
14
+ disabledReason?: string;
13
15
  title?: string;
14
16
  danger?: boolean;
15
17
  hidden?: boolean;
@@ -20,7 +20,7 @@ export interface FilterGroupNode {
20
20
  wrapped?: boolean;
21
21
  }
22
22
  export type FilterNode = FilterRuleNode | FilterGroupNode;
23
- export type FilterIssueCode = 'invalid-filter' | 'limit' | 'unknown-field' | 'operator' | 'value' | 'empty-group';
23
+ export type FilterIssueCode = 'invalid-filter' | 'limit' | 'unknown-field' | 'operator' | 'logic' | 'value' | 'empty-group';
24
24
  export interface FilterEditorIssue {
25
25
  path: string;
26
26
  code: FilterIssueCode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.93.0",
3
+ "version": "0.95.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [