feat: 优化代码结构(在Claude的帮助下)

This commit is contained in:
Lan
2025-09-04 12:01:26 +08:00
parent 68ce796a55
commit c14c4bca62
28 changed files with 2504 additions and 318 deletions

View File

@@ -3,12 +3,13 @@ import { ref, watchEffect, provide, onMounted } from 'vue'
import { RouterView } from 'vue-router'
import ThemeToggle from './components/common/ThemeToggle.vue'
import { useRouter } from 'vue-router'
import api from './utils/api'
const isDarkMode = ref(false)
const isLoading = ref(false)
const router = useRouter()
import AlertComponent from '@/components/common/AlertComponent.vue'
import { useAlertStore } from '@/stores/alertStore'
import { ConfigService } from '@/services'
import type { ApiResponse, ConfigState } from '@/types'
const alertStore = useAlertStore()
// 检查系统颜色模式
@@ -38,8 +39,9 @@ onMounted(() => {
} else {
setColorMode(checkSystemColorScheme())
}
api.post('/', {}).then((res: any) => {
if (res.code === 200) {
ConfigService.getUserConfig().then((res: ApiResponse<ConfigState>) => {
if (res.code === 200 && res.detail) {
console.log(res);
localStorage.setItem('config', JSON.stringify(res.detail))
if (
res.detail.notify_title &&

View File

@@ -0,0 +1,193 @@
<template>
<transition name="fade">
<div
v-if="visible"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
@click.self="$emit('close')"
>
<div
class="p-6 rounded-2xl max-w-3xl w-full mx-4 shadow-2xl transform transition-all duration-300 ease-out backdrop-filter backdrop-blur-lg bg-opacity-70 max-h-[85vh] overflow-hidden flex flex-col"
:class="[isDarkMode ? 'bg-gray-800' : 'bg-white']"
>
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<h3 class="text-2xl font-bold" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
内容预览
</h3>
<div class="flex items-center gap-3">
<button
@click="$emit('copy-content')"
class="px-4 py-1.5 rounded-lg transition duration-300 flex items-center gap-2 text-sm font-medium"
:class="[
isDarkMode
? 'bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white'
: 'bg-gray-100 hover:bg-gray-200 text-gray-700 hover:text-gray-900'
]"
>
<CopyIcon class="w-4 h-4" />
复制
</button>
<button
@click="$emit('close')"
class="p-1.5 rounded-lg transition duration-300 hover:bg-opacity-10"
:class="[
isDarkMode
? 'text-gray-400 hover:text-white hover:bg-white'
: 'text-gray-500 hover:text-gray-900 hover:bg-black'
]"
>
<XIcon class="w-5 h-5" />
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar">
<div
class="prose max-w-none p-6 rounded-xl break-words overflow-wrap-anywhere"
:class="[isDarkMode ? 'prose-invert bg-gray-900 bg-opacity-50' : 'bg-gray-50']"
v-html="renderedContent"
></div>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { CopyIcon, XIcon } from 'lucide-vue-next'
interface Props {
visible: boolean
renderedContent: string
}
interface Emits {
close: []
'copy-content': []
}
defineProps<Props>()
defineEmits<Emits>()
const isDarkMode = inject('isDarkMode')
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 自定义滚动条样式 */
.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;
}
/* 添加 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 */
}
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<div class="flex flex-col space-y-3">
<label :class="['text-sm font-medium', isDarkMode ? 'text-gray-300' : 'text-gray-700']">
过期时间
</label>
<div class="relative flex-grow group">
<div
:class="[
'relative h-12 rounded-2xl border transition-all duration-300 shadow-sm',
isDarkMode
? 'bg-gray-800/60 border-gray-700/60 group-hover:border-gray-600/80 group-hover:shadow-lg group-hover:shadow-gray-900/20'
: 'bg-white border-gray-200 group-hover:border-gray-300 group-hover:shadow-md group-hover:shadow-gray-200/50'
]"
>
<template v-if="expirationMethod !== 'forever'">
<input
:value="expirationValue"
@input="updateValue"
type="number"
:placeholder="getPlaceholder()"
min="1"
:class="[
'w-full h-full px-5 pr-32 rounded-2xl placeholder-gray-400 transition-all duration-300',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
'[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
'bg-transparent',
isDarkMode
? 'text-gray-100 focus:ring-indigo-500/80 placeholder-gray-500'
: 'text-gray-900 focus:ring-indigo-500/60 placeholder-gray-400'
]"
/>
<div
class="absolute right-28 top-0 h-full flex flex-col border-l"
:class="[isDarkMode ? 'border-gray-700/60' : 'border-gray-200']"
>
<button
type="button"
@click="incrementValue(1)"
class="flex-1 px-2 flex items-center justify-center transition-all duration-200"
:class="[
isDarkMode
? 'hover:bg-gray-700/60 text-gray-400 hover:text-gray-200'
: 'hover:bg-gray-50 text-gray-500 hover:text-gray-700'
]"
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
</button>
<button
type="button"
@click="incrementValue(-1)"
class="flex-1 px-2 flex items-center justify-center transition-all duration-200"
:class="[
isDarkMode
? 'hover:bg-gray-700/60 text-gray-400 hover:text-gray-200'
: 'hover:bg-gray-50 text-gray-500 hover:text-gray-700'
]"
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
</template>
<select
:value="expirationMethod"
@change="updateMethod"
:class="[
'absolute right-0 top-0 h-full px-4 rounded-r-2xl border-l transition-all duration-300',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
'bg-transparent appearance-none cursor-pointer',
isDarkMode
? 'border-gray-700/60 text-gray-300 focus:ring-indigo-500/80'
: 'border-gray-200 text-gray-700 focus:ring-indigo-500/60'
]"
>
<option value="count">次数</option>
<option value="minute">分钟</option>
<option value="hour">小时</option>
<option value="day"></option>
<option value="forever">永久</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']"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
interface Props {
expirationMethod: string
expirationValue: number
}
interface Emits {
'update:expirationMethod': [value: string]
'update:expirationValue': [value: number]
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isDarkMode = inject('isDarkMode')
const updateMethod = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('update:expirationMethod', target.value)
}
const updateValue = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:expirationValue', parseInt(target.value) || 1)
}
const incrementValue = (delta: number) => {
const currentValue = props.expirationValue || 1
const newValue = Math.max(1, currentValue + delta)
emit('update:expirationValue', newValue)
}
const getPlaceholder = () => {
switch (props.expirationMethod) {
case 'count':
return '输入次数'
case 'minute':
return '输入分钟数'
case 'hour':
return '输入小时数'
case 'day':
return '输入天数'
default:
return '输入数值'
}
}
</script>

View File

@@ -0,0 +1,170 @@
<template>
<transition name="fade">
<div
v-if="visible"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
@click.self="$emit('close')"
>
<div
class="p-8 rounded-2xl max-w-md w-full mx-4 shadow-2xl transform transition-all duration-300 ease-out backdrop-filter backdrop-blur-lg overflow-hidden"
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-70' : 'bg-white bg-opacity-95']"
>
<h3
class="text-2xl font-bold mb-6 truncate"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']"
>
文件详情
</h3>
<div class="space-y-4" v-if="record">
<div class="flex items-center">
<FileIcon
class="w-6 h-6 mr-3 flex-shrink-0"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
/>
<p
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-800']"
class="truncate flex-grow"
>
<span class="font-medium">文件名</span>{{ record.filename }}
</p>
</div>
<div class="flex items-center">
<CalendarIcon
class="w-6 h-6 mr-3 flex-shrink-0"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
/>
<p
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-800']"
class="truncate flex-grow"
>
<span class="font-medium">取件日期</span>{{ record.date }}
</p>
</div>
<div class="flex items-center">
<HardDriveIcon
class="w-6 h-6 mr-3 flex-shrink-0"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
/>
<p
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-800']"
class="truncate flex-grow"
>
<span class="font-medium">文件大小</span>{{ record.size }}
</p>
</div>
<div class="flex items-center">
<DownloadIcon
class="w-6 h-6 mr-3"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
/>
<p :class="[isDarkMode ? 'text-gray-300' : 'text-gray-800']">
<span class="font-medium">文件内容</span>
</p>
<div v-if="record.filename === 'Text'" class="ml-2">
<button
@click="$emit('preview-content')"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition duration-300"
>
预览内容
</button>
</div>
<div v-else>
<a
:href="getDownloadUrl(record)"
target="_blank"
rel="noopener noreferrer"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition duration-300"
>
点击下载
</a>
</div>
</div>
</div>
<div class="mt-6 flex flex-col items-center" v-if="record">
<h4
class="text-lg font-semibold mb-3"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']"
>
取件二维码
</h4>
<div class="bg-white p-2 rounded-lg shadow-md">
<QRCode :value="getQRCodeValue(record)" :size="128" level="M" />
</div>
<p class="mt-2 text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
扫描二维码快速取件
</p>
</div>
<button
@click="$emit('close')"
class="mt-8 w-full bg-gradient-to-r from-indigo-500 to-purple-600 text-white px-6 py-3 rounded-lg font-medium hover:from-indigo-600 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-opacity-50 transition duration-300 transform hover:scale-105"
>
关闭
</button>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { inject } from 'vue'
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
}
interface Props {
visible: boolean
record: FileRecord | null
}
interface Emits {
close: []
'preview-content': []
}
defineProps<Props>()
defineEmits<Emits>()
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 getQRCodeValue = (record: FileRecord) => {
if (record.downloadUrl) {
return `${baseUrl}${record.downloadUrl}`
} else {
return `${baseUrl}?code=${record.code}`
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,112 @@
<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 }}
</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('view-details', record)"
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
:class="[
isDarkMode
? 'hover:bg-indigo-400 text-indigo-400'
: 'hover:bg-indigo-100 text-indigo-600'
]"
>
<EyeIcon class="w-5 h-5" />
</button>
<button
@click="$emit('download-record', 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'
]"
>
<DownloadIcon 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 { 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
}
interface Props {
records: FileRecord[]
}
interface Emits {
'view-details': [record: FileRecord]
'download-record': [record: FileRecord]
'delete-record': [id: number]
}
defineProps<Props>()
defineEmits<Emits>()
const isDarkMode = inject('isDarkMode')
</script>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-move {
transition: transform 0.3s ease;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div
class="rounded-xl p-8 flex flex-col items-center justify-center border-2 border-dashed transition-all duration-300 group cursor-pointer relative"
:class="[
isDarkMode
? 'bg-gray-800 bg-opacity-50 border-gray-600 hover:border-indigo-500'
: 'bg-gray-100 border-gray-300 hover:border-indigo-500'
]"
@click="triggerFileUpload"
@dragover.prevent
@drop.prevent="handleFileDrop"
>
<input
ref="fileInput"
type="file"
class="hidden"
@change="handleFileUpload"
:accept="acceptedTypes"
/>
<div class="absolute inset-0 w-full h-full" v-if="progress > 0">
<BorderProgressBar :progress="progress" />
</div>
<UploadCloudIcon
:class="[
'w-16 h-16 transition-colors duration-300',
isDarkMode
? 'text-gray-400 group-hover:text-indigo-400'
: 'text-gray-600 group-hover:text-indigo-600'
]"
/>
<p
:class="[
'mt-4 text-sm transition-colors duration-300 w-full text-center',
isDarkMode
? 'text-gray-400 group-hover:text-indigo-400'
: 'text-gray-600 group-hover:text-indigo-600'
]"
>
<span class="block truncate">
{{ selectedFile ? selectedFile.name : placeholder }}
</span>
</p>
<p :class="['mt-2 text-xs', isDarkMode ? 'text-gray-500' : 'text-gray-400']">
{{ description }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref, inject } from 'vue'
import { UploadCloudIcon } from 'lucide-vue-next'
import BorderProgressBar from './BorderProgressBar.vue'
interface Props {
selectedFile?: File | null
progress?: number
placeholder?: string
description?: string
acceptedTypes?: string
}
interface Emits {
fileSelected: [file: File]
fileDrop: [event: DragEvent]
}
withDefaults(defineProps<Props>(), {
selectedFile: null,
progress: 0,
placeholder: '点击或拖放文件到此处上传',
description: '支持各种常见格式',
acceptedTypes: '*'
})
const emit = defineEmits<Emits>()
const isDarkMode = inject('isDarkMode')
const fileInput = ref<HTMLInputElement | null>(null)
const triggerFileUpload = () => {
fileInput.value?.click()
}
const handleFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
emit('fileSelected', file)
}
}
const handleFileDrop = (event: DragEvent) => {
emit('fileDrop', event)
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="flex justify-center space-x-4 mb-6">
<button
type="button"
@click="selectType('file')"
:class="[
'px-4 py-2 rounded-lg transition-colors duration-300',
selectedType === 'file'
? 'bg-indigo-600 text-white'
: isDarkMode
? 'bg-gray-700 text-gray-300 hover:bg-gray-600'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
]"
>
发送文件
</button>
<button
type="button"
@click="selectType('text')"
:class="[
'px-4 py-2 rounded-lg transition-colors duration-300',
selectedType === 'text'
? 'bg-indigo-600 text-white'
: isDarkMode
? 'bg-gray-700 text-gray-300 hover:bg-gray-600'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
]"
>
发送文本
</button>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import type { SendType } from '@/types'
interface Props {
selectedType: SendType
}
interface Emits {
'update:selectedType': [type: SendType]
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const isDarkMode = inject('isDarkMode')
const selectType = (type: SendType) => {
emit('update:selectedType', type)
}
</script>

View File

@@ -0,0 +1,63 @@
<template>
<transition name="drawer">
<div
v-if="visible"
class="fixed inset-y-0 right-0 w-full sm:w-120 bg-opacity-70 backdrop-filter backdrop-blur-xl shadow-2xl z-50 overflow-hidden flex flex-col"
:class="[isDarkMode ? 'bg-gray-900' : 'bg-white']"
>
<div
class="flex justify-between items-center p-6 border-b"
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
>
<h3 class="text-2xl font-bold" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
{{ title }}
</h3>
<button
@click="$emit('close')"
class="hover:text-white transition duration-300"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-800']"
>
<XIcon class="w-6 h-6" />
</button>
</div>
<slot></slot>
</div>
</transition>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { XIcon } from 'lucide-vue-next'
interface Props {
visible: boolean
title: string
}
interface Emits {
close: []
}
defineProps<Props>()
defineEmits<Emits>()
const isDarkMode = inject('isDarkMode')
</script>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
transition: transform 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
transform: translateX(100%);
}
@media (min-width: 640px) {
.sm\:w-120 {
width: 30rem;
/* 480px */
}
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="flex flex-col">
<textarea
:value="modelValue"
@input="updateValue"
:rows="rows"
:placeholder="placeholder"
:class="[
'flex-grow px-4 py-3 rounded-xl placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition duration-300 resize-none custom-scrollbar',
isDarkMode
? 'bg-gray-800 bg-opacity-50 text-white'
: 'bg-white text-gray-900 border border-gray-300'
]"
></textarea>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
interface Props {
modelValue: string
rows?: number
placeholder?: string
}
interface Emits {
'update:modelValue': [value: string]
}
withDefaults(defineProps<Props>(), {
rows: 7,
placeholder: '在此输入要发送的文本...'
})
const emit = defineEmits<Emits>()
const isDarkMode = inject('isDarkMode')
const updateValue = (event: Event) => {
const target = event.target as HTMLTextAreaElement
emit('update:modelValue', target.value)
}
</script>
<style scoped>
/* 自定义滚动条样式 */
.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);
}
</style>

View File

@@ -0,0 +1,144 @@
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
}
}

View File

@@ -0,0 +1,139 @@
import { ref, computed, readonly } from 'vue'
import { FileService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
import type { UploadProgress, UploadStatus } from '@/types'
import { UPLOAD_STATUS, FILE_SIZE_LIMITS } from '@/constants'
export function useFileUpload() {
const alertStore = useAlertStore()
// 状态管理
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 isUploading = computed(() => uploadStatus.value === UPLOAD_STATUS.UPLOADING)
const isSuccess = computed(() => uploadStatus.value === UPLOAD_STATUS.SUCCESS)
const isError = computed(() => uploadStatus.value === UPLOAD_STATUS.ERROR)
const isIdle = computed(() => uploadStatus.value === UPLOAD_STATUS.IDLE)
// 文件验证
const validateFile = (file: File): boolean => {
if (file.size > FILE_SIZE_LIMITS.MAX_FILE_SIZE) {
alertStore.showAlert(
`文件大小不能超过 ${Math.round(FILE_SIZE_LIMITS.MAX_FILE_SIZE / 1024 / 1024)}MB`,
'error'
)
return false
}
return true
}
// 上传文件
const uploadFile = async (file: File): Promise<string | null> => {
if (!validateFile(file)) {
return null
}
try {
uploadStatus.value = UPLOAD_STATUS.UPLOADING
currentFile.value = file
uploadedCode.value = ''
const response = await FileService.uploadFile(file, (progress) => {
uploadProgress.value = 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 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
}
// 格式化文件大小
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),
// 计算属性
isUploading,
isSuccess,
isError,
isIdle,
// 方法
uploadFile,
uploadText,
resetUpload,
validateFile,
formatFileSize
}
}

View File

@@ -0,0 +1,142 @@
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'
export function useSystemConfig() {
const alertStore = useAlertStore()
// 状态管理
const config = ref<SystemConfig>({ ...DEFAULT_CONFIG })
const isLoading = ref(false)
// 从本地存储获取配置
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 saveConfigToStorage = (configData: SystemConfig) => {
try {
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configData))
} catch (error) {
console.error('保存配置到本地存储失败:', error)
}
}
// 获取系统配置
const fetchConfig = async (): Promise<SystemConfig | 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'
)
}
}
return config.value
} else {
throw new Error(response.message || '获取配置失败')
}
} catch (error) {
// 如果网络请求失败,尝试使用本地存储的配置
const storedConfig = getStoredConfig()
if (storedConfig) {
config.value = storedConfig
return config.value
}
const errorMessage = error instanceof Error ? error.message : '获取配置失败'
alertStore.showAlert(errorMessage, 'error')
return null
} finally {
isLoading.value = false
}
}
// 更新系统配置
const updateConfig = async (newConfig: Partial<SystemConfig>): Promise<boolean> => {
try {
isLoading.value = true
const response = await ConfigService.updateConfig(newConfig)
if (response.code === 200) {
config.value = { ...config.value, ...newConfig }
saveConfigToStorage(config.value)
alertStore.showAlert('配置更新成功!', 'success')
return true
} else {
throw new Error(response.message || '更新配置失败')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '更新配置失败'
alertStore.showAlert(errorMessage, 'error')
return false
} finally {
isLoading.value = false
}
}
// 初始化配置
const initConfig = async () => {
// 先尝试从本地存储加载
const storedConfig = getStoredConfig()
if (storedConfig) {
config.value = storedConfig
}
// 然后从服务器获取最新配置
await fetchConfig()
}
// 计算属性
const maxFileSizeMB = computed(() => {
return Math.round(config.value.maxFileSize / 1024 / 1024)
})
const isConfigLoaded = computed(() => {
return config.value.name !== DEFAULT_CONFIG.name || !isLoading.value
})
return {
// 状态
config,
isLoading,
// 计算属性
maxFileSizeMB,
isConfigLoaded,
// 方法
fetchConfig,
updateConfig,
initConfig,
getStoredConfig,
saveConfigToStorage
}
}

138
src/composables/useTheme.ts Normal file
View File

@@ -0,0 +1,138 @@
import { ref, computed } from 'vue'
import { STORAGE_KEYS, THEME_MODES } from '@/constants'
import type { ThemeMode } from '@/types'
export function useTheme() {
// 状态管理
const isDarkMode = ref(false)
const themeMode = ref<ThemeMode>(THEME_MODES.SYSTEM)
// 检查系统颜色模式
const checkSystemColorScheme = (): boolean => {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
}
// 从本地存储获取用户之前的选择
const getUserPreference = (): ThemeMode | null => {
const storedPreference = localStorage.getItem(STORAGE_KEYS.COLOR_MODE)
if (storedPreference && Object.values(THEME_MODES).includes(storedPreference as ThemeMode)) {
return storedPreference as ThemeMode
}
return null
}
// 设置颜色模式
const setThemeMode = (mode: ThemeMode) => {
themeMode.value = mode
localStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode)
// 根据模式设置实际的暗色模式状态
if (mode === THEME_MODES.SYSTEM) {
isDarkMode.value = checkSystemColorScheme()
} else {
isDarkMode.value = mode === THEME_MODES.DARK
}
// 更新 HTML 类名
updateHtmlClass()
}
// 更新 HTML 元素的类名
const updateHtmlClass = () => {
const html = document.documentElement
if (isDarkMode.value) {
html.classList.add('dark')
} else {
html.classList.remove('dark')
}
}
// 切换主题
const toggleTheme = () => {
if (themeMode.value === THEME_MODES.LIGHT) {
setThemeMode(THEME_MODES.DARK)
} else if (themeMode.value === THEME_MODES.DARK) {
setThemeMode(THEME_MODES.SYSTEM)
} else {
setThemeMode(THEME_MODES.LIGHT)
}
}
// 监听系统主题变化
const setupSystemThemeListener = () => {
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
if (themeMode.value === THEME_MODES.SYSTEM) {
isDarkMode.value = e.matches
updateHtmlClass()
}
}
mediaQuery.addEventListener('change', handleChange)
// 返回清理函数
return () => {
mediaQuery.removeEventListener('change', handleChange)
}
}
return () => {}
}
// 初始化主题
const initTheme = () => {
const userPreference = getUserPreference()
if (userPreference) {
setThemeMode(userPreference)
} else {
setThemeMode(THEME_MODES.SYSTEM)
}
// 设置系统主题监听
return setupSystemThemeListener()
}
// 计算属性
const themeIcon = computed(() => {
switch (themeMode.value) {
case THEME_MODES.LIGHT:
return 'sun'
case THEME_MODES.DARK:
return 'moon'
case THEME_MODES.SYSTEM:
return 'monitor'
default:
return 'monitor'
}
})
const themeLabel = computed(() => {
switch (themeMode.value) {
case THEME_MODES.LIGHT:
return '浅色模式'
case THEME_MODES.DARK:
return '深色模式'
case THEME_MODES.SYSTEM:
return '跟随系统'
default:
return '跟随系统'
}
})
return {
// 状态
isDarkMode,
themeMode,
// 计算属性
themeIcon,
themeLabel,
// 方法
setThemeMode,
toggleTheme,
initTheme,
checkSystemColorScheme
}
}

88
src/constants/index.ts Normal file
View File

@@ -0,0 +1,88 @@
// 应用常量配置
// 存储键名
export const STORAGE_KEYS = {
COLOR_MODE: 'colorMode',
ADMIN_PASSWORD: 'adminPassword',
TOKEN: 'token',
CONFIG: 'config',
NOTIFY: 'notify'
} as const
// 主题模式
export const THEME_MODES = {
LIGHT: 'light',
DARK: 'dark',
SYSTEM: 'system'
} as const
// 发送类型
export const SEND_TYPES = {
FILE: 'file',
TEXT: 'text'
} as const
// 警告类型
export const ALERT_TYPES = {
SUCCESS: 'success',
ERROR: 'error',
WARNING: 'warning',
INFO: 'info'
} as const
// 上传状态
export const UPLOAD_STATUS = {
IDLE: 'idle',
UPLOADING: 'uploading',
SUCCESS: 'success',
ERROR: 'error'
} as const
// API 响应状态码
export const API_STATUS_CODES = {
SUCCESS: 200,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
SERVER_ERROR: 500
} as const
// 文件大小限制 (字节)
export const FILE_SIZE_LIMITS = {
MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB
CHUNK_SIZE: 1024 * 1024 // 1MB
} as const
// 时间相关常量
export const TIME_CONSTANTS = {
ALERT_DURATION: 5000, // 5秒
REQUEST_TIMEOUT: 30000, // 30秒
PROGRESS_UPDATE_INTERVAL: 100 // 100毫秒
} as const
// 路由路径
export const ROUTES = {
HOME: '/',
SEND: '/send',
ADMIN: '/admin',
LOGIN: '/login',
DASHBOARD: '/admin/dashboard',
FILE_MANAGE: '/admin/files',
SETTINGS: '/admin/settings'
} as const
// 正则表达式
export const REGEX_PATTERNS = {
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
PHONE: /^1[3-9]\d{9}$/,
CODE: /^[A-Za-z0-9]{4,8}$/ // 取件码格式
} as const
// 默认配置
export const DEFAULT_CONFIG = {
name: 'FileCodeBox',
description: '文件传输工具',
maxFileSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE,
allowedFileTypes: ['*'] as string[],
expireDays: 7
}

14
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,14 @@
// 自定义 Vue hooks 导出文件
// 这里可以导出所有自定义的 Vue hooks
// 示例:导出常用的自定义 hooks
// export { useLocalStorage } from './useLocalStorage'
// export { useDebounce } from './useDebounce'
// export { useThrottle } from './useThrottle'
// export { useEventListener } from './useEventListener'
// export { useClickOutside } from './useClickOutside'
// 目前项目中的业务逻辑已经通过 composables 进行了封装
// hooks 文件夹主要用于存放更通用的、可复用的 Vue hooks
export {}

137
src/services/index.ts Normal file
View File

@@ -0,0 +1,137 @@
// API 服务层
import api from '@/utils/api'
import type { ApiResponse, FileInfo, ConfigState, AdminUser, FileUploadResponse, TextSendResponse, DashboardData, FileListResponse, FileEditForm } from '@/types'
import type { UploadProgress } 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'
},
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.post('/admin/file/update', data)
}
static async deleteAdminFile(id: number): Promise<ApiResponse> {
return api.post('/admin/file/delete', { 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 const services = {
config: ConfigService,
file: FileService,
auth: AuthService,
stats: StatsService
}
export default services

View File

@@ -1,11 +1,79 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { STORAGE_KEYS } from '@/constants'
import type { AdminUser } from '@/types'
export const useAdminData = defineStore('adminData', () => {
const adminPassword = ref(localStorage.getItem('adminPassword') || '')
function updateAdminPwd(pwd: string) {
export const useAdminStore = defineStore('admin', () => {
// 状态
const adminPassword = ref(localStorage.getItem(STORAGE_KEYS.ADMIN_PASSWORD) || '')
const token = ref(localStorage.getItem(STORAGE_KEYS.TOKEN) || '')
const isLoggedIn = ref(false)
const userInfo = ref<AdminUser | null>(null)
// 计算属性
const isAuthenticated = computed(() => {
return isLoggedIn.value && !!token.value
})
// 方法
const updateAdminPassword = (pwd: string) => {
adminPassword.value = pwd
localStorage.setItem('token', pwd)
localStorage.setItem(STORAGE_KEYS.ADMIN_PASSWORD, pwd)
}
const setToken = (newToken: string) => {
token.value = newToken
localStorage.setItem(STORAGE_KEYS.TOKEN, newToken)
}
const setUserInfo = (user: AdminUser) => {
userInfo.value = user
isLoggedIn.value = true
setToken(user.token)
}
const login = (user: AdminUser) => {
setUserInfo(user)
}
const logout = () => {
adminPassword.value = ''
token.value = ''
isLoggedIn.value = false
userInfo.value = null
// 清除本地存储
localStorage.removeItem(STORAGE_KEYS.ADMIN_PASSWORD)
localStorage.removeItem(STORAGE_KEYS.TOKEN)
}
const initAuth = () => {
const storedToken = localStorage.getItem(STORAGE_KEYS.TOKEN)
if (storedToken) {
token.value = storedToken
isLoggedIn.value = true
}
}
return {
// 状态
adminPassword,
token,
isLoggedIn,
userInfo,
// 计算属性
isAuthenticated,
// 方法
updateAdminPassword,
setToken,
setUserInfo,
login,
logout,
initAuth
}
return { adminPassword, updateAdminPwd }
})
// 保持向后兼容
export const useAdminData = useAdminStore

View File

@@ -1,13 +1,6 @@
import { defineStore } from 'pinia'
interface Alert {
id: number
message: string
type: 'success' | 'error' | 'warning' | 'info'
progress: number
duration: number
startTime: number
}
import type { Alert, AlertType } from '@/types'
import { TIME_CONSTANTS } from '@/constants'
export const useAlertStore = defineStore('alert', {
state: () => ({
@@ -16,8 +9,8 @@ export const useAlertStore = defineStore('alert', {
actions: {
showAlert(
message: string,
type: 'success' | 'error' | 'warning' | 'info' = 'info',
duration = 5000
type: AlertType = 'info',
duration = TIME_CONSTANTS.ALERT_DURATION
) {
const id = Date.now()
const startTime = Date.now()

View File

@@ -1,39 +1,290 @@
import { defineStore } from 'pinia'
import { reactive } from 'vue'
import { ref, computed } from 'vue'
import type { FileInfo, UploadProgress, UploadStatus } from '@/types'
import { UPLOAD_STATUS } from '@/constants'
export const useFileDataStore = defineStore('fileData', () => {
const receiveData = reactive(JSON.parse(localStorage.getItem('receiveData') || '[]') || []) // 接收的数据
const shareData = reactive(JSON.parse(localStorage.getItem('shareData') || '[]') || []) // 接收的数据
function save() {
localStorage.setItem('receiveData', JSON.stringify(receiveData))
localStorage.setItem('shareData', JSON.stringify(shareData))
// 上传相关状态
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
}
function addReceiveData(data: any) {
receiveData.unshift(data)
save()
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
}
function addShareData(data: any) {
shareData.unshift(data)
save()
// 添加分享数据方法
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)
}
function deleteReceiveData(index: number) {
receiveData.splice(index, 1)
save()
const removeReceiveData = (id: number) => {
const index = receiveData.value.findIndex(item => item.id === id)
if (index > -1) {
receiveData.value.splice(index, 1)
}
}
const deleteReceiveData = (index: number) => {
if (index > -1 && index < receiveData.value.length) {
receiveData.value.splice(index, 1)
}
}
function deleteShareData(index: number) {
shareData.splice(index, 1)
save()
const clearReceiveData = () => {
receiveData.value = []
}
// 分享数据相关方法
const addShareDataRecord = (data: {
id: number
filename: string
date: string
size: string
expiration: string
retrieveCode: string
}) => {
shareData.value.push(data)
}
const deleteShareData = (index: number) => {
if (index > -1 && index < shareData.value.length) {
shareData.value.splice(index, 1)
}
}
const clearShareData = () => {
shareData.value = []
}
return {
receiveData,
shareData,
save,
// 上传状态
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,
addReceiveData,
removeReceiveData,
deleteReceiveData,
deleteShareData
clearReceiveData,
// 分享数据状态和方法
shareData,
addShareDataRecord,
deleteShareData,
clearShareData
}
})

180
src/types/index.ts Normal file
View File

@@ -0,0 +1,180 @@
// 通用类型定义
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
}
}

View File

@@ -1,18 +1,18 @@
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: 1000000000000000, // 请求超时时间
timeout: TIME_CONSTANTS.REQUEST_TIMEOUT, // 30秒超时
headers: {
'Content-Type': 'application/json'
}
@@ -46,27 +46,26 @@ api.interceptors.response.use(
(error) => {
// 处理错误响应
if (error.response) {
switch (error.response.status) {
const { status } = error.response
switch (status) {
case 401:
console.error('未授权,请重新登录')
localStorage.clear()
window.location.href = '/#/login'
localStorage.removeItem('token')
// 使用 router 进行导航而不是直接修改 location
if (window.location.hash !== '#/login') {
window.location.href = '/#/login'
}
break
case 403:
// 禁止访问
console.error('禁止访问')
break
case 404:
// 未找到
console.error('请求的资源不存在')
break
case 500:
default:
console.error('发生错误:', error.response.data)
// 错误信息通过Promise.reject传递给调用方处理
break
}
} else if (error.request) {
console.error('未收到响应:', error.request)
// 网络错误通过Promise.reject传递给调用方处理
} else {
console.error('请求配置错误:', error.message)
// 请求配置错误通过Promise.reject传递给调用方处理
}
return Promise.reject(error)
}

View File

@@ -386,16 +386,7 @@ interface InputStatus {
loading: boolean
}
interface ApiResponse {
code: number
message?: string
detail?: {
code: string
name: string
text: string
size: number
}
}
import {
BoxIcon,
EyeIcon,
@@ -415,6 +406,14 @@ import QRCode from 'qrcode.vue'
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'
@@ -491,7 +490,7 @@ const handleSubmit = async () => {
const response = await api.post('/share/select/', {
code: code.value
})
const res = (response.data || response) as ApiResponse
const res = (response.data || response) as ApiResponse<RetrieveResponse>
if (res && res.code === 200) {
if (res.detail) {
@@ -574,8 +573,6 @@ const getQRCodeValue = (record: FileRecord) => {
}
const downloadRecord = (record: FileRecord) => {
console.log(record)
if (record.downloadUrl) {
// 如果是文件,直接下载
window.open(

View File

@@ -594,6 +594,7 @@ import QRCode from 'qrcode.vue'
import { useFileDataStore } from '@/stores/fileData'
import { useAlertStore } from '@/stores/alertStore'
import api from '@/utils/api'
import type { ApiResponse } from '@/types'
import { copyRetrieveLink, copyRetrieveCode, copyWgetCommand } from '@/utils/clipboard'
import { getStorageUnit } from '@/utils/convert'
@@ -615,15 +616,7 @@ interface ShareRecord {
retrieveCode: string
}
interface ApiResponse {
code: number
detail: {
code?: string
name?: string
upload_id?: string
existed?: boolean
}
}
const config: Config = JSON.parse(localStorage.getItem('config') || '{}') as Config
@@ -635,7 +628,7 @@ const sendType = ref('file')
const selectedFile = ref<File | null>(null)
const textContent = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const expirationMethod = ref('day')
const expirationMethod = ref(config.expireStyle?.[0] || 'day')
const expirationValue = ref('1')
const uploadProgress = ref(0)
const showDrawer = ref(false)
@@ -880,7 +873,12 @@ const handleChunkUpload = async (file: File) => {
const chunkSize = 5 * 1024 * 1024
const chunks = Math.ceil(file.size / chunkSize)
// 1. 初始化切片上传
const initResponse: ApiResponse = await api.post('chunk/upload/init/', {
const initResponse: ApiResponse<{
code?: string
name?: string
upload_id?: string
existed?: boolean
}> = await api.post('chunk/upload/init/', {
file_name: file.name,
file_size: file.size,
chunk_size: chunkSize,
@@ -890,10 +888,10 @@ const handleChunkUpload = async (file: File) => {
if (initResponse.code !== 200) {
throw new Error('初始化切片上传失败')
}
if (initResponse.detail.existed) {
if (initResponse.detail?.existed) {
return initResponse
}
const uploadId = initResponse.detail.upload_id
const uploadId = initResponse.detail?.upload_id
// 2. 上传切片
for (let i = 0; i < chunks; i++) {
@@ -905,7 +903,7 @@ const handleChunkUpload = async (file: File) => {
chunkFormData.append('chunk', new Blob([chunk], { type: file.type })) // 确保以Blob形式添加
// 使用 application/x-www-form-urlencoded 格式
const chunkResponse: ApiResponse = await api.post(
const chunkResponse: ApiResponse<unknown> = await api.post(
`chunk/upload/chunk/${uploadId}/${i}`,
chunkFormData,
{
@@ -927,7 +925,7 @@ const handleChunkUpload = async (file: File) => {
}
// 3. 完成上传
const completeResponse: ApiResponse = await api.post(`chunk/upload/complete/${uploadId}`, {
const completeResponse: ApiResponse<{ code?: string; name?: string }> = await api.post(`chunk/upload/complete/${uploadId}`, {
expire_value: expirationValue.value ? parseInt(expirationValue.value) : 1,
expire_style: expirationMethod.value
})
@@ -965,7 +963,7 @@ const handleDefaultFileUpload = async (file: File) => {
formData.append('file', file)
formData.append('expire_value', expirationValue.value)
formData.append('expire_style', expirationMethod.value)
const response: ApiResponse = await api.post('share/file/', formData, config)
const response: ApiResponse<{ code?: string; name?: string }> = await api.post('share/file/', formData, config)
return response
}
const checkOpenUpload = () => {
@@ -1060,8 +1058,8 @@ const handleSubmit = async () => {
}
if (response && response.code === 200) {
const retrieveCode = response.detail.code || ''
const fileName = response.detail.name || ''
const retrieveCode = (response.detail as unknown as { code?: string })?.code || ''
const fileName = (response.detail as unknown as { name?: string })?.name || ''
// 添加新的发送记录
const newRecord: ShareRecord = {
id: Date.now(),
@@ -1077,7 +1075,7 @@ const handleSubmit = async () => {
: getExpirationTime(expirationMethod.value, expirationValue.value),
retrieveCode: retrieveCode
}
fileDataStore.addShareData(newRecord)
fileDataStore.addShareDataRecord(newRecord)
// 显示发送成功消息
alertStore.showAlert(`文件发送成功!取件码:${retrieveCode}`, 'success')
@@ -1093,7 +1091,6 @@ const handleSubmit = async () => {
throw new Error('服务器响应异常')
}
} catch (error: unknown) {
console.error('发送失败:', error)
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { detail?: string } } }
if (axiosError.response?.data?.detail) {

View File

@@ -113,14 +113,12 @@ import {
HardDriveIcon,
UsersIcon,
ActivityIcon,
UploadIcon,
TrashIcon,
UserIcon
} from 'lucide-vue-next'
import { StatsService } from '@/services'
import type { DashboardData } from '@/types'
const isDarkMode = inject('isDarkMode')
import api from '@/utils/api'
const dashboardData: any = reactive({
const dashboardData = reactive<DashboardData>({
totalFiles: 0,
storageUsed: 0,
yesterdayCount: 0,
@@ -130,30 +128,6 @@ const dashboardData: any = reactive({
sysUptime: 0
})
// 添加最近活动数据
const recentActivities = [
{
icon: UploadIcon,
description: '张三上传了文件 "项目计划.pdf"',
time: '10分钟前'
},
{
icon: UserIcon,
description: '新用户李四加入了系统',
time: '30分钟前'
},
{
icon: TrashIcon,
description: '王五删除了文件 "旧文档.doc"',
time: '1小时前'
},
{
icon: FileIcon,
description: '系统自动备份完成',
time: '2小时前'
}
]
const getSysUptime = (startTimestamp: number) => {
const now = new Date().getTime()
const uptime = now - startTimestamp
@@ -181,14 +155,16 @@ const getLocalstorageUsed = (nowUsedBit: string) => {
}
}
const getDashboardData = async () => {
const response: any = await api.get('admin/dashboard')
dashboardData.totalFiles = response.detail.totalFiles
dashboardData.storageUsed = getLocalstorageUsed(response.detail.storageUsed)
dashboardData.yesterdaySize = getLocalstorageUsed(response.detail.yesterdaySize)
dashboardData.todaySize = getLocalstorageUsed(response.detail.todaySize)
dashboardData.yesterdayCount = response.detail.yesterdayCount
dashboardData.todayCount = response.detail.todayCount
dashboardData.sysUptime = getSysUptime(response.detail.sysUptime)
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))
}
}
onMounted(() => {

View File

@@ -381,7 +381,8 @@
<script setup lang="ts">
import { inject, ref, computed } from 'vue'
import api from '@/utils/api'
import { FileService } from '@/services'
import type { FileListItem, FileEditForm } from '@/types'
import {
FileIcon,
SearchIcon,
@@ -405,7 +406,7 @@ function formatTimestamp(timestamp: string): string {
}
const isDarkMode = inject('isDarkMode')
const tableData: any = ref([])
const tableData = ref<FileListItem[]>([])
const alertStore = useAlertStore()
// 修改文件表头
const fileTableHeaders = ['取件码', '名称', '大小', '描述', '过期时间', '操作']
@@ -420,7 +421,7 @@ const params = ref({
// 添加编辑相关的状态
const showEditModal = ref(false)
const editForm = ref({
const editForm = ref<FileEditForm>({
id: null,
code: '',
prefix: '',
@@ -430,7 +431,7 @@ const editForm = ref({
})
// 打开编辑模态框
const openEditModal = (file: any) => {
const openEditModal = (file: FileListItem) => {
editForm.value = {
id: file.id,
code: file.code,
@@ -458,99 +459,86 @@ const closeEditModal = () => {
// 处理更新
const handleUpdate = async () => {
try {
await api({
url: 'admin/file/update',
method: 'patch',
data: editForm.value
})
await FileService.updateFile(editForm.value)
await loadFiles()
closeEditModal()
} catch (error: any) {
alertStore.showAlert(error.response.data.detail, 'error')
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
alertStore.showAlert(err.response?.data?.detail || '更新失败', 'error')
}
}
// 下载文件处理
const downloadFile = async (id: number) => {
try {
const response = await api({
url: 'admin/file/download',
method: 'get',
params: { id },
responseType: 'blob'
})
// 下载文件处理 - 暂时移除未使用的函数
// const downloadFile = async (id: number) => {
// try {
// const response = await FileService.downloadAdminFile(id)
const contentDisposition = response.headers['content-disposition']
let filename = 'file'
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch != null && filenameMatch[1]) {
filename = filenameMatch[1].replace(/['"]/g, '')
}
// const contentDisposition = response.headers['content-disposition']
// let filename = 'file'
// const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
// if (filenameMatch != null && filenameMatch[1]) {
// filename = filenameMatch[1].replace(/['"]/g, '')
// }
// @ts-ignore
if (window.showSaveFilePicker) {
await saveFileByWebApi(response.data, filename)
} else {
await saveFileByElementA(response.data, filename)
}
} catch (error) {
console.error('下载失败:', error)
}
}
// // @ts-expect-error - showSaveFilePicker is not in standard Window interface
// if (window.showSaveFilePicker) {
// await saveFileByWebApi(response.data, filename)
// } else {
// await saveFileByElementA(response.data, filename)
// }
// } catch (error) {
// console.error('下载失败:', error)
// }
// }
// 删除文件处理
const deleteFile = async (id: number) => {
try {
await api({
url: 'admin/file/delete',
method: 'delete',
data: { id }
})
await FileService.deleteAdminFile(id)
await loadFiles()
} catch (error) {
console.error('删除失败:', error)
}
}
// 文件保存辅助函数
async function saveFileByElementA(fileBlob: Blob, filename: string) {
const downloadUrl = window.URL.createObjectURL(fileBlob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = filename
document.body.appendChild(link)
link.click()
window.URL.revokeObjectURL(downloadUrl)
document.body.removeChild(link)
}
// 文件保存辅助函数 - 暂时移除未使用的函数
// async function saveFileByElementA(fileBlob: Blob, filename: string) {
// const downloadUrl = window.URL.createObjectURL(fileBlob)
// const link = document.createElement('a')
// link.href = downloadUrl
// link.download = filename
// document.body.appendChild(link)
// link.click()
// window.URL.revokeObjectURL(downloadUrl)
// document.body.removeChild(link)
// }
async function saveFileByWebApi(fileBlob: Blob, filename: string) {
// @ts-ignore
const newHandle = await window.showSaveFilePicker({
suggestedName: filename
})
const writableStream = await newHandle.createWritable()
await writableStream.write(fileBlob)
await writableStream.close()
}
// async function saveFileByWebApi(fileBlob: Blob, filename: string) {
// // @ts-expect-error - showSaveFilePicker is not in standard Window interface
// const newHandle = await window.showSaveFilePicker({
// suggestedName: filename
// })
// const writableStream = await newHandle.createWritable()
// await writableStream.write(fileBlob)
// await writableStream.close()
// }
// 加载文件列表
const loadFiles = async () => {
try {
const res: any = await api({
url: '/admin/file/list',
method: 'get',
params: params.value
})
tableData.value = res.detail.data
params.value.total = res.detail.total
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('加载文件列表失败:', error)
}
}
// 页码改变处理函数
const handlePageChange = async (page: any) => {
const handlePageChange = async (page: number | string) => {
if (typeof page === 'string') return // 忽略省略号
if (page < 1 || page > totalPages.value) return
params.value.page = page
await loadFiles()

View File

@@ -1,33 +1,41 @@
<template>
<div :class="[
'min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 transition-colors duration-200 relative overflow-hidden',
isDarkMode ? 'bg-gray-900' : 'bg-gray-50'
]">
<div
:class="[
'min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 transition-colors duration-200 relative overflow-hidden',
isDarkMode ? 'bg-gray-900' : 'bg-gray-50'
]"
>
<div class="absolute inset-0 z-0">
<div class="cyber-grid"></div>
<div class="floating-particles"></div>
</div>
<div class="max-w-md w-full space-y-8 backdrop-blur-lg bg-opacity-20 p-8 rounded-xl border border-opacity-20"
:class="[isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white/70 border-gray-200']">
<div
class="max-w-md w-full space-y-8 backdrop-blur-lg bg-opacity-20 p-8 rounded-xl border border-opacity-20"
:class="[isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white/70 border-gray-200']"
>
<div>
<div class="mx-auto h-16 w-16 relative">
<div
class="absolute inset-0 bg-gradient-to-r from-cyan-500 via-purple-500 to-pink-500 rounded-full animate-spin-slow">
</div>
class="absolute inset-0 bg-gradient-to-r from-cyan-500 via-purple-500 to-pink-500 rounded-full animate-spin-slow"
></div>
<div
class="absolute -inset-2 bg-gradient-to-r from-cyan-500 via-purple-500 to-pink-500 rounded-full opacity-50 blur-md animate-pulse">
</div>
<div :class="[
'absolute inset-1 rounded-full flex items-center justify-center',
isDarkMode ? 'bg-gray-800' : 'bg-white'
]">
class="absolute -inset-2 bg-gradient-to-r from-cyan-500 via-purple-500 to-pink-500 rounded-full opacity-50 blur-md animate-pulse"
></div>
<div
:class="[
'absolute inset-1 rounded-full flex items-center justify-center',
isDarkMode ? 'bg-gray-800' : 'bg-white'
]"
>
<BoxIcon :class="['h-8 w-8', isDarkMode ? 'text-cyan-400' : 'text-cyan-600']" />
</div>
</div>
<h2 :class="[
'mt-6 text-center text-3xl font-extrabold',
isDarkMode ? 'text-white' : 'text-gray-900'
]">
<h2
:class="[
'mt-6 text-center text-3xl font-extrabold',
isDarkMode ? 'text-white' : 'text-gray-900'
]"
>
登录
</h2>
</div>
@@ -36,23 +44,35 @@
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="password" class="sr-only">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required
v-model="password" :class="[
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
v-model="password"
:class="[
'appearance-none rounded-t-md relative block w-full px-4 py-3 border transition-all duration-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm backdrop-blur-sm',
isDarkMode
? 'bg-gray-800/50 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
: 'bg-white/50 border-gray-300 text-gray-900 hover:border-gray-400'
]" placeholder="密码" />
]"
placeholder="密码"
/>
</div>
</div>
<div>
<button type="submit" :class="[
'group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white transition-all duration-300 transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 shadow-lg hover:shadow-cyan-500/50',
isDarkMode
? 'bg-gradient-to-r from-cyan-500 to-purple-500 hover:from-cyan-600 hover:to-purple-600'
: 'bg-gradient-to-r from-cyan-600 to-purple-600 hover:from-cyan-700 hover:to-purple-700',
isLoading ? 'opacity-75 cursor-not-allowed' : ''
]" :disabled="isLoading">
<button
type="submit"
:class="[
'group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white transition-all duration-300 transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 shadow-lg hover:shadow-cyan-500/50',
isDarkMode
? 'bg-gradient-to-r from-cyan-500 to-purple-500 hover:from-cyan-600 hover:to-purple-600'
: 'bg-gradient-to-r from-cyan-600 to-purple-600 hover:from-cyan-700 hover:to-purple-700',
isLoading ? 'opacity-75 cursor-not-allowed' : ''
]"
:disabled="isLoading"
>
<span class="absolute left-0 inset-y-0 flex items-center pl-3"> </span>
{{ isLoading ? '登录中...' : '登录' }}
</button>
@@ -66,9 +86,15 @@
import { ref, inject } from 'vue'
import { BoxIcon } from 'lucide-vue-next'
import api from '@/utils/api'
import type { ApiResponse } from '@/types'
import { useAlertStore } from '@/stores/alertStore'
import { useAdminData } from '@/stores/adminStore'
import { useRouter } from 'vue-router'
// 登录响应类型定义
interface LoginResponse {
token: string
}
const alertStore = useAlertStore()
const password = ref('')
const isLoading = ref(false)
@@ -91,19 +117,20 @@ const router = useRouter()
const handleSubmit = async () => {
if (!validateForm()) return
api
.post('/admin/login', { password: password.value })
.then((res: any) => {
adminStore.updateAdminPwd(res.detail.token)
.post<ApiResponse<LoginResponse>>('/admin/login', { password: password.value })
.then((res) => {
adminStore.setToken(res.data.detail?.token || '')
router.push('/admin')
})
.catch((error: any) => {
alertStore.showAlert(error.response.data.detail, 'error')
.catch((error) => {
alertStore.showAlert(error.response?.data?.detail || '登录失败', 'error')
})
isLoading.value = true
try {
await new Promise((resolve) => setTimeout(resolve, 2000))
// 处理登录成功
} catch (error) {
console.log(error)
// 处理错误
} finally {
isLoading.value = false
@@ -145,7 +172,8 @@ button:active:not(:disabled) {
}
.cyber-grid {
background-image: linear-gradient(transparent 95%, rgba(99, 102, 241, 0.1) 50%),
background-image:
linear-gradient(transparent 95%, rgba(99, 102, 241, 0.1) 50%),
linear-gradient(90deg, transparent 95%, rgba(99, 102, 241, 0.1) 50%);
background-size: 30px 30px;
width: 100%;

View File

@@ -1,47 +1,10 @@
<script setup lang="ts">
import { inject, ref } from 'vue'
import api from '@/utils/api'
import { ConfigService } from '@/services'
import type { ConfigState } from '@/types'
import { useAlertStore } from '@/stores/alertStore'
const isDarkMode = inject('isDarkMode')
interface ConfigState {
name: string
description: string
file_storage: string
themesChoices: any[]
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
}
const config = ref<ConfigState>({
name: '',
@@ -100,48 +63,54 @@ const convertToSeconds = (time: number, unit: string): number => {
return time * units[unit as keyof typeof units]
}
const refreshData = () => {
api({
url: 'admin/config/get',
method: 'get'
}).then((res: any) => {
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 = '秒'
}
})
}
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 : '获取配置失败'
alertStore.showAlert(errorMessage, 'error')
console.error('获取系统配置失败:', error)
}
}
// 转换文件大小为字节
const convertToBytes = (size: number, unit: string): number => {
const units = {
@@ -163,16 +132,15 @@ const submitSave = () => {
formData.max_save_seconds = convertToSeconds(saveTime.value, saveTimeUnit.value)
}
api({
url: 'admin/config/update',
method: 'patch',
data: formData
}).then((res: any) => {
ConfigService.updateConfig(formData).then((res) => {
if (res.code == 200) {
alertStore.showAlert('保存成功', 'success')
} else {
alertStore.showAlert(res.message, 'error')
alertStore.showAlert(res.message || '保存失败', 'error')
}
}).catch((error) => {
const errorMessage = error instanceof Error ? error.message : '保存失败'
alertStore.showAlert(errorMessage, 'error')
})
}

View File

@@ -13,7 +13,12 @@
"paths": {
"@/*": ["src/*"]
},
"noImplicitAny": false,
"strict":false
"noImplicitAny": true,
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}