@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
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.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface LiteFormErrorEntry {
|
|
3
|
+
fieldKey: string;
|
|
4
|
+
label: string;
|
|
5
|
+
message: string;
|
|
6
|
+
/** 可选的表单控件 id;提供后错误项会链接到该控件。 */
|
|
7
|
+
fieldId?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
errors = [],
|
|
12
|
+
title = 'Please fix the following errors',
|
|
13
|
+
}: {
|
|
14
|
+
errors?: LiteFormErrorEntry[];
|
|
15
|
+
title?: string;
|
|
16
|
+
} = $props();
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
{#if errors.length > 0}
|
|
20
|
+
<section class="lite-error-summary" aria-labelledby="lite-error-summary-title">
|
|
21
|
+
<h2 id="lite-error-summary-title">{title}</h2>
|
|
22
|
+
<ul>
|
|
23
|
+
{#each errors as error (error.fieldKey)}
|
|
24
|
+
<li>
|
|
25
|
+
{#if error.fieldId}
|
|
26
|
+
<a href={`#${error.fieldId}`}>{error.label}: {error.message}</a>
|
|
27
|
+
{:else}
|
|
28
|
+
<span>{error.label}: {error.message}</span>
|
|
29
|
+
{/if}
|
|
30
|
+
</li>
|
|
31
|
+
{/each}
|
|
32
|
+
</ul>
|
|
33
|
+
</section>
|
|
34
|
+
{/if}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface LiteFormErrorEntry {
|
|
2
|
+
fieldKey: string;
|
|
3
|
+
label: string;
|
|
4
|
+
message: string;
|
|
5
|
+
/** 可选的表单控件 id;提供后错误项会链接到该控件。 */
|
|
6
|
+
fieldId?: string;
|
|
7
|
+
}
|
|
8
|
+
type $$ComponentProps = {
|
|
9
|
+
errors?: LiteFormErrorEntry[];
|
|
10
|
+
title?: string;
|
|
11
|
+
};
|
|
12
|
+
declare const LiteErrorSummary: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
13
|
+
type LiteErrorSummary = ReturnType<typeof LiteErrorSummary>;
|
|
14
|
+
export default LiteErrorSummary;
|
|
@@ -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,6 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { CrudOperator, FieldDefinition, Filter } from '@svadmin/core';
|
|
3
|
+
import { filterOperatorsForField as operatorsForField, isNumericFilterField as isNumericField,
|
|
4
|
+
isCollectionFilterOperator, isFilterCollectionValue } from '@svadmin/core';
|
|
3
5
|
import { t } from '@svadmin/core/i18n';
|
|
6
|
+
import { readFilterForm } from '../filter-form.js';
|
|
4
7
|
|
|
5
8
|
export interface FilterRuleItem {
|
|
6
9
|
id: string;
|
|
@@ -9,10 +12,13 @@
|
|
|
9
12
|
value: unknown;
|
|
10
13
|
}
|
|
11
14
|
|
|
15
|
+
type FilterNode =
|
|
16
|
+
| { kind: 'group'; id: string; operator: 'and' | 'or'; children: FilterNode[] }
|
|
17
|
+
| { kind: 'rule'; id: string; field: string; operator: CrudOperator; value: unknown; readonly?: boolean };
|
|
18
|
+
|
|
12
19
|
interface Props {
|
|
13
20
|
fields?: FieldDefinition[];
|
|
14
21
|
filters?: Filter[];
|
|
15
|
-
logicalOperator?: 'and' | 'or';
|
|
16
22
|
action?: string;
|
|
17
23
|
method?: 'GET' | 'POST';
|
|
18
24
|
disabled?: boolean;
|
|
@@ -22,184 +28,191 @@
|
|
|
22
28
|
let {
|
|
23
29
|
fields = [],
|
|
24
30
|
filters = [],
|
|
25
|
-
logicalOperator = 'and',
|
|
26
31
|
action = '',
|
|
32
|
+
method = 'GET',
|
|
27
33
|
disabled = false,
|
|
34
|
+
onApply,
|
|
28
35
|
}: Props = $props();
|
|
36
|
+
let validationError = $state<string | undefined>();
|
|
29
37
|
|
|
30
|
-
const
|
|
38
|
+
const instanceId = $props.id();
|
|
39
|
+
const formId = `${instanceId}-filter-form`;
|
|
40
|
+
const operatorOptions = $derived<{ value: CrudOperator; label: string }[]>([
|
|
31
41
|
{ value: 'eq', label: '= 等于' },
|
|
32
42
|
{ value: 'ne', label: '!= 不等于' },
|
|
33
43
|
{ value: 'contains', label: '包含' },
|
|
34
44
|
{ value: 'ncontains', label: '不包含' },
|
|
45
|
+
{ value: 'startswith', label: '开头为' },
|
|
46
|
+
{ value: 'endswith', label: '结尾为' },
|
|
35
47
|
{ value: 'gt', label: '> 大于' },
|
|
36
48
|
{ value: 'gte', label: '>= 大于等于' },
|
|
37
49
|
{ value: 'lt', label: '< 小于' },
|
|
38
50
|
{ value: 'lte', label: '<= 小于等于' },
|
|
39
|
-
{ value: '
|
|
51
|
+
{ value: 'in', label: t('filter.in') },
|
|
52
|
+
{ value: 'nin', label: t('filter.nin') },
|
|
53
|
+
{ value: 'between', label: t('filter.between') },
|
|
54
|
+
{ value: 'nbetween', label: t('filter.nbetween') },
|
|
55
|
+
{ value: 'null', label: '为空' },
|
|
40
56
|
{ value: 'nnull', label: '不为空' },
|
|
41
|
-
];
|
|
57
|
+
]);
|
|
42
58
|
|
|
43
|
-
|
|
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
|
-
}));
|
|
52
|
-
}
|
|
53
|
-
return [
|
|
54
|
-
{
|
|
55
|
-
id: 'rule_0',
|
|
56
|
-
field: fields[0]?.key ?? '',
|
|
57
|
-
operator: 'eq' as CrudOperator,
|
|
58
|
-
value: '',
|
|
59
|
-
},
|
|
60
|
-
];
|
|
61
|
-
});
|
|
62
|
-
</script>
|
|
59
|
+
const availableFields = $derived(fields.filter(field => field.filterable !== false));
|
|
63
60
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
61
|
+
function nodeFromFilter(filter: Filter, path: string): FilterNode {
|
|
62
|
+
if ('field' in filter) {
|
|
63
|
+
const field = availableFields.find(candidate => candidate.key === filter.field);
|
|
64
|
+
const editable = field !== undefined
|
|
65
|
+
&& operatorsForField(field).includes(filter.operator)
|
|
66
|
+
&& (filter.operator === 'null' || filter.operator === 'nnull'
|
|
67
|
+
? filter.value === null
|
|
68
|
+
: isCollectionFilterOperator(filter.operator)
|
|
69
|
+
? isFilterCollectionValue(field, filter.operator, filter.value)
|
|
70
|
+
: isNumericField(field)
|
|
71
|
+
? typeof filter.value === 'number' && Number.isFinite(filter.value)
|
|
72
|
+
: field.type === 'boolean'
|
|
73
|
+
? typeof filter.value === 'boolean'
|
|
74
|
+
: field.type === 'select'
|
|
75
|
+
? field.options?.some(option => Object.is(option.value, filter.value)) === true
|
|
76
|
+
: typeof filter.value === 'string');
|
|
77
|
+
return { kind: 'rule', id: `rule-${path}`, field: filter.field, operator: filter.operator, value: filter.value, readonly: !editable };
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
kind: 'group',
|
|
81
|
+
id: `group-${path}`,
|
|
82
|
+
operator: filter.operator,
|
|
83
|
+
children: filter.value.map((child, index) => nodeFromFilter(child, `${path}-${index}`)),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
85
86
|
|
|
86
|
-
|
|
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>
|
|
87
|
+
const nodes = $derived(filters.map((filter, index) => nodeFromFilter(filter, String(index))));
|
|
100
88
|
|
|
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>
|
|
110
|
-
{/each}
|
|
111
|
-
</select>
|
|
89
|
+
function readValue(node: FilterNode): unknown {
|
|
90
|
+
return node.kind === 'rule' ? node.value : undefined;
|
|
91
|
+
}
|
|
112
92
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
93
|
+
function handleSubmit(event: SubmitEvent): void {
|
|
94
|
+
if (!onApply) return;
|
|
95
|
+
const submitter = event.submitter;
|
|
96
|
+
if (submitter instanceof HTMLButtonElement && submitter.name === '_action') return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
try {
|
|
99
|
+
validationError = undefined;
|
|
100
|
+
onApply(readFilterForm(filters, fields, new FormData(event.currentTarget as HTMLFormElement)));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
validationError = error instanceof Error ? error.message : 'Invalid filters';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
124
105
|
|
|
125
|
-
|
|
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>
|
|
106
|
+
</script>
|
|
139
107
|
|
|
140
|
-
|
|
141
|
-
|
|
108
|
+
<div class="lite-filter-builder lite-form-group">
|
|
109
|
+
<div class="lite-filter-header">
|
|
110
|
+
<strong>{t('common.filters') || '高级多条件筛选'}</strong>
|
|
111
|
+
<button type="submit" form={formId} class="lite-btn lite-btn-primary lite-btn-sm" disabled={disabled}>
|
|
142
112
|
应用筛选
|
|
143
113
|
</button>
|
|
144
|
-
<a href={action || '?'} class="lite-btn" style="margin-left: 8px;">
|
|
145
|
-
重置
|
|
146
|
-
</a>
|
|
147
114
|
</div>
|
|
115
|
+
|
|
116
|
+
<form id={formId} {action} {method} class="lite-filter-form" onsubmit={handleSubmit}>
|
|
117
|
+
{#if validationError}<p role="alert" class="lite-error">{validationError}</p>{/if}
|
|
118
|
+
{#snippet renderNode(node: FilterNode, path: string, isRoot = false)}
|
|
119
|
+
{#if node.kind === 'group'}
|
|
120
|
+
<fieldset class="lite-filter-group" data-testid={isRoot ? 'lite-filter-root' : 'lite-filter-group'}>
|
|
121
|
+
<legend>
|
|
122
|
+
<label>
|
|
123
|
+
逻辑组
|
|
124
|
+
<select name={`filters[${path}][operator]`} value={node.operator} {disabled}>
|
|
125
|
+
<option value="and">AND (且)</option>
|
|
126
|
+
<option value="or">OR (或)</option>
|
|
127
|
+
</select>
|
|
128
|
+
</label>
|
|
129
|
+
</legend>
|
|
130
|
+
{#each node.children as child, index (child.id)}
|
|
131
|
+
{@render renderNode(child, `${path}.value.${index}`)}
|
|
132
|
+
{:else}
|
|
133
|
+
<p class="lite-hint">暂无筛选条件</p>
|
|
134
|
+
{/each}
|
|
135
|
+
<button type="submit" name="_action" value={`add_filter_rule:${path}`} class="lite-btn lite-btn-sm" {disabled}>添加条件</button>
|
|
136
|
+
<button type="submit" name="_action" value={`add_filter_group:${path}`} class="lite-btn lite-btn-sm" {disabled}>添加条件组</button>
|
|
137
|
+
</fieldset>
|
|
138
|
+
{:else}
|
|
139
|
+
<div class="lite-filter-row" data-testid="lite-filter-rule">
|
|
140
|
+
{#if node.readonly}
|
|
141
|
+
<output aria-label={t('filter.readonly')}>{node.field} {node.operator} {JSON.stringify(node.value)}</output>
|
|
142
|
+
<!-- 严格解析器拒绝非协议属性,防止原生文本传输改变历史条件类型。 -->
|
|
143
|
+
<input type="hidden" name={`filters[${path}][invalid]`} value="true" {disabled} />
|
|
144
|
+
<input type="hidden" name={`filters[${path}][field]`} value={node.field} {disabled} />
|
|
145
|
+
<input type="hidden" name={`filters[${path}][operator]`} value={node.operator} {disabled} />
|
|
146
|
+
{#if node.operator !== 'null' && node.operator !== 'nnull'}
|
|
147
|
+
<input type="hidden" name={`filters[${path}][value]`} value={typeof node.value === 'string' ? node.value : JSON.stringify(node.value)} {disabled} />
|
|
148
|
+
{/if}
|
|
149
|
+
{:else}
|
|
150
|
+
<select name={`filters[${path}][field]`} value={node.field} {disabled}>
|
|
151
|
+
{#each availableFields as field (field.key)}<option value={field.key}>{field.label || field.key}</option>{/each}
|
|
152
|
+
</select>
|
|
153
|
+
<select name={`filters[${path}][operator]`} value={node.operator} {disabled}>
|
|
154
|
+
{#each operatorOptions.filter(operator => operatorsForField(availableFields.find(field => field.key === node.field)).includes(operator.value)) as operator (operator.value)}<option value={operator.value}>{operator.label}</option>{/each}
|
|
155
|
+
</select>
|
|
156
|
+
{#if node.operator !== 'null' && node.operator !== 'nnull'}
|
|
157
|
+
{#if isCollectionFilterOperator(node.operator) && Array.isArray(node.value)}
|
|
158
|
+
{@const field = availableFields.find(field => field.key === node.field)}
|
|
159
|
+
<div class="lite-filter-values">
|
|
160
|
+
{#each node.value as item, index (index)}
|
|
161
|
+
{@const label = node.operator === 'between' || node.operator === 'nbetween'
|
|
162
|
+
? t(index === 0 ? 'filter.rangeStart' : 'filter.rangeEnd')
|
|
163
|
+
: t('filter.collectionItem', { number: String(index + 1) })}
|
|
164
|
+
{#if field?.type === 'select'}
|
|
165
|
+
<select aria-label={label} name={`filters[${path}][values.${index}]`} value={JSON.stringify(item)} {disabled}>
|
|
166
|
+
{#each field.options ?? [] as option, optionIndex (optionIndex)}
|
|
167
|
+
<option value={JSON.stringify(option.value)} disabled={option.disabled}>{option.label}</option>
|
|
168
|
+
{/each}
|
|
169
|
+
</select>
|
|
170
|
+
{:else if field?.type === 'boolean'}
|
|
171
|
+
<select aria-label={label} name={`filters[${path}][values.${index}]`} value={String(item)} {disabled}>
|
|
172
|
+
<option value="true">{t('common.yes')}</option>
|
|
173
|
+
<option value="false">{t('common.no')}</option>
|
|
174
|
+
</select>
|
|
175
|
+
{:else}
|
|
176
|
+
<input aria-label={label} name={`filters[${path}][values.${index}]`}
|
|
177
|
+
type={isNumericField(field) ? 'number' : 'text'} step="any" value={String(item)} {disabled} />
|
|
178
|
+
{/if}
|
|
179
|
+
{/each}
|
|
180
|
+
</div>
|
|
181
|
+
{:else if availableFields.find(field => field.key === node.field)?.type === 'boolean'}
|
|
182
|
+
<select name={`filters[${path}][value]`} value={String(readValue(node) ?? '')} {disabled}>
|
|
183
|
+
<option value="">请选择</option><option value="true">是</option><option value="false">否</option>
|
|
184
|
+
</select>
|
|
185
|
+
{:else}
|
|
186
|
+
<input name={`filters[${path}][value]`} type={isNumericField(availableFields.find(field => field.key === node.field)) ? 'number' : 'text'} value={String(readValue(node) ?? '')} {disabled} />
|
|
187
|
+
{/if}
|
|
188
|
+
{/if}
|
|
189
|
+
{/if}
|
|
190
|
+
<button type="submit" name="_action" value={`remove_filter_rule:${path}`} class="lite-btn lite-btn-sm lite-btn-danger" {disabled} title="删除此行条件">删除</button>
|
|
191
|
+
</div>
|
|
192
|
+
{/if}
|
|
193
|
+
{/snippet}
|
|
194
|
+
|
|
195
|
+
{#if nodes.length === 0}
|
|
196
|
+
<p class="lite-hint">暂无筛选条件,请先添加规则。</p>
|
|
197
|
+
<button type="submit" name="_action" value="add_filter_rule:root" class="lite-btn lite-btn-sm" {disabled}>添加条件</button>
|
|
198
|
+
{:else}
|
|
199
|
+
{#each nodes as node, index (node.id)}
|
|
200
|
+
{@render renderNode(node, String(index), node.kind === 'group' && nodes.length === 1)}
|
|
201
|
+
{/each}
|
|
202
|
+
{/if}
|
|
203
|
+
</form>
|
|
148
204
|
</div>
|
|
149
205
|
|
|
150
206
|
<style>
|
|
151
|
-
.lite-filter-builder {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
.lite-filter-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
}
|
|
207
|
+
.lite-filter-builder { border: 1px solid #cbd5e1; border-radius: 6px; padding: 14px; background: #f8fafc; margin-bottom: 16px; }
|
|
208
|
+
.lite-filter-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
|
209
|
+
.lite-filter-group { border: 1px solid #e2e8f0; border-radius: 4px; padding: 10px; margin-bottom: 8px; background: #fff; }
|
|
210
|
+
.lite-filter-row { display: flex; align-items: center; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px; margin-bottom: 8px; background: #fff; }
|
|
211
|
+
.lite-filter-row > * { min-width: 120px; flex: 1; }
|
|
212
|
+
.lite-filter-row > * + * { margin-left: 8px; }
|
|
213
|
+
.lite-filter-row .lite-btn { flex: 0 0 auto; }
|
|
214
|
+
.lite-filter-values { display: flex; flex-wrap: wrap; min-width: 0; }
|
|
215
|
+
.lite-filter-values > * { width: 160px; max-width: 100%; box-sizing: border-box; margin: 0 8px 8px 0; }
|
|
216
|
+
.lite-hint { color: #64748b; font-size: 12px; }
|
|
217
|
+
.lite-btn { margin-right: 6px; }
|
|
205
218
|
</style>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
|
+
import { GANTT_LIMITS, validateGantt } from '@svadmin/core/gantt';
|
|
2
4
|
export interface GanttTask {
|
|
3
5
|
id: string;
|
|
4
6
|
title: string;
|
|
@@ -6,12 +8,18 @@
|
|
|
6
8
|
durationDays: number;
|
|
7
9
|
progress?: number;
|
|
8
10
|
status?: 'planned' | 'in_progress' | 'completed' | 'delayed';
|
|
11
|
+
dependencies?: string[];
|
|
12
|
+
milestone?: boolean;
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
interface Props {
|
|
12
16
|
tasks?: GanttTask[];
|
|
13
17
|
totalDays?: number;
|
|
14
18
|
dayLabelPrefix?: string;
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
error?: string | undefined;
|
|
21
|
+
retryHref?: string;
|
|
22
|
+
ariaLabel?: string;
|
|
15
23
|
class?: string;
|
|
16
24
|
}
|
|
17
25
|
|
|
@@ -19,48 +27,73 @@
|
|
|
19
27
|
tasks = [],
|
|
20
28
|
totalDays = 14,
|
|
21
29
|
dayLabelPrefix = 'D',
|
|
30
|
+
loading = false,
|
|
31
|
+
error,
|
|
32
|
+
retryHref,
|
|
33
|
+
ariaLabel,
|
|
22
34
|
class: className = '',
|
|
23
35
|
}: Props = $props();
|
|
24
36
|
|
|
25
37
|
const days = $derived(
|
|
26
|
-
Array.from({ length: totalDays }, (_, i) => `${dayLabelPrefix}${i + 1}`)
|
|
38
|
+
Array.from({ length: Number.isSafeInteger(totalDays) && totalDays > 0 && totalDays <= GANTT_LIMITS.days ? totalDays : 0 }, (_, i) => `${dayLabelPrefix}${i + 1}`)
|
|
27
39
|
);
|
|
40
|
+
const i18n = useTranslation();
|
|
41
|
+
const validationError = $derived(validateGantt(loading || error ? [] : tasks, totalDays));
|
|
42
|
+
const displayError = $derived(error || (validationError ? i18n.t(`gantt.${validationError}`) : ''));
|
|
43
|
+
const safeRetryHref = $derived(retryHref?.startsWith('/') && !/^\/[\\/]/.test(retryHref)
|
|
44
|
+
&& !retryHref.includes('\\')
|
|
45
|
+
&& ![...retryHref].some(char => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127) ? retryHref : undefined);
|
|
28
46
|
</script>
|
|
29
47
|
|
|
30
|
-
<div class="sv-lite-gantt-container {className}">
|
|
48
|
+
<div class="sv-lite-gantt-container {className}" aria-busy={loading}>
|
|
31
49
|
<div class="sv-lite-gantt-title">
|
|
32
|
-
<strong>
|
|
50
|
+
<strong>{i18n.t('gantt.title')}</strong> ({i18n.t('gantt.summary', { tasks: tasks.length, days: totalDays })})
|
|
33
51
|
</div>
|
|
34
52
|
|
|
35
|
-
|
|
53
|
+
{#if loading}
|
|
54
|
+
<div role="status">{i18n.t('common.loading')}</div>
|
|
55
|
+
{:else if displayError}
|
|
56
|
+
<div role="alert">{displayError}</div>
|
|
57
|
+
{#if error && safeRetryHref}<a href={safeRetryHref}>{i18n.t('common.retry')}</a>{/if}
|
|
58
|
+
{:else if tasks.length === 0}
|
|
59
|
+
<div role="status">{i18n.t('gantt.noData')}</div>
|
|
60
|
+
{:else}
|
|
61
|
+
<table class="sv-lite-gantt-table" aria-label={ariaLabel ?? i18n.t('gantt.title')}>
|
|
36
62
|
<thead>
|
|
37
63
|
<tr>
|
|
38
|
-
<th class="sv-lite-th-task">
|
|
39
|
-
<th class="sv-lite-th-status">
|
|
64
|
+
<th scope="col" class="sv-lite-th-task">{i18n.t('gantt.task')}</th>
|
|
65
|
+
<th scope="col" class="sv-lite-th-status">{i18n.t('gantt.status')}</th>
|
|
40
66
|
{#each days as day (day)}
|
|
41
|
-
<th class="sv-lite-th-day">{day}</th>
|
|
67
|
+
<th scope="col" class="sv-lite-th-day">{day}</th>
|
|
42
68
|
{/each}
|
|
43
69
|
</tr>
|
|
44
70
|
</thead>
|
|
45
71
|
<tbody>
|
|
46
72
|
{#each tasks as task (task.id)}
|
|
47
73
|
<tr>
|
|
48
|
-
<
|
|
74
|
+
<th scope="row" class="sv-lite-td-task" aria-label={`${task.title}, ${task.milestone
|
|
75
|
+
? i18n.t('gantt.milestone', { day: task.startDay + 1 })
|
|
76
|
+
: i18n.t('gantt.range', { start: task.startDay + 1, end: task.startDay + task.durationDays })}`}>{task.title}</th>
|
|
49
77
|
<td class="sv-lite-td-status">
|
|
50
|
-
<span class="sv-lite-gantt-badge sv-lite-{task.status ?? 'planned'}">{task.status ?? 'planned'}</span>
|
|
78
|
+
<span class="sv-lite-gantt-badge sv-lite-{task.status ?? 'planned'}">{i18n.t(`gantt.${task.status ?? 'planned'}`)}</span>
|
|
51
79
|
</td>
|
|
52
80
|
{#each days as _, dIdx (dIdx)}
|
|
53
81
|
{@const isCovered = dIdx >= task.startDay && dIdx < task.startDay + task.durationDays}
|
|
54
|
-
|
|
82
|
+
{@const isMilestone = task.milestone && dIdx === task.startDay}
|
|
83
|
+
<td aria-label={isMilestone ? `${task.title}, ${i18n.t('gantt.milestone', { day: dIdx + 1 })}`
|
|
84
|
+
: isCovered ? `${task.title}, ${days[dIdx]}, ${i18n.t(`gantt.${task.status ?? 'planned'}`)}` : undefined}
|
|
85
|
+
class="sv-lite-td-cell {isCovered ? 'sv-lite-cell-active sv-lite-bg-' + (task.status ?? 'planned') : ''}">
|
|
55
86
|
{#if isCovered && dIdx === task.startDay && task.progress !== undefined}
|
|
56
87
|
<span class="sv-lite-cell-progress">{task.progress}%</span>
|
|
57
88
|
{/if}
|
|
89
|
+
{#if isMilestone}<span aria-hidden="true">◆</span>{/if}
|
|
58
90
|
</td>
|
|
59
91
|
{/each}
|
|
60
92
|
</tr>
|
|
61
93
|
{/each}
|
|
62
94
|
</tbody>
|
|
63
95
|
</table>
|
|
96
|
+
{/if}
|
|
64
97
|
</div>
|
|
65
98
|
|
|
66
99
|
<style>
|
|
@@ -5,11 +5,17 @@ export interface GanttTask {
|
|
|
5
5
|
durationDays: number;
|
|
6
6
|
progress?: number;
|
|
7
7
|
status?: 'planned' | 'in_progress' | 'completed' | 'delayed';
|
|
8
|
+
dependencies?: string[];
|
|
9
|
+
milestone?: boolean;
|
|
8
10
|
}
|
|
9
11
|
interface Props {
|
|
10
12
|
tasks?: GanttTask[];
|
|
11
13
|
totalDays?: number;
|
|
12
14
|
dayLabelPrefix?: string;
|
|
15
|
+
loading?: boolean;
|
|
16
|
+
error?: string | undefined;
|
|
17
|
+
retryHref?: string;
|
|
18
|
+
ariaLabel?: string;
|
|
13
19
|
class?: string;
|
|
14
20
|
}
|
|
15
21
|
declare const LiteGanttChart: import("svelte").Component<Props, {}, "">;
|