@svadmin/ui 0.88.1 → 0.92.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 +265 -18
- package/dist/components/AutoForm.svelte.d.ts +19 -2
- package/dist/components/AutoTable.svelte +141 -36
- 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 +62 -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/Toast.svelte +13 -2
- 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
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { definedOptions } from '@svadmin/core/options';
|
|
6
6
|
|
|
7
|
-
import { captureAdminContext, captureAuthSession, useResourceContract, getContractFormFields, deriveValidator, useNavigation, useCan, useForm } from '@svadmin/core';
|
|
7
|
+
import { captureAdminContext, captureAuthSession, useResourceContract, getContractFormFields, deriveValidator, useNavigation, useCan, useForm, parseQueryKey } from '@svadmin/core';
|
|
8
|
+
import { useQueryClient } from '@tanstack/svelte-query';
|
|
8
9
|
import { slide } from 'svelte/transition';
|
|
10
|
+
import { untrack } from 'svelte';
|
|
9
11
|
import type { FieldDefinition } from '@svadmin/core';
|
|
10
12
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
11
13
|
|
|
@@ -15,10 +17,13 @@
|
|
|
15
17
|
import { Badge } from './ui/badge/index.js';
|
|
16
18
|
import { Save, ArrowLeft, Loader2, AlertCircle } from '@lucide/svelte';
|
|
17
19
|
import FieldRenderer from './FieldRenderer.svelte';
|
|
20
|
+
import type { UploadCancelHandler, UploadHandler } from './file-upload-types.js';
|
|
18
21
|
import * as Alert from './ui/alert/index.js';
|
|
19
22
|
import { Skeleton } from './ui/skeleton/index.js';
|
|
20
23
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
21
24
|
import ErrorSummary from './ErrorSummary.svelte';
|
|
25
|
+
import { withoutArrayFieldMetadata } from './fields/array-field-metadata.js';
|
|
26
|
+
import { schemaFormSnapshot } from './json-schema-form-state.js';
|
|
22
27
|
import { cn } from '../utils.js';
|
|
23
28
|
import type { Snippet } from 'svelte';
|
|
24
29
|
|
|
@@ -27,18 +32,30 @@
|
|
|
27
32
|
interface Props {
|
|
28
33
|
resourceName: string;
|
|
29
34
|
rendering?: ResourceRendering | undefined;
|
|
30
|
-
id?: string | number;
|
|
35
|
+
id?: string | number | undefined;
|
|
31
36
|
mode?: 'create' | 'edit' | 'clone' | 'show';
|
|
32
37
|
columns?: 1 | 2 | 3 | 4;
|
|
33
38
|
showHeader?: boolean;
|
|
34
39
|
fieldRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; onchange: (v: unknown) => void }]>;
|
|
35
|
-
formActions?: Snippet<[{ isLoading: boolean; onSubmit: () => void }]>;
|
|
40
|
+
formActions?: Snippet<[{ isLoading: boolean; isDisabled: boolean; onSubmit: () => void }]>;
|
|
36
41
|
headerContent?: Snippet;
|
|
37
42
|
onSuccess?: () => void;
|
|
38
43
|
redirect?: 'list' | false;
|
|
39
44
|
onCancel?: () => void;
|
|
40
45
|
onNavigationGuardReady?: (guard: (fn: () => void) => void) => void;
|
|
46
|
+
/**
|
|
47
|
+
* 可选:当当前表单作用域的真实未保存状态变化时通知宿主。
|
|
48
|
+
* 状态只由 `form.isTainted()` 与上传阻塞派生,不根据 DOM 事件或值比较猜测。
|
|
49
|
+
*/
|
|
50
|
+
onDirtyChange?: (dirty: boolean) => void;
|
|
41
51
|
showErrorSummary?: boolean;
|
|
52
|
+
/** 透传给 file 字段的异步上传;成功后字段值只保存 url/uploadId 引用。 */
|
|
53
|
+
upload?: UploadHandler;
|
|
54
|
+
cancelUpload?: UploadCancelHandler;
|
|
55
|
+
/** 宿主显式选择的草稿字段白名单;只包含当前可编辑表单内的字段。 */
|
|
56
|
+
draftFields?: readonly string[];
|
|
57
|
+
/** 仅在宿主显式订阅时输出可分离的字段草稿;不可恢复状态下输出 undefined。 */
|
|
58
|
+
onDraftChange?: (draft: Record<string, unknown> | undefined) => void;
|
|
42
59
|
}
|
|
43
60
|
|
|
44
61
|
let {
|
|
@@ -55,7 +72,12 @@
|
|
|
55
72
|
redirect = 'list',
|
|
56
73
|
onCancel,
|
|
57
74
|
onNavigationGuardReady,
|
|
75
|
+
onDirtyChange,
|
|
58
76
|
showErrorSummary = true,
|
|
77
|
+
upload,
|
|
78
|
+
cancelUpload,
|
|
79
|
+
draftFields,
|
|
80
|
+
onDraftChange,
|
|
59
81
|
}: Props = $props();
|
|
60
82
|
const navigation = useNavigation();
|
|
61
83
|
const context = captureAdminContext();
|
|
@@ -77,6 +99,8 @@
|
|
|
77
99
|
}));
|
|
78
100
|
const permissionPending = $derived(mode === 'create' ? createPermission.isLoading
|
|
79
101
|
: readPermission.isLoading || (mode === 'clone' && createPermission.isLoading));
|
|
102
|
+
const permissionError = $derived(mode === 'create' ? createPermission.isError
|
|
103
|
+
: readPermission.isError || (mode === 'clone' && createPermission.isError));
|
|
80
104
|
const allowed = $derived(mode === 'create' ? resource.canCreate !== false && createPermission.allowed
|
|
81
105
|
: mode === 'edit' ? resource.canEdit !== false && readPermission.allowed
|
|
82
106
|
: mode === 'show' ? resource.canShow !== false && readPermission.allowed
|
|
@@ -135,6 +159,8 @@
|
|
|
135
159
|
case 'select': return field.options?.[0]?.value ?? '';
|
|
136
160
|
case 'relation': return field.multiple ? [] : null;
|
|
137
161
|
case 'json': return {};
|
|
162
|
+
// 文件/引用的空值不是字符串;未上传时保持 undefined 不进入合同快照。
|
|
163
|
+
case 'file': return undefined;
|
|
138
164
|
default: return '';
|
|
139
165
|
}
|
|
140
166
|
}
|
|
@@ -157,6 +183,8 @@
|
|
|
157
183
|
get dataProviderName() { return binding.dataProviderName; },
|
|
158
184
|
get redirect() { return redirect; },
|
|
159
185
|
warnWhenUnsavedChanges: true,
|
|
186
|
+
// 失败已经显示在表单内,不再额外弹一条相同的 toast。
|
|
187
|
+
errorNotification: false,
|
|
160
188
|
get validate() { return validator; },
|
|
161
189
|
get onMutationSuccess() {
|
|
162
190
|
const callback = onSuccess;
|
|
@@ -169,13 +197,21 @@
|
|
|
169
197
|
? [{ fieldKey: field.key, label: field.label || field.key, message }] : [];
|
|
170
198
|
}));
|
|
171
199
|
|
|
172
|
-
const
|
|
200
|
+
const authoritativeDraft = $derived.by(() => {
|
|
173
201
|
try {
|
|
174
202
|
return { ok: true, values: form.ready && activeRendering ? activeRendering.draft(mode, form.values) : form.values };
|
|
175
203
|
} catch {
|
|
176
204
|
return { ok: false, values: {} };
|
|
177
205
|
}
|
|
178
206
|
});
|
|
207
|
+
let acceptedDraft = $state.raw<{
|
|
208
|
+
snapshot: typeof authoritativeDraft;
|
|
209
|
+
values: Record<string, unknown>;
|
|
210
|
+
}>();
|
|
211
|
+
// 仅回显本次写入已确认的 UI 身份;新的 Core 快照使缓存立即失效。
|
|
212
|
+
const renderedDraft = $derived(acceptedDraft?.snapshot === authoritativeDraft
|
|
213
|
+
? { ok: authoritativeDraft.ok, values: acceptedDraft.values }
|
|
214
|
+
: authoritativeDraft);
|
|
179
215
|
|
|
180
216
|
// ─── Submission error (non-field, e.g. network error) ─────────────
|
|
181
217
|
let submitError = $state<string | null>(null);
|
|
@@ -188,13 +224,135 @@
|
|
|
188
224
|
});
|
|
189
225
|
let mounted = true;
|
|
190
226
|
const currentScope = (origin: typeof scope) => mounted && scope === origin && origin.session.isCurrent();
|
|
227
|
+
|
|
228
|
+
// ─── Host-owned draft contract ────────────────────────────────────
|
|
229
|
+
// 只复制纯 JSON 值;调用方永远拿不到 form.values 的内部引用。
|
|
230
|
+
// 复用既有有界纯 JSON 快照:不读 getter、不执行序列化钩子,并受深度/节点预算保护。
|
|
231
|
+
function collectDraftOutput(): Record<string, unknown> | undefined {
|
|
232
|
+
const fields = draftFields;
|
|
233
|
+
if (!fields || fields.length === 0) return undefined;
|
|
234
|
+
const allowlist = new Set(fields);
|
|
235
|
+
const draft: Record<string, unknown> = {};
|
|
236
|
+
for (const field of formFields) {
|
|
237
|
+
if (!allowlist.has(field.key)) continue;
|
|
238
|
+
if (!Object.prototype.hasOwnProperty.call(form.values, field.key)) continue;
|
|
239
|
+
const value = form.values[field.key];
|
|
240
|
+
if (value === undefined) continue;
|
|
241
|
+
try {
|
|
242
|
+
draft[field.key] = schemaFormSnapshot({ field: value })['field'];
|
|
243
|
+
} catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return draft;
|
|
248
|
+
}
|
|
249
|
+
function draftSignature(draft: Record<string, unknown> | undefined): string {
|
|
250
|
+
return draft === undefined ? 'undefined' : JSON.stringify(draft);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 宿主显式恢复入口。候选先整体做纯 JSON 快照,再检查白名单与真实可编辑字段;
|
|
255
|
+
* 全部通过后只调用一次 `form.setValues` 原子写入。业务必填/格式校验留到提交;
|
|
256
|
+
* 提交中、上传阻塞、只读、未授权、换作用域或已卸载时返回 false,异常不外抛。
|
|
257
|
+
*/
|
|
258
|
+
export function restoreDraft(candidate: Record<string, unknown>): boolean {
|
|
259
|
+
try {
|
|
260
|
+
if (!mounted || !currentScope(scope) || !form.ready || !allowed || isReadonly
|
|
261
|
+
|| form.submitting || uploadBlocked) return false;
|
|
262
|
+
const fields = draftFields;
|
|
263
|
+
if (!fields || fields.length === 0) return false;
|
|
264
|
+
let snapshot: Record<string, unknown>;
|
|
265
|
+
try {
|
|
266
|
+
snapshot = schemaFormSnapshot(candidate);
|
|
267
|
+
} catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
const allowlist = new Set(fields);
|
|
271
|
+
const editable = new Set(formFields.map(field => field.key));
|
|
272
|
+
const values: Record<string, unknown> = {};
|
|
273
|
+
for (const [key, value] of Object.entries(snapshot)) {
|
|
274
|
+
if (!allowlist.has(key) || !editable.has(key)) return false;
|
|
275
|
+
values[key] = value;
|
|
276
|
+
}
|
|
277
|
+
if (Object.keys(values).length === 0) return false;
|
|
278
|
+
form.setValues(values, { taint: true });
|
|
279
|
+
return true;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let lastDraftScope: typeof scope | undefined;
|
|
286
|
+
let lastDraftCallback: Props['onDraftChange'];
|
|
287
|
+
let lastDraftSignature: string | undefined;
|
|
288
|
+
$effect(() => {
|
|
289
|
+
const origin = scope;
|
|
290
|
+
const fields = draftFields;
|
|
291
|
+
const callback = onDraftChange;
|
|
292
|
+
const ready = form.ready;
|
|
293
|
+
const permitted = allowed;
|
|
294
|
+
const readonly = isReadonly;
|
|
295
|
+
const submitting = form.submitting;
|
|
296
|
+
const renderingOk = renderedDraft.ok;
|
|
297
|
+
if (!fields || fields.length === 0 || !callback) return;
|
|
298
|
+
if (!currentScope(origin)) return;
|
|
299
|
+
const usable = ready && permitted && !readonly && !submitting && renderingOk;
|
|
300
|
+
const output = usable ? collectDraftOutput() : undefined;
|
|
301
|
+
const signature = draftSignature(output);
|
|
302
|
+
if (lastDraftScope !== origin || lastDraftCallback !== callback) {
|
|
303
|
+
lastDraftScope = origin;
|
|
304
|
+
lastDraftCallback = callback;
|
|
305
|
+
lastDraftSignature = undefined;
|
|
306
|
+
}
|
|
307
|
+
if (lastDraftSignature === signature) return;
|
|
308
|
+
lastDraftSignature = signature;
|
|
309
|
+
untrack(() => callback(output));
|
|
310
|
+
});
|
|
311
|
+
|
|
191
312
|
let submission: object | undefined;
|
|
313
|
+
interface UploadState { busy: boolean; unresolved: boolean }
|
|
314
|
+
// 按字段记录上传队列:busy 阻止提交并驱动 loading,unresolved 阻止提交。
|
|
315
|
+
let uploadStates = $state<Record<string, UploadState>>({});
|
|
316
|
+
const uploadPending = $derived(Object.values(uploadStates).some(state => state.busy));
|
|
317
|
+
const uploadBlocked = $derived(Object.values(uploadStates).some(state => state.busy || state.unresolved));
|
|
318
|
+
let lastDirty: boolean | undefined;
|
|
319
|
+
let lastDirtyScope: typeof scope | undefined;
|
|
320
|
+
$effect(() => {
|
|
321
|
+
const origin = scope;
|
|
322
|
+
const dirty = form.isTainted() || uploadBlocked;
|
|
323
|
+
if (!currentScope(origin)) return;
|
|
324
|
+
if (lastDirtyScope !== origin) {
|
|
325
|
+
lastDirtyScope = origin;
|
|
326
|
+
lastDirty = undefined;
|
|
327
|
+
}
|
|
328
|
+
if (lastDirty === dirty) return;
|
|
329
|
+
lastDirty = dirty;
|
|
330
|
+
untrack(() => onDirtyChange?.(dirty));
|
|
331
|
+
});
|
|
332
|
+
// 记录/provider/auth 作用域变化时,只重挂载文件字段并丢弃旧回执。
|
|
333
|
+
let resetEpoch = $state(0);
|
|
334
|
+
const fileScopeKey = $derived({ scope, resetEpoch, upload, cancelUpload });
|
|
335
|
+
$effect(() => {
|
|
336
|
+
void fileScopeKey;
|
|
337
|
+
uploadStates = {};
|
|
338
|
+
});
|
|
192
339
|
$effect(() => {
|
|
193
340
|
void scope;
|
|
194
341
|
submission = undefined;
|
|
195
342
|
submitError = null;
|
|
196
343
|
confirmOpen = false;
|
|
197
344
|
pendingNavigation = null;
|
|
345
|
+
uploadStates = {};
|
|
346
|
+
});
|
|
347
|
+
const setUploadState = $derived.by(() => {
|
|
348
|
+
const origin = scope;
|
|
349
|
+
const originFileScope = fileScopeKey;
|
|
350
|
+
return (fieldKey: string, state: UploadState) => {
|
|
351
|
+
if (!currentScope(origin) || fileScopeKey !== originFileScope) return;
|
|
352
|
+
const previous = uploadStates[fieldKey];
|
|
353
|
+
if (previous?.busy === state.busy && previous?.unresolved === state.unresolved) return;
|
|
354
|
+
uploadStates = { ...uploadStates, [fieldKey]: state };
|
|
355
|
+
};
|
|
198
356
|
});
|
|
199
357
|
$effect(() => () => { mounted = false; submission = undefined; });
|
|
200
358
|
|
|
@@ -202,24 +360,64 @@
|
|
|
202
360
|
return `${instanceId}-${resourceName}-${fieldKey}-error`;
|
|
203
361
|
}
|
|
204
362
|
|
|
363
|
+
const focusableFieldSelector = [
|
|
364
|
+
'input:not([type="hidden"])', 'textarea', 'select', 'button', '[tabindex]',
|
|
365
|
+
].join(', ');
|
|
366
|
+
const richTextFieldSelector = '[contenteditable="true"][role="textbox"]';
|
|
367
|
+
|
|
368
|
+
function isActionableControl(element: HTMLElement): boolean {
|
|
369
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
370
|
+
if (!(parent instanceof HTMLFieldSetElement) || !parent.disabled) continue;
|
|
371
|
+
const legend = Array.from(parent.children).find(child => child instanceof HTMLLegendElement);
|
|
372
|
+
if (!legend?.contains(element)) return false;
|
|
373
|
+
}
|
|
374
|
+
return !element.hasAttribute('disabled')
|
|
375
|
+
&& !element.matches('input[type="hidden"]')
|
|
376
|
+
&& !element.matches(':disabled')
|
|
377
|
+
&& !element.closest('[hidden], [inert], [aria-disabled="true"]');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function focusFirstActionableControl(field: Element, definition?: FieldDefinition): boolean {
|
|
381
|
+
// 仅默认渲染的顶层 richtext 字段优先正文;数组等容器仍按 DOM 顺序,
|
|
382
|
+
// 自定义 fieldRenderer 也不替宿主猜测正文入口。
|
|
383
|
+
if (!fieldRenderer && definition?.type === 'richtext') {
|
|
384
|
+
const richText = field.querySelector<HTMLElement>(richTextFieldSelector);
|
|
385
|
+
if (richText && isActionableControl(richText)) {
|
|
386
|
+
richText.focus();
|
|
387
|
+
if (document.activeElement === richText) return true;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const candidates = field.querySelectorAll<HTMLElement>(focusableFieldSelector);
|
|
391
|
+
for (const candidate of candidates) {
|
|
392
|
+
if (!isActionableControl(candidate)) continue;
|
|
393
|
+
candidate.focus();
|
|
394
|
+
if (document.activeElement === candidate) return true;
|
|
395
|
+
}
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
|
|
205
399
|
function focusFirstInvalidField(): void {
|
|
206
|
-
|
|
207
|
-
|
|
400
|
+
if (!formElement) return;
|
|
401
|
+
for (const field of formElement.querySelectorAll<HTMLElement>('[data-svadmin-form-field]')) {
|
|
402
|
+
const key = field.getAttribute('data-svadmin-field-key');
|
|
403
|
+
const error = key ? form.errors[key] : undefined;
|
|
404
|
+
const definition = key ? formFields.find(fieldDefinition => fieldDefinition.key === key) : undefined;
|
|
405
|
+
if (typeof error === 'string' && error.length > 0 && focusFirstActionableControl(field, definition)) return;
|
|
406
|
+
}
|
|
208
407
|
}
|
|
209
408
|
|
|
210
409
|
function focusField(fieldKey: string): void {
|
|
211
410
|
const field = formElement?.querySelector<HTMLElement>(
|
|
212
|
-
`[data-svadmin-field-key="${CSS.escape(fieldKey)}"]`,
|
|
411
|
+
`[data-svadmin-form-field][data-svadmin-field-key="${CSS.escape(fieldKey)}"]`,
|
|
213
412
|
);
|
|
214
|
-
field
|
|
215
|
-
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])',
|
|
216
|
-
)?.focus();
|
|
413
|
+
if (field) focusFirstActionableControl(field, formFields.find(definition => definition.key === fieldKey));
|
|
217
414
|
}
|
|
218
415
|
|
|
219
416
|
const handleSubmit = $derived.by(() => {
|
|
220
417
|
const current = scope;
|
|
221
418
|
return async () => {
|
|
222
|
-
if (!currentScope(current) || !allowed || isReadonly || !form.ready || form.submitting
|
|
419
|
+
if (!currentScope(current) || !allowed || isReadonly || !form.ready || form.submitting
|
|
420
|
+
|| uploadBlocked || submission) return;
|
|
223
421
|
const token = {};
|
|
224
422
|
submission = token;
|
|
225
423
|
submitError = null;
|
|
@@ -244,7 +442,15 @@
|
|
|
244
442
|
const fieldChange = $derived.by(() => {
|
|
245
443
|
const origin = scope;
|
|
246
444
|
return (key: string) => (value: unknown) => {
|
|
247
|
-
if (currentScope(origin)
|
|
445
|
+
if (!currentScope(origin) || !form.ready || !allowed || isReadonly) return;
|
|
446
|
+
const previous = renderedDraft.values;
|
|
447
|
+
form.setFieldValue(key, withoutArrayFieldMetadata(value));
|
|
448
|
+
if (currentScope(origin) && authoritativeDraft.ok) {
|
|
449
|
+
acceptedDraft = {
|
|
450
|
+
snapshot: authoritativeDraft,
|
|
451
|
+
values: { ...previous, [key]: value },
|
|
452
|
+
};
|
|
453
|
+
}
|
|
248
454
|
};
|
|
249
455
|
});
|
|
250
456
|
const retry = $derived.by(() => {
|
|
@@ -252,6 +458,32 @@
|
|
|
252
458
|
const refetch = form.query.refetch;
|
|
253
459
|
return () => { if (currentScope(origin)) void refetch().catch(() => {}); };
|
|
254
460
|
});
|
|
461
|
+
const queryClient = useQueryClient();
|
|
462
|
+
function refreshPermission() {
|
|
463
|
+
const resource = resourceName;
|
|
464
|
+
void queryClient.invalidateQueries({
|
|
465
|
+
predicate: query => {
|
|
466
|
+
const parsed = parseQueryKey(query.queryKey);
|
|
467
|
+
return parsed?.kind === 'access' && parsed.resource === resource;
|
|
468
|
+
},
|
|
469
|
+
}).catch(() => {});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 显式 reset 入口:宿主通过 bind:this 调用,恢复 Core 初始草稿并清理本实例
|
|
473
|
+
// 的提交令牌/提示;不触发导航,也不改变只读或未授权状态。
|
|
474
|
+
export function reset(): void {
|
|
475
|
+
if (!allowed || isReadonly) return;
|
|
476
|
+
submission = undefined;
|
|
477
|
+
submitError = null;
|
|
478
|
+
// reset 后基线不再 tainted,旧的关闭保护与其待执行导航必须一起退休,
|
|
479
|
+
// 否则后续确认会执行 reset 前捕获的导航。
|
|
480
|
+
confirmOpen = false;
|
|
481
|
+
pendingNavigation = null;
|
|
482
|
+
uploadStates = {};
|
|
483
|
+
// reset token 重挂载文件字段:旧 FileUpload 会按 unmount 回收在途上传。
|
|
484
|
+
resetEpoch += 1;
|
|
485
|
+
form.reset();
|
|
486
|
+
}
|
|
255
487
|
|
|
256
488
|
const pageTitle = $derived(
|
|
257
489
|
mode === 'create'
|
|
@@ -272,7 +504,7 @@
|
|
|
272
504
|
return (fn: () => void) => {
|
|
273
505
|
if (!currentScope(origin)) return;
|
|
274
506
|
if (form.submitting) return;
|
|
275
|
-
if (form.isTainted()) {
|
|
507
|
+
if (form.isTainted() || uploadBlocked) {
|
|
276
508
|
pendingNavigation = () => { if (currentScope(origin)) fn(); };
|
|
277
509
|
confirmOpen = true;
|
|
278
510
|
} else {
|
|
@@ -343,7 +575,14 @@
|
|
|
343
575
|
</div>
|
|
344
576
|
</div>
|
|
345
577
|
{:else if !allowed}
|
|
346
|
-
|
|
578
|
+
{#if permissionError}
|
|
579
|
+
<p role="alert">{i18n.t('common.permissionUnavailable')}</p>
|
|
580
|
+
<Button type="button" variant="outline" onclick={refreshPermission}>
|
|
581
|
+
{i18n.t('common.retry')}
|
|
582
|
+
</Button>
|
|
583
|
+
{:else}
|
|
584
|
+
<p role="alert">{i18n.t('common.permissionDenied')}</p>
|
|
585
|
+
{/if}
|
|
347
586
|
{:else if !form.ready && form.error}
|
|
348
587
|
<p role="alert">{i18n.t('common.operationFailed')}</p>
|
|
349
588
|
<Button type="button" variant="outline" onclick={retry}>
|
|
@@ -382,7 +621,7 @@
|
|
|
382
621
|
<Card.Content class="svadmin-u-f0faeb26d656 svadmin-u-7a9aabfcd059 svadmin-u-9fcd8a13827e svadmin-u-050494726fba svadmin-u-9335c39f6eff">
|
|
383
622
|
<div class={gridClass}>
|
|
384
623
|
{#each group.fields as field (field.key)}
|
|
385
|
-
<div data-svadmin-field-key={field.key} class={cn(columns > 1 && isFullWidthField(field) && 'svadmin-u-2c955d1b45df', !!form.errors[field.key] && 'svadmin-u-ee1a5af3aa10')}>
|
|
624
|
+
<div data-svadmin-form-field data-svadmin-field-key={field.key} class={cn(columns > 1 && isFullWidthField(field) && 'svadmin-u-2c955d1b45df', !!form.errors[field.key] && 'svadmin-u-ee1a5af3aa10')}>
|
|
386
625
|
{#if fieldRenderer}
|
|
387
626
|
{@render fieldRenderer({ field, value: renderedDraft.values[field.key], onchange: fieldChange(field.key) })}
|
|
388
627
|
{:else}
|
|
@@ -393,6 +632,10 @@
|
|
|
393
632
|
invalid={!!form.errors[field.key]}
|
|
394
633
|
{...definedOptions({ "errorId": form.errors[field.key] ? fieldErrorId(field.key) : undefined })}
|
|
395
634
|
disabled={isReadonly}
|
|
635
|
+
fileReferenceMode
|
|
636
|
+
{fileScopeKey}
|
|
637
|
+
{...definedOptions({ upload, cancelUpload })}
|
|
638
|
+
onUploadStateChange={(state: UploadState) => setUploadState(field.key, state)}
|
|
396
639
|
/>
|
|
397
640
|
{/if}
|
|
398
641
|
{#if form.errors[field.key]}
|
|
@@ -409,7 +652,7 @@
|
|
|
409
652
|
<Card.Content class="svadmin-u-f0faeb26d656 svadmin-u-52be28846b5f svadmin-u-9fcd8a13827e svadmin-u-7a9aabfcd059 svadmin-u-0a58453f3755 svadmin-u-050494726fba">
|
|
410
653
|
<div class={gridClass} data-svadmin-form-grid data-columns={columns}>
|
|
411
654
|
{#each formFields as field (field.key)}
|
|
412
|
-
<div data-svadmin-field-key={field.key} class={cn(columns > 1 && isFullWidthField(field) && 'svadmin-u-2c955d1b45df', !!form.errors[field.key] && 'svadmin-u-ee1a5af3aa10')}>
|
|
655
|
+
<div data-svadmin-form-field data-svadmin-field-key={field.key} class={cn(columns > 1 && isFullWidthField(field) && 'svadmin-u-2c955d1b45df', !!form.errors[field.key] && 'svadmin-u-ee1a5af3aa10')}>
|
|
413
656
|
{#if fieldRenderer}
|
|
414
657
|
{@render fieldRenderer({ field, value: renderedDraft.values[field.key], onchange: fieldChange(field.key) })}
|
|
415
658
|
{:else}
|
|
@@ -420,6 +663,10 @@
|
|
|
420
663
|
invalid={!!form.errors[field.key]}
|
|
421
664
|
{...definedOptions({ "errorId": form.errors[field.key] ? fieldErrorId(field.key) : undefined })}
|
|
422
665
|
disabled={isReadonly}
|
|
666
|
+
fileReferenceMode
|
|
667
|
+
{fileScopeKey}
|
|
668
|
+
{...definedOptions({ upload, cancelUpload })}
|
|
669
|
+
onUploadStateChange={(state: UploadState) => setUploadState(field.key, state)}
|
|
423
670
|
/>
|
|
424
671
|
{/if}
|
|
425
672
|
{#if form.errors[field.key]}
|
|
@@ -434,9 +681,9 @@
|
|
|
434
681
|
|
|
435
682
|
<div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-1004c0c3954c">
|
|
436
683
|
{#if formActions && !isReadonly}
|
|
437
|
-
{@render formActions({ isLoading: form.submitting, onSubmit: handleSubmit })}
|
|
684
|
+
{@render formActions({ isLoading: form.submitting || uploadPending, isDisabled: form.submitting || uploadBlocked, onSubmit: handleSubmit })}
|
|
438
685
|
{:else if !isReadonly}
|
|
439
|
-
<Button type="submit" size="default" disabled={form.submitting}>
|
|
686
|
+
<Button type="submit" size="default" disabled={form.submitting || uploadBlocked}>
|
|
440
687
|
{#if form.submitting}
|
|
441
688
|
<Loader2 class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-afbdd13a380e" data-icon="inline-start" aria-hidden="true" />
|
|
442
689
|
{:else}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { type ResourceRendering } from '../rendering/index.js';
|
|
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
|
interface Props {
|
|
5
6
|
resourceName: string;
|
|
6
7
|
rendering?: ResourceRendering | undefined;
|
|
7
|
-
id?: string | number;
|
|
8
|
+
id?: string | number | undefined;
|
|
8
9
|
mode?: 'create' | 'edit' | 'clone' | 'show';
|
|
9
10
|
columns?: 1 | 2 | 3 | 4;
|
|
10
11
|
showHeader?: boolean;
|
|
@@ -15,6 +16,7 @@ interface Props {
|
|
|
15
16
|
}]>;
|
|
16
17
|
formActions?: Snippet<[{
|
|
17
18
|
isLoading: boolean;
|
|
19
|
+
isDisabled: boolean;
|
|
18
20
|
onSubmit: () => void;
|
|
19
21
|
}]>;
|
|
20
22
|
headerContent?: Snippet;
|
|
@@ -22,8 +24,23 @@ interface Props {
|
|
|
22
24
|
redirect?: 'list' | false;
|
|
23
25
|
onCancel?: () => void;
|
|
24
26
|
onNavigationGuardReady?: (guard: (fn: () => void) => void) => void;
|
|
27
|
+
/**
|
|
28
|
+
* 可选:当当前表单作用域的真实未保存状态变化时通知宿主。
|
|
29
|
+
* 状态只由 `form.isTainted()` 与上传阻塞派生,不根据 DOM 事件或值比较猜测。
|
|
30
|
+
*/
|
|
31
|
+
onDirtyChange?: (dirty: boolean) => void;
|
|
25
32
|
showErrorSummary?: boolean;
|
|
33
|
+
/** 透传给 file 字段的异步上传;成功后字段值只保存 url/uploadId 引用。 */
|
|
34
|
+
upload?: UploadHandler;
|
|
35
|
+
cancelUpload?: UploadCancelHandler;
|
|
36
|
+
/** 宿主显式选择的草稿字段白名单;只包含当前可编辑表单内的字段。 */
|
|
37
|
+
draftFields?: readonly string[];
|
|
38
|
+
/** 仅在宿主显式订阅时输出可分离的字段草稿;不可恢复状态下输出 undefined。 */
|
|
39
|
+
onDraftChange?: (draft: Record<string, unknown> | undefined) => void;
|
|
26
40
|
}
|
|
27
|
-
declare const AutoForm: import("svelte").Component<Props, {
|
|
41
|
+
declare const AutoForm: import("svelte").Component<Props, {
|
|
42
|
+
restoreDraft: (candidate: Record<string, unknown>) => boolean;
|
|
43
|
+
reset: () => void;
|
|
44
|
+
}, "">;
|
|
28
45
|
type AutoForm = ReturnType<typeof AutoForm>;
|
|
29
46
|
export default AutoForm;
|