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

376 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 志愿时长录入 -->
<template>
<ele-modal
:width="640"
:visible="visible"
:maskClosable="false"
:confirm-loading="loading"
title="录入志愿服务时长"
:body-style="{ paddingBottom: '24px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="校外或未通过平台记录的志愿服务,需要上传盖章版证明材料,提交后由二级学院审核。"
/>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-form-item label="志愿项目名称" name="projectName">
<a-input
v-model:value="form.projectName"
allow-clear
placeholder="请输入项目名称"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="组织单位" name="orgName">
<a-input
v-model:value="form.orgName"
allow-clear
placeholder="请输入组织单位"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="服务地点" name="servicePlace">
<a-input
v-model:value="form.servicePlace"
allow-clear
placeholder="请输入服务地点"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="服务时长(小时)" name="hours">
<a-input-number
v-model:value="form.hours"
:min="0.5"
:step="0.5"
class="ele-fluid"
placeholder="请输入时长"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="服务日期" name="serviceDate">
<a-date-picker
v-model:value="serviceDate"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="请选择日期"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结束日期" name="serviceEndDate">
<a-date-picker
v-model:value="serviceEndDate"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="单日可不填"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="服务内容" name="content">
<a-textarea
v-model:value="form.content"
:rows="3"
allow-clear
placeholder="简述服务内容"
/>
</a-form-item>
<a-form-item label="盖章版证明材料" name="proofFiles" required>
<MultiFileUpload v-model="form.proofFiles" :max-count="5" />
<div class="ele-text-secondary" style="font-size: 12px">
支持图片或 PDF,建议上传盖有组织单位公章的证明材料
</div>
<div class="recognize-bar">
<a-button
size="small"
:loading="recognizing"
:disabled="!firstProof"
@click="recognize"
>
<template #icon><scan-outlined /></template>
识别材料自动填写
</a-button>
<span
v-if="!firstProof"
class="ele-text-secondary"
style="font-size: 12px"
>
上传材料后可用
</span>
<span
v-else-if="recognizeTip"
class="ele-text-secondary"
style="font-size: 12px"
>
{{ recognizeTip }}
</span>
</div>
<a-alert
v-if="recognized && recognizedFields.length"
type="info"
show-icon
class="recognize-result"
:message="sealText"
>
<template #description>
<div class="recognize-grid">
<div v-for="item in recognizedFields" :key="item.label">
<span class="recognize-label">{{ item.label }}:</span>
<span>{{ item.value }}</span>
<span v-if="item.differs" class="recognize-diff"
>(当前填写:{{ item.current }})</span
>
</div>
</div>
<a
style="display: inline-block; margin-top: 8px"
@click="applyRecognized"
>
用识别结果覆盖当前填写
</a>
</template>
</a-alert>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { ScanOutlined } from '@ant-design/icons-vue';
import dayjs from 'dayjs';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import {
submitVolunteerRecord,
recognizeVolunteerProof
} from '@/api/growth/volunteer';
import type { VolunteerRecord } from '@/api/growth/volunteer';
import { parseAttachments } from '@/views/gxmu/utils/attachment';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const formRef = ref();
const loading = ref(false);
const recognizing = ref(false);
const recognizeTip = ref('');
const recognized = ref<Record<string, any> | null>(null);
const form = reactive<VolunteerRecord>({});
const serviceDate = ref<any>();
const serviceEndDate = ref<any>();
/** 材料列表里的第一份,识别以它为准 */
const firstProof = computed(() => {
const files = parseAttachments(form.proofFiles);
return files.length ? files[0] : null;
});
/** 识别到的字段与当前填写的对照,差异项在界面上标出来 */
const recognizedFields = computed(() => {
if (!recognized.value) return [];
const data = recognized.value;
const rows = [
{ label: '活动名称', value: data.projectName, current: form.projectName },
{ label: '组织单位', value: data.orgName, current: form.orgName },
{
label: '服务地点',
value: data.servicePlace,
current: form.servicePlace
},
{ label: '服务时长', value: data.hours, current: form.hours },
{ label: '服务日期', value: data.serviceDate, current: serviceDate.value }
];
return rows
.filter(
(row) =>
row.value !== null && row.value !== undefined && row.value !== ''
)
.map((row) => ({
...row,
differs:
row.current !== null &&
row.current !== undefined &&
row.current !== '' &&
String(row.current) !== String(row.value)
}));
});
const sealText = computed(() => {
if (recognized.value?.hasSeal === true) {
return '已识别到公章,请核对下方自动提取的内容';
}
if (recognized.value?.hasSeal === false) {
return '未识别到公章,请确认上传的是盖章版材料';
}
return '材料识别结果';
});
const rules = {
projectName: [
{ required: true, message: '请输入志愿项目名称', trigger: 'blur' }
],
hours: [{ required: true, message: '请输入服务时长', trigger: 'change' }],
serviceDate: [
{ required: true, message: '请选择服务日期', trigger: 'change' }
],
proofFiles: [
{ required: true, message: '请上传证明材料', trigger: 'change' }
]
};
watch(
() => props.visible,
(value) => {
if (value) {
Object.keys(form).forEach((key) => delete (form as any)[key]);
serviceDate.value = undefined;
serviceEndDate.value = undefined;
recognizeTip.value = '';
recognized.value = null;
}
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
/** 调识别接口,只做展示,不直接改动用户已填内容 */
const recognize = async () => {
const file = firstProof.value;
if (!file) {
message.warning('请先上传证明材料');
return;
}
const fileUrl = file.url;
if (!fileUrl) {
message.warning('材料地址缺失,请重新上传');
return;
}
recognizing.value = true;
recognizeTip.value = '';
try {
const data = await recognizeVolunteerProof({
fileUrl,
fileName: file.name
});
if (data.enabled === false) {
recognizeTip.value = data.message || '材料识别未启用';
recognized.value = null;
return;
}
if (data.success === false) {
recognizeTip.value = data.message || '识别失败,请手工填写';
recognized.value = null;
return;
}
recognized.value = data;
recognizeTip.value = data.message || '识别完成';
// 只填空白项:用户已经填过的内容不轻易覆盖,避免识别偏差把正确值改错
fillBlanks(data);
} catch (e: any) {
recognizeTip.value = e?.message || '识别服务暂不可用,请手工填写';
recognized.value = null;
} finally {
recognizing.value = false;
}
};
/** 仅补空字段 */
const fillBlanks = (data: Record<string, any>) => {
if (!form.projectName && data.projectName)
form.projectName = data.projectName;
if (!form.orgName && data.orgName) form.orgName = data.orgName;
if (!form.servicePlace && data.servicePlace)
form.servicePlace = data.servicePlace;
if (!form.hours && data.hours) form.hours = Number(data.hours);
if (!serviceDate.value && data.serviceDate)
serviceDate.value = data.serviceDate;
if (!serviceEndDate.value && data.serviceEndDate)
serviceEndDate.value = data.serviceEndDate;
};
/** 用户确认后用识别结果全量覆盖 */
const applyRecognized = () => {
const data = recognized.value;
if (!data) return;
if (data.projectName) form.projectName = data.projectName;
if (data.orgName) form.orgName = data.orgName;
if (data.servicePlace) form.servicePlace = data.servicePlace;
if (data.hours) form.hours = Number(data.hours);
if (data.serviceDate) serviceDate.value = data.serviceDate;
if (data.serviceEndDate) serviceEndDate.value = data.serviceEndDate;
message.success('已用识别结果覆盖当前填写');
};
const normalizeDate = (value: any) => {
if (!value) return undefined;
return dayjs.isDayjs(value) ? value.format('YYYY-MM-DD') : String(value);
};
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
form.serviceDate = normalizeDate(serviceDate.value);
form.serviceEndDate = normalizeDate(serviceEndDate.value);
const msg = await submitVolunteerRecord(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '提交失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
<style lang="less" scoped>
.recognize-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.recognize-result {
margin-top: 12px;
}
.recognize-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.recognize-label {
color: #8c8c8c;
}
.recognize-diff {
margin-left: 6px;
color: #fa8c16;
}
</style>