feat(volunteer): 新增志愿服务证明材料识别及年度证明功能
- 新增志愿证明材料识别功能,支持自动识别材料并回填关键信息 - 在志愿时长编辑页面添加材料识别按钮及识别结果展示 - 新增志愿服务证明模块,支持查询、生成及下载年度志愿服务证明PDF - 志愿时长列表及审核详情中新增“服务地点”字段显示 - 志愿时长导入功能支持“服务地点”字段,完善导入表头与数据映射 - 志愿时长详情、审核流程等界面优化,增加识别材料公章状态及流程节点提示 - 志愿时长待审核项支持用户撤回操作,撤回后记录被删除需重新录入 - 社团年度报告相关接口新增,实现社团年度报告生成及分页查询 - 调整相关样式与界面布局,提升用户体验与信息展示完整性
This commit is contained in:
@@ -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` 的类型与中文名;
|
||||||
|
本次前端改动未接入历史记录,属于可选增强。
|
||||||
@@ -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 服务;活动审批暂由人工核验材料 |
|
| OCR 公章识别 | 字段已预留(`ocr_status` / `ocr_org` / `ocr_raw`),未接 OCR 服务;活动审批暂由人工核验材料 |
|
||||||
| 审核流并行 | **已实现**,见下方「补充交付」 |
|
| 审核流并行 | **已实现**,见下方「补充交付」 |
|
||||||
| 学分自动获取 | 第二课堂/桂志愿暂无 API,先走 Excel 导入,`source` + `external_id` 已为后续对接预留 |
|
| 学分自动获取 | 第二课堂/桂志愿暂无 API,先走 Excel 导入,`source` + `external_id` 已为后续对接预留 |
|
||||||
|
|||||||
@@ -84,6 +84,10 @@
|
|||||||
|
|
||||||
AI 学员学习情况分析、AI 定制培养建议、OCR 公章识别、AI 数字人、移动端接口整理。
|
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`。
|
统一规范:`id BIGINT AUTO_INCREMENT`、`tenant_id`、`user_id`、`deleted`、`create_time`、`update_time`。
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -81,6 +81,62 @@ export interface ClubActivityParam extends PageParam {
|
|||||||
startTimeEnd?: string;
|
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 {
|
export interface Venue {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|||||||
@@ -15,11 +15,19 @@ export interface VolunteerRecord {
|
|||||||
externalId?: string;
|
externalId?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
orgName?: string;
|
orgName?: string;
|
||||||
|
servicePlace?: string;
|
||||||
hours?: number;
|
hours?: number;
|
||||||
serviceDate?: string;
|
serviceDate?: string;
|
||||||
serviceEndDate?: string;
|
serviceEndDate?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
proofFiles?: string;
|
proofFiles?: string;
|
||||||
|
ocrStatus?: number;
|
||||||
|
ocrOrg?: string;
|
||||||
|
ocrProjectName?: string;
|
||||||
|
ocrPlace?: string;
|
||||||
|
ocrHours?: number;
|
||||||
|
ocrHasSeal?: number;
|
||||||
|
ocrRaw?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
reviewRemark?: string;
|
reviewRemark?: string;
|
||||||
reviewerId?: number;
|
reviewerId?: number;
|
||||||
@@ -53,6 +61,17 @@ export async function pageVolunteerRecords(params: VolunteerRecordParam) {
|
|||||||
return Promise.reject(new Error(res.data.message));
|
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) {
|
export async function submitVolunteerRecord(data: VolunteerRecord) {
|
||||||
const res = await request.post<ApiResult<unknown>>(
|
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));
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 流式) */
|
/** 创意助手(HTTP 流式) */
|
||||||
export function streamCreativeAssistant(
|
export function streamCreativeAssistant(
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
type="info"
|
type="info"
|
||||||
show-icon
|
show-icon
|
||||||
style="margin-bottom: 16px"
|
style="margin-bottom: 16px"
|
||||||
message="年度志愿服务证明按「已通过审核」的志愿时长自动汇总生成。证书编号由后端按年度顺序生成,模板与电子章在 P1 阶段接入。"
|
message="年度志愿服务证明按「已通过审核」的志愿时长自动汇总生成,下载为 PDF。证书编号由系统按年度生成,加盖公章后有效。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<a-space style="margin-bottom: 16px" wrap>
|
<a-space style="margin-bottom: 16px" wrap>
|
||||||
@@ -20,16 +20,55 @@
|
|||||||
v-model:value="studentNo"
|
v-model:value="studentNo"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 180px"
|
style="width: 180px"
|
||||||
placeholder="学号(管理员)"
|
placeholder="学号(管理员代查)"
|
||||||
|
@pressEnter="loadData"
|
||||||
/>
|
/>
|
||||||
<a-button type="primary" :loading="loading" @click="loadData">查询</a-button>
|
<a-button type="primary" :loading="loading" @click="loadData"
|
||||||
<a-button :disabled="!summary.totalHours" :loading="generating" @click="generate">
|
>查询</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-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
|
||||||
<a-spin :spinning="loading">
|
<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">
|
<a-col :xs="24" :sm="8">
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<div class="metric-label">年度累计时长</div>
|
<div class="metric-label">年度累计时长</div>
|
||||||
@@ -40,7 +79,7 @@
|
|||||||
<a-col :xs="24" :sm="8">
|
<a-col :xs="24" :sm="8">
|
||||||
<div class="metric-card accent">
|
<div class="metric-card accent">
|
||||||
<div class="metric-label">志愿项目数</div>
|
<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 class="metric-foot">按项目名称去重</div>
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -48,18 +87,21 @@
|
|||||||
<div class="metric-card warm">
|
<div class="metric-card warm">
|
||||||
<div class="metric-label">记录条数</div>
|
<div class="metric-label">记录条数</div>
|
||||||
<div class="metric-value">{{ summary.approvedCount ?? 0 }}</div>
|
<div class="metric-value">{{ summary.approvedCount ?? 0 }}</div>
|
||||||
<div class="metric-foot">已通过的志愿记录</div>
|
<div class="metric-foot">该年度已通过的志愿记录</div>
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<a-card :bordered="false" class="project-card" title="项目明细">
|
<a-card :bordered="false" class="project-card" title="服务明细">
|
||||||
<a-empty v-if="!projects.length" description="该年度暂无可生成证明的志愿记录" />
|
<a-empty
|
||||||
|
v-if="!summary.items?.length"
|
||||||
|
description="该年度暂无可生成证明的志愿记录"
|
||||||
|
/>
|
||||||
<a-table
|
<a-table
|
||||||
v-else
|
v-else
|
||||||
row-key="projectName"
|
row-key="id"
|
||||||
size="small"
|
size="small"
|
||||||
:data-source="projects"
|
:data-source="summary.items"
|
||||||
:columns="projectColumns"
|
:columns="projectColumns"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
/>
|
/>
|
||||||
@@ -76,9 +118,8 @@
|
|||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
|
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
import {
|
import {
|
||||||
getMyVolunteerSummary,
|
getVolunteerCertificateSummary,
|
||||||
getMyVolunteerProjectStats,
|
generateVolunteerCertificate
|
||||||
getMyVolunteerTotal
|
|
||||||
} from '@/api/growth/volunteer';
|
} from '@/api/growth/volunteer';
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -86,7 +127,6 @@
|
|||||||
const year = ref(dayjs().year());
|
const year = ref(dayjs().year());
|
||||||
const studentNo = ref('');
|
const studentNo = ref('');
|
||||||
const summary = ref<Record<string, any>>({});
|
const summary = ref<Record<string, any>>({});
|
||||||
const projects = ref<Record<string, any>[]>([]);
|
|
||||||
|
|
||||||
const yearOptions = computed(() => {
|
const yearOptions = computed(() => {
|
||||||
const current = dayjs().year();
|
const current = dayjs().year();
|
||||||
@@ -97,44 +137,112 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const projectColumns: ColumnItem[] = [
|
const projectColumns: ColumnItem[] = [
|
||||||
{ title: '志愿项目', dataIndex: 'projectName', key: 'projectName' },
|
{
|
||||||
{ title: '组织单位', dataIndex: 'orgName', key: 'orgName', width: 260 },
|
title: '志愿项目',
|
||||||
{ title: '时长', dataIndex: 'hours', key: 'hours', width: 100, align: 'center' },
|
dataIndex: 'projectName',
|
||||||
{ title: '记录数', dataIndex: 'count', key: 'count', width: 100, align: 'center' }
|
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 () => {
|
const loadData = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const [summaryData, projectData, total] = await Promise.all([
|
summary.value = await getVolunteerCertificateSummary({
|
||||||
getMyVolunteerSummary(),
|
year: year.value,
|
||||||
getMyVolunteerProjectStats(year.value),
|
studentNo: studentNo.value || undefined
|
||||||
getMyVolunteerTotal(year.value)
|
});
|
||||||
]);
|
|
||||||
summary.value = { ...summaryData, totalHours: total ?? 0 };
|
|
||||||
projects.value = projectData || [];
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
summary.value = {};
|
||||||
message.error(e?.message || '加载志愿数据失败');
|
message.error(e?.message || '加载志愿数据失败');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generate = () => {
|
const generate = async () => {
|
||||||
generating.value = true;
|
generating.value = true;
|
||||||
// 证明文件由后端模板渲染,P0 先给出数据核对结果,文件下载在模板接入后开放
|
try {
|
||||||
setTimeout(() => {
|
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;
|
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);
|
onMounted(loadData);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
.student-info {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-card {
|
.metric-card {
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -18,14 +18,33 @@
|
|||||||
/>
|
/>
|
||||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
|
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
|
||||||
<a-form-item label="志愿项目名称" name="projectName">
|
<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-form-item>
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item label="组织单位" name="orgName">
|
<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-form-item>
|
||||||
</a-col>
|
</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-col :span="12">
|
||||||
<a-form-item label="服务时长(小时)" name="hours">
|
<a-form-item label="服务时长(小时)" name="hours">
|
||||||
<a-input-number
|
<a-input-number
|
||||||
@@ -61,25 +80,86 @@
|
|||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-form-item label="服务内容" name="content">
|
<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>
|
||||||
<a-form-item label="盖章版证明材料" name="proofFiles" required>
|
<a-form-item label="盖章版证明材料" name="proofFiles" required>
|
||||||
<MultiFileUpload v-model="form.proofFiles" :max-count="5" />
|
<MultiFileUpload v-model="form.proofFiles" :max-count="5" />
|
||||||
<div class="ele-text-secondary" style="font-size: 12px">
|
<div class="ele-text-secondary" style="font-size: 12px">
|
||||||
支持图片或 PDF,建议上传盖有组织单位公章的证明材料
|
支持图片或 PDF,建议上传盖有组织单位公章的证明材料
|
||||||
</div>
|
</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-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</ele-modal>
|
</ele-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { reactive, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { Form, message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { ScanOutlined } from '@ant-design/icons-vue';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
|
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 type { VolunteerRecord } from '@/api/growth/volunteer';
|
||||||
|
import { parseAttachments } from '@/views/gxmu/utils/attachment';
|
||||||
|
|
||||||
const props = defineProps<{ visible: boolean }>();
|
const props = defineProps<{ visible: boolean }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -89,16 +169,71 @@
|
|||||||
|
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const recognizing = ref(false);
|
||||||
|
const recognizeTip = ref('');
|
||||||
|
const recognized = ref<Record<string, any> | null>(null);
|
||||||
const form = reactive<VolunteerRecord>({});
|
const form = reactive<VolunteerRecord>({});
|
||||||
|
|
||||||
const serviceDate = ref<any>();
|
const serviceDate = ref<any>();
|
||||||
const serviceEndDate = 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 = {
|
const rules = {
|
||||||
projectName: [{ required: true, message: '请输入志愿项目名称', trigger: 'blur' }],
|
projectName: [
|
||||||
|
{ required: true, message: '请输入志愿项目名称', trigger: 'blur' }
|
||||||
|
],
|
||||||
hours: [{ required: true, message: '请输入服务时长', trigger: 'change' }],
|
hours: [{ required: true, message: '请输入服务时长', trigger: 'change' }],
|
||||||
serviceDate: [{ required: true, message: '请选择服务日期', trigger: 'change' }],
|
serviceDate: [
|
||||||
proofFiles: [{ required: true, message: '请上传证明材料', trigger: 'change' }]
|
{ required: true, message: '请选择服务日期', trigger: 'change' }
|
||||||
|
],
|
||||||
|
proofFiles: [
|
||||||
|
{ required: true, message: '请上传证明材料', trigger: 'change' }
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -108,12 +243,82 @@
|
|||||||
Object.keys(form).forEach((key) => delete (form as any)[key]);
|
Object.keys(form).forEach((key) => delete (form as any)[key]);
|
||||||
serviceDate.value = undefined;
|
serviceDate.value = undefined;
|
||||||
serviceEndDate.value = undefined;
|
serviceEndDate.value = undefined;
|
||||||
|
recognizeTip.value = '';
|
||||||
|
recognized.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateVisible = (value: boolean) => emit('update:visible', value);
|
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) => {
|
const normalizeDate = (value: any) => {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
return dayjs.isDayjs(value) ? value.format('YYYY-MM-DD') : String(value);
|
return dayjs.isDayjs(value) ? value.format('YYYY-MM-DD') : String(value);
|
||||||
@@ -140,3 +345,31 @@
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
</script>
|
</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: 'studentName', required: true },
|
||||||
{ header: '志愿项目', key: 'projectName', required: true },
|
{ header: '志愿项目', key: 'projectName', required: true },
|
||||||
{ header: '组织单位', key: 'orgName' },
|
{ header: '组织单位', key: 'orgName' },
|
||||||
|
{ header: '服务地点', key: 'servicePlace' },
|
||||||
{ header: '服务时长', key: 'hours', required: true },
|
{ header: '服务时长', key: 'hours', required: true },
|
||||||
{ header: '服务日期', key: 'serviceDate', required: true },
|
{ header: '服务日期', key: 'serviceDate', required: true },
|
||||||
{ header: '外部编号', key: 'externalId' }
|
{ header: '外部编号', key: 'externalId' }
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
姓名: '张三',
|
姓名: '张三',
|
||||||
志愿项目: '社区义诊志愿服务',
|
志愿项目: '社区义诊志愿服务',
|
||||||
组织单位: '广西医科大学青年志愿者协会',
|
组织单位: '广西医科大学青年志愿者协会',
|
||||||
|
服务地点: '南宁市青秀区社区服务中心',
|
||||||
服务时长: '4',
|
服务时长: '4',
|
||||||
服务日期: '2025-10-12',
|
服务日期: '2025-10-12',
|
||||||
外部编号: 'gzy-2025-0001'
|
外部编号: 'gzy-2025-0001'
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
studentName: String(row['姓名'] || '').trim(),
|
studentName: String(row['姓名'] || '').trim(),
|
||||||
projectName: String(row['志愿项目'] || '').trim(),
|
projectName: String(row['志愿项目'] || '').trim(),
|
||||||
orgName: String(row['组织单位'] || '').trim() || undefined,
|
orgName: String(row['组织单位'] || '').trim() || undefined,
|
||||||
|
servicePlace: String(row['服务地点'] || '').trim() || undefined,
|
||||||
hours: Number(row['服务时长'] || 0),
|
hours: Number(row['服务时长'] || 0),
|
||||||
serviceDate: normalizeDate(row['服务日期']),
|
serviceDate: normalizeDate(row['服务日期']),
|
||||||
externalId: String(row['外部编号'] || '').trim() || undefined,
|
externalId: String(row['外部编号'] || '').trim() || undefined,
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span class="metric-label">待审核</span>
|
<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>
|
<span class="metric-unit">条</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,7 +27,10 @@
|
|||||||
<template #icon><plus-outlined /></template>
|
<template #icon><plus-outlined /></template>
|
||||||
录入时长
|
录入时长
|
||||||
</a-button>
|
</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>
|
||||||
<a-button :loading="loading" @click="reload">刷新</a-button>
|
<a-button :loading="loading" @click="reload">刷新</a-button>
|
||||||
@@ -37,7 +42,7 @@
|
|||||||
row-key="id"
|
row-key="id"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:datasource="datasource"
|
:datasource="datasource"
|
||||||
:scroll="{ x: 1200 }"
|
:scroll="{ x: 1360 }"
|
||||||
tool-class="ele-toolbar-form"
|
tool-class="ele-toolbar-form"
|
||||||
>
|
>
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
@@ -93,11 +98,22 @@
|
|||||||
<template v-else-if="column.key === 'action'">
|
<template v-else-if="column.key === 'action'">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a @click="openDetail(record)">查看</a>
|
<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">
|
<template v-if="record.status === 0 && canReview">
|
||||||
<a-divider type="vertical" />
|
<a-divider type="vertical" />
|
||||||
<a @click="doReview(record, 1)">通过</a>
|
<a @click="doReview(record, 1)">通过</a>
|
||||||
<a-divider type="vertical" />
|
<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>
|
</template>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
v-if="record.status !== 1"
|
v-if="record.status !== 1"
|
||||||
@@ -113,15 +129,9 @@
|
|||||||
</ele-pro-table>
|
</ele-pro-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<VolunteerEdit
|
<VolunteerEdit v-model:visible="showSubmit" @done="onSubmitted" />
|
||||||
v-model:visible="showSubmit"
|
|
||||||
@done="onSubmitted"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VolunteerImport
|
<VolunteerImport v-model:visible="showImport" @done="onSubmitted" />
|
||||||
v-model:visible="showImport"
|
|
||||||
@done="onSubmitted"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<a-modal
|
<a-modal
|
||||||
v-model:visible="showDetail"
|
v-model:visible="showDetail"
|
||||||
@@ -130,19 +140,34 @@
|
|||||||
width="640px"
|
width="640px"
|
||||||
>
|
>
|
||||||
<a-descriptions v-if="current" :column="1" bordered size="small">
|
<a-descriptions v-if="current" :column="1" bordered size="small">
|
||||||
<a-descriptions-item label="项目名称">{{ current.projectName }}</a-descriptions-item>
|
<a-descriptions-item label="项目名称">{{
|
||||||
<a-descriptions-item label="组织单位">{{ current.orgName || '-' }}</a-descriptions-item>
|
current.projectName
|
||||||
<a-descriptions-item label="服务时长">{{ current.hours }} 小时</a-descriptions-item>
|
}}</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="服务日期">
|
<a-descriptions-item label="服务日期">
|
||||||
{{ current.serviceDate || '-' }}
|
{{ current.serviceDate || '-' }}
|
||||||
<span v-if="current.serviceEndDate"> 至 {{ current.serviceEndDate }}</span>
|
<span v-if="current.serviceEndDate">
|
||||||
|
至 {{ current.serviceEndDate }}</span
|
||||||
|
>
|
||||||
</a-descriptions-item>
|
</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="证明材料">
|
<a-descriptions-item label="证明材料">
|
||||||
<AttachmentList :value="current.proofFiles" />
|
<AttachmentList :value="current.proofFiles" />
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
<a-descriptions-item label="审核状态">
|
<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>
|
||||||
<a-descriptions-item v-if="current.reviewRemark" label="审核意见">
|
<a-descriptions-item v-if="current.reviewRemark" label="审核意见">
|
||||||
{{ current.reviewRemark }}
|
{{ current.reviewRemark }}
|
||||||
@@ -170,10 +195,29 @@
|
|||||||
pageVolunteerRecords,
|
pageVolunteerRecords,
|
||||||
removeVolunteerRecord,
|
removeVolunteerRecord,
|
||||||
reviewVolunteerRecord,
|
reviewVolunteerRecord,
|
||||||
|
withdrawVolunteerRecord,
|
||||||
getMyVolunteerSummary
|
getMyVolunteerSummary
|
||||||
} from '@/api/growth/volunteer';
|
} from '@/api/growth/volunteer';
|
||||||
import type { VolunteerRecord } from '@/api/growth/volunteer';
|
import type { VolunteerRecord } from '@/api/growth/volunteer';
|
||||||
import { hasPermission, hasAnyRole } from '@/utils/permission';
|
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 tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -212,8 +256,26 @@
|
|||||||
const columns = ref<ColumnItem[]>([
|
const columns = ref<ColumnItem[]>([
|
||||||
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
|
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
|
||||||
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
|
{ 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: '时长', key: 'hours', width: 90, align: 'center' },
|
||||||
{
|
{
|
||||||
title: '服务日期',
|
title: '服务日期',
|
||||||
|
|||||||
@@ -37,7 +37,9 @@
|
|||||||
<span class="hours-text">{{ record.hours }}</span>
|
<span class="hours-text">{{ record.hours }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'status'">
|
<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>
|
||||||
<template v-else-if="column.key === 'proofFiles'">
|
<template v-else-if="column.key === 'proofFiles'">
|
||||||
<AttachmentList :value="record.proofFiles" :max-count="2" />
|
<AttachmentList :value="record.proofFiles" :max-count="2" />
|
||||||
@@ -55,24 +57,116 @@
|
|||||||
v-model:visible="showReview"
|
v-model:visible="showReview"
|
||||||
title="志愿时长审核"
|
title="志愿时长审核"
|
||||||
:confirm-loading="submitting"
|
:confirm-loading="submitting"
|
||||||
width="640px"
|
:ok-button-props="{ disabled: !myPendingNode }"
|
||||||
|
:ok-text="myPendingNode ? '提交审核' : '当前无待办'"
|
||||||
|
width="720px"
|
||||||
@ok="submitReview"
|
@ok="submitReview"
|
||||||
>
|
>
|
||||||
<a-descriptions v-if="current" :column="1" bordered size="small">
|
<a-descriptions v-if="current" :column="1" bordered size="small">
|
||||||
<a-descriptions-item label="姓名">{{ current.studentName }}</a-descriptions-item>
|
<a-descriptions-item label="姓名">{{
|
||||||
<a-descriptions-item label="学号">{{ current.studentNo }}</a-descriptions-item>
|
current.studentName
|
||||||
<a-descriptions-item label="志愿项目">{{ current.projectName }}</a-descriptions-item>
|
}}</a-descriptions-item>
|
||||||
<a-descriptions-item label="组织单位">{{ current.orgName || '-' }}</a-descriptions-item>
|
<a-descriptions-item label="学号">{{
|
||||||
<a-descriptions-item label="服务时长">{{ current.hours }} 小时</a-descriptions-item>
|
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="服务日期">
|
<a-descriptions-item label="服务日期">
|
||||||
{{ current.serviceDate || '-' }}
|
{{ current.serviceDate || '-' }}
|
||||||
<span v-if="current.serviceEndDate"> 至 {{ current.serviceEndDate }}</span>
|
<span v-if="current.serviceEndDate">
|
||||||
|
至 {{ current.serviceEndDate }}</span
|
||||||
|
>
|
||||||
</a-descriptions-item>
|
</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="证明材料">
|
<a-descriptions-item label="证明材料">
|
||||||
<AttachmentList :value="current.proofFiles" />
|
<AttachmentList :value="current.proofFiles" />
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
</a-descriptions>
|
</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 layout="vertical" style="margin-top: 16px">
|
||||||
<a-form-item label="审核结果" required>
|
<a-form-item label="审核结果" required>
|
||||||
<a-radio-group v-model:value="reviewStatus">
|
<a-radio-group v-model:value="reviewStatus">
|
||||||
@@ -94,9 +188,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import type { EleProTable } from 'ele-admin-pro';
|
import type { EleProTable } from 'ele-admin-pro';
|
||||||
|
import { toDateString } from 'ele-admin-pro';
|
||||||
import type {
|
import type {
|
||||||
DatasourceFunction,
|
DatasourceFunction,
|
||||||
ColumnItem
|
ColumnItem
|
||||||
@@ -104,11 +199,18 @@
|
|||||||
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
|
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
|
||||||
import {
|
import {
|
||||||
pageVolunteerRecords,
|
pageVolunteerRecords,
|
||||||
reviewVolunteerRecord
|
reviewVolunteerRecord,
|
||||||
|
getVolunteerProgress
|
||||||
} from '@/api/growth/volunteer';
|
} 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 tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
|
||||||
const status = ref<number>(0);
|
const status = ref<number>(0);
|
||||||
const searchText = ref('');
|
const searchText = ref('');
|
||||||
const showReview = ref(false);
|
const showReview = ref(false);
|
||||||
@@ -116,6 +218,59 @@
|
|||||||
const reviewStatus = ref(1);
|
const reviewStatus = ref(1);
|
||||||
const reviewRemark = ref('');
|
const reviewRemark = ref('');
|
||||||
const submitting = ref(false);
|
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 = [
|
const statusOptions = [
|
||||||
{ label: '待审核', value: 0 },
|
{ label: '待审核', value: 0 },
|
||||||
@@ -126,14 +281,43 @@
|
|||||||
const columns = ref<ColumnItem[]>([
|
const columns = ref<ColumnItem[]>([
|
||||||
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
|
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
|
||||||
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
|
{ 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: '时长', 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: 'proofFiles', width: 150 },
|
||||||
{ title: '状态', key: 'status', width: 100, align: 'center' },
|
{ 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 }) => {
|
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
|
||||||
@@ -156,15 +340,42 @@
|
|||||||
reload();
|
reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openReview = (record: VolunteerRecord) => {
|
const openReview = async (record: VolunteerRecord) => {
|
||||||
current.value = record;
|
current.value = record;
|
||||||
reviewStatus.value = record.status === 2 ? 2 : 1;
|
reviewStatus.value = record.status === 2 ? 2 : 1;
|
||||||
reviewRemark.value = record.reviewRemark || '';
|
reviewRemark.value = record.reviewRemark || '';
|
||||||
|
progressNodes.value = [];
|
||||||
showReview.value = true;
|
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 () => {
|
const submitReview = async () => {
|
||||||
if (!current.value?.id) return;
|
if (!current.value?.id) return;
|
||||||
|
if (progressNodes.value.length && !myPendingNode.value) {
|
||||||
|
message.warning('当前节点不在你手上,请等待前一级审核完成');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (reviewStatus.value === 2 && !reviewRemark.value.trim()) {
|
if (reviewStatus.value === 2 && !reviewRemark.value.trim()) {
|
||||||
message.warning('驳回时请填写审核意见');
|
message.warning('驳回时请填写审核意见');
|
||||||
return;
|
return;
|
||||||
@@ -205,4 +416,26 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #13c2c2;
|
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>
|
</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>
|
||||||
@@ -60,6 +60,8 @@
|
|||||||
<a-space>
|
<a-space>
|
||||||
<a @click="openDetail(record)">档案</a>
|
<a @click="openDetail(record)">档案</a>
|
||||||
<a-divider type="vertical" />
|
<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 v-if="hasPermission('gxmu:qmgcStudent:update')" @click="openEdit(record)">编辑</a>
|
||||||
<a-divider type="vertical" />
|
<a-divider type="vertical" />
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
@@ -77,6 +79,12 @@
|
|||||||
|
|
||||||
<QmgcStudentEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
<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">
|
<a-drawer v-model:visible="showDetail" title="青马培养档案" width="880">
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
<a-descriptions :column="2" bordered size="small">
|
<a-descriptions :column="2" bordered size="small">
|
||||||
@@ -88,6 +96,21 @@
|
|||||||
<a-descriptions-item label="累计得分">{{ student?.totalScore ?? 0 }}</a-descriptions-item>
|
<a-descriptions-item label="累计得分">{{ student?.totalScore ?? 0 }}</a-descriptions-item>
|
||||||
</a-descriptions>
|
</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-tabs style="margin-top: 16px">
|
||||||
<a-tab-pane key="material" tab="培养材料">
|
<a-tab-pane key="material" tab="培养材料">
|
||||||
<a-space style="margin-bottom: 12px">
|
<a-space style="margin-bottom: 12px">
|
||||||
@@ -209,6 +232,7 @@
|
|||||||
import type { EleProTable } from 'ele-admin-pro';
|
import type { EleProTable } from 'ele-admin-pro';
|
||||||
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
|
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
import QmgcStudentEdit from './components/edit.vue';
|
import QmgcStudentEdit from './components/edit.vue';
|
||||||
|
import AiAnalysis from './components/aiAnalysis.vue';
|
||||||
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
|
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
|
||||||
import {
|
import {
|
||||||
pageQmgcStudents,
|
pageQmgcStudents,
|
||||||
@@ -227,10 +251,13 @@
|
|||||||
const stage = ref<string>();
|
const stage = ref<string>();
|
||||||
const showEdit = ref(false);
|
const showEdit = ref(false);
|
||||||
const showDetail = ref(false);
|
const showDetail = ref(false);
|
||||||
|
const showAnalysis = ref(false);
|
||||||
const showMaterial = ref(false);
|
const showMaterial = ref(false);
|
||||||
const showScoreModal = ref(false);
|
const showScoreModal = ref(false);
|
||||||
const current = ref<QmgcStudent | null>(null);
|
const current = ref<QmgcStudent | null>(null);
|
||||||
const detail = ref<Record<string, any> | 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 materialForm = ref<Partial<QmgcMaterial>>({});
|
||||||
const materialFile = ref('');
|
const materialFile = ref('');
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
@@ -260,7 +287,7 @@
|
|||||||
{ title: '阶段', key: 'stage', width: 100, align: 'center' },
|
{ title: '阶段', key: 'stage', width: 100, align: 'center' },
|
||||||
{ title: '累计得分', dataIndex: 'totalScore', key: 'totalScore', width: 100, align: 'center' },
|
{ title: '累计得分', dataIndex: 'totalScore', key: 'totalScore', width: 100, align: 'center' },
|
||||||
{ title: '状态', key: 'status', width: 90, 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[] = [
|
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) => {
|
const remove = async (row: QmgcStudent) => {
|
||||||
if (!row.id) return;
|
if (!row.id) return;
|
||||||
try {
|
try {
|
||||||
@@ -402,3 +454,23 @@
|
|||||||
return map[value ?? ''] ?? value ?? '-';
|
return map[value ?? ''] ?? value ?? '-';
|
||||||
};
|
};
|
||||||
</script>
|
</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_yxgqty' },
|
||||||
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
|
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
|
||||||
{ label: '青马工程', value: 'gxmu_qmgc_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">
|
<a-spin :spinning="loading">
|
||||||
<div v-if="canOperateDeclare" class="declare-actions">
|
<div v-if="canOperateDeclare" class="declare-actions">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button size="small" :loading="actionLoading" @click="handleWithdraw">
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="handleWithdraw"
|
||||||
|
>
|
||||||
撤回申报
|
撤回申报
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
@@ -225,6 +229,7 @@
|
|||||||
import { getSjqnForm } from '@/api/gxmu/sjqn-form';
|
import { getSjqnForm } from '@/api/gxmu/sjqn-form';
|
||||||
import { getSjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form';
|
import { getSjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form';
|
||||||
import { getTyglForm } from '@/api/gxmu/tygl-form';
|
import { getTyglForm } from '@/api/gxmu/tygl-form';
|
||||||
|
import { getVolunteerRecord } from '@/api/growth/volunteer';
|
||||||
import { getTzbcyForm } from '@/api/gxmu/tzbcy-form';
|
import { getTzbcyForm } from '@/api/gxmu/tzbcy-form';
|
||||||
import { getWshqtwForm } from '@/api/gxmu/wshqtw-form';
|
import { getWshqtwForm } from '@/api/gxmu/wshqtw-form';
|
||||||
import { getWshqtzbForm } from '@/api/gxmu/wshqtzb-form';
|
import { getWshqtzbForm } from '@/api/gxmu/wshqtzb-form';
|
||||||
@@ -345,6 +350,11 @@
|
|||||||
gxmu_tygl: {
|
gxmu_tygl: {
|
||||||
title: '团员管理',
|
title: '团员管理',
|
||||||
fetcher: (id) => getTyglForm(id) as Promise<Record<string, unknown>>
|
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 () => {
|
onOk: async () => {
|
||||||
actionLoading.value = true;
|
actionLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await withdrawDeclareRecord(props.module as string, props.pk as number);
|
await withdrawDeclareRecord(
|
||||||
|
props.module as string,
|
||||||
|
props.pk as number
|
||||||
|
);
|
||||||
message.success('已撤回,申报人可继续修改');
|
message.success('已撤回,申报人可继续修改');
|
||||||
emit('done');
|
emit('done');
|
||||||
updateVisible(false);
|
updateVisible(false);
|
||||||
@@ -1085,5 +1098,4 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #8c8c8c;
|
color: #8c8c8c;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -834,6 +834,43 @@ export const MODULE_DETAIL_CONFIGS: Record<string, ModuleDetailConfig> = {
|
|||||||
],
|
],
|
||||||
materialFields: []
|
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: {
|
cms_manuscript: {
|
||||||
title: '稿件',
|
title: '稿件',
|
||||||
sheetName: '稿件',
|
sheetName: '稿件',
|
||||||
|
|||||||
@@ -27,11 +27,7 @@
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="主键" name="pk">
|
<a-form-item label="主键" name="pk">
|
||||||
<a-input
|
<a-input allow-clear placeholder="请输入主键" v-model:value="form.pk" />
|
||||||
allow-clear
|
|
||||||
placeholder="请输入主键"
|
|
||||||
v-model:value="form.pk"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="状态(0驳回 1通过)" name="status">
|
<a-form-item label="状态(0驳回 1通过)" name="status">
|
||||||
<a-radio-group v-model:value="form.status">
|
<a-radio-group v-model:value="form.status">
|
||||||
@@ -61,11 +57,7 @@
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="" name="userId">
|
<a-form-item label="" name="userId">
|
||||||
<a-input
|
<a-input allow-clear placeholder="请输入" v-model:value="form.userId" />
|
||||||
allow-clear
|
|
||||||
placeholder="请输入"
|
|
||||||
v-model:value="form.userId"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="" name="sortNumber">
|
<a-form-item label="" name="sortNumber">
|
||||||
<a-input-number
|
<a-input-number
|
||||||
@@ -129,7 +121,7 @@
|
|||||||
createTime: undefined,
|
createTime: undefined,
|
||||||
updateTime: undefined,
|
updateTime: undefined,
|
||||||
userId: undefined,
|
userId: undefined,
|
||||||
sortNumber: undefined,
|
sortNumber: undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
/* 更新visible */
|
/* 更新visible */
|
||||||
@@ -138,9 +130,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
const rules = reactive({
|
const rules = reactive({});
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
const { resetFields } = useForm(form, rules);
|
const { resetFields } = useForm(form, rules);
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,9 @@
|
|||||||
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
|
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
|
||||||
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
|
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
|
||||||
{ label: '青马工程', value: 'gxmu_qmgc_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 = () => {
|
const search = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user