feat: add admin file view presets

This commit is contained in:
Lan
2026-06-03 07:01:38 +08:00
parent d211be3b20
commit e33a5deaf2
6 changed files with 516 additions and 0 deletions
+316
View File
@@ -22,6 +22,9 @@ import type {
AdminFileStatusFilter,
AdminFileSummary,
AdminFileTypeFilter,
AdminFileViewPreset,
AdminFileViewPresetParams,
AdminFileViewPresetRequest,
AdminFileViewItem,
ApiResponse,
FileEditForm,
@@ -117,6 +120,89 @@ const formatLocalDateTime = (date: Date) => {
return `${year}-${month}-${day}T${hours}:${minutes}`
}
const builtInAllViewPresetId = 'built_in_all'
const adminFileStatusFilters: AdminFileStatusFilter[] = ['all', 'active', 'expired']
const adminFileTypeFilters: AdminFileTypeFilter[] = ['all', 'file', 'text', 'chunked']
const adminFileHealthFilters: AdminFileHealthFilter[] = [
'all',
'attention',
'danger',
'warning',
'healthy',
'expired',
'expiring_soon',
'storage_issue',
'never_retrieved',
'permanent'
]
const adminFileSortFields = [
'created_at',
'expired_at',
'name',
'size',
'used_count',
'code'
] as const
const adminFileSortOrders = ['asc', 'desc'] as const
type RawViewPresetParams = Partial<
Record<keyof AdminFileViewPresetParams | 'sort_by' | 'sort_order', unknown>
>
const normalizePresetChoice = <T extends string>(
value: unknown,
allowedValues: readonly T[],
fallback: T
): T => {
const normalizedValue = String(value ?? fallback)
.replace('-', '_')
.trim()
.toLowerCase()
return allowedValues.includes(normalizedValue as T) ? (normalizedValue as T) : fallback
}
const normalizePresetSize = (value: unknown) => {
const normalizedSize = Number(value)
if (!Number.isFinite(normalizedSize)) return 10
return Math.min(Math.max(Math.trunc(normalizedSize), 1), 100)
}
const buildDefaultViewPresetParams = (): AdminFileViewPresetParams => ({
keyword: '',
status: 'all',
type: 'all',
health: 'all',
sortBy: 'created_at',
sortOrder: 'desc',
size: 10
})
const normalizeViewPresetParams = (params: unknown): AdminFileViewPresetParams => {
const defaults = buildDefaultViewPresetParams()
const rawParams =
params && typeof params === 'object'
? (params as RawViewPresetParams)
: ({} as RawViewPresetParams)
return {
keyword: String(rawParams.keyword ?? defaults.keyword).trim(),
status: normalizePresetChoice(rawParams.status, adminFileStatusFilters, defaults.status),
type: normalizePresetChoice(rawParams.type, adminFileTypeFilters, defaults.type),
health: normalizePresetChoice(rawParams.health, adminFileHealthFilters, defaults.health),
sortBy: normalizePresetChoice(
rawParams.sortBy ?? rawParams.sort_by,
adminFileSortFields,
defaults.sortBy
),
sortOrder: normalizePresetChoice(
rawParams.sortOrder ?? rawParams.sort_order,
adminFileSortOrders,
defaults.sortOrder
),
size: normalizePresetSize(rawParams.size ?? defaults.size)
}
}
export function useAdminFiles() {
const { t } = useI18n()
const alertStore = useAlertStore()
@@ -166,6 +252,11 @@ export function useAdminFiles() {
const isBatchDeleting = ref(false)
const isBatchUpdating = ref(false)
const isBatchPolicyActionRunning = ref(false)
const savedViewPresets = ref<AdminFileViewPreset[]>([])
const selectedViewPresetId = ref(builtInAllViewPresetId)
const isViewPresetLoading = ref(false)
const isViewPresetSaving = ref(false)
const isViewPresetDeleting = ref(false)
let detailRequestSerial = 0
const isBatchActionRunning = computed(
() => isBatchDeleting.value || isBatchUpdating.value || isBatchPolicyActionRunning.value
@@ -203,6 +294,61 @@ export function useAdminFiles() {
params.value.health !== 'all'
)
const buildCurrentViewPresetParams = (): AdminFileViewPresetParams => ({
keyword: params.value.keyword || '',
status: params.value.status || 'all',
type: params.value.type || 'all',
health: params.value.health || 'all',
sortBy: params.value.sortBy || 'created_at',
sortOrder: params.value.sortOrder || 'desc',
size: params.value.size || 10
})
const buildBuiltInViewPreset = (
id: string,
nameKey: string,
filters: Partial<AdminFileViewPresetParams> = {}
): AdminFileViewPreset => {
const presetFilters = normalizeViewPresetParams({
...buildDefaultViewPresetParams(),
...filters
})
return {
id,
name: t(nameKey),
filters: presetFilters,
params: presetFilters,
isBuiltIn: true,
isDefault: id === builtInAllViewPresetId,
is_default: id === builtInAllViewPresetId
}
}
const builtInViewPresets = computed<AdminFileViewPreset[]>(() => [
buildBuiltInViewPreset(builtInAllViewPresetId, 'fileManage.viewPresetAll'),
buildBuiltInViewPreset('built_in_attention', 'fileManage.viewPresetAttention', {
health: 'attention'
}),
buildBuiltInViewPreset('built_in_expiring_soon', 'fileManage.viewPresetExpiringSoon', {
health: 'expiring_soon'
}),
buildBuiltInViewPreset('built_in_storage_issue', 'fileManage.viewPresetStorageIssue', {
health: 'storage_issue'
}),
buildBuiltInViewPreset('built_in_never_retrieved', 'fileManage.viewPresetNeverRetrieved', {
health: 'never_retrieved'
}),
buildBuiltInViewPreset('built_in_permanent', 'fileManage.viewPresetPermanent', {
health: 'permanent'
})
])
const viewPresets = computed<AdminFileViewPreset[]>(() => [
...builtInViewPresets.value,
...savedViewPresets.value
])
const healthFilterOptions = computed<HealthFilterOption[]>(() => [
{ value: 'all', label: t('fileManage.healthFilters.all'), count: summary.value.totalFiles },
{
@@ -766,6 +912,161 @@ export function useAdminFiles() {
}
}
const getPresetParams = (preset: AdminFileViewPreset) =>
normalizeViewPresetParams(preset.filters ?? preset.params)
const normalizeSavedViewPreset = (preset: AdminFileViewPreset): AdminFileViewPreset | null => {
const id = String(preset.id || '').trim()
const name = String(preset.name || '').trim()
if (!id || !name) return null
const presetParams = getPresetParams(preset)
return {
...preset,
id,
name,
filters: presetParams,
params: presetParams,
isBuiltIn: false
}
}
const upsertSavedViewPreset = (preset: AdminFileViewPreset) => {
const normalizedPreset = normalizeSavedViewPreset(preset)
if (!normalizedPreset) return null
savedViewPresets.value = [
...savedViewPresets.value.filter((item) => item.id !== normalizedPreset.id),
normalizedPreset
]
return normalizedPreset
}
const buildViewPresetRequest = (name: string, id?: string): AdminFileViewPresetRequest | null => {
const normalizedName = name.trim()
if (!normalizedName) return null
const filters = normalizeViewPresetParams(buildCurrentViewPresetParams())
return {
id,
name: normalizedName,
filters,
params: filters
}
}
const markViewPresetDirty = () => {
selectedViewPresetId.value = ''
}
const loadViewPresets = async () => {
isViewPresetLoading.value = true
try {
const response = await FileService.getAdminFileViewPresets()
const rawPresets = response.detail?.presets ?? response.detail?.items ?? []
savedViewPresets.value = rawPresets
.map((preset) => normalizeSavedViewPreset(preset))
.filter((preset): preset is AdminFileViewPreset => Boolean(preset))
if (
selectedViewPresetId.value &&
!viewPresets.value.some((preset) => preset.id === selectedViewPresetId.value)
) {
selectedViewPresetId.value = builtInAllViewPresetId
}
} catch (error: unknown) {
savedViewPresets.value = []
alertStore.showAlert(
isLegacyEndpointUnavailable(error)
? t('fileManage.viewPresetLoadFailed')
: getErrorMessage(error, t('fileManage.viewPresetLoadFailed')),
'warning'
)
} finally {
isViewPresetLoading.value = false
}
}
const applyViewPreset = async (idOrPreset: string | AdminFileViewPreset) => {
const preset =
typeof idOrPreset === 'string'
? viewPresets.value.find((item) => item.id === idOrPreset)
: idOrPreset
if (!preset) return
const presetParams = getPresetParams(preset)
params.value.keyword = presetParams.keyword
params.value.status = presetParams.status
params.value.type = presetParams.type
params.value.health = presetParams.health
params.value.sortBy = presetParams.sortBy
params.value.sortOrder = presetParams.sortOrder
params.value.size = presetParams.size
params.value.page = 1
selectedViewPresetId.value = preset.id
clearSelection()
await loadFiles()
}
const saveCurrentViewPreset = async (name: string) => {
const request = buildViewPresetRequest(name)
if (!request || isViewPresetSaving.value) return null
isViewPresetSaving.value = true
try {
const response = await FileService.saveAdminFileViewPreset(request)
const savedPreset = response.detail ? upsertSavedViewPreset(response.detail) : null
selectedViewPresetId.value = savedPreset?.id || selectedViewPresetId.value
alertStore.showAlert(t('fileManage.viewPresetSaveSuccess'), 'success')
return savedPreset
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, t('fileManage.viewPresetSaveFailed')), 'error')
return null
} finally {
isViewPresetSaving.value = false
}
}
const updateSelectedViewPreset = async () => {
const preset = savedViewPresets.value.find((item) => item.id === selectedViewPresetId.value)
if (!preset || isViewPresetSaving.value) return null
const request = buildViewPresetRequest(preset.name, preset.id)
if (!request) return null
isViewPresetSaving.value = true
try {
const response = await FileService.saveAdminFileViewPreset(request)
const savedPreset = response.detail ? upsertSavedViewPreset(response.detail) : null
selectedViewPresetId.value = savedPreset?.id || preset.id
alertStore.showAlert(t('fileManage.viewPresetSaveSuccess'), 'success')
return savedPreset
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, t('fileManage.viewPresetSaveFailed')), 'error')
return null
} finally {
isViewPresetSaving.value = false
}
}
const deleteSelectedViewPreset = async () => {
const preset = savedViewPresets.value.find((item) => item.id === selectedViewPresetId.value)
if (!preset || isViewPresetDeleting.value) return
if (!window.confirm(t('fileManage.viewPresetDeleteConfirm', { name: preset.name }))) return
isViewPresetDeleting.value = true
try {
await FileService.deleteAdminFileViewPreset(preset.id)
savedViewPresets.value = savedViewPresets.value.filter((item) => item.id !== preset.id)
markViewPresetDirty()
alertStore.showAlert(t('fileManage.viewPresetDeleteSuccess'), 'success')
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, t('fileManage.viewPresetDeleteFailed')), 'error')
} finally {
isViewPresetDeleting.value = false
}
}
const refreshSelectedFileDetail = async (fileId: number, detail?: AdminFileDetailResponse) => {
if (detail) {
setSelectedFileDetail(detail)
@@ -812,6 +1113,7 @@ export function useAdminFiles() {
}
const handleSearch = async () => {
markViewPresetDirty()
params.value.page = 1
await loadFiles()
}
@@ -828,23 +1130,27 @@ export function useAdminFiles() {
params.value.health = 'all'
params.value.sortBy = 'created_at'
params.value.sortOrder = 'desc'
selectedViewPresetId.value = builtInAllViewPresetId
clearSelection()
await loadFiles()
}
const setStatusFilter = async (status: AdminFileStatusFilter) => {
markViewPresetDirty()
params.value.status = status
params.value.page = 1
await loadFiles()
}
const setTypeFilter = async (type: AdminFileTypeFilter) => {
markViewPresetDirty()
params.value.type = type
params.value.page = 1
await loadFiles()
}
const setHealthFilter = async (health: AdminFileHealthFilter) => {
markViewPresetDirty()
params.value.health = health
params.value.page = 1
await loadFiles()
@@ -1342,6 +1648,9 @@ export function useAdminFiles() {
isDetailPolicyActionRunning,
isPreviewLoading,
isSaving,
isViewPresetDeleting,
isViewPresetLoading,
isViewPresetSaving,
batchEditForm,
batchPolicyActionOptions,
detailPolicyActionOptions,
@@ -1355,6 +1664,7 @@ export function useAdminFiles() {
selectedFileDetail,
selectedCount,
selectedFileIds,
selectedViewPresetId,
storageUsedText,
summary,
showBatchEditModal,
@@ -1364,6 +1674,8 @@ export function useAdminFiles() {
showTextPreview,
previewText,
totalPages,
viewPresets,
applyViewPreset,
closeEditModal,
closeBatchEditModal,
closeFileDetail,
@@ -1374,6 +1686,7 @@ export function useAdminFiles() {
clearSelection,
deleteFile,
deleteSelectedFiles,
deleteSelectedViewPreset,
downloadFile,
exportPreviewText,
handlePageChange,
@@ -1382,6 +1695,7 @@ export function useAdminFiles() {
applySelectedPolicyAction,
handleBatchUpdate,
handleUpdate,
loadViewPresets,
loadFiles,
openBatchEditModal,
openEditModal,
@@ -1389,9 +1703,11 @@ export function useAdminFiles() {
openTextPreview,
refreshFiles,
resetFilters,
saveCurrentViewPreset,
setHealthFilter,
setStatusFilter,
setTypeFilter,
updateSelectedViewPreset,
updateDetailMetadata,
toggleCurrentPageSelection,
toggleFileSelection
+18
View File
@@ -418,6 +418,24 @@ export default {
statusLabel: 'Status',
typeLabel: 'Type',
healthLabel: 'Health',
viewPreset: 'View',
viewPresetCustom: 'Custom Filters',
viewPresetAll: 'All Files',
viewPresetAttention: 'Needs Attention',
viewPresetExpiringSoon: 'Expiring Soon',
viewPresetStorageIssue: 'Storage Issues',
viewPresetNeverRetrieved: 'Never Retrieved',
viewPresetPermanent: 'Permanent',
saveViewPreset: 'Save View',
updateViewPreset: 'Update View',
deleteViewPreset: 'Delete View',
viewPresetNamePrompt: 'Enter a view name',
viewPresetSaveSuccess: 'View saved',
viewPresetSaveFailed: 'Failed to save view',
viewPresetDeleteConfirm: 'Delete view "{name}"?',
viewPresetDeleteSuccess: 'View deleted',
viewPresetDeleteFailed: 'Failed to delete view',
viewPresetLoadFailed: 'Failed to load saved views; using built-in views',
all: 'All',
active: 'Available',
expired: 'Expired',
+18
View File
@@ -416,6 +416,24 @@ export default {
statusLabel: '状态',
typeLabel: '类型',
healthLabel: '健康',
viewPreset: '视图',
viewPresetCustom: '自定义筛选',
viewPresetAll: '全部文件',
viewPresetAttention: '需关注',
viewPresetExpiringSoon: '即将过期',
viewPresetStorageIssue: '存储异常',
viewPresetNeverRetrieved: '未取件',
viewPresetPermanent: '永久有效',
saveViewPreset: '保存视图',
updateViewPreset: '覆盖视图',
deleteViewPreset: '删除视图',
viewPresetNamePrompt: '输入视图名称',
viewPresetSaveSuccess: '视图已保存',
viewPresetSaveFailed: '视图保存失败',
viewPresetDeleteConfirm: '确认删除视图“{name}”?',
viewPresetDeleteSuccess: '视图已删除',
viewPresetDeleteFailed: '视图删除失败',
viewPresetLoadFailed: '视图预设加载失败,已使用内置视图',
all: '全部',
active: '可取件',
expired: '已过期',
+35
View File
@@ -12,6 +12,9 @@ import type {
AdminFileMetadataRequest,
AdminFilePolicyActionRequest,
AdminFilePreviewResponse,
AdminFileViewPreset,
AdminFileViewPresetRequest,
AdminFileViewPresetsResponse,
ApiResponse,
ChunkUploadCompleteRequest,
ChunkUploadInitRequest,
@@ -190,6 +193,24 @@ export class FileService {
}
}
static async getAdminFileViewPresets(): Promise<ApiResponse<AdminFileViewPresetsResponse>> {
return api.get('/admin/file/view-presets')
}
static async saveAdminFileViewPreset(
data: AdminFileViewPresetRequest
): Promise<ApiResponse<AdminFileViewPreset>> {
try {
return await api.patch('/admin/file/view-presets', data)
} catch (error: unknown) {
if (!isMethodFallbackError(error)) {
throw error
}
return api.post('/admin/file/view-presets', data)
}
}
static async updateAdminFiles(
data: AdminBatchUpdateFilesRequest
): Promise<ApiResponse<AdminBatchUpdateFilesResponse>> {
@@ -210,6 +231,20 @@ export class FileService {
})
}
static async deleteAdminFileViewPreset(id: string): Promise<ApiResponse> {
try {
return await api.delete('/admin/file/view-presets', {
data: { id }
})
} catch (error: unknown) {
if (!isMethodFallbackError(error)) {
throw error
}
return api.post('/admin/file/view-presets/delete', { id })
}
}
static async downloadAdminFile(
id: number
): Promise<{ data: Blob; headers: Record<string, string> }> {
+37
View File
@@ -102,6 +102,43 @@ export interface AdminFileListParams {
sortOrder?: AdminFileSortOrder
}
export interface AdminFileViewPresetParams {
keyword: string
status: AdminFileStatusFilter
type: AdminFileTypeFilter
health: AdminFileHealthFilter
sortBy: AdminFileSortBy
sortOrder: AdminFileSortOrder
size: number
}
export interface AdminFileViewPreset {
id: string
name: string
filters?: AdminFileViewPresetParams
params?: AdminFileViewPresetParams
isBuiltIn?: boolean
isDefault?: boolean
is_default?: boolean
createdAt?: string | null
created_at?: string | null
updatedAt?: string | null
updated_at?: string | null
}
export interface AdminFileViewPresetRequest {
id?: string
name: string
filters: AdminFileViewPresetParams
params?: AdminFileViewPresetParams
}
export interface AdminFileViewPresetsResponse {
presets?: AdminFileViewPreset[]
items?: AdminFileViewPreset[]
total?: number
}
export interface FileEditForm {
id: number | null
code: string
+92
View File
@@ -159,6 +159,64 @@
{{ option.label }}
</option>
</select>
<div class="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:items-center">
<label class="inline-flex items-center text-sm" :class="[mutedTextClass]">
<FilterIcon class="mr-1 h-3.5 w-3.5" />
{{ t('fileManage.viewPreset') }}
</label>
<select
:value="selectedViewPresetId"
class="min-w-[160px] rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
:disabled="isViewPresetLoading"
@change="handleViewPresetChange"
>
<option value="">{{ t('fileManage.viewPresetCustom') }}</option>
<option v-for="preset in viewPresets" :key="preset.id" :value="preset.id">
{{ preset.name }}
</option>
</select>
<div class="flex flex-wrap items-center gap-2">
<BaseButton
size="sm"
variant="secondary"
:title="t('fileManage.saveViewPreset')"
:loading="isViewPresetSaving"
@click="handleSaveViewPreset"
>
<template #icon>
<CheckIcon class="mr-2 h-4 w-4" />
</template>
{{ t('fileManage.saveViewPreset') }}
</BaseButton>
<BaseButton
size="sm"
variant="outline"
:title="t('fileManage.updateViewPreset')"
:disabled="!canModifySelectedViewPreset"
:loading="isViewPresetSaving"
@click="updateSelectedViewPreset"
>
<template #icon>
<PencilIcon class="mr-2 h-4 w-4" />
</template>
{{ t('fileManage.updateViewPreset') }}
</BaseButton>
<BaseButton
size="sm"
variant="danger"
:title="t('fileManage.deleteViewPreset')"
:disabled="!canModifySelectedViewPreset"
:loading="isViewPresetDeleting"
@click="deleteSelectedViewPreset"
>
<template #icon>
<TrashIcon class="mr-2 h-4 w-4" />
</template>
{{ t('fileManage.deleteViewPreset') }}
</BaseButton>
</div>
</div>
</div>
</section>
@@ -1178,6 +1236,9 @@ const {
isDetailPolicyActionRunning,
isPreviewLoading,
isSaving,
isViewPresetDeleting,
isViewPresetLoading,
isViewPresetSaving,
batchEditForm,
batchPolicyActionOptions,
detailPolicyActionOptions,
@@ -1191,6 +1252,7 @@ const {
selectedFileDetail,
selectedCount,
selectedFileIds,
selectedViewPresetId,
storageUsedText,
summary,
showBatchEditModal,
@@ -1199,6 +1261,8 @@ const {
editForm,
showTextPreview,
previewText,
viewPresets,
applyViewPreset,
closeBatchEditModal,
closeEditModal,
closeFileDetail,
@@ -1209,6 +1273,7 @@ const {
clearSelection,
deleteFile,
deleteSelectedFiles,
deleteSelectedViewPreset,
downloadFile,
exportPreviewText,
handlePageChange,
@@ -1217,6 +1282,7 @@ const {
applySelectedPolicyAction,
handleBatchUpdate,
handleUpdate,
loadViewPresets,
loadFiles,
openBatchEditModal,
openEditModal,
@@ -1224,9 +1290,11 @@ const {
openTextPreview,
refreshFiles,
resetFilters,
saveCurrentViewPreset,
setHealthFilter,
setStatusFilter,
setTypeFilter,
updateSelectedViewPreset,
updateDetailMetadata,
toggleCurrentPageSelection,
toggleFileSelection
@@ -1364,6 +1432,11 @@ const detailInsightReasonLabels = computed(() => {
return file.statusInsightReasons.map((reason) => t(`fileManage.insightReasons.${reason}`))
})
const canModifySelectedViewPreset = computed(() => {
const preset = viewPresets.value.find((item) => item.id === selectedViewPresetId.value)
return Boolean(preset && !preset.isBuiltIn)
})
const openDetailEditModal = () => {
if (!selectedFileDetail.value) return
@@ -1479,6 +1552,24 @@ const handleResetFilters = async () => {
await syncHealthFilterQuery('all')
}
const handleViewPresetChange = async (event: Event) => {
const target = event.target as HTMLSelectElement
if (!target.value) {
selectedViewPresetId.value = ''
return
}
await applyViewPreset(target.value)
await syncHealthFilterQuery(params.value.health || 'all')
}
const handleSaveViewPreset = async () => {
const name = window.prompt(t('fileManage.viewPresetNamePrompt'))
if (!name) return
await saveCurrentViewPreset(name)
}
const batchEditModeOptions = computed<
{ value: AdminBatchEditMode; label: string; icon: Component }[]
>(() => [
@@ -1626,6 +1717,7 @@ watch(
onMounted(() => {
params.value.health = getRouteHealthFilter()
void loadViewPresets()
void loadFiles()
})
</script>