refactor: modularize frontend flows

This commit is contained in:
Lan
2026-06-03 02:01:57 +08:00
parent 6c29b36279
commit 72249dd2dd
85 changed files with 4654 additions and 4363 deletions
+12
View File
@@ -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'
+163
View File
@@ -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<AdminFileViewItem[]>([])
const hasLoadError = ref(false)
const params = ref({
page: 1,
size: 10,
total: 0,
keyword: ''
})
const showEditModal = ref(false)
const editForm = ref<FileEditForm>({
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
}
}
+53
View File
@@ -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
}
}
+52
View File
@@ -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
}
}
+108
View File
@@ -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<DashboardViewData>(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
}
}
-144
View File
@@ -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<FileInfo | null>(null)
const downloadCode = ref('')
// 计算属性
const hasFileInfo = computed(() => fileInfo.value !== null)
const canDownload = computed(() => hasFileInfo.value && !isLoading.value)
// 获取文件信息
const getFileInfo = async (code: string): Promise<FileInfo | null> => {
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<boolean> => {
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
}
}
-287
View File
@@ -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<ConfigState>
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<UploadStatus>(UPLOAD_STATUS.IDLE)
const uploadProgress = ref<UploadProgress>({
loaded: 0,
total: 0,
percentage: 0
})
const uploadedCode = ref<string>('')
const currentFile = ref<File | null>(null)
// 当前是否使用预签名上传
const isUsingPresigned = ref<boolean>(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<string | null> => {
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<string | null> => {
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<string | null> => {
// 构建预签名上传选项
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<string | null> => {
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<void> => {
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
}
}
+7
View File
@@ -0,0 +1,7 @@
import { computed, inject, unref } from 'vue'
import type { Ref } from 'vue'
export function useInjectedDarkMode() {
const injectedDarkMode = inject<Ref<boolean> | boolean>('isDarkMode', false)
return computed(() => Boolean(unref(injectedDarkMode)))
}
+39 -60
View File
@@ -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<ConfigState>
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<PresignUploadStatus>(PRESIGN_UPLOAD_STATUS.IDLE)
const uploadSession = ref<PresignInitResponse | null>(null)
@@ -74,15 +63,23 @@ export function usePresignedUpload() {
const isError = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.ERROR)
const currentMode = computed<PresignUploadMode | null>(() => 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
const status = (error as ErrorWithResponse)?.response?.status
const fallback =
status === 404
? '上传会话不存在或已过期'
: status === 500
? '服务器错误,请稍后重试'
: '上传失败,请重试'
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')
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
}
@@ -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
}
}
+173
View File
@@ -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<InputStatus>({
readonly: false,
loading: false
})
const error = ref('')
const selectedRecord = ref<ReceivedFileRecord | null>(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
}
}
+44
View File
@@ -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
}
}
+337
View File
@@ -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<SendType>('file')
const selectedFile = ref<File | null>(null)
const selectedFiles = ref<File[]>([])
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<SentFileRecord | null>(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
}
}
+122
View File
@@ -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, string | number | undefined>
) => 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<ApiResponse> => {
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<ApiResponse<{ code?: string; name?: string }>> => {
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<ApiResponse | null> => {
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
}
}
+71 -43
View File
@@ -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<SystemConfig>({ ...DEFAULT_CONFIG })
const config = ref<ConfigState>({ ...DEFAULT_CONFIG_STATE })
const isLoading = ref(false)
const fileSize = ref(1)
const sizeUnit = ref<FileSizeUnit>('MB')
const saveTime = ref(1)
const saveTimeUnit = ref<SaveTimeUnit>('天')
// 从本地存储获取配置
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<ConfigState>()
}
// 保存配置到本地存储
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<SystemConfig | null> => {
const fetchConfig = async (): Promise<ConfigState | null> => {
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<SystemConfig>): Promise<boolean> => {
const updateConfig = async (newConfig: Partial<ConfigState>): Promise<boolean> => {
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
}
}
}
+5 -4
View File
@@ -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
}
}
}