import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { FileService } from '@/services' import { useAlertStore } from '@/stores/alertStore' import type { AdminBatchEditForm, AdminBatchDeleteFilesResponse, AdminBatchUpdateFilesRequest, AdminBatchUpdateFilesResponse, AdminFileDetailResponse, AdminFileDetailViewItem, AdminFilePatchPayload, AdminFileListParams, AdminFileStatusFilter, AdminFileSummary, AdminFileTypeFilter, AdminFileViewItem, ApiResponse, FileEditForm, FileListItem } from '@/types' import { copyToClipboard } from '@/utils/clipboard' import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common' import { downloadAdminManagedFile, exportAdminTextFile, getSafeFilename } from '@/utils/download-action' import { buildRetrieveUrl } from '@/utils/share-url' const emptySummary = (): AdminFileSummary => ({ totalFiles: 0, activeCount: 0, expiredCount: 0, textCount: 0, fileCount: 0, chunkedCount: 0, storageUsed: 0, usedCount: 0 }) const normalizeCount = (value: number | string | null | undefined) => Number(value || 0) const isExpiredByDate = (value: string | null | undefined) => { if (!value) return false const timestamp = new Date(value).getTime() return Number.isFinite(timestamp) && timestamp < Date.now() } type ErrorWithResponse = { response?: { status?: number } } const isLegacyEndpointUnavailable = (error: unknown) => { const status = (error as ErrorWithResponse)?.response?.status return status === 404 || status === 405 } const legacyForeverExpiresAt = '2099-12-31T23:59' const emptyBatchEditForm = (): AdminBatchEditForm => ({ mode: 'expiresAt', expired_at: '', expired_count: null }) export function useAdminFiles() { const { t } = useI18n() const alertStore = useAlertStore() const tableData = ref([]) const hasLoadError = ref(false) const isLoading = ref(false) const isSaving = ref(false) const summary = ref(emptySummary()) const params = ref({ page: 1, size: 10, total: 0, keyword: '', status: 'all', type: 'all', sortBy: 'created_at', sortOrder: 'desc' }) const showEditModal = ref(false) const editForm = ref({ id: null, code: '', prefix: '', suffix: '', expired_at: '', expired_count: null }) const showBatchEditModal = ref(false) const batchEditForm = ref(emptyBatchEditForm()) const showTextPreview = ref(false) const previewText = ref('') const previewFile = ref(null) const previewMetaText = ref('') const isPreviewLoading = ref(false) const showFileDetailModal = ref(false) const selectedFileDetail = ref(null) const isDetailLoading = ref(false) const downloadingFileId = ref(null) const selectedFileIds = ref>(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)) const currentPageSelectedCount = computed( () => tableData.value.filter((file) => selectedFileIds.value.has(file.id)).length ) const selectedCount = computed(() => selectedFileIds.value.size) const hasSelectedFiles = computed(() => selectedCount.value > 0) const isAllCurrentPageSelected = computed( () => tableData.value.length > 0 && currentPageSelectedCount.value === tableData.value.length ) const isCurrentPagePartiallySelected = computed( () => currentPageSelectedCount.value > 0 && !isAllCurrentPageSelected.value ) const requestParams = computed(() => ({ page: params.value.page, size: params.value.size, keyword: params.value.keyword, status: params.value.status === 'all' ? undefined : params.value.status, type: params.value.type === 'all' ? undefined : params.value.type, sortBy: params.value.sortBy, sortOrder: params.value.sortOrder })) const hasActiveFilters = computed( () => Boolean(params.value.keyword?.trim()) || params.value.status !== 'all' || params.value.type !== 'all' ) const inferIsText = (file: FileListItem) => { if (typeof file.isText === 'boolean') return file.isText if (typeof file.is_text === 'boolean') return file.is_text if (file.type) return file.type === 'text' return Boolean(file.text) } const inferIsExpired = (file: FileListItem) => { if (typeof file.isExpired === 'boolean') return file.isExpired if (typeof file.is_expired === 'boolean') return file.is_expired if ( file.expired_count !== null && file.expired_count !== undefined && file.expired_count <= 0 ) { return true } return isExpiredByDate(file.expired_at) } const inferIsChunked = (file: FileListItem) => Boolean(file.isChunked ?? file.is_chunked) const getRemainingDownloads = (file: FileListItem) => { if (file.remainingDownloads !== undefined) return file.remainingDownloads if (file.remaining_downloads !== undefined) return file.remaining_downloads if ( file.expired_count !== null && file.expired_count !== undefined && file.expired_count >= 0 ) { return Math.max(file.expired_count, 0) } return null } const buildFallbackSummary = (files: AdminFileViewItem[], total: number): AdminFileSummary => ({ totalFiles: total, activeCount: files.filter((file) => !file.isExpiredFile).length, expiredCount: files.filter((file) => file.isExpiredFile).length, textCount: files.filter((file) => file.isTextFile).length, fileCount: files.filter((file) => !file.isTextFile).length, chunkedCount: files.filter((file) => file.isChunkedFile).length, storageUsed: files.reduce((totalSize, file) => totalSize + normalizeCount(file.size), 0), usedCount: files.reduce( (totalUsed, file) => totalUsed + normalizeCount(file.usedCount ?? file.used_count), 0 ) }) const createFileViewItem = (file: FileListItem): AdminFileViewItem => { const isTextFile = inferIsText(file) const isExpiredFile = inferIsExpired(file) const isChunkedFile = inferIsChunked(file) const remainingDownloadsValue = getRemainingDownloads(file) const usedCount = normalizeCount(file.usedCount ?? file.used_count) return { ...file, displayName: file.name || `${file.prefix}${file.suffix}` || file.code, displaySize: formatFileSize(file.size), displayExpiredAt: file.expired_at ? formatTimestamp(file.expired_at) : t('send.expiration.units.forever'), displayUsage: `${usedCount} ${t('common.times')}`, isTextFile, isExpiredFile, isChunkedFile, remainingDownloadsValue, canPreviewText: isTextFile } } 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( Array.from(selectedFileIds.value).filter((id) => visibleIds.has(id)) ) } const clearSelection = () => { selectedFileIds.value = new Set() } const toggleFileSelection = (id: number) => { const nextSelectedIds = new Set(selectedFileIds.value) if (nextSelectedIds.has(id)) { nextSelectedIds.delete(id) } else { nextSelectedIds.add(id) } selectedFileIds.value = nextSelectedIds } const toggleCurrentPageSelection = () => { if (isAllCurrentPageSelected.value) { clearSelection() return } selectedFileIds.value = new Set(tableData.value.map((file) => file.id)) } const resetEditForm = () => { editForm.value = { id: null, code: '', prefix: '', suffix: '', expired_at: '', expired_count: null } } const resetBatchEditForm = () => { batchEditForm.value = emptyBatchEditForm() } const openBatchEditModal = () => { if (!hasSelectedFiles.value || isBatchActionRunning.value) return resetBatchEditForm() showBatchEditModal.value = true } const closeBatchEditModal = (force = false) => { if (isBatchUpdating.value && !force) return showBatchEditModal.value = false resetBatchEditForm() } const buildBatchUpdatePayload = (ids: number[]): AdminBatchUpdateFilesRequest | null => { const form = batchEditForm.value if (form.mode === 'expiresAt') { if (!form.expired_at) return null return { ids, expired_at: form.expired_at } } if (form.mode === 'downloadLimit') { if (form.expired_count === null || !Number.isFinite(form.expired_count)) return null return { ids, expired_count: form.expired_count } } return { ids, clearExpiredAt: true, clear_expired_at: true } } const buildLegacyBatchPatchPayload = ( id: number, payload: AdminBatchUpdateFilesRequest ): AdminFilePatchPayload => { if (payload.clearExpiredAt || payload.clear_expired_at) { return { id, expired_at: legacyForeverExpiresAt, expired_count: -1 } } const patchPayload: AdminFilePatchPayload = { id } if (payload.expired_at !== undefined) { patchPayload.expired_at = payload.expired_at } if (payload.expired_count !== undefined) { patchPayload.expired_count = payload.expired_count } return patchPayload } const loadFiles = async () => { isLoading.value = true try { hasLoadError.value = false const res = await FileService.getAdminFileList(requestParams.value) if (!res.detail) return tableData.value = res.detail.data.map(createFileViewItem) params.value.total = res.detail.total summary.value = res.detail.summary || buildFallbackSummary(tableData.value, res.detail.total) syncSelectedFilesWithCurrentPage() } catch (error) { hasLoadError.value = true alertStore.showAlert( getErrorMessage(error, t('manage.fileManage.loadFileListFailed')), 'error' ) } finally { isLoading.value = false } } const handleSearch = async () => { params.value.page = 1 await loadFiles() } const refreshFiles = async () => { await loadFiles() } const resetFilters = async () => { params.value.page = 1 params.value.keyword = '' params.value.status = 'all' params.value.type = 'all' params.value.sortBy = 'created_at' params.value.sortOrder = 'desc' clearSelection() await loadFiles() } const setStatusFilter = async (status: AdminFileStatusFilter) => { params.value.status = status params.value.page = 1 await loadFiles() } const setTypeFilter = async (type: AdminFileTypeFilter) => { params.value.type = type params.value.page = 1 await loadFiles() } const handlePageChange = async (page: number | string) => { if (typeof page === 'string') return if (page < 1 || page > totalPages.value) return params.value.page = page await loadFiles() } const openEditModal = (file: FileListItem) => { editForm.value = { id: file.id, code: file.code, prefix: file.prefix, suffix: file.suffix, expired_at: file.expired_at ? file.expired_at.slice(0, 16) : '', expired_count: file.expired_count } showEditModal.value = true } const closeEditModal = () => { showEditModal.value = false resetEditForm() } const handleUpdate = async () => { if (isSaving.value) return isSaving.value = true try { await FileService.updateFile(editForm.value) await loadFiles() closeEditModal() } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.updateFailed')), 'error') } finally { isSaving.value = false } } const deleteFile = async (id: number) => { if (!window.confirm(t('manage.fileManage.deleteConfirm'))) { return } try { await FileService.deleteAdminFile(id) if (tableData.value.length === 1 && params.value.page > 1) { params.value.page -= 1 } selectedFileIds.value.delete(id) selectedFileIds.value = new Set(selectedFileIds.value) await loadFiles() } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error') } } const deleteAdminFilesWithFallback = async ( ids: number[] ): Promise> => { try { return await FileService.deleteAdminFiles(ids) } catch (error: unknown) { if (!isLegacyEndpointUnavailable(error)) { throw error } await Promise.all(ids.map((id) => FileService.deleteAdminFile(id))) return { code: 200, detail: { deletedCount: ids.length, deleted_count: ids.length, deleted: ids, missing: [], failed: [] } } } } const updateAdminFilesWithFallback = async ( ids: number[], payload: AdminBatchUpdateFilesRequest ): Promise> => { try { return await FileService.updateAdminFiles(payload) } catch (error: unknown) { if (!isLegacyEndpointUnavailable(error)) { throw error } await Promise.all( ids.map((id) => FileService.updateFile(buildLegacyBatchPatchPayload(id, payload))) ) return { code: 200, detail: { updatedCount: ids.length, updated_count: ids.length, updated: ids, missing: [], failed: [] } } } } const deleteSelectedFiles = async () => { if (!hasSelectedFiles.value || isBatchActionRunning.value) return const ids = Array.from(selectedFileIds.value) if (!window.confirm(t('fileManage.batchDeleteConfirm', { count: ids.length }))) { return } isBatchDeleting.value = true try { const response = await deleteAdminFilesWithFallback(ids) const detail = response.detail const deletedCount = detail?.deletedCount ?? detail?.deleted_count ?? ids.length const failedCount = detail?.failedCount ?? detail?.failed_count ?? 0 if (tableData.value.length <= deletedCount && params.value.page > 1) { params.value.page -= 1 } clearSelection() await loadFiles() alertStore.showAlert( t( failedCount > 0 ? 'fileManage.batchDeletePartialSuccess' : 'fileManage.batchDeleteSuccess', { count: deletedCount, failed: failedCount } ), failedCount > 0 ? 'warning' : 'success' ) } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, t('fileManage.batchDeleteFailed')), 'error') } finally { isBatchDeleting.value = false } } const handleBatchUpdate = async () => { if (!hasSelectedFiles.value || isBatchActionRunning.value) return const ids = Array.from(selectedFileIds.value) const payload = buildBatchUpdatePayload(ids) if (!payload) { alertStore.showAlert(t('fileManage.batchUpdateNoFields'), 'warning') return } if (!window.confirm(t('fileManage.batchUpdateConfirm', { count: ids.length }))) { return } isBatchUpdating.value = true try { const response = await updateAdminFilesWithFallback(ids, payload) const detail = response.detail const updatedCount = detail?.updatedCount ?? detail?.updated_count ?? ids.length const failedCount = detail?.failedCount ?? detail?.failed_count ?? 0 clearSelection() await loadFiles() closeBatchEditModal(true) alertStore.showAlert( t( failedCount > 0 ? 'fileManage.batchUpdatePartialSuccess' : 'fileManage.batchUpdateSuccess', { count: updatedCount, failed: failedCount } ), failedCount > 0 ? 'warning' : 'success' ) } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, t('fileManage.batchUpdateFailed')), 'error') } finally { isBatchUpdating.value = false } } 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 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'), errorMsg: t('fileManage.copyFailed'), notify: (message, type) => alertStore.showAlert(message, type) }) } 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) } 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, isAllCurrentPageSelected, isBatchActionRunning, isBatchDeleting, isBatchUpdating, isCurrentPagePartiallySelected, isDetailLoading, isPreviewLoading, isSaving, batchEditForm, downloadingFileId, hasSelectedFiles, params, previewFile, previewMetaText, selectedFileDetail, selectedCount, selectedFileIds, storageUsedText, summary, showBatchEditModal, showEditModal, showFileDetailModal, editForm, showTextPreview, previewText, totalPages, closeEditModal, closeBatchEditModal, closeFileDetail, closeTextPreview, copyDetailRetrieveCode, copyDetailRetrieveLink, copyText, clearSelection, deleteFile, deleteSelectedFiles, downloadFile, exportPreviewText, handlePageChange, handleSearch, handleBatchUpdate, handleUpdate, loadFiles, openBatchEditModal, openEditModal, openFileDetail, openTextPreview, refreshFiles, resetFilters, setStatusFilter, setTypeFilter, toggleCurrentPageSelection, toggleFileSelection } }