perf: Optimize the report page

This commit is contained in:
Gerry.tan
2026-03-25 10:50:57 +08:00
committed by 老广
parent 479f2e7338
commit 8bb5e3e55e
19 changed files with 2969 additions and 445 deletions

View File

@@ -4,42 +4,116 @@
:title="title"
:nav="nav"
:name="name"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
>
<div class="charts-grid">
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
:items="totalData"
/>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('RiskyAccount') }}</div>
<RiskSummary :is-title="false" class="risk-summary" />
</div>
</div>
<SwitchDate class="switch-date" :name="name" @change="onChange" />
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('TaskExecutionTrends') }}</div>
<div class="chart">
<Echart
:options="ExecutionMetricsOptions"
:autoresize="true"
<template v-if="displayMode === 'chart'">
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
:items="totalData"
/>
</div>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AccountResult') }}</div>
<AccountSummary :days="days" :is-title="false" :disable-box="true" class="account-summary" />
<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>
<RiskSummary :is-title="false" class="risk-summary" />
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('TaskExecutionTrends') }}</div>
<div class="chart">
<Echart
:options="ExecutionMetricsOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AccountResult') }}</div>
<AccountSummary
:days="days"
:disable-box="true"
:is-title="false"
:metrics="account_result_metrics"
class="account-summary"
/>
</div>
</div>
</template>
<div v-else class="full-width">
<div v-if="Array.isArray(tableData)" class="report-tables full-width">
<div v-if="tableData.length" class="report-table-wrap full-width">
<el-card class="report-card" shadow="hover">
<div class="chart-container-title" v-if="tableData[0].name">
<div class="chart-container-title-text">{{ tableData[0].name }}</div>
</div>
<div class="report-card-body">
<el-table :data="tableData[0].rows" border>
<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>
</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 class="chart-container-title" v-if="t.name">
<div class="chart-container-title-text">{{ t.name }}</div>
</div>
<div class="report-card-body">
<el-table :data="t.rows" border>
<el-table-column v-for="column in t.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
</el-table>
</div>
</el-card>
</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>
</div>
</div>
</div>
@@ -50,22 +124,23 @@
<script>
import BaseReport from '../base/BaseReport.vue'
import SummaryCountCard from '@/components/Dashboard/SummaryCountCard.vue'
import SwitchDate from '@/components/Dashboard/SwitchDate.vue'
import * as echarts from 'echarts'
import Echart from '@/components/Dashboard/Echart.vue'
import AccountSummary from '@/views/reports/pam/ChangeSecret/AccountSummary.vue'
import RiskSummary from '@/views/reports/pam/Dashboard/RiskSummary.vue'
import { scopedLocalStorage as localStorage } from '@/utils/storage'
import reportPageMixin from '@/views/reports/base/reportPageMixin'
import ReportToolbar from '@/views/reports/base/ReportToolbar.vue'
export default {
components: {
RiskSummary,
AccountSummary,
SwitchDate,
SummaryCountCard,
BaseReport,
Echart
Echart,
ReportToolbar
},
mixins: [reportPageMixin],
props: {
nav: {
type: Boolean,
@@ -90,6 +165,11 @@ export default {
legend: [],
dates_metrics_total: {},
series: []
},
account_result_metrics: {
dates_metrics_date: [],
dates_metrics_total_count_success: [0],
dates_metrics_total_count_failed: [0]
}
}
},
@@ -216,7 +296,8 @@ export default {
this.days = val
},
async getData() {
const data = await this.$axios.get(`/api/v1/reports/reports/account-automation/?days=${this.days}`)
const data = await this.fetchReportData('/api/v1/reports/reports/account-automation/')
await this.loadTableData('/api/v1/reports/reports/account-automation/')
this.$set(this.automation_stats, 'push', data.automation_stats.push)
this.$set(this.automation_stats, 'check', data.automation_stats.check)
this.$set(this.automation_stats, 'backup', data.automation_stats.backup)
@@ -245,6 +326,9 @@ export default {
const keys = Object.keys(data.execution_metrics.data)
this.$set(this.execution_metrics, 'legend', keys)
this.$set(this.execution_metrics, 'series', seriesData)
this.$set(this.account_result_metrics, 'dates_metrics_date', data.account_result_metrics?.dates_metrics_date || [])
this.$set(this.account_result_metrics, 'dates_metrics_total_count_success', data.account_result_metrics?.dates_metrics_total_count_success || [])
this.$set(this.account_result_metrics, 'dates_metrics_total_count_failed', data.account_result_metrics?.dates_metrics_total_count_failed || [])
}
}
}
@@ -252,4 +336,4 @@ export default {
<style lang="scss" scoped>
</style>
</style>

View File

@@ -4,61 +4,149 @@
:title="title"
:nav="nav"
:name="name"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
>
<div class="charts-grid">
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
:items="totalData"
/>
<template v-if="displayMode === 'chart'">
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
:items="totalData"
/>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AccountCreationSourceDistribution') }}</div>
<div class="chart">
<Echart
:options="SourceOptions"
:autoresize="true"
/>
<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>
<div class="chart">
<Echart
:options="SourceOptions"
:autoresize="true"
/>
</div>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AccountConnectivityStatusDistribution') }}</div>
<div class="chart">
<Echart
:options="ConnectivityOptions"
:autoresize="true"
/>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AccountConnectivityStatusDistribution') }}</div>
<div class="chart">
<Echart
:options="ConnectivityOptions"
:autoresize="true"
/>
</div>
</div>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AccountPasswordChangeTrends') }}</div>
<div class="chart">
<Echart
:options="ChangeSecretOptions"
:autoresize="true"
/>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AccountPasswordChangeTrends') }}</div>
<div class="chart">
<Echart
:options="ChangeSecretOptions"
:autoresize="true"
/>
</div>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('RankByNumberOfAssetAccounts') }}</div>
<RankTable :config="config.top10_asset_accounts" />
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('RankByNumberOfAssetAccounts') }}</div>
<RankTable :config="config.top10_asset_accounts" />
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AccountAndPasswordChangeRank') }}</div>
<RankTable :config="config.top10_version_accounts" />
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AccountAndPasswordChangeRank') }}</div>
<RankTable :config="config.top10_version_accounts" />
</div>
</div>
</template>
<div v-else class="full-width">
<div v-if="Array.isArray(tableData)" class="report-tables full-width">
<div v-if="tableData.length" class="report-table-wrap full-width">
<el-card class="report-card" shadow="hover">
<div v-if="tableData[0].name" class="chart-container-title">
<div class="chart-container-title-text">{{ tableData[0].name }}</div>
</div>
<div class="report-card-body">
<el-table :data="tableData[0].rows" border>
<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>
</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">
<div class="chart-container-title-text">{{ t.name }}</div>
</div>
<div class="report-card-body">
<el-table :data="t.rows" border>
<el-table-column
v-for="column in t.columns"
:key="column.key"
:label="column.label"
:prop="column.key"
min-width="140"
/>
</el-table>
</div>
</el-card>
</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>
</div>
</div>
</div>
@@ -73,14 +161,18 @@ import * as echarts from 'echarts'
import Echart from '@/components/Dashboard/Echart.vue'
import { mixColors } from '@/views/reports/const'
import RankTable from '@/views/reports/users/components/RankTable.vue'
import reportPageMixin from '@/views/reports/base/reportPageMixin'
import ReportToolbar from '@/views/reports/base/ReportToolbar.vue'
export default {
components: {
RankTable,
SummaryCountCard,
BaseReport,
Echart
Echart,
ReportToolbar
},
mixins: [reportPageMixin],
props: {
nav: {
type: Boolean,
@@ -91,6 +183,7 @@ export default {
return {
title: this.$t('AccountStatisticsReport'),
name: 'AccountStatistics',
days: '30',
account_stats: {
'total': 0,
'active': 0,
@@ -364,30 +457,32 @@ export default {
},
methods: {
async getData() {
const data = await this.$axios.get('/api/v1/reports/reports/account-statistic/?days=30')
this.$set(this.account_stats, 'total', data.account_stats.total)
this.$set(this.account_stats, 'active', data.account_stats.active)
this.$set(this.account_stats, 'connected', data.account_stats.connected)
this.$set(this.account_stats, 'su_from', data.account_stats.su_from)
this.$set(this.account_stats, 'date_change_secret', data.account_stats.date_change_secret)
this.$set(this.account_stats, 'template_total', data.account_stats.template_total)
this.$set(this.change_secret_account_metrics, 'dates_metrics_date', data.change_secret_account_metrics.dates_metrics_date)
this.$set(this.change_secret_account_metrics, 'dates_metrics_total', data.change_secret_account_metrics.dates_metrics_total)
const data = await this.fetchReportData('/api/v1/reports/reports/account-statistic/')
this.$set(this.account_stats, 'total', data.account_stats?.total || 0)
this.$set(this.account_stats, 'active', data.account_stats?.active || 0)
this.$set(this.account_stats, 'connected', data.account_stats?.connected || 0)
this.$set(this.account_stats, 'su_from', data.account_stats?.su_from || 0)
this.$set(this.account_stats, 'date_change_secret', data.account_stats?.date_change_secret || 0)
this.$set(this.account_stats, 'template_total', data.account_stats?.template_total || 0)
this.$set(this.change_secret_account_metrics, 'dates_metrics_date', data.change_secret_account_metrics?.dates_metrics_date || [])
this.$set(this.change_secret_account_metrics, 'dates_metrics_total', data.change_secret_account_metrics?.dates_metrics_total || [])
const accountSourcePie = data.source_pie
const accountSourcePie = data.source_pie || []
if (accountSourcePie.length !== 0) {
this.$set(this.config, 'source_pie', accountSourcePie)
}
const by_connectivity = data.by_connectivity.map(item => {
const by_connectivity = (data.by_connectivity || []).map(item => {
return {
name: item.label,
value: item.total
}
})
this.$set(this.config, 'by_connectivity', by_connectivity)
this.$set(this.config.top10_asset_accounts, 'data', data.top_assets)
this.$set(this.config.top10_version_accounts, 'data', data.top_version_accounts)
this.$set(this.config.top10_asset_accounts, 'data', data.top_assets || [])
this.$set(this.config.top10_version_accounts, 'data', data.top_version_accounts || [])
await this.loadTableData('/api/v1/reports/reports/account-statistic/')
}
}
}
@@ -395,4 +490,4 @@ export default {
<style lang="scss" scoped>
</style>
</style>

View File

@@ -7,33 +7,62 @@
<ul class="folder-list m-b-md" style="padding: 0">
<li
v-for="chart in chartItems"
:key="chart.name"
:class="{ active: selectedChart && selectedChart.name === chart.name }"
:key="chart.key"
:class="{ active: isActive(chart) }"
>
<a style="display: flex; align-items: center;" @click="handleChangeChart(chart)">
<a class="menu-link" @click="handleChangeChart(chart)">
<i :class="chart.icon" style="margin-right: 6px;" />
{{ chart.title }}
</a>
<ul v-if="chart.children && chart.children.length" class="report-children">
<li
v-for="child in chart.children"
:key="child.key"
:class="{ active: isActive(child) }"
>
<a class="menu-link child-link" @click="handleChangeChart(child)">
{{ child.title }}
</a>
</li>
</ul>
</li>
</ul>
</div>
</el-col>
<el-col :span="20" style="background-color: #fff" class="chart">
<component :is="component" :nav="false" :url="url" />
<component :is="component" :key="componentKey" :nav="false" :url="url" />
</el-col>
</el-row>
</Page>
</template>
<script>
import AccountStatistics from './AccountStatistics.vue'
import Page from '@/layout/components/Page'
import { resolveRoute } from '@/utils/vue/index'
import { appendQuery, isSameReportQuery } from '@/views/reports/base/reportUtils'
const MENU_ITEMS = [
{
key: 'AccountStatistics',
titleKey: 'AccountStatisticsReport',
routeName: 'AccountStatistics',
icon: 'fa fa-users',
perm: 'rbac.view_accountstatisticsreport',
reportType: 'AccountStatistics'
},
{
key: 'AccountAutomationReport',
titleKey: 'AccountAutomationReport',
routeName: 'AccountAutomationReport',
icon: 'fa fa-cogs',
perm: 'rbac.view_accountautomationreport',
reportType: 'AccountAutomationReport'
}
]
export default {
name: 'Accounts',
components: {
AccountStatistics,
Page
},
data() {
@@ -41,41 +70,104 @@ export default {
url: '',
title: this.$t('ReportType'),
component: '',
selectedChart: null,
charts: [
{
title: this.$t('AccountStatisticsReport'),
name: 'AccountStatistics',
icon: 'fa fa-users',
hidden: this.$hasPerm('rbac.view_accountstatisticsreport')
},
{
title: this.$t('AccountAutomationReport'),
name: 'AccountAutomationReport',
icon: 'fa fa-cogs',
hidden: this.$hasPerm('rbac.view_accountautomationreport')
}
]
componentKey: '',
selectedChartKey: '',
chartItems: []
}
},
computed: {
chartItems() {
return this.charts.filter(chart => chart.hidden)
watch: {
'$route.fullPath'() {
this.syncSelectedFromRoute()
}
},
created() {
if (this.chartItems.length > 0) {
this.handleChangeChart(this.chartItems[0])
}
async created() {
await this.loadCatalog()
},
methods: {
handleChangeChart(chart) {
this.selectedChart = chart
const route = resolveRoute({ name: chart.name }, this.$router)
getBaseItems() {
return MENU_ITEMS
.filter(item => this.$hasPerm(item.perm))
.map(item => ({
key: item.key,
title: this.$t(item.titleKey),
routeName: item.routeName,
icon: item.icon,
isCustom: false,
reportType: item.reportType,
query: {},
children: []
}))
},
async loadCatalog() {
const items = this.getBaseItems()
const itemMap = items.reduce((acc, item) => {
if (item.reportType) {
acc[item.reportType] = item
}
return acc
}, {})
try {
const data = await this.$axios.get('/api/v1/reports/reports/catalog/')
data.forEach((group) => {
const target = itemMap[group.tp]
if (!target) {
return
}
target.children = (group.children || []).map(child => ({
key: `report-${child.id}`,
title: child.name,
routeName: target.routeName,
reportId: child.id,
isCustom: true,
query: { report_id: child.id }
}))
})
} catch (error) {
console.error('load report catalog failed', error)
}
this.chartItems = items
this.syncSelectedFromRoute()
},
syncSelectedFromRoute() {
const raw = this.$route.query.report_id
const reportId = Array.isArray(raw) ? raw[0] : raw
let target = null
if (reportId) {
target = this.chartItems
.flatMap(item => item.children || [])
.find(item => item.reportId === reportId)
if (!target) {
this.loadCatalog()
return
}
}
if (!target) {
target = this.chartItems[0]
}
if (target) {
this.applyChart(target)
}
},
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
const routePath = route.path
this.url = window.__UI_BASE__ + '#' + routePath
this.name = chart.name
this.componentKey = `${chart.key}-${this.$route.fullPath}`
this.url = appendQuery('/ui/#' + route.path, chart.query || {})
},
handleChangeChart(chart) {
const nextQuery = chart.query || {}
if (isSameReportQuery(this.$route.query, nextQuery)) {
this.applyChart(chart)
return
}
this.$router.replace({
path: this.$route.path,
query: nextQuery
})
}
}
}
@@ -103,9 +195,26 @@ h5 {
margin-right: 10px;
}
}
.menu-link {
display: flex;
align-items: center;
}
.report-children {
margin: 6px 0 0 18px;
padding: 0;
}
.child-link {
color: #606266;
font-size: 12px;
}
.tag-container {
border-radius: 5px;
}
.chart {
padding: 10px;
@@ -115,9 +224,10 @@ h5 {
height: 100%;
}
}
.folder-list li.active {
color: var(--color-primary);
background-color: var(--menu-hover);
border-radius: 4px;
}
</style>
</style>

View File

@@ -4,72 +4,136 @@
:title="title"
:nav="nav"
:name="name"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
>
<div class="charts-grid">
<SwitchDate class="switch-date" :name="name" @change="onChange" />
<br>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
:items="totalData"
<template v-if="displayMode === 'chart'">
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
:items="totalData"
/>
</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" />
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('DistributionOfAssetLoginMethods') }}</div>
<div class="chart">
<Echart
:options="LoginEntryOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('RemoteLoginProtocolUsageDistribution') }}</div>
<div class="chart">
<Echart
:options="LoginProtocolOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('OperatingSystemDistributionOfLoginAssets') }}</div>
<div class="chart">
<Echart
:options="LoginOSOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AssetLoginTrends') }}</div>
<div class="chart">
<Echart
ref="loginTrend"
:options="loginTrendOptions"
:autoresize="true"
/>
</div>
</div>
</div>
</template>
<div v-else class="full-width">
<div v-if="Array.isArray(tableData)" class="report-tables full-width">
<div v-if="tableData.length" class="report-table-wrap">
<el-card class="report-card" shadow="hover">
<div v-if="tableData[0].name" class="chart-container-title">
<div class="chart-container-title-text">{{ tableData[0].name }}</div>
</div>
<div class="report-card-body">
<el-table :data="tableData[0].rows" border>
<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>
</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>
</div>
<div class="chart-container">
<div class="chart-container-title">
<UserAssetActivity :days="days" />
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('DistributionOfAssetLoginMethods') }}</div>
<div class="chart">
<Echart
:options="LoginEntryOptions"
:autoresize="true"
/>
<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">
<div class="chart-container-title-text">{{ t.name }}</div>
</div>
<div class="report-card-body">
<el-table :data="t.rows" border>
<el-table-column v-for="column in t.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
</el-table>
</div>
</el-card>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('RemoteLoginProtocolUsageDistribution') }}</div>
<div class="chart">
<Echart
:options="LoginProtocolOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('OperatingSystemDistributionOfLoginAssets') }}</div>
<div class="chart">
<Echart
:options="LoginOSOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AssetLoginTrends') }}</div>
<div class="chart">
<Echart
ref="loginTrend"
:options="loginTrendOptions"
:autoresize="true"
/>
</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>
</div>
</div>
</div>
@@ -78,23 +142,24 @@
</template>
<script>
import SwitchDate from '@/components/Dashboard/SwitchDate'
import BaseReport from '@/views/reports/base/BaseReport.vue'
import SummaryCountCard from '@/components/Dashboard/SummaryCountCard.vue'
import UserAssetActivity from '@/views/reports/console/UserAssetActivity.vue'
import * as echarts from 'echarts'
import Echart from '@/components/Dashboard/Echart.vue'
import { mixColors } from '@/views/reports/const'
import { scopedLocalStorage as localStorage } from '@/utils/storage'
import reportPageMixin from '@/views/reports/base/reportPageMixin'
import ReportToolbar from '@/views/reports/base/ReportToolbar.vue'
export default {
components: {
UserAssetActivity,
SummaryCountCard,
BaseReport,
SwitchDate,
Echart
Echart,
ReportToolbar
},
mixins: [reportPageMixin],
props: {
nav: {
type: Boolean,
@@ -119,6 +184,11 @@ export default {
asset_login_log_metrics: {
dates_metrics_date: [],
dates_metrics_total: [0]
},
user_asset_activity_metrics: {
dates_metrics_date: [],
dates_metrics_total_count_active_users: [0],
dates_metrics_total_count_active_assets: [0]
}
}
},
@@ -390,12 +460,16 @@ export default {
}
},
async getData() {
const data = await this.$axios.get(`/api/v1/reports/reports/asset-activity/?days=${this.days}`)
const data = await this.fetchReportData('/api/v1/reports/reports/asset-activity/')
await this.loadTableData('/api/v1/reports/reports/asset-activity/')
this.$set(this.session_stats, 'total', data.session_stats.total)
this.$set(this.session_stats, 'asset_count', data.session_stats.asset_count)
this.$set(this.session_stats, 'user_count', data.session_stats.user_count)
this.$set(this.asset_login_log_metrics, 'dates_metrics_date', data.asset_login_log_metrics.dates_metrics_date)
this.$set(this.asset_login_log_metrics, 'dates_metrics_total', data.asset_login_log_metrics.dates_metrics_total)
this.$set(this.user_asset_activity_metrics, 'dates_metrics_date', data.user_asset_activity_metrics?.dates_metrics_date || [])
this.$set(this.user_asset_activity_metrics, 'dates_metrics_total_count_active_users', data.user_asset_activity_metrics?.dates_metrics_total_count_active_users || [])
this.$set(this.user_asset_activity_metrics, 'dates_metrics_total_count_active_assets', data.user_asset_activity_metrics?.dates_metrics_total_count_active_assets || [])
this.setPieData('asset_login_by_type', data.asset_login_by_type)
this.setPieData('asset_login_by_from', data.asset_login_by_from)
@@ -407,4 +481,4 @@ export default {
<style lang="scss" scoped>
</style>
</style>

View File

@@ -4,39 +4,127 @@
:title="title"
:nav="nav"
:name="name"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
>
<div class="charts-grid">
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
:items="totalData"
/>
<template v-if="displayMode === 'chart'">
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
:items="totalData"
/>
</div>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AssetTypeDistribution') }}</div>
<div class="chart">
<Echart
:options="AssetTypeOptions"
:autoresize="true"
/>
<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>
<div class="chart">
<Echart
:options="AssetTypeOptions"
:autoresize="true"
/>
</div>
</div>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('WeeklyGrowthTrend') }}</div>
<div class="chart">
<Echart
:options="AddedAssetOptions"
:autoresize="true"
/>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('WeeklyGrowthTrend') }}</div>
<div class="chart">
<Echart
:options="AddedAssetOptions"
:autoresize="true"
/>
</div>
</div>
</div>
</template>
<div v-else class="full-width">
<div v-if="Array.isArray(tableData)" class="report-tables full-width">
<div v-if="tableData.length" class="report-table-wrap full-width">
<el-card class="report-card" shadow="hover">
<div v-if="tableData[0].name" class="chart-container-title">
<div class="chart-container-title-text">{{ tableData[0].name }}</div>
</div>
<div class="report-card-body">
<el-table :data="tableData[0].rows" border>
<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>
</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">
<div class="chart-container-title-text">{{ t.name }}</div>
</div>
<div class="report-card-body">
<el-table :data="t.rows" border>
<el-table-column
v-for="column in t.columns"
:key="column.key"
:label="column.label"
:prop="column.key"
min-width="140"
/>
</el-table>
</div>
</el-card>
</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>
</div>
</div>
</div>
</BaseReport>
@@ -49,13 +137,17 @@ import SummaryCountCard from '@/components/Dashboard/SummaryCountCard.vue'
import * as echarts from 'echarts'
import Echart from '@/components/Dashboard/Echart.vue'
import { mixColors } from '@/views/reports/const'
import reportPageMixin from '@/views/reports/base/reportPageMixin'
import ReportToolbar from '@/views/reports/base/ReportToolbar.vue'
export default {
components: {
SummaryCountCard,
BaseReport,
Echart
Echart,
ReportToolbar
},
mixins: [reportPageMixin],
props: {
nav: {
type: Boolean,
@@ -66,6 +158,7 @@ export default {
return {
title: this.$t('AssetStatisticsReport'),
name: 'AssetStatistics',
days: '7',
asset_stats: {
'total': 0,
'active': 0,
@@ -274,15 +367,15 @@ export default {
},
methods: {
async getData() {
const data = await this.$axios.get('/api/v1/reports/reports/asset-statistic/?days=7')
this.$set(this.asset_stats, 'total', data.asset_stats.total)
this.$set(this.asset_stats, 'active', data.asset_stats.active)
this.$set(this.asset_stats, 'connected', data.asset_stats.connected)
this.$set(this.asset_stats, 'zone', data.asset_stats.zone)
this.$set(this.asset_stats, 'directory_services', data.asset_stats.directory_services)
this.$set(this.asset_stats, 'platform_count', data.asset_stats.platform_count)
this.$set(this.added_asset_metrics, 'dates_metrics_date', data.added_asset_metrics.dates_metrics_date)
this.$set(this.added_asset_metrics, 'dates_metrics_total', data.added_asset_metrics.dates_metrics_total)
const data = await this.fetchReportData('/api/v1/reports/reports/asset-statistic/')
this.$set(this.asset_stats, 'total', data.asset_stats?.total || 0)
this.$set(this.asset_stats, 'active', data.asset_stats?.active || 0)
this.$set(this.asset_stats, 'connected', data.asset_stats?.connected || 0)
this.$set(this.asset_stats, 'zone', data.asset_stats?.zone || 0)
this.$set(this.asset_stats, 'directory_services', data.asset_stats?.directory_services || 0)
this.$set(this.asset_stats, 'platform_count', data.asset_stats?.platform_count || 0)
this.$set(this.added_asset_metrics, 'dates_metrics_date', data.added_asset_metrics?.dates_metrics_date || [])
this.$set(this.added_asset_metrics, 'dates_metrics_total', data.added_asset_metrics?.dates_metrics_total || [])
const assetsByTypeCategory = data.assets_by_type_category || {}
@@ -316,6 +409,9 @@ export default {
this.$set(this.assets_by_type_category, 'categories', categories)
this.$set(this.assets_by_type_category, 'typeLabelMap', typeLabelMap)
this.$set(this.assets_by_type_category, 'series', series)
// load table data using shared mixin logic (will build tables from chart payload or fallback to export=table)
await this.loadTableData('/api/v1/reports/reports/asset-statistic/')
}
}
}
@@ -323,4 +419,4 @@ export default {
<style lang="scss" scoped>
</style>
</style>

View File

@@ -7,33 +7,62 @@
<ul class="folder-list m-b-md" style="padding: 0">
<li
v-for="chart in chartItems"
:key="chart.name"
:class="{ active: selectedChart && selectedChart.name === chart.name }"
:key="chart.key"
:class="{ active: isActive(chart) }"
>
<a style="display: flex; align-items: center;" @click="handleChangeChart(chart)">
<a class="menu-link" @click="handleChangeChart(chart)">
<i :class="chart.icon" style="margin-right: 6px;" />
{{ chart.title }}
</a>
<ul v-if="chart.children && chart.children.length" class="report-children">
<li
v-for="child in chart.children"
:key="child.key"
:class="{ active: isActive(child) }"
>
<a class="menu-link child-link" @click="handleChangeChart(child)">
{{ child.title }}
</a>
</li>
</ul>
</li>
</ul>
</div>
</el-col>
<el-col :span="20" style="background-color: #fff" class="chart">
<component :is="component" :nav="false" :url="url" />
<component :is="component" :key="componentKey" :nav="false" :url="url" />
</el-col>
</el-row>
</Page>
</template>
<script>
import AssetStatistics from './AssetStatistics.vue'
import Page from '@/layout/components/Page'
import { resolveRoute } from '@/utils/vue/index'
import { appendQuery, isSameReportQuery } from '@/views/reports/base/reportUtils'
const MENU_ITEMS = [
{
key: 'AssetStatistics',
titleKey: 'AssetStatisticsReport',
routeName: 'AssetStatistics',
icon: 'fa fa-database',
perm: 'rbac.view_assetstatisticsreport',
reportType: 'AssetStatistics'
},
{
key: 'AssetReport',
titleKey: 'AssetActivityReport',
routeName: 'AssetReport',
icon: 'fa fa-exchange',
perm: 'rbac.view_assetactivityreport',
reportType: 'AssetReport'
}
]
export default {
name: 'Assets',
components: {
AssetStatistics,
Page
},
data() {
@@ -41,41 +70,104 @@ export default {
url: '',
title: this.$t('ReportType'),
component: '',
selectedChart: null,
charts: [
{
title: this.$t('AssetStatisticsReport'),
name: 'AssetStatistics',
icon: 'fa fa-database',
hidden: this.$hasPerm('rbac.view_assetstatisticsreport')
},
{
title: this.$t('AssetActivityReport'),
name: 'AssetReport',
icon: 'fa fa-exchange',
hidden: this.$hasPerm('rbac.view_assetactivityreport')
}
]
componentKey: '',
selectedChartKey: '',
chartItems: []
}
},
computed: {
chartItems() {
return this.charts.filter(chart => chart.hidden)
watch: {
'$route.fullPath'() {
this.syncSelectedFromRoute()
}
},
created() {
if (this.chartItems.length > 0) {
this.handleChangeChart(this.chartItems[0])
}
async created() {
await this.loadCatalog()
},
methods: {
handleChangeChart(chart) {
this.selectedChart = chart
const route = resolveRoute({ name: chart.name }, this.$router)
getBaseItems() {
return MENU_ITEMS
.filter(item => this.$hasPerm(item.perm))
.map(item => ({
key: item.key,
title: this.$t(item.titleKey),
routeName: item.routeName,
icon: item.icon,
isCustom: false,
reportType: item.reportType,
query: {},
children: []
}))
},
async loadCatalog() {
const items = this.getBaseItems()
const itemMap = items.reduce((acc, item) => {
if (item.reportType) {
acc[item.reportType] = item
}
return acc
}, {})
try {
const data = await this.$axios.get('/api/v1/reports/reports/catalog/')
data.forEach((group) => {
const target = itemMap[group.tp]
if (!target) {
return
}
target.children = (group.children || []).map(child => ({
key: `report-${child.id}`,
title: child.name,
routeName: target.routeName,
reportId: child.id,
isCustom: true,
query: { report_id: child.id }
}))
})
} catch (error) {
console.error('load report catalog failed', error)
}
this.chartItems = items
this.syncSelectedFromRoute()
},
syncSelectedFromRoute() {
const raw = this.$route.query.report_id
const reportId = Array.isArray(raw) ? raw[0] : raw
let target = null
if (reportId) {
target = this.chartItems
.flatMap(item => item.children || [])
.find(item => item.reportId === reportId)
if (!target) {
this.loadCatalog()
return
}
}
if (!target) {
target = this.chartItems[0]
}
if (target) {
this.applyChart(target)
}
},
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
const routePath = route.path
this.url = window.__UI_BASE__ + '#' + routePath
this.name = chart.name
this.componentKey = `${chart.key}-${this.$route.fullPath}`
this.url = appendQuery('/ui/#' + route.path, chart.query || {})
},
handleChangeChart(chart) {
const nextQuery = chart.query || {}
if (isSameReportQuery(this.$route.query, nextQuery)) {
this.applyChart(chart)
return
}
this.$router.replace({
path: this.$route.path,
query: nextQuery
})
}
}
}
@@ -103,9 +195,26 @@ h5 {
margin-right: 10px;
}
}
.menu-link {
display: flex;
align-items: center;
}
.report-children {
margin: 6px 0 0 18px;
padding: 0;
}
.child-link {
color: #606266;
font-size: 12px;
}
.tag-container {
border-radius: 5px;
}
.chart {
padding: 10px;
@@ -115,9 +224,10 @@ h5 {
height: 100%;
}
}
.folder-list li.active {
color: var(--color-primary);
background-color: var(--menu-hover);
border-radius: 4px;
}
</style>
</style>

View File

@@ -4,34 +4,53 @@
<div class="nav-bar-logo">
<Logo />
</div>
<RightAction :name="name" />
<RightAction
:name="name"
:title="title"
:show-operation-dropdown="!isCustomReportPage"
:force-default-actions="nav && isCustomReportPage"
/>
</div>
<div class="content">
<div v-if="!onlyCharts" class="title-bar">
<div class="title">
{{ title }}
<div class="title-left">
<div class="title">
{{ title }}
<span class="datetime">
[{{ new Date().toLocaleString() }}]
</span>
<span class="datetime">
[{{ new Date().toLocaleString() }}]
</span>
<!-- <span v-if="!nav && url" class="export-btn">
<el-button-group v-if="showDisplayModeToggle && !nav" class="display-mode-switch">
<el-button :type="displayMode === 'chart' ? 'primary' : 'default'" size="mini" @click="$emit('update:displayMode', 'chart')">
{{ $t('ChartReport') }}
</el-button>
<el-button :type="displayMode === 'table' ? 'primary' : 'default'" size="mini" @click="$emit('update:displayMode', 'table')">
{{ $t('TableDetails') }}
</el-button>
</el-button-group>
</div>
<div v-if="isDescription" class="description">
{{ description }}
</div>
</div>
<div v-if="!nav" class="title-right">
<RightAction
:name="name"
:title="title"
:editor-only="true"
:show-editor-button="true"
:show-custom-actions-in-editor="isCustomReportPage"
:show-operation-only-in-editor="true"
/>
<span v-if="url && showReportExportBtn" class="export-btn inline-export-btn">
<el-button type="text" @click="openNewWindow">
<i class="fa fa-external-link" style="font-size: 15px;" />
{{ $t('Export') }}
</el-button>
</span> -->
</div>
<div v-if="isDescription" class="description">
{{ description }}
</span>
</div>
</div>
<span v-if="!nav && url && showReportExportBtn" class="export-btn">
<el-button type="text" @click="openNewWindow">
<i class="fa fa-external-link" style="font-size: 15px;" />
{{ $t('Export') }}
</el-button>
</span>
<div class="charts-zone" :class="{ 'charts-zone--no-padding': disableChartsPadding }">
<slot />
</div>
@@ -43,6 +62,7 @@
import Logo from '@/layout/components/NavLeft/Logo'
import RightAction from './RightAction.vue'
import store from '@/store'
import { appendQuery, pickReportQuery } from './reportUtils'
export default {
components: {
@@ -77,6 +97,14 @@ export default {
disableChartsPadding: {
type: Boolean,
default: false
},
showDisplayModeToggle: {
type: Boolean,
default: false
},
displayMode: {
type: String,
default: 'chart'
}
},
data() {
@@ -86,6 +114,12 @@ export default {
isDescription() {
return this.description && this.description.trim() !== ''
},
isCustomReportPage() {
const query = (this.$route && this.$route.query) || {}
const v = query.report_id
const reportId = Array.isArray(v) ? v[0] : v
return !!reportId
},
showReportExportBtn() {
return store.getters.hasValidLicense
}
@@ -103,11 +137,11 @@ export default {
const left = (screen.width - width) / 2
const top = (screen.height - height) / 2
const options = `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
let url = this.url
if (this.$route.query.days) {
const separator = url.includes('?') ? '&' : '?'
url = `${url}${separator}days=${this.$route.query.days}`
}
const query = pickReportQuery(this.$route.query)
const url = appendQuery(this.url, {
...query,
days: this.$route.query.days
})
this.win = window.open(url, '_blank', options)
}
// 确保窗口在最前面
@@ -141,22 +175,50 @@ export default {
}
.title-bar {
display: inline-block;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 8px 16px;
}
.title-left {
display: flex;
flex-direction: column;
flex: 1;
}
.title-right {
display: flex;
align-items: center;
gap: 12px;
}
.title {
height: 40px;
min-height: 40px;
background-color: white;
line-height: 40px;
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
font-size: 15px;
font-weight: 500;
color: #333;
padding: 0 16px;
padding: 6px 16px;
border-radius: 4px;
.datetime {
font-size: 12px;
color: #999;
margin-left: 16px;
}
}
.display-mode-switch {
display: inline-flex;
vertical-align: middle;
::v-deep .el-button {
min-width: 88px;
padding: 6px 10px;
}
}
@@ -173,6 +235,18 @@ export default {
margin-right: 23px;
}
.title-right {
.export-btn {
float: none;
line-height: 1;
margin-right: 0;
}
}
.inline-export-btn {
margin-right: 0;
}
.content {
// background-color: white;
background-color: #F1F1F1;
@@ -322,6 +396,40 @@ export default {
margin-bottom: 8px;
}
.report-toolbar-wrap {
min-width: 0;
max-width: calc(100vw - 60px);
padding: 10px 12px;
.report-toolbar {
margin-bottom: 0;
}
}
/* Tables rendered as cards */
.report-tables {
display: flex;
flex-direction: column;
gap: 16px;
}
.report-table-wrap {
width: 100%;
}
.report-card {
padding: 12px;
box-sizing: border-box;
}
.report-card .chart-container-title {
margin-bottom: 8px;
}
.report-card-body {
padding-top: 6px;
}
// @media (max-width: 767px) {
// .charts-grid {
// grid-template-columns: 1fr;
@@ -333,9 +441,20 @@ export default {
}
}
@media (max-width: 1200px) {
.title-bar {
flex-direction: column;
gap: 10px;
}
.title-right {
width: 100%;
justify-content: flex-end;
}
}
.charts-zone--no-padding {
padding: 0 !important;
}
</style>
</style>

View File

@@ -0,0 +1,336 @@
<template>
<Dialog
v-if="iVisible"
:destroy-on-close="true"
:disabled-status="submitting"
:confirm-title="isEdit ? $t('Update') : $t('Confirm')"
:title="isEdit ? `${$t('Update')} ${$t('Report')}` : `${$t('Create')} ${$t('Report')}`"
:visible.sync="iVisible"
top="8vh"
width="760px"
@cancel="handleClose"
@confirm="handleSubmit"
>
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
<el-form-item :label="$t('Name')" prop="name">
<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-option
v-for="option in presetOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</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>
<el-form-item v-if="filterField" :label="filterLabel">
<Select2 v-model="form.filter_value" v-bind="filterSelect" />
</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'
}
function getDefaultName(title) {
const now = new Date()
const date = now.toISOString().slice(0, 10)
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
},
props: {
visible: {
type: Boolean,
default: false
},
reportType: {
type: String,
default: ''
},
reportTitle: {
type: String,
default: ''
},
report: {
type: Object,
default: () => null
},
defaultDays: {
type: [String, Number],
default: '7'
}
},
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' }]
}
}
},
computed: {
iVisible: {
get() {
return this.visible
},
set(val) {
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] || ''
},
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 {}
}
},
watch: {
visible(val) {
if (val) {
this.form = this.getInitialForm()
}
}
},
methods: {
getInitialForm() {
const report = this.report || {}
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 = ''
}
}
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)
}
},
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
}
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 } : {}
}
},
handleClose() {
this.iVisible = false
},
handleSubmit() {
this.$refs.form.validate(async (valid) => {
if (!valid) {
return
}
this.submitting = true
try {
const url = this.isEdit ? `/api/v1/reports/reports/${this.report.id}/` : '/api/v1/reports/reports/'
const method = this.isEdit ? 'put' : 'post'
const res = await this.$axios[method](url, this.getPayload())
this.$message.success(this.isEdit ? this.$t('UpdateSuccessMsg') : this.$t('CreateSuccessMsg'))
this.$emit('created', res)
this.handleClose()
} finally {
this.submitting = false
}
})
}
}
}
</script>
<style scoped>
.form-help-text {
margin-top: 6px;
color: #909399;
font-size: 12px;
line-height: 1.4;
}
</style>

View File

@@ -0,0 +1,220 @@
<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

@@ -0,0 +1,166 @@
<template>
<Dialog
v-if="iVisible"
:destroy-on-close="true"
:show-confirm="exportType !== 'table'"
:title="$t('Export')"
:visible.sync="iVisible"
top="8vh"
width="900px"
@cancel="handleClose"
@confirm="handleConfirm"
>
<div class="export-dialog">
<el-radio-group v-model="exportType" size="small" @change="handleTypeChange">
<el-radio-button label="table">Table</el-radio-button>
<el-radio-button label="xlsx">Excel</el-radio-button>
</el-radio-group>
<div v-if="exportType === 'table'" v-loading="loading" class="table-preview">
<div v-if="Array.isArray(tableData)">
<div v-for="(t, idx) in tableData" :key="t.name || idx" style="margin-bottom:12px">
<div class="chart-container-title" v-if="t.name">
<div class="chart-container-title-text">{{ t.name }}</div>
</div>
<el-table :data="t.rows" border height="240">
<el-table-column
v-for="column in t.columns"
:key="column.key"
:label="column.label"
:prop="column.key"
min-width="140"
/>
</el-table>
</div>
</div>
<div v-else>
<el-empty v-if="!tableData.rows || !tableData.rows.length" description="No data" />
<el-table v-else :data="tableData.rows" border height="420">
<el-table-column
v-for="column in tableData.columns"
:key="column.key"
:label="column.label"
:prop="column.key"
min-width="140"
/>
</el-table>
</div>
</div>
<div v-else class="excel-preview">
<p>{{ reportName || $t('Report') }}</p>
<p>将下载当前报告的 Excel 文件</p>
</div>
</div>
</Dialog>
</template>
<script>
import Dialog from '@/components/Dialog'
import { download } from '@/utils/common'
import { appendQuery, pickReportQuery } from './reportUtils'
export default {
name: 'ReportExportDialog',
components: {
Dialog
},
props: {
visible: {
type: Boolean,
default: false
},
reportId: {
type: String,
default: ''
},
reportName: {
type: String,
default: ''
},
reportQuery: {
type: Object,
default: () => ({})
}
},
data() {
return {
exportType: 'table',
loading: false,
tableData: {
columns: [],
rows: []
}
}
},
computed: {
iVisible: {
get() {
return this.visible
},
set(val) {
this.$emit('update:visible', val)
}
}
},
watch: {
visible(val) {
if (val) {
this.exportType = 'table'
this.loadTableData()
}
}
},
methods: {
async loadTableData() {
if (!this.reportId) {
return
}
this.loading = true
try {
const data = await this.$axios.get(appendQuery(`/api/v1/reports/reports/${this.reportId}/data/`, {
...pickReportQuery(this.reportQuery),
export: 'table'
}))
this.tableData = {
columns: data.columns || [],
rows: data.rows || []
}
} finally {
this.loading = false
}
},
handleTypeChange(val) {
if (val === 'table') {
this.loadTableData()
}
},
handleConfirm() {
if (!this.reportId) {
return
}
download(appendQuery(`/api/v1/reports/reports/${this.reportId}/data/`, {
...pickReportQuery(this.reportQuery),
export: this.exportType
}))
this.handleClose()
},
handleClose() {
this.iVisible = false
}
}
}
</script>
<style lang="scss" scoped>
.export-dialog {
display: flex;
flex-direction: column;
gap: 16px;
}
.excel-preview {
min-height: 160px;
color: var(--color-text-primary);
}
</style>

View File

@@ -0,0 +1,160 @@
<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>
</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'
export default {
name: 'ReportToolbar',
components: {
Select2,
DatetimeRangePicker
},
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: () => ({})
}
},
data() {
return {
presetOptions: REPORT_RANGE_PRESET_OPTIONS,
localRangePreset: 'last_week',
localDateRange: [],
localFilterValue: ''
}
},
watch: {
filters: {
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 || ''
}
}
},
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)
}
}
}
</script>
<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>

View File

@@ -1,46 +1,249 @@
<template>
<div class="nav-bar-right export-bar">
<div :class="['nav-bar-right', 'export-bar', { 'editor-only': editorOnly }]">
<el-button-group>
<el-button
:loading="exportLoading"
:disabled="exportLoading"
class="export-btn"
type="text"
icon="el-icon-printer"
@click="exportPdf"
>
{{ $t('ExportAsPDF') }}
</el-button>
<el-button class="export-btn" type="text" icon="el-icon-message" @click="emailReport">
{{ $t('EMailReport') }}
</el-button>
<el-button class="export-btn" type="text" icon="el-icon-printer" @click="printReport">
{{ $t('Print') }}
</el-button>
<template v-if="showEditorButton && canSaveReport">
<el-button class="export-btn" type="text" :icon="isCustomReport ? 'el-icon-edit' : 'el-icon-plus'" @click="openEditor">
{{ isCustomReport ? $t('Edit') : $t('Save') }}
</el-button>
</template>
<template v-if="showCustomActions">
<el-dropdown v-if="showOperationDropdown" class="export-btn" @command="handleCommand">
<span class="el-dropdown-link">
{{ $t('Operation') }}
<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-menu>
</el-dropdown>
<template v-if="!showOperationOnlyInEditor || !editorOnly">
<el-button class="export-btn" type="text" icon="el-icon-download" @click="showExportDialog = true">
{{ $t('Export') }}
</el-button>
<el-button class="export-btn" type="text" icon="el-icon-printer" @click="printReport">
{{ $t('Print') }}
</el-button>
</template>
</template>
<template v-if="!editorOnly && (!isCustomReport || forceDefaultActions)">
<el-button
:loading="exportLoading"
:disabled="exportLoading"
class="export-btn"
type="text"
icon="el-icon-printer"
@click="exportPdf"
>
{{ $t('ExportAsPDF') }}
</el-button>
<el-button class="export-btn" type="text" icon="el-icon-message" @click="emailReport">
{{ $t('EMailReport') }}
</el-button>
<el-button class="export-btn" type="text" icon="el-icon-printer" @click="printReport">
{{ $t('Print') }}
</el-button>
</template>
</el-button-group>
<CreateReportDialog
:default-days="getDaysParam()"
:report="editingReport"
:report-title="title"
:report-type="name"
:visible.sync="showCreateDialog"
@created="handleCreated"
/>
<ReportExportDialog
:report-id="reportId"
:report-name="title"
: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 { scopedLocalStorage as localStorage } from '@/utils/storage'
import CreateReportDialog from './CreateReportDialog.vue'
import ReportExecutionDrawer from './ReportExecutionDrawer.vue'
import ReportExportDialog from './ReportExportDialog.vue'
import { appendQuery, pickReportQuery } from './reportUtils'
const REPORT_ACTION_PERM_MAP = {
UserLoginReport: {
create: 'rbac.add_userloginreport',
delete: 'rbac.delete_userloginreport'
},
UserChangePasswordReport: {
create: 'rbac.add_userchangepasswordreport',
delete: 'rbac.delete_userchangepasswordreport'
},
AssetStatistics: {
create: 'rbac.add_assetstatisticsreport',
delete: 'rbac.delete_assetstatisticsreport'
},
AssetReport: {
create: 'rbac.add_assetactivityreport',
delete: 'rbac.delete_assetactivityreport'
},
AccountStatistics: {
create: 'rbac.add_accountstatisticsreport',
delete: 'rbac.delete_accountstatisticsreport'
},
AccountAutomationReport: {
create: 'rbac.add_accountautomationreport',
delete: 'rbac.delete_accountautomationreport'
}
}
export default {
name: 'RightAction',
components: {
CreateReportDialog,
ReportExecutionDrawer,
ReportExportDialog
},
props: {
name: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
editorOnly: {
type: Boolean,
default: false
},
showEditorButton: {
type: Boolean,
default: false
},
showCustomActionsInEditor: {
type: Boolean,
default: false
},
showOperationOnlyInEditor: {
type: Boolean,
default: false
},
showOperationDropdown: {
type: Boolean,
default: true
},
forceDefaultActions: {
type: Boolean,
default: false
}
},
data() {
return {
exportLoading: false
exportLoading: false,
reportData: null,
showCreateDialog: false,
showExecutionDrawer: false,
showExportDialog: false
}
},
computed: {
reportId() {
const v = this.$route.query.report_id
if (Array.isArray(v)) return v[0]
return v || ''
},
isCustomReport() {
return !!this.reportId
},
reportActionPerms() {
return REPORT_ACTION_PERM_MAP[this.name] || {}
},
canSaveReport() {
const perm = this.reportActionPerms.create
return !perm || this.$hasPerm(perm)
},
canDeleteReport() {
if (!this.isCustomReport) {
return false
}
const perm = this.reportActionPerms.delete
return !perm || this.$hasPerm(perm)
},
showCustomActions() {
if (this.forceDefaultActions) {
return false
}
if (!this.isCustomReport) {
return false
}
if (!this.editorOnly) {
return true
}
return this.showCustomActionsInEditor
},
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
}
if (this.isCustomReport) {
return {
...(this.reportData || {}),
filters: {
...(this.reportData?.filters || {}),
...filters
}
}
}
return {
filters
}
},
filterField() {
return {
UserLoginReport: 'user_id',
UserChangePasswordReport: 'user_id',
AssetStatistics: 'asset_id',
AssetReport: 'asset_id',
AccountStatistics: 'account',
AccountAutomationReport: 'account'
}[this.name] || ''
}
},
watch: {
reportId: {
immediate: true,
handler() {
if (this.reportId) {
this.loadReportDetail()
} else {
this.reportData = null
}
}
}
},
methods: {
async loadReportDetail() {
this.reportData = await this.$axios.get(`/api/v1/reports/reports/${this.reportId}/`)
},
checkName() {
if (!this.name) {
this.$message.error('Please select a chart')
@@ -55,8 +258,12 @@ export default {
if (!this.checkName()) {
return
}
const days = this.getDaysParam()
const exportUrl = `/core/reports/export-pdf/?chart=${this.name}&days=${days}`
const query = pickReportQuery(this.$route.query)
const exportUrl = appendQuery('/core/reports/export-pdf/', {
chart: this.name,
days: this.getDaysParam(),
...query
})
this.$message.success(this.$t('Export') + '...')
download(exportUrl)
},
@@ -64,9 +271,14 @@ export default {
if (!this.checkName()) {
return
}
const days = this.getDaysParam()
const query = pickReportQuery(this.$route.query)
const url = appendQuery('/core/reports/send-mail/', {
chart: this.name,
days: this.getDaysParam(),
...query
})
this.$message.success(this.$t('EMailReport') + '...')
this.$axios.post(`/core/reports/send-mail/?chart=${this.name}&days=${days}`,).then((res) => {
this.$axios.post(url).then((res) => {
if (res.error) {
this.$message.error(res.error)
} else {
@@ -78,6 +290,44 @@ export default {
},
printReport() {
window.print()
},
openEditor() {
if (!this.canSaveReport) {
return
}
if (this.isCustomReport && !this.reportData) {
this.loadReportDetail().then(() => {
this.showCreateDialog = true
})
return
}
this.showCreateDialog = true
},
handleCommand(command) {
if (command === 'history') {
this.showExecutionDrawer = true
return
}
if (command === 'delete' && this.canDeleteReport) {
this.handleDelete()
}
},
async handleDelete() {
if (!this.canDeleteReport) {
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: {} })
},
handleCreated(report) {
this.$router.push({
path: this.$route.path,
query: {
report_id: report.id
}
})
}
}
}
@@ -111,7 +361,10 @@ export default {
.export-btn .el-icon-document,
.export-btn .el-icon-printer,
.export-btn .el-icon-message {
.export-btn .el-icon-message,
.export-btn .el-icon-download,
.export-btn .el-icon-plus,
.export-btn .el-icon-tickets {
margin-right: 4px;
}
@@ -124,5 +377,36 @@ export default {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
&.editor-only {
padding: 0;
height: auto;
.el-button-group {
display: inline-flex;
align-items: center;
}
.export-btn,
.export-btn.el-button--text,
.el-dropdown-link {
color: #333;
}
.export-btn,
.el-dropdown-link {
display: inline-flex;
align-items: center;
height: 32px;
line-height: 32px;
padding-top: 0;
padding-bottom: 0;
}
.export-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #333;
}
}
}
</style>
</style>

View File

@@ -0,0 +1,343 @@
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'
}
const TABLE_LABEL_KEY_MAP = {
user_stats: 'Overview',
user_by_source: 'LoginSource',
user_login_log_metrics: 'UserLoginTrends',
user_login_failed_metrics: 'UserLoginTrends',
user_login_method_metrics: 'LoginMethodStatistics',
user_login_time_metrics: 'VisitTimeDistribution',
session_stats: 'Overview',
asset_login_log_metrics: 'AssetLoginTrends',
user_asset_activity_metrics: 'UserAssetActivity',
asset_stats: 'Overview',
assets_by_type_category: 'AssetTypeDistribution',
added_asset_metrics: 'WeeklyGrowthTrend',
execution_metrics: 'TaskExecutionTrends',
account_result_metrics: 'AccountResult',
account_stats: 'Overview',
automation_stats: 'Overview',
source_pie: 'AccountCreationSourceDistribution',
by_connectivity: 'AccountConnectivityStatusDistribution',
change_secret_account_metrics: 'AccountPasswordChangeTrends',
top_assets: 'RankByNumberOfAssetAccounts',
top_version_accounts: 'AccountAndPasswordChangeRank',
total_count_change_password: 'Overview',
change_password_top10_users: 'PasswordChangeUserRank',
change_password_top10_change_bys: 'PasswordChangeOperatorRank',
user_change_password_metrics: 'PasswordChangeLog'
}
const COLUMN_LABEL_KEY_MAP = {
date: 'Date',
name: 'Name',
metric: 'Metric',
value: 'Value',
count: 'Count',
total: 'Total',
active: 'Active',
connected: 'Connectable',
zone: 'LinkedDomains',
directory_services: 'ConnectedDirectoryServices',
platform_count: 'Platform',
asset_count: 'Asset',
user_count: 'User',
not_enabled_mfa: 'NotEnableMfa',
first_login: 'FirstLogin',
valid: 'Valid',
face_vector: 'FaceVector',
need_update_password: 'NeedUpdatePassword',
user_total: 'TargetUser',
change_by_total: 'Operator',
su_from: 'SuFrom',
date_change_secret: 'ResetSecret',
template_total: 'BaseAccountTemplate',
account_count: 'AccountTotal',
version: 'Version',
user: 'Username',
change_by: 'Username'
}
export default {
data() {
return {
reportDetail: null,
displayMode: 'chart',
// now supports multiple tables: array of { name, columns, rows }
tableData: []
}
},
watch: {
'$route.fullPath'() {
this.reportDetail = null
if (typeof this.getData === 'function') {
this.getData()
}
}
},
computed: {
reportId() {
const v = this.$route.query.report_id
if (Array.isArray(v)) return v[0]
return v || ''
},
isCustomReport() {
return !!this.reportId
},
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 || {}
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] || '') : ''
}
}
},
methods: {
async ensureReportDetail(reportId = this.reportId) {
if (!reportId) {
return null
}
if (this.reportDetail?.id === reportId) {
return this.reportDetail
}
const data = await this.$axios.get(`/api/v1/reports/reports/${reportId}/`)
if (this.reportId !== reportId) {
return data
}
this.reportDetail = data
if (data?.name) {
this.title = data.name
}
return data
},
buildTemplateUrl(baseUrl) {
const query = pickReportQuery(this.$route.query)
if (!query.start && !query.end && !query.range_preset && this.days) {
query.days = this.days
}
return appendQuery(baseUrl, query)
},
async fetchReportData(baseUrl) {
const reportId = this.reportId
const query = pickReportQuery(this.$route.query)
if (reportId) {
await this.ensureReportDetail(reportId)
if (this.reportId !== reportId) {
return this.fetchReportData(baseUrl)
}
return this.$axios.get(appendQuery(`/api/v1/reports/reports/${reportId}/data/`, query))
}
return this.$axios.get(this.buildTemplateUrl(baseUrl))
},
async loadTableData(baseUrl) {
const buildLabel = (k) => {
if (!k) return ''
const labelKey = TABLE_LABEL_KEY_MAP[k]
if (labelKey) return this.$t(labelKey)
return k.replace(/_/g, ' ').replace(/metrics/gi, '').trim()
}
const translateColumnLabel = (label) => {
if (!label) return ''
const l = String(label).toLowerCase()
const labelKey = COLUMN_LABEL_KEY_MAP[l]
if (labelKey) return this.$t(labelKey)
// specific patterns first to avoid generic 'total' overriding them
if (l.includes('active_users') || l.includes('total_count_active_users')) return this.$t('ActiveUsers')
if (l.includes('active_assets') || l.includes('total_count_active_assets')) return this.$t('ActiveAssets')
if (l.includes('success') || l === 'dates_metrics_total_count_success') return this.$t('Success')
if (l.includes('failure') || l.includes('failed') || l === 'dates_metrics_total_count_failed') return this.$t('Failed')
if (l.includes('count')) return this.$t('Count')
if (l.includes('total')) return this.$t('Total')
return String(label).replace(/_/g, ' ')
}
// Fetch report payload (works for saved reports and templates)
let obj = null
try {
const payload = await this.fetchReportData(baseUrl)
obj = payload
} catch (e) {
obj = null
}
if (obj) {
try {
const tables = []
// Helper to build date-based table from a metric object
const buildDateTable = (groupKey, groupVal) => {
const dates = groupVal.dates_metrics_date || []
const rows = dates.map(d => ({ date: d }))
const columns = [{ key: 'date', label: this.$t('Date') }]
Object.entries(groupVal).forEach(([k, v]) => {
if (k === 'dates_metrics_date') return
// arrays like dates_metrics_success_total
if (Array.isArray(v)) {
const colKey = `${groupKey}.${k}`
const raw = k.replace(/^dates_metrics_?/, '')
const label = translateColumnLabel(raw)
columns.push({ key: colKey, label })
rows.forEach((row, idx) => {
row[colKey] = (v && v[idx] !== undefined) ? v[idx] : ''
})
return
}
// nested objects like dates_metrics_total: { "密码": [..] }
if (v && typeof v === 'object') {
Object.entries(v).forEach(([innerKey, innerArr]) => {
if (!Array.isArray(innerArr)) return
const colKey = `${groupKey}.${innerKey}`
const label = translateColumnLabel(innerKey)
columns.push({ key: colKey, label })
rows.forEach((row, idx) => {
row[colKey] = (innerArr && innerArr[idx] !== undefined) ? innerArr[idx] : ''
})
})
}
})
return { name: buildLabel(groupKey), columns, rows }
}
// Iterate every top-level key in payload and build appropriate table
for (const [k, v] of Object.entries(obj)) {
// skip null/undefined
if (v === null || v === undefined) continue
// 1) date-series metric objects
if (typeof v === 'object' && Array.isArray(v.dates_metrics_date)) {
tables.push(buildDateTable(k, v))
continue
}
// 2) arrays of {name,value} (e.g., user_by_source)
if (Array.isArray(v) && v.length && typeof v[0] === 'object' && ('name' in v[0] || 'label' in v[0])) {
const columns = [{ key: 'name', label: this.$t('Name') }, { key: 'value', label: this.$t('Value') }]
const rows = v.map(item => ({ name: item.name || item.label || '', value: item.value || item.count || 0 }))
tables.push({ name: buildLabel(k) || k, columns, rows })
continue
}
// 3) plain object of metric buckets (e.g., user_login_time_metrics, user_stats)
if (typeof v === 'object') {
// if its values are primitives (numbers/strings), render key-value table
const entries = Object.entries(v)
const primitive = entries.every(([, val]) => (typeof val !== 'object'))
if (primitive) {
const columns = [{ key: 'metric', label: this.$t('Metric') }, { key: 'value', label: this.$t('Value') }]
const rows = entries.map(([kk, vv]) => ({ metric: translateColumnLabel(kk) || kk, value: vv }))
tables.push({ name: buildLabel(k) || k, columns, rows })
continue
}
// otherwise, attempt to detect nested arrays/object and fallback to date table if matches
if (entries.some(([, val]) => Array.isArray(val))) {
tables.push(buildDateTable(k, v))
continue
}
}
}
if (tables.length) {
this.tableData = tables
return
}
} catch (e) {
// ignore and fallback
console.error('build table from payload failed', e)
}
}
// fallback: request export=table
try {
const fallback = await this.$axios.get(appendQuery(this.buildTemplateUrl(baseUrl), { export: 'table' }))
this.tableData = fallback
return
} catch (e) {
this.tableData = []
return
}
},
handleToolbarFilterChange({ range_preset, start, end, filter_value }) {
const query = {}
if (this.reportId) {
query.report_id = this.reportId
}
if (range_preset && range_preset !== 'custom') {
query.range_preset = range_preset
}
if (range_preset === 'custom') {
query.start = start
query.end = end
}
if (this.filterField && filter_value) {
query[this.filterField] = filter_value
}
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

@@ -0,0 +1,60 @@
import i18n from '@/i18n/i18n'
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 }
]
export const REPORT_FILTER_QUERY_KEYS = [
'start',
'end',
'range_preset',
'user_id',
'asset_id',
'account',
'report_id'
]
export const REPORT_PRESET_DAYS_MAP = REPORT_RANGE_PRESET_OPTIONS.reduce((acc, item) => {
if (item.days) {
acc[item.value] = item.days
}
return acc
}, {})
export function pickReportQuery(query = {}) {
return REPORT_FILTER_QUERY_KEYS.reduce((acc, key) => {
if (query[key] !== undefined && query[key] !== null && query[key] !== '') {
acc[key] = query[key]
}
return acc
}, {})
}
export function isSameReportQuery(currentQuery = {}, nextQuery = {}) {
return JSON.stringify(pickReportQuery(currentQuery)) === JSON.stringify(pickReportQuery(nextQuery))
}
export function appendQuery(url, query = {}) {
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.set(key, value)
}
})
const queryString = params.toString()
if (!queryString) {
return url
}
return `${url}${url.includes('?') ? '&' : '?'}${queryString}`
}
export function getPresetLabel(value) {
const preset = REPORT_RANGE_PRESET_OPTIONS.find(item => item.value === value)
return preset ? preset.label : value
}

View File

@@ -20,6 +20,10 @@ export default {
days: {
type: [String, Number],
default: 7
},
metrics: {
type: Object,
default: () => ({})
}
},
data() {
@@ -39,31 +43,49 @@ export default {
}
},
watch: {
metrics: {
handler() {
if (this.metrics?.dates_metrics_date?.length) {
this.applyMetrics(this.metrics)
}
},
deep: true
},
days() {
if (this.metrics?.dates_metrics_date?.length) {
return
}
this.getMetricData()
}
},
mounted() {
try {
this.getMetricData()
if (this.metrics?.dates_metrics_date?.length) {
this.applyMetrics(this.metrics)
} else {
this.getMetricData()
}
} finally {
this.loading = true
}
},
methods: {
applyMetrics(data) {
const activeUsers = data?.dates_metrics_total_count_active_users || []
const activeAssets = data?.dates_metrics_total_count_active_assets || []
this.lineChartConfig.datesMetrics = data?.dates_metrics_date || []
if (activeUsers.length > 0) {
this.lineChartConfig.primaryData = activeUsers
}
if (activeAssets.length > 0) {
this.lineChartConfig.secondaryData = activeAssets
}
},
async getMetricData() {
setTimeout(() => {
const url = `/api/v1/index/?dates_metrics=1&days=${this.days}`
this.$axios.get(url).then(data => {
const activeUsers = data?.dates_metrics_total_count_active_users
const activeAssets = data?.dates_metrics_total_count_active_assets
this.lineChartConfig.datesMetrics = data.dates_metrics_date
if (activeUsers.length > 0) {
this.lineChartConfig.primaryData = activeUsers
}
if (activeAssets.length > 0) {
this.lineChartConfig.secondaryData = activeAssets
}
this.applyMetrics(data)
})
}, 500)
}
@@ -83,5 +105,4 @@ export default {
margin-bottom: 8px;
}
}
</style>
</style>

View File

@@ -21,6 +21,10 @@ export default {
type: [Number, String],
default: '7'
},
metrics: {
type: Object,
default: () => ({})
},
isTitle: {
type: Boolean,
default: true
@@ -47,31 +51,49 @@ export default {
}
},
watch: {
metrics: {
handler() {
if (this.metrics?.dates_metrics_date?.length) {
this.applyMetrics(this.metrics)
}
},
deep: true
},
days() {
if (this.metrics?.dates_metrics_date?.length) {
return
}
this.getMetricData()
}
},
mounted() {
try {
this.getMetricData()
if (this.metrics?.dates_metrics_date?.length) {
this.applyMetrics(this.metrics)
} else {
this.getMetricData()
}
} finally {
this.loading = true
}
},
methods: {
applyMetrics(data) {
const success = data?.dates_metrics_total_count_success || []
const failed = data?.dates_metrics_total_count_failed || []
this.lineChartConfig.datesMetrics = data?.dates_metrics_date || []
if (success.length > 0) {
this.lineChartConfig.primaryData = success
}
if (failed.length > 0) {
this.lineChartConfig.secondaryData = failed
}
},
async getMetricData() {
setTimeout(() => {
const url = `/api/v1/accounts/change-secret-dashboard/?daily_success_and_failure_metrics=1&days=${this.days}`
this.$axios.get(url).then(data => {
const success = data?.dates_metrics_total_count_success
const failed = data?.dates_metrics_total_count_failed
this.lineChartConfig.datesMetrics = data?.dates_metrics_date
if (success.length > 0) {
this.lineChartConfig.primaryData = success
}
if (failed.length > 0) {
this.lineChartConfig.secondaryData = failed
}
this.applyMetrics(data)
})
}, 500)
}
@@ -91,5 +113,4 @@ export default {
margin-bottom: 8px;
}
}
</style>
</style>

View File

@@ -4,11 +4,12 @@
:title="title"
:nav="nav"
:name="name"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
>
<div class="charts-grid">
<SwitchDate class="switch-date" :name="name" @change="onChange" />
<br>
<template v-if="displayMode === 'chart'">
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
@@ -18,6 +19,16 @@
</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 full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('UserModificationTrends') }}</div>
@@ -43,6 +54,55 @@
<RankTable :config="config.change_password_top10_change_bys" />
</div>
</div>
</template>
<div v-else class="full-width">
<div v-if="Array.isArray(tableData)" class="report-tables full-width">
<div v-if="tableData.length" class="report-table-wrap chart-container full-width">
<div class="chart-container-title" v-if="tableData[0].name">
<div class="chart-container-title-text">{{ tableData[0].name }}</div>
</div>
<div class="report-card-body">
<el-table :data="tableData[0].rows" border>
<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>
</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 class="chart-container-title" v-if="t.name">
<div class="chart-container-title-text">{{ t.name }}</div>
</div>
<div class="report-card-body">
<el-table :data="t.rows" border>
<el-table-column v-for="column in t.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
</el-table>
</div>
</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>
</div>
</div>
</div>
</BaseReport>
</div>
@@ -56,7 +116,8 @@ import SummaryCountCard from '@/components/Dashboard/SummaryCountCard.vue'
import { mixColors } from '@/views/reports/const'
import * as echarts from 'echarts'
import Echart from '@/components/Dashboard/Echart.vue'
import { scopedLocalStorage as localStorage } from '@/utils/storage'
import reportPageMixin from '@/views/reports/base/reportPageMixin'
import ReportToolbar from '@/views/reports/base/ReportToolbar.vue'
export default {
components: {
@@ -64,8 +125,10 @@ export default {
RankTable,
BaseReport,
SwitchDate,
Echart
Echart,
ReportToolbar
},
mixins: [reportPageMixin],
props: {
nav: {
type: Boolean,
@@ -266,7 +329,8 @@ export default {
this.days = val
},
async getData() {
const data = await this.$axios.get(`/api/v1/reports/reports/user-change-password/?days=${this.days}`)
const data = await this.fetchReportData('/api/v1/reports/reports/user-change-password/')
await this.loadTableData('/api/v1/reports/reports/user-change-password/')
this.$set(this.total_count_change_password, 'total', data.total_count_change_password.total)
this.$set(this.total_count_change_password, 'user_total', data.total_count_change_password.user_total)
this.$set(this.total_count_change_password, 'change_by_total', data.total_count_change_password.change_by_total)
@@ -281,4 +345,4 @@ export default {
<style lang="scss" scoped>
</style>
</style>

View File

@@ -4,67 +4,122 @@
:title="title"
:nav="nav"
:name="name"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
>
<div class="charts-grid">
<template v-if="displayMode === 'chart'">
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
:items="totalData"
/>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
:items="totalData"
<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 full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('UserLoginTrends') }}</div>
<div class="chart">
<Echart
ref="loginTrend"
:options="loginTrendOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('LoginSource') }}</div>
<div class="chart">
<Echart
:options="LoginSourceOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('VisitTimeDistribution') }}</div>
<div class="chart">
<Echart
:options="VisitTimeOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('LoginMethodStatistics') }}</div>
<div class="chart">
<Echart
:options="loginMethodOptions"
:autoresize="true"
/>
</div>
</div>
</div>
</template>
<div v-else class="full-width">
<div v-if="Array.isArray(tableData)" class="report-tables full-width">
<div v-if="tableData.length" class="report-table-wrap chart-container full-width">
<div class="chart-container-title" v-if="tableData[0].name">
<div class="chart-container-title-text">{{ tableData[0].name }}</div>
</div>
<el-table :data="tableData[0].rows" border>
<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>
</div>
<SwitchDate class="switch-date" :name="name" @change="onChange" />
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('UserLoginTrends') }}</div>
<div class="chart">
<Echart
ref="loginTrend"
:options="loginTrendOptions"
:autoresize="true"
/>
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap chart-container full-width">
<div class="chart-container-title" v-if="t.name">
<div class="chart-container-title-text">{{ t.name }}</div>
</div>
<el-table :data="t.rows" border>
<el-table-column v-for="column in t.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
</el-table>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('LoginSource') }}</div>
<div class="chart">
<Echart
:options="LoginSourceOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('VisitTimeDistribution') }}</div>
<div class="chart">
<Echart
:options="VisitTimeOptions"
:autoresize="true"
/>
</div>
</div>
</div>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('LoginMethodStatistics') }}</div>
<div class="chart">
<Echart
:options="loginMethodOptions"
:autoresize="true"
/>
</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>
</div>
</div>
</div>
@@ -74,20 +129,21 @@
<script>
import BaseReport from '@/views/reports/base/BaseReport.vue'
import SwitchDate from '@/components/Dashboard/SwitchDate'
import * as echarts from 'echarts'
import { mixColors } from '@/views/reports/const'
import SummaryCountCard from '@/components/Dashboard/SummaryCountCard.vue'
import Echart from '@/components/Dashboard/Echart.vue'
import { scopedLocalStorage as localStorage } from '@/utils/storage'
import reportPageMixin from '@/views/reports/base/reportPageMixin'
import ReportToolbar from '@/views/reports/base/ReportToolbar.vue'
export default {
components: {
SummaryCountCard,
BaseReport,
SwitchDate,
Echart
Echart,
ReportToolbar
},
mixins: [reportPageMixin],
props: {
nav: {
type: Boolean,
@@ -414,7 +470,8 @@ export default {
localStorage.setItem('reportDays', val)
},
async getData() {
const data = await this.$axios.get(`/api/v1/reports/reports/users/?days=${this.days}`)
const data = await this.fetchReportData('/api/v1/reports/reports/users/')
await this.loadTableData('/api/v1/reports/reports/users/')
this.$set(this.user_stats, 'total', data.user_stats.total)
this.$set(this.user_stats, 'not_enabled_mfa', data.user_stats.not_enabled_mfa)
this.$set(this.user_stats, 'valid', data.user_stats.valid)
@@ -441,4 +498,4 @@ export default {
<style lang="scss" scoped>
</style>
</style>

View File

@@ -7,33 +7,58 @@
<ul class="folder-list m-b-md" style="padding: 0">
<li
v-for="chart in chartItems"
:key="chart.name"
:class="{ active: selectedChart && selectedChart.name === chart.name }"
:key="chart.key"
:class="{ active: isActive(chart) }"
>
<a style="display: flex; align-items: center;" @click="handleChangeChart(chart)">
<a class="menu-link" @click="handleChangeChart(chart)">
<i :class="chart.icon" style="margin-right: 6px;" />
{{ chart.title }}
</a>
<ul v-if="chart.children && chart.children.length" class="report-children">
<li
v-for="child in chart.children"
:key="child.key"
:class="{ active: isActive(child) }"
>
<a class="menu-link child-link" @click="handleChangeChart(child)">
{{ child.title }}
</a>
</li>
</ul>
</li>
</ul>
</div>
</el-col>
<el-col :span="20" style="background-color: #fff" class="chart">
<component :is="component" :nav="false" :url="url" />
<component :is="component" :key="componentKey" :nav="false" :url="url" />
</el-col>
</el-row>
</Page>
</template>
<script>
import UserReport from './UserActivity.vue'
import Page from '@/layout/components/Page'
import { resolveRoute } from '@/utils/vue/index'
import { appendQuery, isSameReportQuery } from '@/views/reports/base/reportUtils'
const TEMPLATE_ROUTE_MAP = {
UserLoginReport: {
name: 'UserReport',
titleKey: 'UserLoginReport',
icon: 'fa fa-sign-in',
perm: 'rbac.view_userloginreport'
},
UserChangePasswordReport: {
name: 'ChangePassword',
titleKey: 'UserChangePasswordReport',
icon: 'fa fa-key',
perm: 'rbac.view_userchangepasswordreport'
}
}
export default {
name: 'Users',
components: {
UserReport,
Page
},
data() {
@@ -41,41 +66,102 @@ export default {
url: '',
title: this.$t('ReportType'),
component: '',
selectedChart: null,
charts: [
{
title: this.$t('UserLoginReport'),
name: 'UserReport',
icon: 'fa fa-sign-in',
hidden: this.$hasPerm('rbac.view_userloginreport')
},
{
title: this.$t('UserChangePasswordReport'),
name: 'ChangePassword',
icon: 'fa fa-key',
hidden: this.$hasPerm('rbac.view_userchangepasswordreport')
}
]
componentKey: '',
selectedChartKey: '',
chartItems: []
}
},
computed: {
chartItems() {
return this.charts.filter(chart => chart.hidden)
watch: {
'$route.fullPath'() {
this.syncSelectedFromRoute()
}
},
created() {
if (this.chartItems.length > 0) {
this.handleChangeChart(this.chartItems[0])
}
async created() {
await this.loadCatalog()
},
methods: {
handleChangeChart(chart) {
this.selectedChart = chart
const route = resolveRoute({ name: chart.name }, this.$router)
getBuiltInTemplates() {
return Object.entries(TEMPLATE_ROUTE_MAP)
.filter(([, item]) => this.$hasPerm(item.perm))
.map(([reportType, item]) => ({
key: reportType,
reportType,
title: this.$t(item.titleKey),
routeName: item.name,
icon: item.icon,
isCustom: false,
query: {},
children: []
}))
},
async loadCatalog() {
const templates = this.getBuiltInTemplates()
const chartMap = templates.reduce((acc, item) => {
acc[item.reportType] = item
return acc
}, {})
try {
const data = await this.$axios.get('/api/v1/reports/reports/catalog/')
data.forEach((group) => {
const target = chartMap[group.tp]
if (!target) {
return
}
target.children = (group.children || []).map(child => ({
key: `report-${child.id}`,
title: child.name,
routeName: target.routeName,
reportId: child.id,
isCustom: true,
query: { report_id: child.id }
}))
})
} catch (error) {
console.error('load report catalog failed', error)
}
this.chartItems = templates
this.syncSelectedFromRoute()
},
syncSelectedFromRoute() {
const raw = this.$route.query.report_id
const reportId = Array.isArray(raw) ? raw[0] : raw
let target = null
if (reportId) {
target = this.chartItems
.flatMap(item => item.children || [])
.find(item => item.reportId === reportId)
if (!target) {
this.loadCatalog()
return
}
}
if (!target) {
target = this.chartItems[0]
}
if (target) {
this.applyChart(target)
}
},
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
const routePath = route.path
this.url = window.__UI_BASE__ + '#' + routePath
this.name = chart.name
this.componentKey = `${chart.key}-${this.$route.fullPath}`
this.url = appendQuery('/ui/#' + route.path, chart.query || {})
},
handleChangeChart(chart) {
const nextQuery = chart.query || {}
if (isSameReportQuery(this.$route.query, nextQuery)) {
this.applyChart(chart)
return
}
this.$router.replace({
path: this.$route.path,
query: nextQuery
})
}
}
}
@@ -103,9 +189,26 @@ h5 {
margin-right: 10px;
}
}
.menu-link {
display: flex;
align-items: center;
}
.report-children {
margin: 6px 0 0 18px;
padding: 0;
}
.child-link {
color: #606266;
font-size: 12px;
}
.tag-container {
border-radius: 5px;
}
.chart {
padding: 10px;
@@ -115,9 +218,10 @@ h5 {
height: 100%;
}
}
.folder-list li.active {
color: var(--color-primary);
background-color: var(--menu-hover);
border-radius: 4px;
}
</style>
</style>