Initial hyapp admin platform
This commit is contained in:
commit
e3337f26a6
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
.env
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
5
.prettierrc
Normal file
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"printWidth": 120
|
||||
}
|
||||
95
AGENTS.md
Normal file
95
AGENTS.md
Normal file
@ -0,0 +1,95 @@
|
||||
# AGENTS.md
|
||||
|
||||
## 项目约束
|
||||
|
||||
1. 写代码不要猜。遇到不确定的依赖版本、导出名称、组件 API、项目结构、样式约定,先查本地文件、`node_modules` 或实际命令输出。
|
||||
2. 写前端或客户端项目时,不要添加无意义的描述文案。
|
||||
3. 包管理器固定使用 `pnpm`,不要生成 npm/yarn 锁文件。
|
||||
4. 基础 UI 使用 MUI,但视觉风格必须保持当前白色 HYApp 管理平台风格。
|
||||
5. 修改 MUI 组件颜色、尺寸、hover、focus、active 状态时,优先通过 `src/theme.js` 或项目封装组件处理,避免在页面里硬写零散覆盖。
|
||||
6. 图表必须使用图表库。本项目使用 ECharts,不手写业务图表。
|
||||
7. 组件必须模块化。不要把页面、基础组件、图表、业务表格、抽屉混在单个文件里。
|
||||
8. 页面加载必须有动画占位图,并且占位图要铺满内容区域。
|
||||
9. 动画要覆盖页面切换、按钮、菜单、输入框、选择框、表格、图表、弹窗/抽屉等交互,同时保留 `prefers-reduced-motion` 降级。
|
||||
10. Header 右侧放操作组件,左侧只放菜单展开/收起按钮和当前页 tab。
|
||||
11. 时间筛选属于总览页面内容,不属于 Header。
|
||||
12. 顶部搜索框点击后打开大搜索弹窗,输入后展示列表,列表项必须可点击。
|
||||
13. 左侧菜单 active 样式优先级必须高于 hover 样式。
|
||||
14. 侧边栏展开/收起按钮必须跟随状态切换图标。
|
||||
15. 展开态 Logo 区域和侧边栏宽度保持 248px。
|
||||
|
||||
## 当前实现方式
|
||||
|
||||
- 框架:React + Vite。
|
||||
- UI 组件库:MUI。
|
||||
- 图表库:ECharts。
|
||||
- 包管理器:pnpm。
|
||||
- 页面:`src/pages/OverviewPage.jsx`、`src/pages/ServicesPage.jsx` 和 `src/pages/UserManagementPage.jsx`。
|
||||
- 应用壳:`src/App.jsx`,只做状态编排、懒加载页面、抽屉挂载和事件分发。
|
||||
- 页面加载占位:`src/components/layout/PageSkeleton.jsx`。
|
||||
- 顶部搜索弹窗:`src/components/search/SearchDialog.jsx`。
|
||||
- MUI 主题:`src/theme.js`。
|
||||
- CSS token:`src/styles/tokens.css`。
|
||||
- 主要样式和动画:`src/styles/app.css`。
|
||||
|
||||
## 目录规范
|
||||
|
||||
- `src/components/base/`:项目基础组件封装,内部可以使用 MUI。
|
||||
- `src/components/layout/`:布局组件。
|
||||
- `src/components/charts/`:ECharts 封装和图表组件。
|
||||
- `src/components/service/`:服务管理相关业务组件。
|
||||
- `src/pages/`:页面级模块。
|
||||
- `src/constants/`:导航、状态等常量。
|
||||
- `src/data/`:演示数据。
|
||||
- `src/utils/`:工具函数。
|
||||
|
||||
## 按需导入规则
|
||||
|
||||
- MUI 组件必须路径导入,例如:
|
||||
|
||||
```js
|
||||
import Button from "@mui/material/Button";
|
||||
```
|
||||
|
||||
- MUI 图标必须路径导入,例如:
|
||||
|
||||
```js
|
||||
import Refresh from "@mui/icons-material/Refresh";
|
||||
```
|
||||
|
||||
- 不要使用总入口:
|
||||
|
||||
```js
|
||||
import { Button } from "@mui/material";
|
||||
import { Refresh } from "@mui/icons-material";
|
||||
```
|
||||
|
||||
- ECharts 只在 `src/components/charts/` 内封装和加载,不要在页面里直接散落图表初始化逻辑。
|
||||
- 页面和抽屉继续使用 `React.lazy` 做拆分加载。
|
||||
- 顶部搜索弹窗继续使用 lazy 加载,不要静态塞进主入口。
|
||||
|
||||
## 样式规则
|
||||
|
||||
- 状态色不要随意改:
|
||||
- `--success`:运行中
|
||||
- `--warning`:警告
|
||||
- `--danger`:异常/危险操作
|
||||
- `--stopped`:已停止
|
||||
- 新增颜色优先写入 token 或 theme,不要在多个组件里重复硬编码。
|
||||
- 基础组件的视觉兼容优先改封装组件,不要让页面直接依赖 MUI 细节。
|
||||
- 不添加卡片套卡片。
|
||||
- 不添加无业务意义的说明区、营销区、空泛介绍区。
|
||||
|
||||
## 验证要求
|
||||
|
||||
每次完成前端改动后至少运行:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
如果需要本地预览:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
127
README.md
Normal file
127
README.md
Normal file
@ -0,0 +1,127 @@
|
||||
# HYApp 管理平台前端
|
||||
|
||||
基于 `React + Vite + MUI + ECharts` 从 `deploy-platform` 抽离出的白色主题 HYApp 管理平台前端。包管理器统一使用 `pnpm`。
|
||||
|
||||
## 用户要求
|
||||
|
||||
- 写代码不要猜:不确定的依赖、API、文件结构、现有约定,先查本地项目或依赖实际导出。
|
||||
- 写前端或客户端项目时,不添加无意义的描述文案。
|
||||
- 使用 `pnpm`。
|
||||
- 页面包含:`总览`、`服务管理` 和 `用户管理`。
|
||||
- 所有组件都要模块化,包括基础组件、布局组件、业务组件、图表组件。
|
||||
- 基础 UI 使用 MUI,但整体风格必须统一为白色后台管理平台风格。
|
||||
- 修改 MUI 组件颜色和样式时注意兼容性,优先通过主题和封装组件统一处理。
|
||||
- 图表使用图表库,不手写业务图表。
|
||||
- MUI、图标、图表能力尽量按需导入。
|
||||
- 页面加载时需要占位图,占位图要有动画效果,并且刚好铺满内容区域。
|
||||
- 动画效果要覆盖页面切换、按钮、菜单、弹窗、输入框、选择框、表格、图表等交互。
|
||||
- Header 里的操作组件靠右,左侧只保留菜单收起按钮和当前页 tab。
|
||||
- 时间筛选放在总览页面内容区,不放在 Header。
|
||||
- 点击顶部搜索框打开大搜索弹窗,弹窗输入框展示可点击结果列表。
|
||||
- 左侧菜单 hover 不能覆盖 active 视觉状态。
|
||||
- 当前页 tab 需要有简单阴影效果。
|
||||
- 总览旁边的菜单按钮负责展开/收起侧边栏,并且图标要跟随状态变化。
|
||||
- Logo 区域和展开态侧边栏宽度要比初始版本更宽。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- React 19
|
||||
- Vite 8
|
||||
- MUI 9
|
||||
- ECharts 6
|
||||
- pnpm
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
默认访问:
|
||||
|
||||
```text
|
||||
http://localhost:7001
|
||||
```
|
||||
|
||||
构建:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 实现写法
|
||||
|
||||
- `src/App.jsx` 只负责应用状态、页面切换、抽屉挂载和数据分发。
|
||||
- 页面模块放在 `src/pages/`:
|
||||
- `OverviewPage.jsx`
|
||||
- `ServicesPage.jsx`
|
||||
- `UserManagementPage.jsx`
|
||||
- 基础组件放在 `src/components/base/`,对 MUI 做项目级封装:
|
||||
- `Button`
|
||||
- `IconButton`
|
||||
- `Card`
|
||||
- `SearchBox`
|
||||
- `FilterChip`
|
||||
- `KpiCard`
|
||||
- `SegmentControl`
|
||||
- 布局组件放在 `src/components/layout/`:
|
||||
- `Header`
|
||||
- `Sidebar`
|
||||
- `PageHead`
|
||||
- `PageSkeleton`
|
||||
- 图表组件放在 `src/components/charts/`,统一封装 ECharts:
|
||||
- `EChart`
|
||||
- `LineChart`
|
||||
- `BarChart`
|
||||
- `StatusDonut`
|
||||
- `ChartCard`
|
||||
- 服务业务组件放在 `src/components/service/`:
|
||||
- `ServiceTable`
|
||||
- `ServerRail`
|
||||
- `DetailPanel`
|
||||
- `RowActions`
|
||||
- `ResourceCell`
|
||||
- `StatusBadge`
|
||||
- 常量、模拟数据和工具函数分别放在:
|
||||
- `src/constants/`
|
||||
- `src/data/`
|
||||
- `src/utils/`
|
||||
|
||||
## 样式约定
|
||||
|
||||
- 全局设计 token 在 `src/styles/tokens.css`。
|
||||
- 基础 reset 在 `src/styles/base.css`。
|
||||
- 页面布局、模块样式、动画在 `src/styles/app.css`。
|
||||
- MUI 主题集中在 `src/theme.js`,优先在主题层统一颜色、圆角、hover、focus 和组件兼容样式。
|
||||
- 避免在页面里直接散落 MUI 组件样式;优先改项目封装组件。
|
||||
- 状态色必须保持语义一致:
|
||||
- 运行中:`--success`
|
||||
- 警告:`--warning`
|
||||
- 异常:`--danger`
|
||||
- 已停止:`--stopped`
|
||||
|
||||
## 按需导入约定
|
||||
|
||||
- MUI 组件使用路径导入,例如 `@mui/material/Button`。
|
||||
- MUI 图标使用路径导入,例如 `@mui/icons-material/Refresh`。
|
||||
- 不使用 `@mui/material` 或 `@mui/icons-material` 总入口导入。
|
||||
- ECharts 通过封装组件按需加载所需图表和组件能力。
|
||||
- 页面使用 `React.lazy` 拆分:
|
||||
- `OverviewPage`
|
||||
- `ServicesPage`
|
||||
- `DetailDrawer`
|
||||
|
||||
## 动画与加载
|
||||
|
||||
- 页面切换、卡片进入、表格行进入、图表进入、抽屉显示/消失都有统一动效。
|
||||
- 按钮、菜单、输入框、选择框、筛选项、状态徽标、进度条都有 hover/focus/active 反馈。
|
||||
- `PageSkeleton` 是页面加载占位图,使用 MUI `Skeleton`,铺满 `workspace` 内容区域。
|
||||
- 支持 `prefers-reduced-motion: reduce` 降级。
|
||||
- 顶部搜索弹窗按需加载,点击结果后打开服务详情。
|
||||
- 侧边栏支持展开/收起状态,展开态宽度为 248px。
|
||||
|
||||
## 文案约定
|
||||
|
||||
- 不写无意义介绍、营销口号、功能解释文案。
|
||||
- 只保留运维用户完成工作所需要的标签、状态、数据和操作。
|
||||
272
design.md
Normal file
272
design.md
Normal file
@ -0,0 +1,272 @@
|
||||
HYApp 管理平台 UI 设计规范(v1.0)
|
||||
|
||||
---
|
||||
|
||||
## 一、设计目标
|
||||
|
||||
本系统为 HYApp 管理平台,UI 设计需满足:
|
||||
|
||||
- 高信息密度(服务器 / 服务监控)
|
||||
- 强状态表达(运行 / 异常 / 告警)
|
||||
- 快速操作(启停 / 重启 / 排查)
|
||||
- 统一规范(组件可复用)
|
||||
|
||||
---
|
||||
|
||||
## 二、整体布局(Layout)
|
||||
|
||||
### 2.1 页面结构
|
||||
|
||||
|
||||
┌───────────────────────────────┐
|
||||
│ Header (64px) │
|
||||
├──────────────┬────────────────┤
|
||||
│ Sidebar │ Content │
|
||||
│ (224px) │ (自适应) │
|
||||
└──────────────┴────────────────┘
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 2.2 尺寸规范
|
||||
|
||||
| 模块 | 尺寸 |
|
||||
|------|------|
|
||||
| Header | 64px |
|
||||
| Sidebar | 224px |
|
||||
| 页面 padding | 24px |
|
||||
| 卡片间距 | 16px |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 响应式
|
||||
|
||||
| 分辨率 | 行为 |
|
||||
|--------|------|
|
||||
| ≥1440px | 标准布局 |
|
||||
| <1280px | Sidebar 收缩 |
|
||||
|
||||
---
|
||||
|
||||
## 三、设计 Token(CSS Variables)
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 背景 */
|
||||
--bg-page: #F4F7FB;
|
||||
--bg-sidebar: #FFFFFF;
|
||||
--bg-header: #FFFFFF;
|
||||
--bg-card: #FFFFFF;
|
||||
|
||||
/* 边框 */
|
||||
--border: #D8E0EC;
|
||||
|
||||
/* 主色 */
|
||||
--primary: #2563EB;
|
||||
|
||||
/* 状态色 */
|
||||
--success: #16A34A;
|
||||
--warning: #D97706;
|
||||
--danger: #DC2626;
|
||||
--info: #0284C7;
|
||||
|
||||
/* 字体 */
|
||||
--text-primary: #0F172A;
|
||||
--text-secondary: #475569;
|
||||
--text-tertiary: #64748B;
|
||||
}
|
||||
四、颜色规范(语义化)
|
||||
4.1 状态颜色
|
||||
状态 颜色 含义
|
||||
运行中 #16A34A 正常
|
||||
已停止 #64748B 不活跃
|
||||
异常 #DC2626 错误
|
||||
警告 #D97706 风险
|
||||
|
||||
👉 禁止随意更改状态颜色
|
||||
|
||||
4.2 图表颜色
|
||||
--chart-blue: #2563EB;
|
||||
--chart-green: #16A34A;
|
||||
--chart-red: #DC2626;
|
||||
--chart-orange: #D97706;
|
||||
五、字体规范
|
||||
5.1 字号
|
||||
类型 大小
|
||||
KPI 数值 32px
|
||||
页面标题 20px
|
||||
卡片标题 14px
|
||||
表格文字 13px
|
||||
辅助文字 12px
|
||||
5.2 字体
|
||||
-apple-system, PingFang SC, Microsoft YaHei, sans-serif
|
||||
六、组件规范
|
||||
6.1 Sidebar(侧边栏)
|
||||
宽度:224px
|
||||
item 高度:44px
|
||||
|
||||
状态:
|
||||
|
||||
状态 样式
|
||||
默认 浅色文字
|
||||
hover 背景高亮
|
||||
active 渐变蓝
|
||||
6.2 Header(顶部栏)
|
||||
|
||||
包含:
|
||||
|
||||
搜索框
|
||||
刷新按钮
|
||||
时间筛选
|
||||
通知
|
||||
用户信息
|
||||
6.3 卡片(Card)
|
||||
基础样式
|
||||
background: #FFFFFF;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
6.4 KPI 卡片
|
||||
|
||||
结构:
|
||||
|
||||
图标 + 标题
|
||||
数值(大)
|
||||
辅助信息(增长/状态)
|
||||
6.5 图表卡片
|
||||
|
||||
组成:
|
||||
|
||||
标题
|
||||
筛选器
|
||||
图表
|
||||
|
||||
规范:
|
||||
|
||||
项目 规则
|
||||
折线 2px
|
||||
网格线 低透明
|
||||
字体 12px
|
||||
七、表格规范
|
||||
7.1 表格样式
|
||||
background: #FFFFFF;
|
||||
border-radius: 10px;
|
||||
7.2 表头
|
||||
height: 44px;
|
||||
background: #F1F5F9;
|
||||
7.3 行状态
|
||||
状态 样式
|
||||
默认 无背景
|
||||
hover 高亮
|
||||
7.4 列规范
|
||||
服务列
|
||||
|
||||
包含:
|
||||
|
||||
图标
|
||||
名称
|
||||
描述
|
||||
资源列(CPU / 内存)
|
||||
|
||||
必须包含:
|
||||
|
||||
数值
|
||||
mini 图表 或 进度条
|
||||
操作列
|
||||
|
||||
按钮顺序:
|
||||
|
||||
启动 / 重启 / 停止 / 更多
|
||||
八、按钮规范
|
||||
8.1 类型
|
||||
类型 用途
|
||||
Primary 主操作
|
||||
Default 普通
|
||||
Danger 危险操作
|
||||
Success 启动
|
||||
8.2 样式示例
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: #DC2626;
|
||||
}
|
||||
九、状态组件
|
||||
9.1 状态点
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
9.2 Badge
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
十、抽屉(详情面板)
|
||||
10.1 尺寸
|
||||
属性 值
|
||||
宽度 360px
|
||||
10.2 结构
|
||||
标题
|
||||
Tabs(基础信息 / 配置 / 监控 / 日志)
|
||||
内容区
|
||||
操作区
|
||||
十一、页面规范
|
||||
11.1 总览页
|
||||
|
||||
模块顺序:
|
||||
|
||||
KPI 卡片
|
||||
资源图表
|
||||
告警趋势
|
||||
服务列表
|
||||
11.2 服务管理页
|
||||
|
||||
模块:
|
||||
|
||||
统计卡片
|
||||
趋势图
|
||||
筛选栏
|
||||
表格
|
||||
详情抽屉
|
||||
十二、动效规范
|
||||
场景 时长
|
||||
hover 150ms
|
||||
抽屉 250ms
|
||||
图表更新 渐变
|
||||
十三、前端实现建议
|
||||
13.1 技术栈
|
||||
|
||||
推荐:
|
||||
|
||||
React + Ant Design / Arco
|
||||
Vue + Element Plus / Naive UI
|
||||
图表:ECharts
|
||||
13.2 CSS 架构
|
||||
styles/
|
||||
├── tokens.css
|
||||
├── base.css
|
||||
├── components/
|
||||
└── pages/
|
||||
13.3 命名规范(BEM)
|
||||
.card {}
|
||||
.card__header {}
|
||||
.card__body {}
|
||||
|
||||
.table {}
|
||||
.table__row {}
|
||||
十四、扩展能力
|
||||
|
||||
未来可扩展:
|
||||
|
||||
🌙 深浅色模式
|
||||
📊 多主题
|
||||
🔔 实时告警(WebSocket)
|
||||
📱 移动端适配
|
||||
结语
|
||||
|
||||
本规范为 HYApp 管理平台 UI 基础标准:
|
||||
|
||||
所有页面必须遵循 Token
|
||||
所有组件必须可复用
|
||||
状态颜色必须统一
|
||||
|
||||
如需新增组件,必须先扩展本规范后再开发。
|
||||
32
eslint.config.js
Normal file
32
eslint.config.js
Normal file
@ -0,0 +1,32 @@
|
||||
import js from "@eslint/js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
{ ignores: ["dist", "node_modules"] },
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{js,jsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: "module"
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"react-hooks/use-memo": "off",
|
||||
"react-refresh/only-export-components": "off"
|
||||
}
|
||||
}
|
||||
];
|
||||
12
index.html
Normal file
12
index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HYApp 管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
package.json
Normal file
39
package.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "hyapp-admin-platform",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.1",
|
||||
"@mui/icons-material": "9.0.0",
|
||||
"@mui/material": "9.0.0",
|
||||
"echarts": "6.0.0",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-router-dom": "^7.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"vite": "8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
2880
pnpm-lock.yaml
generated
Normal file
2880
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
132
src/App.jsx
Normal file
132
src/App.jsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import { getMenus } from "./api/navigation.js";
|
||||
import { useAuth } from "./auth/AuthProvider.jsx";
|
||||
import { Header } from "./components/layout/Header.jsx";
|
||||
import { PageSkeleton } from "./components/layout/PageSkeleton.jsx";
|
||||
import { Sidebar } from "./components/layout/Sidebar.jsx";
|
||||
import { findNavItemByPath, mapBackendMenus, navItems } from "./constants/navigation.js";
|
||||
|
||||
const LoginPage = lazy(() => import("./pages/LoginPage.jsx").then((module) => ({ default: module.LoginPage })));
|
||||
const OverviewPage = lazy(() => import("./pages/OverviewPage.jsx").then((module) => ({ default: module.OverviewPage })));
|
||||
const ServicesPage = lazy(() => import("./pages/ServicesPage.jsx").then((module) => ({ default: module.ServicesPage })));
|
||||
const UserManagementPage = lazy(() =>
|
||||
import("./pages/UserManagementPage.jsx").then((module) => ({ default: module.UserManagementPage }))
|
||||
);
|
||||
const RoleManagementPage = lazy(() =>
|
||||
import("./pages/RoleManagementPage.jsx").then((module) => ({ default: module.RoleManagementPage }))
|
||||
);
|
||||
const MenuManagementPage = lazy(() =>
|
||||
import("./pages/MenuManagementPage.jsx").then((module) => ({ default: module.MenuManagementPage }))
|
||||
);
|
||||
const LogsPage = lazy(() => import("./pages/LogsPage.jsx").then((module) => ({ default: module.LogsPage })));
|
||||
const NotificationsPage = lazy(() =>
|
||||
import("./pages/NotificationsPage.jsx").then((module) => ({ default: module.NotificationsPage }))
|
||||
);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/overview" replace />} />
|
||||
<Route path="/overview" element={<RequirePermission code="overview:view"><OverviewPage /></RequirePermission>} />
|
||||
<Route path="/services" element={<RequirePermission code="service:view"><ServicesPage /></RequirePermission>} />
|
||||
<Route path="/system/users" element={<RequirePermission code="user:view"><UserManagementPage /></RequirePermission>} />
|
||||
<Route path="/system/roles" element={<RequirePermission code="role:view"><RoleManagementPage /></RequirePermission>} />
|
||||
<Route path="/system/menus" element={<RequirePermission code="menu:view"><MenuManagementPage /></RequirePermission>} />
|
||||
<Route path="/logs/login" element={<RequirePermission code="log:view"><LogsPage type="login" /></RequirePermission>} />
|
||||
<Route path="/logs/operations" element={<RequirePermission code="log:view"><LogsPage type="operations" /></RequirePermission>} />
|
||||
<Route path="/notifications" element={<RequirePermission code="notification:view"><NotificationsPage /></RequirePermission>} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/overview" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function RequireAuth() {
|
||||
const { loading, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function RequirePermission({ children, code }) {
|
||||
const { can } = useAuth();
|
||||
|
||||
if (!can(code)) {
|
||||
return <Navigate to="/overview" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AdminLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
const [backendMenus, setBackendMenus] = useState([]);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const activeNav = useMemo(() => findNavItemByPath(location.pathname, backendMenus.length ? backendMenus : navItems), [backendMenus, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadMenus() {
|
||||
try {
|
||||
const data = await getMenus();
|
||||
if (mounted) {
|
||||
setBackendMenus(mapBackendMenus(data));
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setBackendMenus(navItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMenus();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const menus = backendMenus.length ? backendMenus : navItems;
|
||||
|
||||
return (
|
||||
<div className={`app-shell ${sidebarCollapsed ? "app-shell--sidebar-collapsed" : ""}`}>
|
||||
<Header
|
||||
activeLabel={activeNav?.label || "总览"}
|
||||
isSidebarCollapsed={sidebarCollapsed}
|
||||
onRefresh={() => setRefreshTick((current) => current + 1)}
|
||||
onToggleSidebar={() => setSidebarCollapsed((current) => !current)}
|
||||
/>
|
||||
|
||||
<div className="body-grid">
|
||||
<Sidebar activePath={location.pathname} isCollapsed={sidebarCollapsed} items={menus} onNavigate={navigate} />
|
||||
|
||||
<main className="workspace">
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<div className="page-transition" key={location.pathname}>
|
||||
<Outlet context={{ refreshTick }} />
|
||||
</div>
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
27
src/App.test.jsx
Normal file
27
src/App.test.jsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { AuthProvider } from "./auth/AuthProvider.jsx";
|
||||
import { ConfirmProvider } from "./components/base/ConfirmProvider.jsx";
|
||||
import { ToastProvider } from "./components/base/ToastProvider.jsx";
|
||||
import { theme } from "./theme.js";
|
||||
import App from "./App.jsx";
|
||||
|
||||
test("renders login route", async () => {
|
||||
render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
|
||||
});
|
||||
21
src/api/auth.js
Normal file
21
src/api/auth.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function login(payload) {
|
||||
return apiRequest("/v1/auth/login", { body: payload, skipAuth: true });
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return apiRequest("/v1/auth/logout", { method: "POST", skipAuth: true });
|
||||
}
|
||||
|
||||
export function refreshSession() {
|
||||
return apiRequest("/v1/auth/refresh", { method: "POST", skipAuth: true });
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return apiRequest("/v1/auth/me");
|
||||
}
|
||||
|
||||
export function changePassword(payload) {
|
||||
return apiRequest("/v1/auth/change-password", { body: payload });
|
||||
}
|
||||
5
src/api/dashboard.js
Normal file
5
src/api/dashboard.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function getDashboardOverview() {
|
||||
return apiRequest("/v1/dashboard/overview");
|
||||
}
|
||||
9
src/api/logs.js
Normal file
9
src/api/logs.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function listLoginLogs(query) {
|
||||
return apiRequest("/v1/logs/login", { query });
|
||||
}
|
||||
|
||||
export function listOperationLogs(query) {
|
||||
return apiRequest("/v1/logs/operations", { query });
|
||||
}
|
||||
5
src/api/navigation.js
Normal file
5
src/api/navigation.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function getMenus() {
|
||||
return apiRequest("/v1/navigation/menus");
|
||||
}
|
||||
9
src/api/notifications.js
Normal file
9
src/api/notifications.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function listNotifications() {
|
||||
return apiRequest("/v1/notifications");
|
||||
}
|
||||
|
||||
export function markNotificationRead(id) {
|
||||
return apiRequest(`/v1/notifications/${id}/read`, { method: "PATCH", body: {} });
|
||||
}
|
||||
105
src/api/request.js
Normal file
105
src/api/request.js
Normal file
@ -0,0 +1,105 @@
|
||||
import { API_BASE_URL } from "../config/env.js";
|
||||
|
||||
const TOKEN_KEY = "hyapp-admin.access-token";
|
||||
|
||||
let accessToken = window.localStorage.getItem(TOKEN_KEY) || "";
|
||||
let refreshHandler = null;
|
||||
let unauthorizedHandler = null;
|
||||
|
||||
export function getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function setAccessToken(token) {
|
||||
accessToken = token || "";
|
||||
if (accessToken) {
|
||||
window.localStorage.setItem(TOKEN_KEY, accessToken);
|
||||
} else {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function setRefreshHandler(handler) {
|
||||
refreshHandler = handler;
|
||||
}
|
||||
|
||||
export function setUnauthorizedHandler(handler) {
|
||||
unauthorizedHandler = handler;
|
||||
}
|
||||
|
||||
export async function apiRequest(path, options = {}) {
|
||||
const {
|
||||
body,
|
||||
headers = {},
|
||||
method = body ? "POST" : "GET",
|
||||
query,
|
||||
retry = true,
|
||||
raw = false,
|
||||
skipAuth = false
|
||||
} = options;
|
||||
const url = buildURL(path, query);
|
||||
const requestHeaders = { ...headers };
|
||||
|
||||
if (body !== undefined && !(body instanceof FormData)) {
|
||||
requestHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
if (accessToken && !skipAuth) {
|
||||
requestHeaders.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (response.status === 401 && retry && refreshHandler && !skipAuth) {
|
||||
const refreshed = await refreshHandler();
|
||||
if (refreshed) {
|
||||
return apiRequest(path, { ...options, retry: false });
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 401 && unauthorizedHandler && !skipAuth) {
|
||||
unauthorizedHandler();
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText || "请求失败");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = await readJSON(response);
|
||||
if (!response.ok || payload.code !== 0) {
|
||||
throw new Error(payload.message || response.statusText || "请求失败");
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
function buildURL(path, query) {
|
||||
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
|
||||
if (query) {
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function readJSON(response) {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { code: response.ok ? 0 : response.status, message: text, data: text };
|
||||
}
|
||||
}
|
||||
25
src/api/roles.js
Normal file
25
src/api/roles.js
Normal file
@ -0,0 +1,25 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function listRoles() {
|
||||
return apiRequest("/v1/roles");
|
||||
}
|
||||
|
||||
export function createRole(payload) {
|
||||
return apiRequest("/v1/roles", { body: payload });
|
||||
}
|
||||
|
||||
export function updateRole(id, payload) {
|
||||
return apiRequest(`/v1/roles/${id}`, { method: "PATCH", body: payload });
|
||||
}
|
||||
|
||||
export function deleteRole(id) {
|
||||
return apiRequest(`/v1/roles/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function replaceRolePermissions(id, permissionIds) {
|
||||
return apiRequest(`/v1/roles/${id}/permissions`, { method: "PUT", body: { permissionIds } });
|
||||
}
|
||||
|
||||
export function listPermissions() {
|
||||
return apiRequest("/v1/permissions");
|
||||
}
|
||||
5
src/api/search.js
Normal file
5
src/api/search.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function searchGlobal(keyword) {
|
||||
return apiRequest("/v1/search", { query: { keyword } });
|
||||
}
|
||||
25
src/api/services.js
Normal file
25
src/api/services.js
Normal file
@ -0,0 +1,25 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function listServices(query) {
|
||||
return apiRequest("/v1/services", { query });
|
||||
}
|
||||
|
||||
export function getService(id) {
|
||||
return apiRequest(`/v1/services/${id}`);
|
||||
}
|
||||
|
||||
export function createService(payload) {
|
||||
return apiRequest("/v1/services", { body: payload });
|
||||
}
|
||||
|
||||
export function updateService(id, payload) {
|
||||
return apiRequest(`/v1/services/${id}`, { method: "PATCH", body: payload });
|
||||
}
|
||||
|
||||
export function updateServiceStatus(id, status) {
|
||||
return apiRequest(`/v1/services/${id}/status`, { method: "PATCH", body: { status } });
|
||||
}
|
||||
|
||||
export function restartService(id) {
|
||||
return apiRequest(`/v1/services/${id}/restart`, { body: {} });
|
||||
}
|
||||
33
src/api/users.js
Normal file
33
src/api/users.js
Normal file
@ -0,0 +1,33 @@
|
||||
import { apiRequest } from "./request.js";
|
||||
|
||||
export function listUsers(query) {
|
||||
return apiRequest("/v1/users", { query });
|
||||
}
|
||||
|
||||
export function getUser(id) {
|
||||
return apiRequest(`/v1/users/${id}`);
|
||||
}
|
||||
|
||||
export function createUser(payload) {
|
||||
return apiRequest("/v1/users", { body: payload });
|
||||
}
|
||||
|
||||
export function updateUser(id, payload) {
|
||||
return apiRequest(`/v1/users/${id}`, { method: "PATCH", body: payload });
|
||||
}
|
||||
|
||||
export function updateUserStatus(id, status) {
|
||||
return apiRequest(`/v1/users/${id}/status`, { method: "PATCH", body: { status } });
|
||||
}
|
||||
|
||||
export function resetUserPassword(id, password) {
|
||||
return apiRequest(`/v1/users/${id}/reset-password`, { body: { password } });
|
||||
}
|
||||
|
||||
export function batchUpdateUserStatus(ids, status) {
|
||||
return apiRequest("/v1/users/batch/status", { body: { ids, status } });
|
||||
}
|
||||
|
||||
export function exportUsers(query) {
|
||||
return apiRequest("/v1/users/export", { query, raw: true });
|
||||
}
|
||||
105
src/auth/AuthProvider.jsx
Normal file
105
src/auth/AuthProvider.jsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import * as authApi from "../api/auth.js";
|
||||
import { getAccessToken, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "../api/request.js";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const applySession = useCallback((session) => {
|
||||
if (session?.accessToken) {
|
||||
setAccessToken(session.accessToken);
|
||||
}
|
||||
setUser(session?.user || null);
|
||||
setPermissions(session?.permissions || []);
|
||||
}, []);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
setAccessToken("");
|
||||
setUser(null);
|
||||
setPermissions([]);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const session = await authApi.refreshSession();
|
||||
applySession(session);
|
||||
return true;
|
||||
} catch {
|
||||
clearSession();
|
||||
return false;
|
||||
}
|
||||
}, [applySession, clearSession]);
|
||||
|
||||
useEffect(() => {
|
||||
setRefreshHandler(refresh);
|
||||
setUnauthorizedHandler(clearSession);
|
||||
return () => {
|
||||
setRefreshHandler(null);
|
||||
setUnauthorizedHandler(null);
|
||||
};
|
||||
}, [clearSession, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function bootstrap() {
|
||||
try {
|
||||
if (getAccessToken()) {
|
||||
const session = await authApi.getMe();
|
||||
if (mounted) {
|
||||
applySession(session);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [applySession, refresh]);
|
||||
|
||||
const login = useCallback(
|
||||
async (payload) => {
|
||||
const session = await authApi.login(payload);
|
||||
applySession(session);
|
||||
return session;
|
||||
},
|
||||
[applySession]
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} finally {
|
||||
clearSession();
|
||||
}
|
||||
}, [clearSession]);
|
||||
|
||||
const can = useCallback((code) => permissions.includes(code), [permissions]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, permissions, loading, login, logout, refresh, can }),
|
||||
[can, loading, login, logout, permissions, refresh, user]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used inside AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
49
src/components/base/Button.jsx
Normal file
49
src/components/base/Button.jsx
Normal file
@ -0,0 +1,49 @@
|
||||
import MuiButton from "@mui/material/Button";
|
||||
|
||||
export function Button({ children, className = "", variant, ...props }) {
|
||||
const { sx, ...buttonProps } = props;
|
||||
const variantClass = variant ? `button--${variant}` : "";
|
||||
const muiVariant = variant === "primary" ? "contained" : "outlined";
|
||||
const color = variant === "success" ? "success" : variant === "danger" ? "error" : "primary";
|
||||
const toneSx = getToneSx(variant);
|
||||
|
||||
return (
|
||||
<MuiButton
|
||||
className={["button", variantClass, className].filter(Boolean).join(" ")}
|
||||
color={color}
|
||||
disableElevation
|
||||
sx={{ ...toneSx, ...sx }}
|
||||
type="button"
|
||||
variant={muiVariant}
|
||||
{...buttonProps}
|
||||
>
|
||||
{children}
|
||||
</MuiButton>
|
||||
);
|
||||
}
|
||||
|
||||
function getToneSx(variant) {
|
||||
if (variant === "success") {
|
||||
return {
|
||||
color: "var(--success)",
|
||||
borderColor: "rgba(46, 219, 95, 0.38)",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(46, 219, 95, 0.72)",
|
||||
backgroundColor: "rgba(46, 219, 95, 0.1)"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === "danger") {
|
||||
return {
|
||||
color: "var(--danger)",
|
||||
borderColor: "rgba(255, 77, 79, 0.38)",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(255, 77, 79, 0.72)",
|
||||
backgroundColor: "rgba(255, 77, 79, 0.1)"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
9
src/components/base/Card.jsx
Normal file
9
src/components/base/Card.jsx
Normal file
@ -0,0 +1,9 @@
|
||||
import MuiCard from "@mui/material/Card";
|
||||
|
||||
export function Card({ children, className = "", component = "div", ...props }) {
|
||||
return (
|
||||
<MuiCard className={["card", className].filter(Boolean).join(" ")} component={component} elevation={0} {...props}>
|
||||
{children}
|
||||
</MuiCard>
|
||||
);
|
||||
}
|
||||
58
src/components/base/ConfirmProvider.jsx
Normal file
58
src/components/base/ConfirmProvider.jsx
Normal file
@ -0,0 +1,58 @@
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "./Button.jsx";
|
||||
|
||||
const ConfirmContext = createContext(null);
|
||||
|
||||
export function ConfirmProvider({ children }) {
|
||||
const resolverRef = useRef(null);
|
||||
const [state, setState] = useState(null);
|
||||
|
||||
const confirm = useCallback((options) => {
|
||||
setState({
|
||||
title: options.title || "确认操作",
|
||||
message: options.message || "该操作提交后会立即生效。",
|
||||
confirmText: options.confirmText || "确认",
|
||||
tone: options.tone || "primary"
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolverRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback((result) => {
|
||||
resolverRef.current?.(result);
|
||||
resolverRef.current = null;
|
||||
setState(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ confirm }), [confirm]);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={value}>
|
||||
{children}
|
||||
<Dialog className="confirm-dialog" maxWidth="xs" onClose={() => close(false)} open={Boolean(state)} fullWidth>
|
||||
<DialogTitle>{state?.title}</DialogTitle>
|
||||
<DialogContent className="confirm-dialog__content">{state?.message}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => close(false)}>取消</Button>
|
||||
<Button variant={state?.tone === "danger" ? "danger" : "primary"} onClick={() => close(true)}>
|
||||
{state?.confirmText}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const context = useContext(ConfirmContext);
|
||||
if (!context) {
|
||||
throw new Error("useConfirm must be used inside ConfirmProvider");
|
||||
}
|
||||
return context.confirm;
|
||||
}
|
||||
28
src/components/base/DataState.jsx
Normal file
28
src/components/base/DataState.jsx
Normal file
@ -0,0 +1,28 @@
|
||||
import Refresh from "@mui/icons-material/Refresh";
|
||||
import { Button } from "./Button.jsx";
|
||||
|
||||
export function DataState({ error, loading, children, onRetry }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="data-state data-state--loading">
|
||||
<div className="page-skeleton__block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="data-state">
|
||||
<div className="data-state__title">{error}</div>
|
||||
{onRetry ? (
|
||||
<Button onClick={onRetry}>
|
||||
<Refresh fontSize="small" />
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
25
src/components/base/FilterChip.jsx
Normal file
25
src/components/base/FilterChip.jsx
Normal file
@ -0,0 +1,25 @@
|
||||
import Chip from "@mui/material/Chip";
|
||||
|
||||
export function FilterChip({ active, label, onClick }) {
|
||||
return (
|
||||
<Chip
|
||||
className={`filter-chip ${active ? "filter-chip--active" : ""}`}
|
||||
clickable
|
||||
label={label}
|
||||
onClick={onClick}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 30,
|
||||
borderColor: active ? "rgba(37, 99, 235, 0.7)" : "var(--border)",
|
||||
backgroundColor: active ? "rgba(37, 99, 235, 0.1)" : "transparent",
|
||||
color: active ? "var(--text-primary)" : "var(--text-tertiary)",
|
||||
fontSize: 12,
|
||||
"&:hover": {
|
||||
borderColor: "rgba(37, 99, 235, 0.7)",
|
||||
backgroundColor: active ? "rgba(37, 99, 235, 0.14)" : "rgba(37, 99, 235, 0.08)"
|
||||
}
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
}
|
||||
43
src/components/base/IconButton.jsx
Normal file
43
src/components/base/IconButton.jsx
Normal file
@ -0,0 +1,43 @@
|
||||
import MuiIconButton from "@mui/material/IconButton";
|
||||
|
||||
export function IconButton({ children, className = "", label, notice, tone, ...props }) {
|
||||
const { sx, ...buttonProps } = props;
|
||||
|
||||
return (
|
||||
<MuiIconButton
|
||||
className={["icon-button", notice ? "icon-button--notice" : "", className].filter(Boolean).join(" ")}
|
||||
aria-label={label}
|
||||
sx={{
|
||||
width: className.includes("action-button") ? 32 : 38,
|
||||
height: className.includes("action-button") ? 32 : 38,
|
||||
padding: 0,
|
||||
border: "1px solid var(--border)",
|
||||
backgroundColor: className.includes("action-button") ? "var(--bg-card-strong)" : "var(--bg-card)",
|
||||
...getToneSx(tone),
|
||||
...sx
|
||||
}}
|
||||
{...buttonProps}
|
||||
>
|
||||
{children}
|
||||
{notice ? <span>{notice}</span> : null}
|
||||
</MuiIconButton>
|
||||
);
|
||||
}
|
||||
|
||||
function getToneSx(tone) {
|
||||
if (tone === "success") {
|
||||
return {
|
||||
color: "var(--success)",
|
||||
"&:hover": { borderColor: "rgba(46, 219, 95, 0.72)", backgroundColor: "rgba(46, 219, 95, 0.1)" }
|
||||
};
|
||||
}
|
||||
|
||||
if (tone === "danger") {
|
||||
return {
|
||||
color: "var(--danger)",
|
||||
"&:hover": { borderColor: "rgba(255, 77, 79, 0.72)", backgroundColor: "rgba(255, 77, 79, 0.1)" }
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
19
src/components/base/KpiCard.jsx
Normal file
19
src/components/base/KpiCard.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { Card } from "./Card.jsx";
|
||||
|
||||
export function KpiCard({ icon: Icon, label, sub, tone = "primary", unit, value }) {
|
||||
return (
|
||||
<Card className="kpi-card" component="article">
|
||||
<div className="kpi-card__top">
|
||||
<div className="kpi-card__label">{label}</div>
|
||||
<div className={`metric-icon metric-icon--${tone}`}>
|
||||
<Icon fontSize="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-value">
|
||||
{value}
|
||||
{unit ? <span>{unit}</span> : null}
|
||||
</div>
|
||||
<div className="kpi-sub">{sub}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
26
src/components/base/PaginationBar.jsx
Normal file
26
src/components/base/PaginationBar.jsx
Normal file
@ -0,0 +1,26 @@
|
||||
import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft";
|
||||
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
|
||||
import { IconButton } from "./IconButton.jsx";
|
||||
|
||||
export function PaginationBar({ page, pageSize, total, onPageChange }) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const end = Math.min(total, page * pageSize);
|
||||
|
||||
return (
|
||||
<div className="pagination-bar">
|
||||
<span>
|
||||
{start}-{end} / 共 {total} 条
|
||||
</span>
|
||||
<div className="pagination-bar__actions">
|
||||
<IconButton disabled={page <= 1} label="上一页" onClick={() => onPageChange(page - 1)}>
|
||||
<KeyboardArrowLeft fontSize="small" />
|
||||
</IconButton>
|
||||
<span>{page} / {totalPages}</span>
|
||||
<IconButton disabled={page >= totalPages} label="下一页" onClick={() => onPageChange(page + 1)}>
|
||||
<KeyboardArrowRight fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/components/base/SearchBox.jsx
Normal file
18
src/components/base/SearchBox.jsx
Normal file
@ -0,0 +1,18 @@
|
||||
import InputBase from "@mui/material/InputBase";
|
||||
import Search from "@mui/icons-material/Search";
|
||||
|
||||
export function SearchBox({ autoFocus = false, className = "", onChange, placeholder, value }) {
|
||||
return (
|
||||
<label className={["search-box", className].filter(Boolean).join(" ")}>
|
||||
<Search fontSize="small" />
|
||||
<InputBase
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
placeholder={placeholder}
|
||||
type="search"
|
||||
sx={{ color: "var(--text-primary)", flex: 1, fontSize: 13 }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
53
src/components/base/SegmentControl.jsx
Normal file
53
src/components/base/SegmentControl.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
import ToggleButton from "@mui/material/ToggleButton";
|
||||
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SegmentControl({ value = "avg" }) {
|
||||
const [selected, setSelected] = useState(value);
|
||||
|
||||
return (
|
||||
<ToggleButtonGroup
|
||||
className="segmented"
|
||||
exclusive
|
||||
size="small"
|
||||
value={selected}
|
||||
onChange={(_, nextValue) => {
|
||||
if (nextValue) {
|
||||
setSelected(nextValue);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
border: "1px solid var(--border)",
|
||||
backgroundColor: "var(--bg-card-strong)",
|
||||
"& .MuiToggleButtonGroup-grouped": {
|
||||
border: 0,
|
||||
margin: 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleButton className="segment" value="avg" sx={segmentSx}>
|
||||
平均值
|
||||
</ToggleButton>
|
||||
<ToggleButton className="segment" value="peak" sx={segmentSx}>
|
||||
峰值
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const segmentSx = {
|
||||
minWidth: 52,
|
||||
height: 24,
|
||||
padding: "0 10px",
|
||||
borderRadius: "6px !important",
|
||||
color: "var(--text-tertiary)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: "rgba(37, 99, 235, 0.1)",
|
||||
color: "var(--text-primary)"
|
||||
},
|
||||
"&.Mui-selected:hover": {
|
||||
backgroundColor: "rgba(37, 99, 235, 0.14)"
|
||||
}
|
||||
};
|
||||
23
src/components/base/TimeRangeSelect.jsx
Normal file
23
src/components/base/TimeRangeSelect.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Select from "@mui/material/Select";
|
||||
|
||||
const ranges = ["近 1 小时", "近 24 小时", "近 7 天"];
|
||||
|
||||
export function TimeRangeSelect({ className = "", onChange, value }) {
|
||||
return (
|
||||
<Select
|
||||
className={["time-select", className].filter(Boolean).join(" ")}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
size="small"
|
||||
sx={{ width: 116, height: 38, fontSize: 13 }}
|
||||
variant="outlined"
|
||||
>
|
||||
{ranges.map((range) => (
|
||||
<MenuItem key={range} value={range}>
|
||||
{range}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
41
src/components/base/ToastProvider.jsx
Normal file
41
src/components/base/ToastProvider.jsx
Normal file
@ -0,0 +1,41 @@
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = useCallback((message, severity = "info") => {
|
||||
setToast({ message, severity });
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ showToast }), [showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<Snackbar
|
||||
anchorOrigin={{ horizontal: "right", vertical: "top" }}
|
||||
autoHideDuration={3200}
|
||||
onClose={() => setToast(null)}
|
||||
open={Boolean(toast)}
|
||||
>
|
||||
{toast ? (
|
||||
<Alert onClose={() => setToast(null)} severity={toast.severity} variant="filled">
|
||||
{toast.message}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Snackbar>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used inside ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
44
src/components/charts/BarChart.jsx
Normal file
44
src/components/charts/BarChart.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { EChart } from "./EChart.jsx";
|
||||
|
||||
export function BarChart({ series }) {
|
||||
return <EChart className="bar-chart" option={createBarOption(series)} />;
|
||||
}
|
||||
|
||||
function createBarOption(series) {
|
||||
return {
|
||||
animationDuration: 450,
|
||||
grid: { left: 8, right: 8, top: 16, bottom: 10, containLabel: false },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#d8e0ec",
|
||||
textStyle: { color: "#0f172a" }
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: series.map((_, index) => index + 1),
|
||||
show: false
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
show: false,
|
||||
splitLine: { show: true, lineStyle: { color: "rgba(100, 116, 139, 0.14)" } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "严重",
|
||||
type: "bar",
|
||||
data: series.map((value) => Math.max(1, Math.round(value * 0.45))),
|
||||
barWidth: 8,
|
||||
itemStyle: { color: "#dc2626", borderRadius: [4, 4, 0, 0] }
|
||||
},
|
||||
{
|
||||
name: "警告",
|
||||
type: "bar",
|
||||
data: series,
|
||||
barWidth: 8,
|
||||
itemStyle: { color: "#d97706", borderRadius: [4, 4, 0, 0] }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
15
src/components/charts/ChartCard.jsx
Normal file
15
src/components/charts/ChartCard.jsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Card } from "../base/Card.jsx";
|
||||
import { SegmentControl } from "../base/SegmentControl.jsx";
|
||||
import { LineChart } from "./LineChart.jsx";
|
||||
|
||||
export function ChartCard({ color, compact = false, series, title }) {
|
||||
return (
|
||||
<Card className={`chart-card ${compact ? "chart-card--compact" : ""}`}>
|
||||
<div className="card-head">
|
||||
<div className="card-title">{title}</div>
|
||||
<SegmentControl />
|
||||
</div>
|
||||
<LineChart color={color} series={series} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
70
src/components/charts/EChart.jsx
Normal file
70
src/components/charts/EChart.jsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
let echartsLoader;
|
||||
|
||||
function loadEcharts() {
|
||||
if (!echartsLoader) {
|
||||
echartsLoader = Promise.all([
|
||||
import("echarts/lib/echarts"),
|
||||
import("echarts/lib/chart/line"),
|
||||
import("echarts/lib/chart/bar"),
|
||||
import("echarts/lib/chart/pie"),
|
||||
import("echarts/lib/component/grid"),
|
||||
import("echarts/lib/component/tooltip"),
|
||||
import("echarts/lib/component/legend")
|
||||
]).then(([echarts]) => echarts);
|
||||
}
|
||||
|
||||
return echartsLoader;
|
||||
}
|
||||
|
||||
export function EChart({ className = "", option }) {
|
||||
const elementRef = useRef(null);
|
||||
const chartRef = useRef(null);
|
||||
const optionRef = useRef(option);
|
||||
|
||||
useEffect(() => {
|
||||
optionRef.current = option;
|
||||
chartRef.current?.setOption(option, true);
|
||||
}, [option]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let removeResize = () => {};
|
||||
|
||||
loadEcharts().then((echarts) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chart = echarts.init(element, null, { renderer: "canvas" });
|
||||
chartRef.current = chart;
|
||||
chart.setOption(optionRef.current);
|
||||
|
||||
const resize = () => chart.resize();
|
||||
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
|
||||
observer?.observe(element);
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
removeResize = () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
removeResize();
|
||||
chartRef.current?.dispose();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div className={["echart", className].filter(Boolean).join(" ")} ref={elementRef} />;
|
||||
}
|
||||
41
src/components/charts/LineChart.jsx
Normal file
41
src/components/charts/LineChart.jsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { EChart } from "./EChart.jsx";
|
||||
|
||||
export function LineChart({ color, series }) {
|
||||
return <EChart className="line-chart" option={createLineOption(color, series)} />;
|
||||
}
|
||||
|
||||
function createLineOption(color, series) {
|
||||
return {
|
||||
animationDuration: 450,
|
||||
grid: { left: 8, right: 8, top: 14, bottom: 8, containLabel: false },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#d8e0ec",
|
||||
textStyle: { color: "#0f172a" }
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
boundaryGap: false,
|
||||
data: series.map((_, index) => index + 1),
|
||||
show: false
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
min: 0,
|
||||
max: 100,
|
||||
show: false,
|
||||
splitLine: { show: true, lineStyle: { color: "rgba(100, 116, 139, 0.16)" } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
data: series,
|
||||
smooth: true,
|
||||
symbol: "none",
|
||||
lineStyle: { width: 2, color },
|
||||
areaStyle: { color, opacity: 0.14 }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
51
src/components/charts/StatusDonut.jsx
Normal file
51
src/components/charts/StatusDonut.jsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { EChart } from "./EChart.jsx";
|
||||
|
||||
export function StatusDonut({ stats }) {
|
||||
return (
|
||||
<div className="donut-wrap">
|
||||
<EChart className="donut-echart" option={createPieOption(stats)} />
|
||||
<div className="donut-legend">
|
||||
<Legend color="success" label="运行中" value={stats.running} />
|
||||
<Legend color="danger" label="异常/警告" value={stats.error + stats.warning} />
|
||||
<Legend color="stopped" label="已停止" value={stats.stopped} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createPieOption(stats) {
|
||||
return {
|
||||
animationDuration: 450,
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#d8e0ec",
|
||||
textStyle: { color: "#0f172a" }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["54%", "78%"],
|
||||
center: ["50%", "50%"],
|
||||
avoidLabelOverlap: true,
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
data: [
|
||||
{ name: "运行中", value: stats.running, itemStyle: { color: "#16a34a" } },
|
||||
{ name: "异常/警告", value: stats.error + stats.warning, itemStyle: { color: "#dc2626" } },
|
||||
{ name: "已停止", value: stats.stopped, itemStyle: { color: "#64748b" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function Legend({ color, label, value }) {
|
||||
return (
|
||||
<div className="legend-row">
|
||||
<span className={`legend-dot legend-dot--${color}`} />
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/components/layout/Header.jsx
Normal file
155
src/components/layout/Header.jsx
Normal file
@ -0,0 +1,155 @@
|
||||
import ExpandMore from "@mui/icons-material/ExpandMore";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import Logout from "@mui/icons-material/Logout";
|
||||
import Menu from "@mui/icons-material/Menu";
|
||||
import MenuOpen from "@mui/icons-material/MenuOpen";
|
||||
import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
|
||||
import Person from "@mui/icons-material/Person";
|
||||
import Refresh from "@mui/icons-material/Refresh";
|
||||
import Search from "@mui/icons-material/Search";
|
||||
import MuiMenu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { listNotifications } from "../../api/notifications.js";
|
||||
import { useAuth } from "../../auth/AuthProvider.jsx";
|
||||
import { IconButton } from "../base/IconButton.jsx";
|
||||
import { useToast } from "../base/ToastProvider.jsx";
|
||||
|
||||
const SearchDialog = lazy(() => import("../search/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog })));
|
||||
|
||||
export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSidebar }) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchMounted, setSearchMounted] = useState(false);
|
||||
const [userMenuAnchor, setUserMenuAnchor] = useState(null);
|
||||
const [unread, setUnread] = useState(0);
|
||||
const navigate = useNavigate();
|
||||
const { logout, user } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu;
|
||||
const userMenuOpen = Boolean(userMenuAnchor);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) {
|
||||
setSearchMounted(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => setSearchMounted(false), 280);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [searchOpen]);
|
||||
|
||||
const openSearch = () => {
|
||||
setSearchMounted(true);
|
||||
setSearchOpen(true);
|
||||
};
|
||||
|
||||
const closeUserMenu = () => {
|
||||
setUserMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
closeUserMenu();
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
const handleSearchSelect = (result) => {
|
||||
if (result?.target) {
|
||||
navigate(result.target);
|
||||
}
|
||||
};
|
||||
|
||||
const openNotifications = () => {
|
||||
navigate("/notifications");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadUnread() {
|
||||
try {
|
||||
const data = await listNotifications();
|
||||
if (mounted) {
|
||||
setUnread(data.unread || 0);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setUnread(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadUnread();
|
||||
const timer = window.setInterval(loadUnread, 30000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = () => {
|
||||
onRefresh();
|
||||
showToast("已刷新当前页面", "success");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">
|
||||
<Inventory2Outlined fontSize="small" />
|
||||
</div>
|
||||
<div className="brand-text">
|
||||
<span className="brand-title">HYApp 管理平台</span>
|
||||
<span className="brand-meta">Admin Platform</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="header-tools">
|
||||
<div className="header-left-tools">
|
||||
<IconButton label={isSidebarCollapsed ? "展开菜单" : "收起菜单"} onClick={onToggleSidebar}>
|
||||
<ToggleIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<div className="header-location">{activeLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="header-right-tools">
|
||||
<button className="search-trigger" type="button" onClick={openSearch}>
|
||||
<Search fontSize="small" />
|
||||
<span>搜索用户、服务、IP...</span>
|
||||
</button>
|
||||
<IconButton label="刷新" onClick={refresh}>
|
||||
<Refresh fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton label="通知" notice={unread ? String(unread) : undefined} onClick={openNotifications}>
|
||||
<NotificationsNoneOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
<button className="user-pill" type="button" onClick={(event) => setUserMenuAnchor(event.currentTarget)}>
|
||||
<Person fontSize="small" />
|
||||
<span>{user?.name || user?.account || "admin"}</span>
|
||||
<ExpandMore fontSize="inherit" />
|
||||
</button>
|
||||
<MuiMenu
|
||||
anchorEl={userMenuAnchor}
|
||||
className="user-menu"
|
||||
onClose={closeUserMenu}
|
||||
open={userMenuOpen}
|
||||
transformOrigin={{ horizontal: "right", vertical: "top" }}
|
||||
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
|
||||
>
|
||||
<MenuItem className="user-menu__item" onClick={handleLogout}>
|
||||
<Logout fontSize="small" />
|
||||
退出
|
||||
</MenuItem>
|
||||
</MuiMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchMounted ? (
|
||||
<Suspense fallback={null}>
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
11
src/components/layout/PageHead.jsx
Normal file
11
src/components/layout/PageHead.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
export function PageHead({ children, meta, title }) {
|
||||
return (
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{meta ? <p className="page-meta">{meta}</p> : null}
|
||||
</div>
|
||||
<div className="page-actions">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/layout/PageSkeleton.jsx
Normal file
36
src/components/layout/PageSkeleton.jsx
Normal file
@ -0,0 +1,36 @@
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
|
||||
export function PageSkeleton() {
|
||||
return (
|
||||
<div className="page-skeleton" aria-hidden="true">
|
||||
<div className="page-skeleton__head">
|
||||
<SkeletonBlock className="page-skeleton__title" />
|
||||
<div className="page-skeleton__actions">
|
||||
<SkeletonBlock />
|
||||
<SkeletonBlock />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-skeleton__kpis">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<SkeletonBlock className="page-skeleton__kpi" key={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="page-skeleton__charts">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<SkeletonBlock className="page-skeleton__chart" key={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="page-skeleton__content">
|
||||
<SkeletonBlock className="page-skeleton__rail" />
|
||||
<SkeletonBlock className="page-skeleton__table" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonBlock({ className = "" }) {
|
||||
return <Skeleton animation="wave" className={["page-skeleton__block", className].filter(Boolean).join(" ")} variant="rounded" />;
|
||||
}
|
||||
181
src/components/layout/Sidebar.jsx
Normal file
181
src/components/layout/Sidebar.jsx
Normal file
@ -0,0 +1,181 @@
|
||||
import ExpandMore from "@mui/icons-material/ExpandMore";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function Sidebar({ activePath, isCollapsed, items, onNavigate }) {
|
||||
const [expandedGroupId, setExpandedGroupId] = useState(() => findParentGroupId(activePath, items));
|
||||
const [flyoutGroupId, setFlyoutGroupId] = useState(null);
|
||||
const sidebarRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedGroupId(findParentGroupId(activePath, items));
|
||||
setFlyoutGroupId(null);
|
||||
}, [activePath, items]);
|
||||
|
||||
useEffect(() => {
|
||||
setFlyoutGroupId(null);
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!flyoutGroupId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const closeFlyout = (event) => {
|
||||
if (sidebarRef.current?.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFlyoutGroupId(null);
|
||||
};
|
||||
|
||||
const closeOnEscape = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
setFlyoutGroupId(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", closeFlyout);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeFlyout);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [flyoutGroupId]);
|
||||
|
||||
const handleNavigate = (path) => {
|
||||
if (path) {
|
||||
onNavigate(path);
|
||||
}
|
||||
setFlyoutGroupId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${isCollapsed ? "sidebar--collapsed" : ""}`} ref={sidebarRef}>
|
||||
<nav className="nav-list" aria-label="主导航">
|
||||
{items.map((item) => (
|
||||
<NavGroup
|
||||
activePath={activePath}
|
||||
isExpanded={expandedGroupId === item.id}
|
||||
isCollapsed={isCollapsed}
|
||||
isFlyoutOpen={flyoutGroupId === item.id}
|
||||
item={item}
|
||||
key={item.id}
|
||||
onNavigate={handleNavigate}
|
||||
onToggleExpanded={() => setExpandedGroupId((current) => (current === item.id ? null : item.id))}
|
||||
onToggleFlyout={() => setFlyoutGroupId((current) => (current === item.id ? null : item.id))}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavGroup({
|
||||
activePath,
|
||||
isCollapsed,
|
||||
isExpanded,
|
||||
isFlyoutOpen,
|
||||
item,
|
||||
onNavigate,
|
||||
onToggleExpanded,
|
||||
onToggleFlyout
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
const children = item.children || [];
|
||||
const hasChildren = children.length > 0;
|
||||
const isActive = Boolean(item.path && activePath.startsWith(item.path));
|
||||
const isChildActive = children.some((child) => child.path && activePath.startsWith(child.path));
|
||||
const isOpen = isCollapsed ? isFlyoutOpen : isExpanded;
|
||||
|
||||
const handleParentClick = () => {
|
||||
if (hasChildren && isCollapsed) {
|
||||
onToggleFlyout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasChildren) {
|
||||
onToggleExpanded();
|
||||
return;
|
||||
}
|
||||
|
||||
onNavigate(item.path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nav-group">
|
||||
<button
|
||||
aria-expanded={hasChildren ? isOpen : undefined}
|
||||
aria-haspopup={hasChildren && isCollapsed ? "menu" : undefined}
|
||||
className={[
|
||||
"nav-item",
|
||||
hasChildren ? "nav-item--parent" : "",
|
||||
isOpen ? "nav-item--expanded" : "",
|
||||
isActive ? "nav-item--active" : "",
|
||||
isChildActive || isOpen ? "nav-item--open" : "",
|
||||
isFlyoutOpen ? "nav-item--flyout-open" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
type="button"
|
||||
onClick={handleParentClick}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
<span className="nav-label">{item.label}</span>
|
||||
{hasChildren ? <ExpandMore className="nav-expand" fontSize="small" /> : null}
|
||||
</button>
|
||||
|
||||
{hasChildren && isOpen && !isCollapsed ? (
|
||||
<div className="nav-children">
|
||||
{children.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`nav-item nav-item--child ${child.path && activePath.startsWith(child.path) ? "nav-item--active" : ""}`}
|
||||
key={child.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate(child.path)}
|
||||
>
|
||||
<ChildIcon fontSize="small" />
|
||||
<span className="nav-label">{child.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasChildren && isCollapsed && isFlyoutOpen ? (
|
||||
<div className="nav-flyout" role="menu">
|
||||
<div className="nav-flyout__title">{item.label}</div>
|
||||
{children.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`nav-flyout__item ${child.path && activePath.startsWith(child.path) ? "nav-flyout__item--active" : ""}`}
|
||||
key={child.id}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
onClick={() => onNavigate(child.path)}
|
||||
>
|
||||
<ChildIcon fontSize="small" />
|
||||
<span>{child.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findParentGroupId(activePath, items) {
|
||||
for (const item of items) {
|
||||
if (item.children?.some((child) => child.path && activePath.startsWith(child.path))) {
|
||||
return item.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
76
src/components/search/SearchDialog.jsx
Normal file
76
src/components/search/SearchDialog.jsx
Normal file
@ -0,0 +1,76 @@
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import List from "@mui/material/List";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import { useEffect, useState } from "react";
|
||||
import { searchGlobal } from "../../api/search.js";
|
||||
import { SearchBox } from "../base/SearchBox.jsx";
|
||||
|
||||
export function SearchDialog({ onClose, onSelect, open }) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setKeyword("");
|
||||
setResults([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await searchGlobal(keyword);
|
||||
setResults(data || []);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 180);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [keyword, open]);
|
||||
|
||||
const selectResult = (item) => {
|
||||
onSelect(item);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="global-search-modal"
|
||||
fullWidth
|
||||
maxWidth="md"
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
slotProps={{ paper: { className: "search-dialog" } }}
|
||||
>
|
||||
<div className="search-dialog__head">
|
||||
<SearchBox autoFocus className="search-dialog__input" value={keyword} onChange={setKeyword} placeholder="搜索服务器、服务、IP..." />
|
||||
</div>
|
||||
|
||||
<List className="search-dialog__list" disablePadding>
|
||||
{results.map((item) => (
|
||||
<ListItemButton className="search-dialog__item" key={`${item.type}-${item.id}`} onClick={() => selectResult(item)}>
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
secondary={`${item.type} / ${item.subtitle}`}
|
||||
slotProps={{
|
||||
primary: { className: "search-dialog__primary" },
|
||||
secondary: { className: "search-dialog__secondary" }
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
{!results.length ? <div className="search-dialog__empty">{loading ? "搜索中..." : "暂无匹配结果"}</div> : null}
|
||||
</List>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
137
src/components/service/DetailPanel.jsx
Normal file
137
src/components/service/DetailPanel.jsx
Normal file
@ -0,0 +1,137 @@
|
||||
import Close from "@mui/icons-material/Close";
|
||||
import PlayArrow from "@mui/icons-material/PlayArrow";
|
||||
import Refresh from "@mui/icons-material/Refresh";
|
||||
import Stop from "@mui/icons-material/Stop";
|
||||
import { Button } from "../base/Button.jsx";
|
||||
import { Card } from "../base/Card.jsx";
|
||||
import { IconButton } from "../base/IconButton.jsx";
|
||||
import { detailTabs } from "../../constants/status.js";
|
||||
import { StatusBadge } from "./StatusBadge.jsx";
|
||||
|
||||
export function InlineInspector({ canOperate = true, restartService, service, setServiceStatus }) {
|
||||
if (!service) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="inline-inspector" component="aside">
|
||||
<PanelHead service={service} />
|
||||
<StaticTabs />
|
||||
<DetailBody activeTab={detailTabs[0]} service={service} />
|
||||
<DrawerActions canOperate={canOperate} restartService={restartService} service={service} setServiceStatus={setServiceStatus} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailDrawer({ activeTab, canOperate = true, isOpen, onClose, restartService, service, setActiveTab, setServiceStatus }) {
|
||||
if (!service) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`drawer ${isOpen ? "drawer--open" : ""}`} aria-hidden={!isOpen}>
|
||||
<div className="drawer-head">
|
||||
<PanelTitle service={service} />
|
||||
<IconButton label="关闭" onClick={onClose}>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="drawer-tabs">
|
||||
{detailTabs.map((tab) => (
|
||||
<button
|
||||
className={`drawer-tab ${activeTab === tab ? "drawer-tab--active" : ""}`}
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DetailBody activeTab={activeTab} service={service} />
|
||||
<DrawerActions canOperate={canOperate} restartService={restartService} service={service} setServiceStatus={setServiceStatus} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHead({ service }) {
|
||||
return (
|
||||
<div className="drawer-head">
|
||||
<PanelTitle service={service} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelTitle({ service }) {
|
||||
return (
|
||||
<div className="panel-title-line">
|
||||
<h2 className="drawer-title">{service.name}</h2>
|
||||
<StatusBadge service={service} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StaticTabs() {
|
||||
return (
|
||||
<div className="drawer-tabs">
|
||||
{detailTabs.map((tab, index) => (
|
||||
<button className={`drawer-tab ${index === 0 ? "drawer-tab--active" : ""}`} key={tab} type="button">
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailBody({ activeTab, service }) {
|
||||
return (
|
||||
<div className="drawer-body">
|
||||
{activeTab === "日志" ? (
|
||||
<ul className="log-list">
|
||||
{service.logs.map((log) => (
|
||||
<li key={log}>{log}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="detail-grid">
|
||||
<DetailRow label="服务名称" value={service.name} />
|
||||
<DetailRow label="服务类型" value={service.desc} />
|
||||
<DetailRow label="部署分组" value={service.deploy} />
|
||||
<DetailRow label="版本" value={service.version} />
|
||||
<DetailRow label="端口" value={service.port} />
|
||||
<DetailRow label="副本" value={service.replicas} />
|
||||
<DetailRow label="响应时间" value={service.response} />
|
||||
<DetailRow label="更新时间" value={service.updated} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }) {
|
||||
return (
|
||||
<div className="detail-row">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerActions({ canOperate, restartService, service, setServiceStatus }) {
|
||||
return (
|
||||
<div className="drawer-actions">
|
||||
<Button disabled={!canOperate || service.status === "running"} variant="success" onClick={() => setServiceStatus(service.id, "running")}>
|
||||
<PlayArrow fontSize="small" />
|
||||
启动
|
||||
</Button>
|
||||
<Button disabled={!canOperate || service.status === "stopped"} onClick={() => restartService(service.id)}>
|
||||
<Refresh fontSize="small" />
|
||||
重启
|
||||
</Button>
|
||||
<Button disabled={!canOperate || service.status === "stopped"} variant="danger" onClick={() => setServiceStatus(service.id, "stopped")}>
|
||||
<Stop fontSize="small" />
|
||||
停止
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/components/service/ResourceCell.jsx
Normal file
12
src/components/service/ResourceCell.jsx
Normal file
@ -0,0 +1,12 @@
|
||||
export function ResourceCell({ memory = false, value }) {
|
||||
return (
|
||||
<div className="service-cell resource-cell">
|
||||
<div className="resource-line">
|
||||
<span>{value}%</span>
|
||||
</div>
|
||||
<div className={`progress ${memory ? "progress--memory" : ""}`}>
|
||||
<span style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/service/RowActions.jsx
Normal file
44
src/components/service/RowActions.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import MoreHoriz from "@mui/icons-material/MoreHoriz";
|
||||
import PlayArrow from "@mui/icons-material/PlayArrow";
|
||||
import Refresh from "@mui/icons-material/Refresh";
|
||||
import Stop from "@mui/icons-material/Stop";
|
||||
import { IconButton } from "../base/IconButton.jsx";
|
||||
|
||||
export function RowActions({ canOperate = true, restartService, service, setServiceStatus }) {
|
||||
const isRunning = service.status === "running";
|
||||
const isStopped = service.status === "stopped";
|
||||
|
||||
return (
|
||||
<div className="row-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<IconButton
|
||||
className="action-button action-button--success"
|
||||
disabled={!canOperate || isRunning}
|
||||
onClick={() => setServiceStatus(service.id, "running")}
|
||||
label="启动"
|
||||
tone="success"
|
||||
>
|
||||
<PlayArrow fontSize="inherit" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="action-button"
|
||||
disabled={!canOperate || isStopped}
|
||||
onClick={() => restartService(service.id)}
|
||||
label="重启"
|
||||
>
|
||||
<Refresh fontSize="inherit" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="action-button action-button--danger"
|
||||
disabled={!canOperate || isStopped}
|
||||
onClick={() => setServiceStatus(service.id, "stopped")}
|
||||
label="停止"
|
||||
tone="danger"
|
||||
>
|
||||
<Stop fontSize="inherit" />
|
||||
</IconButton>
|
||||
<IconButton className="action-button" label="更多">
|
||||
<MoreHoriz fontSize="inherit" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/service/ServerRail.jsx
Normal file
27
src/components/service/ServerRail.jsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Card } from "../base/Card.jsx";
|
||||
import { StatusBadge } from "./StatusBadge.jsx";
|
||||
|
||||
export function ServerRail({ openDetail, services }) {
|
||||
return (
|
||||
<Card className="server-rail" component="aside">
|
||||
<div className="table-toolbar">
|
||||
<div className="card-title">服务器列表</div>
|
||||
<button className="link-button" type="button">
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
<div className="server-list">
|
||||
{services.slice(0, 4).map((service) => (
|
||||
<button className="server-item" key={service.id} type="button" onClick={() => openDetail(service.id)}>
|
||||
<span className={`server-status server-status--${service.status}`} />
|
||||
<span>
|
||||
<strong>{service.deploy}</strong>
|
||||
<small>{service.name}</small>
|
||||
</span>
|
||||
<StatusBadge service={service} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
55
src/components/service/ServiceTable.jsx
Normal file
55
src/components/service/ServiceTable.jsx
Normal file
@ -0,0 +1,55 @@
|
||||
import Storage from "@mui/icons-material/Storage";
|
||||
import { Card } from "../base/Card.jsx";
|
||||
import { ResourceCell } from "./ResourceCell.jsx";
|
||||
import { RowActions } from "./RowActions.jsx";
|
||||
import { StatusBadge } from "./StatusBadge.jsx";
|
||||
|
||||
export function ServiceTable({ canOperate = true, openDetail, restartService, services, setServiceStatus, title }) {
|
||||
return (
|
||||
<Card className="table-card" component="section">
|
||||
<div className="table-toolbar">
|
||||
<div className="card-title">{title}</div>
|
||||
<div className="muted">共 {services.length} 条</div>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<div className="service-table">
|
||||
<div className="service-row service-row--head">
|
||||
<div>服务名称</div>
|
||||
<div>状态</div>
|
||||
<div>部署</div>
|
||||
<div>CPU</div>
|
||||
<div>内存</div>
|
||||
<div>操作</div>
|
||||
</div>
|
||||
{services.length ? (
|
||||
services.map((service) => (
|
||||
<div className="service-row" key={service.id} onClick={() => openDetail(service.id)} role="button" tabIndex={0}>
|
||||
<div className="service-cell service-main">
|
||||
<div className="service-icon">
|
||||
<Storage fontSize="small" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="service-name">{service.name}</div>
|
||||
<div className="service-desc">{service.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="service-cell">
|
||||
<StatusBadge service={service} />
|
||||
</div>
|
||||
<div className="service-cell deploy-cell">
|
||||
{service.deploy}
|
||||
<div className="muted">{service.version}</div>
|
||||
</div>
|
||||
<ResourceCell value={service.cpu} />
|
||||
<ResourceCell value={service.memory} memory />
|
||||
<RowActions canOperate={canOperate} service={service} restartService={restartService} setServiceStatus={setServiceStatus} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-state">暂无匹配服务</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
16
src/components/service/StatusBadge.jsx
Normal file
16
src/components/service/StatusBadge.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import Chip from "@mui/material/Chip";
|
||||
import { statusMeta } from "../../constants/status.js";
|
||||
|
||||
export function StatusBadge({ service }) {
|
||||
const meta = statusMeta[service.status];
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className={`status-badge status-badge--${meta.className}`}
|
||||
icon={<span className="status-point" />}
|
||||
label={service.statusLabel}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
src/components/user/UserStatusBadge.jsx
Normal file
16
src/components/user/UserStatusBadge.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import Chip from "@mui/material/Chip";
|
||||
import { userStatusMeta } from "../../data/users.js";
|
||||
|
||||
export function UserStatusBadge({ status }) {
|
||||
const meta = userStatusMeta[status];
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className={`status-badge status-badge--${meta.tone}`}
|
||||
icon={<span className="status-point" />}
|
||||
label={meta.label}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
}
|
||||
148
src/components/user/UserTable.jsx
Normal file
148
src/components/user/UserTable.jsx
Normal file
@ -0,0 +1,148 @@
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import LockOutlined from "@mui/icons-material/LockOutlined";
|
||||
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
|
||||
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
||||
import Person from "@mui/icons-material/Person";
|
||||
import PersonRemoveOutlined from "@mui/icons-material/PersonRemoveOutlined";
|
||||
import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import { Card } from "../base/Card.jsx";
|
||||
import { IconButton } from "../base/IconButton.jsx";
|
||||
import { UserStatusBadge } from "./UserStatusBadge.jsx";
|
||||
|
||||
export function UserTable({
|
||||
canEdit,
|
||||
canResetPassword,
|
||||
canStatus,
|
||||
onEdit,
|
||||
onResetPassword,
|
||||
onToggleDisabled,
|
||||
onToggleLocked,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
total,
|
||||
users
|
||||
}) {
|
||||
const allChecked = users.length > 0 && users.every((user) => selectedIds.includes(user.id));
|
||||
|
||||
const toggleAll = (checked) => {
|
||||
if (!checked) {
|
||||
setSelectedIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedIds(users.map((user) => user.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="table-card user-table-card" component="section">
|
||||
<div className="table-toolbar">
|
||||
<div className="card-title">用户清单</div>
|
||||
<div className="muted">共 {total} 条</div>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<div className="user-table user-table--selectable">
|
||||
<div className="user-row user-row--head">
|
||||
<div>
|
||||
<Checkbox checked={allChecked} onChange={(event) => toggleAll(event.target.checked)} size="small" />
|
||||
</div>
|
||||
<div>用户</div>
|
||||
<div>角色</div>
|
||||
<div>团队</div>
|
||||
<div>状态</div>
|
||||
<div>MFA</div>
|
||||
<div>最后登录</div>
|
||||
<div>操作</div>
|
||||
</div>
|
||||
{users.length ? (
|
||||
users.map((user) => (
|
||||
<div className="user-row" key={user.id}>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(user.id)}
|
||||
onChange={(event) => {
|
||||
setSelectedIds((current) =>
|
||||
event.target.checked ? [...current, user.id] : current.filter((id) => id !== user.id)
|
||||
);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="user-cell user-main">
|
||||
<div className="user-avatar">
|
||||
<Person fontSize="small" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="service-name">{user.name}</div>
|
||||
<div className="service-desc">{user.account}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-cell">{user.role || "-"}</div>
|
||||
<div className="user-cell">{user.team || "-"}</div>
|
||||
<div className="user-cell">
|
||||
<UserStatusBadge status={user.status} />
|
||||
</div>
|
||||
<div className="user-cell">{user.mfa}</div>
|
||||
<div className="user-cell muted">{user.lastLogin}</div>
|
||||
<UserRowActions
|
||||
canEdit={canEdit}
|
||||
canResetPassword={canResetPassword}
|
||||
canStatus={canStatus}
|
||||
onEdit={onEdit}
|
||||
onResetPassword={onResetPassword}
|
||||
onToggleDisabled={onToggleDisabled}
|
||||
onToggleLocked={onToggleLocked}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-state">暂无匹配用户</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRowActions({
|
||||
canEdit,
|
||||
canResetPassword,
|
||||
canStatus,
|
||||
onEdit,
|
||||
onResetPassword,
|
||||
onToggleDisabled,
|
||||
onToggleLocked,
|
||||
user
|
||||
}) {
|
||||
const isDisabled = user.status === "disabled";
|
||||
const isLocked = user.status === "locked";
|
||||
|
||||
return (
|
||||
<div className="row-actions">
|
||||
<IconButton className="action-button" disabled={!canEdit} label="编辑" onClick={() => onEdit(user)}>
|
||||
<EditOutlined fontSize="inherit" />
|
||||
</IconButton>
|
||||
<IconButton className="action-button" disabled={!canResetPassword} label="重置密码" onClick={() => onResetPassword(user)}>
|
||||
<PasswordOutlined fontSize="inherit" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="action-button"
|
||||
disabled={!canStatus || isDisabled}
|
||||
label={isLocked ? "解锁" : "锁定"}
|
||||
onClick={() => onToggleLocked(user)}
|
||||
tone={isLocked ? "success" : undefined}
|
||||
>
|
||||
{isLocked ? <LockOpenOutlined fontSize="inherit" /> : <LockOutlined fontSize="inherit" />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className={`action-button ${isDisabled ? "action-button--success" : "action-button--danger"}`}
|
||||
disabled={!canStatus}
|
||||
label={isDisabled ? "启用" : "停用"}
|
||||
onClick={() => onToggleDisabled(user)}
|
||||
tone={isDisabled ? "success" : "danger"}
|
||||
>
|
||||
{isDisabled ? <VerifiedUserOutlined fontSize="inherit" /> : <PersonRemoveOutlined fontSize="inherit" />}
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/config/env.js
Normal file
1
src/config/env.js
Normal file
@ -0,0 +1 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api";
|
||||
105
src/constants/navigation.js
Normal file
105
src/constants/navigation.js
Normal file
@ -0,0 +1,105 @@
|
||||
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
||||
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
||||
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
||||
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
||||
import NotificationsNoneOutlined from "@mui/icons-material/NotificationsNoneOutlined";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
|
||||
|
||||
export const navItems = [
|
||||
{ id: "overview", code: "overview", label: "总览", path: "/overview", icon: DashboardOutlined },
|
||||
{ id: "services", code: "services", label: "服务管理", path: "/services", icon: Inventory2Outlined },
|
||||
{
|
||||
id: "system",
|
||||
code: "system",
|
||||
label: "系统管理",
|
||||
icon: SettingsApplicationsOutlined,
|
||||
children: [
|
||||
{ id: "system-users", code: "system-users", label: "用户管理", path: "/system/users", icon: ManageAccountsOutlined },
|
||||
{ id: "system-roles", code: "system-roles", label: "角色配置", path: "/system/roles", icon: ShieldOutlined },
|
||||
{ id: "system-menus", code: "system-menus", label: "菜单权限", path: "/system/menus", icon: MenuOpenOutlined }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "logs",
|
||||
code: "logs",
|
||||
label: "日志审计",
|
||||
icon: HistoryOutlined,
|
||||
children: [
|
||||
{ id: "logs-login", code: "logs-login", label: "登录日志", path: "/logs/login", icon: LoginOutlined },
|
||||
{ id: "logs-operations", code: "logs-operations", label: "操作日志", path: "/logs/operations", icon: ReceiptLongOutlined }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
code: "notifications",
|
||||
label: "通知中心",
|
||||
path: "/notifications",
|
||||
icon: NotificationsNoneOutlined
|
||||
}
|
||||
];
|
||||
|
||||
const iconMap = {
|
||||
dashboard: DashboardOutlined,
|
||||
history: HistoryOutlined,
|
||||
inventory: Inventory2Outlined,
|
||||
login: LoginOutlined,
|
||||
menu: MenuOpenOutlined,
|
||||
notifications: NotificationsNoneOutlined,
|
||||
receipt: ReceiptLongOutlined,
|
||||
settings: SettingsApplicationsOutlined,
|
||||
shield: ShieldOutlined,
|
||||
users: ManageAccountsOutlined
|
||||
};
|
||||
|
||||
export function mapBackendMenus(menus = []) {
|
||||
return menus.map((item) => ({
|
||||
id: item.code || String(item.id),
|
||||
code: item.code,
|
||||
label: item.label,
|
||||
path: item.path,
|
||||
icon: iconMap[item.icon] || MenuOpenOutlined,
|
||||
permissionCode: item.permissionCode,
|
||||
children: item.children?.length ? mapBackendMenus(item.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
export function findNavItemById(id, items = navItems) {
|
||||
for (const item of items) {
|
||||
if (item.id === id) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (item.children?.length) {
|
||||
const child = findNavItemById(id, item.children);
|
||||
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findNavItemByPath(pathname, items = navItems) {
|
||||
let fallback = null;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.path && pathname.startsWith(item.path)) {
|
||||
fallback = item;
|
||||
}
|
||||
|
||||
if (item.children?.length) {
|
||||
const child = findNavItemByPath(pathname, item.children);
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
8
src/constants/status.js
Normal file
8
src/constants/status.js
Normal file
@ -0,0 +1,8 @@
|
||||
export const statusMeta = {
|
||||
running: { label: "运行中", className: "running" },
|
||||
warning: { label: "警告", className: "warning" },
|
||||
error: { label: "异常", className: "error" },
|
||||
stopped: { label: "已停止", className: "stopped" }
|
||||
};
|
||||
|
||||
export const detailTabs = ["基础信息", "配置", "监控", "日志"];
|
||||
108
src/data/services.js
Normal file
108
src/data/services.js
Normal file
@ -0,0 +1,108 @@
|
||||
export const initialServices = [
|
||||
{
|
||||
id: "svc-gateway",
|
||||
name: "API Gateway",
|
||||
desc: "流量入口 / 鉴权 / 限流",
|
||||
status: "running",
|
||||
statusLabel: "运行中",
|
||||
cpu: 36,
|
||||
memory: 58,
|
||||
deploy: "prod-a",
|
||||
version: "v2.8.4",
|
||||
port: "8080",
|
||||
replicas: "6/6",
|
||||
response: "42ms",
|
||||
updated: "14:22",
|
||||
trend: [34, 38, 35, 42, 39, 44, 36],
|
||||
logs: ["14:22 健康检查通过", "14:18 配置热更新完成", "13:55 副本扩容至 6"]
|
||||
},
|
||||
{
|
||||
id: "svc-auth",
|
||||
name: "Auth Service",
|
||||
desc: "账号 / 权限 / Token",
|
||||
status: "warning",
|
||||
statusLabel: "警告",
|
||||
cpu: 68,
|
||||
memory: 74,
|
||||
deploy: "prod-a",
|
||||
version: "v1.14.0",
|
||||
port: "7001",
|
||||
replicas: "4/4",
|
||||
response: "96ms",
|
||||
updated: "14:19",
|
||||
trend: [48, 52, 55, 62, 71, 66, 68],
|
||||
logs: ["14:19 P95 延迟超过阈值", "14:07 慢查询 8 条", "13:42 健康检查通过"]
|
||||
},
|
||||
{
|
||||
id: "svc-order",
|
||||
name: "Order Worker",
|
||||
desc: "订单队列 / 任务分发",
|
||||
status: "running",
|
||||
statusLabel: "运行中",
|
||||
cpu: 52,
|
||||
memory: 63,
|
||||
deploy: "prod-b",
|
||||
version: "v3.2.1",
|
||||
port: "9100",
|
||||
replicas: "8/8",
|
||||
response: "61ms",
|
||||
updated: "14:21",
|
||||
trend: [41, 44, 46, 49, 52, 56, 52],
|
||||
logs: ["14:21 消费速率 2.4k/min", "14:10 队列积压清零", "13:36 副本滚动更新完成"]
|
||||
},
|
||||
{
|
||||
id: "svc-billing",
|
||||
name: "Billing Core",
|
||||
desc: "计费 / 对账 / 回调",
|
||||
status: "error",
|
||||
statusLabel: "异常",
|
||||
cpu: 84,
|
||||
memory: 81,
|
||||
deploy: "prod-b",
|
||||
version: "v2.4.9",
|
||||
port: "7300",
|
||||
replicas: "2/4",
|
||||
response: "318ms",
|
||||
updated: "14:16",
|
||||
trend: [56, 64, 73, 78, 82, 89, 84],
|
||||
logs: ["14:16 2 个副本健康检查失败", "14:12 回调超时率 6.3%", "13:58 自动重试已触发"]
|
||||
},
|
||||
{
|
||||
id: "svc-cache",
|
||||
name: "Redis Cluster",
|
||||
desc: "缓存 / 分布式锁",
|
||||
status: "running",
|
||||
statusLabel: "运行中",
|
||||
cpu: 29,
|
||||
memory: 47,
|
||||
deploy: "infra",
|
||||
version: "v7.2.5",
|
||||
port: "6379",
|
||||
replicas: "3/3",
|
||||
response: "5ms",
|
||||
updated: "14:23",
|
||||
trend: [24, 28, 27, 31, 29, 32, 29],
|
||||
logs: ["14:23 主从延迟 8ms", "14:00 快照完成", "13:30 内存碎片率正常"]
|
||||
},
|
||||
{
|
||||
id: "svc-search",
|
||||
name: "Search Indexer",
|
||||
desc: "索引构建 / 查询同步",
|
||||
status: "stopped",
|
||||
statusLabel: "已停止",
|
||||
cpu: 0,
|
||||
memory: 12,
|
||||
deploy: "staging",
|
||||
version: "v0.9.7",
|
||||
port: "9200",
|
||||
replicas: "0/2",
|
||||
response: "-",
|
||||
updated: "13:45",
|
||||
trend: [23, 18, 14, 8, 0, 0, 0],
|
||||
logs: ["13:45 手动停止", "13:40 索引任务完成", "13:20 灰度环境同步完成"]
|
||||
}
|
||||
];
|
||||
|
||||
export const trafficSeries = [36, 42, 39, 54, 49, 62, 58, 73, 68, 76, 71, 82];
|
||||
export const cpuSeries = [42, 44, 48, 52, 50, 57, 61, 58, 64, 69, 66, 72];
|
||||
export const alertSeries = [4, 5, 3, 8, 6, 9, 7, 12, 10, 14, 11, 9];
|
||||
58
src/data/users.js
Normal file
58
src/data/users.js
Normal file
@ -0,0 +1,58 @@
|
||||
export const userStatusMeta = {
|
||||
active: { label: "启用", tone: "running" },
|
||||
locked: { label: "锁定", tone: "warning" },
|
||||
disabled: { label: "停用", tone: "stopped" }
|
||||
};
|
||||
|
||||
export const initialUsers = [
|
||||
{
|
||||
id: "u-001",
|
||||
name: "admin",
|
||||
account: "admin",
|
||||
role: "平台管理员",
|
||||
team: "平台运维",
|
||||
status: "active",
|
||||
mfa: "已开启",
|
||||
lastLogin: "14:22"
|
||||
},
|
||||
{
|
||||
id: "u-002",
|
||||
name: "陈一",
|
||||
account: "chenyi",
|
||||
role: "运维管理员",
|
||||
team: "应用运维",
|
||||
status: "active",
|
||||
mfa: "已开启",
|
||||
lastLogin: "13:48"
|
||||
},
|
||||
{
|
||||
id: "u-003",
|
||||
name: "李安",
|
||||
account: "lian",
|
||||
role: "审计员",
|
||||
team: "安全合规",
|
||||
status: "locked",
|
||||
mfa: "已开启",
|
||||
lastLogin: "昨日 18:31"
|
||||
},
|
||||
{
|
||||
id: "u-004",
|
||||
name: "王北",
|
||||
account: "wangbei",
|
||||
role: "只读用户",
|
||||
team: "研发协作",
|
||||
status: "disabled",
|
||||
mfa: "未开启",
|
||||
lastLogin: "04-26 09:15"
|
||||
},
|
||||
{
|
||||
id: "u-005",
|
||||
name: "赵敏",
|
||||
account: "zhaomin",
|
||||
role: "运维管理员",
|
||||
team: "数据库运维",
|
||||
status: "active",
|
||||
mfa: "已开启",
|
||||
lastLogin: "12:05"
|
||||
}
|
||||
];
|
||||
28
src/main.jsx
Normal file
28
src/main.jsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { AuthProvider } from "./auth/AuthProvider.jsx";
|
||||
import { ConfirmProvider } from "./components/base/ConfirmProvider.jsx";
|
||||
import { ToastProvider } from "./components/base/ToastProvider.jsx";
|
||||
import App from "./App.jsx";
|
||||
import { theme } from "./theme.js";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/base.css";
|
||||
import "./styles/app.css";
|
||||
|
||||
createRoot(document.getElementById("app")).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
76
src/pages/LoginPage.jsx
Normal file
76
src/pages/LoginPage.jsx
Normal file
@ -0,0 +1,76 @@
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useState } from "react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthProvider.jsx";
|
||||
import { Button } from "../components/base/Button.jsx";
|
||||
import { useToast } from "../components/base/ToastProvider.jsx";
|
||||
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { login, user } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const from = location.state?.from?.pathname || "/overview";
|
||||
|
||||
if (user) {
|
||||
return <Navigate to={from} replace />;
|
||||
}
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login({ username, password });
|
||||
showToast("登录成功", "success");
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
showToast(err.message || "登录失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<form className="login-panel" onSubmit={submit}>
|
||||
<div className="login-brand">
|
||||
<div className="brand-mark">
|
||||
<Inventory2Outlined fontSize="small" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>HYApp 管理平台</h1>
|
||||
<p>Admin Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="login-form">
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="账号"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
value={username}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="密码"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
<Button disabled={submitting} type="submit" variant="primary">
|
||||
<LoginOutlined fontSize="small" />
|
||||
{submitting ? "登录中" : "登录"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
107
src/pages/LogsPage.jsx
Normal file
107
src/pages/LogsPage.jsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { listLoginLogs, listOperationLogs } from "../api/logs.js";
|
||||
import { Card } from "../components/base/Card.jsx";
|
||||
import { DataState } from "../components/base/DataState.jsx";
|
||||
import { PaginationBar } from "../components/base/PaginationBar.jsx";
|
||||
import { SearchBox } from "../components/base/SearchBox.jsx";
|
||||
import { PageHead } from "../components/layout/PageHead.jsx";
|
||||
|
||||
export function LogsPage({ type }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState({ items: [], total: 0, page: 1, pageSize: 10 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const { refreshTick } = useOutletContext();
|
||||
const isLogin = type === "login";
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const loader = isLogin ? listLoginLogs : listOperationLogs;
|
||||
const result = await loader({ page, page_size: 10, keyword: query });
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err.message || "加载日志失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [page, query, refreshTick, type]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={isLogin ? "登录日志" : "操作日志"} />
|
||||
<section className="filters-panel">
|
||||
<SearchBox value={query} onChange={(value) => { setQuery(value); setPage(1); }} placeholder="搜索用户、IP、动作、资源..." />
|
||||
</section>
|
||||
|
||||
<DataState error={error} loading={loading} onRetry={load}>
|
||||
<Card className="table-card" component="section">
|
||||
<div className="table-toolbar">
|
||||
<div className="card-title">{isLogin ? "登录记录" : "操作记录"}</div>
|
||||
<div className="muted">共 {data.total} 条</div>
|
||||
</div>
|
||||
<div className={`admin-table ${isLogin ? "admin-table--login-logs" : "admin-table--operation-logs"}`}>
|
||||
<div className="admin-row admin-row--head">
|
||||
{isLogin ? (
|
||||
<>
|
||||
<div>用户</div>
|
||||
<div>IP</div>
|
||||
<div>状态</div>
|
||||
<div>消息</div>
|
||||
<div>时间</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>用户</div>
|
||||
<div>动作</div>
|
||||
<div>资源</div>
|
||||
<div>状态</div>
|
||||
<div>详情</div>
|
||||
<div>时间</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(data.items || []).map((item) => (
|
||||
<div className="admin-row" key={item.id}>
|
||||
{isLogin ? (
|
||||
<>
|
||||
<div>{item.username || "-"}</div>
|
||||
<div className="muted">{item.ip || "-"}</div>
|
||||
<div>{item.status}</div>
|
||||
<div className="muted">{item.message || "-"}</div>
|
||||
<div className="muted">{formatDate(item.createdAt)}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>{item.username || "-"}</div>
|
||||
<div>{item.action}</div>
|
||||
<div className="muted">{item.resource || "-"}</div>
|
||||
<div>{item.status}</div>
|
||||
<div className="muted">{item.detail || "-"}</div>
|
||||
<div className="muted">{formatDate(item.createdAt)}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!data.items?.length ? <div className="empty-state">暂无日志</div> : null}
|
||||
</div>
|
||||
</Card>
|
||||
<PaginationBar page={page} pageSize={data.pageSize || 10} total={data.total || 0} onPageChange={setPage} />
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
81
src/pages/MenuManagementPage.jsx
Normal file
81
src/pages/MenuManagementPage.jsx
Normal file
@ -0,0 +1,81 @@
|
||||
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { getMenus } from "../api/navigation.js";
|
||||
import { listPermissions } from "../api/roles.js";
|
||||
import { Card } from "../components/base/Card.jsx";
|
||||
import { DataState } from "../components/base/DataState.jsx";
|
||||
import { KpiCard } from "../components/base/KpiCard.jsx";
|
||||
import { PageHead } from "../components/layout/PageHead.jsx";
|
||||
|
||||
export function MenuManagementPage() {
|
||||
const [menus, setMenus] = useState([]);
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const { refreshTick } = useOutletContext();
|
||||
|
||||
const flatMenus = useMemo(() => flattenMenus(menus), [menus]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [menuData, permissionData] = await Promise.all([getMenus(), listPermissions()]);
|
||||
setMenus(menuData || []);
|
||||
setPermissions(permissionData || []);
|
||||
} catch (err) {
|
||||
setError(err.message || "加载菜单权限失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [refreshTick]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="菜单权限" />
|
||||
|
||||
<DataState error={error} loading={loading} onRetry={load}>
|
||||
<section className="user-kpi-row" aria-label="菜单权限指标">
|
||||
<KpiCard icon={MenuOpenOutlined} label="菜单数量" value={flatMenus.length} sub={<span className="status-ok">来自后端菜单树</span>} />
|
||||
<KpiCard icon={ShieldOutlined} label="权限数量" value={permissions.length} tone="info" sub={<span className="status-ok">菜单 / 按钮 / 接口</span>} />
|
||||
</section>
|
||||
|
||||
<Card className="table-card" component="section">
|
||||
<div className="table-toolbar">
|
||||
<div className="card-title">菜单树</div>
|
||||
<div className="muted">共 {flatMenus.length} 项</div>
|
||||
</div>
|
||||
<div className="admin-table admin-table--menus">
|
||||
<div className="admin-row admin-row--head">
|
||||
<div>菜单</div>
|
||||
<div>路由</div>
|
||||
<div>权限码</div>
|
||||
<div>图标</div>
|
||||
</div>
|
||||
{flatMenus.map((menu) => (
|
||||
<div className="admin-row" key={menu.code}>
|
||||
<div style={{ paddingLeft: menu.depth * 18 }}>{menu.label}</div>
|
||||
<div className="muted">{menu.path || "-"}</div>
|
||||
<div>{menu.permissionCode || "-"}</div>
|
||||
<div className="muted">{menu.icon || "-"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function flattenMenus(menus, depth = 0) {
|
||||
return menus.flatMap((menu) => [
|
||||
{ ...menu, depth },
|
||||
...flattenMenus(menu.children || [], depth + 1)
|
||||
]);
|
||||
}
|
||||
76
src/pages/NotificationsPage.jsx
Normal file
76
src/pages/NotificationsPage.jsx
Normal file
@ -0,0 +1,76 @@
|
||||
import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { listNotifications, markNotificationRead } from "../api/notifications.js";
|
||||
import { Button } from "../components/base/Button.jsx";
|
||||
import { Card } from "../components/base/Card.jsx";
|
||||
import { DataState } from "../components/base/DataState.jsx";
|
||||
import { useToast } from "../components/base/ToastProvider.jsx";
|
||||
import { PageHead } from "../components/layout/PageHead.jsx";
|
||||
|
||||
export function NotificationsPage() {
|
||||
const [data, setData] = useState({ items: [], unread: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const { refreshTick } = useOutletContext();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setData(await listNotifications());
|
||||
} catch (err) {
|
||||
setError(err.message || "加载通知失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [refreshTick]);
|
||||
|
||||
const read = async (id) => {
|
||||
await markNotificationRead(id);
|
||||
showToast("通知已标记为已读", "success");
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="通知中心" meta={`未读 ${data.unread || 0} 条`} />
|
||||
<DataState error={error} loading={loading} onRetry={load}>
|
||||
<Card className="table-card" component="section">
|
||||
<div className="notification-list">
|
||||
{(data.items || []).map((item) => (
|
||||
<div className={`notification-item notification-item--${item.level}`} key={item.id}>
|
||||
<div>
|
||||
<div className="notification-title">{item.title}</div>
|
||||
<div className="notification-content">{item.content}</div>
|
||||
<div className="muted">{formatDate(item.createdAt)}</div>
|
||||
</div>
|
||||
{!item.readAt ? (
|
||||
<Button onClick={() => read(item.id)}>
|
||||
<DoneAllOutlined fontSize="small" />
|
||||
已读
|
||||
</Button>
|
||||
) : (
|
||||
<span className="muted">已读</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!data.items?.length ? <div className="empty-state">暂无通知</div> : null}
|
||||
</div>
|
||||
</Card>
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
173
src/pages/OverviewPage.jsx
Normal file
173
src/pages/OverviewPage.jsx
Normal file
@ -0,0 +1,173 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import DeveloperBoardOutlined from "@mui/icons-material/DeveloperBoardOutlined";
|
||||
import DnsOutlined from "@mui/icons-material/DnsOutlined";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import Refresh from "@mui/icons-material/Refresh";
|
||||
import SecurityOutlined from "@mui/icons-material/SecurityOutlined";
|
||||
import StorageOutlined from "@mui/icons-material/StorageOutlined";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { getDashboardOverview } from "../api/dashboard.js";
|
||||
import { listServices, restartService as restartServiceApi, updateServiceStatus } from "../api/services.js";
|
||||
import { useAuth } from "../auth/AuthProvider.jsx";
|
||||
import { Button } from "../components/base/Button.jsx";
|
||||
import { Card } from "../components/base/Card.jsx";
|
||||
import { DataState } from "../components/base/DataState.jsx";
|
||||
import { KpiCard } from "../components/base/KpiCard.jsx";
|
||||
import { TimeRangeSelect } from "../components/base/TimeRangeSelect.jsx";
|
||||
import { useConfirm } from "../components/base/ConfirmProvider.jsx";
|
||||
import { useToast } from "../components/base/ToastProvider.jsx";
|
||||
import { BarChart } from "../components/charts/BarChart.jsx";
|
||||
import { ChartCard } from "../components/charts/ChartCard.jsx";
|
||||
import { StatusDonut } from "../components/charts/StatusDonut.jsx";
|
||||
import { PageHead } from "../components/layout/PageHead.jsx";
|
||||
import { ServerRail } from "../components/service/ServerRail.jsx";
|
||||
import { ServiceTable } from "../components/service/ServiceTable.jsx";
|
||||
|
||||
export function OverviewPage() {
|
||||
const [range, setRange] = useState("近 1 小时");
|
||||
const [overview, setOverview] = useState(null);
|
||||
const [services, setServices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const { refreshTick } = useOutletContext();
|
||||
const { can } = useAuth();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [overviewData, serviceData] = await Promise.all([
|
||||
getDashboardOverview(),
|
||||
listServices({ page: 1, page_size: 8 })
|
||||
]);
|
||||
setOverview(overviewData);
|
||||
setServices(serviceData.items || []);
|
||||
} catch (err) {
|
||||
setError(err.message || "加载总览失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [refreshTick]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!overview) {
|
||||
return { running: 0, stopped: 0, warning: 0, error: 0, avgCpu: 0, avgMemory: 0 };
|
||||
}
|
||||
return {
|
||||
running: overview.running,
|
||||
stopped: overview.stopped,
|
||||
warning: overview.warning,
|
||||
error: overview.error,
|
||||
avgCpu: overview.avgCpu,
|
||||
avgMemory: overview.avgMemory
|
||||
};
|
||||
}, [overview]);
|
||||
|
||||
const setServiceStatus = async (id, status) => {
|
||||
if (status === "stopped") {
|
||||
const ok = await confirm({
|
||||
title: "停止服务",
|
||||
message: "停止后该服务会立即进入不可用状态。",
|
||||
confirmText: "停止",
|
||||
tone: "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await updateServiceStatus(id, status);
|
||||
showToast("服务状态已更新", "success");
|
||||
load();
|
||||
};
|
||||
|
||||
const restartService = async (id) => {
|
||||
const ok = await confirm({
|
||||
title: "重启服务",
|
||||
message: "重启会写入操作日志并刷新服务状态。",
|
||||
confirmText: "重启"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await restartServiceApi(id);
|
||||
showToast("服务已重启", "success");
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="系统概览" meta={`刷新时间 ${overview?.updatedAt || "-"}`}>
|
||||
<TimeRangeSelect value={range} onChange={setRange} />
|
||||
<Button onClick={load}>
|
||||
<Refresh fontSize="small" />
|
||||
刷新
|
||||
</Button>
|
||||
{can("service:create") ? (
|
||||
<Button variant="primary">
|
||||
<Add fontSize="small" />
|
||||
部署服务
|
||||
</Button>
|
||||
) : null}
|
||||
</PageHead>
|
||||
|
||||
<DataState error={error} loading={loading} onRetry={load}>
|
||||
<section className="kpi-grid" aria-label="系统指标">
|
||||
<KpiCard icon={DnsOutlined} label="服务器总数" value={overview?.serversTotal || 0} unit="台" sub={<><span className="status-ok">在线 {overview?.serversOnline || 0}</span></>} />
|
||||
<KpiCard icon={Inventory2Outlined} label="服务总数" value={overview?.servicesTotal || 0} unit="个" sub={<><span className="status-ok">运行中 {stats.running}</span> / 已停止 {stats.stopped}</>} />
|
||||
<KpiCard icon={DeveloperBoardOutlined} label="CPU 使用率" value={stats.avgCpu} unit="%" tone="info" sub={<span className="status-ok">来自服务资产</span>} />
|
||||
<KpiCard icon={StorageOutlined} label="内存使用率" value={stats.avgMemory} unit="%" tone="success" sub={<span className="status-ok">来自服务资产</span>} />
|
||||
<KpiCard icon={SecurityOutlined} label="告警数量" value={stats.warning + stats.error} unit="个" tone="warning" sub={<><span className="status-danger">严重 {stats.error}</span> / 警告 {stats.warning}</>} />
|
||||
</section>
|
||||
|
||||
<section className="overview-charts" aria-label="资源图表">
|
||||
<Card className="chart-card">
|
||||
<div className="card-head">
|
||||
<div className="card-title">服务器状态分布</div>
|
||||
</div>
|
||||
<StatusDonut stats={stats} total={overview?.servicesTotal || services.length} />
|
||||
</Card>
|
||||
<ChartCard title="CPU 使用率(%)" color="#2563eb" series={overview?.series?.cpu || []} />
|
||||
<ChartCard title="内存使用率(%)" color="#16a34a" series={overview?.series?.traffic || []} />
|
||||
<Card className="chart-card">
|
||||
<div className="card-head">
|
||||
<div className="card-title">告警趋势</div>
|
||||
</div>
|
||||
<BarChart series={overview?.series?.alerts || []} />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="split-grid">
|
||||
<ServerRail services={services} openDetail={() => {}} />
|
||||
<ServiceTable
|
||||
canOperate={can("service:operate")}
|
||||
services={services}
|
||||
openDetail={() => {}}
|
||||
restartService={restartService}
|
||||
setServiceStatus={setServiceStatus}
|
||||
title="服务列表"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="alert-strip" aria-label="实时告警">
|
||||
{(overview?.alerts || []).map((alert) => (
|
||||
<div className="alert-item" key={`${alert.name}-${alert.time}`}>
|
||||
<span className={`alert-dot alert-dot--${alert.type}`} />
|
||||
<div>
|
||||
<div className="alert-name">{alert.name}</div>
|
||||
<div className="alert-desc">{alert.desc}</div>
|
||||
</div>
|
||||
<div className="alert-time">{alert.time}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
}
|
||||
194
src/pages/RoleManagementPage.jsx
Normal file
194
src/pages/RoleManagementPage.jsx
Normal file
@ -0,0 +1,194 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { createRole, deleteRole, listPermissions, listRoles, updateRole } from "../api/roles.js";
|
||||
import { useAuth } from "../auth/AuthProvider.jsx";
|
||||
import { Button } from "../components/base/Button.jsx";
|
||||
import { Card } from "../components/base/Card.jsx";
|
||||
import { DataState } from "../components/base/DataState.jsx";
|
||||
import { IconButton } from "../components/base/IconButton.jsx";
|
||||
import { useConfirm } from "../components/base/ConfirmProvider.jsx";
|
||||
import { useToast } from "../components/base/ToastProvider.jsx";
|
||||
import { PageHead } from "../components/layout/PageHead.jsx";
|
||||
|
||||
const emptyRole = { name: "", code: "", description: "", permissionIds: [] };
|
||||
|
||||
export function RoleManagementPage() {
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState(null);
|
||||
const [form, setForm] = useState(emptyRole);
|
||||
const { refreshTick } = useOutletContext();
|
||||
const { can } = useAuth();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const groupedPermissions = useMemo(() => {
|
||||
return permissions.reduce((groups, permission) => {
|
||||
const key = permission.kind || "other";
|
||||
groups[key] = groups[key] || [];
|
||||
groups[key].push(permission);
|
||||
return groups;
|
||||
}, {});
|
||||
}, [permissions]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [roleData, permissionData] = await Promise.all([listRoles(), listPermissions()]);
|
||||
setRoles(roleData || []);
|
||||
setPermissions(permissionData || []);
|
||||
} catch (err) {
|
||||
setError(err.message || "加载角色失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [refreshTick]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingRole(null);
|
||||
setForm(emptyRole);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (role) => {
|
||||
setEditingRole(role);
|
||||
setForm({
|
||||
name: role.name,
|
||||
code: role.code,
|
||||
description: role.description || "",
|
||||
permissionIds: role.permissions?.map((permission) => permission.id) || []
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (editingRole) {
|
||||
await updateRole(editingRole.id, form);
|
||||
showToast("角色已更新", "success");
|
||||
} else {
|
||||
await createRole(form);
|
||||
showToast("角色已创建", "success");
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存角色失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (role) => {
|
||||
const ok = await confirm({
|
||||
title: "删除角色",
|
||||
message: `${role.name} 删除后会影响已绑定用户的权限。`,
|
||||
confirmText: "删除",
|
||||
tone: "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await deleteRole(role.id);
|
||||
showToast("角色已删除", "success");
|
||||
load();
|
||||
};
|
||||
|
||||
const togglePermission = (permissionId, checked) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
permissionIds: checked
|
||||
? [...current.permissionIds, permissionId]
|
||||
: current.permissionIds.filter((id) => id !== permissionId)
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="角色配置">
|
||||
{can("role:manage") ? (
|
||||
<Button variant="primary" onClick={openCreate}>
|
||||
<Add fontSize="small" />
|
||||
新增角色
|
||||
</Button>
|
||||
) : null}
|
||||
</PageHead>
|
||||
|
||||
<DataState error={error} loading={loading} onRetry={load}>
|
||||
<Card className="table-card" component="section">
|
||||
<div className="table-toolbar">
|
||||
<div className="card-title">角色清单</div>
|
||||
<div className="muted">共 {roles.length} 条</div>
|
||||
</div>
|
||||
<div className="admin-table admin-table--roles">
|
||||
<div className="admin-row admin-row--head">
|
||||
<div>角色</div>
|
||||
<div>编码</div>
|
||||
<div>权限数量</div>
|
||||
<div>说明</div>
|
||||
<div>操作</div>
|
||||
</div>
|
||||
{roles.map((role) => (
|
||||
<div className="admin-row" key={role.id}>
|
||||
<div>{role.name}</div>
|
||||
<div className="muted">{role.code}</div>
|
||||
<div>{role.permissions?.length || 0}</div>
|
||||
<div className="muted">{role.description || "-"}</div>
|
||||
<div className="row-actions">
|
||||
<IconButton className="action-button" disabled={!can("role:manage")} label="编辑" onClick={() => openEdit(role)}>
|
||||
<EditOutlined fontSize="inherit" />
|
||||
</IconButton>
|
||||
<IconButton className="action-button action-button--danger" disabled={!can("role:manage")} label="删除" onClick={() => remove(role)} tone="danger">
|
||||
<DeleteOutlineOutlined fontSize="inherit" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</DataState>
|
||||
|
||||
<Drawer anchor="right" open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
<form className="form-drawer form-drawer--wide" onSubmit={submit}>
|
||||
<h2>{editingRole ? "编辑角色" : "新增角色"}</h2>
|
||||
<TextField label="角色名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
<TextField label="角色编码" required value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||||
<TextField label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||
{Object.entries(groupedPermissions).map(([kind, items]) => (
|
||||
<div className="permission-section" key={kind}>
|
||||
<div className="form-section-title">{kind}</div>
|
||||
<div className="permission-grid">
|
||||
{items.map((permission) => (
|
||||
<label className="permission-check" key={permission.id}>
|
||||
<input
|
||||
checked={form.permissionIds.includes(permission.id)}
|
||||
onChange={(event) => togglePermission(permission.id, event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{permission.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="form-drawer__actions">
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button type="submit" variant="primary">提交</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
232
src/pages/ServicesPage.jsx
Normal file
232
src/pages/ServicesPage.jsx
Normal file
@ -0,0 +1,232 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import PlayCircleOutlined from "@mui/icons-material/PlayCircleOutlined";
|
||||
import StopCircleOutlined from "@mui/icons-material/StopCircleOutlined";
|
||||
import WarningAmberOutlined from "@mui/icons-material/WarningAmberOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useOutletContext, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
createService,
|
||||
listServices,
|
||||
restartService as restartServiceApi,
|
||||
updateServiceStatus
|
||||
} from "../api/services.js";
|
||||
import { useAuth } from "../auth/AuthProvider.jsx";
|
||||
import { Button } from "../components/base/Button.jsx";
|
||||
import { DataState } from "../components/base/DataState.jsx";
|
||||
import { FilterChip } from "../components/base/FilterChip.jsx";
|
||||
import { KpiCard } from "../components/base/KpiCard.jsx";
|
||||
import { PaginationBar } from "../components/base/PaginationBar.jsx";
|
||||
import { SearchBox } from "../components/base/SearchBox.jsx";
|
||||
import { useConfirm } from "../components/base/ConfirmProvider.jsx";
|
||||
import { useToast } from "../components/base/ToastProvider.jsx";
|
||||
import { PageHead } from "../components/layout/PageHead.jsx";
|
||||
import { DetailDrawer, InlineInspector } from "../components/service/DetailPanel.jsx";
|
||||
import { ServiceTable } from "../components/service/ServiceTable.jsx";
|
||||
import { detailTabs } from "../constants/status.js";
|
||||
|
||||
const filterItems = [
|
||||
["all", "全部状态"],
|
||||
["running", "运行中"],
|
||||
["warning", "警告"],
|
||||
["error", "异常"],
|
||||
["stopped", "已停止"]
|
||||
];
|
||||
|
||||
const emptyForm = {
|
||||
name: "",
|
||||
desc: "",
|
||||
deploy: "prod-a",
|
||||
version: "v1.0.0",
|
||||
port: "8080",
|
||||
replicas: "1/1",
|
||||
response: "-",
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
status: "running"
|
||||
};
|
||||
|
||||
export function ServicesPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState({ items: [], total: 0, page: 1, pageSize: 10 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(detailTabs[0]);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const { refreshTick } = useOutletContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { can } = useAuth();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await listServices({ page, page_size: 10, keyword: query, status: statusFilter });
|
||||
setData(result);
|
||||
if (!selectedId && result.items?.length) {
|
||||
setSelectedId(Number(searchParams.get("service")) || result.items[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || "加载服务失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [page, query, statusFilter, refreshTick]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const items = data.items || [];
|
||||
return {
|
||||
running: items.filter((service) => service.status === "running").length,
|
||||
stopped: items.filter((service) => service.status === "stopped").length,
|
||||
error: items.filter((service) => service.status === "error").length
|
||||
};
|
||||
}, [data.items]);
|
||||
|
||||
const selectedService = (data.items || []).find((service) => service.id === selectedId) || data.items?.[0];
|
||||
|
||||
const openDetail = (id) => {
|
||||
setSelectedId(id);
|
||||
setActiveTab(detailTabs[0]);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const setServiceStatus = async (id, status) => {
|
||||
if (status === "stopped") {
|
||||
const ok = await confirm({
|
||||
title: "停止服务",
|
||||
message: "停止服务会写入操作日志,并让该服务进入不可用状态。",
|
||||
confirmText: "停止",
|
||||
tone: "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await updateServiceStatus(id, status);
|
||||
showToast("服务状态已更新", "success");
|
||||
load();
|
||||
};
|
||||
|
||||
const restartService = async (id) => {
|
||||
const ok = await confirm({ title: "重启服务", message: "重启会刷新服务状态并记录操作日志。", confirmText: "重启" });
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await restartServiceApi(id);
|
||||
showToast("服务已重启", "success");
|
||||
load();
|
||||
};
|
||||
|
||||
const submitService = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await createService({ ...form, cpu: Number(form.cpu), memory: Number(form.memory) });
|
||||
showToast("服务已创建", "success");
|
||||
setFormOpen(false);
|
||||
setForm(emptyForm);
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast(err.message || "创建服务失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="服务列表">
|
||||
{can("service:create") ? (
|
||||
<Button variant="primary" onClick={() => setFormOpen(true)}>
|
||||
<Add fontSize="small" />
|
||||
创建服务
|
||||
</Button>
|
||||
) : null}
|
||||
</PageHead>
|
||||
|
||||
<section className="service-kpi-row" aria-label="服务指标">
|
||||
<KpiCard icon={Inventory2Outlined} label="服务总数" value={data.total} sub={<span className="status-ok">来自后端资产</span>} />
|
||||
<KpiCard icon={PlayCircleOutlined} label="运行中" value={stats.running} tone="success" sub={<span className="status-ok">当前页统计</span>} />
|
||||
<KpiCard icon={StopCircleOutlined} label="已停止" value={stats.stopped} tone="warning" sub={<span className="status-warn">当前页统计</span>} />
|
||||
<KpiCard icon={WarningAmberOutlined} label="异常" value={stats.error} tone="warning" sub={<span className="status-danger">当前页统计</span>} />
|
||||
</section>
|
||||
|
||||
<section className="filters-panel">
|
||||
<SearchBox value={query} onChange={(value) => { setQuery(value); setPage(1); }} placeholder="搜索服务名称、IP、端口..." />
|
||||
<div className="filter-row">
|
||||
{filterItems.map(([value, label]) => (
|
||||
<FilterChip
|
||||
active={statusFilter === value}
|
||||
key={value}
|
||||
label={label}
|
||||
onClick={() => {
|
||||
setStatusFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DataState error={error} loading={loading} onRetry={load}>
|
||||
<section className="service-page-grid">
|
||||
<div>
|
||||
<ServiceTable
|
||||
canOperate={can("service:operate")}
|
||||
services={data.items || []}
|
||||
openDetail={openDetail}
|
||||
restartService={restartService}
|
||||
setServiceStatus={setServiceStatus}
|
||||
title="服务清单"
|
||||
/>
|
||||
<PaginationBar page={page} pageSize={data.pageSize || 10} total={data.total || 0} onPageChange={setPage} />
|
||||
</div>
|
||||
<InlineInspector
|
||||
canOperate={can("service:operate")}
|
||||
service={selectedService}
|
||||
restartService={restartService}
|
||||
setServiceStatus={setServiceStatus}
|
||||
/>
|
||||
</section>
|
||||
</DataState>
|
||||
|
||||
<DetailDrawer
|
||||
activeTab={activeTab}
|
||||
canOperate={can("service:operate")}
|
||||
isOpen={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
restartService={restartService}
|
||||
service={selectedService}
|
||||
setActiveTab={setActiveTab}
|
||||
setServiceStatus={setServiceStatus}
|
||||
/>
|
||||
|
||||
<Drawer anchor="right" open={formOpen} onClose={() => setFormOpen(false)}>
|
||||
<form className="form-drawer" onSubmit={submitService}>
|
||||
<h2>创建服务</h2>
|
||||
<TextField label="服务名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
<TextField label="服务类型" value={form.desc} onChange={(event) => setForm({ ...form, desc: event.target.value })} />
|
||||
<TextField label="部署分组" value={form.deploy} onChange={(event) => setForm({ ...form, deploy: event.target.value })} />
|
||||
<TextField label="版本" value={form.version} onChange={(event) => setForm({ ...form, version: event.target.value })} />
|
||||
<TextField label="端口" value={form.port} onChange={(event) => setForm({ ...form, port: event.target.value })} />
|
||||
<TextField label="CPU" type="number" value={form.cpu} onChange={(event) => setForm({ ...form, cpu: event.target.value })} />
|
||||
<TextField label="内存" type="number" value={form.memory} onChange={(event) => setForm({ ...form, memory: event.target.value })} />
|
||||
<div className="form-drawer__actions">
|
||||
<Button onClick={() => setFormOpen(false)}>取消</Button>
|
||||
<Button type="submit" variant="primary">提交</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
335
src/pages/UserManagementPage.jsx
Normal file
335
src/pages/UserManagementPage.jsx
Normal file
@ -0,0 +1,335 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import BlockOutlined from "@mui/icons-material/BlockOutlined";
|
||||
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
|
||||
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||
import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useOutletContext } from "react-router-dom";
|
||||
import { listRoles } from "../api/roles.js";
|
||||
import {
|
||||
batchUpdateUserStatus,
|
||||
createUser,
|
||||
exportUsers,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
updateUserStatus
|
||||
} from "../api/users.js";
|
||||
import { useAuth } from "../auth/AuthProvider.jsx";
|
||||
import { Button } from "../components/base/Button.jsx";
|
||||
import { DataState } from "../components/base/DataState.jsx";
|
||||
import { FilterChip } from "../components/base/FilterChip.jsx";
|
||||
import { KpiCard } from "../components/base/KpiCard.jsx";
|
||||
import { PaginationBar } from "../components/base/PaginationBar.jsx";
|
||||
import { SearchBox } from "../components/base/SearchBox.jsx";
|
||||
import { useConfirm } from "../components/base/ConfirmProvider.jsx";
|
||||
import { useToast } from "../components/base/ToastProvider.jsx";
|
||||
import { PageHead } from "../components/layout/PageHead.jsx";
|
||||
import { UserTable } from "../components/user/UserTable.jsx";
|
||||
import { userStatusMeta } from "../data/users.js";
|
||||
|
||||
const statusFilters = [
|
||||
["all", "全部状态"],
|
||||
["active", "启用"],
|
||||
["locked", "锁定"],
|
||||
["disabled", "停用"]
|
||||
];
|
||||
|
||||
const emptyForm = {
|
||||
username: "",
|
||||
name: "",
|
||||
team: "",
|
||||
password: "",
|
||||
status: "active",
|
||||
mfaEnabled: false,
|
||||
roleIds: []
|
||||
};
|
||||
|
||||
export function UserManagementPage() {
|
||||
const [users, setUsers] = useState({ items: [], total: 0, page: 1, pageSize: 10 });
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const { refreshTick } = useOutletContext();
|
||||
const { can } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const roleFilters = useMemo(() => [["all", "全部角色"], ...roles.map((role) => [role.name, role.name])], [roles]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [userData, roleData] = await Promise.all([
|
||||
listUsers({ page, page_size: 10, keyword: query, role: roleFilter, status: statusFilter }),
|
||||
listRoles()
|
||||
]);
|
||||
setUsers(userData);
|
||||
setRoles(roleData || []);
|
||||
setSelectedIds([]);
|
||||
} catch (err) {
|
||||
setError(err.message || "加载用户失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [page, query, roleFilter, statusFilter, refreshTick]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const items = users.items || [];
|
||||
const active = items.filter((user) => user.status === "active").length;
|
||||
const locked = items.filter((user) => user.status === "locked").length;
|
||||
const admin = items.filter((user) => String(user.role).includes("管理员")).length;
|
||||
return { active, admin, locked };
|
||||
}, [users.items]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setForm(emptyForm);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (user) => {
|
||||
setEditingUser(user);
|
||||
setForm({
|
||||
username: user.account,
|
||||
name: user.name,
|
||||
team: user.team || "",
|
||||
password: "",
|
||||
status: user.status,
|
||||
mfaEnabled: user.mfaEnabled,
|
||||
roleIds: user.roleIds || []
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const submitUser = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (editingUser) {
|
||||
await updateUser(editingUser.id, {
|
||||
name: form.name,
|
||||
team: form.team,
|
||||
status: form.status,
|
||||
mfaEnabled: form.mfaEnabled,
|
||||
roleIds: form.roleIds
|
||||
});
|
||||
showToast("用户已更新", "success");
|
||||
} else {
|
||||
const result = await createUser(form);
|
||||
showToast(`用户已创建,初始密码:${result.initialPassword}`, "success");
|
||||
}
|
||||
setFormOpen(false);
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存用户失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLocked = async (user) => {
|
||||
const nextStatus = user.status === "locked" ? "active" : "locked";
|
||||
const ok = await confirm({
|
||||
title: user.status === "locked" ? "解锁用户" : "锁定用户",
|
||||
message: `${user.name} 的登录状态会立即变更。`,
|
||||
confirmText: user.status === "locked" ? "解锁" : "锁定",
|
||||
tone: user.status === "locked" ? "primary" : "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await updateUserStatus(user.id, nextStatus);
|
||||
showToast("用户状态已更新", "success");
|
||||
load();
|
||||
};
|
||||
|
||||
const toggleDisabled = async (user) => {
|
||||
const nextStatus = user.status === "disabled" ? "active" : "disabled";
|
||||
const ok = await confirm({
|
||||
title: user.status === "disabled" ? "启用用户" : "停用用户",
|
||||
message: `${user.name} 的账号状态会立即变更。`,
|
||||
confirmText: user.status === "disabled" ? "启用" : "停用",
|
||||
tone: user.status === "disabled" ? "primary" : "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await updateUserStatus(user.id, nextStatus);
|
||||
showToast("用户状态已更新", "success");
|
||||
load();
|
||||
};
|
||||
|
||||
const resetPassword = async (user) => {
|
||||
const ok = await confirm({
|
||||
title: "重置密码",
|
||||
message: `${user.name} 的密码会重置为系统生成的初始密码。`,
|
||||
confirmText: "重置",
|
||||
tone: "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
const result = await resetUserPassword(user.id);
|
||||
showToast(`已重置,初始密码:${result.initialPassword}`, "success");
|
||||
};
|
||||
|
||||
const batchStatus = async (status) => {
|
||||
if (!selectedIds.length) {
|
||||
showToast("请先选择用户", "warning");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: "批量更新状态",
|
||||
message: `将更新 ${selectedIds.length} 个用户状态。`,
|
||||
confirmText: "确认",
|
||||
tone: status === "active" ? "primary" : "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await batchUpdateUserStatus(selectedIds, status);
|
||||
showToast("批量状态已更新", "success");
|
||||
load();
|
||||
};
|
||||
|
||||
const downloadCSV = async () => {
|
||||
const response = await exportUsers({ keyword: query, role: roleFilter, status: statusFilter });
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "hyapp-users.csv";
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const setRoleChecked = (roleId, checked) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
roleIds: checked ? [...current.roleIds, roleId] : current.roleIds.filter((id) => id !== roleId)
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="用户管理">
|
||||
{can("role:view") ? (
|
||||
<Button onClick={() => navigate("/system/roles")}>
|
||||
<ShieldOutlined fontSize="small" />
|
||||
角色配置
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={downloadCSV}>
|
||||
<DownloadOutlined fontSize="small" />
|
||||
导出
|
||||
</Button>
|
||||
{can("user:create") ? (
|
||||
<Button variant="primary" onClick={openCreate}>
|
||||
<Add fontSize="small" />
|
||||
新增用户
|
||||
</Button>
|
||||
) : null}
|
||||
</PageHead>
|
||||
|
||||
<section className="user-kpi-row" aria-label="用户指标">
|
||||
<KpiCard icon={ManageAccountsOutlined} label="用户总数" value={users.total} sub={<span className="status-ok">{stats.active} 个启用账户</span>} />
|
||||
<KpiCard icon={ShieldOutlined} label="管理员" value={stats.admin} tone="info" sub={<span className="status-ok">当前页统计</span>} />
|
||||
<KpiCard icon={VerifiedUserOutlined} label={userStatusMeta.active.label} value={stats.active} tone="success" sub={<span className="status-ok">可登录控制台</span>} />
|
||||
<KpiCard icon={BlockOutlined} label={userStatusMeta.locked.label} value={stats.locked} tone="warning" sub={<span className="status-warn">需管理员处理</span>} />
|
||||
</section>
|
||||
|
||||
<section className="filters-panel user-filters-panel">
|
||||
<SearchBox value={query} onChange={(value) => { setQuery(value); setPage(1); }} placeholder="搜索用户、账号、角色、团队..." />
|
||||
<div className="filter-row" aria-label="角色筛选">
|
||||
{roleFilters.map(([value, label]) => (
|
||||
<FilterChip active={roleFilter === value} key={value} label={label} onClick={() => { setRoleFilter(value); setPage(1); }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="filter-row" aria-label="状态筛选">
|
||||
{statusFilters.map(([value, label]) => (
|
||||
<FilterChip active={statusFilter === value} key={value} label={label} onClick={() => { setStatusFilter(value); setPage(1); }} />
|
||||
))}
|
||||
</div>
|
||||
{can("user:status") ? (
|
||||
<div className="batch-actions">
|
||||
<Button onClick={() => batchStatus("active")}>批量启用</Button>
|
||||
<Button variant="danger" onClick={() => batchStatus("disabled")}>批量停用</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<DataState error={error} loading={loading} onRetry={load}>
|
||||
<UserTable
|
||||
canEdit={can("user:update")}
|
||||
canResetPassword={can("user:reset-password")}
|
||||
canStatus={can("user:status")}
|
||||
onEdit={openEdit}
|
||||
onResetPassword={resetPassword}
|
||||
onToggleDisabled={toggleDisabled}
|
||||
onToggleLocked={toggleLocked}
|
||||
selectedIds={selectedIds}
|
||||
setSelectedIds={setSelectedIds}
|
||||
total={users.total}
|
||||
users={users.items || []}
|
||||
/>
|
||||
<PaginationBar page={page} pageSize={users.pageSize || 10} total={users.total || 0} onPageChange={setPage} />
|
||||
</DataState>
|
||||
|
||||
<Drawer anchor="right" open={formOpen} onClose={() => setFormOpen(false)}>
|
||||
<form className="form-drawer" onSubmit={submitUser}>
|
||||
<h2>{editingUser ? "编辑用户" : "新增用户"}</h2>
|
||||
<TextField
|
||||
disabled={Boolean(editingUser)}
|
||||
label="账号"
|
||||
required
|
||||
value={form.username}
|
||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
||||
/>
|
||||
<TextField label="姓名" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
<TextField label="团队" value={form.team} onChange={(event) => setForm({ ...form, team: event.target.value })} />
|
||||
{!editingUser ? (
|
||||
<TextField label="初始密码" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
|
||||
) : null}
|
||||
<div className="form-section-title">角色</div>
|
||||
<div className="permission-grid">
|
||||
{roles.map((role) => (
|
||||
<label className="permission-check" key={role.id}>
|
||||
<input
|
||||
checked={form.roleIds.includes(role.id)}
|
||||
onChange={(event) => setRoleChecked(role.id, event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.mfaEnabled} onChange={(event) => setForm({ ...form, mfaEnabled: event.target.checked })} />}
|
||||
label="MFA 已开启"
|
||||
/>
|
||||
<div className="form-drawer__actions">
|
||||
<Button onClick={() => setFormOpen(false)}>取消</Button>
|
||||
<Button type="submit" variant="primary">提交</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
2290
src/styles/app.css
Normal file
2290
src/styles/app.css
Normal file
File diff suppressed because it is too large
Load Diff
40
src/styles/base.css
Normal file
40
src/styles/base.css
Normal file
@ -0,0 +1,40 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
background: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-system);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
52
src/styles/tokens.css
Normal file
52
src/styles/tokens.css
Normal file
@ -0,0 +1,52 @@
|
||||
:root {
|
||||
--bg-page: #f4f7fb;
|
||||
--bg-sidebar: #ffffff;
|
||||
--bg-header: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-strong: #f1f5f9;
|
||||
--bg-hover: rgba(37, 99, 235, 0.08);
|
||||
--bg-input: rgba(248, 250, 252, 0.92);
|
||||
--bg-input-strong: #ffffff;
|
||||
--bg-flyout: rgba(255, 255, 255, 0.98);
|
||||
|
||||
--border: #d8e0ec;
|
||||
--border-soft: rgba(100, 116, 139, 0.22);
|
||||
--row-border: rgba(216, 224, 236, 0.8);
|
||||
--detail-border: rgba(216, 224, 236, 0.75);
|
||||
|
||||
--primary: #2563eb;
|
||||
--primary-strong: #1d4ed8;
|
||||
--brand-secondary: #0ea5e9;
|
||||
|
||||
--success: #16a34a;
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--info: #0284c7;
|
||||
--stopped: #64748b;
|
||||
|
||||
--chart-blue: #2563eb;
|
||||
--chart-green: #16a34a;
|
||||
--chart-red: #dc2626;
|
||||
--chart-orange: #d97706;
|
||||
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-tertiary: #64748b;
|
||||
--active-contrast: #ffffff;
|
||||
|
||||
--shadow-soft: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
--shadow-panel: 0 18px 44px rgba(15, 23, 42, 0.1);
|
||||
--shadow-dialog: 0 28px 80px rgba(15, 23, 42, 0.18);
|
||||
--shadow-drawer: 0 24px 60px rgba(15, 23, 42, 0.18);
|
||||
--overlay-scrim: rgba(15, 23, 42, 0.24);
|
||||
--focus-ring: rgba(37, 99, 235, 0.16);
|
||||
--skeleton-surface: rgba(255, 255, 255, 0.86);
|
||||
--skeleton-shine: rgba(37, 99, 235, 0.1);
|
||||
--motion-fast: 150ms;
|
||||
--motion-base: 220ms;
|
||||
--motion-slow: 320ms;
|
||||
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--font-system: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--header-height: 72px;
|
||||
}
|
||||
1
src/test/setup.js
Normal file
1
src/test/setup.js
Normal file
@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
170
src/theme.js
Normal file
170
src/theme.js
Normal file
@ -0,0 +1,170 @@
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: "light",
|
||||
primary: {
|
||||
main: "#2563eb",
|
||||
contrastText: "#ffffff"
|
||||
},
|
||||
success: {
|
||||
main: "#16a34a"
|
||||
},
|
||||
warning: {
|
||||
main: "#d97706"
|
||||
},
|
||||
error: {
|
||||
main: "#dc2626"
|
||||
},
|
||||
background: {
|
||||
default: "#f4f7fb",
|
||||
paper: "#ffffff"
|
||||
},
|
||||
text: {
|
||||
primary: "#0f172a",
|
||||
secondary: "#475569",
|
||||
disabled: "#94a3b8"
|
||||
},
|
||||
divider: "#d8e0ec"
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
button: {
|
||||
textTransform: "none",
|
||||
fontWeight: 650
|
||||
}
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
defaultProps: {
|
||||
disableRipple: false
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minWidth: 0,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
padding: "0 12px",
|
||||
fontSize: 13,
|
||||
transition:
|
||||
"background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), box-shadow 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
},
|
||||
outlined: {
|
||||
borderColor: "#d8e0ec",
|
||||
color: "#475569",
|
||||
backgroundColor: "#ffffff",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(37, 99, 235, 0.7)",
|
||||
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
||||
color: "#0f172a"
|
||||
}
|
||||
},
|
||||
containedPrimary: {
|
||||
backgroundColor: "#2563eb",
|
||||
"&:hover": {
|
||||
backgroundColor: "#1d4ed8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
border: "1px solid #d8e0ec",
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundImage: "none",
|
||||
color: "#0f172a",
|
||||
transition:
|
||||
"border-color 220ms cubic-bezier(0.2, 0, 0, 1), box-shadow 220ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
height: 24,
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
backgroundColor: "rgba(100, 116, 139, 0.1)",
|
||||
transition:
|
||||
"background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), color 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
},
|
||||
icon: {
|
||||
marginLeft: 8,
|
||||
marginRight: -2
|
||||
},
|
||||
label: {
|
||||
paddingLeft: 8,
|
||||
paddingRight: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
color: "#475569",
|
||||
transition:
|
||||
"background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), color 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiInputBase: {
|
||||
styleOverrides: {
|
||||
input: {
|
||||
"&::placeholder": {
|
||||
color: "#94a3b8",
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiMenu: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
border: "1px solid #d8e0ec",
|
||||
backgroundImage: "none",
|
||||
backgroundColor: "#ffffff",
|
||||
transformOrigin: "top right"
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiSelect: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: "#475569",
|
||||
fontSize: 13
|
||||
},
|
||||
icon: {
|
||||
color: "#475569"
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
notchedOutline: {
|
||||
borderColor: "#d8e0ec"
|
||||
},
|
||||
root: {
|
||||
backgroundColor: "#ffffff",
|
||||
transition:
|
||||
"background-color 150ms cubic-bezier(0.2, 0, 0, 1), box-shadow 220ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(37, 99, 235, 0.7)"
|
||||
},
|
||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "#2563eb"
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
boxShadow: "0 0 0 3px rgba(37, 99, 235, 0.16)",
|
||||
transform: "translateY(-1px)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
8
src/utils/time.js
Normal file
8
src/utils/time.js
Normal file
@ -0,0 +1,8 @@
|
||||
export function formatTime(date = new Date()) {
|
||||
return date.toLocaleTimeString("zh-CN", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
}
|
||||
22
vite.config.js
Normal file
22
vite.config.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 7001,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:13100",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 7001
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.js"
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user