You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

468 lines
19 KiB

<template>
<div class="statistical-analysis statistical-analysis--adaptive" :style="cssVars">
<div class="wrap_all">
<div class="select_set">
<div class="select_item">
<label class="select_area" :class="{ active: deptPanelOpen }" @click="deptPanelOpen = !deptPanelOpen">
{{ deptName }}<a></a>
</label>
<dl v-show="deptPanelOpen" class="fold_cont" :class="{ 'is-open': deptPanelOpen }">
<dd
v-for="dept in deptList"
:key="String(dept.deptId)"
class="check_label"
:class="{ selchk: String(dept.deptId) === String(selectedDeptId) || (dept.deptId === null && selectedDeptId === null) }"
@click="changeDept(dept.deptId, dept.deptName)"
>
{{ dept.deptName }}
</dd>
</dl>
</div>
<div class="select_item">
<label class="select_time" :class="{ active: datePickerOpen }" @click="datePickerOpen = !datePickerOpen">时间筛选<a></a></label>
<el-date-picker
v-if="datePickerOpen"
v-model="dateRange"
type="daterange"
range-separator="至"
value-format="YYYY-MM-DD"
clearable
popper-class="analysis-date-picker"
@change="refreshAll"
/>
<span>综合数据</span>
</div>
</div>
<div class="link_btn" style="text-align: right;">
<a href="javascript:void(0)" style="display: none;">各市直执法部门综合分析</a>
<router-link to="/enforcement-code/statistical/district-direct">各区执法综合分析</router-link>
</div>
<TopSixMetrics :m="stats" />
<div class="echarts_box">
<div class="inner">
<div class="twobox">
<div class="data_cont threebox">
<h3 class="chart_tit">行政执法类别及占比</h3>
<div class="count_wrap" id="law_category">
<div v-for="item in lawCategoryItems" :key="item.label" class="count_item">
<div class="count_lab">{{ item.label }}</div>
<div class="count_div">
<img :src="item.icon" :alt="item.label" />
<div class="count_cont">
<span class="number_count">{{ item.count ? item.count : '-' }}</span>
<span class="number_percent">{{ item.rate ? item.rate : '-' }}<i>%</i></span>
</div>
</div>
</div>
</div>
</div>
<div class="data_cont threebox">
<h3 class="chart_tit">行政执法投诉类别及占比</h3>
<div class="count_wrap com_cate">
<div v-for="item in caseTypeItems" :key="item.label" class="count_item">
<div class="count_lab">{{ item.label }}</div>
<div class="count_div">
<img :src="item.icon" :alt="item.label" />
<div class="count_cont">
<span class="number_count">{{ item.count ? item.count : '-' }}</span>
<span class="number_percent">{{ item.rate ? item.rate : '-' }}<i>%</i></span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="twobox">
<div class="data_cont threebox">
<h3 class="chart_tit">重点领域类别及占比</h3>
<div class="count_wrap key_area">
<div v-for="item in lawFieldItems" :key="item.label" class="count_item">
<div class="count_lab">{{ item.label }}</div>
<div class="count_cont">
<span class="number_count">{{ item.count ? item.count : '-' }}</span>
<span class="number_percent">{{ item.rate ? item.rate : '-' }}<i>%</i></span>
</div>
</div>
</div>
</div>
<div class="data_cont threebox">
<h3 class="chart_tit">行政执法投诉评价</h3>
<div class="count_review">
<div class="count_com">
<h4>投诉</h4>
<div class="count_list">
<div class="count_row">
<span>投诉总量</span><span>{{ stats.entComplaint?.count ?? '-' }}</span>
<span class="percent_ym">
<div class="percent-tag"><label>同比</label><span>{{ fmt(stats.entComplaint?.yoy) }}</span></div>
<div class="percent-tag"><label>环比</label><span>{{ fmt(stats.entComplaint?.mom) }}</span></div>
</span>
</div>
<div class="count_row"><span>已办结</span><span>{{ stats.entComplaint?.completed ?? '-' }}</span><span>{{ completedRate }}%</span></div>
<div class="count_row"><span>未办结</span><span>{{ stats.entComplaint?.unCompleted ?? '-' }}</span><span>{{ unCompletedRate }}%</span></div>
</div>
</div>
<div class="count_com count_rev">
<h4>反馈</h4>
<div class="count_list">
<div class="count_row"><span>满意</span><span>{{ stats.eva?.high ?? '-' }}</span><span>{{ evaRates.high }}%</span></div>
<div class="count_row"><span>较满意</span><span>{{ stats.eva?.mid ?? '-' }}</span><span>{{ evaRates.mid }}%</span></div>
<div class="count_row"><span>不满意</span><span>{{ stats.eva?.low ?? '-' }}</span><span>{{ evaRates.low }}%</span></div>
<div class="count_row"><span>未反馈</span><span>{{ stats.eva?.un ?? '-' }}</span><span>{{ evaRates.un }}%</span></div>
</div>
</div>
</div>
</div>
</div>
<div class="echarts_mult">
<div class="data_cont threebox">
<h3 class="chart_tit">{{ enfEntSortTitle }}</h3>
<span class="sel_rank">
<select v-model="rankSizeEnt" @change="loadEnfEntSort">
<option :value="10">TOP 10</option>
<option :value="5">TOP 5</option>
</select>
</span>
<div ref="rankCompanyRef" class="chart_box"></div>
</div>
<div class="data_cont threebox">
<h3 class="chart_tit">{{ enforcementSortTitle }}</h3>
<span class="sel_rank">
<select v-model="rankSizeInspect" @change="loadEnforcementSort">
<option :value="10">TOP 10</option>
<option :value="5">TOP 5</option>
</select>
</span>
<div ref="rankInspectRef" class="chart_box"></div>
</div>
<div class="data_cont threebox">
<h3 class="chart_tit">{{ entCSortTitle }}</h3>
<span class="sel_rank">
<select v-model="rankSizeComplaint" @change="loadEntCSort">
<option :value="10">TOP 10</option>
<option :value="5">TOP 5</option>
</select>
</span>
<div ref="rankComplaintRef" class="chart_box"></div>
</div>
<div class="data_cont threebox">
<h3 class="chart_tit">委托执法数量及占比</h3>
<div ref="rankPieAutoRef" class="chart_box"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import * as echarts from 'echarts';
import TopSixMetrics from '../components/TopSixMetrics.vue';
import { queryDept, queryDeptLength } from '@/api/supervision/statistical/statisticalPanel';
import {
collectStatistics,
queryAutoRate,
queryEnfEntSort,
queryEnforcementSort,
queryEntCSort,
queryLawEntSort,
queryUnionSort
} from '@/api/supervision/statistical/statisticalPanelCity';
import type { CollectStatisticsData, SysDeptVo } from '@/api/supervision/statistical/types';
import { normalizeRankList, pick, toNum, unwrapData } from '@/api/supervision/statistical/adapters';
import { buildAutoRatePieOption, buildRankBarOption, formatPercent } from '../utils/chartOptions';
async function safeLoad<T>(fn: () => Promise<T>): Promise<T | null> {
try {
return await fn();
} catch (e) {
console.warn('[市直综合分析] 接口加载失败', e);
return null;
}
}
const LAW_CATEGORY_ICONS: Record<string, string> = {
行政检查: 'icon_801.png', 行政处罚: 'icon_802.png', 行政许可: 'icon_803.png', 行政强制: 'icon_804.png',
行政征收: 'icon_805.png', 行政给付: 'icon_806.png', 行政确认: 'icon_807.png', 行政登记: 'icon_808.png',
行政裁决: 'icon_809.png', 文书送达: 'icon_8010.png'
};
const CASE_TYPE_ICONS: Record<string, string> = {
违规执法: 'icon_1001.png', 违法执法: 'icon_1002.png', 入企不扫码: 'icon_1004.png', 其他: 'icon_1003.png'
};
/* ======================== 开发调试用假数据(已删除) ======================== */
const dateRange = ref<[string, string] | null>(null);
const datePickerOpen = ref(false);
const deptPanelOpen = ref(false);
const deptList = ref<(SysDeptVo & { deptId: string | number | null })[]>([]);
const selectedDeptId = ref<string | number | null>(null);
const deptName = ref('全市直');
const stats = ref<Partial<CollectStatisticsData>>({});
const rankSizeEnt = ref(10);
const rankSizeInspect = ref(10);
const rankSizeComplaint = ref(10);
const rankCompanyRef = ref<HTMLElement>();
const rankInspectRef = ref<HTMLElement>();
const rankComplaintRef = ref<HTMLElement>();
const rankPieAutoRef = ref<HTMLElement>();
let rankCompanyChart: echarts.ECharts | null = null;
let rankInspectChart: echarts.ECharts | null = null;
let rankComplaintChart: echarts.ECharts | null = null;
let rankPieAutoChart: echarts.ECharts | null = null;
const isCityWide = computed(() => deptName.value === '全市直' || selectedDeptId.value === null);
const enfEntSortTitle = computed(() => '被执法企业次数排行');
const enforcementSortTitle = computed(() => (isCityWide.value ? '执法单位执法数量排行' : '联合检查单位排行'));
const entCSortTitle = computed(() => (isCityWide.value ? '被投诉单位次数排行' : '执法人员涉企检查数是排行'));
const completedRate = computed(() => {
const c = stats.value.entComplaint;
if (!c) return 0;
const total = (c.completed || 0) + (c.unCompleted || 0);
return total ? parseFloat(((c.completed / total) * 100).toFixed(2)) : 0;
});
const unCompletedRate = computed(() => {
const c = stats.value.entComplaint;
if (!c) return 0;
const total = (c.completed || 0) + (c.unCompleted || 0);
return total ? parseFloat(((c.unCompleted / total) * 100).toFixed(2)) : 0;
});
const evaRates = computed(() => {
const e = stats.value.eva;
if (!e) return { high: 0, mid: 0, low: 0, un: 0 };
const total = e.high + e.mid + e.low + e.un;
const pct = (v: number) => (total ? parseFloat(((v / total) * 100).toFixed(2)) : 0);
return { high: pct(e.high), mid: pct(e.mid), low: pct(e.low), un: pct(e.un) };
});
const BASE = import.meta.env.BASE_URL;
const cssVars = computed(() => {
const p = (name: string) => `url('${BASE}statistical-new/images/${name}')`;
return {
'--bg-body': p('bg_body.jpg'),
'--bg-data-item': p('bg_data_item.jpg'),
'--bg-analy-card': p('bg_analy_card.jpg'),
'--bg-analy-card-2': p('bg_analy_card_2.jpg'),
'--bg-ring': p('bg_ring.png'),
'--bg-base': p('bg_base.png'),
'--bg-shadow': p('bg_shadow.png'),
'--icon-scr': p('icon_scr.png'),
'--icon-com': p('icon_com.png'),
'--icon-rev': p('icon_rev.png'),
'--icon-dept32': p('icon_dept32.png'),
'--icon-dist32': p('icon_dist32.png')
} as Record<string, string>;
});
function buildCategoryItems(data: Record<string, number>[] | undefined, total: number | undefined, iconMap: Record<string, string>) {
if (!data) return [];
return data.map((obj) => {
const [label, rawCount] = Object.entries(obj)[0];
// 接口可能返回字符串 "0",统一转成数字,0 才会被模板判定为空显示 '-'
const count = Number(rawCount) || 0;
const rate = total ? parseFloat(((count / total) * 100).toFixed(2)) : 0;
return { label, count, rate, icon: `${BASE}statistical-new/images/${iconMap[label] || 'icon_801.png'}` };
});
}
const lawCategoryItems = computed(() => buildCategoryItems(stats.value.lawCategory, stats.value.lawCategoryTotal, LAW_CATEGORY_ICONS));
const caseTypeItems = computed(() => buildCategoryItems(stats.value.caseType, stats.value.totalCaseType, CASE_TYPE_ICONS));
const lawFieldItems = computed(() => {
if (!stats.value.lawField) return [];
return stats.value.lawField.map((obj) => {
const [label, rawCount] = Object.entries(obj)[0];
const count = Number(rawCount) || 0;
const rate = stats.value.lawFieldTotal ? parseFloat(((count / stats.value.lawFieldTotal!) * 100).toFixed(2)) : 0;
return { label, count, rate };
});
});
function getParams() {
return {
beginTime: dateRange.value?.[0],
endTime: dateRange.value?.[1],
zone: 1,
bureauDeptId: selectedDeptId.value
};
}
function fmt(val?: string | number) {
if (val === undefined || val === null) return '-%';
return formatPercent(val);
}
async function loadDepts() {
const lenRes = await safeLoad(() => queryDeptLength({ zoneList: [1, 15, 16] }));
const deptLen = unwrapData<SysDeptVo[]>(lenRes)?.length ?? 0;
const res = await safeLoad(() => queryDept({ zoneList: [1, 15, 16] }));
const datas = unwrapData<SysDeptVo[]>(res) || [];
const list: (SysDeptVo & { deptId: string | number | null })[] = [];
if (datas.length === deptLen && datas.length > 0) {
list.push({ deptId: null, deptName: '全市直' });
changeDept(null, '全市直');
} else if (datas.length === 0) {
list.push({ deptId: -1, deptName: '无权限' });
changeDept(-1, '无权限');
} else if (datas.length > 0) {
changeDept(datas[0].deptId, datas[0].deptName);
}
deptList.value = [...list, ...datas];
}
function changeDept(id: string | number | null, name: string) {
selectedDeptId.value = id;
deptName.value = name;
deptPanelOpen.value = false;
refreshAll();
}
async function loadStatistics() {
const res = await safeLoad(() => collectStatistics(getParams()));
if (!res) return;
stats.value = unwrapData(res) as Partial<CollectStatisticsData>;
}
function renderRankChart(
el: HTMLElement | undefined,
chart: echarts.ECharts | null,
data: ReturnType<typeof normalizeRankList>,
nameKey: 'enterprise_name' | 'bureau_dept_name',
seriesName: string
) {
if (!el) return chart;
const instance = chart ?? echarts.init(el);
if (!data.length) {
instance.clear();
return instance;
}
const names = data.map((d) => d[nameKey] || '');
const values = data.map((d) => toNum(d.cnt));
instance.setOption(buildRankBarOption(names, values, seriesName), true);
return instance;
}
async function loadEnfEntSort() {
const res = await safeLoad(() => queryEnfEntSort({ ...getParams(), pageSize: rankSizeEnt.value }));
const list = res ? unwrapData(res) : [];
await nextTick();
rankCompanyChart = renderRankChart(rankCompanyRef.value, rankCompanyChart, normalizeRankList(list), 'enterprise_name', '被执法企业次数')!;
}
async function loadEnforcementSort() {
const res = await safeLoad(() =>
isCityWide.value
? queryEnforcementSort({ ...getParams(), pageSize: rankSizeInspect.value })
: queryUnionSort({ ...getParams(), pageSize: rankSizeInspect.value })
);
const list = res ? unwrapData(res) : [];
await nextTick();
rankInspectChart = renderRankChart(rankInspectRef.value, rankInspectChart, normalizeRankList(list), 'bureau_dept_name', '执法数量')!;
}
async function loadEntCSort() {
const res = await safeLoad(() =>
isCityWide.value
? queryEntCSort({ ...getParams(), pageSize: rankSizeComplaint.value })
: queryLawEntSort({ ...getParams(), pageSize: rankSizeComplaint.value })
);
const list = res ? unwrapData(res) : [];
await nextTick();
const data = normalizeRankList(list);
rankComplaintChart = renderRankChart(rankComplaintRef.value, rankComplaintChart, data, 'bureau_dept_name', '被投诉单位次数')!;
}
async function loadAutoRate() {
const res = await safeLoad(() => queryAutoRate(getParams()));
if (!res) return;
const data = unwrapData(res) as Record<string, unknown>;
await nextTick();
if (rankPieAutoRef.value) {
rankPieAutoChart ??= echarts.init(rankPieAutoRef.value);
rankPieAutoChart.setOption(
buildAutoRatePieOption([
{ value: toNum(pick(data, 'autoCnt', 'auto_cnt')), name: '委托执法' },
{ value: toNum(pick(data, 'unAutoCnt', 'un_auto_cnt')), name: '非委托执法' }
]),
true
);
}
}
async function refreshAll() {
await loadStatistics();
await Promise.allSettled([loadEnfEntSort(), loadEnforcementSort(), loadEntCSort(), loadAutoRate()]);
}
function handleResize() {
rankCompanyChart?.resize();
rankInspectChart?.resize();
rankComplaintChart?.resize();
rankPieAutoChart?.resize();
}
onMounted(async () => {
await loadDepts();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
rankCompanyChart?.dispose();
rankInspectChart?.dispose();
rankComplaintChart?.dispose();
rankPieAutoChart?.dispose();
});
</script>
<style>
/* 使用带 .statistical-analysis 根容器限定的样式,防止污染首页 */
@import '../styles/analysis-scope.css';
@import '/statistical-new/font-awesome/css/font-awesome.min.css';
@import '../styles/analysis-layout.css';
/* 卡片高度随内容自适应,避免条目增多时溢出裁切;同行卡片等高 */
.statistical-analysis--adaptive .twobox {
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.statistical-analysis--adaptive .twobox .threebox {
float: none;
height: auto;
/* min-height: 25rem; */
/* 等高时多出的高度留在底部,内容保持顶部对齐,不被分配到行间 */
align-content: flex-start;
}
/* 标题不再被第一行卡片压住(原 -2% / -0.5% 负外边距会让卡片上移盖住标题) */
.statistical-analysis--adaptive .twobox .chart_tit {
position: relative;
z-index: 1;
}
.statistical-analysis--adaptive .twobox .count_wrap {
margin-top: 0;
}
.statistical-analysis--adaptive .twobox .count_item {
margin-top: 0;
}
/* 投诉类别条目之间留出间隙 */
.statistical-analysis--adaptive .twobox .com_cate {
gap: 0.75rem;
}
.statistical-analysis--adaptive .twobox .com_cate .count_item {
margin: 0;
width: calc(50% - 0.375rem);
}
</style>