@svadmin/lite 0.12.0 → 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 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.
@@ -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;
@@ -1,8 +1,17 @@
1
1
  <script lang="ts">
2
- import { definedOptions } from '@svadmin/core/options';
2
+ interface JsonSchema {
3
+ type?: string;
4
+ title?: string;
5
+ description?: string;
6
+ properties?: Record<string, JsonSchema>;
7
+ items?: JsonSchema;
8
+ enum?: Array<string | number | boolean | null>;
9
+ default?: unknown;
10
+ required?: string[];
11
+ }
3
12
 
4
13
  interface Props {
5
- schema: Record<string, unknown>;
14
+ schema: JsonSchema;
6
15
  value?: Record<string, unknown>;
7
16
  action?: string;
8
17
  method?: 'get' | 'post' | 'dialog' | 'GET' | 'POST' | 'DIALOG' | null;
@@ -11,94 +20,149 @@
11
20
 
12
21
  let {
13
22
  schema,
14
- value = {},
23
+ value = $bindable({}),
15
24
  action = '',
16
25
  method = 'POST',
17
26
  submitText = 'Submit Form',
18
27
  }: Props = $props();
19
28
 
20
- interface FieldMeta {
21
- key: string;
22
- title: string;
23
- type: string;
24
- enum?: Array<string | number>;
25
- required?: boolean;
29
+ function cloneDefault(node: JsonSchema): unknown {
30
+ if (node.default !== undefined) return structuredClone(node.default);
31
+ if (node.type === 'object' || node.properties) {
32
+ return Object.fromEntries(
33
+ Object.entries(node.properties ?? {})
34
+ .map(([key, child]) => [key, cloneDefault(child)])
35
+ .filter(([, child]) => child !== undefined),
36
+ );
37
+ }
38
+ if (node.type === 'array') return [];
39
+ return undefined;
26
40
  }
27
41
 
28
- const properties = $derived.by<FieldMeta[]>(() => {
29
- const propsObj = (schema['properties'] ?? {}) as Record<string, Record<string, unknown>>;
30
- const requiredKeys = Array.isArray(schema['required']) ? (schema['required'] as string[]) : [];
42
+ function mergeDefaults(node: JsonSchema, current: unknown): unknown {
43
+ if (node.type === 'array') {
44
+ if (!Array.isArray(current)) return cloneDefault(node);
45
+ return current.map(item => node.items ? mergeDefaults(node.items, item) : item);
46
+ }
47
+ if (node.type !== 'object' && !node.properties) return current ?? cloneDefault(node);
48
+ const source = current && typeof current === 'object' && !Array.isArray(current)
49
+ ? current as Record<string, unknown>
50
+ : {};
51
+ const result: Record<string, unknown> = { ...source };
52
+ for (const [key, child] of Object.entries(node.properties ?? {})) {
53
+ const next = mergeDefaults(child, result[key]);
54
+ if (next !== undefined) result[key] = next;
55
+ }
56
+ return result;
57
+ }
31
58
 
32
- return Object.entries(propsObj).map(([key, propDef]) => (definedOptions({
33
- key,
34
- title: String(propDef['title'] ?? key),
35
- type: String(propDef['type'] ?? 'string'),
36
- enum: Array.isArray(propDef['enum']) ? (propDef['enum'] as Array<string | number>) : undefined,
37
- required: requiredKeys.includes(key),
38
- })));
59
+ $effect(() => {
60
+ const next = mergeDefaults(schema, value);
61
+ if (JSON.stringify(next) !== JSON.stringify(value)) value = next as Record<string, unknown>;
39
62
  });
63
+
64
+ function readPath(path: string[]): unknown {
65
+ return path.reduce<unknown>((current, key) => (
66
+ current && typeof current === 'object' ? (current as Record<string, unknown>)[key] : undefined
67
+ ), value);
68
+ }
69
+
70
+ function writePath(path: string[], nextValue: unknown): void {
71
+ const next = structuredClone(value);
72
+ let target: Record<string, unknown> = next;
73
+ for (const key of path.slice(0, -1)) {
74
+ const child = target[key];
75
+ target[key] = child && typeof child === 'object' ? child : {};
76
+ target = target[key] as Record<string, unknown>;
77
+ }
78
+ const lastKey = path[path.length - 1];
79
+ if (lastKey !== undefined) target[lastKey] = nextValue;
80
+ value = next;
81
+ }
82
+
83
+ function enumValue(raw: string, options: JsonSchema['enum']): unknown {
84
+ return options?.find(option => String(option) === raw) ?? raw;
85
+ }
86
+
87
+ function addArrayItem(path: string[], itemSchema: JsonSchema): void {
88
+ const current = readPath(path);
89
+ const items = Array.isArray(current) ? current : [];
90
+ writePath(path, [...items, cloneDefault(itemSchema) ?? (itemSchema.type === 'object' ? {} : '')]);
91
+ }
92
+
93
+ function removeArrayItem(path: string[], index: number): void {
94
+ const current = readPath(path);
95
+ if (Array.isArray(current)) writePath(path, current.filter((_, itemIndex) => itemIndex !== index));
96
+ }
40
97
  </script>
41
98
 
42
99
  <div class="lite-schema-form-card">
43
- {#if schema['title']}
44
- <div class="lite-schema-form-header">
45
- <strong>{schema['title']}</strong>
46
- </div>
47
- {/if}
100
+ {#if schema.title}<div class="lite-schema-form-header"><strong>{schema.title}</strong></div>{/if}
101
+ {#if schema.description}<p class="lite-schema-description">{schema.description}</p>{/if}
48
102
 
49
103
  <form {action} {method} class="lite-schema-form">
50
- {#each properties as field (field.key)}
51
- <div class="lite-schema-field">
52
- <label for="lite_json_{field.key}" class="lite-label">
53
- {field.title} {#if field.required}<span style="color: #ef4444;">*</span>{/if}
54
- </label>
55
- {#if field.enum}
56
- <select id="lite_json_{field.key}" name={field.key} class="lite-select" required={field.required}>
57
- {#each field.enum as option (option)}
58
- <option value={String(option)} selected={String(value[field.key]) === String(option)}>
59
- {option}
60
- </option>
61
- {/each}
62
- </select>
63
- {:else if field.type === 'boolean'}
64
- <input id="lite_json_{field.key}" type="checkbox" name={field.key} checked={Boolean(value[field.key])} />
65
- {:else if field.type === 'number' || field.type === 'integer'}
66
- <input id="lite_json_{field.key}" type="number" name={field.key} value={Number(value[field.key] ?? 0)} class="lite-input" required={field.required} />
67
- {:else}
68
- <input id="lite_json_{field.key}" type="text" name={field.key} value={String(value[field.key] ?? '')} class="lite-input" required={field.required} />
69
- {/if}
70
- </div>
71
- {/each}
104
+ {#snippet renderField(node: JsonSchema, path: string[], title: string, required = false)}
105
+ {@const current = readPath(path)}
106
+ {@const id = `lite_json_${path.join('_')}`}
107
+ {#if node.type === 'object' || node.properties}
108
+ <fieldset class="lite-schema-fieldset">
109
+ <legend>{title}</legend>
110
+ {#if node.description}<p class="lite-schema-description">{node.description}</p>{/if}
111
+ {#each Object.entries(node.properties ?? {}) as [key, child] (key)}
112
+ {@render renderField(child, [...path, key], child.title ?? key, node.required?.includes(key) ?? false)}
113
+ {/each}
114
+ </fieldset>
115
+ {:else if node.type === 'array'}
116
+ {@const items = Array.isArray(current) ? current : []}
117
+ <fieldset class="lite-schema-fieldset">
118
+ <legend>{title}</legend>
119
+ {#if node.description}<p class="lite-schema-description">{node.description}</p>{/if}
120
+ {#each items as _, index (index)}
121
+ {@render renderField(node.items ?? { type: 'string' }, [...path, String(index)], `${title} ${index + 1}`)}
122
+ <button type="button" class="lite-btn lite-btn-sm" onclick={() => removeArrayItem(path, index)}>Remove</button>
123
+ {:else}
124
+ <span class="lite-hint">No items</span>
125
+ {/each}
126
+ <button type="button" class="lite-btn lite-btn-sm" onclick={() => addArrayItem(path, node.items ?? { type: 'string' })}>Add item</button>
127
+ </fieldset>
128
+ {:else}
129
+ <div class="lite-schema-field">
130
+ <label for={id} class="lite-label">{title}{#if required}<span aria-hidden="true">*</span>{/if}</label>
131
+ {#if node.description}<p class="lite-schema-description">{node.description}</p>{/if}
132
+ {#if node.enum}
133
+ <select id={id} required={required} value={String(current ?? '')} onchange={(event) => writePath(path, enumValue(event.currentTarget.value, node.enum))} class="lite-select">
134
+ <option value="">Select an option</option>
135
+ {#each node.enum as option (String(option))}<option value={String(option)}>{String(option)}</option>{/each}
136
+ </select>
137
+ {:else if node.type === 'boolean'}
138
+ <input id={id} type="checkbox" checked={Boolean(current)} onchange={(event) => writePath(path, event.currentTarget.checked)} />
139
+ {:else if node.type === 'number' || node.type === 'integer'}
140
+ <input id={id} type="number" required={required} value={current === undefined ? '' : String(current)} oninput={(event) => writePath(path, event.currentTarget.value === '' ? undefined : Number(event.currentTarget.value))} class="lite-input" />
141
+ {:else}
142
+ <input id={id} type="text" required={required} value={String(current ?? '')} oninput={(event) => writePath(path, event.currentTarget.value)} class="lite-input" />
143
+ {/if}
144
+ </div>
145
+ {/if}
146
+ {/snippet}
72
147
 
148
+ {#each Object.entries(schema.properties ?? {}) as [key, node] (key)}
149
+ {@render renderField(node, [key], node.title ?? key, schema.required?.includes(key) ?? false)}
150
+ {/each}
73
151
  <div class="lite-schema-footer">
74
- <button type="submit" class="lite-btn lite-btn-primary lite-btn-sm">
75
- {submitText}
76
- </button>
152
+ <button type="submit" class="lite-btn lite-btn-primary lite-btn-sm">{submitText}</button>
77
153
  </div>
78
154
  </form>
79
155
  </div>
80
156
 
81
157
  <style>
82
- .lite-schema-form-card {
83
- background: #ffffff;
84
- border: 1px solid #cbd5e1;
85
- border-radius: 6px;
86
- padding: 16px;
87
- margin-bottom: 16px;
88
- }
89
- .lite-schema-form-header {
90
- padding-bottom: 8px;
91
- border-bottom: 1px solid #e2e8f0;
92
- margin-bottom: 12px;
93
- font-size: 14px;
94
- }
95
- .lite-schema-field {
96
- margin-bottom: 12px;
97
- }
98
- .lite-schema-footer {
99
- display: flex;
100
- justify-content: flex-end;
101
- padding-top: 12px;
102
- border-top: 1px solid #e2e8f0;
103
- }
158
+ .lite-schema-form-card { background: #fff; border: 1px solid #cbd5e1; border-radius: 6px; padding: 16px; margin-bottom: 16px; }
159
+ .lite-schema-form-header { padding-bottom: 8px; border-bottom: 1px solid #e2e8f0; margin-bottom: 12px; font-size: 14px; }
160
+ .lite-schema-fieldset { border: 1px solid #e2e8f0; border-radius: 4px; padding: 12px; margin: 0 0 12px; }
161
+ .lite-schema-field { margin-bottom: 12px; }
162
+ .lite-schema-fieldset > .lite-schema-field:last-child { margin-bottom: 0; }
163
+ .lite-schema-footer { display: flex; justify-content: flex-end; padding-top: 12px; border-top: 1px solid #e2e8f0; }
164
+ .lite-label { display: block; font-size: 12px; color: #475569; margin-bottom: 4px; }
165
+ .lite-schema-description, .lite-hint { font-size: 12px; color: #64748b; }
166
+ .lite-input, .lite-select { width: 100%; }
167
+ .lite-btn { margin-right: 6px; }
104
168
  </style>
@@ -1,10 +1,20 @@
1
+ interface JsonSchema {
2
+ type?: string;
3
+ title?: string;
4
+ description?: string;
5
+ properties?: Record<string, JsonSchema>;
6
+ items?: JsonSchema;
7
+ enum?: Array<string | number | boolean | null>;
8
+ default?: unknown;
9
+ required?: string[];
10
+ }
1
11
  interface Props {
2
- schema: Record<string, unknown>;
12
+ schema: JsonSchema;
3
13
  value?: Record<string, unknown>;
4
14
  action?: string;
5
15
  method?: 'get' | 'post' | 'dialog' | 'GET' | 'POST' | 'DIALOG' | null;
6
16
  submitText?: string;
7
17
  }
8
- declare const LiteJsonSchemaForm: import("svelte").Component<Props, {}, "">;
18
+ declare const LiteJsonSchemaForm: import("svelte").Component<Props, {}, "value">;
9
19
  type LiteJsonSchemaForm = ReturnType<typeof LiteJsonSchemaForm>;
10
20
  export default LiteJsonSchemaForm;
@@ -0,0 +1,3 @@
1
+ import type { FieldDefinition, Filter } from '@svadmin/core';
2
+ /** 使用已有拓扑读取当前表单值,不把用户字段名解释成对象路径。服务端仍须独立授权。 */
3
+ export declare function readFilterForm(filters: readonly Filter[], fields: readonly FieldDefinition[], data: FormData): Filter[];
@@ -0,0 +1,34 @@
1
+ /** 使用已有拓扑读取当前表单值,不把用户字段名解释成对象路径。服务端仍须独立授权。 */
2
+ export function readFilterForm(filters, fields, data) {
3
+ const operators = new Set(['eq', 'ne', 'contains', 'ncontains', 'gt', 'gte', 'lt', 'lte', 'null', 'nnull']);
4
+ function read(filter, path) {
5
+ const get = (key) => data.get(`filters[${path}][${key}]`);
6
+ if (!('field' in filter)) {
7
+ const operator = get('operator');
8
+ if (operator !== 'and' && operator !== 'or')
9
+ throw new TypeError('Invalid filter group');
10
+ return { operator, value: filter.value.map((child, index) => read(child, `${path}.value.${index}`)) };
11
+ }
12
+ const field = fields.find(candidate => candidate.key === get('field') && candidate.filterable !== false);
13
+ const operator = get('operator');
14
+ if (!field || typeof operator !== 'string' || !operators.has(operator))
15
+ throw new TypeError('Invalid filter rule');
16
+ let value = get('value');
17
+ if (operator === 'null' || operator === 'nnull')
18
+ value = null;
19
+ else if (typeof value !== 'string')
20
+ throw new TypeError('Invalid filter value');
21
+ else if (['number', 'currency', 'percent'].includes(field.type)) {
22
+ if (!value.trim() || !Number.isFinite(Number(value)))
23
+ throw new TypeError('Invalid numeric filter');
24
+ value = Number(value);
25
+ }
26
+ else if (field.type === 'boolean') {
27
+ if (value !== 'true' && value !== 'false')
28
+ throw new TypeError('Invalid boolean filter');
29
+ value = value === 'true';
30
+ }
31
+ return { field: field.key, operator: operator, value };
32
+ }
33
+ return filters.map((filter, index) => read(filter, String(index)));
34
+ }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export { default as LiteTable } from './components/LiteTable.svelte';
9
9
  export { default as LitePagination } from './components/LitePagination.svelte';
10
10
  export { default as LiteForm } from './components/LiteForm.svelte';
11
11
  export { default as LiteFileInput } from './components/LiteFileInput.svelte';
12
+ export { default as LiteFileUpload } from './components/LiteFileUpload.svelte';
12
13
  export { default as LiteLogin } from './components/LiteLogin.svelte';
13
14
  export { default as LiteShow } from './components/LiteShow.svelte';
14
15
  export { default as LiteSearch } from './components/LiteSearch.svelte';
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export { default as LiteTable } from './components/LiteTable.svelte';
13
13
  export { default as LitePagination } from './components/LitePagination.svelte';
14
14
  export { default as LiteForm } from './components/LiteForm.svelte';
15
15
  export { default as LiteFileInput } from './components/LiteFileInput.svelte';
16
+ export { default as LiteFileUpload } from './components/LiteFileUpload.svelte';
16
17
  export { default as LiteLogin } from './components/LiteLogin.svelte';
17
18
  export { default as LiteShow } from './components/LiteShow.svelte';
18
19
  export { default as LiteSearch } from './components/LiteSearch.svelte';
@@ -58,7 +58,60 @@ function listRequestFilters(resource, url) {
58
58
  });
59
59
  }
60
60
  }
61
- return filters;
61
+ const structured = parseStructuredFilters(resource, url);
62
+ return structured.length > 0 ? structured : filters;
63
+ }
64
+ function parseStructuredFilters(resource, url) {
65
+ const entries = [...url.searchParams.entries()]
66
+ .map(([key, value]) => {
67
+ const match = /^filters\[([0-9]+(?:\.value\.[0-9]+)*)\]\[(field|operator|value)\]$/.exec(key);
68
+ return match ? { path: match[1], property: match[2], value } : undefined;
69
+ })
70
+ .filter((entry) => entry !== undefined);
71
+ if (entries.length === 0)
72
+ return [];
73
+ const roots = [];
74
+ for (const entry of entries) {
75
+ const segments = [...entry.path.matchAll(/\d+/g)].map(match => Number(match[0]));
76
+ const rootIndex = segments.shift();
77
+ if (rootIndex === undefined || !Number.isSafeInteger(rootIndex) || rootIndex < 0)
78
+ continue;
79
+ let node = roots[rootIndex];
80
+ if (!node) {
81
+ node = {};
82
+ roots[rootIndex] = node;
83
+ }
84
+ for (const childIndex of segments) {
85
+ if (!Number.isSafeInteger(childIndex) || childIndex < 0)
86
+ break;
87
+ node.children ??= [];
88
+ node = node.children[childIndex] ??= {};
89
+ }
90
+ node[entry.property] = entry.value;
91
+ }
92
+ function compile(node) {
93
+ const operator = node.operator;
94
+ if (node.field && operator && operator !== 'and' && operator !== 'or') {
95
+ const field = resource.fields.find(candidate => candidate.key === node.field);
96
+ if (!field)
97
+ return undefined;
98
+ if (operator === 'null' || operator === 'nnull') {
99
+ return { field: node.field, operator: operator, value: null };
100
+ }
101
+ if (node.value === undefined || node.value === '')
102
+ return undefined;
103
+ const value = field.type === 'number' || field.type === 'currency' || field.type === 'percent'
104
+ ? Number(node.value)
105
+ : field.type === 'boolean' ? node.value === 'true' : node.value;
106
+ return { field: node.field, operator: operator, value };
107
+ }
108
+ if ((operator === 'and' || operator === 'or') && node.children) {
109
+ const children = node.children.map(compile).filter((child) => child !== undefined);
110
+ return children.length > 0 ? { operator, value: children } : undefined;
111
+ }
112
+ return undefined;
113
+ }
114
+ return roots.map(compile).filter((filter) => filter !== undefined);
62
115
  }
63
116
  /**
64
117
  * Creates a SvelteKit `load` function that fetches a resource list
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/lite",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "SSR-first lightweight admin UI for @svadmin with optional progressive enhancement",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "peerDependencies": {
51
51
  "svelte": "^5.56.10",
52
- "@svadmin/core": ">=0.34.2 <0.53.0",
52
+ "@svadmin/core": ">=0.34.2 <0.54.0",
53
53
  "@sveltejs/kit": "^2.70.3"
54
54
  },
55
55
  "dependencies": {