@svadmin/lite 0.12.0 → 0.14.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 -1
- package/dist/components/LiteErrorSummary.svelte +34 -0
- package/dist/components/LiteErrorSummary.svelte.d.ts +14 -0
- package/dist/components/LiteFileUpload.svelte +53 -0
- package/dist/components/LiteFileUpload.svelte.d.ts +16 -0
- package/dist/components/LiteFilterBuilder.svelte +168 -155
- package/dist/components/LiteFilterBuilder.svelte.d.ts +0 -1
- package/dist/components/LiteGanttChart.svelte +43 -10
- package/dist/components/LiteGanttChart.svelte.d.ts +6 -0
- package/dist/components/LiteJsonSchemaForm.svelte +182 -72
- package/dist/components/LiteJsonSchemaForm.svelte.d.ts +3 -2
- package/dist/components/LiteKanbanBoard.svelte +56 -8
- package/dist/components/LiteKanbanBoard.svelte.d.ts +4 -0
- package/dist/components/LitePivotTable.svelte +88 -58
- package/dist/components/LitePivotTable.svelte.d.ts +13 -1
- package/dist/components/LiteSpreadsheetView.svelte +55 -9
- package/dist/components/LiteSpreadsheetView.svelte.d.ts +8 -0
- package/dist/filter-form.d.ts +3 -0
- package/dist/filter-form.js +34 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/schema-form-data.d.ts +18 -0
- package/dist/schema-form-data.js +264 -0
- package/dist/server-adapter.js +170 -2
- package/package.json +2 -2
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { assertSchemaFormSchema, isSchemaFormNodeVisible, type SchemaFormSchema as JsonSchema } from '@svadmin/core/schema-form';
|
|
3
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
4
|
+
import { encodeSchemaFormArrayAction, schemaFormActionName, schemaFormFieldName } from '../schema-form-data';
|
|
3
5
|
|
|
4
6
|
interface Props {
|
|
5
|
-
schema:
|
|
7
|
+
schema: JsonSchema;
|
|
6
8
|
value?: Record<string, unknown>;
|
|
7
9
|
action?: string;
|
|
8
10
|
method?: 'get' | 'post' | 'dialog' | 'GET' | 'POST' | 'DIALOG' | null;
|
|
@@ -11,94 +13,202 @@
|
|
|
11
13
|
|
|
12
14
|
let {
|
|
13
15
|
schema,
|
|
14
|
-
value =
|
|
16
|
+
value = $bindable(undefined),
|
|
15
17
|
action = '',
|
|
16
18
|
method = 'POST',
|
|
17
19
|
submitText = 'Submit Form',
|
|
18
20
|
}: Props = $props();
|
|
21
|
+
const instanceId = $props.id();
|
|
22
|
+
const i18n = useTranslation();
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
type
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
function cloneDefault(node: JsonSchema): unknown {
|
|
25
|
+
if (node.default !== undefined) return structuredClone($state.snapshot(node.default));
|
|
26
|
+
if (node.type === 'null') return null;
|
|
27
|
+
if (node.type === 'object' || node.properties) {
|
|
28
|
+
return Object.fromEntries(
|
|
29
|
+
Object.entries(node.properties ?? {})
|
|
30
|
+
.map(([key, child]) => [key, cloneDefault(child)])
|
|
31
|
+
.filter(([, child]) => child !== undefined),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (node.type === 'array') return [];
|
|
35
|
+
return undefined;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
function mergeDefaults(node: JsonSchema, current: unknown, present: boolean, budget = { nodes: 0 }): unknown {
|
|
39
|
+
if (++budget.nodes > 1000) throw new Error('Schema form data limit exceeded.');
|
|
40
|
+
const candidate = present ? current : cloneDefault(node);
|
|
41
|
+
if (candidate === null || (present && candidate === undefined)) return candidate;
|
|
42
|
+
if (node.type === 'array') {
|
|
43
|
+
if (Array.isArray(candidate) && candidate.length > 1000 - budget.nodes) throw new Error('Schema form data limit exceeded.');
|
|
44
|
+
return Array.isArray(candidate)
|
|
45
|
+
? candidate.map(item => mergeDefaults(node.items ?? { type: 'string' }, item, true, budget))
|
|
46
|
+
: candidate;
|
|
47
|
+
}
|
|
48
|
+
if (node.type !== 'object' && !node.properties) return candidate;
|
|
49
|
+
if (candidate !== undefined && (typeof candidate !== 'object' || Array.isArray(candidate))) return candidate;
|
|
50
|
+
const source = (candidate ?? {}) as Record<string, unknown>;
|
|
51
|
+
const result: Record<string, unknown> = { ...source };
|
|
52
|
+
for (const [key, child] of Object.entries(node.properties ?? {})) {
|
|
53
|
+
const own = Object.hasOwn(source, key);
|
|
54
|
+
const next = mergeDefaults(child, own ? source[key] : undefined, own, budget);
|
|
55
|
+
if (next !== undefined || own) Object.defineProperty(result, key, { value: next, enumerable: true, configurable: true, writable: true });
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const resolved = $derived.by(() => {
|
|
61
|
+
try {
|
|
62
|
+
assertSchemaFormSchema(schema);
|
|
63
|
+
const next = mergeDefaults(schema, value, value !== undefined);
|
|
64
|
+
return { invalid: false, data: next && typeof next === 'object' && !Array.isArray(next) ? next as Record<string, unknown> : {} };
|
|
65
|
+
} catch {
|
|
66
|
+
return { invalid: true, data: {} as Record<string, unknown> };
|
|
67
|
+
}
|
|
39
68
|
});
|
|
69
|
+
const resolvedValue = $derived(resolved.data);
|
|
70
|
+
|
|
71
|
+
function readPath(path: string[]): unknown {
|
|
72
|
+
return path.reduce<unknown>((current, key) => (
|
|
73
|
+
current && typeof current === 'object' && Object.hasOwn(current, key) ? (current as Record<string, unknown>)[key] : undefined
|
|
74
|
+
), resolvedValue);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writePath(path: string[], nextValue: unknown): void {
|
|
78
|
+
const next = structuredClone($state.snapshot(resolvedValue));
|
|
79
|
+
let target: Record<string, unknown> = next;
|
|
80
|
+
for (const key of path.slice(0, -1)) {
|
|
81
|
+
const child = Object.hasOwn(target, key) ? target[key] : undefined;
|
|
82
|
+
const container = child && typeof child === 'object' ? child : {};
|
|
83
|
+
Object.defineProperty(target, key, { value: container, enumerable: true, configurable: true, writable: true });
|
|
84
|
+
target = container as Record<string, unknown>;
|
|
85
|
+
}
|
|
86
|
+
const lastKey = path[path.length - 1];
|
|
87
|
+
if (lastKey !== undefined) Object.defineProperty(target, lastKey, { value: nextValue, enumerable: true, configurable: true, writable: true });
|
|
88
|
+
value = next;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fieldName(path: string[]): string {
|
|
92
|
+
return schemaFormFieldName(path);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function enumToken(index: number): string {
|
|
96
|
+
return `__json_enum_${index}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function enumIndex(current: unknown, options: JsonSchema['enum']): number {
|
|
100
|
+
return options?.findIndex(option => Object.is(option, current)) ?? -1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function enumValue(raw: string, options: JsonSchema['enum']): unknown {
|
|
104
|
+
if (!/^__json_enum_(0|[1-9]\d*)$/.test(raw)) return undefined;
|
|
105
|
+
const index = Number(raw.slice('__json_enum_'.length));
|
|
106
|
+
return Number.isSafeInteger(index) ? options?.[index] : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function addArrayItem(path: string[], itemSchema: JsonSchema): void {
|
|
110
|
+
const current = readPath(path);
|
|
111
|
+
const items = Array.isArray(current) ? current : [];
|
|
112
|
+
const item = cloneDefault(itemSchema);
|
|
113
|
+
writePath(path, [...items, item === undefined ? (itemSchema.type === 'object' ? {} : '') : item]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function removeArrayItem(path: string[], index: number): void {
|
|
117
|
+
const current = readPath(path);
|
|
118
|
+
if (Array.isArray(current)) writePath(path, current.filter((_, itemIndex) => itemIndex !== index));
|
|
119
|
+
}
|
|
40
120
|
</script>
|
|
41
121
|
|
|
42
122
|
<div class="lite-schema-form-card">
|
|
43
|
-
{#if schema
|
|
44
|
-
|
|
45
|
-
<strong>{schema['title']}</strong>
|
|
46
|
-
</div>
|
|
47
|
-
{/if}
|
|
123
|
+
{#if schema.title}<div class="lite-schema-form-header"><strong>{schema.title}</strong></div>{/if}
|
|
124
|
+
{#if schema.description}<p class="lite-schema-description">{schema.description}</p>{/if}
|
|
48
125
|
|
|
49
126
|
<form {action} {method} class="lite-schema-form">
|
|
50
|
-
{#
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
127
|
+
{#if resolved.invalid}<p role="alert">{i18n.t('common.operationFailed')}</p>{/if}
|
|
128
|
+
{#snippet renderField(node: JsonSchema, path: string[], title: string, required = false)}
|
|
129
|
+
{@const current = readPath(path)}
|
|
130
|
+
{@const id = `${instanceId}-${encodeURIComponent(JSON.stringify(path))}`}
|
|
131
|
+
{#if !isSchemaFormNodeVisible(node, resolvedValue)}
|
|
132
|
+
<!-- 隐藏字段不进入原生表单传输。 -->
|
|
133
|
+
{:else if node.type === 'object' || node.properties}
|
|
134
|
+
<fieldset class="lite-schema-fieldset">
|
|
135
|
+
<legend>{title}</legend>
|
|
136
|
+
{#if node.description}<p class="lite-schema-description">{node.description}</p>{/if}
|
|
137
|
+
{#each Object.entries(node.properties ?? {}) as [key, child] (key)}
|
|
138
|
+
{@render renderField(child, [...path, key], child.title ?? key, node.required?.includes(key) ?? false)}
|
|
139
|
+
{/each}
|
|
140
|
+
</fieldset>
|
|
141
|
+
{:else if node.type === 'array'}
|
|
142
|
+
{@const items = Array.isArray(current) ? current : []}
|
|
143
|
+
<fieldset class="lite-schema-fieldset">
|
|
144
|
+
<input type="hidden" name={`length:${JSON.stringify(path)}`} value={items.length} />
|
|
145
|
+
<legend>{title}</legend>
|
|
146
|
+
{#if node.description}<p class="lite-schema-description">{node.description}</p>{/if}
|
|
147
|
+
{#each items as _, index (index)}
|
|
148
|
+
{@render renderField(node.items ?? { type: 'string' }, [...path, String(index)], `${title} ${index + 1}`)}
|
|
149
|
+
<button
|
|
150
|
+
type={action ? 'submit' : 'button'}
|
|
151
|
+
formnovalidate
|
|
152
|
+
name={action ? schemaFormActionName : undefined}
|
|
153
|
+
value={action ? encodeSchemaFormArrayAction({ type: 'remove', path, index }) : undefined}
|
|
154
|
+
class="lite-btn lite-btn-sm"
|
|
155
|
+
disabled={node.minItems !== undefined && items.length <= node.minItems}
|
|
156
|
+
onclick={action ? (event) => { event.preventDefault(); removeArrayItem(path, index); } : () => removeArrayItem(path, index)}
|
|
157
|
+
>Remove</button>
|
|
158
|
+
{:else}
|
|
159
|
+
<span class="lite-hint">No items</span>
|
|
160
|
+
{/each}
|
|
161
|
+
<button
|
|
162
|
+
type={action ? 'submit' : 'button'}
|
|
163
|
+
formnovalidate
|
|
164
|
+
name={action ? schemaFormActionName : undefined}
|
|
165
|
+
value={action ? encodeSchemaFormArrayAction({ type: 'add', path }) : undefined}
|
|
166
|
+
class="lite-btn lite-btn-sm"
|
|
167
|
+
disabled={node.maxItems !== undefined && items.length >= node.maxItems}
|
|
168
|
+
onclick={action ? (event) => { event.preventDefault(); addArrayItem(path, node.items ?? { type: 'string' }); } : () => addArrayItem(path, node.items ?? { type: 'string' })}
|
|
169
|
+
>Add item</button>
|
|
170
|
+
</fieldset>
|
|
171
|
+
{:else}
|
|
172
|
+
<div class="lite-schema-field">
|
|
173
|
+
<label for={id} class="lite-label">{title}{#if required}<span aria-hidden="true">*</span>{/if}</label>
|
|
174
|
+
{#if node.description}<p class="lite-schema-description">{node.description}</p>{/if}
|
|
175
|
+
{#if node.enum}
|
|
176
|
+
<select id={id} name={fieldName(path)} required={required} value={enumIndex(current, node.enum) < 0 ? '' : enumToken(enumIndex(current, node.enum))} onchange={(event) => writePath(path, enumValue(event.currentTarget.value, node.enum))} class="lite-select">
|
|
177
|
+
<option value="">Select an option</option>
|
|
178
|
+
{#each node.enum as option, index (index)}<option value={enumToken(index)}>{String(option)}</option>{/each}
|
|
179
|
+
</select>
|
|
180
|
+
{:else if node.type === 'null'}
|
|
181
|
+
<input id={id} name={fieldName(path)} value="null" readonly />
|
|
182
|
+
{:else if node.type === 'boolean'}
|
|
183
|
+
<input type="hidden" name={fieldName(path)} value="false" />
|
|
184
|
+
<input id={id} type="checkbox" name={fieldName(path)} value="true" checked={Boolean(current)} onchange={(event) => writePath(path, event.currentTarget.checked)} />
|
|
185
|
+
{:else if node.type === 'number' || node.type === 'integer'}
|
|
186
|
+
<input id={id} name={fieldName(path)} type="number" step={node.type === 'integer' ? 1 : 'any'} min={node.minimum} max={node.maximum} required={required} value={current == null ? '' : String(current)} oninput={(event) => writePath(path, event.currentTarget.value === '' ? undefined : Number(event.currentTarget.value))} class="lite-input" />
|
|
187
|
+
{:else}
|
|
188
|
+
<input id={id} name={fieldName(path)} type="text" minlength={node.minLength} maxlength={node.maxLength} pattern={node.pattern} required={required} value={String(current ?? '')} oninput={(event) => writePath(path, event.currentTarget.value)} class="lite-input" />
|
|
189
|
+
{/if}
|
|
190
|
+
</div>
|
|
191
|
+
{/if}
|
|
192
|
+
{/snippet}
|
|
72
193
|
|
|
194
|
+
{#each Object.entries(resolved.invalid ? {} : schema.properties ?? {}) as [key, node] (key)}
|
|
195
|
+
{@render renderField(node, [key], node.title ?? key, schema.required?.includes(key) ?? false)}
|
|
196
|
+
{/each}
|
|
73
197
|
<div class="lite-schema-footer">
|
|
74
|
-
<button type="submit" class="lite-btn lite-btn-primary lite-btn-sm">
|
|
75
|
-
{submitText}
|
|
76
|
-
</button>
|
|
198
|
+
<button type="submit" disabled={resolved.invalid} class="lite-btn lite-btn-primary lite-btn-sm">{submitText}</button>
|
|
77
199
|
</div>
|
|
78
200
|
</form>
|
|
79
201
|
</div>
|
|
80
202
|
|
|
81
203
|
<style>
|
|
82
|
-
.lite-schema-form-card {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
.lite-schema-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
}
|
|
204
|
+
.lite-schema-form-card { background: #fff; border: 1px solid #cbd5e1; border-radius: 6px; padding: 16px; margin-bottom: 16px; }
|
|
205
|
+
.lite-schema-form-header { padding-bottom: 8px; border-bottom: 1px solid #e2e8f0; margin-bottom: 12px; font-size: 14px; }
|
|
206
|
+
.lite-schema-fieldset { border: 1px solid #e2e8f0; border-radius: 4px; padding: 12px; margin: 0 0 12px; }
|
|
207
|
+
.lite-schema-field { margin-bottom: 12px; }
|
|
208
|
+
.lite-schema-fieldset > .lite-schema-field:last-child { margin-bottom: 0; }
|
|
209
|
+
.lite-schema-footer { display: flex; justify-content: flex-end; padding-top: 12px; border-top: 1px solid #e2e8f0; }
|
|
210
|
+
.lite-label { display: block; font-size: 12px; color: #475569; margin-bottom: 4px; }
|
|
211
|
+
.lite-schema-description, .lite-hint { font-size: 12px; color: #64748b; }
|
|
212
|
+
.lite-input, .lite-select { width: 100%; }
|
|
213
|
+
.lite-btn { margin-right: 6px; }
|
|
104
214
|
</style>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { type SchemaFormSchema as JsonSchema } from '@svadmin/core/schema-form';
|
|
1
2
|
interface Props {
|
|
2
|
-
schema:
|
|
3
|
+
schema: JsonSchema;
|
|
3
4
|
value?: Record<string, unknown>;
|
|
4
5
|
action?: string;
|
|
5
6
|
method?: 'get' | 'post' | 'dialog' | 'GET' | 'POST' | 'DIALOG' | null;
|
|
6
7
|
submitText?: string;
|
|
7
8
|
}
|
|
8
|
-
declare const LiteJsonSchemaForm: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
declare const LiteJsonSchemaForm: import("svelte").Component<Props, {}, "value">;
|
|
9
10
|
type LiteJsonSchemaForm = ReturnType<typeof LiteJsonSchemaForm>;
|
|
10
11
|
export default LiteJsonSchemaForm;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
2
3
|
export interface KanbanCard {
|
|
3
4
|
id: string;
|
|
4
5
|
title: string;
|
|
@@ -17,6 +18,10 @@
|
|
|
17
18
|
columns: KanbanColumn[];
|
|
18
19
|
cards?: KanbanCard[];
|
|
19
20
|
formAction?: string;
|
|
21
|
+
loading?: boolean;
|
|
22
|
+
error?: string | undefined;
|
|
23
|
+
retryHref?: string;
|
|
24
|
+
ariaLabel?: string;
|
|
20
25
|
class?: string;
|
|
21
26
|
}
|
|
22
27
|
|
|
@@ -24,14 +29,54 @@
|
|
|
24
29
|
columns = [],
|
|
25
30
|
cards = [],
|
|
26
31
|
formAction = '',
|
|
32
|
+
loading = false,
|
|
33
|
+
error,
|
|
34
|
+
retryHref,
|
|
35
|
+
ariaLabel,
|
|
27
36
|
class: className = '',
|
|
28
37
|
}: Props = $props();
|
|
38
|
+
const i18n = useTranslation();
|
|
39
|
+
function text(value: unknown): value is string {
|
|
40
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= 1000;
|
|
41
|
+
}
|
|
42
|
+
const validationError = $derived.by(() => {
|
|
43
|
+
if (!Array.isArray(columns) || !Array.isArray(cards)) return 'kanban.invalidData';
|
|
44
|
+
if (columns.length > 200 || cards.length > 1000) return 'kanban.limit';
|
|
45
|
+
if (columns.some(column => !column || !text(column.id) || !text(column.title))) return 'kanban.invalidData';
|
|
46
|
+
const ids = new Set(columns.map(column => column.id));
|
|
47
|
+
if (ids.size !== columns.length) return 'kanban.invalidData';
|
|
48
|
+
const cardIds = new Set<string>();
|
|
49
|
+
for (const card of cards) {
|
|
50
|
+
if (!card || !text(card.id) || !text(card.title) || !ids.has(card.columnId) || cardIds.has(card.id)
|
|
51
|
+
|| (card.description !== undefined && (typeof card.description !== 'string' || card.description.length > 10_000))
|
|
52
|
+
|| (card.tags !== undefined && (!Array.isArray(card.tags) || card.tags.length > 100 || !card.tags.every(text)))
|
|
53
|
+
|| (card.priority !== undefined && !['low', 'medium', 'high', 'urgent'].includes(card.priority))) return 'kanban.invalidData';
|
|
54
|
+
cardIds.add(card.id);
|
|
55
|
+
}
|
|
56
|
+
return '';
|
|
57
|
+
});
|
|
58
|
+
const displayError = $derived(error || (validationError ? i18n.t(validationError) : ''));
|
|
59
|
+
function localPath(value: string | undefined): string | undefined {
|
|
60
|
+
return value && (value.startsWith('/') || value.startsWith('?'))
|
|
61
|
+
&& !/^\/[\\/]/.test(value) && !value.includes('\\')
|
|
62
|
+
&& ![...value].some(char => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127) ? value : undefined;
|
|
63
|
+
}
|
|
64
|
+
const safeAction = $derived(localPath(formAction));
|
|
65
|
+
const safeRetry = $derived(localPath(retryHref));
|
|
29
66
|
</script>
|
|
30
67
|
|
|
31
|
-
<div class="sv-lite-kanban-board {className}">
|
|
68
|
+
<div class="sv-lite-kanban-board {className}" role="region" aria-label={ariaLabel ?? i18n.t('kanban.label')} aria-busy={loading}>
|
|
69
|
+
{#if loading}
|
|
70
|
+
<div role="status">{i18n.t('common.loading')}</div>
|
|
71
|
+
{:else if displayError}
|
|
72
|
+
<div role="alert">{displayError}</div>
|
|
73
|
+
{#if error && safeRetry}<a href={safeRetry}>{i18n.t('common.retry')}</a>{/if}
|
|
74
|
+
{:else if columns.length === 0}
|
|
75
|
+
<div role="status">{i18n.t('common.noData')}</div>
|
|
76
|
+
{:else}
|
|
32
77
|
{#each columns as col (col.id)}
|
|
33
78
|
{@const colCards = cards.filter((c) => c.columnId === col.id)}
|
|
34
|
-
<div class="sv-lite-kanban-column">
|
|
79
|
+
<div class="sv-lite-kanban-column" role="region" aria-label={col.title}>
|
|
35
80
|
<div class="sv-lite-column-header">
|
|
36
81
|
<span class="sv-lite-column-title">{col.title}</span>
|
|
37
82
|
<span class="sv-lite-column-count">({colCards.length})</span>
|
|
@@ -39,7 +84,7 @@
|
|
|
39
84
|
|
|
40
85
|
<div class="sv-lite-column-cards">
|
|
41
86
|
{#each colCards as card (card.id)}
|
|
42
|
-
<div class="sv-lite-kanban-card">
|
|
87
|
+
<div class="sv-lite-kanban-card" role="group" aria-label={card.title}>
|
|
43
88
|
<div class="sv-lite-card-title">{card.title}</div>
|
|
44
89
|
{#if card.description}
|
|
45
90
|
<div class="sv-lite-card-desc">{card.description}</div>
|
|
@@ -48,23 +93,26 @@
|
|
|
48
93
|
{#if card.priority}
|
|
49
94
|
<span class="sv-lite-priority-badge sv-lite-{card.priority}">{card.priority}</span>
|
|
50
95
|
{/if}
|
|
51
|
-
{#each card.tags ?? [] as tag (
|
|
96
|
+
{#each card.tags ?? [] as tag, index (index)}
|
|
52
97
|
<span class="sv-lite-tag-badge">#{tag}</span>
|
|
53
98
|
{/each}
|
|
54
99
|
</div>
|
|
55
100
|
</div>
|
|
101
|
+
{:else}
|
|
102
|
+
<div role="status">{i18n.t('common.noData')}</div>
|
|
56
103
|
{/each}
|
|
57
104
|
</div>
|
|
58
105
|
|
|
59
|
-
{#if
|
|
60
|
-
<form method="POST" action={
|
|
106
|
+
{#if safeAction && cards.length < 1000}
|
|
107
|
+
<form method="POST" action={safeAction} class="sv-lite-card-form" aria-label={i18n.t('kanban.addTo', { column: col.title })}>
|
|
61
108
|
<input type="hidden" name="columnId" value={col.id} />
|
|
62
|
-
<input type="text" name="title" placeholder=
|
|
63
|
-
<button type="submit" class="sv-lite-card-btn">
|
|
109
|
+
<input type="text" name="title" aria-label={i18n.t('kanban.cardTitle')} placeholder={i18n.t('kanban.cardTitle')} required maxlength={1000} class="sv-lite-card-input" />
|
|
110
|
+
<button type="submit" class="sv-lite-card-btn">{i18n.t('common.add')}</button>
|
|
64
111
|
</form>
|
|
65
112
|
{/if}
|
|
66
113
|
</div>
|
|
67
114
|
{/each}
|
|
115
|
+
{/if}
|
|
68
116
|
</div>
|
|
69
117
|
|
|
70
118
|
<style>
|
|
@@ -14,6 +14,10 @@ interface Props {
|
|
|
14
14
|
columns: KanbanColumn[];
|
|
15
15
|
cards?: KanbanCard[];
|
|
16
16
|
formAction?: string;
|
|
17
|
+
loading?: boolean;
|
|
18
|
+
error?: string | undefined;
|
|
19
|
+
retryHref?: string;
|
|
20
|
+
ariaLabel?: string;
|
|
17
21
|
class?: string;
|
|
18
22
|
}
|
|
19
23
|
declare const LiteKanbanBoard: import("svelte").Component<Props, {}, "">;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
2
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
|
+
import { buildPivot, buildPivotDrilldown, decodePivotResult, formatPivotValue } from '@svadmin/core/pivot';
|
|
4
|
+
export type AggregationFn = import('@svadmin/core/pivot').PivotAggregation;
|
|
3
5
|
|
|
4
6
|
interface Props {
|
|
5
7
|
data?: Record<string, unknown>[];
|
|
8
|
+
aggregateData?: unknown;
|
|
6
9
|
rowField: string;
|
|
7
10
|
rowLabel?: string;
|
|
8
11
|
columnField: string;
|
|
@@ -10,11 +13,23 @@
|
|
|
10
13
|
valueField: string;
|
|
11
14
|
valueLabel?: string;
|
|
12
15
|
aggregator?: AggregationFn;
|
|
16
|
+
formatValue?: (value: number) => string;
|
|
17
|
+
loading?: boolean;
|
|
18
|
+
error?: string | undefined;
|
|
19
|
+
retryHref?: string;
|
|
20
|
+
exportHref?: string;
|
|
21
|
+
drilldownHref?: (input: import('@svadmin/core/pivot').PivotDrilldown) => string | undefined;
|
|
22
|
+
resource?: string;
|
|
23
|
+
scopeKey?: string;
|
|
24
|
+
filters?: import('@svadmin/core').Filter[];
|
|
25
|
+
meta?: Record<string, unknown>;
|
|
26
|
+
ariaLabel?: string;
|
|
13
27
|
class?: string;
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
let {
|
|
17
31
|
data = [],
|
|
32
|
+
aggregateData,
|
|
18
33
|
rowField,
|
|
19
34
|
rowLabel = rowField,
|
|
20
35
|
columnField,
|
|
@@ -22,91 +37,106 @@
|
|
|
22
37
|
valueField,
|
|
23
38
|
valueLabel = valueField,
|
|
24
39
|
aggregator = 'sum',
|
|
40
|
+
formatValue,
|
|
41
|
+
loading = false,
|
|
42
|
+
error,
|
|
43
|
+
retryHref,
|
|
44
|
+
exportHref,
|
|
45
|
+
drilldownHref,
|
|
46
|
+
resource,
|
|
47
|
+
scopeKey,
|
|
48
|
+
filters,
|
|
49
|
+
meta,
|
|
50
|
+
ariaLabel,
|
|
25
51
|
class: className = '',
|
|
26
52
|
}: Props = $props();
|
|
27
53
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (aggregator === 'sum') return vals.reduce((a, b) => a + b, 0);
|
|
40
|
-
if (aggregator === 'avg') return vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
41
|
-
if (aggregator === 'min') return Math.min(...vals);
|
|
42
|
-
if (aggregator === 'max') return Math.max(...vals);
|
|
43
|
-
return 0;
|
|
54
|
+
const i18n = useTranslation();
|
|
55
|
+
const pivot = $derived(aggregateData !== undefined ? decodePivotResult(aggregateData)
|
|
56
|
+
: buildPivot(loading || error ? [] : data, { rowField, columnField, valueField, aggregator }));
|
|
57
|
+
const displayError = $derived(error || (pivot.error ? i18n.t(`pivot.${pivot.error}`) : ''));
|
|
58
|
+
// 重试仅接受站内绝对路径,避免在服务端页面输出可执行协议。
|
|
59
|
+
const safeRetryHref = $derived(safeHref(retryHref));
|
|
60
|
+
const safeExportHref = $derived(safeHref(exportHref));
|
|
61
|
+
function safeHref(value: string | undefined): string | undefined {
|
|
62
|
+
return value?.startsWith('/') && !/^\/[\\/]/.test(value)
|
|
63
|
+
&& !value.includes('\\')
|
|
64
|
+
&& ![...value].some(char => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127) ? value : undefined;
|
|
44
65
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
66
|
+
function cellHref(rowKey: string, columnKey: string): string | undefined {
|
|
67
|
+
if (!drilldownHref || !pivot.cells.get(rowKey)?.has(columnKey)) return;
|
|
68
|
+
const row = pivot.rows.find(item => item.key === rowKey);
|
|
69
|
+
const column = pivot.columns.find(item => item.key === columnKey);
|
|
70
|
+
const query = { resource, rowField, columnField, valueField, aggregator,
|
|
71
|
+
...(filters === undefined ? {} : { filters }), ...(meta === undefined ? {} : { meta }) };
|
|
72
|
+
const input = buildPivotDrilldown(query, scopeKey, row, column);
|
|
73
|
+
try {
|
|
74
|
+
return safeHref(input ? drilldownHref(input) : undefined);
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
51
78
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const vals = data
|
|
55
|
-
.filter((d) => String(d[rowField] ?? '—') === r)
|
|
56
|
-
.map((d) => Number(d[valueField]) || 0);
|
|
57
|
-
return computeAgg(vals);
|
|
79
|
+
function display(value: number | null | undefined): string {
|
|
80
|
+
return formatPivotValue(value, formatValue ?? (v => v.toLocaleString(i18n.locale, { maximumFractionDigits: 2 })));
|
|
58
81
|
}
|
|
59
|
-
|
|
60
|
-
function getColTotal(c: string): number {
|
|
61
|
-
const vals = data
|
|
62
|
-
.filter((d) => String(d[columnField] ?? '—') === c)
|
|
63
|
-
.map((d) => Number(d[valueField]) || 0);
|
|
64
|
-
return computeAgg(vals);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const grandTotal = $derived(
|
|
68
|
-
computeAgg(data.map((d) => Number(d[valueField]) || 0))
|
|
69
|
-
);
|
|
70
82
|
</script>
|
|
71
83
|
|
|
72
|
-
<div class="sv-lite-pivot-container {className}">
|
|
84
|
+
<div class="sv-lite-pivot-container {className}" aria-busy={loading}>
|
|
73
85
|
<div class="sv-lite-pivot-header">
|
|
74
|
-
<strong>
|
|
75
|
-
<span class="sv-lite-pivot-sub">
|
|
86
|
+
<strong>{i18n.t('pivot.title')}</strong> ({rowLabel} × {columnLabel})
|
|
87
|
+
<span class="sv-lite-pivot-sub">{i18n.t(`pivot.${aggregator}`)} ({valueLabel})</span>
|
|
88
|
+
{#if safeExportHref}<a href={safeExportHref}>{i18n.t('common.export')}</a>{/if}
|
|
76
89
|
</div>
|
|
77
90
|
|
|
78
|
-
|
|
91
|
+
{#if loading}
|
|
92
|
+
<div role="status">{i18n.t('common.loading')}</div>
|
|
93
|
+
{:else if displayError}
|
|
94
|
+
<div role="alert">{displayError}</div>
|
|
95
|
+
{#if error && safeRetryHref}<a href={safeRetryHref}>{i18n.t('common.retry')}</a>{/if}
|
|
96
|
+
{:else if pivot.rows.length === 0}
|
|
97
|
+
<div role="status">{i18n.t('common.noData')}</div>
|
|
98
|
+
{:else}
|
|
99
|
+
<table class="sv-lite-pivot-table" aria-label={ariaLabel ?? `${i18n.t('pivot.title')}: ${rowLabel} / ${columnLabel} / ${valueLabel}`}>
|
|
79
100
|
<thead>
|
|
80
101
|
<tr>
|
|
81
|
-
<th class="sv-lite-th-dim">{rowLabel} \ {columnLabel}</th>
|
|
82
|
-
{#each
|
|
83
|
-
<th>{col}</th>
|
|
102
|
+
<th scope="col" class="sv-lite-th-dim">{rowLabel} \ {columnLabel}</th>
|
|
103
|
+
{#each pivot.columns as col (col.key)}
|
|
104
|
+
<th scope="col">{col.label}</th>
|
|
84
105
|
{/each}
|
|
85
|
-
<th class="sv-lite-th-total">
|
|
106
|
+
<th scope="col" class="sv-lite-th-total">{i18n.t('pivot.total')}</th>
|
|
86
107
|
</tr>
|
|
87
108
|
</thead>
|
|
88
109
|
<tbody>
|
|
89
|
-
{#each
|
|
110
|
+
{#each pivot.rows as row (row.key)}
|
|
90
111
|
<tr>
|
|
91
|
-
<
|
|
92
|
-
{#each
|
|
93
|
-
{@const
|
|
94
|
-
<td class="sv-lite-td-val">
|
|
112
|
+
<th scope="row" class="sv-lite-td-dim">{row.label}</th>
|
|
113
|
+
{#each pivot.columns as col (col.key)}
|
|
114
|
+
{@const href = cellHref(row.key, col.key)}
|
|
115
|
+
<td class="sv-lite-td-val">
|
|
116
|
+
{#if href}
|
|
117
|
+
<a href={href} aria-label={`${i18n.t('pivot.drilldown')}: ${row.label} / ${col.label}`}>
|
|
118
|
+
{display(pivot.cells.get(row.key)?.get(col.key))}
|
|
119
|
+
</a>
|
|
120
|
+
{:else}
|
|
121
|
+
{display(pivot.cells.get(row.key)?.get(col.key))}
|
|
122
|
+
{/if}
|
|
123
|
+
</td>
|
|
95
124
|
{/each}
|
|
96
|
-
<td class="sv-lite-td-rowtotal">{
|
|
125
|
+
<td class="sv-lite-td-rowtotal">{display(pivot.rowTotals.get(row.key))}</td>
|
|
97
126
|
</tr>
|
|
98
127
|
{/each}
|
|
99
128
|
</tbody>
|
|
100
129
|
<tfoot>
|
|
101
130
|
<tr>
|
|
102
|
-
<
|
|
103
|
-
{#each
|
|
104
|
-
<td class="sv-lite-td-val">{
|
|
131
|
+
<th scope="row" class="sv-lite-td-dim">{i18n.t('pivot.grandTotal')}</th>
|
|
132
|
+
{#each pivot.columns as col (col.key)}
|
|
133
|
+
<td class="sv-lite-td-val">{display(pivot.columnTotals.get(col.key))}</td>
|
|
105
134
|
{/each}
|
|
106
|
-
<td class="sv-lite-td-grandtotal">{grandTotal
|
|
135
|
+
<td class="sv-lite-td-grandtotal">{display(pivot.grandTotal)}</td>
|
|
107
136
|
</tr>
|
|
108
137
|
</tfoot>
|
|
109
138
|
</table>
|
|
139
|
+
{/if}
|
|
110
140
|
</div>
|
|
111
141
|
|
|
112
142
|
<style>
|