- 新增志愿证明材料识别功能,支持自动识别材料并回填关键信息 - 在志愿时长编辑页面添加材料识别按钮及识别结果展示 - 新增志愿服务证明模块,支持查询、生成及下载年度志愿服务证明PDF - 志愿时长列表及审核详情中新增“服务地点”字段显示 - 志愿时长导入功能支持“服务地点”字段,完善导入表头与数据映射 - 志愿时长详情、审核流程等界面优化,增加识别材料公章状态及流程节点提示 - 志愿时长待审核项支持用户撤回操作,撤回后记录被删除需重新录入 - 社团年度报告相关接口新增,实现社团年度报告生成及分页查询 - 调整相关样式与界面布局,提升用户体验与信息展示完整性
351 lines
10 KiB
Vue
351 lines
10 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="clubId"
|
||
show-search
|
||
option-filter-prop="label"
|
||
style="width: 260px"
|
||
placeholder="选择社团"
|
||
:options="clubOptions"
|
||
:loading="clubLoading"
|
||
@change="loadPreview"
|
||
/>
|
||
<a-select
|
||
v-model:value="year"
|
||
style="width: 140px"
|
||
:options="yearOptions"
|
||
@change="loadPreview"
|
||
/>
|
||
<a-button type="primary" :loading="loading" @click="loadPreview">查询</a-button>
|
||
<a-button
|
||
type="primary"
|
||
ghost
|
||
:disabled="!clubId"
|
||
:loading="generating"
|
||
@click="generate"
|
||
>
|
||
{{ preview.generated ? '重新生成' : '生成报告' }}
|
||
</a-button>
|
||
<a-button v-if="preview.downloadUrl" type="link" @click="download">
|
||
下载 PDF
|
||
</a-button>
|
||
</a-space>
|
||
|
||
<a-empty v-if="!clubId" description="请选择需要生成年度报告的社团" />
|
||
|
||
<a-spin v-else :spinning="loading">
|
||
<a-descriptions bordered size="small" :column="3" class="club-info">
|
||
<a-descriptions-item label="社团名称">{{ preview.clubName || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="社团类别">{{ preview.category || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="挂靠单位">{{ preview.collegeName || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="指导老师">{{ preview.advisorName || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="社长">{{ preview.presidentName || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="报告状态">
|
||
<a-tag v-if="preview.generated" color="green">已生成</a-tag>
|
||
<a-tag v-else>未生成</a-tag>
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<a-row :gutter="[16, 16]" style="margin-top: 16px">
|
||
<a-col
|
||
v-for="metric in metricCards"
|
||
:key="metric.label"
|
||
:xs="24"
|
||
:sm="12"
|
||
:md="8"
|
||
:lg="4"
|
||
>
|
||
<div class="metric-card" :class="metric.tone">
|
||
<div class="metric-label">{{ metric.label }}</div>
|
||
<div class="metric-value">{{ metric.value }}</div>
|
||
</div>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-row :gutter="[16, 16]" style="margin-top: 16px">
|
||
<a-col :xs="24" :lg="12">
|
||
<a-card :bordered="false" title="活动类别分布" size="small">
|
||
<a-empty v-if="!categoryRows.length" description="本年度暂无活动数据" />
|
||
<VChart
|
||
v-else
|
||
ref="categoryChartRef"
|
||
class="chart"
|
||
:option="categoryOption"
|
||
autoresize
|
||
/>
|
||
</a-card>
|
||
</a-col>
|
||
<a-col :xs="24" :lg="12">
|
||
<a-card :bordered="false" title="成员角色构成" size="small">
|
||
<a-empty v-if="!roleRows.length" description="当前无在社成员" />
|
||
<VChart
|
||
v-else
|
||
ref="roleChartRef"
|
||
class="chart"
|
||
:option="roleOption"
|
||
autoresize
|
||
/>
|
||
</a-card>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-card :bordered="false" title="月度活动趋势" size="small" style="margin-top: 16px">
|
||
<VChart
|
||
ref="monthChartRef"
|
||
class="chart chart-wide"
|
||
:option="monthOption"
|
||
autoresize
|
||
/>
|
||
</a-card>
|
||
|
||
<a-card :bordered="false" title="年度小结" size="small" style="margin-top: 16px">
|
||
<a-textarea
|
||
v-model:value="narrative"
|
||
:rows="5"
|
||
placeholder="可在此调整自动生成的小结,生成 PDF 时会使用修改后的内容"
|
||
/>
|
||
<div class="ele-text-secondary" style="font-size: 12px; margin-top: 6px">
|
||
小结由系统按年度数据自动生成,可自由编辑后再生成报告。
|
||
</div>
|
||
</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 VChart from 'vue-echarts';
|
||
import useEcharts from '@/utils/use-echarts';
|
||
import { listClubs, getClubReportPreview, generateClubReport } from '@/api/growth/club';
|
||
import type { Club } from '@/api/growth/club';
|
||
|
||
const clubs = ref<Club[]>([]);
|
||
const clubLoading = ref(false);
|
||
const clubId = ref<number>();
|
||
const year = ref(dayjs().year());
|
||
const loading = ref(false);
|
||
const generating = ref(false);
|
||
const preview = ref<Record<string, any>>({});
|
||
const narrative = ref('');
|
||
|
||
const categoryChartRef = ref<InstanceType<typeof VChart> | null>(null);
|
||
const roleChartRef = ref<InstanceType<typeof VChart> | null>(null);
|
||
const monthChartRef = ref<InstanceType<typeof VChart> | null>(null);
|
||
useEcharts([categoryChartRef, roleChartRef, monthChartRef]);
|
||
|
||
const clubOptions = computed(() =>
|
||
clubs.value.map((item) => ({
|
||
label: item.clubName,
|
||
value: item.id
|
||
}))
|
||
);
|
||
|
||
const yearOptions = computed(() => {
|
||
const current = dayjs().year();
|
||
return Array.from({ length: 6 }, (_, index) => {
|
||
const value = current - index;
|
||
return { label: `${value} 年`, value };
|
||
});
|
||
});
|
||
|
||
const metricCards = computed(() => {
|
||
const metrics = preview.value.metrics || {};
|
||
return [
|
||
{ label: '年度活动场次', value: metrics['年度活动场次'] ?? 0, tone: '' },
|
||
{ label: '累计参与人次', value: metrics['累计参与人次'] ?? 0, tone: 'accent' },
|
||
{ label: '在社成员人数', value: metrics['在社成员人数'] ?? 0, tone: 'warm' },
|
||
{ label: '本年新增成员', value: metrics['本年新增成员'] ?? 0, tone: 'success' },
|
||
{ label: '场地使用次数', value: metrics['场地使用次数'] ?? 0, tone: 'accent' },
|
||
{ label: '活动预算合计(元)', value: metrics['活动预算合计(元)'] ?? 0, tone: 'warm' }
|
||
];
|
||
});
|
||
|
||
const categoryRows = computed(() =>
|
||
Object.entries(preview.value.activityByCategory || {}).map(([name, value]) => ({
|
||
name,
|
||
value
|
||
}))
|
||
);
|
||
|
||
const roleRows = computed(() =>
|
||
Object.entries(preview.value.memberByRole || {}).map(([name, value]) => ({
|
||
name,
|
||
value
|
||
}))
|
||
);
|
||
|
||
const monthRows = computed(() =>
|
||
Object.entries(preview.value.activityByMonth || {}).map(([name, value]) => ({
|
||
name,
|
||
value
|
||
}))
|
||
);
|
||
|
||
const categoryOption = computed(() => ({
|
||
tooltip: { trigger: 'item' },
|
||
legend: { bottom: 0 },
|
||
series: [
|
||
{
|
||
type: 'pie',
|
||
radius: ['42%', '68%'],
|
||
avoidLabelOverlap: true,
|
||
label: { formatter: '{b}: {c} 场' },
|
||
data: categoryRows.value
|
||
}
|
||
]
|
||
}));
|
||
|
||
const roleOption = computed(() => ({
|
||
tooltip: { trigger: 'item' },
|
||
legend: { bottom: 0 },
|
||
series: [
|
||
{
|
||
type: 'pie',
|
||
radius: '62%',
|
||
label: { formatter: '{b}: {c} 人' },
|
||
data: roleRows.value
|
||
}
|
||
]
|
||
}));
|
||
|
||
const monthOption = computed(() => ({
|
||
tooltip: { trigger: 'axis' },
|
||
grid: { left: 40, right: 24, top: 24, bottom: 32 },
|
||
xAxis: { type: 'category', data: monthRows.value.map((item) => item.name) },
|
||
yAxis: { type: 'value', minInterval: 1 },
|
||
series: [
|
||
{
|
||
type: 'line',
|
||
smooth: true,
|
||
areaStyle: { opacity: 0.15 },
|
||
data: monthRows.value.map((item) => item.value)
|
||
}
|
||
]
|
||
}));
|
||
|
||
const loadClubs = async () => {
|
||
clubLoading.value = true;
|
||
try {
|
||
clubs.value = await listClubs({ status: 1 });
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载社团列表失败');
|
||
} finally {
|
||
clubLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const loadPreview = async () => {
|
||
if (!clubId.value) {
|
||
preview.value = {};
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
try {
|
||
preview.value = await getClubReportPreview({ clubId: clubId.value, year: year.value });
|
||
narrative.value = preview.value.narrative || '';
|
||
} catch (e: any) {
|
||
preview.value = {};
|
||
narrative.value = '';
|
||
message.error(e?.message || '加载报告数据失败');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const generate = async () => {
|
||
if (!clubId.value) {
|
||
message.warning('请先选择社团');
|
||
return;
|
||
}
|
||
generating.value = true;
|
||
try {
|
||
const report = await generateClubReport({
|
||
clubId: clubId.value,
|
||
year: year.value,
|
||
narrative: narrative.value || undefined
|
||
});
|
||
preview.value = {
|
||
...preview.value,
|
||
generated: true,
|
||
downloadUrl: report.downloadUrl || report.fileUrl,
|
||
generatedAt: report.updateTime
|
||
};
|
||
message.success('报告已生成,可下载 PDF');
|
||
} catch (e: any) {
|
||
message.error(e?.message || '生成报告失败');
|
||
} finally {
|
||
generating.value = false;
|
||
}
|
||
};
|
||
|
||
const download = () => {
|
||
const url = preview.value.downloadUrl;
|
||
if (!url) {
|
||
message.warning('暂无可下载的报告,请先生成');
|
||
return;
|
||
}
|
||
window.open(url, '_blank');
|
||
};
|
||
|
||
onMounted(loadClubs);
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.club-info {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.metric-card {
|
||
padding: 16px 18px;
|
||
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-card.success {
|
||
border-left-color: #52c41a;
|
||
}
|
||
|
||
.metric-label {
|
||
font-size: 13px;
|
||
color: #8c8c8c;
|
||
}
|
||
|
||
.metric-value {
|
||
margin-top: 6px;
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: #262626;
|
||
}
|
||
|
||
.chart {
|
||
height: 260px;
|
||
}
|
||
|
||
.chart-wide {
|
||
height: 240px;
|
||
}
|
||
</style>
|