feat(volunteer): 新增志愿服务证明材料识别及年度证明功能
- 新增志愿证明材料识别功能,支持自动识别材料并回填关键信息 - 在志愿时长编辑页面添加材料识别按钮及识别结果展示 - 新增志愿服务证明模块,支持查询、生成及下载年度志愿服务证明PDF - 志愿时长列表及审核详情中新增“服务地点”字段显示 - 志愿时长导入功能支持“服务地点”字段,完善导入表头与数据映射 - 志愿时长详情、审核流程等界面优化,增加识别材料公章状态及流程节点提示 - 志愿时长待审核项支持用户撤回操作,撤回后记录被删除需重新录入 - 社团年度报告相关接口新增,实现社团年度报告生成及分页查询 - 调整相关样式与界面布局,提升用户体验与信息展示完整性
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user