Files
FileCodeBoxFronted2026/src/composables/useAdminFiles.ts
T
2026-06-03 06:37:56 +08:00

1400 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { FileService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
import type {
AdminBatchEditForm,
AdminBatchDeleteFilesResponse,
AdminBatchPolicyActionRequest,
AdminBatchPolicyActionResponse,
AdminBatchUpdateFilesRequest,
AdminBatchUpdateFilesResponse,
AdminFileDetailResponse,
AdminFileDetailTimelineItem,
AdminFileDetailViewItem,
AdminFileMetadataRequest,
AdminFileInsightSeverity,
AdminFilePatchPayload,
AdminFileHealthFilter,
AdminFileListParams,
AdminFilePolicyAction,
AdminFilePolicyActionRequest,
AdminFileStatusFilter,
AdminFileSummary,
AdminFileTypeFilter,
AdminFileViewItem,
ApiResponse,
FileEditForm,
FileListItem
} from '@/types'
import { copyToClipboard } from '@/utils/clipboard'
import { formatDuration, 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,
healthAttentionCount: 0,
healthDangerCount: 0,
healthWarningCount: 0,
expiringSoonCount: 0,
storageIssueCount: 0,
neverRetrievedCount: 0,
healthyCount: 0,
permanentCount: 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 detailPolicyDownloadLimit = 5
const dayInMilliseconds = 24 * 60 * 60 * 1000
type DetailPolicyActionOption = {
action: AdminFilePolicyAction
label: string
description: string
}
type BatchPolicyActionOption = DetailPolicyActionOption
type HealthFilterOption = {
value: AdminFileHealthFilter
label: string
count?: number
}
const emptyBatchEditForm = (): AdminBatchEditForm => ({
mode: 'expiresAt',
expired_at: '',
expired_count: null
})
const metadataTagSeparatorPattern = /[,\n]+/
const emptyDetailMetadataForm = () => ({
note: '',
tagsText: ''
})
const padDatePart = (value: number) => String(value).padStart(2, '0')
const formatLocalDateTime = (date: Date) => {
const year = date.getFullYear()
const month = padDatePart(date.getMonth() + 1)
const day = padDatePart(date.getDate())
const hours = padDatePart(date.getHours())
const minutes = padDatePart(date.getMinutes())
return `${year}-${month}-${day}T${hours}:${minutes}`
}
export function useAdminFiles() {
const { t } = useI18n()
const alertStore = useAlertStore()
const tableData = ref<AdminFileViewItem[]>([])
const hasLoadError = ref(false)
const isLoading = ref(false)
const isSaving = ref(false)
const summary = ref<AdminFileSummary>(emptySummary())
const params = ref<AdminFileListParams & { total: number }>({
page: 1,
size: 10,
total: 0,
keyword: '',
status: 'all',
type: 'all',
health: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
})
const showEditModal = ref(false)
const editForm = ref<FileEditForm>({
id: null,
code: '',
prefix: '',
suffix: '',
expired_at: '',
expired_count: null
})
const showBatchEditModal = ref(false)
const batchEditForm = ref<AdminBatchEditForm>(emptyBatchEditForm())
const showTextPreview = ref(false)
const previewText = ref('')
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 isDetailPolicyActionRunning = ref(false)
const isDetailMetadataSaving = ref(false)
const detailMetadataForm = ref(emptyDetailMetadataForm())
const downloadingFileId = ref<number | null>(null)
const selectedFileIds = ref<Set<number>>(new Set())
const isBatchDeleting = ref(false)
const isBatchUpdating = ref(false)
const isBatchPolicyActionRunning = ref(false)
let detailRequestSerial = 0
const isBatchActionRunning = computed(
() => isBatchDeleting.value || isBatchUpdating.value || isBatchPolicyActionRunning.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<AdminFileListParams>(() => ({
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,
health: params.value.health === 'all' ? undefined : params.value.health,
sortBy: params.value.sortBy,
sortOrder: params.value.sortOrder
}))
const hasActiveFilters = computed(
() =>
Boolean(params.value.keyword?.trim()) ||
params.value.status !== 'all' ||
params.value.type !== 'all' ||
params.value.health !== 'all'
)
const healthFilterOptions = computed<HealthFilterOption[]>(() => [
{ value: 'all', label: t('fileManage.healthFilters.all'), count: summary.value.totalFiles },
{
value: 'attention',
label: t('fileManage.healthFilters.attention'),
count: summary.value.healthAttentionCount
},
{
value: 'danger',
label: t('fileManage.healthFilters.danger'),
count: summary.value.healthDangerCount
},
{
value: 'warning',
label: t('fileManage.healthFilters.warning'),
count: summary.value.healthWarningCount
},
{
value: 'expiring_soon',
label: t('fileManage.healthFilters.expiringSoon'),
count: summary.value.expiringSoonCount
},
{
value: 'storage_issue',
label: t('fileManage.healthFilters.storageIssue'),
count: summary.value.storageIssueCount
},
{
value: 'never_retrieved',
label: t('fileManage.healthFilters.neverRetrieved'),
count: summary.value.neverRetrievedCount
},
{
value: 'healthy',
label: t('fileManage.healthFilters.healthy'),
count: summary.value.healthyCount
},
{
value: 'permanent',
label: t('fileManage.healthFilters.permanent'),
count: summary.value.permanentCount
}
])
const detailPolicyActionOptions = computed<DetailPolicyActionOption[]>(() => [
{
action: 'extend_24h',
label: t('fileManage.policyActions.extend24h'),
description: t('fileManage.policyActionDescriptions.extend24h')
},
{
action: 'extend_7d',
label: t('fileManage.policyActions.extend7d'),
description: t('fileManage.policyActionDescriptions.extend7d')
},
{
action: 'make_permanent',
label: t('fileManage.policyActions.makePermanent'),
description: t('fileManage.policyActionDescriptions.makePermanent')
},
{
action: 'reset_download_limit',
label: t('fileManage.policyActions.resetDownloadLimit', {
count: detailPolicyDownloadLimit
}),
description: t('fileManage.policyActionDescriptions.resetDownloadLimit', {
count: detailPolicyDownloadLimit
})
}
])
const batchPolicyActionOptions = computed<BatchPolicyActionOption[]>(
() => detailPolicyActionOptions.value
)
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,
healthAttentionCount: files.filter((file) =>
['danger', 'warning'].includes(file.statusInsightSeverity)
).length,
healthDangerCount: files.filter((file) => file.statusInsightSeverity === 'danger').length,
healthWarningCount: files.filter((file) => file.statusInsightSeverity === 'warning').length,
expiringSoonCount: files.filter((file) => file.statusInsightReasons.includes('expires_soon'))
.length,
storageIssueCount: files.filter((file) =>
file.statusInsightReasons.includes('storage_metadata_incomplete')
).length,
neverRetrievedCount: files.filter((file) =>
file.statusInsightReasons.includes('never_retrieved')
).length,
healthyCount: files.filter((file) => file.statusInsightSeverity === 'success').length,
permanentCount: files.filter((file) => file.statusInsightState === 'permanent').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 normalizeSummary = (
rawSummary: Partial<AdminFileSummary> | undefined,
files: AdminFileViewItem[],
total: number
): AdminFileSummary => {
const fallback = buildFallbackSummary(files, total)
if (!rawSummary) return fallback
return {
totalFiles: normalizeCount(rawSummary.totalFiles ?? fallback.totalFiles),
activeCount: normalizeCount(rawSummary.activeCount ?? fallback.activeCount),
expiredCount: normalizeCount(rawSummary.expiredCount ?? fallback.expiredCount),
textCount: normalizeCount(rawSummary.textCount ?? fallback.textCount),
fileCount: normalizeCount(rawSummary.fileCount ?? fallback.fileCount),
chunkedCount: normalizeCount(rawSummary.chunkedCount ?? fallback.chunkedCount),
healthAttentionCount: normalizeCount(
rawSummary.healthAttentionCount ?? fallback.healthAttentionCount
),
healthDangerCount: normalizeCount(rawSummary.healthDangerCount ?? fallback.healthDangerCount),
healthWarningCount: normalizeCount(
rawSummary.healthWarningCount ?? fallback.healthWarningCount
),
expiringSoonCount: normalizeCount(rawSummary.expiringSoonCount ?? fallback.expiringSoonCount),
storageIssueCount: normalizeCount(rawSummary.storageIssueCount ?? fallback.storageIssueCount),
neverRetrievedCount: normalizeCount(
rawSummary.neverRetrievedCount ?? fallback.neverRetrievedCount
),
healthyCount: normalizeCount(rawSummary.healthyCount ?? fallback.healthyCount),
permanentCount: normalizeCount(rawSummary.permanentCount ?? fallback.permanentCount),
storageUsed: normalizeCount(rawSummary.storageUsed ?? fallback.storageUsed),
usedCount: normalizeCount(rawSummary.usedCount ?? fallback.usedCount)
}
}
const normalizeInsightSeverity = (
severity: AdminFileInsightSeverity | undefined
): AdminFileInsightSeverity => {
if (severity === 'success' || severity === 'warning' || severity === 'danger') {
return severity
}
if (severity === 'info' || severity === 'neutral') {
return severity
}
return 'neutral'
}
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)
const statusInsights = file.statusInsights ?? file.status_insights
const isPermanentFile =
!file.expired_at &&
(file.expired_count === null || file.expired_count === undefined || file.expired_count < 0)
const statusInsightState =
statusInsights?.state ??
(isExpiredFile ? 'expired' : isPermanentFile ? 'permanent' : 'available')
const statusInsightNextAction =
statusInsights?.nextAction ?? statusInsights?.next_action ?? 'monitor'
const statusInsightSeverity = normalizeInsightSeverity(statusInsights?.severity)
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')}`,
displayHealthState: t(`fileManage.insightStates.${statusInsightState}`),
displayHealthAction: t(`fileManage.insightActions.${statusInsightNextAction}`),
isTextFile,
isExpiredFile,
isChunkedFile,
remainingDownloadsValue,
canPreviewText: isTextFile,
statusInsightSeverity,
statusInsightState,
statusInsightNextAction,
statusInsightReasons: statusInsights?.reasons || []
}
}
const formatTimelineValue = (item: AdminFileDetailTimelineItem) => {
if (item.key === 'download_limit') {
if (item.status === 'unlimited') return t('fileManage.unlimited')
if (typeof item.value === 'number') return t('fileManage.remaining', { count: item.value })
}
if (item.key === 'retrieved' && typeof item.value === 'number') {
return `${item.value} ${t('common.times')}`
}
if (item.key === 'expiration_policy') {
if (item.status === 'unlimited') return t('send.expiration.units.forever')
if (typeof item.value === 'number') {
const duration = formatDuration(Math.abs(item.value), (key) => t(key))
return item.value <= 0
? t('fileManage.timeline.expiration_policy.overdue', { time: duration })
: t('fileManage.timeline.expiration_policy.remaining', { time: duration })
}
}
if (item.detail) return item.detail
if (item.value !== null && item.value !== undefined) return String(item.value)
return ''
}
const createTimelineViewItems = (items: AdminFileDetailTimelineItem[] = []) =>
items.map((item) => {
const timestampText = item.timestamp ? formatTimestamp(item.timestamp) : ''
const valueText = formatTimelineValue(item)
const metaParts = [timestampText, valueText].filter(Boolean)
return {
...item,
severity: normalizeInsightSeverity(item.severity),
displayTitle: t(`fileManage.timeline.${item.key}.title`),
displayDescription: t(`fileManage.timeline.${item.key}.description`, {
status: t(`fileManage.timeline.status.${item.status || 'pending'}`),
detail: item.detail || '',
value: valueText || ''
}),
displayMeta: metaParts.length > 0 ? metaParts.join(' · ') : '-'
}
})
const normalizeMetadataTags = (tags: string[] | undefined) =>
Array.isArray(tags) ? tags.map((tag) => String(tag).trim()).filter(Boolean) : []
const parseMetadataTags = (value: string) =>
normalizeMetadataTags(value.split(metadataTagSeparatorPattern))
const formatMetadataTags = (tags: string[]) => normalizeMetadataTags(tags).join(', ')
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
const statusInsights = detail.statusInsights ?? detail.status_insights
const statusInsightState =
statusInsights?.state ??
(viewItem.isExpiredFile ? 'expired' : isPermanentFile ? 'permanent' : 'available')
const statusInsightNextAction =
statusInsights?.nextAction ?? statusInsights?.next_action ?? 'monitor'
const metadata = detail.metadata ?? detail.meta
const metadataNote = detail.note ?? metadata?.note ?? ''
const metadataTags = normalizeMetadataTags(detail.tags ?? metadata?.tags)
const metadataUpdatedAt =
detail.metadataUpdatedAt ??
detail.metadata_updated_at ??
metadata?.updatedAt ??
metadata?.updated_at ??
null
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,
metadataNote,
metadataTags,
metadataUpdatedAt,
statusInsightSeverity: normalizeInsightSeverity(statusInsights?.severity),
statusInsightState,
statusInsightNextAction,
statusInsightReasons: statusInsights?.reasons || [],
statusInsightMetrics: statusInsights?.metrics,
detailTimeline: createTimelineViewItems(detail.timeline || [])
}
}
const syncDetailMetadataForm = (file: AdminFileDetailViewItem | null) => {
detailMetadataForm.value = {
note: file?.metadataNote || '',
tagsText: file ? formatMetadataTags(file.metadataTags) : ''
}
}
const setSelectedFileDetail = (file: FileListItem | AdminFileDetailResponse) => {
const nextFile = createFileDetailViewItem(file)
selectedFileDetail.value = nextFile
syncDetailMetadataForm(nextFile)
}
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 buildPolicyActionRequest = (
file: AdminFileDetailViewItem,
action: AdminFilePolicyAction
): AdminFilePolicyActionRequest => {
const request: AdminFilePolicyActionRequest = {
id: file.id,
action
}
if (action === 'reset_download_limit') {
request.downloadLimit = detailPolicyDownloadLimit
request.download_limit = detailPolicyDownloadLimit
}
return request
}
const buildBatchPolicyActionRequest = (
ids: number[],
action: AdminFilePolicyAction
): AdminBatchPolicyActionRequest => {
const request: AdminBatchPolicyActionRequest = {
ids,
action
}
if (action === 'reset_download_limit') {
request.downloadLimit = detailPolicyDownloadLimit
request.download_limit = detailPolicyDownloadLimit
}
return request
}
const getPolicyActionBaseDate = (file: Pick<FileListItem, 'expired_at'>) => {
const now = new Date()
const expiredAt = file.expired_at ? new Date(file.expired_at) : null
if (!expiredAt || Number.isNaN(expiredAt.getTime()) || expiredAt.getTime() < now.getTime()) {
return now
}
return expiredAt
}
const buildLegacyPolicyActionPayload = (
file: Pick<FileListItem, 'id' | 'expired_at'>,
action: AdminFilePolicyAction
): AdminFilePatchPayload => {
if (action === 'make_permanent') {
return {
id: file.id,
expired_at: legacyForeverExpiresAt,
expired_count: -1
}
}
if (action === 'reset_download_limit') {
return {
id: file.id,
expired_count: detailPolicyDownloadLimit
}
}
const offsetDays = action === 'extend_7d' ? 7 : 1
const nextExpiredAt = new Date(
getPolicyActionBaseDate(file).getTime() + offsetDays * dayInMilliseconds
)
return {
id: file.id,
expired_at: formatLocalDateTime(nextExpiredAt)
}
}
const refreshSelectedFileDetail = async (fileId: number, detail?: AdminFileDetailResponse) => {
if (detail) {
setSelectedFileDetail(detail)
return
}
const fallbackFile = tableData.value.find((file) => file.id === fileId)
if (fallbackFile) {
setSelectedFileDetail(fallbackFile)
}
try {
const response = await FileService.getAdminFileDetail(fileId)
if (response.detail) {
setSelectedFileDetail(response.detail)
}
} catch (error: unknown) {
if (!isLegacyEndpointUnavailable(error)) {
throw error
}
}
}
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 = normalizeSummary(res.detail.summary, 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.health = '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 setHealthFilter = async (health: AdminFileHealthFilter) => {
params.value.health = health
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<ApiResponse<AdminBatchDeleteFilesResponse>> => {
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<ApiResponse<AdminBatchUpdateFilesResponse>> => {
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 applyAdminFilesPolicyActionWithFallback = async (
ids: number[],
action: AdminFilePolicyAction
): Promise<ApiResponse<AdminBatchPolicyActionResponse>> => {
const request = buildBatchPolicyActionRequest(ids, action)
try {
return await FileService.applyAdminFilesPolicyAction(request)
} catch (error: unknown) {
if (!isLegacyEndpointUnavailable(error)) {
throw error
}
const filesById = new Map(tableData.value.map((file) => [file.id, file]))
const updated: number[] = []
const missing: number[] = []
const failed: { id: number; reason: string }[] = []
for (const id of ids) {
const file = filesById.get(id)
if (!file) {
missing.push(id)
continue
}
try {
await FileService.updateFile(buildLegacyPolicyActionPayload(file, action))
updated.push(id)
} catch (legacyError: unknown) {
failed.push({ id, reason: getErrorMessage(legacyError, t('common.unknownError')) })
}
}
return {
code: 200,
detail: {
requestedCount: ids.length,
requested_count: ids.length,
uniqueCount: ids.length,
unique_count: ids.length,
updatedCount: updated.length,
updated_count: updated.length,
missingCount: missing.length,
missing_count: missing.length,
failedCount: failed.length,
failed_count: failed.length,
action,
updated,
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 applySelectedPolicyAction = async (action: AdminFilePolicyAction) => {
if (!hasSelectedFiles.value || isBatchActionRunning.value) return
const ids = Array.from(selectedFileIds.value)
const actionLabel =
batchPolicyActionOptions.value.find((option) => option.action === action)?.label || action
if (
!window.confirm(
t('fileManage.batchPolicyActionConfirm', {
count: ids.length,
action: actionLabel
})
)
) {
return
}
isBatchPolicyActionRunning.value = true
try {
const response = await applyAdminFilesPolicyActionWithFallback(ids, action)
const detail = response.detail
const updatedCount = detail?.updatedCount ?? detail?.updated_count ?? ids.length
const failedCount =
(detail?.failedCount ?? detail?.failed_count ?? 0) +
(detail?.missingCount ?? detail?.missing_count ?? 0)
clearSelection()
await loadFiles()
alertStore.showAlert(
t(
failedCount > 0
? 'fileManage.batchPolicyActionPartialSuccess'
: 'fileManage.batchPolicyActionSuccess',
{
count: updatedCount,
failed: failedCount
}
),
failedCount > 0 ? 'warning' : 'success'
)
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, t('fileManage.batchPolicyActionFailed')), 'error')
} finally {
isBatchPolicyActionRunning.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
setSelectedFileDetail(file)
showFileDetailModal.value = true
isDetailLoading.value = true
try {
const response = await FileService.getAdminFileDetail(file.id)
if (response.detail && requestSerial === detailRequestSerial) {
setSelectedFileDetail(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 applyDetailPolicyAction = async (action: AdminFilePolicyAction) => {
const file = selectedFileDetail.value
if (!file || isDetailPolicyActionRunning.value) return
isDetailPolicyActionRunning.value = true
try {
let nextDetail: AdminFileDetailResponse | undefined
try {
const response = await FileService.applyAdminFilePolicyAction(
buildPolicyActionRequest(file, action)
)
nextDetail = response.detail
} catch (error: unknown) {
if (!isLegacyEndpointUnavailable(error)) {
throw error
}
await FileService.updateFile(buildLegacyPolicyActionPayload(file, action))
}
await loadFiles()
await refreshSelectedFileDetail(file.id, nextDetail)
alertStore.showAlert(t('fileManage.policyActionSuccess'), 'success')
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, t('fileManage.policyActionFailed')), 'error')
} finally {
isDetailPolicyActionRunning.value = false
}
}
const updateDetailMetadata = async () => {
const file = selectedFileDetail.value
if (!file || isDetailMetadataSaving.value) return
const payload: AdminFileMetadataRequest = {
id: file.id,
note: detailMetadataForm.value.note,
tags: parseMetadataTags(detailMetadataForm.value.tagsText)
}
isDetailMetadataSaving.value = true
try {
const response = await FileService.updateAdminFileMetadata(payload)
if (response.detail) {
setSelectedFileDetail(response.detail)
} else {
await refreshSelectedFileDetail(file.id)
}
alertStore.showAlert(t('fileManage.metadataSaveSuccess'), 'success')
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, t('fileManage.metadataSaveFailed')), 'error')
} finally {
isDetailMetadataSaving.value = false
}
}
const closeFileDetail = () => {
detailRequestSerial += 1
showFileDetailModal.value = false
selectedFileDetail.value = null
syncDetailMetadataForm(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,
isBatchPolicyActionRunning,
isBatchUpdating,
isCurrentPagePartiallySelected,
isDetailLoading,
isDetailMetadataSaving,
isDetailPolicyActionRunning,
isPreviewLoading,
isSaving,
batchEditForm,
batchPolicyActionOptions,
detailPolicyActionOptions,
detailMetadataForm,
downloadingFileId,
healthFilterOptions,
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,
applyDetailPolicyAction,
applySelectedPolicyAction,
handleBatchUpdate,
handleUpdate,
loadFiles,
openBatchEditModal,
openEditModal,
openFileDetail,
openTextPreview,
refreshFiles,
resetFilters,
setHealthFilter,
setStatusFilter,
setTypeFilter,
updateDetailMetadata,
toggleCurrentPageSelection,
toggleFileSelection
}
}