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