Files
tuanwei-admin/src/views/club/volunteerCertificate/index.vue
T
gxwebsoft d8e7e2a2a8 feat(volunteer): 新增志愿服务证明材料识别及年度证明功能
- 新增志愿证明材料识别功能,支持自动识别材料并回填关键信息
- 在志愿时长编辑页面添加材料识别按钮及识别结果展示
- 新增志愿服务证明模块,支持查询、生成及下载年度志愿服务证明PDF
- 志愿时长列表及审核详情中新增“服务地点”字段显示
- 志愿时长导入功能支持“服务地点”字段,完善导入表头与数据映射
- 志愿时长详情、审核流程等界面优化,增加识别材料公章状态及流程节点提示
- 志愿时长待审核项支持用户撤回操作,撤回后记录被删除需重新录入
- 社团年度报告相关接口新增,实现社团年度报告生成及分页查询
- 调整相关样式与界面布局,提升用户体验与信息展示完整性
2026-11-06 12:04:32 +08:00

282 lines
7.6 KiB
Vue

<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="年度志愿服务证明按「已通过审核」的志愿时长自动汇总生成,下载为 PDF。证书编号由系统按年度生成,加盖公章后有效。"
/>
<a-space style="margin-bottom: 16px" wrap>
<a-select
v-model:value="year"
style="width: 140px"
:options="yearOptions"
@change="loadData"
/>
<a-input
v-model:value="studentNo"
allow-clear
style="width: 180px"
placeholder="学号(管理员代查)"
@pressEnter="loadData"
/>
<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-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>
<div class="metric-value">{{ summary.totalHours ?? 0 }}</div>
<div class="metric-foot">仅统计已审核通过的记录</div>
</div>
</a-col>
<a-col :xs="24" :sm="8">
<div class="metric-card accent">
<div class="metric-label">志愿项目数</div>
<div class="metric-value">{{ summary.projectCount ?? 0 }}</div>
<div class="metric-foot">按项目名称去重</div>
</div>
</a-col>
<a-col :xs="24" :sm="8">
<div class="metric-card warm">
<div class="metric-label">记录条数</div>
<div class="metric-value">{{ summary.approvedCount ?? 0 }}</div>
<div class="metric-foot">该年度已通过的志愿记录</div>
</div>
</a-col>
</a-row>
<a-card :bordered="false" class="project-card" title="服务明细">
<a-empty
v-if="!summary.items?.length"
description="该年度暂无可生成证明的志愿记录"
/>
<a-table
v-else
row-key="id"
size="small"
:data-source="summary.items"
:columns="projectColumns"
:pagination="false"
/>
</a-card>
</a-spin>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import dayjs from 'dayjs';
import { message } from 'ant-design-vue';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import {
getVolunteerCertificateSummary,
generateVolunteerCertificate
} from '@/api/growth/volunteer';
const loading = ref(false);
const generating = ref(false);
const year = ref(dayjs().year());
const studentNo = ref('');
const summary = ref<Record<string, any>>({});
const yearOptions = computed(() => {
const current = dayjs().year();
return Array.from({ length: 6 }, (_, index) => {
const value = current - index;
return { label: `${value} 年`, value };
});
});
const projectColumns: ColumnItem[] = [
{
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 {
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 = async () => {
generating.value = true;
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;
}
};
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;
background: #fff;
border-left: 4px solid #1677ff;
}
.metric-card.accent {
border-left-color: #13c2c2;
}
.metric-card.warm {
border-left-color: #faad14;
}
.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;
}
.project-card {
margin-top: 16px;
}
</style>