# 实施文档 - 监管看板

> 模块名称：监管看板
> 关联需求：需求-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
**评审人**：前端架构师、后端架构师
