修改相关展示
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": {
|
"/admin/bd-leaders": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listBDLeaders",
|
"operationId": "listBDLeaders",
|
||||||
@ -5573,6 +5583,18 @@
|
|||||||
"x-permissions": ["level-config:view"]
|
"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}": {
|
"/admin/users/level-config/rules/{track}/{level}": {
|
||||||
"put": {
|
"put": {
|
||||||
"operationId": "upsertRule",
|
"operationId": "upsertRule",
|
||||||
@ -6676,6 +6698,18 @@
|
|||||||
"x-permissions": ["user:update"]
|
"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": {
|
"/users/{id}/finance-scopes": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listUserFinanceScopes",
|
"operationId": "listUserFinanceScopes",
|
||||||
@ -6711,6 +6745,41 @@
|
|||||||
"x-permissions": ["user:update"]
|
"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": {
|
"/users/{id}/reset-password": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "resetUserPassword",
|
"operationId": "resetUserPassword",
|
||||||
@ -7338,6 +7407,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"AppScopeRequest": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UserAppScope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"FinanceCoinSellerRechargeOrderRequest": {
|
"FinanceCoinSellerRechargeOrderRequest": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@ -10335,6 +10413,10 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"appScopeMode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["all", "selected", "none"]
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@ -12452,6 +12534,22 @@
|
|||||||
"type": "integer"
|
"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();
|
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 () => {
|
test("renders social BI funnel view and limits funnel apps to supported apps", async () => {
|
||||||
window.history.pushState(null, "", "/databi/social/?view=funnel");
|
window.history.pushState(null, "", "/databi/social/?view=funnel");
|
||||||
fetchSocialBiMaster.mockResolvedValue({
|
fetchSocialBiMaster.mockResolvedValue({
|
||||||
|
|||||||
@ -45,9 +45,9 @@ export const METRIC_DEFS = {
|
|||||||
arppu_usd_minor: { label: "ARPPU ($)", tooltip: "Finance 币商/H5 充值没有同口径付费人数,当前不展示", type: "money" },
|
arppu_usd_minor: { label: "ARPPU ($)", tooltip: "Finance 币商/H5 充值没有同口径付费人数,当前不展示", type: "money" },
|
||||||
paid_conversion_rate: { label: "付费转化率", tooltip: "付费用户 / 日活", type: "ratio" },
|
paid_conversion_rate: { label: "付费转化率", tooltip: "付费用户 / 日活", type: "ratio" },
|
||||||
recharge_conversion_rate: { label: "充值转化率", tooltip: "充值用户 / 日活", type: "ratio" },
|
recharge_conversion_rate: { label: "充值转化率", tooltip: "充值用户 / 日活", type: "ratio" },
|
||||||
d1_retention_rate: { label: "次日留存", tooltip: "注册次日仍活跃的比例", type: "ratio" },
|
d1_retention_rate: { label: "次日留存", tooltip: "当日活跃的前 1 日注册用户 / 前 1 日注册数(观察日回看口径)", type: "ratio" },
|
||||||
d7_retention_rate: { label: "7日留存", tooltip: "注册第 7 日仍活跃的比例", type: "ratio" },
|
d7_retention_rate: { label: "7日留存", tooltip: "当日活跃的前 7 日注册用户 / 前 7 日注册数(观察日回看口径)", type: "ratio" },
|
||||||
d30_retention_rate: { label: "30日留存", tooltip: "注册第 30 日仍活跃的比例", type: "ratio" }
|
d30_retention_rate: { label: "30日留存", tooltip: "当日活跃的前 30 日注册用户 / 前 30 日注册数(观察日回看口径)", type: "ratio" }
|
||||||
};
|
};
|
||||||
|
|
||||||
// 数据明细宽表的列组:与运营的心智分组一致,支持整组折叠。
|
// 数据明细宽表的列组:与运营的心智分组一致,支持整组折叠。
|
||||||
|
|||||||
@ -32,10 +32,11 @@ const DIM_COLUMNS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 五种明细维度:key 与 derived 的行集字段一一对应。
|
// 五种明细维度:key 与 derived 的行集字段一一对应。
|
||||||
|
// 日维度把 App 放在首列(冻结列):日行按 App 分块连续展示,App 列就是分块标识。
|
||||||
const DIMENSIONS = [
|
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: "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: "region-total", dims: ["app", "region"], hasDay: false, key: "regionTotals", label: "区域合计" },
|
||||||
{ csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" }
|
{ csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" }
|
||||||
];
|
];
|
||||||
@ -514,10 +515,28 @@ export function DataTableView() {
|
|||||||
// 排序/合计每次渲染重算(行集千行量级可接受);react-hooks 的 preserve-manual-memoization
|
// 排序/合计每次渲染重算(行集千行量级可接受);react-hooks 的 preserve-manual-memoization
|
||||||
// 规则不允许在这里包 useMemo(依赖来自模块常量推导,编译器无法证明不可变)。
|
// 规则不允许在这里包 useMemo(依赖来自模块常量推导,编译器无法证明不可变)。
|
||||||
const sortedRows = [...baseRows];
|
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) {
|
if (activeSort) {
|
||||||
sortedRows.sort((a, b) => compareMetric(a, b, activeSort.key, activeSort.dir));
|
sortedRows.sort((a, b) => compareMetric(a, b, activeSort.key, activeSort.dir));
|
||||||
} else if (hasDay) {
|
} else if (hasDay) {
|
||||||
|
// 默认展示不按天全局倒序(那样多 App 会一天一行互相穿插):先按分块聚拢,块内再按日期倒序。
|
||||||
sortedRows.sort((a, b) => {
|
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) {
|
if (a.stat_day !== b.stat_day) {
|
||||||
return String(a.stat_day) < String(b.stat_day) ? 1 : -1;
|
return String(a.stat_day) < String(b.stat_day) ? 1 : -1;
|
||||||
}
|
}
|
||||||
@ -690,7 +709,7 @@ export function DataTableView() {
|
|||||||
<tr className="is-total">
|
<tr className="is-total">
|
||||||
{dimColumns.map((column, index) => (
|
{dimColumns.map((column, index) => (
|
||||||
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
|
<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>
|
</td>
|
||||||
))}
|
))}
|
||||||
{visibleMetrics.map((key) => (
|
{visibleMetrics.map((key) => (
|
||||||
@ -698,8 +717,12 @@ export function DataTableView() {
|
|||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{renderRows.map((row, rowIndex) => (
|
{renderRows.map((row, rowIndex) => {
|
||||||
<tr key={rowIdentity(row, dim.key, 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) => (
|
{dimColumns.map((column, index) => (
|
||||||
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
|
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
|
||||||
{column.key === "app" ? (
|
{column.key === "app" ? (
|
||||||
@ -711,7 +734,8 @@ export function DataTableView() {
|
|||||||
<td key={key}>{formatMetric(key, row[key])}</td>
|
<td key={key}>{formatMetric(key, row[key])}</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
{isTruncated ? (
|
{isTruncated ? (
|
||||||
<tfoot>
|
<tfoot>
|
||||||
|
|||||||
@ -265,7 +265,7 @@ export function RetentionView() {
|
|||||||
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
|
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
|
||||||
<span className="sbi-rt-summary-note">
|
<span className="sbi-rt-summary-note">
|
||||||
{regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
|
{regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
|
||||||
{isBlank(baseUsers) ? "" : ` · cohort 样本 ${formatCount(baseUsers)} 人`}
|
{isBlank(baseUsers) ? "" : ` · 回看基数 ${formatCount(baseUsers)} 人`}
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@ -275,7 +275,7 @@ export function RetentionView() {
|
|||||||
<section className="sbi-card">
|
<section className="sbi-card">
|
||||||
<div className="sbi-card-header">
|
<div className="sbi-card-header">
|
||||||
<strong>新增与次日留存</strong>
|
<strong>新增与次日留存</strong>
|
||||||
<small>柱=新增(左轴)· 线=次日留存率(右轴)· 按{granularityLabel}分桶</small>
|
<small>柱=当日新增(左轴)· 线=前一日注册用户的当日留存率(右轴)· 按{granularityLabel}分桶</small>
|
||||||
</div>
|
</div>
|
||||||
{comboOption ? (
|
{comboOption ? (
|
||||||
<EChart className="sbi-rt-chart-combo" option={comboOption} />
|
<EChart className="sbi-rt-chart-combo" option={comboOption} />
|
||||||
@ -395,7 +395,8 @@ export function RetentionView() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p className="sbi-rt-footnote">
|
<p className="sbi-rt-footnote">
|
||||||
留存按注册日 cohort 计算;观察期未到的日期显示 "--"。hyapp App 的按日留存暂不提供(仅整段区间口径)。
|
留存按观察日回看计算:某日的 N 日留存 = 该日往前推 N 天注册的用户中当日仍活跃的比例;区间合计 =
|
||||||
|
各观察日留存人数之和 / 各观察日 cohort 基数之和。cohort 日无注册或数据未接入时显示 "--"。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -117,6 +117,11 @@
|
|||||||
z-index: 4;
|
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 {
|
.sbi-dt-note td {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
|||||||
@ -30,6 +30,33 @@ test("redirects protected route to login when unauthenticated", async () => {
|
|||||||
expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
|
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 () => {
|
test("renders resource list route with an authenticated session", async () => {
|
||||||
setAccessToken("test-token");
|
setAccessToken("test-token");
|
||||||
vi.mocked(fetch).mockImplementation(async (input) => {
|
vi.mocked(fetch).mockImplementation(async (input) => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { AdminLayout } from "@/app/layout/AdminLayout.jsx";
|
import { AdminLayout } from "@/app/layout/AdminLayout.jsx";
|
||||||
import { DeferredPage } from "@/app/router/DeferredPage.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 { adminRoutes, defaultAdminPath, publicRoutes } from "@/app/router/routeConfig";
|
||||||
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
|
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
|
||||||
|
|
||||||
@ -21,7 +21,13 @@ function App() {
|
|||||||
path={route.path}
|
path={route.path}
|
||||||
element={
|
element={
|
||||||
<RequirePermission code={route.permission}>
|
<RequirePermission code={route.permission}>
|
||||||
|
{route.path === defaultAdminPath ? (
|
||||||
<DeferredPage route={route} />
|
<DeferredPage route={route} />
|
||||||
|
) : (
|
||||||
|
<RequireAppScope>
|
||||||
|
<DeferredPage route={route} />
|
||||||
|
</RequireAppScope>
|
||||||
|
)}
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,30 +1,30 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
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 { getSelectedAppCode, setSelectedAppCode as setRequestAppCode } from "@/shared/api/request";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
|
||||||
const AppScopeContext = createContext(null);
|
const AppScopeContext = createContext(null);
|
||||||
const fallbackAppCode = "lalu";
|
|
||||||
const emptyAppState = { items: [], total: 0 };
|
const emptyAppState = { items: [], total: 0 };
|
||||||
|
|
||||||
export function AppScopeProvider({ children }) {
|
export function AppScopeProvider({ children }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [appCode, setAppCodeState] = useState(() => getSelectedAppCode() || fallbackAppCode);
|
const [appCode, setAppCodeState] = useState(() => getSelectedAppCode());
|
||||||
const { data = emptyAppState, error, loading } = useAdminQuery(() => listAdminApps(), {
|
const { data = emptyAppState, error, loading } = useAdminQuery(() => listVisibleAdminApps(), {
|
||||||
enabled: Boolean(user),
|
enabled: Boolean(user),
|
||||||
errorMessage: "加载 App 列表失败",
|
errorMessage: "加载 App 列表失败",
|
||||||
initialData: emptyAppState,
|
initialData: emptyAppState,
|
||||||
queryKey: ["admin-apps"],
|
// App 范围属于用户授权,缓存键必须包含用户 ID,避免同一浏览器切换账号后复用上一账号的 App 列表。
|
||||||
|
queryKey: ["admin-apps-visible", user?.id || "anonymous"],
|
||||||
staleTime: 5 * 60 * 1000
|
staleTime: 5 * 60 * 1000
|
||||||
});
|
});
|
||||||
const apps = useMemo(() => data?.items || [], [data?.items]);
|
const apps = useMemo(() => data?.items || [], [data?.items]);
|
||||||
|
|
||||||
const applyAppCode = useCallback(
|
const applyAppCode = useCallback(
|
||||||
async (nextAppCode, options = {}) => {
|
async (nextAppCode, options = {}) => {
|
||||||
const normalized = normalizeAppCode(nextAppCode) || fallbackAppCode;
|
const normalized = normalizeAppCode(nextAppCode);
|
||||||
setRequestAppCode(normalized);
|
setRequestAppCode(normalized);
|
||||||
setAppCodeState(normalized);
|
setAppCodeState(normalized);
|
||||||
if (!options.skipRefresh) {
|
if (!options.skipRefresh) {
|
||||||
@ -35,20 +35,28 @@ export function AppScopeProvider({ children }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!apps.length) {
|
if (!apps.length) {
|
||||||
|
// 零授权是合法的通用模式;必须清掉历史 localStorage,否则请求层仍会偷偷发送上一次的 X-App-Code。
|
||||||
|
if (appCode) {
|
||||||
|
void applyAppCode("", { skipRefresh: true });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectedExists = apps.some((item) => normalizeAppCode(item.appCode) === normalizeAppCode(appCode));
|
const selectedExists = apps.some((item) => normalizeAppCode(item.appCode) === normalizeAppCode(appCode));
|
||||||
if (!selectedExists) {
|
if (!selectedExists) {
|
||||||
void applyAppCode(apps[0].appCode, { skipRefresh: false });
|
void applyAppCode(apps[0].appCode, { skipRefresh: false });
|
||||||
}
|
}
|
||||||
}, [appCode, applyAppCode, apps]);
|
}, [appCode, applyAppCode, apps, loading]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
appCode,
|
appCode,
|
||||||
apps,
|
apps,
|
||||||
error,
|
error,
|
||||||
|
hasAppAccess: apps.length > 0,
|
||||||
loading,
|
loading,
|
||||||
setAppCode: applyAppCode
|
setAppCode: applyAppCode
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { matchPath, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { matchPath, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||||
import { getMenus } from "@/features/menus/api";
|
import { getMenus } from "@/features/menus/api";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
import { useRefreshSignal } from "@/shared/hooks/useRefreshSignal.js";
|
import { useRefreshSignal } from "@/shared/hooks/useRefreshSignal.js";
|
||||||
@ -21,6 +22,7 @@ export function AdminLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { can, user } = useAuth();
|
const { can, user } = useAuth();
|
||||||
|
const { hasAppAccess, loading: appScopeLoading } = useAppScope();
|
||||||
const { refresh } = useRefreshSignal();
|
const { refresh } = useRefreshSignal();
|
||||||
const { data: backendMenus } = useAdminQuery(() => getMenus(), {
|
const { data: backendMenus } = useAdminQuery(() => getMenus(), {
|
||||||
errorMessage: "加载菜单失败",
|
errorMessage: "加载菜单失败",
|
||||||
@ -31,10 +33,12 @@ export function AdminLayout() {
|
|||||||
const menus = useMemo(() => {
|
const menus = useMemo(() => {
|
||||||
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, can);
|
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, can);
|
||||||
if (!backendMenus?.length) {
|
if (!backendMenus?.length) {
|
||||||
return fallbackMenus;
|
return appScopeLoading || hasAppAccess ? fallbackMenus : fallbackMenus.filter((item) => item.code === "overview");
|
||||||
}
|
}
|
||||||
return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus, fallbackNavigation);
|
const merged = mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus, fallbackNavigation);
|
||||||
}, [backendMenus, can]);
|
// 通用模式只保留工作台入口;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 activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
|
||||||
const activeRoute = useMemo(
|
const activeRoute = useMemo(
|
||||||
() => adminRoutes.find((route) => matchPath({ end: true, path: route.path }, location.pathname)),
|
() => 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">
|
<div className="header-right-tools">
|
||||||
<TextField
|
<TextField
|
||||||
className="app-switch"
|
className="app-switch"
|
||||||
disabled={appScope.loading || !appScope.apps.length}
|
disabled={appScope.loading || !appScope.hasAppAccess}
|
||||||
label="应用"
|
label="应用"
|
||||||
select
|
select
|
||||||
size="small"
|
size="small"
|
||||||
value={appScope.appCode}
|
slotProps={{ select: { displayEmpty: true } }}
|
||||||
|
value={appScope.hasAppAccess ? appScope.appCode : ""}
|
||||||
onChange={(event) => appScope.setAppCode(event.target.value)}
|
onChange={(event) => appScope.setAppCode(event.target.value)}
|
||||||
>
|
>
|
||||||
{appScope.apps.length ? (
|
{appScope.hasAppAccess ? (
|
||||||
appScope.apps.map((app) => (
|
appScope.apps.map((app) => (
|
||||||
<MenuItem key={app.appCode} value={app.appCode}>
|
<MenuItem key={app.appCode} value={app.appCode}>
|
||||||
<AppIdentity app={app} size="small" />
|
<AppIdentity app={app} size="small" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<MenuItem value={appScope.appCode}>{appScope.appCode}</MenuItem>
|
<MenuItem value="">通用</MenuItem>
|
||||||
)}
|
)}
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||||
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
|
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
|
||||||
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
||||||
import { defaultAdminPath } from "./routeConfig";
|
import { defaultAdminPath } from "./routeConfig";
|
||||||
@ -28,3 +29,15 @@ export function RequirePermission({ children, code }) {
|
|||||||
|
|
||||||
return children;
|
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
|
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 { DashboardWorkspaces } from "@/features/dashboard/components/DashboardWorkspaces.jsx";
|
||||||
import { DashboardUserProfile } from "@/features/dashboard/components/DashboardUserProfile.jsx";
|
import { DashboardUserProfile } from "@/features/dashboard/components/DashboardUserProfile.jsx";
|
||||||
|
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||||
|
|
||||||
export function OverviewPage() {
|
export function OverviewPage() {
|
||||||
|
const { hasAppAccess } = useAppScope();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardWorkspaces />
|
<DashboardWorkspaces />
|
||||||
<DashboardUserProfile />
|
{hasAppAccess ? <DashboardUserProfile /> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,6 +83,26 @@ export interface LevelTierPayload {
|
|||||||
track: LevelTrackCode;
|
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>;
|
type AnyRecord = Record<string, unknown>;
|
||||||
|
|
||||||
export function listLevelConfig(query: PageQuery = {}): Promise<LevelConfigDto> {
|
export function listLevelConfig(query: PageQuery = {}): Promise<LevelConfigDto> {
|
||||||
@ -101,19 +121,12 @@ export function updateTrack(track: LevelTrackCode, payload: LevelTrackPayload):
|
|||||||
}).then(normalizeTrack);
|
}).then(normalizeTrack);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertRule(
|
export function upsertRule(track: LevelTrackCode, level: EntityId, payload: LevelRulePayload): Promise<LevelRuleDto> {
|
||||||
track: LevelTrackCode,
|
|
||||||
level: EntityId,
|
|
||||||
payload: LevelRulePayload,
|
|
||||||
): Promise<LevelRuleDto> {
|
|
||||||
const endpoint = API_ENDPOINTS.upsertRule;
|
const endpoint = API_ENDPOINTS.upsertRule;
|
||||||
return apiRequest<unknown, LevelRulePayload>(
|
return apiRequest<unknown, LevelRulePayload>(apiEndpointPath(API_OPERATIONS.upsertRule, { level, track }), {
|
||||||
apiEndpointPath(API_OPERATIONS.upsertRule, { level, track }),
|
|
||||||
{
|
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
},
|
}).then(normalizeRule);
|
||||||
).then(normalizeRule);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTier(payload: LevelTierPayload): Promise<LevelTierDto> {
|
export function createTier(payload: LevelTierPayload): Promise<LevelTierDto> {
|
||||||
@ -132,6 +145,21 @@ export function updateTier(tierId: EntityId, payload: LevelTierPayload): Promise
|
|||||||
}).then(normalizeTier);
|
}).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 {
|
function normalizeLevelConfig(raw: unknown): LevelConfigDto {
|
||||||
const source = asRecord(raw);
|
const source = asRecord(raw);
|
||||||
return {
|
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 { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { listResourceGroups, listResources } from "@/features/resources/api";
|
import { listResourceGroups, listResources } from "@/features/resources/api";
|
||||||
import { levelTrackLabels } from "@/features/level-config/constants.js";
|
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 {
|
import {
|
||||||
levelRuleFormSchema,
|
batchUpsertLevelConfig,
|
||||||
levelTierFormSchema,
|
createTier,
|
||||||
levelTrackFormSchema,
|
listLevelConfig,
|
||||||
} from "@/features/level-config/schema";
|
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 emptyConfig = { serverTimeMs: 0, tracks: [] };
|
||||||
const defaultTrack = "wealth";
|
const defaultTrack = "wealth";
|
||||||
@ -35,6 +38,8 @@ export function useLevelConfigPage() {
|
|||||||
const [ruleForm, setRuleForm] = useState(() => buildRuleForm());
|
const [ruleForm, setRuleForm] = useState(() => buildRuleForm());
|
||||||
const [tierForm, setTierForm] = useState(() => buildTierForm());
|
const [tierForm, setTierForm] = useState(() => buildTierForm());
|
||||||
const [trackForm, setTrackForm] = useState(() => buildTrackForm());
|
const [trackForm, setTrackForm] = useState(() => buildTrackForm());
|
||||||
|
const [batchForms, setBatchForms] = useState([]);
|
||||||
|
const [batchOriginal, setBatchOriginal] = useState([]);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
@ -66,10 +71,14 @@ export function useLevelConfigPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
const tracks = useMemo(
|
// 保存后只刷新配置数据,不切回整页骨架;滚动容器保持挂载才能保留用户当前位置。
|
||||||
() => (config.tracks || []).filter((item) => item.track === track),
|
const refreshConfig = useCallback(async () => {
|
||||||
[config.tracks, track],
|
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 changeTrack = (value) => setTrack(value);
|
||||||
|
|
||||||
const openRuleCreate = (trackCode = resolveDefaultTrack(config, track)) => {
|
const openRuleCreate = (trackCode = resolveDefaultTrack(config, track)) => {
|
||||||
@ -102,11 +111,27 @@ export function useLevelConfigPage() {
|
|||||||
setActiveAction("track-edit");
|
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 = () => {
|
const closeAction = () => {
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
setSelectedRule(null);
|
setSelectedRule(null);
|
||||||
setSelectedTier(null);
|
setSelectedTier(null);
|
||||||
setSelectedTrack(null);
|
setSelectedTrack(null);
|
||||||
|
setBatchForms([]);
|
||||||
|
setBatchOriginal([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitRule = async (event) => {
|
const submitRule = async (event) => {
|
||||||
@ -114,11 +139,15 @@ export function useLevelConfigPage() {
|
|||||||
const editing = activeAction === "rule-edit";
|
const editing = activeAction === "rule-edit";
|
||||||
const parsed = parseForm(levelRuleFormSchema, ruleForm);
|
const parsed = parseForm(levelRuleFormSchema, ruleForm);
|
||||||
const level = Number(parsed.level);
|
const level = Number(parsed.level);
|
||||||
await runAction(editing ? "rule-edit" : "rule-create", editing ? "等级规则已更新" : "等级规则已创建", async () => {
|
await runAction(
|
||||||
|
editing ? "rule-edit" : "rule-create",
|
||||||
|
editing ? "等级规则已更新" : "等级规则已创建",
|
||||||
|
async () => {
|
||||||
await upsertRule(parsed.track, level, buildRulePayload(parsed));
|
await upsertRule(parsed.track, level, buildRulePayload(parsed));
|
||||||
closeAction();
|
closeAction();
|
||||||
await reload();
|
await refreshConfig();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitTier = async (event) => {
|
const submitTier = async (event) => {
|
||||||
@ -136,7 +165,7 @@ export function useLevelConfigPage() {
|
|||||||
await createTier(payload);
|
await createTier(payload);
|
||||||
}
|
}
|
||||||
closeAction();
|
closeAction();
|
||||||
await reload();
|
await refreshConfig();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -149,22 +178,30 @@ export function useLevelConfigPage() {
|
|||||||
await runAction("track-edit", "等级类型已更新", async () => {
|
await runAction("track-edit", "等级类型已更新", async () => {
|
||||||
await updateTrack(selectedTrack.track, buildTrackPayload(parsed));
|
await updateTrack(selectedTrack.track, buildTrackPayload(parsed));
|
||||||
closeAction();
|
closeAction();
|
||||||
await reload();
|
await refreshConfig();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleRule = async (rule, nextEnabled = rule.status !== "active") => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const nextStatus = nextEnabled ? "active" : "disabled";
|
const nextStatus = nextEnabled ? "active" : "disabled";
|
||||||
if (rule.status === nextStatus) {
|
if (rule.status === nextStatus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runAction(`rule-status-${rule.track}-${rule.level}`, nextEnabled ? "等级规则已启用" : "等级规则已停用", async () => {
|
await runAction(
|
||||||
await upsertRule(rule.track, rule.level, buildRulePayload(buildRuleForm({ ...rule, status: nextStatus })));
|
`rule-status-${rule.track}-${rule.level}`,
|
||||||
await reload();
|
nextEnabled ? "等级规则已启用" : "等级规则已停用",
|
||||||
});
|
async () => {
|
||||||
|
await upsertRule(
|
||||||
|
rule.track,
|
||||||
|
rule.level,
|
||||||
|
buildRulePayload(buildRuleForm({ ...rule, status: nextStatus })),
|
||||||
|
);
|
||||||
|
await refreshConfig();
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTier = async (tier, nextEnabled = tier.status !== "active") => {
|
const toggleTier = async (tier, nextEnabled = tier.status !== "active") => {
|
||||||
@ -177,10 +214,57 @@ export function useLevelConfigPage() {
|
|||||||
}
|
}
|
||||||
await runAction(`tier-status-${tier.tierId}`, nextEnabled ? "等级层已启用" : "等级层已停用", async () => {
|
await runAction(`tier-status-${tier.tierId}`, nextEnabled ? "等级层已启用" : "等级层已停用", async () => {
|
||||||
await updateTier(tier.tierId, buildTierPayload(buildTierForm({ ...tier, status: nextStatus })));
|
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) => {
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
setLoadingAction(action);
|
setLoadingAction(action);
|
||||||
try {
|
try {
|
||||||
@ -198,6 +282,8 @@ export function useLevelConfigPage() {
|
|||||||
activeAction,
|
activeAction,
|
||||||
avatarFrameResources,
|
avatarFrameResources,
|
||||||
badgeResources,
|
badgeResources,
|
||||||
|
batchDirty,
|
||||||
|
batchForms,
|
||||||
changeTrack,
|
changeTrack,
|
||||||
closeAction,
|
closeAction,
|
||||||
config,
|
config,
|
||||||
@ -205,8 +291,10 @@ export function useLevelConfigPage() {
|
|||||||
loading,
|
loading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
openRuleCreate,
|
openRuleCreate,
|
||||||
|
openRuleBatchEdit,
|
||||||
openRuleEdit,
|
openRuleEdit,
|
||||||
openTierCreate,
|
openTierCreate,
|
||||||
|
openTierBatchEdit,
|
||||||
openTierEdit,
|
openTierEdit,
|
||||||
openTrackEdit,
|
openTrackEdit,
|
||||||
reload,
|
reload,
|
||||||
@ -220,7 +308,9 @@ export function useLevelConfigPage() {
|
|||||||
setTierForm,
|
setTierForm,
|
||||||
setTrackForm,
|
setTrackForm,
|
||||||
submitRule,
|
submitRule,
|
||||||
|
submitRuleBatch,
|
||||||
submitTier,
|
submitTier,
|
||||||
|
submitTierBatch,
|
||||||
submitTrack,
|
submitTrack,
|
||||||
tierForm,
|
tierForm,
|
||||||
toggleRule,
|
toggleRule,
|
||||||
@ -228,6 +318,7 @@ export function useLevelConfigPage() {
|
|||||||
track,
|
track,
|
||||||
trackForm,
|
trackForm,
|
||||||
tracks,
|
tracks,
|
||||||
|
updateBatchForm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,3 +438,7 @@ function normalizedJson(value) {
|
|||||||
const raw = String(value || "").trim();
|
const raw = String(value || "").trim();
|
||||||
return raw || "{}";
|
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";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
batchUpsertLevelConfig: vi.fn(),
|
||||||
createTier: vi.fn(),
|
createTier: vi.fn(),
|
||||||
listLevelConfig: vi.fn(),
|
listLevelConfig: vi.fn(),
|
||||||
listResourceGroups: vi.fn(),
|
listResourceGroups: vi.fn(),
|
||||||
@ -18,6 +19,7 @@ vi.mock("@/features/resources/api", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/features/level-config/api", () => ({
|
vi.mock("@/features/level-config/api", () => ({
|
||||||
|
batchUpsertLevelConfig: mocks.batchUpsertLevelConfig,
|
||||||
createTier: mocks.createTier,
|
createTier: mocks.createTier,
|
||||||
listLevelConfig: mocks.listLevelConfig,
|
listLevelConfig: mocks.listLevelConfig,
|
||||||
updateTier: mocks.updateTier,
|
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.badgeResources).toEqual([badgeResource]);
|
||||||
expect(result.current.avatarFrameResources).toEqual([avatarFrameResource]);
|
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-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableActions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactTable {
|
.compactTable {
|
||||||
|
|||||||
@ -22,6 +22,13 @@ import { formatMillis } from "@/shared/utils/time.js";
|
|||||||
import { levelStatusLabels, levelTrackLabels, levelTrackOptions } from "@/features/level-config/constants.js";
|
import { levelStatusLabels, levelTrackLabels, levelTrackOptions } from "@/features/level-config/constants.js";
|
||||||
import { useLevelConfigPage } from "@/features/level-config/hooks/useLevelConfigPage.js";
|
import { useLevelConfigPage } from "@/features/level-config/hooks/useLevelConfigPage.js";
|
||||||
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
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";
|
import styles from "@/features/level-config/level-config.module.css";
|
||||||
|
|
||||||
export function LevelConfigPage() {
|
export function LevelConfigPage() {
|
||||||
@ -101,6 +108,7 @@ export function LevelConfigPage() {
|
|||||||
<RuleFormDialog page={page} />
|
<RuleFormDialog page={page} />
|
||||||
<TierFormDialog page={page} />
|
<TierFormDialog page={page} />
|
||||||
<TrackFormDialog page={page} />
|
<TrackFormDialog page={page} />
|
||||||
|
<LevelConfigBatchDialog page={page} />
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -158,7 +166,19 @@ function LevelTrackSection({ page, track }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.tableStack}>
|
<div className={styles.tableStack}>
|
||||||
<div className={styles.tableBlock}>
|
<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
|
<DataTable
|
||||||
className={styles.compactTable}
|
className={styles.compactTable}
|
||||||
columns={ruleColumns}
|
columns={ruleColumns}
|
||||||
@ -168,7 +188,19 @@ function LevelTrackSection({ page, track }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.tableBlock}>
|
<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
|
<DataTable
|
||||||
className={styles.compactTable}
|
className={styles.compactTable}
|
||||||
columns={tierColumns}
|
columns={tierColumns}
|
||||||
@ -182,11 +214,12 @@ function LevelTrackSection({ page, track }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBlockHeader({ countLabel, title }) {
|
function TableBlockHeader({ actions, countLabel, title }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableHeader}>
|
||||||
<div className={styles.tableTitle}>{title}</div>
|
<div className={styles.tableTitle}>{title}</div>
|
||||||
<span className={styles.tableCount}>{countLabel}</span>
|
<span className={styles.tableCount}>{countLabel}</span>
|
||||||
|
{actions ? <div className={styles.tableActions}>{actions}</div> : null}
|
||||||
</div>
|
</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) {
|
function resourceMetaLabel(resource, id) {
|
||||||
if (resource.resourceType === "avatar_frame") {
|
if (resource.resourceType === "avatar_frame") {
|
||||||
const avatarFrameKind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson);
|
const avatarFrameKind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson);
|
||||||
@ -838,7 +837,11 @@ function badgeKindFromMetadata(metadataJson) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const metadata = JSON.parse(metadataJson);
|
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 {
|
} catch {
|
||||||
return "";
|
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}
|
disabled={disabled || uploadDisabled}
|
||||||
kind="file"
|
kind="file"
|
||||||
label="资源封面"
|
label="资源封面"
|
||||||
|
showSourceActions
|
||||||
value={form.previewUrl}
|
value={form.previewUrl}
|
||||||
onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))}
|
onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))}
|
||||||
/>
|
/>
|
||||||
@ -1239,6 +1240,7 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
disabled={disabled || uploadDisabled}
|
disabled={disabled || uploadDisabled}
|
||||||
kind="file"
|
kind="file"
|
||||||
label="动效素材"
|
label="动效素材"
|
||||||
|
showSourceActions
|
||||||
value={form.animationUrl}
|
value={form.animationUrl}
|
||||||
onChange={(animationUrl) => setForm((previous) => ({ ...previous, animationUrl }))}
|
onChange={(animationUrl) => setForm((previous) => ({ ...previous, animationUrl }))}
|
||||||
onFileSelected={handleAnimationSelected}
|
onFileSelected={handleAnimationSelected}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
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(() => {
|
afterEach(() => {
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
@ -48,6 +48,7 @@ test("finance scope APIs use generated endpoint paths", async () => {
|
|||||||
await listUserFinanceScopes(9);
|
await listUserFinanceScopes(9);
|
||||||
await replaceUserFinanceScopes(9, [{ appCode: "lalu", regionId: 2 }]);
|
await replaceUserFinanceScopes(9, [{ appCode: "lalu", regionId: 2 }]);
|
||||||
await getFinanceScopeCatalog();
|
await getFinanceScopeCatalog();
|
||||||
|
await listFinanceScopeAssignments();
|
||||||
|
|
||||||
const calls = vi.mocked(fetch).mock.calls;
|
const calls = vi.mocked(fetch).mock.calls;
|
||||||
expect(String(calls[0][0])).toContain("/api/v1/users/9/finance-scopes");
|
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(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(String(calls[2][0])).toContain("/api/v1/admin/finance/scope");
|
||||||
expect(calls[2][1]?.method).toBe("GET");
|
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,
|
ApiPage,
|
||||||
CreateUserResultDto,
|
CreateUserResultDto,
|
||||||
EntityId,
|
EntityId,
|
||||||
|
FinanceScopeAssignmentDto,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
ResetPasswordResultDto,
|
ResetPasswordResultDto,
|
||||||
UserFormPayload,
|
UserFormPayload,
|
||||||
UserFinanceScopeDto,
|
UserFinanceScopeDto,
|
||||||
|
UserAppScopeDto,
|
||||||
UserStatus,
|
UserStatus,
|
||||||
UserUpdatePayload
|
UserUpdatePayload
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
@ -87,3 +89,18 @@ export function replaceUserFinanceScopes(id: EntityId, scopes: UserFinanceScopeD
|
|||||||
export function getFinanceScopeCatalog(): Promise<FinanceScopeCatalogDto> {
|
export function getFinanceScopeCatalog(): Promise<FinanceScopeCatalogDto> {
|
||||||
return apiRequest<FinanceScopeCatalogDto>(apiEndpointPath(API_OPERATIONS.getFinanceScope));
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
|
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 apps = useMemo(() => catalog?.apps || [], [catalog?.apps]);
|
||||||
const [activeAppCode, setActiveAppCode] = useState("");
|
const [activeAppCode, setActiveAppCode] = useState("");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selectedOnly, setSelectedOnly] = useState(false);
|
const [selectedOnly, setSelectedOnly] = useState(false);
|
||||||
|
const [unassignedOnly, setUnassignedOnly] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 目录刷新后主动回退到第一个有效应用,防止权限变化导致右侧仍引用已移除的应用。
|
// 目录刷新后主动回退到第一个有效应用,防止权限变化导致右侧仍引用已移除的应用。
|
||||||
@ -18,21 +19,47 @@ export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) {
|
|||||||
}
|
}
|
||||||
}, [activeAppCode, apps]);
|
}, [activeAppCode, apps]);
|
||||||
|
|
||||||
|
// 他人(排除当前编辑对象)的持有关系:区域粒度置灰 + 持有人标注,让"剩下哪些区没分"一眼可见。
|
||||||
|
const holderIndex = useMemo(() => buildHolderIndex(assignments, excludeUserId), [assignments, excludeUserId]);
|
||||||
|
|
||||||
const activeApp = apps.find((app) => app.appCode === activeAppCode) || apps[0];
|
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 activeRegions = useMemo(() => {
|
||||||
const keyword = query.trim().toLowerCase();
|
const keyword = query.trim().toLowerCase();
|
||||||
// 搜索与仅看已选只影响展示集合,原始勾选状态始终保留,切换应用或筛选不会丢失授权。
|
// 搜索与筛选只影响展示集合,原始勾选状态始终保留,切换应用或筛选不会丢失授权。
|
||||||
return (catalog?.regions || []).filter((region) => {
|
return appRegions.filter((region) => {
|
||||||
if (region.appCode !== activeApp?.appCode) {
|
const checked = hasScope(checkedScopes, activeApp?.appCode, region.regionId);
|
||||||
|
if (selectedOnly && !checked) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const checked = hasScope(checkedScopes, region.appCode, region.regionId);
|
if (unassignedOnly && (checked || holdersFor(holderIndex, activeApp?.appCode, region.regionId).length)) {
|
||||||
if (selectedOnly && !checked) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !keyword || [region.name, region.regionCode].some((value) => String(value || "").toLowerCase().includes(keyword));
|
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) {
|
if (catalog?.loading) {
|
||||||
return <div className="form-editor-state">加载中</div>;
|
return <div className="form-editor-state">加载中</div>;
|
||||||
@ -44,7 +71,8 @@ export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) {
|
|||||||
return <div className="form-editor-state">当前无数据</div>;
|
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 (
|
return (
|
||||||
<div className="form-editor-split form-editor-split--finance">
|
<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 className="form-editor-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<strong>{activeApp.appName || activeApp.appCode}</strong>
|
<strong>{activeApp.appName || activeApp.appCode}</strong>
|
||||||
<span>已选 {countAppScopes(checkedScopes, activeApp.appCode)} 项</span>
|
<span>
|
||||||
|
已选 {countAppScopes(checkedScopes, activeApp.appCode)} 项 · 他人已分 {appStats.taken} · 未分配 {appStats.unassigned}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
aria-label="搜索区域"
|
aria-label="搜索区域"
|
||||||
@ -92,31 +122,68 @@ export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) {
|
|||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
<label className="form-editor-filter">
|
<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>
|
<span>仅看已选</span>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="form-option-grid form-option-grid--finance">
|
<div className="form-option-grid form-option-grid--finance">
|
||||||
{(!selectedOnly || allRegionsChecked) && !query ? (
|
{(!selectedOnly || allRegionsChecked) && !unassignedOnly && !query ? (
|
||||||
<FinanceScopeOption
|
<FinanceScopeOption
|
||||||
checked={allRegionsChecked}
|
checked={allRegionsChecked}
|
||||||
code="ALL"
|
code="ALL"
|
||||||
|
// 他人已占任一区域时不允许再整包全选,避免和已有分工重叠。
|
||||||
|
disabled={!allRegionsChecked && appTakenByOthers}
|
||||||
|
holderText={
|
||||||
|
allHolders.length
|
||||||
|
? `已分配:${holderNames(allHolders)}`
|
||||||
|
: appStats.taken > 0
|
||||||
|
? `他人已分 ${appStats.taken} 区`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
label="全部区域"
|
label="全部区域"
|
||||||
onChange={(checked) => onChecked(activeApp.appCode, 0, checked)}
|
onChange={(checked) => onChecked(activeApp.appCode, 0, checked)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeRegions.map((region) => (
|
{activeRegions.map((region) => {
|
||||||
|
const holders = holdersFor(holderIndex, activeApp.appCode, region.regionId);
|
||||||
|
const checked = hasScope(checkedScopes, activeApp.appCode, region.regionId);
|
||||||
|
return (
|
||||||
<FinanceScopeOption
|
<FinanceScopeOption
|
||||||
checked={hasScope(checkedScopes, activeApp.appCode, region.regionId)}
|
checked={checked}
|
||||||
code={region.regionCode}
|
code={region.regionCode}
|
||||||
|
disabled={!checked && holders.length > 0}
|
||||||
|
holderText={holders.length ? `已分配:${holderNames(holders)}` : ""}
|
||||||
key={region.regionId}
|
key={region.regionId}
|
||||||
label={region.name}
|
label={region.name}
|
||||||
onChange={(checked) => onChecked(activeApp.appCode, region.regionId, checked)}
|
onChange={(nextChecked) => onChecked(activeApp.appCode, region.regionId, nextChecked)}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{!activeRegions.length && (query || selectedOnly) && !(selectedOnly && allRegionsChecked) ? (
|
{!activeRegions.length && (query || selectedOnly || unassignedOnly) && !(selectedOnly && allRegionsChecked) ? (
|
||||||
<div className="form-editor-state">当前无匹配区域</div>
|
<div className="form-editor-state">当前无匹配区域</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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 (
|
return (
|
||||||
<label className="form-option">
|
<label className={`form-option${disabled ? " form-option--disabled" : ""}`}>
|
||||||
<Checkbox checked={checked} onChange={(event) => onChange(event.target.checked)} size="small" />
|
<Checkbox checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} size="small" />
|
||||||
<span>
|
<span>
|
||||||
<strong>{label}</strong>
|
<strong>{label}</strong>
|
||||||
{code ? <small>{code}</small> : null}
|
{code ? <small>{code}</small> : null}
|
||||||
|
{holderText ? <small className="form-option__holder">{holderText}</small> : null}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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) {
|
function hasScope(scopes, appCode, regionId) {
|
||||||
return (scopes || []).some(
|
return (scopes || []).some(
|
||||||
(scope) => scope.appCode === appCode && Number(scope.regionId) === Number(regionId),
|
(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 { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import { FormDrawerShell } from "@/shared/ui/FormDrawerShell.jsx";
|
import { FormDrawerShell } from "@/shared/ui/FormDrawerShell.jsx";
|
||||||
import { UserFinanceScopeEditor } from "@/features/users/components/UserFinanceScopeEditor.jsx";
|
import { UserFinanceScopeEditor } from "@/features/users/components/UserFinanceScopeEditor.jsx";
|
||||||
|
import { UserAppScopeEditor } from "@/features/users/components/UserAppScopeEditor.jsx";
|
||||||
|
|
||||||
export function UserFormDrawer({
|
export function UserFormDrawer({
|
||||||
editingUser,
|
editingUser,
|
||||||
|
appScopeCatalog = [],
|
||||||
|
financeScopeAssignments = [],
|
||||||
financeScopeCatalog,
|
financeScopeCatalog,
|
||||||
form,
|
form,
|
||||||
onClose,
|
onClose,
|
||||||
onFinanceScopeChecked,
|
onFinanceScopeChecked,
|
||||||
|
onAppScopeChecked,
|
||||||
|
onAppScopeModeChange,
|
||||||
onRoleChecked,
|
onRoleChecked,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
open,
|
open,
|
||||||
@ -31,7 +36,8 @@ export function UserFormDrawer({
|
|||||||
const sections = [
|
const sections = [
|
||||||
{ key: "profile", label: "账号资料" },
|
{ key: "profile", label: "账号资料" },
|
||||||
{ count: form.financeScopes?.length || 0, key: "finance", 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 (
|
return (
|
||||||
@ -84,8 +90,10 @@ export function UserFormDrawer({
|
|||||||
|
|
||||||
{activeSection === "finance" ? (
|
{activeSection === "finance" ? (
|
||||||
<UserFinanceScopeEditor
|
<UserFinanceScopeEditor
|
||||||
|
assignments={financeScopeAssignments}
|
||||||
catalog={financeScopeCatalog}
|
catalog={financeScopeCatalog}
|
||||||
checkedScopes={form.financeScopes || []}
|
checkedScopes={form.financeScopes || []}
|
||||||
|
excludeUserId={editingUser?.id}
|
||||||
onChecked={onFinanceScopeChecked}
|
onChecked={onFinanceScopeChecked}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -128,6 +136,16 @@ export function UserFormDrawer({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{activeSection === "apps" ? (
|
||||||
|
<UserAppScopeEditor
|
||||||
|
apps={appScopeCatalog}
|
||||||
|
appCodes={form.appCodes || []}
|
||||||
|
mode={form.appScopeMode}
|
||||||
|
onAppChecked={onAppScopeChecked}
|
||||||
|
onModeChange={onAppScopeModeChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</FormDrawerShell>
|
</FormDrawerShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,14 +9,18 @@ import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
|||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { listRoles } from "@/features/roles/api";
|
import { listRoles } from "@/features/roles/api";
|
||||||
import { listTeams } from "@/features/teams/api";
|
import { listTeams } from "@/features/teams/api";
|
||||||
|
import { listAdminApps } from "@/features/app-registry/api";
|
||||||
import {
|
import {
|
||||||
batchUpdateUserStatus,
|
batchUpdateUserStatus,
|
||||||
createUser,
|
createUser,
|
||||||
exportUsers,
|
exportUsers,
|
||||||
getFinanceScopeCatalog,
|
getFinanceScopeCatalog,
|
||||||
|
getUserAppScopes,
|
||||||
|
listFinanceScopeAssignments,
|
||||||
listUserFinanceScopes,
|
listUserFinanceScopes,
|
||||||
listUsers,
|
listUsers,
|
||||||
replaceUserFinanceScopes,
|
replaceUserFinanceScopes,
|
||||||
|
replaceUserAppScopes,
|
||||||
resetUserPassword,
|
resetUserPassword,
|
||||||
updateUser,
|
updateUser,
|
||||||
updateUserStatus,
|
updateUserStatus,
|
||||||
@ -29,6 +33,8 @@ const emptyUsers = { items: [], total: 0, page: 1, pageSize };
|
|||||||
const emptyData = { roles: [], teams: [], users: emptyUsers };
|
const emptyData = { roles: [], teams: [], users: emptyUsers };
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
|
appCodes: [],
|
||||||
|
appScopeMode: "none",
|
||||||
mfaEnabled: false,
|
mfaEnabled: false,
|
||||||
name: "",
|
name: "",
|
||||||
password: "",
|
password: "",
|
||||||
@ -51,6 +57,8 @@ export function useUsersPage() {
|
|||||||
const [profileUser, setProfileUser] = useState(null);
|
const [profileUser, setProfileUser] = useState(null);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [financeScopeCatalog, setFinanceScopeCatalog] = useState({ apps: [], countries: [], error: "", loading: false, regions: [] });
|
const [financeScopeCatalog, setFinanceScopeCatalog] = useState({ apps: [], countries: [], error: "", loading: false, regions: [] });
|
||||||
|
const [financeScopeAssignments, setFinanceScopeAssignments] = useState([]);
|
||||||
|
const [appScopeCatalog, setAppScopeCatalog] = useState([]);
|
||||||
const [mergedUsers, setMergedUsers] = useState(emptyUsers);
|
const [mergedUsers, setMergedUsers] = useState(emptyUsers);
|
||||||
const abilities = useUserAbilities();
|
const abilities = useUserAbilities();
|
||||||
const confirm = useConfirm();
|
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 = () => {
|
const openCreate = () => {
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
loadFinanceScopeCatalog();
|
loadFinanceScopeCatalog();
|
||||||
|
loadFinanceScopeAssignments();
|
||||||
|
loadAppScopeCatalog();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (user) => {
|
const openEdit = (user) => {
|
||||||
setEditingUser(user);
|
setEditingUser(user);
|
||||||
setForm({
|
setForm({
|
||||||
|
appCodes: [],
|
||||||
|
appScopeMode: user.appScopeMode || "none",
|
||||||
mfaEnabled: user.mfaEnabled,
|
mfaEnabled: user.mfaEnabled,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
password: "",
|
password: "",
|
||||||
@ -180,6 +213,17 @@ export function useUsersPage() {
|
|||||||
});
|
});
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
loadFinanceScopeCatalog();
|
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)
|
listUserFinanceScopes(user.id)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const scopes = Array.isArray(data?.items) ? data.items : [];
|
const scopes = Array.isArray(data?.items) ? data.items : [];
|
||||||
@ -210,12 +254,14 @@ export function useUsersPage() {
|
|||||||
if (editingUser) {
|
if (editingUser) {
|
||||||
const payload = parseForm(userUpdateFormSchema, form);
|
const payload = parseForm(userUpdateFormSchema, form);
|
||||||
await updateUser(editingUser.id, payload);
|
await updateUser(editingUser.id, payload);
|
||||||
|
await replaceUserAppScopes(editingUser.id, normalizeAppScope(form));
|
||||||
await replaceUserFinanceScopes(editingUser.id, normalizeFinanceScopes(form.financeScopes));
|
await replaceUserFinanceScopes(editingUser.id, normalizeFinanceScopes(form.financeScopes));
|
||||||
showToast("用户已更新", "success");
|
showToast("用户已更新", "success");
|
||||||
} else {
|
} else {
|
||||||
const payload = parseForm(userCreateFormSchema, form);
|
const payload = parseForm(userCreateFormSchema, form);
|
||||||
const result = await createUser(payload);
|
const result = await createUser(payload);
|
||||||
if (result?.user?.id) {
|
if (result?.user?.id) {
|
||||||
|
await replaceUserAppScopes(result.user.id, normalizeAppScope(form));
|
||||||
await replaceUserFinanceScopes(result.user.id, normalizeFinanceScopes(form.financeScopes));
|
await replaceUserFinanceScopes(result.user.id, normalizeFinanceScopes(form.financeScopes));
|
||||||
}
|
}
|
||||||
showToast(`用户已创建,初始密码:${result.initialPassword}`, "success");
|
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 setFinanceScopeChecked = (appCode, regionId, checked) => {
|
||||||
const normalizedAppCode = String(appCode || "").trim();
|
const normalizedAppCode = String(appCode || "").trim();
|
||||||
const normalizedRegionId = Number(regionId || 0);
|
const normalizedRegionId = Number(regionId || 0);
|
||||||
@ -330,6 +406,7 @@ export function useUsersPage() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
|
appScopeCatalog,
|
||||||
batchStatus,
|
batchStatus,
|
||||||
changeQuery,
|
changeQuery,
|
||||||
changeRoleFilter,
|
changeRoleFilter,
|
||||||
@ -339,6 +416,7 @@ export function useUsersPage() {
|
|||||||
downloadUsers,
|
downloadUsers,
|
||||||
editingUser,
|
editingUser,
|
||||||
error,
|
error,
|
||||||
|
financeScopeAssignments,
|
||||||
financeScopeCatalog,
|
financeScopeCatalog,
|
||||||
form,
|
form,
|
||||||
formOpen,
|
formOpen,
|
||||||
@ -358,6 +436,8 @@ export function useUsersPage() {
|
|||||||
roles,
|
roles,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
setForm,
|
setForm,
|
||||||
|
setAppScopeChecked,
|
||||||
|
setAppScopeMode,
|
||||||
setFinanceScopeChecked,
|
setFinanceScopeChecked,
|
||||||
setPage,
|
setPage,
|
||||||
setRoleChecked,
|
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 = []) {
|
function normalizeFinanceScopes(scopes = []) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return (Array.isArray(scopes) ? scopes : [])
|
return (Array.isArray(scopes) ? scopes : [])
|
||||||
|
|||||||
@ -98,10 +98,14 @@ export function UserManagementPage() {
|
|||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
|
|
||||||
<UserFormDrawer
|
<UserFormDrawer
|
||||||
|
appScopeCatalog={page.appScopeCatalog}
|
||||||
editingUser={page.editingUser}
|
editingUser={page.editingUser}
|
||||||
|
financeScopeAssignments={page.financeScopeAssignments}
|
||||||
financeScopeCatalog={page.financeScopeCatalog}
|
financeScopeCatalog={page.financeScopeCatalog}
|
||||||
form={page.form}
|
form={page.form}
|
||||||
onClose={page.closeForm}
|
onClose={page.closeForm}
|
||||||
|
onAppScopeChecked={page.setAppScopeChecked}
|
||||||
|
onAppScopeModeChange={page.setAppScopeMode}
|
||||||
onFinanceScopeChecked={page.setFinanceScopeChecked}
|
onFinanceScopeChecked={page.setFinanceScopeChecked}
|
||||||
onRoleChecked={page.setRoleChecked}
|
onRoleChecked={page.setRoleChecked}
|
||||||
onSubmit={page.submitUser}
|
onSubmit={page.submitUser}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export const API_OPERATIONS = {
|
|||||||
batchCreateGifts: "batchCreateGifts",
|
batchCreateGifts: "batchCreateGifts",
|
||||||
batchDeleteResources: "batchDeleteResources",
|
batchDeleteResources: "batchDeleteResources",
|
||||||
batchUpdateUserStatus: "batchUpdateUserStatus",
|
batchUpdateUserStatus: "batchUpdateUserStatus",
|
||||||
|
batchUpsertLevelConfig: "batchUpsertLevelConfig",
|
||||||
cancelJob: "cancelJob",
|
cancelJob: "cancelJob",
|
||||||
cancelRoomPin: "cancelRoomPin",
|
cancelRoomPin: "cancelRoomPin",
|
||||||
changePassword: "changePassword",
|
changePassword: "changePassword",
|
||||||
@ -161,6 +162,7 @@ export const API_OPERATIONS = {
|
|||||||
getSocialBiRequirements: "getSocialBiRequirements",
|
getSocialBiRequirements: "getSocialBiRequirements",
|
||||||
getTemporaryPaymentLink: "getTemporaryPaymentLink",
|
getTemporaryPaymentLink: "getTemporaryPaymentLink",
|
||||||
getUser: "getUser",
|
getUser: "getUser",
|
||||||
|
getUserAppScopes: "getUserAppScopes",
|
||||||
getVipConfig: "getVipConfig",
|
getVipConfig: "getVipConfig",
|
||||||
getVipProgram: "getVipProgram",
|
getVipProgram: "getVipProgram",
|
||||||
getWeeklyStarCycle: "getWeeklyStarCycle",
|
getWeeklyStarCycle: "getWeeklyStarCycle",
|
||||||
@ -197,6 +199,7 @@ export const API_OPERATIONS = {
|
|||||||
listEmojiPacks: "listEmojiPacks",
|
listEmojiPacks: "listEmojiPacks",
|
||||||
listExploreTabs: "listExploreTabs",
|
listExploreTabs: "listExploreTabs",
|
||||||
listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders",
|
listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders",
|
||||||
|
listFinanceScopeAssignments: "listFinanceScopeAssignments",
|
||||||
listFinanceWithdrawalApplications: "listFinanceWithdrawalApplications",
|
listFinanceWithdrawalApplications: "listFinanceWithdrawalApplications",
|
||||||
listFirstRechargeRewardClaims: "listFirstRechargeRewardClaims",
|
listFirstRechargeRewardClaims: "listFirstRechargeRewardClaims",
|
||||||
listGameWhitelistUsers: "listGameWhitelistUsers",
|
listGameWhitelistUsers: "listGameWhitelistUsers",
|
||||||
@ -257,6 +260,7 @@ export const API_OPERATIONS = {
|
|||||||
listUserFinanceScopes: "listUserFinanceScopes",
|
listUserFinanceScopes: "listUserFinanceScopes",
|
||||||
listUserLeaderboards: "listUserLeaderboards",
|
listUserLeaderboards: "listUserLeaderboards",
|
||||||
listUsers: "listUsers",
|
listUsers: "listUsers",
|
||||||
|
listVisibleApps: "listVisibleApps",
|
||||||
listWeeklyStarCycles: "listWeeklyStarCycles",
|
listWeeklyStarCycles: "listWeeklyStarCycles",
|
||||||
listWeeklyStarLeaderboard: "listWeeklyStarLeaderboard",
|
listWeeklyStarLeaderboard: "listWeeklyStarLeaderboard",
|
||||||
listWeeklyStarSettlements: "listWeeklyStarSettlements",
|
listWeeklyStarSettlements: "listWeeklyStarSettlements",
|
||||||
@ -282,6 +286,7 @@ export const API_OPERATIONS = {
|
|||||||
replaceRegionCountries: "replaceRegionCountries",
|
replaceRegionCountries: "replaceRegionCountries",
|
||||||
replaceRoleDataScopes: "replaceRoleDataScopes",
|
replaceRoleDataScopes: "replaceRoleDataScopes",
|
||||||
replaceRolePermissions: "replaceRolePermissions",
|
replaceRolePermissions: "replaceRolePermissions",
|
||||||
|
replaceUserAppScopes: "replaceUserAppScopes",
|
||||||
replaceUserFinanceScopes: "replaceUserFinanceScopes",
|
replaceUserFinanceScopes: "replaceUserFinanceScopes",
|
||||||
resetUserPassword: "resetUserPassword",
|
resetUserPassword: "resetUserPassword",
|
||||||
retryRedPacketRefund: "retryRedPacketRefund",
|
retryRedPacketRefund: "retryRedPacketRefund",
|
||||||
@ -519,6 +524,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:status",
|
permission: "user:status",
|
||||||
permissions: ["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: {
|
cancelJob: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.cancelJob,
|
operationId: API_OPERATIONS.cancelJob,
|
||||||
@ -1413,6 +1425,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:view",
|
permission: "user:view",
|
||||||
permissions: ["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: {
|
getVipConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getVipConfig,
|
operationId: API_OPERATIONS.getVipConfig,
|
||||||
@ -1663,6 +1682,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "finance-order:coin-seller-recharge:view",
|
permission: "finance-order:coin-seller-recharge:view",
|
||||||
permissions: ["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: {
|
listFinanceWithdrawalApplications: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listFinanceWithdrawalApplications,
|
operationId: API_OPERATIONS.listFinanceWithdrawalApplications,
|
||||||
@ -2083,6 +2109,11 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:view",
|
permission: "user:view",
|
||||||
permissions: ["user:view"]
|
permissions: ["user:view"]
|
||||||
},
|
},
|
||||||
|
listVisibleApps: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.listVisibleApps,
|
||||||
|
path: "/v1/admin/apps/visible"
|
||||||
|
},
|
||||||
listWeeklyStarCycles: {
|
listWeeklyStarCycles: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listWeeklyStarCycles,
|
operationId: API_OPERATIONS.listWeeklyStarCycles,
|
||||||
@ -2248,6 +2279,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "role:permission",
|
permission: "role:permission",
|
||||||
permissions: ["role:permission","role:manage"]
|
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: {
|
replaceUserFinanceScopes: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.replaceUserFinanceScopes,
|
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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/bd-leaders": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3260,6 +3276,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/admin/users/level-config/rules/{track}/{level}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4012,6 +4044,22 @@ export interface paths {
|
|||||||
patch: operations["updateUser"];
|
patch: operations["updateUser"];
|
||||||
trace?: never;
|
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": {
|
"/users/{id}/finance-scopes": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4028,6 +4076,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/users/{id}/reset-password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -5171,6 +5235,8 @@ export interface components {
|
|||||||
};
|
};
|
||||||
AdminUser: {
|
AdminUser: {
|
||||||
account: string;
|
account: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
appScopeMode?: "all" | "selected" | "none";
|
||||||
id: number;
|
id: number;
|
||||||
lastLogin?: string;
|
lastLogin?: string;
|
||||||
mfa?: string;
|
mfa?: string;
|
||||||
@ -5893,6 +5959,11 @@ export interface components {
|
|||||||
appCode: string;
|
appCode: string;
|
||||||
regionId: number;
|
regionId: number;
|
||||||
};
|
};
|
||||||
|
UserAppScope: {
|
||||||
|
/** @enum {string} */
|
||||||
|
mode: "all" | "selected" | "none";
|
||||||
|
appCodes: string[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
@ -6524,6 +6595,11 @@ export interface components {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
AppScopeRequest: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserAppScope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
FinanceCoinSellerRechargeOrderRequest: {
|
FinanceCoinSellerRechargeOrderRequest: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["FinanceCoinSellerRechargeOrderInput"];
|
"application/json": components["schemas"]["FinanceCoinSellerRechargeOrderInput"];
|
||||||
@ -7995,6 +8071,18 @@ export interface operations {
|
|||||||
200: components["responses"]["AppListResponse"];
|
200: components["responses"]["AppListResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listVisibleApps: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["AppListResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listBDLeaders: {
|
listBDLeaders: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@ -10641,6 +10729,18 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
batchUpsertLevelConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
upsertRule: {
|
upsertRule: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -11531,6 +11631,18 @@ export interface operations {
|
|||||||
200: components["responses"]["UserResponse"];
|
200: components["responses"]["UserResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listFinanceScopeAssignments: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listUserFinanceScopes: {
|
listUserFinanceScopes: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -11559,6 +11671,34 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
resetUserPassword: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -60,6 +60,7 @@ export type UserStatus = "active" | "disabled" | "locked";
|
|||||||
|
|
||||||
export interface AdminUserDto {
|
export interface AdminUserDto {
|
||||||
account: string;
|
account: string;
|
||||||
|
appScopeMode?: UserAppScopeMode;
|
||||||
id: EntityId;
|
id: EntityId;
|
||||||
lastLogin?: string;
|
lastLogin?: string;
|
||||||
lastLoginMs?: number | null;
|
lastLoginMs?: number | null;
|
||||||
@ -74,6 +75,13 @@ export interface AdminUserDto {
|
|||||||
teamName?: string;
|
teamName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserAppScopeMode = "all" | "selected" | "none";
|
||||||
|
|
||||||
|
export interface UserAppScopeDto {
|
||||||
|
appCodes: string[];
|
||||||
|
mode: UserAppScopeMode;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeamDto {
|
export interface TeamDto {
|
||||||
children?: TeamDto[];
|
children?: TeamDto[];
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
@ -100,6 +108,15 @@ export interface UserFinanceScopeDto {
|
|||||||
userId?: EntityId;
|
userId?: EntityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FinanceScopeAssignmentDto {
|
||||||
|
appCode: string;
|
||||||
|
regionId: number;
|
||||||
|
userId: EntityId;
|
||||||
|
userName?: string;
|
||||||
|
userAccount?: string;
|
||||||
|
userStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserFormPayload {
|
export interface UserFormPayload {
|
||||||
mfaEnabled?: boolean;
|
mfaEnabled?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@ -32,6 +32,11 @@
|
|||||||
--admin-form-field-width: 240px;
|
--admin-form-field-width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.full {
|
||||||
|
--admin-form-dialog-width: 1240px;
|
||||||
|
--admin-form-field-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-height: calc(100vh - 64px);
|
max-height: calc(100vh - 64px);
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
|
||||||
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
||||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||||
import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined";
|
import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined";
|
||||||
|
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||||
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
@ -10,6 +12,7 @@ import DialogContent from "@mui/material/DialogContent";
|
|||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { useEffect, useId, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { downloadResponse } from "@/shared/api/download";
|
||||||
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
@ -31,6 +34,7 @@ export function UploadField({
|
|||||||
onFileSelected,
|
onFileSelected,
|
||||||
panelClassName,
|
panelClassName,
|
||||||
previewClassName,
|
previewClassName,
|
||||||
|
showSourceActions = false,
|
||||||
uploadKind,
|
uploadKind,
|
||||||
value = "",
|
value = "",
|
||||||
}) {
|
}) {
|
||||||
@ -39,6 +43,7 @@ export function UploadField({
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [localPreview, setLocalPreview] = useState(null);
|
const [localPreview, setLocalPreview] = useState(null);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
const [videoPreviewOpen, setVideoPreviewOpen] = useState(false);
|
const [videoPreviewOpen, setVideoPreviewOpen] = useState(false);
|
||||||
const isImage = kind === "image";
|
const isImage = kind === "image";
|
||||||
const uploadMode = uploadKind || kind;
|
const uploadMode = uploadKind || kind;
|
||||||
@ -101,6 +106,31 @@ export function UploadField({
|
|||||||
onChange("");
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
@ -117,6 +147,7 @@ export function UploadField({
|
|||||||
{hideType ? null : <span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>}
|
{hideType ? null : <span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className={[styles.panel, panelClassName || ""].filter(Boolean).join(" ")}>
|
<div className={[styles.panel, panelClassName || ""].filter(Boolean).join(" ")}>
|
||||||
|
<div className={styles.previewArea}>
|
||||||
<button
|
<button
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={[styles.preview, previewClassName || ""].filter(Boolean).join(" ")}
|
className={[styles.preview, previewClassName || ""].filter(Boolean).join(" ")}
|
||||||
@ -131,6 +162,38 @@ export function UploadField({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</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}
|
||||||
|
</div>
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<span className={styles.name}>{displayName || "未上传"}</span>
|
<span className={styles.name}>{displayName || "未上传"}</span>
|
||||||
<span className={styles.actions}>
|
<span className={styles.actions}>
|
||||||
|
|||||||
@ -38,6 +38,10 @@
|
|||||||
-webkit-backdrop-filter: var(--glass-blur);
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.previewArea {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.preview {
|
.preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -62,6 +66,53 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -217,6 +268,11 @@
|
|||||||
height: 112px;
|
height: 112px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact .previewAction {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.compact .empty svg {
|
.compact .empty svg {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { downloadResponse } from "@/shared/api/download";
|
||||||
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { UploadField } from "./UploadField.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" })),
|
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 () => {
|
test("file upload field does not restrict picker type and uses generic upload endpoint", async () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const onFileSelected = 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();
|
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;
|
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 {
|
.form-option--single-line {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user