feat(volunteer): 新增志愿服务证明材料识别及年度证明功能

- 新增志愿证明材料识别功能,支持自动识别材料并回填关键信息
- 在志愿时长编辑页面添加材料识别按钮及识别结果展示
- 新增志愿服务证明模块,支持查询、生成及下载年度志愿服务证明PDF
- 志愿时长列表及审核详情中新增“服务地点”字段显示
- 志愿时长导入功能支持“服务地点”字段,完善导入表头与数据映射
- 志愿时长详情、审核流程等界面优化,增加识别材料公章状态及流程节点提示
- 志愿时长待审核项支持用户撤回操作,撤回后记录被删除需重新录入
- 社团年度报告相关接口新增,实现社团年度报告生成及分页查询
- 调整相关样式与界面布局,提升用户体验与信息展示完整性
This commit is contained in:
2026-11-06 12:04:32 +08:00
parent d82a3b5426
commit d8e7e2a2a8
21 changed files with 1980 additions and 103 deletions
+68
View File
@@ -0,0 +1,68 @@
# AI 学情分析接口契约(前端已接入,后端待实现)
青马学员档案里的「AI 学情分析」入口复用现有 AI 流式链路(`utils/ai-stream.ts`),
后端需要新增一个与 `activityOutlineGen` / `manuscriptGen` 同构的端点。
## 1. 端点
| 用途 | 方法 | 路径 |
| --- | --- | --- |
| 流式分析(页面实际调用) | POST | `/api/ai/qmgcStudentAnalysis/stream` |
| 一次性返回(调试/他端) | POST | `/api/ai/qmgcStudentAnalysis` |
响应格式与现有 AI 链路一致:
- `/stream` 返回 `text/event-stream`,每帧一个 `data:` 行,帧内容为 JSON;
- 正文帧:`{ "answer": "<markdown 片段>" }`,也兼容 `{ "notice": "<片段>" }`;
- 心跳/占位帧:`{ "loading": "true" }`,前端忽略;
- 结束帧:`{ "answer": "__END__" }`;
- 失败时可用 `{ "answer": "<片段>", "reset": "true" }` 覆盖已推内容,
末帧 `status: "failed"`,前端会显示统一失败文案。
## 2. 入参
```json
{
"studentId": 123,
"focus": "thought | difficulty | advice | undefined",
"context": {
"studentName": "张三",
"studentNo": "20230001",
"collegeName": "第一临床医学院",
"className": "临床医学 2023 级 1 班",
"batch": "2026 年第 1 期",
"stage": "study",
"totalScore": 86,
"materialCount": 5,
"scoredMaterialCount": 4,
"taskDoneCount": 6,
"taskTotal": 8,
"materialTexts": ["【学习心得】……"],
"taskTexts": ["完成线上团课(已完成)"],
"scoreTexts": ["理论/课堂表现:90 分"]
}
}
```
`context` 是前端已加载数据的摘要,后端实现了按 `studentId` 自行取数后可以忽略它;
保留它是为了后端聚合尚未覆盖某类数据时,AI 仍能拿到培养材料正文。
`focus` 为空表示综合维度;调度到 Dify 时可映射成不同的提示词变量。
## 3. 数据口径
第一期可用的输入只有青马域已有数据:
- `gxmu_qmgc_material`:培养材料标题与正文、评分与评语;
- 学员任务分配记录:任务标题、完成状态;
- `gxmu_qmgc_score`:考核维度、评分项、得分与评语。
**系统目前没有考勤、签到、课堂互动类数据。** 需求原文提到的「课堂互动数据」
在采集入口落地前不参与分析,前端弹窗里已明确向教师提示这一点。后续若新增采集,
在 `context` 中补充对应摘要字段即可,无需改动流式协议。
## 4. 历史记录
如果要把分析结果写入 `ai_chat_list`,`chat_type` 需要新增一个取值
(如 `student_analysis`),并同步前端 `utils/ai-chat-type.ts` 的类型与中文名;
本次前端改动未接入历史记录,属于可选增强。
+3 -1
View File
@@ -137,7 +137,9 @@ src/views/system/{messageTemplate,messageLog}/
| 项 | 说明 |
| --- | --- |
| 志愿证明 PDF | 当前只做数据汇总与提示,模板渲染与电子章在 P1 接入(需学校提供模板与印章图) |
| 志愿证明 PDF | **已实现**:按年度汇总已通过审核的志愿时长,生成含志愿者姓名、学院、专业、班级、学号、活动名称、活动时间、活动地点、时长、数据来源、总时长的 PDF,自动加水印,不设电子章(正文留公章位)。接口 `/api/gxmu/volunteer/certificate/*` |
| 社团年度数据报告 | **已实现**:按年度汇总活动、成员、场地数据,生成含核心指标、类别分布、月度趋势、成员构成图表与文字小结的 PDF。入口「社团与志愿 → 年度数据报告」(`/club/report`),接口 `/api/gxmu/club/report/*`。文字小结可按需编辑后再生成 |
| 志愿记录服务地点 | 新增 `gxmu_volunteer_record.service_place`,录入表单与导入模板均支持;证明材料「活动地点」列取此值 |
| OCR 公章识别 | 字段已预留(`ocr_status` / `ocr_org` / `ocr_raw`),未接 OCR 服务;活动审批暂由人工核验材料 |
| 审核流并行 | **已实现**,见下方「补充交付」 |
| 学分自动获取 | 第二课堂/桂志愿暂无 API,先走 Excel 导入,`source` + `external_id` 已为后续对接预留 |
+4
View File
@@ -84,6 +84,10 @@
AI 学员学习情况分析、AI 定制培养建议、OCR 公章识别、AI 数字人、移动端接口整理。
> AI 学员学习情况分析的前端入口已落在青马学员档案(列表操作列「AI 分析」+ 档案抽屉按钮),
> 复用 `utils/ai-stream.ts` 流式链路,接口契约见 `docs/ai-qmgc-student-analysis-contract.md`。
> 归入 AI 能力域、入口留在业务域,不作为独立 AI 菜单页。
## 四、数据模型
统一规范:`id BIGINT AUTO_INCREMENT`、`tenant_id`、`user_id`、`deleted`、`create_time`、`update_time`。
+24
View File
@@ -0,0 +1,24 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
import type {
QmgcStudentAnalysisParams,
QmgcStudentAnalysisResponse
} from './model';
/**
* 青马学员学情分析(非流式)。
*
* 页面实际走 `/stream`(见 `@/utils/ai-stream`),这里保留普通 POST 便于
* 其他端或调试直接取完整结果。
*/
export async function qmgcStudentAnalysis(data: QmgcStudentAnalysisParams) {
const res = await request.post<ApiResult<QmgcStudentAnalysisResponse>>(
MODULES_API_URL + '/ai/qmgcStudentAnalysis',
data
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message || '分析失败'));
}
@@ -0,0 +1,21 @@
/** 青马学员学情分析入参 */
export interface QmgcStudentAnalysisParams {
/** 青马学员档案 id */
studentId: number;
/** 分析侧重点,为空时按「思想动态 + 学习难点」通用维度分析 */
focus?: string;
}
/** 青马学员学情分析结果 */
export interface QmgcStudentAnalysisResponse {
answer?: string;
/**
* 上游工作流状态。失败时为 `'failed'`,前端据此区分失败文案与正文
* (见 `@/utils/ai-result`)。
*/
status?: string;
conversationId?: string;
messageId?: string;
raw?: Record<string, any>;
[key: string]: any;
}
+56
View File
@@ -81,6 +81,62 @@ export interface ClubActivityParam extends PageParam {
startTimeEnd?: string;
}
/** 社团年度数据报告 */
export interface ClubReport {
id?: number;
clubId?: number;
clubName?: string;
year?: number;
summary?: string;
narrative?: string;
fileId?: number;
fileUrl?: string;
downloadUrl?: string;
status?: number;
createTime?: string;
updateTime?: string;
}
/** 社团年度报告数据预览 */
export async function getClubReportPreview(params: { clubId: number; year?: number }) {
const res = await request.get<ApiResult<Record<string, any>>>(
MODULES_API_URL + '/gxmu/club/report/preview',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 生成社团年度数据报告 PDF */
export async function generateClubReport(data: {
clubId?: number;
year: number;
narrative?: string;
}) {
const res = await request.post<ApiResult<ClubReport>>(
MODULES_API_URL + '/gxmu/club/report/generate',
data
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 分页查询社团年度报告 */
export async function pageClubReports(params?: Record<string, any>) {
const res = await request.get<ApiResult<PageResult<ClubReport>>>(
MODULES_API_URL + '/gxmu/club/report/page',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 场地 */
export interface Venue {
id?: number;
+140
View File
@@ -15,11 +15,19 @@ export interface VolunteerRecord {
externalId?: string;
projectName?: string;
orgName?: string;
servicePlace?: string;
hours?: number;
serviceDate?: string;
serviceEndDate?: string;
content?: string;
proofFiles?: string;
ocrStatus?: number;
ocrOrg?: string;
ocrProjectName?: string;
ocrPlace?: string;
ocrHours?: number;
ocrHasSeal?: number;
ocrRaw?: string;
status?: number;
reviewRemark?: string;
reviewerId?: number;
@@ -53,6 +61,17 @@ export async function pageVolunteerRecords(params: VolunteerRecordParam) {
return Promise.reject(new Error(res.data.message));
}
/** 按 id 查询志愿时长详情 */
export async function getVolunteerRecord(id: number) {
const res = await request.get<ApiResult<VolunteerRecord>>(
MODULES_API_URL + '/gxmu/volunteer/' + id
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 个人录入志愿时长 */
export async function submitVolunteerRecord(data: VolunteerRecord) {
const res = await request.post<ApiResult<unknown>>(
@@ -134,3 +153,124 @@ export async function getMyVolunteerProjectStats(year?: number) {
}
return Promise.reject(new Error(res.data.message));
}
/** 已生成的年度志愿服务证明 */
export interface VolunteerCertificate {
id?: number;
userId?: number;
studentNo?: string;
studentName?: string;
year?: number;
totalHours?: number;
serialNo?: string;
fileId?: number;
fileUrl?: string;
downloadUrl?: string;
status?: number;
createTime?: string;
updateTime?: string;
}
/** 年度志愿服务证明数据核对 */
export async function getVolunteerCertificateSummary(params: {
year?: number;
studentNo?: string;
}) {
const res = await request.get<ApiResult<Record<string, any>>>(
MODULES_API_URL + '/gxmu/volunteer/certificate/summary',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 生成年度志愿服务证明 PDF */
export async function generateVolunteerCertificate(data: {
year: number;
studentNo?: string;
}) {
const res = await request.post<ApiResult<VolunteerCertificate>>(
MODULES_API_URL + '/gxmu/volunteer/certificate/generate',
data
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 分页查询已生成的志愿证明 */
export async function pageVolunteerCertificates(params: Record<string, any>) {
const res = await request.get<ApiResult<PageResult<VolunteerCertificate>>>(
MODULES_API_URL + '/gxmu/volunteer/certificate/page',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 我的年度志愿服务证明列表 */
export async function getMyVolunteerCertificates() {
const res = await request.get<ApiResult<VolunteerCertificate[]>>(
MODULES_API_URL + '/gxmu/volunteer/certificate/my'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 审核进度节点 */
export interface VolunteerReviewNode {
id?: number;
nodeName?: string;
reviewerId?: number;
reviewerName?: string;
status?: number;
content?: string;
parallelGroup?: number;
passMode?: string;
createTime?: string;
updateTime?: string;
}
/** 志愿记录审核进度 */
export async function getVolunteerProgress(id: number) {
const res = await request.get<ApiResult<Record<string, any>>>(
MODULES_API_URL + '/gxmu/volunteer/' + id + '/progress'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 撤回本人待审核的志愿时长 */
export async function withdrawVolunteerRecord(id: number) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/volunteer/' + id + '/withdraw'
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 识别证明材料并回填关键信息 */
export async function recognizeVolunteerProof(data: {
fileUrl: string;
fileName?: string;
}) {
const res = await request.post<ApiResult<Record<string, any>>>(
MODULES_API_URL + '/gxmu/volunteer/proof/recognize',
data
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+14
View File
@@ -134,6 +134,20 @@ export function streamActivityOutlineGen(
});
}
/** 青马学员学情分析(HTTP 流式) */
export function streamQmgcStudentAnalysis(
data: Record<string, unknown>,
onMessage: (raw: string) => void,
signal?: AbortSignal
): Promise<void> {
return streamAiRequest({
path: '/ai/qmgcStudentAnalysis/stream',
data,
onMessage,
signal
});
}
/** 创意助手(HTTP 流式) */
export function streamCreativeAssistant(
data: Record<string, unknown>,
+350
View File
@@ -0,0 +1,350 @@
<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>
+142 -34
View File
@@ -6,7 +6,7 @@
type="info"
show-icon
style="margin-bottom: 16px"
message="年度志愿服务证明按「已通过审核」的志愿时长自动汇总生成。证书编号由后端按年度顺序生成,模板与电子章在 P1 阶段接入。"
message="年度志愿服务证明按「已通过审核」的志愿时长自动汇总生成,下载为 PDF。证书编号由系统按年度生成,加盖公章后有效。"
/>
<a-space style="margin-bottom: 16px" wrap>
@@ -20,16 +20,55 @@
v-model:value="studentNo"
allow-clear
style="width: 180px"
placeholder="学号(管理员)"
placeholder="学号(管理员代查)"
@pressEnter="loadData"
/>
<a-button type="primary" :loading="loading" @click="loadData">查询</a-button>
<a-button :disabled="!summary.totalHours" :loading="generating" @click="generate">
生成证明
<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-row :gutter="[16, 16]">
<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>
@@ -40,7 +79,7 @@
<a-col :xs="24" :sm="8">
<div class="metric-card accent">
<div class="metric-label">志愿项目数</div>
<div class="metric-value">{{ projects.length }}</div>
<div class="metric-value">{{ summary.projectCount ?? 0 }}</div>
<div class="metric-foot">按项目名称去重</div>
</div>
</a-col>
@@ -48,18 +87,21 @@
<div class="metric-card warm">
<div class="metric-label">记录条数</div>
<div class="metric-value">{{ summary.approvedCount ?? 0 }}</div>
<div class="metric-foot">已通过的志愿记录</div>
<div class="metric-foot">该年度已通过的志愿记录</div>
</div>
</a-col>
</a-row>
<a-card :bordered="false" class="project-card" title="项目明细">
<a-empty v-if="!projects.length" description="该年度暂无可生成证明的志愿记录" />
<a-card :bordered="false" class="project-card" title="服务明细">
<a-empty
v-if="!summary.items?.length"
description="该年度暂无可生成证明的志愿记录"
/>
<a-table
v-else
row-key="projectName"
row-key="id"
size="small"
:data-source="projects"
:data-source="summary.items"
:columns="projectColumns"
:pagination="false"
/>
@@ -76,9 +118,8 @@
import { message } from 'ant-design-vue';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import {
getMyVolunteerSummary,
getMyVolunteerProjectStats,
getMyVolunteerTotal
getVolunteerCertificateSummary,
generateVolunteerCertificate
} from '@/api/growth/volunteer';
const loading = ref(false);
@@ -86,7 +127,6 @@
const year = ref(dayjs().year());
const studentNo = ref('');
const summary = ref<Record<string, any>>({});
const projects = ref<Record<string, any>[]>([]);
const yearOptions = computed(() => {
const current = dayjs().year();
@@ -97,44 +137,112 @@
});
const projectColumns: ColumnItem[] = [
{ title: '志愿项目', dataIndex: 'projectName', key: 'projectName' },
{ title: '组织单位', dataIndex: 'orgName', key: 'orgName', width: 260 },
{ title: '时长', dataIndex: 'hours', key: 'hours', width: 100, align: 'center' },
{ title: '记录数', dataIndex: 'count', key: 'count', width: 100, align: 'center' }
{
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 {
const [summaryData, projectData, total] = await Promise.all([
getMyVolunteerSummary(),
getMyVolunteerProjectStats(year.value),
getMyVolunteerTotal(year.value)
]);
summary.value = { ...summaryData, totalHours: total ?? 0 };
projects.value = projectData || [];
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 = () => {
const generate = async () => {
generating.value = true;
// 证明文件由后端模板渲染,P0 先给出数据核对结果,文件下载在模板接入后开放
setTimeout(() => {
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;
message.info(
`已汇总 ${year.value} 年 ${summary.value.totalHours ?? 0} 小时志愿时长,证明材料模板接入后可在此下载 PDF`
);
}, 300);
}
};
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;
@@ -18,14 +18,33 @@
/>
<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-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-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
@@ -61,25 +80,86 @@
</a-col>
</a-row>
<a-form-item label="服务内容" name="content">
<a-textarea v-model:value="form.content" :rows="3" allow-clear placeholder="简述服务内容" />
<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 { reactive, ref, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
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 } from '@/api/growth/volunteer';
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<{
@@ -89,16 +169,71 @@
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' }],
projectName: [
{ required: true, message: '请输入志愿项目名称', trigger: 'blur' }
],
hours: [{ required: true, message: '请输入服务时长', trigger: 'change' }],
serviceDate: [{ required: true, message: '请选择服务日期', trigger: 'change' }],
proofFiles: [{ required: true, message: '请上传证明材料', trigger: 'change' }]
serviceDate: [
{ required: true, message: '请选择服务日期', trigger: 'change' }
],
proofFiles: [
{ required: true, message: '请上传证明材料', trigger: 'change' }
]
};
watch(
@@ -108,12 +243,82 @@
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);
@@ -140,3 +345,31 @@
.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>
@@ -35,6 +35,7 @@
{ header: '姓名', key: 'studentName', required: true },
{ header: '志愿项目', key: 'projectName', required: true },
{ header: '组织单位', key: 'orgName' },
{ header: '服务地点', key: 'servicePlace' },
{ header: '服务时长', key: 'hours', required: true },
{ header: '服务日期', key: 'serviceDate', required: true },
{ header: '外部编号', key: 'externalId' }
@@ -45,6 +46,7 @@
姓名: '张三',
志愿项目: '社区义诊志愿服务',
组织单位: '广西医科大学青年志愿者协会',
服务地点: '南宁市青秀区社区服务中心',
服务时长: '4',
服务日期: '2025-10-12',
外部编号: 'gzy-2025-0001'
@@ -64,6 +66,7 @@
studentName: String(row['姓名'] || '').trim(),
projectName: String(row['志愿项目'] || '').trim(),
orgName: String(row['组织单位'] || '').trim() || undefined,
servicePlace: String(row['服务地点'] || '').trim() || undefined,
hours: Number(row['服务时长'] || 0),
serviceDate: normalizeDate(row['服务日期']),
externalId: String(row['外部编号'] || '').trim() || undefined,
+82 -20
View File
@@ -16,7 +16,9 @@
</div>
<div class="metric">
<span class="metric-label">待审核</span>
<span class="metric-value warning">{{ summary.pendingCount ?? 0 }}</span>
<span class="metric-value warning">{{
summary.pendingCount ?? 0
}}</span>
<span class="metric-unit">条</span>
</div>
</div>
@@ -25,7 +27,10 @@
<template #icon><plus-outlined /></template>
录入时长
</a-button>
<a-button v-if="hasPermission('gxmu:volunteer:import')" @click="showImport = true">
<a-button
v-if="hasPermission('gxmu:volunteer:import')"
@click="showImport = true"
>
导入桂志愿数据
</a-button>
<a-button :loading="loading" @click="reload">刷新</a-button>
@@ -37,7 +42,7 @@
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
:scroll="{ x: 1360 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
@@ -93,11 +98,22 @@
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">查看</a>
<template v-if="record.status === 0 && isMine(record)">
<a-divider type="vertical" />
<a-popconfirm
title="撤回后该记录将被删除,需要重新录入,确定撤回吗?"
@confirm="withdraw(record)"
>
<a>撤回</a>
</a-popconfirm>
</template>
<template v-if="record.status === 0 && canReview">
<a-divider type="vertical" />
<a @click="doReview(record, 1)">通过</a>
<a-divider type="vertical" />
<a class="ele-text-danger" @click="doReview(record, 2)">驳回</a>
<a class="ele-text-danger" @click="doReview(record, 2)"
>驳回</a
>
</template>
<a-popconfirm
v-if="record.status !== 1"
@@ -113,15 +129,9 @@
</ele-pro-table>
</a-card>
<VolunteerEdit
v-model:visible="showSubmit"
@done="onSubmitted"
/>
<VolunteerEdit v-model:visible="showSubmit" @done="onSubmitted" />
<VolunteerImport
v-model:visible="showImport"
@done="onSubmitted"
/>
<VolunteerImport v-model:visible="showImport" @done="onSubmitted" />
<a-modal
v-model:visible="showDetail"
@@ -130,19 +140,34 @@
width="640px"
>
<a-descriptions v-if="current" :column="1" bordered size="small">
<a-descriptions-item label="项目名称">{{ current.projectName }}</a-descriptions-item>
<a-descriptions-item label="组织单位">{{ current.orgName || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务时长">{{ current.hours }} 小时</a-descriptions-item>
<a-descriptions-item label="项目名称">{{
current.projectName
}}</a-descriptions-item>
<a-descriptions-item label="组织单位">{{
current.orgName || '-'
}}</a-descriptions-item>
<a-descriptions-item label="服务地点">{{
current.servicePlace || '-'
}}</a-descriptions-item>
<a-descriptions-item label="服务时长"
>{{ current.hours }} 小时</a-descriptions-item
>
<a-descriptions-item label="服务日期">
{{ current.serviceDate || '-' }}
<span v-if="current.serviceEndDate"> 至 {{ current.serviceEndDate }}</span>
<span v-if="current.serviceEndDate">
至 {{ current.serviceEndDate }}</span
>
</a-descriptions-item>
<a-descriptions-item label="服务内容">{{ current.content || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务内容">{{
current.content || '-'
}}</a-descriptions-item>
<a-descriptions-item label="证明材料">
<AttachmentList :value="current.proofFiles" />
</a-descriptions-item>
<a-descriptions-item label="审核状态">
<a-tag :color="statusColor(current.status)">{{ statusLabel(current.status) }}</a-tag>
<a-tag :color="statusColor(current.status)">{{
statusLabel(current.status)
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="current.reviewRemark" label="审核意见">
{{ current.reviewRemark }}
@@ -170,10 +195,29 @@
pageVolunteerRecords,
removeVolunteerRecord,
reviewVolunteerRecord,
withdrawVolunteerRecord,
getMyVolunteerSummary
} from '@/api/growth/volunteer';
import type { VolunteerRecord } from '@/api/growth/volunteer';
import { hasPermission, hasAnyRole } from '@/utils/permission';
import { useUserStore } from '@/store/modules/user';
const userStore = useUserStore();
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
const isMine = (record: VolunteerRecord) =>
!!record.userId && record.userId === loginUserId.value;
const withdraw = async (record: VolunteerRecord) => {
if (!record.id) return;
try {
const msg = await withdrawVolunteerRecord(record.id);
message.success(msg);
reload();
await loadSummary();
} catch (e: any) {
message.error(e?.message || '撤回失败');
}
};
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const loading = ref(false);
@@ -212,8 +256,26 @@
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '志愿项目', dataIndex: 'projectName', key: 'projectName', ellipsis: true },
{ title: '组织单位', dataIndex: 'orgName', key: 'orgName', width: 160, ellipsis: true },
{
title: '志愿项目',
dataIndex: 'projectName',
key: 'projectName',
ellipsis: true
},
{
title: '组织单位',
dataIndex: 'orgName',
key: 'orgName',
width: 160,
ellipsis: true
},
{
title: '服务地点',
dataIndex: 'servicePlace',
key: 'servicePlace',
width: 160,
ellipsis: true
},
{ title: '时长', key: 'hours', width: 90, align: 'center' },
{
title: '服务日期',
+251 -18
View File
@@ -37,7 +37,9 @@
<span class="hours-text">{{ record.hours }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
<a-tag :color="statusColor(record.status)">{{
statusLabel(record.status)
}}</a-tag>
</template>
<template v-else-if="column.key === 'proofFiles'">
<AttachmentList :value="record.proofFiles" :max-count="2" />
@@ -55,24 +57,116 @@
v-model:visible="showReview"
title="志愿时长审核"
:confirm-loading="submitting"
width="640px"
:ok-button-props="{ disabled: !myPendingNode }"
:ok-text="myPendingNode ? '提交审核' : '当前无待办'"
width="720px"
@ok="submitReview"
>
<a-descriptions v-if="current" :column="1" bordered size="small">
<a-descriptions-item label="姓名">{{ current.studentName }}</a-descriptions-item>
<a-descriptions-item label="学号">{{ current.studentNo }}</a-descriptions-item>
<a-descriptions-item label="志愿项目">{{ current.projectName }}</a-descriptions-item>
<a-descriptions-item label="组织单位">{{ current.orgName || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务时长">{{ current.hours }} 小时</a-descriptions-item>
<a-descriptions-item label="姓名">{{
current.studentName
}}</a-descriptions-item>
<a-descriptions-item label="学号">{{
current.studentNo
}}</a-descriptions-item>
<a-descriptions-item label="志愿项目">{{
current.projectName
}}</a-descriptions-item>
<a-descriptions-item label="组织单位">{{
current.orgName || '-'
}}</a-descriptions-item>
<a-descriptions-item label="服务地点">{{
current.servicePlace || '-'
}}</a-descriptions-item>
<a-descriptions-item label="服务时长"
>{{ current.hours }} 小时</a-descriptions-item
>
<a-descriptions-item label="服务日期">
{{ current.serviceDate || '-' }}
<span v-if="current.serviceEndDate"> 至 {{ current.serviceEndDate }}</span>
<span v-if="current.serviceEndDate">
至 {{ current.serviceEndDate }}</span
>
</a-descriptions-item>
<a-descriptions-item label="服务内容">{{ current.content || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务内容">{{
current.content || '-'
}}</a-descriptions-item>
<a-descriptions-item label="证明材料">
<AttachmentList :value="current.proofFiles" />
</a-descriptions-item>
</a-descriptions>
<a-card
v-if="ocrFields.length"
:bordered="false"
size="small"
class="ocr-card"
title="材料识别结果"
>
<template #extra>
<a-tag v-if="current?.ocrHasSeal === 1" color="green"
>已检出公章</a-tag
>
<a-tag v-else-if="current?.ocrHasSeal === 0" color="red"
>未检出公章</a-tag
>
<a-tag v-else>未识别</a-tag>
</template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item
v-for="item in ocrFields"
:key="item.label"
:label="item.label"
>
<span :class="{ 'ocr-diff': item.differs }">{{
item.value
}}</span>
<span v-if="item.differs" class="ocr-origin"
>(填写:{{ item.current }})</span
>
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card
v-if="progressNodes.length"
:bordered="false"
size="small"
class="flow-card"
title="审核流程"
>
<a-steps direction="vertical" size="small" :current="currentStep">
<a-step
v-for="node in progressNodes"
:key="node.id"
:status="stepStatus(node)"
>
<template #title>
<span>{{ node.nodeName || '审核' }}</span>
<span class="node-reviewer">{{
node.reviewerName || '未指定审核人'
}}</span>
<a-tag
v-if="node.reviewerId === loginUserId && node.status === 0"
color="processing"
style="margin-left: 8px"
>
待我审核
</a-tag>
</template>
<template #description>
<div v-if="node.status === 1"
>已通过 {{ formatTime(node.updateTime) }}</div
>
<div v-else-if="node.status === 2" class="ele-text-danger">
已驳回 {{ formatTime(node.updateTime) }}
</div>
<div v-else>待审核</div>
<div v-if="node.content">意见:{{ node.content }}</div>
</template>
</a-step>
</a-steps>
</a-card>
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="审核结果" required>
<a-radio-group v-model:value="reviewStatus">
@@ -94,9 +188,10 @@
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
@@ -104,11 +199,18 @@
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import {
pageVolunteerRecords,
reviewVolunteerRecord
reviewVolunteerRecord,
getVolunteerProgress
} from '@/api/growth/volunteer';
import type { VolunteerRecord } from '@/api/growth/volunteer';
import type {
VolunteerRecord,
VolunteerReviewNode
} from '@/api/growth/volunteer';
import { useUserStore } from '@/store/modules/user';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const userStore = useUserStore();
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
const status = ref<number>(0);
const searchText = ref('');
const showReview = ref(false);
@@ -116,6 +218,59 @@
const reviewStatus = ref(1);
const reviewRemark = ref('');
const submitting = ref(false);
const progressNodes = ref<VolunteerReviewNode[]>([]);
const progressLoading = ref(false);
/** 当前登录人负责的那个待审节点;为空表示这条记录不在我手上 */
const myPendingNode = computed(() =>
progressNodes.value.find(
(node) => node.reviewerId === loginUserId.value && node.status === 0
)
);
/** 步骤条当前步:第一个待审节点 */
const currentStep = computed(() => {
const index = progressNodes.value.findIndex((node) => node.status === 0);
return index < 0 ? progressNodes.value.length : index;
});
/** 识别结果与填写值的对照 */
const ocrFields = computed(() => {
const record = current.value;
if (
!record ||
record.ocrStatus === null ||
record.ocrStatus === undefined
) {
return [];
}
const rows = [
{
label: '活动名称',
value: record.ocrProjectName,
current: record.projectName
},
{ label: '组织单位', value: record.ocrOrg, current: record.orgName },
{
label: '服务地点',
value: record.ocrPlace,
current: record.servicePlace
},
{ label: '服务时长', value: record.ocrHours, current: record.hours }
];
return rows
.filter(
(row) =>
row.value !== null && row.value !== undefined && row.value !== ''
)
.map((row) => ({
...row,
differs:
row.current !== null &&
row.current !== undefined &&
String(row.current) !== String(row.value)
}));
});
const statusOptions = [
{ label: '待审核', value: 0 },
@@ -126,14 +281,43 @@
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '志愿项目', dataIndex: 'projectName', key: 'projectName', ellipsis: true },
{ title: '组织单位', dataIndex: 'orgName', key: 'orgName', width: 160, ellipsis: true },
{
title: '志愿项目',
dataIndex: 'projectName',
key: 'projectName',
ellipsis: true
},
{
title: '组织单位',
dataIndex: 'orgName',
key: 'orgName',
width: 160,
ellipsis: true
},
{ title: '时长', key: 'hours', width: 90, align: 'center' },
{ title: '服务日期', dataIndex: 'serviceDate', key: 'serviceDate', width: 120, align: 'center' },
{
title: '服务日期',
dataIndex: 'serviceDate',
key: 'serviceDate',
width: 120,
align: 'center'
},
{ title: '证明材料', key: 'proofFiles', width: 150 },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{ title: '审核意见', dataIndex: 'reviewRemark', key: 'reviewRemark', ellipsis: true },
{ title: '操作', key: 'action', width: 90, fixed: 'right', align: 'center', hideInSetting: true }
{
title: '审核意见',
dataIndex: 'reviewRemark',
key: 'reviewRemark',
ellipsis: true
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
@@ -156,15 +340,42 @@
reload();
};
const openReview = (record: VolunteerRecord) => {
const openReview = async (record: VolunteerRecord) => {
current.value = record;
reviewStatus.value = record.status === 2 ? 2 : 1;
reviewRemark.value = record.reviewRemark || '';
progressNodes.value = [];
showReview.value = true;
if (!record.id) return;
progressLoading.value = true;
try {
const data = await getVolunteerProgress(record.id);
progressNodes.value = (data?.nodes as VolunteerReviewNode[]) || [];
} catch (e: any) {
// 没配审核流时接口返回空节点,这里静默处理,退回单级审核
progressNodes.value = [];
} finally {
progressLoading.value = false;
}
};
const formatTime = (value?: string) => {
return value ? toDateString(value, 'yyyy-MM-dd HH:mm') : '';
};
const stepStatus = (node: VolunteerReviewNode) => {
if (node.status === 1) return 'finish';
if (node.status === 2) return 'error';
if (node.reviewerId === loginUserId.value) return 'process';
return 'wait';
};
const submitReview = async () => {
if (!current.value?.id) return;
if (progressNodes.value.length && !myPendingNode.value) {
message.warning('当前节点不在你手上,请等待前一级审核完成');
return;
}
if (reviewStatus.value === 2 && !reviewRemark.value.trim()) {
message.warning('驳回时请填写审核意见');
return;
@@ -205,4 +416,26 @@
font-weight: 600;
color: #13c2c2;
}
.ocr-card,
.flow-card {
margin-top: 16px;
}
.ocr-diff {
color: #fa8c16;
font-weight: 600;
}
.ocr-origin {
margin-left: 4px;
color: #bfbfbf;
font-size: 12px;
}
.node-reviewer {
margin-left: 8px;
color: #8c8c8c;
font-size: 12px;
}
</style>
@@ -0,0 +1,444 @@
<!-- 青马学员 AI 学情分析 -->
<template>
<a-drawer
:visible="visible"
:width="760"
:body-style="{ padding: '20px 24px 24px' }"
@update:visible="updateVisible"
>
<template #title>
<div class="analysis-title">
<span>AI 学情分析</span>
<a-tag v-if="studentName" color="blue">{{ studentName }}</a-tag>
</div>
</template>
<template #extra>
<a-space>
<a-button v-if="loading" size="small" danger @click="stopAnalysis">
停止
</a-button>
<a-button
size="small"
type="primary"
:loading="loading"
:disabled="!canAnalyze"
@click="runAnalysis"
>
{{ hasResult ? '重新分析' : '开始分析' }}
</a-button>
</a-space>
</template>
<a-alert
type="info"
show-icon
class="analysis-tip"
message="分析依据"
:description="dataScopeText"
/>
<div class="analysis-focus">
<span class="focus-label">分析侧重</span>
<a-radio-group v-model:value="focus" :disabled="loading" size="small">
<a-radio-button value="">综合</a-radio-button>
<a-radio-button value="thought">思想动态</a-radio-button>
<a-radio-button value="difficulty">学习难点</a-radio-button>
<a-radio-button value="advice">培养建议</a-radio-button>
</a-radio-group>
</div>
<a-empty
v-if="!loading && !hasResult"
description="点击「开始分析」,AI 将结合该学员的培养材料、任务进度与考核评分给出结论"
/>
<div v-else class="analysis-result">
<div v-if="loading && !displayContent" class="analysis-loading">
<a-spin size="small" />
<span>AI 正在分析该学员的学习情况…</span>
</div>
<ByteMdViewer
v-if="displayContent"
:value="displayContent"
:plugins="plugins"
/>
<div v-if="loading && displayContent" class="analysis-thinking">
<span class="thinking-dot" aria-hidden="true"></span>
<span>正在继续生成…</span>
</div>
</div>
<template #footer>
<div class="analysis-footer">
<span class="footer-hint">分析结果由 AI 生成,仅供参考</span>
<a-space>
<a-button :disabled="!displayContent" @click="copyResult"
>复制结果</a-button
>
<a-button :disabled="loading || !displayContent" @click="clearResult"
>清空</a-button
>
</a-space>
</div>
</template>
</a-drawer>
</template>
<script lang="ts" setup>
import 'bytemd/dist/index.min.css';
import 'github-markdown-css/github-markdown-light.css';
import gfm from '@bytemd/plugin-gfm';
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import highlight from '@bytemd/plugin-highlight';
import 'highlight.js/styles/default.css';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { streamQmgcStudentAnalysis } from '@/utils/ai-stream';
import { copyText } from '@/utils/common';
import { FAILED_TEXT } from '@/utils/ai-result';
import type { QmgcMaterial, QmgcScore, QmgcStudent } from '@/api/growth/qmgc';
const props = defineProps<{
visible: boolean;
/** 当前学员 */
student?: QmgcStudent | null;
/** 学员档案详情(含 materials / tasks / scores) */
detail?: Record<string, any> | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const plugins = ref([
gfm({
locale: zh_HansGfm
}),
highlight()
]);
const loading = ref(false);
const focus = ref('');
const answer = ref('');
const controller = ref<AbortController | null>(null);
const STREAM_END_TEXT = '__END__';
const THINK_OPEN_TAG = '<think>';
const THINK_CLOSE_TAG = '</think>';
const studentName = computed(() => props.student?.studentName || '');
const canAnalyze = computed(() => !!props.student?.id);
const materials = computed<QmgcMaterial[]>(
() => (props.detail?.materials || []) as QmgcMaterial[]
);
const tasks = computed<any[]>(() => (props.detail?.tasks || []) as any[]);
const scores = computed<QmgcScore[]>(
() => (props.detail?.scores || []) as QmgcScore[]
);
const doneTaskCount = computed(
() => tasks.value.filter((item) => item.assignStatus === 1).length
);
const scoredMaterialCount = computed(
() => materials.value.filter((item) => item.status === 1).length
);
/** 展示给教师的取数范围,避免用户误以为 AI「看过」系统外的数据 */
const dataScopeText = computed(() => {
if (!props.student?.id) {
return '尚未选择学员';
}
return `培养材料 ${materials.value.length} 条(已评分 ${scoredMaterialCount.value} 条)、任务 ${doneTaskCount.value}/${tasks.value.length} 项已完成、考核评分 ${scores.value.length} 条。系统暂无考勤与课堂互动数据,分析不含这两类记录。`;
});
const hasResult = computed(() => !!answer.value.trim());
/** 去掉 <think> 思考段,只留正文 */
const parseAssistantContent = (value: string) => {
if (!value) {
return { content: '', thinking: false };
}
let cursor = 0;
let content = '';
let thinking = false;
while (cursor < value.length) {
if (!thinking) {
const openIndex = value.indexOf(THINK_OPEN_TAG, cursor);
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor);
if (closeIndex !== -1 && (openIndex === -1 || closeIndex < openIndex)) {
cursor = closeIndex + THINK_CLOSE_TAG.length;
continue;
}
if (openIndex === -1) {
content += value.slice(cursor);
break;
}
content += value.slice(cursor, openIndex);
cursor = openIndex + THINK_OPEN_TAG.length;
thinking = true;
continue;
}
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor);
if (closeIndex === -1) {
break;
}
cursor = closeIndex + THINK_CLOSE_TAG.length;
thinking = false;
}
return { content, thinking };
};
const displayContent = computed(() =>
parseAssistantContent(answer.value).content.trim()
);
const truncate = (value: string, max: number) =>
value.length > max ? `${value.slice(0, max)}…` : value;
/**
* 组装给 AI 的学员上下文。
*
* 后端可按 studentId 自行取数;这里同时带上前端已加载的摘要,
* 避免后端尚未聚合时 AI「看不到」任何材料内容。
*/
const buildContext = () => {
const student = props.student || {};
const materialTexts = materials.value
.filter((item) => item.content && item.content.trim())
.slice(0, 8)
.map(
(item) =>
`【${item.title || '培养材料'}】${truncate(
String(item.content).trim(),
800
)}`
);
const taskTexts = tasks.value.slice(0, 20).map((item) => {
const title = item.title || item.task?.title || '未命名任务';
const status = item.assignStatus === 1 ? '已完成' : '未完成';
return `${title}(${status})`;
});
const scoreTexts = scores.value.slice(0, 20).map((item) => {
const parts = [item.dimension, item.item].filter(Boolean).join('/');
const comment = item.comment
? `,评语:${truncate(item.comment, 200)}`
: '';
return `${parts || '考核项'}:${item.score ?? 0} 分${comment}`;
});
return {
studentId: student.id,
studentName: student.studentName,
studentNo: student.studentNo,
collegeName: student.collegeName,
className: student.className,
batch: student.batch,
stage: student.stage,
totalScore: student.totalScore,
materialCount: materials.value.length,
scoredMaterialCount: scoredMaterialCount.value,
taskDoneCount: doneTaskCount.value,
taskTotal: tasks.value.length,
materialTexts,
taskTexts,
scoreTexts
};
};
const buildPayload = () => ({
studentId: props.student?.id,
focus: focus.value || undefined,
context: buildContext()
});
const clearResult = () => {
answer.value = '';
};
const updateVisible = (value: boolean) => {
if (!value) {
stopAnalysis();
}
emit('update:visible', value);
};
const stopAnalysis = () => {
controller.value?.abort();
controller.value = null;
loading.value = false;
};
const runAnalysis = async () => {
if (!canAnalyze.value) {
message.warning('请先选择学员');
return;
}
if (loading.value) {
return;
}
clearResult();
loading.value = true;
const current = new AbortController();
controller.value = current;
try {
await streamQmgcStudentAnalysis(
buildPayload() as unknown as Record<string, unknown>,
(raw) => {
if (!raw) {
return;
}
try {
const data = JSON.parse(raw);
if (data?.answer === STREAM_END_TEXT) {
return;
}
if (data?.loading === 'true' || data?.loading === true) {
return;
}
const chunk =
typeof data?.notice === 'string'
? data.notice
: typeof data?.answer === 'string'
? data.answer
: '';
if (chunk) {
answer.value += chunk;
}
} catch {
answer.value += raw;
}
},
current.signal
);
const text = displayContent.value;
if (!text) {
message.warning('这次没有生成出内容,请重试');
} else if (text === FAILED_TEXT) {
message.error('分析失败,请稍后重试');
}
} catch (e: any) {
if (e?.name !== 'AbortError') {
message.error(e?.message || '分析失败');
}
} finally {
if (controller.value === current) {
controller.value = null;
loading.value = false;
}
}
};
const copyResult = () => {
if (!displayContent.value) {
message.warning('暂无可复制内容');
return;
}
copyText(displayContent.value);
};
/** 切换学员或关闭时清掉上一次结果,避免张冠李戴 */
watch(
() => props.student?.id,
() => {
stopAnalysis();
clearResult();
}
);
watch(
() => props.visible,
(value) => {
if (value) {
focus.value = '';
}
}
);
onBeforeUnmount(() => {
stopAnalysis();
});
</script>
<style scoped lang="less">
.analysis-title {
display: flex;
align-items: center;
gap: 10px;
}
.analysis-tip {
margin-bottom: 16px;
border-radius: 10px;
}
.analysis-focus {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.focus-label {
flex: none;
color: #8c8c8c;
font-size: 13px;
}
.analysis-result {
min-height: 240px;
padding: 16px 20px;
border: 1px solid #edf2f8;
border-radius: 12px;
background: #fbfdff;
}
.analysis-loading,
.analysis-thinking {
display: flex;
align-items: center;
gap: 10px;
color: #8c8c8c;
font-size: 13px;
}
.analysis-loading {
justify-content: center;
min-height: 200px;
}
.analysis-thinking {
margin-top: 12px;
}
.thinking-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #1d7bff;
animation: thinkingPulse 1.2s ease-in-out infinite;
}
.analysis-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.footer-hint {
color: #8c8c8c;
font-size: 12px;
}
@keyframes thinkingPulse {
0%,
100% {
transform: scale(0.8);
opacity: 0.45;
}
50% {
transform: scale(1);
opacity: 1;
}
}
</style>
+73 -1
View File
@@ -60,6 +60,8 @@
<a-space>
<a @click="openDetail(record)">档案</a>
<a-divider type="vertical" />
<a @click="openAnalysis(record)">AI 分析</a>
<a-divider type="vertical" />
<a v-if="hasPermission('gxmu:qmgcStudent:update')" @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
@@ -77,6 +79,12 @@
<QmgcStudentEdit v-model:visible="showEdit" :data="current" @done="reload" />
<AiAnalysis
v-model:visible="showAnalysis"
:student="analysisStudent"
:detail="analysisDetail"
/>
<a-drawer v-model:visible="showDetail" title="青马培养档案" width="880">
<template v-if="detail">
<a-descriptions :column="2" bordered size="small">
@@ -88,6 +96,21 @@
<a-descriptions-item label="累计得分">{{ student?.totalScore ?? 0 }}</a-descriptions-item>
</a-descriptions>
<div class="detail-ai-entry">
<span class="detail-ai-hint">
结合下面的培养材料、任务进度与考核评分,让 AI
分析该学员的思想动态与学习难点
</span>
<a-button
type="primary"
ghost
size="small"
@click="openAnalysis(current)"
>
AI 学情分析
</a-button>
</div>
<a-tabs style="margin-top: 16px">
<a-tab-pane key="material" tab="培养材料">
<a-space style="margin-bottom: 12px">
@@ -209,6 +232,7 @@
import type { EleProTable } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import QmgcStudentEdit from './components/edit.vue';
import AiAnalysis from './components/aiAnalysis.vue';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import {
pageQmgcStudents,
@@ -227,10 +251,13 @@
const stage = ref<string>();
const showEdit = ref(false);
const showDetail = ref(false);
const showAnalysis = ref(false);
const showMaterial = ref(false);
const showScoreModal = ref(false);
const current = ref<QmgcStudent | null>(null);
const detail = ref<Record<string, any> | null>(null);
const analysisStudent = ref<QmgcStudent | null>(null);
const analysisDetail = ref<Record<string, any> | null>(null);
const materialForm = ref<Partial<QmgcMaterial>>({});
const materialFile = ref('');
const submitting = ref(false);
@@ -260,7 +287,7 @@
{ title: '阶段', key: 'stage', width: 100, align: 'center' },
{ title: '累计得分', dataIndex: 'totalScore', key: 'totalScore', width: 100, align: 'center' },
{ title: '状态', key: 'status', width: 90, align: 'center' },
{ title: '操作', key: 'action', width: 180, fixed: 'right', align: 'center', hideInSetting: true }
{ title: '操作', key: 'action', width: 230, fixed: 'right', align: 'center', hideInSetting: true }
]);
const taskColumns: ColumnItem[] = [
@@ -316,6 +343,31 @@
}
};
/**
* 打开 AI 学情分析。
*
* 从列表进入时档案详情还没加载,这里按需取一次,保证 AI 能看到
* 培养材料、任务和评分;已加载过(从档案抽屉进入)则直接复用。
*/
const openAnalysis = async (row?: QmgcStudent | null) => {
if (!row?.id) {
message.warning('请先选择学员');
return;
}
if (!detail.value || current.value?.id !== row.id) {
try {
detail.value = await getQmgcStudentDetail(row.id);
} catch (e: any) {
message.error(e?.message || '加载学情数据失败');
return;
}
}
current.value = row;
analysisStudent.value = row;
analysisDetail.value = detail.value;
showAnalysis.value = true;
};
const remove = async (row: QmgcStudent) => {
if (!row.id) return;
try {
@@ -402,3 +454,23 @@
return map[value ?? ''] ?? value ?? '-';
};
</script>
<style scoped lang="less">
.detail-ai-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 12px;
padding: 10px 14px;
border: 1px dashed #d6e4f5;
border-radius: 8px;
background: #f7fbff;
}
.detail-ai-hint {
color: #8c8c8c;
font-size: 12px;
line-height: 1.6;
}
</style>
@@ -121,7 +121,9 @@
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
{ label: '青马工程', value: 'gxmu_qmgc_form' },
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' }
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' },
{ label: '志愿服务时长', value: 'gxmu_volunteer' },
{ label: '社会实践学分', value: 'gxmu_practice_credit' }
];
// 用户信息
@@ -13,7 +13,11 @@
<a-spin :spinning="loading">
<div v-if="canOperateDeclare" class="declare-actions">
<a-space>
<a-button size="small" :loading="actionLoading" @click="handleWithdraw">
<a-button
size="small"
:loading="actionLoading"
@click="handleWithdraw"
>
撤回申报
</a-button>
<a-button
@@ -225,6 +229,7 @@
import { getSjqnForm } from '@/api/gxmu/sjqn-form';
import { getSjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form';
import { getTyglForm } from '@/api/gxmu/tygl-form';
import { getVolunteerRecord } from '@/api/growth/volunteer';
import { getTzbcyForm } from '@/api/gxmu/tzbcy-form';
import { getWshqtwForm } from '@/api/gxmu/wshqtw-form';
import { getWshqtzbForm } from '@/api/gxmu/wshqtzb-form';
@@ -345,6 +350,11 @@
gxmu_tygl: {
title: '团员管理',
fetcher: (id) => getTyglForm(id) as Promise<Record<string, unknown>>
},
gxmu_volunteer: {
title: '志愿服务时长',
fetcher: (id) =>
getVolunteerRecord(id) as Promise<Record<string, unknown>>
}
};
@@ -435,7 +445,10 @@
onOk: async () => {
actionLoading.value = true;
try {
await withdrawDeclareRecord(props.module as string, props.pk as number);
await withdrawDeclareRecord(
props.module as string,
props.pk as number
);
message.success('已撤回,申报人可继续修改');
emit('done');
updateVisible(false);
@@ -1085,5 +1098,4 @@
font-size: 12px;
color: #8c8c8c;
}
</style>
</style>
@@ -834,6 +834,43 @@ export const MODULE_DETAIL_CONFIGS: Record<string, ModuleDetailConfig> = {
],
materialFields: []
},
gxmu_volunteer: {
title: '志愿服务时长',
sheetName: '志愿服务时长记录',
labels: {
studentName: '姓名',
studentNo: '学号',
projectName: '志愿项目',
orgName: '组织单位',
servicePlace: '服务地点',
hours: '服务时长(小时)',
serviceDate: '服务开始日期',
serviceEndDate: '服务结束日期',
content: '服务内容',
source: '数据来源',
proofFiles: '证明材料'
},
sections: [
{
title: '学生信息',
fields: ['studentName', 'studentNo']
},
{
title: '服务信息',
fields: [
'projectName',
'orgName',
'servicePlace',
'hours',
'serviceDate',
'serviceEndDate',
'content',
'source'
]
}
],
materialFields: ['proofFiles']
},
cms_manuscript: {
title: '稿件',
sheetName: '稿件',
@@ -27,11 +27,7 @@
/>
</a-form-item>
<a-form-item label="主键" name="pk">
<a-input
allow-clear
placeholder="请输入主键"
v-model:value="form.pk"
/>
<a-input allow-clear placeholder="请输入主键" v-model:value="form.pk" />
</a-form-item>
<a-form-item label="状态(0驳回 1通过)" name="status">
<a-radio-group v-model:value="form.status">
@@ -61,11 +57,7 @@
/>
</a-form-item>
<a-form-item label="" name="userId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.userId"
/>
<a-input allow-clear placeholder="请输入" v-model:value="form.userId" />
</a-form-item>
<a-form-item label="" name="sortNumber">
<a-input-number
@@ -129,7 +121,7 @@
createTime: undefined,
updateTime: undefined,
userId: undefined,
sortNumber: undefined,
sortNumber: undefined
});
/* 更新visible */
@@ -138,9 +130,7 @@
};
// 表单验证规则
const rules = reactive({
});
const rules = reactive({});
const { resetFields } = useForm(form, rules);
@@ -51,7 +51,9 @@
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
{ label: '青马工程', value: 'gxmu_qmgc_form' },
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' }
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' },
{ label: '志愿服务时长', value: 'gxmu_volunteer' },
{ label: '社会实践学分', value: 'gxmu_practice_credit' }
];
const search = () => {