diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportController.java new file mode 100644 index 00000000..e8df2537 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportController.java @@ -0,0 +1,70 @@ +package org.dromara.aihr.report; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.report.AihrWorkReportDto.AttachmentResponse; +import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest; +import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse; +import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest; +import org.dromara.common.core.domain.R; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.service.ISysOssService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 工作上报:员工提交优秀案例/操作视频/完整SOP/有用知识,主管或运营审核。 + */ +@RestController +@RequiredArgsConstructor +@SaCheckLogin +@RequestMapping("/api/aihr/work-report") +public class AihrWorkReportController { + + private static final long MAX_ATTACHMENT_BYTES = 100L * 1024 * 1024; + + private final AihrWorkReportService reportService; + private final ISysOssService ossService; + + @PostMapping("/reports") + public R create(@RequestBody CreateReportRequest request) { + return R.ok(reportService.create(request)); + } + + @PostMapping(value = "/attachment", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R uploadAttachment(@RequestPart("file") MultipartFile file) { + if (file == null || file.isEmpty()) { + return R.fail("请选择附件文件"); + } + if (file.getSize() > MAX_ATTACHMENT_BYTES) { + return R.fail("附件不能超过 100MB"); + } + SysOssVo oss = ossService.upload(file); + return R.ok(new AttachmentResponse(oss.getOssId(), file.getOriginalFilename())); + } + + @GetMapping("/reports/mine") + public R> mine() { + return R.ok(reportService.myReports()); + } + + @GetMapping("/reports") + public R> list(@RequestParam(value = "status", required = false) String status) { + return R.ok(reportService.pendingReports(status)); + } + + @PostMapping("/reports/{id}/review") + public R review(@PathVariable Long id, @RequestBody ReviewReportRequest request) { + return R.ok(reportService.review(id, request)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportDto.java new file mode 100644 index 00000000..0dd1db9e --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportDto.java @@ -0,0 +1,44 @@ +package org.dromara.aihr.report; + +import java.util.List; + +/** + * 工作上报 DTO:员工提交四类上报(案例/操作视频/SOP/知识),主管或运营审核。 + * 审核通过后由运营在对应库(案例库/知识库)完成最终入库,上报模块只管收集与审核。 + */ +public final class AihrWorkReportDto { + + private AihrWorkReportDto() { + } + + public record CreateReportRequest(String type, + String title, + String content, + Long attachmentOssId, + String attachmentName) { + } + + public record ReviewReportRequest(String decision, String note) { + } + + public record AttachmentResponse(Long ossId, String fileName) { + } + + public record ReportResponse(Long id, + String type, + String typeLabel, + String title, + String content, + String attachmentName, + Long attachmentOssId, + String status, + String statusLabel, + String submitterName, + String reviewNote, + String createdAt, + String reviewedAt) { + } + + public record ReportListResponse(List records) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportService.java new file mode 100644 index 00000000..440c7e13 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportService.java @@ -0,0 +1,248 @@ +package org.dromara.aihr.report; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.community.AihrCommunityPolicy; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest; +import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse; +import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 工作上报:员工提交 → 主管/运营审核(通过/驳回)。 + * 通过后的入库动作(案例库/知识库)由运营在对应模块人工完成,不自动入库。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AihrWorkReportService { + + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm"); + private static final Map TYPE_LABELS = Map.of( + "CASE", "优秀案例", + "VIDEO", "操作视频", + "SOP", "完整SOP", + "KNOWLEDGE", "有用知识" + ); + private static final Map STATUS_LABELS = Map.of( + "PENDING", "待审核", + "APPROVED", "已通过", + "REJECTED", "已驳回" + ); + private static final Set REVIEW_DECISIONS = Set.of("APPROVE", "REJECT"); + private static final int MAX_TITLE = 100; + private static final int MAX_CONTENT = 2000; + + private final JdbcTemplate jdbcTemplate; + private final AihrKnowledgePrincipalResolver principalResolver; + + private volatile boolean tableReady; + + public ReportResponse create(CreateReportRequest request) { + ensureTable(); + AihrKnowledgePrincipal principal = principalResolver.current(); + String type = clean(request == null ? null : request.type()).toUpperCase(); + if (!TYPE_LABELS.containsKey(type)) { + throw new ServiceException("上报类型无效,仅支持:优秀案例/操作视频/完整SOP/有用知识", 400); + } + String title = clean(request.title()); + if (title.isBlank()) { + throw new ServiceException("请填写上报标题", 400); + } + String content = clean(request.content()); + if (content.isBlank()) { + throw new ServiceException("请填写上报说明", 400); + } + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.update(""" + INSERT INTO aihr_work_report + (tenant_id, report_type, title, content, attachment_oss_id, attachment_name, + status, submitter_user_id, submitter_identity, submitter_name, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?, ?) + """, + principal.tenantId(), + type, + truncate(title, MAX_TITLE), + truncate(content, MAX_CONTENT), + request.attachmentOssId(), + truncate(clean(request.attachmentName()), 200), + principal.userId(), + clean(principal.extPartyId()), + displayName(principal), + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ); + Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + return byId(id, principal.tenantId()); + } + + public List myReports() { + ensureTable(); + AihrKnowledgePrincipal principal = principalResolver.current(); + return jdbcTemplate.query(SELECT_SQL + """ + WHERE tenant_id = ? AND submitter_user_id = ? + ORDER BY id DESC + LIMIT 50 + """, this::mapRow, principal.tenantId(), principal.userId()); + } + + public List pendingReports(String status) { + ensureTable(); + AihrKnowledgePrincipal principal = requireModerator(); + String filter = clean(status).toUpperCase(); + if (!filter.isBlank() && !STATUS_LABELS.containsKey(filter)) { + filter = ""; + } + if (filter.isBlank()) { + return jdbcTemplate.query(SELECT_SQL + """ + WHERE tenant_id = ? + ORDER BY CASE status WHEN 'PENDING' THEN 0 ELSE 1 END, id DESC + LIMIT 100 + """, this::mapRow, principal.tenantId()); + } + return jdbcTemplate.query(SELECT_SQL + """ + WHERE tenant_id = ? AND status = ? + ORDER BY id DESC + LIMIT 100 + """, this::mapRow, principal.tenantId(), filter); + } + + public ReportResponse review(Long id, ReviewReportRequest request) { + ensureTable(); + AihrKnowledgePrincipal principal = requireModerator(); + String decision = clean(request == null ? null : request.decision()).toUpperCase(); + if (!REVIEW_DECISIONS.contains(decision)) { + throw new ServiceException("审核结论无效,仅支持 APPROVE / REJECT", 400); + } + String nextStatus = "APPROVE".equals(decision) ? "APPROVED" : "REJECTED"; + int updated = jdbcTemplate.update(""" + UPDATE aihr_work_report + SET status = ?, review_note = ?, reviewer_user_id = ?, reviewer_name = ?, + review_time = ?, update_time = ? + WHERE tenant_id = ? AND id = ? AND status = 'PENDING' + """, + nextStatus, + truncate(clean(request.note()), 500), + principal.userId(), + displayName(principal), + Timestamp.valueOf(LocalDateTime.now()), + Timestamp.valueOf(LocalDateTime.now()), + principal.tenantId(), + id + ); + if (updated == 0) { + throw new ServiceException("该上报不存在或已审核", 400); + } + return byId(id, principal.tenantId()); + } + + private ReportResponse byId(Long id, String tenantId) { + List rows = jdbcTemplate.query(SELECT_SQL + " WHERE tenant_id = ? AND id = ?", + this::mapRow, tenantId, id); + if (rows.isEmpty()) { + throw new ServiceException("上报记录不存在", 404); + } + return rows.get(0); + } + + private static final String SELECT_SQL = """ + SELECT id, report_type, title, content, attachment_oss_id, attachment_name, + status, submitter_name, review_note, create_time, review_time + FROM aihr_work_report + """; + + private ReportResponse mapRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + String type = rs.getString("report_type"); + String status = rs.getString("status"); + return new ReportResponse( + rs.getLong("id"), + type, + TYPE_LABELS.getOrDefault(type, type), + rs.getString("title"), + rs.getString("content"), + rs.getString("attachment_name"), + rs.getObject("attachment_oss_id", Long.class), + status, + STATUS_LABELS.getOrDefault(status, status), + rs.getString("submitter_name"), + rs.getString("review_note"), + time(rs.getTimestamp("create_time")), + time(rs.getTimestamp("review_time")) + ); + } + + private AihrKnowledgePrincipal requireModerator() { + AihrKnowledgePrincipal principal = principalResolver.current(); + if (!AihrCommunityPolicy.canModerate(principal)) { + throw new ServiceException("仅主管或管理员可执行该操作", 403); + } + return principal; + } + + private void ensureTable() { + if (tableReady) { + return; + } + synchronized (this) { + if (tableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_work_report` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号', + `report_type` varchar(20) COLLATE utf8mb4_general_ci NOT NULL COMMENT '类型 CASE/VIDEO/SOP/KNOWLEDGE', + `title` varchar(100) COLLATE utf8mb4_general_ci NOT NULL COMMENT '标题', + `content` varchar(2000) COLLATE utf8mb4_general_ci NOT NULL COMMENT '说明', + `attachment_oss_id` bigint DEFAULT NULL COMMENT '附件OSS编号', + `attachment_name` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '附件名', + `status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/APPROVED/REJECTED', + `submitter_user_id` bigint NOT NULL COMMENT '提交人用户ID', + `submitter_identity` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '提交人身份', + `submitter_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '提交人显示名', + `reviewer_user_id` bigint DEFAULT NULL COMMENT '审核人用户ID', + `reviewer_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核人显示名', + `review_note` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核意见', + `review_time` datetime DEFAULT NULL COMMENT '审核时间', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_work_report_submitter` (`tenant_id`, `submitter_user_id`, `id`), + KEY `idx_aihr_work_report_status` (`tenant_id`, `status`, `id`) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '工作上报' + """); + tableReady = true; + } + } + + private static String displayName(AihrKnowledgePrincipal principal) { + String identity = clean(principal.extPartyId()); + if (identity.isBlank()) { + return "管理员" + principal.userId(); + } + return identity.length() <= 4 ? identity : "员工" + identity.substring(identity.length() - 4); + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static String truncate(String value, int max) { + return value.length() <= max ? value : value.substring(0, max); + } + + private static String time(Timestamp value) { + return value == null ? "" : value.toLocalDateTime().format(TIME_FORMAT); + } +} diff --git a/mobile-uni/src/pages.json b/mobile-uni/src/pages.json index 8aded314..68bc89fe 100644 --- a/mobile-uni/src/pages.json +++ b/mobile-uni/src/pages.json @@ -79,6 +79,12 @@ "navigationBarTitleText": "我的积分" } }, + { + "path": "pages/user/report/index", + "style": { + "navigationBarTitleText": "工作上报" + } + }, { "path": "pages/user/exam/index", "style": { diff --git a/mobile-uni/src/pages/user/profile/index.vue b/mobile-uni/src/pages/user/profile/index.vue index e34fe10e..e785f4e3 100644 --- a/mobile-uni/src/pages/user/profile/index.vue +++ b/mobile-uni/src/pages/user/profile/index.vue @@ -145,7 +145,7 @@ 报 工作上报 - 日报 / 任务 / 问题上报 · 建设中 + 上报案例 / 视频 / SOP / 知识 › @@ -298,7 +298,7 @@ const openPracticeSummary = () => { }; const openReport = () => { - uni.navigateTo({ url: `/pages/common/building/index?title=${encodeURIComponent('工作上报')}` }); + uni.navigateTo({ url: '/pages/user/report/index' }); }; const clearProfileData = () => { diff --git a/mobile-uni/src/pages/user/report/index.vue b/mobile-uni/src/pages/user/report/index.vue new file mode 100644 index 00000000..613ec304 --- /dev/null +++ b/mobile-uni/src/pages/user/report/index.vue @@ -0,0 +1,527 @@ +