修改相关展示
This commit is contained in:
parent
abb5ba9c52
commit
4abab5c164
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(<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({
|
||||
|
||||
@ -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" }
|
||||
};
|
||||
|
||||
// 数据明细宽表的列组:与运营的心智分组一致,支持整组折叠。
|
||||
|
||||
@ -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() {
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
}),
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
245
src/features/level-config/components/LevelConfigBatchDialog.jsx
Normal file
245
src/features/level-config/components/LevelConfigBatchDialog.jsx
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 || {});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 "";
|
||||
}
|
||||
|
||||
62
src/features/level-config/resourceFilters.js
Normal file
62
src/features/level-config/resourceFilters.js
Normal 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 {};
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
|
||||
@ -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"
|
||||
});
|
||||
}
|
||||
|
||||
40
src/features/users/components/UserAppScopeEditor.jsx
Normal file
40
src/features/users/components/UserAppScopeEditor.jsx
Normal 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();
|
||||
}
|
||||
40
src/features/users/components/UserAppScopeEditor.test.jsx
Normal file
40
src/features/users/components/UserAppScopeEditor.test.jsx
Normal 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");
|
||||
});
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 : [])
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
140
src/shared/api/generated/schema.d.ts
vendored
140
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user