refactor: streamline admin management UI

This commit is contained in:
Lan
2026-06-03 12:06:12 +08:00
parent c81c85a41a
commit 2428409953
11 changed files with 215 additions and 1210 deletions
+5 -563
View File
@@ -221,436 +221,7 @@
</div>
</section>
</div>
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-4">
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.fileTypeDistribution') }}
</h3>
<div class="mt-4 space-y-3">
<div
v-if="dashboardData.topSuffixes.length === 0"
class="text-sm"
:class="[mutedTextClass]"
>
{{ t('common.noData') }}
</div>
<div v-for="item in dashboardData.topSuffixes" :key="item.suffix" class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span :class="[primaryTextClass]">{{
item.suffix || t('admin.dashboard.textType')
}}</span>
<span :class="[mutedTextClass]">{{ item.count }}</span>
</div>
<div
class="h-2 overflow-hidden rounded-full"
:class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"
>
<div
class="h-full rounded-full bg-purple-500"
:style="{ width: `${getSuffixRatio(item.count)}%` }"
></div>
</div>
</div>
</div>
</section>
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
<div class="mb-4 flex items-start justify-between gap-3">
<div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.recentActivities') }}
</h3>
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.recentActivitiesDesc') }}
</p>
</div>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors"
:class="[
isDarkMode
? 'bg-gray-900 text-indigo-200 hover:bg-gray-700'
: 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100'
]"
@click="handleOpenActivityTimeline"
>
<HistoryIcon class="h-4 w-4" />
{{ t('admin.dashboard.viewAllActivities') }}
</button>
</div>
<div
v-if="dashboardData.recentActivities.length === 0"
class="rounded-lg border px-4 py-6 text-center text-sm"
:class="[subtlePanelClass, mutedTextClass]"
>
{{ t('admin.dashboard.noActivities') }}
</div>
<div v-else class="space-y-3">
<div
v-for="activity in dashboardData.recentActivities"
:key="activity.id"
class="rounded-lg border px-3 py-3"
:class="[subtlePanelClass]"
>
<div class="flex items-start gap-3">
<span
class="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
:class="getActivityIconClass(activity.action)"
>
<component :is="getActivityIcon(activity.action)" class="h-4 w-4" />
</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium" :class="[primaryTextClass]">
{{ getActivityTitle(activity.action) }}
</p>
<p class="mt-1 truncate text-xs" :class="[mutedTextClass]">
{{ getActivityDescription(activity) }}
</p>
<p class="mt-2 text-xs" :class="[mutedTextClass]">
{{ formatCreatedAt(activity.createdAtValue) }}
</p>
</div>
</div>
</div>
</div>
</section>
<section class="xl:col-span-2 rounded-lg p-5 shadow-sm" :class="[panelClass]">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.recentFiles') }}
</h3>
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.recentFilesDesc') }}
</p>
</div>
</div>
<div
class="overflow-hidden rounded-lg border"
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
>
<table
class="min-w-full divide-y"
:class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']"
>
<thead :class="[isDarkMode ? 'bg-gray-900/50' : 'bg-gray-50']">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
:class="[mutedTextClass]"
>
{{ t('admin.dashboard.table.file') }}
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
:class="[mutedTextClass]"
>
{{ t('admin.dashboard.table.size') }}
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
:class="[mutedTextClass]"
>
{{ t('admin.dashboard.table.usage') }}
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
:class="[mutedTextClass]"
>
{{ t('admin.dashboard.table.status') }}
</th>
</tr>
</thead>
<tbody class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-100']">
<tr v-if="dashboardData.recentFiles.length === 0">
<td colspan="4" class="px-4 py-6 text-center text-sm" :class="[mutedTextClass]">
{{ t('common.noData') }}
</td>
</tr>
<tr v-for="file in dashboardData.recentFiles" :key="file.id">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div
class="rounded-lg p-2"
:class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"
>
<FileTextIcon v-if="file.text" class="h-4 w-4" :class="[mutedTextClass]" />
<FileIcon v-else class="h-4 w-4" :class="[mutedTextClass]" />
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium" :class="[primaryTextClass]">
{{ file.name || file.code }}
</p>
<p class="text-xs" :class="[mutedTextClass]">
{{ file.code }} · {{ formatCreatedAt(file.createdAt) }}
</p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
{{ formatFileSize(file.size) }}
</td>
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
{{ file.usedCount }} {{ t('common.times') }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex rounded-full px-2.5 py-1 text-xs font-medium"
:class="[
file.isExpired
? isDarkMode
? 'bg-red-900/40 text-red-300'
: 'bg-red-100 text-red-700'
: isDarkMode
? 'bg-green-900/40 text-green-300'
: 'bg-green-100 text-green-700'
]"
>
{{ file.isExpired ? t('common.expiredFile') : t('admin.dashboard.available') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
<SideDrawer
:visible="isActivityTimelineOpen"
:title="t('admin.dashboard.activityTimelineTitle')"
@close="closeActivityTimeline"
>
<div class="flex min-h-0 flex-1 flex-col">
<div class="border-b p-5" :class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']">
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg border p-3" :class="[subtlePanelClass]">
<p class="text-xs" :class="[mutedTextClass]">
{{ t('admin.dashboard.activityTimelineFiltered') }}
</p>
<strong class="mt-1 block text-2xl" :class="[primaryTextClass]">
{{ activityTimelineTotal }}
</strong>
</div>
<div class="rounded-lg border p-3" :class="[subtlePanelClass]">
<p class="text-xs" :class="[mutedTextClass]">
{{ t('admin.dashboard.activityTimelineStoredTotal') }}
</p>
<strong class="mt-1 block text-2xl" :class="[primaryTextClass]">
{{ activityTimelineStoredTotal }}
</strong>
</div>
</div>
<form class="mt-4 space-y-3" @submit.prevent="handleApplyActivityFilters">
<label class="block">
<span class="mb-1 block text-xs font-medium" :class="[mutedTextClass]">
{{ t('admin.dashboard.activityKeyword') }}
</span>
<span class="relative block">
<SearchIcon
class="pointer-events-none absolute left-3 top-2.5 h-4 w-4"
:class="[mutedTextClass]"
/>
<input
v-model="activityFilters.keyword"
type="search"
class="w-full rounded-lg border py-2 pl-9 pr-3 text-sm outline-none transition-colors"
:class="[
isDarkMode
? 'border-gray-700 bg-gray-900 text-gray-100 placeholder:text-gray-500 focus:border-indigo-400'
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400 focus:border-indigo-400'
]"
:placeholder="t('admin.dashboard.activityKeywordPlaceholder')"
/>
</span>
</label>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label class="block">
<span class="mb-1 block text-xs font-medium" :class="[mutedTextClass]">
{{ t('admin.dashboard.activityActionFilter') }}
</span>
<select
v-model="activityFilters.action"
class="w-full rounded-lg border px-3 py-2 text-sm outline-none transition-colors"
:class="[
isDarkMode
? 'border-gray-700 bg-gray-900 text-gray-100 focus:border-indigo-400'
: 'border-gray-200 bg-white text-gray-900 focus:border-indigo-400'
]"
>
<option value="">{{ t('admin.dashboard.activityAllActions') }}</option>
<option
v-for="option in activityActionOptions"
:key="option.value"
:value="option.value"
>
{{ getActivityTitle(option.value) }} ({{ option.count }})
</option>
</select>
</label>
<label class="block">
<span class="mb-1 block text-xs font-medium" :class="[mutedTextClass]">
{{ t('admin.dashboard.activityTargetTypeFilter') }}
</span>
<select
v-model="activityFilters.targetType"
class="w-full rounded-lg border px-3 py-2 text-sm outline-none transition-colors"
:class="[
isDarkMode
? 'border-gray-700 bg-gray-900 text-gray-100 focus:border-indigo-400'
: 'border-gray-200 bg-white text-gray-900 focus:border-indigo-400'
]"
>
<option value="">{{ t('admin.dashboard.activityAllTargets') }}</option>
<option
v-for="option in activityTargetTypeOptions"
:key="option.value"
:value="option.value"
>
{{ getActivityTargetTypeTitle(option.value) }} ({{ option.count }})
</option>
</select>
</label>
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="[
isDarkMode
? 'bg-gray-800 text-gray-200 hover:bg-gray-700'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
:disabled="!hasActivityFilters || isActivityTimelineLoading"
@click="handleResetActivityFilters"
>
{{ t('admin.dashboard.activityResetFilters') }}
</button>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="[
isDarkMode
? 'bg-gray-800 text-gray-200 hover:bg-gray-700'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
:disabled="isActivityTimelineLoading"
@click="handleRefreshActivityTimeline"
>
<RefreshCwIcon
class="h-4 w-4"
:class="{ 'animate-spin': isActivityTimelineLoading }"
/>
{{ t('admin.dashboard.activityRefresh') }}
</button>
<button
type="submit"
class="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isActivityTimelineLoading"
>
<ListFilterIcon class="h-4 w-4" />
{{ t('admin.dashboard.activityApplyFilters') }}
</button>
</div>
</div>
</form>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-5 custom-scrollbar">
<div
v-if="activityTimelineError"
class="mb-4 rounded-lg border px-4 py-3 text-sm"
:class="[
isDarkMode
? 'border-red-900/50 bg-red-950/30 text-red-200'
: 'border-red-200 bg-red-50 text-red-700'
]"
>
{{ activityTimelineError }}
</div>
<div
v-if="isActivityTimelineLoading"
class="flex items-center justify-center gap-2 rounded-lg border px-4 py-8 text-sm"
:class="[subtlePanelClass, mutedTextClass]"
>
<RefreshCwIcon class="h-4 w-4 animate-spin" />
{{ t('admin.dashboard.activityLoading') }}
</div>
<div
v-else-if="activityTimeline.length === 0"
class="rounded-lg border px-4 py-8 text-center text-sm"
:class="[subtlePanelClass, mutedTextClass]"
>
{{
hasActivityFilters
? t('admin.dashboard.activityNoFiltered')
: t('admin.dashboard.noActivities')
}}
</div>
<div v-else class="space-y-4">
<div
v-for="activity in activityTimeline"
:key="activity.id"
class="rounded-lg border p-4"
:class="[subtlePanelClass]"
>
<div class="flex items-start gap-3">
<span
class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
:class="getActivityIconClass(activity.action)"
>
<component :is="getActivityIcon(activity.action)" class="h-4 w-4" />
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-sm font-semibold" :class="[primaryTextClass]">
{{ getActivityTitle(activity.action) }}
</p>
<p class="mt-1 text-sm" :class="[mutedTextClass]">
{{ getActivityDescription(activity) }}
</p>
</div>
<span
class="inline-flex shrink-0 rounded-full px-2.5 py-1 text-xs font-medium"
:class="[isDarkMode ? 'bg-gray-800 text-gray-300' : 'bg-white text-gray-600']"
>
{{ getActivityTargetTypeTitle(activity.targetTypeValue) }}
</span>
</div>
<div
class="mt-3 flex flex-wrap items-center gap-2 text-xs"
:class="[mutedTextClass]"
>
<span>{{ formatCreatedAt(activity.createdAtValue) }}</span>
<span v-if="activity.count > 1"
>· {{ activity.count }} {{ t('common.files') }}</span
>
<span v-if="activity.targetId ?? activity.target_id">
· ID {{ activity.targetId ?? activity.target_id }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</SideDrawer>
</template>
<script setup lang="ts">
@@ -662,74 +233,26 @@ import {
AlertTriangleIcon,
ArrowRightIcon,
CheckCircleIcon,
ClockIcon,
DownloadCloudIcon,
FileIcon,
FilesIcon,
FileTextIcon,
HardDriveIcon,
HistoryIcon,
ListFilterIcon,
PencilIcon,
RefreshCwIcon,
SearchIcon,
ShieldCheckIcon,
SlidersHorizontalIcon,
TrashIcon,
UploadCloudIcon
} from 'lucide-vue-next'
import StatCard from '@/components/common/StatCard.vue'
import SideDrawer from '@/components/common/SideDrawer.vue'
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
import { useI18n } from 'vue-i18n'
import { formatFileSize, formatTimestamp } from '@/utils/common'
import { ROUTES } from '@/constants'
import type { DashboardActivityViewItem, DashboardHealthAction } from '@/types'
import type { DashboardHealthAction } from '@/types'
const isDarkMode = useInjectedDarkMode()
const { t } = useI18n()
const router = useRouter()
const {
activityActionOptions,
activityFilters,
activityTargetTypeOptions,
activityTimeline,
activityTimelineError,
activityTimelineStoredTotal,
activityTimelineTotal,
applyActivityFilters,
closeActivityTimeline,
dashboardData,
errorMessage,
fetchActivityTimeline,
fetchDashboardData,
hasActivityFilters,
isActivityTimelineLoading,
isActivityTimelineOpen,
isLoading,
lastUpdatedText,
openActivityTimeline,
resetActivityFilters
} = useDashboardStats({
activityLoadFailedMessage: t('admin.dashboard.activityLoadFailed'),
loadFailedMessage: t('admin.dashboard.loadFailed')
})
const handleOpenActivityTimeline = () => {
void openActivityTimeline()
}
const handleApplyActivityFilters = () => {
void applyActivityFilters()
}
const handleResetActivityFilters = () => {
void resetActivityFilters()
}
const handleRefreshActivityTimeline = () => {
void fetchActivityTimeline()
}
const { dashboardData, errorMessage, fetchDashboardData, isLoading, lastUpdatedText } =
useDashboardStats({
loadFailedMessage: t('admin.dashboard.loadFailed')
})
const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
@@ -739,9 +262,6 @@ const panelClass = computed(() =>
const subtlePanelClass = computed(() =>
isDarkMode.value ? 'border-gray-700 bg-gray-900/30' : 'border-gray-100 bg-gray-50'
)
const maxSuffixCount = computed(() =>
Math.max(...dashboardData.topSuffixes.map((item) => item.count), 1)
)
const maxSaveTimeText = computed(() => {
if (!dashboardData.maxSaveSeconds) return t('admin.dashboard.noSaveLimit')
const days = Math.floor(dashboardData.maxSaveSeconds / 86400)
@@ -794,84 +314,6 @@ const healthActions = computed<DashboardHealthAction[]>(() => [
}
])
const getSuffixRatio = (count: number) => Math.round((count / maxSuffixCount.value) * 100)
const formatCreatedAt = (value: string | null) => {
if (!value) return '-'
return formatTimestamp(value, 'datetime')
}
type ActivityTone = 'danger' | 'warning' | 'success' | 'neutral'
const activityIconMap: Record<string, Component> = {
'file.delete': TrashIcon,
'files.batch_delete': TrashIcon,
'file.update': PencilIcon,
'files.batch_update': PencilIcon,
'file.policy_action': ClockIcon,
'files.batch_policy_action': ClockIcon,
'file.metadata_update': FileTextIcon,
'file.view_preset_create': SlidersHorizontalIcon,
'file.view_preset_update': SlidersHorizontalIcon,
'file.view_preset_delete': SlidersHorizontalIcon,
'config.update': SlidersHorizontalIcon,
'local_file.delete': TrashIcon,
'local_file.share': UploadCloudIcon
}
const getActivityIcon = (action: string) => activityIconMap[action] || HistoryIcon
const getActivityTone = (action: string): ActivityTone => {
if (action.includes('delete')) return 'danger'
if (action.includes('policy') || action.includes('update')) return 'warning'
if (action.includes('share') || action.includes('create')) return 'success'
return 'neutral'
}
const getActivityIconClass = (action: string) => {
const tone = getActivityTone(action)
const darkClasses: Record<ActivityTone, string> = {
danger: 'bg-red-500/10 text-red-300',
warning: 'bg-amber-500/10 text-amber-300',
success: 'bg-emerald-500/10 text-emerald-300',
neutral: 'bg-gray-700 text-gray-300'
}
const lightClasses: Record<ActivityTone, string> = {
danger: 'bg-red-50 text-red-700',
warning: 'bg-amber-50 text-amber-700',
success: 'bg-emerald-50 text-emerald-700',
neutral: 'bg-gray-100 text-gray-700'
}
return isDarkMode.value ? darkClasses[tone] : lightClasses[tone]
}
const getActivityTitle = (action: string) => {
const key = `admin.dashboard.activityActions.${action}`
const title = t(key)
return title === key ? action : title
}
const getActivityTargetTypeTitle = (targetType: string) => {
const key = `admin.dashboard.activityTargetTypes.${targetType}`
const title = t(key)
return title === key ? targetType : title
}
const getActivityDescription = (activity: DashboardActivityViewItem) => {
const targetName = activity.targetNameValue || t('admin.dashboard.activityUnknownTarget')
if (activity.count > 1) {
return t('admin.dashboard.activityBatchDescription', {
target: targetName,
count: activity.count
})
}
return t('admin.dashboard.activityDescription', {
target: targetName
})
}
const healthActionIconMap: Record<DashboardHealthAction['tone'], Component> = {
danger: AlertTriangleIcon,
warning: AlertTriangleIcon,
+179 -153
View File
@@ -52,26 +52,29 @@
</div>
<section class="mb-6 rounded-lg border p-4" :class="[panelClass]">
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div class="flex min-w-0 flex-1 flex-col gap-3 md:flex-row">
<div class="relative min-w-[220px] flex-1">
<input
v-model="params.keyword"
type="text"
:placeholder="t('fileManage.searchPlaceholder')"
class="w-full rounded-lg border py-2.5 pl-10 pr-4 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[
isDarkMode
? 'border-gray-600 bg-gray-700 text-white placeholder-gray-400'
: 'border-gray-300 bg-white text-gray-900 placeholder-gray-400'
]"
@keyup.enter="handleSearch"
/>
<SearchIcon
class="absolute left-3 top-3 h-4 w-4"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
/>
</div>
<div class="grid gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center">
<label class="min-w-0 flex-1">
<span class="sr-only">{{ t('fileManage.searchPlaceholder') }}</span>
<span class="relative block">
<SearchIcon
class="absolute left-3 top-3 h-4 w-4"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
/>
<input
v-model="params.keyword"
type="text"
:placeholder="t('fileManage.searchPlaceholder')"
class="w-full rounded-lg border py-2.5 pl-10 pr-4 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[
isDarkMode
? 'border-gray-600 bg-gray-700 text-white placeholder-gray-400'
: 'border-gray-300 bg-white text-gray-900 placeholder-gray-400'
]"
@keyup.enter="handleSearch"
/>
</span>
</label>
<BaseButton :loading="isLoading" @click="handleSearch">
<template #icon>
<SearchIcon class="mr-2 h-4 w-4" />
@@ -80,142 +83,159 @@
</BaseButton>
</div>
<div class="flex flex-col gap-3 xl:items-end">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center text-xs font-medium" :class="[mutedTextClass]">
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
<label class="space-y-1">
<span class="flex items-center text-xs font-medium" :class="[mutedTextClass]">
<FilterIcon class="mr-1 h-3.5 w-3.5" />
{{ t('fileManage.statusLabel') }}
</span>
<button
v-for="option in statusFilterOptions"
:key="option.value"
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
:class="getStatusFilterClass(option.value)"
@click="setStatusFilter(option.value)"
<select
:value="params.status"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleStatusFilterChange"
>
{{ option.label }}
</button>
</div>
<option
v-for="option in statusFilterOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center text-xs font-medium" :class="[mutedTextClass]">
<label class="space-y-1">
<span class="flex items-center text-xs font-medium" :class="[mutedTextClass]">
<ActivityIcon class="mr-1 h-3.5 w-3.5" />
{{ t('fileManage.healthLabel') }}
</span>
<button
v-for="option in healthFilterOptions"
:key="option.value"
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
:class="getHealthFilterClass(option.value)"
@click="handleHealthFilterChange(option.value)"
<select
:value="params.health"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleHealthFilterSelect"
>
<span>{{ option.label }}</span>
<span v-if="typeof option.count === 'number'">({{ option.count }})</span>
</button>
</div>
<option
v-for="option in healthFilterOptions"
:key="option.value"
:value="option.value"
>
{{ formatFilterOptionLabel(option) }}
</option>
</select>
</label>
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center text-xs font-medium" :class="[mutedTextClass]">
<label class="space-y-1">
<span class="flex items-center text-xs font-medium" :class="[mutedTextClass]">
<ArchiveIcon class="mr-1 h-3.5 w-3.5" />
{{ t('fileManage.typeLabel') }}
</span>
<button
v-for="option in typeFilterOptions"
:key="option.value"
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
:class="getTypeFilterClass(option.value)"
@click="setTypeFilter(option.value)"
<select
:value="params.type"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleTypeFilterChange"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<label class="text-sm" :class="[mutedTextClass]">
{{ t('fileManage.sortBy') }}
</label>
<select
v-model="params.sortBy"
class="rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleSearch"
>
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<select
v-model="params.sortOrder"
class="rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleSearch"
>
<option v-for="option in sortOrderOptions" :key="option.value" :value="option.value">
{{ 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') }}
<option v-for="option in typeFilterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</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"
<label class="space-y-1">
<span class="flex items-center text-xs font-medium" :class="[mutedTextClass]">
<ClockIcon class="mr-1 h-3.5 w-3.5" />
{{ t('fileManage.sortBy') }}
</span>
<select
v-model="params.sortBy"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleSearch"
>
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="space-y-1">
<span class="flex items-center text-xs font-medium" :class="[mutedTextClass]">
<ArrowDownUpIcon class="mr-1 h-3.5 w-3.5" />
{{ t('fileManage.sortOrder') }}
</span>
<select
v-model="params.sortOrder"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleSearch"
>
<option v-for="option in sortOrderOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="space-y-1">
<span class="flex items-center text-xs font-medium" :class="[mutedTextClass]">
<FilterIcon class="mr-1 h-3.5 w-3.5" />
{{ t('fileManage.viewPreset') }}
</span>
<select
:value="selectedViewPresetId"
class="w-full 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>
</label>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<BaseButton
size="sm"
variant="secondary"
:title="t('fileManage.saveViewPreset')"
:loading="isViewPresetSaving"
@click="handleSaveViewPreset"
>
<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>
<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>
</section>
@@ -1178,6 +1198,7 @@ import type {
import {
ActivityIcon,
ArchiveIcon,
ArrowDownUpIcon,
CheckIcon,
ClockIcon,
CopyIcon,
@@ -1547,6 +1568,21 @@ const handleHealthFilterChange = async (health: AdminFileHealthFilter) => {
await syncHealthFilterQuery(health)
}
const handleStatusFilterChange = async (event: Event) => {
const target = event.target as HTMLSelectElement
await setStatusFilter(target.value as AdminFileStatusFilter)
}
const handleTypeFilterChange = async (event: Event) => {
const target = event.target as HTMLSelectElement
await setTypeFilter(target.value as AdminFileTypeFilter)
}
const handleHealthFilterSelect = async (event: Event) => {
const target = event.target as HTMLSelectElement
await handleHealthFilterChange(target.value as AdminFileHealthFilter)
}
const handleResetFilters = async () => {
await resetFilters()
await syncHealthFilterQuery('all')
@@ -1600,21 +1636,11 @@ const detailPolicyActionIconMap: Record<AdminFilePolicyAction, Component> = {
const getDetailPolicyActionIcon = (action: AdminFilePolicyAction) =>
detailPolicyActionIconMap[action]
const getPillClass = (active: boolean) => {
if (active) return 'bg-indigo-600 text-white'
return isDarkMode.value
? 'bg-gray-700 text-gray-300 hover:bg-gray-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
const formatFilterOptionLabel = (option: { label: string; count?: number }) => {
if (typeof option.count !== 'number') return option.label
return `${option.label} (${option.count})`
}
const getStatusFilterClass = (value: AdminFileStatusFilter) =>
getPillClass(params.value.status === value)
const getTypeFilterClass = (value: AdminFileTypeFilter) => getPillClass(params.value.type === value)
const getHealthFilterClass = (value: AdminFileHealthFilter) =>
getPillClass(params.value.health === value)
const getBatchEditModeClass = (value: AdminBatchEditMode) => {
if (batchEditForm.value.mode === value) {
return isDarkMode.value