feat: 迁移商务工作台指标和仪表盘榜单
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import service from '@/utils/request'
|
||||
|
||||
export const getWorkbenchSummary = params => service({ url: '/sw/workbench/summary', method: 'get', params, donNotShowLoading: true })
|
||||
export const getWorkbenchRanks = (endpoint, params) => service({ url: `/sw/workbench/${endpoint}`, method: 'get', params, donNotShowLoading: true })
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="business-dashboard">
|
||||
<el-card shadow="never">
|
||||
<div class="business-toolbar"><strong>商务数据</strong><el-select v-model="sources" multiple collapse-tags clearable placeholder="请选择所属平台" style="width: 260px" @change="refresh"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></div>
|
||||
<el-alert v-if="!sources.length" class="mt-3" type="info" :closable="false" title="请选择所属平台后查看数据" />
|
||||
<el-alert v-else-if="summaryError" class="mt-3" type="error" :closable="false" :title="summaryError" />
|
||||
<el-alert v-else-if="summary?.available === false" class="mt-3" type="warning" :closable="false" title="工作台所需业务数据尚未接入。" />
|
||||
</el-card>
|
||||
<div v-loading="summaryLoading" class="business-kpis">
|
||||
<el-card v-for="card in cards" :key="card.key" shadow="never">
|
||||
<div class="business-muted">{{ card.label }}</div>
|
||||
<div class="business-value">{{ metricValue(card) }}</div>
|
||||
<el-text :type="rateType(summary?.[card.key]?.changeRate)" size="small">{{ metricCompare(card.key) }}</el-text>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-card v-loading="alerts.loading" shadow="never">
|
||||
<div class="business-toolbar"><strong>AI 实时预警 <el-tag size="small" type="warning">待处理 {{ alerts.total == null ? '未获取' : alerts.total }} 条</el-tag></strong><el-button link type="primary" @click="viewAlerts">查看全部</el-button></div>
|
||||
<el-alert v-if="alerts.error" class="mt-3" type="warning" :closable="false" :title="alerts.error" />
|
||||
<el-carousel v-else-if="alerts.rows.length" ref="carousel" height="94px" :interval="4000" indicator-position="none" arrow="hover" :pause-on-hover="true">
|
||||
<el-carousel-item v-for="row in alerts.rows" :key="row.id"><button type="button" class="business-alert" @click="carousel?.next()"><div class="business-muted">{{ row.updatedAt || row.createdAt || '—' }} · {{ row.ruleType }}</div><strong>{{ row.targetPromoteName }} {{ row.ruleName }}</strong><div>{{ row.alertContent }}</div></button></el-carousel-item>
|
||||
</el-carousel>
|
||||
<el-empty v-else :description="sources.length ? '暂无待处理预警' : '请选择所属平台'" :image-size="48" />
|
||||
</el-card>
|
||||
<div class="business-ranks">
|
||||
<el-card v-for="panel in panels" :key="panel.key" v-loading="panel.loading" shadow="never">
|
||||
<div class="business-rank-heading"><strong>{{ panel.title }}</strong><el-radio-group v-if="panel.period" v-model="panel.period" size="small" @change="loadRank(panel)"><el-radio-button v-for="option in periods" :key="option.value" :value="option.value">{{ option.label }}</el-radio-button></el-radio-group></div>
|
||||
<el-alert v-if="panel.error" class="mt-3" type="warning" :closable="false" :title="panel.error" />
|
||||
<template v-else-if="panel.chart && panel.rows.length">
|
||||
<div v-for="row in panel.rows" :key="`${row.sourceDb}:${row.promoteId}`" class="business-bar">
|
||||
<div class="business-bar-heading"><span class="business-bar-name" :title="row.promoteName">{{ row.rank }}. {{ row.promoteName }} <el-text type="info" size="small">{{ row.sourceDbLabel }}</el-text></span><strong>{{ rankValue(panel, row.value) }}</strong></div>
|
||||
<el-progress :percentage="barWidth(panel, row)" :show-text="false" :stroke-width="8" />
|
||||
</div>
|
||||
</template>
|
||||
<el-table v-else-if="!panel.chart" :data="panel.rows" :row-key="row => `${row.sourceDb}:${row.promoteId}`" :empty-text="sources.length ? '暂无数据' : '请选择所属平台'">
|
||||
<el-table-column prop="sourceDbLabel" label="所属平台" width="92" />
|
||||
<el-table-column prop="rank" label="排名" width="65" />
|
||||
<el-table-column prop="promoteName" label="渠道名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="panel.key === 'unlogin' ? '未登录时长' : '充值金额'" min-width="140"><template #default="{ row }">{{ rankValue(panel, row.value) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else :description="sources.length ? '暂无数据' : '请选择所属平台'" :image-size="65" />
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getWorkbenchSummary, getWorkbenchRanks } from '@/api/workbench'
|
||||
import { listAlertRecords } from '@/api/alert'
|
||||
import { channelCount, channelMoney } from '@/view/sw/channel-display'
|
||||
|
||||
const router = useRouter(), carousel = ref()
|
||||
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||
const sources = ref(Object.keys(platforms)), summary = ref(null), summaryLoading = ref(false), summaryError = ref('')
|
||||
const alerts = reactive({ rows: [], total: null, loading: false, error: '', version: 0 })
|
||||
const periods = [{ label: '今日', value: 'today' }, { label: '昨日', value: 'yesterday' }, { label: '近7天', value: 'last7' }, { label: '近30天', value: 'last30' }]
|
||||
const cards = [{ key: 'todayRegister', label: '今日渠道玩家注册' }, { key: 'todayActive', label: '今日渠道活跃玩家' }, { key: 'todayPay', label: '今日充值金额', money: true }, { key: 'monthPay', label: '本月充值金额', money: true }, { key: 'activePromote', label: '活跃渠道数' }, { key: 'newPromote', label: '新增渠道数' }]
|
||||
const panels = reactive([
|
||||
{ key: 'register', endpoint: 'registerTop', title: '渠道注册 TOP10', period: 'today', chart: true },
|
||||
{ key: 'payHigh', endpoint: 'payRank', title: '渠道充值 TOP10', period: 'today', order: 'desc', chart: true },
|
||||
{ key: 'unlogin', endpoint: 'unloginTop', title: '未登录渠道排行 TOP10' },
|
||||
{ key: 'payLow', endpoint: 'payRank', title: '充值金额最低排行 TOP10', period: 'last30', order: 'asc' }
|
||||
].map(panel => ({ ...panel, rows: [], loading: false, error: '', version: 0 })))
|
||||
let summaryVersion = 0
|
||||
const count = value => value == null ? '未获取' : channelCount(String(value).split('.')[0])
|
||||
const metricValue = card => { const value = summary.value?.[card.key]?.value; if (value == null) return !sources.value.length ? '—' : summary.value?.available === false ? '未接入' : '未获取'; return card.money ? channelMoney(value) : count(value) }
|
||||
const rateType = rate => rate == null || Number(rate) === 0 ? 'info' : Number(rate) > 0 ? 'success' : 'danger'
|
||||
const metricCompare = key => {
|
||||
const card = summary.value?.[key]
|
||||
if (card?.value == null) return '—'
|
||||
if (card.changeRate == null) return `${card.compareLabel}:无可比数据`
|
||||
return `${card.compareLabel} ${Number(card.changeRate) > 0 ? '+' : ''}${card.changeRate}%`
|
||||
}
|
||||
const rankValue = (panel, value) => panel.key === 'register' ? count(value) : panel.key === 'unlogin' ? `${count(value)} 天` : channelMoney(value)
|
||||
// 浮点数仅用于条形宽度,金额标签始终使用接口十进制字符串。
|
||||
const barWidth = (panel, row) => { const max = Math.max(...panel.rows.map(item => Number(item.value)), 0); return max > 0 ? Math.min(100, Number(row.value) / max * 100) : 0 }
|
||||
const loadSummary = async () => {
|
||||
const current = ++summaryVersion; summary.value = null; summaryError.value = ''
|
||||
if (!sources.value.length) { summaryLoading.value = false; return }
|
||||
summaryLoading.value = true
|
||||
try { const res = await getWorkbenchSummary({ sourceDbList: sources.value.join(',') }); if (current !== summaryVersion) return; if (res.code === 0) summary.value = res.data; else summaryError.value = res.msg || '商务指标读取失败' }
|
||||
catch { if (current === summaryVersion) summaryError.value = '商务指标读取失败,请重试' } finally { if (current === summaryVersion) summaryLoading.value = false }
|
||||
}
|
||||
const loadRank = async panel => {
|
||||
const current = ++panel.version; panel.rows = []; panel.error = ''
|
||||
if (!sources.value.length) { panel.loading = false; return }
|
||||
panel.loading = true
|
||||
try { const res = await getWorkbenchRanks(panel.endpoint, { sourceDbList: sources.value.join(','), period: panel.period, order: panel.order }); if (current !== panel.version) return; if (res.code === 0) panel.rows = res.data; else panel.error = res.msg || '排行读取失败' }
|
||||
catch { if (current === panel.version) panel.error = '排行读取失败,请重试' } finally { if (current === panel.version) panel.loading = false }
|
||||
}
|
||||
const loadAlerts = async () => {
|
||||
const current = ++alerts.version; alerts.rows = []; alerts.total = null; alerts.error = ''
|
||||
if (!sources.value.length) { alerts.loading = false; return }
|
||||
alerts.loading = true
|
||||
try {
|
||||
const res = await listAlertRecords({ platformCodes: sources.value.join(','), status: 1, pageNum: 1, pageSize: 5 })
|
||||
if (current !== alerts.version) return
|
||||
if (res.code !== 0) alerts.error = res.msg || '预警读取失败'
|
||||
else if (res.data.available === false) alerts.error = '渠道数据尚未接入,暂时无法核对预警可见范围。'
|
||||
else { alerts.rows = res.data.pageData.rows; alerts.total = res.data.pageData.total }
|
||||
} catch { if (current === alerts.version) alerts.error = '预警读取失败,请重试' } finally { if (current === alerts.version) alerts.loading = false }
|
||||
}
|
||||
const viewAlerts = () => { const route = router.getRoutes().find(item => item.name === 'sw_AlertList' || /\/AlertList$/.test(item.path)); if (route) router.push(route.name ? { name: route.name } : route.path); else ElMessage.warning('未找到预警列表菜单,请确认访问权限') }
|
||||
const refresh = () => Promise.allSettled([loadSummary(), loadAlerts(), ...panels.map(loadRank)])
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.business-dashboard { display: flex; flex-direction: column; gap: 12px; }
|
||||
.business-toolbar, .business-rank-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.business-kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.business-value { font-size: 26px; font-weight: 600; margin: 14px 0 10px; overflow-wrap: anywhere; }
|
||||
.business-muted { color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.business-ranks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.business-rank-heading { margin-bottom: 16px; }
|
||||
.business-bar { margin-bottom: 16px; }
|
||||
.business-bar-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 7px; font-size: 13px; }
|
||||
.business-bar-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.business-bar-heading strong { flex-shrink: 0; }
|
||||
.business-alert { border: 0; background: transparent; color: var(--el-text-color-primary); text-align: left; width: 100%; cursor: pointer; padding: 10px 0; line-height: 1.6; }
|
||||
@media (max-width: 1100px) { .business-ranks { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 700px) { .business-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
@@ -4,13 +4,9 @@
|
||||
<kra-card class="rounded-xl border border-slate-200/80 bg-white px-5 py-6 shadow-sm dark:border-slate-700 dark:bg-slate-900">
|
||||
<p class="text-xs tracking-[0.2em] text-muted-foreground">DASHBOARD</p>
|
||||
<h1 class="mt-2 text-xl font-semibold text-base-text lg:text-2xl">欢迎回来,{{ userStore.userInfo.nickName }}</h1>
|
||||
<p class="mt-2 text-sm text-muted-foreground">{{ today }} · 当前展示后台实时数据</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">{{ today }} · 商务工作台</p>
|
||||
</kra-card>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<kra-card v-for="item in statistics" :key="item.title">
|
||||
<div class="p-2"><div class="text-sm text-muted-foreground">{{ item.title }}</div><div class="mt-3 text-3xl font-semibold text-base-text">{{ item.value }}</div></div>
|
||||
</kra-card>
|
||||
</div>
|
||||
<business-dashboard />
|
||||
<div class="grid grid-cols-1 items-stretch gap-2 xl:grid-cols-2">
|
||||
<kra-card title="快捷功能" show-action custom-class="min-h-[300px]"><kra-quick-link /></kra-card>
|
||||
<kra-card title="公告" show-action custom-class="min-h-[300px]"><kra-notice /></kra-card>
|
||||
@@ -20,19 +16,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { getUserList } from '@/api/user'
|
||||
import { getSysOperationRecordList } from '@/api/sysOperationRecord'
|
||||
import { getFileList } from '@/api/fileUploadAndDownload'
|
||||
import { getInfoList } from '@/modules/announcement/api/info'
|
||||
import BusinessDashboard from './components/BusinessDashboard.vue'
|
||||
import { KraNotice, KraQuickLink, KraCard } from './components'
|
||||
defineOptions({ name: 'Dashboard' })
|
||||
const userStore = useUserStore()
|
||||
const today = computed(() => new Date().toLocaleDateString('zh-CN'))
|
||||
const statistics = ref([{ title: '系统用户', value: '—' }, { title: '操作记录', value: '—' }, { title: '媒体文件', value: '—' }, { title: '系统公告', value: '—' }])
|
||||
onMounted(async () => {
|
||||
const results = await Promise.allSettled([getUserList({ page: 1, pageSize: 1 }), getSysOperationRecordList({ page: 1, pageSize: 1 }), getFileList({ page: 1, pageSize: 1 }), getInfoList({ page: 1, pageSize: 1 })])
|
||||
results.forEach((result, index) => { if (result.status === 'fulfilled' && result.value?.code === 0) statistics.value[index].value = result.value.data?.total ?? 0 })
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user