refactor: streamline admin management UI
This commit is contained in:
+2
-2
@@ -5,7 +5,7 @@ import LanguageSwitcher from './components/common/LanguageSwitcher.vue'
|
||||
import AlertComponent from '@/components/common/AlertComponent.vue'
|
||||
import { useAppShell } from '@/composables'
|
||||
|
||||
const { isDarkMode, isLoading, route, showGlobalControls } = useAppShell()
|
||||
const { isDarkMode, isLoading, routeViewKey, showGlobalControls } = useAppShell()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +19,7 @@ const { isDarkMode, isLoading, route, showGlobalControls } = useAppShell()
|
||||
</div>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
<component :is="Component" :key="routeViewKey" />
|
||||
</transition>
|
||||
</RouterView>
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ export function useAppShell() {
|
||||
const { isLoading, setupRouteLoading } = useRouteLoading(router)
|
||||
const { syncPublicConfig } = usePublicConfigBootstrap()
|
||||
const showGlobalControls = computed(() => route.meta.showGlobalControls !== false)
|
||||
const routeViewKey = computed(() =>
|
||||
route.path === ROUTES.ADMIN || route.path.startsWith(`${ROUTES.ADMIN}/`)
|
||||
? ROUTES.ADMIN
|
||||
: route.fullPath
|
||||
)
|
||||
|
||||
let cleanupThemeListener: (() => void) | null = null
|
||||
|
||||
@@ -47,6 +52,7 @@ export function useAppShell() {
|
||||
isDarkMode,
|
||||
isLoading,
|
||||
route,
|
||||
routeViewKey,
|
||||
showGlobalControls
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { computed, ref, reactive } from 'vue'
|
||||
import { StatsService } from '@/services'
|
||||
import type {
|
||||
DashboardActivitiesParams,
|
||||
DashboardActivityOption,
|
||||
DashboardActivity,
|
||||
DashboardActivityViewItem,
|
||||
DashboardData,
|
||||
DashboardHealthSummary,
|
||||
DashboardViewData
|
||||
} from '@/types'
|
||||
import type { DashboardData, DashboardHealthSummary, DashboardViewData } from '@/types'
|
||||
import { formatFileSize, getErrorMessage } from '@/utils/common'
|
||||
|
||||
type UseDashboardStatsOptions = {
|
||||
loadFailedMessage?: string
|
||||
activityLoadFailedMessage?: string
|
||||
}
|
||||
|
||||
const activityTimelineLimit = 40
|
||||
|
||||
const emptyDashboardData = (): DashboardViewData => ({
|
||||
hasExtendedStats: false,
|
||||
totalFiles: 0,
|
||||
@@ -46,9 +35,6 @@ const emptyDashboardData = (): DashboardViewData => ({
|
||||
neverRetrievedCount: 0,
|
||||
healthyCount: 0,
|
||||
permanentCount: 0,
|
||||
topSuffixes: [],
|
||||
recentFiles: [],
|
||||
recentActivities: [],
|
||||
storageUsedText: '0 Bytes',
|
||||
yesterdaySizeText: '0 Bytes',
|
||||
todaySizeText: '0 Bytes',
|
||||
@@ -76,39 +62,6 @@ const formatDuration = (startTimestamp: number | null) => {
|
||||
return `${days}天${hours}小时`
|
||||
}
|
||||
|
||||
const normalizeRecentFiles = (recentFiles: DashboardViewData['recentFiles']) =>
|
||||
recentFiles.map((file) => ({
|
||||
...file,
|
||||
size: toNumber(file.size),
|
||||
expiredCount: toNumber(file.expiredCount),
|
||||
usedCount: toNumber(file.usedCount)
|
||||
}))
|
||||
|
||||
const normalizeRecentActivities = (
|
||||
activities: DashboardActivity[] = []
|
||||
): DashboardActivityViewItem[] =>
|
||||
activities
|
||||
.filter((activity) => activity && activity.id && activity.action)
|
||||
.map((activity) => ({
|
||||
...activity,
|
||||
count: toNumber(activity.count) || 1,
|
||||
targetTypeValue: activity.targetType ?? activity.target_type ?? 'system',
|
||||
targetNameValue: activity.targetName ?? activity.target_name ?? '',
|
||||
createdAtValue: activity.createdAt ?? activity.created_at ?? null,
|
||||
meta: activity.meta && typeof activity.meta === 'object' ? activity.meta : {}
|
||||
}))
|
||||
|
||||
const normalizeActivityOptions = (
|
||||
options: DashboardActivityOption[] = []
|
||||
): DashboardActivityOption[] =>
|
||||
options
|
||||
.filter((option) => option && option.value)
|
||||
.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label || option.value,
|
||||
count: toNumber(option.count)
|
||||
}))
|
||||
|
||||
const healthSummaryKeys: (keyof DashboardHealthSummary)[] = [
|
||||
'healthAttentionCount',
|
||||
'healthDangerCount',
|
||||
@@ -142,32 +95,9 @@ export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const lastUpdatedAt = ref<Date | null>(null)
|
||||
const isActivityTimelineOpen = ref(false)
|
||||
const isActivityTimelineLoading = ref(false)
|
||||
const activityTimelineError = ref('')
|
||||
const activityTimeline = ref<DashboardActivityViewItem[]>([])
|
||||
const activityTimelineTotal = ref(0)
|
||||
const activityTimelineStoredTotal = ref(0)
|
||||
const activityActionOptions = ref<DashboardActivityOption[]>([])
|
||||
const activityTargetTypeOptions = ref<DashboardActivityOption[]>([])
|
||||
const activityFilters = reactive({
|
||||
action: '',
|
||||
targetType: '',
|
||||
keyword: ''
|
||||
})
|
||||
const lastUpdatedText = computed(() =>
|
||||
lastUpdatedAt.value ? lastUpdatedAt.value.toLocaleString() : '-'
|
||||
)
|
||||
const hasActivityFilters = computed(() =>
|
||||
Boolean(activityFilters.action || activityFilters.targetType || activityFilters.keyword)
|
||||
)
|
||||
|
||||
const buildActivityRequestParams = (): DashboardActivitiesParams => ({
|
||||
limit: activityTimelineLimit,
|
||||
action: activityFilters.action || undefined,
|
||||
targetType: activityFilters.targetType || undefined,
|
||||
keyword: activityFilters.keyword.trim() || undefined
|
||||
})
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
isLoading.value = true
|
||||
@@ -205,11 +135,6 @@ export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
|
||||
healthSummaryKeys.forEach((key) => {
|
||||
dashboardData[key] = healthSummary[key]
|
||||
})
|
||||
dashboardData.topSuffixes = detail.topSuffixes || []
|
||||
dashboardData.recentFiles = normalizeRecentFiles(detail.recentFiles || [])
|
||||
dashboardData.recentActivities = normalizeRecentActivities(
|
||||
detail.recentActivities ?? detail.recent_activities ?? []
|
||||
)
|
||||
|
||||
dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed)
|
||||
dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize)
|
||||
@@ -245,93 +170,11 @@ export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchActivityTimeline = async () => {
|
||||
isActivityTimelineLoading.value = true
|
||||
activityTimelineError.value = ''
|
||||
|
||||
try {
|
||||
const response = await StatsService.getActivities(buildActivityRequestParams())
|
||||
if (!response.detail) {
|
||||
throw new Error('No activity data')
|
||||
}
|
||||
|
||||
const detail = response.detail
|
||||
activityTimeline.value = normalizeRecentActivities(detail.activities ?? detail.items ?? [])
|
||||
activityTimelineTotal.value = toNumber(detail.total)
|
||||
activityTimelineStoredTotal.value = toNumber(detail.storedTotal ?? detail.stored_total)
|
||||
activityActionOptions.value = normalizeActivityOptions(
|
||||
detail.actionOptions ?? detail.action_options ?? []
|
||||
)
|
||||
activityTargetTypeOptions.value = normalizeActivityOptions(
|
||||
detail.targetTypeOptions ?? detail.target_type_options ?? []
|
||||
)
|
||||
} catch (error) {
|
||||
activityTimelineError.value = getErrorMessage(
|
||||
error,
|
||||
options.activityLoadFailedMessage || 'Failed to load activity timeline'
|
||||
)
|
||||
} finally {
|
||||
isActivityTimelineLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openActivityTimeline = async () => {
|
||||
isActivityTimelineOpen.value = true
|
||||
await fetchActivityTimeline()
|
||||
}
|
||||
|
||||
const closeActivityTimeline = () => {
|
||||
isActivityTimelineOpen.value = false
|
||||
}
|
||||
|
||||
const setActivityActionFilter = async (action: string) => {
|
||||
activityFilters.action = action
|
||||
await fetchActivityTimeline()
|
||||
}
|
||||
|
||||
const setActivityTargetTypeFilter = async (targetType: string) => {
|
||||
activityFilters.targetType = targetType
|
||||
await fetchActivityTimeline()
|
||||
}
|
||||
|
||||
const setActivityKeywordFilter = (keyword: string) => {
|
||||
activityFilters.keyword = keyword
|
||||
}
|
||||
|
||||
const applyActivityFilters = async () => {
|
||||
await fetchActivityTimeline()
|
||||
}
|
||||
|
||||
const resetActivityFilters = async () => {
|
||||
activityFilters.action = ''
|
||||
activityFilters.targetType = ''
|
||||
activityFilters.keyword = ''
|
||||
await fetchActivityTimeline()
|
||||
}
|
||||
|
||||
return {
|
||||
activityActionOptions,
|
||||
activityFilters,
|
||||
activityTargetTypeOptions,
|
||||
activityTimeline,
|
||||
activityTimelineError,
|
||||
activityTimelineStoredTotal,
|
||||
activityTimelineTotal,
|
||||
applyActivityFilters,
|
||||
closeActivityTimeline,
|
||||
dashboardData,
|
||||
errorMessage,
|
||||
fetchDashboardData,
|
||||
fetchActivityTimeline,
|
||||
hasActivityFilters,
|
||||
isActivityTimelineLoading,
|
||||
isActivityTimelineOpen,
|
||||
isLoading,
|
||||
lastUpdatedText,
|
||||
openActivityTimeline,
|
||||
resetActivityFilters,
|
||||
setActivityActionFilter,
|
||||
setActivityKeywordFilter,
|
||||
setActivityTargetTypeFilter
|
||||
lastUpdatedText
|
||||
}
|
||||
}
|
||||
|
||||
+3
-112
@@ -86,55 +86,6 @@ export default {
|
||||
maxSaveTime: 'Max Retention',
|
||||
noSaveLimit: 'Unlimited',
|
||||
todayCapacityReference: 'Today Size / Single File Limit',
|
||||
fileTypeDistribution: 'Type Distribution',
|
||||
textType: 'Text',
|
||||
recentActivities: 'Recent Activity',
|
||||
recentActivitiesDesc: 'Key admin changes for quick operational traceability.',
|
||||
viewAllActivities: 'View all',
|
||||
activityTimelineTitle: 'Activity Timeline',
|
||||
activityTimelineFiltered: 'Current Results',
|
||||
activityTimelineStoredTotal: 'Retained Records',
|
||||
activityKeyword: 'Keyword',
|
||||
activityKeywordPlaceholder: 'Search target, action, or ID',
|
||||
activityActionFilter: 'Action',
|
||||
activityAllActions: 'All actions',
|
||||
activityTargetTypeFilter: 'Target type',
|
||||
activityAllTargets: 'All targets',
|
||||
activityResetFilters: 'Reset',
|
||||
activityRefresh: 'Refresh',
|
||||
activityApplyFilters: 'Apply',
|
||||
activityLoading: 'Loading activity records...',
|
||||
activityNoFiltered: 'No matching activity records',
|
||||
activityLoadFailed: 'Failed to load activity timeline',
|
||||
noActivities: 'No activity yet',
|
||||
activityUnknownTarget: 'Unnamed target',
|
||||
activityDescription: 'Target: {target}',
|
||||
activityBatchDescription: 'Target: {target}, {count} items',
|
||||
activityActions: {
|
||||
'file.delete': 'Deleted file',
|
||||
'files.batch_delete': 'Batch deleted files',
|
||||
'file.update': 'Edited file',
|
||||
'files.batch_update': 'Batch updated files',
|
||||
'file.policy_action': 'Updated file policy',
|
||||
'files.batch_policy_action': 'Batch updated policies',
|
||||
'file.metadata_update': 'Updated operations notes',
|
||||
'file.view_preset_create': 'Created view preset',
|
||||
'file.view_preset_update': 'Updated view preset',
|
||||
'file.view_preset_delete': 'Deleted view preset',
|
||||
'config.update': 'Updated system settings',
|
||||
'local_file.delete': 'Deleted local file',
|
||||
'local_file.share': 'Shared local file'
|
||||
},
|
||||
activityTargetTypes: {
|
||||
file: 'File',
|
||||
view_preset: 'View preset',
|
||||
config: 'System settings',
|
||||
local_file: 'Local file',
|
||||
system: 'System'
|
||||
},
|
||||
recentFiles: 'Recent Shares',
|
||||
recentFilesDesc: 'Recently created share records for quick status checks.',
|
||||
available: 'Available',
|
||||
healthActions: {
|
||||
attention: {
|
||||
title: 'Needs Attention',
|
||||
@@ -156,12 +107,6 @@ export default {
|
||||
title: 'Permanent',
|
||||
description: 'View long-retention share records'
|
||||
}
|
||||
},
|
||||
table: {
|
||||
file: 'File',
|
||||
size: 'Size',
|
||||
usage: 'Retrievals',
|
||||
status: 'Status'
|
||||
}
|
||||
},
|
||||
fileManage: {
|
||||
@@ -498,6 +443,7 @@ export default {
|
||||
permanent: 'Permanent'
|
||||
},
|
||||
sortBy: 'Sort',
|
||||
sortOrder: 'Direction',
|
||||
unlimited: 'Unlimited',
|
||||
remaining: '{count} left',
|
||||
loadError: 'Failed to load file list',
|
||||
@@ -854,7 +800,7 @@ export default {
|
||||
serverUptime: 'Uptime',
|
||||
refresh: 'Refresh',
|
||||
fileHealth: 'File Health',
|
||||
fileHealthDesc: 'Real file records grouped by availability, expiry, and type.',
|
||||
fileHealthDesc: 'Status insights for available, risky, and pending file queues.',
|
||||
activeFileRatio: 'Active Ratio',
|
||||
fileShareRatio: 'File Ratio',
|
||||
textShareRatio: 'Text Ratio',
|
||||
@@ -870,62 +816,7 @@ export default {
|
||||
guestUpload: 'Guest Upload',
|
||||
maxSaveTime: 'Max Retention',
|
||||
noSaveLimit: 'Unlimited',
|
||||
todayCapacityReference: 'Today Size / Single File Limit',
|
||||
fileTypeDistribution: 'Type Distribution',
|
||||
textType: 'Text',
|
||||
recentActivities: 'Recent Activity',
|
||||
recentActivitiesDesc: 'Key admin changes for quick operational traceability.',
|
||||
viewAllActivities: 'View all',
|
||||
activityTimelineTitle: 'Activity Timeline',
|
||||
activityTimelineFiltered: 'Current Results',
|
||||
activityTimelineStoredTotal: 'Retained Records',
|
||||
activityKeyword: 'Keyword',
|
||||
activityKeywordPlaceholder: 'Search target, action, or ID',
|
||||
activityActionFilter: 'Action',
|
||||
activityAllActions: 'All actions',
|
||||
activityTargetTypeFilter: 'Target type',
|
||||
activityAllTargets: 'All targets',
|
||||
activityResetFilters: 'Reset',
|
||||
activityRefresh: 'Refresh',
|
||||
activityApplyFilters: 'Apply',
|
||||
activityLoading: 'Loading activity records...',
|
||||
activityNoFiltered: 'No matching activity records',
|
||||
activityLoadFailed: 'Failed to load activity timeline',
|
||||
noActivities: 'No activity yet',
|
||||
activityUnknownTarget: 'Unnamed target',
|
||||
activityDescription: 'Target: {target}',
|
||||
activityBatchDescription: 'Target: {target}, {count} items',
|
||||
activityActions: {
|
||||
'file.delete': 'Deleted file',
|
||||
'files.batch_delete': 'Batch deleted files',
|
||||
'file.update': 'Edited file',
|
||||
'files.batch_update': 'Batch updated files',
|
||||
'file.policy_action': 'Updated file policy',
|
||||
'files.batch_policy_action': 'Batch updated policies',
|
||||
'file.metadata_update': 'Updated operations notes',
|
||||
'file.view_preset_create': 'Created view preset',
|
||||
'file.view_preset_update': 'Updated view preset',
|
||||
'file.view_preset_delete': 'Deleted view preset',
|
||||
'config.update': 'Updated system settings',
|
||||
'local_file.delete': 'Deleted local file',
|
||||
'local_file.share': 'Shared local file'
|
||||
},
|
||||
activityTargetTypes: {
|
||||
file: 'File',
|
||||
view_preset: 'View preset',
|
||||
config: 'System settings',
|
||||
local_file: 'Local file',
|
||||
system: 'System'
|
||||
},
|
||||
recentFiles: 'Recent Shares',
|
||||
recentFilesDesc: 'Recently created share records for quick status checks.',
|
||||
available: 'Available',
|
||||
table: {
|
||||
file: 'File',
|
||||
size: 'Size',
|
||||
usage: 'Retrievals',
|
||||
status: 'Status'
|
||||
}
|
||||
todayCapacityReference: 'Today Size / Single File Limit'
|
||||
},
|
||||
fileManage: {
|
||||
title: 'File Management',
|
||||
|
||||
+3
-112
@@ -86,55 +86,6 @@ export default {
|
||||
maxSaveTime: '最长保存',
|
||||
noSaveLimit: '不限制',
|
||||
todayCapacityReference: '今日容量 / 单文件上限',
|
||||
fileTypeDistribution: '类型分布',
|
||||
textType: '文本',
|
||||
recentActivities: '最近操作',
|
||||
recentActivitiesDesc: '记录管理员最近的关键改动,便于回溯处理链路。',
|
||||
viewAllActivities: '查看全部',
|
||||
activityTimelineTitle: '操作时间线',
|
||||
activityTimelineFiltered: '当前结果',
|
||||
activityTimelineStoredTotal: '保留记录',
|
||||
activityKeyword: '关键词',
|
||||
activityKeywordPlaceholder: '搜索对象、动作或 ID',
|
||||
activityActionFilter: '操作类型',
|
||||
activityAllActions: '全部操作',
|
||||
activityTargetTypeFilter: '对象类型',
|
||||
activityAllTargets: '全部对象',
|
||||
activityResetFilters: '重置',
|
||||
activityRefresh: '刷新',
|
||||
activityApplyFilters: '筛选',
|
||||
activityLoading: '正在加载操作记录...',
|
||||
activityNoFiltered: '没有匹配的操作记录',
|
||||
activityLoadFailed: '操作时间线加载失败',
|
||||
noActivities: '暂无操作记录',
|
||||
activityUnknownTarget: '未命名对象',
|
||||
activityDescription: '对象:{target}',
|
||||
activityBatchDescription: '对象:{target},共 {count} 项',
|
||||
activityActions: {
|
||||
'file.delete': '删除文件',
|
||||
'files.batch_delete': '批量删除文件',
|
||||
'file.update': '编辑文件',
|
||||
'files.batch_update': '批量更新文件',
|
||||
'file.policy_action': '更新文件策略',
|
||||
'files.batch_policy_action': '批量更新策略',
|
||||
'file.metadata_update': '更新运营备注',
|
||||
'file.view_preset_create': '创建视图预设',
|
||||
'file.view_preset_update': '覆盖视图预设',
|
||||
'file.view_preset_delete': '删除视图预设',
|
||||
'config.update': '更新系统配置',
|
||||
'local_file.delete': '删除本地文件',
|
||||
'local_file.share': '分享本地文件'
|
||||
},
|
||||
activityTargetTypes: {
|
||||
file: '文件',
|
||||
view_preset: '视图预设',
|
||||
config: '系统配置',
|
||||
local_file: '本地文件',
|
||||
system: '系统'
|
||||
},
|
||||
recentFiles: '最近分享',
|
||||
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
||||
available: '可取件',
|
||||
healthActions: {
|
||||
attention: {
|
||||
title: '需关注',
|
||||
@@ -156,12 +107,6 @@ export default {
|
||||
title: '永久有效',
|
||||
description: '查看长期保留的分享记录'
|
||||
}
|
||||
},
|
||||
table: {
|
||||
file: '文件',
|
||||
size: '大小',
|
||||
usage: '取件',
|
||||
status: '状态'
|
||||
}
|
||||
},
|
||||
fileManage: {
|
||||
@@ -496,6 +441,7 @@ export default {
|
||||
permanent: '永久有效'
|
||||
},
|
||||
sortBy: '排序',
|
||||
sortOrder: '方向',
|
||||
unlimited: '不限次数',
|
||||
remaining: '剩余 {count} 次',
|
||||
loadError: '文件列表加载失败',
|
||||
@@ -816,7 +762,7 @@ export default {
|
||||
serverUptime: '运行时间',
|
||||
refresh: '刷新数据',
|
||||
fileHealth: '文件健康',
|
||||
fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。',
|
||||
fileHealthDesc: '基于状态洞察统计可取件、风险与待处理队列。',
|
||||
activeFileRatio: '有效占比',
|
||||
fileShareRatio: '文件占比',
|
||||
textShareRatio: '文本占比',
|
||||
@@ -832,62 +778,7 @@ export default {
|
||||
guestUpload: '游客上传',
|
||||
maxSaveTime: '最长保存',
|
||||
noSaveLimit: '不限制',
|
||||
todayCapacityReference: '今日容量 / 单文件上限',
|
||||
fileTypeDistribution: '类型分布',
|
||||
textType: '文本',
|
||||
recentActivities: '最近操作',
|
||||
recentActivitiesDesc: '记录管理员最近的关键改动,便于回溯处理链路。',
|
||||
viewAllActivities: '查看全部',
|
||||
activityTimelineTitle: '操作时间线',
|
||||
activityTimelineFiltered: '当前结果',
|
||||
activityTimelineStoredTotal: '保留记录',
|
||||
activityKeyword: '关键词',
|
||||
activityKeywordPlaceholder: '搜索对象、动作或 ID',
|
||||
activityActionFilter: '操作类型',
|
||||
activityAllActions: '全部操作',
|
||||
activityTargetTypeFilter: '对象类型',
|
||||
activityAllTargets: '全部对象',
|
||||
activityResetFilters: '重置',
|
||||
activityRefresh: '刷新',
|
||||
activityApplyFilters: '筛选',
|
||||
activityLoading: '正在加载操作记录...',
|
||||
activityNoFiltered: '没有匹配的操作记录',
|
||||
activityLoadFailed: '操作时间线加载失败',
|
||||
noActivities: '暂无操作记录',
|
||||
activityUnknownTarget: '未命名对象',
|
||||
activityDescription: '对象:{target}',
|
||||
activityBatchDescription: '对象:{target},共 {count} 项',
|
||||
activityActions: {
|
||||
'file.delete': '删除文件',
|
||||
'files.batch_delete': '批量删除文件',
|
||||
'file.update': '编辑文件',
|
||||
'files.batch_update': '批量更新文件',
|
||||
'file.policy_action': '更新文件策略',
|
||||
'files.batch_policy_action': '批量更新策略',
|
||||
'file.metadata_update': '更新运营备注',
|
||||
'file.view_preset_create': '创建视图预设',
|
||||
'file.view_preset_update': '覆盖视图预设',
|
||||
'file.view_preset_delete': '删除视图预设',
|
||||
'config.update': '更新系统配置',
|
||||
'local_file.delete': '删除本地文件',
|
||||
'local_file.share': '分享本地文件'
|
||||
},
|
||||
activityTargetTypes: {
|
||||
file: '文件',
|
||||
view_preset: '视图预设',
|
||||
config: '系统配置',
|
||||
local_file: '本地文件',
|
||||
system: '系统'
|
||||
},
|
||||
recentFiles: '最近分享',
|
||||
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
||||
available: '可取件',
|
||||
table: {
|
||||
file: '文件',
|
||||
size: '大小',
|
||||
usage: '取件',
|
||||
status: '状态'
|
||||
}
|
||||
todayCapacityReference: '今日容量 / 单文件上限'
|
||||
},
|
||||
fileManage: {
|
||||
title: '文件管理',
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
>
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="fixed inset-y-0 border-transparent left-0 z-50 w-64 transform transition-all duration-300 ease-in-out lg:relative lg:translate-x-0 border-r flex flex-col h-full lg:h-screen"
|
||||
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 shrink-0 transform flex-col border-r lg:relative lg:h-screen lg:translate-x-0"
|
||||
:class="[
|
||||
isDarkMode ? 'bg-gray-800 bg-opacity-90 backdrop-filter backdrop-blur-xl ' : 'bg-white ',
|
||||
{ '-translate-x-full': !isSidebarOpen }
|
||||
isDarkMode
|
||||
? 'border-gray-700 bg-gray-800 bg-opacity-90 backdrop-filter backdrop-blur-xl'
|
||||
: 'border-gray-200 bg-white',
|
||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
'transition-transform duration-300 ease-in-out lg:transition-none'
|
||||
]"
|
||||
>
|
||||
<!-- Logo区域 -->
|
||||
@@ -46,18 +49,18 @@
|
||||
<li v-for="item in menuItems" :key="item.id">
|
||||
<RouterLink
|
||||
:to="item.redirect"
|
||||
class="flex items-center p-2 rounded-lg transition-colors duration-200"
|
||||
class="flex h-10 w-full items-center rounded-lg border-l-4 px-3 text-sm font-medium"
|
||||
:class="[
|
||||
route.name === item.id
|
||||
? isDarkMode
|
||||
? 'bg-indigo-900 text-indigo-400'
|
||||
: 'bg-indigo-100 text-indigo-600'
|
||||
? 'border-indigo-400 bg-gray-700/70 text-indigo-200'
|
||||
: 'border-indigo-500 bg-gray-100 text-indigo-700'
|
||||
: isDarkMode
|
||||
? 'text-gray-400 hover:bg-gray-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
? 'border-transparent text-gray-400 hover:bg-gray-700'
|
||||
: 'border-transparent text-gray-600 hover:bg-gray-100'
|
||||
]"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 mr-3" />
|
||||
<component :is="item.icon" class="mr-3 h-5 w-5 shrink-0" />
|
||||
{{ item.name }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
@@ -82,7 +85,7 @@
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex h-full min-h-0 flex-1 flex-col">
|
||||
<div class="flex h-full min-h-0 min-w-0 flex-1 flex-col">
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="shadow-md border-b transition-colors duration-300 h-16"
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ const publicPageMeta = {
|
||||
const adminPageMeta = {
|
||||
requiresAuth: true,
|
||||
showGlobalControls: false,
|
||||
showRouteLoading: true
|
||||
showRouteLoading: false
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
|
||||
+1
-12
@@ -1,19 +1,8 @@
|
||||
import api from './client'
|
||||
import type {
|
||||
ApiResponse,
|
||||
DashboardActivitiesParams,
|
||||
DashboardActivitiesResponse,
|
||||
DashboardData
|
||||
} from '@/types'
|
||||
import type { ApiResponse, DashboardData } from '@/types'
|
||||
|
||||
export class StatsService {
|
||||
static async getDashboard(): Promise<ApiResponse<DashboardData>> {
|
||||
return api.get('/admin/dashboard')
|
||||
}
|
||||
|
||||
static async getActivities(
|
||||
params: DashboardActivitiesParams
|
||||
): Promise<ApiResponse<DashboardActivitiesResponse>> {
|
||||
return api.get('/admin/activities', { params })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,85 +39,6 @@ export interface DashboardData {
|
||||
healthyCount?: number
|
||||
permanentCount?: number
|
||||
healthSummary?: Partial<DashboardHealthSummary>
|
||||
topSuffixes?: DashboardSuffixStat[]
|
||||
recentFiles?: DashboardRecentFile[]
|
||||
recentActivities?: DashboardActivity[]
|
||||
recent_activities?: DashboardActivity[]
|
||||
}
|
||||
|
||||
export interface DashboardSuffixStat {
|
||||
suffix: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface DashboardRecentFile {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
suffix: string
|
||||
size: number
|
||||
text: boolean
|
||||
expiredAt: string | null
|
||||
expiredCount: number
|
||||
usedCount: number
|
||||
createdAt: string | null
|
||||
isExpired: boolean
|
||||
}
|
||||
|
||||
export interface DashboardActivity {
|
||||
id: string
|
||||
action: string
|
||||
targetType?: string
|
||||
target_type?: string
|
||||
targetId?: string | number | null
|
||||
target_id?: string | number | null
|
||||
targetName?: string
|
||||
target_name?: string
|
||||
count: number
|
||||
meta?: Record<string, unknown>
|
||||
createdAt?: string | null
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
export interface DashboardActivityViewItem extends DashboardActivity {
|
||||
targetTypeValue: string
|
||||
targetNameValue: string
|
||||
createdAtValue: string | null
|
||||
}
|
||||
|
||||
export interface DashboardActivityOption {
|
||||
value: string
|
||||
label?: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface DashboardActivityFilters {
|
||||
action?: string
|
||||
targetType?: string
|
||||
target_type?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface DashboardActivitiesParams {
|
||||
limit?: number
|
||||
action?: string
|
||||
targetType?: string
|
||||
target_type?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface DashboardActivitiesResponse {
|
||||
activities?: DashboardActivity[]
|
||||
items?: DashboardActivity[]
|
||||
total?: number
|
||||
storedTotal?: number
|
||||
stored_total?: number
|
||||
limit?: number
|
||||
filters?: DashboardActivityFilters
|
||||
actionOptions?: DashboardActivityOption[]
|
||||
action_options?: DashboardActivityOption[]
|
||||
targetTypeOptions?: DashboardActivityOption[]
|
||||
target_type_options?: DashboardActivityOption[]
|
||||
}
|
||||
|
||||
export type DashboardViewData = Omit<
|
||||
@@ -134,10 +55,6 @@ export type DashboardViewData = Omit<
|
||||
| 'openUpload'
|
||||
| 'enableChunk'
|
||||
| 'maxSaveSeconds'
|
||||
| 'topSuffixes'
|
||||
| 'recentFiles'
|
||||
| 'recentActivities'
|
||||
| 'recent_activities'
|
||||
| 'storageUsed'
|
||||
| 'yesterdaySize'
|
||||
| 'todaySize'
|
||||
@@ -155,9 +72,6 @@ export type DashboardViewData = Omit<
|
||||
openUpload: number
|
||||
enableChunk: number
|
||||
maxSaveSeconds: number
|
||||
topSuffixes: DashboardSuffixStat[]
|
||||
recentFiles: DashboardRecentFile[]
|
||||
recentActivities: DashboardActivityViewItem[]
|
||||
storageUsed: number
|
||||
yesterdaySize: number
|
||||
todaySize: number
|
||||
|
||||
@@ -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'),
|
||||
const { dashboardData, errorMessage, fetchDashboardData, isLoading, lastUpdatedText } =
|
||||
useDashboardStats({
|
||||
loadFailedMessage: t('admin.dashboard.loadFailed')
|
||||
})
|
||||
|
||||
const handleOpenActivityTimeline = () => {
|
||||
void openActivityTimeline()
|
||||
}
|
||||
|
||||
const handleApplyActivityFilters = () => {
|
||||
void applyActivityFilters()
|
||||
}
|
||||
|
||||
const handleResetActivityFilters = () => {
|
||||
void resetActivityFilters()
|
||||
}
|
||||
|
||||
const handleRefreshActivityTimeline = () => {
|
||||
void fetchActivityTimeline()
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
|
||||
@@ -52,9 +52,15 @@
|
||||
</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">
|
||||
<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"
|
||||
@@ -67,11 +73,8 @@
|
||||
]"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<SearchIcon
|
||||
class="absolute left-3 top-3 h-4 w-4"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<BaseButton :loading="isLoading" @click="handleSearch">
|
||||
<template #icon>
|
||||
<SearchIcon class="mr-2 h-4 w-4" />
|
||||
@@ -80,68 +83,74 @@
|
||||
</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
|
||||
<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
|
||||
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)"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</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
|
||||
<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"
|
||||
>
|
||||
<option
|
||||
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)"
|
||||
:value="option.value"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<span v-if="typeof option.count === 'number'">({{ option.count }})</span>
|
||||
</button>
|
||||
</div>
|
||||
{{ 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 v-for="option in typeFilterOptions" :key="option.value" :value="option.value">
|
||||
{{ 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') }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<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="rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
|
||||
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"
|
||||
>
|
||||
@@ -149,9 +158,16 @@
|
||||
{{ 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="rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
|
||||
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"
|
||||
>
|
||||
@@ -159,14 +175,16 @@
|
||||
{{ 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]">
|
||||
</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') }}
|
||||
</label>
|
||||
</span>
|
||||
<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="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"
|
||||
@@ -176,7 +194,10 @@
|
||||
{{ preset.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -217,7 +238,6 @@
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user