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) => (
))
) : (
-
+
)}
;
+ }
+ 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)}}
-