@svadmin/ui 0.63.0 → 0.64.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.
- package/dist/components/AutoTable.svelte +8 -0
- package/dist/components/AutoTable.svelte.d.ts +7 -1
- package/dist/components/Cascader.svelte +205 -0
- package/dist/components/Cascader.svelte.d.ts +21 -0
- package/dist/components/DynamicFormList.svelte +214 -0
- package/dist/components/DynamicFormList.svelte.d.ts +62 -0
- package/dist/components/FieldRenderer.svelte +32 -0
- package/dist/components/FilterBuilder.svelte +310 -0
- package/dist/components/FilterBuilder.svelte.d.ts +23 -0
- package/dist/components/Transfer.svelte +352 -0
- package/dist/components/Transfer.svelte.d.ts +28 -0
- package/dist/components/TreeSelect.svelte +307 -0
- package/dist/components/TreeSelect.svelte.d.ts +22 -0
- package/dist/components/ui/navigation-menu/index.d.ts +11 -0
- package/dist/components/ui/navigation-menu/index.js +13 -0
- package/dist/components/ui/navigation-menu/navigation-menu-content.svelte +21 -0
- package/dist/components/ui/navigation-menu/navigation-menu-content.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-indicator.svelte +22 -0
- package/dist/components/ui/navigation-menu/navigation-menu-indicator.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-item.svelte +17 -0
- package/dist/components/ui/navigation-menu/navigation-menu-item.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-link.svelte +20 -0
- package/dist/components/ui/navigation-menu/navigation-menu-link.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-list.svelte +17 -0
- package/dist/components/ui/navigation-menu/navigation-menu-list.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-trigger.svelte +28 -0
- package/dist/components/ui/navigation-menu/navigation-menu-trigger.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-viewport.svelte +22 -0
- package/dist/components/ui/navigation-menu/navigation-menu-viewport.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu.svelte +24 -0
- package/dist/components/ui/navigation-menu/navigation-menu.svelte.d.ts +4 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Plus, Trash2, RotateCcw, Filter as FilterIcon, Check } from '@lucide/svelte';
|
|
3
|
+
import { cn } from '../utils.js';
|
|
4
|
+
import { Button } from './ui/button/index.js';
|
|
5
|
+
import { Input } from './ui/input/index.js';
|
|
6
|
+
import { Select } from './ui/select/index.js';
|
|
7
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
8
|
+
import type { CrudOperator, FieldDefinition, FieldFilter, Filter, LogicalFilter } from '@svadmin/core';
|
|
9
|
+
|
|
10
|
+
export interface FilterRuleItem {
|
|
11
|
+
id: string;
|
|
12
|
+
field: string;
|
|
13
|
+
operator: CrudOperator;
|
|
14
|
+
value: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const i18n = useTranslation();
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
fields?: FieldDefinition[];
|
|
21
|
+
filters?: Filter[];
|
|
22
|
+
logicalOperator?: 'and' | 'or';
|
|
23
|
+
class?: string;
|
|
24
|
+
onApply?: (filters: Filter[]) => void;
|
|
25
|
+
onReset?: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
fields = [],
|
|
30
|
+
filters = $bindable([]),
|
|
31
|
+
logicalOperator = $bindable('and'),
|
|
32
|
+
class: className,
|
|
33
|
+
onApply,
|
|
34
|
+
onReset,
|
|
35
|
+
}: Props = $props();
|
|
36
|
+
|
|
37
|
+
const operatorOptions: { value: CrudOperator; label: string }[] = [
|
|
38
|
+
{ value: 'eq', label: '等于 (eq)' },
|
|
39
|
+
{ value: 'ne', label: '不等于 (ne)' },
|
|
40
|
+
{ value: 'contains', label: '包含 (contains)' },
|
|
41
|
+
{ value: 'ncontains', label: '不包含 (ncontains)' },
|
|
42
|
+
{ value: 'gt', label: '大于 (>)' },
|
|
43
|
+
{ value: 'gte', label: '大于等于 (>=)' },
|
|
44
|
+
{ value: 'lt', label: '小于 (<)' },
|
|
45
|
+
{ value: 'lte', label: '小于等于 (<=)' },
|
|
46
|
+
{ value: 'null', label: '为空 (null)' },
|
|
47
|
+
{ value: 'nnull', label: '非空 (not null)' },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
let rules = $state<FilterRuleItem[]>([]);
|
|
51
|
+
|
|
52
|
+
// Initialize rules from filters prop
|
|
53
|
+
$effect(() => {
|
|
54
|
+
if (filters && filters.length > 0) {
|
|
55
|
+
const parsedRules: FilterRuleItem[] = [];
|
|
56
|
+
let nextLogical: 'and' | 'or' = 'and';
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < filters.length; i++) {
|
|
59
|
+
const item = filters[i];
|
|
60
|
+
if ('field' in item) {
|
|
61
|
+
parsedRules.push({
|
|
62
|
+
id: `rule-${i}-${Date.now()}`,
|
|
63
|
+
field: item.field,
|
|
64
|
+
operator: item.operator,
|
|
65
|
+
value: item.value,
|
|
66
|
+
});
|
|
67
|
+
} else if (item.operator === 'and' || item.operator === 'or') {
|
|
68
|
+
nextLogical = item.operator;
|
|
69
|
+
for (let j = 0; j < item.value.length; j++) {
|
|
70
|
+
const sub = item.value[j];
|
|
71
|
+
if ('field' in sub) {
|
|
72
|
+
parsedRules.push({
|
|
73
|
+
id: `rule-${i}-${j}-${Date.now()}`,
|
|
74
|
+
field: sub.field,
|
|
75
|
+
operator: sub.operator,
|
|
76
|
+
value: sub.value,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
rules = parsedRules;
|
|
83
|
+
logicalOperator = nextLogical;
|
|
84
|
+
} else {
|
|
85
|
+
rules = [];
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const availableFields = $derived(
|
|
90
|
+
fields.filter((f) => f.filterable !== false)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
export function addRule() {
|
|
94
|
+
const firstField = availableFields[0]?.key ?? 'id';
|
|
95
|
+
const firstFieldDef = availableFields.find((f) => f.key === firstField);
|
|
96
|
+
const defaultOp: CrudOperator = firstFieldDef?.type === 'number' ? 'eq' : 'contains';
|
|
97
|
+
|
|
98
|
+
rules = [
|
|
99
|
+
...rules,
|
|
100
|
+
{
|
|
101
|
+
id: `rule-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
102
|
+
field: firstField,
|
|
103
|
+
operator: defaultOp,
|
|
104
|
+
value: '',
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function removeRule(index: number) {
|
|
110
|
+
rules = rules.filter((_, i) => i !== index);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function reset() {
|
|
114
|
+
rules = [];
|
|
115
|
+
filters = [];
|
|
116
|
+
onReset?.();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function apply() {
|
|
120
|
+
const validFieldFilters: FieldFilter[] = rules
|
|
121
|
+
.filter((r) => r.field && (r.operator === 'null' || r.operator === 'nnull' || (r.value !== '' && r.value !== undefined)))
|
|
122
|
+
.map((r) => ({
|
|
123
|
+
field: r.field,
|
|
124
|
+
operator: r.operator,
|
|
125
|
+
value: r.operator === 'null' || r.operator === 'nnull' ? null : r.value,
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
let compiledFilters: Filter[] = [];
|
|
129
|
+
if (validFieldFilters.length === 1 || logicalOperator === 'and') {
|
|
130
|
+
compiledFilters = validFieldFilters;
|
|
131
|
+
} else if (validFieldFilters.length > 1 && logicalOperator === 'or') {
|
|
132
|
+
const logical: LogicalFilter = {
|
|
133
|
+
operator: 'or',
|
|
134
|
+
value: validFieldFilters,
|
|
135
|
+
};
|
|
136
|
+
compiledFilters = [logical];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
filters = compiledFilters;
|
|
140
|
+
onApply?.(compiledFilters);
|
|
141
|
+
}
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<div class={cn('w-full rounded-lg border border-border bg-card p-4 shadow-xs space-y-3', className)} data-testid="filter-builder">
|
|
145
|
+
<div class="flex items-center justify-between gap-2 border-b border-border/60 pb-3">
|
|
146
|
+
<div class="flex items-center gap-2">
|
|
147
|
+
<FilterIcon class="h-4 w-4 text-muted-foreground" />
|
|
148
|
+
<span class="text-sm font-medium text-foreground">
|
|
149
|
+
{i18n.t('common.filterBuilder', undefined) ?? '高级筛选'}
|
|
150
|
+
</span>
|
|
151
|
+
{#if rules.length > 1}
|
|
152
|
+
<div class="flex items-center rounded-md border border-border bg-muted/40 p-0.5 text-xs">
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
class={cn(
|
|
156
|
+
'rounded px-2 py-0.5 text-xs transition-colors',
|
|
157
|
+
logicalOperator === 'and' ? 'bg-background font-semibold text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'
|
|
158
|
+
)}
|
|
159
|
+
onclick={() => (logicalOperator = 'and')}
|
|
160
|
+
>
|
|
161
|
+
AND (且)
|
|
162
|
+
</button>
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class={cn(
|
|
166
|
+
'rounded px-2 py-0.5 text-xs transition-colors',
|
|
167
|
+
logicalOperator === 'or' ? 'bg-background font-semibold text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'
|
|
168
|
+
)}
|
|
169
|
+
onclick={() => (logicalOperator = 'or')}
|
|
170
|
+
>
|
|
171
|
+
OR (或)
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
{/if}
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div class="flex items-center gap-1.5">
|
|
178
|
+
<Button
|
|
179
|
+
variant="ghost"
|
|
180
|
+
size="sm"
|
|
181
|
+
class="h-8 gap-1 text-xs"
|
|
182
|
+
data-testid="filter-builder-reset"
|
|
183
|
+
onclick={reset}
|
|
184
|
+
>
|
|
185
|
+
<RotateCcw class="h-3.5 w-3.5" />
|
|
186
|
+
{i18n.t('common.reset', undefined) ?? '重置'}
|
|
187
|
+
</Button>
|
|
188
|
+
<Button
|
|
189
|
+
variant="default"
|
|
190
|
+
size="sm"
|
|
191
|
+
class="h-8 gap-1 text-xs"
|
|
192
|
+
data-testid="filter-builder-apply"
|
|
193
|
+
onclick={apply}
|
|
194
|
+
>
|
|
195
|
+
<Check class="h-3.5 w-3.5" />
|
|
196
|
+
{i18n.t('common.confirm', undefined) ?? '应用'}
|
|
197
|
+
</Button>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div class="space-y-2">
|
|
202
|
+
{#each rules as rule, index (rule.id)}
|
|
203
|
+
{@const fieldDef = availableFields.find((f) => f.key === rule.field)}
|
|
204
|
+
<div class="flex flex-wrap sm:flex-nowrap items-center gap-2 rounded-md bg-muted/20 p-2 border border-border/40">
|
|
205
|
+
<!-- Field select -->
|
|
206
|
+
<div class="w-36 shrink-0">
|
|
207
|
+
<Select
|
|
208
|
+
class="h-8 text-xs"
|
|
209
|
+
value={rule.field}
|
|
210
|
+
onchange={(e: Event) => {
|
|
211
|
+
rule.field = (e.currentTarget as HTMLSelectElement).value;
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{#each availableFields as f (f.key)}
|
|
215
|
+
<option value={f.key}>{f.label || f.key}</option>
|
|
216
|
+
{/each}
|
|
217
|
+
</Select>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<!-- Operator select -->
|
|
221
|
+
<div class="w-36 shrink-0">
|
|
222
|
+
<Select
|
|
223
|
+
class="h-8 text-xs"
|
|
224
|
+
value={rule.operator}
|
|
225
|
+
onchange={(e: Event) => {
|
|
226
|
+
rule.operator = (e.currentTarget as HTMLSelectElement).value as CrudOperator;
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
{#each operatorOptions as op (op.value)}
|
|
230
|
+
<option value={op.value}>{op.label}</option>
|
|
231
|
+
{/each}
|
|
232
|
+
</Select>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<!-- Value input -->
|
|
236
|
+
<div class="flex-1 min-w-[120px]">
|
|
237
|
+
{#if rule.operator === 'null' || rule.operator === 'nnull'}
|
|
238
|
+
<div class="h-8 flex items-center px-3 text-xs text-muted-foreground italic bg-muted/30 rounded-md">
|
|
239
|
+
无需填值
|
|
240
|
+
</div>
|
|
241
|
+
{:else if fieldDef?.type === 'select' && fieldDef.options}
|
|
242
|
+
<Select
|
|
243
|
+
class="h-8 text-xs"
|
|
244
|
+
value={String(rule.value ?? '')}
|
|
245
|
+
onchange={(e: Event) => {
|
|
246
|
+
rule.value = (e.currentTarget as HTMLSelectElement).value;
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
<option value="">{i18n.t('field.selectPlaceholder', undefined) ?? '请选择'}</option>
|
|
250
|
+
{#each fieldDef.options as opt (opt.value)}
|
|
251
|
+
<option value={String(opt.value)}>{opt.label}</option>
|
|
252
|
+
{/each}
|
|
253
|
+
</Select>
|
|
254
|
+
{:else if fieldDef?.type === 'boolean'}
|
|
255
|
+
<Select
|
|
256
|
+
class="h-8 text-xs"
|
|
257
|
+
value={String(rule.value ?? '')}
|
|
258
|
+
onchange={(e: Event) => {
|
|
259
|
+
const val = (e.currentTarget as HTMLSelectElement).value;
|
|
260
|
+
rule.value = val === 'true' ? true : val === 'false' ? false : '';
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
<option value="">{i18n.t('field.selectPlaceholder', undefined) ?? '请选择'}</option>
|
|
264
|
+
<option value="true">{i18n.t('common.yes', undefined) ?? '是 (true)'}</option>
|
|
265
|
+
<option value="false">{i18n.t('common.no', undefined) ?? '否 (false)'}</option>
|
|
266
|
+
</Select>
|
|
267
|
+
{:else}
|
|
268
|
+
<Input
|
|
269
|
+
type={fieldDef?.type === 'number' ? 'number' : 'text'}
|
|
270
|
+
class="h-8 text-xs"
|
|
271
|
+
placeholder="输入筛选值..."
|
|
272
|
+
value={String(rule.value ?? '')}
|
|
273
|
+
oninput={(e: Event) => {
|
|
274
|
+
const val = (e.currentTarget as HTMLInputElement).value;
|
|
275
|
+
rule.value = fieldDef?.type === 'number' && val !== '' ? Number(val) : val;
|
|
276
|
+
}}
|
|
277
|
+
/>
|
|
278
|
+
{/if}
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<!-- Remove button -->
|
|
282
|
+
<Button
|
|
283
|
+
type="button"
|
|
284
|
+
variant="ghost"
|
|
285
|
+
size="icon-sm"
|
|
286
|
+
class="shrink-0 text-muted-foreground hover:text-destructive"
|
|
287
|
+
onclick={() => removeRule(index)}
|
|
288
|
+
>
|
|
289
|
+
<Trash2 class="h-3.5 w-3.5" />
|
|
290
|
+
</Button>
|
|
291
|
+
</div>
|
|
292
|
+
{:else}
|
|
293
|
+
<div class="rounded-md border border-dashed border-border/80 py-4 text-center text-xs text-muted-foreground">
|
|
294
|
+
暂无筛选条件,点击下方按钮添加规则
|
|
295
|
+
</div>
|
|
296
|
+
{/each}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<Button
|
|
300
|
+
type="button"
|
|
301
|
+
variant="outline"
|
|
302
|
+
size="sm"
|
|
303
|
+
data-testid="filter-builder-add-rule"
|
|
304
|
+
class="w-full gap-1.5 border-dashed text-xs h-8"
|
|
305
|
+
onclick={addRule}
|
|
306
|
+
>
|
|
307
|
+
<Plus class="h-3.5 w-3.5" />
|
|
308
|
+
{i18n.t('common.addRule', undefined) ?? '添加筛选条件'}
|
|
309
|
+
</Button>
|
|
310
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CrudOperator, FieldDefinition, Filter } from '@svadmin/core';
|
|
2
|
+
export interface FilterRuleItem {
|
|
3
|
+
id: string;
|
|
4
|
+
field: string;
|
|
5
|
+
operator: CrudOperator;
|
|
6
|
+
value: unknown;
|
|
7
|
+
}
|
|
8
|
+
interface Props {
|
|
9
|
+
fields?: FieldDefinition[];
|
|
10
|
+
filters?: Filter[];
|
|
11
|
+
logicalOperator?: 'and' | 'or';
|
|
12
|
+
class?: string;
|
|
13
|
+
onApply?: (filters: Filter[]) => void;
|
|
14
|
+
onReset?: () => void;
|
|
15
|
+
}
|
|
16
|
+
declare const FilterBuilder: import("svelte").Component<Props, {
|
|
17
|
+
addRule: () => void;
|
|
18
|
+
removeRule: (index: number) => void;
|
|
19
|
+
reset: () => void;
|
|
20
|
+
apply: () => void;
|
|
21
|
+
}, "filters" | "logicalOperator">;
|
|
22
|
+
type FilterBuilder = ReturnType<typeof FilterBuilder>;
|
|
23
|
+
export default FilterBuilder;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
import { ChevronRight, ChevronLeft, ChevronsRight, ChevronsLeft, Search } from '@lucide/svelte';
|
|
5
|
+
import { cn } from '../utils.js';
|
|
6
|
+
import { Checkbox } from './ui/checkbox/index.js';
|
|
7
|
+
import { Button } from './ui/button/index.js';
|
|
8
|
+
import { Input } from './ui/input/index.js';
|
|
9
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
10
|
+
|
|
11
|
+
export interface TransferItem {
|
|
12
|
+
key: string | number;
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const i18n = useTranslation();
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
dataSource?: TransferItem[];
|
|
22
|
+
targetKeys?: (string | number)[];
|
|
23
|
+
titles?: [string, string];
|
|
24
|
+
searchable?: boolean;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
class?: string;
|
|
27
|
+
onchange?: (
|
|
28
|
+
nextTargetKeys: (string | number)[],
|
|
29
|
+
direction: 'left' | 'right',
|
|
30
|
+
moveKeys: (string | number)[]
|
|
31
|
+
) => void;
|
|
32
|
+
renderItem?: Snippet<[{ item: TransferItem; direction: 'left' | 'right' }]>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
dataSource = [],
|
|
37
|
+
targetKeys = $bindable([]),
|
|
38
|
+
titles = ['源列表', '目标列表'],
|
|
39
|
+
searchable = true,
|
|
40
|
+
disabled = false,
|
|
41
|
+
class: className,
|
|
42
|
+
onchange,
|
|
43
|
+
renderItem,
|
|
44
|
+
}: Props = $props();
|
|
45
|
+
|
|
46
|
+
let leftSearch = $state('');
|
|
47
|
+
let rightSearch = $state('');
|
|
48
|
+
|
|
49
|
+
const leftSelected = new SvelteSet<string | number>();
|
|
50
|
+
const rightSelected = new SvelteSet<string | number>();
|
|
51
|
+
|
|
52
|
+
const targetKeySet = $derived(new Set(targetKeys));
|
|
53
|
+
|
|
54
|
+
const leftDataSource = $derived(
|
|
55
|
+
dataSource.filter((item) => !targetKeySet.has(item.key))
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const rightDataSource = $derived(
|
|
59
|
+
dataSource.filter((item) => targetKeySet.has(item.key))
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const filteredLeft = $derived(
|
|
63
|
+
leftDataSource.filter((item) => {
|
|
64
|
+
if (!leftSearch.trim()) return true;
|
|
65
|
+
const q = leftSearch.toLowerCase();
|
|
66
|
+
return item.title.toLowerCase().includes(q) || (item.description && item.description.toLowerCase().includes(q));
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const filteredRight = $derived(
|
|
71
|
+
rightDataSource.filter((item) => {
|
|
72
|
+
if (!rightSearch.trim()) return true;
|
|
73
|
+
const q = rightSearch.toLowerCase();
|
|
74
|
+
return item.title.toLowerCase().includes(q) || (item.description && item.description.toLowerCase().includes(q));
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function toggleLeftSelect(key: string | number) {
|
|
79
|
+
if (leftSelected.has(key)) leftSelected.delete(key);
|
|
80
|
+
else leftSelected.add(key);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toggleRightSelect(key: string | number) {
|
|
84
|
+
if (rightSelected.has(key)) rightSelected.delete(key);
|
|
85
|
+
else rightSelected.add(key);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toggleAllLeft() {
|
|
89
|
+
const selectable = filteredLeft.filter((i) => !i.disabled);
|
|
90
|
+
const allSelected = selectable.length > 0 && selectable.every((i) => leftSelected.has(i.key));
|
|
91
|
+
if (allSelected) {
|
|
92
|
+
for (const item of selectable) leftSelected.delete(item.key);
|
|
93
|
+
} else {
|
|
94
|
+
for (const item of selectable) leftSelected.add(item.key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toggleAllRight() {
|
|
99
|
+
const selectable = filteredRight.filter((i) => !i.disabled);
|
|
100
|
+
const allSelected = selectable.length > 0 && selectable.every((i) => rightSelected.has(i.key));
|
|
101
|
+
if (allSelected) {
|
|
102
|
+
for (const item of selectable) rightSelected.delete(item.key);
|
|
103
|
+
} else {
|
|
104
|
+
for (const item of selectable) rightSelected.add(item.key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function moveToRight() {
|
|
109
|
+
if (disabled) return;
|
|
110
|
+
const moveKeys = Array.from(leftSelected).filter((key) =>
|
|
111
|
+
leftDataSource.some((item) => item.key === key && !item.disabled)
|
|
112
|
+
);
|
|
113
|
+
if (moveKeys.length === 0) return;
|
|
114
|
+
|
|
115
|
+
const next = [...targetKeys, ...moveKeys];
|
|
116
|
+
targetKeys = next;
|
|
117
|
+
leftSelected.clear();
|
|
118
|
+
onchange?.(next, 'right', moveKeys);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function moveToLeft() {
|
|
122
|
+
if (disabled) return;
|
|
123
|
+
const moveKeys = Array.from(rightSelected).filter((key) =>
|
|
124
|
+
rightDataSource.some((item) => item.key === key && !item.disabled)
|
|
125
|
+
);
|
|
126
|
+
if (moveKeys.length === 0) return;
|
|
127
|
+
|
|
128
|
+
const moveKeySet = new Set(moveKeys);
|
|
129
|
+
const next = targetKeys.filter((k) => !moveKeySet.has(k));
|
|
130
|
+
targetKeys = next;
|
|
131
|
+
rightSelected.clear();
|
|
132
|
+
onchange?.(next, 'left', moveKeys);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function moveAllToRight() {
|
|
136
|
+
if (disabled) return;
|
|
137
|
+
const moveKeys = leftDataSource.filter((i) => !i.disabled).map((i) => i.key);
|
|
138
|
+
if (moveKeys.length === 0) return;
|
|
139
|
+
const next = [...targetKeys, ...moveKeys];
|
|
140
|
+
targetKeys = next;
|
|
141
|
+
leftSelected.clear();
|
|
142
|
+
onchange?.(next, 'right', moveKeys);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function moveAllToLeft() {
|
|
146
|
+
if (disabled) return;
|
|
147
|
+
const moveKeys = rightDataSource.filter((i) => !i.disabled).map((i) => i.key);
|
|
148
|
+
if (moveKeys.length === 0) return;
|
|
149
|
+
const moveKeySet = new Set(moveKeys);
|
|
150
|
+
const next = targetKeys.filter((k) => !moveKeySet.has(k));
|
|
151
|
+
targetKeys = next;
|
|
152
|
+
rightSelected.clear();
|
|
153
|
+
onchange?.(next, 'left', moveKeys);
|
|
154
|
+
}
|
|
155
|
+
</script>
|
|
156
|
+
|
|
157
|
+
<div class={cn('flex flex-col sm:flex-row items-center gap-3 w-full', className)} data-testid="transfer">
|
|
158
|
+
<!-- Left Panel -->
|
|
159
|
+
<div class="flex-1 w-full rounded-lg border border-border bg-card shadow-xs flex flex-col h-72">
|
|
160
|
+
<div class="flex items-center justify-between border-b border-border/80 px-3 py-2 bg-muted/30">
|
|
161
|
+
<div class="flex items-center gap-2">
|
|
162
|
+
<Checkbox
|
|
163
|
+
checked={filteredLeft.length > 0 && filteredLeft.filter((i) => !i.disabled).every((i) => leftSelected.has(i.key))}
|
|
164
|
+
disabled={disabled || filteredLeft.filter((i) => !i.disabled).length === 0}
|
|
165
|
+
onCheckedChange={toggleAllLeft}
|
|
166
|
+
/>
|
|
167
|
+
<span class="text-xs font-medium text-foreground">{titles[0]}</span>
|
|
168
|
+
</div>
|
|
169
|
+
<span class="text-[11px] text-muted-foreground tabular-nums">
|
|
170
|
+
{leftSelected.size} / {leftDataSource.length}
|
|
171
|
+
</span>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{#if searchable}
|
|
175
|
+
<div class="p-2 border-b border-border/40">
|
|
176
|
+
<div class="relative">
|
|
177
|
+
<Search class="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
|
178
|
+
<Input
|
|
179
|
+
type="text"
|
|
180
|
+
bind:value={leftSearch}
|
|
181
|
+
placeholder={i18n.t('common.search', undefined) ?? '搜索...'}
|
|
182
|
+
class="h-8 pl-8 pr-2 text-xs"
|
|
183
|
+
{disabled}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
{/if}
|
|
188
|
+
|
|
189
|
+
<div class="flex-1 overflow-y-auto p-1 space-y-0.5">
|
|
190
|
+
{#each filteredLeft as item (item.key)}
|
|
191
|
+
{@const selected = leftSelected.has(item.key)}
|
|
192
|
+
<div
|
|
193
|
+
role="button"
|
|
194
|
+
tabindex="0"
|
|
195
|
+
class={cn(
|
|
196
|
+
'flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors cursor-pointer select-none',
|
|
197
|
+
selected ? 'bg-primary/10 text-primary font-medium' : 'hover:bg-muted/50 text-foreground',
|
|
198
|
+
item.disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
|
199
|
+
)}
|
|
200
|
+
onclick={() => toggleLeftSelect(item.key)}
|
|
201
|
+
onkeydown={(e) => {
|
|
202
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
toggleLeftSelect(item.key);
|
|
205
|
+
}
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<Checkbox
|
|
209
|
+
checked={selected}
|
|
210
|
+
disabled={disabled || item.disabled}
|
|
211
|
+
onCheckedChange={() => toggleLeftSelect(item.key)}
|
|
212
|
+
/>
|
|
213
|
+
<div class="min-w-0 flex-1">
|
|
214
|
+
{#if renderItem}
|
|
215
|
+
{@render renderItem({ item, direction: 'left' })}
|
|
216
|
+
{:else}
|
|
217
|
+
<div class="truncate">{item.title}</div>
|
|
218
|
+
{#if item.description}
|
|
219
|
+
<div class="truncate text-[10px] text-muted-foreground">{item.description}</div>
|
|
220
|
+
{/if}
|
|
221
|
+
{/if}
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
{:else}
|
|
225
|
+
<div class="py-10 text-center text-xs text-muted-foreground">
|
|
226
|
+
{i18n.t('common.noData', undefined) ?? '无项目'}
|
|
227
|
+
</div>
|
|
228
|
+
{/each}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<!-- Middle Action Buttons -->
|
|
233
|
+
<div class="flex sm:flex-col gap-1.5 shrink-0 justify-center">
|
|
234
|
+
<Button
|
|
235
|
+
type="button"
|
|
236
|
+
variant="outline"
|
|
237
|
+
size="icon-sm"
|
|
238
|
+
disabled={disabled || leftSelected.size === 0}
|
|
239
|
+
onclick={moveToRight}
|
|
240
|
+
title="转移所选项"
|
|
241
|
+
>
|
|
242
|
+
<ChevronRight class="h-4 w-4" />
|
|
243
|
+
</Button>
|
|
244
|
+
|
|
245
|
+
<Button
|
|
246
|
+
type="button"
|
|
247
|
+
variant="outline"
|
|
248
|
+
size="icon-sm"
|
|
249
|
+
disabled={disabled || rightSelected.size === 0}
|
|
250
|
+
onclick={moveToLeft}
|
|
251
|
+
title="移除所选项"
|
|
252
|
+
>
|
|
253
|
+
<ChevronLeft class="h-4 w-4" />
|
|
254
|
+
</Button>
|
|
255
|
+
|
|
256
|
+
<Button
|
|
257
|
+
type="button"
|
|
258
|
+
variant="ghost"
|
|
259
|
+
size="icon-sm"
|
|
260
|
+
disabled={disabled || leftDataSource.length === 0}
|
|
261
|
+
onclick={moveAllToRight}
|
|
262
|
+
title="全部转移"
|
|
263
|
+
>
|
|
264
|
+
<ChevronsRight class="h-4 w-4" />
|
|
265
|
+
</Button>
|
|
266
|
+
|
|
267
|
+
<Button
|
|
268
|
+
type="button"
|
|
269
|
+
variant="ghost"
|
|
270
|
+
size="icon-sm"
|
|
271
|
+
disabled={disabled || rightDataSource.length === 0}
|
|
272
|
+
onclick={moveAllToLeft}
|
|
273
|
+
title="全部移除"
|
|
274
|
+
>
|
|
275
|
+
<ChevronsLeft class="h-4 w-4" />
|
|
276
|
+
</Button>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<!-- Right Panel -->
|
|
280
|
+
<div class="flex-1 w-full rounded-lg border border-border bg-card shadow-xs flex flex-col h-72">
|
|
281
|
+
<div class="flex items-center justify-between border-b border-border/80 px-3 py-2 bg-muted/30">
|
|
282
|
+
<div class="flex items-center gap-2">
|
|
283
|
+
<Checkbox
|
|
284
|
+
checked={filteredRight.length > 0 && filteredRight.filter((i) => !i.disabled).every((i) => rightSelected.has(i.key))}
|
|
285
|
+
disabled={disabled || filteredRight.filter((i) => !i.disabled).length === 0}
|
|
286
|
+
onCheckedChange={toggleAllRight}
|
|
287
|
+
/>
|
|
288
|
+
<span class="text-xs font-medium text-foreground">{titles[1]}</span>
|
|
289
|
+
</div>
|
|
290
|
+
<span class="text-[11px] text-muted-foreground tabular-nums">
|
|
291
|
+
{rightSelected.size} / {rightDataSource.length}
|
|
292
|
+
</span>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
{#if searchable}
|
|
296
|
+
<div class="p-2 border-b border-border/40">
|
|
297
|
+
<div class="relative">
|
|
298
|
+
<Search class="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
|
299
|
+
<Input
|
|
300
|
+
type="text"
|
|
301
|
+
bind:value={rightSearch}
|
|
302
|
+
placeholder={i18n.t('common.search', undefined) ?? '搜索...'}
|
|
303
|
+
class="h-8 pl-8 pr-2 text-xs"
|
|
304
|
+
{disabled}
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
{/if}
|
|
309
|
+
|
|
310
|
+
<div class="flex-1 overflow-y-auto p-1 space-y-0.5">
|
|
311
|
+
{#each filteredRight as item (item.key)}
|
|
312
|
+
{@const selected = rightSelected.has(item.key)}
|
|
313
|
+
<div
|
|
314
|
+
role="button"
|
|
315
|
+
tabindex="0"
|
|
316
|
+
class={cn(
|
|
317
|
+
'flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors cursor-pointer select-none',
|
|
318
|
+
selected ? 'bg-primary/10 text-primary font-medium' : 'hover:bg-muted/50 text-foreground',
|
|
319
|
+
item.disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
|
320
|
+
)}
|
|
321
|
+
onclick={() => toggleRightSelect(item.key)}
|
|
322
|
+
onkeydown={(e) => {
|
|
323
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
324
|
+
e.preventDefault();
|
|
325
|
+
toggleRightSelect(item.key);
|
|
326
|
+
}
|
|
327
|
+
}}
|
|
328
|
+
>
|
|
329
|
+
<Checkbox
|
|
330
|
+
checked={selected}
|
|
331
|
+
disabled={disabled || item.disabled}
|
|
332
|
+
onCheckedChange={() => toggleRightSelect(item.key)}
|
|
333
|
+
/>
|
|
334
|
+
<div class="min-w-0 flex-1">
|
|
335
|
+
{#if renderItem}
|
|
336
|
+
{@render renderItem({ item, direction: 'right' })}
|
|
337
|
+
{:else}
|
|
338
|
+
<div class="truncate">{item.title}</div>
|
|
339
|
+
{#if item.description}
|
|
340
|
+
<div class="truncate text-[10px] text-muted-foreground">{item.description}</div>
|
|
341
|
+
{/if}
|
|
342
|
+
{/if}
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
{:else}
|
|
346
|
+
<div class="py-10 text-center text-xs text-muted-foreground">
|
|
347
|
+
{i18n.t('common.noData', undefined) ?? '无项目'}
|
|
348
|
+
</div>
|
|
349
|
+
{/each}
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|