xinli/xinlidsj/pages/dashboard/index.vue
2026-02-25 18:16:20 +08:00

1130 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page" v-if="!isH5">
<view class="card">
<view class="filters">
<view class="chip" :class="{ active: range === '7d' }" @click="setRange('7d')">近7天</view>
<view class="chip" :class="{ active: range === '1m' }" @click="setRange('1m')">近1月</view>
<view class="chip" :class="{ active: range === '3m' }" @click="setRange('3m')">近3月</view>
<view class="chip" :class="{ active: range === 'all' }" @click="setRange('all')">全部时间</view>
</view>
<view class="filters" style="margin-top: 14rpx;">
<picker mode="selector" :range="prisonAreaOptions" :value="selectedPrisonAreaIndex" @change="onPrisonAreaChange">
<view class="chip" :class="{ active: selectedPrisonAreaIndex !== 0 }">{{ selectedPrisonAreaLabel }}</view>
</picker>
<view v-if="selectedPrisonAreaIndex !== 0" class="chip" @click="clearPrisonArea">清除</view>
</view>
<view v-if="errorMsg" class="error">{{ errorMsg }}</view>
</view>
<view class="card">
<view class="card-title">概览指标</view>
<view class="stats">
<view class="stat">
<view class="num">{{ displayedOverview.totalAssessments }}</view>
<view class="lab">测评总数</view>
</view>
<view class="stat">
<view class="num">{{ displayedOverview.completedAssessments }}</view>
<view class="lab">已完成</view>
</view>
<view class="stat">
<view class="num">{{ displayedOverview.uniqueParticipants }}</view>
<view class="lab">参与人数</view>
</view>
</view>
</view>
<view class="card">
<view class="card-title">趋势图(折线)</view>
<view class="chart-box">
<qiun-data-charts type="line" :opts="chartOpts" :chartData="lineChartData" canvasId="lineChart" />
</view>
</view>
<view class="card">
<view class="card-title">对比图(柱状)</view>
<view class="chart-box">
<qiun-data-charts type="column" :opts="chartOpts" :chartData="barChartData" canvasId="barChart" />
</view>
</view>
<view class="card">
<view class="card-title">占比图(环形)</view>
<view class="chart-box">
<qiun-data-charts type="ring" :opts="ringOpts" :chartData="ringChartData" canvasId="ringChart" />
</view>
</view>
<view class="panel">
<view class="card-title">热力图(监区×风险等级)</view>
<view v-if="!isH5" class="chart-box heat">
<qiun-data-charts type="demotype" :echartsApp="true" :eopts="heatEopts" :opts="chartOpts" :chartData="heatChartData" canvasId="heatChart" :canvas2d="true" />
</view>
<view v-else class="state">
<uni-icons type="info" size="34" color="#CBD5E1"></uni-icons>
<view class="state-text">H5 暂不支持热力图展示</view>
</view>
</view>
<view class="panel">
<view class="card-title">监区统计</view>
<view v-if="loading" class="state">
<uni-icons type="spinner-cycle" size="28" color="#94A3B8"></uni-icons>
<view class="state-text">加载中...</view>
</view>
<view v-else>
<view v-if="displayedDeptRows.length === 0" class="state">
<uni-icons type="home" size="34" color="#CBD5E1"></uni-icons>
<view class="state-text">暂无数据</view>
</view>
<view v-else class="list">
<view class="item" v-for="(row, idx) in displayedDeptRows" :key="idx">
<view class="item-title">{{ row.deptName || ('部门#' + row.deptId) }}</view>
<view class="item-desc">
测评 {{ row.totalAssessments }} · 完成 {{ row.completedAssessments }} · 报告 {{ row.generatedReports || 0 }} · 参与 {{ row.uniqueParticipants }}
</view>
<view class="subblock" v-if="riskPairs(row).length">
<view class="sub-title">风险等级占比</view>
<view class="chips">
<view
class="chip risk"
:class="riskClass(item.level)"
v-for="(item, rIdx) in riskPairs(row)"
:key="rIdx"
>
{{ riskLabel(item.level) }} {{ item.count }} ({{ formatPercent(item.percent) }})
</view>
</view>
</view>
<view class="subblock" v-if="(row.topProblems || []).length">
<view class="sub-title">重点心理问题 Top{{ (row.topProblems || []).length }}</view>
<view class="problems">
<view class="problem" v-for="(p, pIdx) in row.topProblems" :key="pIdx">
<view class="problem-name">{{ p.problemName }}</view>
<view class="problem-count">{{ p.userCount }}人</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view v-else class="page big">
<view class="big-top">
<view class="big-brand">
<view class="big-title">心理数据研判平台</view>
<view class="big-sub">数据概览 · 监区看板</view>
</view>
<view class="big-top-actions">
<view class="big-chip" :class="{ active: range === '7d' }" @click="setRange('7d')">近7天</view>
<view class="big-chip" :class="{ active: range === '1m' }" @click="setRange('1m')">近1月</view>
<view class="big-chip" :class="{ active: range === '3m' }" @click="setRange('3m')">近3月</view>
<view class="big-chip" :class="{ active: range === 'all' }" @click="setRange('all')">全部</view>
<picker mode="selector" :range="prisonAreaOptions" :value="selectedPrisonAreaIndex" @change="onPrisonAreaChange">
<view class="big-chip" :class="{ active: selectedPrisonAreaIndex !== 0 }">{{ selectedPrisonAreaLabel }}</view>
</picker>
<view v-if="selectedPrisonAreaIndex !== 0" class="big-chip" @click="clearPrisonArea">清除</view>
</view>
</view>
<view v-if="errorMsg" class="big-error">{{ errorMsg }}</view>
<view class="big-grid">
<view class="big-col">
<view class="big-panel">
<view class="big-panel-title">报告占比</view>
<view class="big-chart">
<qiun-data-charts type="ring" :opts="ringOpts" :chartData="ringChartData" canvasId="ringChartBig" />
</view>
</view>
<view class="big-panel">
<view class="big-panel-title">测评趋势</view>
<view class="big-chart">
<qiun-data-charts type="line" :opts="chartOpts" :chartData="lineChartData" canvasId="lineChartBig" />
</view>
</view>
<view class="big-panel">
<view class="big-panel-title">概览指标</view>
<view class="big-kpis">
<view class="big-kpi">
<view class="big-kpi-num">{{ displayedOverview.totalAssessments }}</view>
<view class="big-kpi-lab">测评总数</view>
</view>
<view class="big-kpi">
<view class="big-kpi-num">{{ displayedOverview.completedAssessments }}</view>
<view class="big-kpi-lab">已完成</view>
</view>
<view class="big-kpi">
<view class="big-kpi-num">{{ displayedOverview.uniqueParticipants }}</view>
<view class="big-kpi-lab">参与人数</view>
</view>
</view>
</view>
</view>
<view class="big-center">
<view class="big-hero">
<view class="big-hero-glow"></view>
<view class="big-hero-core">
<view class="big-hero-title">快速导航</view>
<view class="big-nav">
<view class="big-nav-item" @tap="goWarning">
<uni-icons type="notification" size="24" color="#74d8ff"></uni-icons>
<view class="big-nav-text">预警中心</view>
</view>
<view class="big-nav-item" @tap="goProfile">
<uni-icons type="person" size="24" color="#74d8ff"></uni-icons>
<view class="big-nav-text">个体画像</view>
</view>
<view class="big-nav-item" @tap="goComprehensive">
<uni-icons type="paperplane" size="24" color="#74d8ff"></uni-icons>
<view class="big-nav-text">综合报告</view>
</view>
<view class="big-nav-item" @tap="goChartTemplates">
<uni-icons type="bars" size="24" color="#74d8ff"></uni-icons>
<view class="big-nav-text">自定义图表</view>
</view>
<view class="big-nav-item" @tap="goInterventionTasks">
<uni-icons type="flag" size="24" color="#74d8ff"></uni-icons>
<view class="big-nav-text">干预任务</view>
</view>
</view>
</view>
</view>
</view>
<view class="big-col">
<view class="big-panel">
<view class="big-panel-title">报告对比</view>
<view class="big-chart">
<qiun-data-charts type="column" :opts="chartOpts" :chartData="barChartData" canvasId="barChartBig" />
</view>
</view>
<view class="big-panel">
<view class="big-panel-title">监区统计</view>
<view v-if="loading" class="big-state">
<uni-icons type="spinner-cycle" size="28" color="#74d8ff"></uni-icons>
<view class="big-state-text">加载中...</view>
</view>
<view v-else>
<view v-if="displayedDeptRows.length === 0" class="big-state">
<uni-icons type="info" size="30" color="#74d8ff"></uni-icons>
<view class="big-state-text">暂无数据</view>
</view>
<view v-else class="big-list">
<view class="big-item" v-for="(row, idx) in displayedDeptRows" :key="idx">
<view class="big-item-title">{{ row.deptName || ('部门#' + row.deptId) }}</view>
<view class="big-item-desc">测评 {{ row.totalAssessments }} · 完成 {{ row.completedAssessments }} · 报告 {{ row.generatedReports || 0 }} · 参与 {{ row.uniqueParticipants }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { request } from '../../utils/request'
import QiunDataCharts from '../../uni_modules/qiun-data-charts/components/qiun-data-charts/qiun-data-charts.vue'
import UniIcons from '@/uni_modules/uni-icons/components/uni-icons/uni-icons.vue'
export default {
components: {
QiunDataCharts,
UniIcons
},
data() {
return {
range: '7d',
isH5: false,
loading: false,
errorMsg: '',
selectedPrisonAreaIndex: 0,
selectedPrisonArea: '',
prisonAreas: [],
overview: {
totalAssessments: 0,
completedAssessments: 0,
uniqueParticipants: 0
},
deptStats: [],
deptOverview: [],
analytics: null,
lineEopts: {},
barEopts: {},
ringEopts: {},
heatEopts: {},
chartOpts: {
timing: 'easeOut',
duration: 800,
yAxis: {
min: 0,
splitNumber: 5,
format: (val) => {
const n = Number(val)
if (!isFinite(n)) return '0'
return String(Math.round(n))
}
}
},
ringOpts: {
timing: 'easeOut',
duration: 800,
title: { name: '预警报告占比' },
extra: { ring: { ringWidth: 18, offsetAngle: -90 } }
},
emptyChartData: { categories: [' '], series: [{ name: ' ', data: [0] }] },
lineChartData: { categories: [' '], series: [{ name: '测评次数', data: [0] }] },
barChartData: { categories: [' '], series: [{ name: '报告数', data: [0] }] },
ringChartData: { series: [{ data: [{ name: '正常报告', value: 0 }, { name: '预警报告', value: 0 }] }] },
heatChartData: { categories: [' '], series: [{ name: ' ', type: 'heatmap', data: [[0, 0, 0]] }] }
}
},
computed: {
prisonAreaOptions() {
if (this.prisonAreas && this.prisonAreas.length) {
return ['全部监区'].concat(this.prisonAreas)
}
const rows = this.deptRows || []
const names = rows.map((r) => (r && r.deptName) ? r.deptName : String((r && r.deptId) || ''))
const uniq = []
names.forEach((n) => {
if (!n) return
if (uniq.indexOf(n) === -1) uniq.push(n)
})
return ['全部监区'].concat(uniq)
},
selectedPrisonAreaLabel() {
if (this.selectedPrisonAreaIndex === 0) return '全部监区'
return this.prisonAreaOptions[this.selectedPrisonAreaIndex] || '全部监区'
},
deptRows() {
const overviewRows = (this.deptOverview && this.deptOverview.length) ? this.deptOverview : []
// deptOverview 接口在前端请求时可能带 topN导致未返回所选监区。
// 若用户已选择监区且 overviewRows 不包含该监区,则回退使用全量 deptStats。
if (overviewRows.length) {
if (!this.selectedPrisonArea) {
return overviewRows
}
const sel = String(this.selectedPrisonArea || '').trim()
const hit = overviewRows.some((r) => r && String(r.deptName || '').trim() === sel)
if (hit) {
return overviewRows
}
}
return (this.deptStats || []).map((row) => {
const total = row && row.totalAssessments ? Number(row.totalAssessments) : 0
const completed = row && row.completedAssessments ? Number(row.completedAssessments) : 0
const rate = total > 0 ? (completed * 100.0) / total : 0
return Object.assign({}, row, { completionRate: Math.round(rate * 100) / 100 })
})
},
selectedDeptRow() {
if (!this.selectedPrisonArea) return null
const rows = this.deptRows || []
const sel = String(this.selectedPrisonArea || '').trim()
return rows.find((r) => r && (String(r.deptName || '').trim() === sel)) || null
},
displayedDeptRows() {
if (!this.selectedPrisonArea) return this.deptRows || []
const sel = String(this.selectedPrisonArea || '').trim()
return (this.deptRows || []).filter((r) => r && String(r.deptName || '').trim() === sel)
},
displayedOverview() {
const rows = this.displayedDeptRows || []
if (this.selectedPrisonArea) {
const o = (this.analytics && this.analytics.overview) ? this.analytics.overview : null
if (o) {
return o
}
if (rows.length === 1) {
return rows[0] || this.overview
}
}
return this.overview
}
},
onLoad() {
try {
const info = uni.getSystemInfoSync()
this.isH5 = info && info.uniPlatform === 'web'
} catch (e) {
this.isH5 = false
}
this.fetchPrisonAreas().finally(() => {
this.fetchAll()
})
},
methods: {
fetchPrisonAreas() {
return request({
url: '/psychology/profile/student/list',
method: 'GET',
data: {
pageNum: 1,
pageSize: 200
}
}).then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) {
return
}
const rows = data.rows || []
const uniq = []
rows.forEach((r) => {
const v = r && r.prisonArea ? String(r.prisonArea).trim() : ''
if (!v) return
if (uniq.indexOf(v) === -1) uniq.push(v)
})
this.prisonAreas = uniq
}).catch(() => {
// ignore
})
},
onPrisonAreaChange(e) {
const idx = e && e.detail ? Number(e.detail.value) : 0
this.selectedPrisonAreaIndex = isNaN(idx) ? 0 : idx
if (this.selectedPrisonAreaIndex === 0) {
this.selectedPrisonArea = ''
this.fetchAll()
return
}
this.selectedPrisonArea = this.prisonAreaOptions[this.selectedPrisonAreaIndex] || ''
this.fetchAll()
},
clearPrisonArea() {
this.selectedPrisonAreaIndex = 0
this.selectedPrisonArea = ''
this.fetchAll()
},
setRange(r) {
if (this.loading) return
this.range = r
this.fetchAll()
},
formatDate(d) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
},
getDateRange() {
if (this.range === 'all') {
return {
startDate: '',
endDate: ''
}
}
const end = new Date()
const start = new Date()
if (this.range === '7d') start.setDate(end.getDate() - 7)
if (this.range === '1m') start.setMonth(end.getMonth() - 1)
if (this.range === '3m') start.setMonth(end.getMonth() - 3)
return {
startDate: this.formatDate(start),
endDate: this.formatDate(end)
}
},
buildQuery({ startDate, endDate, prisonArea }) {
const qs = []
if (startDate) qs.push(`startDate=${encodeURIComponent(startDate)}`)
if (endDate) qs.push(`endDate=${encodeURIComponent(endDate)}`)
if (prisonArea) qs.push(`prisonArea=${encodeURIComponent(prisonArea)}`)
return qs.length ? `?${qs.join('&')}` : ''
},
fetchAll() {
this.errorMsg = ''
this.loading = true
const { startDate, endDate } = this.getDateRange()
const prisonArea = this.selectedPrisonArea ? String(this.selectedPrisonArea).trim() : ''
const query = this.buildQuery({ startDate, endDate, prisonArea })
Promise.all([
request({ url: `/psychology/assessment/analytics${query}`, method: 'GET' }),
request({ url: `/psychology/assessment/deptStats${query}`, method: 'GET' }),
request({ url: `/psychology/assessment/deptOverview${query}${query ? '&' : '?'}topN=5`, method: 'GET' })
]).then(([aRes, dRes, oRes]) => {
this.loading = false
const aData = aRes && aRes.data ? aRes.data : null
const dData = dRes && dRes.data ? dRes.data : null
const oData = oRes && oRes.data ? oRes.data : null
if (!aData || aData.code !== 200) {
this.errorMsg = (aData && aData.msg) ? aData.msg : '概览加载失败'
return
}
this.analytics = aData.data || null
this.overview = (aData.data && aData.data.overview) ? aData.data.overview : this.overview
if (dData && dData.code === 200) {
this.deptStats = dData.data || []
} else {
this.deptStats = []
this.errorMsg = (dData && dData.msg) ? dData.msg : (this.errorMsg || '监区统计加载失败')
}
if (oData && oData.code === 200) {
this.deptOverview = (oData.data || []).map((row) => {
const merged = Object.assign({}, row)
if (!merged.totalAssessments || !merged.completedAssessments || !merged.uniqueParticipants) {
const fallback = (this.deptStats || []).find((x) => (x && row) && (x.deptId === row.deptId || x.deptName === row.deptName))
if (fallback) {
merged.totalAssessments = merged.totalAssessments || fallback.totalAssessments
merged.completedAssessments = merged.completedAssessments || fallback.completedAssessments
merged.uniqueParticipants = merged.uniqueParticipants || fallback.uniqueParticipants
}
}
return merged
})
} else {
this.deptOverview = []
this.errorMsg = (oData && oData.msg) ? oData.msg : (this.errorMsg || '监区概览加载失败')
}
this.buildCharts()
}).catch((e) => {
this.loading = false
this.errorMsg = e && e.message ? e.message : '网络错误'
})
},
formatPercent(value) {
const num = Number(value || 0)
return `${num.toFixed(2)}%`
},
buildCharts() {
this.lineChartData = this.buildTrendLineChartData()
this.barChartData = this.buildDeptCompareBarChartData()
this.ringChartData = this.buildRiskRingChartData()
this.syncRingCenterText()
this.lineEopts = this.buildTrendLineEopts()
this.barEopts = this.buildDeptCompareBarEopts()
this.ringEopts = this.buildRiskRingEopts()
this.heatEopts = this.buildRiskHeatmapEopts()
},
syncRingCenterText() {
const overview = this.displayedOverview || this.overview || {}
const total = Number(overview.generatedReports || 0)
const warning = Number(overview.warningReports || 0)
const pct = total > 0 ? Math.round((warning * 10000) / total) / 100 : 0
this.ringOpts = Object.assign({}, this.ringOpts, {
title: Object.assign({}, (this.ringOpts && this.ringOpts.title) ? this.ringOpts.title : {}, { name: '预警报告占比' }),
subtitle: { name: `${pct}%` }
})
},
buildTrendLineChartData() {
const list = (this.analytics && this.analytics.monthlyTrend) ? this.analytics.monthlyTrend : []
const categories = (list && list.length) ? list.map((it) => it.label) : ['暂无数据']
const data = (list && list.length) ? list.map((it) => Number(it.value || 0)) : [0]
return { categories, series: [{ name: '测评次数', data }] }
},
buildDeptCompareBarChartData() {
const rows = this.displayedDeptRows || []
const categories = rows.length ? rows.map((r) => r.deptName || String(r.deptId || '')) : ['暂无数据']
const data = rows.length ? rows.map((r) => Number(r.generatedReports || 0)) : [0]
return { categories, series: [{ name: '报告数', data }] }
},
buildRiskRingChartData() {
const overview = this.displayedOverview || this.overview || {}
const totalReports = Number(overview.generatedReports || 0)
const warningReports = Number(overview.warningReports || 0)
const normalReports = Math.max(totalReports - warningReports, 0)
const data = [
{ name: '预警报告', value: warningReports },
{ name: '正常报告', value: normalReports }
].filter((it) => it.value > 0)
return { series: [{ data: data.length ? data : [{ name: '暂无数据', value: 0 }] }] }
},
buildTrendLineEopts() {
const list = (this.analytics && this.analytics.monthlyTrend) ? this.analytics.monthlyTrend : []
const x = (list && list.length) ? list.map((it) => it.label) : ['暂无数据']
const y = (list && list.length) ? list.map((it) => Number(it.value || 0)) : [0]
return {
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 18, top: 24, bottom: 40 },
xAxis: { type: 'category', data: x, boundaryGap: false, axisLabel: { color: '#6B7280' } },
yAxis: { type: 'value', axisLabel: { color: '#6B7280' }, splitLine: { lineStyle: { color: 'rgba(15,23,42,0.08)' } } },
series: [{
name: '测评次数',
type: 'line',
smooth: true,
data: y,
areaStyle: { opacity: 0.12 },
lineStyle: { width: 3, color: '#2B6BFF' },
itemStyle: { color: '#2B6BFF' }
}]
}
},
buildDeptCompareBarEopts() {
const rows = this.displayedDeptRows || []
const x = rows.map((r) => r.deptName || String(r.deptId || ''))
const v = rows.map((r) => Number(r.generatedReports || 0))
return {
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 18, top: 24, bottom: 80 },
xAxis: { type: 'category', data: x.length ? x : ['暂无数据'], axisLabel: { color: '#6B7280', rotate: 35 } },
yAxis: { type: 'value', axisLabel: { color: '#6B7280' }, splitLine: { lineStyle: { color: 'rgba(15,23,42,0.08)' } } },
series: [{ name: '报告数', type: 'bar', data: v.length ? v : [0], barWidth: '50%', itemStyle: { color: '#7C5CFF', borderRadius: [8, 8, 0, 0] } }]
}
},
buildRiskRingEopts() {
const overview = this.displayedOverview || this.overview || {}
const totalReports = Number(overview.generatedReports || 0)
const warningReports = Number(overview.warningReports || 0)
const normalReports = Math.max(totalReports - warningReports, 0)
const data = [
{ name: '预警报告', value: warningReports },
{ name: '正常报告', value: normalReports }
].filter((it) => it.value > 0)
return {
tooltip: { trigger: 'item' },
legend: { bottom: 0, left: 'center', textStyle: { color: '#4B5563' } },
series: [{
name: '预警报告占比',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
data: data.length ? data : [{ name: '暂无数据', value: 0 }],
label: { formatter: '{b}\n{d}%' }
}]
}
},
buildRiskHeatmapEopts() {
const rows = this.displayedDeptRows || []
const depts = rows.map((r) => r.deptName || String(r.deptId || ''))
const levels = ['严重', '高', '中', '低', '无']
const keyMap = { '严重': 'critical', '高': 'high', '中': 'medium', '低': 'low', '无': 'none' }
const data = []
rows.forEach((r, i) => {
const m = (r && r.riskLevelCounts) ? r.riskLevelCounts : {}
levels.forEach((lv, j) => {
const k = keyMap[lv]
data.push([i, j, Number(m[k] || 0)])
})
})
const maxVal = data.reduce((mx, it) => Math.max(mx, Number(it[2] || 0)), 0)
return {
tooltip: {
position: 'top',
formatter: (p) => {
const v = p && p.value ? p.value : []
const dept = depts[v[0]] || ''
const lvl = levels[v[1]] || ''
return `${dept}<br/>${lvl}${v[2]}`
}
},
grid: { top: 10, left: 70, right: 18, bottom: 60 },
xAxis: { type: 'category', data: depts.length ? depts : ['暂无数据'], axisLabel: { color: '#6B7280', rotate: 35 } },
yAxis: { type: 'category', data: levels, axisLabel: { color: '#6B7280' } },
visualMap: {
min: 0,
max: maxVal || 1,
calculable: false,
orient: 'horizontal',
left: 'center',
bottom: 10,
inRange: { color: ['#F3F4F6', '#A5B4FC', '#7C5CFF', '#2B6BFF'] }
},
series: [{
name: '风险热力',
type: 'heatmap',
data,
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.25)' } },
label: { show: false }
}]
}
},
riskLabel(level) {
const map = {
critical: '严重',
high: '高',
medium: '中',
low: '低',
none: '无'
}
return map[level] || level || '未知'
},
riskClass(level) {
const map = {
critical: 'critical',
high: 'high',
medium: 'medium',
low: 'low',
none: 'none'
}
return map[level] || 'none'
},
riskPairs(row) {
const counts = (row && row.riskLevelCounts) ? row.riskLevelCounts : null
if (!counts) return []
const entries = Object.keys(counts).map((k) => ({ level: k, count: Number(counts[k] || 0) }))
const total = entries.reduce((sum, it) => sum + it.count, 0)
return entries
.filter((it) => it.count > 0)
.sort((a, b) => b.count - a.count)
.map((it) => Object.assign({}, it, { percent: total > 0 ? (it.count * 100.0) / total : 0 }))
},
goWarning() {
uni.navigateTo({
url: '/pages/warning/index'
})
},
goProfile() {
uni.navigateTo({
url: '/pages/profile/index'
})
},
goComprehensive() {
uni.navigateTo({
url: '/pages/comprehensive/index'
})
},
goChartTemplates() {
uni.navigateTo({
url: '/pages/chart/templates'
})
},
goInterventionTasks() {
uni.navigateTo({
url: '/pages/interventionTask/index'
})
}
}
}
</script>
<style>
.page {
min-height: 100vh;
padding: 24rpx 24rpx 120rpx;
box-sizing: border-box;
background: #F4F6FB;
}
.card,
.panel {
background: rgba(255, 255, 255, 0.95);
border-radius: 18rpx;
padding: 24rpx;
border: 1px solid rgba(15, 23, 42, 0.06);
box-shadow: 0 10rpx 22rpx rgba(15, 23, 42, 0.05);
margin-bottom: 18rpx;
}
.card-title {
font-size: 28rpx;
font-weight: 700;
color: #111827;
margin-bottom: 14rpx;
}
.filters {
display: flex;
flex-wrap: wrap;
}
.chip {
padding: 14rpx 22rpx;
border-radius: 999rpx;
font-size: 26rpx;
line-height: 36rpx;
min-height: 60rpx;
box-sizing: border-box;
color: #64748B;
background: rgba(15, 23, 42, 0.04);
margin-right: 16rpx;
margin-bottom: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.chip.active {
color: #1677ff;
background: rgba(22, 119, 255, 0.12);
}
.stats {
display: flex;
justify-content: space-between;
}
.stat {
width: 32%;
background: rgba(15, 23, 42, 0.04);
border-radius: 16rpx;
padding: 18rpx;
box-sizing: border-box;
border: 1px solid rgba(15, 23, 42, 0.06);
}
.num {
font-size: 36rpx;
font-weight: 700;
color: #111827;
}
.lab {
margin-top: 8rpx;
font-size: 22rpx;
color: #94A3B8;
}
.list {
margin-top: 8rpx;
}
.item {
padding: 18rpx;
border-radius: 18rpx;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(15, 23, 42, 0.06);
margin-bottom: 14rpx;
}
.item-title {
font-size: 26rpx;
font-weight: 700;
color: #111827;
}
.item-desc {
margin-top: 8rpx;
font-size: 22rpx;
color: #94A3B8;
}
.subblock {
margin-top: 12rpx;
}
.sub-title {
font-size: 22rpx;
color: #64748B;
font-weight: 700;
margin-bottom: 10rpx;
}
.chips {
display: flex;
flex-wrap: wrap;
}
.chip.risk {
padding: 8rpx 14rpx;
border-radius: 999rpx;
font-size: 22rpx;
margin-right: 12rpx;
margin-bottom: 10rpx;
border: 1px solid rgba(15, 23, 42, 0.06);
background: rgba(15, 23, 42, 0.04);
color: #64748B;
}
.chip.risk.critical {
background: rgba(245, 108, 108, 0.12);
border-color: rgba(245, 108, 108, 0.25);
color: #F56C6C;
}
.chip.risk.high {
background: rgba(230, 162, 60, 0.12);
border-color: rgba(230, 162, 60, 0.25);
color: #E6A23C;
}
.chip.risk.medium {
background: rgba(64, 158, 255, 0.12);
border-color: rgba(64, 158, 255, 0.25);
color: #409EFF;
}
.chip.risk.low {
background: rgba(103, 194, 58, 0.12);
border-color: rgba(103, 194, 58, 0.25);
color: #67C23A;
}
.chip.risk.none {
background: rgba(144, 147, 153, 0.10);
border-color: rgba(144, 147, 153, 0.20);
color: #909399;
}
.problems {
margin-top: 6rpx;
}
.problem {
display: flex;
justify-content: space-between;
padding: 10rpx 0;
border-bottom: 1px dashed #EBEEF5;
}
.problem:last-child {
border-bottom: none;
}
.problem-name {
font-size: 22rpx;
color: #111827;
}
.problem-count {
font-size: 22rpx;
color: #94A3B8;
}
.chart-box { height: 320rpx; }
.chart-box.heat { height: 420rpx; }
.state {
height: 360rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(15, 23, 42, 0.06);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.state-text {
font-size: 24rpx;
color: #94A3B8;
font-weight: 700;
}
.error {
margin-top: 14rpx;
font-size: 24rpx;
color: #ff4d4f;
line-height: 36rpx;
}
.page.big {
padding: 22rpx 18rpx 40rpx;
background-image:
radial-gradient(1200rpx 600rpx at 50% 20%, rgba(43, 107, 255, 0.30) 0%, rgba(11, 19, 41, 0.0) 60%),
linear-gradient(180deg, rgba(5, 11, 24, 0.90) 0%, rgba(8, 20, 45, 0.85) 40%, rgba(6, 16, 40, 0.92) 100%),
url('/static/bg.png');
background-size: auto, auto, cover;
background-position: center, center, center;
background-repeat: no-repeat, no-repeat, no-repeat;
color: rgba(235, 248, 255, 0.92);
}
.big-top {
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 10rpx 10rpx 16rpx;
border-bottom: 1px solid rgba(116, 216, 255, 0.18);
background: linear-gradient(90deg, rgba(17, 30, 58, 0.55) 0%, rgba(7, 13, 28, 0.15) 40%, rgba(17, 30, 58, 0.55) 100%);
border-radius: 14rpx;
}
.big-title {
font-size: 34rpx;
font-weight: 800;
letter-spacing: 2rpx;
color: #c9f2ff;
text-shadow: 0 0 16rpx rgba(116, 216, 255, 0.35);
}
.big-sub {
margin-top: 6rpx;
font-size: 22rpx;
color: rgba(201, 242, 255, 0.65);
}
.big-top-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10rpx;
max-width: 520rpx;
}
.big-chip {
padding: 12rpx 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
line-height: 30rpx;
color: rgba(201, 242, 255, 0.70);
border: 1px solid rgba(116, 216, 255, 0.20);
background: rgba(10, 18, 38, 0.65);
backdrop-filter: blur(8px);
}
.big-chip.active {
color: #0b1226;
background: linear-gradient(90deg, rgba(116, 216, 255, 0.95) 0%, rgba(43, 107, 255, 0.90) 100%);
border-color: rgba(116, 216, 255, 0.5);
}
.big-error {
margin-top: 12rpx;
padding: 14rpx 16rpx;
border-radius: 14rpx;
border: 1px solid rgba(255, 77, 79, 0.35);
background: rgba(255, 77, 79, 0.10);
font-size: 24rpx;
color: rgba(255, 220, 220, 0.92);
}
.big-grid {
margin-top: 18rpx;
display: flex;
gap: 14rpx;
}
.big-col {
width: 28%;
display: flex;
flex-direction: column;
gap: 14rpx;
}
.big-center {
flex: 1;
min-width: 0;
}
.big-panel {
position: relative;
border-radius: 16rpx;
padding: 16rpx 16rpx 12rpx;
border: 1px solid rgba(116, 216, 255, 0.22);
background: linear-gradient(180deg, rgba(10, 18, 38, 0.75) 0%, rgba(5, 10, 22, 0.55) 100%);
box-shadow: 0 12rpx 24rpx rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.big-panel:before {
content: '';
position: absolute;
left: -40rpx;
top: -40rpx;
width: 180rpx;
height: 180rpx;
background: radial-gradient(circle, rgba(116, 216, 255, 0.25) 0%, rgba(116, 216, 255, 0.0) 70%);
}
.big-panel-title {
position: relative;
font-size: 24rpx;
font-weight: 800;
color: rgba(201, 242, 255, 0.92);
padding-left: 14rpx;
margin-bottom: 10rpx;
}
.big-panel-title:before {
content: '';
position: absolute;
left: 0;
top: 10rpx;
width: 6rpx;
height: 100rpx;
border-radius: 6rpx;
background: linear-gradient(180deg, rgba(116, 216, 255, 0.95) 0%, rgba(43, 107, 255, 0.85) 100%);
}
.big-chart {
height: 260rpx;
}
.big-kpis {
display: flex;
justify-content: space-between;
gap: 10rpx;
}
.big-kpi {
flex: 1;
min-width: 0;
border-radius: 14rpx;
padding: 14rpx 12rpx;
border: 1px solid rgba(116, 216, 255, 0.18);
background: rgba(7, 13, 28, 0.45);
}
.big-kpi-num {
font-size: 34rpx;
font-weight: 900;
color: #74d8ff;
text-shadow: 0 0 14rpx rgba(116, 216, 255, 0.35);
}
.big-kpi-lab {
margin-top: 8rpx;
font-size: 20rpx;
color: rgba(201, 242, 255, 0.65);
}
.big-hero {
position: relative;
height: 100%;
min-height: 820rpx;
border-radius: 18rpx;
border: 1px solid rgba(116, 216, 255, 0.26);
background: radial-gradient(600rpx 420rpx at 50% 35%, rgba(116, 216, 255, 0.20) 0%, rgba(116, 216, 255, 0.0) 70%),
linear-gradient(180deg, rgba(10, 18, 38, 0.55) 0%, rgba(5, 10, 22, 0.35) 100%);
box-shadow: 0 18rpx 36rpx rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.big-hero-glow {
position: absolute;
left: 50%;
top: 42%;
width: 520rpx;
height: 520rpx;
transform: translate(-50%, -50%);
border-radius: 999rpx;
border: 1px solid rgba(116, 216, 255, 0.28);
box-shadow: 0 0 40rpx rgba(116, 216, 255, 0.22), inset 0 0 30rpx rgba(43, 107, 255, 0.18);
background: radial-gradient(circle, rgba(43, 107, 255, 0.20) 0%, rgba(43, 107, 255, 0.0) 65%);
opacity: 0.9;
}
.big-hero-core {
position: relative;
padding: 22rpx;
}
.big-hero-title {
font-size: 26rpx;
font-weight: 900;
color: rgba(201, 242, 255, 0.92);
margin-bottom: 18rpx;
}
.big-nav {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.big-nav-item {
width: calc(33.33% - 11rpx);
border-radius: 16rpx;
border: 1px solid rgba(116, 216, 255, 0.22);
background: rgba(7, 13, 28, 0.35);
padding: 18rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
box-sizing: border-box;
}
.big-nav-item:active {
transform: scale(0.98);
border-color: rgba(116, 216, 255, 0.45);
}
.big-nav-text {
font-size: 22rpx;
font-weight: 800;
color: rgba(201, 242, 255, 0.86);
}
.big-state {
height: 240rpx;
border-radius: 16rpx;
border: 1px dashed rgba(116, 216, 255, 0.22);
background: rgba(7, 13, 28, 0.30);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.big-state-text {
font-size: 22rpx;
color: rgba(201, 242, 255, 0.70);
font-weight: 800;
}
.big-list {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.big-item {
border-radius: 14rpx;
border: 1px solid rgba(116, 216, 255, 0.18);
background: rgba(7, 13, 28, 0.35);
padding: 12rpx 12rpx;
}
.big-item-title {
font-size: 22rpx;
font-weight: 900;
color: rgba(201, 242, 255, 0.92);
}
.big-item-desc {
margin-top: 6rpx;
font-size: 20rpx;
color: rgba(201, 242, 255, 0.65);
line-height: 30rpx;
}
@media (max-width: 980px) {
.big-grid {
flex-direction: column;
}
.big-col {
width: 100%;
}
.big-hero {
min-height: 520rpx;
}
}
</style>