diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts index 936441a..d399c68 100644 --- a/src/composables/useAdminFiles.ts +++ b/src/composables/useAdminFiles.ts @@ -2,11 +2,36 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { FileService } from '@/services' 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 { 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() { const { t } = useI18n() @@ -14,11 +39,18 @@ export function useAdminFiles() { const tableData = ref([]) const hasLoadError = ref(false) - const params = ref({ + const isLoading = ref(false) + const isSaving = ref(false) + const summary = ref(emptySummary()) + const params = ref({ page: 1, size: 10, total: 0, - keyword: '' + keyword: '', + status: 'all', + type: 'all', + sortBy: 'created_at', + sortOrder: 'desc' }) const showEditModal = ref(false) @@ -33,17 +65,98 @@ export function useAdminFiles() { const showTextPreview = ref(false) 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 => ({ - ...file, - displaySize: formatFileSize(file.size), - displayExpiredAt: file.expired_at - ? formatTimestamp(file.expired_at) - : t('send.expiration.units.forever'), - canPreviewText: Boolean(file.text && file.text.length > TEXT_PREVIEW_THRESHOLD) + 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, + 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 = () => { editForm.value = { id: null, @@ -56,16 +169,23 @@ export function useAdminFiles() { } const loadFiles = async () => { + isLoading.value = true try { hasLoadError.value = false - const res = await FileService.getAdminFileList(params.value) + 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 = res.detail.summary || buildFallbackSummary(tableData.value, res.detail.total) } catch (error) { 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() } + 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) => { if (typeof page === 'string') return if (page < 1 || page > totalPages.value) return @@ -100,12 +246,17 @@ export function useAdminFiles() { } 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 } } @@ -116,6 +267,9 @@ export function useAdminFiles() { try { await FileService.deleteAdminFile(id) + if (tableData.value.length === 1 && params.value.page > 1) { + params.value.page -= 1 + } await loadFiles() } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error') @@ -143,7 +297,12 @@ export function useAdminFiles() { return { tableData, hasLoadError, + hasActiveFilters, + isLoading, + isSaving, params, + storageUsedText, + summary, showEditModal, editForm, showTextPreview, @@ -158,6 +317,10 @@ export function useAdminFiles() { handleUpdate, loadFiles, openEditModal, - openTextPreview + openTextPreview, + refreshFiles, + resetFilters, + setStatusFilter, + setTypeFilter } } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 3edb985..9dfe151 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -378,10 +378,30 @@ export default { // File Management fileManage: { title: 'File Management', - searchPlaceholder: 'Search file name, description...', + subtitle: '{count} share records. Filter by status, type, and usage.', + searchPlaceholder: 'Search code, name, or text...', allFiles: 'All Files', 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', + 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', textPreview: 'Text Preview', copyText: 'Copy Text', @@ -391,11 +411,24 @@ export default { headers: { 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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 2e59ff2..cda7910 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -376,10 +376,30 @@ export default { // 文件管理 fileManage: { title: '文件管理', - searchPlaceholder: '搜索文件名称、描述...', + subtitle: '共 {count} 条分享记录,可按状态、类型和使用情况快速筛选。', + searchPlaceholder: '搜索取件码、名称或文本...', allFiles: '所有文件', editFileInfo: '编辑文件信息', saveChanges: '保存更改', + refresh: '刷新列表', + resetFilters: '重置筛选', + totalFiles: '全部记录', + activeFiles: '可取件', + expiredFiles: '已过期', + storageUsed: '占用空间', + statusLabel: '状态', + typeLabel: '类型', + all: '全部', + active: '可取件', + expired: '已过期', + fileType: '文件', + textType: '文本', + chunkedType: '分片', + sortBy: '排序', + unlimited: '不限次数', + remaining: '剩余 {count} 次', + loadError: '文件列表加载失败', + noMatches: '没有匹配的文件', viewText: '查看', textPreview: '文本预览', copyText: '复制文本', @@ -389,11 +409,24 @@ export default { headers: { code: '取件码', name: '名称', + type: '类型', size: '大小', + usage: '取件', + status: '状态', description: '描述', expiration: '过期时间', actions: '操作' }, + sort: { + createdAt: '创建时间', + expiredAt: '过期时间', + name: '名称', + size: '大小', + usedCount: '取件次数', + code: '取件码', + desc: '降序', + asc: '升序' + }, form: { code: '取件码', codePlaceholder: '输入取件码', diff --git a/src/services/file.ts b/src/services/file.ts index f88ae9e..9b469b4 100644 --- a/src/services/file.ts +++ b/src/services/file.ts @@ -7,6 +7,7 @@ import type { ChunkUploadInitResponse, ChunkUploadResponse, FileEditForm, + AdminFileListParams, FileInfo, FileListResponse, FileUploadResponse, @@ -116,11 +117,9 @@ export class FileService { return response.data } - static async getAdminFileList(params: { - page: number - size: number - keyword?: string - }): Promise> { + static async getAdminFileList( + params: AdminFileListParams + ): Promise> { return api.get('/admin/file/list', { params }) } diff --git a/src/types/file.ts b/src/types/file.ts index 662d465..b3cdfb0 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -16,17 +16,64 @@ export interface FileListItem { size: number text?: string description?: string - expired_at: string + expired_at: string | null expired_count: number | null created_at: string + name?: string + type?: 'text' | 'file' + status?: 'active' | 'expired' + isText?: boolean + is_text?: boolean + isExpired?: boolean + is_expired?: boolean + isChunked?: boolean + is_chunked?: boolean + remainingDownloads?: number | null + remaining_downloads?: number | null + usedCount?: number + used_count?: number + fileHash?: string | null + file_hash?: string | null } export interface AdminFileViewItem extends FileListItem { + displayName: string displaySize: string displayExpiredAt: string + displayUsage: string + isTextFile: boolean + isExpiredFile: boolean + isChunkedFile: boolean + remainingDownloadsValue: number | null 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 { id: number | null code: string @@ -41,6 +88,7 @@ export interface FileListResponse { total: number page: number size: number + summary?: AdminFileSummary } export interface FileUploadResponse { diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index 2921a55..799b054 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -1,168 +1,315 @@