refactor: modularize frontend flows
This commit is contained in:
+39
-416
@@ -42,24 +42,23 @@
|
||||
<FileDetailModal
|
||||
:visible="!!selectedRecord"
|
||||
:record="selectedRecord"
|
||||
@close="selectedRecord = null"
|
||||
@show-content-preview="showContentPreview"
|
||||
:get-download-url="getDownloadUrl"
|
||||
:get-qr-code-value="getQRCodeValue"
|
||||
@close="closeDetails"
|
||||
@preview-content="showContentPreview"
|
||||
/>
|
||||
|
||||
<ContentPreviewModal
|
||||
:visible="showPreview"
|
||||
:rendered-content="renderedContent"
|
||||
@close="showPreview = false"
|
||||
@close="closeContentPreview"
|
||||
@copy-content="copyContent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { inject, onMounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import RetrieveForm from '@/components/common/RetrieveForm.vue'
|
||||
import PageFooter from '@/components/common/PageFooter.vue'
|
||||
@@ -67,424 +66,48 @@ import SideDrawer from '@/components/common/SideDrawer.vue'
|
||||
import FileDetailModal from '@/components/common/FileDetailModal.vue'
|
||||
import FileRecordList from '@/components/common/FileRecordList.vue'
|
||||
import ContentPreviewModal from '@/components/common/ContentPreviewModal.vue'
|
||||
import { useRetrieveFlow } from '@/composables'
|
||||
import { useConfigStore } from '@/stores/configStore'
|
||||
|
||||
// 定义数据接口
|
||||
interface FileRecord {
|
||||
id: number
|
||||
code: string
|
||||
filename: string
|
||||
size: string
|
||||
downloadUrl: string | null
|
||||
content: string | null
|
||||
date: string
|
||||
}
|
||||
|
||||
interface InputStatus {
|
||||
readonly: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useFileDataStore } from '@/stores/fileData'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import api from '@/utils/api'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
interface RetrieveResponse {
|
||||
code: string
|
||||
name: string
|
||||
text: string
|
||||
size: number
|
||||
}
|
||||
import { saveAs } from 'file-saver'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const alertStore = useAlertStore()
|
||||
const baseUrl = window.location.origin
|
||||
|
||||
const router = useRouter()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const fileStore = useFileDataStore()
|
||||
const { receiveData } = storeToRefs(fileStore)
|
||||
const code = ref('')
|
||||
const inputStatus = ref<InputStatus>({
|
||||
readonly: false,
|
||||
loading: false
|
||||
})
|
||||
|
||||
const error = ref('')
|
||||
const selectedRecord = ref<FileRecord | null>(null)
|
||||
const showDrawer = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
// 使用 receiveData 替代原来的 records
|
||||
const records = receiveData
|
||||
const config = JSON.parse(localStorage.getItem('config') || '{}')
|
||||
const codeInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (codeInput.value) {
|
||||
codeInput.value.focus()
|
||||
}
|
||||
const query_code = route.query.code
|
||||
if (query_code && typeof query_code === 'string') {
|
||||
code.value = query_code
|
||||
}
|
||||
})
|
||||
watch(code, (newVal) => {
|
||||
if (newVal.length === 5) {
|
||||
handleSubmit()
|
||||
}
|
||||
})
|
||||
|
||||
const getDownloadUrl = (record: FileRecord) => {
|
||||
if (record.downloadUrl) {
|
||||
if (record.downloadUrl.startsWith('http')) {
|
||||
return record.downloadUrl
|
||||
} else {
|
||||
return `${baseUrl}${record.downloadUrl}`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
// 在其他代码后添加复制功能
|
||||
const copyContent = async () => {
|
||||
if (selectedRecord.value && selectedRecord.value.content) {
|
||||
await copyToClipboard(selectedRecord.value.content, {
|
||||
successMsg: t('fileRecord.contentCopied'),
|
||||
errorMsg: t('fileRecord.copyFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
const handleSubmit = async () => {
|
||||
if (code.value.length !== 5) {
|
||||
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
inputStatus.value.readonly = true
|
||||
inputStatus.value.loading = true
|
||||
|
||||
try {
|
||||
const response = await api.post('/share/select/', {
|
||||
code: code.value
|
||||
})
|
||||
const res = (response.data || response) as ApiResponse<RetrieveResponse>
|
||||
|
||||
if (res && res.code === 200) {
|
||||
if (res.detail) {
|
||||
const isFile = res.detail.text.startsWith('/share/download') || res.detail.name !== 'Text'
|
||||
const newFileData = {
|
||||
id: Date.now(),
|
||||
code: res.detail.code,
|
||||
filename: res.detail.name,
|
||||
size: formatFileSize(res.detail.size),
|
||||
downloadUrl: isFile ? res.detail.text : null,
|
||||
content: isFile ? null : res.detail.text,
|
||||
date: new Date().toLocaleString()
|
||||
}
|
||||
let flag = true
|
||||
fileStore.receiveData.forEach((file: FileRecord) => {
|
||||
if (file.code === newFileData.code) {
|
||||
flag = false
|
||||
}
|
||||
})
|
||||
if (flag) {
|
||||
fileStore.addReceiveData(newFileData)
|
||||
}
|
||||
if (isFile) {
|
||||
selectedRecord.value = newFileData
|
||||
} else {
|
||||
selectedRecord.value = newFileData
|
||||
showPreview.value = true
|
||||
}
|
||||
alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
|
||||
} else {
|
||||
alertStore.showAlert(t('retrieve.messages.invalidCodeError'), 'error')
|
||||
}
|
||||
} else {
|
||||
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + res.detail, 'error')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Retrieve failed:', err)
|
||||
const error = err as { response?: { data?: { detail?: string } }; message?: string }
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || t('retrieve.messages.unknownError')
|
||||
alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
|
||||
} finally {
|
||||
inputStatus.value.readonly = false
|
||||
inputStatus.value.loading = false
|
||||
code.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 ' + t('fileSize.bytes')
|
||||
const k = 1024
|
||||
const sizes = [t('fileSize.bytes'), t('fileSize.kb'), t('fileSize.mb'), t('fileSize.gb'), t('fileSize.tb')]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const viewDetails = (record: FileRecord) => {
|
||||
selectedRecord.value = record
|
||||
}
|
||||
|
||||
const deleteRecord = (id: number) => {
|
||||
const index = records.value.findIndex((record: FileRecord) => record.id === id)
|
||||
if (index !== -1) {
|
||||
fileStore.deleteReceiveData(index)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDrawer = () => {
|
||||
showDrawer.value = !showDrawer.value
|
||||
}
|
||||
const router = useRouter()
|
||||
const configStore = useConfigStore()
|
||||
const { config } = storeToRefs(configStore)
|
||||
const {
|
||||
code,
|
||||
inputStatus,
|
||||
error,
|
||||
records,
|
||||
selectedRecord,
|
||||
showDrawer,
|
||||
showPreview,
|
||||
renderedContent,
|
||||
closeContentPreview,
|
||||
closeDetails,
|
||||
copyContent,
|
||||
deleteRecord,
|
||||
downloadRecord,
|
||||
handleSubmit,
|
||||
showContentPreview,
|
||||
toggleDrawer,
|
||||
viewDetails
|
||||
} = useRetrieveFlow()
|
||||
|
||||
const toSend = () => {
|
||||
router.push('/send')
|
||||
}
|
||||
|
||||
const getQRCodeValue = (record: FileRecord) => {
|
||||
if (record.downloadUrl) {
|
||||
return `${baseUrl}${record.downloadUrl}`
|
||||
} else {
|
||||
return `${baseUrl}?code=${record.code}`
|
||||
onMounted(() => {
|
||||
const queryCode = route.query.code
|
||||
if (queryCode && typeof queryCode === 'string') {
|
||||
code.value = queryCode
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const downloadRecord = (record: FileRecord) => {
|
||||
if (record.downloadUrl) {
|
||||
// 如果是文件,直接下载
|
||||
window.open(
|
||||
`${record.downloadUrl.startsWith('http') ? '' : baseUrl}${record.downloadUrl}`,
|
||||
'_blank'
|
||||
)
|
||||
} else if (record.content) {
|
||||
// 如果是文本,转成txt下载
|
||||
const blob = new Blob([record.content], { type: 'text/plain;charset=utf-8' })
|
||||
saveAs(blob, `${record.filename}.txt`)
|
||||
watch(code, (newCode) => {
|
||||
if (newCode.length === 5) {
|
||||
void handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const showPreview = ref(false)
|
||||
const renderedContent = ref('')
|
||||
|
||||
// 监听selectedRecord变化,异步渲染内容
|
||||
watch(
|
||||
() => selectedRecord.value?.content,
|
||||
async (content) => {
|
||||
if (content) {
|
||||
try {
|
||||
// 使用 marked 解析 Markdown,然后用 DOMPurify 清理 HTML 防止 XSS
|
||||
const rawHtml = await marked(content)
|
||||
renderedContent.value = DOMPurify.sanitize(rawHtml, {
|
||||
// 允许的标签和属性
|
||||
ALLOWED_TAGS: [
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'em',
|
||||
'u',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'blockquote',
|
||||
'code',
|
||||
'pre',
|
||||
'a',
|
||||
'img'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],
|
||||
// 禁用危险的协议
|
||||
ALLOWED_URI_REGEXP:
|
||||
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Markdown 渲染失败:', error)
|
||||
renderedContent.value = content // fallback 到原始内容
|
||||
}
|
||||
} else {
|
||||
renderedContent.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const showContentPreview = () => {
|
||||
showPreview.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes blob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translate(20px, -50px) scale(1.1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translate(50px, 50px) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:w-120 {
|
||||
width: 30rem;
|
||||
/* 480px */
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.w-97-100 {
|
||||
width: 97%;
|
||||
}
|
||||
|
||||
/* 添加 Markdown 样式 */
|
||||
:deep(.prose) {
|
||||
text-align: left;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.prose h1),
|
||||
:deep(.prose h2),
|
||||
:deep(.prose h3),
|
||||
:deep(.prose h4),
|
||||
:deep(.prose h5),
|
||||
:deep(.prose h6) {
|
||||
color: rgb(79, 70, 229);
|
||||
/* text-indigo-600 */
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:deep(.prose p),
|
||||
:deep(.prose div),
|
||||
:deep(.prose span),
|
||||
:deep(.prose code),
|
||||
:deep(.prose pre) {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.prose pre) {
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(.prose code) {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.prose h1),
|
||||
:deep(.prose h2),
|
||||
:deep(.prose h3),
|
||||
:deep(.prose h4),
|
||||
:deep(.prose h5),
|
||||
:deep(.prose h6) {
|
||||
color: rgb(129, 140, 248);
|
||||
/* text-indigo-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加新的宽度类 */
|
||||
@media (min-width: 640px) {
|
||||
.sm\:w-120 {
|
||||
width: 30rem;
|
||||
/* 480px */
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.3) rgba(243, 244, 246, 0.5);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(243, 244, 246, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
/* 深色模式下的滚动条样式 */
|
||||
:deep([class*='dark']) .custom-scrollbar {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(31, 41, 55, 0.5);
|
||||
}
|
||||
|
||||
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(31, 41, 55, 0.5);
|
||||
}
|
||||
|
||||
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(75, 85, 99, 0.7);
|
||||
}
|
||||
|
||||
/* 确保滚动容器有背景色 */
|
||||
.custom-scrollbar {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
/* 文本换行相关样式 */
|
||||
.break-words {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overflow-wrap-anywhere {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
+60
-1085
File diff suppressed because it is too large
Load Diff
@@ -1,134 +1,394 @@
|
||||
<template>
|
||||
<div class="p-6 overflow-y-auto custom-scrollbar">
|
||||
<h2 class="text-2xl font-bold mb-6" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
|
||||
{{ t('admin.dashboard.title') }}
|
||||
</h2>
|
||||
<!-- 统计卡片区域 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm" :class="[mutedTextClass]">FileCodeBox Admin</p>
|
||||
<h2 class="text-2xl font-bold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="fetchDashboardData"
|
||||
class="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-800 text-gray-200 hover:bg-gray-700'
|
||||
: 'bg-white text-gray-700 shadow-sm hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
<RefreshCwIcon class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.dashboard.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
:title="t('admin.dashboard.totalFiles')"
|
||||
:value="dashboardData.totalFiles"
|
||||
:icon="FileIcon"
|
||||
:icon="FilesIcon"
|
||||
icon-color="indigo"
|
||||
description-type="success">
|
||||
>
|
||||
<template #description>
|
||||
<span>{{ t('admin.dashboard.yesterday') }}:{{ dashboardData.yesterdayCount }}</span>
|
||||
<span class="ml-2">{{ t('admin.dashboard.today') }}:{{ dashboardData.todayCount }}</span>
|
||||
{{ t('admin.dashboard.yesterdayShares', { count: dashboardData.yesterdayCount }) }}
|
||||
</template>
|
||||
</StatCard>
|
||||
|
||||
<StatCard
|
||||
:title="t('admin.dashboard.storageSpace')"
|
||||
:value="dashboardData.storageUsed"
|
||||
:value="dashboardData.storageUsedText"
|
||||
:icon="HardDriveIcon"
|
||||
icon-color="purple"
|
||||
description-type="success">
|
||||
>
|
||||
<template #description>
|
||||
<span>{{ t('admin.dashboard.yesterday') }}:{{ dashboardData.yesterdaySize }}</span>
|
||||
<span class="ml-2">{{ t('admin.dashboard.today') }}:{{ dashboardData.todaySize }}</span>
|
||||
{{ t('admin.dashboard.todayIncrease', { count: dashboardData.todaySizeText }) }}
|
||||
</template>
|
||||
</StatCard>
|
||||
|
||||
<StatCard
|
||||
:title="t('admin.dashboard.activeUsers')"
|
||||
value="25"
|
||||
:icon="UsersIcon"
|
||||
:title="t('admin.dashboard.todayShares')"
|
||||
:value="dashboardData.todayCount"
|
||||
:icon="UploadCloudIcon"
|
||||
icon-color="green"
|
||||
description-type="error">
|
||||
>
|
||||
<template #description>
|
||||
<span>{{ t('admin.dashboard.weeklyChange') }}</span>
|
||||
{{ t('admin.dashboard.yesterdayShares', { count: dashboardData.yesterdayCount }) }}
|
||||
</template>
|
||||
</StatCard>
|
||||
|
||||
<StatCard
|
||||
:title="t('admin.dashboard.systemStatus')"
|
||||
:value="t('admin.dashboard.normal')"
|
||||
:icon="ActivityIcon"
|
||||
:title="t('admin.dashboard.totalRetrievals')"
|
||||
:value="dashboardData.usedCount"
|
||||
:icon="DownloadCloudIcon"
|
||||
icon-color="blue"
|
||||
description-type="neutral">
|
||||
>
|
||||
<template #description>
|
||||
{{ t('admin.dashboard.serverUptime') }}: {{ dashboardData.sysUptime }}
|
||||
{{ t('admin.dashboard.serverUptime') }} {{ dashboardData.sysUptimeText }}
|
||||
</template>
|
||||
</StatCard>
|
||||
</div>
|
||||
|
||||
<!-- 添加版本和版权信息 -->
|
||||
<div class="mt-auto text-center py-4" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
|
||||
<p class="text-sm">
|
||||
版本 v2.2.1 更新时间:2025-09-04
|
||||
</p>
|
||||
<p class="text-sm mt-1">
|
||||
© {{ new Date().getFullYear() }} <a href="https://github.com/vastsa/FileCodeBox">FileCodeBox</a>
|
||||
</p>
|
||||
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<section class="xl:col-span-2 rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.fileHealth') }}
|
||||
</h3>
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.fileHealthDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<ActivityIcon class="h-5 w-5" :class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<MetricProgress
|
||||
:label="t('admin.dashboard.activeFileRatio')"
|
||||
:value="dashboardData.activeRatio"
|
||||
:detail="`${dashboardData.activeCount} / ${dashboardData.totalFiles}`"
|
||||
tone="green"
|
||||
/>
|
||||
<MetricProgress
|
||||
:label="t('admin.dashboard.fileShareRatio')"
|
||||
:value="dashboardData.fileRatio"
|
||||
:detail="t('admin.dashboard.binaryFiles', { count: dashboardData.fileCount })"
|
||||
tone="indigo"
|
||||
/>
|
||||
<MetricProgress
|
||||
:label="t('admin.dashboard.textShareRatio')"
|
||||
:value="dashboardData.textRatio"
|
||||
:detail="t('admin.dashboard.textShares', { count: dashboardData.textCount })"
|
||||
tone="purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4" :class="[subtlePanelClass]">
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.expiredFiles') }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-end justify-between">
|
||||
<strong class="text-3xl" :class="[primaryTextClass]">
|
||||
{{ dashboardData.expiredCount }}
|
||||
</strong>
|
||||
<span class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.needCleanup') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4" :class="[subtlePanelClass]">
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.chunkedFiles') }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-end justify-between">
|
||||
<strong class="text-3xl" :class="[primaryTextClass]">
|
||||
{{ dashboardData.chunkedCount }}
|
||||
</strong>
|
||||
<span class="text-sm" :class="[mutedTextClass]">
|
||||
{{ dashboardData.enableChunk ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
||||
<div class="mb-5">
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.storagePolicy') }}
|
||||
</h3>
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.storagePolicyDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<PolicyRow
|
||||
:label="t('admin.dashboard.storageBackend')"
|
||||
:value="dashboardData.storageBackend"
|
||||
/>
|
||||
<PolicyRow
|
||||
:label="t('admin.dashboard.singleFileLimit')"
|
||||
:value="dashboardData.uploadSizeLimitText"
|
||||
/>
|
||||
<PolicyRow
|
||||
:label="t('admin.dashboard.guestUpload')"
|
||||
:value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')"
|
||||
/>
|
||||
<PolicyRow
|
||||
:label="t('admin.dashboard.maxSaveTime')"
|
||||
:value="maxSaveTimeText"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<div class="mb-2 flex items-center justify-between text-sm">
|
||||
<span :class="[mutedTextClass]">{{ t('admin.dashboard.todayCapacityReference') }}</span>
|
||||
<span :class="[primaryTextClass]">{{ dashboardData.todaySizeRatio }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
|
||||
<div
|
||||
class="h-full rounded-full bg-indigo-500"
|
||||
:style="{ width: `${dashboardData.todaySizeRatio}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.fileTypeDistribution') }}
|
||||
</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div v-if="dashboardData.topSuffixes.length === 0" class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('common.noData') }}
|
||||
</div>
|
||||
<div v-for="item in dashboardData.topSuffixes" :key="item.suffix" class="space-y-1">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span :class="[primaryTextClass]">{{ item.suffix || t('admin.dashboard.textType') }}</span>
|
||||
<span :class="[mutedTextClass]">{{ item.count }}</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
|
||||
<div
|
||||
class="h-full rounded-full bg-purple-500"
|
||||
:style="{ width: `${getSuffixRatio(item.count)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="xl:col-span-2 rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.recentFiles') }}
|
||||
</h3>
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.recentFilesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border" :class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']">
|
||||
<table class="min-w-full divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']">
|
||||
<thead :class="[isDarkMode ? 'bg-gray-900/50' : 'bg-gray-50']">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.file') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.size') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.usage') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.status') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-100']">
|
||||
<tr v-if="dashboardData.recentFiles.length === 0">
|
||||
<td colspan="4" class="px-4 py-6 text-center text-sm" :class="[mutedTextClass]">
|
||||
{{ t('common.noData') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="file in dashboardData.recentFiles" :key="file.id">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg p-2" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
|
||||
<FileTextIcon v-if="file.text" class="h-4 w-4" :class="[mutedTextClass]" />
|
||||
<FileIcon v-else class="h-4 w-4" :class="[mutedTextClass]" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium" :class="[primaryTextClass]">
|
||||
{{ file.name || file.code }}
|
||||
</p>
|
||||
<p class="text-xs" :class="[mutedTextClass]">
|
||||
{{ file.code }} · {{ formatCreatedAt(file.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
|
||||
{{ formatFileSize(file.size) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
|
||||
{{ file.usedCount }} {{ t('common.times') }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:class="[
|
||||
file.isExpired
|
||||
? isDarkMode
|
||||
? 'bg-red-900/40 text-red-300'
|
||||
: 'bg-red-100 text-red-700'
|
||||
: isDarkMode
|
||||
? 'bg-green-900/40 text-green-300'
|
||||
: 'bg-green-100 text-green-700'
|
||||
]"
|
||||
>
|
||||
{{ file.isExpired ? t('common.expiredFile') : t('admin.dashboard.available') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, reactive } from 'vue'
|
||||
import { computed, defineComponent, h, onMounted } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import {
|
||||
FileIcon,
|
||||
HardDriveIcon,
|
||||
UsersIcon,
|
||||
ActivityIcon,
|
||||
DownloadCloudIcon,
|
||||
FileIcon,
|
||||
FilesIcon,
|
||||
FileTextIcon,
|
||||
HardDriveIcon,
|
||||
RefreshCwIcon,
|
||||
UploadCloudIcon
|
||||
} from 'lucide-vue-next'
|
||||
import { StatsService } from '@/services'
|
||||
import type { DashboardData } from '@/types'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const { t } = useI18n()
|
||||
import { formatFileSize, formatTimestamp } from '@/utils/common'
|
||||
|
||||
const dashboardData = reactive<DashboardData>({
|
||||
totalFiles: 0,
|
||||
storageUsed: 0,
|
||||
yesterdayCount: 0,
|
||||
todayCount: 0,
|
||||
yesterdaySize: 0,
|
||||
todaySize: 0,
|
||||
sysUptime: 0
|
||||
const isDarkMode = useInjectedDarkMode()
|
||||
const { t } = useI18n()
|
||||
const { dashboardData, fetchDashboardData } = useDashboardStats()
|
||||
|
||||
const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
|
||||
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
|
||||
const panelClass = computed(() =>
|
||||
isDarkMode.value ? 'bg-gray-800/80 border border-gray-700' : 'bg-white border border-gray-100'
|
||||
)
|
||||
const subtlePanelClass = computed(() =>
|
||||
isDarkMode.value ? 'border-gray-700 bg-gray-900/30' : 'border-gray-100 bg-gray-50'
|
||||
)
|
||||
const maxSuffixCount = computed(() =>
|
||||
Math.max(...dashboardData.topSuffixes.map((item) => item.count), 1)
|
||||
)
|
||||
const maxSaveTimeText = computed(() => {
|
||||
if (!dashboardData.maxSaveSeconds) return t('admin.dashboard.noSaveLimit')
|
||||
const days = Math.floor(dashboardData.maxSaveSeconds / 86400)
|
||||
if (days >= 1) return `${days}${t('common.day')}`
|
||||
const hours = Math.floor(dashboardData.maxSaveSeconds / 3600)
|
||||
if (hours >= 1) return `${hours}${t('common.hour')}`
|
||||
return `${Math.floor(dashboardData.maxSaveSeconds / 60)}${t('common.minute')}`
|
||||
})
|
||||
|
||||
const getSysUptime = (startTimestamp: number) => {
|
||||
const now = new Date().getTime()
|
||||
const uptime = now - startTimestamp
|
||||
const days = Math.floor(uptime / (24 * 60 * 60 * 1000))
|
||||
const hours = Math.floor((uptime % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000))
|
||||
return `${days}天${hours}小时`
|
||||
const getSuffixRatio = (count: number) => Math.round((count / maxSuffixCount.value) * 100)
|
||||
|
||||
const formatCreatedAt = (value: string | null) => {
|
||||
if (!value) return '-'
|
||||
return formatTimestamp(value, 'datetime')
|
||||
}
|
||||
|
||||
const getLocalstorageUsed = (nowUsedBit: string) => {
|
||||
const kb = parseInt(nowUsedBit) / 1024
|
||||
const mb = kb / 1024
|
||||
const gb = mb / 1024
|
||||
const tb = gb / 1024
|
||||
// 根据大小选择合适的单位
|
||||
if (tb > 1) {
|
||||
return `${tb.toFixed(2)}TB`
|
||||
} else if (gb > 1) {
|
||||
return `${gb.toFixed(2)}GB`
|
||||
} else if (mb > 1) {
|
||||
return `${mb.toFixed(2)}MB`
|
||||
} else if (kb > 1) {
|
||||
return `${kb.toFixed(2)}KB`
|
||||
} else {
|
||||
return `${nowUsedBit}B`
|
||||
const MetricProgress = defineComponent({
|
||||
name: 'MetricProgress',
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
value: { type: Number, required: true },
|
||||
detail: { type: String, required: true },
|
||||
tone: {
|
||||
type: String as PropType<'green' | 'indigo' | 'purple'>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const toneClass = computed(() => {
|
||||
const classes = {
|
||||
green: 'bg-green-500',
|
||||
indigo: 'bg-indigo-500',
|
||||
purple: 'bg-purple-500'
|
||||
}
|
||||
return classes[props.tone]
|
||||
})
|
||||
|
||||
return () =>
|
||||
h('div', { class: 'rounded-lg border p-4 border-gray-200/60 dark:border-gray-700' }, [
|
||||
h('div', { class: 'mb-2 flex items-center justify-between text-sm' }, [
|
||||
h('span', { class: 'text-gray-500 dark:text-gray-400' }, props.label),
|
||||
h('span', { class: 'font-medium text-gray-900 dark:text-white' }, `${props.value}%`)
|
||||
]),
|
||||
h('div', { class: 'h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700' }, [
|
||||
h('div', {
|
||||
class: ['h-full rounded-full', toneClass.value],
|
||||
style: { width: `${props.value}%` }
|
||||
})
|
||||
]),
|
||||
h('p', { class: 'mt-2 text-sm text-gray-500 dark:text-gray-400' }, props.detail)
|
||||
])
|
||||
}
|
||||
}
|
||||
const getDashboardData = async () => {
|
||||
const response = await StatsService.getDashboard()
|
||||
if (response.detail) {
|
||||
dashboardData.totalFiles = response.detail.totalFiles
|
||||
dashboardData.storageUsed = getLocalstorageUsed(response.detail.storageUsed.toString())
|
||||
dashboardData.yesterdaySize = getLocalstorageUsed(response.detail.yesterdaySize.toString())
|
||||
dashboardData.todaySize = getLocalstorageUsed(response.detail.todaySize.toString())
|
||||
dashboardData.yesterdayCount = response.detail.yesterdayCount
|
||||
dashboardData.todayCount = response.detail.todayCount
|
||||
dashboardData.sysUptime = getSysUptime(Number(response.detail.sysUptime))
|
||||
})
|
||||
|
||||
const PolicyRow = defineComponent({
|
||||
name: 'PolicyRow',
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
value: { type: String, required: true }
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', { class: 'flex items-center justify-between gap-4 border-b border-gray-200/60 pb-3 last:border-b-0 dark:border-gray-700' }, [
|
||||
h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label),
|
||||
h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value)
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getDashboardData()
|
||||
void fetchDashboardData()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="[isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-800']">
|
||||
{{ Math.round((file.size / 1024 / 1024) * 100) / 100 }}MB
|
||||
{{ file.displaySize }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
@@ -71,8 +71,8 @@
|
||||
</span>
|
||||
<!-- 查看全文按钮 - 仅当文本超过一定长度时显示 -->
|
||||
<button
|
||||
v-if="file.text && file.text.length > 30"
|
||||
@click="openTextPreview(file.text)"
|
||||
v-if="file.canPreviewText"
|
||||
@click="openTextPreview(file.text || '')"
|
||||
class="flex-shrink-0 inline-flex items-center px-2 py-1 rounded text-xs transition-colors duration-200"
|
||||
:class="[
|
||||
isDarkMode
|
||||
@@ -91,7 +91,7 @@
|
||||
? (isDarkMode ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-100 text-yellow-800')
|
||||
: (isDarkMode ? 'bg-green-900/30 text-green-400' : 'bg-green-100 text-green-800')
|
||||
]">
|
||||
{{ file.expired_at ? formatTimestamp(file.expired_at) : t('send.expiration.units.forever') }}
|
||||
{{ file.displayExpiredAt }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
@@ -167,125 +167,32 @@
|
||||
<!-- 表单内容 -->
|
||||
<div class="px-6 py-5">
|
||||
<div class="grid gap-6">
|
||||
<!-- 取件码 -->
|
||||
<div class="space-y-2 group">
|
||||
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
|
||||
<span>{{ t('fileManage.form.code') }}</span>
|
||||
<div class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input type="text" v-model="editForm.code"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]" :placeholder="t('fileManage.form.codePlaceholder')">
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件名称 -->
|
||||
<div class="space-y-2 group">
|
||||
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
|
||||
<span>{{ t('fileManage.form.filename') }}</span>
|
||||
<div class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input type="text" v-model="editForm.prefix"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]" :placeholder="t('fileManage.form.filenamePlaceholder')">
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件后缀 -->
|
||||
<div class="space-y-2 group">
|
||||
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
|
||||
<span>{{ t('fileManage.form.suffix') }}</span>
|
||||
<div class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input type="text" v-model="editForm.suffix"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]" :placeholder="t('fileManage.form.suffixPlaceholder')">
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 过期时间 -->
|
||||
<div class="space-y-2 group">
|
||||
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
|
||||
<span>{{ t('send.expiration.label') }}</span>
|
||||
<div class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input type="datetime-local" v-model="editForm.expired_at"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]">
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下载次数限制 -->
|
||||
<div class="space-y-2 group">
|
||||
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
|
||||
<span>{{ t('fileManage.form.downloadLimit') }}</span>
|
||||
<div class="h-px flex-1 transition-colors duration-200"
|
||||
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative rounded-lg shadow-sm">
|
||||
<input type="number" v-model="editForm.expired_count"
|
||||
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
|
||||
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
|
||||
]" :placeholder="t('fileManage.form.downloadLimitPlaceholder')">
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
|
||||
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FileEditField
|
||||
v-model="editForm.code"
|
||||
:label="t('fileManage.form.code')"
|
||||
:placeholder="t('fileManage.form.codePlaceholder')"
|
||||
/>
|
||||
<FileEditField
|
||||
v-model="editForm.prefix"
|
||||
:label="t('fileManage.form.filename')"
|
||||
:placeholder="t('fileManage.form.filenamePlaceholder')"
|
||||
/>
|
||||
<FileEditField
|
||||
v-model="editForm.suffix"
|
||||
:label="t('fileManage.form.suffix')"
|
||||
:placeholder="t('fileManage.form.suffixPlaceholder')"
|
||||
/>
|
||||
<FileEditField
|
||||
v-model="editForm.expired_at"
|
||||
type="datetime-local"
|
||||
:label="t('send.expiration.label')"
|
||||
/>
|
||||
<FileEditField
|
||||
v-model="editForm.expired_count"
|
||||
type="number"
|
||||
:label="t('fileManage.form.downloadLimit')"
|
||||
:placeholder="t('fileManage.form.downloadLimitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -390,10 +297,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref, computed } from 'vue'
|
||||
import { inject, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { FileService } from '@/services'
|
||||
import type { FileListItem, FileEditForm } from '@/types'
|
||||
import {
|
||||
FileIcon,
|
||||
SearchIcon,
|
||||
@@ -406,23 +311,11 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import DataPagination from '@/components/common/DataPagination.vue'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const year = date.getFullYear()
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
import FileEditField from '@/components/common/FileEditField.vue'
|
||||
import { useAdminFiles } from '@/composables'
|
||||
|
||||
const { t } = useI18n()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const tableData = ref<FileListItem[]>([])
|
||||
const alertStore = useAlertStore()
|
||||
// 修改文件表头
|
||||
const fileTableHeaders = computed(() => [
|
||||
t('fileManage.headers.code'),
|
||||
@@ -433,133 +326,28 @@ const fileTableHeaders = computed(() => [
|
||||
t('fileManage.headers.actions')
|
||||
])
|
||||
|
||||
// 分页参数
|
||||
const params = ref({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
keyword: ''
|
||||
const {
|
||||
tableData,
|
||||
params,
|
||||
showEditModal,
|
||||
editForm,
|
||||
showTextPreview,
|
||||
previewText,
|
||||
closeEditModal,
|
||||
closeTextPreview,
|
||||
copyText,
|
||||
deleteFile,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
handleUpdate,
|
||||
loadFiles,
|
||||
openEditModal,
|
||||
openTextPreview
|
||||
} = useAdminFiles()
|
||||
|
||||
onMounted(() => {
|
||||
void loadFiles()
|
||||
})
|
||||
|
||||
// 添加编辑相关的状态
|
||||
const showEditModal = ref(false)
|
||||
const editForm = ref<FileEditForm>({
|
||||
id: null,
|
||||
code: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
expired_at: '',
|
||||
expired_count: null
|
||||
})
|
||||
|
||||
// 文本预览相关状态
|
||||
const showTextPreview = ref(false)
|
||||
const previewText = ref('')
|
||||
|
||||
// 打开文本预览
|
||||
const openTextPreview = (text: string) => {
|
||||
previewText.value = text
|
||||
showTextPreview.value = true
|
||||
}
|
||||
|
||||
// 关闭文本预览
|
||||
const closeTextPreview = () => {
|
||||
showTextPreview.value = false
|
||||
previewText.value = ''
|
||||
}
|
||||
|
||||
// 复制文本到剪贴板
|
||||
const copyText = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(previewText.value)
|
||||
alertStore.showAlert(t('fileManage.copySuccess'), 'success')
|
||||
} catch {
|
||||
alertStore.showAlert(t('fileManage.copyFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑模态框
|
||||
const openEditModal = (file: FileListItem) => {
|
||||
editForm.value = {
|
||||
id: file.id,
|
||||
code: file.code,
|
||||
prefix: file.prefix,
|
||||
suffix: file.suffix,
|
||||
expired_at: file.expired_at ? file.expired_at.slice(0, 16) : '', // 格式化日期时间
|
||||
expired_count: file.expired_count
|
||||
}
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
// 关闭编辑模态框
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editForm.value = {
|
||||
id: null,
|
||||
code: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
expired_at: '',
|
||||
expired_count: null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理更新
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await FileService.updateFile(editForm.value)
|
||||
await loadFiles()
|
||||
closeEditModal()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
alertStore.showAlert(err.response?.data?.detail || t('manage.fileManage.updateFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件处理
|
||||
const deleteFile = async (id: number) => {
|
||||
try {
|
||||
await FileService.deleteAdminFile(id)
|
||||
await loadFiles()
|
||||
} catch (error) {
|
||||
console.error(t('manage.fileManage.deleteFailed'), error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载文件列表
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
const res = await FileService.getAdminFileList(params.value)
|
||||
if (res.detail) {
|
||||
tableData.value = res.detail.data
|
||||
params.value.total = res.detail.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t('manage.fileManage.loadFileListFailed'), error)
|
||||
}
|
||||
}
|
||||
|
||||
// 页码改变处理函数
|
||||
const handlePageChange = async (page: number | string) => {
|
||||
if (typeof page === 'string') return // 忽略省略号
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
params.value.page = page
|
||||
await loadFiles()
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
loadFiles()
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = computed(() => Math.ceil(params.value.total / params.value.size))
|
||||
|
||||
|
||||
|
||||
// 添加搜索处理函数
|
||||
const handleSearch = async () => {
|
||||
params.value.page = 1 // 重置页码到第一页
|
||||
await loadFiles()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -39,8 +39,19 @@
|
||||
登录
|
||||
</h2>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" @submit.prevent="handleSubmit">
|
||||
<form class="mt-8 space-y-6" @submit.prevent="submitLogin">
|
||||
<input type="hidden" name="remember" value="true" />
|
||||
<label for="username" class="sr-only">用户名</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
value="admin"
|
||||
readonly
|
||||
tabindex="-1"
|
||||
class="sr-only"
|
||||
/>
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="password" class="sr-only">密码</label>
|
||||
@@ -83,57 +94,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { BoxIcon } from 'lucide-vue-next'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
import { useAdminData } from '@/stores/adminStore'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AuthService } from '@/services'
|
||||
const alertStore = useAlertStore()
|
||||
const password = ref('')
|
||||
const isLoading = ref(false)
|
||||
import { useAdminLogin } from '@/composables'
|
||||
import { ROUTES } from '@/constants'
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const adminStore = useAdminData()
|
||||
const validateForm = () => {
|
||||
let isValid = true
|
||||
if (!password.value) {
|
||||
alertStore.showAlert('无效的密码', 'error')
|
||||
isValid = false
|
||||
} else if (password.value.length < 6) {
|
||||
alertStore.showAlert('密码长度至少为6位', 'error')
|
||||
isValid = false
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { password, isLoading, handleSubmit } = useAdminLogin()
|
||||
|
||||
const getRedirectPath = () => {
|
||||
const redirect = route.query.redirect
|
||||
if (typeof redirect === 'string' && redirect.startsWith('/')) {
|
||||
return redirect
|
||||
}
|
||||
return isValid
|
||||
return ROUTES.ADMIN
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await AuthService.login(password.value)
|
||||
if (response.detail?.token) {
|
||||
adminStore.setToken(response.detail.token)
|
||||
router.push('/admin')
|
||||
} else {
|
||||
alertStore.showAlert('登录失败:未获取到有效令牌', 'error')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
interface ErrorWithResponse {
|
||||
response?: {
|
||||
data?: {
|
||||
detail?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
const errorMessage = error && typeof error === 'object' && 'response' in error
|
||||
? (error as ErrorWithResponse).response?.data?.detail || '登录失败'
|
||||
: '登录失败'
|
||||
alertStore.showAlert(errorMessage, 'error')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
const submitLogin = async () => {
|
||||
const success = await handleSubmit()
|
||||
if (success) {
|
||||
await router.push(getRedirectPath())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,152 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue'
|
||||
import { ConfigService } from '@/services'
|
||||
import type { ConfigState } from '@/types'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
import { inject, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SettingNumberInput from '@/components/common/SettingNumberInput.vue'
|
||||
import SettingSwitch from '@/components/common/SettingSwitch.vue'
|
||||
import { useSystemConfig } from '@/composables'
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
config,
|
||||
fileSize,
|
||||
sizeUnit,
|
||||
saveTime,
|
||||
saveTimeUnit,
|
||||
refreshConfig,
|
||||
submitConfig,
|
||||
toggleConfigFlag
|
||||
} = useSystemConfig()
|
||||
|
||||
const config = ref<ConfigState>({
|
||||
name: '',
|
||||
description: '',
|
||||
file_storage: '',
|
||||
webdav_url: '',
|
||||
webdav_username: '',
|
||||
webdav_password: '',
|
||||
themesChoices: [],
|
||||
expireStyle: [],
|
||||
admin_token: '',
|
||||
robotsText: '',
|
||||
keywords: '',
|
||||
notify_title: '',
|
||||
storage_path: '',
|
||||
notify_content: '',
|
||||
openUpload: 1,
|
||||
uploadSize: 1,
|
||||
enableChunk: 0,
|
||||
uploadMinute: 1,
|
||||
max_save_seconds: 0,
|
||||
opacity: 0.9,
|
||||
s3_access_key_id: '',
|
||||
background: '',
|
||||
showAdminAddr: 0,
|
||||
page_explain: '',
|
||||
s3_secret_access_key: '',
|
||||
aws_session_token: '',
|
||||
s3_signature_version: '',
|
||||
s3_region_name: '',
|
||||
s3_bucket_name: '',
|
||||
s3_endpoint_url: '',
|
||||
s3_hostname: '',
|
||||
uploadCount: 1,
|
||||
errorMinute: 1,
|
||||
errorCount: 1,
|
||||
s3_proxy: 0,
|
||||
themesSelect: ''
|
||||
onMounted(() => {
|
||||
void refreshConfig()
|
||||
})
|
||||
|
||||
const fileSize = ref(1)
|
||||
const sizeUnit = ref('MB')
|
||||
|
||||
// 添加保存时间相关的响应式变量
|
||||
const saveTime = ref(1)
|
||||
const saveTimeUnit = ref('天')
|
||||
|
||||
// 转换时间为秒
|
||||
const convertToSeconds = (time: number, unit: string): number => {
|
||||
const units = {
|
||||
秒: 1,
|
||||
分: 60,
|
||||
时: 3600,
|
||||
天: 86400
|
||||
}
|
||||
return time * units[unit as keyof typeof units]
|
||||
}
|
||||
|
||||
const alertStore = useAlertStore()
|
||||
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
const res = await ConfigService.getConfig()
|
||||
if (res.code === 200 && res.detail) {
|
||||
// 直接使用ConfigState类型的响应数据
|
||||
config.value = res.detail
|
||||
|
||||
// 将字节转换为合适的单位
|
||||
const size = config.value.uploadSize
|
||||
if (size >= 1024 * 1024 * 1024) {
|
||||
fileSize.value = Math.round(size / (1024 * 1024 * 1024))
|
||||
sizeUnit.value = 'GB'
|
||||
} else if (size >= 1024 * 1024) {
|
||||
fileSize.value = Math.round(size / (1024 * 1024))
|
||||
sizeUnit.value = 'MB'
|
||||
} else {
|
||||
fileSize.value = Math.round(size / 1024)
|
||||
sizeUnit.value = 'KB'
|
||||
}
|
||||
|
||||
// 时间单位转换逻辑
|
||||
const seconds = config.value.max_save_seconds
|
||||
if (seconds === 0) {
|
||||
// 如果是0,显示为7天
|
||||
saveTime.value = 7
|
||||
saveTimeUnit.value = '天'
|
||||
} else if (seconds % 86400 === 0 && seconds >= 86400) {
|
||||
saveTime.value = seconds / 86400
|
||||
saveTimeUnit.value = '天'
|
||||
} else if (seconds % 3600 === 0 && seconds >= 3600) {
|
||||
saveTime.value = seconds / 3600
|
||||
saveTimeUnit.value = '时'
|
||||
} else if (seconds % 60 === 0 && seconds >= 60) {
|
||||
saveTime.value = seconds / 60
|
||||
saveTimeUnit.value = '分'
|
||||
} else {
|
||||
saveTime.value = seconds
|
||||
saveTimeUnit.value = '秒'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t('manage.systemSettings.getConfigFailed')
|
||||
alertStore.showAlert(errorMessage, 'error')
|
||||
console.error('Failed to get system config:', error)
|
||||
}
|
||||
}
|
||||
// 转换文件大小为字节
|
||||
const convertToBytes = (size: number, unit: string): number => {
|
||||
const units = {
|
||||
KB: 1024,
|
||||
MB: 1024 * 1024,
|
||||
GB: 1024 * 1024 * 1024
|
||||
}
|
||||
return size * units[unit as keyof typeof units]
|
||||
}
|
||||
|
||||
const submitSave = () => {
|
||||
const formData = { ...config.value }
|
||||
formData.uploadSize = convertToBytes(fileSize.value, sizeUnit.value)
|
||||
|
||||
// 如果保存时间为0,则默认设置为7天
|
||||
if (saveTime.value === 0) {
|
||||
formData.max_save_seconds = 7 * 86400 // 7天转换为秒
|
||||
} else {
|
||||
formData.max_save_seconds = convertToSeconds(saveTime.value, saveTimeUnit.value)
|
||||
}
|
||||
|
||||
ConfigService.updateConfig(formData).then((res) => {
|
||||
if (res.code == 200) {
|
||||
alertStore.showAlert(t('manage.systemSettings.saveSuccess'), 'success')
|
||||
} else {
|
||||
alertStore.showAlert(res.message || t('manage.systemSettings.saveFailed'), 'error')
|
||||
}
|
||||
}).catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : t('manage.systemSettings.saveFailed')
|
||||
alertStore.showAlert(errorMessage, 'error')
|
||||
})
|
||||
}
|
||||
|
||||
refreshData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -322,27 +196,14 @@ refreshData()
|
||||
<option value="webdav">{{ t('manage.settings.webdavStorage') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2" v-if="config.file_storage === 'local'">
|
||||
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.chunkUploadNote') }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<button type="button" @click="config.enableChunk = config.enableChunk === 1 ? 0 : 1"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
:class="[config.enableChunk === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
|
||||
:aria-checked="config.enableChunk === 1">
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
config.enableChunk === 1 ? 'translate-x-5' : 'translate-x-0',
|
||||
isDarkMode && config.enableChunk !== 1 ? 'bg-gray-100' : 'bg-white'
|
||||
]" />
|
||||
</button>
|
||||
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ config.enableChunk === 1 ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingSwitch
|
||||
v-if="config.file_storage === 'local'"
|
||||
:label="t('manage.settings.chunkUploadNote')"
|
||||
:model-value="config.enableChunk"
|
||||
:enabled-text="t('common.enabled')"
|
||||
:disabled-text="t('common.disabled')"
|
||||
@toggle="toggleConfigFlag('enableChunk')"
|
||||
/>
|
||||
<div v-if="config.file_storage === 'webdav'" class="space-y-4">
|
||||
<!-- 通知设置 -->
|
||||
<div class="space-y-2">
|
||||
@@ -482,51 +343,21 @@ refreshData()
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium mb-2"
|
||||
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.enableProxy') }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<button type="button" @click="config.s3_proxy = config.s3_proxy === 1 ? 0 : 1"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
:class="[config.s3_proxy === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
|
||||
:aria-checked="config.s3_proxy === 1">
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
config.s3_proxy === 1 ? 'translate-x-5' : 'translate-x-0',
|
||||
isDarkMode && config.s3_proxy !== 1 ? 'bg-gray-100' : 'bg-white'
|
||||
]" />
|
||||
</button>
|
||||
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ config.s3_proxy === 1 ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingSwitch
|
||||
:label="t('manage.settings.enableProxy')"
|
||||
:model-value="config.s3_proxy"
|
||||
:enabled-text="t('common.enabled')"
|
||||
:disabled-text="t('common.disabled')"
|
||||
@toggle="toggleConfigFlag('s3_proxy')"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium mb-2"
|
||||
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.chunkUploadNote') }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<button type="button" @click="config.enableChunk = config.enableChunk === 1 ? 0 : 1"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
:class="[config.enableChunk === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
|
||||
:aria-checked="config.enableChunk === 1">
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
config.enableChunk === 1 ? 'translate-x-5' : 'translate-x-0',
|
||||
isDarkMode && config.enableChunk !== 1 ? 'bg-gray-100' : 'bg-white'
|
||||
]" />
|
||||
</button>
|
||||
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ config.enableChunk === 1 ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingSwitch
|
||||
:label="t('manage.settings.chunkUploadNote')"
|
||||
:model-value="config.enableChunk"
|
||||
:enabled-text="t('common.enabled')"
|
||||
:disabled-text="t('common.disabled')"
|
||||
@toggle="toggleConfigFlag('enableChunk')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,37 +370,17 @@ refreshData()
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.uploadPerMinute') }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="number" v-model="config.uploadMinute"
|
||||
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.minute') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingNumberInput
|
||||
v-model="config.uploadMinute"
|
||||
:label="t('manage.settings.uploadPerMinute')"
|
||||
:suffix="t('common.minute')"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.uploadCountLimit') }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="number" v-model="config.uploadCount"
|
||||
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.files') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingNumberInput
|
||||
v-model="config.uploadCount"
|
||||
:label="t('manage.settings.uploadCountLimit')"
|
||||
:suffix="t('common.files')"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
@@ -647,27 +458,13 @@ refreshData()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.guestUpload') }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<button type="button" @click="config.openUpload = config.openUpload === 1 ? 0 : 1"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
:class="[config.openUpload === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
|
||||
:aria-checked="config.openUpload === 1">
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
config.openUpload === 1 ? 'translate-x-5' : 'translate-x-0',
|
||||
isDarkMode && config.openUpload !== 1 ? 'bg-gray-100' : 'bg-white'
|
||||
]" />
|
||||
</button>
|
||||
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ config.openUpload === 1 ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingSwitch
|
||||
:label="t('manage.settings.guestUpload')"
|
||||
:model-value="config.openUpload"
|
||||
:enabled-text="t('common.enabled')"
|
||||
:disabled-text="t('common.disabled')"
|
||||
@toggle="toggleConfigFlag('openUpload')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -678,43 +475,23 @@ refreshData()
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.errorPerMinute') }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="number" v-model="config.errorMinute"
|
||||
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.minute') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingNumberInput
|
||||
v-model="config.errorMinute"
|
||||
:label="t('manage.settings.errorPerMinute')"
|
||||
:suffix="t('common.minute')"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('manage.settings.errorCountLimit') }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="number" v-model="config.errorCount"
|
||||
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.times') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingNumberInput
|
||||
v-model="config.errorCount"
|
||||
:label="t('manage.settings.errorCountLimit')"
|
||||
:suffix="t('common.times')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="flex justify-end mt-8">
|
||||
<button @click="submitSave"
|
||||
<button @click="submitConfig"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200">
|
||||
{{ t('manage.settings.saveChanges') }}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user