@h-ai/ui 0.1.0-alpha.10

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