feat: add admin file preview downloads
This commit is contained in:
@@ -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<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 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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '名称',
|
||||
|
||||
+14
-1
@@ -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<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
static async previewAdminFile(
|
||||
id: number,
|
||||
maxChars = 20000
|
||||
): Promise<ApiResponse<AdminFilePreviewResponse>> {
|
||||
return api.get('/admin/file/preview', {
|
||||
params: {
|
||||
id,
|
||||
maxChars
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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)
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
<EyeIcon class="mr-1.5 h-4 w-4" />
|
||||
{{ t('fileManage.viewText') }}
|
||||
</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
|
||||
type="button"
|
||||
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="[isDarkMode ? 'text-white' : 'text-gray-900']"
|
||||
>
|
||||
{{ t('fileManage.textPreview') }}
|
||||
{{ previewFile?.displayName || t('fileManage.textPreview') }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
@@ -400,18 +421,41 @@
|
||||
class="max-h-[60vh] overflow-y-auto rounded-lg p-4 custom-scrollbar"
|
||||
: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
|
||||
v-else
|
||||
class="whitespace-pre-wrap break-words text-sm font-mono"
|
||||
:class="[isDarkMode ? 'text-gray-200' : 'text-gray-700']"
|
||||
>{{ previewText }}</pre
|
||||
>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<CopyIcon class="w-4 h-4 mr-2" />
|
||||
</template>
|
||||
@@ -441,6 +485,7 @@ import {
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
FileIcon,
|
||||
FileTextIcon,
|
||||
@@ -478,8 +523,12 @@ const {
|
||||
hasActiveFilters,
|
||||
hasLoadError,
|
||||
isLoading,
|
||||
isPreviewLoading,
|
||||
isSaving,
|
||||
downloadingFileId,
|
||||
params,
|
||||
previewFile,
|
||||
previewMetaText,
|
||||
storageUsedText,
|
||||
summary,
|
||||
showEditModal,
|
||||
@@ -490,6 +539,8 @@ const {
|
||||
closeTextPreview,
|
||||
copyText,
|
||||
deleteFile,
|
||||
downloadFile,
|
||||
exportPreviewText,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
handleUpdate,
|
||||
|
||||
Reference in New Issue
Block a user