@h-ai/ui 0.1.0-alpha.35 → 0.1.0-alpha.37
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 +10 -8
- 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 +2 -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/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 +0 -5
- package/dist/messages/zh-CN.json +0 -5
- package/dist/types.d.ts +6 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -150,7 +150,7 @@ components/
|
|
|
150
150
|
| `Spinner` | 加载动画 | `size`, `variant` |
|
|
151
151
|
| `Progress` | 进度条 | `value`, `max`, `variant`, `striped`, `animated` |
|
|
152
152
|
|
|
153
|
-
### 组合组件 Compounds(
|
|
153
|
+
### 组合组件 Compounds(36 个)
|
|
154
154
|
|
|
155
155
|
#### 表单
|
|
156
156
|
|
|
@@ -212,10 +212,10 @@ components/
|
|
|
212
212
|
| 组件 | 描述 | 主要属性 |
|
|
213
213
|
| ------------ | -------- | -------------------------------------------- |
|
|
214
214
|
| `Tabs` | 标签页 | `items`, `active`, `type`, `size` |
|
|
215
|
-
| `Pagination` | 分页(统一 table 风格) | `page`, `total`, `pageSize`, `showTotal`, `showJumper`, `showSizeChanger` |
|
|
215
|
+
| `Pagination` | 分页(统一 table 风格) | `page`, `total`, `pageSize`, `showTotal`, `showJumper`, `showSizeChanger`, `showPageInfo`, `showFirstLast` |
|
|
216
216
|
| `Breadcrumb` | 面包屑 | `items`, `separator` |
|
|
217
217
|
| `Steps` | 步骤条 | `items`, `current`, `direction`, `clickable` |
|
|
218
|
-
| `Dropdown` | 下拉菜单 | `items`, `trigger`, `position`
|
|
218
|
+
| `Dropdown` | 下拉菜单 | `items`, `trigger`, `triggerClass`, `position` |
|
|
219
219
|
| `Tooltip` | 提示 | `content`, `position`, `delay` |
|
|
220
220
|
|
|
221
221
|
#### 状态占位
|
|
@@ -232,7 +232,7 @@ components/
|
|
|
232
232
|
| ------------ | -------- | ------------------------------------------- |
|
|
233
233
|
| `PageHeader` | 页面头部 | `title`, `description`,支持 `actions` 插槽 |
|
|
234
234
|
|
|
235
|
-
### 场景组件 Scenes(
|
|
235
|
+
### 场景组件 Scenes(34 个)
|
|
236
236
|
|
|
237
237
|
#### App 应用级(7 个)
|
|
238
238
|
|
|
@@ -264,11 +264,13 @@ components/
|
|
|
264
264
|
|
|
265
265
|
| 组件 | 描述 | 主要属性 |
|
|
266
266
|
| -------------- | -------- | -------------------------------------------------------- |
|
|
267
|
-
| `FileUpload` | 文件上传 | `accept`, `maxSize`, `maxFiles`, `multiple`, `
|
|
268
|
-
| `ImageUpload` | 图片上传 | `value`, `accept`, `maxSize`, `aspectRatio`
|
|
269
|
-
| `AvatarUpload` | 头像上传 | `value`, `size`, `maxSize`, `fallback`
|
|
267
|
+
| `FileUpload` | 文件上传 | `accept`, `maxSize`, `maxFiles`, `multiple`, `uploadHandler` |
|
|
268
|
+
| `ImageUpload` | 图片上传 | `value`, `accept`, `maxSize`, `aspectRatio`, `uploadHandler` |
|
|
269
|
+
| `AvatarUpload` | 头像上传 | `value`, `size`, `maxSize`, `fallback`, `uploadHandler` |
|
|
270
270
|
| `FileList` | 文件列表 | `files`, `layout`, `showDelete`, `showDownload` |
|
|
271
271
|
|
|
272
|
+
上传组件不内置 presign、请求头或 PUT/POST 协议。应用层通过 `uploadHandler(file, { signal, onProgress })` 调用自己的 service,并返回 `{ url?, response? }`;图片和头像上传需要返回可展示的 `url`。未提供 handler 时,`FileUpload` 只选择文件,图片/头像组件只生成本地预览。
|
|
273
|
+
|
|
272
274
|
#### Crypto 加密展示(3 个)
|
|
273
275
|
|
|
274
276
|
| 组件 | 描述 | 主要属性 |
|
|
@@ -352,7 +354,7 @@ components/
|
|
|
352
354
|
> - 抽屉:`form.drawerSize` 选预设尺寸,`form.drawerWidth` 传任意 CSS 宽度(优先级更高)。
|
|
353
355
|
> - 弹窗:`form.modalSize` 选预设尺寸,`form.modalWidth` / `form.modalHeight` 传任意 CSS 尺寸。
|
|
354
356
|
>
|
|
355
|
-
> `pagination` 配置分页栏(始终显示):`showSizeChanger`、`pageSizeOptions`、`showJumper`、`showTotal`
|
|
357
|
+
> `pagination` 配置分页栏(始终显示):`showSizeChanger`、`pageSizeOptions`、`showJumper`、`showTotal` 默认开启;紧凑视图可用 `showPageInfo` 关闭页码文案、`showFirstLast` 关闭首页 / 末页按钮。
|
|
356
358
|
>
|
|
357
359
|
> `density` 配置列表密度:`'normal'`(默认)或 `'compact'`(紧凑)。它只影响列表行、行内操作、分页与创建/编辑/详情表单元素,不影响页头与筛选栏。
|
|
358
360
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
position = 'bottom',
|
|
14
14
|
align = 'start',
|
|
15
15
|
class: className = '',
|
|
16
|
+
triggerClass = '',
|
|
16
17
|
onselect,
|
|
17
18
|
children,
|
|
18
19
|
...restProps
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
<BareButton
|
|
62
63
|
tabindex={0}
|
|
63
64
|
role='button'
|
|
65
|
+
class={triggerClass}
|
|
64
66
|
onclick={toggleDropdown}
|
|
65
67
|
onkeydown={e => e.key === 'Enter' && toggleDropdown()}
|
|
66
68
|
>
|
|
@@ -28,8 +28,6 @@
|
|
|
28
28
|
}: ModalProps & DataAttributes = $props()
|
|
29
29
|
|
|
30
30
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
31
|
-
// modalElement 持有 overlay 容器引用;不再依赖原生 `<dialog>`,改用 fixed + z-index 叠加。
|
|
32
|
-
let modalElement: HTMLDivElement | undefined = $state()
|
|
33
31
|
|
|
34
32
|
// 标题区仅在显式提供标题或自定义 header 时出现;
|
|
35
33
|
// 仅需关闭按钮的场景改为右上角悬浮按钮,避免业务侧额外占一整行头部。
|
|
@@ -93,27 +91,9 @@
|
|
|
93
91
|
onclose?.()
|
|
94
92
|
}
|
|
95
93
|
|
|
96
|
-
function handleDialogClick(event: MouseEvent) {
|
|
97
|
-
if (event.target === event.currentTarget && closeOnBackdrop) {
|
|
98
|
-
handleClose()
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function handleViewportKeydown(event: KeyboardEvent) {
|
|
103
|
-
if (
|
|
104
|
-
event.target === event.currentTarget
|
|
105
|
-
&& closeOnBackdrop
|
|
106
|
-
&& (event.key === 'Enter' || event.key === ' ')
|
|
107
|
-
) {
|
|
108
|
-
event.preventDefault()
|
|
109
|
-
handleClose()
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
94
|
</script>
|
|
114
95
|
|
|
115
96
|
<div {...dataAttributes}
|
|
116
|
-
bind:this={modalElement}
|
|
117
97
|
class='hai-modal'
|
|
118
98
|
style:display={open ? '' : 'none'}
|
|
119
99
|
aria-hidden={open ? undefined : 'true'}
|
|
@@ -124,11 +104,19 @@
|
|
|
124
104
|
>
|
|
125
105
|
<div
|
|
126
106
|
class='hai-modal__viewport'
|
|
127
|
-
tabindex='-1'
|
|
128
|
-
aria-label={uiM('common_close')}
|
|
129
|
-
onclick={handleDialogClick}
|
|
130
|
-
onkeydown={handleViewportKeydown}
|
|
131
107
|
>
|
|
108
|
+
{#if closeOnBackdrop}
|
|
109
|
+
<button
|
|
110
|
+
type='button'
|
|
111
|
+
class='hai-modal__backdrop'
|
|
112
|
+
aria-label={uiM('common_close')}
|
|
113
|
+
tabindex='-1'
|
|
114
|
+
onclick={handleClose}
|
|
115
|
+
></button>
|
|
116
|
+
{:else}
|
|
117
|
+
<div class='hai-modal__backdrop' aria-hidden='true'></div>
|
|
118
|
+
{/if}
|
|
119
|
+
|
|
132
120
|
<div class={modalBoxClass} style={panelStyle}>
|
|
133
121
|
{#if hasFloatingClose}
|
|
134
122
|
<button
|
|
@@ -230,12 +218,22 @@
|
|
|
230
218
|
place-items: center;
|
|
231
219
|
padding: clamp(1rem, 2.6vw, 2rem);
|
|
232
220
|
pointer-events: auto;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.hai-modal__backdrop {
|
|
224
|
+
position: absolute;
|
|
225
|
+
inset: 0;
|
|
226
|
+
width: 100%;
|
|
227
|
+
height: 100%;
|
|
228
|
+
padding: 0;
|
|
229
|
+
border: 0;
|
|
233
230
|
background: rgb(15 23 42 / 0.26);
|
|
234
231
|
background: color-mix(in srgb, var(--color-base-content) 15%, transparent);
|
|
235
232
|
backdrop-filter: blur(2px);
|
|
236
233
|
}
|
|
237
234
|
|
|
238
235
|
.hai-modal__panel {
|
|
236
|
+
z-index: 1;
|
|
239
237
|
width: var(--hai-modal-width, min(100%, 44rem));
|
|
240
238
|
max-width: min(100%, calc(100vw - clamp(2rem, 5.2vw, 4rem)));
|
|
241
239
|
height: var(--hai-modal-height, auto);
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
showTotal = true,
|
|
25
25
|
showJumper = false,
|
|
26
26
|
showSizeChanger = false,
|
|
27
|
+
showPageInfo = true,
|
|
28
|
+
showFirstLast = true,
|
|
27
29
|
pageSizeOptions = [10, 20, 50, 100],
|
|
28
30
|
labels = {},
|
|
29
31
|
class: className = '',
|
|
@@ -113,21 +115,27 @@
|
|
|
113
115
|
</div>
|
|
114
116
|
{/if}
|
|
115
117
|
|
|
116
|
-
|
|
118
|
+
{#if showPageInfo}
|
|
119
|
+
<span class={tableTextClass}>{pageInfoText}</span>
|
|
120
|
+
{/if}
|
|
117
121
|
|
|
118
122
|
<div class='flex items-center gap-1'>
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
123
|
+
{#if showFirstLast}
|
|
124
|
+
<BareButton class={navBtnClass} disabled={page === 1} onclick={() => goToPage(1)} ariaLabel='First page'>
|
|
125
|
+
<span class={cn('icon-[tabler--chevrons-left]', navIconClass)}></span>
|
|
126
|
+
</BareButton>
|
|
127
|
+
{/if}
|
|
122
128
|
<BareButton class={navBtnClass} disabled={page === 1} onclick={() => goToPage(page - 1)} ariaLabel='Previous page'>
|
|
123
129
|
<span class={cn('icon-[tabler--chevron-left]', navIconClass)}></span>
|
|
124
130
|
</BareButton>
|
|
125
131
|
<BareButton class={navBtnClass} disabled={page === totalPages} onclick={() => goToPage(page + 1)} ariaLabel='Next page'>
|
|
126
132
|
<span class={cn('icon-[tabler--chevron-right]', navIconClass)}></span>
|
|
127
133
|
</BareButton>
|
|
128
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
134
|
+
{#if showFirstLast}
|
|
135
|
+
<BareButton class={navBtnClass} disabled={page === totalPages} onclick={() => goToPage(totalPages)} ariaLabel='Last page'>
|
|
136
|
+
<span class={cn('icon-[tabler--chevrons-right]', navIconClass)}></span>
|
|
137
|
+
</BareButton>
|
|
138
|
+
{/if}
|
|
131
139
|
</div>
|
|
132
140
|
|
|
133
141
|
{#if showJumper}
|
|
@@ -9,11 +9,9 @@
|
|
|
9
9
|
-->
|
|
10
10
|
<script lang='ts'>
|
|
11
11
|
import type { DataAttributes, IconButtonProps } from '../../types.js'
|
|
12
|
+
import { sanitizeInlineSvg } from '../../internal/icon-safety.js'
|
|
12
13
|
import { cn, getDataAttributes, getSizeClass, getVariantClass } from '../../utils.js'
|
|
13
14
|
|
|
14
|
-
const SAFE_INLINE_SVG_REGEX = /^<svg[\s\S]*<\/svg>$/i
|
|
15
|
-
const UNSAFE_INLINE_SVG_REGEX = /<\s*(?:script|foreignObject|iframe|object|embed|link|meta|img)\b|\son[a-z]+\s*=|(?:href|xlink:href)\s*=\s*['"]\s*(?:javascript:|vbscript:|data:text\/html)/i
|
|
16
|
-
|
|
17
15
|
const {
|
|
18
16
|
icon,
|
|
19
17
|
label = '',
|
|
@@ -72,18 +70,6 @@
|
|
|
72
70
|
onclick?.(e)
|
|
73
71
|
}
|
|
74
72
|
|
|
75
|
-
function sanitizeInlineSvg(markup: string): string {
|
|
76
|
-
const trimmed = markup.trim()
|
|
77
|
-
if (!trimmed) {
|
|
78
|
-
return ''
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (!SAFE_INLINE_SVG_REGEX.test(trimmed) || UNSAFE_INLINE_SVG_REGEX.test(trimmed)) {
|
|
82
|
-
return ''
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return trimmed
|
|
86
|
-
}
|
|
87
73
|
</script>
|
|
88
74
|
|
|
89
75
|
{#if tooltip}
|
|
@@ -161,6 +161,8 @@
|
|
|
161
161
|
const paginationShowSizeChanger = $derived(pagination.showSizeChanger !== false)
|
|
162
162
|
const paginationShowJumper = $derived(pagination.showJumper !== false)
|
|
163
163
|
const paginationShowTotal = $derived(pagination.showTotal !== false)
|
|
164
|
+
const paginationShowPageInfo = $derived(pagination.showPageInfo !== false)
|
|
165
|
+
const paginationShowFirstLast = $derived(pagination.showFirstLast !== false)
|
|
164
166
|
const paginationPageSizeOptions = $derived(pagination.pageSizeOptions ?? [10, 20, 50, 100])
|
|
165
167
|
|
|
166
168
|
// 视图状态
|
|
@@ -500,6 +502,8 @@
|
|
|
500
502
|
showTotal={paginationShowTotal}
|
|
501
503
|
showJumper={paginationShowJumper}
|
|
502
504
|
showSizeChanger={paginationShowSizeChanger}
|
|
505
|
+
showPageInfo={paginationShowPageInfo}
|
|
506
|
+
showFirstLast={paginationShowFirstLast}
|
|
503
507
|
pageSizeOptions={paginationPageSizeOptions}
|
|
504
508
|
onchange={handlePageChange}
|
|
505
509
|
onpagesizechange={handlePageSizeChange}
|
|
@@ -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
|
@@ -51,7 +51,6 @@
|
|
|
51
51
|
"image_upload_max_size_hint": "Max:",
|
|
52
52
|
"image_upload_size_exceeded": "Image size exceeds limit (max",
|
|
53
53
|
"image_upload_invalid_type": "Please select an image file",
|
|
54
|
-
"image_upload_get_url_failed": "Failed to get upload URL",
|
|
55
54
|
"image_upload_failed": "Upload failed",
|
|
56
55
|
"image_upload_preview_alt": "Preview",
|
|
57
56
|
"image_upload_change": "Change image",
|
|
@@ -64,9 +63,6 @@
|
|
|
64
63
|
"file_upload_files_unit": "files",
|
|
65
64
|
"file_upload_success": "Upload successful",
|
|
66
65
|
"file_upload_failed": "Upload failed",
|
|
67
|
-
"file_upload_network_error": "Network error",
|
|
68
|
-
"file_upload_no_url": "No upload URL configured",
|
|
69
|
-
"file_upload_get_url_failed": "Failed to get upload URL",
|
|
70
66
|
"file_upload_size_exceeded": "File size exceeds limit (max",
|
|
71
67
|
"file_upload_unsupported_type": "Unsupported file type",
|
|
72
68
|
"file_upload_max_files_exceeded": "Maximum upload limit:",
|
|
@@ -80,7 +76,6 @@
|
|
|
80
76
|
"avatar_upload_alt": "Avatar",
|
|
81
77
|
"avatar_upload_size_exceeded": "Image size exceeds limit (max",
|
|
82
78
|
"avatar_upload_invalid_type": "Please select an image file",
|
|
83
|
-
"avatar_upload_get_url_failed": "Failed to get upload URL",
|
|
84
79
|
"avatar_upload_failed": "Upload failed",
|
|
85
80
|
"user_profile_avatar": "Avatar",
|
|
86
81
|
"user_profile_username": "Username",
|
package/dist/messages/zh-CN.json
CHANGED
|
@@ -51,7 +51,6 @@
|
|
|
51
51
|
"image_upload_max_size_hint": "最大:",
|
|
52
52
|
"image_upload_size_exceeded": "图片大小超出限制(最大",
|
|
53
53
|
"image_upload_invalid_type": "请选择图片文件",
|
|
54
|
-
"image_upload_get_url_failed": "获取上传地址失败",
|
|
55
54
|
"image_upload_failed": "上传失败",
|
|
56
55
|
"image_upload_preview_alt": "预览",
|
|
57
56
|
"image_upload_change": "更换图片",
|
|
@@ -64,9 +63,6 @@
|
|
|
64
63
|
"file_upload_files_unit": "个文件",
|
|
65
64
|
"file_upload_success": "上传成功",
|
|
66
65
|
"file_upload_failed": "上传失败",
|
|
67
|
-
"file_upload_network_error": "网络错误",
|
|
68
|
-
"file_upload_no_url": "未配置上传地址",
|
|
69
|
-
"file_upload_get_url_failed": "获取上传地址失败",
|
|
70
66
|
"file_upload_size_exceeded": "文件大小超出限制(最大",
|
|
71
67
|
"file_upload_unsupported_type": "不支持的文件类型",
|
|
72
68
|
"file_upload_max_files_exceeded": "最多上传:",
|
|
@@ -80,7 +76,6 @@
|
|
|
80
76
|
"avatar_upload_alt": "头像",
|
|
81
77
|
"avatar_upload_size_exceeded": "图片大小超出限制(最大",
|
|
82
78
|
"avatar_upload_invalid_type": "请选择图片文件",
|
|
83
|
-
"avatar_upload_get_url_failed": "获取上传地址失败",
|
|
84
79
|
"avatar_upload_failed": "上传失败",
|
|
85
80
|
"user_profile_avatar": "头像",
|
|
86
81
|
"user_profile_username": "用户名",
|
package/dist/types.d.ts
CHANGED
|
@@ -599,6 +599,10 @@ export interface PaginationProps extends DataAttributes {
|
|
|
599
599
|
showJumper?: boolean;
|
|
600
600
|
/** 是否显示每页条数选择器 */
|
|
601
601
|
showSizeChanger?: boolean;
|
|
602
|
+
/** 是否显示页码信息文案(如「第 1 / 5 页」,默认 true;紧凑视图可关闭) */
|
|
603
|
+
showPageInfo?: boolean;
|
|
604
|
+
/** 是否显示首页 / 末页跳转按钮(默认 true;紧凑视图可仅保留上一页 / 下一页) */
|
|
605
|
+
showFirstLast?: boolean;
|
|
602
606
|
/** 每页条数候选项(配合 showSizeChanger,默认 [10, 20, 50, 100]) */
|
|
603
607
|
pageSizeOptions?: number[];
|
|
604
608
|
/** i18n 文案配置 */
|
|
@@ -727,6 +731,8 @@ export interface DropdownProps extends DataAttributes {
|
|
|
727
731
|
align?: Alignment;
|
|
728
732
|
/** 自定义类名 */
|
|
729
733
|
class?: string;
|
|
734
|
+
/** 触发按钮类名 */
|
|
735
|
+
triggerClass?: string;
|
|
730
736
|
/** 选择事件 */
|
|
731
737
|
onselect?: (key: string) => void;
|
|
732
738
|
/** 触发元素插槽 */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@h-ai/ui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.37",
|
|
5
5
|
"description": "Hai Framework UI component library for Svelte 5 Runes.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"svelte": "./dist/index.js",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"mermaid": "^11.12.0",
|
|
53
53
|
"shiki": "^4.0.2",
|
|
54
54
|
"tailwind-merge": "^3.5.0",
|
|
55
|
-
"@h-ai/core": "0.1.0-alpha.
|
|
55
|
+
"@h-ai/core": "0.1.0-alpha.37"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@sveltejs/package": "^2.5.7",
|