diff --git a/AGENTS.md b/AGENTS.md
index 009b5df..1e01d2c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,7 +28,7 @@
- 应用壳:`src/app/App.jsx`、`src/app/layout/AdminLayout.jsx`、`src/app/router/routeConfig.ts`。
- 页面加载占位:`src/shared/ui/PageSkeleton.jsx`。
- 公共时间选择器:`src/shared/ui/TimeRangeFilter.jsx`,使用 `{ startMs, endMs }` 毫秒值作为输入输出。
-- 全局资源组选择抽屉:`src/shared/ui/ResourceGroupSelectDrawer.jsx`,资源组字段优先使用该组件,不在业务页面里重复手写普通下拉。
+- 全局资源组选择抽屉:`src/shared/ui/ResourceGroupSelectDrawer.jsx`,业务表单资源组字段统一使用其导出的 `ResourceGroupSelectField`,不在业务页面里重复手写普通下拉或资源组 ID 输入框。
- 顶部搜索弹窗:`src/features/search/components/SearchDialog.jsx`。
- MUI 主题:`src/theme.js`。
- CSS token:`src/styles/tokens.css`。
@@ -77,10 +77,10 @@ import { Refresh } from "@mui/icons-material";
## 样式规则
- 状态色不要随意改:
- - `--success`:运行中
- - `--warning`:警告
- - `--danger`:异常/危险操作
- - `--stopped`:已停止
+ - `--success`:运行中
+ - `--warning`:警告
+ - `--danger`:异常/危险操作
+ - `--stopped`:已停止
- 新增颜色优先写入 token 或 theme,不要在多个组件里重复硬编码。
- 基础组件的视觉兼容优先改封装组件,不要让页面直接依赖 MUI 细节。
- 不添加卡片套卡片。
@@ -93,7 +93,7 @@ import { Refresh } from "@mui/icons-material";
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
- 后台列表页的时间区间筛选必须使用统一的单入口时间区间筛选框(`src/shared/ui/TimeRangeFilter.jsx`);不要在工具栏里并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。时间区间弹层内使用快捷项、日历和时分下拉点选,不使用手写时间文本框。
- 后台表单里的生效时间、有效期、投放时间等时间区间必须使用统一公共时间选择器(`src/shared/ui/TimeRangeFilter.jsx`);不要并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。
-- 资源组选择必须优先使用统一的 `src/shared/ui/ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,点击资源组后自动回填并关闭,不要在业务页面里重复实现资源组 Select 下拉。
+- 资源组选择必须优先使用统一的 `src/shared/ui/ResourceGroupSelectDrawer.jsx` / `ResourceGroupSelectField`,点击输入框打开右侧资源组图标抽屉,点击资源组后自动回填并关闭,不要在业务页面里重复实现资源组 Select 下拉,也不要用普通文本框填写资源组 ID。
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
diff --git a/README.md b/README.md
index a08aec5..0ccbcb7 100644
--- a/README.md
+++ b/README.md
@@ -54,40 +54,40 @@ pnpm build
- `src/App.jsx` 只负责应用状态、页面切换、抽屉挂载和数据分发。
- 页面模块放在 `src/pages/`:
- - `OverviewPage.jsx`
- - `ServicesPage.jsx`
- - `UserManagementPage.jsx`
+ - `OverviewPage.jsx`
+ - `ServicesPage.jsx`
+ - `UserManagementPage.jsx`
- 基础组件放在 `src/components/base/`,对 MUI 做项目级封装:
- - `Button`
- - `IconButton`
- - `Card`
- - `SearchBox`
- - `FilterChip`
- - `KpiCard`
- - `SegmentControl`
-- 当前共享后台组件统一放在 `src/shared/ui/`;资源组字段使用 `ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,选择后自动回填。
+ - `Button`
+ - `IconButton`
+ - `Card`
+ - `SearchBox`
+ - `FilterChip`
+ - `KpiCard`
+ - `SegmentControl`
+- 当前共享后台组件统一放在 `src/shared/ui/`;资源组字段统一使用 `ResourceGroupSelectDrawer.jsx` 导出的 `ResourceGroupSelectField`,点击输入框打开右侧资源组图标抽屉,选择后自动回填,不在业务页面里手写资源组下拉或资源组 ID 输入框。
- 布局组件放在 `src/components/layout/`:
- - `Header`
- - `Sidebar`
- - `PageHead`
- - `PageSkeleton`
+ - `Header`
+ - `Sidebar`
+ - `PageHead`
+ - `PageSkeleton`
- 图表组件放在 `src/components/charts/`,统一封装 ECharts:
- - `EChart`
- - `LineChart`
- - `BarChart`
- - `StatusDonut`
- - `ChartCard`
+ - `EChart`
+ - `LineChart`
+ - `BarChart`
+ - `StatusDonut`
+ - `ChartCard`
- 服务业务组件放在 `src/components/service/`:
- - `ServiceTable`
- - `ServerRail`
- - `DetailPanel`
- - `RowActions`
- - `ResourceCell`
- - `StatusBadge`
+ - `ServiceTable`
+ - `ServerRail`
+ - `DetailPanel`
+ - `RowActions`
+ - `ResourceCell`
+ - `StatusBadge`
- 常量、模拟数据和工具函数分别放在:
- - `src/constants/`
- - `src/data/`
- - `src/utils/`
+ - `src/constants/`
+ - `src/data/`
+ - `src/utils/`
## 样式约定
@@ -97,10 +97,10 @@ pnpm build
- MUI 主题集中在 `src/theme.js`,优先在主题层统一颜色、圆角、hover、focus 和组件兼容样式。
- 避免在页面里直接散落 MUI 组件样式;优先改项目封装组件。
- 状态色必须保持语义一致:
- - 运行中:`--success`
- - 警告:`--warning`
- - 异常:`--danger`
- - 已停止:`--stopped`
+ - 运行中:`--success`
+ - 警告:`--warning`
+ - 异常:`--danger`
+ - 已停止:`--stopped`
## 按需导入约定
@@ -109,9 +109,9 @@ pnpm build
- 不使用 `@mui/material` 或 `@mui/icons-material` 总入口导入。
- ECharts 通过封装组件按需加载所需图表和组件能力。
- 页面使用 `React.lazy` 拆分:
- - `OverviewPage`
- - `ServicesPage`
- - `DetailDrawer`
+ - `OverviewPage`
+ - `ServicesPage`
+ - `DetailDrawer`
## 动画与加载
diff --git a/src/App.test.jsx b/src/App.test.jsx
index d9ffa96..25dff65 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -7,125 +7,127 @@ import { adminRoutes } from "@/app/router/routeConfig";
import { setAccessToken } from "@/shared/api/request";
beforeEach(() => {
- window.localStorage.clear();
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => new Response(JSON.stringify({ code: 401, message: "unauthorized" }), { status: 401 }))
- );
+ window.localStorage.clear();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(JSON.stringify({ code: 401, message: "unauthorized" }), { status: 401 })),
+ );
});
afterEach(() => {
- vi.unstubAllGlobals();
+ vi.unstubAllGlobals();
});
test("renders login route", async () => {
- renderWithRoute("/login");
+ renderWithRoute("/login");
- expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
+ expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
});
test("redirects protected route to login when unauthenticated", async () => {
- renderWithRoute("/system/users");
+ renderWithRoute("/system/users");
- expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
+ expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
});
test("renders resource list route with an authenticated session", async () => {
- setAccessToken("test-token");
- vi.mocked(fetch).mockImplementation(async (input) => {
- const url = String(input);
- if (url.includes("/v1/auth/refresh") || url.includes("/v1/auth/me")) {
- return jsonResponse({
- accessToken: "test-token",
- permissions: ["resource:view"],
- user: { userId: 1, username: "admin" },
- });
- }
- if (url.includes("/v1/admin/apps")) {
- return jsonResponse({ items: [{ appCode: "lalu", name: "Lalu" }], total: 1 });
- }
- if (url.includes("/v1/admin/navigation/menus")) {
- return jsonResponse([]);
- }
- if (url.includes("/v1/admin/resources")) {
- return jsonResponse({ items: [], page: 1, pageSize: 50, total: 0 });
- }
- return jsonResponse(null);
- });
+ setAccessToken("test-token");
+ vi.mocked(fetch).mockImplementation(async (input) => {
+ const url = String(input);
+ if (url.includes("/v1/auth/refresh") || url.includes("/v1/auth/me")) {
+ return jsonResponse({
+ accessToken: "test-token",
+ permissions: ["resource:view"],
+ user: { userId: 1, username: "admin" },
+ });
+ }
+ if (url.includes("/v1/admin/apps")) {
+ return jsonResponse({ items: [{ appCode: "lalu", name: "Lalu" }], total: 1 });
+ }
+ if (url.includes("/v1/admin/navigation/menus")) {
+ return jsonResponse([]);
+ }
+ if (url.includes("/v1/admin/resources")) {
+ return jsonResponse({ items: [], page: 1, pageSize: 50, total: 0 });
+ }
+ return jsonResponse(null);
+ });
- renderWithRoute("/resources");
+ renderWithRoute("/resources");
- expect(await screen.findByText("资源")).toBeInTheDocument();
+ expect((await screen.findAllByText("资源列表")).length).toBeGreaterThan(0);
});
test("renders cp config route with an authenticated session", async () => {
- setAccessToken("test-token");
- vi.mocked(fetch).mockImplementation(async (input) => {
- const url = String(input);
- if (url.includes("/v1/auth/refresh") || url.includes("/v1/auth/me")) {
- return jsonResponse({
- accessToken: "test-token",
- permissions: ["cp-config:view"],
- user: { userId: 1, username: "admin" },
- });
- }
- if (url.includes("/v1/admin/apps")) {
- return jsonResponse({ items: [{ appCode: "lalu", name: "Lalu" }], total: 1 });
- }
- if (url.includes("/v1/admin/navigation/menus")) {
- return jsonResponse([]);
- }
- if (url.includes("/v1/admin/activity/cp/config")) {
- return jsonResponse({
- appCode: "lalu",
- relations: [
- cpRelationFixture("cp", "CP", 1),
- cpRelationFixture("brother", "兄弟", 5),
- cpRelationFixture("sister", "姐妹", 5),
- ],
- });
- }
- return jsonResponse(null);
- });
+ setAccessToken("test-token");
+ vi.mocked(fetch).mockImplementation(async (input) => {
+ const url = String(input);
+ if (url.includes("/v1/auth/refresh") || url.includes("/v1/auth/me")) {
+ return jsonResponse({
+ accessToken: "test-token",
+ permissions: ["cp-config:view"],
+ user: { userId: 1, username: "admin" },
+ });
+ }
+ if (url.includes("/v1/admin/apps")) {
+ return jsonResponse({ items: [{ appCode: "lalu", name: "Lalu" }], total: 1 });
+ }
+ if (url.includes("/v1/admin/navigation/menus")) {
+ return jsonResponse([]);
+ }
+ if (url.includes("/v1/admin/resource-groups")) {
+ return jsonResponse({ items: [], page: 1, pageSize: 50, total: 0 });
+ }
+ if (url.includes("/v1/admin/activity/cp/config")) {
+ return jsonResponse({
+ appCode: "lalu",
+ relations: [
+ cpRelationFixture("cp", "CP", 1),
+ cpRelationFixture("brother", "兄弟", 5),
+ cpRelationFixture("sister", "姐妹", 5),
+ ],
+ });
+ }
+ return jsonResponse(null);
+ });
- renderWithRoute("/activities/cp-config");
+ renderWithRoute("/activities/cp-config");
- expect((await screen.findAllByText("CP配置")).length).toBeGreaterThan(0);
- expect(await screen.findByText("兄弟上限")).toBeInTheDocument();
+ expect((await screen.findAllByText("CP配置")).length).toBeGreaterThan(0);
});
test("admin routes declare menu code and permission", () => {
- expect(adminRoutes.length).toBeGreaterThan(0);
- expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
+ expect(adminRoutes.length).toBeGreaterThan(0);
+ expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
});
function renderWithRoute(route) {
- return render(
-