@h-ai/ui 0.1.0-alpha.35 → 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 CHANGED
@@ -150,7 +150,7 @@ components/
150
150
  | `Spinner` | 加载动画 | `size`, `variant` |
151
151
  | `Progress` | 进度条 | `value`, `max`, `variant`, `striped`, `animated` |
152
152
 
153
- ### 组合组件 Compounds(33 个)
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(33 个)
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`, `uploadUrl` |
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
- <span class={tableTextClass}>{pageInfoText}</span>
118
+ {#if showPageInfo}
119
+ <span class={tableTextClass}>{pageInfoText}</span>
120
+ {/if}
117
121
 
118
122
  <div class='flex items-center gap-1'>
119
- <BareButton class={navBtnClass} disabled={page === 1} onclick={() => goToPage(1)} ariaLabel='First page'>
120
- <span class={cn('icon-[tabler--chevrons-left]', navIconClass)}></span>
121
- </BareButton>
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
- <BareButton class={navBtnClass} disabled={page === totalPages} onclick={() => goToPage(totalPages)} ariaLabel='Last page'>
129
- <span class={cn('icon-[tabler--chevrons-right]', navIconClass)}></span>
130
- </BareButton>
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}
@@ -62,6 +62,7 @@
62
62
  {#each items as item (item.key)}
63
63
  <BareButton
64
64
  role='tab'
65
+ ariaSelected={active === item.key}
65
66
  class={cn('tab', active === item.key && 'tab-active')}
66
67
  disabled={item.disabled}
67
68
  onclick={() => handleSelect(item.key)}
@@ -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}
@@ -112,6 +112,8 @@
112
112
  document.addEventListener('click', handleClickOutside)
113
113
  return () => document.removeEventListener('click', handleClickOutside)
114
114
  }
115
+
116
+ return undefined
115
117
  })
116
118
 
117
119
  // 计算下拉层位置(fixed,基于触发器视口坐标;空间不足时向上翻转)
@@ -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
- uploadUrl = '',
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 (!uploadUrl && !presignUrl) {
118
- // 没有上传地址,创建本地预览
120
+ if (!uploadHandler) {
121
+ // 未提供上传处理器时,仅保留本地预览。
119
122
  replaceAvatarValue(URL.createObjectURL(file))
120
123
  return
121
124
  }
122
125
 
123
- let targetUrl = uploadUrl
124
-
125
- // 如果配置了签名 URL,先获取
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
- // 获取最终 URL
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
- loading = false
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
- 文件上传组件,支持签名 URL 直传和多文件上传
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
- uploadUrl = '',
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, XMLHttpRequest>()
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, xhr: XMLHttpRequest): void {
105
- activeRequests.set(fileId, xhr)
101
+ function registerActiveRequest(fileId: string, controller: AbortController): void {
102
+ activeRequests.set(fileId, controller)
106
103
  }
107
104
 
108
- function clearActiveRequest(fileId: string, xhr?: XMLHttpRequest): void {
105
+ function clearActiveRequest(fileId: string, controller?: AbortController): void {
109
106
  const current = activeRequests.get(fileId)
110
- if (!current || (xhr && current !== xhr)) {
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 (!uploadUrl && !presignUrl) {
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
- let xhr: XMLHttpRequest | undefined
201
+ const controller = new AbortController()
202
+ registerActiveRequest(uploadFile.id, controller)
211
203
 
212
204
  try {
213
- let targetUrl = uploadUrl
214
-
215
- // 如果配置了签名 URL,先获取
216
- if (presignUrl) {
217
- const presignResponse = await fetch(presignUrl, {
218
- method: 'POST',
219
- headers: {
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
- const { url } = await presignResponse.json()
235
- if (!SAFE_HTTP_URL_REGEX.test(url)) {
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
- xhr = new XMLHttpRequest()
243
- registerActiveRequest(uploadFile.id, xhr)
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, xhr)
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
- uploadUrl = '',
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 (previewUrl && previewUrl.startsWith('blob:')) {
93
- URL.revokeObjectURL(previewUrl)
108
+ if (localPreviewUrl) {
109
+ URL.revokeObjectURL(localPreviewUrl)
110
+ localPreviewUrl = null
94
111
  }
95
112
 
96
113
  try {
97
114
  // 先创建本地预览
98
- previewUrl = URL.createObjectURL(file)
115
+ localPreviewUrl = URL.createObjectURL(file)
116
+ previewUrl = localPreviewUrl
99
117
 
100
- if (!uploadUrl && !presignUrl) {
101
- // 没有上传地址,只做本地预览
118
+ if (!uploadHandler) {
119
+ // 未提供上传处理器时,仅保留本地预览。
102
120
  value = previewUrl
103
121
  onchange?.(previewUrl)
104
122
  return
105
123
  }
106
124
 
107
- let targetUrl = uploadUrl
108
-
109
- // 如果配置了签名 URL,先获取
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
- // 获取最终 URL
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 (previewUrl && previewUrl.startsWith('blob:')) {
167
- URL.revokeObjectURL(previewUrl)
135
+ if (localPreviewUrl) {
136
+ URL.revokeObjectURL(localPreviewUrl)
137
+ localPreviewUrl = null
168
138
  }
169
- previewUrl = finalUrl
170
- onchange?.(finalUrl)
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
- loading = false
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
- uploadUrl?: string;
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
- uploadUrl?: string;
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
- uploadUrl?: string;
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,7 @@
1
+ /**
2
+ * 校验 IconButton 字符串图标是否属于保守的内联 SVG 子集。
3
+ *
4
+ * 字符串入口只服务由应用代码内置的简单 path/circle 等图标。涉及链接、外部资源、
5
+ * CSS、动画或可执行事件的 SVG 一律拒绝;复杂图标应使用 Svelte Snippet。
6
+ */
7
+ export declare function sanitizeInlineSvg(markup: string): string;
@@ -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
+ }
@@ -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",
@@ -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.35",
4
+ "version": "0.1.0-alpha.36",
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.35"
55
+ "@h-ai/core": "0.1.0-alpha.36"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@sveltejs/package": "^2.5.7",