From 4abab5c164bff6be58d3076d81621a5eadbc1e9d Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 14 Jul 2026 17:58:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=9B=B8=E5=85=B3=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 98 +++++++ databi/src/DatabiApp.test.jsx | 69 +++++ databi/src/social/metrics.js | 6 +- databi/src/social/views/DataTableView.jsx | 58 +++-- databi/src/social/views/RetentionView.jsx | 7 +- databi/src/social/views/data-table-view.css | 5 + src/App.test.jsx | 27 ++ src/app/App.jsx | 10 +- src/app/app-scope/AppScopeProvider.jsx | 22 +- src/app/layout/AdminLayout.jsx | 10 +- src/app/layout/Header.jsx | 9 +- src/app/router/guards.jsx | 13 + src/features/app-registry/api.ts | 7 + src/features/dashboard/pages/OverviewPage.jsx | 5 +- src/features/level-config/api.ts | 52 +++- .../components/LevelConfigBatchDialog.jsx | 245 ++++++++++++++++++ .../LevelConfigBatchDialog.module.css | 87 +++++++ .../level-config/hooks/useLevelConfigPage.js | 141 ++++++++-- .../hooks/useLevelConfigPage.test.jsx | 34 ++- .../level-config/level-config.module.css | 8 + .../level-config/pages/LevelConfigPage.jsx | 79 +++--- src/features/level-config/resourceFilters.js | 62 +++++ .../resources/pages/ResourceListPage.jsx | 2 + src/features/users/api.test.ts | 22 +- src/features/users/api.ts | 17 ++ .../users/components/UserAppScopeEditor.jsx | 40 +++ .../components/UserAppScopeEditor.test.jsx | 40 +++ .../components/UserFinanceScopeEditor.jsx | 163 ++++++++++-- .../UserFinanceScopeEditor.test.jsx | 81 ++++++ .../users/components/UserFormDrawer.jsx | 20 +- src/features/users/hooks/useUsersPage.js | 96 +++++++ .../users/pages/UserManagementPage.jsx | 4 + src/shared/api/generated/endpoints.ts | 38 +++ src/shared/api/generated/schema.d.ts | 140 ++++++++++ src/shared/api/types.ts | 17 ++ src/shared/ui/AdminFormDialog.module.css | 5 + src/shared/ui/UploadField.jsx | 87 ++++++- src/shared/ui/UploadField.module.css | 56 ++++ src/shared/ui/UploadField.test.jsx | 35 +++ src/styles/shared-ui.css | 22 ++ 40 files changed, 1787 insertions(+), 152 deletions(-) create mode 100644 src/features/level-config/components/LevelConfigBatchDialog.jsx create mode 100644 src/features/level-config/components/LevelConfigBatchDialog.module.css create mode 100644 src/features/level-config/resourceFilters.js create mode 100644 src/features/users/components/UserAppScopeEditor.jsx create mode 100644 src/features/users/components/UserAppScopeEditor.test.jsx create mode 100644 src/features/users/components/UserFinanceScopeEditor.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 2d77d94..5222231 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -1623,6 +1623,16 @@ } } }, + "/admin/apps/visible": { + "get": { + "operationId": "listVisibleApps", + "responses": { + "200": { + "$ref": "#/components/responses/AppListResponse" + } + } + } + }, "/admin/bd-leaders": { "get": { "operationId": "listBDLeaders", @@ -5573,6 +5583,18 @@ "x-permissions": ["level-config:view"] } }, + "/admin/users/level-config/batch": { + "put": { + "operationId": "batchUpsertLevelConfig", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "level-config:update", + "x-permissions": ["level-config:update"] + } + }, "/admin/users/level-config/rules/{track}/{level}": { "put": { "operationId": "upsertRule", @@ -6676,6 +6698,18 @@ "x-permissions": ["user:update"] } }, + "/users/finance-scope-assignments": { + "get": { + "operationId": "listFinanceScopeAssignments", + "x-permission": "user:view", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permissions": ["user:view"] + } + }, "/users/{id}/finance-scopes": { "get": { "operationId": "listUserFinanceScopes", @@ -6711,6 +6745,41 @@ "x-permissions": ["user:update"] } }, + "/users/{id}/app-scopes": { + "get": { + "operationId": "getUserAppScopes", + "x-permission": "user:view", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permissions": ["user:view"] + }, + "put": { + "operationId": "replaceUserAppScopes", + "x-permission": "user:update", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/AppScopeRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permissions": ["user:update"] + } + }, "/users/{id}/reset-password": { "post": { "operationId": "resetUserPassword", @@ -7338,6 +7407,15 @@ } } }, + "AppScopeRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAppScope" + } + } + } + }, "FinanceCoinSellerRechargeOrderRequest": { "content": { "application/json": { @@ -10335,6 +10413,10 @@ "account": { "type": "string" }, + "appScopeMode": { + "type": "string", + "enum": ["all", "selected", "none"] + }, "id": { "type": "integer" }, @@ -12452,6 +12534,22 @@ "type": "integer" } } + }, + "UserAppScope": { + "type": "object", + "required": ["mode", "appCodes"], + "properties": { + "mode": { + "type": "string", + "enum": ["all", "selected", "none"] + }, + "appCodes": { + "type": "array", + "items": { + "type": "string" + } + } + } } } } diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index de036c0..60a7131 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -485,6 +485,75 @@ test("renders social BI data requirements table with section filters and CSV exp anchorClick.mockRestore(); }); +test("groups social BI wide-table daily rows by app instead of interleaving by date", async () => { + window.history.pushState(null, "", "/databi/social/?view=table"); + fetchSocialBiMaster.mockResolvedValue({ + access: { all: true, scopes: [] }, + apps: [ + // 带 logo:AppIdentity 渲染图片而不是字母头像,单元格 textContent 恰好等于 App 名。 + { app_code: "lalu", app_name: "Lalu", kind: "hyapp", logo_url: "https://media.example.com/lalu.png" }, + { app_code: "aslan", app_name: "Aslan", kind: "legacy", logo_url: "https://media.example.com/aslan.png" } + ], + operators: [], + permissions: {}, + regions: [] + }); + const dailyRow = (statDay, rechargeUsdMinor) => ({ recharge_usd_minor: rechargeUsdMinor, recharge_users: 1, stat_day: statDay }); + fetchSocialBiOverview.mockResolvedValue({ + access: { all: true, scopes: [] }, + apps: [ + { + app_code: "lalu", + app_name: "Lalu", + country_breakdown: [], + daily_region_breakdown: [], + daily_series: [dailyRow("2026-06-04", 100), dailyRow("2026-06-05", 200)], + kind: "hyapp", + region_breakdown: [], + total: { recharge_usd_minor: 300, recharge_users: 2 } + }, + { + // Aslan 各日充值都高于 Lalu:旧的“按日倒序 + 日内充值倒序”排序会把两个 App 一天一行穿插, + // 新排序必须仍按 master 顺序把 Lalu 块排在前面。 + app_code: "aslan", + app_name: "Aslan", + country_breakdown: [], + daily_region_breakdown: [], + daily_series: [dailyRow("2026-06-04", 900), dailyRow("2026-06-05", 800)], + kind: "legacy", + region_breakdown: [], + total: { recharge_usd_minor: 1700, recharge_users: 2 } + } + ] + }); + + render(); + await flushEffects(); + + const table = document.querySelector(".sbi-dt-table"); + expect(table).toBeTruthy(); + const headerCells = [...table.querySelectorAll("thead tr:first-child th")].map((cell) => cell.textContent.trim()); + expect(headerCells.slice(0, 2)).toEqual(["App", "日期"]); + + // 行序:汇总/平均值之后,每个 App 的日行连成块(块内日期倒序),不按日期把 App 穿插。 + const bodyRows = [...table.querySelectorAll("tbody tr")]; + const dimCells = bodyRows.map((row) => { + const cells = row.querySelectorAll("td"); + return [cells[0].textContent.trim(), cells[1].textContent.trim()]; + }); + expect(dimCells).toEqual([ + ["全部", "汇总"], + ["平均值", "--"], + ["Lalu", "2026-06-05"], + ["Lalu", "2026-06-04"], + ["Aslan", "2026-06-05"], + ["Aslan", "2026-06-04"] + ]); + // 分块分隔线只标在后一个块的首行。 + expect(bodyRows[4].classList.contains("sbi-dt-group-start")).toBe(true); + expect(bodyRows[3].classList.contains("sbi-dt-group-start")).toBe(false); +}); + test("renders social BI funnel view and limits funnel apps to supported apps", async () => { window.history.pushState(null, "", "/databi/social/?view=funnel"); fetchSocialBiMaster.mockResolvedValue({ diff --git a/databi/src/social/metrics.js b/databi/src/social/metrics.js index eb06fc7..1a40c80 100644 --- a/databi/src/social/metrics.js +++ b/databi/src/social/metrics.js @@ -45,9 +45,9 @@ export const METRIC_DEFS = { arppu_usd_minor: { label: "ARPPU ($)", tooltip: "Finance 币商/H5 充值没有同口径付费人数,当前不展示", type: "money" }, paid_conversion_rate: { label: "付费转化率", tooltip: "付费用户 / 日活", type: "ratio" }, recharge_conversion_rate: { label: "充值转化率", tooltip: "充值用户 / 日活", type: "ratio" }, - d1_retention_rate: { label: "次日留存", tooltip: "注册次日仍活跃的比例", type: "ratio" }, - d7_retention_rate: { label: "7日留存", tooltip: "注册第 7 日仍活跃的比例", type: "ratio" }, - d30_retention_rate: { label: "30日留存", tooltip: "注册第 30 日仍活跃的比例", type: "ratio" } + d1_retention_rate: { label: "次日留存", tooltip: "当日活跃的前 1 日注册用户 / 前 1 日注册数(观察日回看口径)", type: "ratio" }, + d7_retention_rate: { label: "7日留存", tooltip: "当日活跃的前 7 日注册用户 / 前 7 日注册数(观察日回看口径)", type: "ratio" }, + d30_retention_rate: { label: "30日留存", tooltip: "当日活跃的前 30 日注册用户 / 前 30 日注册数(观察日回看口径)", type: "ratio" } }; // 数据明细宽表的列组:与运营的心智分组一致,支持整组折叠。 diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx index 5506f14..f574b16 100644 --- a/databi/src/social/views/DataTableView.jsx +++ b/databi/src/social/views/DataTableView.jsx @@ -32,10 +32,11 @@ const DIM_COLUMNS = { }; // 五种明细维度:key 与 derived 的行集字段一一对应。 +// 日维度把 App 放在首列(冻结列):日行按 App 分块连续展示,App 列就是分块标识。 const DIMENSIONS = [ - { csvName: "app-daily", dims: ["day", "app"], hasDay: true, key: "appDaily", label: "App×日" }, + { csvName: "app-daily", dims: ["app", "day"], hasDay: true, key: "appDaily", label: "App×日" }, { csvName: "app-total", dims: ["app"], hasDay: false, key: "appTotals", label: "App 合计" }, - { csvName: "region-daily", dims: ["day", "app", "region"], hasDay: true, key: "regionDaily", label: "区域×日" }, + { csvName: "region-daily", dims: ["app", "region", "day"], hasDay: true, key: "regionDaily", label: "区域×日" }, { csvName: "region-total", dims: ["app", "region"], hasDay: false, key: "regionTotals", label: "区域合计" }, { csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" } ]; @@ -514,10 +515,28 @@ export function DataTableView() { // 排序/合计每次渲染重算(行集千行量级可接受);react-hooks 的 preserve-manual-memoization // 规则不允许在这里包 useMemo(依赖来自模块常量推导,编译器无法证明不可变)。 const sortedRows = [...baseRows]; + // 日维度的分块 key:App×日按 App 分块,区域×日按 App+区域分块; + // 块顺序沿用行集原始顺序(后端 master 的 App 顺序),保证与顶栏 App 筛选chip一致。 + const dayGroupKey = (row) => + dim.dims.includes("region") ? `${row.app_code ?? ""}|${row.region_id ?? ""}` : String(row.app_code ?? ""); + const dayGroupOrder = new Map(); + if (hasDay) { + baseRows.forEach((row) => { + const key = dayGroupKey(row); + if (!dayGroupOrder.has(key)) { + dayGroupOrder.set(key, dayGroupOrder.size); + } + }); + } if (activeSort) { sortedRows.sort((a, b) => compareMetric(a, b, activeSort.key, activeSort.dir)); } else if (hasDay) { + // 默认展示不按天全局倒序(那样多 App 会一天一行互相穿插):先按分块聚拢,块内再按日期倒序。 sortedRows.sort((a, b) => { + const groupGap = (dayGroupOrder.get(dayGroupKey(a)) ?? 0) - (dayGroupOrder.get(dayGroupKey(b)) ?? 0); + if (groupGap !== 0) { + return groupGap; + } if (a.stat_day !== b.stat_day) { return String(a.stat_day) < String(b.stat_day) ? 1 : -1; } @@ -690,7 +709,7 @@ export function DataTableView() { {dimColumns.map((column, index) => ( - {index === 0 ? "平均值" : column.totalText} + {index === 0 ? "平均值" : column.key === "day" ? "--" : column.totalText} ))} {visibleMetrics.map((key) => ( @@ -698,20 +717,25 @@ export function DataTableView() { ))} ) : null} - {renderRows.map((row, rowIndex) => ( - - {dimColumns.map((column, index) => ( - - {column.key === "app" ? ( - - ) : column.value(row)} - - ))} - {visibleMetrics.map((key) => ( - {formatMetric(key, row[key])} - ))} - - ))} + {renderRows.map((row, rowIndex) => { + // 仅默认分块排序下标记块首行:指标排序是全局排序,没有分块语义。 + const isGroupStart = + hasDay && !activeSort && rowIndex > 0 && dayGroupKey(renderRows[rowIndex - 1]) !== dayGroupKey(row); + return ( + + {dimColumns.map((column, index) => ( + + {column.key === "app" ? ( + + ) : column.value(row)} + + ))} + {visibleMetrics.map((key) => ( + {formatMetric(key, row[key])} + ))} + + ); + })} {isTruncated ? ( diff --git a/databi/src/social/views/RetentionView.jsx b/databi/src/social/views/RetentionView.jsx index bca9ba3..b4c9bba 100644 --- a/databi/src/social/views/RetentionView.jsx +++ b/databi/src/social/views/RetentionView.jsx @@ -265,7 +265,7 @@ export function RetentionView() { {formatRatioPercent(summary?.[key])} {regionScoped ? "所选区域汇总" : "全部所选 App 汇总"} - {isBlank(baseUsers) ? "" : ` · cohort 样本 ${formatCount(baseUsers)} 人`} + {isBlank(baseUsers) ? "" : ` · 回看基数 ${formatCount(baseUsers)} 人`} ); @@ -275,7 +275,7 @@ export function RetentionView() {
新增与次日留存 - 柱=新增(左轴)· 线=次日留存率(右轴)· 按{granularityLabel}分桶 + 柱=当日新增(左轴)· 线=前一日注册用户的当日留存率(右轴)· 按{granularityLabel}分桶
{comboOption ? ( @@ -395,7 +395,8 @@ export function RetentionView() {

- 留存按注册日 cohort 计算;观察期未到的日期显示 "--"。hyapp App 的按日留存暂不提供(仅整段区间口径)。 + 留存按观察日回看计算:某日的 N 日留存 = 该日往前推 N 天注册的用户中当日仍活跃的比例;区间合计 = + 各观察日留存人数之和 / 各观察日 cohort 基数之和。cohort 日无注册或数据未接入时显示 "--"。

); diff --git a/databi/src/social/views/data-table-view.css b/databi/src/social/views/data-table-view.css index f09f808..110427d 100644 --- a/databi/src/social/views/data-table-view.css +++ b/databi/src/social/views/data-table-view.css @@ -117,6 +117,11 @@ z-index: 4; } +/* 日维度按 App(或 App+区域)分块展示时的块间分隔线,比常规行线更醒目一档。 */ +.sbi-dt-table tr.sbi-dt-group-start td { + border-top: 2px solid var(--sbi-border); +} + /* 截断提示(表尾整行)。 */ .sbi-dt-note td { padding: 10px 12px; diff --git a/src/App.test.jsx b/src/App.test.jsx index 75c51cb..fb2d4fa 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -30,6 +30,33 @@ test("redirects protected route to login when unauthenticated", async () => { expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument(); }); +test("redirects a zero-App user to the common workspace", 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: ["overview:view", "finance:view", "resource:view"], + user: { id: 7, username: "finance" }, + }); + } + if (url.includes("/v1/admin/apps/visible")) { + return jsonResponse({ items: [], mode: "none", total: 0 }); + } + if (url.includes("/v1/admin/navigation/menus")) { + return jsonResponse([]); + } + return jsonResponse(null); + }); + + renderWithRoute("/resources"); + + expect(await screen.findByRole("link", { name: /财务工作台/ })).toBeInTheDocument(); + expect(screen.getByText("通用")).toBeInTheDocument(); + expect(screen.queryByText("资源列表")).not.toBeInTheDocument(); +}); + test("renders resource list route with an authenticated session", async () => { setAccessToken("test-token"); vi.mocked(fetch).mockImplementation(async (input) => { diff --git a/src/app/App.jsx b/src/app/App.jsx index fd8ddc4..d53fd13 100644 --- a/src/app/App.jsx +++ b/src/app/App.jsx @@ -1,7 +1,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AdminLayout } from "@/app/layout/AdminLayout.jsx"; import { DeferredPage } from "@/app/router/DeferredPage.jsx"; -import { RequireAuth, RequirePermission } from "@/app/router/guards.jsx"; +import { RequireAppScope, RequireAuth, RequirePermission } from "@/app/router/guards.jsx"; import { adminRoutes, defaultAdminPath, publicRoutes } from "@/app/router/routeConfig"; import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx"; @@ -21,7 +21,13 @@ function App() { path={route.path} element={ - + {route.path === defaultAdminPath ? ( + + ) : ( + + + + )} } /> diff --git a/src/app/app-scope/AppScopeProvider.jsx b/src/app/app-scope/AppScopeProvider.jsx index 0aac587..e98e86f 100644 --- a/src/app/app-scope/AppScopeProvider.jsx +++ b/src/app/app-scope/AppScopeProvider.jsx @@ -1,30 +1,30 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/app/auth/AuthProvider.jsx"; -import { listAdminApps } from "@/features/app-registry/api"; +import { listVisibleAdminApps } from "@/features/app-registry/api"; import { getSelectedAppCode, setSelectedAppCode as setRequestAppCode } from "@/shared/api/request"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; const AppScopeContext = createContext(null); -const fallbackAppCode = "lalu"; const emptyAppState = { items: [], total: 0 }; export function AppScopeProvider({ children }) { const { user } = useAuth(); const queryClient = useQueryClient(); - const [appCode, setAppCodeState] = useState(() => getSelectedAppCode() || fallbackAppCode); - const { data = emptyAppState, error, loading } = useAdminQuery(() => listAdminApps(), { + const [appCode, setAppCodeState] = useState(() => getSelectedAppCode()); + const { data = emptyAppState, error, loading } = useAdminQuery(() => listVisibleAdminApps(), { enabled: Boolean(user), errorMessage: "加载 App 列表失败", initialData: emptyAppState, - queryKey: ["admin-apps"], + // App 范围属于用户授权,缓存键必须包含用户 ID,避免同一浏览器切换账号后复用上一账号的 App 列表。 + queryKey: ["admin-apps-visible", user?.id || "anonymous"], staleTime: 5 * 60 * 1000 }); const apps = useMemo(() => data?.items || [], [data?.items]); const applyAppCode = useCallback( async (nextAppCode, options = {}) => { - const normalized = normalizeAppCode(nextAppCode) || fallbackAppCode; + const normalized = normalizeAppCode(nextAppCode); setRequestAppCode(normalized); setAppCodeState(normalized); if (!options.skipRefresh) { @@ -35,20 +35,28 @@ export function AppScopeProvider({ children }) { ); useEffect(() => { + if (loading) { + return; + } if (!apps.length) { + // 零授权是合法的通用模式;必须清掉历史 localStorage,否则请求层仍会偷偷发送上一次的 X-App-Code。 + if (appCode) { + void applyAppCode("", { skipRefresh: true }); + } return; } const selectedExists = apps.some((item) => normalizeAppCode(item.appCode) === normalizeAppCode(appCode)); if (!selectedExists) { void applyAppCode(apps[0].appCode, { skipRefresh: false }); } - }, [appCode, applyAppCode, apps]); + }, [appCode, applyAppCode, apps, loading]); const value = useMemo( () => ({ appCode, apps, error, + hasAppAccess: apps.length > 0, loading, setAppCode: applyAppCode }), diff --git a/src/app/layout/AdminLayout.jsx b/src/app/layout/AdminLayout.jsx index 7c5a3e5..3d94154 100644 --- a/src/app/layout/AdminLayout.jsx +++ b/src/app/layout/AdminLayout.jsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { matchPath, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; import { getMenus } from "@/features/menus/api"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { useRefreshSignal } from "@/shared/hooks/useRefreshSignal.js"; @@ -21,6 +22,7 @@ export function AdminLayout() { const location = useLocation(); const navigate = useNavigate(); const { can, user } = useAuth(); + const { hasAppAccess, loading: appScopeLoading } = useAppScope(); const { refresh } = useRefreshSignal(); const { data: backendMenus } = useAdminQuery(() => getMenus(), { errorMessage: "加载菜单失败", @@ -31,10 +33,12 @@ export function AdminLayout() { const menus = useMemo(() => { const fallbackMenus = filterNavigationByPermission(fallbackNavigation, can); if (!backendMenus?.length) { - return fallbackMenus; + return appScopeLoading || hasAppAccess ? fallbackMenus : fallbackMenus.filter((item) => item.code === "overview"); } - return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus, fallbackNavigation); - }, [backendMenus, can]); + const merged = mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus, fallbackNavigation); + // 通用模式只保留工作台入口;App 业务菜单即使角色有权限,也不能在零 App 状态下进入。 + return appScopeLoading || hasAppAccess ? merged : merged.filter((item) => item.code === "overview"); + }, [appScopeLoading, backendMenus, can, hasAppAccess]); const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]); const activeRoute = useMemo( () => adminRoutes.find((route) => matchPath({ end: true, path: route.path }, location.pathname)), diff --git a/src/app/layout/Header.jsx b/src/app/layout/Header.jsx index f2d083a..b977d25 100644 --- a/src/app/layout/Header.jsx +++ b/src/app/layout/Header.jsx @@ -155,21 +155,22 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
appScope.setAppCode(event.target.value)} > - {appScope.apps.length ? ( + {appScope.hasAppAccess ? ( appScope.apps.map((app) => ( )) ) : ( - {appScope.appCode} + 通用 )} ; + } + if (!hasAppAccess) { + return ; + } + return children; +} diff --git a/src/features/app-registry/api.ts b/src/features/app-registry/api.ts index c55cf62..dc6145a 100644 --- a/src/features/app-registry/api.ts +++ b/src/features/app-registry/api.ts @@ -8,3 +8,10 @@ export function listAdminApps(): Promise> { method: endpoint.method }); } + +export function listVisibleAdminApps(): Promise> { + const endpoint = API_ENDPOINTS.listVisibleApps; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listVisibleApps), { + method: endpoint.method + }); +} diff --git a/src/features/dashboard/pages/OverviewPage.jsx b/src/features/dashboard/pages/OverviewPage.jsx index e2fdc7a..cba66ea 100644 --- a/src/features/dashboard/pages/OverviewPage.jsx +++ b/src/features/dashboard/pages/OverviewPage.jsx @@ -1,11 +1,14 @@ import { DashboardWorkspaces } from "@/features/dashboard/components/DashboardWorkspaces.jsx"; import { DashboardUserProfile } from "@/features/dashboard/components/DashboardUserProfile.jsx"; +import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; export function OverviewPage() { + const { hasAppAccess } = useAppScope(); + return ( <> - + {hasAppAccess ? : null} ); } diff --git a/src/features/level-config/api.ts b/src/features/level-config/api.ts index 4678196..eb2133e 100644 --- a/src/features/level-config/api.ts +++ b/src/features/level-config/api.ts @@ -83,6 +83,26 @@ export interface LevelTierPayload { track: LevelTrackCode; } +export interface BatchLevelRulePayload extends LevelRulePayload { + level: number; + track: LevelTrackCode; +} + +export interface BatchLevelTierPayload extends LevelTierPayload { + tier_id: number; +} + +export interface BatchLevelConfigPayload { + rules: BatchLevelRulePayload[]; + tiers: BatchLevelTierPayload[]; +} + +export interface BatchLevelConfigResult { + rules: LevelRuleDto[]; + serverTimeMs?: number; + tiers: LevelTierDto[]; +} + type AnyRecord = Record; export function listLevelConfig(query: PageQuery = {}): Promise { @@ -101,19 +121,12 @@ export function updateTrack(track: LevelTrackCode, payload: LevelTrackPayload): }).then(normalizeTrack); } -export function upsertRule( - track: LevelTrackCode, - level: EntityId, - payload: LevelRulePayload, -): Promise { +export function upsertRule(track: LevelTrackCode, level: EntityId, payload: LevelRulePayload): Promise { const endpoint = API_ENDPOINTS.upsertRule; - return apiRequest( - apiEndpointPath(API_OPERATIONS.upsertRule, { level, track }), - { - body: payload, - method: endpoint.method, - }, - ).then(normalizeRule); + return apiRequest(apiEndpointPath(API_OPERATIONS.upsertRule, { level, track }), { + body: payload, + method: endpoint.method, + }).then(normalizeRule); } export function createTier(payload: LevelTierPayload): Promise { @@ -132,6 +145,21 @@ export function updateTier(tierId: EntityId, payload: LevelTierPayload): Promise }).then(normalizeTier); } +export function batchUpsertLevelConfig(payload: BatchLevelConfigPayload): Promise { + const endpoint = API_ENDPOINTS.batchUpsertLevelConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.batchUpsertLevelConfig), { + body: payload, + method: endpoint.method, + }).then((raw) => { + const source = asRecord(raw); + return { + rules: arrayValue(source.rules).map(normalizeRule), + serverTimeMs: numberValue(source.serverTimeMs ?? source.server_time_ms), + tiers: arrayValue(source.tiers).map(normalizeTier), + }; + }); +} + function normalizeLevelConfig(raw: unknown): LevelConfigDto { const source = asRecord(raw); return { diff --git a/src/features/level-config/components/LevelConfigBatchDialog.jsx b/src/features/level-config/components/LevelConfigBatchDialog.jsx new file mode 100644 index 0000000..7c21754 --- /dev/null +++ b/src/features/level-config/components/LevelConfigBatchDialog.jsx @@ -0,0 +1,245 @@ +import TextField from "@mui/material/TextField"; +import Pagination from "@mui/material/Pagination"; +import { useState } from "react"; +import { AdminFormDialog, AdminFormSwitchField } from "@/shared/ui/AdminFormDialog.jsx"; +import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx"; +import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx"; +import { + levelAvatarFrameResourcesForTrack, + levelBadgeResourcesForTrack, + levelShortBadgeResourcesForTrack, + withSelectedResource, +} from "@/features/level-config/resourceFilters.js"; +import styles from "@/features/level-config/components/LevelConfigBatchDialog.module.css"; + +export function LevelConfigBatchDialog({ page }) { + if (page.activeAction === "rule-batch-edit") { + return ; + } + if (page.activeAction === "tier-batch-edit") { + return ; + } + return null; +} + +function RuleBatchDialog({ page }) { + const pageSize = 20; + const [editorPage, setEditorPage] = useState(1); + const pageCount = Math.max(1, Math.ceil(page.batchForms.length / pageSize)); + const startIndex = (editorPage - 1) * pageSize; + const visibleForms = page.batchForms.slice(startIndex, startIndex + pageSize); + + return ( + +
+
+ + 当前 {startIndex + 1}-{Math.min(startIndex + pageSize, page.batchForms.length)},共{" "} + {page.batchForms.length} 级 + + setEditorPage(nextPage)} + /> +
+ {visibleForms.map((form, visibleIndex) => { + const index = startIndex + visibleIndex; + return ( +
+
+ Lv.{form.level} + {form.name} +
+
+ + + page.updateBatchForm(index, "longBadgeResourceId", value)} + /> + page.updateBatchForm(index, "avatarFrameResourceId", value)} + /> + page.updateBatchForm(index, "shortBadgeResourceId", value)} + /> + page.updateBatchForm(index, "rewardResourceGroupId", value)} + /> + + + page.updateBatchForm(index, "status", checked ? "active" : "disabled") + } + /> +
+
+ ); + })} +
+
+ ); +} + +function TierBatchDialog({ page }) { + return ( + +
+ {page.batchForms.map((form, index) => ( +
+
+ + {form.minLevel}-{form.maxLevel} + + {form.name} +
+
+ + + + page.updateBatchForm(index, "displayAvatarFrameResourceId", value)} + /> + page.updateBatchForm(index, "displayBadgeResourceId", value)} + /> + page.updateBatchForm(index, "rewardResourceGroupId", value)} + /> + + page.updateBatchForm(index, "status", checked ? "active" : "disabled") + } + /> +
+
+ ))} +
+
+ ); +} + +function changeBatchField(page, index, key) { + return (event) => page.updateBatchForm(index, key, event.target.value); +} diff --git a/src/features/level-config/components/LevelConfigBatchDialog.module.css b/src/features/level-config/components/LevelConfigBatchDialog.module.css new file mode 100644 index 0000000..49f5bda --- /dev/null +++ b/src/features/level-config/components/LevelConfigBatchDialog.module.css @@ -0,0 +1,87 @@ +.list { + display: grid; + gap: var(--space-3); +} + +.pager { + position: sticky; + top: 0; + z-index: 2; + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card); + color: var(--text-secondary); + font-size: var(--admin-font-size); + box-shadow: var(--shadow-sm); +} + +.row { + display: grid; + grid-template-columns: 148px minmax(0, 1fr); + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); + transition: + border-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard); +} + +.row:focus-within { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.identity { + display: grid; + align-content: start; + gap: var(--space-1); + padding: var(--space-2); + color: var(--text-secondary); +} + +.identity strong { + color: var(--primary); + font-size: 16px; +} + +.identity span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ruleFields, +.tierFields { + display: grid; + grid-template-columns: repeat(4, minmax(180px, 1fr)); + gap: var(--space-2); +} + +.tierFields { + grid-template-columns: repeat(4, minmax(180px, 1fr)); +} + +@media (max-width: 980px) { + .row { + grid-template-columns: 1fr; + } + + .ruleFields, + .tierFields { + grid-template-columns: repeat(2, minmax(160px, 1fr)); + } +} + +@media (prefers-reduced-motion: reduce) { + .row { + transition: none; + } +} diff --git a/src/features/level-config/hooks/useLevelConfigPage.js b/src/features/level-config/hooks/useLevelConfigPage.js index 1b5042b..88fc889 100644 --- a/src/features/level-config/hooks/useLevelConfigPage.js +++ b/src/features/level-config/hooks/useLevelConfigPage.js @@ -3,13 +3,16 @@ import { parseForm } from "@/shared/forms/validation"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { listResourceGroups, listResources } from "@/features/resources/api"; import { levelTrackLabels } from "@/features/level-config/constants.js"; -import { createTier, listLevelConfig, updateTier, updateTrack, upsertRule } from "@/features/level-config/api"; -import { useLevelConfigAbilities } from "@/features/level-config/permissions.js"; import { - levelRuleFormSchema, - levelTierFormSchema, - levelTrackFormSchema, -} from "@/features/level-config/schema"; + batchUpsertLevelConfig, + createTier, + listLevelConfig, + updateTier, + updateTrack, + upsertRule, +} from "@/features/level-config/api"; +import { useLevelConfigAbilities } from "@/features/level-config/permissions.js"; +import { levelRuleFormSchema, levelTierFormSchema, levelTrackFormSchema } from "@/features/level-config/schema"; const emptyConfig = { serverTimeMs: 0, tracks: [] }; const defaultTrack = "wealth"; @@ -35,6 +38,8 @@ export function useLevelConfigPage() { const [ruleForm, setRuleForm] = useState(() => buildRuleForm()); const [tierForm, setTierForm] = useState(() => buildTierForm()); const [trackForm, setTrackForm] = useState(() => buildTrackForm()); + const [batchForms, setBatchForms] = useState([]); + const [batchOriginal, setBatchOriginal] = useState([]); const [loadingAction, setLoadingAction] = useState(""); const reload = useCallback(async () => { @@ -66,10 +71,14 @@ export function useLevelConfigPage() { void reload(); }, [reload]); - const tracks = useMemo( - () => (config.tracks || []).filter((item) => item.track === track), - [config.tracks, track], - ); + // 保存后只刷新配置数据,不切回整页骨架;滚动容器保持挂载才能保留用户当前位置。 + const refreshConfig = useCallback(async () => { + const nextConfig = await listLevelConfig(); + setConfig(nextConfig); + return nextConfig; + }, []); + + const tracks = useMemo(() => (config.tracks || []).filter((item) => item.track === track), [config.tracks, track]); const changeTrack = (value) => setTrack(value); const openRuleCreate = (trackCode = resolveDefaultTrack(config, track)) => { @@ -102,11 +111,27 @@ export function useLevelConfigPage() { setActiveAction("track-edit"); }; + const openRuleBatchEdit = (levelTrack) => { + const forms = (levelTrack?.rules || []).map((rule) => buildRuleForm(rule)); + setBatchForms(forms); + setBatchOriginal(forms); + setActiveAction("rule-batch-edit"); + }; + + const openTierBatchEdit = (levelTrack) => { + const forms = (levelTrack?.tiers || []).map((tier) => ({ ...buildTierForm(tier), tierId: tier.tierId })); + setBatchForms(forms); + setBatchOriginal(forms); + setActiveAction("tier-batch-edit"); + }; + const closeAction = () => { setActiveAction(""); setSelectedRule(null); setSelectedTier(null); setSelectedTrack(null); + setBatchForms([]); + setBatchOriginal([]); }; const submitRule = async (event) => { @@ -114,11 +139,15 @@ export function useLevelConfigPage() { const editing = activeAction === "rule-edit"; const parsed = parseForm(levelRuleFormSchema, ruleForm); const level = Number(parsed.level); - await runAction(editing ? "rule-edit" : "rule-create", editing ? "等级规则已更新" : "等级规则已创建", async () => { - await upsertRule(parsed.track, level, buildRulePayload(parsed)); - closeAction(); - await reload(); - }); + await runAction( + editing ? "rule-edit" : "rule-create", + editing ? "等级规则已更新" : "等级规则已创建", + async () => { + await upsertRule(parsed.track, level, buildRulePayload(parsed)); + closeAction(); + await refreshConfig(); + }, + ); }; const submitTier = async (event) => { @@ -136,7 +165,7 @@ export function useLevelConfigPage() { await createTier(payload); } closeAction(); - await reload(); + await refreshConfig(); }); }; @@ -149,22 +178,30 @@ export function useLevelConfigPage() { await runAction("track-edit", "等级类型已更新", async () => { await updateTrack(selectedTrack.track, buildTrackPayload(parsed)); closeAction(); - await reload(); + await refreshConfig(); }); }; const toggleRule = async (rule, nextEnabled = rule.status !== "active") => { - if (!abilities.canUpdate || !rule?.track || !rule?.level) { + if (!abilities.canUpdate || !rule?.track || rule?.level === undefined || rule?.level === null) { return; } const nextStatus = nextEnabled ? "active" : "disabled"; if (rule.status === nextStatus) { return; } - await runAction(`rule-status-${rule.track}-${rule.level}`, nextEnabled ? "等级规则已启用" : "等级规则已停用", async () => { - await upsertRule(rule.track, rule.level, buildRulePayload(buildRuleForm({ ...rule, status: nextStatus }))); - await reload(); - }); + await runAction( + `rule-status-${rule.track}-${rule.level}`, + nextEnabled ? "等级规则已启用" : "等级规则已停用", + async () => { + await upsertRule( + rule.track, + rule.level, + buildRulePayload(buildRuleForm({ ...rule, status: nextStatus })), + ); + await refreshConfig(); + }, + ); }; const toggleTier = async (tier, nextEnabled = tier.status !== "active") => { @@ -177,10 +214,57 @@ export function useLevelConfigPage() { } await runAction(`tier-status-${tier.tierId}`, nextEnabled ? "等级层已启用" : "等级层已停用", async () => { await updateTier(tier.tierId, buildTierPayload(buildTierForm({ ...tier, status: nextStatus }))); - await reload(); + await refreshConfig(); }); }; + const updateBatchForm = (index, key, value) => { + setBatchForms((current) => + current.map((form, formIndex) => (formIndex === index ? { ...form, [key]: value } : form)), + ); + }; + + const submitRuleBatch = async (event) => { + event.preventDefault(); + await runAction("rule-batch-edit", "等级规则已批量更新", async () => { + const rules = batchForms + .filter((form, index) => !sameForm(form, batchOriginal[index])) + .map((form) => { + const parsed = parseForm(levelRuleFormSchema, form); + return { ...buildRulePayload(parsed), level: Number(parsed.level), track: parsed.track }; + }); + if (!rules.length) { + throw new Error("没有需要保存的修改"); + } + await batchUpsertLevelConfig({ rules, tiers: [] }); + closeAction(); + await refreshConfig(); + }); + }; + + const submitTierBatch = async (event) => { + event.preventDefault(); + await runAction("tier-batch-edit", "等级层已批量更新", async () => { + const tiers = batchForms + .filter((form, index) => !sameForm(form, batchOriginal[index])) + .map((form) => { + const parsed = parseForm(levelTierFormSchema, form); + return { ...buildTierPayload(parsed), tier_id: Number(form.tierId) }; + }); + if (!tiers.length) { + throw new Error("没有需要保存的修改"); + } + await batchUpsertLevelConfig({ rules: [], tiers }); + closeAction(); + await refreshConfig(); + }); + }; + + const batchDirty = useMemo( + () => batchForms.some((form, index) => !sameForm(form, batchOriginal[index])), + [batchForms, batchOriginal], + ); + const runAction = async (action, successMessage, submitter) => { setLoadingAction(action); try { @@ -198,6 +282,8 @@ export function useLevelConfigPage() { activeAction, avatarFrameResources, badgeResources, + batchDirty, + batchForms, changeTrack, closeAction, config, @@ -205,8 +291,10 @@ export function useLevelConfigPage() { loading, loadingAction, openRuleCreate, + openRuleBatchEdit, openRuleEdit, openTierCreate, + openTierBatchEdit, openTierEdit, openTrackEdit, reload, @@ -220,7 +308,9 @@ export function useLevelConfigPage() { setTierForm, setTrackForm, submitRule, + submitRuleBatch, submitTier, + submitTierBatch, submitTrack, tierForm, toggleRule, @@ -228,6 +318,7 @@ export function useLevelConfigPage() { track, trackForm, tracks, + updateBatchForm, }; } @@ -347,3 +438,7 @@ function normalizedJson(value) { const raw = String(value || "").trim(); return raw || "{}"; } + +function sameForm(left, right) { + return JSON.stringify(left || {}) === JSON.stringify(right || {}); +} diff --git a/src/features/level-config/hooks/useLevelConfigPage.test.jsx b/src/features/level-config/hooks/useLevelConfigPage.test.jsx index b5f63a1..f0716a9 100644 --- a/src/features/level-config/hooks/useLevelConfigPage.test.jsx +++ b/src/features/level-config/hooks/useLevelConfigPage.test.jsx @@ -1,7 +1,8 @@ -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, expect, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + batchUpsertLevelConfig: vi.fn(), createTier: vi.fn(), listLevelConfig: vi.fn(), listResourceGroups: vi.fn(), @@ -18,6 +19,7 @@ vi.mock("@/features/resources/api", () => ({ })); vi.mock("@/features/level-config/api", () => ({ + batchUpsertLevelConfig: mocks.batchUpsertLevelConfig, createTier: mocks.createTier, listLevelConfig: mocks.listLevelConfig, updateTier: mocks.updateTier, @@ -83,3 +85,33 @@ test("loads level badges through backend resource type filtering", async () => { expect(result.current.badgeResources).toEqual([badgeResource]); expect(result.current.avatarFrameResources).toEqual([avatarFrameResource]); }); + +test("submits changed rules through one batch request without returning to page loading", async () => { + const levelTrack = { + rules: [ + { level: 0, name: "魅力等级 0级", requiredValue: 0, sortOrder: 0, status: "active", track: "charm" }, + { level: 1, name: "魅力等级 1级", requiredValue: 500, sortOrder: 1, status: "active", track: "charm" }, + ], + tiers: [], + track: "charm", + }; + mocks.listLevelConfig.mockResolvedValue({ serverTimeMs: 0, tracks: [levelTrack] }); + mocks.listResources.mockResolvedValue({ items: [], page: 1, pageSize: 500, total: 0 }); + mocks.listResourceGroups.mockResolvedValue({ items: [], page: 1, pageSize: 500, total: 0 }); + mocks.batchUpsertLevelConfig.mockResolvedValue({ rules: [], tiers: [] }); + + const { result } = renderHook(() => useLevelConfigPage()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.openRuleBatchEdit(levelTrack)); + act(() => result.current.updateBatchForm(1, "requiredValue", "800")); + await act(async () => result.current.submitRuleBatch({ preventDefault: vi.fn() })); + + expect(mocks.batchUpsertLevelConfig).toHaveBeenCalledTimes(1); + expect(mocks.batchUpsertLevelConfig).toHaveBeenCalledWith({ + rules: [expect.objectContaining({ level: 1, required_value: 800, track: "charm" })], + tiers: [], + }); + expect(mocks.listLevelConfig).toHaveBeenCalledTimes(2); + expect(result.current.loading).toBe(false); +}); diff --git a/src/features/level-config/level-config.module.css b/src/features/level-config/level-config.module.css index 303de56..58e250a 100644 --- a/src/features/level-config/level-config.module.css +++ b/src/features/level-config/level-config.module.css @@ -215,6 +215,14 @@ font-size: 12px; font-weight: 650; line-height: 1; + margin-left: auto; +} + +.tableActions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--space-2); } .compactTable { diff --git a/src/features/level-config/pages/LevelConfigPage.jsx b/src/features/level-config/pages/LevelConfigPage.jsx index 2b57738..2011f14 100644 --- a/src/features/level-config/pages/LevelConfigPage.jsx +++ b/src/features/level-config/pages/LevelConfigPage.jsx @@ -22,6 +22,13 @@ import { formatMillis } from "@/shared/utils/time.js"; import { levelStatusLabels, levelTrackLabels, levelTrackOptions } from "@/features/level-config/constants.js"; import { useLevelConfigPage } from "@/features/level-config/hooks/useLevelConfigPage.js"; import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx"; +import { LevelConfigBatchDialog } from "@/features/level-config/components/LevelConfigBatchDialog.jsx"; +import { + levelAvatarFrameResourcesForTrack, + levelBadgeResourcesForTrack, + levelShortBadgeResourcesForTrack, + withSelectedResource, +} from "@/features/level-config/resourceFilters.js"; import styles from "@/features/level-config/level-config.module.css"; export function LevelConfigPage() { @@ -101,6 +108,7 @@ export function LevelConfigPage() { + ); } @@ -158,7 +166,19 @@ function LevelTrackSection({ page, track }) {
- + } + onClick={() => page.openRuleBatchEdit(track)} + > + 批量编辑 + + } + countLabel={`${rules.length} 条`} + title="每级升级数值" + />
- + } + onClick={() => page.openTierBatchEdit(track)} + > + 批量编辑 + + } + countLabel={`${tiers.length} 个`} + title="等级层展示与礼物" + />
{title}
{countLabel} + {actions ?
{actions}
: null}
); } @@ -753,40 +786,6 @@ function changeField(setter, key) { }; } -function withSelectedResource(resources, value) { - const selectedId = Number(value || 0); - if (!selectedId || resources.some((resource) => Number(resource.resourceId) === selectedId)) { - return resources; - } - return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources]; -} - -function levelBadgeResourcesForTrack(resources, track) { - return (resources || []).filter((resource) => { - const badgeForm = resource.badgeForm || badgeFormFromMetadata(resource.metadataJson); - const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson); - const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson); - return badgeForm === "strip" && badgeKind === "level" && levelTrack === track; - }); -} - -function levelShortBadgeResourcesForTrack(resources, track) { - return (resources || []).filter((resource) => { - const badgeForm = resource.badgeForm || badgeFormFromMetadata(resource.metadataJson); - const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson); - const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson); - return badgeForm === "tile" && badgeKind === "level" && levelTrack === track; - }); -} - -function levelAvatarFrameResourcesForTrack(resources, track) { - return (resources || []).filter((resource) => { - const avatarFrameKind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson); - const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson); - return resource.resourceType === "avatar_frame" && avatarFrameKind === "level" && levelTrack === track; - }); -} - function resourceMetaLabel(resource, id) { if (resource.resourceType === "avatar_frame") { const avatarFrameKind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson); @@ -838,7 +837,11 @@ function badgeKindFromMetadata(metadataJson) { } try { const metadata = JSON.parse(metadataJson); - return String(metadata?.badge_kind || "").trim().toLowerCase() === "level" ? "level" : "normal"; + return String(metadata?.badge_kind || "") + .trim() + .toLowerCase() === "level" + ? "level" + : "normal"; } catch { return ""; } diff --git a/src/features/level-config/resourceFilters.js b/src/features/level-config/resourceFilters.js new file mode 100644 index 0000000..887173e --- /dev/null +++ b/src/features/level-config/resourceFilters.js @@ -0,0 +1,62 @@ +export function withSelectedResource(resources, value) { + const selectedId = Number(value || 0); + if (!selectedId || resources.some((resource) => Number(resource.resourceId) === selectedId)) { + return resources; + } + return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources]; +} + +export function levelBadgeResourcesForTrack(resources, track) { + return (resources || []).filter((resource) => { + const metadata = resourceMetadata(resource); + return ( + (resource.badgeForm || metadata.badgeForm) === "strip" && + (resource.badgeKind || metadata.badgeKind) === "level" && + (resource.levelTrack || metadata.levelTrack) === track + ); + }); +} + +export function levelShortBadgeResourcesForTrack(resources, track) { + return (resources || []).filter((resource) => { + const metadata = resourceMetadata(resource); + return ( + (resource.badgeForm || metadata.badgeForm) === "tile" && + (resource.badgeKind || metadata.badgeKind) === "level" && + (resource.levelTrack || metadata.levelTrack) === track + ); + }); +} + +export function levelAvatarFrameResourcesForTrack(resources, track) { + return (resources || []).filter((resource) => { + const metadata = resourceMetadata(resource); + return ( + resource.resourceType === "avatar_frame" && + (resource.avatarFrameKind || metadata.avatarFrameKind) === "level" && + (resource.levelTrack || metadata.levelTrack) === track + ); + }); +} + +function resourceMetadata(resource) { + try { + const metadata = JSON.parse(resource?.metadataJson || "{}"); + return { + avatarFrameKind: String(metadata.avatar_frame_kind || "") + .trim() + .toLowerCase(), + badgeForm: String(metadata.badge_form || "") + .trim() + .toLowerCase(), + badgeKind: String(metadata.badge_kind || "") + .trim() + .toLowerCase(), + levelTrack: String(metadata.level_track || "") + .trim() + .toLowerCase(), + }; + } catch { + return {}; + } +} diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index f535bfe..16d3e0f 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -1232,6 +1232,7 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, disabled={disabled || uploadDisabled} kind="file" label="资源封面" + showSourceActions value={form.previewUrl} onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))} /> @@ -1239,6 +1240,7 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, disabled={disabled || uploadDisabled} kind="file" label="动效素材" + showSourceActions value={form.animationUrl} onChange={(animationUrl) => setForm((previous) => ({ ...previous, animationUrl }))} onFileSelected={handleAnimationSelected} diff --git a/src/features/users/api.test.ts b/src/features/users/api.test.ts index 13c98fc..4594b13 100644 --- a/src/features/users/api.test.ts +++ b/src/features/users/api.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test, vi } from "vitest"; import { setAccessToken } from "@/shared/api/request"; -import { getFinanceScopeCatalog, listUserFinanceScopes, listUsers, replaceUserFinanceScopes, updateUserStatus } from "./api"; +import { getFinanceScopeCatalog, getUserAppScopes, listFinanceScopeAssignments, listUserFinanceScopes, listUsers, replaceUserAppScopes, replaceUserFinanceScopes, updateUserStatus } from "./api"; afterEach(() => { setAccessToken(""); @@ -48,6 +48,7 @@ test("finance scope APIs use generated endpoint paths", async () => { await listUserFinanceScopes(9); await replaceUserFinanceScopes(9, [{ appCode: "lalu", regionId: 2 }]); await getFinanceScopeCatalog(); + await listFinanceScopeAssignments(); const calls = vi.mocked(fetch).mock.calls; expect(String(calls[0][0])).toContain("/api/v1/users/9/finance-scopes"); @@ -57,4 +58,23 @@ test("finance scope APIs use generated endpoint paths", async () => { expect(JSON.parse(String(calls[1][1]?.body))).toEqual({ scopes: [{ appCode: "lalu", regionId: 2 }] }); expect(String(calls[2][0])).toContain("/api/v1/admin/finance/scope"); expect(calls[2][1]?.method).toBe("GET"); + expect(String(calls[3][0])).toContain("/api/v1/users/finance-scope-assignments"); + expect(calls[3][1]?.method).toBe("GET"); +}); + +test("app scope APIs use generated endpoint paths and explicit mode", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { appCodes: ["lalu"], mode: "selected" } }))) + ); + + await getUserAppScopes(9); + await replaceUserAppScopes(9, { appCodes: ["lalu"], mode: "selected" }); + + const calls = vi.mocked(fetch).mock.calls; + expect(String(calls[0][0])).toContain("/api/v1/users/9/app-scopes"); + expect(calls[0][1]?.method).toBe("GET"); + expect(String(calls[1][0])).toContain("/api/v1/users/9/app-scopes"); + expect(calls[1][1]?.method).toBe("PUT"); + expect(JSON.parse(String(calls[1][1]?.body))).toEqual({ appCodes: ["lalu"], mode: "selected" }); }); diff --git a/src/features/users/api.ts b/src/features/users/api.ts index 5adb20a..71cb826 100644 --- a/src/features/users/api.ts +++ b/src/features/users/api.ts @@ -5,10 +5,12 @@ import type { ApiPage, CreateUserResultDto, EntityId, + FinanceScopeAssignmentDto, PageQuery, ResetPasswordResultDto, UserFormPayload, UserFinanceScopeDto, + UserAppScopeDto, UserStatus, UserUpdatePayload } from "@/shared/api/types"; @@ -87,3 +89,18 @@ export function replaceUserFinanceScopes(id: EntityId, scopes: UserFinanceScopeD export function getFinanceScopeCatalog(): Promise { return apiRequest(apiEndpointPath(API_OPERATIONS.getFinanceScope)); } + +export function listFinanceScopeAssignments(): Promise<{ items: FinanceScopeAssignmentDto[]; total: number }> { + return apiRequest<{ items: FinanceScopeAssignmentDto[]; total: number }>(apiEndpointPath(API_OPERATIONS.listFinanceScopeAssignments)); +} + +export function getUserAppScopes(id: EntityId): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.getUserAppScopes, { id })); +} + +export function replaceUserAppScopes(id: EntityId, scope: UserAppScopeDto): Promise { + return apiRequest(apiEndpointPath(API_OPERATIONS.replaceUserAppScopes, { id }), { + body: scope, + method: "PUT" + }); +} diff --git a/src/features/users/components/UserAppScopeEditor.jsx b/src/features/users/components/UserAppScopeEditor.jsx new file mode 100644 index 0000000..b3914b3 --- /dev/null +++ b/src/features/users/components/UserAppScopeEditor.jsx @@ -0,0 +1,40 @@ +import Checkbox from "@mui/material/Checkbox"; +import { AppIdentity } from "@/shared/ui/AppIdentity.jsx"; + +export function UserAppScopeEditor({ apps = [], appCodes = [], mode = "none", onAppChecked, onModeChange }) { + const selected = new Set((appCodes || []).map(normalizeAppCode)); + const all = mode === "all"; + const count = all ? apps.length : selected.size; + + if (!apps.length) { + return
当前无数据
; + } + + return ( +
+
+
App 可见范围
+ 已选 {count} 项 +
+
+ + {apps.map((app) => { + const appCode = normalizeAppCode(app.appCode); + return ( + + ); + })} +
+
+ ); +} + +function normalizeAppCode(value) { + return String(value || "").trim().toLowerCase(); +} diff --git a/src/features/users/components/UserAppScopeEditor.test.jsx b/src/features/users/components/UserAppScopeEditor.test.jsx new file mode 100644 index 0000000..dc2bb47 --- /dev/null +++ b/src/features/users/components/UserAppScopeEditor.test.jsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, test, vi } from "vitest"; +import { UserAppScopeEditor } from "./UserAppScopeEditor.jsx"; + +const apps = [ + { appCode: "huwaa", appName: "Huwaa" }, + { appCode: "lalu", appName: "Lalu" } +]; + +test("renders selected App scope and emits individual changes", async () => { + const user = userEvent.setup(); + const onAppChecked = vi.fn(); + render( + + ); + + expect(screen.getByText("已选 1 项")).toBeInTheDocument(); + await user.click(screen.getByRole("checkbox", { name: /Huwaa/ })); + expect(onAppChecked).toHaveBeenCalledWith("huwaa", true); +}); + +test("all mode is explicit and selects every visible App", async () => { + const user = userEvent.setup(); + const onModeChange = vi.fn(); + render( + + ); + + expect(screen.getByText("已选 2 项")).toBeInTheDocument(); + expect(screen.getAllByRole("checkbox").every((item) => item.checked)).toBe(true); + await user.click(screen.getByRole("checkbox", { name: "全部 App" })); + expect(onModeChange).toHaveBeenCalledWith("none"); +}); diff --git a/src/features/users/components/UserFinanceScopeEditor.jsx b/src/features/users/components/UserFinanceScopeEditor.jsx index ad7a5aa..b45cfce 100644 --- a/src/features/users/components/UserFinanceScopeEditor.jsx +++ b/src/features/users/components/UserFinanceScopeEditor.jsx @@ -5,11 +5,12 @@ import TextField from "@mui/material/TextField"; import { useEffect, useMemo, useState } from "react"; import { AppIdentity } from "@/shared/ui/AppIdentity.jsx"; -export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) { +export function UserFinanceScopeEditor({ assignments = [], catalog, checkedScopes, excludeUserId, onChecked }) { const apps = useMemo(() => catalog?.apps || [], [catalog?.apps]); const [activeAppCode, setActiveAppCode] = useState(""); const [query, setQuery] = useState(""); const [selectedOnly, setSelectedOnly] = useState(false); + const [unassignedOnly, setUnassignedOnly] = useState(false); useEffect(() => { // 目录刷新后主动回退到第一个有效应用,防止权限变化导致右侧仍引用已移除的应用。 @@ -18,21 +19,47 @@ export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) { } }, [activeAppCode, apps]); + // 他人(排除当前编辑对象)的持有关系:区域粒度置灰 + 持有人标注,让"剩下哪些区没分"一眼可见。 + const holderIndex = useMemo(() => buildHolderIndex(assignments, excludeUserId), [assignments, excludeUserId]); + const activeApp = apps.find((app) => app.appCode === activeAppCode) || apps[0]; + const appRegions = useMemo( + () => (catalog?.regions || []).filter((region) => region.appCode === activeApp?.appCode), + [activeApp?.appCode, catalog?.regions], + ); + + const allRegionsChecked = activeApp ? hasScope(checkedScopes, activeApp.appCode, 0) : false; + + const appStats = useMemo(() => { + if (!activeApp) { + return { taken: 0, unassigned: 0 }; + } + let taken = 0; + let unassigned = 0; + for (const region of appRegions) { + if (holdersFor(holderIndex, activeApp.appCode, region.regionId).length) { + taken += 1; + } else if (!allRegionsChecked && !hasScope(checkedScopes, activeApp.appCode, region.regionId)) { + unassigned += 1; + } + } + return { taken, unassigned }; + }, [activeApp, allRegionsChecked, appRegions, checkedScopes, holderIndex]); + const activeRegions = useMemo(() => { const keyword = query.trim().toLowerCase(); - // 搜索与仅看已选只影响展示集合,原始勾选状态始终保留,切换应用或筛选不会丢失授权。 - return (catalog?.regions || []).filter((region) => { - if (region.appCode !== activeApp?.appCode) { + // 搜索与筛选只影响展示集合,原始勾选状态始终保留,切换应用或筛选不会丢失授权。 + return appRegions.filter((region) => { + const checked = hasScope(checkedScopes, activeApp?.appCode, region.regionId); + if (selectedOnly && !checked) { return false; } - const checked = hasScope(checkedScopes, region.appCode, region.regionId); - if (selectedOnly && !checked) { + if (unassignedOnly && (checked || holdersFor(holderIndex, activeApp?.appCode, region.regionId).length)) { return false; } return !keyword || [region.name, region.regionCode].some((value) => String(value || "").toLowerCase().includes(keyword)); }); - }, [activeApp?.appCode, catalog?.regions, checkedScopes, query, selectedOnly]); + }, [activeApp?.appCode, appRegions, checkedScopes, holderIndex, query, selectedOnly, unassignedOnly]); if (catalog?.loading) { return
加载中
; @@ -44,7 +71,8 @@ export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) { return
当前无数据
; } - const allRegionsChecked = hasScope(checkedScopes, activeApp.appCode, 0); + const allHolders = holdersFor(holderIndex, activeApp.appCode, 0); + const appTakenByOthers = appStats.taken > 0 || allHolders.length > 0; return (
@@ -74,7 +102,9 @@ export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) {
{activeApp.appName || activeApp.appCode} - 已选 {countAppScopes(checkedScopes, activeApp.appCode)} 项 + + 已选 {countAppScopes(checkedScopes, activeApp.appCode)} 项 · 他人已分 {appStats.taken} · 未分配 {appStats.unassigned} +
+
- {(!selectedOnly || allRegionsChecked) && !query ? ( + {(!selectedOnly || allRegionsChecked) && !unassignedOnly && !query ? ( 0 + ? `他人已分 ${appStats.taken} 区` + : "" + } label="全部区域" onChange={(checked) => onChecked(activeApp.appCode, 0, checked)} /> ) : null} - {activeRegions.map((region) => ( - onChecked(activeApp.appCode, region.regionId, checked)} - /> - ))} + {activeRegions.map((region) => { + const holders = holdersFor(holderIndex, activeApp.appCode, region.regionId); + const checked = hasScope(checkedScopes, activeApp.appCode, region.regionId); + return ( + 0} + holderText={holders.length ? `已分配:${holderNames(holders)}` : ""} + key={region.regionId} + label={region.name} + onChange={(nextChecked) => onChecked(activeApp.appCode, region.regionId, nextChecked)} + /> + ); + })}
- {!activeRegions.length && (query || selectedOnly) && !(selectedOnly && allRegionsChecked) ? ( + {!activeRegions.length && (query || selectedOnly || unassignedOnly) && !(selectedOnly && allRegionsChecked) ? (
当前无匹配区域
) : null} @@ -124,18 +191,66 @@ export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) { ); } -function FinanceScopeOption({ checked, code, label, onChange }) { +function FinanceScopeOption({ checked, code, disabled = false, holderText = "", label, onChange }) { return ( -
) : null} + + {activeSection === "apps" ? ( + + ) : null} ); } diff --git a/src/features/users/hooks/useUsersPage.js b/src/features/users/hooks/useUsersPage.js index 9dc6cf4..cd75016 100644 --- a/src/features/users/hooks/useUsersPage.js +++ b/src/features/users/hooks/useUsersPage.js @@ -9,14 +9,18 @@ import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { listRoles } from "@/features/roles/api"; import { listTeams } from "@/features/teams/api"; +import { listAdminApps } from "@/features/app-registry/api"; import { batchUpdateUserStatus, createUser, exportUsers, getFinanceScopeCatalog, + getUserAppScopes, + listFinanceScopeAssignments, listUserFinanceScopes, listUsers, replaceUserFinanceScopes, + replaceUserAppScopes, resetUserPassword, updateUser, updateUserStatus, @@ -29,6 +33,8 @@ const emptyUsers = { items: [], total: 0, page: 1, pageSize }; const emptyData = { roles: [], teams: [], users: emptyUsers }; const emptyForm = { + appCodes: [], + appScopeMode: "none", mfaEnabled: false, name: "", password: "", @@ -51,6 +57,8 @@ export function useUsersPage() { const [profileUser, setProfileUser] = useState(null); const [form, setForm] = useState(emptyForm); const [financeScopeCatalog, setFinanceScopeCatalog] = useState({ apps: [], countries: [], error: "", loading: false, regions: [] }); + const [financeScopeAssignments, setFinanceScopeAssignments] = useState([]); + const [appScopeCatalog, setAppScopeCatalog] = useState([]); const [mergedUsers, setMergedUsers] = useState(emptyUsers); const abilities = useUserAbilities(); const confirm = useConfirm(); @@ -158,16 +166,41 @@ export function useUsersPage() { } }, []); + const loadFinanceScopeAssignments = useCallback(async () => { + try { + const data = await listFinanceScopeAssignments(); + setFinanceScopeAssignments(Array.isArray(data?.items) ? data.items : []); + } catch (err) { + // 拉不到分配总览时退化为旧行为(不置灰),只提示不阻塞编辑。 + setFinanceScopeAssignments([]); + showToast(err.message || "加载财务范围分配失败", "error"); + } + }, [showToast]); + + const loadAppScopeCatalog = useCallback(async () => { + try { + const data = await listAdminApps(); + setAppScopeCatalog(Array.isArray(data?.items) ? data.items : []); + } catch (err) { + setAppScopeCatalog([]); + showToast(err.message || "加载 App 范围失败", "error"); + } + }, [showToast]); + const openCreate = () => { setEditingUser(null); setForm(emptyForm); setFormOpen(true); loadFinanceScopeCatalog(); + loadFinanceScopeAssignments(); + loadAppScopeCatalog(); }; const openEdit = (user) => { setEditingUser(user); setForm({ + appCodes: [], + appScopeMode: user.appScopeMode || "none", mfaEnabled: user.mfaEnabled, name: user.name, password: "", @@ -180,6 +213,17 @@ export function useUsersPage() { }); setFormOpen(true); loadFinanceScopeCatalog(); + loadFinanceScopeAssignments(); + loadAppScopeCatalog(); + getUserAppScopes(user.id) + .then((scope) => { + setForm((current) => + current.username === user.account + ? { ...current, appCodes: normalizeAppCodes(scope?.appCodes), appScopeMode: normalizeAppScopeMode(scope?.mode) } + : current, + ); + }) + .catch((err) => showToast(err.message || "加载 App 范围失败", "error")); listUserFinanceScopes(user.id) .then((data) => { const scopes = Array.isArray(data?.items) ? data.items : []; @@ -210,12 +254,14 @@ export function useUsersPage() { if (editingUser) { const payload = parseForm(userUpdateFormSchema, form); await updateUser(editingUser.id, payload); + await replaceUserAppScopes(editingUser.id, normalizeAppScope(form)); await replaceUserFinanceScopes(editingUser.id, normalizeFinanceScopes(form.financeScopes)); showToast("用户已更新", "success"); } else { const payload = parseForm(userCreateFormSchema, form); const result = await createUser(payload); if (result?.user?.id) { + await replaceUserAppScopes(result.user.id, normalizeAppScope(form)); await replaceUserFinanceScopes(result.user.id, normalizeFinanceScopes(form.financeScopes)); } showToast(`用户已创建,初始密码:${result.initialPassword}`, "success"); @@ -306,6 +352,36 @@ export function useUsersPage() { })); }; + const setAppScopeMode = (mode) => { + const normalizedMode = normalizeAppScopeMode(mode); + setForm((current) => ({ + ...current, + appCodes: normalizedMode === "selected" ? current.appCodes : [], + appScopeMode: normalizedMode, + })); + }; + + const setAppScopeChecked = (appCode, checked) => { + const normalized = String(appCode || "").trim().toLowerCase(); + if (!normalized) { + return; + } + setForm((current) => { + // 从“全部 App”取消单项时转换为显式 selected,确保只移除目标 App,不把全部授权一次清空。 + const baseCodes = current.appScopeMode === "all" + ? appScopeCatalog.map((app) => String(app.appCode || "").trim().toLowerCase()).filter(Boolean) + : normalizeAppCodes(current.appCodes); + const nextCodes = checked + ? normalizeAppCodes([...baseCodes, normalized]) + : baseCodes.filter((code) => code !== normalized); + return { + ...current, + appCodes: nextCodes, + appScopeMode: nextCodes.length ? "selected" : "none", + }; + }); + }; + const setFinanceScopeChecked = (appCode, regionId, checked) => { const normalizedAppCode = String(appCode || "").trim(); const normalizedRegionId = Number(regionId || 0); @@ -330,6 +406,7 @@ export function useUsersPage() { return { abilities, + appScopeCatalog, batchStatus, changeQuery, changeRoleFilter, @@ -339,6 +416,7 @@ export function useUsersPage() { downloadUsers, editingUser, error, + financeScopeAssignments, financeScopeCatalog, form, formOpen, @@ -358,6 +436,8 @@ export function useUsersPage() { roles, selectedIds, setForm, + setAppScopeChecked, + setAppScopeMode, setFinanceScopeChecked, setPage, setRoleChecked, @@ -372,6 +452,22 @@ export function useUsersPage() { }; } +function normalizeAppScope(form) { + const mode = normalizeAppScopeMode(form?.appScopeMode); + return { + mode, + appCodes: mode === "selected" ? normalizeAppCodes(form?.appCodes) : [], + }; +} + +function normalizeAppScopeMode(mode) { + return ["all", "selected", "none"].includes(mode) ? mode : "none"; +} + +function normalizeAppCodes(appCodes = []) { + return [...new Set((Array.isArray(appCodes) ? appCodes : []).map((code) => String(code || "").trim().toLowerCase()).filter(Boolean))].sort(); +} + function normalizeFinanceScopes(scopes = []) { const seen = new Set(); return (Array.isArray(scopes) ? scopes : []) diff --git a/src/features/users/pages/UserManagementPage.jsx b/src/features/users/pages/UserManagementPage.jsx index 4f42546..ee42e41 100644 --- a/src/features/users/pages/UserManagementPage.jsx +++ b/src/features/users/pages/UserManagementPage.jsx @@ -98,10 +98,14 @@ export function UserManagementPage() { = { permission: "user:status", permissions: ["user:status"] }, + batchUpsertLevelConfig: { + method: "PUT", + operationId: API_OPERATIONS.batchUpsertLevelConfig, + path: "/v1/admin/users/level-config/batch", + permission: "level-config:update", + permissions: ["level-config:update"] + }, cancelJob: { method: "POST", operationId: API_OPERATIONS.cancelJob, @@ -1413,6 +1425,13 @@ export const API_ENDPOINTS: Record = { permission: "user:view", permissions: ["user:view"] }, + getUserAppScopes: { + method: "GET", + operationId: API_OPERATIONS.getUserAppScopes, + path: "/v1/users/{id}/app-scopes", + permission: "user:view", + permissions: ["user:view"] + }, getVipConfig: { method: "GET", operationId: API_OPERATIONS.getVipConfig, @@ -1663,6 +1682,13 @@ export const API_ENDPOINTS: Record = { permission: "finance-order:coin-seller-recharge:view", permissions: ["finance-order:coin-seller-recharge:view"] }, + listFinanceScopeAssignments: { + method: "GET", + operationId: API_OPERATIONS.listFinanceScopeAssignments, + path: "/v1/users/finance-scope-assignments", + permission: "user:view", + permissions: ["user:view"] + }, listFinanceWithdrawalApplications: { method: "GET", operationId: API_OPERATIONS.listFinanceWithdrawalApplications, @@ -2083,6 +2109,11 @@ export const API_ENDPOINTS: Record = { permission: "user:view", permissions: ["user:view"] }, + listVisibleApps: { + method: "GET", + operationId: API_OPERATIONS.listVisibleApps, + path: "/v1/admin/apps/visible" + }, listWeeklyStarCycles: { method: "GET", operationId: API_OPERATIONS.listWeeklyStarCycles, @@ -2248,6 +2279,13 @@ export const API_ENDPOINTS: Record = { permission: "role:permission", permissions: ["role:permission","role:manage"] }, + replaceUserAppScopes: { + method: "PUT", + operationId: API_OPERATIONS.replaceUserAppScopes, + path: "/v1/users/{id}/app-scopes", + permission: "user:update", + permissions: ["user:update"] + }, replaceUserFinanceScopes: { method: "PUT", operationId: API_OPERATIONS.replaceUserFinanceScopes, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 2acc320..cfafa7d 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -1084,6 +1084,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/apps/visible": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listVisibleApps"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/bd-leaders": { parameters: { query?: never; @@ -3260,6 +3276,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/users/level-config/batch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["batchUpsertLevelConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/users/level-config/rules/{track}/{level}": { parameters: { query?: never; @@ -4012,6 +4044,22 @@ export interface paths { patch: operations["updateUser"]; trace?: never; }; + "/users/finance-scope-assignments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listFinanceScopeAssignments"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/{id}/finance-scopes": { parameters: { query?: never; @@ -4028,6 +4076,22 @@ export interface paths { patch?: never; trace?: never; }; + "/users/{id}/app-scopes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUserAppScopes"]; + put: operations["replaceUserAppScopes"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/{id}/reset-password": { parameters: { query?: never; @@ -5171,6 +5235,8 @@ export interface components { }; AdminUser: { account: string; + /** @enum {string} */ + appScopeMode?: "all" | "selected" | "none"; id: number; lastLogin?: string; mfa?: string; @@ -5893,6 +5959,11 @@ export interface components { appCode: string; regionId: number; }; + UserAppScope: { + /** @enum {string} */ + mode: "all" | "selected" | "none"; + appCodes: string[]; + }; }; responses: { /** @description OK */ @@ -6524,6 +6595,11 @@ export interface components { }; }; }; + AppScopeRequest: { + content: { + "application/json": components["schemas"]["UserAppScope"]; + }; + }; FinanceCoinSellerRechargeOrderRequest: { content: { "application/json": components["schemas"]["FinanceCoinSellerRechargeOrderInput"]; @@ -7995,6 +8071,18 @@ export interface operations { 200: components["responses"]["AppListResponse"]; }; }; + listVisibleApps: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["AppListResponse"]; + }; + }; listBDLeaders: { parameters: { query?: { @@ -10641,6 +10729,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + batchUpsertLevelConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; upsertRule: { parameters: { query?: never; @@ -11531,6 +11631,18 @@ export interface operations { 200: components["responses"]["UserResponse"]; }; }; + listFinanceScopeAssignments: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listUserFinanceScopes: { parameters: { query?: never; @@ -11559,6 +11671,34 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + getUserAppScopes: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + replaceUserAppScopes: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["AppScopeRequest"]; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; resetUserPassword: { parameters: { query?: never; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 388b965..7c41a60 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -60,6 +60,7 @@ export type UserStatus = "active" | "disabled" | "locked"; export interface AdminUserDto { account: string; + appScopeMode?: UserAppScopeMode; id: EntityId; lastLogin?: string; lastLoginMs?: number | null; @@ -74,6 +75,13 @@ export interface AdminUserDto { teamName?: string; } +export type UserAppScopeMode = "all" | "selected" | "none"; + +export interface UserAppScopeDto { + appCodes: string[]; + mode: UserAppScopeMode; +} + export interface TeamDto { children?: TeamDto[]; createdAtMs?: number; @@ -100,6 +108,15 @@ export interface UserFinanceScopeDto { userId?: EntityId; } +export interface FinanceScopeAssignmentDto { + appCode: string; + regionId: number; + userId: EntityId; + userName?: string; + userAccount?: string; + userStatus?: string; +} + export interface UserFormPayload { mfaEnabled?: boolean; name: string; diff --git a/src/shared/ui/AdminFormDialog.module.css b/src/shared/ui/AdminFormDialog.module.css index 91f86a6..5d49607 100644 --- a/src/shared/ui/AdminFormDialog.module.css +++ b/src/shared/ui/AdminFormDialog.module.css @@ -32,6 +32,11 @@ --admin-form-field-width: 240px; } +.full { + --admin-form-dialog-width: 1240px; + --admin-form-field-width: 240px; +} + .form { display: flex; max-height: calc(100vh - 64px); diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx index 0decc52..7e6d1ad 100644 --- a/src/shared/ui/UploadField.jsx +++ b/src/shared/ui/UploadField.jsx @@ -1,7 +1,9 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined"; import ImageOutlined from "@mui/icons-material/ImageOutlined"; import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined"; +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; import CircularProgress from "@mui/material/CircularProgress"; import Dialog from "@mui/material/Dialog"; @@ -10,6 +12,7 @@ import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import Tooltip from "@mui/material/Tooltip"; import { useEffect, useId, useRef, useState } from "react"; +import { downloadResponse } from "@/shared/api/download"; import { uploadFile, uploadImage } from "@/shared/api/upload"; import { Button } from "@/shared/ui/Button.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; @@ -31,6 +34,7 @@ export function UploadField({ onFileSelected, panelClassName, previewClassName, + showSourceActions = false, uploadKind, value = "", }) { @@ -39,6 +43,7 @@ export function UploadField({ const { showToast } = useToast(); const [uploading, setUploading] = useState(false); const [localPreview, setLocalPreview] = useState(null); + const [downloading, setDownloading] = useState(false); const [videoPreviewOpen, setVideoPreviewOpen] = useState(false); const isImage = kind === "image"; const uploadMode = uploadKind || kind; @@ -101,6 +106,31 @@ export function UploadField({ onChange(""); }; + const openSource = () => { + if (source) { + window.open(source, "_blank", "noopener,noreferrer"); + } + }; + + const downloadSource = async () => { + if (!source || downloading) { + return; + } + setDownloading(true); + try { + // 先拉取为同源 Blob 再触发下载,避免跨域素材 URL 被浏览器忽略 download 属性并直接打开。 + const response = await fetch(source); + if (!response.ok) { + throw new Error(`素材下载失败 (${response.status})`); + } + await downloadResponse(response, displayName || "asset"); + } catch (err) { + showToast(err.message || "素材下载失败", "error"); + } finally { + setDownloading(false); + } + }; + return (
{assetKindLabel(assetKind, isImage)}}
- + {source && showSourceActions ? ( + + + + + + + + + ) : null} - +
{displayName || "未上传"} diff --git a/src/shared/ui/UploadField.module.css b/src/shared/ui/UploadField.module.css index 280f3d8..6d390a9 100644 --- a/src/shared/ui/UploadField.module.css +++ b/src/shared/ui/UploadField.module.css @@ -38,6 +38,10 @@ -webkit-backdrop-filter: var(--glass-blur); } +.previewArea { + position: relative; +} + .preview { position: relative; display: flex; @@ -62,6 +66,53 @@ cursor: not-allowed; } +.previewActions { + position: absolute; + z-index: 1; + top: var(--space-2); + right: var(--space-2); + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +.previewAction { + display: inline-flex; + width: var(--control-height); + height: var(--control-height); + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-flyout); + box-shadow: var(--shadow-soft); + color: var(--text-secondary); + cursor: pointer; + transition: + border-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + background var(--motion-fast) var(--ease-standard), + transform var(--motion-base) var(--ease-emphasized); +} + +.previewAction:hover:not(:disabled) { + border-color: var(--primary-border); + background: var(--primary-hover); + color: var(--primary); + transform: translateY(-1px); +} + +.previewAction:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +.previewAction:disabled { + cursor: wait; + opacity: 0.6; +} + .image { width: 100%; height: 100%; @@ -217,6 +268,11 @@ height: 112px; } +.compact .previewAction { + width: 28px; + height: 28px; +} + .compact .empty svg { width: 22px; height: 22px; diff --git a/src/shared/ui/UploadField.test.jsx b/src/shared/ui/UploadField.test.jsx index 40a8e3e..e3f3fc2 100644 --- a/src/shared/ui/UploadField.test.jsx +++ b/src/shared/ui/UploadField.test.jsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { expect, test, vi } from "vitest"; +import { downloadResponse } from "@/shared/api/download"; import { uploadFile, uploadImage } from "@/shared/api/upload"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { UploadField } from "./UploadField.jsx"; @@ -9,6 +10,10 @@ vi.mock("@/shared/api/upload", () => ({ uploadImage: vi.fn(async () => ({ url: "https://media.haiyihy.com/admin/images/effect.png" })), })); +vi.mock("@/shared/api/download", () => ({ + downloadResponse: vi.fn(async () => undefined), +})); + test("file upload field does not restrict picker type and uses generic upload endpoint", async () => { const onChange = vi.fn(); const onFileSelected = vi.fn(); @@ -57,3 +62,33 @@ test("mp4 file upload field can open video preview dialog", async () => { expect(screen.queryByRole("dialog", { name: "动效素材 MP4" })).not.toBeInTheDocument(); }); }); + +test("source actions open the asset in a new tab and download it with the source filename", async () => { + const source = "https://media.haiyihy.com/admin/files/frame.webp?sign=1"; + const response = { ok: true, status: 200 }; + const fetchMock = vi.fn(async () => response); + const openMock = vi.spyOn(window, "open").mockImplementation(() => null); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "打开资源封面" })); + expect(openMock).toHaveBeenCalledWith(source, "_blank", "noopener,noreferrer"); + + fireEvent.click(screen.getByRole("button", { name: "下载资源封面" })); + await waitFor(() => expect(downloadResponse).toHaveBeenCalledWith(response, "frame.webp")); + expect(fetchMock).toHaveBeenCalledWith(source); + + openMock.mockRestore(); + vi.unstubAllGlobals(); +}); diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css index 46cc6be..25efcf1 100644 --- a/src/styles/shared-ui.css +++ b/src/styles/shared-ui.css @@ -1315,6 +1315,28 @@ font-size: 11px; } +.form-option--disabled { + cursor: not-allowed; + background: var(--bg-page); +} + +.form-option--disabled:hover { + border-color: var(--border); + background: var(--bg-page); +} + +.form-option--disabled:active { + transform: none; +} + +.form-option--disabled strong { + color: var(--text-tertiary); +} + +.form-option__holder { + color: var(--warning); +} + .form-option--single-line { min-height: 40px; }