更新项目配置,新增设备同步模块,优化WebSocket和Swagger配置,增强SCADA系统的免登录接口,支持数据字典项和登录日志的免登录查询与记录。调整Java编译设置,确保更好的开发体验。

This commit is contained in:
geht
2026-04-28 10:23:58 +08:00
parent bbe46dcf2d
commit 142a0bdaba
1013 changed files with 41858 additions and 28 deletions

7
.vscode/keybindings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
[
{
"key": "ctrl+alt+r",
"command": "workbench.action.tasks.runTask",
"args": "YY.Admin: run"
}
]

53
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,53 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "YY.Admin: restore",
"type": "process",
"command": "C:\\Program Files\\dotnet\\dotnet.exe",
"args": ["restore", "YY.Admin.Entry.sln"],
"options": {
"cwd": "${workspaceFolder}\\yy-admin-master"
},
"problemMatcher": "$msCompile"
},
{
"label": "YY.Admin: build",
"type": "process",
"command": "C:\\Program Files\\dotnet\\dotnet.exe",
"args": ["build", "YY.Admin.Entry.sln", "-c", "Debug"],
"options": {
"cwd": "${workspaceFolder}\\yy-admin-master"
},
"dependsOn": "YY.Admin: restore",
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "YY.Admin: run",
"type": "process",
"command": "${workspaceFolder}\\yy-admin-master\\YY.Admin\\bin\\Debug\\net8.0-windows10.0.19041\\win-x64\\YY.Admin.exe",
"options": {
"cwd": "${workspaceFolder}\\yy-admin-master\\YY.Admin\\bin\\Debug\\net8.0-windows10.0.19041\\win-x64"
},
"dependsOn": "YY.Admin: build",
"problemMatcher": []
},
{
"label": "YY.Admin: run (script)",
"type": "process",
"command": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}\\yy-admin-master\\.vscode\\run-yyadmin.ps1"
],
"options": {
"cwd": "${workspaceFolder}\\yy-admin-master"
},
"problemMatcher": "$msCompile"
}
]
}

View File

@@ -0,0 +1,158 @@
# SCADA 用户同步接口文档
## 1. 目标与适用场景
本文档用于规范 SCADA 系统对 JeecgBoot 用户数据的读取方式,支持以下场景:
- SCADA 首次全量拉取用户并缓存到本地。
- SCADA 按更新时间进行增量同步(断网续传)。
- SCADA 按条件(用户名、工号、手机号)查询特定用户。
- 在需要时拉取用户的组织与租户详细信息。
---
## 2. 接口信息
- **接口名称**SCADA-免登录查询用户信息
- **请求方法**`GET`
- **接口路径**`/jeecg-boot/sys/user/scada/queryUser`
- **认证要求**:免登录(已加入后端白名单)
- **文档状态**:已在 Knife4j 中取消 `X-Access-Token` 必填限制
---
## 3. 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| `username` | string | 否 | - | 用户名,精确匹配 |
| `workNo` | string | 否 | - | 工号,精确匹配 |
| `phone` | string | 否 | - | 手机号,精确匹配 |
| `updatedAfter` | string | 否 | - | 增量同步时间游标,仅返回更新时间或创建时间大于等于该值的数据。支持格式:`yyyy-MM-dd HH:mm:ss``yyyy-MM-dd'T'HH:mm:ss` |
| `pageNo` | int | 否 | `1` | 页码,从 1 开始 |
| `pageSize` | int | 否 | `200` | 每页条数,最大 `1000` |
| `includeDetail` | boolean | 否 | `false` | 是否返回部门/公司/租户详细信息;`true` 会增加查询耗时 |
> 说明:`username/workNo/phone` 三者都可为空;都为空时按分页返回“全部有效用户”。
---
## 4. 返回结构
顶层响应遵循 JeecgBoot 标准格式:
```json
{
"success": true,
"message": "",
"code": 200,
"result": [
{
"id": "用户ID",
"username": "登录账号",
"realname": "真实姓名",
"password": "加密密码串",
"salt": "密码盐值",
"workNo": "工号",
"phone": "手机号",
"email": "邮箱",
"orgCode": "当前组织编码",
"post": "岗位ID/信息",
"status": 1,
"createTime": "创建时间",
"updateTime": "更新时间",
"lastPwdUpdateTime": "最后修改密码时间",
"departIds": "负责部门IDs",
"departmentList": [],
"companyList": [],
"loginTenantId": 1002,
"tenantList": []
}
]
}
```
### 4.1 字段说明(核心)
- `updateTime`**增量同步主游标字段**,建议 SCADA 以此推进同步位点。
- `createTime`:用于补偿“新增但未更新”的数据。
- `departmentList`:仅在 `includeDetail=true` 时有值(否则为空数组)。
- `companyList`:仅在 `includeDetail=true` 时有值(否则为空数组)。
- `tenantList`:仅在 `includeDetail=true` 时有值(否则为空数组)。
- `password`/`salt`:均为后端存储态字段(非明文);如无强依赖,建议 SCADA 侧不落库或脱敏保存。
---
## 5. 查询与同步规则
后端固定只返回“有效用户”:
- `delFlag = 0`
- `status = 1`
当传入 `updatedAfter` 时,后端过滤条件为:
- `updateTime >= updatedAfter` **或**
- `createTime >= updatedAfter`
---
## 6. 推荐调用策略(给 SCADA
### 6.1 首次全量同步
1. `pageNo=1``pageSize=500``includeDetail=false`
2. 按页拉取至空页(或小于 `pageSize`)。
3. 本地记录本次最大 `updateTime` 作为 `syncCursor`
示例:
```http
GET /jeecg-boot/sys/user/scada/queryUser?pageNo=1&pageSize=500&includeDetail=false
```
### 6.2 增量同步(断网续传)
1. 使用本地 `syncCursor` 作为 `updatedAfter`
2. 分页拉取,直到无新增数据。
3. 每处理完一页更新本地游标(建议按返回中最大 `updateTime`)。
示例:
```http
GET /jeecg-boot/sys/user/scada/queryUser?updatedAfter=2026-04-27 10:00:00&pageNo=1&pageSize=500&includeDetail=false
```
### 6.3 明细补拉策略
若 SCADA 需要组织和租户完整信息,仅对“已识别变更的用户”按条件补拉,并使用:
- `includeDetail=true`
- 同时带 `username/workNo/phone` 之一,缩小范围
---
## 7. 性能说明
- 默认 `includeDetail=false`,可避免部门与租户明细查询带来的额外耗时。
- 单次 `pageSize` 上限 `1000`,防止单请求过大。
- 如业务需要更高吞吐,建议后续将部门/租户查询改为批量聚合查询,避免 N+1 查询。
---
## 8. 安全建议
当前接口为免登录接口,建议尽快补充至少一种安全手段:
- IP 白名单(仅允许 SCADA 网段访问)
- 网关层签名校验(`appKey + timestamp + sign`
- HTTPS 强制 + 调用方证书
---
## 9. 当前实现位置
- 控制器:`jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java`
- 文档与安全配置:`jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/Swagger3Config.java`
- 免登录白名单:`jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-*.yml`

View File

@@ -48,7 +48,10 @@ public class Swagger3Config implements WebMvcConfigurer {
"/sys/cas/client/validateLogin",
"/test/jeecgDemo/demo3",
"/sys/thirdLogin/**",
"/sys/user/register"
"/sys/user/register",
"/sys/user/scada/queryUser",
"/sys/dict/scada/queryDictItem",
"/sys/log/scada/addLoginLog"
));
// 预处理通配符模式,提高匹配效率
private static final Set<String> wildcardPatterns = new HashSet<>();
@@ -79,7 +82,18 @@ public class Swagger3Config implements WebMvcConfigurer {
@Bean
public GlobalOpenApiMethodFilter globalOpenApiMethodFilter() {
return method -> method.isAnnotationPresent(Operation.class);
return method -> {
if (method.isAnnotationPresent(Operation.class)) {
return true;
}
RequestMapping classMapping = method.getDeclaringClass().getAnnotation(RequestMapping.class);
if (classMapping != null && classMapping.value().length > 0) {
String classPath = classMapping.value()[0];
// 兼容系统用户管理接口,未加 @Operation 的接口也展示在文档中
return classPath.startsWith("/sys/user");
}
return false;
};
}
@Bean
@@ -89,6 +103,8 @@ public class Swagger3Config implements WebMvcConfigurer {
if (!isExcludedPath(path)) {
operation.addSecurityItem(new SecurityRequirement().addList(CommonConstant.X_ACCESS_TOKEN));
}else{
// 对于免登录接口,显式清空 security覆盖 OpenAPI 全局安全要求
operation.setSecurity(new java.util.ArrayList<>());
log.info("忽略加入 X_ACCESS_TOKEN 的 PATH:" + path);
}
return operation;

View File

@@ -31,7 +31,7 @@ public class WebSocketConfig {
FilterRegistrationBean bean = new FilterRegistrationBean();
bean.setFilter(websocketFilter());
//TODO 临时注释掉测试下线上socket总断的问题
bean.addUrlPatterns("/taskCountSocket/*", "/websocket/*","/eoaSocket/*","/eoaNewChatSocket/*", "/newsWebsocket/*", "/dragChannelSocket/*", "/vxeSocket/*");
bean.addUrlPatterns("/taskCountSocket/*", "/websocket/*","/eoaSocket/*","/eoaNewChatSocket/*", "/newsWebsocket/*", "/dragChannelSocket/*", "/vxeSocket/*", "/ws/*");
return bean;
}

View File

@@ -35,9 +35,15 @@ public class WebsocketFilter implements Filter {
redisUtil = SpringContextUtils.getBean(RedisUtil.class);
}
HttpServletRequest request = (HttpServletRequest)servletRequest;
String requestUri = request.getRequestURI();
// SCADA桌面端固定通道允许免token接入保障工控机实时推送可用
if (isScadaAnonymousEndpoint(requestUri)) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}
String token = request.getHeader(TOKEN_KEY);
log.debug("Websocket连接 Token安全校验Path = {}token:{}", request.getRequestURI(), token);
log.debug("Websocket连接 Token安全校验Path = {}token:{}", requestUri, token);
try {
TokenUtils.verifyToken(token, commonApi, redisUtil);
@@ -51,4 +57,16 @@ public class WebsocketFilter implements Filter {
filterChain.doFilter(servletRequest, servletResponse);
}
private boolean isScadaAnonymousEndpoint(String requestUri) {
if (oConvertUtils.isEmpty(requestUri)) {
return false;
}
String uri = requestUri.toLowerCase();
// 设备同步 STOMP 原生握手(与 jeecg-module-device-sync 的 /ws/device 一致)
if (uri.contains("/ws/device")) {
return true;
}
return uri.endsWith("/websocket/scada-sync");
}
}

View File

@@ -172,6 +172,8 @@ public class ShiroConfig {
//websocket排除
filterChainDefinitionMap.put("/websocket/**", "anon");//系统通知和公告
// 设备同步模块 STOMP工控机统一通道与桌面端 /ws/device 对接)
filterChainDefinitionMap.put("/ws/**", "anon");
filterChainDefinitionMap.put("/newsWebsocket/**", "anon");//CMS模块
filterChainDefinitionMap.put("/vxeSocket/**", "anon");//JVxeTable无痕刷新示例
//App vue3版本查询版本接口

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-parent</artifactId>
<version>3.9.1</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>jeecg-module-device-sync</artifactId>
<name>jeecg-module-device-sync</name>
<description>设备断联续传同步模块</description>
<dependencies>
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-base-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,28 @@
package org.jeecg.modules.device.sync.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
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 配置。
*/
@Configuration("deviceSyncWebSocketConfig")
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/device")
.setAllowedOriginPatterns("*");
}
}

View File

@@ -0,0 +1,80 @@
package org.jeecg.modules.device.sync.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.device.sync.entity.DeviceStatus;
import org.jeecg.modules.device.sync.mapper.DeviceStatusMapper;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
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.Date;
import java.util.HashMap;
import java.util.Map;
/**
* 设备 WebSocket 控制器。
*/
@Slf4j
@RestController
@RequestMapping("/sys/device")
@RequiredArgsConstructor
public class DeviceWebSocketController {
private final DeviceStatusMapper deviceStatusMapper;
private final SimpMessagingTemplate messagingTemplate;
@MessageMapping("/device/status")
public void receiveDeviceStatus(DeviceStatus payload) {
if (payload == null || payload.getDeviceId() == null || payload.getDeviceId().isBlank()) {
log.warn("设备状态上报参数无效");
return;
}
Date now = new Date();
payload.setLastSeenTime(now);
payload.setUpdateTime(now);
if (payload.getCreatedTime() == null) {
payload.setCreatedTime(now);
}
DeviceStatus exists = deviceStatusMapper.selectOne(new LambdaQueryWrapper<DeviceStatus>()
.eq(DeviceStatus::getDeviceId, payload.getDeviceId())
.last("limit 1"));
if (exists == null) {
deviceStatusMapper.insert(payload);
} else {
payload.setId(exists.getId());
payload.setCreatedTime(exists.getCreatedTime());
deviceStatusMapper.updateById(payload);
}
messagingTemplate.convertAndSend("/topic/device/" + payload.getDeviceId(), payload);
log.debug("设备状态已广播, deviceId={}", payload.getDeviceId());
}
@PostMapping("/command")
public Result<Map<String, Object>> sendCommand(@RequestBody Map<String, Object> request) {
String deviceId = request == null ? null : String.valueOf(request.getOrDefault("deviceId", ""));
if (deviceId == null || deviceId.isBlank()) {
return Result.error("deviceId不能为空");
}
String commandJson = request == null ? "{}" : String.valueOf(request.getOrDefault("commandJson", "{}"));
Map<String, Object> commandPayload = new HashMap<>();
commandPayload.put("deviceId", deviceId);
commandPayload.put("commandJson", commandJson);
commandPayload.put("sentAt", System.currentTimeMillis());
messagingTemplate.convertAndSendToUser(deviceId, "/queue/command", commandPayload);
log.info("下发设备指令成功, deviceId={}", deviceId);
Map<String, Object> result = new HashMap<>();
result.put("deviceId", deviceId);
result.put("queued", true);
return Result.OK(result);
}
}

View File

@@ -0,0 +1,136 @@
package org.jeecg.modules.device.sync.controller;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.device.sync.dto.SyncMessageDto;
import org.jeecg.modules.device.sync.entity.DeviceRegistry;
import org.jeecg.modules.device.sync.entity.DeviceStatus;
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.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.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 数据同步控制器。
*/
@Slf4j
@RestController
@RequestMapping("/sys/sync")
@RequiredArgsConstructor
public class SyncController {
private final SyncIdempotentLogMapper syncIdempotentLogMapper;
private final DeviceStatusMapper deviceStatusMapper;
private final DeviceRegistryMapper deviceRegistryMapper;
@PostMapping("/batch")
@Transactional(rollbackFor = Exception.class)
public Result<Map<String, Integer>> batch(@RequestBody List<SyncMessageDto> messages) {
int received = messages == null ? 0 : messages.size();
int inserted = 0;
if (messages == null || messages.isEmpty()) {
Map<String, Integer> empty = new HashMap<>();
empty.put("received", 0);
empty.put("inserted", 0);
return Result.OK(empty);
}
for (SyncMessageDto message : messages) {
if (message == null || message.getMessageId() == null || message.getMessageId().isBlank()) {
continue;
}
if (syncIdempotentLogMapper.exists(message.getMessageId())) {
continue;
}
routeAndPersist(message);
SyncIdempotentLog logEntity = new SyncIdempotentLog();
logEntity.setMessageId(message.getMessageId());
logEntity.setAggregateType(message.getAggregateType());
logEntity.setAggregateId(message.getAggregateId());
logEntity.setEventType(message.getEventType());
logEntity.setCreatedTime(new Date());
syncIdempotentLogMapper.insert(logEntity);
inserted++;
}
Map<String, Integer> result = new HashMap<>();
result.put("received", received);
result.put("inserted", inserted);
return Result.OK(result);
}
private void routeAndPersist(SyncMessageDto message) {
String aggregateType = message.getAggregateType() == null ? "" : message.getAggregateType().trim().toUpperCase();
switch (aggregateType) {
case "DEVICE_STATUS":
saveDeviceStatus(message);
break;
case "DEVICE_REGISTRY":
saveDeviceRegistry(message);
break;
default:
log.debug("未识别aggregateType按透传记录处理messageId={}, aggregateType={}", message.getMessageId(), aggregateType);
break;
}
}
private void saveDeviceStatus(SyncMessageDto message) {
DeviceStatus incoming = JSON.parseObject(message.getPayload(), DeviceStatus.class);
if (incoming == null || incoming.getDeviceId() == null || incoming.getDeviceId().isBlank()) {
return;
}
Date now = new Date();
incoming.setUpdateTime(now);
if (incoming.getCreatedTime() == null) {
incoming.setCreatedTime(now);
}
DeviceStatus exists = deviceStatusMapper.selectOne(new LambdaQueryWrapper<DeviceStatus>()
.eq(DeviceStatus::getDeviceId, incoming.getDeviceId())
.last("limit 1"));
if (exists == null) {
deviceStatusMapper.insert(incoming);
} else {
incoming.setId(exists.getId());
incoming.setCreatedTime(exists.getCreatedTime());
deviceStatusMapper.updateById(incoming);
}
}
private void saveDeviceRegistry(SyncMessageDto message) {
DeviceRegistry incoming = JSON.parseObject(message.getPayload(), DeviceRegistry.class);
if (incoming == null || incoming.getDeviceId() == null || incoming.getDeviceId().isBlank()) {
return;
}
Date now = new Date();
incoming.setUpdateTime(now);
if (incoming.getCreatedTime() == null) {
incoming.setCreatedTime(now);
}
DeviceRegistry exists = deviceRegistryMapper.selectOne(new LambdaQueryWrapper<DeviceRegistry>()
.eq(DeviceRegistry::getDeviceId, incoming.getDeviceId())
.last("limit 1"));
if (exists == null) {
deviceRegistryMapper.insert(incoming);
} else {
incoming.setId(exists.getId());
incoming.setCreatedTime(exists.getCreatedTime());
deviceRegistryMapper.updateById(incoming);
}
}
}

View File

@@ -0,0 +1,44 @@
package org.jeecg.modules.device.sync.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 同步消息 DTO。
*/
@Data
public class SyncMessageDto implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 消息唯一ID幂等键
*/
private String messageId;
/**
* 聚合类型。
*/
private String aggregateType;
/**
* 聚合ID。
*/
private String aggregateId;
/**
* 事件类型。
*/
private String eventType;
/**
* JSON字符串载荷。
*/
private String payload;
/**
* 事件发生时间。
*/
private Date occurredAt;
}

View File

@@ -0,0 +1,37 @@
package org.jeecg.modules.device.sync.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 设备注册实体。
*/
@Data
@TableName("device_registry")
public class DeviceRegistry implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private String deviceId;
private String deviceName;
private String deviceType;
private Integer tokenVersion;
private Integer enabled;
private Date lastSyncTime;
private Date createdTime;
private Date updateTime;
}

View File

@@ -0,0 +1,33 @@
package org.jeecg.modules.device.sync.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 设备状态实体。
*/
@Data
@TableName("device_status")
public class DeviceStatus implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private String deviceId;
private Integer onlineStatus;
private Date lastSeenTime;
private String statusPayload;
private Date createdTime;
private Date updateTime;
}

View File

@@ -0,0 +1,31 @@
package org.jeecg.modules.device.sync.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 同步幂等日志实体。
*/
@Data
@TableName("sync_idempotent_log")
public class SyncIdempotentLog implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private String messageId;
private String aggregateType;
private String aggregateId;
private String eventType;
private Date createdTime;
}

View File

@@ -0,0 +1,81 @@
package org.jeecg.modules.device.sync.filter;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.RedisUtil;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Date;
/**
* 设备 Token 滑动续签过滤器。
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class DeviceTokenRefreshFilter extends OncePerRequestFilter {
private static final long DEVICE_TOKEN_TTL_SECONDS = 30L * 24 * 60 * 60;
private static final long DEVICE_TOKEN_EXPIRE_MILLIS = 30L * 24 * 60 * 60 * 1000;
private static final String PREFIX_DEVICE_TOKEN = "prefix_device_token:";
private final RedisUtil redisUtil;
public DeviceTokenRefreshFilter(RedisUtil redisUtil) {
this.redisUtil = redisUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
String token = auth.substring(7).trim();
try {
DecodedJWT jwt = JWT.decode(token);
String tokenType = jwt.getClaim("tokenType").asString();
if ("device".equalsIgnoreCase(tokenType)) {
String deviceId = resolveDeviceId(jwt);
if (deviceId != null && !deviceId.isBlank()) {
redisUtil.expire(PREFIX_DEVICE_TOKEN + token, DEVICE_TOKEN_TTL_SECONDS);
String refreshed = refreshDeviceToken(deviceId);
redisUtil.set(PREFIX_DEVICE_TOKEN + refreshed, refreshed, DEVICE_TOKEN_TTL_SECONDS);
response.setHeader("X-Refresh-Token", refreshed);
log.debug("设备Token已续签, deviceId={}", deviceId);
}
}
} catch (Exception e) {
log.debug("设备Token续签跳过: {}", e.getMessage());
}
}
filterChain.doFilter(request, response);
}
private String resolveDeviceId(DecodedJWT jwt) {
String deviceId = jwt.getClaim("deviceId").asString();
if (deviceId == null || deviceId.isBlank()) {
deviceId = jwt.getClaim("username").asString();
}
return deviceId;
}
private String refreshDeviceToken(String deviceId) {
Date expiresAt = new Date(System.currentTimeMillis() + DEVICE_TOKEN_EXPIRE_MILLIS);
Algorithm algorithm = Algorithm.HMAC256(deviceId + "_device_secret");
return JWT.create()
.withClaim("username", deviceId)
.withClaim("deviceId", deviceId)
.withClaim("tokenType", "device")
.withExpiresAt(expiresAt)
.sign(algorithm);
}
}

View File

@@ -0,0 +1,9 @@
package org.jeecg.modules.device.sync.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.device.sync.entity.DeviceRegistry;
@Mapper
public interface DeviceRegistryMapper extends BaseMapper<DeviceRegistry> {
}

View File

@@ -0,0 +1,9 @@
package org.jeecg.modules.device.sync.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.device.sync.entity.DeviceStatus;
@Mapper
public interface DeviceStatusMapper extends BaseMapper<DeviceStatus> {
}

View File

@@ -0,0 +1,17 @@
package org.jeecg.modules.device.sync.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.jeecg.modules.device.sync.entity.SyncIdempotentLog;
/**
* 同步幂等日志 Mapper。
*/
@Mapper
public interface SyncIdempotentLogMapper extends BaseMapper<SyncIdempotentLog> {
@Select("SELECT COUNT(1) > 0 FROM sync_idempotent_log WHERE message_id = #{messageId}")
boolean exists(@Param("messageId") String messageId);
}

View File

@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS sync_idempotent_log (
id BIGINT NOT NULL AUTO_INCREMENT,
message_id VARCHAR(64) NOT NULL COMMENT '消息唯一ID',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_sync_idempotent_log_message_id (message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同步幂等日志';
CREATE TABLE IF NOT EXISTS device_status (
id BIGINT NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL COMMENT '设备ID',
online_status TINYINT NOT NULL DEFAULT 0 COMMENT '在线状态:0离线1在线',
last_seen_time DATETIME NULL COMMENT '最后在线时间',
status_payload JSON NULL COMMENT '状态载荷',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_device_status_device_id (device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备状态表';
CREATE TABLE IF NOT EXISTS device_registry (
id BIGINT NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL COMMENT '设备ID',
device_name VARCHAR(128) NULL COMMENT '设备名称',
device_type VARCHAR(64) NULL COMMENT '设备类型',
token_version INT NOT NULL DEFAULT 1 COMMENT '设备令牌版本',
enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用:0否1是',
last_sync_time DATETIME NULL COMMENT '最后同步时间',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_device_registry_device_id (device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备注册表';

View File

@@ -61,6 +61,10 @@
<artifactId>jeecg-boot-module-airag</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>

View File

@@ -10,9 +10,14 @@ import com.alibaba.fastjson.JSONObject;
import org.jeecg.common.base.BaseMap;
import org.jeecg.common.constant.WebsocketConst;
import org.jeecg.common.modules.redis.client.JeecgRedisClient;
import org.jeecg.modules.system.entity.SysLog;
import org.jeecg.modules.system.service.ISysLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import java.util.Date;
/**
* @Author scott
@@ -27,6 +32,13 @@ public class WebSocket {
/**线程安全Map*/
private static ConcurrentHashMap<String, Session> sessionPool = new ConcurrentHashMap<>();
/**
* 当前在线 WebSocket 连接数。
*/
public static int getOnlineCount() {
return sessionPool.size();
}
/**
* Redis触发监听名字
*/
@@ -34,10 +46,15 @@ public class WebSocket {
//避免初次调用出现空指针的情况
private static JeecgRedisClient jeecgRedisClient;
private static ISysLogService sysLogService;
@Autowired
private void setJeecgRedisClient(JeecgRedisClient jeecgRedisClient){
WebSocket.jeecgRedisClient = jeecgRedisClient;
}
@Autowired
private void setSysLogService(ISysLogService sysLogService){
WebSocket.sysLogService = sysLogService;
}
//==========【websocket接受、推送消息等方法 —— 具体服务节点推送ws消息】========================================================================================
@@ -45,7 +62,7 @@ public class WebSocket {
public void onOpen(Session session, @PathParam(value = "userId") String userId) {
try {
sessionPool.put(userId, session);
log.debug("【系统 WebSocket】有新的连接,总数为:" + sessionPool.size());
log.info("【系统 WebSocket】连接建立 userId={}, sessionId={}, online={}", userId, session.getId(), sessionPool.size());
} catch (Exception e) {
}
}
@@ -54,7 +71,7 @@ public class WebSocket {
public void onClose(@PathParam("userId") String userId) {
try {
sessionPool.remove(userId);
log.debug("【系统 WebSocket】连接断开,总数为:" + sessionPool.size());
log.info("【系统 WebSocket】连接断开 userId={}, online={}", userId, sessionPool.size());
} catch (Exception e) {
e.printStackTrace();
}
@@ -67,6 +84,8 @@ public class WebSocket {
* @param message
*/
public void pushMessage(String userId, String message) {
int successCount = 0;
int failCount = 0;
for (Map.Entry<String, Session> item : sessionPool.entrySet()) {
//userId key值= {用户id + "_"+ 登录token的md5串}
//TODO vue2未改key新规则暂时不影响逻辑
@@ -75,29 +94,57 @@ public class WebSocket {
try {
// 代码逻辑说明: websocket报错 https://gitee.com/jeecg/jeecg-boot/issues/I4C0MU
synchronized (session){
log.debug("【系统 WebSocket】推送单人消息:" + message);
session.getBasicRemote().sendText(message);
}
successCount++;
} catch (Exception e) {
failCount++;
log.error(e.getMessage(),e);
}
}
}
// 日志分级策略:业务消息 info心跳消息 debug异常场景 warn/error
if (failCount > 0) {
log.warn("【系统 WebSocket】定向推送存在失败 userKeyContains={}, success={}, fail={}, payload={}",
userId, successCount, failCount, message);
} else if (isHeartbeatOrBlank(message == null ? "" : message.trim())) {
log.debug("【系统 WebSocket】定向心跳推送 userKeyContains={}, success={}, fail={}",
userId, successCount, failCount);
} else {
log.info("【系统 WebSocket】定向业务推送 userKeyContains={}, success={}, fail={}, payload={}",
userId, successCount, failCount, message);
}
}
/**
* ws遍历群发消息
*/
public void pushMessage(String message) {
int successCount = 0;
int failCount = 0;
try {
for (Map.Entry<String, Session> item : sessionPool.entrySet()) {
String sessionKey = item.getKey();
Session session = item.getValue();
try {
item.getValue().getAsyncRemote().sendText(message);
if (session == null || !session.isOpen()) {
failCount++;
log.warn("【系统 WebSocket】连接不可用跳过发送key={}", sessionKey);
continue;
}
// 使用同步发送提升送达确定性,避免 AsyncRemote 在断连边界下静默丢消息
synchronized (session) {
session.getBasicRemote().sendText(message);
}
successCount++;
} catch (Exception e) {
failCount++;
log.warn("【系统 WebSocket】发送失败key={},原因={}", sessionKey, e.getMessage());
log.error(e.getMessage(), e);
}
}
log.debug("【系统 WebSocket】群发消息:" + message);
log.info("【系统 WebSocket】群发完成 success={}, fail={}, online={}, payload={}",
successCount, failCount, sessionPool.size(), message);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
@@ -109,13 +156,25 @@ public class WebSocket {
*/
@OnMessage
public void onMessage(String message, @PathParam(value = "userId") String userId) {
if(!"ping".equals(message) && !WebsocketConst.CMD_CHECK.equals(message)){
log.debug("【系统 WebSocket】收到客户端消息:" + message);
}else{
log.debug("【系统 WebSocket】收到客户端消息:" + message);
String normalized = message == null ? "" : message.trim();
if (isHeartbeatOrBlank(normalized)) {
log.debug("【系统 WebSocket】收到客户端心跳/空消息:{}", normalized);
if ("ping".equalsIgnoreCase(normalized) || WebsocketConst.CMD_CHECK.equalsIgnoreCase(normalized)) {
// 代码逻辑说明: 【issues/1161】前端websocket因心跳导致监听不起作用---
this.sendMessage(userId, "ping");
}
return;
}
if (!isJsonMessage(normalized)) {
log.debug("【系统 WebSocket】忽略非JSON消息:{}", normalized);
return;
}
if (handleScadaLog(normalized, userId)) {
return;
}
log.debug("【系统 WebSocket】收到客户端消息:" + normalized);
// //------------------------------------------------------------------------------
// JSONObject obj = new JSONObject();
@@ -179,6 +238,82 @@ public class WebSocket {
sendMessage(userId, message);
}
}
private boolean handleScadaLog(String message, String userId) {
try {
JSONObject obj = JSONObject.parseObject(message);
if (obj == null) {
return false;
}
String cmd = obj.getString("cmd");
if (!"SCADA_LOGIN_LOG".equals(cmd) && !"SCADA_LOG".equals(cmd)) {
return false;
}
if (sysLogService == null) {
return true;
}
String category = obj.getString("category");
String account = obj.getString("account");
Boolean success = obj.getBoolean("success");
String msg = obj.getString("message");
String clientIp = obj.getString("clientIp");
String clientType = normalizeClientType(obj.getString("clientType"));
String exception = obj.getString("exception");
Integer logType = obj.getInteger("logType");
Integer operateType = obj.getInteger("operateType");
String method = obj.getString("method");
String requestUrl = obj.getString("requestUrl");
SysLog logEntity = new SysLog();
logEntity.setCreateTime(new Date());
logEntity.setLogType(logType == null ? ("EXCEPTION".equalsIgnoreCase(category) ? 2 : 1) : logType);
logEntity.setOperateType(operateType == null ? ("LOGIN".equalsIgnoreCase(category) ? 1 : 5) : operateType);
logEntity.setRequestType("WS");
logEntity.setRequestUrl(oConvertUtils.isEmpty(requestUrl) ? ("/websocket/" + userId) : requestUrl);
logEntity.setMethod(oConvertUtils.isEmpty(method) ? "SCADA_LOG" : method);
logEntity.setUsername(account);
logEntity.setUserid(account);
logEntity.setIp(oConvertUtils.isEmpty(clientIp) ? "desktop" : clientIp);
logEntity.setClientType(clientType);
if ("LOGIN".equalsIgnoreCase(category) || "SCADA_LOGIN_LOG".equals(cmd)) {
logEntity.setLogContent(String.format("桌面端登录%s%s", Boolean.TRUE.equals(success) ? "成功" : "失败", oConvertUtils.isEmpty(msg) ? "" : msg));
} else if ("EXCEPTION".equalsIgnoreCase(category)) {
String exceptionMsg = oConvertUtils.isEmpty(exception) ? "" : (" 异常: " + exception);
logEntity.setLogContent(String.format("桌面端异常日志:%s%s", oConvertUtils.isEmpty(msg) ? "" : msg, exceptionMsg));
} else {
logEntity.setLogContent(String.format("桌面端操作日志:%s", oConvertUtils.isEmpty(msg) ? "" : msg));
}
logEntity.setRequestParam(message);
sysLogService.save(logEntity);
return true;
} catch (Exception e) {
log.warn("处理 SCADA 登录日志消息失败: {}", e.getMessage());
return false;
}
}
private boolean isHeartbeatOrBlank(String message) {
return oConvertUtils.isEmpty(message)
|| "ping".equalsIgnoreCase(message)
|| "pong".equalsIgnoreCase(message)
|| WebsocketConst.CMD_CHECK.equalsIgnoreCase(message);
}
private boolean isJsonMessage(String message) {
return !oConvertUtils.isEmpty(message) && message.startsWith("{");
}
private String normalizeClientType(String rawClientType) {
if (oConvertUtils.isEmpty(rawClientType)) {
return "gkj";
}
String value = rawClientType.trim().toLowerCase();
if ("desktop".equals(value) || "pc".equals(value) || "windows".equals(value)) {
return "gkj";
}
return value;
}
//=======【采用redis发布订阅模式——推送消息】==========================================================================================
}

View File

@@ -40,6 +40,7 @@ import org.jeecgframework.poi.excel.entity.ExportParams;
import org.jeecgframework.poi.excel.entity.ImportParams;
import org.jeecgframework.poi.excel.entity.enmus.ExcelType;
import org.jeecgframework.poi.excel.view.JeecgEntityExcelView;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
@@ -153,6 +154,108 @@ public class SysDictController {
return Result.ok(res);
}
/**
* SCADA 系统专用:免登录查询数据字典项。
* 支持按 dictCode / dictName / itemText / itemValue 条件过滤,支持增量同步。
*/
@GetMapping("/scada/queryDictItem")
@Operation(summary = "SCADA-免登录查询数据字典项")
public Result<List<Map<String, Object>>> scadaQueryDictItem(
@RequestParam(name = "dictCode", required = false) String dictCode,
@RequestParam(name = "dictName", required = false) String dictName,
@RequestParam(name = "itemText", required = false) String itemText,
@RequestParam(name = "itemValue", required = false) String itemValue,
@RequestParam(name = "status", required = false) Integer status,
@RequestParam(name = "updatedAfter", required = false) String updatedAfter,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "500") Integer pageSize) {
if (pageNo == null || pageNo < 1) {
pageNo = 1;
}
if (pageSize == null || pageSize < 1) {
pageSize = 500;
}
pageSize = Math.min(pageSize, 1000);
LambdaQueryWrapper<SysDictItem> itemWrapper = new LambdaQueryWrapper<>();
itemWrapper.like(oConvertUtils.isNotEmpty(itemText), SysDictItem::getItemText, itemText);
itemWrapper.like(oConvertUtils.isNotEmpty(itemValue), SysDictItem::getItemValue, itemValue);
itemWrapper.eq(status != null, SysDictItem::getStatus, status);
if (oConvertUtils.isNotEmpty(updatedAfter)) {
try {
Date parsedSyncTime;
try {
parsedSyncTime = DateUtils.parseDate(updatedAfter, "yyyy-MM-dd HH:mm:ss");
} catch (Exception firstEx) {
parsedSyncTime = DateUtils.parseDate(updatedAfter, "yyyy-MM-dd'T'HH:mm:ss");
}
final Date syncTime = parsedSyncTime;
itemWrapper.and(w -> w.ge(SysDictItem::getUpdateTime, syncTime).or().ge(SysDictItem::getCreateTime, syncTime));
} catch (Exception e) {
return Result.error("updatedAfter格式错误支持yyyy-MM-dd HH:mm:ss 或 yyyy-MM-dd'T'HH:mm:ss");
}
}
// 若传入字典头过滤条件,先按字典头筛出 dictId 再查询字典项
if (oConvertUtils.isNotEmpty(dictCode) || oConvertUtils.isNotEmpty(dictName)) {
LambdaQueryWrapper<SysDict> dictFilter = new LambdaQueryWrapper<>();
dictFilter.eq(SysDict::getDelFlag, CommonConstant.DEL_FLAG_0);
dictFilter.like(oConvertUtils.isNotEmpty(dictCode), SysDict::getDictCode, dictCode);
dictFilter.like(oConvertUtils.isNotEmpty(dictName), SysDict::getDictName, dictName);
List<SysDict> filteredDicts = sysDictService.list(dictFilter);
if (oConvertUtils.isEmpty(filteredDicts)) {
return Result.ok(new ArrayList<>());
}
List<String> filteredDictIds = filteredDicts.stream().map(SysDict::getId).collect(java.util.stream.Collectors.toList());
itemWrapper.in(SysDictItem::getDictId, filteredDictIds);
}
itemWrapper.orderByAsc(SysDictItem::getSortOrder);
itemWrapper.orderByDesc(SysDictItem::getCreateTime);
IPage<SysDictItem> itemPage = sysDictItemService.page(new Page<>(pageNo, pageSize), itemWrapper);
List<SysDictItem> itemList = itemPage.getRecords();
if (oConvertUtils.isEmpty(itemList)) {
return Result.ok(new ArrayList<>());
}
List<String> dictIds = itemList.stream().map(SysDictItem::getDictId).distinct().collect(java.util.stream.Collectors.toList());
LambdaQueryWrapper<SysDict> dictQuery = new LambdaQueryWrapper<>();
dictQuery.in(SysDict::getId, dictIds);
dictQuery.eq(SysDict::getDelFlag, CommonConstant.DEL_FLAG_0);
List<SysDict> dictList = sysDictService.list(dictQuery);
Map<String, SysDict> dictMap = dictList.stream().collect(java.util.stream.Collectors.toMap(SysDict::getId, d -> d));
List<Map<String, Object>> resultList = new ArrayList<>();
for (SysDictItem item : itemList) {
SysDict dict = dictMap.get(item.getDictId());
if (dict == null) {
continue;
}
Map<String, Object> row = new LinkedHashMap<>(24);
row.put("id", item.getId());
row.put("dictId", item.getDictId());
row.put("dictName", dict.getDictName());
row.put("dictCode", dict.getDictCode());
row.put("dictType", dict.getType());
row.put("dictDescription", dict.getDescription());
row.put("itemText", item.getItemText());
row.put("itemValue", item.getItemValue());
row.put("itemDescription", item.getDescription());
row.put("sortOrder", item.getSortOrder());
row.put("status", item.getStatus());
row.put("itemColor", item.getItemColor());
row.put("createBy", item.getCreateBy());
row.put("createTime", item.getCreateTime());
row.put("updateBy", item.getUpdateBy());
row.put("updateTime", item.getUpdateTime());
resultList.add(row);
}
return Result.ok(resultList);
}
/**
* 获取字典数据
* @param dictCode

View File

@@ -8,11 +8,11 @@ import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.IpUtils;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.config.JeecgBaseConfig;
import org.jeecg.modules.system.entity.SysLog;
@@ -23,6 +23,8 @@ import org.jeecgframework.poi.excel.entity.ExportParams;
import org.jeecgframework.poi.excel.view.JeecgEntityExcelView;
import org.jeecgframework.poi.handler.inter.IExcelExportServerEnhanced;
import org.springframework.beans.factory.annotation.Autowired;
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.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
@@ -32,6 +34,7 @@ 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 lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.ModelAndView;
@@ -51,6 +54,77 @@ public class SysLogController extends JeecgController<SysLog, ISysLogService> {
@Autowired
private ISysLogService sysLogService;
@PostMapping("/scada/addLoginLog")
@Operation(summary = "SCADA-免登录写入登录日志")
public Result<String> scadaAddLoginLog(@RequestBody java.util.Map<String, Object> payload, HttpServletRequest request) {
payload.put("category", "LOGIN");
return scadaAddLog(payload, request);
}
@PostMapping("/scada/addLog")
@Operation(summary = "SCADA-免登录写入通用日志")
public Result<String> scadaAddLog(@RequestBody java.util.Map<String, Object> payload, HttpServletRequest request) {
try {
String account = oConvertUtils.getString(payload.get("account"));
String message = oConvertUtils.getString(payload.get("message"));
String category = oConvertUtils.getString(payload.get("category"));
boolean success = Boolean.parseBoolean(oConvertUtils.getString(payload.get("success")));
String clientType = normalizeClientType(oConvertUtils.getString(payload.get("clientType")));
String exception = oConvertUtils.getString(payload.get("exception"));
int logType = oConvertUtils.getInt(payload.get("logType"), "EXCEPTION".equalsIgnoreCase(category) ? 2 : 1);
int operateType = oConvertUtils.getInt(payload.get("operateType"), "LOGIN".equalsIgnoreCase(category) ? 1 : 5);
String method = oConvertUtils.getString(payload.get("method"));
if (oConvertUtils.isEmpty(method)) {
method = "LOGIN".equalsIgnoreCase(category) ? "LOGIN" : "SCADA_LOG";
}
String requestUrl = oConvertUtils.getString(payload.get("requestUrl"));
if (oConvertUtils.isEmpty(requestUrl)) {
requestUrl = "/sys/log/scada/addLog";
}
SysLog logEntity = new SysLog();
logEntity.setCreateTime(new java.util.Date());
logEntity.setLogType(logType);
logEntity.setOperateType(operateType);
logEntity.setRequestType("WS/HTTP");
logEntity.setRequestUrl(requestUrl);
logEntity.setMethod(method);
logEntity.setUsername(account);
logEntity.setUserid(account);
logEntity.setIp(IpUtils.getIpAddr(request));
logEntity.setClientType(clientType);
logEntity.setLogContent(buildLogContent(category, success, message, exception));
logEntity.setRequestParam(com.alibaba.fastjson.JSON.toJSONString(payload));
sysLogService.save(logEntity);
return Result.OK("ok");
} catch (Exception e) {
log.error("SCADA写入日志失败", e);
return Result.error("写入失败:" + e.getMessage());
}
}
private String buildLogContent(String category, boolean success, String message, String exception) {
if ("LOGIN".equalsIgnoreCase(category)) {
return String.format("桌面端登录%s%s", success ? "成功" : "失败", oConvertUtils.isEmpty(message) ? "" : message);
}
if ("EXCEPTION".equalsIgnoreCase(category)) {
String exceptionMsg = oConvertUtils.isEmpty(exception) ? "" : (" 异常: " + exception);
return String.format("桌面端异常日志:%s%s", oConvertUtils.isEmpty(message) ? "" : message, exceptionMsg);
}
return String.format("桌面端操作日志:%s", oConvertUtils.isEmpty(message) ? "" : message);
}
private String normalizeClientType(String rawClientType) {
if (oConvertUtils.isEmpty(rawClientType)) {
return "gkj";
}
String value = rawClientType.trim().toLowerCase();
if ("desktop".equals(value) || "pc".equals(value) || "windows".equals(value)) {
return "gkj";
}
return value;
}
/**
* for [issues/8699]AutoPoi在使用@ExcelEntity当设置show=true并且该项为null时报错
*/

View File

@@ -9,8 +9,11 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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 lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.time.DateUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.authz.annotation.RequiresRoles;
@@ -22,6 +25,7 @@ import org.jeecg.common.constant.CacheConstant;
import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.constant.PasswordConstant;
import org.jeecg.common.constant.SymbolConstant;
import org.jeecg.common.constant.WebsocketConst;
import org.jeecg.common.modules.redis.client.JeecgRedisClient;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.system.util.JwtUtil;
@@ -35,11 +39,13 @@ import org.jeecg.modules.system.excelstyle.ExcelExportSysUserStyle;
import org.jeecg.modules.system.model.DepartIdModel;
import org.jeecg.modules.system.model.SysUserSysDepPostModel;
import org.jeecg.modules.system.model.SysUserSysDepartModel;
import org.jeecg.modules.message.websocket.WebSocket;
import org.jeecg.modules.system.service.*;
import org.jeecg.modules.system.util.ImportSysUserCache;
import org.jeecg.modules.system.vo.SysDepartUsersVO;
import org.jeecg.modules.system.vo.SysUserExportVo;
import org.jeecg.modules.system.vo.SysUserRoleVO;
import org.jeecg.modules.system.vo.SysUserTenantVo;
import org.jeecg.modules.system.vo.lowapp.DepartAndUserInfo;
import org.jeecg.modules.system.vo.lowapp.UpdateDepartInfo;
import org.jeecgframework.poi.excel.def.NormalExcelConstants;
@@ -48,6 +54,7 @@ import org.jeecgframework.poi.excel.entity.enmus.ExcelType;
import org.jeecgframework.poi.excel.view.JeecgEntityExcelView;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
@@ -70,6 +77,7 @@ import java.util.stream.Collectors;
@Slf4j
@RestController
@RequestMapping("/sys/user")
@Tag(name = "用户管理")
public class SysUserController {
@Autowired
@@ -110,6 +118,66 @@ public class SysUserController {
@Autowired
private JeecgBaseConfig jeecgBaseConfig;
@Autowired
private WebSocket webSocket;
/** 设备同步模块 STOMP与桌面端统一通道 /topic/sync/jeecg-users无 device-sync 模块时为 null */
@Autowired(required = false)
private SimpMessagingTemplate simpMessagingTemplate;
private void notifyScadaUserChanged(String action, String userId) {
try {
JSONObject payload = new JSONObject();
payload.put(WebsocketConst.MSG_CMD, "SCADA_USER_CHANGED");
payload.put("action", action);
payload.put("userId", userId);
payload.put("timestamp", System.currentTimeMillis());
log.info("SCADA用户变更通知开始 action={}, userId={}, onlineWs={}", action, userId, WebSocket.getOnlineCount());
// 固定工控机通道定向推送(桌面端连接 /websocket/scada-sync
webSocket.pushMessage("scada-sync", payload.toJSONString());
// 单机直推避免仅依赖Redis链路导致消息偶发丢失
webSocket.pushMessage(payload.toJSONString());
// 集群转发通过Redis发布订阅推送到其它节点在线客户端
webSocket.sendMessage(payload.toJSONString());
pushUserChangeToStompTopic(payload.toJSONString());
log.info("SCADA用户变更通知完成 action={}, userId={}, payload={}", action, userId, payload.toJSONString());
} catch (Exception e) {
log.warn("推送SCADA用户变更消息失败 action={}, userId={}", action, userId, e);
}
}
private void pushUserChangeToStompTopic(String jsonPayload) {
if (simpMessagingTemplate == null) {
return;
}
try {
simpMessagingTemplate.convertAndSend("/topic/sync/jeecg-users", jsonPayload);
} catch (Exception e) {
log.debug("STOMP 广播用户变更跳过device-sync 未启用或 Broker 未就绪): {}", e.getMessage());
}
}
private void notifyScadaUsersChanged(String action, String ids) {
try {
JSONObject payload = new JSONObject();
payload.put(WebsocketConst.MSG_CMD, "SCADA_USER_CHANGED");
payload.put("action", action);
payload.put("userIds", ids);
payload.put("timestamp", System.currentTimeMillis());
log.info("SCADA批量用户变更通知开始 action={}, ids={}, onlineWs={}", action, ids, WebSocket.getOnlineCount());
// 固定工控机通道定向推送(桌面端连接 /websocket/scada-sync
webSocket.pushMessage("scada-sync", payload.toJSONString());
// 单机直推避免仅依赖Redis链路导致消息偶发丢失
webSocket.pushMessage(payload.toJSONString());
// 集群转发通过Redis发布订阅推送到其它节点在线客户端
webSocket.sendMessage(payload.toJSONString());
pushUserChangeToStompTopic(payload.toJSONString());
log.info("SCADA批量用户变更通知完成 action={}, ids={}, payload={}", action, ids, payload.toJSONString());
} catch (Exception e) {
log.warn("推送SCADA批量用户变更消息失败 action={}, ids={}", action, ids, e);
}
}
/**
* 获取租户下用户数据(支持租户隔离)
* @param user
@@ -119,6 +187,7 @@ public class SysUserController {
* @return
*/
@PermissionData(pageComponent = "system/UserList")
@Operation(summary = "用户管理-分页查询")
@RequestMapping(value = "/list", method = RequestMethod.GET)
public Result<IPage<SysUser>> queryPageList(SysUser user,@RequestParam(name="pageNo", defaultValue="1") Integer pageNo,
@RequestParam(name="pageSize", defaultValue="10") Integer pageSize,HttpServletRequest req) {
@@ -156,6 +225,7 @@ public class SysUserController {
}
@RequiresPermissions("system:user:add")
@Operation(summary = "用户管理-新增用户")
@RequestMapping(value = "/add", method = RequestMethod.POST)
public Result<SysUser> add(@RequestBody JSONObject jsonObject) {
Result<SysUser> result = new Result<SysUser>();
@@ -178,6 +248,7 @@ public class SysUserController {
String relTenantIds = jsonObject.getString("relTenantIds");
sysUserService.saveUser(user, selectedRoles, selectedDeparts, relTenantIds, false);
baseCommonService.addLog("添加用户username " +user.getUsername() ,CommonConstant.LOG_TYPE_2, 2);
notifyScadaUserChanged("add", user.getId());
result.success("添加成功!");
} catch (Exception e) {
log.error(e.getMessage(), e);
@@ -187,6 +258,7 @@ public class SysUserController {
}
@RequiresPermissions("system:user:edit")
@Operation(summary = "用户管理-编辑用户")
@RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST})
public Result<SysUser> edit(@RequestBody JSONObject jsonObject) {
Result<SysUser> result = new Result<SysUser>();
@@ -215,6 +287,7 @@ public class SysUserController {
//update-begin---author:wangshuai---date:2025-11-12---for:【JHHB-776】用户编辑应该从数据库查出老数据页面传递什么字段把这些字段覆盖数据库查询结果再更新---
oConvertUtils.copyNonNullFields(user, sysUser);
sysUserService.editUser(sysUser, roles, departs, relTenantIds, updateFromPage);
notifyScadaUserChanged("edit", sysUser.getId());
//update-end---author:wangshuai---date:2025-11-12---for:【JHHB-776】用户编辑应该从数据库查出老数据页面传递什么字段把这些字段覆盖数据库查询结果再更新---
result.success("修改成功!");
}
@@ -253,6 +326,7 @@ public class SysUserController {
String relTenantIds = jsonObject.getString("relTenantIds");
sysUserService.saveUser(user, selectedRoles, selectedDeparts, relTenantIds, true);
baseCommonService.addLog("添加用户username " + user.getUsername(), CommonConstant.LOG_TYPE_2, 2);
notifyScadaUserChanged("addTenantUser", user.getId());
result.success("添加成功!");
} catch (Exception e) {
log.error(e.getMessage(), e);
@@ -265,11 +339,13 @@ public class SysUserController {
* 删除用户
*/
@RequiresPermissions("system:user:delete")
@Operation(summary = "用户管理-删除用户")
@RequestMapping(value = "/delete", method = RequestMethod.DELETE)
public Result<?> delete(@RequestParam(name="id",required=true) String id) {
baseCommonService.addLog("删除用户id " +id ,CommonConstant.LOG_TYPE_2, 3);
List<String> userNameList = sysUserService.userIdToUsername(Arrays.asList(id));
this.sysUserService.deleteUser(id);
notifyScadaUserChanged("delete", id);
if (!userNameList.isEmpty()) {
String joinedString = String.join(",", userNameList);
@@ -281,11 +357,13 @@ public class SysUserController {
* 批量删除用户
*/
@RequiresPermissions("system:user:deleteBatch")
@Operation(summary = "用户管理-批量删除用户")
@RequestMapping(value = "/deleteBatch", method = RequestMethod.DELETE)
public Result<?> deleteBatch(@RequestParam(name="ids",required=true) String ids) {
baseCommonService.addLog("批量删除用户, ids " +ids ,CommonConstant.LOG_TYPE_2, 3);
List<String> userNameList = sysUserService.userIdToUsername(Arrays.asList(ids.split(",")));
this.sysUserService.deleteBatchUsers(ids);
notifyScadaUsersChanged("deleteBatch", ids);
// 用户变更,触发同步工作流
if (!userNameList.isEmpty()) {
@@ -312,6 +390,7 @@ public class SysUserController {
if(oConvertUtils.isNotEmpty(id)) {
// 代码逻辑说明: [QQYUN-5577]用户列表-冻结用户,再解冻之后,用户还是无法登陆,有缓存问题 #5066------------
sysUserService.updateStatus(id,status);
notifyScadaUserChanged("status", id);
}
}
} catch (Exception e) {
@@ -329,6 +408,7 @@ public class SysUserController {
*/
@RequiresRoles({"admin"})
@RequiresPermissions("system:user:resetPassword")
@Operation(summary = "用户管理-重置密码")
@RequestMapping(value = "/resetPassword", method = RequestMethod.PUT)
public Result<SysUser> resetPassword(@RequestParam(name = "usernames") String usernames) {
Result<SysUser> result = new Result<SysUser>();
@@ -344,6 +424,7 @@ public class SysUserController {
}
@RequiresPermissions("system:user:queryById")
@Operation(summary = "用户管理-根据ID查询")
@RequestMapping(value = "/queryById", method = RequestMethod.GET)
public Result<SysUser> queryById(@RequestParam(name = "id", required = true) String id) {
Result<SysUser> result = new Result<SysUser>();
@@ -384,6 +465,7 @@ public class SysUserController {
* @return
*/
@RequestMapping(value = "/checkOnlyUser", method = RequestMethod.GET)
@Operation(summary = "用户管理-校验账号唯一")
public Result<Boolean> checkOnlyUser(SysUser sysUser) {
Result<Boolean> result = new Result<>();
//如果此参数为false则程序发生异常
@@ -411,6 +493,7 @@ public class SysUserController {
* 修改密码
*/
@RequiresPermissions("system:user:changepwd")
@Operation(summary = "用户管理-修改密码")
@RequestMapping(value = "/changePassword", method = RequestMethod.PUT)
public Result<?> changePassword(@RequestBody SysUser sysUser, HttpServletRequest request) {
//-------------------------------------------------------------------------------------
@@ -432,6 +515,107 @@ public class SysUserController {
return sysUserService.changePassword(sysUser);
}
/**
* SCADA 系统专用:免登录查询用户信息。
* 支持通过 username / workNo / phone 任一条件查询,返回基础信息(不返回敏感字段)。
*/
@GetMapping("/scada/queryUser")
@Operation(summary = "SCADA-免登录查询用户信息")
public Result<List<Map<String, Object>>> scadaQueryUser(@RequestParam(name = "username", required = false) String username,
@RequestParam(name = "workNo", required = false) String workNo,
@RequestParam(name = "phone", required = false) String phone,
@RequestParam(name = "updatedAfter", required = false) String updatedAfter,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "200") Integer pageSize,
@RequestParam(name = "includeDetail", defaultValue = "false") Boolean includeDetail) {
if (pageNo == null || pageNo < 1) {
pageNo = 1;
}
if (pageSize == null || pageSize < 1) {
pageSize = 200;
}
// 控制单次最大拉取量,避免接口被大查询拖慢
pageSize = Math.min(pageSize, 1000);
LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SysUser::getDelFlag, CommonConstant.DEL_FLAG_0);
queryWrapper.eq(SysUser::getStatus, Integer.valueOf(CommonConstant.STATUS_1));
queryWrapper.eq(oConvertUtils.isNotEmpty(username), SysUser::getUsername, username);
queryWrapper.eq(oConvertUtils.isNotEmpty(workNo), SysUser::getWorkNo, workNo);
queryWrapper.eq(oConvertUtils.isNotEmpty(phone), SysUser::getPhone, phone);
if (oConvertUtils.isNotEmpty(updatedAfter)) {
try {
Date syncTime = DateUtils.parseDate(updatedAfter, new String[]{"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss"});
queryWrapper.and(w -> w.ge(SysUser::getUpdateTime, syncTime).or().ge(SysUser::getCreateTime, syncTime));
} catch (Exception e) {
return Result.error("updatedAfter格式错误支持yyyy-MM-dd HH:mm:ss 或 yyyy-MM-dd'T'HH:mm:ss");
}
}
queryWrapper.orderByAsc(SysUser::getSort);
queryWrapper.orderByDesc(SysUser::getCreateTime);
IPage<SysUser> userPage = sysUserService.page(new Page<>(pageNo, pageSize), queryWrapper);
List<SysUser> userList = userPage.getRecords();
List<Map<String, Object>> resultList = new ArrayList<>();
for (SysUser user : userList) {
List<Map<String, Object>> departList = new ArrayList<>();
List<Map<String, Object>> companyList = new ArrayList<>();
List<Map<String, Object>> tenantList = new ArrayList<>();
if (Boolean.TRUE.equals(includeDetail)) {
List<SysDepart> userDeparts = sysDepartService.queryUserDeparts(user.getId());
for (SysDepart depart : userDeparts) {
Map<String, Object> departInfo = new LinkedHashMap<>(8);
departInfo.put("id", depart.getId());
departInfo.put("departName", depart.getDepartName());
departInfo.put("orgCode", depart.getOrgCode());
departInfo.put("orgCategory", depart.getOrgCategory());
departList.add(departInfo);
// 机构类别1公司、4子公司
if ("1".equals(depart.getOrgCategory()) || "4".equals(depart.getOrgCategory())) {
companyList.add(departInfo);
}
}
List<SysUserTenantVo> userTenants = userTenantService.getTenantListByUserId(user.getId(), Collections.singletonList(CommonConstant.STATUS_1));
for (SysUserTenantVo tenantVo : userTenants) {
Map<String, Object> tenantInfo = new LinkedHashMap<>(8);
tenantInfo.put("tenantId", tenantVo.getRelTenantIds());
tenantInfo.put("tenantName", tenantVo.getName());
tenantInfo.put("tenantUserStatus", tenantVo.getUserTenantStatus());
tenantInfo.put("tenantUserId", tenantVo.getTenantUserId());
tenantInfo.put("tenantAdmin", tenantVo.getTenantAdmin());
tenantList.add(tenantInfo);
}
}
Map<String, Object> userInfo = new LinkedHashMap<>(16);
userInfo.put("id", user.getId());
userInfo.put("username", user.getUsername());
userInfo.put("realname", user.getRealname());
// SysUser无独立nickname字段SCADA同步端约定用realname兼容昵称展示
userInfo.put("nickname", user.getRealname());
userInfo.put("password", user.getPassword());
userInfo.put("salt", user.getSalt());
userInfo.put("workNo", user.getWorkNo());
userInfo.put("phone", user.getPhone());
userInfo.put("email", user.getEmail());
userInfo.put("orgCode", user.getOrgCode());
userInfo.put("post", user.getPost());
userInfo.put("status", user.getStatus());
userInfo.put("createTime", user.getCreateTime());
userInfo.put("updateTime", user.getUpdateTime());
userInfo.put("lastPwdUpdateTime", user.getLastPwdUpdateTime());
userInfo.put("departIds", user.getDepartIds());
userInfo.put("departmentList", departList);
userInfo.put("companyList", companyList);
userInfo.put("loginTenantId", user.getLoginTenantId());
userInfo.put("tenantList", tenantList);
resultList.add(userInfo);
}
return Result.ok(resultList);
}
/**
* 查询指定用户和部门关联的数据
*

View File

@@ -36,6 +36,12 @@
<artifactId>jeecg-module-xslmes</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- 设备断联续传同步模块 -->
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-module-device-sync</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- flyway 数据库自动升级 -->
<dependency>

View File

@@ -22,7 +22,10 @@ import java.util.Map;
* 报错提醒: 未集成mongo报错可以打开启动类上面的注释 exclude={MongoAutoConfiguration.class}
*/
@Slf4j
@SpringBootApplication(exclude = MongoAutoConfiguration.class)
@SpringBootApplication(
exclude = MongoAutoConfiguration.class,
scanBasePackages = {"org.jeecg"}
)
@ImportAutoConfiguration(JustAuthAutoConfiguration.class) // spring boot 3.x justauth 兼容性处理
public class JeecgSystemApplication extends SpringBootServletInitializer {
@@ -32,6 +35,7 @@ public class JeecgSystemApplication extends SpringBootServletInitializer {
}
public static void main(String[] args) throws UnknownHostException {
SpringApplication app = new SpringApplication(JeecgSystemApplication.class);
Map<String, Object> defaultProperties = new HashMap<>();
defaultProperties.put("management.health.elasticsearch.enabled", false);

View File

@@ -233,7 +233,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
# 短信发送方式 aliyun阿里云短信 tencent腾讯云短信
smsSendType: aliyun
#阿里云oss存储和大鱼短信秘钥配置

View File

@@ -199,7 +199,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -222,7 +222,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -219,7 +219,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -213,7 +213,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -226,7 +226,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -226,7 +226,7 @@ jeecg:
#webapp文件路径
webapp: /opt/jeecg-boot/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/api/getUserInfo
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/api/getUserInfo,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
# 短信发送方式 aliyun阿里云短信 tencent腾讯云短信
smsSendType: aliyun
#阿里云oss存储和大鱼短信秘钥配置

View File

@@ -208,7 +208,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -227,7 +227,7 @@ jeecg:
#webapp文件路径
webapp: D://opt//webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
# 短信发送方式 aliyun阿里云短信 tencent腾讯云短信
smsSendType: aliyun
#阿里云oss存储和大鱼短信秘钥配置

View File

@@ -88,6 +88,7 @@
<module>jeecg-boot-base-core</module>
<module>jeecg-module-system</module>
<module>jeecg-boot-module</module>
<module>jeecg-module-device-sync</module>
</modules>
<repositories>

View File

@@ -18,7 +18,7 @@ importers:
specifier: ^5.2.9
version: 5.2.9
'@fontsource/noto-serif-sc':
specifier: ^5.2.9
specifier: ^5.2.8
version: 5.2.9
'@fontsource/open-sans':
specifier: ^5.2.7
@@ -1939,56 +1939,67 @@ packages:
resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.5':
resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.5':
resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.5':
resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.5':
resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.5':
resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.5':
resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.5':
resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.5':
resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.5':
resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.5':
resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.5':
resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}

View File

@@ -7,6 +7,14 @@
{
"path": "jeecgboot-vue3",
"name": "前端 (jeecgboot-vue3)"
},
{
"path": "yy-admin-master",
"name": "桌面端 (yy-admin-master)"
}
],
"settings": {
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "interactive"
}
]
}

139
yy-admin-master/.gitignore vendored Normal file
View File

@@ -0,0 +1,139 @@
################################################################################
# 此 .gitignore 文件已由 Microsoft(R) Visual Studio 自动创建。
################################################################################
/.vs/YY.Admin.Entry/FileContentIndex
/YY.Admin/bin/Debug/net8.0-windows
/YY.Admin/obj
/YY.Admin.Core/YY.Admin.Core/bin/Debug/net8.0
/YY.Admin.Core/YY.Admin.Core/obj
/YY.Admin.Services/YY.Admin.Services/bin/Debug/net8.0
/YY.Admin.Services/YY.Admin.Services/obj
/.vs/YY.Admin.Entry
/.vs/ProjectEvaluation
/YY.Admin.Application/YY.Admin.Services/bin/Debug/net8.0
/YY.Admin.Application/YY.Admin.Services/obj
/YY.Admin.Core/obj
/YY.Admin.Services/bin/Debug/net8.0-windows
/YY.Admin.Services/obj
/YY.Admin.Core/bin/Debug/net8.0-windows
/TEsrt/obj
# Visual Studio 2022 特定文件
.vs/
*.user
*.userosscache
*.sln.docstates
*.suo
*.cache
*.VC.db
*.VC.VC.opendb
*.vc.db
*.vc.vc.opendb
# 编译生成文件
[Bb]in/
[Oo]bj/
[Dd]ebug/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]uild/
[Bb]uildOutput/
# NuGet 包目录
[Nn]u[Gg]et/
*.nupkg
packages/
# 项目特定文件
_ReSharper.*
*.resharper
*.csproj.user
*.DotSettings
*.DotSettings.user
# MSTest 测试结果
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# 本地化文件
*.iobj
*.ipdb
*.pdb
*.pgd
*.idb
# 发布输出
[Dd]ebugPublic/
[Rr]eleasePublic/
[Pp]ublish/
# 日志文件
*.log
*.tlog
*.bak
*.tmp
# Visual Studio 缓存/临时文件
*.cachefile
*.temporary
*.temp
# Visual Studio Code 工作区文件
*.code-workspace
# Rider IDE 文件
.idea/
*.sln.iml
# 操作系统文件
.DS_Store
Thumbs.db
Desktop.ini
# 项目引用和依赖
.project.json
.project.lock.json
*.njsproj
*.pubxml
# SQL Server 文件
*.mdf
*.ldf
*.sdf
# Azure 函数文件
.local.settings.json
# 包依赖文件
packages.lock.json
# 覆盖率报告
coverage.xml
coverage.json
*.coverage
*.coveragesession
# 性能分析文件
*.psess
*.vsp
*.vspx
# 本地开发配置文件(可根据需要取消注释)
# appsettings.Development.json
# appsettings.Local.json
# secrets.json
# 前端构建文件(如果包含前端项目)
node_modules/
dist/
build/
*.tsbuildinfo
# VS Code C# Dev Tools - Auto-generated runners
.vscodecsdt/

15
yy-admin-master/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "YY.Admin 一键启动",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "YY.Admin: build",
"program": "${workspaceFolder}/YY.Admin/bin/Debug/net8.0-windows10.0.19041/win-x64/YY.Admin.exe",
"cwd": "${workspaceFolder}/YY.Admin/bin/Debug/net8.0-windows10.0.19041/win-x64",
"console": "internalConsole",
"stopAtEntry": false
}
]
}

19
yy-admin-master/.vscode/run-yyadmin.ps1 vendored Normal file
View File

@@ -0,0 +1,19 @@
$ErrorActionPreference = "Stop"
$dotnet = "C:\Program Files\dotnet\dotnet.exe"
$solution = Join-Path $PSScriptRoot "..\YY.Admin.Entry.sln"
$exe = Join-Path $PSScriptRoot "..\YY.Admin\bin\Debug\net8.0-windows10.0.19041\win-x64\YY.Admin.exe"
$exeDir = Split-Path $exe -Parent
& $dotnet build $solution -c Debug
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Push-Location $exeDir
try {
& $exe
}
finally {
Pop-Location
}

5
yy-admin-master/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"terminal.integrated.env.windows": {
"PATH": "C:\\Program Files\\dotnet;${env:PATH}"
}
}

53
yy-admin-master/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,53 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "YY.Admin: restore",
"type": "process",
"command": "C:\\Program Files\\dotnet\\dotnet.exe",
"args": ["restore", "YY.Admin.Entry.sln"],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
"label": "YY.Admin: build",
"type": "process",
"command": "C:\\Program Files\\dotnet\\dotnet.exe",
"args": ["build", "YY.Admin.Entry.sln", "-c", "Debug"],
"options": {
"cwd": "${workspaceFolder}"
},
"dependsOn": "YY.Admin: restore",
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "YY.Admin: run",
"type": "process",
"command": "${workspaceFolder}\\YY.Admin\\bin\\Debug\\net8.0-windows10.0.19041\\win-x64\\YY.Admin.exe",
"options": {
"cwd": "${workspaceFolder}\\YY.Admin\\bin\\Debug\\net8.0-windows10.0.19041\\win-x64"
},
"dependsOn": "YY.Admin: build",
"problemMatcher": []
},
{
"label": "dotnet run --project YY.Admin.csproj --no-build",
"type": "process",
"command": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}\\.vscode\\run-yyadmin.ps1"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
}
]
}

21
yy-admin-master/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 一个后端
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,36 @@
# YY-Admin
#### Description
WPF123123123
#### Software Architecture
Software architecture description
#### Installation
1. xxxx
2. xxxx
3. xxxx
#### Instructions
1. xxxx
2. xxxx
3. xxxx
#### Contribution
1. Fork the repository
2. Create Feat_xxx branch
3. Commit your code
4. Create Pull Request
#### Gitee Feature
1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
2. Gitee blog [blog.gitee.com](https://blog.gitee.com)
3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
4. The most valuable open source project [GVP](https://gitee.com/gvp)
5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)

96
yy-admin-master/README.md Normal file
View File

@@ -0,0 +1,96 @@
# YY-Admin WPF 后端通用框架持续维护中后续会完整实现RBAC权限管理
## 🍎效果截图
<table>
<tr>
<td><img src="https://gitee.com/peng_xi_wei/yy-admin/raw/master/doc/1753498486751.png"/></td>
<td><img src="https://gitee.com/peng_xi_wei/yy-admin/raw/master/doc/17846464112.png"/></td>
<td><img src="https://gitee.com/peng_xi_wei/yy-admin/raw/master/doc/1753498486755.png"/></td>
</tr>
</table>
## 📙介绍
YY-Admin 是一个基于 WPF 的现代后端管理系统通用框架,专为快速构建企业级管理后台而设计。
该框架融合了前沿的 WPF 开发技术和最佳实践,提供了丰富的 UI 组件、
强大的导航系统和完善的权限管理机制,帮助开发者高效构建功能强大、
界面美观的管理系统,开源免费使用。
框架采用模块化架构设计,内置用户管理、角色权限、系统监控等核心模块,
支持快速扩展业务功能。通过精心设计的界面布局和交互体验,
YY-Admin 使开发者能够专注于业务逻辑实现,大幅提升开发效率。
# 动动发财小手,点个⭐ 堆码不易
**🎁核心价值**
🚀 **快速开发**:提供通用管理功能模板,减少重复编码
🎨 **现代化界面**:基于 HandyControl 的优雅 UI 设计
🔒 **完善权限体系**:细粒度的角色和权限控制
📱 **响应式布局**:适配不同屏幕尺寸
⚙️ **模块化架构**:支持功能模块灵活扩展
## 🍁说明
1. 支持各种数据库,后台配置文件自行修改(自动生成数据库及种子数据)
2. 支持主题切换、自定义字体图标、按钮图标
## 软件架构
### 技术栈
| 组件 | 名称 | 版本 | 用途 |
|--------------|------------------|------------|--------------------------|
| UI框架 | HandyControl | 3.5.1 | 提供现代化UI组件和样式 |
| MVVM框架 | Prism.Core | 9.0.537 | 应用架构和模块化管理 |
| IoC容器 | Prism.DryIoc | 9.0.537 | 依赖注入和控制反转 |
| ORM | SqlSugar | - | 数据库访问和对象映射 |
| 导航 | Prism.Regions | 9.0.537 | 实现区域导航系统 |
| 验证 | FluentValidation | - | 数据验证解决方案 |
### 架构设计
```text
YY-Admin
├── YY.Admin.Core # 核心公共组件
│ ├── Models # 数据模型
│ ├── Enums # 枚举类型
│ └── Extensions # 扩展方法
├── YY.Admin.Services # 业务服务层
│ ├── AuthService.cs # 认证服务
│ ├── UserService.cs # 用户服务
│ └── ... # 其他业务服务
├── YY.Admin.Views # 视图层
│ ├── LoginWindow.xaml # 登录窗口
│ ├── MainWindow.xaml # 主窗口
│ └── ... # 功能视图
├── YY.Admin.ViewModels # 视图模型层
│ ├── LoginViewModel.cs # 登录逻辑
│ ├── MainViewModel.cs # 主窗口逻辑
│ └── ... # 其他视图模型
└── App.xaml.cs # 应用入口和配置
```
## 🎖️更新日志
```bash
1. 完成种子数据生成与表设计
2. 完成菜单页的加载
3. 完成自定义图标
4. 完成主题设置
5. 增加菜单标签页
# 本项目会持续迭代维护有问题和建议请提出PR
```
## 参与贡献
欢迎贡献代码,共同打造更好的 WPF 管理框架!
## 开源不易求打赏!!!
<div style="display: flex; justify-content: center; gap: 20px;">
<img src="doc/20260226090853.jpg" width="200" alt="打赏码1" />
<img src="doc/2026022609091.jpg" width="200" alt="打赏码2" />
</div>
## 联系作者微信
<div style="display: flex; justify-content: center; gap: 20px;">
<img src="doc/123213.png" width="200" alt="联系作者微信" />
</div>

View File

@@ -0,0 +1,79 @@
using System.Windows.Controls;
using System.Windows;
namespace YY.Admin.Core
{
/// <summary>
/// DataGrid绑定数据源描述
/// </summary>
public class BindDescriptionAttribute : Attribute
{
/// <summary>
/// 列名
/// </summary>
public string HeaderName { get; set; }
/// <summary>
/// 显示为
/// </summary>
public ShowScheme ShowAs { get; set; }
/// <summary>
/// 显示顺序
/// </summary>
public int DisplayIndex { get; set; }
/// <summary>
/// DataGrid列绑定属性名称
/// </summary>
public string PropertyName { get; set; }
/// <summary>
/// 应用内的容模板Key
/// </summary>
public string ResourceKey { get; set; }
/// <summary>
/// 列宽
/// </summary>
public DataGridLength Width { get; set; }
/// <summary>
/// 列宽ByGrid
/// </summary>
public GridLength CloumnWidth { get; set; }
/// <summary>
/// DataGrid绑定数据源描述
/// </summary>
/// <param name="headerName">列名</param>
/// <param name="showAs">显示为</param>
/// <param name="width">宽度</param>
/// <param name="displayIndex">显示顺序</param>
/// <param name="resourceKey">自定义列Key</param>
public BindDescriptionAttribute(string headerName, ShowScheme showAs = ShowScheme., string width = "Auto", int displayIndex = 0, string resourceKey = "")
{
this.HeaderName= headerName;
DisplayIndex = displayIndex;
ResourceKey = resourceKey;
ShowAs = showAs;
var convert = new DataGridLengthConverter();
Width = (DataGridLength)convert.ConvertFrom(width);
var gridCOnvert = new GridLengthConverter();
CloumnWidth = (GridLength)gridCOnvert.ConvertFrom(width);
if (showAs == ShowScheme. && string.IsNullOrWhiteSpace(resourceKey))
throw new ArgumentException($"自定义列时需要指定{nameof(resourceKey)}参数!");
}
}
/// <summary>
/// 展示方式
/// </summary>
public enum ShowScheme
{
= 1,
= 4
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 常量特性
/// </summary>
[AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = true)]
public class ConstAttribute : Attribute
{
public string Name { get; set; }
public ConstAttribute(string name)
{
Name = name;
}
}
}

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
// 定义生命周期标记接口
public interface ISingletonDependency { }
public interface ITransientDependency { }
public interface IScopedDependency { }
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 忽略表结构初始化特性(标记在实体)
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class IgnoreTableAttribute : Attribute
{
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 忽略更新种子特性(标记在种子类)
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class IgnoreUpdateSeedAttribute : Attribute
{
}
}

View File

@@ -0,0 +1,11 @@

namespace YY.Admin.Core
{
/// <summary>
/// 忽略更新种子列特性(标记在实体属性)
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)]
public class IgnoreUpdateSeedColumnAttribute : Attribute
{
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 增量种子特性
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class IncreSeedAttribute : Attribute
{
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 增量表特性
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class IncreTableAttribute : Attribute
{
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
public enum Lifecycle
{
Singleton,
Transient,
Scoped
}
[AttributeUsage(AttributeTargets.Class)]
public class LifecycleAttribute : Attribute
{
public Lifecycle Lifecycle { get; }
public LifecycleAttribute(Lifecycle lifecycle)
{
Lifecycle = lifecycle;
}
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 日志表特性
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class LogTableAttribute : Attribute
{
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 表示查询操作的前台操作码
/// </summary>
public class OperateCodeAttribute : Attribute
{
/// <summary>
/// 初始化一个<see cref="OperateCodeAttribute"/>类型的新实例
/// </summary>
public OperateCodeAttribute(string code)
{
Code = code;
}
/// <summary>
/// 获取 属性名称
/// </summary>
public string Code { get; private set; }
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 所属用户数据权限
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)]
public class OwnerUserAttribute : Attribute
{
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 查询规则特性
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class QueryRuleAttribute : Attribute
{
/// <summary>
/// 查询字段名称
/// </summary>
public string FieldName { get; set; }
/// <summary>
/// 分组名称
/// </summary>
public string Group { get; set; }
/// <summary>
/// 查询操作符
/// </summary>
public FilterOperateEnum Operate { get; set; }
/// <summary>
/// 分组查询操作符生成sql后面的where 带括号的查询取值只能为or 或 and
/// </summary>
public FilterOperateEnum GroupOperate { get; set; }
/// <summary>
/// 查询规则构造函数
/// </summary>
/// <param name="operate">操作符</param>
/// <param name="fieldName">数据库可接受的查询字段名称,未传直接取属性名称</param>
/// <param name="group">隶属分组</param>
/// <param name="groupOperate">分组查询操作符</param>
public QueryRuleAttribute(FilterOperateEnum operate, string fieldName, string group = "", FilterOperateEnum groupOperate = FilterOperateEnum.And)
{
FieldName = fieldName;
Group = group;
Operate = operate;
GroupOperate = groupOperate;
}
}
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 种子数据特性
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class SeedDataAttribute : Attribute
{
/// <summary>
/// 排序(越大越后执行)
/// </summary>
public int Order { get; set; } = 0;
public SeedDataAttribute(int orderNo)
{
Order = orderNo;
}
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 系统表特性
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class SysTableAttribute : Attribute
{
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
/// <summary>
/// 枚举拓展主题样式
/// </summary>
[AttributeUsage(AttributeTargets.Enum | AttributeTargets.Field)]
public class ThemeAttribute : Attribute
{
public string Theme { get; private set; }
public ThemeAttribute(string theme)
{
this.Theme = theme;
}
}
}

View File

@@ -0,0 +1,141 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using PropertyMetadata = System.Windows.PropertyMetadata;
namespace YY.Admin.Core.Behavior
{
public class TreeViewItemClickBehavior
{
// ICommand attached property
public static readonly DependencyProperty CommandProperty =
DependencyProperty.RegisterAttached(
"Command",
typeof(ICommand),
typeof(TreeViewItemClickBehavior),
new PropertyMetadata(null, OnCommandChanged));
public static void SetCommand(DependencyObject obj, ICommand value) => obj.SetValue(CommandProperty, value);
public static ICommand GetCommand(DependencyObject obj) => (ICommand)obj.GetValue(CommandProperty);
// CommandParameter attached property
public static readonly DependencyProperty CommandParameterProperty =
DependencyProperty.RegisterAttached(
"CommandParameter",
typeof(object),
typeof(TreeViewItemClickBehavior),
new PropertyMetadata(null));
public static void SetCommandParameter(DependencyObject obj, object value) => obj.SetValue(CommandParameterProperty, value);
public static object GetCommandParameter(DependencyObject obj) => obj.GetValue(CommandParameterProperty);
private static void OnCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is TreeViewItem tvi)
{
if (e.NewValue != null && e.OldValue == null)
{
tvi.PreviewMouseLeftButtonUp += OnTreeViewItemMouseLeftButtonUp;
// 监听 TreeViewItem 展开事件Expanded
tvi.Expanded += OnTreeViewItemExpanded;
}
else if (e.NewValue == null && e.OldValue != null)
{
tvi.PreviewMouseLeftButtonUp -= OnTreeViewItemMouseLeftButtonUp;
tvi.Expanded -= OnTreeViewItemExpanded;
}
}
else
{
// 如果Style被应用到容器等也可能不是TreeViewItem延迟订阅由容器化过程处理
// 一般我们只使用在 TreeViewItem 上设置
}
}
private static void OnTreeViewItemMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
// 忽略点击展开按钮
if (e.OriginalSource is DependencyObject dep)
{
if (FindAncestor<ToggleButton>(dep) != null) return;
// 找到真正的 TreeViewItem
var tvi = FindAncestor<TreeViewItem>(dep);
if (tvi == null) return;
var cmd = GetCommand(tvi);
var param = GetCommandParameter(tvi) ?? tvi.DataContext;
// 检查是否有子项
bool hasChildren = tvi.HasItems;
// 如果有子项,则切换展开状态
if (hasChildren)
{
bool IsExpanded = !tvi.IsExpanded;
tvi.IsExpanded = IsExpanded;
// 展开
//if (IsExpanded)
//{
// // 关闭同级其它节点
// CollapseSiblings(tvi);
//}
e.Handled = true;
return;
}
if (cmd != null && cmd.CanExecute(param))
{
cmd.Execute(param);
e.Handled = true;
}
}
}
private static void OnTreeViewItemExpanded(object sender, RoutedEventArgs e)
{
if (sender is TreeViewItem tvi)
{
// 防止事件冒泡重复执行
if (e.OriginalSource != tvi)
return;
CollapseSiblings(tvi);
}
}
/// <summary>
/// 折叠同级其它 TreeViewItem
/// </summary>
private static void CollapseSiblings(TreeViewItem currentItem)
{
ItemsControl parent = ItemsControl.ItemsControlFromItemContainer(currentItem);
if (parent == null) return;
foreach (var item in parent.Items)
{
var container = parent.ItemContainerGenerator.ContainerFromItem(item) as TreeViewItem;
if (container != null && container != currentItem)
{
container.IsExpanded = false;
}
}
}
// 查找祖先辅助方法
private static T FindAncestor<T>(DependencyObject current) where T : DependencyObject
{
while (current != null)
{
if (current is T t) return t;
current = VisualTreeHelper.GetParent(current);
}
return null;
}
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core.BusinessException
{
public class BusinessException : Exception
{
public ErrorCodeEnum ErrorCode { get; }
public BusinessException(ErrorCodeEnum errorCode, string message)
: base(message) => ErrorCode = errorCode;
}
public static class Oops
{
public static BusinessException Oh(ErrorCodeEnum errorCode)
{
// 获取错误信息
string message = $"业务错误 {errorCode}: {errorCode.GetDescription()}";
return new BusinessException(errorCode, message);
}
public static BusinessException Oh(string error)
{
// 获取错误信息
string message = $"业务错误 {error}";
return new BusinessException(ErrorCodeEnum.A1000, message);
}
}
}

View File

@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core
{
public interface ISysCacheService
{
/// <summary>
/// 申请分布式锁
/// </summary>
IDisposable? BeginCacheLock(string key, int msTimeout = 500, int msExpire = 10000, bool throwOnFailure = true);
/// <summary>
/// 获取缓存键名集合
/// </summary>
List<string> GetKeyList();
/// <summary>
/// 增加缓存
/// </summary>
bool Set(string key, object value);
/// <summary>
/// 增加缓存并设置过期时间
/// </summary>
bool Set(string key, object value, TimeSpan expire);
/// <summary>
/// 异步获取或添加缓存(无参数)
/// </summary>
Task<TR> AdGetAsync<TR>(string cacheName, Func<Task<TR>> del, TimeSpan? expiry = default) where TR : class;
/// <summary>
/// 异步获取或添加缓存1个参数
/// </summary>
Task<TR> AdGetAsync<TR, T1>(string cacheName, Func<T1, Task<TR>> del, T1 t1, TimeSpan? expiry = default) where TR : class;
/// <summary>
/// 异步获取或添加缓存2个参数
/// </summary>
Task<TR> AdGetAsync<TR, T1, T2>(string cacheName, Func<T1, T2, Task<TR>> del, T1 t1, T2 t2, TimeSpan? expiry = default) where TR : class;
/// <summary>
/// 异步获取或添加缓存3个参数
/// </summary>
Task<TR> AdGetAsync<TR, T1, T2, T3>(string cacheName, Func<T1, T2, T3, Task<TR>> del, T1 t1, T2 t2, T3 t3, TimeSpan? expiry = default) where TR : class;
/// <summary>
/// 获取缓存1个参数
/// </summary>
T Get<T>(string cacheName, object t1);
/// <summary>
/// 获取缓存2个参数
/// </summary>
T Get<T>(string cacheName, object t1, object t2);
/// <summary>
/// 获取缓存3个参数
/// </summary>
T Get<T>(string cacheName, object t1, object t2, object t3);
/// <summary>
/// 获取缓存的剩余生存时间
/// </summary>
TimeSpan GetExpire(string key);
/// <summary>
/// 获取缓存
/// </summary>
T Get<T>(string key);
/// <summary>
/// 删除缓存
/// </summary>
int Remove(string key);
/// <summary>
/// 清空所有缓存
/// </summary>
void Clear();
/// <summary>
/// 检查缓存是否存在
/// </summary>
bool ExistKey(string key);
/// <summary>
/// 根据键名前缀删除缓存
/// </summary>
int RemoveByPrefixKey(string prefixKey);
/// <summary>
/// 根据键名前缀获取键名集合
/// </summary>
List<string> GetKeysByPrefixKey(string prefixKey);
/// <summary>
/// 获取缓存值(原始对象)
/// </summary>
object GetValue(string key);
/// <summary>
/// 获取或添加缓存(在数据不存在时执行委托请求数据)
/// </summary>
T GetOrAdd<T>(string key, Func<string, T> callback, int expire = -1);
/// <summary>
/// 获取Hash缓存字典
/// </summary>
IDictionary<string, T> GetHashMap<T>(string key);
/// <summary>
/// 批量添加Hash值
/// </summary>
bool HashSet<T>(string key, Dictionary<string, T> dic);
/// <summary>
/// 添加一条Hash值
/// </summary>
void HashAdd<T>(string key, string hashKey, T value);
/// <summary>
/// 添加或更新一条Hash值
/// </summary>
void HashAddOrUpdate<T>(string key, string hashKey, T value);
/// <summary>
/// 获取多条Hash值
/// </summary>
List<T> HashGet<T>(string key, params string[] fields);
/// <summary>
/// 获取一条Hash值
/// </summary>
T HashGetOne<T>(string key, string field);
/// <summary>
/// 根据KEY获取所有Hash值
/// </summary>
IDictionary<string, T> HashGetAll<T>(string key);
/// <summary>
/// 删除Hash值
/// </summary>
int HashDel<T>(string key, params string[] fields);
}
}

View File

@@ -0,0 +1,437 @@

using NewLife.Caching;
using Newtonsoft.Json;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
using YY.Admin.Core.Option;
namespace YY.Admin.Core
{
public class SysCacheService : ISysCacheService
{
private readonly ICacheProvider _cacheProvider;
private readonly BaseCacheOptions _cacheOptions;
public SysCacheService(ICacheProvider provider, BaseCacheOptions cacheOptions)
{
_cacheProvider = provider;
_cacheOptions = cacheOptions;
}
/// <summary>
/// 申请分布式锁 🔖
/// </summary>
/// <param name="key">要锁定的key</param>
/// <param name="msTimeout">申请锁等待的时间,单位毫秒</param>
/// <param name="msExpire">锁过期时间,超过该时间没有主动是放则自动是放,必须整数秒,单位毫秒</param>
/// <param name="throwOnFailure">失败时是否抛出异常,如不抛出异常可通过判断返回null得知申请锁失败</param>
/// <returns></returns>
[DisplayName("申请分布式锁")]
public IDisposable? BeginCacheLock(string key, int msTimeout = 500, int msExpire = 10000, bool throwOnFailure = true)
{
try
{
return _cacheProvider.Cache.AcquireLock(key, msTimeout, msExpire, throwOnFailure);
}
catch
{
return null;
}
}
/// <summary>
/// 获取缓存键名集合 🔖
/// </summary>
/// <returns></returns>
[DisplayName("获取缓存键名集合")]
public List<string> GetKeyList()
{
return _cacheProvider.Cache == Cache.Default
? [.. _cacheProvider.Cache.Keys.Where(u => u.StartsWith(_cacheOptions.Prefix)).Select(u => u[_cacheOptions.Prefix.Length..]).OrderBy(u => u)]
: [.. ((FullRedis)_cacheProvider.Cache).Search($"{_cacheOptions.Prefix}*", int.MaxValue).Select(u => u[_cacheOptions.Prefix.Length..]).OrderBy(u => u)];
}
/// <summary>
/// 增加缓存
/// </summary>
/// <param name="key"></param>
/// <param name="value"></param>
/// <returns></returns>
public bool Set(string key, object value)
{
return !string.IsNullOrWhiteSpace(key) && _cacheProvider.Cache.Set($"{_cacheOptions.Prefix}{key}", value);
}
/// <summary>
/// 增加缓存并设置过期时间
/// </summary>
/// <param name="key"></param>
/// <param name="value"></param>
/// <param name="expire"></param>
/// <returns></returns>
public bool Set(string key, object value, TimeSpan expire)
{
return !string.IsNullOrWhiteSpace(key) && _cacheProvider.Cache.Set($"{_cacheOptions.Prefix}{key}", value, expire);
}
public async Task<TR> AdGetAsync<TR>(String cacheName, Func<Task<TR>> del, TimeSpan? expiry = default) where TR : class
{
return await AdGetAsync<TR>(cacheName, del, [], expiry);
}
public async Task<TR> AdGetAsync<TR, T1>(String cacheName, Func<T1, Task<TR>> del, T1 t1, TimeSpan? expiry = default) where TR : class
{
return await AdGetAsync<TR>(cacheName, del, [t1], expiry);
}
public async Task<TR> AdGetAsync<TR, T1, T2>(String cacheName, Func<T1, T2, Task<TR>> del, T1 t1, T2 t2, TimeSpan? expiry = default) where TR : class
{
return await AdGetAsync<TR>(cacheName, del, [t1, t2], expiry);
}
public async Task<TR> AdGetAsync<TR, T1, T2, T3>(String cacheName, Func<T1, T2, T3, Task<TR>> del, T1 t1, T2 t2, T3 t3, TimeSpan? expiry = default) where TR : class
{
return await AdGetAsync<TR>(cacheName, del, [t1, t2, t3], expiry);
}
private async Task<T> AdGetAsync<T>(string cacheName, Delegate del, Object[] obs, TimeSpan? expiry) where T : class
{
var key = Key(cacheName, obs);
// 使用分布式锁
using (_cacheProvider.Cache.AcquireLock($@"lock:AdGetAsync:{cacheName}", 1000))
{
var value = Get<T>(key);
value ??= await ((dynamic)del).DynamicInvokeAsync(obs);
Set(key, value);
return value;
}
}
public T Get<T>(String cacheName, object t1)
{
return Get<T>(cacheName, [t1]);
}
public T Get<T>(String cacheName, object t1, object t2)
{
return Get<T>(cacheName, [t1, t2]);
}
public T Get<T>(String cacheName, object t1, object t2, object t3)
{
return Get<T>(cacheName, [t1, t2, t3]);
}
private T Get<T>(String cacheName, Object[] obs)
{
var key = cacheName + ":" + obs.Aggregate(string.Empty, (current, o) => current + $"<{o}>");
return Get<T>(key);
}
private static string Key(string cacheName, object[] obs)
{
if (obs.OfType<TimeSpan>().Any()) throw new Exception("缓存参数类型不能能是:TimeSpan类型");
StringBuilder sb = new(cacheName + ":");
foreach (var a in obs) sb.Append($"<{KeySingle(a)}>");
return sb.ToString();
}
private static string KeySingle(object t)
{
return t.GetType().IsClass && !t.GetType().IsPrimitive ? JsonConvert.SerializeObject(t) : t.ToString();
}
/// <summary>
/// 获取缓存的剩余生存时间
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
public TimeSpan GetExpire(string key)
{
return _cacheProvider.Cache.GetExpire(key);
}
/// <summary>
/// 获取缓存
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <returns></returns>
public T Get<T>(string key)
{
return _cacheProvider.Cache.Get<T>($"{_cacheOptions.Prefix}{key}");
}
/// <summary>
/// 删除缓存 🔖
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
[DisplayName("删除缓存")]
public int Remove(string key)
{
return _cacheProvider.Cache.Remove($"{_cacheOptions.Prefix}{key}");
}
/// <summary>
/// 清空所有缓存 🔖
/// </summary>
/// <returns></returns>
[DisplayName("清空所有缓存")]
public void Clear()
{
_cacheProvider.Cache.Clear();
Cache.Default.Clear();
}
/// <summary>
/// 检查缓存是否存在
/// </summary>
/// <param name="key">键</param>
/// <returns></returns>
public bool ExistKey(string key)
{
return _cacheProvider.Cache.ContainsKey($"{_cacheOptions.Prefix}{key}");
}
/// <summary>
/// 根据键名前缀删除缓存 🔖
/// </summary>
/// <param name="prefixKey">键名前缀</param>
/// <returns></returns>
[DisplayName("根据键名前缀删除缓存")]
public int RemoveByPrefixKey(string prefixKey)
{
var delKeys = _cacheProvider.Cache == Cache.Default
? _cacheProvider.Cache.Keys.Where(u => u.StartsWith($"{_cacheOptions.Prefix}{prefixKey}")).ToArray()
: ((FullRedis)_cacheProvider.Cache).Search($"{_cacheOptions.Prefix}{prefixKey}*", int.MaxValue).ToArray();
return _cacheProvider.Cache.Remove(delKeys);
}
/// <summary>
/// 根据键名前缀获取键名集合 🔖
/// </summary>
/// <param name="prefixKey">键名前缀</param>
/// <returns></returns>
[DisplayName("根据键名前缀获取键名集合")]
public List<string> GetKeysByPrefixKey(string prefixKey)
{
return _cacheProvider.Cache == Cache.Default
? _cacheProvider.Cache.Keys.Where(u => u.StartsWith($"{_cacheOptions.Prefix}{prefixKey}")).Select(u => u[_cacheOptions.Prefix.Length..]).ToList()
: ((FullRedis)_cacheProvider.Cache).Search($"{_cacheOptions.Prefix}{prefixKey}*", int.MaxValue).Select(u => u[_cacheOptions.Prefix.Length..]).ToList();
}
/// <summary>
/// 获取缓存值 🔖
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
[DisplayName("获取缓存值")]
public object GetValue(string key)
{
if (string.IsNullOrEmpty(key)) return null;
if (Regex.IsMatch(key, @"%[0-9a-fA-F]{2}"))
key = HttpUtility.UrlDecode(key);
var fullKey = $"{_cacheOptions.Prefix}{key}";
if (_cacheProvider.Cache == Cache.Default)
return _cacheProvider.Cache.Get<object>(fullKey);
if (_cacheProvider.Cache is FullRedis redisCache)
{
if (!redisCache.ContainsKey(fullKey))
return null;
try
{
var keyType = redisCache.TYPE(fullKey)?.ToLower();
switch (keyType)
{
case "string":
return redisCache.Get<string>(fullKey);
case "list":
var list = redisCache.GetList<string>(fullKey);
return list?.ToList();
case "hash":
var hash = redisCache.GetDictionary<string>(fullKey);
return hash?.ToDictionary(k => k.Key, v => v.Value);
case "set":
var set = redisCache.GetSet<string>(fullKey);
return set?.ToArray();
case "zset":
var sortedSet = redisCache.GetSortedSet<string>(fullKey);
return sortedSet?.Range(0, -1)?.ToList();
case "none":
return null;
default:
// 未知类型或特殊类型
return new Dictionary<string, object>
{
{ "key", key },
{ "type", keyType ?? "unknown" },
{ "message", "无法使用标准方式获取此类型数据" }
};
}
}
catch (Exception ex)
{
return new Dictionary<string, object>
{
{ "key", key },
{ "error", ex.Message },
{ "type", "exception" }
};
}
}
return _cacheProvider.Cache.Get<object>(fullKey);
}
/// <summary>
/// 获取或添加缓存(在数据不存在时执行委托请求数据)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <param name="callback"></param>
/// <param name="expire">过期时间,单位秒</param>
/// <returns></returns>
public T GetOrAdd<T>(string key, Func<string, T> callback, int expire = -1)
{
if (string.IsNullOrWhiteSpace(key)) return default;
return _cacheProvider.Cache.GetOrAdd($"{_cacheOptions.Prefix}{key}", callback, expire);
}
/// <summary>
/// Hash匹配
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <returns></returns>
public IDictionary<String, T> GetHashMap<T>(string key)
{
return _cacheProvider.Cache.GetDictionary<T>(key);
}
/// <summary>
/// 批量添加HASH
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <param name="dic"></param>
/// <returns></returns>
public bool HashSet<T>(string key, Dictionary<string, T> dic)
{
var hash = GetHashMap<T>(key);
foreach (var v in dic)
{
hash.Add(v);
}
return true;
}
/// <summary>
/// 添加一条HASH
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <param name="hashKey"></param>
/// <param name="value"></param>
public void HashAdd<T>(string key, string hashKey, T value)
{
var hash = GetHashMap<T>(key);
hash.Add(hashKey, value);
}
/// <summary>
/// 添加或更新一条HASH
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <param name="hashKey"></param>
/// <param name="value"></param>
public void HashAddOrUpdate<T>(string key, string hashKey, T value)
{
var hash = GetHashMap<T>(key);
if (hash.ContainsKey(hashKey))
hash[hashKey] = value;
else
hash.Add(hashKey, value);
}
/// <summary>
/// 获取多条HASH
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <param name="fields"></param>
/// <returns></returns>
public List<T> HashGet<T>(string key, params string[] fields)
{
var hash = GetHashMap<T>(key);
return hash.Where(t => fields.Any(c => t.Key == c)).Select(t => t.Value).ToList();
}
/// <summary>
/// 获取一条HASH
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <param name="field"></param>
/// <returns></returns>
public T HashGetOne<T>(string key, string field)
{
var hash = GetHashMap<T>(key);
return hash.TryGetValue(field, out T value) ? value : default;
}
/// <summary>
/// 根据KEY获取所有HASH
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <returns></returns>
public IDictionary<string, T> HashGetAll<T>(string key)
{
var hash = GetHashMap<T>(key);
return hash;
}
/// <summary>
/// 删除HASH
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <param name="fields"></param>
/// <returns></returns>
public int HashDel<T>(string key, params string[] fields)
{
var hash = GetHashMap<T>(key);
fields.ToList().ForEach(t => hash.Remove(t));
return fields.Length;
}
}
}

View File

@@ -0,0 +1,124 @@
namespace YY.Admin.Core.Const;
/// <summary>
/// 缓存相关常量
/// </summary>
public class CacheConst
{
/// <summary>
/// 用户权限缓存(按钮集合)
/// </summary>
public const string KeyUserButton = "sys_user_button:";
/// <summary>
/// 用户机构缓存
/// </summary>
public const string KeyUserOrg = "sys_user_org:";
/// <summary>
/// 角色最大数据范围缓存
/// </summary>
public const string KeyRoleMaxDataScope = "sys_role_maxDataScope:";
/// <summary>
/// 在线用户缓存
/// </summary>
public const string KeyUserOnline = "sys_user_online:";
/// <summary>
/// 图形验证码缓存
/// </summary>
public const string KeyVerCode = "sys_verCode:";
/// <summary>
/// 手机验证码缓存
/// </summary>
public const string KeyPhoneVerCode = "sys_phoneVerCode:";
/// <summary>
/// 密码错误次数缓存
/// </summary>
public const string KeyPasswordErrorTimes = "sys_password_error_times:";
/// <summary>
/// 租户缓存
/// </summary>
public const string KeyTenant = "sys_tenant";
/// <summary>
/// 常量下拉框
/// </summary>
public const string KeyConst = "sys_const:";
/// <summary>
/// 所有缓存关键字集合
/// </summary>
public const string KeyAll = "sys_keys";
/// <summary>
/// SqlSugar二级缓存
/// </summary>
public const string SqlSugar = "sys_sqlSugar:";
/// <summary>
/// 开放接口身份缓存
/// </summary>
public const string KeyOpenAccess = "sys_open_access:";
/// <summary>
/// 开放接口身份随机数缓存
/// </summary>
public const string KeyOpenAccessNonce = "sys_open_access_nonce:";
/// <summary>
/// 登录黑名单
/// </summary>
public const string KeyBlacklist = "sys_blacklist:";
/// <summary>
/// 系统配置缓存
/// </summary>
public const string KeyConfig = "sys_config:";
/// <summary>
/// 系统租户配置缓存
/// </summary>
public const string KeyTenantConfig = "sys_tenant_config:";
/// <summary>
/// 系统用户配置缓存
/// </summary>
public const string KeyUserConfig = "sys_user_config:";
/// <summary>
/// 系统字典缓存
/// </summary>
public const string KeyDict = "sys_dict:";
/// <summary>
/// 系统租户字典缓存
/// </summary>
public const string KeyTenantDict = "sys_tenant_dict:";
/// <summary>
/// 重复请求(幂等)字典缓存
/// </summary>
public const string KeyIdempotent = "sys_idempotent:";
/// <summary>
/// Excel临时文件缓存
/// </summary>
public const string KeyExcelTemp = "sys_excel_temp:";
/// <summary>
/// 系统更新命令日志缓存
/// </summary>
public const string KeySysUpdateLog = "sys_update_log";
/// <summary>
/// 系统更新间隔标记缓存
/// </summary>
public const string KeySysUpdateInterval = "sys_update_interval";
}

View File

@@ -0,0 +1,64 @@

namespace YY.Admin.Core.Const;
/// <summary>
/// Claim相关常量
/// </summary>
public class ClaimConst
{
/// <summary>
/// 用户Id
/// </summary>
public const string UserId = "UserId";
/// <summary>
/// 账号
/// </summary>
public const string Account = "Account";
/// <summary>
/// 真实姓名
/// </summary>
public const string RealName = "RealName";
/// <summary>
/// 昵称
/// </summary>
public const string NickName = "NickName";
/// <summary>
/// 账号类型
/// </summary>
public const string AccountType = "AccountType";
/// <summary>
/// 租户Id
/// </summary>
public const string TenantId = "TenantId";
/// <summary>
/// 组织机构Id
/// </summary>
public const string OrgId = "OrgId";
/// <summary>
/// 组织机构名称
/// </summary>
public const string OrgName = "OrgName";
/// <summary>
/// 组织机构类型
/// </summary>
public const string OrgType = "OrgType";
/// <summary>
/// 微信OpenId
/// </summary>
public const string OpenId = "OpenId";
/// <summary>
/// 登录模式PC、APP
/// </summary>
public const string LoginMode = "LoginMode";
}

View File

@@ -0,0 +1,53 @@
namespace YY.Admin.Core.Const;
/// <summary>
/// 通用常量
/// </summary>
[Const("平台配置")]
public class CommonConst
{
/// <summary>
/// 日志分组名称
/// </summary>
public const string SysLogCategoryName = "System.Logging.LoggingMonitor";
/// <summary>
/// 事件-增加异常日志
/// </summary>
public const string AddExLog = "Add:ExLog";
/// <summary>
/// 事件-发送异常邮件
/// </summary>
public const string SendErrorMail = "Send:ErrorMail";
/// <summary>
/// 默认基本角色名称
/// </summary>
public const string DefaultBaseRoleName = "默认基本角色";
/// <summary>
/// 默认基本角色编码
/// </summary>
public const string DefaultBaseRoleCode = "default_base_role";
/// <summary>
/// MainWinndow 主内容区域名称
/// </summary>
public const string ContentRegion = "ContentRegion";
/// <summary>
/// MainWinndow 菜单区域名称
/// </summary>
public const string MenuRegion = "MenuRegion";
/// <summary>
/// 系统名称
/// </summary>
public const string SystemName = "智能制造MES工控";
/// <summary>
/// 系统设置文件路径
/// </summary>
public const string AppSettingsFilePath = "AppSettings\\{0}\\appsettings.json";
}

View File

@@ -0,0 +1,144 @@
namespace YY.Admin.Core.Const;
/// <summary>
/// 配置常量
/// </summary>
public class ConfigConst
{
/// <summary>
/// 演示环境
/// </summary>
public const string SysDemoEnv = "sys_demo";
/// <summary>
/// 默认密码
/// </summary>
public const string SysPassword = "sys_password";
/// <summary>
/// 密码最大错误次数
/// </summary>
public const string SysPasswordMaxErrorTimes = "sys_password_max_error_times";
/// <summary>
/// 日志保留天数
/// </summary>
public const string SysLogRetentionDays = "sys_log_retention_days";
/// <summary>
/// 记录操作日志
/// </summary>
public const string SysOpLog = "sys_oplog";
/// <summary>
/// 单设备登录
/// </summary>
public const string SysSingleLogin = "sys_single_login";
/// <summary>
/// 登入登出提醒
/// </summary>
public const string SysLoginOutReminder = "sys_login_out_reminder";
/// <summary>
/// 登陆时隐藏租户
/// </summary>
public const string SysHideTenantLogin = "sys_hide_tenant_login";
/// <summary>
/// 登录二次验证
/// </summary>
public const string SysSecondVer = "sys_second_ver";
/// <summary>
/// 图形验证码
/// </summary>
public const string SysCaptcha = "sys_captcha";
/// <summary>
/// Token过期时间
/// </summary>
public const string SysTokenExpire = "sys_token_expire";
/// <summary>
/// RefreshToken过期时间
/// </summary>
public const string SysRefreshTokenExpire = "sys_refresh_token_expire";
/// <summary>
/// 发送异常日志邮件
/// </summary>
public const string SysErrorMail = "sys_error_mail";
/// <summary>
/// 域登录验证
/// </summary>
public const string SysDomainLogin = "sys_domain_login";
// /// <summary>
// /// 租户域名隔离登录验证
// /// </summary>
// public const string SysTenantHostLogin = "sys_tenant_host_login";
/// <summary>
/// 数据校验日志
/// </summary>
public const string SysValidationLog = "sys_validation_log";
/// <summary>
/// 行政区域同步层级 1-省级,2-市级,3-区县级,4-街道级,5-村级
/// </summary>
public const string SysRegionSyncLevel = "sys_region_sync_level";
/// <summary>
/// Default 分组
/// </summary>
public const string SysDefaultGroup = "Default";
/// <summary>
/// 支付宝授权页面地址
/// </summary>
public const string AlipayAuthPageUrl = "alipay_auth_page_url_";
// /// <summary>
// /// 系统图标
// /// </summary>
// public const string SysWebLogo = "sys_web_logo";
//
// /// <summary>
// /// 系统主标题
// /// </summary>
// public const string SysWebTitle = "sys_web_title";
//
// /// <summary>
// /// 系统副标题
// /// </summary>
// public const string SysWebViceTitle = "sys_web_viceTitle";
//
// /// <summary>
// /// 系统描述
// /// </summary>
// public const string SysWebViceDesc = "sys_web_viceDesc";
//
// /// <summary>
// /// 水印内容
// /// </summary>
// public const string SysWebWatermark = "sys_web_watermark";
//
// /// <summary>
// /// 版权说明
// /// </summary>
// public const string SysWebCopyright = "sys_web_copyright";
//
// /// <summary>
// /// ICP备案号
// /// </summary>
// public const string SysWebIcp = "sys_web_icp";
//
// /// <summary>
// /// ICP地址
// /// </summary>
// public const string SysWebIcpUrl = "sys_web_icpUrl";
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YY.Admin.Core.Const
{
public class SqlSugarConst
{
/// <summary>
/// 默认主数据库标识(默认租户)
/// </summary>
public const string MainConfigId = "1300000000001";
/// <summary>
/// 默认日志数据库标识
/// </summary>
public const string LogConfigId = "1300000000002";
/// <summary>
/// 默认表主键
/// </summary>
public const string PrimaryKey = "Id";
/// <summary>
/// 默认租户Id
/// </summary>
public const long DefaultTenantId = 1300000000001;
}
}

View File

@@ -0,0 +1,300 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using PropertyMetadata = System.Windows.PropertyMetadata;
namespace YY.Admin.Core.Controls
{
//图标官网
public enum FontAwesomeStyle
{
/// <summary>
/// 常规
/// </summary>
Regular,
/// <summary>
/// 实心的
/// </summary>
Solid,
/// <summary>
/// 品牌Logo
/// </summary>
Brands
}
public class FontAwesomeIcon : TextBlock
{
#region Icon
public static readonly DependencyProperty IconProperty =
DependencyProperty.Register(nameof(Icon), typeof(string), typeof(FontAwesomeIcon),
new PropertyMetadata(null, (d, e) => ((FontAwesomeIcon)d).Text = e.NewValue?.ToString()));
public string Icon
{
get => (string)GetValue(IconProperty);
set => SetValue(IconProperty, value);
}
#endregion
#region Spin
public static readonly DependencyProperty SpinProperty =
DependencyProperty.Register(nameof(Spin), typeof(bool), typeof(FontAwesomeIcon),
new PropertyMetadata(false, (d, e) => ((FontAwesomeIcon)d).UpdateAnimation()));
public bool Spin
{
get => (bool)GetValue(SpinProperty);
set => SetValue(SpinProperty, value);
}
#endregion
#region SpinSpeed
public static readonly DependencyProperty SpinSpeedProperty =
DependencyProperty.Register(nameof(SpinSpeed), typeof(double), typeof(FontAwesomeIcon),
new PropertyMetadata(1.0, (d, e) =>
{
var fa = (FontAwesomeIcon)d;
if (fa.Spin)
fa.StartSpin();
}));
public double SpinSpeed
{
get => (double)GetValue(SpinSpeedProperty);
set => SetValue(SpinSpeedProperty, value);
}
#endregion
#region Beat
public static readonly DependencyProperty BeatProperty =
DependencyProperty.Register(nameof(Beat), typeof(bool), typeof(FontAwesomeIcon),
new PropertyMetadata(false, (d, e) => ((FontAwesomeIcon)d).UpdateAnimation()));
public bool Beat
{
get => (bool)GetValue(BeatProperty);
set => SetValue(BeatProperty, value);
}
#endregion
#region BeatScale
public static readonly DependencyProperty BeatScaleProperty =
DependencyProperty.Register(nameof(BeatScale), typeof(double), typeof(FontAwesomeIcon),
new PropertyMetadata(1.3));
public double BeatScale
{
get => (double)GetValue(BeatScaleProperty);
set => SetValue(BeatScaleProperty, value);
}
#endregion
#region BeatDuration
public static readonly DependencyProperty BeatDurationProperty =
DependencyProperty.Register(nameof(BeatDuration), typeof(double), typeof(FontAwesomeIcon),
new PropertyMetadata(0.5));
public double BeatDuration
{
get => (double)GetValue(BeatDurationProperty);
set => SetValue(BeatDurationProperty, value);
}
#endregion
#region IconFamily
public static readonly DependencyProperty IconFamilyProperty =
DependencyProperty.Register(nameof(IconFamily), typeof(FontAwesomeStyle), typeof(FontAwesomeIcon),
new PropertyMetadata(FontAwesomeStyle.Regular, OnIconFamilyChanged));
public FontAwesomeStyle IconFamily
{
get => (FontAwesomeStyle)GetValue(IconFamilyProperty);
set => SetValue(IconFamilyProperty, value);
}
private static void OnIconFamilyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var fa = (FontAwesomeIcon)d;
fa.UpdateFontFamily();
}
private void UpdateFontFamily()
{
FontFamily = IconFamily switch
{
FontAwesomeStyle.Regular => (FontFamily)Application.Current.Resources["FontAwesomeRegular"],
FontAwesomeStyle.Solid => (FontFamily)Application.Current.Resources["FontAwesomeSolid"],
FontAwesomeStyle.Brands => (FontFamily)Application.Current.Resources["FontAwesomeBrands"],
_ => (FontFamily)Application.Current.Resources["FontAwesomeRegular"]
};
}
#endregion
// Transform components
private RotateTransform? _rotateTransform;
private ScaleTransform? _scaleTransform;
private TransformGroup? _transformGroup;
private bool _initialized = false;
private DoubleAnimation? _currentSpinAnimation;
public FontAwesomeIcon()
{
// 设置文本对齐方式,确保内容居中
TextAlignment = TextAlignment.Center;
VerticalAlignment = VerticalAlignment.Center;
HorizontalAlignment = HorizontalAlignment.Center;
// 设置默认字体家庭
UpdateFontFamily();
// 创建 transforms
_rotateTransform = new RotateTransform();
_scaleTransform = new ScaleTransform(1, 1);
_transformGroup = new TransformGroup();
_transformGroup.Children.Add(_scaleTransform);
_transformGroup.Children.Add(_rotateTransform);
// 使用 RenderTransform 避免布局抖动
RenderTransform = _transformGroup;
// 关键:设置变换原点为内容中心
RenderTransformOrigin = new Point(0.5, 0.5);
// 延迟初始化到 Loaded
Loaded += OnLoadedSafe;
Unloaded += OnUnloadedSafe;
SizeChanged += OnSizeChangedSafe;
}
private void OnLoadedSafe(object? sender, RoutedEventArgs e)
{
Dispatcher.BeginInvoke((Action)(() =>
{
if (_initialized) return;
_initialized = true;
UpdateAnimation();
}), DispatcherPriority.Loaded);
}
private void OnSizeChangedSafe(object? sender, SizeChangedEventArgs e)
{
// 确保变换原点始终居中
RenderTransformOrigin = new Point(0.5, 0.5);
}
private void OnUnloadedSafe(object? sender, RoutedEventArgs e)
{
// 停止动画,避免内存泄漏
StopSpin();
StopBeat();
}
private void UpdateAnimation()
{
if (DesignerProperties.GetIsInDesignMode(this)) return;
if (!_initialized)
{
return;
}
if (Spin) StartSpin(); else StopSpin();
if (Beat) StartBeat(); else StopBeat();
}
private void StartSpin()
{
if (_rotateTransform == null) return;
// 重新确认 RenderTransform 已正确赋值
if (RenderTransform != _transformGroup)
{
RenderTransform = _transformGroup;
RenderTransformOrigin = new Point(0.5, 0.5);
}
// 先取消旧动画
StopSpin();
// 方法1使用 By 动画实现无缝旋转(推荐)
var anim = new DoubleAnimation
{
By = 360, // 每次增加360度
Duration = TimeSpan.FromSeconds(Math.Max(0.01, SpinSpeed)),
RepeatBehavior = RepeatBehavior.Forever
};
// 动画在 WPF 内部直接使用 Freezable 缓存,提高性能
anim.Freeze();
_currentSpinAnimation = anim;
_rotateTransform.BeginAnimation(RotateTransform.AngleProperty, anim);
// 方法2使用 IsCumulative 属性(备选方案)
/*
var anim = new DoubleAnimation(0, 360, new Duration(TimeSpan.FromSeconds(Math.Max(0.01, SpinSpeed))))
{
RepeatBehavior = RepeatBehavior.Forever,
IsCumulative = true // 累积值避免跳回0度
};
*/
}
private void StopSpin()
{
_rotateTransform?.BeginAnimation(RotateTransform.AngleProperty, null);
_currentSpinAnimation = null;
// 重置旋转角度
if (_rotateTransform != null)
_rotateTransform.Angle = 0;
}
private void StartBeat()
{
if (_scaleTransform == null)
{
return;
}
// 重新确认 RenderTransform 已正确赋值
if (RenderTransform != _transformGroup)
{
RenderTransform = _transformGroup;
RenderTransformOrigin = new Point(0.5, 0.5);
}
// 先停止已有缩放动画
_scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, null);
_scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, null);
// 使用更平滑的缓动函数
var anim = new DoubleAnimation(1.0, BeatScale, new Duration(TimeSpan.FromSeconds(Math.Max(0.01, BeatDuration))))
{
AutoReverse = true,
RepeatBehavior = RepeatBehavior.Forever,
EasingFunction = new QuadraticEase() { EasingMode = EasingMode.EaseInOut }
};
_scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
_scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
}
private void StopBeat()
{
if (_scaleTransform != null)
{
_scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, null);
_scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, null);
_scaleTransform.ScaleX = 1.0;
_scaleTransform.ScaleY = 1.0;
}
}
}
}

View File

@@ -0,0 +1,255 @@
using System.Windows;
using System.Windows.Controls;
namespace YY.Admin.Core.Controls;
public class GridPanel : Panel
{
// ------------------------------------------------------------
// 间距属性(行列间距)
// ------------------------------------------------------------
public double Gap
{
get => (double)GetValue(GapProperty);
set => SetValue(GapProperty, value);
}
public static readonly DependencyProperty GapProperty =
DependencyProperty.Register(nameof(Gap), typeof(double), typeof(GridPanel),
new FrameworkPropertyMetadata(20.0, FrameworkPropertyMetadataOptions.AffectsMeasure));
// ------------------------------------------------------------
// 横向/纵向布局(类似 CSS flow
// ------------------------------------------------------------
public Orientation Orientation
{
get => (Orientation)GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
public static readonly DependencyProperty OrientationProperty =
DependencyProperty.Register(nameof(Orientation), typeof(Orientation), typeof(GridPanel),
new FrameworkPropertyMetadata(Orientation.Horizontal, FrameworkPropertyMetadataOptions.AffectsMeasure));
// ------------------------------------------------------------
// 测量
// ------------------------------------------------------------
protected override Size MeasureOverride(Size availableSize)
{
return Orientation == Orientation.Horizontal
? MeasureHorizontal(availableSize)
: MeasureVertical(availableSize);
}
// ------------------------------------------------------------
// 布局
// ------------------------------------------------------------
protected override Size ArrangeOverride(Size finalSize)
{
return Orientation == Orientation.Horizontal
? ArrangeHorizontal(finalSize)
: ArrangeVertical(finalSize);
}
// ------------------------------------------------------------
// 横向布局(类似 CSS grid-template-columns
// ------------------------------------------------------------
private Size MeasureHorizontal(Size availableSize)
{
double panelWidth = double.IsInfinity(availableSize.Width)
? MinWidth * 4 + Gap * 3
: availableSize.Width;
// ---- 修复:不能用 MinWidth+Gap 来估算列数,应使用最小子项宽度 ----
double minChildWidth = MinWidth <= 0 ? 1 : MinWidth;
// ---- 修复:列数 = 能放下多少个最小宽度 ----
int columnCount = Math.Max(1, (int)Math.Floor((panelWidth + Gap) / (minChildWidth + Gap)));
int childCount = InternalChildren.Count;
// ---- 修复2如果列数超过子项数裁到子项数实现 auto-fit 行为) ----
if (childCount > 0 && columnCount > childCount)
{
columnCount = childCount;
}
double columnWidth = (panelWidth - Gap * (columnCount - 1)) / columnCount;
double totalHeight = 0;
double rowHeight = 0;
int col = 0;
for (int i = 0; i < childCount; i++)
{
UIElement child = InternalChildren[i];
child.Measure(new Size(columnWidth, double.PositiveInfinity));
rowHeight = Math.Max(rowHeight, child.DesiredSize.Height);
col++;
if (col >= columnCount || i == childCount - 1)
{
// ------------------------------------------------------------
// 最后一行不加Gap
// ------------------------------------------------------------
totalHeight += rowHeight;
if (i != childCount - 1) totalHeight += Gap;
col = 0;
rowHeight = 0;
}
}
return new Size(panelWidth, totalHeight);
}
private Size ArrangeHorizontal(Size finalSize)
{
double panelWidth = finalSize.Width;
// ---- 修复:与 Measure 保持一致 ----
double minChildWidth = MinWidth <= 0 ? 1 : MinWidth;
int columnCount = Math.Max(1, (int)Math.Floor((panelWidth + Gap) / (minChildWidth + Gap)));
int childCount = InternalChildren.Count;
// ---- 修复2裁到子项数以实现 auto-fit ----
if (childCount > 0 && columnCount > childCount)
{
columnCount = childCount;
}
double columnWidth = (panelWidth - Gap * (columnCount - 1)) / columnCount;
double x = 0;
double y = 0;
double rowHeight = 0;
int col = 0;
for (int i = 0; i < childCount; i++)
{
UIElement child = InternalChildren[i];
// 为了保证每列宽度一致,传入 columnWidth 并在 Arrange 时使用 columnWidth
child.Arrange(new Rect(x, y, columnWidth, child.DesiredSize.Height));
rowHeight = Math.Max(rowHeight, child.DesiredSize.Height);
col++;
if (col >= columnCount || i == childCount - 1)
{
x = 0;
// ------------------------------------------------------------
// 最后一行不加Gap
// ------------------------------------------------------------
if (i != childCount - 1) y += rowHeight + Gap;
else y += rowHeight;
col = 0;
rowHeight = 0;
}
else
{
x += columnWidth + Gap;
}
}
return finalSize;
}
// ------------------------------------------------------------
// 纵向布局(类似 CSS grid-template-rows
// ------------------------------------------------------------
private Size MeasureVertical(Size availableSize)
{
double panelHeight = double.IsInfinity(availableSize.Height)
? MinHeight * 4 + Gap * 3
: availableSize.Height;
// ---- 修复:使用最小子项高度,不能用 MinHeight+Gap ----
double minChildHeight = MinHeight <= 0 ? 1 : MinHeight;
// ---- 修复:行数 = 能放下多少个最小高度 ----
int rowCount = Math.Max(1, (int)Math.Floor((panelHeight + Gap) / (minChildHeight + Gap)));
int childCount = InternalChildren.Count;
// ---- 修复2如果行数超过子项数裁到子项数auto-fit ----
if (childCount > 0 && rowCount > childCount)
{
rowCount = childCount;
}
double rowHeight = (panelHeight - Gap * (rowCount - 1)) / rowCount;
double totalWidth = 0;
double columnWidth = 0;
int row = 0;
for (int i = 0; i < childCount; i++)
{
UIElement child = InternalChildren[i];
child.Measure(new Size(double.PositiveInfinity, rowHeight));
columnWidth = Math.Max(columnWidth, child.DesiredSize.Width);
row++;
if (row >= rowCount || i == childCount - 1)
{
// ------------------------------------------------------------
// 最后一列不加Gap
// ------------------------------------------------------------
totalWidth += columnWidth;
if (i != childCount - 1) totalWidth += Gap;
row = 0;
columnWidth = 0;
}
}
return new Size(totalWidth, panelHeight);
}
private Size ArrangeVertical(Size finalSize)
{
double panelHeight = finalSize.Height;
// ---- 修复:与 MeasureVertical 一致 ----
double minChildHeight = MinHeight <= 0 ? 1 : MinHeight;
int rowCount = Math.Max(1, (int)Math.Floor((panelHeight + Gap) / (minChildHeight + Gap)));
int childCount = InternalChildren.Count;
// ---- 修复2裁到子项数 ----
if (childCount > 0 && rowCount > childCount)
{
rowCount = childCount;
}
double rowHeight = (panelHeight - Gap * (rowCount - 1)) / rowCount;
double x = 0;
double y = 0;
double columnWidth = 0;
int row = 0;
for (int i = 0; i < childCount; i++)
{
UIElement child = InternalChildren[i];
child.Arrange(new Rect(x, y, child.DesiredSize.Width, rowHeight));
columnWidth = Math.Max(columnWidth, child.DesiredSize.Width);
row++;
if (row >= rowCount || i == childCount - 1)
{
// ------------------------------------------------------------
// 最后一列不加Gap
// ------------------------------------------------------------
x += columnWidth;
if (i != childCount - 1) x += Gap;
y = 0;
row = 0;
columnWidth = 0;
}
else
{
y += rowHeight + Gap;
}
}
return finalSize;
}
}

View File

@@ -0,0 +1,74 @@
using LiveChartsCore.SkiaSharpView.Painting;
using SkiaSharp;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
using YY.Admin.Core.LiveCharts2;
namespace YY.Admin.Core.Converter
{
public class BrushToSolidColorPaintConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (parameter is not CusSolidColorPaint sourcePaint)
return new SolidColorPaint(SKColors.Transparent);
var skColor = ResolveColor(sourcePaint.CusColor);
var targetPaint = new CusSolidColorPaint(skColor);
MapProperties(sourcePaint, targetPaint);
return targetPaint;
}
// 统一的颜色解析方法
private SKColor ResolveColor(string? colorKey)
{
if (SKColor.TryParse(colorKey, out var parsedColor))
return parsedColor;
var resource = Application.Current.FindResource(colorKey);
return resource switch
{
Color mediaColor => new SKColor(mediaColor.R, mediaColor.G, mediaColor.B, mediaColor.A),
SolidColorBrush brush => new SKColor(brush.Color.R, brush.Color.G, brush.Color.B, brush.Color.A),
_ => SKColors.Transparent,
};
}
protected void MapProperties(CusSolidColorPaint sourcePaint, CusSolidColorPaint targetPaint)
{
targetPaint.IsAntialias = sourcePaint.IsAntialias;
targetPaint.StrokeThickness = sourcePaint.StrokeThickness;
targetPaint.StrokeCap = sourcePaint.StrokeCap;
targetPaint.StrokeMiter = sourcePaint.StrokeMiter;
targetPaint.StrokeJoin = sourcePaint.StrokeJoin;
targetPaint.ImageFilter = sourcePaint.ImageFilter;
targetPaint.PathEffect = sourcePaint.PathEffect;
#pragma warning disable CS0618 // Type or member is obsolete
if (sourcePaint.FontWeight != SKFontStyleWeight.Normal || sourcePaint.FontWidth != SKFontStyleWidth.Normal || sourcePaint.FontSlant != SKFontStyleSlant.Upright)
targetPaint.SKFontStyle = new SKFontStyle(sourcePaint.FontWeight, sourcePaint.FontWidth, sourcePaint.FontSlant);
#pragma warning restore CS0618 // Type or member is obsolete
if (sourcePaint?.FontFamily is not null)
{
targetPaint.SKTypeface = SKTypeface.FromFamilyName(sourcePaint?.FontFamily);
}
else if (sourcePaint?.TypefaceMatchesChar is not null && sourcePaint.TypefaceMatchesChar.Length > 0)
{
targetPaint.SKTypeface = SKFontManager.Default.MatchCharacter(sourcePaint.TypefaceMatchesChar[0]);
}
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,25 @@
using System.Globalization;
using System.Reflection;
using System.Windows.Data;
namespace YY.Admin.Core.Converter
{
public class EnumDescriptionConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null || value.ToString() == null) return string.Empty;
var field = value.GetType().GetField(value.ToString()!);
if (field == null) return value.ToString()!;
var attribute = field.GetCustomAttribute<DescriptionAttribute>();
return attribute?.Description ?? value.ToString()!;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Globalization;
using System.Windows.Data;
namespace YY.Admin.Core.Converter
{
/// <summary>
/// 将枚举值和 bool 互转,适用于 ToggleButton 或 RadioButton
/// </summary>
public class EnumToBoolConverter : IValueConverter
{
/// <summary>
/// 将枚举值转换为 bool
/// </summary>
/// <param name="value">绑定的枚举值</param>
/// <param name="targetType"></param>
/// <param name="parameter">ConverterParameter 指定要匹配的枚举值</param>
/// <param name="culture"></param>
/// <returns>匹配返回 true否则 false</returns>
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
string enumValue = value?.ToString() ?? string.Empty;
string targetValue = parameter?.ToString() ?? string.Empty;
return enumValue.Equals(targetValue, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 将 bool 转回枚举值
/// </summary>
/// <param name="value">ToggleButton.IsChecked</param>
/// <param name="targetType"></param>
/// <param name="parameter">ConverterParameter 指定对应的枚举值</param>
/// <param name="culture"></param>
/// <returns>如果 true 返回枚举,否则返回 Binding.DoNothing</returns>
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool isChecked && isChecked && parameter != null)
{
// 判断 targetType 是否是 Nullable 类型
if (Nullable.GetUnderlyingType(targetType) != null)
{
// 获取 Nullable 的基础类型
Type underlyingType = Nullable.GetUnderlyingType(targetType)!;
// 确保基础类型是枚举类型
if (underlyingType?.IsEnum == true)
{
return Enum.Parse(underlyingType, parameter.ToString()!);
}
}
// 如果不是枚举类型或 Nullable 类型,返回 Binding.DoNothing
else if (targetType.IsEnum)
{
return Enum.Parse(targetType, parameter.ToString()!);
}
}
return Binding.DoNothing;
}
}
}

View File

@@ -0,0 +1,26 @@
using System.Globalization;
using System.Windows.Data;
namespace YY.Admin.Core.Converter
{
public class EnumToIntConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is Enum enumValue)
{
System.Convert.ToInt32(enumValue);
}
return value;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is int intValue && parameter is Type enumType && enumType.IsEnum)
{
return Enum.ToObject(enumType, intValue);
}
return value;
}
}
}

View File

@@ -0,0 +1,29 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace YY.Admin.Core.Converter
{
public class EnumToTagTypeConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is StatusEnum status)
{
return status switch
{
StatusEnum.Enable => Application.Current.FindResource("SuccessBrush"), // 启用 - 绿色
StatusEnum.Disable => Application.Current.FindResource("DangerBrush"), // 禁用 - 红色
_ => Application.Current.FindResource("InfoBrush")
};
}
return Application.Current.FindResource("InfoBrush");
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace YY.Admin.Core.Converter
{
/// <summary>
/// 将枚举值转换为Visibility
/// </summary>
public class EnumToVisibilityConverter : IValueConverter
{
/// <summary>
/// 将枚举值转换为 Visibility
/// </summary>
/// <param name="value">绑定的枚举值</param>
/// <param name="targetType"></param>
/// <param name="parameter">ConverterParameter 指定要匹配的枚举值</param>
/// <param name="culture"></param>
/// <returns>匹配返回Visible</returns>
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null || parameter == null)
return Visibility.Collapsed;
return value.ToString()!.Equals(parameter.ToString(), StringComparison.OrdinalIgnoreCase)
? Visibility.Visible
: Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,23 @@
using System.Globalization;
using System.Windows.Data;
namespace YY.Admin.Core.Converter
{
public class MaxWidthConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is double totalWidth && totalWidth > 0 && parameter != null && double.TryParse(parameter.ToString(), out var offset) && totalWidth >= offset)
{
// 最大宽度
return totalWidth - offset;
}
return value;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,49 @@
using HandyControl.Data;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace YY.Admin.Core.Converter
{
/// <summary>
/// 根据 TitleWidth 计算 Margin.Left如果 TitlePlacement=Top 则不偏移
/// </summary>
public class NegativeLeftThicknessConverter : IMultiValueConverter
{
// 这里改成 MultiBinding方便同时拿 TitleWidth 和 TitlePlacement
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
double left = 0;
double bottomShift = -18;
if (parameter != null && double.TryParse(parameter.ToString(), out var p))
bottomShift = p;
// 如果 TitleWidth 还未设置,直接返回默认 Thickness
if (values.Length > 0 && values[0] != DependencyProperty.UnsetValue)
{
var widthValue = values[0];
if (widthValue is GridLength gridLength && gridLength.IsAbsolute)
left = gridLength.Value;
else if (widthValue is double d)
left = d;
}
// 如果 TitlePlacement 还未设置,也要避免异常
if (values.Length > 1 && values[1] != DependencyProperty.UnsetValue)
{
if (values[1] is TitlePlacementType placement)
{
// 如果标题在上方,则不偏移
if (placement == TitlePlacementType.Top)
left = 0;
}
}
return new Thickness(left, 0, 0, bottomShift);
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
}

View File

@@ -0,0 +1,23 @@
using System.Globalization;
using System.Windows.Data;
namespace YY.Admin.Core.Converter
{
public class PercentageConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is double actualHeight && parameter is string paramString)
{
double percentage = double.Parse(paramString);
return actualHeight * percentage;
}
return value;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,24 @@
using System.Globalization;
using System.Windows.Data;
namespace YY.Admin.Core.Converter
{
public class RadioButtonEnumMultiConverter : IMultiValueConverter
{
// values[0] = RadioButton 自身
// values[1] = SysUser.Status
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length < 2) return false;
if (values[0] == null || values[1] == null) return false;
return values[0].Equals(values[1]);
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
// VM中通过事件实现UI -> VM的同步这里不处理直接返回Binding.DoNothing
return new object[] { Binding.DoNothing, Binding.DoNothing };
}
}
}

View File

@@ -0,0 +1,13 @@
using Prism.Events;
namespace YY.Admin.Core.Events;
public sealed class NetworkStatusChangedPayload
{
public bool IsOnline { get; set; }
public DateTime ChangedAt { get; set; }
}
public class NetworkStatusChangedEvent : PubSubEvent<NetworkStatusChangedPayload>
{
}

View File

@@ -0,0 +1,13 @@
using Prism.Events;
namespace YY.Admin.Core.Events;
public sealed class RemoteCommandPayload
{
public string DeviceId { get; set; } = string.Empty;
public string CommandJson { get; set; } = string.Empty;
}
public class RemoteCommandReceivedEvent : PubSubEvent<RemoteCommandPayload>
{
}

View File

@@ -0,0 +1,7 @@
using Prism.Events;
namespace YY.Admin.Core.Events;
public class SyncCompletedEvent : PubSubEvent<string>
{
}

View File

@@ -0,0 +1,25 @@
using SqlSugar;
namespace YY.Admin.Core.Models;
/// <summary>
/// 设备本地状态快照。
/// </summary>
[SugarTable("device_status_snapshot")]
public class DeviceStatus
{
[SugarColumn(IsPrimaryKey = true, Length = 64)]
public string DeviceId { get; set; } = string.Empty;
[SugarColumn(IsNullable = false)]
public bool IsOnline { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? LastHeartbeatAt { get; set; }
[SugarColumn(ColumnDataType = "TEXT", IsNullable = true)]
public string? StatusJson { get; set; }
[SugarColumn(IsNullable = false)]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,43 @@
using SqlSugar;
namespace YY.Admin.Core.Models;
/// <summary>
/// 断联续传消息实体。
/// </summary>
[SugarTable("outbox_messages")]
public class OutboxMessage
{
[SugarColumn(IsPrimaryKey = true, Length = 64)]
public string Id { get; set; } = Guid.NewGuid().ToString("N");
[SugarColumn(Length = 128, IsNullable = false)]
public string AggregateType { get; set; } = string.Empty;
[SugarColumn(Length = 128, IsNullable = false)]
public string AggregateId { get; set; } = string.Empty;
[SugarColumn(Length = 128, IsNullable = false)]
public string EventType { get; set; } = string.Empty;
[SugarColumn(ColumnDataType = "TEXT", IsNullable = false)]
public string Payload { get; set; } = string.Empty;
[SugarColumn(IsNullable = false)]
public int Status { get; set; }
[SugarColumn(IsNullable = false)]
public int RetryCount { get; set; }
[SugarColumn(Length = 2000, IsNullable = true)]
public string? ErrorMessage { get; set; }
[SugarColumn(IsNullable = false)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[SugarColumn(IsNullable = true)]
public DateTime? LastTriedAt { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? SentAt { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace YY.Admin.Core.Services;
/// <summary>
/// 通过 RESTSCADA拉取用户并写入本地 jeecg_sys_user供 Outbox 统一线路消费。
/// </summary>
public interface IJeecgUserMirrorPullHandler
{
Task<bool> ExecutePullAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,9 @@
namespace YY.Admin.Core.Services;
/// <summary>
/// 将「需拉取 Jeecg 用户镜像」写入 Outbox断网不丢、联网续传由 Services 侧协调器调用。
/// </summary>
public interface IJeecgUserMirrorPullOutbox
{
Task EnqueuePullAsync(string eventType, string? payloadJson, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
namespace YY.Admin.Core.Services;
public interface INetworkMonitor
{
bool IsOnline { get; }
event Action<bool>? StatusChanged;
Task StartAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
namespace YY.Admin.Core.Services;
public interface ISignalRService
{
Task ConnectAsync(string token, CancellationToken cancellationToken = default);
/// <summary>
/// 设备同步统一通道STOMP 订阅 /topic/sync/jeecg-users免密或带设备 Token与 Outbox+REST 同属一条规范线路。
/// </summary>
Task ConnectUnifiedDeviceChannelAsync(CancellationToken cancellationToken = default);
Task SendDeviceStatusAsync(object status, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,11 @@
namespace YY.Admin.Core.Sync;
/// <summary>
/// 用户镜像同步在 Outbox 中的聚合类型(与设备批量上报区分,走本地拉取 REST 而非 /sys/sync/batch
/// </summary>
public static class JeecgUserMirrorOutbox
{
public const string AggregateType = "JeecgUserMirror";
public const string EventSignal = "Signal";
public const string EventBoot = "Boot";
}

View File

@@ -0,0 +1,192 @@
namespace YY.Admin.Core;
/// <summary>
/// 框架实体基类Id
/// </summary>
public abstract class EntityBaseId
{
/// <summary>
/// 雪花Id
/// </summary>
[SugarColumn(ColumnName = "Id", ColumnDescription = "主键Id", IsPrimaryKey = true, IsIdentity = false)]
public virtual long Id { get; set; }
}
/// <summary>
/// 框架实体基类
/// </summary>
[SugarIndex("index_{table}_CT", nameof(CreateTime), OrderByType.Asc)]
public abstract class EntityBase : EntityBaseId
{
/// <summary>
/// 创建时间
/// </summary>
[SugarColumn(ColumnDescription = "创建时间", IsNullable = true, IsOnlyIgnoreUpdate = true)]
public virtual DateTime CreateTime { get; set; }
/// <summary>
/// 更新时间
/// </summary>
[SugarColumn(ColumnDescription = "更新时间")]
public virtual DateTime? UpdateTime { get; set; }
/// <summary>
/// 创建者Id
/// </summary>
[OwnerUser]
[SugarColumn(ColumnDescription = "创建者Id", IsOnlyIgnoreUpdate = true)]
public virtual long? CreateUserId { get; set; }
///// <summary>
///// 创建者
///// </summary>
//[Newtonsoft.Json.JsonIgnore]
//[System.Text.Json.Serialization.JsonIgnore]
//[Navigate(NavigateType.OneToOne, nameof(CreateUserId))]
//public virtual SysUser CreateUser { get; set; }
/// <summary>
/// 创建者姓名
/// </summary>
[SugarColumn(ColumnDescription = "创建者姓名", Length = 64, IsOnlyIgnoreUpdate = true)]
public virtual string? CreateUserName { get; set; }
/// <summary>
/// 修改者Id
/// </summary>
[SugarColumn(ColumnDescription = "修改者Id")]
public virtual long? UpdateUserId { get; set; }
///// <summary>
///// 修改者
///// </summary>
//[Newtonsoft.Json.JsonIgnore]
//[System.Text.Json.Serialization.JsonIgnore]
//[Navigate(NavigateType.OneToOne, nameof(UpdateUserId))]
//public virtual SysUser UpdateUser { get; set; }
/// <summary>
/// 修改者姓名
/// </summary>
[SugarColumn(ColumnDescription = "修改者姓名", Length = 64)]
public virtual string? UpdateUserName { get; set; }
}
/// <summary>
/// 框架实体基类(删除标志)
/// </summary>
[SugarIndex("index_{table}_D", nameof(IsDelete), OrderByType.Asc)]
public abstract class EntityBaseDel : EntityBase, IDeletedFilter
{
/// <summary>
/// 软删除
/// </summary>
[SugarColumn(ColumnDescription = "软删除")]
public virtual bool IsDelete { get; set; } = false;
}
/// <summary>
/// 机构实体基类(数据权限)
/// </summary>
public abstract class EntityBaseOrg : EntityBase, IOrgIdFilter
{
/// <summary>
/// 机构Id
/// </summary>
[SugarColumn(ColumnDescription = "机构Id", IsNullable = true)]
public virtual long OrgId { get; set; }
///// <summary>
///// 创建者部门Id
///// </summary>
//[SugarColumn(ColumnDescription = "创建者部门Id", IsOnlyIgnoreUpdate = true)]
//public virtual long? CreateOrgId { get; set; }
///// <summary>
///// 创建者部门
///// </summary>
//[Newtonsoft.Json.JsonIgnore]
//[System.Text.Json.Serialization.JsonIgnore]
//[Navigate(NavigateType.OneToOne, nameof(CreateOrgId))]
//public virtual SysOrg CreateOrg { get; set; }
///// <summary>
///// 创建者部门名称
///// </summary>
//[SugarColumn(ColumnDescription = "创建者部门名称", Length = 64, IsOnlyIgnoreUpdate = true)]
//public virtual string? CreateOrgName { get; set; }
}
/// <summary>
/// 机构实体基类(数据权限、删除标志)
/// </summary>
public abstract class EntityBaseOrgDel : EntityBaseDel, IOrgIdFilter
{
/// <summary>
/// 机构Id
/// </summary>
[SugarColumn(ColumnDescription = "机构Id", IsNullable = true)]
public virtual long OrgId { get; set; }
}
/// <summary>
/// 租户实体基类
/// </summary>
public abstract class EntityBaseTenant : EntityBase, ITenantIdFilter
{
/// <summary>
/// 租户Id
/// </summary>
[SugarColumn(ColumnDescription = "租户Id", IsOnlyIgnoreUpdate = true)]
public virtual long? TenantId { get; set; }
}
/// <summary>
/// 租户实体基类(删除标志)
/// </summary>
public abstract class EntityBaseTenantDel : EntityBaseDel, ITenantIdFilter
{
/// <summary>
/// 租户Id
/// </summary>
[SugarColumn(ColumnDescription = "租户Id", IsOnlyIgnoreUpdate = true)]
public virtual long? TenantId { get; set; }
}
/// <summary>
/// 租户实体基类Id
/// </summary>
public abstract class EntityBaseTenantId : EntityBaseId, ITenantIdFilter
{
/// <summary>
/// 租户Id
/// </summary>
[SugarColumn(ColumnDescription = "租户Id", IsOnlyIgnoreUpdate = true)]
public virtual long? TenantId { get; set; }
}
/// <summary>
/// 租户机构实体基类(数据权限)
/// </summary>
public abstract class EntityBaseTenantOrg : EntityBaseOrg, ITenantIdFilter
{
/// <summary>
/// 租户Id
/// </summary>
[SugarColumn(ColumnDescription = "租户Id", IsOnlyIgnoreUpdate = true)]
public virtual long? TenantId { get; set; }
}
/// <summary>
/// 租户机构实体基类(数据权限、删除标志)
/// </summary>
public abstract class EntityBaseTenantOrgDel : EntityBaseOrgDel, ITenantIdFilter
{
/// <summary>
/// 租户Id
/// </summary>
[SugarColumn(ColumnDescription = "租户Id", IsOnlyIgnoreUpdate = true)]
public virtual long? TenantId { get; set; }
}

View File

@@ -0,0 +1,36 @@
namespace YY.Admin.Core;
/// <summary>
/// 假删除接口过滤器
/// </summary>
public interface IDeletedFilter
{
/// <summary>
/// 软删除
/// </summary>
bool IsDelete { get; set; }
}
/// <summary>
/// 租户Id接口过滤器
/// </summary>
public interface ITenantIdFilter
{
/// <summary>
/// 租户Id
/// </summary>
long? TenantId { get; set; }
}
/// <summary>
/// 机构Id接口过滤器
/// </summary>
public interface IOrgIdFilter
{
/// <summary>
/// 机构Id
/// </summary>
long OrgId { get; set; }
}

View File

@@ -0,0 +1,59 @@
using SqlSugar;
namespace YY.Admin.Core;
/// <summary>
/// Jeecg 数据字典项同构表(桌面端)
/// </summary>
[SugarTable("jeecg_sys_dict_item", "Jeecg数据字典项同构表")]
[SysTable]
public class JeecgSysDictItem
{
[SugarColumn(ColumnName = "id", IsPrimaryKey = true, Length = 64)]
public string Id { get; set; } = string.Empty;
[SugarColumn(ColumnName = "dict_id", Length = 64, IsNullable = true)]
public string? DictId { get; set; }
[SugarColumn(ColumnName = "dict_name", Length = 255, IsNullable = true)]
public string? DictName { get; set; }
[SugarColumn(ColumnName = "dict_code", Length = 255, IsNullable = true)]
public string? DictCode { get; set; }
[SugarColumn(ColumnName = "dict_type", IsNullable = true)]
public int? DictType { get; set; }
[SugarColumn(ColumnName = "dict_description", Length = 500, IsNullable = true)]
public string? DictDescription { get; set; }
[SugarColumn(ColumnName = "item_text", Length = 255, IsNullable = true)]
public string? ItemText { get; set; }
[SugarColumn(ColumnName = "item_value", Length = 255, IsNullable = true)]
public string? ItemValue { get; set; }
[SugarColumn(ColumnName = "item_description", Length = 500, IsNullable = true)]
public string? ItemDescription { get; set; }
[SugarColumn(ColumnName = "sort_order", IsNullable = true)]
public int? SortOrder { get; set; }
[SugarColumn(ColumnName = "status", IsNullable = true)]
public int? Status { get; set; }
[SugarColumn(ColumnName = "item_color", Length = 100, IsNullable = true)]
public string? ItemColor { get; set; }
[SugarColumn(ColumnName = "create_by", Length = 100, IsNullable = true)]
public string? CreateBy { get; set; }
[SugarColumn(ColumnName = "create_time", IsNullable = true)]
public DateTime? CreateTime { get; set; }
[SugarColumn(ColumnName = "update_by", Length = 100, IsNullable = true)]
public string? UpdateBy { get; set; }
[SugarColumn(ColumnName = "update_time", IsNullable = true)]
public DateTime? UpdateTime { get; set; }
}

View File

@@ -0,0 +1,181 @@
using SqlSugar;
namespace YY.Admin.Core;
/// <summary>
/// Jeecg 用户同构表(桌面端)
/// 说明:
/// 1. 按 JeecgBoot 的 sys_user 字段模型定义。
/// 2. 为避免影响现有 YY.Admin 的 sys_user 业务表,这里单独落表 jeecg_sys_user。
/// </summary>
[SugarTable("jeecg_sys_user", "Jeecg用户同构表")]
[SysTable]
public class JeecgSysUser
{
/// <summary>idJeecg为字符串雪花ID</summary>
[SugarColumn(ColumnName = "id", IsPrimaryKey = true, Length = 64)]
public string Id { get; set; } = string.Empty;
/// <summary>登录账号</summary>
[SugarColumn(ColumnName = "username", Length = 64, IsNullable = true)]
public string? Username { get; set; }
/// <summary>真实姓名</summary>
[SugarColumn(ColumnName = "realname", Length = 64, IsNullable = true)]
public string? Realname { get; set; }
/// <summary>密码</summary>
[SugarColumn(ColumnName = "password", Length = 512, IsNullable = true)]
public string? Password { get; set; }
/// <summary>密码盐</summary>
[SugarColumn(ColumnName = "salt", Length = 64, IsNullable = true)]
public string? Salt { get; set; }
/// <summary>头像</summary>
[SugarColumn(ColumnName = "avatar", Length = 512, IsNullable = true)]
public string? Avatar { get; set; }
/// <summary>生日</summary>
[SugarColumn(ColumnName = "birthday", IsNullable = true)]
public DateTime? Birthday { get; set; }
/// <summary>性别1男2女</summary>
[SugarColumn(ColumnName = "sex", IsNullable = true)]
public int? Sex { get; set; }
/// <summary>邮箱</summary>
[SugarColumn(ColumnName = "email", Length = 128, IsNullable = true)]
public string? Email { get; set; }
/// <summary>手机号</summary>
[SugarColumn(ColumnName = "phone", Length = 32, IsNullable = true)]
public string? Phone { get; set; }
/// <summary>登录选择部门编码</summary>
[SugarColumn(ColumnName = "org_code", Length = 128, IsNullable = true)]
public string? OrgCode { get; set; }
/// <summary>登录选择租户ID</summary>
[SugarColumn(ColumnName = "login_tenant_id", IsNullable = true)]
public int? LoginTenantId { get; set; }
/// <summary>状态(1正常 2冻结)</summary>
[SugarColumn(ColumnName = "status", IsNullable = true)]
public int? Status { get; set; }
/// <summary>删除标志0正常 1已删</summary>
[SugarColumn(ColumnName = "del_flag", IsNullable = true)]
public int? DelFlag { get; set; }
/// <summary>工号</summary>
[SugarColumn(ColumnName = "work_no", Length = 64, IsNullable = true)]
public string? WorkNo { get; set; }
/// <summary>座机号</summary>
[SugarColumn(ColumnName = "telephone", Length = 32, IsNullable = true)]
public string? Telephone { get; set; }
/// <summary>创建人</summary>
[SugarColumn(ColumnName = "create_by", Length = 64, IsNullable = true)]
public string? CreateBy { get; set; }
/// <summary>创建时间</summary>
[SugarColumn(ColumnName = "create_time", IsNullable = true)]
public DateTime? CreateTime { get; set; }
/// <summary>更新人</summary>
[SugarColumn(ColumnName = "update_by", Length = 64, IsNullable = true)]
public string? UpdateBy { get; set; }
/// <summary>更新时间</summary>
[SugarColumn(ColumnName = "update_time", IsNullable = true)]
public DateTime? UpdateTime { get; set; }
/// <summary>流程同步标志</summary>
[SugarColumn(ColumnName = "activiti_sync", IsNullable = true)]
public int? ActivitiSync { get; set; }
/// <summary>身份0普通成员 1上级</summary>
[SugarColumn(ColumnName = "user_identity", IsNullable = true)]
public int? UserIdentity { get; set; }
/// <summary>负责部门IDs</summary>
[SugarColumn(ColumnName = "depart_ids", Length = 1024, IsNullable = true)]
public string? DepartIds { get; set; }
/// <summary>设备ID</summary>
[SugarColumn(ColumnName = "client_id", Length = 256, IsNullable = true)]
public string? ClientId { get; set; }
/// <summary>流程状态</summary>
[SugarColumn(ColumnName = "bpm_status", Length = 64, IsNullable = true)]
public string? BpmStatus { get; set; }
/// <summary>个性签名</summary>
[SugarColumn(ColumnName = "sign", Length = 512, IsNullable = true)]
public string? Sign { get; set; }
/// <summary>是否开启个性签名</summary>
[SugarColumn(ColumnName = "sign_enable", IsNullable = true)]
public int? SignEnable { get; set; }
/// <summary>主岗位</summary>
[SugarColumn(ColumnName = "main_dep_post_id", Length = 128, IsNullable = true)]
public string? MainDepPostId { get; set; }
/// <summary>职务(字典)</summary>
[SugarColumn(ColumnName = "position_type", Length = 64, IsNullable = true)]
public string? PositionType { get; set; }
/// <summary>最后修改密码时间</summary>
[SugarColumn(ColumnName = "last_pwd_update_time", IsNullable = true)]
public DateTime? LastPwdUpdateTime { get; set; }
/// <summary>排序</summary>
[SugarColumn(ColumnName = "sort", IsNullable = true)]
public int? Sort { get; set; }
/// <summary>是否隐藏联系方式 0否1是</summary>
[SugarColumn(ColumnName = "iz_hide_contact", Length = 8, IsNullable = true)]
public string? IzHideContact { get; set; }
// ------------------------- 非持久化字段(与 Jeecg 实体保持一致) -------------------------
/// <summary>部门名称(非持久化)</summary>
[SugarColumn(IsIgnore = true)]
public string? OrgCodeTxt { get; set; }
/// <summary>职务(非持久化)</summary>
[SugarColumn(IsIgnore = true)]
public string? Post { get; set; }
/// <summary>多租户ids临时字段非持久化</summary>
[SugarColumn(IsIgnore = true)]
public string? RelTenantIds { get; set; }
/// <summary>首页路径(非持久化)</summary>
[SugarColumn(IsIgnore = true)]
public string? HomePath { get; set; }
/// <summary>职位名称(非持久化)</summary>
[SugarColumn(IsIgnore = true)]
public string? PostText { get; set; }
/// <summary>是否绑定第三方(非持久化)</summary>
[SugarColumn(IsIgnore = true)]
public bool IzBindThird { get; set; }
/// <summary>兼职岗位(非持久化)</summary>
[SugarColumn(IsIgnore = true)]
public string? OtherDepPostId { get; set; }
/// <summary>登录时选择部门编码(非持久化)</summary>
[SugarColumn(IsIgnore = true)]
public string? LoginOrgCode { get; set; }
/// <summary>所属部门IDs非持久化</summary>
[SugarColumn(IsIgnore = true)]
public string? BelongDepIds { get; set; }
}

View File

@@ -0,0 +1,64 @@
namespace YY.Admin.Core;
/// <summary>
/// 系统配置参数表
/// </summary>
[SugarTable("sys_config", "系统配置参数表")]
[SysTable]
[SugarIndex("index_{table}_N", nameof(Name), OrderByType.Asc)]
[SugarIndex("index_{table}_C", nameof(Code), OrderByType.Asc, IsUnique = true)]
public partial class SysConfig : EntityBase
{
/// <summary>
/// 名称
/// </summary>
[SugarColumn(ColumnDescription = "名称", Length = 64)]
[Required, MaxLength(64)]
public virtual string Name { get; set; }
/// <summary>
/// 编码
/// </summary>
[SugarColumn(ColumnDescription = "编码", Length = 64)]
[MaxLength(64)]
public string? Code { get; set; }
/// <summary>
/// 参数值
/// </summary>
[SugarColumn(ColumnDescription = "参数值", Length = 512)]
[MaxLength(512)]
[IgnoreUpdateSeedColumn]
public string? Value { get; set; }
/// <summary>
/// 是否是内置参数Y-是N-否)
/// </summary>
[SugarColumn(ColumnDescription = "是否是内置参数", DefaultValue = "1")]
public YesNoEnum SysFlag { get; set; } = YesNoEnum.Y;
/// <summary>
/// 分组编码
/// </summary>
[SugarColumn(ColumnDescription = "分组编码", Length = 64)]
[MaxLength(64)]
public string? GroupCode { get; set; }
/// <summary>
/// 排序
/// </summary>
[SugarColumn(ColumnDescription = "排序", DefaultValue = "100")]
public int OrderNo { get; set; } = 100;
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 256)]
[MaxLength(256)]
public string? Remark { get; set; }
}

View File

@@ -0,0 +1,101 @@
namespace YY.Admin.Core;
/// <summary>
/// 系统字典值表
/// </summary>
[SugarTable("sys_dict_data", "系统字典值表")]
[SysTable]
[SugarIndex("index_{table}_TV", nameof(DictTypeId), OrderByType.Asc, nameof(Value), OrderByType.Asc, IsUnique = true)]
public partial class SysDictData : EntityBase
{
/// <summary>
/// 字典类型Id
/// </summary>
[SugarColumn(ColumnDescription = "字典类型Id")]
public long DictTypeId { get; set; }
/// <summary>
/// 字典类型
/// </summary>
[Newtonsoft.Json.JsonIgnore]
[System.Text.Json.Serialization.JsonIgnore]
[Navigate(NavigateType.OneToOne, nameof(DictTypeId))]
public SysDictType DictType { get; set; }
/// <summary>
/// 显示文本
/// </summary>
[SugarColumn(ColumnDescription = "显示文本", Length = 256)]
[Required, MaxLength(256)]
public virtual string Label { get; set; }
/// <summary>
/// 值
/// </summary>
[SugarColumn(ColumnDescription = "值", Length = 256)]
[Required, MaxLength(256)]
public virtual string Value { get; set; }
/// <summary>
/// 编码
/// </summary>
/// <remarks>
/// </remarks>
[SugarColumn(ColumnDescription = "编码", Length = 256)]
public virtual string? Code { get; set; }
/// <summary>
/// 名称
/// </summary>
[SugarColumn(ColumnDescription = "名称", Length = 256)]
[MaxLength(256)]
public virtual string? Name { get; set; }
/// <summary>
/// 显示样式-标签颜色
/// </summary>
[SugarColumn(ColumnDescription = "显示样式-标签颜色", Length = 16)]
[MaxLength(16)]
public string? TagType { get; set; }
/// <summary>
/// 显示样式-Style(控制显示样式)
/// </summary>
[SugarColumn(ColumnDescription = "显示样式-Style", Length = 512)]
[MaxLength(512)]
public string? StyleSetting { get; set; }
/// <summary>
/// 显示样式-Class(控制显示样式)
/// </summary>
[SugarColumn(ColumnDescription = "显示样式-Class", Length = 512)]
[MaxLength(512)]
public string? ClassSetting { get; set; }
/// <summary>
/// 排序
/// </summary>
[SugarColumn(ColumnDescription = "排序", DefaultValue = "100")]
public int OrderNo { get; set; } = 100;
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 2048)]
[MaxLength(2048)]
public string? Remark { get; set; }
/// <summary>
/// 拓展数据(保存业务功能的配置项)
/// </summary>
[SugarColumn(ColumnDescription = "拓展数据(保存业务功能的配置项)", ColumnDataType = StaticConfig.CodeFirst_BigString)]
public string? ExtData { get; set; }
/// <summary>
/// 状态
/// </summary>
[SugarColumn(ColumnDescription = "状态", DefaultValue = "1")]
public StatusEnum Status { get; set; } = StatusEnum.Enable;
}

Some files were not shown because too many files have changed in this diff Show More