@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'),
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 ?
|
|
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=
|
|
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
|
-
|
|
196
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|