新增画像:实现学员360客观画像
This commit is contained in:
1 parent
d0ae543a4d
commit
207491213b
1 file changed
+384
-69
@@ -1,86 +1,401 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true">
|
||||
<el-form-item label="培训计划" prop="planName">
|
||||
<el-input v-model="queryParams.planName" placeholder="请输入计划名称" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="学员" prop="studentName">
|
||||
<el-input v-model="queryParams.studentName" placeholder="请输入学员姓名" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="画像标签" prop="labelTag">
|
||||
<el-input v-model="queryParams.labelTag" placeholder="请输入标签" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-hasPermi="['train:portrait:export']"
|
||||
type="warning"
|
||||
plain
|
||||
icon="el-icon-download"
|
||||
size="mini"
|
||||
@click="handleExport"
|
||||
>导出</el-button>
|
||||
</el-col>
|
||||
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
|
||||
</el-row>
|
||||
<el-alert title="画像由考勤、成绩和学习时长统计生成,本页面仅供查询。" type="info" :closable="false" show-icon class="mb8" />
|
||||
<el-table v-loading="loading" :data="rows">
|
||||
<el-table-column label="培训计划" prop="planName" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="学员" prop="studentName" min-width="100" />
|
||||
<el-table-column label="出勤率" align="right" width="90">
|
||||
<template slot-scope="scope">{{ percent(scope.row.attendanceRate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="理论成绩" prop="theoryScore" align="right" width="95" />
|
||||
<el-table-column label="实操成绩" prop="practicalScore" align="right" width="95" />
|
||||
<el-table-column label="累计学习学时" prop="totalStudyHour" align="right" width="120" />
|
||||
<el-table-column label="画像标签" prop="labelTag" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="综合评价" prop="portraitContent" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="生成时间" prop="generatedTime" width="170" />
|
||||
</el-table>
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
:page.sync="queryParams.pageNum"
|
||||
:limit.sync="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
<div class="app-container student-360-container">
|
||||
<!-- 1. 顶部学员检索栏 -->
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form :inline="true" size="small" @submit.native.prevent="handleSearch">
|
||||
<el-form-item label="查找学员">
|
||||
<el-autocomplete
|
||||
v-model="searchKeyword"
|
||||
:fetch-suggestions="queryStudentSuggestions"
|
||||
placeholder="请输入学员学号或姓名检索"
|
||||
style="width: 280px;"
|
||||
clearable
|
||||
@select="handleSelectStudent"
|
||||
>
|
||||
<template slot-scope="{ item }">
|
||||
<div class="suggest-item">
|
||||
<span class="name">{{ item.sName }}</span>
|
||||
<span class="id">({{ item.sId }})</span>
|
||||
<span class="org">{{ item.orgName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleSearch">加载画像</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 2. 画像核心展示区 (有选中学员时) -->
|
||||
<div v-if="profile && profile.studentId" v-loading="loadingProfile" style="margin-top: 16px;">
|
||||
<!-- A. 基础档案卡片 -->
|
||||
<el-card shadow="never" class="section-card">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-user" style="color: #409EFF; margin-right: 6px;"></i>学员基础主档档案 (Basic Profile)</span>
|
||||
<el-tag size="mini" :type="profile.userBindingStatus === '已绑定' ? 'success' : 'info'">
|
||||
账号:{{ profile.userBindingStatus }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-descriptions border :column="3" size="medium">
|
||||
<el-descriptions-item label="学员姓名">{{ profile.studentName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学员学号">{{ profile.studentId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ profile.genderName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属单位">{{ profile.orgName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ profile.phone || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="电子邮箱">{{ profile.email || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="身份证件 (脱敏)">
|
||||
<span style="font-family: monospace;">{{ profile.idCardMasked || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="出生日期">{{ profile.birth || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="档案状态">
|
||||
<el-tag size="mini" :type="profile.status === 1 ? 'success' : 'danger'">
|
||||
{{ profile.status === 1 ? '正常' : '已停用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- B. 考勤客观纪律画像 -->
|
||||
<el-card shadow="never" class="section-card mt16">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-time" style="color: #67C23A; margin-right: 6px;"></i>考勤纪律客观履历 (Attendance Discipline)</span>
|
||||
<el-tooltip :content="profile.metricDefinitionNotice" placement="top">
|
||||
<i class="el-icon-info" style="color: #E6A23C; cursor: pointer;" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="stat-cell">
|
||||
<div class="val">{{ profile.expectedAttendCount || 0 }}</div>
|
||||
<div class="label">应到总课次</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="stat-cell">
|
||||
<div class="val text-success">{{ profile.normalCount || 0 }}</div>
|
||||
<div class="label">正常出勤</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="3">
|
||||
<div class="stat-cell">
|
||||
<div class="val text-warning">{{ profile.lateCount || 0 }}</div>
|
||||
<div class="label">迟到</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="3">
|
||||
<div class="stat-cell">
|
||||
<div class="val text-warning">{{ profile.earlyCount || 0 }}</div>
|
||||
<div class="label">早退</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="3">
|
||||
<div class="stat-cell">
|
||||
<div class="val text-info">{{ profile.leaveCount || 0 }}</div>
|
||||
<div class="label">请假</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="3">
|
||||
<div class="stat-cell">
|
||||
<div class="val text-danger">{{ profile.absentCount || 0 }}</div>
|
||||
<div class="label">明确缺勤</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="4">
|
||||
<div class="stat-cell rate-cell">
|
||||
<div class="val text-primary">{{ profile.attendanceRate != null ? profile.attendanceRate : 0 }}%</div>
|
||||
<div class="label">个人综合出勤率</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- C. 培训履历时间轴 -->
|
||||
<el-card shadow="never" class="section-card mt16">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-tickets" style="color: #409EFF; margin-right: 6px;"></i>参训培训班履历 (Enrollment History)</span>
|
||||
</div>
|
||||
<el-table
|
||||
v-if="profile.enrollmentTimeline && profile.enrollmentTimeline.length > 0"
|
||||
:data="profile.enrollmentTimeline"
|
||||
border
|
||||
size="small"
|
||||
stripe
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-table-column prop="planId" label="培训班编号" width="130" align="center" />
|
||||
<el-table-column prop="planName" label="培训班名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="enrollmentStatusName" label="参训流转状态" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini">{{ scope.row.enrollmentStatusName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="joinTime" label="报名/入班时间" width="160" align="center" />
|
||||
<el-table-column prop="reportWayName" label="开班报到方式" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.reportWayName || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="verifyResultName" label="核验结果" width="110" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.verifyResultName" size="mini" type="success">{{ scope.row.verifyResultName }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reportTime" label="报到核验时间" width="160" align="center" />
|
||||
</el-table>
|
||||
<el-empty v-else description="暂无参训记录" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<!-- D. 学习、成绩与生活保障状态 (客观真实,无虚构评分) -->
|
||||
<el-row :gutter="16" class="mt16">
|
||||
<!-- 考核成绩状态 -->
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-card shadow="never" class="section-card status-card">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-medal" style="color: #E6A23C; margin-right: 6px;"></i>课程考核成绩</span>
|
||||
</div>
|
||||
<div class="card-body-box">
|
||||
<el-result icon="info" title="暂无成绩数据" sub-title="数据库V2架构成绩分项与学员成绩表尚未完成考务录入,不展示虚假0分。">
|
||||
<template slot="extra">
|
||||
<el-tag size="small" type="info">待考务数据入库</el-tag>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 自主学时状态 -->
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-card shadow="never" class="section-card status-card">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-reading" style="color: #67C23A; margin-right: 6px;"></i>自主学习学时</span>
|
||||
</div>
|
||||
<div class="card-body-box">
|
||||
<el-result icon="info" title="暂无自学记录" sub-title="外部 VR / 在线实训系统尚未同步学习流水,当前学时数据保持待同步状态。">
|
||||
<template slot="extra">
|
||||
<el-tag size="small" type="warning">待外部平台同步</el-tag>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 住宿与门禁状态 (只读) -->
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-card shadow="never" class="section-card status-card">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-office-building" style="color: #409EFF; margin-right: 6px;"></i>宿舍与门禁授权</span>
|
||||
</div>
|
||||
<div class="card-body-box">
|
||||
<div v-if="profile.hasAccommodation" class="dorm-details">
|
||||
<div class="dorm-item"><span class="k">宿舍房间:</span><span class="v font-bold">{{ profile.roomName }}</span></div>
|
||||
<div class="dorm-item"><span class="k">床位编号:</span><span class="v">{{ profile.bedNo || '-' }}</span></div>
|
||||
<div class="dorm-item">
|
||||
<span class="k">住宿状态:</span>
|
||||
<el-tag size="mini" :type="profile.accommodationStatus === 1 ? 'success' : 'info'">{{ profile.accommodationStatusName }}</el-tag>
|
||||
</div>
|
||||
<div class="dorm-item">
|
||||
<span class="k">门禁授权:</span>
|
||||
<el-tag size="mini" :type="profile.authStatus === 'ACTIVE' ? 'success' : 'warning'">{{ profile.authStatusName }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="当前未安排宿舍与门禁授权" :image-size="60" />
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 3. 未选中学员时的空状态提示 -->
|
||||
<el-card v-else shadow="never" class="mt16 empty-card">
|
||||
<el-empty description="请在上方输入学员姓名或学号,检索并加载学员 360 全景客观画像" :image-size="100" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listPortrait } from '@/api/train/portrait'
|
||||
import { getStudent360 } from '@/api/train/analysis'
|
||||
import { listStudent } from '@/api/train/student'
|
||||
|
||||
export default {
|
||||
name: 'Portrait',
|
||||
name: 'StudentPortrait',
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
showSearch: true,
|
||||
total: 0,
|
||||
rows: [],
|
||||
queryParams: { pageNum: 1, pageSize: 10, planName: null, studentName: null, labelTag: null }
|
||||
searchKeyword: '',
|
||||
selectedStudentId: '',
|
||||
loadingProfile: false,
|
||||
profile: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 允许从 URL 参数(如考勤分析跳转携带 ?studentId=xxx)自动加载
|
||||
const sid = this.$route.query.studentId
|
||||
if (sid) {
|
||||
this.searchKeyword = sid
|
||||
this.selectedStudentId = sid
|
||||
this.loadStudentProfile(sid)
|
||||
}
|
||||
},
|
||||
created() { this.getList() },
|
||||
methods: {
|
||||
getList() {
|
||||
this.loading = true
|
||||
listPortrait(this.queryParams).then(response => {
|
||||
this.rows = response.rows
|
||||
this.total = response.total
|
||||
}).finally(() => { this.loading = false })
|
||||
queryStudentSuggestions(queryString, cb) {
|
||||
if (!queryString || queryString.trim().length === 0) {
|
||||
cb([])
|
||||
return
|
||||
}
|
||||
const query = { pageNum: 1, pageSize: 20 }
|
||||
// 简单判断学号还是姓名
|
||||
if (/^[a-zA-Z0-9]+$/.test(queryString)) {
|
||||
query.sId = queryString.trim()
|
||||
} else {
|
||||
query.sName = queryString.trim()
|
||||
}
|
||||
listStudent(query).then(res => {
|
||||
const rows = res.rows || []
|
||||
const results = rows.map(r => ({
|
||||
value: `${r.sName} (${r.sId})`,
|
||||
sId: r.sId,
|
||||
sName: r.sName,
|
||||
orgName: r.orgName || ''
|
||||
}))
|
||||
cb(results)
|
||||
}).catch(() => {
|
||||
cb([])
|
||||
})
|
||||
},
|
||||
percent(value) { return value == null ? '-' : `${value}%` },
|
||||
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
|
||||
resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
|
||||
handleExport() {
|
||||
this.download('train/portrait/export', { ...this.queryParams }, `portrait_${new Date().getTime()}.xlsx`)
|
||||
handleSelectStudent(item) {
|
||||
if (item && item.sId) {
|
||||
this.selectedStudentId = item.sId
|
||||
this.searchKeyword = item.sId
|
||||
this.loadStudentProfile(item.sId)
|
||||
}
|
||||
},
|
||||
handleSearch() {
|
||||
if (!this.searchKeyword || !this.searchKeyword.trim()) {
|
||||
this.$message.warning('请输入学员学号或姓名')
|
||||
return
|
||||
}
|
||||
const kw = this.searchKeyword.trim()
|
||||
// 如果格式是 "张三 (S2026001)",提取括号内的学号
|
||||
const match = kw.match(/\(([^)]+)\)/)
|
||||
const targetId = match ? match[1] : kw
|
||||
this.loadStudentProfile(targetId)
|
||||
},
|
||||
handleReset() {
|
||||
this.searchKeyword = ''
|
||||
this.selectedStudentId = ''
|
||||
this.profile = null
|
||||
},
|
||||
loadStudentProfile(sId) {
|
||||
this.loadingProfile = true
|
||||
getStudent360(sId).then(res => {
|
||||
if (res.code === 200) {
|
||||
this.profile = res.data || null
|
||||
}
|
||||
}).catch(err => {
|
||||
this.profile = null
|
||||
this.$message.error(err.msg || '获取学员画像失败')
|
||||
}).finally(() => {
|
||||
this.loadingProfile = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.student-360-container {
|
||||
background-color: #f0f2f5;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
.suggest-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
.name { font-weight: bold; color: #303133; margin-right: 6px; }
|
||||
.id { color: #409EFF; margin-right: 6px; }
|
||||
.org { color: #909399; font-size: 11px; }
|
||||
}
|
||||
}
|
||||
|
||||
.section-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.mt16 { margin-top: 16px; }
|
||||
|
||||
.stat-cell {
|
||||
background: #fafafa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.val {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.rate-cell {
|
||||
background: #ecf5ff;
|
||||
border-color: #d9ecff;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
min-height: 240px;
|
||||
.card-body-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.dorm-details {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
.dorm-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #ebeef5;
|
||||
font-size: 13px;
|
||||
&:last-child { border-bottom: none; }
|
||||
.k { color: #606266; }
|
||||
.v { color: #303133; }
|
||||
}
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.text-primary { color: #409EFF !important; }
|
||||
.text-success { color: #67C23A !important; }
|
||||
.text-danger { color: #F56C6C !important; }
|
||||
.text-warning { color: #E6A23C !important; }
|
||||
.text-info { color: #909399 !important; }
|
||||
.font-bold { font-weight: bold; }
|
||||
</style>
|
||||
Reference in new issue
Block a user