ruoyi-cloud功能开发demo实战:三级联动 Redis Hash 缓存查看器

本文为使用ruoyi-cloud框架构建一个三级查询redis信息的页面,仅用于练手。

一、需求概述

在若依(RuoYi)前后端分离框架中,系统缓存管理是后台管理系统的常见功能模块。本文将以一个完整的实战案例,演示如何从零开发一个三级联动的缓存管理模块,实现 Redis Hash 结构数据的可视化浏览与删除操作。

该模块采用「缓存列表 → key 名列表 → 缓存内容」的三级联动设计,前端基于 Vue 3 + Element Plus,后端基于 Spring Boot,数据存储使用 Redis Hash 结构。

二、前端菜单配置

在若依框架中,新增页面功能的第一步是在系统菜单中注册路由。菜单配置信息如下:

配置项 值
菜单名称 cacheList
路由地址 system/cache/index
权限标识 system:cache:index

在系统管理 → 菜单管理页面中,点击「新增」按钮,填写上述信息。组件路径填写 system/cache/index,菜单类型选择「目录」或「菜单」,根据实际需要分配访问权限。

三、前端页面开发

在 src/views/system/ 目录下新建 cache 目录,并创建 index.vue 文件。页面采用三栏布局,分别展示缓存列表、key 名列表和缓存内容。

html 复制代码
<template>
  <div class="app-container cache-container">
    <el-row :gutter="20">
      <!-- 一级:Redis Hash 的 key -->
      <el-col :xs="24" :sm="24" :md="8">
        <el-card class="cache-card" shadow="never">
          <template #header>
            <div class="card-header">
              <span>缓存列表</span>
              <el-tag size="small" type="info" round>{{ cacheList.length }} 项</el-tag>
            </div>
          </template>
          <el-table
            ref="cacheTableRef"
            :data="cacheList"
            height="440"
            highlight-current-row
            @row-click="handleCacheClick"
          >
            <el-table-column prop="num" label="序号" width="60" align="center" />
            <el-table-column prop="name" label="缓存名称" min-width="110" show-overflow-tooltip />
            <el-table-column prop="info" label="备注" min-width="90" show-overflow-tooltip />
            <el-table-column label="操作" width="64" align="center">
              <template #default="{ row }">
                <el-button link type="danger" @click.stop="handleDelete(row)">删除</el-button>
              </template>
            </el-table-column>
          </el-table>
        </el-card>
      </el-col>

      <!-- 二级:Hash 的 field -->
      <el-col :xs="24" :sm="24" :md="8">
        <el-card
          class="cache-card"
          shadow="never"
          v-loading="fieldLoading"
          element-loading-text="加载中..."
        >
          <template #header>
            <div class="card-header">
              <span>key名列表</span>
              <el-tag v-if="selectedCache" size="small" type="success" round>
                {{ fieldList.length }} 个
              </el-tag>
            </div>
          </template>
          <template v-if="fieldList.length">
            <el-table
              ref="fieldTableRef"
              :data="fieldList"
              height="440"
              highlight-current-row
              @row-click="handleFieldClick"
            >
              <el-table-column prop="num" label="序号" width="60" align="center" />
              <el-table-column prop="field" label="key 名称" min-width="150" show-overflow-tooltip />
            </el-table>
          </template>
          <div v-else class="empty-wrap">
            <el-empty :description="selectedCache ? '该缓存下暂无数据' : '请先选择左侧缓存'" :image-size="80" />
          </div>
        </el-card>
      </el-col>

      <!-- 三级:Hash 的 value -->
      <el-col :xs="24" :sm="24" :md="8">
        <el-card
          class="cache-card content-card"
          shadow="never"
          v-loading="valueLoading"
          element-loading-text="加载中..."
        >
          <template #header>
            <div class="card-header">
              <span>缓存内容</span>
              <el-tag v-if="selectedCache" size="small" round>{{ selectedCache }}</el-tag>
            </div>
          </template>
          <div v-if="selectedField" class="value-panel">
            <el-descriptions :column="1" size="small" border>
              <el-descriptions-item label="缓存名称">{{ selectedCache }}</el-descriptions-item>
              <el-descriptions-item label="key 名称">{{ selectedField }}</el-descriptions-item>
              <el-descriptions-item label="数据类型">Hash / String</el-descriptions-item>
            </el-descriptions>
            <div class="value-title">Value</div>
            <pre class="value-content">{{ formattedValue || "接口暂未返回该 key 的内容" }}</pre>
          </div>
          <div v-else class="empty-wrap">
            <el-empty
              :description="selectedCache ? '请选择左侧 key 查看内容' : '请依次选择缓存和 key'"
              :image-size="80"
            />
          </div>
        </el-card>
      </el-col>
    </el-row>
  </div>
</template>

<script setup>
import { computed, ref } from "vue"
import { ElMessage, ElMessageBox } from "element-plus"
import { getCacheKey, getCachelist } from "@/api/system/cache.js";

defineOptions({ name: "Cache" })

/**
 * 静态 Redis Hash 结构数据:
 * 第一级 key -> 第二级 field -> 第三级 value
 */
const redisHashData = ref({
  login_tokens: {
    info: "用户登录令牌",
    fields: {
      "b1a2c3d4e5f64780a9b0c1d2e3f4a5b6":
        '{"userId":1,"userName":"admin","nickName":"若依","deptName":"研发部门","ipaddr":"127.0.0.1","loginTime":"2026-10-19 09:30:00","expireTime":"2026-10-19 09:40:00"}',
      "c7d8e9f0a1b24356c7d8e9f0a1b24356":
        '{"userId":2,"userName":"ry","nickName":"普通用户","deptName":"测试部门","ipaddr":"192.168.1.20","loginTime":"2026-10-19 08:15:00","expireTime":"2026-10-19 08:25:00"}',
      "d2e3f4a5b6c74890d1e2f3a4b5c6d7e8":
        '{"userId":3,"userName":"test","nickName":"测试账号","deptName":"测试部门","ipaddr":"10.0.0.15","loginTime":"2026-10-18 21:05:00","expireTime":"2026-10-18 21:15:00"}'
    }
  },
  sys_config: {
    info: "系统配置",
    fields: {
      "sys.index.skinName": "skin-blue",
      "sys.index.sideTheme": "theme-dark",
      "sys.user.initPassword": "123456",
      "sys.account.captchaEnabled": "true",
      "sys.account.registerUser": "false"
    }
  },
  sys_dict: {
    info: "数据字典",
    fields: {
      sys_normal_disable:
        '[{"dictLabel":"正常","dictValue":"0","dictCode":"sys_normal_disable","listClass":"primary"},{"dictLabel":"停用","dictValue":"1","dictCode":"sys_normal_disable","listClass":"danger"}]',
      sys_user_sex:
        '[{"dictLabel":"男","dictValue":"0","dictCode":"sys_user_sex"},{"dictLabel":"女","dictValue":"1","dictCode":"sys_user_sex"},{"dictLabel":"未知","dictValue":"2","dictCode":"sys_user_sex"}]',
      sys_yes_no:
        '[{"dictLabel":"是","dictValue":"Y","dictCode":"sys_yes_no"},{"dictLabel":"否","dictValue":"N","dictCode":"sys_yes_no"}]'
    }
  },
  sys_data_scope: {
    info: "数据权限范围",
    fields: {
      1: "全部数据权限",
      2: "自定义数据权限",
      3: "本部门数据权限",
      4: "本部门及以下数据权限",
      5: "仅本人数据权限"
    }
  }
})

const cacheTableRef = ref(null)
const fieldTableRef = ref(null)

const selectedCache = ref("")
const selectedField = ref("")
/** 二级 key 列表(由 getCachelist 返回的 rows 加载) */
const cacheKeys = ref([])
const fieldLoading = ref(false)
let loadSeq = 0

/** 三级 value(由 getCacheKey 加载) */
const cacheValue = ref("")
const valueLoading = ref(false)
let valueSeq = 0

/** 一级列表 */
const cacheList = computed(() =>
  Object.entries(redisHashData.value).map(([name, item], index) => ({
    num: index + 1,
    name,
    info: item.info
  }))
)

/** 二级列表:接口返回的 rows 为 key 名称字符串数组 */
const fieldList = computed(() =>
  cacheKeys.value.map((field, index) => ({
    num: index + 1,
    field
  }))
)

/** JSON 字符串自动格式化,普通字符串原样展示 */
const formattedValue = computed(() => {
  const raw = cacheValue.value
  if (raw === undefined || raw === null || raw === "") return ""
  if (typeof raw !== "string") {
    return JSON.stringify(raw, null, 2)
  }
  try {
    return JSON.stringify(JSON.parse(raw), null, 2)
  } catch {
    return raw
  }
})

/** 选择一级缓存:通过 getCachelist 加载二级列表 */
async function handleCacheClick(row) {
  if (selectedCache.value === row.name) return
  selectedCache.value = row.name
  selectedField.value = ""
  cacheKeys.value = []
  cacheValue.value = ""
  valueSeq++
  valueLoading.value = false
  fieldTableRef.value?.setCurrentRow()

  fieldLoading.value = true
  try {
    const res = await getCachelist(row.name)
    cacheKeys.value = Array.isArray(res.rows) ? res.rows : []
  } finally {
    fieldLoading.value = false
  }
}

/** 选择二级 key:通过 getCacheKey 加载三级内容 */
async function handleFieldClick(row) {
  selectedField.value = row.field
  cacheValue.value = ""

  // 防止快速切换 key 时旧请求覆盖新请求的内容
  const seq = ++valueSeq
  valueLoading.value = true
  try {
    const res = await getCacheKey(row.field)
    if (seq !== valueSeq) return
    // 后端 success(jsonStr) 会命中 success(String msg) 重载,值放在 msg 中
    cacheValue.value = res.data ?? res.msg ?? ""
  } catch {
    if (seq === valueSeq) cacheValue.value = ""
  } finally {
    if (seq === valueSeq) valueLoading.value = false
  }
}

/** 仅从静态数据中移除,刷新页面后恢复 */
function handleDelete(row) {
  ElMessageBox.confirm(`确定从当前视图删除缓存「${row.name}」吗?`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning"
  })
    .then(() => {
      delete redisHashData.value[row.name]
      if (selectedCache.value === row.name) {
        selectedCache.value = ""
        selectedField.value = ""
        cacheKeys.value = []
        cacheValue.value = ""
        loadSeq++
        valueSeq++
        fieldLoading.value = false
        valueLoading.value = false
        cacheTableRef.value?.setCurrentRow()
      }
      ElMessage.success("已从静态数据中移除")
    })
    .catch(() => {})
}
</script>

<style scoped lang="scss">
.cache-container {
  .card-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }

  .cache-card {
    display: flex;
    flex-direction: column;
    height: 540px;
    margin-bottom: 20px;

    :deep(.el-card__body) {
      flex: 1;
      min-height: 0;
      overflow: hidden;
    }

    :deep(.el-table__row) {
      cursor: pointer;
    }
  }

  .content-card {
    :deep(.el-card__body) {
      overflow: auto;
    }
  }

  .empty-wrap {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
  }

  .value-panel {
    .value-title {
      margin: 16px 0 8px;
      font-weight: 600;
      color: var(--el-text-color-primary);
    }

    .value-content {
      margin: 0;
      padding: 12px;
      min-height: 180px;
      max-height: 260px;
      overflow: auto;
      font-family: Consolas, Monaco, "Courier New", monospace;
      font-size: 12px;
      line-height: 1.6;
      white-space: pre-wrap;
      word-break: break-all;
      color: var(--el-text-color-regular);
      background: var(--el-fill-color-light);
      border-radius: 4px;
    }
  }
}
</style>

四、API 接口封装

在 src/api/system/ 目录下新建 cache.js 文件,封装缓存相关的接口请求。实际项目中,这些接口会调用后端 Controller 提供的 RESTful API。

javascript 复制代码
import request from '@/utils/request'
// 查询缓存列表
export function getCachelist(name) {
return request({
url: /system/test/cache/${name},
method: 'get',
})
}
// 查询缓存 key 列表
export function getCacheKey(name) {
return request({
url: /system/test/cache/key/${name},
method: 'get',
})
}

五、后端接口设计

后端基于 Spring Boot 提供缓存管理接口,核心思路是使用 RedisTemplate 操作 Redis Hash 结构。Controller 层负责接收前端请求并返回统一格式的响应数据。

java 复制代码
package com.ruoyi.system.controller;

import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.core.web.controller.BaseController;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.common.core.web.page.TableDataInfo;
import com.ruoyi.common.redis.service.RedisService;
import com.ruoyi.common.security.annotation.InnerAuth;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.model.LoginUser;
import com.ruoyi.system.service.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.*;

/**
 * 用户信息
 * 
 * @author ruoyi
 */
@RestController
@RequestMapping("/test")
public class SysTestController extends BaseController
{
    @Autowired
    private ISysUserService userService;

    @Autowired
    private ISysPermissionService permissionService;

    @Autowired
    private RedisService redisService;

    /**
     * 获取当前用户信息
     */
//    @InnerAuth   从页面访问,需要去掉 @InnerAuth,否则会报没有权限。
    @GetMapping("/cache/{name}")
    public TableDataInfo cache(@PathVariable("name") String name)
    {
        Collection<String> keys = redisService.keys(name+"*");
        List<String> list = new ArrayList<>(keys);
        return getDataTable(list);
    }

    @GetMapping("/cache/key/{name}")
    public AjaxResult cacheValue(@PathVariable("name") String name)
    {
        Object o =redisService.getCacheObject(name);
        String jsonStr = JSON.toJSONString(o);
        return success(jsonStr);
    }

    /**
     * 获取当前用户信息
     */
    @InnerAuth
    @GetMapping("/info/{username}")
    public R<LoginUser> info(@PathVariable("username") String username)
    {
        SysUser sysUser = userService.selectUserByUserName(username);
        if (StringUtils.isNull(sysUser))
        {
            return R.fail("用户名或密码错误");
        }
        // 角色集合
        Set<String> roles = permissionService.getRolePermission(sysUser);
        // 权限集合
        Set<String> permissions = permissionService.getMenuPermission(sysUser);
        LoginUser sysUserVo = new LoginUser();
        sysUserVo.setSysUser(sysUser);
        sysUserVo.setRoles(roles);
        sysUserVo.setPermissions(permissions);
        return R.ok(sysUserVo);
    }

}

六、接口测试验证

启动后端服务后,通过浏览器或 Postman 访问以下接口进行验证。

6.1 查询缓存列表

访问 localhost:8080/system/test/cache/login_tokens,返回该缓存前缀下的所有 key 列表:

json 复制代码
{
    "total": 1,
    "rows": [
        "login_tokens:3a82bda1-0e32-43fc-b490-c3d7c8b9e101"
    ],
    "code": 200,
    "msg": "查询成功"
}

6.2 查询缓存内容

访问 localhost:8080/system/test/cache/key/login_tokens:3a82bda1-0e32-43fc-b490-c3d7c8b9e101,返回该 key 对应的 value 内容:

json 复制代码
{
    "msg": "{\"expireTime\":1792504275770,\"ipaddr\":\"127.0.0.1\",\"loginTime\":1792461075770,\"permissions\":[\"*:*:*\"],\"roles\":[\"admin\"],\"sysUser\":{\"admin\":true,\"createBy\":\"admin\",\"createTime\":\"2026-10-17 01:15:17\",\"delFlag\":\"0\",\"dept\":{\"ancestors\":\"0,100,101\",\"children\":[],\"deptId\":103,\"deptName\":\"研发部门\",\"leader\":\"若依\",\"orderNum\":1,\"params\":{\"@type\":\"java.util.HashMap\"},\"parentId\":101,\"status\":\"0\"},\"deptId\":103,\"email\":\"ry@163.com\",\"loginDate\":\"2026-10-19 19:37:57\",\"loginIp\":\"127.0.0.1\",\"nickName\":\"若依\",\"params\":{\"@type\":\"java.util.HashMap\"},\"password\":\"$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2\",\"phonenumber\":\"15888888888\",\"pwdUpdateDate\":\"2026-10-16 17:15:17\",\"remark\":\"管理员\",\"roles\":[{\"admin\":true,\"dataScope\":\"1\",\"deptCheckStrictly\":false,\"flag\":false,\"menuCheckStrictly\":false,\"params\":{\"@type\":\"java.util.HashMap\"},\"roleId\":1,\"roleKey\":\"admin\",\"roleName\":\"超级管理员\",\"roleSort\":1,\"status\":\"0\"}],\"sex\":\"1\",\"status\":\"0\",\"userId\":1,\"userName\":\"admin\"},\"token\":\"3a82bda1-0e32-43fc-b490-c3d7c8b9e101\",\"userid\":1,\"username\":\"admin\"}",
    "code": 200
}

七、整体效果

完成上述前后端开发后,整体效果如下:

  • 左侧「缓存列表」展示所有 Redis 缓存 key,点击某一项后,中间栏加载该缓存下的所有 key 名。
  • 中间「key名列表」展示选中缓存下的所有 field,点击某一项后,右侧展示对应的 value 内容。
  • 右侧「缓存内容」以格式化 JSON 的形式展示缓存数据,便于开发人员快速定位问题。
  • 每项缓存支持「删除」操作,点击后弹出确认框,确认后从 Redis 中删除对应缓存。

该模块通过三级联动的方式,将 Redis 中散落的缓存数据组织成清晰的树形浏览结构。效果图如下

相关推荐
杨运交1 小时前
080-缓存模块-Spring Cache缓存键前缀设计与租户隔离实践
java·spring·缓存
Pniubi20 小时前
力扣146.LRU缓存机制
算法·leetcode·缓存
欣栀♡21 小时前
Spring 循环依赖不止三级缓存,Boot 2.6 起这个开关默认已经关了
java·spring·缓存
xiaoqiMikko1 天前
Redis 实战(二):宕机会丢多少数据 —— kill -9 与真断电下 RDB / AOF 逐个实测(6.2.4)
redis·后端
m0_639906941 天前
HashMap:哈希扰动、扩容源码、红黑树转换、并发问题
java·算法·哈希算法
夕除1 天前
redis--ziplist
redis
2301_32241428041 天前
基于SSM框架的在线考试管理系统的设计与实现14750-计算机毕设 原创(免费领源码+部署教程+开发环境)
java·redis·mysql·tomcat·maven·mybatis·ssm
我星期八休息1 天前
Redis 的原理和使用(2)
前端·数据库·redis·python·mysql·缓存·bootstrap
多弗朗皮卡丘1 天前
C++ 哈希(下)
c++·算法·哈希算法