@h-ai/ui 0.1.0-alpha.34 → 0.1.0-alpha.36
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 +29 -8
- package/auto-import.js +2 -2
- package/dist/components/compounds/Dropdown.svelte +2 -0
- package/dist/components/compounds/Modal.svelte +22 -24
- package/dist/components/compounds/Pagination.svelte +15 -7
- package/dist/components/compounds/Tabs.svelte +1 -0
- package/dist/components/primitives/IconButton.svelte +1 -15
- package/dist/components/primitives/Select.svelte +3 -1
- package/dist/components/scenes/charts/Chart.svelte +152 -0
- package/dist/components/scenes/charts/Chart.svelte.d.ts +5 -0
- package/dist/components/scenes/charts/chart-theme.d.ts +23 -0
- package/dist/components/scenes/charts/chart-theme.js +44 -0
- package/dist/components/scenes/charts/chart-types.d.ts +77 -0
- package/dist/components/scenes/charts/chart-types.js +7 -0
- package/dist/components/scenes/charts/index.d.ts +8 -0
- package/dist/components/scenes/charts/index.js +7 -0
- package/dist/components/scenes/charts/renderers/AreaChart.svelte +18 -0
- package/dist/components/scenes/charts/renderers/AreaChart.svelte.d.ts +5 -0
- package/dist/components/scenes/charts/renderers/BarChart.svelte +18 -0
- package/dist/components/scenes/charts/renderers/BarChart.svelte.d.ts +5 -0
- package/dist/components/scenes/charts/renderers/LineChart.svelte +571 -0
- package/dist/components/scenes/charts/renderers/LineChart.svelte.d.ts +19 -0
- package/dist/components/scenes/charts/renderers/PieChart.svelte +18 -0
- package/dist/components/scenes/charts/renderers/PieChart.svelte.d.ts +5 -0
- package/dist/components/scenes/charts/renderers/ScatterChart.svelte +18 -0
- package/dist/components/scenes/charts/renderers/ScatterChart.svelte.d.ts +5 -0
- package/dist/components/scenes/crud/CrudPage.svelte +4 -0
- package/dist/components/scenes/crud/crud-types.d.ts +4 -0
- package/dist/components/scenes/index.d.ts +1 -0
- package/dist/components/scenes/index.js +2 -0
- package/dist/components/scenes/storage/AvatarUpload.svelte +21 -71
- package/dist/components/scenes/storage/FileUpload.svelte +24 -88
- package/dist/components/scenes/storage/ImageUpload.svelte +53 -67
- package/dist/components/scenes/types.d.ts +26 -18
- package/dist/internal/icon-safety.d.ts +7 -0
- package/dist/internal/icon-safety.js +33 -0
- package/dist/messages/en-US.json +2 -5
- package/dist/messages/zh-CN.json +2 -5
- package/dist/types.d.ts +7 -0
- package/package.json +3 -2
|
@@ -44,6 +44,10 @@ export interface CrudPaginationConfig {
|
|
|
44
44
|
showJumper?: boolean;
|
|
45
45
|
/** 是否显示总数(默认 true) */
|
|
46
46
|
showTotal?: boolean;
|
|
47
|
+
/** 是否显示页码信息文案(默认 true;紧凑视图可关闭) */
|
|
48
|
+
showPageInfo?: boolean;
|
|
49
|
+
/** 是否显示首页 / 末页跳转按钮(默认 true;紧凑视图可仅保留上一页 / 下一页) */
|
|
50
|
+
showFirstLast?: boolean;
|
|
47
51
|
}
|
|
48
52
|
/** CrudPage 初始列表数据(由 +page.server.ts load 返回) */
|
|
49
53
|
export interface CrudPageData<T = Record<string, unknown>> {
|
|
@@ -23,9 +23,7 @@
|
|
|
23
23
|
accept = 'image/*',
|
|
24
24
|
maxSize = 2 * 1024 * 1024, // 2MB
|
|
25
25
|
disabled = false,
|
|
26
|
-
|
|
27
|
-
presignUrl = '',
|
|
28
|
-
headers = {},
|
|
26
|
+
uploadHandler,
|
|
29
27
|
fallback = '',
|
|
30
28
|
class: className = '',
|
|
31
29
|
onchange,
|
|
@@ -37,6 +35,7 @@
|
|
|
37
35
|
let loading = $state(false)
|
|
38
36
|
let inputElement = $state<HTMLInputElement | undefined>(undefined)
|
|
39
37
|
let localPreviewUrl: string | null = $state(null)
|
|
38
|
+
let activeUpload: AbortController | null = null
|
|
40
39
|
|
|
41
40
|
const sizeClass = $derived({
|
|
42
41
|
'xs': 'w-8 h-8',
|
|
@@ -102,6 +101,7 @@
|
|
|
102
101
|
|
|
103
102
|
$effect(() => {
|
|
104
103
|
return () => {
|
|
104
|
+
activeUpload?.abort()
|
|
105
105
|
if (localPreviewUrl) {
|
|
106
106
|
URL.revokeObjectURL(localPreviewUrl)
|
|
107
107
|
localPreviewUrl = null
|
|
@@ -111,91 +111,41 @@
|
|
|
111
111
|
|
|
112
112
|
// 上传文件
|
|
113
113
|
async function uploadFile(file: File) {
|
|
114
|
+
activeUpload?.abort()
|
|
115
|
+
const controller = new AbortController()
|
|
116
|
+
activeUpload = controller
|
|
114
117
|
loading = true
|
|
115
118
|
|
|
116
119
|
try {
|
|
117
|
-
if (!
|
|
118
|
-
//
|
|
120
|
+
if (!uploadHandler) {
|
|
121
|
+
// 未提供上传处理器时,仅保留本地预览。
|
|
119
122
|
replaceAvatarValue(URL.createObjectURL(file))
|
|
120
123
|
return
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (presignUrl) {
|
|
127
|
-
const presignResponse = await fetch(presignUrl, {
|
|
128
|
-
method: 'POST',
|
|
129
|
-
headers: {
|
|
130
|
-
'Content-Type': 'application/json',
|
|
131
|
-
...headers,
|
|
132
|
-
},
|
|
133
|
-
body: JSON.stringify({
|
|
134
|
-
filename: file.name,
|
|
135
|
-
contentType: file.type,
|
|
136
|
-
size: file.size,
|
|
137
|
-
}),
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
if (!presignResponse.ok) {
|
|
141
|
-
throw new Error(uiM('avatar_upload_get_url_failed'))
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const data = await presignResponse.json()
|
|
145
|
-
if (typeof data?.url !== 'string' || !SAFE_HTTP_URL_REGEX.test(data.url)) {
|
|
146
|
-
throw new Error(uiM('avatar_upload_get_url_failed'))
|
|
147
|
-
}
|
|
148
|
-
targetUrl = data.url
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// 上传文件
|
|
152
|
-
const response = await fetch(targetUrl!, {
|
|
153
|
-
method: 'PUT',
|
|
154
|
-
headers: {
|
|
155
|
-
'Content-Type': file.type,
|
|
156
|
-
...headers,
|
|
157
|
-
},
|
|
158
|
-
body: file,
|
|
126
|
+
const result = await uploadHandler(file, {
|
|
127
|
+
signal: controller.signal,
|
|
128
|
+
onProgress: () => {},
|
|
159
129
|
})
|
|
160
|
-
|
|
161
|
-
if (!response.ok) {
|
|
130
|
+
if (!result.url || !isSafePreviewUrl(result.url)) {
|
|
162
131
|
throw new Error(uiM('avatar_upload_failed'))
|
|
163
132
|
}
|
|
164
133
|
|
|
165
|
-
|
|
166
|
-
let finalUrl = targetUrl!.split('?')[0]
|
|
167
|
-
let responseUrl: string | undefined
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
const data = await response.clone().json()
|
|
171
|
-
if (typeof data?.url === 'string') {
|
|
172
|
-
responseUrl = data.url
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
catch {
|
|
176
|
-
// 响应不是 JSON
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (responseUrl) {
|
|
180
|
-
if (!isSafePreviewUrl(responseUrl)) {
|
|
181
|
-
throw new Error(uiM('avatar_upload_failed'))
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
finalUrl = responseUrl
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (!isSafePreviewUrl(finalUrl)) {
|
|
188
|
-
throw new Error(uiM('avatar_upload_failed'))
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
replaceAvatarValue(finalUrl)
|
|
134
|
+
replaceAvatarValue(result.url)
|
|
192
135
|
}
|
|
193
136
|
catch (error) {
|
|
137
|
+
// 被后续选择替换的请求不得覆盖新请求的 loading 状态或触发旧错误提示。
|
|
138
|
+
if (activeUpload !== controller) {
|
|
139
|
+
return
|
|
140
|
+
}
|
|
194
141
|
const message = error instanceof Error ? error.message : uiM('avatar_upload_failed')
|
|
195
142
|
onerror?.(message)
|
|
196
143
|
}
|
|
197
144
|
finally {
|
|
198
|
-
|
|
145
|
+
if (activeUpload === controller) {
|
|
146
|
+
activeUpload = null
|
|
147
|
+
loading = false
|
|
148
|
+
}
|
|
199
149
|
}
|
|
200
150
|
}
|
|
201
151
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
=============================================================================
|
|
3
3
|
@h-ai/ui - FileUpload 组件
|
|
4
4
|
=============================================================================
|
|
5
|
-
|
|
5
|
+
文件上传组件,负责文件选择、校验、进度与列表展示
|
|
6
6
|
|
|
7
7
|
使用 Svelte 5 Runes ($props, $state, $derived)
|
|
8
8
|
使用 primitives 组件:Progress, IconButton
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
import IconButton from '../../primitives/IconButton.svelte'
|
|
19
19
|
import Progress from '../../primitives/Progress.svelte'
|
|
20
20
|
|
|
21
|
-
const SAFE_HTTP_URL_REGEX = /^https?:\/\//i
|
|
22
21
|
const MAX_PARALLEL_UPLOADS = 3
|
|
23
22
|
|
|
24
23
|
const {
|
|
@@ -27,9 +26,7 @@
|
|
|
27
26
|
maxFiles = 1,
|
|
28
27
|
multiple = false,
|
|
29
28
|
disabled = false,
|
|
30
|
-
|
|
31
|
-
presignUrl = '',
|
|
32
|
-
headers = {},
|
|
29
|
+
uploadHandler,
|
|
33
30
|
autoUpload = true,
|
|
34
31
|
showList = true,
|
|
35
32
|
dragDrop = true,
|
|
@@ -45,7 +42,7 @@
|
|
|
45
42
|
let files = $state<UploadFile[]>([])
|
|
46
43
|
let isDragging = $state(false)
|
|
47
44
|
let inputElement = $state<HTMLInputElement | undefined>(undefined)
|
|
48
|
-
const activeRequests = new SvelteMap<string,
|
|
45
|
+
const activeRequests = new SvelteMap<string, AbortController>()
|
|
49
46
|
|
|
50
47
|
const containerClass = $derived(
|
|
51
48
|
cn(
|
|
@@ -101,20 +98,16 @@
|
|
|
101
98
|
return null
|
|
102
99
|
}
|
|
103
100
|
|
|
104
|
-
function registerActiveRequest(fileId: string,
|
|
105
|
-
activeRequests.set(fileId,
|
|
101
|
+
function registerActiveRequest(fileId: string, controller: AbortController): void {
|
|
102
|
+
activeRequests.set(fileId, controller)
|
|
106
103
|
}
|
|
107
104
|
|
|
108
|
-
function clearActiveRequest(fileId: string,
|
|
105
|
+
function clearActiveRequest(fileId: string, controller?: AbortController): void {
|
|
109
106
|
const current = activeRequests.get(fileId)
|
|
110
|
-
if (!current || (
|
|
107
|
+
if (!current || (controller && current !== controller)) {
|
|
111
108
|
return
|
|
112
109
|
}
|
|
113
110
|
|
|
114
|
-
current.upload.onprogress = null
|
|
115
|
-
current.onload = null
|
|
116
|
-
current.onerror = null
|
|
117
|
-
current.onabort = null
|
|
118
111
|
activeRequests.delete(fileId)
|
|
119
112
|
}
|
|
120
113
|
|
|
@@ -124,8 +117,8 @@
|
|
|
124
117
|
return
|
|
125
118
|
}
|
|
126
119
|
|
|
127
|
-
clearActiveRequest(fileId, current)
|
|
128
120
|
current.abort()
|
|
121
|
+
activeRequests.delete(fileId)
|
|
129
122
|
}
|
|
130
123
|
|
|
131
124
|
function abortAllActiveRequests(): void {
|
|
@@ -198,89 +191,32 @@
|
|
|
198
191
|
|
|
199
192
|
// 上传单个文件
|
|
200
193
|
async function uploadFile(uploadFile: UploadFile) {
|
|
201
|
-
if (!
|
|
202
|
-
uploadFile.error = uiM('file_upload_no_url')
|
|
203
|
-
uploadFile.state = 'error'
|
|
194
|
+
if (!uploadHandler) {
|
|
204
195
|
return
|
|
205
196
|
}
|
|
206
197
|
|
|
207
198
|
uploadFile.state = 'uploading'
|
|
208
199
|
files = [...files]
|
|
209
200
|
|
|
210
|
-
|
|
201
|
+
const controller = new AbortController()
|
|
202
|
+
registerActiveRequest(uploadFile.id, controller)
|
|
211
203
|
|
|
212
204
|
try {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
'Content-Type': 'application/json',
|
|
221
|
-
...headers,
|
|
222
|
-
},
|
|
223
|
-
body: JSON.stringify({
|
|
224
|
-
filename: uploadFile.name,
|
|
225
|
-
contentType: uploadFile.type,
|
|
226
|
-
size: uploadFile.size,
|
|
227
|
-
}),
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
if (!presignResponse.ok) {
|
|
231
|
-
throw new Error(uiM('file_upload_get_url_failed'))
|
|
232
|
-
}
|
|
205
|
+
const result = await uploadHandler(uploadFile.file, {
|
|
206
|
+
signal: controller.signal,
|
|
207
|
+
onProgress(progress) {
|
|
208
|
+
uploadFile.progress = Math.max(0, Math.min(100, Math.round(progress)))
|
|
209
|
+
files = [...files]
|
|
210
|
+
},
|
|
211
|
+
})
|
|
233
212
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
throw new Error(uiM('file_upload_get_url_failed'))
|
|
237
|
-
}
|
|
238
|
-
targetUrl = url
|
|
213
|
+
if (controller.signal.aborted) {
|
|
214
|
+
return
|
|
239
215
|
}
|
|
240
216
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const activeXhr = xhr
|
|
245
|
-
|
|
246
|
-
await new Promise<void>((resolve, reject) => {
|
|
247
|
-
activeXhr.upload.onprogress = (e) => {
|
|
248
|
-
if (e.lengthComputable) {
|
|
249
|
-
uploadFile.progress = Math.round((e.loaded / e.total) * 100)
|
|
250
|
-
files = [...files]
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
activeXhr.onload = () => {
|
|
255
|
-
if (activeXhr.status >= 200 && activeXhr.status < 300) {
|
|
256
|
-
uploadFile.state = 'success'
|
|
257
|
-
uploadFile.progress = 100
|
|
258
|
-
try {
|
|
259
|
-
uploadFile.response = JSON.parse(activeXhr.responseText)
|
|
260
|
-
}
|
|
261
|
-
catch {
|
|
262
|
-
uploadFile.response = activeXhr.responseText
|
|
263
|
-
}
|
|
264
|
-
resolve()
|
|
265
|
-
}
|
|
266
|
-
else {
|
|
267
|
-
reject(new Error(`${uiM('file_upload_failed')}: ${activeXhr.statusText}`))
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
activeXhr.onerror = () => reject(new Error(uiM('file_upload_network_error')))
|
|
272
|
-
activeXhr.onabort = () => reject(new DOMException('Upload aborted', 'AbortError'))
|
|
273
|
-
|
|
274
|
-
activeXhr.open('PUT', targetUrl!)
|
|
275
|
-
activeXhr.setRequestHeader('Content-Type', uploadFile.type || 'application/octet-stream')
|
|
276
|
-
|
|
277
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
278
|
-
activeXhr.setRequestHeader(key, value)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
activeXhr.send(uploadFile.file)
|
|
282
|
-
})
|
|
283
|
-
|
|
217
|
+
uploadFile.state = 'success'
|
|
218
|
+
uploadFile.progress = 100
|
|
219
|
+
uploadFile.response = result.response ?? result.url
|
|
284
220
|
onupload?.(uploadFile)
|
|
285
221
|
}
|
|
286
222
|
catch (error) {
|
|
@@ -293,7 +229,7 @@
|
|
|
293
229
|
onerror?.(uploadFile.error)
|
|
294
230
|
}
|
|
295
231
|
finally {
|
|
296
|
-
clearActiveRequest(uploadFile.id,
|
|
232
|
+
clearActiveRequest(uploadFile.id, controller)
|
|
297
233
|
}
|
|
298
234
|
|
|
299
235
|
files = [...files]
|
|
@@ -18,15 +18,14 @@
|
|
|
18
18
|
import Spinner from '../../primitives/Spinner.svelte'
|
|
19
19
|
|
|
20
20
|
const SAFE_HTTP_URL_REGEX = /^https?:\/\//i
|
|
21
|
+
const SAFE_RELATIVE_URL_REGEX = /^(?:\/(?!\/)|\.\.?\/)/
|
|
21
22
|
|
|
22
23
|
let {
|
|
23
24
|
value = $bindable(''),
|
|
24
25
|
accept = 'image/*',
|
|
25
26
|
maxSize = 5 * 1024 * 1024, // 5MB
|
|
26
27
|
disabled = false,
|
|
27
|
-
|
|
28
|
-
presignUrl = '',
|
|
29
|
-
headers = {},
|
|
28
|
+
uploadHandler,
|
|
30
29
|
placeholder,
|
|
31
30
|
aspectRatio = '',
|
|
32
31
|
width = '',
|
|
@@ -40,6 +39,8 @@
|
|
|
40
39
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
41
40
|
let loading = $state(false)
|
|
42
41
|
let inputElement = $state<HTMLInputElement | undefined>(undefined)
|
|
42
|
+
let activeUpload: AbortController | null = null
|
|
43
|
+
let localPreviewUrl: string | null = null
|
|
43
44
|
let previewUrl = $derived(value)
|
|
44
45
|
|
|
45
46
|
const containerClass = $derived(
|
|
@@ -84,100 +85,80 @@
|
|
|
84
85
|
return null
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
function isSafePreviewUrl(url: string): boolean {
|
|
89
|
+
return SAFE_HTTP_URL_REGEX.test(url) || SAFE_RELATIVE_URL_REGEX.test(url)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$effect(() => {
|
|
93
|
+
return () => {
|
|
94
|
+
activeUpload?.abort()
|
|
95
|
+
if (localPreviewUrl)
|
|
96
|
+
URL.revokeObjectURL(localPreviewUrl)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
87
100
|
// 上传文件
|
|
88
101
|
async function uploadFile(file: File) {
|
|
102
|
+
activeUpload?.abort()
|
|
103
|
+
const controller = new AbortController()
|
|
104
|
+
activeUpload = controller
|
|
89
105
|
loading = true
|
|
90
106
|
|
|
91
107
|
// 释放旧的 Blob URL
|
|
92
|
-
if (
|
|
93
|
-
URL.revokeObjectURL(
|
|
108
|
+
if (localPreviewUrl) {
|
|
109
|
+
URL.revokeObjectURL(localPreviewUrl)
|
|
110
|
+
localPreviewUrl = null
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
try {
|
|
97
114
|
// 先创建本地预览
|
|
98
|
-
|
|
115
|
+
localPreviewUrl = URL.createObjectURL(file)
|
|
116
|
+
previewUrl = localPreviewUrl
|
|
99
117
|
|
|
100
|
-
if (!
|
|
101
|
-
//
|
|
118
|
+
if (!uploadHandler) {
|
|
119
|
+
// 未提供上传处理器时,仅保留本地预览。
|
|
102
120
|
value = previewUrl
|
|
103
121
|
onchange?.(previewUrl)
|
|
104
122
|
return
|
|
105
123
|
}
|
|
106
124
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (presignUrl) {
|
|
111
|
-
const presignResponse = await fetch(presignUrl, {
|
|
112
|
-
method: 'POST',
|
|
113
|
-
headers: {
|
|
114
|
-
'Content-Type': 'application/json',
|
|
115
|
-
...headers,
|
|
116
|
-
},
|
|
117
|
-
body: JSON.stringify({
|
|
118
|
-
filename: file.name,
|
|
119
|
-
contentType: file.type,
|
|
120
|
-
size: file.size,
|
|
121
|
-
}),
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
if (!presignResponse.ok) {
|
|
125
|
-
throw new Error(uiM('image_upload_get_url_failed'))
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const data = await presignResponse.json()
|
|
129
|
-
targetUrl = data.url
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// 安全检查:验证上传 URL 协议
|
|
133
|
-
if (targetUrl && !SAFE_HTTP_URL_REGEX.test(targetUrl)) {
|
|
134
|
-
throw new Error(uiM('image_upload_failed'))
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// 上传文件
|
|
138
|
-
const response = await fetch(targetUrl!, {
|
|
139
|
-
method: 'PUT',
|
|
140
|
-
headers: {
|
|
141
|
-
'Content-Type': file.type,
|
|
142
|
-
...headers,
|
|
143
|
-
},
|
|
144
|
-
body: file,
|
|
125
|
+
const result = await uploadHandler(file, {
|
|
126
|
+
signal: controller.signal,
|
|
127
|
+
onProgress: () => {},
|
|
145
128
|
})
|
|
146
|
-
|
|
147
|
-
if (!response.ok) {
|
|
129
|
+
if (!result.url || !isSafePreviewUrl(result.url)) {
|
|
148
130
|
throw new Error(uiM('image_upload_failed'))
|
|
149
131
|
}
|
|
150
132
|
|
|
151
|
-
|
|
152
|
-
let finalUrl = targetUrl!.split('?')[0] // 移除签名参数
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
const data = await response.json()
|
|
156
|
-
if (data.url) {
|
|
157
|
-
finalUrl = data.url
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
// 响应不是 JSON,使用 targetUrl
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
value = finalUrl
|
|
133
|
+
value = result.url
|
|
165
134
|
// 释放 Blob URL(已有最终 URL)
|
|
166
|
-
if (
|
|
167
|
-
URL.revokeObjectURL(
|
|
135
|
+
if (localPreviewUrl) {
|
|
136
|
+
URL.revokeObjectURL(localPreviewUrl)
|
|
137
|
+
localPreviewUrl = null
|
|
168
138
|
}
|
|
169
|
-
previewUrl =
|
|
170
|
-
onchange?.(
|
|
139
|
+
previewUrl = result.url
|
|
140
|
+
onchange?.(result.url)
|
|
171
141
|
}
|
|
172
142
|
catch (error) {
|
|
143
|
+
// 被后续选择替换的请求不得覆盖新请求的预览与 loading 状态。
|
|
144
|
+
if (activeUpload !== controller) {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
173
147
|
const message = error instanceof Error ? error.message : uiM('image_upload_failed')
|
|
174
148
|
onerror?.(message)
|
|
175
149
|
// 清除预览
|
|
150
|
+
if (localPreviewUrl) {
|
|
151
|
+
URL.revokeObjectURL(localPreviewUrl)
|
|
152
|
+
localPreviewUrl = null
|
|
153
|
+
}
|
|
176
154
|
previewUrl = ''
|
|
177
155
|
value = ''
|
|
178
156
|
}
|
|
179
157
|
finally {
|
|
180
|
-
|
|
158
|
+
if (activeUpload === controller) {
|
|
159
|
+
activeUpload = null
|
|
160
|
+
loading = false
|
|
161
|
+
}
|
|
181
162
|
}
|
|
182
163
|
}
|
|
183
164
|
|
|
@@ -202,6 +183,11 @@
|
|
|
202
183
|
|
|
203
184
|
function handleRemove(e: MouseEvent) {
|
|
204
185
|
e.stopPropagation()
|
|
186
|
+
activeUpload?.abort()
|
|
187
|
+
if (localPreviewUrl) {
|
|
188
|
+
URL.revokeObjectURL(localPreviewUrl)
|
|
189
|
+
localPreviewUrl = null
|
|
190
|
+
}
|
|
205
191
|
value = ''
|
|
206
192
|
previewUrl = ''
|
|
207
193
|
onchange?.('')
|
|
@@ -335,6 +335,26 @@ export interface UploadFile {
|
|
|
335
335
|
/** 上传响应 */
|
|
336
336
|
response?: unknown;
|
|
337
337
|
}
|
|
338
|
+
/** 上传处理器上下文 */
|
|
339
|
+
export interface UploadHandlerContext {
|
|
340
|
+
/** 组件销毁、文件移除或重试时触发的取消信号 */
|
|
341
|
+
signal: AbortSignal;
|
|
342
|
+
/** 汇报 0-100 的上传进度 */
|
|
343
|
+
onProgress: (progress: number) => void;
|
|
344
|
+
}
|
|
345
|
+
/** 上传处理结果 */
|
|
346
|
+
export interface UploadHandlerResult {
|
|
347
|
+
/** 可展示的最终文件 URL(图片/头像上传必须返回) */
|
|
348
|
+
url?: string;
|
|
349
|
+
/** 业务层原始响应(回传到 UploadFile.response) */
|
|
350
|
+
response?: unknown;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* 应用层上传处理器。
|
|
354
|
+
*
|
|
355
|
+
* UI 组件只管理选择、校验、进度和预览;签名 URL、认证头与上传协议由应用服务层实现。
|
|
356
|
+
*/
|
|
357
|
+
export type UploadHandler = (file: File, context: UploadHandlerContext) => Promise<UploadHandlerResult>;
|
|
338
358
|
/**
|
|
339
359
|
* 文件上传属性
|
|
340
360
|
*/
|
|
@@ -349,12 +369,8 @@ export interface FileUploadProps extends DataAttributes {
|
|
|
349
369
|
multiple?: boolean;
|
|
350
370
|
/** 是否禁用 */
|
|
351
371
|
disabled?: boolean;
|
|
352
|
-
/**
|
|
353
|
-
|
|
354
|
-
/** 签名 URL 地址 */
|
|
355
|
-
presignUrl?: string;
|
|
356
|
-
/** 请求头 */
|
|
357
|
-
headers?: Record<string, string>;
|
|
372
|
+
/** 应用层上传处理器;未提供时组件仅选择文件并保持 pending */
|
|
373
|
+
uploadHandler?: UploadHandler;
|
|
358
374
|
/** 是否自动上传 */
|
|
359
375
|
autoUpload?: boolean;
|
|
360
376
|
/** 是否显示文件列表 */
|
|
@@ -432,12 +448,8 @@ export interface ImageUploadProps extends DataAttributes {
|
|
|
432
448
|
maxSize?: number;
|
|
433
449
|
/** 是否禁用 */
|
|
434
450
|
disabled?: boolean;
|
|
435
|
-
/**
|
|
436
|
-
|
|
437
|
-
/** 签名 URL 地址 */
|
|
438
|
-
presignUrl?: string;
|
|
439
|
-
/** 请求头 */
|
|
440
|
-
headers?: Record<string, string>;
|
|
451
|
+
/** 应用层上传处理器;未提供时仅生成本地预览 */
|
|
452
|
+
uploadHandler?: UploadHandler;
|
|
441
453
|
/** 占位文本 */
|
|
442
454
|
placeholder?: string;
|
|
443
455
|
/** 宽高比 */
|
|
@@ -467,12 +479,8 @@ export interface AvatarUploadProps extends DataAttributes {
|
|
|
467
479
|
maxSize?: number;
|
|
468
480
|
/** 是否禁用 */
|
|
469
481
|
disabled?: boolean;
|
|
470
|
-
/**
|
|
471
|
-
|
|
472
|
-
/** 签名 URL 地址 */
|
|
473
|
-
presignUrl?: string;
|
|
474
|
-
/** 请求头 */
|
|
475
|
-
headers?: Record<string, string>;
|
|
482
|
+
/** 应用层上传处理器;未提供时仅生成本地预览 */
|
|
483
|
+
uploadHandler?: UploadHandler;
|
|
476
484
|
/** 默认文字(没有图片时显示) */
|
|
477
485
|
fallback?: string;
|
|
478
486
|
/** 自定义类名 */
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const unsafeSvgElements = new Set([
|
|
2
|
+
'a',
|
|
3
|
+
'script',
|
|
4
|
+
'style',
|
|
5
|
+
'foreignobject',
|
|
6
|
+
'iframe',
|
|
7
|
+
'object',
|
|
8
|
+
'embed',
|
|
9
|
+
'link',
|
|
10
|
+
'meta',
|
|
11
|
+
'image',
|
|
12
|
+
'use',
|
|
13
|
+
'animate',
|
|
14
|
+
'animatemotion',
|
|
15
|
+
'animatetransform',
|
|
16
|
+
'set',
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* 校验 IconButton 字符串图标是否属于保守的内联 SVG 子集。
|
|
20
|
+
*
|
|
21
|
+
* 字符串入口只服务由应用代码内置的简单 path/circle 等图标。涉及链接、外部资源、
|
|
22
|
+
* CSS、动画或可执行事件的 SVG 一律拒绝;复杂图标应使用 Svelte Snippet。
|
|
23
|
+
*/
|
|
24
|
+
export function sanitizeInlineSvg(markup) {
|
|
25
|
+
const trimmed = markup.trim();
|
|
26
|
+
if (!trimmed || !/^<svg(?:\s[^>]*)?>[\s\S]*<\/svg>$/i.test(trimmed))
|
|
27
|
+
return '';
|
|
28
|
+
const hasUnsafeElement = [...trimmed.matchAll(/<\s*([a-z][\w-]*)\b/gi)]
|
|
29
|
+
.some(([, tagName]) => tagName && unsafeSvgElements.has(tagName.toLowerCase()));
|
|
30
|
+
const hasUnsafeAttribute = /\s(?:on[a-z]+|style|href|xlink:href|src)\s*=/i.test(trimmed);
|
|
31
|
+
const hasExternalReference = /url\s*\(|@import|javascript:|vbscript:|data:text\/html|<\s*!/i.test(trimmed);
|
|
32
|
+
return hasUnsafeElement || hasUnsafeAttribute || hasExternalReference ? '' : trimmed;
|
|
33
|
+
}
|
package/dist/messages/en-US.json
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"combobox_remove": "Remove",
|
|
26
26
|
"data_table_actions": "Actions",
|
|
27
27
|
"data_table_empty": "No data",
|
|
28
|
+
"chart_loading": "Loading chart",
|
|
29
|
+
"chart_empty": "No data",
|
|
28
30
|
"confirm_title": "Confirm",
|
|
29
31
|
"confirm_message": "Are you sure you want to proceed?",
|
|
30
32
|
"confirm_ok": "Confirm",
|
|
@@ -49,7 +51,6 @@
|
|
|
49
51
|
"image_upload_max_size_hint": "Max:",
|
|
50
52
|
"image_upload_size_exceeded": "Image size exceeds limit (max",
|
|
51
53
|
"image_upload_invalid_type": "Please select an image file",
|
|
52
|
-
"image_upload_get_url_failed": "Failed to get upload URL",
|
|
53
54
|
"image_upload_failed": "Upload failed",
|
|
54
55
|
"image_upload_preview_alt": "Preview",
|
|
55
56
|
"image_upload_change": "Change image",
|
|
@@ -62,9 +63,6 @@
|
|
|
62
63
|
"file_upload_files_unit": "files",
|
|
63
64
|
"file_upload_success": "Upload successful",
|
|
64
65
|
"file_upload_failed": "Upload failed",
|
|
65
|
-
"file_upload_network_error": "Network error",
|
|
66
|
-
"file_upload_no_url": "No upload URL configured",
|
|
67
|
-
"file_upload_get_url_failed": "Failed to get upload URL",
|
|
68
66
|
"file_upload_size_exceeded": "File size exceeds limit (max",
|
|
69
67
|
"file_upload_unsupported_type": "Unsupported file type",
|
|
70
68
|
"file_upload_max_files_exceeded": "Maximum upload limit:",
|
|
@@ -78,7 +76,6 @@
|
|
|
78
76
|
"avatar_upload_alt": "Avatar",
|
|
79
77
|
"avatar_upload_size_exceeded": "Image size exceeds limit (max",
|
|
80
78
|
"avatar_upload_invalid_type": "Please select an image file",
|
|
81
|
-
"avatar_upload_get_url_failed": "Failed to get upload URL",
|
|
82
79
|
"avatar_upload_failed": "Upload failed",
|
|
83
80
|
"user_profile_avatar": "Avatar",
|
|
84
81
|
"user_profile_username": "Username",
|