17 Commits

Author SHA1 Message Date
gxwebsoft d8e7e2a2a8 feat(volunteer): 新增志愿服务证明材料识别及年度证明功能
- 新增志愿证明材料识别功能,支持自动识别材料并回填关键信息
- 在志愿时长编辑页面添加材料识别按钮及识别结果展示
- 新增志愿服务证明模块,支持查询、生成及下载年度志愿服务证明PDF
- 志愿时长列表及审核详情中新增“服务地点”字段显示
- 志愿时长导入功能支持“服务地点”字段,完善导入表头与数据映射
- 志愿时长详情、审核流程等界面优化,增加识别材料公章状态及流程节点提示
- 志愿时长待审核项支持用户撤回操作,撤回后记录被删除需重新录入
- 社团年度报告相关接口新增,实现社团年度报告生成及分页查询
- 调整相关样式与界面布局,提升用户体验与信息展示完整性
2026-11-06 12:04:32 +08:00
gxwebsoft d82a3b5426 feat(credit): 增加社会实践学分及学分预警模块
- 实现社会实践学分的提交、撤回、作废、删除、汇总及导出功能
- 审核通过后自动发放学分,确保幂等性
- 新增学分预警功能,支持缺口计算、级别判定和月度去重推送
- 设计四级审核流程,审核状态变更后自动回调处理
- 支持教务处模板导出,输出标准xls格式文件
- 提供前端页面替换原占位页,提升用户体验
- 补充每日定时任务执行学分预警重算,支持配置开关
- 修复审核回写逻辑,避免误更新不存在的表数据
2026-11-05 22:29:01 +08:00
gxwebsoft 9c45ff79f4 feat(credit): 增加社会实践学分及学分预警模块
- 实现社会实践学分的提交、撤回、作废、删除、汇总及导出功能
- 审核通过后自动发放学分,确保幂等性
- 新增学分预警功能,支持缺口计算、级别判定和月度去重推送
- 设计四级审核流程,审核状态变更后自动回调处理
- 支持教务处模板导出,输出标准xls格式文件
- 提供前端页面替换原占位页,提升用户体验
- 补充每日定时任务执行学分预警重算,支持配置开关
- 修复审核回写逻辑,避免误更新不存在的表数据
2026-11-05 19:18:26 +08:00
gxwebsoft 567e4aabdb feat(user): 支持修改当前用户资料接口并更新个人中心调用
- 新增 updateSelfUser 方法,用于修改当前登录用户的个人资料
- 个人中心调用接口由原 updateUser 切换为 updateSelfUser,避免学生账号权限不足导致的保存失败
- 更新了头像保存、个人信息保存等相关调用代码,添加异常捕获和提示
- 修正环境变量 .env.development 中 API URL 配置为生产地址
- 优化审核记录排序逻辑,新增获取最新审核记录和编辑入口显示判断函数
- 调整审核状态标签使用统一排序规则,防止显示和编辑状态不一致
- 新增社团活动、社团编辑、志愿时长录入、学分记录编辑、社会实践学分申报、青马学员编辑等多个新增组件及表单功能
- 各新增功能支持表单验证、操作反馈和数据加载优化
2026-11-05 19:03:40 +08:00
gxwebsoft 1c3ea8ec77 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	.env.production
2026-11-05 16:52:52 +08:00
weicw 0444846422 feat(declare): 申报记录支持撤回/作废,导入支持跳过与失败清单
- 申报记录详情弹窗新增撤回、作废(强制放行)入口,覆盖 9 个模块
- 申报管理支持配置允许重复提交与最多同时条数
- 批量导入逐行容错,命中重复时跳过并给出失败清单导出
2026-11-02 19:28:56 +08:00
weicw 42fddc5c85 feat(cms): 图片库升级为素材库,支持文档与音视频,缩略图统一 4:3
/cms/photo 原来只能传图片(accept=image/*,前后端各拦一次),甲方要求支持常见
文档格式与音视频,并统一缩略图宽高。

- index:标题改「素材库」,上传放开全类型,新增类型筛选(全部/图片/文档/音频/视频)
- image / list:封面统一 4:3(网格 aspect-ratio + object-fit: cover,列表 120x90);
  图片改用 thumbnail 地址(原来直接拉原图);文档/音频用类型图标卡,视频用图标卡
  加播放角标;点击视频/音频开 MediaPreview 弹窗,文档新窗口打开
- photo-edit:去掉上传表单(上传即入库,弹窗只改元数据),顺带修掉「上传已由后端
  落一条记录、点保存又 addFiles 插一条无 path 空记录」的重复入库 bug;分享代码按
  类型分流(图片 img 系列 / 视频 video / 音频 audio / 文档只给链接与下载)
- utils/media.ts:媒体大类判定、上传校验、大小格式化、类型图标与配色的唯一出口,
  MEDIA_ACCEPT 是后端 UploadRules 的镜像,两边要一起改
2026-11-01 21:03:56 +08:00
weicw 6d19cb5a05 feat(gxmu): 文稿工作台新增「一键排版」,按原文排版进富文本编辑器
生成的 markdown 正文现在能一键转成带格式的富文本:
- 正文区改为「Markdown 预览 / 富文本排版」两个 tab,一键排版后自动切到编辑器
- 转换统一走 utils/manuscript-content 的 markdownToHtml,新增 plugins 参数,
  传 gfm 插件还原表格、删除线(不挂 gfm 时表格会漏成一堆 | 管道符)
- 生成标题不拼进正文:标题有独立字段,导出 Word 时才拼进导出件
- 编辑器已有内容且文本不一致时才二次确认覆盖,同一份内容重复排版不打扰
- 富文本下方新增「保存为稿件」,入库写 contentFormat: 'html';
  存过之后按钮置灰,避免连点存出重复草稿
- oa/项目资料 里指向外部排版站的同名按钮改成「公众号排版」,避开同名不同义
2026-11-01 19:27:46 +08:00
weicw 4b810a731c fix(gxmu): 审核驳回状态在各页面统一显示为「驳回」
申报模块状态标签、审核历史、稿件审核记录与列表、我的申报历史、审核列表编辑表单及接口注释,原「不通过」文案统一改为「驳回」。
2026-11-01 18:21:46 +08:00
weicw 11e16ca311 fix(gxmu): 审核状态文案「拒绝」统一改为「驳回」
审核列表状态标签、审核流通过/驳回操作与状态文案同步调整。
2026-11-01 18:18:05 +08:00
weicw 3888438694 feat(gxmu): 审核流的审核意见改为必填项
通过/拒绝审核时审核意见为空或仅空白字符将被拦截,提交前去除首尾空格,输入框限制 500 字并显示字数。
2026-11-01 17:06:13 +08:00
weicw c81e24c977 feat(gxmu): 审核列表「查看数据」按申报表分组展示并新增上传材料页签
- 详情弹窗改为「申报表 / 上传材料」两个页签,按模块配置分组渲染字段
- 明细数组(团队成员、指导老师、评审成员、团员记录、成果归档等)渲染为表格
- 新增附件解析工具与只读附件列表组件,兼容单地址、JSON 数组、对象数组
- 未来学术之星展示经费/中期/结题/成果归档材料,挑战杯拉取 gxmu_tzbcy_material
- 未在配置中声明的字段兜底进「其他信息」,避免数据遗漏
2026-11-01 17:06:07 +08:00
weicw 92d6bdf811 fix(index): 首页能力描述由「AI+党建的双向赋能」更正为「AI+团建的双向赋能」 2026-11-01 16:36:48 +08:00
weicw fd443aced3 feat(gxmu): 申报管理选择挑战杯模块时预填申报表与公开展示信息表配置
- 选中挑战杯模块即按挑战杯申报表口径预填项目申报表与公开展示信息表的
  项目类型(3 项)、项目分组(5 项)
- 默认值复用 TZBCYForm/options 中的常量,避免两处维护产生漂移
- 仅在配置为空(含纯空行)时填充,用户手动编辑或已配置的记录不被覆盖
- 切到其他模块不再清空已填配置,切回挑战杯时保留用户内容
2026-11-01 16:35:56 +08:00
weicw 35e527ce53 Merge pull request 'feat(system): 角色管理表格行增加编辑入口(名称、备注可改,角色标识锁定)' (#2) from weicw-dshsa/tuanwei-admin:agent/role-edit-button-ba5a05896d2c into master
Reviewed-on: b2894lxlx/tuanwei-admin#2
2026-11-01 15:08:56 +08:00
dshsa-lanbg 2233b2c0e3 feat(system): 角色管理表格行增加编辑入口
角色标识参与审批流程等后端判断,前次改动整体隐藏了角色编辑入口;
本次恢复编辑入口,但编辑时角色标识锁定不可改,仅允许修改角色名称与备注,
且更新请求只提交 roleId、roleName、comments,避免误改审批流相关字段。
2026-11-01 14:56:19 +08:00
gxwebsoft 6ef7bce5ad chore(env): 更新生产环境API接口地址
- 将生产环境的API地址从线上地址切换到本地测试地址
- 保持环境变量其他配置不变
- 便于本地调试和开发验证
2026-07-22 12:15:50 +08:00
104 changed files with 18525 additions and 4898 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
VITE_APP_NAME=后台管理系统
VITE_API_URL=http://127.0.0.1:38927/api
#VITE_API_URL=https://tuanwei-api.gxwebsoft.com/api
#VITE_API_URL=http://127.0.0.1:38927/api
VITE_API_URL=https://twai-api.gxmu.edu.cn/api
+34
View File
@@ -0,0 +1,34 @@
# 2026-07-22
## VITE_API_URL 部署配置分析
**项目**:tuanwei-admin(广西医科大学团委智能化平台 - AI团宝)
### 三个 .env 文件当前配置
- `.env`(默认):`VITE_API_URL=/api`(相对路径,配合 nginx 反代)
- `.env.development`:`VITE_API_URL=http://127.0.0.1:10051/api`(开发)
- `.env.production`:`VITE_API_URL=http://twai-api/api`(生产,依赖 nginx upstream 或 docker DNS)
### 部署后登录卡住:根因不是 API URL,而是数据库
**后端错误**:
```
java.sql.SQLSyntaxErrorException: Table 'tuanwei.sys_user' doesn't exist
```
**API URL 连通性实测**:
- ✅ `http://10.129.0.125:38927/api` 通,CORS 已正确配置(Access-Control-Allow-Origin: http://10.129.0.125)
- ❌ `http://127.0.0.1:38927/api` Connection refused(端口不在 127.0.0.1 上监听,Spring Boot 绑定可能是 0.0.0.0:38927)
- ✅ `http://twai-api/api` 取决于 nginx upstream 是否配置
**`docs/sql/` 三个文件均无 sys_user 建表脚本**:
- gxmu_college_class.sql
- gxmu_mysql.sql
- gxmu_declare_migration.sql
按用户记忆约定:sys_user 应归属 gxwebsoft_core 库(租户 ID 10582),但后端当前直查 tuanwei 库导致表不存在。
### 后续待办
1. 确认后端数据源(是否在 yml 里配了多数据源 / 切库)
2. 在后端让 sys_user 走 gxwebsoft_core 库,或在 tuanwei 库建 sys_user 表
3. 生产环境推荐 VITE_API_URL=/api + nginx 反代,避免 CORS 和端口暴露
+6
View File
@@ -0,0 +1,6 @@
# 2026-07-23 工作日志
## 图片压缩需求澄清(项目搞错)
- 用户在 tuanwei-admin 提「列表页图片压缩宽度太大」需求,但 `getCompressedImageUrl` 实际在 **website-admin/app/utils/image.ts**,tuanwei-admin 源码里无此函数(曾搜遍 src / node_modules / git 历史均不存在)。
- 曾误在 tuanwei-admin 新建 `getCompressedImageUrl` 并改 3 个列表页(cms/article、sys/sysSwiper、system/user),已全部 `git checkout` 还原(`.env` 的改动非我所为,保留)。
- 真实修复在 website-admin:见 website-admin/.workbuddy/memory。
+5
View File
@@ -0,0 +1,5 @@
# 项目长期记忆(tuanwei-admin)
## 图片压缩相关需求不在此仓库
- `getCompressedImageUrl` 等 OSS 图片压缩逻辑**不在 tuanwei-admin**(源码/依赖/git 历史均无),实际在 **website-admin/app/utils/image.ts**。
- 若再收到「列表页图片压缩 / 改小宽度」类需求,直接去 website-admin 改,不要动 tuanwei-admin。
@@ -0,0 +1,295 @@
# 团委智能化平台二期实施方案
> 依据《副本二期功能--定稿0802.docx》(4 大模块 26 项功能),结合 tuanwei-admin(Vue3)与 tuanwei-java(Spring Boot)现有底座制定。文档中括号内的疑问已逐条对应到「待确认项」,未确认处按默认假设推进。
## 一、总体判断
二期不是从零建设。现有系统已经具备四项可复用底座:**申报+审核流引擎**(9 张申报表、三级审核、状态回写)、**学生/教师名册与认领体系**(`gxmu_student.user_id` 是身份主键)、**文件与 Word 导出**(S3 存储、XWPF 直绘、电子签名图片叠加)、**爬虫与异步任务**(jsoup + 定时 + 进度表)。
真正缺失的是三块能力,必须新建:
1. **站内消息中心**——后端完全没有 `oa_notice`,但前端顶栏铃铛和 `/user/notice` 页面已经在调 `/oa/notice/*`,属于「前端等后端」。
2. **业务域**——学分、志愿、社团、场地、青马培养档案、专家评审均无表、无接口。
3. **OCR / PDF 生成**——无 OCR 依赖;PDF 只能靠服务器 LibreOffice 转换。
**结论:全量规划、分三期实施。** 本期(P0)先打通「学生成长主页 + 学分 + 志愿 + 社团场地 + 青马档案 + 消息中心 + 团课库」这条主干,P1 补齐审核类与 AI 类,P2 做画像与数字人。
移动端:本机没有 uni-app 工程,`tuanwei-admin` 是纯 PC 后台。**本期只交付 PC,接口按可对外(`/api/open`)设计**,学生端后续独立建工程,不阻塞本期。
---
## 二、菜单规划
原则:**不动现有 12 个一级菜单、不迁移已有子菜单**,避免破坏已配置的角色授权。二期新增 2 个一级菜单,其余能力挂到语义最贴近的现有菜单下。
### 新增一级菜单
```
成长中心 /growth (menu_id 14500)
├ 我的成长 /growth/myHome 14501 学生个人成长主页
├ 学分明细 /growth/credit 14502 第二课堂学分 + 社会实践学分汇总
├ 社会实践学分 /growth/practiceCredit 14503 申报 + 四级审核 + 教务处模板导出
├ 学分预警 /growth/creditWarning 14504 缺口预测 + 补分建议
├ 学生画像 /growth/portrait 14505 多维雷达 + 标签
└ 推荐内容 /growth/recommend 14506 思政文章/活动推荐 + 后台发布
社团与志愿 /club (menu_id 14520)
├ 志愿服务时长 /club/volunteerHour 14521 个人录入 + 桂志愿导入
├ 志愿时长审核 /club/volunteerReview 14522 二级学院审核
├ 志愿证明 /club/volunteerCertificate 14523 年度证明生成下载
├ 社团管理 /club/club 14524 社团 + 成员 + 指导老师
├ 社团活动 /club/activity 14525 发起 + 材料
├ 活动审批 /club/activityReview 14526 四级审批 + 公章识别
├ 场地预约 /club/venue 14527 场地日历 + 预约
├ 场地审批 /club/venueReview 14528 指导老师 → 校团委
├ 社团年审 /club/annualReview 14529 指标 + 自评 + 自动取数 + 公示
└ 社团评优 /club/award 14530 评优申报 + 审核 + 公示
```
### 挂到现有菜单
| 现有菜单 | 新增子项 | path |
|---|---|---|
| 数字化团员 14342 | 青马学员档案 | `/gxmu/qmgcStudent` |
| | 培养任务 | `/gxmu/qmgcTask` |
| | 考核评分 | `/gxmu/qmgcScore` |
| | 主题团课库 | `/gxmu/thematicCourse` |
| AI内容创作与宣传助手 14365 | AI辅助教学设计 | `/gxmu/teachingDesign` |
| | AI主题团日设计 | `/gxmu/themeDayDesign` |
| 挑战杯 14372 | 专家评审 | `/gxmu/expertReview` |
| | 专家库 | `/gxmu/expertPool` |
| 系统设置 8574 | 消息模板 | `/system/messageTemplate` |
| | 推送记录 | `/system/messageLog` |
`青马工程`(14353)保留为**申报入口**,新增的学员档案管培养期,两者不合并。消息中心的用户侧直接复用已有的 `/user/notice`(已隐藏菜单)+ 顶栏铃铛,零改动即可生效。
### 不推荐但已备选
把「五四评优/学术之星/青马工程」从数字化团员迁到申报系统,结构更清晰,但会动 6 个菜单的 `parent_id` 和全部角色授权,回归风险高。如后续确需重组,单独出一个迁移脚本。
---
## 三、分期实施计划
### P0(本期,可上线的最小闭环)
| # | 功能 | 对应文档 | 交付物 |
|---|---|---|---|
| 1 | 站内消息中心 | 模块一·4 | `oa_notice` + 记录表 + `/oa/notice/*` 接口 + 审核钩子(提交/通过/驳回/待办)+ 顶栏铃铛生效 |
| 2 | 第二课堂学分 | 模块一·1 | 学分规则表 + 学分记录表 + Excel 导入(模板/校验/失败清单)+ 明细页 |
| 3 | 学生成长主页 | 模块一·3 | 聚合接口 + 简历式页面(基本信息/学分/志愿/活动/申报/荣誉) |
| 4 | 志愿时长 | 模块三·1·2 | 个人录入(盖章附件)+ 桂志愿导入 + 二级学院审核 |
| 5 | 社团管理 | 模块三·3·7 | 社团主档 + 成员 + 指导老师 + 活动发起 + 四级审批 |
| 6 | 场地预约 | 模块三·6 | 场地主档 + 日历视图 + 冲突校验 + 指导老师→校团委审批 |
| 7 | 青马培养档案 | 模块二·1 | 学员主档 + 培养材料提交 + 任务清单 + 任务完成度 |
| 8 | 主题团课库 | 模块二·6 | 爬虫(jsoup + 定时 + upsert)+ 列表/详情/下载 + 标签 |
| 9 | 工程配套 | — | 菜单/按钮/角色授权 SQL、字典、权限码、联调回归 |
估算 **53 人日**,2 人并行约 **5.5 周**。
### P1(第二批)
社会实践学分申报(四级审核 + 教务处模板导出)、学分预警(规则 + 定时 + 消息)、智能推荐(内容库 + 规则引擎 + 推送)、学生画像(维度聚合 + 雷达图)、社团年审(指标配置 + 自评 + 自动取数 + 公示)、社团评优、年度志愿证明(docx + 电子章 + PDF)、社团年度报告(聚合 + AI 小结 + 导出)、专家评审(专家库 + 任务 + 分配 + 打分 + 汇总)、AI 辅助教学设计与主题团日设计、**审核流并行审批改造**。
估算 **63 人日**,2 人并行约 **6.5 周**。
### P2(探索期)
AI 学员学习情况分析、AI 定制培养建议、OCR 公章识别接入、AI 数字人(形象 + 浮窗 + 知识库问答)、移动端接口整理。
估算 **23 人日**,约 **2.5 周**。
**合计约 139 人日,2 人并行约 14-15 周。**
---
## 四、数据模型(新表)
统一规范:`id BIGINT AUTO_INCREMENT`、`tenant_id`、`user_id`、`deleted`、`create_time`、`update_time`;附件用 `file_id + file_url` 或 JSON 数组;审批一律复用 `gxmu_review_list(module, pk)`。
### 学分 / 成长域
- `gxmu_credit_rule`:学分类别、项目、分值、认定方式(import/manual/practice)、适用年级
- `gxmu_credit_record`:`user_id, source(second_class|practice|activity), external_id, category, item_name, score, acquire_time, status, evidence`
- `gxmu_practice_credit_apply`:学年、活动名、类型、起止、角色、内容、附件 JSON
- `gxmu_graduate_requirement`:`year, college_id, major, category, required_score`
- `gxmu_credit_warning`:`user_id, category, gap, level, hit_time, notified`
- `gxmu_student_profile`:画像快照(维度分、标签 JSON、更新时间)
- `gxmu_recommend_content`:类型(article/activity)、标题、封面、关联 ID、标签
- `gxmu_recommend_delivery`:`user_id, content_id, channel, is_read`
### 志愿域
- `gxmu_volunteer_record`:`user_id, source(gz|self), project, hours, service_date, org_name, proof_files JSON, status`
- `gxmu_certificate_template`:模板标题、正文占位符、电子章图片、编号前缀
- `gxmu_volunteer_certificate`:`user_id, year, total_hours, serial_no, file_id`
### 社团 / 场地域
- `gxmu_club`:名称、类别、`college_id`、`advisor_user_id`、社长、等级、状态
- `gxmu_club_member`:`club_id, user_id, role, join_time, status`
- `gxmu_club_activity`:`club_id, title, start/end, place, scale, budget, attachments JSON, review_status, ocr_status, ocr_org`
- `gxmu_club_annual_metric`:指标名、权重、取数方式(auto/manual)、公式
- `gxmu_club_annual_review`:`club_id, year, self_score, auto_score, final_score, materials, status`
- `gxmu_club_award_apply`:`club_id, year, award_type, materials, status`
- `gxmu_club_report`:`club_id, year, summary JSON, file_id`
- `gxmu_venue`:名称、楼栋、容量、设备、开放时段、管理员、状态
- `gxmu_venue_booking`:`venue_id, user_id, club_id, booking_date, start_time, end_time, purpose, status`
### 青马域
- `gxmu_qmgc_student`:`user_id, form_id, batch, year, college_id, candidate_no, stage, result`
- `gxmu_qmgc_material`:`student_id, stage, type, title, content, file_id, score`
- `gxmu_qmgc_task`:批次、阶段、标题、类型(online/offline)、跳转方式、截止时间
- `gxmu_qmgc_task_assign`:`student_id, task_id, status, submit_time, score`
- `gxmu_qmgc_score`:`student_id, dimension, item, score, scorer, comment`
- `gxmu_qmgc_exam` / `gxmu_qmgc_exam_record`:题目 JSON、及格线、作答、得分
### 团课 / 专家 / 消息
- `gxmu_thematic_course`:`title, type(courseware|script|video|case|activity), source_site, source_url, source_key UNIQUE, file_id, content, tags, heat`
- `gxmu_expert`:`user_id, name, college, field_code, level, phone`
- `gxmu_expert_review_task`:`module, declare_id, round, start/end, quota`
- `gxmu_expert_review_assign`:`task_id, expert_id, status`
- `gxmu_expert_review_score`:`assign_id, dimensions JSON, total, comment, submit_time`
- `oa_notice` + `oa_notice_record`:从 `/Users/gxwebsoft/JAVA/com.gxwebsoft.open` 移植,字段对齐前端已有的 `noticeRecordId/type/files/channel/progress`
### 对现有表的改动(小步、可回滚)
1. `gxmu_review_flow` + `gxmu_review_list` 增加 `parallel_group INT NULL` —— 支持「二级学院团委与党委同时审核」。
2. `gxmu_review_list` 增加 `attachments TEXT` —— 审核节点上传盖章附件。
3. `gxmu_declare.module` 若新增「团委科研课题」,同步加枚举(见待确认项)。
---
## 五、后端设计
### 包结构(新增,不动现有 24 个 gxmu 控制器)
```
com.gxwebsoft.gxmu.credit/ 学分规则、记录、导入、预警
com.gxwebsoft.gxmu.qmgc/ 青马学员、任务、评分、考试
com.gxwebsoft.gxmu.club/ 社团、活动、年审、评优、年度报告
com.gxwebsoft.gxmu.venue/ 场地与预约
com.gxwebsoft.gxmu.volunteer/ 志愿时长与证明
com.gxwebsoft.gxmu.thematic/ 主题团课库与爬虫
com.gxwebsoft.gxmu.portrait/ 画像与推荐
com.gxwebsoft.gxmu.expert/ 专家评审
com.gxwebsoft.notice/ 消息中心(移植 + 渠道)
```
每个子包内 `controller/entity/mapper/service/param`,与现有 `gxmu.openplat` 的组织方式一致。
### 接口约定
- 管理端统一 `/api/gxmu/{domain}/page|list|{id}|save|update|remove|batch`,沿用 `BaseController` + `BaseParam`,前端继续传 `backendAccess=true`。
- 学生自助统一 `/api/gxmu/{domain}/my/*`,用 `hasOnlyStudentRole()` 收口,只返回本人数据。
- 审核类不在各域重复造轮子,统一走 `gxmu_review_list`,前端复用 `reviewStatusTag` / `reviewHistoryModal` / `reviewFlowModal`。
- 消息中心按前端**既有路径** `/api/oa/notice/*` 实现(`page`/`list`/`{id}`/`getUnReadNum`/`save`/`update`/`remove`),顶栏铃铛和 `/user/notice` 不需要改一行代码。
- 导出走现有模式:Controller 生成文件 → `FileStorageService.storeFile` → `FileRecord` → 返回下载地址。
### 关键改造:审核流支持并行节点
现状:`ReviewListController.update` 用 `sortNumber + 1` 取下一节点,隐含「一个节点一个人」。青马选拔要求团委与党委**同时审核**,因此:
- 模板按 `level` 分组,同 `level` 的多行属于同一并行组;
- 通过时先检查同组是否还有 `status=0` 的节点,有则不推进;
- 同组全部通过才进入下一 `level`;
- 组内任一驳回是否终止整条流,做成 `ReviewFlowConfig.rejectMode`(任一驳回 / 全部驳回后终止),默认前者。
- 该改造同时被社会实践学分、社团活动、场地预约复用。
### 关键改造:学分引擎
不引入规则引擎,用「配置表 + 计算服务」:
1. `CreditRuleService.resolve(userId, year)` 按学院/年级/专业匹配 `gxmu_graduate_requirement`,得到各类别应修学分。
2. `CreditSummaryService` 汇总 `gxmu_credit_record` 中 `status=有效` 的记录,按类别输出「已修/应修/差额/完成率」。
3. 预警任务每天跑一次:差额 > 0 且距毕业不足 N 学期 → 生成 `gxmu_credit_warning` + 发站内消息;同一学生同一类别本月只发一次。
4. 推荐:先做规则匹配(缺分 → 推荐可加分的活动/课程,按标签和学院过滤),AI 排序留到 P2。
### 关键技术点
- **志愿证明 PDF**:XWPF 直绘(复用 `WxxzxDocxExportUtil` 的 `addPicture` 叠电子章)→ `OpenOfficeUtil.converterToPDF` → 存 `FileRecord`。若服务器无 `soffice`,降级为前端打印(复用 `youthDataCenter.exportPdf` 模式),不阻塞上线。
- **团课库爬虫**:完全复用 `CrossSchoolActivityCrawlerServiceImpl` 范式(站点配置 + jsoup + `source_key` 唯一键 + `batchUpsert` + 定时任务 + 手动 `/sync`),新增文件下载入库。
- **AI 扩展**:`DifyProperties.AppKeys` 增加 4 个应用 key;`AiChatType` 增加 `teaching_design / theme_day_design / student_analysis / training_advice`;新增 `/api/ai/{xxx}` 与 `/stream`,沿用 `AiGenerationRecorder` 落历史。
- **OCR**:不引入本地推理。P1 通过阿里云 OCR HTTP 接口(需开通 + AK),字段预留 `ocr_status/ocr_org/ocr_raw`;未开通时走「人工核验 + 附件留痕」。
- **消息渠道**:站内信必做;企业微信在 `WxWorkUtil` 增加 `message/send`(token 逻辑已有);短信暂不做。
---
## 六、前端设计
### 目录与页面
```
src/views/growth/
myHome/ 我的成长(简历式:基本信息/学分/志愿/活动/申报/荣誉)
credit/ 学分明细(类别筛选 + 来源 + 明细表 + 导入)
practiceCredit/ 社会实践学分(申报表单 + 审核状态)
creditWarning/ 学分预警(雷达/进度条/建议)
portrait/ 学生画像
recommend/ 推荐内容
src/views/club/
volunteerHour/ 我的志愿时长(录入 + 导入 + 列表)
volunteerReview/ 志愿时长审核
volunteerCertificate/
club/ 社团管理(主档 + 成员抽屉)
activity/ 社团活动
activityReview/ 活动审批(复用 reviewFlowModal)
venue/ 场地预约(日历 + 预约弹窗)
venueReview/
annualReview/ 年审(指标 + 自评 + 打分)
award/
src/views/gxmu/
qmgcStudent/ qmgcTask/ qmgcScore/ thematicCourse/
teachingDesign/ themeDayDesign/
expertReview/ expertPool/
src/views/system/
messageTemplate/ messageLog/
```
### 复用清单(不要重写)
- 列表骨架:`ele-pro-table` + `.page/.ele-body/a-card`,照 `studentRoster`。
- 导入:`views/gxmu/components/common-import.vue`(模板下载、逐行校验、失败清单)。
- 附件:`components/attachmentList.vue` + `views/gxmu/utils/attachment.ts`;单文件用 `components/UploadFile`。
- 审核:`reviewStatusTag` / `reviewHistoryModal` / `reviewFlowModal` / `views/gxmu/utils/review.ts`。
- 图表:`utils/use-echarts.ts` + `vue-echarts`,照 `youthDataCenter`。
- 导出:Excel 用 `xlsx`;打印式 PDF 照 `youthDataCenter.exportPdf`;docx 用 `html-docx-js-typescript`。
- AI 页:复制 `activityOutlineGen` 壳 + `utils/ai-stream.ts` + `utils/ai-result.ts`。
- 权限:`hasPermission` / `v-permission`;角色用 `hasAnyRole`(`hasRole` 是「全部满足」语义)。
### 需要新建的公共件
1. `components/MultiFileUpload`:统一多附件上传(现有各表单各写了一套)。
2. `components/AiGenPageShell`:把「左侧表单 + 右侧流式输出 + 历史入口」抽出,避免再复制 4 个 800-1300 行的页面。
3. `utils/export.ts`:收敛 Excel/CSV/打印 PDF 的重复代码。
4. `src/constants/permissions.ts`:集中权限码。
### 顺带修的两个既有问题
- `views/user/profile` 保存走 `PUT /system/user`(要 `sys:user:update`),学生账号会失败。改为后端已有的 `PUT /system/user/self`,成长主页的「编辑资料」直接受益。
- `reviewStatusTag` 按 `id` 取最新,`utils/review.ts` 按 `createTime+id`,两处规则不一致。二期接入新审核模块前统一到 `utils/review.ts`。
---
## 七、实施顺序(建议按此推进)
1. **基建先行**:消息中心(表 + 移植 + 钩子)+ 菜单/权限 SQL + 字典 + `MultiFileUpload`。
2. **学历域**:学分规则/记录/导入 → 成长主页聚合 → 明细页。
3. **志愿域**:录入/导入 → 审核 → 证明(证明依赖 LibreOffice,放最后)。
4. **社团域**:社团主档/成员 → 活动 → 四级审批 → 场地日历 → 场地审批。
5. **青马域**:学员档案 → 任务 → 材料 → (P1)评分/考试。
6. **团课库**:建表 → 爬虫 → 列表/详情。
7. **P1**:学分预警/推荐/画像 → 社会实践 → 社团年审/评优/年报 → 专家评审 → AI 教学设计/团日设计 → 审核流并行改造(此改造需在青马选拔上线前完成,实际应提前到 P0 末尾)。
8. **P2**:AI 学员分析/培养建议 → OCR → 数字人。
每批交付都包含:建表 SQL(可重复执行)、菜单/按钮 SQL、后端接口、前端页面、联调、回归。第一步先在仓库落地 `docs/phase2-implementation-plan.md`(本方案)和 `docs/sql/phase2_*.sql`。
---
## 八、待你确认的业务细节(有默认值,确认前按默认推进)
| 事项 | 默认假设 | 影响 |
|---|---|---|
| 第二课堂 / 桂志愿 是否有 API | 先 Excel 导入,`source/external_id` 预留,后续换数据源 | P0 导入模板字段 |
| 学分规则与毕业要求 | 按「学院 + 年级 + 专业 + 类别」配应修学分,由管理员在后台维护 | 预警准确性 |
| 青马评分标准/任务清单 | 评分项和权重做成可配置表,任务分线上(可跳转)与线下(仅勾选) | P1 评分页 |
| 社团年审量化指标 | 做成指标配置表,活动数/参与人次/稿件数可自动取数,其余人工 | P1 年审 |
| 指导老师是单人还是多人 | 社团主档存 1 名主指导,审批节点支持多人并行 | 审批流 |
| 志愿证明模板与电子章 | 需要你提供模板文件与印章图片;未提供时用占位版 | P1 证明 |
| 「团委科研课题」是否新申报模块 | 视为新模块,新增 `gxmu_research_project` 表并加入申报枚举 | 专家评审范围 |
| 数字人形象 | 先用团委现有卡通形象,不做 3D | P2 |
其中前三项不确认就无法开工;其余可边做边定。**只要前三项给出结论,P0 可以立即启动。**
+68
View File
@@ -0,0 +1,68 @@
# AI 学情分析接口契约(前端已接入,后端待实现)
青马学员档案里的「AI 学情分析」入口复用现有 AI 流式链路(`utils/ai-stream.ts`),
后端需要新增一个与 `activityOutlineGen` / `manuscriptGen` 同构的端点。
## 1. 端点
| 用途 | 方法 | 路径 |
| --- | --- | --- |
| 流式分析(页面实际调用) | POST | `/api/ai/qmgcStudentAnalysis/stream` |
| 一次性返回(调试/他端) | POST | `/api/ai/qmgcStudentAnalysis` |
响应格式与现有 AI 链路一致:
- `/stream` 返回 `text/event-stream`,每帧一个 `data:` 行,帧内容为 JSON;
- 正文帧:`{ "answer": "<markdown 片段>" }`,也兼容 `{ "notice": "<片段>" }`;
- 心跳/占位帧:`{ "loading": "true" }`,前端忽略;
- 结束帧:`{ "answer": "__END__" }`;
- 失败时可用 `{ "answer": "<片段>", "reset": "true" }` 覆盖已推内容,
末帧 `status: "failed"`,前端会显示统一失败文案。
## 2. 入参
```json
{
"studentId": 123,
"focus": "thought | difficulty | advice | undefined",
"context": {
"studentName": "张三",
"studentNo": "20230001",
"collegeName": "第一临床医学院",
"className": "临床医学 2023 级 1 班",
"batch": "2026 年第 1 期",
"stage": "study",
"totalScore": 86,
"materialCount": 5,
"scoredMaterialCount": 4,
"taskDoneCount": 6,
"taskTotal": 8,
"materialTexts": ["【学习心得】……"],
"taskTexts": ["完成线上团课(已完成)"],
"scoreTexts": ["理论/课堂表现:90 分"]
}
}
```
`context` 是前端已加载数据的摘要,后端实现了按 `studentId` 自行取数后可以忽略它;
保留它是为了后端聚合尚未覆盖某类数据时,AI 仍能拿到培养材料正文。
`focus` 为空表示综合维度;调度到 Dify 时可映射成不同的提示词变量。
## 3. 数据口径
第一期可用的输入只有青马域已有数据:
- `gxmu_qmgc_material`:培养材料标题与正文、评分与评语;
- 学员任务分配记录:任务标题、完成状态;
- `gxmu_qmgc_score`:考核维度、评分项、得分与评语。
**系统目前没有考勤、签到、课堂互动类数据。** 需求原文提到的「课堂互动数据」
在采集入口落地前不参与分析,前端弹窗里已明确向教师提示这一点。后续若新增采集,
在 `context` 中补充对应摘要字段即可,无需改动流式协议。
## 4. 历史记录
如果要把分析结果写入 `ai_chat_list`,`chat_type` 需要新增一个取值
(如 `student_analysis`),并同步前端 `utils/ai-chat-type.ts` 的类型与中文名;
本次前端改动未接入历史记录,属于可选增强。
+394
View File
@@ -0,0 +1,394 @@
# 二期 P0 交付说明
## 一、本次交付范围
按《副本二期功能--定稿0802.docx》规划,P0 阶段交付以下 8 个功能模块,全部已完成前后端联调验证。
| # | 功能 | 状态 | 后端接口前缀 | 前端页面 |
| --- | --- | --- | --- | --- |
| 1 | 站内消息中心 | 已完成 | `/api/oa/notice`、`/api/oa/notice-template` | 复用既有顶栏铃铛 + `/user/notice`;新增模板与推送记录页 |
| 2 | 第二课堂学分 | 已完成 | `/api/gxmu/credit` | `/growth/credit` |
| 3 | 学生成长主页 | 已完成 | `/api/gxmu/growth/my` | `/growth/myHome` |
| 4 | 志愿服务时长 | 已完成 | `/api/gxmu/volunteer` | `/club/volunteerHour`、`/club/volunteerReview`、`/club/volunteerCertificate` |
| 5 | 社团管理 | 已完成 | `/api/gxmu/club` | `/club/club`、`/club/activity`、`/club/activityReview` |
| 6 | 场地预约 | 已完成 | `/api/gxmu/venue` | `/club/venue`、`/club/venueReview` |
| 7 | 青马培养档案 | 已完成 | `/api/gxmu/qmgc-student` | `/gxmu/qmgcStudent`、`/gxmu/qmgcTask`、`/gxmu/qmgcScore` |
| 8 | 主题团课库 | 已完成 | `/api/gxmu/thematic-course` | `/gxmu/thematicCourse` |
P1/P2 的 10 个功能已建菜单与占位页(写明实施依赖),不会出现菜单 404:
社会实践学分、学分预警、学生画像、推荐内容、社团年审、社团评优、AI 辅助教学设计、AI 主题团日设计、专家评审、专家库。
## 二、数据库部署
在 `tuanwei-java/sql/` 下按顺序执行(全部可重复执行):
```bash
mysql -h <host> -P <port> -u <user> -p tuanwei < sql/20261105_phase2_01_growth_credit.sql
mysql ... < sql/20261105_phase2_02_volunteer.sql
mysql ... < sql/20261105_phase2_03_club_venue.sql
mysql ... < sql/20261105_phase2_04_qmgc.sql
mysql ... < sql/20261105_phase2_05_thematic_course.sql
mysql ... < sql/20261105_phase2_06_notice.sql
mysql ... < sql/20261105_phase2_07_review_parallel.sql
mysql ... < sql/20261105_phase2_08_menu_permission.sql
mysql ... < sql/20261105_phase2_09_dict.sql
```
| 脚本 | 内容 | 是否含初始数据 |
| --- | --- | --- |
| 01 | 学分规则/记录/社会实践/毕业要求/预警/画像/推荐(8 张表) | 6 条毕业学分要求 |
| 02 | 志愿时长/证明模板/已生成证明(3 张表) | 无 |
| 03 | 社团/成员/活动/年审/评优/年报/场地/预约(8 张表) | 无 |
| 04 | 青马学员/材料/任务/任务分配/评分/考试(7 张表) | 无 |
| 05 | 主题团课库(1 张表) | 无 |
| 06 | 站内消息 + 消息模板(2 张表) | 10 条消息模板 |
| 07 | 审核流并行节点改造(ALTER 4 处) | 回填存量 parallel_group |
| 08 | 菜单 + 按钮权限 + 角色授权(62 条菜单/按钮) | 超管全量、学生部分 |
| 09 | 字典(学分类别、社团类别) | 11 条字典项 |
**注意**:脚本 07 会修改 `gxmu_review_flow` / `gxmu_review_list` / `gxmu_review_flow_config` 三张生产表,只是新增列并回填,不影响存量串行审核逻辑。
## 三、后端改动清单
### 新增包
```
com.gxwebsoft.notice 消息中心(Notice / NoticeTemplate / 2 个 Controller / 2 个 Mapper)
com.gxwebsoft.gxmu.credit 学分域(规则、记录、汇总、导入)
com.gxwebsoft.gxmu.volunteer 志愿服务(录入、导入、审核、汇总)
com.gxwebsoft.gxmu.club 社团(主档、成员、活动)
com.gxwebsoft.gxmu.venue 场地与预约(含时间段冲突校验)
com.gxwebsoft.gxmu.qmgcpro 青马培养档案(学员、材料、任务、评分)
com.gxwebsoft.gxmu.thematic 主题团课库(含 jsoup 爬虫)
com.gxwebsoft.gxmu.growth 成长主页聚合
```
### 关键实现说明
1. **消息中心**:路径与前端既有约定完全一致(`/api/oa/notice`),顶栏铃铛和 `/user/notice` 无需改代码即可生效。查询默认按当前登录用户收口,学生只能看自己的消息。
2. **学分汇总**:应修学分来自 `gxmu_graduate_requirement`(学院专属优先于「不限」),已修来自 `gxmu_credit_record`;按类别输出已修/应修/差额/完成率。
3. **场地冲突校验**:同场地 + 同日期 + 状态非驳回,时间段半开区间重叠即拒绝;审核通过前会二次校验,避免并发下重复通过。
4. **成长主页聚合**:串起 `sys_user` + `gxmu_student` + `gxmu_tygl_form` + 学分 + 志愿 + 社团 + 青马;任一未建表时静默跳过,不影响其他区块。
5. **消息推送**:志愿提交/审核、场地预约/审核已接入模板推送,失败不阻塞主业务。
6. **团课爬虫**:站点通过 `gxmu.thematic-course.sites` 配置(`名称|列表页|条目选择器|标题选择器|链接选择器`,多个站点换行分隔),未配置时只支持手工维护。
### 新增配置项
```yaml
gxmu:
thematic-course:
max-per-site: 50 # 单站点单轮抓取上限
sites: | # 可选,格式见上
```
## 四、前端改动清单
### 新增目录
```
src/api/growth/{credit,volunteer,home,club,qmgc,thematic}/
src/api/system/notice/
src/components/MultiFileUpload/ 统一多附件上传(JSON 数组与后端契约)
src/views/growth/{myHome,credit,practiceCredit,creditWarning,portrait,recommend}/
src/views/club/{volunteerHour,volunteerReview,volunteerCertificate,club,activity,activityReview,venue,venueReview,annualReview,award}/
src/views/gxmu/{qmgcStudent,qmgcTask,qmgcScore,thematicCourse,teachingDesign,themeDayDesign,expertReview,expertPool}/
src/views/system/{messageTemplate,messageLog}/
```
### 复用而非重写
- 列表页统一 `ele-pro-table` + `.page/.ele-body/a-card`,与 `studentRoster` 一致。
- 导入复用 `views/gxmu/components/common-import.vue`(模板下载、逐行校验、失败清单)。
- 附件展示复用 `attachmentList`,审核状态复用 `reviewStatusTag`。
- 权限判断用 `hasPermission` / `hasAnyRole`(注意 `hasRole` 是「全部满足」语义)。
## 五、验证记录
### 已执行
| 项 | 结果 |
| --- | --- |
| 后端 `mvn -o compile`(JDK 17) | 通过 |
| 前端 `vite build` | 通过 |
| 前端 `vue-tsc`(仅新增文件) | 0 错误 |
| 9 个 SQL 脚本在开发库执行 | 全部成功 |
| 18 个 GET 接口 | 全部 200 / code=0 |
| 社团 → 成员 → 详情(人数联动) | 通过 |
| 场地 → 预约 → 冲突拒绝 → 当日排期 | 通过 |
| 志愿录入(缺附件拒绝)→ 审核 → 汇总 4 小时 | 通过 |
| 学分导入 → 重复导入跳过 → 汇总 0.5/9.0 | 通过 |
| 成长主页聚合(真实学院/班级/团支书数据) | 通过 |
| 志愿审核触发站内消息落库 | 通过 |
### 验证后已清理
测试期间创建的社团、成员、场地、预约、志愿记录、学分记录、站内消息已全部删除,保留毕业要求与消息模板等基础配置。
## 六、上线前必做
1. **执行 9 个 SQL 脚本**(生产库先备份,尤其脚本 07 改动审核表)。
2. **给角色授权**:脚本 08 只授了 `superAdmin` 和 `student`,其余角色(校团委、二级学院团委、指导老师)需在「角色管理」里按需勾选二期菜单。
3. **配置团课抓取站点**(如需自动同步),否则该页只能手工录入。
4. **确认 Redis 可用**:消息与登录依赖 Redis,本地验证时需先启动。
5. **学生账号**:需要 `gxmu_student.user_id` 已认领,成长主页才能带出学籍信息;否则只显示账号基础信息。
## 七、已知边界
| 项 | 说明 |
| --- | --- |
| 志愿证明 PDF | **已实现**:按年度汇总已通过审核的志愿时长,生成含志愿者姓名、学院、专业、班级、学号、活动名称、活动时间、活动地点、时长、数据来源、总时长的 PDF,自动加水印,不设电子章(正文留公章位)。接口 `/api/gxmu/volunteer/certificate/*` |
| 社团年度数据报告 | **已实现**:按年度汇总活动、成员、场地数据,生成含核心指标、类别分布、月度趋势、成员构成图表与文字小结的 PDF。入口「社团与志愿 → 年度数据报告」(`/club/report`),接口 `/api/gxmu/club/report/*`。文字小结可按需编辑后再生成 |
| 志愿记录服务地点 | 新增 `gxmu_volunteer_record.service_place`,录入表单与导入模板均支持;证明材料「活动地点」列取此值 |
| OCR 公章识别 | 字段已预留(`ocr_status` / `ocr_org` / `ocr_raw`),未接 OCR 服务;活动审批暂由人工核验材料 |
| 审核流并行 | **已实现**,见下方「补充交付」 |
| 学分自动获取 | 第二课堂/桂志愿暂无 API,先走 Excel 导入,`source` + `external_id` 已为后续对接预留 |
| 移动端 | 本期只交付 PC,接口均支持 `/api/open` 对外复用,移动端工程后续独立建设 |
## 八、补充交付(P0 收尾追加)
### 8.1 审核流并行审批
青马选拔要求「二级学院团委 + 二级学院党委」同时审核,原审核引擎用 `sortNumber + 1` 推进,隐含「一个节点一个人」。
**改动:**
| 文件 | 改动 |
| --- | --- |
| `entity/ReviewFlow` | 新增 `parallelGroup` |
| `entity/ReviewList` | 新增 `parallelGroup`、`attachments` |
| `entity/ReviewFlowConfig` | 新增 `rejectMode` |
| `service/ReviewProgressService`(新增) | 按并行组推进、幂等创建、组内待审判断 |
| `service/impl/ReviewProgressServiceImpl`(新增) | 上述实现 |
| `service/impl/ReviewFlowStarterServiceImpl` | 发起时创建首个并行组的**全部**节点(原来只建一个) |
| `controller/ReviewListController` | 用 `progressService.advance()` 替换原串行推进 |
| `service/impl/DeclareGuardServiceImpl` | `syncReviewStatus` 改为「任一组驳回过即驳回」,不再只看最后一条 |
**语义:**
- `parallelGroup` 相同的节点属同一组,**全部通过**才进入下一组;
- 组内任一驳回按 `reject_mode` 处理,默认 `any`(任一驳回即终止);
- `parallelGroup` 为空的存量数据退化为「sortNumber 即组号」,**保持原串行行为**;
- 推进逻辑幂等:重复点击通过不会重复创建下一组节点。
**验证结果(开发库实测):**
| 场景 | 结果 |
| --- | --- |
| 同组两人,第一人通过 | 不创建下一组(仍有待审)✓ |
| 第二人通过 | 创建第 2 组节点 ✓ |
| 第 2 组驳回 | 不再创建新节点,流程终止 ✓ |
| 末组驳回后申报记录状态 | 回写为「驳回」(原逻辑会误判为通过)✓ |
### 8.2 修复:9 张申报表缺 `declare_id` 列
开发库未执行过 `sql/20261102_add_declare_uniqueness.sql`,导致青马申报页报 `Unknown column 'declare_id'`。已执行该迁移,9 张申报表接口全部恢复 200。
**上线提醒**:若生产库也未执行此迁移,请一并补上,否则青马工程、五四评优等申报页会直接报错。
### 8.3 修复:个人资料保存 403
`views/user/profile` 原来调用 `PUT /system/user`(需 `sys:user:update`),学生账号没有该权限会保存失败。已改为后端已有的 `PUT /system/user/self`,并新增前端封装 `updateSelfUser`。
### 8.4 统一最新审核记录排序
`reviewStatusTag` 原按 `id` 取最新,`utils/review.ts` 按 `createTime + id`,并行审核下两处可能给出矛盾结论。现已抽出 `getLatestReview()` 统一为「sortNumber → createTime → id」,两个调用方共用。
## 九、P1 首批交付:社会实践学分 + 学分预警
### 9.1 社会实践学分
**后端**(`com.gxwebsoft.gxmu.credit`)
| 文件 | 说明 |
| --- | --- |
| `PracticeCreditController` | 提交/撤回/作废/删除/汇总/导出 |
| `PracticeCreditService` + `Impl` | 校验、状态回写、**通过后自动发放学分** |
| `PracticeCreditApplyParam` | 查询参数 |
**审核接入**:模块名 `gxmu_practice_credit`,走既有 `gxmu_review_list` 引擎,四级审核(个人 → 班级 → 二级学院团委 → 校团委)在「审核流配置」里绑定即可。`ReviewListController` 审核完成后回调:
1. `syncReviewStatus` 回写申报状态;
2. `grantCreditWhenApproved` 在通过时写一条 `gxmu_credit_record`(`source=practice`、`external_id=practice-{id}`,幂等)。
**教务处模板导出**:`POST /api/gxmu/practice-credit/export`,输出 14 列 xls(序号/学号/姓名/学院/班级/学年/活动名称/活动类型/起止日期/实践内容/担任角色/核定学分/审核状态),走既有「生成文件 → FileRecord 入库 → 返回下载地址」模式。
**前端**:`/growth/practiceCredit`,替换原占位页。
### 9.2 学分预警
| 文件 | 说明 |
| --- | --- |
| `CreditWarningService` + `Impl` | 缺口计算、级别判定、月度去重推送 |
| `CreditWarningController` | 列表/汇总/重算/推送 |
| `CreditWarningTask`(新增) | 每天 02:30 全量重算,`gxmu.credit-warning.enabled` 可关 |
**级别规则**:`gap/required ≥ 0.6 且 gap ≥ 2` → danger;`≥ 0.3` → warn;其余 normal。
叠加绝对门槛是因为只看比例会把「应修 1 分、缺 1 分」也判成严重。
**去重**:同一学生同一类别每个自然月只推一条站内消息(`notify_month` 记录)。
**闭环**:补分后重算会自动清理已达成类别的预警(逻辑删除),不会永远挂着。
**前端**:`/growth/creditWarning`,替换原占位页。
### 9.3 验证记录
| 场景 | 结果 |
| --- | --- |
| 社会实践缺附件提交 | 拒绝「请上传社会实践证明材料」✓ |
| 正常提交 → 生成审核节点 | ✓ |
| 审核通过 → 申报状态置 2 | ✓ |
| 审核通过 → 学分自动入账(2.00,source=practice) | ✓ |
| 重复审核同一条 → 学分不重复发 | ✓(仍 1 条 2.00) |
| 学分类别缺口识别 | 6 类全部识别 ✓ |
| 补分后重算 → 该类别预警清除 | ✓(有效预警 6→5) |
| 级别判定 | 缺 2 分 danger、缺 1 分 warn ✓ |
| 预警推送 → 第一次 5 条、第二次 0 条 | 月度去重生效 ✓ |
### 9.4 修复记录
初版曾把 `gxmu_practice_credit` 加进 `DeclareGuardServiceImpl` 的模块列表,导致审核回写时按「模块名=表名」去更新不存在的表。已移除——社会实践用独立表 `gxmu_practice_credit_apply`,状态回写由自己的 service 负责。
## 十、部署排查:部署后看不到新功能
### 症状
前端已是最新版本,侧栏没有「成长中心」「社团与志愿」;访问新接口报错:
```
Table 'tuanwei.gxmu_credit_record' doesn't exist
```
### 原因
三个部分必须同时到位,缺一项都会表现为「没有新功能」:
| 部分 | 状态判断 | 缺失表现 |
| --- | --- | --- |
| 前端 dist | 已确认包含新页面(`index.0475cb90.js` 内有 21 个新路由) | 页面 404 |
| 后端 jar | 已确认新接口可达(返回业务错误而非 404) | 接口 404 |
| **数据库** | **未执行二期 SQL** | **接口报表不存在、侧栏无菜单** |
菜单是**动态从 `sys_menu` 读取的**(`GET /auth/user` → `formatMenus` → `addRoute`),
库里没有菜单记录,前端代码再全也不会显示。
### 部署步骤
在服务器的 `tuanwei-java` 目录执行:
```bash
# 方式一:一键脚本(自动备份 + 执行 9 个脚本 + 验证)
sh sql/deploy_phase2.sh ./sql
# 方式二:合并脚本(单文件,等价于依次执行 01~09)
docker exec -i 1Panel-mariadb-ZOpD \
mysql -utuanwei -p'TytQZsSW5m4wGh38' tuanwei < sql/phase2_all_in_one.sql
# 方式三:逐个执行(便于定位问题)
for f in sql/20261105_phase2_0*.sql; do
docker exec -i 1Panel-mariadb-ZOpD \
mysql -utuanwei -p'TytQZsSW5m4wGh38' tuanwei < "$f"
done
```
执行完重启后端(Docker 里通常已配置 `restart: unless-stopped`,只需
`docker restart tuanwei-api`),然后浏览器强制刷新并**重新登录**。
### 脚本安全性
- 全部可重复执行,实测连续执行两遍:146 条语句零报错,菜单/授权/模板/字典数量完全不变
- `07` 会 ALTER 三张审核表,**只新增列并回填**存量数据,不改原有字段
- 脚本建议先备份;`deploy_phase2.sh` 已内置自动备份并校验备份非空
### 执行后的验证
```sql
SELECT COUNT(*) FROM sys_menu WHERE menu_id BETWEEN 14500 AND 14663; -- 应为 62
SELECT COUNT(*) FROM oa_notice_template; -- 应为 10
SELECT COUNT(*) FROM gxmu_graduate_requirement; -- 应为 6
```
接口验证:
```bash
curl -H "Authorization: <token>" https://twai-api.gxmu.edu.cn/api/gxmu/credit/page
# 应返回 {"code":0,...} 而不是 Table doesn't exist
```
### 若菜单仍未出现
脚本 08 只给 `superAdmin` 和 `student` 授了权。若你当前账号是其他角色
(校团委、二级学院团委、指导老师等),需在「系统设置 → 角色管理」里
手动勾选二期菜单。
## 十一、导入报错 ERROR 1273 Unknown collation
### 症状
导入 SQL 时在第 271 行报:
```
ERROR 1273 (HY000): Unknown collation: 'utf8mb4_0900_ai_ci'
```
出错语句是 `CREATE TABLE gxmu_certificate_template`,结尾带
`COLLATE=utf8mb4_0900_ai_ci`。
### 原因
**导错了文件**:这条语句来自从**开发库导出的整库 dump**,不是二期的迁移脚本。
- 开发库是 **MySQL 8.0.44**,建表时 `DEFAULT CHARSET=utf8mb4` 会隐式采用
MySQL 8 专有的 `utf8mb4_0900_ai_ci`
- 生产是 **MariaDB**,不识别这个 collation,直接报错
经核查,`sql/20261105_phase2_*.sql` 里 `COLLATE=` 出现 **0 次**,
不是迁移脚本的问题。mysqldump 导出时会把隐式默认值显式写出来,于是把
MySQL 8 的特性带进了 dump。
### 解决方案
**方案 A(推荐):用迁移脚本,不要导整库 dump**
```bash
docker exec -i 1Panel-mariadb-ZOpD \
mysql -utuanwei -p'密码' tuanwei < sql/phase2_all_in_one.sql
```
迁移脚本的建表语句现在已显式锁定 `COLLATE=utf8mb4_general_ci`
(30 处建表全部处理),在 MySQL 8 / MariaDB 上都能安全执行。
**方案 B:确实要导整库 dump 时,先做兼容转换**
```bash
python3 sql/fix_mysql8_dump_for_mariadb.py dev_dump.sql
# 生成 dev_dump.mariadb.sql,再导入
docker exec -i 1Panel-mariadb-ZOpD \
mysql -utuanwei -p'密码' tuanwei < dev_dump.mariadb.sql
```
转换器会处理:
| 转换项 | 说明 |
| --- | --- |
| `utf8mb4_0900_*` → `utf8mb4_general_ci` / `utf8mb4_bin` | 核心问题 |
| `utf8mb3_*` → `utf8mb4_*` | MySQL 8 的新叫法,MariaDB 不认 |
| `CHARACTER SET utf8mb3` / `CHARSET=utf8mb3` | 裸写声明 |
| 删除 `SET @@GLOBAL.GTID_PURGED` 等 | MariaDB 不支持或会报错 |
| 处理 `/*!80000 ... */` 版本条件注释 | 仅 MySQL 8 执行的内容 |
### 当前开发库情况
开发库 88 张表中有 **61 张**是 `utf8mb4_0900_ai_ci`(`sys_user`、`gxmu_club`
等),如果整库同步到生产,这 61 张都会失败。**建议只同步二期新增的数据,
或先跑转换器**。
### 防呆
`deploy_phase2.sh` 新增了预检:执行前扫描脚本中是否含
`utf8mb4_0900_ai_ci`,命中则直接中止并提示「不要导入 MySQL 8 的整库 dump」。
+182
View File
@@ -0,0 +1,182 @@
# 团委智能化平台二期实施方案
> 依据《副本二期功能--定稿0802.docx》(4 大模块 26 项功能)制定。
> 前端仓库:`tuanwei-admin`(Vue3 + ele-admin-pro);后端仓库:`tuanwei-java`(Spring Boot + MyBatis-Plus)。
## 一、现状结论
二期的四项底座已经具备,可直接复用:
| 底座 | 现状 | 可复用点 |
| --- | --- | --- |
| 申报 + 审核流 | `gxmu_declare` / `gxmu_review_flow` / `gxmu_review_flow_config` / `gxmu_review_list`,9 张申报表全部接入 | 三级审核、状态回写、`ReviewFlowStarterService` |
| 学生统一身份 | `gxmu_student.user_id`(开放平台同步 + 认领)、`gxmu_teacher`、`gxmu_tygl_form` | 成长主页身份主键、组织作用域 |
| 文件存储 | `/api/file/upload` + S3 对象存储 + 缩略图 + office 转 PDF | 所有附件、证明材料 |
| 爬虫 + 异步任务 | `CrossSchoolActivityCrawlerServiceImpl`(jsoup + 定时 + upsert)、`OpenplatSyncServiceImpl`(进度表 + 轮询) | 主题团课库、批量导入与导出 |
三项能力必须新建:
1. **站内消息中心**:后端无 `oa_notice`,但前端顶栏铃铛与 `/user/notice` 已在调用 `/api/oa/notice/*`,属于前端等后端。
2. **业务域**:学分、志愿、社团、场地、青马培养、专家评审均无表无接口。
3. **OCR 与 PDF 生成**:无 OCR 依赖;PDF 只能通过服务器 LibreOffice 转换。
## 二、菜单规划
原则:不动现有 12 个一级菜单,不迁移已有子菜单(避免破坏角色授权)。新增 2 个一级菜单,其余挂到语义最近的现有菜单。
### 新增一级菜单
```
成长中心 /growth menu_id 14500
├ 我的成长 /growth/myHome 14501
├ 学分明细 /growth/credit 14502
├ 社会实践学分 /growth/practiceCredit 14503
├ 学分预警 /growth/creditWarning 14504
├ 学生画像 /growth/portrait 14505
└ 推荐内容 /growth/recommend 14506
社团与志愿 /club menu_id 14520
├ 志愿服务时长 /club/volunteerHour 14521
├ 志愿时长审核 /club/volunteerReview 14522
├ 志愿证明 /club/volunteerCertificate 14523
├ 社团管理 /club/club 14524
├ 社团活动 /club/activity 14525
├ 活动审批 /club/activityReview 14526
├ 场地预约 /club/venue 14527
├ 场地审批 /club/venueReview 14528
├ 社团年审 /club/annualReview 14529
└ 社团评优 /club/award 14530
```
### 挂到现有菜单
| 现有菜单 | 新增子项 |
| --- | --- |
| 数字化团员 14342 | 青马学员档案 `/gxmu/qmgcStudent`、培养任务 `/gxmu/qmgcTask`、考核评分 `/gxmu/qmgcScore`、主题团课库 `/gxmu/thematicCourse` |
| AI内容创作与宣传助手 14365 | AI辅助教学设计 `/gxmu/teachingDesign`、AI主题团日设计 `/gxmu/themeDayDesign` |
| 挑战杯 14372 | 专家评审 `/gxmu/expertReview`、专家库 `/gxmu/expertPool` |
| 系统设置 8574 | 消息模板 `/system/messageTemplate`、推送记录 `/system/messageLog` |
`青马工程`(14353)保留为申报入口;新增学员档案管理培养期,两者不合并。
消息中心的用户侧直接复用已有 `/user/notice` 与顶栏铃铛,前端零改动即可生效。
## 三、分期
### P0(可上线的最小闭环,约 53 人日)
| # | 功能 | 交付物 |
| --- | --- | --- |
| 1 | 站内消息中心 | `oa_notice` + `oa_notice_record` + `/api/oa/notice/*` + 审核钩子 |
| 2 | 第二课堂学分 | 学分规则/记录表 + Excel 导入 + 明细页 + 汇总 |
| 3 | 学生成长主页 | 聚合接口 + 简历式页面 |
| 4 | 志愿服务 | 个人录入 + 桂志愿导入 + 二级学院审核 + 时长汇总 |
| 5 | 社团管理 | 社团主档 + 成员 + 指导老师 |
| 6 | 场地预约 | 场地主档 + 日历 + 冲突校验 + 审批 |
| 7 | 青马培养档案 | 学员主档 + 培养材料 + 任务清单 |
| 8 | 主题团课库 | 表 + 列表 + 爬虫入库 |
| 9 | 工程配套 | 菜单/按钮/角色 SQL + 字典 + 权限码 |
### P1(第二批,约 63 人日)
社会实践学分申报(四级审核 + 教务处模板导出)、学分预警、智能推荐、学生画像、社团年审、社团评优、年度志愿证明、社团年度报告、专家评审、AI 辅助教学设计与主题团日设计、审核流并行审批改造。
### P2(探索期,约 23 人日)
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`。
附件:单文件用 `file_id + file_url`;多文件用 JSON 数组字符串。
审批统一复用 `gxmu_review_list(module, pk)`,模块名即业务表名。
建表脚本见:
- `docs/sql/phase2_01_growth_credit.sql`:学分、社会实践、毕业要求、预警、画像、推荐
- `docs/sql/phase2_02_volunteer.sql`:志愿服务与证明
- `docs/sql/phase2_03_club_venue.sql`:社团、成员、活动、年审、评优、场地、预约
- `docs/sql/phase2_04_qmgc.sql`:青马学员、材料、任务、评分、考试
- `docs/sql/phase2_05_thematic_course.sql`:主题团课库
- `docs/sql/phase2_06_notice.sql`:消息中心
- `docs/sql/phase2_07_review_parallel.sql`:审核流并行节点改造
- `docs/sql/phase2_08_menu_permission.sql`:菜单、按钮、角色授权
## 五、后端设计
新增包(不改动现有 24 个 gxmu 控制器):
```
com.gxwebsoft.gxmu.credit 学分规则、记录、导入、汇总、预警
com.gxwebsoft.gxmu.volunteer 志愿时长与证明
com.gxwebsoft.gxmu.club 社团、活动、年审、评优
com.gxwebsoft.gxmu.venue 场地与预约
com.gxwebsoft.gxmu.qmgcpro 青马培养档案(与现有 QmgcForm 申报区分)
com.gxwebsoft.gxmu.thematic 主题团课库
com.gxwebsoft.gxmu.growth 成长主页聚合
com.gxwebsoft.notice 消息中心
```
接口约定:
- 管理端:`/api/gxmu/{domain}/page|list|{id}|save|update|remove|batch`,沿用 `BaseController` + `BaseParam`,前端继续传 `backendAccess=true`。
- 学生自助:`/api/gxmu/{domain}/my/*`,用 `hasOnlyStudentRole()` 收口,只返回本人数据。
- 审核:不重复造轮子,统一写 `gxmu_review_list`,前端复用 `reviewStatusTag` / `reviewHistoryModal` / `reviewFlowModal`。
- 消息中心按前端既有路径 `/api/oa/notice/*` 实现,顶栏铃铛与 `/user/notice` 无需改代码。
## 六、前端设计
新增目录:
```
src/views/growth/{myHome,credit,practiceCredit,creditWarning,portrait,recommend}
src/views/club/{volunteerHour,volunteerReview,volunteerCertificate,club,activity,activityReview,venue,venueReview,annualReview,award}
src/views/gxmu/{qmgcStudent,qmgcTask,qmgcScore,thematicCourse,teachingDesign,themeDayDesign,expertReview,expertPool}
src/views/system/{messageTemplate,messageLog}
```
复用清单:
- 列表骨架 `ele-pro-table`,照 `views/gxmu/studentRoster`。
- 导入 `views/gxmu/components/common-import.vue`。
- 附件 `components/attachmentList.vue` + `views/gxmu/utils/attachment.ts`。
- 审核 `reviewStatusTag` / `reviewHistoryModal` / `reviewFlowModal`。
- 图表 `utils/use-echarts.ts`,照 `views/gxmu/youthDataCenter`。
- AI 页复制 `activityOutlineGen` 壳 + `utils/ai-stream.ts`。
新建公共件:
1. `components/MultiFileUpload`:统一多附件上传。
2. `components/AiGenPageShell`:AI 生成页外壳。
3. `utils/export.ts`:收敛 Excel/CSV/打印 PDF。
顺带修复:
- `views/user/profile` 保存改用 `PUT /system/user/self`(学生账号当前会因缺 `sys:user:update` 失败)。
- 统一「最新审核记录」排序规则到 `views/gxmu/utils/review.ts`。
## 七、实施顺序
1. 基建:消息中心 + 菜单权限 SQL + 公共上传组件。
2. 学分域:规则/记录/导入 → 成长主页聚合 → 明细页。
3. 志愿域:录入/导入 → 审核 → 汇总。
4. 社团域:社团主档/成员 → 活动 → 场地 → 预约。
5. 青马域:学员档案 → 任务 → 材料。
6. 团课库:建表 → 爬虫 → 列表。
7. P1 / P2 按上表推进。
## 八、待确认事项(有默认值,不阻塞 P0)
| 事项 | 默认假设 |
| --- | --- |
| 第二课堂 / 桂志愿 是否有 API | 先 Excel 导入,`source/external_id` 预留 |
| 学分规则与毕业要求 | 按「学院 + 年级 + 专业 + 类别」配置应修学分 |
| 青马评分标准 | 做成可配置评分项与权重 |
| 社团年审量化指标 | 指标配置表,活动数/参与人次/稿件数自动取数 |
| 指导老师人数 | 社团主档存 1 名主指导,审批节点支持多人 |
| 志愿证明模板与电子章 | 需提供模板与印章图;未提供时用占位版 |
| 团委科研课题是否新申报模块 | 视为新模块,新增 `gxmu_research_project` |
| 数字人形象 | 先用团委现有卡通形象,不做 3D |
+24
View File
@@ -0,0 +1,24 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
import type {
QmgcStudentAnalysisParams,
QmgcStudentAnalysisResponse
} from './model';
/**
* 青马学员学情分析(非流式)。
*
* 页面实际走 `/stream`(见 `@/utils/ai-stream`),这里保留普通 POST 便于
* 其他端或调试直接取完整结果。
*/
export async function qmgcStudentAnalysis(data: QmgcStudentAnalysisParams) {
const res = await request.post<ApiResult<QmgcStudentAnalysisResponse>>(
MODULES_API_URL + '/ai/qmgcStudentAnalysis',
data
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message || '分析失败'));
}
@@ -0,0 +1,21 @@
/** 青马学员学情分析入参 */
export interface QmgcStudentAnalysisParams {
/** 青马学员档案 id */
studentId: number;
/** 分析侧重点,为空时按「思想动态 + 学习难点」通用维度分析 */
focus?: string;
}
/** 青马学员学情分析结果 */
export interface QmgcStudentAnalysisResponse {
answer?: string;
/**
* 上游工作流状态。失败时为 `'failed'`,前端据此区分失败文案与正文
* (见 `@/utils/ai-result`)。
*/
status?: string;
conversationId?: string;
messageId?: string;
raw?: Record<string, any>;
[key: string]: any;
}
+448
View File
@@ -0,0 +1,448 @@
import type { PageParam, PageResult } from '@/api';
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
/** 社团 */
export interface Club {
id?: number;
clubCode?: string;
clubName?: string;
category?: string;
collegeId?: number;
organizationId?: number;
advisorUserId?: number;
advisorName?: string;
presidentUserId?: number;
presidentName?: string;
presidentPhone?: string;
memberCount?: number;
level?: string;
foundedDate?: string;
intro?: string;
cover?: string;
status?: number;
sortNumber?: number;
collegeName?: string;
members?: ClubMember[];
}
/** 社团成员 */
export interface ClubMember {
id?: number;
clubId?: number;
userId?: number;
studentNo?: string;
studentName?: string;
collegeId?: number;
classId?: number;
roleName?: string;
joinTime?: string;
quitTime?: string;
status?: number;
}
/** 社团活动 */
export interface ClubActivity {
id?: number;
clubId?: number;
clubName?: string;
title?: string;
category?: string;
startTime?: string;
endTime?: string;
place?: string;
scale?: number;
budget?: number;
content?: string;
attachments?: string;
ocrStatus?: number;
ocrOrg?: string;
status?: number;
userId?: number;
createTime?: string;
}
export interface ClubParam extends PageParam {
clubName?: string;
category?: string;
collegeId?: number;
advisorUserId?: number;
status?: number;
}
export interface ClubActivityParam extends PageParam {
clubId?: number;
title?: string;
category?: string;
status?: number;
userId?: number;
startTimeStart?: 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 {
id?: number;
venueName?: string;
building?: string;
roomNo?: string;
capacity?: number;
facilities?: string;
openStartTime?: string;
openEndTime?: string;
openWeekdays?: string;
managerUserId?: number;
managerName?: string;
adminOrgId?: number;
status?: number;
remark?: string;
cover?: string;
sortNumber?: number;
}
/** 场地预约 */
export interface VenueBooking {
id?: number;
venueId?: number;
venueName?: string;
userId?: number;
applicantName?: string;
applicantPhone?: string;
clubId?: number;
clubName?: string;
bookingDate?: string;
startTime?: string;
endTime?: string;
purpose?: string;
attendeeCount?: number;
status?: number;
reviewRemark?: string;
createTime?: string;
}
export interface VenueBookingParam extends PageParam {
venueId?: number;
userId?: number;
clubId?: number;
status?: number;
bookingDate?: string;
bookingDateStart?: string;
bookingDateEnd?: string;
}
// ---------------- 社团 ----------------
export async function pageClubs(params: ClubParam) {
const res = await request.get<ApiResult<PageResult<Club>>>(
MODULES_API_URL + '/gxmu/club/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function listClubs(params?: ClubParam) {
const res = await request.get<ApiResult<Club[]>>(
MODULES_API_URL + '/gxmu/club',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function getClub(id: number) {
const res = await request.get<ApiResult<Club>>(
MODULES_API_URL + '/gxmu/club/' + id
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function addClub(data: Club) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/club',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function updateClub(data: Club) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/club',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removeClub(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/club/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function listClubMembers(clubId: number, status?: number) {
const res = await request.get<ApiResult<ClubMember[]>>(
MODULES_API_URL + `/gxmu/club/${clubId}/member`,
{ params: { status } }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function addClubMember(data: ClubMember) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/club/member',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removeClubMember(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/club/member/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function pageClubActivities(params: ClubActivityParam) {
const res = await request.get<ApiResult<PageResult<ClubActivity>>>(
MODULES_API_URL + '/gxmu/club/activity/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function submitClubActivity(data: ClubActivity) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/club/activity',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function reviewClubActivity(data: ClubActivity) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/club/activity/review',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removeClubActivity(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/club/activity/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function listMyClubs() {
const res = await request.get<ApiResult<Club[]>>(
MODULES_API_URL + '/gxmu/club/my'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
// ---------------- 场地 ----------------
export async function pageVenues(params: { page?: number; limit?: number; venueName?: string; building?: string; status?: number }) {
const res = await request.get<ApiResult<PageResult<Venue>>>(
MODULES_API_URL + '/gxmu/venue/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function listVenues() {
const res = await request.get<ApiResult<Venue[]>>(
MODULES_API_URL + '/gxmu/venue'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function addVenue(data: Venue) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/venue',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function updateVenue(data: Venue) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/venue',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removeVenue(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/venue/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function pageVenueBookings(params: VenueBookingParam) {
const res = await request.get<ApiResult<PageResult<VenueBooking>>>(
MODULES_API_URL + '/gxmu/venue/booking/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function createVenueBooking(data: VenueBooking) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/venue/booking',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function reviewVenueBooking(data: VenueBooking) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/venue/booking/review',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function cancelVenueBooking(id: number) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/venue/booking/cancel/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function getVenueSchedule(venueId: number, date: string) {
const res = await request.get<ApiResult<Record<string, any>[]>>(
MODULES_API_URL + '/gxmu/venue/booking/schedule',
{ params: { venueId, date } }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+224
View File
@@ -0,0 +1,224 @@
import type { PageParam, PageResult } from '@/api';
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
/** 学分分类汇总 */
export interface CategorySummary {
category: string;
earned: number;
required: number;
gap: number;
rate: number;
count: number;
}
/** 学分汇总 */
export interface CreditSummary {
userId: number;
studentName?: string;
studentNo?: string;
totalEarned: number;
totalRequired: number;
totalGap: number;
completionRate: number;
categories: CategorySummary[];
}
/** 学分记录 */
export interface CreditRecord {
id?: number;
userId?: number;
studentNo?: string;
studentName?: string;
collegeId?: number;
classId?: number;
category?: string;
itemName?: string;
score?: number;
acquireTime?: string;
source?: string;
externalId?: string;
evidence?: string;
status?: number;
remark?: string;
createTime?: string;
updateTime?: string;
}
export interface CreditRecordParam extends PageParam {
userId?: number;
studentNo?: string;
studentName?: string;
collegeId?: number;
classId?: number;
category?: string;
source?: string;
status?: number;
acquireTimeStart?: string;
acquireTimeEnd?: string;
backendAccess?: boolean;
}
/** 学分规则 */
export interface CreditRule {
id?: number;
category?: string;
itemName?: string;
score?: number;
source?: string;
gradeScope?: string;
collegeScope?: string;
status?: number;
remark?: string;
}
/** 毕业学分要求 */
export interface GraduateRequirement {
id?: number;
year?: number;
collegeId?: number;
major?: string;
category?: string;
requiredScore?: number;
sortNumber?: number;
remark?: string;
}
/** 分页查询学分记录 */
export async function pageCreditRecords(params: CreditRecordParam) {
const res = await request.get<ApiResult<PageResult<CreditRecord>>>(
MODULES_API_URL + '/gxmu/credit/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 新增学分记录 */
export async function addCreditRecord(data: CreditRecord) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/credit',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 修改学分记录 */
export async function updateCreditRecord(data: CreditRecord) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/credit',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 删除学分记录 */
export async function removeCreditRecord(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/credit/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 导入一条学分记录 */
export async function importCreditRecord(data: CreditRecord) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/credit/import',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 我的学分汇总 */
export async function getMyCreditSummary() {
const res = await request.get<ApiResult<CreditSummary>>(
MODULES_API_URL + '/gxmu/credit/my/summary'
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 我的应修学分要求 */
export async function getMyCreditRequirements() {
const res = await request.get<ApiResult<Record<string, number>>>(
MODULES_API_URL + '/gxmu/credit/my/requirements'
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 学分规则列表 */
export async function listCreditRules() {
const res = await request.get<ApiResult<CreditRule[]>>(
MODULES_API_URL + '/gxmu/credit/rule'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 毕业要求列表 */
export async function listGraduateRequirements() {
const res = await request.get<ApiResult<GraduateRequirement[]>>(
MODULES_API_URL + '/gxmu/credit/requirement'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 新增毕业要求 */
export async function addGraduateRequirement(data: GraduateRequirement) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/credit/requirement',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 修改毕业要求 */
export async function updateGraduateRequirement(data: GraduateRequirement) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/credit/requirement',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 删除毕业要求 */
export async function removeGraduateRequirement(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/credit/requirement/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
+65
View File
@@ -0,0 +1,65 @@
import type { PageParam, PageResult } from '@/api';
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
/** 学生成长主页 */
export interface GrowthHome {
basic?: {
userId?: number;
name?: string;
studentNo?: string;
avatar?: string;
gender?: string;
collegeName?: string;
className?: string;
major?: string;
grade?: string;
phone?: string;
leaguePosition?: string;
joinMonth?: string;
politics?: string;
profileCompletion?: number;
};
credit?: {
totalEarned?: number;
totalRequired?: number;
totalGap?: number;
completionRate?: number;
categories?: {
category: string;
earned: number;
required: number;
gap: number;
rate: number;
count: number;
}[];
};
volunteer?: Record<string, any>;
activity?: Record<string, any>;
declarations?: Record<string, any>[];
qmgc?: Record<string, any> | null;
}
/** 我的成长主页 */
export async function getMyGrowthHome() {
const res = await request.get<ApiResult<GrowthHome>>(
MODULES_API_URL + '/gxmu/growth/my'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 指定学生的成长主页 */
export async function getGrowthHome(userId: number) {
const res = await request.get<ApiResult<GrowthHome>>(
MODULES_API_URL + '/gxmu/growth/home',
{ params: { userId } }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+132
View File
@@ -0,0 +1,132 @@
import type { PageParam, PageResult } from '@/api';
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
/** 社会实践学分申报 */
export interface PracticeCreditApply {
id?: number;
userId?: number;
studentNo?: string;
studentName?: string;
collegeId?: number;
classId?: number;
schoolYear?: string;
activityName?: string;
activityType?: string;
startDate?: string;
endDate?: string;
roleName?: string;
content?: string;
score?: number;
approvedScore?: number;
attachments?: string;
status?: number;
remark?: string;
createTime?: string;
updateTime?: string;
}
export interface PracticeCreditParam extends PageParam {
userId?: number;
studentNo?: string;
studentName?: string;
collegeId?: number;
classId?: number;
schoolYear?: string;
activityType?: string;
status?: number;
backendAccess?: boolean;
}
export const PRACTICE_STATUS_OPTIONS = [
{ label: '草稿', value: 0 },
{ label: '待审核', value: 1 },
{ label: '通过', value: 2 },
{ label: '驳回', value: 3 },
{ label: '作废', value: 4 }
];
export const PRACTICE_TYPE_OPTIONS = [
{ label: '寒暑假社会实践', value: '寒暑假社会实践' },
{ label: '志愿服务', value: '志愿服务' },
{ label: '社会调研', value: '社会调研' },
{ label: '实习实训', value: '实习实训' },
{ label: '其他', value: '其他' }
];
export async function pagePracticeCredits(params: PracticeCreditParam) {
const res = await request.get<ApiResult<PageResult<PracticeCreditApply>>>(
MODULES_API_URL + '/gxmu/practice-credit/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function submitPracticeCredit(data: PracticeCreditApply) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/practice-credit/my',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function withdrawPracticeCredit(id: number) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + `/gxmu/practice-credit/${id}/withdraw`
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function voidPracticeCredit(id: number, reason?: string) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + `/gxmu/practice-credit/${id}/void`,
null,
{ params: { reason } }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removePracticeCredit(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/practice-credit/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function getMyPracticeSummary() {
const res = await request.get<ApiResult<Record<string, any>>>(
MODULES_API_URL + '/gxmu/practice-credit/my/summary'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 导出教务处模板数据表 */
export async function exportPracticeCredit(params: PracticeCreditParam) {
const res = await request.post<ApiResult<Record<string, any>>>(
MODULES_API_URL + '/gxmu/practice-credit/export',
params
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+289
View File
@@ -0,0 +1,289 @@
import type { PageParam, PageResult } from '@/api';
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
/** 青马学员 */
export interface QmgcStudent {
id?: number;
userId?: number;
formId?: number;
studentNo?: string;
studentName?: string;
gender?: string;
phone?: string;
collegeId?: number;
collegeName?: string;
classId?: number;
className?: string;
batch?: string;
year?: number;
candidateNo?: string;
stage?: string;
stageName?: string;
result?: string;
totalScore?: number;
remark?: string;
status?: number;
createTime?: string;
}
/** 青马培养材料 */
export interface QmgcMaterial {
id?: number;
studentId?: number;
stage?: string;
type?: string;
title?: string;
content?: string;
fileId?: number;
fileUrl?: string;
submitTime?: string;
score?: number;
scorerId?: number;
scoreComment?: string;
status?: number;
}
/** 青马培养任务 */
export interface QmgcTask {
id?: number;
batch?: string;
stage?: string;
title?: string;
type?: string;
linkPath?: string;
content?: string;
mustDo?: number;
score?: number;
deadline?: string;
sortNumber?: number;
status?: number;
}
/** 青马考核评分 */
export interface QmgcScore {
id?: number;
studentId?: number;
dimension?: string;
item?: string;
score?: number;
maxScore?: number;
scorerId?: number;
scorerName?: string;
comment?: string;
scoredTime?: string;
}
export interface QmgcStudentParam extends PageParam {
userId?: number;
formId?: number;
studentNo?: string;
studentName?: string;
collegeId?: number;
batch?: string;
year?: number;
stage?: string;
status?: number;
}
export interface QmgcTaskParam extends PageParam {
batch?: string;
stage?: string;
type?: string;
status?: number;
}
export async function pageQmgcStudents(params: QmgcStudentParam) {
const res = await request.get<ApiResult<PageResult<QmgcStudent>>>(
MODULES_API_URL + '/gxmu/qmgc-student/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function getQmgcStudentDetail(id: number) {
const res = await request.get<ApiResult<Record<string, any>>>(
MODULES_API_URL + '/gxmu/qmgc-student/' + id
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function addQmgcStudent(data: QmgcStudent) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/qmgc-student',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function updateQmgcStudent(data: QmgcStudent) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/qmgc-student',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removeQmgcStudent(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/qmgc-student/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function getMyQmgc() {
const res = await request.get<ApiResult<Record<string, any> | null>>(
MODULES_API_URL + '/gxmu/qmgc-student/my'
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function listQmgcMaterials(studentId: number) {
const res = await request.get<ApiResult<QmgcMaterial[]>>(
MODULES_API_URL + `/gxmu/qmgc-student/${studentId}/material`
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function submitQmgcMaterial(data: QmgcMaterial) {
const res = await request.post<ApiResult<QmgcMaterial>>(
MODULES_API_URL + '/gxmu/qmgc-student/material',
data
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function scoreQmgcMaterial(id: number, score: number, comment?: string) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + `/gxmu/qmgc-student/material/${id}/score`,
null,
{ params: { score, comment } }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function listQmgcStudentTasks(studentId: number) {
const res = await request.get<ApiResult<Record<string, any>[]>>(
MODULES_API_URL + `/gxmu/qmgc-student/${studentId}/task`
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function completeQmgcTask(studentId: number, taskId: number, proofFiles?: string) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + `/gxmu/qmgc-student/${studentId}/task/${taskId}/complete`,
null,
{ params: { proofFiles } }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function assignQmgcTasks(studentId: number, batch?: string) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + `/gxmu/qmgc-student/${studentId}/task/assign`,
null,
{ params: { batch } }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function pageQmgcTasks(params: QmgcTaskParam) {
const res = await request.get<ApiResult<PageResult<QmgcTask>>>(
MODULES_API_URL + '/gxmu/qmgc-student/task/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function addQmgcTask(data: QmgcTask) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/qmgc-student/task',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function updateQmgcTask(data: QmgcTask) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/qmgc-student/task',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removeQmgcTask(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/qmgc-student/task/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function listQmgcScores(studentId: number) {
const res = await request.get<ApiResult<QmgcScore[]>>(
MODULES_API_URL + `/gxmu/qmgc-student/${studentId}/score`
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function saveQmgcScore(data: QmgcScore) {
const res = await request.post<ApiResult<QmgcScore>>(
MODULES_API_URL + '/gxmu/qmgc-student/score',
data
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+102
View File
@@ -0,0 +1,102 @@
import type { PageParam, PageResult } from '@/api';
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
/** 主题团课资源 */
export interface ThematicCourse {
id?: number;
title?: string;
type?: string;
sourceSite?: string;
sourceUrl?: string;
sourceKey?: string;
cover?: string;
summary?: string;
content?: string;
fileId?: number;
fileUrl?: string;
tags?: string;
heat?: number;
publishTime?: string;
status?: number;
lastSyncTime?: string;
createTime?: string;
}
export interface ThematicCourseParam extends PageParam {
type?: string;
tags?: string;
status?: number;
}
export const THEMATIC_TYPE_OPTIONS = [
{ label: '课件', value: 'courseware' },
{ label: '讲稿', value: 'script' },
{ label: '视频', value: 'video' },
{ label: '案例', value: 'case' },
{ label: '活动方案', value: 'activity' }
];
export async function pageThematicCourses(params: ThematicCourseParam) {
const res = await request.get<ApiResult<PageResult<ThematicCourse>>>(
MODULES_API_URL + '/gxmu/thematic-course/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function getThematicCourse(id: number) {
const res = await request.get<ApiResult<ThematicCourse>>(
MODULES_API_URL + '/gxmu/thematic-course/' + id
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function addThematicCourse(data: ThematicCourse) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/thematic-course',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function updateThematicCourse(data: ThematicCourse) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/thematic-course',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removeThematicCourse(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/thematic-course/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function syncThematicCourses() {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/thematic-course/sync'
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
+276
View File
@@ -0,0 +1,276 @@
import type { PageParam, PageResult } from '@/api';
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
/** 志愿服务时长记录 */
export interface VolunteerRecord {
id?: number;
userId?: number;
studentNo?: string;
studentName?: string;
collegeId?: number;
classId?: number;
source?: string;
externalId?: string;
projectName?: string;
orgName?: string;
servicePlace?: string;
hours?: number;
serviceDate?: string;
serviceEndDate?: string;
content?: string;
proofFiles?: string;
ocrStatus?: number;
ocrOrg?: string;
ocrProjectName?: string;
ocrPlace?: string;
ocrHours?: number;
ocrHasSeal?: number;
ocrRaw?: string;
status?: number;
reviewRemark?: string;
reviewerId?: number;
reviewTime?: string;
createTime?: string;
updateTime?: string;
}
export interface VolunteerRecordParam extends PageParam {
userId?: number;
studentNo?: string;
studentName?: string;
collegeId?: number;
classId?: number;
source?: string;
status?: number;
serviceDateStart?: string;
serviceDateEnd?: string;
backendAccess?: boolean;
}
/** 分页查询志愿时长 */
export async function pageVolunteerRecords(params: VolunteerRecordParam) {
const res = await request.get<ApiResult<PageResult<VolunteerRecord>>>(
MODULES_API_URL + '/gxmu/volunteer/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 按 id 查询志愿时长详情 */
export async function getVolunteerRecord(id: number) {
const res = await request.get<ApiResult<VolunteerRecord>>(
MODULES_API_URL + '/gxmu/volunteer/' + id
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 个人录入志愿时长 */
export async function submitVolunteerRecord(data: VolunteerRecord) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/volunteer/my',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 导入志愿时长 */
export async function importVolunteerRecord(data: VolunteerRecord) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/volunteer/import',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 审核志愿时长 */
export async function reviewVolunteerRecord(data: VolunteerRecord) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/volunteer/review',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 删除志愿时长 */
export async function removeVolunteerRecord(id: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/volunteer/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 我的累计志愿时长 */
export async function getMyVolunteerTotal(year?: number) {
const res = await request.get<ApiResult<number>>(
MODULES_API_URL + '/gxmu/volunteer/my/total',
{ params: { year } }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 我的志愿时长年度汇总 */
export async function getMyVolunteerSummary() {
const res = await request.get<ApiResult<Record<string, any>>>(
MODULES_API_URL + '/gxmu/volunteer/my/summary'
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 我的志愿项目分组统计 */
export async function getMyVolunteerProjectStats(year?: number) {
const res = await request.get<ApiResult<Record<string, any>[]>>(
MODULES_API_URL + '/gxmu/volunteer/my/project',
{ params: { year } }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
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));
}
+91
View File
@@ -0,0 +1,91 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
/** 学分类警 */
export interface CreditWarning {
id?: number;
userId?: number;
category?: string;
requiredScore?: number;
earnedScore?: number;
gap?: number;
level?: string;
hitTime?: string;
notified?: number;
notifyMonth?: string;
studentName?: string;
studentNo?: string;
}
export interface CreditWarningParam {
userId?: number;
category?: string;
level?: string;
notified?: number;
backendAccess?: boolean;
}
export const WARNING_LEVEL_OPTIONS = [
{ label: '正常', value: 'normal' },
{ label: '注意', value: 'warn' },
{ label: '严重', value: 'danger' }
];
/** 查询预警列表 */
export async function listCreditWarnings(params: CreditWarningParam) {
const res = await request.get<ApiResult<CreditWarning[]>>(
MODULES_API_URL + '/gxmu/credit-warning',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 预警汇总 */
export async function getCreditWarningSummary() {
const res = await request.get<ApiResult<Record<string, any>>>(
MODULES_API_URL + '/gxmu/credit-warning/summary'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 重算我的预警 */
export async function recalcMyWarnings() {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/credit-warning/my/recalculate'
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 全量重算 */
export async function recalcAllWarnings() {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/credit-warning/recalculate'
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 发送预警提醒 */
export async function notifyCreditWarning(userId?: number) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/gxmu/credit-warning/notify',
null,
{ params: { userId } }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
+34
View File
@@ -0,0 +1,34 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
/**
* 撤回申报:待审核 -> 草稿
*/
export async function withdrawDeclareRecord(module: string, id: number) {
const res = await request.post<ApiResult<unknown>>(
'/gxmu/declare-record/withdraw',
{ module, id }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 作废申报(等同强制放行,作废后申报人可重新申报)
*/
export async function voidDeclareRecord(
module: string,
id: number,
reason?: string
) {
const res = await request.post<ApiResult<unknown>>(
'/gxmu/declare-record/void',
{ module, id, reason }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
+13 -1
View File
@@ -36,6 +36,18 @@ export interface Declare {
createTime?: string;
// 更新时间
updateTime?: string;
// 是否允许重复提交:0否 1是
allowRepeat?: number;
// 最多同时进行中的申报条数
maxPending?: number;
// 当前用户在该项目下的最新申报状态:0草稿 1待审核 2通过 3驳回 4作废
myStatus?: number;
// 当前用户在该项目下的最新申报记录id
myRecordId?: number;
// 当前用户在该项目下的最新申报记录提交时间
mySubmitTime?: string;
// 当前用户在该项目下的最新申报记录状态备注
myStatusRemark?: string;
// 删除标记:0未删 1已删
deleted?: number;
// 是否可申报
@@ -55,4 +67,4 @@ export interface DeclareParam extends PageParam {
formProjectGroup?: string;
publicProjectType?: string;
publicProjectGroup?: string;
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ export interface ReviewList {
businessName?: string;
// 主键
pk?: number;
// 状态(0不通过 1通过)
// 状态(0驳回 1通过)
status?: number;
// 审核意见
content?: string;
+2
View File
@@ -39,6 +39,8 @@ export interface FileRecordParam extends PageParam {
name?: string;
path?: string;
contentType?: string;
// 媒体大类: image/video/audio/doc,空表示不限
mediaCategory?: string;
createNickname?: string;
count?: number;
page?: number;
+95
View File
@@ -0,0 +1,95 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
/** 消息模板 */
export interface NoticeTemplate {
id?: number;
code?: string;
type?: string;
title?: string;
content?: string;
icon?: string;
color?: string;
status?: number;
createTime?: string;
updateTime?: string;
}
/** 站内消息 */
export interface NoticeItem {
noticeId?: number;
type?: string;
title?: string;
content?: string;
isRead?: number;
status?: number;
userId?: number;
senderName?: string;
createTime?: string;
}
/** 分页查询消息模板(复用通用分页接口未提供时,前端直接读后端接口) */
export async function pageNoticeTemplates(params: {
page?: number;
limit?: number;
keywords?: string;
}) {
const res = await request.get<ApiResult<PageResult<NoticeTemplate>>>(
'/oa/notice-template/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function addNoticeTemplate(data: NoticeTemplate) {
const res = await request.post<ApiResult<unknown>>(
'/oa/notice-template',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function updateNoticeTemplate(data: NoticeTemplate) {
const res = await request.put<ApiResult<unknown>>(
'/oa/notice-template',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removeNoticeTemplate(id: number) {
const res = await request.delete<ApiResult<unknown>>(
'/oa/notice-template/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 分页查询推送记录(后台视角,跨用户) */
export async function pageNoticeLog(params: {
page?: number;
limit?: number;
type?: string;
keywords?: string;
}) {
const res = await request.get<ApiResult<PageResult<NoticeItem>>>(
'/oa/notice/page',
{ params: { ...params, backendAccess: true } }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+14
View File
@@ -77,6 +77,20 @@ export async function addUser(data: User) {
return Promise.reject(new Error(res.data.message));
}
/**
* 修改当前登录用户自己的资料。
*
* <p>个人中心必须走这个接口:{@code PUT /system/user} 需要 sys:user:update 权限,
* 学生账号没有该权限,直接用会保存失败。
*/
export async function updateSelfUser(data: User) {
const res = await request.put<ApiResult<unknown>>("/system/user/self", data);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 修改用户
*/
+130
View File
@@ -0,0 +1,130 @@
<!-- 多附件上传:v-model 绑定 JSON 数组字符串 -->
<template>
<div class="multi-file-upload">
<a-upload
:file-list="fileList"
:multiple="maxCount > 1"
:max-count="maxCount"
:accept="MEDIA_ACCEPT"
:customRequest="onUpload"
@remove="onRemove"
@preview="onPreview"
>
<a-button :disabled="fileList.length >= maxCount">
<template #icon><upload-outlined /></template>
选择文件
</a-button>
</a-upload>
<div v-if="hint" class="ele-text-secondary upload-hint">{{ hint }}</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import type { UploadFile } from 'ant-design-vue';
import { uploadFile } from '@/api/system/file';
import type { FileRecord } from '@/api/system/file/model';
import { MEDIA_ACCEPT, validateMediaFile } from '@/utils/media';
const props = withDefaults(
defineProps<{
modelValue?: string;
maxCount?: number;
hint?: string;
}>(),
{ maxCount: 5 }
);
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const fileList = ref<UploadFile[]>([]);
const syncing = ref(false);
const parseValue = (value?: string): FileRecord[] => {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [parsed];
} catch (e) {
// 历史数据可能是单个 URL
return value.startsWith('http') ? [{ url: value, name: value.split('/').pop() }] : [];
}
};
const toFileList = (records: FileRecord[]): UploadFile[] =>
records.map((item, index) => ({
uid: `${item.id ?? item.url ?? index}-${index}`,
name: item.name || item.url?.split('/').pop() || `附件${index + 1}`,
status: 'done',
url: item.url,
response: item
}));
watch(
() => props.modelValue,
(value) => {
if (syncing.value) return;
fileList.value = toFileList(parseValue(value));
},
{ immediate: true }
);
const emitValue = () => {
syncing.value = true;
const records = fileList.value
.map((item) => item.response as FileRecord | undefined)
.filter((item): item is FileRecord => !!item?.url);
emit('update:modelValue', records.length ? JSON.stringify(records) : '');
setTimeout(() => {
syncing.value = false;
}, 0);
};
const onUpload = async (options: any) => {
const { file, onSuccess, onError } = options;
const error = validateMediaFile(file);
if (error) {
message.error(error);
onError?.(new Error(error));
return;
}
try {
const record = await uploadFile(file);
const uploadItem: UploadFile = {
uid: `${record.id ?? Date.now()}`,
name: record.name || file.name,
status: 'done',
url: record.url,
response: record
};
fileList.value = [...fileList.value, uploadItem];
emitValue();
onSuccess?.(record);
} catch (e: any) {
message.error(e?.message || '上传失败');
onError?.(e);
}
};
const onRemove = (file: UploadFile) => {
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
emitValue();
return true;
};
const onPreview = (file: UploadFile) => {
if (file.url) {
window.open(file.url, '_blank');
}
};
</script>
<style lang="less" scoped>
.upload-hint {
margin-top: 4px;
font-size: 12px;
}
</style>
+14
View File
@@ -134,6 +134,20 @@ export function streamActivityOutlineGen(
});
}
/** 青马学员学情分析(HTTP 流式) */
export function streamQmgcStudentAnalysis(
data: Record<string, unknown>,
onMessage: (raw: string) => void,
signal?: AbortSignal
): Promise<void> {
return streamAiRequest({
path: '/ai/qmgcStudentAnalysis/stream',
data,
onMessage,
signal
});
}
/** 创意助手(HTTP 流式) */
export function streamCreativeAssistant(
data: Record<string, unknown>,
+13 -3
View File
@@ -10,6 +10,7 @@
* 所以打开编辑弹窗时先把非 HTML 的正文转成 HTML,保存时统一写 `contentFormat: 'html'`。
*/
import { getProcessor } from 'bytemd';
import type { BytemdPlugin } from 'bytemd';
const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i;
const MARKDOWN_LINE_RE =
@@ -57,11 +58,20 @@ export function detectFormat(content?: string | null): ContentFormat {
return looksLikeMarkdown(text) ? 'markdown' : 'text';
}
/** markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。 */
export function markdownToHtml(markdown: string): string {
/**
* markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。
*
* `plugins` 决定润色到什么程度:默认不挂 GFM,表格会原样漏成一堆 `|` 管道符、
* 删除线也会丢。要让「表格/删除线」和预览一致,调用方传 gfm 插件进来
* (generate 页的一键排版就是这么做的)。转换口径只留这一处,别再各写一套。
*/
export function markdownToHtml(
markdown: string,
plugins: BytemdPlugin[] = []
): string {
try {
// processSync 返回的是 vfile(`toString()` 即 HTML),不是裸字符串
const result = getProcessor({}).processSync(String(markdown ?? ''));
const result = getProcessor({ plugins }).processSync(String(markdown ?? ''));
return String(result);
} catch (e) {
console.error('markdown 转换失败', e);
+220
View File
@@ -0,0 +1,220 @@
import type { Component } from 'vue';
import {
CustomerServiceOutlined,
FileExcelOutlined,
FileMarkdownOutlined,
FilePdfOutlined,
FilePptOutlined,
FileTextOutlined,
FileUnknownOutlined,
FileWordOutlined,
VideoCameraOutlined
} from '@ant-design/icons-vue';
import type { FileRecord } from '@/api/system/file/model';
/**
* 媒体大类。
*
* 注意它和 `contentType`(MIME 字符串)不是一回事:MIME 里「文档」没有统一前缀
* (pdf/docx/zip 都是 application/…),所以大类由 MIME 前缀 + 后缀一起判定。
*/
export type MediaKind = 'image' | 'video' | 'audio' | 'doc';
/** 与后端 UploadRules 的白名单保持一致,改这里要同步改后端 */
export const MEDIA_ACCEPT = [
'image/*',
'.pdf',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
'.txt',
'.md',
'.csv',
'.mp3',
'.wav',
'.m4a',
'.aac',
'.mp4',
'.mov',
'.webm'
].join(',');
const DOC_SUFFIXES = [
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'md',
'csv'
];
const VIDEO_SUFFIXES = ['mp4', 'mov', 'webm'];
const AUDIO_SUFFIXES = ['mp3', 'wav', 'm4a', 'aac'];
/** 图片大小上限(MB),其余类型上限(MB),受后端 multipart 100MB 硬顶约束 */
export const MEDIA_MAX_MB_IMAGE = 10;
export const MEDIA_MAX_MB_OTHER = 100;
/** 类型筛选项,value 为空表示不限 */
export const MEDIA_CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: '', label: '全部类型' },
{ value: 'image', label: '图片' },
{ value: 'doc', label: '文档' },
{ value: 'audio', label: '音频' },
{ value: 'video', label: '视频' }
];
export const MEDIA_KIND_LABEL: Record<MediaKind, string> = {
image: '图片',
video: '视频',
audio: '音频',
doc: '文档'
};
/** 取小写后缀,不带点 */
export function mediaSuffix(name?: string | null): string {
if (!name || name.lastIndexOf('.') < 0) {
return '';
}
return name.substring(name.lastIndexOf('.') + 1).toLowerCase();
}
/** 由 MIME + 文件名判定大类 */
export function mediaKindOf(
contentType?: string | null,
name?: string | null
): MediaKind {
const mime = (contentType || '').toLowerCase();
if (mime.startsWith('image/')) {
return 'image';
}
if (mime.startsWith('video/')) {
return 'video';
}
if (mime.startsWith('audio/')) {
return 'audio';
}
const suffix = mediaSuffix(name);
if (VIDEO_SUFFIXES.includes(suffix)) {
return 'video';
}
if (AUDIO_SUFFIXES.includes(suffix)) {
return 'audio';
}
return 'doc';
}
/** 由文件记录判定大类 */
export function mediaKindOfRecord(record?: FileRecord | null): MediaKind {
return mediaKindOf(record?.contentType, record?.path || record?.name);
}
/**
* 上传前校验,通过返回 null,不通过返回给用户看的错误文案。
* 与后端同规则:图片按 MIME 认,其余按后缀白名单认。
*/
export function validateMediaFile(file: File): string | null {
const mime = (file.type || '').toLowerCase();
const suffix = mediaSuffix(file.name);
const isImage = mime.startsWith('image/');
if (!isImage) {
const allowed =
DOC_SUFFIXES.includes(suffix) ||
VIDEO_SUFFIXES.includes(suffix) ||
AUDIO_SUFFIXES.includes(suffix);
if (!allowed) {
return '不支持的文件格式,仅支持图片、文档、音频、视频';
}
}
const maxMb = isImage ? MEDIA_MAX_MB_IMAGE : MEDIA_MAX_MB_OTHER;
if (file.size / 1024 / 1024 > maxMb) {
return `文件大小不能超过 ${maxMb}MB`;
}
return null;
}
/** 文件大小格式化 */
export function formatFileSize(length?: number | null): string {
const size = Number(length ?? 0);
if (!size || size < 0) {
return '-';
}
if (size < 1024) {
return size + 'B';
}
if (size < 1024 * 1024) {
return (size / 1024).toFixed(1) + 'KB';
}
if (size < 1024 * 1024 * 1024) {
return (size / 1024 / 1024).toFixed(1) + 'M';
}
return (size / 1024 / 1024 / 1024).toFixed(1) + 'G';
}
/** 卡片/表格里显示的类型图标 */
export function mediaIcon(kind: MediaKind, name?: string | null): Component {
if (kind === 'audio') {
return CustomerServiceOutlined;
}
if (kind === 'video') {
return VideoCameraOutlined;
}
switch (mediaSuffix(name)) {
case 'pdf':
return FilePdfOutlined;
case 'doc':
case 'docx':
return FileWordOutlined;
case 'xls':
case 'xlsx':
case 'csv':
return FileExcelOutlined;
case 'ppt':
case 'pptx':
return FilePptOutlined;
case 'md':
return FileMarkdownOutlined;
case 'txt':
return FileTextOutlined;
default:
return FileUnknownOutlined;
}
}
/** 图标色,按类型区分但都压在中低饱和,避免卡片花花绿绿 */
export function mediaColor(kind: MediaKind, name?: string | null): string {
if (kind === 'audio') {
return '#805ad5';
}
if (kind === 'video') {
return '#2b6cb0';
}
switch (mediaSuffix(name)) {
case 'pdf':
return '#e5484d';
case 'doc':
case 'docx':
return '#2b6cb0';
case 'xls':
case 'xlsx':
case 'csv':
return '#2f855a';
case 'ppt':
case 'pptx':
return '#c05621';
default:
return '#718096';
}
}
/** 非图片卡片右下角的扩展名徽标文本 */
export function mediaExtLabel(record?: FileRecord | null): string {
const suffix = mediaSuffix(record?.path || record?.name);
return suffix ? suffix.toUpperCase() : 'FILE';
}
+30
View File
@@ -0,0 +1,30 @@
import { API_BASE_URL } from '@/config/setting';
/**
* 统一拼接「当前登录用户」的 WebSocket 地址。
*
* <p>2026-10-21 起统一走 {@code /api} 前缀(后端端点为 {@code /api/chat/{userId}})。
* 原先这里会把 {@code /api} 主动剥掉再拼 {@code /chat/${userId}},现场网关
* (twai-api.gxmu.edu.cn)只对 {@code /api} 放行 WebSocket 升级,导致握手被降级成
* 普通 GET 而返回 404。现在与其它接口同前缀,减少一类环境差异。
*
* <p>若后端回退到旧路径,只需把 {@link USER_WS_PATH} 改成 {@code '/chat'}。
*/
const USER_WS_PATH = '/chat';
export const buildUserWebSocketUrl = (userId: number | string): string => {
const suffix = `${USER_WS_PATH}/${userId}`;
// 绝对地址(生产/联调):https://host/api -> wss://host/api/chat/{userId}
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/+$/, '');
return `${url.toString().replace(/\/$/, '')}${suffix}`;
}
// 相对地址(VITE_API_URL=/api):与页面同源
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const base = API_BASE_URL.replace(/\/+$/, '');
return `${protocol}//${window.location.host}${base}${suffix}`;
};
+190
View File
@@ -0,0 +1,190 @@
<!-- 发起社团活动 -->
<template>
<ele-modal
:width="720"
:visible="visible"
:maskClosable="false"
:confirm-loading="loading"
title="发起社团活动"
:body-style="{ paddingBottom: '24px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属社团" name="clubId">
<a-select
v-model:value="form.clubId"
show-search
option-filter-prop="label"
:options="clubOptions"
:loading="clubLoading"
placeholder="请选择社团"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="活动名称" name="title">
<a-input v-model:value="form.title" allow-clear placeholder="请输入活动名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="活动类型" name="category">
<a-select
v-model:value="form.category"
allow-clear
:options="categoryOptions"
placeholder="请选择活动类型"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="活动地点" name="place">
<a-input v-model:value="form.place" allow-clear placeholder="请输入活动地点" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="开始时间" name="startTime">
<a-date-picker
v-model:value="startTime"
show-time
value-format="YYYY-MM-DD HH:mm:ss"
class="ele-fluid"
placeholder="请选择开始时间"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结束时间" name="endTime">
<a-date-picker
v-model:value="endTime"
show-time
value-format="YYYY-MM-DD HH:mm:ss"
class="ele-fluid"
placeholder="请选择结束时间"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="预计参与人数" name="scale">
<a-input-number v-model:value="form.scale" :min="0" class="ele-fluid" placeholder="请输入人数" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="预算金额" name="budget">
<a-input-number v-model:value="form.budget" :min="0" :step="100" class="ele-fluid" placeholder="请输入预算" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="活动方案" name="content">
<a-textarea v-model:value="form.content" :rows="4" allow-clear placeholder="请简述活动方案" />
</a-form-item>
<a-form-item label="活动材料" name="attachments">
<MultiFileUpload v-model="attachments" :max-count="5" />
</a-form-item>
<a-form-item label="提交方式" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">提交审核</a-radio>
<a-radio :value="0">保存草稿</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import { listClubs, submitClubActivity } from '@/api/growth/club';
import type { ClubActivity } from '@/api/growth/club';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const formRef = ref();
const loading = ref(false);
const form = reactive<ClubActivity>({});
const startTime = ref<any>();
const endTime = ref<any>();
const attachments = ref('');
const clubLoading = ref(false);
const clubList = ref<{ label: string; value: number }[]>([]);
const clubOptions = computed(() => clubList.value);
const categoryOptions = [
{ label: '社团日常活动', value: '社团日常活动' },
{ label: '志愿服务', value: '志愿服务' },
{ label: '学术讲座', value: '学术讲座' },
{ label: '文体比赛', value: '文体比赛' },
{ label: '社会实践活动', value: '社会实践活动' }
];
const rules = {
clubId: [{ required: true, message: '请选择社团', trigger: 'change' }],
title: [{ required: true, message: '请输入活动名称', trigger: 'blur' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }]
};
const loadClubs = async () => {
clubLoading.value = true;
try {
const list = await listClubs({ status: 1 });
clubList.value = list
.filter((item) => item.id)
.map((item) => ({ label: item.clubName || '', value: item.id as number }));
} catch (e: any) {
message.error(e?.message || '加载社团失败');
} finally {
clubLoading.value = false;
}
};
watch(
() => props.visible,
(value) => {
if (!value) return;
Object.keys(form).forEach((key) => delete (form as any)[key]);
startTime.value = undefined;
endTime.value = undefined;
attachments.value = '';
form.status = 1;
loadClubs();
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
form.startTime = startTime.value;
form.endTime = endTime.value;
form.attachments = attachments.value || undefined;
const msg = await submitClubActivity(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '提交失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
+203
View File
@@ -0,0 +1,203 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1300 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" @click="openEdit()">
<template #icon><plus-outlined /></template>
发起活动
</a-button>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="活动名称/社团/地点"
@search="reload"
@pressEnter="reload"
/>
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="状态"
:options="statusOptions"
@change="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'budget'">
{{ record.budget != null ? `¥${record.budget}` : '-' }}
</template>
<template v-else-if="column.key === 'attachments'">
<AttachmentList :value="record.attachments" :max-count="2" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">查看</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除这个活动吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<ActivityEdit v-model:visible="showEdit" @done="reload" />
<a-modal
v-model:visible="showDetail"
title="活动详情"
:footer="null"
width="720px"
>
<a-descriptions v-if="current" :column="2" bordered size="small">
<a-descriptions-item label="活动名称" :span="2">{{ current.title }}</a-descriptions-item>
<a-descriptions-item label="所属社团">{{ current.clubName || '-' }}</a-descriptions-item>
<a-descriptions-item label="活动类型">{{ current.category || '-' }}</a-descriptions-item>
<a-descriptions-item label="开始时间" :span="2">{{ current.startTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="结束时间" :span="2">{{ current.endTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="活动地点">{{ current.place || '-' }}</a-descriptions-item>
<a-descriptions-item label="参与人数">{{ current.scale ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="预算">{{ current.budget ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(current.status)">{{ statusLabel(current.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="活动方案" :span="2">{{ current.content || '-' }}</a-descriptions-item>
<a-descriptions-item label="材料" :span="2">
<AttachmentList :value="current.attachments" />
</a-descriptions-item>
<a-descriptions-item label="公章识别" :span="2">
{{ current.ocrStatus === 1 ? current.ocrOrg || '已识别' : current.ocrStatus === 2 ? '识别失败' : '未识别' }}
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import ActivityEdit from './components/edit.vue';
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import { pageClubActivities, removeClubActivity } from '@/api/growth/club';
import type { ClubActivity } from '@/api/growth/club';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const status = ref<number>();
const showEdit = ref(false);
const showDetail = ref(false);
const current = ref<ClubActivity | null>(null);
const statusOptions = [
{ label: '草稿', value: 0 },
{ label: '待审核', value: 1 },
{ label: '通过', value: 2 },
{ label: '驳回', value: 3 },
{ label: '作废', value: 4 }
];
const columns = ref<ColumnItem[]>([
{ title: '活动名称', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '所属社团', dataIndex: 'clubName', key: 'clubName', width: 160, ellipsis: true },
{ title: '活动类型', dataIndex: 'category', key: 'category', width: 110 },
{
title: '开始时间',
dataIndex: 'startTime',
key: 'startTime',
width: 160,
align: 'center',
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm')
},
{ title: '地点', dataIndex: 'place', key: 'place', width: 160, ellipsis: true },
{ title: '人数', dataIndex: 'scale', key: 'scale', width: 80, align: 'center' },
{ title: '预算', key: 'budget', width: 100, align: 'center' },
{ title: '材料', key: 'attachments', width: 140 },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{ title: '操作', key: 'action', width: 120, fixed: 'right', align: 'center', hideInSetting: true }
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageClubActivities({
...where,
...orders,
page,
limit,
status: status.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
searchText.value = '';
status.value = undefined;
reload();
};
const openEdit = () => {
showEdit.value = true;
};
const openDetail = (record: ClubActivity) => {
current.value = record;
showDetail.value = true;
};
const remove = async (record: ClubActivity) => {
if (!record.id) return;
try {
const msg = await removeClubActivity(record.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const statusLabel = (value?: number) => {
const map: Record<number, string> = {
0: '草稿',
1: '待审核',
2: '通过',
3: '驳回',
4: '作废'
};
return map[value ?? 0] ?? '-';
};
const statusColor = (value?: number) => {
if (value === 2) return 'green';
if (value === 3) return 'red';
if (value === 4) return 'default';
return 'orange';
};
</script>
+202
View File
@@ -0,0 +1,202 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="审核状态"
:options="statusOptions"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="活动名称/社团/地点"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'attachments'">
<AttachmentList :value="record.attachments" :max-count="2" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openReview(record)">审核</a>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<a-modal
v-model:visible="showReview"
title="社团活动审批"
:confirm-loading="submitting"
width="760px"
@ok="submitReview"
>
<template v-if="current">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="活动名称" :span="2">{{ current.title }}</a-descriptions-item>
<a-descriptions-item label="所属社团">{{ current.clubName || '-' }}</a-descriptions-item>
<a-descriptions-item label="活动类型">{{ current.category || '-' }}</a-descriptions-item>
<a-descriptions-item label="开始时间">{{ current.startTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="结束时间">{{ current.endTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="活动地点">{{ current.place || '-' }}</a-descriptions-item>
<a-descriptions-item label="参与人数">{{ current.scale ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="预算">{{ current.budget ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="活动方案" :span="2">
{{ current.content || '-' }}
</a-descriptions-item>
<a-descriptions-item label="材料" :span="2">
<AttachmentList :value="current.attachments" />
</a-descriptions-item>
</a-descriptions>
<a-alert
type="info"
show-icon
style="margin-top: 16px"
message="审批顺序:指导老师 → 二级学院团委/业务指导单位 → 校团委社团管理部 → 校团委领导。当前账号可对本人节点审批。"
/>
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="审核结果" required>
<a-radio-group v-model:value="reviewStatus">
<a-radio :value="2">通过</a-radio>
<a-radio :value="3">驳回</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="审核意见" :required="reviewStatus === 3">
<a-textarea v-model:value="reviewRemark" :rows="3" placeholder="驳回时请说明原因" />
</a-form-item>
</a-form>
</template>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import { pageClubActivities, reviewClubActivity } from '@/api/growth/club';
import type { ClubActivity } from '@/api/growth/club';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const status = ref<number>(1);
const searchText = ref('');
const showReview = ref(false);
const submitting = ref(false);
const current = ref<ClubActivity | null>(null);
const reviewStatus = ref(2);
const reviewRemark = ref('');
const statusOptions = [
{ label: '待审核', value: 1 },
{ label: '通过', value: 2 },
{ label: '驳回', value: 3 }
];
const columns = ref<ColumnItem[]>([
{ title: '活动名称', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '所属社团', dataIndex: 'clubName', key: 'clubName', width: 150, ellipsis: true },
{
title: '开始时间',
dataIndex: 'startTime',
key: 'startTime',
width: 160,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm')
},
{ title: '地点', dataIndex: 'place', key: 'place', width: 150, ellipsis: true },
{ title: '人数', dataIndex: 'scale', key: 'scale', width: 80, align: 'center' },
{ title: '材料', key: 'attachments', width: 130 },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{ title: '操作', key: 'action', width: 90, fixed: 'right', align: 'center', hideInSetting: true }
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageClubActivities({
...where,
...orders,
page,
limit,
status: status.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
status.value = undefined;
searchText.value = '';
reload();
};
const openReview = (record: ClubActivity) => {
current.value = record;
reviewStatus.value = 2;
reviewRemark.value = '';
showReview.value = true;
};
const submitReview = async () => {
if (!current.value?.id) return;
if (reviewStatus.value === 3 && !reviewRemark.value.trim()) {
message.warning('驳回时请填写审核意见');
return;
}
submitting.value = true;
try {
const msg = await reviewClubActivity({
id: current.value.id,
status: reviewStatus.value
});
message.success(msg);
showReview.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '审核失败');
} finally {
submitting.value = false;
}
};
const statusLabel = (value?: number) => {
const map: Record<number, string> = { 0: '草稿', 1: '待审核', 2: '通过', 3: '驳回', 4: '作废' };
return map[value ?? 0] ?? '-';
};
const statusColor = (value?: number) => {
if (value === 2) return 'green';
if (value === 3) return 'red';
return 'orange';
};
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="社团年审" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">按指标配置自动取数(活动数、参与人次、稿件数),社团在线自评后审核公示。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>年审指标配置</li>
<li>自动取数规则</li>
<li>公示流程</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// 社团年审:P1 阶段交付,当前先占位避免菜单跳转 404
</script>
<style lang="less" scoped>
.placeholder-card {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-desc {
color: #595959;
line-height: 1.8;
}
.placeholder-sub {
margin-top: 4px;
font-weight: 600;
color: #262626;
}
.placeholder-list {
margin: 0;
padding-left: 20px;
color: #8c8c8c;
line-height: 1.9;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="社团评优" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">社团与学生会的评优、评先线上申报、审核与公示。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>评优类型配置</li>
<li>申报材料模板</li>
<li>公示流程</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// 社团评优:P1 阶段交付,当前先占位避免菜单跳转 404
</script>
<style lang="less" scoped>
.placeholder-card {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-desc {
color: #595959;
line-height: 1.8;
}
.placeholder-sub {
margin-top: 4px;
font-weight: 600;
color: #262626;
}
.placeholder-list {
margin: 0;
padding-left: 20px;
color: #8c8c8c;
line-height: 1.9;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<!-- 社团编辑 -->
<template>
<ele-modal
:width="720"
:visible="visible"
:maskClosable="false"
:confirm-loading="loading"
:title="isUpdate ? '编辑社团' : '新增社团'"
:body-style="{ paddingBottom: '24px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="社团名称" name="clubName">
<a-input v-model:value="form.clubName" allow-clear placeholder="请输入社团名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="社团编码" name="clubCode">
<a-input v-model:value="form.clubCode" allow-clear placeholder="请输入社团编码" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="社团类别" name="category">
<a-select
v-model:value="form.category"
allow-clear
:options="categoryOptions"
mode="tags"
:max-tag-count="1"
placeholder="请选择或输入类别"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="社团等级" name="level">
<a-select
v-model:value="form.level"
allow-clear
:options="levelOptions"
placeholder="请选择等级"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="指导老师" name="advisorName">
<a-input v-model:value="form.advisorName" allow-clear placeholder="请输入指导老师姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="社长" name="presidentName">
<a-input v-model:value="form.presidentName" allow-clear placeholder="请输入社长姓名" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="社长电话" name="presidentPhone">
<a-input v-model:value="form.presidentPhone" allow-clear placeholder="请输入联系电话" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="成立日期" name="foundedDate">
<a-date-picker
v-model:value="foundedDate"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="请选择成立日期"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="社团简介" name="intro">
<a-textarea v-model:value="form.intro" :rows="4" allow-clear placeholder="请输入社团简介" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">正常</a-radio>
<a-radio :value="0">停用</a-radio>
<a-radio :value="2">注销</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { assignObject } from 'ele-admin-pro';
import { addClub, updateClub } from '@/api/growth/club';
import type { Club } from '@/api/growth/club';
const props = defineProps<{
visible: boolean;
data?: Club | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const form = reactive<Club>({});
const foundedDate = ref<any>();
const categoryOptions = [
{ label: '思想政治类', value: '思想政治类' },
{ label: '学术科技类', value: '学术科技类' },
{ label: '文化体育类', value: '文化体育类' },
{ label: '志愿公益类', value: '志愿公益类' },
{ label: '创新创业类', value: '创新创业类' }
];
const levelOptions = [
{ label: '校级', value: '校级' },
{ label: '院级', value: '院级' }
];
const rules = {
clubName: [{ required: true, message: '请输入社团名称', trigger: 'blur' }]
};
watch(
() => props.visible,
(value) => {
if (!value) return;
isUpdate.value = !!props.data?.id;
Object.keys(form).forEach((key) => delete (form as any)[key]);
foundedDate.value = undefined;
if (props.data) {
assignObject(form, props.data);
foundedDate.value = props.data.foundedDate ? dayjs(props.data.foundedDate) : undefined;
} else {
form.status = 1;
}
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
form.foundedDate = foundedDate.value
? dayjs(foundedDate.value).format('YYYY-MM-DD')
: undefined;
const msg = isUpdate.value ? await updateClub(form) : await addClub(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
+297
View File
@@ -0,0 +1,297 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1300 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-button
v-if="hasPermission('gxmu:club:save')"
type="primary"
@click="openEdit()"
>
<template #icon><plus-outlined /></template>
新增社团
</a-button>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="社团名称/编码/负责人"
@search="reload"
@pressEnter="reload"
/>
<a-select
v-model:value="status"
allow-clear
style="width: 130px"
placeholder="状态"
:options="statusOptions"
@change="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '正常' : record.status === 2 ? '注销' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openMembers(record)">成员</a>
<a-divider type="vertical" />
<a v-if="hasPermission('gxmu:club:update')" @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
v-if="hasPermission('gxmu:club:remove')"
title="确定要删除这个社团吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<ClubEdit v-model:visible="showEdit" :data="current" @done="reload" />
<a-drawer v-model:visible="showMembers" title="社团成员" width="720">
<div class="member-head">
<span>{{ current?.clubName }}</span>
<a-button type="primary" size="small" @click="showMemberEdit = true">
<template #icon><plus-outlined /></template>
添加成员
</a-button>
</div>
<a-table
row-key="id"
:columns="memberColumns"
:data-source="members"
:pagination="false"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '在社' : '退出' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-popconfirm title="确定移除此成员吗?" @confirm="removeMember(record)">
<a class="ele-text-danger">移除</a>
</a-popconfirm>
</template>
</template>
</a-table>
</a-drawer>
<a-modal
v-model:visible="showMemberEdit"
title="添加社团成员"
:confirm-loading="memberSubmitting"
width="520px"
@ok="submitMember"
>
<a-form ref="memberFormRef" :model="memberForm" :rules="memberRules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="学号" name="studentNo">
<a-input v-model:value="memberForm.studentNo" allow-clear placeholder="请输入学号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="姓名" name="studentName">
<a-input v-model:value="memberForm.studentName" allow-clear placeholder="请输入姓名" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="社内职务" name="roleName">
<a-input v-model:value="memberForm.roleName" allow-clear placeholder="如 社长 / 部长 / 成员" />
</a-form-item>
<a-form-item label="加入时间" name="joinTime">
<a-date-picker
v-model:value="memberJoinTime"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="请选择加入时间"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { Form, message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import ClubEdit from './components/edit.vue';
import {
pageClubs,
removeClub,
listClubMembers,
addClubMember,
removeClubMember
} from '@/api/growth/club';
import type { Club, ClubMember } from '@/api/growth/club';
import { hasPermission } from '@/utils/permission';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const status = ref<number>();
const showEdit = ref(false);
const showMembers = ref(false);
const showMemberEdit = ref(false);
const current = ref<Club | null>(null);
const members = ref<ClubMember[]>([]);
const memberFormRef = ref();
const memberSubmitting = ref(false);
const memberJoinTime = ref<any>();
const memberForm = reactive<ClubMember>({});
const statusOptions = [
{ label: '正常', value: 1 },
{ label: '停用', value: 0 },
{ label: '注销', value: 2 }
];
const columns = ref<ColumnItem[]>([
{ title: '社团名称', dataIndex: 'clubName', key: 'clubName', width: 180 },
{ title: '编码', dataIndex: 'clubCode', key: 'clubCode', width: 130 },
{ title: '类别', dataIndex: 'category', key: 'category', width: 120 },
{ title: '指导老师', dataIndex: 'advisorName', key: 'advisorName', width: 110 },
{ title: '社长', dataIndex: 'presidentName', key: 'presidentName', width: 110 },
{ title: '成员数', dataIndex: 'memberCount', key: 'memberCount', width: 90, align: 'center' },
{ title: '等级', dataIndex: 'level', key: 'level', width: 100 },
{ title: '状态', key: 'status', width: 90, align: 'center' },
{ title: '操作', key: 'action', width: 180, fixed: 'right', align: 'center', hideInSetting: true }
]);
const memberColumns: ColumnItem[] = [
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 110 },
{ title: '职务', dataIndex: 'roleName', key: 'roleName', width: 110 },
{ title: '加入时间', dataIndex: 'joinTime', key: 'joinTime', width: 120 },
{ title: '状态', key: 'status', width: 90 },
{ title: '操作', key: 'action', width: 80 }
];
const memberRules = {
studentNo: [{ required: true, message: '请输入学号', trigger: 'blur' }],
studentName: [{ required: true, message: '请输入姓名', trigger: 'blur' }]
};
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageClubs({
...where,
...orders,
page,
limit,
status: status.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
searchText.value = '';
status.value = undefined;
reload();
};
const openEdit = (row?: Club) => {
current.value = row || null;
showEdit.value = true;
};
const remove = async (row: Club) => {
if (!row.id) return;
try {
const msg = await removeClub(row.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openMembers = async (row: Club) => {
current.value = row;
showMembers.value = true;
await loadMembers();
};
const loadMembers = async () => {
if (!current.value?.id) return;
try {
members.value = await listClubMembers(current.value.id);
} catch (e: any) {
message.error(e?.message || '加载成员失败');
}
};
const submitMember = () => {
memberFormRef.value
?.validate?.()
.then(async () => {
memberSubmitting.value = true;
try {
memberForm.clubId = current.value?.id;
memberForm.joinTime = memberJoinTime.value;
const msg = await addClubMember(memberForm);
message.success(msg);
showMemberEdit.value = false;
Object.keys(memberForm).forEach((key) => delete (memberForm as any)[key]);
memberJoinTime.value = undefined;
await loadMembers();
reload();
} catch (e: any) {
message.error(e?.message || '添加失败');
} finally {
memberSubmitting.value = false;
}
})
.catch(() => undefined);
};
const removeMember = async (row: ClubMember) => {
if (!row.id) return;
try {
const msg = await removeClubMember(row.id);
message.success(msg);
await loadMembers();
reload();
} catch (e: any) {
message.error(e?.message || '移除失败');
}
};
</script>
<style lang="less" scoped>
.member-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-weight: 600;
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="社团年度数据报告按年度自动汇总活动、成员与场地数据,生成含多维度图表与文字小结的 PDF,可直接用于社团评优评先的数据支撑。"
/>
<a-space style="margin-bottom: 16px" wrap>
<a-select
v-model:value="clubId"
show-search
option-filter-prop="label"
style="width: 260px"
placeholder="选择社团"
:options="clubOptions"
:loading="clubLoading"
@change="loadPreview"
/>
<a-select
v-model:value="year"
style="width: 140px"
:options="yearOptions"
@change="loadPreview"
/>
<a-button type="primary" :loading="loading" @click="loadPreview">查询</a-button>
<a-button
type="primary"
ghost
:disabled="!clubId"
:loading="generating"
@click="generate"
>
{{ preview.generated ? '重新生成' : '生成报告' }}
</a-button>
<a-button v-if="preview.downloadUrl" type="link" @click="download">
下载 PDF
</a-button>
</a-space>
<a-empty v-if="!clubId" description="请选择需要生成年度报告的社团" />
<a-spin v-else :spinning="loading">
<a-descriptions bordered size="small" :column="3" class="club-info">
<a-descriptions-item label="社团名称">{{ preview.clubName || '-' }}</a-descriptions-item>
<a-descriptions-item label="社团类别">{{ preview.category || '-' }}</a-descriptions-item>
<a-descriptions-item label="挂靠单位">{{ preview.collegeName || '-' }}</a-descriptions-item>
<a-descriptions-item label="指导老师">{{ preview.advisorName || '-' }}</a-descriptions-item>
<a-descriptions-item label="社长">{{ preview.presidentName || '-' }}</a-descriptions-item>
<a-descriptions-item label="报告状态">
<a-tag v-if="preview.generated" color="green">已生成</a-tag>
<a-tag v-else>未生成</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-row :gutter="[16, 16]" style="margin-top: 16px">
<a-col
v-for="metric in metricCards"
:key="metric.label"
:xs="24"
:sm="12"
:md="8"
:lg="4"
>
<div class="metric-card" :class="metric.tone">
<div class="metric-label">{{ metric.label }}</div>
<div class="metric-value">{{ metric.value }}</div>
</div>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" style="margin-top: 16px">
<a-col :xs="24" :lg="12">
<a-card :bordered="false" title="活动类别分布" size="small">
<a-empty v-if="!categoryRows.length" description="本年度暂无活动数据" />
<VChart
v-else
ref="categoryChartRef"
class="chart"
:option="categoryOption"
autoresize
/>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" title="成员角色构成" size="small">
<a-empty v-if="!roleRows.length" description="当前无在社成员" />
<VChart
v-else
ref="roleChartRef"
class="chart"
:option="roleOption"
autoresize
/>
</a-card>
</a-col>
</a-row>
<a-card :bordered="false" title="月度活动趋势" size="small" style="margin-top: 16px">
<VChart
ref="monthChartRef"
class="chart chart-wide"
:option="monthOption"
autoresize
/>
</a-card>
<a-card :bordered="false" title="年度小结" size="small" style="margin-top: 16px">
<a-textarea
v-model:value="narrative"
:rows="5"
placeholder="可在此调整自动生成的小结,生成 PDF 时会使用修改后的内容"
/>
<div class="ele-text-secondary" style="font-size: 12px; margin-top: 6px">
小结由系统按年度数据自动生成,可自由编辑后再生成报告。
</div>
</a-card>
</a-spin>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import dayjs from 'dayjs';
import { message } from 'ant-design-vue';
import VChart from 'vue-echarts';
import useEcharts from '@/utils/use-echarts';
import { listClubs, getClubReportPreview, generateClubReport } from '@/api/growth/club';
import type { Club } from '@/api/growth/club';
const clubs = ref<Club[]>([]);
const clubLoading = ref(false);
const clubId = ref<number>();
const year = ref(dayjs().year());
const loading = ref(false);
const generating = ref(false);
const preview = ref<Record<string, any>>({});
const narrative = ref('');
const categoryChartRef = ref<InstanceType<typeof VChart> | null>(null);
const roleChartRef = ref<InstanceType<typeof VChart> | null>(null);
const monthChartRef = ref<InstanceType<typeof VChart> | null>(null);
useEcharts([categoryChartRef, roleChartRef, monthChartRef]);
const clubOptions = computed(() =>
clubs.value.map((item) => ({
label: item.clubName,
value: item.id
}))
);
const yearOptions = computed(() => {
const current = dayjs().year();
return Array.from({ length: 6 }, (_, index) => {
const value = current - index;
return { label: `${value} 年`, value };
});
});
const metricCards = computed(() => {
const metrics = preview.value.metrics || {};
return [
{ label: '年度活动场次', value: metrics['年度活动场次'] ?? 0, tone: '' },
{ label: '累计参与人次', value: metrics['累计参与人次'] ?? 0, tone: 'accent' },
{ label: '在社成员人数', value: metrics['在社成员人数'] ?? 0, tone: 'warm' },
{ label: '本年新增成员', value: metrics['本年新增成员'] ?? 0, tone: 'success' },
{ label: '场地使用次数', value: metrics['场地使用次数'] ?? 0, tone: 'accent' },
{ label: '活动预算合计(元)', value: metrics['活动预算合计(元)'] ?? 0, tone: 'warm' }
];
});
const categoryRows = computed(() =>
Object.entries(preview.value.activityByCategory || {}).map(([name, value]) => ({
name,
value
}))
);
const roleRows = computed(() =>
Object.entries(preview.value.memberByRole || {}).map(([name, value]) => ({
name,
value
}))
);
const monthRows = computed(() =>
Object.entries(preview.value.activityByMonth || {}).map(([name, value]) => ({
name,
value
}))
);
const categoryOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
type: 'pie',
radius: ['42%', '68%'],
avoidLabelOverlap: true,
label: { formatter: '{b}: {c} 场' },
data: categoryRows.value
}
]
}));
const roleOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
type: 'pie',
radius: '62%',
label: { formatter: '{b}: {c} 人' },
data: roleRows.value
}
]
}));
const monthOption = computed(() => ({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 24, top: 24, bottom: 32 },
xAxis: { type: 'category', data: monthRows.value.map((item) => item.name) },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
type: 'line',
smooth: true,
areaStyle: { opacity: 0.15 },
data: monthRows.value.map((item) => item.value)
}
]
}));
const loadClubs = async () => {
clubLoading.value = true;
try {
clubs.value = await listClubs({ status: 1 });
} catch (e: any) {
message.error(e?.message || '加载社团列表失败');
} finally {
clubLoading.value = false;
}
};
const loadPreview = async () => {
if (!clubId.value) {
preview.value = {};
return;
}
loading.value = true;
try {
preview.value = await getClubReportPreview({ clubId: clubId.value, year: year.value });
narrative.value = preview.value.narrative || '';
} catch (e: any) {
preview.value = {};
narrative.value = '';
message.error(e?.message || '加载报告数据失败');
} finally {
loading.value = false;
}
};
const generate = async () => {
if (!clubId.value) {
message.warning('请先选择社团');
return;
}
generating.value = true;
try {
const report = await generateClubReport({
clubId: clubId.value,
year: year.value,
narrative: narrative.value || undefined
});
preview.value = {
...preview.value,
generated: true,
downloadUrl: report.downloadUrl || report.fileUrl,
generatedAt: report.updateTime
};
message.success('报告已生成,可下载 PDF');
} catch (e: any) {
message.error(e?.message || '生成报告失败');
} finally {
generating.value = false;
}
};
const download = () => {
const url = preview.value.downloadUrl;
if (!url) {
message.warning('暂无可下载的报告,请先生成');
return;
}
window.open(url, '_blank');
};
onMounted(loadClubs);
</script>
<style lang="less" scoped>
.club-info {
margin-bottom: 4px;
}
.metric-card {
padding: 16px 18px;
border-radius: 10px;
background: #fff;
border-left: 4px solid #1677ff;
}
.metric-card.accent {
border-left-color: #13c2c2;
}
.metric-card.warm {
border-left-color: #faad14;
}
.metric-card.success {
border-left-color: #52c41a;
}
.metric-label {
font-size: 13px;
color: #8c8c8c;
}
.metric-value {
margin-top: 6px;
font-size: 24px;
font-weight: 600;
color: #262626;
}
.chart {
height: 260px;
}
.chart-wide {
height: 240px;
}
</style>
+425
View File
@@ -0,0 +1,425 @@
<template>
<div class="page">
<div class="ele-body">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="7">
<a-card :bordered="false" title="场地列表">
<a-spin :spinning="venueLoading">
<a-empty v-if="!venues.length" description="暂无可用场地" />
<div
v-for="item in venues"
:key="item.id"
class="venue-item"
:class="{ active: currentVenueId === item.id }"
@click="selectVenue(item)"
>
<div class="venue-name">{{ item.venueName }}</div>
<div class="venue-meta">
<span>{{ item.building || '' }}{{ item.roomNo ? ` ${item.roomNo}` : '' }}</span>
<span v-if="item.capacity">容纳 {{ item.capacity }} 人</span>
</div>
<div class="venue-time">
开放 {{ item.openStartTime || '08:00' }} - {{ item.openEndTime || '22:00' }}
</div>
</div>
</a-spin>
</a-card>
</a-col>
<a-col :xs="24" :lg="17">
<a-card :bordered="false">
<template #title>
<div class="date-bar">
<span>{{ currentVenue?.venueName || '请选择场地' }}</span>
<a-date-picker
v-model:value="bookingDate"
value-format="YYYY-MM-DD"
:allow-clear="false"
@change="loadSchedule"
/>
<a-button
type="primary"
:disabled="!currentVenueId"
@click="openBooking"
>
预约场地
</a-button>
</div>
</template>
<a-spin :spinning="scheduleLoading">
<a-empty v-if="!schedule.length" description="当天暂无预约" />
<div v-else class="schedule-list">
<div v-for="item in schedule" :key="item.id" class="schedule-item">
<div class="schedule-time">{{ item.startTime }} - {{ item.endTime }}</div>
<div class="schedule-main">
<div class="schedule-purpose">{{ item.purpose || '未填写事由' }}</div>
<div class="schedule-sub">
{{ item.clubName || item.applicantName || '-' }}
</div>
</div>
<a-tag :color="item.status === 1 ? 'green' : 'orange'">
{{ item.status === 1 ? '已通过' : '待审核' }}
</a-tag>
</div>
</div>
</a-spin>
</a-card>
<a-card :bordered="false" class="my-booking-card" title="我的预约">
<a-table
row-key="id"
size="small"
:columns="bookingColumns"
:data-source="myBookings"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="bookingStatusColor(record.status)">
{{ bookingStatusLabel(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-popconfirm
v-if="record.status === 0 || record.status === 1"
title="确定要取消这个预约吗?"
@confirm="cancel(record)"
>
<a class="ele-text-danger">取消</a>
</a-popconfirm>
<span v-else>-</span>
</template>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
<a-modal
v-model:visible="showBooking"
title="预约场地"
:confirm-loading="submitting"
width="560px"
@ok="submitBooking"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
:message="`${currentVenue?.venueName || ''} · ${bookingDate}`"
/>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="开始时间" required>
<a-time-picker
v-model:value="startTime"
format="HH:mm"
value-format="HH:mm"
minute-step="15"
class="ele-fluid"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结束时间" required>
<a-time-picker
v-model:value="endTime"
format="HH:mm"
value-format="HH:mm"
minute-step="15"
class="ele-fluid"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="使用事由" required>
<a-input v-model:value="purpose" allow-clear placeholder="请填写使用事由" />
</a-form-item>
<a-form-item label="使用社团">
<a-select
v-model:value="clubId"
allow-clear
show-search
option-filter-prop="label"
:options="clubOptions"
placeholder="可选"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="申请人">
<a-input v-model:value="applicantName" allow-clear placeholder="请输入申请人" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话">
<a-input v-model:value="applicantPhone" allow-clear placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="参与人数">
<a-input-number v-model:value="attendeeCount" :min="1" class="ele-fluid" />
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import dayjs from 'dayjs';
import { message } from 'ant-design-vue';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import {
listVenues,
listClubs,
getVenueSchedule,
pageVenueBookings,
createVenueBooking,
cancelVenueBooking
} from '@/api/growth/club';
import type { Venue, VenueBooking } from '@/api/growth/club';
const venues = ref<Venue[]>([]);
const venueLoading = ref(false);
const currentVenueId = ref<number>();
const currentVenue = computed(() =>
venues.value.find((item) => item.id === currentVenueId.value)
);
const bookingDate = ref(dayjs().format('YYYY-MM-DD'));
const schedule = ref<Record<string, any>[]>([]);
const scheduleLoading = ref(false);
const myBookings = ref<VenueBooking[]>([]);
const showBooking = ref(false);
const submitting = ref(false);
const startTime = ref('09:00');
const endTime = ref('11:00');
const purpose = ref('');
const clubId = ref<number>();
const clubOptions = ref<{ label: string; value: number }[]>([]);
const applicantName = ref('');
const applicantPhone = ref('');
const attendeeCount = ref(1);
const bookingColumns: ColumnItem[] = [
{ title: '场地', dataIndex: 'venueName', key: 'venueName', width: 140 },
{ title: '日期', dataIndex: 'bookingDate', key: 'bookingDate', width: 110 },
{ title: '时间', key: 'time', width: 130 },
{ title: '事由', dataIndex: 'purpose', key: 'purpose', ellipsis: true },
{ title: '状态', key: 'status', width: 90 },
{ title: '操作', key: 'action', width: 80 }
];
void MultiFileUpload;
const selectVenue = (venue: Venue) => {
currentVenueId.value = venue.id;
loadSchedule();
};
const loadVenues = async () => {
venueLoading.value = true;
try {
venues.value = await listVenues();
if (!currentVenueId.value && venues.value.length) {
currentVenueId.value = venues.value[0].id;
await loadSchedule();
}
} catch (e: any) {
message.error(e?.message || '加载场地失败');
} finally {
venueLoading.value = false;
}
};
const loadSchedule = async () => {
if (!currentVenueId.value) return;
scheduleLoading.value = true;
try {
schedule.value = await getVenueSchedule(currentVenueId.value, bookingDate.value);
} catch (e: any) {
schedule.value = [];
} finally {
scheduleLoading.value = false;
}
};
const loadMyBookings = async () => {
try {
const result = await pageVenueBookings({ page: 1, limit: 50, backendAccess: true });
myBookings.value = result.list || [];
} catch (e: any) {
myBookings.value = [];
}
};
const loadClubs = async () => {
try {
const list = await listClubs({ status: 1 });
clubOptions.value = list
.filter((item) => item.id)
.map((item) => ({ label: item.clubName || '', value: item.id as number }));
} catch (e: any) {
// 忽略
}
};
const openBooking = () => {
startTime.value = currentVenue.value?.openStartTime || '09:00';
endTime.value = currentVenue.value?.openEndTime || '11:00';
purpose.value = '';
clubId.value = undefined;
attendeeCount.value = 1;
showBooking.value = true;
};
const submitBooking = async () => {
if (!currentVenueId.value) return;
if (!purpose.value.trim()) {
message.warning('请填写使用事由');
return;
}
if (startTime.value >= endTime.value) {
message.warning('结束时间必须晚于开始时间');
return;
}
submitting.value = true;
try {
const msg = await createVenueBooking({
venueId: currentVenueId.value,
bookingDate: bookingDate.value,
startTime: startTime.value,
endTime: endTime.value,
purpose: purpose.value.trim(),
clubId: clubId.value,
applicantName: applicantName.value || undefined,
applicantPhone: applicantPhone.value || undefined,
attendeeCount: attendeeCount.value
});
message.success(msg);
showBooking.value = false;
await loadSchedule();
await loadMyBookings();
} catch (e: any) {
message.error(e?.message || '预约失败');
} finally {
submitting.value = false;
}
};
const cancel = async (record: VenueBooking) => {
if (!record.id) return;
try {
const msg = await cancelVenueBooking(record.id);
message.success(msg);
await loadSchedule();
await loadMyBookings();
} catch (e: any) {
message.error(e?.message || '取消失败');
}
};
const bookingStatusLabel = (value?: number) => {
if (value === 1) return '通过';
if (value === 2) return '驳回';
if (value === 3) return '已取消';
return '待审核';
};
const bookingStatusColor = (value?: number) => {
if (value === 1) return 'green';
if (value === 2) return 'red';
if (value === 3) return 'default';
return 'orange';
};
onMounted(() => {
loadVenues();
loadMyBookings();
loadClubs();
});
</script>
<style lang="less" scoped>
.venue-item {
padding: 14px 16px;
margin-bottom: 10px;
border: 1px solid #f0f0f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.venue-item:hover,
.venue-item.active {
border-color: #1677ff;
background: #f0f7ff;
}
.venue-name {
font-weight: 600;
color: #262626;
}
.venue-meta {
display: flex;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: #8c8c8c;
}
.venue-time {
margin-top: 4px;
font-size: 12px;
color: #bfbfbf;
}
.date-bar {
display: flex;
align-items: center;
gap: 12px;
font-weight: 500;
}
.schedule-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.schedule-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.schedule-time {
width: 130px;
font-weight: 600;
color: #1677ff;
}
.schedule-main {
flex: 1;
min-width: 0;
}
.schedule-purpose {
color: #262626;
}
.schedule-sub {
font-size: 12px;
color: #8c8c8c;
}
.my-booking-card {
margin-top: 16px;
}
</style>
+202
View File
@@ -0,0 +1,202 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="审核状态"
:options="statusOptions"
@change="reload"
/>
<a-range-picker v-model:value="dateRange" value-format="YYYY-MM-DD" @change="reload" />
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="事由/申请人/场地"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'time'">
{{ record.startTime }} - {{ record.endTime }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a v-if="record.status === 0" @click="openReview(record)">审核</a>
<span v-else>-</span>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<a-modal
v-model:visible="showReview"
title="场地预约审批"
:confirm-loading="submitting"
width="640px"
@ok="submitReview"
>
<a-descriptions v-if="current" :column="2" bordered size="small">
<a-descriptions-item label="场地" :span="2">{{ current.venueName }}</a-descriptions-item>
<a-descriptions-item label="日期">{{ current.bookingDate }}</a-descriptions-item>
<a-descriptions-item label="时间">
{{ current.startTime }} - {{ current.endTime }}
</a-descriptions-item>
<a-descriptions-item label="申请人">{{ current.applicantName || '-' }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ current.applicantPhone || '-' }}</a-descriptions-item>
<a-descriptions-item label="使用社团">{{ current.clubName || '-' }}</a-descriptions-item>
<a-descriptions-item label="参与人数">{{ current.attendeeCount ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="使用事由" :span="2">{{ current.purpose || '-' }}</a-descriptions-item>
</a-descriptions>
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="审核结果" required>
<a-radio-group v-model:value="reviewStatus">
<a-radio :value="1">通过</a-radio>
<a-radio :value="2">驳回</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="审核意见" :required="reviewStatus === 2">
<a-textarea v-model:value="reviewRemark" :rows="3" placeholder="驳回时请说明原因" />
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
pageVenueBookings,
reviewVenueBooking
} from '@/api/growth/club';
import type { VenueBooking } from '@/api/growth/club';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const status = ref<number>(0);
const searchText = ref('');
const dateRange = ref<string[]>();
const showReview = ref(false);
const submitting = ref(false);
const current = ref<VenueBooking | null>(null);
const reviewStatus = ref(1);
const reviewRemark = ref('');
const statusOptions = [
{ label: '待审核', value: 0 },
{ label: '通过', value: 1 },
{ label: '驳回', value: 2 },
{ label: '已取消', value: 3 }
];
const columns = ref<ColumnItem[]>([
{ title: '场地', dataIndex: 'venueName', key: 'venueName', width: 150 },
{ title: '日期', dataIndex: 'bookingDate', key: 'bookingDate', width: 120 },
{ title: '时间', key: 'time', width: 130 },
{ title: '申请人', dataIndex: 'applicantName', key: 'applicantName', width: 110 },
{ title: '社团', dataIndex: 'clubName', key: 'clubName', width: 150, ellipsis: true },
{ title: '事由', dataIndex: 'purpose', key: 'purpose', ellipsis: true },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{
title: '提交时间',
dataIndex: 'createTime',
key: 'createTime',
width: 160,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm')
},
{ title: '操作', key: 'action', width: 90, fixed: 'right', align: 'center', hideInSetting: true }
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageVenueBookings({
...where,
...orders,
page,
limit,
status: status.value,
bookingDateStart: dateRange.value?.[0],
bookingDateEnd: dateRange.value?.[1],
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
status.value = undefined;
searchText.value = '';
dateRange.value = undefined;
reload();
};
const openReview = (record: VenueBooking) => {
current.value = record;
reviewStatus.value = 1;
reviewRemark.value = '';
showReview.value = true;
};
const submitReview = async () => {
if (!current.value?.id) return;
if (reviewStatus.value === 2 && !reviewRemark.value.trim()) {
message.warning('驳回时请填写审核意见');
return;
}
submitting.value = true;
try {
const msg = await reviewVenueBooking({
id: current.value.id,
status: reviewStatus.value,
reviewRemark: reviewRemark.value.trim() || undefined
});
message.success(msg);
showReview.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '审核失败');
} finally {
submitting.value = false;
}
};
const statusLabel = (value?: number) => {
if (value === 1) return '通过';
if (value === 2) return '驳回';
if (value === 3) return '已取消';
return '待审核';
};
const statusColor = (value?: number) => {
if (value === 1) return 'green';
if (value === 2) return 'red';
if (value === 3) return 'default';
return 'orange';
};
</script>
@@ -0,0 +1,281 @@
<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="year"
style="width: 140px"
:options="yearOptions"
@change="loadData"
/>
<a-input
v-model:value="studentNo"
allow-clear
style="width: 180px"
placeholder="学号(管理员代查)"
@pressEnter="loadData"
/>
<a-button type="primary" :loading="loading" @click="loadData"
>查询</a-button
>
<a-button
type="primary"
ghost
:disabled="!summary.items?.length"
:loading="generating"
@click="generate"
>
{{ summary.generated ? '重新生成' : '生成证明' }}
</a-button>
<a-button v-if="summary.downloadUrl" type="link" @click="download">
下载 PDF
</a-button>
</a-space>
<a-spin :spinning="loading">
<a-descriptions
v-if="summary.studentName || summary.studentNo"
bordered
size="small"
:column="3"
class="student-info"
>
<a-descriptions-item label="姓名">{{
summary.studentName || '-'
}}</a-descriptions-item>
<a-descriptions-item label="学号">{{
summary.studentNo || '-'
}}</a-descriptions-item>
<a-descriptions-item label="学院">{{
summary.collegeName || '-'
}}</a-descriptions-item>
<a-descriptions-item label="专业">{{
summary.major || '-'
}}</a-descriptions-item>
<a-descriptions-item label="班级">{{
summary.className || '-'
}}</a-descriptions-item>
<a-descriptions-item label="证书编号">{{
summary.serialNo || '未生成'
}}</a-descriptions-item>
</a-descriptions>
<a-row :gutter="[16, 16]" style="margin-top: 16px">
<a-col :xs="24" :sm="8">
<div class="metric-card">
<div class="metric-label">年度累计时长</div>
<div class="metric-value">{{ summary.totalHours ?? 0 }}</div>
<div class="metric-foot">仅统计已审核通过的记录</div>
</div>
</a-col>
<a-col :xs="24" :sm="8">
<div class="metric-card accent">
<div class="metric-label">志愿项目数</div>
<div class="metric-value">{{ summary.projectCount ?? 0 }}</div>
<div class="metric-foot">按项目名称去重</div>
</div>
</a-col>
<a-col :xs="24" :sm="8">
<div class="metric-card warm">
<div class="metric-label">记录条数</div>
<div class="metric-value">{{ summary.approvedCount ?? 0 }}</div>
<div class="metric-foot">该年度已通过的志愿记录</div>
</div>
</a-col>
</a-row>
<a-card :bordered="false" class="project-card" title="服务明细">
<a-empty
v-if="!summary.items?.length"
description="该年度暂无可生成证明的志愿记录"
/>
<a-table
v-else
row-key="id"
size="small"
:data-source="summary.items"
:columns="projectColumns"
:pagination="false"
/>
</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 type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import {
getVolunteerCertificateSummary,
generateVolunteerCertificate
} from '@/api/growth/volunteer';
const loading = ref(false);
const generating = ref(false);
const year = ref(dayjs().year());
const studentNo = ref('');
const summary = ref<Record<string, any>>({});
const yearOptions = computed(() => {
const current = dayjs().year();
return Array.from({ length: 6 }, (_, index) => {
const value = current - index;
return { label: `${value} 年`, value };
});
});
const projectColumns: ColumnItem[] = [
{
title: '志愿项目',
dataIndex: 'projectName',
key: 'projectName',
ellipsis: true
},
{
title: '活动时间',
dataIndex: 'serviceDate',
key: 'serviceDate',
width: 180
},
{
title: '服务地点',
dataIndex: 'servicePlace',
key: 'servicePlace',
width: 200,
ellipsis: true
},
{
title: '组织单位',
dataIndex: 'orgName',
key: 'orgName',
width: 200,
ellipsis: true
},
{
title: '时长',
dataIndex: 'hours',
key: 'hours',
width: 90,
align: 'center'
},
{
title: '数据来源',
dataIndex: 'source',
key: 'source',
width: 110,
align: 'center',
customRender: ({ text }) => sourceLabel(text)
}
];
const sourceLabel = (value?: string) => {
const map: Record<string, string> = {
self: '个人录入',
gz: '桂志愿',
import: '导入'
};
return map[value ?? ''] ?? value ?? '-';
};
const loadData = async () => {
loading.value = true;
try {
summary.value = await getVolunteerCertificateSummary({
year: year.value,
studentNo: studentNo.value || undefined
});
} catch (e: any) {
summary.value = {};
message.error(e?.message || '加载志愿数据失败');
} finally {
loading.value = false;
}
};
const generate = async () => {
generating.value = true;
try {
const certificate = await generateVolunteerCertificate({
year: year.value,
studentNo: studentNo.value || undefined
});
message.success('证明已生成,可下载 PDF');
summary.value = {
...summary.value,
generated: true,
serialNo: certificate.serialNo,
downloadUrl: certificate.downloadUrl || certificate.fileUrl,
generatedAt: certificate.createTime
};
} catch (e: any) {
message.error(e?.message || '生成证明失败');
} finally {
generating.value = false;
}
};
const download = () => {
const url = summary.value.downloadUrl;
if (!url) {
message.warning('暂无可下载的证明文件,请先生成');
return;
}
window.open(url, '_blank');
};
onMounted(loadData);
</script>
<style lang="less" scoped>
.student-info {
margin-bottom: 4px;
}
.metric-card {
padding: 18px 20px;
border-radius: 10px;
background: #fff;
border-left: 4px solid #1677ff;
}
.metric-card.accent {
border-left-color: #13c2c2;
}
.metric-card.warm {
border-left-color: #faad14;
}
.metric-label {
font-size: 13px;
color: #8c8c8c;
}
.metric-value {
margin: 6px 0 4px;
font-size: 26px;
font-weight: 600;
color: #262626;
}
.metric-foot {
font-size: 12px;
color: #bfbfbf;
}
.project-card {
margin-top: 16px;
}
</style>
@@ -0,0 +1,375 @@
<!-- 志愿时长录入 -->
<template>
<ele-modal
:width="640"
:visible="visible"
:maskClosable="false"
:confirm-loading="loading"
title="录入志愿服务时长"
:body-style="{ paddingBottom: '24px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="校外或未通过平台记录的志愿服务,需要上传盖章版证明材料,提交后由二级学院审核。"
/>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-form-item label="志愿项目名称" name="projectName">
<a-input
v-model:value="form.projectName"
allow-clear
placeholder="请输入项目名称"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="组织单位" name="orgName">
<a-input
v-model:value="form.orgName"
allow-clear
placeholder="请输入组织单位"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="服务地点" name="servicePlace">
<a-input
v-model:value="form.servicePlace"
allow-clear
placeholder="请输入服务地点"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="服务时长(小时)" name="hours">
<a-input-number
v-model:value="form.hours"
:min="0.5"
:step="0.5"
class="ele-fluid"
placeholder="请输入时长"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="服务日期" name="serviceDate">
<a-date-picker
v-model:value="serviceDate"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="请选择日期"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结束日期" name="serviceEndDate">
<a-date-picker
v-model:value="serviceEndDate"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="单日可不填"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="服务内容" name="content">
<a-textarea
v-model:value="form.content"
:rows="3"
allow-clear
placeholder="简述服务内容"
/>
</a-form-item>
<a-form-item label="盖章版证明材料" name="proofFiles" required>
<MultiFileUpload v-model="form.proofFiles" :max-count="5" />
<div class="ele-text-secondary" style="font-size: 12px">
支持图片或 PDF,建议上传盖有组织单位公章的证明材料
</div>
<div class="recognize-bar">
<a-button
size="small"
:loading="recognizing"
:disabled="!firstProof"
@click="recognize"
>
<template #icon><scan-outlined /></template>
识别材料自动填写
</a-button>
<span
v-if="!firstProof"
class="ele-text-secondary"
style="font-size: 12px"
>
上传材料后可用
</span>
<span
v-else-if="recognizeTip"
class="ele-text-secondary"
style="font-size: 12px"
>
{{ recognizeTip }}
</span>
</div>
<a-alert
v-if="recognized && recognizedFields.length"
type="info"
show-icon
class="recognize-result"
:message="sealText"
>
<template #description>
<div class="recognize-grid">
<div v-for="item in recognizedFields" :key="item.label">
<span class="recognize-label">{{ item.label }}:</span>
<span>{{ item.value }}</span>
<span v-if="item.differs" class="recognize-diff"
>(当前填写:{{ item.current }})</span
>
</div>
</div>
<a
style="display: inline-block; margin-top: 8px"
@click="applyRecognized"
>
用识别结果覆盖当前填写
</a>
</template>
</a-alert>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { ScanOutlined } from '@ant-design/icons-vue';
import dayjs from 'dayjs';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import {
submitVolunteerRecord,
recognizeVolunteerProof
} from '@/api/growth/volunteer';
import type { VolunteerRecord } from '@/api/growth/volunteer';
import { parseAttachments } from '@/views/gxmu/utils/attachment';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const formRef = ref();
const loading = ref(false);
const recognizing = ref(false);
const recognizeTip = ref('');
const recognized = ref<Record<string, any> | null>(null);
const form = reactive<VolunteerRecord>({});
const serviceDate = ref<any>();
const serviceEndDate = ref<any>();
/** 材料列表里的第一份,识别以它为准 */
const firstProof = computed(() => {
const files = parseAttachments(form.proofFiles);
return files.length ? files[0] : null;
});
/** 识别到的字段与当前填写的对照,差异项在界面上标出来 */
const recognizedFields = computed(() => {
if (!recognized.value) return [];
const data = recognized.value;
const rows = [
{ label: '活动名称', value: data.projectName, current: form.projectName },
{ label: '组织单位', value: data.orgName, current: form.orgName },
{
label: '服务地点',
value: data.servicePlace,
current: form.servicePlace
},
{ label: '服务时长', value: data.hours, current: form.hours },
{ label: '服务日期', value: data.serviceDate, current: serviceDate.value }
];
return rows
.filter(
(row) =>
row.value !== null && row.value !== undefined && row.value !== ''
)
.map((row) => ({
...row,
differs:
row.current !== null &&
row.current !== undefined &&
row.current !== '' &&
String(row.current) !== String(row.value)
}));
});
const sealText = computed(() => {
if (recognized.value?.hasSeal === true) {
return '已识别到公章,请核对下方自动提取的内容';
}
if (recognized.value?.hasSeal === false) {
return '未识别到公章,请确认上传的是盖章版材料';
}
return '材料识别结果';
});
const rules = {
projectName: [
{ required: true, message: '请输入志愿项目名称', trigger: 'blur' }
],
hours: [{ required: true, message: '请输入服务时长', trigger: 'change' }],
serviceDate: [
{ required: true, message: '请选择服务日期', trigger: 'change' }
],
proofFiles: [
{ required: true, message: '请上传证明材料', trigger: 'change' }
]
};
watch(
() => props.visible,
(value) => {
if (value) {
Object.keys(form).forEach((key) => delete (form as any)[key]);
serviceDate.value = undefined;
serviceEndDate.value = undefined;
recognizeTip.value = '';
recognized.value = null;
}
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
/** 调识别接口,只做展示,不直接改动用户已填内容 */
const recognize = async () => {
const file = firstProof.value;
if (!file) {
message.warning('请先上传证明材料');
return;
}
const fileUrl = file.url;
if (!fileUrl) {
message.warning('材料地址缺失,请重新上传');
return;
}
recognizing.value = true;
recognizeTip.value = '';
try {
const data = await recognizeVolunteerProof({
fileUrl,
fileName: file.name
});
if (data.enabled === false) {
recognizeTip.value = data.message || '材料识别未启用';
recognized.value = null;
return;
}
if (data.success === false) {
recognizeTip.value = data.message || '识别失败,请手工填写';
recognized.value = null;
return;
}
recognized.value = data;
recognizeTip.value = data.message || '识别完成';
// 只填空白项:用户已经填过的内容不轻易覆盖,避免识别偏差把正确值改错
fillBlanks(data);
} catch (e: any) {
recognizeTip.value = e?.message || '识别服务暂不可用,请手工填写';
recognized.value = null;
} finally {
recognizing.value = false;
}
};
/** 仅补空字段 */
const fillBlanks = (data: Record<string, any>) => {
if (!form.projectName && data.projectName)
form.projectName = data.projectName;
if (!form.orgName && data.orgName) form.orgName = data.orgName;
if (!form.servicePlace && data.servicePlace)
form.servicePlace = data.servicePlace;
if (!form.hours && data.hours) form.hours = Number(data.hours);
if (!serviceDate.value && data.serviceDate)
serviceDate.value = data.serviceDate;
if (!serviceEndDate.value && data.serviceEndDate)
serviceEndDate.value = data.serviceEndDate;
};
/** 用户确认后用识别结果全量覆盖 */
const applyRecognized = () => {
const data = recognized.value;
if (!data) return;
if (data.projectName) form.projectName = data.projectName;
if (data.orgName) form.orgName = data.orgName;
if (data.servicePlace) form.servicePlace = data.servicePlace;
if (data.hours) form.hours = Number(data.hours);
if (data.serviceDate) serviceDate.value = data.serviceDate;
if (data.serviceEndDate) serviceEndDate.value = data.serviceEndDate;
message.success('已用识别结果覆盖当前填写');
};
const normalizeDate = (value: any) => {
if (!value) return undefined;
return dayjs.isDayjs(value) ? value.format('YYYY-MM-DD') : String(value);
};
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
form.serviceDate = normalizeDate(serviceDate.value);
form.serviceEndDate = normalizeDate(serviceEndDate.value);
const msg = await submitVolunteerRecord(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '提交失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
<style lang="less" scoped>
.recognize-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.recognize-result {
margin-top: 12px;
}
.recognize-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.recognize-label {
color: #8c8c8c;
}
.recognize-diff {
margin-left: 6px;
color: #fa8c16;
}
</style>
@@ -0,0 +1,91 @@
<template>
<common-import
v-model:visible="visible"
module-name="志愿服务时长"
file-name="桂志愿数据导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
:validate-item="validateItem"
import-description="请从桂志愿平台导出个人志愿服务记录,按模板整理后上传。导入的记录默认为「已通过」,按(来源 + 外部编号)自动去重。"
@done="onDone"
/>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import CommonImport from '@/views/gxmu/components/common-import.vue';
import { importVolunteerRecord } from '@/api/growth/volunteer';
import type { VolunteerRecord } from '@/api/growth/volunteer';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'done'): void;
}>();
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
});
const templateFields = [
{ header: '学号', key: 'studentNo', required: true },
{ header: '姓名', key: 'studentName', required: true },
{ header: '志愿项目', key: 'projectName', required: true },
{ header: '组织单位', key: 'orgName' },
{ header: '服务地点', key: 'servicePlace' },
{ header: '服务时长', key: 'hours', required: true },
{ header: '服务日期', key: 'serviceDate', required: true },
{ header: '外部编号', key: 'externalId' }
];
const sampleRow = {
学号: '2021001001',
姓名: '张三',
志愿项目: '社区义诊志愿服务',
组织单位: '广西医科大学青年志愿者协会',
服务地点: '南宁市青秀区社区服务中心',
服务时长: '4',
服务日期: '2025-10-12',
外部编号: 'gzy-2025-0001'
};
const normalizeDate = (value: any) => {
if (!value) return undefined;
if (typeof value === 'number') {
const date = new Date((value - 25569) * 86400 * 1000);
return date.toISOString().slice(0, 10);
}
return String(value).trim();
};
const createPayload = (row: Record<string, any>): VolunteerRecord => ({
studentNo: String(row['学号'] || '').trim(),
studentName: String(row['姓名'] || '').trim(),
projectName: String(row['志愿项目'] || '').trim(),
orgName: String(row['组织单位'] || '').trim() || undefined,
servicePlace: String(row['服务地点'] || '').trim() || undefined,
hours: Number(row['服务时长'] || 0),
serviceDate: normalizeDate(row['服务日期']),
externalId: String(row['外部编号'] || '').trim() || undefined,
source: 'gz',
status: 1
});
const validateItem = (item: VolunteerRecord, rowNo: number) => {
const errors: string[] = [];
if (!item.hours || item.hours <= 0) {
errors.push(`第 ${rowNo} 行服务时长必须大于 0`);
}
if (!item.serviceDate) {
errors.push(`第 ${rowNo} 行服务日期格式不正确`);
}
return errors;
};
const saveItem = async (item: VolunteerRecord) => importVolunteerRecord(item);
const onDone = () => emit('done');
</script>
+453
View File
@@ -0,0 +1,453 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<div class="volunteer-head">
<div class="head-metrics">
<div class="metric">
<span class="metric-label">累计通过时长</span>
<span class="metric-value">{{ summary.totalHours ?? 0 }}</span>
<span class="metric-unit">小时</span>
</div>
<div class="metric">
<span class="metric-label">已通过</span>
<span class="metric-value">{{ summary.approvedCount ?? 0 }}</span>
<span class="metric-unit">条</span>
</div>
<div class="metric">
<span class="metric-label">待审核</span>
<span class="metric-value warning">{{
summary.pendingCount ?? 0
}}</span>
<span class="metric-unit">条</span>
</div>
</div>
<a-space>
<a-button type="primary" @click="openSubmit">
<template #icon><plus-outlined /></template>
录入时长
</a-button>
<a-button
v-if="hasPermission('gxmu:volunteer:import')"
@click="showImport = true"
>
导入桂志愿数据
</a-button>
<a-button :loading="loading" @click="reload">刷新</a-button>
</a-space>
</div>
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1360 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="审核状态"
:options="statusOptions"
@change="reload"
/>
<a-select
v-model:value="source"
allow-clear
style="width: 140px"
placeholder="来源"
:options="sourceOptions"
@change="reload"
/>
<a-range-picker
v-model:value="dateRange"
value-format="YYYY-MM-DD"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="项目/组织/姓名/学号"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'hours'">
<span class="hours-text">{{ record.hours }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">
{{ statusLabel(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tag>{{ sourceLabel(record.source) }}</a-tag>
</template>
<template v-else-if="column.key === 'proofFiles'">
<AttachmentList :value="record.proofFiles" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">查看</a>
<template v-if="record.status === 0 && isMine(record)">
<a-divider type="vertical" />
<a-popconfirm
title="撤回后该记录将被删除,需要重新录入,确定撤回吗?"
@confirm="withdraw(record)"
>
<a>撤回</a>
</a-popconfirm>
</template>
<template v-if="record.status === 0 && canReview">
<a-divider type="vertical" />
<a @click="doReview(record, 1)">通过</a>
<a-divider type="vertical" />
<a class="ele-text-danger" @click="doReview(record, 2)"
>驳回</a
>
</template>
<a-popconfirm
v-if="record.status !== 1"
title="确定要删除这条志愿记录吗?"
@confirm="remove(record)"
>
<a-divider type="vertical" />
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<VolunteerEdit v-model:visible="showSubmit" @done="onSubmitted" />
<VolunteerImport v-model:visible="showImport" @done="onSubmitted" />
<a-modal
v-model:visible="showDetail"
title="志愿时长详情"
:footer="null"
width="640px"
>
<a-descriptions v-if="current" :column="1" bordered size="small">
<a-descriptions-item label="项目名称">{{
current.projectName
}}</a-descriptions-item>
<a-descriptions-item label="组织单位">{{
current.orgName || '-'
}}</a-descriptions-item>
<a-descriptions-item label="服务地点">{{
current.servicePlace || '-'
}}</a-descriptions-item>
<a-descriptions-item label="服务时长"
>{{ current.hours }} 小时</a-descriptions-item
>
<a-descriptions-item label="服务日期">
{{ current.serviceDate || '-' }}
<span v-if="current.serviceEndDate">
至 {{ current.serviceEndDate }}</span
>
</a-descriptions-item>
<a-descriptions-item label="服务内容">{{
current.content || '-'
}}</a-descriptions-item>
<a-descriptions-item label="证明材料">
<AttachmentList :value="current.proofFiles" />
</a-descriptions-item>
<a-descriptions-item label="审核状态">
<a-tag :color="statusColor(current.status)">{{
statusLabel(current.status)
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="current.reviewRemark" label="审核意见">
{{ current.reviewRemark }}
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import VolunteerEdit from './components/edit.vue';
import VolunteerImport from './components/import.vue';
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import {
pageVolunteerRecords,
removeVolunteerRecord,
reviewVolunteerRecord,
withdrawVolunteerRecord,
getMyVolunteerSummary
} from '@/api/growth/volunteer';
import type { VolunteerRecord } from '@/api/growth/volunteer';
import { hasPermission, hasAnyRole } from '@/utils/permission';
import { useUserStore } from '@/store/modules/user';
const userStore = useUserStore();
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
const isMine = (record: VolunteerRecord) =>
!!record.userId && record.userId === loginUserId.value;
const withdraw = async (record: VolunteerRecord) => {
if (!record.id) return;
try {
const msg = await withdrawVolunteerRecord(record.id);
message.success(msg);
reload();
await loadSummary();
} catch (e: any) {
message.error(e?.message || '撤回失败');
}
};
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const loading = ref(false);
const status = ref<number>();
const source = ref<string>();
const searchText = ref('');
const dateRange = ref<string[]>();
const showSubmit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<VolunteerRecord | null>(null);
const summary = ref<Record<string, any>>({});
const canReview = computed(
() =>
hasAnyRole([
'SchoolYouthLeagueCommittee',
'YouthLeagueCommitteeOfSecondaryColleges',
'superAdmin',
'admin'
]) || hasPermission('gxmu:volunteer:review')
);
const statusOptions = [
{ label: '待审核', value: 0 },
{ label: '通过', value: 1 },
{ label: '驳回', value: 2 }
];
const sourceOptions = [
{ label: '个人录入', value: 'self' },
{ label: '桂志愿', value: 'gz' },
{ label: '导入', value: 'import' }
];
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{
title: '志愿项目',
dataIndex: 'projectName',
key: 'projectName',
ellipsis: true
},
{
title: '组织单位',
dataIndex: 'orgName',
key: 'orgName',
width: 160,
ellipsis: true
},
{
title: '服务地点',
dataIndex: 'servicePlace',
key: 'servicePlace',
width: 160,
ellipsis: true
},
{ title: '时长', key: 'hours', width: 90, align: 'center' },
{
title: '服务日期',
dataIndex: 'serviceDate',
key: 'serviceDate',
width: 120,
align: 'center'
},
{ title: '来源', key: 'source', width: 100, align: 'center' },
{ title: '证明材料', key: 'proofFiles', width: 150 },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{
title: '提交时间',
dataIndex: 'createTime',
key: 'createTime',
width: 160,
align: 'center',
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
loading.value = true;
return pageVolunteerRecords({
...where,
...orders,
page,
limit,
backendAccess: true,
status: status.value,
source: source.value,
serviceDateStart: dateRange.value?.[0],
serviceDateEnd: dateRange.value?.[1],
keywords: searchText.value || undefined
}).finally(() => {
loading.value = false;
});
};
const reload = () => {
tableRef.value?.reload?.({ page: 1 });
};
const resetSearch = () => {
status.value = undefined;
source.value = undefined;
searchText.value = '';
dateRange.value = undefined;
reload();
};
const openSubmit = () => {
showSubmit.value = true;
};
const openDetail = (record: VolunteerRecord) => {
current.value = record;
showDetail.value = true;
};
const doReview = async (record: VolunteerRecord, value: number) => {
try {
const msg = await reviewVolunteerRecord({ id: record.id, status: value });
message.success(msg);
reload();
await loadSummary();
} catch (e: any) {
message.error(e?.message || '审核失败');
}
};
const remove = async (record: VolunteerRecord) => {
if (!record.id) return;
try {
const msg = await removeVolunteerRecord(record.id);
message.success(msg);
reload();
await loadSummary();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const statusLabel = (value?: number) => {
if (value === 1) return '通过';
if (value === 2) return '驳回';
return '待审核';
};
const statusColor = (value?: number) => {
if (value === 1) return 'green';
if (value === 2) return 'red';
return 'orange';
};
const sourceLabel = (value?: string) => {
const map: Record<string, string> = {
self: '个人录入',
gz: '桂志愿',
import: '导入'
};
return map[value ?? ''] ?? value ?? '-';
};
const loadSummary = async () => {
try {
summary.value = await getMyVolunteerSummary();
} catch (e: any) {
summary.value = {};
}
};
const onSubmitted = () => {
reload();
loadSummary();
};
onMounted(loadSummary);
</script>
<style lang="less" scoped>
.volunteer-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
flex-wrap: wrap;
padding-bottom: 18px;
}
.head-metrics {
display: flex;
gap: 36px;
flex-wrap: wrap;
}
.metric {
display: flex;
align-items: baseline;
gap: 6px;
}
.metric-label {
font-size: 13px;
color: #8c8c8c;
}
.metric-value {
font-size: 24px;
font-weight: 600;
color: #1677ff;
}
.metric-value.warning {
color: #faad14;
}
.metric-unit {
font-size: 12px;
color: #8c8c8c;
}
.hours-text {
font-weight: 600;
color: #13c2c2;
}
</style>
+441
View File
@@ -0,0 +1,441 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="审核状态"
:options="statusOptions"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="项目/组织/姓名/学号"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'hours'">
<span class="hours-text">{{ record.hours }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{
statusLabel(record.status)
}}</a-tag>
</template>
<template v-else-if="column.key === 'proofFiles'">
<AttachmentList :value="record.proofFiles" :max-count="2" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openReview(record)">审核</a>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<a-modal
v-model:visible="showReview"
title="志愿时长审核"
:confirm-loading="submitting"
:ok-button-props="{ disabled: !myPendingNode }"
:ok-text="myPendingNode ? '提交审核' : '当前无待办'"
width="720px"
@ok="submitReview"
>
<a-descriptions v-if="current" :column="1" bordered size="small">
<a-descriptions-item label="姓名">{{
current.studentName
}}</a-descriptions-item>
<a-descriptions-item label="学号">{{
current.studentNo
}}</a-descriptions-item>
<a-descriptions-item label="志愿项目">{{
current.projectName
}}</a-descriptions-item>
<a-descriptions-item label="组织单位">{{
current.orgName || '-'
}}</a-descriptions-item>
<a-descriptions-item label="服务地点">{{
current.servicePlace || '-'
}}</a-descriptions-item>
<a-descriptions-item label="服务时长"
>{{ current.hours }} 小时</a-descriptions-item
>
<a-descriptions-item label="服务日期">
{{ current.serviceDate || '-' }}
<span v-if="current.serviceEndDate">
至 {{ current.serviceEndDate }}</span
>
</a-descriptions-item>
<a-descriptions-item label="服务内容">{{
current.content || '-'
}}</a-descriptions-item>
<a-descriptions-item label="证明材料">
<AttachmentList :value="current.proofFiles" />
</a-descriptions-item>
</a-descriptions>
<a-card
v-if="ocrFields.length"
:bordered="false"
size="small"
class="ocr-card"
title="材料识别结果"
>
<template #extra>
<a-tag v-if="current?.ocrHasSeal === 1" color="green"
>已检出公章</a-tag
>
<a-tag v-else-if="current?.ocrHasSeal === 0" color="red"
>未检出公章</a-tag
>
<a-tag v-else>未识别</a-tag>
</template>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item
v-for="item in ocrFields"
:key="item.label"
:label="item.label"
>
<span :class="{ 'ocr-diff': item.differs }">{{
item.value
}}</span>
<span v-if="item.differs" class="ocr-origin"
>(填写:{{ item.current }})</span
>
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card
v-if="progressNodes.length"
:bordered="false"
size="small"
class="flow-card"
title="审核流程"
>
<a-steps direction="vertical" size="small" :current="currentStep">
<a-step
v-for="node in progressNodes"
:key="node.id"
:status="stepStatus(node)"
>
<template #title>
<span>{{ node.nodeName || '审核' }}</span>
<span class="node-reviewer">{{
node.reviewerName || '未指定审核人'
}}</span>
<a-tag
v-if="node.reviewerId === loginUserId && node.status === 0"
color="processing"
style="margin-left: 8px"
>
待我审核
</a-tag>
</template>
<template #description>
<div v-if="node.status === 1"
>已通过 {{ formatTime(node.updateTime) }}</div
>
<div v-else-if="node.status === 2" class="ele-text-danger">
已驳回 {{ formatTime(node.updateTime) }}
</div>
<div v-else>待审核</div>
<div v-if="node.content">意见:{{ node.content }}</div>
</template>
</a-step>
</a-steps>
</a-card>
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="审核结果" required>
<a-radio-group v-model:value="reviewStatus">
<a-radio :value="1">通过</a-radio>
<a-radio :value="2">驳回</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="审核意见" :required="reviewStatus === 2">
<a-textarea
v-model:value="reviewRemark"
:rows="3"
placeholder="驳回时请说明原因"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import {
pageVolunteerRecords,
reviewVolunteerRecord,
getVolunteerProgress
} from '@/api/growth/volunteer';
import type {
VolunteerRecord,
VolunteerReviewNode
} from '@/api/growth/volunteer';
import { useUserStore } from '@/store/modules/user';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const userStore = useUserStore();
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
const status = ref<number>(0);
const searchText = ref('');
const showReview = ref(false);
const current = ref<VolunteerRecord | null>(null);
const reviewStatus = ref(1);
const reviewRemark = ref('');
const submitting = ref(false);
const progressNodes = ref<VolunteerReviewNode[]>([]);
const progressLoading = ref(false);
/** 当前登录人负责的那个待审节点;为空表示这条记录不在我手上 */
const myPendingNode = computed(() =>
progressNodes.value.find(
(node) => node.reviewerId === loginUserId.value && node.status === 0
)
);
/** 步骤条当前步:第一个待审节点 */
const currentStep = computed(() => {
const index = progressNodes.value.findIndex((node) => node.status === 0);
return index < 0 ? progressNodes.value.length : index;
});
/** 识别结果与填写值的对照 */
const ocrFields = computed(() => {
const record = current.value;
if (
!record ||
record.ocrStatus === null ||
record.ocrStatus === undefined
) {
return [];
}
const rows = [
{
label: '活动名称',
value: record.ocrProjectName,
current: record.projectName
},
{ label: '组织单位', value: record.ocrOrg, current: record.orgName },
{
label: '服务地点',
value: record.ocrPlace,
current: record.servicePlace
},
{ label: '服务时长', value: record.ocrHours, current: record.hours }
];
return rows
.filter(
(row) =>
row.value !== null && row.value !== undefined && row.value !== ''
)
.map((row) => ({
...row,
differs:
row.current !== null &&
row.current !== undefined &&
String(row.current) !== String(row.value)
}));
});
const statusOptions = [
{ label: '待审核', value: 0 },
{ label: '通过', value: 1 },
{ label: '驳回', value: 2 }
];
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{
title: '志愿项目',
dataIndex: 'projectName',
key: 'projectName',
ellipsis: true
},
{
title: '组织单位',
dataIndex: 'orgName',
key: 'orgName',
width: 160,
ellipsis: true
},
{ title: '时长', key: 'hours', width: 90, align: 'center' },
{
title: '服务日期',
dataIndex: 'serviceDate',
key: 'serviceDate',
width: 120,
align: 'center'
},
{ title: '证明材料', key: 'proofFiles', width: 150 },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{
title: '审核意见',
dataIndex: 'reviewRemark',
key: 'reviewRemark',
ellipsis: true
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageVolunteerRecords({
...where,
...orders,
page,
limit,
backendAccess: true,
status: status.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
status.value = undefined;
searchText.value = '';
reload();
};
const openReview = async (record: VolunteerRecord) => {
current.value = record;
reviewStatus.value = record.status === 2 ? 2 : 1;
reviewRemark.value = record.reviewRemark || '';
progressNodes.value = [];
showReview.value = true;
if (!record.id) return;
progressLoading.value = true;
try {
const data = await getVolunteerProgress(record.id);
progressNodes.value = (data?.nodes as VolunteerReviewNode[]) || [];
} catch (e: any) {
// 没配审核流时接口返回空节点,这里静默处理,退回单级审核
progressNodes.value = [];
} finally {
progressLoading.value = false;
}
};
const formatTime = (value?: string) => {
return value ? toDateString(value, 'yyyy-MM-dd HH:mm') : '';
};
const stepStatus = (node: VolunteerReviewNode) => {
if (node.status === 1) return 'finish';
if (node.status === 2) return 'error';
if (node.reviewerId === loginUserId.value) return 'process';
return 'wait';
};
const submitReview = async () => {
if (!current.value?.id) return;
if (progressNodes.value.length && !myPendingNode.value) {
message.warning('当前节点不在你手上,请等待前一级审核完成');
return;
}
if (reviewStatus.value === 2 && !reviewRemark.value.trim()) {
message.warning('驳回时请填写审核意见');
return;
}
submitting.value = true;
try {
const msg = await reviewVolunteerRecord({
id: current.value.id,
status: reviewStatus.value,
reviewRemark: reviewRemark.value.trim() || undefined
});
message.success(msg);
showReview.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '审核失败');
} finally {
submitting.value = false;
}
};
const statusLabel = (value?: number) => {
if (value === 1) return '通过';
if (value === 2) return '驳回';
return '待审核';
};
const statusColor = (value?: number) => {
if (value === 1) return 'green';
if (value === 2) return 'red';
return 'orange';
};
onMounted(() => undefined);
</script>
<style lang="less" scoped>
.hours-text {
font-weight: 600;
color: #13c2c2;
}
.ocr-card,
.flow-card {
margin-top: 16px;
}
.ocr-diff {
color: #fa8c16;
font-weight: 600;
}
.ocr-origin {
margin-left: 4px;
color: #bfbfbf;
font-size: 12px;
}
.node-reviewer {
margin-left: 8px;
color: #8c8c8c;
font-size: 12px;
}
</style>
@@ -47,7 +47,7 @@
:color="item.status === 1 ? 'success' : item.status === 0 ? 'error' : 'default'"
style="margin-left: 8px"
>
{{ item.status === 1 ? '通过' : item.status === 0 ? '不通过' : '-' }}
{{ item.status === 1 ? '通过' : item.status === 0 ? '驳回' : '-' }}
</a-tag>
</div>
<div v-if="item.user?.realName" style="margin-bottom: 4px">
+1 -1
View File
@@ -29,7 +29,7 @@
<a-tag color="green">通过</a-tag>
</template>
<template v-else-if="getLastReview(record)?.status === 2">
<a-tag color="red">不通过</a-tag>
<a-tag color="red">驳回</a-tag>
</template>
<template v-else>
<a-tag>未审核</a-tag>
@@ -0,0 +1,93 @@
<!-- 音频 / 视频预览弹窗:素材库里非图片项的统一播放器 -->
<template>
<ele-modal
:width="kind === 'video' ? 880 : 480"
:footer="null"
:visible="visible"
:title="record?.name"
:destroy-on-close="true"
@update:visible="updateVisible"
>
<div v-if="visible && record" class="media-preview">
<ele-xg-player
v-if="kind === 'video'"
:key="'v-' + record.id"
:config="playerConfig"
/>
<div v-else class="media-preview-audio">
<customer-service-outlined class="media-preview-audio-icon" />
<div class="media-preview-audio-name ele-text-secondary">
{{ record.name }}
</div>
<audio
:key="'a-' + record.id"
:src="record.url"
controls
autoplay
class="media-preview-audio-player"
></audio>
</div>
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { CustomerServiceOutlined } from '@ant-design/icons-vue';
import type { FileRecord } from '@/api/system/file/model';
import { mediaKindOfRecord } from '@/utils/media';
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 当前预览的文件
record?: FileRecord | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const kind = computed(() => mediaKindOfRecord(props.record));
// xgplayer 配置,key 变化时重建实例,避免沿用上一条的播放状态
const playerConfig = computed(() => ({
id: 'cmsPhotoPreviewPlayer',
lang: 'zh-cn',
fluid: true,
url: props.record?.url,
playbackRate: [0.5, 1, 1.5, 2],
pip: true
}));
/* 更新 visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>
<style lang="less" scoped>
.media-preview-audio {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0 8px;
}
.media-preview-audio-icon {
font-size: 48px;
color: #805ad5;
}
.media-preview-audio-name {
margin: 12px 0 20px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-preview-audio-player {
width: 100%;
}
</style>
+113 -113
View File
@@ -1,10 +1,10 @@
<!-- 角色编辑弹窗 -->
<!-- 素材编辑弹窗:文件在列表页上传时已入库,这里只改元数据和分享代码 -->
<template>
<ele-modal
:width="600"
:visible="visible"
:confirm-loading="loading"
:title="isUpdate ? '编辑' : '上传文件'"
title="编辑素材"
:body-style="{ paddingBottom: '8px' }"
okText="保存"
@update:visible="updateVisible"
@@ -19,27 +19,22 @@
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="上传文件" name="fileName" v-if="!isUpdate">
<span
class="ele-text-success"
v-if="fileName"
style="margin-right: 10px"
>
{{ fileName }}
</span>
<a-upload
:show-upload-list="false"
:accept="'video/*'"
v-if="!fileName"
:customRequest="onUpload"
>
<a-button type="primary" class="ele-btn-icon">
<template #icon>
<UploadOutlined />
</template>
<span>上传文件</span>
</a-button>
</a-upload>
<a-form-item label="文件">
<div class="media-file">
<component
:is="fileIcon"
class="media-file-icon"
:style="{ color: fileColor }"
/>
<div class="media-file-info">
<div class="media-file-name" :title="record?.name">
{{ record?.name }}
</div>
<div class="ele-text-secondary media-file-sub">
{{ kindLabel }} · {{ fileSize }}
</div>
</div>
</div>
</a-form-item>
<a-form-item label="文件名称" name="name">
<a-input
@@ -53,17 +48,17 @@
<a-textarea
:rows="4"
:maxlength="200"
placeholder="图片描述"
placeholder="素材描述"
v-model:value="form.comments"
/>
</a-form-item>
<div style="margin-bottom: 22px">
<a-divider orientation="left">图片分享</a-divider>
<a-divider orientation="left">{{ shareTitle }}</a-divider>
</div>
<a-form-item label="图片URL链接">
<a-form-item label="素材链接">
<a-input v-model:value="share.url" @click="copyText(share.url)" />
</a-form-item>
<a-form-item label="网页代码(HTML)">
<a-form-item label="网页代码(HTML)" v-if="share.html">
<a-input v-model:value="share.html" @click="copyText(share.html)" />
</a-form-item>
<a-form-item label="Markdown">
@@ -72,38 +67,36 @@
@click="copyText(share.Markdown)"
/>
</a-form-item>
<a-form-item label="论坛BBCode">
<a-form-item label="论坛BBCode" v-if="share.bbCode">
<a-input v-model:value="share.bbCode" @click="copyText(share.bbCode)" />
</a-form-item>
<!-- <a-form-item label="封面图" name="images">-->
<!-- <ele-image-upload-->
<!-- v-model:value="images"-->
<!-- :limit="1"-->
<!-- :drag="true"-->
<!-- :item-style="{ width: '60px', height: '60px' }"-->
<!-- :accept="'image/*'"-->
<!-- :upload-handler="uploadHandler"-->
<!-- @upload="onUpload"-->
<!-- />-->
<!-- </a-form-item>-->
<a-form-item label="下载地址">
<a-input
v-model:value="share.download"
@click="copyText(share.download)"
/>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue/es';
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
import { storeToRefs } from 'pinia';
import { useThemeStore } from '@/store/modules/theme';
import useFormData from '@/utils/use-form-data';
import type { FileRecord } from '@/api/system/file/model';
import { messageLoading } from 'ele-admin-pro';
import { addFiles, updateFiles, uploadFile } from '@/api/system/file';
import { UploadOutlined } from '@ant-design/icons-vue';
import { RuleObject } from 'ant-design-vue/es/form';
import { updateFiles } from '@/api/system/file';
import { copyText } from '@/utils/common';
import {
formatFileSize,
mediaColor,
mediaIcon,
mediaKindOfRecord,
MEDIA_KIND_LABEL
} from '@/utils/media';
// 是否开启响应式布局
const themeStore = useThemeStore();
@@ -121,20 +114,11 @@
data?: FileRecord | null;
}>();
//
const formRef = ref<FormInstance | null>(null);
const fileName = ref('');
// 是否是修改
const isUpdate = ref(false);
// 当前编辑的原始记录,分享代码依赖它的 url,不随表单变化
const record = ref<FileRecord | null>(null);
// 提交状态
const loading = ref(false);
// 图片资源分享
const share = ref({
url: '',
Markdown: '',
html: '',
bbCode: ''
});
// 表单数据
const { form, resetFields, assignFields } = useFormData<FileRecord>({
@@ -143,22 +127,49 @@
comments: ''
});
const kind = computed(() => mediaKindOfRecord(record.value));
const kindLabel = computed(() => MEDIA_KIND_LABEL[kind.value]);
const fileSize = computed(() => formatFileSize(record.value?.length));
const fileIcon = computed(() =>
mediaIcon(kind.value, record.value?.path || record.value?.name)
);
const fileColor = computed(() =>
mediaColor(kind.value, record.value?.path || record.value?.name)
);
// 文档给的是链接与下载地址,图片/音视频给的是嵌入代码
const shareTitle = computed(() =>
kind.value === 'doc' ? '素材链接' : '素材分享'
);
/**
* 分享代码按类型生成:图片沿用 img 系列,音视频给 video/audio 标签,
* 文档只给直链和下载地址(不提供 HTML 嵌入)。
*/
const share = computed(() => {
const data = record.value;
const url = data?.url || '';
const name = data?.name || '';
const result = {
url,
html: '',
Markdown: `[${name}](${url})`,
bbCode: `[url=${url}]${name}[/url]`,
download: data?.downloadUrl || ''
};
if (kind.value === 'image') {
result.html = `<a href="${url}"><img src="${url}" alt="${name}" border="0" /></a>`;
result.Markdown = `[![${name}](${url})](${url})`;
result.bbCode = `[url=${url}][img]${url}[/img][/url]`;
} else if (kind.value === 'video') {
result.html = `<video src="${url}" controls></video>`;
} else if (kind.value === 'audio') {
result.html = `<audio src="${url}" controls></audio>`;
}
return result;
});
// 表单验证规则
const rules = reactive<Record<string, Rule[]>>({
fileName: [
{
required: true,
message: '请上传文件',
type: 'string',
trigger: 'blur',
validator: async (_rule: RuleObject) => {
if (!isUpdate.value && fileName.value.length == 0) {
return Promise.reject('请上传文件');
}
return Promise.resolve();
}
}
],
name: [
{
required: true,
@@ -169,39 +180,6 @@
]
});
// 上传文件
const onUpload = (item) => {
const { file } = item;
if (!file.type.startsWith('video')) {
message.error('文件格式不正确!');
return;
}
if (file.size / 1024 / 1024 > 100) {
message.error('大小不能超过 100MB');
return;
}
const hide = messageLoading({
content: '上传中..',
duration: 0,
mask: true
});
uploadFile(file)
.then((data) => {
hide();
fileName.value = String(data.name);
// images.value.push({
// uid: data.id,
// url: FILE_THUMBNAIL + data.path,
// status: 'done'
// });
message.success('上传成功');
})
.catch((e) => {
message.error(e.message);
hide();
});
};
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
@@ -211,8 +189,7 @@
.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateFiles : addFiles;
saveOrUpdate(form)
updateFiles(form)
.then((msg) => {
loading.value = false;
message.success(msg);
@@ -236,21 +213,44 @@
() => props.visible,
(visible) => {
if (visible) {
record.value = props.data ?? null;
if (props.data) {
assignFields(props.data);
// 图片分享代码
share.value.url = `${props.data.url}`;
share.value.html = `<a href="${props.data.url}"><img src="${props.data.url}" alt="${props.data.name}" border="0" /></a>`;
share.value.Markdown = `[![${props.data.name}](${props.data.url})](${props.data.url})`;
share.value.bbCode = `[url=${props.data.url}][img]${props.data.url}[/img][/url]`;
isUpdate.value = true;
} else {
isUpdate.value = false;
}
} else {
record.value = null;
resetFields();
formRef.value?.clearValidate();
}
}
);
</script>
<style lang="less" scoped>
.media-file {
display: flex;
align-items: center;
min-width: 0;
}
.media-file-icon {
flex: none;
font-size: 32px;
}
.media-file-info {
flex: 1;
min-width: 0;
margin-left: 10px;
}
.media-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-file-sub {
font-size: 12px;
}
</style>
+131 -28
View File
@@ -1,9 +1,10 @@
<!-- 素材库 · 网格视图 -->
<template>
<div class="page">
<a-row :gutter="20">
<a-col
v-for="(item, index) in data"
:key="index"
v-for="item in data"
:key="item.id"
v-bind="
styleResponsive
? { xl: 4, lg: 8, md: 12, sm: 12, xs: 24 }
@@ -12,10 +13,33 @@
>
<a-card :bordered="false" hoverable style="margin-top: 16px">
<template #cover>
<a-image :src="item.url" alt="" />
<!-- 统一 4:3 封面盒子:图片裁剪填充,其余类型放类型图标 -->
<div class="media-cover" @click="openPreview(item)">
<a-image
v-if="kindOf(item) === 'image'"
class="media-cover-image"
:src="item.thumbnail || item.url"
:preview="{ src: item.url }"
/>
<template v-else>
<component
:is="iconOf(item)"
class="media-cover-icon"
:style="{ color: colorOf(item) }"
/>
<span class="media-cover-ext">{{ extOf(item) }}</span>
<play-circle-filled
v-if="kindOf(item) === 'video'"
class="media-cover-play"
/>
</template>
</div>
</template>
<a-card-meta :title="item.name" @click="openEdit(item)">
<template #description>
<div class="media-meta">
{{ labelOf(item) }} · {{ sizeOf(item) }}
</div>
<div
class="project-list-desc"
v-if="item.comments"
@@ -41,58 +65,85 @@
</a-row>
<!-- 编辑弹窗 -->
<PhotoEdit v-model:visible="showEdit" :data="current" @done="query" />
<!-- 音视频预览 -->
<MediaPreview v-model:visible="showPreview" :record="current" />
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { PlayCircleFilled } from '@ant-design/icons-vue';
import { useThemeStore } from '@/store/modules/theme';
import { FileRecord, FileRecordParam } from '@/api/system/file/model';
import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
import PhotoEdit from './components/photo-edit.vue';
import MediaPreview from './components/media-preview.vue';
import { timeAgo } from 'ele-admin-pro';
import {
formatFileSize,
mediaColor,
mediaExtLabel,
mediaIcon,
mediaKindOfRecord,
MEDIA_KIND_LABEL,
type MediaKind
} from '@/utils/media';
const props = defineProps<{
// 搜索关键字
searchText: string;
defineProps<{
// 列表数据
data?: FileRecord[];
// 搜索条件
where?: FileRecordParam | null;
// 修改回显的数据
data?: FileRecord | null;
}>();
// 当前编辑数据
const current = ref<FileRecord | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
const emit = defineEmits<{
(e: 'done'): void;
}>();
// 当前操作的数据(编辑 / 预览共用)
const current = ref<FileRecord | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示音视频预览
const showPreview = ref(false);
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const kindOf = (item: FileRecord): MediaKind => mediaKindOfRecord(item);
const iconOf = (item: FileRecord) =>
mediaIcon(kindOf(item), item.path || item.name);
const colorOf = (item: FileRecord) =>
mediaColor(kindOf(item), item.path || item.name);
const extOf = (item: FileRecord) => mediaExtLabel(item);
const labelOf = (item: FileRecord) => MEDIA_KIND_LABEL[kindOf(item)];
const sizeOf = (item: FileRecord) => formatFileSize(item.length);
/* 打开编辑弹窗 */
const openEdit = (row?: FileRecord) => {
current.value = row ?? null;
showEdit.value = true;
};
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
/* 点击封面:图片由 a-image 自己预览,音视频开播放器,文档新窗口打开 */
const openPreview = (row: FileRecord) => {
const kind = kindOf(row);
if (kind === 'image') {
return;
}
if (kind === 'doc') {
window.open(row.url, '_blank');
return;
}
current.value = row;
showPreview.value = true;
};
/* 刷新列表 */
const query = () => {
emit('done');
};
watch(
() => props.data,
(visible) => {
if (visible) {
console.log(props.data);
} else {
}
}
);
</script>
<script lang="ts">
@@ -102,6 +153,58 @@
</script>
<style lang="less" scoped>
.media-cover {
position: relative;
display: flex;
align-items: center;
justify-content: center;
// 所有类型共用 4:3,混排时卡片高度才对得齐
aspect-ratio: 4 / 3;
overflow: hidden;
background: #f5f6f8;
cursor: pointer;
:deep(.ant-image) {
width: 100%;
height: 100%;
}
:deep(.ant-image-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.media-cover-icon {
font-size: 40px;
}
.media-cover-ext {
position: absolute;
right: 8px;
bottom: 8px;
padding: 0 6px;
font-size: 12px;
line-height: 18px;
color: #718096;
background: rgba(255, 255, 255, 0.9);
border-radius: 3px;
}
.media-cover-play {
position: absolute;
font-size: 44px;
color: rgba(255, 255, 255, 0.92);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.35));
}
.media-meta {
margin-bottom: 2px;
font-size: 12px;
color: #8c8c8c;
}
.project-list-desc {
height: 44px;
line-height: 22px;
+37 -27
View File
@@ -1,7 +1,7 @@
<template>
<div class="page">
<a-page-header :ghost="false" title="图片库">
<div class="ele-text-secondary"> 上传、分享有价值的图片素材 </div>
<a-page-header :ghost="false" title="素材库">
<div class="ele-text-secondary"> 上传、分享图片、文档与音视频素材 </div>
<template #footer>
<a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="1" tab="网格模式" />
@@ -10,16 +10,29 @@
<div style="margin-bottom: 10px">
<a-upload
:show-upload-list="false"
:accept="'image/*'"
:accept="MEDIA_ACCEPT"
:customRequest="onUpload"
>
<a-button key="1" type="primary" class="ele-btn-icon">
<template #icon>
<UploadOutlined />
</template>
<span>上传图片</span>
<span>上传素材</span>
</a-button>
</a-upload>
<a-select
v-model:value="category"
style="width: 120px; margin-left: 10px"
@change="onCategoryChange"
>
<a-select-option
v-for="item in MEDIA_CATEGORY_OPTIONS"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
<a-input-search
allow-clear
style="width: 360px; margin-left: 10px"
@@ -68,15 +81,22 @@
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { FileRecord, FileRecordParam } from '@/api/system/file/model';
import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
import Image from './image.vue';
import List from './list.vue';
import { messageLoading } from 'ele-admin-pro';
import { pageFiles, uploadFile } from '@/api/system/file';
import { message } from 'ant-design-vue/es';
import {
MEDIA_ACCEPT,
MEDIA_CATEGORY_OPTIONS,
validateMediaFile
} from '@/utils/media';
const type = ref('name');
const searchText = ref('');
// 媒体大类筛选,空串表示全部
const category = ref('');
const data = ref<FileRecord[]>([]);
// 当前选项卡
const activeKey = ref('1');
@@ -94,7 +114,6 @@
/* 查询数据 */
const query = () => {
console.log('query()');
if (type.value == 'name') {
where.name = searchText.value;
}
@@ -103,8 +122,8 @@
}
where.page = page.value;
where.limit = limit.value;
where.contentType = 'image';
console.log(where);
// 空串会被当成一个真实的筛选值,必须置空
where.mediaCategory = category.value || undefined;
const hide = messageLoading('请求中..', 0);
pageFiles(where).then((res) => {
count.value = Number(res?.count);
@@ -113,17 +132,20 @@
});
};
/* 切换类型回到第一页 */
const onCategoryChange = () => {
page.value = 1;
query();
};
query();
// 上传文件
const onUpload = (item) => {
const { file } = item;
if (!file.type.startsWith('image')) {
message.error('只能选择图片');
return;
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
const error = validateMediaFile(file);
if (error) {
message.error(error);
return;
}
const hide = messageLoading({
@@ -132,7 +154,7 @@
mask: true
});
uploadFile(file)
.then((data) => {
.then(() => {
hide();
message.success('上传成功');
query();
@@ -149,15 +171,3 @@
name: 'PhotoIndex'
};
</script>
<style lang="less" scoped>
.project-list-desc {
height: 44px;
line-height: 22px;
margin-bottom: 20px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>
+133 -31
View File
@@ -1,3 +1,4 @@
<!-- 素材库 · 列表视图 -->
<template>
<div class="page">
<a-card :bordered="false">
@@ -8,21 +9,21 @@
:columns="columns"
:datasource="datasource"
v-model:selection="selection"
:scroll="{ x: 800 }"
cache-key="proCmsVideoTable"
:scroll="{ x: 860 }"
cache-key="proCmsPhotoTable"
>
<template #toolbar>
<a-space>
<a-upload
:show-upload-list="false"
:accept="'image/*'"
:accept="MEDIA_ACCEPT"
:customRequest="onUpload"
>
<a-button type="primary" class="ele-btn-icon">
<template #icon>
<UploadOutlined />
</template>
<span>上传图片</span>
<span>上传素材</span>
</a-button>
</a-upload>
<a-button
@@ -37,6 +38,19 @@
</template>
<span>删除</span>
</a-button>
<a-select
v-model:value="category"
style="width: 120px"
@change="reload()"
>
<a-select-option
v-for="item in MEDIA_CATEGORY_OPTIONS"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
<a-input-search
allow-clear
v-model:value="searchText"
@@ -60,7 +74,23 @@
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'path'">
<a-image :src="record.url" :width="120" />
<!-- 与网格视图同一套 4:3 缩略图规则 -->
<div class="media-thumb" @click="openPreview(record)">
<a-image
v-if="kindOf(record) === 'image'"
:src="record.thumbnail || record.url"
:preview="{ src: record.url }"
:width="120"
/>
<template v-else>
<component
:is="iconOf(record)"
class="media-thumb-icon"
:style="{ color: colorOf(record) }"
/>
<span class="media-thumb-ext">{{ extOf(record) }}</span>
</template>
</div>
</template>
<template v-if="column.dataIndex === 'name'">
<span>{{ record.name }}</span>
@@ -78,7 +108,7 @@
<a-divider type="vertical" />
<a-popconfirm
placement="topRight"
title="确定要删除此评价吗?"
title="确定要删除此素材吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
@@ -90,6 +120,8 @@
</a-card>
<!-- 编辑弹窗 -->
<PhotoEdit v-model:visible="showEdit" :data="current" @done="reload" />
<!-- 音视频预览 -->
<MediaPreview v-model:visible="showPreview" :record="current" />
</div>
</template>
@@ -109,6 +141,7 @@
} from 'ele-admin-pro/es/ele-pro-table/types';
import { messageLoading, toDateString } from 'ele-admin-pro/es';
import PhotoEdit from './components/photo-edit.vue';
import MediaPreview from './components/media-preview.vue';
import {
pageFiles,
removeFile,
@@ -119,17 +152,33 @@
FileRecord,
FileRecordParam
} from '@/api/system/file/model/index';
import {
formatFileSize,
mediaColor,
mediaExtLabel,
mediaIcon,
mediaKindOfRecord,
MEDIA_ACCEPT,
MEDIA_CATEGORY_OPTIONS,
MEDIA_KIND_LABEL,
validateMediaFile,
type MediaKind
} from '@/utils/media';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<FileRecord[]>([]);
// 当前编辑数据
// 当前操作的数据(编辑 / 预览共用)
const current = ref<FileRecord | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示音视频预览
const showPreview = ref(false);
const type = ref('name');
const searchText = ref('');
// 媒体大类筛选,空串表示全部
const category = ref('');
// 表格列配置
const columns = ref<ColumnItem[]>([
{
@@ -141,11 +190,17 @@
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
},
{
title: '图片',
title: '缩略图',
dataIndex: 'path',
width: 180,
width: 140,
key: 'path'
},
{
title: '类型',
dataIndex: 'contentType',
width: 100,
customRender: ({ record }) => MEDIA_KIND_LABEL[kindOf(record)]
},
{
title: '文件名称',
dataIndex: 'name'
@@ -161,26 +216,16 @@
sorter: true,
showSorterTooltip: false,
ellipsis: true,
customRender: ({ text }) => {
if (text < 1024) {
return text + 'B';
} else if (text < 1024 * 1024) {
return (text / 1024).toFixed(1) + 'KB';
} else if (text < 1024 * 1024 * 1024) {
return (text / 1024 / 1024).toFixed(1) + 'M';
} else {
return (text / 1024 / 1024 / 1024).toFixed(1) + 'G';
}
},
customRender: ({ text }) => formatFileSize(text),
width: 120
},
{
title: '发布者',
title: '上传人',
width: 120,
dataIndex: 'createNickname'
},
{
title: '发布时间',
title: '上传时间',
dataIndex: 'createTime',
sorter: true,
width: 180,
@@ -196,6 +241,13 @@
}
]);
const kindOf = (record: FileRecord): MediaKind => mediaKindOfRecord(record);
const iconOf = (record: FileRecord) =>
mediaIcon(kindOf(record), record.path || record.name);
const colorOf = (record: FileRecord) =>
mediaColor(kindOf(record), record.path || record.name);
const extOf = (record: FileRecord) => mediaExtLabel(record);
// 表格数据源
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
where = {};
@@ -205,7 +257,8 @@
if (type.value == 'createNickname') {
where.createNickname = searchText.value;
}
where.contentType = 'image';
// 空串会被当成一个真实的筛选值,必须置空
where.mediaCategory = category.value || undefined;
return pageFiles({ ...where, ...orders, page, limit });
};
@@ -221,6 +274,20 @@
showEdit.value = true;
};
/* 点击缩略图:音视频开播放器,文档新窗口打开 */
const openPreview = (row: FileRecord) => {
const kind = kindOf(row);
if (kind === 'image') {
return;
}
if (kind === 'doc') {
window.open(row.url, '_blank');
return;
}
current.value = row;
showPreview.value = true;
};
/* 删除单个 */
const remove = (row: FileRecord) => {
const hide = messageLoading('请求中..', 0);
@@ -266,12 +333,9 @@
// 上传文件
const onUpload = (item) => {
const { file } = item;
if (!file.type.startsWith('image')) {
message.error('只能选择图片');
return;
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
const error = validateMediaFile(file);
if (error) {
message.error(error);
return;
}
const hide = messageLoading({
@@ -280,7 +344,7 @@
mask: true
});
uploadFile(file)
.then((data) => {
.then(() => {
hide();
message.success('上传成功');
reload();
@@ -294,6 +358,44 @@
<script lang="ts">
export default {
name: 'VideoIndex'
name: 'PhotoList'
};
</script>
<style lang="less" scoped>
.media-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 120px;
height: 90px;
overflow: hidden;
background: #f5f6f8;
border-radius: 4px;
cursor: pointer;
:deep(.ant-image),
:deep(.ant-image-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.media-thumb-icon {
font-size: 28px;
}
.media-thumb-ext {
position: absolute;
right: 4px;
bottom: 4px;
padding: 0 4px;
font-size: 11px;
line-height: 16px;
color: #718096;
background: rgba(255, 255, 255, 0.9);
border-radius: 3px;
}
</style>
+148
View File
@@ -0,0 +1,148 @@
<!-- 学分记录编辑弹窗 -->
<template>
<ele-modal
:width="620"
:visible="visible"
:maskClosable="false"
:confirm-loading="loading"
:title="isUpdate ? '编辑学分记录' : '新增学分记录'"
:body-style="{ paddingBottom: '24px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="学号" name="studentNo">
<a-input v-model:value="form.studentNo" allow-clear placeholder="请输入学号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="姓名" name="studentName">
<a-input v-model:value="form.studentName" allow-clear placeholder="请输入姓名" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="学分类别" name="category">
<a-input v-model:value="form.category" allow-clear placeholder="请输入学分类别" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="学分" name="score">
<a-input-number
v-model:value="form.score"
:min="0"
:step="0.5"
class="ele-fluid"
placeholder="请输入学分"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="项目名称" name="itemName">
<a-input v-model:value="form.itemName" allow-clear placeholder="请输入项目名称" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="获得时间" name="acquireTime">
<a-date-picker
v-model:value="acquireTime"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="请选择获得时间"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">有效</a-radio>
<a-radio :value="0">作废</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="form.remark" :rows="2" allow-clear placeholder="选填" />
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { assignObject } from 'ele-admin-pro';
import { addCreditRecord, updateCreditRecord } from '@/api/growth/credit';
import type { CreditRecord } from '@/api/growth/credit';
const props = defineProps<{
visible: boolean;
data?: CreditRecord | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const form = reactive<CreditRecord>({});
const acquireTime = computed({
get: () => (form.acquireTime ? dayjs(form.acquireTime) : undefined),
set: (value: any) => {
form.acquireTime = value ? value.format('YYYY-MM-DD') : undefined;
}
});
const rules = {
studentNo: [{ required: true, message: '请输入学号', trigger: 'blur' }],
category: [{ required: true, message: '请输入学分类别', trigger: 'blur' }],
itemName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
score: [{ required: true, message: '请输入学分', trigger: 'change' }]
};
watch(
() => props.visible,
(value) => {
if (value) {
isUpdate.value = !!props.data?.id;
Object.keys(form).forEach((key) => delete (form as any)[key]);
if (props.data) {
assignObject(form, props.data);
} else {
form.status = 1;
form.source = 'manual';
}
}
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
const msg = isUpdate.value
? await updateCreditRecord(form)
: await addCreditRecord(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
@@ -0,0 +1,89 @@
<template>
<common-import
v-model:visible="visible"
module-name="学分记录"
file-name="学分记录导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
:validate-item="validateItem"
import-description="请按模板填写学号、姓名、学分类别、项目名称、学分与获得时间。学号需能在系统中匹配到学生账号,重复记录的按(来源 + 外部编号)自动跳过。"
@done="onDone"
/>
</template>
<script lang="ts" setup>
import CommonImport from '@/views/gxmu/components/common-import.vue';
import { importCreditRecord } from '@/api/growth/credit';
import type { CreditRecord } from '@/api/growth/credit';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'done'): void;
}>();
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
});
const templateFields = [
{ header: '学号', key: 'studentNo', required: true },
{ header: '姓名', key: 'studentName', required: true },
{ header: '学分类别', key: 'category', required: true },
{ header: '项目名称', key: 'itemName', required: true },
{ header: '学分', key: 'score', required: true },
{ header: '获得时间', key: 'acquireTime', required: true },
{ header: '外部编号', key: 'externalId' }
];
const sampleRow = {
学号: '2021001001',
姓名: '张三',
学分类别: '思想政治与道德修养',
项目名称: '青年大学习第 1 期',
学分: '0.5',
获得时间: '2025-09-10',
外部编号: 'dekt-2025-0001'
};
const createPayload = (row: Record<string, any>): CreditRecord => ({
studentNo: String(row['学号'] || '').trim(),
studentName: String(row['姓名'] || '').trim(),
category: String(row['学分类别'] || '').trim(),
itemName: String(row['项目名称'] || '').trim(),
score: Number(row['学分'] || 0),
acquireTime: normalizeDate(row['获得时间']),
externalId: String(row['外部编号'] || '').trim() || undefined,
source: 'second_class',
status: 1
});
const normalizeDate = (value: any) => {
if (!value) return undefined;
if (typeof value === 'number') {
const date = new Date((value - 25569) * 86400 * 1000);
return date.toISOString().slice(0, 10);
}
return String(value).trim();
};
const validateItem = (item: CreditRecord, rowNo: number) => {
const errors: string[] = [];
if (!item.score || item.score <= 0) {
errors.push(`第 ${rowNo} 行学分必须大于 0`);
}
if (!item.acquireTime) {
errors.push(`第 ${rowNo} 行获得时间格式不正确`);
}
return errors;
};
const saveItem = async (item: CreditRecord) => {
return importCreditRecord(item);
};
const onDone = () => emit('done');
</script>
+312
View File
@@ -0,0 +1,312 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<div class="summary-bar">
<div class="summary-item">
<span class="summary-label">已修学分</span>
<span class="summary-value">{{ summary.totalEarned ?? 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">应修学分</span>
<span class="summary-value">{{ summary.totalRequired ?? 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">还差</span>
<span class="summary-value warning">{{ summary.totalGap ?? 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">完成率</span>
<span class="summary-value success">{{ summary.completionRate ?? 0 }}%</span>
</div>
</div>
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="category"
allow-clear
style="width: 160px"
placeholder="学分类别"
:options="categoryOptions"
@change="reload"
/>
<a-select
v-model:value="status"
allow-clear
style="width: 130px"
placeholder="状态"
:options="statusOptions"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="项目名称/姓名/学号"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
<a-button
v-if="hasPermission('gxmu:credit:import')"
class="ele-btn-icon"
@click="showImport = true"
>
<template #icon><upload-outlined /></template>
导入
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'score'">
<span class="score-text">{{ record.score }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '有效' : '作废' }}
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tag>{{ sourceLabel(record.source) }}</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a
v-if="hasPermission('gxmu:credit:update')"
@click="openEdit(record)"
>修改</a>
<a-divider type="vertical" />
<a-popconfirm
v-if="hasPermission('gxmu:credit:remove')"
title="确定要删除这条学分记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<CreditImport
v-model:visible="showImport"
@done="onImported"
/>
<CreditEdit
v-model:visible="showEdit"
:data="current"
@done="reload"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import CreditImport from './components/import.vue';
import CreditEdit from './components/edit.vue';
import {
pageCreditRecords,
removeCreditRecord,
getMyCreditSummary,
listGraduateRequirements
} from '@/api/growth/credit';
import type { CreditRecord, CreditSummary } from '@/api/growth/credit';
import { hasPermission } from '@/utils/permission';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const summary = ref<CreditSummary>({
userId: 0,
totalEarned: 0,
totalRequired: 0,
totalGap: 0,
completionRate: 0,
categories: []
});
const category = ref<string>();
const status = ref<number>();
const searchText = ref('');
const showImport = ref(false);
const showEdit = ref(false);
const current = ref<CreditRecord | null>(null);
const requirementCategories = ref<string[]>([]);
const categoryOptions = computed(() =>
requirementCategories.value.map((item) => ({ label: item, value: item }))
);
const statusOptions = [
{ label: '有效', value: 1 },
{ label: '作废', value: 0 }
];
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 110 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '学分类别', dataIndex: 'category', key: 'category', width: 150 },
{ title: '项目名称', dataIndex: 'itemName', key: 'itemName', ellipsis: true },
{ title: '学分', key: 'score', width: 90, align: 'center' },
{
title: '获得时间',
dataIndex: 'acquireTime',
key: 'acquireTime',
width: 120,
align: 'center'
},
{ title: '来源', key: 'source', width: 110, align: 'center' },
{ title: '状态', key: 'status', width: 90, align: 'center' },
{
title: '录入时间',
dataIndex: 'createTime',
key: 'createTime',
width: 160,
align: 'center',
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '操作',
key: 'action',
width: 130,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageCreditRecords({
...where,
...orders,
page,
limit,
backendAccess: true,
category: category.value,
status: status.value,
keywords: searchText.value || undefined
});
};
const reload = () => {
tableRef.value?.reload?.({ page: 1 });
};
const resetSearch = () => {
category.value = undefined;
status.value = undefined;
searchText.value = '';
reload();
};
const openEdit = (row: CreditRecord) => {
current.value = row;
showEdit.value = true;
};
const remove = async (row: CreditRecord) => {
if (!row.id) return;
try {
const msg = await removeCreditRecord(row.id);
message.success(msg);
reload();
await loadSummary();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const sourceLabel = (value?: string) => {
const map: Record<string, string> = {
import: '导入',
second_class: '第二课堂',
practice: '社会实践',
activity: '活动',
manual: '手工录入'
};
return map[value ?? ''] ?? value ?? '-';
};
const loadSummary = async () => {
try {
summary.value = await getMyCreditSummary();
} catch (e: any) {
// 汇总失败不阻塞列表
}
};
const loadCategories = async () => {
try {
const list = await listGraduateRequirements();
requirementCategories.value = list.map((item) => item.category).filter((v): v is string => !!v);
} catch (e: any) {
// 忽略
}
};
const onImported = () => {
reload();
loadSummary();
};
onMounted(() => {
loadSummary();
loadCategories();
});
</script>
<style lang="less" scoped>
.summary-bar {
display: flex;
gap: 40px;
flex-wrap: wrap;
padding: 4px 4px 18px;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.summary-label {
font-size: 13px;
color: #8c8c8c;
}
.summary-value {
font-size: 24px;
font-weight: 600;
color: #262626;
}
.summary-value.warning {
color: #faad14;
}
.summary-value.success {
color: #52c41a;
}
.score-text {
font-weight: 600;
color: #1677ff;
}
</style>
+278
View File
@@ -0,0 +1,278 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<div class="head-bar">
<div class="head-metrics">
<div class="metric">
<span class="metric-label">预警项</span>
<span class="metric-value">{{ summary.total ?? 0 }}</span>
</div>
<div class="metric">
<span class="metric-label">严重</span>
<span class="metric-value danger">{{ summary.danger ?? 0 }}</span>
</div>
<div class="metric">
<span class="metric-label">注意</span>
<span class="metric-value warning">{{ summary.warn ?? 0 }}</span>
</div>
<div class="metric">
<span class="metric-label">总缺口</span>
<span class="metric-value">{{ summary.totalGap ?? 0 }}</span>
</div>
</div>
<a-space>
<a-button :loading="loading" @click="recalc">重新计算</a-button>
<a-button type="primary" :disabled="!(summary.total > 0)" @click="notify">
发送预警提醒
</a-button>
</a-space>
</div>
<a-spin :spinning="loading">
<a-empty v-if="!warnings.length" description="当前没有学分缺口,继续保持" />
<div v-else class="warning-list">
<div
v-for="item in warnings"
:key="item.id"
class="warning-card"
:class="`is-${item.level}`"
>
<div class="warning-head">
<div class="warning-title">
<a-tag :color="levelColor(item.level)">{{ levelLabel(item.level) }}</a-tag>
<span class="category">{{ item.category }}</span>
</div>
<span class="gap-text">还差 {{ item.gap }} 分</span>
</div>
<a-progress
:percent="percentOf(item)"
:stroke-color="levelColor(item.level)"
:show-info="false"
/>
<div class="warning-foot">
<span>已修 {{ item.earnedScore }} / 应修 {{ item.requiredScore }}</span>
<span v-if="item.notifyMonth" class="notify-info">
已于 {{ item.notifyMonth }} 提醒
</span>
<span v-else class="notify-info">尚未提醒</span>
</div>
</div>
</div>
<a-card :bordered="false" class="suggest-card" title="补分建议">
<a-list :data-source="suggestions" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.title" :description="item.desc" />
<template #actions>
<a @click="openUrl(item.path)">去看看</a>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</a-spin>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
listCreditWarnings,
getCreditWarningSummary,
recalcMyWarnings,
notifyCreditWarning,
WARNING_LEVEL_OPTIONS
} from '@/api/growth/warning';
import type { CreditWarning } from '@/api/growth/warning';
const loading = ref(false);
const warnings = ref<CreditWarning[]>([]);
const summary = ref<Record<string, any>>({});
const suggestions = computed(() => {
const list = warnings.value.map((item) => ({
title: `补足「${item.category}」${item.gap} 分`,
desc: '可参加可认定该类别的志愿服务、社会实践或校院活动',
path: '/club/volunteerHour'
}));
if (!list.length) {
list.push({
title: '暂无缺口',
desc: '当前所有学分类别均已达成毕业要求',
path: '/growth/credit'
});
}
return list;
});
const loadData = async () => {
loading.value = true;
try {
const [list, s] = await Promise.all([
listCreditWarnings({ backendAccess: true }),
getCreditWarningSummary()
]);
warnings.value = list || [];
summary.value = s || {};
} catch (e: any) {
message.error(e?.message || '加载学分预警失败');
} finally {
loading.value = false;
}
};
const recalc = async () => {
loading.value = true;
try {
const msg = await recalcMyWarnings();
message.success(msg);
await loadData();
} catch (e: any) {
message.error(e?.message || '重算失败');
loading.value = false;
}
};
const notify = async () => {
try {
const msg = await notifyCreditWarning();
message.success(msg);
await loadData();
} catch (e: any) {
message.error(e?.message || '发送失败');
}
};
const levelLabel = (value?: string) =>
WARNING_LEVEL_OPTIONS.find((item) => item.value === value)?.label ?? '正常';
const levelColor = (value?: string) => {
if (value === 'danger') return '#ff4d4f';
if (value === 'warn') return '#faad14';
return '#52c41a';
};
const percentOf = (item: CreditWarning) => {
const required = Number(item.requiredScore ?? 0);
const earned = Number(item.earnedScore ?? 0);
if (required <= 0) return 100;
return Math.min(Math.round((earned / required) * 100), 100);
};
const openUrl = (path?: string) => {
if (path) {
window.location.hash = path;
}
};
onMounted(loadData);
</script>
<style lang="less" scoped>
.head-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
flex-wrap: wrap;
padding-bottom: 18px;
}
.head-metrics {
display: flex;
gap: 36px;
flex-wrap: wrap;
}
.metric {
display: flex;
flex-direction: column;
gap: 4px;
}
.metric-label {
font-size: 13px;
color: #8c8c8c;
}
.metric-value {
font-size: 24px;
font-weight: 600;
color: #262626;
}
.metric-value.danger {
color: #ff4d4f;
}
.metric-value.warning {
color: #faad14;
}
.warning-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.warning-card {
padding: 16px 18px;
border-radius: 10px;
border-left: 4px solid #52c41a;
background: #fafafa;
}
.warning-card.is-warn {
border-left-color: #faad14;
background: #fffbe6;
}
.warning-card.is-danger {
border-left-color: #ff4d4f;
background: #fff1f0;
}
.warning-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.warning-title {
display: flex;
align-items: center;
gap: 8px;
}
.category {
font-weight: 600;
color: #262626;
}
.gap-text {
font-weight: 600;
color: #ff4d4f;
}
.warning-foot {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 12px;
color: #8c8c8c;
}
.notify-info {
color: #bfbfbf;
}
.suggest-card {
margin-top: 16px;
}
</style>
+336
View File
@@ -0,0 +1,336 @@
<template>
<div class="growth-home">
<div class="page-hero">
<div class="hero-main">
<a-avatar :size="72" :src="basic?.avatar">
<template #icon><user-outlined /></template>
</a-avatar>
<div class="hero-text">
<h1>{{ basic?.name || '未命名同学' }}</h1>
<div class="hero-sub">
<span v-if="basic?.studentNo">学号 {{ basic.studentNo }}</span>
<span v-if="basic?.collegeName">{{ basic.collegeName }}</span>
<span v-if="basic?.className">{{ basic.className }}</span>
<span v-if="basic?.major">{{ basic.major }}</span>
</div>
<div class="hero-tags">
<a-tag v-if="basic?.politics" color="blue">{{ basic.politics }}</a-tag>
<a-tag v-if="basic?.leaguePosition" color="green">{{ basic.leaguePosition }}</a-tag>
<a-tag v-if="basic?.joinMonth" color="orange">{{ basic.joinMonth }} 入团</a-tag>
</div>
</div>
</div>
<div class="hero-side">
<div class="completion">
<div class="completion-label">档案完善度</div>
<a-progress
type="circle"
:percent="Number(basic?.profileCompletion ?? 0)"
:size="88"
:stroke-color="completionColor"
/>
</div>
</div>
</div>
<a-spin :spinning="loading">
<a-row :gutter="[16, 16]" class="metric-row">
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card">
<div class="metric-label">已修学分</div>
<div class="metric-value">{{ credit?.totalEarned ?? 0 }}</div>
<div class="metric-foot">
应修 {{ credit?.totalRequired ?? 0 }},还差 {{ credit?.totalGap ?? 0 }}
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card accent">
<div class="metric-label">志愿时长</div>
<div class="metric-value">{{ volunteerTotal }}</div>
<div class="metric-foot">已审核通过累计(小时)</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card warm">
<div class="metric-label">加入社团</div>
<div class="metric-value">{{ activity?.clubCount ?? 0 }}</div>
<div class="metric-foot">在社社团数量</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card muted">
<div class="metric-label">学分完成率</div>
<div class="metric-value">{{ credit?.completionRate ?? 0 }}%</div>
<div class="metric-foot">按毕业要求计算</div>
</div>
</a-col>
</a-row>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :xl="14">
<a-card :bordered="false" class="section-card" title="学分明细">
<a-empty v-if="!creditCategories.length" description="暂无学分记录" />
<div v-else class="credit-list">
<div v-for="item in creditCategories" :key="item.category" class="credit-item">
<div class="credit-head">
<span class="credit-name">{{ item.category }}</span>
<span class="credit-score">
{{ item.earned }} / {{ item.required }}
</span>
</div>
<a-progress
:percent="Math.min(Number(item.rate ?? 0), 100)"
:show-info="false"
:stroke-color="item.gap > 0 ? '#faad14' : '#52c41a'"
/>
<div class="credit-foot">
<span>共 {{ item.count }} 条记录</span>
<span v-if="item.gap > 0" class="credit-gap">还差 {{ item.gap }} 分</span>
<span v-else class="credit-done">已达成</span>
</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :xl="10">
<a-card :bordered="false" class="section-card" title="我的社团">
<a-empty v-if="!clubs.length" description="暂未加入社团" />
<a-list v-else :data-source="clubs" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.clubName" :description="item.roleName || '成员'" />
<template #actions>
<span class="club-join">{{ item.joinTime || '-' }}</span>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
<a-card
v-if="qmgc"
:bordered="false"
class="section-card qmgc-card"
title="青马工程培养"
>
<a-descriptions :column="1" size="small">
<a-descriptions-item label="期次">
{{ qmgcStudent?.batch || '-' }}
</a-descriptions-item>
<a-descriptions-item label="当前阶段">
{{ qmgcStudent?.stageName || qmgcStudent?.stage || '-' }}
</a-descriptions-item>
<a-descriptions-item label="累计得分">
{{ qmgcStudent?.totalScore ?? 0 }}
</a-descriptions-item>
<a-descriptions-item label="任务完成">
{{ completedTaskCount }} / {{ qmgcTasks.length }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</a-row>
</a-spin>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { UserOutlined } from '@ant-design/icons-vue';
import { getMyGrowthHome } from '@/api/growth/home';
import type { GrowthHome } from '@/api/growth/home';
const loading = ref(false);
const data = ref<GrowthHome | null>(null);
const basic = computed(() => data.value?.basic);
const credit = computed(() => data.value?.credit);
const activity = computed(() => data.value?.activity);
const qmgc = computed(() => data.value?.qmgc);
const qmgcStudent = computed(() => (qmgc.value?.student as Record<string, any>) || null);
const qmgcTasks = computed(
() => (qmgc.value?.tasks as Record<string, any>[]) || []
);
const completedTaskCount = computed(
() => qmgcTasks.value.filter((item) => item.assignStatus === 1).length
);
const creditCategories = computed(() => credit.value?.categories || []);
const clubs = computed(
() => (activity.value?.clubs as Record<string, any>[]) || []
);
const volunteerTotal = computed(() => {
const summary = data.value?.volunteer || {};
return summary.totalHours ?? 0;
});
const completionColor = computed(() => {
const value = Number(basic.value?.profileCompletion ?? 0);
if (value >= 90) return '#52c41a';
if (value >= 60) return '#1677ff';
return '#faad14';
});
const loadData = async () => {
loading.value = true;
try {
data.value = await getMyGrowthHome();
} catch (e: any) {
message.error(e?.message || '加载成长档案失败');
} finally {
loading.value = false;
}
};
onMounted(loadData);
</script>
<style lang="less" scoped>
.growth-home {
padding: 16px;
background: #f5f6f8;
min-height: calc(100vh - 120px);
}
.page-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 24px;
margin-bottom: 16px;
border-radius: 12px;
background: linear-gradient(120deg, #1677ff 0%, #4096ff 100%);
color: #fff;
}
.hero-main {
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.hero-text h1 {
margin: 0 0 6px;
font-size: 24px;
font-weight: 600;
color: #fff;
}
.hero-sub {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 13px;
opacity: 0.9;
}
.hero-tags {
margin-top: 10px;
}
.hero-side {
display: flex;
align-items: center;
}
.completion {
text-align: center;
}
.completion-label {
margin-bottom: 6px;
font-size: 12px;
opacity: 0.9;
}
.metric-row {
margin-bottom: 16px;
}
.metric-card {
padding: 18px 20px;
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.muted {
border-left-color: #722ed1;
}
.metric-label {
font-size: 13px;
color: #8c8c8c;
}
.metric-value {
margin: 6px 0 4px;
font-size: 26px;
font-weight: 600;
color: #262626;
}
.metric-foot {
font-size: 12px;
color: #bfbfbf;
}
.section-card {
border-radius: 10px;
height: 100%;
}
.credit-item + .credit-item {
margin-top: 18px;
}
.credit-head {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.credit-name {
font-weight: 500;
color: #262626;
}
.credit-score {
color: #1677ff;
font-weight: 500;
}
.credit-foot {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #8c8c8c;
}
.credit-gap {
color: #faad14;
}
.credit-done {
color: #52c41a;
}
.club-join {
font-size: 12px;
color: #bfbfbf;
}
.qmgc-card {
margin-top: 16px;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="学生画像" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">综合专业、年级、活动参与、兴趣标签构建多维画像,以简历式排版展示。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>学分/志愿/活动数据聚合</li>
<li>画像维度与标签定义</li>
<li>雷达图展示</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// 学生画像:P1 阶段交付,当前先占位避免菜单跳转 404
</script>
<style lang="less" scoped>
.placeholder-card {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-desc {
color: #595959;
line-height: 1.8;
}
.placeholder-sub {
margin-top: 4px;
font-weight: 600;
color: #262626;
}
.placeholder-list {
margin: 0;
padding-left: 20px;
color: #8c8c8c;
line-height: 1.9;
}
</style>
@@ -0,0 +1,155 @@
<!-- 社会实践学分申报 -->
<template>
<ele-modal
:width="680"
:visible="visible"
:maskClosable="false"
:confirm-loading="loading"
title="申报社会实践学分"
:body-style="{ paddingBottom: '24px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="提交后依次经班级、二级学院团委、校团委审核,全部通过后学分自动计入「社会实践与志愿服务」类别。"
/>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="学年" name="schoolYear">
<a-input v-model:value="form.schoolYear" allow-clear placeholder="如 2025-2026" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="活动类型" name="activityType">
<a-select
v-model:value="form.activityType"
:options="PRACTICE_TYPE_OPTIONS"
placeholder="请选择活动类型"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="实践活动名称" name="activityName">
<a-input v-model:value="form.activityName" allow-clear placeholder="请输入活动名称" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="开始日期" name="startDate">
<a-date-picker
v-model:value="startDate"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="请选择开始日期"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结束日期" name="endDate">
<a-date-picker
v-model:value="endDate"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="请选择结束日期"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="担任角色" name="roleName">
<a-input v-model:value="form.roleName" allow-clear placeholder="如 队长 / 队员" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="申请学分" name="score">
<a-input-number
v-model:value="form.score"
:min="0.5"
:step="0.5"
class="ele-fluid"
placeholder="请输入申请学分"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="实践内容与收获" name="content">
<a-textarea v-model:value="form.content" :rows="4" placeholder="简述实践内容与收获" />
</a-form-item>
<a-form-item label="证明材料" name="attachments" required>
<MultiFileUpload v-model="attachments" :max-count="5" />
<div class="ele-text-secondary" style="font-size: 12px">
支持图片或 PDF,建议上传实践单位盖章的证明
</div>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import { submitPracticeCredit, PRACTICE_TYPE_OPTIONS } from '@/api/growth/practice';
import type { PracticeCreditApply } from '@/api/growth/practice';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const formRef = ref();
const loading = ref(false);
const form = reactive<PracticeCreditApply>({});
const startDate = ref<any>();
const endDate = ref<any>();
const attachments = ref('');
const rules = {
schoolYear: [{ required: true, message: '请输入学年', trigger: 'blur' }],
activityName: [{ required: true, message: '请输入活动名称', trigger: 'blur' }],
startDate: [{ required: true, message: '请选择开始日期', trigger: 'change' }],
endDate: [{ required: true, message: '请选择结束日期', trigger: 'change' }],
score: [{ required: true, message: '请输入申请学分', trigger: 'change' }],
attachments: [{ required: true, message: '请上传证明材料', trigger: 'change' }]
};
watch(
() => props.visible,
(value) => {
if (!value) return;
Object.keys(form).forEach((key) => delete (form as any)[key]);
startDate.value = undefined;
endDate.value = undefined;
attachments.value = '';
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
form.startDate = startDate.value;
form.endDate = endDate.value;
form.attachments = attachments.value;
const msg = await submitPracticeCredit(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '提交失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
+375
View File
@@ -0,0 +1,375 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<div class="head-bar">
<div class="head-metrics">
<div class="metric">
<span class="metric-label">已通过</span>
<span class="metric-value">{{ summary.passed ?? 0 }}</span>
</div>
<div class="metric">
<span class="metric-label">待审核</span>
<span class="metric-value warning">{{ summary.pending ?? 0 }}</span>
</div>
<div class="metric">
<span class="metric-label">已获学分</span>
<span class="metric-value success">{{ summary.earnedScore ?? 0 }}</span>
</div>
</div>
<a-space>
<a-button type="primary" @click="openSubmit">
<template #icon><plus-outlined /></template>
申报社会实践
</a-button>
<a-button
v-if="canManage"
:loading="exporting"
@click="doExport"
>
<template #icon><download-outlined /></template>
导出教务处数据表
</a-button>
</a-space>
</div>
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1300 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-input
v-model:value="schoolYear"
allow-clear
style="width: 150px"
placeholder="学年,如 2025-2026"
@pressEnter="reload"
/>
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="审核状态"
:options="PRACTICE_STATUS_OPTIONS"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="活动名称/姓名/学号"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'score'">
<span class="score-text">{{ record.approvedScore ?? record.score ?? 0 }}</span>
</template>
<template v-else-if="column.key === 'attachments'">
<AttachmentList :value="record.attachments" :max-count="2" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">查看</a>
<template v-if="record.status === 1">
<a-divider type="vertical" />
<a @click="withdraw(record)">撤回</a>
</template>
<template v-if="canManage && record.status !== 2">
<a-divider type="vertical" />
<a-popconfirm
title="作废后该学生可重新申报,确定作废吗?"
@confirm="voidApply(record)"
>
<a class="ele-text-danger">作废</a>
</a-popconfirm>
</template>
<template v-if="record.status === 0 || record.status === 3">
<a-divider type="vertical" />
<a-popconfirm title="确定删除这条申报吗?" @confirm="remove(record)">
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</template>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<PracticeEdit v-model:visible="showSubmit" @done="onSubmitted" />
<a-modal
v-model:visible="showDetail"
title="社会实践申报详情"
:footer="null"
width="720px"
>
<a-descriptions v-if="current" :column="2" bordered size="small">
<a-descriptions-item label="姓名">{{ current.studentName }}</a-descriptions-item>
<a-descriptions-item label="学号">{{ current.studentNo }}</a-descriptions-item>
<a-descriptions-item label="学年">{{ current.schoolYear }}</a-descriptions-item>
<a-descriptions-item label="活动类型">{{ current.activityType || '-' }}</a-descriptions-item>
<a-descriptions-item label="活动名称" :span="2">{{ current.activityName }}</a-descriptions-item>
<a-descriptions-item label="实践时间" :span="2">
{{ current.startDate || '-' }} 至 {{ current.endDate || '-' }}
</a-descriptions-item>
<a-descriptions-item label="担任角色">{{ current.roleName || '-' }}</a-descriptions-item>
<a-descriptions-item label="申请学分">{{ current.score ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="核定学分">{{ current.approvedScore ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="审核状态">
<a-tag :color="statusColor(current.status)">{{ statusLabel(current.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="实践内容" :span="2">
{{ current.content || '-' }}
</a-descriptions-item>
<a-descriptions-item label="证明材料" :span="2">
<AttachmentList :value="current.attachments" />
</a-descriptions-item>
<a-descriptions-item v-if="current.remark" label="备注" :span="2">
{{ current.remark }}
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import PracticeEdit from './components/edit.vue';
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import {
pagePracticeCredits,
removePracticeCredit,
withdrawPracticeCredit,
voidPracticeCredit,
getMyPracticeSummary,
exportPracticeCredit,
PRACTICE_STATUS_OPTIONS
} from '@/api/growth/practice';
import type { PracticeCreditApply } from '@/api/growth/practice';
import { hasAnyRole } from '@/utils/permission';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const schoolYear = ref('');
const status = ref<number>();
const searchText = ref('');
const showSubmit = ref(false);
const showDetail = ref(false);
const exporting = ref(false);
const current = ref<PracticeCreditApply | null>(null);
const summary = ref<Record<string, any>>({});
const canManage = computed(() =>
hasAnyRole([
'superAdmin',
'admin',
'SchoolYouthLeagueCommittee',
'YouthLeagueCommitteeOfSecondaryColleges',
'YouthLeagueBranchesOfSecondaryColleges'
])
);
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '学年', dataIndex: 'schoolYear', key: 'schoolYear', width: 120 },
{ title: '实践活动', dataIndex: 'activityName', key: 'activityName', ellipsis: true },
{ title: '活动类型', dataIndex: 'activityType', key: 'activityType', width: 140 },
{
title: '起止日期',
key: 'dateRange',
width: 200,
customRender: ({ record }) =>
`${record.startDate || '-'} ~ ${record.endDate || '-'}`
},
{ title: '学分', key: 'score', width: 90, align: 'center' },
{ title: '材料', key: 'attachments', width: 120 },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{
title: '提交时间',
dataIndex: 'createTime',
key: 'createTime',
width: 160,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm')
},
{ title: '操作', key: 'action', width: 220, fixed: 'right', align: 'center', hideInSetting: true }
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pagePracticeCredits({
...where,
...orders,
page,
limit,
backendAccess: true,
schoolYear: schoolYear.value || undefined,
status: status.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
schoolYear.value = '';
status.value = undefined;
searchText.value = '';
reload();
};
const openSubmit = () => {
showSubmit.value = true;
};
const openDetail = (record: PracticeCreditApply) => {
current.value = record;
showDetail.value = true;
};
const withdraw = async (record: PracticeCreditApply) => {
if (!record.id) return;
try {
const msg = await withdrawPracticeCredit(record.id);
message.success(msg);
reload();
await loadSummary();
} catch (e: any) {
message.error(e?.message || '撤回失败');
}
};
const voidApply = async (record: PracticeCreditApply) => {
if (!record.id) return;
try {
const msg = await voidPracticeCredit(record.id, '管理员作废');
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '作废失败');
}
};
const remove = async (record: PracticeCreditApply) => {
if (!record.id) return;
try {
const msg = await removePracticeCredit(record.id);
message.success(msg);
reload();
await loadSummary();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const doExport = async () => {
exporting.value = true;
try {
const file = await exportPracticeCredit({
backendAccess: true,
schoolYear: schoolYear.value || undefined,
status: status.value,
keywords: searchText.value || undefined
});
if (file?.url) {
window.open(file.url, '_blank');
message.success('导出成功');
}
} catch (e: any) {
message.error(e?.message || '导出失败');
} finally {
exporting.value = false;
}
};
const loadSummary = async () => {
try {
summary.value = await getMyPracticeSummary();
} catch (e: any) {
summary.value = {};
}
};
const onSubmitted = () => {
reload();
loadSummary();
};
const statusLabel = (value?: number) =>
PRACTICE_STATUS_OPTIONS.find((item) => item.value === value)?.label ?? '-';
const statusColor = (value?: number) => {
if (value === 2) return 'green';
if (value === 3) return 'red';
if (value === 4) return 'default';
return 'orange';
};
onMounted(loadSummary);
</script>
<style lang="less" scoped>
.head-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
flex-wrap: wrap;
padding-bottom: 18px;
}
.head-metrics {
display: flex;
gap: 36px;
}
.metric {
display: flex;
flex-direction: column;
gap: 4px;
}
.metric-label {
font-size: 13px;
color: #8c8c8c;
}
.metric-value {
font-size: 24px;
font-weight: 600;
color: #1677ff;
}
.metric-value.warning {
color: #faad14;
}
.metric-value.success {
color: #52c41a;
}
.score-text {
font-weight: 600;
color: #13c2c2;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="推荐内容" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">发布思政文章与活动,按学生画像与学分缺口定向推送。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>内容库维护</li>
<li>规则匹配</li>
<li>投放记录</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// 推荐内容:P1 阶段交付,当前先占位避免菜单跳转 404
</script>
<style lang="less" scoped>
.placeholder-card {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-desc {
color: #595959;
line-height: 1.8;
}
.placeholder-sub {
margin-top: 4px;
font-weight: 600;
color: #262626;
}
.placeholder-list {
margin: 0;
padding-left: 20px;
color: #8c8c8c;
line-height: 1.9;
}
</style>
+2 -1
View File
@@ -99,6 +99,7 @@
:module="REVIEW_MODULE"
module-name="青马工程"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -363,4 +364,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE"
module-name="十佳青年岗位能手"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -386,4 +387,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE"
module-name="十佳团支部书记"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -376,4 +377,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -114,6 +114,7 @@
module="gxmu_tygl_form"
module-name="团员管理"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -510,4 +511,4 @@
gap: 8px 12px;
line-height: 1.5;
}
</style>
</style>
+2 -1
View File
@@ -84,6 +84,7 @@
:module="REVIEW_MODULE"
module-name="挑战杯"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -289,4 +290,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE"
module-name="五四红旗团委"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -444,4 +445,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE"
module-name="五四红旗团支部"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -478,4 +479,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -105,6 +105,7 @@
:module="REVIEW_MODULE"
module-name="未来学术之星"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -375,4 +376,4 @@
gap: 8px 12px;
line-height: 1.5;
}
</style>
</style>
+2 -1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE"
module-name="优秀共青团干部"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -409,4 +410,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -333,6 +333,7 @@
:module="REVIEW_MODULE"
module-name="优秀共青团员"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -867,4 +868,4 @@
grid-template-columns: 1fr;
}
}
</style>
</style>
@@ -0,0 +1,120 @@
<template>
<div class="attachment-list">
<a-empty
v-if="!files.length"
:description="emptyText"
class="attachment-empty"
/>
<div
v-for="(file, index) in files"
:key="`${file.url}-${index}`"
class="attachment-item"
>
<a-image
v-if="isImageFile(file.url)"
:src="file.url"
:width="96"
:height="96"
class="attachment-image"
/>
<div class="attachment-body">
<a
:href="file.url"
target="_blank"
rel="noreferrer"
class="attachment-name"
:title="file.name"
>
{{ file.name }}
</a>
<span class="attachment-tip">点击可预览</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import {
isImageFile,
parseAttachments,
type AttachmentFile
} from '@/views/gxmu/utils/attachment';
const props = withDefaults(
defineProps<{
// 原始附件字段值
value?: unknown;
// 已解析好的文件列表(优先使用)
files?: AttachmentFile[];
emptyText?: string;
}>(),
{
value: undefined,
files: undefined,
emptyText: '暂无材料'
}
);
const files = computed<AttachmentFile[]>(() => {
if (props.files) {
return props.files;
}
return parseAttachments(props.value);
});
</script>
<script lang="ts">
export default {
name: 'GxmuAttachmentList'
};
</script>
<style lang="less" scoped>
.attachment-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.attachment-item {
display: flex;
gap: 12px;
align-items: center;
padding: 8px 10px;
border: 1px solid #eef1f6;
border-radius: 8px;
background: #fbfcfe;
}
.attachment-image {
flex-shrink: 0;
overflow: hidden;
border-radius: 6px;
}
.attachment-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.attachment-name {
overflow: hidden;
max-width: 100%;
color: #1677ff;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-tip {
color: #999;
font-size: 12px;
}
.attachment-empty {
margin: 12px 0;
}
</style>
+70 -6
View File
@@ -26,6 +26,21 @@
</p>
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
</a-upload-dragger>
<a-alert
v-if="failures.length"
type="warning"
show-icon
:message="`${successCount} 条导入成功,${failures.length} 条被跳过`"
>
<template #description>
<div class="import-failures">
<div v-for="failure in failures" :key="failure.row">
第 {{ failure.row }} 行:{{ failure.reason }}
</div>
</div>
<a-button size="small" @click="exportFailures">下载失败清单</a-button>
</template>
</a-alert>
</a-space>
</a-spin>
</ele-modal>
@@ -63,6 +78,8 @@
}>();
const loading = ref(false);
const failures = ref<{ row: number; reason: string }[]>([]);
const successCount = ref(0);
const description = computed(
() =>
@@ -141,13 +158,17 @@
if (!rows.length) {
throw new Error('导入文件内容为空');
}
const data = rows
.map((row, index) => props.createPayload(row, index + 2))
.filter((item) =>
const entries = rows
.map((row, index) => ({
rowNo: index + 2,
item: props.createPayload(row, index + 2)
}))
.filter(({ item }) =>
props.templateFields.some((field) =>
!!String(item[field.key] ?? '').trim()
)
);
const data = entries.map((entry) => entry.item);
if (!data.length) {
throw new Error('未识别到有效数据');
}
@@ -155,10 +176,28 @@
if (errors.length) {
throw new Error(errors.slice(0, 5).join(';'));
}
for (const item of data) {
await props.saveItem(item);
failures.value = [];
successCount.value = 0;
for (const entry of entries) {
try {
await props.saveItem(entry.item);
successCount.value += 1;
} catch (e: any) {
failures.value.push({
row: entry.rowNo,
reason: e?.message || '保存失败'
});
}
}
message.success(`成功导入 ${data.length} 条${props.moduleName}数据`);
if (failures.value.length) {
// 被跳过的是「同一申报项目已有进行中/已通过/被驳回」的记录,保留在清单里供人工处理
message.warning(
`成功 ${successCount.value} 条,跳过 ${failures.value.length} 条`
);
emit('done');
return false;
}
message.success(`成功导入 ${successCount.value} 条${props.moduleName}数据`);
updateVisible(false);
emit('done');
} catch (e: any) {
@@ -169,7 +208,32 @@
return false;
};
const exportFailures = () => {
if (!failures.value.length) {
return;
}
const sheet = utils.aoa_to_sheet([
['行号', '跳过原因'],
...failures.value.map((item) => [item.row, item.reason])
]);
sheet['!cols'] = [{ wch: 10 }, { wch: 64 }];
writeFile(
{
SheetNames: ['失败清单'],
Sheets: { 失败清单: sheet }
},
`${props.moduleName}导入失败清单.xlsx`
);
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>
<style lang="less" scoped>
.import-failures {
max-height: 180px;
overflow: auto;
margin-bottom: 8px;
}
</style>
@@ -33,7 +33,7 @@
:color="item.status === 1 ? 'success' : item.status === 2 ? 'error' : 'default'"
style="margin-left: 8px"
>
{{ item.status === 1 ? '通过' : item.status === 2 ? '不通过' : '待审核' }}
{{ item.status === 1 ? '通过' : item.status === 2 ? '驳回' : '待审核' }}
</a-tag>
</div>
<div v-if="item.user?.realName" style="margin-bottom: 4px">
@@ -3,7 +3,7 @@
<a-tag color="green">通过</a-tag>
</template>
<template v-else-if="latest?.status === 2">
<a-tag color="red">不通过</a-tag>
<a-tag color="red">驳回</a-tag>
</template>
<template v-else>
<a-tag>待审核</a-tag>
@@ -13,13 +13,12 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { ReviewList } from '@/api/gxmu/reviewList/model';
import { getLatestReview } from '../utils/review';
const props = defineProps<{
reviewList?: ReviewList[];
}>();
const latest = computed(() => {
const list = props.reviewList ?? [];
return [...list].sort((a, b) => Number(b.id || 0) - Number(a.id || 0))[0];
});
// 与「能否编辑」共用同一套排序规则,避免两处结论不一致
const latest = computed(() => getLatestReview(props.reviewList));
</script>
@@ -149,6 +149,19 @@
</div>
</a-form-item>
</template>
<a-form-item
label="允许重复提交"
name="allowRepeat"
extra="关闭时,同一用户在同一申报项目下同时只能有一条待审核申报;开启后可配置最多同时几条"
>
<a-switch
:checked="form.allowRepeat === 1"
@change="(checked: boolean) => (form.allowRepeat = checked ? 1 : 0)"
/>
</a-form-item>
<a-form-item v-if="form.allowRepeat === 1" label="最多同时几条" name="maxPending">
<a-input-number :min="1" :max="20" class="ele-fluid" v-model:value="form.maxPending" />
</a-form-item>
<a-form-item label="开始时间" name="startTime">
<a-date-picker
allow-clear
@@ -182,6 +195,10 @@
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
import { DECLARE_MODULE_OPTIONS } from '../moduleOptions';
import {
TZBCY_PROJECT_GROUP_OPTIONS,
TZBCY_PROJECT_TYPE_OPTIONS
} from '../../TZBCYForm/options';
import {
normalizeDeclareRecord,
stringifyDeclareMultiValueField
@@ -244,6 +261,8 @@
publicProjectGroup: [''],
startTime: undefined,
endTime: undefined,
allowRepeat: 0,
maxPending: 1,
userId: undefined,
tenantId: undefined,
createTime: undefined,
@@ -273,29 +292,68 @@
const { resetFields } = useForm(form, rules);
const isTzbcyModule = computed(() => form.module === 'gxmu_tzbcy_form');
/**
* 挑战杯模块的默认配置
* 与挑战杯申报表共用 TZBCYForm/options 中的同一份常量,避免两处维护产生漂移
*/
type ChallengeCupConfigKey =
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup';
const CHALLENGE_CUP_CONFIG_KEYS: ChallengeCupConfigKey[] = [
'formProjectType',
'formProjectGroup',
'publicProjectType',
'publicProjectGroup'
];
const toOptionValues = (options: readonly { value: string }[]) =>
options.map((item) => item.value);
const getChallengeCupDefaultConfig = (
key: ChallengeCupConfigKey
): string[] => {
switch (key) {
case 'formProjectType':
case 'publicProjectType':
return toOptionValues(TZBCY_PROJECT_TYPE_OPTIONS);
default:
return toOptionValues(TZBCY_PROJECT_GROUP_OPTIONS);
}
};
// 列表是否为空(没有任意一个有效值,空行不算)
const isBlankConfigList = (list?: string[]) =>
!(list || []).some((item) => !!item?.trim());
// 仅填充空配置,已有的配置(含用户手动编辑过的内容)保持不变
const fillBlankConfigWithDefault = () => {
CHALLENGE_CUP_CONFIG_KEYS.forEach((key) => {
if (isBlankConfigList(form[key])) {
form[key] = getChallengeCupDefaultConfig(key);
}
});
};
// 选中挑战杯模块时套用默认配置
const applyChallengeCupPreset = () => {
if (isTzbcyModule.value) {
fillBlankConfigWithDefault();
}
};
const normalizeEditableList = (list?: string[]) => {
const values = (list || []).map((item) => item?.trim() || '').filter(Boolean);
return values.length ? values : [''];
};
const addConfigRow = (
key:
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup'
) => {
const addConfigRow = (key: ChallengeCupConfigKey) => {
form[key].push('');
};
const removeConfigRow = (
key:
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup',
index: number
) => {
const removeConfigRow = (key: ChallengeCupConfigKey, index: number) => {
form[key].splice(index, 1);
if (!form[key].length) {
form[key].push('');
@@ -418,6 +476,8 @@
);
yearValue.value = props.data.year ? String(props.data.year) : undefined;
isUpdate.value = true;
// 历史记录若未配置过挑战杯配置,补上默认值;已配置的记录保持原样
applyChallengeCupPreset();
} else {
Object.assign(form, createDefaultForm());
isUpdate.value = false;
@@ -435,14 +495,15 @@
watch(
() => form.module,
(value) => {
if (value !== 'gxmu_tzbcy_form') {
form.projectType = [];
form.projectGroup = [];
form.formProjectType = [''];
form.formProjectGroup = [''];
form.publicProjectType = [''];
form.publicProjectGroup = [''];
if (value === 'gxmu_tzbcy_form') {
// 选中挑战杯模块时预填项目申报表 / 公开展示信息表的默认配置
applyChallengeCupPreset();
return;
}
// 切到其他模块时不清空配置,避免用户手滑切换后已编辑的内容丢失
// (非挑战杯模块不会展示也不会提交这些字段)
form.projectType = [];
form.projectGroup = [];
}
);
</script>
@@ -471,4 +532,4 @@
gap: 8px;
margin-bottom: 8px;
}
</style>
</style>
+3 -2
View File
@@ -63,7 +63,7 @@
</template>
<template v-else-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">通过</a-tag>
<a-tag v-else-if="record.status === 2" color="red">不通过</a-tag>
<a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
<a-tag v-else>待审核</a-tag>
</template>
<template v-else-if="column.key === 'submitTime'">
@@ -92,6 +92,7 @@
:module="detailCurrent?.module"
:module-name="getDeclareModuleLabel(detailCurrent?.module)"
:pk="detailCurrent?.id"
@done="loadHistoryData"
/>
<ReviewFlowModal
v-model:visible="showReviewFlow"
@@ -499,4 +500,4 @@ export default {
line-height: 1.5;
}
}
</style>
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="专家库" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">维护评审专家信息,按研究方向与职称筛选。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>专家信息维护</li>
<li>研究方向字典</li>
<li>评审回避规则</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// 专家库:P1 阶段交付,当前先占位避免菜单跳转 404
</script>
<style lang="less" scoped>
.placeholder-card {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-desc {
color: #595959;
line-height: 1.8;
}
.placeholder-sub {
margin-top: 4px;
font-weight: 600;
color: #262626;
}
.placeholder-list {
margin: 0;
padding-left: 20px;
color: #8c8c8c;
line-height: 1.9;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="专家评审" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">未来学术之星、挑战杯与团委科研课题的线上专家评审、分配与打分汇总。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>专家任务分配</li>
<li>评分维度配置</li>
<li>评审结果汇总</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// 专家评审:P1 阶段交付,当前先占位避免菜单跳转 404
</script>
<style lang="less" scoped>
.placeholder-card {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-desc {
color: #595959;
line-height: 1.8;
}
.placeholder-sub {
margin-top: 4px;
font-weight: 600;
color: #262626;
}
.placeholder-list {
margin: 0;
padding-left: 20px;
color: #8c8c8c;
line-height: 1.9;
}
</style>
+274 -13
View File
@@ -152,6 +152,13 @@
</template>
<template #extra>
<a-space>
<a-button
type="primary"
:disabled="loading || !markdownContent"
@click="formatRichText"
>
一键排版
</a-button>
<a-button :disabled="loading || !markdownContent" @click="exportWord">导出 Word</a-button>
<a-button :disabled="!markdownContent" @click="copyGenerated">复制正文</a-button>
<a-button :disabled="!generatedTitle" @click="copyGeneratedTitle">复制标题</a-button>
@@ -171,17 +178,55 @@
/>
</a-form-item>
<a-form-item label="正文内容">
<div class="markdown-shell">
<div v-if="loading" class="markdown-streaming">
{{ markdownContent || 'AI 正在生成内容...' }}
</div>
<div
v-else-if="markdownContent"
class="markdown-body markdown-viewer"
v-html="renderedMarkdownHtml"
></div>
<div v-else class="markdown-empty">生成结果将显示在这里</div>
</div>
<a-tabs
v-model:activeKey="contentTab"
size="small"
class="content-tabs"
>
<a-tab-pane key="markdown" tab="Markdown 预览">
<div class="markdown-shell">
<div v-if="loading" class="markdown-streaming">
{{ markdownContent || 'AI 正在生成内容...' }}
</div>
<div
v-else-if="markdownContent"
class="markdown-body markdown-viewer"
v-html="renderedMarkdownHtml"
></div>
<div v-else class="markdown-empty">生成结果将显示在这里</div>
</div>
</a-tab-pane>
<a-tab-pane key="rich" tab="富文本排版">
<div class="rich-shell">
<div class="rich-bar">
<span class="rich-bar-tip">
一键排版按原文的标题层级、段落、列表、表格还原,可直接在编辑器里继续改
</span>
<a-button
size="small"
type="primary"
:disabled="saveDisabled"
@click="saveAsManuscript"
>
{{ savedUpToDate ? '已保存为稿件' : '保存为稿件' }}
</a-button>
</div>
<Toolbar
class="rich-toolbar"
:editor="editorRef"
:defaultConfig="toolbarConfig"
mode="default"
/>
<Editor
v-model="richContentHtml"
class="rich-editor"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleEditorCreated"
/>
</div>
</a-tab-pane>
</a-tabs>
</a-form-item>
</a-form>
</template>
@@ -198,10 +243,10 @@
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import highlight from '@bytemd/plugin-highlight-ssr';
import 'highlight.js/styles/default.css';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { Modal, message } from 'ant-design-vue';
import { messageLoading } from 'ele-admin-pro';
import { saveAs } from 'file-saver';
import { asBlob } from 'html-docx-js-typescript';
@@ -215,6 +260,16 @@
import type { FileRecord } from '@/api/system/file/model';
import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
import { markdownToHtml } from '@/utils/manuscript-content';
import { addManuscript } from '@/api/cms/manuscript';
import type { Manuscript } from '@/api/cms/manuscript/model';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import type {
IDomEditor,
IEditorConfig,
IToolbarConfig
} from '@wangeditor/editor';
import '@wangeditor/editor/dist/css/style.css';
interface FormState {
/** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */
@@ -248,6 +303,46 @@
highlight()
]);
const loading = ref(false);
/** 正文区域展示方式:默认看 Markdown 预览,一键排版后切到富文本 */
const contentTab = ref<'markdown' | 'rich'>('markdown');
/** 富文本编辑器里的 HTML(一键排版灌入,人工可继续改) */
const richContentHtml = ref('');
const savingManuscript = ref(false);
/** 上次保存时的正文文本快照:内容没变就不再让点「保存为稿件」,避免存出重复草稿 */
const savedSnapshot = ref('');
const editorRef = shallowRef<IDomEditor>();
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['group-video']
};
const editorConfig: Partial<IEditorConfig> = {
placeholder: '点上方「一键排版」,把生成结果按原文排版放进这里',
MENU_CONF: {
uploadImage: {
allowedFileTypes: ['image/*'],
maxFileSize: 2 * 1024 * 1024,
base64LimitSize: 0,
async customUpload(file: File, insertFn: (url: string) => void) {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片');
return;
}
try {
const result = await uploadFile(file);
insertFn(result.url || '');
} catch (e: any) {
message.error(e.message || '图片上传失败');
}
}
}
}
};
const handleEditorCreated = (editor: IDomEditor) => {
editorRef.value = editor;
};
const generatedTitle = ref('');
const generatedContent = ref('');
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
@@ -569,6 +664,10 @@
const clearGenerated = () => {
generatedTitle.value = '';
generatedContent.value = '';
// 排版结果是这份 markdown 的产物,正文换了就不能留着旧的
richContentHtml.value = '';
savedSnapshot.value = '';
contentTab.value = 'markdown';
};
const resetForm = () => {
@@ -627,6 +726,127 @@
form.number = 600;
};
/** 只留文本、丢掉标签与空白,用来判断两份富文本是不是同一份内容 */
const normalizeForCompare = (html: string) =>
String(html || '')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, '');
/** 编辑器里的内容是否已经存成稿件(按文本比,编辑器规整过标签属性也算同一份) */
const savedUpToDate = computed(
() =>
!!savedSnapshot.value &&
savedSnapshot.value === normalizeForCompare(richContentHtml.value)
);
/** 保存按钮的可用状态:没内容、正在存、这份已经存过,都不给点 */
const saveDisabled = computed(
() =>
savingManuscript.value ||
!hasRichText(richContentHtml.value) ||
savedUpToDate.value
);
/** 编辑器里的“空”:wangEditor 空内容是一段 `<p><br></p>`,不能只看字符串长度 */
const hasRichText = (html: string) => {
const text = String(html || '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, '')
.trim();
return !!text || /<img\b/i.test(String(html || ''));
};
/**
* 一键排版:把生成结果按原文的标题层级、段落、列表、表格转成 HTML,
* 灌进富文本编辑器。
* - 用 gfm 插件,和页面上 Markdown 预览同一套解析(表格不会漏成管道符);
* - 不把生成标题拼进正文:标题有独立字段,导出 Word 时也只在导出件里拼;
* - 不落库:保存是用户在编辑器里确认过的第二次动作(见 saveAsManuscript)。
*/
const formatRichText = () => {
if (loading.value) {
message.warning('正在生成中,暂不能排版');
return;
}
if (!markdownContent.value) {
message.warning('暂无可排版的正文内容');
return;
}
if (markdownContent.value === FAILED_TEXT) {
message.error('生成失败的内容不能排版,请重新生成');
return;
}
const nextHtml = markdownToHtml(markdownContent.value, [
gfm({ locale: zh_HansGfm })
]);
if (!nextHtml.trim()) {
message.warning('当前内容转换不出富文本,请检查正文');
return;
}
const apply = () => {
richContentHtml.value = nextHtml;
// 排版换了内容,之前那次保存的状态就作废
savedSnapshot.value = '';
contentTab.value = 'rich';
message.success('已按原文排版放进富文本编辑器,请检查后保存');
};
// 编辑器里有内容且不是同一份排版(用户改过),才需要确认覆盖。
// 比的是「文本」而不是 HTML:wangEditor 会把 setHtml 进来的标签属性、
// 空白重新规整一遍,按 HTML 比会把「原样再排一次」也判成要覆盖。
if (
hasRichText(richContentHtml.value) &&
normalizeForCompare(richContentHtml.value) !== normalizeForCompare(nextHtml)
) {
Modal.confirm({
title: '覆盖编辑器里的内容?',
content: '富文本编辑器里已有内容,重新排版会覆盖掉当前改动。',
okText: '覆盖',
cancelText: '取消',
onOk: apply
});
return;
}
apply();
};
/**
* 保存为稿件:入库一律写 html,和「编辑稿件」弹窗的口径一致。
* add 接口不回 id,所以这里只负责“存成一条新稿件”,不冒充“再编辑同一篇”:
* 存过之后按钮置灰,改过正文才会重新可点(避免连点存出多条草稿)。
*/
const saveAsManuscript = async () => {
const title = (generatedTitle.value || form.theme).trim();
if (!title) {
message.warning('请先填写生成标题,稿件需要标题');
return;
}
if (!hasRichText(richContentHtml.value)) {
message.warning('富文本里还没有内容,请先点「一键排版」');
return;
}
if (savedUpToDate.value) {
message.info('这份排版已经保存过了,改完再点可以另存一条');
return;
}
savingManuscript.value = true;
try {
const msg = await addManuscript({
title,
content: richContentHtml.value,
contentFormat: 'html',
// 后端 status 是 Integer(列表里 0 待审核 / 1 显示),前端类型写成 string 是历史遗留
status: 1 as unknown as Manuscript['status'],
sortNumber: 0
});
savedSnapshot.value = normalizeForCompare(richContentHtml.value);
message.success(msg || '已保存为稿件');
} catch (e: any) {
message.error(e.message || '保存稿件失败');
} finally {
savingManuscript.value = false;
}
};
const removeUploadedFile = (index: number) => {
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
};
@@ -852,6 +1072,7 @@
onBeforeUnmount(() => {
clearStreamResolver();
editorRef.value?.destroy();
});
</script>
@@ -1047,6 +1268,46 @@
color: #1f2d3d;
}
.content-tabs :deep(.ant-tabs-nav) {
margin-bottom: 10px;
}
.rich-shell {
border: 1px solid #c7e8ff;
border-radius: 16px;
overflow: hidden;
background: #fffdfb;
}
.rich-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #eef4f9;
background: #f5fbff;
}
.rich-bar-tip {
color: #5f7f9f;
font-size: 12px;
line-height: 1.6;
}
.rich-toolbar {
border-bottom: 1px solid #f0f0f0;
}
.rich-editor {
min-height: 360px;
}
.rich-editor :deep(.w-e-text-container) {
min-height: 360px;
}
.full-width {
width: 100%;
}
+224
View File
@@ -0,0 +1,224 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1000 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="姓名/学号/学员编号"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'totalScore'">
<span class="score-text">{{ record.totalScore ?? 0 }}</span>
</template>
<template v-else-if="column.key === 'action'">
<a @click="openScore(record)">评分</a>
</template>
</template>
</ele-pro-table>
</a-card>
<a-drawer
v-model:visible="showDetail"
:title="`${current?.studentName || ''} · 考核评分`"
width="820"
>
<a-space style="margin-bottom: 16px">
<a-button type="primary" @click="openAddScore">新增评分</a-button>
<a-tag color="blue">累计 {{ current?.totalScore ?? 0 }} 分</a-tag>
</a-space>
<a-table
row-key="id"
size="small"
:data-source="scores"
:columns="scoreColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a @click="editScore(record)">编辑</a>
</template>
</template>
</a-table>
</a-drawer>
<a-modal
v-model:visible="showScoreModal"
title="录入考核评分"
:confirm-loading="submitting"
width="520px"
@ok="saveScore"
>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="评分维度" required>
<a-select
v-model:value="form.dimension"
:options="dimensionOptions"
placeholder="请选择维度"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="评分项">
<a-input v-model:value="form.item" allow-clear placeholder="如 理论学习" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="得分" required>
<a-input-number v-model:value="form.score" :min="0" class="ele-fluid" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="满分">
<a-input-number v-model:value="form.maxScore" :min="0" class="ele-fluid" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="评语">
<a-textarea v-model:value="form.comment" :rows="3" />
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import {
pageQmgcStudents,
listQmgcScores,
saveQmgcScore
} from '@/api/growth/qmgc';
import type { QmgcStudent, QmgcScore } from '@/api/growth/qmgc';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showDetail = ref(false);
const showScoreModal = ref(false);
const submitting = ref(false);
const current = ref<QmgcStudent | null>(null);
const scores = ref<QmgcScore[]>([]);
const form = ref<Partial<QmgcScore>>({});
const dimensionOptions = [
{ label: '理论学习', value: '理论学习' },
{ label: '实践锻炼', value: '实践锻炼' },
{ label: '日常表现', value: '日常表现' },
{ label: '考核考试', value: '考核考试' }
];
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 110 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '期次', dataIndex: 'batch', key: 'batch', width: 160 },
{ title: '阶段', dataIndex: 'stage', key: 'stage', width: 100 },
{ title: '累计得分', key: 'totalScore', width: 110, align: 'center' },
{ title: '操作', key: 'action', width: 90, fixed: 'right', align: 'center', hideInSetting: true }
]);
const scoreColumns: ColumnItem[] = [
{ title: '维度', dataIndex: 'dimension', key: 'dimension', width: 130 },
{ title: '评分项', dataIndex: 'item', key: 'item', width: 160 },
{ title: '得分', dataIndex: 'score', key: 'score', width: 90 },
{ title: '满分', dataIndex: 'maxScore', key: 'maxScore', width: 90 },
{ title: '评语', dataIndex: 'comment', key: 'comment', ellipsis: true },
{ title: '操作', key: 'action', width: 80 }
];
const datasource = ({ page, limit, where, orders }: any) => {
return pageQmgcStudents({
...where,
...orders,
page,
limit,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
searchText.value = '';
reload();
};
const openScore = async (record: QmgcStudent) => {
current.value = record;
showDetail.value = true;
await loadScores();
};
const loadScores = async () => {
if (!current.value?.id) return;
try {
scores.value = await listQmgcScores(current.value.id);
} catch (e: any) {
scores.value = [];
}
};
const openAddScore = () => {
form.value = { maxScore: 100 };
showScoreModal.value = true;
};
const editScore = (row: QmgcScore) => {
form.value = { ...row };
showScoreModal.value = true;
};
const saveScore = async () => {
if (!current.value?.id) return;
if (!form.value.dimension) {
message.warning('请选择评分维度');
return;
}
submitting.value = true;
try {
await saveQmgcScore({
...form.value,
studentId: current.value.id
} as QmgcScore);
message.success('保存成功');
showScoreModal.value = false;
await loadScores();
reload();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
submitting.value = false;
}
};
</script>
<style lang="less" scoped>
.score-text {
font-weight: 600;
color: #1677ff;
}
</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>
@@ -0,0 +1,163 @@
<!-- 青马学员编辑 -->
<template>
<ele-modal
:width="680"
:visible="visible"
:maskClosable="false"
:confirm-loading="loading"
:title="isUpdate ? '编辑青马学员' : '新增青马学员'"
:body-style="{ paddingBottom: '24px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="姓名" name="studentName">
<a-input v-model:value="form.studentName" allow-clear placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="学号" name="studentNo">
<a-input v-model:value="form.studentNo" allow-clear placeholder="请输入学号" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="学院" name="collegeName">
<a-input v-model:value="form.collegeName" allow-clear placeholder="请输入学院" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="班级" name="className">
<a-input v-model:value="form.className" allow-clear placeholder="请输入班级" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="期次" name="batch">
<a-input v-model:value="form.batch" allow-clear placeholder="如 2026 年第 1 期" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="年度" name="year">
<a-input-number v-model:value="form.year" :min="2000" :max="2100" class="ele-fluid" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="学员编号" name="candidateNo">
<a-input v-model:value="form.candidateNo" allow-clear placeholder="请输入学员编号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="当前阶段" name="stage">
<a-select v-model:value="form.stage" :options="stageOptions" placeholder="请选择阶段" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="培育结果" name="result">
<a-select v-model:value="form.result" allow-clear :options="resultOptions" placeholder="请选择" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">在培</a-radio>
<a-radio :value="2">结业</a-radio>
<a-radio :value="0">退出</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="form.remark" :rows="2" allow-clear placeholder="选填" />
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject } from 'ele-admin-pro';
import { addQmgcStudent, updateQmgcStudent } from '@/api/growth/qmgc';
import type { QmgcStudent } from '@/api/growth/qmgc';
const props = defineProps<{
visible: boolean;
data?: QmgcStudent | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const form = reactive<QmgcStudent>({});
const stageOptions = [
{ label: '选拔', value: 'selection' },
{ label: '学习', value: 'study' },
{ label: '实践', value: 'practice' },
{ label: '考核', value: 'exam' },
{ label: '结业', value: 'graduated' }
];
const resultOptions = [
{ label: '在培', value: 'ongoing' },
{ label: '通过', value: 'passed' },
{ label: '未通过', value: 'failed' },
{ label: '退出', value: 'quit' }
];
const rules = {
studentName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
studentNo: [{ required: true, message: '请输入学号', trigger: 'blur' }]
};
watch(
() => props.visible,
(value) => {
if (!value) return;
isUpdate.value = !!props.data?.id;
Object.keys(form).forEach((key) => delete (form as any)[key]);
if (props.data) {
assignObject(form, props.data);
} else {
form.status = 1;
form.stage = 'selection';
form.result = 'ongoing';
}
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
const msg = isUpdate.value
? await updateQmgcStudent(form)
: await addQmgcStudent(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
+476
View File
@@ -0,0 +1,476 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1300 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-button
v-if="hasPermission('gxmu:qmgcStudent:save')"
type="primary"
@click="openEdit()"
>
<template #icon><plus-outlined /></template>
新增学员
</a-button>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="姓名/学号/学员编号"
@search="reload"
@pressEnter="reload"
/>
<a-input
v-model:value="batch"
allow-clear
style="width: 180px"
placeholder="期次"
@pressEnter="reload"
/>
<a-select
v-model:value="stage"
allow-clear
style="width: 150px"
placeholder="阶段"
:options="stageOptions"
@change="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'stage'">
<a-tag color="blue">{{ stageLabel(record.stage) }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : record.status === 2 ? 'blue' : 'default'">
{{ record.status === 1 ? '在培' : record.status === 2 ? '结业' : '退出' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">档案</a>
<a-divider type="vertical" />
<a @click="openAnalysis(record)">AI 分析</a>
<a-divider type="vertical" />
<a v-if="hasPermission('gxmu:qmgcStudent:update')" @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
v-if="hasPermission('gxmu:qmgcStudent:remove')"
title="确定要删除这个学员档案吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<QmgcStudentEdit v-model:visible="showEdit" :data="current" @done="reload" />
<AiAnalysis
v-model:visible="showAnalysis"
:student="analysisStudent"
:detail="analysisDetail"
/>
<a-drawer v-model:visible="showDetail" title="青马培养档案" width="880">
<template v-if="detail">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名">{{ student?.studentName }}</a-descriptions-item>
<a-descriptions-item label="学号">{{ student?.studentNo }}</a-descriptions-item>
<a-descriptions-item label="期次">{{ student?.batch || '-' }}</a-descriptions-item>
<a-descriptions-item label="学员编号">{{ student?.candidateNo || '-' }}</a-descriptions-item>
<a-descriptions-item label="当前阶段">{{ stageLabel(student?.stage) }}</a-descriptions-item>
<a-descriptions-item label="累计得分">{{ student?.totalScore ?? 0 }}</a-descriptions-item>
</a-descriptions>
<div class="detail-ai-entry">
<span class="detail-ai-hint">
结合下面的培养材料、任务进度与考核评分,让 AI
分析该学员的思想动态与学习难点
</span>
<a-button
type="primary"
ghost
size="small"
@click="openAnalysis(current)"
>
AI 学情分析
</a-button>
</div>
<a-tabs style="margin-top: 16px">
<a-tab-pane key="material" tab="培养材料">
<a-space style="margin-bottom: 12px">
<a-button size="small" type="primary" @click="showMaterial = true">
提交材料
</a-button>
</a-space>
<a-empty v-if="!materials.length" description="暂无材料" />
<a-list v-else :data-source="materials" item-layout="vertical">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.title" :description="item.scoreComment || item.stage" />
<template #actions>
<a-tag :color="item.status === 1 ? 'green' : 'orange'">
{{ item.status === 1 ? `已评分 ${item.score ?? 0}` : '待评分' }}
</a-tag>
<a @click="openScore(item)">评分</a>
</template>
</a-list-item>
</template>
</a-list>
</a-tab-pane>
<a-tab-pane key="task" tab="任务进度">
<a-empty v-if="!tasks.length" description="暂无任务" />
<a-table
v-else
row-key="taskId"
size="small"
:data-source="tasks"
:columns="taskColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.assignStatus === 1 ? 'green' : 'orange'">
{{ record.assignStatus === 1 ? '已完成' : '未完成' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a v-if="record.assignStatus !== 1" @click="completeTask(record)">标记完成</a>
<span v-else>-</span>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="score" tab="考核评分">
<a-table
row-key="id"
size="small"
:data-source="scores"
:columns="scoreColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a @click="openScore(record)">编辑</a>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</template>
</a-drawer>
<a-modal
v-model:visible="showMaterial"
title="提交培养材料"
:confirm-loading="submitting"
width="560px"
@ok="submitMaterial"
>
<a-form layout="vertical">
<a-form-item label="材料标题" required>
<a-input v-model:value="materialForm.title" allow-clear placeholder="请输入标题" />
</a-form-item>
<a-form-item label="阶段">
<a-select
v-model:value="materialForm.stage"
allow-clear
:options="stageOptions"
placeholder="请选择阶段"
/>
</a-form-item>
<a-form-item label="内容">
<a-textarea v-model:value="materialForm.content" :rows="4" placeholder="请输入材料内容" />
</a-form-item>
<a-form-item label="附件">
<MultiFileUpload v-model="materialFile" :max-count="3" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:visible="showScoreModal"
title="评分"
:confirm-loading="submitting"
width="480px"
@ok="submitScore"
>
<a-form layout="vertical">
<a-form-item label="得分" required>
<a-input-number v-model:value="scoreValue" :min="0" :max="100" class="ele-fluid" />
</a-form-item>
<a-form-item label="评语">
<a-textarea v-model:value="scoreComment" :rows="3" />
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import QmgcStudentEdit from './components/edit.vue';
import AiAnalysis from './components/aiAnalysis.vue';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import {
pageQmgcStudents,
removeQmgcStudent,
getQmgcStudentDetail,
submitQmgcMaterial,
scoreQmgcMaterial,
completeQmgcTask
} from '@/api/growth/qmgc';
import type { QmgcStudent, QmgcMaterial, QmgcScore } from '@/api/growth/qmgc';
import { hasPermission } from '@/utils/permission';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const batch = ref('');
const stage = ref<string>();
const showEdit = ref(false);
const showDetail = ref(false);
const showAnalysis = ref(false);
const showMaterial = ref(false);
const showScoreModal = ref(false);
const current = ref<QmgcStudent | null>(null);
const detail = ref<Record<string, any> | null>(null);
const analysisStudent = ref<QmgcStudent | null>(null);
const analysisDetail = ref<Record<string, any> | null>(null);
const materialForm = ref<Partial<QmgcMaterial>>({});
const materialFile = ref('');
const submitting = ref(false);
const scoreValue = ref(0);
const scoreComment = ref('');
const scoreTargetId = ref<number>();
const stageOptions = [
{ label: '选拔', value: 'selection' },
{ label: '学习', value: 'study' },
{ label: '实践', value: 'practice' },
{ label: '考核', value: 'exam' },
{ label: '结业', value: 'graduated' }
];
const student = computed(() => detail.value?.student || null);
const materials = computed<QmgcMaterial[]>(() => detail.value?.materials || []);
const tasks = computed(() => detail.value?.tasks || []);
const scores = computed<QmgcScore[]>(() => detail.value?.scores || []);
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 110 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '学院', dataIndex: 'collegeName', key: 'collegeName', width: 160 },
{ title: '期次', dataIndex: 'batch', key: 'batch', width: 160 },
{ title: '年度', dataIndex: 'year', key: 'year', width: 90, align: 'center' },
{ title: '阶段', key: 'stage', width: 100, align: 'center' },
{ title: '累计得分', dataIndex: 'totalScore', key: 'totalScore', width: 100, align: 'center' },
{ title: '状态', key: 'status', width: 90, align: 'center' },
{ title: '操作', key: 'action', width: 230, fixed: 'right', align: 'center', hideInSetting: true }
]);
const taskColumns: ColumnItem[] = [
{ title: '任务', key: 'title', ellipsis: true },
{ title: '类型', key: 'type', width: 90 },
{ title: '截止', key: 'deadline', width: 170 },
{ title: '状态', key: 'status', width: 90 },
{ title: '操作', key: 'action', width: 100 }
];
const scoreColumns: ColumnItem[] = [
{ title: '维度', dataIndex: 'dimension', key: 'dimension', width: 140 },
{ title: '评分项', dataIndex: 'item', key: 'item', width: 180 },
{ title: '得分', dataIndex: 'score', key: 'score', width: 90 },
{ title: '评语', dataIndex: 'comment', key: 'comment', ellipsis: true },
{ title: '操作', key: 'action', width: 80 }
];
const datasource = ({ page, limit, where, orders }: any) => {
return pageQmgcStudents({
...where,
...orders,
page,
limit,
batch: batch.value || undefined,
stage: stage.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
searchText.value = '';
batch.value = '';
stage.value = undefined;
reload();
};
const openEdit = (row?: QmgcStudent) => {
current.value = row || null;
showEdit.value = true;
};
const openDetail = async (row: QmgcStudent) => {
current.value = row;
if (!row.id) return;
try {
detail.value = await getQmgcStudentDetail(row.id);
showDetail.value = true;
} catch (e: any) {
message.error(e?.message || '加载档案失败');
}
};
/**
* 打开 AI 学情分析。
*
* 从列表进入时档案详情还没加载,这里按需取一次,保证 AI 能看到
* 培养材料、任务和评分;已加载过(从档案抽屉进入)则直接复用。
*/
const openAnalysis = async (row?: QmgcStudent | null) => {
if (!row?.id) {
message.warning('请先选择学员');
return;
}
if (!detail.value || current.value?.id !== row.id) {
try {
detail.value = await getQmgcStudentDetail(row.id);
} catch (e: any) {
message.error(e?.message || '加载学情数据失败');
return;
}
}
current.value = row;
analysisStudent.value = row;
analysisDetail.value = detail.value;
showAnalysis.value = true;
};
const remove = async (row: QmgcStudent) => {
if (!row.id) return;
try {
const msg = await removeQmgcStudent(row.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const reloadDetail = async () => {
if (!current.value?.id) return;
detail.value = await getQmgcStudentDetail(current.value.id);
};
const submitMaterial = async () => {
if (!materialForm.value.title?.trim()) {
message.warning('请填写材料标题');
return;
}
submitting.value = true;
try {
await submitQmgcMaterial({
studentId: current.value?.id,
title: materialForm.value.title,
stage: materialForm.value.stage,
content: materialForm.value.content,
fileUrl: materialFile.value || undefined
});
message.success('提交成功');
showMaterial.value = false;
materialForm.value = {};
materialFile.value = '';
await reloadDetail();
} catch (e: any) {
message.error(e?.message || '提交失败');
} finally {
submitting.value = false;
}
};
const openScore = (item: any) => {
scoreTargetId.value = item.id;
scoreValue.value = item.score ?? 0;
scoreComment.value = item.scoreComment || item.comment || '';
showScoreModal.value = true;
};
const submitScore = async () => {
if (!scoreTargetId.value) return;
submitting.value = true;
try {
await scoreQmgcMaterial(scoreTargetId.value, Number(scoreValue.value), scoreComment.value);
message.success('评分成功');
showScoreModal.value = false;
await reloadDetail();
} catch (e: any) {
message.error(e?.message || '评分失败');
} finally {
submitting.value = false;
}
};
const completeTask = async (record: any) => {
if (!current.value?.id) return;
try {
const msg = await completeQmgcTask(current.value.id, record.task?.id);
message.success(msg);
await reloadDetail();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const stageLabel = (value?: string) => {
const map: Record<string, string> = {
selection: '选拔',
study: '学习',
practice: '实践',
exam: '考核',
graduated: '结业'
};
return map[value ?? ''] ?? value ?? '-';
};
</script>
<style scoped lang="less">
.detail-ai-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 12px;
padding: 10px 14px;
border: 1px dashed #d6e4f5;
border-radius: 8px;
background: #f7fbff;
}
.detail-ai-hint {
color: #8c8c8c;
font-size: 12px;
line-height: 1.6;
}
</style>
+256
View File
@@ -0,0 +1,256 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1100 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" @click="openEdit()">
<template #icon><plus-outlined /></template>
新增任务
</a-button>
<a-input
v-model:value="batch"
allow-clear
style="width: 180px"
placeholder="期次"
@pressEnter="reload"
/>
<a-select
v-model:value="type"
allow-clear
style="width: 140px"
placeholder="任务类型"
:options="typeOptions"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 220px"
placeholder="任务标题"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="record.type === 'online' ? 'blue' : 'purple'">
{{ record.type === 'online' ? '线上' : '线下' }}
</a-tag>
</template>
<template v-else-if="column.key === 'mustDo'">
<a-tag :color="record.mustDo === 1 ? 'red' : 'default'">
{{ record.mustDo === 1 ? '必修' : '选修' }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '启用' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm title="确定删除这个任务吗?" @confirm="remove(record)">
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<a-modal
v-model:visible="showEdit"
:title="current?.id ? '编辑任务' : '新增任务'"
:confirm-loading="submitting"
width="600px"
@ok="save"
>
<a-form layout="vertical">
<a-form-item label="任务标题" required>
<a-input v-model:value="form.title" allow-clear placeholder="请输入任务标题" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="适用期次">
<a-input v-model:value="form.batch" allow-clear placeholder="空表示全部" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="阶段">
<a-select v-model:value="form.stage" allow-clear :options="stageOptions" placeholder="请选择" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="任务类型">
<a-radio-group v-model:value="form.type">
<a-radio value="online">线上</a-radio>
<a-radio value="offline">线下</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="是否必修">
<a-switch :checked="form.mustDo === 1" @change="(v) => (form.mustDo = v ? 1 : 0)" />
</a-form-item>
</a-col>
</a-row>
<a-form-item v-if="form.type === 'online'" label="跳转地址">
<a-input v-model:value="form.linkPath" allow-clear placeholder="如 /growth/credit" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="完成可得学分">
<a-input-number v-model:value="form.score" :min="0" class="ele-fluid" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="截止时间">
<a-date-picker
v-model:value="deadline"
show-time
value-format="YYYY-MM-DD HH:mm:ss"
class="ele-fluid"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="任务说明">
<a-textarea v-model:value="form.content" :rows="3" />
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import {
pageQmgcTasks,
addQmgcTask,
updateQmgcTask,
removeQmgcTask
} from '@/api/growth/qmgc';
import type { QmgcTask } from '@/api/growth/qmgc';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const batch = ref('');
const type = ref<string>();
const searchText = ref('');
const showEdit = ref(false);
const submitting = ref(false);
const current = ref<QmgcTask | null>(null);
const form = ref<Partial<QmgcTask>>({});
const deadline = ref<any>();
const typeOptions = [
{ label: '线上', value: 'online' },
{ label: '线下', value: 'offline' }
];
const stageOptions = [
{ label: '选拔', value: 'selection' },
{ label: '学习', value: 'study' },
{ label: '实践', value: 'practice' },
{ label: '考核', value: 'exam' },
{ label: '结业', value: 'graduated' }
];
const columns = ref<ColumnItem[]>([
{ title: '任务标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '期次', dataIndex: 'batch', key: 'batch', width: 160 },
{ title: '阶段', dataIndex: 'stage', key: 'stage', width: 100 },
{ title: '类型', key: 'type', width: 90 },
{ title: '必修', key: 'mustDo', width: 90 },
{ title: '学分', dataIndex: 'score', key: 'score', width: 90 },
{
title: '截止时间',
dataIndex: 'deadline',
key: 'deadline',
width: 170,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm')
},
{ title: '状态', key: 'status', width: 90 },
{ title: '操作', key: 'action', width: 130, fixed: 'right' }
]);
const datasource = ({ page, limit, where, orders }: any) => {
return pageQmgcTasks({
...where,
...orders,
page,
limit,
batch: batch.value || undefined,
type: type.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
batch.value = '';
type.value = undefined;
searchText.value = '';
reload();
};
const openEdit = (row?: QmgcTask) => {
current.value = row || null;
form.value = row ? { ...row } : { type: 'online', mustDo: 1, status: 1 };
deadline.value = row?.deadline;
showEdit.value = true;
};
const save = async () => {
if (!form.value.title?.trim()) {
message.warning('请输入任务标题');
return;
}
submitting.value = true;
try {
form.value.deadline = deadline.value;
const msg = current.value?.id
? await updateQmgcTask(form.value as QmgcTask)
: await addQmgcTask(form.value as QmgcTask);
message.success(msg);
showEdit.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
submitting.value = false;
}
};
const remove = async (row: QmgcTask) => {
if (!row.id) return;
try {
const msg = await removeQmgcTask(row.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
</script>
@@ -121,7 +121,9 @@
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
{ label: '青马工程', value: 'gxmu_qmgc_form' },
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' }
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' },
{ label: '志愿服务时长', value: 'gxmu_volunteer' },
{ label: '社会实践学分', value: 'gxmu_practice_credit' }
];
// 用户信息
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,941 @@
/**
* 审核列表「查看数据」详情的申报表配置。
*
* sections 决定申报表的字段分组与展示顺序,materialFields 声明需要在上传材料
* 中展示的附件字段,未列出的字段会兜底追加到「其他信息」分组,避免数据丢失。
*/
export interface ModuleSection {
// 分组标题
title: string;
// 分组内字段(按展示顺序)
fields: string[];
}
export interface ModuleMaterialTable {
// 明细数组字段
field: string;
// 明细中保存附件地址的字段
fileField: string;
// 明细标题字段
titleField?: string;
// 材料分组标题
label: string;
}
export interface ModuleDetailConfig {
// 模块名称
title: string;
// 申报表名称,用于拼接表单标题
sheetName: string;
// 字段中文名
labels: Record<string, string>;
// 申报表分组
sections: ModuleSection[];
// 附件字段(上传材料)
materialFields: string[];
// 明细数组中的附件
materialTables?: ModuleMaterialTable[];
// 额外的材料数据来源
materialSource?: 'tzbcy';
}
/**
* 通用字段中文名
*/
export const COMMON_LABELS: Record<string, string> = {
year: '年份',
id: 'ID',
title: '标题',
content: '内容',
contentFormat: '正文格式',
cover: '封面',
createTime: '提交时间',
updateTime: '更新时间',
status: '状态',
sortNumber: '排序',
provinceCity: '省市',
schoolName: '学校名称',
projectName: '项目名称',
projectType: '项目类型',
projectGroup: '项目分组',
publicProjectType: '公开展示项目类型',
publicProjectGroup: '公开展示项目分组',
leader: '负责人',
phone: '联系电话',
teamMembers: '团队成员',
advisors: '指导老师',
projectBrief: '项目简介',
socialValue: '社会价值',
practiceProcess: '实践过程',
innovationMeaning: '创新意义',
developmentProspect: '发展前景',
teamCooperation: '团队协作情况',
projectMaterials: '项目介绍材料',
otherProofs: '其他相关证明材料',
projectSummary: '项目总结',
teamIntro: '团队介绍',
teamSlogan: '团队口号',
practiceLog: '实践日志',
name: '姓名',
gender: '性别',
nation: '民族',
photo: '电子版照片',
birthMonth: '出生年月',
politics: '政治面貌',
nativePlace: '籍贯',
wechat: '微信号',
email: '邮箱',
qq: 'QQ',
idCardNo: '身份证号',
hobby: '兴趣爱好',
willingToWorkInGuangxi: '是否愿意留桂工作',
schoolInfo: '学校信息',
leaguePosition: '团内职务',
resume: '个人简历',
awards: '获得奖励',
academicPerformance: '学习成绩',
secondaryLeagueOpinion: '二级团组织意见',
secondaryLeagueOpinionDate: '二级团组织意见日期',
secondaryPartyOpinion: '二级党组织意见',
secondaryPartyOpinionDate: '二级党组织意见日期',
schoolLeagueOpinion: '学校团组织意见',
schoolLeagueOpinionDate: '学校团组织意见日期',
applyType: '申报类别',
education: '学历',
position: '职务',
unit: '工作单位',
experience: '主要经历',
mainStory: '主要事迹',
leagueOpinion: '团组织意见',
partyOpinion: '党组织意见',
schoolOpinion: '学校意见',
collegeClass: '学院班级',
branch: '团支部名称',
eduEval: '教育评议情况',
serialNo: '序号',
archiveInCurrentOrg: '团籍是否在本组织',
joinMonth: '入团时间',
memberRecords: '团员记录',
growthArchives: '成长档案',
orgName: '组织名称',
memberTotal: '团员总数',
memberDeveloped2024: '上一年度发展团员数',
smartSystemLogin: '智慧团建系统录入情况',
committeeCount: '团委委员数',
fulltimeCadreCount: '专职团干部数',
parttimeCadreCount: '兼职团干部数',
lastElectionTime: '最近一次换届时间',
feeReceivable2024: '上一年度应收团费',
feeReceived2024: '上一年度实收团费',
feePayable2024: '上一年度应缴团费',
feePaid2024: '上一年度实缴团费',
branchCount: '团支部数量',
standardizedWork2024: '上一年度标准化建设开展情况',
recommendActivist2024: '上一年度推优入党人数',
activistConfirmed: '入党积极分子确定情况',
recommendDevTarget2024: '上一年度推荐发展对象人数',
devTargetConfirmed: '发展对象确定情况',
honorsFiveYears: '近五年获奖情况',
workSummaryThreeYears: '近三年工作总结',
branchName: '团支部名称',
secondOrg: '所属二级党组织',
secretary: '团支部书记',
contact: '联系方式',
establishTime: '成立时间',
branchCommitteeMeetingCount: '支委会次数',
branchMemberMeetingCount: '支部大会次数',
eduEvalDone: '教育评议是否完成',
annualRegDone: '年度团籍注册是否完成',
classCount: '班级数量',
smartSystem100: '智慧团建系统覆盖率',
topicName: '课题名称',
collegeGradeClass: '学院年级班级',
advisor: '指导老师',
topicType: '课题类别',
guidanceTeachers: '指导教师组',
applicants: '申请人信息',
budgets: '经费预算',
rationale: '立项依据',
teacherOpinion: '指导教师意见',
teacherSign: '指导教师签名',
reviewOpinion: '评审意见',
reviewScore: '评审成绩',
reviewLeaderSign: '评审负责人签名',
collegeOpinion: '学院意见',
promiseSigner: '承诺签字人',
promiseSignature: '承诺书签名',
reviewCollege: '评审学院',
reviewReporter: '汇报人',
reviewDate: '评审日期',
reviewMembers: '评审成员',
fundingBudgetFile: '经费预算表',
fundingAllocationFile: '经费划拨明细表',
midtermRemark: '中期检查说明',
midtermMaterials: '中期检查材料',
finalRemark: '结题说明',
finalMaterials: '结题材料',
archiveAwards: '获奖情况',
archiveProofs: '佐证材料',
archiveRecords: '成果归档',
grade: '年级',
className: '班级',
major: '专业',
role: '角色',
item: '项目',
amount: '金额',
reason: '原因',
dept: '部门',
sign: '签名',
department: '部门',
remark: '备注',
type: '类型',
recordTime: '时间',
archiveTime: '时间',
identity: '身份',
organization: '所在组织',
memberNo: '团员编号',
currentDutyTime: '现任职务时间',
cadreYears: '从事团学工作年限',
assessment2024: '上一年度考核情况',
volunteerRegTime: '志愿汇注册时间',
cadreExperience: '团学工作经历',
joinTime: '入团时间',
collegeMajorClass: '学院专业班级',
eduEval2024: '上一年度教育评议情况',
smartSystem: '智慧团建系统录入情况',
totalVolunteerHours: '累计志愿服务时长',
volunteerHours2024: '上一年度志愿服务时长',
duty: '职务',
college: '学院',
gradeMajor: '年级专业',
birthDate: '出生日期',
branchOrganizationName: '所属团支部',
awardName: '获奖名称',
awardTime: '获奖时间',
proofFile: '证明材料',
archiveOrder: '序号'
};
/**
* 五四类申报表的意见栏分组
*/
const OPINION_SECTION: ModuleSection = {
title: '意见栏',
fields: ['leagueOpinion', 'partyOpinion', 'schoolOpinion']
};
export const MODULE_DETAIL_CONFIGS: Record<string, ModuleDetailConfig> = {
gxmu_sjqn: {
title: '十佳青年岗位能手',
sheetName: '十佳青年岗位能手',
labels: {
applyType: '申报类别',
education: '学历',
position: '职务',
title: '职称',
unit: '所在单位',
awards: '近三年曾获校级及以上奖励',
experience: '工作(学习)经历',
mainStory: '主要事迹',
leagueOpinion: '所在二级团组织意见',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'applyType',
'name',
'gender',
'birthMonth',
'nation',
'politics',
'education',
'position',
'title',
'unit'
]
},
{
title: '主要事迹与经历',
fields: ['awards', 'experience', 'mainStory']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_sjtbzbsj: {
title: '十佳团支部书记',
sheetName: '十佳团支部书记',
labels: {
collegeClass: '学院班级/单位科室',
branch: '所在团支部',
eduEval: '上一年度团员教育评议等次',
awards: '近三年曾获校级及以上奖励',
experience: '工作(学习)经历',
mainStory: '主要事迹',
leagueOpinion: '所在二级团组织意见',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'name',
'gender',
'birthMonth',
'nation',
'politics',
'collegeClass',
'branch',
'eduEval'
]
},
{
title: '主要事迹与经历',
fields: ['awards', 'experience', 'mainStory']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_wshqtw: {
title: '五四红旗团委',
sheetName: '五四红旗团委',
labels: {
orgName: '二级团组织全称',
leader: '负责人',
phone: '联系电话',
memberTotal: '现有团员总数',
memberDeveloped2024: '上一年度发展团员人数',
smartSystemLogin: '是否已登录智慧团建系统',
committeeCount: '团委委员人数',
fulltimeCadreCount: '专职团干部数',
parttimeCadreCount: '兼职团干部数',
lastElectionTime: '团委最近一次换届时间',
feeReceivable2024: '上一年度应收团费',
feeReceived2024: '上一年度实收团费',
feePayable2024: '上一年度应上缴团费',
feePaid2024: '上一年度实际上缴团费',
branchCount: '团支部数',
standardizedWork2024: '是否开展规范化建设工作',
recommendActivist2024: '推荐入党积极分子人数',
activistConfirmed: '确定为入党积极分子数',
recommendDevTarget2024: '推荐党的发展对象人数',
devTargetConfirmed: '确定为党的发展对象数',
honorsFiveYears: '近五年获得校级及以上荣誉情况',
workSummaryThreeYears: '近三年开展的主要工作及取得的效果',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{ title: '组织信息', fields: ['year', 'orgName', 'leader', 'phone'] },
{
title: '团员与团干部情况',
fields: [
'memberTotal',
'memberDeveloped2024',
'smartSystemLogin',
'committeeCount',
'fulltimeCadreCount',
'parttimeCadreCount',
'lastElectionTime'
]
},
{
title: '团费收缴情况',
fields: [
'feeReceivable2024',
'feeReceived2024',
'feePayable2024',
'feePaid2024'
]
},
{
title: '团支部与推优情况',
fields: [
'branchCount',
'recommendActivist2024',
'activistConfirmed',
'recommendDevTarget2024',
'devTargetConfirmed'
]
},
{
title: '工作与荣誉',
fields: [
'standardizedWork2024',
'honorsFiveYears',
'workSummaryThreeYears'
]
},
{ title: '意见栏', fields: ['partyOpinion', 'schoolOpinion'] }
],
materialFields: []
},
gxmu_wshqtzb: {
title: '五四红旗团支部',
sheetName: '五四红旗团支部',
labels: {
branchName: '团支部全称',
secondOrg: '所属二级团组织',
secretary: '团支部书记',
politics: '政治面貌',
contact: '联系方式',
establishTime: '成立时间',
lastElectionTime: '最近一次换届时间',
smartSystemLogin: '是否已登录智慧团建系统',
memberTotal: '现有团员总数',
memberDeveloped2024: '上一年度发展团员数',
feeReceivable2024: '上一年度应收团费',
feeReceived2024: '上一年度实收团费',
feePayable2024: '上一年度应上缴团费',
feePaid2024: '上一年度实际上缴团费',
recommendActivist2024: '推荐入党积极分子人数',
activistConfirmed: '确定为入党积极分子数',
recommendDevTarget2024: '推荐党的发展对象人数',
devTargetConfirmed: '确定为党的发展对象数',
branchCommitteeMeetingCount: '团支部委员会会议召开次数',
branchMemberMeetingCount: '团支部团员大会召开次数',
eduEvalDone: '是否开展团员教育评议',
annualRegDone: '是否开展团员年度团籍注册',
classCount: '开展团课次数',
smartSystem100: '是否100%录入智慧团建',
honorsFiveYears: '近五年获得院级及以上荣誉情况',
workSummaryThreeYears: '近三年开展的主要工作及取得的效果',
leagueOpinion: '所在二级团组织意见',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '支部信息',
fields: [
'year',
'branchName',
'secondOrg',
'secretary',
'politics',
'contact',
'establishTime',
'lastElectionTime'
]
},
{
title: '团员与团费情况',
fields: [
'smartSystemLogin',
'memberTotal',
'memberDeveloped2024',
'feeReceivable2024',
'feeReceived2024',
'feePayable2024',
'feePaid2024'
]
},
{
title: '推优情况',
fields: [
'recommendActivist2024',
'activistConfirmed',
'recommendDevTarget2024',
'devTargetConfirmed'
]
},
{
title: '组织生活',
fields: [
'branchCommitteeMeetingCount',
'branchMemberMeetingCount',
'eduEvalDone',
'annualRegDone',
'classCount',
'smartSystem100'
]
},
{
title: '工作与荣誉',
fields: ['honorsFiveYears', 'workSummaryThreeYears']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_yxgqtdgb: {
title: '优秀共青团干部',
sheetName: '优秀共青团干部',
labels: {
identity: '身份',
organization: '所在团组织',
contact: '联系方式',
memberNo: '发展团员编号',
currentDutyTime: '任现团内职务时间',
cadreYears: '担任团干部年限',
assessment2024: '上一年度工作考核结果',
volunteerRegTime: '成为注册志愿者时间',
cadreExperience: '从事团干部经历',
honorsFiveYears: '近五年获得校级及以上荣誉情况',
mainStory: '主要事迹',
leagueOpinion: '所在单位团组织意见',
partyOpinion: '所在单位党组织意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'name',
'gender',
'nation',
'birthMonth',
'politics',
'position',
'identity',
'organization',
'contact',
'memberNo'
]
},
{
title: '团干部履历',
fields: [
'currentDutyTime',
'cadreYears',
'assessment2024',
'volunteerRegTime',
'cadreExperience'
]
},
{
title: '主要事迹与荣誉',
fields: ['honorsFiveYears', 'mainStory']
},
{ title: '意见栏', fields: ['leagueOpinion', 'partyOpinion'] }
],
materialFields: []
},
gxmu_yxgqty: {
title: '优秀共青团员',
sheetName: '优秀共青团员',
labels: {
joinTime: '入团时间',
collegeMajorClass: '所在学院、专业、班级',
position: '职务',
volunteerRegTime: '成为注册志愿者时间',
eduEval2024: '上一年度团员教育评议等次',
smartSystem: '是否已登录智慧团建系统',
contact: '联系电话',
totalVolunteerHours: '累计志愿服务时长',
volunteerHours2024: '上一年度志愿服务时长',
memberNo: '发展团员编号',
honorsFiveYears: '近五年获得荣誉情况',
mainStory: '主要事迹',
leagueOpinion: '学生社团指导教师意见/所在单位团委',
partyOpinion: '业务指导单位意见/所在单位党组织',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'name',
'gender',
'nation',
'politics',
'birthMonth',
'joinTime',
'collegeMajorClass',
'position',
'contact',
'memberNo'
]
},
{
title: '志愿服务与教育评议',
fields: [
'volunteerRegTime',
'eduEval2024',
'smartSystem',
'totalVolunteerHours',
'volunteerHours2024'
]
},
{
title: '主要事迹与荣誉',
fields: ['honorsFiveYears', 'mainStory']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_tzbcy_form: {
title: '挑战杯',
sheetName: '挑战杯',
labels: {
projectMaterials: '项目介绍材料',
otherProofs: '其他相关证明材料',
teamMembers: '团队成员',
advisors: '指导老师'
},
sections: [
{
title: '项目信息',
fields: [
'year',
'projectName',
'provinceCity',
'schoolName',
'projectType',
'projectGroup'
]
},
{ title: '团队成员', fields: ['teamMembers'] },
{ title: '指导老师', fields: ['advisors'] },
{
title: '项目说明',
fields: [
'projectBrief',
'socialValue',
'practiceProcess',
'innovationMeaning',
'developmentProspect',
'teamCooperation',
'projectMaterials',
'otherProofs'
]
},
{
title: '公开展示信息',
fields: [
'publicProjectType',
'publicProjectGroup',
'projectSummary',
'teamIntro',
'teamSlogan',
'practiceLog'
]
}
],
materialFields: [],
materialSource: 'tzbcy'
},
gxmu_qmgc_form: {
title: '青马工程',
sheetName: '青马工程',
labels: {
hobby: '兴趣爱好',
willingToWorkInGuangxi: '是否愿意留桂工作',
schoolInfo: '学校信息',
leaguePosition: '团内职务',
resume: '个人简历',
awards: '获得奖励',
academicPerformance: '学习成绩',
secondaryLeagueOpinion: '二级团组织意见',
secondaryLeagueOpinionDate: '二级团组织意见日期',
secondaryPartyOpinion: '二级党组织意见',
secondaryPartyOpinionDate: '二级党组织意见日期',
schoolLeagueOpinion: '学校团组织意见',
schoolLeagueOpinionDate: '学校团组织意见日期'
},
sections: [
{
title: '个人信息',
fields: [
'year',
'name',
'gender',
'nation',
'photo',
'birthMonth',
'politics',
'nativePlace',
'phone',
'wechat',
'email',
'qq',
'idCardNo',
'hobby',
'willingToWorkInGuangxi',
'schoolInfo',
'leaguePosition'
]
},
{
title: '学习与经历',
fields: ['resume', 'awards', 'academicPerformance']
},
{
title: '意见栏',
fields: [
'secondaryLeagueOpinion',
'secondaryLeagueOpinionDate',
'secondaryPartyOpinion',
'secondaryPartyOpinionDate',
'schoolLeagueOpinion',
'schoolLeagueOpinionDate'
]
}
],
materialFields: []
},
gxmu_wxxzx_project: {
title: '未来学术之星',
sheetName: '未来学术之星',
labels: {
topicName: '课题名称',
leader: '课题负责人',
collegeGradeClass: '学院年级班级',
phone: '联系电话',
advisor: '指导老师',
topicType: '课题类别',
guidanceTeachers: '指导教师组',
applicants: '申请人信息',
budgets: '经费预算',
rationale: '立项依据',
teacherOpinion: '指导教师意见',
teacherSign: '指导教师签名',
reviewCollege: '评审学院',
reviewReporter: '汇报人',
reviewDate: '评审日期',
reviewOpinion: '评审意见',
reviewScore: '评审成绩',
reviewLeaderSign: '评审负责人签名',
collegeOpinion: '学院意见',
schoolOpinion: '学校意见',
reviewMembers: '评审成员',
midtermRemark: '中期检查说明',
midtermMaterials: '中期检查材料',
finalRemark: '结题说明',
finalMaterials: '结题材料',
archiveRecords: '成果归档',
archiveAwards: '获奖情况',
archiveProofs: '佐证材料',
promiseSigner: '承诺签字人',
promiseSignature: '承诺书签名',
fundingBudgetFile: '经费预算表',
fundingAllocationFile: '经费划拨明细表',
proofFile: '证明材料'
},
sections: [
{
title: '课题信息',
fields: [
'year',
'topicName',
'leader',
'collegeGradeClass',
'phone',
'advisor',
'topicType'
]
},
{ title: '指导教师组', fields: ['guidanceTeachers'] },
{ title: '申请人信息', fields: ['applicants'] },
{
title: '经费预算',
fields: ['budgets', 'fundingBudgetFile', 'fundingAllocationFile']
},
{ title: '立项依据', fields: ['rationale'] },
{
title: '审核评审',
fields: [
'teacherOpinion',
'teacherSign',
'reviewCollege',
'reviewReporter',
'reviewDate',
'reviewOpinion',
'reviewScore',
'reviewLeaderSign',
'collegeOpinion',
'schoolOpinion'
]
},
{ title: '评审成员', fields: ['reviewMembers'] },
{
title: '中期检查与结题',
fields: [
'midtermRemark',
'midtermMaterials',
'finalRemark',
'finalMaterials'
]
},
{
title: '成果归档',
fields: ['archiveRecords', 'archiveAwards', 'archiveProofs']
},
{
title: '承诺书',
fields: ['promiseSigner', 'promiseSignature']
}
],
materialFields: [
'fundingBudgetFile',
'fundingAllocationFile',
'midtermMaterials',
'finalMaterials',
'archiveProofs'
],
materialTables: [
{
field: 'archiveRecords',
fileField: 'proofFile',
titleField: 'awardName',
label: '成果归档证明材料'
}
]
},
gxmu_tygl_form: {
title: '团员管理',
sheetName: '团员信息登记表',
labels: {
serialNo: '序号',
college: '学院',
className: '班级',
phone: '手机号码',
leaguePosition: '团内职务',
branchOrganizationName: '所属团支部',
idCardNo: '身份证号',
birthDate: '出生日期',
archiveInCurrentOrg: '团籍是否在本组织',
joinMonth: '入团年月',
memberRecords: '团员记录',
growthArchives: '成长档案'
},
sections: [
{
title: '基本信息',
fields: [
'serialNo',
'name',
'gender',
'nation',
'politics',
'college',
'className',
'phone',
'leaguePosition',
'branchOrganizationName',
'idCardNo',
'birthDate',
'archiveInCurrentOrg',
'joinMonth'
]
},
{ title: '团员记录', fields: ['memberRecords'] },
{ title: '成长档案', fields: ['growthArchives'] }
],
materialFields: []
},
gxmu_volunteer: {
title: '志愿服务时长',
sheetName: '志愿服务时长记录',
labels: {
studentName: '姓名',
studentNo: '学号',
projectName: '志愿项目',
orgName: '组织单位',
servicePlace: '服务地点',
hours: '服务时长(小时)',
serviceDate: '服务开始日期',
serviceEndDate: '服务结束日期',
content: '服务内容',
source: '数据来源',
proofFiles: '证明材料'
},
sections: [
{
title: '学生信息',
fields: ['studentName', 'studentNo']
},
{
title: '服务信息',
fields: [
'projectName',
'orgName',
'servicePlace',
'hours',
'serviceDate',
'serviceEndDate',
'content',
'source'
]
}
],
materialFields: ['proofFiles']
},
cms_manuscript: {
title: '稿件',
sheetName: '稿件',
labels: {
title: '标题',
content: '正文',
contentFormat: '正文格式',
cover: '封面',
status: '状态',
sortNumber: '排序'
},
sections: [
{
title: '基本信息',
fields: ['title', 'status', 'sortNumber']
},
{ title: '封面', fields: ['cover'] },
{ title: '正文', fields: ['content'] }
],
materialFields: []
}
};
// 团员管理模块历史上存在两个模块标识
MODULE_DETAIL_CONFIGS.gxmu_tygl = MODULE_DETAIL_CONFIGS.gxmu_tygl_form;
/**
* 明细数组表头字段中文名
*/
export const TABLE_FIELD_LABELS: Record<string, string> = {
name: '姓名',
gender: '性别',
nation: '民族',
politics: '政治面貌',
birthMonth: '出生年月',
title: '职称',
duty: '职务',
department: '部门',
dept: '部门',
remark: '备注',
college: '学院',
gradeMajor: '年级专业',
grade: '年级',
className: '班级',
major: '专业',
role: '角色',
phone: '联系电话',
sign: '签名',
item: '项目',
amount: '金额',
reason: '原因',
type: '类型',
content: '内容',
recordTime: '时间',
archiveTime: '时间',
awardName: '获奖名称',
awardTime: '获奖时间',
archiveOrder: '序号',
proofFile: '证明材料',
year: '年份'
};
export const getModuleDetailConfig = (module?: string) => {
if (!module) {
return undefined;
}
return MODULE_DETAIL_CONFIGS[module];
};
@@ -15,7 +15,9 @@
<a-tag>{{ moduleName || module || '-' }}</a-tag>
<span>业务ID:</span>
<a-tag>{{ pk ?? '-' }}</a-tag>
<a-button size="small" :loading="loading" @click="loadData">刷新</a-button>
<a-button size="small" :loading="loading" @click="loadData"
>刷新</a-button
>
</a-space>
<div v-if="flowList.length" class="flow-wrap">
@@ -23,7 +25,9 @@
<a-step v-for="item in flowList" :key="item.id">
<template #title>
<div class="flow-title">
<span>{{ item.user?.realName || item.user?.username || '未分配审核人' }}</span>
<span>{{
item.user?.realName || item.user?.username || '未分配审核人'
}}</span>
<a-tag :color="getStatusColor(item.status)">
{{ getStatusText(item.status) }}
</a-tag>
@@ -31,14 +35,28 @@
</template>
<template #description>
<div class="flow-desc">
<div>创建时间:{{ toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss') }}</div>
<div
>创建时间:{{
toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss')
}}</div
>
<div v-if="item.content">审核意见:{{ item.content }}</div>
<a-space v-if="canReview(item)" style="margin-top: 12px">
<a-button type="primary" size="small" :loading="submitting" @click="openActionModal('approve', item)">
<a-button
type="primary"
size="small"
:loading="submitting"
@click="openActionModal('approve', item)"
>
通过
</a-button>
<a-button danger size="small" :loading="submitting" @click="openActionModal('reject', item)">
拒绝
<a-button
danger
size="small"
:loading="submitting"
@click="openActionModal('reject', item)"
>
驳回
</a-button>
</a-space>
</div>
@@ -50,19 +68,26 @@
<a-modal
v-model:visible="actionVisible"
:title="actionType === 'approve' ? '审核通过' : '审核拒绝'"
:title="actionType === 'approve' ? '审核通过' : '审核驳回'"
ok-text="确认"
cancel-text="取消"
:confirm-loading="submitting"
@ok="submitReview"
>
<a-form layout="vertical">
<a-form-item label="审核意见(非必填)">
<a-form
ref="actionFormRef"
layout="vertical"
:model="actionForm"
:rules="actionRules"
>
<a-form-item label="审核意见" name="content">
<a-textarea
v-model:value="actionContent"
v-model:value="actionForm.content"
:rows="4"
:maxlength="500"
show-count
allow-clear
placeholder="请输入审核意见"
placeholder="请输入审核意见(必填)"
/>
</a-form-item>
</a-form>
@@ -71,163 +96,187 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Empty, message } from 'ant-design-vue';
import { toDateString } from 'ele-admin-pro';
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
import type { ReviewList } from '@/api/gxmu/reviewList/model';
import { useUserStore } from '@/store/modules/user';
import { computed, reactive, ref, watch } from 'vue';
import { Empty, message } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue/es/form';
import { toDateString } from 'ele-admin-pro';
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
import type { ReviewList } from '@/api/gxmu/reviewList/model';
import { useUserStore } from '@/store/modules/user';
const props = defineProps<{
visible: boolean;
module?: string;
moduleName?: string;
pk?: number;
}>();
const props = defineProps<{
visible: boolean;
module?: string;
moduleName?: string;
pk?: number;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'done'): void;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'done'): void;
}>();
const userStore = useUserStore();
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
const loading = ref(false);
const submitting = ref(false);
const flowList = ref<ReviewList[]>([]);
const actionVisible = ref(false);
const actionType = ref<'approve' | 'reject'>('approve');
const actionContent = ref('');
const actionTarget = ref<ReviewList | null>(null);
const userStore = useUserStore();
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
const loading = ref(false);
const submitting = ref(false);
const flowList = ref<ReviewList[]>([]);
const actionVisible = ref(false);
const actionType = ref<'approve' | 'reject'>('approve');
const actionFormRef = ref<FormInstance | null>(null);
const actionForm = reactive<{ content: string }>({ content: '' });
const actionTarget = ref<ReviewList | null>(null);
const currentStepIndex = computed(() => {
const pendingIndex = flowList.value.findIndex((item) => item.status === 0);
if (pendingIndex >= 0) {
return pendingIndex;
}
return Math.max(flowList.value.length - 1, 0);
});
// 审核意见必填(仅空白字符视为未填写)
const actionRules = {
content: [
{
required: true,
validator: (_rule: unknown, value: string) => {
const text = (value || '').trim();
return text
? Promise.resolve()
: Promise.reject(new Error('请输入审核意见'));
},
trigger: 'blur'
}
]
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const getStatusText = (status?: number) => {
if (status === 1) return '通过';
if (status === 2) return '拒绝';
return '待审核';
};
const getStatusColor = (status?: number) => {
if (status === 1) return 'success';
if (status === 2) return 'error';
return 'default';
};
const canReview = (item: ReviewList) => {
return item.status === 0 && Number(item.userId || 0) === loginUserId.value;
};
const loadData = () => {
if (!props.module || !props.pk) {
flowList.value = [];
return;
}
loading.value = true;
listReviewList({
module: props.module,
pk: props.pk
})
.then((data) => {
flowList.value = [...(data || [])].sort(
(a, b) => Number(b.id || 0) - Number(a.id || 0)
);
})
.catch((e) => {
flowList.value = [];
message.error(e.message);
})
.finally(() => {
loading.value = false;
});
};
const openActionModal = (type: 'approve' | 'reject', item: ReviewList) => {
if (!canReview(item)) {
message.error('当前记录不是由你审核');
return;
}
actionType.value = type;
actionTarget.value = item;
actionContent.value = '';
actionVisible.value = true;
};
const submitReview = () => {
if (!actionTarget.value?.id) {
actionVisible.value = false;
return;
}
if (!canReview(actionTarget.value)) {
actionVisible.value = false;
message.error('当前记录不是由你审核');
return;
}
submitting.value = true;
updateReviewList({
id: actionTarget.value.id,
status: actionType.value === 'approve' ? 1 : 2,
module: actionTarget.value.module,
pk: actionTarget.value.pk,
sortNumber: actionTarget.value.sortNumber,
content: actionContent.value || undefined
})
.then((msg) => {
message.success(msg);
actionVisible.value = false;
emit('done');
loadData();
})
.catch((e) => {
message.error(e.message);
})
.finally(() => {
submitting.value = false;
});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
loadData();
} else {
flowList.value = [];
actionVisible.value = false;
actionTarget.value = null;
actionContent.value = '';
const currentStepIndex = computed(() => {
const pendingIndex = flowList.value.findIndex((item) => item.status === 0);
if (pendingIndex >= 0) {
return pendingIndex;
}
},
{ immediate: true }
);
return Math.max(flowList.value.length - 1, 0);
});
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const getStatusText = (status?: number) => {
if (status === 1) return '通过';
if (status === 2) return '驳回';
return '待审核';
};
const getStatusColor = (status?: number) => {
if (status === 1) return 'success';
if (status === 2) return 'error';
return 'default';
};
const canReview = (item: ReviewList) => {
return item.status === 0 && Number(item.userId || 0) === loginUserId.value;
};
const loadData = () => {
if (!props.module || !props.pk) {
flowList.value = [];
return;
}
loading.value = true;
listReviewList({
module: props.module,
pk: props.pk
})
.then((data) => {
flowList.value = [...(data || [])].sort(
(a, b) => Number(b.id || 0) - Number(a.id || 0)
);
})
.catch((e) => {
flowList.value = [];
message.error(e.message);
})
.finally(() => {
loading.value = false;
});
};
const openActionModal = (type: 'approve' | 'reject', item: ReviewList) => {
if (!canReview(item)) {
message.error('当前记录不是由你审核');
return;
}
actionType.value = type;
actionTarget.value = item;
actionForm.content = '';
actionVisible.value = true;
};
const submitReview = () => {
const target = actionTarget.value;
if (!target?.id) {
actionVisible.value = false;
return;
}
if (!canReview(target)) {
actionVisible.value = false;
message.error('当前记录不是由你审核');
return;
}
actionFormRef.value
?.validate()
.then(() => {
submitting.value = true;
return updateReviewList({
id: target.id,
status: actionType.value === 'approve' ? 1 : 2,
module: target.module,
pk: target.pk,
sortNumber: target.sortNumber,
content: actionForm.content.trim()
})
.then((msg) => {
message.success(msg);
actionVisible.value = false;
emit('done');
loadData();
})
.catch((e) => {
message.error(e.message);
})
.finally(() => {
submitting.value = false;
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
loadData();
} else {
flowList.value = [];
actionVisible.value = false;
actionTarget.value = null;
actionForm.content = '';
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.flow-wrap {
max-height: 560px;
overflow-y: auto;
padding-right: 8px;
}
.flow-wrap {
max-height: 560px;
overflow-y: auto;
padding-right: 8px;
}
.flow-title {
display: flex;
align-items: center;
gap: 8px;
}
.flow-title {
display: flex;
align-items: center;
gap: 8px;
}
.flow-desc {
color: rgba(0, 0, 0, 0.65);
line-height: 1.8;
}
.flow-desc {
color: rgba(0, 0, 0, 0.65);
line-height: 1.8;
}
</style>
@@ -27,13 +27,9 @@
/>
</a-form-item>
<a-form-item label="主键" name="pk">
<a-input
allow-clear
placeholder="请输入主键"
v-model:value="form.pk"
/>
<a-input allow-clear placeholder="请输入主键" v-model:value="form.pk" />
</a-form-item>
<a-form-item label="状态(0不通过 1通过)" name="status">
<a-form-item label="状态(0驳回 1通过)" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">显示</a-radio>
<a-radio :value="0">隐藏</a-radio>
@@ -61,11 +57,7 @@
/>
</a-form-item>
<a-form-item label="" name="userId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.userId"
/>
<a-input allow-clear placeholder="请输入" v-model:value="form.userId" />
</a-form-item>
<a-form-item label="" name="sortNumber">
<a-input-number
@@ -129,7 +121,7 @@
createTime: undefined,
updateTime: undefined,
userId: undefined,
sortNumber: undefined,
sortNumber: undefined
});
/* 更新visible */
@@ -138,9 +130,7 @@
};
// 表单验证规则
const rules = reactive({
});
const rules = reactive({});
const { resetFields } = useForm(form, rules);
@@ -51,7 +51,9 @@
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
{ label: '青马工程', value: 'gxmu_qmgc_form' },
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' }
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' },
{ label: '志愿服务时长', value: 'gxmu_volunteer' },
{ label: '社会实践学分', value: 'gxmu_practice_credit' }
];
const search = () => {
+170 -150
View File
@@ -23,7 +23,7 @@
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0">待审核</a-tag>
<a-tag v-else-if="record.status === 1" color="green">通过</a-tag>
<a-tag v-else-if="record.status === 2" color="red">拒绝</a-tag>
<a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
@@ -36,12 +36,17 @@
</ele-pro-table>
</a-card>
<ReviewListEdit v-model:visible="showEdit" :data="current" @done="reload" />
<ReviewListEdit
v-model:visible="showEdit"
:data="current"
@done="reload"
/>
<ModuleDataModal
v-model:visible="showDataVisible"
:module="dataCurrent?.module"
:module-name="dataCurrent?.moduleName"
:pk="dataCurrent?.pk"
@done="reload"
/>
<ReviewFlowModal
v-model:visible="showFlowVisible"
@@ -55,162 +60,177 @@
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type { DatasourceFunction, ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import ReviewListEdit from './components/reviewListEdit.vue';
import ModuleDataModal from './components/moduleDataModal.vue';
import ReviewFlowModal from './components/reviewFlowModal.vue';
import { pageGroupReviewList, removeBatchReviewList } from '@/api/gxmu/reviewList';
import type { ReviewList, ReviewListParam } from '@/api/gxmu/reviewList/model';
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import ReviewListEdit from './components/reviewListEdit.vue';
import ModuleDataModal from './components/moduleDataModal.vue';
import ReviewFlowModal from './components/reviewFlowModal.vue';
import {
pageGroupReviewList,
removeBatchReviewList
} from '@/api/gxmu/reviewList';
import type {
ReviewList,
ReviewListParam
} from '@/api/gxmu/reviewList/model';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const selection = ref<ReviewList[]>([]);
const current = ref<ReviewList | null>(null);
const dataCurrent = ref<ReviewList | null>(null);
const flowCurrent = ref<ReviewList | null>(null);
const showEdit = ref(false);
const showDataVisible = ref(false);
const showFlowVisible = ref(false);
const showMove = ref(false);
const loading = ref(true);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const selection = ref<ReviewList[]>([]);
const current = ref<ReviewList | null>(null);
const dataCurrent = ref<ReviewList | null>(null);
const flowCurrent = ref<ReviewList | null>(null);
const showEdit = ref(false);
const showDataVisible = ref(false);
const showFlowVisible = ref(false);
const showMove = ref(false);
const loading = ref(true);
const datasource: DatasourceFunction = ({ page, limit, where, orders, filters }) => {
if (filters) {
where.status = filters.status;
}
return pageGroupReviewList({
...where,
...orders,
const datasource: DatasourceFunction = ({
page,
limit
});
};
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90
},
{
title: '模块',
dataIndex: 'moduleName',
key: 'module',
align: 'center'
},
{
title: '名称',
dataIndex: 'businessName',
key: 'businessName',
align: 'center',
ellipsis: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center'
},
{
title: '最新审核意见',
dataIndex: 'content',
key: 'content',
align: 'center',
ellipsis: true
},
{
title: '提交时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '当前审核人',
dataIndex: ['user', 'realName'],
key: 'userId',
align: 'center'
},
{
title: '操作',
key: 'action',
width: 220,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
const reload = (where?: ReviewListParam) => {
selection.value = [];
tableRef?.value?.reload({ where });
};
const openEdit = (row?: ReviewList) => {
current.value = row ?? null;
showEdit.value = true;
};
const openMove = () => {
showMove.value = true;
};
const openDataModal = (row: ReviewList) => {
dataCurrent.value = row;
showDataVisible.value = true;
};
const openFlowModal = (row: ReviewList) => {
flowCurrent.value = row;
showFlowVisible.value = true;
};
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchReviewList(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
}
});
};
return pageGroupReviewList({
...where,
...orders,
page,
limit
});
};
const query = () => {
loading.value = true;
};
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90
},
{
title: '模块',
dataIndex: 'moduleName',
key: 'module',
align: 'center'
},
{
title: '名称',
dataIndex: 'businessName',
key: 'businessName',
align: 'center',
ellipsis: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center'
},
{
title: '最新审核意见',
dataIndex: 'content',
key: 'content',
align: 'center',
ellipsis: true
},
{
title: '提交时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '当前审核人',
dataIndex: ['user', 'realName'],
key: 'userId',
align: 'center'
},
{
title: '操作',
key: 'action',
width: 220,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
query();
const reload = (where?: ReviewListParam) => {
selection.value = [];
tableRef?.value?.reload({ where });
};
const openEdit = (row?: ReviewList) => {
current.value = row ?? null;
showEdit.value = true;
};
const openMove = () => {
showMove.value = true;
};
const openDataModal = (row: ReviewList) => {
dataCurrent.value = row;
showDataVisible.value = true;
};
const openFlowModal = (row: ReviewList) => {
flowCurrent.value = row;
showFlowVisible.value = true;
};
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchReviewList(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
const query = () => {
loading.value = true;
};
query();
</script>
<script lang="ts">
export default {
name: 'ReviewList'
};
export default {
name: 'ReviewList'
};
</script>
<style lang="less" scoped></style>
<style lang="less" scoped></style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="AI辅助教学设计" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">输入团课/青马课程主题,生成 PPT 大纲、课堂讨论题与随堂测验。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>Dify 教学设计工作流</li>
<li>AI 生成页外壳组件</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// AI辅助教学设计:P1 阶段交付,当前先占位避免菜单跳转 404
</script>
<style lang="less" scoped>
.placeholder-card {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-desc {
color: #595959;
line-height: 1.8;
}
.placeholder-sub {
margin-top: 4px;
font-weight: 600;
color: #262626;
}
.placeholder-list {
margin: 0;
padding-left: 20px;
color: #8c8c8c;
line-height: 1.9;
}
</style>
+293
View File
@@ -0,0 +1,293 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-button
v-if="hasPermission('gxmu:thematicCourse:save')"
type="primary"
@click="openEdit()"
>
<template #icon><plus-outlined /></template>
新增资源
</a-button>
<a-button
v-if="hasPermission('gxmu:thematicCourse:sync')"
:loading="syncing"
@click="doSync"
>
<template #icon><sync-outlined /></template>
同步资源
</a-button>
<a-select
v-model:value="type"
allow-clear
style="width: 150px"
placeholder="资源类型"
:options="THEMATIC_TYPE_OPTIONS"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="标题/摘要/正文"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag color="blue">{{ typeLabel(record.type) }}</a-tag>
</template>
<template v-else-if="column.key === 'title'">
<a @click="openDetail(record)">{{ record.title }}</a>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">查看</a>
<a-divider type="vertical" />
<a v-if="hasPermission('gxmu:thematicCourse:save')" @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm title="确定删除这个资源吗?" @confirm="remove(record)">
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<a-modal
v-model:visible="showEdit"
:title="current?.id ? '编辑团课资源' : '新增团课资源'"
:confirm-loading="submitting"
width="640px"
@ok="save"
>
<a-form layout="vertical">
<a-form-item label="标题" required>
<a-input v-model:value="form.title" allow-clear placeholder="请输入标题" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="资源类型">
<a-select v-model:value="form.type" :options="THEMATIC_TYPE_OPTIONS" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="标签">
<a-input v-model:value="form.tags" allow-clear placeholder="逗号分隔" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="摘要">
<a-textarea v-model:value="form.summary" :rows="2" />
</a-form-item>
<a-form-item label="正文/讲稿">
<a-textarea v-model:value="form.content" :rows="6" />
</a-form-item>
<a-form-item label="附件">
<MultiFileUpload v-model="fileUrl" :max-count="3" />
</a-form-item>
<a-form-item label="来源链接">
<a-input v-model:value="form.sourceUrl" allow-clear placeholder="可选" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:visible="showDetail"
:title="current?.title || '团课资源详情'"
:footer="null"
width="820px"
>
<template v-if="current">
<a-space style="margin-bottom: 12px">
<a-tag color="blue">{{ typeLabel(current.type) }}</a-tag>
<a-tag v-if="current.sourceSite">{{ current.sourceSite }}</a-tag>
<a-tag v-if="current.tags">{{ current.tags }}</a-tag>
<a-tag>{{ current.heat ?? 0 }} 次浏览</a-tag>
</a-space>
<div v-if="current.summary" class="detail-summary">{{ current.summary }}</div>
<div class="detail-content" v-html="renderedContent"></div>
<a-space v-if="current.fileUrl || current.sourceUrl" style="margin-top: 16px">
<a-button v-if="current.fileUrl" type="primary" @click="openUrl(current.fileUrl)">
下载附件
</a-button>
<a-button v-if="current.sourceUrl" @click="openUrl(current.sourceUrl)">查看原文</a-button>
</a-space>
</template>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined, SyncOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import {
pageThematicCourses,
addThematicCourse,
updateThematicCourse,
removeThematicCourse,
syncThematicCourses,
THEMATIC_TYPE_OPTIONS
} from '@/api/growth/thematic';
import type { ThematicCourse } from '@/api/growth/thematic';
import { hasPermission } from '@/utils/permission';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const type = ref<string>();
const searchText = ref('');
const showEdit = ref(false);
const showDetail = ref(false);
const submitting = ref(false);
const syncing = ref(false);
const current = ref<ThematicCourse | null>(null);
const form = ref<Partial<ThematicCourse>>({});
const fileUrl = ref('');
const columns = ref<ColumnItem[]>([
{ title: '标题', key: 'title', ellipsis: true },
{ title: '类型', key: 'type', width: 110, align: 'center' },
{ title: '来源', dataIndex: 'sourceSite', key: 'sourceSite', width: 140 },
{ title: '标签', dataIndex: 'tags', key: 'tags', width: 180, ellipsis: true },
{ title: '热度', dataIndex: 'heat', key: 'heat', width: 90, align: 'center' },
{
title: '同步时间',
dataIndex: 'lastSyncTime',
key: 'lastSyncTime',
width: 160,
customRender: ({ text }) => (text ? toDateString(text, 'yyyy-MM-dd HH:mm') : '-')
},
{ title: '操作', key: 'action', width: 150, fixed: 'right', align: 'center' }
]);
const datasource = ({ page, limit, where, orders }: any) => {
return pageThematicCourses({
...where,
...orders,
page,
limit,
type: type.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
type.value = undefined;
searchText.value = '';
reload();
};
const openEdit = (row?: ThematicCourse) => {
current.value = row || null;
form.value = row ? { ...row } : { type: 'courseware' };
fileUrl.value = row?.fileUrl || '';
showEdit.value = true;
};
const openDetail = (row: ThematicCourse) => {
current.value = row;
showDetail.value = true;
};
const save = async () => {
if (!form.value.title?.trim()) {
message.warning('请输入标题');
return;
}
submitting.value = true;
try {
form.value.fileUrl = fileUrl.value || undefined;
const msg = current.value?.id
? await updateThematicCourse(form.value as ThematicCourse)
: await addThematicCourse(form.value as ThematicCourse);
message.success(msg);
showEdit.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
submitting.value = false;
}
};
const remove = async (row: ThematicCourse) => {
if (!row.id) return;
try {
const msg = await removeThematicCourse(row.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const doSync = async () => {
syncing.value = true;
try {
const msg = await syncThematicCourses();
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '同步失败');
} finally {
syncing.value = false;
}
};
const openUrl = (url?: string) => {
if (url) window.open(url, '_blank');
};
const typeLabel = (value?: string) =>
THEMATIC_TYPE_OPTIONS.find((item) => item.value === value)?.label ?? value ?? '-';
const renderedContent = computed(() => {
const content = current.value?.content || '';
if (!content) return '<p class="ele-text-secondary">暂无正文</p>';
// 简单换行保留,避免引入额外 markdown 依赖
return content
.split('\n')
.map((line) => `<p>${line || '&nbsp;'}</p>`)
.join('');
});
</script>
<style lang="less" scoped>
.detail-summary {
padding: 10px 14px;
margin-bottom: 12px;
background: #fafafa;
border-radius: 8px;
color: #595959;
}
.detail-content {
max-height: 50vh;
overflow: auto;
line-height: 1.8;
color: #262626;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="AI主题团日设计" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">按活动主题与受众特征生成团日活动方案与学习资源。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>Dify 主题团日工作流</li>
<li>复用活动策划页壳</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// AI主题团日设计:P1 阶段交付,当前先占位避免菜单跳转 404
</script>
<style lang="less" scoped>
.placeholder-card {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-desc {
color: #595959;
line-height: 1.8;
}
.placeholder-sub {
margin-top: 4px;
font-weight: 600;
color: #262626;
}
.placeholder-list {
margin: 0;
padding-left: 20px;
color: #8c8c8c;
line-height: 1.9;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
export interface AttachmentFile {
name: string;
url: string;
}
const FILE_EXT_PATTERN =
/\.(pdf|docx?|xlsx?|pptx?|csv|txt|zip|rar|7z|jpe?g|png|gif|webp|bmp|svg|mp4|mp3|wav)$/i;
/**
* 从文件地址中取出展示用的文件名
*/
export function getAttachmentName(url?: string) {
if (!url) {
return '未命名文件';
}
const cleanUrl = url.split('?')[0];
const name = cleanUrl.split('/').pop() || cleanUrl;
try {
return decodeURIComponent(name);
} catch (e) {
return name;
}
}
/**
* 判断字符串是否像文件地址(http(s)、协议相对地址、带扩展名的相对路径)
*/
export function isFileUrl(value?: string) {
const text = value?.trim();
if (!text) {
return false;
}
if (/^(https?:)?\/\//i.test(text)) {
return true;
}
return FILE_EXT_PATTERN.test(text) && !/\s/.test(text);
}
export function isImageFile(url?: string) {
const text = (url || '').split('?')[0];
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(text);
}
const normalizeEntry = (item: unknown): AttachmentFile | null => {
if (typeof item === 'string') {
return isFileUrl(item)
? { name: getAttachmentName(item), url: item.trim() }
: null;
}
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>;
const rawUrl = [
record.url,
record.fileUrl,
record.downloadUrl,
record.path
].find((value) => typeof value === 'string' && isFileUrl(value as string));
if (typeof rawUrl !== 'string') {
return null;
}
const rawName = [record.name, record.fileName, record.originalName].find(
(value) => typeof value === 'string' && (value as string).trim()
);
return {
name: (rawName as string | undefined) || getAttachmentName(rawUrl),
url: rawUrl.trim()
};
}
return null;
};
/**
* 把后端存储的附件字段解析成文件列表。
* 兼容:单个地址、JSON 数组字符串、对象数组(name/url、fileName/fileUrl 等)
*/
export function parseAttachments(value: unknown): AttachmentFile[] {
if (value === null || value === undefined || value === '') {
return [];
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) {
return [];
}
if (text.startsWith('[') || text.startsWith('{')) {
try {
return parseAttachments(JSON.parse(text));
} catch (e) {
// 解析失败时按普通地址处理
}
}
const single = normalizeEntry(text);
return single ? [single] : [];
}
if (Array.isArray(value)) {
return value
.map((item) => normalizeEntry(item))
.filter((item): item is AttachmentFile => !!item);
}
const single = normalizeEntry(value);
return single ? [single] : [];
}
/**
* 把附件字段解析结果转换成图片地址列表
*/
export function parseAttachmentImages(value: unknown) {
const urls: string[] = [];
parseAttachments(value).forEach((item) => {
if (isImageFile(item.url)) {
urls.push(item.url);
}
});
if (!urls.length && typeof value === 'string' && isImageFile(value)) {
urls.push(value);
}
return urls;
}
+27 -4
View File
@@ -1,11 +1,24 @@
import type { ReviewList } from '@/api/gxmu/reviewList/model';
export function shouldHideEditByReviewList(reviewList?: ReviewList[]) {
/**
* 取最新一条审核记录。
*
* <p>统一排序规则:先按 sortNumber(并行组序号)降序,再按 createTime、id 降序。
* 原先 reviewStatusTag 只按 id、这里只按 createTime+id,两处可能给出不同结论
* (并行审核某组后审通过、前组已驳回时,状态标签与「能否编辑」会互相矛盾),
* 现在收敛到这一处。
*/
export function getLatestReview(reviewList?: ReviewList[]): ReviewList | undefined {
const list = reviewList ?? [];
if (!list.length) {
return false;
return undefined;
}
const latest = [...list].sort((a, b) => {
return [...list].sort((a, b) => {
const sortA = Number(a.sortNumber ?? 0);
const sortB = Number(b.sortNumber ?? 0);
if (sortA !== sortB) {
return sortB - sortA;
}
const timeA = Date.parse(a.createTime || '');
const timeB = Date.parse(b.createTime || '');
const safeTimeA = Number.isFinite(timeA) ? timeA : 0;
@@ -15,5 +28,15 @@ export function shouldHideEditByReviewList(reviewList?: ReviewList[]) {
}
return Number(b.id || 0) - Number(a.id || 0);
})[0];
return latest?.status !== 2;
}
/**
* 最新记录不是驳回时,隐藏编辑入口(待审核或已通过都不允许改)。
*/
export function shouldHideEditByReviewList(reviewList?: ReviewList[]) {
const list = reviewList ?? [];
if (!list.length) {
return false;
}
return getLatestReview(list)?.status !== 2;
}
+1 -1
View File
@@ -63,7 +63,7 @@ const router = useRouter();
const features = [
{
title: "AI+党建的双向赋能",
title: "AI+团建的双向赋能",
desc: "赋能广西医科大学团委的各项工作。项目遵循“先基础,后智能”的原则,信息化和智能化相结合,确保项目稳步推进,价值逐步释放。"
},
{
@@ -134,7 +134,7 @@
class="ele-btn-icon"
@click="openUrl('https://p.haoii123.com/')"
>
一键排版
公众号排版
</a-button>
</a-space>
</div>
+131
View File
@@ -0,0 +1,131 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="noticeId"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1000 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="type"
allow-clear
style="width: 140px"
placeholder="消息类型"
:options="typeOptions"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 260px"
placeholder="标题/内容"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="record.type === 'todo' ? 'orange' : record.type === 'letter' ? 'cyan' : 'blue'">
{{ typeLabel(record.type) }}
</a-tag>
</template>
<template v-else-if="column.key === 'isRead'">
<a-tag :color="record.isRead === 1 ? 'default' : 'red'">
{{ record.isRead === 1 ? '已读' : '未读' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a @click="openDetail(record)">查看</a>
</template>
</template>
</ele-pro-table>
</a-card>
<a-modal v-model:visible="showDetail" title="消息详情" :footer="null" width="640px">
<a-descriptions v-if="current" :column="1" bordered size="small">
<a-descriptions-item label="类型">{{ typeLabel(current.type) }}</a-descriptions-item>
<a-descriptions-item label="标题">{{ current.title }}</a-descriptions-item>
<a-descriptions-item label="内容">{{ current.content || '-' }}</a-descriptions-item>
<a-descriptions-item label="接收人ID">{{ current.userId ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="发送人">{{ current.senderName || '-' }}</a-descriptions-item>
<a-descriptions-item label="发送时间">{{ current.createTime }}</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import { pageNoticeLog } from '@/api/system/notice';
import type { NoticeItem } from '@/api/system/notice';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const type = ref<string>();
const searchText = ref('');
const showDetail = ref(false);
const current = ref<NoticeItem | null>(null);
const typeOptions = [
{ label: '通知', value: 'notice' },
{ label: '私信', value: 'letter' },
{ label: '待办', value: 'todo' }
];
const columns = ref<ColumnItem[]>([
{ title: '类型', key: 'type', width: 100, align: 'center' },
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
{ title: '接收人', dataIndex: 'userId', key: 'userId', width: 100, align: 'center' },
{ title: '发送人', dataIndex: 'senderName', key: 'senderName', width: 120 },
{ title: '状态', key: 'isRead', width: 90, align: 'center' },
{
title: '发送时间',
dataIndex: 'createTime',
key: 'createTime',
width: 170,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{ title: '操作', key: 'action', width: 80, align: 'center', hideInSetting: true }
]);
const datasource = ({ page, limit, where, orders }: any) => {
return pageNoticeLog({
...where,
page,
limit,
type: type.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
type.value = undefined;
searchText.value = '';
reload();
};
const openDetail = (record: NoticeItem) => {
current.value = record;
showDetail.value = true;
};
const typeLabel = (value?: string) => {
const map: Record<string, string> = { notice: '通知', letter: '私信', todo: '待办' };
return map[value ?? ''] ?? value ?? '-';
};
</script>
+203
View File
@@ -0,0 +1,203 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1100 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" @click="openEdit()">
<template #icon><plus-outlined /></template>
新增模板
</a-button>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 260px"
placeholder="编码/标题/内容"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="record.type === 'todo' ? 'orange' : record.type === 'letter' ? 'cyan' : 'blue'">
{{ typeLabel(record.type) }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '启用' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm title="确定删除这个模板吗?" @confirm="remove(record)">
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<a-modal
v-model:visible="showEdit"
:title="current?.id ? '编辑消息模板' : '新增消息模板'"
:confirm-loading="submitting"
width="640px"
@ok="save"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="标题与正文支持 ${key} 占位符,发送时由业务方传入变量替换。"
/>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="模板编码" required>
<a-input
v-model:value="form.code"
allow-clear
placeholder="如 declare.approved"
:disabled="!!current?.id"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="消息类型">
<a-select v-model:value="form.type" :options="typeOptions" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="标题" required>
<a-input v-model:value="form.title" allow-clear placeholder="支持 ${moduleName} 等占位符" />
</a-form-item>
<a-form-item label="正文">
<a-textarea v-model:value="form.content" :rows="4" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="图标">
<a-input v-model:value="form.icon" allow-clear placeholder="如 NotificationFilled" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="颜色">
<a-input v-model:value="form.color" allow-clear placeholder="如 #60B2FC" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="状态">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">停用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import {
pageNoticeTemplates,
addNoticeTemplate,
updateNoticeTemplate,
removeNoticeTemplate
} from '@/api/system/notice';
import type { NoticeTemplate } from '@/api/system/notice';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const submitting = ref(false);
const current = ref<NoticeTemplate | null>(null);
const form = ref<Partial<NoticeTemplate>>({});
const typeOptions = [
{ label: '通知', value: 'notice' },
{ label: '私信', value: 'letter' },
{ label: '待办', value: 'todo' }
];
const columns = ref<ColumnItem[]>([
{ title: '模板编码', dataIndex: 'code', key: 'code', width: 200 },
{ title: '类型', key: 'type', width: 100, align: 'center' },
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '正文', dataIndex: 'content', key: 'content', ellipsis: true },
{ title: '状态', key: 'status', width: 90, align: 'center' },
{ title: '操作', key: 'action', width: 130, fixed: 'right', align: 'center', hideInSetting: true }
]);
const datasource = ({ page, limit, where, orders }: any) => {
return pageNoticeTemplates({
...where,
page,
limit,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const openEdit = (row?: NoticeTemplate) => {
current.value = row || null;
form.value = row ? { ...row } : { type: 'notice', status: 1 };
showEdit.value = true;
};
const save = async () => {
if (!form.value.code?.trim() || !form.value.title?.trim()) {
message.warning('模板编码与标题不能为空');
return;
}
submitting.value = true;
try {
const msg = current.value?.id
? await updateNoticeTemplate(form.value as NoticeTemplate)
: await addNoticeTemplate(form.value as NoticeTemplate);
message.success(msg);
showEdit.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
submitting.value = false;
}
};
const remove = async (row: NoticeTemplate) => {
if (!row.id) return;
try {
const msg = await removeNoticeTemplate(row.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const typeLabel = (value?: string) => {
const map: Record<string, string> = { notice: '通知', letter: '私信', todo: '待办' };
return map[value ?? ''] ?? value ?? '-';
};
</script>

Some files were not shown because too many files have changed in this diff Show More