tangweijie bbf4c64391 feat(prison): 新增评估报告等前端页面,优化问卷与罪犯管理
核心变更:
1. 新增页面模块
   - 快捷评语管理 (quick-comment)
   - 报告模板管理 (report-template)
   - 评估报告编辑 (report)
   - 风险分析页面 (risk)
   - 预警管理 (warning)
   - 服刑情况跟踪 (situation)

2. 功能优化
   - 罪犯管理: 新增Workbench工作台页面
   - 问卷模块: 完善QuestionForm组件
   - 计分考核: 优化ScoreForm支持多种评分方式
   - 危险评估: 完善RiskAssessmentForm

3. UI改进
   - 登录页面: 新增监狱特色Loading动画
   - 罪犯详情: 优化展示效果
   - 消费记录: 增强查询功能

4. 基础设施
   - 新增JusticeIcon图标组件
   - 优化字典格式化工具
   - 更新路由配置

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 20:15:17 +08:00

900 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<div class="report-edit-container">
<!-- 左侧罪犯列表 -->
<div class="prisoner-sidebar">
<div class="sidebar-header">
<h3>服刑人员列表</h3>
<el-input
v-model="searchKeyword"
placeholder="搜索姓名/编号"
prefix-icon="Search"
clearable
@input="handleSearch"
@clear="loadPrisonerList"
class="search-input"
/>
</div>
<div class="prisoner-list" v-loading="prisonerLoading">
<div
v-for="prisoner in prisonerList"
:key="prisoner.id"
:class="['prisoner-card', { active: currentPrisoner?.id === prisoner.id }]"
@click="selectPrisoner(prisoner)"
>
<div class="card-header">
<span class="area-name">{{ prisoner.areaName }}</span>
<el-tag
v-if="prisoner.riskLevel"
:type="getRiskLevelType(prisoner.riskLevel)"
size="small"
>
{{ getRiskLevelLabel(prisoner.riskLevel) }}
</el-tag>
</div>
<div class="card-body">
<span class="prisoner-name">{{ prisoner.name }}</span>
<span class="prisoner-no">{{ prisoner.prisonerNo }}</span>
</div>
</div>
<el-empty v-if="!prisonerLoading && prisonerList.length === 0" description="暂无数据" />
</div>
<div class="sidebar-footer">
<el-pagination
v-model:current-page="prisonerPage"
v-model:page-size="prisonerPageSize"
:total="prisonerTotal"
:page-sizes="[10, 20, 50]"
small
layout="prev, pager, next, total"
@current-change="loadPrisonerList"
/>
</div>
</div>
<!-- 右侧报告编辑区 -->
<div class="report-editor">
<!-- 顶部操作栏 -->
<div class="editor-toolbar">
<div class="toolbar-left">
<el-button @click="goBack">
<Icon icon="ep:arrow-left" class="mr-5px" /> 返回
</el-button>
<span class="toolbar-divider">|</span>
<el-button
type="primary"
@click="handleAiGenerateAll"
:loading="aiGenerating"
:disabled="!currentReport"
>
<Icon icon="ep:magic-stick" class="mr-5px" /> AI生成全部
</el-button>
<el-button
@click="handleSaveDraft"
:loading="saving"
:disabled="!currentReport"
>
<Icon icon="ep:document-checked" class="mr-5px" /> 保存草稿
<span v-if="lastSaveTime" class="save-time"> ({{ lastSaveTime }})</span>
</el-button>
</div>
<div class="toolbar-center">
<span class="report-title">{{ currentReport?.title || '未选择报告' }}</span>
</div>
<div class="toolbar-right">
<el-button
@click="handleShowQuickComment"
:disabled="!currentReport"
>
<Icon icon="ep:tickets" class="mr-5px" /> 快捷评语
</el-button>
<el-button
@click="handleShowVersionHistory"
:disabled="!currentReport"
>
<Icon icon="ep:clock" class="mr-5px" /> 历史版本
</el-button>
<el-button
type="success"
@click="handleSubmitReview"
:disabled="!canSubmit"
v-hasPermi="['prison:report:submit']"
>
<Icon icon="ep:position" class="mr-5px" /> 提交审核
</el-button>
<el-button
@click="handlePreview"
:disabled="!currentReport"
>
<Icon icon="ep:view" class="mr-5px" /> 预览
</el-button>
<el-button
type="primary"
@click="handleExport('pdf')"
:disabled="!currentReport"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
</div>
</div>
<!-- 基本信息区 -->
<div class="basic-info" v-if="currentReport">
<el-descriptions :column="4" border size="small">
<el-descriptions-item label="服刑人员">
{{ currentReport.prisonerName }} ({{ currentReport.prisonerNo }})
</el-descriptions-item>
<el-descriptions-item label="监区">
{{ currentReport.areaName || '-' }}
</el-descriptions-item>
<el-descriptions-item label="评估日期">
{{ currentReport.reportDate }}
</el-descriptions-item>
<el-descriptions-item label="模板">
{{ currentReport.templateName }}
</el-descriptions-item>
<el-descriptions-item label="风险等级">
<el-tag
v-if="currentReport.riskLevel"
:type="getRiskLevelType(currentReport.riskLevel)"
>
{{ getRiskLevelLabel(currentReport.riskLevel) }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="状态">
<dict-tag :type="DICT_TYPE.PRISON_REPORT_STATUS" :value="currentReport.status" />
</el-descriptions-item>
<el-descriptions-item label="版本">
v{{ currentReport.version }}
</el-descriptions-item>
<el-descriptions-item label="数据源状态">
<el-tag v-if="dataSourceStatus.complete" type="success" size="small">
已加载 {{ dataSourceStatus.loaded }}/{{ dataSourceStatus.total }}
</el-tag>
<el-tag v-else type="warning" size="small" loading>
加载中...
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 审核退回提示 -->
<div v-if="currentReport?.status === 4 && currentReport.reviewComment" class="reject-notice">
<el-alert
type="warning"
:closable="false"
show-icon
>
<template #title>
审核退回原因{{ currentReport.reviewComment }}
<el-button type="primary" link size="small" @click="scrollToContent">
立即修改
</el-button>
</template>
</el-alert>
</div>
<!-- 编辑内容区 -->
<div class="editor-content" v-if="currentReport" ref="contentRef">
<!-- 维度分析区 -->
<div class="dimension-editor">
<el-collapse v-model="activeDimensions">
<el-collapse-item
v-for="(dimension, index) in currentReport.dimensions"
:key="dimension.dimensionId"
:name="dimension.dimensionId"
:title="dimension.dimensionName"
>
<template #title>
<div class="dimension-title-bar">
<span class="dimension-name">{{ index + 1 }}. {{ dimension.dimensionName }}</span>
<div class="dimension-tags">
<el-tag
v-if="dimension.isAiGenerated"
type="success"
size="small"
>
AI生成
</el-tag>
<el-tag
v-else-if="dimension.lastModifyTime"
type="info"
size="small"
>
已修改
</el-tag>
<el-tag
v-if="dimension.aiGenerateTime"
type="info"
size="small"
>
{{ dimension.aiGenerateTime }}
</el-tag>
</div>
</div>
</template>
<div class="dimension-content">
<el-input
v-model="dimension.content"
type="textarea"
:rows="6"
placeholder="请输入评估内容"
@change="markDimensionModified(dimension)"
/>
<div class="dimension-actions">
<el-button
v-if="dimension.enableAi"
type="primary"
size="small"
@click="handleAiGenerate(dimension)"
:loading="aiGenerating && generatingDimensionId === dimension.dimensionId"
>
<Icon icon="ep:magic-stick" class="mr-5px" />
{{ dimension.isAiGenerated ? '重新生成' : 'AI生成' }}
</el-button>
<el-button
v-if="dimension.lastModifyTime && dimension.isAiGenerated"
size="small"
@click="handleRestoreAiContent(dimension)"
>
恢复AI内容
</el-button>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<!-- 综合结论与建议 -->
<div class="conclusion-section">
<h3 class="section-title">综合结论与建议</h3>
<el-form label-width="100px" size="default">
<el-form-item label="风险等级">
<el-select v-model="currentReport.riskLevel" placeholder="请选择风险等级">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.PRISON_RISK_LEVEL)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="综合结论">
<el-input
v-model="currentReport.conclusion"
type="textarea"
:rows="4"
placeholder="请输入综合结论"
/>
</el-form-item>
<el-form-item label="改造建议">
<el-input
v-model="currentReport.suggestions"
type="textarea"
:rows="4"
placeholder="请输入改造建议"
/>
</el-form-item>
</el-form>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<el-empty description="请从左侧选择服刑人员开始编辑报告">
<el-button type="primary" @click="openCreateDialog">创建报告</el-button>
</el-empty>
</div>
</div>
<!-- 快捷评语弹窗 -->
<QuickCommentDialog
ref="quickCommentDialogRef"
:category-type="1"
@insert="insertComment"
/>
<!-- 历史版本弹窗 -->
<VersionHistoryDialog
ref="versionDialogRef"
:report-id="currentReport?.id"
@restore="handleRestoreVersion"
/>
<!-- 报告预览弹窗 -->
<ReportPreviewDialog ref="previewDialogRef" />
</div>
</template>
<script setup lang="ts">
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { useRoute, useRouter } from 'vue-router'
import { ReportApi, Report, ReportDimensionContent } from '@/api/prison/report'
import { PrisonerSelectApi, PrisonerBrief } from '@/api/prison/report'
import QuickCommentDialog from '@/views/prison/report/components/QuickCommentDialog.vue'
import VersionHistoryDialog from '@/views/prison/report/components/VersionHistoryDialog.vue'
import ReportPreviewDialog from '@/views/prison/report/components/ReportPreviewDialog.vue'
/** 评估报告编辑页面 */
defineOptions({ name: 'PrisonReportEdit' })
const route = useRoute()
const router = useRouter()
const message = useMessage()
// 左侧罪犯列表
const prisonerLoading = ref(false)
const prisonerList = ref<PrisonerBrief[]>([])
const prisonerPage = ref(1)
const prisonerPageSize = ref(20)
const prisonerTotal = ref(0)
const searchKeyword = ref('')
// 右侧编辑区
const currentPrisoner = ref<PrisonerBrief | null>(null)
const currentReport = ref<Report | null>(null)
const activeDimensions = ref<number[]>([])
const lastSaveTime = ref<string | null>(null)
// 状态
const saving = ref(false)
const aiGenerating = ref(false)
const generatingDimensionId = ref<number | null>(null)
const dataSourceStatus = ref({ total: 6, loaded: 0, complete: false })
// 组件引用
const contentRef = ref()
const quickCommentDialogRef = ref()
const versionDialogRef = ref()
const previewDialogRef = ref()
/** 加载罪犯列表 */
const loadPrisonerList = async () => {
prisonerLoading.value = true
try {
const data = await PrisonerSelectApi.getPrisonerPage({
pageNo: prisonerPage.value,
pageSize: prisonerPageSize.value,
name: searchKeyword.value,
prisonerNo: searchKeyword.value
})
prisonerList.value = data.list
prisonerTotal.value = data.total
} finally {
prisonerLoading.value = false
}
}
/** 搜索罪犯 */
const handleSearch = () => {
prisonerPage.value = 1
if (searchKeyword.value) {
// 防抖搜索
setTimeout(() => loadPrisonerList(), 300)
} else {
loadPrisonerList()
}
}
/** 选择罪犯 */
const selectPrisoner = async (prisoner: PrisonerBrief) => {
currentPrisoner.value = prisoner
// 检查是否已有报告
await loadReportForPrisoner(prisoner.id)
}
/** 为罪犯加载/创建报告 */
const loadReportForPrisoner = async (prisonerId: number) => {
try {
// 这里应该调用API获取该罪犯的最新报告
// 暂时使用模拟数据
// const data = await ReportApi.getLatestReportByPrisoner(prisonerId)
// currentReport.value = data
// 模拟初始化报告数据
currentReport.value = {
id: Date.now(),
reportNo: `BG${new Date().toISOString().slice(0, 10).replace(/-/g, '')}${String(Math.floor(Math.random() * 10000)).padStart(4, '0')}`,
prisonerId: prisonerId,
prisonerNo: currentPrisoner.value?.prisonerNo || '',
prisonerName: currentPrisoner.value?.name || '',
templateId: 1,
templateName: '入监综合评估报告',
title: `${currentPrisoner.value?.name}服刑期间综合评估报告`,
reportDate: new Date().toISOString().slice(0, 10),
dimensions: [
{
dimensionId: 1,
dimensionName: '基本信息',
content: '',
isAiGenerated: false,
enableAi: false,
sort: 1
},
{
dimensionId: 2,
dimensionName: '犯罪情况分析',
content: '',
isAiGenerated: true,
enableAi: true,
sort: 2
},
{
dimensionId: 3,
dimensionName: '服刑表现评估',
content: '',
isAiGenerated: true,
enableAi: true,
sort: 3
},
{
dimensionId: 4,
dimensionName: '消费行为分析',
content: '',
isAiGenerated: true,
enableAi: true,
sort: 4
},
{
dimensionId: 5,
dimensionName: '综合评估结论',
content: '',
isAiGenerated: true,
enableAi: true,
sort: 5
}
],
status: 1,
version: 1
}
// 默认展开所有维度
activeDimensions.value = currentReport.value.dimensions.map(d => d.dimensionId)
updateDataSourceStatus()
} catch {}
}
/** 更新数据源状态 */
const updateDataSourceStatus = () => {
// 模拟数据源加载完成
dataSourceStatus.value = { total: 6, loaded: 6, complete: true }
}
/** 获取风险等级类型 */
const getRiskLevelType = (level?: number): string => {
const map: Record<number, string> = {
1: 'success',
2: 'warning',
3: 'danger',
4: 'danger'
}
return level ? map[level] || 'info' : 'info'
}
/** 获取风险等级标签 */
const getRiskLevelLabel = (level?: number): string => {
const map: Record<number, string> = {
1: '低风险',
2: '中风险',
3: '高风险',
4: '极高风险'
}
return level ? map[level] || '-' : '未评估'
}
/** 标记维度已修改 */
const markDimensionModified = (dimension: ReportDimensionContent) => {
dimension.isAiGenerated = false
dimension.lastModifyTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
}
/** AI生成单个维度 */
const handleAiGenerate = async (dimension: ReportDimensionContent) => {
if (!currentReport.value) return
generatingDimensionId.value = dimension.dimensionId
aiGenerating.value = true
try {
// 调用AI生成接口
await ReportApi.generateReportByAi(currentReport.value.id, [dimension.dimensionId])
// 重新获取报告数据
const data = await ReportApi.getReport(currentReport.value.id)
currentReport.value = data
message.success(t('prison.report.aiGenerateComplete'))
} catch {
message.error(t('prison.report.aiGenerateFailed'))
} finally {
aiGenerating.value = false
generatingDimensionId.value = null
}
}
/** AI生成全部 */
const handleAiGenerateAll = async () => {
if (!currentReport.value) return
aiGenerating.value = true
try {
// 调用批量AI生成接口
await ReportApi.generateReportByAi(currentReport.value.id)
// 重新获取报告数据
const data = await ReportApi.getReport(currentReport.value.id)
currentReport.value = data
message.success(t('prison.report.aiGenerateAllComplete'))
} catch {
message.error(t('prison.report.aiGenerateFailed'))
} finally {
aiGenerating.value = false
generatingDimensionId.value = null
}
}
/** 保存草稿 */
const handleSaveDraft = async () => {
if (!currentReport.value) return
saving.value = true
try {
// 调用保存接口
await ReportApi.updateReport(currentReport.value)
lastSaveTime.value = new Date().toLocaleTimeString()
message.success(t('common.saveSuccess'))
} catch {
message.error(t('common.saveFailed'))
} finally {
saving.value = false
}
}
/** 提交审核 */
const handleSubmitReview = async () => {
if (!currentReport.value) return
try {
await message.confirm(t('prison.report.submitConfirm'))
await ReportApi.submitReport(currentReport.value.id)
message.success(t('prison.report.submitSuccess'))
// 刷新报告状态
currentReport.value.status = 2
} catch {}
}
/** 恢复AI原始内容 */
const handleRestoreAiContent = async (dimension: ReportDimensionContent) => {
try {
await message.confirm(t('prison.report.restoreAiConfirm'))
// 调用接口获取AI原始内容
await ReportApi.generateReportByAi(currentReport.value!.id, [dimension.dimensionId])
// 重新获取报告数据
const data = await ReportApi.getReport(currentReport.value!.id)
currentReport.value = data
message.success(t('prison.report.restoreAiSuccess'))
} catch {}
}
/** 预览报告 */
const handlePreview = () => {
if (currentReport.value) {
previewDialogRef.value?.open(currentReport.value.id)
}
}
/** 导出报告 */
const handleExport = async (format: 'pdf' | 'word') => {
if (!currentReport.value) return
try {
const data = await ReportApi.exportReport(currentReport.value.id, format)
const fileName = `${currentReport.value.reportNo}_${currentReport.value.prisonerName}.${format}`
message.success('导出成功')
} catch {}
}
/** 显示快捷评语 */
const handleShowQuickComment = () => {
quickCommentDialogRef.value?.open()
}
/** 插入快捷评语 */
const insertComment = (comment: string) => {
message.success(t('prison.report.commentInserted'))
}
/** 显示历史版本 */
const handleShowVersionHistory = () => {
versionDialogRef.value?.open()
}
/** 恢复版本 */
const handleRestoreVersion = async (versionId: number) => {
try {
await ReportVersionApi.restoreVersion(versionId)
message.success(t('prison.report.versionRestored'))
// 刷新报告内容
loadReportForPrisoner(currentPrisoner.value!.id)
} catch {}
}
/** 跳转到内容区 */
const scrollToContent = () => {
contentRef.value?.scrollIntoView({ behavior: 'smooth' })
}
/** 返回列表 */
const goBack = () => {
router.push('/prison/report')
}
/** 打开创建报告弹窗 */
const openCreateDialog = () => {
// 导航到创建页面
router.push('/prison/report?action=create')
}
/** 是否可以提交 */
const canSubmit = computed(() => {
if (!currentReport.value) return false
if (currentReport.value.status !== 1 && currentReport.value.status !== 4) return false
// 检查是否所有必填项都已填写
return currentReport.value.dimensions.every(d => d.content)
})
// 初始化
onMounted(() => {
loadPrisonerList()
// 如果有报告ID参数加载报告
const reportId = route.query.id
if (reportId) {
// loadReport(Number(reportId))
}
})
// 键盘快捷键
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
/** 处理键盘快捷键 */
const handleKeydown = (e: KeyboardEvent) => {
// Ctrl + S 保存
if (e.ctrlKey && e.key === 's') {
e.preventDefault()
handleSaveDraft()
}
}
</script>
<style lang="scss" scoped>
.report-edit-container {
display: flex;
height: calc(100vh - 84px);
background: #f5f7fa;
}
/* 左侧罪犯列表 */
.prisoner-sidebar {
width: 280px;
background: #fff;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
.sidebar-header {
padding: 15px;
border-bottom: 1px solid #e4e7ed;
h3 {
margin: 0 0 10px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.search-input {
width: 100%;
}
}
.prisoner-list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.prisoner-card {
padding: 12px;
border: 1px solid #e4e7ed;
border-radius: 6px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #409eff;
background: #ecf5ff;
}
&.active {
border-color: #409eff;
background: #ecf5ff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.area-name {
font-size: 12px;
color: #909399;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 4px;
.prisoner-name {
font-size: 15px;
font-weight: 500;
color: #303133;
}
.prisoner-no {
font-size: 12px;
color: #909399;
}
}
}
.sidebar-footer {
padding: 10px;
border-top: 1px solid #e4e7ed;
display: flex;
justify-content: center;
}
}
/* 右侧报告编辑区 */
.report-editor {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.editor-toolbar {
height: 50px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-divider {
margin: 0 10px;
color: #e4e7ed;
}
.toolbar-center {
.report-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.save-time {
font-size: 12px;
color: #909399;
}
}
.basic-info {
padding: 15px 20px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
}
.reject-notice {
padding: 10px 20px;
background: #fdf6ec;
}
.editor-content {
flex: 1;
overflow-y: auto;
padding: 20px;
.dimension-editor {
background: #fff;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
.dimension-title-bar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.dimension-name {
font-weight: 600;
color: #303133;
}
.dimension-tags {
display: flex;
gap: 8px;
}
}
.dimension-content {
.dimension-actions {
margin-top: 10px;
display: flex;
gap: 10px;
}
}
}
.conclusion-section {
background: #fff;
border-radius: 4px;
padding: 20px;
.section-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0 0 20px 0;
padding-bottom: 10px;
border-bottom: 1px solid #e4e7ed;
}
}
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
}
/* 动画 */
.prisoner-card {
transition: all 0.2s ease;
}
</style>