feat: add admin detail policy actions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '策略信息',
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user