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

351 lines
10 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>
<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>