diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java index 3a545a2..1c57fb0 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java @@ -195,6 +195,15 @@ public class ShiroConfig { filterChainDefinitionMap.put("/openapi/call/**", "anon"); + // MES车辆管理免密接口(供桌面端调用) + filterChainDefinitionMap.put("/xslmes/mesXslVehicle/anon/**", "anon"); + // MES客户管理免密接口(供桌面端调用) + filterChainDefinitionMap.put("/xslmes/mesXslCustomer/anon/**", "anon"); + // MES供应商管理免密接口(供桌面端调用) + filterChainDefinitionMap.put("/xslmes/mesXslSupplier/anon/**", "anon"); + // 桌面端用户反同步批量上报(Outbox -> /sys/sync/batch) + filterChainDefinitionMap.put("/sys/sync/batch", "anon"); + // 添加自己的过滤器并且取名为jwt Map filterMap = new HashMap(1); //如果cloudServer为空 则说明是单体 需要加载跨域配置【微服务跨域切换】 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/docs/client-connection-permission.sql b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/docs/client-connection-permission.sql new file mode 100644 index 0000000..0de211d --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/docs/client-connection-permission.sql @@ -0,0 +1,61 @@ +-- 客户端连接列表权限初始化(可直接执行,带防重复) +-- 权限标识:xslmes:mes_xsl_client_connection:list + +-- 1) 请按你们实际菜单树确认 parent_id +-- 优先使用你指定的父菜单 ID;若不指定,脚本会优先挂到“MES基础资料”菜单下 +SET @input_parent_id = NULL; +SET @parent_id = COALESCE( + @input_parent_id, + (SELECT id FROM sys_permission WHERE name = 'MES基础资料' LIMIT 1), + (SELECT id FROM sys_permission WHERE perms = 'xslmes' LIMIT 1) +); + +-- 2) 新增菜单(目录/菜单),避免重复插入 +SET @menu_url = '/xslmes/mesXslClientConnection/list'; +SET @menu_name = '客户端连接列表'; +SET @menu_id = ( + SELECT id FROM sys_permission + WHERE url = @menu_url AND menu_type = '1' + LIMIT 1 +); + +INSERT INTO sys_permission ( + id, parent_id, name, perms, perms_type, menu_type, url, component, sort_no, status, del_flag, create_by, create_time +) +SELECT + REPLACE(UUID(), '-', ''), @parent_id, @menu_name, NULL, '1', '1', + @menu_url, 'xslmes/mesXslClientConnection/MesXslClientConnectionList', 100, '1', 0, 'admin', NOW() +FROM dual +WHERE @menu_id IS NULL AND @parent_id IS NOT NULL; + +SET @menu_id = ( + SELECT id FROM sys_permission + WHERE url = @menu_url AND menu_type = '1' + LIMIT 1 +); + +-- 2.1) 若菜单已存在,则强制迁移到“MES基础资料”父菜单,并修正组件路径 +UPDATE sys_permission +SET parent_id = @parent_id, + component = 'xslmes/mesXslClientConnection/MesXslClientConnectionList', + update_by = 'admin', + update_time = NOW() +WHERE id = @menu_id + AND @menu_id IS NOT NULL + AND @parent_id IS NOT NULL; + +-- 3) 新增按钮权限(接口权限点),避免重复插入 +INSERT INTO sys_permission ( + id, parent_id, name, perms, perms_type, menu_type, url, component, sort_no, status, del_flag, create_by, create_time +) +SELECT + REPLACE(UUID(), '-', ''), @menu_id, '查询', 'xslmes:mes_xsl_client_connection:list', '1', '2', + NULL, NULL, 1, '1', 0, 'admin', NOW() +FROM dual +WHERE @menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM sys_permission WHERE perms = 'xslmes:mes_xsl_client_connection:list' LIMIT 1 + ); + +-- 4) 如果 @parent_id 为空,表示没找到 xslmes 菜单,需要你手工设置 @input_parent_id 再执行 +SELECT @parent_id AS resolved_parent_id, @menu_id AS resolved_menu_id; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslClientConnectionController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslClientConnectionController.java new file mode 100644 index 0000000..46f4178 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslClientConnectionController.java @@ -0,0 +1,59 @@ +package org.jeecg.modules.xslmes.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.jeecg.common.api.vo.Result; +import org.jeecg.modules.xslmes.model.MesXslClientConnectionDTO; +import org.jeecg.modules.xslmes.service.IMesXslClientConnectionStore; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * MES 客户端连接列表 + */ +@Tag(name = "MES客户端连接管理") +@RestController +@RequestMapping("/xslmes/mesXslClientConnection") +public class MesXslClientConnectionController { + + private final IMesXslClientConnectionStore connectionStore; + + public MesXslClientConnectionController(IMesXslClientConnectionStore connectionStore) { + this.connectionStore = connectionStore; + } + + @Operation(summary = "客户端连接列表查询") + @RequiresPermissions("xslmes:mes_xsl_client_connection:list") + @GetMapping("/list") + public Result> list( + @RequestParam(name = "type", required = false, defaultValue = "all") String type, + @RequestParam(name = "pageNo", required = false, defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", required = false, defaultValue = "20") Integer pageSize) { + List all = connectionStore.listByType(type); + int safePageNo = pageNo == null || pageNo < 1 ? 1 : pageNo; + int safePageSize = pageSize == null || pageSize < 1 ? 20 : pageSize; + int fromIndex = (safePageNo - 1) * safePageSize; + int total = all.size(); + List records; + if (fromIndex >= total) { + records = List.of(); + } else { + int toIndex = Math.min(fromIndex + safePageSize, total); + records = all.subList(fromIndex, toIndex); + } + Map page = new HashMap<>(8); + page.put("records", records); + page.put("total", total); + page.put("current", safePageNo); + page.put("size", safePageSize); + page.put("pages", safePageSize == 0 ? 0 : (total + safePageSize - 1) / safePageSize); + return Result.OK(page); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslCustomerController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslCustomerController.java index 2e7d0a4..5821673 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslCustomerController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslCustomerController.java @@ -17,6 +17,7 @@ import org.jeecg.common.util.oConvertUtils; import org.jeecg.modules.xslmes.constant.MesXslCustomerBizStatus; import org.jeecg.modules.xslmes.entity.MesXslCustomer; import org.jeecg.modules.xslmes.service.IMesXslCustomerService; +import org.jeecg.modules.xslmes.service.MesXslStompNotifyService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -45,6 +46,9 @@ public class MesXslCustomerController extends JeecgController> queryPageList( @@ -82,6 +86,7 @@ public class MesXslCustomerController extends JeecgController delete(@RequestParam(name = "id", required = true) String id) { mesXslCustomerService.removeById(id); + stompNotify.publishCustomerChanged("delete", id); return Result.OK("删除成功!"); } @@ -156,6 +164,7 @@ public class MesXslCustomerController extends JeecgController deleteBatch(@RequestParam(name = "ids", required = true) String ids) { mesXslCustomerService.removeByIds(Arrays.asList(ids.split(","))); + stompNotify.publishCustomerChanged("batchDelete", ids); return Result.OK("批量删除成功!"); } @@ -263,4 +272,5 @@ public class MesXslCustomerController extends JeecgController所有供 WPF 桌面端(匿名模式)调用的接口集中于此,便于统一维护权限白名单和版本演进。 + * ShiroConfig 白名单: + * /xslmes/mesXslVehicle/anon/** + * /xslmes/mesXslCustomer/anon/** + */ +@Tag(name = "桌面端免密接口") +@RestController +@Slf4j +@RequiredArgsConstructor +public class MesXslDesktopAnonController { + + private final IMesXslVehicleService vehicleService; + private final IMesXslCustomerService customerService; + private final IMesXslSupplierService supplierService; + private final MesXslStompNotifyService stompNotify; + + // ═══════════════════════════ 车辆管理 ═══════════════════════════ + + @Operation(summary = "车辆-免密分页列表查询") + @GetMapping("/xslmes/mesXslVehicle/anon/list") + public Result> vehicleAnonList( + MesXslVehicle mesXslVehicle, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper qw = QueryGenerator.initQueryWrapper(mesXslVehicle, req.getParameterMap()); + IPage page = vehicleService.page(new Page<>(pageNo, pageSize), qw); + return Result.OK(page); + } + + @Operation(summary = "车辆-免密通过id查询") + @GetMapping("/xslmes/mesXslVehicle/anon/queryById") + public Result vehicleAnonQueryById(@RequestParam(name = "id") String id) { + MesXslVehicle entity = vehicleService.getById(id); + return entity != null ? Result.OK(entity) : Result.error("未找到对应数据"); + } + + @Operation(summary = "车辆-免密添加") + @PostMapping("/xslmes/mesXslVehicle/anon/add") + public Result vehicleAnonAdd(@RequestBody MesXslVehicle mesXslVehicle) { + if (oConvertUtils.isEmpty(mesXslVehicle.getVehicleBelong())) { + return Result.error("车辆归属不能为空"); + } + if (mesXslVehicle.getStatus() == null || mesXslVehicle.getStatus().isEmpty()) { + mesXslVehicle.setStatus("0"); + } + applyVehicleBelong(mesXslVehicle); + vehicleService.save(mesXslVehicle); + stompNotify.publishVehicleChanged("add", mesXslVehicle.getId()); + return Result.OK("添加成功!"); + } + + @Operation(summary = "车辆-免密编辑") + @RequestMapping(value = "/xslmes/mesXslVehicle/anon/edit", method = {RequestMethod.PUT, RequestMethod.POST}) + public Result vehicleAnonEdit(@RequestBody MesXslVehicle mesXslVehicle) { + if (oConvertUtils.isEmpty(mesXslVehicle.getVehicleBelong())) { + return Result.error("车辆归属不能为空"); + } + applyVehicleBelong(mesXslVehicle); + boolean ok = vehicleService.updateById(mesXslVehicle); + if (!ok) { + return Result.error("数据已被他人修改,请刷新后重试"); + } + forceNullOppositeVehicleFieldsInDb(mesXslVehicle.getId(), mesXslVehicle.getVehicleBelong()); + stompNotify.publishVehicleChanged("edit", mesXslVehicle.getId()); + return Result.OK("编辑成功!"); + } + + @Operation(summary = "车辆-免密删除") + @DeleteMapping("/xslmes/mesXslVehicle/anon/delete") + public Result vehicleAnonDelete(@RequestParam(name = "id") String id) { + vehicleService.removeById(id); + stompNotify.publishVehicleChanged("delete", id); + return Result.OK("删除成功!"); + } + + @Operation(summary = "车辆-免密批量删除") + @DeleteMapping("/xslmes/mesXslVehicle/anon/deleteBatch") + public Result vehicleAnonDeleteBatch(@RequestParam(name = "ids") String ids) { + vehicleService.removeByIds(Arrays.asList(ids.split(","))); + stompNotify.publishVehicleChanged("batchDelete", ids); + return Result.OK("批量删除成功!"); + } + + @Operation(summary = "车辆-免密停用/启用") + @PostMapping("/xslmes/mesXslVehicle/anon/updateStatus") + public Result vehicleAnonUpdateStatus( + @RequestParam(name = "id") String id, + @RequestParam(name = "status") String status) { + if (!"0".equals(status) && !"1".equals(status)) { + return Result.error("状态参数非法"); + } + boolean ok = vehicleService.lambdaUpdate() + .eq(MesXslVehicle::getId, id) + .set(MesXslVehicle::getStatus, status) + .update(); + if (ok) { + stompNotify.publishVehicleChanged("status", id); + } + return ok ? Result.OK("操作成功") : Result.error("操作失败"); + } + + // ═══════════════════════════ 客户管理 ═══════════════════════════ + + @Operation(summary = "客户-免密分页列表查询") + @GetMapping("/xslmes/mesXslCustomer/anon/list") + public Result> customerAnonList( + MesXslCustomer mesXslCustomer, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper qw = QueryGenerator.initQueryWrapper(mesXslCustomer, req.getParameterMap()); + IPage page = customerService.page(new Page<>(pageNo, pageSize), qw); + return Result.OK(page); + } + + @Operation(summary = "客户-免密通过id查询") + @GetMapping("/xslmes/mesXslCustomer/anon/queryById") + public Result customerAnonQueryById(@RequestParam(name = "id") String id) { + MesXslCustomer entity = customerService.getById(id); + return entity != null ? Result.OK(entity) : Result.error("未找到对应数据"); + } + + @Operation(summary = "客户-免密添加") + @PostMapping("/xslmes/mesXslCustomer/anon/add") + public Result customerAnonAdd(@RequestBody MesXslCustomer mesXslCustomer) { + if (oConvertUtils.isEmpty(mesXslCustomer.getCustomerCode())) { + return Result.error("客户编码不能为空"); + } + String code = mesXslCustomer.getCustomerCode().trim(); + mesXslCustomer.setCustomerCode(code); + if (customerService.existsSameCustomerCode(code, null)) { + return Result.error("客户编码已存在,不允许重复"); + } + String st = mesXslCustomer.getStatus(); + if (st != null) { + st = st.trim(); + mesXslCustomer.setStatus(st.isEmpty() ? null : st); + } + if (mesXslCustomer.getStatus() == null || mesXslCustomer.getStatus().isEmpty()) { + mesXslCustomer.setStatus(MesXslCustomerBizStatus.ENABLED); + } + customerService.syncIzEnableWithStatus(mesXslCustomer); + customerService.save(mesXslCustomer); + stompNotify.publishCustomerChanged("add", mesXslCustomer.getId()); + return Result.OK("添加成功!"); + } + + @Operation(summary = "客户-免密编辑") + @RequestMapping(value = "/xslmes/mesXslCustomer/anon/edit", method = {RequestMethod.PUT, RequestMethod.POST}) + public Result customerAnonEdit(@RequestBody MesXslCustomer mesXslCustomer) { + if (oConvertUtils.isEmpty(mesXslCustomer.getId())) { + return Result.error("主键不能为空"); + } + if (oConvertUtils.isEmpty(mesXslCustomer.getCustomerCode())) { + return Result.error("客户编码不能为空"); + } + String code = mesXslCustomer.getCustomerCode().trim(); + mesXslCustomer.setCustomerCode(code); + if (customerService.existsSameCustomerCode(code, mesXslCustomer.getId())) { + return Result.error("客户编码已存在,不允许重复"); + } + if (mesXslCustomer.getStatus() != null) { + String s = mesXslCustomer.getStatus().trim(); + mesXslCustomer.setStatus(s.isEmpty() ? null : s); + } + customerService.syncIzEnableWithStatus(mesXslCustomer); + boolean ok = customerService.updateById(mesXslCustomer); + if (!ok) { + return Result.error("数据已被他人修改,请刷新后重试"); + } + stompNotify.publishCustomerChanged("edit", mesXslCustomer.getId()); + return Result.OK("编辑成功!"); + } + + @Operation(summary = "客户-免密删除") + @DeleteMapping("/xslmes/mesXslCustomer/anon/delete") + public Result customerAnonDelete(@RequestParam(name = "id") String id) { + customerService.removeById(id); + stompNotify.publishCustomerChanged("delete", id); + return Result.OK("删除成功!"); + } + + @Operation(summary = "客户-免密批量删除") + @DeleteMapping("/xslmes/mesXslCustomer/anon/deleteBatch") + public Result customerAnonDeleteBatch(@RequestParam(name = "ids") String ids) { + customerService.removeByIds(Arrays.asList(ids.split(","))); + stompNotify.publishCustomerChanged("batchDelete", ids); + return Result.OK("批量删除成功!"); + } + + @Operation(summary = "客户-免密启用/停用") + @PostMapping("/xslmes/mesXslCustomer/anon/updateStatus") + public Result customerAnonUpdateStatus( + @RequestParam(name = "id") String id, + @RequestParam(name = "status") String status) { + if (status != null) { + status = status.trim(); + } + if (!MesXslCustomerBizStatus.ENABLED.equals(status) && !MesXslCustomerBizStatus.DISABLED.equals(status)) { + return Result.error("状态参数非法"); + } + int izEnable = MesXslCustomerBizStatus.DISABLED.equals(status) ? 0 : 1; + boolean updated = customerService.lambdaUpdate() + .eq(MesXslCustomer::getId, id) + .set(MesXslCustomer::getStatus, status) + .set(MesXslCustomer::getIzEnable, izEnable) + .update(); + if (updated) { + stompNotify.publishCustomerChanged("status", id); + return Result.OK("操作成功"); + } + MesXslCustomer cur = customerService.getById(id); + if (cur != null && Objects.equals(status, cur.getStatus())) { + stompNotify.publishCustomerChanged("status", id); + return Result.OK("操作成功"); + } + return Result.error("操作失败"); + } + + @Operation(summary = "客户-免密校验客户编码是否重复") + @GetMapping("/xslmes/mesXslCustomer/anon/checkCustomerCode") + public Result customerAnonCheckCustomerCode( + @RequestParam(name = "customerCode") String customerCode, + @RequestParam(name = "dataId", required = false) String dataId) { + if (oConvertUtils.isEmpty(customerCode) || customerCode.trim().isEmpty()) { + return Result.OK("该值可用!"); + } + if (customerService.existsSameCustomerCode(customerCode.trim(), dataId)) { + return Result.error("该客户编码已存在"); + } + return Result.OK("该值可用!"); + } + + // ═══════════════════════════ 供应商管理 ═══════════════════════════ + + @Operation(summary = "供应商-免密分页列表查询") + @GetMapping("/xslmes/mesXslSupplier/anon/list") + public Result> supplierAnonList( + MesXslSupplier mesXslSupplier, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper qw = QueryGenerator.initQueryWrapper(mesXslSupplier, req.getParameterMap()); + IPage page = supplierService.page(new Page<>(pageNo, pageSize), qw); + return Result.OK(page); + } + + @Operation(summary = "供应商-免密通过id查询") + @GetMapping("/xslmes/mesXslSupplier/anon/queryById") + public Result supplierAnonQueryById(@RequestParam(name = "id") String id) { + MesXslSupplier entity = supplierService.getById(id); + return entity != null ? Result.OK(entity) : Result.error("未找到对应数据"); + } + + @Operation(summary = "供应商-免密添加") + @PostMapping("/xslmes/mesXslSupplier/anon/add") + public Result supplierAnonAdd(@RequestBody MesXslSupplier mesXslSupplier) { + if (oConvertUtils.isEmpty(mesXslSupplier.getSupplierCode())) { + return Result.error("供应商编码不能为空"); + } + if (mesXslSupplier.getStatus() == null || mesXslSupplier.getStatus().isEmpty()) { + mesXslSupplier.setStatus("0"); + } + supplierService.save(mesXslSupplier); + stompNotify.publishSupplierChanged("add", mesXslSupplier.getId()); + return Result.OK("添加成功!"); + } + + @Operation(summary = "供应商-免密编辑") + @RequestMapping(value = "/xslmes/mesXslSupplier/anon/edit", method = {RequestMethod.PUT, RequestMethod.POST}) + public Result supplierAnonEdit(@RequestBody MesXslSupplier mesXslSupplier) { + boolean ok = supplierService.updateById(mesXslSupplier); + if (!ok) { + return Result.error("数据已被他人修改,请刷新后重试"); + } + stompNotify.publishSupplierChanged("edit", mesXslSupplier.getId()); + return Result.OK("编辑成功!"); + } + + @Operation(summary = "供应商-免密删除") + @DeleteMapping("/xslmes/mesXslSupplier/anon/delete") + public Result supplierAnonDelete(@RequestParam(name = "id") String id) { + supplierService.removeById(id); + stompNotify.publishSupplierChanged("delete", id); + return Result.OK("删除成功!"); + } + + @Operation(summary = "供应商-免密批量删除") + @DeleteMapping("/xslmes/mesXslSupplier/anon/deleteBatch") + public Result supplierAnonDeleteBatch(@RequestParam(name = "ids") String ids) { + supplierService.removeByIds(Arrays.asList(ids.split(","))); + stompNotify.publishSupplierChanged("batchDelete", ids); + return Result.OK("批量删除成功!"); + } + + @Operation(summary = "供应商-免密启用/停用") + @PostMapping("/xslmes/mesXslSupplier/anon/updateStatus") + public Result supplierAnonUpdateStatus( + @RequestParam(name = "id") String id, + @RequestParam(name = "status") String status) { + if (!"0".equals(status) && !"1".equals(status)) { + return Result.error("状态参数非法"); + } + boolean ok = supplierService.lambdaUpdate() + .eq(MesXslSupplier::getId, id) + .set(MesXslSupplier::getStatus, status) + .update(); + if (ok) { + stompNotify.publishSupplierChanged("status", id); + } + return ok ? Result.OK("操作成功") : Result.error("操作失败"); + } + + // ─────────────────────────── 车辆私有辅助 ──────────────────────────── + + private void applyVehicleBelong(MesXslVehicle v) { + if (oConvertUtils.isEmpty(v.getVehicleBelong())) { + return; + } + String b = v.getVehicleBelong(); + if ("1".equals(b)) { + v.setSupplierId(null); + v.setSupplierName(null); + v.setSupplierShortName(null); + } else if ("2".equals(b)) { + v.setCustomerIds(null); + v.setCustomerShortName(null); + } else if ("3".equals(b)) { + v.setCustomerIds(null); + v.setCustomerShortName(null); + v.setSupplierId(null); + v.setSupplierName(null); + v.setSupplierShortName(null); + } + } + + private void forceNullOppositeVehicleFieldsInDb(String id, String vehicleBelong) { + if (oConvertUtils.isEmpty(id) || oConvertUtils.isEmpty(vehicleBelong)) { + return; + } + LambdaUpdateWrapper uw = new LambdaUpdateWrapper().eq(MesXslVehicle::getId, id); + if ("1".equals(vehicleBelong)) { + uw.set(MesXslVehicle::getSupplierId, null) + .set(MesXslVehicle::getSupplierName, null) + .set(MesXslVehicle::getSupplierShortName, null); + } else if ("2".equals(vehicleBelong)) { + uw.set(MesXslVehicle::getCustomerIds, null).set(MesXslVehicle::getCustomerShortName, null); + } else if ("3".equals(vehicleBelong)) { + uw.set(MesXslVehicle::getCustomerIds, null) + .set(MesXslVehicle::getCustomerShortName, null) + .set(MesXslVehicle::getSupplierId, null) + .set(MesXslVehicle::getSupplierName, null) + .set(MesXslVehicle::getSupplierShortName, null); + } else { + return; + } + vehicleService.update(null, uw); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslSupplierController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslSupplierController.java index 2e23f80..7fc300b 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslSupplierController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslSupplierController.java @@ -15,6 +15,7 @@ import org.jeecg.common.system.base.controller.JeecgController; import org.jeecg.common.system.query.QueryGenerator; import org.jeecg.modules.xslmes.entity.MesXslSupplier; import org.jeecg.modules.xslmes.service.IMesXslSupplierService; +import org.jeecg.modules.xslmes.service.MesXslStompNotifyService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.ModelAndView; @@ -32,6 +33,8 @@ public class MesXslSupplierController extends JeecgController edit(@RequestBody MesXslSupplier mesXslSupplier) { mesXslSupplierService.updateById(mesXslSupplier); + stompNotify.publishSupplierChanged("edit", mesXslSupplier.getId()); return Result.OK("编辑成功!"); } @@ -81,6 +86,9 @@ public class MesXslSupplierController extends JeecgController delete(@RequestParam(name = "id", required = true) String id) { mesXslSupplierService.removeById(id); + stompNotify.publishSupplierChanged("delete", id); return Result.OK("删除成功!"); } @@ -99,6 +108,7 @@ public class MesXslSupplierController extends JeecgController deleteBatch(@RequestParam(name = "ids", required = true) String ids) { mesXslSupplierService.removeByIds(Arrays.asList(ids.split(","))); + stompNotify.publishSupplierChanged("batchDelete", ids); return Result.OK("批量删除成功!"); } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslVehicleController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslVehicleController.java index 99a2a62..14a0aae 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslVehicleController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslVehicleController.java @@ -17,6 +17,7 @@ import org.jeecg.common.system.query.QueryGenerator; import org.jeecg.common.util.oConvertUtils; import org.jeecg.modules.xslmes.entity.MesXslVehicle; import org.jeecg.modules.xslmes.service.IMesXslVehicleService; +import org.jeecg.modules.xslmes.service.MesXslStompNotifyService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.ModelAndView; @@ -35,6 +36,9 @@ public class MesXslVehicleController extends JeecgController> queryPageList( @@ -61,6 +65,7 @@ public class MesXslVehicleController extends JeecgController delete(@RequestParam(name = "id", required = true) String id) { mesXslVehicleService.removeById(id); + stompNotify.publishVehicleChanged("delete", id); return Result.OK("删除成功!"); } @@ -161,6 +171,7 @@ public class MesXslVehicleController extends JeecgController deleteBatch(@RequestParam(name = "ids", required = true) String ids) { mesXslVehicleService.removeByIds(Arrays.asList(ids.split(","))); + stompNotify.publishVehicleChanged("batchDelete", ids); return Result.OK("批量删除成功!"); } @@ -185,4 +196,5 @@ public class MesXslVehicleController extends JeecgController importExcel(HttpServletRequest request, HttpServletResponse response) { return super.importExcel(request, response, MesXslVehicle.class); } + } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslWeightRecordController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslWeightRecordController.java new file mode 100644 index 0000000..91c639c --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslWeightRecordController.java @@ -0,0 +1,127 @@ +package org.jeecg.modules.xslmes.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.aspect.annotation.AutoLog; +import org.jeecg.common.system.base.controller.JeecgController; +import org.jeecg.common.system.query.QueryGenerator; +import org.jeecg.modules.xslmes.entity.MesXslWeightRecord; +import org.jeecg.modules.xslmes.service.IMesXslWeightRecordService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.ModelAndView; + +import java.math.BigDecimal; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.Random; + +/** + * 地磅数据记录 + */ +@Tag(name = "地磅数据记录") +@RestController +@RequestMapping("/xslmes/mesXslWeightRecord") +@Slf4j +public class MesXslWeightRecordController extends JeecgController { + + @Autowired + private IMesXslWeightRecordService mesXslWeightRecordService; + + @Operation(summary = "地磅数据记录-分页列表查询") + @GetMapping(value = "/list") + public Result> queryPageList(MesXslWeightRecord mesXslWeightRecord, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper(mesXslWeightRecord, req.getParameterMap()); + queryWrapper.orderByDesc("create_time"); + Page page = new Page<>(pageNo, pageSize); + IPage pageList = mesXslWeightRecordService.page(page, queryWrapper); + return Result.OK(pageList); + } + + @AutoLog(value = "地磅数据记录-添加") + @Operation(summary = "地磅数据记录-添加") + @RequiresPermissions("xslmes:mes_xsl_weight_record:add") + @PostMapping(value = "/add") + public Result add(@RequestBody MesXslWeightRecord mesXslWeightRecord) { + // 自动生成榜单号:BDH-yyyyMMddHHmmss + 3位随机数 + if (mesXslWeightRecord.getBillNo() == null || mesXslWeightRecord.getBillNo().isBlank()) { + String dateStr = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); + String seq = String.format("%03d", new Random().nextInt(1000)); + mesXslWeightRecord.setBillNo("BDH-" + dateStr + seq); + } + computeNetWeight(mesXslWeightRecord); + mesXslWeightRecordService.save(mesXslWeightRecord); + return Result.OK("添加成功!"); + } + + @AutoLog(value = "地磅数据记录-编辑") + @Operation(summary = "地磅数据记录-编辑") + @RequiresPermissions("xslmes:mes_xsl_weight_record:edit") + @RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST}) + public Result edit(@RequestBody MesXslWeightRecord mesXslWeightRecord) { + computeNetWeight(mesXslWeightRecord); + mesXslWeightRecordService.updateById(mesXslWeightRecord); + return Result.OK("编辑成功!"); + } + + @AutoLog(value = "地磅数据记录-通过id删除") + @Operation(summary = "地磅数据记录-通过id删除") + @RequiresPermissions("xslmes:mes_xsl_weight_record:delete") + @DeleteMapping(value = "/delete") + public Result delete(@RequestParam(name = "id", required = true) String id) { + mesXslWeightRecordService.removeById(id); + return Result.OK("删除成功!"); + } + + @AutoLog(value = "地磅数据记录-批量删除") + @Operation(summary = "地磅数据记录-批量删除") + @RequiresPermissions("xslmes:mes_xsl_weight_record:deleteBatch") + @DeleteMapping(value = "/deleteBatch") + public Result deleteBatch(@RequestParam(name = "ids", required = true) String ids) { + this.mesXslWeightRecordService.removeByIds(Arrays.asList(ids.split(","))); + return Result.OK("批量删除成功!"); + } + + @Operation(summary = "地磅数据记录-通过id查询") + @GetMapping(value = "/queryById") + public Result queryById(@RequestParam(name = "id", required = true) String id) { + MesXslWeightRecord record = mesXslWeightRecordService.getById(id); + if (record == null) { + return Result.error("未找到对应数据"); + } + return Result.OK(record); + } + + @RequiresPermissions("xslmes:mes_xsl_weight_record:exportXls") + @RequestMapping(value = "/exportXls") + public ModelAndView exportXls(HttpServletRequest request, MesXslWeightRecord mesXslWeightRecord) { + return super.exportXls(request, mesXslWeightRecord, MesXslWeightRecord.class, "地磅数据记录"); + } + + @RequiresPermissions("xslmes:mes_xsl_weight_record:importExcel") + @RequestMapping(value = "/importExcel", method = RequestMethod.POST) + public Result importExcel(HttpServletRequest request, HttpServletResponse response) { + return super.importExcel(request, response, MesXslWeightRecord.class); + } + + private void computeNetWeight(MesXslWeightRecord record) { + BigDecimal gross = record.getGrossWeight(); + BigDecimal tare = record.getTareWeight(); + if (gross != null && tare != null) { + BigDecimal net = gross.subtract(tare); + record.setNetWeight(net.compareTo(BigDecimal.ZERO) >= 0 ? net : BigDecimal.ZERO); + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslCustomer.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslCustomer.java index 0a63b04..97b104c 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslCustomer.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslCustomer.java @@ -2,6 +2,7 @@ package org.jeecg.modules.xslmes.entity; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -52,7 +53,10 @@ public class MesXslCustomer extends JeecgEntity implements Serializable { @Schema(description = "业务状态(字典 xslmes_customer_status:0启用1停用2删除);逻辑删除见 del_flag") private String status; - @Schema(description = "删除状态(0正常 1已删除)") + @Schema(description = "乐观锁版本(MyBatis-Plus @Version,更新时自增)") + @Version + private Integer version; + @TableLogic private Integer delFlag; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslSupplier.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslSupplier.java index 7f9f95e..ca3aef4 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslSupplier.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslSupplier.java @@ -2,6 +2,7 @@ package org.jeecg.modules.xslmes.entity; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -49,7 +50,10 @@ public class MesXslSupplier extends JeecgEntity implements Serializable { @Schema(description = "状态:0启用 1停用") private String status; - @Schema(description = "删除状态(0正常 1已删除)") + @Schema(description = "乐观锁版本(MyBatis-Plus @Version,更新时自增)") + @Version + private Integer version; + @TableLogic private Integer delFlag; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslVehicle.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslVehicle.java index 86f33d4..0c0f832 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslVehicle.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslVehicle.java @@ -2,6 +2,7 @@ package org.jeecg.modules.xslmes.entity; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -95,7 +96,10 @@ public class MesXslVehicle extends JeecgEntity implements Serializable { @Schema(description = "状态:0启用 1停用") private String status; - @Schema(description = "删除状态(0正常 1已删除)") + @Schema(description = "乐观锁版本(MyBatis-Plus @Version,更新时自增)") + @Version + private Integer version; + @TableLogic private Integer delFlag; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslWeightRecord.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslWeightRecord.java new file mode 100644 index 0000000..a6d1474 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslWeightRecord.java @@ -0,0 +1,86 @@ +package org.jeecg.modules.xslmes.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.jeecg.common.aspect.annotation.Dict; +import org.jeecg.common.system.base.entity.JeecgEntity; +import org.jeecgframework.poi.excel.annotation.Excel; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 地磅数据记录 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("mes_xsl_weight_record") +@Schema(description = "地磅数据记录") +public class MesXslWeightRecord extends JeecgEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Excel(name = "榜单号", width = 18) + @Schema(description = "榜单号") + private String billNo; + + @Excel(name = "称重日期", width = 15, format = "yyyy-MM-dd") + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd") + @DateTimeFormat(pattern = "yyyy-MM-dd") + @Schema(description = "称重日期") + private Date weighDate; + + @Excel(name = "进出方向", width = 10, dicCode = "xslmes_inout_direction") + @Dict(dicCode = "xslmes_inout_direction") + @Schema(description = "进出方向:1进厂 2出厂") + private String inoutDirection; + + @Schema(description = "车辆ID") + private String vehicleId; + + @Excel(name = "车号", width = 15) + @Schema(description = "车号(车牌号)") + private String plateNumber; + + @Excel(name = "发货单位", width = 25) + @Schema(description = "发货单位(进厂时为供应商名称)") + private String senderUnit; + + @Excel(name = "收货单位", width = 25) + @Schema(description = "收货单位(出厂时为客户简称)") + private String receiverUnit; + + @Excel(name = "货物名称", width = 20) + @Schema(description = "货物名称") + private String goodsName; + + @Excel(name = "毛重(KG)", width = 12) + @Schema(description = "毛重(KG),实际称量") + private BigDecimal grossWeight; + + @Excel(name = "皮重(KG)", width = 12) + @Schema(description = "皮重(KG),从车辆档案带出") + private BigDecimal tareWeight; + + @Excel(name = "净重(KG)", width = 12) + @Schema(description = "净重(KG)=毛重-皮重,自动计算") + private BigDecimal netWeight; + + @Excel(name = "司机", width = 12) + @Schema(description = "司机") + private String driverName; + + @Excel(name = "手机号", width = 15) + @Schema(description = "手机号") + private String driverPhone; + + @Schema(description = "租户ID") + private Integer tenantId; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/http/MesXslHttpLastSeenFilter.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/http/MesXslHttpLastSeenFilter.java new file mode 100644 index 0000000..6a2efc2 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/http/MesXslHttpLastSeenFilter.java @@ -0,0 +1,117 @@ +package org.jeecg.modules.xslmes.http; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.model.MesXslClientConnectionDTO; +import org.jeecg.modules.xslmes.service.IMesXslClientConnectionStore; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Date; + +/** + * xslmes 免密接口 lastSeen 采集 + */ +@Component +public class MesXslHttpLastSeenFilter extends OncePerRequestFilter { + + private final IMesXslClientConnectionStore connectionStore; + + public MesXslHttpLastSeenFilter(IMesXslClientConnectionStore connectionStore) { + this.connectionStore = connectionStore; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (oConvertUtils.isEmpty(uri)) { + return true; + } + String lower = uri.toLowerCase(); + return !(lower.contains("/xslmes/") && lower.contains("/anon/")); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + filterChain.doFilter(request, response); + + String ip = resolveIp(request); + String hostName = resolveHostName(request); + // 优先用客户端显式传来的设备ID,否则回退到 http:ip + String explicitDeviceId = request.getHeader("X-Device-Id"); + String deviceId = oConvertUtils.isNotEmpty(explicitDeviceId) + ? explicitDeviceId.trim() + : "http:" + ip; + Date now = new Date(); + + String userName = request.getHeader("X-User-Name"); + String realName = request.getHeader("X-Real-Name"); + + MesXslClientConnectionDTO dto = new MesXslClientConnectionDTO(); + dto.setDeviceId(deviceId); + dto.setSessionId(null); + dto.setUserName(oConvertUtils.isNotEmpty(userName) ? userName.trim() : "unknown"); + dto.setRealName(oConvertUtils.isNotEmpty(realName) ? realName.trim() : ""); + dto.setPlatform(resolvePlatform(request)); + dto.setIp(ip); + dto.setHostName(hostName); + dto.setConnectTime(now); + dto.setLastSeen(now); + connectionStore.upsertHttp(dto); + } + + private String resolveIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (oConvertUtils.isNotEmpty(xff)) { + int comma = xff.indexOf(','); + return comma > 0 ? xff.substring(0, comma).trim() : xff.trim(); + } + String realIp = request.getHeader("X-Real-IP"); + if (oConvertUtils.isNotEmpty(realIp)) { + return realIp.trim(); + } + String remote = request.getRemoteAddr(); + return oConvertUtils.isNotEmpty(remote) ? remote : "unknown"; + } + + private String resolveHostName(HttpServletRequest request) { + String value = request.getHeader("X-Host-Name"); + if (oConvertUtils.isNotEmpty(value)) { + return value.trim(); + } + return "unknown"; + } + + private String resolvePlatform(HttpServletRequest request) { + // 优先读客户端显式传来的平台标识 + String explicit = request.getHeader("X-Platform"); + if (oConvertUtils.isNotEmpty(explicit)) { + String v = explicit.trim().toLowerCase(); + if ("desktop".equals(v) || "pc".equals(v) || "windows".equals(v) || "win".equals(v)) return "desktop"; + if ("pda".equals(v) || "mobile".equals(v) || "android".equals(v) || "ios".equals(v)) return "pda"; + return v; + } + // 回退到 User-Agent 分析 + String ua = request.getHeader("User-Agent"); + if (oConvertUtils.isEmpty(ua)) { + return "unknown"; + } + String lower = ua.toLowerCase(); + if (lower.contains("android") || lower.contains("iphone") || lower.contains("ipad") || lower.contains("mobile")) { + return "pda"; + } + if (lower.contains("windows") || lower.contains("macintosh") || lower.contains("linux")) { + return "desktop"; + } + // WPF HttpClient 默认 UA 为 "dotnet-httpclient",归为 desktop + if (lower.contains("dotnet")) { + return "desktop"; + } + return "unknown"; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mapper/MesXslWeightRecordMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mapper/MesXslWeightRecordMapper.java new file mode 100644 index 0000000..ce7ee78 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mapper/MesXslWeightRecordMapper.java @@ -0,0 +1,10 @@ +package org.jeecg.modules.xslmes.mapper; + +import org.jeecg.modules.xslmes.entity.MesXslWeightRecord; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 地磅数据记录 Mapper + */ +public interface MesXslWeightRecordMapper extends BaseMapper { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mapper/xml/MesXslWeightRecordMapper.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mapper/xml/MesXslWeightRecordMapper.xml new file mode 100644 index 0000000..c333a89 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mapper/xml/MesXslWeightRecordMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/model/MesXslClientConnectionDTO.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/model/MesXslClientConnectionDTO.java new file mode 100644 index 0000000..9b7b78c --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/model/MesXslClientConnectionDTO.java @@ -0,0 +1,113 @@ +package org.jeecg.modules.xslmes.model; + +import java.io.Serializable; +import java.util.Date; + +/** + * 客户端连接信息 + */ +public class MesXslClientConnectionDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 来源:stomp/http */ + private String source; + /** STOMP 会话 ID(HTTP 场景为空) */ + private String sessionId; + /** 设备标识:当前默认使用 sessionId 或 http:ip */ + private String deviceId; + /** 用户账号/登录名,未知时为 unknown */ + private String userName; + /** 用户姓名(真实姓名) */ + private String realName; + /** 平台:desktop/pda/unknown */ + private String platform; + /** 客户端 IP */ + private String ip; + /** 客户端主机名(机器名),WPF 传 Environment.MachineName */ + private String hostName; + /** 首次连接时间 */ + private Date connectTime; + /** 最近活跃时间 */ + private Date lastSeen; + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getRealName() { + return realName; + } + + public void setRealName(String realName) { + this.realName = realName; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getIp() { + return ip; + } + + public void setIp(String ip) { + this.ip = ip; + } + + public String getHostName() { + return hostName; + } + + public void setHostName(String hostName) { + this.hostName = hostName; + } + + public Date getConnectTime() { + return connectTime; + } + + public void setConnectTime(Date connectTime) { + this.connectTime = connectTime; + } + + public Date getLastSeen() { + return lastSeen; + } + + public void setLastSeen(Date lastSeen) { + this.lastSeen = lastSeen; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/IMesXslClientConnectionStore.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/IMesXslClientConnectionStore.java new file mode 100644 index 0000000..7440c1c --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/IMesXslClientConnectionStore.java @@ -0,0 +1,25 @@ +package org.jeecg.modules.xslmes.service; + +import org.jeecg.modules.xslmes.model.MesXslClientConnectionDTO; + +import java.util.Date; +import java.util.List; + +/** + * 客户端连接状态存储 + */ +public interface IMesXslClientConnectionStore { + + void upsertStomp(MesXslClientConnectionDTO dto); + + void touchStomp(String sessionId, Date lastSeen); + + void removeStomp(String sessionId); + + void upsertHttp(MesXslClientConnectionDTO dto); + + List listByType(String type); + + /** 清空所有连接记录(后端重启时调用,清除进程失联后的残留记录) */ + void clearAll(); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/IMesXslWeightRecordService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/IMesXslWeightRecordService.java new file mode 100644 index 0000000..ed24f7f --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/IMesXslWeightRecordService.java @@ -0,0 +1,10 @@ +package org.jeecg.modules.xslmes.service; + +import org.jeecg.modules.xslmes.entity.MesXslWeightRecord; +import com.baomidou.mybatisplus.extension.service.IService; + +/** + * 地磅数据记录 Service + */ +public interface IMesXslWeightRecordService extends IService { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java new file mode 100644 index 0000000..4e6c010 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java @@ -0,0 +1,52 @@ +package org.jeecg.modules.xslmes.service; + +import com.alibaba.fastjson.JSON; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +/** + * 桌面端 STOMP 实时通知服务 + * 统一管理各业务实体的变更事件广播,供各 Controller 注入使用。 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class MesXslStompNotifyService { + + private final SimpMessagingTemplate messagingTemplate; + + /** 广播车辆数据变更事件到 /topic/sync/mes-vehicles */ + public void publishVehicleChanged(String action, String vehicleId) { + publish("/topic/sync/mes-vehicles", "MES_VEHICLE_CHANGED", "vehicleId", vehicleId, action); + } + + /** 广播客户数据变更事件到 /topic/sync/mes-customers */ + public void publishCustomerChanged(String action, String customerId) { + publish("/topic/sync/mes-customers", "MES_CUSTOMER_CHANGED", "customerId", customerId, action); + } + + /** 广播供应商数据变更事件到 /topic/sync/mes-suppliers */ + public void publishSupplierChanged(String action, String supplierId) { + publish("/topic/sync/mes-suppliers", "MES_SUPPLIER_CHANGED", "supplierId", supplierId, action); + } + + // ─────────────────────────── 私有辅助 ──────────────────────────── + + private void publish(String topic, String cmd, String idKey, String idValue, String action) { + try { + Map event = new HashMap<>(); + event.put("cmd", cmd); + event.put("action", action); + event.put(idKey, idValue); + event.put("timestamp", System.currentTimeMillis()); + messagingTemplate.convertAndSend(topic, JSON.toJSONString(event)); + } catch (Exception e) { + log.debug("广播 STOMP 事件失败 [{}]: {}", cmd, e.getMessage()); + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslClientConnectionStore.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslClientConnectionStore.java new file mode 100644 index 0000000..c24f0a4 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslClientConnectionStore.java @@ -0,0 +1,154 @@ +package org.jeecg.modules.xslmes.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.RedisUtil; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.model.MesXslClientConnectionDTO; +import org.jeecg.modules.xslmes.service.IMesXslClientConnectionStore; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 客户端连接状态 Redis 存储 + */ +@Slf4j +@Service +public class MesXslClientConnectionStore implements IMesXslClientConnectionStore { + + private static final String STOMP_MAP_KEY = "mes:xsl:client-connections:stomp:map"; + private static final String HTTP_MAP_KEY = "mes:xsl:client-connections:http:map"; + private static final long TTL_SECONDS = 180L; + + private final RedisUtil redisUtil; + + public MesXslClientConnectionStore(RedisUtil redisUtil) { + this.redisUtil = redisUtil; + } + + @Override + public synchronized void upsertStomp(MesXslClientConnectionDTO dto) { + if (dto == null || oConvertUtils.isEmpty(dto.getSessionId())) { + return; + } + dto.setSource("stomp"); + Map map = loadMap(STOMP_MAP_KEY); + MesXslClientConnectionDTO old = map.get(dto.getSessionId()); + if (old != null && dto.getConnectTime() == null) { + dto.setConnectTime(old.getConnectTime()); + } + if (dto.getConnectTime() == null) { + dto.setConnectTime(new Date()); + } + if (dto.getLastSeen() == null) { + dto.setLastSeen(new Date()); + } + map.put(dto.getSessionId(), dto); + saveMap(STOMP_MAP_KEY, map); + } + + @Override + public synchronized void touchStomp(String sessionId, Date lastSeen) { + if (oConvertUtils.isEmpty(sessionId)) { + return; + } + Map map = loadMap(STOMP_MAP_KEY); + MesXslClientConnectionDTO dto = map.get(sessionId); + if (dto == null) { + dto = new MesXslClientConnectionDTO(); + dto.setSource("stomp"); + dto.setSessionId(sessionId); + dto.setDeviceId(sessionId); + dto.setUserName("unknown"); + dto.setPlatform("unknown"); + dto.setIp("unknown"); + dto.setConnectTime(lastSeen == null ? new Date() : lastSeen); + } + dto.setLastSeen(lastSeen == null ? new Date() : lastSeen); + map.put(sessionId, dto); + saveMap(STOMP_MAP_KEY, map); + } + + @Override + public synchronized void removeStomp(String sessionId) { + if (oConvertUtils.isEmpty(sessionId)) { + return; + } + Map map = loadMap(STOMP_MAP_KEY); + if (map.remove(sessionId) != null) { + saveMap(STOMP_MAP_KEY, map); + } + } + + @Override + public synchronized void upsertHttp(MesXslClientConnectionDTO dto) { + if (dto == null || oConvertUtils.isEmpty(dto.getDeviceId())) { + return; + } + dto.setSource("http"); + Map map = loadMap(HTTP_MAP_KEY); + MesXslClientConnectionDTO old = map.get(dto.getDeviceId()); + if (old != null && dto.getConnectTime() == null) { + dto.setConnectTime(old.getConnectTime()); + } + if (dto.getConnectTime() == null) { + dto.setConnectTime(new Date()); + } + if (dto.getLastSeen() == null) { + dto.setLastSeen(new Date()); + } + map.put(dto.getDeviceId(), dto); + saveMap(HTTP_MAP_KEY, map); + } + + @Override + public synchronized List listByType(String type) { + String t = oConvertUtils.getString(type); + List result = new ArrayList<>(); + if ("stomp".equalsIgnoreCase(t)) { + result.addAll(loadMap(STOMP_MAP_KEY).values()); + } else if ("http".equalsIgnoreCase(t)) { + result.addAll(loadMap(HTTP_MAP_KEY).values()); + } else { + result.addAll(loadMap(STOMP_MAP_KEY).values()); + result.addAll(loadMap(HTTP_MAP_KEY).values()); + } + result.sort(Comparator.comparing(MesXslClientConnectionDTO::getLastSeen, + Comparator.nullsLast(Comparator.reverseOrder()))); + return result; + } + + @Override + public synchronized void clearAll() { + try { + redisUtil.del(STOMP_MAP_KEY); + redisUtil.del(HTTP_MAP_KEY); + log.info("[连接管理] 已清空所有客户端连接记录(后端重启)"); + } catch (Exception e) { + log.warn("[连接管理] 清空连接记录失败: {}", e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private Map loadMap(String key) { + Object obj = redisUtil.get(key); + if (obj instanceof Map) { + return (Map) obj; + } + return new HashMap<>(); + } + + private void saveMap(String key, Map map) { + try { + redisUtil.set(key, map); + redisUtil.expire(key, TTL_SECONDS); + } catch (Exception e) { + log.warn("保存客户端连接状态失败, key={}, error={}", key, e.getMessage()); + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslWeightRecordServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslWeightRecordServiceImpl.java new file mode 100644 index 0000000..ab8fa7c --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslWeightRecordServiceImpl.java @@ -0,0 +1,15 @@ +package org.jeecg.modules.xslmes.service.impl; + +import org.jeecg.modules.xslmes.entity.MesXslWeightRecord; +import org.jeecg.modules.xslmes.mapper.MesXslWeightRecordMapper; +import org.jeecg.modules.xslmes.service.IMesXslWeightRecordService; +import org.springframework.stereotype.Service; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + +/** + * 地磅数据记录 ServiceImpl + */ +@Service +public class MesXslWeightRecordServiceImpl extends ServiceImpl + implements IMesXslWeightRecordService { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/websocket/MesXslConnectionLifecycleListener.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/websocket/MesXslConnectionLifecycleListener.java new file mode 100644 index 0000000..008729f --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/websocket/MesXslConnectionLifecycleListener.java @@ -0,0 +1,29 @@ +package org.jeecg.modules.xslmes.websocket; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.modules.xslmes.service.IMesXslClientConnectionStore; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * 后端启动/关闭时维护客户端连接状态一致性。 + * + *

后端进程重启时,所有 WebSocket 连接已断开,但 Redis 里的 STOMP/HTTP 记录 + * 可能仍在 TTL 内残留,导致旧 sessionId 与新连接共存。 + * 在 ApplicationReadyEvent(容器全部就绪后)清空全部记录,保证重启后列表干净。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MesXslConnectionLifecycleListener { + + private final IMesXslClientConnectionStore connectionStore; + + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + connectionStore.clearAll(); + log.info("[连接管理] 后端启动完成,已清空历史连接记录"); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/websocket/MesXslStompConnectionChannelInterceptor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/websocket/MesXslStompConnectionChannelInterceptor.java new file mode 100644 index 0000000..c2280fd --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/websocket/MesXslStompConnectionChannelInterceptor.java @@ -0,0 +1,129 @@ +package org.jeecg.modules.xslmes.websocket; + +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.model.MesXslClientConnectionDTO; +import org.jeecg.modules.xslmes.service.IMesXslClientConnectionStore; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.stereotype.Component; + +import java.security.Principal; +import java.util.Date; +import java.util.List; + +/** + * STOMP 连接状态采集拦截器 + */ +@Component +public class MesXslStompConnectionChannelInterceptor implements ChannelInterceptor { + + private final IMesXslClientConnectionStore connectionStore; + + public MesXslStompConnectionChannelInterceptor(IMesXslClientConnectionStore connectionStore) { + this.connectionStore = connectionStore; + } + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (accessor == null) { + return message; + } + + String sessionId = accessor.getSessionId(); + if (oConvertUtils.isEmpty(sessionId)) { + return message; + } + + Date now = new Date(); + StompCommand command = accessor.getCommand(); + if (StompCommand.DISCONNECT.equals(command)) { + connectionStore.removeStomp(sessionId); + return message; + } + if (StompCommand.CONNECT.equals(command)) { + MesXslClientConnectionDTO dto = new MesXslClientConnectionDTO(); + dto.setSessionId(sessionId); + dto.setDeviceId(resolveDeviceId(accessor, sessionId)); + dto.setUserName(resolveUserName(accessor)); + dto.setRealName(resolveRealName(accessor)); + dto.setPlatform(resolvePlatform(accessor)); + dto.setIp(resolveIp(accessor)); + dto.setHostName(resolveHostName(accessor)); + dto.setConnectTime(now); + dto.setLastSeen(now); + connectionStore.upsertStomp(dto); + return message; + } + + // 对 SEND/SUBSCRIBE/HEARTBEAT 等其他活动统一刷新活跃时间 + connectionStore.touchStomp(sessionId, now); + return message; + } + + private String resolveUserName(StompHeaderAccessor accessor) { + Principal principal = accessor.getUser(); + if (principal != null && oConvertUtils.isNotEmpty(principal.getName())) { + return principal.getName(); + } + String byHeader = firstNotEmptyHeader(accessor, "userName", "username", "userId", "x-user"); + return oConvertUtils.isNotEmpty(byHeader) ? byHeader : "unknown"; + } + + private String resolvePlatform(StompHeaderAccessor accessor) { + String value = firstNotEmptyHeader(accessor, "platform", "x-platform", "deviceType"); + if (oConvertUtils.isEmpty(value)) { + return "unknown"; + } + String v = value.trim().toLowerCase(); + if ("desktop".equals(v) || "pc".equals(v) || "windows".equals(v) || "win".equals(v)) { + return "desktop"; + } + if ("pda".equals(v) || "mobile".equals(v) || "android".equals(v) || "ios".equals(v) || "iphone".equals(v)) { + return "pda"; + } + return v; + } + + private String resolveIp(StompHeaderAccessor accessor) { + String value = firstNotEmptyHeader(accessor, "ip", "clientIp", "X-Forwarded-For", "x-forwarded-for"); + if (oConvertUtils.isEmpty(value)) { + return "unknown"; + } + int comma = value.indexOf(','); + return comma > 0 ? value.substring(0, comma).trim() : value.trim(); + } + + private String firstNotEmptyHeader(StompHeaderAccessor accessor, String... keys) { + for (String key : keys) { + List values = accessor.getNativeHeader(key); + if (values != null) { + for (String v : values) { + if (oConvertUtils.isNotEmpty(v)) { + return v; + } + } + } + } + return null; + } + + private String resolveRealName(StompHeaderAccessor accessor) { + String value = firstNotEmptyHeader(accessor, "realName", "real-name", "x-real-name", "displayName"); + return oConvertUtils.isNotEmpty(value) ? value.trim() : ""; + } + + private String resolveHostName(StompHeaderAccessor accessor) { + String value = firstNotEmptyHeader(accessor, "hostName", "hostname", "x-host-name", "machineName"); + return oConvertUtils.isNotEmpty(value) ? value.trim() : "unknown"; + } + + private String resolveDeviceId(StompHeaderAccessor accessor, String sessionId) { + String value = firstNotEmptyHeader(accessor, "deviceId", "terminalNo", "terminalId", "clientId"); + return oConvertUtils.isNotEmpty(value) ? value : sessionId; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/websocket/MesXslWebSocketBrokerConfig.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/websocket/MesXslWebSocketBrokerConfig.java new file mode 100644 index 0000000..76f369a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/websocket/MesXslWebSocketBrokerConfig.java @@ -0,0 +1,23 @@ +package org.jeecg.modules.xslmes.websocket; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +/** + * xslmes WebSocket 入站通道配置 + */ +@Configuration +public class MesXslWebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer { + + private final MesXslStompConnectionChannelInterceptor connectionInterceptor; + + public MesXslWebSocketBrokerConfig(MesXslStompConnectionChannelInterceptor connectionInterceptor) { + this.connectionInterceptor = connectionInterceptor; + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(connectionInterceptor); + } +} diff --git a/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/config/WebSocketConfig.java b/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/config/WebSocketConfig.java index a19f3a4..8b8d1c1 100644 --- a/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/config/WebSocketConfig.java +++ b/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/config/WebSocketConfig.java @@ -1,21 +1,40 @@ package org.jeecg.modules.device.sync.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; /** * 设备同步 WebSocket STOMP 配置。 + * + * 端点:/ws/device(纯 WebSocket,不启用 SockJS)。 + * 客户端连接地址:ws(s)://host/context/ws/device + * 心跳:10 s 双向,由 SimpleBroker 通过 taskScheduler 定期发 \n 帧保活。 */ @Configuration("deviceSyncWebSocketConfig") @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + @Bean(name = "stompHeartbeatScheduler") + public TaskScheduler stompHeartbeatScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("stomp-hb-"); + scheduler.initialize(); + return scheduler; + } + @Override public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableSimpleBroker("/topic", "/queue"); + registry.enableSimpleBroker("/topic", "/queue") + // 10 s 双向心跳:服务端每 10 s 向客户端发 \n,客户端亦声明 10 s 发一次 + .setHeartbeatValue(new long[]{10000, 10000}) + .setTaskScheduler(stompHeartbeatScheduler()); registry.setApplicationDestinationPrefixes("/app"); registry.setUserDestinationPrefix("/user"); } diff --git a/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/controller/DeviceWebSocketController.java b/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/controller/DeviceWebSocketController.java index 7689f20..eb672dd 100644 --- a/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/controller/DeviceWebSocketController.java +++ b/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/controller/DeviceWebSocketController.java @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.Optional; /** * 设备 WebSocket 控制器。 @@ -57,6 +58,26 @@ public class DeviceWebSocketController { log.debug("设备状态已广播, deviceId={}", payload.getDeviceId()); } + /** + * 应用层 PING:客户端发 {"cmd":"PING_DEVICE","deviceId":"xxx"} 到 /app/device/ping, + * 服务端广播 PONG 到 /topic/device/{deviceId}/pong,客户端收到后重置假在线计时器。 + */ + @MessageMapping("/device/ping") + public void handlePing(@org.springframework.messaging.handler.annotation.Payload(required = false) Map payload) { + String deviceId = Optional.ofNullable(payload) + .map(p -> String.valueOf(p.getOrDefault("deviceId", ""))) + .orElse(""); + if (deviceId.isBlank()) { + return; + } + Map pong = new HashMap<>(); + pong.put("cmd", "PONG_DEVICE"); + pong.put("deviceId", deviceId); + pong.put("respondedAt", System.currentTimeMillis()); + messagingTemplate.convertAndSend("/topic/device/" + deviceId + "/pong", pong); + log.debug("PONG 已回复, deviceId={}", deviceId); + } + @PostMapping("/command") public Result> sendCommand(@RequestBody Map request) { String deviceId = request == null ? null : String.valueOf(request.getOrDefault("deviceId", "")); diff --git a/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/controller/SyncController.java b/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/controller/SyncController.java index 558853f..59c449e 100644 --- a/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/controller/SyncController.java +++ b/jeecg-boot/jeecg-module-device-sync/src/main/java/org/jeecg/modules/device/sync/controller/SyncController.java @@ -12,12 +12,15 @@ import org.jeecg.modules.device.sync.entity.SyncIdempotentLog; import org.jeecg.modules.device.sync.mapper.DeviceRegistryMapper; import org.jeecg.modules.device.sync.mapper.DeviceStatusMapper; import org.jeecg.modules.device.sync.mapper.SyncIdempotentLogMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.transaction.annotation.Transactional; 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.RestController; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -35,6 +38,8 @@ public class SyncController { private final SyncIdempotentLogMapper syncIdempotentLogMapper; private final DeviceStatusMapper deviceStatusMapper; private final DeviceRegistryMapper deviceRegistryMapper; + private final JdbcTemplate jdbcTemplate; + private final SimpMessagingTemplate messagingTemplate; @PostMapping("/batch") @Transactional(rollbackFor = Exception.class) @@ -82,6 +87,9 @@ public class SyncController { case "DEVICE_REGISTRY": saveDeviceRegistry(message); break; + case "SYS_USER": + handleSysUser(message); + break; default: log.debug("未识别aggregateType,按透传记录处理,messageId={}, aggregateType={}", message.getMessageId(), aggregateType); break; @@ -111,6 +119,140 @@ public class SyncController { } } + @SuppressWarnings("unchecked") + private void handleSysUser(SyncMessageDto message) { + String eventType = message.getEventType() == null ? "" : message.getEventType().trim().toUpperCase(); + Map payload; + try { + payload = JSON.parseObject(message.getPayload(), Map.class); + } catch (Exception e) { + log.warn("SYS_USER payload 解析失败, messageId={}", message.getMessageId(), e); + return; + } + if (payload == null) { + return; + } + + switch (eventType) { + case "CREATE": { + String userId = String.valueOf(payload.getOrDefault("userId", "")).trim(); + String account = String.valueOf(payload.getOrDefault("account", "")).trim(); + if (userId.isBlank() || account.isBlank()) { + return; + } + jdbcTemplate.update( + "INSERT INTO sys_user (id, username, realname, sex, birthday, phone, email, status, create_by, create_time, update_by, update_time, del_flag) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) " + + "ON DUPLICATE KEY UPDATE " + + "username=VALUES(username), realname=VALUES(realname), sex=VALUES(sex), birthday=VALUES(birthday), phone=VALUES(phone), email=VALUES(email), status=VALUES(status), update_by=VALUES(update_by), update_time=VALUES(update_time), del_flag=0", + userId, + account, + payload.get("realName"), + payload.get("sex"), + payload.get("birthday"), + payload.get("phone"), + payload.get("email"), + payload.get("status"), + payload.get("updateBy"), + new Date(), + payload.get("updateBy"), + new Date() + ); + publishUserChanged("create", userId, null); + log.info("SYS_USER CREATE 已同步, userId={}, account={}", userId, account); + break; + } + case "UPDATE": { + String userId = String.valueOf(payload.getOrDefault("userId", "")).trim(); + if (userId.isBlank()) { + return; + } + String account = String.valueOf(payload.getOrDefault("account", "")).trim(); + jdbcTemplate.update( + "UPDATE sys_user SET username=COALESCE(NULLIF(?, ''), username), realname=?, sex=?, birthday=?, phone=?, email=?, status=?, update_by=?, update_time=? WHERE id=?", + account, + payload.get("realName"), + payload.get("sex"), + payload.get("birthday"), + payload.get("phone"), + payload.get("email"), + payload.get("status"), + payload.get("updateBy"), + new Date(), + userId + ); + publishUserChanged("edit", userId, null); + log.info("SYS_USER UPDATE 已同步, userId={}", userId); + break; + } + case "TOGGLE_STATUS": { + String userId = String.valueOf(payload.getOrDefault("userId", "")).trim(); + if (userId.isBlank()) { + return; + } + Object status = payload.get("status"); + jdbcTemplate.update( + "UPDATE sys_user SET status=?, update_by=?, update_time=? WHERE id=?", + status, + payload.get("updateBy"), + new Date(), + userId + ); + publishUserChanged("status", userId, null); + log.info("SYS_USER TOGGLE_STATUS 已同步, userId={}, status={}", userId, status); + break; + } + case "DELETE": { + String userId = String.valueOf(payload.getOrDefault("userId", "")).trim(); + if (userId.isBlank()) { + return; + } + jdbcTemplate.update( + "UPDATE sys_user SET del_flag=1 WHERE id=?", + userId + ); + publishUserChanged("delete", userId, null); + log.info("SYS_USER DELETE 已同步, userId={}", userId); + break; + } + case "BATCH_DELETE": { + List userIds = (List) payload.get("userIds"); + if (userIds == null || userIds.isEmpty()) { + return; + } + String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?")); + jdbcTemplate.update( + "UPDATE sys_user SET del_flag=1 WHERE id IN (" + placeholders + ")", + userIds.toArray() + ); + publishUserChanged("batchDelete", null, userIds); + log.info("SYS_USER BATCH_DELETE 已同步, count={}", userIds.size()); + break; + } + default: + log.debug("SYS_USER 未知 eventType={}", eventType); + } + } + + private void publishUserChanged(String action, String userId, List userIds) { + try { + Map event = new HashMap<>(); + // 批量事件使用复数命令,单条变更保持兼容旧命令 + event.put("cmd", (userIds != null && !userIds.isEmpty()) ? "SCADA_USERS_CHANGED" : "SCADA_USER_CHANGED"); + event.put("action", action); + event.put("timestamp", System.currentTimeMillis()); + if (userId != null && !userId.isBlank()) { + event.put("userId", userId); + } + if (userIds != null && !userIds.isEmpty()) { + event.put("userIds", userIds); + } + messagingTemplate.convertAndSend("/topic/sync/jeecg-users", JSON.toJSONString(event)); + } catch (Exception e) { + log.debug("广播用户同步事件失败: {}", e.getMessage()); + } + } + private void saveDeviceRegistry(SyncMessageDto message) { DeviceRegistry incoming = JSON.parseObject(message.getPayload(), DeviceRegistry.class); if (incoming == null || incoming.getDeviceId() == null || incoming.getDeviceId().isBlank()) { diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java index 087f90e..eca32ad 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java @@ -160,7 +160,7 @@ public class SysUserController { private void notifyScadaUsersChanged(String action, String ids) { try { JSONObject payload = new JSONObject(); - payload.put(WebsocketConst.MSG_CMD, "SCADA_USER_CHANGED"); + payload.put(WebsocketConst.MSG_CMD, "SCADA_USERS_CHANGED"); payload.put("action", action); payload.put("userIds", ids); payload.put("timestamp", System.currentTimeMillis()); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_31__mes_xsl_weight_record.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_31__mes_xsl_weight_record.sql new file mode 100644 index 0000000..be7798c --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_31__mes_xsl_weight_record.sql @@ -0,0 +1,77 @@ +-- 地磅数据记录:建表 + 进出方向字典 + 菜单权限(幂等) + +-- ===================== 1. 建表 ===================== +CREATE TABLE IF NOT EXISTS `mes_xsl_weight_record` ( + `id` varchar(36) NOT NULL COMMENT '主键', + `bill_no` varchar(100) DEFAULT NULL COMMENT '榜单号(BDH-yyyyMMddHHmmssxxx)', + `weigh_date` date DEFAULT NULL COMMENT '称重日期', + `inout_direction` varchar(10) DEFAULT '1' COMMENT '进出方向(字典 xslmes_inout_direction,1进厂 2出厂)', + `vehicle_id` varchar(36) DEFAULT NULL COMMENT '车辆ID(mes_xsl_vehicle.id)', + `plate_number` varchar(50) DEFAULT NULL COMMENT '车号(车牌号)', + `sender_unit` varchar(200) DEFAULT NULL COMMENT '发货单位(进厂时为供应商名称)', + `receiver_unit` varchar(200) DEFAULT NULL COMMENT '收货单位(出厂时为客户简称)', + `goods_name` varchar(200) DEFAULT NULL COMMENT '货物名称', + `gross_weight` decimal(10,2) DEFAULT NULL COMMENT '毛重(KG),实际称量', + `tare_weight` decimal(10,2) DEFAULT NULL COMMENT '皮重(KG),从车辆档案带出', + `net_weight` decimal(10,2) DEFAULT NULL COMMENT '净重(KG)=毛重-皮重,自动计算', + `driver_name` varchar(50) DEFAULT NULL COMMENT '司机', + `driver_phone` varchar(30) DEFAULT NULL COMMENT '手机号', + `del_flag` tinyint(1) DEFAULT '0' COMMENT '删除标志(0正常 1已删除)', + `create_by` varchar(50) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(50) DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `sys_org_code` varchar(64) DEFAULT NULL COMMENT '所属部门', + `tenant_id` int DEFAULT NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + KEY `idx_weight_record_bill_no` (`bill_no`), + KEY `idx_weight_record_weigh_date` (`weigh_date`), + KEY `idx_weight_record_direction` (`inout_direction`), + KEY `idx_weight_record_plate` (`plate_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='地磅数据记录'; + +-- ===================== 2. 进出方向字典 ===================== +INSERT INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`) +SELECT REPLACE(UUID(), '-', ''), 'MES进出方向', 'xslmes_inout_direction', '地磅进出方向:进厂/出厂', 0, 'admin', NOW(), 0, 0 +WHERE NOT EXISTS (SELECT 1 FROM `sys_dict` WHERE `dict_code` = 'xslmes_inout_direction' AND `del_flag` = 0); + +INSERT INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `sort_order`, `status`, `create_by`, `create_time`) +SELECT REPLACE(UUID(), '-', ''), d.id, '进厂', '1', 1, 1, 'admin', NOW() +FROM `sys_dict` d +WHERE d.`dict_code` = 'xslmes_inout_direction' + AND NOT EXISTS (SELECT 1 FROM `sys_dict_item` i WHERE i.`dict_id` = d.id AND i.`item_value` = '1'); + +INSERT INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `sort_order`, `status`, `create_by`, `create_time`) +SELECT REPLACE(UUID(), '-', ''), d.id, '出厂', '2', 2, 1, 'admin', NOW() +FROM `sys_dict` d +WHERE d.`dict_code` = 'xslmes_inout_direction' + AND NOT EXISTS (SELECT 1 FROM `sys_dict_item` i WHERE i.`dict_id` = d.id AND i.`item_value` = '2'); + +-- ===================== 3. 菜单 + 按钮权限(父菜单:MES 1900000000000000300)===================== +INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`) +SELECT '1900000000000000500', '1900000000000000300', '地磅数据记录', '/xslmes/mesXslWeightRecord', 'xslmes/mesXslWeightRecord/MesXslWeightRecordList', 1, NULL, NULL, 1, NULL, '0', 10.00, 0, 'ant-design:database-outlined', 0, 1, 0, 0, '地磅数据记录', 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000500'); + +INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`) +SELECT '1900000000000000501', '1900000000000000500', '添加', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_weight_record:add', '1', 1.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000501'); + +INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`) +SELECT '1900000000000000502', '1900000000000000500', '编辑', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_weight_record:edit', '1', 2.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000502'); + +INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`) +SELECT '1900000000000000503', '1900000000000000500', '删除', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_weight_record:delete', '1', 3.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000503'); + +INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`) +SELECT '1900000000000000504', '1900000000000000500', '批量删除', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_weight_record:deleteBatch', '1', 4.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000504'); + +INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`) +SELECT '1900000000000000505', '1900000000000000500', '导出', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_weight_record:exportXls', '1', 5.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000505'); + +INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`) +SELECT '1900000000000000506', '1900000000000000500', '导入', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_weight_record:importExcel', '1', 6.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000506'); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_32__mes_xsl_opt_lock_version.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_32__mes_xsl_opt_lock_version.sql new file mode 100644 index 0000000..d39549b --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_32__mes_xsl_opt_lock_version.sql @@ -0,0 +1,10 @@ +-- 客户/车辆/供应商:补充 MyBatis-Plus 乐观锁列 version(与实体 @Version 对应,仅执行一次) + +ALTER TABLE `mes_xsl_customer` + ADD COLUMN `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本' AFTER `status`; + +ALTER TABLE `mes_xsl_vehicle` + ADD COLUMN `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本' AFTER `status`; + +ALTER TABLE `mes_xsl_supplier` + ADD COLUMN `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本' AFTER `status`; diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslClientConnection/MesXslClientConnection.api.ts b/jeecgboot-vue3/src/views/xslmes/mesXslClientConnection/MesXslClientConnection.api.ts new file mode 100644 index 0000000..d83a3c6 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/mesXslClientConnection/MesXslClientConnection.api.ts @@ -0,0 +1,7 @@ +import { defHttp } from '/@/utils/http/axios'; + +enum Api { + list = '/xslmes/mesXslClientConnection/list', +} + +export const list = (params) => defHttp.get({ url: Api.list, params }); diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslClientConnection/MesXslClientConnection.data.ts b/jeecgboot-vue3/src/views/xslmes/mesXslClientConnection/MesXslClientConnection.data.ts new file mode 100644 index 0000000..cef0c85 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/mesXslClientConnection/MesXslClientConnection.data.ts @@ -0,0 +1,31 @@ +import { BasicColumn, FormSchema } from '/@/components/Table'; + +export const columns: BasicColumn[] = [ + { title: '来源', align: 'center', dataIndex: 'source', width: 80 }, + { title: '平台', align: 'center', dataIndex: 'platform', width: 80 }, + { title: '登录名', align: 'center', dataIndex: 'userName', width: 120, ellipsis: true }, + { title: '姓名', align: 'center', dataIndex: 'realName', width: 100, ellipsis: true }, + { title: '主机名', align: 'center', dataIndex: 'hostName', width: 160, ellipsis: true }, + { title: '设备ID', align: 'center', dataIndex: 'deviceId', width: 200, ellipsis: true }, + { title: 'IP', align: 'center', dataIndex: 'ip', width: 140 }, + { title: '会话ID', align: 'center', dataIndex: 'sessionId', width: 200, ellipsis: true }, + { title: '连接时间', align: 'center', dataIndex: 'connectTime', width: 170 }, + { title: '最后活跃', align: 'center', dataIndex: 'lastSeen', width: 170 }, +]; + +export const searchFormSchema: FormSchema[] = [ + { + label: '类型', + field: 'type', + component: 'Select', + defaultValue: 'all', + componentProps: { + options: [ + { label: '全部', value: 'all' }, + { label: 'STOMP', value: 'stomp' }, + { label: 'HTTP', value: 'http' }, + ], + }, + colProps: { span: 6 }, + }, +]; diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslClientConnection/MesXslClientConnectionList.vue b/jeecgboot-vue3/src/views/xslmes/mesXslClientConnection/MesXslClientConnectionList.vue new file mode 100644 index 0000000..cdcbac5 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/mesXslClientConnection/MesXslClientConnectionList.vue @@ -0,0 +1,34 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/MesXslWeightRecord.api.ts b/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/MesXslWeightRecord.api.ts new file mode 100644 index 0000000..789bc95 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/MesXslWeightRecord.api.ts @@ -0,0 +1,45 @@ +import { defHttp } from '/@/utils/http/axios'; +import { useMessage } from '/@/hooks/web/useMessage'; + +const { createConfirm } = useMessage(); + +enum Api { + list = '/xslmes/mesXslWeightRecord/list', + save = '/xslmes/mesXslWeightRecord/add', + edit = '/xslmes/mesXslWeightRecord/edit', + deleteOne = '/xslmes/mesXslWeightRecord/delete', + deleteBatch = '/xslmes/mesXslWeightRecord/deleteBatch', + importExcel = '/xslmes/mesXslWeightRecord/importExcel', + exportXls = '/xslmes/mesXslWeightRecord/exportXls', +} + +export const getExportUrl = Api.exportXls; +export const getImportUrl = Api.importExcel; + +export const list = (params) => defHttp.get({ url: Api.list, params }); + +export const deleteOne = (params, handleSuccess) => { + return defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => { + handleSuccess(); + }); +}; + +export const batchDelete = (params, handleSuccess) => { + createConfirm({ + iconType: 'warning', + title: '确认删除', + content: '是否删除选中数据', + okText: '确认', + cancelText: '取消', + onOk: () => { + return defHttp.delete({ url: Api.deleteBatch, data: params }, { joinParamsToUrl: true }).then(() => { + handleSuccess(); + }); + }, + }); +}; + +export const saveOrUpdate = (params, isUpdate) => { + const url = isUpdate ? Api.edit : Api.save; + return defHttp.post({ url, params }); +}; diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/MesXslWeightRecord.data.ts b/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/MesXslWeightRecord.data.ts new file mode 100644 index 0000000..24fff98 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/MesXslWeightRecord.data.ts @@ -0,0 +1,124 @@ +import { BasicColumn, FormSchema } from '/@/components/Table'; + +export const columns: BasicColumn[] = [ + { title: '榜单号', align: 'center', dataIndex: 'billNo', width: 200 }, + { + title: '称重日期', + align: 'center', + dataIndex: 'weighDate', + width: 110, + customRender: ({ text }) => (!text ? '' : text.length > 10 ? text.substring(0, 10) : text), + }, + { title: '进出方向', align: 'center', dataIndex: 'inoutDirection_dictText', width: 90 }, + { title: '车号', align: 'center', dataIndex: 'plateNumber', width: 120 }, + { title: '发货单位', align: 'center', dataIndex: 'senderUnit', width: 160, ellipsis: true }, + { title: '收货单位', align: 'center', dataIndex: 'receiverUnit', width: 160, ellipsis: true }, + { title: '货物名称', align: 'center', dataIndex: 'goodsName', width: 140, ellipsis: true }, + { title: '毛重(KG)', align: 'center', dataIndex: 'grossWeight', width: 100 }, + { title: '皮重(KG)', align: 'center', dataIndex: 'tareWeight', width: 100 }, + { title: '净重(KG)', align: 'center', dataIndex: 'netWeight', width: 100 }, + { title: '司机', align: 'center', dataIndex: 'driverName', width: 90 }, + { title: '手机号', align: 'center', dataIndex: 'driverPhone', width: 120 }, +]; + +export const searchFormSchema: FormSchema[] = [ + { label: '榜单号', field: 'billNo', component: 'JInput', colProps: { span: 6 } }, + { + label: '进出方向', + field: 'inoutDirection', + component: 'JDictSelectTag', + componentProps: { dictCode: 'xslmes_inout_direction' }, + colProps: { span: 6 }, + }, + { label: '车号', field: 'plateNumber', component: 'JInput', colProps: { span: 6 } }, + { label: '称重日期', field: 'weighDate', component: 'RangePicker', componentProps: { valueFormat: 'YYYY-MM-DD' }, colProps: { span: 8 } }, +]; + +export const formSchema: FormSchema[] = [ + { label: '', field: 'id', component: 'Input', show: false }, + { label: '', field: 'vehicleId', component: 'Input', show: false }, + { + label: '榜单号', + field: 'billNo', + component: 'Input', + componentProps: { disabled: true, placeholder: '保存后自动生成' }, + }, + { + label: '称重日期', + field: 'weighDate', + required: true, + component: 'DatePicker', + componentProps: { showTime: false, valueFormat: 'YYYY-MM-DD', placeholder: '请选择称重日期' }, + }, + { + label: '进出方向', + field: 'inoutDirection', + required: true, + component: 'Input', + slot: 'directionPicker', + defaultValue: '1', + }, + { + label: '车号', + field: 'plateNumber', + component: 'Input', + slot: 'vehiclePicker', + }, + { + label: '发货单位', + field: 'senderUnit', + component: 'Input', + componentProps: { placeholder: '进厂时自动带出供应商名称' }, + }, + { + label: '收货单位', + field: 'receiverUnit', + component: 'Input', + componentProps: { placeholder: '出厂时自动带出客户简称' }, + }, + { + label: '货物名称', + field: 'goodsName', + component: 'Input', + componentProps: { placeholder: '请输入货物名称' }, + }, + { + label: '毛重(KG)', + field: 'grossWeight', + component: 'Input', + slot: 'grossWeightInput', + }, + { + label: '皮重(KG)', + field: 'tareWeight', + component: 'InputNumber', + componentProps: { disabled: true, min: 0, precision: 2, placeholder: '选车辆后自动带出', style: { width: '100%' } }, + }, + { + label: '净重(KG)', + field: 'netWeight', + component: 'InputNumber', + componentProps: { disabled: true, min: 0, precision: 2, placeholder: '自动计算(毛重-皮重)', style: { width: '100%' } }, + }, + { + label: '司机', + field: 'driverName', + component: 'Input', + componentProps: { placeholder: '选车辆后自动带出' }, + }, + { + label: '手机号', + field: 'driverPhone', + component: 'Input', + componentProps: { placeholder: '选车辆后自动带出' }, + }, +]; + +export const superQuerySchema = { + billNo: { title: '榜单号', order: 0, view: 'text' }, + inoutDirection: { title: '进出方向', order: 1, view: 'list', dictCode: 'xslmes_inout_direction' }, + plateNumber: { title: '车号', order: 2, view: 'text' }, + weighDate: { title: '称重日期', order: 3, view: 'date' }, + grossWeight: { title: '毛重(KG)', order: 4, view: 'number' }, + netWeight: { title: '净重(KG)', order: 5, view: 'number' }, +}; diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/MesXslWeightRecordList.vue b/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/MesXslWeightRecordList.vue new file mode 100644 index 0000000..77fab00 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/MesXslWeightRecordList.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/components/MesXslVehicleSelectModal.vue b/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/components/MesXslVehicleSelectModal.vue new file mode 100644 index 0000000..b678552 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/components/MesXslVehicleSelectModal.vue @@ -0,0 +1,65 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/components/MesXslWeightRecordModal.vue b/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/components/MesXslWeightRecordModal.vue new file mode 100644 index 0000000..709c290 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/mesXslWeightRecord/components/MesXslWeightRecordModal.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/supplier_scan.json b/supplier_scan.json new file mode 100644 index 0000000..065d052 --- /dev/null +++ b/supplier_scan.json @@ -0,0 +1,134 @@ +{ + "scanKeyword": "MesXslSupplier", + "entityClass": "MesXslSupplier", + "tableName": "mes_xsl_supplier", + "javaEntityFile": "jeecg-boot\\jeecg-boot-module\\jeecg-module-xslmes\\src\\main\\java\\org\\jeecg\\modules\\xslmes\\entity\\MesXslSupplier.java", + "backendArch": { + "unifiedAnonCtrl": "jeecg-boot\\jeecg-boot-module\\jeecg-module-xslmes\\src\\main\\java\\org\\jeecg\\modules\\xslmes\\controller\\MesXslDesktopAnonController.java", + "registeredInAnonCtrl": false, + "anonEndpoints": [], + "stompNotifySvc": "jeecg-boot\\jeecg-boot-module\\jeecg-module-xslmes\\src\\main\\java\\org\\jeecg\\modules\\xslmes\\service\\MesXslStompNotifyService.java", + "registeredInStompSvc": false, + "bizCtrlFile": "jeecg-boot\\jeecg-boot-module\\jeecg-module-xslmes\\src\\main\\java\\org\\jeecg\\modules\\xslmes\\controller\\MesXslSupplierController.java", + "bizCtrlUsesSharedNotify": false + }, + "apiPrefix": "/xslmes/mesXslSupplier", + "stompCmd": "SUPPLIER_CHANGED", + "stompTopic": "/topic/sync/suppliers", + "stompSubscriptionId": "sub-mes-xsl-supplier", + "syncMode": "A", + "syncModeReason": "未发现免密端点,WPF可能是写入方,适合模式A(Outbox Push)", + "filterFields": [ + "supplierCode", + "supplierName", + "supplierShortName", + "erpCode" + ], + "fields": [ + { + "javaName": "supplierCode", + "csName": "SupplierCode", + "sqlName": "supplier_code", + "javaType": "String", + "csType": "string?", + "comment": "", + "isPk": false, + "isAudit": false, + "required": false, + "dictCode": null + }, + { + "javaName": "supplierName", + "csName": "SupplierName", + "sqlName": "supplier_name", + "javaType": "String", + "csType": "string?", + "comment": "", + "isPk": false, + "isAudit": false, + "required": false, + "dictCode": null + }, + { + "javaName": "supplierShortName", + "csName": "SupplierShortName", + "sqlName": "supplier_short_name", + "javaType": "String", + "csType": "string?", + "comment": "", + "isPk": false, + "isAudit": false, + "required": false, + "dictCode": null + }, + { + "javaName": "erpCode", + "csName": "ErpCode", + "sqlName": "erp_code", + "javaType": "String", + "csType": "string?", + "comment": "", + "isPk": false, + "isAudit": false, + "required": false, + "dictCode": null + }, + { + "javaName": "remark", + "csName": "Remark", + "sqlName": "remark", + "javaType": "String", + "csType": "string?", + "comment": "", + "isPk": false, + "isAudit": false, + "required": false, + "dictCode": null + }, + { + "javaName": "status", + "csName": "Status", + "sqlName": "status", + "javaType": "String", + "csType": "string?", + "comment": "", + "isPk": false, + "isAudit": false, + "required": false, + "dictCode": "xslmes_supplier_status" + } + ], + "pkField": { + "csName": "Id", + "csType": "string?" + }, + "auditFields": [ + "DelFlag", + "TenantId" + ], + "dbConfig": { + "url": "jdbc:mysql://localhost:3306/jeecg-boot-dev?characterEncoding=UTF-8&useUnicode=true&useSSL=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai", + "username": "root", + "configFile": "jeecg-boot\\jeecg-boot-module\\jeecg-boot-module-airag\\src\\main\\resources\\application.yml" + }, + "dbColumns": [], + "csEntityStub": "public class MesXslSupplier\n{\n public string? SupplierCode { get; set; }\n public string? SupplierName { get; set; }\n public string? SupplierShortName { get; set; }\n public string? ErpCode { get; set; }\n public string? Remark { get; set; }\n public string? Status { get; set; } [Dict:xslmes_supplier_status]\n public int? DelFlag { get; set; }\n public int? TenantId { get; set; }\n}", + "generationHints": { + "eventClassName": "MesXslSupplierChangedEvent", + "serviceInterface": "IMesXslSupplierService", + "serviceImpl": "MesXslSupplierService", + "syncCoordinator": "MesXslSupplierSyncCoordinator", + "listViewModel": "MesXslSupplierListViewModel", + "editDialogViewModel": "MesXslSupplierEditDialogViewModel", + "listView": "MesXslSupplierListView", + "editDialogView": "MesXslSupplierEditDialogView", + "pendingOpsFile": "mes-xsl-supplier-pending-ops.json", + "cacheFile": "mes-xsl-supplier-cache.json", + "backendFilesToModify": [ + "jeecg-boot\\jeecg-boot-module\\jeecg-module-xslmes\\src\\main\\java\\org\\jeecg\\modules\\xslmes\\controller\\MesXslDesktopAnonController.java", + "jeecg-boot\\jeecg-boot-module\\jeecg-module-xslmes\\src\\main\\java\\org\\jeecg\\modules\\xslmes\\service\\MesXslStompNotifyService.java", + "jeecg-boot\\jeecg-boot-module\\jeecg-module-xslmes\\src\\main\\java\\org\\jeecg\\modules\\xslmes\\controller\\MesXslSupplierController.java", + "jeecg-boot-base-core/.../ShiroConfig.java" + ] + } +} \ No newline at end of file diff --git a/yy-admin-master/YY.Admin.Core/Const/ConfigConst.cs b/yy-admin-master/YY.Admin.Core/Const/ConfigConst.cs index 6d1dedd..96577ec 100644 --- a/yy-admin-master/YY.Admin.Core/Const/ConfigConst.cs +++ b/yy-admin-master/YY.Admin.Core/Const/ConfigConst.cs @@ -67,6 +67,21 @@ public class ConfigConst /// public const string SysRefreshTokenExpire = "sys_refresh_token_expire"; + ///

+ /// 用户操作时:会话剩余不足该分钟数则续期为「Token过期时间」整段(桌面端) + /// + public const string SysTokenIdleExtendMinutes = "sys_token_idle_extend_minutes"; + + /// + /// 登录状态检查间隔(分钟),用于检测是否过期 + /// + public const string SysTokenCheckIntervalMinutes = "sys_token_check_interval_minutes"; + + /// + /// 是否启用永不过期(桌面端) + /// + public const string SysTokenNeverExpire = "sys_token_never_expire"; + /// /// 发送异常日志邮件 /// diff --git a/yy-admin-master/YY.Admin.Core/Core/Events/CustomerChangedEvent.cs b/yy-admin-master/YY.Admin.Core/Core/Events/CustomerChangedEvent.cs new file mode 100644 index 0000000..9f098da --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Events/CustomerChangedEvent.cs @@ -0,0 +1,11 @@ +using Prism.Events; + +namespace YY.Admin.Core.Events; + +public class CustomerChangedPayload +{ + public string Action { get; set; } = string.Empty; + public string? CustomerId { get; set; } +} + +public class CustomerChangedEvent : PubSubEvent { } diff --git a/yy-admin-master/YY.Admin.Core/Core/Events/SupplierChangedEvent.cs b/yy-admin-master/YY.Admin.Core/Core/Events/SupplierChangedEvent.cs new file mode 100644 index 0000000..8583f97 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Events/SupplierChangedEvent.cs @@ -0,0 +1,11 @@ +using Prism.Events; + +namespace YY.Admin.Core.Events; + +public class SupplierChangedPayload +{ + public string Action { get; set; } = string.Empty; + public string? SupplierId { get; set; } +} + +public class SupplierChangedEvent : PubSubEvent { } diff --git a/yy-admin-master/YY.Admin.Core/Core/Events/SyncConflictEvent.cs b/yy-admin-master/YY.Admin.Core/Core/Events/SyncConflictEvent.cs new file mode 100644 index 0000000..ecbbb9a --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Events/SyncConflictEvent.cs @@ -0,0 +1,13 @@ +using Prism.Events; + +namespace YY.Admin.Core.Events; + +public class SyncConflictPayload +{ + public string EntityName { get; set; } = string.Empty; + public int PushedCount { get; set; } + public int ConflictCount { get; set; } + public int NewRecordsPushed { get; set; } +} + +public class SyncConflictEvent : PubSubEvent { } diff --git a/yy-admin-master/YY.Admin.Core/Core/Events/VehicleChangedEvent.cs b/yy-admin-master/YY.Admin.Core/Core/Events/VehicleChangedEvent.cs new file mode 100644 index 0000000..dc632bc --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Events/VehicleChangedEvent.cs @@ -0,0 +1,11 @@ +using Prism.Events; + +namespace YY.Admin.Core.Events; + +public class VehicleChangedPayload +{ + public string Action { get; set; } = string.Empty; + public string? VehicleId { get; set; } +} + +public class VehicleChangedEvent : PubSubEvent { } diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/ICustomerService.cs b/yy-admin-master/YY.Admin.Core/Core/Services/ICustomerService.cs new file mode 100644 index 0000000..1b0ef3f --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Services/ICustomerService.cs @@ -0,0 +1,19 @@ +using YY.Admin.Core.Entity; + +namespace YY.Admin.Core.Services; + +public interface ICustomerService +{ + Task PageAsync(int pageNo, int pageSize, + string? customerCode = null, string? customerName = null, + string? status = null, string? customerRegion = null, + CancellationToken ct = default); + + Task GetByIdAsync(string id, CancellationToken ct = default); + Task AddAsync(MesXslCustomer customer, CancellationToken ct = default); + Task EditAsync(MesXslCustomer customer, CancellationToken ct = default); + Task DeleteAsync(string id, CancellationToken ct = default); + Task UpdateStatusAsync(string id, string status, CancellationToken ct = default); +} + +public record CustomerPageResult(List Records, long Total, int PageNo, int PageSize); diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/INetworkMonitor.cs b/yy-admin-master/YY.Admin.Core/Core/Services/INetworkMonitor.cs index 1abc627..a4e68de 100644 --- a/yy-admin-master/YY.Admin.Core/Core/Services/INetworkMonitor.cs +++ b/yy-admin-master/YY.Admin.Core/Core/Services/INetworkMonitor.cs @@ -5,4 +5,9 @@ public interface INetworkMonitor bool IsOnline { get; } event Action? StatusChanged; Task StartAsync(CancellationToken cancellationToken = default); + + /// + /// 由 STOMP 设备通道在连接成功/断开时回写,与 HTTP 探活结果取或,统一 与网络事件。 + /// + void SetStompTransportOnline(bool online); } diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/ISignalRService.cs b/yy-admin-master/YY.Admin.Core/Core/Services/ISignalRService.cs index b653705..d5a4dac 100644 --- a/yy-admin-master/YY.Admin.Core/Core/Services/ISignalRService.cs +++ b/yy-admin-master/YY.Admin.Core/Core/Services/ISignalRService.cs @@ -10,4 +10,9 @@ public interface ISignalRService Task ConnectUnifiedDeviceChannelAsync(CancellationToken cancellationToken = default); Task SendDeviceStatusAsync(object status, CancellationToken cancellationToken = default); + + /// + /// 主动断开 STOMP 连接并停止重连。 + /// + Task DisconnectAsync(CancellationToken cancellationToken = default); } diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/ISupplierService.cs b/yy-admin-master/YY.Admin.Core/Core/Services/ISupplierService.cs new file mode 100644 index 0000000..d5c7649 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Services/ISupplierService.cs @@ -0,0 +1,26 @@ +using YY.Admin.Core.Entity; + +namespace YY.Admin.Core.Services; + +public interface ISupplierService +{ + Task PageAsync(int pageNo, int pageSize, + string? supplierCode = null, string? supplierName = null, + string? supplierShortName = null, string? erpCode = null, + string? status = null, CancellationToken ct = default); + + Task GetByIdAsync(string id, CancellationToken ct = default); + Task AddAsync(MesXslSupplier supplier, CancellationToken ct = default); + Task EditAsync(MesXslSupplier supplier, CancellationToken ct = default); + Task DeleteAsync(string id, CancellationToken ct = default); + Task UpdateStatusAsync(string id, string status, CancellationToken ct = default); + + /// + /// 重连后将离线期间的本地改动推送到后端,并检测冲突。 + /// + Task PushPendingOnReconnectAsync(CancellationToken ct = default); +} + +public record SupplierPageResult(List Records, long Total, int PageNo, int PageSize); + +public record PushPendingResult(int PushedCount, int ConflictCount, int NewRecordsPushed); diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/IUserSyncOutbox.cs b/yy-admin-master/YY.Admin.Core/Core/Services/IUserSyncOutbox.cs new file mode 100644 index 0000000..8a61d79 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Services/IUserSyncOutbox.cs @@ -0,0 +1,13 @@ +namespace YY.Admin.Core.Services; + +/// +/// 将桌面端用户 CRUD 操作写入 Outbox,异步反同步到 Jeecg 后端。 +/// +public interface IUserSyncOutbox +{ + Task EnqueueCreateAsync(string userId, string account, string? realName, int? sex, DateTime? birthday, string? phone, string? email, int status, string? updateBy, CancellationToken cancellationToken = default); + Task EnqueueUpdateAsync(string userId, string account, string? realName, int? sex, DateTime? birthday, string? phone, string? email, int status, string? updateBy, CancellationToken cancellationToken = default); + Task EnqueueToggleStatusAsync(string userId, int status, string? updateBy, CancellationToken cancellationToken = default); + Task EnqueueDeleteAsync(string userId, CancellationToken cancellationToken = default); + Task EnqueueBatchDeleteAsync(IReadOnlyList userIds, CancellationToken cancellationToken = default); +} diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/IVehicleService.cs b/yy-admin-master/YY.Admin.Core/Core/Services/IVehicleService.cs new file mode 100644 index 0000000..12762b7 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Services/IVehicleService.cs @@ -0,0 +1,16 @@ +using YY.Admin.Core.Entity; + +namespace YY.Admin.Core.Services; + +public record VehiclePageResult(List Records, long Total, int Current, int Size); + +public interface IVehicleService +{ + Task PageAsync(int pageNo, int pageSize, string? plateNumber = null, string? vehicleBelong = null, string? status = null, CancellationToken ct = default); + Task GetByIdAsync(string id, CancellationToken ct = default); + Task AddAsync(MesXslVehicle vehicle, CancellationToken ct = default); + Task EditAsync(MesXslVehicle vehicle, CancellationToken ct = default); + Task DeleteAsync(string id, CancellationToken ct = default); + Task DeleteBatchAsync(string ids, CancellationToken ct = default); + Task UpdateStatusAsync(string id, string status, CancellationToken ct = default); +} diff --git a/yy-admin-master/YY.Admin.Core/Core/Sync/SysUserSyncOutbox.cs b/yy-admin-master/YY.Admin.Core/Core/Sync/SysUserSyncOutbox.cs new file mode 100644 index 0000000..b3589e7 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Sync/SysUserSyncOutbox.cs @@ -0,0 +1,16 @@ +namespace YY.Admin.Core.Sync; + +/// +/// 桌面→后端用户反同步在 Outbox 中的聚合类型常量。 +/// 走 /sys/sync/batch 而非本地拉取路径。 +/// +public static class SysUserSyncOutbox +{ + public const string AggregateType = "SYS_USER"; + + public const string EventCreate = "CREATE"; + public const string EventUpdate = "UPDATE"; + public const string EventToggleStatus = "TOGGLE_STATUS"; + public const string EventDelete = "DELETE"; + public const string EventBatchDelete = "BATCH_DELETE"; +} diff --git a/yy-admin-master/YY.Admin.Core/Entity/MesXslCustomer.cs b/yy-admin-master/YY.Admin.Core/Entity/MesXslCustomer.cs new file mode 100644 index 0000000..6b6a984 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Entity/MesXslCustomer.cs @@ -0,0 +1,25 @@ +using System; + +namespace YY.Admin.Core.Entity; + +public class MesXslCustomer +{ + public string? Id { get; set; } + public string? CustomerCode { get; set; } + public string? CustomerName { get; set; } + public string? CustomerShortName { get; set; } + public string? CustomerRegion { get; set; } // Dict: xslmes_customer_region + public string? ErpCode { get; set; } + public string? Status { get; set; } // Dict: xslmes_customer_status "0"启用 "1"停用 + public int? IzEnable { get; set; } // 与 Status 联动,由服务端写入,本端只读 + public string? CustomerDesc { get; set; } + public int? TenantId { get; set; } + public string? CreateBy { get; set; } + public DateTime? CreateTime { get; set; } + public string? UpdateBy { get; set; } + public DateTime? UpdateTime { get; set; } + public string? SysOrgCode { get; set; } + public int? Version { get; set; } + + public string StatusText => Status == "1" ? "停用" : "启用"; +} diff --git a/yy-admin-master/YY.Admin.Core/Entity/MesXslSupplier.cs b/yy-admin-master/YY.Admin.Core/Entity/MesXslSupplier.cs new file mode 100644 index 0000000..39ee75b --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Entity/MesXslSupplier.cs @@ -0,0 +1,23 @@ +using System; + +namespace YY.Admin.Core.Entity; + +public class MesXslSupplier +{ + public string? Id { get; set; } + public string? SupplierCode { get; set; } + public string? SupplierName { get; set; } + public string? SupplierShortName { get; set; } + public string? ErpCode { get; set; } + public string? Remark { get; set; } + public string? Status { get; set; } // Dict: xslmes_supplier_status "0"启用 "1"停用 + public int? TenantId { get; set; } + public string? CreateBy { get; set; } + public DateTime? CreateTime { get; set; } + public string? UpdateBy { get; set; } + public DateTime? UpdateTime { get; set; } + public string? SysOrgCode { get; set; } + public int? Version { get; set; } + + public string StatusText => Status == "1" ? "停用" : "启用"; +} diff --git a/yy-admin-master/YY.Admin.Core/Entity/MesXslVehicle.cs b/yy-admin-master/YY.Admin.Core/Entity/MesXslVehicle.cs new file mode 100644 index 0000000..f3aabe9 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Entity/MesXslVehicle.cs @@ -0,0 +1,46 @@ +namespace YY.Admin.Core.Entity; + +public class MesXslVehicle +{ + public string? Id { get; set; } + public string? PlateNumber { get; set; } + + /// 车辆归属:1客户 2供应商 3本公司 + public string? VehicleBelong { get; set; } + + public decimal? TareWeightKg { get; set; } + public decimal? LoadCapacity { get; set; } + public string? UnitId { get; set; } + public string? LoadUnit { get; set; } + public string? CustomerIds { get; set; } + public string? CustomerShortName { get; set; } + public string? SupplierId { get; set; } + public string? SupplierName { get; set; } + public string? SupplierShortName { get; set; } + public decimal? VehicleLength { get; set; } + public decimal? VehicleWidth { get; set; } + public decimal? VehicleHeight { get; set; } + public string? DriverName { get; set; } + public string? DriverPhone { get; set; } + + /// 状态:0启用 1停用 + public string? Status { get; set; } + + public int? TenantId { get; set; } + public string? CreateBy { get; set; } + public DateTime? CreateTime { get; set; } + public string? UpdateBy { get; set; } + public DateTime? UpdateTime { get; set; } + public string? SysOrgCode { get; set; } + public int? Version { get; set; } + + public string VehicleBelongText => VehicleBelong switch + { + "1" => "客户", + "2" => "供应商", + "3" => "本公司", + _ => VehicleBelong ?? "" + }; + + public string StatusText => Status == "1" ? "停用" : "启用"; +} diff --git a/yy-admin-master/YY.Admin.Core/SeedData/SysConfigSeedData.cs b/yy-admin-master/YY.Admin.Core/SeedData/SysConfigSeedData.cs index 32f5416..f308324 100644 --- a/yy-admin-master/YY.Admin.Core/SeedData/SysConfigSeedData.cs +++ b/yy-admin-master/YY.Admin.Core/SeedData/SysConfigSeedData.cs @@ -30,6 +30,9 @@ public class SysConfigSeedData : ISqlSugarEntitySeedData new SysConfig{ Id=1300000000172, Name="登录时隐藏租户", Code=ConfigConst.SysHideTenantLogin, Value="True", SysFlag=YesNoEnum.Y, Remark="登录时隐藏租户", OrderNo=90, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") }, new SysConfig{ Id=1300000000181, Name="Token过期时间", Code=ConfigConst.SysTokenExpire, Value="30", SysFlag=YesNoEnum.Y, Remark="Token过期时间(分钟)", OrderNo=100, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") }, new SysConfig{ Id=1300000000191, Name="RefreshToken过期时间", Code=ConfigConst.SysRefreshTokenExpire, Value="20160", SysFlag=YesNoEnum.Y, Remark="刷新Token过期时间(分钟)(一般 refresh_token 的有效时间 > 2 * access_token 的有效时间)", OrderNo=110, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") }, + new SysConfig{ Id=1300000000192, Name="会话续期阈值", Code=ConfigConst.SysTokenIdleExtendMinutes, Value="20", SysFlag=YesNoEnum.Y, Remark="桌面端:用户有操作时,若会话剩余不足该分钟数则续期为「Token过期时间」整段", OrderNo=115, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") }, + new SysConfig{ Id=1300000000193, Name="登录状态检查间隔", Code=ConfigConst.SysTokenCheckIntervalMinutes, Value="1", SysFlag=YesNoEnum.Y, Remark="桌面端:定时检查登录是否过期的间隔(分钟)", OrderNo=118, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") }, + new SysConfig{ Id=1300000000194, Name="登录永不过期", Code=ConfigConst.SysTokenNeverExpire, Value="False", SysFlag=YesNoEnum.Y, Remark="桌面端:开启后不触发登录过期提示与自动踢回登录页", OrderNo=119, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") }, new SysConfig{ Id=1300000000201, Name="发送异常日志邮件", Code=ConfigConst.SysErrorMail, Value="False", SysFlag=YesNoEnum.Y, Remark="是否发送异常日志邮件", OrderNo=120, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") }, new SysConfig{ Id=1300000000211, Name="域登录验证", Code=ConfigConst.SysDomainLogin, Value="False", SysFlag=YesNoEnum.Y, Remark="是否开启域登录验证", OrderNo=130, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") }, new SysConfig{ Id=1300000000221, Name="数据校验日志", Code=ConfigConst.SysValidationLog, Value="True", SysFlag=YesNoEnum.Y, Remark="是否数据校验日志", OrderNo=140, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") }, diff --git a/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs b/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs index c31fdcd..ac6b358 100644 --- a/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs +++ b/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs @@ -21,6 +21,20 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData // 建议此处Id范围之间放置具体业务应用菜单 + #region 基础资料 + + new SysMenu{ Id=1300150000101, Pid=0, Title="基础资料", Path="/base", Name="base", Component="Layout", Icon="", Type=MenuTypeEnum.Dir, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=9000 }, + + // 车辆管理 + new SysMenu{ Id=1300150010101, Pid=1300150000101, Title="车辆管理", Path="/xslmes/mesXslVehicle", Name="mesXslVehicle", Component="VehicleListView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 }, + + // 客户管理 + new SysMenu{ Id=1300150010201, Pid=1300150000101, Title="客户管理", Path="/xslmes/mesXslCustomer", Name="mesXslCustomer", Component="CustomerListView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=101 }, + // 供应商管理 + new SysMenu{ Id=1300150010301, Pid=1300150000101, Title="供应商管理", Path="/xslmes/mesXslSupplier", Name="mesXslSupplier", Component="SupplierListView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=102 }, + + #endregion + #region 系统管理 new SysMenu{ Id=1300200000101, Pid=0, Title="系统管理", Path="", Name="system", Component="Layout", Icon="", Type=MenuTypeEnum.Dir, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=10000 }, @@ -43,6 +57,9 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData new SysMenu{ Id=1300200012011, Pid=1300200012001, Title="查询", Permission="sysDict:page", Type=MenuTypeEnum.Btn, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 }, new SysMenu{ Id=1300200012021, Pid=1300200012001, Title="同步", Permission="sysDict:sync", Type=MenuTypeEnum.Btn, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 }, + // 登录设置(桌面端会话与检查间隔) + new SysMenu{ Id=1300200013001, Pid=1300200000101, Title="登录设置", Path="LoginSettingsView", Name="loginSettings", Component="LoginSettingsView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=107 }, + // 角色管理 new SysMenu{ Id=1300200020101, Pid=1300200000101, Title="角色管理", Path="RoleManagementView", Name="sysRole", Component="/system/role/index", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=110 }, new SysMenu{ Id=1300200020201, Pid=1300200020101, Title="查询", Permission="sysRole:page", Type=MenuTypeEnum.Btn, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 }, @@ -114,7 +131,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData new SysMenu{ Id=1300300011201, Pid=1300300010101, Title="进入租管端", Permission="sysTenant:goTenant", Type=MenuTypeEnum.Btn, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 }, // 菜单管理 - new SysMenu{ Id=1300300030101, Pid=1300300000101, Title="菜单管理", Path="/platform/menu", Name="sysMenu", Component="/system/menu/index", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=110 }, + new SysMenu{ Id=1300300030101, Pid=1300300000101, Title="菜单管理", Path="MenuManagementView", Name="sysMenu", Component="MenuManagementView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=110 }, new SysMenu{ Id=1300300030201, Pid=1300300030101, Title="查询", Permission="sysMenu:list", Type=MenuTypeEnum.Btn, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 }, new SysMenu{ Id=1300300030301, Pid=1300300030101, Title="编辑", Permission="sysMenu:update", Type=MenuTypeEnum.Btn, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 }, new SysMenu{ Id=1300300030401, Pid=1300300030101, Title="增加", Permission="sysMenu:add", Type=MenuTypeEnum.Btn, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 }, diff --git a/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs b/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs index a90df13..d33b400 100644 --- a/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs +++ b/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs @@ -21,6 +21,9 @@ public class SysTenantMenuSeedData : ISqlSugarEntitySeedData return new[] { new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300100000101}, + new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300150000101}, + new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300150010101}, + new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300150010201}, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200010701 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300300100601 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200090401 }, @@ -42,6 +45,7 @@ public class SysTenantMenuSeedData : ISqlSugarEntitySeedData new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300300090201 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200011101 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200010101 }, + new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200013001 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300500010101 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300500030101 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300300051301 }, diff --git a/yy-admin-master/YY.Admin.Core/Util/SnowflakeId.cs b/yy-admin-master/YY.Admin.Core/Util/SnowflakeId.cs new file mode 100644 index 0000000..97ddfb3 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Util/SnowflakeId.cs @@ -0,0 +1,11 @@ +using Yitter.IdGenerator; + +namespace YY.Admin.Core; + +/// +/// 分布式雪花 Id(依赖应用启动时 SqlSugarSetup 中对 YitIdHelper 的初始化) +/// +public static class SnowflakeId +{ + public static long Next() => YitIdHelper.NextId(); +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Auth/SysAuthService.cs b/yy-admin-master/YY.Admin.Services/Service/Auth/SysAuthService.cs index 2ad6ce5..310964f 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Auth/SysAuthService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Auth/SysAuthService.cs @@ -15,13 +15,6 @@ namespace YY.Admin.Services.Service.Auth { public class SysAuthService : ISysAuthService, ISingletonDependency { - /// - ///token过期时间(分) - /// - //private const int _refreshExpires = 30; - // token过期剩余时间(分) - private readonly TimeSpan _idleTimeout = TimeSpan.FromMinutes(20); - private SysUser? _currentUser; public SysUser? CurrentUser => _currentUser; public event EventHandler? UserChanged; @@ -2324,7 +2317,25 @@ namespace YY.Admin.Services.Service.Auth private async Task getSysTokenExpireAsync() { - return await _sysConfigService.GetConfigValue(ConfigConst.SysTokenExpire); + var v = await _sysConfigService.GetConfigValue(ConfigConst.SysTokenExpire); + return v > 0 ? v : 30; + } + + /// + /// 用户操作时续期阈值(分钟),默认 20 + /// + private async Task getSysTokenIdleExtendMinutesAsync() + { + var v = await _sysConfigService.GetConfigValue(ConfigConst.SysTokenIdleExtendMinutes); + return v > 0 ? v : 20; + } + + /// + /// 是否开启永不过期 + /// + private async Task getSysTokenNeverExpireAsync() + { + return await _sysConfigService.GetConfigValue(ConfigConst.SysTokenNeverExpire); } @@ -2338,8 +2349,9 @@ namespace YY.Admin.Services.Service.Auth // 生成访问令牌(实际项目应使用JWT) var accessToken = GenerateSecureToken(32); var refreshToken = GenerateSecureToken(32); + var neverExpire = await getSysTokenNeverExpireAsync(); var refreshExpires = await getSysTokenExpireAsync(); - var refreshExpiration = DateTime.Now.AddMinutes(refreshExpires); + var refreshExpiration = neverExpire ? DateTime.MaxValue : DateTime.Now.AddMinutes(refreshExpires); // 存储Token关联信息 _tokenStore[accessToken] = new UserContext { @@ -2380,6 +2392,9 @@ namespace YY.Admin.Services.Service.Auth if (string.IsNullOrEmpty(accessToken)) return false; + if (getSysTokenNeverExpireAsync().GetAwaiter().GetResult()) + return _tokenStore.ContainsKey(accessToken); + return _tokenStore.TryGetValue(accessToken, out var tokenInfo) && tokenInfo.Token.RefreshExpires >= DateTime.Now; } @@ -2388,9 +2403,13 @@ namespace YY.Admin.Services.Service.Auth { if (string.IsNullOrEmpty(accessToken)) return; + if (await getSysTokenNeverExpireAsync()) + return; if (_tokenStore.TryGetValue(accessToken, out var tokenInfo)) { - if (tokenInfo.Token.RefreshExpires - DateTime.Now <= _idleTimeout) + var idleMin = await getSysTokenIdleExtendMinutesAsync(); + var idleSpan = TimeSpan.FromMinutes(idleMin); + if (tokenInfo.Token.RefreshExpires - DateTime.Now <= idleSpan) { var refreshExpires = await getSysTokenExpireAsync(); tokenInfo.Token.RefreshExpires = DateTime.Now.AddMinutes(refreshExpires); @@ -2430,6 +2449,7 @@ namespace YY.Admin.Services.Service.Auth _tokenStore.TryRemove(accessToken, out _); var refreshExpires = await getSysTokenExpireAsync(); + var neverExpire = await getSysTokenNeverExpireAsync(); _tokenStore[newToken] = new UserContext { UserId = user.Id, @@ -2442,7 +2462,7 @@ namespace YY.Admin.Services.Service.Auth Token = new UserToken() { RefreshToken = refreshToken, - RefreshExpires = DateTime.Now.AddMinutes(refreshExpires), + RefreshExpires = neverExpire ? DateTime.MaxValue : DateTime.Now.AddMinutes(refreshExpires), } }; diff --git a/yy-admin-master/YY.Admin.Services/Service/Config/ISysConfigService.cs b/yy-admin-master/YY.Admin.Services/Service/Config/ISysConfigService.cs index ff3276f..fa6d260 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Config/ISysConfigService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Config/ISysConfigService.cs @@ -9,5 +9,10 @@ namespace YY.Admin.Services.Service.Config public interface ISysConfigService { Task GetConfigValue(string code); + + /// + /// 按编码更新配置值并清除该项缓存 + /// + Task<(bool ok, string message)> SetConfigValueAsync(string code, string value); } } diff --git a/yy-admin-master/YY.Admin.Services/Service/Config/SysConfigService.cs b/yy-admin-master/YY.Admin.Services/Service/Config/SysConfigService.cs index 010846b..dfc1d05 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Config/SysConfigService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Config/SysConfigService.cs @@ -1,9 +1,6 @@ using SqlSugar; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using YY.Admin.Core; +using YY.Admin.Core.Const; namespace YY.Admin.Services.Service.Config { @@ -31,5 +28,23 @@ namespace YY.Admin.Services.Service.Config if (string.IsNullOrWhiteSpace(value)) return default; return (T)Convert.ChangeType(value, typeof(T)); } + + /// + public async Task<(bool ok, string message)> SetConfigValueAsync(string code, string value) + { + if (string.IsNullOrWhiteSpace(code)) + return (false, "配置编码无效"); + + var n = await _dbContext.Updateable() + .SetColumns(c => new SysConfig { Value = value, UpdateTime = DateTime.Now }) + .Where(c => c.Code == code) + .ExecuteCommandAsync(); + + if (n <= 0) + return (false, "未找到对应配置项或无需更新"); + + _sysCacheService.Remove($"{CacheConst.KeyConfig}{code}"); + return (true, "保存成功"); + } } } diff --git a/yy-admin-master/YY.Admin.Services/Service/Customer/CustomerService.cs b/yy-admin-master/YY.Admin.Services/Service/Customer/CustomerService.cs new file mode 100644 index 0000000..f4b8a10 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Customer/CustomerService.cs @@ -0,0 +1,790 @@ +using Microsoft.Extensions.Configuration; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Web; +using Prism.Events; +using YY.Admin.Core; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.Customer; + +public class CustomerService : ICustomerService, ISingletonDependency +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; + private readonly INetworkMonitor _networkMonitor; + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + private readonly SemaphoreSlim _syncLock = new(1, 1); + private readonly object _cacheLock = new(); + private readonly string _pendingOpsFilePath; + private readonly string _cacheFilePath; + private List _pendingOps = new(); + private List _localCache = new(); + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new NullableDateTimeJsonConverter() } + }; + + public CustomerService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + INetworkMonitor networkMonitor, + IEventAggregator eventAggregator, + ILoggerService logger) + { + _httpClientFactory = httpClientFactory; + _configuration = configuration; + _networkMonitor = networkMonitor; + _eventAggregator = eventAggregator; + _logger = logger; + + var appDataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "YY.Admin", "sync-cache"); + Directory.CreateDirectory(appDataDir); + _pendingOpsFilePath = Path.Combine(appDataDir, "mes-xsl-customer-pending-ops.json"); + _cacheFilePath = Path.Combine(appDataDir, "mes-xsl-customer-cache.json"); + + LoadPendingOpsFromDisk(); + LoadCacheFromDisk(); + _logger.Information($"[客户同步] 服务初始化,缓存={_localCache.Count},待上传={_pendingOps.Count},在线={_networkMonitor.IsOnline}"); + + _networkMonitor.StatusChanged += OnNetworkStatusChanged; + if (_networkMonitor.IsOnline) + _ = Task.Run(() => SyncAfterReconnectAsync(CancellationToken.None)); + } + + private const int MaxPendingRetries = 5; + + private string BaseUrl => (_configuration.GetValue("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/'); + private int DefaultTenantId => (int?)_configuration.GetValue("JeecgIntegration:DefaultTenantId") ?? 1002; + + private HttpClient CreateClient() => _httpClientFactory.CreateClient("JeecgApi"); + + // ── 分页 ────────────────────────────────────────────────────────────── + + public async Task PageAsync(int pageNo, int pageSize, + string? customerCode = null, string? customerName = null, + string? status = null, string? customerRegion = null, + CancellationToken ct = default) + { + List? source = null; + if (_networkMonitor.IsOnline) + { + try + { + source = await FetchRemoteListAsync(ct).ConfigureAwait(false); + lock (_cacheLock) + { + _localCache = source.Select(Clone).ToList(); + SaveCacheToDiskUnsafe(); + } + _logger.Information($"[客户列表] 远端拉取成功 count={source.Count}"); + } + catch (Exception ex) + { + source = null; + _logger.Warning($"[客户列表] 远端拉取失败,回退缓存:{ex.Message}"); + } + } + + lock (_cacheLock) + { + source ??= _localCache.Select(Clone).ToList(); + source = ApplyPendingOpsSnapshotUnsafe(source); + } + + var filtered = ApplyFilters(source, customerCode, customerName, status, customerRegion); + var total = filtered.Count; + var records = filtered.Skip(Math.Max(0, (pageNo - 1) * pageSize)).Take(pageSize).ToList(); + return new CustomerPageResult(records, total, pageNo, pageSize); + } + + // ── 详情 ────────────────────────────────────────────────────────────── + + public async Task GetByIdAsync(string id, CancellationToken ct = default) + { + if (_networkMonitor.IsOnline) + { + try + { + var url = $"{BaseUrl}/xslmes/mesXslCustomer/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return null; + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("result", out var resultEl)) return null; + return resultEl.Deserialize(_jsonOpts); + } + catch (Exception ex) + { + _logger.Warning($"[客户详情] 远端查询失败 id={id},回退缓存:{ex.Message}"); + } + } + lock (_cacheLock) + { + return _localCache.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase)) is { } found + ? Clone(found) : null; + } + } + + // ── 新增 ────────────────────────────────────────────────────────────── + + public async Task AddAsync(MesXslCustomer customer, CancellationToken ct = default) + { + if (!customer.TenantId.HasValue || customer.TenantId.Value <= 0) + customer.TenantId = DefaultTenantId; + + var local = Clone(customer); + if (string.IsNullOrWhiteSpace(local.Id)) + local.Id = $"local-{Guid.NewGuid():N}"; + if (string.IsNullOrWhiteSpace(local.Status)) + local.Status = "0"; + SyncIzEnable(local); + + if (_networkMonitor.IsOnline) + { + try + { + var ok = await RemoteAddAsync(local, ct).ConfigureAwait(false); + if (ok) { UpsertLocalCache(local); return true; } + return false; + } + catch (Exception ex) + { + _logger.Warning($"[客户新增] 远端失败,转离线入队:{ex.Message}"); + } + } + + EnqueuePendingOperation(new CustomerPendingOperation + { OpType = CustomerOperationType.Add, CustomerId = local.Id, Customer = local }); + UpsertLocalCache(local); + return true; + } + + // ── 编辑 ────────────────────────────────────────────────────────────── + + public async Task EditAsync(MesXslCustomer customer, CancellationToken ct = default) + { + if (!customer.TenantId.HasValue || customer.TenantId.Value <= 0) + customer.TenantId = DefaultTenantId; + + var local = Clone(customer); + SyncIzEnable(local); + + if (_networkMonitor.IsOnline) + { + try + { + var (ok, _) = await RemoteEditAsync(local, ct).ConfigureAwait(false); + if (ok) { UpsertLocalCache(local); return true; } + return false; + } + catch (Exception ex) + { + _logger.Warning($"[客户编辑] 远端失败,转离线入队:{ex.Message}"); + } + } + + EnqueuePendingOperation(new CustomerPendingOperation + { + OpType = CustomerOperationType.Edit, + CustomerId = local.Id, + Customer = local, + AnchorUpdateTime = local.UpdateTime + }); + UpsertLocalCache(local); + return true; + } + + // ── 删除 ────────────────────────────────────────────────────────────── + + public async Task DeleteAsync(string id, CancellationToken ct = default) + { + if (_networkMonitor.IsOnline) + { + try + { + var ok = await RemoteDeleteAsync(id, ct).ConfigureAwait(false); + if (ok) { RemoveFromLocalCache(id); return true; } + return false; + } + catch (Exception ex) + { + _logger.Warning($"[客户删除] 远端失败,转离线入队:{ex.Message}"); + } + } + + DateTime? anchor; + lock (_cacheLock) + { + anchor = _localCache.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime; + } + EnqueuePendingOperation(new CustomerPendingOperation + { + OpType = CustomerOperationType.Delete, + CustomerId = id, + AnchorUpdateTime = anchor + }); + RemoveFromLocalCache(id); + return true; + } + + // ── 状态切换 ────────────────────────────────────────────────────────── + + public async Task UpdateStatusAsync(string id, string status, CancellationToken ct = default) + { + if (_networkMonitor.IsOnline) + { + try + { + var ok = await RemoteUpdateStatusAsync(id, status, ct).ConfigureAwait(false); + if (ok) { UpdateLocalStatus(id, status); return true; } + return false; + } + catch (Exception ex) + { + _logger.Warning($"[客户状态] 远端失败,转离线入队:{ex.Message}"); + } + } + + DateTime? anchor; + lock (_cacheLock) + { + anchor = _localCache.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime; + } + EnqueuePendingOperation(new CustomerPendingOperation + { + OpType = CustomerOperationType.UpdateStatus, + CustomerId = id, + Status = status, + AnchorUpdateTime = anchor + }); + UpdateLocalStatus(id, status); + return true; + } + + // ── 远端调用 ────────────────────────────────────────────────────────── + + private async Task> FetchRemoteListAsync(CancellationToken ct) + { + var query = HttpUtility.ParseQueryString(string.Empty); + query["pageNo"] = "1"; + query["pageSize"] = "10000"; + query["tenantId"] = DefaultTenantId.ToString(); + var url = $"{BaseUrl}/xslmes/mesXslCustomer/anon/list?{query}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("result").GetProperty("records") + .Deserialize>(_jsonOpts) ?? new(); + } + + private async Task RemoteAddAsync(MesXslCustomer customer, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslCustomer/anon/add?tenantId={DefaultTenantId}"; + var payload = Clone(customer); + if (IsLocalTempId(payload.Id)) payload.Id = null; + return await PostJsonAsync(url, payload, ct).ConfigureAwait(false); + } + + private async Task<(bool Ok, bool IsVersionConflict)> RemoteEditAsync(MesXslCustomer customer, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslCustomer/anon/edit?tenantId={DefaultTenantId}"; + return await PostJsonCheckVersionAsync(url, customer, ct).ConfigureAwait(false); + } + + private async Task RemoteDeleteAsync(string id, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslCustomer/anon/delete?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + } + + private async Task RemoteUpdateStatusAsync(string id, string status, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslCustomer/anon/updateStatus?id={Uri.EscapeDataString(id)}&status={Uri.EscapeDataString(status)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.PostAsync(url, null, ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + } + + private async Task PostJsonAsync(string url, object body, CancellationToken ct) + { + var content = new StringContent(JsonSerializer.Serialize(body, _jsonOpts), Encoding.UTF8, "application/json"); + using var client = CreateClient(); + var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + } + + private async Task<(bool Ok, bool IsVersionConflict)> PostJsonCheckVersionAsync(string url, object body, CancellationToken ct) + { + var content = new StringContent(JsonSerializer.Serialize(body, _jsonOpts), Encoding.UTF8, "application/json"); + using var client = CreateClient(); + var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return (false, false); + try + { + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + int code = 200; + if (doc.RootElement.TryGetProperty("code", out var codeEl)) code = codeEl.GetInt32(); + if (code == 200) return (true, false); + if (doc.RootElement.TryGetProperty("message", out var msgEl)) + { + var msg = msgEl.GetString() ?? ""; + if (msg.Contains("已被他人修改")) return (false, true); + } + return (false, false); + } + catch { return (true, false); } + } + + private static async Task IsSuccessResultAsync(HttpResponseMessage resp, CancellationToken ct) + { + try + { + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("code", out var code)) return code.GetInt32() == 200; + if (doc.RootElement.TryGetProperty("success", out var success)) return success.GetBoolean(); + return true; + } + catch { return true; } + } + + // ── 断线续连 ────────────────────────────────────────────────────────── + + private void OnNetworkStatusChanged(bool isOnline) + { + if (!isOnline) return; + _ = Task.Run(() => SyncAfterReconnectAsync(CancellationToken.None)); + } + + private async Task SyncAfterReconnectAsync(CancellationToken ct) + { + var pushResult = await PushPendingOnReconnectAsync(ct).ConfigureAwait(false); + if (!_networkMonitor.IsOnline) return; + try + { + var remote = await FetchRemoteListAsync(ct).ConfigureAwait(false); + lock (_cacheLock) + { + _localCache = remote.Select(Clone).ToList(); + SaveCacheToDiskUnsafe(); + } + _eventAggregator.GetEvent() + .Publish(new CustomerChangedPayload { Action = "pull" }); + _logger.Information($"[客户重连] 全量回拉成功 count={remote.Count}"); + } + catch (Exception ex) + { + _logger.Warning($"[客户重连] 全量回拉失败:{ex.Message}"); + } + + var hasActivity = pushResult.PushedCount > 0 + || pushResult.ConflictCount > 0 + || pushResult.NewRecordsPushed > 0; + if (hasActivity) + { + _eventAggregator.GetEvent() + .Publish(new SyncConflictPayload + { + EntityName = "客户", + PushedCount = pushResult.PushedCount, + ConflictCount = pushResult.ConflictCount, + NewRecordsPushed = pushResult.NewRecordsPushed + }); + } + } + + private async Task PushPendingOnReconnectAsync(CancellationToken ct) + { + if (!await _syncLock.WaitAsync(0, ct).ConfigureAwait(false)) + return new PushPendingResult(0, 0, 0); + try + { + List snapshot; + lock (_cacheLock) { snapshot = _pendingOps.OrderBy(x => x.CreatedAt).ToList(); } + _logger.Information($"[客户回放] pending={snapshot.Count}"); + + int pushed = 0, conflicts = 0, newPushed = 0; + + foreach (var op in snapshot) + { + if (!_networkMonitor.IsOnline) break; + + // 如果该 op 已在上一条冲突处理中被清理,则跳过 + lock (_cacheLock) + { + if (!_pendingOps.Any(x => x.Id == op.Id)) continue; + } + + var result = await ExecutePendingOpWithConflictAsync(op, ct).ConfigureAwait(false); + if (!result.Ok) + { + lock (_cacheLock) + { + op.RetryCount++; + if (op.RetryCount >= MaxPendingRetries) + { + _logger.Warning($"[客户回放] op={op.OpType} 超过最大重试次数({MaxPendingRetries}),放弃 customerId={op.CustomerId}"); + _pendingOps.RemoveAll(x => x.Id == op.Id); + SavePendingOpsToDiskUnsafe(); + continue; + } + SavePendingOpsToDiskUnsafe(); + } + break; + } + + if (result.IsConflict) + { + conflicts++; + // 冲突时:以服务器版本为准,直接丢弃同一条记录的所有 pending + if (!string.IsNullOrWhiteSpace(result.EntityId)) + RemovePendingOpsByCustomerId(result.EntityId); + } + else if (op.OpType == CustomerOperationType.Add) + { + newPushed++; + lock (_cacheLock) + { + _pendingOps.RemoveAll(x => x.Id == op.Id); + SavePendingOpsToDiskUnsafe(); + } + } + else + { + pushed++; + lock (_cacheLock) + { + _pendingOps.RemoveAll(x => x.Id == op.Id); + SavePendingOpsToDiskUnsafe(); + } + } + } + + return new PushPendingResult(pushed, conflicts, newPushed); + } + finally { _syncLock.Release(); } + } + + private sealed record PendingReplayResult(bool Ok, bool IsConflict, string? EntityId); + + private async Task ExecutePendingOpWithConflictAsync(CustomerPendingOperation op, CancellationToken ct) + { + try + { + return op.OpType switch + { + CustomerOperationType.Add => await ExecuteAddAsync(op, ct).ConfigureAwait(false), + CustomerOperationType.Edit => await ExecuteEditWithConflictAsync(op, ct).ConfigureAwait(false), + CustomerOperationType.Delete => await ExecuteDeleteWithConflictAsync(op, ct).ConfigureAwait(false), + CustomerOperationType.UpdateStatus => await ExecuteUpdateStatusWithConflictAsync(op, ct).ConfigureAwait(false), + _ => new PendingReplayResult(true, false, null) + }; + } + catch (Exception ex) + { + _logger.Warning($"[客户回放] 执行失败 op={op.OpType}:{ex.Message}"); + return new PendingReplayResult(false, false, null); + } + } + + private async Task ExecuteAddAsync(CustomerPendingOperation op, CancellationToken ct) + { + var ok = op.Customer != null && await RemoteAddAsync(op.Customer, ct).ConfigureAwait(false); + return ok + ? new PendingReplayResult(true, false, op.CustomerId) + : new PendingReplayResult(false, false, null); + } + + private async Task ExecuteEditWithConflictAsync(CustomerPendingOperation op, CancellationToken ct) + { + var id = op.Customer?.Id; + if (string.IsNullOrWhiteSpace(id)) + return new PendingReplayResult(false, false, null); + + // 冲突检测:服务器 UpdateTime != 本地 AnchorUpdateTime + var remote = await FetchRemoteSingleAsync(id!, ct).ConfigureAwait(false); + if (remote != null && op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime) + { + UpsertLocalCache(remote); + return new PendingReplayResult(true, true, id); + } + + var (ok, isVersionConflict) = await RemoteEditAsync(op.Customer!, ct).ConfigureAwait(false); + if (isVersionConflict) + { + var fresh = await FetchRemoteSingleAsync(id!, ct).ConfigureAwait(false); + if (fresh != null) UpsertLocalCache(fresh); + return new PendingReplayResult(true, true, id); + } + return ok + ? new PendingReplayResult(true, false, id) + : new PendingReplayResult(false, false, null); + } + + private async Task ExecuteDeleteWithConflictAsync(CustomerPendingOperation op, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(op.CustomerId)) + return new PendingReplayResult(false, false, null); + + var id = op.CustomerId!; + var remote = await FetchRemoteSingleAsync(id, ct).ConfigureAwait(false); + if (remote == null) + { + // 后端已不存在:删除无需操作,也视为“成功清理 pending” + return new PendingReplayResult(true, false, id); + } + + if (op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime) + { + // 冲突:服务器版本获胜,恢复到服务器版本 + UpsertLocalCache(remote); + return new PendingReplayResult(true, true, id); + } + + var ok = await RemoteDeleteAsync(id, ct).ConfigureAwait(false); + return ok + ? new PendingReplayResult(true, false, id) + : new PendingReplayResult(false, false, null); + } + + private async Task ExecuteUpdateStatusWithConflictAsync(CustomerPendingOperation op, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(op.CustomerId) || string.IsNullOrWhiteSpace(op.Status)) + return new PendingReplayResult(false, false, null); + + var id = op.CustomerId!; + var remote = await FetchRemoteSingleAsync(id, ct).ConfigureAwait(false); + if (remote != null && op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime) + { + UpsertLocalCache(remote); + return new PendingReplayResult(true, true, id); + } + + var ok = await RemoteUpdateStatusAsync(id, op.Status!, ct).ConfigureAwait(false); + return ok + ? new PendingReplayResult(true, false, id) + : new PendingReplayResult(false, false, null); + } + + private void RemovePendingOpsByCustomerId(string customerId) + { + lock (_cacheLock) + { + _pendingOps.RemoveAll(x => + (x.CustomerId != null && string.Equals(x.CustomerId, customerId, StringComparison.OrdinalIgnoreCase)) || + (x.Customer?.Id != null && string.Equals(x.Customer.Id, customerId, StringComparison.OrdinalIgnoreCase))); + SavePendingOpsToDiskUnsafe(); + } + } + + private async Task FetchRemoteSingleAsync(string id, CancellationToken ct) + { + try + { + var url = $"{BaseUrl}/xslmes/mesXslCustomer/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return null; + + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("result", out var resultEl)) + return resultEl.Deserialize(_jsonOpts); + return null; + } + catch + { + return null; + } + } + + // ── 本地缓存操作 ────────────────────────────────────────────────────── + + private static List ApplyFilters(List source, + string? customerCode, string? customerName, string? status, string? customerRegion) + { + IEnumerable q = source; + if (!string.IsNullOrWhiteSpace(customerCode)) + q = q.Where(c => (c.CustomerCode ?? "").Contains(customerCode, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(customerName)) + q = q.Where(c => (c.CustomerName ?? "").Contains(customerName, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(status)) + q = q.Where(c => string.Equals(c.Status, status, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(customerRegion)) + q = q.Where(c => string.Equals(c.CustomerRegion, customerRegion, StringComparison.OrdinalIgnoreCase)); + return q.OrderByDescending(c => c.CreateTime ?? DateTime.MinValue).ToList(); + } + + private List ApplyPendingOpsSnapshotUnsafe(List source) + { + var map = source.Where(c => !string.IsNullOrWhiteSpace(c.Id)) + .ToDictionary(c => c.Id!, Clone, StringComparer.OrdinalIgnoreCase); + foreach (var op in _pendingOps.OrderBy(x => x.CreatedAt)) + { + switch (op.OpType) + { + case CustomerOperationType.Add: + case CustomerOperationType.Edit: + if (op.Customer?.Id != null) map[op.Customer.Id] = Clone(op.Customer); + break; + case CustomerOperationType.Delete: + if (op.CustomerId != null) map.Remove(op.CustomerId); + break; + case CustomerOperationType.UpdateStatus: + if (op.CustomerId != null && op.Status != null && map.TryGetValue(op.CustomerId, out var c)) + { + c.Status = op.Status; + SyncIzEnable(c); + } + break; + } + } + return map.Values.ToList(); + } + + private void EnqueuePendingOperation(CustomerPendingOperation op) + { + lock (_cacheLock) { _pendingOps.Add(op); SavePendingOpsToDiskUnsafe(); } + } + + private void UpsertLocalCache(MesXslCustomer customer) + { + lock (_cacheLock) + { + var idx = _localCache.FindIndex(c => string.Equals(c.Id, customer.Id, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) _localCache[idx] = Clone(customer); + else _localCache.Insert(0, Clone(customer)); + SaveCacheToDiskUnsafe(); + } + } + + private void RemoveFromLocalCache(string id) + { + lock (_cacheLock) + { + _localCache.RemoveAll(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase)); + SaveCacheToDiskUnsafe(); + } + } + + private void UpdateLocalStatus(string id, string status) + { + lock (_cacheLock) + { + var item = _localCache.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase)); + if (item != null) { item.Status = status; SyncIzEnable(item); SaveCacheToDiskUnsafe(); } + } + } + + private void LoadPendingOpsFromDisk() + { + try + { + if (!File.Exists(_pendingOpsFilePath)) return; + _pendingOps = JsonSerializer.Deserialize>( + File.ReadAllText(_pendingOpsFilePath), _jsonOpts) ?? new(); + } + catch { _pendingOps = new(); } + } + + private void LoadCacheFromDisk() + { + try + { + if (!File.Exists(_cacheFilePath)) return; + _localCache = JsonSerializer.Deserialize>( + File.ReadAllText(_cacheFilePath), _jsonOpts) ?? new(); + } + catch { _localCache = new(); } + } + + private void SavePendingOpsToDiskUnsafe() => + File.WriteAllText(_pendingOpsFilePath, JsonSerializer.Serialize(_pendingOps, _jsonOpts)); + + private void SaveCacheToDiskUnsafe() => + File.WriteAllText(_cacheFilePath, JsonSerializer.Serialize(_localCache, _jsonOpts)); + + // ── 辅助方法 ────────────────────────────────────────────────────────── + + // status "0"启用 → izEnable=1;status "1"停用 → izEnable=0 + private static void SyncIzEnable(MesXslCustomer c) => + c.IzEnable = c.Status == "1" ? 0 : 1; + + private static bool IsLocalTempId(string? id) => + !string.IsNullOrWhiteSpace(id) && id.StartsWith("local-", StringComparison.OrdinalIgnoreCase); + + private static MesXslCustomer Clone(MesXslCustomer c) => new() + { + Id = c.Id, CustomerCode = c.CustomerCode, CustomerName = c.CustomerName, + CustomerShortName = c.CustomerShortName, CustomerRegion = c.CustomerRegion, + ErpCode = c.ErpCode, Status = c.Status, IzEnable = c.IzEnable, + CustomerDesc = c.CustomerDesc, TenantId = c.TenantId, + CreateBy = c.CreateBy, CreateTime = c.CreateTime, + UpdateBy = c.UpdateBy, UpdateTime = c.UpdateTime, SysOrgCode = c.SysOrgCode + }; + + // ── 内部类型 ────────────────────────────────────────────────────────── + + private sealed class CustomerPendingOperation + { + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public CustomerOperationType OpType { get; set; } + public string? CustomerId { get; set; } + public string? Status { get; set; } + public MesXslCustomer? Customer { get; set; } + // 冲突检测用的版本锚点:当本地首次针对该记录产生修改时,记录当时的服务器 UpdateTime + public DateTime? AnchorUpdateTime { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public int RetryCount { get; set; } = 0; + } + + private enum CustomerOperationType { Add = 1, Edit = 2, Delete = 3, UpdateStatus = 4 } + + private sealed class NullableDateTimeJsonConverter : JsonConverter + { + private static readonly string[] Formats = + [ + "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ss", "yyyy-MM-ddTHH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ssZ", "yyyy-MM-ddTHH:mm:ss.fffZ" + ]; + + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return null; + if (reader.TokenType == JsonTokenType.String) + { + var raw = reader.GetString(); + if (string.IsNullOrWhiteSpace(raw)) return null; + if (DateTime.TryParseExact(raw, Formats, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeLocal, out var dt)) return dt; + if (DateTime.TryParse(raw, out var fb)) return fb; + } + throw new JsonException($"无法转换为 DateTime?,token={reader.TokenType}"); + } + + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + if (value.HasValue) writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss")); + else writer.WriteNullValue(); + } + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Customer/CustomerSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Customer/CustomerSyncCoordinator.cs new file mode 100644 index 0000000..a46a4e7 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Customer/CustomerSyncCoordinator.cs @@ -0,0 +1,60 @@ +using Prism.Events; +using System.Text.Json; +using YY.Admin.Core; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.Customer; + +public class CustomerSyncCoordinator : ISingletonDependency +{ + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + + public CustomerSyncCoordinator(IEventAggregator eventAggregator, ILoggerService logger) + { + _eventAggregator = eventAggregator; + _logger = logger; + _eventAggregator.GetEvent() + .Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread); + _eventAggregator.GetEvent() + .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + _logger.Information("[客户推送] CustomerSyncCoordinator 已启动"); + } + + private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload) + { + if (!payload.IsOnline) return; + _logger.Information("[客户推送] 网络恢复,触发补偿刷新"); + _eventAggregator.GetEvent() + .Publish(new CustomerChangedPayload { Action = "reconnect" }); + } + + private void OnRemoteCommand(RemoteCommandPayload payload) + { + try + { + var json = payload.CommandJson ?? string.Empty; + if (string.IsNullOrWhiteSpace(json)) return; + + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("cmd", out var cmdEl)) return; + if (!cmdEl.GetString().Equals("MES_CUSTOMER_CHANGED", StringComparison.OrdinalIgnoreCase)) return; + + doc.RootElement.TryGetProperty("action", out var actionEl); + doc.RootElement.TryGetProperty("customerId", out var idEl); + + var changed = new CustomerChangedPayload + { + Action = actionEl.GetString() ?? string.Empty, + CustomerId = idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : null + }; + _logger.Information($"[客户推送] action={changed.Action}, customerId={changed.CustomerId}"); + _eventAggregator.GetEvent().Publish(changed); + } + catch (Exception ex) + { + _logger.Warning($"[客户推送] 处理STOMP命令失败:{ex.Message}"); + } + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Dict/IJeecgDictSyncService.cs b/yy-admin-master/YY.Admin.Services/Service/Dict/IJeecgDictSyncService.cs index f871594..d1e16b5 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Dict/IJeecgDictSyncService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Dict/IJeecgDictSyncService.cs @@ -8,4 +8,9 @@ public interface IJeecgDictSyncService /// 从 Jeecg 后端同步数据字典到本地同构表 /// Task SyncFromJeecgAsync(); + + /// + /// 从桌面端本地字典镜像表读取指定字典编码的选项。 + /// + Task>> GetDictOptionsAsync(string dictCode, bool includeAll = false); } diff --git a/yy-admin-master/YY.Admin.Services/Service/Dict/JeecgDictSyncService.cs b/yy-admin-master/YY.Admin.Services/Service/Dict/JeecgDictSyncService.cs index 1788511..4c3319b 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Dict/JeecgDictSyncService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Dict/JeecgDictSyncService.cs @@ -164,6 +164,37 @@ public class JeecgDictSyncService : IJeecgDictSyncService, ISingletonDependency return synced; } + public async Task>> GetDictOptionsAsync(string dictCode, bool includeAll = false) + { + var result = new List>(); + if (includeAll) + { + result.Add(new KeyValuePair("全部", "")); + } + if (string.IsNullOrWhiteSpace(dictCode)) + { + return result; + } + + var rows = await _dbContext.Queryable() + .ClearFilter() + .Where(x => x.DictCode == dictCode) + .Where(x => x.Status == null || x.Status == 1) + .OrderBy(x => SqlFunc.IsNull(x.SortOrder, 0)) + .OrderBy(x => SqlFunc.Asc(x.ItemValue)) + .ToListAsync(); + + foreach (var row in rows) + { + if (string.IsNullOrWhiteSpace(row.ItemValue)) + { + continue; + } + result.Add(new KeyValuePair(row.ItemText ?? row.ItemValue, row.ItemValue)); + } + return result; + } + private static string? GetString(JsonElement row, string propertyName) { if (!row.TryGetProperty(propertyName, out var el)) diff --git a/yy-admin-master/YY.Admin.Services/Service/Jeecg/JeecgUserSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Jeecg/JeecgUserSyncCoordinator.cs index 9ba286e..9157438 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Jeecg/JeecgUserSyncCoordinator.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Jeecg/JeecgUserSyncCoordinator.cs @@ -20,6 +20,7 @@ public class JeecgUserSyncCoordinator : IJeecgUserSyncCoordinator, ISingletonDep private CancellationTokenSource? _cts; private readonly object _lifecycleLock = new(); private SubscriptionToken? _remoteCommandSubscription; + private SubscriptionToken? _networkStatusSubscription; public JeecgUserSyncCoordinator( IConfiguration configuration, @@ -52,7 +53,9 @@ public class JeecgUserSyncCoordinator : IJeecgUserSyncCoordinator, ISingletonDep { CancelAndDisposeCts(); UnsubscribeRemoteCommand(); + UnsubscribeNetworkStatus(); _remoteCommandSubscription = _eventAggregator.GetEvent().Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread); + _networkStatusSubscription = _eventAggregator.GetEvent().Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); _cts = new CancellationTokenSource(); token = _cts.Token; @@ -83,6 +86,7 @@ public class JeecgUserSyncCoordinator : IJeecgUserSyncCoordinator, ISingletonDep lock (_lifecycleLock) { UnsubscribeRemoteCommand(); + UnsubscribeNetworkStatus(); CancelAndDisposeCts(); } } @@ -115,6 +119,36 @@ public class JeecgUserSyncCoordinator : IJeecgUserSyncCoordinator, ISingletonDep } } + private void UnsubscribeNetworkStatus() + { + if (_networkStatusSubscription != null) + { + _eventAggregator.GetEvent().Unsubscribe(_networkStatusSubscription); + _networkStatusSubscription = null; + } + } + + private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload) + { + if (payload is null || !payload.IsOnline) + { + return; + } + + try + { + _logger.Information("检测到网络恢复,入队一次用户全量拉取(断线重连补偿)。"); + _ = _mirrorOutbox.EnqueuePullAsync( + JeecgUserMirrorOutbox.EventBoot, + "{\"reason\":\"network-reconnected\"}", + CancellationToken.None); + } + catch (Exception ex) + { + _logger.Warning($"网络恢复后入队用户同步失败: {ex.Message}"); + } + } + private void CancelAndDisposeCts() { try diff --git a/yy-admin-master/YY.Admin.Services/Service/Menu/ISysMenuService.cs b/yy-admin-master/YY.Admin.Services/Service/Menu/ISysMenuService.cs index adfac08..ec3bba5 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Menu/ISysMenuService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Menu/ISysMenuService.cs @@ -1,13 +1,29 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using YY.Admin.Core; +using YY.Admin.Services; -namespace YY.Admin.Services.Service.Menu +namespace YY.Admin.Services.Service.Menu; + +public interface ISysMenuService { - public interface ISysMenuService - { - Task> GetLoginMenuTree(); - } + Task> GetLoginMenuTree(); + + /// + /// 菜单管理:加载全部菜单(含按钮类型),按排序与 Id 排序 + /// + Task> GetAllMenusForManageAsync(); + + /// + /// 新增菜单并可选关联当前用户租户 + /// + Task<(bool ok, string message, long id)> CreateMenuAsync(SysMenu input); + + /// + /// 更新菜单 + /// + Task<(bool ok, string message)> UpdateMenuAsync(SysMenu input); + + /// + /// 删除菜单(无子节点时) + /// + Task<(bool ok, string message)> DeleteMenuAsync(long id); } diff --git a/yy-admin-master/YY.Admin.Services/Service/Menu/SysMenuService.cs b/yy-admin-master/YY.Admin.Services/Service/Menu/SysMenuService.cs index aa20849..eb840f9 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Menu/SysMenuService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Menu/SysMenuService.cs @@ -1,10 +1,6 @@ using Mapster; using SqlSugar; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using YY.Admin.Core; using YY.Admin.Core.Session; using YY.Admin.Services.Service.Role; using YY.Admin.Services.Service.User; @@ -132,5 +128,137 @@ namespace YY.Admin.Services.Service.Menu .ToTreeAsync(u => u.Children, u => u.Pid, 0); } + /// + public async Task> GetAllMenusForManageAsync() + { + return await _dbContext.Queryable() + .OrderBy(m => m.OrderNo) + .OrderBy(m => m.Id) + .ToListAsync(); + } + + /// + public async Task<(bool ok, string message, long id)> CreateMenuAsync(SysMenu input) + { + if (string.IsNullOrWhiteSpace(input.Title)) + return (false, "菜单名称不能为空", 0); + + var all = await GetAllMenusForManageAsync(); + if (input.Pid != 0 && all.All(m => m.Id != input.Pid)) + return (false, "父级菜单不存在", 0); + + var id = SnowflakeId.Next(); + input.Id = id; + input.CreateTime = DateTime.Now; + input.UpdateTime = null; + input.Children = new List(); + + var n = await _dbContext.Insertable(input).ExecuteCommandAsync(); + if (n <= 0) + return (false, "保存失败", 0); + + await TryLinkCurrentTenantMenuAsync(id); + return (true, "保存成功", id); + } + + /// + public async Task<(bool ok, string message)> UpdateMenuAsync(SysMenu input) + { + if (input.Id <= 0) + return (false, "无效的菜单 Id"); + if (string.IsNullOrWhiteSpace(input.Title)) + return (false, "菜单名称不能为空"); + + var all = await GetAllMenusForManageAsync(); + var existing = all.FirstOrDefault(m => m.Id == input.Id); + if (existing == null) + return (false, "菜单不存在"); + + if (input.Pid != 0 && all.All(m => m.Id != input.Pid)) + return (false, "父级菜单不存在"); + + if (NewParentIsInsideMenuSubtree(input.Id, input.Pid, all)) + return (false, "不能将父级设为当前菜单或其子菜单"); + + existing.Pid = input.Pid; + existing.Type = input.Type; + existing.Name = input.Name; + existing.Path = input.Path; + existing.Component = input.Component; + existing.Redirect = input.Redirect; + existing.Permission = input.Permission; + existing.Title = input.Title.Trim(); + existing.Icon = input.Icon; + existing.IsIframe = input.IsIframe; + existing.OutLink = input.OutLink; + existing.IsHide = input.IsHide; + existing.IsKeepAlive = input.IsKeepAlive; + existing.IsAffix = input.IsAffix; + existing.OrderNo = input.OrderNo; + existing.Status = input.Status; + existing.Remark = input.Remark; + existing.UpdateTime = DateTime.Now; + + var n = await _dbContext.Updateable(existing).ExecuteCommandAsync(); + return n > 0 ? (true, "保存成功") : (false, "更新失败"); + } + + /// + public async Task<(bool ok, string message)> DeleteMenuAsync(long id) + { + if (id <= 0) + return (false, "无效的菜单 Id"); + + var childCount = await _dbContext.Queryable().Where(m => m.Pid == id).CountAsync(); + if (childCount > 0) + return (false, "存在子菜单,请先删除子节点"); + + await _dbContext.Deleteable().Where(r => r.MenuId == id).ExecuteCommandAsync(); + await _dbContext.Deleteable().Where(t => t.MenuId == id).ExecuteCommandAsync(); + var n = await _dbContext.Deleteable().Where(m => m.Id == id).ExecuteCommandAsync(); + return n > 0 ? (true, "已删除") : (false, "删除失败"); + } + + private async Task TryLinkCurrentTenantMenuAsync(long menuId) + { + var tenantId = AppSession.CurrentUser?.TenantId; + if (tenantId == null || tenantId <= 0) + return; + + var exists = await _dbContext.Queryable() + .AnyAsync(t => t.TenantId == tenantId && t.MenuId == menuId); + if (exists) + return; + + await _dbContext.Insertable(new SysTenantMenu + { + Id = SnowflakeId.Next(), + TenantId = tenantId.Value, + MenuId = menuId + }).ExecuteCommandAsync(); + } + + /// + /// 判断 newPid 是否位于以 menuId 为根的子树内(含自身),用于防止环状父级 + /// + private static bool NewParentIsInsideMenuSubtree(long menuId, long newPid, List all) + { + if (newPid == 0) + return false; + if (newPid == menuId) + return true; + + var cur = all.FirstOrDefault(x => x.Id == newPid); + for (var i = 0; i < 5000 && cur != null; i++) + { + if (cur.Pid == menuId) + return true; + if (cur.Pid == 0) + return false; + cur = all.FirstOrDefault(x => x.Id == cur.Pid); + } + + return false; + } } } diff --git a/yy-admin-master/YY.Admin.Services/Service/Supplier/SupplierService.cs b/yy-admin-master/YY.Admin.Services/Service/Supplier/SupplierService.cs new file mode 100644 index 0000000..248b2e8 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Supplier/SupplierService.cs @@ -0,0 +1,685 @@ +using Microsoft.Extensions.Configuration; +using Prism.Events; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Web; +using YY.Admin.Core; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.Supplier; + +public class SupplierService : ISupplierService, ISingletonDependency +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; + private readonly INetworkMonitor _networkMonitor; + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + + private readonly object _cacheLock = new(); + private readonly string _cacheFilePath; + private readonly string _pendingFilePath; + private List _localCache = new(); + + private const int MaxPendingRetries = 5; + + // 断开期间被本地修改的条目(含版本锚点),重连后推送到后端 + private readonly HashSet _pendingLocalModifiedIds = new(StringComparer.OrdinalIgnoreCase); + // 断开期间被本地删除的条目 + private readonly HashSet _pendingLocalDeletedIds = new(StringComparer.OrdinalIgnoreCase); + // 版本锚点:最后一次从后端同步时该条目的 UpdateTime,用于冲突检测 + private readonly Dictionary _anchors = new(StringComparer.OrdinalIgnoreCase); + // 每条待推送记录的重试次数,超过上限后放弃 + private readonly Dictionary _pendingRetryCount = new(StringComparer.OrdinalIgnoreCase); + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new NullableDateTimeJsonConverter() } + }; + + public SupplierService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + INetworkMonitor networkMonitor, + IEventAggregator eventAggregator, + ILoggerService logger) + { + _httpClientFactory = httpClientFactory; + _configuration = configuration; + _networkMonitor = networkMonitor; + _eventAggregator = eventAggregator; + _logger = logger; + var appDataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "YY.Admin", "sync-cache"); + Directory.CreateDirectory(appDataDir); + _cacheFilePath = Path.Combine(appDataDir, "mes-xsl-supplier-cache.json"); + _pendingFilePath = Path.Combine(appDataDir, "mes-xsl-supplier-pending.json"); + LoadCacheFromDisk(); + LoadPendingFromDisk(); + } + + // ── 配置 ────────────────────────────────────────────────────────────────── + + private string BaseUrl => + (_configuration.GetValue("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/'); + private int DefaultTenantId => + (int?)_configuration.GetValue("JeecgIntegration:DefaultTenantId") ?? 1002; + private HttpClient CreateClient() => _httpClientFactory.CreateClient("JeecgApi"); + + // ── 公开接口 ────────────────────────────────────────────────────────────── + + public async Task PageAsync( + int pageNo, int pageSize, + string? supplierCode = null, string? supplierName = null, + string? supplierShortName = null, string? erpCode = null, + string? status = null, CancellationToken ct = default) + { + List source; + try + { + source = _networkMonitor.IsOnline + ? await FetchRemoteListAsync(ct).ConfigureAwait(false) + : GetCacheSnapshot(); + lock (_cacheLock) + { + MergeIntoCache(source); + SaveCacheToDiskUnsafe(); + } + source = GetCacheSnapshot(); + } + catch + { + source = GetCacheSnapshot(); + } + + IEnumerable q = source; + if (!string.IsNullOrWhiteSpace(supplierCode)) + q = q.Where(x => (x.SupplierCode ?? "").Contains(supplierCode, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(supplierName)) + q = q.Where(x => (x.SupplierName ?? "").Contains(supplierName, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(supplierShortName)) + q = q.Where(x => (x.SupplierShortName ?? "").Contains(supplierShortName, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(erpCode)) + q = q.Where(x => (x.ErpCode ?? "").Contains(erpCode, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(status)) + q = q.Where(x => string.Equals(x.Status, status, StringComparison.OrdinalIgnoreCase)); + + var ordered = q.OrderByDescending(x => x.CreateTime ?? DateTime.MinValue).ToList(); + var total = ordered.Count; + var records = ordered.Skip(Math.Max(0, (pageNo - 1) * pageSize)).Take(pageSize).ToList(); + return new SupplierPageResult(records, total, pageNo, pageSize); + } + + public async Task GetByIdAsync(string id, CancellationToken ct = default) + { + // 有本地待同步改动时优先返回本地版本,避免编辑弹窗被后端旧版本覆盖 + if (_networkMonitor.IsOnline && !_pendingLocalModifiedIds.Contains(id)) + { + var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + if (resp.IsSuccessStatusCode) + { + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("result", out var resultEl)) + return resultEl.Deserialize(_jsonOpts); + } + } + return GetCacheSnapshot().FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + public Task AddAsync(MesXslSupplier supplier, CancellationToken ct = default) => + PostAndRefreshAsync( + $"{BaseUrl}/xslmes/mesXslSupplier/anon/add?tenantId={DefaultTenantId}", + supplier, "add", supplier.Id, ct); + + public Task EditAsync(MesXslSupplier supplier, CancellationToken ct = default) => + PostAndRefreshAsync( + $"{BaseUrl}/xslmes/mesXslSupplier/anon/edit?tenantId={DefaultTenantId}", + supplier, "edit", supplier.Id, ct); + + public async Task DeleteAsync(string id, CancellationToken ct = default) + { + using var client = CreateClient(); + var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/delete?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false); + + if (IsDisconnectedResponse(resp)) + { + var anchor = GetCacheSnapshot() + .FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime; + RemoveFromLocalCache(id); + MarkLocalDeleted(id, anchor); + _eventAggregator.GetEvent() + .Publish(new SupplierChangedPayload { Action = "delete", SupplierId = id }); + return true; + } + + var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + if (ok) + { + RemoveFromLocalCache(id); + ClearPending(id); + _eventAggregator.GetEvent() + .Publish(new SupplierChangedPayload { Action = "delete", SupplierId = id }); + } + return ok; + } + + public async Task UpdateStatusAsync(string id, string status, CancellationToken ct = default) + { + using var client = CreateClient(); + var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/updateStatus?id={Uri.EscapeDataString(id)}&status={Uri.EscapeDataString(status)}&tenantId={DefaultTenantId}"; + var resp = await client.PostAsync(url, null, ct).ConfigureAwait(false); + + if (IsDisconnectedResponse(resp)) + { + var anchor = GetCacheSnapshot() + .FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime; + UpdateLocalStatus(id, status); + MarkLocalModified(id, anchor); + _eventAggregator.GetEvent() + .Publish(new SupplierChangedPayload { Action = "status", SupplierId = id }); + return true; + } + + var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + if (ok) + { + UpdateLocalStatus(id, status); + ClearPending(id); + _eventAggregator.GetEvent() + .Publish(new SupplierChangedPayload { Action = "status", SupplierId = id }); + } + return ok; + } + + /// + /// 重连后将离线期间的本地改动推送到后端,并检测冲突。 + /// 调用方应在此方法完成后再触发 UI 刷新,确保页面看到的是推送结果。 + /// + public async Task PushPendingOnReconnectAsync(CancellationToken ct = default) + { + int pushed = 0, conflicts = 0, newPushed = 0; + + // ── 1. 推送离线期间修改的现有记录 ───────────────────────────────────── + foreach (var id in new List(_pendingLocalModifiedIds)) + { + try + { + var local = GetCacheSnapshot() + .FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + if (local == null) { ClearPending(id); continue; } + + var remote = await FetchRemoteSingleAsync(id, ct); + + if (remote != null && HasConflict(id, remote.UpdateTime)) + { + // 后端也改了 → 后端版本获胜(MES 多用户安全策略) + UpsertLocalCache(remote); + ClearPending(id); + conflicts++; + _logger.Warning($"[供应商同步] 冲突:{local.SupplierName}({id}),后端版本获胜"); + } + else + { + // 仅本地改了 → 安全推送 + var (ok, isVersionConflict) = await PushEditAsync(local, ct); + if (isVersionConflict) + { + var fresh = await FetchRemoteSingleAsync(id, ct); + if (fresh != null) UpsertLocalCache(fresh); + ClearPending(id); + conflicts++; + _logger.Warning($"[供应商同步] 服务端版本冲突:{local.SupplierName}({id}),后端版本获胜"); + } + else if (ok) + { + ClearPending(id); + pushed++; + _logger.Information($"[供应商同步] 推送成功:{local.SupplierName}({id})"); + } + else + { + _pendingRetryCount.TryGetValue(id, out var retries); + retries++; + if (retries >= MaxPendingRetries) + { + _logger.Warning($"[供应商同步] 推送超过最大重试次数({MaxPendingRetries}),放弃:{id}"); + ClearPending(id); + } + else + { + _pendingRetryCount[id] = retries; + _logger.Warning($"[供应商同步] 推送失败,下次重连重试({retries}/{MaxPendingRetries}):{id}"); + } + } + } + } + catch (Exception ex) + { + _logger.Warning($"[供应商同步] 处理修改记录 {id} 时异常:{ex.Message}"); + } + } + + // ── 2. 推送离线期间删除的记录 ────────────────────────────────────────── + foreach (var id in new List(_pendingLocalDeletedIds)) + { + try + { + var remote = await FetchRemoteSingleAsync(id, ct); + if (remote == null) { ClearPending(id); continue; } // 后端已不存在,无需操作 + + if (HasConflict(id, remote.UpdateTime)) + { + // 后端改动了但本地要删 → 保守策略:后端版本获胜,恢复到本地 + UpsertLocalCache(remote); + ClearPending(id); + conflicts++; + _logger.Warning($"[供应商同步] 冲突:删除操作与后端改动冲突,已恢复记录 {id}"); + } + else + { + if (await PushDeleteAsync(id, ct)) + { + ClearPending(id); + pushed++; + } + else + { + _logger.Warning($"[供应商同步] 删除推送失败,下次重连重试:{id}"); + } + } + } + catch (Exception ex) + { + _logger.Warning($"[供应商同步] 处理删除记录 {id} 时异常:{ex.Message}"); + } + } + + // ── 3. 推送离线期间新增的 local- 记录 ───────────────────────────────── + var localOnlyRecords = GetCacheSnapshot() + .Where(x => (x.Id ?? "").StartsWith("local-", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var record in localOnlyRecords) + { + try + { + if (await PushAddAsync(record, ct)) + { + RemoveFromLocalCache(record.Id!); + newPushed++; + // 下次 FetchRemoteListAsync 时后端会返回真实 ID 版本 + } + else + { + _logger.Warning($"[供应商同步] 新增推送失败,下次重连重试:{record.SupplierName}"); + } + } + catch (Exception ex) + { + _logger.Warning($"[供应商同步] 推送新增 {record.SupplierName} 时异常:{ex.Message}"); + } + } + + return new PushPendingResult(pushed, conflicts, newPushed); + } + + // ── 私有:写操作核心 ────────────────────────────────────────────────────── + + private async Task PostAndRefreshAsync( + string url, MesXslSupplier supplier, string action, string? supplierId, CancellationToken ct) + { + if (!supplier.TenantId.HasValue || supplier.TenantId <= 0) supplier.TenantId = DefaultTenantId; + if (string.IsNullOrWhiteSpace(supplier.Status)) supplier.Status = "0"; + + var content = new StringContent(JsonSerializer.Serialize(supplier, _jsonOpts), Encoding.UTF8, "application/json"); + using var client = CreateClient(); + var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false); + + if (IsDisconnectedResponse(resp)) + { + if (string.IsNullOrWhiteSpace(supplier.Id)) + { + // 新增:分配 local- ID,不进 pendingModifiedIds(通过 local- 前缀识别) + supplier.Id = $"local-{Guid.NewGuid():N}"; + supplier.UpdateTime = DateTime.Now; + UpsertLocalCache(supplier); + } + else + { + // 编辑已有记录:捕获版本锚点(修改前的后端 UpdateTime) + var anchor = GetCacheSnapshot() + .FirstOrDefault(x => string.Equals(x.Id, supplier.Id, StringComparison.OrdinalIgnoreCase))?.UpdateTime; + supplier.UpdateTime = DateTime.Now; + UpsertLocalCache(supplier); + MarkLocalModified(supplier.Id, anchor); + } + _eventAggregator.GetEvent() + .Publish(new SupplierChangedPayload { Action = action, SupplierId = supplierId ?? supplier.Id }); + return true; + } + + var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + if (ok) + { + UpsertLocalCache(supplier); + ClearPending(supplierId ?? supplier.Id ?? ""); + _eventAggregator.GetEvent() + .Publish(new SupplierChangedPayload { Action = action, SupplierId = supplierId ?? supplier.Id }); + } + return ok; + } + + // ── 私有:重连推送方法 ──────────────────────────────────────────────────── + + private async Task<(bool Ok, bool IsVersionConflict)> PushEditAsync(MesXslSupplier supplier, CancellationToken ct) + { + var content = new StringContent( + JsonSerializer.Serialize(supplier, _jsonOpts), Encoding.UTF8, "application/json"); + using var client = CreateClient(); + var resp = await client.PostAsync( + $"{BaseUrl}/xslmes/mesXslSupplier/anon/edit?tenantId={DefaultTenantId}", content, ct) + .ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return (false, false); + try + { + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + int code = 200; + if (doc.RootElement.TryGetProperty("code", out var codeEl)) code = codeEl.GetInt32(); + if (code == 200) return (true, false); + if (doc.RootElement.TryGetProperty("message", out var msgEl)) + { + var msg = msgEl.GetString() ?? ""; + if (msg.Contains("已被他人修改")) return (false, true); + } + return (false, false); + } + catch { return (true, false); } + } + + private async Task PushDeleteAsync(string id, CancellationToken ct) + { + using var client = CreateClient(); + var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/delete?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + } + + private async Task PushAddAsync(MesXslSupplier supplier, CancellationToken ct) + { + var payload = Clone(supplier); + payload.Id = null; // 让后端生成真实 ID + var content = new StringContent( + JsonSerializer.Serialize(payload, _jsonOpts), Encoding.UTF8, "application/json"); + using var client = CreateClient(); + var resp = await client.PostAsync( + $"{BaseUrl}/xslmes/mesXslSupplier/anon/add?tenantId={DefaultTenantId}", content, ct) + .ConfigureAwait(false); + return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + } + + private async Task FetchRemoteSingleAsync(string id, CancellationToken ct) + { + try + { + var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return null; + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("result", out var resultEl)) + return resultEl.Deserialize(_jsonOpts); + return null; + } + catch { return null; } + } + + // ── 私有:冲突检测 ──────────────────────────────────────────────────────── + + /// + /// 判断后端是否在断开期间修改了该记录: + /// 后端当前 UpdateTime 与本地锚点不同 → 两端都改了 → 冲突。 + /// + private bool HasConflict(string id, DateTime? remoteUpdateTime) + { + if (!_anchors.TryGetValue(id, out var anchor)) return false; + return remoteUpdateTime != anchor; + } + + private static bool IsDisconnectedResponse(HttpResponseMessage resp) => + (int)resp.StatusCode == 499; + + // ── 私有:pending 状态管理 ──────────────────────────────────────────────── + + private void MarkLocalModified(string id, DateTime? anchor) + { + lock (_cacheLock) + { + _pendingLocalDeletedIds.Remove(id); + _pendingLocalModifiedIds.Add(id); + if (!_anchors.ContainsKey(id)) _anchors[id] = anchor; // 只记第一次(保留原始锚点) + SavePendingToDiskUnsafe(); + } + } + + private void MarkLocalDeleted(string id, DateTime? anchor) + { + lock (_cacheLock) + { + _pendingLocalModifiedIds.Remove(id); + _pendingLocalDeletedIds.Add(id); + if (!_anchors.ContainsKey(id)) _anchors[id] = anchor; + SavePendingToDiskUnsafe(); + } + } + + private void ClearPending(string id) + { + lock (_cacheLock) + { + _pendingLocalModifiedIds.Remove(id); + _pendingLocalDeletedIds.Remove(id); + _anchors.Remove(id); + _pendingRetryCount.Remove(id); + SavePendingToDiskUnsafe(); + } + } + + // ── 私有:缓存合并 ──────────────────────────────────────────────────────── + + /// + /// 后端全量数据与本地 pending 改动合并,保证本地未同步改动不被覆盖。 + /// + private void MergeIntoCache(List backendList) + { + bool hasPending = _pendingLocalModifiedIds.Count > 0 || _pendingLocalDeletedIds.Count > 0; + if (!hasPending) + { + _localCache = backendList.Select(Clone).ToList(); + return; + } + + var localById = _localCache.ToDictionary(x => x.Id ?? "", StringComparer.OrdinalIgnoreCase); + var merged = new List(backendList.Count + 8); + + foreach (var remote in backendList) + { + var id = remote.Id ?? ""; + if (_pendingLocalDeletedIds.Contains(id)) continue; // 保持本地删除状态 + if (_pendingLocalModifiedIds.Contains(id) && localById.TryGetValue(id, out var local)) + merged.Add(Clone(local)); // 本地版本优先(尚未推送成功) + else + merged.Add(Clone(remote)); + } + + // 保留本地新增(local- 前缀,尚未推送到后端) + foreach (var local in _localCache) + { + if ((local.Id ?? "").StartsWith("local-", StringComparison.OrdinalIgnoreCase)) + merged.Add(Clone(local)); + } + + // 安全保留:pending-modified 中后端未返回的条目(后端异常情况) + foreach (var modId in _pendingLocalModifiedIds) + { + if (!merged.Any(x => string.Equals(x.Id, modId, StringComparison.OrdinalIgnoreCase)) + && localById.TryGetValue(modId, out var orphan)) + merged.Add(Clone(orphan)); + } + + _localCache = merged; + } + + // ── 私有:磁盘持久化 ────────────────────────────────────────────────────── + + private async Task> FetchRemoteListAsync(CancellationToken ct) + { + var query = HttpUtility.ParseQueryString(string.Empty); + query["pageNo"] = "1"; + query["pageSize"] = "10000"; + query["tenantId"] = DefaultTenantId.ToString(); + var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/list?{query}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("result").GetProperty("records") + .Deserialize>(_jsonOpts) ?? new(); + } + + private List GetCacheSnapshot() + { + lock (_cacheLock) return _localCache.Select(Clone).ToList(); + } + + private void LoadCacheFromDisk() + { + try + { + if (!File.Exists(_cacheFilePath)) return; + _localCache = JsonSerializer.Deserialize>( + File.ReadAllText(_cacheFilePath), _jsonOpts) ?? new(); + } + catch { _localCache = new(); } + } + + private void SaveCacheToDiskUnsafe() => + File.WriteAllText(_cacheFilePath, JsonSerializer.Serialize(_localCache, _jsonOpts)); + + private void LoadPendingFromDisk() + { + try + { + if (!File.Exists(_pendingFilePath)) return; + var state = JsonSerializer.Deserialize(File.ReadAllText(_pendingFilePath)) ?? new(); + foreach (var id in state.Modified) _pendingLocalModifiedIds.Add(id); + foreach (var id in state.Deleted) _pendingLocalDeletedIds.Add(id); + foreach (var (id, timeStr) in state.Anchors) + _anchors[id] = timeStr != null && DateTime.TryParse(timeStr, out var dt) ? dt : null; + } + catch { } + } + + private void SavePendingToDiskUnsafe() + { + var state = new PendingState + { + Modified = _pendingLocalModifiedIds.ToList(), + Deleted = _pendingLocalDeletedIds.ToList(), + Anchors = _anchors.ToDictionary( + kv => kv.Key, + kv => kv.Value?.ToString("yyyy-MM-dd HH:mm:ss")) + }; + File.WriteAllText(_pendingFilePath, JsonSerializer.Serialize(state)); + } + + private void UpsertLocalCache(MesXslSupplier supplier) + { + lock (_cacheLock) + { + if (string.IsNullOrWhiteSpace(supplier.Id)) + { + _localCache.Insert(0, Clone(supplier)); + } + else + { + var idx = _localCache.FindIndex(x => + string.Equals(x.Id, supplier.Id, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) _localCache[idx] = Clone(supplier); + else _localCache.Insert(0, Clone(supplier)); + } + SaveCacheToDiskUnsafe(); + } + } + + private void RemoveFromLocalCache(string id) + { + lock (_cacheLock) + { + _localCache.RemoveAll(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + SaveCacheToDiskUnsafe(); + } + } + + private void UpdateLocalStatus(string id, string status) + { + lock (_cacheLock) + { + var item = _localCache.FirstOrDefault(x => + string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + if (item != null) { item.Status = status; SaveCacheToDiskUnsafe(); } + } + } + + private static async Task IsSuccessResultAsync(HttpResponseMessage resp, CancellationToken ct) + { + try + { + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("code", out var code)) return code.GetInt32() == 200; + if (doc.RootElement.TryGetProperty("success", out var success)) return success.GetBoolean(); + } + catch { } + return true; + } + + private static MesXslSupplier Clone(MesXslSupplier x) => new() + { + Id = x.Id, SupplierCode = x.SupplierCode, SupplierName = x.SupplierName, + SupplierShortName = x.SupplierShortName, ErpCode = x.ErpCode, Remark = x.Remark, + Status = x.Status, TenantId = x.TenantId, CreateBy = x.CreateBy, + CreateTime = x.CreateTime, UpdateBy = x.UpdateBy, UpdateTime = x.UpdateTime, + SysOrgCode = x.SysOrgCode + }; + + private sealed class PendingState + { + public List Modified { get; set; } = new(); + public List Deleted { get; set; } = new(); + public Dictionary Anchors { get; set; } = new(); + } + + private sealed class NullableDateTimeJsonConverter : JsonConverter + { + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType == JsonTokenType.String + && DateTime.TryParse(reader.GetString(), out var dt) ? dt : null; + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + => writer.WriteStringValue(value?.ToString("yyyy-MM-dd HH:mm:ss")); + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Supplier/SupplierSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Supplier/SupplierSyncCoordinator.cs new file mode 100644 index 0000000..2baf190 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Supplier/SupplierSyncCoordinator.cs @@ -0,0 +1,87 @@ +using Prism.Events; +using System.Text.Json; +using YY.Admin.Core; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.Supplier; + +public class SupplierSyncCoordinator : ISingletonDependency +{ + private readonly IEventAggregator _eventAggregator; + private readonly ISupplierService _supplierService; + private readonly ILoggerService _logger; + + public SupplierSyncCoordinator( + IEventAggregator eventAggregator, + ISupplierService supplierService, + ILoggerService logger) + { + _eventAggregator = eventAggregator; + _supplierService = supplierService; + _logger = logger; + _eventAggregator.GetEvent() + .Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread); + _eventAggregator.GetEvent() + .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + } + + private async void OnNetworkStatusChanged(NetworkStatusChangedPayload payload) + { + if (!payload.IsOnline) return; + + // 先推送本地 pending 改动到后端,再通知 UI 刷新列表 + PushPendingResult result; + try + { + result = await _supplierService.PushPendingOnReconnectAsync(); + } + catch (Exception ex) + { + _logger.Warning($"[供应商同步] 重连推送异常:{ex.Message}"); + result = new PushPendingResult(0, 0, 0); + } + + // 通知列表刷新 + _eventAggregator.GetEvent() + .Publish(new SupplierChangedPayload { Action = "reconnect" }); + + // 若有推送结果,通知 UI 显示摘要 + bool hasActivity = result.PushedCount > 0 + || result.ConflictCount > 0 + || result.NewRecordsPushed > 0; + if (hasActivity) + { + _eventAggregator.GetEvent().Publish(new SyncConflictPayload + { + EntityName = "供应商", + PushedCount = result.PushedCount, + ConflictCount = result.ConflictCount, + NewRecordsPushed = result.NewRecordsPushed + }); + } + } + + private void OnRemoteCommand(RemoteCommandPayload payload) + { + try + { + var json = payload.CommandJson ?? string.Empty; + if (string.IsNullOrWhiteSpace(json)) return; + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("cmd", out var cmdEl)) return; + if (!cmdEl.GetString().Equals("MES_SUPPLIER_CHANGED", StringComparison.OrdinalIgnoreCase)) return; + doc.RootElement.TryGetProperty("action", out var actionEl); + doc.RootElement.TryGetProperty("supplierId", out var idEl); + _eventAggregator.GetEvent().Publish(new SupplierChangedPayload + { + Action = actionEl.GetString() ?? string.Empty, + SupplierId = idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : null + }); + } + catch (Exception ex) + { + _logger.Warning($"[供应商推送] 处理失败:{ex.Message}"); + } + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/User/SysUserService.cs b/yy-admin-master/YY.Admin.Services/Service/User/SysUserService.cs index 1fd7111..cc1d926 100644 --- a/yy-admin-master/YY.Admin.Services/Service/User/SysUserService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/User/SysUserService.cs @@ -1,9 +1,8 @@ - -using Dm.util; +using Microsoft.Extensions.Configuration; using SqlSugar; using System.Globalization; using YY.Admin.Core; -using YY.Admin.Core.SeedData; +using YY.Admin.Core.Services; using YY.Admin.Core.Session; using YY.Admin.Core.Util; @@ -11,27 +10,33 @@ namespace YY.Admin.Services.Service.User { public class SysUserService : ISysUserService, ISingletonDependency { - private readonly ISysOrgService _sysOrgService; private readonly ISqlSugarClient _dbContext; - public SysUserService(ISysOrgService orgService, ISqlSugarClient dbContext) + private readonly IConfiguration _configuration; + private readonly IUserSyncOutbox _userSyncOutbox; + + public SysUserService(ISqlSugarClient dbContext, IConfiguration configuration, IUserSyncOutbox userSyncOutbox) { - _sysOrgService = orgService; _dbContext = dbContext; + _configuration = configuration; + _userSyncOutbox = userSyncOutbox; } public async Task> GetUsersAsync() { - await Task.Delay(200); + await Task.CompletedTask; return new List(); } + // ── 查询 ────────────────────────────────────────────────────────────── + public async Task> PageAsync(PageUserInput input) { var sexFilter = input.Sex.HasValue ? (int?)input.Sex.Value : null; var statusFilter = input.Status.HasValue ? (int?)input.Status.Value : null; - // 账号管理查询改为从 Jeecg 同构账号表读取 var query = _dbContext.Queryable().ClearFilter() + // 只显示未软删除的记录 + .Where(u => u.DelFlag == null || u.DelFlag == 0) .WhereIF(input.TenantId > 0, u => u.LoginTenantId == input.TenantId) .WhereIF(!string.IsNullOrWhiteSpace(input.Account), u => u.Username != null && u.Username.Contains(input.Account)) .WhereIF(!string.IsNullOrWhiteSpace(input.RealName), u => u.Realname != null && u.Realname.Contains(input.RealName)) @@ -39,6 +44,7 @@ namespace YY.Admin.Services.Service.User .WhereIF(input.BeginTime.HasValue, u => u.CreateTime >= input.BeginTime) .WhereIF(input.EndTime.HasValue, u => u.CreateTime <= input.EndTime) .OrderBy(u => SqlFunc.Desc(u.CreateTime)); + if (sexFilter.HasValue) { var sexValue = sexFilter.Value; @@ -51,35 +57,7 @@ namespace YY.Admin.Services.Service.User } var pageData = await query.ToPagedListAsync(input.Page, input.PageSize); - var mapped = pageData.Items.Select(u => - { - long id = 0; - long.TryParse(u.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out id); - var sex = GenderEnum.Unknown; - if (u.Sex == 1) sex = GenderEnum.Male; - if (u.Sex == 2) sex = GenderEnum.Female; - var status = u.Status == 1 ? StatusEnum.Enable : StatusEnum.Disable; - return new UserOutput - { - Id = id, - Account = u.Username ?? string.Empty, - RealName = u.Realname ?? string.Empty, - // Jeecg 同构表无 nickname 字段,昵称回退为真实姓名,避免页面显示被“清空” - NickName = string.IsNullOrWhiteSpace(u.Realname) ? (u.Username ?? string.Empty) : u.Realname, - Avatar = u.Avatar, - Sex = sex, - Birthday = u.Birthday, - Phone = u.Phone, - Email = u.Email, - OfficePhone = u.Telephone, - Status = status, - CreateTime = u.CreateTime, - OrgName = u.OrgCode ?? string.Empty, - PosName = u.PositionType ?? string.Empty, - RoleName = string.Empty, - AccountType = AccountTypeEnum.NormalUser - }; - }).ToList(); + var mapped = pageData.Items.Select(MapToOutput).ToList(); return new SqlSugarPagedList { @@ -93,147 +71,205 @@ namespace YY.Admin.Services.Service.User }; } - public async Task BatchDeleteAsync(List ids) + public async Task AccountExistsAsync(string account, long? excludeUserId = null) { - int count = 0; - if (ids == null || ids.isEmpty()) + var query = _dbContext.Queryable().ClearFilter() + .Where(u => (u.DelFlag == null || u.DelFlag == 0) && u.Username == account); + + if (excludeUserId.HasValue && excludeUserId.Value != 0) { - return count; - } - - try - { - await _dbContext.AsTenant().BeginTranAsync(); - - count = await _dbContext.Deleteable().In(ids).ExecuteCommandAsync(); - - await _dbContext.AsTenant().CommitTranAsync(); - } - catch (Exception) - { - await _dbContext.AsTenant().RollbackTranAsync(); - } - - return count; - } - - public async Task DeleteAsync(long id) - { - int count = 0; - try - { - await _dbContext.AsTenant().BeginTranAsync(); - - count = await _dbContext.Deleteable().In(id).ExecuteCommandAsync(); - - await _dbContext.AsTenant().CommitTranAsync(); - } - catch (Exception) - { - await _dbContext.AsTenant().RollbackTranAsync(); - } - - return count; - } - - public async Task CreateAsync(SysUser sysUser) - { - long maxId = await ReadMaxIdAsync(); - - sysUser.Id = ++maxId; - sysUser.Password = CryptogramUtil.Encrypt(sysUser.Password); - sysUser.CardType = CardTypeEnum.IdCard; - sysUser.CultureLevel = CultureLevelEnum.Level0; - sysUser.PosId = new SysPosSeedData().HasData().ToList()[0].Id; - sysUser.TenantId = SqlSugarConst.DefaultTenantId; - sysUser.CreateTime = DateTime.Now; - sysUser.CreateUserId = AppSession.UserId; - sysUser.CreateUserName = AppSession.CurrentUser!.Account; - - int count = 0; - try - { - await _dbContext.AsTenant().BeginTranAsync(); - - count = await _dbContext.Insertable(sysUser).ExecuteCommandAsync(); - - await _dbContext.AsTenant().CommitTranAsync(); - } - catch (Exception) - { - await _dbContext.AsTenant().RollbackTranAsync(); - } - - return count; - } - - public async Task UpdateAsync(SysUser sysUser) - { - sysUser.UpdateUserId = AppSession.UserId; ; - sysUser.UpdateUserName = AppSession.CurrentUser!.Account; - sysUser.UpdateTime = DateTime.Now; - - int count = 0; - try - { - await _dbContext.AsTenant().BeginTranAsync(); - - count = await _dbContext.Updateable(sysUser) - .UpdateColumns(it => new { it.RealName, it.NickName, it.Sex, it.Birthday, it.Age, it.Status, it.UpdateUserId, it.UpdateUserName, it.UpdateTime }) - .ExecuteCommandAsync(); - - await _dbContext.AsTenant().CommitTranAsync(); - } - catch (Exception) - { - await _dbContext.AsTenant().RollbackTranAsync(); - } - - return count; - } - - public async Task ReadMaxIdAsync() - { - return await _dbContext.Queryable().MaxAsync("Id"); - } - - public async Task AccountExistsAsync(string account, long? excludeUserId) - { - var query = _dbContext.Queryable() - . Where(u => u.Account == account); - - // excludeUserId不等于null && 不等于 0 - if (excludeUserId.HasValue && excludeUserId != 0) - { - query = query.Where(u => u.Id != excludeUserId.Value); + var excludeIdStr = excludeUserId.Value.ToString(CultureInfo.InvariantCulture); + query = query.Where(u => u.Id != excludeIdStr); } return await query.AnyAsync(); } + // ── 新增 ────────────────────────────────────────────────────────────── + + public async Task CreateAsync(SysUser sysUser) + { + var defaultTenantId = (int?)_configuration.GetValue("JeecgIntegration:DefaultTenantId") ?? 1002; + var now = DateTime.Now; + + var jeecgUser = new JeecgSysUser + { + // 用毫秒时间戳生成本地唯一 ID(数值型字符串,与 PageAsync 的 long.TryParse 兼容) + Id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + Username = sysUser.Account, + Realname = sysUser.RealName, + // 本地创建账号使用 CryptogramUtil 加密,无 salt;登录时走 CryptogramUtil 路径 + Password = string.IsNullOrWhiteSpace(sysUser.Password) + ? string.Empty + : CryptogramUtil.Encrypt(sysUser.Password), + Salt = null, + Sex = ToJeecgSex(sysUser.Sex), + Birthday = sysUser.Birthday, + Phone = sysUser.Phone, + Email = sysUser.Email, + Status = 1, // 默认启用 + DelFlag = 0, + LoginTenantId = defaultTenantId, + CreateBy = AppSession.CurrentUser?.Account, + CreateTime = now, + UpdateTime = now, + }; + + var affected = await _dbContext.Insertable(jeecgUser).ExecuteCommandAsync(); + if (affected > 0) + { + _ = _userSyncOutbox.EnqueueCreateAsync( + jeecgUser.Id, + jeecgUser.Username ?? string.Empty, + jeecgUser.Realname, + jeecgUser.Sex, + jeecgUser.Birthday, + jeecgUser.Phone, + jeecgUser.Email, + jeecgUser.Status ?? 1, + jeecgUser.CreateBy); + } + return affected; + } + + // ── 修改 ────────────────────────────────────────────────────────────── + + public async Task UpdateAsync(SysUser sysUser) + { + var idStr = sysUser.Id.ToString(CultureInfo.InvariantCulture); + var now = DateTime.Now; + var updater = AppSession.CurrentUser?.Account; + var jeecgStatus = sysUser.Status == StatusEnum.Enable ? 1 : 2; + var jeecgSex = ToJeecgSex(sysUser.Sex); + var account = (sysUser.Account ?? string.Empty).Trim(); + + var affected = await _dbContext.Updateable() + .SetColumns(u => new JeecgSysUser + { + Username = account, + Realname = sysUser.RealName, + Sex = jeecgSex, + Birthday = sysUser.Birthday, + Phone = sysUser.Phone, + Email = sysUser.Email, + Status = jeecgStatus, + UpdateBy = updater, + UpdateTime = now, + }) + .Where(u => u.Id == idStr) + .ExecuteCommandAsync(); + + if (affected > 0) + { + _ = _userSyncOutbox.EnqueueUpdateAsync(idStr, account, sysUser.RealName, jeecgSex, sysUser.Birthday, sysUser.Phone, sysUser.Email, jeecgStatus, updater); + } + return affected; + } + + // ── 状态切换 ────────────────────────────────────────────────────────── + public async Task ToggleStatus(SysUser sysUser) { - sysUser.UpdateUserId = AppSession.UserId; ; - sysUser.UpdateUserName = AppSession.CurrentUser!.Account; - sysUser.UpdateTime = DateTime.Now; + var idStr = sysUser.Id.ToString(CultureInfo.InvariantCulture); + // Jeecg 约定:1=正常,2=冻结 + var jeecgStatus = sysUser.Status == StatusEnum.Enable ? 1 : 2; + var now = DateTime.Now; + var updater = AppSession.CurrentUser?.Account; - int count = 0; - try + var affected = await _dbContext.Updateable() + .SetColumns(u => new JeecgSysUser + { + Status = jeecgStatus, + UpdateBy = updater, + UpdateTime = now, + }) + .Where(u => u.Id == idStr) + .ExecuteCommandAsync(); + + if (affected > 0) { - await _dbContext.AsTenant().BeginTranAsync(); - - count = await _dbContext.Updateable(sysUser) - .UpdateColumns(it => new { it.Status, it.UpdateUserId, it.UpdateUserName, it.UpdateTime }) - .ExecuteCommandAsync(); - - await _dbContext.AsTenant().CommitTranAsync(); + _ = _userSyncOutbox.EnqueueToggleStatusAsync(idStr, jeecgStatus, updater); } - catch (Exception) - { - await _dbContext.AsTenant().RollbackTranAsync(); - } - - return count; + return affected; } + + // ── 删除 ────────────────────────────────────────────────────────────── + + public async Task DeleteAsync(long id) + { + var idStr = id.ToString(CultureInfo.InvariantCulture); + // 软删除:保留记录供审计,PageAsync 已过滤 del_flag=1 + var affected = await _dbContext.Updateable() + .SetColumns(u => new JeecgSysUser { DelFlag = 1 }) + .Where(u => u.Id == idStr) + .ExecuteCommandAsync(); + + if (affected > 0) + { + _ = _userSyncOutbox.EnqueueDeleteAsync(idStr); + } + return affected; + } + + public async Task BatchDeleteAsync(List ids) + { + if (ids == null || ids.Count == 0) + { + return 0; + } + var idStrings = ids.Select(i => i.ToString(CultureInfo.InvariantCulture)).ToList(); + var affected = await _dbContext.Updateable() + .SetColumns(u => new JeecgSysUser { DelFlag = 1 }) + .Where(u => idStrings.Contains(u.Id)) + .ExecuteCommandAsync(); + + if (affected > 0) + { + _ = _userSyncOutbox.EnqueueBatchDeleteAsync(idStrings); + } + return affected; + } + + // ── 辅助 ────────────────────────────────────────────────────────────── + + public async Task ReadMaxIdAsync() + { + // jeecg_sys_user 使用字符串ID,此方法不再适用;保留签名兼容旧调用 + await Task.CompletedTask; + return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + private static UserOutput MapToOutput(JeecgSysUser u) + { + long.TryParse(u.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id); + var sex = u.Sex == 1 ? GenderEnum.Male : u.Sex == 2 ? GenderEnum.Female : GenderEnum.Unknown; + var status = u.Status == 1 ? StatusEnum.Enable : StatusEnum.Disable; + return new UserOutput + { + Id = id, + Account = u.Username ?? string.Empty, + RealName = u.Realname ?? string.Empty, + NickName = string.IsNullOrWhiteSpace(u.Realname) ? (u.Username ?? string.Empty) : u.Realname, + Avatar = u.Avatar, + Sex = sex, + Birthday = u.Birthday, + Phone = u.Phone, + Email = u.Email, + OfficePhone = u.Telephone, + Status = status, + CreateTime = u.CreateTime, + OrgName = u.OrgCode ?? string.Empty, + PosName = u.PositionType ?? string.Empty, + RoleName = string.Empty, + AccountType = AccountTypeEnum.NormalUser + }; + } + + private static int? ToJeecgSex(GenderEnum? sex) => sex switch + { + GenderEnum.Male => 1, + GenderEnum.Female => 2, + _ => null + }; } } diff --git a/yy-admin-master/YY.Admin.Services/Service/Vehicle/VehicleService.cs b/yy-admin-master/YY.Admin.Services/Service/Vehicle/VehicleService.cs new file mode 100644 index 0000000..0b1cb33 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Vehicle/VehicleService.cs @@ -0,0 +1,984 @@ +using Microsoft.Extensions.Configuration; +using System.Net.Http; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Web; +using Prism.Events; +using YY.Admin.Core; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.Vehicle; + +public class VehicleService : IVehicleService, ISingletonDependency +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; + private readonly INetworkMonitor _networkMonitor; + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + private readonly SemaphoreSlim _syncLock = new(1, 1); + private readonly object _cacheLock = new(); + private readonly string _pendingOpsFilePath; + private readonly string _cacheFilePath; + private List _pendingOps = new(); + private List _localCache = new(); + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = + { + new NullableDateTimeJsonConverter() + } + }; + + public VehicleService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + INetworkMonitor networkMonitor, + IEventAggregator eventAggregator, + ILoggerService logger) + { + _httpClientFactory = httpClientFactory; + _configuration = configuration; + _networkMonitor = networkMonitor; + _eventAggregator = eventAggregator; + _logger = logger; + + var appDataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "YY.Admin", + "sync-cache"); + Directory.CreateDirectory(appDataDir); + _pendingOpsFilePath = Path.Combine(appDataDir, "vehicle-pending-ops.json"); + _cacheFilePath = Path.Combine(appDataDir, "vehicle-cache.json"); + + LoadPendingOpsFromDisk(); + LoadCacheFromDisk(); + _logger.Information($"[车辆同步] 服务初始化完成,缓存目录={appDataDir}, 本地缓存={_localCache.Count}, 待上传={_pendingOps.Count}, 在线={_networkMonitor.IsOnline}"); + + _networkMonitor.StatusChanged += OnNetworkStatusChanged; + if (_networkMonitor.IsOnline) + { + _ = Task.Run(() => SyncAfterReconnectAsync(CancellationToken.None)); + } + } + + private const int MaxPendingRetries = 5; + + private string BaseUrl => (_configuration.GetValue("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/'); + private int DefaultTenantId => (int?)_configuration.GetValue("JeecgIntegration:DefaultTenantId") ?? 1002; + + private HttpClient CreateClient() + { + var client = _httpClientFactory.CreateClient("JeecgApi"); + return client; + } + + public async Task PageAsync(int pageNo, int pageSize, string? plateNumber = null, string? vehicleBelong = null, string? status = null, CancellationToken ct = default) + { + List? source = null; + _logger.Information($"[车辆列表] 请求分页 pageNo={pageNo}, pageSize={pageSize}, plate={plateNumber}, belong={vehicleBelong}, status={status}, online={_networkMonitor.IsOnline}"); + if (_networkMonitor.IsOnline) + { + try + { + source = await FetchRemoteListAsync(ct).ConfigureAwait(false); + lock (_cacheLock) + { + _localCache = source.Select(CloneVehicle).ToList(); + SaveCacheToDiskUnsafe(); + } + _logger.Information($"[车辆列表] 远端拉取成功,记录数={source.Count},已刷新本地缓存"); + } + catch (Exception ex) + { + source = null; + _logger.Warning($"[车辆列表] 远端拉取失败,回退本地缓存:{ex.Message}"); + } + } + + lock (_cacheLock) + { + source ??= _localCache.Select(CloneVehicle).ToList(); + source = ApplyPendingOpsSnapshotUnsafe(source); + } + + var filtered = ApplyFilters(source, plateNumber, vehicleBelong, status); + var total = filtered.Count; + var pageRecords = filtered + .Skip(Math.Max(0, (pageNo - 1) * pageSize)) + .Take(pageSize) + .ToList(); + _logger.Information($"[车辆列表] 返回记录 total={total}, pageRecords={pageRecords.Count}, pending={_pendingOps.Count}"); + return new VehiclePageResult(pageRecords, total, pageNo, pageSize); + } + + public async Task GetByIdAsync(string id, CancellationToken ct = default) + { + _logger.Information($"[车辆详情] 查询 id={id}, online={_networkMonitor.IsOnline}"); + if (_networkMonitor.IsOnline) + { + try + { + var url = $"{BaseUrl}/xslmes/mesXslVehicle/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return null; + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("result", out var resultEl)) return null; + var result = resultEl.Deserialize(_jsonOpts); + _logger.Information($"[车辆详情] 远端查询成功 id={id}, found={result != null}"); + return result; + } + catch (Exception ex) + { + _logger.Warning($"[车辆详情] 远端查询异常,回退本地缓存 id={id}, err={ex.Message}"); + } + } + + lock (_cacheLock) + { + return _localCache.FirstOrDefault(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase)) is { } found + ? CloneVehicle(found) + : null; + } + } + + public async Task AddAsync(MesXslVehicle vehicle, CancellationToken ct = default) + { + if (!vehicle.TenantId.HasValue || vehicle.TenantId.Value <= 0) + { + vehicle.TenantId = DefaultTenantId; + } + + var local = CloneVehicle(vehicle); + if (string.IsNullOrWhiteSpace(local.Id)) + { + local.Id = $"local-{Guid.NewGuid():N}"; + } + + if (_networkMonitor.IsOnline) + { + try + { + _logger.Information($"[车辆新增] 尝试远端新增 id={local.Id}, plate={local.PlateNumber}"); + var ok = await RemoteAddAsync(local, ct).ConfigureAwait(false); + if (ok) + { + UpsertLocalCache(local); + _logger.Information($"[车辆新增] 远端新增成功 id={local.Id}"); + return true; + } + _logger.Warning($"[车辆新增] 远端新增返回失败 id={local.Id}"); + return false; + } + catch (Exception ex) + { + _logger.Warning($"[车辆新增] 远端新增异常,转离线入队 id={local.Id}, err={ex.Message}"); + } + } + + EnqueuePendingOperation(new VehiclePendingOperation + { + OpType = VehicleOperationType.Add, + VehicleId = local.Id, + Vehicle = local, + CreatedAt = DateTime.UtcNow + }); + UpsertLocalCache(local); + _logger.Information($"[车辆新增] 已离线入队 id={local.Id}, pending={_pendingOps.Count}"); + return true; + } + + public async Task EditAsync(MesXslVehicle vehicle, CancellationToken ct = default) + { + if (!vehicle.TenantId.HasValue || vehicle.TenantId.Value <= 0) + { + vehicle.TenantId = DefaultTenantId; + } + var local = CloneVehicle(vehicle); + if (_networkMonitor.IsOnline) + { + try + { + _logger.Information($"[车辆修改] 尝试远端修改 id={local.Id}, plate={local.PlateNumber}"); + var (ok, _) = await RemoteEditAsync(local, ct).ConfigureAwait(false); + if (ok) + { + UpsertLocalCache(local); + _logger.Information($"[车辆修改] 远端修改成功 id={local.Id}"); + return true; + } + _logger.Warning($"[车辆修改] 远端修改返回失败 id={local.Id}"); + return false; + } + catch (Exception ex) + { + _logger.Warning($"[车辆修改] 远端修改异常,转离线入队 id={local.Id}, err={ex.Message}"); + } + } + + EnqueuePendingOperation(new VehiclePendingOperation + { + OpType = VehicleOperationType.Edit, + VehicleId = local.Id, + Vehicle = local, + AnchorUpdateTime = local.UpdateTime, + CreatedAt = DateTime.UtcNow + }); + UpsertLocalCache(local); + _logger.Information($"[车辆修改] 已离线入队 id={local.Id}, pending={_pendingOps.Count}"); + return true; + } + + public async Task DeleteAsync(string id, CancellationToken ct = default) + { + if (_networkMonitor.IsOnline) + { + try + { + _logger.Information($"[车辆删除] 尝试远端删除 id={id}"); + var ok = await RemoteDeleteAsync(id, ct).ConfigureAwait(false); + if (ok) + { + RemoveFromLocalCache(id); + _logger.Information($"[车辆删除] 远端删除成功 id={id}"); + return true; + } + _logger.Warning($"[车辆删除] 远端删除返回失败 id={id}"); + return false; + } + catch (Exception ex) + { + _logger.Warning($"[车辆删除] 远端删除异常,转离线入队 id={id}, err={ex.Message}"); + } + } + + DateTime? anchor; + lock (_cacheLock) + { + anchor = _localCache.FirstOrDefault(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime; + } + EnqueuePendingOperation(new VehiclePendingOperation + { + OpType = VehicleOperationType.Delete, + VehicleId = id, + AnchorUpdateTime = anchor, + CreatedAt = DateTime.UtcNow + }); + RemoveFromLocalCache(id); + _logger.Information($"[车辆删除] 已离线入队 id={id}, pending={_pendingOps.Count}"); + return true; + } + + public async Task DeleteBatchAsync(string ids, CancellationToken ct = default) + { + var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var allSuccess = true; + foreach (var id in idList) + { + var ok = await DeleteAsync(id, ct).ConfigureAwait(false); + allSuccess &= ok; + } + return allSuccess; + } + + public async Task UpdateStatusAsync(string id, string status, CancellationToken ct = default) + { + if (_networkMonitor.IsOnline) + { + try + { + _logger.Information($"[车辆状态] 尝试远端更新 id={id}, status={status}"); + var ok = await RemoteUpdateStatusAsync(id, status, ct).ConfigureAwait(false); + if (ok) + { + UpdateLocalStatus(id, status); + _logger.Information($"[车辆状态] 远端更新成功 id={id}, status={status}"); + return true; + } + _logger.Warning($"[车辆状态] 远端更新返回失败 id={id}, status={status}"); + return false; + } + catch (Exception ex) + { + _logger.Warning($"[车辆状态] 远端更新异常,转离线入队 id={id}, status={status}, err={ex.Message}"); + } + } + + DateTime? anchor; + lock (_cacheLock) + { + anchor = _localCache.FirstOrDefault(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime; + } + EnqueuePendingOperation(new VehiclePendingOperation + { + OpType = VehicleOperationType.UpdateStatus, + VehicleId = id, + Status = status, + AnchorUpdateTime = anchor, + CreatedAt = DateTime.UtcNow + }); + UpdateLocalStatus(id, status); + _logger.Information($"[车辆状态] 已离线入队 id={id}, status={status}, pending={_pendingOps.Count}"); + return true; + } + + private async Task> FetchRemoteListAsync(CancellationToken ct) + { + var query = HttpUtility.ParseQueryString(string.Empty); + query["pageNo"] = "1"; + query["pageSize"] = "10000"; + query["tenantId"] = DefaultTenantId.ToString(); + var url = $"{BaseUrl}/xslmes/mesXslVehicle/anon/list?{query}"; + using var client = CreateClient(); + _logger.Information($"[车辆远端] GET {url}"); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + var result = doc.RootElement.GetProperty("result"); + var records = result.GetProperty("records").Deserialize>(_jsonOpts) ?? new(); + _logger.Information($"[车辆远端] 列表拉取成功 count={records.Count}"); + return records; + } + + private async Task RemoteAddAsync(MesXslVehicle vehicle, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslVehicle/anon/add?tenantId={DefaultTenantId}"; + var payload = CloneVehicle(vehicle); + // 离线本地临时ID不能直接入后端主键,需置空让Jeecg自动生成雪花ID + if (IsLocalTempId(payload.Id)) + { + _logger.Information($"[车辆远端] 新增检测到本地临时ID,自动清空 id={payload.Id}"); + payload.Id = null; + } + return await PostJsonAsync(url, payload, ct).ConfigureAwait(false); + } + + private async Task<(bool Ok, bool IsVersionConflict)> RemoteEditAsync(MesXslVehicle vehicle, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslVehicle/anon/edit?tenantId={DefaultTenantId}"; + return await PostJsonCheckVersionAsync(url, vehicle, ct).ConfigureAwait(false); + } + + private async Task RemoteDeleteAsync(string id, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslVehicle/anon/delete?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + _logger.Information($"[车辆远端] DELETE {url}"); + var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + } + + private async Task RemoteUpdateStatusAsync(string id, string status, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslVehicle/anon/updateStatus?id={Uri.EscapeDataString(id)}&status={Uri.EscapeDataString(status)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + _logger.Information($"[车辆远端] POST {url}"); + var resp = await client.PostAsync(url, null, ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + } + + private async Task PostJsonAsync(string url, object body, CancellationToken ct) + { + var content = new StringContent(JsonSerializer.Serialize(body, _jsonOpts), Encoding.UTF8, "application/json"); + using var client = CreateClient(); + _logger.Information($"[车辆远端] POST {url}, bodyType={body.GetType().Name}"); + var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false); + var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + _logger.Information($"[车辆远端] POST完成 url={url}, status={(int)resp.StatusCode}, ok={ok}"); + return ok; + } + + private async Task<(bool Ok, bool IsVersionConflict)> PostJsonCheckVersionAsync(string url, object body, CancellationToken ct) + { + var content = new StringContent(JsonSerializer.Serialize(body, _jsonOpts), Encoding.UTF8, "application/json"); + using var client = CreateClient(); + _logger.Information($"[车辆远端] POST {url}, bodyType={body.GetType().Name}"); + var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + return (false, false); + try + { + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + int code = 200; + if (doc.RootElement.TryGetProperty("code", out var codeEl)) + code = codeEl.GetInt32(); + if (code == 200) + return (true, false); + if (doc.RootElement.TryGetProperty("message", out var msgEl)) + { + var msg = msgEl.GetString() ?? ""; + if (msg.Contains("已被他人修改")) + return (false, true); + } + return (false, false); + } + catch + { + return (true, false); + } + } + + private static async Task IsSuccessResultAsync(HttpResponseMessage resp, CancellationToken ct) + { + try + { + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("code", out var code)) + { + return code.GetInt32() == 200; + } + if (doc.RootElement.TryGetProperty("success", out var success)) + { + return success.GetBoolean(); + } + return true; + } + catch + { + return true; + } + } + + private void OnNetworkStatusChanged(bool isOnline) + { + _logger.Information($"[车辆网络] 状态变化 online={isOnline}"); + if (!isOnline) return; + _ = Task.Run(() => SyncAfterReconnectAsync(CancellationToken.None)); + } + + private async Task SyncAfterReconnectAsync(CancellationToken cancellationToken) + { + _logger.Information("[车辆重连] 开始执行重连同步"); + var pushResult = await PushPendingOnReconnectAsync(cancellationToken).ConfigureAwait(false); + + if (!_networkMonitor.IsOnline) + { + return; + } + + try + { + var remote = await FetchRemoteListAsync(cancellationToken).ConfigureAwait(false); + lock (_cacheLock) + { + _localCache = remote.Select(CloneVehicle).ToList(); + SaveCacheToDiskUnsafe(); + } + + // 拉取成功后主动通知页面刷新,避免用户手动点查询 + _eventAggregator.GetEvent().Publish(new VehicleChangedPayload + { + Action = "pull", + VehicleId = null + }); + _logger.Information($"[车辆重连] 全量回拉成功 count={remote.Count},已发布刷新事件"); + } + catch (Exception ex) + { + _logger.Warning($"[车辆重连] 全量回拉失败,继续使用本地缓存:{ex.Message}"); + } + + var hasActivity = pushResult.PushedCount > 0 + || pushResult.ConflictCount > 0 + || pushResult.NewRecordsPushed > 0; + if (hasActivity) + { + _eventAggregator.GetEvent() + .Publish(new SyncConflictPayload + { + EntityName = "车辆", + PushedCount = pushResult.PushedCount, + ConflictCount = pushResult.ConflictCount, + NewRecordsPushed = pushResult.NewRecordsPushed + }); + } + } + + private sealed record PendingReplayResult(bool Ok, bool IsConflict, string? EntityId); + + private async Task PushPendingOnReconnectAsync(CancellationToken cancellationToken) + { + if (!await _syncLock.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + _logger.Information("[车辆回放] 已有回放任务在执行,本次跳过"); + return new PushPendingResult(0, 0, 0); + } + try + { + List snapshot; + lock (_cacheLock) + { + snapshot = _pendingOps.OrderBy(x => x.CreatedAt).ToList(); + } + _logger.Information($"[车辆推送] 开始推送 pending={snapshot.Count}"); + + int pushed = 0, conflicts = 0, newPushed = 0; + + foreach (var op in snapshot) + { + if (!_networkMonitor.IsOnline) + { + break; + } + + // 如果该条 pending 在上一轮冲突中已被清理,则跳过 + lock (_cacheLock) + { + if (!_pendingOps.Any(x => x.Id == op.Id)) + continue; + } + + var result = await ExecutePendingOperationWithConflictAsync(op, cancellationToken).ConfigureAwait(false); + if (!result.Ok) + { + lock (_cacheLock) + { + op.RetryCount++; + if (op.RetryCount >= MaxPendingRetries) + { + _logger.Warning($"[车辆推送] op={op.OpType} 超过最大重试次数({MaxPendingRetries}),放弃 vehicleId={op.VehicleId}"); + _pendingOps.RemoveAll(x => x.Id == op.Id); + SavePendingOpsToDiskUnsafe(); + continue; + } + SavePendingOpsToDiskUnsafe(); + } + _logger.Warning($"[车辆推送] 推送中断 op={op.OpType}, vehicleId={op.VehicleId}, retry={op.RetryCount}"); + break; + } + + if (result.IsConflict) + { + conflicts++; + if (!string.IsNullOrWhiteSpace(result.EntityId)) + RemovePendingOpsByVehicleId(result.EntityId!); + continue; + } + + lock (_cacheLock) + { + if (op.OpType == VehicleOperationType.Add) + newPushed++; + else + pushed++; + + _pendingOps.RemoveAll(x => x.Id == op.Id); + SavePendingOpsToDiskUnsafe(); + } + _logger.Information($"[车辆推送] 推送成功 op={op.OpType}, vehicleId={op.VehicleId}, remain={_pendingOps.Count}"); + } + + return new PushPendingResult(pushed, conflicts, newPushed); + } + finally + { + _syncLock.Release(); + } + } + + private async Task ExecutePendingOperationWithConflictAsync(VehiclePendingOperation op, CancellationToken cancellationToken) + { + try + { + _logger.Information($"[车辆推送] 执行 op={op.OpType}, vehicleId={op.VehicleId}"); + + switch (op.OpType) + { + case VehicleOperationType.Add: + { + var ok = op.Vehicle != null && await RemoteAddAsync(op.Vehicle, cancellationToken).ConfigureAwait(false); + return ok + ? new PendingReplayResult(true, false, op.VehicleId) + : new PendingReplayResult(false, false, null); + } + case VehicleOperationType.Edit: + { + if (op.Vehicle == null || string.IsNullOrWhiteSpace(op.Vehicle.Id)) + return new PendingReplayResult(false, false, null); + + var id = op.Vehicle.Id; + var remote = await FetchRemoteSingleAsync(id, cancellationToken).ConfigureAwait(false); + if (remote != null && op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime) + { + // 冲突:后端版本获胜(服务器覆盖本地) + UpsertLocalCache(remote); + return new PendingReplayResult(true, true, id); + } + + var (ok, isVersionConflict) = await RemoteEditAsync(op.Vehicle, cancellationToken).ConfigureAwait(false); + if (isVersionConflict) + { + var fresh = await FetchRemoteSingleAsync(id, cancellationToken).ConfigureAwait(false); + if (fresh != null) UpsertLocalCache(fresh); + return new PendingReplayResult(true, true, id); + } + return ok + ? new PendingReplayResult(true, false, id) + : new PendingReplayResult(false, false, null); + } + case VehicleOperationType.Delete: + { + if (string.IsNullOrWhiteSpace(op.VehicleId)) + return new PendingReplayResult(false, false, null); + + var id = op.VehicleId!; + var remote = await FetchRemoteSingleAsync(id, cancellationToken).ConfigureAwait(false); + if (remote == null) + { + // 后端已不存在:删除无需操作,视为成功 + return new PendingReplayResult(true, false, id); + } + + if (op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime) + { + UpsertLocalCache(remote); + return new PendingReplayResult(true, true, id); + } + + var ok = await RemoteDeleteAsync(id, cancellationToken).ConfigureAwait(false); + return ok + ? new PendingReplayResult(true, false, id) + : new PendingReplayResult(false, false, null); + } + case VehicleOperationType.UpdateStatus: + { + if (string.IsNullOrWhiteSpace(op.VehicleId) || string.IsNullOrWhiteSpace(op.Status)) + return new PendingReplayResult(false, false, null); + + var id = op.VehicleId!; + var remote = await FetchRemoteSingleAsync(id, cancellationToken).ConfigureAwait(false); + if (remote != null && op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime) + { + UpsertLocalCache(remote); + return new PendingReplayResult(true, true, id); + } + + var ok = await RemoteUpdateStatusAsync(id, op.Status!, cancellationToken).ConfigureAwait(false); + return ok + ? new PendingReplayResult(true, false, id) + : new PendingReplayResult(false, false, null); + } + default: + return new PendingReplayResult(true, false, null); + } + } + catch (Exception ex) + { + _logger.Warning($"[车辆推送] 执行异常 op={op.OpType}, vehicleId={op.VehicleId}, err={ex.Message}"); + return new PendingReplayResult(false, false, null); + } + } + + private void RemovePendingOpsByVehicleId(string vehicleId) + { + lock (_cacheLock) + { + _pendingOps.RemoveAll(x => + (!string.IsNullOrWhiteSpace(x.VehicleId) && + string.Equals(x.VehicleId, vehicleId, StringComparison.OrdinalIgnoreCase)) || + (x.Vehicle?.Id != null && string.Equals(x.Vehicle.Id, vehicleId, StringComparison.OrdinalIgnoreCase))); + SavePendingOpsToDiskUnsafe(); + } + } + + private async Task FetchRemoteSingleAsync(string id, CancellationToken cancellationToken) + { + try + { + var url = $"{BaseUrl}/xslmes/mesXslVehicle/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return null; + + var json = await resp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("result", out var resultEl)) + return resultEl.Deserialize(_jsonOpts); + return null; + } + catch + { + return null; + } + } + + private static List ApplyFilters( + List source, + string? plateNumber, + string? vehicleBelong, + string? status) + { + IEnumerable query = source; + if (!string.IsNullOrWhiteSpace(plateNumber)) + { + query = query.Where(v => (v.PlateNumber ?? string.Empty).Contains(plateNumber, StringComparison.OrdinalIgnoreCase)); + } + if (!string.IsNullOrWhiteSpace(vehicleBelong)) + { + query = query.Where(v => string.Equals(v.VehicleBelong, vehicleBelong, StringComparison.OrdinalIgnoreCase)); + } + if (!string.IsNullOrWhiteSpace(status)) + { + query = query.Where(v => string.Equals(v.Status, status, StringComparison.OrdinalIgnoreCase)); + } + return query.OrderByDescending(v => v.CreateTime ?? DateTime.MinValue).ToList(); + } + + private List ApplyPendingOpsSnapshotUnsafe(List source) + { + var map = source + .Where(v => !string.IsNullOrWhiteSpace(v.Id)) + .ToDictionary(v => v.Id!, CloneVehicle, StringComparer.OrdinalIgnoreCase); + + foreach (var op in _pendingOps.OrderBy(x => x.CreatedAt)) + { + switch (op.OpType) + { + case VehicleOperationType.Add: + case VehicleOperationType.Edit: + if (op.Vehicle != null && !string.IsNullOrWhiteSpace(op.Vehicle.Id)) + { + map[op.Vehicle.Id] = CloneVehicle(op.Vehicle); + } + break; + case VehicleOperationType.Delete: + if (!string.IsNullOrWhiteSpace(op.VehicleId)) + { + map.Remove(op.VehicleId); + } + break; + case VehicleOperationType.UpdateStatus: + if (!string.IsNullOrWhiteSpace(op.VehicleId) + && !string.IsNullOrWhiteSpace(op.Status) + && map.TryGetValue(op.VehicleId, out var v)) + { + v.Status = op.Status; + } + break; + } + } + + return map.Values.ToList(); + } + + private void EnqueuePendingOperation(VehiclePendingOperation op) + { + lock (_cacheLock) + { + _pendingOps.Add(op); + SavePendingOpsToDiskUnsafe(); + _logger.Information($"[车辆入队] op={op.OpType}, vehicleId={op.VehicleId}, pending={_pendingOps.Count}"); + } + } + + private void UpsertLocalCache(MesXslVehicle vehicle) + { + lock (_cacheLock) + { + var idx = _localCache.FindIndex(v => string.Equals(v.Id, vehicle.Id, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + { + _localCache[idx] = CloneVehicle(vehicle); + } + else + { + _localCache.Insert(0, CloneVehicle(vehicle)); + } + SaveCacheToDiskUnsafe(); + } + } + + private void RemoveFromLocalCache(string id) + { + lock (_cacheLock) + { + _localCache.RemoveAll(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase)); + SaveCacheToDiskUnsafe(); + } + } + + private void UpdateLocalStatus(string id, string status) + { + lock (_cacheLock) + { + var item = _localCache.FirstOrDefault(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase)); + if (item != null) + { + item.Status = status; + SaveCacheToDiskUnsafe(); + } + } + } + + private void LoadPendingOpsFromDisk() + { + try + { + if (!File.Exists(_pendingOpsFilePath)) return; + var json = File.ReadAllText(_pendingOpsFilePath); + var data = JsonSerializer.Deserialize>(json, _jsonOpts); + _pendingOps = data ?? new List(); + _logger.Information($"[车辆本地] 载入待上传成功 count={_pendingOps.Count}"); + } + catch (Exception ex) + { + _pendingOps = new List(); + _logger.Warning($"[车辆本地] 载入待上传失败,已清空:{ex.Message}"); + } + } + + private void LoadCacheFromDisk() + { + try + { + if (!File.Exists(_cacheFilePath)) return; + var json = File.ReadAllText(_cacheFilePath); + var data = JsonSerializer.Deserialize>(json, _jsonOpts); + _localCache = data ?? new List(); + _logger.Information($"[车辆本地] 载入缓存成功 count={_localCache.Count}"); + } + catch (Exception ex) + { + _localCache = new List(); + _logger.Warning($"[车辆本地] 载入缓存失败,已清空:{ex.Message}"); + } + } + + private void SavePendingOpsToDiskUnsafe() + { + var json = JsonSerializer.Serialize(_pendingOps, _jsonOpts); + File.WriteAllText(_pendingOpsFilePath, json); + } + + private void SaveCacheToDiskUnsafe() + { + var json = JsonSerializer.Serialize(_localCache, _jsonOpts); + File.WriteAllText(_cacheFilePath, json); + } + + private static MesXslVehicle CloneVehicle(MesXslVehicle input) + { + return new MesXslVehicle + { + Id = input.Id, + PlateNumber = input.PlateNumber, + VehicleBelong = input.VehicleBelong, + TareWeightKg = input.TareWeightKg, + LoadCapacity = input.LoadCapacity, + UnitId = input.UnitId, + LoadUnit = input.LoadUnit, + CustomerIds = input.CustomerIds, + CustomerShortName = input.CustomerShortName, + SupplierId = input.SupplierId, + SupplierName = input.SupplierName, + SupplierShortName = input.SupplierShortName, + VehicleLength = input.VehicleLength, + VehicleWidth = input.VehicleWidth, + VehicleHeight = input.VehicleHeight, + DriverName = input.DriverName, + DriverPhone = input.DriverPhone, + Status = input.Status, + TenantId = input.TenantId, + CreateBy = input.CreateBy, + CreateTime = input.CreateTime, + UpdateBy = input.UpdateBy, + UpdateTime = input.UpdateTime, + SysOrgCode = input.SysOrgCode + }; + } + + private static bool IsLocalTempId(string? id) + { + return !string.IsNullOrWhiteSpace(id) + && id.StartsWith("local-", StringComparison.OrdinalIgnoreCase); + } + + private sealed class VehiclePendingOperation + { + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public VehicleOperationType OpType { get; set; } + public string? VehicleId { get; set; } + public string? Status { get; set; } + public MesXslVehicle? Vehicle { get; set; } + // 冲突检测用的版本锚点:当本地首次针对该车辆产生修改时,记录当时的服务器 UpdateTime + public DateTime? AnchorUpdateTime { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public int RetryCount { get; set; } = 0; + } + + private enum VehicleOperationType + { + Add = 1, + Edit = 2, + Delete = 3, + UpdateStatus = 4 + } + + /// + /// 兼容 Jeecg 常见时间字符串格式:yyyy-MM-dd HH:mm:ss + /// + private sealed class NullableDateTimeJsonConverter : JsonConverter + { + private static readonly string[] SupportedFormats = + [ + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ssZ", + "yyyy-MM-ddTHH:mm:ss.fffZ" + ]; + + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + var raw = reader.GetString(); + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + if (DateTime.TryParseExact(raw, SupportedFormats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var exact)) + { + return exact; + } + + if (DateTime.TryParse(raw, out var fallback)) + { + return fallback; + } + } + + throw new JsonException($"无法将 JSON 值转换为 DateTime?,token={reader.TokenType}"); + } + + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + if (value.HasValue) + { + writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss")); + return; + } + + writer.WriteNullValue(); + } + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Vehicle/VehicleSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Vehicle/VehicleSyncCoordinator.cs new file mode 100644 index 0000000..57e3e33 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Vehicle/VehicleSyncCoordinator.cs @@ -0,0 +1,81 @@ +using Prism.Events; +using System.Text.Json; +using YY.Admin.Core; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.Vehicle; + +/// +/// 监听 STOMP 收到的车辆变更信号,转发为桌面端 Prism 事件,触发列表刷新。 +/// +public class VehicleSyncCoordinator : ISingletonDependency +{ + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + private SubscriptionToken? _remoteCommandToken; + private SubscriptionToken? _networkStatusToken; + + public VehicleSyncCoordinator(IEventAggregator eventAggregator, ILoggerService logger) + { + _eventAggregator = eventAggregator; + _logger = logger; + _remoteCommandToken = _eventAggregator + .GetEvent() + .Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread); + // 断线重连后补拉一次,覆盖离线期间漏掉的 STOMP 事件 + _networkStatusToken = _eventAggregator + .GetEvent() + .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + _logger.Information("[车辆推送] VehicleSyncCoordinator 已启动,开始监听 RemoteCommandReceivedEvent"); + } + + private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload) + { + if (!payload.IsOnline) return; + _logger.Information("[车辆推送] 网络恢复,触发补偿刷新"); + _eventAggregator.GetEvent().Publish(new VehicleChangedPayload { Action = "reconnect" }); + } + + private void OnRemoteCommand(RemoteCommandPayload payload) + { + try + { + var json = payload.CommandJson ?? string.Empty; + if (string.IsNullOrWhiteSpace(json)) + { + _logger.Information("[车辆推送] 收到空命令,忽略"); + return; + } + + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("cmd", out var cmdEl)) + { + _logger.Information("[车辆推送] 命令无cmd字段,忽略"); + return; + } + var cmd = cmdEl.GetString() ?? string.Empty; + if (!cmd.Equals("MES_VEHICLE_CHANGED", StringComparison.OrdinalIgnoreCase)) + { + _logger.Information($"[车辆推送] 非车辆命令 cmd={cmd},忽略"); + return; + } + + doc.RootElement.TryGetProperty("action", out var actionEl); + doc.RootElement.TryGetProperty("vehicleId", out var idEl); + + var changedPayload = new VehicleChangedPayload + { + Action = actionEl.GetString() ?? string.Empty, + VehicleId = idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : null, + }; + + _logger.Information($"收到车辆变更信号: action={changedPayload.Action}, vehicleId={changedPayload.VehicleId}"); + _eventAggregator.GetEvent().Publish(changedPayload); + } + catch (Exception ex) + { + _logger.Warning($"处理 STOMP 车辆变更信号失败: {ex.Message}"); + } + } +} diff --git a/yy-admin-master/YY.Admin.Services/YY.Admin.Services.csproj b/yy-admin-master/YY.Admin.Services/YY.Admin.Services.csproj index 59cc40a..20e209c 100644 --- a/yy-admin-master/YY.Admin.Services/YY.Admin.Services.csproj +++ b/yy-admin-master/YY.Admin.Services/YY.Admin.Services.csproj @@ -18,6 +18,10 @@ + + + + PreserveNewest diff --git a/yy-admin-master/YY.Admin/Event/MenuStructureChangedEvent.cs b/yy-admin-master/YY.Admin/Event/MenuStructureChangedEvent.cs new file mode 100644 index 0000000..c8bf068 --- /dev/null +++ b/yy-admin-master/YY.Admin/Event/MenuStructureChangedEvent.cs @@ -0,0 +1,10 @@ +using Prism.Events; + +namespace YY.Admin.Event; + +/// +/// 本地 sys_menu 结构变更后通知左侧菜单树重新加载 +/// +public class MenuStructureChangedEvent : PubSubEvent +{ +} diff --git a/yy-admin-master/YY.Admin/FluentValidation/SysUserValidator.cs b/yy-admin-master/YY.Admin/FluentValidation/SysUserValidator.cs index 35a1712..8981264 100644 --- a/yy-admin-master/YY.Admin/FluentValidation/SysUserValidator.cs +++ b/yy-admin-master/YY.Admin/FluentValidation/SysUserValidator.cs @@ -20,8 +20,7 @@ namespace YY.Admin.FluentValidation { var exists = await _userService.AccountExistsAsync(account, user.Id); return !exists; - }).WithMessage("账号已存在") - .When(x => x.Id == 0); + }).WithMessage("账号已存在"); RuleFor(x => x.RealName) .NotEmpty().WithMessage("姓名不能为空") diff --git a/yy-admin-master/YY.Admin/Helper/ServerSettingsStore.cs b/yy-admin-master/YY.Admin/Helper/ServerSettingsStore.cs index 1739464..aace8dd 100644 --- a/yy-admin-master/YY.Admin/Helper/ServerSettingsStore.cs +++ b/yy-admin-master/YY.Admin/Helper/ServerSettingsStore.cs @@ -19,6 +19,10 @@ namespace YY.Admin.Helper public string BasePath { get; set; } = "/jeecg-boot"; public string WebSocketUrl { get; set; } = string.Empty; public string WebSocketPath { get; set; } = DefaultWebSocketPath; + /// + /// 是否断开连接(true=断开,false=连接) + /// + public bool DisconnectConnection { get; set; } = false; } public static string GetConfigPath() @@ -54,6 +58,7 @@ namespace YY.Admin.Helper model.WebSocketUrl = jeecg.Value("WebSocketUrl") ?? string.Empty; model.WebSocketPath = NormalizeWebSocketPath(jeecg.Value("WebSocketPath")); + model.DisconnectConnection = jeecg.Value("DisconnectConnection") ?? false; return model; } @@ -88,6 +93,7 @@ namespace YY.Admin.Helper jeecg["BaseUrl"] = baseUrl; jeecg["WebSocketUrl"] = webSocketUrl; jeecg["WebSocketPath"] = webSocketPath; + jeecg["DisconnectConnection"] = model.DisconnectConnection; File.WriteAllText(path, root.ToString(Formatting.Indented)); } diff --git a/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs b/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs index 582a3e3..b6f1b7d 100644 --- a/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs +++ b/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs @@ -3,29 +3,62 @@ using Prism.Events; using System.IO; using System.Net.WebSockets; using System.Text; +using System.Timers; using YY.Admin.Core.Events; using YY.Admin.Core.Services; +using YY.Admin.Core.Session; +using YY.Admin.Helper; using YY.Admin.Infrastructure.Storage; namespace YY.Admin.Infrastructure.Hubs; public class StompWebSocketService : ISignalRService { + // STOMP heart-beat: send \n every 10 s, declare we want to receive every 10 s + private const int HeartbeatMs = 10_000; + // Watchdog: if nothing received in 3 × HeartbeatMs, treat connection as zombie + private const int WatchdogMs = 30_000; + private readonly IConfiguration _configuration; private readonly IEventAggregator _eventAggregator; private readonly TokenStore _tokenStore; + private readonly INetworkMonitor _networkMonitor; + private ClientWebSocket? _socket; private string _deviceId = "default-device"; private string _token = string.Empty; + private CancellationTokenSource? _connectionCts; + private System.Timers.Timer? _heartbeatTimer; + private System.Timers.Timer? _watchdogTimer; + private volatile int _lastReceivedTick = Environment.TickCount; + public StompWebSocketService( IConfiguration configuration, IEventAggregator eventAggregator, - TokenStore tokenStore) + TokenStore tokenStore, + INetworkMonitor networkMonitor) { _configuration = configuration; _eventAggregator = eventAggregator; _tokenStore = tokenStore; + _networkMonitor = networkMonitor; + + // When the network comes back online, reconnect the STOMP channel if it is down. + eventAggregator.GetEvent() + .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + } + + private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload) + { + if (!payload.IsOnline || IsDisconnectedByUser()) + { + return; + } + if (_socket == null || _socket.State != WebSocketState.Open) + { + _ = Task.Run(() => ConnectUnifiedDeviceChannelAsync(CancellationToken.None)); + } } /// @@ -38,6 +71,12 @@ public class StompWebSocketService : ISignalRService /// public async Task ConnectUnifiedDeviceChannelAsync(CancellationToken cancellationToken = default) { + if (IsDisconnectedByUser()) + { + await DisconnectAsync(cancellationToken).ConfigureAwait(false); + return; + } + var anonymous = _configuration.GetValue("JeecgIntegration:AnonymousMode", true); if (anonymous) { @@ -61,41 +100,70 @@ public class StompWebSocketService : ISignalRService try { + // Tear down previous session before opening a new one. + var oldCts = Interlocked.Exchange(ref _connectionCts, null); + oldCts?.Cancel(); + oldCts?.Dispose(); + StopTimers(); _socket?.Dispose(); + _socket = new ClientWebSocket(); _socket.Options.AddSubProtocol("v12.stomp"); await _socket.ConnectAsync(new Uri(wsUrl), cancellationToken).ConfigureAwait(false); var connectFrame = anonymous || string.IsNullOrWhiteSpace(_token) - ? "CONNECT\naccept-version:1.2\nheart-beat:10000,10000\n\n\0" - : BuildConnectFrame(_token); + ? BuildConnectFrame(null, _deviceId) + : BuildConnectFrame(_token, _deviceId); await SendFrameAsync(connectFrame, cancellationToken).ConfigureAwait(false); - // 用户镜像变更:与后端 /topic/sync/jeecg-users 对齐(设备同步统一线路) - await SendFrameAsync(BuildSubscribeFrame("sub-jeecg-users", "/topic/sync/jeecg-users"), cancellationToken).ConfigureAwait(false); + // 用户镜像变更:订阅 /topic/sync/jeecg-users + await SendFrameAsync( + BuildSubscribeFrame("sub-jeecg-users", "/topic/sync/jeecg-users"), + cancellationToken).ConfigureAwait(false); + + // 车辆数据变更:订阅 /topic/sync/mes-vehicles + await SendFrameAsync( + BuildSubscribeFrame("sub-mes-vehicles", "/topic/sync/mes-vehicles"), + cancellationToken).ConfigureAwait(false); + + // 客户数据变更:订阅 /topic/sync/mes-customers + await SendFrameAsync( + BuildSubscribeFrame("sub-mes-customers", "/topic/sync/mes-customers"), + cancellationToken).ConfigureAwait(false); + // 供应商数据变更:订阅 /topic/sync/mes-suppliers + await SendFrameAsync( + BuildSubscribeFrame("sub-mes-suppliers", "/topic/sync/mes-suppliers"), + cancellationToken).ConfigureAwait(false); + + // 订阅服务端 PONG 回复(应用层假在线检测) + await SendFrameAsync( + BuildSubscribeFrame("sub-device-pong", $"/topic/device/{_deviceId}/pong"), + cancellationToken).ConfigureAwait(false); - // 非免密时同时订阅设备点对点指令队列 if (!anonymous && !string.IsNullOrWhiteSpace(_token)) { - await SendFrameAsync(BuildSubscribeFrame("sub-device-command", $"/user/{_deviceId}/queue/command"), cancellationToken).ConfigureAwait(false); + await SendFrameAsync( + BuildSubscribeFrame("sub-device-command", $"/user/{_deviceId}/queue/command"), + cancellationToken).ConfigureAwait(false); } - _eventAggregator.GetEvent().Publish(new NetworkStatusChangedPayload - { - IsOnline = true, - ChangedAt = DateTime.UtcNow - }); + // Reset watchdog baseline to now. + _lastReceivedTick = Environment.TickCount; - _ = Task.Run(() => ReceiveLoopAsync(cancellationToken), cancellationToken); + var cts = new CancellationTokenSource(); + _connectionCts = cts; + + StartHeartbeatTimer(cts.Token); + StartWatchdogTimer(cts.Token); + + _networkMonitor.SetStompTransportOnline(true); + + _ = Task.Run(() => ReceiveLoopAsync(cts.Token), cts.Token); return; } catch { - _eventAggregator.GetEvent().Publish(new NetworkStatusChangedPayload - { - IsOnline = false, - ChangedAt = DateTime.UtcNow - }); + _networkMonitor.SetStompTransportOnline(false); } } } @@ -103,19 +171,149 @@ public class StompWebSocketService : ISignalRService /// public async Task SendDeviceStatusAsync(object status, CancellationToken cancellationToken = default) { - if (_socket == null || _socket.State != WebSocketState.Open) + if (IsDisconnectedByUser() || _socket == null || _socket.State != WebSocketState.Open) { return; } var json = System.Text.Json.JsonSerializer.Serialize(status); - var frame = $"SEND\n" + - $"destination:/app/device/status\n" + - $"content-type:application/json\n" + + var frame = "SEND\n" + + "destination:/app/device/status\n" + + "content-type:application/json\n" + $"content-length:{Encoding.UTF8.GetByteCount(json)}\n\n" + $"{json}\0"; await SendFrameAsync(frame, cancellationToken).ConfigureAwait(false); } + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + var cts = Interlocked.Exchange(ref _connectionCts, null); + cts?.Cancel(); + cts?.Dispose(); + StopTimers(); + + var socket = _socket; + _socket = null; + if (socket == null) + { + _networkMonitor.SetStompTransportOnline(false); + return; + } + + try + { + if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived) + { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "manual disconnect", cancellationToken).ConfigureAwait(false); + } + } + catch + { + socket.Abort(); + } + finally + { + socket.Dispose(); + _networkMonitor.SetStompTransportOnline(false); + } + } + + // ── Heartbeat ────────────────────────────────────────────────────────── + + private void StartHeartbeatTimer(CancellationToken cancellationToken) + { + _heartbeatTimer = new System.Timers.Timer(HeartbeatMs) { AutoReset = true }; + _heartbeatTimer.Elapsed += async (_, _) => + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + // STOMP protocol keepalive (\n frame) + await SendStompHeartbeatAsync(cancellationToken).ConfigureAwait(false); + // Application-layer PING: server replies to /topic/device/{id}/pong + await SendAppPingAsync(cancellationToken).ConfigureAwait(false); + }; + _heartbeatTimer.Start(); + } + + private async Task SendStompHeartbeatAsync(CancellationToken cancellationToken) + { + if (_socket == null || _socket.State != WebSocketState.Open) + { + return; + } + try + { + // STOMP spec: a single LF (0x0A) constitutes a heartbeat frame. + await _socket.SendAsync( + new ArraySegment(new byte[] { 0x0A }), + WebSocketMessageType.Text, + true, + cancellationToken).ConfigureAwait(false); + } + catch + { + // Send failure will be caught by the watchdog. + } + } + + private async Task SendAppPingAsync(CancellationToken cancellationToken) + { + if (_socket == null || _socket.State != WebSocketState.Open) + { + return; + } + try + { + var body = $"{{\"cmd\":\"PING_DEVICE\",\"deviceId\":\"{_deviceId}\",\"ts\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}}}"; + var frame = "SEND\n" + + "destination:/app/device/ping\n" + + "content-type:application/json\n" + + $"content-length:{Encoding.UTF8.GetByteCount(body)}\n\n" + + $"{body}\0"; + await SendFrameAsync(frame, cancellationToken).ConfigureAwait(false); + } + catch + { + // Watchdog handles reconnect. + } + } + + // ── Watchdog ─────────────────────────────────────────────────────────── + + private void StartWatchdogTimer(CancellationToken cancellationToken) + { + _watchdogTimer = new System.Timers.Timer(WatchdogMs) { AutoReset = true }; + _watchdogTimer.Elapsed += (_, _) => + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + // TickCount wraps; unchecked subtraction handles it correctly. + var silenceMs = unchecked(Environment.TickCount - _lastReceivedTick); + if (silenceMs >= WatchdogMs) + { + // No frame (heartbeat or MESSAGE) received for WatchdogMs — zombie connection. + _ = Task.Run(() => ConnectUnifiedDeviceChannelAsync(CancellationToken.None)); + } + }; + _watchdogTimer.Start(); + } + + private void StopTimers() + { + _heartbeatTimer?.Stop(); + _heartbeatTimer?.Dispose(); + _heartbeatTimer = null; + + _watchdogTimer?.Stop(); + _watchdogTimer?.Dispose(); + _watchdogTimer = null; + } + + // ── Receive loop ─────────────────────────────────────────────────────── + private async Task ReceiveLoopAsync(CancellationToken cancellationToken) { if (_socket == null) @@ -132,17 +330,16 @@ public class StompWebSocketService : ISignalRService result = await _socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken).ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) { - _eventAggregator.GetEvent().Publish(new NetworkStatusChangedPayload - { - IsOnline = false, - ChangedAt = DateTime.UtcNow - }); + _networkMonitor.SetStompTransportOnline(false); await ConnectUnifiedDeviceChannelAsync(cancellationToken).ConfigureAwait(false); return; } ms.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); + // Any received frame (heartbeat \n or MESSAGE) resets the watchdog. + _lastReceivedTick = Environment.TickCount; + var text = Encoding.UTF8.GetString(ms.ToArray()); if (!text.StartsWith("MESSAGE", StringComparison.OrdinalIgnoreCase)) { @@ -169,21 +366,27 @@ public class StompWebSocketService : ISignalRService return; } var data = Encoding.UTF8.GetBytes(frame); - await _socket.SendAsync(new ArraySegment(data), WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); + await _socket.SendAsync( + new ArraySegment(data), + WebSocketMessageType.Text, + true, + cancellationToken).ConfigureAwait(false); } + // ── Helpers ──────────────────────────────────────────────────────────── + private string ResolveWsUrl() { var baseUrl = _configuration.GetValue("JeecgIntegration:BaseUrl")?.TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { - return "ws://127.0.0.1:8080/jeecg-boot/ws/device/websocket"; + return "ws://127.0.0.1:8080/jeecg-boot/ws/device"; } if (baseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { - return "wss://" + baseUrl["https://".Length..] + "/ws/device/websocket"; + return "wss://" + baseUrl["https://".Length..] + "/ws/device"; } - return "ws://" + baseUrl["http://".Length..] + "/ws/device/websocket"; + return "ws://" + baseUrl["http://".Length..] + "/ws/device"; } private static string ResolveDeviceId(string token) @@ -215,12 +418,22 @@ public class StompWebSocketService : ISignalRService } } - private static string BuildConnectFrame(string token) + private static string BuildConnectFrame(string? token, string deviceId) { - return "CONNECT\n" + - "accept-version:1.2\n" + - "heart-beat:10000,10000\n" + - $"Authorization:Bearer {token}\n\n\0"; + var user = AppSession.CurrentUser; + var sb = new System.Text.StringBuilder(); + sb.Append("CONNECT\n"); + sb.Append("accept-version:1.2\n"); + sb.Append($"heart-beat:{HeartbeatMs},{HeartbeatMs}\n"); + if (!string.IsNullOrWhiteSpace(token)) + sb.Append($"Authorization:Bearer {token}\n"); + sb.Append("platform:desktop\n"); + sb.Append($"hostName:{Environment.MachineName}\n"); + sb.Append($"deviceId:{deviceId}\n"); + sb.Append($"userName:{user?.Account ?? "unknown"}\n"); + sb.Append($"realName:{user?.RealName ?? ""}\n"); + sb.Append("\n\0"); + return sb.ToString(); } private static string BuildSubscribeFrame(string subscriptionId, string destination) @@ -230,4 +443,16 @@ public class StompWebSocketService : ISignalRService $"destination:{destination}\n" + "ack:auto\n\n\0"; } + + private static bool IsDisconnectedByUser() + { + try + { + return ServerSettingsStore.Load().DisconnectConnection; + } + catch + { + return false; + } + } } diff --git a/yy-admin-master/YY.Admin/Infrastructure/Network/DisconnectGuardHandler.cs b/yy-admin-master/YY.Admin/Infrastructure/Network/DisconnectGuardHandler.cs new file mode 100644 index 0000000..dc5beca --- /dev/null +++ b/yy-admin-master/YY.Admin/Infrastructure/Network/DisconnectGuardHandler.cs @@ -0,0 +1,26 @@ +using System.Net; +using System.Net.Http; +using YY.Admin.Helper; + +namespace YY.Admin.Infrastructure.Network; + +/// +/// 全局断开保护:用户勾选"断开连接"时,直接短路所有后端 HTTP 请求, +/// 返回 499 而不发起真实网络调用,各服务的 catch/IsSuccessStatusCode 分支自行降级。 +/// +internal sealed class DisconnectGuardHandler : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (ServerSettingsStore.Load().DisconnectConnection) + { + return Task.FromResult(new HttpResponseMessage((HttpStatusCode)499) + { + ReasonPhrase = "User Disconnected" + }); + } + return base.SendAsync(request, cancellationToken); + } +} diff --git a/yy-admin-master/YY.Admin/Infrastructure/Network/NetworkMonitor.cs b/yy-admin-master/YY.Admin/Infrastructure/Network/NetworkMonitor.cs index 066ba64..6dea0bd 100644 --- a/yy-admin-master/YY.Admin/Infrastructure/Network/NetworkMonitor.cs +++ b/yy-admin-master/YY.Admin/Infrastructure/Network/NetworkMonitor.cs @@ -3,6 +3,7 @@ using Prism.Events; using System.Net.Http; using YY.Admin.Core.Events; using YY.Admin.Core.Services; +using YY.Admin.Helper; namespace YY.Admin.Infrastructure.Network; @@ -12,8 +13,11 @@ public class NetworkMonitor : INetworkMonitor private readonly IConfiguration _configuration; private readonly IEventAggregator _eventAggregator; private readonly SemaphoreSlim _startLock = new(1, 1); + private readonly object _aggregateLock = new(); private Task? _loopTask; private CancellationTokenSource? _cts; + private volatile bool _httpProbeOnline; + private volatile bool _stompTransportOnline; private volatile bool _isOnline; public NetworkMonitor( @@ -30,6 +34,13 @@ public class NetworkMonitor : INetworkMonitor public event Action? StatusChanged; + /// + public void SetStompTransportOnline(bool online) + { + _stompTransportOnline = online; + RecomputeAggregatedOnline(); + } + public async Task StartAsync(CancellationToken cancellationToken = default) { await _startLock.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -51,27 +62,63 @@ public class NetworkMonitor : INetworkMonitor private async Task MonitorLoopAsync(CancellationToken cancellationToken) { + // 启动后立即探活一次,避免首屏 10 秒内 IsOnline 恒为 false + await RunHttpProbeAndRecomputeAsync(cancellationToken).ConfigureAwait(false); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)); while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { - var online = await ProbeAsync(cancellationToken).ConfigureAwait(false); - if (online == _isOnline) + await RunHttpProbeAndRecomputeAsync(cancellationToken).ConfigureAwait(false); + } + } + + private async Task RunHttpProbeAndRecomputeAsync(CancellationToken cancellationToken) + { + var httpOk = await ProbeAsync(cancellationToken).ConfigureAwait(false); + _httpProbeOnline = httpOk; + RecomputeAggregatedOnline(); + } + + private void RecomputeAggregatedOnline() + { + var combined = ComputeCombinedOnline(); + bool newValue; + lock (_aggregateLock) + { + if (combined == _isOnline) { - continue; + return; } - _isOnline = online; - StatusChanged?.Invoke(online); - _eventAggregator.GetEvent().Publish(new NetworkStatusChangedPayload - { - IsOnline = online, - ChangedAt = DateTime.UtcNow - }); + _isOnline = combined; + newValue = combined; } + + StatusChanged?.Invoke(newValue); + _eventAggregator.GetEvent().Publish(new NetworkStatusChangedPayload + { + IsOnline = newValue, + ChangedAt = DateTime.UtcNow + }); + } + + private bool ComputeCombinedOnline() + { + if (IsDisconnectedByUser()) + { + return false; + } + + return _httpProbeOnline || _stompTransportOnline; } private async Task ProbeAsync(CancellationToken cancellationToken) { + if (IsDisconnectedByUser()) + { + return false; + } + var baseUrl = _configuration.GetValue("JeecgIntegration:BaseUrl")?.TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { @@ -108,4 +155,16 @@ public class NetworkMonitor : INetworkMonitor return false; } + + private static bool IsDisconnectedByUser() + { + try + { + return ServerSettingsStore.Load().DisconnectConnection; + } + catch + { + return false; + } + } } diff --git a/yy-admin-master/YY.Admin/Infrastructure/Sync/UserSyncOutbox.cs b/yy-admin-master/YY.Admin/Infrastructure/Sync/UserSyncOutbox.cs new file mode 100644 index 0000000..e8dfc7d --- /dev/null +++ b/yy-admin-master/YY.Admin/Infrastructure/Sync/UserSyncOutbox.cs @@ -0,0 +1,74 @@ +using YY.Admin.Core.Services; +using YY.Admin.Core.Sync; + +namespace YY.Admin.Infrastructure.Sync; + +/// +/// 将桌面用户 CRUD 写入 Outbox,通过 /sys/sync/batch 反同步到 Jeecg 后端。 +/// 断网时自动持久化,联网后续传。 +/// +public sealed class UserSyncOutbox : IUserSyncOutbox +{ + private readonly OutboxProcessor _outboxProcessor; + + public UserSyncOutbox(OutboxProcessor outboxProcessor) + { + _outboxProcessor = outboxProcessor; + } + + public Task EnqueueCreateAsync( + string userId, string account, string? realName, int? sex, DateTime? birthday, string? phone, string? email, int status, string? updateBy, + CancellationToken cancellationToken = default) + { + return _outboxProcessor.EnqueueAsync( + SysUserSyncOutbox.AggregateType, + userId, + SysUserSyncOutbox.EventCreate, + new { userId, account, realName, sex, birthday, phone, email, status, updateBy }, + cancellationToken); + } + + public Task EnqueueUpdateAsync( + string userId, string account, string? realName, int? sex, DateTime? birthday, string? phone, string? email, int status, string? updateBy, + CancellationToken cancellationToken = default) + { + return _outboxProcessor.EnqueueAsync( + SysUserSyncOutbox.AggregateType, + userId, + SysUserSyncOutbox.EventUpdate, + new { userId, account, realName, sex, birthday, phone, email, status, updateBy }, + cancellationToken); + } + + public Task EnqueueToggleStatusAsync( + string userId, int status, string? updateBy, + CancellationToken cancellationToken = default) + { + return _outboxProcessor.EnqueueAsync( + SysUserSyncOutbox.AggregateType, + userId, + SysUserSyncOutbox.EventToggleStatus, + new { userId, status, updateBy }, + cancellationToken); + } + + public Task EnqueueDeleteAsync(string userId, CancellationToken cancellationToken = default) + { + return _outboxProcessor.EnqueueAsync( + SysUserSyncOutbox.AggregateType, + userId, + SysUserSyncOutbox.EventDelete, + new { userId }, + cancellationToken); + } + + public Task EnqueueBatchDeleteAsync(IReadOnlyList userIds, CancellationToken cancellationToken = default) + { + return _outboxProcessor.EnqueueAsync( + SysUserSyncOutbox.AggregateType, + string.Join(",", userIds), + SysUserSyncOutbox.EventBatchDelete, + new { userIds }, + cancellationToken); + } +} diff --git a/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs b/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs index 2f6bebe..52a676a 100644 --- a/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs +++ b/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs @@ -7,6 +7,10 @@ using YY.Admin.Views; using YY.Admin.Views.Control; using YY.Admin.Views.Dialogs; using YY.Admin.Views.SysManage; +using YY.Admin.Views.Customer; +using YY.Admin.Views.Supplier; +using YY.Admin.ViewModels.Vehicle; +using YY.Admin.Views.Vehicle; namespace YY.Admin { @@ -49,7 +53,14 @@ namespace YY.Admin containerRegistry.RegisterForNavigation(); containerRegistry.RegisterForNavigation(); containerRegistry.RegisterForNavigation(); - + containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); + // 车辆管理 + containerRegistry.RegisterForNavigation(); + // 客户管理 + containerRegistry.RegisterForNavigation(); + // 供应商管理 + containerRegistry.RegisterForNavigation(); } } public class DialogWindow : Window, IDialogWindow diff --git a/yy-admin-master/YY.Admin/Module/SyncModule.cs b/yy-admin-master/YY.Admin/Module/SyncModule.cs index d81e622..dfd5642 100644 --- a/yy-admin-master/YY.Admin/Module/SyncModule.cs +++ b/yy-admin-master/YY.Admin/Module/SyncModule.cs @@ -5,11 +5,17 @@ using Polly.Extensions.Http; using Prism.Ioc; using Prism.Modularity; using System.Net.Http; +using System.Text; using YY.Admin.Core.Services; +using YY.Admin.Core.Session; +using YY.Admin.EventBus; using YY.Admin.Infrastructure.Hubs; using YY.Admin.Infrastructure.Network; using YY.Admin.Infrastructure.Storage; using YY.Admin.Infrastructure.Sync; +using YY.Admin.Services.Service.Customer; +using YY.Admin.Services.Service.Supplier; +using YY.Admin.Services.Service.Vehicle; namespace YY.Admin.Module; @@ -23,8 +29,22 @@ public class SyncModule : IModule containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + // 本地用户事件订阅器:将增删改操作入 Outbox 回传后端 + containerRegistry.RegisterSingleton(); + + // 车辆管理:免密 API 直连 + STOMP 实时通知 + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + // 客户管理:免密 API 直连 + STOMP 实时通知 + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + // 供应商管理:免密 API 直连 + STOMP 实时通知 + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); var serviceCollection = new ServiceCollection(); + serviceCollection.AddTransient(); serviceCollection.AddHttpClient("JeecgApi", (sp, client) => { var config = containerRegistry.GetContainer().Resolve(); @@ -40,7 +60,14 @@ public class SyncModule : IModule { client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); } - }).AddPolicyHandler(GetRetryPolicy()); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-Platform", "desktop"); + // .NET HttpClient 要求请求头值为 ASCII;主机名/中文姓名否则会报 Request headers must contain only ASCII characters + client.DefaultRequestHeaders.TryAddWithoutValidation("X-Host-Name", ToAsciiHttpHeaderValue(Environment.MachineName)); + var currentUser = AppSession.CurrentUser; + client.DefaultRequestHeaders.TryAddWithoutValidation("X-User-Name", ToAsciiHttpHeaderValue(currentUser?.Account ?? "unknown")); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-Real-Name", ToAsciiHttpHeaderValue(currentUser?.RealName ?? "")); + }).AddPolicyHandler(GetRetryPolicy()) + .AddHttpMessageHandler(); var provider = serviceCollection.BuildServiceProvider(); var httpClientFactory = provider.GetRequiredService(); @@ -57,6 +84,14 @@ public class SyncModule : IModule _ = outboxProcessor.StartConsumerAsync(CancellationToken.None); // 用户镜像 + 设备指令:统一 STOMP(/ws/device),免密与设备 Token 模式均启动 _ = Task.Run(() => signalService.ConnectUnifiedDeviceChannelAsync(CancellationToken.None)); + // 强制实例化事件订阅器(单例,构造函数内完成订阅注册) + _ = containerProvider.Resolve(); + // 强制实例化车辆同步协调器(构造函数内订阅 STOMP 车辆变更事件) + _ = containerProvider.Resolve(); + // 强制实例化客户同步协调器 + _ = containerProvider.Resolve(); + // 强制实例化供应商同步协调器 + _ = containerProvider.Resolve(); } private static IAsyncPolicy GetRetryPolicy() @@ -66,4 +101,24 @@ public class SyncModule : IModule .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); } + /// + /// 将自定义请求头转为 ASCII(含非 ASCII 时整体做 UTF-8 Base64,前缀 B64. 便于后端按需解码)。 + /// + private static string ToAsciiHttpHeaderValue(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + foreach (var c in value) + { + if (c > 127) + { + return "B64." + Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + } + } + + return value; + } } diff --git a/yy-admin-master/YY.Admin/Subscriber/SysUserEventSubscriber.cs b/yy-admin-master/YY.Admin/Subscriber/SysUserEventSubscriber.cs index 1cce8b5..c151be8 100644 --- a/yy-admin-master/YY.Admin/Subscriber/SysUserEventSubscriber.cs +++ b/yy-admin-master/YY.Admin/Subscriber/SysUserEventSubscriber.cs @@ -1,163 +1,107 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using YY.Admin.Core; -using static YY.Admin.Core.SysUserEvents; using Prism.Events; -namespace YY.Admin.EventBus +using YY.Admin.Core; +using YY.Admin.Core.Services; +using static YY.Admin.Core.SysUserEvents; + +namespace YY.Admin.EventBus; + +/// +/// 本地用户事件订阅器:记录本地 CRUD 操作日志。 +/// 注意:本地操作不触发 mirror pull(拉取会覆盖本地改动), +/// 后端→桌面的实时同步由 JeecgUserSyncCoordinator 统一负责。 +/// 实现 ISingletonDependency 使其由程序集扫描自动注册为单例。 +/// +public class SysUserEventSubscriber : IDisposable, ISingletonDependency { - public class SysUserEventSubscriber : IDisposable + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + private readonly List _subscriptions = new(); + + public SysUserEventSubscriber( + IEventAggregator eventAggregator, + ILoggerService logger) { - private readonly IEventAggregator _eventAggregator; - private readonly ILoggerService _logger; - private readonly List _subscriptions = new(); - public SysUserEventSubscriber( - IEventAggregator eventAggregator, - ILoggerService logger) - { - _eventAggregator = eventAggregator; - _logger = logger; - SubscribeEvents(); - } - public void SubscribeEvents() - { - _eventAggregator.GetEvent().Subscribe(OnAddUser, ThreadOption.BackgroundThread); - _eventAggregator.GetEvent().Subscribe(OnUpdateUser, ThreadOption.BackgroundThread); - _eventAggregator.GetEvent().Subscribe(OnDeleteUser, ThreadOption.BackgroundThread); - _eventAggregator.GetEvent().Subscribe(OnSetUserStatus, ThreadOption.BackgroundThread); - _eventAggregator.GetEvent().Subscribe(OnChangePwd, ThreadOption.BackgroundThread); - _eventAggregator.GetEvent().Subscribe(OnResetPwd, ThreadOption.BackgroundThread); - _eventAggregator.GetEvent().Subscribe(OnUnlockUserLogin, ThreadOption.BackgroundThread); - _eventAggregator.GetEvent().Subscribe(OnRegisterUser, ThreadOption.BackgroundThread); - _eventAggregator.GetEvent().Subscribe(OnLoginUser, ThreadOption.BackgroundThread); - _eventAggregator.GetEvent().Subscribe(OnLoginOut, ThreadOption.BackgroundThread); - _eventAggregator.GetEvent().Subscribe(OnUpdateUserRole, ThreadOption.BackgroundThread); - } + _eventAggregator = eventAggregator; + _logger = logger; + SubscribeEvents(); + } - public void OnAddUser(SysUser payload) - { - try - { - _logger.Information($"添加新用户: {payload.Account}"); - } - catch (Exception ex) - { - _logger.Error($"添加用户事件处理失败: {ex.Message}", ex); - } - } + private void SubscribeEvents() + { + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnAddUser, ThreadOption.BackgroundThread)); + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnUpdateUser, ThreadOption.BackgroundThread)); + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnDeleteUser, ThreadOption.BackgroundThread)); + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnSetUserStatus, ThreadOption.BackgroundThread)); + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnChangePwd, ThreadOption.BackgroundThread)); + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnResetPwd, ThreadOption.BackgroundThread)); + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnUnlockUserLogin, ThreadOption.BackgroundThread)); + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnRegisterUser, ThreadOption.BackgroundThread)); + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnLoginUser, ThreadOption.BackgroundThread)); + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnLoginOut, ThreadOption.BackgroundThread)); + _subscriptions.Add(_eventAggregator.GetEvent().Subscribe(OnUpdateUserRole, ThreadOption.BackgroundThread)); + } - public void OnRegisterUser(SysUser payload) - { - try - { - Task.Run(() => { - - - }); - _logger.Information($"用户注册"); - } - catch (Exception ex) - { - _logger.Error($"注册用户事件处理失败: {ex.Message}", ex); - } - } + public void OnAddUser(SysUser payload) + { + _logger.Information($"[本地] 新增用户: {payload?.Account}"); + } - public void OnUpdateUser((SysUser Original, SysUser Updated) payload) - { - try - { - _logger.Information($"更新用户"); - } - catch (Exception ex) - { - _logger.Error($"更新用户事件处理失败: {ex.Message}", ex); - } - } + public void OnRegisterUser(SysUser payload) + { + _logger.Information($"[本地] 用户注册: {payload?.Account}"); + } - public void OnDeleteUser(SysUser payload) - { - try - { - } - catch (Exception ex) - { - _logger.Error($"删除用户事件处理失败: {ex.Message}", ex); - } - } + public void OnUpdateUser((SysUser Original, SysUser Updated) payload) + { + _logger.Information($"[本地] 修改用户: {payload.Updated?.Account}"); + } - public void OnSetUserStatus((SysUser User, StatusEnum NewStatus) payload) - { - try - { - } - catch (Exception ex) - { - _logger.Error($"设置用户状态事件处理失败: {ex.Message}", ex); - } - } - public void OnChangePwd(SysUser payload) - { - try - { - } - catch (Exception ex) - { - _logger.Error($"设置用户状态事件处理失败: {ex.Message}", ex); - } - } - public void OnUpdateUserRole((SysUser User, List RoleIds) payload) - { - try - { - } - catch (Exception ex) - { - _logger.Error($"更新用户角色事件处理失败: {ex.Message}", ex); - } - } + public void OnDeleteUser(SysUser payload) + { + _logger.Information($"[本地] 删除用户: {payload?.Account}"); + } - public void OnUnlockUserLogin(SysUser payload) - { - try - { - } - catch (Exception ex) - { - _logger.Error($"解除登录锁定事件处理失败: {ex.Message}", ex); - } - } + public void OnSetUserStatus((SysUser User, StatusEnum NewStatus) payload) + { + _logger.Information($"[本地] 设置状态: {payload.User?.Account} → {payload.NewStatus}"); + } - public void OnResetPwd(SysUser payload) - { - throw new NotImplementedException(); - } + public void OnChangePwd(SysUser payload) + { + _logger.Information($"[本地] 修改密码: {payload?.Account}"); + } - public void OnLoginUser(SysUser payload) - { - try - { - _logger.Information($"登录成功"); - } - catch (Exception ex) - { - _logger.Error($"登录处理失败: {ex.Message}", ex); - } - } + public void OnResetPwd(SysUser payload) + { + _logger.Information($"[本地] 重置密码: {payload?.Account}"); + } - public void OnLoginOut(SysUser payload) + public void OnUpdateUserRole((SysUser User, List RoleIds) payload) + { + _logger.Information($"[本地] 更新角色: {payload.User?.Account}"); + } + + public void OnUnlockUserLogin(SysUser payload) + { + _logger.Information($"[本地] 解除锁定: {payload?.Account}"); + } + + public void OnLoginUser(SysUser payload) + { + _logger.Information($"[本地] 登录成功: {payload?.Account ?? ""}"); + } + + public void OnLoginOut(SysUser payload) + { + // 勿抛异常:Prism 事件总线上若此处抛错,可能影响同事件其它订阅者 + _logger.Information($"[本地] 用户登出: {payload?.Account ?? ""}"); + } + + public void Dispose() + { + foreach (var token in _subscriptions) { - // 勿抛异常:Prism 事件总线上若此处抛错,可能影响同事件其它订阅者(如主窗口释放与 WS 停止) - _logger.Information($"用户登出事件: {payload?.Account ?? ""}"); - } - public void Dispose() - { - // 显式取消所有订阅 - foreach (var token in _subscriptions) - { - token.Dispose(); - } + token.Dispose(); } } } diff --git a/yy-admin-master/YY.Admin/ViewModels/Base/BaseViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Base/BaseViewModel.cs index 262756c..c5861d0 100644 --- a/yy-admin-master/YY.Admin/ViewModels/Base/BaseViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/Base/BaseViewModel.cs @@ -10,6 +10,7 @@ using YY.Admin.Core.Const; using YY.Admin.Core.EventBus; using YY.Admin.Core.Session; using YY.Admin.Services.Service.Auth; +using YY.Admin.Services.Service.Config; using YY.Admin.Views; namespace YY.Admin.ViewModels @@ -87,15 +88,58 @@ namespace YY.Admin.ViewModels } #region 用户操作 + /// + /// 从系统配置应用登录状态检查间隔(分钟) + /// + private static void ApplyTokenCheckIntervalFromConfig() + { + var minutes = 1; + try + { + var cfg = Prism.Ioc.ContainerLocator.Current.Resolve(); + var v = cfg.GetConfigValue(ConfigConst.SysTokenCheckIntervalMinutes).GetAwaiter().GetResult(); + if (v >= 1 && v <= 120) + minutes = v; + } + catch + { + // 使用默认 1 分钟 + } + + if (_tokenCheckTimer != null) + _tokenCheckTimer.Interval = TimeSpan.FromMinutes(minutes); + } + + /// + /// 是否开启登录永不过期 + /// + private static bool IsTokenNeverExpireEnabled() + { + try + { + var cfg = Prism.Ioc.ContainerLocator.Current.Resolve(); + return cfg.GetConfigValue(ConfigConst.SysTokenNeverExpire).GetAwaiter().GetResult(); + } + catch + { + return false; + } + } + + /// + /// 登录设置保存后刷新检查间隔(已启动定时器时立即生效) + /// + public static void RefreshTokenCheckIntervalFromConfig() + { + ApplyTokenCheckIntervalFromConfig(); + } + // 启动定时器的方法 public static void StartTokenCheckTimer() { if (_tokenCheckTimer == null) { - _tokenCheckTimer = new DispatcherTimer - { - Interval = TimeSpan.FromMinutes(1) - }; + _tokenCheckTimer = new DispatcherTimer(); _tokenCheckTimer.Tick += CheckTokenExpiration; // 捕获全局用户输入事件 @@ -107,6 +151,8 @@ namespace YY.Admin.ViewModels new KeyEventHandler(OnUserActivity)); } + ApplyTokenCheckIntervalFromConfig(); + if (!_tokenCheckTimer.IsEnabled) { _tokenCheckTimer.Start(); @@ -124,6 +170,8 @@ namespace YY.Admin.ViewModels private static void OnUserActivity(object sender, EventArgs e) { + if (IsTokenNeverExpireEnabled()) + return; var authService = ContainerLocator.Current.Resolve(); authService.RefreshToken(UserContext?.Token?.AccessToken); } @@ -146,6 +194,9 @@ namespace YY.Admin.ViewModels return; } + if (IsTokenNeverExpireEnabled()) + return; + var authService = ContainerLocator.Current.Resolve(); if (!authService.ValidateToken(UserContext.Token.AccessToken)) { diff --git a/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs index f24b89a..ff98b5a 100644 --- a/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs @@ -4,6 +4,7 @@ using System.Windows.Threading; using YY.Admin.Core; using YY.Admin.Core.Util; using YY.Admin.Event; +using Prism.Events; using YY.Admin.Module; using YY.Admin.Services; using YY.Admin.Services.Service.Menu; @@ -64,7 +65,40 @@ namespace YY.Admin.ViewModels.Control ["/system/tenant"] = "TenantManagementView", ["/system/tenant/index"] = "TenantManagementView", ["/platform/tenant"] = "TenantManagementView", - ["sysTenant"] = "TenantManagementView" + ["sysTenant"] = "TenantManagementView", + + // 已实现页面:菜单管理 + ["MenuManagementView"] = "MenuManagementView", + ["/platform/menu"] = "MenuManagementView", + ["/system/menu/index"] = "MenuManagementView", + ["sysMenu"] = "MenuManagementView", + + // 已实现页面:登录设置 + ["LoginSettingsView"] = "LoginSettingsView", + ["/system/loginSettings"] = "LoginSettingsView", + ["/system/login-setting"] = "LoginSettingsView", + ["/system/login-settings"] = "LoginSettingsView", + ["loginSettings"] = "LoginSettingsView", + ["登录设置"] = "LoginSettingsView", + ["登陆设置"] = "LoginSettingsView", + + // 已实现页面:车辆管理 + ["VehicleListView"] = "VehicleListView", + ["/xslmes/mesXslVehicle"] = "VehicleListView", + ["/xslmes/vehicle"] = "VehicleListView", + ["mesXslVehicle"] = "VehicleListView", + + // 已实现页面:客户管理 + ["CustomerListView"] = "CustomerListView", + ["/xslmes/mesXslCustomer"] = "CustomerListView", + ["/xslmes/customer"] = "CustomerListView", + ["mesXslCustomer"] = "CustomerListView", + + // 已实现页面:供应商管理 + ["SupplierListView"] = "SupplierListView", + ["/xslmes/mesXslSupplier"] = "SupplierListView", + ["/xslmes/supplier"] = "SupplierListView", + ["mesXslSupplier"] = "SupplierListView" }; private MenuItem? _selectedMenuItem; @@ -83,6 +117,7 @@ namespace YY.Admin.ViewModels.Control private SubscriptionToken? tabSelectedToken; private SubscriptionToken? tabClosedToken; + private SubscriptionToken? _menuStructureToken; public MenuTreeViewModel( ISysMenuService sysMenuService, @@ -99,10 +134,30 @@ namespace YY.Admin.ViewModels.Control // 订阅事件 tabSelectedToken = _eventAggregator.GetEvent().Subscribe(OnTabSelected); tabClosedToken = _eventAggregator.GetEvent().Subscribe(OnTabClosed); + _menuStructureToken = _eventAggregator.GetEvent() + .Subscribe(async _ => await ReloadMenusAsync(), ThreadOption.UIThread); + } + + /// + /// 菜单数据变更后刷新左侧树(不重复执行默认页签导航) + /// + private async Task ReloadMenusAsync() + { + try + { + var menuTree = await _sysMenuService.GetLoginMenuTree(); + MenuItems.Clear(); + ConvertMenuTreeToViewModel(menuTree); + } + catch (Exception ex) + { + _logger.Error($"菜单重新加载失败: {ex.Message}", ex); + } } public void OpenOrActivateTab(MenuItem menuItem) { + _logger.Debug($"菜单点击触发: Name={menuItem?.Name}, ViewName={menuItem?.ViewName}, ChildrenCount={menuItem?.Children?.Count ?? 0}"); // 发布事件 _eventAggregator.GetEvent().Publish(menuItem); } @@ -169,6 +224,7 @@ namespace YY.Admin.ViewModels.Control /// private void ConvertMenuTreeToViewModel(List menuTree) { + MenuItems.Clear(); // 过滤并排序菜单项:只包含目录和菜单类型,排除按钮类型,并按排序号排序 var rootMenus = menuTree .Where(m => m.Type == MenuTypeEnum.Dir || m.Type == MenuTypeEnum.Menu) @@ -244,10 +300,14 @@ namespace YY.Admin.ViewModels.Control continue; if (RouteToViewMap.TryGetValue(candidate.Trim(), out var viewName)) + { + _logger.Debug($"菜单路由命中: Title={menu.Title}, Candidate={candidate}, View={viewName}"); return viewName; + } } // 保留原始Path,若未注册将统一展示NotFoundView + _logger.Warning($"菜单路由未命中映射: Title={menu.Title}, Path={menu.Path}, Component={menu.Component}, Name={menu.Name}"); return menu.Path; } @@ -336,6 +396,12 @@ namespace YY.Admin.ViewModels.Control .Unsubscribe(tabClosedToken); tabClosedToken = null; } + + if (_menuStructureToken != null) + { + _eventAggregator.GetEvent().Unsubscribe(_menuStructureToken); + _menuStructureToken = null; + } } /// diff --git a/yy-admin-master/YY.Admin/ViewModels/Customer/CustomerEditDialogViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Customer/CustomerEditDialogViewModel.cs new file mode 100644 index 0000000..344db3f --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/Customer/CustomerEditDialogViewModel.cs @@ -0,0 +1,138 @@ +using HandyControl.Controls; +using HandyControl.Tools.Extension; +using System.Collections.ObjectModel; +using YY.Admin.Core; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Services; +using YY.Admin.Services.Service; + +namespace YY.Admin.ViewModels.Customer; + +public class CustomerEditDialogViewModel : BaseViewModel, IDialogResultable +{ + private readonly ICustomerService _customerService; + private readonly IJeecgDictSyncService _dictSyncService; + + private MesXslCustomer? _customer; + public MesXslCustomer? Customer + { + get => _customer; + set => SetProperty(ref _customer, value); + } + + public bool IsAddMode => string.IsNullOrWhiteSpace(Customer?.Id) || (Customer?.Id?.StartsWith("local-") ?? false); + public string DialogTitle => IsAddMode ? "新增客户" : "编辑客户"; + + public ObservableCollection> StatusOptions { get; } = new(); + public ObservableCollection> CustomerRegionOptions { get; } = new(); + + private bool _result; + public bool Result { get => _result; set => SetProperty(ref _result, value); } + public Action? CloseAction { get; set; } + + public DelegateCommand SaveCommand { get; } + public DelegateCommand CancelCommand { get; } + + public CustomerEditDialogViewModel( + ICustomerService customerService, + IJeecgDictSyncService dictSyncService, + IContainerExtension container, + IRegionManager regionManager) : base(container, regionManager) + { + _customerService = customerService; + _dictSyncService = dictSyncService; + SaveCommand = new DelegateCommand(async () => await SaveAsync()); + CancelCommand = new DelegateCommand(() => CloseAction?.Invoke()); + _ = LoadDictOptionsAsync(); + } + + private async Task LoadDictOptionsAsync() + { + try + { + var statusOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_customer_status"); + var regionOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_customer_region"); + + StatusOptions.Clear(); + foreach (var item in statusOpts) StatusOptions.Add(item); + CustomerRegionOptions.Clear(); + foreach (var item in regionOpts) CustomerRegionOptions.Add(item); + + if (StatusOptions.Count == 0) + { + StatusOptions.Add(new KeyValuePair("启用", "0")); + StatusOptions.Add(new KeyValuePair("停用", "1")); + } + } + catch + { + StatusOptions.Clear(); + StatusOptions.Add(new KeyValuePair("启用", "0")); + StatusOptions.Add(new KeyValuePair("停用", "1")); + CustomerRegionOptions.Clear(); + } + } + + public void InitializeForAdd() + { + Customer = new MesXslCustomer { Status = "0" }; + RaisePropertyChanged(nameof(IsAddMode)); + RaisePropertyChanged(nameof(DialogTitle)); + } + + public void InitializeForEdit(MesXslCustomer customer) + { + Customer = new MesXslCustomer + { + Id = customer.Id, + CustomerCode = customer.CustomerCode, + CustomerName = customer.CustomerName, + CustomerShortName = customer.CustomerShortName, + CustomerRegion = customer.CustomerRegion, + ErpCode = customer.ErpCode, + Status = customer.Status, + IzEnable = customer.IzEnable, + CustomerDesc = customer.CustomerDesc, + TenantId = customer.TenantId, + }; + RaisePropertyChanged(nameof(IsAddMode)); + RaisePropertyChanged(nameof(DialogTitle)); + } + + private async Task SaveAsync() + { + if (Customer == null) return; + if (string.IsNullOrWhiteSpace(Customer.CustomerCode)) + { + HandyControl.Controls.MessageBox.Warning("客户编码不能为空!"); + return; + } + if (string.IsNullOrWhiteSpace(Customer.CustomerName)) + { + HandyControl.Controls.MessageBox.Warning("客户名称不能为空!"); + return; + } + + try + { + bool ok; + if (IsAddMode) + { + ok = await _customerService.AddAsync(Customer); + if (ok) HandyControl.Controls.MessageBox.Success("新增客户成功!"); + else { HandyControl.Controls.MessageBox.Error("新增客户失败!"); return; } + } + else + { + ok = await _customerService.EditAsync(Customer); + if (!ok) { HandyControl.Controls.MessageBox.Error("编辑客户失败!"); return; } + } + Result = ok; + CloseAction?.Invoke(); + } + catch (Exception ex) + { + HandyControl.Controls.MessageBox.Error($"操作失败:{ex.Message}"); + } + } +} diff --git a/yy-admin-master/YY.Admin/ViewModels/Customer/CustomerListViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Customer/CustomerListViewModel.cs new file mode 100644 index 0000000..cdbf818 --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/Customer/CustomerListViewModel.cs @@ -0,0 +1,288 @@ +using HandyControl.Controls; +using HandyControl.Tools.Extension; +using Prism.Events; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Windows; +using YY.Admin.Core; +using YY.Admin.Core.Events; +using YY.Admin.Core.Helper; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Services; +using YY.Admin.Services.Service; +using YY.Admin.Services.Service.Customer; +using YY.Admin.Views.Customer; + +namespace YY.Admin.ViewModels.Customer; + +public class CustomerListViewModel : BaseViewModel +{ + private readonly ICustomerService _customerService; + private readonly IJeecgDictSyncService _dictSyncService; + private readonly IDialogService _dialogService; + private SubscriptionToken? _customerChangedToken; + private SubscriptionToken? _syncConflictToken; + + private ObservableCollection _customers = new(); + public ObservableCollection Customers + { + get => _customers; + set => SetProperty(ref _customers, value); + } + + private long _total; + public long Total { get => _total; set => SetProperty(ref _total, value); } + + private int _pageNo = 1; + public int PageNo { get => _pageNo; set => SetProperty(ref _pageNo, value); } + + private int _pageSize = 20; + public int PageSize { get => _pageSize; set => SetProperty(ref _pageSize, value); } + + private string? _filterCustomerCode; + public string? FilterCustomerCode { get => _filterCustomerCode; set => SetProperty(ref _filterCustomerCode, value); } + + private string? _filterCustomerName; + public string? FilterCustomerName { get => _filterCustomerName; set => SetProperty(ref _filterCustomerName, value); } + + private string? _filterStatus; + public string? FilterStatus { get => _filterStatus; set => SetProperty(ref _filterStatus, value); } + + private string? _filterCustomerRegion; + public string? FilterCustomerRegion { get => _filterCustomerRegion; set => SetProperty(ref _filterCustomerRegion, value); } + + public ObservableCollection> StatusOptions { get; } = new(); + public ObservableCollection> CustomerRegionOptions { get; } = new(); + + public DelegateCommand SearchCommand { get; } + public DelegateCommand ResetCommand { get; } + public DelegateCommand AddCommand { get; } + public DelegateCommand EditCommand { get; } + public DelegateCommand DeleteCommand { get; } + public DelegateCommand ToggleStatusCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + + public CustomerListViewModel( + ICustomerService customerService, + IJeecgDictSyncService dictSyncService, + IContainerExtension container, + IDialogService dialogService, + IRegionManager regionManager) : base(container, regionManager) + { + _customerService = customerService; + _dictSyncService = dictSyncService; + _dialogService = dialogService; + + SearchCommand = new DelegateCommand(async () => { PageNo = 1; await LoadAsync(); }); + ResetCommand = new DelegateCommand(async () => + { + FilterCustomerCode = null; FilterCustomerName = null; + FilterStatus = null; FilterCustomerRegion = null; + PageNo = 1; await LoadAsync(); + }); + AddCommand = new DelegateCommand(async () => await ShowAddDialogAsync()); + EditCommand = new DelegateCommand(async c => await ShowEditDialogAsync(c)); + DeleteCommand = new DelegateCommand(async c => await DeleteAsync(c)); + ToggleStatusCommand = new DelegateCommand(async c => await ToggleStatusAsync(c)); + PrevPageCommand = new DelegateCommand(async () => { if (PageNo > 1) { PageNo--; await LoadAsync(); } }); + NextPageCommand = new DelegateCommand(async () => { if ((long)PageNo * PageSize < Total) { PageNo++; await LoadAsync(); } }); + + _customerChangedToken = _eventAggregator.GetEvent() + .Subscribe(async p => await OnCustomerChangedAsync(p), ThreadOption.UIThread); + + _syncConflictToken = _eventAggregator.GetEvent() + .Subscribe(OnSyncConflict, ThreadOption.UIThread); + + _ = InitializeAsync(); + } + + private async Task OnCustomerChangedAsync(CustomerChangedPayload payload) + { + if (payload.Action == "edit" && !string.IsNullOrWhiteSpace(payload.CustomerId)) + { + await RefreshSingleAsync(payload.CustomerId!); + } + else + { + await LoadAsync(); + } + } + + private async Task RefreshSingleAsync(string customerId) + { + try + { + var updated = await _customerService.GetByIdAsync(customerId); + if (updated == null) return; + var idx = Customers.ToList().FindIndex(c => string.Equals(c.Id, customerId, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + Customers[idx] = updated; + } + catch (Exception ex) + { + Debug.WriteLine($"[客户] 单条刷新失败: {ex.Message}"); + } + } + + private void OnSyncConflict(SyncConflictPayload payload) + { + if (!string.Equals(payload.EntityName, "客户", StringComparison.OrdinalIgnoreCase)) return; + + var parts = new List(); + if (payload.PushedCount > 0) + parts.Add($"已同步 {payload.PushedCount} 条本地改动到服务器"); + if (payload.NewRecordsPushed > 0) + parts.Add($"已上传 {payload.NewRecordsPushed} 条本地新增记录"); + if (payload.ConflictCount > 0) + parts.Add($"{payload.ConflictCount} 条记录与服务器版本冲突,已保留服务器版本"); + + if (parts.Count == 0) return; + + var message = string.Join("\n", parts); + if (payload.ConflictCount > 0) + Growl.Warning(message); + else + Growl.Success(message); + } + + private async Task InitializeAsync() + { + try + { + await LoadDictOptionsAsync(); + await UIHelper.WaitForRenderAsync(); + await LoadAsync(); + } + catch (Exception ex) + { + Debug.WriteLine($"客户列表初始化失败: {ex.Message}"); + } + } + + private async Task LoadDictOptionsAsync() + { + try + { + var statusOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_customer_status", includeAll: true); + var regionOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_customer_region", includeAll: true); + + StatusOptions.Clear(); + foreach (var item in statusOpts) StatusOptions.Add(item); + CustomerRegionOptions.Clear(); + foreach (var item in regionOpts) CustomerRegionOptions.Add(item); + + if (StatusOptions.Count == 0) + { + StatusOptions.Add(new KeyValuePair("全部", "")); + StatusOptions.Add(new KeyValuePair("启用", "0")); + StatusOptions.Add(new KeyValuePair("停用", "1")); + } + if (CustomerRegionOptions.Count == 0) + CustomerRegionOptions.Add(new KeyValuePair("全部", "")); + } + catch + { + StatusOptions.Clear(); + StatusOptions.Add(new KeyValuePair("全部", "")); + StatusOptions.Add(new KeyValuePair("启用", "0")); + StatusOptions.Add(new KeyValuePair("停用", "1")); + CustomerRegionOptions.Clear(); + CustomerRegionOptions.Add(new KeyValuePair("全部", "")); + } + } + + public async Task LoadAsync() + { + try + { + IsLoading = true; + var result = await _customerService.PageAsync(PageNo, PageSize, + FilterCustomerCode, FilterCustomerName, FilterStatus, FilterCustomerRegion); + Customers = new ObservableCollection(result.Records); + Total = result.Total; + } + catch (Exception ex) + { + Growl.Error($"加载客户列表失败:{ex.Message}"); + } + finally + { + IsLoading = false; + } + } + + private async Task ShowAddDialogAsync() + { + try + { + var result = await HandyControl.Controls.Dialog.Show() + .Initialize(vm => vm.InitializeForAdd()) + .GetResultAsync(); + if (result) await LoadAsync(); + } + catch (Exception ex) + { + Growl.Error($"打开新增对话框失败:{ex.Message}"); + } + } + + private async Task ShowEditDialogAsync(MesXslCustomer customer) + { + if (customer == null) return; + try + { + var result = await HandyControl.Controls.Dialog.Show() + .Initialize(vm => vm.InitializeForEdit(customer)) + .GetResultAsync(); + if (result) await LoadAsync(); + } + catch (Exception ex) + { + Growl.Error($"打开编辑对话框失败:{ex.Message}"); + } + } + + private async Task DeleteAsync(MesXslCustomer customer) + { + if (customer?.Id == null) return; + var confirm = System.Windows.MessageBox.Show( + $"确定删除客户【{customer.CustomerName}】?此操作不可恢复!", + "确认删除", MessageBoxButton.OKCancel, MessageBoxImage.Question); + if (confirm != System.Windows.MessageBoxResult.OK) return; + + var ok = await _customerService.DeleteAsync(customer.Id); + if (ok) { Growl.Success("删除成功!"); await LoadAsync(); } + else Growl.Error("删除失败!"); + } + + private async Task ToggleStatusAsync(MesXslCustomer customer) + { + if (customer?.Id == null) return; + var newStatus = customer.Status == "1" ? "0" : "1"; + var ok = await _customerService.UpdateStatusAsync(customer.Id, newStatus); + if (ok) + { + // 客户列表离线场景下只改行对象字段,DataGrid 对计算列(StatusText)可能不会立即重绘。 + // 这里统一重载一次列表,确保状态、筛选结果和分页统计立即一致。 + await LoadAsync(); + } + else Growl.Error("状态切换失败!"); + } + + protected override void CleanUp() + { + base.CleanUp(); + if (_customerChangedToken != null) + { + _eventAggregator.GetEvent().Unsubscribe(_customerChangedToken); + _customerChangedToken = null; + } + + if (_syncConflictToken != null) + { + _eventAggregator.GetEvent().Unsubscribe(_syncConflictToken); + _syncConflictToken = null; + } + } +} diff --git a/yy-admin-master/YY.Admin/ViewModels/Dialogs/ServerSettingsDialogViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Dialogs/ServerSettingsDialogViewModel.cs index 1254a7b..f484305 100644 --- a/yy-admin-master/YY.Admin/ViewModels/Dialogs/ServerSettingsDialogViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/Dialogs/ServerSettingsDialogViewModel.cs @@ -44,6 +44,27 @@ namespace YY.Admin.ViewModels.Dialogs set => SetProperty(ref _errorMessage, value); } + private bool _disconnectConnection; + /// + /// 是否断开连接(true=断开,false=连接) + /// + public bool DisconnectConnection + { + get => _disconnectConnection; + set + { + if (SetProperty(ref _disconnectConnection, value)) + { + RaisePropertyChanged(nameof(IsConnectionFieldsEnabled)); + } + } + } + + /// + /// 连接参数是否可编辑(勾选断开连接后禁用)。 + /// + public bool IsConnectionFieldsEnabled => !DisconnectConnection; + public DelegateCommand SaveCommand { get; } public DelegateCommand CancelCommand { get; } public DialogCloseListener RequestClose { get; private set; } @@ -68,6 +89,7 @@ namespace YY.Admin.ViewModels.Dialogs : settings.WebSocketUrl; _loadedWebSocketUrl = WebSocketUrl; BasePath = string.IsNullOrWhiteSpace(settings.BasePath) ? "/jeecg-boot" : settings.BasePath; + DisconnectConnection = settings.DisconnectConnection; ErrorMessage = string.Empty; } @@ -102,7 +124,8 @@ namespace YY.Admin.ViewModels.Dialogs BaseScheme = "http", BasePath = basePath, WebSocketUrl = finalWsUrl, - WebSocketPath = DefaultWebSocketPath + WebSocketPath = DefaultWebSocketPath, + DisconnectConnection = DisconnectConnection }); RequestClose.Invoke(new DialogResult(ButtonResult.OK)); } diff --git a/yy-admin-master/YY.Admin/ViewModels/MainWindowViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/MainWindowViewModel.cs index 7b30958..51b33d3 100644 --- a/yy-admin-master/YY.Admin/ViewModels/MainWindowViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/MainWindowViewModel.cs @@ -12,6 +12,7 @@ using System.Windows.Threading; using YY.Admin.Core; using YY.Admin.Core.Const; using YY.Admin.Core.Model; +using YY.Admin.Core.Services; using YY.Admin.Core.Session; using YY.Admin.Core.Util; using YY.Admin.Event; @@ -20,6 +21,7 @@ using YY.Admin.Services.Service.Auth; using YY.Admin.Services.Service.Jeecg; using YY.Admin.Services.Service.Menu; using YY.Admin.ViewModels.Control; +using YY.Admin.Helper; namespace YY.Admin.ViewModels { @@ -220,8 +222,9 @@ namespace YY.Admin.ViewModels _refreshTabToken = _eventAggregator.GetEvent().Subscribe(OnRefreshTab); _loginOutToken = _eventAggregator.GetEvent().Subscribe(Destroy); - // Jeecg 用户增量同步:定时 + 可选 WebSocket(工控机断网续传) - _jeecgUserSyncCoordinator.Start(); + // 首次按默认连接;后续按本地保存状态恢复 + var settings = ServerSettingsStore.Load(); + IsServerConnectionEnabled = !settings.DisconnectConnection; // 主窗口底部连接状态圆点 _ = StartBackendConnectivityLoopAsync(_backendConnectivityCts.Token); @@ -251,6 +254,30 @@ namespace YY.Admin.ViewModels public Brush BackendConnectionStatusBrush => IsBackendConnected ? Brushes.LimeGreen : Brushes.Red; + private bool _isServerConnectionEnabled = true; + /// + /// 是否启用服务器连接(默认启用) + /// + public bool IsServerConnectionEnabled + { + get => _isServerConnectionEnabled; + set + { + if (SetProperty(ref _isServerConnectionEnabled, value)) + { + if (!value) + { + IsBackendConnected = false; + _jeecgUserSyncCoordinator.Stop(); + } + else + { + _jeecgUserSyncCoordinator.Start(); + } + } + } + } + public DelegateCommand LogoutCommand { get; } private void InitNavItems() @@ -355,7 +382,8 @@ namespace YY.Admin.ViewModels } else { - _logger.Error($"导航失败: {viewName}"); + var exMsg = result.Exception?.Message; + _logger.Error($"导航失败: {viewName}, Region={regionName}, Exception={exMsg}"); tcs.SetResult(false); } }, parameters); @@ -586,6 +614,26 @@ namespace YY.Admin.ViewModels while (!cancellationToken.IsCancellationRequested) { bool connected = false; + if (!IsServerConnectionEnabled) + { + try + { + await Application.Current.Dispatcher.InvokeAsync(() => { IsBackendConnected = false; }); + } + catch + { + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(ConnectivityCheckIntervalSeconds), cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + continue; + } try { // 每轮都重新读取配置,保存服务器设置后可即时生效 @@ -631,7 +679,24 @@ namespace YY.Admin.ViewModels private void OpenServerSettings() { - _dialogService.ShowDialog("ServerSettingsDialog", r => { }); + _dialogService.ShowDialog("ServerSettingsDialog", r => + { + if (r.Result == ButtonResult.OK) + { + var settings = ServerSettingsStore.Load(); + IsServerConnectionEnabled = !settings.DisconnectConnection; + if (settings.DisconnectConnection) + { + var signalService = _container.Resolve(); + _ = signalService.DisconnectAsync(); + } + else + { + var signalService = _container.Resolve(); + _ = signalService.ConnectUnifiedDeviceChannelAsync(CancellationToken.None); + } + } + }); } /// diff --git a/yy-admin-master/YY.Admin/ViewModels/Supplier/SupplierEditDialogViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Supplier/SupplierEditDialogViewModel.cs new file mode 100644 index 0000000..eceac0a --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/Supplier/SupplierEditDialogViewModel.cs @@ -0,0 +1,111 @@ +using HandyControl.Controls; +using HandyControl.Tools.Extension; +using System.Collections.ObjectModel; +using YY.Admin.Core; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Services; +using YY.Admin.Services.Service; + +namespace YY.Admin.ViewModels.Supplier; + +public class SupplierEditDialogViewModel : BaseViewModel, HandyControl.Tools.Extension.IDialogResultable +{ + private readonly ISupplierService _supplierService; + private readonly IJeecgDictSyncService _dictSyncService; + private bool _result; + public bool Result { get => _result; set => SetProperty(ref _result, value); } + public Action? CloseAction { get; set; } + + private MesXslSupplier? _supplier; + public MesXslSupplier? Supplier + { + get => _supplier; + set => SetProperty(ref _supplier, value); + } + + public bool IsAddMode => string.IsNullOrWhiteSpace(Supplier?.Id) || (Supplier?.Id?.StartsWith("local-") ?? false); + public string DialogTitle => IsAddMode ? "新增供应商" : "编辑供应商"; + public ObservableCollection> StatusOptions { get; } = new(); + public DelegateCommand SaveCommand { get; } + public DelegateCommand CancelCommand { get; } + + public SupplierEditDialogViewModel(ISupplierService supplierService, IJeecgDictSyncService dictSyncService, IContainerExtension container, IRegionManager regionManager) : base(container, regionManager) + { + _supplierService = supplierService; + _dictSyncService = dictSyncService; + SaveCommand = new DelegateCommand(async () => await SaveAsync()); + CancelCommand = new DelegateCommand(() => CloseAction?.Invoke()); + _ = LoadDictOptionsAsync(); + } + + private async Task LoadDictOptionsAsync() + { + try + { + var statusOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_supplier_status"); + StatusOptions.Clear(); + foreach (var item in statusOpts) StatusOptions.Add(item); + if (StatusOptions.Count == 0) + { + StatusOptions.Add(new KeyValuePair("启用", "0")); + StatusOptions.Add(new KeyValuePair("停用", "1")); + } + } + catch { } + } + + public void InitializeForAdd() + { + Supplier = new MesXslSupplier { Status = "0" }; + RaisePropertyChanged(nameof(IsAddMode)); + RaisePropertyChanged(nameof(DialogTitle)); + } + + public void InitializeForEdit(MesXslSupplier supplier) + { + Supplier = new MesXslSupplier + { + Id = supplier.Id, + SupplierCode = supplier.SupplierCode, + SupplierName = supplier.SupplierName, + SupplierShortName = supplier.SupplierShortName, + ErpCode = supplier.ErpCode, + Status = supplier.Status, + Remark = supplier.Remark, + TenantId = supplier.TenantId, + }; + RaisePropertyChanged(nameof(IsAddMode)); + RaisePropertyChanged(nameof(DialogTitle)); + } + + private async Task SaveAsync() + { + if (Supplier == null) return; + if (string.IsNullOrWhiteSpace(Supplier.SupplierCode)) + { + HandyControl.Controls.MessageBox.Warning("供应商编码不能为空!"); + return; + } + if (string.IsNullOrWhiteSpace(Supplier.SupplierName)) + { + HandyControl.Controls.MessageBox.Warning("供应商名称不能为空!"); + return; + } + + bool ok = IsAddMode ? await _supplierService.AddAsync(Supplier) : await _supplierService.EditAsync(Supplier); + if (ok) + { + if (IsAddMode) + { + HandyControl.Controls.MessageBox.Success("新增供应商成功!"); + } + Result = true; + CloseAction?.Invoke(); + return; + } + else + { + HandyControl.Controls.MessageBox.Error("保存供应商失败!"); + } + } +} diff --git a/yy-admin-master/YY.Admin/ViewModels/Supplier/SupplierListViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Supplier/SupplierListViewModel.cs new file mode 100644 index 0000000..18918eb --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/Supplier/SupplierListViewModel.cs @@ -0,0 +1,189 @@ +using HandyControl.Controls; +using HandyControl.Tools.Extension; +using Prism.Events; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Windows; +using YY.Admin.Core; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; +using YY.Admin.Services.Service; +using YY.Admin.Views.Supplier; + +namespace YY.Admin.ViewModels.Supplier; + +public class SupplierListViewModel : BaseViewModel +{ + private readonly ISupplierService _supplierService; + private readonly IJeecgDictSyncService _dictSyncService; + private SubscriptionToken? _supplierChangedToken; + private SubscriptionToken? _syncConflictToken; + + public ObservableCollection Suppliers { get; set; } = []; + public ObservableCollection> StatusOptions { get; } = []; + public long Total { get; set; } + public int PageNo { get; set; } = 1; + public int PageSize { get; set; } = 20; + public string? FilterSupplierCode { get; set; } + public string? FilterSupplierName { get; set; } + public string? FilterSupplierShortName { get; set; } + public string? FilterErpCode { get; set; } + public string? FilterStatus { get; set; } + + public DelegateCommand SearchCommand { get; } + public DelegateCommand ResetCommand { get; } + public DelegateCommand AddCommand { get; } + public DelegateCommand EditCommand { get; } + public DelegateCommand DeleteCommand { get; } + public DelegateCommand ToggleStatusCommand { get; } + + public SupplierListViewModel( + ISupplierService supplierService, + IJeecgDictSyncService dictSyncService, + IContainerExtension container, + IRegionManager regionManager) + : base(container, regionManager) + { + _supplierService = supplierService; + _dictSyncService = dictSyncService; + SearchCommand = new DelegateCommand(async () => { PageNo = 1; await LoadAsync(); }); + ResetCommand = new DelegateCommand(async () => + { + FilterSupplierCode = FilterSupplierName = FilterSupplierShortName = FilterErpCode = FilterStatus = null; + PageNo = 1; + await LoadAsync(); + }); + AddCommand = new DelegateCommand(async () => await ShowAddDialogAsync()); + EditCommand = new DelegateCommand(async s => await ShowEditDialogAsync(s)); + DeleteCommand = new DelegateCommand(async s => await DeleteAsync(s)); + ToggleStatusCommand = new DelegateCommand(async s => await ToggleStatusAsync(s)); + + _supplierChangedToken = _eventAggregator.GetEvent() + .Subscribe(async p => await OnSupplierChangedAsync(p), ThreadOption.UIThread); + _syncConflictToken = _eventAggregator.GetEvent() + .Subscribe(OnSyncConflict, ThreadOption.UIThread); + + _ = InitializeAsync(); + } + + private async Task InitializeAsync() + { + try + { + var statusOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_supplier_status", includeAll: true); + StatusOptions.Clear(); + foreach (var item in statusOpts) StatusOptions.Add(item); + if (StatusOptions.Count == 0) + { + StatusOptions.Add(new("全部", "")); + StatusOptions.Add(new("启用", "0")); + StatusOptions.Add(new("停用", "1")); + } + } + catch { } + await LoadAsync(); + } + + public async Task LoadAsync() + { + var result = await _supplierService.PageAsync( + PageNo, PageSize, + FilterSupplierCode, FilterSupplierName, + FilterSupplierShortName, FilterErpCode, FilterStatus); + Suppliers = new ObservableCollection(result.Records); + Total = result.Total; + RaisePropertyChanged(nameof(Suppliers)); + RaisePropertyChanged(nameof(Total)); + } + + private async Task OnSupplierChangedAsync(SupplierChangedPayload payload) + { + if (payload.Action == "edit" && !string.IsNullOrWhiteSpace(payload.SupplierId)) + { + await RefreshSingleAsync(payload.SupplierId!); + } + else + { + await LoadAsync(); + } + } + + private async Task RefreshSingleAsync(string supplierId) + { + try + { + var updated = await _supplierService.GetByIdAsync(supplierId); + if (updated == null) return; + var idx = Suppliers.ToList().FindIndex(s => string.Equals(s.Id, supplierId, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + { + Suppliers[idx] = updated; + RaisePropertyChanged(nameof(Suppliers)); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[供应商] 单条刷新失败: {ex.Message}"); + } + } + + private void OnSyncConflict(SyncConflictPayload payload) + { + if (!string.Equals(payload.EntityName, "供应商", StringComparison.Ordinal)) return; + + var parts = new List(); + if (payload.PushedCount > 0) + parts.Add($"已同步 {payload.PushedCount} 条本地改动到服务器"); + if (payload.NewRecordsPushed > 0) + parts.Add($"已上传 {payload.NewRecordsPushed} 条本地新增记录"); + if (payload.ConflictCount > 0) + parts.Add($"{payload.ConflictCount} 条记录与服务器版本冲突,已保留服务器版本"); + + if (parts.Count == 0) return; + + var message = string.Join("\n", parts); + if (payload.ConflictCount > 0) + Growl.Warning(message); + else + Growl.Success(message); + } + + private async Task ShowAddDialogAsync() + { + var ok = await HandyControl.Controls.Dialog.Show() + .Initialize(vm => vm.InitializeForAdd()) + .GetResultAsync(); + if (ok) await LoadAsync(); + } + + private async Task ShowEditDialogAsync(MesXslSupplier supplier) + { + if (supplier == null) return; + var ok = await HandyControl.Controls.Dialog.Show() + .Initialize(vm => vm.InitializeForEdit(supplier)) + .GetResultAsync(); + if (ok) await LoadAsync(); + } + + private async Task DeleteAsync(MesXslSupplier supplier) + { + if (string.IsNullOrWhiteSpace(supplier?.Id)) return; + if (System.Windows.MessageBox.Show( + $"确定删除供应商【{supplier.SupplierName}】?", + "确认删除", + System.Windows.MessageBoxButton.OKCancel) != System.Windows.MessageBoxResult.OK) return; + if (await _supplierService.DeleteAsync(supplier.Id)) await LoadAsync(); + } + + private async Task ToggleStatusAsync(MesXslSupplier supplier) + { + if (string.IsNullOrWhiteSpace(supplier?.Id)) return; + var newStatus = supplier.Status == "1" ? "0" : "1"; + if (await _supplierService.UpdateStatusAsync(supplier.Id, newStatus)) + { + supplier.Status = newStatus; + RaisePropertyChanged(nameof(Suppliers)); + } + } +} diff --git a/yy-admin-master/YY.Admin/ViewModels/SysManage/LoginSettingsViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/SysManage/LoginSettingsViewModel.cs new file mode 100644 index 0000000..9df6d02 --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/SysManage/LoginSettingsViewModel.cs @@ -0,0 +1,166 @@ +using HandyControl.Controls; +using Prism.Commands; +using Prism.Ioc; +using YY.Admin.Core.Const; +using YY.Admin.Services.Service.Config; + +namespace YY.Admin.ViewModels.SysManage; + +/// +/// 登录与会话相关系统配置 +/// +public class LoginSettingsViewModel : BaseViewModel +{ + private readonly ISysConfigService _configService; + + private int _tokenExpireMinutes = 30; + public int TokenExpireMinutes + { + get => _tokenExpireMinutes; + set => SetProperty(ref _tokenExpireMinutes, value); + } + + private int _refreshTokenExpireMinutes = 20160; + public int RefreshTokenExpireMinutes + { + get => _refreshTokenExpireMinutes; + set => SetProperty(ref _refreshTokenExpireMinutes, value); + } + + private int _idleExtendMinutes = 20; + public int IdleExtendMinutes + { + get => _idleExtendMinutes; + set => SetProperty(ref _idleExtendMinutes, value); + } + + private int _checkIntervalMinutes = 1; + public int CheckIntervalMinutes + { + get => _checkIntervalMinutes; + set => SetProperty(ref _checkIntervalMinutes, value); + } + + private bool _neverExpire; + public bool NeverExpire + { + get => _neverExpire; + set + { + if (SetProperty(ref _neverExpire, value)) + RaisePropertyChanged(nameof(IsConfigEditable)); + } + } + + public bool IsConfigEditable => !NeverExpire; + + public DelegateCommand LoadCommand { get; } + public DelegateCommand SaveCommand { get; } + + public LoginSettingsViewModel( + ISysConfigService configService, + IContainerExtension container, + IRegionManager regionManager) : base(container, regionManager) + { + _configService = configService; + LoadCommand = new DelegateCommand(async () => await LoadAsync()); + SaveCommand = new DelegateCommand(async () => await SaveAsync()); + _ = LoadAsync(); + } + + private async Task LoadAsync() + { + try + { + IsLoading = true; + var t = await _configService.GetConfigValue(ConfigConst.SysTokenExpire); + TokenExpireMinutes = t > 0 ? t : 30; + + var r = await _configService.GetConfigValue(ConfigConst.SysRefreshTokenExpire); + RefreshTokenExpireMinutes = r > 0 ? r : 20160; + + var i = await _configService.GetConfigValue(ConfigConst.SysTokenIdleExtendMinutes); + IdleExtendMinutes = i > 0 ? i : 20; + + var c = await _configService.GetConfigValue(ConfigConst.SysTokenCheckIntervalMinutes); + CheckIntervalMinutes = c > 0 ? c : 1; + + NeverExpire = await _configService.GetConfigValue(ConfigConst.SysTokenNeverExpire); + } + catch (Exception ex) + { + Growl.Error($"加载配置失败:{ex.Message}"); + } + finally + { + IsLoading = false; + } + } + + private async Task SaveAsync() + { + if (!NeverExpire && (TokenExpireMinutes < 5 || TokenExpireMinutes > 43200)) + { + Growl.Warning("Token 过期时间建议在 5~43200 分钟之间"); + return; + } + + if (!NeverExpire && (IdleExtendMinutes < 1 || IdleExtendMinutes > 1440)) + { + Growl.Warning("会话续期阈值建议在 1~1440 分钟之间"); + return; + } + + if (!NeverExpire && IdleExtendMinutes > TokenExpireMinutes) + { + Growl.Warning("续期阈值不应大于 Token 过期时间"); + return; + } + + if (!NeverExpire && (CheckIntervalMinutes < 1 || CheckIntervalMinutes > 120)) + { + Growl.Warning("登录状态检查间隔建议在 1~120 分钟之间"); + return; + } + + if (!NeverExpire && RefreshTokenExpireMinutes < TokenExpireMinutes * 2) + { + Growl.Warning("RefreshToken 过期时间建议不小于 Token 过期时间的 2 倍"); + return; + } + + try + { + IsLoading = true; + var pairs = new[] + { + (ConfigConst.SysTokenExpire, TokenExpireMinutes.ToString()), + (ConfigConst.SysRefreshTokenExpire, RefreshTokenExpireMinutes.ToString()), + (ConfigConst.SysTokenIdleExtendMinutes, IdleExtendMinutes.ToString()), + (ConfigConst.SysTokenCheckIntervalMinutes, CheckIntervalMinutes.ToString()), + (ConfigConst.SysTokenNeverExpire, NeverExpire.ToString()), + }; + + foreach (var (code, val) in pairs) + { + var (ok, msg) = await _configService.SetConfigValueAsync(code, val); + if (!ok) + { + Growl.Warning($"{code}:{msg}"); + return; + } + } + + Growl.Success("登录相关配置已保存"); + BaseViewModel.RefreshTokenCheckIntervalFromConfig(); + } + catch (Exception ex) + { + Growl.Error($"保存失败:{ex.Message}"); + } + finally + { + IsLoading = false; + } + } +} diff --git a/yy-admin-master/YY.Admin/ViewModels/SysManage/MenuManagementViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/SysManage/MenuManagementViewModel.cs new file mode 100644 index 0000000..7eceeaf --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/SysManage/MenuManagementViewModel.cs @@ -0,0 +1,390 @@ +using System.Collections.ObjectModel; +using System.Linq; +using HandyControl.Controls; +using Prism.Commands; +using Prism.Mvvm; +using YY.Admin.Core; +using YY.Admin.Core.Extension; +using YY.Admin.Event; +using YY.Admin.Services.Service.Menu; + +namespace YY.Admin.ViewModels.SysManage; + +/// +/// 左侧列表一行(树形扁平展示) +/// +public sealed class MenuFlatRow +{ + public SysMenu Menu { get; } + public int Depth { get; } + public string IndentTitle { get; } + + public MenuFlatRow(SysMenu menu, int depth) + { + Menu = menu; + Depth = depth; + var pad = new string(' ', depth); + var tag = menu.Type == MenuTypeEnum.Dir ? "[目录] " : menu.Type == MenuTypeEnum.Btn ? "[按钮] " : "[菜单] "; + IndentTitle = pad + tag + menu.Title; + } +} + +/// +/// 可编辑字段(与界面绑定) +/// +public class MenuEditorModel : BindableBase +{ + private long _id; + private long _pid; + private MenuTypeEnum _type = MenuTypeEnum.Menu; + private string? _name; + private string? _path; + private string? _component; + private string? _redirect; + private string? _permission; + private string _title = string.Empty; + private string? _icon; + private bool _isIframe; + private string? _outLink; + private bool _isHide; + private bool _isKeepAlive = true; + private bool _isAffix; + private int _orderNo = 100; + private StatusEnum _status = StatusEnum.Enable; + private string? _remark; + + public long Id { get => _id; set => SetProperty(ref _id, value); } + public long Pid { get => _pid; set => SetProperty(ref _pid, value); } + public MenuTypeEnum Type { get => _type; set => SetProperty(ref _type, value); } + public string? Name { get => _name; set => SetProperty(ref _name, value); } + public string? Path { get => _path; set => SetProperty(ref _path, value); } + public string? Component { get => _component; set => SetProperty(ref _component, value); } + public string? Redirect { get => _redirect; set => SetProperty(ref _redirect, value); } + public string? Permission { get => _permission; set => SetProperty(ref _permission, value); } + public string Title { get => _title; set => SetProperty(ref _title, value); } + public string? Icon { get => _icon; set => SetProperty(ref _icon, value); } + public bool IsIframe { get => _isIframe; set => SetProperty(ref _isIframe, value); } + public string? OutLink { get => _outLink; set => SetProperty(ref _outLink, value); } + public bool IsHide { get => _isHide; set => SetProperty(ref _isHide, value); } + public bool IsKeepAlive { get => _isKeepAlive; set => SetProperty(ref _isKeepAlive, value); } + public bool IsAffix { get => _isAffix; set => SetProperty(ref _isAffix, value); } + public int OrderNo { get => _orderNo; set => SetProperty(ref _orderNo, value); } + public StatusEnum Status { get => _status; set => SetProperty(ref _status, value); } + public string? Remark { get => _remark; set => SetProperty(ref _remark, value); } + + public bool IsNew => Id == 0; + + public void LoadFrom(SysMenu m) + { + Id = m.Id; + Pid = m.Pid; + Type = m.Type; + Name = m.Name; + Path = m.Path; + Component = m.Component; + Redirect = m.Redirect; + Permission = m.Permission; + Title = m.Title; + Icon = m.Icon; + IsIframe = m.IsIframe; + OutLink = m.OutLink; + IsHide = m.IsHide; + IsKeepAlive = m.IsKeepAlive; + IsAffix = m.IsAffix; + OrderNo = m.OrderNo; + Status = m.Status; + Remark = m.Remark; + } + + public SysMenu ToSysMenu() + { + return new SysMenu + { + Id = Id, + Pid = Pid, + Type = Type, + Name = Name, + Path = Path, + Component = Component, + Redirect = Redirect, + Permission = Permission, + Title = Title.Trim(), + Icon = Icon, + IsIframe = IsIframe, + OutLink = OutLink, + IsHide = IsHide, + IsKeepAlive = IsKeepAlive, + IsAffix = IsAffix, + OrderNo = OrderNo, + Status = Status, + Remark = Remark + }; + } + + public void ResetForNew(long pid, MenuTypeEnum type) + { + Id = 0; + Pid = pid; + Type = type; + Name = null; + Path = null; + Component = null; + Redirect = null; + Permission = null; + Title = type == MenuTypeEnum.Dir ? "新目录" : type == MenuTypeEnum.Menu ? "新菜单" : "新按钮"; + Icon = ""; + IsIframe = false; + OutLink = null; + IsHide = false; + IsKeepAlive = true; + IsAffix = false; + OrderNo = 100; + Status = StatusEnum.Enable; + Remark = null; + } +} + +public class MenuManagementViewModel : BaseViewModel +{ + private readonly ISysMenuService _menuService; + + public ObservableCollection FlatRows { get; } = new(); + public ObservableCollection> ParentOptions { get; } = new(); + + private MenuFlatRow? _selectedRow; + public MenuFlatRow? SelectedRow + { + get => _selectedRow; + set + { + if (SetProperty(ref _selectedRow, value)) + { + if (value == null) + { + Editor = null; + RaisePropertyChanged(nameof(Editor)); + RaisePropertyChanged(nameof(HasEditor)); + } + else + { + Editor ??= new MenuEditorModel(); + Editor.LoadFrom(value.Menu); + RaisePropertyChanged(nameof(Editor)); + RaisePropertyChanged(nameof(HasEditor)); + } + } + } + } + + private MenuEditorModel? _editor; + public MenuEditorModel? Editor + { + get => _editor; + set + { + if (SetProperty(ref _editor, value)) + { + RaisePropertyChanged(nameof(HasEditor)); + } + } + } + + public bool HasEditor => Editor != null; + + public List> StatusList => + Enum.GetValues(typeof(StatusEnum)) + .Cast() + .Select(e => new KeyValuePair(e.GetDescription(), (int)e)) + .ToList(); + + public List> MenuTypeList => + Enum.GetValues(typeof(MenuTypeEnum)) + .Cast() + .Select(e => new KeyValuePair(e.GetDescription(), (int)e)) + .ToList(); + + public DelegateCommand RefreshCommand { get; } + public DelegateCommand AddRootCommand { get; } + public DelegateCommand AddChildCommand { get; } + public DelegateCommand SaveCommand { get; } + public DelegateCommand DeleteCommand { get; } + + public MenuManagementViewModel( + ISysMenuService menuService, + IContainerExtension container, + IRegionManager regionManager) : base(container, regionManager) + { + _menuService = menuService; + + RefreshCommand = new DelegateCommand(async () => await RefreshAsync()); + AddRootCommand = new DelegateCommand(() => BeginNew(0, MenuTypeEnum.Dir)); + AddChildCommand = new DelegateCommand(BeginNewChild, () => SelectedRow != null) + .ObservesProperty(() => SelectedRow); + SaveCommand = new DelegateCommand(async () => await SaveAsync(), () => Editor != null) + .ObservesProperty(() => Editor); + DeleteCommand = new DelegateCommand(async () => await DeleteAsync(), () => SelectedRow != null && SelectedRow.Menu.Id != 0) + .ObservesProperty(() => SelectedRow); + + _ = RefreshAsync(); + } + + private async Task RefreshAsync() + { + try + { + IsLoading = true; + var all = await _menuService.GetAllMenusForManageAsync(); + RebuildFlat(all); + RebuildParentOptions(all); + } + catch (Exception ex) + { + Growl.Error($"加载菜单失败:{ex.Message}"); + } + finally + { + IsLoading = false; + } + } + + private void RebuildFlat(List all) + { + FlatRows.Clear(); + void Walk(long pid, int depth) + { + foreach (var m in all.Where(x => x.Pid == pid).OrderBy(x => x.OrderNo).ThenBy(x => x.Id)) + { + FlatRows.Add(new MenuFlatRow(m, depth)); + Walk(m.Id, depth + 1); + } + } + + Walk(0, 0); + } + + private void RebuildParentOptions(List all) + { + ParentOptions.Clear(); + ParentOptions.Add(new KeyValuePair(0, "(根目录)")); + void Walk(long pid, int depth) + { + foreach (var m in all.Where(x => x.Pid == pid).OrderBy(x => x.OrderNo).ThenBy(x => x.Id)) + { + var indent = new string(' ', depth); + ParentOptions.Add(new KeyValuePair(m.Id, indent + m.Title)); + Walk(m.Id, depth + 1); + } + } + + Walk(0, 0); + } + + private void BeginNew(long pid, MenuTypeEnum type) + { + // 先取消列表选中,避免 setter 把新建的 Editor 清空 + _selectedRow = null; + RaisePropertyChanged(nameof(SelectedRow)); + Editor = new MenuEditorModel(); + Editor.ResetForNew(pid, type); + RaisePropertyChanged(nameof(Editor)); + RaisePropertyChanged(nameof(HasEditor)); + } + + private void BeginNewChild() + { + if (SelectedRow == null) + return; + BeginNew(SelectedRow.Menu.Id, MenuTypeEnum.Menu); + } + + private async Task SaveAsync() + { + if (Editor == null) + return; + + try + { + IsLoading = true; + var entity = Editor.ToSysMenu(); + if (Editor.IsNew) + { + var (ok, msg, id) = await _menuService.CreateMenuAsync(entity); + if (ok) + { + Growl.Success(msg); + _eventAggregator.GetEvent().Publish(true); + await RefreshAsync(); + SelectedRow = FlatRows.FirstOrDefault(r => r.Menu.Id == id); + } + else + { + Growl.Warning(msg); + } + } + else + { + var (ok, msg) = await _menuService.UpdateMenuAsync(entity); + if (ok) + { + Growl.Success(msg); + _eventAggregator.GetEvent().Publish(true); + var keepId = entity.Id; + await RefreshAsync(); + SelectedRow = FlatRows.FirstOrDefault(r => r.Menu.Id == keepId); + } + else + { + Growl.Warning(msg); + } + } + } + catch (Exception ex) + { + Growl.Error($"保存失败:{ex.Message}"); + } + finally + { + IsLoading = false; + } + } + + private async Task DeleteAsync() + { + if (SelectedRow == null) + return; + + var r = System.Windows.MessageBox.Show( + $"确定删除「{SelectedRow.Menu.Title}」?若存在子菜单将无法删除。", + "确认删除", + System.Windows.MessageBoxButton.OKCancel, + System.Windows.MessageBoxImage.Question); + if (r != System.Windows.MessageBoxResult.OK) + return; + + try + { + IsLoading = true; + var (ok, msg) = await _menuService.DeleteMenuAsync(SelectedRow.Menu.Id); + if (ok) + { + Growl.Success(msg); + _eventAggregator.GetEvent().Publish(true); + Editor = null; + SelectedRow = null; + await RefreshAsync(); + } + else + { + Growl.Warning(msg); + } + } + catch (Exception ex) + { + Growl.Error($"删除失败:{ex.Message}"); + } + finally + { + IsLoading = false; + } + } +} diff --git a/yy-admin-master/YY.Admin/ViewModels/SysManage/UserEditDialogViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/SysManage/UserEditDialogViewModel.cs index 4b558c8..80e2eb8 100644 --- a/yy-admin-master/YY.Admin/ViewModels/SysManage/UserEditDialogViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/SysManage/UserEditDialogViewModel.cs @@ -135,14 +135,12 @@ namespace YY.Admin.ViewModels.SysManage SysUser = new SysUser { Id = user.Id, - //Account = user.Account, + Account = user.Account, RealName = user.RealName, NickName = user.NickName, - //Phone = user.Phone, Sex = user.Sex, Birthday = user.Birthday, Age = user.Age, - //AccountType = user.AccountType, Status = user.Status }; } diff --git a/yy-admin-master/YY.Admin/ViewModels/SysManage/UserManagementViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/SysManage/UserManagementViewModel.cs index 1861c5a..15c6364 100644 --- a/yy-admin-master/YY.Admin/ViewModels/SysManage/UserManagementViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/SysManage/UserManagementViewModel.cs @@ -6,6 +6,7 @@ using System.Windows; using YY.Admin.Core; using YY.Admin.Core.Extension; using YY.Admin.Core.Helper; +using YY.Admin.Infrastructure.Sync; using YY.Admin.Services.Service; using YY.Admin.Services.Service.User; using YY.Admin.ViewModels.Control; @@ -20,9 +21,11 @@ namespace YY.Admin.ViewModels.SysManage private PageUserInput _userInput; private readonly ISysUserService _sysUserService; + private readonly OutboxProcessor _outboxProcessor; private readonly IDialogService _dialogService; private SubscriptionToken? _jeecgSyncToken; + private bool _isManualUploading; public PaginationDataGridViewModel PaginationDataGridViewModel { get => _paginationDataGridViewModel; @@ -94,6 +97,7 @@ namespace YY.Admin.ViewModels.SysManage public DelegateCommand EditCommand { get; private set; } public DelegateCommand StatusToggleCommand { get; private set; } + public DelegateCommand ManualUploadCommand { get; private set; } // 行选择改变命令 @@ -101,12 +105,14 @@ namespace YY.Admin.ViewModels.SysManage public UserManagementViewModel( ISysUserService sysUserService, + OutboxProcessor outboxProcessor, IContainerExtension container, IDialogService dialogService, IRegionManager regionManager ) : base(container, regionManager) { _sysUserService= sysUserService; + _outboxProcessor = outboxProcessor; _dialogService = dialogService; // 创建分页控件的 ViewModel,传递一个获取数据的委托 _paginationDataGridViewModel = new PaginationDataGridViewModel(FetchUsersAsync); @@ -132,6 +138,7 @@ namespace YY.Admin.ViewModels.SysManage EditCommand = new DelegateCommand(async (user) => await ShowEditDialog(user)); StatusToggleCommand = new DelegateCommand(async (user) => await ToggleStatus(user)); + ManualUploadCommand = new DelegateCommand(async () => await ManualUploadAsync(), () => !_isManualUploading); //RowSelectionChangedCommand = new DelegateCommand(OnRowSelectionChanged); @@ -457,6 +464,31 @@ namespace YY.Admin.ViewModels.SysManage } } + private async Task ManualUploadAsync() + { + if (_isManualUploading) + { + return; + } + + _isManualUploading = true; + ManualUploadCommand.RaiseCanExecuteChanged(); + try + { + await _outboxProcessor.FlushPendingAsync(CancellationToken.None); + Growl.Success("已触发手动上传,请稍后查看后端数据。"); + } + catch (Exception ex) + { + Growl.Warning($"手动上传失败:{ex.Message}"); + } + finally + { + _isManualUploading = false; + ManualUploadCommand.RaiseCanExecuteChanged(); + } + } + // 全选/取消全选方法 //private void SelectAll(bool isSelected) //{ diff --git a/yy-admin-master/YY.Admin/ViewModels/Vehicle/VehicleEditDialogViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Vehicle/VehicleEditDialogViewModel.cs new file mode 100644 index 0000000..a8a3a0a --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/Vehicle/VehicleEditDialogViewModel.cs @@ -0,0 +1,176 @@ +using HandyControl.Controls; +using HandyControl.Tools.Extension; +using System.Collections.ObjectModel; +using YY.Admin.Core; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Services; +using YY.Admin.Services.Service; + +namespace YY.Admin.ViewModels.Vehicle; + +public class VehicleEditDialogViewModel : BaseViewModel, IDialogResultable +{ + private readonly IVehicleService _vehicleService; + private readonly IJeecgDictSyncService _dictSyncService; + + private MesXslVehicle? _vehicle; + public MesXslVehicle? Vehicle + { + get => _vehicle; + set => SetProperty(ref _vehicle, value); + } + + public bool IsAddMode => string.IsNullOrWhiteSpace(Vehicle?.Id); + public string DialogTitle => IsAddMode ? "新增车辆" : "编辑车辆"; + + public ObservableCollection> VehicleBelongOptions { get; } = new(); + + public ObservableCollection> StatusOptions { get; } = new(); + + private bool _result; + public bool Result { get => _result; set => SetProperty(ref _result, value); } + public Action? CloseAction { get; set; } + + public DelegateCommand SaveCommand { get; } + public DelegateCommand CancelCommand { get; } + + public VehicleEditDialogViewModel( + IVehicleService vehicleService, + IJeecgDictSyncService dictSyncService, + IContainerExtension container, + IRegionManager regionManager) : base(container, regionManager) + { + _vehicleService = vehicleService; + _dictSyncService = dictSyncService; + SaveCommand = new DelegateCommand(async () => await SaveAsync()); + CancelCommand = new DelegateCommand(() => CloseAction?.Invoke()); + _ = LoadDictOptionsAsync(); + } + + private async Task LoadDictOptionsAsync() + { + try + { + var belongOptions = await _dictSyncService.GetDictOptionsAsync("xslmes_vehicle_belong"); + var statusOptions = await _dictSyncService.GetDictOptionsAsync("xslmes_vehicle_status"); + + VehicleBelongOptions.Clear(); + foreach (var item in belongOptions) + { + VehicleBelongOptions.Add(item); + } + + StatusOptions.Clear(); + foreach (var item in statusOptions) + { + StatusOptions.Add(item); + } + + if (VehicleBelongOptions.Count == 0) + { + VehicleBelongOptions.Add(new KeyValuePair("客户", "1")); + VehicleBelongOptions.Add(new KeyValuePair("供应商", "2")); + VehicleBelongOptions.Add(new KeyValuePair("本公司", "3")); + } + if (StatusOptions.Count == 0) + { + StatusOptions.Add(new KeyValuePair("启用", "0")); + StatusOptions.Add(new KeyValuePair("停用", "1")); + } + } + catch + { + VehicleBelongOptions.Clear(); + VehicleBelongOptions.Add(new KeyValuePair("客户", "1")); + VehicleBelongOptions.Add(new KeyValuePair("供应商", "2")); + VehicleBelongOptions.Add(new KeyValuePair("本公司", "3")); + + StatusOptions.Clear(); + StatusOptions.Add(new KeyValuePair("启用", "0")); + StatusOptions.Add(new KeyValuePair("停用", "1")); + } + } + + public void InitializeForAdd() + { + Vehicle = new MesXslVehicle { Status = "0", VehicleBelong = "1" }; + RaisePropertyChanged(nameof(IsAddMode)); + RaisePropertyChanged(nameof(DialogTitle)); + } + + public void InitializeForEdit(MesXslVehicle vehicle) + { + Vehicle = new MesXslVehicle + { + Id = vehicle.Id, + PlateNumber = vehicle.PlateNumber, + VehicleBelong = vehicle.VehicleBelong, + TareWeightKg = vehicle.TareWeightKg, + LoadCapacity = vehicle.LoadCapacity, + UnitId = vehicle.UnitId, + LoadUnit = vehicle.LoadUnit, + CustomerIds = vehicle.CustomerIds, + CustomerShortName = vehicle.CustomerShortName, + SupplierId = vehicle.SupplierId, + SupplierName = vehicle.SupplierName, + SupplierShortName = vehicle.SupplierShortName, + VehicleLength = vehicle.VehicleLength, + VehicleWidth = vehicle.VehicleWidth, + VehicleHeight = vehicle.VehicleHeight, + DriverName = vehicle.DriverName, + DriverPhone = vehicle.DriverPhone, + Status = vehicle.Status, + TenantId = vehicle.TenantId, + }; + RaisePropertyChanged(nameof(IsAddMode)); + RaisePropertyChanged(nameof(DialogTitle)); + } + + private async Task SaveAsync() + { + if (Vehicle == null) return; + if (string.IsNullOrWhiteSpace(Vehicle.PlateNumber)) + { + HandyControl.Controls.MessageBox.Warning("车牌号不能为空!"); + return; + } + if (string.IsNullOrWhiteSpace(Vehicle.VehicleBelong)) + { + HandyControl.Controls.MessageBox.Warning("车辆归属不能为空!"); + return; + } + + try + { + bool ok; + if (IsAddMode) + { + ok = await _vehicleService.AddAsync(Vehicle); + if (ok) + { + HandyControl.Controls.MessageBox.Success("新增车辆成功!"); + } + else + { + HandyControl.Controls.MessageBox.Error("新增车辆失败!"); + return; + } + } + else + { + ok = await _vehicleService.EditAsync(Vehicle); + if (!ok) + { + HandyControl.Controls.MessageBox.Error("编辑车辆失败!"); + return; + } + } + Result = ok; + CloseAction?.Invoke(); + } + catch (Exception ex) + { + HandyControl.Controls.MessageBox.Error($"操作失败:{ex.Message}"); + } + } +} diff --git a/yy-admin-master/YY.Admin/ViewModels/Vehicle/VehicleListViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Vehicle/VehicleListViewModel.cs new file mode 100644 index 0000000..ade50a6 --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/Vehicle/VehicleListViewModel.cs @@ -0,0 +1,306 @@ +using HandyControl.Controls; +using HandyControl.Tools.Extension; +using Prism.Events; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Windows; +using YY.Admin.Core; +using YY.Admin.Core.Events; +using YY.Admin.Core.Helper; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Services; +using YY.Admin.Services.Service; +using YY.Admin.Views.Vehicle; + +namespace YY.Admin.ViewModels.Vehicle; + +public class VehicleListViewModel : BaseViewModel +{ + private readonly IVehicleService _vehicleService; + private readonly IJeecgDictSyncService _dictSyncService; + private readonly IDialogService _dialogService; + private SubscriptionToken? _vehicleChangedToken; + private SubscriptionToken? _syncConflictToken; + + private ObservableCollection _vehicles = new(); + public ObservableCollection Vehicles + { + get => _vehicles; + set => SetProperty(ref _vehicles, value); + } + + private long _total; + public long Total { get => _total; set => SetProperty(ref _total, value); } + + private int _pageNo = 1; + public int PageNo { get => _pageNo; set => SetProperty(ref _pageNo, value); } + + private int _pageSize = 20; + public int PageSize { get => _pageSize; set => SetProperty(ref _pageSize, value); } + + private string? _filterPlateNumber; + public string? FilterPlateNumber { get => _filterPlateNumber; set => SetProperty(ref _filterPlateNumber, value); } + + private string? _filterVehicleBelong; + public string? FilterVehicleBelong { get => _filterVehicleBelong; set => SetProperty(ref _filterVehicleBelong, value); } + + private string? _filterStatus; + public string? FilterStatus { get => _filterStatus; set => SetProperty(ref _filterStatus, value); } + + public ObservableCollection> VehicleBelongOptions { get; } = new(); + + public ObservableCollection> StatusOptions { get; } = new(); + + public DelegateCommand SearchCommand { get; } + public DelegateCommand ResetCommand { get; } + public DelegateCommand AddCommand { get; } + public DelegateCommand EditCommand { get; } + public DelegateCommand DeleteCommand { get; } + public DelegateCommand ToggleStatusCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + + public VehicleListViewModel( + IVehicleService vehicleService, + IJeecgDictSyncService dictSyncService, + IContainerExtension container, + IDialogService dialogService, + IRegionManager regionManager) : base(container, regionManager) + { + _vehicleService = vehicleService; + _dictSyncService = dictSyncService; + _dialogService = dialogService; + + SearchCommand = new DelegateCommand(async () => { PageNo = 1; await LoadAsync(); }); + ResetCommand = new DelegateCommand(async () => + { + FilterPlateNumber = null; + FilterVehicleBelong = null; + FilterStatus = null; + PageNo = 1; + await LoadAsync(); + }); + AddCommand = new DelegateCommand(async () => await ShowAddDialogAsync()); + EditCommand = new DelegateCommand(async v => await ShowEditDialogAsync(v)); + DeleteCommand = new DelegateCommand(async v => await DeleteAsync(v)); + ToggleStatusCommand = new DelegateCommand(async v => await ToggleStatusAsync(v)); + PrevPageCommand = new DelegateCommand(async () => { if (PageNo > 1) { PageNo--; await LoadAsync(); } }); + NextPageCommand = new DelegateCommand(async () => { if ((long)PageNo * PageSize < Total) { PageNo++; await LoadAsync(); } }); + + _vehicleChangedToken = _eventAggregator + .GetEvent() + .Subscribe(async p => await OnVehicleChangedAsync(p), ThreadOption.UIThread); + + _syncConflictToken = _eventAggregator.GetEvent() + .Subscribe(OnSyncConflict, ThreadOption.UIThread); + + _ = InitializeAsync(); + } + + private async Task OnVehicleChangedAsync(VehicleChangedPayload payload) + { + if (payload.Action == "edit" && !string.IsNullOrWhiteSpace(payload.VehicleId)) + { + await RefreshSingleAsync(payload.VehicleId!); + } + else + { + await LoadAsync(); + } + } + + private async Task RefreshSingleAsync(string vehicleId) + { + try + { + var updated = await _vehicleService.GetByIdAsync(vehicleId); + if (updated == null) return; + var idx = Vehicles.ToList().FindIndex(v => string.Equals(v.Id, vehicleId, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + Vehicles[idx] = updated; + } + catch (Exception ex) + { + Debug.WriteLine($"[车辆] 单条刷新失败: {ex.Message}"); + } + } + + private void OnSyncConflict(SyncConflictPayload payload) + { + if (!string.Equals(payload.EntityName, "车辆", StringComparison.OrdinalIgnoreCase)) return; + + var parts = new List(); + if (payload.PushedCount > 0) + parts.Add($"已同步 {payload.PushedCount} 条本地改动到服务器"); + if (payload.NewRecordsPushed > 0) + parts.Add($"已上传 {payload.NewRecordsPushed} 条本地新增记录"); + if (payload.ConflictCount > 0) + parts.Add($"{payload.ConflictCount} 条记录与服务器版本冲突,已保留服务器版本"); + + if (parts.Count == 0) return; + + var message = string.Join("\n", parts); + if (payload.ConflictCount > 0) + Growl.Warning(message); + else + Growl.Success(message); + } + + private async Task InitializeAsync() + { + try + { + await LoadDictOptionsAsync(); + await UIHelper.WaitForRenderAsync(); + await LoadAsync(); + } + catch (Exception ex) + { + Debug.WriteLine($"车辆列表初始化失败: {ex.Message}"); + } + } + + private async Task LoadDictOptionsAsync() + { + try + { + var belongOptions = await _dictSyncService.GetDictOptionsAsync("xslmes_vehicle_belong", includeAll: true); + var statusOptions = await _dictSyncService.GetDictOptionsAsync("xslmes_vehicle_status", includeAll: true); + + VehicleBelongOptions.Clear(); + foreach (var item in belongOptions) + { + VehicleBelongOptions.Add(item); + } + + StatusOptions.Clear(); + foreach (var item in statusOptions) + { + StatusOptions.Add(item); + } + + if (VehicleBelongOptions.Count == 0) + { + VehicleBelongOptions.Add(new KeyValuePair("全部", "")); + } + if (StatusOptions.Count == 0) + { + StatusOptions.Add(new KeyValuePair("全部", "")); + } + } + catch + { + VehicleBelongOptions.Clear(); + VehicleBelongOptions.Add(new KeyValuePair("全部", "")); + VehicleBelongOptions.Add(new KeyValuePair("客户", "1")); + VehicleBelongOptions.Add(new KeyValuePair("供应商", "2")); + VehicleBelongOptions.Add(new KeyValuePair("本公司", "3")); + + StatusOptions.Clear(); + StatusOptions.Add(new KeyValuePair("全部", "")); + StatusOptions.Add(new KeyValuePair("启用", "0")); + StatusOptions.Add(new KeyValuePair("停用", "1")); + } + } + + public async Task LoadAsync() + { + try + { + IsLoading = true; + var result = await _vehicleService.PageAsync(PageNo, PageSize, FilterPlateNumber, FilterVehicleBelong, FilterStatus); + Vehicles = new ObservableCollection(result.Records); + Total = result.Total; + } + catch (Exception ex) + { + Growl.Error($"加载车辆列表失败:{ex.Message}"); + } + finally + { + IsLoading = false; + } + } + + private async Task ShowAddDialogAsync() + { + try + { + var result = await HandyControl.Controls.Dialog.Show() + .Initialize(vm => vm.InitializeForAdd()) + .GetResultAsync(); + if (result) await LoadAsync(); + } + catch (Exception ex) + { + Growl.Error($"打开新增对话框失败:{ex.Message}"); + } + } + + private async Task ShowEditDialogAsync(MesXslVehicle vehicle) + { + if (vehicle == null) return; + try + { + var result = await HandyControl.Controls.Dialog.Show() + .Initialize(vm => vm.InitializeForEdit(vehicle)) + .GetResultAsync(); + if (result) await LoadAsync(); + } + catch (Exception ex) + { + Growl.Error($"打开编辑对话框失败:{ex.Message}"); + } + } + + private async Task DeleteAsync(MesXslVehicle vehicle) + { + if (vehicle?.Id == null) return; + var confirm = System.Windows.MessageBox.Show($"确定删除车辆 {vehicle.PlateNumber}?此操作不可恢复!", "确认删除", + MessageBoxButton.OKCancel, MessageBoxImage.Question); + if (confirm != System.Windows.MessageBoxResult.OK) return; + + var ok = await _vehicleService.DeleteAsync(vehicle.Id); + if (ok) + { + Growl.Success("删除成功!"); + await LoadAsync(); + } + else + { + Growl.Error("删除失败!"); + } + } + + private async Task ToggleStatusAsync(MesXslVehicle vehicle) + { + if (vehicle?.Id == null) return; + var newStatus = vehicle.Status == "1" ? "0" : "1"; + var ok = await _vehicleService.UpdateStatusAsync(vehicle.Id, newStatus); + if (ok) + { + vehicle.Status = newStatus; + RaisePropertyChanged(nameof(Vehicles)); + } + else + { + Growl.Error("状态切换失败!"); + } + } + + protected override void CleanUp() + { + base.CleanUp(); + if (_vehicleChangedToken != null) + { + _eventAggregator.GetEvent().Unsubscribe(_vehicleChangedToken); + _vehicleChangedToken = null; + } + + if (_syncConflictToken != null) + { + _eventAggregator.GetEvent().Unsubscribe(_syncConflictToken); + _syncConflictToken = null; + } + } +} diff --git a/yy-admin-master/YY.Admin/Views/Customer/CustomerEditDialogView.xaml b/yy-admin-master/YY.Admin/Views/Customer/CustomerEditDialogView.xaml new file mode 100644 index 0000000..40c5fae --- /dev/null +++ b/yy-admin-master/YY.Admin/Views/Customer/CustomerEditDialogView.xaml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/yy-admin-master/YY.Admin/Views/Supplier/SupplierListView.xaml.cs b/yy-admin-master/YY.Admin/Views/Supplier/SupplierListView.xaml.cs new file mode 100644 index 0000000..b5b5fb6 --- /dev/null +++ b/yy-admin-master/YY.Admin/Views/Supplier/SupplierListView.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace YY.Admin.Views.Supplier; + +public partial class SupplierListView : UserControl +{ + public SupplierListView() + { + InitializeComponent(); + } +} diff --git a/yy-admin-master/YY.Admin/Views/SysManage/LoginSettingsView.xaml b/yy-admin-master/YY.Admin/Views/SysManage/LoginSettingsView.xaml new file mode 100644 index 0000000..e9d0a67 --- /dev/null +++ b/yy-admin-master/YY.Admin/Views/SysManage/LoginSettingsView.xaml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/yy-admin-master/YY.Admin/Views/SysManage/LoginSettingsView.xaml.cs b/yy-admin-master/YY.Admin/Views/SysManage/LoginSettingsView.xaml.cs new file mode 100644 index 0000000..2336215 --- /dev/null +++ b/yy-admin-master/YY.Admin/Views/SysManage/LoginSettingsView.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace YY.Admin.Views.SysManage; + +public partial class LoginSettingsView : UserControl +{ + public LoginSettingsView() + { + InitializeComponent(); + } +} diff --git a/yy-admin-master/YY.Admin/Views/SysManage/MenuManagementView.xaml b/yy-admin-master/YY.Admin/Views/SysManage/MenuManagementView.xaml new file mode 100644 index 0000000..6566816 --- /dev/null +++ b/yy-admin-master/YY.Admin/Views/SysManage/MenuManagementView.xaml @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/yy-admin-master/YY.Admin/Views/SysManage/MenuManagementView.xaml.cs b/yy-admin-master/YY.Admin/Views/SysManage/MenuManagementView.xaml.cs new file mode 100644 index 0000000..66bea17 --- /dev/null +++ b/yy-admin-master/YY.Admin/Views/SysManage/MenuManagementView.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace YY.Admin.Views.SysManage; + +public partial class MenuManagementView : UserControl +{ + public MenuManagementView() + { + InitializeComponent(); + } +} diff --git a/yy-admin-master/YY.Admin/Views/SysManage/UserEditDialogView.xaml b/yy-admin-master/YY.Admin/Views/SysManage/UserEditDialogView.xaml index 48da3ed..ba83d26 100644 --- a/yy-admin-master/YY.Admin/Views/SysManage/UserEditDialogView.xaml +++ b/yy-admin-master/YY.Admin/Views/SysManage/UserEditDialogView.xaml @@ -28,18 +28,17 @@ - - + + hc:InfoElement.Symbol="*"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +