@h-ai/ui 0.1.0-alpha5

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.
Files changed (234) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +488 -0
  3. package/auto-import.d.ts +96 -0
  4. package/auto-import.js +259 -0
  5. package/dist/components/compounds/Accordion.svelte +142 -0
  6. package/dist/components/compounds/Accordion.svelte.d.ts +25 -0
  7. package/dist/components/compounds/ActionSheet.svelte +116 -0
  8. package/dist/components/compounds/ActionSheet.svelte.d.ts +22 -0
  9. package/dist/components/compounds/Alert.svelte +67 -0
  10. package/dist/components/compounds/Alert.svelte.d.ts +8 -0
  11. package/dist/components/compounds/AppBar.svelte +77 -0
  12. package/dist/components/compounds/AppBar.svelte.d.ts +20 -0
  13. package/dist/components/compounds/BottomNav.svelte +75 -0
  14. package/dist/components/compounds/BottomNav.svelte.d.ts +18 -0
  15. package/dist/components/compounds/Breadcrumb.svelte +49 -0
  16. package/dist/components/compounds/Breadcrumb.svelte.d.ts +4 -0
  17. package/dist/components/compounds/Calendar.svelte +116 -0
  18. package/dist/components/compounds/Calendar.svelte.d.ts +47 -0
  19. package/dist/components/compounds/Card.svelte +77 -0
  20. package/dist/components/compounds/Card.svelte.d.ts +8 -0
  21. package/dist/components/compounds/Combobox.svelte +327 -0
  22. package/dist/components/compounds/Combobox.svelte.d.ts +69 -0
  23. package/dist/components/compounds/Confirm.svelte +90 -0
  24. package/dist/components/compounds/Confirm.svelte.d.ts +4 -0
  25. package/dist/components/compounds/DataTable.svelte +133 -0
  26. package/dist/components/compounds/DataTable.svelte.d.ts +69 -0
  27. package/dist/components/compounds/DatePicker.svelte +135 -0
  28. package/dist/components/compounds/DatePicker.svelte.d.ts +46 -0
  29. package/dist/components/compounds/Drawer.svelte +113 -0
  30. package/dist/components/compounds/Drawer.svelte.d.ts +4 -0
  31. package/dist/components/compounds/Dropdown.svelte +91 -0
  32. package/dist/components/compounds/Dropdown.svelte.d.ts +8 -0
  33. package/dist/components/compounds/Empty.svelte +83 -0
  34. package/dist/components/compounds/Empty.svelte.d.ts +4 -0
  35. package/dist/components/compounds/Form.svelte +96 -0
  36. package/dist/components/compounds/Form.svelte.d.ts +7 -0
  37. package/dist/components/compounds/FormField.svelte +56 -0
  38. package/dist/components/compounds/FormField.svelte.d.ts +4 -0
  39. package/dist/components/compounds/InfiniteScroll.svelte +82 -0
  40. package/dist/components/compounds/InfiniteScroll.svelte.d.ts +22 -0
  41. package/dist/components/compounds/Modal.svelte +123 -0
  42. package/dist/components/compounds/Modal.svelte.d.ts +8 -0
  43. package/dist/components/compounds/PageHeader.svelte +44 -0
  44. package/dist/components/compounds/PageHeader.svelte.d.ts +28 -0
  45. package/dist/components/compounds/Pagination.svelte +157 -0
  46. package/dist/components/compounds/Pagination.svelte.d.ts +4 -0
  47. package/dist/components/compounds/Popover.svelte +137 -0
  48. package/dist/components/compounds/Popover.svelte.d.ts +4 -0
  49. package/dist/components/compounds/PullRefresh.svelte +98 -0
  50. package/dist/components/compounds/PullRefresh.svelte.d.ts +16 -0
  51. package/dist/components/compounds/Result.svelte +84 -0
  52. package/dist/components/compounds/Result.svelte.d.ts +4 -0
  53. package/dist/components/compounds/SafeArea.svelte +37 -0
  54. package/dist/components/compounds/SafeArea.svelte.d.ts +12 -0
  55. package/dist/components/compounds/Skeleton.svelte +75 -0
  56. package/dist/components/compounds/Skeleton.svelte.d.ts +8 -0
  57. package/dist/components/compounds/Steps.svelte +111 -0
  58. package/dist/components/compounds/Steps.svelte.d.ts +4 -0
  59. package/dist/components/compounds/SwipeCell.svelte +115 -0
  60. package/dist/components/compounds/SwipeCell.svelte.d.ts +19 -0
  61. package/dist/components/compounds/Tabs.svelte +79 -0
  62. package/dist/components/compounds/Tabs.svelte.d.ts +8 -0
  63. package/dist/components/compounds/TagInput.svelte +108 -0
  64. package/dist/components/compounds/TagInput.svelte.d.ts +4 -0
  65. package/dist/components/compounds/Timeline.svelte +167 -0
  66. package/dist/components/compounds/Timeline.svelte.d.ts +25 -0
  67. package/dist/components/compounds/ToastContainer.svelte +59 -0
  68. package/dist/components/compounds/ToastContainer.svelte.d.ts +3 -0
  69. package/dist/components/compounds/Tooltip.svelte +41 -0
  70. package/dist/components/compounds/Tooltip.svelte.d.ts +4 -0
  71. package/dist/components/compounds/accordion-types.d.ts +16 -0
  72. package/dist/components/compounds/accordion-types.js +5 -0
  73. package/dist/components/compounds/action-sheet-types.d.ts +14 -0
  74. package/dist/components/compounds/action-sheet-types.js +5 -0
  75. package/dist/components/compounds/bottom-nav-types.d.ts +17 -0
  76. package/dist/components/compounds/bottom-nav-types.js +5 -0
  77. package/dist/components/compounds/index.d.ts +44 -0
  78. package/dist/components/compounds/index.js +54 -0
  79. package/dist/components/compounds/swipe-cell-types.d.ts +14 -0
  80. package/dist/components/compounds/swipe-cell-types.js +5 -0
  81. package/dist/components/compounds/timeline-types.d.ts +20 -0
  82. package/dist/components/compounds/timeline-types.js +5 -0
  83. package/dist/components/index.d.ts +9 -0
  84. package/dist/components/index.js +12 -0
  85. package/dist/components/primitives/Avatar.svelte +102 -0
  86. package/dist/components/primitives/Avatar.svelte.d.ts +8 -0
  87. package/dist/components/primitives/Badge.svelte +33 -0
  88. package/dist/components/primitives/Badge.svelte.d.ts +8 -0
  89. package/dist/components/primitives/BareButton.svelte +45 -0
  90. package/dist/components/primitives/BareButton.svelte.d.ts +4 -0
  91. package/dist/components/primitives/BareInput.svelte +101 -0
  92. package/dist/components/primitives/BareInput.svelte.d.ts +4 -0
  93. package/dist/components/primitives/Button.svelte +60 -0
  94. package/dist/components/primitives/Button.svelte.d.ts +8 -0
  95. package/dist/components/primitives/Checkbox.svelte +58 -0
  96. package/dist/components/primitives/Checkbox.svelte.d.ts +4 -0
  97. package/dist/components/primitives/IconButton.svelte +105 -0
  98. package/dist/components/primitives/IconButton.svelte.d.ts +4 -0
  99. package/dist/components/primitives/Input.svelte +130 -0
  100. package/dist/components/primitives/Input.svelte.d.ts +8 -0
  101. package/dist/components/primitives/Progress.svelte +58 -0
  102. package/dist/components/primitives/Progress.svelte.d.ts +4 -0
  103. package/dist/components/primitives/Radio.svelte +60 -0
  104. package/dist/components/primitives/Radio.svelte.d.ts +25 -0
  105. package/dist/components/primitives/Range.svelte +117 -0
  106. package/dist/components/primitives/Range.svelte.d.ts +27 -0
  107. package/dist/components/primitives/Rating.svelte +148 -0
  108. package/dist/components/primitives/Rating.svelte.d.ts +27 -0
  109. package/dist/components/primitives/Select.svelte +95 -0
  110. package/dist/components/primitives/Select.svelte.d.ts +25 -0
  111. package/dist/components/primitives/Spinner.svelte +54 -0
  112. package/dist/components/primitives/Spinner.svelte.d.ts +4 -0
  113. package/dist/components/primitives/Switch.svelte +53 -0
  114. package/dist/components/primitives/Switch.svelte.d.ts +4 -0
  115. package/dist/components/primitives/Tag.svelte +64 -0
  116. package/dist/components/primitives/Tag.svelte.d.ts +4 -0
  117. package/dist/components/primitives/Textarea.svelte +92 -0
  118. package/dist/components/primitives/Textarea.svelte.d.ts +4 -0
  119. package/dist/components/primitives/ToggleCheckbox.svelte +36 -0
  120. package/dist/components/primitives/ToggleCheckbox.svelte.d.ts +4 -0
  121. package/dist/components/primitives/ToggleInput.svelte +36 -0
  122. package/dist/components/primitives/ToggleInput.svelte.d.ts +4 -0
  123. package/dist/components/primitives/ToggleRadio.svelte +36 -0
  124. package/dist/components/primitives/ToggleRadio.svelte.d.ts +4 -0
  125. package/dist/components/primitives/index.d.ts +26 -0
  126. package/dist/components/primitives/index.js +30 -0
  127. package/dist/components/scenes/ai/AiDocumentDownloadMenu.svelte +387 -0
  128. package/dist/components/scenes/ai/AiDocumentDownloadMenu.svelte.d.ts +38 -0
  129. package/dist/components/scenes/ai/AiDocumentEditor.svelte +2259 -0
  130. package/dist/components/scenes/ai/AiDocumentEditor.svelte.d.ts +4 -0
  131. package/dist/components/scenes/ai/MarkdownRenderer.svelte +460 -0
  132. package/dist/components/scenes/ai/MarkdownRenderer.svelte.d.ts +4 -0
  133. package/dist/components/scenes/ai/document-download.d.ts +40 -0
  134. package/dist/components/scenes/ai/document-download.js +300 -0
  135. package/dist/components/scenes/ai/document-parse.d.ts +28 -0
  136. package/dist/components/scenes/ai/document-parse.js +243 -0
  137. package/dist/components/scenes/ai/document-types.d.ts +164 -0
  138. package/dist/components/scenes/ai/document-types.js +1 -0
  139. package/dist/components/scenes/ai/index.d.ts +17 -0
  140. package/dist/components/scenes/ai/index.js +13 -0
  141. package/dist/components/scenes/ai/markdown-parse.d.ts +32 -0
  142. package/dist/components/scenes/ai/markdown-parse.js +161 -0
  143. package/dist/components/scenes/app/FeedbackModal.svelte +160 -0
  144. package/dist/components/scenes/app/FeedbackModal.svelte.d.ts +47 -0
  145. package/dist/components/scenes/app/LanguageSwitch.svelte +136 -0
  146. package/dist/components/scenes/app/LanguageSwitch.svelte.d.ts +50 -0
  147. package/dist/components/scenes/app/SettingsModal.svelte +174 -0
  148. package/dist/components/scenes/app/SettingsModal.svelte.d.ts +31 -0
  149. package/dist/components/scenes/app/ThemeSelector.svelte +176 -0
  150. package/dist/components/scenes/app/ThemeSelector.svelte.d.ts +31 -0
  151. package/dist/components/scenes/app/ThemeToggle.svelte +76 -0
  152. package/dist/components/scenes/app/ThemeToggle.svelte.d.ts +29 -0
  153. package/dist/components/scenes/app/index.d.ts +11 -0
  154. package/dist/components/scenes/app/index.js +11 -0
  155. package/dist/components/scenes/crud/CrudDeleteConfirm.svelte +39 -0
  156. package/dist/components/scenes/crud/CrudDeleteConfirm.svelte.d.ts +18 -0
  157. package/dist/components/scenes/crud/CrudDetailDrawer.svelte +155 -0
  158. package/dist/components/scenes/crud/CrudDetailDrawer.svelte.d.ts +38 -0
  159. package/dist/components/scenes/crud/CrudEditDrawer.svelte +222 -0
  160. package/dist/components/scenes/crud/CrudEditDrawer.svelte.d.ts +48 -0
  161. package/dist/components/scenes/crud/CrudFilterBar.svelte +96 -0
  162. package/dist/components/scenes/crud/CrudFilterBar.svelte.d.ts +31 -0
  163. package/dist/components/scenes/crud/CrudPage.svelte +499 -0
  164. package/dist/components/scenes/crud/CrudPage.svelte.d.ts +114 -0
  165. package/dist/components/scenes/crud/crud-types.d.ts +135 -0
  166. package/dist/components/scenes/crud/crud-types.js +7 -0
  167. package/dist/components/scenes/crud/index.d.ts +12 -0
  168. package/dist/components/scenes/crud/index.js +11 -0
  169. package/dist/components/scenes/crypto/EncryptedInput.svelte +156 -0
  170. package/dist/components/scenes/crypto/EncryptedInput.svelte.d.ts +32 -0
  171. package/dist/components/scenes/crypto/HashDisplay.svelte +97 -0
  172. package/dist/components/scenes/crypto/HashDisplay.svelte.d.ts +4 -0
  173. package/dist/components/scenes/crypto/SignatureDisplay.svelte +161 -0
  174. package/dist/components/scenes/crypto/SignatureDisplay.svelte.d.ts +4 -0
  175. package/dist/components/scenes/crypto/index.d.ts +9 -0
  176. package/dist/components/scenes/crypto/index.js +11 -0
  177. package/dist/components/scenes/iam/ChangePasswordForm.svelte +134 -0
  178. package/dist/components/scenes/iam/ChangePasswordForm.svelte.d.ts +8 -0
  179. package/dist/components/scenes/iam/ForgotPasswordForm.svelte +147 -0
  180. package/dist/components/scenes/iam/ForgotPasswordForm.svelte.d.ts +4 -0
  181. package/dist/components/scenes/iam/LoginForm.svelte +191 -0
  182. package/dist/components/scenes/iam/LoginForm.svelte.d.ts +8 -0
  183. package/dist/components/scenes/iam/PasswordInput.svelte +203 -0
  184. package/dist/components/scenes/iam/PasswordInput.svelte.d.ts +49 -0
  185. package/dist/components/scenes/iam/PermGuard.svelte +69 -0
  186. package/dist/components/scenes/iam/PermGuard.svelte.d.ts +16 -0
  187. package/dist/components/scenes/iam/RegisterForm.svelte +269 -0
  188. package/dist/components/scenes/iam/RegisterForm.svelte.d.ts +8 -0
  189. package/dist/components/scenes/iam/ResetPasswordForm.svelte +153 -0
  190. package/dist/components/scenes/iam/ResetPasswordForm.svelte.d.ts +8 -0
  191. package/dist/components/scenes/iam/UserProfile.svelte +251 -0
  192. package/dist/components/scenes/iam/UserProfile.svelte.d.ts +4 -0
  193. package/dist/components/scenes/iam/index.d.ts +16 -0
  194. package/dist/components/scenes/iam/index.js +18 -0
  195. package/dist/components/scenes/iam/password-utils.d.ts +15 -0
  196. package/dist/components/scenes/iam/password-utils.js +19 -0
  197. package/dist/components/scenes/iam/permission-context.svelte.d.ts +90 -0
  198. package/dist/components/scenes/iam/permission-context.svelte.js +114 -0
  199. package/dist/components/scenes/index.d.ts +13 -0
  200. package/dist/components/scenes/index.js +20 -0
  201. package/dist/components/scenes/storage/AvatarUpload.svelte +218 -0
  202. package/dist/components/scenes/storage/AvatarUpload.svelte.d.ts +4 -0
  203. package/dist/components/scenes/storage/FileList.svelte +260 -0
  204. package/dist/components/scenes/storage/FileList.svelte.d.ts +4 -0
  205. package/dist/components/scenes/storage/FileUpload.svelte +400 -0
  206. package/dist/components/scenes/storage/FileUpload.svelte.d.ts +7 -0
  207. package/dist/components/scenes/storage/ImageUpload.svelte +285 -0
  208. package/dist/components/scenes/storage/ImageUpload.svelte.d.ts +4 -0
  209. package/dist/components/scenes/storage/index.d.ts +10 -0
  210. package/dist/components/scenes/storage/index.js +12 -0
  211. package/dist/components/scenes/types.d.ts +583 -0
  212. package/dist/components/scenes/types.js +7 -0
  213. package/dist/i18n.svelte.d.ts +44 -0
  214. package/dist/i18n.svelte.js +108 -0
  215. package/dist/index.d.ts +13 -0
  216. package/dist/index.js +19 -0
  217. package/dist/messages/en-US.json +242 -0
  218. package/dist/messages/zh-CN.json +242 -0
  219. package/dist/messages.d.ts +29 -0
  220. package/dist/messages.js +42 -0
  221. package/dist/styles/design-tokens.css +58 -0
  222. package/dist/styles/global.css +218 -0
  223. package/dist/styles/mobile.css +105 -0
  224. package/dist/theme-config.d.ts +92 -0
  225. package/dist/theme-config.js +122 -0
  226. package/dist/toast.svelte.d.ts +45 -0
  227. package/dist/toast.svelte.js +77 -0
  228. package/dist/types.d.ts +944 -0
  229. package/dist/types.js +7 -0
  230. package/dist/utils/platform.svelte.d.ts +54 -0
  231. package/dist/utils/platform.svelte.js +87 -0
  232. package/dist/utils.d.ts +46 -0
  233. package/dist/utils.js +100 -0
  234. package/package.json +69 -0
@@ -0,0 +1,400 @@
1
+ <!--
2
+ =============================================================================
3
+ @h-ai/ui - FileUpload 组件
4
+ =============================================================================
5
+ 文件上传组件,支持签名 URL 直传和多文件上传
6
+
7
+ 使用 Svelte 5 Runes ($props, $state, $derived)
8
+ 使用 primitives 组件:Progress, IconButton
9
+ =============================================================================
10
+ -->
11
+ <script lang='ts'>
12
+ import type { FileUploadProps, UploadFile } from '../types.js'
13
+ import { uiM } from '../../../messages.js'
14
+ import { cn, generateId } from '../../../utils.js'
15
+ import BareInput from '../../primitives/BareInput.svelte'
16
+ import IconButton from '../../primitives/IconButton.svelte'
17
+ import Progress from '../../primitives/Progress.svelte'
18
+
19
+ const {
20
+ accept = '*',
21
+ maxSize = 10 * 1024 * 1024, // 10MB
22
+ maxFiles = 1,
23
+ multiple = false,
24
+ disabled = false,
25
+ uploadUrl = '',
26
+ presignUrl = '',
27
+ headers = {},
28
+ autoUpload = true,
29
+ showList = true,
30
+ dragDrop = true,
31
+ class: className = '',
32
+ onchange,
33
+ onupload,
34
+ onerror,
35
+ onremove,
36
+ }: FileUploadProps = $props()
37
+
38
+ let files = $state<UploadFile[]>([])
39
+ let isDragging = $state(false)
40
+ let inputElement = $state<HTMLInputElement | undefined>(undefined)
41
+
42
+ const containerClass = $derived(
43
+ cn(
44
+ 'file-upload',
45
+ disabled && 'opacity-50 pointer-events-none',
46
+ className,
47
+ ),
48
+ )
49
+
50
+ const dropzoneClass = $derived(
51
+ cn(
52
+ 'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
53
+ isDragging ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary',
54
+ ),
55
+ )
56
+
57
+ // 格式化文件大小
58
+ function formatSize(bytes: number): string {
59
+ if (bytes === 0)
60
+ return '0 B'
61
+ const k = 1024
62
+ const sizes = ['B', 'KB', 'MB', 'GB']
63
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
64
+ return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
65
+ }
66
+
67
+ // 验证文件
68
+ function validateFile(file: File): string | null {
69
+ if (file.size > maxSize) {
70
+ return `${uiM('file_upload_size_exceeded')} ${formatSize(maxSize)})`
71
+ }
72
+
73
+ if (accept !== '*') {
74
+ const acceptedTypes = accept.split(',').map(t => t.trim())
75
+ const fileType = file.type
76
+ const fileExt = `.${file.name.split('.').pop()?.toLowerCase()}`
77
+
78
+ const isAccepted = acceptedTypes.some((type) => {
79
+ if (type.startsWith('.')) {
80
+ return fileExt === type.toLowerCase()
81
+ }
82
+ if (type.endsWith('/*')) {
83
+ return fileType.startsWith(type.replace('/*', '/'))
84
+ }
85
+ return fileType === type
86
+ })
87
+
88
+ if (!isAccepted) {
89
+ return uiM('file_upload_unsupported_type')
90
+ }
91
+ }
92
+
93
+ return null
94
+ }
95
+
96
+ // 添加文件
97
+ async function addFiles(fileList: FileList | null) {
98
+ if (!fileList)
99
+ return
100
+
101
+ const newFiles: UploadFile[] = []
102
+
103
+ for (const file of Array.from(fileList)) {
104
+ if (files.length + newFiles.length >= maxFiles) {
105
+ onerror?.(`${uiM('file_upload_max_files_exceeded')} ${maxFiles} ${uiM('file_upload_files_unit')}`)
106
+ break
107
+ }
108
+
109
+ const error = validateFile(file)
110
+
111
+ const uploadFile: UploadFile = {
112
+ id: generateId('file'),
113
+ file,
114
+ name: file.name,
115
+ size: file.size,
116
+ type: file.type,
117
+ state: error ? 'error' : 'pending',
118
+ progress: 0,
119
+ error: error || undefined,
120
+ }
121
+
122
+ newFiles.push(uploadFile)
123
+ }
124
+
125
+ files = [...files, ...newFiles]
126
+ onchange?.(files)
127
+
128
+ // 自动上传
129
+ if (autoUpload) {
130
+ for (const file of newFiles.filter(f => f.state === 'pending')) {
131
+ await uploadFile(file)
132
+ }
133
+ }
134
+ }
135
+
136
+ // 上传单个文件
137
+ async function uploadFile(uploadFile: UploadFile) {
138
+ if (!uploadUrl && !presignUrl) {
139
+ uploadFile.error = uiM('file_upload_no_url')
140
+ uploadFile.state = 'error'
141
+ return
142
+ }
143
+
144
+ uploadFile.state = 'uploading'
145
+ files = [...files]
146
+
147
+ try {
148
+ let targetUrl = uploadUrl
149
+
150
+ // 如果配置了签名 URL,先获取
151
+ if (presignUrl) {
152
+ const presignResponse = await fetch(presignUrl, {
153
+ method: 'POST',
154
+ headers: {
155
+ 'Content-Type': 'application/json',
156
+ ...headers,
157
+ },
158
+ body: JSON.stringify({
159
+ filename: uploadFile.name,
160
+ contentType: uploadFile.type,
161
+ size: uploadFile.size,
162
+ }),
163
+ })
164
+
165
+ if (!presignResponse.ok) {
166
+ throw new Error(uiM('file_upload_get_url_failed'))
167
+ }
168
+
169
+ const { url } = await presignResponse.json()
170
+ if (!/^https?:\/\//i.test(url)) {
171
+ throw new Error(uiM('file_upload_get_url_failed'))
172
+ }
173
+ targetUrl = url
174
+ }
175
+
176
+ // 上传文件
177
+ const xhr = new XMLHttpRequest()
178
+
179
+ await new Promise<void>((resolve, reject) => {
180
+ xhr.upload.onprogress = (e) => {
181
+ if (e.lengthComputable) {
182
+ uploadFile.progress = Math.round((e.loaded / e.total) * 100)
183
+ files = [...files]
184
+ }
185
+ }
186
+
187
+ xhr.onload = () => {
188
+ if (xhr.status >= 200 && xhr.status < 300) {
189
+ uploadFile.state = 'success'
190
+ uploadFile.progress = 100
191
+ try {
192
+ uploadFile.response = JSON.parse(xhr.responseText)
193
+ }
194
+ catch {
195
+ uploadFile.response = xhr.responseText
196
+ }
197
+ resolve()
198
+ }
199
+ else {
200
+ reject(new Error(`${uiM('file_upload_failed')}: ${xhr.statusText}`))
201
+ }
202
+ }
203
+
204
+ xhr.onerror = () => reject(new Error(uiM('file_upload_network_error')))
205
+
206
+ xhr.open('PUT', targetUrl!)
207
+ xhr.setRequestHeader('Content-Type', uploadFile.type || 'application/octet-stream')
208
+
209
+ for (const [key, value] of Object.entries(headers)) {
210
+ xhr.setRequestHeader(key, value)
211
+ }
212
+
213
+ xhr.send(uploadFile.file)
214
+ })
215
+
216
+ onupload?.(uploadFile)
217
+ }
218
+ catch (error) {
219
+ uploadFile.state = 'error'
220
+ uploadFile.error = error instanceof Error ? error.message : uiM('file_upload_failed')
221
+ onerror?.(uploadFile.error)
222
+ }
223
+
224
+ files = [...files]
225
+ }
226
+
227
+ // 移除文件
228
+ function removeFile(id: string) {
229
+ const file = files.find(f => f.id === id)
230
+ if (file) {
231
+ files = files.filter(f => f.id !== id)
232
+ onremove?.(file)
233
+ onchange?.(files)
234
+ }
235
+ }
236
+
237
+ // 重试上传
238
+ async function retryUpload(id: string) {
239
+ const file = files.find(f => f.id === id)
240
+ if (file && file.state === 'error') {
241
+ file.state = 'pending'
242
+ file.error = undefined
243
+ file.progress = 0
244
+ files = [...files]
245
+ await uploadFile(file)
246
+ }
247
+ }
248
+
249
+ // 拖拽处理
250
+ function handleDragOver(e: DragEvent) {
251
+ e.preventDefault()
252
+ if (dragDrop) {
253
+ isDragging = true
254
+ }
255
+ }
256
+
257
+ function handleDragLeave() {
258
+ isDragging = false
259
+ }
260
+
261
+ function handleDrop(e: DragEvent) {
262
+ e.preventDefault()
263
+ isDragging = false
264
+ if (dragDrop) {
265
+ addFiles(e.dataTransfer?.files || null)
266
+ }
267
+ }
268
+
269
+ function handleClick() {
270
+ inputElement?.click()
271
+ }
272
+
273
+ function handleChange(e: Event & { currentTarget: HTMLInputElement }) {
274
+ addFiles(e.currentTarget.files)
275
+ e.currentTarget.value = ''
276
+ }
277
+
278
+ /** 手动触发上传 */
279
+ export async function upload() {
280
+ for (const file of files.filter(f => f.state === 'pending')) {
281
+ await uploadFile(file)
282
+ }
283
+ }
284
+
285
+ /** 清空文件列表 */
286
+ export function clear() {
287
+ files = []
288
+ onchange?.(files)
289
+ }
290
+ </script>
291
+
292
+ <div class={containerClass}>
293
+ <!-- 上传区域 -->
294
+ <div
295
+ class={dropzoneClass}
296
+ role='button'
297
+ tabindex='0'
298
+ ondragover={handleDragOver}
299
+ ondragleave={handleDragLeave}
300
+ ondrop={handleDrop}
301
+ onclick={handleClick}
302
+ onkeydown={e => e.key === 'Enter' && handleClick()}
303
+ >
304
+ <BareInput
305
+ type='file'
306
+ class='hidden'
307
+ bind:inputRef={inputElement}
308
+ {accept}
309
+ {multiple}
310
+ {disabled}
311
+ onchange={handleChange}
312
+ />
313
+
314
+ <div class='flex flex-col items-center gap-2'>
315
+ <svg xmlns='http://www.w3.org/2000/svg' class='h-12 w-12 text-base-content/30' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
316
+ <path stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12' />
317
+ </svg>
318
+ <div class='text-base-content/70'>
319
+ {#if dragDrop}
320
+ <span>{uiM('file_upload_drag_hint')}</span>
321
+ {/if}
322
+ <span class='text-primary'>{uiM('file_upload_click_to_select')}</span>
323
+ </div>
324
+ <div class='text-xs text-base-content/50'>
325
+ {#if accept !== '*'}
326
+ {uiM('file_upload_supported_formats')} {accept},
327
+ {/if}
328
+ {uiM('file_upload_max_size')} {formatSize(maxSize)}
329
+ {#if maxFiles > 1}
330
+ ,{uiM('file_upload_max_files_hint')} {maxFiles} {uiM('file_upload_files_unit')}
331
+ {/if}
332
+ </div>
333
+ </div>
334
+ </div>
335
+
336
+ <!-- 文件列表 -->
337
+ {#if showList && files.length > 0}
338
+ <div class='mt-4 space-y-2'>
339
+ {#each files as file (file.id)}
340
+ <div class='flex items-center gap-3 p-3 bg-base-200 rounded-lg'>
341
+ <!-- 文件图标 -->
342
+ <div class='text-base-content/50'>
343
+ <svg xmlns='http://www.w3.org/2000/svg' class='h-8 w-8' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
344
+ <path stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' />
345
+ </svg>
346
+ </div>
347
+
348
+ <!-- 文件信息 -->
349
+ <div class='flex-1 min-w-0'>
350
+ <div class='flex items-center gap-2'>
351
+ <span class='truncate font-medium'>{file.name}</span>
352
+ <span class='text-xs text-base-content/50'>{formatSize(file.size)}</span>
353
+ </div>
354
+
355
+ {#if file.state === 'uploading'}
356
+ <div class='mt-1'>
357
+ <Progress value={file.progress} max={100} size='xs' />
358
+ <span class='text-xs text-base-content/50'>{file.progress}%</span>
359
+ </div>
360
+ {:else if file.state === 'error'}
361
+ <div class='text-xs text-error mt-1'>{file.error}</div>
362
+ {:else if file.state === 'success'}
363
+ <div class='text-xs text-success mt-1'>{uiM('file_upload_success')}</div>
364
+ {/if}
365
+ </div>
366
+
367
+ <!-- 操作按钮 -->
368
+ <div class='flex gap-1'>
369
+ {#if file.state === 'error'}
370
+ <IconButton
371
+ size='xs'
372
+ variant='ghost'
373
+ label={uiM('file_upload_retry')}
374
+ onclick={() => retryUpload(file.id)}
375
+ >
376
+ {#snippet children()}
377
+ <svg xmlns='http://www.w3.org/2000/svg' class='h-4 w-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
378
+ <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' />
379
+ </svg>
380
+ {/snippet}
381
+ </IconButton>
382
+ {/if}
383
+ <IconButton
384
+ size='xs'
385
+ variant='ghost'
386
+ label={uiM('file_upload_remove')}
387
+ onclick={() => removeFile(file.id)}
388
+ >
389
+ {#snippet children()}
390
+ <svg xmlns='http://www.w3.org/2000/svg' class='h-4 w-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
391
+ <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 18L18 6M6 6l12 12' />
392
+ </svg>
393
+ {/snippet}
394
+ </IconButton>
395
+ </div>
396
+ </div>
397
+ {/each}
398
+ </div>
399
+ {/if}
400
+ </div>
@@ -0,0 +1,7 @@
1
+ import type { FileUploadProps } from '../types.js';
2
+ declare const FileUpload: import("svelte").Component<FileUploadProps, {
3
+ upload: () => Promise<void>;
4
+ clear: () => void;
5
+ }, "">;
6
+ type FileUpload = ReturnType<typeof FileUpload>;
7
+ export default FileUpload;
@@ -0,0 +1,285 @@
1
+ <!--
2
+ =============================================================================
3
+ @h-ai/ui - ImageUpload 组件
4
+ =============================================================================
5
+ 图片上传组件,支持预览
6
+
7
+ 使用 Svelte 5 Runes ($props, $state, $derived)
8
+ 使用 primitives 组件:IconButton, Spinner
9
+ =============================================================================
10
+ -->
11
+ <script lang='ts'>
12
+ import type { ImageUploadProps } from '../types.js'
13
+ import { uiM } from '../../../messages.js'
14
+ import { cn } from '../../../utils.js'
15
+ import BareInput from '../../primitives/BareInput.svelte'
16
+ import IconButton from '../../primitives/IconButton.svelte'
17
+ import Spinner from '../../primitives/Spinner.svelte'
18
+
19
+ let {
20
+ value = $bindable(''),
21
+ accept = 'image/*',
22
+ maxSize = 5 * 1024 * 1024, // 5MB
23
+ disabled = false,
24
+ uploadUrl = '',
25
+ presignUrl = '',
26
+ headers = {},
27
+ placeholder,
28
+ aspectRatio = '',
29
+ width = '',
30
+ height = '',
31
+ class: className = '',
32
+ onchange,
33
+ onerror,
34
+ }: ImageUploadProps = $props()
35
+
36
+ let loading = $state(false)
37
+ let inputElement = $state<HTMLInputElement | undefined>(undefined)
38
+ let previewUrl = $state('')
39
+
40
+ // 当 value 变化时更新预览
41
+ $effect(() => {
42
+ previewUrl = value
43
+ })
44
+
45
+ const containerClass = $derived(
46
+ cn(
47
+ 'image-upload relative overflow-hidden rounded-lg border-2 border-dashed transition-colors',
48
+ !disabled && 'cursor-pointer hover:border-primary',
49
+ disabled && 'opacity-50 cursor-not-allowed',
50
+ previewUrl ? 'border-transparent' : 'border-base-300',
51
+ className,
52
+ ),
53
+ )
54
+
55
+ const containerStyle = $derived(
56
+ [
57
+ width && `width: ${width}`,
58
+ height && `height: ${height}`,
59
+ aspectRatio && `aspect-ratio: ${aspectRatio}`,
60
+ !width && !height && !aspectRatio && 'width: 200px; height: 200px',
61
+ ].filter(Boolean).join('; '),
62
+ )
63
+
64
+ // 格式化文件大小
65
+ function formatSize(bytes: number): string {
66
+ if (bytes === 0)
67
+ return '0 B'
68
+ const k = 1024
69
+ const sizes = ['B', 'KB', 'MB', 'GB']
70
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
71
+ return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
72
+ }
73
+
74
+ // 验证文件
75
+ function validateFile(file: File): string | null {
76
+ if (file.size > maxSize) {
77
+ return `${uiM('image_upload_size_exceeded')} ${formatSize(maxSize)})`
78
+ }
79
+
80
+ if (!file.type.startsWith('image/')) {
81
+ return uiM('image_upload_invalid_type')
82
+ }
83
+
84
+ return null
85
+ }
86
+
87
+ // 上传文件
88
+ async function uploadFile(file: File) {
89
+ loading = true
90
+
91
+ // 释放旧的 Blob URL
92
+ if (previewUrl && previewUrl.startsWith('blob:')) {
93
+ URL.revokeObjectURL(previewUrl)
94
+ }
95
+
96
+ try {
97
+ // 先创建本地预览
98
+ previewUrl = URL.createObjectURL(file)
99
+
100
+ if (!uploadUrl && !presignUrl) {
101
+ // 没有上传地址,只做本地预览
102
+ value = previewUrl
103
+ onchange?.(previewUrl)
104
+ return
105
+ }
106
+
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 && !/^https?:\/\//i.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,
145
+ })
146
+
147
+ if (!response.ok) {
148
+ throw new Error(uiM('image_upload_failed'))
149
+ }
150
+
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
165
+ // 释放 Blob URL(已有最终 URL)
166
+ if (previewUrl && previewUrl.startsWith('blob:')) {
167
+ URL.revokeObjectURL(previewUrl)
168
+ }
169
+ previewUrl = finalUrl
170
+ onchange?.(finalUrl)
171
+ }
172
+ catch (error) {
173
+ const message = error instanceof Error ? error.message : uiM('image_upload_failed')
174
+ onerror?.(message)
175
+ // 清除预览
176
+ previewUrl = ''
177
+ value = ''
178
+ }
179
+ finally {
180
+ loading = false
181
+ }
182
+ }
183
+
184
+ function handleClick() {
185
+ if (!disabled) {
186
+ inputElement?.click()
187
+ }
188
+ }
189
+
190
+ function handleChange(e: Event & { currentTarget: HTMLInputElement }) {
191
+ const file = e.currentTarget.files?.[0]
192
+ if (file) {
193
+ const error = validateFile(file)
194
+ if (error) {
195
+ onerror?.(error)
196
+ return
197
+ }
198
+ uploadFile(file)
199
+ }
200
+ e.currentTarget.value = ''
201
+ }
202
+
203
+ function handleRemove(e: MouseEvent) {
204
+ e.stopPropagation()
205
+ value = ''
206
+ previewUrl = ''
207
+ onchange?.('')
208
+ }
209
+ </script>
210
+
211
+ <div
212
+ class={containerClass}
213
+ style={containerStyle}
214
+ role='button'
215
+ tabindex='0'
216
+ onclick={handleClick}
217
+ onkeydown={e => e.key === 'Enter' && handleClick()}
218
+ >
219
+ <BareInput
220
+ type='file'
221
+ class='hidden'
222
+ bind:inputRef={inputElement}
223
+ {accept}
224
+ {disabled}
225
+ onchange={handleChange}
226
+ />
227
+
228
+ {#if previewUrl}
229
+ <!-- 预览图 -->
230
+ <img
231
+ src={previewUrl}
232
+ alt={uiM('image_upload_preview_alt')}
233
+ class='w-full h-full object-cover'
234
+ />
235
+
236
+ <!-- 遮罩层 -->
237
+ {#if !disabled}
238
+ <div class='absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2'>
239
+ {#if loading}
240
+ <Spinner size='md' class='text-white' />
241
+ {:else}
242
+ <IconButton
243
+ size='sm'
244
+ variant='ghost'
245
+ label={uiM('image_upload_change')}
246
+ onclick={handleClick}
247
+ class='text-white'
248
+ >
249
+ {#snippet children()}
250
+ <svg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
251
+ <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' />
252
+ </svg>
253
+ {/snippet}
254
+ </IconButton>
255
+ <IconButton
256
+ size='sm'
257
+ variant='ghost'
258
+ label={uiM('image_upload_delete')}
259
+ onclick={handleRemove}
260
+ class='text-white'
261
+ >
262
+ {#snippet children()}
263
+ <svg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
264
+ <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' />
265
+ </svg>
266
+ {/snippet}
267
+ </IconButton>
268
+ {/if}
269
+ </div>
270
+ {/if}
271
+ {:else}
272
+ <!-- 占位符 -->
273
+ <div class='w-full h-full flex flex-col items-center justify-center text-base-content/50 p-4'>
274
+ {#if loading}
275
+ <Spinner size='lg' />
276
+ {:else}
277
+ <svg xmlns='http://www.w3.org/2000/svg' class='h-12 w-12 mb-2' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
278
+ <path stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' />
279
+ </svg>
280
+ <span class='text-sm text-center'>{placeholder || uiM('image_upload_placeholder')}</span>
281
+ <span class='text-xs mt-1'>{uiM('image_upload_max_size_hint')} {formatSize(maxSize)}</span>
282
+ {/if}
283
+ </div>
284
+ {/if}
285
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { ImageUploadProps } from '../types.js';
2
+ declare const ImageUpload: import("svelte").Component<ImageUploadProps, {}, "value">;
3
+ type ImageUpload = ReturnType<typeof ImageUpload>;
4
+ export default ImageUpload;