refactor: modularize frontend flows

This commit is contained in:
Lan
2026-06-03 02:01:57 +08:00
parent 9300607f96
commit a11e7900b4
85 changed files with 4654 additions and 4363 deletions
+39 -416
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+342 -82
View File
@@ -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>
+54 -266
View File
@@ -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>
+30 -47
View File
@@ -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>
+66 -289
View File
@@ -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>