Merge branch 'feature/ux-compat-platform'

This commit is contained in:
Lan
2026-06-03 18:40:50 +08:00
36 changed files with 5672 additions and 811 deletions
+1
View File
@@ -12,6 +12,7 @@ node_modules
dist dist
dist-ssr dist-ssr
coverage coverage
output
*.local *.local
/cypress/videos/ /cypress/videos/
+1
View File
@@ -110,6 +110,7 @@ const rules = [
/^src\/utils\/auth-storage\.ts$/, /^src\/utils\/auth-storage\.ts$/,
/^src\/utils\/config-storage\.ts$/, /^src\/utils\/config-storage\.ts$/,
/^src\/utils\/preference-storage\.ts$/, /^src\/utils\/preference-storage\.ts$/,
/^src\/utils\/record-storage\.ts$/,
], ],
}, },
{ {
+34 -3
View File
@@ -5,7 +5,14 @@ import LanguageSwitcher from './components/common/LanguageSwitcher.vue'
import AlertComponent from '@/components/common/AlertComponent.vue' import AlertComponent from '@/components/common/AlertComponent.vue'
import { useAppShell } from '@/composables' import { useAppShell } from '@/composables'
const { isDarkMode, isLoading, route, showGlobalControls } = useAppShell() const {
isDarkMode,
isLoading,
routeTransitionMode,
routeTransitionName,
routeViewKey,
showGlobalControls
} = useAppShell()
</script> </script>
<template> <template>
@@ -18,8 +25,8 @@ const { isDarkMode, isLoading, route, showGlobalControls } = useAppShell()
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
</div> </div>
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<transition name="fade" mode="out-in"> <transition :name="routeTransitionName" :mode="routeTransitionMode">
<component :is="Component" :key="route.fullPath" /> <component :is="Component" :key="routeViewKey" />
</transition> </transition>
</RouterView> </RouterView>
@@ -52,6 +59,30 @@ const { isDarkMode, isLoading, route, showGlobalControls } = useAppShell()
opacity: 0; opacity: 0;
} }
.transfer-fade-enter-active,
.transfer-fade-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.transfer-fade-enter-from {
opacity: 0;
transform: translateY(8px);
}
.transfer-fade-leave-active {
position: absolute;
inset: 0;
width: 100%;
pointer-events: none;
}
.transfer-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
.loading-overlay { .loading-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
+82 -20
View File
@@ -1,14 +1,26 @@
<template> <template>
<div <div
class="rounded-xl p-8 flex flex-col items-center justify-center border-2 border-dashed transition-all duration-300 group cursor-pointer relative" class="rounded-2xl p-8 flex flex-col items-center justify-center border-2 border-dashed transition-all duration-300 group cursor-pointer relative min-h-72 overflow-hidden"
:class="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-800 bg-opacity-50 border-gray-600 hover:border-indigo-500' ? 'bg-gray-800/60 border-gray-600 hover:border-indigo-400'
: 'bg-gray-100 border-gray-300 hover:border-indigo-500', : 'bg-white/80 border-gray-300 hover:border-indigo-500',
isDragActive
? isDarkMode
? 'border-indigo-400 bg-indigo-500/10 shadow-lg shadow-indigo-900/30'
: 'border-indigo-500 bg-indigo-50 shadow-lg shadow-indigo-100'
: '',
statusClass statusClass
]" ]"
role="button"
tabindex="0"
:aria-busy="isUploading"
@click="triggerFileUpload" @click="triggerFileUpload"
@dragover.prevent @keydown.enter.prevent="triggerFileUpload"
@keydown.space.prevent="triggerFileUpload"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleFileDrop" @drop.prevent="handleFileDrop"
> >
<input <input
@@ -20,6 +32,18 @@
:disabled="isUploading" :disabled="isUploading"
multiple multiple
/> />
<div
class="absolute inset-0 opacity-0 transition-opacity duration-300"
:class="[isDragActive ? 'opacity-100' : '']"
>
<div
class="absolute inset-3 rounded-2xl border"
:class="[
isDarkMode ? 'border-indigo-300/30 bg-indigo-400/5' : 'border-indigo-300 bg-indigo-50/80'
]"
></div>
</div>
<div class="absolute inset-0 w-full h-full" v-if="progress > 0"> <div class="absolute inset-0 w-full h-full" v-if="progress > 0">
<BorderProgressBar :progress="progress" /> <BorderProgressBar :progress="progress" />
</div> </div>
@@ -27,24 +51,20 @@
<!-- 上传状态图标 --> <!-- 上传状态图标 -->
<component <component
:is="statusIcon" :is="statusIcon"
:class="['w-16 h-16 transition-colors duration-300', statusIconClass]" :class="['relative z-10 w-16 h-16 transition-colors duration-300', statusIconClass]"
/> />
<!-- 文件名或占位文本 --> <!-- 文件名或占位文本 -->
<p <p
:class="[ :class="[
'mt-4 text-sm transition-colors duration-300 w-full text-center', 'relative z-10 mt-4 text-sm transition-colors duration-300 w-full text-center',
isDarkMode isDarkMode
? 'text-gray-400 group-hover:text-indigo-400' ? 'text-gray-400 group-hover:text-indigo-400'
: 'text-gray-600 group-hover:text-indigo-600' : 'text-gray-600 group-hover:text-indigo-600'
]" ]"
> >
<span v-if="selectedFiles && selectedFiles.length > 1" class="block"> <span v-if="selectedFiles && selectedFiles.length > 1" class="block">
<span <span v-for="(f, i) in selectedFiles" :key="i" class="block truncate">{{ f.name }}</span>
v-for="(f, i) in selectedFiles"
:key="i"
class="block truncate"
>{{ f.name }}</span>
</span> </span>
<span v-else class="block truncate"> <span v-else class="block truncate">
{{ displayText }} {{ displayText }}
@@ -52,12 +72,33 @@
</p> </p>
<!-- 状态描述或默认描述 --> <!-- 状态描述或默认描述 -->
<p :class="['mt-2 text-xs', statusDescriptionClass]"> <p :class="['relative z-10 mt-2 text-xs text-center leading-5', statusDescriptionClass]">
{{ statusDescription }} {{ statusDescription }}
</p> </p>
<div
v-if="selectedFiles.length > 1"
class="relative z-10 mt-4 flex flex-wrap items-center justify-center gap-2"
>
<span
v-for="file in selectedFiles.slice(0, 3)"
:key="`${file.name}-${file.size}`"
class="max-w-40 truncate rounded-full px-3 py-1 text-xs"
:class="[isDarkMode ? 'bg-gray-700/80 text-gray-200' : 'bg-gray-100 text-gray-700']"
>
{{ file.name }}
</span>
<span
v-if="selectedFiles.length > 3"
class="rounded-full px-3 py-1 text-xs"
:class="[isDarkMode ? 'bg-indigo-500/20 text-indigo-200' : 'bg-indigo-100 text-indigo-700']"
>
+{{ selectedFiles.length - 3 }}
</span>
</div>
<!-- 进度详情上传中显示 --> <!-- 进度详情上传中显示 -->
<div v-if="isUploading && showProgressDetails" class="mt-3 w-full"> <div v-if="isUploading && showProgressDetails" class="relative z-10 mt-3 w-full">
<div <div
class="flex justify-between text-xs mb-1" class="flex justify-between text-xs mb-1"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
@@ -78,7 +119,7 @@
: 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-500 hover:bg-indigo-600 text-white'
]" ]"
> >
{{ retryText }} {{ retryLabel }}
</button> </button>
</div> </div>
</template> </template>
@@ -136,7 +177,7 @@ const props = withDefaults(defineProps<Props>(), {
totalBytes: 0, totalBytes: 0,
errorMessage: '', errorMessage: '',
allowRetry: true, allowRetry: true,
retryText: '重试', retryText: '',
showProgressDetails: true showProgressDetails: true
}) })
@@ -147,7 +188,9 @@ const isDarkMode = useInjectedDarkMode()
// 使用computed属性处理多语言文本 // 使用computed属性处理多语言文本
const placeholderText = computed(() => props.placeholder || t('send.uploadArea.placeholder')) const placeholderText = computed(() => props.placeholder || t('send.uploadArea.placeholder'))
const descriptionText = computed(() => props.description || t('send.uploadArea.description')) const descriptionText = computed(() => props.description || t('send.uploadArea.description'))
const retryLabel = computed(() => props.retryText || t('send.uploadArea.retry'))
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const isDragActive = ref(false)
const isUploading = computed(() => { const isUploading = computed(() => {
return ['uploading', 'initializing', 'confirming'].includes(props.uploadStatus) return ['uploading', 'initializing', 'confirming'].includes(props.uploadStatus)
@@ -162,7 +205,7 @@ const displayText = computed(() => {
return props.selectedFiles[0].name return props.selectedFiles[0].name
} }
if (props.selectedFiles && props.selectedFiles.length > 1) { if (props.selectedFiles && props.selectedFiles.length > 1) {
return `已选择 ${props.selectedFiles.length} 个文件` return t('send.uploadArea.selectedFiles', { count: props.selectedFiles.length })
} }
if (props.selectedFile) { if (props.selectedFile) {
return props.selectedFile.name return props.selectedFile.name
@@ -207,16 +250,16 @@ const statusDescription = computed(() => {
return props.errorMessage return props.errorMessage
} }
if (props.uploadStatus === 'initializing') { if (props.uploadStatus === 'initializing') {
return '正在初始化上传...' return t('send.uploadArea.status.initializing')
} }
if (props.uploadStatus === 'uploading') { if (props.uploadStatus === 'uploading') {
return '正在上传文件...' return t('send.uploadArea.status.uploading')
} }
if (props.uploadStatus === 'confirming') { if (props.uploadStatus === 'confirming') {
return '正在确认上传...' return t('send.uploadArea.status.confirming')
} }
if (isSuccess.value) { if (isSuccess.value) {
return '上传成功!' return t('send.uploadArea.status.success')
} }
return descriptionText.value return descriptionText.value
}) })
@@ -245,6 +288,24 @@ const triggerFileUpload = () => {
fileInput.value?.click() fileInput.value?.click()
} }
const handleDragEnter = () => {
if (isUploading.value) return
isDragActive.value = true
}
const handleDragOver = () => {
if (isUploading.value) return
isDragActive.value = true
}
const handleDragLeave = (event: DragEvent) => {
const target = event.currentTarget as HTMLElement
const relatedTarget = event.relatedTarget as Node | null
if (!relatedTarget || !target.contains(relatedTarget)) {
isDragActive.value = false
}
}
const handleFileUpload = (event: Event) => { const handleFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
const files = target.files const files = target.files
@@ -261,6 +322,7 @@ const handleFileUpload = (event: Event) => {
const handleFileDrop = (event: DragEvent) => { const handleFileDrop = (event: DragEvent) => {
// 上传中不允许拖放 // 上传中不允许拖放
isDragActive.value = false
if (isUploading.value) return if (isUploading.value) return
emit('fileDrop', event) emit('fileDrop', event)
} }
+1
View File
@@ -1,5 +1,6 @@
export { useAdminFiles } from './useAdminFiles' export { useAdminFiles } from './useAdminFiles'
export { useAdminLogin } from './useAdminLogin' export { useAdminLogin } from './useAdminLogin'
export { useAdminSession } from './useAdminSession'
export { useAppShell } from './useAppShell' export { useAppShell } from './useAppShell'
export { useDashboardStats } from './useDashboardStats' export { useDashboardStats } from './useDashboardStats'
export { useInjectedDarkMode } from './useInjectedDarkMode' export { useInjectedDarkMode } from './useInjectedDarkMode'
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -35,7 +35,13 @@ export function useAdminLogin() {
return false return false
} }
adminStore.setToken(response.detail.token) adminStore.login({
id: response.detail.id || 'admin',
username: response.detail.username || 'admin',
token: response.detail.token,
token_type: response.detail.token_type,
expires_at: response.detail.expires_at
})
return true return true
} catch (error: unknown) { } catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, '登录失败'), 'error') alertStore.showAlert(getErrorMessage(error, '登录失败'), 'error')
+35
View File
@@ -0,0 +1,35 @@
import { AuthService } from '@/services'
import { useAdminStore } from '@/stores/adminStore'
export function useAdminSession() {
const adminStore = useAdminStore()
const verifySession = async () => {
if (!adminStore.hasToken) return false
try {
const response = await AuthService.verifyToken()
if (response.code === 200 && response.detail?.token) {
adminStore.login(response.detail)
return true
}
} catch {
adminStore.logout()
}
return false
}
const logout = async () => {
try {
await AuthService.logout()
} finally {
adminStore.logout()
}
}
return {
verifySession,
logout
}
}
+10
View File
@@ -13,6 +13,13 @@ export function useAppShell() {
const { isLoading, setupRouteLoading } = useRouteLoading(router) const { isLoading, setupRouteLoading } = useRouteLoading(router)
const { syncPublicConfig } = usePublicConfigBootstrap() const { syncPublicConfig } = usePublicConfigBootstrap()
const showGlobalControls = computed(() => route.meta.showGlobalControls !== false) const showGlobalControls = computed(() => route.meta.showGlobalControls !== false)
const routeTransitionName = computed(() => String(route.meta.routeTransition || 'fade'))
const routeTransitionMode = computed(() => (route.meta.routeTransition ? undefined : 'out-in'))
const routeViewKey = computed(() =>
route.path === ROUTES.ADMIN || route.path.startsWith(`${ROUTES.ADMIN}/`)
? ROUTES.ADMIN
: route.fullPath
)
let cleanupThemeListener: (() => void) | null = null let cleanupThemeListener: (() => void) | null = null
@@ -47,6 +54,9 @@ export function useAppShell() {
isDarkMode, isDarkMode,
isLoading, isLoading,
route, route,
routeTransitionMode,
routeTransitionName,
routeViewKey,
showGlobalControls showGlobalControls
} }
} }
+122 -50
View File
@@ -1,7 +1,11 @@
import { reactive } from 'vue' import { computed, ref, reactive } from 'vue'
import { StatsService } from '@/services' import { StatsService } from '@/services'
import type { DashboardViewData } from '@/types' import type { DashboardData, DashboardHealthSummary, DashboardViewData } from '@/types'
import { formatFileSize } from '@/utils/common' import { formatFileSize, getErrorMessage } from '@/utils/common'
type UseDashboardStatsOptions = {
loadFailedMessage?: string
}
const emptyDashboardData = (): DashboardViewData => ({ const emptyDashboardData = (): DashboardViewData => ({
hasExtendedStats: false, hasExtendedStats: false,
@@ -23,8 +27,14 @@ const emptyDashboardData = (): DashboardViewData => ({
openUpload: 0, openUpload: 0,
enableChunk: 0, enableChunk: 0,
maxSaveSeconds: 0, maxSaveSeconds: 0,
topSuffixes: [], healthAttentionCount: 0,
recentFiles: [], healthDangerCount: 0,
healthWarningCount: 0,
expiringSoonCount: 0,
storageIssueCount: 0,
neverRetrievedCount: 0,
healthyCount: 0,
permanentCount: 0,
storageUsedText: '0 Bytes', storageUsedText: '0 Bytes',
yesterdaySizeText: '0 Bytes', yesterdaySizeText: '0 Bytes',
todaySizeText: '0 Bytes', todaySizeText: '0 Bytes',
@@ -33,6 +43,8 @@ const emptyDashboardData = (): DashboardViewData => ({
activeRatio: 0, activeRatio: 0,
textRatio: 0, textRatio: 0,
fileRatio: 0, fileRatio: 0,
healthyRatio: 0,
healthAttentionRatio: 0,
todaySizeRatio: 0 todaySizeRatio: 0
}) })
@@ -50,59 +62,119 @@ const formatDuration = (startTimestamp: number | null) => {
return `${days}${hours}小时` return `${days}${hours}小时`
} }
export function useDashboardStats() { const healthSummaryKeys: (keyof DashboardHealthSummary)[] = [
'healthAttentionCount',
'healthDangerCount',
'healthWarningCount',
'expiringSoonCount',
'storageIssueCount',
'neverRetrievedCount',
'healthyCount',
'permanentCount'
]
const normalizeHealthSummary = (detail: DashboardData): DashboardHealthSummary => ({
healthAttentionCount: toNumber(
detail.healthSummary?.healthAttentionCount ?? detail.healthAttentionCount
),
healthDangerCount: toNumber(detail.healthSummary?.healthDangerCount ?? detail.healthDangerCount),
healthWarningCount: toNumber(
detail.healthSummary?.healthWarningCount ?? detail.healthWarningCount
),
expiringSoonCount: toNumber(detail.healthSummary?.expiringSoonCount ?? detail.expiringSoonCount),
storageIssueCount: toNumber(detail.healthSummary?.storageIssueCount ?? detail.storageIssueCount),
neverRetrievedCount: toNumber(
detail.healthSummary?.neverRetrievedCount ?? detail.neverRetrievedCount
),
healthyCount: toNumber(detail.healthSummary?.healthyCount ?? detail.healthyCount),
permanentCount: toNumber(detail.healthSummary?.permanentCount ?? detail.permanentCount)
})
export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
const dashboardData = reactive<DashboardViewData>(emptyDashboardData()) const dashboardData = reactive<DashboardViewData>(emptyDashboardData())
const isLoading = ref(false)
const errorMessage = ref('')
const lastUpdatedAt = ref<Date | null>(null)
const lastUpdatedText = computed(() =>
lastUpdatedAt.value ? lastUpdatedAt.value.toLocaleString() : '-'
)
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
const response = await StatsService.getDashboard() isLoading.value = true
if (!response.detail) return errorMessage.value = ''
const detail = response.detail try {
dashboardData.totalFiles = toNumber(detail.totalFiles) const response = await StatsService.getDashboard()
dashboardData.storageUsed = toNumber(detail.storageUsed) if (!response.detail) {
dashboardData.yesterdayCount = toNumber(detail.yesterdayCount) throw new Error('No dashboard data')
dashboardData.todayCount = toNumber(detail.todayCount) }
dashboardData.yesterdaySize = toNumber(detail.yesterdaySize)
dashboardData.todaySize = toNumber(detail.todaySize)
dashboardData.sysUptime = detail.sysUptime
dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount')
dashboardData.activeCount = dashboardData.hasExtendedStats
? toNumber(detail.activeCount)
: dashboardData.totalFiles
dashboardData.expiredCount = toNumber(detail.expiredCount)
dashboardData.textCount = toNumber(detail.textCount)
dashboardData.fileCount = toNumber(detail.fileCount)
dashboardData.chunkedCount = toNumber(detail.chunkedCount)
dashboardData.usedCount = toNumber(detail.usedCount)
dashboardData.storageBackend = detail.storageBackend || '-'
dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit)
dashboardData.openUpload = toNumber(detail.openUpload)
dashboardData.enableChunk = toNumber(detail.enableChunk)
dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds)
dashboardData.topSuffixes = detail.topSuffixes || []
dashboardData.recentFiles = detail.recentFiles || []
dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed) const detail = response.detail
dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize) dashboardData.totalFiles = toNumber(detail.totalFiles)
dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize) dashboardData.storageUsed = toNumber(detail.storageUsed)
dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit) dashboardData.yesterdayCount = toNumber(detail.yesterdayCount)
dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime) dashboardData.todayCount = toNumber(detail.todayCount)
dashboardData.activeRatio = dashboardData.totalFiles dashboardData.yesterdaySize = toNumber(detail.yesterdaySize)
? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100) dashboardData.todaySize = toNumber(detail.todaySize)
: 0 dashboardData.sysUptime = detail.sysUptime
dashboardData.textRatio = dashboardData.totalFiles dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount')
? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100) dashboardData.activeCount = dashboardData.hasExtendedStats
: 0 ? toNumber(detail.activeCount)
dashboardData.fileRatio = dashboardData.totalFiles : dashboardData.totalFiles
? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100) dashboardData.expiredCount = toNumber(detail.expiredCount)
: 0 dashboardData.textCount = toNumber(detail.textCount)
dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit dashboardData.fileCount = toNumber(detail.fileCount)
? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100) dashboardData.chunkedCount = toNumber(detail.chunkedCount)
: 0 dashboardData.usedCount = toNumber(detail.usedCount)
dashboardData.storageBackend = detail.storageBackend || '-'
dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit)
dashboardData.openUpload = toNumber(detail.openUpload)
dashboardData.enableChunk = toNumber(detail.enableChunk)
dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds)
const healthSummary = normalizeHealthSummary(detail)
healthSummaryKeys.forEach((key) => {
dashboardData[key] = healthSummary[key]
})
dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed)
dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize)
dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize)
dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit)
dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime)
dashboardData.activeRatio = dashboardData.totalFiles
? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100)
: 0
dashboardData.textRatio = dashboardData.totalFiles
? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100)
: 0
dashboardData.fileRatio = dashboardData.totalFiles
? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100)
: 0
dashboardData.healthyRatio = dashboardData.totalFiles
? clampRatio((dashboardData.healthyCount / dashboardData.totalFiles) * 100)
: 0
dashboardData.healthAttentionRatio = dashboardData.totalFiles
? clampRatio((dashboardData.healthAttentionCount / dashboardData.totalFiles) * 100)
: 0
dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit
? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100)
: 0
lastUpdatedAt.value = new Date()
} catch (error) {
errorMessage.value = getErrorMessage(
error,
options.loadFailedMessage || 'Failed to load dashboard data'
)
} finally {
isLoading.value = false
}
} }
return { return {
dashboardData, dashboardData,
fetchDashboardData errorMessage,
fetchDashboardData,
isLoading,
lastUpdatedText
} }
} }
+4 -1
View File
@@ -213,10 +213,13 @@ export function usePresignedUpload(options: UsePresignedUploadOptions = {}) {
presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING
const progressHandler = createProgressHandler(options?.onProgress) const progressHandler = createProgressHandler(options?.onProgress)
const uploadUrl =
session.proxy_upload_url || session.upload_url || session.legacy_proxy_upload_url
const response = await PresignUploadService.proxyUpload( const response = await PresignUploadService.proxyUpload(
session.upload_id, session.upload_id,
file, file,
progressHandler progressHandler,
uploadUrl
) )
if (response.code === 200 && response.detail?.code) { if (response.code === 200 && response.detail?.code) {
+2 -1
View File
@@ -13,7 +13,8 @@ export function usePublicConfigBootstrap() {
return return
} }
const notifyMessage = configStore.applyRemoteConfig(res.detail) configStore.applyPublicMeta(res.detail.meta)
const notifyMessage = configStore.applyRemoteConfig(res.detail.config)
if (notifyMessage) { if (notifyMessage) {
alertStore.showAlert(notifyMessage, 'success') alertStore.showAlert(notifyMessage, 'success')
} }
+92 -18
View File
@@ -1,10 +1,10 @@
import { ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { FileService } from '@/services' import { FileService } from '@/services'
import { useAlertStore } from '@/stores/alertStore' import { useAlertStore } from '@/stores/alertStore'
import { useFileDataStore } from '@/stores/fileData' import { useFileDataStore } from '@/stores/fileData'
import type { ReceivedFileRecord } from '@/types' import type { ReceivedFileRecord, ShareMetadataResponse, ShareSelectResponse } from '@/types'
import { copyToClipboard } from '@/utils/clipboard' import { copyToClipboard } from '@/utils/clipboard'
import { getErrorMessage } from '@/utils/common' import { getErrorMessage } from '@/utils/common'
import { renderMarkdownPreview } from '@/utils/content-preview' import { renderMarkdownPreview } from '@/utils/content-preview'
@@ -22,6 +22,9 @@ export function useRetrieveFlow() {
const { receiveData: records } = storeToRefs(fileStore) const { receiveData: records } = storeToRefs(fileStore)
const code = ref('') const code = ref('')
const inspectedFile = ref<ShareMetadataResponse | null>(null)
const isInspecting = ref(false)
const isRetrieving = ref(false)
const inputStatus = ref<InputStatus>({ const inputStatus = ref<InputStatus>({
readonly: false, readonly: false,
loading: false loading: false
@@ -31,6 +34,9 @@ export function useRetrieveFlow() {
const showDrawer = ref(false) const showDrawer = ref(false)
const showPreview = ref(false) const showPreview = ref(false)
const renderedContent = ref('') const renderedContent = ref('')
const normalizedCode = computed(() => code.value.trim())
const isWorking = computed(() => isInspecting.value || isRetrieving.value)
const hasValidCode = computed(() => normalizedCode.value.length === 5)
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 ' + t('fileSize.bytes') if (bytes === 0) return '0 ' + t('fileSize.bytes')
@@ -46,52 +52,103 @@ export function useRetrieveFlow() {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
} }
const createRecord = (detail: { const createRecord = (detail: ShareSelectResponse): ReceivedFileRecord => {
code: string const isText =
name: string typeof detail.is_text === 'boolean'
text: string ? detail.is_text
size: number : detail.type
}): ReceivedFileRecord => { ? detail.type === 'text'
const isFile = detail.text.startsWith('/share/download') || detail.name !== 'Text' : detail.name === 'Text' && !detail.text.startsWith('/share/download')
const content = isText ? (detail.content ?? detail.text) : null
const downloadUrl = isText ? null : (detail.download_url ?? detail.text)
return { return {
id: Date.now(), id: Date.now(),
code: detail.code, code: detail.code,
filename: detail.name, filename: detail.name,
size: formatFileSize(detail.size), size: formatFileSize(detail.size),
downloadUrl: isFile ? detail.text : null, downloadUrl,
content: isFile ? null : detail.text, content,
date: new Date().toLocaleString() date: new Date().toLocaleString(),
type: isText ? 'text' : 'file',
remainingDownloads: detail.remaining_downloads
} }
} }
const handleSubmit = async () => { const resetInspection = () => {
if (code.value.length !== 5) { inspectedFile.value = null
error.value = ''
}
const inspectCode = async () => {
if (!hasValidCode.value) {
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error') alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
return return
} }
isInspecting.value = true
inputStatus.value.readonly = true inputStatus.value.readonly = true
inputStatus.value.loading = true inputStatus.value.loading = true
error.value = ''
try { try {
const res = await FileService.selectFile(code.value) const res = await FileService.inspectFile(normalizedCode.value)
if (res.code === 200 && res.detail) {
inspectedFile.value = res.detail
} else {
inspectedFile.value = null
error.value = String(res.detail || res.message || '')
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + error.value, 'error')
}
} catch {
inspectedFile.value = null
error.value = t('retrieve.messages.previewUnavailable')
alertStore.showAlert(error.value, 'warning')
} finally {
isInspecting.value = false
inputStatus.value.readonly = false
inputStatus.value.loading = false
}
}
const handleSubmit = async () => {
if (!hasValidCode.value) {
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
return
}
isRetrieving.value = true
inputStatus.value.readonly = true
inputStatus.value.loading = true
error.value = ''
try {
const res = await FileService.selectFile(normalizedCode.value)
if (res.code === 200 && res.detail) { if (res.code === 200 && res.detail) {
const newFileData = createRecord(res.detail) const newFileData = createRecord(res.detail)
if (!fileStore.receiveData.some((file) => file.code === newFileData.code)) { const existingIndex = fileStore.receiveData.findIndex(
fileStore.addReceiveData(newFileData) (file) => file.code === newFileData.code
)
if (existingIndex !== -1) {
fileStore.deleteReceiveData(existingIndex)
} }
fileStore.addReceiveData(newFileData)
selectedRecord.value = newFileData selectedRecord.value = newFileData
if (newFileData.content) { if (newFileData.content) {
showPreview.value = true showPreview.value = true
} }
inspectedFile.value = null
alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success') alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
} else { } else {
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + res.detail, 'error') error.value = String(res.detail || '')
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + error.value, 'error')
} }
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = getErrorMessage(err, t('retrieve.messages.unknownError')) const errorMessage = getErrorMessage(err, t('retrieve.messages.unknownError'))
error.value = errorMessage
alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error') alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
} finally { } finally {
isRetrieving.value = false
inputStatus.value.readonly = false inputStatus.value.readonly = false
inputStatus.value.loading = false inputStatus.value.loading = false
code.value = '' code.value = ''
@@ -151,8 +208,23 @@ export function useRetrieveFlow() {
{ immediate: true } { immediate: true }
) )
watch(normalizedCode, (nextCode) => {
if (inspectedFile.value && inspectedFile.value.code !== nextCode) {
resetInspection()
}
if (nextCode.length < 5 && error.value) {
error.value = ''
}
})
return { return {
code, code,
inspectedFile,
isInspecting,
isRetrieving,
isWorking,
hasValidCode,
inputStatus, inputStatus,
error, error,
records, records,
@@ -166,6 +238,8 @@ export function useRetrieveFlow() {
deleteRecord, deleteRecord,
downloadRecord, downloadRecord,
handleSubmit, handleSubmit,
inspectCode,
resetInspection,
showContentPreview, showContentPreview,
toggleDrawer, toggleDrawer,
viewDetails viewDetails
+4 -2
View File
@@ -32,8 +32,10 @@ export function useSendFlow() {
const isSubmitting = ref(false) const isSubmitting = ref(false)
const fileHash = ref('') const fileHash = ref('')
const sendRecords = computed(() => fileDataStore.shareData) const sendRecords = computed(() => fileDataStore.shareData)
const uploadDescription = computed( const uploadDescription = computed(() =>
() => `支持各种常见格式,最大${getStorageUnit(config.value.uploadSize)}` t('send.uploadArea.descriptionWithLimit', {
size: getStorageUnit(config.value.uploadSize)
})
) )
const expirationOptions = computed(() => const expirationOptions = computed(() =>
config.value.expireStyle.map((value) => ({ config.value.expireStyle.map((value) => ({
+63 -17
View File
@@ -21,15 +21,54 @@ export function useSystemConfig() {
// 状态管理 // 状态管理
const config = ref<ConfigState>({ ...DEFAULT_CONFIG_STATE }) const config = ref<ConfigState>({ ...DEFAULT_CONFIG_STATE })
const isLoading = ref(false) const isRefreshing = ref(false)
const isSaving = ref(false)
const savedPayloadSnapshot = ref('')
const fileSize = ref(1) const fileSize = ref(1)
const sizeUnit = ref<FileSizeUnit>('MB') const sizeUnit = ref<FileSizeUnit>('MB')
const saveTime = ref(1) const saveTime = ref(1)
const saveTimeUnit = ref<SaveTimeUnit>('天') const saveTimeUnit = ref<SaveTimeUnit>('天')
const isLoading = computed(() => isRefreshing.value || isSaving.value)
const buildSubmitPayload = (): Partial<ConfigState> => {
const payload: Partial<ConfigState> = buildConfigSubmitPayload(
config.value,
{ value: fileSize.value, unit: sizeUnit.value },
{ value: saveTime.value, unit: saveTimeUnit.value }
)
if (!payload.admin_token) {
delete payload.admin_token
}
return payload
}
const snapshotPayload = (payload: Partial<ConfigState>) => JSON.stringify(payload)
const normalizeEditableConfig = (nextConfig: Partial<ConfigState>): ConfigState => ({
...DEFAULT_CONFIG_STATE,
...nextConfig,
admin_token: ''
})
const markConfigSaved = () => {
savedPayloadSnapshot.value = snapshotPayload(buildSubmitPayload())
}
const isDirty = computed(() => {
if (!savedPayloadSnapshot.value) {
return false
}
return snapshotPayload(buildSubmitPayload()) !== savedPayloadSnapshot.value
})
// 从本地存储获取配置 // 从本地存储获取配置
const getStoredConfig = (): ConfigState | null => { const getStoredConfig = (): ConfigState | null => {
return readStoredConfig<ConfigState>() const storedConfig = readStoredConfig<Partial<ConfigState>>()
return storedConfig ? normalizeEditableConfig(storedConfig) : null
} }
// 保存配置到本地存储 // 保存配置到本地存储
@@ -40,12 +79,12 @@ export function useSystemConfig() {
// 获取系统配置 // 获取系统配置
const fetchConfig = async (): Promise<ConfigState | null> => { const fetchConfig = async (): Promise<ConfigState | null> => {
try { try {
isLoading.value = true isRefreshing.value = true
const response = await ConfigService.getConfig() const response = await ConfigService.getConfig()
if (response.code === 200 && response.detail) { if (response.code === 200 && response.detail) {
config.value = { ...DEFAULT_CONFIG_STATE, ...response.detail } config.value = normalizeEditableConfig(response.detail)
const notifyMessage = configStore.applyRemoteConfig(config.value) const notifyMessage = configStore.applyRemoteConfig(config.value)
if (notifyMessage) { if (notifyMessage) {
alertStore.showAlert(notifyMessage, 'success') alertStore.showAlert(notifyMessage, 'success')
@@ -66,19 +105,21 @@ export function useSystemConfig() {
alertStore.showAlert(getErrorMessage(error, '获取配置失败'), 'error') alertStore.showAlert(getErrorMessage(error, '获取配置失败'), 'error')
return null return null
} finally { } finally {
isLoading.value = false isRefreshing.value = false
} }
} }
// 更新系统配置 // 更新系统配置
const updateConfig = async (newConfig: Partial<ConfigState>): Promise<boolean> => { const updateConfig = async (newConfig: Partial<ConfigState>): Promise<boolean> => {
try { try {
isLoading.value = true isSaving.value = true
const response = await ConfigService.updateConfig(newConfig) const response = await ConfigService.updateConfig(newConfig)
if (response.code === 200) { if (response.code === 200) {
config.value = { ...config.value, ...newConfig } config.value = normalizeEditableConfig({ ...config.value, ...newConfig })
syncConfigForm(config.value)
markConfigSaved()
saveConfigToStorage(config.value) saveConfigToStorage(config.value)
alertStore.showAlert('配置更新成功!', 'success') alertStore.showAlert('配置更新成功!', 'success')
return true return true
@@ -89,7 +130,7 @@ export function useSystemConfig() {
alertStore.showAlert(getErrorMessage(error, '更新配置失败'), 'error') alertStore.showAlert(getErrorMessage(error, '更新配置失败'), 'error')
return false return false
} finally { } finally {
isLoading.value = false isSaving.value = false
} }
} }
@@ -111,17 +152,17 @@ export function useSystemConfig() {
const latestConfig = await fetchConfig() const latestConfig = await fetchConfig()
if (latestConfig) { if (latestConfig) {
syncConfigForm(latestConfig) syncConfigForm(latestConfig)
markConfigSaved()
} }
} }
const submitConfig = () => const submitConfig = () => {
updateConfig( if (!isDirty.value || isSaving.value) {
buildConfigSubmitPayload( return Promise.resolve(false)
config.value, }
{ value: fileSize.value, unit: sizeUnit.value },
{ value: saveTime.value, unit: saveTimeUnit.value } return updateConfig(buildSubmitPayload())
) }
)
// 初始化配置 // 初始化配置
const initConfig = async () => { const initConfig = async () => {
@@ -129,10 +170,12 @@ export function useSystemConfig() {
const storedConfig = getStoredConfig() const storedConfig = getStoredConfig()
if (storedConfig) { if (storedConfig) {
config.value = storedConfig config.value = storedConfig
syncConfigForm(storedConfig)
markConfigSaved()
} }
// 然后从服务器获取最新配置 // 然后从服务器获取最新配置
await fetchConfig() await refreshConfig()
} }
// 计算属性 // 计算属性
@@ -148,6 +191,9 @@ export function useSystemConfig() {
// 状态 // 状态
config, config,
isLoading, isLoading,
isRefreshing,
isSaving,
isDirty,
fileSize, fileSize,
sizeUnit, sizeUnit,
saveTime, saveTime,
+326 -28
View File
@@ -65,8 +65,14 @@ export default {
yesterdayShares: 'Yesterday: {count}', yesterdayShares: 'Yesterday: {count}',
serverUptime: 'Uptime', serverUptime: 'Uptime',
refresh: 'Refresh', refresh: 'Refresh',
refreshing: 'Refreshing',
lastUpdated: 'Last updated: {time}',
loadFailed: 'Failed to load dashboard data',
footerProduct: 'FileCodeBox Admin Console',
runtimeVersion: 'Runtime Version',
versionPending: 'Pending sync',
fileHealth: 'File Health', fileHealth: 'File Health',
fileHealthDesc: 'Real file records grouped by availability, expiry, and type.', fileHealthDesc: 'Status insights for available, risky, and pending file queues.',
activeFileRatio: 'Active Ratio', activeFileRatio: 'Active Ratio',
fileShareRatio: 'File Ratio', fileShareRatio: 'File Ratio',
textShareRatio: 'Text Ratio', textShareRatio: 'Text Ratio',
@@ -83,16 +89,27 @@ export default {
maxSaveTime: 'Max Retention', maxSaveTime: 'Max Retention',
noSaveLimit: 'Unlimited', noSaveLimit: 'Unlimited',
todayCapacityReference: 'Today Size / Single File Limit', todayCapacityReference: 'Today Size / Single File Limit',
fileTypeDistribution: 'Type Distribution', healthActions: {
textType: 'Text', attention: {
recentFiles: 'Recent Shares', title: 'Needs Attention',
recentFilesDesc: 'Recently created share records for quick status checks.', description: 'Handle issue and warning files together'
available: 'Available', },
table: { storageIssue: {
file: 'File', title: 'Storage Issues',
size: 'Size', description: 'Review files missing download metadata'
usage: 'Retrievals', },
status: 'Status' expiringSoon: {
title: 'Expiring Soon',
description: 'Extend shares that should stay available'
},
neverRetrieved: {
title: 'Never Retrieved',
description: 'Find shares that have not been retrieved yet'
},
permanent: {
title: 'Permanent',
description: 'View long-retention share records'
}
} }
}, },
fileManage: { fileManage: {
@@ -202,7 +219,39 @@ export default {
invalidCodeError: 'Invalid retrieval code', invalidCodeError: 'Invalid retrieval code',
retrieveFailure: 'Failed to retrieve file: ', retrieveFailure: 'Failed to retrieve file: ',
networkError: 'Retrieval failed, please try again later: ', networkError: 'Retrieval failed, please try again later: ',
unknownError: 'Unknown error' unknownError: 'Unknown error',
previewUnavailable: 'Preview is unavailable, you can still retrieve directly'
},
workspace: {
sendFile: 'Send File',
inspect: 'Preview',
inspecting: 'Previewing',
retrieving: 'Retrieving',
ready: 'Ready',
noPreview: 'Waiting for a code',
noPreviewDesc:
'Enter a 5-character retrieval code to preview file details before consuming a retrieval.',
latestRecord: 'Latest Retrieval',
noRecord: 'No retrieval records yet',
currentCode: 'Code',
fileSize: 'File Size',
viewDetail: 'View Detail',
historyCount: '{count} records',
openRecords: 'Open retrieval records',
security: 'Safe Retrieval',
securityState:
'Previewing does not consume retrieval count. Access is recorded only after confirmation.',
noExpiry: 'No limit',
unlimited: 'Unlimited',
remainingCount: '{count} left',
textType: 'Text Content',
fileType: 'File Download',
expiresAt: 'Expires At',
remainingDownloads: 'Remaining',
usedCount: 'Retrieved',
emptyCode: 'Empty',
previewState: 'Preview State',
waiting: 'Waiting'
} }
}, },
@@ -215,13 +264,45 @@ export default {
clickText: 'click to select files', clickText: 'click to select files',
textInput: 'Enter text to send here...', textInput: 'Enter text to send here...',
placeholder: 'Click or drag files here to upload', placeholder: 'Click or drag files here to upload',
description: 'Supports various common formats' description: 'Supports various common formats',
descriptionWithLimit: 'Supports common formats, up to {size}',
retry: 'Retry',
selectedFiles: '{count} files selected',
status: {
initializing: 'Preparing upload...',
uploading: 'Uploading files...',
confirming: 'Confirming upload...',
success: 'Upload complete!'
}
}, },
submit: 'Secure Send', submit: 'Secure Send',
submitting: 'Sending...', submitting: 'Sending...',
needRetrieveFile: 'Need to retrieve? Click here', needRetrieveFile: 'Need to retrieve? Click here',
sendRecords: 'Send Records', sendRecords: 'Send Records',
secureEncryption: 'Secure Encryption', secureEncryption: 'Secure Encryption',
workspace: {
uploadLimit: 'Single File Limit',
uploadMode: 'Upload Mode',
standardMode: 'Standard',
chunkMode: 'Chunked',
guestPolicy: 'Guest Upload',
guestOpen: 'Allowed',
guestClosed: 'Disabled',
currentTask: 'Current Task',
awaitingFile: 'Waiting for files',
fileReady: '{count} files ready',
textDraft: 'Text draft, {count} chars',
payload: 'Payload',
expirationPreview: 'Expiration',
security: 'Security',
latestRecord: 'Latest Send',
noRecord: 'No records yet',
copyLink: 'Copy Link',
viewDetail: 'View Detail',
historyTitle: 'Send History',
historyCount: '{count} records',
openRecords: 'Open send records'
},
fileDetails: 'File Details', fileDetails: 'File Details',
expirationMethod: 'Expiration Method', expirationMethod: 'Expiration Method',
expiration: { expiration: {
@@ -311,24 +392,246 @@ export default {
// File Management // File Management
fileManage: { fileManage: {
title: 'File Management', title: 'File Management',
searchPlaceholder: 'Search file name, description...', subtitle: '{count} share records. Filter by status, type, and usage.',
searchPlaceholder: 'Search code, name, or text...',
allFiles: 'All Files', allFiles: 'All Files',
detail: 'Details',
detailTitle: 'File Details',
detailLoading: 'Loading details...',
detailFailed: 'Failed to load details',
editFileInfo: 'Edit File Information', editFileInfo: 'Edit File Information',
saveChanges: 'Save Changes', saveChanges: 'Save Changes',
refresh: 'Refresh List',
resetFilters: 'Reset Filters',
totalFiles: 'All Records',
activeFiles: 'Available',
expiredFiles: 'Expired',
storageUsed: 'Storage Used',
statusLabel: 'Status',
typeLabel: 'Type',
healthLabel: 'Health',
viewPreset: 'View',
viewPresetCustom: 'Custom Filters',
viewPresetAll: 'All Files',
viewPresetAttention: 'Needs Attention',
viewPresetExpiringSoon: 'Expiring Soon',
viewPresetStorageIssue: 'Storage Issues',
viewPresetNeverRetrieved: 'Never Retrieved',
viewPresetPermanent: 'Permanent',
saveViewPreset: 'Save View',
updateViewPreset: 'Update View',
deleteViewPreset: 'Delete View',
viewPresetNamePrompt: 'Enter a view name',
viewPresetSaveSuccess: 'View saved',
viewPresetSaveFailed: 'Failed to save view',
viewPresetDeleteConfirm: 'Delete view "{name}"?',
viewPresetDeleteSuccess: 'View deleted',
viewPresetDeleteFailed: 'Failed to delete view',
viewPresetLoadFailed: 'Failed to load saved views; using built-in views',
all: 'All',
active: 'Available',
expired: 'Expired',
fileType: 'File',
textType: 'Text',
chunkedType: 'Chunked',
healthFilters: {
all: 'All',
attention: 'Needs Attention',
danger: 'Issues',
warning: 'Warnings',
expiringSoon: 'Expiring Soon',
storageIssue: 'Storage Issues',
neverRetrieved: 'Never Retrieved',
healthy: 'Healthy',
permanent: 'Permanent'
},
sortBy: 'Sort',
sortOrder: 'Direction',
unlimited: 'Unlimited',
remaining: '{count} left',
loadError: 'Failed to load file list',
noMatches: 'No matching files',
viewText: 'View', viewText: 'View',
textPreview: 'Text Preview', textPreview: 'Text Preview',
copyText: 'Copy Text', copyText: 'Copy Text',
copyCode: 'Copy Code',
copyLink: 'Copy Link',
copySuccess: 'Text copied to clipboard', copySuccess: 'Text copied to clipboard',
copyCodeSuccess: 'Retrieve code copied to clipboard',
copyLinkSuccess: 'Retrieve link copied to clipboard',
copyFailed: 'Copy failed, please try again', copyFailed: 'Copy failed, please try again',
charCount: '{count} characters', charCount: '{count} characters',
downloadFile: 'Download File',
exportText: 'Export Text',
downloadSuccess: 'Downloaded: {name}',
exportSuccess: 'Exported: {name}',
downloadFailed: 'Download failed',
previewFailed: 'Failed to load preview',
loadingPreview: 'Loading preview...',
previewComplete: 'Loaded full content, {count} characters',
previewTruncated: 'Showing {shown} / {total} characters',
previewFallback: 'Preview endpoint unavailable; using text from the list',
selectCurrentPage: 'Select current page',
selectedCount: '{count} selected',
selectFile: 'Select {name}',
clearSelection: 'Clear Selection',
batchDelete: 'Batch Delete',
batchDeleteConfirm: 'Delete the selected {count} files? This action cannot be undone.',
batchDeleteSuccess: 'Deleted {count} files',
batchDeletePartialSuccess: 'Deleted {count} files, {failed} failed',
batchDeleteFailed: 'Batch delete failed',
batchEdit: 'Batch Edit',
batchEditTitle: 'Batch Edit File Policy',
batchEditSelected: 'Update the selected {count} files',
batchEditMode: 'Update Mode',
batchEditExpiresAt: 'Set Expiration Time',
batchEditDownloadLimit: 'Set Retrieval Limit',
batchEditForever: 'Make Permanent',
batchEditForeverHint: 'Clear the expiration time and make retrievals unlimited.',
batchUpdateConfirm: 'Update the selected {count} files?',
batchUpdateSuccess: 'Updated {count} files',
batchUpdatePartialSuccess: 'Updated {count} files, {failed} failed',
batchUpdateFailed: 'Batch update failed',
batchUpdateNoFields: 'Choose a policy to update',
batchPolicyActionConfirm: 'Apply "{action}" to the selected {count} files?',
batchPolicyActionSuccess: 'Processed {count} files',
batchPolicyActionPartialSuccess: 'Processed {count} files, {failed} failed',
batchPolicyActionFailed: 'Batch policy action failed',
policyActionSuccess: 'File policy updated',
policyActionFailed: 'Failed to apply policy action',
metadataInfo: 'Operations Notes',
metadataHint:
'Visible to admins only. Use it for handling notes, archive tags, and follow-up actions.',
metadataNote: 'Note',
metadataNotePlaceholder: 'Record context, handling history, or ownership...',
metadataTags: 'Tags',
metadataTagsPlaceholder: 'Comma-separated, e.g. Client A, Renew, Important',
metadataUpdatedAt: 'Last saved: {time}',
metadataNeverUpdated: 'No notes saved yet',
saveMetadata: 'Save Notes',
metadataSaveSuccess: 'Notes and tags saved',
metadataSaveFailed: 'Failed to save notes',
policyActions: {
extend24h: 'Extend 24h',
extend7d: 'Extend 7d',
makePermanent: 'Make Permanent',
resetDownloadLimit: 'Reset to {count}'
},
policyActionDescriptions: {
extend24h: 'Add one day from the active expiration',
extend7d: 'Good for a one-week temporary extension',
makePermanent: 'Clear expiration time and retrieval limits',
resetDownloadLimit: 'Restore retrievals to {count}'
},
applyBatchEdit: 'Apply Changes',
overview: 'Overview',
policyInfo: 'Policy',
storageInfo: 'Storage',
statusInsight: 'Status Insight',
nextAction: 'Next Action',
lifecycle: 'Lifecycle',
permanent: 'Permanent',
createdAt: 'Created Time',
remainingDownloads: 'Remaining Downloads',
textLength: 'Text Length',
hasDownloadLimit: 'Has Retrieval Limit',
hasExpirationTime: 'Has Expiration',
canPreviewText: 'Can Preview Text',
canDownload: 'Can Download',
storageBackend: 'Storage Backend',
fileHash: 'File Hash',
isChunked: 'Chunked Upload',
filePath: 'Storage Path',
uuidFileName: 'Stored File Name',
uploadId: 'Upload Session',
yes: 'Yes',
no: 'No',
insightSeverity: {
success: 'Healthy',
warning: 'Needs Attention',
danger: 'Issue',
info: 'Info',
neutral: 'Watch'
},
insightStates: {
available: 'Available for retrieval',
expired: 'Unavailable for retrieval',
storage_incomplete: 'Storage metadata incomplete',
expiring_soon: 'Expiring soon',
permanent: 'Permanent'
},
insightActions: {
monitor: 'Keep monitoring',
extend_or_delete: 'Extend retention or clean up',
inspect_storage: 'Inspect storage path and file name',
extend_expiration: 'Extend expiration if needed'
},
insightReasons: {
expired: 'Past expiration time',
download_limit_exhausted: 'Retrieval limit exhausted',
expires_soon: 'Expires within 24 hours',
never_retrieved: 'Never retrieved',
storage_metadata_incomplete: 'Missing downloadable storage metadata',
chunked_upload: 'Created by chunked upload'
},
timeline: {
status: {
done: 'Done',
pending: 'Pending',
active: 'Active',
expired: 'Expired',
exhausted: 'Exhausted',
unlimited: 'Unlimited'
},
created: {
title: 'Record Created',
description: 'The share record has been created and is currently {status}.'
},
content_ready: {
title: 'Content Ready',
description: 'Content type is {detail}; current status is {status}.'
},
upload_session: {
title: 'Upload Session',
description: 'Upload session recorded: {detail}.'
},
expiration_policy: {
title: 'Expiration Policy',
description: 'Expiration policy is currently {status}.',
remaining: '{time} left',
overdue: '{time} overdue'
},
download_limit: {
title: 'Retrieval Limit',
description: 'Retrieval limit is currently {status}; {value}.'
},
retrieved: {
title: 'Retrieval History',
description: 'Retrieved {value}; current status is {status}.'
}
},
headers: { headers: {
select: 'Select',
code: 'Retrieve Code', code: 'Retrieve Code',
name: 'Name', name: 'Name',
type: 'Type',
size: 'Size', size: 'Size',
usage: 'Retrievals',
status: 'Status',
description: 'Description', description: 'Description',
expiration: 'Expiration', expiration: 'Expiration',
actions: 'Actions' actions: 'Actions'
}, },
sort: {
createdAt: 'Created Time',
expiredAt: 'Expiration',
name: 'Name',
size: 'Size',
usedCount: 'Retrievals',
code: 'Retrieve Code',
desc: 'Descending',
asc: 'Ascending'
},
form: { form: {
code: 'Retrieve Code', code: 'Retrieve Code',
codePlaceholder: 'Enter retrieve code', codePlaceholder: 'Enter retrieve code',
@@ -414,7 +717,13 @@ export default {
mb: 'MB', mb: 'MB',
gb: 'GB' gb: 'GB'
}, },
saveChanges: 'Save Settings' saveChanges: 'Save Settings',
refreshConfig: 'Refresh Config',
refreshing: 'Refreshing',
saving: 'Saving',
unsavedChanges: 'Unsaved configuration changes',
allChangesSaved: 'All configuration changes are saved',
refreshBlocked: 'Save current changes before refreshing'
}, },
systemSettings: { systemSettings: {
title: 'System Settings', title: 'System Settings',
@@ -494,7 +803,7 @@ export default {
serverUptime: 'Uptime', serverUptime: 'Uptime',
refresh: 'Refresh', refresh: 'Refresh',
fileHealth: 'File Health', fileHealth: 'File Health',
fileHealthDesc: 'Real file records grouped by availability, expiry, and type.', fileHealthDesc: 'Status insights for available, risky, and pending file queues.',
activeFileRatio: 'Active Ratio', activeFileRatio: 'Active Ratio',
fileShareRatio: 'File Ratio', fileShareRatio: 'File Ratio',
textShareRatio: 'Text Ratio', textShareRatio: 'Text Ratio',
@@ -510,18 +819,7 @@ export default {
guestUpload: 'Guest Upload', guestUpload: 'Guest Upload',
maxSaveTime: 'Max Retention', maxSaveTime: 'Max Retention',
noSaveLimit: 'Unlimited', noSaveLimit: 'Unlimited',
todayCapacityReference: 'Today Size / Single File Limit', todayCapacityReference: 'Today Size / Single File Limit'
fileTypeDistribution: 'Type Distribution',
textType: 'Text',
recentFiles: 'Recent Shares',
recentFilesDesc: 'Recently created share records for quick status checks.',
available: 'Available',
table: {
file: 'File',
size: 'Size',
usage: 'Retrievals',
status: 'Status'
}
}, },
fileManage: { fileManage: {
title: 'File Management', title: 'File Management',
+323 -28
View File
@@ -65,8 +65,14 @@ export default {
yesterdayShares: '昨日分享:{count}', yesterdayShares: '昨日分享:{count}',
serverUptime: '运行时间', serverUptime: '运行时间',
refresh: '刷新数据', refresh: '刷新数据',
refreshing: '刷新中',
lastUpdated: '最近更新:{time}',
loadFailed: '仪表盘数据加载失败',
footerProduct: 'FileCodeBox 管理后台',
runtimeVersion: '运行版本',
versionPending: '待同步',
fileHealth: '文件健康', fileHealth: '文件健康',
fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。', fileHealthDesc: '基于状态洞察统计可取件、风险与待处理队列。',
activeFileRatio: '有效占比', activeFileRatio: '有效占比',
fileShareRatio: '文件占比', fileShareRatio: '文件占比',
textShareRatio: '文本占比', textShareRatio: '文本占比',
@@ -83,16 +89,27 @@ export default {
maxSaveTime: '最长保存', maxSaveTime: '最长保存',
noSaveLimit: '不限制', noSaveLimit: '不限制',
todayCapacityReference: '今日容量 / 单文件上限', todayCapacityReference: '今日容量 / 单文件上限',
fileTypeDistribution: '类型分布', healthActions: {
textType: '文本', attention: {
recentFiles: '最近分享', title: '需关注',
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。', description: '集中处理异常和预警文件'
available: '可取件', },
table: { storageIssue: {
file: '文件', title: '存储异常',
size: '大小', description: '检查缺少下载信息的文件'
usage: '取件', },
status: '状态' expiringSoon: {
title: '即将过期',
description: '优先续期仍需保留的分享'
},
neverRetrieved: {
title: '未取件',
description: '识别创建后尚未被取件的记录'
},
permanent: {
title: '永久有效',
description: '查看长期保留的分享记录'
}
} }
}, },
fileManage: { fileManage: {
@@ -202,7 +219,37 @@ export default {
invalidCodeError: '无效的取件码', invalidCodeError: '无效的取件码',
retrieveFailure: '获取文件失败:', retrieveFailure: '获取文件失败:',
networkError: '取件失败,请稍后重试:', networkError: '取件失败,请稍后重试:',
unknownError: '未知错误' unknownError: '未知错误',
previewUnavailable: '暂时无法预览文件信息,可直接取件'
},
workspace: {
sendFile: '发送文件',
inspect: '预览',
inspecting: '预览中',
retrieving: '取件中',
ready: '可取件',
noPreview: '等待取件码',
noPreviewDesc: '输入 5 位取件码后会先读取文件信息,确认后再消耗取件次数。',
latestRecord: '最近取件',
noRecord: '暂无取件记录',
currentCode: '取件码',
fileSize: '文件大小',
viewDetail: '查看详情',
historyCount: '{count} 条记录',
openRecords: '打开取件记录',
security: '安全取件',
securityState: '预览不会消耗取件次数,确认取件后才会记录访问。',
noExpiry: '不限制',
unlimited: '不限次数',
remainingCount: '剩余 {count} 次',
textType: '文本内容',
fileType: '文件下载',
expiresAt: '过期时间',
remainingDownloads: '剩余次数',
usedCount: '已取件',
emptyCode: '未输入',
previewState: '预览状态',
waiting: '等待输入'
} }
}, },
@@ -217,13 +264,45 @@ export default {
clickText: '点击选择文件', clickText: '点击选择文件',
textInput: '在此输入要发送的文本...', textInput: '在此输入要发送的文本...',
placeholder: '点击或拖放文件到此处上传', placeholder: '点击或拖放文件到此处上传',
description: '支持各种常见格式' description: '支持各种常见格式',
descriptionWithLimit: '支持各种常见格式,最大{size}',
retry: '重试',
selectedFiles: '已选择 {count} 个文件',
status: {
initializing: '正在初始化上传...',
uploading: '正在上传文件...',
confirming: '正在确认上传...',
success: '上传成功!'
}
}, },
submit: '安全寄送', submit: '安全寄送',
submitting: '发送中...', submitting: '发送中...',
needRetrieveFile: '需要取件?点击这里', needRetrieveFile: '需要取件?点击这里',
sendRecords: '发件记录', sendRecords: '发件记录',
secureEncryption: '安全加密', secureEncryption: '安全加密',
workspace: {
uploadLimit: '单文件上限',
uploadMode: '上传模式',
standardMode: '标准上传',
chunkMode: '分片上传',
guestPolicy: '游客上传',
guestOpen: '允许',
guestClosed: '已关闭',
currentTask: '当前任务',
awaitingFile: '等待选择文件',
fileReady: '已选择 {count} 个文件',
textDraft: '文本草稿 {count} 字',
payload: '发送内容',
expirationPreview: '有效期',
security: '安全状态',
latestRecord: '最近一次发送',
noRecord: '暂无记录',
copyLink: '复制链接',
viewDetail: '查看详情',
historyTitle: '发送历史',
historyCount: '{count} 条记录',
openRecords: '打开发送记录'
},
fileDetail: { fileDetail: {
title: '文件详情', title: '文件详情',
content: '文件内容', content: '文件内容',
@@ -311,24 +390,245 @@ export default {
// 文件管理 // 文件管理
fileManage: { fileManage: {
title: '文件管理', title: '文件管理',
searchPlaceholder: '搜索文件名称、描述...', subtitle: '共 {count} 条分享记录,可按状态、类型和使用情况快速筛选。',
searchPlaceholder: '搜索取件码、名称或文本...',
allFiles: '所有文件', allFiles: '所有文件',
detail: '详情',
detailTitle: '文件详情',
detailLoading: '正在加载详情...',
detailFailed: '详情加载失败',
editFileInfo: '编辑文件信息', editFileInfo: '编辑文件信息',
saveChanges: '保存更改', saveChanges: '保存更改',
refresh: '刷新列表',
resetFilters: '重置筛选',
totalFiles: '全部记录',
activeFiles: '可取件',
expiredFiles: '已过期',
storageUsed: '占用空间',
statusLabel: '状态',
typeLabel: '类型',
healthLabel: '健康',
viewPreset: '视图',
viewPresetCustom: '自定义筛选',
viewPresetAll: '全部文件',
viewPresetAttention: '需关注',
viewPresetExpiringSoon: '即将过期',
viewPresetStorageIssue: '存储异常',
viewPresetNeverRetrieved: '未取件',
viewPresetPermanent: '永久有效',
saveViewPreset: '保存视图',
updateViewPreset: '覆盖视图',
deleteViewPreset: '删除视图',
viewPresetNamePrompt: '输入视图名称',
viewPresetSaveSuccess: '视图已保存',
viewPresetSaveFailed: '视图保存失败',
viewPresetDeleteConfirm: '确认删除视图“{name}”?',
viewPresetDeleteSuccess: '视图已删除',
viewPresetDeleteFailed: '视图删除失败',
viewPresetLoadFailed: '视图预设加载失败,已使用内置视图',
all: '全部',
active: '可取件',
expired: '已过期',
fileType: '文件',
textType: '文本',
chunkedType: '分片',
healthFilters: {
all: '全部',
attention: '需关注',
danger: '异常',
warning: '预警',
expiringSoon: '即将过期',
storageIssue: '存储异常',
neverRetrieved: '未取件',
healthy: '健康',
permanent: '永久有效'
},
sortBy: '排序',
sortOrder: '方向',
unlimited: '不限次数',
remaining: '剩余 {count} 次',
loadError: '文件列表加载失败',
noMatches: '没有匹配的文件',
viewText: '查看', viewText: '查看',
textPreview: '文本预览', textPreview: '文本预览',
copyText: '复制文本', copyText: '复制文本',
copyCode: '复制取件码',
copyLink: '复制取件链接',
copySuccess: '文本已复制到剪贴板', copySuccess: '文本已复制到剪贴板',
copyCodeSuccess: '取件码已复制到剪贴板',
copyLinkSuccess: '取件链接已复制到剪贴板',
copyFailed: '复制失败,请重试', copyFailed: '复制失败,请重试',
charCount: '共 {count} 个字符', charCount: '共 {count} 个字符',
downloadFile: '下载文件',
exportText: '导出文本',
downloadSuccess: '已下载:{name}',
exportSuccess: '已导出:{name}',
downloadFailed: '下载失败',
previewFailed: '预览加载失败',
loadingPreview: '正在加载预览...',
previewComplete: '已加载完整内容,共 {count} 个字符',
previewTruncated: '已显示 {shown} / {total} 个字符',
previewFallback: '预览接口不可用,已使用列表中的文本内容',
selectCurrentPage: '选择当前页',
selectedCount: '已选择 {count} 项',
selectFile: '选择 {name}',
clearSelection: '清空选择',
batchDelete: '批量删除',
batchDeleteConfirm: '确认删除选中的 {count} 个文件?此操作不可撤销。',
batchDeleteSuccess: '已删除 {count} 个文件',
batchDeletePartialSuccess: '已删除 {count} 个文件,{failed} 个失败',
batchDeleteFailed: '批量删除失败',
batchEdit: '批量编辑',
batchEditTitle: '批量编辑文件策略',
batchEditSelected: '将更新选中的 {count} 个文件',
batchEditMode: '更新方式',
batchEditExpiresAt: '设置过期时间',
batchEditDownloadLimit: '设置取件次数',
batchEditForever: '设为永久有效',
batchEditForeverHint: '清空过期时间,并将取件次数设为不限。',
batchUpdateConfirm: '确认更新选中的 {count} 个文件?',
batchUpdateSuccess: '已更新 {count} 个文件',
batchUpdatePartialSuccess: '已更新 {count} 个文件,{failed} 个失败',
batchUpdateFailed: '批量更新失败',
batchUpdateNoFields: '请选择要更新的策略',
batchPolicyActionConfirm: '确认对选中的 {count} 个文件执行“{action}”?',
batchPolicyActionSuccess: '已处理 {count} 个文件',
batchPolicyActionPartialSuccess: '已处理 {count} 个文件,{failed} 个失败',
batchPolicyActionFailed: '批量策略动作执行失败',
policyActionSuccess: '文件策略已更新',
policyActionFailed: '策略动作执行失败',
metadataInfo: '运营备注',
metadataHint: '仅管理员可见,用于记录处理线索、归档标签和后续动作。',
metadataNote: '备注',
metadataNotePlaceholder: '记录这个分享的背景、处理记录或负责人...',
metadataTags: '标签',
metadataTagsPlaceholder: '逗号分隔,例如:客户A, 待续期, 重要',
metadataUpdatedAt: '最近保存:{time}',
metadataNeverUpdated: '尚未保存备注',
saveMetadata: '保存备注',
metadataSaveSuccess: '备注和标签已保存',
metadataSaveFailed: '备注保存失败',
policyActions: {
extend24h: '延长 24 小时',
extend7d: '延长 7 天',
makePermanent: '设为永久',
resetDownloadLimit: '重置为 {count} 次'
},
policyActionDescriptions: {
extend24h: '从当前有效期继续追加一天',
extend7d: '适合临时续期一周',
makePermanent: '清空过期时间和次数限制',
resetDownloadLimit: '恢复可取件次数到 {count} 次'
},
applyBatchEdit: '应用更改',
overview: '概览',
policyInfo: '策略信息',
storageInfo: '存储信息',
statusInsight: '状态洞察',
nextAction: '建议动作',
lifecycle: '生命周期',
permanent: '永久有效',
createdAt: '创建时间',
remainingDownloads: '剩余次数',
textLength: '文本长度',
hasDownloadLimit: '限制取件次数',
hasExpirationTime: '限制过期时间',
canPreviewText: '可预览文本',
canDownload: '可下载',
storageBackend: '存储后端',
fileHash: '文件哈希',
isChunked: '分片上传',
filePath: '存储路径',
uuidFileName: '存储文件名',
uploadId: '上传会话',
yes: '是',
no: '否',
insightSeverity: {
success: '正常',
warning: '需关注',
danger: '异常',
info: '提示',
neutral: '待观察'
},
insightStates: {
available: '当前可正常取件',
expired: '当前已不可取件',
storage_incomplete: '存储信息不完整',
expiring_soon: '即将过期',
permanent: '永久有效'
},
insightActions: {
monitor: '保持观察',
extend_or_delete: '延长有效期或清理记录',
inspect_storage: '检查存储路径与文件名',
extend_expiration: '按需延长过期时间'
},
insightReasons: {
expired: '已超过过期时间',
download_limit_exhausted: '取件次数已用尽',
expires_soon: '24 小时内过期',
never_retrieved: '尚未被取件',
storage_metadata_incomplete: '缺少可下载存储信息',
chunked_upload: '由分片上传生成'
},
timeline: {
status: {
done: '已完成',
pending: '等待中',
active: '生效中',
expired: '已过期',
exhausted: '已用尽',
unlimited: '不限'
},
created: {
title: '记录创建',
description: '分享记录已创建,当前状态为 {status}。'
},
content_ready: {
title: '内容就绪',
description: '内容类型为 {detail},当前状态为 {status}。'
},
upload_session: {
title: '上传会话',
description: '上传会话已记录:{detail}。'
},
expiration_policy: {
title: '过期策略',
description: '过期策略当前为 {status}。',
remaining: '还剩 {time}',
overdue: '已超时 {time}'
},
download_limit: {
title: '取件次数',
description: '取件次数策略当前为 {status}{value}。'
},
retrieved: {
title: '取件记录',
description: '累计取件 {value},当前状态为 {status}。'
}
},
headers: { headers: {
select: '选择',
code: '取件码', code: '取件码',
name: '名称', name: '名称',
type: '类型',
size: '大小', size: '大小',
usage: '取件',
status: '状态',
description: '描述', description: '描述',
expiration: '过期时间', expiration: '过期时间',
actions: '操作' actions: '操作'
}, },
sort: {
createdAt: '创建时间',
expiredAt: '过期时间',
name: '名称',
size: '大小',
usedCount: '取件次数',
code: '取件码',
desc: '降序',
asc: '升序'
},
form: { form: {
code: '取件码', code: '取件码',
codePlaceholder: '输入取件码', codePlaceholder: '输入取件码',
@@ -445,7 +745,13 @@ export default {
errorLimits: '访问保护', errorLimits: '访问保护',
errorPerMinute: '检测时间窗口(在此时间内统计错误次数)', errorPerMinute: '检测时间窗口(在此时间内统计错误次数)',
errorCountLimit: '允许错误次数(超过后临时封禁)', errorCountLimit: '允许错误次数(超过后临时封禁)',
saveChanges: '保存设置' saveChanges: '保存设置',
refreshConfig: '刷新配置',
refreshing: '刷新中',
saving: '保存中',
unsavedChanges: '有未保存的配置变更',
allChangesSaved: '所有配置已保存',
refreshBlocked: '请先保存当前变更再刷新'
}, },
dashboard: { dashboard: {
title: '仪表盘', title: '仪表盘',
@@ -459,7 +765,7 @@ export default {
serverUptime: '运行时间', serverUptime: '运行时间',
refresh: '刷新数据', refresh: '刷新数据',
fileHealth: '文件健康', fileHealth: '文件健康',
fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。', fileHealthDesc: '基于状态洞察统计可取件、风险与待处理队列。',
activeFileRatio: '有效占比', activeFileRatio: '有效占比',
fileShareRatio: '文件占比', fileShareRatio: '文件占比',
textShareRatio: '文本占比', textShareRatio: '文本占比',
@@ -475,18 +781,7 @@ export default {
guestUpload: '游客上传', guestUpload: '游客上传',
maxSaveTime: '最长保存', maxSaveTime: '最长保存',
noSaveLimit: '不限制', noSaveLimit: '不限制',
todayCapacityReference: '今日容量 / 单文件上限', todayCapacityReference: '今日容量 / 单文件上限'
fileTypeDistribution: '类型分布',
textType: '文本',
recentFiles: '最近分享',
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
available: '可取件',
table: {
file: '文件',
size: '大小',
usage: '取件',
status: '状态'
}
}, },
fileManage: { fileManage: {
title: '文件管理', title: '文件管理',
+25 -17
View File
@@ -1,14 +1,17 @@
<template> <template>
<div <div
class="h-screen flex flex-col lg:flex-row transition-colors duration-300" class="flex h-screen overflow-hidden flex-col transition-colors duration-300 lg:flex-row"
:class="[isDarkMode ? 'bg-gray-900' : 'bg-gray-50']" :class="[isDarkMode ? 'bg-gray-900' : 'bg-gray-50']"
> >
<!-- Sidebar --> <!-- Sidebar -->
<aside <aside
class="fixed inset-y-0 border-transparent left-0 z-50 w-64 transform transition-all duration-300 ease-in-out lg:relative lg:translate-x-0 border-r flex flex-col h-full lg:h-screen" class="fixed inset-y-0 left-0 z-50 flex h-full w-64 shrink-0 transform flex-col border-r lg:relative lg:h-screen lg:translate-x-0"
:class="[ :class="[
isDarkMode ? 'bg-gray-800 bg-opacity-90 backdrop-filter backdrop-blur-xl ' : 'bg-white ', isDarkMode
{ '-translate-x-full': !isSidebarOpen } ? 'border-gray-700 bg-gray-800 bg-opacity-90 backdrop-filter backdrop-blur-xl'
: 'border-gray-200 bg-white',
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
'transition-transform duration-300 ease-in-out lg:transition-none'
]" ]"
> >
<!-- Logo区域 --> <!-- Logo区域 -->
@@ -46,18 +49,18 @@
<li v-for="item in menuItems" :key="item.id"> <li v-for="item in menuItems" :key="item.id">
<RouterLink <RouterLink
:to="item.redirect" :to="item.redirect"
class="flex items-center p-2 rounded-lg transition-colors duration-200" class="flex h-10 w-full items-center rounded-lg border-l-4 px-3 text-sm font-medium"
:class="[ :class="[
route.name === item.id route.name === item.id
? isDarkMode ? isDarkMode
? 'bg-indigo-900 text-indigo-400' ? 'border-indigo-400 bg-gray-700/70 text-indigo-200'
: 'bg-indigo-100 text-indigo-600' : 'border-indigo-500 bg-gray-100 text-indigo-700'
: isDarkMode : isDarkMode
? 'text-gray-400 hover:bg-gray-700' ? 'border-transparent text-gray-400 hover:bg-gray-700'
: 'text-gray-600 hover:bg-gray-100' : 'border-transparent text-gray-600 hover:bg-gray-100'
]" ]"
> >
<component :is="item.icon" class="w-5 h-5 mr-3" /> <component :is="item.icon" class="mr-3 h-5 w-5 shrink-0" />
{{ item.name }} {{ item.name }}
</RouterLink> </RouterLink>
</li> </li>
@@ -82,7 +85,7 @@
</aside> </aside>
<!-- Main Content --> <!-- Main Content -->
<div class="flex-1 flex flex-col h-full"> <div class="flex h-full min-h-0 min-w-0 flex-1 flex-col">
<!-- Header --> <!-- Header -->
<header <header
class="shadow-md border-b transition-colors duration-300 h-16" class="shadow-md border-b transition-colors duration-300 h-16"
@@ -97,7 +100,7 @@
<!-- Content --> <!-- Content -->
<main <main
class="overflow-y-auto transition-colors duration-300 custom-scrollbar" class="min-h-0 flex-1 overflow-y-auto transition-colors duration-300 custom-scrollbar"
:class="[isDarkMode ? 'bg-gray-900' : 'bg-gray-50']" :class="[isDarkMode ? 'bg-gray-900' : 'bg-gray-50']"
> >
<router-view /> <router-view />
@@ -120,7 +123,7 @@ import {
import { RouterLink, useRoute, useRouter } from 'vue-router' import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ROUTE_NAMES, ROUTES } from '@/constants' import { ROUTE_NAMES, ROUTES } from '@/constants'
import { useAdminStore } from '@/stores/adminStore' import { useAdminSession } from '@/composables'
interface MenuItem { interface MenuItem {
id: string id: string
@@ -133,7 +136,7 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const adminStore = useAdminStore() const { verifySession, logout } = useAdminSession()
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
id: ROUTE_NAMES.DASHBOARD, id: ROUTE_NAMES.DASHBOARD,
@@ -172,6 +175,11 @@ const handleResize = () => {
onMounted(() => { onMounted(() => {
handleResize() handleResize()
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
void verifySession().then((isValid) => {
if (!isValid) {
void router.push(ROUTES.LOGIN)
}
})
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -179,9 +187,9 @@ onUnmounted(() => {
}) })
// 登出处理 // 登出处理
const handleLogout = () => { const handleLogout = async () => {
adminStore.logout() await logout()
router.push(ROUTES.LOGIN) await router.push(ROUTES.LOGIN)
} }
</script> </script>
+3 -2
View File
@@ -5,13 +5,14 @@ import { readStoredToken } from '@/utils/auth-storage'
const publicPageMeta = { const publicPageMeta = {
showGlobalControls: true, showGlobalControls: true,
showRouteLoading: true showRouteLoading: false,
routeTransition: 'transfer-fade'
} }
const adminPageMeta = { const adminPageMeta = {
requiresAuth: true, requiresAuth: true,
showGlobalControls: false, showGlobalControls: false,
showRouteLoading: true showRouteLoading: false
} }
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
+39 -3
View File
@@ -1,13 +1,49 @@
import api from './client' import api from './client'
import type { ApiResponse, ConfigState } from '@/types' import type { ApiResponse, ConfigState, PublicConfigPayload } from '@/types'
const isPublicConfigEnvelope = (
detail: ConfigState | PublicConfigPayload | null | undefined
): detail is PublicConfigPayload => {
return !!detail && typeof detail === 'object' && 'config' in detail
}
const normalizeUserConfigResponse = (
response: ApiResponse<ConfigState | PublicConfigPayload>
): ApiResponse<PublicConfigPayload> => {
if (isPublicConfigEnvelope(response.detail)) {
return {
...response,
detail: {
config: response.detail.config,
meta: response.detail.meta
}
}
}
return {
...response,
detail: {
config: response.detail ?? {}
}
}
}
export class ConfigService { export class ConfigService {
static async getConfig(): Promise<ApiResponse<ConfigState>> { static async getConfig(): Promise<ApiResponse<ConfigState>> {
return api.get('/admin/config/get') return api.get('/admin/config/get')
} }
static async getUserConfig(): Promise<ApiResponse<ConfigState>> { static async getUserConfig(): Promise<ApiResponse<PublicConfigPayload>> {
return api.post('/') try {
const response = (await api.get('/api/v1/config')) as ApiResponse<
ConfigState | PublicConfigPayload
>
return normalizeUserConfigResponse(response)
} catch {
const response = (await api.post('/')) as ApiResponse<ConfigState>
return normalizeUserConfigResponse(response)
}
} }
static async updateConfig(config: Partial<ConfigState>): Promise<ApiResponse> { static async updateConfig(config: Partial<ConfigState>): Promise<ApiResponse> {
+134 -6
View File
@@ -1,6 +1,20 @@
import api, { rawApiClient } from './client' import api, { rawApiClient } from './client'
import { multipartUploadConfig } from './shared' import { multipartUploadConfig } from './shared'
import type { import type {
AdminBatchDeleteFilesResponse,
AdminBatchPolicyActionRequest,
AdminBatchPolicyActionResponse,
AdminBatchUpdateFilesRequest,
AdminBatchUpdateFilesResponse,
AdminFilePatchPayload,
AdminFileDetailResponse,
AdminFileListParams,
AdminFileMetadataRequest,
AdminFilePolicyActionRequest,
AdminFilePreviewResponse,
AdminFileViewPreset,
AdminFileViewPresetRequest,
AdminFileViewPresetsResponse,
ApiResponse, ApiResponse,
ChunkUploadCompleteRequest, ChunkUploadCompleteRequest,
ChunkUploadInitRequest, ChunkUploadInitRequest,
@@ -10,6 +24,7 @@ import type {
FileInfo, FileInfo,
FileListResponse, FileListResponse,
FileUploadResponse, FileUploadResponse,
ShareMetadataResponse,
ShareSelectResponse, ShareSelectResponse,
TextSendResponse, TextSendResponse,
UploadProgress UploadProgress
@@ -29,6 +44,11 @@ const toUrlEncodedForm = (data: Record<string, string | number>) => {
return form return form
} }
const isMethodFallbackError = (error: unknown) => {
const status = (error as { response?: { status?: number } })?.response?.status
return status === 404 || status === 405
}
export class FileService { export class FileService {
static async uploadFile( static async uploadFile(
file: File, file: File,
@@ -100,6 +120,10 @@ export class FileService {
return api.post('/share/select/', { code }) return api.post('/share/select/', { code })
} }
static async inspectFile(code: string): Promise<ApiResponse<ShareMetadataResponse>> {
return api.post('/share/metadata/', { code })
}
static async getFile(code: string): Promise<ApiResponse<FileInfo>> { static async getFile(code: string): Promise<ApiResponse<FileInfo>> {
return api.get(`/file/${code}`) return api.get(`/file/${code}`)
} }
@@ -111,24 +135,116 @@ export class FileService {
return response.data return response.data
} }
static async getAdminFileList(params: { static async getAdminFileList(
page: number params: AdminFileListParams
size: number ): Promise<ApiResponse<FileListResponse>> {
keyword?: string
}): Promise<ApiResponse<FileListResponse>> {
return api.get('/admin/file/list', { params }) return api.get('/admin/file/list', { params })
} }
static async updateFile(data: FileEditForm): Promise<ApiResponse> { static async getAdminFileDetail(id: number): Promise<ApiResponse<AdminFileDetailResponse>> {
return api.get('/admin/file/detail', {
params: { id }
})
}
static async updateFile(data: FileEditForm | AdminFilePatchPayload): Promise<ApiResponse> {
return api.patch('/admin/file/update', data) return api.patch('/admin/file/update', data)
} }
static async applyAdminFilePolicyAction(
data: AdminFilePolicyActionRequest
): Promise<ApiResponse<AdminFileDetailResponse>> {
try {
return await api.patch('/admin/file/policy-action', data)
} catch (error: unknown) {
if (!isMethodFallbackError(error)) {
throw error
}
return api.post('/admin/file/policy-action', data)
}
}
static async applyAdminFilesPolicyAction(
data: AdminBatchPolicyActionRequest
): Promise<ApiResponse<AdminBatchPolicyActionResponse>> {
try {
return await api.patch('/admin/file/batch-policy-action', data)
} catch (error: unknown) {
if (!isMethodFallbackError(error)) {
throw error
}
return api.post('/admin/file/batch-policy-action', data)
}
}
static async updateAdminFileMetadata(
data: AdminFileMetadataRequest
): Promise<ApiResponse<AdminFileDetailResponse>> {
try {
return await api.patch('/admin/file/metadata', data)
} catch (error: unknown) {
if (!isMethodFallbackError(error)) {
throw error
}
return api.post('/admin/file/metadata', data)
}
}
static async getAdminFileViewPresets(): Promise<ApiResponse<AdminFileViewPresetsResponse>> {
return api.get('/admin/file/view-presets')
}
static async saveAdminFileViewPreset(
data: AdminFileViewPresetRequest
): Promise<ApiResponse<AdminFileViewPreset>> {
try {
return await api.patch('/admin/file/view-presets', data)
} catch (error: unknown) {
if (!isMethodFallbackError(error)) {
throw error
}
return api.post('/admin/file/view-presets', data)
}
}
static async updateAdminFiles(
data: AdminBatchUpdateFilesRequest
): Promise<ApiResponse<AdminBatchUpdateFilesResponse>> {
return api.patch('/admin/file/batch-update', data)
}
static async deleteAdminFile(id: number): Promise<ApiResponse> { static async deleteAdminFile(id: number): Promise<ApiResponse> {
return api.delete('/admin/file/delete', { return api.delete('/admin/file/delete', {
data: { id } data: { id }
}) })
} }
static async deleteAdminFiles(
ids: number[]
): Promise<ApiResponse<AdminBatchDeleteFilesResponse>> {
return api.delete('/admin/file/batch-delete', {
data: { ids }
})
}
static async deleteAdminFileViewPreset(id: string): Promise<ApiResponse> {
try {
return await api.delete('/admin/file/view-presets', {
data: { id }
})
} catch (error: unknown) {
if (!isMethodFallbackError(error)) {
throw error
}
return api.post('/admin/file/view-presets/delete', { id })
}
}
static async downloadAdminFile( static async downloadAdminFile(
id: number id: number
): Promise<{ data: Blob; headers: Record<string, string> }> { ): Promise<{ data: Blob; headers: Record<string, string> }> {
@@ -141,4 +257,16 @@ export class FileService {
headers: response.headers as Record<string, string> headers: response.headers as Record<string, string>
} }
} }
static async previewAdminFile(
id: number,
maxChars = 20000
): Promise<ApiResponse<AdminFilePreviewResponse>> {
return api.get('/admin/file/preview', {
params: {
id,
maxChars
}
})
}
} }
+7 -2
View File
@@ -19,12 +19,17 @@ export class PresignUploadService {
static async proxyUpload( static async proxyUpload(
uploadId: string, uploadId: string,
file: File, file: File,
onProgress?: (progress: UploadProgress) => void onProgress?: (progress: UploadProgress) => void,
uploadUrl?: string
): Promise<ApiResponse<PresignUploadResult>> { ): Promise<ApiResponse<PresignUploadResult>> {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
return api.put(`/presign/upload/proxy/${uploadId}`, formData, multipartUploadConfig(onProgress)) return api.put(
uploadUrl || `/presign/upload/proxy/${uploadId}`,
formData,
multipartUploadConfig(onProgress)
)
} }
static async confirmUpload( static async confirmUpload(
+15 -1
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { ConfigState } from '@/types' import type { ConfigState, PublicConfigMeta } from '@/types'
import { import {
DEFAULT_PUBLIC_CONFIG, DEFAULT_PUBLIC_CONFIG,
readNotifyKey, readNotifyKey,
@@ -16,8 +16,10 @@ export const useConfigStore = defineStore('config', () => {
...DEFAULT_PUBLIC_CONFIG, ...DEFAULT_PUBLIC_CONFIG,
...toPublicConfig(readStoredConfig<Partial<ConfigState>>()) ...toPublicConfig(readStoredConfig<Partial<ConfigState>>())
}) })
const publicMeta = ref<PublicConfigMeta>({})
const uploadSizeLimit = computed(() => config.value.uploadSize) const uploadSizeLimit = computed(() => config.value.uploadSize)
const appVersion = computed(() => publicMeta.value.version || '')
const updateConfig = (nextConfig: Partial<ConfigState>) => { const updateConfig = (nextConfig: Partial<ConfigState>) => {
config.value = { config.value = {
@@ -45,6 +47,15 @@ export const useConfigStore = defineStore('config', () => {
return `${notifyTitle}: ${notifyContent}` return `${notifyTitle}: ${notifyContent}`
} }
const applyPublicMeta = (nextMeta: PublicConfigMeta | undefined) => {
if (!nextMeta) return
publicMeta.value = {
...publicMeta.value,
...nextMeta
}
}
const reloadStoredConfig = () => { const reloadStoredConfig = () => {
config.value = { config.value = {
...DEFAULT_PUBLIC_CONFIG, ...DEFAULT_PUBLIC_CONFIG,
@@ -53,8 +64,11 @@ export const useConfigStore = defineStore('config', () => {
} }
return { return {
appVersion,
config, config,
publicMeta,
uploadSizeLimit, uploadSizeLimit,
applyPublicMeta,
applyRemoteConfig, applyRemoteConfig,
updateConfig, updateConfig,
reloadStoredConfig reloadStoredConfig
+1
View File
@@ -1,6 +1,7 @@
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
code: number code: number
message?: string message?: string
msg?: string
detail?: T detail?: T
} }
+2
View File
@@ -2,4 +2,6 @@ export interface AdminUser {
id: string id: string
username: string username: string
token: string token: string
token_type?: string
expires_at?: number
} }
+12
View File
@@ -15,6 +15,18 @@ export interface ThemeChoice {
version: string version: string
} }
export interface PublicConfigMeta {
version?: string
api?: Record<string, string>
features?: Record<string, unknown>
limits?: Record<string, unknown>
}
export interface PublicConfigPayload {
config: Partial<ConfigState>
meta?: PublicConfigMeta
}
export interface ConfigState { export interface ConfigState {
name: string name: string
description: string description: string
+89 -45
View File
@@ -1,54 +1,98 @@
import type { AdminFileHealthFilter } from './file'
export interface DashboardHealthSummary {
healthAttentionCount: number
healthDangerCount: number
healthWarningCount: number
expiringSoonCount: number
storageIssueCount: number
neverRetrievedCount: number
healthyCount: number
permanentCount: number
}
export interface DashboardData { export interface DashboardData {
totalFiles: number totalFiles: number
storageUsed: number storageUsed: number | string
yesterdayCount: number yesterdayCount: number
todayCount: number todayCount: number
yesterdaySize: number yesterdaySize: number | string
todaySize: number todaySize: number | string
sysUptime: number | null sysUptime: number | null
activeCount: number activeCount?: number
expiredCount: number expiredCount?: number
textCount: number textCount?: number
fileCount: number fileCount?: number
chunkedCount: number chunkedCount?: number
usedCount: number usedCount?: number
storageBackend: string storageBackend?: string
uploadSizeLimit: number uploadSizeLimit?: number
openUpload: number openUpload?: number
enableChunk: number enableChunk?: number
maxSaveSeconds: number maxSaveSeconds?: number
topSuffixes: DashboardSuffixStat[] healthAttentionCount?: number
recentFiles: DashboardRecentFile[] healthDangerCount?: number
healthWarningCount?: number
expiringSoonCount?: number
storageIssueCount?: number
neverRetrievedCount?: number
healthyCount?: number
permanentCount?: number
healthSummary?: Partial<DashboardHealthSummary>
} }
export interface DashboardSuffixStat { export type DashboardViewData = Omit<
suffix: string DashboardData,
| keyof DashboardHealthSummary
| 'activeCount'
| 'expiredCount'
| 'textCount'
| 'fileCount'
| 'chunkedCount'
| 'usedCount'
| 'storageBackend'
| 'uploadSizeLimit'
| 'openUpload'
| 'enableChunk'
| 'maxSaveSeconds'
| 'storageUsed'
| 'yesterdaySize'
| 'todaySize'
> &
DashboardHealthSummary & {
hasExtendedStats: boolean
activeCount: number
expiredCount: number
textCount: number
fileCount: number
chunkedCount: number
usedCount: number
storageBackend: string
uploadSizeLimit: number
openUpload: number
enableChunk: number
maxSaveSeconds: number
storageUsed: number
yesterdaySize: number
todaySize: number
storageUsedText: string
yesterdaySizeText: string
todaySizeText: string
uploadSizeLimitText: string
sysUptimeText: string
activeRatio: number
textRatio: number
fileRatio: number
healthyRatio: number
healthAttentionRatio: number
todaySizeRatio: number
}
export interface DashboardHealthAction {
key: string
label: string
description: string
count: number count: number
} health: AdminFileHealthFilter
tone: 'danger' | 'warning' | 'success' | 'neutral'
export interface DashboardRecentFile {
id: number
code: string
name: string
suffix: string
size: number
text: boolean
expiredAt: string | null
expiredCount: number
usedCount: number
createdAt: string | null
isExpired: boolean
}
export interface DashboardViewData extends DashboardData {
hasExtendedStats: boolean
storageUsedText: string
yesterdaySizeText: string
todaySizeText: string
uploadSizeLimitText: string
sysUptimeText: string
activeRatio: number
textRatio: number
fileRatio: number
todaySizeRatio: number
} }
+385 -1
View File
@@ -16,15 +16,127 @@ export interface FileListItem {
size: number size: number
text?: string text?: string
description?: string description?: string
expired_at: string expired_at: string | null
expired_count: number | null expired_count: number | null
created_at: string created_at: string
name?: string
type?: 'text' | 'file'
status?: 'active' | 'expired'
isText?: boolean
is_text?: boolean
isExpired?: boolean
is_expired?: boolean
isChunked?: boolean
is_chunked?: boolean
statusInsights?: AdminFileDetailStatusInsights
status_insights?: AdminFileDetailStatusInsights
remainingDownloads?: number | null
remaining_downloads?: number | null
usedCount?: number
used_count?: number
fileHash?: string | null
file_hash?: string | null
} }
export interface AdminFileViewItem extends FileListItem { export interface AdminFileViewItem extends FileListItem {
displayName: string
displaySize: string displaySize: string
displayExpiredAt: string displayExpiredAt: string
displayUsage: string
displayHealthState: string
displayHealthAction: string
isTextFile: boolean
isExpiredFile: boolean
isChunkedFile: boolean
remainingDownloadsValue: number | null
canPreviewText: boolean canPreviewText: boolean
statusInsightSeverity: AdminFileInsightSeverity
statusInsightState: string
statusInsightNextAction: string
statusInsightReasons: string[]
}
export interface AdminFileSummary {
totalFiles: number
activeCount: number
expiredCount: number
textCount: number
fileCount: number
chunkedCount: number
healthAttentionCount: number
healthDangerCount: number
healthWarningCount: number
expiringSoonCount: number
storageIssueCount: number
neverRetrievedCount: number
healthyCount: number
permanentCount: number
storageUsed: number
usedCount: number
}
export type AdminFileStatusFilter = 'all' | 'active' | 'expired'
export type AdminFileTypeFilter = 'all' | 'file' | 'text' | 'chunked'
export type AdminFileHealthFilter =
| 'all'
| 'attention'
| 'danger'
| 'warning'
| 'healthy'
| 'expired'
| 'expiring_soon'
| 'storage_issue'
| 'never_retrieved'
| 'permanent'
export type AdminFileSortBy = 'created_at' | 'expired_at' | 'name' | 'size' | 'used_count' | 'code'
export type AdminFileSortOrder = 'asc' | 'desc'
export interface AdminFileListParams {
page: number
size: number
keyword?: string
status?: AdminFileStatusFilter
type?: AdminFileTypeFilter
health?: AdminFileHealthFilter
sortBy?: AdminFileSortBy
sortOrder?: AdminFileSortOrder
}
export interface AdminFileViewPresetParams {
keyword: string
status: AdminFileStatusFilter
type: AdminFileTypeFilter
health: AdminFileHealthFilter
sortBy: AdminFileSortBy
sortOrder: AdminFileSortOrder
size: number
}
export interface AdminFileViewPreset {
id: string
name: string
filters?: AdminFileViewPresetParams
params?: AdminFileViewPresetParams
isBuiltIn?: boolean
isDefault?: boolean
is_default?: boolean
createdAt?: string | null
created_at?: string | null
updatedAt?: string | null
updated_at?: string | null
}
export interface AdminFileViewPresetRequest {
id?: string
name: string
filters: AdminFileViewPresetParams
params?: AdminFileViewPresetParams
}
export interface AdminFileViewPresetsResponse {
presets?: AdminFileViewPreset[]
items?: AdminFileViewPreset[]
total?: number
} }
export interface FileEditForm { export interface FileEditForm {
@@ -36,11 +148,257 @@ export interface FileEditForm {
expired_count: number | null expired_count: number | null
} }
export interface AdminFilePatchPayload {
id: number
code?: string
prefix?: string
suffix?: string
expired_at?: string | null
expired_count?: number | null
}
export type AdminFilePolicyAction =
| 'extend_24h'
| 'extend_7d'
| 'make_permanent'
| 'reset_download_limit'
export interface AdminFilePolicyActionRequest {
id: number
action: AdminFilePolicyAction
downloadLimit?: number
download_limit?: number
}
export interface AdminFileMetadata {
note: string
tags: string[]
updatedAt?: string | null
updated_at?: string | null
}
export interface AdminFileMetadataRequest {
id: number
note?: string
tags?: string[]
}
export interface FileListResponse { export interface FileListResponse {
data: FileListItem[] data: FileListItem[]
total: number total: number
page: number page: number
size: number size: number
summary?: AdminFileSummary
}
export interface AdminFilePreviewResponse {
id: number
code: string
name: string
type: 'text'
content: string
length: number
previewLength?: number
preview_length?: number
truncated: boolean
maxChars?: number
max_chars?: number
createdAt?: string | null
created_at?: string | null
expiredAt?: string | null
expired_at?: string | null
}
export interface AdminFileDetailPolicy {
expiredAt?: string | null
expired_at?: string | null
expiredCount?: number | null
expired_count?: number | null
remainingDownloads?: number | null
remaining_downloads?: number | null
isExpired?: boolean
is_expired?: boolean
isPermanent?: boolean
is_permanent?: boolean
}
export interface AdminFileDetailStorage {
backend?: string
filePath?: string | null
file_path?: string | null
uuidFileName?: string | null
uuid_file_name?: string | null
fileHash?: string | null
file_hash?: string | null
isChunked?: boolean
is_chunked?: boolean
uploadId?: string | null
upload_id?: string | null
}
export type AdminFileInsightSeverity = 'success' | 'warning' | 'danger' | 'info' | 'neutral'
export interface AdminFileDetailInsightMetrics {
ageSeconds?: number
age_seconds?: number
secondsUntilExpiration?: number | null
seconds_until_expiration?: number | null
remainingDownloads?: number | null
remaining_downloads?: number | null
usedCount?: number
used_count?: number
}
export interface AdminFileDetailStatusInsights {
severity?: AdminFileInsightSeverity
state?: string
nextAction?: string
next_action?: string
reasons?: string[]
metrics?: AdminFileDetailInsightMetrics
}
export interface AdminFileDetailTimelineItem {
key: string
status?: string
severity?: AdminFileInsightSeverity
timestamp?: string | null
value?: number | string | null
detail?: string | null
}
export interface AdminFileDetailTimelineViewItem extends AdminFileDetailTimelineItem {
severity: AdminFileInsightSeverity
displayTitle: string
displayDescription: string
displayMeta: string
}
export interface AdminFileDetailResponse extends FileListItem {
filename?: string
displayName?: string
display_name?: string
isPermanent?: boolean
is_permanent?: boolean
hasDownloadLimit?: boolean
has_download_limit?: boolean
hasExpirationTime?: boolean
has_expiration_time?: boolean
textLength?: number
text_length?: number
canPreviewText?: boolean
can_preview_text?: boolean
canDownload?: boolean
can_download?: boolean
storageBackend?: string
storage_backend?: string
filePath?: string | null
file_path?: string | null
uuidFileName?: string | null
uuid_file_name?: string | null
uploadId?: string | null
upload_id?: string | null
policy?: AdminFileDetailPolicy
storage?: AdminFileDetailStorage
metadata?: AdminFileMetadata
meta?: AdminFileMetadata
note?: string
tags?: string[]
metadataUpdatedAt?: string | null
metadata_updated_at?: string | null
statusInsights?: AdminFileDetailStatusInsights
status_insights?: AdminFileDetailStatusInsights
timeline?: AdminFileDetailTimelineItem[]
}
export interface AdminFileDetailViewItem extends AdminFileViewItem {
displayCreatedAt: string
displayRetrieveUrl: string
textLengthValue: number
canDownloadFile: boolean
isPermanentFile: boolean
hasDownloadLimitFile: boolean
hasExpirationTimeFile: boolean
isChunkedStorage: boolean
storageBackendValue: string
fileHashValue?: string | null
filePathValue?: string | null
uuidFileNameValue?: string | null
uploadIdValue?: string | null
metadataNote: string
metadataTags: string[]
metadataUpdatedAt: string | null
statusInsightMetrics?: AdminFileDetailInsightMetrics
detailTimeline: AdminFileDetailTimelineViewItem[]
}
export interface AdminBatchDeleteFileFailure {
id: number
reason: string
}
export interface AdminBatchDeleteFilesResponse {
requestedCount?: number
requested_count?: number
uniqueCount?: number
unique_count?: number
deletedCount?: number
deleted_count?: number
missingCount?: number
missing_count?: number
failedCount?: number
failed_count?: number
deleted?: number[]
missing?: number[]
failed?: AdminBatchDeleteFileFailure[]
}
export interface AdminBatchUpdateFileFailure {
id: number
reason: string
}
export interface AdminBatchUpdateFilesRequest {
ids: number[]
expired_at?: string | null
expired_count?: number | null
clearExpiredAt?: boolean
clear_expired_at?: boolean
}
export interface AdminBatchUpdateFilesResponse {
requestedCount?: number
requested_count?: number
uniqueCount?: number
unique_count?: number
updatedCount?: number
updated_count?: number
missingCount?: number
missing_count?: number
failedCount?: number
failed_count?: number
updated?: number[]
missing?: number[]
failed?: AdminBatchUpdateFileFailure[]
}
export interface AdminBatchPolicyActionRequest {
ids: number[]
action: AdminFilePolicyAction
downloadLimit?: number
download_limit?: number
}
export interface AdminBatchPolicyActionResponse extends AdminBatchUpdateFilesResponse {
action?: AdminFilePolicyAction | string
}
export type AdminBatchEditMode = 'expiresAt' | 'downloadLimit' | 'forever'
export interface AdminBatchEditForm {
mode: AdminBatchEditMode
expired_at: string
expired_count: number | null
} }
export interface FileUploadResponse { export interface FileUploadResponse {
@@ -57,6 +415,30 @@ export interface ShareSelectResponse {
name: string name: string
text: string text: string
size: number size: number
type?: 'file' | 'text'
is_text?: boolean
content?: string | null
download_url?: string | null
created_at?: string | null
expired_at?: string | null
expires_at?: string | null
expired_count?: number | null
used_count?: number
remaining_downloads?: number | null
}
export interface ShareMetadataResponse {
code: string
name: string
size: number
type: 'file' | 'text'
is_text: boolean
created_at?: string | null
expired_at?: string | null
expires_at?: string | null
expired_count?: number | null
used_count?: number
remaining_downloads?: number | null
} }
export interface ReceivedFileRecord { export interface ReceivedFileRecord {
@@ -67,6 +449,8 @@ export interface ReceivedFileRecord {
downloadUrl: string | null downloadUrl: string | null
content: string | null content: string | null
date: string date: string
type?: 'file' | 'text'
remainingDownloads?: number | null
} }
export interface SentFileRecord { export interface SentFileRecord {
+2
View File
@@ -22,6 +22,8 @@ export interface PresignInitRequest {
export interface PresignInitResponse { export interface PresignInitResponse {
upload_id: string upload_id: string
upload_url: string upload_url: string
proxy_upload_url?: string
legacy_proxy_upload_url?: string
mode: PresignUploadMode mode: PresignUploadMode
expires_in: number expires_in: number
} }
+65 -1
View File
@@ -1,5 +1,5 @@
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import type { ReceivedFileRecord } from '@/types' import type { AdminFileViewItem, ApiResponse, ReceivedFileRecord } from '@/types'
import { buildDownloadUrl } from '@/utils/share-url' import { buildDownloadUrl } from '@/utils/share-url'
export function downloadReceivedRecord(record: ReceivedFileRecord): void { export function downloadReceivedRecord(record: ReceivedFileRecord): void {
@@ -13,3 +13,67 @@ export function downloadReceivedRecord(record: ReceivedFileRecord): void {
saveAs(blob, `${record.filename}.txt`) saveAs(blob, `${record.filename}.txt`)
} }
} }
export const getSafeFilename = (name: string) =>
name
.replace(/[\\/:*?"<>|\x00-\x1F]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180) || 'download'
const getFilenameFromDisposition = (disposition?: string) => {
if (!disposition) return ''
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch {
return utf8Match[1]
}
}
const asciiMatch = disposition.match(/filename="?([^"]+)"?/i)
return asciiMatch?.[1] || ''
}
const readBlobAsText = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error)
reader.readAsText(blob)
})
export function exportAdminTextFile(file: AdminFileViewItem, content: string): void {
const filename = getSafeFilename(file.displayName || file.code)
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
saveAs(blob, `${filename}.txt`)
}
export async function downloadAdminManagedFile(
file: AdminFileViewItem,
response: { data: Blob; headers: Record<string, string> }
): Promise<void> {
if (file.isTextFile) {
const text = await readBlobAsText(response.data)
let content = text
try {
const payload = JSON.parse(text) as ApiResponse<string>
content = typeof payload.detail === 'string' ? payload.detail : text
} catch {
content = text
}
exportAdminTextFile(file, content)
return
}
const disposition =
response.headers['content-disposition'] || response.headers['Content-Disposition']
const filename = getSafeFilename(
getFilenameFromDisposition(disposition) || file.displayName || file.code
)
saveAs(response.data, filename)
}
+23
View File
@@ -0,0 +1,23 @@
export function readStoredRecords<T>(key: string): T[] {
if (typeof window === 'undefined') return []
try {
const raw = localStorage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as T[]) : []
} catch {
return []
}
}
export function writeStoredRecords<T>(key: string, records: T[]) {
if (typeof window === 'undefined') return
try {
localStorage.setItem(key, JSON.stringify(records))
} catch {
// 本地存储不可用时保持内存记录,不影响当前会话。
}
}
+15 -1
View File
@@ -22,6 +22,7 @@
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
<div v-if="sendType === 'file'" key="file" class="grid grid-cols-1 gap-8"> <div v-if="sendType === 'file'" key="file" class="grid grid-cols-1 gap-8">
<FileUploadArea <FileUploadArea
class="payload-panel"
:selected-file="selectedFile" :selected-file="selectedFile"
:selected-files="selectedFiles" :selected-files="selectedFiles"
:progress="uploadProgress" :progress="uploadProgress"
@@ -32,7 +33,11 @@
/> />
</div> </div>
<div v-else key="text" class="grid grid-cols-1 gap-8"> <div v-else key="text" class="grid grid-cols-1 gap-8">
<TextInputArea v-model="textContent" :placeholder="t('send.uploadArea.textInput')" /> <TextInputArea
v-model="textContent"
class="payload-panel"
:placeholder="t('send.uploadArea.textInput')"
/>
</div> </div>
</transition> </transition>
<ExpirationSelector <ExpirationSelector
@@ -202,6 +207,15 @@ const toRetrieve = () => {
transform: translateY(0); transform: translateY(0);
} }
:deep(.payload-panel) {
height: 12rem;
min-height: 12rem;
}
:deep(.payload-panel textarea) {
height: 100%;
}
select option { select option {
padding: 8px; padding: 8px;
margin: 4px; margin: 4px;
+175 -131
View File
@@ -1,27 +1,43 @@
<template> <template>
<div class="p-6 overflow-y-auto custom-scrollbar"> <div class="p-6">
<div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> <div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<p class="text-sm" :class="[mutedTextClass]">FileCodeBox Admin</p> <p class="text-sm" :class="[mutedTextClass]">FileCodeBox Admin</p>
<h2 class="text-2xl font-bold" :class="[primaryTextClass]"> <h2 class="text-2xl font-bold" :class="[primaryTextClass]">
{{ t('admin.dashboard.title') }} {{ t('admin.dashboard.title') }}
</h2> </h2>
<p class="mt-1 text-xs" :class="[mutedTextClass]">
{{ t('admin.dashboard.lastUpdated', { time: lastUpdatedText }) }}
</p>
</div> </div>
<button <button
type="button" type="button"
:disabled="isLoading"
@click="fetchDashboardData" @click="fetchDashboardData"
class="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors" class="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-70"
:class="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-800 text-gray-200 hover:bg-gray-700' ? 'bg-gray-800 text-gray-200 hover:bg-gray-700'
: 'bg-white text-gray-700 shadow-sm hover:bg-gray-50' : 'bg-white text-gray-700 shadow-sm hover:bg-gray-50'
]" ]"
> >
<RefreshCwIcon class="mr-2 h-4 w-4" /> <RefreshCwIcon class="mr-2 h-4 w-4" :class="{ 'animate-spin': isLoading }" />
{{ t('admin.dashboard.refresh') }} {{ isLoading ? t('admin.dashboard.refreshing') : t('admin.dashboard.refresh') }}
</button> </button>
</div> </div>
<div
v-if="errorMessage"
class="mb-6 rounded-lg border px-4 py-3 text-sm"
:class="[
isDarkMode
? 'border-red-900/50 bg-red-950/30 text-red-200'
: 'border-red-200 bg-red-50 text-red-700'
]"
>
{{ errorMessage }}
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard <StatCard
:title="t('admin.dashboard.totalFiles')" :title="t('admin.dashboard.totalFiles')"
@@ -79,7 +95,10 @@
{{ t('admin.dashboard.fileHealthDesc') }} {{ t('admin.dashboard.fileHealthDesc') }}
</p> </p>
</div> </div>
<ActivityIcon class="h-5 w-5" :class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']" /> <ActivityIcon
class="h-5 w-5"
:class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']"
/>
</div> </div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
@@ -103,6 +122,31 @@
/> />
</div> </div>
<div class="mt-6 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
<button
v-for="action in healthActions"
:key="action.key"
type="button"
class="group flex min-h-28 flex-col justify-between rounded-lg border p-4 text-left transition-colors"
:class="getHealthActionClass(action.tone)"
@click="openHealthQueue(action.health)"
>
<span class="flex items-start justify-between gap-3">
<span>
<span class="block text-2xl font-semibold">{{ action.count }}</span>
<span class="mt-1 block text-sm font-medium">{{ action.label }}</span>
</span>
<component :is="getHealthActionIcon(action.tone)" class="h-5 w-5 shrink-0" />
</span>
<span class="mt-3 flex items-center justify-between gap-2 text-xs">
<span class="line-clamp-2">{{ action.description }}</span>
<ArrowRightIcon
class="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-0.5"
/>
</span>
</button>
</div>
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="rounded-lg border p-4" :class="[subtlePanelClass]"> <div class="rounded-lg border p-4" :class="[subtlePanelClass]">
<p class="text-sm" :class="[mutedTextClass]"> <p class="text-sm" :class="[mutedTextClass]">
@@ -157,10 +201,7 @@
:label="t('admin.dashboard.guestUpload')" :label="t('admin.dashboard.guestUpload')"
:value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')" :value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')"
/> />
<PolicyRow <PolicyRow :label="t('admin.dashboard.maxSaveTime')" :value="maxSaveTimeText" />
:label="t('admin.dashboard.maxSaveTime')"
:value="maxSaveTimeText"
/>
</div> </div>
<div class="mt-5"> <div class="mt-5">
@@ -168,7 +209,10 @@
<span :class="[mutedTextClass]">{{ t('admin.dashboard.todayCapacityReference') }}</span> <span :class="[mutedTextClass]">{{ t('admin.dashboard.todayCapacityReference') }}</span>
<span :class="[primaryTextClass]">{{ dashboardData.todaySizeRatio }}%</span> <span :class="[primaryTextClass]">{{ dashboardData.todaySizeRatio }}%</span>
</div> </div>
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"> <div
class="h-2 overflow-hidden rounded-full"
:class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"
>
<div <div
class="h-full rounded-full bg-indigo-500" class="h-full rounded-full bg-indigo-500"
:style="{ width: `${dashboardData.todaySizeRatio}%` }" :style="{ width: `${dashboardData.todaySizeRatio}%` }"
@@ -178,147 +222,71 @@
</section> </section>
</div> </div>
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3"> <footer
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]"> class="mt-6 flex flex-col gap-2 border-t pt-4 text-xs sm:flex-row sm:items-center sm:justify-between"
<h3 class="text-lg font-semibold" :class="[primaryTextClass]"> :class="[isDarkMode ? 'border-gray-800 text-gray-500' : 'border-gray-200 text-gray-500']"
{{ t('admin.dashboard.fileTypeDistribution') }} >
</h3> <span>{{ t('admin.dashboard.footerProduct') }}</span>
<div class="mt-4 space-y-3"> <span class="inline-flex items-center gap-2">
<div v-if="dashboardData.topSuffixes.length === 0" class="text-sm" :class="[mutedTextClass]"> <span>{{ t('admin.dashboard.runtimeVersion') }}</span>
{{ t('common.noData') }} <span
</div> class="rounded-md border px-2 py-0.5 font-medium"
<div v-for="item in dashboardData.topSuffixes" :key="item.suffix" class="space-y-1"> :class="[
<div class="flex items-center justify-between text-sm"> isDarkMode
<span :class="[primaryTextClass]">{{ item.suffix || t('admin.dashboard.textType') }}</span> ? 'border-gray-700 bg-gray-800/70 text-gray-300'
<span :class="[mutedTextClass]">{{ item.count }}</span> : 'border-gray-200 bg-white text-gray-700'
</div> ]"
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"> >
<div {{ versionText }}
class="h-full rounded-full bg-purple-500" </span>
:style="{ width: `${getSuffixRatio(item.count)}%` }" </span>
></div> </footer>
</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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, onMounted } from 'vue' import { computed, defineComponent, h, onMounted } from 'vue'
import type { PropType } from 'vue' import type { Component, PropType } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { import {
ActivityIcon, ActivityIcon,
AlertTriangleIcon,
ArrowRightIcon,
CheckCircleIcon,
DownloadCloudIcon, DownloadCloudIcon,
FileIcon,
FilesIcon, FilesIcon,
FileTextIcon,
HardDriveIcon, HardDriveIcon,
RefreshCwIcon, RefreshCwIcon,
ShieldCheckIcon,
UploadCloudIcon UploadCloudIcon
} from 'lucide-vue-next' } from 'lucide-vue-next'
import StatCard from '@/components/common/StatCard.vue' import StatCard from '@/components/common/StatCard.vue'
import { useDashboardStats, useInjectedDarkMode } from '@/composables' import { useDashboardStats, useInjectedDarkMode } from '@/composables'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { formatFileSize, formatTimestamp } from '@/utils/common' import { ROUTES } from '@/constants'
import { useConfigStore } from '@/stores/configStore'
import type { DashboardHealthAction } from '@/types'
const isDarkMode = useInjectedDarkMode() const isDarkMode = useInjectedDarkMode()
const { t } = useI18n() const { t } = useI18n()
const { dashboardData, fetchDashboardData } = useDashboardStats() const router = useRouter()
const configStore = useConfigStore()
const { appVersion } = storeToRefs(configStore)
const { dashboardData, errorMessage, fetchDashboardData, isLoading, lastUpdatedText } =
useDashboardStats({
loadFailedMessage: t('admin.dashboard.loadFailed')
})
const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900')) const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500')) const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
const versionText = computed(() => appVersion.value || t('admin.dashboard.versionPending'))
const panelClass = computed(() => const panelClass = computed(() =>
isDarkMode.value ? 'bg-gray-800/80 border border-gray-700' : 'bg-white border border-gray-100' isDarkMode.value ? 'bg-gray-800/80 border border-gray-700' : 'bg-white border border-gray-100'
) )
const subtlePanelClass = computed(() => const subtlePanelClass = computed(() =>
isDarkMode.value ? 'border-gray-700 bg-gray-900/30' : 'border-gray-100 bg-gray-50' 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(() => { const maxSaveTimeText = computed(() => {
if (!dashboardData.maxSaveSeconds) return t('admin.dashboard.noSaveLimit') if (!dashboardData.maxSaveSeconds) return t('admin.dashboard.noSaveLimit')
const days = Math.floor(dashboardData.maxSaveSeconds / 86400) const days = Math.floor(dashboardData.maxSaveSeconds / 86400)
@@ -328,11 +296,80 @@ const maxSaveTimeText = computed(() => {
return `${Math.floor(dashboardData.maxSaveSeconds / 60)}${t('common.minute')}` return `${Math.floor(dashboardData.maxSaveSeconds / 60)}${t('common.minute')}`
}) })
const getSuffixRatio = (count: number) => Math.round((count / maxSuffixCount.value) * 100) const healthActions = computed<DashboardHealthAction[]>(() => [
{
key: 'attention',
label: t('admin.dashboard.healthActions.attention.title'),
description: t('admin.dashboard.healthActions.attention.description'),
count: dashboardData.healthAttentionCount,
health: 'attention',
tone: dashboardData.healthAttentionCount > 0 ? 'danger' : 'success'
},
{
key: 'storageIssue',
label: t('admin.dashboard.healthActions.storageIssue.title'),
description: t('admin.dashboard.healthActions.storageIssue.description'),
count: dashboardData.storageIssueCount,
health: 'storage_issue',
tone: dashboardData.storageIssueCount > 0 ? 'danger' : 'success'
},
{
key: 'expiringSoon',
label: t('admin.dashboard.healthActions.expiringSoon.title'),
description: t('admin.dashboard.healthActions.expiringSoon.description'),
count: dashboardData.expiringSoonCount,
health: 'expiring_soon',
tone: dashboardData.expiringSoonCount > 0 ? 'warning' : 'success'
},
{
key: 'neverRetrieved',
label: t('admin.dashboard.healthActions.neverRetrieved.title'),
description: t('admin.dashboard.healthActions.neverRetrieved.description'),
count: dashboardData.neverRetrievedCount,
health: 'never_retrieved',
tone: dashboardData.neverRetrievedCount > 0 ? 'neutral' : 'success'
},
{
key: 'permanent',
label: t('admin.dashboard.healthActions.permanent.title'),
description: t('admin.dashboard.healthActions.permanent.description'),
count: dashboardData.permanentCount,
health: 'permanent',
tone: 'success'
}
])
const formatCreatedAt = (value: string | null) => { const healthActionIconMap: Record<DashboardHealthAction['tone'], Component> = {
if (!value) return '-' danger: AlertTriangleIcon,
return formatTimestamp(value, 'datetime') warning: AlertTriangleIcon,
success: CheckCircleIcon,
neutral: ShieldCheckIcon
}
const getHealthActionIcon = (tone: DashboardHealthAction['tone']) => healthActionIconMap[tone]
const getHealthActionClass = (tone: DashboardHealthAction['tone']) => {
const darkClasses: Record<DashboardHealthAction['tone'], string> = {
danger: 'border-red-500/20 bg-red-500/10 text-red-200 hover:border-red-400/40',
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-200 hover:border-amber-400/40',
success: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200 hover:border-emerald-400/40',
neutral: 'border-gray-700 bg-gray-900/30 text-gray-300 hover:border-gray-600'
}
const lightClasses: Record<DashboardHealthAction['tone'], string> = {
danger: 'border-red-100 bg-red-50 text-red-700 hover:border-red-200',
warning: 'border-amber-100 bg-amber-50 text-amber-700 hover:border-amber-200',
success: 'border-emerald-100 bg-emerald-50 text-emerald-700 hover:border-emerald-200',
neutral: 'border-gray-100 bg-gray-50 text-gray-700 hover:border-gray-200'
}
return isDarkMode.value ? darkClasses[tone] : lightClasses[tone]
}
const openHealthQueue = (health: DashboardHealthAction['health']) => {
void router.push({
path: ROUTES.FILE_MANAGE,
query: { health }
})
} }
const MetricProgress = defineComponent({ const MetricProgress = defineComponent({
@@ -381,10 +418,17 @@ const PolicyRow = defineComponent({
}, },
setup(props) { setup(props) {
return () => 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(
h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label), 'div',
h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value) {
]) 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)
]
)
} }
}) })
File diff suppressed because it is too large Load Diff
+319 -105
View File
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, onMounted } from 'vue' import { inject, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { RefreshCwIcon, SaveIcon } from 'lucide-vue-next'
import BaseButton from '@/components/common/BaseButton.vue'
import SettingNumberInput from '@/components/common/SettingNumberInput.vue' import SettingNumberInput from '@/components/common/SettingNumberInput.vue'
import SettingSwitch from '@/components/common/SettingSwitch.vue' import SettingSwitch from '@/components/common/SettingSwitch.vue'
import { useSystemConfig } from '@/composables' import { useSystemConfig } from '@/composables'
@@ -9,6 +11,9 @@ const isDarkMode = inject('isDarkMode')
const { t } = useI18n() const { t } = useI18n()
const { const {
config, config,
isRefreshing,
isSaving,
isDirty,
fileSize, fileSize,
sizeUnit, sizeUnit,
saveTime, saveTime,
@@ -24,12 +29,62 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 h-screen overflow-y-auto custom-scrollbar"> <div class="p-6">
<h2 class="text-2xl font-bold mb-6" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"> <div
{{ t('admin.settings.title') }} class="sticky top-0 z-20 -mx-6 -mt-6 mb-6 border-b px-6 py-4 backdrop-blur"
</h2> :class="[isDarkMode ? 'border-gray-700 bg-gray-900/85' : 'border-gray-200 bg-gray-50/90']"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-2xl font-bold" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
{{ t('admin.settings.title') }}
</h2>
<p
class="mt-1 text-sm"
:class="[isDirty ? 'text-amber-500' : isDarkMode ? 'text-gray-400' : 'text-gray-500']"
>
{{
isDirty ? t('manage.settings.unsavedChanges') : t('manage.settings.allChangesSaved')
}}
</p>
</div>
<div class="space-y-6 rounded-lg shadow-md p-6" :class="[isDarkMode ? 'bg-gray-800 bg-opacity-70' : 'bg-white']"> <div class="flex flex-wrap items-center gap-3">
<BaseButton
variant="secondary"
:loading="isRefreshing"
:disabled="isSaving || isDirty"
:title="
isDirty ? t('manage.settings.refreshBlocked') : t('manage.settings.refreshConfig')
"
@click="refreshConfig"
>
<template #icon>
<RefreshCwIcon class="mr-2 h-4 w-4" />
</template>
{{
isRefreshing ? t('manage.settings.refreshing') : t('manage.settings.refreshConfig')
}}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="!isDirty || isRefreshing"
@click="submitConfig"
>
<template #icon>
<SaveIcon class="mr-2 h-4 w-4" />
</template>
{{ isSaving ? t('manage.settings.saving') : t('manage.settings.saveChanges') }}
</BaseButton>
</div>
</div>
</div>
<div
class="space-y-6 rounded-lg shadow-md p-6"
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-70' : 'bg-white']"
>
<!-- 基本设置 --> <!-- 基本设置 -->
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-lg font-medium mb-4" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"> <h3 class="text-lg font-medium mb-4" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
@@ -39,158 +94,226 @@ onMounted(() => {
<!-- 网基本信息 --> <!-- 网基本信息 -->
<div class="grid grid-cols-1 gap-6"> <div class="grid grid-cols-1 gap-6">
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('admin.settings.siteName') }} {{ t('admin.settings.siteName') }}
</label> </label>
<input type="text" v-model="config.name" <input
type="text"
v-model="config.name"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('admin.settings.websiteDescription') }} {{ t('admin.settings.websiteDescription') }}
</label> </label>
<input type="text" v-model="config.description" <input
type="text"
v-model="config.description"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('admin.settings.adminPassword') }} {{ t('admin.settings.adminPassword') }}
</label> </label>
<div class="relative"> <div class="relative">
<input type="password" minlength="6" v-model="config.admin_token" :placeholder="t('admin.settings.passwordPlaceholder')" <input
type="password"
minlength="6"
v-model="config.admin_token"
:placeholder="t('admin.settings.passwordPlaceholder')"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
<div class="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-gray-400" />
:class="[isDarkMode ? 'text-gray-500' : 'text-gray-400']"> <div
class="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-gray-400"
:class="[isDarkMode ? 'text-gray-500' : 'text-gray-400']"
>
<span class="text-xs">{{ t('admin.settings.passwordNote') }}</span> <span class="text-xs">{{ t('admin.settings.passwordNote') }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('admin.settings.keywords') }} {{ t('admin.settings.keywords') }}
</label> </label>
<input type="text" v-model="config.keywords" <input
type="text"
v-model="config.keywords"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<!-- 主题选择 --> <!-- 主题选择 -->
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.themeSelection') }} {{ t('manage.settings.themeSelection') }}
</label> </label>
<select v-model="config.themesSelect" <select
v-model="config.themesSelect"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border appearance-none bg-no-repeat bg-right focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none cursor-pointer" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border appearance-none bg-no-repeat bg-right focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none cursor-pointer"
:class="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500' ? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400' : 'border-gray-300 hover:border-gray-400'
]" style=" ]"
style="
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M7%208l3%203%203-3%22%20stroke%3D%22%236B7280%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E'); background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M7%208l3%203%203-3%22%20stroke%3D%22%236B7280%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E');
"> "
>
<option v-for="item in config.themesChoices" :value="item.key" :key="item.key"> <option v-for="item in config.themesChoices" :value="item.key" :key="item.key">
{{ item.name }} (by {{ item.author }} V{{ item.version }}) {{ item.name }} (by {{ item.author }} V{{ item.version }})
</option> </option>
</select> </select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.robotsFile') }} {{ t('manage.settings.robotsFile') }}
</label> </label>
<textarea v-model="config.robotsText" rows="3" <textarea
v-model="config.robotsText"
rows="3"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border resize-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border resize-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]"></textarea> ]"
></textarea>
</div> </div>
</div> </div>
<!-- 通知设置 --> <!-- 通知设置 -->
<div class="grid grid-cols-1 gap-6 mt-8"> <div class="grid grid-cols-1 gap-6 mt-8">
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.notificationTitle') }} {{ t('manage.settings.notificationTitle') }}
</label> </label>
<input type="text" v-model="config.notify_title" <input
type="text"
v-model="config.notify_title"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.notificationContent') }} {{ t('manage.settings.notificationContent') }}
</label> </label>
<textarea v-model="config.notify_content" rows="3" <textarea
v-model="config.notify_content"
rows="3"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border resize-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border resize-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]"></textarea> ]"
></textarea>
</div> </div>
</div> </div>
<!-- 存储设置 --> <!-- 存储设置 -->
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-lg font-medium mb-4" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"> <h3
class="text-lg font-medium mb-4"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']"
>
{{ t('manage.settings.storageSettings') }} {{ t('manage.settings.storageSettings') }}
</h3> </h3>
<!-- 通知设置 --> <!-- 通知设置 -->
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.storagePath') }} {{ t('manage.settings.storagePath') }}
</label> </label>
<input type="text" :placeholder="t('manage.settings.storagePathPlaceholder')" v-model="config.storage_path" <input
type="text"
:placeholder="t('manage.settings.storagePathPlaceholder')"
v-model="config.storage_path"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.storageMethod') }} {{ t('manage.settings.storageMethod') }}
</label> </label>
<select v-model="config.file_storage" <select
v-model="config.file_storage"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border appearance-none bg-no-repeat bg-right focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none cursor-pointer" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border appearance-none bg-no-repeat bg-right focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none cursor-pointer"
:class="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500' ? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400' : 'border-gray-300 hover:border-gray-400'
]" style=" ]"
style="
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M7%208l3%203%203-3%22%20stroke%3D%22%236B7280%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E'); background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M7%208l3%203%203-3%22%20stroke%3D%22%236B7280%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E');
"> "
>
<option value="local">{{ t('manage.settings.localStorage') }}</option> <option value="local">{{ t('manage.settings.localStorage') }}</option>
<option value="s3">{{ t('manage.settings.s3Storage') }}</option> <option value="s3">{{ t('manage.settings.s3Storage') }}</option>
<option value="webdav">{{ t('manage.settings.webdavStorage') }}</option> <option value="webdav">{{ t('manage.settings.webdavStorage') }}</option>
@@ -207,42 +330,63 @@ onMounted(() => {
<div v-if="config.file_storage === 'webdav'" class="space-y-4"> <div v-if="config.file_storage === 'webdav'" class="space-y-4">
<!-- 通知设置 --> <!-- 通知设置 -->
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
Webdav URL Webdav URL
</label> </label>
<input type="text" :placeholder="t('manage.settings.webdavUrlPlaceholder')" v-model="config.webdav_url" <input
type="text"
:placeholder="t('manage.settings.webdavUrlPlaceholder')"
v-model="config.webdav_url"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
Webdav Username Webdav Username
</label> </label>
<input type="text" :placeholder="t('manage.settings.webdavUsernamePlaceholder')" v-model="config.webdav_username" <input
type="text"
:placeholder="t('manage.settings.webdavUsernamePlaceholder')"
v-model="config.webdav_username"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
Webdav Password Webdav Password
</label> </label>
<input type="password" :placeholder="t('manage.settings.webdavPasswordPlaceholder')" v-model="config.webdav_password" <input
type="password"
:placeholder="t('manage.settings.webdavPasswordPlaceholder')"
v-model="config.webdav_password"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -250,97 +394,139 @@ onMounted(() => {
<div v-if="config.file_storage === 's3'" class="space-y-4"> <div v-if="config.file_storage === 's3'" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.s3AccessKeyId') }} {{ t('manage.settings.s3AccessKeyId') }}
</label> </label>
<input type="text" v-model="config.s3_access_key_id" <input
type="text"
v-model="config.s3_access_key_id"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.s3SecretAccessKey') }} {{ t('manage.settings.s3SecretAccessKey') }}
</label> </label>
<input type="password" v-model="config.s3_secret_access_key" <input
type="password"
v-model="config.s3_secret_access_key"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.s3BucketName') }} {{ t('manage.settings.s3BucketName') }}
</label> </label>
<input type="text" v-model="config.s3_bucket_name" <input
type="text"
v-model="config.s3_bucket_name"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.s3EndpointUrl') }} {{ t('manage.settings.s3EndpointUrl') }}
</label> </label>
<input type="text" v-model="config.s3_endpoint_url" <input
type="text"
v-model="config.s3_endpoint_url"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.s3RegionName') }} {{ t('manage.settings.s3RegionName') }}
</label> </label>
<input type="text" v-model="config.s3_region_name" :placeholder="t('manage.settings.autoPlaceholder')" <input
type="text"
v-model="config.s3_region_name"
:placeholder="t('manage.settings.autoPlaceholder')"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.s3SignatureVersion') }} {{ t('manage.settings.s3SignatureVersion') }}
</label> </label>
<select v-model="config.s3_signature_version" <select
v-model="config.s3_signature_version"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500' ? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400' : 'border-gray-300 hover:border-gray-400'
]"> ]"
>
<option value="s3v2">{{ t('manage.settings.s3v2') }}</option> <option value="s3v2">{{ t('manage.settings.s3v2') }}</option>
<option value="s3v4">{{ t('manage.settings.s3v4') }}</option> <option value="s3v4">{{ t('manage.settings.s3v4') }}</option>
</select> </select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.s3Hostname') }} {{ t('manage.settings.s3Hostname') }}
</label> </label>
<input type="text" v-model="config.s3_hostname" <input
type="text"
v-model="config.s3_hostname"
class="w-full 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="w-full 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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
/>
</div> </div>
<SettingSwitch <SettingSwitch
@@ -365,7 +551,10 @@ onMounted(() => {
<!-- 上传限制 --> <!-- 上传限制 -->
<div class="mt-8"> <div class="mt-8">
<h3 class="text-lg font-medium mb-4" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"> <h3
class="text-lg font-medium mb-4"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']"
>
{{ t('manage.settings.uploadLimits') }} {{ t('manage.settings.uploadLimits') }}
</h3> </h3>
@@ -383,48 +572,70 @@ onMounted(() => {
/> />
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.fileSizeLimit') }} {{ t('manage.settings.fileSizeLimit') }}
</label> </label>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input type="number" v-model="fileSize" <input
type="number"
v-model="fileSize"
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="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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
<select v-model="sizeUnit" />
<select
v-model="sizeUnit"
class="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="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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500' ? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400' : 'border-gray-300 hover:border-gray-400'
]"> ]"
>
<option value="KB">{{ t('manage.settings.fileSizeUnits.kb') }}</option> <option value="KB">{{ t('manage.settings.fileSizeUnits.kb') }}</option>
<option value="MB">{{ t('manage.settings.fileSizeUnits.mb') }}</option> <option value="MB">{{ t('manage.settings.fileSizeUnits.mb') }}</option>
<option value="GB">{{ t('manage.settings.fileSizeUnits.gb') }}</option> <option value="GB">{{ t('manage.settings.fileSizeUnits.gb') }}</option>
</select> </select>
</div> </div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium mb-2"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.expirationType') }} {{ t('manage.settings.expirationType') }}
</label> </label>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<label v-for="style in ['day', 'hour', 'minute', 'forever', 'count']" :key="style" <label
class="relative inline-flex items-center group cursor-pointer"> v-for="style in ['day', 'hour', 'minute', 'forever', 'count']"
<input type="checkbox" :value="style" v-model="config.expireStyle" class="peer sr-only" /> :key="style"
<div class="px-4 py-2 rounded-full border-2 transition-all duration-200 select-none" :class="[ class="relative inline-flex items-center group cursor-pointer"
config.expireStyle.includes(style) >
? isDarkMode <input
? 'bg-indigo-600 border-indigo-600 text-white' type="checkbox"
: 'bg-indigo-600 border-indigo-600 text-white' :value="style"
: isDarkMode v-model="config.expireStyle"
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-indigo-500' class="peer sr-only"
: 'bg-white border-gray-300 text-gray-700 hover:border-indigo-500' />
]"> <div
class="px-4 py-2 rounded-full border-2 transition-all duration-200 select-none"
:class="[
config.expireStyle.includes(style)
? isDarkMode
? 'bg-indigo-600 border-indigo-600 text-white'
: 'bg-indigo-600 border-indigo-600 text-white'
: isDarkMode
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-indigo-500'
: 'bg-white border-gray-300 text-gray-700 hover:border-indigo-500'
]"
>
{{ t(`manage.settings.expiration.${style}`) }} {{ t(`manage.settings.expiration.${style}`) }}
</div> </div>
</label> </label>
@@ -432,24 +643,32 @@ onMounted(() => {
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label
class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
>
{{ t('manage.settings.maxSaveTime') }} {{ t('manage.settings.maxSaveTime') }}
</label> </label>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input type="number" v-model="saveTime" <input
type="number"
v-model="saveTime"
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="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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? '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' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" /> ]"
<select v-model="saveTimeUnit" />
<select
v-model="saveTimeUnit"
class="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="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="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500' ? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400' : 'border-gray-300 hover:border-gray-400'
]"> ]"
>
<option value="秒">{{ t('common.second') }}</option> <option value="秒">{{ t('common.second') }}</option>
<option value="分">{{ t('common.minute') }}</option> <option value="分">{{ t('common.minute') }}</option>
<option value="时">{{ t('common.hour') }}</option> <option value="时">{{ t('common.hour') }}</option>
@@ -470,7 +689,10 @@ onMounted(() => {
<!-- 错误限制 --> <!-- 错误限制 -->
<div class="mt-8"> <div class="mt-8">
<h3 class="text-lg font-medium mb-4" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"> <h3
class="text-lg font-medium mb-4"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']"
>
{{ t('manage.settings.errorLimits') }} {{ t('manage.settings.errorLimits') }}
</h3> </h3>
@@ -488,14 +710,6 @@ onMounted(() => {
/> />
</div> </div>
</div> </div>
<!-- 保存按钮 -->
<div class="flex justify-end mt-8">
<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>
</div>
</section> </section>
</div> </div>
</div> </div>