feat(user): 支持修改当前用户资料接口并更新个人中心调用
- 新增 updateSelfUser 方法,用于修改当前登录用户的个人资料 - 个人中心调用接口由原 updateUser 切换为 updateSelfUser,避免学生账号权限不足导致的保存失败 - 更新了头像保存、个人信息保存等相关调用代码,添加异常捕获和提示 - 修正环境变量 .env.development 中 API URL 配置为生产地址 - 优化审核记录排序逻辑,新增获取最新审核记录和编辑入口显示判断函数 - 调整审核状态标签使用统一排序规则,防止显示和编辑状态不一致 - 新增社团活动、社团编辑、志愿时长录入、学分记录编辑、社会实践学分申报、青马学员编辑等多个新增组件及表单功能 - 各新增功能支持表单验证、操作反馈和数据加载优化
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -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 和端口暴露
|
||||
@@ -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。
|
||||
@@ -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 可以立即启动。**
|
||||
@@ -0,0 +1,194 @@
|
||||
# 二期 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 | 当前只做数据汇总与提示,模板渲染与电子章在 P1 接入(需学校提供模板与印章图) |
|
||||
| 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」,两个调用方共用。
|
||||
@@ -0,0 +1,178 @@
|
||||
# 团委智能化平台二期实施方案
|
||||
|
||||
> 依据《副本二期功能--定稿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 数字人、移动端接口整理。
|
||||
|
||||
## 四、数据模型
|
||||
|
||||
统一规范:`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 |
|
||||
@@ -0,0 +1,392 @@
|
||||
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 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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
hours?: number;
|
||||
serviceDate?: string;
|
||||
serviceEndDate?: string;
|
||||
content?: string;
|
||||
proofFiles?: 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));
|
||||
}
|
||||
|
||||
/** 个人录入志愿时长 */
|
||||
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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,173 @@
|
||||
<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="年度志愿服务证明按「已通过审核」的志愿时长自动汇总生成。证书编号由后端按年度顺序生成,模板与电子章在 P1 阶段接入。"
|
||||
/>
|
||||
|
||||
<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="学号(管理员)"
|
||||
/>
|
||||
<a-button type="primary" :loading="loading" @click="loadData">查询</a-button>
|
||||
<a-button :disabled="!summary.totalHours" :loading="generating" @click="generate">
|
||||
生成证明
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<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">{{ projects.length }}</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="!projects.length" description="该年度暂无可生成证明的志愿记录" />
|
||||
<a-table
|
||||
v-else
|
||||
row-key="projectName"
|
||||
size="small"
|
||||
:data-source="projects"
|
||||
: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 {
|
||||
getMyVolunteerSummary,
|
||||
getMyVolunteerProjectStats,
|
||||
getMyVolunteerTotal
|
||||
} 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 projects = 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' },
|
||||
{ title: '组织单位', dataIndex: 'orgName', key: 'orgName', width: 260 },
|
||||
{ title: '时长', dataIndex: 'hours', key: 'hours', width: 100, align: 'center' },
|
||||
{ title: '记录数', dataIndex: 'count', key: 'count', width: 100, align: 'center' }
|
||||
];
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [summaryData, projectData, total] = await Promise.all([
|
||||
getMyVolunteerSummary(),
|
||||
getMyVolunteerProjectStats(year.value),
|
||||
getMyVolunteerTotal(year.value)
|
||||
]);
|
||||
summary.value = { ...summaryData, totalHours: total ?? 0 };
|
||||
projects.value = projectData || [];
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载志愿数据失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const generate = () => {
|
||||
generating.value = true;
|
||||
// 证明文件由后端模板渲染,P0 先给出数据核对结果,文件下载在模板接入后开放
|
||||
setTimeout(() => {
|
||||
generating.value = false;
|
||||
message.info(
|
||||
`已汇总 ${year.value} 年 ${summary.value.totalHours ?? 0} 小时志愿时长,证明材料模板接入后可在此下载 PDF`
|
||||
);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.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,142 @@
|
||||
<!-- 志愿时长录入 -->
|
||||
<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="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>
|
||||
</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 MultiFileUpload from '@/components/MultiFileUpload/index.vue';
|
||||
import { submitVolunteerRecord } from '@/api/growth/volunteer';
|
||||
import type { VolunteerRecord } from '@/api/growth/volunteer';
|
||||
|
||||
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<VolunteerRecord>({});
|
||||
|
||||
const serviceDate = ref<any>();
|
||||
const serviceEndDate = ref<any>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const updateVisible = (value: boolean) => emit('update:visible', value);
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,88 @@
|
||||
<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: '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,
|
||||
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>
|
||||
@@ -0,0 +1,391 @@
|
||||
<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: 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-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 && 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.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,
|
||||
getMyVolunteerSummary
|
||||
} from '@/api/growth/volunteer';
|
||||
import type { VolunteerRecord } from '@/api/growth/volunteer';
|
||||
import { hasPermission, hasAnyRole } from '@/utils/permission';
|
||||
|
||||
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: '时长', 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>
|
||||
@@ -0,0 +1,208 @@
|
||||
<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"
|
||||
width="640px"
|
||||
@ok="submitReview"
|
||||
>
|
||||
<a-descriptions v-if="current" :column="1" bordered size="small">
|
||||
<a-descriptions-item label="姓名">{{ current.studentName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="学号">{{ current.studentNo }}</a-descriptions-item>
|
||||
<a-descriptions-item label="志愿项目">{{ current.projectName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="组织单位">{{ current.orgName || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务时长">{{ current.hours }} 小时</a-descriptions-item>
|
||||
<a-descriptions-item label="服务日期">
|
||||
{{ current.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-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 { onMounted, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { EleProTable } 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
|
||||
} from '@/api/growth/volunteer';
|
||||
import type { VolunteerRecord } from '@/api/growth/volunteer';
|
||||
|
||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||
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 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 = (record: VolunteerRecord) => {
|
||||
current.value = record;
|
||||
reviewStatus.value = record.status === 2 ? 2 : 1;
|
||||
reviewRemark.value = record.reviewRemark || '';
|
||||
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 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;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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,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>
|
||||
@@ -0,0 +1,404 @@
|
||||
<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 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" />
|
||||
|
||||
<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>
|
||||
|
||||
<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 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 showMaterial = ref(false);
|
||||
const showScoreModal = ref(false);
|
||||
const current = ref<QmgcStudent | null>(null);
|
||||
const detail = 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: 180, 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 || '加载档案失败');
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 || ' '}</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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -263,7 +263,7 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import type { User } from '@/api/system/user/model';
|
||||
import { updateUser } from '@/api/system/user';
|
||||
import { updateSelfUser } from '@/api/system/user';
|
||||
import SexSelect from './components/sex-select.vue';
|
||||
import { getMobile } from '@/utils/common';
|
||||
import request from '@/utils/request';
|
||||
@@ -378,10 +378,17 @@
|
||||
form.province = city.value[0];
|
||||
form.city = city.value[1];
|
||||
form.region = city.value[2];
|
||||
updateUser(form).then((res) => {
|
||||
loading.value = false;
|
||||
message.success('保存成功');
|
||||
});
|
||||
// 个人中心走 /system/user/self:学生账号没有 sys:user:update 权限,
|
||||
// 用管理员接口会直接 403
|
||||
updateSelfUser(form)
|
||||
.then(() => {
|
||||
loading.value = false;
|
||||
message.success('保存成功');
|
||||
})
|
||||
.catch((e: any) => {
|
||||
loading.value = false;
|
||||
message.error(e?.message || '保存失败');
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
@@ -397,10 +404,17 @@
|
||||
console.log(res.data.data.path);
|
||||
form.avatar = FILE_SERVER + res.data.data.path;
|
||||
visible.value = false;
|
||||
updateUser(form).then((res) => {
|
||||
loading.value = false;
|
||||
message.success('保存成功');
|
||||
});
|
||||
// 个人中心走 /system/user/self:学生账号没有 sys:user:update 权限,
|
||||
// 用管理员接口会直接 403
|
||||
updateSelfUser(form)
|
||||
.then(() => {
|
||||
loading.value = false;
|
||||
message.success('保存成功');
|
||||
})
|
||||
.catch((e: any) => {
|
||||
loading.value = false;
|
||||
message.error(e?.message || '保存失败');
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
@@ -412,7 +426,7 @@
|
||||
console.log(result);
|
||||
form.avatar = result;
|
||||
visible.value = false;
|
||||
updateUser(form).then((res) => {
|
||||
updateSelfUser(form).then(() => {
|
||||
loading.value = false;
|
||||
message.success('保存成功');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user