修改相关展示

This commit is contained in:
zhx 2026-07-14 17:58:09 +08:00
parent abb5ba9c52
commit 4abab5c164
40 changed files with 1787 additions and 152 deletions

View File

@ -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"
}
}
}
}
}
}

View File

@ -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: [
// logoAppIdentity 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(<DatabiApp />);
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({

View File

@ -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" }
};
// 数据明细宽表的列组:与运营的心智分组一致,支持整组折叠。

View File

@ -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];
// keyApp× 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() {
<tr className="is-total">
{dimColumns.map((column, index) => (
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
{index === 0 ? "平均值" : column.totalText}
{index === 0 ? "平均值" : column.key === "day" ? "--" : column.totalText}
</td>
))}
{visibleMetrics.map((key) => (
@ -698,20 +717,25 @@ export function DataTableView() {
))}
</tr>
) : null}
{renderRows.map((row, rowIndex) => (
<tr key={rowIdentity(row, dim.key, rowIndex)}>
{dimColumns.map((column, index) => (
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
{column.key === "app" ? (
<SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
) : column.value(row)}
</td>
))}
{visibleMetrics.map((key) => (
<td key={key}>{formatMetric(key, row[key])}</td>
))}
</tr>
))}
{renderRows.map((row, rowIndex) => {
//
const isGroupStart =
hasDay && !activeSort && rowIndex > 0 && dayGroupKey(renderRows[rowIndex - 1]) !== dayGroupKey(row);
return (
<tr className={isGroupStart ? "sbi-dt-group-start" : undefined} key={rowIdentity(row, dim.key, rowIndex)}>
{dimColumns.map((column, index) => (
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
{column.key === "app" ? (
<SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
) : column.value(row)}
</td>
))}
{visibleMetrics.map((key) => (
<td key={key}>{formatMetric(key, row[key])}</td>
))}
</tr>
);
})}
</tbody>
{isTruncated ? (
<tfoot>

View File

@ -265,7 +265,7 @@ export function RetentionView() {
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
<span className="sbi-rt-summary-note">
{regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
{isBlank(baseUsers) ? "" : ` · cohort 样本 ${formatCount(baseUsers)}`}
{isBlank(baseUsers) ? "" : ` · 回看基数 ${formatCount(baseUsers)}`}
</span>
</section>
);
@ -275,7 +275,7 @@ export function RetentionView() {
<section className="sbi-card">
<div className="sbi-card-header">
<strong>新增与次日留存</strong>
<small>=新增左轴· 线=日留存率右轴· {granularityLabel}分桶</small>
<small>=当日新增左轴· 线=前一日注册用户的当日留存率右轴· {granularityLabel}分桶</small>
</div>
{comboOption ? (
<EChart className="sbi-rt-chart-combo" option={comboOption} />
@ -395,7 +395,8 @@ export function RetentionView() {
</section>
<p className="sbi-rt-footnote">
留存按注册日 cohort 计算观察期未到的日期显示 "--"hyapp App 的按日留存暂不提供仅整段区间口径
留存按观察日回看计算某日的 N 日留存 = 该日往前推 N 天注册的用户中当日仍活跃的比例区间合计 =
各观察日留存人数之和 / 各观察日 cohort 基数之和cohort 日无注册或数据未接入时显示 "--"
</p>
</div>
);

View File

@ -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;

View File

@ -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) => {

View File

@ -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={
<RequirePermission code={route.permission}>
<DeferredPage route={route} />
{route.path === defaultAdminPath ? (
<DeferredPage route={route} />
) : (
<RequireAppScope>
<DeferredPage route={route} />
</RequireAppScope>
)}
</RequirePermission>
}
/>

View File

@ -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
}),

View File

@ -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)),

View File

@ -155,21 +155,22 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
<div className="header-right-tools">
<TextField
className="app-switch"
disabled={appScope.loading || !appScope.apps.length}
disabled={appScope.loading || !appScope.hasAppAccess}
label="应用"
select
size="small"
value={appScope.appCode}
slotProps={{ select: { displayEmpty: true } }}
value={appScope.hasAppAccess ? appScope.appCode : ""}
onChange={(event) => appScope.setAppCode(event.target.value)}
>
{appScope.apps.length ? (
{appScope.hasAppAccess ? (
appScope.apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
<AppIdentity app={app} size="small" />
</MenuItem>
))
) : (
<MenuItem value={appScope.appCode}>{appScope.appCode}</MenuItem>
<MenuItem value="">通用</MenuItem>
)}
</TextField>
<TextField

View File

@ -1,5 +1,6 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
import { defaultAdminPath } from "./routeConfig";
@ -28,3 +29,15 @@ export function RequirePermission({ children, code }) {
return children;
}
export function RequireAppScope({ children }) {
const { hasAppAccess, loading } = useAppScope();
if (loading) {
return <PageSkeleton />;
}
if (!hasAppAccess) {
return <Navigate to={defaultAdminPath} replace />;
}
return children;
}

View File

@ -8,3 +8,10 @@ export function listAdminApps(): Promise<ApiList<AdminAppDto>> {
method: endpoint.method
});
}
export function listVisibleAdminApps(): Promise<ApiList<AdminAppDto>> {
const endpoint = API_ENDPOINTS.listVisibleApps;
return apiRequest<ApiList<AdminAppDto>>(apiEndpointPath(API_OPERATIONS.listVisibleApps), {
method: endpoint.method
});
}

View File

@ -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 (
<>
<DashboardWorkspaces />
<DashboardUserProfile />
{hasAppAccess ? <DashboardUserProfile /> : null}
</>
);
}

View File

@ -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<string, unknown>;
export function listLevelConfig(query: PageQuery = {}): Promise<LevelConfigDto> {
@ -101,19 +121,12 @@ export function updateTrack(track: LevelTrackCode, payload: LevelTrackPayload):
}).then(normalizeTrack);
}
export function upsertRule(
track: LevelTrackCode,
level: EntityId,
payload: LevelRulePayload,
): Promise<LevelRuleDto> {
export function upsertRule(track: LevelTrackCode, level: EntityId, payload: LevelRulePayload): Promise<LevelRuleDto> {
const endpoint = API_ENDPOINTS.upsertRule;
return apiRequest<unknown, LevelRulePayload>(
apiEndpointPath(API_OPERATIONS.upsertRule, { level, track }),
{
body: payload,
method: endpoint.method,
},
).then(normalizeRule);
return apiRequest<unknown, LevelRulePayload>(apiEndpointPath(API_OPERATIONS.upsertRule, { level, track }), {
body: payload,
method: endpoint.method,
}).then(normalizeRule);
}
export function createTier(payload: LevelTierPayload): Promise<LevelTierDto> {
@ -132,6 +145,21 @@ export function updateTier(tierId: EntityId, payload: LevelTierPayload): Promise
}).then(normalizeTier);
}
export function batchUpsertLevelConfig(payload: BatchLevelConfigPayload): Promise<BatchLevelConfigResult> {
const endpoint = API_ENDPOINTS.batchUpsertLevelConfig;
return apiRequest<unknown, BatchLevelConfigPayload>(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 {

View File

@ -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 <RuleBatchDialog page={page} />;
}
if (page.activeAction === "tier-batch-edit") {
return <TierBatchDialog page={page} />;
}
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 (
<AdminFormDialog
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "rule-batch-edit"}
open
size="full"
submitDisabled={!page.batchDirty || !page.abilities.canUpdate || page.loadingAction === "rule-batch-edit"}
submitLabel="保存全部修改"
title="批量编辑每级升级数值"
onClose={page.closeAction}
onSubmit={page.submitRuleBatch}
>
<div className={styles.list}>
<div className={styles.pager}>
<span>
当前 {startIndex + 1}-{Math.min(startIndex + pageSize, page.batchForms.length)}{" "}
{page.batchForms.length}
</span>
<Pagination
count={pageCount}
page={editorPage}
shape="rounded"
onChange={(_, nextPage) => setEditorPage(nextPage)}
/>
</div>
{visibleForms.map((form, visibleIndex) => {
const index = startIndex + visibleIndex;
return (
<section className={styles.row} key={`${form.track}-${form.level}`}>
<div className={styles.identity}>
<strong>Lv.{form.level}</strong>
<span>{form.name}</span>
</div>
<div className={styles.ruleFields}>
<TextField
disabled={!page.abilities.canUpdate}
inputProps={{ min: 0 }}
label="升级数值"
type="number"
value={form.requiredValue}
onChange={changeBatchField(page, index, "requiredValue")}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="展示名称"
value={form.name}
onChange={changeBatchField(page, index, "name")}
/>
<ResourceSelectField
disabled={!page.abilities.canUpdate}
drawerTitle={`选择 Lv.${form.level} 长徽章`}
label="长徽章"
required={false}
resources={withSelectedResource(
levelBadgeResourcesForTrack(page.badgeResources, form.track),
form.longBadgeResourceId,
)}
value={form.longBadgeResourceId}
onChange={(value) => page.updateBatchForm(index, "longBadgeResourceId", value)}
/>
<ResourceSelectField
disabled={!page.abilities.canUpdate}
drawerTitle={`选择 Lv.${form.level} 头像框`}
label="头像框"
required={false}
resources={withSelectedResource(
levelAvatarFrameResourcesForTrack(page.avatarFrameResources, form.track),
form.avatarFrameResourceId,
)}
value={form.avatarFrameResourceId}
onChange={(value) => page.updateBatchForm(index, "avatarFrameResourceId", value)}
/>
<ResourceSelectField
disabled={!page.abilities.canUpdate}
drawerTitle={`选择 Lv.${form.level} 短徽章`}
label="短徽章"
required={false}
resources={withSelectedResource(
levelShortBadgeResourcesForTrack(page.badgeResources, form.track),
form.shortBadgeResourceId,
)}
value={form.shortBadgeResourceId}
onChange={(value) => page.updateBatchForm(index, "shortBadgeResourceId", value)}
/>
<ResourceGroupSelectField
allowEmpty
disabled={!page.abilities.canUpdate}
drawerTitle={`选择 Lv.${form.level} 升级礼物`}
groups={page.resourceGroups}
label="升级礼物"
value={form.rewardResourceGroupId}
onChange={(value) => page.updateBatchForm(index, "rewardResourceGroupId", value)}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="排序"
type="number"
value={form.sortOrder}
onChange={changeBatchField(page, index, "sortOrder")}
/>
<AdminFormSwitchField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
uncheckedLabel="停用"
onChange={(checked) =>
page.updateBatchForm(index, "status", checked ? "active" : "disabled")
}
/>
</div>
</section>
);
})}
</div>
</AdminFormDialog>
);
}
function TierBatchDialog({ page }) {
return (
<AdminFormDialog
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "tier-batch-edit"}
open
size="full"
submitDisabled={!page.batchDirty || !page.abilities.canUpdate || page.loadingAction === "tier-batch-edit"}
submitLabel="保存全部修改"
title="批量编辑等级层展示与礼物"
onClose={page.closeAction}
onSubmit={page.submitTierBatch}
>
<div className={styles.list}>
{page.batchForms.map((form, index) => (
<section className={styles.row} key={form.tierId}>
<div className={styles.identity}>
<strong>
{form.minLevel}-{form.maxLevel}
</strong>
<span>{form.name}</span>
</div>
<div className={styles.tierFields}>
<TextField
disabled={!page.abilities.canUpdate}
label="起始等级"
type="number"
value={form.minLevel}
onChange={changeBatchField(page, index, "minLevel")}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="结束等级"
type="number"
value={form.maxLevel}
onChange={changeBatchField(page, index, "maxLevel")}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="等级层名称"
value={form.name}
onChange={changeBatchField(page, index, "name")}
/>
<ResourceSelectField
disabled={!page.abilities.canUpdate}
drawerTitle={`选择 ${form.minLevel}-${form.maxLevel} 头像框`}
label="头像框"
required={false}
resources={withSelectedResource(
levelAvatarFrameResourcesForTrack(page.avatarFrameResources, form.track),
form.displayAvatarFrameResourceId,
)}
value={form.displayAvatarFrameResourceId}
onChange={(value) => page.updateBatchForm(index, "displayAvatarFrameResourceId", value)}
/>
<ResourceSelectField
disabled={!page.abilities.canUpdate}
drawerTitle={`选择 ${form.minLevel}-${form.maxLevel} 徽章`}
label="徽章"
required={false}
resources={withSelectedResource(
levelBadgeResourcesForTrack(page.badgeResources, form.track),
form.displayBadgeResourceId,
)}
value={form.displayBadgeResourceId}
onChange={(value) => page.updateBatchForm(index, "displayBadgeResourceId", value)}
/>
<ResourceGroupSelectField
allowEmpty
disabled={!page.abilities.canUpdate}
drawerTitle={`选择 ${form.minLevel}-${form.maxLevel} 升级礼物`}
groups={page.resourceGroups}
label="升级礼物"
value={form.rewardResourceGroupId}
onChange={(value) => page.updateBatchForm(index, "rewardResourceGroupId", value)}
/>
<AdminFormSwitchField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
uncheckedLabel="停用"
onChange={(checked) =>
page.updateBatchForm(index, "status", checked ? "active" : "disabled")
}
/>
</div>
</section>
))}
</div>
</AdminFormDialog>
);
}
function changeBatchField(page, index, key) {
return (event) => page.updateBatchForm(index, key, event.target.value);
}

View File

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

View File

@ -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 || {});
}

View File

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

View File

@ -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 {

View File

@ -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() {
<RuleFormDialog page={page} />
<TierFormDialog page={page} />
<TrackFormDialog page={page} />
<LevelConfigBatchDialog page={page} />
</AdminListPage>
);
}
@ -158,7 +166,19 @@ function LevelTrackSection({ page, track }) {
</div>
<div className={styles.tableStack}>
<div className={styles.tableBlock}>
<TableBlockHeader countLabel={`${rules.length}`} title="每级升级数值" />
<TableBlockHeader
actions={
<Button
disabled={!page.abilities.canUpdate || !rules.length}
startIcon={<EditOutlined fontSize="small" />}
onClick={() => page.openRuleBatchEdit(track)}
>
批量编辑
</Button>
}
countLabel={`${rules.length}`}
title="每级升级数值"
/>
<DataTable
className={styles.compactTable}
columns={ruleColumns}
@ -168,7 +188,19 @@ function LevelTrackSection({ page, track }) {
/>
</div>
<div className={styles.tableBlock}>
<TableBlockHeader countLabel={`${tiers.length}`} title="等级层展示与礼物" />
<TableBlockHeader
actions={
<Button
disabled={!page.abilities.canUpdate || !tiers.length}
startIcon={<EditOutlined fontSize="small" />}
onClick={() => page.openTierBatchEdit(track)}
>
批量编辑
</Button>
}
countLabel={`${tiers.length}`}
title="等级层展示与礼物"
/>
<DataTable
className={styles.compactTable}
columns={tierColumns}
@ -182,11 +214,12 @@ function LevelTrackSection({ page, track }) {
);
}
function TableBlockHeader({ countLabel, title }) {
function TableBlockHeader({ actions, countLabel, title }) {
return (
<div className={styles.tableHeader}>
<div className={styles.tableTitle}>{title}</div>
<span className={styles.tableCount}>{countLabel}</span>
{actions ? <div className={styles.tableActions}>{actions}</div> : null}
</div>
);
}
@ -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 "";
}

View File

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

View File

@ -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}

View File

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

View File

@ -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<FinanceScopeCatalogDto> {
return apiRequest<FinanceScopeCatalogDto>(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<UserAppScopeDto> {
return apiRequest<UserAppScopeDto>(apiEndpointPath(API_OPERATIONS.getUserAppScopes, { id }));
}
export function replaceUserAppScopes(id: EntityId, scope: UserAppScopeDto): Promise<UserAppScopeDto> {
return apiRequest<UserAppScopeDto, UserAppScopeDto>(apiEndpointPath(API_OPERATIONS.replaceUserAppScopes, { id }), {
body: scope,
method: "PUT"
});
}

View File

@ -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 <div className="form-editor-state">当前无数据</div>;
}
return (
<section className="form-drawer__panel form-drawer__section" aria-label="App 可见范围">
<div className="form-drawer__section-heading">
<div className="form-drawer__section-title">App 可见范围</div>
<span>已选 {count} </span>
</div>
<div className="form-option-grid form-option-grid--roles">
<label className="form-option form-option--single-line">
<Checkbox checked={all} onChange={(event) => onModeChange(event.target.checked ? "all" : "none")} size="small" />
<span>全部 App</span>
</label>
{apps.map((app) => {
const appCode = normalizeAppCode(app.appCode);
return (
<label className="form-option form-option--single-line" key={appCode}>
<Checkbox checked={all || selected.has(appCode)} onChange={(event) => onAppChecked(appCode, event.target.checked)} size="small" />
<AppIdentity app={app} size="small" />
</label>
);
})}
</div>
</section>
);
}
function normalizeAppCode(value) {
return String(value || "").trim().toLowerCase();
}

View File

@ -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(
<UserAppScopeEditor
apps={apps}
appCodes={["lalu"]}
mode="selected"
onAppChecked={onAppChecked}
onModeChange={vi.fn()}
/>
);
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(
<UserAppScopeEditor apps={apps} appCodes={[]} mode="all" onAppChecked={vi.fn()} onModeChange={onModeChange} />
);
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");
});

View File

@ -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 <div className="form-editor-state">加载中</div>;
@ -44,7 +71,8 @@ export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) {
return <div className="form-editor-state">当前无数据</div>;
}
const allRegionsChecked = hasScope(checkedScopes, activeApp.appCode, 0);
const allHolders = holdersFor(holderIndex, activeApp.appCode, 0);
const appTakenByOthers = appStats.taken > 0 || allHolders.length > 0;
return (
<div className="form-editor-split form-editor-split--finance">
@ -74,7 +102,9 @@ export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) {
<div className="form-editor-toolbar">
<div>
<strong>{activeApp.appName || activeApp.appCode}</strong>
<span>已选 {countAppScopes(checkedScopes, activeApp.appCode)} </span>
<span>
已选 {countAppScopes(checkedScopes, activeApp.appCode)} · 他人已分 {appStats.taken} · 未分配 {appStats.unassigned}
</span>
</div>
<TextField
aria-label="搜索区域"
@ -92,31 +122,68 @@ export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) {
value={query}
/>
<label className="form-editor-filter">
<Checkbox checked={selectedOnly} onChange={(event) => setSelectedOnly(event.target.checked)} size="small" />
<Checkbox
checked={selectedOnly}
onChange={(event) => {
setSelectedOnly(event.target.checked);
if (event.target.checked) {
setUnassignedOnly(false);
}
}}
size="small"
/>
<span>仅看已选</span>
</label>
<label className="form-editor-filter">
<Checkbox
checked={unassignedOnly}
onChange={(event) => {
setUnassignedOnly(event.target.checked);
if (event.target.checked) {
setSelectedOnly(false);
}
}}
size="small"
/>
<span>仅看未分配</span>
</label>
</div>
<div className="form-option-grid form-option-grid--finance">
{(!selectedOnly || allRegionsChecked) && !query ? (
{(!selectedOnly || allRegionsChecked) && !unassignedOnly && !query ? (
<FinanceScopeOption
checked={allRegionsChecked}
code="ALL"
//
disabled={!allRegionsChecked && appTakenByOthers}
holderText={
allHolders.length
? `已分配:${holderNames(allHolders)}`
: appStats.taken > 0
? `他人已分 ${appStats.taken}`
: ""
}
label="全部区域"
onChange={(checked) => onChecked(activeApp.appCode, 0, checked)}
/>
) : null}
{activeRegions.map((region) => (
<FinanceScopeOption
checked={hasScope(checkedScopes, activeApp.appCode, region.regionId)}
code={region.regionCode}
key={region.regionId}
label={region.name}
onChange={(checked) => 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 (
<FinanceScopeOption
checked={checked}
code={region.regionCode}
disabled={!checked && holders.length > 0}
holderText={holders.length ? `已分配:${holderNames(holders)}` : ""}
key={region.regionId}
label={region.name}
onChange={(nextChecked) => onChecked(activeApp.appCode, region.regionId, nextChecked)}
/>
);
})}
</div>
{!activeRegions.length && (query || selectedOnly) && !(selectedOnly && allRegionsChecked) ? (
{!activeRegions.length && (query || selectedOnly || unassignedOnly) && !(selectedOnly && allRegionsChecked) ? (
<div className="form-editor-state">当前无匹配区域</div>
) : null}
</section>
@ -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 (
<label className="form-option">
<Checkbox checked={checked} onChange={(event) => onChange(event.target.checked)} size="small" />
<label className={`form-option${disabled ? " form-option--disabled" : ""}`}>
<Checkbox checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} size="small" />
<span>
<strong>{label}</strong>
{code ? <small>{code}</small> : null}
{holderText ? <small className="form-option__holder">{holderText}</small> : null}
</span>
</label>
);
}
function scopeKey(appCode, regionId) {
return `${String(appCode || "").trim().toLowerCase()}:${Number(regionId || 0)}`;
}
function buildHolderIndex(assignments, excludeUserId) {
const index = new Map();
for (const item of Array.isArray(assignments) ? assignments : []) {
if (excludeUserId != null && Number(item.userId) === Number(excludeUserId)) {
continue;
}
const key = scopeKey(item.appCode, item.regionId);
const holders = index.get(key) || [];
if (!holders.some((holder) => Number(holder.userId) === Number(item.userId))) {
holders.push({ name: String(item.userName || item.userAccount || `用户${item.userId}`), userId: item.userId });
}
index.set(key, holders);
}
return index;
}
function holdersFor(index, appCode, regionId) {
const exact = index.get(scopeKey(appCode, regionId)) || [];
if (Number(regionId || 0) === 0) {
return exact;
}
// "" App
const holdAll = index.get(scopeKey(appCode, 0)) || [];
if (!holdAll.length) {
return exact;
}
const merged = [...exact];
for (const holder of holdAll) {
if (!merged.some((existing) => Number(existing.userId) === Number(holder.userId))) {
merged.push(holder);
}
}
return merged;
}
function holderNames(holders) {
const names = holders.map((holder) => holder.name);
if (names.length > 2) {
return `${names.slice(0, 2).join("、")}${names.length}`;
}
return names.join("、");
}
function hasScope(scopes, appCode, regionId) {
return (scopes || []).some(
(scope) => scope.appCode === appCode && Number(scope.regionId) === Number(regionId),

View File

@ -0,0 +1,81 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { UserFinanceScopeEditor } from "./UserFinanceScopeEditor.jsx";
const catalog = {
apps: [{ appCode: "aslan", appName: "Aslan" }],
regions: [
{ appCode: "aslan", name: "阿语区", regionCode: "AR", regionId: 1 },
{ appCode: "aslan", name: "菲律宾", regionCode: "PH", regionId: 2 },
{ appCode: "aslan", name: "土耳其", regionCode: "TR", regionId: 3 }
]
};
test("greys out regions held by other users and keeps own/unassigned regions editable", async () => {
const user = userEvent.setup();
const onChecked = vi.fn();
render(
<UserFinanceScopeEditor
assignments={[
{ appCode: "aslan", regionId: 1, userId: 9, userName: "王宏力" },
{ appCode: "aslan", regionId: 2, userId: 7, userName: "李浩铭" }
]}
catalog={catalog}
checkedScopes={[{ appCode: "aslan", regionId: 2 }]}
excludeUserId={7}
onChecked={onChecked}
/>
);
expect(screen.getByText("已选 1 项 · 他人已分 1 · 未分配 1")).toBeInTheDocument();
expect(screen.getByText("已分配:王宏力")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /阿语区/ })).toBeDisabled();
const ownRegion = screen.getByRole("checkbox", { name: /菲律宾/ });
expect(ownRegion).toBeChecked();
expect(ownRegion).not.toBeDisabled();
await user.click(ownRegion);
expect(onChecked).toHaveBeenCalledWith("aslan", 2, false);
await user.click(screen.getByRole("checkbox", { name: /土耳其/ }));
expect(onChecked).toHaveBeenCalledWith("aslan", 3, true);
});
test("another user holding 全部区域 blocks every region of that app", () => {
render(
<UserFinanceScopeEditor
assignments={[{ appCode: "aslan", regionId: 0, userId: 9, userName: "王宏力" }]}
catalog={catalog}
checkedScopes={[]}
excludeUserId={7}
onChecked={vi.fn()}
/>
);
expect(screen.getByText("已选 0 项 · 他人已分 3 · 未分配 0")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /全部区域/ })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /阿语区/ })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /菲律宾/ })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /土耳其/ })).toBeDisabled();
});
test("仅看未分配 filter keeps only regions nobody holds", async () => {
const user = userEvent.setup();
render(
<UserFinanceScopeEditor
assignments={[{ appCode: "aslan", regionId: 1, userId: 9, userName: "王宏力" }]}
catalog={catalog}
checkedScopes={[{ appCode: "aslan", regionId: 2 }]}
excludeUserId={7}
onChecked={vi.fn()}
/>
);
await user.click(screen.getByRole("checkbox", { name: "仅看未分配" }));
expect(screen.queryByRole("checkbox", { name: /全部区域/ })).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /阿语区/ })).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /菲律宾/ })).not.toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /土耳其/ })).toBeInTheDocument();
});

View File

@ -5,13 +5,18 @@ import { useEffect, useState } from "react";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { FormDrawerShell } from "@/shared/ui/FormDrawerShell.jsx";
import { UserFinanceScopeEditor } from "@/features/users/components/UserFinanceScopeEditor.jsx";
import { UserAppScopeEditor } from "@/features/users/components/UserAppScopeEditor.jsx";
export function UserFormDrawer({
editingUser,
appScopeCatalog = [],
financeScopeAssignments = [],
financeScopeCatalog,
form,
onClose,
onFinanceScopeChecked,
onAppScopeChecked,
onAppScopeModeChange,
onRoleChecked,
onSubmit,
open,
@ -31,7 +36,8 @@ export function UserFormDrawer({
const sections = [
{ key: "profile", label: "账号资料" },
{ count: form.financeScopes?.length || 0, key: "finance", label: "财务范围" },
{ count: form.roleIds?.length || 0, key: "access", label: "角色与安全" }
{ count: form.roleIds?.length || 0, key: "access", label: "角色与安全" },
{ count: form.appScopeMode === "all" ? appScopeCatalog.length : form.appCodes?.length || 0, key: "apps", label: "App范围" }
];
return (
@ -84,8 +90,10 @@ export function UserFormDrawer({
{activeSection === "finance" ? (
<UserFinanceScopeEditor
assignments={financeScopeAssignments}
catalog={financeScopeCatalog}
checkedScopes={form.financeScopes || []}
excludeUserId={editingUser?.id}
onChecked={onFinanceScopeChecked}
/>
) : null}
@ -128,6 +136,16 @@ export function UserFormDrawer({
</section>
</div>
) : null}
{activeSection === "apps" ? (
<UserAppScopeEditor
apps={appScopeCatalog}
appCodes={form.appCodes || []}
mode={form.appScopeMode}
onAppChecked={onAppScopeChecked}
onModeChange={onAppScopeModeChange}
/>
) : null}
</FormDrawerShell>
);
}

View File

@ -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 : [])

View File

@ -98,10 +98,14 @@ export function UserManagementPage() {
</AdminListPage>
<UserFormDrawer
appScopeCatalog={page.appScopeCatalog}
editingUser={page.editingUser}
financeScopeAssignments={page.financeScopeAssignments}
financeScopeCatalog={page.financeScopeCatalog}
form={page.form}
onClose={page.closeForm}
onAppScopeChecked={page.setAppScopeChecked}
onAppScopeModeChange={page.setAppScopeMode}
onFinanceScopeChecked={page.setFinanceScopeChecked}
onRoleChecked={page.setRoleChecked}
onSubmit={page.submitUser}

View File

@ -33,6 +33,7 @@ export const API_OPERATIONS = {
batchCreateGifts: "batchCreateGifts",
batchDeleteResources: "batchDeleteResources",
batchUpdateUserStatus: "batchUpdateUserStatus",
batchUpsertLevelConfig: "batchUpsertLevelConfig",
cancelJob: "cancelJob",
cancelRoomPin: "cancelRoomPin",
changePassword: "changePassword",
@ -161,6 +162,7 @@ export const API_OPERATIONS = {
getSocialBiRequirements: "getSocialBiRequirements",
getTemporaryPaymentLink: "getTemporaryPaymentLink",
getUser: "getUser",
getUserAppScopes: "getUserAppScopes",
getVipConfig: "getVipConfig",
getVipProgram: "getVipProgram",
getWeeklyStarCycle: "getWeeklyStarCycle",
@ -197,6 +199,7 @@ export const API_OPERATIONS = {
listEmojiPacks: "listEmojiPacks",
listExploreTabs: "listExploreTabs",
listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders",
listFinanceScopeAssignments: "listFinanceScopeAssignments",
listFinanceWithdrawalApplications: "listFinanceWithdrawalApplications",
listFirstRechargeRewardClaims: "listFirstRechargeRewardClaims",
listGameWhitelistUsers: "listGameWhitelistUsers",
@ -257,6 +260,7 @@ export const API_OPERATIONS = {
listUserFinanceScopes: "listUserFinanceScopes",
listUserLeaderboards: "listUserLeaderboards",
listUsers: "listUsers",
listVisibleApps: "listVisibleApps",
listWeeklyStarCycles: "listWeeklyStarCycles",
listWeeklyStarLeaderboard: "listWeeklyStarLeaderboard",
listWeeklyStarSettlements: "listWeeklyStarSettlements",
@ -282,6 +286,7 @@ export const API_OPERATIONS = {
replaceRegionCountries: "replaceRegionCountries",
replaceRoleDataScopes: "replaceRoleDataScopes",
replaceRolePermissions: "replaceRolePermissions",
replaceUserAppScopes: "replaceUserAppScopes",
replaceUserFinanceScopes: "replaceUserFinanceScopes",
resetUserPassword: "resetUserPassword",
retryRedPacketRefund: "retryRedPacketRefund",
@ -519,6 +524,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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,

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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 (
<div
className={[
@ -117,20 +147,53 @@ export function UploadField({
{hideType ? null : <span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>}
</div>
<div className={[styles.panel, panelClassName || ""].filter(Boolean).join(" ")}>
<button
aria-label={label}
className={[styles.preview, previewClassName || ""].filter(Boolean).join(" ")}
disabled={disabled || uploading}
type="button"
onClick={openPicker}
>
<AssetPreview assetKind={assetKind} isImage={isImage} src={source} />
{uploading ? (
<span className={styles.overlay}>
<CircularProgress color="inherit" size={18} />
<div className={styles.previewArea}>
<button
aria-label={label}
className={[styles.preview, previewClassName || ""].filter(Boolean).join(" ")}
disabled={disabled || uploading}
type="button"
onClick={openPicker}
>
<AssetPreview assetKind={assetKind} isImage={isImage} src={source} />
{uploading ? (
<span className={styles.overlay}>
<CircularProgress color="inherit" size={18} />
</span>
) : null}
</button>
{source && showSourceActions ? (
<span className={styles.previewActions}>
<Tooltip arrow title="打开">
<button
aria-label={`打开${label}`}
className={styles.previewAction}
type="button"
onClick={openSource}
>
<OpenInNewOutlined fontSize="small" />
</button>
</Tooltip>
<Tooltip arrow title="下载">
<span>
<button
aria-label={`下载${label}`}
className={styles.previewAction}
disabled={downloading}
type="button"
onClick={downloadSource}
>
{downloading ? (
<CircularProgress color="inherit" size={16} />
) : (
<DownloadOutlined fontSize="small" />
)}
</button>
</span>
</Tooltip>
</span>
) : null}
</button>
</div>
<div className={styles.footer}>
<span className={styles.name}>{displayName || "未上传"}</span>
<span className={styles.actions}>

View File

@ -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;

View File

@ -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(
<ToastProvider>
<UploadField
kind="file"
label="资源封面"
showSourceActions
value={source}
onChange={vi.fn()}
/>
</ToastProvider>,
);
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();
});

View File

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