xinli/xinli-ui/src/views/psychology/assessment/analysis.vue
xiao12feng8 ff8cd67fdb refactor: 移除所有RuoYi相关痕迹,隐藏项目来源
- 重命名文件: ruoyi.js  common.js, ruoyi.scss  common.scss
- 重命名组件: RuoYi/  Common/
- 创建新类: XinliConfig.java (替代RuoYiConfig.java)
- 更新所有导入语句和引用 (50+ 处)
- 更新配置前缀: ruoyi  xinli
- 更新Swagger文档标题和描述
- 更新许可证版权信息
- 移除所有RuoYi文档链接和示例代码
2026-01-30 17:31:21 +08:00

792 lines
27 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="assessment-analysis">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="全局概览" name="overview">
<div class="tab-pane-body" v-loading="loading">
<el-card class="filter-card" shadow="never">
<el-form :inline="true" size="small">
<el-form-item label="测评量表">
<el-select v-model="queryParams.scaleId" clearable filterable placeholder="全部量表" style="width: 220px">
<el-option
v-for="item in scaleOptions"
:key="item.scaleId"
:label="item.scaleName"
:value="item.scaleId"
/>
</el-select>
</el-form-item>
<el-form-item label="测评时间">
<el-date-picker
v-model="queryParams.dateRange"
type="daterange"
unlink-panels
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
range-separator="至"
style="width: 320px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-row :gutter="16" class="overview-row">
<el-col v-for="card in overviewCards" :key="card.key" :xs="12" :sm="6" :md="4">
<el-card class="stat-card" shadow="hover">
<div class="stat-title">{{ card.label }}</div>
<div class="stat-value">{{ formatNumber(card.value) }}</div>
<div class="stat-desc">{{ card.desc }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="chart-row">
<el-col :md="24" :sm="24">
<el-card shadow="always" class="chart-card">
<div slot="header" class="chart-card__title">测评状态分布</div>
<div ref="statusPie" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="chart-row">
<el-col :md="24" :sm="24">
<el-card shadow="always" class="chart-card">
<div slot="header" class="chart-card__title">近期待完成趋势</div>
<div ref="trendChart" class="chart-container trend-chart"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="detail-row">
<el-col :md="12" :sm="24">
<el-card shadow="never">
<div slot="header" class="detail-title">量表参与 TOP10</div>
<el-table
:data="analytics.scaleDistribution"
size="small"
height="300"
:empty-text="emptyText"
border
>
<el-table-column type="index" label="#" width="60" />
<el-table-column prop="name" label="量表" show-overflow-tooltip />
<el-table-column prop="value" label="参与次数" width="120" align="right" />
</el-table>
</el-card>
</el-col>
<el-col :md="12" :sm="24">
<el-card shadow="never">
<div slot="header" class="detail-title">得分区间分布</div>
<el-table
:data="analytics.scoreRangeDistribution"
size="small"
height="300"
:empty-text="emptyText"
border
>
<el-table-column prop="name" label="区间" />
<el-table-column prop="value" label="人数" width="120" align="right" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</el-tab-pane>
<el-tab-pane label="学员视角" name="student">
<div class="tab-pane-body" v-loading="studentLoading">
<el-card class="filter-card" shadow="never">
<el-form :inline="true" size="small">
<el-form-item label="选择学员">
<el-select
v-model="selectedStudentId"
style="width: 280px"
clearable
filterable
remote
reserve-keyword
:remote-method="loadStudentOptions"
:loading="studentOptionsLoading"
placeholder="输入姓名或账号搜索"
@change="handleStudentChange"
>
<el-option
v-for="item in studentOptions"
:key="item.userId"
:label="buildStudentLabel(item)"
:value="item.userId"
>
<div class="student-option">
<span class="name">{{ item.nickName || item.userName }}</span>
<span class="dept">{{ item.deptName || '未分配单位' }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" :disabled="!selectedStudentId" @click="fetchUserSummary">
载入
</el-button>
<el-button icon="el-icon-refresh" @click="resetStudentSelection">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<template v-if="studentSummary">
<el-card shadow="never" class="student-info-card">
<el-descriptions size="small" :column="3" border>
<el-descriptions-item label="罪犯姓名">{{ studentSummary.nickName || studentSummary.userName || '-' }}</el-descriptions-item>
<el-descriptions-item label="信息编号">{{ studentSummary.infoNumber || '-' }}</el-descriptions-item>
<el-descriptions-item label="监狱">{{ studentSummary.prisonName || '-' }}</el-descriptions-item>
<el-descriptions-item label="监区">{{ studentSummary.prisonAreaName || '-' }}</el-descriptions-item>
<el-descriptions-item label="民族">{{ studentSummary.nation || '-' }}</el-descriptions-item>
<el-descriptions-item label="文化程度">{{ studentSummary.educationLevel || '-' }}</el-descriptions-item>
<el-descriptions-item label="罪名">{{ studentSummary.crimeName || '-' }}</el-descriptions-item>
<el-descriptions-item label="刑期">{{ studentSummary.sentenceTerm || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ studentSummary.custodyStatus || '-' }}</el-descriptions-item>
<el-descriptions-item label="刑期起日">{{ studentSummary.sentenceStartDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="刑期止日">{{ studentSummary.sentenceEndDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="入监时间">{{ studentSummary.entryDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="量表数量">{{ (studentSummary.scales || []).length }}</el-descriptions-item>
<el-descriptions-item label="测评次数">{{ studentSummary.totalAssessments || 0 }}</el-descriptions-item>
<el-descriptions-item label="原账号">{{ studentSummary.loginAccount || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-collapse v-model="openedScalePanels" accordion class="scale-collapse">
<el-collapse-item
v-for="scale in studentScales"
:key="scale.scaleId"
:name="scale.scaleId"
:title="scale.scaleName + '' + scale.attempts.length + '次)'"
>
<div class="scale-attempts">
<el-radio-group v-model="scale.selectedAssessmentId" size="mini" class="attempt-radio-group">
<el-radio-button
v-for="attempt in scale.attempts"
:key="attempt.assessmentId"
:label="attempt.assessmentId"
>
{{ attempt.submitTime ? formatDateTime(attempt.submitTime) : '未提交' }}
</el-radio-button>
</el-radio-group>
<div class="attempt-detail" v-if="getSelectedAttempt(scale)">
<div class="attempt-detail__row">
<div class="attempt-field">
<span>提交时间</span>
<strong>{{ formatDateTime(getSelectedAttempt(scale).submitTime) }}</strong>
</div>
<div class="attempt-field">
<span>总分</span>
<strong>{{ getSelectedAttempt(scale).totalScore != null ? getSelectedAttempt(scale).totalScore : '-' }}</strong>
</div>
<div class="attempt-field">
<span>状态</span>
<el-tag size="small" :type="statusTagType(getSelectedAttempt(scale).status)">
{{ statusLabel(getSelectedAttempt(scale).status) }}
</el-tag>
</div>
</div>
<div class="attempt-summary">
<div class="summary-title">报告摘要</div>
<div class="summary-content">{{ getSelectedAttempt(scale).reportSummary || '暂无报告摘要' }}</div>
</div>
<el-button
type="primary"
size="mini"
:disabled="!getSelectedAttempt(scale).reportId"
@click="navigateReport(getSelectedAttempt(scale))"
>
查看报告
</el-button>
</div>
</div>
</el-collapse-item>
</el-collapse>
</template>
<el-empty v-else description="请选择学员查看测评详情"></el-empty>
</div>
</el-tab-pane>
<el-tab-pane label="量表 / 单位" name="scaleDept">
<div class="tab-pane-body" v-loading="scaleStatsLoading">
<el-card class="filter-card" shadow="never">
<el-form :inline="true" size="small">
<el-form-item label="量表">
<el-select v-model="scaleDeptForm.scaleId" filterable placeholder="请选择量表" style="width: 220px">
<el-option
v-for="item in scaleOptions"
:key="item.scaleId"
:label="item.scaleName"
:value="item.scaleId"
/>
</el-select>
</el-form-item>
<el-form-item label="监区">
<Treeselect
v-model="scaleDeptForm.deptIds"
:options="deptOptions"
:multiple="true"
:clearable="true"
placeholder="可多选监区单位"
style="width: 260px"
value-consists-of="LEAF_PRIORITY"
></Treeselect>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-data-analysis" @click="handleScaleDeptQuery">统计</el-button>
<el-button icon="el-icon-refresh" @click="resetScaleDeptForm">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<template v-if="scaleStats && (scaleStats.totalTargets || scaleStats.measuredCount)">
<el-row :gutter="16" class="overview-row">
<el-col :md="6" :sm="12">
<el-card class="stat-card">
<div class="stat-title">目标人数</div>
<div class="stat-value">{{ formatNumber(scaleStats.totalTargets || 0) }}</div>
<div class="stat-desc">选择单位内的全部受测人</div>
</el-card>
</el-col>
<el-col :md="6" :sm="12">
<el-card class="stat-card">
<div class="stat-title">已测评</div>
<div class="stat-value">{{ formatNumber(scaleStats.measuredCount || 0) }}</div>
<div class="stat-desc">至少完成一次该量表</div>
</el-card>
</el-col>
<el-col :md="6" :sm="12">
<el-card class="stat-card">
<div class="stat-title">未测评</div>
<div class="stat-value">{{ formatNumber(scaleStats.unmeasuredCount || 0) }}</div>
<div class="stat-desc">尚未参与或未提交</div>
</el-card>
</el-col>
<el-col :md="6" :sm="12">
<el-card class="stat-card">
<div class="stat-title">完成率</div>
<div class="stat-value">{{ formatPercent(scaleStats.completionRate) }}</div>
<div class="stat-desc">已测评 / 目标人数</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="chart-row">
<el-col :md="12" :sm="24">
<el-card shadow="always" class="chart-card">
<div slot="header" class="chart-card__title">完成情况</div>
<div ref="scaleCompletionPie" class="chart-container"></div>
</el-card>
</el-col>
<el-col :md="12" :sm="24">
<el-card shadow="always" class="chart-card">
<div slot="header" class="chart-card__title">报告结果分布</div>
<div ref="scaleResultPie" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
</template>
<el-empty v-else description="请选择量表与单位后统计"></el-empty>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
import * as echarts from 'echarts'
require('echarts/theme/macarons')
import Treeselect from '@riophae/vue-treeselect'
import '@riophae/vue-treeselect/dist/vue-treeselect.css'
import { parseTime } from '@/utils/common'
import {
getAssessmentAnalytics,
getStudentOptions,
getUserAssessmentSummary,
getScaleDeptStats
} from '@/api/psychology/assessment'
import { listScale } from '@/api/psychology/scale'
import { getCustodyTreeOptions, findCustodyLabel } from '@/utils/custodyArea'
export default {
name: 'AssessmentAnalysis',
components: { Treeselect },
data() {
return {
activeTab: 'overview',
loading: false,
studentLoading: false,
scaleStatsLoading: false,
queryParams: {
scaleId: undefined,
dateRange: []
},
scaleOptions: [],
deptOptions: JSON.parse(JSON.stringify(getCustodyTreeOptions())),
analytics: this.createEmptyAnalytics(),
charts: {},
resizeHandler: null,
emptyText: '暂无数据',
studentOptions: [],
studentOptionsLoading: false,
selectedStudentId: undefined,
studentSummary: null,
studentScales: [],
openedScalePanels: [],
scaleDeptForm: {
scaleId: undefined,
deptIds: []
},
scaleStats: this.createEmptyScaleStats()
}
},
computed: {
overviewCards() {
const overview = this.analytics.overview || {}
return [
{ key: 'total', label: '测评总数', value: overview.totalAssessments || 0, desc: '所有已发起测评' },
{ key: 'completed', label: '已完成', value: overview.completedAssessments || 0, desc: '提交并生成结果' },
{ key: 'inProgress', label: '进行中', value: overview.inProgressAssessments || 0, desc: '仍在答题' },
{ key: 'paused', label: '已暂停', value: overview.pausedAssessments || 0, desc: '等待恢复' },
{ key: 'invalid', label: '已作废', value: overview.invalidAssessments || 0, desc: '无效测评' },
{ key: 'reports', label: '报告数量', value: overview.generatedReports || 0, desc: '已生成报告' },
{ key: 'participants', label: '覆盖人数', value: overview.uniqueParticipants || 0, desc: '参测人员数' }
]
}
},
watch: {
activeTab(val) {
if (val === 'overview') {
this.$nextTick(() => this.renderCharts())
}
if (val === 'scaleDept') {
this.$nextTick(() => this.renderScaleDeptCharts())
}
}
},
created() {
this.fetchScaleOptions()
this.loadStudentOptions('')
},
mounted() {
this.resizeHandler = () => {
Object.values(this.charts).forEach((chart) => {
if (chart) {
chart.resize()
}
})
}
window.addEventListener('resize', this.resizeHandler)
this.loadAnalytics()
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeHandler)
this.disposeCharts()
},
methods: {
createEmptyAnalytics() {
return {
overview: {},
statusDistribution: [],
scaleDistribution: [],
scoreRangeDistribution: [],
monthlyTrend: []
}
},
createEmptyScaleStats() {
return {
totalTargets: 0,
measuredCount: 0,
unmeasuredCount: 0,
completionRate: 0,
resultDistribution: []
}
},
formatNumber(value) {
if (!value) {
return '0'
}
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
},
formatPercent(value) {
if (!value) {
return '0%'
}
return `${parseFloat(value).toFixed(2)}%`
},
formatDateTime(value) {
return value ? parseTime(value) : '-'
},
buildQueryParams() {
const params = {}
if (this.queryParams.scaleId) {
params.scaleId = this.queryParams.scaleId
}
if (this.queryParams.dateRange && this.queryParams.dateRange.length === 2) {
params.startDate = this.queryParams.dateRange[0]
params.endDate = this.queryParams.dateRange[1]
}
return params
},
handleQuery() {
this.loadAnalytics()
},
resetQuery() {
this.queryParams.scaleId = undefined
this.queryParams.dateRange = []
this.handleQuery()
},
fetchScaleOptions() {
listScale({ pageNum: 1, pageSize: 999 }).then((res) => {
this.scaleOptions = res.rows || []
})
},
loadAnalytics() {
this.loading = true
const params = this.buildQueryParams()
getAssessmentAnalytics(params)
.then((res) => {
const data = res.data || this.createEmptyAnalytics()
this.analytics = Object.assign(this.createEmptyAnalytics(), data)
this.$nextTick(() => {
this.renderCharts()
})
})
.finally(() => {
this.loading = false
})
},
loadStudentOptions(query) {
this.studentOptionsLoading = true
getStudentOptions({ keyword: query, limit: 20 })
.then((res) => {
this.studentOptions = res.data || []
})
.finally(() => {
this.studentOptionsLoading = false
})
},
handleStudentChange() {
if (!this.selectedStudentId) {
this.studentSummary = null
this.studentScales = []
this.openedScalePanels = []
}
},
resetStudentSelection() {
this.selectedStudentId = undefined
this.studentSummary = null
this.studentScales = []
this.openedScalePanels = []
},
fetchUserSummary() {
if (!this.selectedStudentId) {
return
}
this.studentLoading = true
getUserAssessmentSummary(this.selectedStudentId)
.then((res) => {
this.studentSummary = res.data || null
const scales = (this.studentSummary && this.studentSummary.scales) ? this.studentSummary.scales : []
this.studentScales = scales.map((scale) => ({
...scale,
selectedAssessmentId:
scale.latestAssessmentId || (scale.attempts && scale.attempts.length ? scale.attempts[0].assessmentId : undefined)
}))
this.openedScalePanels = this.studentScales.map((item) => item.scaleId)
})
.finally(() => {
this.studentLoading = false
})
},
statusLabel(status) {
const map = {
0: '进行中',
1: '已完成',
2: '已作废',
3: '已暂停'
}
return map[status] || '未知'
},
statusTagType(status) {
const map = {
1: 'success',
0: 'warning',
3: 'info',
2: 'danger'
}
return map[status] || 'info'
},
getSelectedAttempt(scale) {
if (!scale || !scale.attempts) {
return null
}
return scale.attempts.find((item) => item.assessmentId === scale.selectedAssessmentId) || scale.attempts[0]
},
navigateReport(attempt) {
if (!attempt || !attempt.reportId) {
this.$modal.msgWarning('当前测评尚未生成报告')
return
}
this.$router.push({ path: '/psychology/assessment/report', query: { assessmentId: attempt.assessmentId } })
},
buildStudentLabel(option) {
if (!option) {
return ''
}
const name = option.nickName || option.userName || ''
const dept = option.deptName ? ` - ${option.deptName}` : ''
return name + dept
},
handleScaleDeptQuery() {
if (!this.scaleDeptForm.scaleId) {
this.$message.warning('请选择量表')
return
}
if (!this.scaleDeptForm.deptIds || this.scaleDeptForm.deptIds.length === 0) {
this.$message.warning('请至少选择一个监区')
return
}
this.scaleStatsLoading = true
getScaleDeptStats(this.scaleDeptForm)
.then((res) => {
this.scaleStats = Object.assign(this.createEmptyScaleStats(), res.data || {})
this.$nextTick(() => {
this.renderScaleDeptCharts()
})
})
.finally(() => {
this.scaleStatsLoading = false
})
},
resetScaleDeptForm() {
this.scaleDeptForm.scaleId = undefined
this.scaleDeptForm.deptIds = []
this.scaleStats = this.createEmptyScaleStats()
this.$nextTick(() => this.renderScaleDeptCharts())
},
renderCharts() {
this.renderPie('statusPie', this.analytics.statusDistribution, '测评状态')
this.renderTrendChart(this.analytics.monthlyTrend, 'trendChart')
},
renderScaleDeptCharts() {
const completionData = [
{ name: '已测评', value: this.scaleStats.measuredCount || 0 },
{ name: '未测评', value: this.scaleStats.unmeasuredCount || 0 }
]
this.renderPie('scaleCompletionPie', completionData, '完成情况')
this.renderPie(
'scaleResultPie',
this.scaleStats.resultDistribution && this.scaleStats.resultDistribution.length
? this.scaleStats.resultDistribution
: [{ name: '暂无数据', value: 0 }],
'报告结果'
)
},
ensureChartInstance(refName) {
const dom = this.$refs[refName]
if (!dom) {
return null
}
if (this.charts[refName]) {
return this.charts[refName]
}
this.charts[refName] = echarts.init(dom, 'macarons')
return this.charts[refName]
},
renderPie(refName, sourceData, title) {
const chart = this.ensureChartInstance(refName)
if (!chart) {
return
}
const data = sourceData && sourceData.length > 0 ? sourceData : [{ name: '暂无数据', value: 0 }]
chart.setOption({
tooltip: {
trigger: 'item',
formatter: '{b} : {c} ({d}%)'
},
legend: {
bottom: 0,
left: 'center',
data: data.map((item) => item.name)
},
series: [
{
name: title,
type: 'pie',
radius: ['35%', '70%'],
center: ['50%', '45%'],
roseType: 'radius',
data: data,
label: {
formatter: '{b}\n{d}%'
}
}
]
})
},
renderTrendChart(sourceData, refName) {
const chart = this.ensureChartInstance(refName)
if (!chart) {
return
}
const list = sourceData && sourceData.length > 0 ? sourceData : [{ label: '暂无数据', value: 0 }]
chart.setOption({
tooltip: {
trigger: 'axis'
},
grid: {
left: '3%',
right: '4%',
bottom: '5%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: list.map((item) => item.label)
},
yAxis: {
type: 'value'
},
series: [
{
name: '测评次数',
type: 'line',
smooth: true,
data: list.map((item) => item.value),
areaStyle: {
opacity: 0.15
},
lineStyle: {
width: 3
},
itemStyle: {
color: '#5B8FF9'
}
}
]
})
},
disposeCharts() {
Object.keys(this.charts).forEach((key) => {
if (this.charts[key]) {
this.charts[key].dispose()
this.charts[key] = null
}
})
}
}
}
</script>
<style lang="scss" scoped>
.assessment-analysis {
.tab-pane-body {
padding: 12px;
}
.filter-card {
margin-bottom: 16px;
}
.overview-row {
margin-bottom: 16px;
}
.chart-row {
margin-bottom: 16px;
}
.stat-card {
text-align: left;
margin-bottom: 16px;
.stat-title {
font-size: 14px;
color: #909399;
}
.stat-value {
font-size: 26px;
font-weight: 600;
margin: 8px 0;
color: #303133;
}
.stat-desc {
font-size: 12px;
color: #c0c4cc;
}
}
.chart-card {
height: 380px;
.chart-container {
width: 100%;
height: 320px;
}
.trend-chart {
height: 320px;
}
}
.detail-title {
font-weight: 600;
}
.student-info-card {
margin-bottom: 16px;
}
.scale-collapse {
background: #fff;
}
.scale-attempts {
.attempt-radio-group {
margin-bottom: 12px;
}
}
.attempt-detail {
border: 1px dashed #ebeef5;
padding: 12px;
border-radius: 4px;
background: #fafafa;
.attempt-detail__row {
display: flex;
flex-wrap: wrap;
margin-bottom: 12px;
.attempt-field {
width: 220px;
margin-right: 24px;
span {
color: #909399;
display: block;
}
strong {
font-size: 16px;
}
}
}
.attempt-summary {
margin-bottom: 12px;
.summary-title {
font-weight: 600;
margin-bottom: 6px;
}
.summary-content {
color: #606266;
}
}
}
.student-option {
display: flex;
justify-content: space-between;
.name {
font-weight: 500;
}
.dept {
color: #909399;
font-size: 12px;
}
}
}
</style>