feat: add admin activity timeline filters
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { computed, ref, reactive } from 'vue'
|
||||
import { StatsService } from '@/services'
|
||||
import type {
|
||||
DashboardActivitiesParams,
|
||||
DashboardActivityOption,
|
||||
DashboardActivity,
|
||||
DashboardActivityViewItem,
|
||||
DashboardData,
|
||||
@@ -11,8 +13,11 @@ import { formatFileSize, getErrorMessage } from '@/utils/common'
|
||||
|
||||
type UseDashboardStatsOptions = {
|
||||
loadFailedMessage?: string
|
||||
activityLoadFailedMessage?: string
|
||||
}
|
||||
|
||||
const activityTimelineLimit = 40
|
||||
|
||||
const emptyDashboardData = (): DashboardViewData => ({
|
||||
hasExtendedStats: false,
|
||||
totalFiles: 0,
|
||||
@@ -93,6 +98,17 @@ const normalizeRecentActivities = (
|
||||
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',
|
||||
@@ -126,9 +142,32 @@ 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
|
||||
@@ -206,11 +245,93 @@ 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
|
||||
lastUpdatedText,
|
||||
openActivityTimeline,
|
||||
resetActivityFilters,
|
||||
setActivityActionFilter,
|
||||
setActivityKeywordFilter,
|
||||
setActivityTargetTypeFilter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,22 @@ export default {
|
||||
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}',
|
||||
@@ -109,6 +125,13 @@ export default {
|
||||
'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',
|
||||
@@ -852,6 +875,22 @@ export default {
|
||||
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}',
|
||||
@@ -871,6 +910,13 @@ export default {
|
||||
'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',
|
||||
|
||||
@@ -90,6 +90,22 @@ export default {
|
||||
textType: '文本',
|
||||
recentActivities: '最近操作',
|
||||
recentActivitiesDesc: '记录管理员最近的关键改动,便于回溯处理链路。',
|
||||
viewAllActivities: '查看全部',
|
||||
activityTimelineTitle: '操作时间线',
|
||||
activityTimelineFiltered: '当前结果',
|
||||
activityTimelineStoredTotal: '保留记录',
|
||||
activityKeyword: '关键词',
|
||||
activityKeywordPlaceholder: '搜索对象、动作或 ID',
|
||||
activityActionFilter: '操作类型',
|
||||
activityAllActions: '全部操作',
|
||||
activityTargetTypeFilter: '对象类型',
|
||||
activityAllTargets: '全部对象',
|
||||
activityResetFilters: '重置',
|
||||
activityRefresh: '刷新',
|
||||
activityApplyFilters: '筛选',
|
||||
activityLoading: '正在加载操作记录...',
|
||||
activityNoFiltered: '没有匹配的操作记录',
|
||||
activityLoadFailed: '操作时间线加载失败',
|
||||
noActivities: '暂无操作记录',
|
||||
activityUnknownTarget: '未命名对象',
|
||||
activityDescription: '对象:{target}',
|
||||
@@ -109,6 +125,13 @@ export default {
|
||||
'local_file.delete': '删除本地文件',
|
||||
'local_file.share': '分享本地文件'
|
||||
},
|
||||
activityTargetTypes: {
|
||||
file: '文件',
|
||||
view_preset: '视图预设',
|
||||
config: '系统配置',
|
||||
local_file: '本地文件',
|
||||
system: '系统'
|
||||
},
|
||||
recentFiles: '最近分享',
|
||||
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
||||
available: '可取件',
|
||||
@@ -814,6 +837,22 @@ export default {
|
||||
textType: '文本',
|
||||
recentActivities: '最近操作',
|
||||
recentActivitiesDesc: '记录管理员最近的关键改动,便于回溯处理链路。',
|
||||
viewAllActivities: '查看全部',
|
||||
activityTimelineTitle: '操作时间线',
|
||||
activityTimelineFiltered: '当前结果',
|
||||
activityTimelineStoredTotal: '保留记录',
|
||||
activityKeyword: '关键词',
|
||||
activityKeywordPlaceholder: '搜索对象、动作或 ID',
|
||||
activityActionFilter: '操作类型',
|
||||
activityAllActions: '全部操作',
|
||||
activityTargetTypeFilter: '对象类型',
|
||||
activityAllTargets: '全部对象',
|
||||
activityResetFilters: '重置',
|
||||
activityRefresh: '刷新',
|
||||
activityApplyFilters: '筛选',
|
||||
activityLoading: '正在加载操作记录...',
|
||||
activityNoFiltered: '没有匹配的操作记录',
|
||||
activityLoadFailed: '操作时间线加载失败',
|
||||
noActivities: '暂无操作记录',
|
||||
activityUnknownTarget: '未命名对象',
|
||||
activityDescription: '对象:{target}',
|
||||
@@ -833,6 +872,13 @@ export default {
|
||||
'local_file.delete': '删除本地文件',
|
||||
'local_file.share': '分享本地文件'
|
||||
},
|
||||
activityTargetTypes: {
|
||||
file: '文件',
|
||||
view_preset: '视图预设',
|
||||
config: '系统配置',
|
||||
local_file: '本地文件',
|
||||
system: '系统'
|
||||
},
|
||||
recentFiles: '最近分享',
|
||||
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
||||
available: '可取件',
|
||||
|
||||
+12
-1
@@ -1,8 +1,19 @@
|
||||
import api from './client'
|
||||
import type { ApiResponse, DashboardData } from '@/types'
|
||||
import type {
|
||||
ApiResponse,
|
||||
DashboardActivitiesParams,
|
||||
DashboardActivitiesResponse,
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,41 @@ export interface DashboardActivityViewItem extends DashboardActivity {
|
||||
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<
|
||||
DashboardData,
|
||||
| keyof DashboardHealthSummary
|
||||
|
||||
@@ -265,10 +265,19 @@
|
||||
{{ t('admin.dashboard.recentActivitiesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<HistoryIcon
|
||||
class="h-5 w-5 shrink-0"
|
||||
:class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']"
|
||||
/>
|
||||
<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
|
||||
@@ -411,6 +420,237 @@
|
||||
</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">
|
||||
@@ -429,14 +669,17 @@ import {
|
||||
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'
|
||||
@@ -446,11 +689,48 @@ import type { DashboardActivityViewItem, DashboardHealthAction } from '@/types'
|
||||
const isDarkMode = useInjectedDarkMode()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { dashboardData, errorMessage, fetchDashboardData, isLoading, lastUpdatedText } =
|
||||
useDashboardStats({
|
||||
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 primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
|
||||
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
|
||||
const panelClass = computed(() =>
|
||||
@@ -572,6 +852,12 @@ const getActivityTitle = (action: string) => {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user