@svadmin/ui 0.88.1 → 0.91.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/dist/app.css +4 -0
- package/dist/app.theme.css +4 -0
- package/dist/component-registry.svelte.d.ts +2 -2
- package/dist/components/AboutSettings.svelte +23 -4
- package/dist/components/AdminApp.svelte +6 -4
- package/dist/components/AdminApp.svelte.d.ts +3 -3
- package/dist/components/ApiSettings.svelte +43 -27
- package/dist/components/ApprovalActionCard.svelte +8 -9
- package/dist/components/ApprovalCenter.svelte +24 -5
- package/dist/components/AuditLogViewer.svelte +2 -2
- package/dist/components/AutoComplete.svelte +610 -0
- package/dist/components/AutoComplete.svelte.d.ts +25 -0
- package/dist/components/AutoForm.svelte +263 -18
- package/dist/components/AutoForm.svelte.d.ts +19 -2
- package/dist/components/AutoTable.svelte +135 -35
- package/dist/components/AutoTable.svelte.d.ts +8 -0
- package/dist/components/BatchActionBar.svelte +42 -14
- package/dist/components/BatchActionBar.svelte.d.ts +5 -0
- package/dist/components/BoundRecordDetailDrawer.svelte +5 -3
- package/dist/components/CancelTaskButton.svelte +14 -1
- package/dist/components/Cascader.svelte +412 -53
- package/dist/components/Cascader.svelte.d.ts +7 -1
- package/dist/components/ColorPicker.svelte +1 -1
- package/dist/components/ColorPicker.svelte.d.ts +1 -1
- package/dist/components/ComboboxField.svelte +270 -34
- package/dist/components/ComboboxField.svelte.d.ts +4 -0
- package/dist/components/CommandPalette.svelte +4 -4
- package/dist/components/DashboardView.svelte +73 -7
- package/dist/components/DashboardView.svelte.d.ts +3 -1
- package/dist/components/DateRangeInput.svelte +8 -1
- package/dist/components/DetailWorkspaceAsyncSection.svelte +107 -0
- package/dist/components/DetailWorkspaceAsyncSection.svelte.d.ts +36 -0
- package/dist/components/DetailWorkspaceSections.svelte +233 -0
- package/dist/components/DetailWorkspaceSections.svelte.d.ts +9 -0
- package/dist/components/DrawerForm.svelte +78 -9
- package/dist/components/DrawerForm.svelte.d.ts +3 -3
- package/dist/components/ErrorBoundary.svelte +1 -1
- package/dist/components/ErrorComponent.svelte +19 -6
- package/dist/components/ErrorComponent.svelte.d.ts +1 -0
- package/dist/components/ErrorSummary.svelte +9 -4
- package/dist/components/FieldRenderer.svelte +203 -90
- package/dist/components/FieldRenderer.svelte.d.ts +16 -1
- package/dist/components/FileUpload.svelte +116 -51
- package/dist/components/FileUpload.svelte.d.ts +7 -27
- package/dist/components/FileUploadField.svelte +170 -0
- package/dist/components/FileUploadField.svelte.d.ts +21 -0
- package/dist/components/FilterBuilder.svelte +13 -7
- package/dist/components/FilterCollectionInput.svelte +9 -7
- package/dist/components/FilterCollectionInput.svelte.d.ts +3 -0
- package/dist/components/ForgotPasswordPage.svelte +1 -1
- package/dist/components/Header.svelte +1 -1
- package/dist/components/ImportWizard.svelte +197 -28
- package/dist/components/ImportWizard.svelte.d.ts +5 -1
- package/dist/components/InferencerPanel.svelte +4 -2
- package/dist/components/InfiniteList.svelte +52 -9
- package/dist/components/JsonSchemaForm.svelte +237 -42
- package/dist/components/JsonSchemaForm.svelte.d.ts +10 -3
- package/dist/components/KanbanBoard.svelte +43 -7
- package/dist/components/KanbanBoard.svelte.d.ts +2 -2
- package/dist/components/Layout.svelte +1 -1
- package/dist/components/LoginPage.svelte +1 -1
- package/dist/components/MetricStrip.svelte +8 -3
- package/dist/components/MetricStrip.svelte.d.ts +1 -0
- package/dist/components/ModalForm.svelte +85 -9
- package/dist/components/ModalForm.svelte.d.ts +3 -3
- package/dist/components/MoneyInput.svelte +5 -3
- package/dist/components/MoneyInput.svelte.d.ts +4 -2
- package/dist/components/MultiTabKeepAlive.svelte +502 -43
- package/dist/components/MultiTabKeepAlive.svelte.d.ts +40 -6
- package/dist/components/NotificationsSettings.svelte +10 -10
- package/dist/components/NumberInput.svelte +5 -3
- package/dist/components/NumberInput.svelte.d.ts +4 -2
- package/dist/components/NumericInput.svelte +277 -25
- package/dist/components/NumericInput.svelte.d.ts +3 -1
- package/dist/components/PercentInput.svelte +5 -3
- package/dist/components/PercentInput.svelte.d.ts +4 -2
- package/dist/components/PermissionMatrix.svelte +44 -8
- package/dist/components/PermissionMatrix.svelte.d.ts +1 -0
- package/dist/components/ProfilePage.svelte +38 -13
- package/dist/components/RadioGroup.svelte +203 -4
- package/dist/components/RadioGroup.svelte.d.ts +1 -1
- package/dist/components/Rate.svelte +3 -3
- package/dist/components/Rate.svelte.d.ts +2 -2
- package/dist/components/RecordDetailDrawer.svelte +1 -1
- package/dist/components/RecordDetailDrawer.svelte.d.ts +1 -1
- package/dist/components/RegisterPage.svelte +1 -1
- package/dist/components/RelationPicker.svelte +39 -11
- package/dist/components/ResourceAccessGuard.svelte +7 -2
- package/dist/components/RetryTaskButton.svelte +14 -1
- package/dist/components/RolesSettings.svelte +74 -28
- package/dist/components/SecuritySettings.svelte +66 -51
- package/dist/components/SegmentedControl.svelte +4 -2
- package/dist/components/SegmentedControl.svelte.d.ts +1 -1
- package/dist/components/Sidebar.svelte +1 -1
- package/dist/components/StepsForm.svelte +60 -10
- package/dist/components/StepsForm.svelte.d.ts +4 -2
- package/dist/components/SvadminLogo.svelte +1 -0
- package/dist/components/SvarDataGrid.svelte +42 -24
- package/dist/components/SvarDataGrid.svelte.d.ts +10 -10
- package/dist/components/SvarResourceRead.svelte +2 -0
- package/dist/components/SvarResourceTableView.svelte +4 -2
- package/dist/components/TagsInput.svelte +3 -3
- package/dist/components/TagsInput.svelte.d.ts +2 -2
- package/dist/components/TaskDetails.svelte +22 -16
- package/dist/components/TaskDetails.svelte.d.ts +1 -1
- package/dist/components/TaskList.svelte +57 -15
- package/dist/components/TaskQueueDrawer.svelte +54 -34
- package/dist/components/Transfer.svelte +67 -5
- package/dist/components/Transfer.svelte.d.ts +4 -0
- package/dist/components/TreeSelect.svelte +337 -77
- package/dist/components/TreeSelect.svelte.d.ts +4 -0
- package/dist/components/TreeTable.svelte +6 -1
- package/dist/components/TwoFactorAuthPage.svelte +8 -4
- package/dist/components/UpdatePasswordPage.svelte +1 -1
- package/dist/components/VersionDiffViewer.svelte +9 -7
- package/dist/components/account/CompanyProfilePage.svelte +13 -12
- package/dist/components/account/MemberDirectory.svelte +14 -10
- package/dist/components/account/SettingsEnterprisePage.svelte +18 -18
- package/dist/components/content/AuditTimeline.svelte +8 -6
- package/dist/components/content/DataState.svelte +1 -1
- package/dist/components/content/FileList.svelte +44 -13
- package/dist/components/content/FileList.svelte.d.ts +4 -2
- package/dist/components/content/FileListRow.svelte +87 -0
- package/dist/components/content/FileListRow.svelte.d.ts +11 -0
- package/dist/components/content/ImportDropzone.svelte +109 -12
- package/dist/components/content/ImportDropzone.svelte.d.ts +1 -0
- package/dist/components/content/IntegrationCard.svelte +3 -3
- package/dist/components/content/IntegrationCard.svelte.d.ts +2 -0
- package/dist/components/content/MediaThumbnail.svelte +6 -2
- package/dist/components/detail-workspace.d.ts +60 -0
- package/dist/components/detail-workspace.js +3 -0
- package/dist/components/enterprise/json-schema-form.d.ts +1 -1
- package/dist/components/enterprise/recursive-schema-form.d.ts +15 -1
- package/dist/components/enterprise/recursive-schema-form.js +288 -14
- package/dist/components/enterprise/schema-form-references.d.ts +14 -0
- package/dist/components/enterprise/schema-form-references.js +316 -0
- package/dist/components/fields/ArrayField.svelte +178 -14
- package/dist/components/fields/ArrayField.svelte.d.ts +17 -3
- package/dist/components/fields/array-field-metadata.d.ts +4 -0
- package/dist/components/fields/array-field-metadata.js +36 -0
- package/dist/components/file-upload-reference.d.ts +11 -0
- package/dist/components/file-upload-reference.js +50 -0
- package/dist/components/file-upload-types.d.ts +32 -0
- package/dist/components/file-upload-types.js +1 -0
- package/dist/components/form-contract.upload-type-fixture.svelte +108 -0
- package/dist/components/form-contract.upload-type-fixture.svelte.d.ts +18 -0
- package/dist/components/json-schema-form-state.d.ts +19 -0
- package/dist/components/list-deletion.svelte.js +6 -1
- package/dist/components/list-state.svelte.d.ts +12 -1
- package/dist/components/list-state.svelte.js +56 -16
- package/dist/components/numeric-format.d.ts +44 -0
- package/dist/components/numeric-format.js +198 -0
- package/dist/components/record-detail.svelte.js +2 -0
- package/dist/components/saved-list-views.d.ts +9 -2
- package/dist/components/saved-list-views.js +42 -11
- package/dist/components/saved-list-views.svelte.d.ts +3 -0
- package/dist/components/saved-list-views.svelte.js +56 -7
- package/dist/components/task-action.svelte.d.ts +1 -0
- package/dist/components/task-action.svelte.js +55 -3
- package/dist/components/task-utils.d.ts +1 -0
- package/dist/components/task-utils.js +18 -0
- package/dist/components/ui/input/input.svelte +1 -1
- package/dist/components/ui/input/input.svelte.d.ts +1 -1
- package/dist/components/ui/sheet/sheet.svelte +4 -1
- package/dist/components/version-history.d.ts +14 -0
- package/dist/components/version-history.js +32 -0
- package/dist/components/workspace-tab-state.d.ts +78 -0
- package/dist/components/workspace-tab-state.js +587 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +4 -0
- package/package.json +5 -5
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { FieldDefinition } from '@svadmin/core';
|
|
2
2
|
import type { FieldDefinition } from '@svadmin/core';
|
|
3
|
+
import type { UploadCancelHandler, UploadHandler } from './file-upload-types.js';
|
|
3
4
|
import type { Snippet } from 'svelte';
|
|
4
5
|
type $$ComponentProps = {
|
|
5
6
|
field: FieldDefinition;
|
|
@@ -7,8 +8,22 @@ type $$ComponentProps = {
|
|
|
7
8
|
onchange: (val: unknown) => void;
|
|
8
9
|
disabled?: boolean;
|
|
9
10
|
invalid?: boolean;
|
|
10
|
-
errorId?: string;
|
|
11
|
+
errorId?: string | undefined;
|
|
12
|
+
id?: string;
|
|
11
13
|
children?: Snippet;
|
|
14
|
+
/** 嵌套数组的可序列化值由外层 JSON 提交;原始文件保留原生通道。 */
|
|
15
|
+
nativeSubmit?: boolean;
|
|
16
|
+
/** 开启异步上传后,字段值只保存可序列化引用,不再接收 File。 */
|
|
17
|
+
upload?: UploadHandler;
|
|
18
|
+
cancelUpload?: UploadCancelHandler;
|
|
19
|
+
onUploadStateChange?: (state: {
|
|
20
|
+
busy: boolean;
|
|
21
|
+
unresolved: boolean;
|
|
22
|
+
}) => void;
|
|
23
|
+
/** AutoForm 的 JSON 文件模式;独立 FieldRenderer 默认保留原生 multipart。 */
|
|
24
|
+
fileReferenceMode?: boolean;
|
|
25
|
+
/** 记录/上传作用域或 reset 变化时只重挂载文件字段。 */
|
|
26
|
+
fileScopeKey?: string | number | object;
|
|
12
27
|
};
|
|
13
28
|
declare const FieldRenderer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
14
29
|
type FieldRenderer = ReturnType<typeof FieldRenderer>;
|
|
@@ -1,34 +1,19 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
/* eslint-disable no-import-assign */
|
|
3
|
+
export type {
|
|
4
|
+
UploadItemStatus, UploadItem, UploadSession, UploadCancellation,
|
|
5
|
+
UploadHandler, UploadCancelHandler,
|
|
6
|
+
} from './file-upload-types.js';
|
|
7
|
+
</script>
|
|
8
|
+
|
|
1
9
|
<script lang="ts">
|
|
2
10
|
import { onDestroy, untrack } from 'svelte';
|
|
3
11
|
import { X, Upload, RotateCw, Ban } from '@lucide/svelte';
|
|
4
12
|
import { Button } from './ui/button/index.js';
|
|
5
13
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export interface UploadItem {
|
|
10
|
-
id: string;
|
|
11
|
-
file: File;
|
|
12
|
-
status: UploadItemStatus;
|
|
13
|
-
progress: number;
|
|
14
|
-
url?: string;
|
|
15
|
-
error?: string;
|
|
16
|
-
uploadId?: string;
|
|
17
|
-
cleanupStatus?: 'pending' | 'success' | 'error';
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface UploadSession {
|
|
21
|
-
signal: AbortSignal;
|
|
22
|
-
idempotencyKey: string;
|
|
23
|
-
setUploadId: (uploadId: string) => void;
|
|
24
|
-
onProgress: (progress: number) => void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface UploadCancellation {
|
|
28
|
-
uploadId: string;
|
|
29
|
-
idempotencyKey: string;
|
|
30
|
-
reason: 'cancel' | 'remove' | 'replace' | 'scope-change' | 'unmount';
|
|
31
|
-
}
|
|
14
|
+
import type {
|
|
15
|
+
UploadItem, UploadSession, UploadCancellation, UploadHandler, UploadCancelHandler,
|
|
16
|
+
} from './file-upload-types.js';
|
|
32
17
|
|
|
33
18
|
interface Props {
|
|
34
19
|
id?: string;
|
|
@@ -39,8 +24,11 @@ export interface UploadCancellation {
|
|
|
39
24
|
maxSize?: number;
|
|
40
25
|
disabled?: boolean;
|
|
41
26
|
required?: boolean;
|
|
42
|
-
|
|
43
|
-
|
|
27
|
+
ariaLabel?: string;
|
|
28
|
+
invalid?: boolean;
|
|
29
|
+
describedby?: string;
|
|
30
|
+
upload?: UploadHandler;
|
|
31
|
+
cancelUpload?: UploadCancelHandler;
|
|
44
32
|
onChange?: (items: UploadItem[]) => void;
|
|
45
33
|
onReject?: (file: File, reason: string) => void;
|
|
46
34
|
class?: string;
|
|
@@ -55,6 +43,9 @@ export interface UploadCancellation {
|
|
|
55
43
|
maxSize,
|
|
56
44
|
disabled = false,
|
|
57
45
|
required = false,
|
|
46
|
+
ariaLabel,
|
|
47
|
+
invalid = false,
|
|
48
|
+
describedby,
|
|
58
49
|
upload,
|
|
59
50
|
cancelUpload,
|
|
60
51
|
onChange,
|
|
@@ -79,18 +70,53 @@ export interface UploadCancellation {
|
|
|
79
70
|
reason?: UploadCancellation['reason'];
|
|
80
71
|
cleanupStarted?: boolean;
|
|
81
72
|
settled?: boolean;
|
|
73
|
+
// 已成功交付给宿主的尝试不可再被回收,即使 notify 期间宿主同步卸载或抛错。
|
|
74
|
+
succeeded?: boolean;
|
|
82
75
|
}
|
|
83
76
|
const attempts = new Map<string, UploadAttempt>();
|
|
84
77
|
const latestAttempts = new Map<string, UploadAttempt>();
|
|
78
|
+
// 失败但已登记 uploadId 的尝试仍可能持有服务端临时会话,需保留到移除/替换/
|
|
79
|
+
// 卸载/作用域替换或用户重试时,再用原尝试捕获的回调回收。
|
|
80
|
+
const pendingCleanup = new Map<string, UploadAttempt>();
|
|
85
81
|
let scopeEpoch = 0;
|
|
86
82
|
let observedUpload = untrack(() => upload);
|
|
87
83
|
let destroyed = false;
|
|
84
|
+
let pendingNativeReset: { event: Event; form: HTMLFormElement } | undefined;
|
|
85
|
+
|
|
86
|
+
$effect(() => {
|
|
87
|
+
const form = input?.form;
|
|
88
|
+
if (upload || !form) return;
|
|
89
|
+
const reset = (event: Event) => {
|
|
90
|
+
flushNativeReset();
|
|
91
|
+
pendingNativeReset = { event, form };
|
|
92
|
+
// 原生事件可能在监听器之间执行微任务,下一任务才读取最终取消状态。
|
|
93
|
+
setTimeout(flushNativeReset, 0);
|
|
94
|
+
};
|
|
95
|
+
form.addEventListener('reset', reset);
|
|
96
|
+
return () => {
|
|
97
|
+
form.removeEventListener('reset', reset);
|
|
98
|
+
if (pendingNativeReset?.form === form) pendingNativeReset = undefined;
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
function flushNativeReset(): void {
|
|
103
|
+
const reset = pendingNativeReset;
|
|
104
|
+
if (!reset || reset.event.eventPhase !== Event.NONE) return;
|
|
105
|
+
pendingNativeReset = undefined;
|
|
106
|
+
if (destroyed || upload || reset.event.defaultPrevented || input?.form !== reset.form) return;
|
|
107
|
+
items = [];
|
|
108
|
+
rejected = [];
|
|
109
|
+
latestAttempts.clear();
|
|
110
|
+
emitChange();
|
|
111
|
+
}
|
|
88
112
|
|
|
89
113
|
$effect.pre(() => {
|
|
90
114
|
if (upload === observedUpload) return;
|
|
91
115
|
observedUpload = upload;
|
|
92
116
|
scopeEpoch += 1;
|
|
93
117
|
untrack(() => {
|
|
118
|
+
// 已失败的尝试没有 controller,但仍持有临时会话,作用域替换时一并回收。
|
|
119
|
+
for (const id of [...pendingCleanup.keys()]) retire(id, 'scope-change');
|
|
94
120
|
const activeIds = [...controllers.keys()];
|
|
95
121
|
for (const id of activeIds) retire(id, 'scope-change');
|
|
96
122
|
const cancelledIds = new Set(activeIds);
|
|
@@ -104,16 +130,20 @@ export interface UploadCancellation {
|
|
|
104
130
|
});
|
|
105
131
|
|
|
106
132
|
function retire(id: string, reason: UploadCancellation['reason'] = 'cancel'): void {
|
|
107
|
-
const attempt = attempts.get(id);
|
|
133
|
+
const attempt = attempts.get(id) ?? pendingCleanup.get(id);
|
|
108
134
|
const controller = controllers.get(id);
|
|
109
135
|
controllers.delete(id);
|
|
110
136
|
attempts.delete(id);
|
|
137
|
+
pendingCleanup.delete(id);
|
|
111
138
|
if (attempt) attempt.reason = reason;
|
|
112
139
|
controller?.abort();
|
|
113
140
|
if (attempt) cleanupAttempt(id, attempt);
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
function cleanupAttempt(id: string, attempt: UploadAttempt): void {
|
|
144
|
+
// 成功附件没有临时会话需要回收;此守卫必须早于 reason 判断,
|
|
145
|
+
// 因为宿主可能在成功通知中同步卸载,retire 会给成功尝试补上 reason。
|
|
146
|
+
if (attempt.succeeded) return;
|
|
117
147
|
if (!attempt.reason || attempt.cleanupStarted) return;
|
|
118
148
|
if (attempt?.uploadId && attempt.cancelUpload) {
|
|
119
149
|
attempt.cleanupStarted = true;
|
|
@@ -137,11 +167,12 @@ export interface UploadCancellation {
|
|
|
137
167
|
|
|
138
168
|
onDestroy(() => {
|
|
139
169
|
destroyed = true;
|
|
140
|
-
for (const id of controllers.keys()) retire(id, 'unmount');
|
|
170
|
+
for (const id of [...new Set([...controllers.keys(), ...pendingCleanup.keys()])]) retire(id, 'unmount');
|
|
141
171
|
latestAttempts.clear();
|
|
142
172
|
});
|
|
143
173
|
|
|
144
174
|
function emitChange(): void {
|
|
175
|
+
syncNativeInput();
|
|
145
176
|
onChange?.(items.map(item => ({ ...item })));
|
|
146
177
|
}
|
|
147
178
|
|
|
@@ -167,6 +198,13 @@ export interface UploadCancellation {
|
|
|
167
198
|
return undefined;
|
|
168
199
|
}
|
|
169
200
|
|
|
201
|
+
function rejectionLabel(reason: string): string {
|
|
202
|
+
if (reason === 'File type is not accepted.') return i18n.t('upload.typeRejected');
|
|
203
|
+
if (reason === 'File is too large.') return i18n.t('upload.tooLarge');
|
|
204
|
+
if (reason === 'Maximum file count exceeded.') return i18n.t('upload.tooMany');
|
|
205
|
+
return i18n.t('upload.failed');
|
|
206
|
+
}
|
|
207
|
+
|
|
170
208
|
const normalizedMaxFiles = $derived(
|
|
171
209
|
multiple ? (Number.isSafeInteger(maxFiles) && maxFiles > 0 ? maxFiles : 10) : 1,
|
|
172
210
|
);
|
|
@@ -193,9 +231,22 @@ export interface UploadCancellation {
|
|
|
193
231
|
emitChange();
|
|
194
232
|
}
|
|
195
233
|
|
|
234
|
+
function syncNativeInput(): void {
|
|
235
|
+
if (destroyed || upload || !input) return;
|
|
236
|
+
// 原生表单提交与已接收队列保持同源,避免提交被拒绝或已移除的文件。
|
|
237
|
+
const transfer = new DataTransfer();
|
|
238
|
+
for (const item of items) transfer.items.add(item.file);
|
|
239
|
+
input.files = transfer.files;
|
|
240
|
+
}
|
|
241
|
+
|
|
196
242
|
async function process(item: UploadItem): Promise<void> {
|
|
197
243
|
if (destroyed || !upload || disabled) return;
|
|
198
244
|
if (controllers.has(item.id) || !items.some(candidate => candidate.id === item.id)) return;
|
|
245
|
+
// 重试复用同一 item:先按替换语义回收旧失败尝试的临时会话,避免泄漏。
|
|
246
|
+
if (pendingCleanup.has(item.id)) {
|
|
247
|
+
latestAttempts.delete(item.id);
|
|
248
|
+
retire(item.id, 'replace');
|
|
249
|
+
}
|
|
199
250
|
const controller = new AbortController();
|
|
200
251
|
controllers.set(item.id, controller);
|
|
201
252
|
const attempt: UploadAttempt = {
|
|
@@ -227,6 +278,7 @@ export interface UploadCancellation {
|
|
|
227
278
|
uploadId: undefined,
|
|
228
279
|
cleanupStatus: undefined,
|
|
229
280
|
});
|
|
281
|
+
let failed = false;
|
|
230
282
|
try {
|
|
231
283
|
// 宿主回调可以同步卸载或替换组件;通知返回后必须重新确认本次请求仍有效。
|
|
232
284
|
if (destroyed || !current() || controller.signal.aborted) return;
|
|
@@ -240,6 +292,9 @@ export interface UploadCancellation {
|
|
|
240
292
|
});
|
|
241
293
|
if (result?.uploadId !== undefined) register(result.uploadId);
|
|
242
294
|
const uploadId = result?.uploadId ?? attempt.uploadId;
|
|
295
|
+
// 先锁定成功语义,再通知宿主;通知中的同步卸载/抛错不得把成功对象
|
|
296
|
+
// 降级为失败或登记为待回收临时会话。
|
|
297
|
+
attempt.succeeded = true;
|
|
243
298
|
updateCurrent({
|
|
244
299
|
status: 'success',
|
|
245
300
|
progress: 100,
|
|
@@ -247,6 +302,8 @@ export interface UploadCancellation {
|
|
|
247
302
|
...(uploadId === undefined ? {} : { uploadId }),
|
|
248
303
|
});
|
|
249
304
|
} catch (error) {
|
|
305
|
+
if (attempt.succeeded) return;
|
|
306
|
+
failed = true;
|
|
250
307
|
if (!current()) return;
|
|
251
308
|
updateItem(item.id, {
|
|
252
309
|
status: controller.signal.aborted ? 'cancelled' : 'error',
|
|
@@ -257,31 +314,30 @@ export interface UploadCancellation {
|
|
|
257
314
|
if (current()) {
|
|
258
315
|
controllers.delete(item.id);
|
|
259
316
|
attempts.delete(item.id);
|
|
317
|
+
if (failed && !controller.signal.aborted && attempt.uploadId && attempt.cancelUpload
|
|
318
|
+
&& !attempt.cleanupStarted && latestAttempts.get(item.id) === attempt) {
|
|
319
|
+
pendingCleanup.set(item.id, attempt);
|
|
320
|
+
}
|
|
260
321
|
}
|
|
261
322
|
}
|
|
262
323
|
}
|
|
263
324
|
|
|
264
325
|
function addFiles(selected: File[]): void {
|
|
326
|
+
flushNativeReset();
|
|
265
327
|
if (destroyed || disabled) return;
|
|
266
|
-
|
|
267
|
-
? Math.max(0, normalizedMaxFiles - items.length)
|
|
268
|
-
: 1;
|
|
328
|
+
syncNativeInput();
|
|
269
329
|
rejected = [];
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (destroyed) return;
|
|
273
|
-
rejected = [...rejected, { name: file.name, reason: 'Maximum file count exceeded.' }];
|
|
274
|
-
onReject?.(file, 'Maximum file count exceeded.');
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
for (const file of selected.slice(0, multiple ? available : Math.min(available, 1))) {
|
|
330
|
+
let accepted = 0;
|
|
331
|
+
for (const file of selected) {
|
|
278
332
|
if (destroyed) return;
|
|
279
|
-
const
|
|
333
|
+
const full = multiple ? items.length >= normalizedMaxFiles : accepted > 0;
|
|
334
|
+
const reason = validate(file) ?? (full ? 'Maximum file count exceeded.' : undefined);
|
|
280
335
|
if (reason) {
|
|
281
336
|
rejected = [...rejected, { name: file.name, reason }];
|
|
282
337
|
onReject?.(file, reason);
|
|
283
338
|
continue;
|
|
284
339
|
}
|
|
340
|
+
accepted += 1;
|
|
285
341
|
const item: UploadItem = { id: nextId(), file, status: 'queued', progress: 0 };
|
|
286
342
|
if (!multiple) {
|
|
287
343
|
for (const previous of items) {
|
|
@@ -308,7 +364,7 @@ export interface UploadCancellation {
|
|
|
308
364
|
retire(id, 'remove');
|
|
309
365
|
latestAttempts.delete(id);
|
|
310
366
|
items = items.filter(item => item.id !== id);
|
|
311
|
-
if (input) input.value = '';
|
|
367
|
+
if (input && upload) input.value = '';
|
|
312
368
|
emitChange();
|
|
313
369
|
}
|
|
314
370
|
|
|
@@ -324,6 +380,7 @@ export interface UploadCancellation {
|
|
|
324
380
|
}
|
|
325
381
|
|
|
326
382
|
function handleKeydown(event: KeyboardEvent): void {
|
|
383
|
+
if (disabled) return;
|
|
327
384
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
328
385
|
event.preventDefault();
|
|
329
386
|
input?.click();
|
|
@@ -341,6 +398,9 @@ export interface UploadCancellation {
|
|
|
341
398
|
{multiple}
|
|
342
399
|
disabled={disabled}
|
|
343
400
|
required={upload ? false : required}
|
|
401
|
+
aria-label={ariaLabel}
|
|
402
|
+
aria-invalid={invalid || undefined}
|
|
403
|
+
aria-describedby={describedby}
|
|
344
404
|
class="svadmin-file-upload-input"
|
|
345
405
|
onchange={handleInput}
|
|
346
406
|
/>
|
|
@@ -349,7 +409,12 @@ export interface UploadCancellation {
|
|
|
349
409
|
role="button"
|
|
350
410
|
tabindex={disabled ? -1 : 0}
|
|
351
411
|
aria-disabled={disabled}
|
|
352
|
-
|
|
412
|
+
aria-label={ariaLabel}
|
|
413
|
+
aria-describedby={describedby}
|
|
414
|
+
data-invalid={invalid || undefined}
|
|
415
|
+
onclick={() => {
|
|
416
|
+
if (!disabled) input?.click();
|
|
417
|
+
}}
|
|
353
418
|
onkeydown={handleKeydown}
|
|
354
419
|
ondragover={(event) => event.preventDefault()}
|
|
355
420
|
ondrop={(event) => {
|
|
@@ -367,32 +432,32 @@ export interface UploadCancellation {
|
|
|
367
432
|
{#if rejected.length}
|
|
368
433
|
<ul aria-label={i18n.t('upload.rejectedFiles')}>
|
|
369
434
|
{#each rejected as rejection (rejection)}
|
|
370
|
-
<li>{rejection.name}: {rejection.reason}</li>
|
|
435
|
+
<li>{rejection.name}: {rejectionLabel(rejection.reason)}</li>
|
|
371
436
|
{/each}
|
|
372
437
|
</ul>
|
|
373
438
|
{/if}
|
|
374
439
|
|
|
375
440
|
{#if items.length}
|
|
376
|
-
<ul class="svadmin-file-upload-list" aria-label=
|
|
441
|
+
<ul class="svadmin-file-upload-list" aria-label={i18n.t('upload.selectedFiles')}>
|
|
377
442
|
{#each items as item (item.id)}
|
|
378
443
|
<li>
|
|
379
444
|
<span class="svadmin-file-upload-name">{item.file.name}</span>
|
|
380
445
|
<span class="svadmin-file-upload-status">
|
|
381
|
-
{#if item.status === 'uploading'}{item.progress}%{:else}{item.status}{/if}
|
|
446
|
+
{#if item.status === 'uploading'}{item.progress}%{:else}{i18n.t(`upload.status.${item.status}`)}{/if}
|
|
382
447
|
</span>
|
|
383
448
|
{#if item.status === 'uploading'}
|
|
384
|
-
<Button type="button" variant="ghost" size="icon-sm" aria-label=
|
|
449
|
+
<Button type="button" variant="ghost" size="icon-sm" aria-label={i18n.t('upload.cancel')} title={i18n.t('upload.cancel')} aria-describedby={describedby} aria-invalid={invalid || undefined} onclick={() => cancel(item.id)}>
|
|
385
450
|
<Ban aria-hidden="true" />
|
|
386
451
|
</Button>
|
|
387
452
|
{:else if item.status === 'error' || item.status === 'cancelled'}
|
|
388
|
-
<Button type="button" variant="ghost" size="icon-sm" aria-label=
|
|
453
|
+
<Button type="button" variant="ghost" size="icon-sm" aria-label={i18n.t('upload.retry')} title={i18n.t('upload.retry')} aria-describedby={describedby} aria-invalid={invalid || undefined} {disabled} onclick={() => retry(item)}>
|
|
389
454
|
<RotateCw aria-hidden="true" />
|
|
390
455
|
</Button>
|
|
391
456
|
{/if}
|
|
392
|
-
<Button type="button" variant="ghost" size="icon-sm" aria-label=
|
|
457
|
+
<Button type="button" variant="ghost" size="icon-sm" aria-label={i18n.t('upload.remove')} title={i18n.t('upload.remove')} aria-describedby={describedby} aria-invalid={invalid || undefined} {disabled} onclick={() => remove(item.id)}>
|
|
393
458
|
<X aria-hidden="true" />
|
|
394
459
|
</Button>
|
|
395
|
-
{#if item.error}<span role="alert">{item.
|
|
460
|
+
{#if item.status === 'error' || item.status === 'cancelled'}<span role="alert">{i18n.t(item.status === 'cancelled' ? 'upload.cancelled' : 'upload.failed')}</span>{/if}
|
|
396
461
|
{#if item.cleanupStatus === 'pending'}<span role="status">{i18n.t('upload.cleanupPending')}</span>{/if}
|
|
397
462
|
{#if item.cleanupStatus === 'error'}<span role="alert">{i18n.t('upload.cleanupFailed')}</span>{/if}
|
|
398
463
|
</li>
|
|
@@ -1,25 +1,5 @@
|
|
|
1
|
-
export type UploadItemStatus
|
|
2
|
-
|
|
3
|
-
id: string;
|
|
4
|
-
file: File;
|
|
5
|
-
status: UploadItemStatus;
|
|
6
|
-
progress: number;
|
|
7
|
-
url?: string;
|
|
8
|
-
error?: string;
|
|
9
|
-
uploadId?: string;
|
|
10
|
-
cleanupStatus?: 'pending' | 'success' | 'error';
|
|
11
|
-
}
|
|
12
|
-
export interface UploadSession {
|
|
13
|
-
signal: AbortSignal;
|
|
14
|
-
idempotencyKey: string;
|
|
15
|
-
setUploadId: (uploadId: string) => void;
|
|
16
|
-
onProgress: (progress: number) => void;
|
|
17
|
-
}
|
|
18
|
-
export interface UploadCancellation {
|
|
19
|
-
uploadId: string;
|
|
20
|
-
idempotencyKey: string;
|
|
21
|
-
reason: 'cancel' | 'remove' | 'replace' | 'scope-change' | 'unmount';
|
|
22
|
-
}
|
|
1
|
+
export type { UploadItemStatus, UploadItem, UploadSession, UploadCancellation, UploadHandler, UploadCancelHandler, } from './file-upload-types.js';
|
|
2
|
+
import type { UploadItem, UploadHandler, UploadCancelHandler } from './file-upload-types.js';
|
|
23
3
|
interface Props {
|
|
24
4
|
id?: string;
|
|
25
5
|
name?: string;
|
|
@@ -29,11 +9,11 @@ interface Props {
|
|
|
29
9
|
maxSize?: number;
|
|
30
10
|
disabled?: boolean;
|
|
31
11
|
required?: boolean;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
cancelUpload?:
|
|
12
|
+
ariaLabel?: string;
|
|
13
|
+
invalid?: boolean;
|
|
14
|
+
describedby?: string;
|
|
15
|
+
upload?: UploadHandler;
|
|
16
|
+
cancelUpload?: UploadCancelHandler;
|
|
37
17
|
onChange?: (items: UploadItem[]) => void;
|
|
38
18
|
onReject?: (file: File, reason: string) => void;
|
|
39
19
|
class?: string;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { FieldDefinition } from '@svadmin/core';
|
|
3
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
4
|
+
import { definedOptions } from '@svadmin/core/options';
|
|
5
|
+
import { onDestroy } from 'svelte';
|
|
6
|
+
import { X } from '@lucide/svelte';
|
|
7
|
+
import FileUpload from './FileUpload.svelte';
|
|
8
|
+
import type {
|
|
9
|
+
UploadCancelHandler, UploadHandler, UploadItem,
|
|
10
|
+
} from './file-upload-types.js';
|
|
11
|
+
import { Button } from './ui/button/index.js';
|
|
12
|
+
import {
|
|
13
|
+
mergeUploadReferences, normalizeUploadReferences, uploadReferenceFor,
|
|
14
|
+
uploadReferenceKey, uploadReferenceLabel, type UploadReference,
|
|
15
|
+
} from './file-upload-reference.js';
|
|
16
|
+
|
|
17
|
+
interface UploadState {
|
|
18
|
+
busy: boolean;
|
|
19
|
+
unresolved: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
id?: string;
|
|
24
|
+
field: FieldDefinition;
|
|
25
|
+
value: unknown;
|
|
26
|
+
onchange: (value: unknown) => void;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
invalid?: boolean;
|
|
29
|
+
describedby?: string;
|
|
30
|
+
upload?: UploadHandler;
|
|
31
|
+
cancelUpload?: UploadCancelHandler;
|
|
32
|
+
onUploadStateChange?: (state: UploadState) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
id, field, value, onchange, disabled = false, invalid = false,
|
|
37
|
+
describedby, upload, cancelUpload, onUploadStateChange,
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
const i18n = useTranslation();
|
|
41
|
+
const UPLOADING = new Set<UploadItem['status']>(['queued', 'uploading']);
|
|
42
|
+
let baseline = $state<UploadReference[]>([]);
|
|
43
|
+
let uploaded = $state<UploadReference[]>([]);
|
|
44
|
+
let lastEmitted = '';
|
|
45
|
+
let busy = $state(false);
|
|
46
|
+
let unresolved = $state(false);
|
|
47
|
+
let missingReference = $state(false);
|
|
48
|
+
|
|
49
|
+
// 外部 value 更新基线;本组件自己的写入由队列结算,避免重复回显。
|
|
50
|
+
$effect(() => {
|
|
51
|
+
const next = normalizeUploadReferences(value);
|
|
52
|
+
const key = JSON.stringify(next.map(uploadReferenceKey));
|
|
53
|
+
if (key === lastEmitted) return;
|
|
54
|
+
baseline = next;
|
|
55
|
+
lastEmitted = key;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 单值字段成功上传后替代旧基线;多值字段始终保留原引用。
|
|
59
|
+
const displayedBaseline = $derived(
|
|
60
|
+
field.multiple || uploaded.length === 0 ? baseline : [],
|
|
61
|
+
);
|
|
62
|
+
const merged = $derived.by(() => field.multiple
|
|
63
|
+
? mergeUploadReferences(baseline, uploaded)
|
|
64
|
+
: uploaded.length > 0 ? [uploaded[uploaded.length - 1] as UploadReference] : baseline.slice(0, 1));
|
|
65
|
+
|
|
66
|
+
function report(): void {
|
|
67
|
+
onUploadStateChange?.({ busy, unresolved });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function emit(next: UploadReference[]): void {
|
|
71
|
+
const key = JSON.stringify(next.map(uploadReferenceKey));
|
|
72
|
+
if (key === lastEmitted) return;
|
|
73
|
+
lastEmitted = key;
|
|
74
|
+
onchange(field.multiple ? next : next[0] ?? null);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleItems(items: UploadItem[]): void {
|
|
78
|
+
const successful = items.flatMap(item => {
|
|
79
|
+
const reference = uploadReferenceFor(item);
|
|
80
|
+
return reference === undefined ? [] : [reference];
|
|
81
|
+
});
|
|
82
|
+
missingReference = items.some(item =>
|
|
83
|
+
item.status === 'success' && uploadReferenceFor(item) === undefined);
|
|
84
|
+
busy = items.some(item => UPLOADING.has(item.status));
|
|
85
|
+
unresolved = missingReference || items.some(item =>
|
|
86
|
+
item.status === 'error' || item.status === 'cancelled');
|
|
87
|
+
if (!field.multiple) {
|
|
88
|
+
// 成功替换退休旧引用;下一次上传未成功时保留最近一次成功引用。
|
|
89
|
+
if (successful.length > 0) baseline = [];
|
|
90
|
+
else if (uploaded.length > 0) baseline = items.length > 0 ? uploaded.slice(-1) : [];
|
|
91
|
+
}
|
|
92
|
+
uploaded = successful;
|
|
93
|
+
// 缺少有效标识的成功结果不写入表单;移除该项后会重新结算剩余成功项。
|
|
94
|
+
if (!missingReference) emit(merged);
|
|
95
|
+
report();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function removeBaseline(index: number): void {
|
|
99
|
+
if (disabled) return;
|
|
100
|
+
baseline = baseline.filter((_reference, current) => current !== index);
|
|
101
|
+
emit(merged);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
onDestroy(() => {
|
|
105
|
+
if (!busy && !unresolved) return;
|
|
106
|
+
busy = false;
|
|
107
|
+
unresolved = false;
|
|
108
|
+
report();
|
|
109
|
+
});
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
{#if displayedBaseline.length > 0}
|
|
113
|
+
<ul class="svadmin-upload-reference-list" aria-label={field.label}>
|
|
114
|
+
{#each displayedBaseline as reference, index (uploadReferenceKey(reference) + index)}
|
|
115
|
+
{@const label = uploadReferenceLabel(reference)}
|
|
116
|
+
<li class="svadmin-upload-reference">
|
|
117
|
+
<span class="svadmin-upload-reference-text">{label}</span>
|
|
118
|
+
<Button
|
|
119
|
+
type="button"
|
|
120
|
+
variant="ghost"
|
|
121
|
+
size="icon-sm"
|
|
122
|
+
aria-label={`${i18n.t('common.remove')} ${label}`}
|
|
123
|
+
aria-describedby={describedby}
|
|
124
|
+
aria-invalid={invalid || undefined}
|
|
125
|
+
{disabled}
|
|
126
|
+
onclick={() => removeBaseline(index)}
|
|
127
|
+
>
|
|
128
|
+
<X aria-hidden="true" />
|
|
129
|
+
</Button>
|
|
130
|
+
</li>
|
|
131
|
+
{/each}
|
|
132
|
+
</ul>
|
|
133
|
+
{/if}
|
|
134
|
+
|
|
135
|
+
{#if upload}
|
|
136
|
+
<FileUpload
|
|
137
|
+
{...definedOptions({ id })}
|
|
138
|
+
name={field.key}
|
|
139
|
+
{...definedOptions({ multiple: field.multiple })}
|
|
140
|
+
{...definedOptions({ required: field.required && baseline.length === 0 && uploaded.length === 0 })}
|
|
141
|
+
{disabled}
|
|
142
|
+
ariaLabel={field.label}
|
|
143
|
+
{invalid}
|
|
144
|
+
{...definedOptions({ describedby })}
|
|
145
|
+
{...definedOptions({ accept: field.accept, maxSize: field.maxSize })}
|
|
146
|
+
{upload}
|
|
147
|
+
{...definedOptions({ cancelUpload })}
|
|
148
|
+
onChange={handleItems}
|
|
149
|
+
/>
|
|
150
|
+
{:else}
|
|
151
|
+
<!-- AutoForm 的托管字段没有上传回调时不接受 File,避免 File 进入 JSON 快照。 -->
|
|
152
|
+
<FileUpload
|
|
153
|
+
{...definedOptions({ id, multiple: field.multiple })}
|
|
154
|
+
disabled
|
|
155
|
+
ariaLabel={field.label}
|
|
156
|
+
{invalid}
|
|
157
|
+
{...definedOptions({ describedby })}
|
|
158
|
+
/>
|
|
159
|
+
<p role="alert">{i18n.t('common.operationFailed')}</p>
|
|
160
|
+
{/if}
|
|
161
|
+
|
|
162
|
+
{#if missingReference}
|
|
163
|
+
<p role="alert">{i18n.t('common.operationFailed')}</p>
|
|
164
|
+
{/if}
|
|
165
|
+
|
|
166
|
+
<style>
|
|
167
|
+
.svadmin-upload-reference-list { display: grid; gap: 4px; margin: 0 0 8px; padding: 0; list-style: none; }
|
|
168
|
+
.svadmin-upload-reference { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
|
|
169
|
+
.svadmin-upload-reference-text { overflow: hidden; flex: 1; color: var(--foreground); text-overflow: ellipsis; white-space: nowrap; }
|
|
170
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FieldDefinition } from '@svadmin/core';
|
|
2
|
+
import type { UploadCancelHandler, UploadHandler } from './file-upload-types.js';
|
|
3
|
+
interface UploadState {
|
|
4
|
+
busy: boolean;
|
|
5
|
+
unresolved: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface Props {
|
|
8
|
+
id?: string;
|
|
9
|
+
field: FieldDefinition;
|
|
10
|
+
value: unknown;
|
|
11
|
+
onchange: (value: unknown) => void;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
invalid?: boolean;
|
|
14
|
+
describedby?: string;
|
|
15
|
+
upload?: UploadHandler;
|
|
16
|
+
cancelUpload?: UploadCancelHandler;
|
|
17
|
+
onUploadStateChange?: (state: UploadState) => void;
|
|
18
|
+
}
|
|
19
|
+
declare const FileUploadField: import("svelte").Component<Props, {}, "">;
|
|
20
|
+
type FileUploadField = ReturnType<typeof FileUploadField>;
|
|
21
|
+
export default FileUploadField;
|