feat: upgrade admin file workspace
This commit is contained in:
@@ -2,11 +2,36 @@ import { computed, ref } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { FileService } from '@/services'
|
import { FileService } from '@/services'
|
||||||
import { useAlertStore } from '@/stores/alertStore'
|
import { useAlertStore } from '@/stores/alertStore'
|
||||||
import type { AdminFileViewItem, FileEditForm, FileListItem } from '@/types'
|
import type {
|
||||||
|
AdminFileListParams,
|
||||||
|
AdminFileStatusFilter,
|
||||||
|
AdminFileSummary,
|
||||||
|
AdminFileTypeFilter,
|
||||||
|
AdminFileViewItem,
|
||||||
|
FileEditForm,
|
||||||
|
FileListItem
|
||||||
|
} from '@/types'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common'
|
import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common'
|
||||||
|
|
||||||
const TEXT_PREVIEW_THRESHOLD = 30
|
const emptySummary = (): AdminFileSummary => ({
|
||||||
|
totalFiles: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
expiredCount: 0,
|
||||||
|
textCount: 0,
|
||||||
|
fileCount: 0,
|
||||||
|
chunkedCount: 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()
|
||||||
|
}
|
||||||
|
|
||||||
export function useAdminFiles() {
|
export function useAdminFiles() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -14,11 +39,18 @@ export function useAdminFiles() {
|
|||||||
|
|
||||||
const tableData = ref<AdminFileViewItem[]>([])
|
const tableData = ref<AdminFileViewItem[]>([])
|
||||||
const hasLoadError = ref(false)
|
const hasLoadError = ref(false)
|
||||||
const params = ref({
|
const isLoading = ref(false)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const summary = ref<AdminFileSummary>(emptySummary())
|
||||||
|
const params = ref<AdminFileListParams & { total: number }>({
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 10,
|
size: 10,
|
||||||
total: 0,
|
total: 0,
|
||||||
keyword: ''
|
keyword: '',
|
||||||
|
status: 'all',
|
||||||
|
type: 'all',
|
||||||
|
sortBy: 'created_at',
|
||||||
|
sortOrder: 'desc'
|
||||||
})
|
})
|
||||||
|
|
||||||
const showEditModal = ref(false)
|
const showEditModal = ref(false)
|
||||||
@@ -33,17 +65,98 @@ export function useAdminFiles() {
|
|||||||
|
|
||||||
const showTextPreview = ref(false)
|
const showTextPreview = ref(false)
|
||||||
const previewText = ref('')
|
const previewText = ref('')
|
||||||
const totalPages = computed(() => Math.ceil(params.value.total / params.value.size))
|
const totalPages = computed(() => Math.max(Math.ceil(params.value.total / params.value.size), 1))
|
||||||
|
const storageUsedText = computed(() => formatFileSize(summary.value.storageUsed))
|
||||||
|
|
||||||
const createFileViewItem = (file: FileListItem): AdminFileViewItem => ({
|
const requestParams = computed<AdminFileListParams>(() => ({
|
||||||
...file,
|
page: params.value.page,
|
||||||
displaySize: formatFileSize(file.size),
|
size: params.value.size,
|
||||||
displayExpiredAt: file.expired_at
|
keyword: params.value.keyword,
|
||||||
? formatTimestamp(file.expired_at)
|
status: params.value.status === 'all' ? undefined : params.value.status,
|
||||||
: t('send.expiration.units.forever'),
|
type: params.value.type === 'all' ? undefined : params.value.type,
|
||||||
canPreviewText: Boolean(file.text && file.text.length > TEXT_PREVIEW_THRESHOLD)
|
sortBy: params.value.sortBy,
|
||||||
|
sortOrder: params.value.sortOrder
|
||||||
|
}))
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(
|
||||||
|
() =>
|
||||||
|
Boolean(params.value.keyword?.trim()) ||
|
||||||
|
params.value.status !== 'all' ||
|
||||||
|
params.value.type !== 'all'
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
storageUsed: files.reduce((totalSize, file) => totalSize + normalizeCount(file.size), 0),
|
||||||
|
usedCount: files.reduce(
|
||||||
|
(totalUsed, file) => totalUsed + normalizeCount(file.usedCount ?? file.used_count),
|
||||||
|
0
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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')}`,
|
||||||
|
isTextFile,
|
||||||
|
isExpiredFile,
|
||||||
|
isChunkedFile,
|
||||||
|
remainingDownloadsValue,
|
||||||
|
canPreviewText: Boolean(file.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resetEditForm = () => {
|
const resetEditForm = () => {
|
||||||
editForm.value = {
|
editForm.value = {
|
||||||
id: null,
|
id: null,
|
||||||
@@ -56,16 +169,23 @@ export function useAdminFiles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadFiles = async () => {
|
const loadFiles = async () => {
|
||||||
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
hasLoadError.value = false
|
hasLoadError.value = false
|
||||||
const res = await FileService.getAdminFileList(params.value)
|
const res = await FileService.getAdminFileList(requestParams.value)
|
||||||
if (!res.detail) return
|
if (!res.detail) return
|
||||||
|
|
||||||
tableData.value = res.detail.data.map(createFileViewItem)
|
tableData.value = res.detail.data.map(createFileViewItem)
|
||||||
params.value.total = res.detail.total
|
params.value.total = res.detail.total
|
||||||
|
summary.value = res.detail.summary || buildFallbackSummary(tableData.value, res.detail.total)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasLoadError.value = true
|
hasLoadError.value = true
|
||||||
alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.loadFileListFailed')), 'error')
|
alertStore.showAlert(
|
||||||
|
getErrorMessage(error, t('manage.fileManage.loadFileListFailed')),
|
||||||
|
'error'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +194,32 @@ export function useAdminFiles() {
|
|||||||
await loadFiles()
|
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.sortBy = 'created_at'
|
||||||
|
params.value.sortOrder = 'desc'
|
||||||
|
await loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setStatusFilter = async (status: AdminFileStatusFilter) => {
|
||||||
|
params.value.status = status
|
||||||
|
params.value.page = 1
|
||||||
|
await loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTypeFilter = async (type: AdminFileTypeFilter) => {
|
||||||
|
params.value.type = type
|
||||||
|
params.value.page = 1
|
||||||
|
await loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
const handlePageChange = async (page: number | string) => {
|
const handlePageChange = async (page: number | string) => {
|
||||||
if (typeof page === 'string') return
|
if (typeof page === 'string') return
|
||||||
if (page < 1 || page > totalPages.value) return
|
if (page < 1 || page > totalPages.value) return
|
||||||
@@ -100,12 +246,17 @@ export function useAdminFiles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
|
if (isSaving.value) return
|
||||||
|
|
||||||
|
isSaving.value = true
|
||||||
try {
|
try {
|
||||||
await FileService.updateFile(editForm.value)
|
await FileService.updateFile(editForm.value)
|
||||||
await loadFiles()
|
await loadFiles()
|
||||||
closeEditModal()
|
closeEditModal()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.updateFailed')), 'error')
|
alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.updateFailed')), 'error')
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +267,9 @@ export function useAdminFiles() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await FileService.deleteAdminFile(id)
|
await FileService.deleteAdminFile(id)
|
||||||
|
if (tableData.value.length === 1 && params.value.page > 1) {
|
||||||
|
params.value.page -= 1
|
||||||
|
}
|
||||||
await loadFiles()
|
await loadFiles()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error')
|
alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error')
|
||||||
@@ -143,7 +297,12 @@ export function useAdminFiles() {
|
|||||||
return {
|
return {
|
||||||
tableData,
|
tableData,
|
||||||
hasLoadError,
|
hasLoadError,
|
||||||
|
hasActiveFilters,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
params,
|
params,
|
||||||
|
storageUsedText,
|
||||||
|
summary,
|
||||||
showEditModal,
|
showEditModal,
|
||||||
editForm,
|
editForm,
|
||||||
showTextPreview,
|
showTextPreview,
|
||||||
@@ -158,6 +317,10 @@ export function useAdminFiles() {
|
|||||||
handleUpdate,
|
handleUpdate,
|
||||||
loadFiles,
|
loadFiles,
|
||||||
openEditModal,
|
openEditModal,
|
||||||
openTextPreview
|
openTextPreview,
|
||||||
|
refreshFiles,
|
||||||
|
resetFilters,
|
||||||
|
setStatusFilter,
|
||||||
|
setTypeFilter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,10 +378,30 @@ export default {
|
|||||||
// File Management
|
// File Management
|
||||||
fileManage: {
|
fileManage: {
|
||||||
title: 'File Management',
|
title: 'File Management',
|
||||||
searchPlaceholder: 'Search file name, description...',
|
subtitle: '{count} share records. Filter by status, type, and usage.',
|
||||||
|
searchPlaceholder: 'Search code, name, or text...',
|
||||||
allFiles: 'All Files',
|
allFiles: 'All Files',
|
||||||
editFileInfo: 'Edit File Information',
|
editFileInfo: 'Edit File Information',
|
||||||
saveChanges: 'Save Changes',
|
saveChanges: 'Save Changes',
|
||||||
|
refresh: 'Refresh List',
|
||||||
|
resetFilters: 'Reset Filters',
|
||||||
|
totalFiles: 'All Records',
|
||||||
|
activeFiles: 'Available',
|
||||||
|
expiredFiles: 'Expired',
|
||||||
|
storageUsed: 'Storage Used',
|
||||||
|
statusLabel: 'Status',
|
||||||
|
typeLabel: 'Type',
|
||||||
|
all: 'All',
|
||||||
|
active: 'Available',
|
||||||
|
expired: 'Expired',
|
||||||
|
fileType: 'File',
|
||||||
|
textType: 'Text',
|
||||||
|
chunkedType: 'Chunked',
|
||||||
|
sortBy: 'Sort',
|
||||||
|
unlimited: 'Unlimited',
|
||||||
|
remaining: '{count} left',
|
||||||
|
loadError: 'Failed to load file list',
|
||||||
|
noMatches: 'No matching files',
|
||||||
viewText: 'View',
|
viewText: 'View',
|
||||||
textPreview: 'Text Preview',
|
textPreview: 'Text Preview',
|
||||||
copyText: 'Copy Text',
|
copyText: 'Copy Text',
|
||||||
@@ -391,11 +411,24 @@ export default {
|
|||||||
headers: {
|
headers: {
|
||||||
code: 'Retrieve Code',
|
code: 'Retrieve Code',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
|
type: 'Type',
|
||||||
size: 'Size',
|
size: 'Size',
|
||||||
|
usage: 'Retrievals',
|
||||||
|
status: 'Status',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
expiration: 'Expiration',
|
expiration: 'Expiration',
|
||||||
actions: 'Actions'
|
actions: 'Actions'
|
||||||
},
|
},
|
||||||
|
sort: {
|
||||||
|
createdAt: 'Created Time',
|
||||||
|
expiredAt: 'Expiration',
|
||||||
|
name: 'Name',
|
||||||
|
size: 'Size',
|
||||||
|
usedCount: 'Retrievals',
|
||||||
|
code: 'Retrieve Code',
|
||||||
|
desc: 'Descending',
|
||||||
|
asc: 'Ascending'
|
||||||
|
},
|
||||||
form: {
|
form: {
|
||||||
code: 'Retrieve Code',
|
code: 'Retrieve Code',
|
||||||
codePlaceholder: 'Enter retrieve code',
|
codePlaceholder: 'Enter retrieve code',
|
||||||
|
|||||||
@@ -376,10 +376,30 @@ export default {
|
|||||||
// 文件管理
|
// 文件管理
|
||||||
fileManage: {
|
fileManage: {
|
||||||
title: '文件管理',
|
title: '文件管理',
|
||||||
searchPlaceholder: '搜索文件名称、描述...',
|
subtitle: '共 {count} 条分享记录,可按状态、类型和使用情况快速筛选。',
|
||||||
|
searchPlaceholder: '搜索取件码、名称或文本...',
|
||||||
allFiles: '所有文件',
|
allFiles: '所有文件',
|
||||||
editFileInfo: '编辑文件信息',
|
editFileInfo: '编辑文件信息',
|
||||||
saveChanges: '保存更改',
|
saveChanges: '保存更改',
|
||||||
|
refresh: '刷新列表',
|
||||||
|
resetFilters: '重置筛选',
|
||||||
|
totalFiles: '全部记录',
|
||||||
|
activeFiles: '可取件',
|
||||||
|
expiredFiles: '已过期',
|
||||||
|
storageUsed: '占用空间',
|
||||||
|
statusLabel: '状态',
|
||||||
|
typeLabel: '类型',
|
||||||
|
all: '全部',
|
||||||
|
active: '可取件',
|
||||||
|
expired: '已过期',
|
||||||
|
fileType: '文件',
|
||||||
|
textType: '文本',
|
||||||
|
chunkedType: '分片',
|
||||||
|
sortBy: '排序',
|
||||||
|
unlimited: '不限次数',
|
||||||
|
remaining: '剩余 {count} 次',
|
||||||
|
loadError: '文件列表加载失败',
|
||||||
|
noMatches: '没有匹配的文件',
|
||||||
viewText: '查看',
|
viewText: '查看',
|
||||||
textPreview: '文本预览',
|
textPreview: '文本预览',
|
||||||
copyText: '复制文本',
|
copyText: '复制文本',
|
||||||
@@ -389,11 +409,24 @@ export default {
|
|||||||
headers: {
|
headers: {
|
||||||
code: '取件码',
|
code: '取件码',
|
||||||
name: '名称',
|
name: '名称',
|
||||||
|
type: '类型',
|
||||||
size: '大小',
|
size: '大小',
|
||||||
|
usage: '取件',
|
||||||
|
status: '状态',
|
||||||
description: '描述',
|
description: '描述',
|
||||||
expiration: '过期时间',
|
expiration: '过期时间',
|
||||||
actions: '操作'
|
actions: '操作'
|
||||||
},
|
},
|
||||||
|
sort: {
|
||||||
|
createdAt: '创建时间',
|
||||||
|
expiredAt: '过期时间',
|
||||||
|
name: '名称',
|
||||||
|
size: '大小',
|
||||||
|
usedCount: '取件次数',
|
||||||
|
code: '取件码',
|
||||||
|
desc: '降序',
|
||||||
|
asc: '升序'
|
||||||
|
},
|
||||||
form: {
|
form: {
|
||||||
code: '取件码',
|
code: '取件码',
|
||||||
codePlaceholder: '输入取件码',
|
codePlaceholder: '输入取件码',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ChunkUploadInitResponse,
|
ChunkUploadInitResponse,
|
||||||
ChunkUploadResponse,
|
ChunkUploadResponse,
|
||||||
FileEditForm,
|
FileEditForm,
|
||||||
|
AdminFileListParams,
|
||||||
FileInfo,
|
FileInfo,
|
||||||
FileListResponse,
|
FileListResponse,
|
||||||
FileUploadResponse,
|
FileUploadResponse,
|
||||||
@@ -116,11 +117,9 @@ export class FileService {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getAdminFileList(params: {
|
static async getAdminFileList(
|
||||||
page: number
|
params: AdminFileListParams
|
||||||
size: number
|
): Promise<ApiResponse<FileListResponse>> {
|
||||||
keyword?: string
|
|
||||||
}): Promise<ApiResponse<FileListResponse>> {
|
|
||||||
return api.get('/admin/file/list', { params })
|
return api.get('/admin/file/list', { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-1
@@ -16,17 +16,64 @@ export interface FileListItem {
|
|||||||
size: number
|
size: number
|
||||||
text?: string
|
text?: string
|
||||||
description?: string
|
description?: string
|
||||||
expired_at: string
|
expired_at: string | null
|
||||||
expired_count: number | null
|
expired_count: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
name?: string
|
||||||
|
type?: 'text' | 'file'
|
||||||
|
status?: 'active' | 'expired'
|
||||||
|
isText?: boolean
|
||||||
|
is_text?: boolean
|
||||||
|
isExpired?: boolean
|
||||||
|
is_expired?: boolean
|
||||||
|
isChunked?: boolean
|
||||||
|
is_chunked?: boolean
|
||||||
|
remainingDownloads?: number | null
|
||||||
|
remaining_downloads?: number | null
|
||||||
|
usedCount?: number
|
||||||
|
used_count?: number
|
||||||
|
fileHash?: string | null
|
||||||
|
file_hash?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminFileViewItem extends FileListItem {
|
export interface AdminFileViewItem extends FileListItem {
|
||||||
|
displayName: string
|
||||||
displaySize: string
|
displaySize: string
|
||||||
displayExpiredAt: string
|
displayExpiredAt: string
|
||||||
|
displayUsage: string
|
||||||
|
isTextFile: boolean
|
||||||
|
isExpiredFile: boolean
|
||||||
|
isChunkedFile: boolean
|
||||||
|
remainingDownloadsValue: number | null
|
||||||
canPreviewText: boolean
|
canPreviewText: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminFileSummary {
|
||||||
|
totalFiles: number
|
||||||
|
activeCount: number
|
||||||
|
expiredCount: number
|
||||||
|
textCount: number
|
||||||
|
fileCount: number
|
||||||
|
chunkedCount: number
|
||||||
|
storageUsed: number
|
||||||
|
usedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminFileStatusFilter = 'all' | 'active' | 'expired'
|
||||||
|
export type AdminFileTypeFilter = 'all' | 'file' | 'text' | 'chunked'
|
||||||
|
export type AdminFileSortBy = 'created_at' | 'expired_at' | 'name' | 'size' | 'used_count' | 'code'
|
||||||
|
export type AdminFileSortOrder = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export interface AdminFileListParams {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
keyword?: string
|
||||||
|
status?: AdminFileStatusFilter
|
||||||
|
type?: AdminFileTypeFilter
|
||||||
|
sortBy?: AdminFileSortBy
|
||||||
|
sortOrder?: AdminFileSortOrder
|
||||||
|
}
|
||||||
|
|
||||||
export interface FileEditForm {
|
export interface FileEditForm {
|
||||||
id: number | null
|
id: number | null
|
||||||
code: string
|
code: string
|
||||||
@@ -41,6 +88,7 @@ export interface FileListResponse {
|
|||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
size: number
|
size: number
|
||||||
|
summary?: AdminFileSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileUploadResponse {
|
export interface FileUploadResponse {
|
||||||
|
|||||||
+440
-164
@@ -1,168 +1,315 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-6 overflow-y-auto custom-scrollbar">
|
<div class="p-6 overflow-y-auto custom-scrollbar">
|
||||||
<!-- 页面标题和统计信息 -->
|
<div class="mb-6 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div class="mb-8">
|
<div>
|
||||||
<h2 class="text-2xl font-bold mb-4" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
|
<h2 class="text-2xl font-bold" :class="[primaryTextClass]">
|
||||||
{{ t('fileManage.title') }}
|
{{ t('fileManage.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
<p class="mt-1 text-sm" :class="[mutedTextClass]">
|
||||||
|
{{ t('fileManage.subtitle', { count: summary.totalFiles }) }}
|
||||||
<!-- 搜索和操作栏 -->
|
</p>
|
||||||
<div
|
</div>
|
||||||
class="mb-6 flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between bg-opacity-70 p-4 rounded-lg shadow-sm"
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
:class="[isDarkMode ? 'bg-gray-800' : 'bg-white']"
|
<BaseButton variant="secondary" :loading="isLoading" @click="refreshFiles">
|
||||||
>
|
<template #icon>
|
||||||
<div class="flex flex-1 gap-4 w-full sm:w-auto">
|
<RefreshCwIcon class="mr-2 h-4 w-4" />
|
||||||
<div class="relative flex-1">
|
</template>
|
||||||
<input
|
{{ t('fileManage.refresh') }}
|
||||||
type="text"
|
</BaseButton>
|
||||||
v-model="params.keyword"
|
<BaseButton
|
||||||
@keyup.enter="handleSearch"
|
v-if="hasActiveFilters"
|
||||||
:class="[
|
variant="outline"
|
||||||
isDarkMode
|
:disabled="isLoading"
|
||||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400'
|
@click="resetFilters"
|
||||||
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-400',
|
|
||||||
'w-full pl-10 pr-4 py-2.5 rounded-lg border focus:ring-2 focus:ring-indigo-500 focus:border-transparent'
|
|
||||||
]"
|
|
||||||
:placeholder="t('fileManage.searchPlaceholder')"
|
|
||||||
/>
|
|
||||||
<SearchIcon
|
|
||||||
class="absolute left-3 top-3 w-5 h-5"
|
|
||||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="handleSearch"
|
|
||||||
class="px-4 py-2.5 rounded-lg inline-flex items-center transition-all duration-200 bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm"
|
|
||||||
>
|
>
|
||||||
<SearchIcon class="w-5 h-5 mr-2" />
|
<template #icon>
|
||||||
{{ t('common.search') }}
|
<XIcon class="mr-2 h-4 w-4" />
|
||||||
</button>
|
</template>
|
||||||
|
{{ t('fileManage.resetFilters') }}
|
||||||
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文件列表 -->
|
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div
|
||||||
|
v-for="card in summaryCards"
|
||||||
|
:key="card.label"
|
||||||
|
class="rounded-lg border p-4"
|
||||||
|
:class="[panelClass]"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm" :class="[mutedTextClass]">{{ card.label }}</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold" :class="[primaryTextClass]">
|
||||||
|
{{ card.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg p-2" :class="[card.iconClass]">
|
||||||
|
<component :is="card.icon" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mb-6 rounded-lg border p-4" :class="[panelClass]">
|
||||||
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-3 md:flex-row">
|
||||||
|
<div class="relative min-w-[220px] flex-1">
|
||||||
|
<input
|
||||||
|
v-model="params.keyword"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('fileManage.searchPlaceholder')"
|
||||||
|
class="w-full rounded-lg border py-2.5 pl-10 pr-4 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
|
||||||
|
:class="[
|
||||||
|
isDarkMode
|
||||||
|
? 'border-gray-600 bg-gray-700 text-white placeholder-gray-400'
|
||||||
|
: 'border-gray-300 bg-white text-gray-900 placeholder-gray-400'
|
||||||
|
]"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<SearchIcon
|
||||||
|
class="absolute left-3 top-3 h-4 w-4"
|
||||||
|
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<BaseButton :loading="isLoading" @click="handleSearch">
|
||||||
|
<template #icon>
|
||||||
|
<SearchIcon class="mr-2 h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ t('common.search') }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 xl:items-end">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="inline-flex items-center text-xs font-medium" :class="[mutedTextClass]">
|
||||||
|
<FilterIcon class="mr-1 h-3.5 w-3.5" />
|
||||||
|
{{ t('fileManage.statusLabel') }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-for="option in statusFilterOptions"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="getStatusFilterClass(option.value)"
|
||||||
|
@click="setStatusFilter(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="inline-flex items-center text-xs font-medium" :class="[mutedTextClass]">
|
||||||
|
<ArchiveIcon class="mr-1 h-3.5 w-3.5" />
|
||||||
|
{{ t('fileManage.typeLabel') }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-for="option in typeFilterOptions"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="getTypeFilterClass(option.value)"
|
||||||
|
@click="setTypeFilter(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<label class="text-sm" :class="[mutedTextClass]">
|
||||||
|
{{ t('fileManage.sortBy') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="params.sortBy"
|
||||||
|
class="rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
|
||||||
|
:class="[fieldClass]"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-model="params.sortOrder"
|
||||||
|
class="rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
|
||||||
|
:class="[fieldClass]"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<option v-for="option in sortOrderOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<DataTable :title="t('fileManage.allFiles')" :headers="fileTableHeaders">
|
<DataTable :title="t('fileManage.allFiles')" :headers="fileTableHeaders">
|
||||||
<template #body>
|
<template #body>
|
||||||
<tr
|
<tr v-if="isLoading">
|
||||||
v-for="file in tableData"
|
<td :colspan="fileTableHeaders.length" class="px-6 py-12 text-center">
|
||||||
:key="file.id"
|
<div class="inline-flex items-center text-sm" :class="[mutedTextClass]">
|
||||||
class="hover:bg-opacity-50 transition-colors duration-200"
|
<RefreshCwIcon class="mr-2 h-4 w-4 animate-spin" />
|
||||||
:class="[isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50']"
|
{{ t('common.loading') }}
|
||||||
>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span
|
|
||||||
class="font-medium select-all"
|
|
||||||
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
|
|
||||||
>
|
|
||||||
{{ file.code }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex items-center group relative">
|
|
||||||
<FileIcon
|
|
||||||
class="w-5 h-5 mr-2 flex-shrink-0"
|
|
||||||
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-500']"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="font-medium truncate max-w-[200px]"
|
|
||||||
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
|
|
||||||
:title="file.prefix"
|
|
||||||
>
|
|
||||||
{{ file.prefix }}
|
|
||||||
</span>
|
|
||||||
<!-- 悬浮提示 -->
|
|
||||||
<div
|
|
||||||
class="absolute left-0 -top-2 -translate-y-full opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"
|
|
||||||
>
|
|
||||||
<div class="bg-gray-900 text-white text-sm rounded px-2 py-1 max-w-xs break-all">
|
|
||||||
{{ file.prefix }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
:class="[isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-800']"
|
|
||||||
>
|
|
||||||
{{ file.displaySize }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="block truncate max-w-[200px]"
|
|
||||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
|
||||||
>
|
|
||||||
{{ file.text || '-' }}
|
|
||||||
</span>
|
|
||||||
<!-- 查看全文按钮 - 仅当文本超过一定长度时显示 -->
|
|
||||||
<button
|
|
||||||
v-if="file.canPreviewText"
|
|
||||||
@click="openTextPreview(file.text || '')"
|
|
||||||
class="flex-shrink-0 inline-flex items-center px-2 py-1 rounded text-xs transition-colors duration-200"
|
|
||||||
:class="[
|
|
||||||
isDarkMode
|
|
||||||
? 'bg-gray-700 text-gray-300 hover:bg-gray-600 hover:text-white'
|
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<EyeIcon class="w-3 h-3 mr-1" />
|
|
||||||
{{ t('fileManage.viewText') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
:class="[
|
|
||||||
file.expired_at
|
|
||||||
? isDarkMode
|
|
||||||
? 'bg-yellow-900/30 text-yellow-400'
|
|
||||||
: 'bg-yellow-100 text-yellow-800'
|
|
||||||
: isDarkMode
|
|
||||||
? 'bg-green-900/30 text-green-400'
|
|
||||||
: 'bg-green-100 text-green-800'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ file.displayExpiredAt }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
@click="openEditModal(file)"
|
|
||||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200"
|
|
||||||
:class="[
|
|
||||||
isDarkMode
|
|
||||||
? 'bg-blue-900/20 text-blue-400 hover:bg-blue-900/30'
|
|
||||||
: 'bg-blue-50 text-blue-600 hover:bg-blue-100'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<PencilIcon class="w-4 h-4 mr-1.5" />
|
|
||||||
{{ t('common.edit') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="deleteFile(file.id)"
|
|
||||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200"
|
|
||||||
:class="[
|
|
||||||
isDarkMode
|
|
||||||
? 'bg-red-900/20 text-red-400 hover:bg-red-900/30'
|
|
||||||
: 'bg-red-50 text-red-600 hover:bg-red-100'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<TrashIcon class="w-4 h-4 mr-1.5" />
|
|
||||||
{{ t('common.delete') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<tr v-else-if="hasLoadError">
|
||||||
|
<td :colspan="fileTableHeaders.length" class="px-6 py-12 text-center">
|
||||||
|
<div class="mx-auto max-w-sm">
|
||||||
|
<p class="font-medium" :class="[primaryTextClass]">
|
||||||
|
{{ t('fileManage.loadError') }}
|
||||||
|
</p>
|
||||||
|
<BaseButton class="mt-4" variant="secondary" @click="refreshFiles">
|
||||||
|
<template #icon>
|
||||||
|
<RefreshCwIcon class="mr-2 h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ t('fileManage.refresh') }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-else-if="tableData.length === 0">
|
||||||
|
<td :colspan="fileTableHeaders.length" class="px-6 py-12 text-center">
|
||||||
|
<div class="mx-auto max-w-sm">
|
||||||
|
<p class="font-medium" :class="[primaryTextClass]">
|
||||||
|
{{ hasActiveFilters ? t('fileManage.noMatches') : t('common.noData') }}
|
||||||
|
</p>
|
||||||
|
<BaseButton
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
class="mt-4"
|
||||||
|
variant="secondary"
|
||||||
|
@click="resetFilters"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<XIcon class="mr-2 h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ t('fileManage.resetFilters') }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<tr
|
||||||
|
v-for="file in tableData"
|
||||||
|
:key="file.id"
|
||||||
|
class="transition-colors duration-200"
|
||||||
|
:class="[isDarkMode ? 'hover:bg-gray-700/70' : 'hover:bg-gray-50']"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="font-medium select-all" :class="[primaryTextClass]">
|
||||||
|
{{ file.code }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<div class="rounded-lg p-2" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
|
||||||
|
<FileTextIcon v-if="file.isTextFile" class="h-4 w-4" :class="[mutedTextClass]" />
|
||||||
|
<FileIcon v-else class="h-4 w-4" :class="[mutedTextClass]" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-medium" :class="[primaryTextClass]">
|
||||||
|
{{ file.displayName }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 truncate text-xs" :class="[mutedTextClass]">
|
||||||
|
{{ file.suffix || t('fileManage.textType') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
|
||||||
|
:class="getTypeBadgeClass(file)"
|
||||||
|
>
|
||||||
|
{{ getTypeLabel(file) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
|
||||||
|
:class="[isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-800']"
|
||||||
|
>
|
||||||
|
{{ file.displaySize }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm" :class="[primaryTextClass]">{{ file.displayUsage }}</div>
|
||||||
|
<div class="mt-0.5 text-xs" :class="[mutedTextClass]">
|
||||||
|
{{
|
||||||
|
file.remainingDownloadsValue === null
|
||||||
|
? t('fileManage.unlimited')
|
||||||
|
: t('fileManage.remaining', { count: file.remainingDownloadsValue })
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm" :class="[primaryTextClass]">
|
||||||
|
{{ file.displayExpiredAt }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
|
||||||
|
:class="getStatusBadgeClass(file)"
|
||||||
|
>
|
||||||
|
{{ file.isExpiredFile ? t('fileManage.expired') : t('fileManage.active') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
v-if="file.canPreviewText"
|
||||||
|
type="button"
|
||||||
|
:title="t('fileManage.viewText')"
|
||||||
|
class="inline-flex items-center rounded-md px-3 py-1.5 transition-colors duration-200"
|
||||||
|
:class="[
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
@click="openTextPreview(file.text || '')"
|
||||||
|
>
|
||||||
|
<EyeIcon class="mr-1.5 h-4 w-4" />
|
||||||
|
{{ t('fileManage.viewText') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-md px-3 py-1.5 transition-colors duration-200"
|
||||||
|
:class="[
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-blue-900/20 text-blue-300 hover:bg-blue-900/30'
|
||||||
|
: 'bg-blue-50 text-blue-600 hover:bg-blue-100'
|
||||||
|
]"
|
||||||
|
@click="openEditModal(file)"
|
||||||
|
>
|
||||||
|
<PencilIcon class="mr-1.5 h-4 w-4" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-md px-3 py-1.5 transition-colors duration-200"
|
||||||
|
:class="[
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-red-900/20 text-red-300 hover:bg-red-900/30'
|
||||||
|
: 'bg-red-50 text-red-600 hover:bg-red-100'
|
||||||
|
]"
|
||||||
|
@click="deleteFile(file.id)"
|
||||||
|
>
|
||||||
|
<TrashIcon class="mr-1.5 h-4 w-4" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<DataPagination
|
<DataPagination
|
||||||
|
v-if="params.total > 0"
|
||||||
:current-page="params.page"
|
:current-page="params.page"
|
||||||
:page-size="params.size"
|
:page-size="params.size"
|
||||||
:total="params.total"
|
:total="params.total"
|
||||||
@@ -219,10 +366,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<BaseButton variant="secondary" @click="closeEditModal">
|
<BaseButton variant="secondary" :disabled="isSaving" @click="closeEditModal">
|
||||||
{{ t('common.cancel') }}
|
{{ t('common.cancel') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
<BaseButton @click="handleUpdate">
|
<BaseButton :loading="isSaving" @click="handleUpdate">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<CheckIcon class="w-4 h-4 mr-2" />
|
<CheckIcon class="w-4 h-4 mr-2" />
|
||||||
</template>
|
</template>
|
||||||
@@ -279,40 +426,62 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type {
|
||||||
|
AdminFileSortBy,
|
||||||
|
AdminFileSortOrder,
|
||||||
|
AdminFileStatusFilter,
|
||||||
|
AdminFileTypeFilter,
|
||||||
|
AdminFileViewItem
|
||||||
|
} from '@/types'
|
||||||
import {
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
ArchiveIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ClockIcon,
|
||||||
|
CopyIcon,
|
||||||
|
EyeIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
FilterIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
PencilIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
PencilIcon,
|
XIcon
|
||||||
CheckIcon,
|
|
||||||
EyeIcon,
|
|
||||||
CopyIcon,
|
|
||||||
FileTextIcon
|
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import DataPagination from '@/components/common/DataPagination.vue'
|
import DataPagination from '@/components/common/DataPagination.vue'
|
||||||
import FileEditField from '@/components/common/FileEditField.vue'
|
import FileEditField from '@/components/common/FileEditField.vue'
|
||||||
import BaseModal from '@/components/common/BaseModal.vue'
|
import BaseModal from '@/components/common/BaseModal.vue'
|
||||||
import BaseButton from '@/components/common/BaseButton.vue'
|
import BaseButton from '@/components/common/BaseButton.vue'
|
||||||
import { useAdminFiles } from '@/composables'
|
import { useAdminFiles, useInjectedDarkMode } from '@/composables'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const isDarkMode = inject('isDarkMode')
|
const isDarkMode = useInjectedDarkMode()
|
||||||
// 修改文件表头
|
|
||||||
const fileTableHeaders = computed(() => [
|
const fileTableHeaders = computed(() => [
|
||||||
t('fileManage.headers.code'),
|
t('fileManage.headers.code'),
|
||||||
t('fileManage.headers.name'),
|
t('fileManage.headers.name'),
|
||||||
|
t('fileManage.headers.type'),
|
||||||
t('fileManage.headers.size'),
|
t('fileManage.headers.size'),
|
||||||
t('fileManage.headers.description'),
|
t('fileManage.headers.usage'),
|
||||||
t('fileManage.headers.expiration'),
|
t('fileManage.headers.expiration'),
|
||||||
|
t('fileManage.headers.status'),
|
||||||
t('fileManage.headers.actions')
|
t('fileManage.headers.actions')
|
||||||
])
|
])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
tableData,
|
tableData,
|
||||||
|
hasActiveFilters,
|
||||||
|
hasLoadError,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
params,
|
params,
|
||||||
|
storageUsedText,
|
||||||
|
summary,
|
||||||
showEditModal,
|
showEditModal,
|
||||||
editForm,
|
editForm,
|
||||||
showTextPreview,
|
showTextPreview,
|
||||||
@@ -326,9 +495,116 @@ const {
|
|||||||
handleUpdate,
|
handleUpdate,
|
||||||
loadFiles,
|
loadFiles,
|
||||||
openEditModal,
|
openEditModal,
|
||||||
openTextPreview
|
openTextPreview,
|
||||||
|
refreshFiles,
|
||||||
|
resetFilters,
|
||||||
|
setStatusFilter,
|
||||||
|
setTypeFilter
|
||||||
} = useAdminFiles()
|
} = useAdminFiles()
|
||||||
|
|
||||||
|
const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
|
||||||
|
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
|
||||||
|
const panelClass = computed(() =>
|
||||||
|
isDarkMode.value ? 'border-gray-700 bg-gray-800/80' : 'border-gray-100 bg-white'
|
||||||
|
)
|
||||||
|
const fieldClass = computed(() =>
|
||||||
|
isDarkMode.value
|
||||||
|
? 'border-gray-600 bg-gray-700 text-white'
|
||||||
|
: 'border-gray-300 bg-white text-gray-900'
|
||||||
|
)
|
||||||
|
|
||||||
|
const summaryCards = computed(() => [
|
||||||
|
{
|
||||||
|
label: t('fileManage.totalFiles'),
|
||||||
|
value: summary.value.totalFiles,
|
||||||
|
icon: ArchiveIcon,
|
||||||
|
iconClass: isDarkMode.value
|
||||||
|
? 'bg-indigo-900/30 text-indigo-300'
|
||||||
|
: 'bg-indigo-50 text-indigo-600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('fileManage.activeFiles'),
|
||||||
|
value: summary.value.activeCount,
|
||||||
|
icon: ActivityIcon,
|
||||||
|
iconClass: isDarkMode.value ? 'bg-green-900/30 text-green-300' : 'bg-green-50 text-green-600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('fileManage.expiredFiles'),
|
||||||
|
value: summary.value.expiredCount,
|
||||||
|
icon: ClockIcon,
|
||||||
|
iconClass: isDarkMode.value ? 'bg-red-900/30 text-red-300' : 'bg-red-50 text-red-600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('fileManage.storageUsed'),
|
||||||
|
value: storageUsedText.value,
|
||||||
|
icon: HardDriveIcon,
|
||||||
|
iconClass: isDarkMode.value
|
||||||
|
? 'bg-purple-900/30 text-purple-300'
|
||||||
|
: 'bg-purple-50 text-purple-600'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const statusFilterOptions = computed<{ value: AdminFileStatusFilter; label: string }[]>(() => [
|
||||||
|
{ value: 'all', label: t('fileManage.all') },
|
||||||
|
{ value: 'active', label: t('fileManage.active') },
|
||||||
|
{ value: 'expired', label: t('fileManage.expired') }
|
||||||
|
])
|
||||||
|
|
||||||
|
const typeFilterOptions = computed<{ value: AdminFileTypeFilter; label: string }[]>(() => [
|
||||||
|
{ value: 'all', label: t('fileManage.all') },
|
||||||
|
{ value: 'file', label: t('fileManage.fileType') },
|
||||||
|
{ value: 'text', label: t('fileManage.textType') },
|
||||||
|
{ value: 'chunked', label: t('fileManage.chunkedType') }
|
||||||
|
])
|
||||||
|
|
||||||
|
const sortOptions = computed<{ value: AdminFileSortBy; label: string }[]>(() => [
|
||||||
|
{ value: 'created_at', label: t('fileManage.sort.createdAt') },
|
||||||
|
{ value: 'expired_at', label: t('fileManage.sort.expiredAt') },
|
||||||
|
{ value: 'name', label: t('fileManage.sort.name') },
|
||||||
|
{ value: 'size', label: t('fileManage.sort.size') },
|
||||||
|
{ value: 'used_count', label: t('fileManage.sort.usedCount') },
|
||||||
|
{ value: 'code', label: t('fileManage.sort.code') }
|
||||||
|
])
|
||||||
|
|
||||||
|
const sortOrderOptions = computed<{ value: AdminFileSortOrder; label: string }[]>(() => [
|
||||||
|
{ value: 'desc', label: t('fileManage.sort.desc') },
|
||||||
|
{ value: 'asc', label: t('fileManage.sort.asc') }
|
||||||
|
])
|
||||||
|
|
||||||
|
const getPillClass = (active: boolean) => {
|
||||||
|
if (active) return 'bg-indigo-600 text-white'
|
||||||
|
return isDarkMode.value
|
||||||
|
? 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusFilterClass = (value: AdminFileStatusFilter) =>
|
||||||
|
getPillClass(params.value.status === value)
|
||||||
|
|
||||||
|
const getTypeFilterClass = (value: AdminFileTypeFilter) => getPillClass(params.value.type === value)
|
||||||
|
|
||||||
|
const getTypeLabel = (file: AdminFileViewItem) => {
|
||||||
|
if (file.isChunkedFile) return t('fileManage.chunkedType')
|
||||||
|
return file.isTextFile ? t('fileManage.textType') : t('fileManage.fileType')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeBadgeClass = (file: AdminFileViewItem) => {
|
||||||
|
if (file.isChunkedFile) {
|
||||||
|
return isDarkMode.value ? 'bg-blue-900/30 text-blue-300' : 'bg-blue-100 text-blue-700'
|
||||||
|
}
|
||||||
|
if (file.isTextFile) {
|
||||||
|
return isDarkMode.value ? 'bg-purple-900/30 text-purple-300' : 'bg-purple-100 text-purple-700'
|
||||||
|
}
|
||||||
|
return isDarkMode.value ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadgeClass = (file: AdminFileViewItem) => {
|
||||||
|
if (file.isExpiredFile) {
|
||||||
|
return isDarkMode.value ? 'bg-red-900/30 text-red-300' : 'bg-red-100 text-red-700'
|
||||||
|
}
|
||||||
|
return isDarkMode.value ? 'bg-green-900/30 text-green-300' : 'bg-green-100 text-green-700'
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadFiles()
|
void loadFiles()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user