perf: update reports

This commit is contained in:
Gerry.tan
2026-03-30 22:26:02 +08:00
parent 50561aa485
commit 4ab8f43711
17 changed files with 784 additions and 947 deletions

View File

@@ -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 {

View File

@@ -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/')

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
})
}
}

View File

@@ -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: {} })
}
}
}

View File

@@ -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
}

View File

@@ -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/')

View File

@@ -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/')

View File

@@ -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;
}