886 lines
25 KiB
Markdown
886 lines
25 KiB
Markdown
# 实施文档 - 监管看板
|
||
|
||
> 模块名称:监管看板
|
||
> 关联需求:需求-01-监管看板.md
|
||
> 创建日期:2026-01-14
|
||
|
||
---
|
||
|
||
## 一、实施概览
|
||
|
||
### 1.1 功能清单
|
||
|
||
| 序号 | 功能模块 | 后端 | 前端 | 优先级 |
|
||
|------|---------|------|------|--------|
|
||
| 1 | 核心指标卡片 | ✅ | ✅ | P0 |
|
||
| 2 | 年龄分布饼图 | ✅ | ✅ | P0 |
|
||
| 3 | 刑期分布饼图 | ✅ | ✅ | P0 |
|
||
| 4 | 文化程度饼图 | ✅ | ✅ | P0 |
|
||
| 5 | 省份分布地图 | ✅ | ✅ | P0 |
|
||
| 6 | 数据脱敏组件 | ✅ | ✅ | P0 |
|
||
|
||
### 1.2 预估工时
|
||
|
||
| 阶段 | 后端 | 前端 | 合计 |
|
||
|------|------|------|------|
|
||
| 数据库 | 0.5h | - | 0.5h |
|
||
| 接口开发 | 4h | - | 4h |
|
||
| 前端页面 | - | 6h | 6h |
|
||
| 联调测试 | 2h | 2h | 4h |
|
||
| **总计** | **6.5h** | **8h** | **14.5h** |
|
||
|
||
---
|
||
|
||
## 二、数据库实施
|
||
|
||
> **说明**:使用现有表进行统计,无需新增表
|
||
> - "本月已移交" 使用现有的 `prison_prisoner_area_log` 表(调监记录)
|
||
> - "本月已释放" 使用现有的 `prison_prisoner_release` 表
|
||
|
||
### 2.1 新增索引
|
||
|
||
```sql
|
||
-- 罪犯表:年龄计算需要用到 birthday 字段
|
||
ALTER TABLE prison_prisoner ADD INDEX idx_birthday (birthday);
|
||
|
||
-- 释放记录表:按月统计需要
|
||
ALTER TABLE prison_prisoner_release ADD INDEX idx_actual_release_date (actual_release_date);
|
||
|
||
-- 监区变动记录表:按月统计需要
|
||
ALTER TABLE prison_prisoner_area_log ADD INDEX idx_operate_time_change_type (operate_time, change_type);
|
||
```
|
||
|
||
### 2.3 菜单权限 SQL
|
||
|
||
```sql
|
||
-- 监管看板菜单
|
||
INSERT INTO system_menu (name, permission, type, sort, path, icon, component, parent_id)
|
||
VALUES ('监管看板', '', 2, 1, 'dashboard', 'ep:data-board', 'prison/dashboard/index',
|
||
(SELECT id FROM system_menu WHERE name = '监狱管理' LIMIT 1));
|
||
|
||
-- 获取刚插入的菜单ID
|
||
SET @dashboard_menu_id = LAST_INSERT_ID();
|
||
|
||
-- 监管看板查询权限
|
||
INSERT INTO system_menu (name, permission, type, sort, parent_id)
|
||
VALUES ('监管看板查询', 'prison:dashboard:query', 3, 1, @dashboard_menu_id);
|
||
```
|
||
|
||
---
|
||
|
||
## 三、后端实施
|
||
|
||
### 3.1 文件结构
|
||
|
||
```
|
||
backend/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/
|
||
├── controller/admin/dashboard/
|
||
│ ├── PrisonDashboardController.java # 控制器
|
||
│ └── vo/
|
||
│ ├── DashboardStatisticsVO.java # 看板统计响应
|
||
│ ├── DashboardCardVO.java # 卡片数据响应
|
||
│ ├── ChartDataVO.java # 图表数据项
|
||
│ └── ProvinceChartVO.java # 省份数据项
|
||
├── service/dashboard/
|
||
│ ├── PrisonDashboardService.java # 服务接口
|
||
│ └── impl/
|
||
│ └── PrisonDashboardServiceImpl.java # 服务实现
|
||
├── dal/mysql/dashboard/
|
||
│ └── PrisonDashboardMapper.java # Mapper接口
|
||
├── dal/mysql/transfer/
|
||
│ └── PrisonTransferMapper.java # 移交记录Mapper(新增)
|
||
└── util/
|
||
└── DataMaskUtils.java # 数据脱敏工具类(新增)
|
||
```
|
||
|
||
### 3.2 VO 定义
|
||
|
||
#### 3.2.1 DashboardStatisticsVO.java
|
||
|
||
```java
|
||
package cn.iocoder.yudao.module.prison.controller.admin.dashboard.vo;
|
||
|
||
import io.swagger.v3.oas.annotations.media.Schema;
|
||
import lombok.Data;
|
||
import java.time.LocalDateTime;
|
||
import java.util.List;
|
||
|
||
@Data
|
||
@Schema(description = "管理后台 - 监管看板统计 Response VO")
|
||
public class DashboardStatisticsVO {
|
||
|
||
// 核心指标卡片
|
||
@Schema(description = "在册罪犯总数")
|
||
private Integer totalPrisoners;
|
||
|
||
@Schema(description = "本月已释放人数")
|
||
private Integer monthlyReleased;
|
||
|
||
@Schema(description = "本月已移交人数")
|
||
private Integer monthlyTransferred;
|
||
|
||
@Schema(description = "当前就医人数")
|
||
private Integer hospitalCount;
|
||
|
||
@Schema(description = "当前禁闭人数")
|
||
private Integer solitaryCount;
|
||
|
||
// 图表数据
|
||
@Schema(description = "年龄分布")
|
||
private List<ChartDataVO> ageDistribution;
|
||
|
||
@Schema(description = "刑期分布")
|
||
private List<ChartDataVO> sentenceDistribution;
|
||
|
||
@Schema(description = "文化程度分布")
|
||
private List<ChartDataVO> educationDistribution;
|
||
|
||
@Schema(description = "省份分布")
|
||
private List<ProvinceChartVO> provinceDistribution;
|
||
|
||
@Schema(description = "统计时间")
|
||
private LocalDateTime statisticsTime;
|
||
}
|
||
```
|
||
|
||
#### 3.2.2 ChartDataVO.java
|
||
|
||
```java
|
||
package cn.iocoder.yudao.module.prison.controller.admin.dashboard.vo;
|
||
|
||
import io.swagger.v3.oas.annotations.media.Schema;
|
||
import lombok.AllArgsConstructor;
|
||
import lombok.Builder;
|
||
import lombok.Data;
|
||
import lombok.NoArgsConstructor;
|
||
|
||
@Data
|
||
@Builder
|
||
@NoArgsConstructor
|
||
@AllArgsConstructor
|
||
@Schema(description = "图表数据项")
|
||
public class ChartDataVO {
|
||
|
||
@Schema(description = "分组名称", example = "18-30岁")
|
||
private String name;
|
||
|
||
@Schema(description = "数量", example = "100")
|
||
private Integer value;
|
||
|
||
@Schema(description = "占比", example = "28.5")
|
||
private Double percentage;
|
||
}
|
||
```
|
||
|
||
#### 3.2.3 ProvinceChartVO.java
|
||
|
||
```java
|
||
package cn.iocoder.yudao.module.prison.controller.admin.dashboard.vo;
|
||
|
||
import io.swagger.v3.oas.annotations.media.Schema;
|
||
import lombok.AllArgsConstructor;
|
||
import lombok.Builder;
|
||
import lombok.Data;
|
||
import lombok.NoArgsConstructor;
|
||
|
||
@Data
|
||
@Builder
|
||
@NoArgsConstructor
|
||
@AllArgsConstructor
|
||
@Schema(description = "省份图表数据")
|
||
public class ProvinceChartVO {
|
||
|
||
@Schema(description = "省份名称", example = "河南省")
|
||
private String province;
|
||
|
||
@Schema(description = "省份编码", example = "41")
|
||
private Integer provinceCode;
|
||
|
||
@Schema(description = "人数", example = "156")
|
||
private Integer count;
|
||
}
|
||
```
|
||
|
||
### 3.3 Mapper 接口
|
||
|
||
#### 3.3.1 PrisonDashboardMapper.java
|
||
|
||
```java
|
||
package cn.iocoder.yudao.module.prison.dal.mysql.dashboard;
|
||
|
||
import cn.iocoder.yudao.module.prison.controller.admin.dashboard.vo.ChartDataVO;
|
||
import cn.iocoder.yudao.module.prison.controller.admin.dashboard.vo.DashboardCardVO;
|
||
import cn.iocoder.yudao.module.prison.controller.admin.dashboard.vo.ProvinceChartVO;
|
||
import org.apache.ibatis.annotations.Mapper;
|
||
import org.apache.ibatis.annotations.Param;
|
||
import org.apache.ibatis.annotations.Select;
|
||
|
||
import java.util.List;
|
||
|
||
@Mapper
|
||
public interface PrisonDashboardMapper {
|
||
|
||
/**
|
||
* 查询核心指标卡片数据
|
||
*/
|
||
@Select("""
|
||
SELECT
|
||
COALESCE(SUM(CASE WHEN p.status = 1 THEN 1 ELSE 0 END), 0) AS total_prisoners,
|
||
COALESCE(SUM(CASE WHEN pa.type = 5 THEN 1 ELSE 0 END), 0) AS hospital_count,
|
||
COALESCE(SUM(CASE WHEN pa.type = 6 THEN 1 ELSE 0 END), 0) AS solitary_count
|
||
FROM prison_prisoner p
|
||
LEFT JOIN prison_area pa ON p.prison_area_id = pa.id
|
||
WHERE p.deleted = 0
|
||
""")
|
||
DashboardCardVO selectDashboardCards();
|
||
|
||
/**
|
||
* 查询本月释放人数
|
||
*/
|
||
@Select("""
|
||
SELECT COUNT(*) FROM prison_prisoner_release
|
||
WHERE deleted = 0
|
||
AND DATE_FORMAT(actual_release_date, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
AND status = 1
|
||
""")
|
||
Integer selectMonthlyReleased();
|
||
|
||
/**
|
||
* 查询本月调监次数(移交)
|
||
* change_type: 1-调入 2-调出
|
||
* 使用现有的 prison_prisoner_area_log 表
|
||
*/
|
||
@Select("""
|
||
SELECT COUNT(*) FROM prison_prisoner_area_log
|
||
WHERE deleted = 0
|
||
AND change_type IN (1, 2)
|
||
AND DATE_FORMAT(operate_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
""")
|
||
Integer selectMonthlyTransferred();
|
||
|
||
/**
|
||
* 查询年龄分布
|
||
*/
|
||
@Select("""
|
||
SELECT
|
||
CASE
|
||
WHEN TIMESTAMPDIFF(YEAR, p.birthday, CURDATE()) < 18 THEN '未成年(≤17)'
|
||
WHEN TIMESTAMPDIFF(YEAR, p.birthday, CURDATE()) BETWEEN 18 AND 30 THEN '青年(18-30)'
|
||
WHEN TIMESTAMPDIFF(YEAR, p.birthday, CURDATE()) BETWEEN 31 AND 50 THEN '中年(31-50)'
|
||
WHEN TIMESTAMPDIFF(YEAR, p.birthday, CURDATE()) BETWEEN 51 AND 60 THEN '中老年(51-60)'
|
||
ELSE '老龄(60+)'
|
||
END AS name,
|
||
COUNT(*) AS value
|
||
FROM prison_prisoner p
|
||
WHERE p.status = 1 AND p.deleted = 0
|
||
GROUP BY name
|
||
""")
|
||
List<ChartDataVO> selectAgeDistribution();
|
||
|
||
/**
|
||
* 查询刑期分布
|
||
*/
|
||
@Select("""
|
||
SELECT
|
||
CASE
|
||
WHEN p.death_sentence_reprieve = 1 THEN '死缓'
|
||
WHEN p.life_imprisonment = 1 THEN '无期'
|
||
WHEN (COALESCE(p.sentence_years, 0) * 12 + COALESCE(p.sentence_months, 0)) <= 36 THEN '短刑(≤3年)'
|
||
WHEN (COALESCE(p.sentence_years, 0) * 12 + COALESCE(p.sentence_months, 0)) BETWEEN 37 AND 120 THEN '中刑(3-10年)'
|
||
ELSE '长刑(10年以上)'
|
||
END AS name,
|
||
COUNT(*) AS value
|
||
FROM prison_prisoner p
|
||
WHERE p.status = 1 AND p.deleted = 0
|
||
GROUP BY name
|
||
""")
|
||
List<ChartDataVO> selectSentenceDistribution();
|
||
|
||
/**
|
||
* 查询文化程度分布
|
||
*/
|
||
@Select("""
|
||
SELECT
|
||
CASE p.education
|
||
WHEN 1 THEN '文盲'
|
||
WHEN 2 THEN '小学'
|
||
WHEN 3 THEN '初中'
|
||
WHEN 4 THEN '高中'
|
||
WHEN 5 THEN '中专'
|
||
WHEN 6 THEN '大专'
|
||
WHEN 7 THEN '本科'
|
||
WHEN 8 THEN '研究生及以上'
|
||
ELSE '未知'
|
||
END AS name,
|
||
COUNT(*) AS value
|
||
FROM prison_prisoner p
|
||
WHERE p.status = 1 AND p.deleted = 0
|
||
GROUP BY p.education
|
||
""")
|
||
List<ChartDataVO> selectEducationDistribution();
|
||
|
||
/**
|
||
* 查询省份分布
|
||
*/
|
||
@Select("""
|
||
SELECT
|
||
LEFT(p.native_place, 2) AS province_code,
|
||
COUNT(*) AS count
|
||
FROM prison_prisoner p
|
||
WHERE p.status = 1 AND p.deleted = 0 AND p.native_place IS NOT NULL
|
||
GROUP BY LEFT(p.native_place, 2)
|
||
""")
|
||
List<ProvinceChartVO> selectProvinceDistributionRaw();
|
||
}
|
||
```
|
||
|
||
### 3.4 Service 实现
|
||
|
||
```java
|
||
package cn.iocoder.yudao.module.prison.service.dashboard.impl;
|
||
|
||
import cn.iocoder.yudao.module.prison.controller.admin.dashboard.vo.*;
|
||
import cn.iocoder.yudao.module.prison.dal.mysql.dashboard.PrisonDashboardMapper;
|
||
import lombok.RequiredArgsConstructor;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.cache.annotation.Cacheable;
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import java.time.LocalDateTime;
|
||
import java.util.List;
|
||
|
||
@Slf4j
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
public class PrisonDashboardServiceImpl implements PrisonDashboardService {
|
||
|
||
private final PrisonDashboardMapper dashboardMapper;
|
||
|
||
private static final String DASHBOARD_CACHE_KEY = "prison:dashboard:stats:";
|
||
|
||
@Override
|
||
@Cacheable(value = "dashboard", key = "#root.target.getCacheKey()", unless = "#result == null")
|
||
public DashboardStatisticsVO getDashboardStatistics() {
|
||
DashboardStatisticsVO vo = new DashboardStatisticsVO();
|
||
vo.setStatisticsTime(LocalDateTime.now());
|
||
|
||
// 核心指标卡片
|
||
DashboardCardVO cards = dashboardMapper.selectDashboardCards();
|
||
vo.setTotalPrisoners(cards.getTotalPrisoners());
|
||
vo.setHospitalCount(cards.getHospitalCount());
|
||
vo.setSolitaryCount(cards.getSolitaryCount());
|
||
vo.setMonthlyReleased(dashboardMapper.selectMonthlyReleased());
|
||
vo.setMonthlyTransferred(dashboardMapper.selectMonthlyTransferred());
|
||
|
||
// 图表数据
|
||
vo.setAgeDistribution(dashboardMapper.selectAgeDistribution());
|
||
vo.setSentenceDistribution(dashboardMapper.selectSentenceDistribution());
|
||
vo.setEducationDistribution(dashboardMapper.selectEducationDistribution());
|
||
vo.setProvinceDistribution(convertProvinceData(dashboardMapper.selectProvinceDistributionRaw()));
|
||
|
||
return vo;
|
||
}
|
||
|
||
private List<ProvinceChartVO> convertProvinceData(List<ProvinceChartVO> rawData) {
|
||
// 省份编码转省份名称的映射逻辑
|
||
return rawData.stream()
|
||
.map(item -> ProvinceChartVO.builder()
|
||
.province(ProvinceCodeMapper.getName(item.getProvinceCode()))
|
||
.provinceCode(item.getProvinceCode())
|
||
.count(item.getCount())
|
||
.build())
|
||
.toList();
|
||
}
|
||
|
||
public String getCacheKey() {
|
||
return DASHBOARD_CACHE_KEY + "all";
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.5 Controller
|
||
|
||
```java
|
||
package cn.iocoder.yudao.module.prison.controller.admin.dashboard;
|
||
|
||
import cn.iocoder.yudao.module.prison.controller.admin.dashboard.vo.DashboardStatisticsVO;
|
||
import cn.iocoder.yudao.module.prison.service.dashboard.PrisonDashboardService;
|
||
import io.swagger.v3.oas.annotations.Operation;
|
||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||
import lombok.RequiredArgsConstructor;
|
||
import org.springframework.security.access.prepost.PreAuthorize;
|
||
import org.springframework.web.bind.annotation.GetMapping;
|
||
import org.springframework.web.bind.annotation.RequestMapping;
|
||
import org.springframework.web.bind.annotation.RestController;
|
||
|
||
@RestController
|
||
@RequestMapping("/prison/dashboard")
|
||
@Tag(name = "管理后台 - 监管看板")
|
||
@RequiredArgsConstructor
|
||
public class PrisonDashboardController {
|
||
|
||
private final PrisonDashboardService dashboardService;
|
||
|
||
@GetMapping("/statistics")
|
||
@Operation(summary = "获取看板统计数据")
|
||
@PreAuthorize("@ss.hasPermission('prison:dashboard:query')")
|
||
public DashboardStatisticsVO getStatistics() {
|
||
return dashboardService.getDashboardStatistics();
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.6 数据脱敏工具
|
||
|
||
```java
|
||
package cn.iocoder.yudao.module.prison.util;
|
||
|
||
import cn.hutool.core.util.StrUtil;
|
||
|
||
public class DataMaskUtils {
|
||
|
||
/**
|
||
* 身份证号脱敏:110101199001011234 -> 110***********1234
|
||
*/
|
||
public static String maskIdCard(String idCard) {
|
||
if (StrUtil.isBlank(idCard) || idCard.length() < 7) {
|
||
return idCard;
|
||
}
|
||
return idCard.substring(0, 3) + "*".repeat(idCard.length() - 7) + idCard.substring(idCard.length() - 4);
|
||
}
|
||
|
||
/**
|
||
* 手机号脱敏:13812345678 -> 138****5678
|
||
*/
|
||
public static String maskPhone(String phone) {
|
||
if (StrUtil.isBlank(phone) || phone.length() < 7) {
|
||
return phone;
|
||
}
|
||
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
|
||
}
|
||
|
||
/**
|
||
* 姓名脱敏:张三丰 -> 张**
|
||
*/
|
||
public static String maskName(String name) {
|
||
if (StrUtil.isBlank(name) || name.length() == 1) {
|
||
return name;
|
||
}
|
||
return name.charAt(0) + "*".repeat(name.length() - 1);
|
||
}
|
||
|
||
/**
|
||
* 地址脱敏:北京市海淀区*** -> 北京市海淀区***
|
||
*/
|
||
public static String maskAddress(String address) {
|
||
if (StrUtil.isBlank(address)) {
|
||
return address;
|
||
}
|
||
int lastSeparator = Math.max(address.lastIndexOf('区'), address.lastIndexOf('县'));
|
||
if (lastSeparator > 0 && lastSeparator < address.length() - 1) {
|
||
return address.substring(0, lastSeparator + 1) + "***";
|
||
}
|
||
return address;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、前端实施
|
||
|
||
### 4.1 文件结构
|
||
|
||
```
|
||
frontend/src/
|
||
├── views/prison/dashboard/
|
||
│ ├── index.vue # 主页面组件
|
||
│ └── components/
|
||
│ ├── StatCard.vue # 指标卡片组件
|
||
│ ├── AgePieChart.vue # 年龄分布饼图
|
||
│ ├── SentencePieChart.vue # 刑期分布饼图
|
||
│ ├── EducationPieChart.vue # 文化程度饼图
|
||
│ └── ChinaMap.vue # 中国地图热力图
|
||
├── api/prison/dashboard/
|
||
│ └── index.ts # API 接口定义
|
||
├── components/DataMaskingCell/
|
||
│ ├── index.vue # 脱敏单元格组件
|
||
│ └── index.ts # 组件类型定义
|
||
└── plugins/echarts.ts # ECharts 地图注册
|
||
```
|
||
|
||
### 4.2 API 定义
|
||
|
||
```typescript
|
||
// frontend/src/api/prison/dashboard/index.ts
|
||
|
||
/** 看板统计响应 */
|
||
export interface DashboardStatisticsVO {
|
||
totalPrisoners: number;
|
||
monthlyReleased: number;
|
||
monthlyTransferred: number;
|
||
hospitalCount: number;
|
||
solitaryCount: number;
|
||
ageDistribution: ChartDataVO[];
|
||
sentenceDistribution: ChartDataVO[];
|
||
educationDistribution: ChartDataVO[];
|
||
provinceDistribution: ProvinceChartVO[];
|
||
statisticsTime: string;
|
||
}
|
||
|
||
/** 图表数据项 */
|
||
export interface ChartDataVO {
|
||
name: string;
|
||
value: number;
|
||
percentage?: number;
|
||
}
|
||
|
||
/** 省份数据 */
|
||
export interface ProvinceChartVO {
|
||
province: string;
|
||
provinceCode: number;
|
||
count: number;
|
||
}
|
||
|
||
export const DashboardApi = {
|
||
getStatistics: async (): Promise<DashboardStatisticsVO> => {
|
||
return await request.get({ url: '/prison/dashboard/statistics' })
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.3 指标卡片组件
|
||
|
||
```vue
|
||
<!-- frontend/src/views/prison/dashboard/components/StatCard.vue -->
|
||
<template>
|
||
<el-card shadow="hover" class="stat-card">
|
||
<div class="stat-content">
|
||
<div class="stat-info">
|
||
<div class="stat-value" v-loading="loading">
|
||
<el-skeleton v-if="loading" animated :rows="0" />
|
||
<template v-else>{{ formatNumber(value) }}</template>
|
||
</div>
|
||
<div class="stat-title">{{ title }}</div>
|
||
</div>
|
||
<div class="stat-icon" :class="iconClass">
|
||
<Icon :icon="icon" :size="36" />
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
interface Props {
|
||
title: string
|
||
value: number
|
||
icon: string
|
||
iconClass?: string
|
||
loading?: boolean
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
const formatNumber = (num: number) => {
|
||
return new Intl.NumberFormat('zh-CN').format(num)
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.stat-card {
|
||
height: 100%;
|
||
|
||
.stat-content {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 28px;
|
||
font-weight: bold;
|
||
color: #303133;
|
||
}
|
||
|
||
.stat-title {
|
||
font-size: 14px;
|
||
color: #909399;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.stat-icon {
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&.primary { background: #ecf5ff; color: #409eff; }
|
||
&.success { background: #f0f9eb; color: #67c23a; }
|
||
&.warning { background: #fdf6ec; color: #e6a23c; }
|
||
&.danger { background: #fef0f0; color: #f56c6c; }
|
||
}
|
||
}
|
||
</style>
|
||
```
|
||
|
||
### 4.4 主页面
|
||
|
||
```vue
|
||
<!-- frontend/src/views/prison/dashboard/index.vue -->
|
||
<template>
|
||
<div class="dashboard-container">
|
||
<el-row :gutter="16" class="mb-16px">
|
||
<el-col :xs="24" :sm="12" :md="8" :lg="24/5" v-for="item in statCards" :key="item.title">
|
||
<StatCard v-bind="item" :loading="loading" :value="item.value" />
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="16" class="mb-16px">
|
||
<el-col :xs="24" :sm="24" :md="8">
|
||
<el-card>
|
||
<template #header>年龄分布</template>
|
||
<BasePieChart :options="ageChartOptions" :loading="loading" />
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :xs="24" :sm="24" :md="8">
|
||
<el-card>
|
||
<template #header>刑期分布</template>
|
||
<BasePieChart :options="sentenceChartOptions" :loading="loading" />
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :xs="24" :sm="24" :md="8">
|
||
<el-card>
|
||
<template #header>文化程度</template>
|
||
<BasePieChart :options="educationChartOptions" :loading="loading" />
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="16">
|
||
<el-col :span="24">
|
||
<el-card>
|
||
<template #header>籍贯分布</template>
|
||
<ChinaMap :data="provinceData" :loading="loading" />
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { StatCard } from './components'
|
||
import { DashboardApi, type DashboardStatisticsVO } from '@/api/prison/dashboard'
|
||
import BasePieChart from '@/components/Echart/src/BasePieChart.vue'
|
||
import ChinaMap from './components/ChinaMap.vue'
|
||
|
||
const loading = ref(true)
|
||
const data = ref<DashboardStatisticsVO | null>(null)
|
||
|
||
const statCards = computed(() => [
|
||
{ title: '在册罪犯', value: data.value?.totalPrisoners || 0, icon: 'ep:user', iconClass: 'primary' },
|
||
{ title: '本月释放', value: data.value?.monthlyReleased || 0, icon: 'ep:select', iconClass: 'success' },
|
||
{ title: '本月移交', value: data.value?.monthlyTransferred || 0, icon: 'ep:rank-list', iconClass: 'warning' },
|
||
{ title: '当前就医', value: data.value?.hospitalCount || 0, icon: 'ep:first-aid-kit', iconClass: 'danger' },
|
||
{ title: '当前禁闭', value: data.value?.solitaryCount || 0, icon: 'ep:lock', iconClass: 'danger' }
|
||
])
|
||
|
||
const ageChartOptions = computed(() => ({
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||
legend: { orient: 'vertical', right: 10, top: 'center' },
|
||
series: [{
|
||
type: 'pie',
|
||
radius: ['40%', '70%'],
|
||
data: data.value?.ageDistribution || []
|
||
}]
|
||
}))
|
||
|
||
const sentenceChartOptions = computed(() => ({
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||
legend: { orient: 'vertical', right: 10, top: 'center' },
|
||
series: [{
|
||
type: 'pie',
|
||
radius: ['40%', '70%'],
|
||
data: data.value?.sentenceDistribution || []
|
||
}]
|
||
}))
|
||
|
||
const educationChartOptions = computed(() => ({
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||
legend: { orient: 'vertical', right: 10, top: 'center' },
|
||
series: [{
|
||
type: 'pie',
|
||
radius: ['40%', '70%'],
|
||
data: data.value?.educationDistribution || []
|
||
}]
|
||
}))
|
||
|
||
const provinceData = computed(() => data.value?.provinceDistribution || [])
|
||
|
||
const fetchData = async () => {
|
||
loading.value = true
|
||
try {
|
||
data.value = await DashboardApi.getStatistics()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(fetchData)
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.dashboard-container {
|
||
padding: 16px;
|
||
}
|
||
.mb-16px { margin-bottom: 16px; }
|
||
</style>
|
||
```
|
||
|
||
### 4.5 地图资源配置
|
||
|
||
```typescript
|
||
// frontend/src/plugins/echarts.ts
|
||
import * as echarts from 'echarts'
|
||
import chinaJson from 'echarts/map/json/china.json'
|
||
|
||
// 注册中国地图
|
||
echarts.registerMap('china', chinaJson)
|
||
|
||
export { echarts }
|
||
```
|
||
|
||
> **注意**:需要下载中国地图 JSON 文件到 `frontend/public/china.json`
|
||
> 下载地址:https://echarts.apache.org/examples/zh/data/asset/geo/china.json
|
||
|
||
### 4.6 路由配置
|
||
|
||
```typescript
|
||
// frontend/src/router/routes/modules/prison.ts
|
||
|
||
{
|
||
path: 'dashboard',
|
||
name: 'PrisonDashboard',
|
||
component: () => import('@/views/prison/dashboard/index.vue'),
|
||
meta: {
|
||
title: '监管看板',
|
||
icon: 'ep:data-board',
|
||
permission: 'prison:dashboard:query'
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、任务分解
|
||
|
||
### 5.1 后端任务
|
||
|
||
> **说明**:使用现有表,无需新建移交记录表
|
||
|
||
| 序号 | 任务 | 负责人 | 预计工时 | 状态 |
|
||
|------|------|--------|---------|------|
|
||
| 1 | 新增数据库索引 SQL(3个索引) | | 0.5h | 待开发 |
|
||
| 2 | 菜单权限 SQL | | 0.5h | 待开发 |
|
||
| 3 | VO 类开发(4个文件) | | 1h | 待开发 |
|
||
| 4 | PrisonDashboardMapper 接口 | | 1h | 待开发 |
|
||
| 5 | PrisonDashboardService 接口和实现 | | 1h | 待开发 |
|
||
| 6 | PrisonDashboardController | | 0.5h | 待开发 |
|
||
| 7 | DataMaskUtils 工具类 | | 0.5h | 待开发 |
|
||
| 8 | 接口测试 | | 1h | 待开发 |
|
||
|
||
### 5.2 前端任务
|
||
|
||
| 序号 | 任务 | 负责人 | 预计工时 | 状态 |
|
||
|------|------|--------|---------|------|
|
||
| 1 | 下载并配置中国地图 JSON | | 0.5h | 待开发 |
|
||
| 2 | API 接口定义 | | 0.5h | 待开发 |
|
||
| 3 | StatCard 组件 | | 1h | 待开发 |
|
||
| 4 | ChinaMap 组件 | | 2h | 待开发 |
|
||
| 5 | 主页面开发 | | 2h | 待开发 |
|
||
| 6 | 路由配置 | | 0.5h | 待开发 |
|
||
| 7 | 联调测试 | | 2h | 待开发 |
|
||
|
||
---
|
||
|
||
## 六、验收标准
|
||
|
||
### 6.1 功能验收
|
||
|
||
- [ ] 核心指标卡片数据与数据库一致
|
||
- [ ] 年龄分布饼图按要求分段显示
|
||
- [ ] 刑期分布饼图按要求分段显示
|
||
- [ ] 文化程度饼图按要求分类显示
|
||
- [ ] 中国地图正确显示各省热力分布
|
||
- [ ] 点击地图省份显示详细信息
|
||
- [ ] 响应式布局在各种屏幕尺寸下正常显示
|
||
- [ ] 数据加载时显示骨架屏
|
||
|
||
### 6.2 性能验收
|
||
|
||
- [ ] 页面加载时间 < 3秒
|
||
- [ ] 图表渲染时间 < 1秒
|
||
- [ ] 支持多用户并发访问
|
||
|
||
### 6.3 安全验收
|
||
|
||
- [ ] 敏感信息已配置脱敏规则
|
||
- [ ] 数据权限控制生效(按监区)
|
||
|
||
---
|
||
|
||
## 七、联调步骤
|
||
|
||
### 7.1 后端启动
|
||
```bash
|
||
cd backend/yudao-module-prison
|
||
mvn clean package -DskipTests
|
||
cd ../yudao-server
|
||
mvn spring-boot:run
|
||
```
|
||
|
||
### 7.2 前端启动
|
||
```bash
|
||
cd frontend
|
||
pnpm dev
|
||
```
|
||
|
||
### 7.3 测试数据
|
||
```sql
|
||
-- 确保有以下测试数据
|
||
-- 1. 罪犯表有在押状态数据
|
||
-- 2. 释放记录表有本月数据
|
||
-- 3. 监区表有医院(5)和禁闭室(6)类型数据
|
||
```
|
||
|
||
---
|
||
|
||
## 八、风险与应对
|
||
|
||
| 风险 | 等级 | 应对措施 |
|
||
|------|------|----------|
|
||
| 移交记录表缺失 | 高 | 优先完成数据库设计 |
|
||
| 地图 JSON 资源 | 中 | 提前下载或使用 CDN |
|
||
| 数据权限复杂性 | 中 | 先实现基础版本,再迭代 |
|
||
| 性能问题 | 低 | 添加 Redis 缓存 |
|
||
|
||
---
|
||
|
||
## 九、依赖项
|
||
|
||
### 9.1 前置依赖
|
||
- [ ] 罪犯信息管理模块(数据来源)
|
||
- [ ] 释放记录模块(数据来源)
|
||
- [ ] 监区管理模块(数据来源)
|
||
|
||
### 9.2 外部依赖
|
||
- [ ] ECharts 地图 JSON 文件
|
||
|
||
---
|
||
|
||
**文档版本**:v1.0
|
||
**创建日期**:2026-01-14
|
||
**评审人**:前端架构师、后端架构师
|