feat: add admin activity timeline filters
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { computed, ref, reactive } from 'vue'
|
import { computed, ref, reactive } from 'vue'
|
||||||
import { StatsService } from '@/services'
|
import { StatsService } from '@/services'
|
||||||
import type {
|
import type {
|
||||||
|
DashboardActivitiesParams,
|
||||||
|
DashboardActivityOption,
|
||||||
DashboardActivity,
|
DashboardActivity,
|
||||||
DashboardActivityViewItem,
|
DashboardActivityViewItem,
|
||||||
DashboardData,
|
DashboardData,
|
||||||
@@ -11,8 +13,11 @@ import { formatFileSize, getErrorMessage } from '@/utils/common'
|
|||||||
|
|
||||||
type UseDashboardStatsOptions = {
|
type UseDashboardStatsOptions = {
|
||||||
loadFailedMessage?: string
|
loadFailedMessage?: string
|
||||||
|
activityLoadFailedMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activityTimelineLimit = 40
|
||||||
|
|
||||||
const emptyDashboardData = (): DashboardViewData => ({
|
const emptyDashboardData = (): DashboardViewData => ({
|
||||||
hasExtendedStats: false,
|
hasExtendedStats: false,
|
||||||
totalFiles: 0,
|
totalFiles: 0,
|
||||||
@@ -93,6 +98,17 @@ const normalizeRecentActivities = (
|
|||||||
meta: activity.meta && typeof activity.meta === 'object' ? activity.meta : {}
|
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)[] = [
|
const healthSummaryKeys: (keyof DashboardHealthSummary)[] = [
|
||||||
'healthAttentionCount',
|
'healthAttentionCount',
|
||||||
'healthDangerCount',
|
'healthDangerCount',
|
||||||
@@ -126,9 +142,32 @@ export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const lastUpdatedAt = ref<Date | null>(null)
|
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(() =>
|
const lastUpdatedText = computed(() =>
|
||||||
lastUpdatedAt.value ? lastUpdatedAt.value.toLocaleString() : '-'
|
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 () => {
|
const fetchDashboardData = async () => {
|
||||||
isLoading.value = true
|
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 {
|
return {
|
||||||
|
activityActionOptions,
|
||||||
|
activityFilters,
|
||||||
|
activityTargetTypeOptions,
|
||||||
|
activityTimeline,
|
||||||
|
activityTimelineError,
|
||||||
|
activityTimelineStoredTotal,
|
||||||
|
activityTimelineTotal,
|
||||||
|
applyActivityFilters,
|
||||||
|
closeActivityTimeline,
|
||||||
dashboardData,
|
dashboardData,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
fetchDashboardData,
|
fetchDashboardData,
|
||||||
|
fetchActivityTimeline,
|
||||||
|
hasActivityFilters,
|
||||||
|
isActivityTimelineLoading,
|
||||||
|
isActivityTimelineOpen,
|
||||||
isLoading,
|
isLoading,
|
||||||
lastUpdatedText
|
lastUpdatedText,
|
||||||
|
openActivityTimeline,
|
||||||
|
resetActivityFilters,
|
||||||
|
setActivityActionFilter,
|
||||||
|
setActivityKeywordFilter,
|
||||||
|
setActivityTargetTypeFilter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ export default {
|
|||||||
textType: 'Text',
|
textType: 'Text',
|
||||||
recentActivities: 'Recent Activity',
|
recentActivities: 'Recent Activity',
|
||||||
recentActivitiesDesc: 'Key admin changes for quick operational traceability.',
|
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',
|
noActivities: 'No activity yet',
|
||||||
activityUnknownTarget: 'Unnamed target',
|
activityUnknownTarget: 'Unnamed target',
|
||||||
activityDescription: 'Target: {target}',
|
activityDescription: 'Target: {target}',
|
||||||
@@ -109,6 +125,13 @@ export default {
|
|||||||
'local_file.delete': 'Deleted local file',
|
'local_file.delete': 'Deleted local file',
|
||||||
'local_file.share': 'Shared 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',
|
recentFiles: 'Recent Shares',
|
||||||
recentFilesDesc: 'Recently created share records for quick status checks.',
|
recentFilesDesc: 'Recently created share records for quick status checks.',
|
||||||
available: 'Available',
|
available: 'Available',
|
||||||
@@ -852,6 +875,22 @@ export default {
|
|||||||
textType: 'Text',
|
textType: 'Text',
|
||||||
recentActivities: 'Recent Activity',
|
recentActivities: 'Recent Activity',
|
||||||
recentActivitiesDesc: 'Key admin changes for quick operational traceability.',
|
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',
|
noActivities: 'No activity yet',
|
||||||
activityUnknownTarget: 'Unnamed target',
|
activityUnknownTarget: 'Unnamed target',
|
||||||
activityDescription: 'Target: {target}',
|
activityDescription: 'Target: {target}',
|
||||||
@@ -871,6 +910,13 @@ export default {
|
|||||||
'local_file.delete': 'Deleted local file',
|
'local_file.delete': 'Deleted local file',
|
||||||
'local_file.share': 'Shared 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',
|
recentFiles: 'Recent Shares',
|
||||||
recentFilesDesc: 'Recently created share records for quick status checks.',
|
recentFilesDesc: 'Recently created share records for quick status checks.',
|
||||||
available: 'Available',
|
available: 'Available',
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ export default {
|
|||||||
textType: '文本',
|
textType: '文本',
|
||||||
recentActivities: '最近操作',
|
recentActivities: '最近操作',
|
||||||
recentActivitiesDesc: '记录管理员最近的关键改动,便于回溯处理链路。',
|
recentActivitiesDesc: '记录管理员最近的关键改动,便于回溯处理链路。',
|
||||||
|
viewAllActivities: '查看全部',
|
||||||
|
activityTimelineTitle: '操作时间线',
|
||||||
|
activityTimelineFiltered: '当前结果',
|
||||||
|
activityTimelineStoredTotal: '保留记录',
|
||||||
|
activityKeyword: '关键词',
|
||||||
|
activityKeywordPlaceholder: '搜索对象、动作或 ID',
|
||||||
|
activityActionFilter: '操作类型',
|
||||||
|
activityAllActions: '全部操作',
|
||||||
|
activityTargetTypeFilter: '对象类型',
|
||||||
|
activityAllTargets: '全部对象',
|
||||||
|
activityResetFilters: '重置',
|
||||||
|
activityRefresh: '刷新',
|
||||||
|
activityApplyFilters: '筛选',
|
||||||
|
activityLoading: '正在加载操作记录...',
|
||||||
|
activityNoFiltered: '没有匹配的操作记录',
|
||||||
|
activityLoadFailed: '操作时间线加载失败',
|
||||||
noActivities: '暂无操作记录',
|
noActivities: '暂无操作记录',
|
||||||
activityUnknownTarget: '未命名对象',
|
activityUnknownTarget: '未命名对象',
|
||||||
activityDescription: '对象:{target}',
|
activityDescription: '对象:{target}',
|
||||||
@@ -109,6 +125,13 @@ export default {
|
|||||||
'local_file.delete': '删除本地文件',
|
'local_file.delete': '删除本地文件',
|
||||||
'local_file.share': '分享本地文件'
|
'local_file.share': '分享本地文件'
|
||||||
},
|
},
|
||||||
|
activityTargetTypes: {
|
||||||
|
file: '文件',
|
||||||
|
view_preset: '视图预设',
|
||||||
|
config: '系统配置',
|
||||||
|
local_file: '本地文件',
|
||||||
|
system: '系统'
|
||||||
|
},
|
||||||
recentFiles: '最近分享',
|
recentFiles: '最近分享',
|
||||||
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
||||||
available: '可取件',
|
available: '可取件',
|
||||||
@@ -814,6 +837,22 @@ export default {
|
|||||||
textType: '文本',
|
textType: '文本',
|
||||||
recentActivities: '最近操作',
|
recentActivities: '最近操作',
|
||||||
recentActivitiesDesc: '记录管理员最近的关键改动,便于回溯处理链路。',
|
recentActivitiesDesc: '记录管理员最近的关键改动,便于回溯处理链路。',
|
||||||
|
viewAllActivities: '查看全部',
|
||||||
|
activityTimelineTitle: '操作时间线',
|
||||||
|
activityTimelineFiltered: '当前结果',
|
||||||
|
activityTimelineStoredTotal: '保留记录',
|
||||||
|
activityKeyword: '关键词',
|
||||||
|
activityKeywordPlaceholder: '搜索对象、动作或 ID',
|
||||||
|
activityActionFilter: '操作类型',
|
||||||
|
activityAllActions: '全部操作',
|
||||||
|
activityTargetTypeFilter: '对象类型',
|
||||||
|
activityAllTargets: '全部对象',
|
||||||
|
activityResetFilters: '重置',
|
||||||
|
activityRefresh: '刷新',
|
||||||
|
activityApplyFilters: '筛选',
|
||||||
|
activityLoading: '正在加载操作记录...',
|
||||||
|
activityNoFiltered: '没有匹配的操作记录',
|
||||||
|
activityLoadFailed: '操作时间线加载失败',
|
||||||
noActivities: '暂无操作记录',
|
noActivities: '暂无操作记录',
|
||||||
activityUnknownTarget: '未命名对象',
|
activityUnknownTarget: '未命名对象',
|
||||||
activityDescription: '对象:{target}',
|
activityDescription: '对象:{target}',
|
||||||
@@ -833,6 +872,13 @@ export default {
|
|||||||
'local_file.delete': '删除本地文件',
|
'local_file.delete': '删除本地文件',
|
||||||
'local_file.share': '分享本地文件'
|
'local_file.share': '分享本地文件'
|
||||||
},
|
},
|
||||||
|
activityTargetTypes: {
|
||||||
|
file: '文件',
|
||||||
|
view_preset: '视图预设',
|
||||||
|
config: '系统配置',
|
||||||
|
local_file: '本地文件',
|
||||||
|
system: '系统'
|
||||||
|
},
|
||||||
recentFiles: '最近分享',
|
recentFiles: '最近分享',
|
||||||
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
||||||
available: '可取件',
|
available: '可取件',
|
||||||
|
|||||||
+12
-1
@@ -1,8 +1,19 @@
|
|||||||
import api from './client'
|
import api from './client'
|
||||||
import type { ApiResponse, DashboardData } from '@/types'
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
DashboardActivitiesParams,
|
||||||
|
DashboardActivitiesResponse,
|
||||||
|
DashboardData
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
export class StatsService {
|
export class StatsService {
|
||||||
static async getDashboard(): Promise<ApiResponse<DashboardData>> {
|
static async getDashboard(): Promise<ApiResponse<DashboardData>> {
|
||||||
return api.get('/admin/dashboard')
|
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
|
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<
|
export type DashboardViewData = Omit<
|
||||||
DashboardData,
|
DashboardData,
|
||||||
| keyof DashboardHealthSummary
|
| keyof DashboardHealthSummary
|
||||||
|
|||||||
@@ -265,10 +265,19 @@
|
|||||||
{{ t('admin.dashboard.recentActivitiesDesc') }}
|
{{ t('admin.dashboard.recentActivitiesDesc') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<HistoryIcon
|
<button
|
||||||
class="h-5 w-5 shrink-0"
|
type="button"
|
||||||
:class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']"
|
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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -411,6 +420,237 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -429,14 +669,17 @@ import {
|
|||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
HardDriveIcon,
|
HardDriveIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
|
ListFilterIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
|
SearchIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
SlidersHorizontalIcon,
|
SlidersHorizontalIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UploadCloudIcon
|
UploadCloudIcon
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import StatCard from '@/components/common/StatCard.vue'
|
import StatCard from '@/components/common/StatCard.vue'
|
||||||
|
import SideDrawer from '@/components/common/SideDrawer.vue'
|
||||||
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
|
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { formatFileSize, formatTimestamp } from '@/utils/common'
|
import { formatFileSize, formatTimestamp } from '@/utils/common'
|
||||||
@@ -446,10 +689,47 @@ import type { DashboardActivityViewItem, DashboardHealthAction } from '@/types'
|
|||||||
const isDarkMode = useInjectedDarkMode()
|
const isDarkMode = useInjectedDarkMode()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { dashboardData, errorMessage, fetchDashboardData, isLoading, lastUpdatedText } =
|
const {
|
||||||
useDashboardStats({
|
activityActionOptions,
|
||||||
loadFailedMessage: t('admin.dashboard.loadFailed')
|
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 primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
|
||||||
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
|
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
|
||||||
@@ -572,6 +852,12 @@ const getActivityTitle = (action: string) => {
|
|||||||
return title === key ? action : title
|
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 getActivityDescription = (activity: DashboardActivityViewItem) => {
|
||||||
const targetName = activity.targetNameValue || t('admin.dashboard.activityUnknownTarget')
|
const targetName = activity.targetNameValue || t('admin.dashboard.activityUnknownTarget')
|
||||||
if (activity.count > 1) {
|
if (activity.count > 1) {
|
||||||
|
|||||||
Reference in New Issue
Block a user