Merge branch 'feature/ux-compat-platform'
This commit is contained in:
@@ -12,6 +12,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
output
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
|
||||
@@ -110,6 +110,7 @@ const rules = [
|
||||
/^src\/utils\/auth-storage\.ts$/,
|
||||
/^src\/utils\/config-storage\.ts$/,
|
||||
/^src\/utils\/preference-storage\.ts$/,
|
||||
/^src\/utils\/record-storage\.ts$/,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+34
-3
@@ -5,7 +5,14 @@ import LanguageSwitcher from './components/common/LanguageSwitcher.vue'
|
||||
import AlertComponent from '@/components/common/AlertComponent.vue'
|
||||
import { useAppShell } from '@/composables'
|
||||
|
||||
const { isDarkMode, isLoading, route, showGlobalControls } = useAppShell()
|
||||
const {
|
||||
isDarkMode,
|
||||
isLoading,
|
||||
routeTransitionMode,
|
||||
routeTransitionName,
|
||||
routeViewKey,
|
||||
showGlobalControls
|
||||
} = useAppShell()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,8 +25,8 @@ const { isDarkMode, isLoading, route, showGlobalControls } = useAppShell()
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
<transition :name="routeTransitionName" :mode="routeTransitionMode">
|
||||
<component :is="Component" :key="routeViewKey" />
|
||||
</transition>
|
||||
</RouterView>
|
||||
|
||||
@@ -52,6 +59,30 @@ const { isDarkMode, isLoading, route, showGlobalControls } = useAppShell()
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-xl p-8 flex flex-col items-center justify-center border-2 border-dashed transition-all duration-300 group cursor-pointer relative"
|
||||
class="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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-800 bg-opacity-50 border-gray-600 hover:border-indigo-500'
|
||||
: 'bg-gray-100 border-gray-300 hover:border-indigo-500',
|
||||
? 'bg-gray-800/60 border-gray-600 hover:border-indigo-400'
|
||||
: '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
|
||||
]"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-busy="isUploading"
|
||||
@click="triggerFileUpload"
|
||||
@dragover.prevent
|
||||
@keydown.enter.prevent="triggerFileUpload"
|
||||
@keydown.space.prevent="triggerFileUpload"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleFileDrop"
|
||||
>
|
||||
<input
|
||||
@@ -20,6 +32,18 @@
|
||||
:disabled="isUploading"
|
||||
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">
|
||||
<BorderProgressBar :progress="progress" />
|
||||
</div>
|
||||
@@ -27,24 +51,20 @@
|
||||
<!-- 上传状态图标 -->
|
||||
<component
|
||||
: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
|
||||
: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
|
||||
? 'text-gray-400 group-hover:text-indigo-400'
|
||||
: 'text-gray-600 group-hover:text-indigo-600'
|
||||
]"
|
||||
>
|
||||
<span v-if="selectedFiles && selectedFiles.length > 1" class="block">
|
||||
<span
|
||||
v-for="(f, i) in selectedFiles"
|
||||
:key="i"
|
||||
class="block truncate"
|
||||
>{{ f.name }}</span>
|
||||
<span v-for="(f, i) in selectedFiles" :key="i" class="block truncate">{{ f.name }}</span>
|
||||
</span>
|
||||
<span v-else class="block truncate">
|
||||
{{ displayText }}
|
||||
@@ -52,12 +72,33 @@
|
||||
</p>
|
||||
|
||||
<!-- 状态描述或默认描述 -->
|
||||
<p :class="['mt-2 text-xs', statusDescriptionClass]">
|
||||
<p :class="['relative z-10 mt-2 text-xs text-center leading-5', statusDescriptionClass]">
|
||||
{{ statusDescription }}
|
||||
</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
|
||||
class="flex justify-between text-xs mb-1"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
@@ -78,7 +119,7 @@
|
||||
: 'bg-indigo-500 hover:bg-indigo-600 text-white'
|
||||
]"
|
||||
>
|
||||
{{ retryText }}
|
||||
{{ retryLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -136,7 +177,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
totalBytes: 0,
|
||||
errorMessage: '',
|
||||
allowRetry: true,
|
||||
retryText: '重试',
|
||||
retryText: '',
|
||||
showProgressDetails: true
|
||||
})
|
||||
|
||||
@@ -147,7 +188,9 @@ const isDarkMode = useInjectedDarkMode()
|
||||
// 使用computed属性处理多语言文本
|
||||
const placeholderText = computed(() => props.placeholder || t('send.uploadArea.placeholder'))
|
||||
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 isDragActive = ref(false)
|
||||
|
||||
const isUploading = computed(() => {
|
||||
return ['uploading', 'initializing', 'confirming'].includes(props.uploadStatus)
|
||||
@@ -162,7 +205,7 @@ const displayText = computed(() => {
|
||||
return props.selectedFiles[0].name
|
||||
}
|
||||
if (props.selectedFiles && props.selectedFiles.length > 1) {
|
||||
return `已选择 ${props.selectedFiles.length} 个文件`
|
||||
return t('send.uploadArea.selectedFiles', { count: props.selectedFiles.length })
|
||||
}
|
||||
if (props.selectedFile) {
|
||||
return props.selectedFile.name
|
||||
@@ -207,16 +250,16 @@ const statusDescription = computed(() => {
|
||||
return props.errorMessage
|
||||
}
|
||||
if (props.uploadStatus === 'initializing') {
|
||||
return '正在初始化上传...'
|
||||
return t('send.uploadArea.status.initializing')
|
||||
}
|
||||
if (props.uploadStatus === 'uploading') {
|
||||
return '正在上传文件...'
|
||||
return t('send.uploadArea.status.uploading')
|
||||
}
|
||||
if (props.uploadStatus === 'confirming') {
|
||||
return '正在确认上传...'
|
||||
return t('send.uploadArea.status.confirming')
|
||||
}
|
||||
if (isSuccess.value) {
|
||||
return '上传成功!'
|
||||
return t('send.uploadArea.status.success')
|
||||
}
|
||||
return descriptionText.value
|
||||
})
|
||||
@@ -245,6 +288,24 @@ const triggerFileUpload = () => {
|
||||
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 target = event.target as HTMLInputElement
|
||||
const files = target.files
|
||||
@@ -261,6 +322,7 @@ const handleFileUpload = (event: Event) => {
|
||||
|
||||
const handleFileDrop = (event: DragEvent) => {
|
||||
// 上传中不允许拖放
|
||||
isDragActive.value = false
|
||||
if (isUploading.value) return
|
||||
emit('fileDrop', event)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { useAdminFiles } from './useAdminFiles'
|
||||
export { useAdminLogin } from './useAdminLogin'
|
||||
export { useAdminSession } from './useAdminSession'
|
||||
export { useAppShell } from './useAppShell'
|
||||
export { useDashboardStats } from './useDashboardStats'
|
||||
export { useInjectedDarkMode } from './useInjectedDarkMode'
|
||||
|
||||
+1570
-18
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,13 @@ export function useAdminLogin() {
|
||||
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
|
||||
} catch (error: unknown) {
|
||||
alertStore.showAlert(getErrorMessage(error, '登录失败'), 'error')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,13 @@ export function useAppShell() {
|
||||
const { isLoading, setupRouteLoading } = useRouteLoading(router)
|
||||
const { syncPublicConfig } = usePublicConfigBootstrap()
|
||||
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
|
||||
|
||||
@@ -47,6 +54,9 @@ export function useAppShell() {
|
||||
isDarkMode,
|
||||
isLoading,
|
||||
route,
|
||||
routeTransitionMode,
|
||||
routeTransitionName,
|
||||
routeViewKey,
|
||||
showGlobalControls
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { reactive } from 'vue'
|
||||
import { computed, ref, reactive } from 'vue'
|
||||
import { StatsService } from '@/services'
|
||||
import type { DashboardViewData } from '@/types'
|
||||
import { formatFileSize } from '@/utils/common'
|
||||
import type { DashboardData, DashboardHealthSummary, DashboardViewData } from '@/types'
|
||||
import { formatFileSize, getErrorMessage } from '@/utils/common'
|
||||
|
||||
type UseDashboardStatsOptions = {
|
||||
loadFailedMessage?: string
|
||||
}
|
||||
|
||||
const emptyDashboardData = (): DashboardViewData => ({
|
||||
hasExtendedStats: false,
|
||||
@@ -23,8 +27,14 @@ const emptyDashboardData = (): DashboardViewData => ({
|
||||
openUpload: 0,
|
||||
enableChunk: 0,
|
||||
maxSaveSeconds: 0,
|
||||
topSuffixes: [],
|
||||
recentFiles: [],
|
||||
healthAttentionCount: 0,
|
||||
healthDangerCount: 0,
|
||||
healthWarningCount: 0,
|
||||
expiringSoonCount: 0,
|
||||
storageIssueCount: 0,
|
||||
neverRetrievedCount: 0,
|
||||
healthyCount: 0,
|
||||
permanentCount: 0,
|
||||
storageUsedText: '0 Bytes',
|
||||
yesterdaySizeText: '0 Bytes',
|
||||
todaySizeText: '0 Bytes',
|
||||
@@ -33,6 +43,8 @@ const emptyDashboardData = (): DashboardViewData => ({
|
||||
activeRatio: 0,
|
||||
textRatio: 0,
|
||||
fileRatio: 0,
|
||||
healthyRatio: 0,
|
||||
healthAttentionRatio: 0,
|
||||
todaySizeRatio: 0
|
||||
})
|
||||
|
||||
@@ -50,59 +62,119 @@ const formatDuration = (startTimestamp: number | null) => {
|
||||
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 isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const lastUpdatedAt = ref<Date | null>(null)
|
||||
const lastUpdatedText = computed(() =>
|
||||
lastUpdatedAt.value ? lastUpdatedAt.value.toLocaleString() : '-'
|
||||
)
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
const response = await StatsService.getDashboard()
|
||||
if (!response.detail) return
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
const detail = response.detail
|
||||
dashboardData.totalFiles = toNumber(detail.totalFiles)
|
||||
dashboardData.storageUsed = toNumber(detail.storageUsed)
|
||||
dashboardData.yesterdayCount = toNumber(detail.yesterdayCount)
|
||||
dashboardData.todayCount = toNumber(detail.todayCount)
|
||||
dashboardData.yesterdaySize = toNumber(detail.yesterdaySize)
|
||||
dashboardData.todaySize = toNumber(detail.todaySize)
|
||||
dashboardData.sysUptime = detail.sysUptime
|
||||
dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount')
|
||||
dashboardData.activeCount = dashboardData.hasExtendedStats
|
||||
? toNumber(detail.activeCount)
|
||||
: dashboardData.totalFiles
|
||||
dashboardData.expiredCount = toNumber(detail.expiredCount)
|
||||
dashboardData.textCount = toNumber(detail.textCount)
|
||||
dashboardData.fileCount = toNumber(detail.fileCount)
|
||||
dashboardData.chunkedCount = toNumber(detail.chunkedCount)
|
||||
dashboardData.usedCount = toNumber(detail.usedCount)
|
||||
dashboardData.storageBackend = detail.storageBackend || '-'
|
||||
dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit)
|
||||
dashboardData.openUpload = toNumber(detail.openUpload)
|
||||
dashboardData.enableChunk = toNumber(detail.enableChunk)
|
||||
dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds)
|
||||
dashboardData.topSuffixes = detail.topSuffixes || []
|
||||
dashboardData.recentFiles = detail.recentFiles || []
|
||||
try {
|
||||
const response = await StatsService.getDashboard()
|
||||
if (!response.detail) {
|
||||
throw new Error('No dashboard data')
|
||||
}
|
||||
|
||||
dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed)
|
||||
dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize)
|
||||
dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize)
|
||||
dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit)
|
||||
dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime)
|
||||
dashboardData.activeRatio = dashboardData.totalFiles
|
||||
? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100)
|
||||
: 0
|
||||
dashboardData.textRatio = dashboardData.totalFiles
|
||||
? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100)
|
||||
: 0
|
||||
dashboardData.fileRatio = dashboardData.totalFiles
|
||||
? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100)
|
||||
: 0
|
||||
dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit
|
||||
? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100)
|
||||
: 0
|
||||
const detail = response.detail
|
||||
dashboardData.totalFiles = toNumber(detail.totalFiles)
|
||||
dashboardData.storageUsed = toNumber(detail.storageUsed)
|
||||
dashboardData.yesterdayCount = toNumber(detail.yesterdayCount)
|
||||
dashboardData.todayCount = toNumber(detail.todayCount)
|
||||
dashboardData.yesterdaySize = toNumber(detail.yesterdaySize)
|
||||
dashboardData.todaySize = toNumber(detail.todaySize)
|
||||
dashboardData.sysUptime = detail.sysUptime
|
||||
dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount')
|
||||
dashboardData.activeCount = dashboardData.hasExtendedStats
|
||||
? toNumber(detail.activeCount)
|
||||
: dashboardData.totalFiles
|
||||
dashboardData.expiredCount = toNumber(detail.expiredCount)
|
||||
dashboardData.textCount = toNumber(detail.textCount)
|
||||
dashboardData.fileCount = toNumber(detail.fileCount)
|
||||
dashboardData.chunkedCount = toNumber(detail.chunkedCount)
|
||||
dashboardData.usedCount = toNumber(detail.usedCount)
|
||||
dashboardData.storageBackend = detail.storageBackend || '-'
|
||||
dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit)
|
||||
dashboardData.openUpload = toNumber(detail.openUpload)
|
||||
dashboardData.enableChunk = toNumber(detail.enableChunk)
|
||||
dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds)
|
||||
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 {
|
||||
dashboardData,
|
||||
fetchDashboardData
|
||||
errorMessage,
|
||||
fetchDashboardData,
|
||||
isLoading,
|
||||
lastUpdatedText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,10 +213,13 @@ export function usePresignedUpload(options: UsePresignedUploadOptions = {}) {
|
||||
presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING
|
||||
|
||||
const progressHandler = createProgressHandler(options?.onProgress)
|
||||
const uploadUrl =
|
||||
session.proxy_upload_url || session.upload_url || session.legacy_proxy_upload_url
|
||||
const response = await PresignUploadService.proxyUpload(
|
||||
session.upload_id,
|
||||
file,
|
||||
progressHandler
|
||||
progressHandler,
|
||||
uploadUrl
|
||||
)
|
||||
|
||||
if (response.code === 200 && response.detail?.code) {
|
||||
|
||||
@@ -13,7 +13,8 @@ export function usePublicConfigBootstrap() {
|
||||
return
|
||||
}
|
||||
|
||||
const notifyMessage = configStore.applyRemoteConfig(res.detail)
|
||||
configStore.applyPublicMeta(res.detail.meta)
|
||||
const notifyMessage = configStore.applyRemoteConfig(res.detail.config)
|
||||
if (notifyMessage) {
|
||||
alertStore.showAlert(notifyMessage, 'success')
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { FileService } from '@/services'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
import { useFileDataStore } from '@/stores/fileData'
|
||||
import type { ReceivedFileRecord } from '@/types'
|
||||
import type { ReceivedFileRecord, ShareMetadataResponse, ShareSelectResponse } from '@/types'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import { getErrorMessage } from '@/utils/common'
|
||||
import { renderMarkdownPreview } from '@/utils/content-preview'
|
||||
@@ -22,6 +22,9 @@ export function useRetrieveFlow() {
|
||||
const { receiveData: records } = storeToRefs(fileStore)
|
||||
|
||||
const code = ref('')
|
||||
const inspectedFile = ref<ShareMetadataResponse | null>(null)
|
||||
const isInspecting = ref(false)
|
||||
const isRetrieving = ref(false)
|
||||
const inputStatus = ref<InputStatus>({
|
||||
readonly: false,
|
||||
loading: false
|
||||
@@ -31,6 +34,9 @@ export function useRetrieveFlow() {
|
||||
const showDrawer = ref(false)
|
||||
const showPreview = ref(false)
|
||||
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) => {
|
||||
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]
|
||||
}
|
||||
|
||||
const createRecord = (detail: {
|
||||
code: string
|
||||
name: string
|
||||
text: string
|
||||
size: number
|
||||
}): ReceivedFileRecord => {
|
||||
const isFile = detail.text.startsWith('/share/download') || detail.name !== 'Text'
|
||||
const createRecord = (detail: ShareSelectResponse): ReceivedFileRecord => {
|
||||
const isText =
|
||||
typeof detail.is_text === 'boolean'
|
||||
? detail.is_text
|
||||
: detail.type
|
||||
? detail.type === '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 {
|
||||
id: Date.now(),
|
||||
code: detail.code,
|
||||
filename: detail.name,
|
||||
size: formatFileSize(detail.size),
|
||||
downloadUrl: isFile ? detail.text : null,
|
||||
content: isFile ? null : detail.text,
|
||||
date: new Date().toLocaleString()
|
||||
downloadUrl,
|
||||
content,
|
||||
date: new Date().toLocaleString(),
|
||||
type: isText ? 'text' : 'file',
|
||||
remainingDownloads: detail.remaining_downloads
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (code.value.length !== 5) {
|
||||
const resetInspection = () => {
|
||||
inspectedFile.value = null
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const inspectCode = async () => {
|
||||
if (!hasValidCode.value) {
|
||||
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
isInspecting.value = true
|
||||
inputStatus.value.readonly = true
|
||||
inputStatus.value.loading = true
|
||||
error.value = ''
|
||||
|
||||
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) {
|
||||
const newFileData = createRecord(res.detail)
|
||||
if (!fileStore.receiveData.some((file) => file.code === newFileData.code)) {
|
||||
fileStore.addReceiveData(newFileData)
|
||||
const existingIndex = fileStore.receiveData.findIndex(
|
||||
(file) => file.code === newFileData.code
|
||||
)
|
||||
if (existingIndex !== -1) {
|
||||
fileStore.deleteReceiveData(existingIndex)
|
||||
}
|
||||
fileStore.addReceiveData(newFileData)
|
||||
selectedRecord.value = newFileData
|
||||
if (newFileData.content) {
|
||||
showPreview.value = true
|
||||
}
|
||||
inspectedFile.value = null
|
||||
alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
|
||||
} 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) {
|
||||
const errorMessage = getErrorMessage(err, t('retrieve.messages.unknownError'))
|
||||
error.value = errorMessage
|
||||
alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
|
||||
} finally {
|
||||
isRetrieving.value = false
|
||||
inputStatus.value.readonly = false
|
||||
inputStatus.value.loading = false
|
||||
code.value = ''
|
||||
@@ -151,8 +208,23 @@ export function useRetrieveFlow() {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(normalizedCode, (nextCode) => {
|
||||
if (inspectedFile.value && inspectedFile.value.code !== nextCode) {
|
||||
resetInspection()
|
||||
}
|
||||
|
||||
if (nextCode.length < 5 && error.value) {
|
||||
error.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
code,
|
||||
inspectedFile,
|
||||
isInspecting,
|
||||
isRetrieving,
|
||||
isWorking,
|
||||
hasValidCode,
|
||||
inputStatus,
|
||||
error,
|
||||
records,
|
||||
@@ -166,6 +238,8 @@ export function useRetrieveFlow() {
|
||||
deleteRecord,
|
||||
downloadRecord,
|
||||
handleSubmit,
|
||||
inspectCode,
|
||||
resetInspection,
|
||||
showContentPreview,
|
||||
toggleDrawer,
|
||||
viewDetails
|
||||
|
||||
@@ -32,8 +32,10 @@ export function useSendFlow() {
|
||||
const isSubmitting = ref(false)
|
||||
const fileHash = ref('')
|
||||
const sendRecords = computed(() => fileDataStore.shareData)
|
||||
const uploadDescription = computed(
|
||||
() => `支持各种常见格式,最大${getStorageUnit(config.value.uploadSize)}`
|
||||
const uploadDescription = computed(() =>
|
||||
t('send.uploadArea.descriptionWithLimit', {
|
||||
size: getStorageUnit(config.value.uploadSize)
|
||||
})
|
||||
)
|
||||
const expirationOptions = computed(() =>
|
||||
config.value.expireStyle.map((value) => ({
|
||||
|
||||
@@ -21,15 +21,54 @@ export function useSystemConfig() {
|
||||
|
||||
// 状态管理
|
||||
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 sizeUnit = ref<FileSizeUnit>('MB')
|
||||
const saveTime = ref(1)
|
||||
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 => {
|
||||
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> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
isRefreshing.value = true
|
||||
|
||||
const response = await ConfigService.getConfig()
|
||||
|
||||
if (response.code === 200 && response.detail) {
|
||||
config.value = { ...DEFAULT_CONFIG_STATE, ...response.detail }
|
||||
config.value = normalizeEditableConfig(response.detail)
|
||||
const notifyMessage = configStore.applyRemoteConfig(config.value)
|
||||
if (notifyMessage) {
|
||||
alertStore.showAlert(notifyMessage, 'success')
|
||||
@@ -66,19 +105,21 @@ export function useSystemConfig() {
|
||||
alertStore.showAlert(getErrorMessage(error, '获取配置失败'), 'error')
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新系统配置
|
||||
const updateConfig = async (newConfig: Partial<ConfigState>): Promise<boolean> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
isSaving.value = true
|
||||
|
||||
const response = await ConfigService.updateConfig(newConfig)
|
||||
|
||||
if (response.code === 200) {
|
||||
config.value = { ...config.value, ...newConfig }
|
||||
config.value = normalizeEditableConfig({ ...config.value, ...newConfig })
|
||||
syncConfigForm(config.value)
|
||||
markConfigSaved()
|
||||
saveConfigToStorage(config.value)
|
||||
alertStore.showAlert('配置更新成功!', 'success')
|
||||
return true
|
||||
@@ -89,7 +130,7 @@ export function useSystemConfig() {
|
||||
alertStore.showAlert(getErrorMessage(error, '更新配置失败'), 'error')
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,17 +152,17 @@ export function useSystemConfig() {
|
||||
const latestConfig = await fetchConfig()
|
||||
if (latestConfig) {
|
||||
syncConfigForm(latestConfig)
|
||||
markConfigSaved()
|
||||
}
|
||||
}
|
||||
|
||||
const submitConfig = () =>
|
||||
updateConfig(
|
||||
buildConfigSubmitPayload(
|
||||
config.value,
|
||||
{ value: fileSize.value, unit: sizeUnit.value },
|
||||
{ value: saveTime.value, unit: saveTimeUnit.value }
|
||||
)
|
||||
)
|
||||
const submitConfig = () => {
|
||||
if (!isDirty.value || isSaving.value) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
return updateConfig(buildSubmitPayload())
|
||||
}
|
||||
|
||||
// 初始化配置
|
||||
const initConfig = async () => {
|
||||
@@ -129,10 +170,12 @@ export function useSystemConfig() {
|
||||
const storedConfig = getStoredConfig()
|
||||
if (storedConfig) {
|
||||
config.value = storedConfig
|
||||
syncConfigForm(storedConfig)
|
||||
markConfigSaved()
|
||||
}
|
||||
|
||||
// 然后从服务器获取最新配置
|
||||
await fetchConfig()
|
||||
await refreshConfig()
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
@@ -148,6 +191,9 @@ export function useSystemConfig() {
|
||||
// 状态
|
||||
config,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
isSaving,
|
||||
isDirty,
|
||||
fileSize,
|
||||
sizeUnit,
|
||||
saveTime,
|
||||
|
||||
+326
-28
@@ -65,8 +65,14 @@ export default {
|
||||
yesterdayShares: 'Yesterday: {count}',
|
||||
serverUptime: 'Uptime',
|
||||
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',
|
||||
fileHealthDesc: 'Real file records grouped by availability, expiry, and type.',
|
||||
fileHealthDesc: 'Status insights for available, risky, and pending file queues.',
|
||||
activeFileRatio: 'Active Ratio',
|
||||
fileShareRatio: 'File Ratio',
|
||||
textShareRatio: 'Text Ratio',
|
||||
@@ -83,16 +89,27 @@ export default {
|
||||
maxSaveTime: 'Max Retention',
|
||||
noSaveLimit: 'Unlimited',
|
||||
todayCapacityReference: 'Today Size / Single File Limit',
|
||||
fileTypeDistribution: 'Type Distribution',
|
||||
textType: 'Text',
|
||||
recentFiles: 'Recent Shares',
|
||||
recentFilesDesc: 'Recently created share records for quick status checks.',
|
||||
available: 'Available',
|
||||
table: {
|
||||
file: 'File',
|
||||
size: 'Size',
|
||||
usage: 'Retrievals',
|
||||
status: 'Status'
|
||||
healthActions: {
|
||||
attention: {
|
||||
title: 'Needs Attention',
|
||||
description: 'Handle issue and warning files together'
|
||||
},
|
||||
storageIssue: {
|
||||
title: 'Storage Issues',
|
||||
description: 'Review files missing download metadata'
|
||||
},
|
||||
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: {
|
||||
@@ -202,7 +219,39 @@ export default {
|
||||
invalidCodeError: 'Invalid retrieval code',
|
||||
retrieveFailure: 'Failed to retrieve file: ',
|
||||
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',
|
||||
textInput: 'Enter text to send here...',
|
||||
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',
|
||||
submitting: 'Sending...',
|
||||
needRetrieveFile: 'Need to retrieve? Click here',
|
||||
sendRecords: 'Send Records',
|
||||
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',
|
||||
expirationMethod: 'Expiration Method',
|
||||
expiration: {
|
||||
@@ -311,24 +392,246 @@ export default {
|
||||
// File Management
|
||||
fileManage: {
|
||||
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',
|
||||
detail: 'Details',
|
||||
detailTitle: 'File Details',
|
||||
detailLoading: 'Loading details...',
|
||||
detailFailed: 'Failed to load details',
|
||||
editFileInfo: 'Edit File Information',
|
||||
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',
|
||||
textPreview: 'Text Preview',
|
||||
copyText: 'Copy Text',
|
||||
copyCode: 'Copy Code',
|
||||
copyLink: 'Copy Link',
|
||||
copySuccess: 'Text copied to clipboard',
|
||||
copyCodeSuccess: 'Retrieve code copied to clipboard',
|
||||
copyLinkSuccess: 'Retrieve link copied to clipboard',
|
||||
copyFailed: 'Copy failed, please try again',
|
||||
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: {
|
||||
select: 'Select',
|
||||
code: 'Retrieve Code',
|
||||
name: 'Name',
|
||||
type: 'Type',
|
||||
size: 'Size',
|
||||
usage: 'Retrievals',
|
||||
status: 'Status',
|
||||
description: 'Description',
|
||||
expiration: 'Expiration',
|
||||
actions: 'Actions'
|
||||
},
|
||||
sort: {
|
||||
createdAt: 'Created Time',
|
||||
expiredAt: 'Expiration',
|
||||
name: 'Name',
|
||||
size: 'Size',
|
||||
usedCount: 'Retrievals',
|
||||
code: 'Retrieve Code',
|
||||
desc: 'Descending',
|
||||
asc: 'Ascending'
|
||||
},
|
||||
form: {
|
||||
code: 'Retrieve Code',
|
||||
codePlaceholder: 'Enter retrieve code',
|
||||
@@ -414,7 +717,13 @@ export default {
|
||||
mb: 'MB',
|
||||
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: {
|
||||
title: 'System Settings',
|
||||
@@ -494,7 +803,7 @@ export default {
|
||||
serverUptime: 'Uptime',
|
||||
refresh: 'Refresh',
|
||||
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',
|
||||
fileShareRatio: 'File Ratio',
|
||||
textShareRatio: 'Text Ratio',
|
||||
@@ -510,18 +819,7 @@ export default {
|
||||
guestUpload: 'Guest Upload',
|
||||
maxSaveTime: 'Max Retention',
|
||||
noSaveLimit: 'Unlimited',
|
||||
todayCapacityReference: 'Today Size / Single File Limit',
|
||||
fileTypeDistribution: 'Type Distribution',
|
||||
textType: 'Text',
|
||||
recentFiles: 'Recent Shares',
|
||||
recentFilesDesc: 'Recently created share records for quick status checks.',
|
||||
available: 'Available',
|
||||
table: {
|
||||
file: 'File',
|
||||
size: 'Size',
|
||||
usage: 'Retrievals',
|
||||
status: 'Status'
|
||||
}
|
||||
todayCapacityReference: 'Today Size / Single File Limit'
|
||||
},
|
||||
fileManage: {
|
||||
title: 'File Management',
|
||||
|
||||
+323
-28
@@ -65,8 +65,14 @@ export default {
|
||||
yesterdayShares: '昨日分享:{count}',
|
||||
serverUptime: '运行时间',
|
||||
refresh: '刷新数据',
|
||||
refreshing: '刷新中',
|
||||
lastUpdated: '最近更新:{time}',
|
||||
loadFailed: '仪表盘数据加载失败',
|
||||
footerProduct: 'FileCodeBox 管理后台',
|
||||
runtimeVersion: '运行版本',
|
||||
versionPending: '待同步',
|
||||
fileHealth: '文件健康',
|
||||
fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。',
|
||||
fileHealthDesc: '基于状态洞察统计可取件、风险与待处理队列。',
|
||||
activeFileRatio: '有效占比',
|
||||
fileShareRatio: '文件占比',
|
||||
textShareRatio: '文本占比',
|
||||
@@ -83,16 +89,27 @@ export default {
|
||||
maxSaveTime: '最长保存',
|
||||
noSaveLimit: '不限制',
|
||||
todayCapacityReference: '今日容量 / 单文件上限',
|
||||
fileTypeDistribution: '类型分布',
|
||||
textType: '文本',
|
||||
recentFiles: '最近分享',
|
||||
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
||||
available: '可取件',
|
||||
table: {
|
||||
file: '文件',
|
||||
size: '大小',
|
||||
usage: '取件',
|
||||
status: '状态'
|
||||
healthActions: {
|
||||
attention: {
|
||||
title: '需关注',
|
||||
description: '集中处理异常和预警文件'
|
||||
},
|
||||
storageIssue: {
|
||||
title: '存储异常',
|
||||
description: '检查缺少下载信息的文件'
|
||||
},
|
||||
expiringSoon: {
|
||||
title: '即将过期',
|
||||
description: '优先续期仍需保留的分享'
|
||||
},
|
||||
neverRetrieved: {
|
||||
title: '未取件',
|
||||
description: '识别创建后尚未被取件的记录'
|
||||
},
|
||||
permanent: {
|
||||
title: '永久有效',
|
||||
description: '查看长期保留的分享记录'
|
||||
}
|
||||
}
|
||||
},
|
||||
fileManage: {
|
||||
@@ -202,7 +219,37 @@ export default {
|
||||
invalidCodeError: '无效的取件码',
|
||||
retrieveFailure: '获取文件失败:',
|
||||
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: '点击选择文件',
|
||||
textInput: '在此输入要发送的文本...',
|
||||
placeholder: '点击或拖放文件到此处上传',
|
||||
description: '支持各种常见格式'
|
||||
description: '支持各种常见格式',
|
||||
descriptionWithLimit: '支持各种常见格式,最大{size}',
|
||||
retry: '重试',
|
||||
selectedFiles: '已选择 {count} 个文件',
|
||||
status: {
|
||||
initializing: '正在初始化上传...',
|
||||
uploading: '正在上传文件...',
|
||||
confirming: '正在确认上传...',
|
||||
success: '上传成功!'
|
||||
}
|
||||
},
|
||||
submit: '安全寄送',
|
||||
submitting: '发送中...',
|
||||
needRetrieveFile: '需要取件?点击这里',
|
||||
sendRecords: '发件记录',
|
||||
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: {
|
||||
title: '文件详情',
|
||||
content: '文件内容',
|
||||
@@ -311,24 +390,245 @@ export default {
|
||||
// 文件管理
|
||||
fileManage: {
|
||||
title: '文件管理',
|
||||
searchPlaceholder: '搜索文件名称、描述...',
|
||||
subtitle: '共 {count} 条分享记录,可按状态、类型和使用情况快速筛选。',
|
||||
searchPlaceholder: '搜索取件码、名称或文本...',
|
||||
allFiles: '所有文件',
|
||||
detail: '详情',
|
||||
detailTitle: '文件详情',
|
||||
detailLoading: '正在加载详情...',
|
||||
detailFailed: '详情加载失败',
|
||||
editFileInfo: '编辑文件信息',
|
||||
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: '查看',
|
||||
textPreview: '文本预览',
|
||||
copyText: '复制文本',
|
||||
copyCode: '复制取件码',
|
||||
copyLink: '复制取件链接',
|
||||
copySuccess: '文本已复制到剪贴板',
|
||||
copyCodeSuccess: '取件码已复制到剪贴板',
|
||||
copyLinkSuccess: '取件链接已复制到剪贴板',
|
||||
copyFailed: '复制失败,请重试',
|
||||
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: {
|
||||
select: '选择',
|
||||
code: '取件码',
|
||||
name: '名称',
|
||||
type: '类型',
|
||||
size: '大小',
|
||||
usage: '取件',
|
||||
status: '状态',
|
||||
description: '描述',
|
||||
expiration: '过期时间',
|
||||
actions: '操作'
|
||||
},
|
||||
sort: {
|
||||
createdAt: '创建时间',
|
||||
expiredAt: '过期时间',
|
||||
name: '名称',
|
||||
size: '大小',
|
||||
usedCount: '取件次数',
|
||||
code: '取件码',
|
||||
desc: '降序',
|
||||
asc: '升序'
|
||||
},
|
||||
form: {
|
||||
code: '取件码',
|
||||
codePlaceholder: '输入取件码',
|
||||
@@ -445,7 +745,13 @@ export default {
|
||||
errorLimits: '访问保护',
|
||||
errorPerMinute: '检测时间窗口(在此时间内统计错误次数)',
|
||||
errorCountLimit: '允许错误次数(超过后临时封禁)',
|
||||
saveChanges: '保存设置'
|
||||
saveChanges: '保存设置',
|
||||
refreshConfig: '刷新配置',
|
||||
refreshing: '刷新中',
|
||||
saving: '保存中',
|
||||
unsavedChanges: '有未保存的配置变更',
|
||||
allChangesSaved: '所有配置已保存',
|
||||
refreshBlocked: '请先保存当前变更再刷新'
|
||||
},
|
||||
dashboard: {
|
||||
title: '仪表盘',
|
||||
@@ -459,7 +765,7 @@ export default {
|
||||
serverUptime: '运行时间',
|
||||
refresh: '刷新数据',
|
||||
fileHealth: '文件健康',
|
||||
fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。',
|
||||
fileHealthDesc: '基于状态洞察统计可取件、风险与待处理队列。',
|
||||
activeFileRatio: '有效占比',
|
||||
fileShareRatio: '文件占比',
|
||||
textShareRatio: '文本占比',
|
||||
@@ -475,18 +781,7 @@ export default {
|
||||
guestUpload: '游客上传',
|
||||
maxSaveTime: '最长保存',
|
||||
noSaveLimit: '不限制',
|
||||
todayCapacityReference: '今日容量 / 单文件上限',
|
||||
fileTypeDistribution: '类型分布',
|
||||
textType: '文本',
|
||||
recentFiles: '最近分享',
|
||||
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
||||
available: '可取件',
|
||||
table: {
|
||||
file: '文件',
|
||||
size: '大小',
|
||||
usage: '取件',
|
||||
status: '状态'
|
||||
}
|
||||
todayCapacityReference: '今日容量 / 单文件上限'
|
||||
},
|
||||
fileManage: {
|
||||
title: '文件管理',
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<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']"
|
||||
>
|
||||
<!-- Sidebar -->
|
||||
<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="[
|
||||
isDarkMode ? 'bg-gray-800 bg-opacity-90 backdrop-filter backdrop-blur-xl ' : 'bg-white ',
|
||||
{ '-translate-x-full': !isSidebarOpen }
|
||||
isDarkMode
|
||||
? '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区域 -->
|
||||
@@ -46,18 +49,18 @@
|
||||
<li v-for="item in menuItems" :key="item.id">
|
||||
<RouterLink
|
||||
: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="[
|
||||
route.name === item.id
|
||||
? isDarkMode
|
||||
? 'bg-indigo-900 text-indigo-400'
|
||||
: 'bg-indigo-100 text-indigo-600'
|
||||
? 'border-indigo-400 bg-gray-700/70 text-indigo-200'
|
||||
: 'border-indigo-500 bg-gray-100 text-indigo-700'
|
||||
: isDarkMode
|
||||
? 'text-gray-400 hover:bg-gray-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
? 'border-transparent text-gray-400 hover:bg-gray-700'
|
||||
: '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 }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
@@ -82,7 +85,7 @@
|
||||
</aside>
|
||||
|
||||
<!-- 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
|
||||
class="shadow-md border-b transition-colors duration-300 h-16"
|
||||
@@ -97,7 +100,7 @@
|
||||
|
||||
<!-- Content -->
|
||||
<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']"
|
||||
>
|
||||
<router-view />
|
||||
@@ -120,7 +123,7 @@ import {
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ROUTE_NAMES, ROUTES } from '@/constants'
|
||||
import { useAdminStore } from '@/stores/adminStore'
|
||||
import { useAdminSession } from '@/composables'
|
||||
|
||||
interface MenuItem {
|
||||
id: string
|
||||
@@ -133,7 +136,7 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const adminStore = useAdminStore()
|
||||
const { verifySession, logout } = useAdminSession()
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: ROUTE_NAMES.DASHBOARD,
|
||||
@@ -172,6 +175,11 @@ const handleResize = () => {
|
||||
onMounted(() => {
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
void verifySession().then((isValid) => {
|
||||
if (!isValid) {
|
||||
void router.push(ROUTES.LOGIN)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -179,9 +187,9 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
// 登出处理
|
||||
const handleLogout = () => {
|
||||
adminStore.logout()
|
||||
router.push(ROUTES.LOGIN)
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
await router.push(ROUTES.LOGIN)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+3
-2
@@ -5,13 +5,14 @@ import { readStoredToken } from '@/utils/auth-storage'
|
||||
|
||||
const publicPageMeta = {
|
||||
showGlobalControls: true,
|
||||
showRouteLoading: true
|
||||
showRouteLoading: false,
|
||||
routeTransition: 'transfer-fade'
|
||||
}
|
||||
|
||||
const adminPageMeta = {
|
||||
requiresAuth: true,
|
||||
showGlobalControls: false,
|
||||
showRouteLoading: true
|
||||
showRouteLoading: false
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
|
||||
+39
-3
@@ -1,13 +1,49 @@
|
||||
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 {
|
||||
static async getConfig(): Promise<ApiResponse<ConfigState>> {
|
||||
return api.get('/admin/config/get')
|
||||
}
|
||||
|
||||
static async getUserConfig(): Promise<ApiResponse<ConfigState>> {
|
||||
return api.post('/')
|
||||
static async getUserConfig(): Promise<ApiResponse<PublicConfigPayload>> {
|
||||
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> {
|
||||
|
||||
+134
-6
@@ -1,6 +1,20 @@
|
||||
import api, { rawApiClient } from './client'
|
||||
import { multipartUploadConfig } from './shared'
|
||||
import type {
|
||||
AdminBatchDeleteFilesResponse,
|
||||
AdminBatchPolicyActionRequest,
|
||||
AdminBatchPolicyActionResponse,
|
||||
AdminBatchUpdateFilesRequest,
|
||||
AdminBatchUpdateFilesResponse,
|
||||
AdminFilePatchPayload,
|
||||
AdminFileDetailResponse,
|
||||
AdminFileListParams,
|
||||
AdminFileMetadataRequest,
|
||||
AdminFilePolicyActionRequest,
|
||||
AdminFilePreviewResponse,
|
||||
AdminFileViewPreset,
|
||||
AdminFileViewPresetRequest,
|
||||
AdminFileViewPresetsResponse,
|
||||
ApiResponse,
|
||||
ChunkUploadCompleteRequest,
|
||||
ChunkUploadInitRequest,
|
||||
@@ -10,6 +24,7 @@ import type {
|
||||
FileInfo,
|
||||
FileListResponse,
|
||||
FileUploadResponse,
|
||||
ShareMetadataResponse,
|
||||
ShareSelectResponse,
|
||||
TextSendResponse,
|
||||
UploadProgress
|
||||
@@ -29,6 +44,11 @@ const toUrlEncodedForm = (data: Record<string, string | number>) => {
|
||||
return form
|
||||
}
|
||||
|
||||
const isMethodFallbackError = (error: unknown) => {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
return status === 404 || status === 405
|
||||
}
|
||||
|
||||
export class FileService {
|
||||
static async uploadFile(
|
||||
file: File,
|
||||
@@ -100,6 +120,10 @@ export class FileService {
|
||||
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>> {
|
||||
return api.get(`/file/${code}`)
|
||||
}
|
||||
@@ -111,24 +135,116 @@ export class FileService {
|
||||
return response.data
|
||||
}
|
||||
|
||||
static async getAdminFileList(params: {
|
||||
page: number
|
||||
size: number
|
||||
keyword?: string
|
||||
}): Promise<ApiResponse<FileListResponse>> {
|
||||
static async getAdminFileList(
|
||||
params: AdminFileListParams
|
||||
): Promise<ApiResponse<FileListResponse>> {
|
||||
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)
|
||||
}
|
||||
|
||||
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> {
|
||||
return api.delete('/admin/file/delete', {
|
||||
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(
|
||||
id: number
|
||||
): Promise<{ data: Blob; headers: Record<string, string> }> {
|
||||
@@ -141,4 +257,16 @@ export class FileService {
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,17 @@ export class PresignUploadService {
|
||||
static async proxyUpload(
|
||||
uploadId: string,
|
||||
file: File,
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
onProgress?: (progress: UploadProgress) => void,
|
||||
uploadUrl?: string
|
||||
): Promise<ApiResponse<PresignUploadResult>> {
|
||||
const formData = new FormData()
|
||||
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(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ConfigState } from '@/types'
|
||||
import type { ConfigState, PublicConfigMeta } from '@/types'
|
||||
import {
|
||||
DEFAULT_PUBLIC_CONFIG,
|
||||
readNotifyKey,
|
||||
@@ -16,8 +16,10 @@ export const useConfigStore = defineStore('config', () => {
|
||||
...DEFAULT_PUBLIC_CONFIG,
|
||||
...toPublicConfig(readStoredConfig<Partial<ConfigState>>())
|
||||
})
|
||||
const publicMeta = ref<PublicConfigMeta>({})
|
||||
|
||||
const uploadSizeLimit = computed(() => config.value.uploadSize)
|
||||
const appVersion = computed(() => publicMeta.value.version || '')
|
||||
|
||||
const updateConfig = (nextConfig: Partial<ConfigState>) => {
|
||||
config.value = {
|
||||
@@ -45,6 +47,15 @@ export const useConfigStore = defineStore('config', () => {
|
||||
return `${notifyTitle}: ${notifyContent}`
|
||||
}
|
||||
|
||||
const applyPublicMeta = (nextMeta: PublicConfigMeta | undefined) => {
|
||||
if (!nextMeta) return
|
||||
|
||||
publicMeta.value = {
|
||||
...publicMeta.value,
|
||||
...nextMeta
|
||||
}
|
||||
}
|
||||
|
||||
const reloadStoredConfig = () => {
|
||||
config.value = {
|
||||
...DEFAULT_PUBLIC_CONFIG,
|
||||
@@ -53,8 +64,11 @@ export const useConfigStore = defineStore('config', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
appVersion,
|
||||
config,
|
||||
publicMeta,
|
||||
uploadSizeLimit,
|
||||
applyPublicMeta,
|
||||
applyRemoteConfig,
|
||||
updateConfig,
|
||||
reloadStoredConfig
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
message?: string
|
||||
msg?: string
|
||||
detail?: T
|
||||
}
|
||||
|
||||
|
||||
@@ -2,4 +2,6 @@ export interface AdminUser {
|
||||
id: string
|
||||
username: string
|
||||
token: string
|
||||
token_type?: string
|
||||
expires_at?: number
|
||||
}
|
||||
|
||||
@@ -15,6 +15,18 @@ export interface ThemeChoice {
|
||||
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 {
|
||||
name: string
|
||||
description: string
|
||||
|
||||
+89
-45
@@ -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 {
|
||||
totalFiles: number
|
||||
storageUsed: number
|
||||
storageUsed: number | string
|
||||
yesterdayCount: number
|
||||
todayCount: number
|
||||
yesterdaySize: number
|
||||
todaySize: number
|
||||
yesterdaySize: number | string
|
||||
todaySize: number | string
|
||||
sysUptime: number | null
|
||||
activeCount: number
|
||||
expiredCount: number
|
||||
textCount: number
|
||||
fileCount: number
|
||||
chunkedCount: number
|
||||
usedCount: number
|
||||
storageBackend: string
|
||||
uploadSizeLimit: number
|
||||
openUpload: number
|
||||
enableChunk: number
|
||||
maxSaveSeconds: number
|
||||
topSuffixes: DashboardSuffixStat[]
|
||||
recentFiles: DashboardRecentFile[]
|
||||
activeCount?: number
|
||||
expiredCount?: number
|
||||
textCount?: number
|
||||
fileCount?: number
|
||||
chunkedCount?: number
|
||||
usedCount?: number
|
||||
storageBackend?: string
|
||||
uploadSizeLimit?: number
|
||||
openUpload?: number
|
||||
enableChunk?: number
|
||||
maxSaveSeconds?: number
|
||||
healthAttentionCount?: number
|
||||
healthDangerCount?: number
|
||||
healthWarningCount?: number
|
||||
expiringSoonCount?: number
|
||||
storageIssueCount?: number
|
||||
neverRetrievedCount?: number
|
||||
healthyCount?: number
|
||||
permanentCount?: number
|
||||
healthSummary?: Partial<DashboardHealthSummary>
|
||||
}
|
||||
|
||||
export interface DashboardSuffixStat {
|
||||
suffix: string
|
||||
export type DashboardViewData = Omit<
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
health: AdminFileHealthFilter
|
||||
tone: 'danger' | 'warning' | 'success' | 'neutral'
|
||||
}
|
||||
|
||||
+385
-1
@@ -16,15 +16,127 @@ export interface FileListItem {
|
||||
size: number
|
||||
text?: string
|
||||
description?: string
|
||||
expired_at: string
|
||||
expired_at: string | null
|
||||
expired_count: number | null
|
||||
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 {
|
||||
displayName: string
|
||||
displaySize: string
|
||||
displayExpiredAt: string
|
||||
displayUsage: string
|
||||
displayHealthState: string
|
||||
displayHealthAction: string
|
||||
isTextFile: boolean
|
||||
isExpiredFile: boolean
|
||||
isChunkedFile: boolean
|
||||
remainingDownloadsValue: number | null
|
||||
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 {
|
||||
@@ -36,11 +148,257 @@ export interface FileEditForm {
|
||||
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 {
|
||||
data: FileListItem[]
|
||||
total: number
|
||||
page: 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 {
|
||||
@@ -57,6 +415,30 @@ export interface ShareSelectResponse {
|
||||
name: string
|
||||
text: string
|
||||
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 {
|
||||
@@ -67,6 +449,8 @@ export interface ReceivedFileRecord {
|
||||
downloadUrl: string | null
|
||||
content: string | null
|
||||
date: string
|
||||
type?: 'file' | 'text'
|
||||
remainingDownloads?: number | null
|
||||
}
|
||||
|
||||
export interface SentFileRecord {
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface PresignInitRequest {
|
||||
export interface PresignInitResponse {
|
||||
upload_id: string
|
||||
upload_url: string
|
||||
proxy_upload_url?: string
|
||||
legacy_proxy_upload_url?: string
|
||||
mode: PresignUploadMode
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { saveAs } from 'file-saver'
|
||||
import type { ReceivedFileRecord } from '@/types'
|
||||
import type { AdminFileViewItem, ApiResponse, ReceivedFileRecord } from '@/types'
|
||||
import { buildDownloadUrl } from '@/utils/share-url'
|
||||
|
||||
export function downloadReceivedRecord(record: ReceivedFileRecord): void {
|
||||
@@ -13,3 +13,67 @@ export function downloadReceivedRecord(record: ReceivedFileRecord): void {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 本地存储不可用时保持内存记录,不影响当前会话。
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
<transition name="fade" mode="out-in">
|
||||
<div v-if="sendType === 'file'" key="file" class="grid grid-cols-1 gap-8">
|
||||
<FileUploadArea
|
||||
class="payload-panel"
|
||||
:selected-file="selectedFile"
|
||||
:selected-files="selectedFiles"
|
||||
:progress="uploadProgress"
|
||||
@@ -32,7 +33,11 @@
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</transition>
|
||||
<ExpirationSelector
|
||||
@@ -202,6 +207,15 @@ const toRetrieve = () => {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
:deep(.payload-panel) {
|
||||
height: 12rem;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
:deep(.payload-panel textarea) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
select option {
|
||||
padding: 8px;
|
||||
margin: 4px;
|
||||
|
||||
+175
-131
@@ -1,27 +1,43 @@
|
||||
<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>
|
||||
<p class="text-sm" :class="[mutedTextClass]">FileCodeBox Admin</p>
|
||||
<h2 class="text-2xl font-bold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.lastUpdated', { time: lastUpdatedText }) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isLoading"
|
||||
@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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-800 text-gray-200 hover:bg-gray-700'
|
||||
: 'bg-white text-gray-700 shadow-sm hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
<RefreshCwIcon class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.dashboard.refresh') }}
|
||||
<RefreshCwIcon class="mr-2 h-4 w-4" :class="{ 'animate-spin': isLoading }" />
|
||||
{{ isLoading ? t('admin.dashboard.refreshing') : t('admin.dashboard.refresh') }}
|
||||
</button>
|
||||
</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">
|
||||
<StatCard
|
||||
:title="t('admin.dashboard.totalFiles')"
|
||||
@@ -79,7 +95,10 @@
|
||||
{{ t('admin.dashboard.fileHealthDesc') }}
|
||||
</p>
|
||||
</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 class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
@@ -103,6 +122,31 @@
|
||||
/>
|
||||
</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="rounded-lg border p-4" :class="[subtlePanelClass]">
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
@@ -157,10 +201,7 @@
|
||||
:label="t('admin.dashboard.guestUpload')"
|
||||
:value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')"
|
||||
/>
|
||||
<PolicyRow
|
||||
:label="t('admin.dashboard.maxSaveTime')"
|
||||
:value="maxSaveTimeText"
|
||||
/>
|
||||
<PolicyRow :label="t('admin.dashboard.maxSaveTime')" :value="maxSaveTimeText" />
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
@@ -168,7 +209,10 @@
|
||||
<span :class="[mutedTextClass]">{{ t('admin.dashboard.todayCapacityReference') }}</span>
|
||||
<span :class="[primaryTextClass]">{{ dashboardData.todaySizeRatio }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
|
||||
<div
|
||||
class="h-2 overflow-hidden rounded-full"
|
||||
:class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-indigo-500"
|
||||
:style="{ width: `${dashboardData.todaySizeRatio}%` }"
|
||||
@@ -178,147 +222,71 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.fileTypeDistribution') }}
|
||||
</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div v-if="dashboardData.topSuffixes.length === 0" class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('common.noData') }}
|
||||
</div>
|
||||
<div v-for="item in dashboardData.topSuffixes" :key="item.suffix" class="space-y-1">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span :class="[primaryTextClass]">{{ item.suffix || t('admin.dashboard.textType') }}</span>
|
||||
<span :class="[mutedTextClass]">{{ item.count }}</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
|
||||
<div
|
||||
class="h-full rounded-full bg-purple-500"
|
||||
:style="{ width: `${getSuffixRatio(item.count)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="xl:col-span-2 rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.recentFiles') }}
|
||||
</h3>
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.recentFilesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border" :class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']">
|
||||
<table class="min-w-full divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']">
|
||||
<thead :class="[isDarkMode ? 'bg-gray-900/50' : 'bg-gray-50']">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.file') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.size') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.usage') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
|
||||
{{ t('admin.dashboard.table.status') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-100']">
|
||||
<tr v-if="dashboardData.recentFiles.length === 0">
|
||||
<td colspan="4" class="px-4 py-6 text-center text-sm" :class="[mutedTextClass]">
|
||||
{{ t('common.noData') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="file in dashboardData.recentFiles" :key="file.id">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg p-2" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
|
||||
<FileTextIcon v-if="file.text" class="h-4 w-4" :class="[mutedTextClass]" />
|
||||
<FileIcon v-else class="h-4 w-4" :class="[mutedTextClass]" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium" :class="[primaryTextClass]">
|
||||
{{ file.name || file.code }}
|
||||
</p>
|
||||
<p class="text-xs" :class="[mutedTextClass]">
|
||||
{{ file.code }} · {{ formatCreatedAt(file.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
|
||||
{{ formatFileSize(file.size) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
|
||||
{{ file.usedCount }} {{ t('common.times') }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:class="[
|
||||
file.isExpired
|
||||
? isDarkMode
|
||||
? 'bg-red-900/40 text-red-300'
|
||||
: 'bg-red-100 text-red-700'
|
||||
: isDarkMode
|
||||
? 'bg-green-900/40 text-green-300'
|
||||
: 'bg-green-100 text-green-700'
|
||||
]"
|
||||
>
|
||||
{{ file.isExpired ? t('common.expiredFile') : t('admin.dashboard.available') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer
|
||||
class="mt-6 flex flex-col gap-2 border-t pt-4 text-xs sm:flex-row sm:items-center sm:justify-between"
|
||||
:class="[isDarkMode ? 'border-gray-800 text-gray-500' : 'border-gray-200 text-gray-500']"
|
||||
>
|
||||
<span>{{ t('admin.dashboard.footerProduct') }}</span>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span>{{ t('admin.dashboard.runtimeVersion') }}</span>
|
||||
<span
|
||||
class="rounded-md border px-2 py-0.5 font-medium"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'border-gray-700 bg-gray-800/70 text-gray-300'
|
||||
: 'border-gray-200 bg-white text-gray-700'
|
||||
]"
|
||||
>
|
||||
{{ versionText }}
|
||||
</span>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 {
|
||||
ActivityIcon,
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
CheckCircleIcon,
|
||||
DownloadCloudIcon,
|
||||
FileIcon,
|
||||
FilesIcon,
|
||||
FileTextIcon,
|
||||
HardDriveIcon,
|
||||
RefreshCwIcon,
|
||||
ShieldCheckIcon,
|
||||
UploadCloudIcon
|
||||
} from 'lucide-vue-next'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
|
||||
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 { 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 mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
|
||||
const versionText = computed(() => appVersion.value || t('admin.dashboard.versionPending'))
|
||||
const panelClass = computed(() =>
|
||||
isDarkMode.value ? 'bg-gray-800/80 border border-gray-700' : 'bg-white border border-gray-100'
|
||||
)
|
||||
const subtlePanelClass = computed(() =>
|
||||
isDarkMode.value ? 'border-gray-700 bg-gray-900/30' : 'border-gray-100 bg-gray-50'
|
||||
)
|
||||
const maxSuffixCount = computed(() =>
|
||||
Math.max(...dashboardData.topSuffixes.map((item) => item.count), 1)
|
||||
)
|
||||
const maxSaveTimeText = computed(() => {
|
||||
if (!dashboardData.maxSaveSeconds) return t('admin.dashboard.noSaveLimit')
|
||||
const days = Math.floor(dashboardData.maxSaveSeconds / 86400)
|
||||
@@ -328,11 +296,80 @@ const maxSaveTimeText = computed(() => {
|
||||
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) => {
|
||||
if (!value) return '-'
|
||||
return formatTimestamp(value, 'datetime')
|
||||
const healthActionIconMap: Record<DashboardHealthAction['tone'], Component> = {
|
||||
danger: AlertTriangleIcon,
|
||||
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({
|
||||
@@ -381,10 +418,17 @@ const PolicyRow = defineComponent({
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', { class: 'flex items-center justify-between gap-4 border-b border-gray-200/60 pb-3 last:border-b-0 dark:border-gray-700' }, [
|
||||
h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label),
|
||||
h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value)
|
||||
])
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class:
|
||||
'flex items-center justify-between gap-4 border-b border-gray-200/60 pb-3 last:border-b-0 dark:border-gray-700'
|
||||
},
|
||||
[
|
||||
h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label),
|
||||
h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value)
|
||||
]
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+1665
-290
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted } from 'vue'
|
||||
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 SettingSwitch from '@/components/common/SettingSwitch.vue'
|
||||
import { useSystemConfig } from '@/composables'
|
||||
@@ -9,6 +11,9 @@ const isDarkMode = inject('isDarkMode')
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
config,
|
||||
isRefreshing,
|
||||
isSaving,
|
||||
isDirty,
|
||||
fileSize,
|
||||
sizeUnit,
|
||||
saveTime,
|
||||
@@ -24,12 +29,62 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 h-screen overflow-y-auto custom-scrollbar">
|
||||
<h2 class="text-2xl font-bold mb-6" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
|
||||
{{ t('admin.settings.title') }}
|
||||
</h2>
|
||||
<div class="p-6">
|
||||
<div
|
||||
class="sticky top-0 z-20 -mx-6 -mt-6 mb-6 border-b px-6 py-4 backdrop-blur"
|
||||
: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">
|
||||
<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="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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</label>
|
||||
<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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 主题选择 -->
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500'
|
||||
: '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');
|
||||
">
|
||||
"
|
||||
>
|
||||
<option v-for="item in config.themesChoices" :value="item.key" :key="item.key">
|
||||
{{ item.name }} (by {{ item.author }} V{{ item.version }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]"></textarea>
|
||||
]"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知设置 -->
|
||||
<div class="grid grid-cols-1 gap-6 mt-8">
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]"></textarea>
|
||||
]"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 存储设置 -->
|
||||
<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') }}
|
||||
</h3>
|
||||
<!-- 通知设置 -->
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500'
|
||||
: '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');
|
||||
">
|
||||
"
|
||||
>
|
||||
<option value="local">{{ t('manage.settings.localStorage') }}</option>
|
||||
<option value="s3">{{ t('manage.settings.s3Storage') }}</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 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
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
<label
|
||||
class="block text-sm font-medium"
|
||||
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
|
||||
>
|
||||
Webdav Username
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,97 +394,139 @@ onMounted(() => {
|
||||
<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="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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
]">
|
||||
]"
|
||||
>
|
||||
<option value="s3v2">{{ t('manage.settings.s3v2') }}</option>
|
||||
<option value="s3v4">{{ t('manage.settings.s3v4') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingSwitch
|
||||
@@ -365,7 +551,10 @@ onMounted(() => {
|
||||
|
||||
<!-- 上传限制 -->
|
||||
<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') }}
|
||||
</h3>
|
||||
|
||||
@@ -383,48 +572,70 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<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') }}
|
||||
</label>
|
||||
<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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
]">
|
||||
]"
|
||||
>
|
||||
<option value="KB">{{ t('manage.settings.fileSizeUnits.kb') }}</option>
|
||||
<option value="MB">{{ t('manage.settings.fileSizeUnits.mb') }}</option>
|
||||
<option value="GB">{{ t('manage.settings.fileSizeUnits.gb') }}</option>
|
||||
<option value="MB">{{ t('manage.settings.fileSizeUnits.mb') }}</option>
|
||||
<option value="GB">{{ t('manage.settings.fileSizeUnits.gb') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label v-for="style in ['day', 'hour', 'minute', 'forever', 'count']" :key="style"
|
||||
class="relative inline-flex items-center group cursor-pointer">
|
||||
<input type="checkbox" :value="style" v-model="config.expireStyle" class="peer sr-only" />
|
||||
<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'
|
||||
]">
|
||||
<label
|
||||
v-for="style in ['day', 'hour', 'minute', 'forever', 'count']"
|
||||
:key="style"
|
||||
class="relative inline-flex items-center group cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="style"
|
||||
v-model="config.expireStyle"
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<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}`) }}
|
||||
</div>
|
||||
</label>
|
||||
@@ -432,24 +643,32 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</label>
|
||||
<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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
]" />
|
||||
<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="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
]">
|
||||
]"
|
||||
>
|
||||
<option value="秒">{{ t('common.second') }}</option>
|
||||
<option value="分">{{ t('common.minute') }}</option>
|
||||
<option value="时">{{ t('common.hour') }}</option>
|
||||
@@ -470,7 +689,10 @@ onMounted(() => {
|
||||
|
||||
<!-- 错误限制 -->
|
||||
<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') }}
|
||||
</h3>
|
||||
|
||||
@@ -488,14 +710,6 @@ onMounted(() => {
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user