forked from b2894lxlx/tuanwei-admin
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c45ff79f4 | |||
| 567e4aabdb | |||
| 1c3ea8ec77 | |||
| 0444846422 | |||
| 42fddc5c85 | |||
| 6d19cb5a05 | |||
| 4b810a731c | |||
| 11e16ca311 | |||
| 3888438694 | |||
| c81e24c977 | |||
| 92d6bdf811 | |||
| fd443aced3 | |||
| 35e527ce53 | |||
| 2233b2c0e3 | |||
| 6ef7bce5ad |
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
VITE_APP_NAME=后台管理系统
|
VITE_APP_NAME=后台管理系统
|
||||||
VITE_API_URL=http://127.0.0.1:38927/api
|
#VITE_API_URL=http://127.0.0.1:38927/api
|
||||||
#VITE_API_URL=https://tuanwei-api.gxwebsoft.com/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,251 @@
|
|||||||
|
# 二期 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」,两个调用方共用。
|
||||||
|
|
||||||
|
|
||||||
|
## 九、P1 首批交付:社会实践学分 + 学分预警
|
||||||
|
|
||||||
|
### 9.1 社会实践学分
|
||||||
|
|
||||||
|
**后端**(`com.gxwebsoft.gxmu.credit`)
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `PracticeCreditController` | 提交/撤回/作废/删除/汇总/导出 |
|
||||||
|
| `PracticeCreditService` + `Impl` | 校验、状态回写、**通过后自动发放学分** |
|
||||||
|
| `PracticeCreditApplyParam` | 查询参数 |
|
||||||
|
|
||||||
|
**审核接入**:模块名 `gxmu_practice_credit`,走既有 `gxmu_review_list` 引擎,四级审核(个人 → 班级 → 二级学院团委 → 校团委)在「审核流配置」里绑定即可。`ReviewListController` 审核完成后回调:
|
||||||
|
|
||||||
|
1. `syncReviewStatus` 回写申报状态;
|
||||||
|
2. `grantCreditWhenApproved` 在通过时写一条 `gxmu_credit_record`(`source=practice`、`external_id=practice-{id}`,幂等)。
|
||||||
|
|
||||||
|
**教务处模板导出**:`POST /api/gxmu/practice-credit/export`,输出 14 列 xls(序号/学号/姓名/学院/班级/学年/活动名称/活动类型/起止日期/实践内容/担任角色/核定学分/审核状态),走既有「生成文件 → FileRecord 入库 → 返回下载地址」模式。
|
||||||
|
|
||||||
|
**前端**:`/growth/practiceCredit`,替换原占位页。
|
||||||
|
|
||||||
|
### 9.2 学分预警
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `CreditWarningService` + `Impl` | 缺口计算、级别判定、月度去重推送 |
|
||||||
|
| `CreditWarningController` | 列表/汇总/重算/推送 |
|
||||||
|
| `CreditWarningTask`(新增) | 每天 02:30 全量重算,`gxmu.credit-warning.enabled` 可关 |
|
||||||
|
|
||||||
|
**级别规则**:`gap/required ≥ 0.6 且 gap ≥ 2` → danger;`≥ 0.3` → warn;其余 normal。
|
||||||
|
叠加绝对门槛是因为只看比例会把「应修 1 分、缺 1 分」也判成严重。
|
||||||
|
|
||||||
|
**去重**:同一学生同一类别每个自然月只推一条站内消息(`notify_month` 记录)。
|
||||||
|
|
||||||
|
**闭环**:补分后重算会自动清理已达成类别的预警(逻辑删除),不会永远挂着。
|
||||||
|
|
||||||
|
**前端**:`/growth/creditWarning`,替换原占位页。
|
||||||
|
|
||||||
|
### 9.3 验证记录
|
||||||
|
|
||||||
|
| 场景 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 社会实践缺附件提交 | 拒绝「请上传社会实践证明材料」✓ |
|
||||||
|
| 正常提交 → 生成审核节点 | ✓ |
|
||||||
|
| 审核通过 → 申报状态置 2 | ✓ |
|
||||||
|
| 审核通过 → 学分自动入账(2.00,source=practice) | ✓ |
|
||||||
|
| 重复审核同一条 → 学分不重复发 | ✓(仍 1 条 2.00) |
|
||||||
|
| 学分类别缺口识别 | 6 类全部识别 ✓ |
|
||||||
|
| 补分后重算 → 该类别预警清除 | ✓(有效预警 6→5) |
|
||||||
|
| 级别判定 | 缺 2 分 danger、缺 1 分 warn ✓ |
|
||||||
|
| 预警推送 → 第一次 5 条、第二次 0 条 | 月度去重生效 ✓ |
|
||||||
|
|
||||||
|
### 9.4 修复记录
|
||||||
|
|
||||||
|
初版曾把 `gxmu_practice_credit` 加进 `DeclareGuardServiceImpl` 的模块列表,导致审核回写时按「模块名=表名」去更新不存在的表。已移除——社会实践用独立表 `gxmu_practice_credit_apply`,状态回写由自己的 service 负责。
|
||||||
@@ -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,34 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { ApiResult } from '@/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤回申报:待审核 -> 草稿
|
||||||
|
*/
|
||||||
|
export async function withdrawDeclareRecord(module: string, id: number) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>(
|
||||||
|
'/gxmu/declare-record/withdraw',
|
||||||
|
{ module, id }
|
||||||
|
);
|
||||||
|
if (res.data.code === 0) {
|
||||||
|
return res.data.message;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作废申报(等同强制放行,作废后申报人可重新申报)
|
||||||
|
*/
|
||||||
|
export async function voidDeclareRecord(
|
||||||
|
module: string,
|
||||||
|
id: number,
|
||||||
|
reason?: string
|
||||||
|
) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>(
|
||||||
|
'/gxmu/declare-record/void',
|
||||||
|
{ module, id, reason }
|
||||||
|
);
|
||||||
|
if (res.data.code === 0) {
|
||||||
|
return res.data.message;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
@@ -36,6 +36,18 @@ export interface Declare {
|
|||||||
createTime?: string;
|
createTime?: string;
|
||||||
// 更新时间
|
// 更新时间
|
||||||
updateTime?: string;
|
updateTime?: string;
|
||||||
|
// 是否允许重复提交:0否 1是
|
||||||
|
allowRepeat?: number;
|
||||||
|
// 最多同时进行中的申报条数
|
||||||
|
maxPending?: number;
|
||||||
|
// 当前用户在该项目下的最新申报状态:0草稿 1待审核 2通过 3驳回 4作废
|
||||||
|
myStatus?: number;
|
||||||
|
// 当前用户在该项目下的最新申报记录id
|
||||||
|
myRecordId?: number;
|
||||||
|
// 当前用户在该项目下的最新申报记录提交时间
|
||||||
|
mySubmitTime?: string;
|
||||||
|
// 当前用户在该项目下的最新申报记录状态备注
|
||||||
|
myStatusRemark?: string;
|
||||||
// 删除标记:0未删 1已删
|
// 删除标记:0未删 1已删
|
||||||
deleted?: number;
|
deleted?: number;
|
||||||
// 是否可申报
|
// 是否可申报
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface ReviewList {
|
|||||||
businessName?: string;
|
businessName?: string;
|
||||||
// 主键
|
// 主键
|
||||||
pk?: number;
|
pk?: number;
|
||||||
// 状态(0不通过 1通过)
|
// 状态(0驳回 1通过)
|
||||||
status?: number;
|
status?: number;
|
||||||
// 审核意见
|
// 审核意见
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface FileRecordParam extends PageParam {
|
|||||||
name?: string;
|
name?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
|
// 媒体大类: image/video/audio/doc,空表示不限
|
||||||
|
mediaCategory?: string;
|
||||||
createNickname?: string;
|
createNickname?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|||||||
@@ -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));
|
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>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
* 所以打开编辑弹窗时先把非 HTML 的正文转成 HTML,保存时统一写 `contentFormat: 'html'`。
|
* 所以打开编辑弹窗时先把非 HTML 的正文转成 HTML,保存时统一写 `contentFormat: 'html'`。
|
||||||
*/
|
*/
|
||||||
import { getProcessor } from 'bytemd';
|
import { getProcessor } from 'bytemd';
|
||||||
|
import type { BytemdPlugin } from 'bytemd';
|
||||||
|
|
||||||
const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i;
|
const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i;
|
||||||
const MARKDOWN_LINE_RE =
|
const MARKDOWN_LINE_RE =
|
||||||
@@ -57,11 +58,20 @@ export function detectFormat(content?: string | null): ContentFormat {
|
|||||||
return looksLikeMarkdown(text) ? 'markdown' : 'text';
|
return looksLikeMarkdown(text) ? 'markdown' : 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。 */
|
/**
|
||||||
export function markdownToHtml(markdown: string): string {
|
* markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。
|
||||||
|
*
|
||||||
|
* `plugins` 决定润色到什么程度:默认不挂 GFM,表格会原样漏成一堆 `|` 管道符、
|
||||||
|
* 删除线也会丢。要让「表格/删除线」和预览一致,调用方传 gfm 插件进来
|
||||||
|
* (generate 页的一键排版就是这么做的)。转换口径只留这一处,别再各写一套。
|
||||||
|
*/
|
||||||
|
export function markdownToHtml(
|
||||||
|
markdown: string,
|
||||||
|
plugins: BytemdPlugin[] = []
|
||||||
|
): string {
|
||||||
try {
|
try {
|
||||||
// processSync 返回的是 vfile(`toString()` 即 HTML),不是裸字符串
|
// processSync 返回的是 vfile(`toString()` 即 HTML),不是裸字符串
|
||||||
const result = getProcessor({}).processSync(String(markdown ?? ''));
|
const result = getProcessor({ plugins }).processSync(String(markdown ?? ''));
|
||||||
return String(result);
|
return String(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('markdown 转换失败', e);
|
console.error('markdown 转换失败', e);
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import type { Component } from 'vue';
|
||||||
|
import {
|
||||||
|
CustomerServiceOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
FileMarkdownOutlined,
|
||||||
|
FilePdfOutlined,
|
||||||
|
FilePptOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
FileUnknownOutlined,
|
||||||
|
FileWordOutlined,
|
||||||
|
VideoCameraOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体大类。
|
||||||
|
*
|
||||||
|
* 注意它和 `contentType`(MIME 字符串)不是一回事:MIME 里「文档」没有统一前缀
|
||||||
|
* (pdf/docx/zip 都是 application/…),所以大类由 MIME 前缀 + 后缀一起判定。
|
||||||
|
*/
|
||||||
|
export type MediaKind = 'image' | 'video' | 'audio' | 'doc';
|
||||||
|
|
||||||
|
/** 与后端 UploadRules 的白名单保持一致,改这里要同步改后端 */
|
||||||
|
export const MEDIA_ACCEPT = [
|
||||||
|
'image/*',
|
||||||
|
'.pdf',
|
||||||
|
'.doc',
|
||||||
|
'.docx',
|
||||||
|
'.xls',
|
||||||
|
'.xlsx',
|
||||||
|
'.ppt',
|
||||||
|
'.pptx',
|
||||||
|
'.txt',
|
||||||
|
'.md',
|
||||||
|
'.csv',
|
||||||
|
'.mp3',
|
||||||
|
'.wav',
|
||||||
|
'.m4a',
|
||||||
|
'.aac',
|
||||||
|
'.mp4',
|
||||||
|
'.mov',
|
||||||
|
'.webm'
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const DOC_SUFFIXES = [
|
||||||
|
'pdf',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'xls',
|
||||||
|
'xlsx',
|
||||||
|
'ppt',
|
||||||
|
'pptx',
|
||||||
|
'txt',
|
||||||
|
'md',
|
||||||
|
'csv'
|
||||||
|
];
|
||||||
|
const VIDEO_SUFFIXES = ['mp4', 'mov', 'webm'];
|
||||||
|
const AUDIO_SUFFIXES = ['mp3', 'wav', 'm4a', 'aac'];
|
||||||
|
|
||||||
|
/** 图片大小上限(MB),其余类型上限(MB),受后端 multipart 100MB 硬顶约束 */
|
||||||
|
export const MEDIA_MAX_MB_IMAGE = 10;
|
||||||
|
export const MEDIA_MAX_MB_OTHER = 100;
|
||||||
|
|
||||||
|
/** 类型筛选项,value 为空表示不限 */
|
||||||
|
export const MEDIA_CATEGORY_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: '', label: '全部类型' },
|
||||||
|
{ value: 'image', label: '图片' },
|
||||||
|
{ value: 'doc', label: '文档' },
|
||||||
|
{ value: 'audio', label: '音频' },
|
||||||
|
{ value: 'video', label: '视频' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MEDIA_KIND_LABEL: Record<MediaKind, string> = {
|
||||||
|
image: '图片',
|
||||||
|
video: '视频',
|
||||||
|
audio: '音频',
|
||||||
|
doc: '文档'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 取小写后缀,不带点 */
|
||||||
|
export function mediaSuffix(name?: string | null): string {
|
||||||
|
if (!name || name.lastIndexOf('.') < 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return name.substring(name.lastIndexOf('.') + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由 MIME + 文件名判定大类 */
|
||||||
|
export function mediaKindOf(
|
||||||
|
contentType?: string | null,
|
||||||
|
name?: string | null
|
||||||
|
): MediaKind {
|
||||||
|
const mime = (contentType || '').toLowerCase();
|
||||||
|
if (mime.startsWith('image/')) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
if (mime.startsWith('video/')) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
if (mime.startsWith('audio/')) {
|
||||||
|
return 'audio';
|
||||||
|
}
|
||||||
|
const suffix = mediaSuffix(name);
|
||||||
|
if (VIDEO_SUFFIXES.includes(suffix)) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
if (AUDIO_SUFFIXES.includes(suffix)) {
|
||||||
|
return 'audio';
|
||||||
|
}
|
||||||
|
return 'doc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由文件记录判定大类 */
|
||||||
|
export function mediaKindOfRecord(record?: FileRecord | null): MediaKind {
|
||||||
|
return mediaKindOf(record?.contentType, record?.path || record?.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传前校验,通过返回 null,不通过返回给用户看的错误文案。
|
||||||
|
* 与后端同规则:图片按 MIME 认,其余按后缀白名单认。
|
||||||
|
*/
|
||||||
|
export function validateMediaFile(file: File): string | null {
|
||||||
|
const mime = (file.type || '').toLowerCase();
|
||||||
|
const suffix = mediaSuffix(file.name);
|
||||||
|
const isImage = mime.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
const allowed =
|
||||||
|
DOC_SUFFIXES.includes(suffix) ||
|
||||||
|
VIDEO_SUFFIXES.includes(suffix) ||
|
||||||
|
AUDIO_SUFFIXES.includes(suffix);
|
||||||
|
if (!allowed) {
|
||||||
|
return '不支持的文件格式,仅支持图片、文档、音频、视频';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const maxMb = isImage ? MEDIA_MAX_MB_IMAGE : MEDIA_MAX_MB_OTHER;
|
||||||
|
if (file.size / 1024 / 1024 > maxMb) {
|
||||||
|
return `文件大小不能超过 ${maxMb}MB`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件大小格式化 */
|
||||||
|
export function formatFileSize(length?: number | null): string {
|
||||||
|
const size = Number(length ?? 0);
|
||||||
|
if (!size || size < 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
if (size < 1024) {
|
||||||
|
return size + 'B';
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return (size / 1024).toFixed(1) + 'KB';
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024 * 1024) {
|
||||||
|
return (size / 1024 / 1024).toFixed(1) + 'M';
|
||||||
|
}
|
||||||
|
return (size / 1024 / 1024 / 1024).toFixed(1) + 'G';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片/表格里显示的类型图标 */
|
||||||
|
export function mediaIcon(kind: MediaKind, name?: string | null): Component {
|
||||||
|
if (kind === 'audio') {
|
||||||
|
return CustomerServiceOutlined;
|
||||||
|
}
|
||||||
|
if (kind === 'video') {
|
||||||
|
return VideoCameraOutlined;
|
||||||
|
}
|
||||||
|
switch (mediaSuffix(name)) {
|
||||||
|
case 'pdf':
|
||||||
|
return FilePdfOutlined;
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
return FileWordOutlined;
|
||||||
|
case 'xls':
|
||||||
|
case 'xlsx':
|
||||||
|
case 'csv':
|
||||||
|
return FileExcelOutlined;
|
||||||
|
case 'ppt':
|
||||||
|
case 'pptx':
|
||||||
|
return FilePptOutlined;
|
||||||
|
case 'md':
|
||||||
|
return FileMarkdownOutlined;
|
||||||
|
case 'txt':
|
||||||
|
return FileTextOutlined;
|
||||||
|
default:
|
||||||
|
return FileUnknownOutlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图标色,按类型区分但都压在中低饱和,避免卡片花花绿绿 */
|
||||||
|
export function mediaColor(kind: MediaKind, name?: string | null): string {
|
||||||
|
if (kind === 'audio') {
|
||||||
|
return '#805ad5';
|
||||||
|
}
|
||||||
|
if (kind === 'video') {
|
||||||
|
return '#2b6cb0';
|
||||||
|
}
|
||||||
|
switch (mediaSuffix(name)) {
|
||||||
|
case 'pdf':
|
||||||
|
return '#e5484d';
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
return '#2b6cb0';
|
||||||
|
case 'xls':
|
||||||
|
case 'xlsx':
|
||||||
|
case 'csv':
|
||||||
|
return '#2f855a';
|
||||||
|
case 'ppt':
|
||||||
|
case 'pptx':
|
||||||
|
return '#c05621';
|
||||||
|
default:
|
||||||
|
return '#718096';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非图片卡片右下角的扩展名徽标文本 */
|
||||||
|
export function mediaExtLabel(record?: FileRecord | null): string {
|
||||||
|
const suffix = mediaSuffix(record?.path || record?.name);
|
||||||
|
return suffix ? suffix.toUpperCase() : 'FILE';
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
:color="item.status === 1 ? 'success' : item.status === 0 ? 'error' : 'default'"
|
:color="item.status === 1 ? 'success' : item.status === 0 ? 'error' : 'default'"
|
||||||
style="margin-left: 8px"
|
style="margin-left: 8px"
|
||||||
>
|
>
|
||||||
{{ item.status === 1 ? '通过' : item.status === 0 ? '不通过' : '-' }}
|
{{ item.status === 1 ? '通过' : item.status === 0 ? '驳回' : '-' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.user?.realName" style="margin-bottom: 4px">
|
<div v-if="item.user?.realName" style="margin-bottom: 4px">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<a-tag color="green">通过</a-tag>
|
<a-tag color="green">通过</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="getLastReview(record)?.status === 2">
|
<template v-else-if="getLastReview(record)?.status === 2">
|
||||||
<a-tag color="red">不通过</a-tag>
|
<a-tag color="red">驳回</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-tag>未审核</a-tag>
|
<a-tag>未审核</a-tag>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<!-- 音频 / 视频预览弹窗:素材库里非图片项的统一播放器 -->
|
||||||
|
<template>
|
||||||
|
<ele-modal
|
||||||
|
:width="kind === 'video' ? 880 : 480"
|
||||||
|
:footer="null"
|
||||||
|
:visible="visible"
|
||||||
|
:title="record?.name"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
@update:visible="updateVisible"
|
||||||
|
>
|
||||||
|
<div v-if="visible && record" class="media-preview">
|
||||||
|
<ele-xg-player
|
||||||
|
v-if="kind === 'video'"
|
||||||
|
:key="'v-' + record.id"
|
||||||
|
:config="playerConfig"
|
||||||
|
/>
|
||||||
|
<div v-else class="media-preview-audio">
|
||||||
|
<customer-service-outlined class="media-preview-audio-icon" />
|
||||||
|
<div class="media-preview-audio-name ele-text-secondary">
|
||||||
|
{{ record.name }}
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
:key="'a-' + record.id"
|
||||||
|
:src="record.url"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
class="media-preview-audio-player"
|
||||||
|
></audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ele-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { CustomerServiceOutlined } from '@ant-design/icons-vue';
|
||||||
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
|
import { mediaKindOfRecord } from '@/utils/media';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
// 弹窗是否打开
|
||||||
|
visible: boolean;
|
||||||
|
// 当前预览的文件
|
||||||
|
record?: FileRecord | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', visible: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const kind = computed(() => mediaKindOfRecord(props.record));
|
||||||
|
|
||||||
|
// xgplayer 配置,key 变化时重建实例,避免沿用上一条的播放状态
|
||||||
|
const playerConfig = computed(() => ({
|
||||||
|
id: 'cmsPhotoPreviewPlayer',
|
||||||
|
lang: 'zh-cn',
|
||||||
|
fluid: true,
|
||||||
|
url: props.record?.url,
|
||||||
|
playbackRate: [0.5, 1, 1.5, 2],
|
||||||
|
pip: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
/* 更新 visible */
|
||||||
|
const updateVisible = (value: boolean) => {
|
||||||
|
emit('update:visible', value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.media-preview-audio {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-preview-audio-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #805ad5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-preview-audio-name {
|
||||||
|
margin: 12px 0 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-preview-audio-player {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<!-- 角色编辑弹窗 -->
|
<!-- 素材编辑弹窗:文件在列表页上传时已入库,这里只改元数据和分享代码 -->
|
||||||
<template>
|
<template>
|
||||||
<ele-modal
|
<ele-modal
|
||||||
:width="600"
|
:width="600"
|
||||||
:visible="visible"
|
:visible="visible"
|
||||||
:confirm-loading="loading"
|
:confirm-loading="loading"
|
||||||
:title="isUpdate ? '编辑' : '上传文件'"
|
title="编辑素材"
|
||||||
:body-style="{ paddingBottom: '8px' }"
|
:body-style="{ paddingBottom: '8px' }"
|
||||||
okText="保存"
|
okText="保存"
|
||||||
@update:visible="updateVisible"
|
@update:visible="updateVisible"
|
||||||
@@ -19,27 +19,22 @@
|
|||||||
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
|
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<a-form-item label="上传文件" name="fileName" v-if="!isUpdate">
|
<a-form-item label="文件">
|
||||||
<span
|
<div class="media-file">
|
||||||
class="ele-text-success"
|
<component
|
||||||
v-if="fileName"
|
:is="fileIcon"
|
||||||
style="margin-right: 10px"
|
class="media-file-icon"
|
||||||
>
|
:style="{ color: fileColor }"
|
||||||
{{ fileName }}
|
/>
|
||||||
</span>
|
<div class="media-file-info">
|
||||||
<a-upload
|
<div class="media-file-name" :title="record?.name">
|
||||||
:show-upload-list="false"
|
{{ record?.name }}
|
||||||
:accept="'video/*'"
|
</div>
|
||||||
v-if="!fileName"
|
<div class="ele-text-secondary media-file-sub">
|
||||||
:customRequest="onUpload"
|
{{ kindLabel }} · {{ fileSize }}
|
||||||
>
|
</div>
|
||||||
<a-button type="primary" class="ele-btn-icon">
|
</div>
|
||||||
<template #icon>
|
</div>
|
||||||
<UploadOutlined />
|
|
||||||
</template>
|
|
||||||
<span>上传文件</span>
|
|
||||||
</a-button>
|
|
||||||
</a-upload>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="文件名称" name="name">
|
<a-form-item label="文件名称" name="name">
|
||||||
<a-input
|
<a-input
|
||||||
@@ -53,17 +48,17 @@
|
|||||||
<a-textarea
|
<a-textarea
|
||||||
:rows="4"
|
:rows="4"
|
||||||
:maxlength="200"
|
:maxlength="200"
|
||||||
placeholder="图片描述"
|
placeholder="素材描述"
|
||||||
v-model:value="form.comments"
|
v-model:value="form.comments"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<div style="margin-bottom: 22px">
|
<div style="margin-bottom: 22px">
|
||||||
<a-divider orientation="left">图片分享</a-divider>
|
<a-divider orientation="left">{{ shareTitle }}</a-divider>
|
||||||
</div>
|
</div>
|
||||||
<a-form-item label="图片URL链接">
|
<a-form-item label="素材链接">
|
||||||
<a-input v-model:value="share.url" @click="copyText(share.url)" />
|
<a-input v-model:value="share.url" @click="copyText(share.url)" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="网页代码(HTML)">
|
<a-form-item label="网页代码(HTML)" v-if="share.html">
|
||||||
<a-input v-model:value="share.html" @click="copyText(share.html)" />
|
<a-input v-model:value="share.html" @click="copyText(share.html)" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Markdown">
|
<a-form-item label="Markdown">
|
||||||
@@ -72,38 +67,36 @@
|
|||||||
@click="copyText(share.Markdown)"
|
@click="copyText(share.Markdown)"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="论坛BBCode">
|
<a-form-item label="论坛BBCode" v-if="share.bbCode">
|
||||||
<a-input v-model:value="share.bbCode" @click="copyText(share.bbCode)" />
|
<a-input v-model:value="share.bbCode" @click="copyText(share.bbCode)" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="下载地址">
|
||||||
<!-- <a-form-item label="封面图" name="images">-->
|
<a-input
|
||||||
<!-- <ele-image-upload-->
|
v-model:value="share.download"
|
||||||
<!-- v-model:value="images"-->
|
@click="copyText(share.download)"
|
||||||
<!-- :limit="1"-->
|
/>
|
||||||
<!-- :drag="true"-->
|
</a-form-item>
|
||||||
<!-- :item-style="{ width: '60px', height: '60px' }"-->
|
|
||||||
<!-- :accept="'image/*'"-->
|
|
||||||
<!-- :upload-handler="uploadHandler"-->
|
|
||||||
<!-- @upload="onUpload"-->
|
|
||||||
<!-- />-->
|
|
||||||
<!-- </a-form-item>-->
|
|
||||||
</a-form>
|
</a-form>
|
||||||
</ele-modal>
|
</ele-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { message } from 'ant-design-vue/es';
|
import { message } from 'ant-design-vue/es';
|
||||||
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import useFormData from '@/utils/use-form-data';
|
import useFormData from '@/utils/use-form-data';
|
||||||
import type { FileRecord } from '@/api/system/file/model';
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
import { messageLoading } from 'ele-admin-pro';
|
import { updateFiles } from '@/api/system/file';
|
||||||
import { addFiles, updateFiles, uploadFile } from '@/api/system/file';
|
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
|
||||||
import { RuleObject } from 'ant-design-vue/es/form';
|
|
||||||
import { copyText } from '@/utils/common';
|
import { copyText } from '@/utils/common';
|
||||||
|
import {
|
||||||
|
formatFileSize,
|
||||||
|
mediaColor,
|
||||||
|
mediaIcon,
|
||||||
|
mediaKindOfRecord,
|
||||||
|
MEDIA_KIND_LABEL
|
||||||
|
} from '@/utils/media';
|
||||||
|
|
||||||
// 是否开启响应式布局
|
// 是否开启响应式布局
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
@@ -121,20 +114,11 @@
|
|||||||
data?: FileRecord | null;
|
data?: FileRecord | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
//
|
|
||||||
const formRef = ref<FormInstance | null>(null);
|
const formRef = ref<FormInstance | null>(null);
|
||||||
const fileName = ref('');
|
// 当前编辑的原始记录,分享代码依赖它的 url,不随表单变化
|
||||||
// 是否是修改
|
const record = ref<FileRecord | null>(null);
|
||||||
const isUpdate = ref(false);
|
|
||||||
// 提交状态
|
// 提交状态
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
// 图片资源分享
|
|
||||||
const share = ref({
|
|
||||||
url: '',
|
|
||||||
Markdown: '',
|
|
||||||
html: '',
|
|
||||||
bbCode: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const { form, resetFields, assignFields } = useFormData<FileRecord>({
|
const { form, resetFields, assignFields } = useFormData<FileRecord>({
|
||||||
@@ -143,22 +127,49 @@
|
|||||||
comments: ''
|
comments: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const kind = computed(() => mediaKindOfRecord(record.value));
|
||||||
|
const kindLabel = computed(() => MEDIA_KIND_LABEL[kind.value]);
|
||||||
|
const fileSize = computed(() => formatFileSize(record.value?.length));
|
||||||
|
const fileIcon = computed(() =>
|
||||||
|
mediaIcon(kind.value, record.value?.path || record.value?.name)
|
||||||
|
);
|
||||||
|
const fileColor = computed(() =>
|
||||||
|
mediaColor(kind.value, record.value?.path || record.value?.name)
|
||||||
|
);
|
||||||
|
// 文档给的是链接与下载地址,图片/音视频给的是嵌入代码
|
||||||
|
const shareTitle = computed(() =>
|
||||||
|
kind.value === 'doc' ? '素材链接' : '素材分享'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分享代码按类型生成:图片沿用 img 系列,音视频给 video/audio 标签,
|
||||||
|
* 文档只给直链和下载地址(不提供 HTML 嵌入)。
|
||||||
|
*/
|
||||||
|
const share = computed(() => {
|
||||||
|
const data = record.value;
|
||||||
|
const url = data?.url || '';
|
||||||
|
const name = data?.name || '';
|
||||||
|
const result = {
|
||||||
|
url,
|
||||||
|
html: '',
|
||||||
|
Markdown: `[${name}](${url})`,
|
||||||
|
bbCode: `[url=${url}]${name}[/url]`,
|
||||||
|
download: data?.downloadUrl || ''
|
||||||
|
};
|
||||||
|
if (kind.value === 'image') {
|
||||||
|
result.html = `<a href="${url}"><img src="${url}" alt="${name}" border="0" /></a>`;
|
||||||
|
result.Markdown = `[](${url})`;
|
||||||
|
result.bbCode = `[url=${url}][img]${url}[/img][/url]`;
|
||||||
|
} else if (kind.value === 'video') {
|
||||||
|
result.html = `<video src="${url}" controls></video>`;
|
||||||
|
} else if (kind.value === 'audio') {
|
||||||
|
result.html = `<audio src="${url}" controls></audio>`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
const rules = reactive<Record<string, Rule[]>>({
|
const rules = reactive<Record<string, Rule[]>>({
|
||||||
fileName: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: '请上传文件',
|
|
||||||
type: 'string',
|
|
||||||
trigger: 'blur',
|
|
||||||
validator: async (_rule: RuleObject) => {
|
|
||||||
if (!isUpdate.value && fileName.value.length == 0) {
|
|
||||||
return Promise.reject('请上传文件');
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
name: [
|
name: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
@@ -169,39 +180,6 @@
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// 上传文件
|
|
||||||
const onUpload = (item) => {
|
|
||||||
const { file } = item;
|
|
||||||
if (!file.type.startsWith('video')) {
|
|
||||||
message.error('文件格式不正确!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 > 100) {
|
|
||||||
message.error('大小不能超过 100MB');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hide = messageLoading({
|
|
||||||
content: '上传中..',
|
|
||||||
duration: 0,
|
|
||||||
mask: true
|
|
||||||
});
|
|
||||||
uploadFile(file)
|
|
||||||
.then((data) => {
|
|
||||||
hide();
|
|
||||||
fileName.value = String(data.name);
|
|
||||||
// images.value.push({
|
|
||||||
// uid: data.id,
|
|
||||||
// url: FILE_THUMBNAIL + data.path,
|
|
||||||
// status: 'done'
|
|
||||||
// });
|
|
||||||
message.success('上传成功');
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
message.error(e.message);
|
|
||||||
hide();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/* 保存编辑 */
|
/* 保存编辑 */
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (!formRef.value) {
|
if (!formRef.value) {
|
||||||
@@ -211,8 +189,7 @@
|
|||||||
.validate()
|
.validate()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const saveOrUpdate = isUpdate.value ? updateFiles : addFiles;
|
updateFiles(form)
|
||||||
saveOrUpdate(form)
|
|
||||||
.then((msg) => {
|
.then((msg) => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
message.success(msg);
|
message.success(msg);
|
||||||
@@ -236,21 +213,44 @@
|
|||||||
() => props.visible,
|
() => props.visible,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
record.value = props.data ?? null;
|
||||||
if (props.data) {
|
if (props.data) {
|
||||||
assignFields(props.data);
|
assignFields(props.data);
|
||||||
// 图片分享代码
|
|
||||||
share.value.url = `${props.data.url}`;
|
|
||||||
share.value.html = `<a href="${props.data.url}"><img src="${props.data.url}" alt="${props.data.name}" border="0" /></a>`;
|
|
||||||
share.value.Markdown = `[](${props.data.url})`;
|
|
||||||
share.value.bbCode = `[url=${props.data.url}][img]${props.data.url}[/img][/url]`;
|
|
||||||
isUpdate.value = true;
|
|
||||||
} else {
|
|
||||||
isUpdate.value = false;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
record.value = null;
|
||||||
resetFields();
|
resetFields();
|
||||||
formRef.value?.clearValidate();
|
formRef.value?.clearValidate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.media-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-file-icon {
|
||||||
|
flex: none;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-file-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-file-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-file-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+131
-28
@@ -1,9 +1,10 @@
|
|||||||
|
<!-- 素材库 · 网格视图 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<a-row :gutter="20">
|
<a-row :gutter="20">
|
||||||
<a-col
|
<a-col
|
||||||
v-for="(item, index) in data"
|
v-for="item in data"
|
||||||
:key="index"
|
:key="item.id"
|
||||||
v-bind="
|
v-bind="
|
||||||
styleResponsive
|
styleResponsive
|
||||||
? { xl: 4, lg: 8, md: 12, sm: 12, xs: 24 }
|
? { xl: 4, lg: 8, md: 12, sm: 12, xs: 24 }
|
||||||
@@ -12,10 +13,33 @@
|
|||||||
>
|
>
|
||||||
<a-card :bordered="false" hoverable style="margin-top: 16px">
|
<a-card :bordered="false" hoverable style="margin-top: 16px">
|
||||||
<template #cover>
|
<template #cover>
|
||||||
<a-image :src="item.url" alt="" />
|
<!-- 统一 4:3 封面盒子:图片裁剪填充,其余类型放类型图标 -->
|
||||||
|
<div class="media-cover" @click="openPreview(item)">
|
||||||
|
<a-image
|
||||||
|
v-if="kindOf(item) === 'image'"
|
||||||
|
class="media-cover-image"
|
||||||
|
:src="item.thumbnail || item.url"
|
||||||
|
:preview="{ src: item.url }"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<component
|
||||||
|
:is="iconOf(item)"
|
||||||
|
class="media-cover-icon"
|
||||||
|
:style="{ color: colorOf(item) }"
|
||||||
|
/>
|
||||||
|
<span class="media-cover-ext">{{ extOf(item) }}</span>
|
||||||
|
<play-circle-filled
|
||||||
|
v-if="kindOf(item) === 'video'"
|
||||||
|
class="media-cover-play"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-card-meta :title="item.name" @click="openEdit(item)">
|
<a-card-meta :title="item.name" @click="openEdit(item)">
|
||||||
<template #description>
|
<template #description>
|
||||||
|
<div class="media-meta">
|
||||||
|
{{ labelOf(item) }} · {{ sizeOf(item) }}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="project-list-desc"
|
class="project-list-desc"
|
||||||
v-if="item.comments"
|
v-if="item.comments"
|
||||||
@@ -41,58 +65,85 @@
|
|||||||
</a-row>
|
</a-row>
|
||||||
<!-- 编辑弹窗 -->
|
<!-- 编辑弹窗 -->
|
||||||
<PhotoEdit v-model:visible="showEdit" :data="current" @done="query" />
|
<PhotoEdit v-model:visible="showEdit" :data="current" @done="query" />
|
||||||
|
<!-- 音视频预览 -->
|
||||||
|
<MediaPreview v-model:visible="showPreview" :record="current" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { PlayCircleFilled } from '@ant-design/icons-vue';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { FileRecord, FileRecordParam } from '@/api/system/file/model';
|
import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
|
||||||
import PhotoEdit from './components/photo-edit.vue';
|
import PhotoEdit from './components/photo-edit.vue';
|
||||||
|
import MediaPreview from './components/media-preview.vue';
|
||||||
import { timeAgo } from 'ele-admin-pro';
|
import { timeAgo } from 'ele-admin-pro';
|
||||||
|
import {
|
||||||
|
formatFileSize,
|
||||||
|
mediaColor,
|
||||||
|
mediaExtLabel,
|
||||||
|
mediaIcon,
|
||||||
|
mediaKindOfRecord,
|
||||||
|
MEDIA_KIND_LABEL,
|
||||||
|
type MediaKind
|
||||||
|
} from '@/utils/media';
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
// 搜索关键字
|
// 列表数据
|
||||||
searchText: string;
|
data?: FileRecord[];
|
||||||
// 搜索条件
|
// 搜索条件
|
||||||
where?: FileRecordParam | null;
|
where?: FileRecordParam | null;
|
||||||
// 修改回显的数据
|
|
||||||
data?: FileRecord | null;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// 当前编辑数据
|
|
||||||
const current = ref<FileRecord | null>(null);
|
|
||||||
// 是否显示编辑弹窗
|
|
||||||
const showEdit = ref(false);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'done'): void;
|
(e: 'done'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// 当前操作的数据(编辑 / 预览共用)
|
||||||
|
const current = ref<FileRecord | null>(null);
|
||||||
|
// 是否显示编辑弹窗
|
||||||
|
const showEdit = ref(false);
|
||||||
|
// 是否显示音视频预览
|
||||||
|
const showPreview = ref(false);
|
||||||
|
|
||||||
|
// 是否开启响应式布局
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
const { styleResponsive } = storeToRefs(themeStore);
|
||||||
|
|
||||||
|
const kindOf = (item: FileRecord): MediaKind => mediaKindOfRecord(item);
|
||||||
|
const iconOf = (item: FileRecord) =>
|
||||||
|
mediaIcon(kindOf(item), item.path || item.name);
|
||||||
|
const colorOf = (item: FileRecord) =>
|
||||||
|
mediaColor(kindOf(item), item.path || item.name);
|
||||||
|
const extOf = (item: FileRecord) => mediaExtLabel(item);
|
||||||
|
const labelOf = (item: FileRecord) => MEDIA_KIND_LABEL[kindOf(item)];
|
||||||
|
const sizeOf = (item: FileRecord) => formatFileSize(item.length);
|
||||||
|
|
||||||
/* 打开编辑弹窗 */
|
/* 打开编辑弹窗 */
|
||||||
const openEdit = (row?: FileRecord) => {
|
const openEdit = (row?: FileRecord) => {
|
||||||
current.value = row ?? null;
|
current.value = row ?? null;
|
||||||
showEdit.value = true;
|
showEdit.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 是否开启响应式布局
|
/* 点击封面:图片由 a-image 自己预览,音视频开播放器,文档新窗口打开 */
|
||||||
const themeStore = useThemeStore();
|
const openPreview = (row: FileRecord) => {
|
||||||
const { styleResponsive } = storeToRefs(themeStore);
|
const kind = kindOf(row);
|
||||||
|
if (kind === 'image') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'doc') {
|
||||||
|
window.open(row.url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.value = row;
|
||||||
|
showPreview.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 刷新列表 */
|
||||||
const query = () => {
|
const query = () => {
|
||||||
emit('done');
|
emit('done');
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.data,
|
|
||||||
(visible) => {
|
|
||||||
if (visible) {
|
|
||||||
console.log(props.data);
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -102,6 +153,58 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
.media-cover {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
// 所有类型共用 4:3,混排时卡片高度才对得齐
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f6f8;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
:deep(.ant-image) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-image-img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-cover-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-cover-ext {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: #718096;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-cover-play {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 44px;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-meta {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
.project-list-desc {
|
.project-list-desc {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<a-page-header :ghost="false" title="图片库">
|
<a-page-header :ghost="false" title="素材库">
|
||||||
<div class="ele-text-secondary"> 上传、分享有价值的图片素材 </div>
|
<div class="ele-text-secondary"> 上传、分享图片、文档与音视频素材 </div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<a-tabs v-model:activeKey="activeKey">
|
<a-tabs v-model:activeKey="activeKey">
|
||||||
<a-tab-pane key="1" tab="网格模式" />
|
<a-tab-pane key="1" tab="网格模式" />
|
||||||
@@ -10,16 +10,29 @@
|
|||||||
<div style="margin-bottom: 10px">
|
<div style="margin-bottom: 10px">
|
||||||
<a-upload
|
<a-upload
|
||||||
:show-upload-list="false"
|
:show-upload-list="false"
|
||||||
:accept="'image/*'"
|
:accept="MEDIA_ACCEPT"
|
||||||
:customRequest="onUpload"
|
:customRequest="onUpload"
|
||||||
>
|
>
|
||||||
<a-button key="1" type="primary" class="ele-btn-icon">
|
<a-button key="1" type="primary" class="ele-btn-icon">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<UploadOutlined />
|
<UploadOutlined />
|
||||||
</template>
|
</template>
|
||||||
<span>上传图片</span>
|
<span>上传素材</span>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-upload>
|
</a-upload>
|
||||||
|
<a-select
|
||||||
|
v-model:value="category"
|
||||||
|
style="width: 120px; margin-left: 10px"
|
||||||
|
@change="onCategoryChange"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in MEDIA_CATEGORY_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
<a-input-search
|
<a-input-search
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 360px; margin-left: 10px"
|
style="width: 360px; margin-left: 10px"
|
||||||
@@ -68,15 +81,22 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { reactive, ref } from 'vue';
|
import { reactive, ref } from 'vue';
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||||
import { FileRecord, FileRecordParam } from '@/api/system/file/model';
|
import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
|
||||||
import Image from './image.vue';
|
import Image from './image.vue';
|
||||||
import List from './list.vue';
|
import List from './list.vue';
|
||||||
import { messageLoading } from 'ele-admin-pro';
|
import { messageLoading } from 'ele-admin-pro';
|
||||||
import { pageFiles, uploadFile } from '@/api/system/file';
|
import { pageFiles, uploadFile } from '@/api/system/file';
|
||||||
import { message } from 'ant-design-vue/es';
|
import { message } from 'ant-design-vue/es';
|
||||||
|
import {
|
||||||
|
MEDIA_ACCEPT,
|
||||||
|
MEDIA_CATEGORY_OPTIONS,
|
||||||
|
validateMediaFile
|
||||||
|
} from '@/utils/media';
|
||||||
|
|
||||||
const type = ref('name');
|
const type = ref('name');
|
||||||
const searchText = ref('');
|
const searchText = ref('');
|
||||||
|
// 媒体大类筛选,空串表示全部
|
||||||
|
const category = ref('');
|
||||||
const data = ref<FileRecord[]>([]);
|
const data = ref<FileRecord[]>([]);
|
||||||
// 当前选项卡
|
// 当前选项卡
|
||||||
const activeKey = ref('1');
|
const activeKey = ref('1');
|
||||||
@@ -94,7 +114,6 @@
|
|||||||
|
|
||||||
/* 查询数据 */
|
/* 查询数据 */
|
||||||
const query = () => {
|
const query = () => {
|
||||||
console.log('query()');
|
|
||||||
if (type.value == 'name') {
|
if (type.value == 'name') {
|
||||||
where.name = searchText.value;
|
where.name = searchText.value;
|
||||||
}
|
}
|
||||||
@@ -103,8 +122,8 @@
|
|||||||
}
|
}
|
||||||
where.page = page.value;
|
where.page = page.value;
|
||||||
where.limit = limit.value;
|
where.limit = limit.value;
|
||||||
where.contentType = 'image';
|
// 空串会被当成一个真实的筛选值,必须置空
|
||||||
console.log(where);
|
where.mediaCategory = category.value || undefined;
|
||||||
const hide = messageLoading('请求中..', 0);
|
const hide = messageLoading('请求中..', 0);
|
||||||
pageFiles(where).then((res) => {
|
pageFiles(where).then((res) => {
|
||||||
count.value = Number(res?.count);
|
count.value = Number(res?.count);
|
||||||
@@ -113,17 +132,20 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* 切换类型回到第一页 */
|
||||||
|
const onCategoryChange = () => {
|
||||||
|
page.value = 1;
|
||||||
|
query();
|
||||||
|
};
|
||||||
|
|
||||||
query();
|
query();
|
||||||
|
|
||||||
// 上传文件
|
// 上传文件
|
||||||
const onUpload = (item) => {
|
const onUpload = (item) => {
|
||||||
const { file } = item;
|
const { file } = item;
|
||||||
if (!file.type.startsWith('image')) {
|
const error = validateMediaFile(file);
|
||||||
message.error('只能选择图片');
|
if (error) {
|
||||||
return;
|
message.error(error);
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 > 10) {
|
|
||||||
message.error('大小不能超过 10MB');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hide = messageLoading({
|
const hide = messageLoading({
|
||||||
@@ -132,7 +154,7 @@
|
|||||||
mask: true
|
mask: true
|
||||||
});
|
});
|
||||||
uploadFile(file)
|
uploadFile(file)
|
||||||
.then((data) => {
|
.then(() => {
|
||||||
hide();
|
hide();
|
||||||
message.success('上传成功');
|
message.success('上传成功');
|
||||||
query();
|
query();
|
||||||
@@ -149,15 +171,3 @@
|
|||||||
name: 'PhotoIndex'
|
name: 'PhotoIndex'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.project-list-desc {
|
|
||||||
height: 44px;
|
|
||||||
line-height: 22px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
+133
-31
@@ -1,3 +1,4 @@
|
|||||||
|
<!-- 素材库 · 列表视图 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<a-card :bordered="false">
|
<a-card :bordered="false">
|
||||||
@@ -8,21 +9,21 @@
|
|||||||
:columns="columns"
|
:columns="columns"
|
||||||
:datasource="datasource"
|
:datasource="datasource"
|
||||||
v-model:selection="selection"
|
v-model:selection="selection"
|
||||||
:scroll="{ x: 800 }"
|
:scroll="{ x: 860 }"
|
||||||
cache-key="proCmsVideoTable"
|
cache-key="proCmsPhotoTable"
|
||||||
>
|
>
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-upload
|
<a-upload
|
||||||
:show-upload-list="false"
|
:show-upload-list="false"
|
||||||
:accept="'image/*'"
|
:accept="MEDIA_ACCEPT"
|
||||||
:customRequest="onUpload"
|
:customRequest="onUpload"
|
||||||
>
|
>
|
||||||
<a-button type="primary" class="ele-btn-icon">
|
<a-button type="primary" class="ele-btn-icon">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<UploadOutlined />
|
<UploadOutlined />
|
||||||
</template>
|
</template>
|
||||||
<span>上传图片</span>
|
<span>上传素材</span>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-upload>
|
</a-upload>
|
||||||
<a-button
|
<a-button
|
||||||
@@ -37,6 +38,19 @@
|
|||||||
</template>
|
</template>
|
||||||
<span>删除</span>
|
<span>删除</span>
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-select
|
||||||
|
v-model:value="category"
|
||||||
|
style="width: 120px"
|
||||||
|
@change="reload()"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in MEDIA_CATEGORY_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
<a-input-search
|
<a-input-search
|
||||||
allow-clear
|
allow-clear
|
||||||
v-model:value="searchText"
|
v-model:value="searchText"
|
||||||
@@ -60,7 +74,23 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'path'">
|
<template v-if="column.key === 'path'">
|
||||||
<a-image :src="record.url" :width="120" />
|
<!-- 与网格视图同一套 4:3 缩略图规则 -->
|
||||||
|
<div class="media-thumb" @click="openPreview(record)">
|
||||||
|
<a-image
|
||||||
|
v-if="kindOf(record) === 'image'"
|
||||||
|
:src="record.thumbnail || record.url"
|
||||||
|
:preview="{ src: record.url }"
|
||||||
|
:width="120"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<component
|
||||||
|
:is="iconOf(record)"
|
||||||
|
class="media-thumb-icon"
|
||||||
|
:style="{ color: colorOf(record) }"
|
||||||
|
/>
|
||||||
|
<span class="media-thumb-ext">{{ extOf(record) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.dataIndex === 'name'">
|
<template v-if="column.dataIndex === 'name'">
|
||||||
<span>{{ record.name }}</span>
|
<span>{{ record.name }}</span>
|
||||||
@@ -78,7 +108,7 @@
|
|||||||
<a-divider type="vertical" />
|
<a-divider type="vertical" />
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
placement="topRight"
|
placement="topRight"
|
||||||
title="确定要删除此评价吗?"
|
title="确定要删除此素材吗?"
|
||||||
@confirm="remove(record)"
|
@confirm="remove(record)"
|
||||||
>
|
>
|
||||||
<a class="ele-text-danger">删除</a>
|
<a class="ele-text-danger">删除</a>
|
||||||
@@ -90,6 +120,8 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
<!-- 编辑弹窗 -->
|
<!-- 编辑弹窗 -->
|
||||||
<PhotoEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
<PhotoEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
||||||
|
<!-- 音视频预览 -->
|
||||||
|
<MediaPreview v-model:visible="showPreview" :record="current" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -109,6 +141,7 @@
|
|||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
import { messageLoading, toDateString } from 'ele-admin-pro/es';
|
import { messageLoading, toDateString } from 'ele-admin-pro/es';
|
||||||
import PhotoEdit from './components/photo-edit.vue';
|
import PhotoEdit from './components/photo-edit.vue';
|
||||||
|
import MediaPreview from './components/media-preview.vue';
|
||||||
import {
|
import {
|
||||||
pageFiles,
|
pageFiles,
|
||||||
removeFile,
|
removeFile,
|
||||||
@@ -119,17 +152,33 @@
|
|||||||
FileRecord,
|
FileRecord,
|
||||||
FileRecordParam
|
FileRecordParam
|
||||||
} from '@/api/system/file/model/index';
|
} from '@/api/system/file/model/index';
|
||||||
|
import {
|
||||||
|
formatFileSize,
|
||||||
|
mediaColor,
|
||||||
|
mediaExtLabel,
|
||||||
|
mediaIcon,
|
||||||
|
mediaKindOfRecord,
|
||||||
|
MEDIA_ACCEPT,
|
||||||
|
MEDIA_CATEGORY_OPTIONS,
|
||||||
|
MEDIA_KIND_LABEL,
|
||||||
|
validateMediaFile,
|
||||||
|
type MediaKind
|
||||||
|
} from '@/utils/media';
|
||||||
|
|
||||||
// 表格实例
|
// 表格实例
|
||||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
// 表格选中数据
|
// 表格选中数据
|
||||||
const selection = ref<FileRecord[]>([]);
|
const selection = ref<FileRecord[]>([]);
|
||||||
// 当前编辑数据
|
// 当前操作的数据(编辑 / 预览共用)
|
||||||
const current = ref<FileRecord | null>(null);
|
const current = ref<FileRecord | null>(null);
|
||||||
// 是否显示编辑弹窗
|
// 是否显示编辑弹窗
|
||||||
const showEdit = ref(false);
|
const showEdit = ref(false);
|
||||||
|
// 是否显示音视频预览
|
||||||
|
const showPreview = ref(false);
|
||||||
const type = ref('name');
|
const type = ref('name');
|
||||||
const searchText = ref('');
|
const searchText = ref('');
|
||||||
|
// 媒体大类筛选,空串表示全部
|
||||||
|
const category = ref('');
|
||||||
// 表格列配置
|
// 表格列配置
|
||||||
const columns = ref<ColumnItem[]>([
|
const columns = ref<ColumnItem[]>([
|
||||||
{
|
{
|
||||||
@@ -141,11 +190,17 @@
|
|||||||
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
|
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '图片',
|
title: '缩略图',
|
||||||
dataIndex: 'path',
|
dataIndex: 'path',
|
||||||
width: 180,
|
width: 140,
|
||||||
key: 'path'
|
key: 'path'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'contentType',
|
||||||
|
width: 100,
|
||||||
|
customRender: ({ record }) => MEDIA_KIND_LABEL[kindOf(record)]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '文件名称',
|
title: '文件名称',
|
||||||
dataIndex: 'name'
|
dataIndex: 'name'
|
||||||
@@ -161,26 +216,16 @@
|
|||||||
sorter: true,
|
sorter: true,
|
||||||
showSorterTooltip: false,
|
showSorterTooltip: false,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
customRender: ({ text }) => {
|
customRender: ({ text }) => formatFileSize(text),
|
||||||
if (text < 1024) {
|
|
||||||
return text + 'B';
|
|
||||||
} else if (text < 1024 * 1024) {
|
|
||||||
return (text / 1024).toFixed(1) + 'KB';
|
|
||||||
} else if (text < 1024 * 1024 * 1024) {
|
|
||||||
return (text / 1024 / 1024).toFixed(1) + 'M';
|
|
||||||
} else {
|
|
||||||
return (text / 1024 / 1024 / 1024).toFixed(1) + 'G';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
width: 120
|
width: 120
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '发布者',
|
title: '上传人',
|
||||||
width: 120,
|
width: 120,
|
||||||
dataIndex: 'createNickname'
|
dataIndex: 'createNickname'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '发布时间',
|
title: '上传时间',
|
||||||
dataIndex: 'createTime',
|
dataIndex: 'createTime',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
width: 180,
|
width: 180,
|
||||||
@@ -196,6 +241,13 @@
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const kindOf = (record: FileRecord): MediaKind => mediaKindOfRecord(record);
|
||||||
|
const iconOf = (record: FileRecord) =>
|
||||||
|
mediaIcon(kindOf(record), record.path || record.name);
|
||||||
|
const colorOf = (record: FileRecord) =>
|
||||||
|
mediaColor(kindOf(record), record.path || record.name);
|
||||||
|
const extOf = (record: FileRecord) => mediaExtLabel(record);
|
||||||
|
|
||||||
// 表格数据源
|
// 表格数据源
|
||||||
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
|
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
|
||||||
where = {};
|
where = {};
|
||||||
@@ -205,7 +257,8 @@
|
|||||||
if (type.value == 'createNickname') {
|
if (type.value == 'createNickname') {
|
||||||
where.createNickname = searchText.value;
|
where.createNickname = searchText.value;
|
||||||
}
|
}
|
||||||
where.contentType = 'image';
|
// 空串会被当成一个真实的筛选值,必须置空
|
||||||
|
where.mediaCategory = category.value || undefined;
|
||||||
return pageFiles({ ...where, ...orders, page, limit });
|
return pageFiles({ ...where, ...orders, page, limit });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,6 +274,20 @@
|
|||||||
showEdit.value = true;
|
showEdit.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* 点击缩略图:音视频开播放器,文档新窗口打开 */
|
||||||
|
const openPreview = (row: FileRecord) => {
|
||||||
|
const kind = kindOf(row);
|
||||||
|
if (kind === 'image') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'doc') {
|
||||||
|
window.open(row.url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.value = row;
|
||||||
|
showPreview.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
/* 删除单个 */
|
/* 删除单个 */
|
||||||
const remove = (row: FileRecord) => {
|
const remove = (row: FileRecord) => {
|
||||||
const hide = messageLoading('请求中..', 0);
|
const hide = messageLoading('请求中..', 0);
|
||||||
@@ -266,12 +333,9 @@
|
|||||||
// 上传文件
|
// 上传文件
|
||||||
const onUpload = (item) => {
|
const onUpload = (item) => {
|
||||||
const { file } = item;
|
const { file } = item;
|
||||||
if (!file.type.startsWith('image')) {
|
const error = validateMediaFile(file);
|
||||||
message.error('只能选择图片');
|
if (error) {
|
||||||
return;
|
message.error(error);
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 > 10) {
|
|
||||||
message.error('大小不能超过 10MB');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hide = messageLoading({
|
const hide = messageLoading({
|
||||||
@@ -280,7 +344,7 @@
|
|||||||
mask: true
|
mask: true
|
||||||
});
|
});
|
||||||
uploadFile(file)
|
uploadFile(file)
|
||||||
.then((data) => {
|
.then(() => {
|
||||||
hide();
|
hide();
|
||||||
message.success('上传成功');
|
message.success('上传成功');
|
||||||
reload();
|
reload();
|
||||||
@@ -294,6 +358,44 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
name: 'VideoIndex'
|
name: 'PhotoList'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.media-thumb {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 120px;
|
||||||
|
height: 90px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f6f8;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
:deep(.ant-image),
|
||||||
|
:deep(.ant-image-img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumb-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumb-ext {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #718096;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -99,6 +99,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="青马工程"
|
module-name="青马工程"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="十佳青年岗位能手"
|
module-name="十佳青年岗位能手"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="十佳团支部书记"
|
module-name="十佳团支部书记"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
module="gxmu_tygl_form"
|
module="gxmu_tygl_form"
|
||||||
module-name="团员管理"
|
module-name="团员管理"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="挑战杯"
|
module-name="挑战杯"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="五四红旗团委"
|
module-name="五四红旗团委"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="五四红旗团支部"
|
module-name="五四红旗团支部"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="未来学术之星"
|
module-name="未来学术之星"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="优秀共青团干部"
|
module-name="优秀共青团干部"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
|
|||||||
@@ -333,6 +333,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="优秀共青团员"
|
module-name="优秀共青团员"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="attachment-list">
|
||||||
|
<a-empty
|
||||||
|
v-if="!files.length"
|
||||||
|
:description="emptyText"
|
||||||
|
class="attachment-empty"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-for="(file, index) in files"
|
||||||
|
:key="`${file.url}-${index}`"
|
||||||
|
class="attachment-item"
|
||||||
|
>
|
||||||
|
<a-image
|
||||||
|
v-if="isImageFile(file.url)"
|
||||||
|
:src="file.url"
|
||||||
|
:width="96"
|
||||||
|
:height="96"
|
||||||
|
class="attachment-image"
|
||||||
|
/>
|
||||||
|
<div class="attachment-body">
|
||||||
|
<a
|
||||||
|
:href="file.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="attachment-name"
|
||||||
|
:title="file.name"
|
||||||
|
>
|
||||||
|
{{ file.name }}
|
||||||
|
</a>
|
||||||
|
<span class="attachment-tip">点击可预览</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
isImageFile,
|
||||||
|
parseAttachments,
|
||||||
|
type AttachmentFile
|
||||||
|
} from '@/views/gxmu/utils/attachment';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
// 原始附件字段值
|
||||||
|
value?: unknown;
|
||||||
|
// 已解析好的文件列表(优先使用)
|
||||||
|
files?: AttachmentFile[];
|
||||||
|
emptyText?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
value: undefined,
|
||||||
|
files: undefined,
|
||||||
|
emptyText: '暂无材料'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = computed<AttachmentFile[]>(() => {
|
||||||
|
if (props.files) {
|
||||||
|
return props.files;
|
||||||
|
}
|
||||||
|
return parseAttachments(props.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
name: 'GxmuAttachmentList'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.attachment-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #eef1f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-image {
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
color: #1677ff;
|
||||||
|
font-weight: 500;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-tip {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-empty {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,6 +26,21 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
|
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
|
||||||
</a-upload-dragger>
|
</a-upload-dragger>
|
||||||
|
<a-alert
|
||||||
|
v-if="failures.length"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:message="`${successCount} 条导入成功,${failures.length} 条被跳过`"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<div class="import-failures">
|
||||||
|
<div v-for="failure in failures" :key="failure.row">
|
||||||
|
第 {{ failure.row }} 行:{{ failure.reason }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-button size="small" @click="exportFailures">下载失败清单</a-button>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</ele-modal>
|
</ele-modal>
|
||||||
@@ -63,6 +78,8 @@
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const failures = ref<{ row: number; reason: string }[]>([]);
|
||||||
|
const successCount = ref(0);
|
||||||
|
|
||||||
const description = computed(
|
const description = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -141,13 +158,17 @@
|
|||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
throw new Error('导入文件内容为空');
|
throw new Error('导入文件内容为空');
|
||||||
}
|
}
|
||||||
const data = rows
|
const entries = rows
|
||||||
.map((row, index) => props.createPayload(row, index + 2))
|
.map((row, index) => ({
|
||||||
.filter((item) =>
|
rowNo: index + 2,
|
||||||
|
item: props.createPayload(row, index + 2)
|
||||||
|
}))
|
||||||
|
.filter(({ item }) =>
|
||||||
props.templateFields.some((field) =>
|
props.templateFields.some((field) =>
|
||||||
!!String(item[field.key] ?? '').trim()
|
!!String(item[field.key] ?? '').trim()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const data = entries.map((entry) => entry.item);
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
throw new Error('未识别到有效数据');
|
throw new Error('未识别到有效数据');
|
||||||
}
|
}
|
||||||
@@ -155,10 +176,28 @@
|
|||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
throw new Error(errors.slice(0, 5).join(';'));
|
throw new Error(errors.slice(0, 5).join(';'));
|
||||||
}
|
}
|
||||||
for (const item of data) {
|
failures.value = [];
|
||||||
await props.saveItem(item);
|
successCount.value = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
try {
|
||||||
|
await props.saveItem(entry.item);
|
||||||
|
successCount.value += 1;
|
||||||
|
} catch (e: any) {
|
||||||
|
failures.value.push({
|
||||||
|
row: entry.rowNo,
|
||||||
|
reason: e?.message || '保存失败'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
message.success(`成功导入 ${data.length} 条${props.moduleName}数据`);
|
}
|
||||||
|
if (failures.value.length) {
|
||||||
|
// 被跳过的是「同一申报项目已有进行中/已通过/被驳回」的记录,保留在清单里供人工处理
|
||||||
|
message.warning(
|
||||||
|
`成功 ${successCount.value} 条,跳过 ${failures.value.length} 条`
|
||||||
|
);
|
||||||
|
emit('done');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
message.success(`成功导入 ${successCount.value} 条${props.moduleName}数据`);
|
||||||
updateVisible(false);
|
updateVisible(false);
|
||||||
emit('done');
|
emit('done');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -169,7 +208,32 @@
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportFailures = () => {
|
||||||
|
if (!failures.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sheet = utils.aoa_to_sheet([
|
||||||
|
['行号', '跳过原因'],
|
||||||
|
...failures.value.map((item) => [item.row, item.reason])
|
||||||
|
]);
|
||||||
|
sheet['!cols'] = [{ wch: 10 }, { wch: 64 }];
|
||||||
|
writeFile(
|
||||||
|
{
|
||||||
|
SheetNames: ['失败清单'],
|
||||||
|
Sheets: { 失败清单: sheet }
|
||||||
|
},
|
||||||
|
`${props.moduleName}导入失败清单.xlsx`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const updateVisible = (value: boolean) => {
|
const updateVisible = (value: boolean) => {
|
||||||
emit('update:visible', value);
|
emit('update:visible', value);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.import-failures {
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
:color="item.status === 1 ? 'success' : item.status === 2 ? 'error' : 'default'"
|
:color="item.status === 1 ? 'success' : item.status === 2 ? 'error' : 'default'"
|
||||||
style="margin-left: 8px"
|
style="margin-left: 8px"
|
||||||
>
|
>
|
||||||
{{ item.status === 1 ? '通过' : item.status === 2 ? '不通过' : '待审核' }}
|
{{ item.status === 1 ? '通过' : item.status === 2 ? '驳回' : '待审核' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.user?.realName" style="margin-bottom: 4px">
|
<div v-if="item.user?.realName" style="margin-bottom: 4px">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<a-tag color="green">通过</a-tag>
|
<a-tag color="green">通过</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="latest?.status === 2">
|
<template v-else-if="latest?.status === 2">
|
||||||
<a-tag color="red">不通过</a-tag>
|
<a-tag color="red">驳回</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-tag>待审核</a-tag>
|
<a-tag>待审核</a-tag>
|
||||||
@@ -13,13 +13,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
||||||
|
import { getLatestReview } from '../utils/review';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
reviewList?: ReviewList[];
|
reviewList?: ReviewList[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const latest = computed(() => {
|
// 与「能否编辑」共用同一套排序规则,避免两处结论不一致
|
||||||
const list = props.reviewList ?? [];
|
const latest = computed(() => getLatestReview(props.reviewList));
|
||||||
return [...list].sort((a, b) => Number(b.id || 0) - Number(a.id || 0))[0];
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -149,6 +149,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
<a-form-item
|
||||||
|
label="允许重复提交"
|
||||||
|
name="allowRepeat"
|
||||||
|
extra="关闭时,同一用户在同一申报项目下同时只能有一条待审核申报;开启后可配置最多同时几条"
|
||||||
|
>
|
||||||
|
<a-switch
|
||||||
|
:checked="form.allowRepeat === 1"
|
||||||
|
@change="(checked: boolean) => (form.allowRepeat = checked ? 1 : 0)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.allowRepeat === 1" label="最多同时几条" name="maxPending">
|
||||||
|
<a-input-number :min="1" :max="20" class="ele-fluid" v-model:value="form.maxPending" />
|
||||||
|
</a-form-item>
|
||||||
<a-form-item label="开始时间" name="startTime">
|
<a-form-item label="开始时间" name="startTime">
|
||||||
<a-date-picker
|
<a-date-picker
|
||||||
allow-clear
|
allow-clear
|
||||||
@@ -182,6 +195,10 @@
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { FormInstance } from 'ant-design-vue/es/form';
|
import { FormInstance } from 'ant-design-vue/es/form';
|
||||||
import { DECLARE_MODULE_OPTIONS } from '../moduleOptions';
|
import { DECLARE_MODULE_OPTIONS } from '../moduleOptions';
|
||||||
|
import {
|
||||||
|
TZBCY_PROJECT_GROUP_OPTIONS,
|
||||||
|
TZBCY_PROJECT_TYPE_OPTIONS
|
||||||
|
} from '../../TZBCYForm/options';
|
||||||
import {
|
import {
|
||||||
normalizeDeclareRecord,
|
normalizeDeclareRecord,
|
||||||
stringifyDeclareMultiValueField
|
stringifyDeclareMultiValueField
|
||||||
@@ -244,6 +261,8 @@
|
|||||||
publicProjectGroup: [''],
|
publicProjectGroup: [''],
|
||||||
startTime: undefined,
|
startTime: undefined,
|
||||||
endTime: undefined,
|
endTime: undefined,
|
||||||
|
allowRepeat: 0,
|
||||||
|
maxPending: 1,
|
||||||
userId: undefined,
|
userId: undefined,
|
||||||
tenantId: undefined,
|
tenantId: undefined,
|
||||||
createTime: undefined,
|
createTime: undefined,
|
||||||
@@ -273,29 +292,68 @@
|
|||||||
const { resetFields } = useForm(form, rules);
|
const { resetFields } = useForm(form, rules);
|
||||||
const isTzbcyModule = computed(() => form.module === 'gxmu_tzbcy_form');
|
const isTzbcyModule = computed(() => form.module === 'gxmu_tzbcy_form');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挑战杯模块的默认配置
|
||||||
|
* 与挑战杯申报表共用 TZBCYForm/options 中的同一份常量,避免两处维护产生漂移
|
||||||
|
*/
|
||||||
|
type ChallengeCupConfigKey =
|
||||||
|
| 'formProjectType'
|
||||||
|
| 'formProjectGroup'
|
||||||
|
| 'publicProjectType'
|
||||||
|
| 'publicProjectGroup';
|
||||||
|
|
||||||
|
const CHALLENGE_CUP_CONFIG_KEYS: ChallengeCupConfigKey[] = [
|
||||||
|
'formProjectType',
|
||||||
|
'formProjectGroup',
|
||||||
|
'publicProjectType',
|
||||||
|
'publicProjectGroup'
|
||||||
|
];
|
||||||
|
|
||||||
|
const toOptionValues = (options: readonly { value: string }[]) =>
|
||||||
|
options.map((item) => item.value);
|
||||||
|
|
||||||
|
const getChallengeCupDefaultConfig = (
|
||||||
|
key: ChallengeCupConfigKey
|
||||||
|
): string[] => {
|
||||||
|
switch (key) {
|
||||||
|
case 'formProjectType':
|
||||||
|
case 'publicProjectType':
|
||||||
|
return toOptionValues(TZBCY_PROJECT_TYPE_OPTIONS);
|
||||||
|
default:
|
||||||
|
return toOptionValues(TZBCY_PROJECT_GROUP_OPTIONS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 列表是否为空(没有任意一个有效值,空行不算)
|
||||||
|
const isBlankConfigList = (list?: string[]) =>
|
||||||
|
!(list || []).some((item) => !!item?.trim());
|
||||||
|
|
||||||
|
// 仅填充空配置,已有的配置(含用户手动编辑过的内容)保持不变
|
||||||
|
const fillBlankConfigWithDefault = () => {
|
||||||
|
CHALLENGE_CUP_CONFIG_KEYS.forEach((key) => {
|
||||||
|
if (isBlankConfigList(form[key])) {
|
||||||
|
form[key] = getChallengeCupDefaultConfig(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选中挑战杯模块时套用默认配置
|
||||||
|
const applyChallengeCupPreset = () => {
|
||||||
|
if (isTzbcyModule.value) {
|
||||||
|
fillBlankConfigWithDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeEditableList = (list?: string[]) => {
|
const normalizeEditableList = (list?: string[]) => {
|
||||||
const values = (list || []).map((item) => item?.trim() || '').filter(Boolean);
|
const values = (list || []).map((item) => item?.trim() || '').filter(Boolean);
|
||||||
return values.length ? values : [''];
|
return values.length ? values : [''];
|
||||||
};
|
};
|
||||||
|
|
||||||
const addConfigRow = (
|
const addConfigRow = (key: ChallengeCupConfigKey) => {
|
||||||
key:
|
|
||||||
| 'formProjectType'
|
|
||||||
| 'formProjectGroup'
|
|
||||||
| 'publicProjectType'
|
|
||||||
| 'publicProjectGroup'
|
|
||||||
) => {
|
|
||||||
form[key].push('');
|
form[key].push('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeConfigRow = (
|
const removeConfigRow = (key: ChallengeCupConfigKey, index: number) => {
|
||||||
key:
|
|
||||||
| 'formProjectType'
|
|
||||||
| 'formProjectGroup'
|
|
||||||
| 'publicProjectType'
|
|
||||||
| 'publicProjectGroup',
|
|
||||||
index: number
|
|
||||||
) => {
|
|
||||||
form[key].splice(index, 1);
|
form[key].splice(index, 1);
|
||||||
if (!form[key].length) {
|
if (!form[key].length) {
|
||||||
form[key].push('');
|
form[key].push('');
|
||||||
@@ -418,6 +476,8 @@
|
|||||||
);
|
);
|
||||||
yearValue.value = props.data.year ? String(props.data.year) : undefined;
|
yearValue.value = props.data.year ? String(props.data.year) : undefined;
|
||||||
isUpdate.value = true;
|
isUpdate.value = true;
|
||||||
|
// 历史记录若未配置过挑战杯配置,补上默认值;已配置的记录保持原样
|
||||||
|
applyChallengeCupPreset();
|
||||||
} else {
|
} else {
|
||||||
Object.assign(form, createDefaultForm());
|
Object.assign(form, createDefaultForm());
|
||||||
isUpdate.value = false;
|
isUpdate.value = false;
|
||||||
@@ -435,14 +495,15 @@
|
|||||||
watch(
|
watch(
|
||||||
() => form.module,
|
() => form.module,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (value !== 'gxmu_tzbcy_form') {
|
if (value === 'gxmu_tzbcy_form') {
|
||||||
|
// 选中挑战杯模块时预填项目申报表 / 公开展示信息表的默认配置
|
||||||
|
applyChallengeCupPreset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 切到其他模块时不清空配置,避免用户手滑切换后已编辑的内容丢失
|
||||||
|
// (非挑战杯模块不会展示也不会提交这些字段)
|
||||||
form.projectType = [];
|
form.projectType = [];
|
||||||
form.projectGroup = [];
|
form.projectGroup = [];
|
||||||
form.formProjectType = [''];
|
|
||||||
form.formProjectGroup = [''];
|
|
||||||
form.publicProjectType = [''];
|
|
||||||
form.publicProjectGroup = [''];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="column.key === 'status'">
|
||||||
<a-tag v-if="record.status === 1" color="green">通过</a-tag>
|
<a-tag v-if="record.status === 1" color="green">通过</a-tag>
|
||||||
<a-tag v-else-if="record.status === 2" color="red">不通过</a-tag>
|
<a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
|
||||||
<a-tag v-else>待审核</a-tag>
|
<a-tag v-else>待审核</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'submitTime'">
|
<template v-else-if="column.key === 'submitTime'">
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
:module="detailCurrent?.module"
|
:module="detailCurrent?.module"
|
||||||
:module-name="getDeclareModuleLabel(detailCurrent?.module)"
|
:module-name="getDeclareModuleLabel(detailCurrent?.module)"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="loadHistoryData"
|
||||||
/>
|
/>
|
||||||
<ReviewFlowModal
|
<ReviewFlowModal
|
||||||
v-model:visible="showReviewFlow"
|
v-model:visible="showReviewFlow"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -152,6 +152,13 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-space>
|
<a-space>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="loading || !markdownContent"
|
||||||
|
@click="formatRichText"
|
||||||
|
>
|
||||||
|
一键排版
|
||||||
|
</a-button>
|
||||||
<a-button :disabled="loading || !markdownContent" @click="exportWord">导出 Word</a-button>
|
<a-button :disabled="loading || !markdownContent" @click="exportWord">导出 Word</a-button>
|
||||||
<a-button :disabled="!markdownContent" @click="copyGenerated">复制正文</a-button>
|
<a-button :disabled="!markdownContent" @click="copyGenerated">复制正文</a-button>
|
||||||
<a-button :disabled="!generatedTitle" @click="copyGeneratedTitle">复制标题</a-button>
|
<a-button :disabled="!generatedTitle" @click="copyGeneratedTitle">复制标题</a-button>
|
||||||
@@ -171,6 +178,12 @@
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="正文内容">
|
<a-form-item label="正文内容">
|
||||||
|
<a-tabs
|
||||||
|
v-model:activeKey="contentTab"
|
||||||
|
size="small"
|
||||||
|
class="content-tabs"
|
||||||
|
>
|
||||||
|
<a-tab-pane key="markdown" tab="Markdown 预览">
|
||||||
<div class="markdown-shell">
|
<div class="markdown-shell">
|
||||||
<div v-if="loading" class="markdown-streaming">
|
<div v-if="loading" class="markdown-streaming">
|
||||||
{{ markdownContent || 'AI 正在生成内容...' }}
|
{{ markdownContent || 'AI 正在生成内容...' }}
|
||||||
@@ -182,6 +195,38 @@
|
|||||||
></div>
|
></div>
|
||||||
<div v-else class="markdown-empty">生成结果将显示在这里</div>
|
<div v-else class="markdown-empty">生成结果将显示在这里</div>
|
||||||
</div>
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
<a-tab-pane key="rich" tab="富文本排版">
|
||||||
|
<div class="rich-shell">
|
||||||
|
<div class="rich-bar">
|
||||||
|
<span class="rich-bar-tip">
|
||||||
|
一键排版按原文的标题层级、段落、列表、表格还原,可直接在编辑器里继续改
|
||||||
|
</span>
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:disabled="saveDisabled"
|
||||||
|
@click="saveAsManuscript"
|
||||||
|
>
|
||||||
|
{{ savedUpToDate ? '已保存为稿件' : '保存为稿件' }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<Toolbar
|
||||||
|
class="rich-toolbar"
|
||||||
|
:editor="editorRef"
|
||||||
|
:defaultConfig="toolbarConfig"
|
||||||
|
mode="default"
|
||||||
|
/>
|
||||||
|
<Editor
|
||||||
|
v-model="richContentHtml"
|
||||||
|
class="rich-editor"
|
||||||
|
:defaultConfig="editorConfig"
|
||||||
|
mode="default"
|
||||||
|
@onCreated="handleEditorCreated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</template>
|
</template>
|
||||||
@@ -198,10 +243,10 @@
|
|||||||
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
|
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
|
||||||
import highlight from '@bytemd/plugin-highlight-ssr';
|
import highlight from '@bytemd/plugin-highlight-ssr';
|
||||||
import 'highlight.js/styles/default.css';
|
import 'highlight.js/styles/default.css';
|
||||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue';
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
import { Modal, message } from 'ant-design-vue';
|
||||||
import { messageLoading } from 'ele-admin-pro';
|
import { messageLoading } from 'ele-admin-pro';
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { asBlob } from 'html-docx-js-typescript';
|
import { asBlob } from 'html-docx-js-typescript';
|
||||||
@@ -215,6 +260,16 @@
|
|||||||
import type { FileRecord } from '@/api/system/file/model';
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
import { copyText } from '@/utils/common';
|
import { copyText } from '@/utils/common';
|
||||||
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
|
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
|
||||||
|
import { markdownToHtml } from '@/utils/manuscript-content';
|
||||||
|
import { addManuscript } from '@/api/cms/manuscript';
|
||||||
|
import type { Manuscript } from '@/api/cms/manuscript/model';
|
||||||
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
||||||
|
import type {
|
||||||
|
IDomEditor,
|
||||||
|
IEditorConfig,
|
||||||
|
IToolbarConfig
|
||||||
|
} from '@wangeditor/editor';
|
||||||
|
import '@wangeditor/editor/dist/css/style.css';
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
/** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */
|
/** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */
|
||||||
@@ -248,6 +303,46 @@
|
|||||||
highlight()
|
highlight()
|
||||||
]);
|
]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
/** 正文区域展示方式:默认看 Markdown 预览,一键排版后切到富文本 */
|
||||||
|
const contentTab = ref<'markdown' | 'rich'>('markdown');
|
||||||
|
/** 富文本编辑器里的 HTML(一键排版灌入,人工可继续改) */
|
||||||
|
const richContentHtml = ref('');
|
||||||
|
const savingManuscript = ref(false);
|
||||||
|
/** 上次保存时的正文文本快照:内容没变就不再让点「保存为稿件」,避免存出重复草稿 */
|
||||||
|
const savedSnapshot = ref('');
|
||||||
|
const editorRef = shallowRef<IDomEditor>();
|
||||||
|
|
||||||
|
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||||
|
excludeKeys: ['group-video']
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorConfig: Partial<IEditorConfig> = {
|
||||||
|
placeholder: '点上方「一键排版」,把生成结果按原文排版放进这里',
|
||||||
|
MENU_CONF: {
|
||||||
|
uploadImage: {
|
||||||
|
allowedFileTypes: ['image/*'],
|
||||||
|
maxFileSize: 2 * 1024 * 1024,
|
||||||
|
base64LimitSize: 0,
|
||||||
|
async customUpload(file: File, insertFn: (url: string) => void) {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
message.error('只能上传图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await uploadFile(file);
|
||||||
|
insertFn(result.url || '');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || '图片上传失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorCreated = (editor: IDomEditor) => {
|
||||||
|
editorRef.value = editor;
|
||||||
|
};
|
||||||
|
|
||||||
const generatedTitle = ref('');
|
const generatedTitle = ref('');
|
||||||
const generatedContent = ref('');
|
const generatedContent = ref('');
|
||||||
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
|
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
|
||||||
@@ -569,6 +664,10 @@
|
|||||||
const clearGenerated = () => {
|
const clearGenerated = () => {
|
||||||
generatedTitle.value = '';
|
generatedTitle.value = '';
|
||||||
generatedContent.value = '';
|
generatedContent.value = '';
|
||||||
|
// 排版结果是这份 markdown 的产物,正文换了就不能留着旧的
|
||||||
|
richContentHtml.value = '';
|
||||||
|
savedSnapshot.value = '';
|
||||||
|
contentTab.value = 'markdown';
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -627,6 +726,127 @@
|
|||||||
form.number = 600;
|
form.number = 600;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 只留文本、丢掉标签与空白,用来判断两份富文本是不是同一份内容 */
|
||||||
|
const normalizeForCompare = (html: string) =>
|
||||||
|
String(html || '')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
/** 编辑器里的内容是否已经存成稿件(按文本比,编辑器规整过标签属性也算同一份) */
|
||||||
|
const savedUpToDate = computed(
|
||||||
|
() =>
|
||||||
|
!!savedSnapshot.value &&
|
||||||
|
savedSnapshot.value === normalizeForCompare(richContentHtml.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 保存按钮的可用状态:没内容、正在存、这份已经存过,都不给点 */
|
||||||
|
const saveDisabled = computed(
|
||||||
|
() =>
|
||||||
|
savingManuscript.value ||
|
||||||
|
!hasRichText(richContentHtml.value) ||
|
||||||
|
savedUpToDate.value
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 编辑器里的“空”:wangEditor 空内容是一段 `<p><br></p>`,不能只看字符串长度 */
|
||||||
|
const hasRichText = (html: string) => {
|
||||||
|
const text = String(html || '')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/ /gi, '')
|
||||||
|
.trim();
|
||||||
|
return !!text || /<img\b/i.test(String(html || ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键排版:把生成结果按原文的标题层级、段落、列表、表格转成 HTML,
|
||||||
|
* 灌进富文本编辑器。
|
||||||
|
* - 用 gfm 插件,和页面上 Markdown 预览同一套解析(表格不会漏成管道符);
|
||||||
|
* - 不把生成标题拼进正文:标题有独立字段,导出 Word 时也只在导出件里拼;
|
||||||
|
* - 不落库:保存是用户在编辑器里确认过的第二次动作(见 saveAsManuscript)。
|
||||||
|
*/
|
||||||
|
const formatRichText = () => {
|
||||||
|
if (loading.value) {
|
||||||
|
message.warning('正在生成中,暂不能排版');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!markdownContent.value) {
|
||||||
|
message.warning('暂无可排版的正文内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (markdownContent.value === FAILED_TEXT) {
|
||||||
|
message.error('生成失败的内容不能排版,请重新生成');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextHtml = markdownToHtml(markdownContent.value, [
|
||||||
|
gfm({ locale: zh_HansGfm })
|
||||||
|
]);
|
||||||
|
if (!nextHtml.trim()) {
|
||||||
|
message.warning('当前内容转换不出富文本,请检查正文');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const apply = () => {
|
||||||
|
richContentHtml.value = nextHtml;
|
||||||
|
// 排版换了内容,之前那次保存的状态就作废
|
||||||
|
savedSnapshot.value = '';
|
||||||
|
contentTab.value = 'rich';
|
||||||
|
message.success('已按原文排版放进富文本编辑器,请检查后保存');
|
||||||
|
};
|
||||||
|
// 编辑器里有内容且不是同一份排版(用户改过),才需要确认覆盖。
|
||||||
|
// 比的是「文本」而不是 HTML:wangEditor 会把 setHtml 进来的标签属性、
|
||||||
|
// 空白重新规整一遍,按 HTML 比会把「原样再排一次」也判成要覆盖。
|
||||||
|
if (
|
||||||
|
hasRichText(richContentHtml.value) &&
|
||||||
|
normalizeForCompare(richContentHtml.value) !== normalizeForCompare(nextHtml)
|
||||||
|
) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '覆盖编辑器里的内容?',
|
||||||
|
content: '富文本编辑器里已有内容,重新排版会覆盖掉当前改动。',
|
||||||
|
okText: '覆盖',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: apply
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存为稿件:入库一律写 html,和「编辑稿件」弹窗的口径一致。
|
||||||
|
* add 接口不回 id,所以这里只负责“存成一条新稿件”,不冒充“再编辑同一篇”:
|
||||||
|
* 存过之后按钮置灰,改过正文才会重新可点(避免连点存出多条草稿)。
|
||||||
|
*/
|
||||||
|
const saveAsManuscript = async () => {
|
||||||
|
const title = (generatedTitle.value || form.theme).trim();
|
||||||
|
if (!title) {
|
||||||
|
message.warning('请先填写生成标题,稿件需要标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasRichText(richContentHtml.value)) {
|
||||||
|
message.warning('富文本里还没有内容,请先点「一键排版」');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (savedUpToDate.value) {
|
||||||
|
message.info('这份排版已经保存过了,改完再点可以另存一条');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savingManuscript.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await addManuscript({
|
||||||
|
title,
|
||||||
|
content: richContentHtml.value,
|
||||||
|
contentFormat: 'html',
|
||||||
|
// 后端 status 是 Integer(列表里 0 待审核 / 1 显示),前端类型写成 string 是历史遗留
|
||||||
|
status: 1 as unknown as Manuscript['status'],
|
||||||
|
sortNumber: 0
|
||||||
|
});
|
||||||
|
savedSnapshot.value = normalizeForCompare(richContentHtml.value);
|
||||||
|
message.success(msg || '已保存为稿件');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || '保存稿件失败');
|
||||||
|
} finally {
|
||||||
|
savingManuscript.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeUploadedFile = (index: number) => {
|
const removeUploadedFile = (index: number) => {
|
||||||
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
|
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
|
||||||
};
|
};
|
||||||
@@ -852,6 +1072,7 @@
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
|
editorRef.value?.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1047,6 +1268,46 @@
|
|||||||
color: #1f2d3d;
|
color: #1f2d3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-tabs :deep(.ant-tabs-nav) {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-shell {
|
||||||
|
border: 1px solid #c7e8ff;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fffdfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #eef4f9;
|
||||||
|
background: #f5fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-bar-tip {
|
||||||
|
color: #5f7f9f;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-toolbar {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-text-container) {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.full-width {
|
.full-width {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,904 @@
|
|||||||
|
/**
|
||||||
|
* 审核列表「查看数据」详情的申报表配置。
|
||||||
|
*
|
||||||
|
* sections 决定申报表的字段分组与展示顺序,materialFields 声明需要在上传材料
|
||||||
|
* 中展示的附件字段,未列出的字段会兜底追加到「其他信息」分组,避免数据丢失。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ModuleSection {
|
||||||
|
// 分组标题
|
||||||
|
title: string;
|
||||||
|
// 分组内字段(按展示顺序)
|
||||||
|
fields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleMaterialTable {
|
||||||
|
// 明细数组字段
|
||||||
|
field: string;
|
||||||
|
// 明细中保存附件地址的字段
|
||||||
|
fileField: string;
|
||||||
|
// 明细标题字段
|
||||||
|
titleField?: string;
|
||||||
|
// 材料分组标题
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleDetailConfig {
|
||||||
|
// 模块名称
|
||||||
|
title: string;
|
||||||
|
// 申报表名称,用于拼接表单标题
|
||||||
|
sheetName: string;
|
||||||
|
// 字段中文名
|
||||||
|
labels: Record<string, string>;
|
||||||
|
// 申报表分组
|
||||||
|
sections: ModuleSection[];
|
||||||
|
// 附件字段(上传材料)
|
||||||
|
materialFields: string[];
|
||||||
|
// 明细数组中的附件
|
||||||
|
materialTables?: ModuleMaterialTable[];
|
||||||
|
// 额外的材料数据来源
|
||||||
|
materialSource?: 'tzbcy';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用字段中文名
|
||||||
|
*/
|
||||||
|
export const COMMON_LABELS: Record<string, string> = {
|
||||||
|
year: '年份',
|
||||||
|
id: 'ID',
|
||||||
|
title: '标题',
|
||||||
|
content: '内容',
|
||||||
|
contentFormat: '正文格式',
|
||||||
|
cover: '封面',
|
||||||
|
createTime: '提交时间',
|
||||||
|
updateTime: '更新时间',
|
||||||
|
status: '状态',
|
||||||
|
sortNumber: '排序',
|
||||||
|
provinceCity: '省市',
|
||||||
|
schoolName: '学校名称',
|
||||||
|
projectName: '项目名称',
|
||||||
|
projectType: '项目类型',
|
||||||
|
projectGroup: '项目分组',
|
||||||
|
publicProjectType: '公开展示项目类型',
|
||||||
|
publicProjectGroup: '公开展示项目分组',
|
||||||
|
leader: '负责人',
|
||||||
|
phone: '联系电话',
|
||||||
|
teamMembers: '团队成员',
|
||||||
|
advisors: '指导老师',
|
||||||
|
projectBrief: '项目简介',
|
||||||
|
socialValue: '社会价值',
|
||||||
|
practiceProcess: '实践过程',
|
||||||
|
innovationMeaning: '创新意义',
|
||||||
|
developmentProspect: '发展前景',
|
||||||
|
teamCooperation: '团队协作情况',
|
||||||
|
projectMaterials: '项目介绍材料',
|
||||||
|
otherProofs: '其他相关证明材料',
|
||||||
|
projectSummary: '项目总结',
|
||||||
|
teamIntro: '团队介绍',
|
||||||
|
teamSlogan: '团队口号',
|
||||||
|
practiceLog: '实践日志',
|
||||||
|
name: '姓名',
|
||||||
|
gender: '性别',
|
||||||
|
nation: '民族',
|
||||||
|
photo: '电子版照片',
|
||||||
|
birthMonth: '出生年月',
|
||||||
|
politics: '政治面貌',
|
||||||
|
nativePlace: '籍贯',
|
||||||
|
wechat: '微信号',
|
||||||
|
email: '邮箱',
|
||||||
|
qq: 'QQ',
|
||||||
|
idCardNo: '身份证号',
|
||||||
|
hobby: '兴趣爱好',
|
||||||
|
willingToWorkInGuangxi: '是否愿意留桂工作',
|
||||||
|
schoolInfo: '学校信息',
|
||||||
|
leaguePosition: '团内职务',
|
||||||
|
resume: '个人简历',
|
||||||
|
awards: '获得奖励',
|
||||||
|
academicPerformance: '学习成绩',
|
||||||
|
secondaryLeagueOpinion: '二级团组织意见',
|
||||||
|
secondaryLeagueOpinionDate: '二级团组织意见日期',
|
||||||
|
secondaryPartyOpinion: '二级党组织意见',
|
||||||
|
secondaryPartyOpinionDate: '二级党组织意见日期',
|
||||||
|
schoolLeagueOpinion: '学校团组织意见',
|
||||||
|
schoolLeagueOpinionDate: '学校团组织意见日期',
|
||||||
|
applyType: '申报类别',
|
||||||
|
education: '学历',
|
||||||
|
position: '职务',
|
||||||
|
unit: '工作单位',
|
||||||
|
experience: '主要经历',
|
||||||
|
mainStory: '主要事迹',
|
||||||
|
leagueOpinion: '团组织意见',
|
||||||
|
partyOpinion: '党组织意见',
|
||||||
|
schoolOpinion: '学校意见',
|
||||||
|
collegeClass: '学院班级',
|
||||||
|
branch: '团支部名称',
|
||||||
|
eduEval: '教育评议情况',
|
||||||
|
serialNo: '序号',
|
||||||
|
archiveInCurrentOrg: '团籍是否在本组织',
|
||||||
|
joinMonth: '入团时间',
|
||||||
|
memberRecords: '团员记录',
|
||||||
|
growthArchives: '成长档案',
|
||||||
|
orgName: '组织名称',
|
||||||
|
memberTotal: '团员总数',
|
||||||
|
memberDeveloped2024: '上一年度发展团员数',
|
||||||
|
smartSystemLogin: '智慧团建系统录入情况',
|
||||||
|
committeeCount: '团委委员数',
|
||||||
|
fulltimeCadreCount: '专职团干部数',
|
||||||
|
parttimeCadreCount: '兼职团干部数',
|
||||||
|
lastElectionTime: '最近一次换届时间',
|
||||||
|
feeReceivable2024: '上一年度应收团费',
|
||||||
|
feeReceived2024: '上一年度实收团费',
|
||||||
|
feePayable2024: '上一年度应缴团费',
|
||||||
|
feePaid2024: '上一年度实缴团费',
|
||||||
|
branchCount: '团支部数量',
|
||||||
|
standardizedWork2024: '上一年度标准化建设开展情况',
|
||||||
|
recommendActivist2024: '上一年度推优入党人数',
|
||||||
|
activistConfirmed: '入党积极分子确定情况',
|
||||||
|
recommendDevTarget2024: '上一年度推荐发展对象人数',
|
||||||
|
devTargetConfirmed: '发展对象确定情况',
|
||||||
|
honorsFiveYears: '近五年获奖情况',
|
||||||
|
workSummaryThreeYears: '近三年工作总结',
|
||||||
|
branchName: '团支部名称',
|
||||||
|
secondOrg: '所属二级党组织',
|
||||||
|
secretary: '团支部书记',
|
||||||
|
contact: '联系方式',
|
||||||
|
establishTime: '成立时间',
|
||||||
|
branchCommitteeMeetingCount: '支委会次数',
|
||||||
|
branchMemberMeetingCount: '支部大会次数',
|
||||||
|
eduEvalDone: '教育评议是否完成',
|
||||||
|
annualRegDone: '年度团籍注册是否完成',
|
||||||
|
classCount: '班级数量',
|
||||||
|
smartSystem100: '智慧团建系统覆盖率',
|
||||||
|
topicName: '课题名称',
|
||||||
|
collegeGradeClass: '学院年级班级',
|
||||||
|
advisor: '指导老师',
|
||||||
|
topicType: '课题类别',
|
||||||
|
guidanceTeachers: '指导教师组',
|
||||||
|
applicants: '申请人信息',
|
||||||
|
budgets: '经费预算',
|
||||||
|
rationale: '立项依据',
|
||||||
|
teacherOpinion: '指导教师意见',
|
||||||
|
teacherSign: '指导教师签名',
|
||||||
|
reviewOpinion: '评审意见',
|
||||||
|
reviewScore: '评审成绩',
|
||||||
|
reviewLeaderSign: '评审负责人签名',
|
||||||
|
collegeOpinion: '学院意见',
|
||||||
|
promiseSigner: '承诺签字人',
|
||||||
|
promiseSignature: '承诺书签名',
|
||||||
|
reviewCollege: '评审学院',
|
||||||
|
reviewReporter: '汇报人',
|
||||||
|
reviewDate: '评审日期',
|
||||||
|
reviewMembers: '评审成员',
|
||||||
|
fundingBudgetFile: '经费预算表',
|
||||||
|
fundingAllocationFile: '经费划拨明细表',
|
||||||
|
midtermRemark: '中期检查说明',
|
||||||
|
midtermMaterials: '中期检查材料',
|
||||||
|
finalRemark: '结题说明',
|
||||||
|
finalMaterials: '结题材料',
|
||||||
|
archiveAwards: '获奖情况',
|
||||||
|
archiveProofs: '佐证材料',
|
||||||
|
archiveRecords: '成果归档',
|
||||||
|
grade: '年级',
|
||||||
|
className: '班级',
|
||||||
|
major: '专业',
|
||||||
|
role: '角色',
|
||||||
|
item: '项目',
|
||||||
|
amount: '金额',
|
||||||
|
reason: '原因',
|
||||||
|
dept: '部门',
|
||||||
|
sign: '签名',
|
||||||
|
department: '部门',
|
||||||
|
remark: '备注',
|
||||||
|
type: '类型',
|
||||||
|
recordTime: '时间',
|
||||||
|
archiveTime: '时间',
|
||||||
|
identity: '身份',
|
||||||
|
organization: '所在组织',
|
||||||
|
memberNo: '团员编号',
|
||||||
|
currentDutyTime: '现任职务时间',
|
||||||
|
cadreYears: '从事团学工作年限',
|
||||||
|
assessment2024: '上一年度考核情况',
|
||||||
|
volunteerRegTime: '志愿汇注册时间',
|
||||||
|
cadreExperience: '团学工作经历',
|
||||||
|
joinTime: '入团时间',
|
||||||
|
collegeMajorClass: '学院专业班级',
|
||||||
|
eduEval2024: '上一年度教育评议情况',
|
||||||
|
smartSystem: '智慧团建系统录入情况',
|
||||||
|
totalVolunteerHours: '累计志愿服务时长',
|
||||||
|
volunteerHours2024: '上一年度志愿服务时长',
|
||||||
|
duty: '职务',
|
||||||
|
college: '学院',
|
||||||
|
gradeMajor: '年级专业',
|
||||||
|
birthDate: '出生日期',
|
||||||
|
branchOrganizationName: '所属团支部',
|
||||||
|
awardName: '获奖名称',
|
||||||
|
awardTime: '获奖时间',
|
||||||
|
proofFile: '证明材料',
|
||||||
|
archiveOrder: '序号'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 五四类申报表的意见栏分组
|
||||||
|
*/
|
||||||
|
const OPINION_SECTION: ModuleSection = {
|
||||||
|
title: '意见栏',
|
||||||
|
fields: ['leagueOpinion', 'partyOpinion', 'schoolOpinion']
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MODULE_DETAIL_CONFIGS: Record<string, ModuleDetailConfig> = {
|
||||||
|
gxmu_sjqn: {
|
||||||
|
title: '十佳青年岗位能手',
|
||||||
|
sheetName: '十佳青年岗位能手',
|
||||||
|
labels: {
|
||||||
|
applyType: '申报类别',
|
||||||
|
education: '学历',
|
||||||
|
position: '职务',
|
||||||
|
title: '职称',
|
||||||
|
unit: '所在单位',
|
||||||
|
awards: '近三年曾获校级及以上奖励',
|
||||||
|
experience: '工作(学习)经历',
|
||||||
|
mainStory: '主要事迹',
|
||||||
|
leagueOpinion: '所在二级团组织意见',
|
||||||
|
partyOpinion: '所在单位党组织意见',
|
||||||
|
schoolOpinion: '校团委意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'applyType',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'birthMonth',
|
||||||
|
'nation',
|
||||||
|
'politics',
|
||||||
|
'education',
|
||||||
|
'position',
|
||||||
|
'title',
|
||||||
|
'unit'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主要事迹与经历',
|
||||||
|
fields: ['awards', 'experience', 'mainStory']
|
||||||
|
},
|
||||||
|
OPINION_SECTION
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_sjtbzbsj: {
|
||||||
|
title: '十佳团支部书记',
|
||||||
|
sheetName: '十佳团支部书记',
|
||||||
|
labels: {
|
||||||
|
collegeClass: '学院班级/单位科室',
|
||||||
|
branch: '所在团支部',
|
||||||
|
eduEval: '上一年度团员教育评议等次',
|
||||||
|
awards: '近三年曾获校级及以上奖励',
|
||||||
|
experience: '工作(学习)经历',
|
||||||
|
mainStory: '主要事迹',
|
||||||
|
leagueOpinion: '所在二级团组织意见',
|
||||||
|
partyOpinion: '所在单位党组织意见',
|
||||||
|
schoolOpinion: '校团委意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'birthMonth',
|
||||||
|
'nation',
|
||||||
|
'politics',
|
||||||
|
'collegeClass',
|
||||||
|
'branch',
|
||||||
|
'eduEval'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主要事迹与经历',
|
||||||
|
fields: ['awards', 'experience', 'mainStory']
|
||||||
|
},
|
||||||
|
OPINION_SECTION
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_wshqtw: {
|
||||||
|
title: '五四红旗团委',
|
||||||
|
sheetName: '五四红旗团委',
|
||||||
|
labels: {
|
||||||
|
orgName: '二级团组织全称',
|
||||||
|
leader: '负责人',
|
||||||
|
phone: '联系电话',
|
||||||
|
memberTotal: '现有团员总数',
|
||||||
|
memberDeveloped2024: '上一年度发展团员人数',
|
||||||
|
smartSystemLogin: '是否已登录智慧团建系统',
|
||||||
|
committeeCount: '团委委员人数',
|
||||||
|
fulltimeCadreCount: '专职团干部数',
|
||||||
|
parttimeCadreCount: '兼职团干部数',
|
||||||
|
lastElectionTime: '团委最近一次换届时间',
|
||||||
|
feeReceivable2024: '上一年度应收团费',
|
||||||
|
feeReceived2024: '上一年度实收团费',
|
||||||
|
feePayable2024: '上一年度应上缴团费',
|
||||||
|
feePaid2024: '上一年度实际上缴团费',
|
||||||
|
branchCount: '团支部数',
|
||||||
|
standardizedWork2024: '是否开展规范化建设工作',
|
||||||
|
recommendActivist2024: '推荐入党积极分子人数',
|
||||||
|
activistConfirmed: '确定为入党积极分子数',
|
||||||
|
recommendDevTarget2024: '推荐党的发展对象人数',
|
||||||
|
devTargetConfirmed: '确定为党的发展对象数',
|
||||||
|
honorsFiveYears: '近五年获得校级及以上荣誉情况',
|
||||||
|
workSummaryThreeYears: '近三年开展的主要工作及取得的效果',
|
||||||
|
partyOpinion: '所在单位党组织意见',
|
||||||
|
schoolOpinion: '校团委意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{ title: '组织信息', fields: ['year', 'orgName', 'leader', 'phone'] },
|
||||||
|
{
|
||||||
|
title: '团员与团干部情况',
|
||||||
|
fields: [
|
||||||
|
'memberTotal',
|
||||||
|
'memberDeveloped2024',
|
||||||
|
'smartSystemLogin',
|
||||||
|
'committeeCount',
|
||||||
|
'fulltimeCadreCount',
|
||||||
|
'parttimeCadreCount',
|
||||||
|
'lastElectionTime'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '团费收缴情况',
|
||||||
|
fields: [
|
||||||
|
'feeReceivable2024',
|
||||||
|
'feeReceived2024',
|
||||||
|
'feePayable2024',
|
||||||
|
'feePaid2024'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '团支部与推优情况',
|
||||||
|
fields: [
|
||||||
|
'branchCount',
|
||||||
|
'recommendActivist2024',
|
||||||
|
'activistConfirmed',
|
||||||
|
'recommendDevTarget2024',
|
||||||
|
'devTargetConfirmed'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '工作与荣誉',
|
||||||
|
fields: [
|
||||||
|
'standardizedWork2024',
|
||||||
|
'honorsFiveYears',
|
||||||
|
'workSummaryThreeYears'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: '意见栏', fields: ['partyOpinion', 'schoolOpinion'] }
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_wshqtzb: {
|
||||||
|
title: '五四红旗团支部',
|
||||||
|
sheetName: '五四红旗团支部',
|
||||||
|
labels: {
|
||||||
|
branchName: '团支部全称',
|
||||||
|
secondOrg: '所属二级团组织',
|
||||||
|
secretary: '团支部书记',
|
||||||
|
politics: '政治面貌',
|
||||||
|
contact: '联系方式',
|
||||||
|
establishTime: '成立时间',
|
||||||
|
lastElectionTime: '最近一次换届时间',
|
||||||
|
smartSystemLogin: '是否已登录智慧团建系统',
|
||||||
|
memberTotal: '现有团员总数',
|
||||||
|
memberDeveloped2024: '上一年度发展团员数',
|
||||||
|
feeReceivable2024: '上一年度应收团费',
|
||||||
|
feeReceived2024: '上一年度实收团费',
|
||||||
|
feePayable2024: '上一年度应上缴团费',
|
||||||
|
feePaid2024: '上一年度实际上缴团费',
|
||||||
|
recommendActivist2024: '推荐入党积极分子人数',
|
||||||
|
activistConfirmed: '确定为入党积极分子数',
|
||||||
|
recommendDevTarget2024: '推荐党的发展对象人数',
|
||||||
|
devTargetConfirmed: '确定为党的发展对象数',
|
||||||
|
branchCommitteeMeetingCount: '团支部委员会会议召开次数',
|
||||||
|
branchMemberMeetingCount: '团支部团员大会召开次数',
|
||||||
|
eduEvalDone: '是否开展团员教育评议',
|
||||||
|
annualRegDone: '是否开展团员年度团籍注册',
|
||||||
|
classCount: '开展团课次数',
|
||||||
|
smartSystem100: '是否100%录入智慧团建',
|
||||||
|
honorsFiveYears: '近五年获得院级及以上荣誉情况',
|
||||||
|
workSummaryThreeYears: '近三年开展的主要工作及取得的效果',
|
||||||
|
leagueOpinion: '所在二级团组织意见',
|
||||||
|
partyOpinion: '所在单位党组织意见',
|
||||||
|
schoolOpinion: '校团委意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '支部信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'branchName',
|
||||||
|
'secondOrg',
|
||||||
|
'secretary',
|
||||||
|
'politics',
|
||||||
|
'contact',
|
||||||
|
'establishTime',
|
||||||
|
'lastElectionTime'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '团员与团费情况',
|
||||||
|
fields: [
|
||||||
|
'smartSystemLogin',
|
||||||
|
'memberTotal',
|
||||||
|
'memberDeveloped2024',
|
||||||
|
'feeReceivable2024',
|
||||||
|
'feeReceived2024',
|
||||||
|
'feePayable2024',
|
||||||
|
'feePaid2024'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '推优情况',
|
||||||
|
fields: [
|
||||||
|
'recommendActivist2024',
|
||||||
|
'activistConfirmed',
|
||||||
|
'recommendDevTarget2024',
|
||||||
|
'devTargetConfirmed'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '组织生活',
|
||||||
|
fields: [
|
||||||
|
'branchCommitteeMeetingCount',
|
||||||
|
'branchMemberMeetingCount',
|
||||||
|
'eduEvalDone',
|
||||||
|
'annualRegDone',
|
||||||
|
'classCount',
|
||||||
|
'smartSystem100'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '工作与荣誉',
|
||||||
|
fields: ['honorsFiveYears', 'workSummaryThreeYears']
|
||||||
|
},
|
||||||
|
OPINION_SECTION
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_yxgqtdgb: {
|
||||||
|
title: '优秀共青团干部',
|
||||||
|
sheetName: '优秀共青团干部',
|
||||||
|
labels: {
|
||||||
|
identity: '身份',
|
||||||
|
organization: '所在团组织',
|
||||||
|
contact: '联系方式',
|
||||||
|
memberNo: '发展团员编号',
|
||||||
|
currentDutyTime: '任现团内职务时间',
|
||||||
|
cadreYears: '担任团干部年限',
|
||||||
|
assessment2024: '上一年度工作考核结果',
|
||||||
|
volunteerRegTime: '成为注册志愿者时间',
|
||||||
|
cadreExperience: '从事团干部经历',
|
||||||
|
honorsFiveYears: '近五年获得校级及以上荣誉情况',
|
||||||
|
mainStory: '主要事迹',
|
||||||
|
leagueOpinion: '所在单位团组织意见',
|
||||||
|
partyOpinion: '所在单位党组织意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'nation',
|
||||||
|
'birthMonth',
|
||||||
|
'politics',
|
||||||
|
'position',
|
||||||
|
'identity',
|
||||||
|
'organization',
|
||||||
|
'contact',
|
||||||
|
'memberNo'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '团干部履历',
|
||||||
|
fields: [
|
||||||
|
'currentDutyTime',
|
||||||
|
'cadreYears',
|
||||||
|
'assessment2024',
|
||||||
|
'volunteerRegTime',
|
||||||
|
'cadreExperience'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主要事迹与荣誉',
|
||||||
|
fields: ['honorsFiveYears', 'mainStory']
|
||||||
|
},
|
||||||
|
{ title: '意见栏', fields: ['leagueOpinion', 'partyOpinion'] }
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_yxgqty: {
|
||||||
|
title: '优秀共青团员',
|
||||||
|
sheetName: '优秀共青团员',
|
||||||
|
labels: {
|
||||||
|
joinTime: '入团时间',
|
||||||
|
collegeMajorClass: '所在学院、专业、班级',
|
||||||
|
position: '职务',
|
||||||
|
volunteerRegTime: '成为注册志愿者时间',
|
||||||
|
eduEval2024: '上一年度团员教育评议等次',
|
||||||
|
smartSystem: '是否已登录智慧团建系统',
|
||||||
|
contact: '联系电话',
|
||||||
|
totalVolunteerHours: '累计志愿服务时长',
|
||||||
|
volunteerHours2024: '上一年度志愿服务时长',
|
||||||
|
memberNo: '发展团员编号',
|
||||||
|
honorsFiveYears: '近五年获得荣誉情况',
|
||||||
|
mainStory: '主要事迹',
|
||||||
|
leagueOpinion: '学生社团指导教师意见/所在单位团委',
|
||||||
|
partyOpinion: '业务指导单位意见/所在单位党组织',
|
||||||
|
schoolOpinion: '校团委意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'nation',
|
||||||
|
'politics',
|
||||||
|
'birthMonth',
|
||||||
|
'joinTime',
|
||||||
|
'collegeMajorClass',
|
||||||
|
'position',
|
||||||
|
'contact',
|
||||||
|
'memberNo'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '志愿服务与教育评议',
|
||||||
|
fields: [
|
||||||
|
'volunteerRegTime',
|
||||||
|
'eduEval2024',
|
||||||
|
'smartSystem',
|
||||||
|
'totalVolunteerHours',
|
||||||
|
'volunteerHours2024'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主要事迹与荣誉',
|
||||||
|
fields: ['honorsFiveYears', 'mainStory']
|
||||||
|
},
|
||||||
|
OPINION_SECTION
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_tzbcy_form: {
|
||||||
|
title: '挑战杯',
|
||||||
|
sheetName: '挑战杯',
|
||||||
|
labels: {
|
||||||
|
projectMaterials: '项目介绍材料',
|
||||||
|
otherProofs: '其他相关证明材料',
|
||||||
|
teamMembers: '团队成员',
|
||||||
|
advisors: '指导老师'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '项目信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'projectName',
|
||||||
|
'provinceCity',
|
||||||
|
'schoolName',
|
||||||
|
'projectType',
|
||||||
|
'projectGroup'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: '团队成员', fields: ['teamMembers'] },
|
||||||
|
{ title: '指导老师', fields: ['advisors'] },
|
||||||
|
{
|
||||||
|
title: '项目说明',
|
||||||
|
fields: [
|
||||||
|
'projectBrief',
|
||||||
|
'socialValue',
|
||||||
|
'practiceProcess',
|
||||||
|
'innovationMeaning',
|
||||||
|
'developmentProspect',
|
||||||
|
'teamCooperation',
|
||||||
|
'projectMaterials',
|
||||||
|
'otherProofs'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '公开展示信息',
|
||||||
|
fields: [
|
||||||
|
'publicProjectType',
|
||||||
|
'publicProjectGroup',
|
||||||
|
'projectSummary',
|
||||||
|
'teamIntro',
|
||||||
|
'teamSlogan',
|
||||||
|
'practiceLog'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
materialFields: [],
|
||||||
|
materialSource: 'tzbcy'
|
||||||
|
},
|
||||||
|
gxmu_qmgc_form: {
|
||||||
|
title: '青马工程',
|
||||||
|
sheetName: '青马工程',
|
||||||
|
labels: {
|
||||||
|
hobby: '兴趣爱好',
|
||||||
|
willingToWorkInGuangxi: '是否愿意留桂工作',
|
||||||
|
schoolInfo: '学校信息',
|
||||||
|
leaguePosition: '团内职务',
|
||||||
|
resume: '个人简历',
|
||||||
|
awards: '获得奖励',
|
||||||
|
academicPerformance: '学习成绩',
|
||||||
|
secondaryLeagueOpinion: '二级团组织意见',
|
||||||
|
secondaryLeagueOpinionDate: '二级团组织意见日期',
|
||||||
|
secondaryPartyOpinion: '二级党组织意见',
|
||||||
|
secondaryPartyOpinionDate: '二级党组织意见日期',
|
||||||
|
schoolLeagueOpinion: '学校团组织意见',
|
||||||
|
schoolLeagueOpinionDate: '学校团组织意见日期'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '个人信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'nation',
|
||||||
|
'photo',
|
||||||
|
'birthMonth',
|
||||||
|
'politics',
|
||||||
|
'nativePlace',
|
||||||
|
'phone',
|
||||||
|
'wechat',
|
||||||
|
'email',
|
||||||
|
'qq',
|
||||||
|
'idCardNo',
|
||||||
|
'hobby',
|
||||||
|
'willingToWorkInGuangxi',
|
||||||
|
'schoolInfo',
|
||||||
|
'leaguePosition'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '学习与经历',
|
||||||
|
fields: ['resume', 'awards', 'academicPerformance']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '意见栏',
|
||||||
|
fields: [
|
||||||
|
'secondaryLeagueOpinion',
|
||||||
|
'secondaryLeagueOpinionDate',
|
||||||
|
'secondaryPartyOpinion',
|
||||||
|
'secondaryPartyOpinionDate',
|
||||||
|
'schoolLeagueOpinion',
|
||||||
|
'schoolLeagueOpinionDate'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_wxxzx_project: {
|
||||||
|
title: '未来学术之星',
|
||||||
|
sheetName: '未来学术之星',
|
||||||
|
labels: {
|
||||||
|
topicName: '课题名称',
|
||||||
|
leader: '课题负责人',
|
||||||
|
collegeGradeClass: '学院年级班级',
|
||||||
|
phone: '联系电话',
|
||||||
|
advisor: '指导老师',
|
||||||
|
topicType: '课题类别',
|
||||||
|
guidanceTeachers: '指导教师组',
|
||||||
|
applicants: '申请人信息',
|
||||||
|
budgets: '经费预算',
|
||||||
|
rationale: '立项依据',
|
||||||
|
teacherOpinion: '指导教师意见',
|
||||||
|
teacherSign: '指导教师签名',
|
||||||
|
reviewCollege: '评审学院',
|
||||||
|
reviewReporter: '汇报人',
|
||||||
|
reviewDate: '评审日期',
|
||||||
|
reviewOpinion: '评审意见',
|
||||||
|
reviewScore: '评审成绩',
|
||||||
|
reviewLeaderSign: '评审负责人签名',
|
||||||
|
collegeOpinion: '学院意见',
|
||||||
|
schoolOpinion: '学校意见',
|
||||||
|
reviewMembers: '评审成员',
|
||||||
|
midtermRemark: '中期检查说明',
|
||||||
|
midtermMaterials: '中期检查材料',
|
||||||
|
finalRemark: '结题说明',
|
||||||
|
finalMaterials: '结题材料',
|
||||||
|
archiveRecords: '成果归档',
|
||||||
|
archiveAwards: '获奖情况',
|
||||||
|
archiveProofs: '佐证材料',
|
||||||
|
promiseSigner: '承诺签字人',
|
||||||
|
promiseSignature: '承诺书签名',
|
||||||
|
fundingBudgetFile: '经费预算表',
|
||||||
|
fundingAllocationFile: '经费划拨明细表',
|
||||||
|
proofFile: '证明材料'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '课题信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'topicName',
|
||||||
|
'leader',
|
||||||
|
'collegeGradeClass',
|
||||||
|
'phone',
|
||||||
|
'advisor',
|
||||||
|
'topicType'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: '指导教师组', fields: ['guidanceTeachers'] },
|
||||||
|
{ title: '申请人信息', fields: ['applicants'] },
|
||||||
|
{
|
||||||
|
title: '经费预算',
|
||||||
|
fields: ['budgets', 'fundingBudgetFile', 'fundingAllocationFile']
|
||||||
|
},
|
||||||
|
{ title: '立项依据', fields: ['rationale'] },
|
||||||
|
{
|
||||||
|
title: '审核评审',
|
||||||
|
fields: [
|
||||||
|
'teacherOpinion',
|
||||||
|
'teacherSign',
|
||||||
|
'reviewCollege',
|
||||||
|
'reviewReporter',
|
||||||
|
'reviewDate',
|
||||||
|
'reviewOpinion',
|
||||||
|
'reviewScore',
|
||||||
|
'reviewLeaderSign',
|
||||||
|
'collegeOpinion',
|
||||||
|
'schoolOpinion'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: '评审成员', fields: ['reviewMembers'] },
|
||||||
|
{
|
||||||
|
title: '中期检查与结题',
|
||||||
|
fields: [
|
||||||
|
'midtermRemark',
|
||||||
|
'midtermMaterials',
|
||||||
|
'finalRemark',
|
||||||
|
'finalMaterials'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成果归档',
|
||||||
|
fields: ['archiveRecords', 'archiveAwards', 'archiveProofs']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '承诺书',
|
||||||
|
fields: ['promiseSigner', 'promiseSignature']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
materialFields: [
|
||||||
|
'fundingBudgetFile',
|
||||||
|
'fundingAllocationFile',
|
||||||
|
'midtermMaterials',
|
||||||
|
'finalMaterials',
|
||||||
|
'archiveProofs'
|
||||||
|
],
|
||||||
|
materialTables: [
|
||||||
|
{
|
||||||
|
field: 'archiveRecords',
|
||||||
|
fileField: 'proofFile',
|
||||||
|
titleField: 'awardName',
|
||||||
|
label: '成果归档证明材料'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
gxmu_tygl_form: {
|
||||||
|
title: '团员管理',
|
||||||
|
sheetName: '团员信息登记表',
|
||||||
|
labels: {
|
||||||
|
serialNo: '序号',
|
||||||
|
college: '学院',
|
||||||
|
className: '班级',
|
||||||
|
phone: '手机号码',
|
||||||
|
leaguePosition: '团内职务',
|
||||||
|
branchOrganizationName: '所属团支部',
|
||||||
|
idCardNo: '身份证号',
|
||||||
|
birthDate: '出生日期',
|
||||||
|
archiveInCurrentOrg: '团籍是否在本组织',
|
||||||
|
joinMonth: '入团年月',
|
||||||
|
memberRecords: '团员记录',
|
||||||
|
growthArchives: '成长档案'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: [
|
||||||
|
'serialNo',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'nation',
|
||||||
|
'politics',
|
||||||
|
'college',
|
||||||
|
'className',
|
||||||
|
'phone',
|
||||||
|
'leaguePosition',
|
||||||
|
'branchOrganizationName',
|
||||||
|
'idCardNo',
|
||||||
|
'birthDate',
|
||||||
|
'archiveInCurrentOrg',
|
||||||
|
'joinMonth'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: '团员记录', fields: ['memberRecords'] },
|
||||||
|
{ title: '成长档案', fields: ['growthArchives'] }
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
cms_manuscript: {
|
||||||
|
title: '稿件',
|
||||||
|
sheetName: '稿件',
|
||||||
|
labels: {
|
||||||
|
title: '标题',
|
||||||
|
content: '正文',
|
||||||
|
contentFormat: '正文格式',
|
||||||
|
cover: '封面',
|
||||||
|
status: '状态',
|
||||||
|
sortNumber: '排序'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: ['title', 'status', 'sortNumber']
|
||||||
|
},
|
||||||
|
{ title: '封面', fields: ['cover'] },
|
||||||
|
{ title: '正文', fields: ['content'] }
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 团员管理模块历史上存在两个模块标识
|
||||||
|
MODULE_DETAIL_CONFIGS.gxmu_tygl = MODULE_DETAIL_CONFIGS.gxmu_tygl_form;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明细数组表头字段中文名
|
||||||
|
*/
|
||||||
|
export const TABLE_FIELD_LABELS: Record<string, string> = {
|
||||||
|
name: '姓名',
|
||||||
|
gender: '性别',
|
||||||
|
nation: '民族',
|
||||||
|
politics: '政治面貌',
|
||||||
|
birthMonth: '出生年月',
|
||||||
|
title: '职称',
|
||||||
|
duty: '职务',
|
||||||
|
department: '部门',
|
||||||
|
dept: '部门',
|
||||||
|
remark: '备注',
|
||||||
|
college: '学院',
|
||||||
|
gradeMajor: '年级专业',
|
||||||
|
grade: '年级',
|
||||||
|
className: '班级',
|
||||||
|
major: '专业',
|
||||||
|
role: '角色',
|
||||||
|
phone: '联系电话',
|
||||||
|
sign: '签名',
|
||||||
|
item: '项目',
|
||||||
|
amount: '金额',
|
||||||
|
reason: '原因',
|
||||||
|
type: '类型',
|
||||||
|
content: '内容',
|
||||||
|
recordTime: '时间',
|
||||||
|
archiveTime: '时间',
|
||||||
|
awardName: '获奖名称',
|
||||||
|
awardTime: '获奖时间',
|
||||||
|
archiveOrder: '序号',
|
||||||
|
proofFile: '证明材料',
|
||||||
|
year: '年份'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getModuleDetailConfig = (module?: string) => {
|
||||||
|
if (!module) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return MODULE_DETAIL_CONFIGS[module];
|
||||||
|
};
|
||||||
@@ -15,7 +15,9 @@
|
|||||||
<a-tag>{{ moduleName || module || '-' }}</a-tag>
|
<a-tag>{{ moduleName || module || '-' }}</a-tag>
|
||||||
<span>业务ID:</span>
|
<span>业务ID:</span>
|
||||||
<a-tag>{{ pk ?? '-' }}</a-tag>
|
<a-tag>{{ pk ?? '-' }}</a-tag>
|
||||||
<a-button size="small" :loading="loading" @click="loadData">刷新</a-button>
|
<a-button size="small" :loading="loading" @click="loadData"
|
||||||
|
>刷新</a-button
|
||||||
|
>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
|
||||||
<div v-if="flowList.length" class="flow-wrap">
|
<div v-if="flowList.length" class="flow-wrap">
|
||||||
@@ -23,7 +25,9 @@
|
|||||||
<a-step v-for="item in flowList" :key="item.id">
|
<a-step v-for="item in flowList" :key="item.id">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flow-title">
|
<div class="flow-title">
|
||||||
<span>{{ item.user?.realName || item.user?.username || '未分配审核人' }}</span>
|
<span>{{
|
||||||
|
item.user?.realName || item.user?.username || '未分配审核人'
|
||||||
|
}}</span>
|
||||||
<a-tag :color="getStatusColor(item.status)">
|
<a-tag :color="getStatusColor(item.status)">
|
||||||
{{ getStatusText(item.status) }}
|
{{ getStatusText(item.status) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
@@ -31,14 +35,28 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #description>
|
<template #description>
|
||||||
<div class="flow-desc">
|
<div class="flow-desc">
|
||||||
<div>创建时间:{{ toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss') }}</div>
|
<div
|
||||||
|
>创建时间:{{
|
||||||
|
toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss')
|
||||||
|
}}</div
|
||||||
|
>
|
||||||
<div v-if="item.content">审核意见:{{ item.content }}</div>
|
<div v-if="item.content">审核意见:{{ item.content }}</div>
|
||||||
<a-space v-if="canReview(item)" style="margin-top: 12px">
|
<a-space v-if="canReview(item)" style="margin-top: 12px">
|
||||||
<a-button type="primary" size="small" :loading="submitting" @click="openActionModal('approve', item)">
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="openActionModal('approve', item)"
|
||||||
|
>
|
||||||
通过
|
通过
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button danger size="small" :loading="submitting" @click="openActionModal('reject', item)">
|
<a-button
|
||||||
拒绝
|
danger
|
||||||
|
size="small"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="openActionModal('reject', item)"
|
||||||
|
>
|
||||||
|
驳回
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,19 +68,26 @@
|
|||||||
|
|
||||||
<a-modal
|
<a-modal
|
||||||
v-model:visible="actionVisible"
|
v-model:visible="actionVisible"
|
||||||
:title="actionType === 'approve' ? '审核通过' : '审核拒绝'"
|
:title="actionType === 'approve' ? '审核通过' : '审核驳回'"
|
||||||
ok-text="确认"
|
ok-text="确认"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
:confirm-loading="submitting"
|
:confirm-loading="submitting"
|
||||||
@ok="submitReview"
|
@ok="submitReview"
|
||||||
>
|
>
|
||||||
<a-form layout="vertical">
|
<a-form
|
||||||
<a-form-item label="审核意见(非必填)">
|
ref="actionFormRef"
|
||||||
|
layout="vertical"
|
||||||
|
:model="actionForm"
|
||||||
|
:rules="actionRules"
|
||||||
|
>
|
||||||
|
<a-form-item label="审核意见" name="content">
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-model:value="actionContent"
|
v-model:value="actionForm.content"
|
||||||
:rows="4"
|
:rows="4"
|
||||||
|
:maxlength="500"
|
||||||
|
show-count
|
||||||
allow-clear
|
allow-clear
|
||||||
placeholder="请输入审核意见"
|
placeholder="请输入审核意见(必填)"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
@@ -71,8 +96,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { Empty, message } from 'ant-design-vue';
|
import { Empty, message } from 'ant-design-vue';
|
||||||
|
import type { FormInstance } from 'ant-design-vue/es/form';
|
||||||
import { toDateString } from 'ele-admin-pro';
|
import { toDateString } from 'ele-admin-pro';
|
||||||
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
|
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
|
||||||
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
||||||
@@ -98,9 +124,26 @@ const submitting = ref(false);
|
|||||||
const flowList = ref<ReviewList[]>([]);
|
const flowList = ref<ReviewList[]>([]);
|
||||||
const actionVisible = ref(false);
|
const actionVisible = ref(false);
|
||||||
const actionType = ref<'approve' | 'reject'>('approve');
|
const actionType = ref<'approve' | 'reject'>('approve');
|
||||||
const actionContent = ref('');
|
const actionFormRef = ref<FormInstance | null>(null);
|
||||||
|
const actionForm = reactive<{ content: string }>({ content: '' });
|
||||||
const actionTarget = ref<ReviewList | null>(null);
|
const actionTarget = ref<ReviewList | null>(null);
|
||||||
|
|
||||||
|
// 审核意见必填(仅空白字符视为未填写)
|
||||||
|
const actionRules = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
validator: (_rule: unknown, value: string) => {
|
||||||
|
const text = (value || '').trim();
|
||||||
|
return text
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(new Error('请输入审核意见'));
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
const currentStepIndex = computed(() => {
|
const currentStepIndex = computed(() => {
|
||||||
const pendingIndex = flowList.value.findIndex((item) => item.status === 0);
|
const pendingIndex = flowList.value.findIndex((item) => item.status === 0);
|
||||||
if (pendingIndex >= 0) {
|
if (pendingIndex >= 0) {
|
||||||
@@ -115,7 +158,7 @@ const updateVisible = (value: boolean) => {
|
|||||||
|
|
||||||
const getStatusText = (status?: number) => {
|
const getStatusText = (status?: number) => {
|
||||||
if (status === 1) return '通过';
|
if (status === 1) return '通过';
|
||||||
if (status === 2) return '拒绝';
|
if (status === 2) return '驳回';
|
||||||
return '待审核';
|
return '待审核';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,28 +203,32 @@ const openActionModal = (type: 'approve' | 'reject', item: ReviewList) => {
|
|||||||
}
|
}
|
||||||
actionType.value = type;
|
actionType.value = type;
|
||||||
actionTarget.value = item;
|
actionTarget.value = item;
|
||||||
actionContent.value = '';
|
actionForm.content = '';
|
||||||
actionVisible.value = true;
|
actionVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitReview = () => {
|
const submitReview = () => {
|
||||||
if (!actionTarget.value?.id) {
|
const target = actionTarget.value;
|
||||||
|
if (!target?.id) {
|
||||||
actionVisible.value = false;
|
actionVisible.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!canReview(actionTarget.value)) {
|
if (!canReview(target)) {
|
||||||
actionVisible.value = false;
|
actionVisible.value = false;
|
||||||
message.error('当前记录不是由你审核');
|
message.error('当前记录不是由你审核');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
actionFormRef.value
|
||||||
|
?.validate()
|
||||||
|
.then(() => {
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
updateReviewList({
|
return updateReviewList({
|
||||||
id: actionTarget.value.id,
|
id: target.id,
|
||||||
status: actionType.value === 'approve' ? 1 : 2,
|
status: actionType.value === 'approve' ? 1 : 2,
|
||||||
module: actionTarget.value.module,
|
module: target.module,
|
||||||
pk: actionTarget.value.pk,
|
pk: target.pk,
|
||||||
sortNumber: actionTarget.value.sortNumber,
|
sortNumber: target.sortNumber,
|
||||||
content: actionContent.value || undefined
|
content: actionForm.content.trim()
|
||||||
})
|
})
|
||||||
.then((msg) => {
|
.then((msg) => {
|
||||||
message.success(msg);
|
message.success(msg);
|
||||||
@@ -195,6 +242,8 @@ const submitReview = () => {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -206,7 +255,7 @@ watch(
|
|||||||
flowList.value = [];
|
flowList.value = [];
|
||||||
actionVisible.value = false;
|
actionVisible.value = false;
|
||||||
actionTarget.value = null;
|
actionTarget.value = null;
|
||||||
actionContent.value = '';
|
actionForm.content = '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
v-model:value="form.pk"
|
v-model:value="form.pk"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="状态(0不通过 1通过)" name="status">
|
<a-form-item label="状态(0驳回 1通过)" name="status">
|
||||||
<a-radio-group v-model:value="form.status">
|
<a-radio-group v-model:value="form.status">
|
||||||
<a-radio :value="1">显示</a-radio>
|
<a-radio :value="1">显示</a-radio>
|
||||||
<a-radio :value="0">隐藏</a-radio>
|
<a-radio :value="0">隐藏</a-radio>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<template v-if="column.key === 'status'">
|
<template v-if="column.key === 'status'">
|
||||||
<a-tag v-if="record.status === 0">待审核</a-tag>
|
<a-tag v-if="record.status === 0">待审核</a-tag>
|
||||||
<a-tag v-else-if="record.status === 1" color="green">通过</a-tag>
|
<a-tag v-else-if="record.status === 1" color="green">通过</a-tag>
|
||||||
<a-tag v-else-if="record.status === 2" color="red">拒绝</a-tag>
|
<a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<a-space>
|
<a-space>
|
||||||
@@ -36,12 +36,17 @@
|
|||||||
</ele-pro-table>
|
</ele-pro-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<ReviewListEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
<ReviewListEdit
|
||||||
|
v-model:visible="showEdit"
|
||||||
|
:data="current"
|
||||||
|
@done="reload"
|
||||||
|
/>
|
||||||
<ModuleDataModal
|
<ModuleDataModal
|
||||||
v-model:visible="showDataVisible"
|
v-model:visible="showDataVisible"
|
||||||
:module="dataCurrent?.module"
|
:module="dataCurrent?.module"
|
||||||
:module-name="dataCurrent?.moduleName"
|
:module-name="dataCurrent?.moduleName"
|
||||||
:pk="dataCurrent?.pk"
|
:pk="dataCurrent?.pk"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewFlowModal
|
<ReviewFlowModal
|
||||||
v-model:visible="showFlowVisible"
|
v-model:visible="showFlowVisible"
|
||||||
@@ -60,13 +65,22 @@ import { message, Modal } from 'ant-design-vue';
|
|||||||
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
||||||
import type { EleProTable } from 'ele-admin-pro';
|
import type { EleProTable } from 'ele-admin-pro';
|
||||||
import { toDateString } from 'ele-admin-pro';
|
import { toDateString } from 'ele-admin-pro';
|
||||||
import type { DatasourceFunction, ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
|
import type {
|
||||||
|
DatasourceFunction,
|
||||||
|
ColumnItem
|
||||||
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
import Search from './components/search.vue';
|
import Search from './components/search.vue';
|
||||||
import ReviewListEdit from './components/reviewListEdit.vue';
|
import ReviewListEdit from './components/reviewListEdit.vue';
|
||||||
import ModuleDataModal from './components/moduleDataModal.vue';
|
import ModuleDataModal from './components/moduleDataModal.vue';
|
||||||
import ReviewFlowModal from './components/reviewFlowModal.vue';
|
import ReviewFlowModal from './components/reviewFlowModal.vue';
|
||||||
import { pageGroupReviewList, removeBatchReviewList } from '@/api/gxmu/reviewList';
|
import {
|
||||||
import type { ReviewList, ReviewListParam } from '@/api/gxmu/reviewList/model';
|
pageGroupReviewList,
|
||||||
|
removeBatchReviewList
|
||||||
|
} from '@/api/gxmu/reviewList';
|
||||||
|
import type {
|
||||||
|
ReviewList,
|
||||||
|
ReviewListParam
|
||||||
|
} from '@/api/gxmu/reviewList/model';
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
const selection = ref<ReviewList[]>([]);
|
const selection = ref<ReviewList[]>([]);
|
||||||
@@ -79,7 +93,13 @@ const showFlowVisible = ref(false);
|
|||||||
const showMove = ref(false);
|
const showMove = ref(false);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
const datasource: DatasourceFunction = ({ page, limit, where, orders, filters }) => {
|
const datasource: DatasourceFunction = ({
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
where,
|
||||||
|
orders,
|
||||||
|
filters
|
||||||
|
}) => {
|
||||||
if (filters) {
|
if (filters) {
|
||||||
where.status = filters.status;
|
where.status = filters.status;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
export interface AttachmentFile {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_EXT_PATTERN =
|
||||||
|
/\.(pdf|docx?|xlsx?|pptx?|csv|txt|zip|rar|7z|jpe?g|png|gif|webp|bmp|svg|mp4|mp3|wav)$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件地址中取出展示用的文件名
|
||||||
|
*/
|
||||||
|
export function getAttachmentName(url?: string) {
|
||||||
|
if (!url) {
|
||||||
|
return '未命名文件';
|
||||||
|
}
|
||||||
|
const cleanUrl = url.split('?')[0];
|
||||||
|
const name = cleanUrl.split('/').pop() || cleanUrl;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(name);
|
||||||
|
} catch (e) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断字符串是否像文件地址(http(s)、协议相对地址、带扩展名的相对路径)
|
||||||
|
*/
|
||||||
|
export function isFileUrl(value?: string) {
|
||||||
|
const text = value?.trim();
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^(https?:)?\/\//i.test(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return FILE_EXT_PATTERN.test(text) && !/\s/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageFile(url?: string) {
|
||||||
|
const text = (url || '').split('?')[0];
|
||||||
|
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeEntry = (item: unknown): AttachmentFile | null => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return isFileUrl(item)
|
||||||
|
? { name: getAttachmentName(item), url: item.trim() }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const rawUrl = [
|
||||||
|
record.url,
|
||||||
|
record.fileUrl,
|
||||||
|
record.downloadUrl,
|
||||||
|
record.path
|
||||||
|
].find((value) => typeof value === 'string' && isFileUrl(value as string));
|
||||||
|
if (typeof rawUrl !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rawName = [record.name, record.fileName, record.originalName].find(
|
||||||
|
(value) => typeof value === 'string' && (value as string).trim()
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: (rawName as string | undefined) || getAttachmentName(rawUrl),
|
||||||
|
url: rawUrl.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把后端存储的附件字段解析成文件列表。
|
||||||
|
* 兼容:单个地址、JSON 数组字符串、对象数组(name/url、fileName/fileUrl 等)
|
||||||
|
*/
|
||||||
|
export function parseAttachments(value: unknown): AttachmentFile[] {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (text.startsWith('[') || text.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
return parseAttachments(JSON.parse(text));
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败时按普通地址处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const single = normalizeEntry(text);
|
||||||
|
return single ? [single] : [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => normalizeEntry(item))
|
||||||
|
.filter((item): item is AttachmentFile => !!item);
|
||||||
|
}
|
||||||
|
const single = normalizeEntry(value);
|
||||||
|
return single ? [single] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把附件字段解析结果转换成图片地址列表
|
||||||
|
*/
|
||||||
|
export function parseAttachmentImages(value: unknown) {
|
||||||
|
const urls: string[] = [];
|
||||||
|
parseAttachments(value).forEach((item) => {
|
||||||
|
if (isImageFile(item.url)) {
|
||||||
|
urls.push(item.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!urls.length && typeof value === 'string' && isImageFile(value)) {
|
||||||
|
urls.push(value);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
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 ?? [];
|
const list = reviewList ?? [];
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
return false;
|
return undefined;
|
||||||
|
}
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const sortA = Number(a.sortNumber ?? 0);
|
||||||
|
const sortB = Number(b.sortNumber ?? 0);
|
||||||
|
if (sortA !== sortB) {
|
||||||
|
return sortB - sortA;
|
||||||
}
|
}
|
||||||
const latest = [...list].sort((a, b) => {
|
|
||||||
const timeA = Date.parse(a.createTime || '');
|
const timeA = Date.parse(a.createTime || '');
|
||||||
const timeB = Date.parse(b.createTime || '');
|
const timeB = Date.parse(b.createTime || '');
|
||||||
const safeTimeA = Number.isFinite(timeA) ? timeA : 0;
|
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);
|
return Number(b.id || 0) - Number(a.id || 0);
|
||||||
})[0];
|
})[0];
|
||||||
return latest?.status !== 2;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最新记录不是驳回时,隐藏编辑入口(待审核或已通过都不允许改)。
|
||||||
|
*/
|
||||||
|
export function shouldHideEditByReviewList(reviewList?: ReviewList[]) {
|
||||||
|
const list = reviewList ?? [];
|
||||||
|
if (!list.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return getLatestReview(list)?.status !== 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const router = useRouter();
|
|||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
title: "AI+党建的双向赋能",
|
title: "AI+团建的双向赋能",
|
||||||
desc: "赋能广西医科大学团委的各项工作。项目遵循“先基础,后智能”的原则,信息化和智能化相结合,确保项目稳步推进,价值逐步释放。"
|
desc: "赋能广西医科大学团委的各项工作。项目遵循“先基础,后智能”的原则,信息化和智能化相结合,确保项目稳步推进,价值逐步释放。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
class="ele-btn-icon"
|
class="ele-btn-icon"
|
||||||
@click="openUrl('https://p.haoii123.com/')"
|
@click="openUrl('https://p.haoii123.com/')"
|
||||||
>
|
>
|
||||||
一键排版
|
公众号排版
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
:width="460"
|
:width="460"
|
||||||
:visible="visible"
|
:visible="visible"
|
||||||
:confirm-loading="loading"
|
:confirm-loading="loading"
|
||||||
:title="isUpdate ? '修改角色' : '添加角色'"
|
:title="isUpdate ? '编辑角色' : '添加角色'"
|
||||||
:body-style="{ paddingBottom: '8px' }"
|
:body-style="{ paddingBottom: '8px' }"
|
||||||
@update:visible="updateVisible"
|
@update:visible="updateVisible"
|
||||||
@ok="save"
|
@ok="save"
|
||||||
@@ -26,10 +26,17 @@
|
|||||||
v-model:value="form.roleName"
|
v-model:value="form.roleName"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="角色标识" name="roleCode">
|
<a-form-item
|
||||||
|
label="角色标识"
|
||||||
|
name="roleCode"
|
||||||
|
:extra="
|
||||||
|
isUpdate ? '角色标识参与审批流程判断,创建后不可修改' : undefined
|
||||||
|
"
|
||||||
|
>
|
||||||
<a-input
|
<a-input
|
||||||
allow-clear
|
allow-clear
|
||||||
:maxlength="180"
|
:maxlength="180"
|
||||||
|
:disabled="isUpdate"
|
||||||
placeholder="请输入角色标识"
|
placeholder="请输入角色标识"
|
||||||
v-model:value="form.roleCode"
|
v-model:value="form.roleCode"
|
||||||
/>
|
/>
|
||||||
@@ -119,7 +126,15 @@
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const saveOrUpdate = isUpdate.value ? updateRole : addRole;
|
const saveOrUpdate = isUpdate.value ? updateRole : addRole;
|
||||||
saveOrUpdate(form)
|
// 角色标识参与审批流程判断,修改时只提交可编辑的字段
|
||||||
|
const data: Role = isUpdate.value
|
||||||
|
? {
|
||||||
|
roleId: form.roleId,
|
||||||
|
roleName: form.roleName,
|
||||||
|
comments: form.comments
|
||||||
|
}
|
||||||
|
: { ...form };
|
||||||
|
saveOrUpdate(data)
|
||||||
.then((msg) => {
|
.then((msg) => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
message.success(msg);
|
message.success(msg);
|
||||||
|
|||||||
@@ -37,13 +37,11 @@
|
|||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<a-space>
|
<a-space>
|
||||||
<template v-if="canEdit(record)">
|
<a @click="openEdit(record)">编辑</a>
|
||||||
<a @click="openEdit(record)">修改</a>
|
|
||||||
<a-divider type="vertical" />
|
<a-divider type="vertical" />
|
||||||
</template>
|
|
||||||
<a @click="openAuth(record)">分配权限</a>
|
<a @click="openAuth(record)">分配权限</a>
|
||||||
|
<template v-if="canRemove(record)">
|
||||||
<a-divider type="vertical" />
|
<a-divider type="vertical" />
|
||||||
<template v-if="canEdit(record)">
|
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
placement="topRight"
|
placement="topRight"
|
||||||
title="确定要删除此角色吗?"
|
title="确定要删除此角色吗?"
|
||||||
@@ -132,7 +130,7 @@ const columns = ref<ColumnItem[]>([
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canEdit = (record: Role) => {
|
const canRemove = (record: Role) => {
|
||||||
// return ['student'].includes(record?.roleCode)
|
// return ['student'].includes(record?.roleCode)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,7 +263,7 @@
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import type { User } from '@/api/system/user/model';
|
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 SexSelect from './components/sex-select.vue';
|
||||||
import { getMobile } from '@/utils/common';
|
import { getMobile } from '@/utils/common';
|
||||||
import request from '@/utils/request';
|
import request from '@/utils/request';
|
||||||
@@ -378,9 +378,16 @@
|
|||||||
form.province = city.value[0];
|
form.province = city.value[0];
|
||||||
form.city = city.value[1];
|
form.city = city.value[1];
|
||||||
form.region = city.value[2];
|
form.region = city.value[2];
|
||||||
updateUser(form).then((res) => {
|
// 个人中心走 /system/user/self:学生账号没有 sys:user:update 权限,
|
||||||
|
// 用管理员接口会直接 403
|
||||||
|
updateSelfUser(form)
|
||||||
|
.then(() => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
message.success('保存成功');
|
message.success('保存成功');
|
||||||
|
})
|
||||||
|
.catch((e: any) => {
|
||||||
|
loading.value = false;
|
||||||
|
message.error(e?.message || '保存失败');
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -397,9 +404,16 @@
|
|||||||
console.log(res.data.data.path);
|
console.log(res.data.data.path);
|
||||||
form.avatar = FILE_SERVER + res.data.data.path;
|
form.avatar = FILE_SERVER + res.data.data.path;
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
updateUser(form).then((res) => {
|
// 个人中心走 /system/user/self:学生账号没有 sys:user:update 权限,
|
||||||
|
// 用管理员接口会直接 403
|
||||||
|
updateSelfUser(form)
|
||||||
|
.then(() => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
message.success('保存成功');
|
message.success('保存成功');
|
||||||
|
})
|
||||||
|
.catch((e: any) => {
|
||||||
|
loading.value = false;
|
||||||
|
message.error(e?.message || '保存失败');
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -412,7 +426,7 @@
|
|||||||
console.log(result);
|
console.log(result);
|
||||||
form.avatar = result;
|
form.avatar = result;
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
updateUser(form).then((res) => {
|
updateSelfUser(form).then(() => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
message.success('保存成功');
|
message.success('保存成功');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user