feat: add admin file preview downloads

This commit is contained in:
Lan
2026-06-03 04:20:52 +08:00
parent b97f2cb86b
commit a4edd1293a
7 changed files with 248 additions and 9 deletions
+76 -3
View File
@@ -13,6 +13,11 @@ import type {
} from '@/types' } 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'
import {
downloadAdminManagedFile,
exportAdminTextFile,
getSafeFilename
} from '@/utils/download-action'
const emptySummary = (): AdminFileSummary => ({ const emptySummary = (): AdminFileSummary => ({
totalFiles: 0, totalFiles: 0,
@@ -65,6 +70,10 @@ export function useAdminFiles() {
const showTextPreview = ref(false) const showTextPreview = ref(false)
const previewText = ref('') const previewText = ref('')
const previewFile = ref<AdminFileViewItem | null>(null)
const previewMetaText = ref('')
const isPreviewLoading = ref(false)
const downloadingFileId = ref<number | null>(null)
const totalPages = computed(() => Math.max(Math.ceil(params.value.total / params.value.size), 1)) const totalPages = computed(() => Math.max(Math.ceil(params.value.total / params.value.size), 1))
const storageUsedText = computed(() => formatFileSize(summary.value.storageUsed)) const storageUsedText = computed(() => formatFileSize(summary.value.storageUsed))
@@ -153,7 +162,7 @@ export function useAdminFiles() {
isExpiredFile, isExpiredFile,
isChunkedFile, isChunkedFile,
remainingDownloadsValue, remainingDownloadsValue,
canPreviewText: Boolean(file.text) canPreviewText: isTextFile
} }
} }
@@ -276,14 +285,47 @@ export function useAdminFiles() {
} }
} }
const openTextPreview = (text: string) => { const openTextPreview = async (file: AdminFileViewItem) => {
previewText.value = text if (!file.isTextFile || isPreviewLoading.value) return
previewFile.value = file
previewText.value = file.text || ''
previewMetaText.value = ''
showTextPreview.value = true 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 = () => { const closeTextPreview = () => {
showTextPreview.value = false showTextPreview.value = false
previewText.value = '' previewText.value = ''
previewFile.value = null
previewMetaText.value = ''
} }
const copyText = async () => { 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 { return {
tableData, tableData,
hasLoadError, hasLoadError,
hasActiveFilters, hasActiveFilters,
isLoading, isLoading,
isPreviewLoading,
isSaving, isSaving,
downloadingFileId,
params, params,
previewFile,
previewMetaText,
storageUsedText, storageUsedText,
summary, summary,
showEditModal, showEditModal,
@@ -312,6 +383,8 @@ export function useAdminFiles() {
closeTextPreview, closeTextPreview,
copyText, copyText,
deleteFile, deleteFile,
downloadFile,
exportPreviewText,
handlePageChange, handlePageChange,
handleSearch, handleSearch,
handleUpdate, handleUpdate,
+10
View File
@@ -408,6 +408,16 @@ export default {
copySuccess: 'Text copied to clipboard', copySuccess: 'Text copied to clipboard',
copyFailed: 'Copy failed, please try again', copyFailed: 'Copy failed, please try again',
charCount: '{count} characters', charCount: '{count} characters',
downloadFile: 'Download File',
exportText: 'Export Text',
downloadSuccess: 'Downloaded: {name}',
exportSuccess: 'Exported: {name}',
downloadFailed: 'Download failed',
previewFailed: 'Failed to load preview',
loadingPreview: 'Loading preview...',
previewComplete: 'Loaded full content, {count} characters',
previewTruncated: 'Showing {shown} / {total} characters',
previewFallback: 'Preview endpoint unavailable; using text from the list',
headers: { headers: {
code: 'Retrieve Code', code: 'Retrieve Code',
name: 'Name', name: 'Name',
+10
View File
@@ -406,6 +406,16 @@ export default {
copySuccess: '文本已复制到剪贴板', copySuccess: '文本已复制到剪贴板',
copyFailed: '复制失败,请重试', copyFailed: '复制失败,请重试',
charCount: '共 {count} 个字符', charCount: '共 {count} 个字符',
downloadFile: '下载文件',
exportText: '导出文本',
downloadSuccess: '已下载:{name}',
exportSuccess: '已导出:{name}',
downloadFailed: '下载失败',
previewFailed: '预览加载失败',
loadingPreview: '正在加载预览...',
previewComplete: '已加载完整内容,共 {count} 个字符',
previewTruncated: '已显示 {shown} / {total} 个字符',
previewFallback: '预览接口不可用,已使用列表中的文本内容',
headers: { headers: {
code: '取件码', code: '取件码',
name: '名称', name: '名称',
+14 -1
View File
@@ -1,13 +1,14 @@
import api, { rawApiClient } from './client' import api, { rawApiClient } from './client'
import { multipartUploadConfig } from './shared' import { multipartUploadConfig } from './shared'
import type { import type {
AdminFileListParams,
AdminFilePreviewResponse,
ApiResponse, ApiResponse,
ChunkUploadCompleteRequest, ChunkUploadCompleteRequest,
ChunkUploadInitRequest, ChunkUploadInitRequest,
ChunkUploadInitResponse, ChunkUploadInitResponse,
ChunkUploadResponse, ChunkUploadResponse,
FileEditForm, FileEditForm,
AdminFileListParams,
FileInfo, FileInfo,
FileListResponse, FileListResponse,
FileUploadResponse, FileUploadResponse,
@@ -145,4 +146,16 @@ export class FileService {
headers: response.headers as Record<string, string> headers: response.headers as Record<string, string>
} }
} }
static async previewAdminFile(
id: number,
maxChars = 20000
): Promise<ApiResponse<AdminFilePreviewResponse>> {
return api.get('/admin/file/preview', {
params: {
id,
maxChars
}
})
}
} }
+18
View File
@@ -91,6 +91,24 @@ export interface FileListResponse {
summary?: AdminFileSummary 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 { export interface FileUploadResponse {
code: string code: string
name: string name: string
+65 -1
View File
@@ -1,5 +1,5 @@
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import type { ReceivedFileRecord } from '@/types' import type { AdminFileViewItem, ApiResponse, ReceivedFileRecord } from '@/types'
import { buildDownloadUrl } from '@/utils/share-url' import { buildDownloadUrl } from '@/utils/share-url'
export function downloadReceivedRecord(record: ReceivedFileRecord): void { export function downloadReceivedRecord(record: ReceivedFileRecord): void {
@@ -13,3 +13,67 @@ export function downloadReceivedRecord(record: ReceivedFileRecord): void {
saveAs(blob, `${record.filename}.txt`) saveAs(blob, `${record.filename}.txt`)
} }
} }
export const getSafeFilename = (name: string) =>
name
.replace(/[\\/:*?"<>|\x00-\x1F]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180) || 'download'
const getFilenameFromDisposition = (disposition?: string) => {
if (!disposition) return ''
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch {
return utf8Match[1]
}
}
const asciiMatch = disposition.match(/filename="?([^"]+)"?/i)
return asciiMatch?.[1] || ''
}
const readBlobAsText = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error)
reader.readAsText(blob)
})
export function exportAdminTextFile(file: AdminFileViewItem, content: string): void {
const filename = getSafeFilename(file.displayName || file.code)
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
saveAs(blob, `${filename}.txt`)
}
export async function downloadAdminManagedFile(
file: AdminFileViewItem,
response: { data: Blob; headers: Record<string, string> }
): Promise<void> {
if (file.isTextFile) {
const text = await readBlobAsText(response.data)
let content = text
try {
const payload = JSON.parse(text) as ApiResponse<string>
content = typeof payload.detail === 'string' ? payload.detail : text
} catch {
content = text
}
exportAdminTextFile(file, content)
return
}
const disposition =
response.headers['content-disposition'] || response.headers['Content-Disposition']
const filename = getSafeFilename(
getFilenameFromDisposition(disposition) || file.displayName || file.code
)
saveAs(response.data, filename)
}
+55 -4
View File
@@ -271,11 +271,32 @@
? 'bg-gray-700 text-gray-300 hover:bg-gray-600' ? 'bg-gray-700 text-gray-300 hover:bg-gray-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]" ]"
@click="openTextPreview(file.text || '')" @click="openTextPreview(file)"
> >
<EyeIcon class="mr-1.5 h-4 w-4" /> <EyeIcon class="mr-1.5 h-4 w-4" />
{{ t('fileManage.viewText') }} {{ t('fileManage.viewText') }}
</button> </button>
<button
type="button"
:title="
file.isTextFile ? t('fileManage.exportText') : t('fileManage.downloadFile')
"
:disabled="Boolean(downloadingFileId)"
class="inline-flex items-center rounded-md px-3 py-1.5 transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-60"
:class="[
isDarkMode
? 'bg-emerald-900/20 text-emerald-300 hover:bg-emerald-900/30'
: 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
]"
@click="downloadFile(file)"
>
<RefreshCwIcon
v-if="downloadingFileId === file.id"
class="mr-1.5 h-4 w-4 animate-spin"
/>
<DownloadIcon v-else class="mr-1.5 h-4 w-4" />
{{ file.isTextFile ? t('fileManage.exportText') : t('fileManage.downloadFile') }}
</button>
<button <button
type="button" type="button"
class="inline-flex items-center rounded-md px-3 py-1.5 transition-colors duration-200" class="inline-flex items-center rounded-md px-3 py-1.5 transition-colors duration-200"
@@ -391,7 +412,7 @@
class="text-xl font-semibold leading-6" class="text-xl font-semibold leading-6"
:class="[isDarkMode ? 'text-white' : 'text-gray-900']" :class="[isDarkMode ? 'text-white' : 'text-gray-900']"
> >
{{ t('fileManage.textPreview') }} {{ previewFile?.displayName || t('fileManage.textPreview') }}
</h3> </h3>
</div> </div>
</template> </template>
@@ -400,18 +421,41 @@
class="max-h-[60vh] overflow-y-auto rounded-lg p-4 custom-scrollbar" class="max-h-[60vh] overflow-y-auto rounded-lg p-4 custom-scrollbar"
:class="[isDarkMode ? 'bg-gray-700/50' : 'bg-gray-50']" :class="[isDarkMode ? 'bg-gray-700/50' : 'bg-gray-50']"
> >
<div
v-if="isPreviewLoading"
class="flex min-h-32 items-center justify-center text-sm"
:class="[mutedTextClass]"
>
<RefreshCwIcon class="mr-2 h-4 w-4 animate-spin" />
{{ t('fileManage.loadingPreview') }}
</div>
<pre <pre
v-else
class="whitespace-pre-wrap break-words text-sm font-mono" class="whitespace-pre-wrap break-words text-sm font-mono"
:class="[isDarkMode ? 'text-gray-200' : 'text-gray-700']" :class="[isDarkMode ? 'text-gray-200' : 'text-gray-700']"
>{{ previewText }}</pre >{{ previewText }}</pre
> >
</div> </div>
<div class="mt-2 text-xs" :class="[isDarkMode ? 'text-gray-500' : 'text-gray-400']"> <div class="mt-2 text-xs" :class="[isDarkMode ? 'text-gray-500' : 'text-gray-400']">
{{ t('fileManage.charCount', { count: previewText.length }) }} {{ previewMetaText || t('fileManage.charCount', { count: previewText.length }) }}
</div> </div>
<template #footer> <template #footer>
<BaseButton variant="secondary" @click="copyText"> <BaseButton
variant="secondary"
:disabled="isPreviewLoading || !previewText"
@click="exportPreviewText"
>
<template #icon>
<DownloadIcon class="w-4 h-4 mr-2" />
</template>
{{ t('fileManage.exportText') }}
</BaseButton>
<BaseButton
variant="secondary"
:disabled="isPreviewLoading || !previewText"
@click="copyText"
>
<template #icon> <template #icon>
<CopyIcon class="w-4 h-4 mr-2" /> <CopyIcon class="w-4 h-4 mr-2" />
</template> </template>
@@ -441,6 +485,7 @@ import {
CheckIcon, CheckIcon,
ClockIcon, ClockIcon,
CopyIcon, CopyIcon,
DownloadIcon,
EyeIcon, EyeIcon,
FileIcon, FileIcon,
FileTextIcon, FileTextIcon,
@@ -478,8 +523,12 @@ const {
hasActiveFilters, hasActiveFilters,
hasLoadError, hasLoadError,
isLoading, isLoading,
isPreviewLoading,
isSaving, isSaving,
downloadingFileId,
params, params,
previewFile,
previewMetaText,
storageUsedText, storageUsedText,
summary, summary,
showEditModal, showEditModal,
@@ -490,6 +539,8 @@ const {
closeTextPreview, closeTextPreview,
copyText, copyText,
deleteFile, deleteFile,
downloadFile,
exportPreviewText,
handlePageChange, handlePageChange,
handleSearch, handleSearch,
handleUpdate, handleUpdate,