From 81cee8981d0ccf180a7d85145bf71ef878434ca6 Mon Sep 17 00:00:00 2001 From: yiqiu Date: Tue, 24 Feb 2026 10:11:06 +0800 Subject: [PATCH 01/58] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=8F=96?= =?UTF-8?q?=E4=BB=B6=E7=A0=81=E8=BE=93=E5=85=A5=E6=A1=86=E7=9A=84=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=BC=8F=E6=95=B0=E6=8D=AE=E6=B5=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/RetrieveForm.vue | 16 ++++++++-------- src/views/RetrievewFileView.vue | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/common/RetrieveForm.vue b/src/components/common/RetrieveForm.vue index 24e352f..46bda72 100644 --- a/src/components/common/RetrieveForm.vue +++ b/src/components/common/RetrieveForm.vue @@ -59,7 +59,7 @@ \ No newline at end of file + diff --git a/src/components/common/ExpirationSelector.vue b/src/components/common/ExpirationSelector.vue index ec29f68..458d22f 100644 --- a/src/components/common/ExpirationSelector.vue +++ b/src/components/common/ExpirationSelector.vue @@ -77,23 +77,40 @@ :value="expirationMethod" @change="updateMethod" :class="[ - 'absolute right-0 top-0 h-full px-4 rounded-r-2xl border-l transition-all duration-300', + 'absolute right-0 top-0 h-full appearance-none cursor-pointer transition-all duration-300', 'focus:outline-none focus:ring-2 focus:ring-offset-0', - 'bg-transparent appearance-none cursor-pointer', + expirationMethod === 'forever' + ? 'w-full px-5 rounded-2xl' + : 'w-28 pl-4 pr-9 border-l rounded-r-2xl', isDarkMode - ? 'border-gray-700/60 text-gray-300 focus:ring-indigo-500/80' - : 'border-gray-200 text-gray-700 focus:ring-indigo-500/60' + ? 'text-gray-100 border-gray-700/60 focus:ring-indigo-500/80 bg-gray-800/60' + : 'text-gray-900 border-gray-200 focus:ring-indigo-500/60 bg-white' ]" + :style="{ + color: isDarkMode ? '#f3f4f6' : '#111827', + backgroundColor: isDarkMode ? 'rgba(31, 41, 55, 0.5)' : '#ffffff' + }" > - - - - - +
} interface Emits { 'update:expirationMethod': [value: string] - 'update:expirationValue': [value: number] + 'update:expirationValue': [value: string] } const props = defineProps() @@ -136,13 +157,13 @@ const updateMethod = (event: Event) => { const updateValue = (event: Event) => { const target = event.target as HTMLInputElement - emit('update:expirationValue', parseInt(target.value) || 1) + emit('update:expirationValue', target.value) } const incrementValue = (delta: number) => { - const currentValue = props.expirationValue || 1 + const currentValue = parseInt(props.expirationValue) || 0 const newValue = Math.max(1, currentValue + delta) - emit('update:expirationValue', newValue) + emit('update:expirationValue', newValue.toString()) } const getPlaceholder = () => { @@ -159,4 +180,4 @@ const getPlaceholder = () => { return t('send.expiration.placeholders.default') } } - \ No newline at end of file + diff --git a/src/components/common/FileDetailModal.vue b/src/components/common/FileDetailModal.vue index 5d5c4ce..d4b4d81 100644 --- a/src/components/common/FileDetailModal.vue +++ b/src/components/common/FileDetailModal.vue @@ -112,20 +112,12 @@ import { inject } from 'vue' import { useI18n } from 'vue-i18n' import { FileIcon, CalendarIcon, HardDriveIcon, DownloadIcon } from 'lucide-vue-next' import QRCode from 'qrcode.vue' - -interface FileRecord { - id: number - code: string - filename: string - size: string - downloadUrl: string | null - content: string | null - date: string -} +import type { ReceivedFileRecord } from '@/types' +import { buildDownloadUrl, buildReceivedRecordQrValue } from '@/utils/share-url' interface Props { visible: boolean - record: FileRecord | null + record: ReceivedFileRecord | null } interface Emits { @@ -137,25 +129,13 @@ defineProps() defineEmits() const { t } = useI18n() const isDarkMode = inject('isDarkMode') -const baseUrl = window.location.origin -const getDownloadUrl = (record: FileRecord) => { - if (record.downloadUrl) { - if (record.downloadUrl.startsWith('http')) { - return record.downloadUrl - } else { - return `${baseUrl}${record.downloadUrl}` - } - } - return '' +const getDownloadUrl = (record: ReceivedFileRecord) => { + return buildDownloadUrl(record.downloadUrl) } -const getQRCodeValue = (record: FileRecord) => { - if (record.downloadUrl) { - return `${baseUrl}${record.downloadUrl}` - } else { - return `${baseUrl}?code=${record.code}` - } +const getQRCodeValue = (record: ReceivedFileRecord) => { + return buildReceivedRecordQrValue(record) } @@ -169,4 +149,4 @@ const getQRCodeValue = (record: FileRecord) => { .fade-leave-to { opacity: 0; } - \ No newline at end of file + diff --git a/src/components/common/FileEditField.vue b/src/components/common/FileEditField.vue new file mode 100644 index 0000000..dbccf84 --- /dev/null +++ b/src/components/common/FileEditField.vue @@ -0,0 +1,75 @@ +
+ +
+ +
+ +
+
+
+ + + diff --git a/src/components/common/FileRecordList.vue b/src/components/common/FileRecordList.vue index a0abf92..300d638 100644 --- a/src/components/common/FileRecordList.vue +++ b/src/components/common/FileRecordList.vue @@ -68,24 +68,15 @@ \ No newline at end of file + diff --git a/src/components/common/SentRecordDetailModal.vue b/src/components/common/SentRecordDetailModal.vue new file mode 100644 index 0000000..178f258 --- /dev/null +++ b/src/components/common/SentRecordDetailModal.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/src/components/common/SentRecordList.vue b/src/components/common/SentRecordList.vue new file mode 100644 index 0000000..5773ced --- /dev/null +++ b/src/components/common/SentRecordList.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/components/common/SettingNumberInput.vue b/src/components/common/SettingNumberInput.vue new file mode 100644 index 0000000..a44453a --- /dev/null +++ b/src/components/common/SettingNumberInput.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/common/SettingSwitch.vue b/src/components/common/SettingSwitch.vue new file mode 100644 index 0000000..fdaaf72 --- /dev/null +++ b/src/components/common/SettingSwitch.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/components/common/StatCard.vue b/src/components/common/StatCard.vue index e86cbab..28d343f 100644 --- a/src/components/common/StatCard.vue +++ b/src/components/common/StatCard.vue @@ -21,8 +21,9 @@ \ No newline at end of file + diff --git a/src/composables/index.ts b/src/composables/index.ts new file mode 100644 index 0000000..5086761 --- /dev/null +++ b/src/composables/index.ts @@ -0,0 +1,12 @@ +export { useAdminFiles } from './useAdminFiles' +export { useAdminLogin } from './useAdminLogin' +export { useAppShell } from './useAppShell' +export { useDashboardStats } from './useDashboardStats' +export { useInjectedDarkMode } from './useInjectedDarkMode' +export { usePresignedUpload } from './usePresignedUpload' +export { usePublicConfigBootstrap } from './usePublicConfigBootstrap' +export { useRetrieveFlow } from './useRetrieveFlow' +export { useRouteLoading } from './useRouteLoading' +export { useSendFlow } from './useSendFlow' +export { useSystemConfig } from './useSystemConfig' +export { useTheme } from './useTheme' diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts new file mode 100644 index 0000000..936441a --- /dev/null +++ b/src/composables/useAdminFiles.ts @@ -0,0 +1,163 @@ +import { computed, ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { FileService } from '@/services' +import { useAlertStore } from '@/stores/alertStore' +import type { AdminFileViewItem, FileEditForm, FileListItem } from '@/types' +import { copyToClipboard } from '@/utils/clipboard' +import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common' + +const TEXT_PREVIEW_THRESHOLD = 30 + +export function useAdminFiles() { + const { t } = useI18n() + const alertStore = useAlertStore() + + const tableData = ref([]) + const hasLoadError = ref(false) + const params = ref({ + page: 1, + size: 10, + total: 0, + keyword: '' + }) + + const showEditModal = ref(false) + const editForm = ref({ + id: null, + code: '', + prefix: '', + suffix: '', + expired_at: '', + expired_count: null + }) + + const showTextPreview = ref(false) + const previewText = ref('') + const totalPages = computed(() => Math.ceil(params.value.total / params.value.size)) + + const createFileViewItem = (file: FileListItem): AdminFileViewItem => ({ + ...file, + displaySize: formatFileSize(file.size), + displayExpiredAt: file.expired_at + ? formatTimestamp(file.expired_at) + : t('send.expiration.units.forever'), + canPreviewText: Boolean(file.text && file.text.length > TEXT_PREVIEW_THRESHOLD) + }) + + const resetEditForm = () => { + editForm.value = { + id: null, + code: '', + prefix: '', + suffix: '', + expired_at: '', + expired_count: null + } + } + + const loadFiles = async () => { + try { + hasLoadError.value = false + const res = await FileService.getAdminFileList(params.value) + if (!res.detail) return + + tableData.value = res.detail.data.map(createFileViewItem) + params.value.total = res.detail.total + } catch (error) { + hasLoadError.value = true + alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.loadFileListFailed')), 'error') + } + } + + const handleSearch = async () => { + params.value.page = 1 + await loadFiles() + } + + const handlePageChange = async (page: number | string) => { + if (typeof page === 'string') return + if (page < 1 || page > totalPages.value) return + + params.value.page = page + await loadFiles() + } + + const openEditModal = (file: FileListItem) => { + editForm.value = { + id: file.id, + code: file.code, + prefix: file.prefix, + suffix: file.suffix, + expired_at: file.expired_at ? file.expired_at.slice(0, 16) : '', + expired_count: file.expired_count + } + showEditModal.value = true + } + + const closeEditModal = () => { + showEditModal.value = false + resetEditForm() + } + + const handleUpdate = async () => { + try { + await FileService.updateFile(editForm.value) + await loadFiles() + closeEditModal() + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.updateFailed')), 'error') + } + } + + const deleteFile = async (id: number) => { + if (!window.confirm(t('manage.fileManage.deleteConfirm'))) { + return + } + + try { + await FileService.deleteAdminFile(id) + await loadFiles() + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error') + } + } + + const openTextPreview = (text: string) => { + previewText.value = text + showTextPreview.value = true + } + + const closeTextPreview = () => { + showTextPreview.value = false + previewText.value = '' + } + + const copyText = async () => { + await copyToClipboard(previewText.value, { + successMsg: t('fileManage.copySuccess'), + errorMsg: t('fileManage.copyFailed'), + notify: (message, type) => alertStore.showAlert(message, type) + }) + } + + return { + tableData, + hasLoadError, + params, + showEditModal, + editForm, + showTextPreview, + previewText, + totalPages, + closeEditModal, + closeTextPreview, + copyText, + deleteFile, + handlePageChange, + handleSearch, + handleUpdate, + loadFiles, + openEditModal, + openTextPreview + } +} diff --git a/src/composables/useAdminLogin.ts b/src/composables/useAdminLogin.ts new file mode 100644 index 0000000..7c81627 --- /dev/null +++ b/src/composables/useAdminLogin.ts @@ -0,0 +1,53 @@ +import { ref } from 'vue' +import { AuthService } from '@/services' +import { useAdminStore } from '@/stores/adminStore' +import { useAlertStore } from '@/stores/alertStore' +import { getErrorMessage } from '@/utils/common' + +export function useAdminLogin() { + const alertStore = useAlertStore() + const adminStore = useAdminStore() + const password = ref('') + const isLoading = ref(false) + + const validateForm = () => { + if (!password.value) { + alertStore.showAlert('无效的密码', 'error') + return false + } + + if (password.value.length < 6) { + alertStore.showAlert('密码长度至少为6位', 'error') + return false + } + + return true + } + + const handleSubmit = async () => { + if (!validateForm()) return false + + isLoading.value = true + try { + const response = await AuthService.login(password.value) + if (!response.detail?.token) { + alertStore.showAlert('登录失败:未获取到有效令牌', 'error') + return false + } + + adminStore.setToken(response.detail.token) + return true + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, '登录失败'), 'error') + return false + } finally { + isLoading.value = false + } + } + + return { + password, + isLoading, + handleSubmit + } +} diff --git a/src/composables/useAppShell.ts b/src/composables/useAppShell.ts new file mode 100644 index 0000000..dee134a --- /dev/null +++ b/src/composables/useAppShell.ts @@ -0,0 +1,52 @@ +import { computed, onMounted, onUnmounted, provide } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { AUTH_EVENTS } from '@/services' +import { ROUTES } from '@/constants' +import { useTheme } from './useTheme' +import { usePublicConfigBootstrap } from './usePublicConfigBootstrap' +import { useRouteLoading } from './useRouteLoading' + +export function useAppShell() { + const route = useRoute() + const router = useRouter() + const { isDarkMode, toggleTheme, initTheme } = useTheme() + const { isLoading, setupRouteLoading } = useRouteLoading(router) + const { syncPublicConfig } = usePublicConfigBootstrap() + const showGlobalControls = computed(() => route.meta.showGlobalControls !== false) + + let cleanupThemeListener: (() => void) | null = null + + const handleUnauthorized = () => { + if (router.currentRoute.value.path !== ROUTES.LOGIN) { + void router.push({ + path: ROUTES.LOGIN, + query: { + redirect: router.currentRoute.value.fullPath + } + }) + } + } + + onMounted(() => { + cleanupThemeListener = initTheme() + setupRouteLoading() + window.addEventListener(AUTH_EVENTS.UNAUTHORIZED, handleUnauthorized) + void syncPublicConfig() + }) + + onUnmounted(() => { + cleanupThemeListener?.() + window.removeEventListener(AUTH_EVENTS.UNAUTHORIZED, handleUnauthorized) + }) + + provide('isDarkMode', isDarkMode) + provide('toggleTheme', toggleTheme) + provide('isLoading', isLoading) + + return { + isDarkMode, + isLoading, + route, + showGlobalControls + } +} diff --git a/src/composables/useDashboardStats.ts b/src/composables/useDashboardStats.ts new file mode 100644 index 0000000..452fc92 --- /dev/null +++ b/src/composables/useDashboardStats.ts @@ -0,0 +1,108 @@ +import { reactive } from 'vue' +import { StatsService } from '@/services' +import type { DashboardViewData } from '@/types' +import { formatFileSize } from '@/utils/common' + +const emptyDashboardData = (): DashboardViewData => ({ + hasExtendedStats: false, + totalFiles: 0, + storageUsed: 0, + yesterdayCount: 0, + todayCount: 0, + yesterdaySize: 0, + todaySize: 0, + sysUptime: null, + activeCount: 0, + expiredCount: 0, + textCount: 0, + fileCount: 0, + chunkedCount: 0, + usedCount: 0, + storageBackend: '-', + uploadSizeLimit: 0, + openUpload: 0, + enableChunk: 0, + maxSaveSeconds: 0, + topSuffixes: [], + recentFiles: [], + storageUsedText: '0 Bytes', + yesterdaySizeText: '0 Bytes', + todaySizeText: '0 Bytes', + uploadSizeLimitText: '0 Bytes', + sysUptimeText: '-', + activeRatio: 0, + textRatio: 0, + fileRatio: 0, + todaySizeRatio: 0 +}) + +const toNumber = (value: number | string | null | undefined) => Number(value || 0) + +const clampRatio = (value: number) => Math.max(0, Math.min(100, Math.round(value))) + +const hasOwn = (target: object, key: string) => Object.prototype.hasOwnProperty.call(target, key) + +const formatDuration = (startTimestamp: number | null) => { + if (!startTimestamp) return '-' + const uptime = Date.now() - startTimestamp + const days = Math.floor(uptime / (24 * 60 * 60 * 1000)) + const hours = Math.floor((uptime % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)) + return `${days}天${hours}小时` +} + +export function useDashboardStats() { + const dashboardData = reactive(emptyDashboardData()) + + const fetchDashboardData = async () => { + const response = await StatsService.getDashboard() + if (!response.detail) return + + const detail = response.detail + dashboardData.totalFiles = toNumber(detail.totalFiles) + dashboardData.storageUsed = toNumber(detail.storageUsed) + dashboardData.yesterdayCount = toNumber(detail.yesterdayCount) + dashboardData.todayCount = toNumber(detail.todayCount) + dashboardData.yesterdaySize = toNumber(detail.yesterdaySize) + dashboardData.todaySize = toNumber(detail.todaySize) + dashboardData.sysUptime = detail.sysUptime + dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount') + dashboardData.activeCount = dashboardData.hasExtendedStats + ? toNumber(detail.activeCount) + : dashboardData.totalFiles + dashboardData.expiredCount = toNumber(detail.expiredCount) + dashboardData.textCount = toNumber(detail.textCount) + dashboardData.fileCount = toNumber(detail.fileCount) + dashboardData.chunkedCount = toNumber(detail.chunkedCount) + dashboardData.usedCount = toNumber(detail.usedCount) + dashboardData.storageBackend = detail.storageBackend || '-' + dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit) + dashboardData.openUpload = toNumber(detail.openUpload) + dashboardData.enableChunk = toNumber(detail.enableChunk) + dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds) + dashboardData.topSuffixes = detail.topSuffixes || [] + dashboardData.recentFiles = detail.recentFiles || [] + + dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed) + dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize) + dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize) + dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit) + dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime) + dashboardData.activeRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.textRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.fileRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit + ? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100) + : 0 + } + + return { + dashboardData, + fetchDashboardData + } +} diff --git a/src/composables/useFileDownload.ts b/src/composables/useFileDownload.ts deleted file mode 100644 index b578fb2..0000000 --- a/src/composables/useFileDownload.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { ref, computed } from 'vue' -import { FileService } from '@/services' -import { useAlertStore } from '@/stores/alertStore' -import type { FileInfo } from '@/types' -import { saveAs } from 'file-saver' - -export function useFileDownload() { - const alertStore = useAlertStore() - - // 状态管理 - const isLoading = ref(false) - const fileInfo = ref(null) - const downloadCode = ref('') - - // 计算属性 - const hasFileInfo = computed(() => fileInfo.value !== null) - const canDownload = computed(() => hasFileInfo.value && !isLoading.value) - - // 获取文件信息 - const getFileInfo = async (code: string): Promise => { - if (!code.trim()) { - alertStore.showAlert('请输入取件码', 'warning') - return null - } - - try { - isLoading.value = true - downloadCode.value = code - - const response = await FileService.getFile(code) - - if (response.code === 200 && response.detail) { - fileInfo.value = response.detail - return response.detail - } else { - throw new Error(response.message || '文件不存在或已过期') - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '获取文件信息失败' - alertStore.showAlert(errorMessage, 'error') - fileInfo.value = null - return null - } finally { - isLoading.value = false - } - } - - // 下载文件 - const downloadFile = async (code?: string): Promise => { - const targetCode = code || downloadCode.value - - if (!targetCode.trim()) { - alertStore.showAlert('请输入取件码', 'warning') - return false - } - - try { - isLoading.value = true - - // 如果没有文件信息,先获取 - if (!fileInfo.value || downloadCode.value !== targetCode) { - const info = await getFileInfo(targetCode) - if (!info) { - return false - } - } - - const blob = await FileService.downloadFile(targetCode) - - // 使用 file-saver 保存文件 - if (fileInfo.value?.name) { - saveAs(blob, fileInfo.value.name) - alertStore.showAlert('文件下载成功!', 'success') - return true - } else { - throw new Error('文件名获取失败') - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '下载失败' - alertStore.showAlert(errorMessage, 'error') - return false - } finally { - isLoading.value = false - } - } - - // 重置状态 - const resetDownload = () => { - isLoading.value = false - fileInfo.value = null - downloadCode.value = '' - } - - // 格式化文件大小 - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] - } - - // 格式化时间 - const formatTime = (timeString: string): string => { - try { - const date = new Date(timeString) - return date.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }) - } catch { - return timeString - } - } - - // 检查文件是否过期 - const isFileExpired = computed(() => { - if (!fileInfo.value?.expireTime) return false - return new Date(fileInfo.value.expireTime) < new Date() - }) - - return { - // 状态 - isLoading, - fileInfo, - downloadCode, - - // 计算属性 - hasFileInfo, - canDownload, - isFileExpired, - - // 方法 - getFileInfo, - downloadFile, - resetDownload, - formatFileSize, - formatTime - } -} \ No newline at end of file diff --git a/src/composables/useFileUpload.ts b/src/composables/useFileUpload.ts deleted file mode 100644 index 3e507ed..0000000 --- a/src/composables/useFileUpload.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { ref, computed, readonly } from 'vue' -import { FileService } from '@/services' -import { useAlertStore } from '@/stores/alertStore' -import { usePresignedUpload } from '@/composables/usePresignedUpload' -import type { UploadProgress, UploadStatus, PresignUploadOptions, ExpireStyle, ConfigState } from '@/types' -import { UPLOAD_STATUS, FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants' - -/** - * 获取最大文件大小限制(字节) - * 优先从后端配置获取,否则使用默认值 - */ -function getMaxFileSize(): number { - try { - const configStr = localStorage.getItem(STORAGE_KEYS.CONFIG) - if (configStr) { - const config = JSON.parse(configStr) as Partial - if (config.uploadSize && config.uploadSize > 0) { - // uploadSize 单位是字节 - return config.uploadSize - } - } - } catch { - // 解析失败时使用默认值 - } - return FILE_SIZE_LIMITS.MAX_FILE_SIZE -} - -export interface FileUploadOptions { - /** 是否使用预签名上传,默认 false 保持向后兼容 */ - usePresigned?: boolean - /** 过期时间值 */ - expireValue?: number - /** 过期时间类型 */ - expireStyle?: ExpireStyle - /** 进度回调 */ - onProgress?: (progress: UploadProgress) => void -} - -export function useFileUpload(options?: { defaultUsePresigned?: boolean }) { - const alertStore = useAlertStore() - - // 预签名上传 composable - const presignedUpload = usePresignedUpload() - - // 是否默认使用预签名上传 - const defaultUsePresigned = options?.defaultUsePresigned ?? false - - // 状态管理 - const uploadStatus = ref(UPLOAD_STATUS.IDLE) - const uploadProgress = ref({ - loaded: 0, - total: 0, - percentage: 0 - }) - const uploadedCode = ref('') - const currentFile = ref(null) - - // 当前是否使用预签名上传 - const isUsingPresigned = ref(false) - - // 计算属性 - const isUploading = computed(() => { - if (isUsingPresigned.value) { - return presignedUpload.isUploading.value || presignedUpload.isInitializing.value || presignedUpload.isConfirming.value - } - return uploadStatus.value === UPLOAD_STATUS.UPLOADING - }) - const isSuccess = computed(() => { - if (isUsingPresigned.value) { - return presignedUpload.isSuccess.value - } - return uploadStatus.value === UPLOAD_STATUS.SUCCESS - }) - const isError = computed(() => { - if (isUsingPresigned.value) { - return presignedUpload.isError.value - } - return uploadStatus.value === UPLOAD_STATUS.ERROR - }) - const isIdle = computed(() => { - if (isUsingPresigned.value) { - return presignedUpload.presignStatus.value === 'idle' - } - return uploadStatus.value === UPLOAD_STATUS.IDLE - }) - - // 文件验证 - const validateFile = (file: File): boolean => { - const maxFileSize = getMaxFileSize() - if (file.size > maxFileSize) { - alertStore.showAlert( - `文件大小不能超过 ${Math.round(maxFileSize / 1024 / 1024)}MB`, - 'error' - ) - return false - } - return true - } - - /** - * 上传文件(支持预签名上传和传统上传) - */ - const uploadFile = async (file: File, uploadOptions?: FileUploadOptions): Promise => { - const shouldUsePresigned = uploadOptions?.usePresigned ?? defaultUsePresigned - - if (!validateFile(file)) { - return null - } - - // 记录当前使用的上传方式 - isUsingPresigned.value = shouldUsePresigned - currentFile.value = file - - if (shouldUsePresigned) { - // 使用预签名上传 - return await uploadFileWithPresigned(file, uploadOptions) - } else { - // 使用传统上传方式 - return await uploadFileTraditional(file, uploadOptions?.onProgress) - } - } - - /** - * 传统上传方式 - */ - const uploadFileTraditional = async ( - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise => { - try { - uploadStatus.value = UPLOAD_STATUS.UPLOADING - uploadedCode.value = '' - - const response = await FileService.uploadFile(file, (progress) => { - uploadProgress.value = progress - onProgress?.(progress) - }) - - if (response.code === 200 && response.detail?.code) { - uploadStatus.value = UPLOAD_STATUS.SUCCESS - uploadedCode.value = String(response.detail.code) - alertStore.showAlert('文件上传成功!', 'success') - return String(response.detail.code) - } else { - throw new Error(response.message || '上传失败') - } - } catch (error) { - uploadStatus.value = UPLOAD_STATUS.ERROR - const errorMessage = error instanceof Error ? error.message : '上传失败' - alertStore.showAlert(errorMessage, 'error') - return null - } - } - - /** - * 预签名上传方式 - */ - const uploadFileWithPresigned = async ( - file: File, - uploadOptions?: FileUploadOptions - ): Promise => { - // 构建预签名上传选项 - const presignOptions: PresignUploadOptions = { - expireValue: uploadOptions?.expireValue, - expireStyle: uploadOptions?.expireStyle, - onProgress: (progress) => { - // 同步进度到本 composable 的状态 - uploadProgress.value = progress - uploadOptions?.onProgress?.(progress) - } - } - - const result = await presignedUpload.uploadFile(file, presignOptions) - - if (result) { - // 同步预签名上传的结果到本 composable 的状态 - uploadedCode.value = result - uploadStatus.value = UPLOAD_STATUS.SUCCESS - } else { - uploadStatus.value = UPLOAD_STATUS.ERROR - } - - return result - } - - // 上传文本 - const uploadText = async (text: string): Promise => { - if (!text.trim()) { - alertStore.showAlert('请输入要发送的文本内容', 'warning') - return null - } - - try { - uploadStatus.value = UPLOAD_STATUS.UPLOADING - uploadedCode.value = '' - - const response = await FileService.uploadText(text) - - if (response.code === 200 && response.detail?.code) { - uploadStatus.value = UPLOAD_STATUS.SUCCESS - uploadedCode.value = String(response.detail.code) - alertStore.showAlert('文本发送成功!', 'success') - return String(response.detail.code) - } else { - throw new Error(response.message || '发送失败') - } - } catch (error) { - uploadStatus.value = UPLOAD_STATUS.ERROR - const errorMessage = error instanceof Error ? error.message : '发送失败' - alertStore.showAlert(errorMessage, 'error') - return null - } - } - - // 重置状态 - const resetUpload = () => { - uploadStatus.value = UPLOAD_STATUS.IDLE - uploadProgress.value = { - loaded: 0, - total: 0, - percentage: 0 - } - uploadedCode.value = '' - currentFile.value = null - - // 如果使用预签名上传,也重置预签名状态 - if (isUsingPresigned.value) { - presignedUpload.reset() - } - isUsingPresigned.value = false - } - - /** - * 取消上传 - */ - const cancelUpload = async (): Promise => { - if (isUsingPresigned.value) { - await presignedUpload.cancelUpload() - } - resetUpload() - } - - // 格式化文件大小 - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] - } - - return { - // 状态 - uploadStatus: readonly(uploadStatus), - uploadProgress: readonly(uploadProgress), - uploadedCode: readonly(uploadedCode), - currentFile: readonly(currentFile), - isUsingPresigned: readonly(isUsingPresigned), - - // 预签名上传相关状态(透传) - presignStatus: presignedUpload.presignStatus, - uploadSession: presignedUpload.uploadSession, - currentMode: presignedUpload.currentMode, - presignErrorMessage: presignedUpload.errorMessage, - - // 计算属性 - isUploading, - isSuccess, - isError, - isIdle, - - // 预签名上传计算属性(透传) - isInitializing: presignedUpload.isInitializing, - isConfirming: presignedUpload.isConfirming, - - // 方法 - uploadFile, - uploadText, - resetUpload, - cancelUpload, - validateFile, - formatFileSize, - - // 预签名上传方法(透传) - getPresignStatus: presignedUpload.getStatus - } -} \ No newline at end of file diff --git a/src/composables/useInjectedDarkMode.ts b/src/composables/useInjectedDarkMode.ts new file mode 100644 index 0000000..c6497b8 --- /dev/null +++ b/src/composables/useInjectedDarkMode.ts @@ -0,0 +1,7 @@ +import { computed, inject, unref } from 'vue' +import type { Ref } from 'vue' + +export function useInjectedDarkMode() { + const injectedDarkMode = inject | boolean>('isDarkMode', false) + return computed(() => Boolean(unref(injectedDarkMode))) +} diff --git a/src/composables/usePresignedUpload.ts b/src/composables/usePresignedUpload.ts index bd3e153..2ff2035 100644 --- a/src/composables/usePresignedUpload.ts +++ b/src/composables/usePresignedUpload.ts @@ -1,7 +1,5 @@ import { ref, computed, readonly } from 'vue' import { PresignUploadService } from '@/services' -import { useAlertStore } from '@/stores/alertStore' -import { FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants' import type { PresignUploadStatus, PresignUploadMode, @@ -10,29 +8,9 @@ import type { PresignStatusResponse, UploadProgress, ExpireStyle, - ConfigState + AlertType } from '@/types' -import axios from 'axios' - -/** - * 获取最大文件大小限制(字节) - * 优先从后端配置获取,否则使用默认值 - */ -function getMaxFileSize(): number { - try { - const configStr = localStorage.getItem(STORAGE_KEYS.CONFIG) - if (configStr) { - const config = JSON.parse(configStr) as Partial - if (config.uploadSize && config.uploadSize > 0) { - // uploadSize 单位是字节 - return config.uploadSize - } - } - } catch { - // 解析失败时使用默认值 - } - return FILE_SIZE_LIMITS.MAX_FILE_SIZE -} +import { getErrorMessage } from '@/utils/common' // 预签名上传状态常量 export const PRESIGN_UPLOAD_STATUS = { @@ -48,13 +26,24 @@ export const PRESIGN_UPLOAD_STATUS = { const DEFAULT_EXPIRE_VALUE = 1 const DEFAULT_EXPIRE_STYLE: ExpireStyle = 'day' +type ErrorWithResponse = { + response?: { + status?: number + } +} + +type PresignedUploadNotifier = (message: string, type: AlertType) => void + +type UsePresignedUploadOptions = { + getMaxFileSize?: () => number + notify?: PresignedUploadNotifier +} + /** * 预签名上传 Composable * 支持 S3 直传模式和服务器代理模式 */ -export function usePresignedUpload() { - const alertStore = useAlertStore() - +export function usePresignedUpload(options: UsePresignedUploadOptions = {}) { // 状态管理 const presignStatus = ref(PRESIGN_UPLOAD_STATUS.IDLE) const uploadSession = ref(null) @@ -74,15 +63,23 @@ export function usePresignedUpload() { const isError = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.ERROR) const currentMode = computed(() => uploadSession.value?.mode ?? null) + const notify: PresignedUploadNotifier = (message, type) => { + options.notify?.(message, type) + } + /** * 文件大小验证 */ const validateFileSize = (file: File): boolean => { - const maxFileSize = getMaxFileSize() + const maxFileSize = options.getMaxFileSize?.() + if (!maxFileSize) { + return true + } + if (file.size > maxFileSize) { const maxSizeMB = Math.round(maxFileSize / 1024 / 1024) errorMessage.value = `文件大小不能超过 ${maxSizeMB}MB` - alertStore.showAlert(errorMessage.value, 'error') + notify(errorMessage.value, 'error') return false } return true @@ -113,34 +110,16 @@ export function usePresignedUpload() { */ const handleUploadError = (error: unknown): void => { presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR - - if (axios.isAxiosError(error)) { - const status = error.response?.status - const detail = error.response?.data?.detail - - switch (status) { - case 400: - errorMessage.value = detail || '请求参数错误' - break - case 403: - errorMessage.value = detail || '操作被禁止' - break - case 404: - errorMessage.value = '上传会话不存在或已过期' - break - case 500: - errorMessage.value = '服务器错误,请稍后重试' - break - default: - errorMessage.value = detail || '上传失败,请重试' - } - } else if (error instanceof Error) { - errorMessage.value = error.message - } else { - errorMessage.value = '未知错误' - } - - alertStore.showAlert(errorMessage.value, 'error') + const status = (error as ErrorWithResponse)?.response?.status + const fallback = + status === 404 + ? '上传会话不存在或已过期' + : status === 500 + ? '服务器错误,请稍后重试' + : '上传失败,请重试' + + errorMessage.value = getErrorMessage(error, fallback) + notify(errorMessage.value, 'error') } /** @@ -211,7 +190,7 @@ export function usePresignedUpload() { total: file.size, percentage: 100 } - alertStore.showAlert('文件上传成功!', 'success') + notify('文件上传成功!', 'success') return uploadedCode.value } else { throw new Error(confirmResponse.message || '确认上传失败') @@ -249,7 +228,7 @@ export function usePresignedUpload() { total: file.size, percentage: 100 } - alertStore.showAlert('文件上传成功!', 'success') + notify('文件上传成功!', 'success') return uploadedCode.value } else { throw new Error(response.message || '代理上传失败') @@ -290,7 +269,7 @@ export function usePresignedUpload() { try { await PresignUploadService.cancelUpload(uploadSession.value.upload_id) - alertStore.showAlert('上传已取消', 'info') + notify('上传已取消', 'info') } catch (error) { // 取消失败时静默处理,因为会话可能已过期 console.warn('取消上传失败:', error) @@ -313,7 +292,7 @@ export function usePresignedUpload() { // 检查会话是否过期 if (response.detail.is_expired) { errorMessage.value = '上传会话已过期' - alertStore.showAlert(errorMessage.value, 'warning') + notify(errorMessage.value, 'warning') } return response.detail } diff --git a/src/composables/usePublicConfigBootstrap.ts b/src/composables/usePublicConfigBootstrap.ts new file mode 100644 index 0000000..43b5482 --- /dev/null +++ b/src/composables/usePublicConfigBootstrap.ts @@ -0,0 +1,25 @@ +import { ConfigService } from '@/services' +import { useAlertStore } from '@/stores/alertStore' +import { useConfigStore } from '@/stores/configStore' + +export function usePublicConfigBootstrap() { + const alertStore = useAlertStore() + const configStore = useConfigStore() + + const syncPublicConfig = async () => { + const res = await ConfigService.getUserConfig() + + if (res.code !== 200 || !res.detail) { + return + } + + const notifyMessage = configStore.applyRemoteConfig(res.detail) + if (notifyMessage) { + alertStore.showAlert(notifyMessage, 'success') + } + } + + return { + syncPublicConfig + } +} diff --git a/src/composables/useRetrieveFlow.ts b/src/composables/useRetrieveFlow.ts new file mode 100644 index 0000000..60dc4ef --- /dev/null +++ b/src/composables/useRetrieveFlow.ts @@ -0,0 +1,173 @@ +import { ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { storeToRefs } from 'pinia' +import { FileService } from '@/services' +import { useAlertStore } from '@/stores/alertStore' +import { useFileDataStore } from '@/stores/fileData' +import type { ReceivedFileRecord } from '@/types' +import { copyToClipboard } from '@/utils/clipboard' +import { getErrorMessage } from '@/utils/common' +import { renderMarkdownPreview } from '@/utils/content-preview' +import { downloadReceivedRecord } from '@/utils/download-action' + +type InputStatus = { + readonly: boolean + loading: boolean +} + +export function useRetrieveFlow() { + const { t } = useI18n() + const alertStore = useAlertStore() + const fileStore = useFileDataStore() + const { receiveData: records } = storeToRefs(fileStore) + + const code = ref('') + const inputStatus = ref({ + readonly: false, + loading: false + }) + const error = ref('') + const selectedRecord = ref(null) + const showDrawer = ref(false) + const showPreview = ref(false) + const renderedContent = ref('') + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 ' + t('fileSize.bytes') + const k = 1024 + const sizes = [ + t('fileSize.bytes'), + t('fileSize.kb'), + t('fileSize.mb'), + t('fileSize.gb'), + t('fileSize.tb') + ] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const createRecord = (detail: { + code: string + name: string + text: string + size: number + }): ReceivedFileRecord => { + const isFile = detail.text.startsWith('/share/download') || detail.name !== 'Text' + return { + id: Date.now(), + code: detail.code, + filename: detail.name, + size: formatFileSize(detail.size), + downloadUrl: isFile ? detail.text : null, + content: isFile ? null : detail.text, + date: new Date().toLocaleString() + } + } + + const handleSubmit = async () => { + if (code.value.length !== 5) { + alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error') + return + } + + inputStatus.value.readonly = true + inputStatus.value.loading = true + + try { + const res = await FileService.selectFile(code.value) + if (res.code === 200 && res.detail) { + const newFileData = createRecord(res.detail) + if (!fileStore.receiveData.some((file) => file.code === newFileData.code)) { + fileStore.addReceiveData(newFileData) + } + selectedRecord.value = newFileData + if (newFileData.content) { + showPreview.value = true + } + alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success') + } else { + alertStore.showAlert(t('retrieve.messages.retrieveFailure') + res.detail, 'error') + } + } catch (err: unknown) { + const errorMessage = getErrorMessage(err, t('retrieve.messages.unknownError')) + alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error') + } finally { + inputStatus.value.readonly = false + inputStatus.value.loading = false + code.value = '' + } + } + + const copyContent = async () => { + if (selectedRecord.value?.content) { + await copyToClipboard(selectedRecord.value.content, { + successMsg: t('fileRecord.contentCopied'), + errorMsg: t('fileRecord.copyFailed'), + notify: (message, type) => alertStore.showAlert(message, type) + }) + } + } + + const viewDetails = (record: ReceivedFileRecord) => { + selectedRecord.value = record + } + + const closeDetails = () => { + selectedRecord.value = null + } + + const deleteRecord = (id: number) => { + const index = records.value.findIndex((record) => record.id === id) + if (index !== -1) { + fileStore.deleteReceiveData(index) + } + } + + const toggleDrawer = () => { + showDrawer.value = !showDrawer.value + } + + const downloadRecord = (record: ReceivedFileRecord) => { + downloadReceivedRecord(record) + } + + const showContentPreview = () => { + showPreview.value = true + } + + const closeContentPreview = () => { + showPreview.value = false + } + + watch( + () => selectedRecord.value?.content, + async (content) => { + if (content) { + renderedContent.value = await renderMarkdownPreview(content) + } else { + renderedContent.value = '' + } + }, + { immediate: true } + ) + + return { + code, + inputStatus, + error, + records, + selectedRecord, + showDrawer, + showPreview, + renderedContent, + closeContentPreview, + closeDetails, + copyContent, + deleteRecord, + downloadRecord, + handleSubmit, + showContentPreview, + toggleDrawer, + viewDetails + } +} diff --git a/src/composables/useRouteLoading.ts b/src/composables/useRouteLoading.ts new file mode 100644 index 0000000..6c3592f --- /dev/null +++ b/src/composables/useRouteLoading.ts @@ -0,0 +1,44 @@ +import { onUnmounted, ref } from 'vue' +import type { Router } from 'vue-router' + +const ROUTE_LOADING_DELAY = 200 + +export function useRouteLoading(router: Router) { + const isLoading = ref(false) + let loadingTimer: number | null = null + let cleanupBeforeGuard: (() => void) | null = null + let cleanupAfterHook: (() => void) | null = null + + const clearLoadingTimer = () => { + if (loadingTimer !== null) { + window.clearTimeout(loadingTimer) + loadingTimer = null + } + } + + const setupRouteLoading = () => { + cleanupBeforeGuard = router.beforeEach((to) => { + clearLoadingTimer() + isLoading.value = to.meta.showRouteLoading !== false + }) + + cleanupAfterHook = router.afterEach(() => { + clearLoadingTimer() + loadingTimer = window.setTimeout(() => { + isLoading.value = false + loadingTimer = null + }, ROUTE_LOADING_DELAY) + }) + } + + onUnmounted(() => { + clearLoadingTimer() + cleanupBeforeGuard?.() + cleanupAfterHook?.() + }) + + return { + isLoading, + setupRouteLoading + } +} diff --git a/src/composables/useSendFlow.ts b/src/composables/useSendFlow.ts new file mode 100644 index 0000000..a798816 --- /dev/null +++ b/src/composables/useSendFlow.ts @@ -0,0 +1,337 @@ +import { computed, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { useAlertStore } from '@/stores/alertStore' +import { useAdminStore } from '@/stores/adminStore' +import { useConfigStore } from '@/stores/configStore' +import { useFileDataStore } from '@/stores/fileData' +import type { SendType, SentFileRecord } from '@/types' +import { getClipboardFile, insertTextAtSelection } from '@/utils/clipboard-paste' +import { getErrorMessage } from '@/utils/common' +import { getStorageUnit } from '@/utils/convert' +import { calculateFileHash } from '@/utils/file-processing' +import { buildSentRecord, isExpirationWithinLimit } from '@/utils/send-record' +import { createSentRecordActions } from '@/utils/sent-record-actions' +import { useSendSubmit } from './useSendSubmit' + +export function useSendFlow() { + const { t } = useI18n() + const alertStore = useAlertStore() + const adminStore = useAdminStore() + const configStore = useConfigStore() + const fileDataStore = useFileDataStore() + const config = computed(() => configStore.config) + const sendType = ref('file') + const selectedFile = ref(null) + const selectedFiles = ref([]) + const textContent = ref('') + const expirationMethod = ref(config.value.expireStyle[0] || 'day') + const expirationValue = ref('1') + const uploadProgress = ref(0) + const showDrawer = ref(false) + const selectedRecord = ref(null) + const isSubmitting = ref(false) + const fileHash = ref('') + const sendRecords = computed(() => fileDataStore.shareData) + const uploadDescription = computed( + () => `支持各种常见格式,最大${getStorageUnit(config.value.uploadSize)}` + ) + const expirationOptions = computed(() => + config.value.expireStyle.map((value) => ({ + value, + label: getUnit(value) + })) + ) + watch( + () => config.value.expireStyle, + (expireStyle) => { + if (expireStyle.length > 0 && !expireStyle.includes(expirationMethod.value)) { + expirationMethod.value = expireStyle[0] + } + }, + { immediate: true } + ) + const notifyCopyResult = (message: string, type: 'success' | 'error') => { + alertStore.showAlert(message, type) + } + const sentRecordActions = createSentRecordActions(notifyCopyResult) + const { resetPresignUpload, submitFile, submitText } = useSendSubmit({ + getMaxFileSize: () => configStore.uploadSizeLimit, + notify: (message, type) => alertStore.showAlert(message, type), + translate: t, + onProgress: (progress) => { + uploadProgress.value = progress + }, + onHashCalculated: (hash) => { + fileHash.value = hash + } + }) + + const checkOpenUpload = () => { + if (config.value.openUpload === 0 && !adminStore.hasToken) { + alertStore.showAlert(t('send.messages.guestUploadDisabled'), 'error') + return false + } + return true + } + + const checkFileSize = (file: File) => { + if (file.size > config.value.uploadSize) { + alertStore.showAlert( + t('send.messages.fileSizeExceeded', { size: getStorageUnit(config.value.uploadSize) }), + 'error' + ) + selectedFile.value = null + return false + } + return true + } + + const checkExpirationTime = (method: string, value: string): boolean => + isExpirationWithinLimit(method, value, config.value.max_save_seconds || 0) + + const checkUpload = () => { + if (!selectedFile.value) return false + if (!checkOpenUpload()) return false + if (!checkFileSize(selectedFile.value)) return false + if (!checkExpirationTime(expirationMethod.value, expirationValue.value)) return false + return true + } + + const handleFileSelected = async (file: File) => { + selectedFile.value = file + selectedFiles.value = [] + if (!checkOpenUpload()) return + if (!checkFileSize(file)) return + fileHash.value = await calculateFileHash(file) + } + + const handleFilesSelected = async (files: File[]) => { + if (!checkOpenUpload()) return + selectedFiles.value = files + selectedFile.value = null + fileHash.value = '' + } + + const handleFileDrop = async (event: DragEvent) => { + if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) return + const files = Array.from(event.dataTransfer.files) + if (files.length === 1) { + const file = files[0] + selectedFile.value = file + selectedFiles.value = [] + if (!checkUpload()) return + fileHash.value = await calculateFileHash(file) + } else { + if (!checkOpenUpload()) return + selectedFiles.value = files + selectedFile.value = null + fileHash.value = '' + } + } + + const handlePaste = async (event: ClipboardEvent) => { + const items = event.clipboardData?.items + if (!items) return + + const file = getClipboardFile(items) + if (file) { + if (file.size === 0) { + alertStore.showAlert(t('send.messages.emptyFileError'), 'error') + return + } + + selectedFile.value = file + if (!checkUpload()) return + + try { + fileHash.value = await calculateFileHash(file) + alertStore.showAlert( + t('send.messages.fileAddedFromClipboard', { filename: file.name }), + 'success' + ) + } catch (err) { + alertStore.showAlert(t('send.messages.fileProcessingFailed'), 'error') + console.error('File hash calculation failed:', err) + } + return + } + + const textItem = items[0] + if (!textItem) return + + sendType.value = 'text' + textItem.getAsString((str: string) => { + const trimmedStr = str.trim() + if (!trimmedStr) return + + const textareaElement = document.getElementById('text-content') as HTMLTextAreaElement + if (!textareaElement) { + textContent.value += trimmedStr + return + } + + const insertion = insertTextAtSelection({ + text: textContent.value, + insertText: trimmedStr, + selectionStart: textareaElement.selectionStart, + selectionEnd: textareaElement.selectionEnd + }) + textContent.value = insertion.value + + setTimeout(() => { + textareaElement.setSelectionRange(insertion.cursor, insertion.cursor) + textareaElement.focus() + }, 0) + }) + } + + const getUnit = (value: string = expirationMethod.value) => { + switch (value) { + case 'day': + return t('send.expiration.units.days') + case 'hour': + return t('send.expiration.units.hours') + case 'minute': + return t('send.expiration.units.minutes') + case 'count': + return t('send.expiration.units.times') + case 'forever': + return t('send.expiration.units.forever') + default: + return '' + } + } + + const handleSubmit = async () => { + if (isSubmitting.value) return + isSubmitting.value = true + + try { + if (sendType.value === 'file' && !selectedFile.value && selectedFiles.value.length === 0) { + alertStore.showAlert(t('send.messages.selectFile'), 'error') + return + } + if (sendType.value === 'text' && !textContent.value.trim()) { + alertStore.showAlert(t('send.messages.enterText'), 'error') + return + } + if (!checkOpenUpload()) { + return + } + if (expirationMethod.value !== 'forever' && !expirationValue.value) { + alertStore.showAlert(t('send.messages.enterExpirationValue'), 'error') + return + } + + if (!checkExpirationTime(expirationMethod.value, expirationValue.value)) { + const maxDays = Math.floor(config.value.max_save_seconds / 86400) + alertStore.showAlert(t('send.messages.expirationTooLong', { days: maxDays }), 'error') + return + } + + const expireValue = expirationValue.value ? parseInt(expirationValue.value) : 1 + let response + if (sendType.value === 'file') { + response = await submitFile({ + selectedFile: selectedFile.value, + selectedFiles: selectedFiles.value, + expireValue, + expireStyle: expirationMethod.value, + enableChunk: Boolean(config.value.enableChunk), + validateFileSize: checkFileSize + }) + } else { + response = await submitText({ + text: textContent.value, + expireValue, + expireStyle: expirationMethod.value + }) + } + + if (!response) return + + if (response?.code === 200) { + const newRecord = buildSentRecord({ + response, + sendType: sendType.value, + textContent: textContent.value, + selectedFile: selectedFile.value, + selectedFiles: selectedFiles.value, + expirationMethod: expirationMethod.value, + expirationValue: expirationValue.value, + translate: t, + getUnit + }) + fileDataStore.addShareDataRecord(newRecord) + alertStore.showAlert( + t('send.messages.sendSuccess', { code: newRecord.retrieveCode }), + 'success' + ) + selectedFile.value = null + selectedFiles.value = [] + textContent.value = '' + uploadProgress.value = 0 + resetPresignUpload() + selectedRecord.value = newRecord + await sentRecordActions.copyLink(newRecord) + } else { + throw new Error(t('send.messages.serverError')) + } + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, t('send.messages.sendFailed')), 'error') + } finally { + uploadProgress.value = 0 + isSubmitting.value = false + } + } + + const toggleDrawer = () => { + showDrawer.value = !showDrawer.value + } + + const viewDetails = (record: SentFileRecord) => { + selectedRecord.value = record + } + + const closeDetails = () => { + selectedRecord.value = null + } + + const deleteRecord = (id: number) => { + const index = fileDataStore.shareData.findIndex((record) => record.id === id) + if (index !== -1) { + fileDataStore.deleteShareData(index) + } + } + + return { + config, + sendType, + selectedFile, + selectedFiles, + textContent, + expirationMethod, + expirationValue, + uploadProgress, + showDrawer, + selectedRecord, + isSubmitting, + sendRecords, + uploadDescription, + expirationOptions, + closeDetails, + deleteRecord, + copySentRecordCode: sentRecordActions.copyCode, + copySentRecordLink: sentRecordActions.copyLink, + copySentRecordWgetCommand: sentRecordActions.copyWgetCommand, + getQRCodeValue: sentRecordActions.getQRCodeValue, + getUnit, + handleFileDrop, + handleFileSelected, + handleFilesSelected, + handlePaste, + handleSubmit, + toggleDrawer, + viewDetails + } +} diff --git a/src/composables/useSendSubmit.ts b/src/composables/useSendSubmit.ts new file mode 100644 index 0000000..958b6ca --- /dev/null +++ b/src/composables/useSendSubmit.ts @@ -0,0 +1,122 @@ +import { FileService, uploadChunkedFile } from '@/services' +import type { AlertType, ApiResponse, ExpireStyle, UploadProgress } from '@/types' +import { calculateFileHash, packFilesAsZip } from '@/utils/file-processing' +import { usePresignedUpload } from './usePresignedUpload' + +type Translate = ( + key: string, + params?: Record +) => string + +type UseSendSubmitOptions = { + getMaxFileSize: () => number + notify: (message: string, type: AlertType) => void + translate: Translate + onProgress: (progress: number) => void + onHashCalculated: (hash: string) => void +} + +type SubmitFileOptions = { + selectedFile: File | null + selectedFiles: File[] + expireValue: number + expireStyle: string + enableChunk: boolean + validateFileSize: (file: File) => boolean +} + +type SubmitTextOptions = { + text: string + expireValue: number + expireStyle: string +} + +export function useSendSubmit(options: UseSendSubmitOptions) { + const { uploadFile: presignUploadFile, reset: resetPresignUpload } = usePresignedUpload({ + getMaxFileSize: options.getMaxFileSize, + notify: options.notify + }) + + const handleChunkUpload = async ( + file: File, + expireValue: number, + expireStyle: string + ): Promise => { + return uploadChunkedFile(file, { + expireValue, + expireStyle, + onHashCalculated: options.onHashCalculated, + onProgress: (progress: UploadProgress) => { + options.onProgress(progress.percentage) + }, + messages: { + initFailed: options.translate('send.messages.initChunkUploadFailed'), + chunkFailed: (index) => options.translate('send.messages.chunkUploadFailed', { index }), + completeFailed: options.translate('send.messages.completeUploadFailed') + } + }) + } + + const handlePresignedUpload = async ( + file: File, + expireValue: number, + expireStyle: string + ): Promise> => { + const code = await presignUploadFile(file, { + expireValue, + expireStyle: expireStyle as ExpireStyle, + onProgress: (progress) => { + options.onProgress(progress.percentage) + } + }) + + if (!code) { + throw new Error(options.translate('send.messages.uploadFailed')) + } + + return { + code: 200, + detail: { + code, + name: file.name + } + } + } + + const submitFile = async ({ + selectedFile, + selectedFiles, + expireValue, + expireStyle, + enableChunk, + validateFileSize + }: SubmitFileOptions): Promise => { + let fileToUpload = selectedFile + + if (selectedFiles.length > 0) { + options.notify('正在打包文件...', 'success') + fileToUpload = await packFilesAsZip(selectedFiles) + if (!validateFileSize(fileToUpload)) { + return null + } + options.onHashCalculated(await calculateFileHash(fileToUpload)) + } + + if (!fileToUpload) { + throw new Error(options.translate('send.messages.selectFile')) + } + + return enableChunk + ? handleChunkUpload(fileToUpload, expireValue, expireStyle) + : handlePresignedUpload(fileToUpload, expireValue, expireStyle) + } + + const submitText = ({ text, expireValue, expireStyle }: SubmitTextOptions) => + FileService.uploadText(text, expireValue, expireStyle) + + return { + resetPresignUpload, + submitFile, + submitText + } +} diff --git a/src/composables/useSystemConfig.ts b/src/composables/useSystemConfig.ts index 0b7fe04..5b8ec87 100644 --- a/src/composables/useSystemConfig.ts +++ b/src/composables/useSystemConfig.ts @@ -1,61 +1,54 @@ import { ref, computed } from 'vue' import { ConfigService } from '@/services' import { useAlertStore } from '@/stores/alertStore' -import type { SystemConfig } from '@/types' -import { STORAGE_KEYS, DEFAULT_CONFIG } from '@/constants' +import { useConfigStore } from '@/stores/configStore' +import type { ConfigState } from '@/types' +import { DEFAULT_CONFIG_STATE, readStoredConfig } from '@/utils/config-storage' +import { getErrorMessage } from '@/utils/common' +import { + buildConfigSubmitPayload, + bytesToFileSizeForm, + secondsToSaveTimeForm, + type FileSizeUnit, + type SaveTimeUnit +} from '@/utils/config-form' + +type ConfigFlagKey = 'enableChunk' | 's3_proxy' | 'openUpload' export function useSystemConfig() { const alertStore = useAlertStore() + const configStore = useConfigStore() // 状态管理 - const config = ref({ ...DEFAULT_CONFIG }) + const config = ref({ ...DEFAULT_CONFIG_STATE }) const isLoading = ref(false) + const fileSize = ref(1) + const sizeUnit = ref('MB') + const saveTime = ref(1) + const saveTimeUnit = ref('天') // 从本地存储获取配置 - const getStoredConfig = (): SystemConfig | null => { - try { - const storedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG) - if (storedConfig) { - return JSON.parse(storedConfig) - } - } catch (error) { - console.error('解析本地配置失败:', error) - } - return null + const getStoredConfig = (): ConfigState | null => { + return readStoredConfig() } // 保存配置到本地存储 - const saveConfigToStorage = (configData: SystemConfig) => { - try { - localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configData)) - } catch (error) { - console.error('保存配置到本地存储失败:', error) - } + const saveConfigToStorage = (configData: ConfigState) => { + configStore.updateConfig(configData) } // 获取系统配置 - const fetchConfig = async (): Promise => { + const fetchConfig = async (): Promise => { try { isLoading.value = true const response = await ConfigService.getConfig() if (response.code === 200 && response.detail) { - config.value = { ...DEFAULT_CONFIG, ...response.detail } - saveConfigToStorage(config.value) - - // 处理通知 - if (response.detail.notify_title && response.detail.notify_content) { - const notifyKey = response.detail.notify_title + response.detail.notify_content - const lastNotify = localStorage.getItem(STORAGE_KEYS.NOTIFY) - - if (lastNotify !== notifyKey) { - localStorage.setItem(STORAGE_KEYS.NOTIFY, notifyKey) - alertStore.showAlert( - `${response.detail.notify_title}: ${response.detail.notify_content}`, - 'success' - ) - } + config.value = { ...DEFAULT_CONFIG_STATE, ...response.detail } + const notifyMessage = configStore.applyRemoteConfig(config.value) + if (notifyMessage) { + alertStore.showAlert(notifyMessage, 'success') } return config.value @@ -70,8 +63,7 @@ export function useSystemConfig() { return config.value } - const errorMessage = error instanceof Error ? error.message : '获取配置失败' - alertStore.showAlert(errorMessage, 'error') + alertStore.showAlert(getErrorMessage(error, '获取配置失败'), 'error') return null } finally { isLoading.value = false @@ -79,7 +71,7 @@ export function useSystemConfig() { } // 更新系统配置 - const updateConfig = async (newConfig: Partial): Promise => { + const updateConfig = async (newConfig: Partial): Promise => { try { isLoading.value = true @@ -94,13 +86,42 @@ export function useSystemConfig() { throw new Error(response.message || '更新配置失败') } } catch (error) { - const errorMessage = error instanceof Error ? error.message : '更新配置失败' - alertStore.showAlert(errorMessage, 'error') + alertStore.showAlert(getErrorMessage(error, '更新配置失败'), 'error') return false } finally { isLoading.value = false } } + + const toggleConfigFlag = (key: ConfigFlagKey) => { + config.value[key] = config.value[key] === 1 ? 0 : 1 + } + + const syncConfigForm = (nextConfig: ConfigState) => { + const sizeForm = bytesToFileSizeForm(nextConfig.uploadSize) + fileSize.value = sizeForm.value + sizeUnit.value = sizeForm.unit + + const saveTimeForm = secondsToSaveTimeForm(nextConfig.max_save_seconds) + saveTime.value = saveTimeForm.value + saveTimeUnit.value = saveTimeForm.unit + } + + const refreshConfig = async () => { + const latestConfig = await fetchConfig() + if (latestConfig) { + syncConfigForm(latestConfig) + } + } + + const submitConfig = () => + updateConfig( + buildConfigSubmitPayload( + config.value, + { value: fileSize.value, unit: sizeUnit.value }, + { value: saveTime.value, unit: saveTimeUnit.value } + ) + ) // 初始化配置 const initConfig = async () => { @@ -116,17 +137,21 @@ export function useSystemConfig() { // 计算属性 const maxFileSizeMB = computed(() => { - return Math.round(config.value.maxFileSize / 1024 / 1024) + return Math.round(config.value.uploadSize / 1024 / 1024) }) const isConfigLoaded = computed(() => { - return config.value.name !== DEFAULT_CONFIG.name || !isLoading.value + return config.value.name !== DEFAULT_CONFIG_STATE.name || !isLoading.value }) return { // 状态 config, isLoading, + fileSize, + sizeUnit, + saveTime, + saveTimeUnit, // 计算属性 maxFileSizeMB, @@ -135,8 +160,11 @@ export function useSystemConfig() { // 方法 fetchConfig, updateConfig, + refreshConfig, + submitConfig, + toggleConfigFlag, initConfig, getStoredConfig, saveConfigToStorage } -} \ No newline at end of file +} diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts index aab65aa..63fe823 100644 --- a/src/composables/useTheme.ts +++ b/src/composables/useTheme.ts @@ -1,6 +1,7 @@ import { ref, computed } from 'vue' -import { STORAGE_KEYS, THEME_MODES } from '@/constants' +import { THEME_MODES } from '@/constants' import type { ThemeMode } from '@/types' +import { readStoredThemeMode, writeStoredThemeMode } from '@/utils/preference-storage' export function useTheme() { // 状态管理 @@ -14,7 +15,7 @@ export function useTheme() { // 从本地存储获取用户之前的选择 const getUserPreference = (): ThemeMode | null => { - const storedPreference = localStorage.getItem(STORAGE_KEYS.COLOR_MODE) + const storedPreference = readStoredThemeMode() if (storedPreference && Object.values(THEME_MODES).includes(storedPreference as ThemeMode)) { return storedPreference as ThemeMode } @@ -24,7 +25,7 @@ export function useTheme() { // 设置颜色模式 const setThemeMode = (mode: ThemeMode) => { themeMode.value = mode - localStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode) + writeStoredThemeMode(mode) // 根据模式设置实际的暗色模式状态 if (mode === THEME_MODES.SYSTEM) { @@ -135,4 +136,4 @@ export function useTheme() { initTheme, checkSystemColorScheme } -} \ No newline at end of file +} diff --git a/src/constants/index.ts b/src/constants/index.ts index 9585dc7..498481f 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -71,6 +71,16 @@ export const ROUTES = { SETTINGS: '/admin/settings' } as const +export const ROUTE_NAMES = { + RETRIEVE: 'Retrieve', + SEND: 'Send', + ADMIN: 'Manage', + LOGIN: 'Login', + DASHBOARD: 'Dashboard', + FILE_MANAGE: 'FileManage', + SETTINGS: 'Settings' +} as const + // 正则表达式 export const REGEX_PATTERNS = { EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, @@ -85,4 +95,4 @@ export const DEFAULT_CONFIG = { maxFileSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE, allowedFileTypes: ['*'] as string[], expireDays: 7 -} \ No newline at end of file +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index d8a416d..61c2821 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,10 +1,11 @@ import { createI18n } from 'vue-i18n' import zhCN from './locales/zh-CN' import enUS from './locales/en-US' +import { readStoredLocale, writeStoredLocale } from '@/utils/preference-storage' // 获取浏览器语言设置 const getDefaultLocale = (): string => { - const savedLocale = localStorage.getItem('locale') + const savedLocale = readStoredLocale() if (savedLocale) { return savedLocale } @@ -34,7 +35,7 @@ export default i18n // 导出切换语言的函数 export const setLocale = (locale: string) => { i18n.global.locale.value = locale as 'zh-CN' | 'en-US' - localStorage.setItem('locale', locale) + writeStoredLocale(locale) document.documentElement.lang = locale } @@ -47,4 +48,4 @@ export const getCurrentLocale = () => { export const availableLocales = [ { code: 'zh-CN', name: '中文' }, { code: 'en-US', name: 'English' } -] \ No newline at end of file +] diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index aa4cf60..c0aa755 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -58,14 +58,42 @@ export default { title: 'Dashboard', totalFiles: 'Total Files', storageSpace: 'Storage Space', - activeUsers: 'Active Users', - systemStatus: 'System Status', - yesterday: 'Yesterday:', - today: 'Today:', - weeklyChange: '↓ 5% from last week', - normal: 'Normal', - serverUptime: 'Server Uptime:', - version: 'Version v2.2.1 Updated: 2025-09-04' + todayShares: 'Today Shares', + totalRetrievals: 'Total Retrievals', + activeFiles: 'Active files: {count}', + todayIncrease: 'Today added: {count}', + yesterdayShares: 'Yesterday: {count}', + serverUptime: 'Uptime', + refresh: 'Refresh', + fileHealth: 'File Health', + fileHealthDesc: 'Real file records grouped by availability, expiry, and type.', + activeFileRatio: 'Active Ratio', + fileShareRatio: 'File Ratio', + textShareRatio: 'Text Ratio', + binaryFiles: '{count} file shares', + textShares: '{count} text shares', + expiredFiles: 'Expired Files', + needCleanup: 'Clean up in file management', + chunkedFiles: 'Chunked Files', + storagePolicy: 'Storage & Upload Policy', + storagePolicyDesc: 'Current settings that affect upload behavior.', + storageBackend: 'Storage Backend', + singleFileLimit: 'Single File Limit', + guestUpload: 'Guest Upload', + maxSaveTime: 'Max Retention', + noSaveLimit: 'Unlimited', + todayCapacityReference: 'Today Size / Single File Limit', + fileTypeDistribution: 'Type Distribution', + textType: 'Text', + recentFiles: 'Recent Shares', + recentFilesDesc: 'Recently created share records for quick status checks.', + available: 'Available', + table: { + file: 'File', + size: 'Size', + usage: 'Retrievals', + status: 'Status' + } }, fileManage: { title: 'File Management' @@ -458,14 +486,42 @@ export default { title: 'Dashboard', totalFiles: 'Total Files', storageSpace: 'Storage Space', - activeUsers: 'Active Users', - systemStatus: 'System Status', - yesterday: 'Yesterday:', - today: 'Today:', - weeklyChange: '↓ 5% from last week', - normal: 'Normal', - serverUptime: 'Server Uptime:', - version: 'Version v2.2.1 Updated: 2025-09-04' + todayShares: 'Today Shares', + totalRetrievals: 'Total Retrievals', + activeFiles: 'Active files: {count}', + todayIncrease: 'Today added: {count}', + yesterdayShares: 'Yesterday: {count}', + serverUptime: 'Uptime', + refresh: 'Refresh', + fileHealth: 'File Health', + fileHealthDesc: 'Real file records grouped by availability, expiry, and type.', + activeFileRatio: 'Active Ratio', + fileShareRatio: 'File Ratio', + textShareRatio: 'Text Ratio', + binaryFiles: '{count} file shares', + textShares: '{count} text shares', + expiredFiles: 'Expired Files', + needCleanup: 'Clean up in file management', + chunkedFiles: 'Chunked Files', + storagePolicy: 'Storage & Upload Policy', + storagePolicyDesc: 'Current settings that affect upload behavior.', + storageBackend: 'Storage Backend', + singleFileLimit: 'Single File Limit', + guestUpload: 'Guest Upload', + maxSaveTime: 'Max Retention', + noSaveLimit: 'Unlimited', + todayCapacityReference: 'Today Size / Single File Limit', + fileTypeDistribution: 'Type Distribution', + textType: 'Text', + recentFiles: 'Recent Shares', + recentFilesDesc: 'Recently created share records for quick status checks.', + available: 'Available', + table: { + file: 'File', + size: 'Size', + usage: 'Retrievals', + status: 'Status' + } }, fileManage: { title: 'File Management', @@ -493,6 +549,7 @@ export default { }, updateFailed: 'Update failed', deleteFailed: 'Delete failed', + deleteConfirm: 'Delete this file? This action cannot be undone.', loadFileListFailed: 'Failed to load file list' }, login: { diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index b9c7c47..ca5e9b8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -12,6 +12,7 @@ export default { next: '下一页', previous: '上一页', loading: '加载中...', + noData: '暂无数据', success: '成功', error: '错误', warning: '警告', @@ -57,14 +58,42 @@ export default { title: '仪表盘', totalFiles: '总文件数', storageSpace: '存储空间', - activeUsers: '活跃用户', - systemStatus: '系统状态', - yesterday: '昨天:', - today: '今天:', - weeklyChange: '↓ 5% 较上周', - normal: '正常', - serverUptime: '服务器运行时间:', - version: '版本 v2.2.1 更新时间:2025-09-04' + todayShares: '今日分享', + totalRetrievals: '累计取件', + activeFiles: '有效文件:{count}', + todayIncrease: '今日新增容量:{count}', + yesterdayShares: '昨日分享:{count}', + serverUptime: '运行时间', + refresh: '刷新数据', + fileHealth: '文件健康', + fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。', + activeFileRatio: '有效占比', + fileShareRatio: '文件占比', + textShareRatio: '文本占比', + binaryFiles: '文件分享 {count} 个', + textShares: '文本分享 {count} 条', + expiredFiles: '已过期文件', + needCleanup: '可在文件管理中清理', + chunkedFiles: '分片上传文件', + storagePolicy: '存储与上传策略', + storagePolicyDesc: '当前后台配置对上传链路的影响。', + storageBackend: '存储后端', + singleFileLimit: '单文件上限', + guestUpload: '游客上传', + maxSaveTime: '最长保存', + noSaveLimit: '不限制', + todayCapacityReference: '今日容量 / 单文件上限', + fileTypeDistribution: '类型分布', + textType: '文本', + recentFiles: '最近分享', + recentFilesDesc: '最近创建的分享记录,便于快速核对状态。', + available: '可取件', + table: { + file: '文件', + size: '大小', + usage: '取件', + status: '状态' + } }, fileManage: { title: '文件管理' @@ -422,14 +451,42 @@ export default { title: '仪表盘', totalFiles: '总文件数', storageSpace: '存储空间', - activeUsers: '活跃用户', - systemStatus: '系统状态', - yesterday: '昨天:', - today: '今天:', - weeklyChange: '↓ 5% 较上周', - normal: '正常', - serverUptime: '服务器运行时间:', - version: '版本 v2.2.1 更新时间:2025-09-04' + todayShares: '今日分享', + totalRetrievals: '累计取件', + activeFiles: '有效文件:{count}', + todayIncrease: '今日新增容量:{count}', + yesterdayShares: '昨日分享:{count}', + serverUptime: '运行时间', + refresh: '刷新数据', + fileHealth: '文件健康', + fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。', + activeFileRatio: '有效占比', + fileShareRatio: '文件占比', + textShareRatio: '文本占比', + binaryFiles: '文件分享 {count} 个', + textShares: '文本分享 {count} 条', + expiredFiles: '已过期文件', + needCleanup: '可在文件管理中清理', + chunkedFiles: '分片上传文件', + storagePolicy: '存储与上传策略', + storagePolicyDesc: '当前后台配置对上传链路的影响。', + storageBackend: '存储后端', + singleFileLimit: '单文件上限', + guestUpload: '游客上传', + maxSaveTime: '最长保存', + noSaveLimit: '不限制', + todayCapacityReference: '今日容量 / 单文件上限', + fileTypeDistribution: '类型分布', + textType: '文本', + recentFiles: '最近分享', + recentFilesDesc: '最近创建的分享记录,便于快速核对状态。', + available: '可取件', + table: { + file: '文件', + size: '大小', + usage: '取件', + status: '状态' + } }, fileManage: { title: '文件管理', @@ -457,6 +514,7 @@ export default { }, updateFailed: '更新失败', deleteFailed: '删除失败', + deleteConfirm: '确认删除这个文件?此操作不可撤销。', loadFileListFailed: '加载文件列表失败' }, systemSettings: { diff --git a/src/layout/AdminLayout/AdminLayout.vue b/src/layout/AdminLayout/AdminLayout.vue index fc6dfda..6eb588f 100644 --- a/src/layout/AdminLayout/AdminLayout.vue +++ b/src/layout/AdminLayout/AdminLayout.vue @@ -44,11 +44,11 @@ @@ -117,8 +117,9 @@ import { LayoutDashboardIcon, LogOutIcon } from 'lucide-vue-next' -import { useRouter } from 'vue-router' +import { RouterLink, useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' +import { ROUTE_NAMES, ROUTES } from '@/constants' import { useAdminStore } from '@/stores/adminStore' interface MenuItem { @@ -129,23 +130,29 @@ interface MenuItem { } const router = useRouter() +const route = useRoute() const { t } = useI18n() const isDarkMode = inject('isDarkMode') const adminStore = useAdminStore() const menuItems: MenuItem[] = [ { - id: 'Dashboard', + id: ROUTE_NAMES.DASHBOARD, name: t('admin.dashboard.title'), icon: LayoutDashboardIcon, - redirect: '/admin/dashboard' + redirect: ROUTES.DASHBOARD }, { - id: 'FileManage', + id: ROUTE_NAMES.FILE_MANAGE, name: t('admin.fileManage.title'), icon: FolderIcon, - redirect: '/admin/files' + redirect: ROUTES.FILE_MANAGE }, - { id: 'Settings', name: t('admin.settings.title'), icon: CogIcon, redirect: '/admin/settings' } + { + id: ROUTE_NAMES.SETTINGS, + name: t('admin.settings.title'), + icon: CogIcon, + redirect: ROUTES.SETTINGS + } ] const isSidebarOpen = ref(true) @@ -171,33 +178,10 @@ onUnmounted(() => { window.removeEventListener('resize', handleResize) }) -// 分页参数 -const params = ref({ - page: 1, - size: 10, - total: 0 -}) - -// 加载文件列表 -const loadFiles = async () => { - try { - params.value.total = 85 - // 更新文件列表数据... - } catch (error) { - console.error('加载文件列表失败:', error) - // 处理错误... - } -} - -// 初始加载 -onMounted(() => { - loadFiles() -}) - // 登出处理 const handleLogout = () => { adminStore.logout() - router.push('/login') + router.push(ROUTES.LOGIN) } diff --git a/src/router/index.ts b/src/router/index.ts index 2891e0a..b918d6e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,50 +1,104 @@ import { createRouter, createWebHashHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' +import { ROUTE_NAMES, ROUTES } from '@/constants' +import { readStoredToken } from '@/utils/auth-storage' -// 预加载 SendFileView 组件 -const SendFileView = () => import('../views/SendFileView.vue') -const router = createRouter({ - history: createWebHashHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - name: 'Retrieve', - component: () => import('@/views/RetrievewFileView.vue') - }, - { - path: '/send', - name: 'Send', - component: SendFileView - }, - { - path: '/admin', - name: 'Manage', - component: () => import('@/layout/AdminLayout/AdminLayout.vue'), - redirect: '/admin/dashboard', - children: [ - { - path: '/admin/dashboard', - name: 'Dashboard', - component: () => import('@/views/manage/DashboardView.vue') - }, - { - path: '/admin/files', - name: 'FileManage', - component: () => import('@/views/manage/FileManageView.vue') - }, - { - path: '/admin/settings', - name: 'Settings', - component: () => import('@/views/manage/SystemSettingsView.vue') +const publicPageMeta = { + showGlobalControls: true, + showRouteLoading: true +} + +const adminPageMeta = { + requiresAuth: true, + showGlobalControls: false, + showRouteLoading: true +} + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: ROUTE_NAMES.RETRIEVE, + component: () => import('@/views/RetrievewFileView.vue'), + meta: { + ...publicPageMeta, + title: 'retrieve' + } + }, + { + path: ROUTES.SEND, + name: ROUTE_NAMES.SEND, + component: () => import('@/views/SendFileView.vue'), + meta: { + ...publicPageMeta, + title: 'send' + } + }, + { + path: ROUTES.ADMIN, + name: ROUTE_NAMES.ADMIN, + component: () => import('@/layout/AdminLayout/AdminLayout.vue'), + redirect: ROUTES.DASHBOARD, + meta: adminPageMeta, + children: [ + { + path: 'dashboard', + name: ROUTE_NAMES.DASHBOARD, + component: () => import('@/views/manage/DashboardView.vue'), + meta: { + ...adminPageMeta, + title: 'dashboard' + } + }, + { + path: 'files', + name: ROUTE_NAMES.FILE_MANAGE, + component: () => import('@/views/manage/FileManageView.vue'), + meta: { + ...adminPageMeta, + title: 'files' } - ] - }, - { - path: '/login', - name: 'Login', - component: () => import('@/views/manage/LoginView.vue') + }, + { + path: 'settings', + name: ROUTE_NAMES.SETTINGS, + component: () => import('@/views/manage/SystemSettingsView.vue'), + meta: { + ...adminPageMeta, + title: 'settings' + } + } + ] + }, + { + path: ROUTES.LOGIN, + name: ROUTE_NAMES.LOGIN, + component: () => import('@/views/manage/LoginView.vue'), + meta: { + showGlobalControls: true, + showRouteLoading: true, + title: 'login' } - ] + }, + { + path: '/:pathMatch(.*)*', + redirect: ROUTES.HOME + } +] + +const router = createRouter({ + history: createWebHashHistory(import.meta.env.BASE_URL), + routes }) +router.beforeEach((to) => { + if (to.meta.requiresAuth && !readStoredToken()) { + return { + path: ROUTES.LOGIN, + query: { + redirect: to.fullPath + } + } + } +}) export default router diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..47fb448 --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,16 @@ +import api from './client' +import type { AdminUser, ApiResponse } from '@/types' + +export class AuthService { + static async login(password: string): Promise> { + return api.post('/admin/login', { password }) + } + + static async logout(): Promise { + return api.post('/admin/logout') + } + + static async verifyToken(): Promise> { + return api.get('/admin/verify') + } +} diff --git a/src/services/client.ts b/src/services/client.ts new file mode 100644 index 0000000..4a39bfa --- /dev/null +++ b/src/services/client.ts @@ -0,0 +1,61 @@ +import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios' +import { API_STATUS_CODES, TIME_CONSTANTS } from '@/constants' +import type { ApiErrorPayload } from '@/types' +import { clearStoredToken, readStoredToken } from '@/utils/auth-storage' + +export const AUTH_EVENTS = { + UNAUTHORIZED: 'filecodebox:auth:unauthorized' +} as const + +const rawBaseURL = + import.meta.env.MODE === 'production' + ? import.meta.env.VITE_API_BASE_URL_PROD + : import.meta.env.VITE_API_BASE_URL_DEV + +export const apiBaseURL = typeof rawBaseURL === 'string' ? rawBaseURL.replace(/\/+$/, '') : '' + +const clientOptions = { + baseURL: apiBaseURL, + timeout: TIME_CONSTANTS.REQUEST_TIMEOUT, + headers: { + 'Content-Type': 'application/json' + } +} + +const apiClient = axios.create(clientOptions) +export const rawApiClient = axios.create(clientOptions) + +const attachAuthToken = (config: InternalAxiosRequestConfig) => { + const token = readStoredToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +} + +const handleAuthError = (error: AxiosError) => { + if (error.response?.status === API_STATUS_CODES.UNAUTHORIZED) { + clearStoredToken() + window.dispatchEvent(new CustomEvent(AUTH_EVENTS.UNAUTHORIZED)) + } + return Promise.reject(error) +} + +apiClient.interceptors.request.use( + attachAuthToken, + (error) => Promise.reject(error) +) + +rawApiClient.interceptors.request.use( + attachAuthToken, + (error) => Promise.reject(error) +) + +apiClient.interceptors.response.use( + (response) => response.data, + handleAuthError +) + +rawApiClient.interceptors.response.use((response) => response, handleAuthError) + +export default apiClient diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 0000000..9d60b75 --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,16 @@ +import api from './client' +import type { ApiResponse, ConfigState } from '@/types' + +export class ConfigService { + static async getConfig(): Promise> { + return api.get('/admin/config/get') + } + + static async getUserConfig(): Promise> { + return api.post('/') + } + + static async updateConfig(config: Partial): Promise { + return api.patch('/admin/config/update', config) + } +} diff --git a/src/services/file.ts b/src/services/file.ts new file mode 100644 index 0000000..979855b --- /dev/null +++ b/src/services/file.ts @@ -0,0 +1,144 @@ +import api, { rawApiClient } from './client' +import { multipartUploadConfig } from './shared' +import type { + ApiResponse, + ChunkUploadCompleteRequest, + ChunkUploadInitRequest, + ChunkUploadInitResponse, + ChunkUploadResponse, + FileEditForm, + FileInfo, + FileListResponse, + FileUploadResponse, + ShareSelectResponse, + TextSendResponse, + UploadProgress +} from '@/types' + +const urlEncodedConfig = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } +} + +const toUrlEncodedForm = (data: Record) => { + const form = new URLSearchParams() + Object.entries(data).forEach(([key, value]) => { + form.append(key, String(value)) + }) + return form +} + +export class FileService { + static async uploadFile( + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise> { + const formData = new FormData() + formData.append('file', file) + + return api.post('/share/file/', formData, multipartUploadConfig(onProgress)) + } + + static async uploadText( + text: string, + expireValue = 1, + expireStyle = 'day' + ): Promise> { + const formData = new FormData() + formData.append('text', text) + formData.append('expire_value', String(expireValue)) + formData.append('expire_style', expireStyle) + return api.post('/share/text/', formData, multipartUploadConfig()) + } + + static async initChunkUpload( + request: ChunkUploadInitRequest + ): Promise> { + return api.post( + '/chunk/upload/init/', + toUrlEncodedForm({ + file_name: request.file_name, + file_size: request.file_size, + chunk_size: request.chunk_size, + file_hash: request.file_hash + }), + urlEncodedConfig + ) + } + + static async uploadChunk( + uploadId: string, + chunkIndex: number, + chunk: Blob, + onProgress?: (progress: UploadProgress) => void + ): Promise> { + const formData = new FormData() + formData.append('chunk', chunk) + return api.post( + `/chunk/upload/chunk/${uploadId}/${chunkIndex}`, + formData, + multipartUploadConfig(onProgress) + ) + } + + static async completeChunkUpload( + uploadId: string, + request: ChunkUploadCompleteRequest + ): Promise> { + return api.post( + `/chunk/upload/complete/${uploadId}`, + toUrlEncodedForm({ + expire_value: request.expire_value, + expire_style: request.expire_style + }), + urlEncodedConfig + ) + } + + static async selectFile(code: string): Promise> { + return api.post('/share/select/', { code }) + } + + static async getFile(code: string): Promise> { + return api.get(`/file/${code}`) + } + + static async downloadFile(code: string): Promise { + const response = await rawApiClient.get(`/download/${code}`, { + responseType: 'blob' + }) + return response.data + } + + static async getAdminFileList(params: { + page: number + size: number + keyword?: string + }): Promise> { + return api.get('/admin/file/list', { params }) + } + + static async updateFile(data: FileEditForm): Promise { + return api.patch('/admin/file/update', data) + } + + static async deleteAdminFile(id: number): Promise { + return api.delete('/admin/file/delete', { + data: { id } + }) + } + + static async downloadAdminFile( + id: number + ): Promise<{ data: Blob; headers: Record }> { + const response = await rawApiClient.get('/admin/file/download', { + params: { id }, + responseType: 'blob' + }) + return { + data: response.data, + headers: response.headers as Record + } + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 8ed9e4d..38e6d24 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,266 +1,7 @@ -// API 服务层 -import api from '@/utils/api' -import axios from 'axios' -import type { - ApiResponse, - FileInfo, - ConfigState, - AdminUser, - FileUploadResponse, - TextSendResponse, - DashboardData, - FileListResponse, - FileEditForm -} from '@/types' -import type { - UploadProgress, - PresignInitRequest, - PresignInitResponse, - PresignConfirmRequest, - PresignUploadResult, - PresignStatusResponse -} from '@/types' - -// 系统配置服务 -export class ConfigService { - static async getConfig(): Promise> { - return api.get('/admin/config/get') - } - static async getUserConfig(): Promise> { - return api.post('/') - } - static async updateConfig(config: Partial): Promise { - return api.patch('/admin/config/update', config) - } -} - -// 文件服务 -export class FileService { - static async uploadFile( - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise> { - const formData = new FormData() - formData.append('file', file) - - return api.post('/share/file/', formData, { - headers: { - 'Content-Type': 'multipart/form-data' - }, - timeout: 0, // 禁用超时限制 - onUploadProgress: (progressEvent) => { - if (onProgress && progressEvent.total) { - const progress: UploadProgress = { - loaded: progressEvent.loaded, - total: progressEvent.total, - percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) - } - onProgress(progress) - } - } - }) - } - - static async uploadText(text: string): Promise> { - return api.post('/share/text/', { content: text }) - } - - static async getFile(code: string): Promise> { - return api.get(`/file/${code}`) - } - - static async downloadFile(code: string): Promise { - const response = await api.get(`/download/${code}`, { - responseType: 'blob' - }) - return response.data - } - - static async deleteFile(fileId: string): Promise { - return api.post('/admin/file/delete', { id: fileId }) - } - - static async getFileList( - page = 1, - limit = 10 - ): Promise< - ApiResponse<{ - files: FileInfo[] - total: number - page: number - limit: number - }> - > { - return api.get('/admin/file/list', { - params: { page, size: limit } - }) - } - - // 文件管理相关方法 - static async getAdminFileList(params: { - page: number - size: number - keyword?: string - }): Promise> { - return api.get('/admin/file/list', { params }) - } - - static async updateFile(data: FileEditForm): Promise { - return api.patch('/admin/file/update', data) - } - - static async deleteAdminFile(id: number): Promise { - return api.delete('/admin/file/delete', { - data: { id } - }) - } - - static async downloadAdminFile( - id: number - ): Promise<{ data: Blob; headers: Record }> { - return api.get('/admin/file/download', { - params: { id }, - responseType: 'blob' - }) - } -} - -// 认证服务 -export class AuthService { - static async login(password: string): Promise> { - return api.post('/admin/login', { password }) - } - - static async logout(): Promise { - return api.post('/admin/logout') - } - - static async verifyToken(): Promise> { - return api.get('/admin/verify') - } -} - -// 统计服务 -export class StatsService { - static async getDashboardStats(): Promise< - ApiResponse<{ - totalFiles: number - totalDownloads: number - todayUploads: number - todayDownloads: number - storageUsed: number - recentFiles: FileInfo[] - }> - > { - return api.get('/admin/dashboard') - } - - static async getDashboard(): Promise> { - return api.get('/admin/dashboard') - } -} - -// 预签名上传服务 -export class PresignUploadService { - /** - * 初始化上传会话 - */ - static async initUpload(request: PresignInitRequest): Promise> { - return api.post('/presign/upload/init', request) - } - - /** - * 代理模式上传 - */ - static async proxyUpload( - uploadId: string, - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise> { - const formData = new FormData() - formData.append('file', file) - - return api.put(`/presign/upload/proxy/${uploadId}`, formData, { - headers: { - 'Content-Type': 'multipart/form-data' - }, - timeout: 0, - onUploadProgress: (progressEvent) => { - if (onProgress && progressEvent.total) { - const progress: UploadProgress = { - loaded: progressEvent.loaded, - total: progressEvent.total, - percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) - } - onProgress(progress) - } - } - }) - } - - /** - * 确认直传上传 - */ - static async confirmUpload( - uploadId: string, - request?: PresignConfirmRequest - ): Promise> { - return api.post(`/presign/upload/confirm/${uploadId}`, request || {}) - } - - /** - * 查询上传状态 - */ - static async getUploadStatus(uploadId: string): Promise> { - return api.get(`/presign/upload/status/${uploadId}`) - } - - /** - * 取消上传 - */ - static async cancelUpload(uploadId: string): Promise> { - return api.delete(`/presign/upload/${uploadId}`) - } - - /** - * S3 直传(不经过后端) - */ - static async directUploadToS3( - uploadUrl: string, - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise { - try { - await axios.put(uploadUrl, file, { - headers: { - 'Content-Type': 'application/octet-stream' - }, - timeout: 0, - onUploadProgress: (progressEvent) => { - if (onProgress && progressEvent.total) { - const progress: UploadProgress = { - loaded: progressEvent.loaded, - total: progressEvent.total, - percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) - } - onProgress(progress) - } - } - }) - return true - } catch { - return false - } - } -} - -// 导出所有服务 -export const services = { - config: ConfigService, - file: FileService, - auth: AuthService, - stats: StatsService, - presignUpload: PresignUploadService -} - -export default services +export { AUTH_EVENTS } from './client' +export { AuthService } from './auth' +export { ConfigService } from './config' +export { FileService } from './file' +export { PresignUploadService } from './presign-upload' +export { StatsService } from './stats' +export { uploadChunkedFile } from './upload-strategy' diff --git a/src/services/presign-upload.ts b/src/services/presign-upload.ts new file mode 100644 index 0000000..3d1def8 --- /dev/null +++ b/src/services/presign-upload.ts @@ -0,0 +1,53 @@ +import api from './client' +import { multipartUploadConfig } from './shared' +import { uploadToExternalUrl } from './upload-client' +import type { + ApiResponse, + PresignConfirmRequest, + PresignInitRequest, + PresignInitResponse, + PresignStatusResponse, + PresignUploadResult, + UploadProgress +} from '@/types' + +export class PresignUploadService { + static async initUpload(request: PresignInitRequest): Promise> { + return api.post('/presign/upload/init', request) + } + + static async proxyUpload( + uploadId: string, + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise> { + const formData = new FormData() + formData.append('file', file) + + return api.put(`/presign/upload/proxy/${uploadId}`, formData, multipartUploadConfig(onProgress)) + } + + static async confirmUpload( + uploadId: string, + request?: PresignConfirmRequest + ): Promise> { + return api.post(`/presign/upload/confirm/${uploadId}`, request || {}) + } + + static async getUploadStatus(uploadId: string): Promise> { + return api.get(`/presign/upload/status/${uploadId}`) + } + + static async cancelUpload(uploadId: string): Promise> { + return api.delete(`/presign/upload/${uploadId}`) + } + + static async directUploadToS3( + uploadUrl: string, + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise { + await uploadToExternalUrl(uploadUrl, file, onProgress) + return true + } +} diff --git a/src/services/shared.ts b/src/services/shared.ts new file mode 100644 index 0000000..58c5535 --- /dev/null +++ b/src/services/shared.ts @@ -0,0 +1,20 @@ +import type { AxiosRequestConfig } from 'axios' +import type { UploadProgress } from '@/types' + +export const multipartUploadConfig = ( + onProgress?: (progress: UploadProgress) => void +): AxiosRequestConfig => ({ + headers: { + 'Content-Type': 'multipart/form-data' + }, + timeout: 0, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) + }) + } + } +}) diff --git a/src/services/stats.ts b/src/services/stats.ts new file mode 100644 index 0000000..c275a12 --- /dev/null +++ b/src/services/stats.ts @@ -0,0 +1,8 @@ +import api from './client' +import type { ApiResponse, DashboardData } from '@/types' + +export class StatsService { + static async getDashboard(): Promise> { + return api.get('/admin/dashboard') + } +} diff --git a/src/services/upload-client.ts b/src/services/upload-client.ts new file mode 100644 index 0000000..062fe77 --- /dev/null +++ b/src/services/upload-client.ts @@ -0,0 +1,26 @@ +import axios from 'axios' +import type { UploadProgress } from '@/types' + +export async function uploadToExternalUrl( + uploadUrl: string, + file: File, + onProgress?: (progress: UploadProgress) => void +): Promise { + await axios.put(uploadUrl, file, { + headers: { + 'Content-Type': 'application/octet-stream' + }, + timeout: 0, + onUploadProgress: (progressEvent) => { + if (!onProgress || !progressEvent.total) { + return + } + + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) + }) + } + }) +} diff --git a/src/services/upload-strategy.ts b/src/services/upload-strategy.ts new file mode 100644 index 0000000..933a483 --- /dev/null +++ b/src/services/upload-strategy.ts @@ -0,0 +1,97 @@ +import { FileService } from './file' +import type { ApiResponse, ChunkUploadInitResponse, FileUploadResponse, UploadProgress } from '@/types' +import { calculateFileHash } from '@/utils/file-processing' + +const CHUNK_SIZE = 5 * 1024 * 1024 + +type ChunkedUploadOptions = { + expireValue: number + expireStyle: string + onHashCalculated?: (hash: string) => void + onProgress?: (progress: UploadProgress) => void + messages?: { + initFailed?: string + chunkFailed?: (index: number) => string + completeFailed?: string + } +} + +type ChunkedUploadResult = ChunkUploadInitResponse | FileUploadResponse + +const calculateCompletedBytes = (uploadedChunks: Set, chunkSize: number, fileSize: number) => + Array.from(uploadedChunks).reduce((total, index) => { + const chunkStart = index * chunkSize + const chunkEnd = Math.min((index + 1) * chunkSize, fileSize) + return total + Math.max(0, chunkEnd - chunkStart) + }, 0) + +export const uploadChunkedFile = async ( + file: File, + options: ChunkedUploadOptions +): Promise> => { + const fileHash = await calculateFileHash(file) + options.onHashCalculated?.(fileHash) + + const chunks = Math.ceil(file.size / CHUNK_SIZE) + const initResponse = await FileService.initChunkUpload({ + file_name: file.name, + file_size: file.size, + chunk_size: CHUNK_SIZE, + file_hash: fileHash + }) + + if (initResponse.code !== 200) { + throw new Error(options.messages?.initFailed || 'Init chunk upload failed') + } + + if (initResponse.detail?.existed) { + return initResponse + } + + const initDetail = initResponse.detail + const uploadId = initDetail?.upload_id + if (!uploadId) { + throw new Error(options.messages?.initFailed || 'Init chunk upload failed') + } + + const uploadedChunks = new Set(initDetail.uploaded_chunks || []) + for (let index = 0; index < chunks; index++) { + if (uploadedChunks.has(index)) { + continue + } + + const start = index * CHUNK_SIZE + const end = Math.min(start + CHUNK_SIZE, file.size) + const chunk = file.slice(start, end) + const chunkResponse = await FileService.uploadChunk( + uploadId, + index, + new Blob([chunk], { type: file.type }), + (progress) => { + const completedBytes = calculateCompletedBytes(uploadedChunks, CHUNK_SIZE, file.size) + const percentage = Math.round(((completedBytes + progress.loaded) * 100) / file.size) + options.onProgress?.({ + loaded: completedBytes + progress.loaded, + total: file.size, + percentage: Math.min(percentage, 99) + }) + } + ) + + if (chunkResponse.code !== 200) { + throw new Error(options.messages?.chunkFailed?.(index) || `Chunk upload failed: ${index}`) + } + uploadedChunks.add(index) + } + + const completeResponse = await FileService.completeChunkUpload(uploadId, { + expire_value: options.expireValue, + expire_style: options.expireStyle + }) + + if (completeResponse.code !== 200) { + throw new Error(options.messages?.completeFailed || 'Complete chunk upload failed') + } + + return completeResponse +} diff --git a/src/stores/adminStore.ts b/src/stores/adminStore.ts index 639e9f6..93d2bad 100644 --- a/src/stores/adminStore.ts +++ b/src/stores/adminStore.ts @@ -1,12 +1,18 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { STORAGE_KEYS } from '@/constants' import type { AdminUser } from '@/types' +import { + clearStoredAuth, + readStoredAdminPassword, + readStoredToken, + writeStoredAdminPassword, + writeStoredToken +} from '@/utils/auth-storage' export const useAdminStore = defineStore('admin', () => { // 状态 - const adminPassword = ref(localStorage.getItem(STORAGE_KEYS.ADMIN_PASSWORD) || '') - const token = ref(localStorage.getItem(STORAGE_KEYS.TOKEN) || '') + const adminPassword = ref(readStoredAdminPassword()) + const token = ref(readStoredToken()) const isLoggedIn = ref(false) const userInfo = ref(null) @@ -14,16 +20,17 @@ export const useAdminStore = defineStore('admin', () => { const isAuthenticated = computed(() => { return isLoggedIn.value && !!token.value }) + const hasToken = computed(() => !!token.value) // 方法 const updateAdminPassword = (pwd: string) => { adminPassword.value = pwd - localStorage.setItem(STORAGE_KEYS.ADMIN_PASSWORD, pwd) + writeStoredAdminPassword(pwd) } const setToken = (newToken: string) => { token.value = newToken - localStorage.setItem(STORAGE_KEYS.TOKEN, newToken) + writeStoredToken(newToken) } const setUserInfo = (user: AdminUser) => { @@ -42,13 +49,11 @@ export const useAdminStore = defineStore('admin', () => { isLoggedIn.value = false userInfo.value = null - // 清除本地存储 - localStorage.removeItem(STORAGE_KEYS.ADMIN_PASSWORD) - localStorage.removeItem(STORAGE_KEYS.TOKEN) + clearStoredAuth() } const initAuth = () => { - const storedToken = localStorage.getItem(STORAGE_KEYS.TOKEN) + const storedToken = readStoredToken() if (storedToken) { token.value = storedToken isLoggedIn.value = true @@ -64,6 +69,7 @@ export const useAdminStore = defineStore('admin', () => { // 计算属性 isAuthenticated, + hasToken, // 方法 updateAdminPassword, @@ -74,6 +80,3 @@ export const useAdminStore = defineStore('admin', () => { initAuth } }) - -// 保持向后兼容 -export const useAdminData = useAdminStore diff --git a/src/stores/alertStore.ts b/src/stores/alertStore.ts index 6eb6ff0..d4318c5 100644 --- a/src/stores/alertStore.ts +++ b/src/stores/alertStore.ts @@ -2,6 +2,8 @@ import { defineStore } from 'pinia' import type { Alert, AlertType } from '@/types' import { TIME_CONSTANTS } from '@/constants' +let progressTimer: ReturnType | null = null + export const useAlertStore = defineStore('alert', { state: () => ({ alerts: [] as Alert[] @@ -33,6 +35,25 @@ export const useAlertStore = defineStore('alert', { this.removeAlert(id) } } + }, + startProgressTimer() { + if (progressTimer) { + return + } + + progressTimer = setInterval(() => { + this.alerts.forEach((alert) => { + this.updateAlertProgress(alert.id) + }) + }, TIME_CONSTANTS.PROGRESS_UPDATE_INTERVAL) + }, + stopProgressTimer() { + if (!progressTimer) { + return + } + + clearInterval(progressTimer) + progressTimer = null } } }) diff --git a/src/stores/configStore.ts b/src/stores/configStore.ts new file mode 100644 index 0000000..6cd4d68 --- /dev/null +++ b/src/stores/configStore.ts @@ -0,0 +1,62 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import type { ConfigState } from '@/types' +import { + DEFAULT_PUBLIC_CONFIG, + readNotifyKey, + readStoredConfig, + toPublicConfig, + writeNotifyKey, + writeStoredConfig, + type PublicConfig +} from '@/utils/config-storage' + +export const useConfigStore = defineStore('config', () => { + const config = ref({ + ...DEFAULT_PUBLIC_CONFIG, + ...toPublicConfig(readStoredConfig>()) + }) + + const uploadSizeLimit = computed(() => config.value.uploadSize) + + const updateConfig = (nextConfig: Partial) => { + config.value = { + ...DEFAULT_PUBLIC_CONFIG, + ...config.value, + ...toPublicConfig(nextConfig) + } + writeStoredConfig(config.value) + } + + const applyRemoteConfig = (nextConfig: Partial): string | null => { + updateConfig(nextConfig) + + const { notify_title: notifyTitle, notify_content: notifyContent } = nextConfig + if (!notifyTitle || !notifyContent) { + return null + } + + const notifyKey = notifyTitle + notifyContent + if (readNotifyKey() === notifyKey) { + return null + } + + writeNotifyKey(notifyKey) + return `${notifyTitle}: ${notifyContent}` + } + + const reloadStoredConfig = () => { + config.value = { + ...DEFAULT_PUBLIC_CONFIG, + ...toPublicConfig(readStoredConfig>()) + } + } + + return { + config, + uploadSizeLimit, + applyRemoteConfig, + updateConfig, + reloadStoredConfig + } +}) diff --git a/src/stores/fileData.ts b/src/stores/fileData.ts index a03519c..a4836fe 100644 --- a/src/stores/fileData.ts +++ b/src/stores/fileData.ts @@ -1,197 +1,24 @@ import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import type { FileInfo, UploadProgress, UploadStatus } from '@/types' -import { UPLOAD_STATUS } from '@/constants' +import { ref } from 'vue' +import type { ReceivedFileRecord, SentFileRecord } from '@/types' export const useFileDataStore = defineStore('fileData', () => { - // 上传相关状态 - const uploadStatus = ref(UPLOAD_STATUS.IDLE) - const uploadProgress = ref({ - loaded: 0, - total: 0, - percentage: 0 - }) - const uploadedCode = ref('') - const currentFile = ref(null) - - // 下载相关状态 - const downloadCode = ref('') - const fileInfo = ref(null) - const isDownloading = ref(false) - - // 文件列表状态(管理页面使用) - const fileList = ref([]) - const totalFiles = ref(0) - const currentPage = ref(1) - const pageSize = ref(10) - const isLoadingList = ref(false) - - // 接收数据状态(取件记录) - const receiveData = ref>([]) - - // 分享数据状态(发送记录) - const shareData = ref>([]) - - // 计算属性 - const isUploading = computed(() => uploadStatus.value === UPLOAD_STATUS.UPLOADING) - const isUploadSuccess = computed(() => uploadStatus.value === UPLOAD_STATUS.SUCCESS) - const isUploadError = computed(() => uploadStatus.value === UPLOAD_STATUS.ERROR) - const hasFileInfo = computed(() => fileInfo.value !== null) - const canDownload = computed(() => hasFileInfo.value && !isDownloading.value) - - const totalPages = computed(() => { - return Math.ceil(totalFiles.value / pageSize.value) - }) - - // 上传相关方法 - const setUploadStatus = (status: UploadStatus) => { - uploadStatus.value = status - } - - const setUploadProgress = (progress: UploadProgress) => { - uploadProgress.value = progress - } - - const setUploadedCode = (code: string) => { - uploadedCode.value = code - } - - const setCurrentFile = (file: File | null) => { - currentFile.value = file - } - - const resetUpload = () => { - uploadStatus.value = UPLOAD_STATUS.IDLE - uploadProgress.value = { - loaded: 0, - total: 0, - percentage: 0 - } - uploadedCode.value = '' - currentFile.value = null - } - - // 下载相关方法 - const setDownloadCode = (code: string) => { - downloadCode.value = code - } - - const setFileInfo = (info: FileInfo | null) => { - fileInfo.value = info - } - - const setDownloading = (loading: boolean) => { - isDownloading.value = loading - } - - const resetDownload = () => { - downloadCode.value = '' - fileInfo.value = null - isDownloading.value = false - } - - // 文件列表相关方法 - const setFileList = (files: FileInfo[]) => { - fileList.value = files - } - - const addFile = (file: FileInfo) => { - fileList.value.unshift(file) - totalFiles.value += 1 - } - - const removeFile = (fileId: string) => { - const index = fileList.value.findIndex(file => file.id === fileId) - if (index > -1) { - fileList.value.splice(index, 1) - totalFiles.value -= 1 - } - } - - const updateFile = (fileId: string, updates: Partial) => { - const index = fileList.value.findIndex(file => file.id === fileId) - if (index > -1) { - fileList.value[index] = { ...fileList.value[index], ...updates } - } - } - - const setTotalFiles = (total: number) => { - totalFiles.value = total - } - - const setCurrentPage = (page: number) => { - currentPage.value = page - } - - const setPageSize = (size: number) => { - pageSize.value = size - } - - const setLoadingList = (loading: boolean) => { - isLoadingList.value = loading - } - - const resetFileList = () => { - fileList.value = [] - totalFiles.value = 0 - currentPage.value = 1 - isLoadingList.value = false - } - - // 添加分享数据方法 - const addShareData = (data: { code: string; name?: string }) => { - setUploadedCode(data.code) - if (data.name) { - // 如果有文件名,可以创建一个临时的 FileInfo 对象 - const fileInfo: FileInfo = { - id: data.code, - name: data.name, - size: 0, - type: '', - uploadTime: new Date().toISOString(), - downloadCount: 0 - } - addFile(fileInfo) - } - } + const receiveData = ref([]) + const shareData = ref([]) - // 接收数据相关方法 - const addReceiveData = (data: { - id: number - code: string - filename: string - size: string - downloadUrl: string | null - content: string | null - date: string - }) => { - receiveData.value.push(data) + const addReceiveData = (record: ReceivedFileRecord) => { + receiveData.value.push(record) } const removeReceiveData = (id: number) => { - const index = receiveData.value.findIndex(item => item.id === id) - if (index > -1) { + const index = receiveData.value.findIndex((record) => record.id === id) + if (index !== -1) { receiveData.value.splice(index, 1) } } - + const deleteReceiveData = (index: number) => { - if (index > -1 && index < receiveData.value.length) { + if (index >= 0 && index < receiveData.value.length) { receiveData.value.splice(index, 1) } } @@ -199,90 +26,28 @@ export const useFileDataStore = defineStore('fileData', () => { const clearReceiveData = () => { receiveData.value = [] } - - // 分享数据相关方法 - const addShareDataRecord = (data: { - id: number - filename: string - date: string - size: string - expiration: string - retrieveCode: string - }) => { - shareData.value.push(data) + + const addShareDataRecord = (record: SentFileRecord) => { + shareData.value.push(record) } - + const deleteShareData = (index: number) => { - if (index > -1 && index < shareData.value.length) { + if (index >= 0 && index < shareData.value.length) { shareData.value.splice(index, 1) } } - + const clearShareData = () => { shareData.value = [] } return { - // 上传状态 - uploadStatus, - uploadProgress, - uploadedCode, - currentFile, - - // 下载状态 - downloadCode, - fileInfo, - isDownloading, - - // 文件列表状态 - fileList, - totalFiles, - currentPage, - pageSize, - isLoadingList, - - // 计算属性 - isUploading, - isUploadSuccess, - isUploadError, - hasFileInfo, - canDownload, - totalPages, - - // 上传方法 - setUploadStatus, - setUploadProgress, - setUploadedCode, - setCurrentFile, - resetUpload, - - // 下载方法 - setDownloadCode, - setFileInfo, - setDownloading, - resetDownload, - - // 文件列表方法 - setFileList, - addFile, - removeFile, - updateFile, - setTotalFiles, - setCurrentPage, - setPageSize, - setLoadingList, - resetFileList, - addShareData, - - // 接收数据状态和方法 receiveData, + shareData, addReceiveData, removeReceiveData, deleteReceiveData, clearReceiveData, - - // 分享数据状态和方法 - shareData, addShareDataRecord, deleteShareData, clearShareData diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..3ae1779 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,10 @@ +export interface ApiResponse { + code: number + message?: string + detail?: T +} + +export interface ApiErrorPayload { + detail?: string + message?: string +} diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..8558757 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,5 @@ +export interface AdminUser { + id: string + username: string + token: string +} diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..1272c0d --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,55 @@ +export interface SystemConfig { + name: string + description?: string + maxFileSize: number + allowedFileTypes: string[] + expireDays: number + notify_title?: string + notify_content?: string +} + +export interface ThemeChoice { + key: string + name: string + author: string + version: string +} + +export interface ConfigState { + name: string + description: string + file_storage: string + themesChoices: ThemeChoice[] + expireStyle: string[] + admin_token: string + robotsText: string + keywords: string + notify_title: string + notify_content: string + openUpload: number + uploadSize: number + storage_path: string + uploadMinute: number + max_save_seconds: number + opacity: number + enableChunk: number + s3_access_key_id: string + background: string + showAdminAddr: number + page_explain: string + s3_secret_access_key: string + aws_session_token: string + s3_signature_version: string + s3_region_name: string + s3_bucket_name: string + s3_endpoint_url: string + s3_hostname: string + uploadCount: number + errorMinute: number + errorCount: number + s3_proxy: number + themesSelect: string + webdav_url: string + webdav_username: string + webdav_password: string +} diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts new file mode 100644 index 0000000..5d402d3 --- /dev/null +++ b/src/types/dashboard.ts @@ -0,0 +1,54 @@ +export interface DashboardData { + totalFiles: number + storageUsed: number + yesterdayCount: number + todayCount: number + yesterdaySize: number + todaySize: number + sysUptime: number | null + activeCount: number + expiredCount: number + textCount: number + fileCount: number + chunkedCount: number + usedCount: number + storageBackend: string + uploadSizeLimit: number + openUpload: number + enableChunk: number + maxSaveSeconds: number + topSuffixes: DashboardSuffixStat[] + recentFiles: DashboardRecentFile[] +} + +export interface DashboardSuffixStat { + suffix: string + count: number +} + +export interface DashboardRecentFile { + id: number + code: string + name: string + suffix: string + size: number + text: boolean + expiredAt: string | null + expiredCount: number + usedCount: number + createdAt: string | null + isExpired: boolean +} + +export interface DashboardViewData extends DashboardData { + hasExtendedStats: boolean + storageUsedText: string + yesterdaySizeText: string + todaySizeText: string + uploadSizeLimitText: string + sysUptimeText: string + activeRatio: number + textRatio: number + fileRatio: number + todaySizeRatio: number +} diff --git a/src/types/file.ts b/src/types/file.ts new file mode 100644 index 0000000..4102f0d --- /dev/null +++ b/src/types/file.ts @@ -0,0 +1,107 @@ +export interface FileInfo { + id: string + name: string + size: number + type: string + uploadTime: string + downloadCount: number + expireTime?: string +} + +export interface FileListItem { + id: number + code: string + prefix: string + suffix: string + size: number + text?: string + description?: string + expired_at: string + expired_count: number | null + created_at: string +} + +export interface AdminFileViewItem extends FileListItem { + displaySize: string + displayExpiredAt: string + canPreviewText: boolean +} + +export interface FileEditForm { + id: number | null + code: string + prefix: string + suffix: string + expired_at: string + expired_count: number | null +} + +export interface FileListResponse { + data: FileListItem[] + total: number + page: number + size: number +} + +export interface FileUploadResponse { + code: string + name: string +} + +export interface TextSendResponse { + code: string +} + +export interface ShareSelectResponse { + code: string + name: string + text: string + size: number +} + +export interface ReceivedFileRecord { + id: number + code: string + filename: string + size: string + downloadUrl: string | null + content: string | null + date: string +} + +export interface SentFileRecord { + id: number + filename: string + date: string + size: string + expiration: string + retrieveCode: string +} + +export interface UploadProgress { + loaded: number + total: number + percentage: number +} + +export interface ChunkUploadInitRequest { + file_name: string + file_size: number + chunk_size: number + file_hash: string +} + +export interface ChunkUploadInitResponse { + code?: string + name?: string + upload_id?: string + existed?: boolean + uploaded_chunks?: number[] +} + +export interface ChunkUploadCompleteRequest { + expire_value: number + expire_style: string +} + +export type ChunkUploadResponse = null diff --git a/src/types/index.ts b/src/types/index.ts index 00169ab..9553f6d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,243 +1,7 @@ -// 通用类型定义 -export interface ApiResponse { - code: number - message?: string - detail?: T -} - -// 文件相关类型 -export interface FileInfo { - id: string - name: string - size: number - type: string - uploadTime: string - downloadCount: number - expireTime?: string -} - -// 文件管理相关类型 -export interface FileListItem { - id: number - code: string - prefix: string - suffix: string - size: number - text?: string - description?: string - expired_at: string - expired_count: number | null - created_at: string -} - -export interface FileEditForm { - id: number | null - code: string - prefix: string - suffix: string - expired_at: string - expired_count: number | null -} - -export interface FileListResponse { - data: FileListItem[] - total: number - page: number - size: number -} - -// 文件上传响应类型 -export interface FileUploadResponse { - code: number - name: string -} - -// 文本发送响应类型 -export interface TextSendResponse { - code: number -} - -export interface UploadProgress { - loaded: number - total: number - percentage: number -} - -// 系统配置类型 -export interface SystemConfig { - name: string - description?: string - maxFileSize: number - allowedFileTypes: string[] - expireDays: number - notify_title?: string - notify_content?: string -} - -// 主题选择项类型 -export interface ThemeChoice { - key: string - name: string - author: string - version: string -} - -// 完整的系统配置状态类型 -export interface ConfigState { - name: string - description: string - file_storage: string - themesChoices: ThemeChoice[] - expireStyle: string[] - admin_token: string - robotsText: string - keywords: string - notify_title: string - notify_content: string - openUpload: number - uploadSize: number - storage_path: string - uploadMinute: number - max_save_seconds: number - opacity: number - enableChunk: number - s3_access_key_id: string - background: string - showAdminAddr: number - page_explain: string - s3_secret_access_key: string - aws_session_token: string - s3_signature_version: string - s3_region_name: string - s3_bucket_name: string - s3_endpoint_url: string - s3_hostname: string - uploadCount: number - errorMinute: number - errorCount: number - s3_proxy: number - themesSelect: string - webdav_url: string - webdav_username: string - webdav_password: string -} - -// 系统配置API响应类型 -export interface ConfigResponse { - code: number - message?: string - detail?: ConfigState -} - -// 用户相关类型 -export interface AdminUser { - id: string - username: string - token: string -} - -// Dashboard 数据类型 -export interface DashboardData { - totalFiles: number - storageUsed: number | string - yesterdayCount: number - todayCount: number - yesterdaySize: number | string - todaySize: number | string - sysUptime: number | string -} - -// 主题相关类型 -export type ThemeMode = 'light' | 'dark' | 'system' - -// 发送类型 -export type SendType = 'file' | 'text' - -// 警告类型 -export type AlertType = 'success' | 'error' | 'warning' | 'info' - -export interface Alert { - id: number - message: string - type: AlertType - progress: number - duration: number - startTime: number -} - -// 文件上传状态 -export type UploadStatus = 'idle' | 'uploading' | 'success' | 'error' - -// 路由相关类型 -export interface RouteConfig { - path: string - name: string - component: () => Promise<{ default: object }> - meta?: { - requiresAuth?: boolean - title?: string - } -} - -// ==================== 预签名上传相关类型 ==================== - -// 预签名上传模式 -export type PresignUploadMode = 'direct' | 'proxy' - -// 预签名上传状态 -export type PresignUploadStatus = - | 'idle' - | 'initializing' - | 'uploading' - | 'confirming' - | 'success' - | 'error' - -// 过期类型 -export type ExpireStyle = 'day' | 'hour' | 'minute' | 'forever' | 'count' - -// 初始化上传请求 -export interface PresignInitRequest { - file_name: string - file_size: number - expire_value?: number - expire_style?: ExpireStyle -} - -// 初始化上传响应 -export interface PresignInitResponse { - upload_id: string - upload_url: string - mode: PresignUploadMode - expires_in: number -} - -// 确认上传请求 -export interface PresignConfirmRequest { - expire_value?: number - expire_style?: ExpireStyle -} - -// 上传结果 -export interface PresignUploadResult { - code: string - name: string -} - -// 上传状态查询响应 -export interface PresignStatusResponse { - upload_id: string - file_name: string - file_size: number - mode: PresignUploadMode - created_at: string - expires_at: string - is_expired: boolean -} - -// 上传配置选项 -export interface PresignUploadOptions { - expireValue?: number - expireStyle?: ExpireStyle - onProgress?: (progress: UploadProgress) => void -} +export * from './api' +export * from './auth' +export * from './config' +export * from './dashboard' +export * from './file' +export * from './presign-upload' +export * from './ui' diff --git a/src/types/presign-upload.ts b/src/types/presign-upload.ts new file mode 100644 index 0000000..8f38144 --- /dev/null +++ b/src/types/presign-upload.ts @@ -0,0 +1,53 @@ +import type { UploadProgress } from './file' + +export type PresignUploadMode = 'direct' | 'proxy' + +export type PresignUploadStatus = + | 'idle' + | 'initializing' + | 'uploading' + | 'confirming' + | 'success' + | 'error' + +export type ExpireStyle = 'day' | 'hour' | 'minute' | 'forever' | 'count' + +export interface PresignInitRequest { + file_name: string + file_size: number + expire_value?: number + expire_style?: ExpireStyle +} + +export interface PresignInitResponse { + upload_id: string + upload_url: string + mode: PresignUploadMode + expires_in: number +} + +export interface PresignConfirmRequest { + expire_value?: number + expire_style?: ExpireStyle +} + +export interface PresignUploadResult { + code: string + name: string +} + +export interface PresignStatusResponse { + upload_id: string + file_name: string + file_size: number + mode: PresignUploadMode + created_at: string + expires_at: string + is_expired: boolean +} + +export interface PresignUploadOptions { + expireValue?: number + expireStyle?: ExpireStyle + onProgress?: (progress: UploadProgress) => void +} diff --git a/src/types/ui.ts b/src/types/ui.ts new file mode 100644 index 0000000..8a8e21f --- /dev/null +++ b/src/types/ui.ts @@ -0,0 +1,26 @@ +export type ThemeMode = 'light' | 'dark' | 'system' + +export type SendType = 'file' | 'text' + +export type AlertType = 'success' | 'error' | 'warning' | 'info' + +export interface Alert { + id: number + message: string + type: AlertType + progress: number + duration: number + startTime: number +} + +export type UploadStatus = 'idle' | 'uploading' | 'success' | 'error' + +export interface RouteConfig { + path: string + name: string + component: () => Promise<{ default: object }> + meta?: { + requiresAuth?: boolean + title?: string + } +} diff --git a/src/utils/api.ts b/src/utils/api.ts index 5ccf4e1..f4e76cb 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -1,74 +1,2 @@ -import axios from 'axios' -import { TIME_CONSTANTS } from '@/constants' - -// 从环境变量中获取 API 基础 URL -const baseURL = - import.meta.env.MODE === 'production' - ? import.meta.env.VITE_API_BASE_URL_PROD - : import.meta.env.VITE_API_BASE_URL_DEV -// 确保 baseURL 是一个有效的字符串 -const sanitizedBaseURL = typeof baseURL === 'string' ? baseURL : '' - -// 创建 axios 实例 -const api = axios.create({ - baseURL: sanitizedBaseURL, - timeout: TIME_CONSTANTS.REQUEST_TIMEOUT, // 30秒超时 - headers: { - 'Content-Type': 'application/json' - } -}) - -// 请求拦截器 -api.interceptors.request.use( - (config) => { - // 从 localStorage 获取 token - const token = localStorage.getItem('token') - if (token) { - config.headers['Authorization'] = `Bearer ${token}` - } - - // 确保 URL 是有效的 - if (config.url && !config.url.startsWith('http')) { - config.url = `${sanitizedBaseURL}/${config.url.replace(/^\//, '')}` - } - - return config - }, - (error) => { - return Promise.reject(error) - } -) -// 响应拦截器 -api.interceptors.response.use( - (response) => { - return response.data - }, - (error) => { - // 处理错误响应 - if (error.response) { - const { status } = error.response - switch (status) { - case 401: - localStorage.removeItem('token') - // 使用 router 进行导航而不是直接修改 location - if (window.location.hash !== '#/login') { - window.location.href = '/#/login' - } - break - case 403: - case 404: - case 500: - default: - // 错误信息通过Promise.reject传递给调用方处理 - break - } - } else if (error.request) { - // 网络错误,通过Promise.reject传递给调用方处理 - } else { - // 请求配置错误,通过Promise.reject传递给调用方处理 - } - return Promise.reject(error) - } -) - -export default api +export { apiBaseURL, rawApiClient } from '@/services/client' +export { default } from '@/services/client' diff --git a/src/utils/auth-storage.ts b/src/utils/auth-storage.ts new file mode 100644 index 0000000..ff59afb --- /dev/null +++ b/src/utils/auth-storage.ts @@ -0,0 +1,26 @@ +import { STORAGE_KEYS } from '@/constants' + +export function readStoredAdminPassword(): string { + return localStorage.getItem(STORAGE_KEYS.ADMIN_PASSWORD) || '' +} + +export function writeStoredAdminPassword(password: string) { + localStorage.setItem(STORAGE_KEYS.ADMIN_PASSWORD, password) +} + +export function readStoredToken(): string { + return localStorage.getItem(STORAGE_KEYS.TOKEN) || '' +} + +export function writeStoredToken(token: string) { + localStorage.setItem(STORAGE_KEYS.TOKEN, token) +} + +export function clearStoredAuth() { + localStorage.removeItem(STORAGE_KEYS.ADMIN_PASSWORD) + localStorage.removeItem(STORAGE_KEYS.TOKEN) +} + +export function clearStoredToken() { + localStorage.removeItem(STORAGE_KEYS.TOKEN) +} diff --git a/src/utils/clipboard-paste.ts b/src/utils/clipboard-paste.ts new file mode 100644 index 0000000..4a44e0a --- /dev/null +++ b/src/utils/clipboard-paste.ts @@ -0,0 +1,40 @@ +export const getClipboardFile = (items: DataTransferItemList): File | null => { + for (let index = 0; index < items.length; index++) { + const item = items[index] + if (item.kind !== 'file') { + continue + } + + const file = item.getAsFile() + if (file) { + return file + } + } + return null +} + +export type TextInsertionInput = { + text: string + insertText: string + selectionStart: number + selectionEnd: number +} + +export type TextInsertionResult = { + value: string + cursor: number +} + +export const insertTextAtSelection = ({ + text, + insertText, + selectionStart, + selectionEnd +}: TextInsertionInput): TextInsertionResult => { + const beforeSelection = text.substring(0, selectionStart) + const afterSelection = text.substring(selectionEnd) + return { + value: beforeSelection + insertText + afterSelection, + cursor: selectionStart + insertText.length + } +} diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index ddea5dd..1f38d21 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -2,11 +2,15 @@ * 剪贴板工具函数 */ -import { useAlertStore } from '@/stores/alertStore' +import { buildRetrieveUrl, buildWgetCommand } from '@/utils/share-url' + +type CopyNotifyType = 'success' | 'error' + interface CopyOptions { successMsg?: string errorMsg?: string showMsg?: boolean + notify?: (message: string, type: CopyNotifyType) => void } /** @@ -19,13 +23,24 @@ export const copyToClipboard = async ( text: string, options: CopyOptions = {} ): Promise => { - const { successMsg = '复制成功', errorMsg = '复制失败,请手动复制', showMsg = true } = options - const alertStore = useAlertStore() + const { + successMsg = '复制成功', + errorMsg = '复制失败,请手动复制', + showMsg = true, + notify + } = options + + const showCopyMessage = (message: string, type: CopyNotifyType) => { + if (showMsg) { + notify?.(message, type) + } + } + try { // 优先使用 Clipboard API if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text) - if (showMsg) alertStore.showAlert(successMsg, 'success') + showCopyMessage(successMsg, 'success') return true } // 后备方案:使用传统的复制方法 @@ -38,14 +53,14 @@ export const copyToClipboard = async ( const success = document.execCommand('copy') document.body.removeChild(textarea) if (success) { - if (showMsg) alertStore.showAlert(successMsg, 'success') + showCopyMessage(successMsg, 'success') return true } else { throw new Error('execCommand copy failed') } } catch (err) { console.error('复制失败:', err) - if (showMsg) alertStore.showAlert(errorMsg, 'error') + showCopyMessage(errorMsg, 'error') return false } } @@ -55,11 +70,15 @@ export const copyToClipboard = async ( * @param code 取件码 * @returns Promise 是否复制成功 */ -export const copyRetrieveLink = async (code: string): Promise => { - const link = `${window.location.origin}/#/?code=${code}` +export const copyRetrieveLink = async ( + code: string, + options: Pick = {} +): Promise => { + const link = buildRetrieveUrl(code) return copyToClipboard(link, { successMsg: '取件链接已复制到剪贴板', - errorMsg: '复制失败,请手动复制取件链接' + errorMsg: '复制失败,请手动复制取件链接', + ...options }) } @@ -68,50 +87,26 @@ export const copyRetrieveLink = async (code: string): Promise => { * @param code 取件码 * @returns Promise 是否复制成功 */ -export const copyRetrieveCode = async (code: string): Promise => { +export const copyRetrieveCode = async ( + code: string, + options: Pick = {} +): Promise => { return copyToClipboard(code, { successMsg: '取件码已复制到剪贴板', - errorMsg: '复制失败,请手动复制取件码' + errorMsg: '复制失败,请手动复制取件码', + ...options }) } -const baseUrl = window.location.origin + '/' - -export const copyWgetCommand = (retrieveCode: string, fileName: string) => { - const command = `wget ${baseUrl}share/select?code=${retrieveCode} -O "${fileName}"` - - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard - .writeText(command) - .then(() => { - console.log('命令已复制到剪贴板!') - }) - .catch((err) => { - console.error('复制失败,使用回退方法:', err) - fallbackCopyTextToClipboard(command) - }) - } else { - console.warn('Clipboard API 不可用,使用回退方法。') - fallbackCopyTextToClipboard(command) - } -} -function fallbackCopyTextToClipboard(command: string) { - const textArea = document.createElement('textarea') - textArea.value = command - textArea.style.position = 'fixed' // 避免滚动 - document.body.appendChild(textArea) - textArea.focus() - textArea.select() - try { - const successful = document.execCommand('copy') - console.log('回退复制操作成功:', successful) - if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(command) - } else { - console.error('回退复制操作失败') - } - } catch (err) { - console.error('回退复制操作失败:', err) - } - document.body.removeChild(textArea) +export const copyWgetCommand = ( + retrieveCode: string, + fileName: string, + options: Pick = {} +) => { + const command = buildWgetCommand(retrieveCode, fileName) + void copyToClipboard(command, { + successMsg: '命令已复制到剪贴板', + errorMsg: '复制失败,请手动复制命令', + ...options + }) } diff --git a/src/utils/common.ts b/src/utils/common.ts index cd5c150..f818fe6 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,6 +1,7 @@ /** * 通用工具函数 */ +import type { ApiErrorPayload } from '@/types' /** * 格式化时间戳为可读格式 @@ -74,36 +75,6 @@ export function formatDuration(seconds: number, t?: (key: string) => string): st return `${seconds}${secondName}` } -/** - * 复制文本到剪贴板 - * @param text 要复制的文本 - * @returns Promise 是否复制成功 - */ -export async function copyToClipboard(text: string): Promise { - try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text) - return true - } else { - // 降级方案 - const textArea = document.createElement('textarea') - textArea.value = text - textArea.style.position = 'fixed' - textArea.style.left = '-999999px' - textArea.style.top = '-999999px' - document.body.appendChild(textArea) - textArea.focus() - textArea.select() - const result = document.execCommand('copy') - textArea.remove() - return result - } - } catch (error) { - console.error('Copy failed:', error) - return false - } -} - /** * 防抖函数 * @param func 要防抖的函数 @@ -241,4 +212,25 @@ export function isMobile(): boolean { */ export function formatNumber(num: number): string { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') -} \ No newline at end of file +} + +type ErrorWithResponse = { + response?: { + data?: ApiErrorPayload + } + message?: string +} + +export function getErrorMessage(error: unknown, fallback: string): string { + if (!error || typeof error !== 'object') { + return fallback + } + + const errorWithResponse = error as ErrorWithResponse + return ( + errorWithResponse.response?.data?.detail || + errorWithResponse.response?.data?.message || + errorWithResponse.message || + fallback + ) +} diff --git a/src/utils/config-form.ts b/src/utils/config-form.ts new file mode 100644 index 0000000..4f14636 --- /dev/null +++ b/src/utils/config-form.ts @@ -0,0 +1,107 @@ +import type { ConfigState } from '@/types' + +export type FileSizeUnit = 'KB' | 'MB' | 'GB' +export type SaveTimeUnit = '秒' | '分' | '时' | '天' + +export type FileSizeForm = { + value: number + unit: FileSizeUnit +} + +export type SaveTimeForm = { + value: number + unit: SaveTimeUnit +} + +const FILE_SIZE_UNITS: Record = { + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024 +} + +const SAVE_TIME_UNITS: Record = { + 秒: 1, + 分: 60, + 时: 3600, + 天: 86400 +} + +export function bytesToFileSizeForm(bytes: number): FileSizeForm { + if (bytes >= FILE_SIZE_UNITS.GB) { + return { + value: Math.round(bytes / FILE_SIZE_UNITS.GB), + unit: 'GB' + } + } + + if (bytes >= FILE_SIZE_UNITS.MB) { + return { + value: Math.round(bytes / FILE_SIZE_UNITS.MB), + unit: 'MB' + } + } + + return { + value: Math.round(bytes / FILE_SIZE_UNITS.KB), + unit: 'KB' + } +} + +export function fileSizeFormToBytes(value: number, unit: FileSizeUnit): number { + return value * FILE_SIZE_UNITS[unit] +} + +export function secondsToSaveTimeForm(seconds: number): SaveTimeForm { + if (seconds === 0) { + return { + value: 7, + unit: '天' + } + } + + if (seconds % SAVE_TIME_UNITS.天 === 0 && seconds >= SAVE_TIME_UNITS.天) { + return { + value: seconds / SAVE_TIME_UNITS.天, + unit: '天' + } + } + + if (seconds % SAVE_TIME_UNITS.时 === 0 && seconds >= SAVE_TIME_UNITS.时) { + return { + value: seconds / SAVE_TIME_UNITS.时, + unit: '时' + } + } + + if (seconds % SAVE_TIME_UNITS.分 === 0 && seconds >= SAVE_TIME_UNITS.分) { + return { + value: seconds / SAVE_TIME_UNITS.分, + unit: '分' + } + } + + return { + value: seconds, + unit: '秒' + } +} + +export function saveTimeFormToSeconds(value: number, unit: SaveTimeUnit): number { + if (value === 0) { + return 7 * SAVE_TIME_UNITS.天 + } + + return value * SAVE_TIME_UNITS[unit] +} + +export function buildConfigSubmitPayload( + config: ConfigState, + fileSize: FileSizeForm, + saveTime: SaveTimeForm +): ConfigState { + return { + ...config, + uploadSize: fileSizeFormToBytes(fileSize.value, fileSize.unit), + max_save_seconds: saveTimeFormToSeconds(saveTime.value, saveTime.unit) + } +} diff --git a/src/utils/config-storage.ts b/src/utils/config-storage.ts new file mode 100644 index 0000000..2c03801 --- /dev/null +++ b/src/utils/config-storage.ts @@ -0,0 +1,107 @@ +import { DEFAULT_CONFIG, FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants' +import type { ConfigState, SystemConfig } from '@/types' + +export type PublicConfig = SystemConfig & { + uploadSize: number + expireStyle: string[] + openUpload: number + max_save_seconds: number + enableChunk: number + notify_title?: string + notify_content?: string + page_explain?: string + showAdminAddr?: number + themesSelect?: string + background?: string + opacity?: number +} + +export const DEFAULT_PUBLIC_CONFIG: PublicConfig = { + ...DEFAULT_CONFIG, + uploadSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE, + expireStyle: ['day'], + openUpload: 1, + max_save_seconds: 0, + enableChunk: 0 +} + +export const DEFAULT_CONFIG_STATE: ConfigState = { + name: DEFAULT_PUBLIC_CONFIG.name, + description: DEFAULT_PUBLIC_CONFIG.description || '', + file_storage: '', + themesChoices: [], + expireStyle: DEFAULT_PUBLIC_CONFIG.expireStyle, + admin_token: '', + robotsText: '', + keywords: '', + notify_title: '', + notify_content: '', + openUpload: DEFAULT_PUBLIC_CONFIG.openUpload, + uploadSize: DEFAULT_PUBLIC_CONFIG.uploadSize, + storage_path: '', + uploadMinute: 1, + max_save_seconds: DEFAULT_PUBLIC_CONFIG.max_save_seconds, + opacity: 0.9, + enableChunk: DEFAULT_PUBLIC_CONFIG.enableChunk, + s3_access_key_id: '', + background: '', + showAdminAddr: 0, + page_explain: '', + s3_secret_access_key: '', + aws_session_token: '', + s3_signature_version: '', + s3_region_name: '', + s3_bucket_name: '', + s3_endpoint_url: '', + s3_hostname: '', + uploadCount: 1, + errorMinute: 1, + errorCount: 1, + s3_proxy: 0, + themesSelect: '', + webdav_url: '', + webdav_username: '', + webdav_password: '' +} + +export function readStoredConfig>(): T | null { + try { + const rawConfig = localStorage.getItem(STORAGE_KEYS.CONFIG) + return rawConfig ? (JSON.parse(rawConfig) as T) : null + } catch { + return null + } +} + +export function toPublicConfig(config: Partial | null | undefined): Partial { + if (!config) return {} + + return { + name: config.name, + description: config.description, + uploadSize: config.uploadSize, + expireStyle: config.expireStyle, + openUpload: config.openUpload, + max_save_seconds: config.max_save_seconds, + enableChunk: config.enableChunk, + notify_title: config.notify_title, + notify_content: config.notify_content, + page_explain: config.page_explain, + showAdminAddr: config.showAdminAddr, + themesSelect: config.themesSelect, + background: config.background, + opacity: config.opacity + } +} + +export function writeStoredConfig(config: object) { + localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(toPublicConfig(config as Partial))) +} + +export function readNotifyKey(): string | null { + return localStorage.getItem(STORAGE_KEYS.NOTIFY) +} + +export function writeNotifyKey(notifyKey: string) { + localStorage.setItem(STORAGE_KEYS.NOTIFY, notifyKey) +} diff --git a/src/utils/content-preview.ts b/src/utils/content-preview.ts new file mode 100644 index 0000000..7d172b8 --- /dev/null +++ b/src/utils/content-preview.ts @@ -0,0 +1,41 @@ +import { marked } from 'marked' +import DOMPurify from 'dompurify' + +const MARKDOWN_ALLOWED_TAGS = [ + 'p', + 'br', + 'strong', + 'em', + 'u', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'blockquote', + 'code', + 'pre', + 'a', + 'img' +] + +const MARKDOWN_ALLOWED_ATTR = ['href', 'src', 'alt', 'title', 'class'] + +export async function renderMarkdownPreview(content: string): Promise { + try { + const rawHtml = await marked(content) + return DOMPurify.sanitize(rawHtml, { + ALLOWED_TAGS: MARKDOWN_ALLOWED_TAGS, + ALLOWED_ATTR: MARKDOWN_ALLOWED_ATTR, + ALLOWED_URI_REGEXP: + /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i + }) + } catch (error) { + console.error('Markdown 渲染失败:', error) + return content + } +} diff --git a/src/utils/download-action.ts b/src/utils/download-action.ts new file mode 100644 index 0000000..be34bef --- /dev/null +++ b/src/utils/download-action.ts @@ -0,0 +1,15 @@ +import { saveAs } from 'file-saver' +import type { ReceivedFileRecord } from '@/types' +import { buildDownloadUrl } from '@/utils/share-url' + +export function downloadReceivedRecord(record: ReceivedFileRecord): void { + if (record.downloadUrl) { + window.open(buildDownloadUrl(record.downloadUrl), '_blank') + return + } + + if (record.content) { + const blob = new Blob([record.content], { type: 'text/plain;charset=utf-8' }) + saveAs(blob, `${record.filename}.txt`) + } +} diff --git a/src/utils/file-processing.ts b/src/utils/file-processing.ts new file mode 100644 index 0000000..ba91f76 --- /dev/null +++ b/src/utils/file-processing.ts @@ -0,0 +1,60 @@ +import JSZip from 'jszip' + +const SMALL_FILE_HASH_LIMIT = 10 * 1024 * 1024 +const LARGE_FILE_HASH_CHUNK_SIZE = 5 * 1024 * 1024 + +const generateFallbackHash = (file: File): string => { + const fileInfo = `${file.name}-${file.size}-${file.lastModified}` + let hash = 0 + for (let i = 0; i < fileInfo.length; i++) { + const char = fileInfo.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash + } + return Math.abs(hash).toString(16).padStart(64, '0') +} + +const createSha256Hash = async (data: BufferSource): Promise => { + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export const calculateFileHash = async (file: File): Promise => { + try { + if (file.size <= SMALL_FILE_HASH_LIMIT) { + const buffer = await file.arrayBuffer() + return window.isSecureContext ? createSha256Hash(buffer) : generateFallbackHash(file) + } + + const firstChunk = file.slice(0, LARGE_FILE_HASH_CHUNK_SIZE) + const lastChunk = file.slice(-LARGE_FILE_HASH_CHUNK_SIZE) + 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) + + return window.isSecureContext ? createSha256Hash(combined) : generateFallbackHash(file) + } catch (error) { + console.error('File hash calculation failed:', error) + return generateFallbackHash(file) + } +} + +export const packFilesAsZip = async (files: File[]): Promise => { + const zip = new JSZip() + for (const file of files) { + zip.file(file.name, file) + } + const blob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }) + return new File([blob], `files_${Date.now()}.zip`, { type: 'application/zip' }) +} diff --git a/src/utils/preference-storage.ts b/src/utils/preference-storage.ts new file mode 100644 index 0000000..6582123 --- /dev/null +++ b/src/utils/preference-storage.ts @@ -0,0 +1,20 @@ +import { STORAGE_KEYS } from '@/constants' +import type { ThemeMode } from '@/types' + +const LOCALE_STORAGE_KEY = 'locale' + +export function readStoredThemeMode(): string | null { + return localStorage.getItem(STORAGE_KEYS.COLOR_MODE) +} + +export function writeStoredThemeMode(mode: ThemeMode) { + localStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode) +} + +export function readStoredLocale(): string | null { + return localStorage.getItem(LOCALE_STORAGE_KEY) +} + +export function writeStoredLocale(locale: string) { + localStorage.setItem(LOCALE_STORAGE_KEY, locale) +} diff --git a/src/utils/send-record.ts b/src/utils/send-record.ts new file mode 100644 index 0000000..00fe046 --- /dev/null +++ b/src/utils/send-record.ts @@ -0,0 +1,99 @@ +import type { ApiResponse, SendType, SentFileRecord } from '@/types' + +type Translate = (key: string, params?: Record) => string + +type BuildSentRecordInput = { + response: ApiResponse + sendType: SendType + textContent: string + selectedFile: File | null + selectedFiles: File[] + expirationMethod: string + expirationValue: string + translate: Translate + getUnit: (method: string) => string +} + +const expirationSecondsByMethod: Record = { + minute: 60, + hour: 3600, + day: 86400 +} + +export function isExpirationWithinLimit( + method: string, + value: string, + maxSaveSeconds: number +): boolean { + if (method === 'forever' || method === 'count') return true + if (maxSaveSeconds === 0) return true + + const multiplier = expirationSecondsByMethod[method] + if (!multiplier) return false + + return parseInt(value) * multiplier <= maxSaveSeconds +} + +export function formatExpirationTime( + method: string, + value: string, + translate: Translate, + getUnit: (method: string) => string +): string { + if (method === 'forever') return translate('send.expiration.units.forever') + if (method === 'count') return translate('send.messages.expiresAfterCount', { count: value }) + + const now = new Date() + const expireValue = parseInt(value) + + switch (method) { + case 'minute': + now.setMinutes(now.getMinutes() + expireValue) + break + case 'hour': + now.setHours(now.getHours() + expireValue) + break + case 'day': + now.setDate(now.getDate() + expireValue) + break + default: + return translate('send.messages.expiresAfter', { value, unit: getUnit(method) }) + } + + const year = now.getFullYear() + const month = (now.getMonth() + 1).toString().padStart(2, '0') + const day = now.getDate().toString().padStart(2, '0') + const hours = now.getHours().toString().padStart(2, '0') + const minutes = now.getMinutes().toString().padStart(2, '0') + return translate('send.messages.expiresAt', { date: `${year}-${month}-${day} ${hours}:${minutes}` }) +} + +export function buildSentRecord(input: BuildSentRecordInput): SentFileRecord { + const retrieveCode = (input.response.detail as { code?: string } | undefined)?.code || '' + const fileName = (input.response.detail as { name?: string } | undefined)?.name || '' + + const totalSelectedSize = input.selectedFiles.reduce((total, file) => total + file.size, 0) + const displaySize = + input.sendType === 'text' + ? `${(input.textContent.length / 1024).toFixed(2)} KB` + : input.selectedFiles.length > 0 + ? `${(totalSelectedSize / (1024 * 1024)).toFixed(1)} MB` + : `${((input.selectedFile?.size || 0) / (1024 * 1024)).toFixed(1)} MB` + + return { + id: Date.now(), + filename: fileName, + date: new Date().toISOString().split('T')[0], + size: displaySize, + expiration: + input.expirationMethod === 'forever' + ? input.translate('send.expiration.forever') + : formatExpirationTime( + input.expirationMethod, + input.expirationValue, + input.translate, + input.getUnit + ), + retrieveCode + } +} diff --git a/src/utils/sent-record-actions.ts b/src/utils/sent-record-actions.ts new file mode 100644 index 0000000..944cbb0 --- /dev/null +++ b/src/utils/sent-record-actions.ts @@ -0,0 +1,17 @@ +import type { SentFileRecord } from '@/types' +import { copyRetrieveCode, copyRetrieveLink, copyWgetCommand } from '@/utils/clipboard' +import { buildSentRecordQrValue } from '@/utils/share-url' + +type CopyNotify = (message: string, type: 'success' | 'error') => void + +export function createSentRecordActions(notify: CopyNotify) { + return { + copyLink: (record: SentFileRecord) => + copyRetrieveLink(record.retrieveCode, { notify }), + copyCode: (record: SentFileRecord) => + copyRetrieveCode(record.retrieveCode, { notify }), + copyWgetCommand: (record: SentFileRecord) => + copyWgetCommand(record.retrieveCode, record.filename, { notify }), + getQRCodeValue: (record: SentFileRecord) => buildSentRecordQrValue(record) + } +} diff --git a/src/utils/share-url.ts b/src/utils/share-url.ts new file mode 100644 index 0000000..b361ab5 --- /dev/null +++ b/src/utils/share-url.ts @@ -0,0 +1,38 @@ +import { apiBaseURL } from '@/services/client' + +const getApiOrigin = () => { + if (!apiBaseURL) return window.location.origin + return new URL(apiBaseURL, window.location.origin).origin +} + +export function buildAbsoluteUrl(path: string): string { + if (/^https?:\/\//i.test(path)) { + return path + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${getApiOrigin()}${normalizedPath}` +} + +export function buildRetrieveUrl(code: string): string { + return `${window.location.origin}/#/?code=${code}` +} + +export function buildDownloadUrl(downloadUrl: string | null): string { + return downloadUrl ? buildAbsoluteUrl(downloadUrl) : '' +} + +export function buildReceivedRecordQrValue(record: { + code: string + downloadUrl: string | null +}): string { + return record.downloadUrl ? buildDownloadUrl(record.downloadUrl) : buildRetrieveUrl(record.code) +} + +export function buildSentRecordQrValue(record: { retrieveCode: string }): string { + return buildRetrieveUrl(record.retrieveCode) +} + +export function buildWgetCommand(retrieveCode: string, fileName: string): string { + return `wget ${buildAbsoluteUrl(`/share/select?code=${retrieveCode}`)} -O "${fileName}"` +} diff --git a/src/views/RetrievewFileView.vue b/src/views/RetrievewFileView.vue index 49f7c69..9430646 100644 --- a/src/views/RetrievewFileView.vue +++ b/src/views/RetrievewFileView.vue @@ -42,24 +42,23 @@
- - diff --git a/src/views/SendFileView.vue b/src/views/SendFileView.vue index 7d0e9c0..a65be2c 100644 --- a/src/views/SendFileView.vue +++ b/src/views/SendFileView.vue @@ -15,7 +15,7 @@
@@ -25,7 +25,7 @@ :selected-file="selectedFile" :selected-files="selectedFiles" :progress="uploadProgress" - :description="`支持各种常见格式,最大${getStorageUnit(config.uploadSize)}`" + :description="uploadDescription" @file-selected="handleFileSelected" @files-selected="handleFilesSelected" @file-drop="handleFileDrop" @@ -35,135 +35,11 @@ - -
- -
-
- - -
- - - -
-
-
-
+ - -
- -
-
- -
-
-

- {{ record.filename ? record.filename : 'Text' }} -

-

- {{ record.date }} · {{ record.size }} -

-
-
- - - -
-
-
-
- - - - - -
-
- -
-
-

- {{ t('send.fileDetails') }} -

- -
-
- - -
- -
-
-
- -
-
-

- {{ selectedRecord.filename }} -

-

- {{ selectedRecord.size }} · {{ selectedRecord.date }} -

-
-
-
-
- - - {{ selectedRecord.expiration }} - -
-
- - - 安全加密 - -
-
-
- - -
- -
-
-
-

取件码

- -
-

- {{ selectedRecord.retrieveCode }} -

-
- -
-
-

- - wget下载 -

- -
-

- 点击复制wget命令 -

-
-
- - -
-
- -
-

- 扫描二维码快速取件 -

-
-
-
- - -
- -
-
-
-
+ + + + + From bef0d2ce3f3d6fd3d1ddb7e9c74f9b04fcb26c25 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 02:38:29 +0800 Subject: [PATCH 08/58] fix: localize upload status text --- src/components/common/FileUploadArea.vue | 15 ++++++++------- src/composables/useSendFlow.ts | 6 ++++-- src/i18n/locales/en-US.ts | 11 ++++++++++- src/i18n/locales/zh-CN.ts | 11 ++++++++++- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/components/common/FileUploadArea.vue b/src/components/common/FileUploadArea.vue index 40d95bf..cff37d5 100644 --- a/src/components/common/FileUploadArea.vue +++ b/src/components/common/FileUploadArea.vue @@ -119,7 +119,7 @@ : 'bg-indigo-500 hover:bg-indigo-600 text-white' ]" > - {{ retryText }} + {{ retryLabel }} @@ -177,7 +177,7 @@ const props = withDefaults(defineProps(), { totalBytes: 0, errorMessage: '', allowRetry: true, - retryText: '重试', + retryText: '', showProgressDetails: true }) @@ -188,6 +188,7 @@ const isDarkMode = useInjectedDarkMode() // 使用computed属性处理多语言文本 const placeholderText = computed(() => props.placeholder || t('send.uploadArea.placeholder')) const descriptionText = computed(() => props.description || t('send.uploadArea.description')) +const retryLabel = computed(() => props.retryText || t('send.uploadArea.retry')) const fileInput = ref(null) const isDragActive = ref(false) @@ -204,7 +205,7 @@ const displayText = computed(() => { return props.selectedFiles[0].name } if (props.selectedFiles && props.selectedFiles.length > 1) { - return `已选择 ${props.selectedFiles.length} 个文件` + return t('send.uploadArea.selectedFiles', { count: props.selectedFiles.length }) } if (props.selectedFile) { return props.selectedFile.name @@ -249,16 +250,16 @@ const statusDescription = computed(() => { return props.errorMessage } if (props.uploadStatus === 'initializing') { - return '正在初始化上传...' + return t('send.uploadArea.status.initializing') } if (props.uploadStatus === 'uploading') { - return '正在上传文件...' + return t('send.uploadArea.status.uploading') } if (props.uploadStatus === 'confirming') { - return '正在确认上传...' + return t('send.uploadArea.status.confirming') } if (isSuccess.value) { - return '上传成功!' + return t('send.uploadArea.status.success') } return descriptionText.value }) diff --git a/src/composables/useSendFlow.ts b/src/composables/useSendFlow.ts index a798816..3f6a6b8 100644 --- a/src/composables/useSendFlow.ts +++ b/src/composables/useSendFlow.ts @@ -32,8 +32,10 @@ export function useSendFlow() { const isSubmitting = ref(false) const fileHash = ref('') const sendRecords = computed(() => fileDataStore.shareData) - const uploadDescription = computed( - () => `支持各种常见格式,最大${getStorageUnit(config.value.uploadSize)}` + const uploadDescription = computed(() => + t('send.uploadArea.descriptionWithLimit', { + size: getStorageUnit(config.value.uploadSize) + }) ) const expirationOptions = computed(() => config.value.expireStyle.map((value) => ({ diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 8f91b08..39150eb 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -215,7 +215,16 @@ export default { clickText: 'click to select files', textInput: 'Enter text to send here...', placeholder: 'Click or drag files here to upload', - description: 'Supports various common formats' + description: 'Supports various common formats', + descriptionWithLimit: 'Supports common formats, up to {size}', + retry: 'Retry', + selectedFiles: '{count} files selected', + status: { + initializing: 'Preparing upload...', + uploading: 'Uploading files...', + confirming: 'Confirming upload...', + success: 'Upload complete!' + } }, submit: 'Secure Send', submitting: 'Sending...', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 8ff63c0..680b1ca 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -217,7 +217,16 @@ export default { clickText: '点击选择文件', textInput: '在此输入要发送的文本...', placeholder: '点击或拖放文件到此处上传', - description: '支持各种常见格式' + description: '支持各种常见格式', + descriptionWithLimit: '支持各种常见格式,最大{size}', + retry: '重试', + selectedFiles: '已选择 {count} 个文件', + status: { + initializing: '正在初始化上传...', + uploading: '正在上传文件...', + confirming: '正在确认上传...', + success: '上传成功!' + } }, submit: '安全寄送', submitting: '发送中...', From 32cbc10cd8d7eab845422c2ad4f3f59c21636445 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 02:39:30 +0800 Subject: [PATCH 09/58] chore: ignore local verification output --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8ee54e8..2d29e0a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ node_modules dist dist-ssr coverage +output *.local /cypress/videos/ From c6f33096be936cb092408dd95e1583d2736b693b Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 02:52:38 +0800 Subject: [PATCH 10/58] feat: connect admin session and public config endpoints --- src/composables/index.ts | 1 + src/composables/useAdminLogin.ts | 8 +++++- src/composables/useAdminSession.ts | 35 ++++++++++++++++++++++++++ src/layout/AdminLayout/AdminLayout.vue | 15 +++++++---- src/services/config.ts | 28 ++++++++++++++++++++- src/types/api.ts | 1 + src/types/auth.ts | 2 ++ 7 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 src/composables/useAdminSession.ts diff --git a/src/composables/index.ts b/src/composables/index.ts index 5086761..c894854 100644 --- a/src/composables/index.ts +++ b/src/composables/index.ts @@ -1,5 +1,6 @@ export { useAdminFiles } from './useAdminFiles' export { useAdminLogin } from './useAdminLogin' +export { useAdminSession } from './useAdminSession' export { useAppShell } from './useAppShell' export { useDashboardStats } from './useDashboardStats' export { useInjectedDarkMode } from './useInjectedDarkMode' diff --git a/src/composables/useAdminLogin.ts b/src/composables/useAdminLogin.ts index 7c81627..ce6a219 100644 --- a/src/composables/useAdminLogin.ts +++ b/src/composables/useAdminLogin.ts @@ -35,7 +35,13 @@ export function useAdminLogin() { return false } - adminStore.setToken(response.detail.token) + adminStore.login({ + id: response.detail.id || 'admin', + username: response.detail.username || 'admin', + token: response.detail.token, + token_type: response.detail.token_type, + expires_at: response.detail.expires_at + }) return true } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, '登录失败'), 'error') diff --git a/src/composables/useAdminSession.ts b/src/composables/useAdminSession.ts new file mode 100644 index 0000000..9333146 --- /dev/null +++ b/src/composables/useAdminSession.ts @@ -0,0 +1,35 @@ +import { AuthService } from '@/services' +import { useAdminStore } from '@/stores/adminStore' + +export function useAdminSession() { + const adminStore = useAdminStore() + + const verifySession = async () => { + if (!adminStore.hasToken) return false + + try { + const response = await AuthService.verifyToken() + if (response.code === 200 && response.detail?.token) { + adminStore.login(response.detail) + return true + } + } catch { + adminStore.logout() + } + + return false + } + + const logout = async () => { + try { + await AuthService.logout() + } finally { + adminStore.logout() + } + } + + return { + verifySession, + logout + } +} diff --git a/src/layout/AdminLayout/AdminLayout.vue b/src/layout/AdminLayout/AdminLayout.vue index 6eb588f..24c3a7d 100644 --- a/src/layout/AdminLayout/AdminLayout.vue +++ b/src/layout/AdminLayout/AdminLayout.vue @@ -120,7 +120,7 @@ import { import { RouterLink, useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { ROUTE_NAMES, ROUTES } from '@/constants' -import { useAdminStore } from '@/stores/adminStore' +import { useAdminSession } from '@/composables' interface MenuItem { id: string @@ -133,7 +133,7 @@ const router = useRouter() const route = useRoute() const { t } = useI18n() const isDarkMode = inject('isDarkMode') -const adminStore = useAdminStore() +const { verifySession, logout } = useAdminSession() const menuItems: MenuItem[] = [ { id: ROUTE_NAMES.DASHBOARD, @@ -172,6 +172,11 @@ const handleResize = () => { onMounted(() => { handleResize() window.addEventListener('resize', handleResize) + void verifySession().then((isValid) => { + if (!isValid) { + void router.push(ROUTES.LOGIN) + } + }) }) onUnmounted(() => { @@ -179,9 +184,9 @@ onUnmounted(() => { }) // 登出处理 -const handleLogout = () => { - adminStore.logout() - router.push(ROUTES.LOGIN) +const handleLogout = async () => { + await logout() + await router.push(ROUTES.LOGIN) } diff --git a/src/services/config.ts b/src/services/config.ts index 9d60b75..0f183bf 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -1,13 +1,39 @@ import api from './client' import type { ApiResponse, ConfigState } from '@/types' +type PublicConfigEnvelope = { + config?: Partial + meta?: unknown +} + +const isPublicConfigEnvelope = ( + detail: ConfigState | PublicConfigEnvelope | null | undefined +): detail is PublicConfigEnvelope => { + return !!detail && typeof detail === 'object' && 'config' in detail +} + export class ConfigService { static async getConfig(): Promise> { return api.get('/admin/config/get') } static async getUserConfig(): Promise> { - return api.post('/') + try { + const response = (await api.get('/api/v1/config')) as ApiResponse< + ConfigState | PublicConfigEnvelope + > + + if (isPublicConfigEnvelope(response.detail) && response.detail.config) { + return { + ...response, + detail: response.detail.config as ConfigState + } + } + + return response as ApiResponse + } catch { + return api.post('/') + } } static async updateConfig(config: Partial): Promise { diff --git a/src/types/api.ts b/src/types/api.ts index 3ae1779..ebb6f43 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,6 +1,7 @@ export interface ApiResponse { code: number message?: string + msg?: string detail?: T } diff --git a/src/types/auth.ts b/src/types/auth.ts index 8558757..800740b 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -2,4 +2,6 @@ export interface AdminUser { id: string username: string token: string + token_type?: string + expires_at?: number } From 46496812451413e11cd286046d17115ebe1a5a33 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 03:01:40 +0800 Subject: [PATCH 11/58] fix: honor presign proxy upload URLs --- src/composables/usePresignedUpload.ts | 5 ++++- src/services/presign-upload.ts | 9 +++++++-- src/types/presign-upload.ts | 2 ++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/composables/usePresignedUpload.ts b/src/composables/usePresignedUpload.ts index 2ff2035..97630af 100644 --- a/src/composables/usePresignedUpload.ts +++ b/src/composables/usePresignedUpload.ts @@ -213,10 +213,13 @@ export function usePresignedUpload(options: UsePresignedUploadOptions = {}) { presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING const progressHandler = createProgressHandler(options?.onProgress) + const uploadUrl = + session.proxy_upload_url || session.upload_url || session.legacy_proxy_upload_url const response = await PresignUploadService.proxyUpload( session.upload_id, file, - progressHandler + progressHandler, + uploadUrl ) if (response.code === 200 && response.detail?.code) { diff --git a/src/services/presign-upload.ts b/src/services/presign-upload.ts index 3d1def8..47c6684 100644 --- a/src/services/presign-upload.ts +++ b/src/services/presign-upload.ts @@ -19,12 +19,17 @@ export class PresignUploadService { static async proxyUpload( uploadId: string, file: File, - onProgress?: (progress: UploadProgress) => void + onProgress?: (progress: UploadProgress) => void, + uploadUrl?: string ): Promise> { const formData = new FormData() formData.append('file', file) - return api.put(`/presign/upload/proxy/${uploadId}`, formData, multipartUploadConfig(onProgress)) + return api.put( + uploadUrl || `/presign/upload/proxy/${uploadId}`, + formData, + multipartUploadConfig(onProgress) + ) } static async confirmUpload( diff --git a/src/types/presign-upload.ts b/src/types/presign-upload.ts index 8f38144..abb09ea 100644 --- a/src/types/presign-upload.ts +++ b/src/types/presign-upload.ts @@ -22,6 +22,8 @@ export interface PresignInitRequest { export interface PresignInitResponse { upload_id: string upload_url: string + proxy_upload_url?: string + legacy_proxy_upload_url?: string mode: PresignUploadMode expires_in: number } From 7a9ad94a1a6b861dfa3341fd20a8308d01d48d77 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 03:13:04 +0800 Subject: [PATCH 12/58] feat: improve system settings save workflow --- src/composables/useSystemConfig.ts | 116 +++++-- src/i18n/locales/en-US.ts | 8 +- src/i18n/locales/zh-CN.ts | 8 +- src/views/manage/SystemSettingsView.vue | 422 ++++++++++++++++++------ 4 files changed, 413 insertions(+), 141 deletions(-) diff --git a/src/composables/useSystemConfig.ts b/src/composables/useSystemConfig.ts index 5b8ec87..1d27030 100644 --- a/src/composables/useSystemConfig.ts +++ b/src/composables/useSystemConfig.ts @@ -18,39 +18,78 @@ type ConfigFlagKey = 'enableChunk' | 's3_proxy' | 'openUpload' export function useSystemConfig() { const alertStore = useAlertStore() const configStore = useConfigStore() - + // 状态管理 const config = ref({ ...DEFAULT_CONFIG_STATE }) - const isLoading = ref(false) + const isRefreshing = ref(false) + const isSaving = ref(false) + const savedPayloadSnapshot = ref('') const fileSize = ref(1) const sizeUnit = ref('MB') const saveTime = ref(1) const saveTimeUnit = ref('天') - + + const isLoading = computed(() => isRefreshing.value || isSaving.value) + + const buildSubmitPayload = (): Partial => { + const payload: Partial = buildConfigSubmitPayload( + config.value, + { value: fileSize.value, unit: sizeUnit.value }, + { value: saveTime.value, unit: saveTimeUnit.value } + ) + + if (!payload.admin_token) { + delete payload.admin_token + } + + return payload + } + + const snapshotPayload = (payload: Partial) => JSON.stringify(payload) + + const normalizeEditableConfig = (nextConfig: Partial): ConfigState => ({ + ...DEFAULT_CONFIG_STATE, + ...nextConfig, + admin_token: '' + }) + + const markConfigSaved = () => { + savedPayloadSnapshot.value = snapshotPayload(buildSubmitPayload()) + } + + const isDirty = computed(() => { + if (!savedPayloadSnapshot.value) { + return false + } + + return snapshotPayload(buildSubmitPayload()) !== savedPayloadSnapshot.value + }) + // 从本地存储获取配置 const getStoredConfig = (): ConfigState | null => { - return readStoredConfig() + const storedConfig = readStoredConfig>() + return storedConfig ? normalizeEditableConfig(storedConfig) : null } - + // 保存配置到本地存储 const saveConfigToStorage = (configData: ConfigState) => { configStore.updateConfig(configData) } - + // 获取系统配置 const fetchConfig = async (): Promise => { try { - isLoading.value = true - + isRefreshing.value = true + const response = await ConfigService.getConfig() - + if (response.code === 200 && response.detail) { - config.value = { ...DEFAULT_CONFIG_STATE, ...response.detail } + config.value = normalizeEditableConfig(response.detail) const notifyMessage = configStore.applyRemoteConfig(config.value) if (notifyMessage) { alertStore.showAlert(notifyMessage, 'success') } - + return config.value } else { throw new Error(response.message || '获取配置失败') @@ -62,23 +101,25 @@ export function useSystemConfig() { config.value = storedConfig return config.value } - + alertStore.showAlert(getErrorMessage(error, '获取配置失败'), 'error') return null } finally { - isLoading.value = false + isRefreshing.value = false } } - + // 更新系统配置 const updateConfig = async (newConfig: Partial): Promise => { try { - isLoading.value = true - + isSaving.value = true + const response = await ConfigService.updateConfig(newConfig) - + if (response.code === 200) { - config.value = { ...config.value, ...newConfig } + config.value = normalizeEditableConfig({ ...config.value, ...newConfig }) + syncConfigForm(config.value) + markConfigSaved() saveConfigToStorage(config.value) alertStore.showAlert('配置更新成功!', 'success') return true @@ -89,7 +130,7 @@ export function useSystemConfig() { alertStore.showAlert(getErrorMessage(error, '更新配置失败'), 'error') return false } finally { - isLoading.value = false + isSaving.value = false } } @@ -111,52 +152,57 @@ export function useSystemConfig() { const latestConfig = await fetchConfig() if (latestConfig) { syncConfigForm(latestConfig) + markConfigSaved() } } - const submitConfig = () => - updateConfig( - buildConfigSubmitPayload( - config.value, - { value: fileSize.value, unit: sizeUnit.value }, - { value: saveTime.value, unit: saveTimeUnit.value } - ) - ) - + const submitConfig = () => { + if (!isDirty.value || isSaving.value) { + return Promise.resolve(false) + } + + return updateConfig(buildSubmitPayload()) + } + // 初始化配置 const initConfig = async () => { // 先尝试从本地存储加载 const storedConfig = getStoredConfig() if (storedConfig) { config.value = storedConfig + syncConfigForm(storedConfig) + markConfigSaved() } - + // 然后从服务器获取最新配置 - await fetchConfig() + await refreshConfig() } - + // 计算属性 const maxFileSizeMB = computed(() => { return Math.round(config.value.uploadSize / 1024 / 1024) }) - + const isConfigLoaded = computed(() => { return config.value.name !== DEFAULT_CONFIG_STATE.name || !isLoading.value }) - + return { // 状态 config, isLoading, + isRefreshing, + isSaving, + isDirty, fileSize, sizeUnit, saveTime, saveTimeUnit, - + // 计算属性 maxFileSizeMB, isConfigLoaded, - + // 方法 fetchConfig, updateConfig, diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 39150eb..7a1d55d 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -446,7 +446,13 @@ export default { mb: 'MB', gb: 'GB' }, - saveChanges: 'Save Settings' + saveChanges: 'Save Settings', + refreshConfig: 'Refresh Config', + refreshing: 'Refreshing', + saving: 'Saving', + unsavedChanges: 'Unsaved configuration changes', + allChangesSaved: 'All configuration changes are saved', + refreshBlocked: 'Save current changes before refreshing' }, systemSettings: { title: 'System Settings', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 680b1ca..0e5d958 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -477,7 +477,13 @@ export default { errorLimits: '访问保护', errorPerMinute: '检测时间窗口(在此时间内统计错误次数)', errorCountLimit: '允许错误次数(超过后临时封禁)', - saveChanges: '保存设置' + saveChanges: '保存设置', + refreshConfig: '刷新配置', + refreshing: '刷新中', + saving: '保存中', + unsavedChanges: '有未保存的配置变更', + allChangesSaved: '所有配置已保存', + refreshBlocked: '请先保存当前变更再刷新' }, dashboard: { title: '仪表盘', diff --git a/src/views/manage/SystemSettingsView.vue b/src/views/manage/SystemSettingsView.vue index e2b7153..317ed4c 100644 --- a/src/views/manage/SystemSettingsView.vue +++ b/src/views/manage/SystemSettingsView.vue @@ -1,6 +1,8 @@ From 060e77d343ca0daca6718c0be5c0f956a18cd857 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 03:39:13 +0800 Subject: [PATCH 14/58] feat: improve dashboard refresh state --- src/composables/useDashboardStats.ts | 130 +++++++++++++++++---------- src/i18n/locales/en-US.ts | 3 + src/i18n/locales/zh-CN.ts | 3 + src/types/dashboard.ts | 48 ++++++---- src/views/manage/DashboardView.vue | 107 +++++++++++++++++----- 5 files changed, 204 insertions(+), 87 deletions(-) diff --git a/src/composables/useDashboardStats.ts b/src/composables/useDashboardStats.ts index 452fc92..b28fa75 100644 --- a/src/composables/useDashboardStats.ts +++ b/src/composables/useDashboardStats.ts @@ -1,7 +1,11 @@ -import { reactive } from 'vue' +import { computed, ref, reactive } from 'vue' import { StatsService } from '@/services' import type { DashboardViewData } from '@/types' -import { formatFileSize } from '@/utils/common' +import { formatFileSize, getErrorMessage } from '@/utils/common' + +type UseDashboardStatsOptions = { + loadFailedMessage?: string +} const emptyDashboardData = (): DashboardViewData => ({ hasExtendedStats: false, @@ -50,59 +54,91 @@ const formatDuration = (startTimestamp: number | null) => { return `${days}天${hours}小时` } -export function useDashboardStats() { +const normalizeRecentFiles = (recentFiles: DashboardViewData['recentFiles']) => + recentFiles.map((file) => ({ + ...file, + size: toNumber(file.size), + expiredCount: toNumber(file.expiredCount), + usedCount: toNumber(file.usedCount) + })) + +export function useDashboardStats(options: UseDashboardStatsOptions = {}) { const dashboardData = reactive(emptyDashboardData()) + const isLoading = ref(false) + const errorMessage = ref('') + const lastUpdatedAt = ref(null) + const lastUpdatedText = computed(() => + lastUpdatedAt.value ? lastUpdatedAt.value.toLocaleString() : '-' + ) const fetchDashboardData = async () => { - const response = await StatsService.getDashboard() - if (!response.detail) return + isLoading.value = true + errorMessage.value = '' + + try { + const response = await StatsService.getDashboard() + if (!response.detail) { + throw new Error('No dashboard data') + } - const detail = response.detail - dashboardData.totalFiles = toNumber(detail.totalFiles) - dashboardData.storageUsed = toNumber(detail.storageUsed) - dashboardData.yesterdayCount = toNumber(detail.yesterdayCount) - dashboardData.todayCount = toNumber(detail.todayCount) - dashboardData.yesterdaySize = toNumber(detail.yesterdaySize) - dashboardData.todaySize = toNumber(detail.todaySize) - dashboardData.sysUptime = detail.sysUptime - dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount') - dashboardData.activeCount = dashboardData.hasExtendedStats - ? toNumber(detail.activeCount) - : dashboardData.totalFiles - dashboardData.expiredCount = toNumber(detail.expiredCount) - dashboardData.textCount = toNumber(detail.textCount) - dashboardData.fileCount = toNumber(detail.fileCount) - dashboardData.chunkedCount = toNumber(detail.chunkedCount) - dashboardData.usedCount = toNumber(detail.usedCount) - dashboardData.storageBackend = detail.storageBackend || '-' - dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit) - dashboardData.openUpload = toNumber(detail.openUpload) - dashboardData.enableChunk = toNumber(detail.enableChunk) - dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds) - dashboardData.topSuffixes = detail.topSuffixes || [] - dashboardData.recentFiles = detail.recentFiles || [] + const detail = response.detail + dashboardData.totalFiles = toNumber(detail.totalFiles) + dashboardData.storageUsed = toNumber(detail.storageUsed) + dashboardData.yesterdayCount = toNumber(detail.yesterdayCount) + dashboardData.todayCount = toNumber(detail.todayCount) + dashboardData.yesterdaySize = toNumber(detail.yesterdaySize) + dashboardData.todaySize = toNumber(detail.todaySize) + dashboardData.sysUptime = detail.sysUptime + dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount') + dashboardData.activeCount = dashboardData.hasExtendedStats + ? toNumber(detail.activeCount) + : dashboardData.totalFiles + dashboardData.expiredCount = toNumber(detail.expiredCount) + dashboardData.textCount = toNumber(detail.textCount) + dashboardData.fileCount = toNumber(detail.fileCount) + dashboardData.chunkedCount = toNumber(detail.chunkedCount) + dashboardData.usedCount = toNumber(detail.usedCount) + dashboardData.storageBackend = detail.storageBackend || '-' + dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit) + dashboardData.openUpload = toNumber(detail.openUpload) + dashboardData.enableChunk = toNumber(detail.enableChunk) + dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds) + dashboardData.topSuffixes = detail.topSuffixes || [] + dashboardData.recentFiles = normalizeRecentFiles(detail.recentFiles || []) - dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed) - dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize) - dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize) - dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit) - dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime) - dashboardData.activeRatio = dashboardData.totalFiles - ? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100) - : 0 - dashboardData.textRatio = dashboardData.totalFiles - ? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100) - : 0 - dashboardData.fileRatio = dashboardData.totalFiles - ? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100) - : 0 - dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit - ? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100) - : 0 + dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed) + dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize) + dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize) + dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit) + dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime) + dashboardData.activeRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.textRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.fileRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit + ? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100) + : 0 + lastUpdatedAt.value = new Date() + } catch (error) { + errorMessage.value = getErrorMessage( + error, + options.loadFailedMessage || 'Failed to load dashboard data' + ) + } finally { + isLoading.value = false + } } return { dashboardData, - fetchDashboardData + errorMessage, + fetchDashboardData, + isLoading, + lastUpdatedText } } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 91d328f..3edb985 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -65,6 +65,9 @@ export default { yesterdayShares: 'Yesterday: {count}', serverUptime: 'Uptime', refresh: 'Refresh', + refreshing: 'Refreshing', + lastUpdated: 'Last updated: {time}', + loadFailed: 'Failed to load dashboard data', fileHealth: 'File Health', fileHealthDesc: 'Real file records grouped by availability, expiry, and type.', activeFileRatio: 'Active Ratio', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 51d6610..2e59ff2 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -65,6 +65,9 @@ export default { yesterdayShares: '昨日分享:{count}', serverUptime: '运行时间', refresh: '刷新数据', + refreshing: '刷新中', + lastUpdated: '最近更新:{time}', + loadFailed: '仪表盘数据加载失败', fileHealth: '文件健康', fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。', activeFileRatio: '有效占比', diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts index 5d402d3..a200857 100644 --- a/src/types/dashboard.ts +++ b/src/types/dashboard.ts @@ -1,24 +1,24 @@ export interface DashboardData { totalFiles: number - storageUsed: number + storageUsed: number | string yesterdayCount: number todayCount: number - yesterdaySize: number - todaySize: number + yesterdaySize: number | string + todaySize: number | string sysUptime: number | null - activeCount: number - expiredCount: number - textCount: number - fileCount: number - chunkedCount: number - usedCount: number - storageBackend: string - uploadSizeLimit: number - openUpload: number - enableChunk: number - maxSaveSeconds: number - topSuffixes: DashboardSuffixStat[] - recentFiles: DashboardRecentFile[] + activeCount?: number + expiredCount?: number + textCount?: number + fileCount?: number + chunkedCount?: number + usedCount?: number + storageBackend?: string + uploadSizeLimit?: number + openUpload?: number + enableChunk?: number + maxSaveSeconds?: number + topSuffixes?: DashboardSuffixStat[] + recentFiles?: DashboardRecentFile[] } export interface DashboardSuffixStat { @@ -42,6 +42,22 @@ export interface DashboardRecentFile { export interface DashboardViewData extends DashboardData { hasExtendedStats: boolean + activeCount: number + expiredCount: number + textCount: number + fileCount: number + chunkedCount: number + usedCount: number + storageBackend: string + uploadSizeLimit: number + openUpload: number + enableChunk: number + maxSaveSeconds: number + topSuffixes: DashboardSuffixStat[] + recentFiles: DashboardRecentFile[] + storageUsed: number + yesterdaySize: number + todaySize: number storageUsedText: string yesterdaySizeText: string todaySizeText: string diff --git a/src/views/manage/DashboardView.vue b/src/views/manage/DashboardView.vue index e5cf014..b00bfde 100644 --- a/src/views/manage/DashboardView.vue +++ b/src/views/manage/DashboardView.vue @@ -6,22 +6,38 @@

{{ t('admin.dashboard.title') }}

+

+ {{ t('admin.dashboard.lastUpdated', { time: lastUpdatedText }) }} +

+
+ {{ errorMessage }} +
+
- +
@@ -157,10 +176,7 @@ :label="t('admin.dashboard.guestUpload')" :value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')" /> - +
@@ -168,7 +184,10 @@ {{ t('admin.dashboard.todayCapacityReference') }} {{ dashboardData.todaySizeRatio }}%
-
+
-
+
{{ t('common.noData') }}
- {{ item.suffix || t('admin.dashboard.textType') }} + {{ + item.suffix || t('admin.dashboard.textType') + }} {{ item.count }}
-
+
-
- +
+
- - - - @@ -241,7 +287,10 @@
+ {{ t('admin.dashboard.table.file') }} + {{ t('admin.dashboard.table.size') }} + {{ t('admin.dashboard.table.usage') }} + {{ t('admin.dashboard.table.status') }}
-
+
@@ -306,7 +355,10 @@ import { formatFileSize, formatTimestamp } from '@/utils/common' const isDarkMode = useInjectedDarkMode() const { t } = useI18n() -const { dashboardData, fetchDashboardData } = useDashboardStats() +const { dashboardData, errorMessage, fetchDashboardData, isLoading, lastUpdatedText } = + useDashboardStats({ + loadFailedMessage: t('admin.dashboard.loadFailed') + }) const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900')) const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500')) @@ -381,10 +433,17 @@ const PolicyRow = defineComponent({ }, setup(props) { return () => - h('div', { class: 'flex items-center justify-between gap-4 border-b border-gray-200/60 pb-3 last:border-b-0 dark:border-gray-700' }, [ - h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label), - h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value) - ]) + h( + 'div', + { + class: + 'flex items-center justify-between gap-4 border-b border-gray-200/60 pb-3 last:border-b-0 dark:border-gray-700' + }, + [ + h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label), + h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value) + ] + ) } }) From e243f1597b9983b81565087e0a1ec25850cafe44 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 04:01:28 +0800 Subject: [PATCH 15/58] feat: upgrade admin file workspace --- src/composables/useAdminFiles.ts | 195 +++++++++- src/i18n/locales/en-US.ts | 35 +- src/i18n/locales/zh-CN.ts | 35 +- src/services/file.ts | 9 +- src/types/file.ts | 50 ++- src/views/manage/FileManageView.vue | 584 ++++++++++++++++++++-------- 6 files changed, 730 insertions(+), 178 deletions(-) diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts index 936441a..d399c68 100644 --- a/src/composables/useAdminFiles.ts +++ b/src/composables/useAdminFiles.ts @@ -2,11 +2,36 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { FileService } from '@/services' import { useAlertStore } from '@/stores/alertStore' -import type { AdminFileViewItem, FileEditForm, FileListItem } from '@/types' +import type { + AdminFileListParams, + AdminFileStatusFilter, + AdminFileSummary, + AdminFileTypeFilter, + AdminFileViewItem, + FileEditForm, + FileListItem +} from '@/types' import { copyToClipboard } from '@/utils/clipboard' import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common' -const TEXT_PREVIEW_THRESHOLD = 30 +const emptySummary = (): AdminFileSummary => ({ + totalFiles: 0, + activeCount: 0, + expiredCount: 0, + textCount: 0, + fileCount: 0, + chunkedCount: 0, + storageUsed: 0, + usedCount: 0 +}) + +const normalizeCount = (value: number | string | null | undefined) => Number(value || 0) + +const isExpiredByDate = (value: string | null | undefined) => { + if (!value) return false + const timestamp = new Date(value).getTime() + return Number.isFinite(timestamp) && timestamp < Date.now() +} export function useAdminFiles() { const { t } = useI18n() @@ -14,11 +39,18 @@ export function useAdminFiles() { const tableData = ref([]) const hasLoadError = ref(false) - const params = ref({ + const isLoading = ref(false) + const isSaving = ref(false) + const summary = ref(emptySummary()) + const params = ref({ page: 1, size: 10, total: 0, - keyword: '' + keyword: '', + status: 'all', + type: 'all', + sortBy: 'created_at', + sortOrder: 'desc' }) const showEditModal = ref(false) @@ -33,17 +65,98 @@ export function useAdminFiles() { const showTextPreview = ref(false) const previewText = ref('') - const totalPages = computed(() => Math.ceil(params.value.total / params.value.size)) - - const createFileViewItem = (file: FileListItem): AdminFileViewItem => ({ - ...file, - displaySize: formatFileSize(file.size), - displayExpiredAt: file.expired_at - ? formatTimestamp(file.expired_at) - : t('send.expiration.units.forever'), - canPreviewText: Boolean(file.text && file.text.length > TEXT_PREVIEW_THRESHOLD) + const totalPages = computed(() => Math.max(Math.ceil(params.value.total / params.value.size), 1)) + const storageUsedText = computed(() => formatFileSize(summary.value.storageUsed)) + + const requestParams = computed(() => ({ + page: params.value.page, + size: params.value.size, + keyword: params.value.keyword, + status: params.value.status === 'all' ? undefined : params.value.status, + type: params.value.type === 'all' ? undefined : params.value.type, + sortBy: params.value.sortBy, + sortOrder: params.value.sortOrder + })) + + const hasActiveFilters = computed( + () => + Boolean(params.value.keyword?.trim()) || + params.value.status !== 'all' || + params.value.type !== 'all' + ) + + const inferIsText = (file: FileListItem) => { + if (typeof file.isText === 'boolean') return file.isText + if (typeof file.is_text === 'boolean') return file.is_text + if (file.type) return file.type === 'text' + return Boolean(file.text) + } + + const inferIsExpired = (file: FileListItem) => { + if (typeof file.isExpired === 'boolean') return file.isExpired + if (typeof file.is_expired === 'boolean') return file.is_expired + if ( + file.expired_count !== null && + file.expired_count !== undefined && + file.expired_count <= 0 + ) { + return true + } + return isExpiredByDate(file.expired_at) + } + + const inferIsChunked = (file: FileListItem) => Boolean(file.isChunked ?? file.is_chunked) + + const getRemainingDownloads = (file: FileListItem) => { + if (file.remainingDownloads !== undefined) return file.remainingDownloads + if (file.remaining_downloads !== undefined) return file.remaining_downloads + if ( + file.expired_count !== null && + file.expired_count !== undefined && + file.expired_count >= 0 + ) { + return Math.max(file.expired_count, 0) + } + return null + } + + const buildFallbackSummary = (files: AdminFileViewItem[], total: number): AdminFileSummary => ({ + totalFiles: total, + activeCount: files.filter((file) => !file.isExpiredFile).length, + expiredCount: files.filter((file) => file.isExpiredFile).length, + textCount: files.filter((file) => file.isTextFile).length, + fileCount: files.filter((file) => !file.isTextFile).length, + chunkedCount: files.filter((file) => file.isChunkedFile).length, + storageUsed: files.reduce((totalSize, file) => totalSize + normalizeCount(file.size), 0), + usedCount: files.reduce( + (totalUsed, file) => totalUsed + normalizeCount(file.usedCount ?? file.used_count), + 0 + ) }) + const createFileViewItem = (file: FileListItem): AdminFileViewItem => { + const isTextFile = inferIsText(file) + const isExpiredFile = inferIsExpired(file) + const isChunkedFile = inferIsChunked(file) + const remainingDownloadsValue = getRemainingDownloads(file) + const usedCount = normalizeCount(file.usedCount ?? file.used_count) + + return { + ...file, + displayName: file.name || `${file.prefix}${file.suffix}` || file.code, + displaySize: formatFileSize(file.size), + displayExpiredAt: file.expired_at + ? formatTimestamp(file.expired_at) + : t('send.expiration.units.forever'), + displayUsage: `${usedCount} ${t('common.times')}`, + isTextFile, + isExpiredFile, + isChunkedFile, + remainingDownloadsValue, + canPreviewText: Boolean(file.text) + } + } + const resetEditForm = () => { editForm.value = { id: null, @@ -56,16 +169,23 @@ export function useAdminFiles() { } const loadFiles = async () => { + isLoading.value = true try { hasLoadError.value = false - const res = await FileService.getAdminFileList(params.value) + const res = await FileService.getAdminFileList(requestParams.value) if (!res.detail) return tableData.value = res.detail.data.map(createFileViewItem) params.value.total = res.detail.total + summary.value = res.detail.summary || buildFallbackSummary(tableData.value, res.detail.total) } catch (error) { hasLoadError.value = true - alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.loadFileListFailed')), 'error') + alertStore.showAlert( + getErrorMessage(error, t('manage.fileManage.loadFileListFailed')), + 'error' + ) + } finally { + isLoading.value = false } } @@ -74,6 +194,32 @@ export function useAdminFiles() { await loadFiles() } + const refreshFiles = async () => { + await loadFiles() + } + + const resetFilters = async () => { + params.value.page = 1 + params.value.keyword = '' + params.value.status = 'all' + params.value.type = 'all' + params.value.sortBy = 'created_at' + params.value.sortOrder = 'desc' + await loadFiles() + } + + const setStatusFilter = async (status: AdminFileStatusFilter) => { + params.value.status = status + params.value.page = 1 + await loadFiles() + } + + const setTypeFilter = async (type: AdminFileTypeFilter) => { + params.value.type = type + params.value.page = 1 + await loadFiles() + } + const handlePageChange = async (page: number | string) => { if (typeof page === 'string') return if (page < 1 || page > totalPages.value) return @@ -100,12 +246,17 @@ export function useAdminFiles() { } const handleUpdate = async () => { + if (isSaving.value) return + + isSaving.value = true try { await FileService.updateFile(editForm.value) await loadFiles() closeEditModal() } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.updateFailed')), 'error') + } finally { + isSaving.value = false } } @@ -116,6 +267,9 @@ export function useAdminFiles() { try { await FileService.deleteAdminFile(id) + if (tableData.value.length === 1 && params.value.page > 1) { + params.value.page -= 1 + } await loadFiles() } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error') @@ -143,7 +297,12 @@ export function useAdminFiles() { return { tableData, hasLoadError, + hasActiveFilters, + isLoading, + isSaving, params, + storageUsedText, + summary, showEditModal, editForm, showTextPreview, @@ -158,6 +317,10 @@ export function useAdminFiles() { handleUpdate, loadFiles, openEditModal, - openTextPreview + openTextPreview, + refreshFiles, + resetFilters, + setStatusFilter, + setTypeFilter } } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 3edb985..9dfe151 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -378,10 +378,30 @@ export default { // File Management fileManage: { title: 'File Management', - searchPlaceholder: 'Search file name, description...', + subtitle: '{count} share records. Filter by status, type, and usage.', + searchPlaceholder: 'Search code, name, or text...', allFiles: 'All Files', editFileInfo: 'Edit File Information', saveChanges: 'Save Changes', + refresh: 'Refresh List', + resetFilters: 'Reset Filters', + totalFiles: 'All Records', + activeFiles: 'Available', + expiredFiles: 'Expired', + storageUsed: 'Storage Used', + statusLabel: 'Status', + typeLabel: 'Type', + all: 'All', + active: 'Available', + expired: 'Expired', + fileType: 'File', + textType: 'Text', + chunkedType: 'Chunked', + sortBy: 'Sort', + unlimited: 'Unlimited', + remaining: '{count} left', + loadError: 'Failed to load file list', + noMatches: 'No matching files', viewText: 'View', textPreview: 'Text Preview', copyText: 'Copy Text', @@ -391,11 +411,24 @@ export default { headers: { code: 'Retrieve Code', name: 'Name', + type: 'Type', size: 'Size', + usage: 'Retrievals', + status: 'Status', description: 'Description', expiration: 'Expiration', actions: 'Actions' }, + sort: { + createdAt: 'Created Time', + expiredAt: 'Expiration', + name: 'Name', + size: 'Size', + usedCount: 'Retrievals', + code: 'Retrieve Code', + desc: 'Descending', + asc: 'Ascending' + }, form: { code: 'Retrieve Code', codePlaceholder: 'Enter retrieve code', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 2e59ff2..cda7910 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -376,10 +376,30 @@ export default { // 文件管理 fileManage: { title: '文件管理', - searchPlaceholder: '搜索文件名称、描述...', + subtitle: '共 {count} 条分享记录,可按状态、类型和使用情况快速筛选。', + searchPlaceholder: '搜索取件码、名称或文本...', allFiles: '所有文件', editFileInfo: '编辑文件信息', saveChanges: '保存更改', + refresh: '刷新列表', + resetFilters: '重置筛选', + totalFiles: '全部记录', + activeFiles: '可取件', + expiredFiles: '已过期', + storageUsed: '占用空间', + statusLabel: '状态', + typeLabel: '类型', + all: '全部', + active: '可取件', + expired: '已过期', + fileType: '文件', + textType: '文本', + chunkedType: '分片', + sortBy: '排序', + unlimited: '不限次数', + remaining: '剩余 {count} 次', + loadError: '文件列表加载失败', + noMatches: '没有匹配的文件', viewText: '查看', textPreview: '文本预览', copyText: '复制文本', @@ -389,11 +409,24 @@ export default { headers: { code: '取件码', name: '名称', + type: '类型', size: '大小', + usage: '取件', + status: '状态', description: '描述', expiration: '过期时间', actions: '操作' }, + sort: { + createdAt: '创建时间', + expiredAt: '过期时间', + name: '名称', + size: '大小', + usedCount: '取件次数', + code: '取件码', + desc: '降序', + asc: '升序' + }, form: { code: '取件码', codePlaceholder: '输入取件码', diff --git a/src/services/file.ts b/src/services/file.ts index f88ae9e..9b469b4 100644 --- a/src/services/file.ts +++ b/src/services/file.ts @@ -7,6 +7,7 @@ import type { ChunkUploadInitResponse, ChunkUploadResponse, FileEditForm, + AdminFileListParams, FileInfo, FileListResponse, FileUploadResponse, @@ -116,11 +117,9 @@ export class FileService { return response.data } - static async getAdminFileList(params: { - page: number - size: number - keyword?: string - }): Promise> { + static async getAdminFileList( + params: AdminFileListParams + ): Promise> { return api.get('/admin/file/list', { params }) } diff --git a/src/types/file.ts b/src/types/file.ts index 662d465..b3cdfb0 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -16,17 +16,64 @@ export interface FileListItem { size: number text?: string description?: string - expired_at: string + expired_at: string | null expired_count: number | null created_at: string + name?: string + type?: 'text' | 'file' + status?: 'active' | 'expired' + isText?: boolean + is_text?: boolean + isExpired?: boolean + is_expired?: boolean + isChunked?: boolean + is_chunked?: boolean + remainingDownloads?: number | null + remaining_downloads?: number | null + usedCount?: number + used_count?: number + fileHash?: string | null + file_hash?: string | null } export interface AdminFileViewItem extends FileListItem { + displayName: string displaySize: string displayExpiredAt: string + displayUsage: string + isTextFile: boolean + isExpiredFile: boolean + isChunkedFile: boolean + remainingDownloadsValue: number | null canPreviewText: boolean } +export interface AdminFileSummary { + totalFiles: number + activeCount: number + expiredCount: number + textCount: number + fileCount: number + chunkedCount: number + storageUsed: number + usedCount: number +} + +export type AdminFileStatusFilter = 'all' | 'active' | 'expired' +export type AdminFileTypeFilter = 'all' | 'file' | 'text' | 'chunked' +export type AdminFileSortBy = 'created_at' | 'expired_at' | 'name' | 'size' | 'used_count' | 'code' +export type AdminFileSortOrder = 'asc' | 'desc' + +export interface AdminFileListParams { + page: number + size: number + keyword?: string + status?: AdminFileStatusFilter + type?: AdminFileTypeFilter + sortBy?: AdminFileSortBy + sortOrder?: AdminFileSortOrder +} + export interface FileEditForm { id: number | null code: string @@ -41,6 +88,7 @@ export interface FileListResponse { total: number page: number size: number + summary?: AdminFileSummary } export interface FileUploadResponse { diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index 2921a55..799b054 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -1,168 +1,315 @@