feat: add admin detail policy actions

This commit is contained in:
Lan
2026-06-03 05:45:02 +08:00
parent 4ab0c0e7dc
commit 167d5cce3f
6 changed files with 273 additions and 1 deletions
+162 -1
View File
@@ -13,6 +13,8 @@ import type {
AdminFileInsightSeverity,
AdminFilePatchPayload,
AdminFileListParams,
AdminFilePolicyAction,
AdminFilePolicyActionRequest,
AdminFileStatusFilter,
AdminFileSummary,
AdminFileTypeFilter,
@@ -61,6 +63,14 @@ const isLegacyEndpointUnavailable = (error: unknown) => {
}
const legacyForeverExpiresAt = '2099-12-31T23:59'
const detailPolicyDownloadLimit = 5
const dayInMilliseconds = 24 * 60 * 60 * 1000
type DetailPolicyActionOption = {
action: AdminFilePolicyAction
label: string
description: string
}
const emptyBatchEditForm = (): AdminBatchEditForm => ({
mode: 'expiresAt',
@@ -68,6 +78,18 @@ const emptyBatchEditForm = (): AdminBatchEditForm => ({
expired_count: null
})
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()
@@ -108,6 +130,7 @@ export function useAdminFiles() {
const showFileDetailModal = ref(false)
const selectedFileDetail = ref<AdminFileDetailViewItem | null>(null)
const isDetailLoading = ref(false)
const isDetailPolicyActionRunning = ref(false)
const downloadingFileId = ref<number | null>(null)
const selectedFileIds = ref<Set<number>>(new Set())
const isBatchDeleting = ref(false)
@@ -145,6 +168,33 @@ export function useAdminFiles() {
params.value.type !== 'all'
)
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 inferIsText = (file: FileListItem) => {
if (typeof file.isText === 'boolean') return file.isText
if (typeof file.is_text === 'boolean') return file.is_text
@@ -158,7 +208,7 @@ export function useAdminFiles() {
if (
file.expired_count !== null &&
file.expired_count !== undefined &&
file.expired_count <= 0
file.expired_count === 0
) {
return true
}
@@ -463,6 +513,84 @@ export function useAdminFiles() {
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 getPolicyActionBaseDate = (file: AdminFileDetailViewItem) => {
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: AdminFileDetailViewItem,
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) {
selectedFileDetail.value = createFileDetailViewItem(detail)
return
}
const fallbackFile = tableData.value.find((file) => file.id === fileId)
if (fallbackFile) {
selectedFileDetail.value = createFileDetailViewItem(fallbackFile)
}
try {
const response = await FileService.getAdminFileDetail(fileId)
if (response.detail) {
selectedFileDetail.value = createFileDetailViewItem(response.detail)
}
} catch (error: unknown) {
if (!isLegacyEndpointUnavailable(error)) {
throw error
}
}
}
const loadFiles = async () => {
isLoading.value = true
try {
@@ -775,6 +903,36 @@ export function useAdminFiles() {
}
}
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 closeFileDetail = () => {
detailRequestSerial += 1
showFileDetailModal.value = false
@@ -846,9 +1004,11 @@ export function useAdminFiles() {
isBatchUpdating,
isCurrentPagePartiallySelected,
isDetailLoading,
isDetailPolicyActionRunning,
isPreviewLoading,
isSaving,
batchEditForm,
detailPolicyActionOptions,
downloadingFileId,
hasSelectedFiles,
params,
@@ -880,6 +1040,7 @@ export function useAdminFiles() {
exportPreviewText,
handlePageChange,
handleSearch,
applyDetailPolicyAction,
handleBatchUpdate,
handleUpdate,
loadFiles,
+14
View File
@@ -448,6 +448,20 @@ export default {
batchUpdatePartialSuccess: 'Updated {count} files, {failed} failed',
batchUpdateFailed: 'Batch update failed',
batchUpdateNoFields: 'Choose a policy to update',
policyActionSuccess: 'File policy updated',
policyActionFailed: 'Failed to apply policy action',
policyActions: {
extend24h: 'Extend 24h',
extend7d: 'Extend 7d',
makePermanent: 'Make Permanent',
resetDownloadLimit: 'Reset to {count}'
},
policyActionDescriptions: {
extend24h: 'Add one day from the active expiration',
extend7d: 'Good for a one-week temporary extension',
makePermanent: 'Clear expiration time and retrieval limits',
resetDownloadLimit: 'Restore retrievals to {count}'
},
applyBatchEdit: 'Apply Changes',
overview: 'Overview',
policyInfo: 'Policy',
+14
View File
@@ -446,6 +446,20 @@ export default {
batchUpdatePartialSuccess: '已更新 {count} 个文件,{failed} 个失败',
batchUpdateFailed: '批量更新失败',
batchUpdateNoFields: '请选择要更新的策略',
policyActionSuccess: '文件策略已更新',
policyActionFailed: '策略动作执行失败',
policyActions: {
extend24h: '延长 24 小时',
extend7d: '延长 7 天',
makePermanent: '设为永久',
resetDownloadLimit: '重置为 {count} 次'
},
policyActionDescriptions: {
extend24h: '从当前有效期继续追加一天',
extend7d: '适合临时续期一周',
makePermanent: '清空过期时间和次数限制',
resetDownloadLimit: '恢复可取件次数到 {count} 次'
},
applyBatchEdit: '应用更改',
overview: '概览',
policyInfo: '策略信息',
+20
View File
@@ -7,6 +7,7 @@ import type {
AdminFilePatchPayload,
AdminFileDetailResponse,
AdminFileListParams,
AdminFilePolicyActionRequest,
AdminFilePreviewResponse,
ApiResponse,
ChunkUploadCompleteRequest,
@@ -37,6 +38,11 @@ const toUrlEncodedForm = (data: Record<string, string | number>) => {
return form
}
const isMethodFallbackError = (error: unknown) => {
const status = (error as { response?: { status?: number } })?.response?.status
return status === 404 || status === 405
}
export class FileService {
static async uploadFile(
file: File,
@@ -139,6 +145,20 @@ export class FileService {
return api.patch('/admin/file/update', data)
}
static async applyAdminFilePolicyAction(
data: AdminFilePolicyActionRequest
): Promise<ApiResponse<AdminFileDetailResponse>> {
try {
return await api.patch('/admin/file/policy-action', data)
} catch (error: unknown) {
if (!isMethodFallbackError(error)) {
throw error
}
return api.post('/admin/file/policy-action', data)
}
}
static async updateAdminFiles(
data: AdminBatchUpdateFilesRequest
): Promise<ApiResponse<AdminBatchUpdateFilesResponse>> {
+13
View File
@@ -92,6 +92,19 @@ export interface AdminFilePatchPayload {
expired_count?: number | null
}
export type AdminFilePolicyAction =
| 'extend_24h'
| 'extend_7d'
| 'make_permanent'
| 'reset_download_limit'
export interface AdminFilePolicyActionRequest {
id: number
action: AdminFilePolicyAction
downloadLimit?: number
download_limit?: number
}
export interface FileListResponse {
data: FileListItem[]
total: number
+50
View File
@@ -620,6 +620,35 @@
{{ reason }}
</span>
</div>
<div class="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<button
v-for="action in detailPolicyActionOptions"
:key="action.action"
type="button"
class="flex min-h-16 items-start gap-2 rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="detailPolicyActionClass"
:disabled="isDetailPolicyActionRunning || isDetailLoading"
:title="action.description"
@click="applyDetailPolicyAction(action.action)"
>
<RefreshCwIcon
v-if="isDetailPolicyActionRunning"
class="mt-0.5 h-4 w-4 shrink-0 animate-spin"
/>
<component
:is="getDetailPolicyActionIcon(action.action)"
v-else
class="mt-0.5 h-4 w-4 shrink-0"
/>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ action.label }}</span>
<span class="mt-0.5 block text-xs leading-4" :class="[mutedTextClass]">
{{ action.description }}
</span>
</span>
</button>
</div>
</section>
<section class="space-y-3">
@@ -969,6 +998,7 @@ import { useI18n } from 'vue-i18n'
import type {
AdminBatchEditMode,
AdminFileInsightSeverity,
AdminFilePolicyAction,
AdminFileSortBy,
AdminFileSortOrder,
AdminFileStatusFilter,
@@ -987,8 +1017,10 @@ import {
FileTextIcon,
FilterIcon,
HardDriveIcon,
InfinityIcon,
PencilIcon,
RefreshCwIcon,
RotateCcwIcon,
SearchIcon,
TrashIcon,
XIcon
@@ -1026,9 +1058,11 @@ const {
isBatchUpdating,
isCurrentPagePartiallySelected,
isDetailLoading,
isDetailPolicyActionRunning,
isPreviewLoading,
isSaving,
batchEditForm,
detailPolicyActionOptions,
downloadingFileId,
hasSelectedFiles,
params,
@@ -1059,6 +1093,7 @@ const {
exportPreviewText,
handlePageChange,
handleSearch,
applyDetailPolicyAction,
handleBatchUpdate,
handleUpdate,
loadFiles,
@@ -1089,6 +1124,11 @@ const detailActionClass = computed(() =>
? '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'
)
const detailPolicyActionClass = computed(() =>
isDarkMode.value
? 'border-gray-700 bg-gray-800/70 text-gray-200 hover:border-blue-500/40 hover:bg-blue-500/10'
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-200 hover:bg-blue-50'
)
type DetailInfoItem = {
label: string
@@ -1291,6 +1331,16 @@ const batchEditModeOptions = computed<
}
])
const detailPolicyActionIconMap: Record<AdminFilePolicyAction, Component> = {
extend_24h: ClockIcon,
extend_7d: ClockIcon,
make_permanent: InfinityIcon,
reset_download_limit: RotateCcwIcon
}
const getDetailPolicyActionIcon = (action: AdminFilePolicyAction) =>
detailPolicyActionIconMap[action]
const getPillClass = (active: boolean) => {
if (active) return 'bg-indigo-600 text-white'
return isDarkMode.value