refactor: modularize frontend flows
This commit is contained in:
+3
-57
@@ -1,70 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, provide, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import ThemeToggle from './components/common/ThemeToggle.vue'
|
||||
import LanguageSwitcher from './components/common/LanguageSwitcher.vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import AlertComponent from '@/components/common/AlertComponent.vue'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
import { ConfigService } from '@/services'
|
||||
import type { ApiResponse, ConfigState } from '@/types'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useAppShell } from '@/composables'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const alertStore = useAlertStore()
|
||||
|
||||
// 使用主题 composable
|
||||
const { isDarkMode, toggleTheme, initTheme } = useTheme()
|
||||
|
||||
// 清理函数
|
||||
let cleanupThemeListener: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化主题并设置监听器
|
||||
cleanupThemeListener = initTheme()
|
||||
ConfigService.getUserConfig().then((res: ApiResponse<ConfigState>) => {
|
||||
if (res.code === 200 && res.detail) {
|
||||
localStorage.setItem('config', JSON.stringify(res.detail))
|
||||
if (
|
||||
res.detail.notify_title &&
|
||||
res.detail.notify_content &&
|
||||
localStorage.getItem('notify') !== res.detail.notify_title + res.detail.notify_content
|
||||
) {
|
||||
localStorage.setItem('notify', res.detail.notify_title + res.detail.notify_content)
|
||||
alertStore.showAlert(res.detail.notify_title + ': ' + res.detail.notify_content, 'success')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理主题监听器
|
||||
if (cleanupThemeListener) {
|
||||
cleanupThemeListener()
|
||||
}
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
isLoading.value = true
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
setTimeout(() => {
|
||||
isLoading.value = false
|
||||
}, 200) // 添加一个小延迟,以确保组件已加载
|
||||
})
|
||||
|
||||
provide('isDarkMode', isDarkMode)
|
||||
provide('toggleTheme', toggleTheme)
|
||||
provide('isLoading', isLoading)
|
||||
const { isDarkMode, isLoading, route, showGlobalControls } = useAppShell()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['app-container', isDarkMode ? 'dark' : 'light']">
|
||||
<div class="fixed top-4 right-4 z-50 flex items-center space-x-3">
|
||||
<div v-if="showGlobalControls" class="fixed top-4 right-4 z-50 flex items-center space-x-3">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle v-model="isDarkMode" />
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ const { t } = useI18n()
|
||||
|
||||
const alertStore = useAlertStore()
|
||||
const { alerts } = storeToRefs(alertStore)
|
||||
const { removeAlert, updateAlertProgress } = alertStore
|
||||
const { removeAlert, startProgressTimer, stopProgressTimer } = alertStore
|
||||
|
||||
const gradientClasses = {
|
||||
success: 'from-green-500 to-green-600',
|
||||
@@ -69,18 +69,12 @@ const alertIcons = {
|
||||
info: Info
|
||||
}
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval>
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = setInterval(() => {
|
||||
alerts.value.forEach((alert) => {
|
||||
updateAlertProgress(alert.id)
|
||||
})
|
||||
}, 100)
|
||||
startProgressTimer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(intervalId)
|
||||
stopProgressTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useInjectedDarkMode } from '@/composables'
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'outline'
|
||||
@@ -40,7 +41,7 @@ defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const isDarkMode = useInjectedDarkMode()
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes = {
|
||||
@@ -59,7 +60,7 @@ const variantClasses = computed(() => {
|
||||
}
|
||||
|
||||
if (props.variant === 'secondary') {
|
||||
return isDarkMode
|
||||
return isDarkMode.value
|
||||
? `${baseClasses} bg-gray-700 text-gray-300 hover:bg-gray-600 focus:ring-gray-500 border border-gray-600`
|
||||
: `${baseClasses} bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 border border-gray-300`
|
||||
}
|
||||
@@ -73,11 +74,11 @@ const variantClasses = computed(() => {
|
||||
}
|
||||
|
||||
if (props.variant === 'outline') {
|
||||
return isDarkMode
|
||||
return isDarkMode.value
|
||||
? `${baseClasses} border border-gray-600 text-gray-300 hover:bg-gray-700 focus:ring-gray-500`
|
||||
: `${baseClasses} border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500`
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -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'
|
||||
}"
|
||||
>
|
||||
<option value="count">{{ t('send.expiration.units.times') }}</option>
|
||||
<option value="minute">{{ t('send.expiration.units.minutes') }}</option>
|
||||
<option value="hour">{{ t('send.expiration.units.hours') }}</option>
|
||||
<option value="day">{{ t('send.expiration.units.days') }}</option>
|
||||
<option value="forever">{{ t('send.expiration.units.forever') }}</option>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:class="[isDarkMode ? 'bg-gray-800 text-gray-100' : 'bg-white text-gray-900']"
|
||||
:style="{
|
||||
color: isDarkMode ? '#f3f4f6' : '#111827',
|
||||
backgroundColor: isDarkMode ? '#1f2937' : '#ffffff'
|
||||
}"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<div
|
||||
class="absolute right-4 top-1/2 transform -translate-y-1/2 pointer-events-none"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
class="absolute pointer-events-none"
|
||||
:class="[
|
||||
expirationMethod === 'forever' ? 'right-3' : 'right-2',
|
||||
'top-1/2 -translate-y-1/2',
|
||||
isDarkMode ? 'text-gray-400' : 'text-gray-500'
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
@@ -117,12 +134,16 @@ const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
expirationMethod: string
|
||||
expirationValue: number
|
||||
expirationValue: string
|
||||
options: Array<{
|
||||
label: string
|
||||
value: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
'update:expirationMethod': [value: string]
|
||||
'update:expirationValue': [value: number]
|
||||
'update:expirationValue': [value: string]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -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<Props>()
|
||||
defineEmits<Emits>()
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -169,4 +149,4 @@ const getQRCodeValue = (record: FileRecord) => {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="space-y-2 group">
|
||||
<label
|
||||
class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'text-gray-300 group-focus-within:text-indigo-400'
|
||||
: 'text-gray-700 group-focus-within:text-indigo-600'
|
||||
]"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<div
|
||||
class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 group-focus-within:bg-indigo-500/50'
|
||||
: 'bg-gray-200 group-focus-within:bg-indigo-500/30'
|
||||
]"
|
||||
></div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input
|
||||
:type="type"
|
||||
:value="modelValue ?? ''"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]"
|
||||
:placeholder="placeholder"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100"
|
||||
>
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: string | number | null
|
||||
placeholder?: string
|
||||
type?: 'text' | 'number' | 'datetime-local'
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
type: 'text'
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | null]
|
||||
}>()
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (props.type === 'number') {
|
||||
emit('update:modelValue', target.value === '' ? null : target.valueAsNumber)
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
</script>
|
||||
@@ -68,24 +68,15 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { FileIcon, EyeIcon, DownloadIcon, TrashIcon } from 'lucide-vue-next'
|
||||
|
||||
interface FileRecord {
|
||||
id: number
|
||||
code: string
|
||||
filename: string
|
||||
size: string
|
||||
downloadUrl: string | null
|
||||
content: string | null
|
||||
date: string
|
||||
}
|
||||
import type { ReceivedFileRecord } from '@/types'
|
||||
|
||||
interface Props {
|
||||
records: FileRecord[]
|
||||
records: ReceivedFileRecord[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
'view-details': [record: FileRecord]
|
||||
'download-record': [record: FileRecord]
|
||||
'view-details': [record: ReceivedFileRecord]
|
||||
'download-record': [record: ReceivedFileRecord]
|
||||
'delete-record': [id: number]
|
||||
}
|
||||
|
||||
@@ -109,4 +100,4 @@ const isDarkMode = inject('isDarkMode')
|
||||
.list-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -84,10 +84,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject, computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { UploadCloudIcon, CheckCircleIcon, XCircleIcon, LoaderIcon } from 'lucide-vue-next'
|
||||
import BorderProgressBar from './BorderProgressBar.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useInjectedDarkMode } from '@/composables'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -141,7 +142,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const isDarkMode = useInjectedDarkMode()
|
||||
|
||||
// 使用computed属性处理多语言文本
|
||||
const placeholderText = computed(() => props.placeholder || t('send.uploadArea.placeholder'))
|
||||
@@ -178,25 +179,25 @@ const statusIcon = computed(() => {
|
||||
|
||||
const statusIconClass = computed(() => {
|
||||
if (isUploading.value) {
|
||||
return isDarkMode ? 'text-indigo-400 animate-spin' : 'text-indigo-600 animate-spin'
|
||||
return isDarkMode.value ? 'text-indigo-400 animate-spin' : 'text-indigo-600 animate-spin'
|
||||
}
|
||||
if (isSuccess.value) {
|
||||
return isDarkMode ? 'text-green-400' : 'text-green-600'
|
||||
return isDarkMode.value ? 'text-green-400' : 'text-green-600'
|
||||
}
|
||||
if (hasError.value) {
|
||||
return isDarkMode ? 'text-red-400' : 'text-red-600'
|
||||
return isDarkMode.value ? 'text-red-400' : 'text-red-600'
|
||||
}
|
||||
return isDarkMode
|
||||
return isDarkMode.value
|
||||
? 'text-gray-400 group-hover:text-indigo-400'
|
||||
: 'text-gray-600 group-hover:text-indigo-600'
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (hasError.value) {
|
||||
return isDarkMode ? 'border-red-500/50' : 'border-red-300'
|
||||
return isDarkMode.value ? 'border-red-500/50' : 'border-red-300'
|
||||
}
|
||||
if (isSuccess.value) {
|
||||
return isDarkMode ? 'border-green-500/50' : 'border-green-300'
|
||||
return isDarkMode.value ? 'border-green-500/50' : 'border-green-300'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
@@ -222,12 +223,12 @@ const statusDescription = computed(() => {
|
||||
|
||||
const statusDescriptionClass = computed(() => {
|
||||
if (hasError.value) {
|
||||
return isDarkMode ? 'text-red-400' : 'text-red-500'
|
||||
return isDarkMode.value ? 'text-red-400' : 'text-red-500'
|
||||
}
|
||||
if (isSuccess.value) {
|
||||
return isDarkMode ? 'text-green-400' : 'text-green-500'
|
||||
return isDarkMode.value ? 'text-green-400' : 'text-green-500'
|
||||
}
|
||||
return isDarkMode ? 'text-gray-500' : 'text-gray-400'
|
||||
return isDarkMode.value ? 'text-gray-500' : 'text-gray-400'
|
||||
})
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="switcherRef" class="relative">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
:class="[
|
||||
@@ -69,6 +69,7 @@ import { availableLocales, setLocale } from '@/i18n/index'
|
||||
const { locale } = useI18n()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const isDropdownOpen = ref(false)
|
||||
const switcherRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const currentLocale = computed(() => locale.value)
|
||||
const currentLanguage = computed(() => {
|
||||
@@ -86,8 +87,8 @@ const switchLanguage = (langCode: string) => {
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
const handleClickOutside = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.relative')) {
|
||||
const target = event.target as Node | null
|
||||
if (target && !switcherRef.value?.contains(target)) {
|
||||
isDropdownOpen.value = false
|
||||
}
|
||||
}
|
||||
@@ -99,4 +100,4 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="record"
|
||||
class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-3 sm:p-4 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-2xl rounded-2xl shadow-2xl transform transition-all duration-300 ease-out overflow-hidden"
|
||||
:class="[isDarkMode ? 'bg-gray-900 bg-opacity-70' : 'bg-white bg-opacity-95']"
|
||||
>
|
||||
<div
|
||||
class="px-4 sm:px-6 py-3 sm:py-4 border-b"
|
||||
:class="[isDarkMode ? 'border-gray-800' : 'border-gray-100']"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3
|
||||
class="text-lg sm:text-xl font-semibold"
|
||||
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
|
||||
>
|
||||
{{ t('send.fileDetails') }}
|
||||
</h3>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="p-1.5 sm:p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<XIcon
|
||||
class="w-4 h-4 sm:w-5 sm:h-5"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-6">
|
||||
<div
|
||||
class="rounded-xl p-3 sm:p-4 mb-4 sm:mb-6"
|
||||
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-50' : 'bg-gray-50 bg-opacity-95']"
|
||||
>
|
||||
<div class="flex items-center mb-3 sm:mb-4">
|
||||
<div class="p-2 sm:p-3 rounded-lg" :class="[isDarkMode ? 'bg-gray-800' : 'bg-white']">
|
||||
<FileIcon
|
||||
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 sm:ml-4 min-w-0 flex-1">
|
||||
<h4
|
||||
class="font-medium text-sm sm:text-base truncate"
|
||||
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
|
||||
>
|
||||
{{ record.filename }}
|
||||
</h4>
|
||||
<p
|
||||
class="text-xs sm:text-sm truncate"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
>
|
||||
{{ record.size }} · {{ record.date }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:gap-4">
|
||||
<div class="flex items-center min-w-0">
|
||||
<ClockIcon
|
||||
class="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
/>
|
||||
<span
|
||||
class="text-xs sm:text-sm truncate"
|
||||
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
|
||||
>
|
||||
{{ record.expiration }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center min-w-0">
|
||||
<ShieldCheckIcon
|
||||
class="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
/>
|
||||
<span
|
||||
class="text-xs sm:text-sm truncate"
|
||||
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
|
||||
>
|
||||
安全加密
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div class="space-y-3 sm:space-y-4">
|
||||
<div class="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl p-4 sm:p-5 text-white">
|
||||
<div class="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<h4 class="font-medium text-sm sm:text-base">取件码</h4>
|
||||
<button
|
||||
@click="$emit('copy-code', record)"
|
||||
class="p-1.5 sm:p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ClipboardCopyIcon class="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-2xl sm:text-3xl font-bold tracking-wider text-center break-all">
|
||||
{{ record.retrieveCode }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl p-3 sm:p-4"
|
||||
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-50' : 'bg-gray-50 bg-opacity-95']"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2 sm:mb-3">
|
||||
<h4
|
||||
class="font-medium text-sm sm:text-base flex items-center min-w-0"
|
||||
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
|
||||
>
|
||||
<TerminalIcon class="w-4 h-4 sm:w-5 sm:h-5 mr-1.5 sm:mr-2 text-indigo-500 flex-shrink-0" />
|
||||
<span class="truncate">wget下载</span>
|
||||
</h4>
|
||||
<button
|
||||
@click="$emit('copy-wget', record)"
|
||||
class="p-1.5 sm:p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex-shrink-0"
|
||||
>
|
||||
<ClipboardCopyIcon
|
||||
class="w-4 h-4 sm:w-5 sm:h-5"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs sm:text-sm font-mono break-all line-clamp-2"
|
||||
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
|
||||
>
|
||||
点击复制wget命令
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl p-4 sm:p-5 flex flex-col items-center"
|
||||
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-50' : 'bg-gray-50 bg-opacity-95']"
|
||||
>
|
||||
<div class="bg-white p-3 sm:p-4 rounded-lg shadow-sm mb-3 sm:mb-4">
|
||||
<QRCode :value="qrValue" :size="140" level="M" class="sm:w-[160px] sm:h-[160px]" />
|
||||
</div>
|
||||
<p
|
||||
class="text-xs sm:text-sm truncate max-w-full"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
>
|
||||
扫描二维码快速取件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="px-4 sm:px-6 py-3 sm:py-4 border-t"
|
||||
:class="[isDarkMode ? 'border-gray-800' : 'border-gray-100']"
|
||||
>
|
||||
<button
|
||||
@click="$emit('copy-link', record)"
|
||||
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white px-4 sm:px-6 py-2 sm:py-3 rounded-lg text-sm sm:text-base font-medium transition-colors"
|
||||
>
|
||||
复制取件链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
ClockIcon,
|
||||
FileIcon,
|
||||
ShieldCheckIcon,
|
||||
TerminalIcon,
|
||||
XIcon
|
||||
} from 'lucide-vue-next'
|
||||
import QRCode from 'qrcode.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SentFileRecord } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
record: SentFileRecord | null
|
||||
getQRCodeValue: (record: SentFileRecord) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
'copy-code': [record: SentFileRecord]
|
||||
'copy-link': [record: SentFileRecord]
|
||||
'copy-wget': [record: SentFileRecord]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const qrValue = computed(() => (props.record ? props.getQRCodeValue(props.record) : ''))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex-grow overflow-y-auto p-6">
|
||||
<transition-group name="list" tag="div" class="space-y-4">
|
||||
<div
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="bg-opacity-50 rounded-lg p-4 flex items-center shadow-md hover:shadow-lg transition duration-300 transform hover:scale-102"
|
||||
:class="[isDarkMode ? 'bg-gray-800 hover:bg-gray-700' : 'bg-gray-100 hover:bg-white']"
|
||||
>
|
||||
<div class="flex-shrink-0 mr-4">
|
||||
<FileIcon
|
||||
class="w-10 h-10"
|
||||
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-grow min-w-0 mr-4">
|
||||
<p
|
||||
class="font-medium text-lg truncate"
|
||||
:class="[isDarkMode ? 'text-white' : 'text-gray-800']"
|
||||
>
|
||||
{{ record.filename ? record.filename : 'Text' }}
|
||||
</p>
|
||||
<p class="text-sm truncate" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
|
||||
{{ record.date }} · {{ record.size }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
<button
|
||||
@click="$emit('copy-link', record)"
|
||||
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
|
||||
:class="[
|
||||
isDarkMode ? 'hover:bg-blue-400 text-blue-400' : 'hover:bg-blue-100 text-blue-600'
|
||||
]"
|
||||
>
|
||||
<ClipboardCopyIcon class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('view-details', record)"
|
||||
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'hover:bg-green-400 text-green-400'
|
||||
: 'hover:bg-green-100 text-green-600'
|
||||
]"
|
||||
>
|
||||
<EyeIcon class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('delete-record', record.id)"
|
||||
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
|
||||
:class="[
|
||||
isDarkMode ? 'hover:bg-red-400 text-red-400' : 'hover:bg-red-100 text-red-600'
|
||||
]"
|
||||
>
|
||||
<TrashIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { ClipboardCopyIcon, EyeIcon, FileIcon, TrashIcon } from 'lucide-vue-next'
|
||||
import type { SentFileRecord } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
records: SentFileRecord[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'copy-link': [record: SentFileRecord]
|
||||
'view-details': [record: SentFileRecord]
|
||||
'delete-record': [id: number]
|
||||
}>()
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
:value="modelValue"
|
||||
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ suffix }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: number
|
||||
suffix: string
|
||||
}>()
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.value) {
|
||||
emit('update:modelValue', 0)
|
||||
return
|
||||
}
|
||||
|
||||
const nextValue = input.valueAsNumber
|
||||
if (!Number.isNaN(nextValue)) {
|
||||
emit('update:modelValue', nextValue)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
:class="[modelValue === 1 ? 'bg-indigo-600' : 'bg-gray-200']"
|
||||
role="switch"
|
||||
:aria-checked="modelValue === 1"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
modelValue === 1 ? 'translate-x-5' : 'translate-x-0',
|
||||
isDarkMode && modelValue !== 1 ? 'bg-gray-100' : 'bg-white'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ modelValue === 1 ? enabledText : disabledText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: number
|
||||
enabledText: string
|
||||
disabledText: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
</script>
|
||||
@@ -21,8 +21,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, computed } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useInjectedDarkMode } from '@/composables'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -36,34 +37,34 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
descriptionType: 'neutral'
|
||||
})
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const isDarkMode = useInjectedDarkMode()
|
||||
|
||||
const iconBgClass = computed(() => {
|
||||
const colorMap = {
|
||||
indigo: isDarkMode ? 'bg-indigo-900' : 'bg-indigo-100',
|
||||
purple: isDarkMode ? 'bg-purple-900' : 'bg-purple-100',
|
||||
green: isDarkMode ? 'bg-green-900' : 'bg-green-100',
|
||||
blue: isDarkMode ? 'bg-blue-900' : 'bg-blue-100'
|
||||
indigo: isDarkMode.value ? 'bg-indigo-900' : 'bg-indigo-100',
|
||||
purple: isDarkMode.value ? 'bg-purple-900' : 'bg-purple-100',
|
||||
green: isDarkMode.value ? 'bg-green-900' : 'bg-green-100',
|
||||
blue: isDarkMode.value ? 'bg-blue-900' : 'bg-blue-100'
|
||||
}
|
||||
return colorMap[props.iconColor]
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
const colorMap = {
|
||||
indigo: isDarkMode ? 'text-indigo-400' : 'text-indigo-600',
|
||||
purple: isDarkMode ? 'text-purple-400' : 'text-purple-600',
|
||||
green: isDarkMode ? 'text-green-400' : 'text-green-600',
|
||||
blue: isDarkMode ? 'text-blue-400' : 'text-blue-600'
|
||||
indigo: isDarkMode.value ? 'text-indigo-400' : 'text-indigo-600',
|
||||
purple: isDarkMode.value ? 'text-purple-400' : 'text-purple-600',
|
||||
green: isDarkMode.value ? 'text-green-400' : 'text-green-600',
|
||||
blue: isDarkMode.value ? 'text-blue-400' : 'text-blue-600'
|
||||
}
|
||||
return colorMap[props.iconColor]
|
||||
})
|
||||
|
||||
const descriptionClass = computed(() => {
|
||||
const typeMap = {
|
||||
success: isDarkMode ? 'text-green-400' : 'text-green-600',
|
||||
error: isDarkMode ? 'text-red-400' : 'text-red-600',
|
||||
neutral: isDarkMode ? 'text-gray-400' : 'text-gray-600'
|
||||
success: isDarkMode.value ? 'text-green-400' : 'text-green-600',
|
||||
error: isDarkMode.value ? 'text-red-400' : 'text-red-600',
|
||||
neutral: isDarkMode.value ? 'text-gray-400' : 'text-gray-600'
|
||||
}
|
||||
return typeMap[props.descriptionType]
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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' }
|
||||
]
|
||||
]
|
||||
|
||||
+73
-16
@@ -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: {
|
||||
|
||||
+74
-16
@@ -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: {
|
||||
|
||||
@@ -44,11 +44,11 @@
|
||||
<nav class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<ul class="p-4 space-y-2">
|
||||
<li v-for="item in menuItems" :key="item.id">
|
||||
<a
|
||||
@click="router.push(item.redirect)"
|
||||
<RouterLink
|
||||
:to="item.redirect"
|
||||
class="flex items-center p-2 rounded-lg transition-colors duration-200"
|
||||
:class="[
|
||||
router.currentRoute.value.name === item.id
|
||||
route.name === item.id
|
||||
? isDarkMode
|
||||
? 'bg-indigo-900 text-indigo-400'
|
||||
: 'bg-indigo-100 text-indigo-600'
|
||||
@@ -59,7 +59,7 @@
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 mr-3" />
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -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)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+96
-42
@@ -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'
|
||||
|
||||
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: '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
|
||||
}
|
||||
]
|
||||
|
||||
// 预加载 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')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/manage/LoginView.vue')
|
||||
}
|
||||
]
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.requiresAuth && !readStoredToken()) {
|
||||
return {
|
||||
path: ROUTES.LOGIN,
|
||||
query: {
|
||||
redirect: to.fullPath
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import api from './client'
|
||||
import type { AdminUser, ApiResponse } from '@/types'
|
||||
|
||||
export class AuthService {
|
||||
static async login(password: string): Promise<ApiResponse<AdminUser>> {
|
||||
return api.post('/admin/login', { password })
|
||||
}
|
||||
|
||||
static async logout(): Promise<ApiResponse> {
|
||||
return api.post('/admin/logout')
|
||||
}
|
||||
|
||||
static async verifyToken(): Promise<ApiResponse<AdminUser>> {
|
||||
return api.get('/admin/verify')
|
||||
}
|
||||
}
|
||||
@@ -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<ApiErrorPayload>) => {
|
||||
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
|
||||
@@ -0,0 +1,16 @@
|
||||
import api from './client'
|
||||
import type { ApiResponse, ConfigState } from '@/types'
|
||||
|
||||
export class ConfigService {
|
||||
static async getConfig(): Promise<ApiResponse<ConfigState>> {
|
||||
return api.get('/admin/config/get')
|
||||
}
|
||||
|
||||
static async getUserConfig(): Promise<ApiResponse<ConfigState>> {
|
||||
return api.post('/')
|
||||
}
|
||||
|
||||
static async updateConfig(config: Partial<ConfigState>): Promise<ApiResponse> {
|
||||
return api.patch('/admin/config/update', config)
|
||||
}
|
||||
}
|
||||
@@ -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<string, string | number>) => {
|
||||
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<ApiResponse<FileUploadResponse>> {
|
||||
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<ApiResponse<TextSendResponse>> {
|
||||
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<ApiResponse<ChunkUploadInitResponse>> {
|
||||
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<ApiResponse<ChunkUploadResponse>> {
|
||||
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<ApiResponse<FileUploadResponse>> {
|
||||
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<ApiResponse<ShareSelectResponse>> {
|
||||
return api.post('/share/select/', { code })
|
||||
}
|
||||
|
||||
static async getFile(code: string): Promise<ApiResponse<FileInfo>> {
|
||||
return api.get(`/file/${code}`)
|
||||
}
|
||||
|
||||
static async downloadFile(code: string): Promise<Blob> {
|
||||
const response = await rawApiClient.get<Blob>(`/download/${code}`, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
static async getAdminFileList(params: {
|
||||
page: number
|
||||
size: number
|
||||
keyword?: string
|
||||
}): Promise<ApiResponse<FileListResponse>> {
|
||||
return api.get('/admin/file/list', { params })
|
||||
}
|
||||
|
||||
static async updateFile(data: FileEditForm): Promise<ApiResponse> {
|
||||
return api.patch('/admin/file/update', data)
|
||||
}
|
||||
|
||||
static async deleteAdminFile(id: number): Promise<ApiResponse> {
|
||||
return api.delete('/admin/file/delete', {
|
||||
data: { id }
|
||||
})
|
||||
}
|
||||
|
||||
static async downloadAdminFile(
|
||||
id: number
|
||||
): Promise<{ data: Blob; headers: Record<string, string> }> {
|
||||
const response = await rawApiClient.get<Blob>('/admin/file/download', {
|
||||
params: { id },
|
||||
responseType: 'blob'
|
||||
})
|
||||
return {
|
||||
data: response.data,
|
||||
headers: response.headers as Record<string, string>
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-266
@@ -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<ApiResponse<ConfigState>> {
|
||||
return api.get('/admin/config/get')
|
||||
}
|
||||
static async getUserConfig(): Promise<ApiResponse<ConfigState>> {
|
||||
return api.post('/')
|
||||
}
|
||||
static async updateConfig(config: Partial<ConfigState>): Promise<ApiResponse> {
|
||||
return api.patch('/admin/config/update', config)
|
||||
}
|
||||
}
|
||||
|
||||
// 文件服务
|
||||
export class FileService {
|
||||
static async uploadFile(
|
||||
file: File,
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
): Promise<ApiResponse<FileUploadResponse>> {
|
||||
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<ApiResponse<TextSendResponse>> {
|
||||
return api.post('/share/text/', { content: text })
|
||||
}
|
||||
|
||||
static async getFile(code: string): Promise<ApiResponse<FileInfo>> {
|
||||
return api.get(`/file/${code}`)
|
||||
}
|
||||
|
||||
static async downloadFile(code: string): Promise<Blob> {
|
||||
const response = await api.get(`/download/${code}`, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
static async deleteFile(fileId: string): Promise<ApiResponse> {
|
||||
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<ApiResponse<FileListResponse>> {
|
||||
return api.get('/admin/file/list', { params })
|
||||
}
|
||||
|
||||
static async updateFile(data: FileEditForm): Promise<ApiResponse> {
|
||||
return api.patch('/admin/file/update', data)
|
||||
}
|
||||
|
||||
static async deleteAdminFile(id: number): Promise<ApiResponse> {
|
||||
return api.delete('/admin/file/delete', {
|
||||
data: { id }
|
||||
})
|
||||
}
|
||||
|
||||
static async downloadAdminFile(
|
||||
id: number
|
||||
): Promise<{ data: Blob; headers: Record<string, string> }> {
|
||||
return api.get('/admin/file/download', {
|
||||
params: { id },
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 认证服务
|
||||
export class AuthService {
|
||||
static async login(password: string): Promise<ApiResponse<AdminUser>> {
|
||||
return api.post('/admin/login', { password })
|
||||
}
|
||||
|
||||
static async logout(): Promise<ApiResponse> {
|
||||
return api.post('/admin/logout')
|
||||
}
|
||||
|
||||
static async verifyToken(): Promise<ApiResponse<AdminUser>> {
|
||||
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<ApiResponse<DashboardData>> {
|
||||
return api.get('/admin/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
// 预签名上传服务
|
||||
export class PresignUploadService {
|
||||
/**
|
||||
* 初始化上传会话
|
||||
*/
|
||||
static async initUpload(request: PresignInitRequest): Promise<ApiResponse<PresignInitResponse>> {
|
||||
return api.post('/presign/upload/init', request)
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理模式上传
|
||||
*/
|
||||
static async proxyUpload(
|
||||
uploadId: string,
|
||||
file: File,
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
): Promise<ApiResponse<PresignUploadResult>> {
|
||||
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<ApiResponse<PresignUploadResult>> {
|
||||
return api.post(`/presign/upload/confirm/${uploadId}`, request || {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询上传状态
|
||||
*/
|
||||
static async getUploadStatus(uploadId: string): Promise<ApiResponse<PresignStatusResponse>> {
|
||||
return api.get(`/presign/upload/status/${uploadId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消上传
|
||||
*/
|
||||
static async cancelUpload(uploadId: string): Promise<ApiResponse<{ message: string }>> {
|
||||
return api.delete(`/presign/upload/${uploadId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* S3 直传(不经过后端)
|
||||
*/
|
||||
static async directUploadToS3(
|
||||
uploadUrl: string,
|
||||
file: File,
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
): Promise<boolean> {
|
||||
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'
|
||||
|
||||
@@ -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<ApiResponse<PresignInitResponse>> {
|
||||
return api.post('/presign/upload/init', request)
|
||||
}
|
||||
|
||||
static async proxyUpload(
|
||||
uploadId: string,
|
||||
file: File,
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
): Promise<ApiResponse<PresignUploadResult>> {
|
||||
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<ApiResponse<PresignUploadResult>> {
|
||||
return api.post(`/presign/upload/confirm/${uploadId}`, request || {})
|
||||
}
|
||||
|
||||
static async getUploadStatus(uploadId: string): Promise<ApiResponse<PresignStatusResponse>> {
|
||||
return api.get(`/presign/upload/status/${uploadId}`)
|
||||
}
|
||||
|
||||
static async cancelUpload(uploadId: string): Promise<ApiResponse<{ message: string }>> {
|
||||
return api.delete(`/presign/upload/${uploadId}`)
|
||||
}
|
||||
|
||||
static async directUploadToS3(
|
||||
uploadUrl: string,
|
||||
file: File,
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
): Promise<boolean> {
|
||||
await uploadToExternalUrl(uploadUrl, file, onProgress)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import api from './client'
|
||||
import type { ApiResponse, DashboardData } from '@/types'
|
||||
|
||||
export class StatsService {
|
||||
static async getDashboard(): Promise<ApiResponse<DashboardData>> {
|
||||
return api.get('/admin/dashboard')
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<number>, 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<ApiResponse<ChunkedUploadResult>> => {
|
||||
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
|
||||
}
|
||||
+15
-12
@@ -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<AdminUser | null>(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
|
||||
|
||||
@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
|
||||
import type { Alert, AlertType } from '@/types'
|
||||
import { TIME_CONSTANTS } from '@/constants'
|
||||
|
||||
let progressTimer: ReturnType<typeof setInterval> | 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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<PublicConfig>({
|
||||
...DEFAULT_PUBLIC_CONFIG,
|
||||
...toPublicConfig(readStoredConfig<Partial<ConfigState>>())
|
||||
})
|
||||
|
||||
const uploadSizeLimit = computed(() => config.value.uploadSize)
|
||||
|
||||
const updateConfig = (nextConfig: Partial<ConfigState>) => {
|
||||
config.value = {
|
||||
...DEFAULT_PUBLIC_CONFIG,
|
||||
...config.value,
|
||||
...toPublicConfig(nextConfig)
|
||||
}
|
||||
writeStoredConfig(config.value)
|
||||
}
|
||||
|
||||
const applyRemoteConfig = (nextConfig: Partial<ConfigState>): 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<Partial<ConfigState>>())
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
uploadSizeLimit,
|
||||
applyRemoteConfig,
|
||||
updateConfig,
|
||||
reloadStoredConfig
|
||||
}
|
||||
})
|
||||
+17
-252
@@ -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<UploadStatus>(UPLOAD_STATUS.IDLE)
|
||||
const uploadProgress = ref<UploadProgress>({
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
percentage: 0
|
||||
})
|
||||
const uploadedCode = ref('')
|
||||
const currentFile = ref<File | null>(null)
|
||||
|
||||
// 下载相关状态
|
||||
const downloadCode = ref('')
|
||||
const fileInfo = ref<FileInfo | null>(null)
|
||||
const isDownloading = ref(false)
|
||||
|
||||
// 文件列表状态(管理页面使用)
|
||||
const fileList = ref<FileInfo[]>([])
|
||||
const totalFiles = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const isLoadingList = ref(false)
|
||||
|
||||
// 接收数据状态(取件记录)
|
||||
const receiveData = ref<Array<{
|
||||
id: number
|
||||
code: string
|
||||
filename: string
|
||||
size: string
|
||||
downloadUrl: string | null
|
||||
content: string | null
|
||||
date: string
|
||||
}>>([])
|
||||
|
||||
// 分享数据状态(发送记录)
|
||||
const shareData = ref<Array<{
|
||||
id: number
|
||||
filename: string
|
||||
date: string
|
||||
size: string
|
||||
expiration: string
|
||||
retrieveCode: string
|
||||
}>>([])
|
||||
|
||||
// 计算属性
|
||||
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<FileInfo>) => {
|
||||
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 receiveData = ref<ReceivedFileRecord[]>([])
|
||||
const shareData = ref<SentFileRecord[]>([])
|
||||
|
||||
// 添加分享数据方法
|
||||
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 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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
message?: string
|
||||
detail?: T
|
||||
}
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
detail?: string
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
username: string
|
||||
token: string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
+7
-243
@@ -1,243 +1,7 @@
|
||||
// 通用类型定义
|
||||
export interface ApiResponse<T = unknown> {
|
||||
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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+2
-74
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+45
-50
@@ -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<boolean> => {
|
||||
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<boolean> 是否复制成功
|
||||
*/
|
||||
export const copyRetrieveLink = async (code: string): Promise<boolean> => {
|
||||
const link = `${window.location.origin}/#/?code=${code}`
|
||||
export const copyRetrieveLink = async (
|
||||
code: string,
|
||||
options: Pick<CopyOptions, 'notify' | 'showMsg'> = {}
|
||||
): Promise<boolean> => {
|
||||
const link = buildRetrieveUrl(code)
|
||||
return copyToClipboard(link, {
|
||||
successMsg: '取件链接已复制到剪贴板',
|
||||
errorMsg: '复制失败,请手动复制取件链接'
|
||||
errorMsg: '复制失败,请手动复制取件链接',
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,50 +87,26 @@ export const copyRetrieveLink = async (code: string): Promise<boolean> => {
|
||||
* @param code 取件码
|
||||
* @returns Promise<boolean> 是否复制成功
|
||||
*/
|
||||
export const copyRetrieveCode = async (code: string): Promise<boolean> => {
|
||||
export const copyRetrieveCode = async (
|
||||
code: string,
|
||||
options: Pick<CopyOptions, 'notify' | 'showMsg'> = {}
|
||||
): Promise<boolean> => {
|
||||
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<CopyOptions, 'notify' | 'showMsg'> = {}
|
||||
) => {
|
||||
const command = buildWgetCommand(retrieveCode, fileName)
|
||||
void copyToClipboard(command, {
|
||||
successMsg: '命令已复制到剪贴板',
|
||||
errorMsg: '复制失败,请手动复制命令',
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
+23
-31
@@ -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<boolean> 是否复制成功
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
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, ',')
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<FileSizeUnit, number> = {
|
||||
KB: 1024,
|
||||
MB: 1024 * 1024,
|
||||
GB: 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
const SAVE_TIME_UNITS: Record<SaveTimeUnit, number> = {
|
||||
秒: 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)
|
||||
}
|
||||
}
|
||||
@@ -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 extends object = Partial<ConfigState>>(): 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<ConfigState> | null | undefined): Partial<PublicConfig> {
|
||||
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<ConfigState>)))
|
||||
}
|
||||
|
||||
export function readNotifyKey(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEYS.NOTIFY)
|
||||
}
|
||||
|
||||
export function writeNotifyKey(notifyKey: string) {
|
||||
localStorage.setItem(STORAGE_KEYS.NOTIFY, notifyKey)
|
||||
}
|
||||
@@ -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<string> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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<string> => {
|
||||
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<string> => {
|
||||
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<File> => {
|
||||
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' })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ApiResponse, SendType, SentFileRecord } from '@/types'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => 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<string, number> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}"`
|
||||
}
|
||||
+39
-416
@@ -42,24 +42,23 @@
|
||||
<FileDetailModal
|
||||
:visible="!!selectedRecord"
|
||||
:record="selectedRecord"
|
||||
@close="selectedRecord = null"
|
||||
@show-content-preview="showContentPreview"
|
||||
:get-download-url="getDownloadUrl"
|
||||
:get-qr-code-value="getQRCodeValue"
|
||||
@close="closeDetails"
|
||||
@preview-content="showContentPreview"
|
||||
/>
|
||||
|
||||
<ContentPreviewModal
|
||||
:visible="showPreview"
|
||||
:rendered-content="renderedContent"
|
||||
@close="showPreview = false"
|
||||
@close="closeContentPreview"
|
||||
@copy-content="copyContent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { inject, onMounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import RetrieveForm from '@/components/common/RetrieveForm.vue'
|
||||
import PageFooter from '@/components/common/PageFooter.vue'
|
||||
@@ -67,424 +66,48 @@ import SideDrawer from '@/components/common/SideDrawer.vue'
|
||||
import FileDetailModal from '@/components/common/FileDetailModal.vue'
|
||||
import FileRecordList from '@/components/common/FileRecordList.vue'
|
||||
import ContentPreviewModal from '@/components/common/ContentPreviewModal.vue'
|
||||
import { useRetrieveFlow } from '@/composables'
|
||||
import { useConfigStore } from '@/stores/configStore'
|
||||
|
||||
// 定义数据接口
|
||||
interface FileRecord {
|
||||
id: number
|
||||
code: string
|
||||
filename: string
|
||||
size: string
|
||||
downloadUrl: string | null
|
||||
content: string | null
|
||||
date: string
|
||||
}
|
||||
|
||||
interface InputStatus {
|
||||
readonly: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useFileDataStore } from '@/stores/fileData'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import api from '@/utils/api'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
interface RetrieveResponse {
|
||||
code: string
|
||||
name: string
|
||||
text: string
|
||||
size: number
|
||||
}
|
||||
import { saveAs } from 'file-saver'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const alertStore = useAlertStore()
|
||||
const baseUrl = window.location.origin
|
||||
|
||||
const router = useRouter()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const fileStore = useFileDataStore()
|
||||
const { receiveData } = storeToRefs(fileStore)
|
||||
const code = ref('')
|
||||
const inputStatus = ref<InputStatus>({
|
||||
readonly: false,
|
||||
loading: false
|
||||
})
|
||||
|
||||
const error = ref('')
|
||||
const selectedRecord = ref<FileRecord | null>(null)
|
||||
const showDrawer = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
// 使用 receiveData 替代原来的 records
|
||||
const records = receiveData
|
||||
const config = JSON.parse(localStorage.getItem('config') || '{}')
|
||||
const codeInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (codeInput.value) {
|
||||
codeInput.value.focus()
|
||||
}
|
||||
const query_code = route.query.code
|
||||
if (query_code && typeof query_code === 'string') {
|
||||
code.value = query_code
|
||||
}
|
||||
})
|
||||
watch(code, (newVal) => {
|
||||
if (newVal.length === 5) {
|
||||
handleSubmit()
|
||||
}
|
||||
})
|
||||
|
||||
const getDownloadUrl = (record: FileRecord) => {
|
||||
if (record.downloadUrl) {
|
||||
if (record.downloadUrl.startsWith('http')) {
|
||||
return record.downloadUrl
|
||||
} else {
|
||||
return `${baseUrl}${record.downloadUrl}`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
// 在其他代码后添加复制功能
|
||||
const copyContent = async () => {
|
||||
if (selectedRecord.value && selectedRecord.value.content) {
|
||||
await copyToClipboard(selectedRecord.value.content, {
|
||||
successMsg: t('fileRecord.contentCopied'),
|
||||
errorMsg: t('fileRecord.copyFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
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 response = await api.post('/share/select/', {
|
||||
code: code.value
|
||||
})
|
||||
const res = (response.data || response) as ApiResponse<RetrieveResponse>
|
||||
|
||||
if (res && res.code === 200) {
|
||||
if (res.detail) {
|
||||
const isFile = res.detail.text.startsWith('/share/download') || res.detail.name !== 'Text'
|
||||
const newFileData = {
|
||||
id: Date.now(),
|
||||
code: res.detail.code,
|
||||
filename: res.detail.name,
|
||||
size: formatFileSize(res.detail.size),
|
||||
downloadUrl: isFile ? res.detail.text : null,
|
||||
content: isFile ? null : res.detail.text,
|
||||
date: new Date().toLocaleString()
|
||||
}
|
||||
let flag = true
|
||||
fileStore.receiveData.forEach((file: FileRecord) => {
|
||||
if (file.code === newFileData.code) {
|
||||
flag = false
|
||||
}
|
||||
})
|
||||
if (flag) {
|
||||
fileStore.addReceiveData(newFileData)
|
||||
}
|
||||
if (isFile) {
|
||||
selectedRecord.value = newFileData
|
||||
} else {
|
||||
selectedRecord.value = newFileData
|
||||
showPreview.value = true
|
||||
}
|
||||
alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
|
||||
} else {
|
||||
alertStore.showAlert(t('retrieve.messages.invalidCodeError'), 'error')
|
||||
}
|
||||
} else {
|
||||
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + res.detail, 'error')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Retrieve failed:', err)
|
||||
const error = err as { response?: { data?: { detail?: string } }; message?: string }
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || t('retrieve.messages.unknownError')
|
||||
alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
|
||||
} finally {
|
||||
inputStatus.value.readonly = false
|
||||
inputStatus.value.loading = false
|
||||
code.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
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 viewDetails = (record: FileRecord) => {
|
||||
selectedRecord.value = record
|
||||
}
|
||||
|
||||
const deleteRecord = (id: number) => {
|
||||
const index = records.value.findIndex((record: FileRecord) => record.id === id)
|
||||
if (index !== -1) {
|
||||
fileStore.deleteReceiveData(index)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDrawer = () => {
|
||||
showDrawer.value = !showDrawer.value
|
||||
}
|
||||
const router = useRouter()
|
||||
const configStore = useConfigStore()
|
||||
const { config } = storeToRefs(configStore)
|
||||
const {
|
||||
code,
|
||||
inputStatus,
|
||||
error,
|
||||
records,
|
||||
selectedRecord,
|
||||
showDrawer,
|
||||
showPreview,
|
||||
renderedContent,
|
||||
closeContentPreview,
|
||||
closeDetails,
|
||||
copyContent,
|
||||
deleteRecord,
|
||||
downloadRecord,
|
||||
handleSubmit,
|
||||
showContentPreview,
|
||||
toggleDrawer,
|
||||
viewDetails
|
||||
} = useRetrieveFlow()
|
||||
|
||||
const toSend = () => {
|
||||
router.push('/send')
|
||||
}
|
||||
|
||||
const getQRCodeValue = (record: FileRecord) => {
|
||||
if (record.downloadUrl) {
|
||||
return `${baseUrl}${record.downloadUrl}`
|
||||
} else {
|
||||
return `${baseUrl}?code=${record.code}`
|
||||
onMounted(() => {
|
||||
const queryCode = route.query.code
|
||||
if (queryCode && typeof queryCode === 'string') {
|
||||
code.value = queryCode
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const downloadRecord = (record: FileRecord) => {
|
||||
if (record.downloadUrl) {
|
||||
// 如果是文件,直接下载
|
||||
window.open(
|
||||
`${record.downloadUrl.startsWith('http') ? '' : baseUrl}${record.downloadUrl}`,
|
||||
'_blank'
|
||||
)
|
||||
} else if (record.content) {
|
||||
// 如果是文本,转成txt下载
|
||||
const blob = new Blob([record.content], { type: 'text/plain;charset=utf-8' })
|
||||
saveAs(blob, `${record.filename}.txt`)
|
||||
watch(code, (newCode) => {
|
||||
if (newCode.length === 5) {
|
||||
void handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const showPreview = ref(false)
|
||||
const renderedContent = ref('')
|
||||
|
||||
// 监听selectedRecord变化,异步渲染内容
|
||||
watch(
|
||||
() => selectedRecord.value?.content,
|
||||
async (content) => {
|
||||
if (content) {
|
||||
try {
|
||||
// 使用 marked 解析 Markdown,然后用 DOMPurify 清理 HTML 防止 XSS
|
||||
const rawHtml = await marked(content)
|
||||
renderedContent.value = DOMPurify.sanitize(rawHtml, {
|
||||
// 允许的标签和属性
|
||||
ALLOWED_TAGS: [
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'em',
|
||||
'u',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'blockquote',
|
||||
'code',
|
||||
'pre',
|
||||
'a',
|
||||
'img'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],
|
||||
// 禁用危险的协议
|
||||
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)
|
||||
renderedContent.value = content // fallback 到原始内容
|
||||
}
|
||||
} else {
|
||||
renderedContent.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const showContentPreview = () => {
|
||||
showPreview.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes blob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translate(20px, -50px) scale(1.1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translate(50px, 50px) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:w-120 {
|
||||
width: 30rem;
|
||||
/* 480px */
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.w-97-100 {
|
||||
width: 97%;
|
||||
}
|
||||
|
||||
/* 添加 Markdown 样式 */
|
||||
:deep(.prose) {
|
||||
text-align: left;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.prose h1),
|
||||
:deep(.prose h2),
|
||||
:deep(.prose h3),
|
||||
:deep(.prose h4),
|
||||
:deep(.prose h5),
|
||||
:deep(.prose h6) {
|
||||
color: rgb(79, 70, 229);
|
||||
/* text-indigo-600 */
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:deep(.prose p),
|
||||
:deep(.prose div),
|
||||
:deep(.prose span),
|
||||
:deep(.prose code),
|
||||
:deep(.prose pre) {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.prose pre) {
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(.prose code) {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.prose h1),
|
||||
:deep(.prose h2),
|
||||
:deep(.prose h3),
|
||||
:deep(.prose h4),
|
||||
:deep(.prose h5),
|
||||
:deep(.prose h6) {
|
||||
color: rgb(129, 140, 248);
|
||||
/* text-indigo-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加新的宽度类 */
|
||||
@media (min-width: 640px) {
|
||||
.sm\:w-120 {
|
||||
width: 30rem;
|
||||
/* 480px */
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.3) rgba(243, 244, 246, 0.5);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(243, 244, 246, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
/* 深色模式下的滚动条样式 */
|
||||
:deep([class*='dark']) .custom-scrollbar {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(31, 41, 55, 0.5);
|
||||
}
|
||||
|
||||
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(31, 41, 55, 0.5);
|
||||
}
|
||||
|
||||
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(75, 85, 99, 0.7);
|
||||
}
|
||||
|
||||
/* 确保滚动容器有背景色 */
|
||||
.custom-scrollbar {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
/* 文本换行相关样式 */
|
||||
.break-words {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overflow-wrap-anywhere {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
+60
-1085
File diff suppressed because it is too large
Load Diff
@@ -1,134 +1,394 @@
|
||||
<template>
|
||||
<div class="p-6 overflow-y-auto custom-scrollbar">
|
||||
<h2 class="text-2xl font-bold mb-6" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
|
||||
{{ t('admin.dashboard.title') }}
|
||||
</h2>
|
||||
<!-- 统计卡片区域 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm" :class="[mutedTextClass]">FileCodeBox Admin</p>
|
||||
<h2 class="text-2xl font-bold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="fetchDashboardData"
|
||||
class="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-800 text-gray-200 hover:bg-gray-700'
|
||||
: 'bg-white text-gray-700 shadow-sm hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
<RefreshCwIcon class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.dashboard.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
:title="t('admin.dashboard.totalFiles')"
|
||||
:value="dashboardData.totalFiles"
|
||||
:icon="FileIcon"
|
||||
:icon="FilesIcon"
|
||||
icon-color="indigo"
|
||||
description-type="success">
|
||||
>
|
||||
<template #description>
|
||||
<span>{{ t('admin.dashboard.yesterday') }}:{{ dashboardData.yesterdayCount }}</span>
|
||||
<span class="ml-2">{{ t('admin.dashboard.today') }}:{{ dashboardData.todayCount }}</span>
|
||||
{{ t('admin.dashboard.yesterdayShares', { count: dashboardData.yesterdayCount }) }}
|
||||
</template>
|
||||
</StatCard>
|
||||
|
||||
<StatCard
|
||||
:title="t('admin.dashboard.storageSpace')"
|
||||
:value="dashboardData.storageUsed"
|
||||
:value="dashboardData.storageUsedText"
|
||||
:icon="HardDriveIcon"
|
||||
icon-color="purple"
|
||||
description-type="success">
|
||||
>
|
||||
<template #description>
|
||||
<span>{{ t('admin.dashboard.yesterday') }}:{{ dashboardData.yesterdaySize }}</span>
|
||||
<span class="ml-2">{{ t('admin.dashboard.today') }}:{{ dashboardData.todaySize }}</span>
|
||||
{{ t('admin.dashboard.todayIncrease', { count: dashboardData.todaySizeText }) }}
|
||||
</template>
|
||||
</StatCard>
|
||||
|
||||
<StatCard
|
||||
:title="t('admin.dashboard.activeUsers')"
|
||||
value="25"
|
||||
:icon="UsersIcon"
|
||||
:title="t('admin.dashboard.todayShares')"
|
||||
:value="dashboardData.todayCount"
|
||||
:icon="UploadCloudIcon"
|
||||
icon-color="green"
|
||||
description-type="error">
|
||||
>
|
||||
<template #description>
|
||||
<span>{{ t('admin.dashboard.weeklyChange') }}</span>
|
||||
{{ t('admin.dashboard.yesterdayShares', { count: dashboardData.yesterdayCount }) }}
|
||||
</template>
|
||||
</StatCard>
|
||||
|
||||
<StatCard
|
||||
:title="t('admin.dashboard.systemStatus')"
|
||||
:value="t('admin.dashboard.normal')"
|
||||
:icon="ActivityIcon"
|
||||
:title="t('admin.dashboard.totalRetrievals')"
|
||||
:value="dashboardData.usedCount"
|
||||
:icon="DownloadCloudIcon"
|
||||
icon-color="blue"
|
||||
description-type="neutral">
|
||||
>
|
||||
<template #description>
|
||||
{{ t('admin.dashboard.serverUptime') }}: {{ dashboardData.sysUptime }}
|
||||
{{ t('admin.dashboard.serverUptime') }} {{ dashboardData.sysUptimeText }}
|
||||
</template>
|
||||
</StatCard>
|
||||
</div>
|
||||
|
||||
<!-- 添加版本和版权信息 -->
|
||||
<div class="mt-auto text-center py-4" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
|
||||
<p class="text-sm">
|
||||
版本 v2.2.1 更新时间:2025-09-04
|
||||
</p>
|
||||
<p class="text-sm mt-1">
|
||||
© {{ new Date().getFullYear() }} <a href="https://github.com/vastsa/FileCodeBox">FileCodeBox</a>
|
||||
</p>
|
||||
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<section class="xl:col-span-2 rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.fileHealth') }}
|
||||
</h3>
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.fileHealthDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<ActivityIcon class="h-5 w-5" :class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<MetricProgress
|
||||
:label="t('admin.dashboard.activeFileRatio')"
|
||||
:value="dashboardData.activeRatio"
|
||||
:detail="`${dashboardData.activeCount} / ${dashboardData.totalFiles}`"
|
||||
tone="green"
|
||||
/>
|
||||
<MetricProgress
|
||||
:label="t('admin.dashboard.fileShareRatio')"
|
||||
:value="dashboardData.fileRatio"
|
||||
:detail="t('admin.dashboard.binaryFiles', { count: dashboardData.fileCount })"
|
||||
tone="indigo"
|
||||
/>
|
||||
<MetricProgress
|
||||
:label="t('admin.dashboard.textShareRatio')"
|
||||
:value="dashboardData.textRatio"
|
||||
:detail="t('admin.dashboard.textShares', { count: dashboardData.textCount })"
|
||||
tone="purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4" :class="[subtlePanelClass]">
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.expiredFiles') }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-end justify-between">
|
||||
<strong class="text-3xl" :class="[primaryTextClass]">
|
||||
{{ dashboardData.expiredCount }}
|
||||
</strong>
|
||||
<span class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.needCleanup') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4" :class="[subtlePanelClass]">
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.chunkedFiles') }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-end justify-between">
|
||||
<strong class="text-3xl" :class="[primaryTextClass]">
|
||||
{{ dashboardData.chunkedCount }}
|
||||
</strong>
|
||||
<span class="text-sm" :class="[mutedTextClass]">
|
||||
{{ dashboardData.enableChunk ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
||||
<div class="mb-5">
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.storagePolicy') }}
|
||||
</h3>
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.storagePolicyDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<PolicyRow
|
||||
:label="t('admin.dashboard.storageBackend')"
|
||||
:value="dashboardData.storageBackend"
|
||||
/>
|
||||
<PolicyRow
|
||||
:label="t('admin.dashboard.singleFileLimit')"
|
||||
:value="dashboardData.uploadSizeLimitText"
|
||||
/>
|
||||
<PolicyRow
|
||||
:label="t('admin.dashboard.guestUpload')"
|
||||
:value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')"
|
||||
/>
|
||||
<PolicyRow
|
||||
:label="t('admin.dashboard.maxSaveTime')"
|
||||
:value="maxSaveTimeText"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<div class="mb-2 flex items-center justify-between text-sm">
|
||||
<span :class="[mutedTextClass]">{{ t('admin.dashboard.todayCapacityReference') }}</span>
|
||||
<span :class="[primaryTextClass]">{{ dashboardData.todaySizeRatio }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
|
||||
<div
|
||||
class="h-full rounded-full bg-indigo-500"
|
||||
:style="{ width: `${dashboardData.todaySizeRatio}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.fileTypeDistribution') }}
|
||||
</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div v-if="dashboardData.topSuffixes.length === 0" class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('common.noData') }}
|
||||
</div>
|
||||
<div v-for="item in dashboardData.topSuffixes" :key="item.suffix" class="space-y-1">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span :class="[primaryTextClass]">{{ item.suffix || t('admin.dashboard.textType') }}</span>
|
||||
<span :class="[mutedTextClass]">{{ item.count }}</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
|
||||
<div
|
||||
class="h-full rounded-full bg-purple-500"
|
||||
:style="{ width: `${getSuffixRatio(item.count)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="xl:col-span-2 rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.recentFiles') }}
|
||||
</h3>
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.recentFilesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border" :class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']">
|
||||
<table class="min-w-full divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']">
|
||||
<thead :class="[isDarkMode ? 'bg-gray-900/50' : 'bg-gray-50']">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.file') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.size') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.usage') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.status') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-100']">
|
||||
<tr v-if="dashboardData.recentFiles.length === 0">
|
||||
<td colspan="4" class="px-4 py-6 text-center text-sm" :class="[mutedTextClass]">
|
||||
{{ t('common.noData') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="file in dashboardData.recentFiles" :key="file.id">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg p-2" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
|
||||
<FileTextIcon v-if="file.text" class="h-4 w-4" :class="[mutedTextClass]" />
|
||||
<FileIcon v-else class="h-4 w-4" :class="[mutedTextClass]" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium" :class="[primaryTextClass]">
|
||||
{{ file.name || file.code }}
|
||||
</p>
|
||||
<p class="text-xs" :class="[mutedTextClass]">
|
||||
{{ file.code }} · {{ formatCreatedAt(file.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
|
||||
{{ formatFileSize(file.size) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
|
||||
{{ file.usedCount }} {{ t('common.times') }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:class="[
|
||||
file.isExpired
|
||||
? isDarkMode
|
||||
? 'bg-red-900/40 text-red-300'
|
||||
: 'bg-red-100 text-red-700'
|
||||
: isDarkMode
|
||||
? 'bg-green-900/40 text-green-300'
|
||||
: 'bg-green-100 text-green-700'
|
||||
]"
|
||||
>
|
||||
{{ file.isExpired ? t('common.expiredFile') : t('admin.dashboard.available') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, reactive } from 'vue'
|
||||
import { computed, defineComponent, h, onMounted } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import {
|
||||
FileIcon,
|
||||
HardDriveIcon,
|
||||
UsersIcon,
|
||||
ActivityIcon,
|
||||
DownloadCloudIcon,
|
||||
FileIcon,
|
||||
FilesIcon,
|
||||
FileTextIcon,
|
||||
HardDriveIcon,
|
||||
RefreshCwIcon,
|
||||
UploadCloudIcon
|
||||
} from 'lucide-vue-next'
|
||||
import { StatsService } from '@/services'
|
||||
import type { DashboardData } from '@/types'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const { t } = useI18n()
|
||||
import { formatFileSize, formatTimestamp } from '@/utils/common'
|
||||
|
||||
const dashboardData = reactive<DashboardData>({
|
||||
totalFiles: 0,
|
||||
storageUsed: 0,
|
||||
yesterdayCount: 0,
|
||||
todayCount: 0,
|
||||
yesterdaySize: 0,
|
||||
todaySize: 0,
|
||||
sysUptime: 0
|
||||
const isDarkMode = useInjectedDarkMode()
|
||||
const { t } = useI18n()
|
||||
const { dashboardData, fetchDashboardData } = useDashboardStats()
|
||||
|
||||
const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
|
||||
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
|
||||
const panelClass = computed(() =>
|
||||
isDarkMode.value ? 'bg-gray-800/80 border border-gray-700' : 'bg-white border border-gray-100'
|
||||
)
|
||||
const subtlePanelClass = computed(() =>
|
||||
isDarkMode.value ? 'border-gray-700 bg-gray-900/30' : 'border-gray-100 bg-gray-50'
|
||||
)
|
||||
const maxSuffixCount = computed(() =>
|
||||
Math.max(...dashboardData.topSuffixes.map((item) => item.count), 1)
|
||||
)
|
||||
const maxSaveTimeText = computed(() => {
|
||||
if (!dashboardData.maxSaveSeconds) return t('admin.dashboard.noSaveLimit')
|
||||
const days = Math.floor(dashboardData.maxSaveSeconds / 86400)
|
||||
if (days >= 1) return `${days}${t('common.day')}`
|
||||
const hours = Math.floor(dashboardData.maxSaveSeconds / 3600)
|
||||
if (hours >= 1) return `${hours}${t('common.hour')}`
|
||||
return `${Math.floor(dashboardData.maxSaveSeconds / 60)}${t('common.minute')}`
|
||||
})
|
||||
|
||||
const getSysUptime = (startTimestamp: number) => {
|
||||
const now = new Date().getTime()
|
||||
const uptime = 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}小时`
|
||||
const getSuffixRatio = (count: number) => Math.round((count / maxSuffixCount.value) * 100)
|
||||
|
||||
const formatCreatedAt = (value: string | null) => {
|
||||
if (!value) return '-'
|
||||
return formatTimestamp(value, 'datetime')
|
||||
}
|
||||
|
||||
const getLocalstorageUsed = (nowUsedBit: string) => {
|
||||
const kb = parseInt(nowUsedBit) / 1024
|
||||
const mb = kb / 1024
|
||||
const gb = mb / 1024
|
||||
const tb = gb / 1024
|
||||
// 根据大小选择合适的单位
|
||||
if (tb > 1) {
|
||||
return `${tb.toFixed(2)}TB`
|
||||
} else if (gb > 1) {
|
||||
return `${gb.toFixed(2)}GB`
|
||||
} else if (mb > 1) {
|
||||
return `${mb.toFixed(2)}MB`
|
||||
} else if (kb > 1) {
|
||||
return `${kb.toFixed(2)}KB`
|
||||
} else {
|
||||
return `${nowUsedBit}B`
|
||||
const MetricProgress = defineComponent({
|
||||
name: 'MetricProgress',
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
value: { type: Number, required: true },
|
||||
detail: { type: String, required: true },
|
||||
tone: {
|
||||
type: String as PropType<'green' | 'indigo' | 'purple'>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const toneClass = computed(() => {
|
||||
const classes = {
|
||||
green: 'bg-green-500',
|
||||
indigo: 'bg-indigo-500',
|
||||
purple: 'bg-purple-500'
|
||||
}
|
||||
return classes[props.tone]
|
||||
})
|
||||
|
||||
return () =>
|
||||
h('div', { class: 'rounded-lg border p-4 border-gray-200/60 dark:border-gray-700' }, [
|
||||
h('div', { class: 'mb-2 flex items-center justify-between text-sm' }, [
|
||||
h('span', { class: 'text-gray-500 dark:text-gray-400' }, props.label),
|
||||
h('span', { class: 'font-medium text-gray-900 dark:text-white' }, `${props.value}%`)
|
||||
]),
|
||||
h('div', { class: 'h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700' }, [
|
||||
h('div', {
|
||||
class: ['h-full rounded-full', toneClass.value],
|
||||
style: { width: `${props.value}%` }
|
||||
})
|
||||
]),
|
||||
h('p', { class: 'mt-2 text-sm text-gray-500 dark:text-gray-400' }, props.detail)
|
||||
])
|
||||
}
|
||||
}
|
||||
const getDashboardData = async () => {
|
||||
const response = await StatsService.getDashboard()
|
||||
if (response.detail) {
|
||||
dashboardData.totalFiles = response.detail.totalFiles
|
||||
dashboardData.storageUsed = getLocalstorageUsed(response.detail.storageUsed.toString())
|
||||
dashboardData.yesterdaySize = getLocalstorageUsed(response.detail.yesterdaySize.toString())
|
||||
dashboardData.todaySize = getLocalstorageUsed(response.detail.todaySize.toString())
|
||||
dashboardData.yesterdayCount = response.detail.yesterdayCount
|
||||
dashboardData.todayCount = response.detail.todayCount
|
||||
dashboardData.sysUptime = getSysUptime(Number(response.detail.sysUptime))
|
||||
})
|
||||
|
||||
const PolicyRow = defineComponent({
|
||||
name: 'PolicyRow',
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
value: { type: String, required: true }
|
||||
},
|
||||
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)
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getDashboardData()
|
||||
void fetchDashboardData()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="[isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-800']">
|
||||
{{ Math.round((file.size / 1024 / 1024) * 100) / 100 }}MB
|
||||
{{ file.displaySize }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
@@ -71,8 +71,8 @@
|
||||
</span>
|
||||
<!-- 查看全文按钮 - 仅当文本超过一定长度时显示 -->
|
||||
<button
|
||||
v-if="file.text && file.text.length > 30"
|
||||
@click="openTextPreview(file.text)"
|
||||
v-if="file.canPreviewText"
|
||||
@click="openTextPreview(file.text || '')"
|
||||
class="flex-shrink-0 inline-flex items-center px-2 py-1 rounded text-xs transition-colors duration-200"
|
||||
:class="[
|
||||
isDarkMode
|
||||
@@ -91,7 +91,7 @@
|
||||
? (isDarkMode ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-100 text-yellow-800')
|
||||
: (isDarkMode ? 'bg-green-900/30 text-green-400' : 'bg-green-100 text-green-800')
|
||||
]">
|
||||
{{ file.expired_at ? formatTimestamp(file.expired_at) : t('send.expiration.units.forever') }}
|
||||
{{ file.displayExpiredAt }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
@@ -167,125 +167,32 @@
|
||||
<!-- 表单内容 -->
|
||||
<div class="px-6 py-5">
|
||||
<div class="grid gap-6">
|
||||
<!-- 取件码 -->
|
||||
<div class="space-y-2 group">
|
||||
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
|
||||
<span>{{ t('fileManage.form.code') }}</span>
|
||||
<div class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input type="text" v-model="editForm.code"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]" :placeholder="t('fileManage.form.codePlaceholder')">
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件名称 -->
|
||||
<div class="space-y-2 group">
|
||||
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
|
||||
<span>{{ t('fileManage.form.filename') }}</span>
|
||||
<div class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input type="text" v-model="editForm.prefix"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]" :placeholder="t('fileManage.form.filenamePlaceholder')">
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件后缀 -->
|
||||
<div class="space-y-2 group">
|
||||
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
|
||||
<span>{{ t('fileManage.form.suffix') }}</span>
|
||||
<div class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input type="text" v-model="editForm.suffix"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]" :placeholder="t('fileManage.form.suffixPlaceholder')">
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 过期时间 -->
|
||||
<div class="space-y-2 group">
|
||||
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
|
||||
<span>{{ t('send.expiration.label') }}</span>
|
||||
<div class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input type="datetime-local" v-model="editForm.expired_at"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]">
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下载次数限制 -->
|
||||
<div class="space-y-2 group">
|
||||
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
|
||||
<span>{{ t('fileManage.form.downloadLimit') }}</span>
|
||||
<div class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input type="number" v-model="editForm.expired_count"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]" :placeholder="t('fileManage.form.downloadLimitPlaceholder')">
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FileEditField
|
||||
v-model="editForm.code"
|
||||
:label="t('fileManage.form.code')"
|
||||
:placeholder="t('fileManage.form.codePlaceholder')"
|
||||
/>
|
||||
<FileEditField
|
||||
v-model="editForm.prefix"
|
||||
:label="t('fileManage.form.filename')"
|
||||
:placeholder="t('fileManage.form.filenamePlaceholder')"
|
||||
/>
|
||||
<FileEditField
|
||||
v-model="editForm.suffix"
|
||||
:label="t('fileManage.form.suffix')"
|
||||
:placeholder="t('fileManage.form.suffixPlaceholder')"
|
||||
/>
|
||||
<FileEditField
|
||||
v-model="editForm.expired_at"
|
||||
type="datetime-local"
|
||||
:label="t('send.expiration.label')"
|
||||
/>
|
||||
<FileEditField
|
||||
v-model="editForm.expired_count"
|
||||
type="number"
|
||||
:label="t('fileManage.form.downloadLimit')"
|
||||
:placeholder="t('fileManage.form.downloadLimitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -390,10 +297,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref, computed } from 'vue'
|
||||
import { inject, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { FileService } from '@/services'
|
||||
import type { FileListItem, FileEditForm } from '@/types'
|
||||
import {
|
||||
FileIcon,
|
||||
SearchIcon,
|
||||
@@ -406,23 +311,11 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import DataPagination from '@/components/common/DataPagination.vue'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const year = date.getFullYear()
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
import FileEditField from '@/components/common/FileEditField.vue'
|
||||
import { useAdminFiles } from '@/composables'
|
||||
|
||||
const { t } = useI18n()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const tableData = ref<FileListItem[]>([])
|
||||
const alertStore = useAlertStore()
|
||||
// 修改文件表头
|
||||
const fileTableHeaders = computed(() => [
|
||||
t('fileManage.headers.code'),
|
||||
@@ -433,133 +326,28 @@ const fileTableHeaders = computed(() => [
|
||||
t('fileManage.headers.actions')
|
||||
])
|
||||
|
||||
// 分页参数
|
||||
const params = ref({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
keyword: ''
|
||||
const {
|
||||
tableData,
|
||||
params,
|
||||
showEditModal,
|
||||
editForm,
|
||||
showTextPreview,
|
||||
previewText,
|
||||
closeEditModal,
|
||||
closeTextPreview,
|
||||
copyText,
|
||||
deleteFile,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
handleUpdate,
|
||||
loadFiles,
|
||||
openEditModal,
|
||||
openTextPreview
|
||||
} = useAdminFiles()
|
||||
|
||||
onMounted(() => {
|
||||
void loadFiles()
|
||||
})
|
||||
|
||||
// 添加编辑相关的状态
|
||||
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 openTextPreview = (text: string) => {
|
||||
previewText.value = text
|
||||
showTextPreview.value = true
|
||||
}
|
||||
|
||||
// 关闭文本预览
|
||||
const closeTextPreview = () => {
|
||||
showTextPreview.value = false
|
||||
previewText.value = ''
|
||||
}
|
||||
|
||||
// 复制文本到剪贴板
|
||||
const copyText = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(previewText.value)
|
||||
alertStore.showAlert(t('fileManage.copySuccess'), 'success')
|
||||
} catch {
|
||||
alertStore.showAlert(t('fileManage.copyFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑模态框
|
||||
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
|
||||
editForm.value = {
|
||||
id: null,
|
||||
code: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
expired_at: '',
|
||||
expired_count: null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理更新
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await FileService.updateFile(editForm.value)
|
||||
await loadFiles()
|
||||
closeEditModal()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
alertStore.showAlert(err.response?.data?.detail || t('manage.fileManage.updateFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件处理
|
||||
const deleteFile = async (id: number) => {
|
||||
try {
|
||||
await FileService.deleteAdminFile(id)
|
||||
await loadFiles()
|
||||
} catch (error) {
|
||||
console.error(t('manage.fileManage.deleteFailed'), error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载文件列表
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
const res = await FileService.getAdminFileList(params.value)
|
||||
if (res.detail) {
|
||||
tableData.value = res.detail.data
|
||||
params.value.total = res.detail.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t('manage.fileManage.loadFileListFailed'), error)
|
||||
}
|
||||
}
|
||||
|
||||
// 页码改变处理函数
|
||||
const handlePageChange = async (page: number | string) => {
|
||||
if (typeof page === 'string') return // 忽略省略号
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
params.value.page = page
|
||||
await loadFiles()
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
loadFiles()
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = computed(() => Math.ceil(params.value.total / params.value.size))
|
||||
|
||||
|
||||
|
||||
// 添加搜索处理函数
|
||||
const handleSearch = async () => {
|
||||
params.value.page = 1 // 重置页码到第一页
|
||||
await loadFiles()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -39,8 +39,19 @@
|
||||
登录
|
||||
</h2>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" @submit.prevent="handleSubmit">
|
||||
<form class="mt-8 space-y-6" @submit.prevent="submitLogin">
|
||||
<input type="hidden" name="remember" value="true" />
|
||||
<label for="username" class="sr-only">用户名</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
value="admin"
|
||||
readonly
|
||||
tabindex="-1"
|
||||
class="sr-only"
|
||||
/>
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="password" class="sr-only">密码</label>
|
||||
@@ -83,57 +94,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { BoxIcon } from 'lucide-vue-next'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
import { useAdminData } from '@/stores/adminStore'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AuthService } from '@/services'
|
||||
const alertStore = useAlertStore()
|
||||
const password = ref('')
|
||||
const isLoading = ref(false)
|
||||
import { useAdminLogin } from '@/composables'
|
||||
import { ROUTES } from '@/constants'
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const adminStore = useAdminData()
|
||||
const validateForm = () => {
|
||||
let isValid = true
|
||||
if (!password.value) {
|
||||
alertStore.showAlert('无效的密码', 'error')
|
||||
isValid = false
|
||||
} else if (password.value.length < 6) {
|
||||
alertStore.showAlert('密码长度至少为6位', 'error')
|
||||
isValid = false
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { password, isLoading, handleSubmit } = useAdminLogin()
|
||||
|
||||
const getRedirectPath = () => {
|
||||
const redirect = route.query.redirect
|
||||
if (typeof redirect === 'string' && redirect.startsWith('/')) {
|
||||
return redirect
|
||||
}
|
||||
return isValid
|
||||
return ROUTES.ADMIN
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await AuthService.login(password.value)
|
||||
if (response.detail?.token) {
|
||||
adminStore.setToken(response.detail.token)
|
||||
router.push('/admin')
|
||||
} else {
|
||||
alertStore.showAlert('登录失败:未获取到有效令牌', 'error')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
interface ErrorWithResponse {
|
||||
response?: {
|
||||
data?: {
|
||||
detail?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
const errorMessage = error && typeof error === 'object' && 'response' in error
|
||||
? (error as ErrorWithResponse).response?.data?.detail || '登录失败'
|
||||
: '登录失败'
|
||||
alertStore.showAlert(errorMessage, 'error')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
const submitLogin = async () => {
|
||||
const success = await handleSubmit()
|
||||
if (success) {
|
||||
await router.push(getRedirectPath())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,152 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue'
|
||||
import { ConfigService } from '@/services'
|
||||
import type { ConfigState } from '@/types'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
import { inject, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SettingNumberInput from '@/components/common/SettingNumberInput.vue'
|
||||
import SettingSwitch from '@/components/common/SettingSwitch.vue'
|
||||
import { useSystemConfig } from '@/composables'
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
config,
|
||||
fileSize,
|
||||
sizeUnit,
|
||||
saveTime,
|
||||
saveTimeUnit,
|
||||
refreshConfig,
|
||||
submitConfig,
|
||||
toggleConfigFlag
|
||||
} = useSystemConfig()
|
||||
|
||||
const config = ref<ConfigState>({
|
||||
name: '',
|
||||
description: '',
|
||||
file_storage: '',
|
||||
webdav_url: '',
|
||||
webdav_username: '',
|
||||
webdav_password: '',
|
||||
themesChoices: [],
|
||||
expireStyle: [],
|
||||
admin_token: '',
|
||||
robotsText: '',
|
||||
keywords: '',
|
||||
notify_title: '',
|
||||
storage_path: '',
|
||||
notify_content: '',
|
||||
openUpload: 1,
|
||||
uploadSize: 1,
|
||||
enableChunk: 0,
|
||||
uploadMinute: 1,
|
||||
max_save_seconds: 0,
|
||||
opacity: 0.9,
|
||||
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: ''
|
||||
onMounted(() => {
|
||||
void refreshConfig()
|
||||
})
|
||||
|
||||
const fileSize = ref(1)
|
||||
const sizeUnit = ref('MB')
|
||||
|
||||
// 添加保存时间相关的响应式变量
|
||||
const saveTime = ref(1)
|
||||
const saveTimeUnit = ref('天')
|
||||
|
||||
// 转换时间为秒
|
||||
const convertToSeconds = (time: number, unit: string): number => {
|
||||
const units = {
|
||||
秒: 1,
|
||||
分: 60,
|
||||
时: 3600,
|
||||
天: 86400
|
||||
}
|
||||
return time * units[unit as keyof typeof units]
|
||||
}
|
||||
|
||||
const alertStore = useAlertStore()
|
||||
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
const res = await ConfigService.getConfig()
|
||||
if (res.code === 200 && res.detail) {
|
||||
// 直接使用ConfigState类型的响应数据
|
||||
config.value = res.detail
|
||||
|
||||
// 将字节转换为合适的单位
|
||||
const size = config.value.uploadSize
|
||||
if (size >= 1024 * 1024 * 1024) {
|
||||
fileSize.value = Math.round(size / (1024 * 1024 * 1024))
|
||||
sizeUnit.value = 'GB'
|
||||
} else if (size >= 1024 * 1024) {
|
||||
fileSize.value = Math.round(size / (1024 * 1024))
|
||||
sizeUnit.value = 'MB'
|
||||
} else {
|
||||
fileSize.value = Math.round(size / 1024)
|
||||
sizeUnit.value = 'KB'
|
||||
}
|
||||
|
||||
// 时间单位转换逻辑
|
||||
const seconds = config.value.max_save_seconds
|
||||
if (seconds === 0) {
|
||||
// 如果是0,显示为7天
|
||||
saveTime.value = 7
|
||||
saveTimeUnit.value = '天'
|
||||
} else if (seconds % 86400 === 0 && seconds >= 86400) {
|
||||
saveTime.value = seconds / 86400
|
||||
saveTimeUnit.value = '天'
|
||||
} else if (seconds % 3600 === 0 && seconds >= 3600) {
|
||||
saveTime.value = seconds / 3600
|
||||
saveTimeUnit.value = '时'
|
||||
} else if (seconds % 60 === 0 && seconds >= 60) {
|
||||
saveTime.value = seconds / 60
|
||||
saveTimeUnit.value = '分'
|
||||
} else {
|
||||
saveTime.value = seconds
|
||||
saveTimeUnit.value = '秒'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t('manage.systemSettings.getConfigFailed')
|
||||
alertStore.showAlert(errorMessage, 'error')
|
||||
console.error('Failed to get system config:', error)
|
||||
}
|
||||
}
|
||||
// 转换文件大小为字节
|
||||
const convertToBytes = (size: number, unit: string): number => {
|
||||
const units = {
|
||||
KB: 1024,
|
||||
MB: 1024 * 1024,
|
||||
GB: 1024 * 1024 * 1024
|
||||
}
|
||||
return size * units[unit as keyof typeof units]
|
||||
}
|
||||
|
||||
const submitSave = () => {
|
||||
const formData = { ...config.value }
|
||||
formData.uploadSize = convertToBytes(fileSize.value, sizeUnit.value)
|
||||
|
||||
// 如果保存时间为0,则默认设置为7天
|
||||
if (saveTime.value === 0) {
|
||||
formData.max_save_seconds = 7 * 86400 // 7天转换为秒
|
||||
} else {
|
||||
formData.max_save_seconds = convertToSeconds(saveTime.value, saveTimeUnit.value)
|
||||
}
|
||||
|
||||
ConfigService.updateConfig(formData).then((res) => {
|
||||
if (res.code == 200) {
|
||||
alertStore.showAlert(t('manage.systemSettings.saveSuccess'), 'success')
|
||||
} else {
|
||||
alertStore.showAlert(res.message || t('manage.systemSettings.saveFailed'), 'error')
|
||||
}
|
||||
}).catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : t('manage.systemSettings.saveFailed')
|
||||
alertStore.showAlert(errorMessage, 'error')
|
||||
})
|
||||
}
|
||||
|
||||
refreshData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -322,27 +196,14 @@ refreshData()
|
||||
<option value="webdav">{{ t('manage.settings.webdavStorage') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2" v-if="config.file_storage === 'local'">
|
||||
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.chunkUploadNote') }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<button type="button" @click="config.enableChunk = config.enableChunk === 1 ? 0 : 1"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
:class="[config.enableChunk === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
|
||||
:aria-checked="config.enableChunk === 1">
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
config.enableChunk === 1 ? 'translate-x-5' : 'translate-x-0',
|
||||
isDarkMode && config.enableChunk !== 1 ? 'bg-gray-100' : 'bg-white'
|
||||
]" />
|
||||
</button>
|
||||
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ config.enableChunk === 1 ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingSwitch
|
||||
v-if="config.file_storage === 'local'"
|
||||
:label="t('manage.settings.chunkUploadNote')"
|
||||
:model-value="config.enableChunk"
|
||||
:enabled-text="t('common.enabled')"
|
||||
:disabled-text="t('common.disabled')"
|
||||
@toggle="toggleConfigFlag('enableChunk')"
|
||||
/>
|
||||
<div v-if="config.file_storage === 'webdav'" class="space-y-4">
|
||||
<!-- 通知设置 -->
|
||||
<div class="space-y-2">
|
||||
@@ -482,51 +343,21 @@ refreshData()
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium mb-2"
|
||||
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.enableProxy') }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<button type="button" @click="config.s3_proxy = config.s3_proxy === 1 ? 0 : 1"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
:class="[config.s3_proxy === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
|
||||
:aria-checked="config.s3_proxy === 1">
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
config.s3_proxy === 1 ? 'translate-x-5' : 'translate-x-0',
|
||||
isDarkMode && config.s3_proxy !== 1 ? 'bg-gray-100' : 'bg-white'
|
||||
]" />
|
||||
</button>
|
||||
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ config.s3_proxy === 1 ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingSwitch
|
||||
:label="t('manage.settings.enableProxy')"
|
||||
:model-value="config.s3_proxy"
|
||||
:enabled-text="t('common.enabled')"
|
||||
:disabled-text="t('common.disabled')"
|
||||
@toggle="toggleConfigFlag('s3_proxy')"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium mb-2"
|
||||
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.chunkUploadNote') }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<button type="button" @click="config.enableChunk = config.enableChunk === 1 ? 0 : 1"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
:class="[config.enableChunk === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
|
||||
:aria-checked="config.enableChunk === 1">
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
config.enableChunk === 1 ? 'translate-x-5' : 'translate-x-0',
|
||||
isDarkMode && config.enableChunk !== 1 ? 'bg-gray-100' : 'bg-white'
|
||||
]" />
|
||||
</button>
|
||||
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ config.enableChunk === 1 ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingSwitch
|
||||
:label="t('manage.settings.chunkUploadNote')"
|
||||
:model-value="config.enableChunk"
|
||||
:enabled-text="t('common.enabled')"
|
||||
:disabled-text="t('common.disabled')"
|
||||
@toggle="toggleConfigFlag('enableChunk')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,37 +370,17 @@ refreshData()
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.uploadPerMinute') }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="number" v-model="config.uploadMinute"
|
||||
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.minute') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingNumberInput
|
||||
v-model="config.uploadMinute"
|
||||
:label="t('manage.settings.uploadPerMinute')"
|
||||
:suffix="t('common.minute')"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.uploadCountLimit') }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="number" v-model="config.uploadCount"
|
||||
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.files') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingNumberInput
|
||||
v-model="config.uploadCount"
|
||||
:label="t('manage.settings.uploadCountLimit')"
|
||||
:suffix="t('common.files')"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
@@ -647,27 +458,13 @@ refreshData()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.guestUpload') }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<button type="button" @click="config.openUpload = config.openUpload === 1 ? 0 : 1"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
:class="[config.openUpload === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
|
||||
:aria-checked="config.openUpload === 1">
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
config.openUpload === 1 ? 'translate-x-5' : 'translate-x-0',
|
||||
isDarkMode && config.openUpload !== 1 ? 'bg-gray-100' : 'bg-white'
|
||||
]" />
|
||||
</button>
|
||||
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ config.openUpload === 1 ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingSwitch
|
||||
:label="t('manage.settings.guestUpload')"
|
||||
:model-value="config.openUpload"
|
||||
:enabled-text="t('common.enabled')"
|
||||
:disabled-text="t('common.disabled')"
|
||||
@toggle="toggleConfigFlag('openUpload')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -678,43 +475,23 @@ refreshData()
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.errorPerMinute') }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="number" v-model="config.errorMinute"
|
||||
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.minute') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingNumberInput
|
||||
v-model="config.errorMinute"
|
||||
:label="t('manage.settings.errorPerMinute')"
|
||||
:suffix="t('common.minute')"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.errorCountLimit') }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="number" v-model="config.errorCount"
|
||||
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.times') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingNumberInput
|
||||
v-model="config.errorCount"
|
||||
:label="t('manage.settings.errorCountLimit')"
|
||||
:suffix="t('common.times')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="flex justify-end mt-8">
|
||||
<button @click="submitSave"
|
||||
<button @click="submitConfig"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200">
|
||||
{{ t('manage.settings.saveChanges') }}
|
||||
</button>
|
||||
|
||||
Vendored
+11
@@ -3,3 +3,14 @@ declare module '*.vue' {
|
||||
const componentOptions: ComponentOptions
|
||||
export default componentOptions
|
||||
}
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
requiresAuth?: boolean
|
||||
showGlobalControls?: boolean
|
||||
showRouteLoading?: boolean
|
||||
title?: string
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
Reference in New Issue
Block a user