feat(volunteer): 新增志愿服务证明材料识别及年度证明功能

- 新增志愿证明材料识别功能,支持自动识别材料并回填关键信息
- 在志愿时长编辑页面添加材料识别按钮及识别结果展示
- 新增志愿服务证明模块,支持查询、生成及下载年度志愿服务证明PDF
- 志愿时长列表及审核详情中新增“服务地点”字段显示
- 志愿时长导入功能支持“服务地点”字段,完善导入表头与数据映射
- 志愿时长详情、审核流程等界面优化,增加识别材料公章状态及流程节点提示
- 志愿时长待审核项支持用户撤回操作,撤回后记录被删除需重新录入
- 社团年度报告相关接口新增,实现社团年度报告生成及分页查询
- 调整相关样式与界面布局,提升用户体验与信息展示完整性
This commit is contained in:
2026-11-06 12:04:32 +08:00
parent d82a3b5426
commit d8e7e2a2a8
21 changed files with 1980 additions and 103 deletions
@@ -0,0 +1,444 @@
<!-- 青马学员 AI 学情分析 -->
<template>
<a-drawer
:visible="visible"
:width="760"
:body-style="{ padding: '20px 24px 24px' }"
@update:visible="updateVisible"
>
<template #title>
<div class="analysis-title">
<span>AI 学情分析</span>
<a-tag v-if="studentName" color="blue">{{ studentName }}</a-tag>
</div>
</template>
<template #extra>
<a-space>
<a-button v-if="loading" size="small" danger @click="stopAnalysis">
停止
</a-button>
<a-button
size="small"
type="primary"
:loading="loading"
:disabled="!canAnalyze"
@click="runAnalysis"
>
{{ hasResult ? '重新分析' : '开始分析' }}
</a-button>
</a-space>
</template>
<a-alert
type="info"
show-icon
class="analysis-tip"
message="分析依据"
:description="dataScopeText"
/>
<div class="analysis-focus">
<span class="focus-label">分析侧重</span>
<a-radio-group v-model:value="focus" :disabled="loading" size="small">
<a-radio-button value="">综合</a-radio-button>
<a-radio-button value="thought">思想动态</a-radio-button>
<a-radio-button value="difficulty">学习难点</a-radio-button>
<a-radio-button value="advice">培养建议</a-radio-button>
</a-radio-group>
</div>
<a-empty
v-if="!loading && !hasResult"
description="点击「开始分析」,AI 将结合该学员的培养材料、任务进度与考核评分给出结论"
/>
<div v-else class="analysis-result">
<div v-if="loading && !displayContent" class="analysis-loading">
<a-spin size="small" />
<span>AI 正在分析该学员的学习情况…</span>
</div>
<ByteMdViewer
v-if="displayContent"
:value="displayContent"
:plugins="plugins"
/>
<div v-if="loading && displayContent" class="analysis-thinking">
<span class="thinking-dot" aria-hidden="true"></span>
<span>正在继续生成…</span>
</div>
</div>
<template #footer>
<div class="analysis-footer">
<span class="footer-hint">分析结果由 AI 生成,仅供参考</span>
<a-space>
<a-button :disabled="!displayContent" @click="copyResult"
>复制结果</a-button
>
<a-button :disabled="loading || !displayContent" @click="clearResult"
>清空</a-button
>
</a-space>
</div>
</template>
</a-drawer>
</template>
<script lang="ts" setup>
import 'bytemd/dist/index.min.css';
import 'github-markdown-css/github-markdown-light.css';
import gfm from '@bytemd/plugin-gfm';
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import highlight from '@bytemd/plugin-highlight';
import 'highlight.js/styles/default.css';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { streamQmgcStudentAnalysis } from '@/utils/ai-stream';
import { copyText } from '@/utils/common';
import { FAILED_TEXT } from '@/utils/ai-result';
import type { QmgcMaterial, QmgcScore, QmgcStudent } from '@/api/growth/qmgc';
const props = defineProps<{
visible: boolean;
/** 当前学员 */
student?: QmgcStudent | null;
/** 学员档案详情(含 materials / tasks / scores) */
detail?: Record<string, any> | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const plugins = ref([
gfm({
locale: zh_HansGfm
}),
highlight()
]);
const loading = ref(false);
const focus = ref('');
const answer = ref('');
const controller = ref<AbortController | null>(null);
const STREAM_END_TEXT = '__END__';
const THINK_OPEN_TAG = '<think>';
const THINK_CLOSE_TAG = '</think>';
const studentName = computed(() => props.student?.studentName || '');
const canAnalyze = computed(() => !!props.student?.id);
const materials = computed<QmgcMaterial[]>(
() => (props.detail?.materials || []) as QmgcMaterial[]
);
const tasks = computed<any[]>(() => (props.detail?.tasks || []) as any[]);
const scores = computed<QmgcScore[]>(
() => (props.detail?.scores || []) as QmgcScore[]
);
const doneTaskCount = computed(
() => tasks.value.filter((item) => item.assignStatus === 1).length
);
const scoredMaterialCount = computed(
() => materials.value.filter((item) => item.status === 1).length
);
/** 展示给教师的取数范围,避免用户误以为 AI「看过」系统外的数据 */
const dataScopeText = computed(() => {
if (!props.student?.id) {
return '尚未选择学员';
}
return `培养材料 ${materials.value.length} 条(已评分 ${scoredMaterialCount.value} 条)、任务 ${doneTaskCount.value}/${tasks.value.length} 项已完成、考核评分 ${scores.value.length} 条。系统暂无考勤与课堂互动数据,分析不含这两类记录。`;
});
const hasResult = computed(() => !!answer.value.trim());
/** 去掉 <think> 思考段,只留正文 */
const parseAssistantContent = (value: string) => {
if (!value) {
return { content: '', thinking: false };
}
let cursor = 0;
let content = '';
let thinking = false;
while (cursor < value.length) {
if (!thinking) {
const openIndex = value.indexOf(THINK_OPEN_TAG, cursor);
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor);
if (closeIndex !== -1 && (openIndex === -1 || closeIndex < openIndex)) {
cursor = closeIndex + THINK_CLOSE_TAG.length;
continue;
}
if (openIndex === -1) {
content += value.slice(cursor);
break;
}
content += value.slice(cursor, openIndex);
cursor = openIndex + THINK_OPEN_TAG.length;
thinking = true;
continue;
}
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor);
if (closeIndex === -1) {
break;
}
cursor = closeIndex + THINK_CLOSE_TAG.length;
thinking = false;
}
return { content, thinking };
};
const displayContent = computed(() =>
parseAssistantContent(answer.value).content.trim()
);
const truncate = (value: string, max: number) =>
value.length > max ? `${value.slice(0, max)}…` : value;
/**
* 组装给 AI 的学员上下文。
*
* 后端可按 studentId 自行取数;这里同时带上前端已加载的摘要,
* 避免后端尚未聚合时 AI「看不到」任何材料内容。
*/
const buildContext = () => {
const student = props.student || {};
const materialTexts = materials.value
.filter((item) => item.content && item.content.trim())
.slice(0, 8)
.map(
(item) =>
`【${item.title || '培养材料'}】${truncate(
String(item.content).trim(),
800
)}`
);
const taskTexts = tasks.value.slice(0, 20).map((item) => {
const title = item.title || item.task?.title || '未命名任务';
const status = item.assignStatus === 1 ? '已完成' : '未完成';
return `${title}(${status})`;
});
const scoreTexts = scores.value.slice(0, 20).map((item) => {
const parts = [item.dimension, item.item].filter(Boolean).join('/');
const comment = item.comment
? `,评语:${truncate(item.comment, 200)}`
: '';
return `${parts || '考核项'}:${item.score ?? 0} 分${comment}`;
});
return {
studentId: student.id,
studentName: student.studentName,
studentNo: student.studentNo,
collegeName: student.collegeName,
className: student.className,
batch: student.batch,
stage: student.stage,
totalScore: student.totalScore,
materialCount: materials.value.length,
scoredMaterialCount: scoredMaterialCount.value,
taskDoneCount: doneTaskCount.value,
taskTotal: tasks.value.length,
materialTexts,
taskTexts,
scoreTexts
};
};
const buildPayload = () => ({
studentId: props.student?.id,
focus: focus.value || undefined,
context: buildContext()
});
const clearResult = () => {
answer.value = '';
};
const updateVisible = (value: boolean) => {
if (!value) {
stopAnalysis();
}
emit('update:visible', value);
};
const stopAnalysis = () => {
controller.value?.abort();
controller.value = null;
loading.value = false;
};
const runAnalysis = async () => {
if (!canAnalyze.value) {
message.warning('请先选择学员');
return;
}
if (loading.value) {
return;
}
clearResult();
loading.value = true;
const current = new AbortController();
controller.value = current;
try {
await streamQmgcStudentAnalysis(
buildPayload() as unknown as Record<string, unknown>,
(raw) => {
if (!raw) {
return;
}
try {
const data = JSON.parse(raw);
if (data?.answer === STREAM_END_TEXT) {
return;
}
if (data?.loading === 'true' || data?.loading === true) {
return;
}
const chunk =
typeof data?.notice === 'string'
? data.notice
: typeof data?.answer === 'string'
? data.answer
: '';
if (chunk) {
answer.value += chunk;
}
} catch {
answer.value += raw;
}
},
current.signal
);
const text = displayContent.value;
if (!text) {
message.warning('这次没有生成出内容,请重试');
} else if (text === FAILED_TEXT) {
message.error('分析失败,请稍后重试');
}
} catch (e: any) {
if (e?.name !== 'AbortError') {
message.error(e?.message || '分析失败');
}
} finally {
if (controller.value === current) {
controller.value = null;
loading.value = false;
}
}
};
const copyResult = () => {
if (!displayContent.value) {
message.warning('暂无可复制内容');
return;
}
copyText(displayContent.value);
};
/** 切换学员或关闭时清掉上一次结果,避免张冠李戴 */
watch(
() => props.student?.id,
() => {
stopAnalysis();
clearResult();
}
);
watch(
() => props.visible,
(value) => {
if (value) {
focus.value = '';
}
}
);
onBeforeUnmount(() => {
stopAnalysis();
});
</script>
<style scoped lang="less">
.analysis-title {
display: flex;
align-items: center;
gap: 10px;
}
.analysis-tip {
margin-bottom: 16px;
border-radius: 10px;
}
.analysis-focus {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.focus-label {
flex: none;
color: #8c8c8c;
font-size: 13px;
}
.analysis-result {
min-height: 240px;
padding: 16px 20px;
border: 1px solid #edf2f8;
border-radius: 12px;
background: #fbfdff;
}
.analysis-loading,
.analysis-thinking {
display: flex;
align-items: center;
gap: 10px;
color: #8c8c8c;
font-size: 13px;
}
.analysis-loading {
justify-content: center;
min-height: 200px;
}
.analysis-thinking {
margin-top: 12px;
}
.thinking-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #1d7bff;
animation: thinkingPulse 1.2s ease-in-out infinite;
}
.analysis-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.footer-hint {
color: #8c8c8c;
font-size: 12px;
}
@keyframes thinkingPulse {
0%,
100% {
transform: scale(0.8);
opacity: 0.45;
}
50% {
transform: scale(1);
opacity: 1;
}
}
</style>
+73 -1
View File
@@ -60,6 +60,8 @@
<a-space>
<a @click="openDetail(record)">档案</a>
<a-divider type="vertical" />
<a @click="openAnalysis(record)">AI 分析</a>
<a-divider type="vertical" />
<a v-if="hasPermission('gxmu:qmgcStudent:update')" @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
@@ -77,6 +79,12 @@
<QmgcStudentEdit v-model:visible="showEdit" :data="current" @done="reload" />
<AiAnalysis
v-model:visible="showAnalysis"
:student="analysisStudent"
:detail="analysisDetail"
/>
<a-drawer v-model:visible="showDetail" title="青马培养档案" width="880">
<template v-if="detail">
<a-descriptions :column="2" bordered size="small">
@@ -88,6 +96,21 @@
<a-descriptions-item label="累计得分">{{ student?.totalScore ?? 0 }}</a-descriptions-item>
</a-descriptions>
<div class="detail-ai-entry">
<span class="detail-ai-hint">
结合下面的培养材料、任务进度与考核评分,让 AI
分析该学员的思想动态与学习难点
</span>
<a-button
type="primary"
ghost
size="small"
@click="openAnalysis(current)"
>
AI 学情分析
</a-button>
</div>
<a-tabs style="margin-top: 16px">
<a-tab-pane key="material" tab="培养材料">
<a-space style="margin-bottom: 12px">
@@ -209,6 +232,7 @@
import type { EleProTable } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import QmgcStudentEdit from './components/edit.vue';
import AiAnalysis from './components/aiAnalysis.vue';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import {
pageQmgcStudents,
@@ -227,10 +251,13 @@
const stage = ref<string>();
const showEdit = ref(false);
const showDetail = ref(false);
const showAnalysis = ref(false);
const showMaterial = ref(false);
const showScoreModal = ref(false);
const current = ref<QmgcStudent | null>(null);
const detail = ref<Record<string, any> | null>(null);
const analysisStudent = ref<QmgcStudent | null>(null);
const analysisDetail = ref<Record<string, any> | null>(null);
const materialForm = ref<Partial<QmgcMaterial>>({});
const materialFile = ref('');
const submitting = ref(false);
@@ -260,7 +287,7 @@
{ title: '阶段', key: 'stage', width: 100, align: 'center' },
{ title: '累计得分', dataIndex: 'totalScore', key: 'totalScore', width: 100, align: 'center' },
{ title: '状态', key: 'status', width: 90, align: 'center' },
{ title: '操作', key: 'action', width: 180, fixed: 'right', align: 'center', hideInSetting: true }
{ title: '操作', key: 'action', width: 230, fixed: 'right', align: 'center', hideInSetting: true }
]);
const taskColumns: ColumnItem[] = [
@@ -316,6 +343,31 @@
}
};
/**
* 打开 AI 学情分析。
*
* 从列表进入时档案详情还没加载,这里按需取一次,保证 AI 能看到
* 培养材料、任务和评分;已加载过(从档案抽屉进入)则直接复用。
*/
const openAnalysis = async (row?: QmgcStudent | null) => {
if (!row?.id) {
message.warning('请先选择学员');
return;
}
if (!detail.value || current.value?.id !== row.id) {
try {
detail.value = await getQmgcStudentDetail(row.id);
} catch (e: any) {
message.error(e?.message || '加载学情数据失败');
return;
}
}
current.value = row;
analysisStudent.value = row;
analysisDetail.value = detail.value;
showAnalysis.value = true;
};
const remove = async (row: QmgcStudent) => {
if (!row.id) return;
try {
@@ -402,3 +454,23 @@
return map[value ?? ''] ?? value ?? '-';
};
</script>
<style scoped lang="less">
.detail-ai-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 12px;
padding: 10px 14px;
border: 1px dashed #d6e4f5;
border-radius: 8px;
background: #f7fbff;
}
.detail-ai-hint {
color: #8c8c8c;
font-size: 12px;
line-height: 1.6;
}
</style>
@@ -121,7 +121,9 @@
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
{ label: '青马工程', value: 'gxmu_qmgc_form' },
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' }
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' },
{ label: '志愿服务时长', value: 'gxmu_volunteer' },
{ label: '社会实践学分', value: 'gxmu_practice_credit' }
];
// 用户信息
@@ -13,7 +13,11 @@
<a-spin :spinning="loading">
<div v-if="canOperateDeclare" class="declare-actions">
<a-space>
<a-button size="small" :loading="actionLoading" @click="handleWithdraw">
<a-button
size="small"
:loading="actionLoading"
@click="handleWithdraw"
>
撤回申报
</a-button>
<a-button
@@ -225,6 +229,7 @@
import { getSjqnForm } from '@/api/gxmu/sjqn-form';
import { getSjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form';
import { getTyglForm } from '@/api/gxmu/tygl-form';
import { getVolunteerRecord } from '@/api/growth/volunteer';
import { getTzbcyForm } from '@/api/gxmu/tzbcy-form';
import { getWshqtwForm } from '@/api/gxmu/wshqtw-form';
import { getWshqtzbForm } from '@/api/gxmu/wshqtzb-form';
@@ -345,6 +350,11 @@
gxmu_tygl: {
title: '团员管理',
fetcher: (id) => getTyglForm(id) as Promise<Record<string, unknown>>
},
gxmu_volunteer: {
title: '志愿服务时长',
fetcher: (id) =>
getVolunteerRecord(id) as Promise<Record<string, unknown>>
}
};
@@ -435,7 +445,10 @@
onOk: async () => {
actionLoading.value = true;
try {
await withdrawDeclareRecord(props.module as string, props.pk as number);
await withdrawDeclareRecord(
props.module as string,
props.pk as number
);
message.success('已撤回,申报人可继续修改');
emit('done');
updateVisible(false);
@@ -1085,5 +1098,4 @@
font-size: 12px;
color: #8c8c8c;
}
</style>
</style>
@@ -834,6 +834,43 @@ export const MODULE_DETAIL_CONFIGS: Record<string, ModuleDetailConfig> = {
],
materialFields: []
},
gxmu_volunteer: {
title: '志愿服务时长',
sheetName: '志愿服务时长记录',
labels: {
studentName: '姓名',
studentNo: '学号',
projectName: '志愿项目',
orgName: '组织单位',
servicePlace: '服务地点',
hours: '服务时长(小时)',
serviceDate: '服务开始日期',
serviceEndDate: '服务结束日期',
content: '服务内容',
source: '数据来源',
proofFiles: '证明材料'
},
sections: [
{
title: '学生信息',
fields: ['studentName', 'studentNo']
},
{
title: '服务信息',
fields: [
'projectName',
'orgName',
'servicePlace',
'hours',
'serviceDate',
'serviceEndDate',
'content',
'source'
]
}
],
materialFields: ['proofFiles']
},
cms_manuscript: {
title: '稿件',
sheetName: '稿件',
@@ -27,11 +27,7 @@
/>
</a-form-item>
<a-form-item label="主键" name="pk">
<a-input
allow-clear
placeholder="请输入主键"
v-model:value="form.pk"
/>
<a-input allow-clear placeholder="请输入主键" v-model:value="form.pk" />
</a-form-item>
<a-form-item label="状态(0驳回 1通过)" name="status">
<a-radio-group v-model:value="form.status">
@@ -61,11 +57,7 @@
/>
</a-form-item>
<a-form-item label="" name="userId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.userId"
/>
<a-input allow-clear placeholder="请输入" v-model:value="form.userId" />
</a-form-item>
<a-form-item label="" name="sortNumber">
<a-input-number
@@ -129,7 +121,7 @@
createTime: undefined,
updateTime: undefined,
userId: undefined,
sortNumber: undefined,
sortNumber: undefined
});
/* 更新visible */
@@ -138,9 +130,7 @@
};
// 表单验证规则
const rules = reactive({
});
const rules = reactive({});
const { resetFields } = useForm(form, rules);
@@ -51,7 +51,9 @@
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
{ label: '青马工程', value: 'gxmu_qmgc_form' },
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' }
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' },
{ label: '志愿服务时长', value: 'gxmu_volunteer' },
{ label: '社会实践学分', value: 'gxmu_practice_credit' }
];
const search = () => {