From ab41cd46ec78cdb0e037c25a631055778a3700ae Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 04:20:52 +0800 Subject: [PATCH] feat: add admin file preview downloads --- src/composables/useAdminFiles.ts | 79 +++++++++++++++++++++++++++-- src/i18n/locales/en-US.ts | 10 ++++ src/i18n/locales/zh-CN.ts | 10 ++++ src/services/file.ts | 15 +++++- src/types/file.ts | 18 +++++++ src/utils/download-action.ts | 66 +++++++++++++++++++++++- src/views/manage/FileManageView.vue | 59 +++++++++++++++++++-- 7 files changed, 248 insertions(+), 9 deletions(-) diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts index d399c68..9f322de 100644 --- a/src/composables/useAdminFiles.ts +++ b/src/composables/useAdminFiles.ts @@ -13,6 +13,11 @@ import type { } from '@/types' import { copyToClipboard } from '@/utils/clipboard' import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common' +import { + downloadAdminManagedFile, + exportAdminTextFile, + getSafeFilename +} from '@/utils/download-action' const emptySummary = (): AdminFileSummary => ({ totalFiles: 0, @@ -65,6 +70,10 @@ export function useAdminFiles() { const showTextPreview = ref(false) const previewText = ref('') + const previewFile = ref(null) + const previewMetaText = ref('') + const isPreviewLoading = ref(false) + const downloadingFileId = ref(null) const totalPages = computed(() => Math.max(Math.ceil(params.value.total / params.value.size), 1)) const storageUsedText = computed(() => formatFileSize(summary.value.storageUsed)) @@ -153,7 +162,7 @@ export function useAdminFiles() { isExpiredFile, isChunkedFile, remainingDownloadsValue, - canPreviewText: Boolean(file.text) + canPreviewText: isTextFile } } @@ -276,14 +285,47 @@ export function useAdminFiles() { } } - const openTextPreview = (text: string) => { - previewText.value = text + 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 copyText = async () => { @@ -294,13 +336,42 @@ export function useAdminFiles() { }) } + const exportPreviewText = () => { + if (!previewFile.value || !previewText.value) return + exportAdminTextFile(previewFile.value, previewText.value) + } + + 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, + isPreviewLoading, isSaving, + downloadingFileId, params, + previewFile, + previewMetaText, storageUsedText, summary, showEditModal, @@ -312,6 +383,8 @@ export function useAdminFiles() { closeTextPreview, copyText, deleteFile, + downloadFile, + exportPreviewText, handlePageChange, handleSearch, handleUpdate, diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 9dfe151..9bcd7c5 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -408,6 +408,16 @@ export default { copySuccess: 'Text 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', headers: { code: 'Retrieve Code', name: 'Name', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index cda7910..9c7343d 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -406,6 +406,16 @@ export default { copySuccess: '文本已复制到剪贴板', copyFailed: '复制失败,请重试', charCount: '共 {count} 个字符', + downloadFile: '下载文件', + exportText: '导出文本', + downloadSuccess: '已下载:{name}', + exportSuccess: '已导出:{name}', + downloadFailed: '下载失败', + previewFailed: '预览加载失败', + loadingPreview: '正在加载预览...', + previewComplete: '已加载完整内容,共 {count} 个字符', + previewTruncated: '已显示 {shown} / {total} 个字符', + previewFallback: '预览接口不可用,已使用列表中的文本内容', headers: { code: '取件码', name: '名称', diff --git a/src/services/file.ts b/src/services/file.ts index 9b469b4..1c68707 100644 --- a/src/services/file.ts +++ b/src/services/file.ts @@ -1,13 +1,14 @@ import api, { rawApiClient } from './client' import { multipartUploadConfig } from './shared' import type { + AdminFileListParams, + AdminFilePreviewResponse, ApiResponse, ChunkUploadCompleteRequest, ChunkUploadInitRequest, ChunkUploadInitResponse, ChunkUploadResponse, FileEditForm, - AdminFileListParams, FileInfo, FileListResponse, FileUploadResponse, @@ -145,4 +146,16 @@ export class FileService { headers: response.headers as Record } } + + static async previewAdminFile( + id: number, + maxChars = 20000 + ): Promise> { + return api.get('/admin/file/preview', { + params: { + id, + maxChars + } + }) + } } diff --git a/src/types/file.ts b/src/types/file.ts index b3cdfb0..e1478d9 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -91,6 +91,24 @@ export interface FileListResponse { summary?: AdminFileSummary } +export interface AdminFilePreviewResponse { + id: number + code: string + name: string + type: 'text' + content: string + length: number + previewLength?: number + preview_length?: number + truncated: boolean + maxChars?: number + max_chars?: number + createdAt?: string | null + created_at?: string | null + expiredAt?: string | null + expired_at?: string | null +} + export interface FileUploadResponse { code: string name: string diff --git a/src/utils/download-action.ts b/src/utils/download-action.ts index be34bef..3e14409 100644 --- a/src/utils/download-action.ts +++ b/src/utils/download-action.ts @@ -1,5 +1,5 @@ import { saveAs } from 'file-saver' -import type { ReceivedFileRecord } from '@/types' +import type { AdminFileViewItem, ApiResponse, ReceivedFileRecord } from '@/types' import { buildDownloadUrl } from '@/utils/share-url' export function downloadReceivedRecord(record: ReceivedFileRecord): void { @@ -13,3 +13,67 @@ export function downloadReceivedRecord(record: ReceivedFileRecord): void { saveAs(blob, `${record.filename}.txt`) } } + +export const getSafeFilename = (name: string) => + name + .replace(/[\\/:*?"<>|\x00-\x1F]/g, '_') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180) || 'download' + +const getFilenameFromDisposition = (disposition?: string) => { + if (!disposition) return '' + + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i) + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1]) + } catch { + return utf8Match[1] + } + } + + const asciiMatch = disposition.match(/filename="?([^"]+)"?/i) + return asciiMatch?.[1] || '' +} + +const readBlobAsText = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result || '')) + reader.onerror = () => reject(reader.error) + reader.readAsText(blob) + }) + +export function exportAdminTextFile(file: AdminFileViewItem, content: string): void { + const filename = getSafeFilename(file.displayName || file.code) + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) + saveAs(blob, `${filename}.txt`) +} + +export async function downloadAdminManagedFile( + file: AdminFileViewItem, + response: { data: Blob; headers: Record } +): Promise { + if (file.isTextFile) { + const text = await readBlobAsText(response.data) + let content = text + + try { + const payload = JSON.parse(text) as ApiResponse + content = typeof payload.detail === 'string' ? payload.detail : text + } catch { + content = text + } + + exportAdminTextFile(file, content) + return + } + + const disposition = + response.headers['content-disposition'] || response.headers['Content-Disposition'] + const filename = getSafeFilename( + getFilenameFromDisposition(disposition) || file.displayName || file.code + ) + saveAs(response.data, filename) +} diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index 799b054..d5a123f 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -271,11 +271,32 @@ ? 'bg-gray-700 text-gray-300 hover:bg-gray-600' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' ]" - @click="openTextPreview(file.text || '')" + @click="openTextPreview(file)" > {{ t('fileManage.viewText') }} +