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