运维页面
This commit is contained in:
@@ -2,8 +2,10 @@ package com.yunhai.bschclassmanage.Repository;
|
||||
|
||||
import com.yunhai.bschclassmanage.entity.SyncLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface SyncLogRepository extends JpaRepository<SyncLog, Long> {
|
||||
public interface SyncLogRepository
|
||||
extends JpaRepository<SyncLog, Long>, JpaSpecificationExecutor<SyncLog> {
|
||||
}
|
||||
|
||||
+33
-4
@@ -3,9 +3,11 @@ package com.yunhai.bschclassmanage.controller.api;
|
||||
import com.yunhai.bschclassmanage.config.Config;
|
||||
import com.yunhai.bschclassmanage.controller.BaseController;
|
||||
import com.yunhai.bschclassmanage.entity.SysOperationLog;
|
||||
import com.yunhai.bschclassmanage.enums.UserType;
|
||||
import com.yunhai.bschclassmanage.model.vo.PageVO;
|
||||
import com.yunhai.bschclassmanage.service.ISysOperationLogService;
|
||||
import com.yunhai.bschclassmanage.utils.UserContext;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -16,6 +18,8 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH + "/syslog/")
|
||||
@@ -25,22 +29,35 @@ public class SysOperationLogController extends BaseController {
|
||||
@Autowired
|
||||
private ISysOperationLogService sysOperationLogService;
|
||||
|
||||
/** 审计日志分页查询(默认按当前用户 schId 隔离) */
|
||||
/**
|
||||
* 超管校验:非超级管理员直接抛异常阻断(运维工作台仅超级管理员可访问)
|
||||
*/
|
||||
private void denyIfNotSuperAdmin() {
|
||||
boolean isSuperAdmin = UserType.SUPER_ADMINISTRATOR.getId().equals(UserContext.getType());
|
||||
if (!isSuperAdmin) {
|
||||
throw new DException("无权限:仅超级管理员可访问接口日志");
|
||||
}
|
||||
}
|
||||
|
||||
/** 审计日志分页查询;schId 为"所选学校"筛选条件(非登录用户隔离),不传则查全部学校 */
|
||||
@GetMapping("list")
|
||||
public PageVO<SysOperationLog> list(
|
||||
@RequestParam(required = false) Integer schId,
|
||||
@RequestParam(required = false) String uri,
|
||||
@RequestParam(required = false) String method,
|
||||
@RequestParam(required = false) Integer statusCode,
|
||||
@RequestParam(required = false) Integer logLevel,
|
||||
@RequestParam(required = false) Integer userId,
|
||||
@RequestParam(required = false) String userName,
|
||||
Pageable pageable) {
|
||||
return sysOperationLogService.pageQuery(UserContext.getSchId(), uri, method,
|
||||
statusCode, logLevel, userId, pageable);
|
||||
denyIfNotSuperAdmin();
|
||||
return sysOperationLogService.pageQuery(schId, uri, method,
|
||||
statusCode, logLevel, userName, pageable);
|
||||
}
|
||||
|
||||
/** 下载某条响应原文(GZIP 解压) */
|
||||
@GetMapping("download/{id}")
|
||||
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
|
||||
denyIfNotSuperAdmin();
|
||||
String data = sysOperationLogService.getResponseData(id);
|
||||
if (data == null) data = "该条审计日志未记录响应数据";
|
||||
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
|
||||
@@ -55,4 +72,16 @@ public class SysOperationLogController extends BaseController {
|
||||
.contentType(MediaType.parseMediaType("application/octet-stream"))
|
||||
.body(new InputStreamResource(bis));
|
||||
}
|
||||
|
||||
/** 重试某条失败的操作:按记录中的 classMethod + requestParams 反射重放原始操作 */
|
||||
@PostMapping("retry/{id}")
|
||||
public Map<String, Object> retry(@PathVariable Long id) {
|
||||
denyIfNotSuperAdmin();
|
||||
Object result = sysOperationLogService.retry(id);
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("success", true);
|
||||
m.put("message", "重试已提交,执行结果见新增的审计日志");
|
||||
if (result != null) m.put("data", result);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.yunhai.bschclassmanage.service;
|
||||
|
||||
import com.yunhai.bschclassmanage.entity.SyncLog;
|
||||
import com.yunhai.bschclassmanage.model.vo.PageVO;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface ISyncLogService {
|
||||
/**
|
||||
@@ -8,4 +10,18 @@ public interface ISyncLogService {
|
||||
* 由 SyncLogAspect 在 finally 中调用,失败仅记日志、不抛异常、不阻塞主流程。
|
||||
*/
|
||||
void saveAsync(SyncLog entity);
|
||||
|
||||
/** 分页查询(默认按当前用户 schId 隔离) */
|
||||
PageVO<SyncLog> pageQuery(Integer schId, String syncType, String syncTypeName,
|
||||
String classMethod, Integer status, String userName,
|
||||
Pageable pageable);
|
||||
|
||||
/** 取单条(含 jsonData / errorMsg) */
|
||||
SyncLog getById(Long id);
|
||||
|
||||
/** 取下载原文:优先 jsonData(请求入参),回退 errorMsg(异常信息) */
|
||||
String getDownloadData(Long id);
|
||||
|
||||
/** 重试某条同步/推送调用:按 classMethod + jsonData 反射重放原始操作 */
|
||||
Object retry(Long id);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public interface ISysOperationLogService {
|
||||
/** 分页查询(不含 response_data 投影;实体直查,初版简单先行) */
|
||||
PageVO<SysOperationLog> pageQuery(Integer schId, String uri, String method,
|
||||
Integer statusCode, Integer logLevel,
|
||||
Integer userId, Pageable pageable);
|
||||
String userName, Pageable pageable);
|
||||
|
||||
/** 取单条(含 response_data)用于下载 */
|
||||
SysOperationLog getById(Long id);
|
||||
@@ -19,6 +19,9 @@ public interface ISysOperationLogService {
|
||||
/** 解压某条响应原文 */
|
||||
String getResponseData(Long id);
|
||||
|
||||
/** 重试:按记录的 classMethod + requestParams 反射重放原始操作(仅失败记录可重试) */
|
||||
Object retry(Long id);
|
||||
|
||||
/** 定时清理,返回删除条数 */
|
||||
long cleanupExpired();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
package com.yunhai.bschclassmanage.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.bschclassmanage.Repository.SyncLogRepository;
|
||||
import com.yunhai.bschclassmanage.entity.SyncLog;
|
||||
import com.yunhai.bschclassmanage.model.vo.PageVO;
|
||||
import com.yunhai.bschclassmanage.service.ISyncLogService;
|
||||
import com.yunhai.bschclassmanage.utils.ReflectRetryUtil;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SyncLogServiceImpl implements ISyncLogService {
|
||||
@@ -15,6 +35,9 @@ public class SyncLogServiceImpl implements ISyncLogService {
|
||||
@Autowired
|
||||
private SyncLogRepository repository;
|
||||
|
||||
@Autowired
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Async("logExecutor")
|
||||
@Override
|
||||
public void saveAsync(SyncLog entity) {
|
||||
@@ -24,4 +47,97 @@ public class SyncLogServiceImpl implements ISyncLogService {
|
||||
log.error("同步日志异步落库失败, classMethod={}", entity.getClassMethod(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageVO<SyncLog> pageQuery(Integer schId, String syncType, String syncTypeName,
|
||||
String classMethod, Integer status, String userName,
|
||||
Pageable pageable) {
|
||||
Specification<SyncLog> spec = (root, query, cb) -> {
|
||||
List<Predicate> ps = new ArrayList<>();
|
||||
if (schId != null) ps.add(cb.equal(root.get("schId").as(Integer.class), schId));
|
||||
if (StringUtils.isNotBlank(syncType)) ps.add(cb.equal(root.get("syncType").as(String.class), syncType));
|
||||
if (StringUtils.isNotBlank(syncTypeName))
|
||||
ps.add(cb.like(root.get("syncTypeName").as(String.class), "%" + syncTypeName + "%"));
|
||||
if (StringUtils.isNotBlank(classMethod))
|
||||
ps.add(cb.like(root.get("classMethod").as(String.class), "%" + classMethod + "%"));
|
||||
if (status != null) ps.add(cb.equal(root.get("status").as(Integer.class), status));
|
||||
if (StringUtils.isNotBlank(userName))
|
||||
ps.add(cb.like(root.get("userName").as(String.class), "%" + userName.trim() + "%"));
|
||||
return cb.and(ps.toArray(new Predicate[0]));
|
||||
};
|
||||
Pageable page = pageable;
|
||||
if (!page.getSort().isSorted()) {
|
||||
page = PageRequest.of(page.getPageNumber(), page.getPageSize(),
|
||||
Sort.by(Sort.Direction.DESC, "createTime"));
|
||||
}
|
||||
Page<SyncLog> p = repository.findAll(spec, page);
|
||||
return new PageVO<>(p.getNumber() + 1, p.getSize(), p.getTotalElements(), p.getContent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SyncLog getById(Long id) {
|
||||
return repository.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDownloadData(Long id) {
|
||||
SyncLog e = getById(id);
|
||||
if (e == null) return "未找到该同步日志(id=" + id + ")";
|
||||
if (StringUtils.isNotBlank(e.getJsonData())) return e.getJsonData();
|
||||
if (StringUtils.isNotBlank(e.getErrorMsg())) return e.getErrorMsg();
|
||||
return "该条同步日志未记录请求数据/异常信息";
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试:按 classMethod + jsonData 反射重放原始同步/推送调用。
|
||||
* - 仅允许失败/未判定(status != 1)的记录重试;
|
||||
* - 经 Spring 代理调用,会再次走 @SyncOpLog 切面、生成新的同步日志;
|
||||
* - 入参按位置映射(切面用 LinkedHashMap 按入参顺序生成 jsonData),覆盖基本类型/枚举/List/POJO;
|
||||
* - 代理安全:支持 CGLIB / JDK 动态代理(见 ReflectRetryUtil);
|
||||
* - 注意:原日志已对 pwd/idCard/phone 等脱敏为 ***,用户类增改重试可能因此失败(best-effort)。
|
||||
*/
|
||||
@Override
|
||||
public Object retry(Long id) {
|
||||
SyncLog e = getById(id);
|
||||
if (e == null) throw new DException("同步日志不存在(id=" + id + ")");
|
||||
if (e.getStatus() != null && e.getStatus() == 1) throw new DException("该同步已成功,无需重试");
|
||||
if (StringUtils.isBlank(e.getClassMethod())) throw new DException("该日志缺少调用信息(classMethod),无法重试");
|
||||
if (StringUtils.isBlank(e.getJsonData())) throw new DException("该日志缺少入参(jsonData),无法重试");
|
||||
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> argMap = JSON.parseObject(e.getJsonData(), LinkedHashMap.class);
|
||||
if (argMap == null) throw new DException("入参解析失败,无法重试");
|
||||
|
||||
// 定位目标 Bean + Method(代理安全:支持 CGLIB / JDK 代理兜底扫描)
|
||||
ReflectRetryUtil.Resolved resolved = ReflectRetryUtil.resolveBeanAndMethod(
|
||||
applicationContext, e.getClassMethod(), argMap.size());
|
||||
|
||||
Method target = resolved.method;
|
||||
Parameter[] params = target.getParameters();
|
||||
Object[] args = new Object[params.length];
|
||||
if (params.length > 0 && params[0].isNamePresent()) {
|
||||
// 编译期保留参数名:按参数名匹配
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
args[i] = ReflectRetryUtil.convert(argMap.get(params[i].getName()), params[i]);
|
||||
}
|
||||
} else {
|
||||
// 按位置匹配(切面保证 jsonData 与入参同序)
|
||||
List<Object> values = new ArrayList<>(argMap.values());
|
||||
if (values.size() != params.length) {
|
||||
throw new DException("入参个数不匹配,无法重试(日志=" + values.size() + ", 方法=" + params.length + ")");
|
||||
}
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
args[i] = ReflectRetryUtil.convert(values.get(i), params[i]);
|
||||
}
|
||||
}
|
||||
|
||||
Method invokeMethod = ReflectRetryUtil.getInvokeMethod(resolved.bean, target);
|
||||
return invokeMethod.invoke(resolved.bean, args);
|
||||
} catch (DException de) {
|
||||
throw de;
|
||||
} catch (InvocationTargetException | IllegalAccessException ex) {
|
||||
throw ReflectRetryUtil.rethrow(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+87
-2
@@ -1,13 +1,18 @@
|
||||
package com.yunhai.bschclassmanage.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.yunhai.bschclassmanage.Repository.SysOperationLogRepository;
|
||||
import com.yunhai.bschclassmanage.entity.SysOperationLog;
|
||||
import com.yunhai.bschclassmanage.model.vo.PageVO;
|
||||
import com.yunhai.bschclassmanage.service.ISysOperationLogService;
|
||||
import com.yunhai.bschclassmanage.utils.GzipUtil;
|
||||
import com.yunhai.bschclassmanage.utils.ReflectRetryUtil;
|
||||
import com.yunhai.bschclassmanage.utils.UserContext;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -19,6 +24,9 @@ import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -33,6 +41,9 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService {
|
||||
@Autowired
|
||||
private SysOperationLogRepository repository;
|
||||
|
||||
@Autowired
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Async("auditLogExecutor")
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
@@ -48,7 +59,7 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService {
|
||||
@Override
|
||||
public PageVO<SysOperationLog> pageQuery(Integer schId, String uri, String method,
|
||||
Integer statusCode, Integer logLevel,
|
||||
Integer userId, Pageable pageable) {
|
||||
String userName, Pageable pageable) {
|
||||
Specification<SysOperationLog> spec = (root, query, cb) -> {
|
||||
List<Predicate> ps = new ArrayList<>();
|
||||
if (schId != null) ps.add(cb.equal(root.get("schId").as(Integer.class), schId));
|
||||
@@ -56,7 +67,8 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService {
|
||||
if (StringUtils.isNotBlank(method)) ps.add(cb.equal(root.get("requestMethod").as(String.class), method.toUpperCase()));
|
||||
if (statusCode != null) ps.add(cb.equal(root.get("statusCode").as(Integer.class), statusCode));
|
||||
if (logLevel != null) ps.add(cb.equal(root.get("logLevel").as(Integer.class), logLevel));
|
||||
if (userId != null) ps.add(cb.equal(root.get("userId").as(Integer.class), userId));
|
||||
if (StringUtils.isNotBlank(userName))
|
||||
ps.add(cb.like(root.get("userName").as(String.class), "%" + userName.trim() + "%"));
|
||||
return cb.and(ps.toArray(new Predicate[0]));
|
||||
};
|
||||
Pageable page = pageable;
|
||||
@@ -93,4 +105,77 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService {
|
||||
deleted, generalDeadline, importantDeadline);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试:按记录的 classMethod + requestParams 反射重放原始 Controller 操作。
|
||||
* 说明:
|
||||
* - 仅允许 statusCode != 200(失败)的记录重试;
|
||||
* - 仅支持入参为 JSON 数组形式(POST body / @RequestBody)。GET query 串无法可靠重建,直接拒绝;
|
||||
* - 经 Spring 代理调用,会再次走 SysOperationLogAspect 切面、生成一条新的审计日志;
|
||||
* - 重放前还原原操作人上下文(UserContext),使新审计日志记录正确的操作人;
|
||||
* - servlet / Spring MVC 相关参数(HttpServletRequest 等)原由容器注入、日志未记录,重放时置 null;
|
||||
* - 多校隔离:仅允许重试当前用户 schId 下的记录。
|
||||
*/
|
||||
@Override
|
||||
public Object retry(Long id) {
|
||||
SysOperationLog e = getById(id);
|
||||
if (e == null) throw new DException("审计日志不存在(id=" + id + ")");
|
||||
if (e.getStatusCode() != null && e.getStatusCode() == 200) {
|
||||
throw new DException("该操作已成功(statusCode=200),无需重试");
|
||||
}
|
||||
// 多校隔离:不允许重试其他学校的操作日志
|
||||
if (e.getSchId() != null && UserContext.getSchId() != null
|
||||
&& !e.getSchId().equals(UserContext.getSchId())) {
|
||||
throw new DException("无权限重试其他学校的操作日志");
|
||||
}
|
||||
if (StringUtils.isBlank(e.getClassMethod())) {
|
||||
throw new DException("该日志记录缺少调用信息(classMethod),无法重试");
|
||||
}
|
||||
if (StringUtils.isBlank(e.getRequestParams())) {
|
||||
throw new DException("该日志记录缺少入参(requestParams),无法重试");
|
||||
}
|
||||
// 仅支持 JSON 数组入参;GET query 串无法可靠重放
|
||||
if (!e.getRequestParams().trim().startsWith("[")) {
|
||||
throw new DException("非 JSON 数组入参(如 GET query)无法可靠重放,请手动重试");
|
||||
}
|
||||
|
||||
JSONArray arr;
|
||||
try {
|
||||
arr = JSONArray.parseArray(e.getRequestParams());
|
||||
} catch (Exception ex) {
|
||||
throw new DException("入参解析失败(requestParams),无法重试");
|
||||
}
|
||||
if (arr == null) throw new DException("入参解析失败(requestParams),无法重试");
|
||||
|
||||
ReflectRetryUtil.Resolved resolved = ReflectRetryUtil.resolveBeanAndMethod(
|
||||
applicationContext, e.getClassMethod(), arr.size());
|
||||
|
||||
Method target = resolved.method;
|
||||
Parameter[] params = target.getParameters();
|
||||
Object[] args = new Object[params.length];
|
||||
int jsonIdx = 0;
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
if (ReflectRetryUtil.isSkippable(params[i].getType())) {
|
||||
args[i] = null; // 容器注入参数,无法重建
|
||||
} else {
|
||||
Object raw = (jsonIdx < arr.size()) ? arr.get(jsonIdx++) : null;
|
||||
args[i] = ReflectRetryUtil.convert(raw, params[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// 还原原操作人上下文,使重放触发的审计切面记录正确操作人;结束后恢复本次请求上下文
|
||||
UserContext.UserInfo snapshot = UserContext.getCurrentUser();
|
||||
try {
|
||||
UserContext.setUserId(e.getUserId());
|
||||
UserContext.setUserName(e.getUserName());
|
||||
UserContext.setSchId(e.getSchId());
|
||||
UserContext.setIp(e.getRequestIp());
|
||||
Method invokeMethod = ReflectRetryUtil.getInvokeMethod(resolved.bean, target);
|
||||
return invokeMethod.invoke(resolved.bean, args);
|
||||
} catch (InvocationTargetException | IllegalAccessException ex) {
|
||||
throw ReflectRetryUtil.rethrow(ex);
|
||||
} finally {
|
||||
UserContext.setUserInfo(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Redis 缓存查看(超管)</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--panel: #1a212b;
|
||||
--panel-2: #222c38;
|
||||
--border: #2c3845;
|
||||
--text: #d7e0ea;
|
||||
--muted: #8b9aab;
|
||||
--accent: #4c9aff;
|
||||
--accent-2: #2f6fd1;
|
||||
--green: #36c275;
|
||||
--red: #ff6b6b;
|
||||
--yellow: #f7c948;
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, #16202c, #0f1419);
|
||||
}
|
||||
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
header .sub { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.wrap { display: flex; gap: 16px; padding: 16px 24px; align-items: flex-start; }
|
||||
.left { width: 320px; flex: 0 0 320px; }
|
||||
.right { flex: 1 1 auto; min-width: 0; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.card + .card { margin-top: 16px; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 6px; }
|
||||
input[type=text] {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
outline: none;
|
||||
}
|
||||
input[type=text]:focus { border-color: var(--accent); }
|
||||
.btn {
|
||||
padding: 9px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); }
|
||||
.btn.primary { background: var(--accent-2); border-color: var(--accent); color: #fff; }
|
||||
.btn.primary:hover { background: var(--accent); }
|
||||
.btn.danger { background: transparent; border-color: var(--red); color: var(--red); }
|
||||
.btn.danger:hover { background: rgba(255,107,107,.12); }
|
||||
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.keys-list { max-height: 60vh; overflow: auto; margin-top: 10px; border-top: 1px solid var(--border); }
|
||||
.keys-list .k {
|
||||
padding: 8px 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.keys-list .k:hover { background: var(--panel-2); }
|
||||
.keys-list .k.active { background: rgba(76,154,255,.15); border-left: 3px solid var(--accent); padding-left: 7px; }
|
||||
.keys-list .k .kname {
|
||||
display: block; color: var(--accent); font-size: 11px; margin-bottom: 2px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.keys-list .k .kkey {
|
||||
display: block; font-family: var(--mono); font-size: 12px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.meta { display: grid; grid-template-columns: 90px 1fr; gap: 10px 14px; margin-bottom: 14px; }
|
||||
.meta .mk { color: var(--muted); font-size: 12px; }
|
||||
.meta .mv { font-family: var(--mono); word-break: break-all; }
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||
font-size: 11px; border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
.badge.string { color: var(--green); border-color: rgba(54,194,117,.4); }
|
||||
.badge.hash { color: var(--accent); border-color: rgba(76,154,255,.4); }
|
||||
.badge.list, .badge.set, .badge.zset { color: var(--yellow); border-color: rgba(247,201,72,.4); }
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.count { color: var(--muted); font-size: 12px; }
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
select:focus { border-color: var(--accent); }
|
||||
pre.json {
|
||||
margin: 0; padding: 12px; background: #0b0f14; border: 1px solid var(--border);
|
||||
border-radius: 8px; overflow: auto; max-height: 60vh; font-family: var(--mono);
|
||||
font-size: 12px; line-height: 1.5; color: #cfe3ff; white-space: pre;
|
||||
}
|
||||
pre.json .jk { color: #e06c75; } /* key */
|
||||
pre.json .js { color: #98c379; } /* string value */
|
||||
pre.json .jn { color: #d19a66; } /* number */
|
||||
pre.json .jb { color: #c678dd; } /* boolean / null */
|
||||
.empty { color: var(--muted); padding: 24px; text-align: center; }
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
|
||||
background: #2a1d1d; border: 1px solid var(--red); color: #ffd5d5;
|
||||
padding: 10px 16px; border-radius: 8px; font-size: 13px; max-width: 80vw;
|
||||
display: none; z-index: 50;
|
||||
}
|
||||
.toast.ok { background: #1d2a20; border-color: var(--green); color: #c9f4d8; }
|
||||
.toast.show { display: block; }
|
||||
.hint { color: var(--muted); font-size: 11px; margin-top: 6px; }
|
||||
.spinner {
|
||||
display: inline-block; width: 14px; height: 14px; border: 2px solid var(--muted);
|
||||
border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite;
|
||||
vertical-align: -2px; margin-right: 6px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
code { background: var(--panel-2); padding: 1px 5px; border-radius: 4px; font-family: var(--mono); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Redis 缓存查看 <span style="color:var(--accent)">(超管)</span></h1>
|
||||
<div class="sub">仅超级管理员可访问。部署后同源访问自动带登录态。</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="left">
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label>按 Key 精确查询</label>
|
||||
<input type="text" id="keyInput" placeholder="例如 bsch.classmanage.sch_class_1" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn primary" id="btnQuery">查询</button>
|
||||
<button class="btn" id="btnClear">清空</button>
|
||||
</div>
|
||||
<div class="field" style="margin-top:14px">
|
||||
<label>按模式列出 Keys(支持 * ? )</label>
|
||||
<input type="text" id="patternInput" placeholder="例如 bsch.classmanage.*" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" id="btnList">列出 Keys</button>
|
||||
</div>
|
||||
<div class="hint">生产大库下 KEYS 会阻塞,请尽量指定明确前缀,勿用单独的 <code>*</code>。</div>
|
||||
<div class="keys-list" id="keysList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<div class="card" id="detailCard">
|
||||
<div class="empty" id="detailEmpty">输入 Key 或列出 Keys 后点击查询,结果将显示在此处。</div>
|
||||
<div id="detailBody" style="display:none">
|
||||
<div class="meta">
|
||||
<div class="mk">Key</div><div class="mv" id="mKey"></div>
|
||||
<div class="mk">缓存名称</div><div class="mv" id="mCacheName"></div>
|
||||
<div class="mk">类型</div><div class="mv" id="mType"></div>
|
||||
<div class="mk">过期时间</div><div class="mv" id="mExpire"></div>
|
||||
<div class="mk">大小</div><div class="mv" id="mSize"></div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<select id="formatSelect" title="格式">
|
||||
<option value="json" selected>JSON</option>
|
||||
<option value="text">Text</option>
|
||||
</select>
|
||||
<button class="btn" id="btnCopy" title="复制当前内容">复制</button>
|
||||
<button class="btn" id="btnRefresh" title="刷新当前 Key">刷新</button>
|
||||
<span class="count" id="rowCount"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn danger" id="btnDelete">删除此 Key</button>
|
||||
</div>
|
||||
<pre class="json" id="valueJson"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// 同源时从页面自身路径推导 API 基址(兼容 context-path = 根 / 或 /bschclass)
|
||||
function resolveApi() {
|
||||
const base = location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1);
|
||||
return base + 'api/cache';
|
||||
}
|
||||
const API = resolveApi();
|
||||
|
||||
let currentKey = null;
|
||||
let currentData = null;
|
||||
let currentValue = null;
|
||||
let currentFormat = 'json';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// 缓存名称映射:prefix 与 Key 精确相等或以 prefix_ 开头即命中。
|
||||
// 注意:数组须按前缀长度降序排列,避免 sch_class 抢走 sch_class_teach / sch_class_student。
|
||||
const CACHE_NAMES = [
|
||||
{ prefix: 'bsch.classmanage.sch_class_student', name: '班级学生缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_class_teach', name: '班级任教缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_group_teacher', name: '教师组缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_class', name: '班级缓存' },
|
||||
{ prefix: 'bsch.classmanage.external_plan_data',name: '执行任务数据缓存' },
|
||||
{ prefix: 'bsch.classmanage.external_progress', name: '执行进度缓存' },
|
||||
{ prefix: 'bsch.classmanage.system_group_tree', name: '全区树缓存' },
|
||||
{ prefix: 'bsch.classmanage.system_schsystem', name: '系统学制缓存' },
|
||||
{ prefix: 'bsch.classmanage.system_yearterm', name: '系统学年学期缓存' },
|
||||
{ prefix: 'bsch.classmanage.system_subject', name: '系统科目缓存' },
|
||||
{ prefix: 'bsch.classmanage.system_grdae', name: '系统年级缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_tree', name: '学校树缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_list', name: '学校列表缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_subject', name: '学校科目缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_campus', name: '学校院校缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_division', name: '学部缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_majors', name: '专业缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_course', name: '课程缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_grdae', name: '学校年级缓存' },
|
||||
{ prefix: 'bsch.classmanage.sch_teacher', name: '教师缓存' }
|
||||
];
|
||||
|
||||
function cacheName(key) {
|
||||
if (!key) return '';
|
||||
for (const c of CACHE_NAMES) {
|
||||
if (key === c.prefix || key.startsWith(c.prefix + '_')) return c.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return { 'Accept': 'application/json' };
|
||||
}
|
||||
|
||||
function toast(msg, ok) {
|
||||
const t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show' + (ok ? ' ok' : '');
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(() => t.classList.remove('show'), 3500);
|
||||
}
|
||||
|
||||
function showLoading(btn, text) {
|
||||
btn.dataset.old = btn.textContent;
|
||||
btn.innerHTML = '<span class="spinner"></span>' + text;
|
||||
btn.disabled = true;
|
||||
}
|
||||
function hideLoading(btn) {
|
||||
btn.textContent = btn.dataset.old || btn.textContent;
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes == null || bytes < 0) return '-';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||
return (bytes / 1024 / 1024).toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
// 检测被 CAS 登录拦截(302 重定向到登录页后再被 fetch 跟随后返回 HTML)
|
||||
function checkLoginRedirect(res) {
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (res.redirected || !ct.includes('json')) {
|
||||
// 页面地址动态推导:开发 http://192.168.10.166:8086/,生产 http://base.yunhaixingtong.com/bschclass/
|
||||
const pageUrl = location.origin + location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1) + 'cache-view.html';
|
||||
return '未登录或登录态无效(请求被重定向到 CAS 登录页)。' +
|
||||
'请在「同一个浏览器、已 CAS 登录且为超级管理员」的会话下访问:' + pageUrl;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function apiGet(path, params) {
|
||||
const url = API + path + '?' + new URLSearchParams(params).toString();
|
||||
const res = await fetch(url, { credentials: 'include', headers: authHeaders() });
|
||||
const loginErr = checkLoginRedirect(res);
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (e) { /* ignore */ }
|
||||
if (loginErr) throw new Error(loginErr);
|
||||
if (!res.ok) {
|
||||
const msg = (data && (data.msg || data.message)) || ('请求失败 HTTP ' + res.status);
|
||||
throw new Error(msg);
|
||||
}
|
||||
// 统一解包:后端统一返回 { code, msg, data } 结构,成功(code===0)时取内层 data
|
||||
if (data && typeof data === 'object' && 'code' in data && 'data' in data) {
|
||||
if (data.code !== 0) {
|
||||
throw new Error(data.msg || data.message || ('业务错误 code=' + data.code));
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function queryByKey(key) {
|
||||
if (!key) { toast('请输入 Key'); return; }
|
||||
const btn = $('btnQuery');
|
||||
showLoading(btn, '查询中');
|
||||
try {
|
||||
const data = await apiGet('/get', { key });
|
||||
currentKey = key;
|
||||
currentData = data;
|
||||
renderDetail(data);
|
||||
markActiveKey(key);
|
||||
} catch (e) {
|
||||
toast('查询失败:' + e.message);
|
||||
} finally {
|
||||
hideLoading(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function listKeys(pattern) {
|
||||
pattern = pattern || '*';
|
||||
const btn = $('btnList');
|
||||
showLoading(btn, '扫描中');
|
||||
try {
|
||||
const keys = await apiGet('/keys', { pattern });
|
||||
renderKeys(Array.from(keys || []).sort());
|
||||
} catch (e) {
|
||||
toast('列出失败:' + e.message);
|
||||
} finally {
|
||||
hideLoading(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderKeys(keys) {
|
||||
const box = $('keysList');
|
||||
box.innerHTML = '';
|
||||
if (!keys.length) {
|
||||
box.innerHTML = '<div class="k" style="cursor:default;color:var(--muted)">未匹配到 Key</div>';
|
||||
return;
|
||||
}
|
||||
keys.forEach(k => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'k';
|
||||
const name = cacheName(k);
|
||||
div.innerHTML = (name ? '<span class="kname">' + escHtml(name) + '</span>' : '') +
|
||||
'<span class="kkey">' + escHtml(k) + '</span>';
|
||||
div.title = k;
|
||||
div.onclick = () => { $('keyInput').value = k; queryByKey(k); };
|
||||
box.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function markActiveKey(key) {
|
||||
document.querySelectorAll('#keysList .k').forEach(el => {
|
||||
el.classList.toggle('active', el.textContent === key);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail(data) {
|
||||
$('detailEmpty').style.display = 'none';
|
||||
$('detailBody').style.display = 'block';
|
||||
|
||||
$('mKey').textContent = data.key || '';
|
||||
$('mCacheName').textContent = cacheName(data.key) || '-';
|
||||
const type = data.type || '-';
|
||||
$('mType').innerHTML = '<span class="badge ' + type + '">' + type + '</span>';
|
||||
|
||||
const expireSec = data.expire;
|
||||
let exp = data.expireText || '';
|
||||
if (expireSec !== undefined && expireSec !== null && expireSec >= 0) {
|
||||
exp += '(' + expireSec + ' 秒)';
|
||||
}
|
||||
$('mExpire').textContent = exp || '-';
|
||||
$('mSize').textContent = (data.size !== undefined ? formatSize(data.size) : '-') +
|
||||
(data.size !== undefined ? '(' + data.size + ' 字节)' : '');
|
||||
|
||||
renderValue(data.value);
|
||||
}
|
||||
|
||||
function renderValue(value) {
|
||||
currentValue = value;
|
||||
currentData = Object.assign({}, currentData, { value });
|
||||
const valType = Array.isArray(value) ? 'array' : (value && typeof value === 'object' ? 'object' : 'scalar');
|
||||
const count = Array.isArray(value) ? value.length : (value && typeof value === 'object' ? Object.keys(value).length : 1);
|
||||
renderFormatted();
|
||||
$('rowCount').textContent = '类型=' + valType + ' · 数量=' + count;
|
||||
}
|
||||
|
||||
function renderFormatted() {
|
||||
if (currentValue === undefined || currentValue === null) return;
|
||||
const value = currentValue;
|
||||
if (currentFormat === 'text') {
|
||||
const text = JSON.stringify(value);
|
||||
$('valueJson').textContent = text;
|
||||
} else {
|
||||
const json = JSON.stringify(value, null, 2);
|
||||
$('valueJson').innerHTML = highlightJson(json);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightJson(json) {
|
||||
let html = escHtml(json);
|
||||
// 高亮 key(引号后跟冒号)
|
||||
html = html.replace(/("(?:\\.|[^"\\])*")(\s*:)/g, '<span class="jk">$1</span>$2');
|
||||
// 高亮 string value(冒号+空白后跟引号串)
|
||||
html = html.replace(/(:\s*)("(?:\\.|[^"\\])*")/g, '$1<span class="js">$2</span>');
|
||||
// 高亮 number
|
||||
html = html.replace(/(:\s*)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/g, '$1<span class="jn">$2</span>');
|
||||
// 高亮 boolean / null
|
||||
html = html.replace(/(:\s*)(true|false|null)\b/g, '$1<span class="jb">$2</span>');
|
||||
return html;
|
||||
}
|
||||
|
||||
async function deleteKey() {
|
||||
if (!currentKey) return;
|
||||
if (!confirm('确认删除 Key:\n' + currentKey + '\n此操作不可恢复!')) return;
|
||||
const btn = $('btnDelete');
|
||||
showLoading(btn, '删除中');
|
||||
try {
|
||||
const res = await fetch(API + '/delete?key=' + encodeURIComponent(currentKey), {
|
||||
method: 'POST', credentials: 'include', headers: authHeaders()
|
||||
});
|
||||
const loginErr = checkLoginRedirect(res);
|
||||
let data = null; try { data = await res.json(); } catch (e) {}
|
||||
if (loginErr) throw new Error(loginErr);
|
||||
if (!res.ok) throw new Error((data && (data.msg || data.message)) || ('HTTP ' + res.status));
|
||||
toast('已删除:' + (data.msg || ''), true);
|
||||
$('detailBody').style.display = 'none';
|
||||
$('detailEmpty').style.display = 'block';
|
||||
currentKey = null; currentData = null;
|
||||
} catch (e) {
|
||||
toast('删除失败:' + e.message);
|
||||
} finally {
|
||||
hideLoading(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyValue() {
|
||||
if (currentValue === null || currentValue === undefined) { toast('当前没有可复制的内容'); return; }
|
||||
const text = currentFormat === 'text' ? JSON.stringify(currentValue) : JSON.stringify(currentValue, null, 2);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast('已复制到剪贴板', true);
|
||||
} catch (e) {
|
||||
// 降级方案
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
toast('已复制到剪贴板', true);
|
||||
} catch (err) {
|
||||
toast('复制失败:' + err.message);
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshValue() {
|
||||
if (!currentKey) { toast('当前没有 Key 可刷新'); return; }
|
||||
queryByKey(currentKey);
|
||||
}
|
||||
|
||||
function changeFormat(fmt) {
|
||||
currentFormat = fmt || $('formatSelect').value;
|
||||
$('formatSelect').value = currentFormat;
|
||||
renderFormatted();
|
||||
}
|
||||
|
||||
// 事件绑定
|
||||
$('btnQuery').onclick = () => queryByKey($('keyInput').value.trim());
|
||||
$('btnList').onclick = () => listKeys($('patternInput').value.trim() || '*');
|
||||
$('btnClear').onclick = () => { $('keyInput').value = ''; $('patternInput').value = ''; $('keysList').innerHTML = ''; };
|
||||
$('btnDelete').onclick = deleteKey;
|
||||
$('btnCopy').onclick = copyValue;
|
||||
$('btnRefresh').onclick = refreshValue;
|
||||
$('formatSelect').onchange = () => changeFormat();
|
||||
$('keyInput').addEventListener('keydown', e => { if (e.key === 'Enter') queryByKey(e.target.value.trim()); });
|
||||
$('patternInput').addEventListener('keydown', e => { if (e.key === 'Enter') listKeys(e.target.value.trim() || '*'); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user