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