mirror of
https://github.com/jumpserver/lina.git
synced 2026-05-17 21:16:46 +00:00
perf: Optimize the report page
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
336
src/views/reports/base/CreateReportDialog.vue
Normal file
336
src/views/reports/base/CreateReportDialog.vue
Normal 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>
|
||||
220
src/views/reports/base/ReportExecutionDrawer.vue
Normal file
220
src/views/reports/base/ReportExecutionDrawer.vue
Normal 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>
|
||||
166
src/views/reports/base/ReportExportDialog.vue
Normal file
166
src/views/reports/base/ReportExportDialog.vue
Normal 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>
|
||||
160
src/views/reports/base/ReportToolbar.vue
Normal file
160
src/views/reports/base/ReportToolbar.vue
Normal 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>
|
||||
@@ -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>
|
||||
343
src/views/reports/base/reportPageMixin.js
Normal file
343
src/views/reports/base/reportPageMixin.js
Normal 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: {} })
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/views/reports/base/reportUtils.js
Normal file
60
src/views/reports/base/reportUtils.js
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user