@svadmin/lite 0.10.1 → 0.13.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.
Files changed (51) hide show
  1. package/README.md +1 -19
  2. package/dist/compatibility.js +29 -29
  3. package/dist/components/LiteArrayItem.svelte +2 -2
  4. package/dist/components/LiteCanvasAnnotation.svelte +3 -1
  5. package/dist/components/LiteFileInput.svelte +40 -0
  6. package/dist/components/LiteFileInput.svelte.d.ts +11 -0
  7. package/dist/components/LiteFileUpload.svelte +53 -0
  8. package/dist/components/LiteFileUpload.svelte.d.ts +16 -0
  9. package/dist/components/LiteFilterBuilder.svelte +107 -153
  10. package/dist/components/LiteFilterBuilder.svelte.d.ts +0 -1
  11. package/dist/components/LiteForm.svelte +2 -2
  12. package/dist/components/LiteImportWizard.svelte +2 -1
  13. package/dist/components/LiteJsonSchemaForm.svelte +136 -70
  14. package/dist/components/LiteJsonSchemaForm.svelte.d.ts +12 -2
  15. package/dist/components/LiteMasterDetailView.svelte +2 -2
  16. package/dist/components/LiteMediaLibraryModal.svelte +5 -2
  17. package/dist/components/LiteSensitiveDataMask.svelte +2 -1
  18. package/dist/components/LiteShowField.svelte +6 -4
  19. package/dist/components/LiteSignaturePad.svelte +3 -1
  20. package/dist/components/advanced/LiteAutoSaveIndicator.svelte +2 -2
  21. package/dist/components/advanced/LiteToast.svelte +2 -2
  22. package/dist/components/advanced/LiteVirtualTable.svelte +6 -5
  23. package/dist/components/buttons/LiteCloneButton.svelte +3 -0
  24. package/dist/components/buttons/LiteCreateButton.svelte +3 -0
  25. package/dist/components/buttons/LiteDeleteButton.svelte +3 -0
  26. package/dist/components/buttons/LiteEditButton.svelte +3 -0
  27. package/dist/components/buttons/LiteExportButton.svelte +3 -0
  28. package/dist/components/buttons/LiteImportButton.svelte +5 -1
  29. package/dist/components/buttons/LiteListButton.svelte +3 -0
  30. package/dist/components/buttons/LiteRefreshButton.svelte +3 -0
  31. package/dist/components/buttons/LiteSaveButton.svelte +3 -0
  32. package/dist/components/buttons/LiteShowButton.svelte +3 -0
  33. package/dist/components/compatibility/LiteDirectoryUpload.svelte +4 -3
  34. package/dist/components/fields/LiteAvatarField.svelte +3 -3
  35. package/dist/components/fields/LiteCascader.svelte +4 -2
  36. package/dist/components/fields/LiteDateField.svelte +3 -0
  37. package/dist/components/fields/LiteDateRangeField.svelte +9 -4
  38. package/dist/components/fields/LiteFileField.svelte +2 -2
  39. package/dist/components/fields/LiteTreeSelect.svelte +4 -2
  40. package/dist/components/pages/LiteListPage.svelte +4 -2
  41. package/dist/filter-form.d.ts +3 -0
  42. package/dist/filter-form.js +34 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.js +2 -0
  45. package/dist/lite.css +86 -0
  46. package/dist/server-adapter.d.ts +15 -15
  47. package/dist/server-adapter.js +62 -8
  48. package/package.json +7 -14
  49. package/dist/polyfill-entry.d.ts +0 -2
  50. package/dist/polyfill-entry.js +0 -3
  51. package/dist/polyfill.js +0 -20707
package/README.md CHANGED
@@ -13,7 +13,7 @@ The main `@svadmin/ui` package delivers a premium SPA experience using Svelte 5,
13
13
  **What Lite is:**
14
14
  - A **Zero-JS SSR-first admin component suite** designed with an IE11-safe CSS baseline.
15
15
  - 100% interoperable with `@svadmin/core` — sharing the same `DataProvider`, `AuthProvider`, `Resource`, and `FieldDefinition` contracts.
16
- - An architectural companion to `@svadmin/ui` achieving **100% component parity (103/103 components)** through 1:1 server-rendered matches, deliberate semantic fallbacks, or explicit SPA-only classification.
16
+ - An architectural companion to `@svadmin/ui` with tracked server-rendered mappings, semantic fallbacks, and explicit SPA-only classifications in `PARITY.md`. Coverage measures the listed mappings, not a complete public-export inventory or verified behavioral equivalence.
17
17
  - **Not** a generic Tailwind-to-IE11 compiler or a full client-side port of shadcn-svelte (which depends on modern browser JS runtimes and CSS custom properties).
18
18
 
19
19
  `@svadmin/lite` provides a server-rendered fallback that **shares the same DataProvider, AuthProvider, and Resource definitions** — only the rendering layer is different.
@@ -207,24 +207,6 @@ import { createLegacyRedirectHook } from '@svadmin/lite';
207
207
  export const handle = createLegacyRedirectHook('/lite');
208
208
  ```
209
209
 
210
- ### 3b. Optional IE11 polyfill and enhancement
211
-
212
- The Lite SSR baseline does not require a polyfill: native links, native forms,
213
- server actions, and full-page navigation work without browser JavaScript. If
214
- you enable `enhance.js`, load the small ES5 DOM baseline first:
215
-
216
- ```html
217
- <script src="/polyfill.js"></script>
218
- <script src="/enhance.js"></script>
219
- ```
220
-
221
- Copy both assets from the package exports `@svadmin/lite/polyfill.js` and
222
- `@svadmin/lite/enhance.js` into the application's static directory. The
223
- published polyfill bundles `core-js/stable` and `whatwg-fetch` for the legacy
224
- enhancement layer. It does not polyfill WebSocket, WASM, Canvas, or the Svelte
225
- runtime. Those capabilities must continue to use the documented server
226
- fallback.
227
-
228
210
  ### 4. Optional legacy-browser metadata
229
211
 
230
212
  ```html
@@ -14,17 +14,17 @@ function hasFunction(record, key) {
14
14
  }
15
15
  function supportsCanvas(documentValue, context) {
16
16
  const documentRecord = asRecord(documentValue);
17
- if (!documentRecord || typeof documentRecord.createElement !== 'function')
17
+ if (!documentRecord || typeof documentRecord['createElement'] !== 'function')
18
18
  return false;
19
- const canvas = documentRecord.createElement('canvas');
19
+ const canvas = documentRecord['createElement']('canvas');
20
20
  const canvasRecord = asRecord(canvas);
21
- if (!canvasRecord || typeof canvasRecord.getContext !== 'function')
21
+ if (!canvasRecord || typeof canvasRecord['getContext'] !== 'function')
22
22
  return false;
23
- return Boolean(canvasRecord.getContext(context));
23
+ return Boolean(canvasRecord['getContext'](context));
24
24
  }
25
25
  function supportsDirectoryUpload(environment) {
26
- const inputConstructor = asRecord(environment.HTMLInputElement);
27
- const prototype = asRecord(inputConstructor?.prototype);
26
+ const inputConstructor = asRecord(environment['HTMLInputElement']);
27
+ const prototype = asRecord(inputConstructor?.['prototype']);
28
28
  return prototype !== undefined && ('webkitdirectory' in prototype || 'directory' in prototype);
29
29
  }
30
30
  export const LITE_COMPATIBILITY_CATALOG = [
@@ -56,34 +56,34 @@ export function detectLiteCapabilities(environment) {
56
56
  const env = asRecord(explicitEnvironment
57
57
  ? environment
58
58
  : typeof globalThis === 'undefined' ? undefined : globalThis) ?? {};
59
- const navigator = asRecord(env.navigator);
60
- const clipboard = asRecord(navigator?.clipboard);
61
- const webAssembly = asRecord(env.WebAssembly);
62
- const readableStream = asRecord(env.ReadableStream);
59
+ const navigator = asRecord(env['navigator']);
60
+ const clipboard = asRecord(navigator?.['clipboard']);
61
+ const webAssembly = asRecord(env['WebAssembly']);
62
+ const readableStream = asRecord(env['ReadableStream']);
63
63
  return {
64
- 'canvas-2d': supportsCanvas(env.document, '2d'),
65
- webgl: supportsCanvas(env.document, 'webgl'),
64
+ 'canvas-2d': supportsCanvas(env['document'], '2d'),
65
+ webgl: supportsCanvas(env['document'], 'webgl'),
66
66
  wasm: hasFunction(webAssembly, 'instantiate'),
67
67
  'wasm-streaming': hasFunction(webAssembly, 'instantiateStreaming'),
68
- websocket: typeof env.WebSocket === 'function',
69
- 'event-source': typeof env.EventSource === 'function',
70
- worker: typeof env.Worker === 'function',
68
+ websocket: typeof env['WebSocket'] === 'function',
69
+ 'event-source': typeof env['EventSource'] === 'function',
70
+ worker: typeof env['Worker'] === 'function',
71
71
  'directory-upload': supportsDirectoryUpload(env),
72
- 'file-system-access': typeof env.showOpenFilePicker === 'function'
73
- || typeof env.showDirectoryPicker === 'function',
72
+ 'file-system-access': typeof env['showOpenFilePicker'] === 'function'
73
+ || typeof env['showDirectoryPicker'] === 'function',
74
74
  clipboard: hasFunction(clipboard, 'writeText'),
75
- 'broadcast-channel': typeof env.BroadcastChannel === 'function',
76
- 'intersection-observer': typeof env.IntersectionObserver === 'function',
77
- 'resize-observer': typeof env.ResizeObserver === 'function',
78
- 'service-worker': asRecord(navigator?.serviceWorker) !== undefined,
79
- 'indexed-db': asRecord(env.indexedDB) !== undefined,
80
- notifications: typeof env.Notification === 'function',
81
- 'media-capture': hasFunction(asRecord(navigator?.mediaDevices), 'getUserMedia')
82
- || typeof env.MediaRecorder === 'function',
83
- 'web-rtc': typeof env.RTCPeerConnection === 'function',
84
- geolocation: asRecord(navigator?.geolocation) !== undefined,
85
- 'web-streams': asRecord(env.ReadableStream) !== undefined
86
- && (hasFunction(readableStream, 'from') || typeof env.WritableStream === 'function'),
75
+ 'broadcast-channel': typeof env['BroadcastChannel'] === 'function',
76
+ 'intersection-observer': typeof env['IntersectionObserver'] === 'function',
77
+ 'resize-observer': typeof env['ResizeObserver'] === 'function',
78
+ 'service-worker': asRecord(navigator?.['serviceWorker']) !== undefined,
79
+ 'indexed-db': asRecord(env['indexedDB']) !== undefined,
80
+ notifications: typeof env['Notification'] === 'function',
81
+ 'media-capture': hasFunction(asRecord(navigator?.['mediaDevices']), 'getUserMedia')
82
+ || typeof env['MediaRecorder'] === 'function',
83
+ 'web-rtc': typeof env['RTCPeerConnection'] === 'function',
84
+ geolocation: asRecord(navigator?.['geolocation']) !== undefined,
85
+ 'web-streams': asRecord(env['ReadableStream']) !== undefined
86
+ && (hasFunction(readableStream, 'from') || typeof env['WritableStream'] === 'function'),
87
87
  };
88
88
  }
89
89
  export function resolveLiteCompatibility(capability, support = detectLiteCapabilities()) {
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { FieldDefinition } from '@svadmin/core';
3
3
  import { fieldToInputType, fieldToPlaceholder } from '../schema-generator';
4
+ import LiteFileInput from './LiteFileInput.svelte';
4
5
  import { isExplicitBooleanTrue } from '../value-normalization';
5
6
 
6
7
  interface Props {
@@ -142,10 +143,9 @@
142
143
  <input type="hidden" name={fieldName(sub.key)} value={reference} />
143
144
  {/each}
144
145
  {/if}
145
- <input
146
+ <LiteFileInput
146
147
  id={fieldId(sub.key)}
147
148
  name={fieldName(sub.key)}
148
- type="file"
149
149
  class="lite-input"
150
150
  required={sub.required
151
151
  && !isDraft
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import LiteFileInput from './LiteFileInput.svelte';
3
+
2
4
  export interface AnnotationItem {
3
5
  id: string;
4
6
  type: string;
@@ -50,7 +52,7 @@
50
52
  {#if formAction}
51
53
  <div class="sv-lite-anno-upload">
52
54
  <form method="POST" action={formAction} enctype="multipart/form-data">
53
- <input type="file" name="annotatedImage" accept="image/*" class="sv-lite-file-input" />
55
+ <LiteFileInput name="annotatedImage" accept="image/*" class="sv-lite-file-input" />
54
56
  <button type="submit" class="sv-lite-upload-btn">Upload Markup</button>
55
57
  </form>
56
58
  </div>
@@ -0,0 +1,40 @@
1
+ <script lang="ts">
2
+ import { t } from '@svadmin/core/i18n';
3
+ import type { HTMLInputAttributes } from 'svelte/elements';
4
+
5
+ type Props = Omit<HTMLInputAttributes, 'type' | 'value' | 'files'> & {
6
+ files?: FileList;
7
+ label?: string;
8
+ class?: string;
9
+ webkitdirectory?: boolean;
10
+ directory?: boolean;
11
+ };
12
+
13
+ let {
14
+ files = $bindable(),
15
+ label,
16
+ class: className = '',
17
+ ...restProps
18
+ }: Props = $props();
19
+
20
+ const selectedFiles = $derived(files ? Array.from(files) : []);
21
+ const selectedLabel = $derived.by(() => {
22
+ const firstFile = selectedFiles[0];
23
+ if (firstFile === undefined) return t('common.noFileChosen') || 'No file chosen';
24
+ if (selectedFiles.length === 1) return firstFile.name;
25
+ return t('common.filesSelected', { count: selectedFiles.length }) || `${selectedFiles.length} files selected`;
26
+ });
27
+ </script>
28
+
29
+ <span class={`lite-file-picker ${className}`} data-disabled={restProps.disabled ? 'true' : undefined}>
30
+ <input
31
+ class="lite-file-picker__native"
32
+ type="file"
33
+ bind:files
34
+ {...restProps}
35
+ />
36
+ <span class="lite-file-picker__visual" aria-hidden="true">
37
+ <span class="lite-file-picker__button">{label ?? (t('common.chooseFile') || 'Choose file')}</span>
38
+ <span class="lite-file-picker__name">{selectedLabel}</span>
39
+ </span>
40
+ </span>
@@ -0,0 +1,11 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ type Props = Omit<HTMLInputAttributes, 'type' | 'value' | 'files'> & {
3
+ files?: FileList;
4
+ label?: string;
5
+ class?: string;
6
+ webkitdirectory?: boolean;
7
+ directory?: boolean;
8
+ };
9
+ declare const LiteFileInput: import("svelte").Component<Props, {}, "files">;
10
+ type LiteFileInput = ReturnType<typeof LiteFileInput>;
11
+ export default LiteFileInput;
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ name: string;
4
+ label: string;
5
+ id?: string;
6
+ form?: string;
7
+ accept?: string;
8
+ multiple?: boolean;
9
+ required?: boolean;
10
+ disabled?: boolean;
11
+ /** Explicit undefined clears a previously displayed server error. */
12
+ error?: string | undefined;
13
+ class?: string;
14
+ }
15
+
16
+ let {
17
+ name,
18
+ label,
19
+ id,
20
+ form,
21
+ accept,
22
+ multiple = false,
23
+ required = false,
24
+ disabled = false,
25
+ error,
26
+ class: className = '',
27
+ }: Props = $props();
28
+
29
+ const generatedId = $props.id();
30
+ const controlId = $derived(id ?? generatedId);
31
+ </script>
32
+
33
+ <div class={`lite-form-group ${className}`}>
34
+ <label for={controlId}>{label}</label>
35
+ <!-- 保留可见原生控件,无客户端脚本时仍可显示浏览器选择状态。 -->
36
+ <input
37
+ type="file"
38
+ id={controlId}
39
+ {name}
40
+ {form}
41
+ {accept}
42
+ {multiple}
43
+ {required}
44
+ {disabled}
45
+ class:lite-input-error={!!error}
46
+ class="lite-input"
47
+ aria-invalid={error ? 'true' : undefined}
48
+ aria-describedby={error ? `${controlId}-error` : undefined}
49
+ />
50
+ {#if error}
51
+ <p id={`${controlId}-error`} class="lite-error-text" role="alert">{error}</p>
52
+ {/if}
53
+ </div>
@@ -0,0 +1,16 @@
1
+ interface Props {
2
+ name: string;
3
+ label: string;
4
+ id?: string;
5
+ form?: string;
6
+ accept?: string;
7
+ multiple?: boolean;
8
+ required?: boolean;
9
+ disabled?: boolean;
10
+ /** Explicit undefined clears a previously displayed server error. */
11
+ error?: string | undefined;
12
+ class?: string;
13
+ }
14
+ declare const LiteFileUpload: import("svelte").Component<Props, {}, "">;
15
+ type LiteFileUpload = ReturnType<typeof LiteFileUpload>;
16
+ export default LiteFileUpload;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { CrudOperator, FieldDefinition, Filter } from '@svadmin/core';
3
+ import { readFilterForm } from '../filter-form.js';
3
4
  import { t } from '@svadmin/core/i18n';
4
5
 
5
6
  export interface FilterRuleItem {
@@ -9,10 +10,13 @@
9
10
  value: unknown;
10
11
  }
11
12
 
13
+ type FilterNode =
14
+ | { kind: 'group'; id: string; operator: 'and' | 'or'; children: FilterNode[] }
15
+ | { kind: 'rule'; id: string; field: string; operator: CrudOperator; value: unknown };
16
+
12
17
  interface Props {
13
18
  fields?: FieldDefinition[];
14
19
  filters?: Filter[];
15
- logicalOperator?: 'and' | 'or';
16
20
  action?: string;
17
21
  method?: 'GET' | 'POST';
18
22
  disabled?: boolean;
@@ -22,11 +26,28 @@
22
26
  let {
23
27
  fields = [],
24
28
  filters = [],
25
- logicalOperator = 'and',
26
29
  action = '',
30
+ method = 'GET',
27
31
  disabled = false,
32
+ onApply,
28
33
  }: Props = $props();
29
34
 
35
+ const formId = $props.id();
36
+ let formError = $state('');
37
+
38
+ function handleSubmit(event: SubmitEvent): void {
39
+ if (disabled) { event.preventDefault(); return; }
40
+ // 无回调时保留原生 GET/POST;添加/删除规则仍交给原生服务端 action。
41
+ if (!onApply || (event.submitter instanceof HTMLButtonElement && event.submitter.name === '_action')) return;
42
+ event.preventDefault();
43
+ if (!(event.currentTarget instanceof HTMLFormElement)) return;
44
+ let next: Filter[];
45
+ try { next = readFilterForm(filters, availableFields, new FormData(event.currentTarget)); }
46
+ catch { formError = '筛选条件无效,请检查字段、运算符和值。'; return; }
47
+ formError = '';
48
+ onApply(next);
49
+ }
50
+
30
51
  const operatorOptions: { value: CrudOperator; label: string }[] = [
31
52
  { value: 'eq', label: '= 等于' },
32
53
  { value: 'ne', label: '!= 不等于' },
@@ -36,170 +57,103 @@
36
57
  { value: 'gte', label: '>= 大于等于' },
37
58
  { value: 'lt', label: '< 小于' },
38
59
  { value: 'lte', label: '<= 小于等于' },
39
- { value: 'null', label: '为空 (null)' },
60
+ { value: 'null', label: '为空' },
40
61
  { value: 'nnull', label: '不为空' },
41
62
  ];
42
63
 
43
- // Flatten initial filters or fallback to 1 empty rule
44
- const initialRules = $derived.by(() => {
45
- if (filters.length > 0) {
46
- return filters.map((f, i) => ({
47
- id: `rule_${i}`,
48
- field: 'field' in f ? String(f.field) : (fields[0]?.key ?? ''),
49
- operator: ('operator' in f ? f.operator : 'eq') as CrudOperator,
50
- value: 'value' in f ? f.value : '',
51
- }));
64
+ const availableFields = $derived(fields.filter(field => field.filterable !== false));
65
+
66
+ function nodeFromFilter(filter: Filter, path: string): FilterNode {
67
+ if ('field' in filter) {
68
+ return { kind: 'rule', id: `rule-${path}`, field: filter.field, operator: filter.operator, value: filter.value };
52
69
  }
53
- return [
54
- {
55
- id: 'rule_0',
56
- field: fields[0]?.key ?? '',
57
- operator: 'eq' as CrudOperator,
58
- value: '',
59
- },
60
- ];
61
- });
70
+ return {
71
+ kind: 'group',
72
+ id: `group-${path}`,
73
+ operator: filter.operator,
74
+ children: filter.value.map((child, index) => nodeFromFilter(child, `${path}-${index}`)),
75
+ };
76
+ }
77
+
78
+ const nodes = $derived(filters.map((filter, index) => nodeFromFilter(filter, String(index))));
79
+
80
+ function readValue(node: FilterNode): unknown {
81
+ return node.kind === 'rule' ? node.value : undefined;
82
+ }
83
+
62
84
  </script>
63
85
 
64
86
  <div class="lite-filter-builder lite-form-group">
65
87
  <div class="lite-filter-header">
66
- <div class="lite-filter-title">
67
- <strong>{t('common.filters') || '高级多条件筛选'}</strong>
68
- <select
69
- name="logical_op"
70
- class="lite-select lite-select-sm"
71
- style="width: 120px; display: inline-block; margin-left: 8px;"
72
- value={logicalOperator}
73
- {disabled}
74
- >
75
- <option value="and">符合全部 (AND)</option>
76
- <option value="or">符合任一 (OR)</option>
77
- </select>
78
- </div>
79
- <div>
80
- <button type="submit" name="_action" value="add_filter_rule" class="lite-btn lite-btn-sm" {disabled}>
81
- + 添加条件
82
- </button>
83
- </div>
88
+ <strong>{t('common.filters') || '高级多条件筛选'}</strong>
89
+ <button type="submit" form={formId} class="lite-btn lite-btn-primary lite-btn-sm" disabled={disabled}>
90
+ 应用筛选
91
+ </button>
84
92
  </div>
85
93
 
86
- <div class="lite-filter-rules">
87
- {#each initialRules as rule, idx (rule.id)}
88
- <div class="lite-filter-row">
89
- <!-- Field select -->
90
- <select
91
- name="filter_field_{idx}"
92
- class="lite-select lite-filter-field"
93
- value={rule.field}
94
- {disabled}
95
- >
96
- {#each fields as fld (fld.key)}
97
- <option value={fld.key}>{fld.label || fld.key}</option>
98
- {/each}
99
- </select>
100
-
101
- <!-- Operator select -->
102
- <select
103
- name="filter_op_{idx}"
104
- class="lite-select lite-filter-op"
105
- value={rule.operator}
106
- {disabled}
107
- >
108
- {#each operatorOptions as op (op.value)}
109
- <option value={op.value}>{op.label}</option>
94
+ {#if formError}<p role="alert">{formError}</p>{/if}
95
+ <form id={formId} {action} {method} onsubmit={handleSubmit} class="lite-filter-form">
96
+ {#snippet renderNode(node: FilterNode, path: string, isRoot = false)}
97
+ {#if node.kind === 'group'}
98
+ <fieldset class="lite-filter-group" data-testid={isRoot ? 'lite-filter-root' : 'lite-filter-group'}>
99
+ <legend>
100
+ <label>
101
+ 逻辑组
102
+ <select name={`filters[${path}][operator]`} value={node.operator} {disabled}>
103
+ <option value="and">AND (且)</option>
104
+ <option value="or">OR (或)</option>
105
+ </select>
106
+ </label>
107
+ </legend>
108
+ {#each node.children as child, index (child.id)}
109
+ {@render renderNode(child, `${path}.value.${index}`)}
110
+ {:else}
111
+ <p class="lite-hint">暂无筛选条件</p>
110
112
  {/each}
111
- </select>
112
-
113
- <!-- Value input -->
114
- {#if rule.operator !== 'null' && rule.operator !== 'nnull'}
115
- <input
116
- type="text"
117
- name="filter_val_{idx}"
118
- class="lite-input lite-filter-val"
119
- value={String(rule.value ?? '')}
120
- placeholder="输入筛选值..."
121
- {disabled}
122
- />
123
- {/if}
124
-
125
- <!-- Remove row button -->
126
- <button
127
- type="submit"
128
- name="_action"
129
- value="remove_filter_rule_{idx}"
130
- class="lite-btn lite-btn-sm lite-btn-danger"
131
- {disabled}
132
- title="删除此行条件"
133
- >
134
- ✕
135
- </button>
136
- </div>
137
- {/each}
138
- </div>
113
+ <button type="submit" name="_action" value={`add_filter_rule:${path}`} class="lite-btn lite-btn-sm" {disabled}>添加条件</button>
114
+ <button type="submit" name="_action" value={`add_filter_group:${path}`} class="lite-btn lite-btn-sm" {disabled}>添加条件组</button>
115
+ </fieldset>
116
+ {:else}
117
+ <div class="lite-filter-row" data-testid="lite-filter-rule">
118
+ <select name={`filters[${path}][field]`} value={node.field} {disabled}>
119
+ {#each availableFields as field (field.key)}<option value={field.key}>{field.label || field.key}</option>{/each}
120
+ </select>
121
+ <select name={`filters[${path}][operator]`} value={node.operator} {disabled}>
122
+ {#each operatorOptions as operator (operator.value)}<option value={operator.value}>{operator.label}</option>{/each}
123
+ </select>
124
+ {#if node.operator !== 'null' && node.operator !== 'nnull'}
125
+ {#if availableFields.find(field => field.key === node.field)?.type === 'boolean'}
126
+ <select name={`filters[${path}][value]`} value={String(readValue(node) ?? '')} {disabled}>
127
+ <option value="">请选择</option><option value="true">是</option><option value="false">否</option>
128
+ </select>
129
+ {:else}
130
+ <input name={`filters[${path}][value]`} type={availableFields.find(field => field.key === node.field)?.type === 'number' ? 'number' : 'text'} value={String(readValue(node) ?? '')} {disabled} />
131
+ {/if}
132
+ {/if}
133
+ <button type="submit" name="_action" value={`remove_filter_rule:${path}`} class="lite-btn lite-btn-sm lite-btn-danger" {disabled} title="删除此行条件">删除</button>
134
+ </div>
135
+ {/if}
136
+ {/snippet}
139
137
 
140
- <div class="lite-filter-footer">
141
- <button type="submit" class="lite-btn lite-btn-primary">
142
- 应用筛选
143
- </button>
144
- <a href={action || '?'} class="lite-btn" style="margin-left: 8px;">
145
- 重置
146
- </a>
147
- </div>
138
+ {#if nodes.length === 0}
139
+ <p class="lite-hint">暂无筛选条件,请先添加规则。</p>
140
+ <button type="submit" name="_action" value="add_filter_rule:root" class="lite-btn lite-btn-sm" {disabled}>添加条件</button>
141
+ {:else}
142
+ {#each nodes as node, index (node.id)}
143
+ {@render renderNode(node, String(index), node.kind === 'group' && nodes.length === 1)}
144
+ {/each}
145
+ {/if}
146
+ </form>
148
147
  </div>
149
148
 
150
149
  <style>
151
- .lite-filter-builder {
152
- border: 1px solid #cbd5e1;
153
- border-radius: 6px;
154
- padding: 14px;
155
- background: #f8fafc;
156
- margin-bottom: 16px;
157
- }
158
- .lite-filter-header {
159
- display: flex;
160
- justify-content: space-between;
161
- align-items: center;
162
- margin-bottom: 12px;
163
- }
164
- .lite-filter-rules {
165
- display: flex;
166
- flex-direction: column;
167
- margin-bottom: 12px;
168
- }
169
- .lite-filter-row {
170
- display: flex;
171
- align-items: center;
172
- background: #ffffff;
173
- padding: 8px;
174
- border: 1px solid #e2e8f0;
175
- border-radius: 4px;
176
- margin-bottom: 8px;
177
- }
178
- .lite-filter-row:last-child {
179
- margin-bottom: 0;
180
- }
181
- .lite-filter-row > * {
182
- margin-right: 8px;
183
- }
184
- .lite-filter-row > *:last-child {
185
- margin-right: 0;
186
- }
187
- .lite-filter-field {
188
- flex: 2;
189
- min-width: 130px;
190
- }
191
- .lite-filter-op {
192
- flex: 2;
193
- min-width: 120px;
194
- }
195
- .lite-filter-val {
196
- flex: 3;
197
- min-width: 140px;
198
- }
199
- .lite-filter-footer {
200
- display: flex;
201
- align-items: center;
202
- border-top: 1px solid #e2e8f0;
203
- padding-top: 10px;
204
- }
150
+ .lite-filter-builder { border: 1px solid #cbd5e1; border-radius: 6px; padding: 14px; background: #f8fafc; margin-bottom: 16px; }
151
+ .lite-filter-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
152
+ .lite-filter-group { border: 1px solid #e2e8f0; border-radius: 4px; padding: 10px; margin-bottom: 8px; background: #fff; }
153
+ .lite-filter-row { display: flex; align-items: center; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px; margin-bottom: 8px; background: #fff; }
154
+ .lite-filter-row > * { min-width: 120px; flex: 1; }
155
+ .lite-filter-row > * + * { margin-left: 8px; }
156
+ .lite-filter-row .lite-btn { flex: 0 0 auto; }
157
+ .lite-hint { color: #64748b; font-size: 12px; }
158
+ .lite-btn { margin-right: 6px; }
205
159
  </style>
@@ -8,7 +8,6 @@ export interface FilterRuleItem {
8
8
  interface Props {
9
9
  fields?: FieldDefinition[];
10
10
  filters?: Filter[];
11
- logicalOperator?: 'and' | 'or';
12
11
  action?: string;
13
12
  method?: 'GET' | 'POST';
14
13
  disabled?: boolean;
@@ -9,6 +9,7 @@
9
9
  import { fieldToInputType, fieldToPlaceholder } from '../schema-generator';
10
10
  import { isExplicitBooleanTrue } from '../value-normalization';
11
11
  import LiteArrayField from './LiteArrayField.svelte';
12
+ import LiteFileInput from './LiteFileInput.svelte';
12
13
 
13
14
  interface Props {
14
15
  fields: FieldDefinition[];
@@ -156,8 +157,7 @@
156
157
  {/if}
157
158
  </select>
158
159
  {:else if inputType === 'file'}
159
- <input
160
- type="file"
160
+ <LiteFileInput
161
161
  name={field.key}
162
162
  id={field.key}
163
163
  class="lite-input {hasError ? 'lite-input-error' : ''}"
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { FieldDefinition } from '@svadmin/core';
3
+ import LiteFileInput from './LiteFileInput.svelte';
3
4
 
4
5
  interface Props {
5
6
  resourceName: string;
@@ -25,7 +26,7 @@
25
26
  <form {action} {method} enctype="multipart/form-data" class="lite-import-form">
26
27
  <div class="lite-file-row">
27
28
  <label for="import_file" class="lite-label">Select CSV / JSON file:</label>
28
- <input type="file" id="import_file" name="file" accept=".csv,.json" class="lite-input" required />
29
+ <LiteFileInput id="import_file" name="file" accept=".csv,.json" class="lite-input" required />
29
30
  </div>
30
31
 
31
32
  {#if fields.length > 0}