-
+
+
+
+
+
+
+
+
-
-
+
+
@@ -91,21 +79,19 @@ provide('isLoading', isLoading)
diff --git a/src/components/common/BaseButton.vue b/src/components/common/BaseButton.vue
new file mode 100644
index 0000000..f9029f6
--- /dev/null
+++ b/src/components/common/BaseButton.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
diff --git a/src/components/common/BaseModal.vue b/src/components/common/BaseModal.vue
new file mode 100644
index 0000000..e1804bc
--- /dev/null
+++ b/src/components/common/BaseModal.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/BorderProgressBar.vue b/src/components/common/BorderProgressBar.vue
index a9ef269..07bb016 100644
--- a/src/components/common/BorderProgressBar.vue
+++ b/src/components/common/BorderProgressBar.vue
@@ -5,135 +5,177 @@
\ No newline at end of file
+
diff --git a/src/components/common/ContentPreviewModal.vue b/src/components/common/ContentPreviewModal.vue
new file mode 100644
index 0000000..e8f28ef
--- /dev/null
+++ b/src/components/common/ContentPreviewModal.vue
@@ -0,0 +1,194 @@
+
+
+
+
+
+
+ {{ t('contentPreview.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/DataPagination.vue b/src/components/common/DataPagination.vue
new file mode 100644
index 0000000..879aef7
--- /dev/null
+++ b/src/components/common/DataPagination.vue
@@ -0,0 +1,134 @@
+
+
+
+ 显示第 {{ (currentPage - 1) * pageSize + 1 }} 到
+ {{ Math.min(currentPage * pageSize, total) }} 条,共 {{ total }} 条
+
+
+
+
+
+
+
+
+
+ ...
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/DataTable.vue b/src/components/common/DataTable.vue
new file mode 100644
index 0000000..dd0461c
--- /dev/null
+++ b/src/components/common/DataTable.vue
@@ -0,0 +1,61 @@
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+ |
+ {{ header }}
+ |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/ExpirationSelector.vue b/src/components/common/ExpirationSelector.vue
new file mode 100644
index 0000000..eae56d0
--- /dev/null
+++ b/src/components/common/ExpirationSelector.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/FileDetailModal.vue b/src/components/common/FileDetailModal.vue
new file mode 100644
index 0000000..8b24042
--- /dev/null
+++ b/src/components/common/FileDetailModal.vue
@@ -0,0 +1,196 @@
+
+
+
+
+
+ {{ t('fileDetail.title') }}
+
+
+
+
+
+ {{ t('fileRecord.filename') }}:{{ record.filename }}
+
+
+
+
+
+ {{ t('fileRecord.date') }}:{{ record.date }}
+
+
+
+
+
+ {{ t('fileRecord.size') }}:{{ record.size }}
+
+
+
+
+
+ {{ t('fileDetail.content') }}:
+
+
+
+
+
+
+
+
+
+
+ {{ t('fileDetail.qrCode') }}
+
+
+
+
+
+ {{ t('fileDetail.scanQrCode') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/FileEditField.vue b/src/components/common/FileEditField.vue
new file mode 100644
index 0000000..6da7718
--- /dev/null
+++ b/src/components/common/FileEditField.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
diff --git a/src/components/common/FileRecordList.vue b/src/components/common/FileRecordList.vue
new file mode 100644
index 0000000..8fc0464
--- /dev/null
+++ b/src/components/common/FileRecordList.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+ {{ record.filename }}
+
+
+ {{ record.size }}
+
+ {{ record.date }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.noData') }}
+
+
+
+
+
+
+
diff --git a/src/components/common/FileUploadArea.vue b/src/components/common/FileUploadArea.vue
new file mode 100644
index 0000000..2b7ee5e
--- /dev/null
+++ b/src/components/common/FileUploadArea.vue
@@ -0,0 +1,341 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ f.name }}
+
+
+ {{ displayText }}
+
+
+
+
+
+ {{ statusDescription }}
+
+
+
+
+ {{ file.name }}
+
+
+ +{{ selectedFiles.length - 3 }}
+
+
+
+
+
+
+ {{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}
+ {{ formatBytes(uploadSpeed) }}/s
+ {{ progress }}%
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/FormInput.vue b/src/components/common/FormInput.vue
new file mode 100644
index 0000000..3a2b6dc
--- /dev/null
+++ b/src/components/common/FormInput.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
{{ error }}
+
+ {{ hint }}
+
+
+
+
+
diff --git a/src/components/common/LanguageSwitcher.vue b/src/components/common/LanguageSwitcher.vue
new file mode 100644
index 0000000..7b467fe
--- /dev/null
+++ b/src/components/common/LanguageSwitcher.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/MediaPreviewModal.vue b/src/components/common/MediaPreviewModal.vue
new file mode 100644
index 0000000..535ffc1
--- /dev/null
+++ b/src/components/common/MediaPreviewModal.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+ {{ record.filename }}
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/PageFooter.vue b/src/components/common/PageFooter.vue
new file mode 100644
index 0000000..7a579a9
--- /dev/null
+++ b/src/components/common/PageFooter.vue
@@ -0,0 +1,53 @@
+
+
+
+
+ {{ linkText }}
+
+
+
+
+
+
+
+
diff --git a/src/components/common/PageHeader.vue b/src/components/common/PageHeader.vue
new file mode 100644
index 0000000..4911f12
--- /dev/null
+++ b/src/components/common/PageHeader.vue
@@ -0,0 +1,59 @@
+
+
+
+
+ {{ title }}
+
+
+ {{ subtitle }}
+
+
+
+
+
diff --git a/src/components/common/RetrieveForm.vue b/src/components/common/RetrieveForm.vue
new file mode 100644
index 0000000..9928497
--- /dev/null
+++ b/src/components/common/RetrieveForm.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
diff --git a/src/components/common/SendTypeSelector.vue b/src/components/common/SendTypeSelector.vue
new file mode 100644
index 0000000..634185b
--- /dev/null
+++ b/src/components/common/SendTypeSelector.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common/SentRecordDetailModal.vue b/src/components/common/SentRecordDetailModal.vue
new file mode 100644
index 0000000..459aee8
--- /dev/null
+++ b/src/components/common/SentRecordDetailModal.vue
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
+ {{ t('send.fileDetails') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ record.filename }}
+
+
+ {{ record.size }} · {{ record.date }}
+
+
+
+
+
+
+
+ {{ record.expiration }}
+
+
+
+
+
+ 安全加密
+
+
+
+
+
+
+
+
+
+
取件码
+
+
+
+ {{ record.retrieveCode }}
+
+
+
+
+
+
+
+ wget下载
+
+
+
+
+ 点击复制wget命令
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/SentRecordList.vue b/src/components/common/SentRecordList.vue
new file mode 100644
index 0000000..22abc4d
--- /dev/null
+++ b/src/components/common/SentRecordList.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+ {{ record.filename ? record.filename : 'Text' }}
+
+
+ {{ record.size }}
+
+ {{ record.date }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.noData') }}
+
+
+
+
+
+
+
diff --git a/src/components/common/SettingNumberInput.vue b/src/components/common/SettingNumberInput.vue
new file mode 100644
index 0000000..2e4ba13
--- /dev/null
+++ b/src/components/common/SettingNumberInput.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+ {{ suffix }}
+
+
+
+
+
diff --git a/src/components/common/SettingSwitch.vue b/src/components/common/SettingSwitch.vue
new file mode 100644
index 0000000..a601237
--- /dev/null
+++ b/src/components/common/SettingSwitch.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+ {{ modelValue === 1 ? enabledText : disabledText }}
+
+
+
+
+
+
diff --git a/src/components/common/SideDrawer.vue b/src/components/common/SideDrawer.vue
new file mode 100644
index 0000000..92fb1c1
--- /dev/null
+++ b/src/components/common/SideDrawer.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
diff --git a/src/components/common/StatCard.vue b/src/components/common/StatCard.vue
new file mode 100644
index 0000000..10e4848
--- /dev/null
+++ b/src/components/common/StatCard.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+ {{ title }}
+
+
+ {{ value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/TextInputArea.vue b/src/components/common/TextInputArea.vue
new file mode 100644
index 0000000..88bd5db
--- /dev/null
+++ b/src/components/common/TextInputArea.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/ThemeToggle.vue b/src/components/common/ThemeToggle.vue
index 68b9e4f..d8ec75d 100644
--- a/src/components/common/ThemeToggle.vue
+++ b/src/components/common/ThemeToggle.vue
@@ -3,20 +3,25 @@ import { inject } from 'vue'
import { SunIcon, MoonIcon } from 'lucide-vue-next'
const isDarkMode = inject('isDarkMode') as { value: boolean }
-const setColorMode = inject('setColorMode') as (isDark: boolean) => void
+const toggleTheme = inject('toggleTheme') as () => void
const toggleColorMode = () => {
- setColorMode(!isDarkMode.value)
+ toggleTheme()
}
diff --git a/src/components/retrievew/ContentPreviewModal.vue b/src/components/retrievew/ContentPreviewModal.vue
deleted file mode 100644
index b3797b1..0000000
--- a/src/components/retrievew/ContentPreviewModal.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
- 内容预览
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/retrievew/FileDetailsModal.vue b/src/components/retrievew/FileDetailsModal.vue
deleted file mode 100644
index 7890116..0000000
--- a/src/components/retrievew/FileDetailsModal.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
- 文件详情
-
-
-
-
-
-
-
-
- {{ record.filename }}
-
-
{{ record.size }}
-
-
-
-
-
-
- 上传时间:{{ record.date }}
-
-
-
-
-
- 文件大小:{{ record.size }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/retrievew/FileInputForm.vue b/src/components/retrievew/FileInputForm.vue
deleted file mode 100644
index 0dd73b2..0000000
--- a/src/components/retrievew/FileInputForm.vue
+++ /dev/null
@@ -1,94 +0,0 @@
-
-
-
-
-
-
-
diff --git a/src/components/retrievew/HistoryDrawer.vue b/src/components/retrievew/HistoryDrawer.vue
deleted file mode 100644
index 573de0c..0000000
--- a/src/components/retrievew/HistoryDrawer.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-
-
-
-
-
- 历史记录
-
-
-
-
-
-
-
-
-
-
-
-
- {{ record.filename }}
-
-
{{ record.size }} · {{ record.date }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/send/ContentPreviewModal.vue b/src/components/send/ContentPreviewModal.vue
deleted file mode 100644
index 67c0695..0000000
--- a/src/components/send/ContentPreviewModal.vue
+++ /dev/null
@@ -1,86 +0,0 @@
-
-
-
-
-
-
-
- 内容预览
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/send/ExpirationSelector.vue b/src/components/send/ExpirationSelector.vue
deleted file mode 100644
index 45132ab..0000000
--- a/src/components/send/ExpirationSelector.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-
-
-
-
-
-
-
-
- {{ getUnit() }}
-
-
-
-
-
-
-
diff --git a/src/components/send/FileDetailsModal.vue b/src/components/send/FileDetailsModal.vue
deleted file mode 100644
index b3a7028..0000000
--- a/src/components/send/FileDetailsModal.vue
+++ /dev/null
@@ -1,126 +0,0 @@
-
-
-
-
-
-
-
- 文件详情
-
-
-
-
-
-
-
-
-
-
- {{ record.filename }}
-
-
- {{ record.size }}
-
-
-
-
-
-
-
-
-
- 上传时间:{{ record.date }}
-
-
-
-
-
- 过期时间:{{ record.expireDate }}
-
-
-
-
-
- 取件码:{{ record.code }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/send/FileUploader.vue b/src/components/send/FileUploader.vue
deleted file mode 100644
index 08faa00..0000000
--- a/src/components/send/FileUploader.vue
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ selectedFile ? selectedFile.name : '点击或拖放文件到此处上传' }}
-
-
-
- 支持各种常见格式,最大20MB
-
-
-
-
-
diff --git a/src/components/send/HistoryDrawer.vue b/src/components/send/HistoryDrawer.vue
deleted file mode 100644
index 825f8ca..0000000
--- a/src/components/send/HistoryDrawer.vue
+++ /dev/null
@@ -1,114 +0,0 @@
-
-
-
-
-
-
- 发送记录
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ record.filename }}
-
-
{{ record.size }} · {{ record.date }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/send/SendButton.vue b/src/components/send/SendButton.vue
deleted file mode 100644
index ba9a80d..0000000
--- a/src/components/send/SendButton.vue
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
diff --git a/src/components/send/SendFooter.vue b/src/components/send/SendFooter.vue
deleted file mode 100644
index 9775ae1..0000000
--- a/src/components/send/SendFooter.vue
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
- 安全加密
-
-
-
-
-
-
diff --git a/src/components/send/SendHeader.vue b/src/components/send/SendHeader.vue
deleted file mode 100644
index 3c1751c..0000000
--- a/src/components/send/SendHeader.vue
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
diff --git a/src/components/send/SendTypeSelector.vue b/src/components/send/SendTypeSelector.vue
deleted file mode 100644
index 255f544..0000000
--- a/src/components/send/SendTypeSelector.vue
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/src/components/send/TextEditor.vue b/src/components/send/TextEditor.vue
deleted file mode 100644
index 70fc588..0000000
--- a/src/components/send/TextEditor.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
diff --git a/src/composables/index.ts b/src/composables/index.ts
new file mode 100644
index 0000000..c894854
--- /dev/null
+++ b/src/composables/index.ts
@@ -0,0 +1,13 @@
+export { useAdminFiles } from './useAdminFiles'
+export { useAdminLogin } from './useAdminLogin'
+export { useAdminSession } from './useAdminSession'
+export { useAppShell } from './useAppShell'
+export { useDashboardStats } from './useDashboardStats'
+export { useInjectedDarkMode } from './useInjectedDarkMode'
+export { usePresignedUpload } from './usePresignedUpload'
+export { usePublicConfigBootstrap } from './usePublicConfigBootstrap'
+export { useRetrieveFlow } from './useRetrieveFlow'
+export { useRouteLoading } from './useRouteLoading'
+export { useSendFlow } from './useSendFlow'
+export { useSystemConfig } from './useSystemConfig'
+export { useTheme } from './useTheme'
diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts
new file mode 100644
index 0000000..bb78a1f
--- /dev/null
+++ b/src/composables/useAdminFiles.ts
@@ -0,0 +1,1720 @@
+import { computed, ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { FileService } from '@/services'
+import { useAlertStore } from '@/stores/alertStore'
+import type {
+ AdminBatchEditForm,
+ AdminBatchDeleteFilesResponse,
+ AdminBatchPolicyActionRequest,
+ AdminBatchPolicyActionResponse,
+ AdminBatchUpdateFilesRequest,
+ AdminBatchUpdateFilesResponse,
+ AdminFileDetailResponse,
+ AdminFileDetailTimelineItem,
+ AdminFileDetailViewItem,
+ AdminFileMetadataRequest,
+ AdminFileInsightSeverity,
+ AdminFilePatchPayload,
+ AdminFileHealthFilter,
+ AdminFileListParams,
+ AdminFilePolicyAction,
+ AdminFilePolicyActionRequest,
+ AdminFileStatusFilter,
+ AdminFileSummary,
+ AdminFileTypeFilter,
+ AdminFileViewPreset,
+ AdminFileViewPresetParams,
+ AdminFileViewPresetRequest,
+ AdminFileViewItem,
+ ApiResponse,
+ FileEditForm,
+ FileListItem
+} from '@/types'
+import { copyToClipboard } from '@/utils/clipboard'
+import { formatDuration, formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common'
+import {
+ downloadAdminManagedFile,
+ exportAdminTextFile,
+ getSafeFilename
+} from '@/utils/download-action'
+import { buildRetrieveUrl } from '@/utils/share-url'
+
+const emptySummary = (): AdminFileSummary => ({
+ totalFiles: 0,
+ activeCount: 0,
+ expiredCount: 0,
+ textCount: 0,
+ fileCount: 0,
+ chunkedCount: 0,
+ healthAttentionCount: 0,
+ healthDangerCount: 0,
+ healthWarningCount: 0,
+ expiringSoonCount: 0,
+ storageIssueCount: 0,
+ neverRetrievedCount: 0,
+ healthyCount: 0,
+ permanentCount: 0,
+ storageUsed: 0,
+ usedCount: 0
+})
+
+const normalizeCount = (value: number | string | null | undefined) => Number(value || 0)
+
+const isExpiredByDate = (value: string | null | undefined) => {
+ if (!value) return false
+ const timestamp = new Date(value).getTime()
+ return Number.isFinite(timestamp) && timestamp < Date.now()
+}
+
+type ErrorWithResponse = {
+ response?: {
+ status?: number
+ }
+}
+
+const isLegacyEndpointUnavailable = (error: unknown) => {
+ const status = (error as ErrorWithResponse)?.response?.status
+ return status === 404 || status === 405
+}
+
+const legacyForeverExpiresAt = '2099-12-31T23:59'
+const detailPolicyDownloadLimit = 5
+const dayInMilliseconds = 24 * 60 * 60 * 1000
+
+type DetailPolicyActionOption = {
+ action: AdminFilePolicyAction
+ label: string
+ description: string
+}
+
+type BatchPolicyActionOption = DetailPolicyActionOption
+
+type HealthFilterOption = {
+ value: AdminFileHealthFilter
+ label: string
+ count?: number
+}
+
+const emptyBatchEditForm = (): AdminBatchEditForm => ({
+ mode: 'expiresAt',
+ expired_at: '',
+ expired_count: null
+})
+
+const metadataTagSeparatorPattern = /[,,\n]+/
+
+const emptyDetailMetadataForm = () => ({
+ note: '',
+ tagsText: ''
+})
+
+const padDatePart = (value: number) => String(value).padStart(2, '0')
+
+const formatLocalDateTime = (date: Date) => {
+ const year = date.getFullYear()
+ const month = padDatePart(date.getMonth() + 1)
+ const day = padDatePart(date.getDate())
+ const hours = padDatePart(date.getHours())
+ const minutes = padDatePart(date.getMinutes())
+
+ return `${year}-${month}-${day}T${hours}:${minutes}`
+}
+
+const builtInAllViewPresetId = 'built_in_all'
+const adminFileStatusFilters: AdminFileStatusFilter[] = ['all', 'active', 'expired']
+const adminFileTypeFilters: AdminFileTypeFilter[] = ['all', 'file', 'text', 'chunked']
+const adminFileHealthFilters: AdminFileHealthFilter[] = [
+ 'all',
+ 'attention',
+ 'danger',
+ 'warning',
+ 'healthy',
+ 'expired',
+ 'expiring_soon',
+ 'storage_issue',
+ 'never_retrieved',
+ 'permanent'
+]
+const adminFileSortFields = [
+ 'created_at',
+ 'expired_at',
+ 'name',
+ 'size',
+ 'used_count',
+ 'code'
+] as const
+const adminFileSortOrders = ['asc', 'desc'] as const
+
+type RawViewPresetParams = Partial<
+ Record
+>
+
+const normalizePresetChoice = (
+ value: unknown,
+ allowedValues: readonly T[],
+ fallback: T
+): T => {
+ const normalizedValue = String(value ?? fallback)
+ .replace('-', '_')
+ .trim()
+ .toLowerCase()
+ return allowedValues.includes(normalizedValue as T) ? (normalizedValue as T) : fallback
+}
+
+const normalizePresetSize = (value: unknown) => {
+ const normalizedSize = Number(value)
+ if (!Number.isFinite(normalizedSize)) return 10
+ return Math.min(Math.max(Math.trunc(normalizedSize), 1), 100)
+}
+
+const buildDefaultViewPresetParams = (): AdminFileViewPresetParams => ({
+ keyword: '',
+ status: 'all',
+ type: 'all',
+ health: 'all',
+ sortBy: 'created_at',
+ sortOrder: 'desc',
+ size: 10
+})
+
+const normalizeViewPresetParams = (params: unknown): AdminFileViewPresetParams => {
+ const defaults = buildDefaultViewPresetParams()
+ const rawParams =
+ params && typeof params === 'object'
+ ? (params as RawViewPresetParams)
+ : ({} as RawViewPresetParams)
+
+ return {
+ keyword: String(rawParams.keyword ?? defaults.keyword).trim(),
+ status: normalizePresetChoice(rawParams.status, adminFileStatusFilters, defaults.status),
+ type: normalizePresetChoice(rawParams.type, adminFileTypeFilters, defaults.type),
+ health: normalizePresetChoice(rawParams.health, adminFileHealthFilters, defaults.health),
+ sortBy: normalizePresetChoice(
+ rawParams.sortBy ?? rawParams.sort_by,
+ adminFileSortFields,
+ defaults.sortBy
+ ),
+ sortOrder: normalizePresetChoice(
+ rawParams.sortOrder ?? rawParams.sort_order,
+ adminFileSortOrders,
+ defaults.sortOrder
+ ),
+ size: normalizePresetSize(rawParams.size ?? defaults.size)
+ }
+}
+
+export function useAdminFiles() {
+ const { t } = useI18n()
+ const alertStore = useAlertStore()
+
+ const tableData = ref([])
+ const hasLoadError = ref(false)
+ const isLoading = ref(false)
+ const isSaving = ref(false)
+ const summary = ref(emptySummary())
+ const params = ref({
+ page: 1,
+ size: 10,
+ total: 0,
+ keyword: '',
+ status: 'all',
+ type: 'all',
+ health: 'all',
+ sortBy: 'created_at',
+ sortOrder: 'desc'
+ })
+
+ const showEditModal = ref(false)
+ const editForm = ref({
+ id: null,
+ code: '',
+ prefix: '',
+ suffix: '',
+ expired_at: '',
+ expired_count: null
+ })
+ const showBatchEditModal = ref(false)
+ const batchEditForm = ref(emptyBatchEditForm())
+
+ const showTextPreview = ref(false)
+ const previewText = ref('')
+ const previewFile = ref(null)
+ const previewMetaText = ref('')
+ const isPreviewLoading = ref(false)
+ const showFileDetailModal = ref(false)
+ const selectedFileDetail = ref(null)
+ const isDetailLoading = ref(false)
+ const isDetailPolicyActionRunning = ref(false)
+ const isDetailMetadataSaving = ref(false)
+ const detailMetadataForm = ref(emptyDetailMetadataForm())
+ const downloadingFileId = ref(null)
+ const selectedFileIds = ref>(new Set())
+ const isBatchDeleting = ref(false)
+ const isBatchUpdating = ref(false)
+ const isBatchPolicyActionRunning = ref(false)
+ const savedViewPresets = ref([])
+ const selectedViewPresetId = ref(builtInAllViewPresetId)
+ const isViewPresetLoading = ref(false)
+ const isViewPresetSaving = ref(false)
+ const isViewPresetDeleting = ref(false)
+ let detailRequestSerial = 0
+ const isBatchActionRunning = computed(
+ () => isBatchDeleting.value || isBatchUpdating.value || isBatchPolicyActionRunning.value
+ )
+ const totalPages = computed(() => Math.max(Math.ceil(params.value.total / params.value.size), 1))
+ const storageUsedText = computed(() => formatFileSize(summary.value.storageUsed))
+ const currentPageSelectedCount = computed(
+ () => tableData.value.filter((file) => selectedFileIds.value.has(file.id)).length
+ )
+ const selectedCount = computed(() => selectedFileIds.value.size)
+ const hasSelectedFiles = computed(() => selectedCount.value > 0)
+ const isAllCurrentPageSelected = computed(
+ () => tableData.value.length > 0 && currentPageSelectedCount.value === tableData.value.length
+ )
+ const isCurrentPagePartiallySelected = computed(
+ () => currentPageSelectedCount.value > 0 && !isAllCurrentPageSelected.value
+ )
+
+ const requestParams = computed(() => ({
+ page: params.value.page,
+ size: params.value.size,
+ keyword: params.value.keyword,
+ status: params.value.status === 'all' ? undefined : params.value.status,
+ type: params.value.type === 'all' ? undefined : params.value.type,
+ health: params.value.health === 'all' ? undefined : params.value.health,
+ sortBy: params.value.sortBy,
+ sortOrder: params.value.sortOrder
+ }))
+
+ const hasActiveFilters = computed(
+ () =>
+ Boolean(params.value.keyword?.trim()) ||
+ params.value.status !== 'all' ||
+ params.value.type !== 'all' ||
+ params.value.health !== 'all'
+ )
+
+ const buildCurrentViewPresetParams = (): AdminFileViewPresetParams => ({
+ keyword: params.value.keyword || '',
+ status: params.value.status || 'all',
+ type: params.value.type || 'all',
+ health: params.value.health || 'all',
+ sortBy: params.value.sortBy || 'created_at',
+ sortOrder: params.value.sortOrder || 'desc',
+ size: params.value.size || 10
+ })
+
+ const buildBuiltInViewPreset = (
+ id: string,
+ nameKey: string,
+ filters: Partial = {}
+ ): AdminFileViewPreset => {
+ const presetFilters = normalizeViewPresetParams({
+ ...buildDefaultViewPresetParams(),
+ ...filters
+ })
+
+ return {
+ id,
+ name: t(nameKey),
+ filters: presetFilters,
+ params: presetFilters,
+ isBuiltIn: true,
+ isDefault: id === builtInAllViewPresetId,
+ is_default: id === builtInAllViewPresetId
+ }
+ }
+
+ const builtInViewPresets = computed(() => [
+ buildBuiltInViewPreset(builtInAllViewPresetId, 'fileManage.viewPresetAll'),
+ buildBuiltInViewPreset('built_in_attention', 'fileManage.viewPresetAttention', {
+ health: 'attention'
+ }),
+ buildBuiltInViewPreset('built_in_expiring_soon', 'fileManage.viewPresetExpiringSoon', {
+ health: 'expiring_soon'
+ }),
+ buildBuiltInViewPreset('built_in_storage_issue', 'fileManage.viewPresetStorageIssue', {
+ health: 'storage_issue'
+ }),
+ buildBuiltInViewPreset('built_in_never_retrieved', 'fileManage.viewPresetNeverRetrieved', {
+ health: 'never_retrieved'
+ }),
+ buildBuiltInViewPreset('built_in_permanent', 'fileManage.viewPresetPermanent', {
+ health: 'permanent'
+ })
+ ])
+
+ const viewPresets = computed(() => [
+ ...builtInViewPresets.value,
+ ...savedViewPresets.value
+ ])
+
+ const healthFilterOptions = computed(() => [
+ { value: 'all', label: t('fileManage.healthFilters.all'), count: summary.value.totalFiles },
+ {
+ value: 'attention',
+ label: t('fileManage.healthFilters.attention'),
+ count: summary.value.healthAttentionCount
+ },
+ {
+ value: 'danger',
+ label: t('fileManage.healthFilters.danger'),
+ count: summary.value.healthDangerCount
+ },
+ {
+ value: 'warning',
+ label: t('fileManage.healthFilters.warning'),
+ count: summary.value.healthWarningCount
+ },
+ {
+ value: 'expiring_soon',
+ label: t('fileManage.healthFilters.expiringSoon'),
+ count: summary.value.expiringSoonCount
+ },
+ {
+ value: 'storage_issue',
+ label: t('fileManage.healthFilters.storageIssue'),
+ count: summary.value.storageIssueCount
+ },
+ {
+ value: 'never_retrieved',
+ label: t('fileManage.healthFilters.neverRetrieved'),
+ count: summary.value.neverRetrievedCount
+ },
+ {
+ value: 'healthy',
+ label: t('fileManage.healthFilters.healthy'),
+ count: summary.value.healthyCount
+ },
+ {
+ value: 'permanent',
+ label: t('fileManage.healthFilters.permanent'),
+ count: summary.value.permanentCount
+ }
+ ])
+
+ const detailPolicyActionOptions = computed(() => [
+ {
+ action: 'extend_24h',
+ label: t('fileManage.policyActions.extend24h'),
+ description: t('fileManage.policyActionDescriptions.extend24h')
+ },
+ {
+ action: 'extend_7d',
+ label: t('fileManage.policyActions.extend7d'),
+ description: t('fileManage.policyActionDescriptions.extend7d')
+ },
+ {
+ action: 'make_permanent',
+ label: t('fileManage.policyActions.makePermanent'),
+ description: t('fileManage.policyActionDescriptions.makePermanent')
+ },
+ {
+ action: 'reset_download_limit',
+ label: t('fileManage.policyActions.resetDownloadLimit', {
+ count: detailPolicyDownloadLimit
+ }),
+ description: t('fileManage.policyActionDescriptions.resetDownloadLimit', {
+ count: detailPolicyDownloadLimit
+ })
+ }
+ ])
+
+ const batchPolicyActionOptions = computed(
+ () => detailPolicyActionOptions.value
+ )
+
+ const inferIsText = (file: FileListItem) => {
+ if (typeof file.isText === 'boolean') return file.isText
+ if (typeof file.is_text === 'boolean') return file.is_text
+ if (file.type) return file.type === 'text'
+ return Boolean(file.text)
+ }
+
+ const inferIsExpired = (file: FileListItem) => {
+ if (typeof file.isExpired === 'boolean') return file.isExpired
+ if (typeof file.is_expired === 'boolean') return file.is_expired
+ if (
+ file.expired_count !== null &&
+ file.expired_count !== undefined &&
+ file.expired_count === 0
+ ) {
+ return true
+ }
+ return isExpiredByDate(file.expired_at)
+ }
+
+ const inferIsChunked = (file: FileListItem) => Boolean(file.isChunked ?? file.is_chunked)
+
+ const getRemainingDownloads = (file: FileListItem) => {
+ if (file.remainingDownloads !== undefined) return file.remainingDownloads
+ if (file.remaining_downloads !== undefined) return file.remaining_downloads
+ if (
+ file.expired_count !== null &&
+ file.expired_count !== undefined &&
+ file.expired_count >= 0
+ ) {
+ return Math.max(file.expired_count, 0)
+ }
+ return null
+ }
+
+ const buildFallbackSummary = (files: AdminFileViewItem[], total: number): AdminFileSummary => ({
+ totalFiles: total,
+ activeCount: files.filter((file) => !file.isExpiredFile).length,
+ expiredCount: files.filter((file) => file.isExpiredFile).length,
+ textCount: files.filter((file) => file.isTextFile).length,
+ fileCount: files.filter((file) => !file.isTextFile).length,
+ chunkedCount: files.filter((file) => file.isChunkedFile).length,
+ healthAttentionCount: files.filter((file) =>
+ ['danger', 'warning'].includes(file.statusInsightSeverity)
+ ).length,
+ healthDangerCount: files.filter((file) => file.statusInsightSeverity === 'danger').length,
+ healthWarningCount: files.filter((file) => file.statusInsightSeverity === 'warning').length,
+ expiringSoonCount: files.filter((file) => file.statusInsightReasons.includes('expires_soon'))
+ .length,
+ storageIssueCount: files.filter((file) =>
+ file.statusInsightReasons.includes('storage_metadata_incomplete')
+ ).length,
+ neverRetrievedCount: files.filter((file) =>
+ file.statusInsightReasons.includes('never_retrieved')
+ ).length,
+ healthyCount: files.filter((file) => file.statusInsightSeverity === 'success').length,
+ permanentCount: files.filter((file) => file.statusInsightState === 'permanent').length,
+ storageUsed: files.reduce((totalSize, file) => totalSize + normalizeCount(file.size), 0),
+ usedCount: files.reduce(
+ (totalUsed, file) => totalUsed + normalizeCount(file.usedCount ?? file.used_count),
+ 0
+ )
+ })
+
+ const normalizeSummary = (
+ rawSummary: Partial | undefined,
+ files: AdminFileViewItem[],
+ total: number
+ ): AdminFileSummary => {
+ const fallback = buildFallbackSummary(files, total)
+ if (!rawSummary) return fallback
+
+ return {
+ totalFiles: normalizeCount(rawSummary.totalFiles ?? fallback.totalFiles),
+ activeCount: normalizeCount(rawSummary.activeCount ?? fallback.activeCount),
+ expiredCount: normalizeCount(rawSummary.expiredCount ?? fallback.expiredCount),
+ textCount: normalizeCount(rawSummary.textCount ?? fallback.textCount),
+ fileCount: normalizeCount(rawSummary.fileCount ?? fallback.fileCount),
+ chunkedCount: normalizeCount(rawSummary.chunkedCount ?? fallback.chunkedCount),
+ healthAttentionCount: normalizeCount(
+ rawSummary.healthAttentionCount ?? fallback.healthAttentionCount
+ ),
+ healthDangerCount: normalizeCount(rawSummary.healthDangerCount ?? fallback.healthDangerCount),
+ healthWarningCount: normalizeCount(
+ rawSummary.healthWarningCount ?? fallback.healthWarningCount
+ ),
+ expiringSoonCount: normalizeCount(rawSummary.expiringSoonCount ?? fallback.expiringSoonCount),
+ storageIssueCount: normalizeCount(rawSummary.storageIssueCount ?? fallback.storageIssueCount),
+ neverRetrievedCount: normalizeCount(
+ rawSummary.neverRetrievedCount ?? fallback.neverRetrievedCount
+ ),
+ healthyCount: normalizeCount(rawSummary.healthyCount ?? fallback.healthyCount),
+ permanentCount: normalizeCount(rawSummary.permanentCount ?? fallback.permanentCount),
+ storageUsed: normalizeCount(rawSummary.storageUsed ?? fallback.storageUsed),
+ usedCount: normalizeCount(rawSummary.usedCount ?? fallback.usedCount)
+ }
+ }
+
+ const normalizeInsightSeverity = (
+ severity: AdminFileInsightSeverity | undefined
+ ): AdminFileInsightSeverity => {
+ if (severity === 'success' || severity === 'warning' || severity === 'danger') {
+ return severity
+ }
+ if (severity === 'info' || severity === 'neutral') {
+ return severity
+ }
+ return 'neutral'
+ }
+
+ const createFileViewItem = (file: FileListItem): AdminFileViewItem => {
+ const isTextFile = inferIsText(file)
+ const isExpiredFile = inferIsExpired(file)
+ const isChunkedFile = inferIsChunked(file)
+ const remainingDownloadsValue = getRemainingDownloads(file)
+ const usedCount = normalizeCount(file.usedCount ?? file.used_count)
+ const statusInsights = file.statusInsights ?? file.status_insights
+ const isPermanentFile =
+ !file.expired_at &&
+ (file.expired_count === null || file.expired_count === undefined || file.expired_count < 0)
+ const statusInsightState =
+ statusInsights?.state ??
+ (isExpiredFile ? 'expired' : isPermanentFile ? 'permanent' : 'available')
+ const statusInsightNextAction =
+ statusInsights?.nextAction ?? statusInsights?.next_action ?? 'monitor'
+ const statusInsightSeverity = normalizeInsightSeverity(statusInsights?.severity)
+
+ return {
+ ...file,
+ displayName: file.name || `${file.prefix}${file.suffix}` || file.code,
+ displaySize: formatFileSize(file.size),
+ displayExpiredAt: file.expired_at
+ ? formatTimestamp(file.expired_at)
+ : t('send.expiration.units.forever'),
+ displayUsage: `${usedCount} ${t('common.times')}`,
+ displayHealthState: t(`fileManage.insightStates.${statusInsightState}`),
+ displayHealthAction: t(`fileManage.insightActions.${statusInsightNextAction}`),
+ isTextFile,
+ isExpiredFile,
+ isChunkedFile,
+ remainingDownloadsValue,
+ canPreviewText: isTextFile,
+ statusInsightSeverity,
+ statusInsightState,
+ statusInsightNextAction,
+ statusInsightReasons: statusInsights?.reasons || []
+ }
+ }
+
+ const formatTimelineValue = (item: AdminFileDetailTimelineItem) => {
+ if (item.key === 'download_limit') {
+ if (item.status === 'unlimited') return t('fileManage.unlimited')
+ if (typeof item.value === 'number') return t('fileManage.remaining', { count: item.value })
+ }
+
+ if (item.key === 'retrieved' && typeof item.value === 'number') {
+ return `${item.value} ${t('common.times')}`
+ }
+
+ if (item.key === 'expiration_policy') {
+ if (item.status === 'unlimited') return t('send.expiration.units.forever')
+ if (typeof item.value === 'number') {
+ const duration = formatDuration(Math.abs(item.value), (key) => t(key))
+ return item.value <= 0
+ ? t('fileManage.timeline.expiration_policy.overdue', { time: duration })
+ : t('fileManage.timeline.expiration_policy.remaining', { time: duration })
+ }
+ }
+
+ if (item.detail) return item.detail
+ if (item.value !== null && item.value !== undefined) return String(item.value)
+ return ''
+ }
+
+ const createTimelineViewItems = (items: AdminFileDetailTimelineItem[] = []) =>
+ items.map((item) => {
+ const timestampText = item.timestamp ? formatTimestamp(item.timestamp) : ''
+ const valueText = formatTimelineValue(item)
+ const metaParts = [timestampText, valueText].filter(Boolean)
+ return {
+ ...item,
+ severity: normalizeInsightSeverity(item.severity),
+ displayTitle: t(`fileManage.timeline.${item.key}.title`),
+ displayDescription: t(`fileManage.timeline.${item.key}.description`, {
+ status: t(`fileManage.timeline.status.${item.status || 'pending'}`),
+ detail: item.detail || '',
+ value: valueText || ''
+ }),
+ displayMeta: metaParts.length > 0 ? metaParts.join(' · ') : '-'
+ }
+ })
+
+ const normalizeMetadataTags = (tags: string[] | undefined) =>
+ Array.isArray(tags) ? tags.map((tag) => String(tag).trim()).filter(Boolean) : []
+
+ const parseMetadataTags = (value: string) =>
+ normalizeMetadataTags(value.split(metadataTagSeparatorPattern))
+
+ const formatMetadataTags = (tags: string[]) => normalizeMetadataTags(tags).join(', ')
+
+ const createFileDetailViewItem = (
+ file: FileListItem | AdminFileDetailResponse
+ ): AdminFileDetailViewItem => {
+ const detail = file as AdminFileDetailResponse
+ const policy = detail.policy
+ const storage = detail.storage
+ const expiredAt = policy?.expiredAt ?? policy?.expired_at ?? file.expired_at ?? null
+ const expiredCount = policy?.expiredCount ?? policy?.expired_count ?? file.expired_count ?? null
+ const prefixedName = `${file.prefix || ''}${file.suffix || ''}`
+ const displayName =
+ detail.displayName ??
+ detail.display_name ??
+ detail.filename ??
+ file.name ??
+ (prefixedName || file.code)
+ const normalizedFile: FileListItem = {
+ ...file,
+ name: displayName,
+ expired_at: expiredAt,
+ expired_count: expiredCount
+ }
+ const viewItem = createFileViewItem(normalizedFile)
+ const remainingDownloadsValue =
+ policy?.remainingDownloads ?? policy?.remaining_downloads ?? viewItem.remainingDownloadsValue
+ const canPreviewText =
+ detail.canPreviewText ?? detail.can_preview_text ?? viewItem.canPreviewText
+ const canDownloadFile = detail.canDownload ?? detail.can_download ?? !viewItem.isExpiredFile
+ const textLengthValue = normalizeCount(
+ detail.textLength ?? detail.text_length ?? detail.text?.length
+ )
+ const isPermanentFile =
+ detail.isPermanent ??
+ detail.is_permanent ??
+ policy?.isPermanent ??
+ policy?.is_permanent ??
+ (!expiredAt && (expiredCount === null || expiredCount === undefined || expiredCount < 0))
+ const hasDownloadLimitFile =
+ detail.hasDownloadLimit ??
+ detail.has_download_limit ??
+ (expiredCount !== null && expiredCount !== undefined && expiredCount >= 0)
+ const hasExpirationTimeFile =
+ detail.hasExpirationTime ?? detail.has_expiration_time ?? Boolean(expiredAt)
+ const storageBackendValue =
+ storage?.backend ?? detail.storageBackend ?? detail.storage_backend ?? '-'
+ const isChunkedStorage = storage?.isChunked ?? storage?.is_chunked ?? viewItem.isChunkedFile
+ const statusInsights = detail.statusInsights ?? detail.status_insights
+ const statusInsightState =
+ statusInsights?.state ??
+ (viewItem.isExpiredFile ? 'expired' : isPermanentFile ? 'permanent' : 'available')
+ const statusInsightNextAction =
+ statusInsights?.nextAction ?? statusInsights?.next_action ?? 'monitor'
+ const metadata = detail.metadata ?? detail.meta
+ const metadataNote = detail.note ?? metadata?.note ?? ''
+ const metadataTags = normalizeMetadataTags(detail.tags ?? metadata?.tags)
+ const metadataUpdatedAt =
+ detail.metadataUpdatedAt ??
+ detail.metadata_updated_at ??
+ metadata?.updatedAt ??
+ metadata?.updated_at ??
+ null
+
+ return {
+ ...viewItem,
+ displayName,
+ displayExpiredAt: expiredAt ? formatTimestamp(expiredAt) : t('send.expiration.units.forever'),
+ displayCreatedAt: file.created_at ? formatTimestamp(file.created_at) : '-',
+ displayRetrieveUrl: buildRetrieveUrl(file.code),
+ remainingDownloadsValue,
+ canPreviewText,
+ textLengthValue,
+ canDownloadFile,
+ isPermanentFile,
+ hasDownloadLimitFile,
+ hasExpirationTimeFile,
+ isChunkedStorage: Boolean(isChunkedStorage),
+ storageBackendValue,
+ fileHashValue: storage?.fileHash ?? storage?.file_hash ?? detail.fileHash ?? detail.file_hash,
+ filePathValue: storage?.filePath ?? storage?.file_path ?? detail.filePath ?? detail.file_path,
+ uuidFileNameValue:
+ storage?.uuidFileName ??
+ storage?.uuid_file_name ??
+ detail.uuidFileName ??
+ detail.uuid_file_name,
+ uploadIdValue: storage?.uploadId ?? storage?.upload_id ?? detail.uploadId ?? detail.upload_id,
+ metadataNote,
+ metadataTags,
+ metadataUpdatedAt,
+ statusInsightSeverity: normalizeInsightSeverity(statusInsights?.severity),
+ statusInsightState,
+ statusInsightNextAction,
+ statusInsightReasons: statusInsights?.reasons || [],
+ statusInsightMetrics: statusInsights?.metrics,
+ detailTimeline: createTimelineViewItems(detail.timeline || [])
+ }
+ }
+
+ const syncDetailMetadataForm = (file: AdminFileDetailViewItem | null) => {
+ detailMetadataForm.value = {
+ note: file?.metadataNote || '',
+ tagsText: file ? formatMetadataTags(file.metadataTags) : ''
+ }
+ }
+
+ const setSelectedFileDetail = (file: FileListItem | AdminFileDetailResponse) => {
+ const nextFile = createFileDetailViewItem(file)
+ selectedFileDetail.value = nextFile
+ syncDetailMetadataForm(nextFile)
+ }
+
+ const syncSelectedFilesWithCurrentPage = () => {
+ const visibleIds = new Set(tableData.value.map((file) => file.id))
+ selectedFileIds.value = new Set(
+ Array.from(selectedFileIds.value).filter((id) => visibleIds.has(id))
+ )
+ }
+
+ const clearSelection = () => {
+ selectedFileIds.value = new Set()
+ }
+
+ const toggleFileSelection = (id: number) => {
+ const nextSelectedIds = new Set(selectedFileIds.value)
+ if (nextSelectedIds.has(id)) {
+ nextSelectedIds.delete(id)
+ } else {
+ nextSelectedIds.add(id)
+ }
+ selectedFileIds.value = nextSelectedIds
+ }
+
+ const toggleCurrentPageSelection = () => {
+ if (isAllCurrentPageSelected.value) {
+ clearSelection()
+ return
+ }
+
+ selectedFileIds.value = new Set(tableData.value.map((file) => file.id))
+ }
+
+ const resetEditForm = () => {
+ editForm.value = {
+ id: null,
+ code: '',
+ prefix: '',
+ suffix: '',
+ expired_at: '',
+ expired_count: null
+ }
+ }
+
+ const resetBatchEditForm = () => {
+ batchEditForm.value = emptyBatchEditForm()
+ }
+
+ const openBatchEditModal = () => {
+ if (!hasSelectedFiles.value || isBatchActionRunning.value) return
+
+ resetBatchEditForm()
+ showBatchEditModal.value = true
+ }
+
+ const closeBatchEditModal = (force = false) => {
+ if (isBatchUpdating.value && !force) return
+
+ showBatchEditModal.value = false
+ resetBatchEditForm()
+ }
+
+ const buildBatchUpdatePayload = (ids: number[]): AdminBatchUpdateFilesRequest | null => {
+ const form = batchEditForm.value
+
+ if (form.mode === 'expiresAt') {
+ if (!form.expired_at) return null
+ return {
+ ids,
+ expired_at: form.expired_at
+ }
+ }
+
+ if (form.mode === 'downloadLimit') {
+ if (form.expired_count === null || !Number.isFinite(form.expired_count)) return null
+ return {
+ ids,
+ expired_count: form.expired_count
+ }
+ }
+
+ return {
+ ids,
+ clearExpiredAt: true,
+ clear_expired_at: true
+ }
+ }
+
+ const buildLegacyBatchPatchPayload = (
+ id: number,
+ payload: AdminBatchUpdateFilesRequest
+ ): AdminFilePatchPayload => {
+ if (payload.clearExpiredAt || payload.clear_expired_at) {
+ return {
+ id,
+ expired_at: legacyForeverExpiresAt,
+ expired_count: -1
+ }
+ }
+
+ const patchPayload: AdminFilePatchPayload = { id }
+ if (payload.expired_at !== undefined) {
+ patchPayload.expired_at = payload.expired_at
+ }
+ if (payload.expired_count !== undefined) {
+ patchPayload.expired_count = payload.expired_count
+ }
+ return patchPayload
+ }
+
+ const buildPolicyActionRequest = (
+ file: AdminFileDetailViewItem,
+ action: AdminFilePolicyAction
+ ): AdminFilePolicyActionRequest => {
+ const request: AdminFilePolicyActionRequest = {
+ id: file.id,
+ action
+ }
+
+ if (action === 'reset_download_limit') {
+ request.downloadLimit = detailPolicyDownloadLimit
+ request.download_limit = detailPolicyDownloadLimit
+ }
+
+ return request
+ }
+
+ const buildBatchPolicyActionRequest = (
+ ids: number[],
+ action: AdminFilePolicyAction
+ ): AdminBatchPolicyActionRequest => {
+ const request: AdminBatchPolicyActionRequest = {
+ ids,
+ action
+ }
+
+ if (action === 'reset_download_limit') {
+ request.downloadLimit = detailPolicyDownloadLimit
+ request.download_limit = detailPolicyDownloadLimit
+ }
+
+ return request
+ }
+
+ const getPolicyActionBaseDate = (file: Pick) => {
+ const now = new Date()
+ const expiredAt = file.expired_at ? new Date(file.expired_at) : null
+ if (!expiredAt || Number.isNaN(expiredAt.getTime()) || expiredAt.getTime() < now.getTime()) {
+ return now
+ }
+ return expiredAt
+ }
+
+ const buildLegacyPolicyActionPayload = (
+ file: Pick,
+ action: AdminFilePolicyAction
+ ): AdminFilePatchPayload => {
+ if (action === 'make_permanent') {
+ return {
+ id: file.id,
+ expired_at: legacyForeverExpiresAt,
+ expired_count: -1
+ }
+ }
+
+ if (action === 'reset_download_limit') {
+ return {
+ id: file.id,
+ expired_count: detailPolicyDownloadLimit
+ }
+ }
+
+ const offsetDays = action === 'extend_7d' ? 7 : 1
+ const nextExpiredAt = new Date(
+ getPolicyActionBaseDate(file).getTime() + offsetDays * dayInMilliseconds
+ )
+ return {
+ id: file.id,
+ expired_at: formatLocalDateTime(nextExpiredAt)
+ }
+ }
+
+ const getPresetParams = (preset: AdminFileViewPreset) =>
+ normalizeViewPresetParams(preset.filters ?? preset.params)
+
+ const normalizeSavedViewPreset = (preset: AdminFileViewPreset): AdminFileViewPreset | null => {
+ const id = String(preset.id || '').trim()
+ const name = String(preset.name || '').trim()
+ if (!id || !name) return null
+
+ const presetParams = getPresetParams(preset)
+ return {
+ ...preset,
+ id,
+ name,
+ filters: presetParams,
+ params: presetParams,
+ isBuiltIn: false
+ }
+ }
+
+ const upsertSavedViewPreset = (preset: AdminFileViewPreset) => {
+ const normalizedPreset = normalizeSavedViewPreset(preset)
+ if (!normalizedPreset) return null
+
+ savedViewPresets.value = [
+ ...savedViewPresets.value.filter((item) => item.id !== normalizedPreset.id),
+ normalizedPreset
+ ]
+ return normalizedPreset
+ }
+
+ const buildViewPresetRequest = (name: string, id?: string): AdminFileViewPresetRequest | null => {
+ const normalizedName = name.trim()
+ if (!normalizedName) return null
+
+ const filters = normalizeViewPresetParams(buildCurrentViewPresetParams())
+ return {
+ id,
+ name: normalizedName,
+ filters,
+ params: filters
+ }
+ }
+
+ const markViewPresetDirty = () => {
+ selectedViewPresetId.value = ''
+ }
+
+ const loadViewPresets = async () => {
+ isViewPresetLoading.value = true
+ try {
+ const response = await FileService.getAdminFileViewPresets()
+ const rawPresets = response.detail?.presets ?? response.detail?.items ?? []
+ savedViewPresets.value = rawPresets
+ .map((preset) => normalizeSavedViewPreset(preset))
+ .filter((preset): preset is AdminFileViewPreset => Boolean(preset))
+
+ if (
+ selectedViewPresetId.value &&
+ !viewPresets.value.some((preset) => preset.id === selectedViewPresetId.value)
+ ) {
+ selectedViewPresetId.value = builtInAllViewPresetId
+ }
+ } catch (error: unknown) {
+ savedViewPresets.value = []
+ alertStore.showAlert(
+ isLegacyEndpointUnavailable(error)
+ ? t('fileManage.viewPresetLoadFailed')
+ : getErrorMessage(error, t('fileManage.viewPresetLoadFailed')),
+ 'warning'
+ )
+ } finally {
+ isViewPresetLoading.value = false
+ }
+ }
+
+ const applyViewPreset = async (idOrPreset: string | AdminFileViewPreset) => {
+ const preset =
+ typeof idOrPreset === 'string'
+ ? viewPresets.value.find((item) => item.id === idOrPreset)
+ : idOrPreset
+ if (!preset) return
+
+ const presetParams = getPresetParams(preset)
+ params.value.keyword = presetParams.keyword
+ params.value.status = presetParams.status
+ params.value.type = presetParams.type
+ params.value.health = presetParams.health
+ params.value.sortBy = presetParams.sortBy
+ params.value.sortOrder = presetParams.sortOrder
+ params.value.size = presetParams.size
+ params.value.page = 1
+ selectedViewPresetId.value = preset.id
+ clearSelection()
+ await loadFiles()
+ }
+
+ const saveCurrentViewPreset = async (name: string) => {
+ const request = buildViewPresetRequest(name)
+ if (!request || isViewPresetSaving.value) return null
+
+ isViewPresetSaving.value = true
+ try {
+ const response = await FileService.saveAdminFileViewPreset(request)
+ const savedPreset = response.detail ? upsertSavedViewPreset(response.detail) : null
+ selectedViewPresetId.value = savedPreset?.id || selectedViewPresetId.value
+ alertStore.showAlert(t('fileManage.viewPresetSaveSuccess'), 'success')
+ return savedPreset
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.viewPresetSaveFailed')), 'error')
+ return null
+ } finally {
+ isViewPresetSaving.value = false
+ }
+ }
+
+ const updateSelectedViewPreset = async () => {
+ const preset = savedViewPresets.value.find((item) => item.id === selectedViewPresetId.value)
+ if (!preset || isViewPresetSaving.value) return null
+
+ const request = buildViewPresetRequest(preset.name, preset.id)
+ if (!request) return null
+
+ isViewPresetSaving.value = true
+ try {
+ const response = await FileService.saveAdminFileViewPreset(request)
+ const savedPreset = response.detail ? upsertSavedViewPreset(response.detail) : null
+ selectedViewPresetId.value = savedPreset?.id || preset.id
+ alertStore.showAlert(t('fileManage.viewPresetSaveSuccess'), 'success')
+ return savedPreset
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.viewPresetSaveFailed')), 'error')
+ return null
+ } finally {
+ isViewPresetSaving.value = false
+ }
+ }
+
+ const deleteSelectedViewPreset = async () => {
+ const preset = savedViewPresets.value.find((item) => item.id === selectedViewPresetId.value)
+ if (!preset || isViewPresetDeleting.value) return
+ if (!window.confirm(t('fileManage.viewPresetDeleteConfirm', { name: preset.name }))) return
+
+ isViewPresetDeleting.value = true
+ try {
+ await FileService.deleteAdminFileViewPreset(preset.id)
+ savedViewPresets.value = savedViewPresets.value.filter((item) => item.id !== preset.id)
+ markViewPresetDirty()
+ alertStore.showAlert(t('fileManage.viewPresetDeleteSuccess'), 'success')
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.viewPresetDeleteFailed')), 'error')
+ } finally {
+ isViewPresetDeleting.value = false
+ }
+ }
+
+ const refreshSelectedFileDetail = async (fileId: number, detail?: AdminFileDetailResponse) => {
+ if (detail) {
+ setSelectedFileDetail(detail)
+ return
+ }
+
+ const fallbackFile = tableData.value.find((file) => file.id === fileId)
+ if (fallbackFile) {
+ setSelectedFileDetail(fallbackFile)
+ }
+
+ try {
+ const response = await FileService.getAdminFileDetail(fileId)
+ if (response.detail) {
+ setSelectedFileDetail(response.detail)
+ }
+ } catch (error: unknown) {
+ if (!isLegacyEndpointUnavailable(error)) {
+ throw error
+ }
+ }
+ }
+
+ const loadFiles = async () => {
+ isLoading.value = true
+ try {
+ hasLoadError.value = false
+ const res = await FileService.getAdminFileList(requestParams.value)
+ if (!res.detail) return
+
+ tableData.value = res.detail.data.map(createFileViewItem)
+ params.value.total = res.detail.total
+ summary.value = normalizeSummary(res.detail.summary, tableData.value, res.detail.total)
+ syncSelectedFilesWithCurrentPage()
+ } catch (error) {
+ hasLoadError.value = true
+ alertStore.showAlert(
+ getErrorMessage(error, t('manage.fileManage.loadFileListFailed')),
+ 'error'
+ )
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ const handleSearch = async () => {
+ markViewPresetDirty()
+ params.value.page = 1
+ await loadFiles()
+ }
+
+ const refreshFiles = async () => {
+ await loadFiles()
+ }
+
+ const resetFilters = async () => {
+ params.value.page = 1
+ params.value.keyword = ''
+ params.value.status = 'all'
+ params.value.type = 'all'
+ params.value.health = 'all'
+ params.value.sortBy = 'created_at'
+ params.value.sortOrder = 'desc'
+ selectedViewPresetId.value = builtInAllViewPresetId
+ clearSelection()
+ await loadFiles()
+ }
+
+ const setStatusFilter = async (status: AdminFileStatusFilter) => {
+ markViewPresetDirty()
+ params.value.status = status
+ params.value.page = 1
+ await loadFiles()
+ }
+
+ const setTypeFilter = async (type: AdminFileTypeFilter) => {
+ markViewPresetDirty()
+ params.value.type = type
+ params.value.page = 1
+ await loadFiles()
+ }
+
+ const setHealthFilter = async (health: AdminFileHealthFilter) => {
+ markViewPresetDirty()
+ params.value.health = health
+ params.value.page = 1
+ await loadFiles()
+ }
+
+ const handlePageChange = async (page: number | string) => {
+ if (typeof page === 'string') return
+ if (page < 1 || page > totalPages.value) return
+
+ params.value.page = page
+ await loadFiles()
+ }
+
+ const openEditModal = (file: FileListItem) => {
+ editForm.value = {
+ id: file.id,
+ code: file.code,
+ prefix: file.prefix,
+ suffix: file.suffix,
+ expired_at: file.expired_at ? file.expired_at.slice(0, 16) : '',
+ expired_count: file.expired_count
+ }
+ showEditModal.value = true
+ }
+
+ const closeEditModal = () => {
+ showEditModal.value = false
+ resetEditForm()
+ }
+
+ const handleUpdate = async () => {
+ if (isSaving.value) return
+
+ isSaving.value = true
+ try {
+ await FileService.updateFile(editForm.value)
+ await loadFiles()
+ closeEditModal()
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.updateFailed')), 'error')
+ } finally {
+ isSaving.value = false
+ }
+ }
+
+ const deleteFile = async (id: number) => {
+ if (!window.confirm(t('manage.fileManage.deleteConfirm'))) {
+ return
+ }
+
+ try {
+ await FileService.deleteAdminFile(id)
+ if (tableData.value.length === 1 && params.value.page > 1) {
+ params.value.page -= 1
+ }
+ selectedFileIds.value.delete(id)
+ selectedFileIds.value = new Set(selectedFileIds.value)
+ await loadFiles()
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error')
+ }
+ }
+
+ const deleteAdminFilesWithFallback = async (
+ ids: number[]
+ ): Promise> => {
+ try {
+ return await FileService.deleteAdminFiles(ids)
+ } catch (error: unknown) {
+ if (!isLegacyEndpointUnavailable(error)) {
+ throw error
+ }
+
+ await Promise.all(ids.map((id) => FileService.deleteAdminFile(id)))
+ return {
+ code: 200,
+ detail: {
+ deletedCount: ids.length,
+ deleted_count: ids.length,
+ deleted: ids,
+ missing: [],
+ failed: []
+ }
+ }
+ }
+ }
+
+ const updateAdminFilesWithFallback = async (
+ ids: number[],
+ payload: AdminBatchUpdateFilesRequest
+ ): Promise> => {
+ try {
+ return await FileService.updateAdminFiles(payload)
+ } catch (error: unknown) {
+ if (!isLegacyEndpointUnavailable(error)) {
+ throw error
+ }
+
+ await Promise.all(
+ ids.map((id) => FileService.updateFile(buildLegacyBatchPatchPayload(id, payload)))
+ )
+
+ return {
+ code: 200,
+ detail: {
+ updatedCount: ids.length,
+ updated_count: ids.length,
+ updated: ids,
+ missing: [],
+ failed: []
+ }
+ }
+ }
+ }
+
+ const applyAdminFilesPolicyActionWithFallback = async (
+ ids: number[],
+ action: AdminFilePolicyAction
+ ): Promise> => {
+ const request = buildBatchPolicyActionRequest(ids, action)
+ try {
+ return await FileService.applyAdminFilesPolicyAction(request)
+ } catch (error: unknown) {
+ if (!isLegacyEndpointUnavailable(error)) {
+ throw error
+ }
+
+ const filesById = new Map(tableData.value.map((file) => [file.id, file]))
+ const updated: number[] = []
+ const missing: number[] = []
+ const failed: { id: number; reason: string }[] = []
+
+ for (const id of ids) {
+ const file = filesById.get(id)
+ if (!file) {
+ missing.push(id)
+ continue
+ }
+
+ try {
+ await FileService.updateFile(buildLegacyPolicyActionPayload(file, action))
+ updated.push(id)
+ } catch (legacyError: unknown) {
+ failed.push({ id, reason: getErrorMessage(legacyError, t('common.unknownError')) })
+ }
+ }
+
+ return {
+ code: 200,
+ detail: {
+ requestedCount: ids.length,
+ requested_count: ids.length,
+ uniqueCount: ids.length,
+ unique_count: ids.length,
+ updatedCount: updated.length,
+ updated_count: updated.length,
+ missingCount: missing.length,
+ missing_count: missing.length,
+ failedCount: failed.length,
+ failed_count: failed.length,
+ action,
+ updated,
+ missing,
+ failed
+ }
+ }
+ }
+ }
+
+ const deleteSelectedFiles = async () => {
+ if (!hasSelectedFiles.value || isBatchActionRunning.value) return
+
+ const ids = Array.from(selectedFileIds.value)
+ if (!window.confirm(t('fileManage.batchDeleteConfirm', { count: ids.length }))) {
+ return
+ }
+
+ isBatchDeleting.value = true
+ try {
+ const response = await deleteAdminFilesWithFallback(ids)
+ const detail = response.detail
+ const deletedCount = detail?.deletedCount ?? detail?.deleted_count ?? ids.length
+ const failedCount = detail?.failedCount ?? detail?.failed_count ?? 0
+
+ if (tableData.value.length <= deletedCount && params.value.page > 1) {
+ params.value.page -= 1
+ }
+
+ clearSelection()
+ await loadFiles()
+ alertStore.showAlert(
+ t(
+ failedCount > 0
+ ? 'fileManage.batchDeletePartialSuccess'
+ : 'fileManage.batchDeleteSuccess',
+ {
+ count: deletedCount,
+ failed: failedCount
+ }
+ ),
+ failedCount > 0 ? 'warning' : 'success'
+ )
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.batchDeleteFailed')), 'error')
+ } finally {
+ isBatchDeleting.value = false
+ }
+ }
+
+ const handleBatchUpdate = async () => {
+ if (!hasSelectedFiles.value || isBatchActionRunning.value) return
+
+ const ids = Array.from(selectedFileIds.value)
+ const payload = buildBatchUpdatePayload(ids)
+ if (!payload) {
+ alertStore.showAlert(t('fileManage.batchUpdateNoFields'), 'warning')
+ return
+ }
+
+ if (!window.confirm(t('fileManage.batchUpdateConfirm', { count: ids.length }))) {
+ return
+ }
+
+ isBatchUpdating.value = true
+ try {
+ const response = await updateAdminFilesWithFallback(ids, payload)
+ const detail = response.detail
+ const updatedCount = detail?.updatedCount ?? detail?.updated_count ?? ids.length
+ const failedCount = detail?.failedCount ?? detail?.failed_count ?? 0
+
+ clearSelection()
+ await loadFiles()
+ closeBatchEditModal(true)
+ alertStore.showAlert(
+ t(
+ failedCount > 0
+ ? 'fileManage.batchUpdatePartialSuccess'
+ : 'fileManage.batchUpdateSuccess',
+ {
+ count: updatedCount,
+ failed: failedCount
+ }
+ ),
+ failedCount > 0 ? 'warning' : 'success'
+ )
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.batchUpdateFailed')), 'error')
+ } finally {
+ isBatchUpdating.value = false
+ }
+ }
+
+ const applySelectedPolicyAction = async (action: AdminFilePolicyAction) => {
+ if (!hasSelectedFiles.value || isBatchActionRunning.value) return
+
+ const ids = Array.from(selectedFileIds.value)
+ const actionLabel =
+ batchPolicyActionOptions.value.find((option) => option.action === action)?.label || action
+ if (
+ !window.confirm(
+ t('fileManage.batchPolicyActionConfirm', {
+ count: ids.length,
+ action: actionLabel
+ })
+ )
+ ) {
+ return
+ }
+
+ isBatchPolicyActionRunning.value = true
+ try {
+ const response = await applyAdminFilesPolicyActionWithFallback(ids, action)
+ const detail = response.detail
+ const updatedCount = detail?.updatedCount ?? detail?.updated_count ?? ids.length
+ const failedCount =
+ (detail?.failedCount ?? detail?.failed_count ?? 0) +
+ (detail?.missingCount ?? detail?.missing_count ?? 0)
+
+ clearSelection()
+ await loadFiles()
+ alertStore.showAlert(
+ t(
+ failedCount > 0
+ ? 'fileManage.batchPolicyActionPartialSuccess'
+ : 'fileManage.batchPolicyActionSuccess',
+ {
+ count: updatedCount,
+ failed: failedCount
+ }
+ ),
+ failedCount > 0 ? 'warning' : 'success'
+ )
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.batchPolicyActionFailed')), 'error')
+ } finally {
+ isBatchPolicyActionRunning.value = false
+ }
+ }
+
+ const openTextPreview = async (file: AdminFileViewItem) => {
+ if (!file.isTextFile || isPreviewLoading.value) return
+
+ previewFile.value = file
+ previewText.value = file.text || ''
+ previewMetaText.value = ''
+ showTextPreview.value = true
+ isPreviewLoading.value = true
+
+ try {
+ const response = await FileService.previewAdminFile(file.id)
+ if (!response.detail) {
+ throw new Error(t('fileManage.previewFailed'))
+ }
+
+ previewText.value = response.detail.content
+ previewMetaText.value = response.detail.truncated
+ ? t('fileManage.previewTruncated', {
+ shown: response.detail.previewLength ?? response.detail.preview_length ?? 0,
+ total: response.detail.length
+ })
+ : t('fileManage.previewComplete', { count: response.detail.length })
+ } catch (error: unknown) {
+ if (file.text) {
+ previewText.value = file.text
+ previewMetaText.value = t('fileManage.previewFallback')
+ return
+ }
+
+ closeTextPreview()
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.previewFailed')), 'error')
+ } finally {
+ isPreviewLoading.value = false
+ }
+ }
+
+ const closeTextPreview = () => {
+ showTextPreview.value = false
+ previewText.value = ''
+ previewFile.value = null
+ previewMetaText.value = ''
+ }
+
+ const openFileDetail = async (file: AdminFileViewItem) => {
+ const requestSerial = ++detailRequestSerial
+ setSelectedFileDetail(file)
+ showFileDetailModal.value = true
+ isDetailLoading.value = true
+
+ try {
+ const response = await FileService.getAdminFileDetail(file.id)
+ if (response.detail && requestSerial === detailRequestSerial) {
+ setSelectedFileDetail(response.detail)
+ }
+ } catch (error: unknown) {
+ if (isLegacyEndpointUnavailable(error)) return
+
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.detailFailed')), 'error')
+ } finally {
+ if (requestSerial === detailRequestSerial) {
+ isDetailLoading.value = false
+ }
+ }
+ }
+
+ const applyDetailPolicyAction = async (action: AdminFilePolicyAction) => {
+ const file = selectedFileDetail.value
+ if (!file || isDetailPolicyActionRunning.value) return
+
+ isDetailPolicyActionRunning.value = true
+ try {
+ let nextDetail: AdminFileDetailResponse | undefined
+
+ try {
+ const response = await FileService.applyAdminFilePolicyAction(
+ buildPolicyActionRequest(file, action)
+ )
+ nextDetail = response.detail
+ } catch (error: unknown) {
+ if (!isLegacyEndpointUnavailable(error)) {
+ throw error
+ }
+ await FileService.updateFile(buildLegacyPolicyActionPayload(file, action))
+ }
+
+ await loadFiles()
+ await refreshSelectedFileDetail(file.id, nextDetail)
+ alertStore.showAlert(t('fileManage.policyActionSuccess'), 'success')
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.policyActionFailed')), 'error')
+ } finally {
+ isDetailPolicyActionRunning.value = false
+ }
+ }
+
+ const updateDetailMetadata = async () => {
+ const file = selectedFileDetail.value
+ if (!file || isDetailMetadataSaving.value) return
+
+ const payload: AdminFileMetadataRequest = {
+ id: file.id,
+ note: detailMetadataForm.value.note,
+ tags: parseMetadataTags(detailMetadataForm.value.tagsText)
+ }
+
+ isDetailMetadataSaving.value = true
+ try {
+ const response = await FileService.updateAdminFileMetadata(payload)
+ if (response.detail) {
+ setSelectedFileDetail(response.detail)
+ } else {
+ await refreshSelectedFileDetail(file.id)
+ }
+ alertStore.showAlert(t('fileManage.metadataSaveSuccess'), 'success')
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.metadataSaveFailed')), 'error')
+ } finally {
+ isDetailMetadataSaving.value = false
+ }
+ }
+
+ const closeFileDetail = () => {
+ detailRequestSerial += 1
+ showFileDetailModal.value = false
+ selectedFileDetail.value = null
+ syncDetailMetadataForm(null)
+ isDetailLoading.value = false
+ }
+
+ const copyText = async () => {
+ await copyToClipboard(previewText.value, {
+ successMsg: t('fileManage.copySuccess'),
+ errorMsg: t('fileManage.copyFailed'),
+ notify: (message, type) => alertStore.showAlert(message, type)
+ })
+ }
+
+ const copyDetailRetrieveCode = async () => {
+ if (!selectedFileDetail.value) return
+
+ await copyToClipboard(selectedFileDetail.value.code, {
+ successMsg: t('fileManage.copyCodeSuccess'),
+ errorMsg: t('fileManage.copyFailed'),
+ notify: (message, type) => alertStore.showAlert(message, type)
+ })
+ }
+
+ const copyDetailRetrieveLink = async () => {
+ if (!selectedFileDetail.value) return
+
+ await copyToClipboard(selectedFileDetail.value.displayRetrieveUrl, {
+ successMsg: t('fileManage.copyLinkSuccess'),
+ errorMsg: t('fileManage.copyFailed'),
+ notify: (message, type) => alertStore.showAlert(message, type)
+ })
+ }
+
+ const exportPreviewText = async () => {
+ if (!previewFile.value || !previewText.value) return
+
+ try {
+ await exportAdminTextFile(previewFile.value, previewText.value)
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.downloadFailed')), 'error')
+ }
+ }
+
+ const downloadFile = async (file: AdminFileViewItem) => {
+ if (downloadingFileId.value) return
+
+ downloadingFileId.value = file.id
+ try {
+ const response = await FileService.downloadAdminFile(file.id)
+ await downloadAdminManagedFile(file, response)
+ alertStore.showAlert(
+ t(file.isTextFile ? 'fileManage.exportSuccess' : 'fileManage.downloadSuccess', {
+ name: getSafeFilename(file.displayName || file.code)
+ }),
+ 'success'
+ )
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('fileManage.downloadFailed')), 'error')
+ } finally {
+ downloadingFileId.value = null
+ }
+ }
+
+ return {
+ tableData,
+ hasLoadError,
+ hasActiveFilters,
+ isLoading,
+ isAllCurrentPageSelected,
+ isBatchActionRunning,
+ isBatchDeleting,
+ isBatchPolicyActionRunning,
+ isBatchUpdating,
+ isCurrentPagePartiallySelected,
+ isDetailLoading,
+ isDetailMetadataSaving,
+ isDetailPolicyActionRunning,
+ isPreviewLoading,
+ isSaving,
+ isViewPresetDeleting,
+ isViewPresetLoading,
+ isViewPresetSaving,
+ batchEditForm,
+ batchPolicyActionOptions,
+ detailPolicyActionOptions,
+ detailMetadataForm,
+ downloadingFileId,
+ healthFilterOptions,
+ hasSelectedFiles,
+ params,
+ previewFile,
+ previewMetaText,
+ selectedFileDetail,
+ selectedCount,
+ selectedFileIds,
+ selectedViewPresetId,
+ storageUsedText,
+ summary,
+ showBatchEditModal,
+ showEditModal,
+ showFileDetailModal,
+ editForm,
+ showTextPreview,
+ previewText,
+ totalPages,
+ viewPresets,
+ applyViewPreset,
+ closeEditModal,
+ closeBatchEditModal,
+ closeFileDetail,
+ closeTextPreview,
+ copyDetailRetrieveCode,
+ copyDetailRetrieveLink,
+ copyText,
+ clearSelection,
+ deleteFile,
+ deleteSelectedFiles,
+ deleteSelectedViewPreset,
+ downloadFile,
+ exportPreviewText,
+ handlePageChange,
+ handleSearch,
+ applyDetailPolicyAction,
+ applySelectedPolicyAction,
+ handleBatchUpdate,
+ handleUpdate,
+ loadViewPresets,
+ loadFiles,
+ openBatchEditModal,
+ openEditModal,
+ openFileDetail,
+ openTextPreview,
+ refreshFiles,
+ resetFilters,
+ saveCurrentViewPreset,
+ setHealthFilter,
+ setStatusFilter,
+ setTypeFilter,
+ updateSelectedViewPreset,
+ updateDetailMetadata,
+ toggleCurrentPageSelection,
+ toggleFileSelection
+ }
+}
diff --git a/src/composables/useAdminLogin.ts b/src/composables/useAdminLogin.ts
new file mode 100644
index 0000000..2f68aaf
--- /dev/null
+++ b/src/composables/useAdminLogin.ts
@@ -0,0 +1,59 @@
+import { ref } from 'vue'
+import { AuthService } from '@/services'
+import { useAdminStore } from '@/stores/adminStore'
+import { useAlertStore } from '@/stores/alertStore'
+import { getErrorMessage } from '@/utils/common'
+
+export function useAdminLogin() {
+ const alertStore = useAlertStore()
+ const adminStore = useAdminStore()
+ const password = ref('')
+ const isLoading = ref(false)
+
+ const validateForm = () => {
+ if (!password.value) {
+ alertStore.showAlert('无效的密码', 'error')
+ return false
+ }
+
+ if (password.value.length < 6) {
+ alertStore.showAlert('密码长度至少为6位', 'error')
+ return false
+ }
+
+ return true
+ }
+
+ const handleSubmit = async () => {
+ if (!validateForm()) return false
+
+ isLoading.value = true
+ try {
+ const response = await AuthService.login(password.value)
+ if (!response.detail?.token || !response.detail.expires_at) {
+ alertStore.showAlert('登录失败:未获取到有效令牌', 'error')
+ return false
+ }
+
+ adminStore.login({
+ id: response.detail.id || 'admin',
+ username: response.detail.username || 'admin',
+ token: response.detail.token,
+ token_type: response.detail.token_type,
+ expires_at: response.detail.expires_at
+ })
+ return true
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, '登录失败'), 'error')
+ return false
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ return {
+ password,
+ isLoading,
+ handleSubmit
+ }
+}
diff --git a/src/composables/useAdminSession.ts b/src/composables/useAdminSession.ts
new file mode 100644
index 0000000..b70069b
--- /dev/null
+++ b/src/composables/useAdminSession.ts
@@ -0,0 +1,34 @@
+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
+ }
+}
diff --git a/src/composables/useAppShell.ts b/src/composables/useAppShell.ts
new file mode 100644
index 0000000..0472ea8
--- /dev/null
+++ b/src/composables/useAppShell.ts
@@ -0,0 +1,86 @@
+import { computed, onMounted, onUnmounted, provide } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { AUTH_EVENTS } from '@/services'
+import { ROUTES } from '@/constants'
+import { useTheme } from './useTheme'
+import { usePublicConfigBootstrap } from './usePublicConfigBootstrap'
+import { useRouteLoading } from './useRouteLoading'
+import { useAdminStore } from '@/stores/adminStore'
+
+export function useAppShell() {
+ const route = useRoute()
+ const router = useRouter()
+ const { isDarkMode, toggleTheme, initTheme } = useTheme()
+ const { isLoading, setupRouteLoading } = useRouteLoading(router)
+ const { syncPublicConfig } = usePublicConfigBootstrap()
+ const adminStore = useAdminStore()
+ 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 setupRedirecting = false
+
+ const handleUnauthorized = () => {
+ adminStore.logout()
+ if (router.currentRoute.value.path !== ROUTES.LOGIN) {
+ void router.push({
+ path: ROUTES.LOGIN,
+ query: {
+ redirect: router.currentRoute.value.fullPath
+ }
+ })
+ }
+ }
+
+ const handleSetupRequired = (event: Event) => {
+ const setupPath =
+ event instanceof CustomEvent && typeof event.detail?.setupPath === 'string'
+ ? event.detail.setupPath
+ : '/setup'
+
+ if (
+ !setupPath ||
+ setupRedirecting ||
+ window.location.pathname.replace(/\/+$/, '') === setupPath
+ ) {
+ return
+ }
+
+ setupRedirecting = true
+ window.location.assign(setupPath)
+ }
+
+ onMounted(() => {
+ cleanupThemeListener = initTheme()
+ setupRouteLoading()
+ window.addEventListener(AUTH_EVENTS.UNAUTHORIZED, handleUnauthorized)
+ window.addEventListener(AUTH_EVENTS.SETUP_REQUIRED, handleSetupRequired)
+ void syncPublicConfig()
+ })
+
+ onUnmounted(() => {
+ cleanupThemeListener?.()
+ window.removeEventListener(AUTH_EVENTS.UNAUTHORIZED, handleUnauthorized)
+ window.removeEventListener(AUTH_EVENTS.SETUP_REQUIRED, handleSetupRequired)
+ })
+
+ provide('isDarkMode', isDarkMode)
+ provide('toggleTheme', toggleTheme)
+ provide('isLoading', isLoading)
+
+ return {
+ isDarkMode,
+ isLoading,
+ route,
+ routeTransitionMode,
+ routeTransitionName,
+ routeViewKey,
+ showGlobalControls
+ }
+}
diff --git a/src/composables/useDashboardStats.ts b/src/composables/useDashboardStats.ts
new file mode 100644
index 0000000..9de8ab7
--- /dev/null
+++ b/src/composables/useDashboardStats.ts
@@ -0,0 +1,180 @@
+import { computed, ref, reactive } from 'vue'
+import { StatsService } from '@/services'
+import type { DashboardData, DashboardHealthSummary, DashboardViewData } from '@/types'
+import { formatFileSize, getErrorMessage } from '@/utils/common'
+
+type UseDashboardStatsOptions = {
+ loadFailedMessage?: string
+}
+
+const emptyDashboardData = (): DashboardViewData => ({
+ hasExtendedStats: false,
+ totalFiles: 0,
+ storageUsed: 0,
+ yesterdayCount: 0,
+ todayCount: 0,
+ yesterdaySize: 0,
+ todaySize: 0,
+ sysUptime: null,
+ activeCount: 0,
+ expiredCount: 0,
+ textCount: 0,
+ fileCount: 0,
+ chunkedCount: 0,
+ usedCount: 0,
+ storageBackend: '-',
+ uploadSizeLimit: 0,
+ openUpload: 0,
+ enableChunk: 0,
+ maxSaveSeconds: 0,
+ healthAttentionCount: 0,
+ healthDangerCount: 0,
+ healthWarningCount: 0,
+ expiringSoonCount: 0,
+ storageIssueCount: 0,
+ neverRetrievedCount: 0,
+ healthyCount: 0,
+ permanentCount: 0,
+ storageUsedText: '0 Bytes',
+ yesterdaySizeText: '0 Bytes',
+ todaySizeText: '0 Bytes',
+ uploadSizeLimitText: '0 Bytes',
+ sysUptimeText: '-',
+ activeRatio: 0,
+ textRatio: 0,
+ fileRatio: 0,
+ healthyRatio: 0,
+ healthAttentionRatio: 0,
+ todaySizeRatio: 0
+})
+
+const toNumber = (value: number | string | null | undefined) => Number(value || 0)
+
+const clampRatio = (value: number) => Math.max(0, Math.min(100, Math.round(value)))
+
+const hasOwn = (target: object, key: string) => Object.prototype.hasOwnProperty.call(target, key)
+
+const formatDuration = (startTimestamp: number | null) => {
+ if (!startTimestamp) return '-'
+ const uptime = Date.now() - startTimestamp
+ const days = Math.floor(uptime / (24 * 60 * 60 * 1000))
+ const hours = Math.floor((uptime % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000))
+ return `${days}天${hours}小时`
+}
+
+const 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(emptyDashboardData())
+ const isLoading = ref(false)
+ const errorMessage = ref('')
+ const lastUpdatedAt = ref(null)
+ const lastUpdatedText = computed(() =>
+ lastUpdatedAt.value ? lastUpdatedAt.value.toLocaleString() : '-'
+ )
+
+ const fetchDashboardData = async () => {
+ isLoading.value = true
+ errorMessage.value = ''
+
+ try {
+ const response = await StatsService.getDashboard()
+ if (!response.detail) {
+ throw new Error('No dashboard data')
+ }
+
+ const detail = response.detail
+ dashboardData.totalFiles = toNumber(detail.totalFiles)
+ dashboardData.storageUsed = toNumber(detail.storageUsed)
+ dashboardData.yesterdayCount = toNumber(detail.yesterdayCount)
+ dashboardData.todayCount = toNumber(detail.todayCount)
+ dashboardData.yesterdaySize = toNumber(detail.yesterdaySize)
+ dashboardData.todaySize = toNumber(detail.todaySize)
+ dashboardData.sysUptime = detail.sysUptime
+ dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount')
+ dashboardData.activeCount = dashboardData.hasExtendedStats
+ ? toNumber(detail.activeCount)
+ : dashboardData.totalFiles
+ dashboardData.expiredCount = toNumber(detail.expiredCount)
+ dashboardData.textCount = toNumber(detail.textCount)
+ dashboardData.fileCount = toNumber(detail.fileCount)
+ dashboardData.chunkedCount = toNumber(detail.chunkedCount)
+ dashboardData.usedCount = toNumber(detail.usedCount)
+ dashboardData.storageBackend = detail.storageBackend || '-'
+ dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit)
+ dashboardData.openUpload = toNumber(detail.openUpload)
+ dashboardData.enableChunk = toNumber(detail.enableChunk)
+ dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds)
+ const healthSummary = normalizeHealthSummary(detail)
+ healthSummaryKeys.forEach((key) => {
+ dashboardData[key] = healthSummary[key]
+ })
+
+ dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed)
+ dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize)
+ dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize)
+ dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit)
+ dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime)
+ dashboardData.activeRatio = dashboardData.totalFiles
+ ? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100)
+ : 0
+ dashboardData.textRatio = dashboardData.totalFiles
+ ? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100)
+ : 0
+ dashboardData.fileRatio = dashboardData.totalFiles
+ ? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100)
+ : 0
+ dashboardData.healthyRatio = dashboardData.totalFiles
+ ? clampRatio((dashboardData.healthyCount / dashboardData.totalFiles) * 100)
+ : 0
+ dashboardData.healthAttentionRatio = dashboardData.totalFiles
+ ? clampRatio((dashboardData.healthAttentionCount / dashboardData.totalFiles) * 100)
+ : 0
+ dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit
+ ? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100)
+ : 0
+ lastUpdatedAt.value = new Date()
+ } catch (error) {
+ errorMessage.value = getErrorMessage(
+ error,
+ options.loadFailedMessage || 'Failed to load dashboard data'
+ )
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ return {
+ dashboardData,
+ errorMessage,
+ fetchDashboardData,
+ isLoading,
+ lastUpdatedText
+ }
+}
diff --git a/src/composables/useInjectedDarkMode.ts b/src/composables/useInjectedDarkMode.ts
new file mode 100644
index 0000000..c6497b8
--- /dev/null
+++ b/src/composables/useInjectedDarkMode.ts
@@ -0,0 +1,7 @@
+import { computed, inject, unref } from 'vue'
+import type { Ref } from 'vue'
+
+export function useInjectedDarkMode() {
+ const injectedDarkMode = inject[ | boolean>('isDarkMode', false)
+ return computed(() => Boolean(unref(injectedDarkMode)))
+}
diff --git a/src/composables/usePresignedUpload.ts b/src/composables/usePresignedUpload.ts
new file mode 100644
index 0000000..97630af
--- /dev/null
+++ b/src/composables/usePresignedUpload.ts
@@ -0,0 +1,346 @@
+import { ref, computed, readonly } from 'vue'
+import { PresignUploadService } from '@/services'
+import type {
+ PresignUploadStatus,
+ PresignUploadMode,
+ PresignInitResponse,
+ PresignUploadOptions,
+ PresignStatusResponse,
+ UploadProgress,
+ ExpireStyle,
+ AlertType
+} from '@/types'
+import { getErrorMessage } from '@/utils/common'
+
+// 预签名上传状态常量
+export const PRESIGN_UPLOAD_STATUS = {
+ IDLE: 'idle',
+ INITIALIZING: 'initializing',
+ UPLOADING: 'uploading',
+ CONFIRMING: 'confirming',
+ SUCCESS: 'success',
+ ERROR: 'error'
+} as const
+
+// 默认过期配置
+const DEFAULT_EXPIRE_VALUE = 1
+const DEFAULT_EXPIRE_STYLE: ExpireStyle = 'day'
+
+type ErrorWithResponse = {
+ response?: {
+ status?: number
+ }
+}
+
+type PresignedUploadNotifier = (message: string, type: AlertType) => void
+
+type UsePresignedUploadOptions = {
+ getMaxFileSize?: () => number
+ notify?: PresignedUploadNotifier
+}
+
+/**
+ * 预签名上传 Composable
+ * 支持 S3 直传模式和服务器代理模式
+ */
+export function usePresignedUpload(options: UsePresignedUploadOptions = {}) {
+ // 状态管理
+ const presignStatus = ref(PRESIGN_UPLOAD_STATUS.IDLE)
+ const uploadSession = ref(null)
+ const uploadProgress = ref({
+ loaded: 0,
+ total: 0,
+ percentage: 0
+ })
+ const uploadedCode = ref('')
+ const errorMessage = ref('')
+
+ // 计算属性
+ const isInitializing = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.INITIALIZING)
+ const isUploading = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.UPLOADING)
+ const isConfirming = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.CONFIRMING)
+ const isSuccess = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.SUCCESS)
+ const isError = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.ERROR)
+ const currentMode = computed(() => uploadSession.value?.mode ?? null)
+
+ const notify: PresignedUploadNotifier = (message, type) => {
+ options.notify?.(message, type)
+ }
+
+ /**
+ * 文件大小验证
+ */
+ const validateFileSize = (file: File): boolean => {
+ const maxFileSize = options.getMaxFileSize?.()
+ if (!maxFileSize) {
+ return true
+ }
+
+ if (file.size > maxFileSize) {
+ const maxSizeMB = Math.round(maxFileSize / 1024 / 1024)
+ errorMessage.value = `文件大小不能超过 ${maxSizeMB}MB`
+ notify(errorMessage.value, 'error')
+ return false
+ }
+ return true
+ }
+
+ /**
+ * 进度追踪回调
+ */
+ const createProgressHandler = (
+ externalHandler?: (progress: UploadProgress) => void
+ ): ((progress: UploadProgress) => void) => {
+ return (progress: UploadProgress) => {
+ // 确保进度值在有效范围内
+ uploadProgress.value = {
+ loaded: Math.max(0, progress.loaded),
+ total: Math.max(0, progress.total),
+ percentage: Math.min(100, Math.max(0, progress.percentage))
+ }
+ // 调用外部进度回调
+ if (externalHandler) {
+ externalHandler(uploadProgress.value)
+ }
+ }
+ }
+
+ /**
+ * 错误处理
+ */
+ const handleUploadError = (error: unknown): void => {
+ presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR
+ const status = (error as ErrorWithResponse)?.response?.status
+ const fallback =
+ status === 404
+ ? '上传会话不存在或已过期'
+ : status === 500
+ ? '服务器错误,请稍后重试'
+ : '上传失败,请重试'
+
+ errorMessage.value = getErrorMessage(error, fallback)
+ notify(errorMessage.value, 'error')
+ }
+
+ /**
+ * 初始化上传会话
+ */
+ const initUploadSession = async (
+ file: File,
+ options?: PresignUploadOptions
+ ): Promise => {
+ try {
+ presignStatus.value = PRESIGN_UPLOAD_STATUS.INITIALIZING
+
+ const response = await PresignUploadService.initUpload({
+ file_name: file.name,
+ file_size: file.size,
+ expire_value: options?.expireValue ?? DEFAULT_EXPIRE_VALUE,
+ expire_style: options?.expireStyle ?? DEFAULT_EXPIRE_STYLE
+ })
+
+ if (response.code === 200 && response.detail) {
+ uploadSession.value = response.detail
+ return response.detail
+ } else {
+ throw new Error(response.message || '初始化上传失败')
+ }
+ } catch (error) {
+ handleUploadError(error)
+ return null
+ }
+ }
+
+ /**
+ * 直传模式上传到 S3
+ */
+ const uploadDirect = async (
+ file: File,
+ session: PresignInitResponse,
+ options?: PresignUploadOptions
+ ): Promise => {
+ try {
+ presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING
+
+ // 上传到 S3
+ const progressHandler = createProgressHandler(options?.onProgress)
+ const uploadSuccess = await PresignUploadService.directUploadToS3(
+ session.upload_url,
+ file,
+ progressHandler
+ )
+
+ if (!uploadSuccess) {
+ throw new Error('S3 上传失败')
+ }
+
+ // 确认上传
+ presignStatus.value = PRESIGN_UPLOAD_STATUS.CONFIRMING
+ const confirmResponse = await PresignUploadService.confirmUpload(session.upload_id, {
+ expire_value: options?.expireValue ?? DEFAULT_EXPIRE_VALUE,
+ expire_style: options?.expireStyle ?? DEFAULT_EXPIRE_STYLE
+ })
+
+ if (confirmResponse.code === 200 && confirmResponse.detail?.code) {
+ presignStatus.value = PRESIGN_UPLOAD_STATUS.SUCCESS
+ uploadedCode.value = String(confirmResponse.detail.code)
+ // 确保进度为 100%
+ uploadProgress.value = {
+ loaded: file.size,
+ total: file.size,
+ percentage: 100
+ }
+ notify('文件上传成功!', 'success')
+ return uploadedCode.value
+ } else {
+ throw new Error(confirmResponse.message || '确认上传失败')
+ }
+ } catch (error) {
+ handleUploadError(error)
+ return null
+ }
+ }
+
+ /**
+ * 代理模式上传
+ */
+ const uploadProxy = async (
+ file: File,
+ session: PresignInitResponse,
+ options?: PresignUploadOptions
+ ): Promise => {
+ try {
+ presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING
+
+ const progressHandler = createProgressHandler(options?.onProgress)
+ const uploadUrl =
+ session.proxy_upload_url || session.upload_url || session.legacy_proxy_upload_url
+ const response = await PresignUploadService.proxyUpload(
+ session.upload_id,
+ file,
+ progressHandler,
+ uploadUrl
+ )
+
+ if (response.code === 200 && response.detail?.code) {
+ presignStatus.value = PRESIGN_UPLOAD_STATUS.SUCCESS
+ uploadedCode.value = String(response.detail.code)
+ // 确保进度为 100%
+ uploadProgress.value = {
+ loaded: file.size,
+ total: file.size,
+ percentage: 100
+ }
+ notify('文件上传成功!', 'success')
+ return uploadedCode.value
+ } else {
+ throw new Error(response.message || '代理上传失败')
+ }
+ } catch (error) {
+ handleUploadError(error)
+ return null
+ }
+ }
+
+ const uploadFile = async (file: File, options?: PresignUploadOptions): Promise => {
+ reset()
+
+ if (!validateFileSize(file)) {
+ presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR
+ return null
+ }
+
+ const session = await initUploadSession(file, options)
+ if (!session) {
+ return null
+ }
+
+ if (session.mode === 'direct') {
+ return await uploadDirect(file, session, options)
+ } else {
+ return await uploadProxy(file, session, options)
+ }
+ }
+
+ /**
+ * 取消上传
+ */
+ const cancelUpload = async (): Promise => {
+ if (!uploadSession.value?.upload_id) {
+ return
+ }
+
+ try {
+ await PresignUploadService.cancelUpload(uploadSession.value.upload_id)
+ notify('上传已取消', 'info')
+ } catch (error) {
+ // 取消失败时静默处理,因为会话可能已过期
+ console.warn('取消上传失败:', error)
+ } finally {
+ reset()
+ }
+ }
+
+ /**
+ * 获取上传状态
+ */
+ const getStatus = async (): Promise => {
+ if (!uploadSession.value?.upload_id) {
+ return null
+ }
+
+ try {
+ const response = await PresignUploadService.getUploadStatus(uploadSession.value.upload_id)
+ if (response.code === 200 && response.detail) {
+ // 检查会话是否过期
+ if (response.detail.is_expired) {
+ errorMessage.value = '上传会话已过期'
+ notify(errorMessage.value, 'warning')
+ }
+ return response.detail
+ }
+ return null
+ } catch (error) {
+ handleUploadError(error)
+ return null
+ }
+ }
+
+ /**
+ * 重置状态
+ */
+ const reset = (): void => {
+ presignStatus.value = PRESIGN_UPLOAD_STATUS.IDLE
+ uploadSession.value = null
+ uploadProgress.value = {
+ loaded: 0,
+ total: 0,
+ percentage: 0
+ }
+ uploadedCode.value = ''
+ errorMessage.value = ''
+ }
+
+ return {
+ // 状态 (只读)
+ presignStatus: readonly(presignStatus),
+ uploadSession: readonly(uploadSession),
+ uploadProgress: readonly(uploadProgress),
+ uploadedCode: readonly(uploadedCode),
+ errorMessage: readonly(errorMessage),
+
+ // 计算属性
+ isInitializing,
+ isUploading,
+ isConfirming,
+ isSuccess,
+ isError,
+ currentMode,
+
+ // 方法
+ uploadFile,
+ cancelUpload,
+ getStatus,
+ reset
+ }
+}
diff --git a/src/composables/usePublicConfigBootstrap.ts b/src/composables/usePublicConfigBootstrap.ts
new file mode 100644
index 0000000..d32c536
--- /dev/null
+++ b/src/composables/usePublicConfigBootstrap.ts
@@ -0,0 +1,26 @@
+import { ConfigService } from '@/services'
+import { useAlertStore } from '@/stores/alertStore'
+import { useConfigStore } from '@/stores/configStore'
+
+export function usePublicConfigBootstrap() {
+ const alertStore = useAlertStore()
+ const configStore = useConfigStore()
+
+ const syncPublicConfig = async () => {
+ const res = await ConfigService.getUserConfig()
+
+ if (res.code !== 200 || !res.detail) {
+ return
+ }
+
+ configStore.applyPublicMeta(res.detail.meta)
+ const notifyMessage = configStore.applyRemoteConfig(res.detail.config)
+ if (notifyMessage) {
+ alertStore.showAlert(notifyMessage, 'success')
+ }
+ }
+
+ return {
+ syncPublicConfig
+ }
+}
diff --git a/src/composables/useRetrieveFlow.ts b/src/composables/useRetrieveFlow.ts
new file mode 100644
index 0000000..039d609
--- /dev/null
+++ b/src/composables/useRetrieveFlow.ts
@@ -0,0 +1,268 @@
+import { computed, ref, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { storeToRefs } from 'pinia'
+import { FileService } from '@/services'
+import { useAlertStore } from '@/stores/alertStore'
+import { useFileDataStore } from '@/stores/fileData'
+import type { ReceivedFileRecord, ShareMetadataResponse, ShareSelectResponse } from '@/types'
+import { copyToClipboard } from '@/utils/clipboard'
+import { getErrorMessage, getResponseMessage } from '@/utils/common'
+import { renderMarkdownPreview } from '@/utils/content-preview'
+import { downloadReceivedRecord } from '@/utils/download-action'
+
+type InputStatus = {
+ readonly: boolean
+ loading: boolean
+}
+
+export function useRetrieveFlow() {
+ const { t } = useI18n()
+ const alertStore = useAlertStore()
+ const fileStore = useFileDataStore()
+ const { receiveData: records } = storeToRefs(fileStore)
+
+ const code = ref('')
+ const inspectedFile = ref(null)
+ const isInspecting = ref(false)
+ const isRetrieving = ref(false)
+ const inputStatus = ref({
+ readonly: false,
+ loading: false
+ })
+ const error = ref('')
+ const selectedRecord = ref(null)
+ const previewMediaRecord = ref(null)
+ const showDrawer = ref(false)
+ const showPreview = ref(false)
+ const showMediaPreview = ref(false)
+ const renderedContent = ref('')
+ const normalizedCode = computed(() => code.value.trim())
+ const isWorking = computed(() => isInspecting.value || isRetrieving.value)
+ const hasValidCode = computed(() => normalizedCode.value.length === 5)
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return '0 ' + t('fileSize.bytes')
+ const k = 1024
+ const sizes = [
+ t('fileSize.bytes'),
+ t('fileSize.kb'),
+ t('fileSize.mb'),
+ t('fileSize.gb'),
+ t('fileSize.tb')
+ ]
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ const createRecord = (detail: ShareSelectResponse): ReceivedFileRecord => {
+ const isText =
+ typeof detail.is_text === 'boolean'
+ ? detail.is_text
+ : detail.type
+ ? detail.type === 'text'
+ : detail.name === 'Text' && !detail.text.startsWith('/share/download')
+ const content = isText ? (detail.content ?? detail.text) : null
+ const downloadUrl = isText ? null : (detail.download_url ?? detail.text)
+
+ return {
+ id: Date.now(),
+ code: detail.code,
+ filename: detail.name,
+ size: formatFileSize(detail.size),
+ downloadUrl,
+ content,
+ date: new Date().toLocaleString(),
+ type: isText ? 'text' : 'file',
+ remainingDownloads: detail.remaining_downloads
+ }
+ }
+
+ const resetInspection = () => {
+ inspectedFile.value = null
+ error.value = ''
+ }
+
+ const inspectCode = async () => {
+ if (!hasValidCode.value) {
+ alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
+ return
+ }
+
+ isInspecting.value = true
+ inputStatus.value.readonly = true
+ inputStatus.value.loading = true
+ error.value = ''
+
+ try {
+ const res = await FileService.inspectFile(normalizedCode.value)
+ if (res.code === 200 && res.detail) {
+ inspectedFile.value = res.detail
+ } else {
+ inspectedFile.value = null
+ error.value = getResponseMessage(res, t('retrieve.messages.unknownError'))
+ alertStore.showAlert(t('retrieve.messages.retrieveFailure') + error.value, 'error')
+ }
+ } catch {
+ inspectedFile.value = null
+ error.value = t('retrieve.messages.previewUnavailable')
+ alertStore.showAlert(error.value, 'warning')
+ } finally {
+ isInspecting.value = false
+ inputStatus.value.readonly = false
+ inputStatus.value.loading = false
+ }
+ }
+
+ const handleSubmit = async () => {
+ if (!hasValidCode.value) {
+ alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
+ return
+ }
+
+ isRetrieving.value = true
+ inputStatus.value.readonly = true
+ inputStatus.value.loading = true
+ error.value = ''
+
+ try {
+ const res = await FileService.selectFile(normalizedCode.value)
+ if (res.code === 200 && res.detail) {
+ const newFileData = createRecord(res.detail)
+ const existingIndex = fileStore.receiveData.findIndex(
+ (file) => file.code === newFileData.code
+ )
+ if (existingIndex !== -1) {
+ fileStore.deleteReceiveData(existingIndex)
+ }
+ fileStore.addReceiveData(newFileData)
+ selectedRecord.value = newFileData
+ if (newFileData.content) {
+ showPreview.value = true
+ }
+ inspectedFile.value = null
+ alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
+ } else {
+ error.value = getResponseMessage(res, t('retrieve.messages.unknownError'))
+ alertStore.showAlert(t('retrieve.messages.retrieveFailure') + error.value, 'error')
+ }
+ } catch (err: unknown) {
+ const errorMessage = getErrorMessage(err, t('retrieve.messages.unknownError'))
+ error.value = errorMessage
+ alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
+ } finally {
+ isRetrieving.value = false
+ inputStatus.value.readonly = false
+ inputStatus.value.loading = false
+ code.value = ''
+ }
+ }
+
+ const copyContent = async () => {
+ if (selectedRecord.value?.content) {
+ await copyToClipboard(selectedRecord.value.content, {
+ successMsg: t('fileRecord.contentCopied'),
+ errorMsg: t('fileRecord.copyFailed'),
+ notify: (message, type) => alertStore.showAlert(message, type)
+ })
+ }
+ }
+
+ const viewDetails = (record: ReceivedFileRecord) => {
+ selectedRecord.value = record
+ }
+
+ const closeDetails = () => {
+ selectedRecord.value = null
+ }
+
+ const deleteRecord = (id: number) => {
+ const index = records.value.findIndex((record) => record.id === id)
+ if (index !== -1) {
+ fileStore.deleteReceiveData(index)
+ }
+ }
+
+ const toggleDrawer = () => {
+ showDrawer.value = !showDrawer.value
+ }
+
+ const downloadRecord = async (record: ReceivedFileRecord) => {
+ try {
+ await downloadReceivedRecord(record)
+ } catch (err: unknown) {
+ alertStore.showAlert(getErrorMessage(err, t('common.downloadFailed')), 'error')
+ }
+ }
+
+ const showContentPreview = () => {
+ showPreview.value = true
+ }
+
+ const closeContentPreview = () => {
+ showPreview.value = false
+ }
+
+ const showFilePreview = () => {
+ if (!selectedRecord.value) return
+ previewMediaRecord.value = selectedRecord.value
+ showMediaPreview.value = true
+ }
+
+ const closeFilePreview = () => {
+ showMediaPreview.value = false
+ previewMediaRecord.value = null
+ }
+
+ watch(
+ () => selectedRecord.value?.content,
+ async (content) => {
+ if (content) {
+ renderedContent.value = await renderMarkdownPreview(content)
+ } else {
+ renderedContent.value = ''
+ }
+ },
+ { immediate: true }
+ )
+
+ watch(normalizedCode, (nextCode) => {
+ if (inspectedFile.value && inspectedFile.value.code !== nextCode) {
+ resetInspection()
+ }
+
+ if (nextCode.length < 5 && error.value) {
+ error.value = ''
+ }
+ })
+
+ return {
+ code,
+ inspectedFile,
+ isInspecting,
+ isRetrieving,
+ isWorking,
+ hasValidCode,
+ inputStatus,
+ error,
+ records,
+ selectedRecord,
+ previewMediaRecord,
+ showDrawer,
+ showPreview,
+ showMediaPreview,
+ renderedContent,
+ closeFilePreview,
+ closeContentPreview,
+ closeDetails,
+ copyContent,
+ deleteRecord,
+ downloadRecord,
+ handleSubmit,
+ inspectCode,
+ resetInspection,
+ showFilePreview,
+ showContentPreview,
+ toggleDrawer,
+ viewDetails
+ }
+}
diff --git a/src/composables/useRouteLoading.ts b/src/composables/useRouteLoading.ts
new file mode 100644
index 0000000..6c3592f
--- /dev/null
+++ b/src/composables/useRouteLoading.ts
@@ -0,0 +1,44 @@
+import { onUnmounted, ref } from 'vue'
+import type { Router } from 'vue-router'
+
+const ROUTE_LOADING_DELAY = 200
+
+export function useRouteLoading(router: Router) {
+ const isLoading = ref(false)
+ let loadingTimer: number | null = null
+ let cleanupBeforeGuard: (() => void) | null = null
+ let cleanupAfterHook: (() => void) | null = null
+
+ const clearLoadingTimer = () => {
+ if (loadingTimer !== null) {
+ window.clearTimeout(loadingTimer)
+ loadingTimer = null
+ }
+ }
+
+ const setupRouteLoading = () => {
+ cleanupBeforeGuard = router.beforeEach((to) => {
+ clearLoadingTimer()
+ isLoading.value = to.meta.showRouteLoading !== false
+ })
+
+ cleanupAfterHook = router.afterEach(() => {
+ clearLoadingTimer()
+ loadingTimer = window.setTimeout(() => {
+ isLoading.value = false
+ loadingTimer = null
+ }, ROUTE_LOADING_DELAY)
+ })
+ }
+
+ onUnmounted(() => {
+ clearLoadingTimer()
+ cleanupBeforeGuard?.()
+ cleanupAfterHook?.()
+ })
+
+ return {
+ isLoading,
+ setupRouteLoading
+ }
+}
diff --git a/src/composables/useSendFlow.ts b/src/composables/useSendFlow.ts
new file mode 100644
index 0000000..f675f95
--- /dev/null
+++ b/src/composables/useSendFlow.ts
@@ -0,0 +1,420 @@
+import { computed, ref, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useAlertStore } from '@/stores/alertStore'
+import { useAdminStore } from '@/stores/adminStore'
+import { useConfigStore } from '@/stores/configStore'
+import { useFileDataStore } from '@/stores/fileData'
+import type { SendType, SentFileRecord, UploadProgress } from '@/types'
+import { getClipboardFile, insertTextAtSelection } from '@/utils/clipboard-paste'
+import { getErrorMessage } from '@/utils/common'
+import { getStorageUnit } from '@/utils/convert'
+import { calculateFileHash } from '@/utils/file-processing'
+import { buildSentRecord, isExpirationWithinLimit } from '@/utils/send-record'
+import { createSentRecordActions } from '@/utils/sent-record-actions'
+import { useSendSubmit } from './useSendSubmit'
+
+export function useSendFlow() {
+ const { t } = useI18n()
+ const alertStore = useAlertStore()
+ const adminStore = useAdminStore()
+ const configStore = useConfigStore()
+ const fileDataStore = useFileDataStore()
+ const config = computed(() => configStore.config)
+ const sendType = ref('file')
+ const selectedFile = ref(null)
+ const selectedFiles = ref([])
+ const textContent = ref('')
+ const expirationMethod = ref(config.value.expireStyle[0] || 'day')
+ const expirationValue = ref('1')
+ const uploadProgress = ref(0)
+ const uploadedBytes = ref(0)
+ const totalBytes = ref(0)
+ const uploadSpeed = ref(0)
+ const lastProgressSnapshot = ref({ loaded: 0, time: 0 })
+ const showDrawer = ref(false)
+ const selectedRecord = ref(null)
+ const isSubmitting = ref(false)
+ const fileHash = ref('')
+ const sendRecords = computed(() => fileDataStore.shareData)
+ const uploadDescription = computed(() =>
+ t('send.uploadArea.descriptionWithLimit', {
+ size: getStorageUnit(config.value.uploadSize)
+ })
+ )
+ const allowedFileTypes = computed(() => {
+ const types = config.value.allowedFileTypes || config.value.allowed_file_types || ['*']
+ const normalized = types.map((type) => String(type).trim()).filter(Boolean)
+ return normalized.length > 0 ? normalized : ['*']
+ })
+ const acceptedTypes = computed(() => {
+ if (allowedFileTypes.value.some((type) => type === '*' || type === '*/*')) return '*'
+
+ return allowedFileTypes.value
+ .map((type) => {
+ const normalizedType = type.toLowerCase()
+ if (normalizedType.includes('/')) return normalizedType
+ return normalizedType.startsWith('.') ? normalizedType : `.${normalizedType}`
+ })
+ .join(',')
+ })
+ const expirationOptions = computed(() =>
+ config.value.expireStyle.map((value) => ({
+ value,
+ label: getUnit(value)
+ }))
+ )
+ watch(
+ () => config.value.expireStyle,
+ (expireStyle) => {
+ if (expireStyle.length > 0 && !expireStyle.includes(expirationMethod.value)) {
+ expirationMethod.value = expireStyle[0]
+ }
+ },
+ { immediate: true }
+ )
+ const notifyCopyResult = (message: string, type: 'success' | 'error') => {
+ alertStore.showAlert(message, type)
+ }
+ const sentRecordActions = createSentRecordActions(notifyCopyResult)
+ const { resetPresignUpload, submitFile, submitText } = useSendSubmit({
+ getMaxFileSize: () => configStore.uploadSizeLimit,
+ notify: (message, type) => alertStore.showAlert(message, type),
+ translate: t,
+ onProgress: (progress: UploadProgress) => {
+ const now = performance.now()
+ if (lastProgressSnapshot.value.time > 0 && now > lastProgressSnapshot.value.time) {
+ const deltaBytes = Math.max(0, progress.loaded - lastProgressSnapshot.value.loaded)
+ const deltaSeconds = (now - lastProgressSnapshot.value.time) / 1000
+ uploadSpeed.value = deltaSeconds > 0 ? deltaBytes / deltaSeconds : 0
+ }
+
+ lastProgressSnapshot.value = {
+ loaded: progress.loaded,
+ time: now
+ }
+ uploadedBytes.value = progress.loaded
+ totalBytes.value = progress.total
+ uploadProgress.value = progress.percentage
+ },
+ onHashCalculated: (hash) => {
+ fileHash.value = hash
+ }
+ })
+
+ const checkOpenUpload = () => {
+ if (config.value.openUpload === 0 && !adminStore.hasToken) {
+ alertStore.showAlert(t('send.messages.guestUploadDisabled'), 'error')
+ return false
+ }
+ return true
+ }
+
+ const resetUploadProgress = () => {
+ uploadProgress.value = 0
+ uploadedBytes.value = 0
+ totalBytes.value = 0
+ uploadSpeed.value = 0
+ lastProgressSnapshot.value = { loaded: 0, time: 0 }
+ }
+
+ const checkFileSize = (file: File) => {
+ if (file.size > config.value.uploadSize) {
+ alertStore.showAlert(
+ t('send.messages.fileSizeExceeded', { size: getStorageUnit(config.value.uploadSize) }),
+ 'error'
+ )
+ selectedFile.value = null
+ return false
+ }
+ return true
+ }
+
+ const checkFileType = (file: File) => {
+ if (allowedFileTypes.value.some((type) => type === '*' || type === '*/*')) {
+ return true
+ }
+
+ const fileName = file.name.toLowerCase()
+ const mimeType = file.type.toLowerCase()
+ const isAllowed = allowedFileTypes.value.some((type) => {
+ const rule = type.toLowerCase()
+ if (rule.includes('/')) {
+ if (rule.endsWith('/*')) {
+ return mimeType.startsWith(rule.slice(0, -1))
+ }
+ return mimeType === rule
+ }
+
+ const extension = rule.startsWith('.') ? rule : `.${rule}`
+ return fileName.endsWith(extension)
+ })
+
+ if (!isAllowed) {
+ alertStore.showAlert(
+ t('send.messages.fileTypeNotAllowed', { types: allowedFileTypes.value.join(', ') }),
+ 'error'
+ )
+ }
+
+ return isAllowed
+ }
+
+ const checkExpirationTime = (method: string, value: string): boolean =>
+ isExpirationWithinLimit(method, value, config.value.max_save_seconds || 0)
+
+ const checkUpload = () => {
+ if (!selectedFile.value) return false
+ if (!checkOpenUpload()) return false
+ if (!checkFileSize(selectedFile.value)) return false
+ if (!checkFileType(selectedFile.value)) return false
+ if (!checkExpirationTime(expirationMethod.value, expirationValue.value)) return false
+ return true
+ }
+
+ const handleFileSelected = async (file: File) => {
+ selectedFile.value = file
+ selectedFiles.value = []
+ if (!checkOpenUpload()) return
+ if (!checkFileSize(file)) return
+ if (!checkFileType(file)) return
+ fileHash.value = await calculateFileHash(file)
+ }
+
+ const handleFilesSelected = async (files: File[]) => {
+ if (!checkOpenUpload()) return
+ const invalidFile = files.find((file) => !checkFileSize(file) || !checkFileType(file))
+ if (invalidFile) return
+ selectedFiles.value = files
+ selectedFile.value = null
+ fileHash.value = ''
+ }
+
+ const handleFileDrop = async (event: DragEvent) => {
+ if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) return
+ const files = Array.from(event.dataTransfer.files)
+ if (files.length === 1) {
+ const file = files[0]
+ selectedFile.value = file
+ selectedFiles.value = []
+ if (!checkUpload()) return
+ fileHash.value = await calculateFileHash(file)
+ } else {
+ if (!checkOpenUpload()) return
+ const invalidFile = files.find((file) => !checkFileSize(file) || !checkFileType(file))
+ if (invalidFile) return
+ selectedFiles.value = files
+ selectedFile.value = null
+ fileHash.value = ''
+ }
+ }
+
+ const handlePaste = async (event: ClipboardEvent) => {
+ const items = event.clipboardData?.items
+ if (!items) return
+
+ const file = getClipboardFile(items)
+ if (file) {
+ if (file.size === 0) {
+ alertStore.showAlert(t('send.messages.emptyFileError'), 'error')
+ return
+ }
+
+ selectedFile.value = file
+ if (!checkUpload()) return
+
+ try {
+ fileHash.value = await calculateFileHash(file)
+ alertStore.showAlert(
+ t('send.messages.fileAddedFromClipboard', { filename: file.name }),
+ 'success'
+ )
+ } catch (err) {
+ alertStore.showAlert(t('send.messages.fileProcessingFailed'), 'error')
+ console.error('File hash calculation failed:', err)
+ }
+ return
+ }
+
+ const textItem = items[0]
+ if (!textItem) return
+
+ sendType.value = 'text'
+ textItem.getAsString((str: string) => {
+ const trimmedStr = str.trim()
+ if (!trimmedStr) return
+
+ const textareaElement = document.getElementById('text-content') as HTMLTextAreaElement
+ if (!textareaElement) {
+ textContent.value += trimmedStr
+ return
+ }
+
+ const insertion = insertTextAtSelection({
+ text: textContent.value,
+ insertText: trimmedStr,
+ selectionStart: textareaElement.selectionStart,
+ selectionEnd: textareaElement.selectionEnd
+ })
+ textContent.value = insertion.value
+
+ setTimeout(() => {
+ textareaElement.setSelectionRange(insertion.cursor, insertion.cursor)
+ textareaElement.focus()
+ }, 0)
+ })
+ }
+
+ const getUnit = (value: string = expirationMethod.value) => {
+ switch (value) {
+ case 'day':
+ return t('send.expiration.units.days')
+ case 'hour':
+ return t('send.expiration.units.hours')
+ case 'minute':
+ return t('send.expiration.units.minutes')
+ case 'count':
+ return t('send.expiration.units.times')
+ case 'forever':
+ return t('send.expiration.units.forever')
+ default:
+ return ''
+ }
+ }
+
+ const handleSubmit = async () => {
+ if (isSubmitting.value) return
+ isSubmitting.value = true
+
+ try {
+ if (sendType.value === 'file' && !selectedFile.value && selectedFiles.value.length === 0) {
+ alertStore.showAlert(t('send.messages.selectFile'), 'error')
+ return
+ }
+ if (sendType.value === 'text' && !textContent.value.trim()) {
+ alertStore.showAlert(t('send.messages.enterText'), 'error')
+ return
+ }
+ if (!checkOpenUpload()) {
+ return
+ }
+ if (expirationMethod.value !== 'forever' && !expirationValue.value) {
+ alertStore.showAlert(t('send.messages.enterExpirationValue'), 'error')
+ return
+ }
+
+ if (!checkExpirationTime(expirationMethod.value, expirationValue.value)) {
+ const maxDays = Math.floor(config.value.max_save_seconds / 86400)
+ alertStore.showAlert(t('send.messages.expirationTooLong', { days: maxDays }), 'error')
+ return
+ }
+
+ const expireValue = expirationValue.value ? parseInt(expirationValue.value) : 1
+ let response
+ if (sendType.value === 'file') {
+ response = await submitFile({
+ selectedFile: selectedFile.value,
+ selectedFiles: selectedFiles.value,
+ expireValue,
+ expireStyle: expirationMethod.value,
+ enableChunk: Boolean(config.value.enableChunk),
+ validateFileSize: checkFileSize
+ })
+ } else {
+ response = await submitText({
+ text: textContent.value,
+ expireValue,
+ expireStyle: expirationMethod.value
+ })
+ }
+
+ if (!response) return
+
+ if (response?.code === 200) {
+ const newRecord = buildSentRecord({
+ response,
+ sendType: sendType.value,
+ textContent: textContent.value,
+ selectedFile: selectedFile.value,
+ selectedFiles: selectedFiles.value,
+ expirationMethod: expirationMethod.value,
+ expirationValue: expirationValue.value,
+ translate: t,
+ getUnit
+ })
+ fileDataStore.addShareDataRecord(newRecord)
+ alertStore.showAlert(
+ t('send.messages.sendSuccess', { code: newRecord.retrieveCode }),
+ 'success'
+ )
+ selectedFile.value = null
+ selectedFiles.value = []
+ textContent.value = ''
+ resetUploadProgress()
+ resetPresignUpload()
+ selectedRecord.value = newRecord
+ await sentRecordActions.copyLink(newRecord)
+ } else {
+ throw new Error(t('send.messages.serverError'))
+ }
+ } catch (error: unknown) {
+ alertStore.showAlert(getErrorMessage(error, t('send.messages.sendFailed')), 'error')
+ } finally {
+ resetUploadProgress()
+ isSubmitting.value = false
+ }
+ }
+
+ const toggleDrawer = () => {
+ showDrawer.value = !showDrawer.value
+ }
+
+ const viewDetails = (record: SentFileRecord) => {
+ selectedRecord.value = record
+ }
+
+ const closeDetails = () => {
+ selectedRecord.value = null
+ }
+
+ const deleteRecord = (id: number) => {
+ const index = fileDataStore.shareData.findIndex((record) => record.id === id)
+ if (index !== -1) {
+ fileDataStore.deleteShareData(index)
+ }
+ }
+
+ return {
+ config,
+ sendType,
+ selectedFile,
+ selectedFiles,
+ textContent,
+ expirationMethod,
+ expirationValue,
+ uploadProgress,
+ uploadedBytes,
+ totalBytes,
+ uploadSpeed,
+ acceptedTypes,
+ showDrawer,
+ selectedRecord,
+ isSubmitting,
+ sendRecords,
+ uploadDescription,
+ expirationOptions,
+ closeDetails,
+ deleteRecord,
+ copySentRecordCode: sentRecordActions.copyCode,
+ copySentRecordLink: sentRecordActions.copyLink,
+ copySentRecordWgetCommand: sentRecordActions.copyWgetCommand,
+ getQRCodeValue: sentRecordActions.getQRCodeValue,
+ getUnit,
+ handleFileDrop,
+ handleFileSelected,
+ handleFilesSelected,
+ handlePaste,
+ handleSubmit,
+ toggleDrawer,
+ viewDetails
+ }
+}
diff --git a/src/composables/useSendSubmit.ts b/src/composables/useSendSubmit.ts
new file mode 100644
index 0000000..645fd58
--- /dev/null
+++ b/src/composables/useSendSubmit.ts
@@ -0,0 +1,122 @@
+import { FileService, uploadChunkedFile } from '@/services'
+import type { AlertType, ApiResponse, ExpireStyle, UploadProgress } from '@/types'
+import { calculateFileHash, packFilesAsZip } from '@/utils/file-processing'
+import { usePresignedUpload } from './usePresignedUpload'
+
+type Translate = (
+ key: string,
+ params?: Record
+) => string
+
+type UseSendSubmitOptions = {
+ getMaxFileSize: () => number
+ notify: (message: string, type: AlertType) => void
+ translate: Translate
+ onProgress: (progress: UploadProgress) => void
+ onHashCalculated: (hash: string) => void
+}
+
+type SubmitFileOptions = {
+ selectedFile: File | null
+ selectedFiles: File[]
+ expireValue: number
+ expireStyle: string
+ enableChunk: boolean
+ validateFileSize: (file: File) => boolean
+}
+
+type SubmitTextOptions = {
+ text: string
+ expireValue: number
+ expireStyle: string
+}
+
+export function useSendSubmit(options: UseSendSubmitOptions) {
+ const { uploadFile: presignUploadFile, reset: resetPresignUpload } = usePresignedUpload({
+ getMaxFileSize: options.getMaxFileSize,
+ notify: options.notify
+ })
+
+ const handleChunkUpload = async (
+ file: File,
+ expireValue: number,
+ expireStyle: string
+ ): Promise => {
+ return uploadChunkedFile(file, {
+ expireValue,
+ expireStyle,
+ onHashCalculated: options.onHashCalculated,
+ onProgress: (progress: UploadProgress) => {
+ options.onProgress(progress)
+ },
+ messages: {
+ initFailed: options.translate('send.messages.initChunkUploadFailed'),
+ chunkFailed: (index) => options.translate('send.messages.chunkUploadFailed', { index }),
+ completeFailed: options.translate('send.messages.completeUploadFailed')
+ }
+ })
+ }
+
+ const handlePresignedUpload = async (
+ file: File,
+ expireValue: number,
+ expireStyle: string
+ ): Promise> => {
+ const code = await presignUploadFile(file, {
+ expireValue,
+ expireStyle: expireStyle as ExpireStyle,
+ onProgress: (progress) => {
+ options.onProgress(progress)
+ }
+ })
+
+ if (!code) {
+ throw new Error(options.translate('send.messages.uploadFailed'))
+ }
+
+ return {
+ code: 200,
+ detail: {
+ code,
+ name: file.name
+ }
+ }
+ }
+
+ const submitFile = async ({
+ selectedFile,
+ selectedFiles,
+ expireValue,
+ expireStyle,
+ enableChunk,
+ validateFileSize
+ }: SubmitFileOptions): Promise => {
+ let fileToUpload = selectedFile
+
+ if (selectedFiles.length > 0) {
+ options.notify('正在打包文件...', 'success')
+ fileToUpload = await packFilesAsZip(selectedFiles)
+ if (!validateFileSize(fileToUpload)) {
+ return null
+ }
+ options.onHashCalculated(await calculateFileHash(fileToUpload))
+ }
+
+ if (!fileToUpload) {
+ throw new Error(options.translate('send.messages.selectFile'))
+ }
+
+ return enableChunk
+ ? handleChunkUpload(fileToUpload, expireValue, expireStyle)
+ : handlePresignedUpload(fileToUpload, expireValue, expireStyle)
+ }
+
+ const submitText = ({ text, expireValue, expireStyle }: SubmitTextOptions) =>
+ FileService.uploadText(text, expireValue, expireStyle)
+
+ return {
+ resetPresignUpload,
+ submitFile,
+ submitText
+ }
+}
diff --git a/src/composables/useSystemConfig.ts b/src/composables/useSystemConfig.ts
index e69de29..af02458 100644
--- a/src/composables/useSystemConfig.ts
+++ b/src/composables/useSystemConfig.ts
@@ -0,0 +1,258 @@
+import { ref, computed } from 'vue'
+import { ConfigService } from '@/services'
+import { useAlertStore } from '@/stores/alertStore'
+import { useConfigStore } from '@/stores/configStore'
+import type { ConfigState } from '@/types'
+import { DEFAULT_CONFIG_STATE, readStoredConfig } from '@/utils/config-storage'
+import { getErrorMessage } from '@/utils/common'
+import {
+ buildConfigSubmitPayload,
+ bytesToFileSizeForm,
+ fileSizeFormToBytes,
+ secondsToSaveTimeForm,
+ type FileSizeUnit,
+ type SaveTimeUnit
+} from '@/utils/config-form'
+
+type ConfigFlagKey = 'enableChunk' | 's3_proxy' | 'openUpload' | 'showAdminAddr'
+
+const SECONDS_PER_DAY = 24 * 60 * 60
+
+export function useSystemConfig() {
+ const alertStore = useAlertStore()
+ const configStore = useConfigStore()
+
+ // 状态管理
+ const config = ref({ ...DEFAULT_CONFIG_STATE })
+ const isRefreshing = ref(false)
+ const isSaving = ref(false)
+ const savedPayloadSnapshot = ref('')
+ const fileSize = ref(1)
+ const sizeUnit = ref('MB')
+ const storageLimit = ref(0)
+ const storageLimitUnit = ref('GB')
+ const saveTime = ref(1)
+ const saveTimeUnit = ref('天')
+ const adminSessionDays = ref(30)
+
+ const isLoading = computed(() => isRefreshing.value || isSaving.value)
+
+ const buildSubmitPayload = (): Partial => {
+ const payload: Partial = buildConfigSubmitPayload(
+ config.value,
+ { value: fileSize.value, unit: sizeUnit.value },
+ { value: saveTime.value, unit: saveTimeUnit.value }
+ )
+ payload.adminSessionExpire = Math.round(adminSessionDays.value * SECONDS_PER_DAY)
+ payload.storageLimit =
+ storageLimit.value === 0 ? 0 : fileSizeFormToBytes(storageLimit.value, storageLimitUnit.value)
+
+ if (!payload.admin_token) {
+ delete payload.admin_token
+ }
+
+ return payload
+ }
+
+ const snapshotPayload = (payload: Partial) => JSON.stringify(payload)
+
+ const normalizeAllowedFileTypes = (value: unknown): string[] => {
+ const rawTypes =
+ typeof value === 'string'
+ ? value.split(',')
+ : Array.isArray(value)
+ ? value
+ : DEFAULT_CONFIG_STATE.allowed_file_types
+
+ const normalized = rawTypes.map((item) => String(item).trim()).filter(Boolean)
+ return normalized.length > 0 ? normalized : ['*']
+ }
+
+ const normalizeEditableConfig = (nextConfig: Partial): ConfigState => {
+ const allowedFileTypes = normalizeAllowedFileTypes(
+ nextConfig.allowed_file_types ?? nextConfig.allowedFileTypes
+ )
+ const codeGenerateType = nextConfig.code_generate_type === 'secret' ? 'secret' : 'number'
+
+ return {
+ ...DEFAULT_CONFIG_STATE,
+ ...nextConfig,
+ allowed_file_types: allowedFileTypes,
+ allowedFileTypes,
+ code_generate_type: codeGenerateType,
+ 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 storedConfig = readStoredConfig>()
+ return storedConfig ? normalizeEditableConfig(storedConfig) : null
+ }
+
+ // 保存配置到本地存储
+ const saveConfigToStorage = (configData: ConfigState) => {
+ configStore.updateConfig(configData)
+ }
+
+ // 获取系统配置
+ const fetchConfig = async (): Promise => {
+ try {
+ isRefreshing.value = true
+
+ const response = await ConfigService.getConfig()
+
+ if (response.code === 200 && response.detail) {
+ config.value = normalizeEditableConfig(response.detail)
+ const notifyMessage = configStore.applyRemoteConfig(config.value)
+ if (notifyMessage) {
+ alertStore.showAlert(notifyMessage, 'success')
+ }
+
+ return config.value
+ } else {
+ throw new Error(response.message || '获取配置失败')
+ }
+ } catch (error) {
+ // 如果网络请求失败,尝试使用本地存储的配置
+ const storedConfig = getStoredConfig()
+ if (storedConfig) {
+ config.value = storedConfig
+ return config.value
+ }
+
+ alertStore.showAlert(getErrorMessage(error, '获取配置失败'), 'error')
+ return null
+ } finally {
+ isRefreshing.value = false
+ }
+ }
+
+ // 更新系统配置
+ const updateConfig = async (newConfig: Partial): Promise => {
+ try {
+ isSaving.value = true
+
+ const response = await ConfigService.updateConfig(newConfig)
+
+ if (response.code === 200) {
+ config.value = normalizeEditableConfig({ ...config.value, ...newConfig })
+ syncConfigForm(config.value)
+ markConfigSaved()
+ saveConfigToStorage(config.value)
+ alertStore.showAlert('配置更新成功!', 'success')
+ return true
+ } else {
+ throw new Error(response.message || '更新配置失败')
+ }
+ } catch (error) {
+ alertStore.showAlert(getErrorMessage(error, '更新配置失败'), 'error')
+ return false
+ } finally {
+ isSaving.value = false
+ }
+ }
+
+ const toggleConfigFlag = (key: ConfigFlagKey) => {
+ config.value[key] = config.value[key] === 1 ? 0 : 1
+ }
+
+ const syncConfigForm = (nextConfig: ConfigState) => {
+ const sizeForm = bytesToFileSizeForm(nextConfig.uploadSize)
+ fileSize.value = sizeForm.value
+ sizeUnit.value = sizeForm.unit
+
+ const storageLimitForm = bytesToFileSizeForm(nextConfig.storageLimit)
+ storageLimit.value = storageLimitForm.value
+ storageLimitUnit.value = storageLimitForm.unit
+
+ const saveTimeForm = secondsToSaveTimeForm(nextConfig.max_save_seconds)
+ saveTime.value = saveTimeForm.value
+ saveTimeUnit.value = saveTimeForm.unit
+ adminSessionDays.value = Math.max(
+ 1,
+ Math.round(nextConfig.adminSessionExpire / SECONDS_PER_DAY)
+ )
+ }
+
+ const refreshConfig = async () => {
+ const latestConfig = await fetchConfig()
+ if (latestConfig) {
+ syncConfigForm(latestConfig)
+ markConfigSaved()
+ }
+ }
+
+ const submitConfig = () => {
+ if (!isDirty.value || isSaving.value) {
+ return Promise.resolve(false)
+ }
+
+ return updateConfig(buildSubmitPayload())
+ }
+
+ // 初始化配置
+ const initConfig = async () => {
+ // 先尝试从本地存储加载
+ const storedConfig = getStoredConfig()
+ if (storedConfig) {
+ config.value = storedConfig
+ syncConfigForm(storedConfig)
+ markConfigSaved()
+ }
+
+ // 然后从服务器获取最新配置
+ await refreshConfig()
+ }
+
+ // 计算属性
+ const maxFileSizeMB = computed(() => {
+ return Math.round(config.value.uploadSize / 1024 / 1024)
+ })
+
+ const isConfigLoaded = computed(() => {
+ return config.value.name !== DEFAULT_CONFIG_STATE.name || !isLoading.value
+ })
+
+ return {
+ // 状态
+ config,
+ isLoading,
+ isRefreshing,
+ isSaving,
+ isDirty,
+ fileSize,
+ sizeUnit,
+ storageLimit,
+ storageLimitUnit,
+ saveTime,
+ saveTimeUnit,
+ adminSessionDays,
+
+ // 计算属性
+ maxFileSizeMB,
+ isConfigLoaded,
+
+ // 方法
+ fetchConfig,
+ updateConfig,
+ refreshConfig,
+ submitConfig,
+ toggleConfigFlag,
+ initConfig,
+ getStoredConfig,
+ saveConfigToStorage
+ }
+}
diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts
new file mode 100644
index 0000000..63fe823
--- /dev/null
+++ b/src/composables/useTheme.ts
@@ -0,0 +1,139 @@
+import { ref, computed } from 'vue'
+import { THEME_MODES } from '@/constants'
+import type { ThemeMode } from '@/types'
+import { readStoredThemeMode, writeStoredThemeMode } from '@/utils/preference-storage'
+
+export function useTheme() {
+ // 状态管理
+ const isDarkMode = ref(false)
+ const themeMode = ref(THEME_MODES.SYSTEM)
+
+ // 检查系统颜色模式
+ const checkSystemColorScheme = (): boolean => {
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
+ }
+
+ // 从本地存储获取用户之前的选择
+ const getUserPreference = (): ThemeMode | null => {
+ const storedPreference = readStoredThemeMode()
+ if (storedPreference && Object.values(THEME_MODES).includes(storedPreference as ThemeMode)) {
+ return storedPreference as ThemeMode
+ }
+ return null
+ }
+
+ // 设置颜色模式
+ const setThemeMode = (mode: ThemeMode) => {
+ themeMode.value = mode
+ writeStoredThemeMode(mode)
+
+ // 根据模式设置实际的暗色模式状态
+ if (mode === THEME_MODES.SYSTEM) {
+ isDarkMode.value = checkSystemColorScheme()
+ } else {
+ isDarkMode.value = mode === THEME_MODES.DARK
+ }
+
+ // 更新 HTML 类名
+ updateHtmlClass()
+ }
+
+ // 更新 HTML 元素的类名
+ const updateHtmlClass = () => {
+ const html = document.documentElement
+ if (isDarkMode.value) {
+ html.classList.add('dark')
+ } else {
+ html.classList.remove('dark')
+ }
+ }
+
+ // 切换主题
+ const toggleTheme = () => {
+ if (themeMode.value === THEME_MODES.LIGHT) {
+ setThemeMode(THEME_MODES.DARK)
+ } else if (themeMode.value === THEME_MODES.DARK) {
+ setThemeMode(THEME_MODES.SYSTEM)
+ } else {
+ setThemeMode(THEME_MODES.LIGHT)
+ }
+ }
+
+ // 监听系统主题变化
+ const setupSystemThemeListener = () => {
+ if (window.matchMedia) {
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
+
+ const handleChange = (e: MediaQueryListEvent) => {
+ if (themeMode.value === THEME_MODES.SYSTEM) {
+ isDarkMode.value = e.matches
+ updateHtmlClass()
+ }
+ }
+
+ mediaQuery.addEventListener('change', handleChange)
+
+ // 返回清理函数
+ return () => {
+ mediaQuery.removeEventListener('change', handleChange)
+ }
+ }
+ return () => {}
+ }
+
+ // 初始化主题
+ const initTheme = () => {
+ const userPreference = getUserPreference()
+ if (userPreference) {
+ setThemeMode(userPreference)
+ } else {
+ setThemeMode(THEME_MODES.SYSTEM)
+ }
+
+ // 设置系统主题监听
+ return setupSystemThemeListener()
+ }
+
+ // 计算属性
+ const themeIcon = computed(() => {
+ switch (themeMode.value) {
+ case THEME_MODES.LIGHT:
+ return 'sun'
+ case THEME_MODES.DARK:
+ return 'moon'
+ case THEME_MODES.SYSTEM:
+ return 'monitor'
+ default:
+ return 'monitor'
+ }
+ })
+
+ const themeLabel = computed(() => {
+ switch (themeMode.value) {
+ case THEME_MODES.LIGHT:
+ return '浅色模式'
+ case THEME_MODES.DARK:
+ return '深色模式'
+ case THEME_MODES.SYSTEM:
+ return '跟随系统'
+ default:
+ return '跟随系统'
+ }
+ })
+
+ return {
+ // 状态
+ isDarkMode,
+ themeMode,
+
+ // 计算属性
+ themeIcon,
+ themeLabel,
+
+ // 方法
+ setThemeMode,
+ toggleTheme,
+ initTheme,
+ checkSystemColorScheme
+ }
+}
diff --git a/src/constants/index.ts b/src/constants/index.ts
new file mode 100644
index 0000000..ae2ab41
--- /dev/null
+++ b/src/constants/index.ts
@@ -0,0 +1,100 @@
+// 应用常量配置
+
+// 存储键名
+export const STORAGE_KEYS = {
+ COLOR_MODE: 'colorMode',
+ ADMIN_PASSWORD: 'adminPassword',
+ TOKEN: 'token',
+ TOKEN_EXPIRES_AT: 'tokenExpiresAt',
+ CONFIG: 'config',
+ NOTIFY: 'notify'
+} as const
+
+// 主题模式
+export const THEME_MODES = {
+ LIGHT: 'light',
+ DARK: 'dark',
+ SYSTEM: 'system'
+} as const
+
+// 发送类型
+export const SEND_TYPES = {
+ FILE: 'file',
+ TEXT: 'text'
+} as const
+
+// 警告类型
+export const ALERT_TYPES = {
+ SUCCESS: 'success',
+ ERROR: 'error',
+ WARNING: 'warning',
+ INFO: 'info'
+} as const
+
+// 上传状态
+export const UPLOAD_STATUS = {
+ IDLE: 'idle',
+ UPLOADING: 'uploading',
+ SUCCESS: 'success',
+ ERROR: 'error'
+} as const
+
+// API 响应状态码
+export const API_STATUS_CODES = {
+ SUCCESS: 200,
+ UNAUTHORIZED: 401,
+ SETUP_REQUIRED: 428,
+ FORBIDDEN: 403,
+ NOT_FOUND: 404,
+ SERVER_ERROR: 500
+} as const
+
+// 文件大小限制 (字节)
+export const FILE_SIZE_LIMITS = {
+ MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB
+ CHUNK_SIZE: 1024 * 1024 // 1MB
+} as const
+
+// 时间相关常量
+export const TIME_CONSTANTS = {
+ ALERT_DURATION: 5000, // 5秒
+ REQUEST_TIMEOUT: 300000000,
+ PROGRESS_UPDATE_INTERVAL: 100 // 100毫秒
+} as const
+
+// 路由路径
+export const ROUTES = {
+ HOME: '/',
+ SEND: '/send',
+ ADMIN: '/admin',
+ LOGIN: '/login',
+ DASHBOARD: '/admin/dashboard',
+ FILE_MANAGE: '/admin/files',
+ SETTINGS: '/admin/settings'
+} as const
+
+export const ROUTE_NAMES = {
+ RETRIEVE: 'Retrieve',
+ SEND: 'Send',
+ ADMIN: 'Manage',
+ LOGIN: 'Login',
+ DASHBOARD: 'Dashboard',
+ FILE_MANAGE: 'FileManage',
+ SETTINGS: 'Settings'
+} as const
+
+// 正则表达式
+export const REGEX_PATTERNS = {
+ EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
+ PHONE: /^1[3-9]\d{9}$/,
+ CODE: /^[A-Za-z0-9]{4,8}$/ // 取件码格式
+} as const
+
+// 默认配置
+export const DEFAULT_CONFIG = {
+ name: 'FileCodeBox',
+ description: '文件传输工具',
+ maxFileSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE,
+ allowedFileTypes: ['*'] as string[],
+ expireDays: 7
+}
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000..0207fdd
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1,14 @@
+// 自定义 Vue hooks 导出文件
+// 这里可以导出所有自定义的 Vue hooks
+
+// 示例:导出常用的自定义 hooks
+// export { useLocalStorage } from './useLocalStorage'
+// export { useDebounce } from './useDebounce'
+// export { useThrottle } from './useThrottle'
+// export { useEventListener } from './useEventListener'
+// export { useClickOutside } from './useClickOutside'
+
+// 目前项目中的业务逻辑已经通过 composables 进行了封装
+// hooks 文件夹主要用于存放更通用的、可复用的 Vue hooks
+
+export {}
\ No newline at end of file
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 0000000..7072171
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,67 @@
+import { createI18n } from 'vue-i18n'
+import zhCN from './locales/zh-CN'
+import enUS from './locales/en-US'
+import { readStoredLocale, writeStoredLocale } from '@/utils/preference-storage'
+
+type SupportedLocale = 'zh-CN' | 'en-US'
+
+const normalizeLocale = (locale: string | null | undefined): SupportedLocale | null => {
+ if (!locale) return null
+
+ const normalized = locale.trim().toLowerCase()
+ if (normalized === 'zh' || normalized.startsWith('zh-')) return 'zh-CN'
+ if (normalized === 'en' || normalized.startsWith('en-')) return 'en-US'
+
+ return null
+}
+
+// 获取浏览器语言设置
+const getDefaultLocale = (): SupportedLocale => {
+ const savedLocale = normalizeLocale(readStoredLocale())
+ if (savedLocale) {
+ writeStoredLocale(savedLocale)
+ return savedLocale
+ }
+
+ return normalizeLocale(navigator.language) ?? 'en-US'
+}
+
+const messages = {
+ 'zh-CN': zhCN,
+ 'en-US': enUS
+}
+
+const defaultLocale = getDefaultLocale()
+
+const i18n = createI18n({
+ legacy: false, // 使用 Composition API 模式
+ locale: defaultLocale,
+ fallbackLocale: 'zh-CN',
+ messages,
+ globalInjection: true, // 全局注入 $t 函数
+ missingWarn: import.meta.env.DEV,
+ fallbackWarn: import.meta.env.DEV
+})
+
+document.documentElement.lang = defaultLocale
+
+export default i18n
+
+// 导出切换语言的函数
+export const setLocale = (locale: string) => {
+ const nextLocale = normalizeLocale(locale) ?? 'zh-CN'
+ i18n.global.locale.value = nextLocale
+ writeStoredLocale(nextLocale)
+ document.documentElement.lang = nextLocale
+}
+
+// 导出获取当前语言的函数
+export const getCurrentLocale = () => {
+ return i18n.global.locale.value
+}
+
+// 导出可用语言列表
+export const availableLocales = [
+ { code: 'zh-CN', name: '中文' },
+ { code: 'en-US', name: 'English' }
+]
diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts
new file mode 100644
index 0000000..ddca646
--- /dev/null
+++ b/src/i18n/locales/en-US.ts
@@ -0,0 +1,999 @@
+export default {
+ // Common
+ common: {
+ confirm: 'Confirm',
+ cancel: 'Cancel',
+ close: 'Close',
+ delete: 'Delete',
+ edit: 'Edit',
+ save: 'Save',
+ add: 'Add',
+ back: 'Back',
+ next: 'Next',
+ previous: 'Previous',
+ loading: 'Loading...',
+ noData: 'No Data',
+ success: 'Success',
+ error: 'Error',
+ warning: 'Warning',
+ info: 'Information',
+ search: 'Search',
+ copy: 'Copy',
+ uploadSuccess: 'Upload successful',
+ uploadFailed: 'Upload failed',
+ copySuccess: 'Copy successful',
+ copyFailed: 'Copy failed',
+ deleteSuccess: 'Delete successful',
+ deleteFailed: 'Delete failed',
+ downloadSuccess: 'Download successful',
+ downloadFailed: 'Download failed',
+ shareSuccess: 'Share successful',
+ shareFailed: 'Share failed',
+ expiredFile: 'File has expired',
+ fileNotFound: 'File not found',
+ networkError: 'Network error',
+ unknownError: 'Unknown error',
+ invalidFileType: 'Invalid file type',
+ fileTooLarge: 'File too large',
+ uploadCanceled: 'Upload canceled',
+ processing: 'Processing',
+ pleaseWait: 'Please wait',
+ fileDetails: 'File Details',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ minute: 'minute',
+ files: 'files',
+ second: 'second',
+ hour: 'hour',
+ day: 'day',
+ times: 'times',
+ appName: 'FileCodeBox - File Transfer',
+ appDescription: 'Ready-to-use file transfer system'
+ },
+
+ // Admin module
+ admin: {
+ logout: 'Logout',
+ session: {
+ loggedIn: 'Signed in, open admin console',
+ loggedOut: 'Sign in to admin console'
+ },
+ dashboard: {
+ title: 'Dashboard',
+ totalFiles: 'Total Files',
+ storageSpace: 'Storage Space',
+ todayShares: 'Today Shares',
+ totalRetrievals: 'Total Retrievals',
+ activeFiles: 'Active files: {count}',
+ todayIncrease: 'Today added: {count}',
+ yesterdayShares: 'Yesterday: {count}',
+ serverUptime: 'Uptime',
+ refresh: 'Refresh',
+ refreshing: 'Refreshing',
+ lastUpdated: 'Last updated: {time}',
+ loadFailed: 'Failed to load dashboard data',
+ footerProduct: 'FileCodeBox Admin Console',
+ backendVersion: 'Server Version',
+ frontendVersion: 'Frontend Version',
+ versionPending: 'Pending sync',
+ fileHealth: 'File Health',
+ fileHealthDesc: 'Status insights for available, risky, and pending file queues.',
+ activeFileRatio: 'Active Ratio',
+ fileShareRatio: 'File Ratio',
+ textShareRatio: 'Text Ratio',
+ binaryFiles: '{count} file shares',
+ textShares: '{count} text shares',
+ expiredFiles: 'Expired Files',
+ needCleanup: 'Clean up in file management',
+ chunkedFiles: 'Chunked Files',
+ storagePolicy: 'Storage & Upload Policy',
+ storagePolicyDesc: 'Current settings that affect upload behavior.',
+ storageBackend: 'Storage Backend',
+ singleFileLimit: 'Single File Limit',
+ guestUpload: 'Guest Upload',
+ maxSaveTime: 'Max Retention',
+ noSaveLimit: 'Unlimited',
+ todayCapacityReference: 'Today Size / Single File Limit',
+ healthActions: {
+ attention: {
+ title: 'Needs Attention',
+ description: 'Handle issue and warning files together'
+ },
+ storageIssue: {
+ title: 'Storage Issues',
+ description: 'Review files missing download metadata'
+ },
+ expiringSoon: {
+ title: 'Expiring Soon',
+ description: 'Extend shares that should stay available'
+ },
+ neverRetrieved: {
+ title: 'Never Retrieved',
+ description: 'Find shares that have not been retrieved yet'
+ },
+ permanent: {
+ title: 'Permanent',
+ description: 'View long-retention share records'
+ }
+ }
+ },
+ fileManage: {
+ title: 'File Management'
+ },
+ settings: {
+ title: 'System Settings',
+ basicSettings: 'General',
+ websiteInfo: 'Site Information',
+ websiteName: 'Site Name',
+ websiteDescription: 'Site Description',
+ siteName: 'Site Name',
+ adminPassword: 'Admin Password',
+ sessionDuration: 'Login Session Duration',
+ sessionDurationHelp: 'Applies to new logins only. Allowed range: 1 to 365 days.',
+ showAdminAddress: 'Show Admin Entry on Home Pages',
+ passwordPlaceholder: 'Enter new password to change',
+ passwordNote: 'Leave empty to keep current',
+ keywords: 'SEO Keywords',
+ themeSelection: 'Theme',
+ robotsFile: 'Robots.txt Configuration',
+ notificationSettings: 'Notifications',
+ notificationTitle: 'Announcement Title',
+ notificationContent: 'Announcement Content',
+ storageSettings: 'Storage',
+ storageLimit: 'Total Storage Capacity',
+ storageLimitHelp:
+ 'Active shares and pending uploads share this quota. Set to 0 for unlimited.',
+ storagePath: 'Storage Path',
+ storagePathPlaceholder: 'Leave empty for default path',
+ storageMethod: 'Storage Type',
+ localStorage: 'Local Storage',
+ s3Storage: 'S3 Object Storage',
+ webdavStorage: 'WebDAV Storage',
+ chunkUploadNote: 'Chunked Upload (experimental, better for large files)',
+ s3AccessKeyId: 'Access Key ID',
+ s3SecretAccessKey: 'Secret Access Key',
+ s3BucketName: 'Bucket Name',
+ s3EndpointUrl: 'Endpoint URL',
+ s3RegionName: 'Region Name',
+ s3SignatureVersion: 'Signature Version',
+ s3AddressingStyle: 'S3 Addressing Style',
+ s3AddressingAuto: 'Auto',
+ s3AddressingPath: 'Path-style',
+ s3AddressingVirtual: 'Virtual-host',
+ s3Hostname: 'Custom Domain',
+ s3v2: 'S3v2',
+ s3v4: 'S3v4',
+ autoPlaceholder: 'Auto-detect',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ webdavUrl: 'Enter WebDAV server URL',
+ webdavUsername: 'Enter WebDAV username',
+ webdavPassword: 'Enter WebDAV password',
+ webdavUrlPlaceholder: 'Enter WebDAV server URL',
+ webdavUsernamePlaceholder: 'Enter WebDAV username',
+ webdavPasswordPlaceholder: 'Enter WebDAV password',
+ enableProxy: 'Enable Download Proxy',
+ uploadLimits: 'Upload Settings',
+ uploadRateLimit: 'Time Window (limit uploads within this period)',
+ uploadPerMinute: 'Time Window (limit uploads within this period)',
+ minute: 'min',
+ uploadCountLimit: 'Max Files Allowed (within the time window)',
+ files: 'files',
+ fileSizeLimit: 'Max File Size',
+ allowedFileTypes: 'Allowed File Types',
+ allowedFileTypesPlaceholder: '* or .rar, .zip, image/*',
+ allowedFileTypesHelp:
+ 'Separate multiple types with commas. * allows all; extensions and MIME wildcards are supported.',
+ codeGenerateType: 'Passcode Rule',
+ codeGenerateNumber: 'Numeric Passcode',
+ codeGenerateSecret: 'Secret Passcode',
+ codeGenerateTypeHelp:
+ 'Numeric keeps the existing 5-digit code. Secret uses a random alphanumeric code.',
+ expirationMethod: 'Expiration Options',
+ expirationType: 'Expiration Options',
+ expiration: {
+ day: 'Days',
+ hour: 'Hours',
+ minute: 'Minutes',
+ forever: 'Never Expire',
+ count: 'Download Count'
+ },
+ maxSaveTime: 'Max Retention Period',
+ timeUnits: {
+ second: 'sec',
+ minute: 'min',
+ hour: 'hr',
+ day: 'day'
+ },
+ guestUpload: 'Allow Guest Uploads',
+ errorLimits: 'Access Protection',
+ errorRateLimit: 'Detection Window (count errors within this period)',
+ errorPerMinute: 'Detection Window (count errors within this period)',
+ errorCountLimit: 'Max Errors Allowed (block after exceeding)',
+ times: 'times',
+ saveChanges: 'Save Settings',
+ fileSizeUnits: {
+ kb: 'KB',
+ mb: 'MB',
+ gb: 'GB'
+ }
+ }
+ },
+
+ // Navigation
+ nav: {
+ sendFile: 'Send File',
+ retrieveFile: 'Retrieve',
+ fileRecords: 'File Records'
+ },
+
+ // Retrieve page
+ retrieve: {
+ title: 'Retrieve File',
+ codeInput: {
+ placeholder: 'Enter your 5-character retrieval code',
+ label: 'Retrieval Code'
+ },
+ submit: 'Retrieve Now',
+ needSendFile: 'Need to send a file?',
+ recordsDrawer: 'Retrieval Records',
+ messages: {
+ invalidCode: 'Please enter a 5-character retrieval code',
+ retrieveSuccess: 'File retrieved successfully',
+ invalidCodeError: 'Invalid retrieval code',
+ retrieveFailure: 'Failed to retrieve file: ',
+ networkError: 'Retrieval failed, please try again later: ',
+ 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'
+ }
+ },
+
+ // Send page
+ send: {
+ title: 'File Send',
+ sendText: 'Send Text',
+ uploadArea: {
+ dragText: 'Drag files here or',
+ clickText: 'click to select files',
+ textInput: 'Enter text to send here...',
+ placeholder: 'Click or drag files here to upload',
+ description: 'Supports various common formats',
+ descriptionWithLimit: 'Supports common formats, up to {size}',
+ retry: 'Retry',
+ selectedFiles: '{count} files selected',
+ status: {
+ initializing: 'Preparing upload...',
+ uploading: 'Uploading files...',
+ confirming: 'Confirming upload...',
+ success: 'Upload complete!'
+ }
+ },
+ submit: 'Secure Send',
+ submitting: 'Sending...',
+ needRetrieveFile: 'Need to retrieve? Click here',
+ sendRecords: 'Send Records',
+ secureEncryption: 'Secure Encryption',
+ workspace: {
+ uploadLimit: 'Single File Limit',
+ uploadMode: 'Upload Mode',
+ standardMode: 'Standard',
+ chunkMode: 'Chunked',
+ guestPolicy: 'Guest Upload',
+ guestOpen: 'Allowed',
+ guestClosed: 'Disabled',
+ currentTask: 'Current Task',
+ awaitingFile: 'Waiting for files',
+ fileReady: '{count} files ready',
+ textDraft: 'Text draft, {count} chars',
+ payload: 'Payload',
+ expirationPreview: 'Expiration',
+ security: 'Security',
+ latestRecord: 'Latest Send',
+ noRecord: 'No records yet',
+ copyLink: 'Copy Link',
+ viewDetail: 'View Detail',
+ historyTitle: 'Send History',
+ historyCount: '{count} records',
+ openRecords: 'Open send records'
+ },
+ fileDetails: 'File Details',
+ expirationMethod: 'Expiration Method',
+ expiration: {
+ day: 'By Day',
+ hour: 'By Hour',
+ minute: 'By Minute',
+ forever: 'Forever',
+ count: 'By Count',
+ label: 'Expiration Time',
+ placeholders: {
+ days: 'Enter days',
+ hours: 'Enter hours',
+ minutes: 'Enter minutes',
+ count: 'Enter view count',
+ forever: 'Forever',
+ default: 'Enter value'
+ },
+ units: {
+ days: 'days',
+ hours: 'hours',
+ minutes: 'minutes',
+ times: 'times',
+ forever: 'Forever'
+ }
+ },
+ time: {
+ day: 'By Day',
+ hour: 'By Hour',
+ minute: 'By Minute',
+ forever: 'Forever',
+ count: 'By Count'
+ },
+ messages: {
+ selectFile: 'Please select a file to upload',
+ enterText: 'Please enter text to send',
+ enterExpirationValue: 'Please enter expiration value',
+ expirationTooLong: 'Expiration time cannot exceed {days} days',
+ sendSuccess: 'File sent successfully! Retrieve code: {code}',
+ initChunkUploadFailed: 'Failed to initialize chunk upload',
+ chunkUploadFailed: 'Chunk {index} upload failed',
+ completeUploadFailed: 'Failed to complete upload',
+ uploadFailed: 'Upload failed, please try again later',
+ guestUploadDisabled: 'Guest upload feature is disabled',
+ fileSizeExceeded: 'File size exceeds limit ({size})',
+ fileTypeNotAllowed: 'This file type is not allowed. Allowed types: {types}',
+ serverError: 'Server response error',
+ sendFailed: 'Send failed, please try again later',
+ expiresAfterCount: 'Expires after {count} times',
+ expiresAt: 'Expires at {date}',
+ emptyFileError: 'Cannot read empty file',
+ fileAddedFromClipboard: 'File added from clipboard: {filename}',
+ fileProcessingFailed: 'File processing failed',
+ expiresAfter: 'Expires after {value} {unit}'
+ }
+ },
+
+ // File records
+ fileRecord: {
+ filename: 'Filename',
+ size: 'Size',
+ date: 'Date',
+ code: 'Code',
+ actions: 'Actions',
+ download: 'Download',
+ viewDetails: 'View Details',
+ deleteRecord: 'Delete Record',
+ preview: 'Preview',
+ copyContent: 'Copy Content',
+ contentCopied: 'Content copied to clipboard',
+ copyFailed: 'Copy failed, please try again'
+ },
+
+ // File Detail Modal
+ fileDetail: {
+ title: 'File Details',
+ content: 'File Content',
+ previewContent: 'Preview Content',
+ download: 'Click to Download',
+ qrCode: 'Retrieve QR Code',
+ scanQrCode: 'Scan QR code for quick retrieval'
+ },
+
+ // Content Preview Modal
+ contentPreview: {
+ title: 'Content Preview'
+ },
+
+ // File Management
+ fileManage: {
+ title: 'File Management',
+ subtitle: '{count} share records. Filter by status, type, and usage.',
+ searchPlaceholder: 'Search code, name, or text...',
+ allFiles: 'All Files',
+ detail: 'Details',
+ detailTitle: 'File Details',
+ detailLoading: 'Loading details...',
+ detailFailed: 'Failed to load details',
+ editFileInfo: 'Edit File Information',
+ saveChanges: 'Save Changes',
+ refresh: 'Refresh List',
+ resetFilters: 'Reset Filters',
+ totalFiles: 'All Records',
+ activeFiles: 'Available',
+ expiredFiles: 'Expired',
+ storageUsed: 'Storage Used',
+ statusLabel: 'Status',
+ typeLabel: 'Type',
+ healthLabel: 'Health',
+ viewPreset: 'View',
+ viewPresetCustom: 'Custom Filters',
+ viewPresetAll: 'All Files',
+ viewPresetAttention: 'Needs Attention',
+ viewPresetExpiringSoon: 'Expiring Soon',
+ viewPresetStorageIssue: 'Storage Issues',
+ viewPresetNeverRetrieved: 'Never Retrieved',
+ viewPresetPermanent: 'Permanent',
+ saveViewPreset: 'Save View',
+ updateViewPreset: 'Update View',
+ deleteViewPreset: 'Delete View',
+ viewPresetNamePrompt: 'Enter a view name',
+ viewPresetSaveSuccess: 'View saved',
+ viewPresetSaveFailed: 'Failed to save view',
+ viewPresetDeleteConfirm: 'Delete view "{name}"?',
+ viewPresetDeleteSuccess: 'View deleted',
+ viewPresetDeleteFailed: 'Failed to delete view',
+ viewPresetLoadFailed: 'Failed to load saved views; using built-in views',
+ all: 'All',
+ active: 'Available',
+ expired: 'Expired',
+ fileType: 'File',
+ textType: 'Text',
+ chunkedType: 'Chunked',
+ healthFilters: {
+ all: 'All',
+ attention: 'Needs Attention',
+ danger: 'Issues',
+ warning: 'Warnings',
+ expiringSoon: 'Expiring Soon',
+ storageIssue: 'Storage Issues',
+ neverRetrieved: 'Never Retrieved',
+ healthy: 'Healthy',
+ permanent: 'Permanent'
+ },
+ sortBy: 'Sort',
+ sortOrder: 'Direction',
+ unlimited: 'Unlimited',
+ remaining: '{count} left',
+ loadError: 'Failed to load file list',
+ noMatches: 'No matching files',
+ viewText: 'View',
+ textPreview: 'Text Preview',
+ copyText: 'Copy Text',
+ copyCode: 'Copy Code',
+ copyLink: 'Copy Link',
+ copySuccess: 'Text copied to clipboard',
+ copyCodeSuccess: 'Retrieve code copied to clipboard',
+ copyLinkSuccess: 'Retrieve link copied to clipboard',
+ copyFailed: 'Copy failed, please try again',
+ charCount: '{count} characters',
+ downloadFile: 'Download File',
+ exportText: 'Export Text',
+ downloadSuccess: 'Downloaded: {name}',
+ exportSuccess: 'Exported: {name}',
+ downloadFailed: 'Download failed',
+ previewFailed: 'Failed to load preview',
+ loadingPreview: 'Loading preview...',
+ previewComplete: 'Loaded full content, {count} characters',
+ previewTruncated: 'Showing {shown} / {total} characters',
+ previewFallback: 'Preview endpoint unavailable; using text from the list',
+ selectCurrentPage: 'Select current page',
+ selectedCount: '{count} selected',
+ selectFile: 'Select {name}',
+ clearSelection: 'Clear Selection',
+ batchDelete: 'Batch Delete',
+ batchDeleteConfirm: 'Delete the selected {count} files? This action cannot be undone.',
+ batchDeleteSuccess: 'Deleted {count} files',
+ batchDeletePartialSuccess: 'Deleted {count} files, {failed} failed',
+ batchDeleteFailed: 'Batch delete failed',
+ batchEdit: 'Batch Edit',
+ batchEditTitle: 'Batch Edit File Policy',
+ batchEditSelected: 'Update the selected {count} files',
+ batchEditMode: 'Update Mode',
+ batchEditExpiresAt: 'Set Expiration Time',
+ batchEditDownloadLimit: 'Set Retrieval Limit',
+ batchEditForever: 'Make Permanent',
+ batchEditForeverHint: 'Clear the expiration time and make retrievals unlimited.',
+ batchUpdateConfirm: 'Update the selected {count} files?',
+ batchUpdateSuccess: 'Updated {count} files',
+ batchUpdatePartialSuccess: 'Updated {count} files, {failed} failed',
+ batchUpdateFailed: 'Batch update failed',
+ batchUpdateNoFields: 'Choose a policy to update',
+ batchPolicyActionConfirm: 'Apply "{action}" to the selected {count} files?',
+ batchPolicyActionSuccess: 'Processed {count} files',
+ batchPolicyActionPartialSuccess: 'Processed {count} files, {failed} failed',
+ batchPolicyActionFailed: 'Batch policy action failed',
+ policyActionSuccess: 'File policy updated',
+ policyActionFailed: 'Failed to apply policy action',
+ metadataInfo: 'Operations Notes',
+ metadataHint:
+ 'Visible to admins only. Use it for handling notes, archive tags, and follow-up actions.',
+ metadataNote: 'Note',
+ metadataNotePlaceholder: 'Record context, handling history, or ownership...',
+ metadataTags: 'Tags',
+ metadataTagsPlaceholder: 'Comma-separated, e.g. Client A, Renew, Important',
+ metadataUpdatedAt: 'Last saved: {time}',
+ metadataNeverUpdated: 'No notes saved yet',
+ saveMetadata: 'Save Notes',
+ metadataSaveSuccess: 'Notes and tags saved',
+ metadataSaveFailed: 'Failed to save notes',
+ policyActions: {
+ extend24h: 'Extend 24h',
+ extend7d: 'Extend 7d',
+ makePermanent: 'Make Permanent',
+ resetDownloadLimit: 'Reset to {count}'
+ },
+ policyActionDescriptions: {
+ extend24h: 'Add one day from the active expiration',
+ extend7d: 'Good for a one-week temporary extension',
+ makePermanent: 'Clear expiration time and retrieval limits',
+ resetDownloadLimit: 'Restore retrievals to {count}'
+ },
+ applyBatchEdit: 'Apply Changes',
+ overview: 'Overview',
+ policyInfo: 'Policy',
+ storageInfo: 'Storage',
+ statusInsight: 'Status Insight',
+ nextAction: 'Next Action',
+ lifecycle: 'Lifecycle',
+ permanent: 'Permanent',
+ createdAt: 'Created Time',
+ remainingDownloads: 'Remaining Downloads',
+ textLength: 'Text Length',
+ hasDownloadLimit: 'Has Retrieval Limit',
+ hasExpirationTime: 'Has Expiration',
+ canPreviewText: 'Can Preview Text',
+ canDownload: 'Can Download',
+ storageBackend: 'Storage Backend',
+ fileHash: 'File Hash',
+ isChunked: 'Chunked Upload',
+ filePath: 'Storage Path',
+ uuidFileName: 'Stored File Name',
+ uploadId: 'Upload Session',
+ yes: 'Yes',
+ no: 'No',
+ insightSeverity: {
+ success: 'Healthy',
+ warning: 'Needs Attention',
+ danger: 'Issue',
+ info: 'Info',
+ neutral: 'Watch'
+ },
+ insightStates: {
+ available: 'Available for retrieval',
+ expired: 'Unavailable for retrieval',
+ storage_incomplete: 'Storage metadata incomplete',
+ expiring_soon: 'Expiring soon',
+ permanent: 'Permanent'
+ },
+ insightActions: {
+ monitor: 'Keep monitoring',
+ extend_or_delete: 'Extend retention or clean up',
+ inspect_storage: 'Inspect storage path and file name',
+ extend_expiration: 'Extend expiration if needed'
+ },
+ insightReasons: {
+ expired: 'Past expiration time',
+ download_limit_exhausted: 'Retrieval limit exhausted',
+ expires_soon: 'Expires within 24 hours',
+ never_retrieved: 'Never retrieved',
+ storage_metadata_incomplete: 'Missing downloadable storage metadata',
+ chunked_upload: 'Created by chunked upload'
+ },
+ timeline: {
+ status: {
+ done: 'Done',
+ pending: 'Pending',
+ active: 'Active',
+ expired: 'Expired',
+ exhausted: 'Exhausted',
+ unlimited: 'Unlimited'
+ },
+ created: {
+ title: 'Record Created',
+ description: 'The share record has been created and is currently {status}.'
+ },
+ content_ready: {
+ title: 'Content Ready',
+ description: 'Content type is {detail}; current status is {status}.'
+ },
+ upload_session: {
+ title: 'Upload Session',
+ description: 'Upload session recorded: {detail}.'
+ },
+ expiration_policy: {
+ title: 'Expiration Policy',
+ description: 'Expiration policy is currently {status}.',
+ remaining: '{time} left',
+ overdue: '{time} overdue'
+ },
+ download_limit: {
+ title: 'Retrieval Limit',
+ description: 'Retrieval limit is currently {status}; {value}.'
+ },
+ retrieved: {
+ title: 'Retrieval History',
+ description: 'Retrieved {value}; current status is {status}.'
+ }
+ },
+ headers: {
+ select: 'Select',
+ code: 'Retrieve Code',
+ name: 'Name',
+ type: 'Type',
+ size: 'Size',
+ usage: 'Retrievals',
+ status: 'Status',
+ description: 'Description',
+ expiration: 'Expiration',
+ actions: 'Actions'
+ },
+ sort: {
+ createdAt: 'Created Time',
+ expiredAt: 'Expiration',
+ name: 'Name',
+ size: 'Size',
+ usedCount: 'Retrievals',
+ code: 'Retrieve Code',
+ desc: 'Descending',
+ asc: 'Ascending'
+ },
+ form: {
+ code: 'Retrieve Code',
+ codePlaceholder: 'Enter retrieve code',
+ filename: 'File Name',
+ filenamePlaceholder: 'Enter file name',
+ suffix: 'File Extension',
+ suffixPlaceholder: 'Enter file extension',
+ downloadLimit: 'Download Limit',
+ downloadLimitPlaceholder: 'Enter download limit'
+ }
+ },
+
+ // Side drawer
+ drawer: {
+ noRecords: 'No records'
+ },
+
+ // Admin pages
+ manage: {
+ settings: {
+ title: 'System Settings',
+ basicSettings: 'General',
+ websiteInfo: 'Site Information',
+ websiteName: 'Site Name',
+ websiteDescription: 'Site Description',
+ siteName: 'Site Name',
+ adminPassword: 'Admin Password',
+ passwordPlaceholder: 'Enter new password to change',
+ passwordNote: 'Leave empty to keep current',
+ keywords: 'SEO Keywords',
+ themeSelection: 'Theme',
+ notificationSettings: 'Notifications',
+ notificationTitle: 'Announcement Title',
+ notificationContent: 'Announcement Content',
+ storageSettings: 'Storage',
+ storagePath: 'Storage Path',
+ storagePathPlaceholder: 'Leave empty for default path',
+ storageMethod: 'Storage Type',
+ localStorage: 'Local Storage',
+ s3Storage: 'S3 Object Storage',
+ webdavStorage: 'WebDAV Storage',
+ chunkUploadNote: 'Chunked Upload (experimental, better for large files)',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ webdavUrl: 'Enter WebDAV server URL',
+ webdavUsername: 'Enter WebDAV username',
+ webdavPassword: 'Enter WebDAV password',
+ webdavUrlPlaceholder: 'Enter WebDAV server URL',
+ webdavUsernamePlaceholder: 'Enter WebDAV username',
+ webdavPasswordPlaceholder: 'Enter WebDAV password',
+ enableProxy: 'Enable Download Proxy',
+ uploadLimits: 'Upload Settings',
+ uploadRateLimit: 'Time Window (limit uploads within this period)',
+ uploadPerMinute: 'Time Window (limit uploads within this period)',
+ minute: 'min',
+ uploadCountLimit: 'Max Files Allowed (within the time window)',
+ files: 'files',
+ fileSizeLimit: 'Max File Size',
+ allowedFileTypes: 'Allowed File Types',
+ allowedFileTypesPlaceholder: '* or .rar, .zip, image/*',
+ allowedFileTypesHelp:
+ 'Separate multiple types with commas. * allows all; extensions and MIME wildcards are supported.',
+ codeGenerateType: 'Passcode Rule',
+ codeGenerateNumber: 'Numeric Passcode',
+ codeGenerateSecret: 'Secret Passcode',
+ codeGenerateTypeHelp:
+ 'Numeric keeps the existing 5-digit code. Secret uses a random alphanumeric code.',
+ expirationMethod: 'Expiration Options',
+ expirationType: 'Expiration Options',
+ expiration: {
+ day: 'Days',
+ hour: 'Hours',
+ minute: 'Minutes',
+ forever: 'Never Expire',
+ count: 'Download Count'
+ },
+ maxSaveTime: 'Max Retention Period',
+ timeUnits: {
+ second: 'sec',
+ minute: 'min',
+ hour: 'hr',
+ day: 'day'
+ },
+ guestUpload: 'Allow Guest Uploads',
+ errorLimits: 'Access Protection',
+ errorRateLimit: 'Detection Window (count errors within this period)',
+ errorPerMinute: 'Detection Window (count errors within this period)',
+ errorCountLimit: 'Max Errors Allowed (block after exceeding)',
+ times: 'times',
+ fileSizeUnits: {
+ kb: 'KB',
+ mb: 'MB',
+ gb: 'GB'
+ },
+ saveChanges: 'Save Settings',
+ refreshConfig: 'Refresh Config',
+ refreshing: 'Refreshing',
+ saving: 'Saving',
+ unsavedChanges: 'Unsaved configuration changes',
+ allChangesSaved: 'All configuration changes are saved',
+ refreshBlocked: 'Save current changes before refreshing'
+ },
+ systemSettings: {
+ title: 'System Settings',
+ basicSettings: 'General',
+ websiteInfo: 'Site Information',
+ websiteName: 'Site Name',
+ websiteDescription: 'Site Description',
+ adminPassword: 'Admin Password',
+ passwordPlaceholder: 'Enter new password to change',
+ passwordNote: 'Leave empty to keep current',
+ keywords: 'SEO Keywords',
+ themeSelection: 'Theme',
+ notificationSettings: 'Notifications',
+ notificationTitle: 'Announcement Title',
+ notificationContent: 'Announcement Content',
+ storageSettings: 'Storage',
+ storagePath: 'Storage Path',
+ storagePathPlaceholder: 'Leave empty for default path',
+ storageMethod: 'Storage Type',
+ localStorage: 'Local Storage',
+ s3Storage: 'S3 Object Storage',
+ webdavStorage: 'WebDAV Storage',
+ chunkUploadNote: 'Chunked Upload (experimental, better for large files)',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ webdavUrl: 'Enter WebDAV server URL',
+ webdavUsername: 'Enter WebDAV username',
+ webdavPassword: 'Enter WebDAV password',
+ enableProxy: 'Enable Download Proxy',
+ uploadLimits: 'Upload Settings',
+ uploadRateLimit: 'Time Window (limit uploads within this period)',
+ minute: 'min',
+ uploadCountLimit: 'Max Files Allowed (within the time window)',
+ files: 'files',
+ fileSizeLimit: 'Max File Size',
+ codeGenerateType: 'Passcode Rule',
+ codeGenerateNumber: 'Numeric Passcode',
+ codeGenerateSecret: 'Secret Passcode',
+ codeGenerateTypeHelp:
+ 'Numeric keeps the existing 5-digit code. Secret uses a random alphanumeric code.',
+ expirationMethod: 'Expiration Options',
+ expirationMethods: {
+ day: 'Days',
+ hour: 'Hours',
+ minute: 'Minutes',
+ forever: 'Never Expire',
+ count: 'Download Count'
+ },
+ expiration: {
+ day: 'Days',
+ hour: 'Hours',
+ minute: 'Minutes',
+ forever: 'Never Expire',
+ count: 'Download Count'
+ },
+ maxSaveTime: 'Max Retention Period',
+ timeUnits: {
+ second: 'sec',
+ minute: 'min',
+ hour: 'hr',
+ day: 'day'
+ },
+ guestUpload: 'Allow Guest Uploads',
+ errorLimits: 'Access Protection',
+ errorRateLimit: 'Detection Window (count errors within this period)',
+ errorCountLimit: 'Max Errors Allowed (block after exceeding)',
+ times: 'times',
+ saveSettings: 'Save Settings',
+ saveSuccess: 'Settings saved successfully',
+ saveFailed: 'Failed to save, please try again',
+ getConfigFailed: 'Failed to load settings, please refresh'
+ },
+ dashboard: {
+ title: 'Dashboard',
+ totalFiles: 'Total Files',
+ storageSpace: 'Storage Space',
+ todayShares: 'Today Shares',
+ totalRetrievals: 'Total Retrievals',
+ activeFiles: 'Active files: {count}',
+ todayIncrease: 'Today added: {count}',
+ yesterdayShares: 'Yesterday: {count}',
+ serverUptime: 'Uptime',
+ refresh: 'Refresh',
+ fileHealth: 'File Health',
+ fileHealthDesc: 'Status insights for available, risky, and pending file queues.',
+ activeFileRatio: 'Active Ratio',
+ fileShareRatio: 'File Ratio',
+ textShareRatio: 'Text Ratio',
+ binaryFiles: '{count} file shares',
+ textShares: '{count} text shares',
+ expiredFiles: 'Expired Files',
+ needCleanup: 'Clean up in file management',
+ chunkedFiles: 'Chunked Files',
+ storagePolicy: 'Storage & Upload Policy',
+ storagePolicyDesc: 'Current settings that affect upload behavior.',
+ storageBackend: 'Storage Backend',
+ singleFileLimit: 'Single File Limit',
+ guestUpload: 'Guest Upload',
+ maxSaveTime: 'Max Retention',
+ noSaveLimit: 'Unlimited',
+ todayCapacityReference: 'Today Size / Single File Limit'
+ },
+ fileManage: {
+ title: 'File Management',
+ searchPlaceholder: 'Search file name, description...',
+ allFiles: 'All Files',
+ editFileInfo: 'Edit File Info',
+ saveChanges: 'Save Changes',
+ headers: {
+ code: 'Code',
+ name: 'Name',
+ size: 'Size',
+ description: 'Description',
+ expiration: 'Expiration',
+ actions: 'Actions'
+ },
+ form: {
+ code: 'Code',
+ codePlaceholder: 'Enter code',
+ filename: 'File Name',
+ filenamePlaceholder: 'Enter file name',
+ suffix: 'File Suffix',
+ suffixPlaceholder: 'Enter file suffix',
+ downloadLimit: 'Download Limit',
+ downloadLimitPlaceholder: 'Enter download limit'
+ },
+ updateFailed: 'Update failed',
+ deleteFailed: 'Delete failed',
+ deleteConfirm: 'Delete this file? This action cannot be undone.',
+ loadFileListFailed: 'Failed to load file list'
+ },
+ login: {
+ title: 'Login',
+ password: 'Password',
+ passwordPlaceholder: 'Password',
+ loginButton: 'Login',
+ loggingIn: 'Logging in...',
+ invalidPassword: 'Invalid password',
+ passwordTooShort: 'Password must be at least 6 characters',
+ loginFailed: 'Login failed',
+ noValidToken: 'Login failed: No valid token received'
+ }
+ },
+
+ // File size units
+ fileSize: {
+ bytes: 'Bytes',
+ kb: 'KB',
+ mb: 'MB',
+ gb: 'GB',
+ tb: 'TB'
+ },
+
+ // Utility functions
+ utils: {
+ // Time formatting
+ formatTime: 'Format timestamp to readable format',
+ formatFileSize: 'Format file size',
+ formatDuration: 'Format duration',
+ time: {
+ forever: 'Forever',
+ day: 'day',
+ hour: 'hour',
+ minute: 'minute',
+ second: 'second'
+ },
+ copyToClipboard: 'Copy text to clipboard',
+ copyFailed: 'Copy failed:',
+ debounce: 'Debounce function',
+ throttle: 'Throttle function',
+ validateEmail: 'Validate email format',
+ validateUrl: 'Validate URL format',
+ generateRandomString: 'Generate random string',
+ deepClone: 'Deep clone object',
+ getFileExtension: 'Get file extension',
+ isMobileDevice: 'Check if mobile device',
+ formatNumber: 'Format number with thousand separators',
+ clipboard: {
+ title: 'Clipboard utility functions',
+ copyText: 'Copy text to clipboard',
+ copySuccess: 'Copy successful',
+ copyFailed: 'Copy failed, please copy manually'
+ }
+ },
+
+ // Components
+ components: {
+ pagination: {
+ showing: 'Showing',
+ to: 'to',
+ of: 'of',
+ total: 'total',
+ previous: 'Previous',
+ next: 'Next'
+ },
+ languageSwitcher: {
+ clickOutsideToClose: 'Click outside to close dropdown menu'
+ },
+ borderProgressBar: {
+ borderWidth: 'Border width',
+ cornerRadius: 'Corner radius',
+ createGradient: 'Create gradient',
+ drawBackground: 'Draw background',
+ calculateProgress: 'Calculate progress',
+ drawProgress: 'Draw progress'
+ }
+ },
+
+ // Miscellaneous
+ misc: {
+ emptyFileError: 'Cannot read empty file',
+ fileAddedFromClipboard: 'File added from clipboard: ',
+ fileProcessFailed: 'File processing failed',
+ chunkSize: 'Keep 2MB chunk size for hash calculation',
+ secureContext: 'If not in secure context (HTTP), return an alternative hash based on file info',
+ cryptoFallback: 'If crypto.subtle.digest fails, use fallback method',
+ generateAlternativeHash: 'Function to generate alternative hash',
+ fileInfoHash: 'Generate simple hash using filename, size and last modified time',
+ convertToHex: 'Convert to hex string and pad to 64 bits',
+ defaultChunkSize: 'Default chunk size is 5MB',
+ initChunkUpload: '1. Initialize chunk upload',
+ uploadChunk: '2. Upload chunk',
+ completeUpload: '3. Complete upload',
+ chunkUploadFailed: 'Chunk upload failed: ',
+ uploadProgressListener: 'Add upload progress listener',
+ noLimitCheck: 'If no limit, return true directly',
+ expirationValidation: 'Add expiration time validation',
+ chunkUploadReplacement: 'Use chunk upload to replace direct upload',
+ textUploadUnchanged: 'Text upload remains unchanged',
+ addSendRecord: 'Add new send record',
+ permanent: 'Permanent',
+ sendSuccessMessage: 'Show send success message',
+ resetForm: 'Reset form - only reset file and text content, keep expiration info',
+ showDetails: 'Show details',
+ autoCopyLink: 'Auto copy retrieve code link',
+ delayedLoading: 'Use onMounted hook to delay load non-critical resources or initialization',
+ nonCriticalInit: 'Place non-immediately needed initialization code here'
+ }
+}
diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts
new file mode 100644
index 0000000..c4da53c
--- /dev/null
+++ b/src/i18n/locales/zh-CN.ts
@@ -0,0 +1,1017 @@
+export default {
+ // 通用
+ common: {
+ confirm: '确认',
+ cancel: '取消',
+ close: '关闭',
+ delete: '删除',
+ edit: '编辑',
+ save: '保存',
+ add: '添加',
+ back: '返回',
+ next: '下一页',
+ previous: '上一页',
+ loading: '加载中...',
+ noData: '暂无数据',
+ success: '成功',
+ error: '错误',
+ warning: '警告',
+ info: '信息',
+ search: '搜索',
+ copy: '复制',
+ uploadSuccess: '上传成功',
+ uploadFailed: '上传失败',
+ copySuccess: '复制成功',
+ copyFailed: '复制失败',
+ deleteSuccess: '删除成功',
+ deleteFailed: '删除失败',
+ downloadSuccess: '下载成功',
+ downloadFailed: '下载失败',
+ shareSuccess: '分享成功',
+ shareFailed: '分享失败',
+ expiredFile: '文件已过期',
+ fileNotFound: '文件不存在',
+ networkError: '网络错误',
+ unknownError: '未知错误',
+ invalidFileType: '不支持的文件类型',
+ fileTooLarge: '文件过大',
+ uploadCanceled: '上传已取消',
+ processing: '处理中...',
+ pleaseWait: '请稍候...',
+ fileDetails: '文件详情',
+ enabled: '已开启',
+ disabled: '已关闭',
+ minute: '分钟',
+ files: '个文件',
+ second: '秒',
+ hour: '小时',
+ day: '天',
+ times: '次',
+ appName: '文件快递柜 - FileCodeBox',
+ appDescription: '开箱即用的文件快传系统'
+ },
+
+ // 管理员模块
+ admin: {
+ logout: '退出登录',
+ session: {
+ loggedIn: '已登录,进入管理后台',
+ loggedOut: '登录管理后台'
+ },
+ dashboard: {
+ title: '仪表盘',
+ totalFiles: '总文件数',
+ storageSpace: '存储空间',
+ todayShares: '今日分享',
+ totalRetrievals: '累计取件',
+ activeFiles: '有效文件:{count}',
+ todayIncrease: '今日新增容量:{count}',
+ yesterdayShares: '昨日分享:{count}',
+ serverUptime: '运行时间',
+ refresh: '刷新数据',
+ refreshing: '刷新中',
+ lastUpdated: '最近更新:{time}',
+ loadFailed: '仪表盘数据加载失败',
+ footerProduct: 'FileCodeBox 管理后台',
+ backendVersion: '服务端版本',
+ frontendVersion: '前端版本',
+ versionPending: '待同步',
+ fileHealth: '文件健康',
+ fileHealthDesc: '基于状态洞察统计可取件、风险与待处理队列。',
+ activeFileRatio: '有效占比',
+ fileShareRatio: '文件占比',
+ textShareRatio: '文本占比',
+ binaryFiles: '文件分享 {count} 个',
+ textShares: '文本分享 {count} 条',
+ expiredFiles: '已过期文件',
+ needCleanup: '可在文件管理中清理',
+ chunkedFiles: '分片上传文件',
+ storagePolicy: '存储与上传策略',
+ storagePolicyDesc: '当前后台配置对上传链路的影响。',
+ storageBackend: '存储后端',
+ singleFileLimit: '单文件上限',
+ guestUpload: '游客上传',
+ maxSaveTime: '最长保存',
+ noSaveLimit: '不限制',
+ todayCapacityReference: '今日容量 / 单文件上限',
+ healthActions: {
+ attention: {
+ title: '需关注',
+ description: '集中处理异常和预警文件'
+ },
+ storageIssue: {
+ title: '存储异常',
+ description: '检查缺少下载信息的文件'
+ },
+ expiringSoon: {
+ title: '即将过期',
+ description: '优先续期仍需保留的分享'
+ },
+ neverRetrieved: {
+ title: '未取件',
+ description: '识别创建后尚未被取件的记录'
+ },
+ permanent: {
+ title: '永久有效',
+ description: '查看长期保留的分享记录'
+ }
+ }
+ },
+ fileManage: {
+ title: '文件管理'
+ },
+ settings: {
+ title: '系统设置',
+ basicSettings: '基本设置',
+ websiteInfo: '网站基本信息',
+ websiteName: '网站名称',
+ websiteDescription: '网站描述',
+ siteName: '网站名称',
+ adminPassword: '管理员密码',
+ sessionDuration: '登录会话时长',
+ sessionDurationHelp: '仅影响新登录,允许设置 1 至 365 天。',
+ showAdminAddress: '在主页显示管理入口',
+ passwordPlaceholder: '如需修改请输入新密码',
+ passwordNote: '不修改请留空',
+ keywords: 'SEO 关键词',
+ themeSelection: '界面主题',
+ robotsFile: 'Robots.txt 配置',
+ notificationSettings: '通知设置',
+ notificationTitle: '公告标题',
+ notificationContent: '公告内容',
+ storageSettings: '存储配置',
+ storageLimit: '总存储容量上限',
+ storageLimitHelp: '所有有效分享与未完成上传共同占用容量,设置为 0 表示不限制。',
+ storagePath: '文件存储路径',
+ storagePathPlaceholder: '使用默认路径可留空',
+ storageMethod: '存储方式',
+ localStorage: '本地存储',
+ s3Storage: 'S3 对象存储',
+ webdavStorage: 'WebDAV 存储',
+ chunkUploadNote: '分片上传(实验性功能,大文件上传更稳定)',
+ s3AccessKeyId: 'Access Key ID',
+ s3SecretAccessKey: 'Secret Access Key',
+ s3BucketName: '存储桶名称',
+ s3EndpointUrl: '服务端点 URL',
+ s3RegionName: '区域名称',
+ s3SignatureVersion: '签名版本',
+ s3AddressingStyle: 'S3 地址风格',
+ s3AddressingAuto: '自动',
+ s3AddressingPath: 'Path-style',
+ s3AddressingVirtual: 'Virtual-host',
+ s3Hostname: '自定义域名',
+ s3v2: 'S3v2',
+ s3v4: 'S3v4',
+ autoPlaceholder: '自动识别',
+ enabled: '已启用',
+ disabled: '已禁用',
+ webdavUrl: '输入 WebDAV 服务地址',
+ webdavUsername: '输入 WebDAV 用户名',
+ webdavPassword: '输入 WebDAV 密码',
+ webdavUrlPlaceholder: '输入 WebDAV 服务地址',
+ webdavUsernamePlaceholder: '输入 WebDAV 用户名',
+ webdavPasswordPlaceholder: '输入 WebDAV 密码',
+ enableProxy: '启用下载代理',
+ uploadLimits: '上传设置',
+ uploadRateLimit: '限流时间窗口(在此时间内限制上传次数)',
+ uploadPerMinute: '限流时间窗口(在此时间内限制上传次数)',
+ minute: '分钟',
+ uploadCountLimit: '允许上传文件数(限流窗口内最多上传几个文件)',
+ files: '个文件',
+ fileSizeLimit: '单文件大小上限',
+ allowedFileTypes: '允许上传类型',
+ allowedFileTypesPlaceholder: '* 或 .rar, .zip, image/*',
+ allowedFileTypesHelp: '多个类型用英文逗号分隔,* 表示不限制;支持扩展名和 MIME 通配。',
+ codeGenerateType: '取件码规则',
+ codeGenerateNumber: '数字取件码',
+ codeGenerateSecret: 'Secret 取件码',
+ codeGenerateTypeHelp: '数字模式保持原有 5 位数字;Secret 模式使用字母数字混合随机码。',
+ expirationMethod: '过期方式',
+ expirationType: '过期方式',
+ expiration: {
+ day: '按天数',
+ hour: '按小时',
+ minute: '按分钟',
+ forever: '永久有效',
+ count: '按下载次数'
+ },
+ maxSaveTime: '文件最长保存时间',
+ timeUnits: {
+ second: '秒',
+ minute: '分钟',
+ hour: '小时',
+ day: '天'
+ },
+ guestUpload: '允许游客上传',
+ errorLimits: '访问保护',
+ errorRateLimit: '检测时间窗口(在此时间内统计错误次数)',
+ errorPerMinute: '检测时间窗口(在此时间内统计错误次数)',
+ errorCountLimit: '允许错误次数(超过后临时封禁)',
+ times: '次',
+ saveChanges: '保存设置',
+ fileSizeUnits: {
+ kb: 'KB',
+ mb: 'MB',
+ gb: 'GB'
+ }
+ }
+ },
+
+ // 页面标题和导航
+ nav: {
+ sendFile: '发送文件',
+ retrieveFile: '取件',
+ fileRecords: '取件记录'
+ },
+
+ // 取件页面
+ retrieve: {
+ title: '提取文件',
+ codeInput: {
+ placeholder: '请输入您的 5 位取件码',
+ label: '取件码'
+ },
+ submit: '立即提取',
+ needSendFile: '需要发送文件?',
+ recordsDrawer: '取件记录',
+ messages: {
+ invalidCode: '请输入5位取件码',
+ retrieveSuccess: '文件获取成功',
+ invalidCodeError: '无效的取件码',
+ retrieveFailure: '获取文件失败:',
+ networkError: '取件失败,请稍后重试:',
+ 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: '等待输入'
+ }
+ },
+
+ // 发送页面
+ send: {
+ title: '文件发送',
+ sendText: '发送文本',
+ fileDetails: '文件详情',
+ expirationMethod: '过期方式',
+ uploadArea: {
+ dragText: '拖拽文件到此处或',
+ clickText: '点击选择文件',
+ textInput: '在此输入要发送的文本...',
+ placeholder: '点击或拖放文件到此处上传',
+ description: '支持各种常见格式',
+ descriptionWithLimit: '支持各种常见格式,最大{size}',
+ retry: '重试',
+ selectedFiles: '已选择 {count} 个文件',
+ status: {
+ initializing: '正在初始化上传...',
+ uploading: '正在上传文件...',
+ confirming: '正在确认上传...',
+ success: '上传成功!'
+ }
+ },
+ submit: '安全寄送',
+ submitting: '发送中...',
+ needRetrieveFile: '需要取件?',
+ sendRecords: '发件记录',
+ secureEncryption: '安全加密',
+ workspace: {
+ uploadLimit: '单文件上限',
+ uploadMode: '上传模式',
+ standardMode: '标准上传',
+ chunkMode: '分片上传',
+ guestPolicy: '游客上传',
+ guestOpen: '允许',
+ guestClosed: '已关闭',
+ currentTask: '当前任务',
+ awaitingFile: '等待选择文件',
+ fileReady: '已选择 {count} 个文件',
+ textDraft: '文本草稿 {count} 字',
+ payload: '发送内容',
+ expirationPreview: '有效期',
+ security: '安全状态',
+ latestRecord: '最近一次发送',
+ noRecord: '暂无记录',
+ copyLink: '复制链接',
+ viewDetail: '查看详情',
+ historyTitle: '发送历史',
+ historyCount: '{count} 条记录',
+ openRecords: '打开发送记录'
+ },
+ fileDetail: {
+ title: '文件详情',
+ content: '文件内容',
+ previewContent: '预览内容',
+ download: '点击下载',
+ qrCode: '取件二维码',
+ scanQrCode: '扫描二维码快速取件'
+ },
+ contentPreview: {
+ title: '内容预览'
+ },
+ fileManage: {
+ title: '文件管理',
+ searchPlaceholder: '搜索文件名称、描述...',
+ allFiles: '所有文件',
+ editFileInfo: '编辑文件信息',
+ saveChanges: '保存更改',
+ headers: {
+ code: '取件码',
+ name: '名称',
+ size: '大小',
+ description: '描述',
+ expiration: '过期时间',
+ actions: '操作'
+ },
+ form: {
+ code: '取件码',
+ codePlaceholder: '输入取件码',
+ filename: '文件名称',
+ filenamePlaceholder: '输入文件名称',
+ suffix: '文件后缀',
+ suffixPlaceholder: '输入文件后缀',
+ downloadLimit: '下载次数限制',
+ downloadLimitPlaceholder: '输入下载次数限制'
+ }
+ },
+ expiration: {
+ label: '过期时间',
+ placeholders: {
+ days: '输入天数',
+ hours: '输入小时数',
+ minutes: '输入分钟数',
+ count: '输入查看次数',
+ forever: '永久',
+ default: '输入值'
+ },
+ units: {
+ days: '天',
+ hours: '小时',
+ minutes: '分钟',
+ times: '次',
+ forever: '永久'
+ }
+ },
+ time: {
+ day: '按天',
+ hour: '按小时',
+ minute: '按分钟',
+ forever: '永久',
+ count: '按次数'
+ },
+ messages: {
+ selectFile: '请选择要上传的文件',
+ enterText: '请输入要发送的文本',
+ enterExpirationValue: '请输入过期值',
+ expirationTooLong: '过期时间不能超过{days}天',
+ sendSuccess: '文件发送成功!取件码:{code}',
+ initChunkUploadFailed: '初始化切片上传失败',
+ chunkUploadFailed: '切片 {index} 上传失败',
+ completeUploadFailed: '完成上传失败',
+ uploadFailed: '上传失败,请稍后重试',
+ guestUploadDisabled: '游客上传功能已关闭',
+ fileSizeExceeded: '文件大小超过限制 ({size})',
+ fileTypeNotAllowed: '不允许上传该类型文件,允许类型:{types}',
+ serverError: '服务器响应异常',
+ sendFailed: '发送失败,请稍后重试',
+ expiresAfterCount: '{count}次后过期',
+ expiresAt: '{date}过期',
+ emptyFileError: '无法读取空文件',
+ fileAddedFromClipboard: '已从剪贴板添加文件:{filename}',
+ fileProcessingFailed: '文件处理失败',
+ expiresAfter: '{value}{unit}后过期'
+ }
+ },
+
+ // 文件管理
+ fileManage: {
+ title: '文件管理',
+ subtitle: '共 {count} 条分享记录,可按状态、类型和使用情况快速筛选。',
+ searchPlaceholder: '搜索取件码、名称或文本...',
+ allFiles: '所有文件',
+ detail: '详情',
+ detailTitle: '文件详情',
+ detailLoading: '正在加载详情...',
+ detailFailed: '详情加载失败',
+ editFileInfo: '编辑文件信息',
+ saveChanges: '保存更改',
+ refresh: '刷新列表',
+ resetFilters: '重置筛选',
+ totalFiles: '全部记录',
+ activeFiles: '可取件',
+ expiredFiles: '已过期',
+ storageUsed: '占用空间',
+ statusLabel: '状态',
+ typeLabel: '类型',
+ healthLabel: '健康',
+ viewPreset: '视图',
+ viewPresetCustom: '自定义筛选',
+ viewPresetAll: '全部文件',
+ viewPresetAttention: '需关注',
+ viewPresetExpiringSoon: '即将过期',
+ viewPresetStorageIssue: '存储异常',
+ viewPresetNeverRetrieved: '未取件',
+ viewPresetPermanent: '永久有效',
+ saveViewPreset: '保存视图',
+ updateViewPreset: '覆盖视图',
+ deleteViewPreset: '删除视图',
+ viewPresetNamePrompt: '输入视图名称',
+ viewPresetSaveSuccess: '视图已保存',
+ viewPresetSaveFailed: '视图保存失败',
+ viewPresetDeleteConfirm: '确认删除视图“{name}”?',
+ viewPresetDeleteSuccess: '视图已删除',
+ viewPresetDeleteFailed: '视图删除失败',
+ viewPresetLoadFailed: '视图预设加载失败,已使用内置视图',
+ all: '全部',
+ active: '可取件',
+ expired: '已过期',
+ fileType: '文件',
+ textType: '文本',
+ chunkedType: '分片',
+ healthFilters: {
+ all: '全部',
+ attention: '需关注',
+ danger: '异常',
+ warning: '预警',
+ expiringSoon: '即将过期',
+ storageIssue: '存储异常',
+ neverRetrieved: '未取件',
+ healthy: '健康',
+ permanent: '永久有效'
+ },
+ sortBy: '排序',
+ sortOrder: '方向',
+ unlimited: '不限次数',
+ remaining: '剩余 {count} 次',
+ loadError: '文件列表加载失败',
+ noMatches: '没有匹配的文件',
+ viewText: '查看',
+ textPreview: '文本预览',
+ copyText: '复制文本',
+ copyCode: '复制取件码',
+ copyLink: '复制取件链接',
+ copySuccess: '文本已复制到剪贴板',
+ copyCodeSuccess: '取件码已复制到剪贴板',
+ copyLinkSuccess: '取件链接已复制到剪贴板',
+ copyFailed: '复制失败,请重试',
+ charCount: '共 {count} 个字符',
+ downloadFile: '下载文件',
+ exportText: '导出文本',
+ downloadSuccess: '已下载:{name}',
+ exportSuccess: '已导出:{name}',
+ downloadFailed: '下载失败',
+ previewFailed: '预览加载失败',
+ loadingPreview: '正在加载预览...',
+ previewComplete: '已加载完整内容,共 {count} 个字符',
+ previewTruncated: '已显示 {shown} / {total} 个字符',
+ previewFallback: '预览接口不可用,已使用列表中的文本内容',
+ selectCurrentPage: '选择当前页',
+ selectedCount: '已选择 {count} 项',
+ selectFile: '选择 {name}',
+ clearSelection: '清空选择',
+ batchDelete: '批量删除',
+ batchDeleteConfirm: '确认删除选中的 {count} 个文件?此操作不可撤销。',
+ batchDeleteSuccess: '已删除 {count} 个文件',
+ batchDeletePartialSuccess: '已删除 {count} 个文件,{failed} 个失败',
+ batchDeleteFailed: '批量删除失败',
+ batchEdit: '批量编辑',
+ batchEditTitle: '批量编辑文件策略',
+ batchEditSelected: '将更新选中的 {count} 个文件',
+ batchEditMode: '更新方式',
+ batchEditExpiresAt: '设置过期时间',
+ batchEditDownloadLimit: '设置取件次数',
+ batchEditForever: '设为永久有效',
+ batchEditForeverHint: '清空过期时间,并将取件次数设为不限。',
+ batchUpdateConfirm: '确认更新选中的 {count} 个文件?',
+ batchUpdateSuccess: '已更新 {count} 个文件',
+ batchUpdatePartialSuccess: '已更新 {count} 个文件,{failed} 个失败',
+ batchUpdateFailed: '批量更新失败',
+ batchUpdateNoFields: '请选择要更新的策略',
+ batchPolicyActionConfirm: '确认对选中的 {count} 个文件执行“{action}”?',
+ batchPolicyActionSuccess: '已处理 {count} 个文件',
+ batchPolicyActionPartialSuccess: '已处理 {count} 个文件,{failed} 个失败',
+ batchPolicyActionFailed: '批量策略动作执行失败',
+ policyActionSuccess: '文件策略已更新',
+ policyActionFailed: '策略动作执行失败',
+ metadataInfo: '运营备注',
+ metadataHint: '仅管理员可见,用于记录处理线索、归档标签和后续动作。',
+ metadataNote: '备注',
+ metadataNotePlaceholder: '记录这个分享的背景、处理记录或负责人...',
+ metadataTags: '标签',
+ metadataTagsPlaceholder: '逗号分隔,例如:客户A, 待续期, 重要',
+ metadataUpdatedAt: '最近保存:{time}',
+ metadataNeverUpdated: '尚未保存备注',
+ saveMetadata: '保存备注',
+ metadataSaveSuccess: '备注和标签已保存',
+ metadataSaveFailed: '备注保存失败',
+ policyActions: {
+ extend24h: '延长 24 小时',
+ extend7d: '延长 7 天',
+ makePermanent: '设为永久',
+ resetDownloadLimit: '重置为 {count} 次'
+ },
+ policyActionDescriptions: {
+ extend24h: '从当前有效期继续追加一天',
+ extend7d: '适合临时续期一周',
+ makePermanent: '清空过期时间和次数限制',
+ resetDownloadLimit: '恢复可取件次数到 {count} 次'
+ },
+ applyBatchEdit: '应用更改',
+ overview: '概览',
+ policyInfo: '策略信息',
+ storageInfo: '存储信息',
+ statusInsight: '状态洞察',
+ nextAction: '建议动作',
+ lifecycle: '生命周期',
+ permanent: '永久有效',
+ createdAt: '创建时间',
+ remainingDownloads: '剩余次数',
+ textLength: '文本长度',
+ hasDownloadLimit: '限制取件次数',
+ hasExpirationTime: '限制过期时间',
+ canPreviewText: '可预览文本',
+ canDownload: '可下载',
+ storageBackend: '存储后端',
+ fileHash: '文件哈希',
+ isChunked: '分片上传',
+ filePath: '存储路径',
+ uuidFileName: '存储文件名',
+ uploadId: '上传会话',
+ yes: '是',
+ no: '否',
+ insightSeverity: {
+ success: '正常',
+ warning: '需关注',
+ danger: '异常',
+ info: '提示',
+ neutral: '待观察'
+ },
+ insightStates: {
+ available: '当前可正常取件',
+ expired: '当前已不可取件',
+ storage_incomplete: '存储信息不完整',
+ expiring_soon: '即将过期',
+ permanent: '永久有效'
+ },
+ insightActions: {
+ monitor: '保持观察',
+ extend_or_delete: '延长有效期或清理记录',
+ inspect_storage: '检查存储路径与文件名',
+ extend_expiration: '按需延长过期时间'
+ },
+ insightReasons: {
+ expired: '已超过过期时间',
+ download_limit_exhausted: '取件次数已用尽',
+ expires_soon: '24 小时内过期',
+ never_retrieved: '尚未被取件',
+ storage_metadata_incomplete: '缺少可下载存储信息',
+ chunked_upload: '由分片上传生成'
+ },
+ timeline: {
+ status: {
+ done: '已完成',
+ pending: '等待中',
+ active: '生效中',
+ expired: '已过期',
+ exhausted: '已用尽',
+ unlimited: '不限'
+ },
+ created: {
+ title: '记录创建',
+ description: '分享记录已创建,当前状态为 {status}。'
+ },
+ content_ready: {
+ title: '内容就绪',
+ description: '内容类型为 {detail},当前状态为 {status}。'
+ },
+ upload_session: {
+ title: '上传会话',
+ description: '上传会话已记录:{detail}。'
+ },
+ expiration_policy: {
+ title: '过期策略',
+ description: '过期策略当前为 {status}。',
+ remaining: '还剩 {time}',
+ overdue: '已超时 {time}'
+ },
+ download_limit: {
+ title: '取件次数',
+ description: '取件次数策略当前为 {status},{value}。'
+ },
+ retrieved: {
+ title: '取件记录',
+ description: '累计取件 {value},当前状态为 {status}。'
+ }
+ },
+ headers: {
+ select: '选择',
+ code: '取件码',
+ name: '名称',
+ type: '类型',
+ size: '大小',
+ usage: '取件',
+ status: '状态',
+ description: '描述',
+ expiration: '过期时间',
+ actions: '操作'
+ },
+ sort: {
+ createdAt: '创建时间',
+ expiredAt: '过期时间',
+ name: '名称',
+ size: '大小',
+ usedCount: '取件次数',
+ code: '取件码',
+ desc: '降序',
+ asc: '升序'
+ },
+ form: {
+ code: '取件码',
+ codePlaceholder: '输入取件码',
+ filename: '文件名称',
+ filenamePlaceholder: '输入文件名称',
+ suffix: '文件后缀',
+ suffixPlaceholder: '输入文件后缀',
+ downloadLimit: '下载次数限制',
+ downloadLimitPlaceholder: '输入下载次数限制'
+ }
+ },
+
+ // 文件记录
+ fileRecord: {
+ filename: '文件名',
+ size: '文件大小',
+ date: '取件日期',
+ code: '取件码',
+ actions: '操作',
+ download: '下载',
+ viewDetails: '查看详情',
+ deleteRecord: '删除记录',
+ preview: '预览',
+ copyContent: '复制内容',
+ contentCopied: '内容已复制到剪贴板',
+ copyFailed: '复制失败,请重试'
+ },
+
+ // 文件详情模态框
+ fileDetail: {
+ title: '文件详情',
+ content: '文件内容',
+ previewContent: '预览内容',
+ download: '点击下载',
+ qrCode: '取件二维码',
+ scanQrCode: '扫描二维码快速取件'
+ },
+
+ // 内容预览
+ contentPreview: {
+ title: '内容预览'
+ },
+
+ // 侧边抽屉
+ drawer: {
+ noRecords: '暂无记录'
+ },
+
+ // 文件大小单位
+ fileSize: {
+ bytes: 'Bytes',
+ kb: 'KB',
+ mb: 'MB',
+ gb: 'GB',
+ tb: 'TB'
+ },
+
+ // 管理页面
+ manage: {
+ settings: {
+ title: '系统设置',
+ basicSettings: '基本设置',
+ siteName: '网站名称',
+ adminPassword: '管理员密码',
+ passwordPlaceholder: '如需修改请输入新密码',
+ passwordNote: '不修改请留空',
+ keywords: 'SEO 关键词',
+ themeSelection: '界面主题',
+ robotsFile: 'Robots.txt 配置',
+ notificationSettings: '通知设置',
+ notificationTitle: '公告标题',
+ notificationContent: '公告内容',
+ storageSettings: '存储配置',
+ storagePath: '文件存储路径',
+ storagePathPlaceholder: '使用默认路径可留空',
+ storageMethod: '存储方式',
+ localStorage: '本地存储',
+ s3Storage: 'S3 对象存储',
+ webdavStorage: 'WebDAV 存储',
+ chunkUploadNote: '分片上传(实验性功能,大文件上传更稳定)',
+ uploadLimits: '上传设置',
+ uploadPerMinute: '限流时间窗口(在此时间内限制上传次数)',
+ uploadCountLimit: '允许上传文件数(限流窗口内最多上传几个文件)',
+ fileSizeLimit: '单文件大小上限',
+ allowedFileTypes: '允许上传类型',
+ allowedFileTypesPlaceholder: '* 或 .rar, .zip, image/*',
+ allowedFileTypesHelp: '多个类型用英文逗号分隔,* 表示不限制;支持扩展名和 MIME 通配。',
+ codeGenerateType: '取件码规则',
+ codeGenerateNumber: '数字取件码',
+ codeGenerateSecret: 'Secret 取件码',
+ codeGenerateTypeHelp: '数字模式保持原有 5 位数字;Secret 模式使用字母数字混合随机码。',
+ expiration: {
+ day: '按天数',
+ hour: '按小时',
+ minute: '按分钟',
+ forever: '永久有效',
+ count: '按下载次数'
+ },
+ maxSaveTime: '文件最长保存时间',
+ s3AccessKeyId: 'Access Key ID',
+ s3SecretAccessKey: 'Secret Access Key',
+ s3BucketName: '存储桶名称',
+ s3EndpointUrl: '服务端点 URL',
+ s3RegionName: '区域名称',
+ s3SignatureVersion: '签名版本',
+ s3AddressingStyle: 'S3 地址风格',
+ s3AddressingAuto: '自动',
+ s3AddressingPath: 'Path-style',
+ s3AddressingVirtual: 'Virtual-host',
+ s3Hostname: '自定义域名',
+ s3v2: 'S3v2',
+ s3v4: 'S3v4',
+ autoPlaceholder: '自动识别',
+ enableProxy: '启用下载代理',
+ webdavUrlPlaceholder: '输入 WebDAV 服务地址',
+ webdavUsernamePlaceholder: '输入 WebDAV 用户名',
+ webdavPasswordPlaceholder: '输入 WebDAV 密码',
+ fileSizeUnits: {
+ kb: 'KB',
+ mb: 'MB',
+ gb: 'GB'
+ },
+ expirationType: '过期方式',
+ guestUpload: '允许游客上传',
+ errorLimits: '访问保护',
+ errorPerMinute: '检测时间窗口(在此时间内统计错误次数)',
+ errorCountLimit: '允许错误次数(超过后临时封禁)',
+ saveChanges: '保存设置',
+ refreshConfig: '刷新配置',
+ refreshing: '刷新中',
+ saving: '保存中',
+ unsavedChanges: '有未保存的配置变更',
+ allChangesSaved: '所有配置已保存',
+ refreshBlocked: '请先保存当前变更再刷新'
+ },
+ dashboard: {
+ title: '仪表盘',
+ totalFiles: '总文件数',
+ storageSpace: '存储空间',
+ todayShares: '今日分享',
+ totalRetrievals: '累计取件',
+ activeFiles: '有效文件:{count}',
+ todayIncrease: '今日新增容量:{count}',
+ yesterdayShares: '昨日分享:{count}',
+ serverUptime: '运行时间',
+ refresh: '刷新数据',
+ fileHealth: '文件健康',
+ fileHealthDesc: '基于状态洞察统计可取件、风险与待处理队列。',
+ activeFileRatio: '有效占比',
+ fileShareRatio: '文件占比',
+ textShareRatio: '文本占比',
+ binaryFiles: '文件分享 {count} 个',
+ textShares: '文本分享 {count} 条',
+ expiredFiles: '已过期文件',
+ needCleanup: '可在文件管理中清理',
+ chunkedFiles: '分片上传文件',
+ storagePolicy: '存储与上传策略',
+ storagePolicyDesc: '当前后台配置对上传链路的影响。',
+ storageBackend: '存储后端',
+ singleFileLimit: '单文件上限',
+ guestUpload: '游客上传',
+ maxSaveTime: '最长保存',
+ noSaveLimit: '不限制',
+ todayCapacityReference: '今日容量 / 单文件上限'
+ },
+ fileManage: {
+ title: '文件管理',
+ searchPlaceholder: '搜索文件名称、描述...',
+ allFiles: '所有文件',
+ editFileInfo: '编辑文件信息',
+ saveChanges: '保存更改',
+ headers: {
+ code: '取件码',
+ name: '名称',
+ size: '大小',
+ description: '描述',
+ expiration: '过期时间',
+ actions: '操作'
+ },
+ form: {
+ code: '取件码',
+ codePlaceholder: '输入取件码',
+ filename: '文件名称',
+ filenamePlaceholder: '输入文件名称',
+ suffix: '文件后缀',
+ suffixPlaceholder: '输入文件后缀',
+ downloadLimit: '下载次数限制',
+ downloadLimitPlaceholder: '输入下载次数限制'
+ },
+ updateFailed: '更新失败',
+ deleteFailed: '删除失败',
+ deleteConfirm: '确认删除这个文件?此操作不可撤销。',
+ loadFileListFailed: '加载文件列表失败'
+ },
+ systemSettings: {
+ title: '系统设置',
+ basicSettings: '基本设置',
+ websiteInfo: '网站基本信息',
+ websiteName: '网站名称',
+ websiteDescription: '网站描述',
+ adminPassword: '管理员密码',
+ passwordPlaceholder: '如需修改请输入新密码',
+ passwordNote: '不修改请留空',
+ keywords: 'SEO 关键词',
+ themeSelection: '界面主题',
+ notificationSettings: '通知设置',
+ notificationTitle: '公告标题',
+ notificationContent: '公告内容',
+ storageSettings: '存储配置',
+ storagePath: '文件存储路径',
+ storagePathPlaceholder: '使用默认路径可留空',
+ storageMethod: '存储方式',
+ localStorage: '本地存储',
+ s3Storage: 'S3 对象存储',
+ webdavStorage: 'WebDAV 存储',
+ chunkUploadNote: '分片上传(实验性功能,大文件上传更稳定)',
+ enabled: '已启用',
+ disabled: '已禁用',
+ webdavUrl: '输入 WebDAV 服务地址',
+ webdavUsername: '输入 WebDAV 用户名',
+ webdavPassword: '输入 WebDAV 密码',
+ enableProxy: '启用下载代理',
+ uploadLimits: '上传设置',
+ uploadRateLimit: '限流时间窗口(在此时间内限制上传次数)',
+ minute: '分钟',
+ uploadCountLimit: '允许上传文件数(限流窗口内最多上传几个文件)',
+ files: '个文件',
+ fileSizeLimit: '单文件大小上限',
+ codeGenerateType: '取件码规则',
+ codeGenerateNumber: '数字取件码',
+ codeGenerateSecret: 'Secret 取件码',
+ codeGenerateTypeHelp: '数字模式保持原有 5 位数字;Secret 模式使用字母数字混合随机码。',
+ expirationMethod: '过期方式',
+ expirationMethods: {
+ day: '按天数',
+ hour: '按小时',
+ minute: '按分钟',
+ forever: '永久有效',
+ count: '按下载次数'
+ },
+ expiration: {
+ day: '按天数',
+ hour: '按小时',
+ minute: '按分钟',
+ forever: '永久有效',
+ count: '按下载次数'
+ },
+ maxSaveTime: '文件最长保存时间',
+ timeUnits: {
+ second: '秒',
+ minute: '分钟',
+ hour: '小时',
+ day: '天'
+ },
+ guestUpload: '允许游客上传',
+ errorLimits: '访问保护',
+ errorRateLimit: '检测时间窗口(在此时间内统计错误次数)',
+ errorCountLimit: '允许错误次数(超过后临时封禁)',
+ times: '次',
+ saveSettings: '保存设置',
+ saveSuccess: '设置已保存',
+ saveFailed: '保存失败,请重试',
+ getConfigFailed: '获取配置失败,请刷新页面'
+ },
+ login: {
+ title: '登录',
+ password: '密码',
+ passwordPlaceholder: '密码',
+ loginButton: '登录',
+ loggingIn: '登录中...',
+ invalidPassword: '无效的密码',
+ passwordTooShort: '密码长度至少为6位',
+ loginFailed: '登录失败',
+ noValidToken: '登录失败:未获取到有效令牌'
+ }
+ },
+
+ // 工具函数相关
+ utils: {
+ common: {
+ formatTime: '格式化时间戳为可读格式',
+ formatFileSize: '格式化文件大小',
+ formatDuration: '格式化持续时间',
+ time: {
+ forever: '永久',
+ day: '天',
+ hour: '小时',
+ minute: '分钟',
+ second: '秒'
+ },
+ copyToClipboard: '复制文本到剪贴板',
+ copyFailed: '复制失败:',
+ debounce: '防抖函数',
+ throttle: '节流函数',
+ validateEmail: '验证邮箱格式',
+ validateUrl: '验证URL格式',
+ generateRandomString: '生成随机字符串',
+ deepClone: '深度克隆对象',
+ getFileExtension: '获取文件扩展名',
+ isMobileDevice: '检查是否为移动设备',
+ formatNumber: '格式化数字,添加千分位分隔符'
+ },
+ clipboard: {
+ title: '剪贴板工具函数',
+ copyText: '复制文本到剪贴板',
+ copySuccess: '复制成功',
+ copyFailed: '复制失败,请手动复制'
+ }
+ },
+
+ // 组件相关
+ components: {
+ pagination: {
+ showing: '显示第',
+ to: '到',
+ of: '条,共',
+ total: '条',
+ previous: '上一页',
+ next: '下一页'
+ },
+ languageSwitcher: {
+ clickOutsideToClose: '点击外部关闭下拉菜单'
+ },
+ borderProgressBar: {
+ borderWidth: '边框宽度',
+ cornerRadius: '拐角半径',
+ createGradient: '创建渐变',
+ drawBackground: '绘制背景',
+ calculateProgress: '计算进度',
+ drawProgress: '绘制进度'
+ }
+ },
+
+ // 其他通用文本
+ misc: {
+ emptyFileError: '无法读取空文件',
+ fileAddedFromClipboard: '已从剪贴板添加文件:',
+ fileProcessFailed: '文件处理失败',
+ chunkSize: '保持 2MB 的切片大小用于计算哈希',
+ secureContext: '如果不是安全上下文(HTTP),则返回一个基于文件信息的替代哈希',
+ cryptoFallback: '如果 crypto.subtle.digest 失败,使用替代方案',
+ generateAlternativeHash: '生成替代哈希的函数',
+ fileInfoHash: '使用文件名、大小和最后修改时间生成一个简单的哈希',
+ convertToHex: '转换为16进制字符串并填充到64位',
+ defaultChunkSize: '默认切片大小为5MB',
+ initChunkUpload: '1. 初始化切片上传',
+ uploadChunk: '2. 上传切片',
+ completeUpload: '3. 完成上传',
+ chunkUploadFailed: '切片上传失败:',
+ uploadProgressListener: '添加上传进度监听',
+ noLimitCheck: '如果没有限制,直接返回true',
+ expirationValidation: '添加过期时间验证',
+ chunkUploadReplacement: '使用切片上传替代原来的直接上传',
+ textUploadUnchanged: '文本上传保持不变',
+ addSendRecord: '添加新的发送记录',
+ permanent: '永久',
+ sendSuccessMessage: '显示发送成功消息',
+ resetForm: '重置表单 - 只重置文件和文本内容,保留过期信息',
+ showDetails: '显示详情',
+ autoCopyLink: '自动复制取件码链接',
+ delayedLoading: '使用 onMounted 钩子延迟加载一些非关键资源或初始化',
+ nonCriticalInit: '这里可以放置一些非立即需要的初始化代码'
+ }
+}
diff --git a/src/layout/AdminLayout/AdminLayout.vue b/src/layout/AdminLayout/AdminLayout.vue
index 2b12828..85df4ee 100644
--- a/src/layout/AdminLayout/AdminLayout.vue
+++ b/src/layout/AdminLayout/AdminLayout.vue
@@ -1,89 +1,81 @@
]