feat(growth): 实现学生画像功能并下线推荐内容
- 替换学生画像页面占位符,新增简历式头部展示 - 集成雷达图展示能力维度评分 - 增加学分类别达成和成长轨迹时间线展示 - 管理端支持学生检索与切换查看画像 - 接口调用区分纯学生角色权限限制 - 删除推荐内容相关前端页面及菜单配置 - 更新文档说明学生画像功能与推荐内容下线情况 - 细节优化标题本地化判断逻辑及状态类型声明
This commit is contained in:
@@ -15,9 +15,11 @@
|
||||
| 7 | 青马培养档案 | 已完成 | `/api/gxmu/qmgc-student` | `/gxmu/qmgcStudent`、`/gxmu/qmgcTask`、`/gxmu/qmgcScore` |
|
||||
| 8 | 主题团课库 | 已完成 | `/api/gxmu/thematic-course` | `/gxmu/thematicCourse` |
|
||||
|
||||
P1/P2 的 10 个功能已建菜单与占位页(写明实施依赖),不会出现菜单 404:
|
||||
P1/P2 的功能已建菜单与占位页(写明实施依赖),不会出现菜单 404:
|
||||
|
||||
社会实践学分、学分预警、学生画像、推荐内容、社团年审、社团评优、AI 辅助教学设计、AI 主题团日设计、专家评审、专家库。
|
||||
社会实践学分、学分预警、学生画像、社团年审、社团评优、AI 辅助教学设计、AI 主题团日设计、专家评审、专家库。
|
||||
|
||||
> 推荐内容已按业务反馈下线:前端页面删除,`sys_menu` 中的 14506 与其角色授权由 08 脚本清理。
|
||||
|
||||
## 二、数据库部署
|
||||
|
||||
@@ -44,7 +46,7 @@ mysql ... < sql/20261105_phase2_09_dict.sql
|
||||
| 05 | 主题团课库(1 张表) | 无 |
|
||||
| 06 | 站内消息 + 消息模板(2 张表) | 10 条消息模板 |
|
||||
| 07 | 审核流并行节点改造(ALTER 4 处) | 回填存量 parallel_group |
|
||||
| 08 | 菜单 + 按钮权限 + 角色授权(62 条菜单/按钮) | 超管全量、学生部分 |
|
||||
| 08 | 菜单 + 按钮权限 + 角色授权(61 条菜单/按钮) | 超管全量、学生部分 |
|
||||
| 09 | 字典(学分类别、社团类别) | 11 条字典项 |
|
||||
|
||||
**注意**:脚本 07 会修改 `gxmu_review_flow` / `gxmu_review_list` / `gxmu_review_flow_config` 三张生产表,只是新增列并回填,不影响存量串行审核逻辑。
|
||||
@@ -90,7 +92,7 @@ gxmu:
|
||||
src/api/growth/{credit,volunteer,home,club,qmgc,thematic}/
|
||||
src/api/system/notice/
|
||||
src/components/MultiFileUpload/ 统一多附件上传(JSON 数组与后端契约)
|
||||
src/views/growth/{myHome,credit,practiceCredit,creditWarning,portrait,recommend}/
|
||||
src/views/growth/{myHome,credit,practiceCredit,creditWarning,portrait}/
|
||||
src/views/club/{volunteerHour,volunteerReview,volunteerCertificate,club,activity,activityReview,venue,venueReview,annualReview,award}/
|
||||
src/views/gxmu/{qmgcStudent,qmgcTask,qmgcScore,thematicCourse,teachingDesign,themeDayDesign,expertReview,expertPool}/
|
||||
src/views/system/{messageTemplate,messageLog}/
|
||||
@@ -253,6 +255,31 @@ src/views/system/{messageTemplate,messageLog}/
|
||||
初版曾把 `gxmu_practice_credit` 加进 `DeclareGuardServiceImpl` 的模块列表,导致审核回写时按「模块名=表名」去更新不存在的表。已移除——社会实践用独立表 `gxmu_practice_credit_apply`,状态回写由自己的 service 负责。
|
||||
|
||||
|
||||
### 9.5 学生画像
|
||||
|
||||
**后端**(`com.gxwebsoft.gxmu.growth`)
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `StudentPortraitController` | `/api/gxmu/portrait/my`、`/api/gxmu/portrait?userId=`、`/api/gxmu/portrait/student/search` |
|
||||
| `StudentPortraitService` + `Impl` | 六维评分、标签、一句话画像、成长轨迹聚合 |
|
||||
| `StudentProfile` + Mapper | 每次打开画像写一份快照到 `gxmu_student_profile` |
|
||||
|
||||
**六个维度**:思想引领(政治面貌/团内职务/青马/档案完善度)、学业表现(学分完成率)、
|
||||
志愿服务(40 小时折算满分)、社会实践(2 学分折算满分)、组织参与(在社数量与骨干经历)、
|
||||
文体拓展(文体/学术科技/技能三类学分达成率取最高)。评分都在服务端算好,前端只负责展示。
|
||||
|
||||
**权限收口**:菜单仍按原规划只授予管理端(学生角色的 14500–14504 子集不含画像),
|
||||
接口层保留 `/my` 供后续学生端接入;纯学生角色调用时只能取本人数据,检索接口直接拒绝。
|
||||
画像列表不叠加组织范围:学生名册本身对所有管理端角色可见,画像与之一致,且不含审核数据。
|
||||
|
||||
**前端**:`/growth/portrait`,替换原占位页。简历式头部 + 雷达图 + 学分类别达成 + 成长轨迹时间线,
|
||||
管理端顶部可检索学生切换查看。
|
||||
|
||||
**推荐内容下线**:前端目录 `src/views/growth/recommend` 删除;`sys_menu` 14506 与角色授权由
|
||||
`sql/20261105_phase2_08_menu_permission.sql` 与新增的 `sql/20261107_portrait_menu.sql` 清理。
|
||||
|
||||
|
||||
## 十、部署排查:部署后看不到新功能
|
||||
|
||||
### 症状
|
||||
@@ -307,7 +334,7 @@ done
|
||||
### 执行后的验证
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM sys_menu WHERE menu_id BETWEEN 14500 AND 14663; -- 应为 62
|
||||
SELECT COUNT(*) FROM sys_menu WHERE menu_id BETWEEN 14500 AND 14663; -- 应为 61(推荐内容 14506 已下线)
|
||||
SELECT COUNT(*) FROM oa_notice_template; -- 应为 10
|
||||
SELECT COUNT(*) FROM gxmu_graduate_requirement; -- 应为 6
|
||||
```
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
├ 学分明细 /growth/credit 14502
|
||||
├ 社会实践学分 /growth/practiceCredit 14503
|
||||
├ 学分预警 /growth/creditWarning 14504
|
||||
├ 学生画像 /growth/portrait 14505
|
||||
└ 推荐内容 /growth/recommend 14506
|
||||
└ 学生画像 /growth/portrait 14505
|
||||
|
||||
社团与志愿 /club menu_id 14520
|
||||
├ 志愿服务时长 /club/volunteerHour 14521
|
||||
@@ -132,7 +131,7 @@ com.gxwebsoft.notice 消息中心
|
||||
新增目录:
|
||||
|
||||
```
|
||||
src/views/growth/{myHome,credit,practiceCredit,creditWarning,portrait,recommend}
|
||||
src/views/growth/{myHome,credit,practiceCredit,creditWarning,portrait}
|
||||
src/views/club/{volunteerHour,volunteerReview,volunteerCertificate,club,activity,activityReview,venue,venueReview,annualReview,award}
|
||||
src/views/gxmu/{qmgcStudent,qmgcTask,qmgcScore,thematicCourse,teachingDesign,themeDayDesign,expertReview,expertPool}
|
||||
src/views/system/{messageTemplate,messageLog}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import request from '@/utils/request';
|
||||
import type { ApiResult } from '@/api';
|
||||
import { MODULES_API_URL } from '@/config/setting';
|
||||
import type { GrowthHome } from '@/api/growth/home';
|
||||
|
||||
/** 画像维度 */
|
||||
export interface PortraitDimension {
|
||||
key: string;
|
||||
name: string;
|
||||
score: number;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
/** 成长轨迹节点 */
|
||||
export interface PortraitTimelineItem {
|
||||
type: string;
|
||||
typeName: string;
|
||||
title: string;
|
||||
time: string;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
/** 学生画像 */
|
||||
export interface StudentPortrait {
|
||||
basic?: GrowthHome['basic'];
|
||||
dimensions?: PortraitDimension[];
|
||||
tags?: string[];
|
||||
summary?: string;
|
||||
credit?: GrowthHome['credit'];
|
||||
volunteer?: Record<string, any>;
|
||||
activity?: Record<string, any>;
|
||||
practice?: Record<string, any>;
|
||||
qmgc?: Record<string, any> | null;
|
||||
timeline?: PortraitTimelineItem[];
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
/** 画像可选学生 */
|
||||
export interface PortraitStudentOption {
|
||||
userId: number;
|
||||
name?: string;
|
||||
studentNo?: string;
|
||||
collegeName?: string;
|
||||
className?: string;
|
||||
major?: string;
|
||||
grade?: string;
|
||||
}
|
||||
|
||||
/** 我的画像 */
|
||||
export async function getMyPortrait() {
|
||||
const res = await request.get<ApiResult<StudentPortrait>>(
|
||||
MODULES_API_URL + '/gxmu/portrait/my'
|
||||
);
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/** 指定学生的画像 */
|
||||
export async function getStudentPortrait(userId: number) {
|
||||
const res = await request.get<ApiResult<StudentPortrait>>(
|
||||
MODULES_API_URL + '/gxmu/portrait',
|
||||
{ params: { userId } }
|
||||
);
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/** 检索可查看画像的学生 */
|
||||
export async function searchPortraitStudents(keyword?: string) {
|
||||
const res = await request.get<ApiResult<PortraitStudentOption[]>>(
|
||||
MODULES_API_URL + '/gxmu/portrait/student/search',
|
||||
{ params: { keyword } }
|
||||
);
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
@@ -112,7 +112,7 @@
|
||||
import type { TabCtxMenuOption } from 'ele-admin-pro/es/ele-pro-layout/types';
|
||||
|
||||
const { push } = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { t, te, locale } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
@@ -255,9 +255,8 @@
|
||||
return;
|
||||
}
|
||||
const k = 'route.' + key + '._name';
|
||||
const title = t(k);
|
||||
if (title !== k) {
|
||||
return title;
|
||||
if (te(k)) {
|
||||
return t(k);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -147,5 +147,8 @@ export {
|
||||
BorderOutlined,
|
||||
PieChartOutlined,
|
||||
AreaChartOutlined,
|
||||
KeyOutlined
|
||||
KeyOutlined,
|
||||
RadarChartOutlined,
|
||||
RiseOutlined,
|
||||
SolutionOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
@@ -30,7 +30,7 @@ export function setDocumentTitle(title: string) {
|
||||
*/
|
||||
export function useSetDocumentTitle() {
|
||||
const { currentRoute } = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { t, te, locale } = useI18n();
|
||||
const themeStore = useThemeStore();
|
||||
const { tabs } = storeToRefs(themeStore);
|
||||
|
||||
@@ -50,8 +50,7 @@ export function useSetDocumentTitle() {
|
||||
return;
|
||||
}
|
||||
const k = `route.${pathKey}._name`;
|
||||
const v = t(k);
|
||||
setDocumentTitle(v === k || !v ? title : v);
|
||||
setDocumentTitle(te(k) ? t(k) : title);
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||
const userStore = useUserStore();
|
||||
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
|
||||
const status = ref<number>(0);
|
||||
const status = ref<number | undefined>(0);
|
||||
const searchText = ref('');
|
||||
const showReview = ref(false);
|
||||
const current = ref<VolunteerRecord | null>(null);
|
||||
@@ -407,8 +407,6 @@
|
||||
if (value === 2) return 'red';
|
||||
return 'orange';
|
||||
};
|
||||
|
||||
onMounted(() => undefined);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
||||
@@ -1,52 +1,771 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="ele-body">
|
||||
<a-card :bordered="false" class="placeholder-card">
|
||||
<a-result status="info" title="学生画像" sub-title="该功能规划在 P1 阶段实施">
|
||||
<template #extra>
|
||||
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
|
||||
<div class="placeholder-desc">综合专业、年级、活动参与、兴趣标签构建多维画像,以简历式排版展示。</div>
|
||||
<div class="placeholder-sub">实施依赖:</div>
|
||||
<ul class="placeholder-list">
|
||||
<li>学分/志愿/活动数据聚合</li>
|
||||
<li>画像维度与标签定义</li>
|
||||
<li>雷达图展示</li>
|
||||
</ul>
|
||||
</a-space>
|
||||
<a-card :bordered="false" :body-style="{ padding: '16px' }">
|
||||
<div class="portrait-toolbar">
|
||||
<div class="toolbar-title">
|
||||
<span class="title-text">学生画像</span>
|
||||
<span class="title-desc">
|
||||
综合专业、年级、活动参与与兴趣标签构建多维画像,以简历式排版呈现。
|
||||
</span>
|
||||
</div>
|
||||
<a-space wrap>
|
||||
<a-select
|
||||
v-if="canSearchStudent"
|
||||
v-model:value="selectedUserId"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="false"
|
||||
:options="studentOptions"
|
||||
:loading="searching"
|
||||
style="width: 320px"
|
||||
placeholder="输入姓名或学号检索学生"
|
||||
@search="onSearchStudent"
|
||||
@change="onStudentChange"
|
||||
/>
|
||||
<a-button :loading="loading" @click="loadPortrait">刷新</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty
|
||||
v-if="!loading && !portrait"
|
||||
:description="
|
||||
canSearchStudent ? '请检索并选择一名学生查看画像' : '暂无画像数据'
|
||||
"
|
||||
class="portrait-empty"
|
||||
/>
|
||||
<template v-else-if="portrait">
|
||||
<div class="portrait-hero">
|
||||
<div class="hero-main">
|
||||
<a-avatar :size="80" :src="basic?.avatar">
|
||||
<template #icon><user-outlined /></template>
|
||||
</a-avatar>
|
||||
<div class="hero-text">
|
||||
<div class="hero-name-row">
|
||||
<h1>{{ basic?.name || '未命名同学' }}</h1>
|
||||
<a-tag v-if="basic?.studentNo" color="blue">
|
||||
学号 {{ basic.studentNo }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="hero-sub">
|
||||
<span v-if="basic?.collegeName">{{
|
||||
basic.collegeName
|
||||
}}</span>
|
||||
<span v-if="basic?.major">{{ basic.major }}</span>
|
||||
<span v-if="basic?.className">{{ basic.className }}</span>
|
||||
<span v-if="basic?.grade">{{ basic.grade }}</span>
|
||||
</div>
|
||||
<div class="hero-tags">
|
||||
<a-tag
|
||||
v-for="tag in portrait.tags || []"
|
||||
:key="tag"
|
||||
color="processing"
|
||||
>
|
||||
{{ tag }}
|
||||
</a-tag>
|
||||
<span
|
||||
v-if="!(portrait.tags || []).length"
|
||||
class="hero-empty-tip"
|
||||
>
|
||||
暂无标签
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-side">
|
||||
<div class="completion">
|
||||
<div class="completion-label">档案完善度</div>
|
||||
<a-progress
|
||||
type="circle"
|
||||
:percent="Number(basic?.profileCompletion ?? 0)"
|
||||
:size="88"
|
||||
:stroke-color="completionColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="portrait.summary"
|
||||
class="summary-bar"
|
||||
type="info"
|
||||
show-icon
|
||||
:message="portrait.summary"
|
||||
/>
|
||||
|
||||
<a-row :gutter="[16, 16]" class="metric-row">
|
||||
<a-col
|
||||
v-for="metric in metrics"
|
||||
:key="metric.label"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:xl="6"
|
||||
>
|
||||
<div class="metric-card" :class="metric.tone">
|
||||
<div class="metric-label">{{ metric.label }}</div>
|
||||
<div class="metric-value">{{ metric.value }}</div>
|
||||
<div class="metric-foot">{{ metric.foot }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :xl="10">
|
||||
<a-card :bordered="false" class="section-card" title="能力维度">
|
||||
<a-empty
|
||||
v-if="!dimensions.length"
|
||||
description="暂无可用于画像的数据"
|
||||
/>
|
||||
<v-chart
|
||||
v-else
|
||||
ref="radarChartRef"
|
||||
class="radar-chart"
|
||||
:option="radarOption"
|
||||
autoresize
|
||||
/>
|
||||
<div class="dimension-list">
|
||||
<div
|
||||
v-for="item in dimensions"
|
||||
:key="item.key"
|
||||
class="dimension-item"
|
||||
>
|
||||
<span class="dimension-name">{{ item.name }}</span>
|
||||
<a-progress
|
||||
:percent="item.score"
|
||||
:show-info="false"
|
||||
:stroke-color="dimensionColor(item.score)"
|
||||
size="small"
|
||||
/>
|
||||
<span class="dimension-score">{{ item.score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :xl="14">
|
||||
<a-card :bordered="false" class="section-card" title="成长档案">
|
||||
<a-descriptions
|
||||
:column="1"
|
||||
size="small"
|
||||
bordered
|
||||
class="resume-desc"
|
||||
>
|
||||
<a-descriptions-item label="政治面貌">
|
||||
{{ basic?.politics || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="团内职务">
|
||||
{{ basic?.leaguePosition || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="入团年月">
|
||||
{{ basic?.joinMonth || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="累计学分">
|
||||
{{ credit?.totalEarned ?? 0 }} /
|
||||
{{ credit?.totalRequired ?? 0 }} (完成率
|
||||
{{ credit?.completionRate ?? 0 }}%)
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="志愿服务">
|
||||
{{ volunteerHours }} 小时 · 已审核
|
||||
{{ volunteer?.approvedCount ?? 0 }} 条
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="社会实践">
|
||||
通过 {{ practice?.passed ?? 0 }} 项 · 获
|
||||
{{ practice?.earnedScore ?? 0 }} 学分
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="学生社团">
|
||||
{{ clubNames }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="青马培养">
|
||||
<template v-if="qmgcStudent">
|
||||
{{ qmgcStudent.batch || '-' }} ·
|
||||
{{ qmgcStudent.stageName || qmgcStudent.stage || '-' }}
|
||||
· 累计 {{ qmgcStudent.totalScore ?? 0 }} 分
|
||||
</template>
|
||||
<template v-else>未参加</template>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="[16, 16]" class="bottom-row">
|
||||
<a-col :xs="24" :xl="12">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
class="section-card"
|
||||
title="学分类别达成"
|
||||
>
|
||||
<a-empty
|
||||
v-if="!creditCategories.length"
|
||||
description="暂无学分记录"
|
||||
/>
|
||||
<div v-else class="credit-list">
|
||||
<div
|
||||
v-for="item in creditCategories"
|
||||
:key="item.category"
|
||||
class="credit-item"
|
||||
>
|
||||
<div class="credit-head">
|
||||
<span class="credit-name">{{ item.category }}</span>
|
||||
<span class="credit-score">
|
||||
{{ item.earned }} / {{ item.required }}
|
||||
</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="Math.min(Number(item.rate ?? 0), 100)"
|
||||
:show-info="false"
|
||||
:stroke-color="item.gap > 0 ? '#faad14' : '#52c41a'"
|
||||
/>
|
||||
<div class="credit-foot">
|
||||
<span>共 {{ item.count }} 条记录</span>
|
||||
<span v-if="item.gap > 0" class="credit-gap">
|
||||
还差 {{ item.gap }} 分
|
||||
</span>
|
||||
<span v-else class="credit-done">已达成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :xl="12">
|
||||
<a-card :bordered="false" class="section-card" title="成长轨迹">
|
||||
<a-empty v-if="!timeline.length" description="暂无成长记录" />
|
||||
<a-timeline v-else class="timeline">
|
||||
<a-timeline-item
|
||||
v-for="(item, index) in timeline"
|
||||
:key="`timeline-${index}`"
|
||||
:color="timelineColor(item.type)"
|
||||
>
|
||||
<div class="timeline-title">
|
||||
<a-tag :color="timelineColor(item.type)">
|
||||
{{ item.typeName }}
|
||||
</a-tag>
|
||||
<span class="timeline-text">{{ item.title }}</span>
|
||||
</div>
|
||||
<div class="timeline-meta">
|
||||
<span>{{ item.time }}</span>
|
||||
<span v-if="item.extra" class="timeline-extra">
|
||||
{{ item.extra }}
|
||||
</span>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div v-if="portrait.updateTime" class="portrait-footer">
|
||||
画像生成时间:{{ portrait.updateTime }}
|
||||
</div>
|
||||
</template>
|
||||
</a-result>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// 学生画像:P1 阶段交付,当前先占位避免菜单跳转 404
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { UserOutlined } from '@ant-design/icons-vue';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import { use } from 'echarts/core';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { RadarChart } from 'echarts/charts';
|
||||
import {
|
||||
LegendComponent,
|
||||
RadarComponent,
|
||||
TooltipComponent
|
||||
} from 'echarts/components';
|
||||
import VChart from 'vue-echarts';
|
||||
import useEcharts from '@/utils/use-echarts';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import {
|
||||
getMyPortrait,
|
||||
getStudentPortrait,
|
||||
searchPortraitStudents
|
||||
} from '@/api/growth/portrait';
|
||||
import type {
|
||||
PortraitDimension,
|
||||
PortraitStudentOption,
|
||||
PortraitTimelineItem,
|
||||
StudentPortrait
|
||||
} from '@/api/growth/portrait';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
RadarChart,
|
||||
RadarComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent
|
||||
]);
|
||||
|
||||
const userStore = useUserStore();
|
||||
// 纯学生角色只能看自己的画像,不展示检索框
|
||||
const canSearchStudent = computed(() => {
|
||||
const roles = (userStore.roles ?? []).filter(
|
||||
(role): role is string => !!role
|
||||
);
|
||||
return !(roles.length === 1 && roles[0] === 'student');
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const searching = ref(false);
|
||||
const portrait = ref<StudentPortrait | null>(null);
|
||||
const selectedUserId = ref<number | undefined>(undefined);
|
||||
const studentOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const radarChartRef = ref<InstanceType<typeof VChart> | null>(null);
|
||||
useEcharts([radarChartRef]);
|
||||
|
||||
const basic = computed(() => portrait.value?.basic);
|
||||
const credit = computed(() => portrait.value?.credit);
|
||||
const volunteer = computed(() => portrait.value?.volunteer || {});
|
||||
const activity = computed(() => portrait.value?.activity || {});
|
||||
const practice = computed(() => portrait.value?.practice || {});
|
||||
const qmgcStudent = computed(() => {
|
||||
const qmgc = portrait.value?.qmgc;
|
||||
return (qmgc?.student as Record<string, any>) || null;
|
||||
});
|
||||
const dimensions = computed<PortraitDimension[]>(
|
||||
() => portrait.value?.dimensions || []
|
||||
);
|
||||
const timeline = computed<PortraitTimelineItem[]>(
|
||||
() => portrait.value?.timeline || []
|
||||
);
|
||||
const creditCategories = computed<any[]>(
|
||||
() => credit.value?.categories || []
|
||||
);
|
||||
const clubs = computed<any[]>(() => (activity.value?.clubs as any[]) || []);
|
||||
const clubNames = computed(() =>
|
||||
clubs.value.length
|
||||
? clubs.value.map((item) => item.clubName).join('、')
|
||||
: '暂无'
|
||||
);
|
||||
const volunteerHours = computed(() =>
|
||||
Number(volunteer.value?.totalHours ?? 0)
|
||||
);
|
||||
|
||||
const completionColor = computed(() => {
|
||||
const value = Number(basic.value?.profileCompletion ?? 0);
|
||||
if (value >= 90) return '#52c41a';
|
||||
if (value >= 60) return '#1677ff';
|
||||
return '#faad14';
|
||||
});
|
||||
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
label: '第二课堂学分',
|
||||
value: credit.value?.totalEarned ?? 0,
|
||||
foot: `应修 ${credit.value?.totalRequired ?? 0},还差 ${
|
||||
credit.value?.totalGap ?? 0
|
||||
}`,
|
||||
tone: ''
|
||||
},
|
||||
{
|
||||
label: '志愿服务时长',
|
||||
value: volunteerHours.value,
|
||||
foot: `已审核通过 ${volunteer.value?.approvedCount ?? 0} 条(小时)`,
|
||||
tone: 'accent'
|
||||
},
|
||||
{
|
||||
label: '社会实践',
|
||||
value: practice.value?.passed ?? 0,
|
||||
foot: `累计获 ${practice.value?.earnedScore ?? 0} 学分(项)`,
|
||||
tone: 'warm'
|
||||
},
|
||||
{
|
||||
label: '学生社团',
|
||||
value: clubs.value.length,
|
||||
foot: '当前在社社团数量',
|
||||
tone: 'muted'
|
||||
}
|
||||
]);
|
||||
|
||||
const radarOption = computed<EChartsCoreOption>(() => {
|
||||
const list = dimensions.value;
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
radar: {
|
||||
indicator: list.map((item) => ({
|
||||
name: item.name,
|
||||
max: 100
|
||||
})),
|
||||
radius: '68%',
|
||||
splitNumber: 4,
|
||||
axisName: { color: '#595959', fontSize: 12 },
|
||||
splitLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: ['rgba(22, 119, 255, 0.03)', 'rgba(22, 119, 255, 0.08)']
|
||||
}
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
symbolSize: 6,
|
||||
data: [
|
||||
{
|
||||
value: list.map((item) => item.score),
|
||||
name: '能力得分',
|
||||
areaStyle: { color: 'rgba(22, 119, 255, 0.25)' },
|
||||
lineStyle: { color: '#1677ff', width: 2 },
|
||||
itemStyle: { color: '#1677ff' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const dimensionColor = (score: number) => {
|
||||
if (score >= 80) return '#52c41a';
|
||||
if (score >= 60) return '#1677ff';
|
||||
if (score >= 40) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
};
|
||||
|
||||
const timelineColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'credit':
|
||||
return 'blue';
|
||||
case 'volunteer':
|
||||
return 'green';
|
||||
case 'practice':
|
||||
return 'orange';
|
||||
case 'club':
|
||||
return 'purple';
|
||||
case 'qmgc':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const loadPortrait = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (selectedUserId.value) {
|
||||
portrait.value = await getStudentPortrait(selectedUserId.value);
|
||||
} else if (canSearchStudent.value) {
|
||||
// 管理端未选择学生时不展示自己的画像,避免空白页
|
||||
portrait.value = null;
|
||||
} else {
|
||||
portrait.value = await getMyPortrait();
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载学生画像失败');
|
||||
portrait.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const searchStudents = async (keyword?: string) => {
|
||||
searching.value = true;
|
||||
try {
|
||||
const list = await searchPortraitStudents(keyword);
|
||||
studentOptions.value = list.map((item: PortraitStudentOption) => ({
|
||||
label: [
|
||||
item.name || '未命名',
|
||||
item.studentNo,
|
||||
item.className || item.collegeName
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
value: item.userId
|
||||
}));
|
||||
// 管理端首次进入自动选中第一个学生
|
||||
if (
|
||||
canSearchStudent.value &&
|
||||
!selectedUserId.value &&
|
||||
studentOptions.value.length
|
||||
) {
|
||||
selectedUserId.value = studentOptions.value[0].value;
|
||||
loadPortrait();
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '检索学生失败');
|
||||
studentOptions.value = [];
|
||||
} finally {
|
||||
searching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onSearchStudent = debounce((value: string) => {
|
||||
searchStudents(value);
|
||||
}, 300);
|
||||
|
||||
const onStudentChange = () => {
|
||||
loadPortrait();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (canSearchStudent.value) {
|
||||
searchStudents();
|
||||
} else {
|
||||
loadPortrait();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.placeholder-card {
|
||||
min-height: calc(100vh - 180px);
|
||||
.portrait-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.placeholder-desc {
|
||||
color: #595959;
|
||||
line-height: 1.8;
|
||||
.toolbar-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.placeholder-sub {
|
||||
margin-top: 4px;
|
||||
.title-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.placeholder-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
.title-desc {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.portrait-empty {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.portrait-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
padding: 22px 24px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e6f1ff 100%);
|
||||
}
|
||||
|
||||
.hero-main {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-name-row h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.hero-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.hero-empty-tip {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.completion {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.completion-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.summary-bar {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 18px 20px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border-left: 4px solid #1677ff;
|
||||
}
|
||||
|
||||
.metric-card.accent {
|
||||
border-left-color: #13c2c2;
|
||||
}
|
||||
|
||||
.metric-card.warm {
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.metric-card.muted {
|
||||
border-left-color: #722ed1;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.metric-foot {
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: 10px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.radar-chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.dimension-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dimension-item {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 1fr 40px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dimension-name {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.dimension-score {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.resume-desc {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.credit-item + .credit-item {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.credit-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.credit-name {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.credit-score {
|
||||
color: #1677ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.credit-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.credit-gap {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.credit-done {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timeline-text {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.timeline-extra {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.portrait-footer {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="ele-body">
|
||||
<a-card :bordered="false" class="placeholder-card">
|
||||
<a-result status="info" title="推荐内容" sub-title="该功能规划在 P1 阶段实施">
|
||||
<template #extra>
|
||||
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
|
||||
<div class="placeholder-desc">发布思政文章与活动,按学生画像与学分缺口定向推送。</div>
|
||||
<div class="placeholder-sub">实施依赖:</div>
|
||||
<ul class="placeholder-list">
|
||||
<li>内容库维护</li>
|
||||
<li>规则匹配</li>
|
||||
<li>投放记录</li>
|
||||
</ul>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-result>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// 推荐内容:P1 阶段交付,当前先占位避免菜单跳转 404
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.placeholder-card {
|
||||
min-height: calc(100vh - 180px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.placeholder-desc {
|
||||
color: #595959;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.placeholder-sub {
|
||||
margin-top: 4px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.placeholder-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.9;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user