@h-ai/ui 0.1.0-alpha.18 → 0.1.0-alpha.20
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 +49 -11
- package/auto-import.d.ts +22 -0
- package/auto-import.js +9 -1
- package/dist/components/primitives/IconButton.svelte +32 -6
- package/dist/components/scenes/ai/AiDocumentEditor.svelte +20 -136
- package/dist/components/scenes/ai/MarkdownRenderer.svelte +12 -10
- package/dist/components/scenes/ai/code-preview.d.ts +18 -0
- package/dist/components/scenes/ai/code-preview.js +112 -0
- package/dist/components/scenes/ai/document-types.d.ts +14 -0
- package/dist/components/scenes/crypto/EncryptedInput.svelte +2 -1
- package/dist/components/scenes/crypto/HashDisplay.svelte +3 -6
- package/dist/components/scenes/crypto/SignatureDisplay.svelte +5 -12
- package/dist/components/scenes/iam/ForgotPasswordForm.svelte +11 -2
- package/dist/components/scenes/iam/RegisterForm.svelte +13 -4
- package/dist/components/scenes/storage/AvatarUpload.svelte +52 -7
- package/dist/components/scenes/storage/FileUpload.svelte +88 -18
- package/dist/i18n.svelte.js +3 -2
- package/dist/internal/browser-safety.d.ts +26 -0
- package/dist/internal/browser-safety.js +58 -0
- package/dist/theme-config.d.ts +3 -2
- package/dist/theme-config.js +8 -8
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const HTML_DOCUMENT_PATTERN = /<\s*(?:!doctype|html|body|head|div|section|article|main|span|style|script|svg|canvas)\b/i;
|
|
2
|
+
const HTML_LANGUAGE_SET = new Set(['html', 'htm', 'xhtml', 'xml', 'svg']);
|
|
3
|
+
const JAVASCRIPT_LANGUAGE_SET = new Set(['javascript', 'js', 'typescript', 'ts']);
|
|
4
|
+
const CSS_LANGUAGE_SET = new Set(['css']);
|
|
5
|
+
/**
|
|
6
|
+
* 根据运行结果构造 iframe sandbox 值。
|
|
7
|
+
*
|
|
8
|
+
* 默认不允许脚本执行;只有显式声明 `allowScripts` 时才放开 `allow-scripts`。
|
|
9
|
+
*/
|
|
10
|
+
export function resolvePreviewSandbox(allowScripts) {
|
|
11
|
+
return allowScripts ? 'allow-scripts' : '';
|
|
12
|
+
}
|
|
13
|
+
export function createBuiltInCodePreview(request, options) {
|
|
14
|
+
const language = (request.language ?? '').trim().toLowerCase();
|
|
15
|
+
if (language === 'markdown' || language === 'md') {
|
|
16
|
+
return {
|
|
17
|
+
kind: 'markdown',
|
|
18
|
+
title: options.previewTitle,
|
|
19
|
+
content: request.code,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (!options.allowUnsafeCodePreview) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
if (HTML_LANGUAGE_SET.has(language) || (!language && looksLikeHtml(request.code))) {
|
|
26
|
+
return {
|
|
27
|
+
kind: 'html',
|
|
28
|
+
title: options.previewTitle,
|
|
29
|
+
content: request.code,
|
|
30
|
+
allowScripts: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (JAVASCRIPT_LANGUAGE_SET.has(language)) {
|
|
34
|
+
return {
|
|
35
|
+
kind: 'html',
|
|
36
|
+
title: options.previewTitle,
|
|
37
|
+
content: buildJavaScriptPreview(request.code),
|
|
38
|
+
allowScripts: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (CSS_LANGUAGE_SET.has(language)) {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'html',
|
|
44
|
+
title: options.previewTitle,
|
|
45
|
+
content: buildCssPreview(request.code),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
function looksLikeHtml(code) {
|
|
51
|
+
return HTML_DOCUMENT_PATTERN.test(code);
|
|
52
|
+
}
|
|
53
|
+
function buildJavaScriptPreview(code) {
|
|
54
|
+
const safeCode = code.replace(/<\/script/gi, '<\\/script');
|
|
55
|
+
return `<!doctype html>
|
|
56
|
+
<html lang="en">
|
|
57
|
+
<body>
|
|
58
|
+
<div id="app"></div>
|
|
59
|
+
<script type="module">
|
|
60
|
+
${safeCode}
|
|
61
|
+
</script>
|
|
62
|
+
</body>
|
|
63
|
+
</html>`;
|
|
64
|
+
}
|
|
65
|
+
function buildCssPreview(code) {
|
|
66
|
+
const sampleMarkup = [
|
|
67
|
+
'<div class="hai-preview-shell">',
|
|
68
|
+
' <button class="hai-preview-button">Preview Button</button>',
|
|
69
|
+
' <div class="hai-preview-card">Preview Card</div>',
|
|
70
|
+
'</div>',
|
|
71
|
+
].join('\n');
|
|
72
|
+
return `<!doctype html>
|
|
73
|
+
<html lang="en">
|
|
74
|
+
<head>
|
|
75
|
+
<style>
|
|
76
|
+
body {
|
|
77
|
+
margin: 0;
|
|
78
|
+
min-height: 100vh;
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
justify-content: center;
|
|
82
|
+
background: #f5f5f5;
|
|
83
|
+
font-family: system-ui, sans-serif;
|
|
84
|
+
}
|
|
85
|
+
.hai-preview-shell {
|
|
86
|
+
display: grid;
|
|
87
|
+
gap: 12px;
|
|
88
|
+
padding: 24px;
|
|
89
|
+
}
|
|
90
|
+
.hai-preview-button {
|
|
91
|
+
border: 0;
|
|
92
|
+
border-radius: 999px;
|
|
93
|
+
padding: 10px 16px;
|
|
94
|
+
background: #2563eb;
|
|
95
|
+
color: white;
|
|
96
|
+
font-size: 14px;
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
}
|
|
99
|
+
.hai-preview-card {
|
|
100
|
+
padding: 16px;
|
|
101
|
+
border-radius: 16px;
|
|
102
|
+
background: white;
|
|
103
|
+
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
|
104
|
+
}
|
|
105
|
+
${code}
|
|
106
|
+
</style>
|
|
107
|
+
</head>
|
|
108
|
+
<body>
|
|
109
|
+
${sampleMarkup}
|
|
110
|
+
</body>
|
|
111
|
+
</html>`;
|
|
112
|
+
}
|
|
@@ -41,6 +41,13 @@ export interface MarkdownCodeRunResult {
|
|
|
41
41
|
title?: string;
|
|
42
42
|
/** Optional preview description. */
|
|
43
43
|
description?: string;
|
|
44
|
+
/**
|
|
45
|
+
* 预览 iframe 是否允许执行脚本。
|
|
46
|
+
*
|
|
47
|
+
* 默认值为空,组件会以最严格的 sandbox 渲染 HTML 预览;
|
|
48
|
+
* 只有调用方显式声明后才会放开 `allow-scripts`。
|
|
49
|
+
*/
|
|
50
|
+
allowScripts?: boolean;
|
|
44
51
|
}
|
|
45
52
|
export interface MarkdownRewriteAction {
|
|
46
53
|
/** Rewrite action id used in callbacks. */
|
|
@@ -136,6 +143,13 @@ export interface AiDocumentEditorProps {
|
|
|
136
143
|
showRunButton?: boolean;
|
|
137
144
|
/** Whether to show the code/preview toggle on code blocks. */
|
|
138
145
|
showCodePreviewToggle?: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* 是否启用内置的高风险代码预览。
|
|
148
|
+
*
|
|
149
|
+
* 默认仅允许 Markdown 预览;HTML / JS / CSS 等会执行或渲染任意代码的内置预览
|
|
150
|
+
* 需要宿主显式开启,或由宿主通过 `oncoderun` 自行提供受控沙箱结果。
|
|
151
|
+
*/
|
|
152
|
+
allowUnsafeCodePreview?: boolean;
|
|
139
153
|
/** Optional helper text displayed beside language when preview toggle is enabled. */
|
|
140
154
|
codePreviewHint?: string;
|
|
141
155
|
/** Whether syntax highlighting is enabled. */
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
-->
|
|
11
11
|
<script lang='ts'>
|
|
12
12
|
import type { Size } from '../../../types.js'
|
|
13
|
+
import { writeTextToClipboard } from '../../../internal/browser-safety.js'
|
|
13
14
|
import { uiM } from '../../../messages.js'
|
|
14
15
|
import { BareButton, IconButton, Input } from '../../primitives/index.js'
|
|
15
16
|
|
|
@@ -87,7 +88,7 @@
|
|
|
87
88
|
|
|
88
89
|
function copyEncrypted() {
|
|
89
90
|
if (encryptedValue) {
|
|
90
|
-
|
|
91
|
+
void writeTextToClipboard(encryptedValue)
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
</script>
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
-->
|
|
11
11
|
<script lang='ts'>
|
|
12
12
|
import type { HashDisplayProps } from '../types.js'
|
|
13
|
+
import { writeTextToClipboard } from '../../../internal/browser-safety.js'
|
|
13
14
|
import { uiM } from '../../../messages.js'
|
|
14
15
|
import { cn } from '../../../utils.js'
|
|
15
16
|
import { Badge, IconButton } from '../../primitives/index.js'
|
|
@@ -44,16 +45,12 @@
|
|
|
44
45
|
if (!value)
|
|
45
46
|
return
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
await navigator.clipboard.writeText(value)
|
|
48
|
+
if (await writeTextToClipboard(value)) {
|
|
49
49
|
copied = true
|
|
50
|
-
setTimeout(() => {
|
|
50
|
+
window.setTimeout(() => {
|
|
51
51
|
copied = false
|
|
52
52
|
}, 2000)
|
|
53
53
|
}
|
|
54
|
-
catch {
|
|
55
|
-
// 复制失败
|
|
56
|
-
}
|
|
57
54
|
}
|
|
58
55
|
</script>
|
|
59
56
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
-->
|
|
11
11
|
<script lang='ts'>
|
|
12
12
|
import type { SignatureDisplayProps } from '../types.js'
|
|
13
|
+
import { writeTextToClipboard } from '../../../internal/browser-safety.js'
|
|
13
14
|
import { uiM } from '../../../messages.js'
|
|
14
15
|
import { cn } from '../../../utils.js'
|
|
15
16
|
import { Badge, IconButton } from '../../primitives/index.js'
|
|
@@ -46,31 +47,23 @@
|
|
|
46
47
|
async function copySignature() {
|
|
47
48
|
if (!signature)
|
|
48
49
|
return
|
|
49
|
-
|
|
50
|
-
await navigator.clipboard.writeText(signature)
|
|
50
|
+
if (await writeTextToClipboard(signature)) {
|
|
51
51
|
copiedSig = true
|
|
52
|
-
setTimeout(() => {
|
|
52
|
+
window.setTimeout(() => {
|
|
53
53
|
copiedSig = false
|
|
54
54
|
}, 2000)
|
|
55
55
|
}
|
|
56
|
-
catch {
|
|
57
|
-
// 复制失败
|
|
58
|
-
}
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
async function copyPublicKey() {
|
|
62
59
|
if (!publicKey)
|
|
63
60
|
return
|
|
64
|
-
|
|
65
|
-
await navigator.clipboard.writeText(publicKey)
|
|
61
|
+
if (await writeTextToClipboard(publicKey)) {
|
|
66
62
|
copiedKey = true
|
|
67
|
-
setTimeout(() => {
|
|
63
|
+
window.setTimeout(() => {
|
|
68
64
|
copiedKey = false
|
|
69
65
|
}, 2000)
|
|
70
66
|
}
|
|
71
|
-
catch {
|
|
72
|
-
// 复制失败
|
|
73
|
-
}
|
|
74
67
|
}
|
|
75
68
|
</script>
|
|
76
69
|
|
|
@@ -42,14 +42,23 @@
|
|
|
42
42
|
),
|
|
43
43
|
)
|
|
44
44
|
|
|
45
|
+
function getSubmittedValue(formData: FormData, key: string, fallback = ''): string | undefined {
|
|
46
|
+
const submitted = formData.get(key)
|
|
47
|
+
const value = typeof submitted === 'string' ? submitted : fallback
|
|
48
|
+
const trimmed = value.trim()
|
|
49
|
+
return trimmed || undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
async function handleSubmit(e: SubmitEvent) {
|
|
46
53
|
e.preventDefault()
|
|
47
54
|
if (loading || disabled)
|
|
48
55
|
return
|
|
49
56
|
|
|
57
|
+
const submittedFormData = new FormData(e.currentTarget as HTMLFormElement)
|
|
58
|
+
|
|
50
59
|
const data: ForgotPasswordFormData = {
|
|
51
|
-
email: mode === 'email' ? email : undefined,
|
|
52
|
-
phone: mode === 'phone' ? phone : undefined,
|
|
60
|
+
email: mode === 'email' ? getSubmittedValue(submittedFormData, 'email', email) : undefined,
|
|
61
|
+
phone: mode === 'phone' ? getSubmittedValue(submittedFormData, 'phone', phone) : undefined,
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
await onsubmit?.(data)
|
|
@@ -101,6 +101,13 @@
|
|
|
101
101
|
: ''),
|
|
102
102
|
)
|
|
103
103
|
|
|
104
|
+
function getSubmittedValue(formData: FormData, key: string, fallback = ''): string | undefined {
|
|
105
|
+
const submitted = formData.get(key)
|
|
106
|
+
const value = typeof submitted === 'string' ? submitted : fallback
|
|
107
|
+
const trimmed = value.trim()
|
|
108
|
+
return trimmed || undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
104
111
|
async function handleSubmit(e: SubmitEvent) {
|
|
105
112
|
e.preventDefault()
|
|
106
113
|
if (loading || disabled)
|
|
@@ -108,13 +115,15 @@
|
|
|
108
115
|
if (!passwordsMatch)
|
|
109
116
|
return
|
|
110
117
|
|
|
118
|
+
const submittedFormData = new FormData(e.currentTarget as HTMLFormElement)
|
|
119
|
+
|
|
111
120
|
const data: RegisterFormData = {
|
|
112
|
-
username,
|
|
113
|
-
email,
|
|
114
|
-
phone,
|
|
121
|
+
username: getSubmittedValue(submittedFormData, 'username', username),
|
|
122
|
+
email: getSubmittedValue(submittedFormData, 'email', email),
|
|
123
|
+
phone: getSubmittedValue(submittedFormData, 'phone', phone),
|
|
115
124
|
password,
|
|
116
125
|
confirmPassword: requireConfirmPassword ? confirmPassword : undefined,
|
|
117
|
-
nickname,
|
|
126
|
+
nickname: getSubmittedValue(submittedFormData, 'nickname', nickname),
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
await onsubmit?.(data)
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
import { cn } from '../../../utils.js'
|
|
14
14
|
import BareInput from '../../primitives/BareInput.svelte'
|
|
15
15
|
|
|
16
|
+
const SAFE_HTTP_URL_REGEX = /^https?:\/\//i
|
|
17
|
+
const SAFE_RELATIVE_URL_REGEX = /^(?:\/(?!\/)|\.\.?\/)/
|
|
18
|
+
|
|
16
19
|
let {
|
|
17
20
|
value = $bindable(''),
|
|
18
21
|
size = 'lg',
|
|
@@ -30,6 +33,7 @@
|
|
|
30
33
|
|
|
31
34
|
let loading = $state(false)
|
|
32
35
|
let inputElement = $state<HTMLInputElement | undefined>(undefined)
|
|
36
|
+
let localPreviewUrl: string | null = $state(null)
|
|
33
37
|
|
|
34
38
|
const sizeClass = $derived({
|
|
35
39
|
'xs': 'w-8 h-8',
|
|
@@ -75,6 +79,33 @@
|
|
|
75
79
|
return null
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
function isSafePreviewUrl(url: string): boolean {
|
|
83
|
+
return SAFE_HTTP_URL_REGEX.test(url) || SAFE_RELATIVE_URL_REGEX.test(url)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function replaceAvatarValue(nextValue: string): void {
|
|
87
|
+
if (localPreviewUrl && localPreviewUrl !== nextValue) {
|
|
88
|
+
URL.revokeObjectURL(localPreviewUrl)
|
|
89
|
+
localPreviewUrl = null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (nextValue.startsWith('blob:')) {
|
|
93
|
+
localPreviewUrl = nextValue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
value = nextValue
|
|
97
|
+
onchange?.(nextValue)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
$effect(() => {
|
|
101
|
+
return () => {
|
|
102
|
+
if (localPreviewUrl) {
|
|
103
|
+
URL.revokeObjectURL(localPreviewUrl)
|
|
104
|
+
localPreviewUrl = null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
78
109
|
// 上传文件
|
|
79
110
|
async function uploadFile(file: File) {
|
|
80
111
|
loading = true
|
|
@@ -82,8 +113,7 @@
|
|
|
82
113
|
try {
|
|
83
114
|
if (!uploadUrl && !presignUrl) {
|
|
84
115
|
// 没有上传地址,创建本地预览
|
|
85
|
-
|
|
86
|
-
onchange?.(value)
|
|
116
|
+
replaceAvatarValue(URL.createObjectURL(file))
|
|
87
117
|
return
|
|
88
118
|
}
|
|
89
119
|
|
|
@@ -109,6 +139,9 @@
|
|
|
109
139
|
}
|
|
110
140
|
|
|
111
141
|
const data = await presignResponse.json()
|
|
142
|
+
if (typeof data?.url !== 'string' || !SAFE_HTTP_URL_REGEX.test(data.url)) {
|
|
143
|
+
throw new Error(uiM('avatar_upload_get_url_failed'))
|
|
144
|
+
}
|
|
112
145
|
targetUrl = data.url
|
|
113
146
|
}
|
|
114
147
|
|
|
@@ -128,19 +161,31 @@
|
|
|
128
161
|
|
|
129
162
|
// 获取最终 URL
|
|
130
163
|
let finalUrl = targetUrl!.split('?')[0]
|
|
164
|
+
let responseUrl: string | undefined
|
|
131
165
|
|
|
132
166
|
try {
|
|
133
|
-
const data = await response.json()
|
|
134
|
-
if (data
|
|
135
|
-
|
|
167
|
+
const data = await response.clone().json()
|
|
168
|
+
if (typeof data?.url === 'string') {
|
|
169
|
+
responseUrl = data.url
|
|
136
170
|
}
|
|
137
171
|
}
|
|
138
172
|
catch {
|
|
139
173
|
// 响应不是 JSON
|
|
140
174
|
}
|
|
141
175
|
|
|
142
|
-
|
|
143
|
-
|
|
176
|
+
if (responseUrl) {
|
|
177
|
+
if (!isSafePreviewUrl(responseUrl)) {
|
|
178
|
+
throw new Error(uiM('avatar_upload_failed'))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
finalUrl = responseUrl
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!isSafePreviewUrl(finalUrl)) {
|
|
185
|
+
throw new Error(uiM('avatar_upload_failed'))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
replaceAvatarValue(finalUrl)
|
|
144
189
|
}
|
|
145
190
|
catch (error) {
|
|
146
191
|
const message = error instanceof Error ? error.message : uiM('avatar_upload_failed')
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
-->
|
|
11
11
|
<script lang='ts'>
|
|
12
12
|
import type { FileUploadProps, UploadFile } from '../types.js'
|
|
13
|
+
import { SvelteMap } from 'svelte/reactivity'
|
|
13
14
|
import { uiM } from '../../../messages.js'
|
|
14
15
|
import { cn, generateId } from '../../../utils.js'
|
|
15
16
|
import BareInput from '../../primitives/BareInput.svelte'
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
import Progress from '../../primitives/Progress.svelte'
|
|
18
19
|
|
|
19
20
|
const SAFE_HTTP_URL_REGEX = /^https?:\/\//i
|
|
21
|
+
const MAX_PARALLEL_UPLOADS = 3
|
|
20
22
|
|
|
21
23
|
const {
|
|
22
24
|
accept = '*',
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
let files = $state<UploadFile[]>([])
|
|
41
43
|
let isDragging = $state(false)
|
|
42
44
|
let inputElement = $state<HTMLInputElement | undefined>(undefined)
|
|
45
|
+
const activeRequests = new SvelteMap<string, XMLHttpRequest>()
|
|
43
46
|
|
|
44
47
|
const containerClass = $derived(
|
|
45
48
|
cn(
|
|
@@ -95,6 +98,63 @@
|
|
|
95
98
|
return null
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
function registerActiveRequest(fileId: string, xhr: XMLHttpRequest): void {
|
|
102
|
+
activeRequests.set(fileId, xhr)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clearActiveRequest(fileId: string, xhr?: XMLHttpRequest): void {
|
|
106
|
+
const current = activeRequests.get(fileId)
|
|
107
|
+
if (!current || (xhr && current !== xhr)) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
current.upload.onprogress = null
|
|
112
|
+
current.onload = null
|
|
113
|
+
current.onerror = null
|
|
114
|
+
current.onabort = null
|
|
115
|
+
activeRequests.delete(fileId)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function abortActiveRequest(fileId: string): void {
|
|
119
|
+
const current = activeRequests.get(fileId)
|
|
120
|
+
if (!current) {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
clearActiveRequest(fileId, current)
|
|
125
|
+
current.abort()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function abortAllActiveRequests(): void {
|
|
129
|
+
for (const fileId of [...activeRequests.keys()]) {
|
|
130
|
+
abortActiveRequest(fileId)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function uploadPendingFiles(targetFiles: UploadFile[]): Promise<void> {
|
|
135
|
+
const queue = [...targetFiles]
|
|
136
|
+
const workerCount = Math.min(MAX_PARALLEL_UPLOADS, queue.length)
|
|
137
|
+
|
|
138
|
+
await Promise.all(
|
|
139
|
+
Array.from({ length: workerCount }, async () => {
|
|
140
|
+
while (queue.length > 0) {
|
|
141
|
+
const nextFile = queue.shift()
|
|
142
|
+
if (!nextFile) {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await uploadFile(nextFile)
|
|
147
|
+
}
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
$effect(() => {
|
|
153
|
+
return () => {
|
|
154
|
+
abortAllActiveRequests()
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
98
158
|
// 添加文件
|
|
99
159
|
async function addFiles(fileList: FileList | null) {
|
|
100
160
|
if (!fileList)
|
|
@@ -129,9 +189,7 @@
|
|
|
129
189
|
|
|
130
190
|
// 自动上传
|
|
131
191
|
if (autoUpload) {
|
|
132
|
-
|
|
133
|
-
await uploadFile(file)
|
|
134
|
-
}
|
|
192
|
+
await uploadPendingFiles(newFiles.filter(f => f.state === 'pending'))
|
|
135
193
|
}
|
|
136
194
|
}
|
|
137
195
|
|
|
@@ -146,6 +204,8 @@
|
|
|
146
204
|
uploadFile.state = 'uploading'
|
|
147
205
|
files = [...files]
|
|
148
206
|
|
|
207
|
+
let xhr: XMLHttpRequest | undefined
|
|
208
|
+
|
|
149
209
|
try {
|
|
150
210
|
let targetUrl = uploadUrl
|
|
151
211
|
|
|
@@ -176,52 +236,62 @@
|
|
|
176
236
|
}
|
|
177
237
|
|
|
178
238
|
// 上传文件
|
|
179
|
-
|
|
239
|
+
xhr = new XMLHttpRequest()
|
|
240
|
+
registerActiveRequest(uploadFile.id, xhr)
|
|
241
|
+
const activeXhr = xhr
|
|
180
242
|
|
|
181
243
|
await new Promise<void>((resolve, reject) => {
|
|
182
|
-
|
|
244
|
+
activeXhr.upload.onprogress = (e) => {
|
|
183
245
|
if (e.lengthComputable) {
|
|
184
246
|
uploadFile.progress = Math.round((e.loaded / e.total) * 100)
|
|
185
247
|
files = [...files]
|
|
186
248
|
}
|
|
187
249
|
}
|
|
188
250
|
|
|
189
|
-
|
|
190
|
-
if (
|
|
251
|
+
activeXhr.onload = () => {
|
|
252
|
+
if (activeXhr.status >= 200 && activeXhr.status < 300) {
|
|
191
253
|
uploadFile.state = 'success'
|
|
192
254
|
uploadFile.progress = 100
|
|
193
255
|
try {
|
|
194
|
-
uploadFile.response = JSON.parse(
|
|
256
|
+
uploadFile.response = JSON.parse(activeXhr.responseText)
|
|
195
257
|
}
|
|
196
258
|
catch {
|
|
197
|
-
uploadFile.response =
|
|
259
|
+
uploadFile.response = activeXhr.responseText
|
|
198
260
|
}
|
|
199
261
|
resolve()
|
|
200
262
|
}
|
|
201
263
|
else {
|
|
202
|
-
reject(new Error(`${uiM('file_upload_failed')}: ${
|
|
264
|
+
reject(new Error(`${uiM('file_upload_failed')}: ${activeXhr.statusText}`))
|
|
203
265
|
}
|
|
204
266
|
}
|
|
205
267
|
|
|
206
|
-
|
|
268
|
+
activeXhr.onerror = () => reject(new Error(uiM('file_upload_network_error')))
|
|
269
|
+
activeXhr.onabort = () => reject(new DOMException('Upload aborted', 'AbortError'))
|
|
207
270
|
|
|
208
|
-
|
|
209
|
-
|
|
271
|
+
activeXhr.open('PUT', targetUrl!)
|
|
272
|
+
activeXhr.setRequestHeader('Content-Type', uploadFile.type || 'application/octet-stream')
|
|
210
273
|
|
|
211
274
|
for (const [key, value] of Object.entries(headers)) {
|
|
212
|
-
|
|
275
|
+
activeXhr.setRequestHeader(key, value)
|
|
213
276
|
}
|
|
214
277
|
|
|
215
|
-
|
|
278
|
+
activeXhr.send(uploadFile.file)
|
|
216
279
|
})
|
|
217
280
|
|
|
218
281
|
onupload?.(uploadFile)
|
|
219
282
|
}
|
|
220
283
|
catch (error) {
|
|
284
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
221
288
|
uploadFile.state = 'error'
|
|
222
289
|
uploadFile.error = error instanceof Error ? error.message : uiM('file_upload_failed')
|
|
223
290
|
onerror?.(uploadFile.error)
|
|
224
291
|
}
|
|
292
|
+
finally {
|
|
293
|
+
clearActiveRequest(uploadFile.id, xhr)
|
|
294
|
+
}
|
|
225
295
|
|
|
226
296
|
files = [...files]
|
|
227
297
|
}
|
|
@@ -230,6 +300,7 @@
|
|
|
230
300
|
function removeFile(id: string) {
|
|
231
301
|
const file = files.find(f => f.id === id)
|
|
232
302
|
if (file) {
|
|
303
|
+
abortActiveRequest(id)
|
|
233
304
|
files = files.filter(f => f.id !== id)
|
|
234
305
|
onremove?.(file)
|
|
235
306
|
onchange?.(files)
|
|
@@ -279,13 +350,12 @@
|
|
|
279
350
|
|
|
280
351
|
/** 手动触发上传 */
|
|
281
352
|
export async function upload() {
|
|
282
|
-
|
|
283
|
-
await uploadFile(file)
|
|
284
|
-
}
|
|
353
|
+
await uploadPendingFiles(files.filter(f => f.state === 'pending'))
|
|
285
354
|
}
|
|
286
355
|
|
|
287
356
|
/** 清空文件列表 */
|
|
288
357
|
export function clear() {
|
|
358
|
+
abortAllActiveRequests()
|
|
289
359
|
files = []
|
|
290
360
|
onchange?.(files)
|
|
291
361
|
}
|
package/dist/i18n.svelte.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* @module i18n.svelte
|
|
6
6
|
*/
|
|
7
7
|
import { core } from '@h-ai/core';
|
|
8
|
+
import { readStoredValue, writeStoredValue } from './internal/browser-safety.js';
|
|
8
9
|
// 从 core.i18n 解构常用函数
|
|
9
10
|
const { DEFAULT_LOCALE, DEFAULT_LOCALES, setGlobalLocale, } = core.i18n;
|
|
10
11
|
function isLocaleSupported(locale, supportedLocales = DEFAULT_LOCALES) {
|
|
@@ -56,7 +57,7 @@ export function createLocaleStore(options = {}) {
|
|
|
56
57
|
let initialLocale = defaultLocale;
|
|
57
58
|
if (typeof window !== 'undefined') {
|
|
58
59
|
// 1. 优先从 localStorage 读取
|
|
59
|
-
const savedLocale =
|
|
60
|
+
const savedLocale = readStoredValue(persistKey);
|
|
60
61
|
if (savedLocale && isLocaleSupported(savedLocale, supportedLocales)) {
|
|
61
62
|
initialLocale = savedLocale;
|
|
62
63
|
}
|
|
@@ -93,7 +94,7 @@ export function createLocaleStore(options = {}) {
|
|
|
93
94
|
setGlobalLocale(resolved);
|
|
94
95
|
// 持久化到 localStorage
|
|
95
96
|
if (typeof window !== 'undefined') {
|
|
96
|
-
|
|
97
|
+
writeStoredValue(persistKey, resolved);
|
|
97
98
|
}
|
|
98
99
|
},
|
|
99
100
|
/** 检查语言是否支持 */
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @h-ai/ui — 浏览器能力安全辅助
|
|
3
|
+
*
|
|
4
|
+
* 统一封装容易在受限 WebView、隐私模式或不安全上下文中失败的浏览器 API,
|
|
5
|
+
* 让组件在能力缺失时优雅降级而不是抛出运行时错误。
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* 安全读取 localStorage。
|
|
9
|
+
*
|
|
10
|
+
* 某些浏览器隐私模式或嵌入式 WebView 会在访问 localStorage 时直接抛出 SecurityError,
|
|
11
|
+
* 这里统一吞掉异常并回退为 null。
|
|
12
|
+
*/
|
|
13
|
+
export declare function readStoredValue(key: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* 安全写入 localStorage。
|
|
16
|
+
*
|
|
17
|
+
* 返回值显式告诉调用方持久化是否成功,方便调用方在失败时继续使用内存态而不必中断交互。
|
|
18
|
+
*/
|
|
19
|
+
export declare function writeStoredValue(key: string, value: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* 安全写入剪贴板。
|
|
22
|
+
*
|
|
23
|
+
* 在不安全上下文、旧 WebView 或浏览器策略禁用时直接返回 false,
|
|
24
|
+
* 让上层决定是否展示成功反馈。
|
|
25
|
+
*/
|
|
26
|
+
export declare function writeTextToClipboard(text: string): Promise<boolean>;
|