Initial hyapp admin platform

This commit is contained in:
170-carry 2026-05-01 00:14:08 +08:00
commit e3337f26a6
74 changed files with 9667 additions and 0 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=/api

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.DS_Store
.env
node_modules/
dist/
coverage/
*.log

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"tabWidth": 4,
"useTabs": false,
"printWidth": 120
}

95
AGENTS.md Normal file
View 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
View 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
View 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 收缩 |
---
## 三、设计 TokenCSS 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

132
src/App.jsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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 {};
}

View 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>
);
}

View 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;
}

View 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;
}

View 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"
/>
);
}

View 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 {};
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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)"
}
};

View 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>
);
}

View 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;
}

View 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] }
}
]
};
}

View 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>
);
}

View 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} />;
}

View 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 }
}
]
};
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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" />;
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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"
/>
);
}

View 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"
/>
);
}

View 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
View File

@ -0,0 +1 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api";

105
src/constants/navigation.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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 });
}

View 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)
]);
}

View 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
View 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>
</>
);
}

View 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
View 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>
</>
);
}

View 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

File diff suppressed because it is too large Load Diff

40
src/styles/base.css Normal file
View 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
View 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
View File

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

170
src/theme.js Normal file
View 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
View 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
View 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"
}
});