From 5bdbe33f8f55e4e34882b11a004ec05f5cc40d5f Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 8 Jun 2026 14:27:25 +0800 Subject: [PATCH] =?UTF-8?q?cp=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 12 +- README.md | 72 +++---- src/App.test.jsx | 178 +++++++++--------- src/features/cp-config/cp-config.module.css | 144 +++++++++++++- .../cp-config/hooks/useCPConfigPage.js | 29 ++- src/features/cp-config/pages/CPConfigPage.jsx | 110 +++++++---- 6 files changed, 365 insertions(+), 180 deletions(-) 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( - - - - - - ); + return render( + + + + + , + ); } function jsonResponse(data) { - return new Response(JSON.stringify({ code: 0, data }), { status: 200 }); + return new Response(JSON.stringify({ code: 0, data }), { status: 200 }); } function cpRelationFixture(relationType, displayName, maxCountPerUser) { - return { - relationType, - displayName, - maxCountPerUser, - applicationExpireHours: 24, - status: "active", - levels: Array.from({ length: 5 }, (_, index) => ({ - level: index + 1, - intimacyThreshold: index * 10000, - rewardResourceGroupId: 0, - levelIconUrl: "", - status: "active", - })), - }; + return { + relationType, + displayName, + maxCountPerUser, + applicationExpireHours: 24, + status: "active", + levels: Array.from({ length: 5 }, (_, index) => ({ + level: index + 1, + intimacyThreshold: index * 10000, + rewardResourceGroupId: 0, + levelIconUrl: "", + status: "active", + })), + }; } diff --git a/src/features/cp-config/cp-config.module.css b/src/features/cp-config/cp-config.module.css index 41a6e14..e40fc2e 100644 --- a/src/features/cp-config/cp-config.module.css +++ b/src/features/cp-config/cp-config.module.css @@ -92,22 +92,84 @@ .relationEditorList, .levelEditorList { display: grid; - gap: 12px; + gap: 16px; } -.relationEditor, -.levelEditor { - border: 1px solid var(--color-border-subtle); - border-radius: 8px; - background: var(--color-surface-subtle); +.configDrawer { + box-sizing: border-box; + width: min(980px, calc(100vw - 48px)); + height: 100vh; + max-height: 100vh; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + padding: var(--space-5); +} + +.configDrawerHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--border); +} + +.configDrawerHeader h2 { + margin: 0; + font-size: 20px; + font-weight: 800; +} + +.configDrawerStats { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + +.configDrawerStats span { + display: inline-flex; + height: var(--admin-tag-height); + align-items: center; + padding: 0 var(--space-3); + border: 1px solid var(--admin-tag-border); + border-radius: var(--admin-tag-radius); + background: var(--admin-tag-bg); + color: var(--admin-tag-color); + font-size: 12px; + font-weight: 700; +} + +.configDrawerBody { + min-height: 0; + overflow: auto; + padding-right: 2px; +} + +.configDrawerActions { + padding-top: var(--space-3); + border-top: 1px solid var(--border); + background: var(--bg-card); } .relationEditor { - padding: 14px; + border: 1px solid var(--color-border-subtle); + border-radius: 8px; + background: var(--bg-card); +} + +.relationEditor { + padding: 18px; } .levelEditor { - padding: 12px; + display: grid; + gap: var(--space-3); + min-width: 0; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); } .relationEditorHeader, @@ -116,12 +178,54 @@ align-items: center; justify-content: space-between; gap: 12px; - margin-bottom: 12px; + margin-bottom: 14px; font-weight: 700; } .levelEditorList { - margin-top: 12px; + grid-template-columns: repeat(2, minmax(320px, 1fr)); + margin-top: 16px; +} + +.relationTitleGroup, +.levelTitleGroup { + display: grid; + min-width: 0; + gap: 3px; +} + +.relationTitle, +.levelTitle { + color: var(--text-primary); + font-size: 16px; + font-weight: 800; +} + +.relationSubtitle, +.levelMeta { + min-width: 0; + overflow: hidden; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.relationControls { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: var(--space-3); +} + +.levelFields { + display: grid; + grid-template-columns: minmax(140px, 0.7fr) minmax(220px, 1fr); + gap: var(--space-3); +} + +.levelFields > :last-child { + grid-column: 1 / -1; } @media (max-width: 760px) { @@ -133,4 +237,24 @@ .summaryActions { justify-content: flex-end; } + + .configDrawer { + width: 100vw; + padding: var(--space-4); + } + + .configDrawerHeader { + align-items: flex-start; + flex-direction: column; + } + + .levelEditorList, + .relationControls, + .levelFields { + grid-template-columns: 1fr; + } + + .levelFields > :last-child { + grid-column: auto; + } } diff --git a/src/features/cp-config/hooks/useCPConfigPage.js b/src/features/cp-config/hooks/useCPConfigPage.js index b74c04f..4f98674 100644 --- a/src/features/cp-config/hooks/useCPConfigPage.js +++ b/src/features/cp-config/hooks/useCPConfigPage.js @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { getCPConfig, updateCPConfig } from "@/features/cp-config/api"; import { useCPConfigAbilities } from "@/features/cp-config/permissions.js"; +import { listResourceGroups } from "@/features/resources/api"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; export const cpRelationTypes = ["cp", "brother", "sister"]; @@ -20,6 +21,7 @@ export function useCPConfigPage() { const [form, setForm] = useState({ relations: defaultRelations().map(relationToForm) }); const [drawerOpen, setDrawerOpen] = useState(false); const [loading, setLoading] = useState(false); + const [resourceGroups, setResourceGroups] = useState([]); const [saving, setSaving] = useState(false); const activeRelationCount = useMemo( @@ -30,10 +32,23 @@ export function useCPConfigPage() { const reload = useCallback(async () => { setLoading(true); try { - const remoteConfig = await getCPConfig(); - const relations = completeRelations(remoteConfig.relations || []); - setConfig({ ...remoteConfig, relations }); - setForm({ relations: relations.map(relationToForm) }); + const [configResult, groupResult] = await Promise.allSettled([ + getCPConfig(), + listResourceGroups({ page: 1, page_size: 200, status: "active" }), + ]); + if (configResult.status === "fulfilled") { + const remoteConfig = configResult.value; + const relations = completeRelations(remoteConfig.relations || []); + setConfig({ ...remoteConfig, relations }); + setForm({ relations: relations.map(relationToForm) }); + } else { + showToast(configResult.reason?.message || "加载 CP 配置失败", "error"); + } + if (groupResult.status === "fulfilled") { + setResourceGroups(groupResult.value?.items || []); + } else { + showToast(groupResult.reason?.message || "加载资源组失败", "error"); + } } catch (err) { showToast(err.message || "加载 CP 配置失败", "error"); } finally { @@ -120,6 +135,7 @@ export function useCPConfigPage() { loading, openDrawer, reload, + resourceGroups, saving, submit, updateLevel, @@ -218,7 +234,10 @@ function payloadFromForm(form) { function completeFormRelations(relations) { const byType = new Map((relations || []).map((relation) => [relation.relationType, relation])); - return defaultRelations().map((fallback) => ({ ...relationToForm(fallback), ...(byType.get(fallback.relationType) || {}) })); + return defaultRelations().map((fallback) => ({ + ...relationToForm(fallback), + ...(byType.get(fallback.relationType) || {}), + })); } function completeFormLevels(levels, relationType) { diff --git a/src/features/cp-config/pages/CPConfigPage.jsx b/src/features/cp-config/pages/CPConfigPage.jsx index 2c39eb1..a383e6c 100644 --- a/src/features/cp-config/pages/CPConfigPage.jsx +++ b/src/features/cp-config/pages/CPConfigPage.jsx @@ -4,16 +4,14 @@ import SaveOutlined from "@mui/icons-material/SaveOutlined"; import Drawer from "@mui/material/Drawer"; import TextField from "@mui/material/TextField"; import { useMemo } from "react"; -import { - cpRelationLabels, - useCPConfigPage, -} from "@/features/cp-config/hooks/useCPConfigPage.js"; +import { cpRelationLabels, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js"; import styles from "@/features/cp-config/cp-config.module.css"; import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx"; import { formatMillis } from "@/shared/utils/time.js"; export function CPConfigPage() { @@ -31,7 +29,11 @@ export function CPConfigPage() { {relationMax(relations, "sister")}
- {page.abilities.canUpdate ? ( @@ -48,7 +50,12 @@ export function CPConfigPage() {
- item.relationType} /> + item.relationType} + /> @@ -60,23 +67,32 @@ function CPConfigDrawer({ page }) { const disabled = !page.abilities.canUpdate || page.saving || page.loading; return ( -
-

CP配置

-
-
关系与等级
-
- {(page.form.relations || []).map((relation) => ( - - ))} + +
+

CP配置

+
+ {(page.form.relations || []).length} 个关系 + {page.activeRelationCount} 个启用
-
-
+
+
+
+
关系与等级
+
+ {(page.form.relations || []).map((relation) => ( + + ))} +
+
+
+
@@ -94,14 +110,22 @@ function CPConfigDrawer({ page }) { ); } -function RelationEditor({ disabled, relation, updateLevel, updateRelation }) { +function RelationEditor({ disabled, relation, resourceGroups, updateLevel, updateRelation }) { const relationType = relation.relationType; const maxCountDisabled = disabled || relationType === "cp"; + const activeLevelCount = (relation.levels || []).filter((level) => level.status === "active").length; return (
- {relation.displayName || cpRelationLabels[relationType]} +
+ + {relation.displayName || cpRelationLabels[relationType]} + + + {relationType} / {activeLevelCount} 个等级启用 + +
-
+
))} @@ -146,11 +171,18 @@ function RelationEditor({ disabled, relation, updateLevel, updateRelation }) { ); } -function LevelEditor({ disabled, level, relationType, updateLevel }) { +function LevelEditor({ disabled, level, relationType, resourceGroups, updateLevel }) { + const selectedGroup = resourceGroups.find((group) => String(group.groupId) === String(level.rewardResourceGroupId)); + return (
- {level.level}级 +
+ {level.level}级 + + {resourceGroupLabel(selectedGroup, level.rewardResourceGroupId)} + +
-
+
- - updateLevel(relationType, level.level, { rewardResourceGroupId: event.target.value }) - } + onChange={(value) => updateLevel(relationType, level.level, { rewardResourceGroupId: value })} />