25 Commits

Author SHA1 Message Date
RuoYi
63a556bd3a 若依 3.6.8 2026-03-30 08:01:13 +08:00
RuoYi
d94b7d1a5f 优化页签全屏显示 2026-03-26 00:53:31 +08:00
RuoYi
385fc50c19 优化快速点击页签刷新出现404问题 2026-03-25 21:22:47 +08:00
RuoYi
50fc2989ee 修复typescript版多表代码生成报错问题 2026-03-25 13:03:12 +08:00
RuoYi
80483dd17c 菜单管理列表新增类型显示 2026-03-24 15:34:28 +08:00
RuoYi
ab77b8cb8d 优化菜单主题风格 2026-03-24 10:52:30 +08:00
RuoYi
14e8885fba 升级axios到最新版本0.30.3 2026-03-23 11:45:11 +08:00
RuoYi
c29f5e2c04 优化页签显示 2026-03-23 11:43:40 +08:00
RuoYi
b33ea72445 添加持久化标签页开关功能 2026-03-22 23:29:55 +08:00
RuoYi
543d169827 添加持久化标签页开关功能 2026-03-22 21:13:35 +08:00
RuoYi
9ddcb875a1 菜单搜索支持文本高亮&数量提示 2026-03-22 16:55:37 +08:00
RuoYi
aa5530f51b 优化页签功能&支持全屏按钮操作 2026-03-22 15:03:02 +08:00
RuoYi
0017cc8862 首页新增通知公告消息提醒 2026-03-22 00:15:34 +08:00
RuoYi
88c2e4979a 新增锁定屏幕功能 2026-03-21 23:29:22 +08:00
RuoYi
b43048a589 菜单管理支持批量保存排序 2026-03-21 22:54:21 +08:00
RuoYi
0025941824 保存排序添加编辑权限 2026-03-21 22:41:49 +08:00
RuoYi
784fe5c399 优化定时任务详情页展示&补充执行时间字段 2026-03-21 22:23:32 +08:00
RuoYi
af2d446a5f 字典类型列表新增抽屉效果详细信息 2026-03-21 22:07:16 +08:00
RuoYi
1f8dd5feb5 操作日志详细页面优化 2026-03-21 21:11:40 +08:00
RuoYi
d3b5e93952 优化RightToolbar搜索栏切换动画 2026-03-21 20:51:34 +08:00
RuoYi
8d84783daf update README.md 2026-03-14 13:44:17 +08:00
若依
0bf6679753 !453 readme中添加nacos版本说明
Merge pull request !453 from magic/update/readme
2026-03-14 05:39:09 +00:00
gowshwah
e1e8b541db readme中添加nacos版本说明 2026-03-14 00:31:55 +08:00
若依
d30a6450c9 !450 update docker image version for master branch
Merge pull request !450 from magic/fix/docker
2026-03-12 14:22:10 +00:00
gowshwah
571a25d486 update docker image version for master branch 2026-03-12 16:42:31 +08:00
103 changed files with 3158 additions and 453 deletions

View File

@@ -1,11 +1,11 @@
<p align="center">
<img alt="logo" src="https://oscimg.oschina.net/oscnet/up-b99b286755aef70355a7084753f89cdb7c9.png">
</p>
<h1 align="center" style="margin: 30px 0 30px; font-weight: bold;">RuoYi v3.6.7</h1>
<h1 align="center" style="margin: 30px 0 30px; font-weight: bold;">RuoYi v3.6.8</h1>
<h4 align="center">基于 Vue/Element UI 和 Spring Boot/Spring Cloud & Alibaba 前后端分离的分布式微服务架构</h4>
<p align="center">
<a href="https://gitee.com/y_project/RuoYi-Cloud/stargazers"><img src="https://gitee.com/y_project/RuoYi-Cloud/badge/star.svg?theme=dark"></a>
<a href="https://gitee.com/y_project/RuoYi-Cloud"><img src="https://img.shields.io/badge/RuoYi-v3.6.7-brightgreen.svg"></a>
<a href="https://gitee.com/y_project/RuoYi-Cloud"><img src="https://img.shields.io/badge/RuoYi-v3.6.8-brightgreen.svg"></a>
<a href="https://gitee.com/y_project/RuoYi-Cloud/blob/master/LICENSE"><img src="https://img.shields.io/github/license/mashape/apistatus.svg"></a>
</p>
@@ -23,11 +23,11 @@
RuoYi-Cloud 后端项目提供 Spring Boot 2.x / 3.x / 4.x 多版本分支的并行维护。
| 名称 | 说明 | 地址 |
| :---------------- | :------------------------ | :-------------------------------------------------------- |
| master 默认分支 | Spring Boot 4.x (JDK 17+) | https://gitee.com/y_project/RuoYi-Cloud |
| springboot3 分支 | Spring Boot 3.x (JDK 17+) | https://gitee.com/y_project/RuoYi-Cloud/tree/springboot3 |
| springboot2 分支 | Spring Boot 2.x (JDK 8+) | https://gitee.com/y_project/RuoYi-Cloud/tree/springboot2 |
| 名称 | 说明 | 地址 |
| :---------------- | :----------------------------------- | :-------------------------------------------------------- |
| master 默认分支 | Spring Boot 4.x (JDK 17+、Nacos 3.x) | https://gitee.com/y_project/RuoYi-Cloud |
| springboot3 分支 | Spring Boot 3.x (JDK 17+、Nacos 3.x) | https://gitee.com/y_project/RuoYi-Cloud/tree/springboot3 |
| springboot2 分支 | Spring Boot 2.x (JDK 8+、 Nacos 2.x) | https://gitee.com/y_project/RuoYi-Cloud/tree/springboot2 |
RuoYi-Cloud 前端项目提供 Vue 2.x / 3.x / JavaScript TypeScript 版本均可混用搭配

View File

@@ -9,7 +9,7 @@ usage() {
# copy sql
echo "begin copy sql "
cp ../sql/ry_20250523.sql ./mysql/db
cp ../sql/ry_20260321.sql ./mysql/db
cp ../sql/ry_config_20260311.sql ./mysql/db
# copy html

View File

@@ -2,11 +2,14 @@ version : '3.8'
services:
ruoyi-nacos:
container_name: ruoyi-nacos
image: nacos/nacos-server
image: nacos/nacos-server:v3.0.2
build:
context: ./nacos
environment:
- MODE=standalone
- NACOS_AUTH_TOKEN=your_auth_token
- NACOS_AUTH_IDENTITY_KEY=your_identity_key
- NACOS_AUTH_IDENTITY_VALUE=your_identity_value
volumes:
- ./nacos/logs/:/home/nacos/logs
- ./nacos/conf/application.properties:/home/nacos/conf/application.properties

View File

@@ -24,6 +24,7 @@ nacos.core.auth.system.type=nacos
nacos.core.auth.enabled=false
nacos.core.auth.default.token.expire.seconds=18000
nacos.core.auth.default.token.secret.key=SecretKey012345678901234567890123456789012345678901234567890123456789
nacos.core.auth.plugin.nacos.token.secret.key=SecretKey012345678901234567890123456789012345678901234567890123456789
nacos.core.auth.caching.enabled=true
nacos.core.auth.enable.userAgentAuthWhite=false
nacos.core.auth.server.identity.key=serverIdentity

View File

@@ -1,5 +1,5 @@
# 基础镜像
FROM nacos/nacos-server
FROM nacos/nacos-server:v3.0.2
# author
MAINTAINER ruoyi

View File

@@ -1,5 +1,5 @@
# 基础镜像
FROM openjdk:8-jre
FROM openjdk:17
# author
MAINTAINER ruoyi

View File

@@ -1,5 +1,5 @@
# 基础镜像
FROM openjdk:8-jre
FROM openjdk:17
# author
MAINTAINER ruoyi

View File

@@ -1,5 +1,5 @@
# 基础镜像
FROM openjdk:8-jre
FROM openjdk:17
# author
MAINTAINER ruoyi

View File

@@ -1,5 +1,5 @@
# 基础镜像
FROM openjdk:8-jre
FROM openjdk:17
# author
MAINTAINER ruoyi

View File

@@ -1,5 +1,5 @@
# 基础镜像
FROM openjdk:8-jre
FROM openjdk:17
# author
MAINTAINER ruoyi

View File

@@ -1,5 +1,5 @@
# 基础镜像
FROM openjdk:8-jre
FROM openjdk:17
# author
MAINTAINER ruoyi

View File

@@ -1,5 +1,5 @@
# 基础镜像
FROM openjdk:8-jre
FROM openjdk:17
# author
MAINTAINER ruoyi

View File

@@ -6,14 +6,14 @@
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
<name>ruoyi</name>
<url>http://www.ruoyi.vip</url>
<description>若依微服务系统</description>
<properties>
<ruoyi.version>3.6.7</ruoyi.version>
<ruoyi.version>3.6.8</ruoyi.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>17</java.version>

View File

@@ -4,7 +4,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-api</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -4,7 +4,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.auth.form.LoginBody;
import com.ruoyi.auth.form.RegisterBody;
import com.ruoyi.auth.form.UnLockBody;
import com.ruoyi.auth.service.SysLoginService;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.utils.JwtUtils;
@@ -75,4 +76,14 @@ public class TokenController
sysLoginService.register(registerBody.getUsername(), registerBody.getPassword());
return R.ok();
}
/**
* 解锁屏幕
*/
@PostMapping("/unlockscreen")
public R<?> unlockScreen(@RequestBody UnLockBody unLockBody)
{
sysLoginService.unlock(unLockBody.getPassword());
return R.ok();
}
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.auth.form;
/**
* 系统解锁对象
*
* @author ruoyi
*/
public class UnLockBody
{
/**
* 用户密码
*/
private String password;
public String getPassword()
{
return password;
}
public void setPassword(String password)
{
this.password = password;
}
}

View File

@@ -113,11 +113,40 @@ public class SysLoginService
remoteUserService.recordUserLogin(sysUser, SecurityConstants.INNER);
}
/**
* 退出
*/
public void logout(String loginName)
{
recordLogService.recordLogininfor(loginName, Constants.LOGOUT, "退出成功");
}
/**
* 解锁
*/
public void unlock(String password)
{
String username = SecurityUtils.getUsername();
// 或密码为空 错误
if (StringUtils.isEmpty(password))
{
throw new ServiceException("密码不能为空");
}
// 查询用户信息
R<LoginUser> userResult = remoteUserService.getUserInfo(username, SecurityConstants.INNER);
if (R.FAIL == userResult.getCode())
{
throw new ServiceException(userResult.getMsg());
}
SysUser user = userResult.getData().getSysUser();
if (!SecurityUtils.matchesPassword(password, user.getPassword()))
{
throw new ServiceException("密码错误,请重新输入");
}
}
/**
* 注册
*/

View File

@@ -4,7 +4,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -4,7 +4,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -4,7 +4,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -4,7 +4,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-modules</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-modules</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -4,6 +4,8 @@ import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.StringWriter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -228,11 +230,7 @@ public class GenTableServiceImpl implements IGenTableService
@Override
public byte[] downloadCode(String tableName)
{
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(outputStream);
generatorCode(tableName, zip);
IOUtils.closeQuietly(zip);
return outputStream.toByteArray();
return downloadCode(new String[] { tableName });
}
/**
@@ -343,9 +341,14 @@ public class GenTableServiceImpl implements IGenTableService
{
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(outputStream);
Map<String, StringBuffer> typeFiles = new HashMap<>();
for (String tableName : tableNames)
{
generatorCode(tableName, zip);
generatorCode(tableName, zip, typeFiles);
}
for (Map.Entry<String, StringBuffer> entry : typeFiles.entrySet())
{
writeToZip(zip, entry.getKey(), entry.getValue().toString());
}
IOUtils.closeQuietly(zip);
return outputStream.toByteArray();
@@ -354,7 +357,7 @@ public class GenTableServiceImpl implements IGenTableService
/**
* 查询表信息并生成代码
*/
private void generatorCode(String tableName, ZipOutputStream zip)
private void generatorCode(String tableName, ZipOutputStream zip, Map<String, StringBuffer> typeFiles)
{
// 查询表信息
GenTable table = genTableMapper.selectGenTableByName(tableName);
@@ -375,22 +378,49 @@ public class GenTableServiceImpl implements IGenTableService
StringWriter sw = new StringWriter();
Template tpl = Velocity.getTemplate(template, Constants.UTF8);
tpl.merge(context, sw);
try
String fileName = VelocityUtils.getFileName(template, table);
// index-bak.ts 模版,追加内容
if (fileName.contains("index-bak.ts"))
{
// 添加到zip
zip.putNextEntry(new ZipEntry(VelocityUtils.getFileName(template, table)));
IOUtils.write(sw.toString(), zip, Constants.UTF8);
IOUtils.closeQuietly(sw);
zip.flush();
zip.closeEntry();
if (!typeFiles.containsKey(fileName))
{
typeFiles.put(fileName, new StringBuffer(sw.toString()));
}
else
{
Arrays.stream(sw.toString().split("\n")).filter(line -> line.startsWith("export * from")).forEach(line -> typeFiles.get(fileName).append("\n").append(line));
}
}
catch (IOException e)
else
{
log.error("渲染模板失败,表名:" + table.getTableName(), e);
// 其他文件正常添加
writeToZip(zip, fileName, sw.toString());
}
}
}
/**
* 将字符串内容写入ZIP输出流
*
* @param zip ZIP输出流
* @param fileName ZIP条目名称即文件名
* @param content 要写入的内容
*/
private void writeToZip(ZipOutputStream zip, String fileName, String content)
{
try
{
zip.putNextEntry(new ZipEntry(fileName));
IOUtils.write(content, zip, Constants.UTF8);
zip.flush();
zip.closeEntry();
}
catch (IOException e)
{
log.error("写入ZIP文件失败文件名: " + fileName, e);
}
}
/**
* 修改保存参数校验
*

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-modules</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -3,6 +3,7 @@ package com.ruoyi.job.domain;
import java.util.Date;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.annotation.Excel;
import com.ruoyi.common.core.web.domain.BaseEntity;
@@ -44,10 +45,12 @@ public class SysJobLog extends BaseEntity
private String exceptionInfo;
/** 开始时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
/** 停止时间 */
private Date stopTime;
/** 结束时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
public Long getJobLogId()
{
@@ -129,14 +132,14 @@ public class SysJobLog extends BaseEntity
this.startTime = startTime;
}
public Date getStopTime()
public Date getEndTime()
{
return stopTime;
return endTime;
}
public void setStopTime(Date stopTime)
public void setEndTime(Date endTime)
{
this.stopTime = stopTime;
this.endTime = endTime;
}
@Override
@@ -149,7 +152,7 @@ public class SysJobLog extends BaseEntity
.append("status", getStatus())
.append("exceptionInfo", getExceptionInfo())
.append("startTime", getStartTime())
.append("stopTime", getStopTime())
.append("stopTime", getEndTime())
.toString();
}
}

View File

@@ -76,8 +76,8 @@ public abstract class AbstractQuartzJob implements Job
sysJobLog.setJobGroup(sysJob.getJobGroup());
sysJobLog.setInvokeTarget(sysJob.getInvokeTarget());
sysJobLog.setStartTime(startTime);
sysJobLog.setStopTime(new Date());
long runMs = sysJobLog.getStopTime().getTime() - sysJobLog.getStartTime().getTime();
sysJobLog.setEndTime(new Date());
long runMs = sysJobLog.getEndTime().getTime() - sysJobLog.getStartTime().getTime();
sysJobLog.setJobMessage(sysJobLog.getJobName() + " 总共耗时:" + runMs + "毫秒");
if (e != null)
{

View File

@@ -12,11 +12,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="jobMessage" column="job_message" />
<result property="status" column="status" />
<result property="exceptionInfo" column="exception_info" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectJobLogVo">
select job_log_id, job_name, job_group, invoke_target, job_message, status, exception_info, create_time
select job_log_id, job_name, job_group, invoke_target, job_message, status, exception_info, start_time, end_time, create_time
from sys_job_log
</sql>
@@ -78,6 +80,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="jobMessage != null and jobMessage != ''">job_message,</if>
<if test="status != null and status != ''">status,</if>
<if test="exceptionInfo != null and exceptionInfo != ''">exception_info,</if>
<if test="startTime != null">start_time,</if>
<if test="endTime != null">end_time,</if>
create_time
)values(
<if test="jobLogId != null and jobLogId != 0">#{jobLogId},</if>
@@ -87,6 +91,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="jobMessage != null and jobMessage != ''">#{jobMessage},</if>
<if test="status != null and status != ''">#{status},</if>
<if test="exceptionInfo != null and exceptionInfo != ''">#{exceptionInfo},</if>
<if test="startTime != null">#{startTime},</if>
<if test="endTime != null">#{endTime},</if>
sysdate()
)
</insert>

View File

@@ -21,7 +21,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectJobVo">
select job_id, job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark
select job_id, job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, update_by, update_time, remark
from sys_job
</sql>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-modules</artifactId>
<version>3.6.7</version>
<version>3.6.8</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -1,6 +1,7 @@
package com.ruoyi.system.controller;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.ArrayUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
@@ -111,6 +112,20 @@ public class SysDeptController extends BaseController
return toAjax(deptService.updateDept(dept));
}
/**
* 保存部门排序
*/
@RequiresPermissions("system:dept:edit")
@Log(title = "保存部门排序", businessType = BusinessType.UPDATE)
@PutMapping("/updateSort")
public AjaxResult updateSort(@RequestBody Map<String, String> params)
{
String[] deptIds = params.get("deptIds").split(",");
String[] orderNums = params.get("orderNums").split(",");
deptService.updateDeptSort(deptIds, orderNums);
return success();
}
/**
* 删除部门
*/

View File

@@ -1,6 +1,7 @@
package com.ruoyi.system.controller;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -133,6 +134,20 @@ public class SysMenuController extends BaseController
return toAjax(menuService.updateMenu(menu));
}
/**
* 保存菜单排序
*/
@RequiresPermissions("system:menu:edit")
@Log(title = "保存菜单排序", businessType = BusinessType.UPDATE)
@PutMapping("/updateSort")
public AjaxResult updateSort(@RequestBody Map<String, String> params)
{
String[] menuIds = params.get("menuIds").split(",");
String[] orderNums = params.get("orderNums").split(",");
menuService.updateMenuSort(menuIds, orderNums);
return success();
}
/**
* 删除菜单
*/

View File

@@ -10,7 +10,9 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.text.Convert;
import com.ruoyi.common.core.web.controller.BaseController;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.common.core.web.page.TableDataInfo;
@@ -19,6 +21,7 @@ import com.ruoyi.common.log.enums.BusinessType;
import com.ruoyi.common.security.annotation.RequiresPermissions;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.system.domain.SysNotice;
import com.ruoyi.system.service.ISysNoticeReadService;
import com.ruoyi.system.service.ISysNoticeService;
/**
@@ -33,6 +36,9 @@ public class SysNoticeController extends BaseController
@Autowired
private ISysNoticeService noticeService;
@Autowired
private ISysNoticeReadService noticeReadService;
/**
* 获取通知公告列表
*/
@@ -79,6 +85,46 @@ public class SysNoticeController extends BaseController
return toAjax(noticeService.updateNotice(notice));
}
/**
* 首页顶部公告列表返回全部正常公告带当前用户已读标记最多5条
*/
@GetMapping("/listTop")
@ResponseBody
public AjaxResult listTop()
{
Long userId = SecurityUtils.getUserId();
List<SysNotice> list = noticeReadService.selectNoticeListWithReadStatus(userId, 5);
long unreadCount = list.stream().filter(n -> !n.getIsRead()).count();
AjaxResult result = AjaxResult.success(list);
result.put("unreadCount", unreadCount);
return result;
}
/**
* 标记公告已读
*/
@PostMapping("/markRead")
@ResponseBody
public AjaxResult markRead(Long noticeId)
{
Long userId = SecurityUtils.getUserId();
noticeReadService.markRead(noticeId, userId);
return success();
}
/**
* 批量标记已读
*/
@PostMapping("/markReadAll")
@ResponseBody
public AjaxResult markReadAll(String ids)
{
Long userId = SecurityUtils.getUserId();
Long[] noticeIds = Convert.toLongArray(ids);
noticeReadService.markReadBatch(userId, noticeIds);
return success();
}
/**
* 删除通知公告
*/
@@ -87,6 +133,7 @@ public class SysNoticeController extends BaseController
@DeleteMapping("/{noticeIds}")
public AjaxResult remove(@PathVariable Long[] noticeIds)
{
noticeReadService.deleteByNoticeIds(noticeIds);
return toAjax(noticeService.deleteNoticeByIds(noticeIds));
}
}

View File

@@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ruoyi.common.core.web.domain.BaseEntity;
import com.ruoyi.common.core.xss.Xss;
@@ -31,6 +32,10 @@ public class SysNotice extends BaseEntity
/** 公告状态0正常 1关闭 */
private String status;
/** 是否已读 */
@JsonProperty("isRead")
private boolean isRead;
public Long getNoticeId()
{
return noticeId;
@@ -84,6 +89,16 @@ public class SysNotice extends BaseEntity
return status;
}
public boolean getIsRead()
{
return isRead;
}
public void setIsRead(boolean isRead)
{
this.isRead = isRead;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)

View File

@@ -0,0 +1,76 @@
package com.ruoyi.system.domain;
import java.util.Date;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
/**
* 公告已读记录表 sys_notice_read
*
* @author ruoyi
*/
public class SysNoticeRead
{
/** 主键 */
private Long readId;
/** 公告ID */
private Long noticeId;
/** 用户ID */
private Long userId;
/** 阅读时间 */
private Date readTime;
public Long getReadId()
{
return readId;
}
public void setReadId(Long readId)
{
this.readId = readId;
}
public Long getNoticeId()
{
return noticeId;
}
public void setNoticeId(Long noticeId)
{
this.noticeId = noticeId;
}
public Long getUserId()
{
return userId;
}
public void setUserId(Long userId)
{
this.userId = userId;
}
public Date getReadTime()
{
return readTime;
}
public void setReadTime(Date readTime)
{
this.readTime = readTime;
}
@Override
public String toString()
{
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("readId", getReadId())
.append("noticeId", getNoticeId())
.append("userId", getUserId())
.append("readTime", getReadTime())
.toString();
}
}

View File

@@ -108,6 +108,13 @@ public interface SysDeptMapper
*/
public int updateDeptChildren(@Param("depts") List<SysDept> depts);
/**
* 保存部门排序
*
* @param dept 部门信息
*/
public void updateDeptSort(SysDept dept);
/**
* 删除部门管理信息
*

View File

@@ -106,6 +106,13 @@ public interface SysMenuMapper
*/
public int updateMenu(SysMenu menu);
/**
* 保存菜单排序
*
* @param menu 菜单信息
*/
public void updateMenuSort(SysMenu menu);
/**
* 删除菜单管理信息
*

View File

@@ -0,0 +1,65 @@
package com.ruoyi.system.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.system.domain.SysNoticeRead;
import com.ruoyi.system.domain.SysNotice;
/**
* 公告已读记录 数据层
*
* @author ruoyi
*/
public interface SysNoticeReadMapper
{
/**
* 新增已读记录(忽略重复)
*
* @param noticeRead 已读记录
* @return 结果
*/
public int insertNoticeRead(SysNoticeRead noticeRead);
/**
* 查询某用户未读公告数量
*
* @param userId 用户ID
* @return 未读数量
*/
public int selectUnreadCount(@Param("userId") Long userId);
/**
* 查询某用户是否已读某公告
*
* @param noticeId 公告ID
* @param userId 用户ID
* @return 已读记录数0未读 1已读
*/
public int selectIsRead(@Param("noticeId") Long noticeId, @Param("userId") Long userId);
/**
* 批量标记已读
*
* @param userId 用户ID
* @param noticeIds 公告ID数组
* @return 结果
*/
public int insertNoticeReadBatch(@Param("userId") Long userId, @Param("noticeIds") Long[] noticeIds);
/**
* 查询带已读状态的公告列表SQL层限制条数一次查询完成
*
* @param userId 用户ID
* @param limit 最多返回条数
* @return 带 isRead 标记的公告列表
*/
public List<SysNotice> selectNoticeListWithReadStatus(@Param("userId") Long userId, @Param("limit") int limit);
/**
* 公告删除时清理对应已读记录
*
* @param noticeIds 公告ID数组
* @return 结果
*/
public int deleteByNoticeIds(@Param("noticeIds") Long[] noticeIds);
}

View File

@@ -114,6 +114,14 @@ public interface ISysDeptService
*/
public int updateDept(SysDept dept);
/**
* 保存部门排序
*
* @param deptIds 部门ID数组
* @param orderNums 排序数组
*/
public void updateDeptSort(String[] deptIds, String[] orderNums);
/**
* 删除部门管理信息
*

View File

@@ -126,6 +126,14 @@ public interface ISysMenuService
*/
public int updateMenu(SysMenu menu);
/**
* 保存菜单排序
*
* @param menuIds 菜单ID
* @param orderNums 排序ID
*/
public void updateMenuSort(String[] menuIds, String[] orderNums);
/**
* 删除菜单管理信息
*

View File

@@ -0,0 +1,52 @@
package com.ruoyi.system.service;
import java.util.List;
import com.ruoyi.system.domain.SysNotice;
/**
* 公告已读记录 服务层
*
* @author ruoyi
*/
public interface ISysNoticeReadService
{
/**
* 标记已读(幂等,重复调用不报错)
*
* @param noticeId 公告ID
* @param userId 用户ID
*/
public void markRead(Long noticeId, Long userId);
/**
* 查询某用户未读公告数量
*
* @param userId 用户ID
* @return 未读数量
*/
public int selectUnreadCount(Long userId);
/**
* 查询公告列表并标记当前用户已读状态(用于首页展示)
*
* @param userId 用户ID
* @param limit 最多返回条数
* @return 带 isRead 标记的公告列表
*/
public List<SysNotice> selectNoticeListWithReadStatus(Long userId, int limit);
/**
* 批量标记已读
*
* @param userId 用户ID
* @param noticeIds 公告ID数组
*/
public void markReadBatch(Long userId, Long[] noticeIds);
/**
* 删除公告时清理对应已读记录
*
* @param noticeIds 公告ID数组
*/
public void deleteByNoticeIds(Long[] noticeIds);
}

View File

@@ -6,6 +6,7 @@ import java.util.List;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.core.constant.UserConstants;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.text.Convert;
@@ -280,6 +281,32 @@ public class SysDeptServiceImpl implements ISysDeptService
}
}
/**
* 保存部门排序
*
* @param deptIds 部门ID数组
* @param orderNums 排序数组
*/
@Override
@Transactional
public void updateDeptSort(String[] deptIds, String[] orderNums)
{
try
{
for (int i = 0; i < deptIds.length; i++)
{
SysDept dept = new SysDept();
dept.setDeptId(Convert.toLong(deptIds[i]));
dept.setOrderNum(Convert.toInt(orderNums[i]));
deptMapper.updateDeptSort(dept);
}
}
catch (Exception e)
{
throw new ServiceException("保存排序异常,请联系管理员");
}
}
/**
* 删除部门管理信息
*

View File

@@ -12,8 +12,11 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.core.constant.Constants;
import com.ruoyi.common.core.constant.UserConstants;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.text.Convert;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.system.api.domain.SysRole;
@@ -321,6 +324,32 @@ public class SysMenuServiceImpl implements ISysMenuService
return menuMapper.updateMenu(menu);
}
/**
* 保存菜单排序
*
* @param menuIds 菜单ID
* @param orderNums 排序ID
*/
@Override
@Transactional
public void updateMenuSort(String[] menuIds, String[] orderNums)
{
try
{
for (int i = 0; i < menuIds.length; i++)
{
SysMenu menu = new SysMenu();
menu.setMenuId(Convert.toLong(menuIds[i]));
menu.setOrderNum(Convert.toInt(orderNums[i]));
menuMapper.updateMenuSort(menu);
}
}
catch (Exception e)
{
throw new ServiceException("保存排序异常,请联系管理员");
}
}
/**
* 删除菜单管理信息
*

View File

@@ -0,0 +1,73 @@
package com.ruoyi.system.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.system.domain.SysNoticeRead;
import com.ruoyi.system.domain.SysNotice;
import com.ruoyi.system.mapper.SysNoticeReadMapper;
import com.ruoyi.system.service.ISysNoticeReadService;
/**
* 公告已读记录 服务层实现
*
* @author ruoyi
*/
@Service
public class SysNoticeReadServiceImpl implements ISysNoticeReadService
{
@Autowired
private SysNoticeReadMapper noticeReadMapper;
/**
* 标记已读
*/
@Override
public void markRead(Long noticeId, Long userId)
{
SysNoticeRead record = new SysNoticeRead();
record.setNoticeId(noticeId);
record.setUserId(userId);
noticeReadMapper.insertNoticeRead(record);
}
/**
* 查询某用户未读公告数量
*/
@Override
public int selectUnreadCount(Long userId)
{
return noticeReadMapper.selectUnreadCount(userId);
}
/**
* 查询公告列表并标记当前用户已读状态
*/
@Override
public List<SysNotice> selectNoticeListWithReadStatus(Long userId, int limit)
{
return noticeReadMapper.selectNoticeListWithReadStatus(userId, limit);
}
/**
* 批量标记已读
*/
@Override
public void markReadBatch(Long userId, Long[] noticeIds)
{
if (noticeIds == null || noticeIds.length == 0)
{
return;
}
noticeReadMapper.insertNoticeReadBatch(userId, noticeIds);
}
/**
* 删除公告时清理对应已读记录
*/
@Override
public void deleteByNoticeIds(Long[] noticeIds)
{
noticeReadMapper.deleteByNoticeIds(noticeIds);
}
}

View File

@@ -150,8 +150,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach>
</update>
<update id="updateDeptSort" parameterType="SysDept">
update sys_dept set order_num = #{orderNum} where dept_id = #{deptId}
</update>
<delete id="deleteDeptById" parameterType="Long">
update sys_dept set del_flag = '2' where dept_id = #{deptId}
</delete>
</mapper>
</mapper>

View File

@@ -204,6 +204,10 @@
)
</insert>
<update id="updateMenuSort" parameterType="SysMenu">
update sys_menu set order_num = #{orderNum} where menu_id = #{menuId}
</update>
<delete id="deleteMenuById" parameterType="Long">
delete from sys_menu where menu_id = #{menuId}
</delete>

View File

@@ -40,6 +40,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
AND create_by like concat('%', #{createBy}, '%')
</if>
</where>
order by notice_id desc
</select>
<insert id="insertNotice" parameterType="SysNotice">

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.SysNoticeReadMapper">
<resultMap type="SysNoticeRead" id="SysNoticeReadResult">
<id property="readId" column="read_id" />
<result property="noticeId" column="notice_id" />
<result property="userId" column="user_id" />
<result property="readTime" column="read_time" />
</resultMap>
<!-- 新增已读记录 -->
<insert id="insertNoticeRead" parameterType="SysNoticeRead">
insert ignore into sys_notice_read (notice_id, user_id, read_time)
values (#{noticeId}, #{userId}, sysdate())
</insert>
<!-- 查询未读数量:正常状态公告 减去 当前用户已读数 -->
<select id="selectUnreadCount" resultType="int">
select count(*) from sys_notice n
where n.status = '0' and not exists (select 1 from sys_notice_read r where r.notice_id = n.notice_id and r.user_id = #{userId})
</select>
<!-- 查询是否已读 -->
<select id="selectIsRead" resultType="int">
select count(*) from sys_notice_read where notice_id = #{noticeId} and user_id = #{userId}
</select>
<!-- 查询带已读状态的公告列表直接在SQL中限制条数 -->
<select id="selectNoticeListWithReadStatus" resultType="SysNotice">
select
n.notice_id as noticeId,
n.notice_title as noticeTitle,
n.notice_type as noticeType,
n.status,
n.create_by as createBy,
n.create_time as createTime,
case when r.notice_id is not null then true else false end as isRead
from sys_notice n
left join sys_notice_read r
on r.notice_id = n.notice_id and r.user_id = #{userId}
where n.status = '0'
order by n.notice_id desc
limit #{limit}
</select>
<!-- 批量标记已读 -->
<insert id="insertNoticeReadBatch">
insert ignore into sys_notice_read (notice_id, user_id, read_time)
values
<foreach collection="noticeIds" item="noticeId" separator=",">
(#{noticeId}, #{userId}, sysdate())
</foreach>
</insert>
<!-- 删除公告时清理已读记录 -->
<delete id="deleteByNoticeIds">
delete from sys_notice_read where notice_id in
<foreach collection="noticeIds" item="noticeId" open="(" separator="," close=")">
#{noticeId}
</foreach>
</delete>
</mapper>

View File

@@ -1,6 +1,6 @@
{
"name": "ruoyi",
"version": "3.6.7",
"version": "3.6.8",
"description": "若依管理系统",
"author": "若依",
"license": "MIT",
@@ -25,7 +25,7 @@
},
"dependencies": {
"@riophae/vue-treeselect": "0.4.0",
"axios": "0.28.1",
"axios": "0.30.3",
"clipboard": "2.0.8",
"core-js": "3.37.1",
"echarts": "5.4.0",

View File

@@ -25,6 +25,15 @@ export function register(data) {
})
}
// 解锁屏幕
export function unlockScreen(password) {
return request({
url: '/auth/unlockscreen',
method: 'post',
data: { password }
})
}
// 刷新方法
export function refreshToken() {
return request({

View File

@@ -43,6 +43,15 @@ export function updateDept(data) {
})
}
// 保存部门排序
export function updateDeptSort(data) {
return request({
url: '/system/dept/updateSort',
method: 'put',
data: data
})
}
// 删除部门
export function delDept(deptId) {
return request({

View File

@@ -51,6 +51,15 @@ export function updateMenu(data) {
})
}
// 保存菜单排序
export function updateMenuSort(data) {
return request({
url: '/system/menu/updateSort',
method: 'put',
data: data
})
}
// 删除菜单
export function delMenu(menuId) {
return request({

View File

@@ -41,4 +41,30 @@ export function delNotice(noticeId) {
url: '/system/notice/' + noticeId,
method: 'delete'
})
}
}
// 首页顶部公告列表(带已读状态)
export function listNoticeTop() {
return request({
url: '/system/notice/listTop',
method: 'get'
})
}
// 标记公告已读
export function markNoticeRead(noticeId) {
return request({
url: '/system/notice/markRead',
method: 'post',
params: { noticeId }
})
}
// 批量标记已读
export function markNoticeReadAll(ids) {
return request({
url: '/system/notice/markReadAll',
method: 'post',
params: { ids }
})
}

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1773923748724" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5930" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 212l48.8 12c101.6 24.8 176 117.6 176 220.8v254.4l18.4 18.4 24.8 25.6h-536l24.8-25.6 18.4-18.4V444.8c0-103.2 73.6-196.8 176-220.8l48.8-12M512 64c-36.8 0-64 30.4-64 68v30.4C320.8 192 223.2 307.2 223.2 444.8v228.8L136 763.2v44.8h752v-44.8l-87.2-89.6V444.8c0-137.6-97.6-252.8-224.8-283.2v-28.8c0-32-17.6-60.8-48-67.2-5.6-1.6-11.2-1.6-16-1.6z m88 808H424c0 49.6 38.4 88 88 88s88-38.4 88-88z" p-id="5931"></path></svg>

After

Width:  |  Height:  |  Size: 750 B

View File

@@ -82,3 +82,11 @@
.el-range-separator {
box-sizing: content-box;
}
.el-menu--collapse
> div
> .el-submenu
> .el-submenu__title
.el-submenu__icon-arrow {
display: none;
}

View File

@@ -17,7 +17,7 @@ $--button-font-weight: 400;
$--border-color-light: #dfe4ed;
$--border-color-lighter: #e6ebf5;
$--table-border:1px solid#dfe6ec;
$--table-border: 1px solid #dfe6ec;
/* icon font path, required */
$--font-path: '~element-ui/lib/theme-chalk/fonts';

View File

@@ -81,6 +81,10 @@
margin-top: 6vh !important;
}
.el-dialog__body {
padding: 8px 20px !important;
}
.el-dialog__wrapper.scrollbar .el-dialog .el-dialog__body {
overflow: auto;
overflow-x: hidden;
@@ -228,6 +232,120 @@
color: #FFFFFF;
}
/** 详细卡片样式 */
.detail-wrap { padding: 0 4px; }
.detail-card {
border: 1px solid #ebeef5;
border-radius: 6px;
margin-bottom: 14px;
overflow: hidden;
}
.detail-card-title {
background: #f7f9fb;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
color: #333;
border-bottom: 1px solid #ebeef5;
}
.detail-card-title i { margin-right: 5px; color: #409EFF; }
.detail-row { padding: 0 8px; }
.detail-item {
display: flex;
align-items: flex-start;
padding: 10px 8px;
font-size: 13px;
border-bottom: 1px solid #f5f7fa;
}
.detail-item:last-child { border-bottom: none; }
.detail-label {
flex-shrink: 0;
width: 72px;
color: #909399;
margin-right: 12px;
}
.detail-value { color: #303133; flex: 1; word-break: break-all; }
.detail-location { color: #999; font-size: 12px; }
.method-tag {
display: inline-block;
padding: 1px 7px;
border-radius: 3px;
font-size: 11px;
font-weight: 700;
margin-right: 6px;
vertical-align: middle;
}
.mono { font-family: Consolas, 'SFMono-Regular', monospace; font-size: 12px; }
.code-body { padding: 14px; }
.code-wrap {
background: #f7f9fb;
border: 1px solid #e8ecf0;
border-radius: 4px;
overflow: hidden;
max-height: 260px;
position: relative;
}
.code-action {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
margin: 0;
padding: 0;
}
.code-action .el-button {
height: 24px;
font-size: 12px;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #dcdcdc;
}
.code-action .el-button:hover {
background: #ffffff;
border-color: #409EFF;
}
.code-pre {
margin: 0;
padding: 12px 14px;
font-size: 12px;
line-height: 1.6;
font-family: Consolas, 'SFMono-Regular', monospace;
color: #444;
white-space: pre-wrap;
word-break: break-all;
overflow: auto;
max-height: 240px;
display: block;
}
/* error */
.error-title { color: #c0392b !important; }
.error-title i { color: #c0392b !important; }
.error-body { padding: 12px 16px; }
.error-msg {
background: #fff8f8;
border-left: 3px solid #e74c3c;
border-radius: 3px;
padding: 8px 12px;
color: #c0392b;
font-size: 12px;
line-height: 1.7;
word-break: break-all;
white-space: pre-wrap;
}
/* http method */
.method-GET { background: #e8f5e9; color: #27ae60; }
.method-POST { background: #e3f2fd; color: #1565c0; }
.method-PUT { background: #fff3e0; color: #e65100; }
.method-DELETE { background: #ffebee; color: #c62828; }
/* text color */
.text-navy {
color: #1ab394;

View File

@@ -74,6 +74,8 @@
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
height: 44px !important;
line-height: 44px !important;
}
// menu hover
@@ -105,6 +107,96 @@
background-color: $base-sub-menu-hover !important;
}
}
// theme-dark 深色主题
&.theme-dark {
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.4);
border-right: none;
.el-menu-item.is-active {
position: relative;
&::before {
content: '';
position: absolute;
inset: 0;
background-color: var(--current-color-dark-bg, rgba(64, 158, 255, 0.2));
border-right: 3px solid var(--current-color, #409eff);
pointer-events: none;
z-index: 1;
}
}
.el-submenu.is-active > .el-submenu__title {
color: var(--current-color, #409eff) !important;
}
.el-menu-item:not(.is-active),
.submenu-title-noDropdown,
.el-submenu__title {
position: relative;
&::before {
content: '';
position: absolute;
inset: 0;
background-color: transparent;
pointer-events: none;
z-index: 1;
transition: background-color 0.2s;
}
&:hover::before {
background-color: var(--current-color-dark-bg, rgba(64, 158, 255, 0.2));
}
}
}
// theme-light 浅色主题
&.theme-light {
border-right: 1px solid #e8eaf0;
box-shadow: none;
.el-menu-item,
.el-submenu__title {
color: rgba(0, 0, 0, 0.65);
}
.el-menu-item.is-active {
color: var(--current-color, #409eff) !important;
position: relative;
&::before {
content: '';
position: absolute;
inset: 0;
background-color: var(--current-color-light, #ecf5ff);
border-right: 3px solid var(--current-color, #409eff);
pointer-events: none;
z-index: 1;
}
}
.el-submenu.is-active > .el-submenu__title {
color: var(--current-color, #409eff) !important;
}
.el-menu-item:not(.is-active):hover,
.submenu-title-noDropdown:hover,
.el-submenu__title:hover {
background-color: #f5f7fa !important;
color: rgba(0, 0, 0, 0.85) !important;
}
.nest-menu .el-submenu > .el-submenu__title,
.el-submenu .el-menu-item {
background-color: #fafafa !important;
&:hover {
background-color: #f0f5ff !important;
}
}
}
}
.hideSidebar {

View File

@@ -9,32 +9,17 @@ $yellow:#FEC171;
$panGreen: #30B08F;
// 默认菜单主题风格
$base-menu-color:#bfcbd9;
$base-menu-color-active:#f4f4f5;
$base-menu-background:#304156;
$base-menu-color: #bfcbd9;
$base-menu-color-active: #ffffff;
$base-menu-background: #1a1f2e;
$base-logo-title-color: #ffffff;
$base-menu-light-color:rgba(0,0,0,.70);
$base-menu-light-background:#ffffff;
$base-logo-light-title-color: #001529;
$base-sub-menu-background:#1f2d3d;
$base-sub-menu-hover:#001528;
// 自定义暗色菜单风格
/**
$base-menu-color:hsla(0,0%,100%,.65);
$base-menu-color-active:#fff;
$base-menu-background:#001529;
$base-logo-title-color: #ffffff;
$base-menu-light-color:rgba(0,0,0,.70);
$base-menu-light-background:#ffffff;
$base-logo-light-title-color: #001529;
$base-sub-menu-background:#000c17;
$base-sub-menu-hover:#001528;
*/
$base-sub-menu-background: #141824;
$base-sub-menu-hover: rgba(255,255,255,.06);
$base-sidebar-width: 200px;

View File

@@ -5,6 +5,7 @@
:visible.sync="show"
width="600px"
@close="close"
@opened="onDialogOpened"
:show-close="false"
append-to-body
>
@@ -21,24 +22,54 @@
@keydown.down.native="navigateResult('down')"
>
</el-input>
<div class="result-count" v-if="search && options.length > 0">
找到 <strong>{{ options.length }}</strong> 个结果
</div>
<el-scrollbar wrap-class="right-scrollbar-wrapper">
<div class="result-wrap">
<div class="search-item" v-for="(item, index) in options" :key="item.path" :style="activeStyle(index)" @mouseenter="activeIndex = index" @mouseleave="activeIndex = -1">
<div class="left">
<svg-icon class="menu-icon" :icon-class="item.icon" />
</div>
<div class="search-info" @click="change(item)">
<div class="menu-title">
{{ item.title.join(" / ") }}
<template v-if="options.length > 0">
<div
class="search-item"
v-for="(item, index) in options"
:key="item.path"
:class="{ 'is-active': index === activeIndex }"
:style="activeStyle(index)"
@mouseenter="activeIndex = index"
@mouseleave="activeIndex = -1"
>
<div class="left">
<svg-icon class="menu-icon" :icon-class="item.icon" />
</div>
<div class="menu-path">
{{ item.path }}
<div class="search-info" @click="change(item)">
<div class="menu-title" v-html="highlightText(item.title.join(' / '))"></div>
<div class="menu-path" v-html="highlightText(item.path)"></div>
</div>
<svg-icon icon-class="enter" v-show="index === activeIndex" />
</div>
<svg-icon icon-class="enter" v-show="index === activeIndex"/>
</template>
<div class="empty-state" v-else-if="search && options.length === 0">
<i class="el-icon-search empty-icon"></i>
<p class="empty-text">未找到 "<strong>{{ search }}</strong>" 相关菜单</p>
<p class="empty-tip">试试其他关键词或路径</p>
</div>
</div>
</div>
</el-scrollbar>
<div class="search-footer">
<span class="shortcut-item">
<kbd></kbd><kbd></kbd> 切换
</span>
<span class="shortcut-item">
<kbd></kbd> 选择
</span>
<span class="shortcut-item">
<kbd>Esc</kbd> 关闭
</span>
</div>
</el-dialog>
</div>
</template>
@@ -83,33 +114,37 @@ export default {
click() {
this.show = !this.show
if (this.show) {
this.$refs.headerSearchSelect && this.$refs.headerSearchSelect.focus()
this.options = this.searchPool
}
},
onDialogOpened() {
this.$nextTick(() => {
this.$refs.headerSearchSelectRef && this.$refs.headerSearchSelectRef.focus()
})
},
close() {
this.$refs.headerSearchSelect && this.$refs.headerSearchSelect.blur()
this.$refs.headerSearchSelectRef && this.$refs.headerSearchSelectRef.blur()
this.search = ''
this.options = []
this.options = this.searchPool
this.show = false
this.activeIndex = -1
},
change(val) {
const path = val.path
const p = val.path
const query = val.query
if(isHttp(val.path)) {
if (isHttp(val.path)) {
// http(s):// 路径新窗口打开
const pindex = path.indexOf("http")
window.open(path.substr(pindex, path.length), "_blank")
const pindex = p.indexOf('http')
window.open(p.substr(pindex, p.length), '_blank')
} else {
if (query) {
this.$router.push({ path: path, query: JSON.parse(query) })
this.$router.push({ path: p, query: JSON.parse(query) })
} else {
this.$router.push(path)
this.$router.push(p)
}
}
this.search = ''
this.options = []
this.options = this.searchPool
this.$nextTick(() => {
this.show = false
})
@@ -117,7 +152,7 @@ export default {
initFuse(list) {
this.fuse = new Fuse(list, {
shouldSort: true,
threshold: 0.4,
threshold: 0.2,
minMatchCharLength: 1,
keys: [{
name: 'title',
@@ -128,37 +163,25 @@ export default {
}]
})
},
// Filter out the routes that can be displayed in the sidebar
// And generate the internationalized title
generateRoutes(routes, basePath = '/', prefixTitle = []) {
let res = []
for (const router of routes) {
// skip hidden router
if (router.hidden) { continue }
const data = {
path: !isHttp(router.path) ? path.resolve(basePath, router.path) : router.path,
title: [...prefixTitle],
icon: ''
}
if (router.meta && router.meta.title) {
data.title = [...data.title, router.meta.title]
data.icon = router.meta.icon
if (router.redirect !== 'noRedirect') {
// only push the routes with title
// special case: need to exclude parent router without redirect
res.push(data)
}
}
if (router.query) {
data.query = router.query
}
// recursive child routes
if (router.children) {
const tempRoutes = this.generateRoutes(router.children, data.path, data.title)
if (tempRoutes.length >= 1) {
@@ -171,7 +194,18 @@ export default {
querySearch(query) {
this.activeIndex = -1
if (query !== '') {
this.options = this.fuse.search(query).map((item) => item.item) ?? this.searchPool
const q = query.toLowerCase()
const pathMatches = this.searchPool.filter(item =>
item.path.toLowerCase().includes(q)
)
const fuseMatches = this.fuse.search(query).map(item => item.item)
const merged = [...pathMatches]
fuseMatches.forEach(item => {
if (!merged.find(m => m.path === item.path)) {
merged.push(item)
}
})
this.options = merged
} else {
this.options = this.searchPool
}
@@ -179,14 +213,14 @@ export default {
activeStyle(index) {
if (index !== this.activeIndex) return {}
return {
"background-color": this.theme,
"color": "#fff"
'background-color': this.theme,
'color': '#fff'
}
},
navigateResult(direction) {
if (direction === "up") {
if (direction === 'up') {
this.activeIndex = this.activeIndex <= 0 ? this.options.length - 1 : this.activeIndex - 1
} else if (direction === "down") {
} else if (direction === 'down') {
this.activeIndex = this.activeIndex >= this.options.length - 1 ? 0 : this.activeIndex + 1
}
},
@@ -194,6 +228,16 @@ export default {
if (this.options.length > 0 && this.activeIndex >= 0) {
this.change(this.options[this.activeIndex])
}
},
highlightText(text) {
if (!text) return ''
if (!this.search) return text
const keyword = this.escapeRegExp(this.search)
const reg = new RegExp(`(${keyword})`, 'gi')
return text.replace(reg, '<span class="highlight">$1</span>')
},
escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
}
}
@@ -202,7 +246,17 @@ export default {
<style lang='scss' scoped>
::v-deep {
.el-dialog__header {
padding: 0 !important;
padding: 6px !important;
}
.highlight {
color: red;
font-weight: 600;
}
.is-active .highlight {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
}
@@ -214,19 +268,33 @@ export default {
}
}
.result-count {
padding: 6px 16px 0;
font-size: 12px;
color: #aaa;
strong {
color: red;
font-weight: 600;
}
}
.result-wrap {
height: 280px;
margin: 6px 0;
margin: 4px 0;
.search-item {
display: flex;
height: 48px;
align-items: center;
padding-right: 10px;
border-radius: 4px;
transition: background 0.15s;
.left {
width: 60px;
text-align: center;
flex-shrink: 0;
.menu-icon {
width: 18px;
@@ -242,11 +310,16 @@ export default {
flex-direction: column;
justify-content: flex-start;
flex: 1;
overflow: hidden;
.menu-title,
.menu-path {
height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-path {
color: #ccc;
font-size: 10px;
@@ -257,6 +330,68 @@ export default {
.search-item:hover {
cursor: pointer;
}
}
</style>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
.empty-icon {
font-size: 42px;
color: #e0e0e0;
margin-bottom: 14px;
}
.empty-text {
font-size: 14px;
color: #999;
margin: 0 0 6px;
strong {
color: #666;
}
}
.empty-tip {
font-size: 12px;
color: #bbb;
margin: 0;
}
}
}
.search-footer {
display: flex;
align-items: center;
gap: 28px;
padding: 10px 20px;
border-top: 1px solid #f0f0f0;
color: #999;
font-size: 12px;
.shortcut-item {
display: flex;
align-items: center;
gap: 5px;
}
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border: 1px solid #ddd;
border-radius: 4px;
background: #f7f7f7;
color: #555;
font-size: 11px;
font-family: inherit;
line-height: 1;
box-shadow: 0 1px 0 #ccc;
}
}
</style>

View File

@@ -123,7 +123,34 @@ export default {
methods: {
// 搜索
toggleSearch() {
this.$emit("update:showSearch", !this.showSearch)
let el = this.$el
let formEl = null
while ((el = el.parentElement) && el !== document.body) {
if ((formEl = el.querySelector('.el-form'))) break
}
if (!formEl) return this.$emit('update:showSearch', !this.showSearch)
this._animateSearch(formEl, this.showSearch)
},
// 搜索栏动画
_animateSearch(el, isHide) {
const DURATION = 260
const TRANSITION = 'max-height 0.25s ease, opacity 0.2s ease'
const clear = () => Object.assign(el.style, { transition: '', maxHeight: '', opacity: '', overflow: '' })
Object.assign(el.style, { overflow: 'hidden', transition: '' })
if (isHide) {
Object.assign(el.style, { maxHeight: el.scrollHeight + 'px', opacity: '1', transition: TRANSITION })
requestAnimationFrame(() => Object.assign(el.style, { maxHeight: '0', opacity: '0' }))
setTimeout(() => { this.$emit('update:showSearch', false); clear() }, DURATION)
} else {
this.$emit('update:showSearch', true)
this.$nextTick(() => {
Object.assign(el.style, { maxHeight: '0', opacity: '0' })
requestAnimationFrame(() => requestAnimationFrame(() => {
Object.assign(el.style, { transition: TRANSITION, maxHeight: el.scrollHeight + 'px', opacity: '1' })
}))
setTimeout(clear, DURATION)
})
}
},
// 刷新
refresh() {

View File

@@ -51,6 +51,14 @@ export default {
width: 100%;
position: relative;
overflow: hidden;
&:fullscreen,
&:-webkit-full-screen,
&:-moz-full-screen,
&:-ms-fullscreen {
background: #fff;
overflow-y: auto;
}
}
.fixed-header + .app-main {
@@ -129,4 +137,4 @@ export default {
background-color: #c0c0c0;
border-radius: 3px;
}
</style>
</style>

View File

@@ -0,0 +1,229 @@
<template>
<div>
<el-popover ref="noticePopover" placement="bottom-end" width="320" trigger="manual" :value="noticeVisible" popper-class="notice-popover">
<div class="notice-header">
<span class="notice-title">通知公告</span>
<span class="notice-mark-all" @click="markAllRead">全部已读</span>
</div>
<div v-if="noticeLoading" class="notice-loading"><i class="el-icon-loading"></i> 加载中...</div>
<div v-else-if="noticeList.length === 0" class="notice-empty"><i class="el-icon-inbox"></i><br>暂无公告</div>
<div v-else>
<div v-for="item in noticeList" :key="item.noticeId" class="notice-item" :class="{ 'is-read': item.isRead }" @click="previewNotice(item)">
<el-tag size="mini" :type="item.noticeType === '1' ? 'warning' : 'success'" class="notice-tag">
{{ item.noticeType === '1' ? '通知' : '公告' }}
</el-tag>
<span class="notice-item-title">{{ item.noticeTitle }}</span>
<span class="notice-item-date">{{ item.createTime }}</span>
</div>
</div>
</el-popover>
<div v-popover:noticePopover class="right-menu-item hover-effect notice-trigger" @mouseenter="onNoticeEnter" @mouseleave="onNoticeLeave">
<svg-icon icon-class="bell" />
<span v-if="unreadCount > 0" class="notice-badge">{{ unreadCount }}</span>
</div>
<el-dialog :title="previewTitle" :visible.sync="previewVisible" width="680px" append-to-body custom-class="notice-preview-dialog">
<div class="notice-preview-meta">
<el-tag size="small" :type="previewNoticeType === '1' ? 'warning' : 'success'">
{{ previewNoticeType === '1' ? '通知' : '公告' }}
</el-tag>
<span class="notice-preview-info"><i class="el-icon-user"></i> {{ previewCreateBy }}</span>
<span class="notice-preview-info"><i class="el-icon-time"></i> {{ previewCreateTime }}</span>
</div>
<div class="notice-preview-divider"></div>
<div class="notice-preview-content" v-html="previewContent"></div>
</el-dialog>
</div>
</template>
<script>
import { listNoticeTop, markNoticeRead, markNoticeReadAll, getNotice } from '@/api/system/notice'
export default {
name: 'HeaderNotice',
data() {
return {
noticeList: [], // 通知列表
unreadCount: 0, // 未读数量
noticeLoading: false, // 加载状态
noticeVisible: false, // 弹出层显示状态
noticeLeaveTimer: null, // 鼠标离开计时器
previewVisible: false, // 预览弹窗显示状态
previewTitle: '', // 预览弹窗标题
previewContent: '', // 预览弹窗内容
previewNoticeType: '', // 预览弹窗类型
previewCreateBy: '', // 预览弹窗创建人
previewCreateTime: '' // 预览弹窗创建时间
}
},
mounted() {
this.loadNoticeTop()
},
methods: {
// 鼠标移入铃铛区域
onNoticeEnter() {
clearTimeout(this.noticeLeaveTimer)
this.noticeVisible = true
this.$nextTick(() => {
const popper = this.$refs.noticePopover.$refs.popper
if (popper && !popper._noticeBound) {
popper._noticeBound = true
popper.addEventListener('mouseenter', () => clearTimeout(this.noticeLeaveTimer))
popper.addEventListener('mouseleave', () => {
this.noticeLeaveTimer = setTimeout(() => { this.noticeVisible = false }, 100)
})
}
})
},
// 鼠标离开铃铛区域
onNoticeLeave() {
this.noticeLeaveTimer = setTimeout(() => { this.noticeVisible = false }, 150)
},
// 加载顶部公告列表
loadNoticeTop() {
this.noticeLoading = true
listNoticeTop().then(res => {
this.noticeList = res.data || []
this.unreadCount = res.unreadCount !== undefined ? res.unreadCount : this.noticeList.filter(n => !n.isRead).length
}).finally(() => {
this.noticeLoading = false
})
},
// 预览公告详情
previewNotice(item) {
if (!item.isRead) {
markNoticeRead(item.noticeId).catch(() => {})
item.isRead = true
const idx = this.noticeList.indexOf(item)
if (idx !== -1) this.$set(this.noticeList, idx, { ...item, isRead: true })
this.unreadCount = Math.max(0, this.unreadCount - 1)
}
getNotice(item.noticeId).then(res => {
const notice = res.data
this.previewTitle = notice.noticeTitle
this.previewContent = notice.noticeContent
this.previewNoticeType = notice.noticeType
this.previewCreateBy = notice.createBy
this.previewCreateTime = notice.createTime
this.previewVisible = true
})
},
// 全部已读
markAllRead() {
const ids = this.noticeList.map(n => n.noticeId).join(',')
if (!ids) return
markNoticeReadAll(ids).catch(() => {})
this.noticeList = this.noticeList.map(n => ({ ...n, isRead: true }))
this.unreadCount = 0
}
}
}
</script>
<style lang="scss" scoped>
.notice-trigger {
position: relative;
transform: translateX(-6px);
.svg-icon { width: 1.2em; height: 1.2em; vertical-align: -0.2em; }
.notice-badge {
position: absolute;
top: 7px;
right: -3px;
background: #f56c6c;
color: #fff;
border-radius: 10px;
font-size: 10px;
height: 16px;
line-height: 16px;
padding: 0 4px;
min-width: 16px;
text-align: center;
white-space: nowrap;
pointer-events: none;
}
}
.notice-popover {
padding: 0 !important;
}
.notice-popover .notice-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f7f9fb;
border-bottom: 1px solid #eee;
font-size: 13px;
font-weight: 600;
color: #333;
}
.notice-popover .notice-mark-all {
font-size: 12px;
color: #409EFF;
font-weight: normal;
cursor: pointer;
}
.notice-popover .notice-mark-all:hover { color: #2b7cc1; }
.notice-popover .notice-loading,
.notice-popover .notice-empty {
padding: 24px;
text-align: center;
color: #bbb;
font-size: 12px;
line-height: 1.8;
}
.notice-popover .notice-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.15s;
}
.notice-popover .notice-item:last-child { border-bottom: none; }
.notice-popover .notice-item:hover { background: #f7f9fb; }
.notice-popover .notice-item.is-read .notice-tag,
.notice-popover .notice-item.is-read .notice-item-title,
.notice-popover .notice-item.is-read .notice-item-date { opacity: 0.45; filter: grayscale(1); color: #999; }
.notice-popover .notice-tag { flex-shrink: 0; }
.notice-popover .notice-item-title {
flex: 1;
font-size: 12px;
color: #333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.notice-popover .notice-item-date {
flex-shrink: 0;
font-size: 11px;
color: #bbb;
}
::v-deep .notice-preview-dialog {
.el-dialog__body { padding: 0 20px 20px; }
.notice-preview-meta {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 0;
font-size: 12px;
color: #888;
.notice-preview-info { display: flex; align-items: center; gap: 4px; }
}
.notice-preview-divider {
height: 1px;
background: linear-gradient(to right, transparent, #e2e8f0, transparent);
margin-bottom: 16px;
}
.notice-preview-content {
font-size: 14px;
line-height: 1.85;
color: #2d3748;
word-break: break-word;
img { max-width: 100%; border-radius: 4px; }
p { margin: 0 0 1em; }
a { color: #409EFF; text-decoration: underline; }
}
}
</style>

View File

@@ -26,6 +26,10 @@
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip>
<el-tooltip content="消息通知" effect="dark" placement="bottom">
<header-notice id="header-notice" class="right-menu-item hover-effect" />
</el-tooltip>
</template>
<el-dropdown class="avatar-container right-menu-item hover-effect" trigger="hover">
@@ -40,6 +44,9 @@
<el-dropdown-item @click.native="setLayout" v-if="setting">
<span>布局设置</span>
</el-dropdown-item>
<el-dropdown-item @click.native="lockScreen">
<span>锁定屏幕</span>
</el-dropdown-item>
<el-dropdown-item divided @click.native="logout">
<span>退出登录</span>
</el-dropdown-item>
@@ -61,9 +68,9 @@ import SizeSelect from '@/components/SizeSelect'
import Search from '@/components/HeaderSearch'
import RuoYiGit from '@/components/RuoYi/Git'
import RuoYiDoc from '@/components/RuoYi/Doc'
import HeaderNotice from './HeaderNotice'
export default {
emits: ['setLayout'],
components: {
Breadcrumb,
Logo,
@@ -74,7 +81,8 @@ export default {
SizeSelect,
Search,
RuoYiGit,
RuoYiDoc
RuoYiDoc,
HeaderNotice
},
computed: {
...mapGetters([
@@ -106,6 +114,12 @@ export default {
setLayout(event) {
this.$emit('setLayout')
},
lockScreen() {
const currentPath = this.$route.fullPath
this.$store.dispatch('lock/lockScreen', currentPath).then(() => {
this.$router.push('/lock')
})
},
logout() {
this.$confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
@@ -173,11 +187,6 @@ export default {
margin-left: 8px;
}
.errLog-container {
display: inline-block;
vertical-align: top;
}
.right-menu {
height: 100%;
line-height: 50px;

View File

@@ -65,6 +65,11 @@
<el-switch v-model="tagsView" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>持久化标签页</span>
<el-switch v-model="tagsViewPersist" :disabled="!tagsView" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示页签图标</span>
<el-switch v-model="tagsIcon" :disabled="!tagsView" class="drawer-switch" />
@@ -125,6 +130,17 @@ export default {
})
}
},
tagsViewPersist: {
get() {
return this.$store.state.settings.tagsViewPersist
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'tagsViewPersist',
value: val
})
}
},
tagsView: {
get() {
return this.$store.state.settings.tagsView
@@ -231,12 +247,16 @@ export default {
},
saveSetting() {
this.$modal.loading("正在保存到本地,请稍候...")
if (!this.tagsViewPersist) {
this.$cache.local.remove('tags-view-visited')
}
this.$cache.local.set(
"layout-setting",
`{
"navType":${this.navType},
"tagsView":${this.tagsView},
"tagsIcon":${this.tagsIcon},
"tagsViewPersist":${this.tagsViewPersist},
"fixedHeader":${this.fixedHeader},
"sidebarLogo":${this.sidebarLogo},
"dynamicTitle":${this.dynamicTitle},
@@ -249,6 +269,7 @@ export default {
},
resetSetting() {
this.$modal.loading("正在清除设置缓存并刷新,请稍候...")
this.$cache.local.remove('tags-view-visited')
this.$cache.local.remove("layout-setting")
setTimeout("window.location.reload()", 1000)
}

View File

@@ -1,5 +1,5 @@
<template>
<div :class="{'has-logo':showLogo}" :style="{ backgroundColor: settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
<div :class="['sidebar-theme-wrapper', {'has-logo':showLogo}, settings.sideTheme]" :style="{ backgroundColor: settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
<logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar :class="settings.sideTheme" wrap-class="scrollbar-wrapper">
<el-menu

View File

@@ -5,7 +5,7 @@
</template>
<script>
const tagAndTagSpacing = 4 // tagAndTagSpacing
const tagAndTagSpacing = 4
export default {
name: 'ScrollPane',
@@ -26,13 +26,44 @@ export default {
this.scrollWrapper.removeEventListener('scroll', this.emitScroll)
},
methods: {
smoothScrollTo(target) {
const $scrollWrapper = this.scrollWrapper
const start = $scrollWrapper.scrollLeft
const distance = target - start
const duration = 300
let startTime = null
function ease(t, b, c, d) {
t /= d / 2
if (t < 1) return c / 2 * t * t + b
t--
return -c / 2 * (t * (t - 2) - 1) + b
}
const emit = this.$emit.bind(this)
function step(timestamp) {
if (!startTime) startTime = timestamp
const elapsed = timestamp - startTime
$scrollWrapper.scrollLeft = ease(elapsed, start, distance, duration)
if (elapsed < duration) {
requestAnimationFrame(step)
} else {
$scrollWrapper.scrollLeft = target
emit('updateArrows')
}
}
requestAnimationFrame(step)
},
handleScroll(e) {
const eventDelta = e.wheelDelta || -e.deltaY * 40
const $scrollWrapper = this.scrollWrapper
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
this.$emit('updateArrows')
},
emitScroll() {
this.$emit('scroll')
this.$emit('updateArrows')
},
moveToTarget(currentTag) {
const $container = this.$refs.scrollContainer.$el
@@ -43,34 +74,56 @@ export default {
let firstTag = null
let lastTag = null
// find first tag and last tag
if (tagList.length > 0) {
firstTag = tagList[0]
lastTag = tagList[tagList.length - 1]
}
if (firstTag === currentTag) {
$scrollWrapper.scrollLeft = 0
this.smoothScrollTo(0)
} else if (lastTag === currentTag) {
$scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
this.smoothScrollTo($scrollWrapper.scrollWidth - $containerWidth)
} else {
// find preTag and nextTag
const currentIndex = tagList.findIndex(item => item === currentTag)
const prevTag = tagList[currentIndex - 1]
const nextTag = tagList[currentIndex + 1]
// the tag's offsetLeft after of nextTag
const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
// the tag's offsetLeft before of prevTag
const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
$scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
this.smoothScrollTo(afterNextTagOffsetLeft - $containerWidth)
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
$scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
this.smoothScrollTo(beforePrevTagOffsetLeft)
}
}
},
// 向左滚动固定距离
scrollLeft() {
const $scrollWrapper = this.scrollWrapper
this.smoothScrollTo(Math.max(0, $scrollWrapper.scrollLeft - 200))
},
// 向右滚动固定距离
scrollRight() {
const $scrollWrapper = this.scrollWrapper
const maxScroll = $scrollWrapper.scrollWidth - $scrollWrapper.clientWidth
this.smoothScrollTo(Math.min(maxScroll, $scrollWrapper.scrollLeft + 200))
},
// 直接平滑滚动到最左端
scrollToStart() {
this.smoothScrollTo(0)
},
// 直接平滑滚动到最右端
scrollToEnd() {
const $scrollWrapper = this.scrollWrapper
this.smoothScrollTo($scrollWrapper.scrollWidth - $scrollWrapper.clientWidth)
},
// 获取是否可以继续向左/右滚动
getScrollState() {
const $scrollWrapper = this.scrollWrapper
return {
canLeft: $scrollWrapper.scrollLeft > 0,
canRight: $scrollWrapper.scrollLeft < $scrollWrapper.scrollWidth - $scrollWrapper.clientWidth - 1
}
}
}
}
@@ -87,7 +140,9 @@ export default {
bottom: 0px;
}
.el-scrollbar__wrap {
height: 49px;
height: 34px;
display: flex;
align-items: center;
}
}
}

View File

@@ -1,6 +1,12 @@
<template>
<div id="tags-view-container" class="tags-view-container">
<scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll">
<!-- 左切换箭头 -->
<span class="tags-nav-btn tags-nav-btn--left" :class="{ disabled: !canScrollLeft }" @click="scrollLeft">
<i class="el-icon-arrow-left" />
</span>
<!-- 标签滚动区 -->
<scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll" @updateArrows="updateArrowState">
<router-link
v-for="tag in visitedViews"
ref="tag"
@@ -10,15 +16,45 @@
tag="span"
class="tags-view-item"
:style="activeStyle(tag)"
@click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
@contextmenu.prevent.native="openMenu(tag,$event)"
@click.middle.native="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent.native="openMenu(tag, $event)"
>
<svg-icon v-if="tagsIcon && tag.meta && tag.meta.icon && tag.meta.icon !== '#'" :icon-class="tag.meta.icon" />
{{ tag.title }}
<span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
</router-link>
</scroll-pane>
<ul v-show="visible" :style="{left:left+'px',top:top+'px'}" class="contextmenu">
<!-- 右切换箭头 -->
<span class="tags-nav-btn tags-nav-btn--right" :class="{ disabled: !canScrollRight }" @click="scrollRight">
<i class="el-icon-arrow-right" />
</span>
<!-- 下拉操作菜单 -->
<el-dropdown class="tags-action-dropdown" trigger="click" placement="bottom-end" @command="handleDropdownCommand">
<span class="tags-action-btn">
<i class="el-icon-arrow-down" />
</span>
<el-dropdown-menu slot="dropdown" class="tags-dropdown-menu">
<el-dropdown-item v-if="!isAffix(selectedDropdownTag)" command="close" icon="el-icon-close">关闭当前</el-dropdown-item>
<el-dropdown-item command="closeOthers" icon="el-icon-circle-close">关闭其他</el-dropdown-item>
<el-dropdown-item command="closeLeft" :disabled="isFirstView()" icon="el-icon-back">关闭左侧</el-dropdown-item>
<el-dropdown-item command="closeRight" :disabled="isLastView()" icon="el-icon-right">关闭右侧</el-dropdown-item>
<el-dropdown-item command="closeAll" icon="el-icon-circle-close">全部关闭</el-dropdown-item>
<el-dropdown-item command="fullscreen" divided>
<template v-if="!isFullscreen"><i class="el-icon-full-screen"></i>全屏显示</template>
<template v-else><i class="el-icon-close"></i>退出全屏</template>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<!-- 刷新按钮 -->
<span class="tags-action-btn tags-refresh-btn" title="刷新页面" @click="refreshSelectedTag(selectedDropdownTag)">
<i class="el-icon-refresh-right" /> 刷新
</span>
<!-- 右键上下文菜单 -->
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)"><i class="el-icon-refresh-right"></i> 刷新页面</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)"><i class="el-icon-close"></i> 关闭当前</li>
<li @click="closeOthersTags"><i class="el-icon-circle-close"></i> 关闭其他</li>
@@ -41,7 +77,11 @@ export default {
top: 0,
left: 0,
selectedTag: {},
affixTags: []
affixTags: [],
canScrollLeft: false,
canScrollRight: false,
isFullscreen: false,
hiddenElements: []
}
},
computed: {
@@ -56,6 +96,9 @@ export default {
},
tagsIcon() {
return this.$store.state.settings.tagsIcon
},
selectedDropdownTag() {
return this.visitedViews.find(v => this.isActive(v)) || {}
}
},
watch: {
@@ -69,13 +112,30 @@ export default {
} else {
document.body.removeEventListener('click', this.closeMenu)
}
},
visitedViews() {
this.$nextTick(() => {
this.updateArrowState()
})
}
},
mounted() {
this.initTags()
this.addTags()
window.addEventListener('resize', this.updateArrowState)
window.addEventListener('keydown', this.handleKeyDown)
},
beforeDestroy() {
window.removeEventListener('resize', this.updateArrowState)
window.removeEventListener('keydown', this.handleKeyDown)
},
methods: {
handleKeyDown(event) {
// 当按下Esc键且处于全屏状态时退出全屏
if (event.key === 'Escape' && this.isFullscreen) {
this.toggleFullscreen()
}
},
isActive(route) {
return route.path === this.$route.path
},
@@ -87,18 +147,20 @@ export default {
}
},
isAffix(tag) {
return tag.meta && tag.meta.affix
return tag && tag.meta && tag.meta.affix
},
isFirstView() {
try {
return this.selectedTag.fullPath === '/index' || this.selectedTag.fullPath === this.visitedViews[1].fullPath
const tag = this.selectedTag && this.selectedTag.fullPath ? this.selectedTag : this.selectedDropdownTag
return tag.fullPath === '/index' || tag.fullPath === this.visitedViews[1].fullPath
} catch (err) {
return false
}
},
isLastView() {
try {
return this.selectedTag.fullPath === this.visitedViews[this.visitedViews.length - 1].fullPath
const tag = this.selectedTag && this.selectedTag.fullPath ? this.selectedTag : this.selectedDropdownTag
return tag.fullPath === this.visitedViews[this.visitedViews.length - 1].fullPath
} catch (err) {
return false
}
@@ -125,11 +187,13 @@ export default {
return tags
},
initTags() {
if (this.$store.state.settings.tagsViewPersist) {
this.$store.dispatch('tagsView/loadPersistedViews')
}
const affixTags = this.affixTags = this.filterAffixTags(this.routes)
for (const tag of affixTags) {
// Must have tag name
if (tag.name) {
this.$store.dispatch('tagsView/addVisitedView', tag)
this.$store.dispatch('tagsView/addAffixView', tag)
}
}
},
@@ -145,7 +209,6 @@ export default {
for (const tag of tags) {
if (tag.to.path === this.$route.path) {
this.$refs.scrollPane.moveToTarget(tag)
// when query is different then update
if (tag.to.fullPath !== this.$route.fullPath) {
this.$store.dispatch('tagsView/updateVisitedView', this.$route)
}
@@ -154,6 +217,85 @@ export default {
}
})
},
scrollLeft() {
if (!this.canScrollLeft) return
this.$refs.scrollPane.scrollToStart()
},
scrollRight() {
if (!this.canScrollRight) return
this.$refs.scrollPane.scrollToEnd()
},
updateArrowState() {
this.$nextTick(() => {
if (this.$refs.scrollPane) {
const state = this.$refs.scrollPane.getScrollState()
this.canScrollLeft = state.canLeft
this.canScrollRight = state.canRight
}
})
},
toggleFullscreen() {
const mainContainer = document.querySelector('.main-container')
const navbar = document.querySelector('.navbar')
const sidebar = document.querySelector('.sidebar-container')
if (!mainContainer) return
if (!this.isFullscreen) {
mainContainer.classList.add('fullscreen-mode')
document.body.style.overflow = 'hidden'
const elementsToHide = [
{ el: navbar, originalDisplay: (navbar && navbar.style.display) || '' },
{ el: sidebar, originalDisplay: (sidebar && sidebar.style.display) || '' }
]
elementsToHide.forEach(item => {
if (item.el && item.el.style.display !== 'none') {
item.originalDisplay = item.el.style.display
item.el.style.display = 'none'
this.hiddenElements.push(item)
}
})
this.isFullscreen = true
} else {
mainContainer.classList.remove('fullscreen-mode')
document.body.style.overflow = ''
this.hiddenElements.forEach(item => {
if (item.el) {
item.el.style.display = item.originalDisplay
}
})
this.hiddenElements = []
const btn = document.querySelector('.tags-action-btn')
if (btn) btn.blur()
this.isFullscreen = false
}
},
handleDropdownCommand(command) {
const tag = this.selectedDropdownTag
this.selectedTag = tag
switch (command) {
case 'refresh':
this.refreshSelectedTag(tag)
break
case 'fullscreen':
this.toggleFullscreen()
break
case 'close':
this.closeSelectedTag(tag)
break
case 'closeOthers':
this.closeOthersTags()
break
case 'closeLeft':
this.closeLeftTags()
break
case 'closeRight':
this.closeRightTags()
break
case 'closeAll':
this.closeAllTags(tag)
break
}
},
refreshSelectedTag(view) {
this.$tab.refreshPage(view)
if (this.$route.meta.link) {
@@ -182,7 +324,7 @@ export default {
})
},
closeOthersTags() {
this.$router.push(this.selectedTag.fullPath).catch(()=>{})
this.$router.push(this.selectedTag.fullPath).catch(() => {})
this.$tab.closeOtherPage(this.selectedTag).then(() => {
this.moveToCurrentTag()
})
@@ -200,10 +342,7 @@ export default {
if (latestView) {
this.$router.push(latestView.fullPath)
} else {
// now the default is to redirect to the home page if there is no tags-view,
// you can adjust it according to your needs.
if (view.name === 'Dashboard') {
// to reload home page
if (view && view.name === 'Dashboard') {
this.$router.replace({ path: '/redirect' + view.fullPath })
} else {
this.$router.push('/')
@@ -211,18 +350,7 @@ export default {
}
},
openMenu(tag, e) {
const menuMinWidth = 105
const offsetLeft = this.$el.getBoundingClientRect().left // container margin left
const offsetWidth = this.$el.offsetWidth // container width
const maxLeft = offsetWidth - menuMinWidth // left boundary
const left = e.clientX - offsetLeft + 15 // 15: margin right
if (left > maxLeft) {
this.left = maxLeft
} else {
this.left = left
}
this.left = e.clientX
this.top = e.clientY
this.visible = true
this.selectedTag = tag
@@ -232,6 +360,7 @@ export default {
},
handleScroll() {
this.closeMenu()
this.updateArrowState()
}
}
}
@@ -243,8 +372,52 @@ export default {
width: 100%;
background: #fff;
border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
display: flex;
align-items: center;
$btn-width: 28px;
$btn-color: #71717a;
$btn-hover-bg: #f0f2f5;
$btn-hover-color: #303133;
$btn-disabled-color: #c0c4cc;
$divider: 1px solid #d8dce5;
.tags-nav-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: $btn-width;
height: 34px;
cursor: pointer;
color: $btn-color;
font-size: 13px;
user-select: none;
transition: background 0.15s, color 0.15s;
&:hover:not(.disabled) {
background: $btn-hover-bg;
color: $btn-hover-color;
}
&.disabled {
color: $btn-disabled-color;
cursor: not-allowed;
}
&--left {
border-right: $divider;
}
&--right {
border-left: $divider;
}
}
.tags-view-wrapper {
flex: 1;
min-width: 0;
.tags-view-item {
display: inline-block;
position: relative;
@@ -257,9 +430,10 @@ export default {
padding: 0 8px;
font-size: 12px;
margin-left: 5px;
margin-top: 4px;
border-radius: 3px;
&:first-of-type {
margin-left: 15px;
margin-left: 6px;
}
&:last-of-type {
margin-right: 15px;
@@ -286,11 +460,40 @@ export default {
content: none !important;
}
.tags-action-dropdown {
flex-shrink: 0;
display: flex;
align-items: center;
}
.tags-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: $btn-width;
height: 34px;
cursor: pointer;
color: $btn-color;
font-size: 13px;
border-left: $divider;
user-select: none;
transition: background 0.15s, color 0.15s;
&:hover {
background: $btn-hover-bg;
color: $btn-hover-color;
}
}
.tags-refresh-btn {
width: 60px;
}
.contextmenu {
margin: 0;
background: #fff;
z-index: 3000;
position: absolute;
position: fixed;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
@@ -298,6 +501,7 @@ export default {
font-weight: 400;
color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
li {
margin: 0;
padding: 7px 16px;
@@ -311,7 +515,6 @@ export default {
</style>
<style lang="scss">
//reset element css of el-icon-close
.tags-view-wrapper {
.tags-view-item {
.el-icon-close {
@@ -334,4 +537,41 @@ export default {
}
}
}
/* 页签全屏模式样式 */
.main-container.fullscreen-mode {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.main-container.fullscreen-mode .fixed-header {
display: block !important;
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100% !important;
z-index: 1000;
}
.main-container.fullscreen-mode .fixed-header .navbar {
display: none !important;
}
.main-container.fullscreen-mode .app-main {
position: fixed;
top: 34px;
left: 0;
right: 0;
bottom: 0;
margin: 0 !important;
padding: 0 !important;
height: calc(100vh - 34px) !important;
min-height: calc(100vh - 34px) !important;
overflow: auto;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div :class="classObj" class="app-wrapper" :style="{'--current-color': theme}">
<div :class="classObj" class="app-wrapper" :style="{'--current-color': theme, '--current-color-light': theme + '1a', '--current-color-dark-bg': theme + '33'}">
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
<sidebar v-if="!sidebar.hide" class="sidebar-container"/>
<div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide}" class="main-container">

View File

@@ -19,12 +19,19 @@ router.beforeEach((to, from, next) => {
NProgress.start()
if (getToken()) {
to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
const isLock = store.getters.isLock
/* has token*/
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
} else if (isWhiteList(to.path)) {
next()
} else if (isLock && to.path !== '/lock') {
next({ path: '/lock' })
NProgress.done()
} else if (!isLock && to.path === '/lock') {
next({ path: '/' })
NProgress.done()
} else {
if (store.getters.roles.length === 0) {
isRelogin.show = true

View File

@@ -5,6 +5,10 @@ export default {
// 刷新当前tab页签
refreshPage(obj) {
const { path, query, matched } = router.currentRoute
// 防止在重定向过程中重复刷新
if (path.startsWith('/redirect/')) {
return Promise.resolve()
}
if (obj === undefined) {
matched.forEach((m) => {
if (m.components && m.components.default && m.components.default.name) {

View File

@@ -74,6 +74,12 @@ export const constantRoutes = [
}
]
},
{
path: '/lock',
component: () => import('@/views/lock'),
hidden: true,
meta: { title: '锁定屏幕' }
},
{
path: '/user',
component: Layout,

View File

@@ -23,7 +23,12 @@ module.exports = {
* 是否显示 tagsView
*/
tagsView: true,
/**
* 持久化标签页
*/
tagsViewPersist: false,
/**
* 显示页签图标
*/

View File

@@ -3,6 +3,8 @@ const getters = {
size: state => state.app.size,
device: state => state.app.device,
dict: state => state.dict.dict,
isLock: state => state.lock.isLock,
lockPath: state => state.lock.lockPath,
visitedViews: state => state.tagsView.visitedViews,
cachedViews: state => state.tagsView.cachedViews,
token: state => state.user.token,

View File

@@ -1,6 +1,7 @@
import Vue from 'vue'
import Vuex from 'vuex'
import app from './modules/app'
import lock from './modules/lock'
import dict from './modules/dict'
import user from './modules/user'
import tagsView from './modules/tagsView'
@@ -13,6 +14,7 @@ Vue.use(Vuex)
const store = new Vuex.Store({
modules: {
app,
lock,
dict,
user,
tagsView,

View File

@@ -0,0 +1,34 @@
const LOCK_KEY = 'screen-lock'
const LOCK_PATH_KEY = 'screen-lock-path'
const lock = {
namespaced: true,
state: {
isLock: JSON.parse(localStorage.getItem(LOCK_KEY) || 'false'),
lockPath: localStorage.getItem(LOCK_PATH_KEY) || '/index'
},
mutations: {
SET_LOCK(state, status) {
state.isLock = status
localStorage.setItem(LOCK_KEY, JSON.stringify(status))
},
SET_LOCK_PATH(state, path) {
state.lockPath = path
localStorage.setItem(LOCK_PATH_KEY, path)
}
},
actions: {
// 锁定屏幕,同时记录当前路径
lockScreen({ commit }, currentPath) {
commit('SET_LOCK_PATH', currentPath || '/index')
commit('SET_LOCK', true)
},
// 解锁屏幕,清除路径
unlockScreen({ commit }) {
commit('SET_LOCK', false)
commit('SET_LOCK_PATH', '/index')
}
}
}
export default lock

View File

@@ -1,7 +1,7 @@
import defaultSettings from '@/settings'
import { useDynamicTitle } from '@/utils/dynamicTitle'
const { sideTheme, showSettings, navType, tagsView, tagsIcon, fixedHeader, sidebarLogo, dynamicTitle, footerVisible, footerContent } = defaultSettings
const { sideTheme, showSettings, navType, tagsView, tagsViewPersist, tagsIcon, fixedHeader, sidebarLogo, dynamicTitle, footerVisible, footerContent } = defaultSettings
const storageSetting = JSON.parse(localStorage.getItem('layout-setting')) || ''
const state = {
@@ -11,6 +11,7 @@ const state = {
showSettings: showSettings,
navType: storageSetting.navType === undefined ? navType : storageSetting.navType,
tagsView: storageSetting.tagsView === undefined ? tagsView : storageSetting.tagsView,
tagsViewPersist: storageSetting.tagsViewPersist === undefined ? tagsViewPersist : storageSetting.tagsViewPersist,
tagsIcon: storageSetting.tagsIcon === undefined ? tagsIcon : storageSetting.tagsIcon,
fixedHeader: storageSetting.fixedHeader === undefined ? fixedHeader : storageSetting.fixedHeader,
sidebarLogo: storageSetting.sidebarLogo === undefined ? sidebarLogo : storageSetting.sidebarLogo,

View File

@@ -1,3 +1,26 @@
import store from '@/store'
import cache from '@/plugins/cache'
const PERSIST_KEY = 'tags-view-visited'
function isPersistEnabled() {
return store.state.settings.tagsViewPersist
}
function saveVisitedViews(views) {
if (!isPersistEnabled()) return
const toSave = views.filter(v => !(v.meta && v.meta.affix)).map(v => ({ path: v.path, fullPath: v.fullPath, name: v.name, title: v.title, query: v.query, meta: v.meta }))
cache.local.setJSON(PERSIST_KEY, toSave)
}
function loadVisitedViews() {
return cache.local.getJSON(PERSIST_KEY) || []
}
function clearVisitedViews() {
cache.local.remove(PERSIST_KEY)
}
const state = {
visitedViews: [],
cachedViews: [],
@@ -20,6 +43,15 @@ const mutations = {
title: view.meta.title || 'no-name'
})
)
saveVisitedViews(state.visitedViews)
},
ADD_VISITED_VIEW_FIRST: (state, view) => {
if (state.visitedViews.some(v => v.path === view.path)) return
state.visitedViews.unshift(
Object.assign({}, view, {
title: view.meta.title || 'no-name'
})
)
},
ADD_CACHED_VIEW: (state, view) => {
if (state.cachedViews.includes(view.name)) return
@@ -35,6 +67,7 @@ const mutations = {
}
}
state.iframeViews = state.iframeViews.filter(item => item.path !== view.path)
saveVisitedViews(state.visitedViews)
},
DEL_IFRAME_VIEW: (state, view) => {
state.iframeViews = state.iframeViews.filter(item => item.path !== view.path)
@@ -49,6 +82,7 @@ const mutations = {
return v.meta.affix || v.path === view.path
})
state.iframeViews = state.iframeViews.filter(item => item.path === view.path)
saveVisitedViews(state.visitedViews)
},
DEL_OTHERS_CACHED_VIEWS: (state, view) => {
const index = state.cachedViews.indexOf(view.name)
@@ -63,6 +97,7 @@ const mutations = {
const affixTags = state.visitedViews.filter(tag => tag.meta.affix)
state.visitedViews = affixTags
state.iframeViews = []
clearVisitedViews()
},
DEL_ALL_CACHED_VIEWS: state => {
state.cachedViews = []
@@ -94,6 +129,7 @@ const mutations = {
}
return false
})
saveVisitedViews(state.visitedViews)
},
DEL_LEFT_VIEWS: (state, view) => {
const index = state.visitedViews.findIndex(v => v.path === view.path)
@@ -114,6 +150,7 @@ const mutations = {
}
return false
})
saveVisitedViews(state.visitedViews)
}
}
@@ -128,6 +165,9 @@ const actions = {
addVisitedView({ commit }, view) {
commit('ADD_VISITED_VIEW', view)
},
addAffixView({ commit }, view) {
commit('ADD_VISITED_VIEW_FIRST', view)
},
addCachedView({ commit }, view) {
commit('ADD_CACHED_VIEW', view)
},
@@ -218,6 +258,13 @@ const actions = {
resolve([...state.visitedViews])
})
},
// 恢复持久化的 tags
loadPersistedViews({ commit }) {
const views = loadVisitedViews()
views.forEach(view => {
commit('ADD_VISITED_VIEW', view)
})
},
}
export default {

View File

@@ -1,3 +1,4 @@
import store from '@/store'
import router from '@/router'
import { MessageBox, } from 'element-ui'
import { login, logout, getInfo, refreshToken } from '@/api/login'
@@ -57,6 +58,7 @@ const user = {
commit('SET_TOKEN', data.access_token)
setExpiresIn(data.expires_in)
commit('SET_EXPIRES_IN', data.expires_in)
store.dispatch('lock/unlockScreen')
resolve()
}).catch(error => {
reject(error)

View File

@@ -108,6 +108,41 @@
<span>更新日志</span>
</div>
<el-collapse accordion>
<el-collapse-item title="v3.6.8 - 2026-03-30">
<ol>
<li>新增锁定屏幕功能</li>
<li>首页新增通知公告消息提醒</li>
<li>添加持久化标签页开关功能</li>
<li>菜单搜索支持文本高亮&数量提示</li>
<li>添加菜单路由地址和名称的校验规则</li>
<li>字典类型列表新增抽屉效果详细信息</li>
<li>升级axios到最新版本0.30.3</li>
<li>升级spring-cloud到最新版2025.1.0</li>
<li>升级spring-cloud-alibaba到最新版2025.1.0.0</li>
<li>升级spring-boot-admin到最新版4.0.2</li>
<li>升级dynamic-ds到最新版本4.5.0</li>
<li>升级springdoc到最新版本3.0.2</li>
<li>升级transmittable-thread-local到最新版本2.14.5</li>
<li>升级spring-boot到最新版本4.0.3</li>
<li>升级druid到最新版本1.2.28</li>
<li>升级pagehelper到最新版2.1.1</li>
<li>优化操作日志详细页面</li>
<li>部门管理支持批量保存排序</li>
<li>菜单管理支持批量保存排序</li>
<li>菜单管理列表新增类型显示</li>
<li>代码生成模板支持TypeScript版本</li>
<li>优化菜单主题风格显示</li>
<li>优化点击任务名称查看详细</li>
<li>优化字典类型属性提醒说明</li>
<li>优化防重提交间隔时间可自定义</li>
<li>优化页签功能&支持全屏按钮操作</li>
<li>优化RightToolbar搜索栏切换动画</li>
<li>修复Excel自定义格式样式污染问题</li>
<li>优化isAdmin方法统一到SecurityUtils</li>
<li>优化定时任务详情页展示&补充执行时间字段</li>
<li>其他细节优化</li>
</ol>
</el-collapse-item>
<el-collapse-item title="v3.6.7 - 2025-12-22">
<ol>
<li>支持防盗链功能</li>
@@ -987,7 +1022,7 @@ export default {
data() {
return {
// 版本号
version: "3.6.7"
version: "3.6.8"
}
},
methods: {

375
ruoyi-ui/src/views/lock.vue Normal file
View File

@@ -0,0 +1,375 @@
<template>
<div class="lock-container">
<!-- 动态粒子背景 -->
<canvas ref="particleCanvas" class="particle-bg"></canvas>
<!-- 时钟 -->
<div class="lock-time">{{ currentTime }}</div>
<div class="lock-date">{{ currentDate }}</div>
<!-- 锁屏卡片 -->
<div class="lock-card">
<div class="avatar-wrap">
<img :src="avatar" class="lock-avatar" @error="onAvatarError" />
<div class="lock-icon">🔒</div>
</div>
<div class="lock-username">{{ nickName }}</div>
<div class="lock-hint">系统已锁定请输入密码解锁</div>
<div class="input-wrap" :class="{ shake: isShaking }">
<input ref="passwordInput" v-model="password" type="password" placeholder="请输入登录密码" class="lock-input" @keydown.enter="handleUnlock" autocomplete="off" />
<button class="unlock-btn" @click="handleUnlock" :disabled="loading">
<span v-if="!loading"></span>
<span v-else class="loading-dot">···</span>
</button>
</div>
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
<div class="lock-footer">
<a href="/login" @click.prevent="goLogin">退出重新登录</a>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { unlockScreen } from '@/api/login'
import defAva from '@/assets/images/profile.jpg'
export default {
name: 'LockScreen',
data() {
return {
password: '',
loading: false,
errorMsg: '',
isShaking: false,
currentTime: '',
currentDate: '',
timer: null,
animationId: null,
particles: []
}
},
computed: {
...mapGetters(['avatar', 'nickName'])
},
mounted() {
this.startClock()
this.initParticles()
this.$nextTick(() => {
this.$refs.passwordInput && this.$refs.passwordInput.focus()
})
},
beforeDestroy() {
clearInterval(this.timer)
cancelAnimationFrame(this.animationId)
},
methods: {
onAvatarError(e) {
e.target.src = defAva
},
startClock() {
const update = () => {
const now = new Date()
const h = String(now.getHours()).padStart(2, '0')
const m = String(now.getMinutes()).padStart(2, '0')
const s = String(now.getSeconds()).padStart(2, '0')
this.currentTime = `${h}:${m}:${s}`
const days = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六']
this.currentDate = `${now.getFullYear()}${now.getMonth()+1}${now.getDate()}${days[now.getDay()]}`
}
update()
this.timer = setInterval(update, 1000)
},
async handleUnlock() {
if (!this.password) {
this.showError('请输入密码')
return
}
this.loading = true
this.errorMsg = ''
try {
await unlockScreen(this.password)
const lockPath = this.$store.getters.lockPath // 取锁屏前的路径
await this.$store.dispatch('lock/unlockScreen')
this.$router.replace(lockPath)
} catch (err) {
const msg = err.message || err.toString()
this.showError(msg)
this.password = ''
this.$refs.passwordInput && this.$refs.passwordInput.focus()
} finally {
this.loading = false
}
},
showError(msg) {
this.errorMsg = msg
this.isShaking = true
setTimeout(() => { this.isShaking = false }, 600)
},
goLogin() {
this.$store.dispatch('lock/unlockScreen')
this.$store.dispatch('LogOut').then(() => {
this.$router.push('/login')
})
},
// 粒子背景
initParticles() {
const canvas = this.$refs.particleCanvas
if (!canvas) return
const ctx = canvas.getContext('2d')
const resize = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resize()
window.addEventListener('resize', resize)
const count = 80
for (let i = 0; i < count; i++) {
this.particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
r: Math.random() * 2 + 1,
dx: (Math.random() - 0.5) * 0.6,
dy: (Math.random() - 0.5) * 0.6,
alpha: Math.random() * 0.5 + 0.2
})
}
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
this.particles.forEach(p => {
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255,255,255,${p.alpha})`
ctx.fill()
p.x += p.dx
p.y += p.dy
if (p.x < 0 || p.x > canvas.width) p.dx *= -1
if (p.y < 0 || p.y > canvas.height) p.dy *= -1
})
// 连线
for (let i = 0; i < this.particles.length; i++) {
for (let j = i + 1; j < this.particles.length; j++) {
const a = this.particles[i], b = this.particles[j]
const dist = Math.hypot(a.x - b.x, a.y - b.y)
if (dist < 120) {
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.strokeStyle = `rgba(255,255,255,${0.15 * (1 - dist / 120)})`
ctx.lineWidth = 0.5
ctx.stroke()
}
}
}
this.animationId = requestAnimationFrame(draw)
}
draw()
}
}
}
</script>
<style scoped>
.lock-container {
position: fixed;
inset: 0;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
overflow: hidden;
}
.particle-bg {
position: absolute;
inset: 0;
z-index: 0;
}
.lock-time {
position: relative;
z-index: 1;
font-size: 72px;
font-weight: 200;
color: #fff;
letter-spacing: 4px;
text-shadow: 0 0 40px rgba(255,255,255,0.3);
margin-bottom: 8px;
font-variant-numeric: tabular-nums;
}
.lock-date {
position: relative;
z-index: 1;
font-size: 15px;
color: rgba(255,255,255,0.6);
margin-bottom: 48px;
letter-spacing: 2px;
}
.lock-card {
position: relative;
z-index: 1;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 24px;
padding: 40px 48px;
width: 360px;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 25px 60px rgba(0,0,0,0.4);
}
.avatar-wrap {
position: relative;
margin-bottom: 16px;
}
.lock-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
border: 3px solid rgba(255,255,255,0.3);
object-fit: cover;
display: block;
}
.lock-icon {
position: absolute;
bottom: -4px;
right: -4px;
background: rgba(255,255,255,0.15);
border-radius: 50%;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
backdrop-filter: blur(8px);
}
.lock-username {
color: #fff;
font-size: 18px;
font-weight: 600;
margin-bottom: 6px;
letter-spacing: 1px;
}
.lock-hint {
color: rgba(255,255,255,0.5);
font-size: 13px;
margin-bottom: 28px;
}
.input-wrap {
width: 100%;
display: flex;
align-items: center;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 50px;
padding: 4px 4px 4px 20px;
transition: border-color 0.3s;
}
.input-wrap:focus-within {
border-color: rgba(255,255,255,0.6);
background: rgba(255,255,255,0.13);
}
.input-wrap.shake {
animation: shake 0.5s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-6px); }
80% { transform: translateX(6px); }
}
.lock-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #fff;
font-size: 15px;
padding: 10px 0;
}
.lock-input::placeholder {
color: rgba(255,255,255,0.35);
}
.unlock-btn {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
border: none;
color: #fff;
font-size: 18px;
cursor: pointer;
transition: transform 0.2s, opacity 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.unlock-btn:hover:not(:disabled) {
transform: scale(1.08);
}
.unlock-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-dot {
font-size: 13px;
letter-spacing: 1px;
}
.error-msg {
margin-top: 14px;
color: #ff7675;
font-size: 13px;
text-align: center;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.lock-footer {
margin-top: 24px;
}
.lock-footer a {
color: rgba(255,255,255,0.4);
font-size: 13px;
text-decoration: none;
transition: color 0.2s;
}
.lock-footer a:hover {
color: rgba(255,255,255,0.8);
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<el-dialog :title="type === 'log' ? '调度日志详细' : '任务详细'" :visible.sync="visible" width="780px" append-to-body @close="$emit('update:visible', false)">
<div class="detail-wrap">
<template v-if="type === 'log'">
<!-- 基本信息 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-info"></i> 基本信息</div>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">日志编号</span><span class="detail-value">{{ form.jobLogId }}</span></div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<span class="detail-label">执行状态</span>
<el-tag v-if="form.status == 0" type="success" size="small">正常</el-tag>
<el-tag v-else type="danger" size="small">失败</el-tag>
</div>
</el-col>
</el-row>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">开始时间</span><span class="detail-value">{{ form.startTime }}</span></div>
</el-col>
<el-col :span="12">
<div class="detail-item"><span class="detail-label">结束时间</span><span class="detail-value">{{ form.endTime }}</span></div>
</el-col>
</el-row>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">记录时间</span><span class="detail-value">{{ form.createTime }}</span></div>
</el-col>
<el-col :span="12" v-if="form.status == 0 && form.startTime && form.endTime">
<div class="detail-item"><span class="detail-label">执行耗时</span><span class="detail-value">{{ costTime }} 毫秒</span></div>
</el-col>
</el-row>
</div>
<!-- 任务信息 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-time"></i> 任务信息</div>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">任务名称</span><span class="detail-value">{{ form.jobName }}</span></div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<span class="detail-label">任务分组</span>
<dict-tag :options="dict.type.sys_job_group" :value="form.jobGroup" />
</div>
</el-col>
</el-row>
<el-row class="detail-row">
<el-col :span="24">
<div class="detail-item"><span class="detail-label">日志信息</span><span class="detail-value">{{ form.jobMessage }}</span></div>
</el-col>
</el-row>
</div>
<!-- 调用目标 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-s-operation"></i> 调用目标</div>
<div class="code-body">
<div class="code-wrap"><pre class="code-pre">{{ form.invokeTarget || '(无)' }}</pre></div>
</div>
</div>
<!-- 异常信息 -->
<div class="detail-card" v-if="form.status == 1">
<div class="detail-card-title error-title"><i class="el-icon-warning"></i> 异常信息</div>
<div class="error-body"><div class="error-msg">{{ form.exceptionInfo }}</div></div>
</div>
</template>
<template v-else>
<!-- 任务配置 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-setting"></i> 任务配置</div>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">任务编号</span><span class="detail-value">{{ form.jobId }}</span></div>
</el-col>
<el-col :span="12">
<div class="detail-item"><span class="detail-label">任务名称</span><span class="detail-value">{{ form.jobName }}</span></div>
</el-col>
</el-row>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item">
<span class="detail-label">任务分组</span>
<dict-tag :options="dict.type.sys_job_group" :value="form.jobGroup" />
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<span class="detail-label">执行状态</span>
<el-tag v-if="form.status == 0" type="success" size="small">正常</el-tag>
<el-tag v-else type="info" size="small">暂停</el-tag>
</div>
</el-col>
</el-row>
</div>
<!-- 调度信息 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-date"></i> 调度信息</div>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">cron 表达式</span><span class="detail-value mono">{{ form.cronExpression }}</span></div>
</el-col>
<el-col :span="12">
<div class="detail-item"><span class="detail-label">下次执行时间</span><span class="detail-value">{{ parseTime(form.nextValidTime) }}</span></div>
</el-col>
</el-row>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item">
<span class="detail-label">执行策略</span>
<el-tag v-if="form.misfirePolicy == 0" type="info" size="small">默认策略</el-tag>
<el-tag v-else-if="form.misfirePolicy == 1" type="warning" size="small">立即执行</el-tag>
<el-tag v-else-if="form.misfirePolicy == 2" type="primary" size="small">执行一次</el-tag>
<el-tag v-else-if="form.misfirePolicy == 3" type="danger" size="small">放弃执行</el-tag>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<span class="detail-label">并发执行</span>
<el-tag v-if="form.concurrent == 0" type="success" size="small">允许</el-tag>
<el-tag v-else type="danger" size="small">禁止</el-tag>
</div>
</el-col>
</el-row>
</div>
<!-- 执行方法 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-s-operation"></i> 执行方法</div>
<div class="code-body">
<div class="code-wrap"><pre class="code-pre">{{ form.invokeTarget || '(无)' }}</pre></div>
</div>
</div>
<!-- 元信息 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-document"></i> 元信息</div>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">创建人</span><span class="detail-value">{{ form.createBy || '-' }}</span></div>
</el-col>
<el-col :span="12">
<div class="detail-item"><span class="detail-label">创建时间</span><span class="detail-value">{{ form.createTime }}</span></div>
</el-col>
</el-row>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">更新人</span><span class="detail-value">{{ form.updateBy || '-' }}</span></div>
</el-col>
<el-col :span="12">
<div class="detail-item"><span class="detail-label">更新时间</span><span class="detail-value">{{ form.updateTime || '-' }}</span></div>
</el-col>
</el-row>
<el-row class="detail-row" v-if="form.remark">
<el-col :span="24">
<div class="detail-item"><span class="detail-label">备注</span><span class="detail-value">{{ form.remark }}</span></div>
</el-col>
</el-row>
</div>
</template>
</div>
</el-dialog>
</template>
<script>
export default {
dicts: ['sys_job_group'],
props: {
visible: { type: Boolean, default: false },
row: { type: Object, default: () => ({}) },
// 'job' 任务详细 | 'log' 调度日志详细
type: { type: String, default: 'job' }
},
computed: {
form() { return this.row || {} },
costTime() {
if (!this.form.startTime || !this.form.endTime) return 0
return new Date(this.form.endTime).getTime() - new Date(this.form.startTime).getTime()
}
}
}
</script>
<style scoped>
.detail-label {
width: 80px;
}
</style>

View File

@@ -94,7 +94,11 @@
<el-table v-loading="loading" :data="jobList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="任务编号" width="100" align="center" prop="jobId" />
<el-table-column label="任务名称" align="center" prop="jobName" :show-overflow-tooltip="true" />
<el-table-column label="任务名称" align="center" :show-overflow-tooltip="true">
<template slot-scope="scope">
<a class="link-type" style="cursor:pointer" @click="handleView(scope.row)">{{ scope.row.jobName }}</a>
</template>
</el-table-column>
<el-table-column label="任务组名" align="center" prop="jobGroup">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_job_group" :value="scope.row.jobGroup"/>
@@ -133,8 +137,6 @@
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="handleRun" icon="el-icon-caret-right"
v-hasPermi="['monitor:job:changeStatus']">执行一次</el-dropdown-item>
<el-dropdown-item command="handleView" icon="el-icon-view"
v-hasPermi="['monitor:job:query']">任务详细</el-dropdown-item>
<el-dropdown-item command="handleJobLog" icon="el-icon-s-operation"
v-hasPermi="['monitor:job:query']">调度日志</el-dropdown-item>
</el-dropdown-menu>
@@ -241,61 +243,17 @@
</el-dialog>
<!-- 任务日志详细 -->
<el-dialog title="任务详细" :visible.sync="openView" width="700px" append-to-body>
<el-form ref="form" :model="form" label-width="120px" size="mini">
<el-row>
<el-col :span="12">
<el-form-item label="任务编号:">{{ form.jobId }}</el-form-item>
<el-form-item label="任务名称:">{{ form.jobName }}</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="任务分组:">{{ jobGroupFormat(form) }}</el-form-item>
<el-form-item label="创建时间:">{{ form.createTime }}</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="cron表达式">{{ form.cronExpression }}</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="下次执行时间:">{{ parseTime(form.nextValidTime) }}</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="调用目标方法:">{{ form.invokeTarget }}</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="任务状态:">
<div v-if="form.status == 0">正常</div>
<div v-else-if="form.status == 1">暂停</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否并发:">
<div v-if="form.concurrent == 0">允许</div>
<div v-else-if="form.concurrent == 1">禁止</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="执行策略:">
<div v-if="form.misfirePolicy == 0">默认策略</div>
<div v-else-if="form.misfirePolicy == 1">立即执行</div>
<div v-else-if="form.misfirePolicy == 2">执行一次</div>
<div v-else-if="form.misfirePolicy == 3">放弃执行</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="openView = false"> </el-button>
</div>
</el-dialog>
<job-detail :visible.sync="openView" :row="form" type="job" />
</div>
</template>
<script>
import { listJob, getJob, delJob, addJob, updateJob, runJob, changeJobStatus } from "@/api/monitor/job";
import { listJob, getJob, delJob, addJob, updateJob, runJob, changeJobStatus } from "@/api/monitor/job"
import JobDetail from './detail'
import Crontab from '@/components/Crontab'
export default {
components: { Crontab },
components: { Crontab, JobDetail },
name: "Job",
dicts: ['sys_job_group', 'sys_job_status'],
data() {
@@ -346,29 +304,29 @@ export default {
{ required: true, message: "cron执行表达式不能为空", trigger: "blur" }
]
}
};
}
},
created() {
this.getList();
this.getList()
},
methods: {
/** 查询定时任务列表 */
getList() {
this.loading = true;
this.loading = true
listJob(this.queryParams).then(response => {
this.jobList = response.rows;
this.total = response.total;
this.loading = false;
});
this.jobList = response.rows
this.total = response.total
this.loading = false
})
},
// 任务组名字典翻译
jobGroupFormat(row, column) {
return this.selectDictLabel(this.dict.type.sys_job_group, row.jobGroup);
return this.selectDictLabel(this.dict.type.sys_job_group, row.jobGroup)
},
// 取消按钮
cancel() {
this.open = false;
this.reset();
this.open = false
this.reset()
},
// 表单重置
reset() {
@@ -381,96 +339,96 @@ export default {
misfirePolicy: 1,
concurrent: 1,
status: "0"
};
this.resetForm("form");
}
this.resetForm("form")
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm");
this.handleQuery();
this.resetForm("queryForm")
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.jobId);
this.single = selection.length != 1;
this.multiple = !selection.length;
this.ids = selection.map(item => item.jobId)
this.single = selection.length != 1
this.multiple = !selection.length
},
// 更多操作触发
handleCommand(command, row) {
switch (command) {
case "handleRun":
this.handleRun(row);
break;
this.handleRun(row)
break
case "handleView":
this.handleView(row);
break;
this.handleView(row)
break
case "handleJobLog":
this.handleJobLog(row);
break;
this.handleJobLog(row)
break
default:
break;
break
}
},
// 任务状态修改
handleStatusChange(row) {
let text = row.status === "0" ? "启用" : "停用";
let text = row.status === "0" ? "启用" : "停用"
this.$modal.confirm('确认要"' + text + '""' + row.jobName + '"任务吗?').then(function() {
return changeJobStatus(row.jobId, row.status);
return changeJobStatus(row.jobId, row.status)
}).then(() => {
this.$modal.msgSuccess(text + "成功");
this.$modal.msgSuccess(text + "成功")
}).catch(function() {
row.status = row.status === "0" ? "1" : "0";
});
row.status = row.status === "0" ? "1" : "0"
})
},
/* 立即执行一次 */
handleRun(row) {
this.$modal.confirm('确认要立即执行一次"' + row.jobName + '"任务吗?').then(function() {
return runJob(row.jobId, row.jobGroup);
return runJob(row.jobId, row.jobGroup)
}).then(() => {
this.$modal.msgSuccess("执行成功");
}).catch(() => {});
this.$modal.msgSuccess("执行成功")
}).catch(() => {})
},
/** 任务详细信息 */
handleView(row) {
getJob(row.jobId).then(response => {
this.form = response.data;
this.openView = true;
});
this.form = response.data
this.openView = true
})
},
/** cron表达式按钮操作 */
handleShowCron() {
this.expression = this.form.cronExpression;
this.openCron = true;
this.expression = this.form.cronExpression
this.openCron = true
},
/** 确定后回传值 */
crontabFill(value) {
this.form.cronExpression = value;
this.form.cronExpression = value
},
/** 任务日志列表查询 */
handleJobLog(row) {
const jobId = row.jobId || 0;
const jobId = row.jobId || 0
this.$router.push('/monitor/job-log/index/' + jobId)
},
/** 新增按钮操作 */
handleAdd() {
this.reset();
this.open = true;
this.title = "添加任务";
this.reset()
this.open = true
this.title = "添加任务"
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset();
const jobId = row.jobId || this.ids;
this.reset()
const jobId = row.jobId || this.ids
getJob(jobId).then(response => {
this.form = response.data;
this.open = true;
this.title = "修改任务";
});
this.form = response.data
this.open = true
this.title = "修改任务"
})
},
/** 提交按钮 */
submitForm: function() {
@@ -478,29 +436,29 @@ export default {
if (valid) {
if (this.form.jobId != undefined) {
updateJob(this.form).then(() => {
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();
});
this.$modal.msgSuccess("修改成功")
this.open = false
this.getList()
})
} else {
addJob(this.form).then(() => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
});
this.$modal.msgSuccess("新增成功")
this.open = false
this.getList()
})
}
}
});
})
},
/** 删除按钮操作 */
handleDelete(row) {
const jobIds = row.jobId || this.ids;
const jobIds = row.jobId || this.ids
this.$modal.confirm('是否确认删除定时任务编号为"' + jobIds + '"的数据项?').then(function() {
return delJob(jobIds);
return delJob(jobIds)
}).then(() => {
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {});
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
},
/** 导出按钮操作 */
handleExport() {
@@ -509,5 +467,5 @@ export default {
}, `job_${new Date().getTime()}.xlsx`)
}
}
};
}
</script>

View File

@@ -143,48 +143,18 @@
@pagination="getList"
/>
<!-- 调度日志详细 -->
<el-dialog title="调度日志详细" :visible.sync="open" width="700px" append-to-body>
<el-form ref="form" :model="form" label-width="100px" size="mini">
<el-row>
<el-col :span="12">
<el-form-item label="日志序号:">{{ form.jobLogId }}</el-form-item>
<el-form-item label="任务名称:">{{ form.jobName }}</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="任务分组:">{{ form.jobGroup }}</el-form-item>
<el-form-item label="执行时间:">{{ form.createTime }}</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="调用方法:">{{ form.invokeTarget }}</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="日志信息:">{{ form.jobMessage }}</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="执行状态:">
<div v-if="form.status == 0">正常</div>
<div v-else-if="form.status == 1">失败</div>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="异常信息:" v-if="form.status == 1">{{ form.exceptionInfo }}</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="open = false"> </el-button>
</div>
</el-dialog>
<job-log-detail :visible.sync="open" :row="form" type="log" />
</div>
</template>
<script>
import { getJob} from "@/api/monitor/job";
import { listJobLog, delJobLog, cleanJobLog } from "@/api/monitor/jobLog";
import { getJob} from "@/api/monitor/job"
import { listJobLog, delJobLog, cleanJobLog } from "@/api/monitor/jobLog"
import JobLogDetail from './detail'
export default {
name: "JobLog",
components: { JobLogDetail },
dicts: ['sys_common_status', 'sys_job_group'],
data() {
return {
@@ -214,75 +184,75 @@ export default {
jobGroup: undefined,
status: undefined
}
};
}
},
created() {
const jobId = this.$route.params && this.$route.params.jobId;
const jobId = this.$route.params && this.$route.params.jobId
if (jobId !== undefined && jobId != 0) {
getJob(jobId).then(response => {
this.queryParams.jobName = response.data.jobName;
this.queryParams.jobGroup = response.data.jobGroup;
this.getList();
});
this.queryParams.jobName = response.data.jobName
this.queryParams.jobGroup = response.data.jobGroup
this.getList()
})
} else {
this.getList();
this.getList()
}
},
methods: {
/** 查询调度日志列表 */
getList() {
this.loading = true;
this.loading = true
listJobLog(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
this.jobLogList = response.rows;
this.total = response.total;
this.loading = false;
this.jobLogList = response.rows
this.total = response.total
this.loading = false
}
);
)
},
// 返回按钮
handleClose() {
const obj = { path: "/monitor/job" };
this.$tab.closeOpenPage(obj);
const obj = { path: "/monitor/job" }
this.$tab.closeOpenPage(obj)
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.dateRange = [];
this.resetForm("queryForm");
this.handleQuery();
this.dateRange = []
this.resetForm("queryForm")
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.jobLogId);
this.multiple = !selection.length;
this.ids = selection.map(item => item.jobLogId)
this.multiple = !selection.length
},
/** 详细按钮操作 */
handleView(row) {
this.open = true;
this.form = row;
this.open = true
this.form = row
},
/** 删除按钮操作 */
handleDelete(row) {
const jobLogIds = this.ids;
const jobLogIds = this.ids
this.$modal.confirm('是否确认删除调度日志编号为"' + jobLogIds + '"的数据项?').then(function() {
return delJobLog(jobLogIds);
return delJobLog(jobLogIds)
}).then(() => {
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {});
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
},
/** 清空按钮操作 */
handleClean() {
this.$modal.confirm('是否确认清空所有调度日志数据项?').then(function() {
return cleanJobLog();
return cleanJobLog()
}).then(() => {
this.getList();
this.$modal.msgSuccess("清空成功");
}).catch(() => {});
this.getList()
this.$modal.msgSuccess("清空成功")
}).catch(() => {})
},
/** 导出按钮操作 */
handleExport() {
@@ -291,5 +261,5 @@ export default {
}, `log_${new Date().getTime()}.xlsx`)
}
}
};
</script>
}
</script>

View File

@@ -36,6 +36,16 @@
v-hasPermi="['system:dept:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-check"
size="mini"
@click="handleSaveSort"
v-hasPermi="['system:dept:edit']"
>保存排序</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="info"
@@ -57,7 +67,11 @@
:tree-props="{children: 'children', hasChildren: 'hasChildren'}"
>
<el-table-column prop="deptName" label="部门名称" width="260"></el-table-column>
<el-table-column prop="orderNum" label="排序" width="200"></el-table-column>
<el-table-column prop="orderNum" label="排序" width="200">
<template slot-scope="scope">
<el-input-number v-model="scope.row.orderNum" controls-position="right" :min="0" size="mini" style="width: 88px" />
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_normal_disable" :value="scope.row.status"/>
@@ -158,7 +172,7 @@
</template>
<script>
import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild } from "@/api/system/dept"
import { listDept, getDept, delDept, addDept, updateDept, updateDeptSort, listDeptExcludeChild } from "@/api/system/dept"
import Treeselect from "@riophae/vue-treeselect"
import "@riophae/vue-treeselect/dist/vue-treeselect.css"
@@ -184,6 +198,8 @@ export default {
isExpandAll: true,
// 重新渲染表格状态
refreshTable: true,
// 记录原始排序,用于对比变更
originalOrders: {},
// 查询参数
queryParams: {
deptName: undefined,
@@ -228,6 +244,8 @@ export default {
this.loading = true
listDept(this.queryParams).then(response => {
this.deptList = this.handleTree(response.data, "deptId")
// 记录原始排序值
this.recordOriginalOrders(this.deptList)
this.loading = false
})
},
@@ -326,6 +344,40 @@ export default {
}
})
},
/** 递归记录原始排序 */
recordOriginalOrders(list) {
list.forEach(item => {
this.originalOrders[item.deptId] = item.orderNum
if (item.children && item.children.length) {
this.recordOriginalOrders(item.children)
}
})
},
/** 保存排序 */
handleSaveSort() {
const changedDeptIds = []
const changedOrderNums = []
const collectChanged = (list) => {
list.forEach(item => {
if (String(this.originalOrders[item.deptId]) !== String(item.orderNum)) {
changedDeptIds.push(item.deptId)
changedOrderNums.push(item.orderNum)
}
if (item.children && item.children.length) {
collectChanged(item.children)
}
})
}
collectChanged(this.deptList)
if (changedDeptIds.length === 0) {
this.$modal.msgWarning("未检测到排序修改")
return
}
updateDeptSort({ deptIds: changedDeptIds.join(","), orderNums: changedOrderNums.join(",") }).then(() => {
this.$modal.msgSuccess("排序保存成功")
this.recordOriginalOrders(this.deptList)
})
},
/** 删除按钮操作 */
handleDelete(row) {
this.$modal.confirm('是否确认删除名称为"' + row.deptName + '"的数据项?').then(function() {

View File

@@ -0,0 +1,203 @@
<template>
<el-drawer :title="drawerTitle" :visible.sync="localVisible" direction="rtl" size="700px" append-to-body>
<div class="drawer-wrap">
<div v-if="loading" class="drawer-loading">
<i class="el-icon-loading"></i>
<span>加载中...</span>
</div>
<div v-else-if="!dataList.length" class="drawer-empty">
<i class="el-icon-document"></i>
<div>暂无字典数据</div>
</div>
<template v-else>
<el-row :gutter="12" class="stat-row">
<el-col :span="disabledCount > 0 ? 8 : 12">
<div class="stat-card">
<div class="stat-num">{{ dataList.length }}</div>
<div class="stat-label">共计条目</div>
</div>
</el-col>
<el-col :span="disabledCount > 0 ? 8 : 12">
<div class="stat-card">
<div class="stat-num success">{{ normalCount }}</div>
<div class="stat-label">正常</div>
</div>
</el-col>
<el-col :span="8" v-if="disabledCount > 0">
<div class="stat-card">
<div class="stat-num danger">{{ disabledCount }}</div>
<div class="stat-label">停用</div>
</div>
</el-col>
</el-row>
<div v-for="item in dataList" :key="item.dictCode" class="dict-item">
<div class="dict-cell">
<div class="dict-cell-key">标签</div>
<div class="dict-cell-val">
<el-tag v-if="item.listClass && item.listClass !== 'default'" :type="item.listClass === 'primary' ? '' : item.listClass" size="small">{{ item.dictLabel }}</el-tag>
<span v-else>{{ item.dictLabel }}</span>
</div>
</div>
<div class="dict-cell">
<div class="dict-cell-key">键值</div>
<div class="dict-cell-val">{{ item.dictValue }}</div>
</div>
<div class="dict-cell">
<div class="dict-cell-key">状态</div>
<div class="dict-cell-val">
<el-tag :type="item.status === '0' ? 'success' : 'danger'" size="small">
{{ item.status === '0' ? '正常' : '停用' }}
</el-tag>
</div>
</div>
</div>
</template>
</div>
</el-drawer>
</template>
<script>
import { listData } from '@/api/system/dict/data'
export default {
props: {
visible: { type: Boolean, default: false },
row: { type: Object, default: () => ({}) }
},
data() {
return {
localVisible: false,
loading: false,
dataList: []
}
},
computed: {
drawerTitle() {
return this.row.dictName + ' ' + (this.row.dictType || '')
},
normalCount() {
return this.dataList.filter(r => r.status === '0').length
},
disabledCount() {
return this.dataList.filter(r => r.status !== '0').length
}
},
watch: {
visible(val) {
this.localVisible = val
},
localVisible(val) {
this.$emit('update:visible', val)
if (val) {
this.loadData()
} else {
this.dataList = []
}
}
},
methods: {
loadData() {
if (!this.row || !this.row.dictType) return
this.loading = true
this.dataList = []
listData({ dictType: this.row.dictType, pageSize: 100, pageNum: 1 }).then(response => {
this.dataList = response.rows || []
}).catch(() => {}).finally(() => {
this.loading = false
})
}
}
}
</script>
<style scoped>
.drawer-wrap {
padding: 0 20px 20px;
}
.drawer-loading {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
color: #aaa;
font-size: 13px;
gap: 8px;
}
.drawer-empty {
text-align: center;
color: #bbb;
padding: 60px 0;
font-size: 13px;
}
.drawer-empty i {
font-size: 36px;
display: block;
margin-bottom: 8px;
}
.stat-row {
margin-bottom: 16px;
}
.stat-card {
background: #f7f9fb;
border: 1px solid #e8ecf0;
border-radius: 6px;
padding: 10px 14px;
text-align: center;
}
.stat-num {
font-size: 22px;
font-weight: 700;
color: #2c3e50;
}
.stat-num.success { color: #27ae60; }
.stat-num.danger { color: #e74c3c; }
.stat-label {
font-size: 11px;
color: #95a5a6;
margin-top: 4px;
}
.dict-item {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
border: 1px solid #e8ecf0;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}
.dict-cell {
display: grid;
grid-template-columns: 70px 1fr;
border-right: 1px solid #f0f4f8;
}
.dict-cell:last-child {
border-right: 0;
}
.dict-cell-key {
padding: 9px 14px;
font-size: 12px;
color: #888;
background: #f7f9fb;
border-right: 1px solid #f0f4f8;
}
.dict-cell-val {
padding: 9px 14px;
font-size: 13px;
color: #2c3e50;
word-break: break-all;
display: flex;
align-items: center;
}
</style>

View File

@@ -113,9 +113,7 @@
<el-table-column label="字典名称" align="center" prop="dictName" :show-overflow-tooltip="true" />
<el-table-column label="字典类型" align="center" :show-overflow-tooltip="true">
<template slot-scope="scope">
<router-link :to="'/system/dict-data/index/' + scope.row.dictId" class="link-type">
<span>{{ scope.row.dictType }}</span>
</router-link>
<a class="link-type" style="cursor:pointer" @click="handleViewData(scope.row)">{{ scope.row.dictType }}</a>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status">
@@ -138,6 +136,13 @@
@click="handleUpdate(scope.row)"
v-hasPermi="['system:dict:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-s-operation"
@click="handleDataList(scope.row)"
v-hasPermi="['system:dict:edit']"
>列表</el-button>
<el-button
size="mini"
type="text"
@@ -190,14 +195,18 @@
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
<dict-data-drawer :visible.sync="drawerVisible" :row="drawerRow" />
</div>
</template>
<script>
import DictDataDrawer from './detail'
import { listType, getType, delType, addType, updateType, refreshCache } from "@/api/system/dict/type"
export default {
name: "Dict",
components: { DictDataDrawer },
dicts: ['sys_normal_disable'],
data() {
return {
@@ -219,6 +228,10 @@ export default {
title: "",
// 是否显示弹出层
open: false,
// 字典数据抽屉状态
drawerVisible: false,
// 字典数据信息
drawerRow: {},
// 日期范围
dateRange: [],
// 查询参数
@@ -295,6 +308,15 @@ export default {
this.single = selection.length!=1
this.multiple = !selection.length
},
/** 字典数据抽屉显示信息 */
handleViewData(row) {
this.drawerRow = row
this.drawerVisible = true
},
/** 字典数据列表页面 */
handleDataList(row) {
this.$tab.openPage("字典数据", '/system/dict-data/index/' + row.dictId)
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()

View File

@@ -36,6 +36,16 @@
v-hasPermi="['system:menu:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-check"
size="mini"
@click="handleSaveSort"
v-hasPermi="['system:menu:edit']"
>保存排序</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="info"
@@ -56,23 +66,31 @@
:default-expand-all="isExpandAll"
:tree-props="{children: 'children', hasChildren: 'hasChildren'}"
>
<el-table-column prop="menuName" label="菜单名称" :show-overflow-tooltip="true" width="160"></el-table-column>
<el-table-column prop="icon" label="图标" align="center" width="100">
<el-table-column prop="menuName" label="菜单名称" :show-overflow-tooltip="true" width="220">
<template slot-scope="scope">
<svg-icon :icon-class="scope.row.icon" />
<svg-icon :icon-class="scope.row.icon" />
<span class="ml5">{{ scope.row.menuName }}</span>
</template>
</el-table-column>
<el-table-column prop="orderNum" label="排序" width="60"></el-table-column>
<el-table-column prop="perms" label="权限标识" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="component" label="组件路径" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="menuName" label="类型" :show-overflow-tooltip="true" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.menuType === 'M' && scope.row.isFrame === '0'" type="danger" size="small">外链</el-tag>
<el-tag v-else-if="scope.row.menuType === 'M'" type="primary" size="small">目录</el-tag>
<el-tag v-else-if="scope.row.menuType === 'C' && scope.row.isFrame === '0'" type="danger" size="small">外链</el-tag>
<el-tag v-else-if="scope.row.menuType === 'C'" type="success" size="small">菜单</el-tag>
<el-tag v-else-if="scope.row.menuType === 'F'" type="warning" size="small">按钮</el-tag>
</template>
</el-table-column>
<el-table-column prop="orderNum" label="排序" width="200">
<template slot-scope="scope">
<el-input-number v-model="scope.row.orderNum" controls-position="right" :min="0" size="mini" style="width: 88px" />
</template>
</el-table-column>
<el-table-column prop="perms" label="权限标识" :show-overflow-tooltip="true" />
<el-table-column prop="component" label="组件路径" :show-overflow-tooltip="true" />
<el-table-column prop="status" label="状态" width="80">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_normal_disable" :value="scope.row.status"/>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
<dict-tag :options="dict.type.sys_normal_disable" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
@@ -299,7 +317,7 @@
</template>
<script>
import { listMenu, getMenu, delMenu, addMenu, updateMenu } from "@/api/system/menu"
import { listMenu, getMenu, delMenu, addMenu, updateMenu, updateMenuSort } from "@/api/system/menu"
import Treeselect from "@riophae/vue-treeselect"
import "@riophae/vue-treeselect/dist/vue-treeselect.css"
import IconSelect from "@/components/IconSelect"
@@ -326,6 +344,8 @@ export default {
isExpandAll: false,
// 重新渲染表格状态
refreshTable: true,
// 记录原始排序,用于对比变更
originalOrders: {},
// 查询参数
queryParams: {
menuName: undefined,
@@ -360,6 +380,8 @@ export default {
this.loading = true
listMenu(this.queryParams).then(response => {
this.menuList = this.handleTree(response.data, "menuId")
// 记录原始排序值
this.recordOriginalOrders(this.menuList)
this.loading = false
})
},
@@ -463,6 +485,40 @@ export default {
}
})
},
/** 递归记录原始排序 */
recordOriginalOrders(list) {
list.forEach(item => {
this.originalOrders[item.menuId] = item.orderNum
if (item.children && item.children.length) {
this.recordOriginalOrders(item.children)
}
})
},
/** 保存排序 */
handleSaveSort() {
const changedMenuIds = []
const changedOrderNums = []
const collectChanged = (list) => {
list.forEach(item => {
if (String(this.originalOrders[item.menuId]) !== String(item.orderNum)) {
changedMenuIds.push(item.menuId)
changedOrderNums.push(item.orderNum)
}
if (item.children && item.children.length) {
collectChanged(item.children)
}
})
}
collectChanged(this.menuList)
if (changedMenuIds.length === 0) {
this.$modal.msgWarning("未检测到排序修改")
return
}
updateMenuSort({ menuIds: changedMenuIds.join(","), orderNums: changedOrderNums.join(",") }).then(() => {
this.$modal.msgSuccess("排序保存成功")
this.recordOriginalOrders(this.menuList)
})
},
/** 删除按钮操作 */
handleDelete(row) {
this.$modal.confirm('是否确认删除名称为"' + row.menuName + '"的数据项?').then(function() {

View File

@@ -0,0 +1,147 @@
<template>
<el-dialog title="操作日志详细" :visible.sync="visible" width="780px" append-to-body @close="$emit('update:visible', false)">
<div class="detail-wrap">
<!-- 基本信息 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-info"></i> 基本信息</div>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">操作模块</span><span class="detail-value">{{ form.title }}</span></div>
</el-col>
<el-col :span="12">
<div class="detail-item"><span class="detail-label">业务类型</span><span class="detail-value">{{ typeLabel }}</span></div>
</el-col>
</el-row>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">操作时间</span><span class="detail-value">{{ form.operTime }}</span></div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<span class="detail-label">执行状态</span>
<el-tag v-if="form.status === 0" type="success" size="small"><i class="el-icon-check"></i> 正常</el-tag>
<el-tag v-else type="danger" size="small"><i class="el-icon-close"></i> 异常</el-tag>
</div>
</el-col>
</el-row>
</div>
<!-- 操作人员 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-user"></i> 操作人员</div>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">操作人员</span><span class="detail-value">{{ form.operName }}</span></div>
</el-col>
<el-col :span="12" v-if="form.deptName">
<div class="detail-item"><span class="detail-label">所属部门</span><span class="detail-value">{{ form.deptName }}</span></div>
</el-col>
</el-row>
<el-row class="detail-row">
<el-col :span="24">
<div class="detail-item">
<span class="detail-label">操作地址</span>
<span class="detail-value">{{ form.operIp }}&nbsp;&nbsp;<span class="detail-location">{{ form.operLocation }}</span></span>
</div>
</el-col>
</el-row>
</div>
<!-- 请求信息 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-sort"></i> 请求信息</div>
<el-row class="detail-row">
<el-col :span="24">
<div class="detail-item">
<span class="detail-label">请求地址</span>
<span class="detail-value">
<span :class="'method-tag method-' + form.requestMethod">{{ form.requestMethod }}</span>
{{ form.operUrl }}
</span>
</div>
</el-col>
</el-row>
<el-row class="detail-row">
<el-col :span="24">
<div class="detail-item"><span class="detail-label">操作方法</span><span class="detail-value mono">{{ form.method }}</span></div>
</el-col>
</el-row>
<el-row class="detail-row">
<el-col :span="12">
<div class="detail-item"><span class="detail-label">消耗时间</span><span class="detail-value">{{ form.costTime }} 毫秒</span></div>
</el-col>
</el-row>
</div>
<!-- 请求参数 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-upload2"></i> 请求参数</div>
<div class="code-body">
<div class="code-wrap">
<div class="code-action">
<el-button size="mini" icon="el-icon-copy-document" @click="copyText(form.operParam)">复制</el-button>
</div>
<pre class="code-pre">{{ formatJson(form.operParam) }}</pre>
</div>
</div>
</div>
<!-- 返回参数 -->
<div class="detail-card">
<div class="detail-card-title"><i class="el-icon-download"></i> 返回参数</div>
<div class="code-body">
<div class="code-wrap">
<div class="code-action">
<el-button size="mini" icon="el-icon-copy-document" @click="copyText(form.jsonResult)">复制</el-button>
</div>
<pre class="code-pre">{{ formatJson(form.jsonResult) }}</pre>
</div>
</div>
</div>
<!-- 异常信息 -->
<div class="detail-card" v-if="form.status !== 0">
<div class="detail-card-title error-title"><i class="el-icon-warning"></i> 异常信息</div>
<div class="error-body">
<div class="error-msg">{{ form.errorMsg }}</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'OperlogDetail',
dicts: ['sys_oper_type'],
props: {
visible: { type: Boolean, default: false },
row: { type: Object, default: () => ({}) }
},
computed: {
form() { return this.row || {} },
typeLabel() { return this.selectDictLabel(this.dict.type.sys_oper_type, this.form.businessType) || '-' }
},
methods: {
formatJson(str) {
if (!str) return '(无数据)'
try { return JSON.stringify(JSON.parse(str), null, 2) } catch { return str }
},
copyText(str) {
const text = this.formatJson(str)
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => this.$message({ message: '已复制', type: 'success', duration: 1500 }))
} else {
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
this.$message({ message: '已复制', type: 'success', duration: 1500 })
}
}
}
}
</script>

View File

@@ -144,7 +144,7 @@
size="mini"
type="text"
icon="el-icon-view"
@click="handleView(scope.row,scope.index)"
@click="handleDetail(scope.row,scope.index)"
v-hasPermi="['system:operlog:query']"
>详细</el-button>
</template>
@@ -159,58 +159,17 @@
@pagination="getList"
/>
<!-- 操作日志详细 -->
<el-dialog title="操作日志详细" :visible.sync="open" width="800px" append-to-body>
<el-form ref="form" :model="form" label-width="100px" size="mini">
<el-row>
<el-col :span="12">
<el-form-item label="操作模块:">{{ form.title }} / {{ typeFormat(form) }}</el-form-item>
<el-form-item
label="登录信息:"
>{{ form.operName }} / {{ form.operIp }}</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="请求地址:">{{ form.operUrl }}</el-form-item>
<el-form-item label="请求方式:">{{ form.requestMethod }}</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="操作方法:">{{ form.method }}</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="请求参数:">{{ form.operParam }}</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="返回参数:">{{ form.jsonResult }}</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="操作状态:">
<div v-if="form.status === 0">正常</div>
<div v-else-if="form.status === 1">失败</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="消耗时间:">{{ form.costTime }}毫秒</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="操作时间:">{{ parseTime(form.operTime) }}</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="异常信息:" v-if="form.status === 1">{{ form.errorMsg }}</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="open = false"> </el-button>
</div>
</el-dialog>
<operlog-detail :visible.sync="detailVisible" :row="detailRow" />
</div>
</template>
<script>
import OperlogDetail from './detail'
import { list, delOperlog, cleanOperlog } from "@/api/system/operlog"
export default {
name: "Operlog",
components: { OperlogDetail },
dicts: ['sys_oper_type', 'sys_common_status'],
data() {
return {
@@ -227,7 +186,8 @@ export default {
// 表格数据
list: [],
// 是否显示弹出层
open: false,
detailVisible: false,
detailRow: {},
// 日期范围
dateRange: [],
// 默认排序
@@ -260,9 +220,10 @@ export default {
}
)
},
// 操作日志类型字典翻译
typeFormat(row, column) {
return this.selectDictLabel(this.dict.type.sys_oper_type, row.businessType)
// 详细按钮操作
handleDetail(row) {
this.detailRow = row
this.detailVisible = true
},
/** 搜索按钮操作 */
handleQuery() {
@@ -287,11 +248,6 @@ export default {
this.queryParams.isAsc = column.order
this.getList()
},
/** 详细按钮操作 */
handleView(row) {
this.open = true
this.form = row
},
/** 删除按钮操作 */
handleDelete(row) {
const operIds = row.operId || this.ids

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