feat: add admin file preview downloads
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user