mirror of
https://github.com/jumpserver/lina.git
synced 2026-05-18 13:45:01 +00:00
perf: update reports
This commit is contained in:
@@ -34,6 +34,10 @@ export default {
|
||||
let key
|
||||
if (this.$route.query['_']) {
|
||||
key = this.$route.query['_']
|
||||
} else if (this.$route.path.startsWith('/audit/reports/')) {
|
||||
// 报表页面:只用路径作为 key,让同一路径的组件实例被复用
|
||||
// 包含 query 会导致每次 query 变化都创建新的缓存实例,积累的 deactivated 实例会同时响应路由变化形成循环
|
||||
key = _.trimEnd(this.$route.path, '/')
|
||||
} else if (this.$route.name.toLowerCase().includes('list')) {
|
||||
key = _.trimEnd(this.$route.path, '/') + '?' + new URLSearchParams(query).toString()
|
||||
} else {
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
:title="title"
|
||||
:nav="nav"
|
||||
:name="name"
|
||||
:charts="charts"
|
||||
:tables="tables"
|
||||
:show-display-mode-toggle="true"
|
||||
:display-mode.sync="displayMode"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div class="charts-grid">
|
||||
<ReportToolbar
|
||||
:filters="currentFilters"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<template v-if="showChart">
|
||||
<div class="chart-container full-width">
|
||||
<div class="chart-container-title">
|
||||
@@ -19,17 +26,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
:show-date-controls="false"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
|
||||
<div v-if="!isCustomReport" class="chart-container full-width">
|
||||
<div class="chart-container-title">
|
||||
<div class="chart-container-title-text">{{ $t('RiskyAccount') }}</div>
|
||||
@@ -76,17 +72,6 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<ReportToolbar
|
||||
v-if="tableData.length"
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
:show-date-controls="false"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap full-width">
|
||||
<el-card class="report-card" shadow="hover">
|
||||
<div v-if="t.name" class="chart-container-title">
|
||||
@@ -101,16 +86,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
:show-date-controls="false"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<el-table :data="tableData.rows" border>
|
||||
<el-table-column v-for="column in tableData.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
|
||||
</el-table>
|
||||
@@ -151,6 +126,16 @@ export default {
|
||||
return {
|
||||
title: this.$t('AccountAutomationReport'),
|
||||
name: 'AccountAutomationReport',
|
||||
charts: [
|
||||
{ name: 'Overview', title: this.$t('Overview') },
|
||||
{ name: 'TaskExecutionTrends', title: this.$t('TaskExecutionTrends') },
|
||||
{ name: 'AccountResult', title: this.$t('AccountResult') }
|
||||
],
|
||||
tables: [
|
||||
{ name: 'Overview', title: this.$t('Overview') },
|
||||
{ name: 'TaskExecutionTrends', title: this.$t('TaskExecutionTrends') },
|
||||
{ name: 'AccountResult', title: this.$t('AccountResult') }
|
||||
],
|
||||
description: '-',
|
||||
days: localStorage.getItem(this.name) || '7',
|
||||
automation_stats: {
|
||||
@@ -283,18 +268,10 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
days() {
|
||||
this.getData()
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.getData()
|
||||
},
|
||||
methods: {
|
||||
onChange(val) {
|
||||
this.days = val
|
||||
},
|
||||
async getData() {
|
||||
const data = await this.fetchReportData('/api/v1/reports/reports/account-automation/')
|
||||
await this.loadTableData('/api/v1/reports/reports/account-automation/')
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
:title="title"
|
||||
:nav="nav"
|
||||
:name="name"
|
||||
:charts="charts"
|
||||
:tables="tables"
|
||||
:show-display-mode-toggle="true"
|
||||
:display-mode.sync="displayMode"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div class="charts-grid">
|
||||
<ReportToolbar
|
||||
:filters="currentFilters"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<template v-if="showChart">
|
||||
<div class="chart-container full-width">
|
||||
<div class="chart-container-title">
|
||||
@@ -19,17 +26,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
:show-date-controls="false"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
|
||||
<div class="chart-container">
|
||||
<div class="chart-container-title">
|
||||
<div class="chart-container-title-text">{{ $t('AccountCreationSourceDistribution') }}</div>
|
||||
@@ -97,17 +93,6 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<ReportToolbar
|
||||
v-if="tableData.length"
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
:show-date-controls="false"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap full-width">
|
||||
<el-card class="report-card" shadow="hover">
|
||||
<div v-if="t.name" class="chart-container-title">
|
||||
@@ -128,16 +113,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
:show-date-controls="false"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<el-table :data="tableData.rows" border>
|
||||
<el-table-column
|
||||
v-for="column in tableData.columns"
|
||||
@@ -183,6 +158,22 @@ export default {
|
||||
return {
|
||||
title: this.$t('AccountStatisticsReport'),
|
||||
name: 'AccountStatistics',
|
||||
charts: [
|
||||
{ name: 'Overview', title: this.$t('Overview') },
|
||||
{ name: 'AccountCreationSourceDistribution', title: this.$t('AccountCreationSourceDistribution') },
|
||||
{ name: 'AccountConnectivityStatusDistribution', title: this.$t('AccountConnectivityStatusDistribution') },
|
||||
{ name: 'AccountPasswordChangeTrends', title: this.$t('AccountPasswordChangeTrends') },
|
||||
{ name: 'RankByNumberOfAssetAccounts', title: this.$t('RankByNumberOfAssetAccounts') },
|
||||
{ name: 'AccountAndPasswordChangeRank', title: this.$t('AccountAndPasswordChangeRank') }
|
||||
],
|
||||
tables: [
|
||||
{ name: 'Overview', title: this.$t('Overview') },
|
||||
{ name: 'AccountCreationSourceDistribution', title: this.$t('AccountCreationSourceDistribution') },
|
||||
{ name: 'AccountConnectivityStatusDistribution', title: this.$t('AccountConnectivityStatusDistribution') },
|
||||
{ name: 'AccountPasswordChangeTrends', title: this.$t('AccountPasswordChangeTrends') },
|
||||
{ name: 'RankByNumberOfAssetAccounts', title: this.$t('RankByNumberOfAssetAccounts') },
|
||||
{ name: 'AccountAndPasswordChangeRank', title: this.$t('AccountAndPasswordChangeRank') }
|
||||
],
|
||||
days: '30',
|
||||
account_stats: {
|
||||
'total': 0,
|
||||
|
||||
@@ -38,14 +38,16 @@
|
||||
|
||||
<script>
|
||||
import Page from '@/layout/components/Page'
|
||||
import { resolveRoute } from '@/utils/vue/index'
|
||||
import { appendQuery, isSameReportQuery } from '@/views/reports/base/reportUtils'
|
||||
import AccountStatistics from '@/views/reports/accounts/AccountStatistics.vue'
|
||||
import AccountAutomation from '@/views/reports/accounts/AccountAutomation.vue'
|
||||
import { appendQuery, buildCustomReportRouteQuery, reportDebugLog } from '@/views/reports/base/reportUtils'
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{
|
||||
key: 'AccountStatistics',
|
||||
titleKey: 'AccountStatisticsReport',
|
||||
routeName: 'AccountStatistics',
|
||||
component: AccountStatistics,
|
||||
path: '/reports/accounts/account-statistics',
|
||||
icon: 'fa fa-users',
|
||||
perm: 'rbac.view_accountstatisticsreport',
|
||||
reportType: 'AccountStatistics'
|
||||
@@ -53,7 +55,8 @@ const MENU_ITEMS = [
|
||||
{
|
||||
key: 'AccountAutomationReport',
|
||||
titleKey: 'AccountAutomationReport',
|
||||
routeName: 'AccountAutomationReport',
|
||||
component: AccountAutomation,
|
||||
path: '/reports/accounts/account-automation',
|
||||
icon: 'fa fa-cogs',
|
||||
perm: 'rbac.view_accountautomationreport',
|
||||
reportType: 'AccountAutomationReport'
|
||||
@@ -72,17 +75,36 @@ export default {
|
||||
component: '',
|
||||
componentKey: '',
|
||||
selectedChartKey: '',
|
||||
chartItems: []
|
||||
chartItems: [],
|
||||
catalogLoaded: false,
|
||||
lastSyncQueryKey: '',
|
||||
isPageActive: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.fullPath'() {
|
||||
if (!this.isPageActive) return
|
||||
const routeKey = this.buildRouteSyncKey(this.$route.query)
|
||||
if (routeKey === this.lastSyncQueryKey) return
|
||||
this.lastSyncQueryKey = routeKey
|
||||
reportDebugLog('accounts.index.route.fullPath', {
|
||||
routePath: this.$route.path,
|
||||
query: this.$route.query,
|
||||
selectedChartKey: this.selectedChartKey
|
||||
})
|
||||
this.syncSelectedFromRoute()
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.loadCatalog()
|
||||
},
|
||||
activated() {
|
||||
this.isPageActive = true
|
||||
this.syncSelectedFromRoute()
|
||||
},
|
||||
deactivated() {
|
||||
this.isPageActive = false
|
||||
},
|
||||
methods: {
|
||||
getBaseItems() {
|
||||
return MENU_ITEMS
|
||||
@@ -90,15 +112,20 @@ export default {
|
||||
.map(item => ({
|
||||
key: item.key,
|
||||
title: this.$t(item.titleKey),
|
||||
routeName: item.routeName,
|
||||
component: item.component,
|
||||
path: item.path,
|
||||
icon: item.icon,
|
||||
isCustom: false,
|
||||
reportType: item.reportType,
|
||||
query: {},
|
||||
query: {
|
||||
chart_key: item.key
|
||||
},
|
||||
children: []
|
||||
}))
|
||||
},
|
||||
async loadCatalog() {
|
||||
reportDebugLog('accounts.index.loadCatalog.start', { routePath: this.$route.path, query: this.$route.query })
|
||||
this.catalogLoaded = false
|
||||
const items = this.getBaseItems()
|
||||
const itemMap = items.reduce((acc, item) => {
|
||||
if (item.reportType) {
|
||||
@@ -116,16 +143,24 @@ export default {
|
||||
target.children = (group.children || []).map(child => ({
|
||||
key: `report-${child.id}`,
|
||||
title: child.name,
|
||||
routeName: target.routeName,
|
||||
reportId: child.id,
|
||||
component: target.component,
|
||||
path: target.path,
|
||||
reportId: String(child.id),
|
||||
isCustom: true,
|
||||
query: { report_id: child.id }
|
||||
query: {
|
||||
chart_key: target.key,
|
||||
...buildCustomReportRouteQuery(child)
|
||||
}
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('load report catalog failed', error)
|
||||
}
|
||||
this.chartItems = items
|
||||
this.catalogLoaded = true
|
||||
reportDebugLog('accounts.index.loadCatalog.done', {
|
||||
items: items.map(item => ({ key: item.key, childCount: (item.children || []).length }))
|
||||
})
|
||||
this.syncSelectedFromRoute()
|
||||
},
|
||||
syncSelectedFromRoute() {
|
||||
@@ -135,32 +170,75 @@ export default {
|
||||
if (reportId) {
|
||||
target = this.chartItems
|
||||
.flatMap(item => item.children || [])
|
||||
.find(item => item.reportId === reportId)
|
||||
.find(item => String(item.reportId) === String(reportId))
|
||||
if (!target) {
|
||||
this.loadCatalog()
|
||||
if (!this.catalogLoaded) {
|
||||
return
|
||||
}
|
||||
const nextQuery = { ...(this.$route.query || {}) }
|
||||
delete nextQuery.report_id
|
||||
this.$router.replace({ path: this.$route.path, query: nextQuery })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!target) {
|
||||
target = this.chartItems[0]
|
||||
const chartKey = this.$route.query.chart_key
|
||||
target = this.chartItems.find(item => item.key === chartKey)
|
||||
|| this.chartItems.find(item => item.key === this.selectedChartKey)
|
||||
|| this.chartItems[0]
|
||||
}
|
||||
if (target) {
|
||||
if (target && (this.selectedChartKey !== target.key || !this.component)) {
|
||||
this.applyChart(target)
|
||||
}
|
||||
reportDebugLog('accounts.index.syncSelectedFromRoute', {
|
||||
reportId,
|
||||
selectedChartKey: this.selectedChartKey,
|
||||
targetKey: target?.key || ''
|
||||
})
|
||||
},
|
||||
buildRouteSyncKey(query = {}) {
|
||||
return JSON.stringify({
|
||||
report_id: query.report_id || '',
|
||||
chart_key: query.chart_key || '',
|
||||
days: query.days || ''
|
||||
})
|
||||
},
|
||||
isActive(item) {
|
||||
return this.selectedChartKey === item.key
|
||||
},
|
||||
applyChart(chart) {
|
||||
this.selectedChartKey = chart.key
|
||||
const route = resolveRoute({ name: chart.routeName }, this.$router)
|
||||
this.component = route.components.default
|
||||
this.componentKey = `${chart.key}-${this.$route.fullPath}`
|
||||
this.url = appendQuery('/ui/#' + route.path, chart.query || {})
|
||||
if (!chart.component || !chart.path) {
|
||||
return
|
||||
}
|
||||
this.component = chart.component
|
||||
this.componentKey = chart.key
|
||||
this.url = appendQuery('/ui/#' + chart.path, chart.query || {})
|
||||
reportDebugLog('accounts.index.applyChart', {
|
||||
chartKey: chart.key,
|
||||
reportId: chart.reportId || '',
|
||||
url: this.url,
|
||||
routePath: this.$route.path,
|
||||
query: this.$route.query
|
||||
})
|
||||
},
|
||||
handleChangeChart(chart) {
|
||||
const nextQuery = chart.query || {}
|
||||
if (isSameReportQuery(this.$route.query, nextQuery)) {
|
||||
const nextQuery = {
|
||||
...(this.$route.query.days ? { days: this.$route.query.days } : {}),
|
||||
...(chart.query || {})
|
||||
}
|
||||
reportDebugLog('accounts.index.handleChangeChart', {
|
||||
chartKey: chart.key,
|
||||
reportId: chart.reportId || '',
|
||||
nextQuery,
|
||||
currentQuery: this.$route.query
|
||||
})
|
||||
const normalize = (query = {}) => ({
|
||||
days: query.days || '',
|
||||
report_id: query.report_id || '',
|
||||
chart_key: query.chart_key || ''
|
||||
})
|
||||
if (JSON.stringify(normalize(this.$route.query)) === JSON.stringify(normalize(nextQuery))) {
|
||||
this.applyChart(chart)
|
||||
return
|
||||
}
|
||||
@@ -231,7 +309,7 @@ h5 {
|
||||
}
|
||||
}
|
||||
|
||||
.folder-list li.active menu-link {
|
||||
.folder-list li.active > .menu-link {
|
||||
color: var(--color-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
:title="title"
|
||||
:nav="nav"
|
||||
:name="name"
|
||||
:charts="charts"
|
||||
:tables="tables"
|
||||
:show-display-mode-toggle="true"
|
||||
:display-mode.sync="displayMode"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div class="charts-grid">
|
||||
<ReportToolbar
|
||||
:filters="currentFilters"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<template v-if="showChart">
|
||||
<div class="chart-container full-width">
|
||||
<div class="chart-container-title">
|
||||
@@ -19,16 +26,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
|
||||
<div class="chart-container">
|
||||
<div class="chart-container-title">
|
||||
<UserAssetActivity :days="days" :metrics="user_asset_activity_metrics" />
|
||||
@@ -98,16 +95,6 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<ReportToolbar
|
||||
v-if="tableData.length"
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap">
|
||||
<el-card class="report-card" shadow="hover">
|
||||
<div v-if="t.name" class="chart-container-title">
|
||||
@@ -122,15 +109,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<el-table :data="tableData.rows" border>
|
||||
<el-table-column v-for="column in tableData.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
|
||||
</el-table>
|
||||
@@ -170,6 +148,22 @@ export default {
|
||||
return {
|
||||
title: this.$t('AssetActivityReport'),
|
||||
name: 'AssetReport',
|
||||
charts: [
|
||||
{ name: 'Overview', title: this.$t('Overview') },
|
||||
{ name: 'UserAssetActivity', title: this.$t('UserAssetActivity') },
|
||||
{ name: 'DistributionOfAssetLoginMethods', title: this.$t('DistributionOfAssetLoginMethods') },
|
||||
{ name: 'RemoteLoginProtocolUsageDistribution', title: this.$t('RemoteLoginProtocolUsageDistribution') },
|
||||
{ name: 'OperatingSystemDistributionOfLoginAssets', title: this.$t('OperatingSystemDistributionOfLoginAssets') },
|
||||
{ name: 'AssetLoginTrends', title: this.$t('AssetLoginTrends') }
|
||||
],
|
||||
tables: [
|
||||
{ name: 'Overview', title: this.$t('Overview') },
|
||||
{ name: 'UserAssetActivity', title: this.$t('UserAssetActivity') },
|
||||
{ name: 'DistributionOfAssetLoginMethods', title: this.$t('DistributionOfAssetLoginMethods') },
|
||||
{ name: 'RemoteLoginProtocolUsageDistribution', title: this.$t('RemoteLoginProtocolUsageDistribution') },
|
||||
{ name: 'OperatingSystemDistributionOfLoginAssets', title: this.$t('OperatingSystemDistributionOfLoginAssets') },
|
||||
{ name: 'AssetLoginTrends', title: this.$t('AssetLoginTrends') }
|
||||
],
|
||||
days: localStorage.getItem(this.name) || '7',
|
||||
session_stats: {
|
||||
'total': 0,
|
||||
@@ -431,18 +425,10 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
days() {
|
||||
this.getData()
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.getData()
|
||||
},
|
||||
methods: {
|
||||
onChange(val) {
|
||||
this.days = val
|
||||
},
|
||||
conversionData(data) {
|
||||
return data.map(item => {
|
||||
return {
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
:title="title"
|
||||
:nav="nav"
|
||||
:name="name"
|
||||
:charts="charts"
|
||||
:tables="tables"
|
||||
:show-display-mode-toggle="true"
|
||||
:display-mode.sync="displayMode"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div class="charts-grid">
|
||||
<ReportToolbar
|
||||
:filters="currentFilters"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<template v-if="showChart">
|
||||
<div class="chart-container full-width">
|
||||
<div class="chart-container-title">
|
||||
@@ -19,17 +26,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
:show-date-controls="false"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
|
||||
<div class="chart-container full-width">
|
||||
<div class="chart-container-title">
|
||||
<div class="chart-container-title-text">{{ $t('AssetTypeDistribution') }}</div>
|
||||
@@ -74,17 +70,6 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<ReportToolbar
|
||||
v-if="tableData.length"
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
:show-date-controls="false"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap full-width">
|
||||
<el-card class="report-card" shadow="hover">
|
||||
<div v-if="t.name" class="chart-container-title">
|
||||
@@ -105,16 +90,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
:show-date-controls="false"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<el-table :data="tableData.rows" border>
|
||||
<el-table-column
|
||||
v-for="column in tableData.columns"
|
||||
@@ -158,6 +133,16 @@ export default {
|
||||
return {
|
||||
title: this.$t('AssetStatisticsReport'),
|
||||
name: 'AssetStatistics',
|
||||
charts: [
|
||||
{ name: 'Overview', title: this.$t('Overview') },
|
||||
{ name: 'AssetTypeDistribution', title: this.$t('AssetTypeDistribution') },
|
||||
{ name: 'WeeklyGrowthTrend', title: this.$t('WeeklyGrowthTrend') }
|
||||
],
|
||||
tables: [
|
||||
{ name: 'Overview', title: this.$t('Overview') },
|
||||
{ name: 'AssetTypeDistribution', title: this.$t('AssetTypeDistribution') },
|
||||
{ name: 'WeeklyGrowthTrend', title: this.$t('WeeklyGrowthTrend') }
|
||||
],
|
||||
days: '7',
|
||||
asset_stats: {
|
||||
'total': 0,
|
||||
|
||||
@@ -38,14 +38,16 @@
|
||||
|
||||
<script>
|
||||
import Page from '@/layout/components/Page'
|
||||
import { resolveRoute } from '@/utils/vue/index'
|
||||
import { appendQuery, isSameReportQuery } from '@/views/reports/base/reportUtils'
|
||||
import AssetActivity from '@/views/reports/assets/AssetActivity.vue'
|
||||
import AssetStatistics from '@/views/reports/assets/AssetStatistics.vue'
|
||||
import { appendQuery, buildCustomReportRouteQuery, reportDebugLog } from '@/views/reports/base/reportUtils'
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{
|
||||
key: 'AssetStatistics',
|
||||
titleKey: 'AssetStatisticsReport',
|
||||
routeName: 'AssetStatistics',
|
||||
component: AssetStatistics,
|
||||
path: '/reports/assets/asset-statistics',
|
||||
icon: 'fa fa-database',
|
||||
perm: 'rbac.view_assetstatisticsreport',
|
||||
reportType: 'AssetStatistics'
|
||||
@@ -53,7 +55,8 @@ const MENU_ITEMS = [
|
||||
{
|
||||
key: 'AssetReport',
|
||||
titleKey: 'AssetActivityReport',
|
||||
routeName: 'AssetReport',
|
||||
component: AssetActivity,
|
||||
path: '/reports/assets/asset-activity',
|
||||
icon: 'fa fa-exchange',
|
||||
perm: 'rbac.view_assetactivityreport',
|
||||
reportType: 'AssetReport'
|
||||
@@ -72,17 +75,36 @@ export default {
|
||||
component: '',
|
||||
componentKey: '',
|
||||
selectedChartKey: '',
|
||||
chartItems: []
|
||||
chartItems: [],
|
||||
catalogLoaded: false,
|
||||
lastSyncQueryKey: '',
|
||||
isPageActive: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.fullPath'() {
|
||||
if (!this.isPageActive) return
|
||||
const routeKey = this.buildRouteSyncKey(this.$route.query)
|
||||
if (routeKey === this.lastSyncQueryKey) return
|
||||
this.lastSyncQueryKey = routeKey
|
||||
reportDebugLog('assets.index.route.fullPath', {
|
||||
routePath: this.$route.path,
|
||||
query: this.$route.query,
|
||||
selectedChartKey: this.selectedChartKey
|
||||
})
|
||||
this.syncSelectedFromRoute()
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.loadCatalog()
|
||||
},
|
||||
activated() {
|
||||
this.isPageActive = true
|
||||
this.syncSelectedFromRoute()
|
||||
},
|
||||
deactivated() {
|
||||
this.isPageActive = false
|
||||
},
|
||||
methods: {
|
||||
getBaseItems() {
|
||||
return MENU_ITEMS
|
||||
@@ -90,15 +112,20 @@ export default {
|
||||
.map(item => ({
|
||||
key: item.key,
|
||||
title: this.$t(item.titleKey),
|
||||
routeName: item.routeName,
|
||||
component: item.component,
|
||||
path: item.path,
|
||||
icon: item.icon,
|
||||
isCustom: false,
|
||||
reportType: item.reportType,
|
||||
query: {},
|
||||
query: {
|
||||
chart_key: item.key
|
||||
},
|
||||
children: []
|
||||
}))
|
||||
},
|
||||
async loadCatalog() {
|
||||
reportDebugLog('assets.index.loadCatalog.start', { routePath: this.$route.path, query: this.$route.query })
|
||||
this.catalogLoaded = false
|
||||
const items = this.getBaseItems()
|
||||
const itemMap = items.reduce((acc, item) => {
|
||||
if (item.reportType) {
|
||||
@@ -116,16 +143,24 @@ export default {
|
||||
target.children = (group.children || []).map(child => ({
|
||||
key: `report-${child.id}`,
|
||||
title: child.name,
|
||||
routeName: target.routeName,
|
||||
reportId: child.id,
|
||||
component: target.component,
|
||||
path: target.path,
|
||||
reportId: String(child.id),
|
||||
isCustom: true,
|
||||
query: { report_id: child.id }
|
||||
query: {
|
||||
chart_key: target.key,
|
||||
...buildCustomReportRouteQuery(child)
|
||||
}
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('load report catalog failed', error)
|
||||
}
|
||||
this.chartItems = items
|
||||
this.catalogLoaded = true
|
||||
reportDebugLog('assets.index.loadCatalog.done', {
|
||||
items: items.map(item => ({ key: item.key, childCount: (item.children || []).length }))
|
||||
})
|
||||
this.syncSelectedFromRoute()
|
||||
},
|
||||
syncSelectedFromRoute() {
|
||||
@@ -135,32 +170,75 @@ export default {
|
||||
if (reportId) {
|
||||
target = this.chartItems
|
||||
.flatMap(item => item.children || [])
|
||||
.find(item => item.reportId === reportId)
|
||||
.find(item => String(item.reportId) === String(reportId))
|
||||
if (!target) {
|
||||
this.loadCatalog()
|
||||
if (!this.catalogLoaded) {
|
||||
return
|
||||
}
|
||||
const nextQuery = { ...(this.$route.query || {}) }
|
||||
delete nextQuery.report_id
|
||||
this.$router.replace({ path: this.$route.path, query: nextQuery })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!target) {
|
||||
target = this.chartItems[0]
|
||||
const chartKey = this.$route.query.chart_key
|
||||
target = this.chartItems.find(item => item.key === chartKey)
|
||||
|| this.chartItems.find(item => item.key === this.selectedChartKey)
|
||||
|| this.chartItems[0]
|
||||
}
|
||||
if (target) {
|
||||
if (target && (this.selectedChartKey !== target.key || !this.component)) {
|
||||
this.applyChart(target)
|
||||
}
|
||||
reportDebugLog('assets.index.syncSelectedFromRoute', {
|
||||
reportId,
|
||||
selectedChartKey: this.selectedChartKey,
|
||||
targetKey: target?.key || ''
|
||||
})
|
||||
},
|
||||
isActive(item) {
|
||||
return this.selectedChartKey === item.key
|
||||
},
|
||||
applyChart(chart) {
|
||||
this.selectedChartKey = chart.key
|
||||
const route = resolveRoute({ name: chart.routeName }, this.$router)
|
||||
this.component = route.components.default
|
||||
this.componentKey = `${chart.key}-${this.$route.fullPath}`
|
||||
this.url = appendQuery('/ui/#' + route.path, chart.query || {})
|
||||
if (!chart.component || !chart.path) {
|
||||
return
|
||||
}
|
||||
this.component = chart.component
|
||||
this.componentKey = chart.key
|
||||
this.url = appendQuery('/ui/#' + chart.path, chart.query || {})
|
||||
reportDebugLog('assets.index.applyChart', {
|
||||
chartKey: chart.key,
|
||||
reportId: chart.reportId || '',
|
||||
url: this.url,
|
||||
routePath: this.$route.path,
|
||||
query: this.$route.query
|
||||
})
|
||||
},
|
||||
buildRouteSyncKey(query = {}) {
|
||||
return JSON.stringify({
|
||||
report_id: query.report_id || '',
|
||||
chart_key: query.chart_key || '',
|
||||
days: query.days || ''
|
||||
})
|
||||
},
|
||||
handleChangeChart(chart) {
|
||||
const nextQuery = chart.query || {}
|
||||
if (isSameReportQuery(this.$route.query, nextQuery)) {
|
||||
const nextQuery = {
|
||||
...(this.$route.query.days ? { days: this.$route.query.days } : {}),
|
||||
...(chart.query || {})
|
||||
}
|
||||
reportDebugLog('assets.index.handleChangeChart', {
|
||||
chartKey: chart.key,
|
||||
reportId: chart.reportId || '',
|
||||
nextQuery,
|
||||
currentQuery: this.$route.query
|
||||
})
|
||||
const normalize = (query = {}) => ({
|
||||
days: query.days || '',
|
||||
report_id: query.report_id || '',
|
||||
chart_key: query.chart_key || ''
|
||||
})
|
||||
if (JSON.stringify(normalize(this.$route.query)) === JSON.stringify(normalize(nextQuery))) {
|
||||
this.applyChart(chart)
|
||||
return
|
||||
}
|
||||
@@ -231,7 +309,7 @@ h5 {
|
||||
}
|
||||
}
|
||||
|
||||
.folder-list li.active .menu-link {
|
||||
.folder-list li.active > .menu-link {
|
||||
color: var(--color-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
<Logo />
|
||||
</div>
|
||||
<RightAction
|
||||
:chart-options="chartOptions"
|
||||
:name="name"
|
||||
:selected-chart-names="selectedChartNames"
|
||||
:selected-table-names="selectedTableNames"
|
||||
:table-options="tableOptions"
|
||||
:title="title"
|
||||
:show-operation-dropdown="!isCustomReportPage"
|
||||
:force-default-actions="nav && isCustomReportPage"
|
||||
@@ -21,7 +25,7 @@
|
||||
[{{ new Date().toLocaleString() }}]
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="customizeMode" class="report-visibility-panel">
|
||||
<div v-if="customizeMode || isCustomReportPage" class="report-visibility-panel">
|
||||
<div class="report-visibility-row">
|
||||
<el-checkbox :value="isDisplayModeEnabled('chart')" @change="handleModeToggle('chart', $event)">
|
||||
{{ $t('ChartReport') }}
|
||||
@@ -61,12 +65,16 @@
|
||||
</div>
|
||||
<div v-if="!nav" class="title-right">
|
||||
<RightAction
|
||||
:chart-options="chartOptions"
|
||||
:name="name"
|
||||
:title="title"
|
||||
:editor-only="true"
|
||||
:selected-chart-names="selectedChartNames"
|
||||
:selected-table-names="selectedTableNames"
|
||||
:show-editor-button="false"
|
||||
:show-custom-actions-in-editor="isCustomReportPage"
|
||||
:show-operation-only-in-editor="true"
|
||||
:table-options="tableOptions"
|
||||
/>
|
||||
<span v-if="url && showReportExportBtn" class="export-btn inline-export-btn">
|
||||
<el-button type="text" @click="openNewWindow">
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('TimeRange')" prop="range_preset">
|
||||
<el-select v-model="form.range_preset" style="width: 100%">
|
||||
<el-form-item :label="$t('TimeRange')" prop="days">
|
||||
<el-select v-model="form.days" style="width: 100%">
|
||||
<el-option
|
||||
v-for="option in presetOptions"
|
||||
:key="option.value"
|
||||
@@ -27,58 +27,36 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.range_preset === 'custom'" :label="$t('StartAndEnd')" prop="date_range">
|
||||
<el-date-picker
|
||||
v-model="form.date_range"
|
||||
end-placeholder="结束日期"
|
||||
start-placeholder="开始日期"
|
||||
style="width: 100%"
|
||||
type="daterange"
|
||||
value-format="yyyy-MM-dd"
|
||||
/>
|
||||
<el-form-item :label="$t('ChartReport')" prop="visibleCharts">
|
||||
<el-checkbox-group v-model="form.visibleCharts">
|
||||
<el-checkbox
|
||||
v-for="item in normalizedChartOptions"
|
||||
:key="item.name"
|
||||
:label="item.name"
|
||||
>
|
||||
{{ item.title }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="filterField" :label="filterLabel">
|
||||
<Select2 v-model="form.filter_value" v-bind="filterSelect" />
|
||||
<el-form-item :label="$t('TableDetails')" prop="visibleTables">
|
||||
<el-checkbox-group v-model="form.visibleTables">
|
||||
<el-checkbox
|
||||
v-for="item in normalizedTableOptions"
|
||||
:key="item.name"
|
||||
:label="item.name"
|
||||
>
|
||||
{{ item.title }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('ReportPeriodicExecution')">
|
||||
<el-switch v-model="form.is_periodic" />
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="form.is_periodic">
|
||||
<el-form-item :label="$t('Interval')" prop="interval">
|
||||
<el-input-number v-model="form.interval" :min="1" :precision="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('Crontab')">
|
||||
<CronTab v-model="form.crontab" />
|
||||
<div class="form-help-text">{{ $t('ReportSchedulePriorityTip') }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('ReportRecipientsLabel')" prop="recipients">
|
||||
<Select2 v-model="form.recipients" v-bind="recipientSelect" />
|
||||
<div class="form-help-text">{{ $t('ReportRecipientsTip') }}</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Dialog from '@/components/Dialog'
|
||||
import Select2 from '@/components/Form/FormFields/Select2.vue'
|
||||
import CronTab from '@/components/Form/FormFields/CronTab/index.vue'
|
||||
import { REPORT_PRESET_DAYS_MAP, REPORT_RANGE_PRESET_OPTIONS } from './reportUtils'
|
||||
|
||||
const FILTER_FIELD_MAP = {
|
||||
UserLoginReport: 'user_id',
|
||||
UserChangePasswordReport: 'user_id',
|
||||
AssetStatistics: 'asset_id',
|
||||
AssetReport: 'asset_id',
|
||||
AccountStatistics: 'account',
|
||||
AccountAutomationReport: 'account'
|
||||
}
|
||||
import { REPORT_RANGE_PRESET_OPTIONS, normalizeReportDays } from './reportUtils'
|
||||
|
||||
function getDefaultName(title) {
|
||||
const now = new Date()
|
||||
@@ -86,22 +64,10 @@ function getDefaultName(title) {
|
||||
return `${title}-${date}`
|
||||
}
|
||||
|
||||
function normalizeRecipients(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
if (value && Array.isArray(value.ids)) {
|
||||
return value.ids
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'CreateReportDialog',
|
||||
components: {
|
||||
CronTab,
|
||||
Dialog,
|
||||
Select2
|
||||
Dialog
|
||||
},
|
||||
props: {
|
||||
visible: {
|
||||
@@ -123,52 +89,33 @@ export default {
|
||||
defaultDays: {
|
||||
type: [String, Number],
|
||||
default: '7'
|
||||
},
|
||||
chartOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tableOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
defaultVisibleCharts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
defaultVisibleTables: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
const validateCustomRange = (rule, value, callback) => {
|
||||
if (this.form.range_preset !== 'custom') {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!Array.isArray(value) || value.length !== 2 || !value[0] || !value[1]) {
|
||||
callback(new Error(this.$t('SelectStartAndEndDate')))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
const validateRecipients = (rule, value, callback) => {
|
||||
if (!this.form.is_periodic) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
callback(new Error(this.$t('PleaseSelectRecipients')))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
const validateSchedule = (rule, value, callback) => {
|
||||
if (!this.form.is_periodic) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!this.form.interval && !this.form.crontab) {
|
||||
callback(new Error(this.$t('RequireIntervalOrCrontabSetting')))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
return {
|
||||
submitting: false,
|
||||
form: this.getInitialForm(),
|
||||
presetOptions: REPORT_RANGE_PRESET_OPTIONS,
|
||||
rules: {
|
||||
name: [{ required: true, message: this.$t('ThisFieldIsRequired'), trigger: 'blur' }],
|
||||
range_preset: [{ required: true, message: this.$t('PleaseSelectTimeRange'), trigger: 'change' }],
|
||||
date_range: [{ validator: validateCustomRange, trigger: 'change' }],
|
||||
interval: [{ validator: validateSchedule, trigger: 'change' }],
|
||||
recipients: [{ validator: validateRecipients, trigger: 'change' }]
|
||||
visibleCharts: [{ validator: this.validateVisibleReports, trigger: 'change' }],
|
||||
visibleTables: [{ validator: this.validateVisibleReports, trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -181,62 +128,14 @@ export default {
|
||||
this.$emit('update:visible', val)
|
||||
}
|
||||
},
|
||||
filterField() {
|
||||
return FILTER_FIELD_MAP[this.reportType] || ''
|
||||
},
|
||||
isEdit() {
|
||||
return !!this.report?.id
|
||||
},
|
||||
filterLabel() {
|
||||
return {
|
||||
user_id: this.$t('UserFilterLabel'),
|
||||
asset_id: this.$t('AssetFilterLabel'),
|
||||
account: this.$t('AccountFilterLabel')
|
||||
}[this.filterField] || ''
|
||||
normalizedChartOptions() {
|
||||
return this.normalizeOptions(this.chartOptions)
|
||||
},
|
||||
recipientSelect() {
|
||||
return {
|
||||
ajax: {
|
||||
url: '/api/v1/users/users/?fields_size=mini',
|
||||
transformOption: (item) => ({ label: `${item.name}(${item.username})`, value: item.id })
|
||||
}
|
||||
}
|
||||
},
|
||||
filterSelect() {
|
||||
if (this.filterField === 'user_id') {
|
||||
return {
|
||||
multiple: false,
|
||||
ajax: {
|
||||
url: '/api/v1/users/users/suggestions/',
|
||||
transformOption: (item) => ({ label: `${item.name}(${item.username})`, value: item.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.filterField === 'asset_id') {
|
||||
return {
|
||||
multiple: false,
|
||||
ajax: {
|
||||
url: '/api/v1/assets/assets/?fields_size=mini',
|
||||
transformOption: (item) => ({
|
||||
label: item.name || item.address || item.hostname || item.id,
|
||||
value: item.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.filterField === 'account') {
|
||||
return {
|
||||
multiple: false,
|
||||
ajax: {
|
||||
url: '/api/v1/accounts/accounts/?fields_size=mini',
|
||||
transformOption: (item) => ({
|
||||
label: item.asset ? `${item.username} @ ${item.asset.name}` : item.username,
|
||||
value: item.username
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
normalizedTableOptions() {
|
||||
return this.normalizeOptions(this.tableOptions)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -247,58 +146,61 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
normalizeDays(days) {
|
||||
return normalizeReportDays(days, '7')
|
||||
},
|
||||
normalizeOptions(items = []) {
|
||||
if (!Array.isArray(items)) {
|
||||
return []
|
||||
}
|
||||
return items
|
||||
.filter(item => item && item.name)
|
||||
.map(item => ({
|
||||
name: String(item.name),
|
||||
title: String(item.title || item.name)
|
||||
}))
|
||||
},
|
||||
normalizeSelection(raw, options = []) {
|
||||
const safeOptions = Array.isArray(options) ? options : []
|
||||
const optionNames = safeOptions.map(item => item.name)
|
||||
const selected = Array.isArray(raw)
|
||||
? raw.map(item => String(item).trim()).filter(Boolean)
|
||||
: []
|
||||
const filtered = selected.filter(name => optionNames.includes(name))
|
||||
return filtered.length ? filtered : optionNames
|
||||
},
|
||||
validateVisibleReports(rule, value, callback) {
|
||||
const total = (this.form.visibleCharts || []).length + (this.form.visibleTables || []).length
|
||||
if (total <= 0) {
|
||||
callback(new Error(this.$t('PleaseSelectAtLeastOneReportSection')))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
getInitialForm() {
|
||||
const report = this.report || {}
|
||||
const reportDays = this.normalizeDays(report.days || this.defaultDays || '7')
|
||||
const filters = report.filters || {}
|
||||
const preset = Object.entries(REPORT_PRESET_DAYS_MAP).find(([, days]) => String(days) === String(this.defaultDays))
|
||||
let filterValue = ''
|
||||
if (this.filterField) {
|
||||
// Only accept backend-provided user id options; ignore legacy username strings
|
||||
if (report._filter_user_options && report._filter_user_options.user_id && report._filter_user_options.user_id.length) {
|
||||
filterValue = report._filter_user_options.user_id[0].id
|
||||
} else if (filters[this.filterField]) {
|
||||
filterValue = filters[this.filterField]
|
||||
} else {
|
||||
filterValue = ''
|
||||
}
|
||||
}
|
||||
const chartOptions = this.normalizedChartOptions
|
||||
const tableOptions = this.normalizedTableOptions
|
||||
return {
|
||||
name: report.name || getDefaultName(this.reportTitle || this.reportType || 'report'),
|
||||
range_preset: filters.range_preset || (filters.start && filters.end ? 'custom' : (preset ? preset[0] : 'last_week')),
|
||||
date_range: filters.start && filters.end ? [filters.start, filters.end] : [],
|
||||
filter_value: filterValue,
|
||||
is_periodic: !!report.is_periodic,
|
||||
interval: report.interval || 24,
|
||||
crontab: report.crontab || '',
|
||||
recipients: normalizeRecipients(report.recipients)
|
||||
days: reportDays,
|
||||
visibleCharts: this.normalizeSelection(filters.visible_charts || this.defaultVisibleCharts, chartOptions),
|
||||
visibleTables: this.normalizeSelection(filters.visible_tables || this.defaultVisibleTables, tableOptions)
|
||||
}
|
||||
},
|
||||
getPayload() {
|
||||
const filters = {}
|
||||
let rangeDays = REPORT_PRESET_DAYS_MAP[this.form.range_preset] || parseInt(this.defaultDays) || 7
|
||||
if (this.form.range_preset === 'custom') {
|
||||
filters.start = this.form.date_range[0]
|
||||
filters.end = this.form.date_range[1]
|
||||
filters.range_preset = ''
|
||||
const start = new Date(`${filters.start}T00:00:00`)
|
||||
const end = new Date(`${filters.end}T00:00:00`)
|
||||
rangeDays = Math.max(1, Math.round((end - start) / 86400000) + 1)
|
||||
} else {
|
||||
filters.range_preset = this.form.range_preset
|
||||
}
|
||||
if (this.filterField && this.form.filter_value) {
|
||||
filters[this.filterField] = this.form.filter_value
|
||||
}
|
||||
const rangeDays = parseInt(this.normalizeDays(this.form.days), 10)
|
||||
return {
|
||||
name: this.form.name,
|
||||
tp: this.reportType,
|
||||
is_active: true,
|
||||
range_days: rangeDays,
|
||||
filters,
|
||||
is_periodic: this.form.is_periodic,
|
||||
interval: this.form.is_periodic ? this.form.interval : null,
|
||||
crontab: this.form.is_periodic ? (this.form.crontab || '') : '',
|
||||
recipients: this.form.recipients.length > 0 ? { type: 'ids', ids: this.form.recipients } : {}
|
||||
days: rangeDays,
|
||||
filters: {
|
||||
visible_charts: this.form.visibleCharts,
|
||||
visible_tables: this.form.visibleTables
|
||||
}
|
||||
}
|
||||
},
|
||||
handleClose() {
|
||||
@@ -325,12 +227,3 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-help-text {
|
||||
margin-top: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -1,220 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Drawer
|
||||
:title="`${reportName || $t('Report')} - ${$t('ExecutionRecords')}`"
|
||||
:visible.sync="iVisible"
|
||||
size="980px"
|
||||
@close-drawer="handleClose"
|
||||
>
|
||||
<div v-loading="loading" class="report-execution-drawer">
|
||||
<el-table :data="executions" border height="calc(100vh - 160px)">
|
||||
<el-table-column :label="$t('ID')" min-width="180" prop="id" />
|
||||
<el-table-column :label="$t('Status')" min-width="120" prop="status" />
|
||||
<el-table-column :label="$t('Trigger')" min-width="120" prop="trigger" />
|
||||
<el-table-column :label="$t('DateStart')" min-width="180" prop="date_start" />
|
||||
<el-table-column :label="$t('DateFinished')" min-width="180" prop="date_finished" />
|
||||
<el-table-column :label="$t('DurationSeconds')" min-width="100" prop="duration" />
|
||||
<el-table-column :label="$t('SendRecords')" min-width="100">
|
||||
<template slot-scope="{ row }">
|
||||
{{ row.send_record_count || 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Actions')" min-width="120" fixed="right">
|
||||
<template slot-scope="{ row }">
|
||||
<el-button size="mini" type="text" @click="openDetail(row)">
|
||||
{{ $t('Detail') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<Dialog
|
||||
v-if="detailVisible"
|
||||
:destroy-on-close="true"
|
||||
:show-confirm="false"
|
||||
:title="detailTitle"
|
||||
:visible.sync="detailVisible"
|
||||
top="8vh"
|
||||
width="900px"
|
||||
@cancel="detailVisible = false"
|
||||
>
|
||||
<div v-loading="detailLoading">
|
||||
<el-descriptions v-if="selectedExecution" :column="2" border class="detail-summary">
|
||||
<el-descriptions-item :label="$t('ID')">{{ selectedExecution.id }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('Status')">{{ selectedExecution.status }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('Trigger')">{{ selectedExecution.trigger }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('DurationSeconds')">{{ selectedExecution.duration || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('DateStart')">{{ selectedExecution.date_start || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('DateFinished')">{{ selectedExecution.date_finished || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table :data="sendRecords" border max-height="360">
|
||||
<el-table-column :label="$t('Receiver')" min-width="120" prop="receiver" />
|
||||
<el-table-column :label="$t('Backend')" min-width="120" prop="backend" />
|
||||
<el-table-column :label="$t('Result')" min-width="100">
|
||||
<template slot-scope="{ row }">
|
||||
<span :class="row.is_success ? 'text-primary' : 'text-danger'">
|
||||
{{ row.is_success ? $t('Success') : $t('Failed') }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('CreatedTime')" min-width="180" prop="date_created" />
|
||||
<el-table-column :label="$t('Actions')" min-width="100" fixed="right">
|
||||
<template slot-scope="{ row }">
|
||||
<el-button size="mini" type="text" @click="showRecordLog(row)">
|
||||
{{ $t('Log') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="selectedExecution && selectedExecution.summary" class="execution-summary">
|
||||
<h4>{{ $t('ExecutionSummary') }}</h4>
|
||||
<pre>{{ formatJson(selectedExecution.summary) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
v-if="logVisible"
|
||||
:destroy-on-close="true"
|
||||
:show-confirm="false"
|
||||
:title="$t('ExecutionLog')"
|
||||
:visible.sync="logVisible"
|
||||
top="15vh"
|
||||
width="760px"
|
||||
@cancel="logVisible = false"
|
||||
>
|
||||
<pre class="log-detail">{{ selectedLog }}</pre>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Dialog from '@/components/Dialog'
|
||||
import Drawer from '@/components/Drawer'
|
||||
|
||||
export default {
|
||||
name: 'ReportExecutionDrawer',
|
||||
components: {
|
||||
Dialog,
|
||||
Drawer
|
||||
},
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
reportId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
reportName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
detailLoading: false,
|
||||
detailVisible: false,
|
||||
logVisible: false,
|
||||
executions: [],
|
||||
sendRecords: [],
|
||||
selectedExecution: null,
|
||||
selectedLog: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
iVisible: {
|
||||
get() {
|
||||
return this.visible
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:visible', val)
|
||||
}
|
||||
},
|
||||
detailTitle() {
|
||||
return this.selectedExecution ? `${this.$t('ExecutionDetail')} - ${this.selectedExecution.id}` : this.$t('ExecutionDetail')
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (val) {
|
||||
this.loadExecutions()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadExecutions() {
|
||||
if (!this.reportId) {
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
try {
|
||||
const data = await this.$axios.get('/api/v1/reports/report-executions/', {
|
||||
params: {
|
||||
report: this.reportId
|
||||
}
|
||||
})
|
||||
this.executions = data.results || data
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async openDetail(row) {
|
||||
this.detailVisible = true
|
||||
this.detailLoading = true
|
||||
this.selectedExecution = row
|
||||
try {
|
||||
const data = await this.$axios.get(`/api/v1/reports/report-executions/${row.id}/`)
|
||||
this.selectedExecution = data
|
||||
this.sendRecords = data.send_records || []
|
||||
} finally {
|
||||
this.detailLoading = false
|
||||
}
|
||||
},
|
||||
showRecordLog(row) {
|
||||
this.selectedLog = row.detail || row.error || '-'
|
||||
this.logVisible = true
|
||||
},
|
||||
formatJson(value) {
|
||||
return JSON.stringify(value || {}, null, 2)
|
||||
},
|
||||
handleClose() {
|
||||
this.iVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.report-execution-drawer {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.execution-summary {
|
||||
margin-top: 16px;
|
||||
|
||||
pre {
|
||||
background: #f5f7fa;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.log-detail {
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
min-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,78 +1,35 @@
|
||||
<template>
|
||||
<div class="report-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<template v-if="showDateControls">
|
||||
<div class="toolbar-item">
|
||||
<span class="toolbar-label">{{ $t('TimeRange') }}</span>
|
||||
<el-select v-model="localRangePreset" size="mini" style="width: 130px" @change="emitChange">
|
||||
<el-option v-for="option in presetOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
|
||||
<DatetimeRangePicker
|
||||
v-if="localRangePreset === 'custom'"
|
||||
:date-start="localDateRange?.[0]"
|
||||
:date-end="localDateRange?.[1]"
|
||||
@dateChange="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="filterField" class="toolbar-item">
|
||||
<span class="toolbar-label">{{ filterLabel }}</span>
|
||||
<Select2
|
||||
v-model="localFilterValue"
|
||||
v-bind="filterSelect"
|
||||
style="width: 220px"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SwitchDate :name="storageKey" :days="localDays" @change="emitChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Select2 from '@/components/Form/FormFields/Select2.vue'
|
||||
import DatetimeRangePicker from '@/components/Form/FormFields/DatetimeRangePicker.vue'
|
||||
import { REPORT_RANGE_PRESET_OPTIONS } from './reportUtils'
|
||||
import SwitchDate from '@/components/Dashboard/SwitchDate.vue'
|
||||
|
||||
export default {
|
||||
name: 'ReportToolbar',
|
||||
components: {
|
||||
Select2,
|
||||
DatetimeRangePicker
|
||||
SwitchDate
|
||||
},
|
||||
props: {
|
||||
showDateControls: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isCustomReport: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
filterField: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
filterLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
filterSelect: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
reportName: {
|
||||
type: String,
|
||||
default: 'reportDays'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
presetOptions: REPORT_RANGE_PRESET_OPTIONS,
|
||||
localRangePreset: 'last_week',
|
||||
localDateRange: [],
|
||||
localFilterValue: ''
|
||||
localDays: '7'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
storageKey() {
|
||||
return this.reportName || this.$route?.name || 'reportDays'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -80,29 +37,14 @@ export default {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
handler(val) {
|
||||
this.localRangePreset = val.range_preset || (val.start && val.end ? 'custom' : 'last_week')
|
||||
this.localDateRange = val.start && val.end ? [new Date(val.start), new Date(val.end)] : []
|
||||
this.localFilterValue = val.filter_value || ''
|
||||
this.localDays = String(val.days || '7')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onDateChange(val) {
|
||||
this.localDateRange = val
|
||||
this.emitChange()
|
||||
},
|
||||
emitChange() {
|
||||
const payload = {
|
||||
filter_value: this.localFilterValue
|
||||
}
|
||||
if (this.showDateControls) {
|
||||
payload.range_preset = this.localRangePreset
|
||||
if (this.localRangePreset === 'custom') {
|
||||
payload.start = this.localDateRange?.[0] ? this.localDateRange[0].toISOString() : ''
|
||||
payload.end = this.localDateRange?.[1] ? this.localDateRange[1].toISOString() : ''
|
||||
}
|
||||
}
|
||||
this.$emit('filter-change', payload)
|
||||
emitChange(days) {
|
||||
this.localDays = String(days || '7')
|
||||
this.$emit('filter-change', { days: this.localDays })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,50 +53,8 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.report-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
|
||||
&.chart-container {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.toolbar-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.toolbar-label {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.el-select,
|
||||
.select2,
|
||||
.el-date-editor {
|
||||
min-height: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<i class="el-icon-arrow-down el-icon--right" />
|
||||
</span>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item command="history">{{ $t('ExecutionHistory') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canDeleteReport" command="delete" divided>{{ $t('Delete') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canSaveReport" command="edit">{{ $t('Edit') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canDeleteReport" :divided="canSaveReport" command="delete">{{ $t('Delete') }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
<template v-if="!showOperationOnlyInEditor || !editorOnly">
|
||||
@@ -45,10 +45,14 @@
|
||||
</el-button-group>
|
||||
|
||||
<CreateReportDialog
|
||||
:chart-options="chartOptions"
|
||||
:default-days="getDaysParam()"
|
||||
:default-visible-charts="selectedChartNames"
|
||||
:default-visible-tables="selectedTableNames"
|
||||
:report="editingReport"
|
||||
:report-title="title"
|
||||
:report-type="name"
|
||||
:table-options="tableOptions"
|
||||
:visible.sync="showCreateDialog"
|
||||
@created="handleCreated"
|
||||
/>
|
||||
@@ -58,20 +62,14 @@
|
||||
:report-query="$route.query"
|
||||
:visible.sync="showExportDialog"
|
||||
/>
|
||||
<ReportExecutionDrawer
|
||||
:report-id="reportId"
|
||||
:report-name="title"
|
||||
:visible.sync="showExecutionDrawer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { download } from '@/utils/common'
|
||||
import CreateReportDialog from './CreateReportDialog.vue'
|
||||
import ReportExecutionDrawer from './ReportExecutionDrawer.vue'
|
||||
import ReportExportDialog from './ReportExportDialog.vue'
|
||||
import { appendQuery, pickReportQuery } from './reportUtils'
|
||||
import { appendQuery, pickReportQuery, buildCustomReportRouteQuery, normalizeReportDays, fetchReportDetailShared } from './reportUtils'
|
||||
|
||||
const REPORT_ACTION_PERM_MAP = {
|
||||
UserLoginReport: {
|
||||
@@ -104,7 +102,6 @@ export default {
|
||||
name: 'RightAction',
|
||||
components: {
|
||||
CreateReportDialog,
|
||||
ReportExecutionDrawer,
|
||||
ReportExportDialog
|
||||
},
|
||||
props: {
|
||||
@@ -139,6 +136,22 @@ export default {
|
||||
forceDefaultActions: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
chartOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tableOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedChartNames: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedTableNames: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -146,7 +159,6 @@ export default {
|
||||
exportLoading: false,
|
||||
reportData: null,
|
||||
showCreateDialog: false,
|
||||
showExecutionDrawer: false,
|
||||
showExportDialog: false
|
||||
}
|
||||
},
|
||||
@@ -187,43 +199,26 @@ export default {
|
||||
},
|
||||
editingReport() {
|
||||
const query = this.$route.query || {}
|
||||
const filters = {}
|
||||
if (query.range_preset) {
|
||||
filters.range_preset = query.range_preset
|
||||
}
|
||||
if (query.start) {
|
||||
filters.start = query.start
|
||||
}
|
||||
if (query.end) {
|
||||
filters.end = query.end
|
||||
}
|
||||
const key = this.filterField
|
||||
const filterValue = key ? query[key] : ''
|
||||
if (key && filterValue) {
|
||||
filters[key] = filterValue
|
||||
}
|
||||
const reportDays = parseInt(normalizeReportDays(query.days || this.reportData?.days || this.getDaysParam(), '7'), 10)
|
||||
if (this.isCustomReport) {
|
||||
return {
|
||||
...(this.reportData || {}),
|
||||
days: reportDays,
|
||||
filters: {
|
||||
...(this.reportData?.filters || {}),
|
||||
...filters
|
||||
// 使用当前页面的勾选状态,而不是已保存的状态
|
||||
visible_charts: this.selectedChartNames,
|
||||
visible_tables: this.selectedTableNames
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
filters
|
||||
days: reportDays,
|
||||
filters: {
|
||||
visible_charts: this.selectedChartNames,
|
||||
visible_tables: this.selectedTableNames
|
||||
}
|
||||
}
|
||||
},
|
||||
filterField() {
|
||||
return {
|
||||
UserLoginReport: 'user_id',
|
||||
UserChangePasswordReport: 'user_id',
|
||||
AssetStatistics: 'asset_id',
|
||||
AssetReport: 'asset_id',
|
||||
AccountStatistics: 'account',
|
||||
AccountAutomationReport: 'account'
|
||||
}[this.name] || ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -240,7 +235,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async loadReportDetail() {
|
||||
this.reportData = await this.$axios.get(`/api/v1/reports/reports/${this.reportId}/`)
|
||||
this.reportData = await fetchReportDetailShared(this.$axios, this.reportId)
|
||||
},
|
||||
checkName() {
|
||||
if (!this.name) {
|
||||
@@ -250,7 +245,7 @@ export default {
|
||||
return true
|
||||
},
|
||||
getDaysParam() {
|
||||
return this.$route.query.days || localStorage.getItem(this.name) || '7'
|
||||
return normalizeReportDays(this.$route.query.days || localStorage.getItem(this.name), '7')
|
||||
},
|
||||
exportPdf() {
|
||||
if (!this.checkName()) {
|
||||
@@ -302,8 +297,8 @@ export default {
|
||||
this.showCreateDialog = true
|
||||
},
|
||||
handleCommand(command) {
|
||||
if (command === 'history') {
|
||||
this.showExecutionDrawer = true
|
||||
if (command === 'edit' && this.canSaveReport) {
|
||||
this.openEditor()
|
||||
return
|
||||
}
|
||||
if (command === 'delete' && this.canDeleteReport) {
|
||||
@@ -320,11 +315,15 @@ export default {
|
||||
this.$router.replace({ path: this.$route.path, query: {} })
|
||||
},
|
||||
handleCreated(report) {
|
||||
const query = {
|
||||
...buildCustomReportRouteQuery(report)
|
||||
}
|
||||
if (this.$route.query.chart_key) {
|
||||
query.chart_key = this.$route.query.chart_key
|
||||
}
|
||||
this.$router.push({
|
||||
path: this.$route.path,
|
||||
query: {
|
||||
report_id: report.id
|
||||
}
|
||||
query
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import { appendQuery, pickReportQuery } from './reportUtils'
|
||||
|
||||
const FILTER_FIELD_MAP = {
|
||||
UserLoginReport: 'user_id',
|
||||
UserChangePasswordReport: 'user_id',
|
||||
AssetReport: 'asset_id',
|
||||
AssetStatistics: 'asset_id',
|
||||
AccountStatistics: 'account',
|
||||
AccountAutomationReport: 'account'
|
||||
}
|
||||
import { appendQuery, normalizeReportDays, normalizeVisibleFilterList, pickReportQuery, reportDebugLog, fetchReportDetailShared } from './reportUtils'
|
||||
|
||||
const TABLE_LABEL_KEY_MAP = {
|
||||
user_stats: 'Overview',
|
||||
@@ -18,6 +9,9 @@ const TABLE_LABEL_KEY_MAP = {
|
||||
user_login_time_metrics: 'VisitTimeDistribution',
|
||||
session_stats: 'Overview',
|
||||
asset_login_log_metrics: 'AssetLoginTrends',
|
||||
asset_login_by_type: 'OperatingSystemDistributionOfLoginAssets',
|
||||
asset_login_by_from: 'DistributionOfAssetLoginMethods',
|
||||
asset_login_by_protocol: 'RemoteLoginProtocolUsageDistribution',
|
||||
user_asset_activity_metrics: 'UserAssetActivity',
|
||||
asset_stats: 'Overview',
|
||||
assets_by_type_category: 'AssetTypeDistribution',
|
||||
@@ -73,17 +67,44 @@ export default {
|
||||
reportDetail: null,
|
||||
displayMode: ['chart', 'table'],
|
||||
// now supports multiple tables: array of { name, columns, rows }
|
||||
tableData: []
|
||||
tableData: [],
|
||||
reportFetchInFlight: Object.create(null),
|
||||
reportFetchCache: Object.create(null),
|
||||
lastGetDataRouteKey: '',
|
||||
lastFetchedReportId: ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.fullPath'() {
|
||||
const routeKey = this.buildGetDataRouteKey(this.$route.query)
|
||||
if (routeKey === this.lastGetDataRouteKey) {
|
||||
return
|
||||
}
|
||||
// reportId 变了说明切换到了不同的报告,父组件会改变 :key 导致本组件被销毁并重建
|
||||
// 新组件的 mounted() 会负责初始化加载,此处不重复 getData
|
||||
if (this.reportId !== this.lastFetchedReportId) {
|
||||
this.lastGetDataRouteKey = routeKey
|
||||
this.lastFetchedReportId = this.reportId
|
||||
return
|
||||
}
|
||||
this.lastGetDataRouteKey = routeKey
|
||||
this.lastFetchedReportId = this.reportId
|
||||
reportDebugLog('mixin.route.fullPath', {
|
||||
name: this.name,
|
||||
routePath: this.$route.path,
|
||||
query: this.$route.query
|
||||
})
|
||||
this.reportDetail = null
|
||||
if (typeof this.getData === 'function') {
|
||||
this.getData()
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 预填当前路由 key,防止 mounted() 调用 getData 后,route watcher 对同一 key 重复触发
|
||||
this.lastGetDataRouteKey = this.buildGetDataRouteKey(this.$route.query)
|
||||
this.lastFetchedReportId = this.reportId
|
||||
},
|
||||
computed: {
|
||||
displayModes() {
|
||||
const modes = Array.isArray(this.displayMode) ? this.displayMode : [this.displayMode]
|
||||
@@ -107,27 +128,48 @@ export default {
|
||||
reportTitle() {
|
||||
return this.reportDetail?.name || this.title
|
||||
},
|
||||
filterField() {
|
||||
return FILTER_FIELD_MAP[this.name] || ''
|
||||
},
|
||||
filterLabel() {
|
||||
return {
|
||||
user_id: this.$t('UserFilterLabel'),
|
||||
asset_id: this.$t('AssetFilterLabel'),
|
||||
account: this.$t('AccountFilterLabel')
|
||||
}[this.filterField] || ''
|
||||
},
|
||||
currentFilters() {
|
||||
const reportFilters = this.reportDetail?.filters || {}
|
||||
const reportDays = this.reportDetail?.days
|
||||
const fallbackDays = this.days || reportDays || 7
|
||||
return {
|
||||
range_preset: this.$route.query.range_preset || reportFilters.range_preset || '',
|
||||
start: this.$route.query.start || reportFilters.start || '',
|
||||
end: this.$route.query.end || reportFilters.end || '',
|
||||
filter_value: this.filterField ? (this.$route.query[this.filterField] || reportFilters[this.filterField] || '') : ''
|
||||
days: normalizeReportDays(this.$route.query.days || fallbackDays, '7')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildGetDataRouteKey(query = {}) {
|
||||
return JSON.stringify({
|
||||
path: this.$route.path,
|
||||
report_id: query.report_id || '',
|
||||
days: query.days || '',
|
||||
chart_key: query.chart_key || '',
|
||||
visible_charts: query.visible_charts || '',
|
||||
visible_tables: query.visible_tables || ''
|
||||
})
|
||||
},
|
||||
async fetchWithDedupe(url) {
|
||||
const now = Date.now()
|
||||
const cached = this.reportFetchCache[url]
|
||||
// Reuse recent same-url result to absorb rapid duplicate triggers.
|
||||
if (cached && (now - cached.ts) < 600) {
|
||||
reportDebugLog('mixin.fetch.cacheHit', { name: this.name, requestUrl: url })
|
||||
return cached.data
|
||||
}
|
||||
if (this.reportFetchInFlight[url]) {
|
||||
reportDebugLog('mixin.fetch.inFlightJoin', { name: this.name, requestUrl: url })
|
||||
return this.reportFetchInFlight[url]
|
||||
}
|
||||
const request = this.$axios.get(url)
|
||||
.then((res) => {
|
||||
this.reportFetchCache[url] = { ts: Date.now(), data: res }
|
||||
return res
|
||||
})
|
||||
.finally(() => {
|
||||
delete this.reportFetchInFlight[url]
|
||||
})
|
||||
this.reportFetchInFlight[url] = request
|
||||
return request
|
||||
},
|
||||
async ensureReportDetail(reportId = this.reportId) {
|
||||
if (!reportId) {
|
||||
return null
|
||||
@@ -135,7 +177,7 @@ export default {
|
||||
if (this.reportDetail?.id === reportId) {
|
||||
return this.reportDetail
|
||||
}
|
||||
const data = await this.$axios.get(`/api/v1/reports/reports/${reportId}/`)
|
||||
const data = await fetchReportDetailShared(this.$axios, reportId)
|
||||
if (this.reportId !== reportId) {
|
||||
return data
|
||||
}
|
||||
@@ -147,7 +189,7 @@ export default {
|
||||
},
|
||||
buildTemplateUrl(baseUrl) {
|
||||
const query = pickReportQuery(this.$route.query)
|
||||
if (!query.start && !query.end && !query.range_preset && this.days) {
|
||||
if (!query.days && this.days) {
|
||||
query.days = this.days
|
||||
}
|
||||
return appendQuery(baseUrl, query)
|
||||
@@ -158,11 +200,32 @@ export default {
|
||||
if (reportId) {
|
||||
await this.ensureReportDetail(reportId)
|
||||
if (this.reportId !== reportId) {
|
||||
reportDebugLog('mixin.fetch.retry', {
|
||||
name: this.name,
|
||||
fromReportId: reportId,
|
||||
toReportId: this.reportId,
|
||||
routePath: this.$route.path,
|
||||
query: this.$route.query
|
||||
})
|
||||
return this.fetchReportData(baseUrl)
|
||||
}
|
||||
return this.$axios.get(appendQuery(`/api/v1/reports/reports/${reportId}/data/`, query))
|
||||
const requestUrl = appendQuery(`/api/v1/reports/reports/${reportId}/data/`, query)
|
||||
reportDebugLog('mixin.fetch.custom', {
|
||||
name: this.name,
|
||||
requestUrl,
|
||||
routePath: this.$route.path,
|
||||
query: this.$route.query
|
||||
})
|
||||
return this.fetchWithDedupe(requestUrl)
|
||||
}
|
||||
return this.$axios.get(this.buildTemplateUrl(baseUrl))
|
||||
const requestUrl = this.buildTemplateUrl(baseUrl)
|
||||
reportDebugLog('mixin.fetch.template', {
|
||||
name: this.name,
|
||||
requestUrl,
|
||||
routePath: this.$route.path,
|
||||
query: this.$route.query
|
||||
})
|
||||
return this.fetchWithDedupe(requestUrl)
|
||||
},
|
||||
async loadTableData(baseUrl) {
|
||||
const buildLabel = (k) => {
|
||||
@@ -294,61 +357,32 @@ export default {
|
||||
return
|
||||
}
|
||||
},
|
||||
handleToolbarFilterChange({ range_preset, start, end, filter_value }) {
|
||||
handleToolbarFilterChange({ days }) {
|
||||
const query = {}
|
||||
if (this.$route.query.chart_key) {
|
||||
query.chart_key = this.$route.query.chart_key
|
||||
}
|
||||
if (this.reportId) {
|
||||
query.report_id = this.reportId
|
||||
}
|
||||
if (range_preset && range_preset !== 'custom') {
|
||||
query.range_preset = range_preset
|
||||
if (days) {
|
||||
query.days = normalizeReportDays(days, '7')
|
||||
}
|
||||
if (range_preset === 'custom') {
|
||||
query.start = start
|
||||
query.end = end
|
||||
const visibleCharts = normalizeVisibleFilterList(this.$route.query.visible_charts || this.reportDetail?.filters?.visible_charts)
|
||||
const visibleTables = normalizeVisibleFilterList(this.$route.query.visible_tables || this.reportDetail?.filters?.visible_tables)
|
||||
if (visibleCharts.length) {
|
||||
query.visible_charts = visibleCharts.join(',')
|
||||
}
|
||||
if (this.filterField && filter_value) {
|
||||
query[this.filterField] = filter_value
|
||||
if (visibleTables.length) {
|
||||
query.visible_tables = visibleTables.join(',')
|
||||
}
|
||||
if (this.days !== undefined && days) {
|
||||
this.days = normalizeReportDays(days, '7')
|
||||
}
|
||||
if (this.buildGetDataRouteKey(this.$route.query) === this.buildGetDataRouteKey(query)) {
|
||||
return
|
||||
}
|
||||
this.$router.replace({ path: this.$route.path, query })
|
||||
},
|
||||
getFilterSelect() {
|
||||
if (this.filterField === 'user_id') {
|
||||
return {
|
||||
multiple: false,
|
||||
ajax: {
|
||||
url: '/api/v1/users/users/suggestions/',
|
||||
transformOption: (item) => ({ label: `${item.name}(${item.username})`, value: item.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.filterField === 'asset_id') {
|
||||
return {
|
||||
multiple: false,
|
||||
ajax: {
|
||||
url: '/api/v1/assets/assets/?fields_size=mini',
|
||||
transformOption: (item) => ({ label: item.name || item.address || item.hostname || item.id, value: item.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.filterField === 'account') {
|
||||
return {
|
||||
multiple: false,
|
||||
ajax: {
|
||||
url: '/api/v1/accounts/accounts/?fields_size=mini',
|
||||
transformOption: (item) => ({ label: item.asset ? `${item.username} @ ${item.asset.name}` : item.username, value: item.username })
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
},
|
||||
async handleDeleteReport() {
|
||||
if (!this.reportId) {
|
||||
return
|
||||
}
|
||||
await this.$confirm(this.$t('ConfirmDeleteReport'), this.$t('Tip'), { type: 'warning' })
|
||||
await this.$axios.delete(`/api/v1/reports/reports/${this.reportId}/`)
|
||||
this.$message.success(this.$t('DeleteSuccessMsg'))
|
||||
this.$router.replace({ path: this.$route.path, query: {} })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import i18n from '@/i18n/i18n'
|
||||
|
||||
export const REPORT_DEBUG_SWITCH_KEY = '__REPORT_DEBUG_SWITCH__'
|
||||
|
||||
export const REPORT_RANGE_PRESET_OPTIONS = [
|
||||
{ label: i18n.t('LastDay'), value: 'last_day', days: 1 },
|
||||
{ label: i18n.t('Last7Days'), value: 'last_week', days: 7 },
|
||||
{ label: i18n.t('Last30Days'), value: 'last_month', days: 30 },
|
||||
{ label: i18n.t('LastThreeMonths'), value: 'last_three_months', days: 90 },
|
||||
{ label: i18n.t('LastHalfYear'), value: 'last_half_year', days: 180 },
|
||||
{ label: i18n.t('LastYear'), value: 'last_year', days: 365 },
|
||||
{ label: i18n.t('Custom'), value: 'custom', days: null }
|
||||
{ label: i18n.t('Today'), value: '1', days: 1 },
|
||||
{ label: i18n.t('Last7Days'), value: '7', days: 7 },
|
||||
{ label: i18n.t('Last30Days'), value: '30', days: 30 }
|
||||
]
|
||||
|
||||
export const REPORT_ALLOWED_DAYS = REPORT_RANGE_PRESET_OPTIONS.map(item => String(item.value))
|
||||
|
||||
export const REPORT_FILTER_QUERY_KEYS = [
|
||||
'start',
|
||||
'end',
|
||||
'range_preset',
|
||||
'user_id',
|
||||
'asset_id',
|
||||
'account',
|
||||
'days',
|
||||
'report_id'
|
||||
]
|
||||
|
||||
@@ -57,4 +52,89 @@ export function appendQuery(url, query = {}) {
|
||||
export function getPresetLabel(value) {
|
||||
const preset = REPORT_RANGE_PRESET_OPTIONS.find(item => item.value === value)
|
||||
return preset ? preset.label : value
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeReportDays(value, fallback = '7') {
|
||||
const normalizedFallback = REPORT_ALLOWED_DAYS.includes(String(fallback)) ? String(fallback) : '7'
|
||||
const normalizedValue = String(value || '')
|
||||
if (REPORT_ALLOWED_DAYS.includes(normalizedValue)) {
|
||||
return normalizedValue
|
||||
}
|
||||
return normalizedFallback
|
||||
}
|
||||
|
||||
export function normalizeVisibleFilterList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => String(item).trim()).filter(Boolean)
|
||||
}
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return []
|
||||
}
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildCustomReportRouteQuery(report = {}) {
|
||||
const normalizedDays = normalizeReportDays(report.days, '7')
|
||||
const query = {
|
||||
report_id: report.id,
|
||||
days: normalizedDays
|
||||
}
|
||||
const filters = report.filters || {}
|
||||
const visibleCharts = normalizeVisibleFilterList(filters.visible_charts)
|
||||
const visibleTables = normalizeVisibleFilterList(filters.visible_tables)
|
||||
if (visibleCharts.length) {
|
||||
query.visible_charts = visibleCharts.join(',')
|
||||
}
|
||||
if (visibleTables.length) {
|
||||
query.visible_tables = visibleTables.join(',')
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
export function isReportDebugEnabled() {
|
||||
try {
|
||||
const val = localStorage.getItem(REPORT_DEBUG_SWITCH_KEY)
|
||||
return val === '1' || val === 'true'
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function reportDebugLog(scope, payload = {}) {
|
||||
if (!isReportDebugEnabled()) {
|
||||
return
|
||||
}
|
||||
console.log(`[report-debug:${scope}]`, payload)
|
||||
}
|
||||
|
||||
// 模块级 report detail 请求去重缓存
|
||||
// 多个组件实例(reportPageMixin + RightAction)可能同时请求同一 reportId,
|
||||
// 通过此缓存保证同一 URL 在短时间内只发一次 HTTP 请求
|
||||
const _reportDetailInFlight = Object.create(null)
|
||||
const _reportDetailCache = Object.create(null)
|
||||
const REPORT_DETAIL_CACHE_TTL = 1500
|
||||
|
||||
export function fetchReportDetailShared(axios, reportId) {
|
||||
const url = `/api/v1/reports/reports/${reportId}/`
|
||||
const now = Date.now()
|
||||
const cached = _reportDetailCache[url]
|
||||
if (cached && (now - cached.ts) < REPORT_DETAIL_CACHE_TTL) {
|
||||
return Promise.resolve(cached.data)
|
||||
}
|
||||
if (_reportDetailInFlight[url]) {
|
||||
return _reportDetailInFlight[url]
|
||||
}
|
||||
const request = axios.get(url)
|
||||
.then((res) => {
|
||||
_reportDetailCache[url] = { ts: Date.now(), data: res }
|
||||
return res
|
||||
})
|
||||
.finally(() => {
|
||||
delete _reportDetailInFlight[url]
|
||||
})
|
||||
_reportDetailInFlight[url] = request
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
:title="title"
|
||||
:nav="nav"
|
||||
:name="name"
|
||||
:charts="charts"
|
||||
:tables="tables"
|
||||
:show-display-mode-toggle="true"
|
||||
:display-mode.sync="displayMode"
|
||||
v-bind="$attrs"
|
||||
@@ -20,11 +22,7 @@
|
||||
</div>
|
||||
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
@@ -70,16 +68,6 @@
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
<ReportToolbar
|
||||
v-if="tableData.length"
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap chart-container full-width">
|
||||
<div v-if="t.name" class="chart-container-title">
|
||||
<div class="chart-container-title-text">{{ t.name }}</div>
|
||||
@@ -92,15 +80,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<el-table :data="tableData.rows" border>
|
||||
<el-table-column v-for="column in tableData.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
|
||||
</el-table>
|
||||
@@ -140,6 +119,18 @@ export default {
|
||||
return {
|
||||
title: this.$t('UserChangePasswordReport'),
|
||||
name: 'UserChangePasswordReport',
|
||||
charts: [
|
||||
{ name: 'Overview', title: this.$t('Overview') },
|
||||
{ name: 'UserModificationTrends', title: this.$t('UserModificationTrends') },
|
||||
{ name: 'ModifyTheTargetUserTopTank', title: this.$t('ModifyTheTargetUserTopTank') },
|
||||
{ name: 'TopRankOfOperateUsers', title: this.$t('TopRankOfOperateUsers') }
|
||||
],
|
||||
tables: [
|
||||
{ name: 'Overview', title: this.$t('Overview') },
|
||||
{ name: 'UserModificationTrends', title: this.$t('UserModificationTrends') },
|
||||
{ name: 'ModifyTheTargetUserTopTank', title: this.$t('ModifyTheTargetUserTopTank') },
|
||||
{ name: 'TopRankOfOperateUsers', title: this.$t('TopRankOfOperateUsers') }
|
||||
],
|
||||
days: localStorage.getItem(this.name) || '7',
|
||||
total_count_change_password: {
|
||||
'total': 0,
|
||||
@@ -317,18 +308,10 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
days() {
|
||||
this.getData()
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.getData()
|
||||
},
|
||||
methods: {
|
||||
onChange(val) {
|
||||
this.days = val
|
||||
},
|
||||
async getData() {
|
||||
const data = await this.fetchReportData('/api/v1/reports/reports/user-change-password/')
|
||||
await this.loadTableData('/api/v1/reports/reports/user-change-password/')
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
>
|
||||
<template #default>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-container full-width" data-report-type="chart" data-report-name="UserLoginTrends">
|
||||
<div class="chart-container full-width" data-report-type="chart" data-report-name="Overview">
|
||||
<div class="chart-container-title">
|
||||
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
|
||||
<SummaryCountCard
|
||||
@@ -20,11 +20,7 @@
|
||||
</div>
|
||||
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
@@ -95,16 +91,6 @@
|
||||
<el-table-column v-for="column in tableData[0].columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
|
||||
</el-table>
|
||||
</div>
|
||||
<ReportToolbar
|
||||
v-if="tableData.length"
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<div
|
||||
v-for="(t, idx) in tableData.slice(1)"
|
||||
:key="t.name || idx"
|
||||
@@ -121,15 +107,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ReportToolbar
|
||||
:filter-field="filterField"
|
||||
:filter-label="filterLabel"
|
||||
:filter-select="getFilterSelect()"
|
||||
:filters="currentFilters"
|
||||
:is-custom-report="isCustomReport"
|
||||
class="chart-container full-width report-toolbar-wrap"
|
||||
@filter-change="handleToolbarFilterChange"
|
||||
/>
|
||||
<el-table :data="tableData.rows" border>
|
||||
<el-table-column v-for="column in tableData.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
|
||||
</el-table>
|
||||
@@ -168,6 +145,10 @@ export default {
|
||||
title: this.$t('UserLoginReport'),
|
||||
name: 'UserLoginReport',
|
||||
charts: [
|
||||
{
|
||||
name: 'Overview',
|
||||
title: this.$t('Overview')
|
||||
},
|
||||
{
|
||||
name: 'UserLoginTrends',
|
||||
title: this.$t('UserLoginTrends')
|
||||
@@ -190,23 +171,22 @@ export default {
|
||||
name: 'Overview',
|
||||
title: this.$t('Overview')
|
||||
},
|
||||
{
|
||||
name: 'LoginSource',
|
||||
title: this.$t('LoginSource')
|
||||
},
|
||||
{
|
||||
name: 'UserLoginTrends',
|
||||
title: this.$t('UserLoginTrends')
|
||||
},
|
||||
{
|
||||
name: 'LoginMethodStatistics',
|
||||
title: this.$t('LoginMethodStatistics')
|
||||
name: 'LoginSource',
|
||||
title: this.$t('LoginSource')
|
||||
},
|
||||
{
|
||||
name: 'VisitTimeDistribution',
|
||||
title: this.$t('VisitTimeDistribution')
|
||||
},
|
||||
{
|
||||
name: 'LoginMethodStatistics',
|
||||
title: this.$t('LoginMethodStatistics')
|
||||
}
|
||||
|
||||
],
|
||||
days: localStorage.getItem(this.name) || '7',
|
||||
user_stats: {
|
||||
@@ -510,19 +490,10 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
days() {
|
||||
this.getData()
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.getData()
|
||||
},
|
||||
methods: {
|
||||
onChange(val) {
|
||||
this.days = val
|
||||
localStorage.setItem('reportDays', val)
|
||||
},
|
||||
async getData() {
|
||||
const data = await this.fetchReportData('/api/v1/reports/reports/users/')
|
||||
await this.loadTableData('/api/v1/reports/reports/users/')
|
||||
|
||||
@@ -38,20 +38,25 @@
|
||||
|
||||
<script>
|
||||
import Page from '@/layout/components/Page'
|
||||
import { resolveRoute } from '@/utils/vue/index'
|
||||
import { appendQuery, isSameReportQuery } from '@/views/reports/base/reportUtils'
|
||||
import UserActivity from '@/views/reports/users/UserActivity.vue'
|
||||
import ChangePassword from '@/views/reports/users/ChangePassword.vue'
|
||||
import { appendQuery, buildCustomReportRouteQuery, reportDebugLog } from '@/views/reports/base/reportUtils'
|
||||
|
||||
const TEMPLATE_ROUTE_MAP = {
|
||||
UserLoginReport: {
|
||||
name: 'UserReport',
|
||||
key: 'UserReport',
|
||||
titleKey: 'UserLoginReport',
|
||||
icon: 'fa fa-sign-in',
|
||||
component: UserActivity,
|
||||
path: '/reports/users/user-activity',
|
||||
perm: 'rbac.view_userloginreport'
|
||||
},
|
||||
UserChangePasswordReport: {
|
||||
name: 'ChangePassword',
|
||||
key: 'ChangePassword',
|
||||
titleKey: 'UserChangePasswordReport',
|
||||
icon: 'fa fa-key',
|
||||
component: ChangePassword,
|
||||
path: '/reports/users/change-password',
|
||||
perm: 'rbac.view_userchangepasswordreport'
|
||||
}
|
||||
}
|
||||
@@ -68,33 +73,68 @@ export default {
|
||||
component: '',
|
||||
componentKey: '',
|
||||
selectedChartKey: '',
|
||||
chartItems: []
|
||||
chartItems: [],
|
||||
catalogLoaded: false,
|
||||
lastSyncQueryKey: '',
|
||||
isPageActive: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.fullPath'() {
|
||||
if (!this.isPageActive) return
|
||||
const routeKey = this.buildRouteSyncKey(this.$route.query)
|
||||
if (routeKey === this.lastSyncQueryKey) {
|
||||
return
|
||||
}
|
||||
this.lastSyncQueryKey = routeKey
|
||||
reportDebugLog('users.index.route.fullPath', {
|
||||
routePath: this.$route.path,
|
||||
query: this.$route.query,
|
||||
selectedChartKey: this.selectedChartKey
|
||||
})
|
||||
this.syncSelectedFromRoute()
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.loadCatalog()
|
||||
},
|
||||
activated() {
|
||||
this.isPageActive = true
|
||||
this.syncSelectedFromRoute()
|
||||
},
|
||||
deactivated() {
|
||||
this.isPageActive = false
|
||||
},
|
||||
methods: {
|
||||
buildRouteSyncKey(query = {}) {
|
||||
return JSON.stringify({
|
||||
report_id: query.report_id || '',
|
||||
chart_key: query.chart_key || '',
|
||||
days: query.days || '',
|
||||
visible_charts: query.visible_charts || '',
|
||||
visible_tables: query.visible_tables || ''
|
||||
})
|
||||
},
|
||||
getBuiltInTemplates() {
|
||||
return Object.entries(TEMPLATE_ROUTE_MAP)
|
||||
.filter(([, item]) => this.$hasPerm(item.perm))
|
||||
.map(([reportType, item]) => ({
|
||||
key: reportType,
|
||||
key: item.key,
|
||||
reportType,
|
||||
title: this.$t(item.titleKey),
|
||||
routeName: item.name,
|
||||
component: item.component,
|
||||
path: item.path,
|
||||
icon: item.icon,
|
||||
isCustom: false,
|
||||
query: {},
|
||||
query: {
|
||||
chart_key: item.key
|
||||
},
|
||||
children: []
|
||||
}))
|
||||
},
|
||||
async loadCatalog() {
|
||||
reportDebugLog('users.index.loadCatalog.start', { routePath: this.$route.path, query: this.$route.query })
|
||||
this.catalogLoaded = false
|
||||
const templates = this.getBuiltInTemplates()
|
||||
const chartMap = templates.reduce((acc, item) => {
|
||||
acc[item.reportType] = item
|
||||
@@ -110,16 +150,24 @@ export default {
|
||||
target.children = (group.children || []).map(child => ({
|
||||
key: `report-${child.id}`,
|
||||
title: child.name,
|
||||
routeName: target.routeName,
|
||||
reportId: child.id,
|
||||
component: target.component,
|
||||
path: target.path,
|
||||
reportId: String(child.id),
|
||||
isCustom: true,
|
||||
query: { report_id: child.id }
|
||||
query: {
|
||||
chart_key: target.key,
|
||||
...buildCustomReportRouteQuery(child)
|
||||
}
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('load report catalog failed', error)
|
||||
}
|
||||
this.chartItems = templates
|
||||
this.catalogLoaded = true
|
||||
reportDebugLog('users.index.loadCatalog.done', {
|
||||
items: templates.map(item => ({ key: item.key, childCount: (item.children || []).length }))
|
||||
})
|
||||
this.syncSelectedFromRoute()
|
||||
},
|
||||
syncSelectedFromRoute() {
|
||||
@@ -129,32 +177,74 @@ export default {
|
||||
if (reportId) {
|
||||
target = this.chartItems
|
||||
.flatMap(item => item.children || [])
|
||||
.find(item => item.reportId === reportId)
|
||||
.find(item => String(item.reportId) === String(reportId))
|
||||
if (!target) {
|
||||
this.loadCatalog()
|
||||
if (!this.catalogLoaded) {
|
||||
return
|
||||
}
|
||||
const nextQuery = { ...(this.$route.query || {}) }
|
||||
delete nextQuery.report_id
|
||||
this.$router.replace({ path: this.$route.path, query: nextQuery })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!target) {
|
||||
target = this.chartItems[0]
|
||||
const chartKey = this.$route.query.chart_key
|
||||
target = this.chartItems.find(item => item.key === chartKey)
|
||||
|| this.chartItems.find(item => item.key === this.selectedChartKey)
|
||||
|| this.chartItems[0]
|
||||
}
|
||||
if (target) {
|
||||
if (target && (this.selectedChartKey !== target.key || !this.component)) {
|
||||
this.applyChart(target)
|
||||
}
|
||||
reportDebugLog('users.index.syncSelectedFromRoute', {
|
||||
reportId,
|
||||
selectedChartKey: this.selectedChartKey,
|
||||
targetKey: target?.key || ''
|
||||
})
|
||||
},
|
||||
isActive(item) {
|
||||
return this.selectedChartKey === item.key
|
||||
},
|
||||
applyChart(chart) {
|
||||
this.selectedChartKey = chart.key
|
||||
const route = resolveRoute({ name: chart.routeName }, this.$router)
|
||||
this.component = route.components.default
|
||||
this.componentKey = `${chart.key}-${this.$route.fullPath}`
|
||||
this.url = appendQuery('/ui/#' + route.path, chart.query || {})
|
||||
if (!chart.component || !chart.path) {
|
||||
return
|
||||
}
|
||||
const nextUrl = appendQuery('/ui/#' + chart.path, chart.query || {})
|
||||
if (this.component === chart.component && this.componentKey === chart.key && this.url === nextUrl) {
|
||||
return
|
||||
}
|
||||
this.component = chart.component
|
||||
this.componentKey = chart.key
|
||||
this.url = nextUrl
|
||||
reportDebugLog('users.index.applyChart', {
|
||||
chartKey: chart.key,
|
||||
reportId: chart.reportId || '',
|
||||
url: this.url,
|
||||
routePath: this.$route.path,
|
||||
query: this.$route.query
|
||||
})
|
||||
},
|
||||
handleChangeChart(chart) {
|
||||
const nextQuery = chart.query || {}
|
||||
if (isSameReportQuery(this.$route.query, nextQuery)) {
|
||||
const nextQuery = {
|
||||
...(this.$route.query.days ? { days: this.$route.query.days } : {}),
|
||||
...(chart.query || {})
|
||||
}
|
||||
reportDebugLog('users.index.handleChangeChart', {
|
||||
chartKey: chart.key,
|
||||
reportId: chart.reportId || '',
|
||||
nextQuery,
|
||||
currentQuery: this.$route.query
|
||||
})
|
||||
const normalize = (query = {}) => ({
|
||||
days: query.days || '',
|
||||
report_id: query.report_id || '',
|
||||
chart_key: query.chart_key || '',
|
||||
visible_charts: query.visible_charts || '',
|
||||
visible_tables: query.visible_tables || ''
|
||||
})
|
||||
if (JSON.stringify(normalize(this.$route.query)) === JSON.stringify(normalize(nextQuery))) {
|
||||
this.applyChart(chart)
|
||||
return
|
||||
}
|
||||
@@ -225,7 +315,7 @@ h5 {
|
||||
}
|
||||
}
|
||||
|
||||
.folder-list li.active .menu-link {
|
||||
.folder-list li.active > .menu-link {
|
||||
color: var(--color-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user