feat: add admin file detail modal
This commit is contained in:
@@ -7,6 +7,8 @@ import type {
|
||||
AdminBatchDeleteFilesResponse,
|
||||
AdminBatchUpdateFilesRequest,
|
||||
AdminBatchUpdateFilesResponse,
|
||||
AdminFileDetailResponse,
|
||||
AdminFileDetailViewItem,
|
||||
AdminFilePatchPayload,
|
||||
AdminFileListParams,
|
||||
AdminFileStatusFilter,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
exportAdminTextFile,
|
||||
getSafeFilename
|
||||
} from '@/utils/download-action'
|
||||
import { buildRetrieveUrl } from '@/utils/share-url'
|
||||
|
||||
const emptySummary = (): AdminFileSummary => ({
|
||||
totalFiles: 0,
|
||||
@@ -50,7 +53,7 @@ type ErrorWithResponse = {
|
||||
}
|
||||
}
|
||||
|
||||
const isLegacyBatchActionUnavailable = (error: unknown) => {
|
||||
const isLegacyEndpointUnavailable = (error: unknown) => {
|
||||
const status = (error as ErrorWithResponse)?.response?.status
|
||||
return status === 404 || status === 405
|
||||
}
|
||||
@@ -100,10 +103,14 @@ export function useAdminFiles() {
|
||||
const previewFile = ref<AdminFileViewItem | null>(null)
|
||||
const previewMetaText = ref('')
|
||||
const isPreviewLoading = ref(false)
|
||||
const showFileDetailModal = ref(false)
|
||||
const selectedFileDetail = ref<AdminFileDetailViewItem | null>(null)
|
||||
const isDetailLoading = ref(false)
|
||||
const downloadingFileId = ref<number | null>(null)
|
||||
const selectedFileIds = ref<Set<number>>(new Set())
|
||||
const isBatchDeleting = ref(false)
|
||||
const isBatchUpdating = ref(false)
|
||||
let detailRequestSerial = 0
|
||||
const isBatchActionRunning = computed(() => isBatchDeleting.value || isBatchUpdating.value)
|
||||
const totalPages = computed(() => Math.max(Math.ceil(params.value.total / params.value.size), 1))
|
||||
const storageUsedText = computed(() => formatFileSize(summary.value.storageUsed))
|
||||
@@ -208,6 +215,78 @@ export function useAdminFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
const createFileDetailViewItem = (
|
||||
file: FileListItem | AdminFileDetailResponse
|
||||
): AdminFileDetailViewItem => {
|
||||
const detail = file as AdminFileDetailResponse
|
||||
const policy = detail.policy
|
||||
const storage = detail.storage
|
||||
const expiredAt = policy?.expiredAt ?? policy?.expired_at ?? file.expired_at ?? null
|
||||
const expiredCount = policy?.expiredCount ?? policy?.expired_count ?? file.expired_count ?? null
|
||||
const displayName =
|
||||
detail.displayName ??
|
||||
detail.display_name ??
|
||||
detail.filename ??
|
||||
file.name ??
|
||||
`${file.prefix}${file.suffix}` ??
|
||||
file.code
|
||||
const normalizedFile: FileListItem = {
|
||||
...file,
|
||||
name: displayName,
|
||||
expired_at: expiredAt,
|
||||
expired_count: expiredCount
|
||||
}
|
||||
const viewItem = createFileViewItem(normalizedFile)
|
||||
const remainingDownloadsValue =
|
||||
policy?.remainingDownloads ?? policy?.remaining_downloads ?? viewItem.remainingDownloadsValue
|
||||
const canPreviewText =
|
||||
detail.canPreviewText ?? detail.can_preview_text ?? viewItem.canPreviewText
|
||||
const canDownloadFile = detail.canDownload ?? detail.can_download ?? !viewItem.isExpiredFile
|
||||
const textLengthValue = normalizeCount(
|
||||
detail.textLength ?? detail.text_length ?? detail.text?.length
|
||||
)
|
||||
const isPermanentFile =
|
||||
detail.isPermanent ??
|
||||
detail.is_permanent ??
|
||||
policy?.isPermanent ??
|
||||
policy?.is_permanent ??
|
||||
(!expiredAt && (expiredCount === null || expiredCount === undefined || expiredCount < 0))
|
||||
const hasDownloadLimitFile =
|
||||
detail.hasDownloadLimit ??
|
||||
detail.has_download_limit ??
|
||||
(expiredCount !== null && expiredCount !== undefined && expiredCount >= 0)
|
||||
const hasExpirationTimeFile =
|
||||
detail.hasExpirationTime ?? detail.has_expiration_time ?? Boolean(expiredAt)
|
||||
const storageBackendValue =
|
||||
storage?.backend ?? detail.storageBackend ?? detail.storage_backend ?? '-'
|
||||
const isChunkedStorage = storage?.isChunked ?? storage?.is_chunked ?? viewItem.isChunkedFile
|
||||
|
||||
return {
|
||||
...viewItem,
|
||||
displayName,
|
||||
displayExpiredAt: expiredAt ? formatTimestamp(expiredAt) : t('send.expiration.units.forever'),
|
||||
displayCreatedAt: file.created_at ? formatTimestamp(file.created_at) : '-',
|
||||
displayRetrieveUrl: buildRetrieveUrl(file.code),
|
||||
remainingDownloadsValue,
|
||||
canPreviewText,
|
||||
textLengthValue,
|
||||
canDownloadFile,
|
||||
isPermanentFile,
|
||||
hasDownloadLimitFile,
|
||||
hasExpirationTimeFile,
|
||||
isChunkedStorage: Boolean(isChunkedStorage),
|
||||
storageBackendValue,
|
||||
fileHashValue: storage?.fileHash ?? storage?.file_hash ?? detail.fileHash ?? detail.file_hash,
|
||||
filePathValue: storage?.filePath ?? storage?.file_path ?? detail.filePath ?? detail.file_path,
|
||||
uuidFileNameValue:
|
||||
storage?.uuidFileName ??
|
||||
storage?.uuid_file_name ??
|
||||
detail.uuidFileName ??
|
||||
detail.uuid_file_name,
|
||||
uploadIdValue: storage?.uploadId ?? storage?.upload_id ?? detail.uploadId ?? detail.upload_id
|
||||
}
|
||||
}
|
||||
|
||||
const syncSelectedFilesWithCurrentPage = () => {
|
||||
const visibleIds = new Set(tableData.value.map((file) => file.id))
|
||||
selectedFileIds.value = new Set(
|
||||
@@ -433,7 +512,7 @@ export function useAdminFiles() {
|
||||
try {
|
||||
return await FileService.deleteAdminFiles(ids)
|
||||
} catch (error: unknown) {
|
||||
if (!isLegacyBatchActionUnavailable(error)) {
|
||||
if (!isLegacyEndpointUnavailable(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -458,7 +537,7 @@ export function useAdminFiles() {
|
||||
try {
|
||||
return await FileService.updateAdminFiles(payload)
|
||||
} catch (error: unknown) {
|
||||
if (!isLegacyBatchActionUnavailable(error)) {
|
||||
if (!isLegacyEndpointUnavailable(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -605,6 +684,35 @@ export function useAdminFiles() {
|
||||
previewMetaText.value = ''
|
||||
}
|
||||
|
||||
const openFileDetail = async (file: AdminFileViewItem) => {
|
||||
const requestSerial = ++detailRequestSerial
|
||||
selectedFileDetail.value = createFileDetailViewItem(file)
|
||||
showFileDetailModal.value = true
|
||||
isDetailLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await FileService.getAdminFileDetail(file.id)
|
||||
if (response.detail && requestSerial === detailRequestSerial) {
|
||||
selectedFileDetail.value = createFileDetailViewItem(response.detail)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isLegacyEndpointUnavailable(error)) return
|
||||
|
||||
alertStore.showAlert(getErrorMessage(error, t('fileManage.detailFailed')), 'error')
|
||||
} finally {
|
||||
if (requestSerial === detailRequestSerial) {
|
||||
isDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const closeFileDetail = () => {
|
||||
detailRequestSerial += 1
|
||||
showFileDetailModal.value = false
|
||||
selectedFileDetail.value = null
|
||||
isDetailLoading.value = false
|
||||
}
|
||||
|
||||
const copyText = async () => {
|
||||
await copyToClipboard(previewText.value, {
|
||||
successMsg: t('fileManage.copySuccess'),
|
||||
@@ -613,6 +721,26 @@ export function useAdminFiles() {
|
||||
})
|
||||
}
|
||||
|
||||
const copyDetailRetrieveCode = async () => {
|
||||
if (!selectedFileDetail.value) return
|
||||
|
||||
await copyToClipboard(selectedFileDetail.value.code, {
|
||||
successMsg: t('fileManage.copyCodeSuccess'),
|
||||
errorMsg: t('fileManage.copyFailed'),
|
||||
notify: (message, type) => alertStore.showAlert(message, type)
|
||||
})
|
||||
}
|
||||
|
||||
const copyDetailRetrieveLink = async () => {
|
||||
if (!selectedFileDetail.value) return
|
||||
|
||||
await copyToClipboard(selectedFileDetail.value.displayRetrieveUrl, {
|
||||
successMsg: t('fileManage.copyLinkSuccess'),
|
||||
errorMsg: t('fileManage.copyFailed'),
|
||||
notify: (message, type) => alertStore.showAlert(message, type)
|
||||
})
|
||||
}
|
||||
|
||||
const exportPreviewText = () => {
|
||||
if (!previewFile.value || !previewText.value) return
|
||||
exportAdminTextFile(previewFile.value, previewText.value)
|
||||
@@ -648,6 +776,7 @@ export function useAdminFiles() {
|
||||
isBatchDeleting,
|
||||
isBatchUpdating,
|
||||
isCurrentPagePartiallySelected,
|
||||
isDetailLoading,
|
||||
isPreviewLoading,
|
||||
isSaving,
|
||||
batchEditForm,
|
||||
@@ -656,19 +785,24 @@ export function useAdminFiles() {
|
||||
params,
|
||||
previewFile,
|
||||
previewMetaText,
|
||||
selectedFileDetail,
|
||||
selectedCount,
|
||||
selectedFileIds,
|
||||
storageUsedText,
|
||||
summary,
|
||||
showBatchEditModal,
|
||||
showEditModal,
|
||||
showFileDetailModal,
|
||||
editForm,
|
||||
showTextPreview,
|
||||
previewText,
|
||||
totalPages,
|
||||
closeEditModal,
|
||||
closeBatchEditModal,
|
||||
closeFileDetail,
|
||||
closeTextPreview,
|
||||
copyDetailRetrieveCode,
|
||||
copyDetailRetrieveLink,
|
||||
copyText,
|
||||
clearSelection,
|
||||
deleteFile,
|
||||
@@ -682,6 +816,7 @@ export function useAdminFiles() {
|
||||
loadFiles,
|
||||
openBatchEditModal,
|
||||
openEditModal,
|
||||
openFileDetail,
|
||||
openTextPreview,
|
||||
refreshFiles,
|
||||
resetFilters,
|
||||
|
||||
@@ -381,6 +381,10 @@ export default {
|
||||
subtitle: '{count} share records. Filter by status, type, and usage.',
|
||||
searchPlaceholder: 'Search code, name, or text...',
|
||||
allFiles: 'All Files',
|
||||
detail: 'Details',
|
||||
detailTitle: 'File Details',
|
||||
detailLoading: 'Loading details...',
|
||||
detailFailed: 'Failed to load details',
|
||||
editFileInfo: 'Edit File Information',
|
||||
saveChanges: 'Save Changes',
|
||||
refresh: 'Refresh List',
|
||||
@@ -405,7 +409,11 @@ export default {
|
||||
viewText: 'View',
|
||||
textPreview: 'Text Preview',
|
||||
copyText: 'Copy Text',
|
||||
copyCode: 'Copy Code',
|
||||
copyLink: 'Copy Link',
|
||||
copySuccess: 'Text copied to clipboard',
|
||||
copyCodeSuccess: 'Retrieve code copied to clipboard',
|
||||
copyLinkSuccess: 'Retrieve link copied to clipboard',
|
||||
copyFailed: 'Copy failed, please try again',
|
||||
charCount: '{count} characters',
|
||||
downloadFile: 'Download File',
|
||||
@@ -441,6 +449,25 @@ export default {
|
||||
batchUpdateFailed: 'Batch update failed',
|
||||
batchUpdateNoFields: 'Choose a policy to update',
|
||||
applyBatchEdit: 'Apply Changes',
|
||||
overview: 'Overview',
|
||||
policyInfo: 'Policy',
|
||||
storageInfo: 'Storage',
|
||||
permanent: 'Permanent',
|
||||
createdAt: 'Created Time',
|
||||
remainingDownloads: 'Remaining Downloads',
|
||||
textLength: 'Text Length',
|
||||
hasDownloadLimit: 'Has Retrieval Limit',
|
||||
hasExpirationTime: 'Has Expiration',
|
||||
canPreviewText: 'Can Preview Text',
|
||||
canDownload: 'Can Download',
|
||||
storageBackend: 'Storage Backend',
|
||||
fileHash: 'File Hash',
|
||||
isChunked: 'Chunked Upload',
|
||||
filePath: 'Storage Path',
|
||||
uuidFileName: 'Stored File Name',
|
||||
uploadId: 'Upload Session',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
headers: {
|
||||
select: 'Select',
|
||||
code: 'Retrieve Code',
|
||||
|
||||
@@ -379,6 +379,10 @@ export default {
|
||||
subtitle: '共 {count} 条分享记录,可按状态、类型和使用情况快速筛选。',
|
||||
searchPlaceholder: '搜索取件码、名称或文本...',
|
||||
allFiles: '所有文件',
|
||||
detail: '详情',
|
||||
detailTitle: '文件详情',
|
||||
detailLoading: '正在加载详情...',
|
||||
detailFailed: '详情加载失败',
|
||||
editFileInfo: '编辑文件信息',
|
||||
saveChanges: '保存更改',
|
||||
refresh: '刷新列表',
|
||||
@@ -403,7 +407,11 @@ export default {
|
||||
viewText: '查看',
|
||||
textPreview: '文本预览',
|
||||
copyText: '复制文本',
|
||||
copyCode: '复制取件码',
|
||||
copyLink: '复制取件链接',
|
||||
copySuccess: '文本已复制到剪贴板',
|
||||
copyCodeSuccess: '取件码已复制到剪贴板',
|
||||
copyLinkSuccess: '取件链接已复制到剪贴板',
|
||||
copyFailed: '复制失败,请重试',
|
||||
charCount: '共 {count} 个字符',
|
||||
downloadFile: '下载文件',
|
||||
@@ -439,6 +447,25 @@ export default {
|
||||
batchUpdateFailed: '批量更新失败',
|
||||
batchUpdateNoFields: '请选择要更新的策略',
|
||||
applyBatchEdit: '应用更改',
|
||||
overview: '概览',
|
||||
policyInfo: '策略信息',
|
||||
storageInfo: '存储信息',
|
||||
permanent: '永久有效',
|
||||
createdAt: '创建时间',
|
||||
remainingDownloads: '剩余次数',
|
||||
textLength: '文本长度',
|
||||
hasDownloadLimit: '限制取件次数',
|
||||
hasExpirationTime: '限制过期时间',
|
||||
canPreviewText: '可预览文本',
|
||||
canDownload: '可下载',
|
||||
storageBackend: '存储后端',
|
||||
fileHash: '文件哈希',
|
||||
isChunked: '分片上传',
|
||||
filePath: '存储路径',
|
||||
uuidFileName: '存储文件名',
|
||||
uploadId: '上传会话',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
headers: {
|
||||
select: '选择',
|
||||
code: '取件码',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
AdminBatchUpdateFilesRequest,
|
||||
AdminBatchUpdateFilesResponse,
|
||||
AdminFilePatchPayload,
|
||||
AdminFileDetailResponse,
|
||||
AdminFileListParams,
|
||||
AdminFilePreviewResponse,
|
||||
ApiResponse,
|
||||
@@ -128,6 +129,12 @@ export class FileService {
|
||||
return api.get('/admin/file/list', { params })
|
||||
}
|
||||
|
||||
static async getAdminFileDetail(id: number): Promise<ApiResponse<AdminFileDetailResponse>> {
|
||||
return api.get('/admin/file/detail', {
|
||||
params: { id }
|
||||
})
|
||||
}
|
||||
|
||||
static async updateFile(data: FileEditForm | AdminFilePatchPayload): Promise<ApiResponse> {
|
||||
return api.patch('/admin/file/update', data)
|
||||
}
|
||||
|
||||
@@ -118,6 +118,77 @@ export interface AdminFilePreviewResponse {
|
||||
expired_at?: string | null
|
||||
}
|
||||
|
||||
export interface AdminFileDetailPolicy {
|
||||
expiredAt?: string | null
|
||||
expired_at?: string | null
|
||||
expiredCount?: number | null
|
||||
expired_count?: number | null
|
||||
remainingDownloads?: number | null
|
||||
remaining_downloads?: number | null
|
||||
isExpired?: boolean
|
||||
is_expired?: boolean
|
||||
isPermanent?: boolean
|
||||
is_permanent?: boolean
|
||||
}
|
||||
|
||||
export interface AdminFileDetailStorage {
|
||||
backend?: string
|
||||
filePath?: string | null
|
||||
file_path?: string | null
|
||||
uuidFileName?: string | null
|
||||
uuid_file_name?: string | null
|
||||
fileHash?: string | null
|
||||
file_hash?: string | null
|
||||
isChunked?: boolean
|
||||
is_chunked?: boolean
|
||||
uploadId?: string | null
|
||||
upload_id?: string | null
|
||||
}
|
||||
|
||||
export interface AdminFileDetailResponse extends FileListItem {
|
||||
filename?: string
|
||||
displayName?: string
|
||||
display_name?: string
|
||||
isPermanent?: boolean
|
||||
is_permanent?: boolean
|
||||
hasDownloadLimit?: boolean
|
||||
has_download_limit?: boolean
|
||||
hasExpirationTime?: boolean
|
||||
has_expiration_time?: boolean
|
||||
textLength?: number
|
||||
text_length?: number
|
||||
canPreviewText?: boolean
|
||||
can_preview_text?: boolean
|
||||
canDownload?: boolean
|
||||
can_download?: boolean
|
||||
storageBackend?: string
|
||||
storage_backend?: string
|
||||
filePath?: string | null
|
||||
file_path?: string | null
|
||||
uuidFileName?: string | null
|
||||
uuid_file_name?: string | null
|
||||
uploadId?: string | null
|
||||
upload_id?: string | null
|
||||
policy?: AdminFileDetailPolicy
|
||||
storage?: AdminFileDetailStorage
|
||||
}
|
||||
|
||||
export interface AdminFileDetailViewItem extends AdminFileViewItem {
|
||||
displayCreatedAt: string
|
||||
displayRetrieveUrl: string
|
||||
textLengthValue: number
|
||||
canDownloadFile: boolean
|
||||
isPermanentFile: boolean
|
||||
hasDownloadLimitFile: boolean
|
||||
hasExpirationTimeFile: boolean
|
||||
isChunkedStorage: boolean
|
||||
storageBackendValue: string
|
||||
fileHashValue?: string | null
|
||||
filePathValue?: string | null
|
||||
uuidFileNameValue?: string | null
|
||||
uploadIdValue?: string | null
|
||||
}
|
||||
|
||||
export interface AdminBatchDeleteFileFailure {
|
||||
id: number
|
||||
reason: string
|
||||
|
||||
@@ -341,6 +341,20 @@
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:title="t('fileManage.detail')"
|
||||
class="inline-flex items-center rounded-md px-3 py-1.5 transition-colors duration-200"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-indigo-900/20 text-indigo-300 hover:bg-indigo-900/30'
|
||||
: 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100'
|
||||
]"
|
||||
@click="openFileDetail(file)"
|
||||
>
|
||||
<FileTextIcon class="mr-1.5 h-4 w-4" />
|
||||
{{ t('fileManage.detail') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="file.canPreviewText"
|
||||
type="button"
|
||||
@@ -419,6 +433,234 @@
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<BaseModal :show="showFileDetailModal" size="xl" @close="closeFileDetail">
|
||||
<template #header>
|
||||
<div class="flex min-w-0 items-center space-x-3">
|
||||
<div class="rounded-lg p-2" :class="[isDarkMode ? 'bg-indigo-500/10' : 'bg-indigo-50']">
|
||||
<FileTextIcon
|
||||
class="h-5 w-5"
|
||||
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3
|
||||
class="truncate text-xl font-semibold leading-6"
|
||||
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
|
||||
>
|
||||
{{ t('fileManage.detailTitle') }}
|
||||
</h3>
|
||||
<p v-if="selectedFileDetail" class="mt-1 truncate text-sm" :class="[mutedTextClass]">
|
||||
{{ selectedFileDetail.displayName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="selectedFileDetail" class="space-y-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
class="rounded-lg p-3"
|
||||
:class="[isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-700']"
|
||||
>
|
||||
<FileTextIcon v-if="selectedFileDetail.isTextFile" class="h-5 w-5" />
|
||||
<FileIcon v-else class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="break-words text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ selectedFileDetail.displayName }}
|
||||
</h4>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:class="getTypeBadgeClass(selectedFileDetail)"
|
||||
>
|
||||
{{ getTypeLabel(selectedFileDetail) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:class="getStatusBadgeClass(selectedFileDetail)"
|
||||
>
|
||||
{{
|
||||
selectedFileDetail.isExpiredFile
|
||||
? t('fileManage.expired')
|
||||
: t('fileManage.active')
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedFileDetail.isPermanentFile"
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:class="
|
||||
isDarkMode
|
||||
? 'bg-emerald-900/30 text-emerald-300'
|
||||
: 'bg-emerald-100 text-emerald-700'
|
||||
"
|
||||
>
|
||||
{{ t('fileManage.permanent') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDetailLoading"
|
||||
class="inline-flex items-center text-sm"
|
||||
:class="[mutedTextClass]"
|
||||
>
|
||||
<RefreshCwIcon class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t('fileManage.detailLoading') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border px-4 py-3"
|
||||
:class="[isDarkMode ? 'border-gray-700 bg-gray-700/30' : 'border-gray-200 bg-gray-50']"
|
||||
>
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-medium uppercase" :class="[mutedTextClass]">
|
||||
{{ t('fileManage.form.code') }}
|
||||
</p>
|
||||
<p class="mt-1 select-all text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ selectedFileDetail.code }}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-xs" :class="[mutedTextClass]">
|
||||
{{ selectedFileDetail.displayRetrieveUrl }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<BaseButton size="sm" variant="secondary" @click="copyDetailRetrieveCode">
|
||||
<template #icon>
|
||||
<CopyIcon class="mr-2 h-4 w-4" />
|
||||
</template>
|
||||
{{ t('fileManage.copyCode') }}
|
||||
</BaseButton>
|
||||
<BaseButton size="sm" variant="secondary" @click="copyDetailRetrieveLink">
|
||||
<template #icon>
|
||||
<CopyIcon class="mr-2 h-4 w-4" />
|
||||
</template>
|
||||
{{ t('fileManage.copyLink') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-12 items-center justify-center rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="detailActionClass"
|
||||
@click="openDetailEditModal"
|
||||
>
|
||||
<PencilIcon class="mr-2 h-4 w-4" />
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedFileDetail.canPreviewText"
|
||||
type="button"
|
||||
class="flex min-h-12 items-center justify-center rounded-lg border px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="detailActionClass"
|
||||
@click="openDetailTextPreview"
|
||||
>
|
||||
<EyeIcon class="mr-2 h-4 w-4" />
|
||||
{{ t('fileManage.viewText') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-12 items-center justify-center rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="detailActionClass"
|
||||
:disabled="!selectedFileDetail.canDownloadFile || Boolean(downloadingFileId)"
|
||||
@click="downloadFile(selectedFileDetail)"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
v-if="downloadingFileId === selectedFileDetail.id"
|
||||
class="mr-2 h-4 w-4 animate-spin"
|
||||
/>
|
||||
<DownloadIcon v-else class="mr-2 h-4 w-4" />
|
||||
{{
|
||||
selectedFileDetail.isTextFile
|
||||
? t('fileManage.exportText')
|
||||
: t('fileManage.downloadFile')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('fileManage.overview') }}
|
||||
</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="item in detailOverviewItems"
|
||||
:key="item.label"
|
||||
class="rounded-lg border px-4 py-3"
|
||||
:class="[isDarkMode ? 'border-gray-700 bg-gray-700/30' : 'border-gray-200 bg-white']"
|
||||
>
|
||||
<p class="text-xs" :class="[mutedTextClass]">{{ item.label }}</p>
|
||||
<p class="mt-1 break-words text-sm font-medium" :class="[primaryTextClass]">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('fileManage.policyInfo') }}
|
||||
</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="item in detailPolicyItems"
|
||||
:key="item.label"
|
||||
class="rounded-lg border px-4 py-3"
|
||||
:class="[isDarkMode ? 'border-gray-700 bg-gray-700/30' : 'border-gray-200 bg-white']"
|
||||
>
|
||||
<p class="text-xs" :class="[mutedTextClass]">{{ item.label }}</p>
|
||||
<p class="mt-1 break-words text-sm font-medium" :class="[primaryTextClass]">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('fileManage.storageInfo') }}
|
||||
</h4>
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
v-for="item in detailStorageItems"
|
||||
:key="item.label"
|
||||
class="grid gap-1 rounded-lg border px-4 py-3 sm:grid-cols-[160px_minmax(0,1fr)] sm:items-center"
|
||||
:class="[isDarkMode ? 'border-gray-700 bg-gray-700/30' : 'border-gray-200 bg-white']"
|
||||
>
|
||||
<p class="text-xs" :class="[mutedTextClass]">{{ item.label }}</p>
|
||||
<p
|
||||
class="break-all text-sm font-medium"
|
||||
:class="[item.mono ? 'font-mono' : '', primaryTextClass]"
|
||||
>
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
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.detailLoading') }}
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<BaseButton variant="secondary" @click="closeFileDetail">
|
||||
{{ t('common.close') }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<BaseModal :show="showEditModal" size="lg" @close="closeEditModal">
|
||||
<template #header>
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -699,6 +941,7 @@ const {
|
||||
isBatchDeleting,
|
||||
isBatchUpdating,
|
||||
isCurrentPagePartiallySelected,
|
||||
isDetailLoading,
|
||||
isPreviewLoading,
|
||||
isSaving,
|
||||
batchEditForm,
|
||||
@@ -707,18 +950,23 @@ const {
|
||||
params,
|
||||
previewFile,
|
||||
previewMetaText,
|
||||
selectedFileDetail,
|
||||
selectedCount,
|
||||
selectedFileIds,
|
||||
storageUsedText,
|
||||
summary,
|
||||
showBatchEditModal,
|
||||
showEditModal,
|
||||
showFileDetailModal,
|
||||
editForm,
|
||||
showTextPreview,
|
||||
previewText,
|
||||
closeBatchEditModal,
|
||||
closeEditModal,
|
||||
closeFileDetail,
|
||||
closeTextPreview,
|
||||
copyDetailRetrieveCode,
|
||||
copyDetailRetrieveLink,
|
||||
copyText,
|
||||
clearSelection,
|
||||
deleteFile,
|
||||
@@ -732,6 +980,7 @@ const {
|
||||
loadFiles,
|
||||
openBatchEditModal,
|
||||
openEditModal,
|
||||
openFileDetail,
|
||||
openTextPreview,
|
||||
refreshFiles,
|
||||
resetFilters,
|
||||
@@ -751,6 +1000,127 @@ const fieldClass = computed(() =>
|
||||
? 'border-gray-600 bg-gray-700 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-900'
|
||||
)
|
||||
const detailActionClass = computed(() =>
|
||||
isDarkMode.value
|
||||
? 'border-gray-700 bg-gray-700/50 text-gray-300 hover:border-gray-600 hover:bg-gray-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
||||
)
|
||||
|
||||
type DetailInfoItem = {
|
||||
label: string
|
||||
value: string
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
const emptyDetailValue = '-'
|
||||
const getBooleanLabel = (value: boolean) => t(value ? 'fileManage.yes' : 'fileManage.no')
|
||||
const formatDetailValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return emptyDetailValue
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const detailOverviewItems = computed<DetailInfoItem[]>(() => {
|
||||
const file = selectedFileDetail.value
|
||||
if (!file) return []
|
||||
|
||||
return [
|
||||
{ label: t('fileManage.form.code'), value: file.code, mono: true },
|
||||
{ label: t('fileManage.createdAt'), value: file.displayCreatedAt },
|
||||
{ label: t('fileManage.headers.size'), value: file.displaySize },
|
||||
{ label: t('fileManage.headers.usage'), value: file.displayUsage },
|
||||
{
|
||||
label: t('fileManage.remainingDownloads'),
|
||||
value:
|
||||
file.remainingDownloadsValue === null
|
||||
? t('fileManage.unlimited')
|
||||
: t('fileManage.remaining', { count: file.remainingDownloadsValue })
|
||||
},
|
||||
{
|
||||
label: t('fileManage.textLength'),
|
||||
value: file.isTextFile
|
||||
? t('fileManage.charCount', { count: file.textLengthValue })
|
||||
: emptyDetailValue
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const detailPolicyItems = computed<DetailInfoItem[]>(() => {
|
||||
const file = selectedFileDetail.value
|
||||
if (!file) return []
|
||||
|
||||
return [
|
||||
{ label: t('fileManage.permanent'), value: getBooleanLabel(file.isPermanentFile) },
|
||||
{
|
||||
label: t('fileManage.hasDownloadLimit'),
|
||||
value: getBooleanLabel(file.hasDownloadLimitFile)
|
||||
},
|
||||
{
|
||||
label: t('fileManage.hasExpirationTime'),
|
||||
value: getBooleanLabel(file.hasExpirationTimeFile)
|
||||
},
|
||||
{ label: t('fileManage.headers.expiration'), value: file.displayExpiredAt },
|
||||
{
|
||||
label: t('fileManage.canPreviewText'),
|
||||
value: getBooleanLabel(file.canPreviewText)
|
||||
},
|
||||
{
|
||||
label: t('fileManage.canDownload'),
|
||||
value: getBooleanLabel(file.canDownloadFile)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const detailStorageItems = computed<DetailInfoItem[]>(() => {
|
||||
const file = selectedFileDetail.value
|
||||
if (!file) return []
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('fileManage.storageBackend'),
|
||||
value: formatDetailValue(file.storageBackendValue)
|
||||
},
|
||||
{
|
||||
label: t('fileManage.fileHash'),
|
||||
value: formatDetailValue(file.fileHashValue),
|
||||
mono: true
|
||||
},
|
||||
{
|
||||
label: t('fileManage.isChunked'),
|
||||
value: getBooleanLabel(file.isChunkedStorage)
|
||||
},
|
||||
{
|
||||
label: t('fileManage.filePath'),
|
||||
value: formatDetailValue(file.filePathValue),
|
||||
mono: true
|
||||
},
|
||||
{
|
||||
label: t('fileManage.uuidFileName'),
|
||||
value: formatDetailValue(file.uuidFileNameValue),
|
||||
mono: true
|
||||
},
|
||||
{
|
||||
label: t('fileManage.uploadId'),
|
||||
value: formatDetailValue(file.uploadIdValue),
|
||||
mono: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const openDetailEditModal = () => {
|
||||
if (!selectedFileDetail.value) return
|
||||
|
||||
const file = selectedFileDetail.value
|
||||
closeFileDetail()
|
||||
openEditModal(file)
|
||||
}
|
||||
|
||||
const openDetailTextPreview = () => {
|
||||
if (!selectedFileDetail.value) return
|
||||
|
||||
const file = selectedFileDetail.value
|
||||
closeFileDetail()
|
||||
void openTextPreview(file)
|
||||
}
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user