@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.
- package/README.md +1 -19
- package/dist/compatibility.js +29 -29
- package/dist/components/LiteArrayItem.svelte +2 -2
- package/dist/components/LiteCanvasAnnotation.svelte +3 -1
- package/dist/components/LiteFileInput.svelte +40 -0
- package/dist/components/LiteFileInput.svelte.d.ts +11 -0
- package/dist/components/LiteFileUpload.svelte +53 -0
- package/dist/components/LiteFileUpload.svelte.d.ts +16 -0
- package/dist/components/LiteFilterBuilder.svelte +107 -153
- package/dist/components/LiteFilterBuilder.svelte.d.ts +0 -1
- package/dist/components/LiteForm.svelte +2 -2
- package/dist/components/LiteImportWizard.svelte +2 -1
- package/dist/components/LiteJsonSchemaForm.svelte +136 -70
- package/dist/components/LiteJsonSchemaForm.svelte.d.ts +12 -2
- package/dist/components/LiteMasterDetailView.svelte +2 -2
- package/dist/components/LiteMediaLibraryModal.svelte +5 -2
- package/dist/components/LiteSensitiveDataMask.svelte +2 -1
- package/dist/components/LiteShowField.svelte +6 -4
- package/dist/components/LiteSignaturePad.svelte +3 -1
- package/dist/components/advanced/LiteAutoSaveIndicator.svelte +2 -2
- package/dist/components/advanced/LiteToast.svelte +2 -2
- package/dist/components/advanced/LiteVirtualTable.svelte +6 -5
- package/dist/components/buttons/LiteCloneButton.svelte +3 -0
- package/dist/components/buttons/LiteCreateButton.svelte +3 -0
- package/dist/components/buttons/LiteDeleteButton.svelte +3 -0
- package/dist/components/buttons/LiteEditButton.svelte +3 -0
- package/dist/components/buttons/LiteExportButton.svelte +3 -0
- package/dist/components/buttons/LiteImportButton.svelte +5 -1
- package/dist/components/buttons/LiteListButton.svelte +3 -0
- package/dist/components/buttons/LiteRefreshButton.svelte +3 -0
- package/dist/components/buttons/LiteSaveButton.svelte +3 -0
- package/dist/components/buttons/LiteShowButton.svelte +3 -0
- package/dist/components/compatibility/LiteDirectoryUpload.svelte +4 -3
- package/dist/components/fields/LiteAvatarField.svelte +3 -3
- package/dist/components/fields/LiteCascader.svelte +4 -2
- package/dist/components/fields/LiteDateField.svelte +3 -0
- package/dist/components/fields/LiteDateRangeField.svelte +9 -4
- package/dist/components/fields/LiteFileField.svelte +2 -2
- package/dist/components/fields/LiteTreeSelect.svelte +4 -2
- package/dist/components/pages/LiteListPage.svelte +4 -2
- package/dist/filter-form.d.ts +3 -0
- package/dist/filter-form.js +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lite.css +86 -0
- package/dist/server-adapter.d.ts +15 -15
- package/dist/server-adapter.js +62 -8
- package/package.json +7 -14
- package/dist/polyfill-entry.d.ts +0 -2
- package/dist/polyfill-entry.js +0 -3
- 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`
|
|
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
|
package/dist/compatibility.js
CHANGED
|
@@ -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
|
|
17
|
+
if (!documentRecord || typeof documentRecord['createElement'] !== 'function')
|
|
18
18
|
return false;
|
|
19
|
-
const canvas = documentRecord
|
|
19
|
+
const canvas = documentRecord['createElement']('canvas');
|
|
20
20
|
const canvasRecord = asRecord(canvas);
|
|
21
|
-
if (!canvasRecord || typeof canvasRecord
|
|
21
|
+
if (!canvasRecord || typeof canvasRecord['getContext'] !== 'function')
|
|
22
22
|
return false;
|
|
23
|
-
return Boolean(canvasRecord
|
|
23
|
+
return Boolean(canvasRecord['getContext'](context));
|
|
24
24
|
}
|
|
25
25
|
function supportsDirectoryUpload(environment) {
|
|
26
|
-
const inputConstructor = asRecord(environment
|
|
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
|
|
60
|
-
const clipboard = asRecord(navigator?.clipboard);
|
|
61
|
-
const webAssembly = asRecord(env
|
|
62
|
-
const readableStream = asRecord(env
|
|
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
|
|
65
|
-
webgl: supportsCanvas(env
|
|
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
|
|
69
|
-
'event-source': typeof env
|
|
70
|
-
worker: typeof env
|
|
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
|
|
73
|
-
|| typeof env
|
|
72
|
+
'file-system-access': typeof env['showOpenFilePicker'] === 'function'
|
|
73
|
+
|| typeof env['showDirectoryPicker'] === 'function',
|
|
74
74
|
clipboard: hasFunction(clipboard, 'writeText'),
|
|
75
|
-
'broadcast-channel': typeof env
|
|
76
|
-
'intersection-observer': typeof env
|
|
77
|
-
'resize-observer': typeof env
|
|
78
|
-
'service-worker': asRecord(navigator?.serviceWorker) !== undefined,
|
|
79
|
-
'indexed-db': asRecord(env
|
|
80
|
-
notifications: typeof env
|
|
81
|
-
'media-capture': hasFunction(asRecord(navigator?.mediaDevices), 'getUserMedia')
|
|
82
|
-
|| typeof env
|
|
83
|
-
'web-rtc': typeof env
|
|
84
|
-
geolocation: asRecord(navigator?.geolocation) !== undefined,
|
|
85
|
-
'web-streams': asRecord(env
|
|
86
|
-
&& (hasFunction(readableStream, 'from') || typeof env
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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: '为空
|
|
60
|
+
{ value: 'null', label: '为空' },
|
|
40
61
|
{ value: 'nnull', label: '不为空' },
|
|
41
62
|
];
|
|
42
63
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
{disabled}
|
|
132
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
.lite-
|
|
159
|
-
|
|
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>
|
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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}
|