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
+142 -34
View File
@@ -6,7 +6,7 @@
type="info"
show-icon
style="margin-bottom: 16px"
message="年度志愿服务证明按「已通过审核」的志愿时长自动汇总生成。证书编号由后端按年度顺序生成,模板与电子章在 P1 阶段接入。"
message="年度志愿服务证明按「已通过审核」的志愿时长自动汇总生成,下载为 PDF。证书编号由系统按年度生成,加盖公章后有效。"
/>
<a-space style="margin-bottom: 16px" wrap>
@@ -20,16 +20,55 @@
v-model:value="studentNo"
allow-clear
style="width: 180px"
placeholder="学号(管理员)"
placeholder="学号(管理员代查)"
@pressEnter="loadData"
/>
<a-button type="primary" :loading="loading" @click="loadData">查询</a-button>
<a-button :disabled="!summary.totalHours" :loading="generating" @click="generate">
生成证明
<a-button type="primary" :loading="loading" @click="loadData"
>查询</a-button
>
<a-button
type="primary"
ghost
:disabled="!summary.items?.length"
:loading="generating"
@click="generate"
>
{{ summary.generated ? '重新生成' : '生成证明' }}
</a-button>
<a-button v-if="summary.downloadUrl" type="link" @click="download">
下载 PDF
</a-button>
</a-space>
<a-spin :spinning="loading">
<a-row :gutter="[16, 16]">
<a-descriptions
v-if="summary.studentName || summary.studentNo"
bordered
size="small"
:column="3"
class="student-info"
>
<a-descriptions-item label="姓名">{{
summary.studentName || '-'
}}</a-descriptions-item>
<a-descriptions-item label="学号">{{
summary.studentNo || '-'
}}</a-descriptions-item>
<a-descriptions-item label="学院">{{
summary.collegeName || '-'
}}</a-descriptions-item>
<a-descriptions-item label="专业">{{
summary.major || '-'
}}</a-descriptions-item>
<a-descriptions-item label="班级">{{
summary.className || '-'
}}</a-descriptions-item>
<a-descriptions-item label="证书编号">{{
summary.serialNo || '未生成'
}}</a-descriptions-item>
</a-descriptions>
<a-row :gutter="[16, 16]" style="margin-top: 16px">
<a-col :xs="24" :sm="8">
<div class="metric-card">
<div class="metric-label">年度累计时长</div>
@@ -40,7 +79,7 @@
<a-col :xs="24" :sm="8">
<div class="metric-card accent">
<div class="metric-label">志愿项目数</div>
<div class="metric-value">{{ projects.length }}</div>
<div class="metric-value">{{ summary.projectCount ?? 0 }}</div>
<div class="metric-foot">按项目名称去重</div>
</div>
</a-col>
@@ -48,18 +87,21 @@
<div class="metric-card warm">
<div class="metric-label">记录条数</div>
<div class="metric-value">{{ summary.approvedCount ?? 0 }}</div>
<div class="metric-foot">已通过的志愿记录</div>
<div class="metric-foot">该年度已通过的志愿记录</div>
</div>
</a-col>
</a-row>
<a-card :bordered="false" class="project-card" title="项目明细">
<a-empty v-if="!projects.length" description="该年度暂无可生成证明的志愿记录" />
<a-card :bordered="false" class="project-card" title="服务明细">
<a-empty
v-if="!summary.items?.length"
description="该年度暂无可生成证明的志愿记录"
/>
<a-table
v-else
row-key="projectName"
row-key="id"
size="small"
:data-source="projects"
:data-source="summary.items"
:columns="projectColumns"
:pagination="false"
/>
@@ -76,9 +118,8 @@
import { message } from 'ant-design-vue';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import {
getMyVolunteerSummary,
getMyVolunteerProjectStats,
getMyVolunteerTotal
getVolunteerCertificateSummary,
generateVolunteerCertificate
} from '@/api/growth/volunteer';
const loading = ref(false);
@@ -86,7 +127,6 @@
const year = ref(dayjs().year());
const studentNo = ref('');
const summary = ref<Record<string, any>>({});
const projects = ref<Record<string, any>[]>([]);
const yearOptions = computed(() => {
const current = dayjs().year();
@@ -97,44 +137,112 @@
});
const projectColumns: ColumnItem[] = [
{ title: '志愿项目', dataIndex: 'projectName', key: 'projectName' },
{ title: '组织单位', dataIndex: 'orgName', key: 'orgName', width: 260 },
{ title: '时长', dataIndex: 'hours', key: 'hours', width: 100, align: 'center' },
{ title: '记录数', dataIndex: 'count', key: 'count', width: 100, align: 'center' }
{
title: '志愿项目',
dataIndex: 'projectName',
key: 'projectName',
ellipsis: true
},
{
title: '活动时间',
dataIndex: 'serviceDate',
key: 'serviceDate',
width: 180
},
{
title: '服务地点',
dataIndex: 'servicePlace',
key: 'servicePlace',
width: 200,
ellipsis: true
},
{
title: '组织单位',
dataIndex: 'orgName',
key: 'orgName',
width: 200,
ellipsis: true
},
{
title: '时长',
dataIndex: 'hours',
key: 'hours',
width: 90,
align: 'center'
},
{
title: '数据来源',
dataIndex: 'source',
key: 'source',
width: 110,
align: 'center',
customRender: ({ text }) => sourceLabel(text)
}
];
const sourceLabel = (value?: string) => {
const map: Record<string, string> = {
self: '个人录入',
gz: '桂志愿',
import: '导入'
};
return map[value ?? ''] ?? value ?? '-';
};
const loadData = async () => {
loading.value = true;
try {
const [summaryData, projectData, total] = await Promise.all([
getMyVolunteerSummary(),
getMyVolunteerProjectStats(year.value),
getMyVolunteerTotal(year.value)
]);
summary.value = { ...summaryData, totalHours: total ?? 0 };
projects.value = projectData || [];
summary.value = await getVolunteerCertificateSummary({
year: year.value,
studentNo: studentNo.value || undefined
});
} catch (e: any) {
summary.value = {};
message.error(e?.message || '加载志愿数据失败');
} finally {
loading.value = false;
}
};
const generate = () => {
const generate = async () => {
generating.value = true;
// 证明文件由后端模板渲染,P0 先给出数据核对结果,文件下载在模板接入后开放
setTimeout(() => {
try {
const certificate = await generateVolunteerCertificate({
year: year.value,
studentNo: studentNo.value || undefined
});
message.success('证明已生成,可下载 PDF');
summary.value = {
...summary.value,
generated: true,
serialNo: certificate.serialNo,
downloadUrl: certificate.downloadUrl || certificate.fileUrl,
generatedAt: certificate.createTime
};
} catch (e: any) {
message.error(e?.message || '生成证明失败');
} finally {
generating.value = false;
message.info(
`已汇总 ${year.value} 年 ${summary.value.totalHours ?? 0} 小时志愿时长,证明材料模板接入后可在此下载 PDF`
);
}, 300);
}
};
const download = () => {
const url = summary.value.downloadUrl;
if (!url) {
message.warning('暂无可下载的证明文件,请先生成');
return;
}
window.open(url, '_blank');
};
onMounted(loadData);
</script>
<style lang="less" scoped>
.student-info {
margin-bottom: 4px;
}
.metric-card {
padding: 18px 20px;
border-radius: 10px;