Merge remote-tracking branch 'origin/main'

This commit is contained in:
Lan
2025-12-27 23:34:48 +08:00
7 changed files with 113 additions and 103 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ export const FILE_SIZE_LIMITS = {
// 时间相关常量
export const TIME_CONSTANTS = {
ALERT_DURATION: 5000, // 5秒
REQUEST_TIMEOUT: 30000, // 30秒
REQUEST_TIMEOUT: 300000000,
PROGRESS_UPDATE_INTERVAL: 100 // 100毫秒
} as const
+3 -3
View File
@@ -92,7 +92,7 @@ export default {
localStorage: 'Local Storage',
s3Storage: 'S3 Storage',
webdavStorage: 'Webdav Storage',
chunkUploadNote: 'Enable chunk upload (experimental feature, still in development, currently only available for local storage, may have unknown issues)',
chunkUploadNote: 'Enable chunk upload (experimental feature, may have unknown issues)',
s3AccessKeyId: 'S3 AccessKeyId',
s3SecretAccessKey: 'S3 SecretAccessKey',
s3BucketName: 'S3 BucketName',
@@ -334,7 +334,7 @@ export default {
localStorage: 'Local Storage',
s3Storage: 'S3 Storage',
webdavStorage: 'Webdav Storage',
chunkUploadNote: 'Enable chunk upload (experimental feature, still in development, currently only available for local storage, may have unknown issues)',
chunkUploadNote: 'Enable chunk upload (experimental feature, may have unknown issues)',
enabled: 'Enabled',
disabled: 'Disabled',
webdavUrl: 'Please enter Webdav URL',
@@ -401,7 +401,7 @@ export default {
localStorage: 'Local Storage',
s3Storage: 'S3 Storage',
webdavStorage: 'Webdav Storage',
chunkUploadNote: 'Enable chunk upload (experimental feature, still in development, currently only available for local storage, may have unknown issues)',
chunkUploadNote: 'Enable chunk upload (experimental feature, may have unknown issues)',
enabled: 'Enabled',
disabled: 'Disabled',
webdavUrl: 'Please enter Webdav URL',
+18 -3
View File
@@ -91,7 +91,7 @@ export default {
localStorage: '本地存储',
s3Storage: 'S3 存储',
webdavStorage: 'Webdav 存储',
chunkUploadNote: '开启切片上传(实验性功能,还在开发中,目前仅本地存储可用,可能会出现未知问题)',
chunkUploadNote: '开启切片上传(实验性功能,可能会出现未知问题)',
s3AccessKeyId: 'S3 AccessKeyId',
s3SecretAccessKey: 'S3 SecretAccessKey',
s3BucketName: 'S3 BucketName',
@@ -369,7 +369,9 @@ export default {
storagePathPlaceholder: '留空则使用默认路径,可不填写',
storageMethod: '存储方式',
localStorage: '本地存储',
chunkUploadNote: '开启切片上传(实验性功能,还在开发中,目前仅本地存储可用,可能会出现未知问题)',
s3Storage: 'S3 存储',
webdavStorage: 'Webdav 存储',
chunkUploadNote: '开启切片上传(实验性功能,可能会出现未知问题)',
uploadLimits: '上传限制',
uploadPerMinute: '每分钟上传限制',
uploadCountLimit: '上传数量限制',
@@ -382,7 +384,20 @@ export default {
count: '按次数'
},
maxSaveTime: '最长保存时间',
s3AccessKeyId: 'S3 AccessKeyId',
s3SecretAccessKey: 'S3 SecretAccessKey',
s3BucketName: 'S3 BucketName',
s3EndpointUrl: 'S3 EndpointUrl',
s3RegionName: 'S3 Region Name',
s3SignatureVersion: 'S3 Signature Version',
s3Hostname: 'S3 Hostname',
s3v2: 'S3v2',
s3v4: 'S3v4',
autoPlaceholder: 'auto',
enableProxy: '启用代理',
webdavUrlPlaceholder: '请输入 Webdav URL',
webdavUsernamePlaceholder: '请输入 Webdav Username',
webdavPasswordPlaceholder: '请输入 Webdav Password',
fileSizeUnits: {
kb: 'KB',
mb: 'MB',
@@ -457,7 +472,7 @@ export default {
localStorage: '本地存储',
s3Storage: 'S3 存储',
webdavStorage: 'Webdav 存储',
chunkUploadNote: '开启切片上传(实验性功能,还在开发中,目前仅本地存储可用,可能会出现未知问题)',
chunkUploadNote: '开启切片上传(实验性功能,可能会出现未知问题)',
enabled: '已开启',
disabled: '已关闭',
webdavUrl: '请输入 Webdav URL',
+1 -1
View File
@@ -78,7 +78,7 @@ export class FileService {
}
static async updateFile(data: FileEditForm): Promise<ApiResponse> {
return api.post('/admin/file/update', data)
return api.patch('/admin/file/update', data)
}
static async deleteAdminFile(id: number): Promise<ApiResponse> {
+62 -45
View File
@@ -556,8 +556,11 @@ const fileHash = ref('')
const handleFileSelected = (file: File) => {
const handleFileSelected = async (file: File) => {
selectedFile.value = file
if (!checkOpenUpload()) return
if (!checkFileSize(file)) return
fileHash.value = await calculateFileHash(file)
}
@@ -648,49 +651,46 @@ const handlePaste = async (event: ClipboardEvent) => {
}
const calculateFileHash = async (file: File): Promise<string> => {
return new Promise((resolve) => {
const chunkSize = 2097152 // 保持 2MB 的切片大小用于计算哈希
const fileReader = new FileReader()
let currentChunk = 0
const chunks = Math.ceil(file.size / chunkSize)
fileReader.onload = async (e) => {
const chunk = new Uint8Array(e.target!.result as ArrayBuffer)
try {
// 尝试使用 crypto.subtle.digest
if (window.isSecureContext) {
const hashBuffer = await crypto.subtle.digest('SHA-256', chunk)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(hashHex)
}
} else {
// 如果不是安全上下文(HTTP),则返回一个基于文件信息的替代哈希
const fallbackHash = generateFallbackHash(file)
resolve(fallbackHash)
}
} catch (err) {
// 如果 crypto.subtle.digest 失败,使用替代方案
const fallbackHash = generateFallbackHash(file)
console.error('File hash calculation failed:', err)
resolve(fallbackHash)
try {
// 对于小文件,直接计算整个文件的哈希
if (file.size <= 10 * 1024 * 1024) {
const buffer = await file.arrayBuffer()
if (window.isSecureContext) {
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
}
return generateFallbackHash(file)
}
const loadNext = () => {
const start = currentChunk * chunkSize
const end = start + chunkSize >= file.size ? file.size : start + chunkSize
fileReader.readAsArrayBuffer(file.slice(start, end))
}
// 对于大文件,取首尾各5MB计算哈希
const chunkSize = 5 * 1024 * 1024
const firstChunk = file.slice(0, chunkSize)
const lastChunk = file.slice(-chunkSize)
loadNext()
})
const [firstBuffer, lastBuffer] = await Promise.all([
firstChunk.arrayBuffer(),
lastChunk.arrayBuffer()
])
// 合并首尾数据
const combined = new Uint8Array(firstBuffer.byteLength + lastBuffer.byteLength + 16)
combined.set(new Uint8Array(firstBuffer), 0)
combined.set(new Uint8Array(lastBuffer), firstBuffer.byteLength)
// 添加文件大小信息
const sizeBytes = new TextEncoder().encode(file.size.toString())
combined.set(sizeBytes, firstBuffer.byteLength + lastBuffer.byteLength)
if (window.isSecureContext) {
const hashBuffer = await crypto.subtle.digest('SHA-256', combined)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
}
return generateFallbackHash(file)
} catch (err) {
console.error('File hash calculation failed:', err)
return generateFallbackHash(file)
}
}
// 生成替代哈希的函数
@@ -777,6 +777,11 @@ const getExpirationTime = (method: string, value: string) => {
const handleChunkUpload = async (file: File) => {
try {
// 确保文件哈希已计算
// 每次上传都重新计算哈希,确保哈希值正确
const hash = await calculateFileHash(file)
fileHash.value = hash
console.log('Calculated file hash:', hash)
// 默认切片大小为5MB
const chunkSize = 5 * 1024 * 1024
const chunks = Math.ceil(file.size / chunkSize)
@@ -786,6 +791,7 @@ const handleChunkUpload = async (file: File) => {
name?: string
upload_id?: string
existed?: boolean
uploaded_chunks?: number[]
}> = await api.post('chunk/upload/init/', {
file_name: file.name,
file_size: file.size,
@@ -800,17 +806,22 @@ const handleChunkUpload = async (file: File) => {
return initResponse
}
const uploadId = initResponse.detail?.upload_id
const uploadedChunks = new Set(initResponse.detail?.uploaded_chunks || [])
// 2. 上传切片
// 2. 上传切片(跳过已上传的)
for (let i = 0; i < chunks; i++) {
// 跳过已上传的块
if (uploadedChunks.has(i)) {
continue
}
const start = i * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
const chunkFormData = new FormData()
chunkFormData.append('chunk', new Blob([chunk], { type: file.type })) // 确保以Blob形式添加
chunkFormData.append('chunk', new Blob([chunk], { type: file.type }))
// 使用 application/x-www-form-urlencoded 格式
const chunkResponse: ApiResponse<unknown> = await api.post(
`chunk/upload/chunk/${uploadId}/${i}`,
chunkFormData,
@@ -819,10 +830,16 @@ const handleChunkUpload = async (file: File) => {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent: { loaded: number; total?: number }) => {
// 计算已上传块的大小
const uploadedSize = Array.from(uploadedChunks).reduce((acc, idx) => {
const chunkEnd = Math.min((idx + 1) * chunkSize, file.size)
const chunkStart = idx * chunkSize
return acc + (chunkEnd - chunkStart)
}, 0)
const percentCompleted = Math.round(
((i * chunkSize + progressEvent.loaded) * 100) / file.size
((uploadedSize + i * chunkSize + progressEvent.loaded) * 100) / file.size
)
uploadProgress.value = percentCompleted
uploadProgress.value = Math.min(percentCompleted, 99)
}
}
)
-45
View File
@@ -405,29 +405,6 @@ const handleUpdate = async () => {
}
}
// 下载文件处理 - 暂时移除未使用的函数
// const downloadFile = async (id: number) => {
// try {
// const response = await FileService.downloadAdminFile(id)
// const contentDisposition = response.headers['content-disposition']
// let filename = 'file'
// const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
// if (filenameMatch != null && filenameMatch[1]) {
// filename = filenameMatch[1].replace(/['"]/g, '')
// }
// // @ts-expect-error - showSaveFilePicker is not in standard Window interface
// if (window.showSaveFilePicker) {
// await saveFileByWebApi(response.data, filename)
// } else {
// await saveFileByElementA(response.data, filename)
// }
// } catch (error) {
// console.error('下载失败:', error)
// }
// }
// 删除文件处理
const deleteFile = async (id: number) => {
try {
@@ -438,28 +415,6 @@ const deleteFile = async (id: number) => {
}
}
// 文件保存辅助函数 - 暂时移除未使用的函数
// async function saveFileByElementA(fileBlob: Blob, filename: string) {
// const downloadUrl = window.URL.createObjectURL(fileBlob)
// const link = document.createElement('a')
// link.href = downloadUrl
// link.download = filename
// document.body.appendChild(link)
// link.click()
// window.URL.revokeObjectURL(downloadUrl)
// document.body.removeChild(link)
// }
// async function saveFileByWebApi(fileBlob: Blob, filename: string) {
// // @ts-expect-error - showSaveFilePicker is not in standard Window interface
// const newHandle = await window.showSaveFilePicker({
// suggestedName: filename
// })
// const writableStream = await newHandle.createWritable()
// await writableStream.write(fileBlob)
// await writableStream.close()
// }
// 加载文件列表
const loadFiles = async () => {
try {
+28 -5
View File
@@ -108,9 +108,9 @@ const refreshData = async () => {
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '获取配置失败'
const errorMessage = error instanceof Error ? error.message : t('manage.systemSettings.getConfigFailed')
alertStore.showAlert(errorMessage, 'error')
console.error('获取系统配置失败:', error)
console.error('Failed to get system config:', error)
}
}
// 转换文件大小为字节
@@ -136,12 +136,12 @@ const submitSave = () => {
ConfigService.updateConfig(formData).then((res) => {
if (res.code == 200) {
alertStore.showAlert('保存成功', 'success')
alertStore.showAlert(t('manage.systemSettings.saveSuccess'), 'success')
} else {
alertStore.showAlert(res.message || '保存失败', 'error')
alertStore.showAlert(res.message || t('manage.systemSettings.saveFailed'), 'error')
}
}).catch((error) => {
const errorMessage = error instanceof Error ? error.message : '保存失败'
const errorMessage = error instanceof Error ? error.message : t('manage.systemSettings.saveFailed')
alertStore.showAlert(errorMessage, 'error')
})
}
@@ -504,6 +504,29 @@ refreshData()
</span>
</div>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium mb-2"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ t('manage.settings.chunkUploadNote') }}
</label>
<div class="flex items-center">
<button type="button" @click="config.enableChunk = config.enableChunk === 1 ? 0 : 1"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
:class="[config.enableChunk === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
:aria-checked="config.enableChunk === 1">
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="[
config.enableChunk === 1 ? 'translate-x-5' : 'translate-x-0',
isDarkMode && config.enableChunk !== 1 ? 'bg-gray-100' : 'bg-white'
]" />
</button>
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ config.enableChunk === 1 ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
</div>
</div>
</div>
</div>