feat(growth): 实现学生画像功能并下线推荐内容

- 替换学生画像页面占位符,新增简历式头部展示
- 集成雷达图展示能力维度评分
- 增加学分类别达成和成长轨迹时间线展示
- 管理端支持学生检索与切换查看画像
- 接口调用区分纯学生角色权限限制
- 删除推荐内容相关前端页面及菜单配置
- 更新文档说明学生画像功能与推荐内容下线情况
- 细节优化标题本地化判断逻辑及状态类型声明
This commit is contained in:
2026-11-07 10:28:08 +08:00
parent d8e7e2a2a8
commit 6236c7f723
9 changed files with 871 additions and 97 deletions
+32 -5
View File
@@ -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
```
+2 -3
View File
@@ -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}
+82
View File
@@ -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));
}
+3 -4
View File
@@ -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>
+4 -1
View File
@@ -147,5 +147,8 @@ export {
BorderOutlined,
PieChartOutlined,
AreaChartOutlined,
KeyOutlined
KeyOutlined,
RadarChartOutlined,
RiseOutlined,
SolutionOutlined
} from '@ant-design/icons-vue';
+2 -3
View File
@@ -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(
+1 -3
View File
@@ -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>
+745 -26
View File
@@ -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>
-52
View File
@@ -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>