@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.
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
- import { definedOptions } from '@svadmin/core/options';
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: Record<string, unknown>;
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
- interface FieldMeta {
21
- key: string;
22
- title: string;
23
- type: string;
24
- enum?: Array<string | number>;
25
- required?: boolean;
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
- 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[]) : [];
31
-
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
- })));
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['title']}
44
- <div class="lite-schema-form-header">
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
- {#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}
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
- 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
- }
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: Record<string, unknown>;
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 (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 formAction}
60
- <form method="POST" action={formAction} class="sv-lite-card-form">
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="+ Add card..." class="sv-lite-card-input" />
63
- <button type="submit" class="sv-lite-card-btn">Add</button>
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
- export type AggregationFn = 'sum' | 'count' | 'avg' | 'min' | 'max';
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 distinctRowValues = $derived(
29
- Array.from(new Set(data.map((d) => String(d[rowField] ?? '—')))).sort()
30
- );
31
-
32
- const distinctColValues = $derived(
33
- Array.from(new Set(data.map((d) => String(d[columnField] ?? '—')))).sort()
34
- );
35
-
36
- function computeAgg(vals: number[]): number {
37
- if (vals.length === 0) return 0;
38
- if (aggregator === 'count') return vals.length;
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
- function getCellValue(r: string, c: string): number {
47
- const vals = data
48
- .filter((d) => String(d[rowField] ?? '—') === r && String(d[columnField] ?? '—') === c)
49
- .map((d) => Number(d[valueField]) || 0);
50
- return computeAgg(vals);
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
- function getRowTotal(r: string): number {
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>Pivot Analysis</strong> ({rowLabel} × {columnLabel})
75
- <span class="sv-lite-pivot-sub">Aggregator: {aggregator.toUpperCase()} ({valueLabel})</span>
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
- <table class="sv-lite-pivot-table">
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 distinctColValues as col (col)}
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">Total</th>
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 distinctRowValues as row (row)}
110
+ {#each pivot.rows as row (row.key)}
90
111
  <tr>
91
- <td class="sv-lite-td-dim">{row}</td>
92
- {#each distinctColValues as col (col)}
93
- {@const val = getCellValue(row, col)}
94
- <td class="sv-lite-td-val">{val.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
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">{getRowTotal(row).toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
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
- <td class="sv-lite-td-dim">Grand Total</td>
103
- {#each distinctColValues as col (col)}
104
- <td class="sv-lite-td-val">{getColTotal(col).toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
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.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
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>