xlcp/requirements/实施文档-计分考核-前端.md
tangweijie 260dc75a32 fix: 代码审查修复与前端类型完善
代码审查修复:
- 前端页面统一使用 dateFormatter 格式化日期
- API 文件添加分页参数类型定义
- 移除未使用的 Dayjs 导入

前端类型完善:
- RiskAssessmentPageParams 危险评估分页参数
- ScorePageParams 计分考核分页参数
- ConsumptionPageParams 消费记录分页参数

新增评估模块前端:
- assessment API 接口定义
- assessment/record 评估记录列表页面
2026-01-15 20:51:49 +08:00

49 KiB
Raw Permalink Blame History

计分考核模块 - 前端实施文档

版本v1.0 创建日期2026-01-14 优先级P0


一、概述

1.1 文档说明

本文档为计分考核模块的前端实施指南,基于需求文档「需求-03-计分考核.md」和后端实施文档编写。

1.2 前端文件结构

frontend/src/
├── views/prison/score/
│   ├── subject/                           # 考核规则配置
│   │   ├── index.vue                      # 列表页
│   │   └── SubjectForm.vue                # 新增/编辑表单
│   ├── record/                            # 日常考核记录
│   │   ├── index.vue                      # 列表页
│   │   └── RecordForm.vue                 # 新增/编辑表单(含批量录入)
│   ├── monthly/                           # 月度考核汇总
│   │   ├── index.vue                      # 列表页
│   │   └── MonthlyDetail.vue              # 月度明细
│   ├── level/                             # 等级规则配置
│   │   └── index.vue                      # 配置页
│   ├── notice/                            # 考核公示
│   │   ├── index.vue                      # 列表页
│   │   └── NoticeForm.vue                 # 新增/编辑/发布表单
│   └── parole/                            # 减刑假释数据
│       └── index.vue                      # 数据提取页
│
└── api/prison/score/
    ├── subject.ts                         # 考核规则API
    ├── record.ts                          # 日常记录API
    ├── monthly.ts                         # 月度汇总API
    ├── level.ts                           # 等级规则API
    ├── notice.ts                          # 公示公告API
    └── parole.ts                          # 减刑假释API

二、API接口定义

2.1 考核规则科目APIsubject.ts

import { request } from '@/config/axios'
import type { PageData, PageResult } from '@/types'

/** 考核规则科目接口 */
export interface ScoreSubject {
  id: number
  code: string
  name: string
  category: number
  categoryName: string
  score: number
  dailyLimit: number
  monthlyLimit: number
  description: string
  status: number
  statusName: string
  sort: number
  createTime: string
}

/** 查询参数 */
export interface ScoreSubjectQuery extends PageData {
  code?: string
  name?: string
  category?: number
  status?: number
}

/** 保存参数 */
export interface ScoreSubjectSaveParams {
  id?: number
  code: string
  name: string
  category: number
  score: number
  dailyLimit?: number
  monthlyLimit?: number
  description?: string
  status?: number
  sort?: number
}

/** 考核类别选项 */
export const CATEGORY_OPTIONS = [
  { value: 1, label: '劳动改造' },
  { value: 2, label: '教育改造' },
  { value: 3, label: '日常行为' },
  { value: 4, label: '卫生纪律' },
  { value: 5, label: '加分项' },
  { value: 6, label: '扣分项' }
]

export const ScoreSubjectApi = {
  /** 分页查询 */
  getPage: async (params: ScoreSubjectQuery) => {
    return await request.get({ url: '/score/subject/page', params })
  },

  /** 获取详情 */
  get: async (id: number) => {
    return await request.get({ url: `/score/subject/get?id=${id}` })
  },

  /** 获取列表(无分页) */
  getList: async (params?: Omit<ScoreSubjectQuery, 'pageNum' | 'pageSize'>) => {
    return await request.get({ url: '/score/subject/list', params })
  },

  /** 创建 */
  create: async (data: ScoreSubjectSaveParams) => {
    return await request.post({ url: '/score/subject/create', data })
  },

  /** 更新 */
  update: async (data: ScoreSubjectSaveParams) => {
    return await request.put({ url: '/score/subject/update', data })
  },

  /** 删除 */
  delete: async (id: number) => {
    return await request.delete({ url: `/score/subject/delete?id=${id}` })
  },

  /** 批量删除 */
  deleteList: async (ids: number[]) => {
    return await request.delete({ url: `/score/subject/delete-list?ids=${ids.join(',')}` })
  },

  /** 更新状态 */
  updateStatus: async (id: number, status: number) => {
    return await request.put({ url: '/score/subject/update-status', params: { id, status } })
  },

  /** 导出Excel */
  export: async (params: ScoreSubjectQuery) => {
    return await request.download({ url: '/score/subject/export-excel', params })
  }
}

2.2 日常考核记录APIrecord.ts

import { request } from '@/config/axios'
import type { PageData, PageResult } from '@/types'

/** 日常考核记录接口 */
export interface ScoreRecord {
  id: number
  prisonerId: number
  prisonerNo: string
  prisonerName: string
  prisonAreaId: number
  prisonAreaName: string
  recordDate: string
  subjectId: number
  subjectName: string
  category: number
  categoryName: string
  score: number
  actualScore: number
  remark: string
  recorderId: number
  recorderName: string
  createTime: string
}

/** 查询参数 */
export interface ScoreRecordQuery extends PageData {
  prisonerNo?: string
  prisonerName?: string
  recordDate?: string
  recordDateRange?: string[]
  category?: number
  subjectId?: number
  prisonAreaId?: number
}

/** 保存参数 */
export interface ScoreRecordSaveParams {
  id?: number
  prisonerId: number
  recordDate: string
  subjectId: number
  score: number
  remark?: string
}

/** 批量创建参数 */
export interface ScoreRecordBatchParams {
  recordDate: string
  prisonerIds: number[]
  subjectId: number
  score: number
  remark?: string
}

export const ScoreRecordApi = {
  /** 分页查询 */
  getPage: async (params: ScoreRecordQuery) => {
    return await request.get({ url: '/score/record/page', params })
  },

  /** 获取详情 */
  get: async (id: number) => {
    return await request.get({ url: `/score/record/get?id=${id}` })
  },

  /** 获取罪犯考核明细 */
  getByPrisoner: async (prisonerId: number, year?: number, month?: number) => {
    return await request.get({ url: '/score/record/get-by-prisoner', params: { prisonerId, year, month } })
  },

  /** 创建 */
  create: async (data: ScoreRecordSaveParams) => {
    return await request.post({ url: '/score/record/create', data })
  },

  /** 批量创建 */
  batchCreate: async (data: ScoreRecordBatchParams) => {
    return await request.post({ url: '/score/record/batch-create', data })
  },

  /** 更新 */
  update: async (data: ScoreRecordSaveParams) => {
    return await request.put({ url: '/score/record/update', data })
  },

  /** 删除 */
  delete: async (id: number) => {
    return await request.delete({ url: `/score/record/delete?id=${id}` })
  },

  /** 导出Excel */
  export: async (params: ScoreRecordQuery) => {
    return await request.download({ url: '/score/record/export-excel', params })
  }
}

2.3 月度考核汇总APImonthly.ts

import { request } from '@/config/axios'
import type { PageData } from '@/types'

/** 月度考核汇总接口 */
export interface ScoreMonthly {
  id: number
  prisonerId: number
  prisonerNo: string
  prisonerName: string
  prisonAreaId: number
  prisonAreaName: string
  year: number
  month: number
  baseScore: number
  rewardScore: number
  penaltyScore: number
  totalScore: number
  level: number
  levelName: string
  assessorId: number
  assessorName: string
  status: number
  statusName: string
  remark: string
  createTime: string
}

/** 查询参数 */
export interface ScoreMonthlyQuery extends PageData {
  prisonerNo?: string
  prisonerName?: string
  year: number
  month?: number
  level?: number
  status?: number
  prisonAreaId?: number
}

/** 月度汇总明细 */
export interface ScoreMonthlyDetail {
  monthly: ScoreMonthly
  records: ScoreRecord[]
}

/** 减刑假释数据 */
export interface ScoreParoleData {
  prisonerId: number
  prisonerNo: string
  prisonerName: string
  monthlyScores: Array<{
    year: number
    month: number
    totalScore: number
    level: number
    levelName: string
  }>
  yearlyTotalScore: number
  praiseCount: number
  warningCount: number
  suggestion: {
    canApplyReduction: boolean
    suggestedReductionMonths: number
    canApplyParole: boolean
    paroleOpinion: string
  }
}

export const ScoreMonthlyApi = {
  /** 分页查询 */
  getPage: async (params: ScoreMonthlyQuery) => {
    return await request.get({ url: '/score/monthly/page', params })
  },

  /** 获取汇总详情 */
  getSummary: async (id: number) => {
    return await request.get({ url: `/score/monthly/get-summary?id=${id}` })
  },

  /** 获取月度汇总明细 */
  getDetail: async (id: number) => {
    return await request.get({ url: `/score/monthly/get-detail?id=${id}` })
  },

  /** 手动触发月度汇总计算 */
  calculate: async (year: number, month: number) => {
    return await request.post({ url: '/score/monthly/calculate', params: { year, month } })
  },

  /** 提交审核 */
  submitAudit: async (id: number) => {
    return await request.post({ url: '/score/monthly/submit-audit', params: { id } })
  },

  /** 审核通过 */
  auditPass: async (id: number, remark?: string) => {
    return await request.post({ url: '/score/monthly/audit', params: { id, status: 2, remark } })
  },

  /** 审核驳回 */
  auditReject: async (id: number, remark: string) => {
    return await request.post({ url: '/score/monthly/audit', params: { id, status: 3, remark } })
  },

  /** 导出Excel */
  export: async (params: ScoreMonthlyQuery) => {
    return await request.download({ url: '/score/monthly/export-excel', params })
  },

  /** 获取减刑假释数据 */
  getParoleData: async (prisonerId: number) => {
    return await request.get({ url: '/score/parole/get-data', params: { prisonerId } })
  },

  /** 生成减刑假释建议 */
  generateSuggestion: async (prisonerId: number) => {
    return await request.post({ url: '/score/parole/generate-suggestion', params: { prisonerId } })
  }
}

2.4 等级规则APIlevel.ts

import { request } from '@/config/axios'

/** 等级规则接口 */
export interface ScoreLevelRule {
  id: number
  level: number
  levelName: string
  minScore: number
  maxScore: number
  description: string
  sort: number
  status: number
}

/** 基础分配置 */
export interface BaseScoreConfig {
  baseScore: number
}

export const ScoreLevelApi = {
  /** 获取等级规则列表 */
  getList: async () => {
    return await request.get({ url: '/score/level/get' })
  },

  /** 更新等级规则 */
  update: async (data: ScoreLevelRule[]) => {
    return await request.put({ url: '/score/level/update', data })
  },

  /** 获取基础分配置 */
  getBaseScore: async () => {
    return await request.get({ url: '/score/level/get-base-score' })
  },

  /** 更新基础分配置 */
  updateBaseScore: async (baseScore: number) => {
    return await request.put({ url: '/score/level/update-base-score', params: { baseScore } })
  }
}

2.5 公示公告APInotice.ts

import { request } from '@/config/axios'
import type { PageData } from '@/types'

/** 公示公告接口 */
export interface ScoreNotice {
  id: number
  title: string
  noticeType: number
  noticeTypeName: string
  startDate: string
  endDate: string
  scopeType: number
  scopeTypeName: string
  scopePrisonAreaIds: string
  scopePrisonAreaNames: string
  content: string
  status: number
  statusName: string
  publisherId: number
  publisherName: string
  publishTime: string
  createTime: string
}

/** 查询参数 */
export interface ScoreNoticeQuery extends PageData {
  title?: string
  noticeType?: number
  status?: number
  startDate?: string
  endDate?: string
}

/** 保存参数 */
export interface ScoreNoticeSaveParams {
  id?: number
  title: string
  noticeType: number
  startDate: string
  endDate: string
  scopeType: number
  scopePrisonAreaIds?: string
  content?: string
}

export const ScoreNoticeApi = {
  /** 分页查询 */
  getPage: async (params: ScoreNoticeQuery) => {
    return await request.get({ url: '/score/notice/page', params })
  },

  /** 获取详情 */
  get: async (id: number) => {
    return await request.get({ url: `/score/notice/get?id=${id}` })
  },

  /** 创建 */
  create: async (data: ScoreNoticeSaveParams) => {
    return await request.post({ url: '/score/notice/create', data })
  },

  /** 更新 */
  update: async (data: ScoreNoticeSaveParams) => {
    return await request.put({ url: '/score/notice/update', data })
  },

  /** 发布 */
  publish: async (id: number) => {
    return await request.put({ url: `/score/notice/publish?id=${id}` })
  },

  /** 撤回 */
  withdraw: async (id: number) => {
    return await request.put({ url: `/score/notice/withdraw?id=${id}` })
  },

  /** 删除 */
  delete: async (id: number) => {
    return await request.delete({ url: `/score/notice/delete?id=${id}` })
  }
}

三、页面组件设计

3.1 考核规则配置页面subject/index.vue

<template>
  <div class="score-subject">
    <el-card>
      <template #header>
        <div class="card-header">
          <span>考核规则配置</span>
          <el-button type="primary" @click="handleAdd">新增规则</el-button>
        </div>
      </template>

      <!-- 搜索区域 -->
      <el-form :model="queryParams" ref="queryForm" :inline="true">
        <el-form-item label="规则编码" prop="code">
          <el-input v-model="queryParams.code" placeholder="请输入规则编码" clearable />
        </el-form-item>
        <el-form-item label="规则名称" prop="name">
          <el-input v-model="queryParams.name" placeholder="请输入规则名称" clearable />
        </el-form-item>
        <el-form-item label="考核类别" prop="category">
          <el-select v-model="queryParams.category" placeholder="请选择" clearable>
            <el-option v-for="item in CATEGORY_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-select v-model="queryParams.status" placeholder="请选择" clearable>
            <el-option label="启用" :value="1" />
            <el-option label="禁用" :value="2" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleQuery">搜索</el-button>
          <el-button @click="resetQuery">重置</el-button>
        </el-form-item>
      </el-form>

      <!-- 表格区域 -->
      <el-table v-loading="loading" :data="tableData" @selection-change="handleSelectionChange">
        <el-table-column type="selection" width="50" />
        <el-table-column prop="code" label="规则编码" width="120" />
        <el-table-column prop="name" label="规则名称" width="180" />
        <el-table-column prop="categoryName" label="考核类别" width="120" />
        <el-table-column prop="score" label="分值" width="100">
          <template #default="{ row }">
            <span :class="{ 'text-success': row.score > 0, 'text-danger': row.score < 0 }">
              {{ row.score > 0 ? '+' : '' }}{{ row.score }}
            </span>
          </template>
        </el-table-column>
        <el-table-column prop="dailyLimit" label="日限" width="80" />
        <el-table-column prop="monthlyLimit" label="月限" width="80" />
        <el-table-column prop="statusName" label="状态" width="80">
          <template #default="{ row }">
            <el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.statusName }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="sort" label="排序" width="80" />
        <el-table-column prop="createTime" label="创建时间" width="180" />
        <el-table-column label="操作" width="150" fixed="right">
          <template #default="{ row }">
            <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
            <el-button link type="primary" @click="handleToggleStatus(row)">
              {{ row.status === 1 ? '禁用' : '启用' }}
            </el-button>
            <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>

      <Pagination
        :total="total"
        v-model:page="queryParams.pageNum"
        v-model:limit="queryParams.pageSize"
        @pagination="getList"
      />
    </el-card>

    <!-- 新增/编辑弹窗 -->
    <SubjectForm ref="formRef" @success="getList" />
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ScoreSubjectApi, type ScoreSubject, type ScoreSubjectQuery } from '@/api/prison/score/subject'
import SubjectForm from './SubjectForm.vue'

const loading = ref(false)
const tableData = ref<ScoreSubject[]>([])
const total = ref(0)
const queryParams = reactive<ScoreSubjectQuery>({
  pageNum: 1,
  pageSize: 10,
  code: '',
  name: '',
  category: undefined,
  status: undefined
})
const selectedIds = ref<number[]>([])
const formRef = ref()

const getList = async () => {
  loading.value = true
  try {
    const data = await ScoreSubjectApi.getPage(queryParams)
    tableData.value = data.list
    total.value = data.total
  } finally {
    loading.value = false
  }
}

const handleQuery = () => {
  queryParams.pageNum = 1
  getList()
}

const resetQuery = () => {
  queryParams.code = ''
  queryParams.name = ''
  queryParams.category = undefined
  queryParams.status = undefined
  handleQuery()
}

const handleAdd = () => {
  formRef.value?.open()
}

const handleEdit = (row: ScoreSubject) => {
  formRef.value?.open(row.id)
}

const handleDelete = async (row: ScoreSubject) => {
  await ElMessageBox.confirm(`确定要删除规则"${row.name}"吗?`, '提示')
  await ScoreSubjectApi.delete(row.id)
  ElMessage.success('删除成功')
  getList()
}

const handleToggleStatus = async (row: ScoreSubject) => {
  const newStatus = row.status === 1 ? 2 : 1
  await ScoreSubjectApi.updateStatus(row.id, newStatus)
  ElMessage.success(newStatus === 1 ? '已启用' : '已禁用')
  getList()
}

const handleSelectionChange = (selection: ScoreSubject[]) => {
  selectedIds.value = selection.map(item => item.id)
}

onMounted(() => {
  getList()
})
</script>

3.2 考核规则表单组件subject/SubjectForm.vue

<template>
  <el-dialog v-model="visible" :title="isEdit ? '编辑规则' : '新增规则'" width="600px">
    <el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
      <el-form-item label="规则编码" prop="code">
        <el-input v-model="formData.code" :disabled="isEdit" placeholder="请输入规则编码" />
      </el-form-item>
      <el-form-item label="规则名称" prop="name">
        <el-input v-model="formData.name" placeholder="请输入规则名称" />
      </el-form-item>
      <el-form-item label="考核类别" prop="category">
        <el-select v-model="formData.category" placeholder="请选择考核类别">
          <el-option v-for="item in CATEGORY_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
        </el-select>
      </el-form-item>
      <el-form-item label="分值" prop="score">
        <el-input-number v-model="formData.score" :precision="2" :step="0.5" placeholder="正数为加分,负数为扣分" />
      </el-form-item>
      <el-form-item label="日最高分" prop="dailyLimit">
        <el-input-number v-model="formData.dailyLimit" :precision="2" :step="0.5" placeholder="请输入日最高分限制" />
      </el-form-item>
      <el-form-item label="月最高分" prop="monthlyLimit">
        <el-input-number v-model="formData.monthlyLimit" :precision="2" :step="0.5" placeholder="请输入月最高分限制" />
      </el-form-item>
      <el-form-item label="规则说明" prop="description">
        <el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入规则说明" />
      </el-form-item>
      <el-form-item label="排序" prop="sort">
        <el-input-number v-model="formData.sort" :min="0" :max="999" />
      </el-form-item>
      <el-form-item label="状态" prop="status">
        <el-radio-group v-model="formData.status">
          <el-radio :label="1">启用</el-radio>
          <el-radio :label="2">禁用</el-radio>
        </el-radio-group>
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { ScoreSubjectApi, CATEGORY_OPTIONS, type ScoreSubjectSaveParams } from '@/api/prison/score/subject'

const emit = defineEmits(['success'])

const visible = ref(false)
const submitting = ref(false)
const formRef = ref()
const currentId = ref<number | null>(null)

const isEdit = computed(() => !!currentId.value)

const formData = reactive<ScoreSubjectSaveParams>({
  code: '',
  name: '',
  category: undefined as unknown as number,
  score: 0,
  dailyLimit: 0,
  monthlyLimit: 0,
  description: '',
  sort: 0,
  status: 1
})

const rules = {
  code: [{ required: true, message: '请输入规则编码', trigger: 'blur' }],
  name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
  category: [{ required: true, message: '请选择考核类别', trigger: 'change' }],
  score: [{ required: true, message: '请输入分值', trigger: 'blur' }]
}

const open = async (id?: number) => {
  currentId.value = id ?? null
  visible.value = true
  if (id) {
    const data = await ScoreSubjectApi.get(id)
    Object.assign(formData, data)
  } else {
    Object.assign(formData, {
      code: '',
      name: '',
      category: undefined,
      score: 0,
      dailyLimit: 0,
      monthlyLimit: 0,
      description: '',
      sort: 0,
      status: 1
    })
  }
}

const handleSubmit = async () => {
  await formRef.value?.validate()
  submitting.value = true
  try {
    if (isEdit.value) {
      await ScoreSubjectApi.update(formData)
      ElMessage.success('更新成功')
    } else {
      await ScoreSubjectApi.create(formData)
      ElMessage.success('创建成功')
    }
    visible.value = false
    emit('success')
  } finally {
    submitting.value = false
  }
}

defineExpose({ open })
</script>

3.3 日常考核记录页面record/index.vue

<template>
  <div class="score-record">
    <el-card>
      <template #header>
        <div class="card-header">
          <span>日常考核记录</span>
          <div>
            <el-button type="primary" @click="handleAdd">新增记录</el-button>
            <el-button type="primary" @click="handleBatchAdd">批量录入</el-button>
          </div>
        </div>
      </template>

      <!-- 搜索区域 -->
      <el-form :model="queryParams" ref="queryForm" :inline="true">
        <el-form-item label="考核日期" prop="recordDateRange">
          <el-date-picker
            v-model="queryParams.recordDateRange"
            type="daterange"
            range-separator=""
            start-placeholder="开始日期"
            end-placeholder="结束日期"
            value-format="YYYY-MM-DD"
          />
        </el-form-item>
        <el-form-item label="罪犯编号" prop="prisonerNo">
          <el-input v-model="queryParams.prisonerNo" placeholder="请输入罪犯编号" clearable />
        </el-form-item>
        <el-form-item label="罪犯姓名" prop="prisonerName">
          <el-input v-model="queryParams.prisonerName" placeholder="请输入罪犯姓名" clearable />
        </el-form-item>
        <el-form-item label="考核类别" prop="category">
          <el-select v-model="queryParams.category" placeholder="请选择" clearable>
            <el-option v-for="item in CATEGORY_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleQuery">搜索</el-button>
          <el-button @click="resetQuery">重置</el-button>
          <el-button type="success" @click="handleExport">导出</el-button>
        </el-form-item>
      </el-form>

      <!-- 表格区域 -->
      <el-table v-loading="loading" :data="tableData" @selection-change="handleSelectionChange">
        <el-table-column type="selection" width="50" />
        <el-table-column prop="recordDate" label="考核日期" width="120" />
        <el-table-column prop="prisonerNo" label="罪犯编号" width="100" />
        <el-table-column prop="prisonerName" label="罪犯姓名" width="100" />
        <el-table-column prop="prisonAreaName" label="监区/分监区" width="150" />
        <el-table-column prop="categoryName" label="考核类别" width="120" />
        <el-table-column prop="subjectName" label="规则名称" width="180" />
        <el-table-column prop="score" label="得分" width="100">
          <template #default="{ row }">
            <span :class="{ 'text-success': row.score > 0, 'text-danger': row.score < 0 }">
              {{ row.score > 0 ? '+' : '' }}{{ row.score }}
            </span>
          </template>
        </el-table-column>
        <el-table-column prop="actualScore" label="实际得分" width="100" />
        <el-table-column prop="recorderName" label="记录人" width="100" />
        <el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
        <el-table-column label="操作" width="100" fixed="right">
          <template #default="{ row }">
            <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>

      <Pagination
        :total="total"
        v-model:page="queryParams.pageNum"
        v-model:limit="queryParams.pageSize"
        @pagination="getList"
      />
    </el-card>

    <!-- 新增/编辑弹窗 -->
    <RecordForm ref="formRef" @success="getList" />

    <!-- 批量录入弹窗 -->
    <BatchRecordForm ref="batchFormRef" @success="getList" />
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ScoreRecordApi, CATEGORY_OPTIONS, type ScoreRecord, type ScoreRecordQuery } from '@/api/prison/score/record'
import RecordForm from './RecordForm.vue'
import BatchRecordForm from './BatchRecordForm.vue'

const loading = ref(false)
const tableData = ref<ScoreRecord[]>([])
const total = ref(0)
const queryParams = reactive<ScoreRecordQuery>({
  pageNum: 1,
  pageSize: 10,
  recordDateRange: [],
  prisonerNo: '',
  prisonerName: '',
  category: undefined
})
const selectedIds = ref<number[]>([])
const formRef = ref()
const batchFormRef = ref()

const getList = async () => {
  loading.value = true
  try {
    const params = { ...queryParams }
    if (params.recordDateRange?.length === 2) {
      params.recordDate = params.recordDateRange[0]
    }
    delete params.recordDateRange
    const data = await ScoreRecordApi.getPage(params)
    tableData.value = data.list
    total.value = data.total
  } finally {
    loading.value = false
  }
}

const handleQuery = () => {
  queryParams.pageNum = 1
  getList()
}

const resetQuery = () => {
  Object.assign(queryParams, {
    pageNum: 1,
    recordDateRange: [],
    prisonerNo: '',
    prisonerName: '',
    category: undefined
  })
  handleQuery()
}

const handleAdd = () => {
  formRef.value?.open()
}

const handleBatchAdd = () => {
  batchFormRef.value?.open()
}

const handleDelete = async (row: ScoreRecord) => {
  await ElMessageBox.confirm(`确定要删除该考核记录吗?`, '提示')
  await ScoreRecordApi.delete(row.id)
  ElMessage.success('删除成功')
  getList()
}

const handleExport = () => {
  ScoreRecordApi.export(queryParams)
}

const handleSelectionChange = (selection: ScoreRecord[]) => {
  selectedIds.value = selection.map(item => item.id)
}

onMounted(() => {
  getList()
})
</script>

3.4 批量录入表单组件record/BatchRecordForm.vue

<template>
  <el-dialog v-model="visible" title="批量录入考核" width="700px">
    <el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
      <el-form-item label="考核日期" prop="recordDate">
        <el-date-picker
          v-model="formData.recordDate"
          type="date"
          placeholder="请选择考核日期"
          value-format="YYYY-MM-DD"
        />
      </el-form-item>
      <el-form-item label="选择罪犯" prop="prisonerIds">
        <el-select
          v-model="formData.prisonerIds"
          multiple
          filterable
          remote
          placeholder="请选择罪犯"
          :remote-method="searchPrisoners"
          :loading="prisonerLoading"
        >
          <el-option
            v-for="item in prisonerOptions"
            :key="item.id"
            :label="`${item.prisonerNo} - ${item.name}`"
            :value="item.id"
          />
        </el-select>
        <div class="form-tip">已选择 {{ formData.prisonerIds.length }} 名罪犯</div>
      </el-form-item>
      <el-form-item label="考核规则" prop="subjectId">
        <el-select v-model="formData.subjectId" placeholder="请选择考核规则" filterable>
          <el-option-group v-for="group in subjectOptions" :key="group.label" :label="group.label">
            <el-option
              v-for="item in group.options"
              :key="item.id"
              :label="item.name"
              :value="item.id"
            />
          </el-option-group>
        </el-select>
      </el-form-item>
      <el-form-item label="得分" prop="score">
        <el-input-number v-model="formData.score" :precision="2" :step="0.5" />
        <span class="unit"></span>
      </el-form-item>
      <el-form-item label="备注说明" prop="remark">
        <el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注说明" />
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" @click="handleSubmit" :loading="submitting" :disabled="formData.prisonerIds.length === 0">
        确定录入
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { ScoreRecordApi, type ScoreRecordBatchParams } from '@/api/prison/score/record'

const emit = defineEmits(['success'])

const visible = ref(false)
const submitting = ref(false)
const prisonerLoading = ref(false)
const formRef = ref()
const prisonerOptions = ref<any[]>([])
const subjectOptions = ref<any[]>([])

const formData = reactive<ScoreRecordBatchParams>({
  recordDate: '',
  prisonerIds: [],
  subjectId: undefined as unknown as number,
  score: 0,
  remark: ''
})

const rules = {
  recordDate: [{ required: true, message: '请选择考核日期', trigger: 'change' }],
  prisonerIds: [{ required: true, message: '请选择罪犯', trigger: 'change' }],
  subjectId: [{ required: true, message: '请选择考核规则', trigger: 'change' }],
  score: [{ required: true, message: '请输入得分', trigger: 'blur' }]
}

const open = async () => {
  visible.value = true
  // 加载考核规则列表
  const subjects = await ScoreSubjectApi.getList({ status: 1 })
  // 按类别分组
  subjectOptions.value = groupSubjectsByCategory(subjects)
  // 重置表单
  Object.assign(formData, {
    recordDate: new Date().toISOString().split('T')[0],
    prisonerIds: [],
    subjectId: undefined,
    score: 0,
    remark: ''
  })
}

const searchPrisoners = async (keyword: string) => {
  if (!keyword) return
  prisonerLoading.value = true
  try {
    // 调用罪犯搜索接口
    prisonerOptions.value = await prisonerApi.search(keyword)
  } finally {
    prisonerLoading.value = false
  }
}

const handleSubmit = async () => {
  await formRef.value?.validate()
  submitting.value = true
  try {
    await ScoreRecordApi.batchCreate(formData)
    ElMessage.success(`成功录入 ${formData.prisonerIds.length} 条考核记录`)
    visible.value = false
    emit('success')
  } finally {
    submitting.value = false
  }
}

const groupSubjectsByCategory = (subjects: any[]) => {
  const groups: Record<number, any[]> = {}
  subjects.forEach(item => {
    if (!groups[item.category]) {
      groups[item.category] = []
    }
    groups[item.category].push(item)
  })
  return Object.entries(groups).map(([key, value]) => ({
    label: getCategoryLabel(Number(key)),
    options: value
  }))
}

const getCategoryLabel = (category: number) => {
  const labels: Record<number, string> = {
    1: '劳动改造',
    2: '教育改造',
    3: '日常行为',
    4: '卫生纪律',
    5: '加分项',
    6: '扣分项'
  }
  return labels[category] || '其他'
}

defineExpose({ open })
</script>

3.5 月度考核汇总页面monthly/index.vue

<template>
  <div class="score-monthly">
    <el-card>
      <template #header>
        <div class="card-header">
          <span>月度考核汇总</span>
          <div>
            <el-button type="primary" @click="handleCalculate">重新计算</el-button>
            <el-button type="success" @click="handleExport">导出报表</el-button>
          </div>
        </div>
      </template>

      <!-- 搜索区域 -->
      <el-form :model="queryParams" ref="queryForm" :inline="true">
        <el-form-item label="考核年月" prop="yearMonth">
          <el-date-picker
            v-model="queryParams.yearMonth"
            type="month"
            placeholder="请选择考核年月"
            value-format="YYYY-MM"
          />
        </el-form-item>
        <el-form-item label="罪犯编号" prop="prisonerNo">
          <el-input v-model="queryParams.prisonerNo" placeholder="请输入罪犯编号" clearable />
        </el-form-item>
        <el-form-item label="罪犯姓名" prop="prisonerName">
          <el-input v-model="queryParams.prisonerName" placeholder="请输入罪犯姓名" clearable />
        </el-form-item>
        <el-form-item label="考核等级" prop="level">
          <el-select v-model="queryParams.level" placeholder="请选择" clearable>
            <el-option label="优秀" :value="1" />
            <el-option label="良好" :value="2" />
            <el-option label="合格" :value="3" />
            <el-option label="不合格" :value="4" />
          </el-select>
        </el-form-item>
        <el-form-item label="审核状态" prop="status">
          <el-select v-model="queryParams.status" placeholder="请选择" clearable>
            <el-option label="待审核" :value="1" />
            <el-option label="已通过" :value="2" />
            <el-option label="已驳回" :value="3" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleQuery">搜索</el-button>
          <el-button @click="resetQuery">重置</el-button>
        </el-form-item>
      </el-form>

      <!-- 统计卡片 -->
      <el-row :gutter="20" class="stat-cards">
        <el-col :span="6">
          <el-card shadow="hover" class="stat-card">
            <div class="stat-value">{{ summaryStats.excellent }}</div>
            <div class="stat-label">优秀</div>
          </el-card>
        </el-col>
        <el-col :span="6">
          <el-card shadow="hover" class="stat-card">
            <div class="stat-value">{{ summaryStats.good }}</div>
            <div class="stat-label">良好</div>
          </el-card>
        </el-col>
        <el-col :span="6">
          <el-card shadow="hover" class="stat-card">
            <div class="stat-value">{{ summaryStats.qualified }}</div>
            <div class="stat-label">合格</div>
          </el-card>
        </el-col>
        <el-col :span="6">
          <el-card shadow="hover" class="stat-card danger">
            <div class="stat-value">{{ summaryStats.unqualified }}</div>
            <div class="stat-label">不合格</div>
          </el-card>
        </el-col>
      </el-row>

      <!-- 表格区域 -->
      <el-table v-loading="loading" :data="tableData" @row-click="handleRowClick">
        <el-table-column prop="prisonerNo" label="罪犯编号" width="100" />
        <el-table-column prop="prisonerName" label="罪犯姓名" width="100" />
        <el-table-column prop="prisonAreaName" label="监区/分监区" width="150" />
        <el-table-column prop="baseScore" label="基础分" width="80" />
        <el-table-column prop="rewardScore" label="加分" width="80">
          <template #default="{ row }">
            <span class="text-success">+{{ row.rewardScore }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="penaltyScore" label="扣分" width="80">
          <template #default="{ row }">
            <span class="text-danger">{{ row.penaltyScore }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="totalScore" label="总分" width="100">
          <template #default="{ row }">
            <span class="total-score">{{ row.totalScore }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="levelName" label="等级" width="80">
          <template #default="{ row }">
            <el-tag :type="getLevelType(row.level)">{{ row.levelName }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="statusName" label="审核状态" width="100">
          <template #default="{ row }">
            <el-tag :type="getStatusType(row.status)">{{ row.statusName }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="assessorName" label="考核人" width="100" />
        <el-table-column label="操作" width="150" fixed="right" @click.stop>
          <template #default="{ row }">
            <el-button link type="primary" @click="handleViewDetail(row)">明细</el-button>
            <el-button v-if="row.status === 1" link type="success" @click="handleSubmitAudit(row)">提交审核</el-button>
            <el-button v-if="row.status === 3" link type="primary" @click="handleViewRemark(row)">查看驳回原因</el-button>
          </template>
        </el-table-column>
      </el-table>

      <Pagination
        :total="total"
        v-model:page="queryParams.pageNum"
        v-model:limit="queryParams.pageSize"
        @pagination="getList"
      />
    </el-card>

    <!-- 月度明细弹窗 -->
    <MonthlyDetail ref="detailRef" />
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ScoreMonthlyApi, type ScoreMonthly, type ScoreMonthlyQuery } from '@/api/prison/score/monthly'
import MonthlyDetail from './MonthlyDetail.vue'

const loading = ref(false)
const tableData = ref<ScoreMonthly[]>([])
const total = ref(0)
const queryParams = reactive<ScoreMonthlyQuery>({
  pageNum: 1,
  pageSize: 10,
  yearMonth: new Date().toISOString().slice(0, 7),
  prisonerNo: '',
  prisonerName: '',
  level: undefined,
  status: undefined
})
const summaryStats = reactive({
  excellent: 0,
  good: 0,
  qualified: 0,
  unqualified: 0
})
const detailRef = ref()

const getList = async () => {
  loading.value = true
  try {
    const params = { ...queryParams }
    if (params.yearMonth) {
      const [year, month] = params.yearMonth.split('-')
      params.year = parseInt(year)
      params.month = parseInt(month)
    }
    delete params.yearMonth
    const data = await ScoreMonthlyApi.getPage(params)
    tableData.value = data.list
    total.value = data.total
    // 计算统计数据
    calculateStats(data.list)
  } finally {
    loading.value = false
  }
}

const calculateStats = (list: ScoreMonthly[]) => {
  summaryStats.excellent = list.filter(item => item.level === 1).length
  summaryStats.good = list.filter(item => item.level === 2).length
  summaryStats.qualified = list.filter(item => item.level === 3).length
  summaryStats.unqualified = list.filter(item => item.level === 4).length
}

const getLevelType = (level: number) => {
  const types: Record<number, string> = {
    1: 'success',
    2: '',
    3: 'warning',
    4: 'danger'
  }
  return types[level] || ''
}

const getStatusType = (status: number) => {
  const types: Record<number, string> = {
    1: 'info',
    2: 'success',
    3: 'danger'
  }
  return types[status] || ''
}

const handleQuery = () => {
  queryParams.pageNum = 1
  getList()
}

const resetQuery = () => {
  Object.assign(queryParams, {
    pageNum: 1,
    yearMonth: new Date().toISOString().slice(0, 7),
    prisonerNo: '',
    prisonerName: '',
    level: undefined,
    status: undefined
  })
  handleQuery()
}

const handleCalculate = async () => {
  const [year, month] = queryParams.yearMonth!.split('-').map(Number)
  await ElMessageBox.confirm('确定要重新计算本月度考核汇总吗?这将覆盖现有数据。', '提示')
  await ScoreMonthlyApi.calculate(year, month)
  ElMessage.success('计算完成')
  getList()
}

const handleExport = () => {
  const params = { ...queryParams }
  if (params.yearMonth) {
    const [year, month] = params.yearMonth.split('-')
    params.year = parseInt(year)
    params.month = parseInt(month)
  }
  delete params.yearMonth
  ScoreMonthlyApi.export(params)
}

const handleRowClick = (row: ScoreMonthly) => {
  handleViewDetail(row)
}

const handleViewDetail = (row: ScoreMonthly) => {
  detailRef.value?.open(row.id)
}

const handleSubmitAudit = async (row: ScoreMonthly) => {
  await ElMessageBox.confirm(`确定要提交"${row.prisonerName}"的考核结果进行审核吗?`, '提示')
  await ScoreMonthlyApi.submitAudit(row.id)
  ElMessage.success('提交成功')
  getList()
}

const handleViewRemark = (row: ScoreMonthly) => {
  ElMessage.info(row.remark || '无驳回原因')
}

onMounted(() => {
  getList()
})
</script>

<style scoped lang="scss">
.stat-cards {
  margin-bottom: 20px;
}

.stat-card {
  text-align: center;

  .stat-value {
    font-size: 32px;
    font-weight: bold;
    color: #409eff;
  }

  .stat-label {
    color: #909399;
    margin-top: 8px;
  }

  &.danger .stat-value {
    color: #f56c6c;
  }
}

.text-success {
  color: #67c23a;
}

.text-danger {
  color: #f56c6c;
}

.total-score {
  font-size: 16px;
  font-weight: bold;
}
</style>

3.6 月度明细组件monthly/MonthlyDetail.vue

<template>
  <el-dialog v-model="visible" title="月度考核明细" width="900px">
    <div class="monthly-summary">
      <!-- 基本信息 -->
      <el-descriptions :column="3" border>
        <el-descriptions-item label="罪犯编号">{{ data.prisonerNo }}</el-descriptions-item>
        <el-descriptions-item label="罪犯姓名">{{ data.prisonerName }}</el-descriptions-item>
        <el-descriptions-item label="考核期间">{{ data.year }}{{ data.month }}</el-descriptions-item>
        <el-descriptions-item label="基础分">{{ data.baseScore }}</el-descriptions-item>
        <el-descriptions-item label="加分合计">
          <span class="text-success">+{{ data.rewardScore }}</span>
        </el-descriptions-item>
        <el-descriptions-item label="扣分合计">
          <span class="text-danger">{{ data.penaltyScore }}</span>
        </el-descriptions-item>
        <el-descriptions-item label="月度总分">
          <span class="total-score">{{ data.totalScore }}</span>
        </el-descriptions-item>
        <el-descriptions-item label="考核等级">
          <el-tag :type="getLevelType(data.level)">{{ data.levelName }}</el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="审核状态">
          <el-tag :type="getStatusType(data.status)">{{ data.statusName }}</el-tag>
        </el-descriptions-item>
      </el-descriptions>

      <!-- 考核明细表格 -->
      <div class="detail-section">
        <h4>考核明细</h4>
        <el-table :data="records" max-height="400">
          <el-table-column prop="recordDate" label="考核日期" width="120" />
          <el-table-column prop="categoryName" label="考核类别" width="100" />
          <el-table-column prop="subjectName" label="规则名称" width="180" />
          <el-table-column prop="score" label="原始得分" width="100">
            <template #default="{ row }">
              <span :class="{ 'text-success': row.score > 0, 'text-danger': row.score < 0 }">
                {{ row.score > 0 ? '+' : '' }}{{ row.score }}
              </span>
            </template>
          </el-table-column>
          <el-table-column prop="actualScore" label="实际得分" width="100" />
          <el-table-column prop="recorderName" label="记录人" width="100" />
          <el-table-column prop="remark" label="备注" min-width="150" />
        </el-table>
      </div>
    </div>
    <template #footer>
      <el-button @click="visible = false">关闭</el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ScoreMonthlyApi, type ScoreMonthly, type ScoreRecord } from '@/api/prison/score/monthly'

const visible = ref(false)
const data = ref<ScoreMonthly>({} as ScoreMonthly)
const records = ref<ScoreRecord[]>([])

const open = async (id: number) => {
  visible.value = true
  const detail = await ScoreMonthlyApi.getDetail(id)
  data.value = detail.monthly
  records.value = detail.records
}

const getLevelType = (level: number) => {
  const types: Record<number, string> = { 1: 'success', 2: '', 3: 'warning', 4: 'danger' }
  return types[level] || ''
}

const getStatusType = (status: number) => {
  const types: Record<number, string> = { 1: 'info', 2: 'success', 3: 'danger' }
  return types[status] || ''
}

defineExpose({ open })
</script>

四、路由配置

// router/routes/modules/prison.ts

{
  path: '/prison/score',
  component: Layout,
  redirect: '/prison/score/subject',
  name: 'PrisonScore',
  meta: {
    title: '计分考核',
    icon: 'score',
    alwaysShow: true
  },
  children: [
    {
      path: 'subject',
      component: () => import('@/views/prison/score/subject/index.vue'),
      name: 'PrisonScoreSubject',
      meta: {
        title: '考核规则配置',
        icon: 'list',
        permissions: ['prison:score:subject:query']
      }
    },
    {
      path: 'record',
      component: () => import('@/views/prison/score/record/index.vue'),
      name: 'PrisonScoreRecord',
      meta: {
        title: '日常考核记录',
        icon: 'edit',
        permissions: ['prison:score:record:query']
      }
    },
    {
      path: 'monthly',
      component: () => import('@/views/prison/score/monthly/index.vue'),
      name: 'PrisonScoreMonthly',
      meta: {
        title: '月度考核汇总',
        icon: 'date',
        permissions: ['prison:score:monthly:query']
      }
    },
    {
      path: 'level',
      component: () => import('@/views/prison/score/level/index.vue'),
      name: 'PrisonScoreLevel',
      meta: {
        title: '等级规则配置',
        icon: 'setting',
        permissions: ['prison:score:level:query']
      }
    },
    {
      path: 'notice',
      component: () => import('@/views/prison/score/notice/index.vue'),
      name: 'PrisonScoreNotice',
      meta: {
        title: '考核公示',
        icon: 'notification',
        permissions: ['prison:score:notice:query']
      }
    },
    {
      path: 'parole',
      component: () => import('@/views/prison/score/parole/index.vue'),
      name: 'PrisonScoreParole',
      meta: {
        title: '减刑假释数据',
        icon: 'document',
        permissions: ['prison:score:parole:query']
      }
    }
  ]
}

五、菜单权限SQL

-- 计分考核模块菜单权限

-- 1. 考核规则配置
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component) VALUES
('考核规则配置', '', 2, 1, (SELECT id FROM system_menu WHERE name = '计分考核'), 'subject', 'list', 'prison/score/subject/index');

-- 2. 日常考核记录
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component) VALUES
('日常考核记录', '', 2, 2, (SELECT id FROM system_menu WHERE name = '计分考核'), 'record', 'edit', 'prison/score/record/index');

-- 3. 月度考核汇总
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component) VALUES
('月度考核汇总', '', 2, 3, (SELECT id FROM system_menu WHERE name = '计分考核'), 'monthly', 'date', 'prison/score/monthly/index');

-- 4. 等级规则配置
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component) VALUES
('等级规则配置', '', 2, 4, (SELECT id FROM system_menu WHERE name = '计分考核'), 'level', 'setting', 'prison/score/level/index');

-- 5. 考核公示
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component) VALUES
('考核公示', '', 2, 5, (SELECT id FROM system_menu WHERE name = '计分考核'), 'notice', 'notification', 'prison/score/notice/index');

-- 6. 减刑假释数据
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component) VALUES
('减刑假释数据', '', 2, 6, (SELECT id FROM system_menu WHERE name = '计分考核'), 'parole', 'document', 'prison/score/parole/index');

六、验收标准

  • 考核规则配置页面:支持规则的增删改查、状态切换
  • 日常考核记录页面:支持单条录入和批量录入
  • 月度考核汇总页面:支持月度统计展示、明细查看
  • 等级规则配置页面:支持等级标准和基础分配置
  • 考核公示页面:支持公示的发布和撤回
  • 减刑假释数据页面:支持考核数据提取和展示
  • 所有页面通过TypeScript类型检查
  • 所有页面响应式适配