diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java index bff0ab2..a9783fc 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java @@ -55,6 +55,7 @@ public class SysUserController extends BaseController { private final ISysRoleService roleService; private final ISysPostService postService; private final ISysDeptService deptService; + private final com.ruoyi.oa.im.ImSendService imSendService; /** * 获取用户列表 @@ -156,7 +157,12 @@ public class SysUserController extends BaseController { return R.fail("新增用户'" + user.getUserName() + "'失败,邮箱账号已存在"); } user.setPassword(BCrypt.hashpw(user.getPassword())); - return toAjax(userService.insertUser(user)); + int rows = userService.insertUser(user); + if (rows > 0 && StringUtils.isNotEmpty(user.getPhonenumber())) { + imSendService.bindOrRegister(user.getUserId(), user.getPhonenumber(), + StringUtils.isNotEmpty(user.getNickName()) ? user.getNickName() : user.getUserName()); + } + return toAjax(rows); } /** diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserDashboardController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserDashboardController.java new file mode 100644 index 0000000..fd47205 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserDashboardController.java @@ -0,0 +1,65 @@ +package com.ruoyi.web.controller.system; + +import cn.dev33.satoken.annotation.SaCheckRole; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.system.service.ISysUserDashboardService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.constraints.NotNull; +import java.util.HashMap; +import java.util.Map; + +/** + * 用户工作台布局 + * + * @author wangyu + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/user/dashboard") +public class SysUserDashboardController extends BaseController { + + private final ISysUserDashboardService dashboardService; + + /** 获取当前用户的工作台布局 */ + @GetMapping + public R> getLayout() { + Long userId = LoginHelper.getUserId(); + Map data = new HashMap<>(2); + data.put("layout", dashboardService.getLayout(userId)); + return R.ok(data); + } + + /** 保存当前用户的工作台布局 */ + @PutMapping + public R saveLayout(@RequestBody @NotNull LayoutBody body) { + dashboardService.saveLayout(LoginHelper.getUserId(), body.getLayout()); + return R.ok(); + } + + /** 重置当前用户的工作台为默认布局 */ + @PostMapping("/reset") + public R resetLayout() { + dashboardService.resetLayout(LoginHelper.getUserId()); + return R.ok(); + } + + /** 管理员:保存系统默认工作台布局(影响所有未自定义的用户) */ + @SaCheckRole("admin") + @PutMapping("/default") + public R saveDefault(@RequestBody @NotNull LayoutBody body) { + dashboardService.saveDefaultLayout(body.getLayout()); + return R.ok(); + } + + public static class LayoutBody { + private String layout; + public String getLayout() { return layout; } + public void setLayout(String layout) { this.layout = layout; } + } +} diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 81ab05f..2541964 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -174,3 +174,17 @@ sms: signName: 测试 # 腾讯专用 sdkAppId: + +--- # OpenIM 集成 +openim: + # OpenIM 核心 API 地址(admin token、发消息) + api-url: http://49.232.154.205:10002 + # OpenIM chat 业务 API 地址(手机号注册/查询,可选) + chat-url: http://49.232.154.205:10008 + # 与 OpenIM share.yml 中保持一致的 secret + secret: openIM123 + # 管理员 userID(chat share.yml 中 adminUserID) + admin-user-id: imAdmin + # 发送系统消息时显示的发送者 + notification-sender: imAdmin + enabled: true diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OaWarehouseAuditController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OaWarehouseAuditController.java new file mode 100644 index 0000000..7df9fc1 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OaWarehouseAuditController.java @@ -0,0 +1,50 @@ +package com.ruoyi.oa.audit; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.oa.audit.mapper.OaWarehouseAuditLogMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 库房操作日志 + * + * @author wangyu + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/oa/warehouseAudit") +public class OaWarehouseAuditController extends BaseController { + + private final OaWarehouseAuditLogMapper mapper; + + @GetMapping("/list") + public TableDataInfo list(OaWarehouseAuditLog query, PageQuery pageQuery) { + LambdaQueryWrapper qw = Wrappers.lambdaQuery(); + if (query.getOpType() != null && !query.getOpType().isEmpty()) { + qw.eq(OaWarehouseAuditLog::getOpType, query.getOpType()); + } + if (query.getRefType() != null && !query.getRefType().isEmpty()) { + qw.eq(OaWarehouseAuditLog::getRefType, query.getRefType()); + } + if (query.getRefId() != null) { + qw.eq(OaWarehouseAuditLog::getRefId, query.getRefId()); + } + if (query.getProjectId() != null) { + qw.eq(OaWarehouseAuditLog::getProjectId, query.getProjectId()); + } + if (query.getWarehouseId() != null) { + qw.eq(OaWarehouseAuditLog::getWarehouseId, query.getWarehouseId()); + } + if (query.getOpUserId() != null) { + qw.eq(OaWarehouseAuditLog::getOpUserId, query.getOpUserId()); + } + qw.orderByDesc(OaWarehouseAuditLog::getOpTime); + return TableDataInfo.build(mapper.selectPage(pageQuery.build(), qw)); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OaWarehouseAuditLog.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OaWarehouseAuditLog.java new file mode 100644 index 0000000..8fa1fc8 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OaWarehouseAuditLog.java @@ -0,0 +1,41 @@ +package com.ruoyi.oa.audit; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 库房统一操作日志 sys_oa_warehouse_audit_log + * + * @author wangyu + */ +@Data +@TableName("sys_oa_warehouse_audit_log") +public class OaWarehouseAuditLog { + + @TableId(value = "log_id") + private Long logId; + + /** 操作类型,见 {@link OpType} */ + private String opType; + + /** 关联实体类型:requirement/task/master/warehouse */ + private String refType; + + /** 关联实体 ID */ + private Long refId; + + private Long projectId; + private Long warehouseId; + + private String summary; + private String beforeJson; + private String afterJson; + + private Long opUserId; + private String opUserName; + private Date opTime; + private String remark; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OaWarehouseAuditService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OaWarehouseAuditService.java new file mode 100644 index 0000000..d9780dd --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OaWarehouseAuditService.java @@ -0,0 +1,56 @@ +package com.ruoyi.oa.audit; + +import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.oa.audit.mapper.OaWarehouseAuditLogMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +/** + * 业务侧统一打点入口。 + * 所有调用异步执行,失败不影响主业务。 + * + * @author wangyu + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OaWarehouseAuditService { + + private final OaWarehouseAuditLogMapper mapper; + + /** 最常用:单参摘要 */ + @Async + public void log(String opType, String refType, Long refId, String summary) { + log(opType, refType, refId, null, null, summary, null, null); + } + + /** 完整版 */ + @Async + public void log(String opType, String refType, Long refId, + Long projectId, Long warehouseId, + String summary, String beforeJson, String afterJson) { + try { + OaWarehouseAuditLog entity = new OaWarehouseAuditLog(); + entity.setOpType(opType); + entity.setRefType(refType); + entity.setRefId(refId); + entity.setProjectId(projectId); + entity.setWarehouseId(warehouseId); + entity.setSummary(summary == null ? "" : summary); + entity.setBeforeJson(beforeJson); + entity.setAfterJson(afterJson); + try { + entity.setOpUserId(LoginHelper.getUserId()); + entity.setOpUserName(LoginHelper.getNickName()); + } catch (Exception ignore) { + // 定时任务/系统调用下无登录态 + entity.setOpUserName("system"); + } + mapper.insert(entity); + } catch (Exception e) { + log.warn("[audit] write failed: {}", e.getMessage()); + } + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OpType.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OpType.java new file mode 100644 index 0000000..289bac9 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/OpType.java @@ -0,0 +1,30 @@ +package com.ruoyi.oa.audit; + +/** + * 库房操作类型枚举。值用 String,便于库里直接读懂。 + * + * @author wangyu + */ +public final class OpType { + private OpType() {} + + public static final String REQ_CREATE = "REQ_CREATE"; // 采购需求新建 + public static final String REQ_UPDATE = "REQ_UPDATE"; // 采购需求修改 + public static final String REQ_DONE = "REQ_DONE"; // 采购需求完成 + public static final String REQ_CANCEL = "REQ_CANCEL"; // 采购需求取消 + public static final String REQ_DELETE = "REQ_DELETE"; // 采购需求删除 + + public static final String TASK_CREATE = "TASK_CREATE"; // 车间采购创建 + public static final String TASK_DONE = "TASK_DONE"; // 车间采购完成 + public static final String TASK_CANCEL = "TASK_CANCEL"; // 车间采购取消 + + public static final String IN = "IN"; // 入库 + public static final String OUT = "OUT"; // 出库 + public static final String RETURN = "RETURN"; // 退库 + public static final String STOCK_ADJUST = "STOCK_ADJUST"; // 库存盘点/修正 + + public static final String REF_REQUIREMENT = "requirement"; + public static final String REF_TASK = "task"; + public static final String REF_MASTER = "master"; + public static final String REF_WAREHOUSE = "warehouse"; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/RequirementBatchService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/RequirementBatchService.java new file mode 100644 index 0000000..d203ab5 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/RequirementBatchService.java @@ -0,0 +1,106 @@ +package com.ruoyi.oa.audit; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.oa.domain.SysOaWarehouse; +import com.ruoyi.oa.domain.SysOaWarehouseDetail; +import com.ruoyi.oa.domain.SysOaWarehouseMaster; +import com.ruoyi.oa.mapper.SysOaWarehouseDetailMapper; +import com.ruoyi.oa.mapper.SysOaWarehouseMapper; +import com.ruoyi.oa.mapper.SysOaWarehouseMasterMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 查询某个采购需求关联的入库批次列表。 + * + * @author wangyu + */ +@Service +@RequiredArgsConstructor +public class RequirementBatchService { + + private final SysOaWarehouseMasterMapper masterMapper; + private final SysOaWarehouseDetailMapper detailMapper; + private final SysOaWarehouseMapper warehouseMapper; + + public List listByRequirement(Long requirementId) { + // 1. 找所有 type=1 (入库) 且 requirement_id 匹配 的 master + List masters = masterMapper.selectList( + Wrappers.lambdaQuery() + .eq(SysOaWarehouseMaster::getRequirementId, requirementId) + .eq(SysOaWarehouseMaster::getType, 1) + .eq(SysOaWarehouseMaster::getDelFlag, 0) + .orderByDesc(SysOaWarehouseMaster::getSignTime)); + if (masters.isEmpty()) return Collections.emptyList(); + + // 2. 一次性查这些 master 的 detail + List masterIds = masters.stream() + .map(SysOaWarehouseMaster::getMasterId).collect(Collectors.toList()); + List details = detailMapper.selectList( + Wrappers.lambdaQuery() + .in(SysOaWarehouseDetail::getMasterId, masterIds) + .eq(SysOaWarehouseDetail::getDelFlag, 0)); + Map> detailsByMaster = details.stream() + .collect(Collectors.groupingBy(SysOaWarehouseDetail::getMasterId)); + + // 3. 一次性查涉及的物料 + Set warehouseIds = details.stream() + .map(SysOaWarehouseDetail::getWarehouseId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map whMap = warehouseIds.isEmpty() + ? Collections.emptyMap() + : warehouseMapper.selectBatchIds(warehouseIds).stream() + .collect(Collectors.toMap(SysOaWarehouse::getId, w -> w)); + + // 4. 组装 + List result = new ArrayList<>(); + for (SysOaWarehouseMaster m : masters) { + RequirementBatchVo vo = new RequirementBatchVo(); + vo.setMasterId(m.getMasterId()); + vo.setMasterNum(m.getMasterNum()); + vo.setSignTime(m.getSignTime()); + vo.setSignUser(m.getSignUser()); + + List ds = detailsByMaster.getOrDefault(m.getMasterId(), Collections.emptyList()); + long totalQty = 0L; + BigDecimal totalAmount = BigDecimal.ZERO; + List dvos = new ArrayList<>(); + List nameParts = new ArrayList<>(); + for (SysOaWarehouseDetail d : ds) { + SysOaWarehouse w = whMap.get(d.getWarehouseId()); + long qty = d.getAmount() == null ? 0L : d.getAmount(); + totalQty += qty; + BigDecimal unitPrice = d.getSignPrice() == null ? null : BigDecimal.valueOf(d.getSignPrice()); + if (unitPrice != null) { + totalAmount = totalAmount.add(unitPrice.multiply(BigDecimal.valueOf(qty))); + } + RequirementBatchVo.BatchDetailVo dv = new RequirementBatchVo.BatchDetailVo(); + dv.setDetailId(d.getId()); + dv.setWarehouseId(d.getWarehouseId()); + dv.setAmount(d.getAmount()); + dv.setSignPrice(unitPrice); + if (w != null) { + dv.setWarehouseName(w.getName()); + dv.setModel(w.getModel()); + dv.setSpecifications(w.getSpecifications()); + dv.setUnit(w.getUnit()); + if (nameParts.size() < 3) nameParts.add(w.getName()); + } + dvos.add(dv); + } + vo.setDetails(dvos); + vo.setTotalQty(totalQty); + vo.setTotalAmount(totalAmount); + String summary = String.join("、", nameParts); + if (dvos.size() > 3) summary += " 等 " + dvos.size() + " 项"; + vo.setSummary(summary); + result.add(vo); + } + return result; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/RequirementBatchVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/RequirementBatchVo.java new file mode 100644 index 0000000..1282d5d --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/RequirementBatchVo.java @@ -0,0 +1,45 @@ +package com.ruoyi.oa.audit; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; + +/** + * 采购需求关联的入库批次(一个需求多批到货) + * + * @author wangyu + */ +@Data +public class RequirementBatchVo { + + /** 入库单 ID */ + private Long masterId; + /** 入库单编号 */ + private String masterNum; + /** 入库时间 */ + private Date signTime; + /** 入库人 */ + private String signUser; + /** 本批次物料总件数(detail.amount 之和) */ + private Long totalQty; + /** 本批次金额 */ + private BigDecimal totalAmount; + /** 物料概览(前几个名字拼接) */ + private String summary; + /** 行明细 */ + private List details; + + @Data + public static class BatchDetailVo { + private Long detailId; + private Long warehouseId; + private String warehouseName; + private String model; + private String specifications; + private String unit; + private Long amount; + private BigDecimal signPrice; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/mapper/OaWarehouseAuditLogMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/mapper/OaWarehouseAuditLogMapper.java new file mode 100644 index 0000000..4184725 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/audit/mapper/OaWarehouseAuditLogMapper.java @@ -0,0 +1,11 @@ +package com.ruoyi.oa.audit.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.oa.audit.OaWarehouseAuditLog; + +/** + * 审计日志 Mapper + * 包路径包含 .mapper,被 @MapperScan("com.ruoyi.**.mapper") 自动扫描 + */ +public interface OaWarehouseAuditLogMapper extends BaseMapperPlus { +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaRequirementsController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaRequirementsController.java index ca08a07..82b5626 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaRequirementsController.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaRequirementsController.java @@ -38,6 +38,17 @@ public class OaRequirementsController extends BaseController { private final IOaRequirementsService iOaRequirementsService; + private final com.ruoyi.oa.audit.RequirementBatchService requirementBatchService; + + /** + * 查询采购需求关联的入库批次列表 + */ + @GetMapping("/{requirementId}/batches") + public R> batches( + @PathVariable Long requirementId) { + return R.ok(requirementBatchService.listByRequirement(requirementId)); + } + /** * 查询OA 需求列表 */ diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/SysOaWarehouseLog.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/SysOaWarehouseLog.java index 3a46269..3be0eef 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/SysOaWarehouseLog.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/SysOaWarehouseLog.java @@ -17,7 +17,7 @@ import com.ruoyi.common.core.domain.BaseEntity; */ @Data @EqualsAndHashCode(callSuper = true) -@TableName("sys_oa_warehouse_log") +@TableName("_deprecated_sys_oa_warehouse_log") public class SysOaWarehouseLog extends BaseEntity { private static final long serialVersionUID=1L; diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaRequirementsVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaRequirementsVo.java index 2db8b77..17b1923 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaRequirementsVo.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaRequirementsVo.java @@ -89,5 +89,8 @@ public class OaRequirementsVo extends BaseEntity { private String ownerNickName; private String projectName; + /** 附件文件列表(已联查 sys_oss,每项形如 "||",逗号分隔) */ + private String accessoryFiles; + } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/SysOaWarehouseMasterVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/SysOaWarehouseMasterVo.java index b5e43ac..376cf93 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/SysOaWarehouseMasterVo.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/SysOaWarehouseMasterVo.java @@ -91,4 +91,11 @@ public class SysOaWarehouseMasterVo { private Long requirementId; private String requirementName; + /** 物料种类数 */ + private Integer itemCount; + /** 入库/出库总数量 */ + private Long totalQty; + /** 物料概览(GROUP_CONCAT 名称) */ + private String itemsSummary; + } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/im/ImBind.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/ImBind.java new file mode 100644 index 0000000..cbe2c55 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/ImBind.java @@ -0,0 +1,26 @@ +package com.ruoyi.oa.im; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * OA-OpenIM 用户绑定 + * + * @author wangyu + */ +@Data +@TableName("sys_user_im_bind") +public class ImBind { + + @TableId(value = "user_id") + private Long userId; + + private String phone; + private String imUserId; + private Integer bindStatus; + private Date createTime; + private Date updateTime; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/im/ImSendService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/ImSendService.java new file mode 100644 index 0000000..f93ee69 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/ImSendService.java @@ -0,0 +1,88 @@ +package com.ruoyi.oa.im; + +import com.ruoyi.oa.im.mapper.ImBindMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +/** + * 业务侧统一推送入口。 + * 业务代码只关心 OA userId,不用碰 OpenIM 概念。 + * + * @author wangyu + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ImSendService { + + private final ImBindMapper bindMapper; + private final OpenImClient openImClient; + + /** + * 为 OA 用户绑定一个 IM 账号。若 IM 侧已有该手机号则跳过创建;否则先在 chat 注册。 + * 异步执行,新建/绑定失败不影响业务。 + */ + @Async + public void bindOrRegister(Long oaUserId, String phone, String nickname) { + if (oaUserId == null || phone == null || phone.isEmpty()) { return; } + try { + ImBind exist = bindMapper.selectById(oaUserId); + if (exist != null && exist.getImUserId() != null) { + return; + } + String imUserId = openImClient.registerImUser(phone, nickname); + if (imUserId == null) { + log.info("[IM] register skipped for OA user {} phone {}", oaUserId, phone); + return; + } + ImBind bind = new ImBind(); + bind.setUserId(oaUserId); + bind.setPhone(phone); + bind.setImUserId(imUserId); + bind.setBindStatus(1); + bindMapper.insert(bind); + log.info("[IM] bound OA {} -> IM {}", oaUserId, imUserId); + } catch (Exception e) { + log.warn("[IM] bindOrRegister failed for OA {} : {}", oaUserId, e.getMessage()); + } + } + + /** + * 给 OA 用户推送一条业务通知(自定义消息)。 + * 异步执行,失败不抛出(业务流程不应被推送阻塞)。 + * + * @param oaUserId OA 用户ID (sys_user.user_id) + * @param title 通知标题 + * @param description 通知内容(手机端通知栏展示) + * @param bizType 业务类型,如 "task" / "project" / "salary" + * @param bizId 业务主键,前端点通知后可据此跳转 + * @param route 前端跳转路径(可选) + */ + @Async + public void sendToOaUser(Long oaUserId, String title, String description, + String bizType, Object bizId, String route) { + if (oaUserId == null) { return; } + try { + ImBind bind = bindMapper.selectById(oaUserId); + if (bind == null || bind.getImUserId() == null + || (bind.getBindStatus() != null && bind.getBindStatus() == 0)) { + log.debug("[IM] user {} not bound, skip push", oaUserId); + return; + } + + Map payload = new HashMap<>(); + payload.put("bizType", bizType); + payload.put("bizId", bizId); + if (route != null) { payload.put("route", route); } + + openImClient.sendCustomToUser(bind.getImUserId(), title, description, payload); + } catch (Exception e) { + log.warn("[IM] push to OA user {} failed: {}", oaUserId, e.getMessage()); + } + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/im/OpenImClient.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/OpenImClient.java new file mode 100644 index 0000000..0ea5039 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/OpenImClient.java @@ -0,0 +1,220 @@ +package com.ruoyi.oa.im; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * OpenIM 服务端调用封装 + *

+ * 1. 管理 admin token(带缓存与过期重取)
+ * 2. 发送自定义消息(OA 业务通知) + * + * @author wangyu + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OpenImClient { + + /** 自定义消息 contentType,OpenIM 约定 110 以上为自定义 */ + public static final int CUSTOM_CONTENT_TYPE = 110; + + /** 会话类型:单聊 */ + public static final int SESSION_SINGLE = 1; + + private final OpenImProperties props; + + /** 内存缓存的 admin token;过期前重取 */ + private volatile String adminToken; + private volatile long adminTokenExpireAt = 0L; + + /** chat 后端管理员 token 缓存 */ + private volatile String chatAdminToken; + private volatile long chatAdminTokenExpireAt = 0L; + + /** 获取 admin token,自动续期 */ + public String getAdminToken() { + long now = System.currentTimeMillis(); + if (adminToken != null && now < adminTokenExpireAt - 60_000L) { + return adminToken; + } + synchronized (this) { + if (adminToken != null && now < adminTokenExpireAt - 60_000L) { + return adminToken; + } + Map body = new HashMap<>(2); + body.put("secret", props.getSecret()); + body.put("userID", props.getAdminUserId()); + + JSONObject resp = postJson(props.getApiUrl() + "/auth/get_admin_token", body, null); + JSONObject data = resp.getJSONObject("data"); + if (data == null) { + throw new RuntimeException("get admin token failed: " + resp); + } + adminToken = data.getString("token"); + long expireSec = data.getLongValue("expireTimeSeconds"); + if (expireSec <= 0) { expireSec = 7 * 24 * 3600; } + adminTokenExpireAt = now + expireSec * 1000L; + log.info("[OpenIM] admin token refreshed, expires in {}s", expireSec); + return adminToken; + } + } + + /** + * 发送自定义业务消息到指定 IM userID(单聊,发送者为 admin)。 + * + * @param recvImUserId 接收者 OpenIM userID + * @param title 业务标题(如"任务分派") + * @param description 简短描述(通知栏展示) + * @param payload 额外业务字段(如 taskId、route 等) + * @return true 发送成功 + */ + public boolean sendCustomToUser(String recvImUserId, String title, String description, + Map payload) { + if (!props.isEnabled()) { + log.debug("[OpenIM] disabled, skip send to {}", recvImUserId); + return false; + } + Map data = new HashMap<>(); + data.put("title", title); + data.put("description", description); + if (payload != null) { data.putAll(payload); } + + Map customElem = new HashMap<>(3); + customElem.put("data", JSON.toJSONString(data)); + customElem.put("description", description); + customElem.put("extension", ""); + + Map content = new HashMap<>(); + content.put("customElem", customElem); + + Map offlinePush = new HashMap<>(); + offlinePush.put("title", title); + offlinePush.put("desc", description); + offlinePush.put("ex", ""); + offlinePush.put("iOSPushSound", "default"); + offlinePush.put("iOSBadgeCount", true); + + Map sendMsg = new HashMap<>(); + sendMsg.put("sendID", props.getNotificationSender()); + sendMsg.put("recvID", recvImUserId); + sendMsg.put("senderNickname", "系统通知"); + sendMsg.put("senderPlatformID", 1); + sendMsg.put("content", content); + sendMsg.put("contentType", CUSTOM_CONTENT_TYPE); + sendMsg.put("sessionType", SESSION_SINGLE); + sendMsg.put("isOnlineOnly", false); + sendMsg.put("notOfflinePush", false); + sendMsg.put("offlinePushInfo", offlinePush); + + Map body = new HashMap<>(); + body.put("sendMsg", sendMsg); + + JSONObject resp = postJson(props.getApiUrl() + "/msg/send_msg", body, getAdminToken()); + Integer errCode = resp.getInteger("errCode"); + if (errCode != null && errCode == 0) { + return true; + } + log.warn("[OpenIM] send_msg failed: {}", resp); + return false; + } + + /** chat 后端 admin token */ + public String getChatAdminToken() { + long now = System.currentTimeMillis(); + if (chatAdminToken != null && now < chatAdminTokenExpireAt - 60_000L) { + return chatAdminToken; + } + synchronized (this) { + if (chatAdminToken != null && now < chatAdminTokenExpireAt - 60_000L) { + return chatAdminToken; + } + Map body = new HashMap<>(); + body.put("account", props.getChatAdminAccount()); + body.put("password", md5(props.getChatAdminPassword())); + JSONObject resp = postJson(props.getChatAdminUrl() + "/account/login", body, null); + JSONObject data = resp.getJSONObject("data"); + if (data == null) { throw new RuntimeException("chat admin login failed: " + resp); } + chatAdminToken = data.getString("adminToken"); + // chat admin token 默认 90 天,保守取 7 天续期 + chatAdminTokenExpireAt = now + 7L * 24 * 3600_000L; + return chatAdminToken; + } + } + + /** + * 在 chat 后端创建一个新 IM 账号(无需短信验证)。 + * 成功返回新 userID,已存在返回 null。 + */ + public String registerImUser(String phoneNumber, String nickname) { + if (!props.isEnabled()) { return null; } + Map user = new HashMap<>(); + user.put("nickname", nickname == null ? phoneNumber : nickname); + user.put("areaCode", props.getDefaultAreaCode()); + user.put("phoneNumber", phoneNumber); + user.put("password", md5(props.getDefaultUserPassword())); + + Map body = new HashMap<>(); + body.put("verifyCode", "666666"); + body.put("deviceID", "oa-backend"); + body.put("platform", 5); + body.put("autoLogin", false); + body.put("user", user); + + JSONObject resp = postJson(props.getChatUrl() + "/account/register", body, getChatAdminToken()); + Integer errCode = resp.getInteger("errCode"); + if (errCode != null && errCode == 0) { + JSONObject data = resp.getJSONObject("data"); + return data == null ? null : data.getString("userID"); + } + if (errCode != null && errCode == 20004) { + log.info("[OpenIM] phone {} already registered, skip", phoneNumber); + return null; + } + log.warn("[OpenIM] register failed for phone {}: {}", phoneNumber, resp); + return null; + } + + private static String md5(String text) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(text.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { sb.append(String.format("%02x", b)); } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException("md5 failed", e); + } + } + + /** 内部:POST JSON */ + private JSONObject postJson(String url, Map body, String token) { + HttpRequest req = HttpRequest.post(url) + .header("Content-Type", "application/json") + .header("operationID", UUID.randomUUID().toString()) + .body(JSON.toJSONString(body)) + .timeout(8000); + if (token != null) { req.header("token", token); } + try (HttpResponse resp = req.execute()) { + String text = resp.body(); + if (resp.getStatus() / 100 != 2) { + log.warn("[OpenIM] http {} {} -> {}", resp.getStatus(), url, text); + } + return JSON.parseObject(text); + } catch (Exception e) { + log.error("[OpenIM] call {} failed", url, e); + throw new RuntimeException("openim call failed: " + e.getMessage(), e); + } + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/im/OpenImProperties.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/OpenImProperties.java new file mode 100644 index 0000000..1818ca9 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/OpenImProperties.java @@ -0,0 +1,49 @@ +package com.ruoyi.oa.im; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * OpenIM 配置 + * + * @author wangyu + */ +@Data +@Component +@ConfigurationProperties(prefix = "openim") +public class OpenImProperties { + + /** OpenIM 核心 API 地址 */ + private String apiUrl = "http://49.232.154.205:10002"; + + /** OpenIM chat 业务 API 地址 */ + private String chatUrl = "http://49.232.154.205:10008"; + + /** OpenIM chat 管理 API 地址(注册新用户、admin login) */ + private String chatAdminUrl = "http://49.232.154.205:10009"; + + /** chat 后端 admin 账号 */ + private String chatAdminAccount = "chatAdmin"; + + /** chat 后端 admin 密码(md5 后传输;明文配置,登录时自动哈希) */ + private String chatAdminPassword = "chatAdmin"; + + /** 默认区号 */ + private String defaultAreaCode = "+86"; + + /** 新建 IM 用户的默认密码(明文,注册时自动 md5) */ + private String defaultUserPassword = "123456"; + + /** 与 OpenIM share.yml 一致的 secret */ + private String secret = "openIM123"; + + /** 管理员 userID */ + private String adminUserId = "imAdmin"; + + /** 系统通知发送者 userID */ + private String notificationSender = "imAdmin"; + + /** 总开关 */ + private boolean enabled = true; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/im/mapper/ImBindMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/mapper/ImBindMapper.java new file mode 100644 index 0000000..1ff54f1 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/mapper/ImBindMapper.java @@ -0,0 +1,11 @@ +package com.ruoyi.oa.im.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.oa.im.ImBind; + +/** + * OA-IM 绑定 Mapper + * 包路径包含 .mapper,被 @MapperScan("com.ruoyi.**.mapper") 自动扫描 + */ +public interface ImBindMapper extends BaseMapperPlus { +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaRequirementsServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaRequirementsServiceImpl.java index 03ee92b..98baf76 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaRequirementsServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaRequirementsServiceImpl.java @@ -8,6 +8,8 @@ import com.ruoyi.common.core.domain.PageQuery; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.oa.audit.OaWarehouseAuditService; +import com.ruoyi.oa.audit.OpType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import com.ruoyi.oa.domain.bo.OaRequirementsBo; @@ -32,6 +34,8 @@ public class OaRequirementsServiceImpl implements IOaRequirementsService { private final OaRequirementsMapper baseMapper; + private final OaWarehouseAuditService auditService; + /** * 查询OA 需求 */ @@ -85,6 +89,8 @@ public class OaRequirementsServiceImpl implements IOaRequirementsService { boolean flag = baseMapper.insert(add) > 0; if (flag) { bo.setRequirementId(add.getRequirementId()); + auditService.log(OpType.REQ_CREATE, OpType.REF_REQUIREMENT, add.getRequirementId(), + "新建采购需求:" + add.getTitle()); } return flag; } @@ -96,7 +102,22 @@ public class OaRequirementsServiceImpl implements IOaRequirementsService { public Boolean updateByBo(OaRequirementsBo bo) { OaRequirements update = BeanUtil.toBean(bo, OaRequirements.class); validEntityBeforeSave(update); - return baseMapper.updateById(update) > 0; + boolean ok = baseMapper.updateById(update) > 0; + if (ok) { + String op = OpType.REQ_UPDATE; + String summary = "修改采购需求:" + (update.getTitle() == null ? "" : update.getTitle()); + if (bo.getStatus() != null) { + if (bo.getStatus() == 2) { + op = OpType.REQ_DONE; + summary = "采购需求完成:" + (update.getTitle() == null ? "" : update.getTitle()); + } else if (bo.getStatus() == 3) { + op = OpType.REQ_CANCEL; + summary = "采购需求取消:" + (update.getTitle() == null ? "" : update.getTitle()); + } + } + auditService.log(op, OpType.REF_REQUIREMENT, update.getRequirementId(), summary); + } + return ok; } /** @@ -114,6 +135,12 @@ public class OaRequirementsServiceImpl implements IOaRequirementsService { if(isValid){ //TODO 做一些业务上的校验,判断是否需要校验 } - return baseMapper.deleteBatchIds(ids) > 0; + boolean ok = baseMapper.deleteBatchIds(ids) > 0; + if (ok) { + for (Long id : ids) { + auditService.log(OpType.REQ_DELETE, OpType.REF_REQUIREMENT, id, "删除采购需求"); + } + } + return ok; } } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java index 4b23fd8..6fed139 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java @@ -24,6 +24,7 @@ import com.ruoyi.oa.domain.vo.SysOaTaskVo; import com.ruoyi.oa.mapper.SysOaTaskMapper; import com.ruoyi.oa.service.IOaProjectOperationLogService; import com.ruoyi.oa.service.ISysOaTaskService; +import com.ruoyi.oa.im.ImSendService; import org.springframework.context.annotation.Lazy; import javax.annotation.Resource; @@ -56,6 +57,8 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService { private final SysUserMapper userMapper; + private final ImSendService imSendService; + @Lazy @Resource private IOaProjectOperationLogService operationLogService; @@ -217,6 +220,17 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService { if (flag) { operationLogService.recordLog(add.getProjectId(), 3, add.getTaskId(), add.getTaskTitle(), 1, "新增任务: " + add.getTaskTitle(), null, null); + // 推送给被分配的执行人(异步,失败不影响业务) + if (!Objects.equals(workerId, LoginHelper.getUserId())) { + imSendService.sendToOaUser( + workerId, + "新任务分派", + "您有新任务:" + add.getTaskTitle(), + "task", + add.getTaskId(), + "/task/task?taskId=" + add.getTaskId() + ); + } } // 判断是否为报工模式 if (bo.getStatus()==1L){ @@ -307,6 +321,17 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService { if (task != null) { operationLogService.recordLog(task.getProjectId(), 3, bo.getTaskId(), task.getTaskTitle(), 6, "任务申请延期: " + task.getTaskTitle(), null, null); + // 推送给任务创建人(审批人) + if (task.getCreateUserId() != null + && !Objects.equals(task.getCreateUserId(), LoginHelper.getUserId())) { + imSendService.sendToOaUser( + task.getCreateUserId(), + "任务申请延期", + "任务【" + task.getTaskTitle() + "】申请延期,请审批", + "task", task.getTaskId(), + "/task/task?taskId=" + task.getTaskId() + ); + } } } return ok; @@ -333,6 +358,16 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService { if (ok) { operationLogService.recordLog(sysOaTask.getProjectId(), 3, sysOaTask.getTaskId(), sysOaTask.getTaskTitle(), 7, "任务延期审批通过: " + sysOaTask.getTaskTitle(), null, null); + // 推送给执行人,告知延期已批准 + if (sysOaTask.getWorkerId() != null) { + imSendService.sendToOaUser( + sysOaTask.getWorkerId(), + "任务延期已通过", + "任务【" + sysOaTask.getTaskTitle() + "】延期审批已通过", + "task", sysOaTask.getTaskId(), + "/task/task?taskId=" + sysOaTask.getTaskId() + ); + } } return ok; } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaWarehouseMasterServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaWarehouseMasterServiceImpl.java index ba2698d..ae43a6e 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaWarehouseMasterServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaWarehouseMasterServiceImpl.java @@ -13,6 +13,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.ruoyi.oa.domain.SysOaWarehouse; import com.ruoyi.oa.domain.SysOaWarehouseDetail; +import com.ruoyi.oa.audit.OaWarehouseAuditService; +import com.ruoyi.oa.audit.OpType; import com.ruoyi.oa.domain.SysOaWarehouseLog; import com.ruoyi.oa.domain.bo.ReturnDetailBo; import com.ruoyi.oa.domain.bo.SysOaWarehouseBo; @@ -59,6 +61,9 @@ public class SysOaWarehouseMasterServiceImpl implements ISysOaWarehouseMasterSer @Autowired private SysOaWarehouseLogMapper warehouseLogMapper; + @Autowired + private OaWarehouseAuditService auditService; + /** * 查询出库单管理 @@ -117,6 +122,10 @@ public class SysOaWarehouseMasterServiceImpl implements ISysOaWarehouseMasterSer lqw.eq(bo.getType() != null, "sowm.type", bo.getType()) .eq(bo.getProjectId()!= null, "sowm.project_id", bo.getProjectId()) .eq(bo.getReturnType() != null, "sowm.return_type", bo.getReturnType()) + .eq(bo.getStatus() != null, "sowm.status", bo.getStatus()) + .eq(bo.getRequirementId() != null, "sowm.requirement_id", bo.getRequirementId()) + .like(bo.getMasterNum() != null && !bo.getMasterNum().isEmpty(), "sowm.master_num", bo.getMasterNum()) + .like(bo.getRemark() != null && !bo.getRemark().isEmpty(), "sowm.remark", bo.getRemark()) // 其他过滤…… .eq("sowm.del_flag", 0) .orderByDesc("sowm.create_time"); @@ -154,6 +163,20 @@ public class SysOaWarehouseMasterServiceImpl implements ISysOaWarehouseMasterSer public Boolean insertByBo(SysOaWarehouseMasterBo bo) { SysOaWarehouseMaster add = BeanUtil.toBean(bo, SysOaWarehouseMaster.class); validEntityBeforeSave(add); + + // 规则 1:出库(type=0)无项目 + 无备注 → 自动写入"办公耗材自动写入" + if (add.getType() != null && add.getType() == 0 + && add.getProjectId() == null + && (add.getRemark() == null || add.getRemark().trim().isEmpty())) { + add.setRemark("办公耗材自动写入"); + } + + // 规则 2:入库(type=1)必须强关联采购需求(requirement_id) + if (add.getType() != null && add.getType() == 1 + && add.getRequirementId() == null) { + throw new RuntimeException("入库必须关联采购需求,请先选择需求"); + } + add.setReturnType(0); // 默认0 add.setSignUser(LoginHelper.getNickName()); boolean flag = baseMapper.insert(add) > 0; @@ -164,6 +187,16 @@ public class SysOaWarehouseMasterServiceImpl implements ISysOaWarehouseMasterSer sysOaWarehouseDetailBo.setType(bo.getType()); warehouseDetailService.insertByBo(sysOaWarehouseDetailBo); } + // 审计:根据 type 写不同操作类型 + String op = add.getType() == 0 ? OpType.OUT + : add.getType() == 1 ? OpType.IN + : OpType.RETURN; + String typeLabel = add.getType() == 0 ? "出库" : add.getType() == 1 ? "入库" : "退库"; + auditService.log(op, OpType.REF_MASTER, add.getMasterId(), + add.getProjectId(), null, + typeLabel + "单 " + (add.getMasterNum() == null ? "" : add.getMasterNum()) + + ",共 " + (bo.getWarehouseList() == null ? 0 : bo.getWarehouseList().size()) + " 项", + null, null); } return flag; } @@ -211,13 +244,11 @@ public class SysOaWarehouseMasterServiceImpl implements ISysOaWarehouseMasterSer SysOaWarehouse warehouse = warehouseMapper.selectById(detail.getWarehouseId()); warehouse.setInventory(warehouse.getInventory() + dto.getReturnNum()); warehouseMapper.updateById(warehouse); - // 日志表插入记录 - SysOaWarehouseLog log = new SysOaWarehouseLog(); - log.setMasterId(detail.getMasterId()); - log.setWarehouseId(detail.getWarehouseId()); - log.setNum(Long.valueOf(dto.getReturnNum())); - log.setRemark("退库操作"); - warehouseLogMapper.insert(log); + // 统一审计日志 + auditService.log(OpType.RETURN, OpType.REF_MASTER, detail.getMasterId(), + null, detail.getWarehouseId(), + "退库 " + dto.getReturnNum() + " 件,物料 " + warehouse.getName(), + null, null); } return true; } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaWarehouseTaskServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaWarehouseTaskServiceImpl.java index ab8646a..224685d 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaWarehouseTaskServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaWarehouseTaskServiceImpl.java @@ -20,6 +20,8 @@ import com.ruoyi.oa.mapper.SysOaWarehouseMapper; import com.ruoyi.oa.mapper.SysOaWarehouseMasterMapper; import com.ruoyi.oa.service.ISysOaWarehouseDetailService; import com.ruoyi.oa.service.ISysOaWarehouseMasterService; +import com.ruoyi.oa.audit.OaWarehouseAuditService; +import com.ruoyi.oa.audit.OpType; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -43,6 +45,8 @@ public class SysOaWarehouseTaskServiceImpl implements ISysOaWarehouseTaskService private final SysOaWarehouseTaskMapper baseMapper; + private final OaWarehouseAuditService auditService; + private final SysOaWarehouseMasterMapper masterMapper; @Autowired @@ -100,6 +104,11 @@ public class SysOaWarehouseTaskServiceImpl implements ISysOaWarehouseTaskService boolean flag = baseMapper.insert(add) > 0; if (flag) { bo.setTaskId(add.getTaskId()); + auditService.log(OpType.TASK_CREATE, OpType.REF_TASK, add.getTaskId(), + null, add.getWarehouseId(), + "新建车间采购:" + (add.getName() == null ? "" : add.getName()) + + " × " + (add.getTaskInventory() == null ? 0 : add.getTaskInventory()), + null, null); } return flag; } @@ -111,7 +120,19 @@ public class SysOaWarehouseTaskServiceImpl implements ISysOaWarehouseTaskService public Boolean updateByBo(SysOaWarehouseTaskBo bo) { SysOaWarehouseTask update = BeanUtil.toBean(bo, SysOaWarehouseTask.class); validEntityBeforeSave(update); - return baseMapper.updateById(update) > 0; + boolean ok = baseMapper.updateById(update) > 0; + if (ok && bo.getTaskStatus() != null) { + String op; + String label; + if (bo.getTaskStatus() == 2) { op = OpType.TASK_DONE; label = "完成"; } + else if (bo.getTaskStatus() == 3) { op = OpType.TASK_CANCEL; label = "取消"; } + else { op = "TASK_UPDATE"; label = "修改"; } + auditService.log(op, OpType.REF_TASK, update.getTaskId(), + null, update.getWarehouseId(), + label + "车间采购:" + (update.getName() == null ? "" : update.getName()), + null, null); + } + return ok; } /** diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/task/OaSalaryRemindScheduler.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/task/OaSalaryRemindScheduler.java index 3e29231..f808fad 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/task/OaSalaryRemindScheduler.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/task/OaSalaryRemindScheduler.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.ruoyi.oa.domain.OaSalaryMaster; import com.ruoyi.oa.domain.SysOaRemind; +import com.ruoyi.oa.im.ImSendService; import com.ruoyi.oa.mapper.OaSalaryMasterMapper; import com.ruoyi.oa.mapper.SysOaRemindMapper; import lombok.RequiredArgsConstructor; @@ -19,6 +20,7 @@ public class OaSalaryRemindScheduler { private final OaSalaryMasterMapper salaryMasterMapper; private final SysOaRemindMapper remindMapper; + private final ImSendService imSendService; // 老板用户ID private static final Long BOSS_USER_ID = 1859252208375152641L; @@ -60,6 +62,15 @@ public class OaSalaryRemindScheduler { remind.setRemark(master.getRemark()); remindMapper.insert(remind); + + // 同时通过 IM 推送给老板 + imSendService.sendToOaUser( + BOSS_USER_ID, + "工资审批提醒", + remind.getContent(), + "salary", master.getMasterId(), + "/finance/salary/list" + ); } } } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/task/ProjectExpiryRemindScheduler.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/task/ProjectExpiryRemindScheduler.java new file mode 100644 index 0000000..424bfe4 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/task/ProjectExpiryRemindScheduler.java @@ -0,0 +1,82 @@ +package com.ruoyi.oa.task; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.oa.domain.SysOaProject; +import com.ruoyi.oa.im.ImSendService; +import com.ruoyi.oa.mapper.SysOaProjectMapper; +import com.ruoyi.system.mapper.SysUserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +/** + * 项目到期提醒:每天 09:00 扫描 finishTime 在 [today, today+THRESHOLD_DAYS] 区间且未完结的项目, + * 通过 OpenIM 推送给项目负责人。 + * + * @author wangyu + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProjectExpiryRemindScheduler { + + /** 提前多少天提醒 */ + private static final int THRESHOLD_DAYS = 3; + + private final SysOaProjectMapper projectMapper; + private final SysUserMapper userMapper; + private final ImSendService imSendService; + + @Scheduled(cron = "0 0 9 * * ?") + public void notifyExpiringProjects() { + Calendar cal = Calendar.getInstance(); + cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); + Date start = cal.getTime(); + cal.add(Calendar.DAY_OF_MONTH, THRESHOLD_DAYS + 1); + Date end = cal.getTime(); + + LambdaQueryWrapper qw = Wrappers.lambdaQuery(); + qw.between(SysOaProject::getFinishTime, start, end); + // 已结项的不推("3" 通常表示已完结,按你们的字典调整) + qw.ne(SysOaProject::getProjectStatus, "3"); + List projects = projectMapper.selectList(qw); + + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + for (SysOaProject p : projects) { + String nickName = p.getFunctionary(); + if (nickName == null || nickName.isEmpty()) { continue; } + + // functionary 存的是 nickName,反查 user_id + SysUser user = userMapper.selectOne( + Wrappers.lambdaQuery() + .eq(SysUser::getNickName, nickName) + .eq(SysUser::getDelFlag, "0") + .last("limit 1") + ); + if (user == null) { + log.debug("[ProjectExpiry] nickName={} 未找到对应用户,跳过", nickName); + continue; + } + + String title = "项目即将到期"; + String desc = String.format("项目【%s】将于 %s 到期,请及时跟进", + p.getProjectName(), sdf.format(p.getFinishTime())); + + imSendService.sendToOaUser( + user.getUserId(), title, desc, + "project", p.getProjectId(), + "/oa/project?projectId=" + p.getProjectId() + ); + } + log.info("[ProjectExpiry] 推送 {} 个即将到期项目", projects.size()); + } +} diff --git a/ruoyi-oa/src/main/resources/mapper/oa/OaRequirementsMapper.xml b/ruoyi-oa/src/main/resources/mapper/oa/OaRequirementsMapper.xml index bddae66..2a4e9ba 100644 --- a/ruoyi-oa/src/main/resources/mapper/oa/OaRequirementsMapper.xml +++ b/ruoyi-oa/src/main/resources/mapper/oa/OaRequirementsMapper.xml @@ -27,7 +27,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" r.*, u1.nick_name AS requester_nick_name, u2.nick_name AS owner_nick_name, - p.project_name + p.project_name, + ( + SELECT GROUP_CONCAT(CONCAT_WS('|', o.oss_id, COALESCE(o.original_name, o.file_name), o.url) + ORDER BY FIND_IN_SET(o.oss_id, r.accessory) SEPARATOR ',,') + FROM sys_oss o + WHERE r.accessory IS NOT NULL AND r.accessory <> '' + AND FIND_IN_SET(o.oss_id, r.accessory) + ) AS accessory_files FROM oa_requirements r LEFT JOIN sys_user u1 ON r.requester_id = u1.user_id LEFT JOIN sys_user u2 ON r.owner_id = u2.user_id @@ -40,7 +47,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" r.*, u1.nick_name AS requester_nick_name, u2.nick_name AS owner_nick_name, - p.project_name + p.project_name, + ( + SELECT GROUP_CONCAT(CONCAT_WS('|', o.oss_id, COALESCE(o.original_name, o.file_name), o.url) + ORDER BY FIND_IN_SET(o.oss_id, r.accessory) SEPARATOR ',,') + FROM sys_oss o + WHERE r.accessory IS NOT NULL AND r.accessory <> '' + AND FIND_IN_SET(o.oss_id, r.accessory) + ) AS accessory_files FROM oa_requirements r LEFT JOIN sys_user u1 ON r.requester_id = u1.user_id LEFT JOIN sys_user u2 ON r.owner_id = u2.user_id diff --git a/ruoyi-oa/src/main/resources/mapper/oa/SysOaWarehouseMasterMapper.xml b/ruoyi-oa/src/main/resources/mapper/oa/SysOaWarehouseMasterMapper.xml index a20256b..6a73288 100644 --- a/ruoyi-oa/src/main/resources/mapper/oa/SysOaWarehouseMasterMapper.xml +++ b/ruoyi-oa/src/main/resources/mapper/oa/SysOaWarehouseMasterMapper.xml @@ -26,6 +26,9 @@ + + + diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/SysUserDashboard.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/SysUserDashboard.java new file mode 100644 index 0000000..8ecbdaa --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/SysUserDashboard.java @@ -0,0 +1,24 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 用户工作台布局 sys_user_dashboard + * + * @author wangyu + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_user_dashboard") +public class SysUserDashboard extends BaseEntity { + + @TableId(value = "user_id") + private Long userId; + + /** 布局 JSON: [{i,x,y,w,h,widgetKey,config}] */ + private String layoutJson; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserDashboardMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserDashboardMapper.java new file mode 100644 index 0000000..afe9950 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserDashboardMapper.java @@ -0,0 +1,12 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.SysUserDashboard; + +/** + * 用户工作台布局 数据层 + * + * @author wangyu + */ +public interface SysUserDashboardMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserDashboardService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserDashboardService.java new file mode 100644 index 0000000..1e93cdf --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserDashboardService.java @@ -0,0 +1,24 @@ +package com.ruoyi.system.service; + +/** + * 用户工作台布局 服务层 + * + * @author wangyu + */ +public interface ISysUserDashboardService { + + /** 读取指定用户布局;为空则返回默认布局 */ + String getLayout(Long userId); + + /** 保存指定用户布局 */ + void saveLayout(Long userId, String layoutJson); + + /** 重置为默认布局(删除用户自定义记录) */ + void resetLayout(Long userId); + + /** 读取系统默认布局 */ + String getDefaultLayout(); + + /** 保存系统默认布局(管理员) */ + void saveDefaultLayout(String layoutJson); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserDashboardServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserDashboardServiceImpl.java new file mode 100644 index 0000000..eb153c3 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserDashboardServiceImpl.java @@ -0,0 +1,67 @@ +package com.ruoyi.system.service.impl; + +import com.ruoyi.system.domain.SysConfig; +import com.ruoyi.system.domain.SysUserDashboard; +import com.ruoyi.system.mapper.SysUserDashboardMapper; +import com.ruoyi.system.service.ISysConfigService; +import com.ruoyi.system.service.ISysUserDashboardService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 用户工作台布局 服务实现 + * + * @author wangyu + */ +@Service +@RequiredArgsConstructor +public class SysUserDashboardServiceImpl implements ISysUserDashboardService { + + private static final String DEFAULT_LAYOUT_KEY = "sys.dashboard.defaultLayout"; + private static final String FALLBACK_LAYOUT = "[]"; + + private final SysUserDashboardMapper dashboardMapper; + private final ISysConfigService configService; + + @Override + public String getLayout(Long userId) { + SysUserDashboard entity = dashboardMapper.selectById(userId); + if (entity != null && entity.getLayoutJson() != null && !entity.getLayoutJson().isEmpty()) { + return entity.getLayoutJson(); + } + return getDefaultLayout(); + } + + @Override + public void saveLayout(Long userId, String layoutJson) { + SysUserDashboard exist = dashboardMapper.selectById(userId); + if (exist == null) { + SysUserDashboard entity = new SysUserDashboard(); + entity.setUserId(userId); + entity.setLayoutJson(layoutJson); + dashboardMapper.insert(entity); + } else { + exist.setLayoutJson(layoutJson); + dashboardMapper.updateById(exist); + } + } + + @Override + public void resetLayout(Long userId) { + dashboardMapper.deleteById(userId); + } + + @Override + public String getDefaultLayout() { + String value = configService.selectConfigByKey(DEFAULT_LAYOUT_KEY); + return (value == null || value.isEmpty()) ? FALLBACK_LAYOUT : value; + } + + @Override + public void saveDefaultLayout(String layoutJson) { + SysConfig config = new SysConfig(); + config.setConfigKey(DEFAULT_LAYOUT_KEY); + config.setConfigValue(layoutJson); + configService.updateConfig(config); + } +} diff --git a/ruoyi-ui/package.json b/ruoyi-ui/package.json index 3939739..696f136 100644 --- a/ruoyi-ui/package.json +++ b/ruoyi-ui/package.json @@ -84,6 +84,7 @@ "vue-count-to": "1.0.13", "vue-cropper": "0.5.5", "vue-demi": "^0.14.10", + "vue-grid-layout": "^2.4.0", "vue-meta": "2.4.0", "vue-plugin-hiprint": "^0.0.60", "vue-print-nb": "^1.7.5", diff --git a/ruoyi-ui/src/api/oa/requirement.js b/ruoyi-ui/src/api/oa/requirement.js index 04e9d7b..e7841b4 100644 --- a/ruoyi-ui/src/api/oa/requirement.js +++ b/ruoyi-ui/src/api/oa/requirement.js @@ -1,5 +1,13 @@ import request from '@/utils/request' +// 查询采购需求关联的入库批次 +export function getRequirementBatches(requirementId) { + return request({ + url: `/oa/requirements/${requirementId}/batches`, + method: 'get' + }) +} + // 查询OA 需求列表 export function listRequirements(query) { return request({ diff --git a/ruoyi-ui/src/api/oa/warehouse/auditLog.js b/ruoyi-ui/src/api/oa/warehouse/auditLog.js new file mode 100644 index 0000000..d66a286 --- /dev/null +++ b/ruoyi-ui/src/api/oa/warehouse/auditLog.js @@ -0,0 +1,10 @@ +import request from '@/utils/request' + +// 库房操作日志(分页) +export function listWarehouseAudit (query) { + return request({ + url: '/oa/warehouseAudit/list', + method: 'get', + params: query + }) +} diff --git a/ruoyi-ui/src/api/system/dashboard.js b/ruoyi-ui/src/api/system/dashboard.js new file mode 100644 index 0000000..4d25b41 --- /dev/null +++ b/ruoyi-ui/src/api/system/dashboard.js @@ -0,0 +1,35 @@ +import request from '@/utils/request' + +// 获取当前用户工作台布局 +export function getDashboardLayout() { + return request({ + url: '/system/user/dashboard', + method: 'get' + }) +} + +// 保存当前用户工作台布局 +export function saveDashboardLayout(layout) { + return request({ + url: '/system/user/dashboard', + method: 'put', + data: { layout } + }) +} + +// 重置为默认布局 +export function resetDashboardLayout() { + return request({ + url: '/system/user/dashboard/reset', + method: 'post' + }) +} + +// 管理员:保存系统默认工作台 +export function saveDefaultDashboardLayout(layout) { + return request({ + url: '/system/user/dashboard/default', + method: 'put', + data: { layout } + }) +} diff --git a/ruoyi-ui/src/assets/styles/element-variables.scss b/ruoyi-ui/src/assets/styles/element-variables.scss index 0d9ff4e..7694f80 100644 --- a/ruoyi-ui/src/assets/styles/element-variables.scss +++ b/ruoyi-ui/src/assets/styles/element-variables.scss @@ -5,10 +5,10 @@ /* theme color */ -$--color-primary: #1890ff; -$--color-success: #13ce66; -$--color-warning: #ffba00; -$--color-danger: #ff4949; +$--color-primary: #1677ff; +$--color-success: #00b42a; +$--color-warning: #ff7d00; +$--color-danger: #f53f3f; // $--color-info: #1E1E1E; $--button-font-weight: 400; diff --git a/ruoyi-ui/src/assets/styles/index.scss b/ruoyi-ui/src/assets/styles/index.scss index 06b0920..1999122 100644 --- a/ruoyi-ui/src/assets/styles/index.scss +++ b/ruoyi-ui/src/assets/styles/index.scss @@ -8,10 +8,201 @@ body { height: 100%; + background-color: #f4f7f9; + font-size: 12px; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; - font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif; + font-family: "Inter", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif; +} + +// ===== 全局紧凑化 ===== +// Element UI 默认字号 14px / 行高较松,整体收紧到 13px +#app { + font-size: 12px; +} + +// 表单 / 输入 / 按钮 紧凑 +.el-form-item { + margin-bottom: 14px; +} +.el-form-item__label { + font-size: 12px; + line-height: 30px; + padding: 0 8px 0 0; +} +.el-form-item--small.el-form-item { + margin-bottom: 12px; +} +/* 全局输入控件统一压低高度(含 input/select/cascader/date/range/autocomplete) */ +.el-input__inner, +.el-textarea__inner, +.el-select .el-input__inner, +.el-cascader .el-input__inner, +.el-autocomplete .el-input__inner, +.el-date-editor .el-input__inner, +.el-date-editor--daterange.el-range-editor, +.el-date-editor--datetimerange.el-range-editor, +.el-date-editor--monthrange.el-range-editor, +.el-range-editor.el-input__inner { + height: 28px !important; + line-height: 28px !important; + font-size: 12px !important; + padding: 0 8px !important; +} +.el-input--medium .el-input__inner { height: 30px !important; line-height: 30px !important; } +.el-input--small .el-input__inner { height: 28px !important; line-height: 28px !important; } +.el-input--mini .el-input__inner { height: 24px !important; line-height: 24px !important; font-size: 12px !important; } +.el-input--mini.el-input, +.el-input--mini .el-input__suffix, +.el-input--mini .el-input__prefix { line-height: 24px !important; } +.el-range-editor.el-input__inner { padding: 0 10px !important; } +.el-range-editor .el-range-input { font-size: 12px !important; height: 100% !important; line-height: 1 !important; } +.el-range-editor .el-range-separator { line-height: 26px !important; font-size: 12px !important; } +.el-input__icon { line-height: 28px !important; } +.el-input__suffix-inner .el-input__icon { line-height: 28px !important; } +.el-form-item__content { line-height: 28px; } +.el-button { + padding: 7px 12px; + font-size: 12px; + border-radius: 6px; +} +.el-button--mini, +.el-button--small { + padding: 6px 10px; + font-size: 12px; +} +.el-button--medium { + padding: 8px 14px; + font-size: 12px; +} +.el-button + .el-button { + margin-left: 8px; +} + +// 表格紧凑 +.el-table { + font-size: 12px; +} +.el-table th.el-table__cell > .cell, +.el-table td.el-table__cell > .cell { + padding-left: 10px; + padding-right: 10px; +} +.el-table th.el-table__cell { + padding: 6px 0; + background-color: #fafafa; +} +.el-table td.el-table__cell { + padding: 6px 0; +} +.el-table--mini th.el-table__cell, +.el-table--mini td.el-table__cell { + padding: 4px 0; +} + +// 分页紧凑 +.el-pagination { + padding: 6px 0; + font-size: 12px; +} +.el-pagination .btn-prev, +.el-pagination .btn-next, +.el-pagination .el-pager li { + min-width: 28px; + height: 28px; + line-height: 28px; +} + +// 卡片 / 容器 紧凑 +.el-card__header { + padding: 10px 16px; + font-size: 13px; +} +.el-card__body { + padding: 14px 16px; +} +.el-dialog__header { + padding: 14px 18px 10px; +} +.el-dialog__body { + padding: 16px 18px; + font-size: 12px; +} +.el-dialog__footer { + padding: 10px 18px 14px; +} + +// 菜单收紧 +.el-menu-item, +.el-submenu__title { + font-size: 12px; + height: 38px !important; + line-height: 38px !important; +} + +// Tabs / Tags +.el-tabs__item { + height: 36px; + line-height: 36px; + font-size: 12px; +} +.el-tag { + height: 22px; + line-height: 20px; + padding: 0 8px; + font-size: 12px; +} + +// 描述列表 / 步骤 +.el-descriptions__body .el-descriptions__table .el-descriptions-item__cell { + padding: 8px 12px; + font-size: 12px; +} + +// 复选框 / 单选 +.el-checkbox__label, +.el-radio__label { + font-size: 12px; +} + +// Vben 风格:浅色侧边栏选中项 +#app .sidebar-container.theme-light { + .el-menu-item, + .el-submenu__title { + border-radius: 6px; + margin: 2px 8px; + height: 40px; + line-height: 40px; + } + .el-menu-item.is-active { + background-color: #e6f4ff !important; + color: #1677ff !important; + font-weight: 500; + } +} + +// 顶部导航 & 内容卡片化 +.app-main { + background-color: #f4f7f9; +} + +.app-container { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 21, 41, 0.04); +} + +// 圆角按钮/输入框 +.el-button { + border-radius: 6px; +} +.el-input__inner, +.el-textarea__inner { + border-radius: 6px; +} +.el-card { + border-radius: 8px; } label { @@ -122,7 +313,7 @@ aside { //main-container全局样式 .app-container { - padding: 20px; + padding: 12px 14px; } .components-container { @@ -180,4 +371,116 @@ aside { vertical-align: middle; margin-bottom: 10px; } -} \ No newline at end of file +} +/* 全局按钮缩小:默认按钮等比 small 化 */ +.el-button { + padding: 7px 12px; + font-size: 12px; + border-radius: 3px; +} +.el-button--medium { + padding: 8px 14px; + font-size: 12px; +} +.el-button--small { + padding: 6px 10px; + font-size: 12px; +} +.el-button--mini { + padding: 5px 8px; + font-size: 12px; +} +.el-button [class*="el-icon-"] + span { + margin-left: 4px; +} +.el-button-group > .el-button { + padding-left: 10px; + padding-right: 10px; +} + +/* 列表页搜索表单紧凑模式(加 class="compact-search" 即可生效) */ +.compact-search.el-form--inline { + margin-bottom: 8px; +} +.compact-search .el-form-item { + margin-bottom: 8px; + margin-right: 12px; +} +.compact-search .el-form-item__label { + font-size: 12px; + font-weight: normal; + color: #606266; + line-height: 28px; + padding-right: 6px; +} +.compact-search .el-form-item__content { + line-height: 28px; +} +.compact-search .el-input--mini .el-input__inner, +.compact-search .el-select .el-input__inner, +.compact-search .el-date-editor .el-input__inner, +.compact-search .el-range-editor .el-range-input { + height: 28px; + line-height: 28px; + font-size: 12px; +} +.compact-search .el-range-editor.el-input__inner { + padding: 0 8px; +} + +/* compact-search 进一步收紧 */ +.compact-search .el-form-item__label { + font-size: 11px !important; + line-height: 26px !important; + padding-right: 4px !important; +} +.compact-search .el-form-item__content, +.compact-search .el-input--mini, +.compact-search .el-select, +.compact-search .el-date-editor { + font-size: 11px !important; +} +.compact-search .el-input--mini .el-input__inner, +.compact-search .el-select .el-input__inner, +.compact-search .el-date-editor .el-input__inner, +.compact-search .el-range-editor .el-range-input { + height: 26px !important; + line-height: 26px !important; + font-size: 11px !important; +} +.compact-search .el-form-item { + margin-bottom: 4px !important; + margin-right: 8px !important; +} +.compact-search .el-button--mini { + padding: 5px 10px !important; + font-size: 11px !important; +} + +/* 紧凑型 tabs(class="compact-tabs") */ +.compact-tabs.el-tabs { + margin-bottom: 6px; +} +.compact-tabs .el-tabs__header { + margin-bottom: 6px; +} +.compact-tabs .el-tabs__nav-wrap::after { + height: 1px; +} +.compact-tabs .el-tabs__item { + height: 28px; + line-height: 28px; + font-size: 12px; + padding: 0 12px; +} +.compact-tabs .el-tabs__active-bar { + height: 2px; +} + +/* 加急行高亮(用于备注含 "急" 的未完成单据) */ +.el-table .row-urgent > td.el-table__cell { + background: #fff1f0 !important; +} +.el-table .row-urgent:hover > td.el-table__cell { + background: #ffd8d6 !important; +} diff --git a/ruoyi-ui/src/assets/styles/ruoyi.scss b/ruoyi-ui/src/assets/styles/ruoyi.scss index b6dd9dd..67460da 100644 --- a/ruoyi-ui/src/assets/styles/ruoyi.scss +++ b/ruoyi-ui/src/assets/styles/ruoyi.scss @@ -304,8 +304,18 @@ h6 { } .top-right-btn { - position: relative; - float: right; + position: absolute; + top: 12px; + right: 16px; + z-index: 3; + float: none; +} +.app-container { position: relative; } +.top-right-btn .el-button.is-circle { + width: 24px; + height: 24px; + padding: 0; + font-size: 12px; } .table-input { diff --git a/ruoyi-ui/src/assets/styles/sidebar.scss b/ruoyi-ui/src/assets/styles/sidebar.scss index abe5b63..28517a3 100644 --- a/ruoyi-ui/src/assets/styles/sidebar.scss +++ b/ruoyi-ui/src/assets/styles/sidebar.scss @@ -24,8 +24,9 @@ left: 0; z-index: 1001; overflow: hidden; - -webkit-box-shadow: 2px 0 6px rgba(0,21,41,.35); - box-shadow: 2px 0 6px rgba(0,21,41,.35); + -webkit-box-shadow: 1px 0 4px rgba(0, 21, 41, 0.08); + box-shadow: 1px 0 4px rgba(0, 21, 41, 0.08); + border-right: 1px solid #f0f0f0; // reset element-ui css .horizontal-collapse-transition { @@ -46,7 +47,7 @@ &.has-logo { .el-scrollbar { - height: calc(100% - 50px); + height: calc(100% - 40px); } } @@ -74,6 +75,14 @@ overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; + font-size: 12px !important; + height: 40px !important; + line-height: 40px !important; + } + + .svg-icon { + margin-right: 10px; + font-size: 14px; } // menu hover @@ -91,10 +100,18 @@ & .nest-menu .el-submenu>.el-submenu__title, & .el-submenu .el-menu-item { min-width: $base-sidebar-width !important; + font-size: 12px !important; + height: 36px !important; + line-height: 36px !important; &:hover { background-color: rgba(0, 0, 0, 0.06) !important; } + + // 隐藏二级菜单及更深层级的图标 + .svg-icon { + display: none !important; + } } & .theme-dark .nest-menu .el-submenu>.el-submenu__title, diff --git a/ruoyi-ui/src/assets/styles/variables.scss b/ruoyi-ui/src/assets/styles/variables.scss index 34484d4..a714740 100644 --- a/ruoyi-ui/src/assets/styles/variables.scss +++ b/ruoyi-ui/src/assets/styles/variables.scss @@ -8,18 +8,18 @@ $tiffany: #4AB7BD; $yellow:#FEC171; $panGreen: #30B08F; -// 默认菜单主题风格 -$base-menu-color:#bfcbd9; -$base-menu-color-active:#f4f4f5; -$base-menu-background:#304156; -$base-logo-title-color: #ffffff; +// 默认菜单主题风格(Vben 风格浅色) +$base-menu-color: rgba(0,0,0,.75); +$base-menu-color-active: #1677ff; +$base-menu-background: #ffffff; +$base-logo-title-color: #1677ff; -$base-menu-light-color:rgba(0,0,0,.70); -$base-menu-light-background:#ffffff; -$base-logo-light-title-color: #001529; +$base-menu-light-color: rgba(0,0,0,.75); +$base-menu-light-background: #ffffff; +$base-logo-light-title-color: #1677ff; -$base-sub-menu-background:#1f2d3d; -$base-sub-menu-hover:#001528; +$base-sub-menu-background: #ffffff; +$base-sub-menu-hover: #f0f5ff; // 自定义暗色菜单风格 /** @@ -36,7 +36,7 @@ $base-sub-menu-background:#000c17; $base-sub-menu-hover:#001528; */ -$base-sidebar-width: 200px; +$base-sidebar-width: 150px; // the :export directive is the magic sauce for webpack // https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass diff --git a/ruoyi-ui/src/components/Workbench/WidgetWrapper.vue b/ruoyi-ui/src/components/Workbench/WidgetWrapper.vue new file mode 100644 index 0000000..aabe28c --- /dev/null +++ b/ruoyi-ui/src/components/Workbench/WidgetWrapper.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/ruoyi-ui/src/components/Workbench/index.vue b/ruoyi-ui/src/components/Workbench/index.vue new file mode 100644 index 0000000..592fe21 --- /dev/null +++ b/ruoyi-ui/src/components/Workbench/index.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/ruoyi-ui/src/components/Workbench/widgets/registry.js b/ruoyi-ui/src/components/Workbench/widgets/registry.js new file mode 100644 index 0000000..5aedfd0 --- /dev/null +++ b/ruoyi-ui/src/components/Workbench/widgets/registry.js @@ -0,0 +1,81 @@ +// Widget 注册表:key -> { title, component, defaultSize } +// 所有可放入工作台的组件统一在此登记。新增组件只需在此添加一项即可。 +// 栅格 col-num = 12,三列 ≈ w:4 + +import Announcements from '@/components/Announcements/index.vue' +import MiniCalendar from '@/components/MiniCalendar/index.vue' +import QuickEntry from '@/components/QuickEntry/index.vue' +import { + ExpressQuestionList, + FeedbackList, + FinancialCharts, + MyTaskList, + OwnerTaskList, + ProjectManagement, + RequirementList +} from '@/components/HomeModules/index' + +export const WIDGET_REGISTRY = { + announcements: { + title: '通知公告', + component: Announcements, + defaultSize: { w: 4, h: 8 } + }, + projectManagement: { + title: '项目管理', + component: ProjectManagement, + defaultSize: { w: 4, h: 8 } + }, + ownerTaskList: { + title: '分配我的任务', + component: OwnerTaskList, + defaultSize: { w: 4, h: 8 } + }, + myTaskList: { + title: '我发放的任务', + component: MyTaskList, + defaultSize: { w: 4, h: 8 } + }, + financialCharts: { + title: '财务图表', + component: FinancialCharts, + defaultSize: { w: 4, h: 8 } + }, + feedbackList: { + title: '问题反馈', + component: FeedbackList, + defaultSize: { w: 4, h: 8 } + }, + requirementList: { + title: '需求下发', + component: RequirementList, + defaultSize: { w: 4, h: 8 } + }, + expressQuestionList: { + title: '快递问题', + component: ExpressQuestionList, + defaultSize: { w: 4, h: 8 } + }, + miniCalendar: { + title: '日程日历', + component: MiniCalendar, + defaultSize: { w: 4, h: 8 } + }, + quickEntry: { + title: '快捷入口', + component: QuickEntry, + defaultSize: { w: 12, h: 4 } + } +} + +export function getWidget(key) { + return WIDGET_REGISTRY[key] +} + +export function listWidgets() { + return Object.keys(WIDGET_REGISTRY).map(key => ({ + key, + title: WIDGET_REGISTRY[key].title, + defaultSize: WIDGET_REGISTRY[key].defaultSize + })) +} diff --git a/ruoyi-ui/src/layout/components/AppMain.vue b/ruoyi-ui/src/layout/components/AppMain.vue index c7cdf8f..c7c598d 100644 --- a/ruoyi-ui/src/layout/components/AppMain.vue +++ b/ruoyi-ui/src/layout/components/AppMain.vue @@ -28,8 +28,8 @@ export default { diff --git a/ruoyi-ui/src/layout/components/Navbar.vue b/ruoyi-ui/src/layout/components/Navbar.vue index 676ae5a..d013615 100644 --- a/ruoyi-ui/src/layout/components/Navbar.vue +++ b/ruoyi-ui/src/layout/components/Navbar.vue @@ -20,7 +20,6 @@ @close="hiddenChat" /> --> -

@@ -279,14 +278,14 @@ export default { diff --git a/ruoyi-ui/src/views/oa/oaInWarehouse/index.vue b/ruoyi-ui/src/views/oa/oaInWarehouse/index.vue index e6a6c3c..de93848 100644 --- a/ruoyi-ui/src/views/oa/oaInWarehouse/index.vue +++ b/ruoyi-ui/src/views/oa/oaInWarehouse/index.vue @@ -26,21 +26,36 @@ - - - - - + + + + + + + - - - + + + + + + + + + + @@ -58,7 +73,7 @@ 入库单 - {{ detailData.masterId }} + {{ detailData.masterNum }} @@ -106,6 +121,20 @@ + + + + {{ r.title }} + + {{ r.projectName || '无项目' }} + + + + @@ -216,6 +245,7 @@ import { listOaWarehouseMaster, updateOaWarehouseMaster } from "@/api/oa/warehouse/warehouseMaster"; +import { listRequirements } from "@/api/oa/requirement"; import ProjectSelect from "@/components/fad-service/ProjectSelect"; export default { @@ -270,6 +300,9 @@ export default { open: false, // 是否绑定项目 projectFlag: false, + // 采购需求下拉 + requirementOptions: [], + requirementLoading: false, // 库存查询参数 warehouseParams: { pageSize: 999, @@ -297,6 +330,9 @@ export default { warehouseId: [ { required: true, message: "入库对象id不能为空", trigger: "blur" } ], + requirementId: [ + { required: true, message: "入库必须关联采购需求", trigger: "change" } + ], } }; }, @@ -304,6 +340,20 @@ export default { this.getList(); }, methods: { + // 远程搜索采购需求 + loadRequirementOptions (keyword) { + this.requirementLoading = true + listRequirements({ pageNum: 1, pageSize: 20, title: keyword || undefined, status: 1 }) + .then(res => { this.requirementOptions = res.rows || [] }) + .finally(() => { this.requirementLoading = false }) + }, + onRequirementChange (id) { + const r = this.requirementOptions.find(x => x.requirementId === id) + if (r && r.projectId) { + this.projectFlag = true + this.form.projectId = r.projectId + } + }, // 添加新的一行 addRow () { @@ -358,6 +408,7 @@ export default { reset () { this.form = { projectId: undefined, + requirementId: undefined, warehouseList: [], }; this.resetForm("form"); @@ -382,6 +433,7 @@ export default { handleAdd () { this.reset(); this.open = true; + this.loadRequirementOptions(); if (this.drawer) { // 如果抽屉是打开的说明是从项目处进入的新增,从而加入projectId this.projectFlag = true; diff --git a/ruoyi-ui/src/views/oa/oaOutWarehouse/detail.vue b/ruoyi-ui/src/views/oa/oaOutWarehouse/detail.vue new file mode 100644 index 0000000..a0bf7d6 --- /dev/null +++ b/ruoyi-ui/src/views/oa/oaOutWarehouse/detail.vue @@ -0,0 +1,102 @@ + + + diff --git a/ruoyi-ui/src/views/oa/oaOutWarehouse/index.vue b/ruoyi-ui/src/views/oa/oaOutWarehouse/index.vue index 6f693ef..a69ed07 100644 --- a/ruoyi-ui/src/views/oa/oaOutWarehouse/index.vue +++ b/ruoyi-ui/src/views/oa/oaOutWarehouse/index.vue @@ -1,36 +1,31 @@