本章目标
上一章我们完成了 Vue 3 用户端前端实战。
用户端解决的是普通用户怎么使用系统:
text
登录
-> 创建知识库
-> 上传文档
-> 等待索引
-> 向量检索
-> RAG 问答
-> 查看引用和模型状态
但是,一个企业级平台不能只有用户端。
因为普通用户只能看到自己的数据,而系统维护者需要知道整个平台的运行情况:
- 当前有哪些用户。
- 哪些用户是管理员。
- 哪些知识库被创建了。
- 哪些文档索引成功或失败。
- 哪些索引任务一直卡住。
- 哪些问答触发了 AI 降级。
- 项目演示时,应该怎么快速展示完整能力。
这些事情就需要后台管理端。
在 KnowHub 中,后台管理端对应 rag-admin-web,后端接口统一使用 /admin/** 路径,并由 Gateway 校验管理员权限。
本章要讲清楚四件事:
- 后台管理端为什么不是用户端的重复。
- USER 和 ADMIN 这套基础 RBAC 是怎么落地的。
rag-admin-web如何组织登录、用户、知识库、文档、任务和问答日志视图。- 管理端如何支撑项目演示和问题排查。
17.1 为什么需要后台管理端
先从一个简单问题开始:
text
普通用户能看到自己的知识库,为什么还要做管理端?
原因很现实。
普通用户关注的是"我的业务能不能完成"。管理员关注的是"整个平台是否健康"。
两者视角不同。
普通用户进入用户端后,看到的是:
- 我的知识库
- 我的文档
- 我的问答
- 我的任务状态
管理员进入管理端后,看到的是:
- 全部用户
- 全部知识库
- 全部文档
- 全部索引任务
- 全部问答日志
也就是说,用户端是个人工作台,管理端是平台观察台。
在 RAG 平台里,管理端尤其重要。因为 RAG 系统的链路比普通 CRUD 系统更长:
text
登录认证
-> 文件上传
-> 文档解析
-> 文本切片
-> 异步任务
-> 向量化
-> pgvector 写入
-> 检索召回
-> 模型生成
-> 限流降级
-> 日志记录
任何一个环节出问题,普通用户通常只能看到"失败"或"没有答案"。管理员则需要根据后台数据判断问题在哪里。
所以管理端不是可有可无的页面,而是平台治理、排查和演示的重要入口。
17.2 后台管理端和用户端的区别
第 16 章的 rag-user-web 面向普通用户。
第 17 章的 rag-admin-web 面向管理员。
它们都用 Vue 3 和 Element Plus,但目标完全不同。
用户端强调的是流程:
text
让用户完成知识库问答。
管理端强调的是观察和控制:
text
让管理员看到系统全局状态,并对关键对象进行处理。
可以用下面这张对照表理解。
| 对比项 | 用户端 | 管理端 |
|---|---|---|
| 使用者 | 普通用户 | 管理员 |
| 数据范围 | 当前用户自己的资源 | 全局资源 |
| 核心页面 | 知识库工作台 | 后台控制台 |
| 核心操作 | 上传、检索、问答 | 查询、过滤、角色调整、任务重试 |
| 权限要求 | 登录即可 | 必须是 ADMIN |
| 主要价值 | 完成业务流程 | 排查、治理、演示 |
这里要强调一个原则:
text
管理端权限不能只靠前端判断。
前端可以判断当前登录用户是不是 ADMIN,但这只是为了改善体验。真正的权限边界必须在 Gateway 和后端。
否则,用户只要绕过前端,直接请求 /admin/** 接口,就可能访问管理员数据。
KnowHub 的做法是:
text
前端检查 ADMIN -> Gateway 再检查 ADMIN -> 后端只暴露 /admin/** 管理接口
这样权限边界更可靠。
17.3 基础 RBAC:USER 与 ADMIN
RBAC 是 Role-Based Access Control 的缩写,意思是"基于角色的访问控制"。
不要被这个词吓到。
在 KnowHub 当前阶段,RBAC 很简单,只区分两个角色:
USER:普通用户。ADMIN:管理员。
角色定义在 rag-common 的 UserRole 中:
java
public enum UserRole {
USER,
ADMIN;
}
当前系统没有做复杂的"菜单权限、按钮权限、数据权限规则引擎"。这是刻意控制范围。
对于一个学习型企业 RAG 项目,先把两个问题做好更重要:
- 普通用户不能访问后台接口。
- 管理员可以查看全局数据。
role 字段从哪里来
数据库里通过脚本给 user_account 表增加 role 字段。
脚本位置是:
text
sql/mysql/02_admin_rbac_schema.sql
核心逻辑是:
sql
ALTER TABLE user_account
ADD COLUMN role VARCHAR(16) NOT NULL DEFAULT 'USER'
COMMENT '用户角色:USER=普通用户,ADMIN=管理员'
AFTER nickname;
默认注册出来的用户都是 USER。
如果要创建管理员,当前项目采用一个很适合教学演示的做法:
sql
UPDATE user_account SET role = 'ADMIN' WHERE username = '你的管理员用户名';
也就是说,先正常注册账号,再手动把这个账号升级为管理员。
这种方式虽然不是完整商业系统里的管理员开通流程,但非常适合学习和演示,因为它简单、可控、不会把管理员密码写进 SQL。
17.4 JWT 中的 role 如何被 Gateway 使用
有了数据库里的 role 字段还不够。
前端访问后台接口时,Gateway 不会每次都去查数据库,它主要看 Token。
所以登录成功签发 JWT 时,必须把角色写进 Token。
JwtTokenService 里有一个 ROLE_CLAIM:
java
private static final String ROLE_CLAIM = "role";
签发 Token 时,会写入:
java
.claim(ROLE_CLAIM, UserRole.normalize(role))
解析 Token 时,又会把它取出来形成 UserClaims。
这样 Gateway 就知道当前请求者是 USER 还是 ADMIN。
Gateway 怎么挡住普通用户
Gateway 的全局过滤器是 JwtAuthGlobalFilter。
它里面有一个后台路径前缀:
java
private static final String ADMIN_PATH_PREFIX = "/admin/";
如果请求路径是 /admin/ 开头,但 Token 中的角色不是 ADMIN,就会抛出异常:
java
throw new BusinessException(ErrorCode.FORBIDDEN, "只有管理员可以访问后台接口");
这一步非常关键。
因为它说明管理端权限不是页面自己说了算,而是由统一网关负责拦截。
Gateway 还会覆盖用户头
过滤器里还有一个很重要的安全细节:
text
覆盖外部伪造的用户头,只信任 JWT 解析出的用户身份。
也就是说,即使有人自己构造请求头:
text
X-User-Id: 1
X-User-Role: ADMIN
Gateway 也不会直接相信,而是先删掉外部传入的头,再根据 JWT 重新写入可信用户信息。
这就是企业级系统必须具备的安全意识。
17.4.4 JwtAuthGlobalFilter 管理端权限校验逻辑
下面给出 JwtAuthGlobalFilter 中和管理端权限相关的核心代码片段。
这段逻辑通常放在 Token 已经校验通过之后、向下游服务透传 X-User-Id 和 X-User-Role 之前。也就是说,Gateway 先确认"你是谁",再确认"你是不是管理员",最后才把可信身份传给后端服务。
java
private static final String ADMIN_PATH_PREFIX = "/admin/";
private static final String ROLE_ADMIN = "ADMIN";
private Mono<Void> filterAdminRequest(ServerWebExchange exchange,
GatewayFilterChain chain,
UserClaims claims) {
String path = exchange.getRequest().getURI().getPath();
// 只有 `/admin/**` 需要进入管理员校验;普通用户端接口继续走原有登录校验。
if (!path.startsWith(ADMIN_PATH_PREFIX)) {
return chain.filter(exchange);
}
// claims 来自已经校验通过的 JWT,不信任前端自己传入的 `X-User-Role`。
String role = claims.getRole();
if (!ROLE_ADMIN.equalsIgnoreCase(role)) {
throw new BusinessException(
ErrorCode.FORBIDDEN,
"只有管理员可以访问后台接口"
);
}
// 管理员校验通过后,再进入后续的用户信息透传逻辑。
return chain.filter(exchange);
}
这里的关键点不是代码长短,而是边界位置:
text
JWT 校验通过 -> 判断 `/admin/**` 是否要求 ADMIN -> 覆盖并透传用户身份 -> 路由到后端服务
这样即使普通用户绕过管理端页面,直接调用 /admin/users,也会被 Gateway 拦住。
17.5 Gateway 后台路由怎么分发
管理端前端只访问 Gateway,不直接访问具体服务。
Gateway 再把不同 /admin/** 路径转发到不同微服务。
在 rag-gateway-service 的 application.yml 中,后台路由分成三组。
用户管理路由
text
/admin/users/** -> rag-auth-service
用户账号、角色、状态都属于认证服务,所以用户管理接口放在 auth-service。
知识库管理路由
text
/admin/knowledge-bases/**
/admin/documents/**
/admin/qa-logs/**
-> rag-knowledge-service
知识库、文档和问答日志都是知识库服务的数据,所以它们进入 knowledge-service。
索引任务路由
text
/admin/index-tasks/** -> rag-task-service
索引任务的执行和重试属于 task-service,所以后台任务管理接口转发到 task-service。
这和前面微服务拆分章节是一致的:
text
auth-service 管用户
knowledge-service 管知识库和问答
task-service 管索引任务
Gateway 管统一入口和鉴权
17.5.4 Gateway 后台路由完整配置
下面给出 rag-gateway-service/src/main/resources/application.yml 中后台路由的完整配置示例。
yaml
spring:
cloud:
gateway:
routes:
- id: admin-user-routes
uri: lb://auth-service
predicates:
- Path=/admin/users/**
filters:
# 去掉 /admin 前缀,auth-service 实际收到的是 /users/**
- StripPrefix=1
- id: admin-kb-doc-routes
uri: lb://knowledge-service
predicates:
- Path=/admin/knowledge-bases/**,/admin/documents/**
filters:
# 去掉 /admin 前缀,knowledge-service 实际收到的是 /knowledge-bases/** 或 /documents/**
- StripPrefix=1
- id: admin-task-log-routes
uri: lb://task-service
predicates:
- Path=/admin/index-tasks/**,/admin/qa-logs/**
filters:
# 去掉 /admin 前缀,task-service 实际收到的是 /index-tasks/** 或 /qa-logs/**
- StripPrefix=1
StripPrefix=1 的作用是去掉路径中的第一段 /admin。
例如前端请求:
text
GET /admin/users
经过 Gateway 后,auth-service 实际收到的是:
text
GET /users
所以后端管理控制器上的 @RequestMapping 不需要再写 /admin 前缀。/admin 是 Gateway 层的统一入口语义,后端服务只关心自己的资源路径。
17.6 rag-admin-web 项目结构
rag-admin-web 是一个 Vue 3 管理端前端。
它的文件比用户端更集中。
核心文件有四个:
src/main.tssrc/api.tssrc/App.vuesrc/styles.css
虽然 package.json 里也安装了 Pinia 和 Vue Router,但当前管理端实现主要是一个单页控制台,没有像用户端那样拆出多个路由页面和 store。
这点要如实理解。
main.ts
入口非常简单:
ts
createApp(App).use(ElementPlus).mount('#app')
这说明当前管理端只注册了 ElementPlus,没有额外注册 Router 或 Pinia。
api.ts
这个文件比较重要,它承担了四类职责:
- 保存管理员 Token。
- 创建 Axios 请求实例。
- 定义接口返回类型。
- 封装后台接口函数。
管理端 Token 存储 key 是:
ts
const TOKEN_KEY = 'rag_admin_token'
这和用户端的 rag_user_token 分开,避免两个前端登录态互相影响。
管理端默认后端地址是:
ts
const BASE_URL = import.meta.env.VITE_RAG_GATEWAY_URL || 'http://localhost:9000'
这说明它也是默认访问 Gateway 的 9000 端口。
App.vue
当前所有管理端页面逻辑都集中在 App.vue。
它包含:
- 管理员登录页。
- 左侧导航。
- 顶部栏。
- 用户管理视图。
- 知识库管理视图。
- 文档管理视图。
- 索引任务视图。
- 问答日志视图。
这种写法不是大型后台系统的最终形态,但对于当前项目演示非常实用。
因为它减少了文件数量,让读者能在一个组件里看完整后台控制台的基本逻辑。
17.6.6 管理员接口示例:AdminUserController
下面给出 auth-service 中 AdminUserController 的完整示例。
注意这里的路径是 /users,不是 /admin/users。因为 Gateway 已经通过 StripPrefix=1 去掉了 /admin 前缀。
java
package com.knowhub.auth.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.knowhub.auth.entity.UserAccount;
import com.knowhub.auth.service.UserAccountService;
import com.knowhub.common.context.UserContext;
import com.knowhub.common.exception.BusinessException;
import com.knowhub.common.exception.ErrorCode;
import com.knowhub.common.response.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class AdminUserController {
private final UserAccountService userAccountService;
@GetMapping
public ApiResponse<List<UserAccount>> listUsers(@RequestParam(required = false) String username,
@RequestParam(required = false) String role) {
requireAdmin();
LambdaQueryWrapper<UserAccount> wrapper = new LambdaQueryWrapper<>();
// 管理端查询全局用户列表,不带 userId 过滤;这和用户端"只查当前用户资源"不同。
wrapper.like(StringUtils.hasText(username), UserAccount::getUsername, username)
.eq(StringUtils.hasText(role), UserAccount::getRole, role)
.orderByDesc(UserAccount::getCreatedAt);
return ApiResponse.success(userAccountService.list(wrapper));
}
@PutMapping("/{userId}/role")
public ApiResponse<Void> updateRole(@PathVariable Long userId,
@RequestParam String role) {
requireAdmin();
if (!"USER".equals(role) && !"ADMIN".equals(role)) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "role 只能是 USER 或 ADMIN");
}
UserAccount entity = new UserAccount();
entity.setId(userId);
entity.setRole(role);
userAccountService.updateById(entity);
return ApiResponse.success(null);
}
@PutMapping("/{userId}/status")
public ApiResponse<Void> updateStatus(@PathVariable Long userId,
@RequestParam Boolean enabled) {
requireAdmin();
UserAccount entity = new UserAccount();
entity.setId(userId);
entity.setEnabled(Boolean.TRUE.equals(enabled));
userAccountService.updateById(entity);
return ApiResponse.success(null);
}
private void requireAdmin() {
// Gateway 已经做过 ADMIN 校验,这里保留二次防御,避免服务被绕过直连。
if (!"ADMIN".equalsIgnoreCase(UserContext.requireRole())) {
throw new BusinessException(ErrorCode.FORBIDDEN, "只有管理员可以访问后台接口");
}
}
}
用户端的知识库列表接口通常会带上:
sql
WHERE user_id = currentUserId
而管理端的 GET /admin/users 面向全局观察,不按当前用户过滤。这就是管理端接口和用户端接口最根本的区别。
17.6.7 管理员接口示例:AdminIndexTaskController
下面给出 task-service 中 AdminIndexTaskController 的关键方法代码。
java
package com.knowhub.task.admin;
import com.knowhub.common.context.UserContext;
import com.knowhub.common.exception.BusinessException;
import com.knowhub.common.exception.ErrorCode;
import com.knowhub.common.response.ApiResponse;
import com.knowhub.task.model.response.IndexTaskResponse;
import com.knowhub.task.service.IndexTaskService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/index-tasks")
@RequiredArgsConstructor
public class AdminIndexTaskController {
private final IndexTaskService indexTaskService;
@GetMapping
public ApiResponse<List<IndexTaskResponse>> listTasks(@RequestParam(required = false) String status,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) Long kbId) {
requireAdmin();
// 管理端查询全局索引任务,不带 WHERE user_id = currentUserId。
return ApiResponse.success(indexTaskService.listAdminTasks(status, userId, kbId));
}
@PostMapping("/{taskId}/retry")
public ApiResponse<Void> retry(@PathVariable Long taskId) {
requireAdmin();
// 对应第 9 章的索引任务可靠性设计:只有 FAILED/TIMEOUT 且未超过最大重试次数的任务允许重试。
indexTaskService.retryFailedTask(taskId);
return ApiResponse.success(null);
}
private void requireAdmin() {
if (!"ADMIN".equalsIgnoreCase(UserContext.requireRole())) {
throw new BusinessException(ErrorCode.FORBIDDEN, "只有管理员可以访问后台接口");
}
}
}
真正的状态校验应该放在 IndexTaskService.retryFailedTask(taskId) 中完成:
text
`status` 必须是 `FAILED` 或 `TIMEOUT`
`retry_count` 必须小于 `max_retry_count`
重新投递 `RabbitMQ` 索引消息
这样即使未来有多个入口都能触发重试,也不会绕过任务状态机。
17.7 管理端登录流程
17.7.1 如何启动管理端前端
管理端前端的启动方式和第 16 章的用户端类似。
前置要求:
- Node.js 16+
- npm
进入 rag-admin-web 目录:
bash
cd rag-admin-web
首次运行先安装依赖:
bash
npm install
启动开发服务器:
bash
npm run dev
启动成功后,Vite 会在控制台输出本地访问地址。管理端通常运行在:
text
http://localhost:5174
如果用户端 rag-user-web 已经占用了 5173,Vite 会自动分配下一个可用端口。实际访问地址以控制台输出为准。
管理端也需要通过 Gateway 调用后端接口。可以在 .env.development 中配置:
env
VITE_API_BASE_URL=http://localhost:9000
如果 Gateway 端口不是 9000,这里要同步修改。
管理端和用户端可以同时启动:
text
用户端:http://localhost:5173
管理端:http://localhost:5174
后端统一入口:http://localhost:9000
两端都不直接访问具体微服务,而是统一经过 Gateway。这样才能让 JWT 校验、ADMIN 权限判断和路由转发保持一致。
管理端登录仍然调用普通登录接口:
text
/auth/login
为什么不单独做一个 /admin/login?
因为登录本质上都是认证行为,由 auth-service 统一负责就够了。
区别在于:登录成功后,管理端前端会检查返回用户是不是 ADMIN。
在 App.vue 中,登录成功后有这样的判断:
ts
if (result.role !== 'ADMIN') {
tokenStore.clear()
ElMessage.error('当前账号不是管理员')
return
}
这一步是前端体验层面的拦截。
如果普通用户用管理端登录,即使用户名密码正确,也不会进入后台页面。
但是要注意:这不是最终安全边界。
真正的安全边界仍然是 Gateway 对 /admin/** 的 ADMIN 校验。
刷新页面后怎么恢复登录
管理端启动后会执行:
ts
onMounted(loadCurrentUser)
如果本地有 rag_admin_token,就调用:
text
/auth/me
后端返回当前用户信息后,再检查 role 是否是 ADMIN。
如果不是 ADMIN,就清理 Token。
这个流程和用户端相似,但管理端多了一步角色校验。
17.7.3 App.vue 核心结构
当前管理端采用"单页多 Tab"的组织方式,而不是拆成多个路由页面。
原因很简单:本章管理端的目标是全局观察和演示支撑,页面数量少,交互链路短,使用一个 App.vue 能让读者更快看到完整控制台是如何组织数据的。
vue
<template>
<el-container class="admin-layout">
<el-header class="admin-header">
<div class="brand">KnowHub 管理端</div>
<div class="user-area">
<span>{{ currentUsername }}</span>
<el-button type="danger" plain size="small" @click="logout">退出</el-button>
</div>
</el-header>
<el-main>
<el-tabs v-model="activeTab">
<el-tab-pane label="用户管理" name="users">
<el-table :data="users" border>
<el-table-column prop="id" label="用户ID" width="100" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="role" label="角色" />
<el-table-column prop="enabled" label="状态" />
</el-table>
</el-tab-pane>
<el-tab-pane label="知识库" name="knowledgeBases">
<el-table :data="knowledgeBases" border>
<el-table-column prop="id" label="知识库ID" />
<el-table-column prop="userId" label="用户ID" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="status" label="状态" />
</el-table>
</el-tab-pane>
<el-tab-pane label="文档" name="documents">
<el-table :data="documents" border>
<el-table-column prop="id" label="文档ID" />
<el-table-column prop="kbId" label="知识库ID" />
<el-table-column prop="fileName" label="文件名" />
<el-table-column prop="indexStatus" label="索引状态" />
</el-table>
</el-tab-pane>
<el-tab-pane label="任务" name="tasks">
<admin-tasks :tasks="tasks" />
</el-tab-pane>
<el-tab-pane label="日志" name="logs">
<el-table :data="logs" border>
<el-table-column prop="id" label="日志ID" />
<el-table-column prop="userId" label="用户ID" />
<el-table-column prop="question" label="问题" />
<el-table-column prop="modelFallback" label="是否降级" />
<el-table-column prop="costTimeMs" label="总耗时(ms)" />
</el-table>
</el-tab-pane>
</el-tabs>
</el-main>
</el-container>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { removeToken } from './utils/token'
import AdminTasks from './views/AdminTasks.vue'
const router = useRouter()
const activeTab = ref('users')
const currentUsername = ref('admin')
// 五个全局数据数组分别对应管理端的五个观察视图。
const users = ref([])
const knowledgeBases = ref([])
const documents = ref([])
const tasks = ref([])
const logs = ref([])
function logout() {
removeToken()
router.push('/login')
}
</script>
如果后续管理端页面变多,再拆成 views/UserManage.vue、views/TaskManage.vue 和路由表会更合适。当前阶段先用一个控制台把全局数据讲清楚。
17.8 用户管理视图
用户管理是后台管理端的第一个视图。
它对应后端接口:
text
GET /admin/users
PUT /admin/users/{userId}/role
PUT /admin/users/{userId}/status
后端控制器是:
text
AdminUserController
用户列表查询
用户列表支持三个过滤条件:
keyword:用户名或昵称。role:USER 或 ADMIN。status:正常或禁用。
后端查询时限制最多返回 100 条:
text
LIMIT 100
这是一种很简单的保护手段,避免后台页面一次拉出过多数据。
修改角色
页面允许把用户角色改为:
- USER
- ADMIN
后端并不是直接相信前端传来的字符串,而是调用 normalizeStrictRole 校验。
如果角色不是 USER 或 ADMIN,就返回参数错误。
这说明后端仍然要做校验,不能依赖前端下拉框。
启用和禁用用户
用户状态只能是:
1:正常0:禁用
请求 DTO 使用了 @Min(0) 和 @Max(1)。
这类校验很基础,但非常有必要。因为后台管理端修改的是平台级数据,不能随意接受非法状态值。
17.9 知识库管理视图
知识库管理视图对应:
text
GET /admin/knowledge-bases
它由 knowledge-service 的 AdminKnowledgeController 提供。
支持过滤条件:
userIdstatuskeyword
这里最重要的差异是数据范围。
用户端的知识库列表只能看当前用户自己的知识库。
管理端可以按用户 ID 查看全局知识库。
这就是"用户端"和"管理端"的本质区别。
管理端知识库表格展示:
- 知识库 ID
- 用户 ID
- 名称
- 描述
- 状态
- 创建时间
这对演示很有帮助。
比如你可以先用普通用户创建知识库,然后切到管理端,立即看到这条知识库记录出现。
这样老师、HR 或面试官就能直观看到:
text
普通用户操作产生的数据,可以被管理员从全局视角观察到。
17.10 文档管理视图
文档管理视图对应:
text
GET /admin/documents
它支持:
userIdkbIdindexStatuskeyword
页面展示:
- 文档 ID
- 用户 ID
- 知识库 ID
- 文件名
- 文件类型
- 索引状态
- 切片数量
- 创建时间
这一页和第 6 章到第 8 章关系很密切。
上传文档后,系统不是只保存一个文件名,而是会形成文档元数据:
text
document_info
文档的 indexStatus 会随着任务执行发生变化。
所以文档管理视图可以用来回答这些问题:
- 哪个用户上传了文档?
- 文档属于哪个知识库?
- 文档当前是否索引完成?
- 切片数量是不是合理?
- 是否存在大量失败文档?
这些问题普通用户端不适合全局展示,但管理员必须能看见。
17.10.5 AdminTasks.vue 完整代码
索引任务管理页面的重点不是复杂前端交互,而是让管理员看到全局任务状态,并能对失败任务发起重试。
下面给出 rag-admin-web/src/views/AdminTasks.vue 的完整代码示例。
vue
<template>
<section class="admin-section">
<el-form :inline="true" :model="queryForm" class="query-form">
<el-form-item label="状态">
<el-select v-model="queryForm.status" clearable placeholder="全部状态" style="width: 160px">
<el-option label="FAILED" value="FAILED" />
<el-option label="TIMEOUT" value="TIMEOUT" />
<el-option label="RUNNING" value="RUNNING" />
<el-option label="SUCCESS" value="SUCCESS" />
</el-select>
</el-form-item>
<el-form-item label="用户ID">
<el-input-number v-model="queryForm.userId" :min="1" controls-position="right" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadTasks">查询</el-button>
</el-form-item>
</el-form>
<el-table :data="tasks" border v-loading="loading">
<el-table-column prop="taskId" label="任务ID" width="100" />
<el-table-column prop="documentId" label="文档ID" width="100" />
<el-table-column prop="kbId" label="知识库ID" width="110" />
<el-table-column prop="userId" label="用户ID" width="100" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column prop="retryCount" label="重试次数" width="100" />
<el-table-column prop="errorMessage" label="错误信息" min-width="220" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="180" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-if="canRetry(row.status)"
type="warning"
link
@click="handleRetry(row.taskId)"
>
重试
</el-button>
</template>
</el-table-column>
</el-table>
</section>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAdminIndexTasks, retryIndexTask, type IndexTask } from '../api/api'
const loading = ref(false)
const tasks = ref<IndexTask[]>([])
const queryForm = reactive<{
status?: string
userId?: number
}>({
status: undefined,
userId: undefined
})
async function loadTasks() {
loading.value = true
try {
tasks.value = await getAdminIndexTasks({
status: queryForm.status,
userId: queryForm.userId
})
} finally {
loading.value = false
}
}
function canRetry(status: string) {
// 只允许 FAILED 和 TIMEOUT 重试,避免破坏第 9 章定义的任务状态机。
return status === 'FAILED' || status === 'TIMEOUT'
}
async function handleRetry(taskId: number) {
await retryIndexTask(taskId)
ElMessage.success('重试消息已投递')
await loadTasks()
}
onMounted(loadTasks)
</script>
这个页面要展示的是"任务为什么失败、能不能恢复"。所以 errorMessage、retryCount 和"重试"按钮比页面样式更重要。
17.11 索引任务视图
索引任务视图对应:
text
GET /admin/index-tasks
POST /admin/index-tasks/{taskId}/retry
后端控制器是 Task-Service 中的:
text
`AdminIndexTaskController`
查询任务
后端支持按这些字段过滤:
userIdkbIddocumentIdstatus
当前管理端前端页面主要开放了 status 过滤。
这也是一个可以继续扩展的地方:以后可以把 userId、kbId、documentId 也加入筛选表单。
任务状态
页面会展示任务状态标签。
常见状态包括:
- WAITING
- RUNNING
- SUCCESS
- FAILED
- TIMEOUT
- RETRYING
不同状态对应不同颜色:
- SUCCESS / INDEXED:成功色。
- FAILED / TIMEOUT:危险色。
- RUNNING / INDEXING / RETRYING:警告色。
这和用户端工作台的状态展示保持一致。
失败任务重试
任务视图最重要的操作是重试。
当前页面只允许对:
- FAILED
- TIMEOUT
状态的任务点击重试。
这对应前面第 9 章的索引任务可靠性设计。
失败不是终点,而是可恢复状态。
管理员看到任务失败后,可以在后台触发:
text
POST /admin/index-tasks/{taskId}/retry
后端再交给 IndexTaskService 处理。
这一步把"排查"和"恢复"连起来了。
17.12 问答日志视图
问答日志视图对应:
text
GET /admin/qa-logs
它支持:
userIdkbIdmodelFallback
页面展示:
- 问答日志 ID
- 用户 ID
- 知识库 ID
- 问题
- 模型名称
- 模型状态
- 总耗时
- 创建时间
这里最关键的是模型状态。
页面会根据返回字段显示:
text
modelFallback = true -> 降级
modelSuccess = true -> 成功
否则 -> 失败
这和第 12 章、第 14 章、第 15 章完全对应。
如果用户说"系统没有正常回答",管理员可以打开问答日志,看它到底是:
- 检索结果为空。
- 模型调用失败。
- 模型超时。
- Sentinel 限流。
- 触发了 fallback。
当前管理端表格主要展示概览字段,没有展开完整答案和完整错误详情。作为演示台已经够用;如果要做更完整的企业后台,可以增加详情抽屉,把 answer、modelErrorMessage、引用片段和 Prompt 上下文展示出来。
17.13 管理端 API 封装
src/api.ts 是管理端前端的请求中心。
它定义了多个 TypeScript 接口:
LoginResponseCurrentUserAdminUserAdminKnowledgeBaseAdminDocumentAdminIndexTaskAdminQaLog
这些类型的作用是让前端知道后端会返回哪些字段。
比如 AdminIndexTask 包含:
iddocumentIdkbIduserIdstatusretryCountmaxRetryCounterrorMessageworkerIdstartTimeendTime
这正好对应任务管理页面需要展示和排查的信息。
请求解包
管理端同样使用统一响应体:
ts
export interface ApiResponse<T> {
code: number
message: string
data: T
}
如果 code !== 0,就抛出错误:
ts
throw new Error(response.data.message || '请求失败')
这样页面层只需要 try/catch,不用每个接口都重复判断业务状态码。
Token 自动携带
请求拦截器会读取 rag_admin_token,并写入:
text
Authorization: Bearer token
这一点和用户端是一致的。
也就是说,用户端和管理端虽然页面不同,但安全访问模式相同:
text
前端保存 Token
-> Axios 自动带 Token
-> Gateway 验证 Token
-> Gateway 判断角色
-> Gateway 转发到后端服务
17.13.5 api.ts 完整代码
下面给出 rag-admin-web/src/api/api.ts 的完整示例。所有接口都以 /admin 开头,都会经过 Gateway 的 ADMIN 角色校验。
ts
import request from '../utils/request'
export interface User {
id: number
username: string
nickname?: string
role: 'USER' | 'ADMIN'
enabled: boolean
createdAt: string
}
export interface KnowledgeBase {
id: number
userId: number
name: string
description?: string
status: string
createdAt: string
}
export interface Document {
id: number
kbId: number
userId: number
fileName: string
fileType: string
indexStatus: string
chunkCount: number
errorMessage?: string
createdAt: string
}
export interface IndexTask {
taskId: number
documentId: number
kbId: number
userId: number
status: string
retryCount: number
maxRetryCount: number
errorMessage?: string
createdAt: string
}
export interface QaLog {
id: number
kbId: number
userId: number
question: string
answer: string
modelName: string
costTimeMs: number
modelSuccess: boolean
modelFallback: boolean
createdAt: string
}
// 用户管理:由 Gateway 转发到 auth-service。
export function getUsers(params?: { username?: string; role?: string }) {
return request.get<User[]>('/admin/users', { params })
}
export function updateUserRole(userId: number, role: string) {
return request.put<void>(`/admin/users/${userId}/role`, null, {
params: { role }
})
}
export function updateUserStatus(userId: number, enabled: boolean) {
return request.put<void>(`/admin/users/${userId}/status`, null, {
params: { enabled }
})
}
// 知识库与文档:由 Gateway 转发到 knowledge-service。
export function getAllKnowledgeBases(params?: { userId?: number; keyword?: string }) {
return request.get<KnowledgeBase[]>('/admin/knowledge-bases', { params })
}
export function getAllDocuments(params?: { userId?: number; kbId?: number }) {
return request.get<Document[]>('/admin/documents', { params })
}
// 索引任务:由 Gateway 转发到 task-service。
export function getAdminIndexTasks(params?: { status?: string; userId?: number }) {
return request.get<IndexTask[]>('/admin/index-tasks', { params })
}
export function retryIndexTask(taskId: number) {
return request.post<void>(`/admin/index-tasks/${taskId}/retry`)
}
// 问答日志:管理端按全局维度查询,用于观察模型调用、耗时和降级情况。
export function getAdminQaLogs(params?: { userId?: number; kbId?: number; modelFallback?: boolean }) {
return request.get<QaLog[]>('/admin/qa-logs', { params })
}
这些方法和用户端 API 的区别在于:用户端接口强调"当前用户自己的数据",管理端接口强调"全局数据 + 条件筛选"。权限边界不靠这些 TypeScript 方法保证,而是靠 Gateway 的 /admin/** ADMIN 校验保证。
17.14 管理端如何支持项目演示
这一章标题里还有"演示支持"。
这是因为对于学生项目、毕业设计、实习展示和面试作品来说,光有功能还不够,还要能讲清楚、演示清楚。
KnowHub 的演示可以按下面的顺序准备。
第一步:准备两个普通用户和一个管理员
先注册几个账号:
text
user_a
user_b
admin
然后把 admin 升级为管理员:
sql
UPDATE user_account SET role = 'ADMIN' WHERE username = 'admin';
重新登录管理端后,JWT 中就会携带 role=ADMIN。
第二步:普通用户创建知识库
用 user_a 登录用户端,创建一个知识库。
再进入管理端的"知识库"视图,按 userId 或 keyword 查询。
这样可以展示:
text
用户端产生业务数据,管理端能从全局视角观察。
第三步:上传文档并观察索引
在用户端上传文档,然后切到管理端的"文档"和"任务"视图。
你可以展示:
- 文档记录出现。
indexStatus变化。- 索引任务出现。
- 成功后任务变成 SUCCESS。
- 失败后任务出现 errorMessage。
这一步能把第 7 章到第 11 章的内容串起来。
第四步:发起 RAG 问答
在用户端问一个和文档相关的问题。
然后在管理端打开"问答日志"。
你可以展示:
- 问题记录。
- 使用的模型。
- 模型是否成功。
- 是否发生降级。
- 总耗时。
这一步能把第 12 章到第 15 章串起来。
第五步:演示异常和恢复
如果你想展示项目更有工程含量,可以准备一次失败演示。
比如:
- 停掉模型服务,让问答触发 fallback。
- 制造索引失败任务,再在管理端点击重试。
- 调低 Sentinel 阈值,演示限流和降级。
演示时不要只说"我做了 AI 问答"。
更好的表达是:
text
这个系统不仅能回答问题,还能观察文档索引状态、任务失败原因、模型调用状态和降级日志。
这会比单纯展示一个问答框更有说服力。
17.14.7 完整演示操作步骤
下面把前面五步演示目标展开成可以直接照着做的操作步骤。
步骤一:准备管理员账号(详见 17.3 基础 RBAC)
用 SQL 工具连接 MySQL,执行:
sql
UPDATE user_account SET role = 'ADMIN' WHERE username = 'admin';
确认 admin 账号的 role 字段已经变为 ADMIN。然后重新登录管理端,让新签发的 JWT 携带 role=ADMIN。
步骤二:启动管理端前端(详见 17.7.1 如何启动管理端前端)
进入 rag-admin-web 目录:
bash
npm run dev
在浏览器打开:
text
http://localhost:5174
看到登录页后,输入 admin 账号和密码登录。预期跳转到管理端主页,并看到"用户管理、知识库、文档、任务、日志"五个 Tab。
步骤三:查看全局用户(详见 17.8 用户管理视图)
切换到"用户管理" Tab,点击查询。
预期结果:
- 能看到全部用户列表,包括
user_a、user_b和admin。 admin的role列显示为ADMIN。- 普通用户的
role列显示为USER。
这一步展示的是管理端和用户端的核心差异:管理端查的是全局用户数据。
步骤四:用户端创建数据,管理端观察全局数据(详见第 16 章用户端工作台、17.9 和 17.10)
用 user_a 登录用户端:
text
http://localhost:5173
创建一个知识库,并上传一个测试文档。
然后切换回管理端,在"知识库"和"文档" Tab 中按 userId 查询。
预期结果:
- 能看到
user_a创建的知识库。 - 能看到该知识库下的文档。
- 文档的
indexStatus会从INDEXING变为INDEXED或FAILED。
步骤五:发起问答并查看 qa_log(详见第 12 章 RAG 问答闭环、17.12 问答日志视图)
在用户端发起一次和测试文档相关的问答。
切换到管理端的"问答日志" Tab,按 userId 查询。
预期结果:
- 能看到刚才的问答记录。
- 记录中包含问题、答案、模型名称、总耗时。
modelSuccess和modelFallback能反映本次模型调用状态。
步骤六:异常演示与恢复(详见第 9 章任务重试、第 15 章异常演练)
临时停止 Embedding 模型服务,然后在用户端上传一个新文档。
等待约 30 秒后,在管理端"任务" Tab 查询:
text
status=FAILED
预期结果:
- 新任务处于
FAILED状态。 errorMessage包含模型连接失败或认证失败信息。
恢复 Embedding 模型服务后,点击"重试"按钮。
再次刷新任务列表,预期任务最终变为 SUCCESS。
演示前建议准备好 Postman 或浏览器开发者工具。必要时同时展示 Gateway 的请求转发日志和后端服务的业务日志,这样能把"前端操作、网关鉴权、服务处理、任务状态变化"完整串起来。
17.15 当前管理端的边界
本章要真实,不要把当前管理端夸大成完整商业后台。
当前 rag-admin-web 是一个基础管理控制台,它已经能支撑:
- 管理员登录。
- 用户列表查询。
- 用户角色调整。
- 用户启用和禁用。
- 全局知识库查询。
- 全局文档查询。
- 全局索引任务查询。
- 失败和超时任务重试。
- 全局问答日志查询。
但它还不是完整企业级后台。
后续可以扩展:
- 分页组件,而不是只靠 LIMIT。
- 操作审计,记录管理员改了什么。
- 详情抽屉,展示文档错误、问答答案、模型错误详情。
- 图表大盘,展示任务成功率、失败率、平均耗时。
- 更细粒度 RBAC,比如菜单权限和按钮权限。
- 批量处理,比如批量重试失败任务。
- 管理端路由拆分,让每个模块独立维护。
这种写法比较实事求是。
真实项目简历和答辩中,最怕把"基础后台"说成"大型权限系统"。那样很容易被追问穿。
更稳的表达是:
text
我实现了基础 USER/ADMIN RBAC 和管理端全局观察能力,并预留了细粒度权限、审计和统计大盘的扩展方向。
这样既真实,也体现工程理解。
17.16 本章和前面章节的关系
管理端不是新开一条线,它是前面所有能力的总览入口。
可以这样对应:
text
第 4 章:JWT 登录认证 -> 管理员登录和 role claim
第 5 章:用户资源隔离 -> 普通用户看自己,管理员看全局
第 6 章:知识库与文档元数据 -> 后台知识库和文档管理
第 9 章:RabbitMQ 与索引任务 -> 后台任务查询和重试
第 12 章:RAG 问答闭环 -> 后台问答日志
第 14 章:Sentinel 与 AI 降级 -> modelFallback 观察
第 15 章:异常演练与排障 -> 管理端辅助定位问题
第 16 章:用户端前端 -> 普通用户工作台
第 17 章:后台管理端 -> 管理员观察台和演示支持
有了用户端,系统能被普通用户使用。
有了管理端,系统能被维护、观察和讲清楚。
这就是本章的价值。
本章小结
这一章我们讲了后台管理端与演示支持。
KnowHub 的后台管理端由 rag-admin-web 和后端 /admin/** 接口组成。前端当前是一个 Vue 3 + Element Plus 单页控制台,核心逻辑集中在 App.vue 和 api.ts;后端管理接口分布在 auth-service、knowledge-service 和 task-service;Gateway 负责把 /admin/users/**、/admin/knowledge-bases/**、/admin/documents/**、/admin/qa-logs/**、/admin/index-tasks/** 转发到对应服务,并通过 JWT 中的 role=ADMIN 进行访问控制。
本章最重要的点是:管理端不是用户端的重复,而是平台全局观察和问题处理入口。它可以管理用户角色和状态,查看全局知识库、文档、索引任务和问答日志,还可以对失败或超时任务发起重试。对于项目演示来说,管理端能把用户操作背后的数据变化、任务状态和模型调用状态展示出来,让系统从"能问答"提升到"能解释、能排查、能展示工程链路"。
下一章,我们会进入接口回归、异常演练与轻量压测,验证前面已经完成的后端、用户端和管理端是否能稳定串起来。
本章涉及的关键类与文件
text
rag-gateway-service/src/main/resources/
application.yml (后台路由配置)
rag-gateway-service/src/main/java/.../filter/
JwtAuthGlobalFilter.java (管理端权限校验)
auth-service/src/main/java/.../admin/
AdminUserController.java (用户管理接口)
knowledge-service/src/main/java/.../admin/
AdminKnowledgeBaseController.java (知识库管理接口)
AdminDocumentController.java (文档管理接口)
task-service/src/main/java/.../admin/
AdminIndexTaskController.java (任务管理接口)
AdminQaLogController.java (日志管理接口)
rag-admin-web/src/
App.vue (管理端主页,五个 Tab)
api/api.ts (管理端接口调用封装)
utils/request.ts (Axios 封装,同用户端)
utils/token.ts (Token 管理,同用户端)
views/ (如果拆分页面,放在此目录)
AdminTasks.vue (任务管理页面示例)
这张清单帮助读者在动手前建立整体感知:管理端不是一个孤立前端页面,而是 Gateway 权限校验、后端全局查询接口和前端观察台共同组成的一条演示支撑链路。
思考题
- 为什么后台管理端不能只看作用户端的另一个页面?
- USER 和 ADMIN 这两个角色分别解决什么问题?
- 为什么管理员账号当前采用"先注册,再更新 role"的方式更适合教学演示?
- JWT 中为什么要写入
role字段? - 为什么
/admin/**的权限校验必须放在 Gateway,而不能只放在前端? - Gateway 为什么要覆盖外部传入的
X-User-Role请求头? /admin/users/**、/admin/knowledge-bases/**、/admin/index-tasks/**为什么会被路由到不同服务?- 用户管理视图中,为什么角色和状态更新仍然要在后端校验?
- 文档管理视图为什么要展示
indexStatus和chunkCount? - 索引任务视图为什么只允许 FAILED 和 TIMEOUT 任务重试?
- 问答日志中的
modelFallback对排查模型问题有什么帮助? - 如果你要把当前管理端升级成更完整的企业后台,你会优先补哪些能力?
参考答案
1. 为什么后台管理端不能只看作用户端的另一个页面?
因为两者的目标完全不同。用户端是个人工作台,面向普通用户,只展示"我的知识库、我的文档、我的问答、我的任务状态",解决的是"我的业务能不能完成";管理端是平台观察台,面向管理员,展示"全部用户、全部知识库、全部文档、全部索引任务、全部问答日志",解决的是"整个平台是否健康"。管理端还要承担排查、治理和演示支撑的职责,这些都不是用户端页面的简单扩展。
2. USER 和 ADMIN 这两个角色分别解决什么问题?
USER(普通用户):解决"普通用户不能访问后台接口"的问题,保证普通用户只能操作自己的资源。ADMIN(管理员):解决"管理员可以查看全局数据"的问题,让管理员能观察整个平台的运行状态并对关键对象(用户角色、任务重试等)进行处理。
3. 为什么管理员账号当前采用"先注册,再更新 role"的方式更适合教学演示?
因为这种方式简单、可控、不会把管理员密码写进 SQL。先正常注册账号,再手动执行 UPDATE user_account SET role = 'ADMIN' WHERE username = '...' 把账号升级为管理员,避免了在 SQL 脚本里硬编码管理员密码的安全隐患,也省去了复杂的开通流程,非常适合学习和演示场景。
4. JWT 中为什么要写入 role 字段?
因为 Gateway 不会每次都去查数据库,它主要看 Token。登录成功签发 JWT 时把角色写进 Token(ROLE_CLAIM = "role"),Gateway 解析 Token 后就能直接知道当前请求者是 USER 还是 ADMIN,从而快速判断是否允许访问 /admin/** 接口,无需每次请求都回源数据库查询角色。
5. 为什么 /admin/** 的权限校验必须放在 Gateway,而不能只放在前端?
因为前端判断只是改善体验,不是安全边界。用户只要绕过前端页面,直接构造请求调用 /admin/** 接口,就可能访问管理员数据。只有把权限校验放在 Gateway(和后端二次防御),才能保证无论请求来自哪里,只要不是 ADMIN 角色就会被统一拦截。KnowHub 的做法是"前端检查 ADMIN -> Gateway 再检查 ADMIN -> 后端只暴露 /admin/** 管理接口"三层防线。
6. Gateway 为什么要覆盖外部传入的 X-User-Role 请求头?
为了防止身份伪造。攻击者可以自己构造 X-User-Id: 1、X-User-Role: ADMIN 这样的请求头来冒充管理员。Gateway 不信任外部传入的用户头,而是先删掉它们,再根据 JWT 解析出的可信身份重新写入用户信息,确保下游服务拿到的身份一定是经过认证的。
7. /admin/users/**、/admin/knowledge-bases/**、/admin/index-tasks/** 为什么会被路由到不同服务?
因为这三类数据分别属于不同的微服务:用户账号、角色、状态属于认证服务,所以 /admin/users/** 转发到 rag-auth-service;知识库、文档和问答日志是知识库服务的数据,所以 /admin/knowledge-bases/**、/admin/documents/**、/admin/qa-logs/** 转发到 rag-knowledge-service;索引任务的执行和重试属于任务服务,所以 /admin/index-tasks/** 转发到 rag-task-service。这与前面微服务拆分的职责划分保持一致。
8. 用户管理视图中,为什么角色和状态更新仍然要在后端校验?
因为后端不能依赖前端下拉框传来的字符串。前端可能被绕过或篡改,后端必须自己校验:角色必须是 USER 或 ADMIN(如 normalizeStrictRole 校验),状态值必须合法(如 @Min(0)、@Max(1))。后台管理端修改的是平台级数据,不能随意接受非法状态值,所以后端校验必不可少。
9. 文档管理视图为什么要展示 indexStatus 和 chunkCount?
因为这两个字段能反映文档索引的健康状况。indexStatus 展示文档当前是否索引完成(INDEXING、INDEXED、FAILED),chunkCount 展示切片数量是否合理。管理员据此可以回答:哪个用户上传了文档、文档属于哪个知识库、是否索引完成、切片数量是否合理、是否存在大量失败文档等问题,这是普通用户端不适合全局展示、但管理员必须能看见的信息。
10. 索引任务视图为什么只允许 FAILED 和 TIMEOUT 任务重试?
因为这对应第 9 章的索引任务可靠性设计。只有 FAILED 和 TIMEOUT 状态的任务是"可恢复"的失败状态,允许重试;而 RUNNING 表示任务还在执行中,SUCCESS 表示已完成,都不应该重试。这样能避免破坏任务状态机,保证重试逻辑的正确性。
11. 问答日志中的 modelFallback 对排查模型问题有什么帮助?
modelFallback 能直接告诉管理员本次问答是否触发了 AI 降级。当用户反馈"系统没有正常回答"时,管理员打开问答日志,结合 modelFallback、modelSuccess 等字段就能快速判断问题属于哪一类:检索结果为空、模型调用失败、模型超时、Sentinel 限流,还是触发了 fallback。这和第 12、14、15 章的内容完全对应,是定位模型链路问题的关键线索。
12. 如果你要把当前管理端升级成更完整的企业后台,你会优先补哪些能力?
我会优先补齐以下能力(按优先级):
- 分页组件 :替代只靠
LIMIT 100的简单保护,支持大数据量下的稳定查询。 - 操作审计:记录管理员改了什么(角色调整、状态变更、任务重试),满足合规和追溯需求。
- 详情抽屉:展示文档错误详情、问答完整答案、模型错误信息、引用片段和 Prompt 上下文,提升排查能力。
- 图表大盘:展示任务成功率、失败率、平均耗时等统计指标,让管理员一眼看到平台健康度。
- 更细粒度 RBAC:从 USER/ADMIN 两级扩展到菜单权限、按钮权限。
- 批量处理:如批量重试失败任务,提升运维效率。
- 管理端路由拆分:把单页多 Tab 拆成独立路由页面和 store,便于后续扩展维护。