Merge branch 'main' into test
This commit is contained in:
commit
cab76f4c78
@ -6,12 +6,6 @@
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 7001
|
||||
},
|
||||
{
|
||||
"name": "admin-platform-alt",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "7031"],
|
||||
"port": 7031
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -6337,7 +6337,22 @@
|
||||
}
|
||||
},
|
||||
"x-permission": "external-admin-user:create",
|
||||
"x-permissions": ["external-admin-user:create"]
|
||||
"x-permissions": ["external-admin-user:create", "external-admin-user:permissions"]
|
||||
}
|
||||
},
|
||||
"/admin/external-admin-users/permission-catalog": {
|
||||
"get": {
|
||||
"operationId": "listExternalAdminPermissionCatalog",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/AppCodeHeader" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/ExternalAdminPermissionCatalogResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "external-admin-user:permissions",
|
||||
"x-permissions": ["external-admin-user:permissions"]
|
||||
}
|
||||
},
|
||||
"/admin/external-admin-users/target": {
|
||||
@ -6417,6 +6432,46 @@
|
||||
"x-permissions": ["external-admin-user:reset-password"]
|
||||
}
|
||||
},
|
||||
"/admin/external-admin-users/{id}/permissions": {
|
||||
"get": {
|
||||
"operationId": "getExternalAdminUserPermissions",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/AppCodeHeader" },
|
||||
{ "$ref": "#/components/parameters/Id" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/ExternalAdminUserPermissionsResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "external-admin-user:permissions",
|
||||
"x-permissions": ["external-admin-user:permissions"]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updateExternalAdminUserPermissions",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/AppCodeHeader" },
|
||||
{ "$ref": "#/components/parameters/Id" }
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExternalAdminUserPermissionsInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/ExternalAdminUserResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "external-admin-user:permissions",
|
||||
"x-permissions": ["external-admin-user:permissions"]
|
||||
}
|
||||
},
|
||||
"/exports/app-users": {
|
||||
"post": {
|
||||
"operationId": "appExportUsers",
|
||||
@ -8870,6 +8925,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExternalAdminPermissionCatalogResponse": {
|
||||
"description": "External admin permission catalog",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminPermissionCatalog" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExternalAdminUserPermissionsResponse": {
|
||||
"description": "External admin account permissions",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUserPermissions" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"AgencyPageResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
@ -9865,6 +9936,28 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseExternalAdminPermissionCatalog": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/Envelope" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": { "$ref": "#/components/schemas/ExternalAdminPermissionCatalog" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseExternalAdminUserPermissions": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/Envelope" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": { "$ref": "#/components/schemas/ExternalAdminUserPermissions" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiPageExternalAdminUser": {
|
||||
"type": "object",
|
||||
"required": ["items", "page", "pageSize", "total"],
|
||||
@ -9902,7 +9995,7 @@
|
||||
},
|
||||
"ExternalAdminUser": {
|
||||
"type": "object",
|
||||
"required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "createdAtMs"],
|
||||
"required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "permissionRevision", "createdAtMs"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"appCode": { "type": "string" },
|
||||
@ -9913,6 +10006,7 @@
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"permissionRevision": { "type": "integer", "format": "int64", "minimum": 1 },
|
||||
"createdByAdminId": { "type": "integer" },
|
||||
"lastLoginAtMs": { "type": "integer", "format": "int64", "nullable": true },
|
||||
"createdAtMs": { "type": "integer", "format": "int64" },
|
||||
@ -9931,7 +10025,72 @@
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z0-9._-]+$"
|
||||
},
|
||||
"password": { "type": "string", "minLength": 8, "maxLength": 72 }
|
||||
"password": { "type": "string", "minLength": 1, "maxLength": 72 },
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExternalAdminPermissionCatalogItem": {
|
||||
"type": "object",
|
||||
"required": ["code", "label", "group", "dependencies", "capabilities", "defaultGranted"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": { "type": "string", "minLength": 1 },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"group": { "type": "string", "minLength": 1 },
|
||||
"dependencies": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"defaultGranted": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"ExternalAdminPermissionCatalog": {
|
||||
"type": "object",
|
||||
"required": ["items", "total"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/ExternalAdminPermissionCatalogItem" }
|
||||
},
|
||||
"total": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"ExternalAdminUserPermissions": {
|
||||
"type": "object",
|
||||
"required": ["accountId", "permissions", "revision"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"accountId": { "type": "string", "minLength": 1 },
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"revision": { "type": "integer", "format": "int64", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"ExternalAdminUserPermissionsInput": {
|
||||
"type": "object",
|
||||
"required": ["permissions", "expectedRevision"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"expectedRevision": { "type": "integer", "format": "int64", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"ExternalAdminUserStatusInput": {
|
||||
@ -9947,7 +10106,7 @@
|
||||
"required": ["password"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"password": { "type": "string", "minLength": 8, "maxLength": 72 }
|
||||
"password": { "type": "string", "minLength": 1, "maxLength": 72 }
|
||||
}
|
||||
},
|
||||
"ApiPageAppUser": {
|
||||
|
||||
@ -543,10 +543,31 @@ test("renders social BI data requirements table with section filters and CSV exp
|
||||
expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payerType: "all",
|
||||
regionIds: [9, 11],
|
||||
section: "new_users",
|
||||
section: "revenue",
|
||||
userRole: "all"
|
||||
}));
|
||||
expect(screen.getByRole("tab", { name: "新用户" })).toHaveAttribute("aria-selected", "true");
|
||||
const requirementTabs = screen.getByRole("tablist", { name: "数据需求分组" });
|
||||
expect(within(requirementTabs).getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
|
||||
"充值",
|
||||
"新增",
|
||||
"日活",
|
||||
"礼物",
|
||||
"游戏",
|
||||
"留存"
|
||||
]);
|
||||
expect(screen.getByRole("tab", { name: "充值" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
|
||||
expect(screen.getByRole("radiogroup", { name: "付费身份" })).toBeTruthy();
|
||||
expect(screen.getByText("充值用户")).toBeTruthy();
|
||||
expect(screen.getByText("充值金额")).toBeTruthy();
|
||||
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole("tab", { name: "新增" }).click();
|
||||
});
|
||||
await flushEffects();
|
||||
expect(fetchSocialBiRequirements).toHaveBeenLastCalledWith(expect.objectContaining({ section: "new_users" }));
|
||||
expect(screen.getByRole("tab", { name: "新增" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("下载APP人数")).toBeTruthy();
|
||||
expect(screen.getByText("注册成功率")).toBeTruthy();
|
||||
expect(screen.getAllByText("12").length).toBeGreaterThan(0);
|
||||
@ -555,7 +576,7 @@ test("renders social BI data requirements table with section filters and CSV exp
|
||||
expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole("tab", { name: "营收" }).click();
|
||||
screen.getByRole("tab", { name: "充值" }).click();
|
||||
});
|
||||
await flushEffects();
|
||||
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
|
||||
|
||||
@ -88,7 +88,7 @@ export function SocialBiApp() {
|
||||
}, []);
|
||||
|
||||
const openRequirementSection = useCallback((requirementSection) => {
|
||||
// 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 新用户”。
|
||||
// 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 充值”。
|
||||
setTableState({ mode: "requirements", requirementSection });
|
||||
setViewKey("table");
|
||||
}, []);
|
||||
|
||||
@ -6,12 +6,12 @@ import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
|
||||
export const ALL = "all";
|
||||
|
||||
const TABLE_MODES = new Set(["wide", "requirements"]);
|
||||
const REQUIREMENT_SECTIONS = new Set(["new_users", "revenue", "retention", "active", "gift", "game"]);
|
||||
const REQUIREMENT_SECTIONS = new Set(["revenue", "new_users", "active", "gift", "game", "retention"]);
|
||||
|
||||
export function createDefaultTableState() {
|
||||
return {
|
||||
mode: "wide",
|
||||
requirementSection: "new_users"
|
||||
requirementSection: "revenue"
|
||||
};
|
||||
}
|
||||
|
||||
@ -200,9 +200,9 @@ export function writeStateToURL(filters, view, table, history = window.history,
|
||||
params.set("view", view);
|
||||
}
|
||||
if (view === "table" && table?.mode === "requirements") {
|
||||
// 目标分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个游戏数据视图。
|
||||
// 非默认分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个业务明细视图。
|
||||
params.set("mode", "requirements");
|
||||
if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "new_users") {
|
||||
if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "revenue") {
|
||||
params.set("section", table.requirementSection);
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,17 +47,6 @@ const TABLE_MODES = [
|
||||
];
|
||||
|
||||
const REQUIREMENT_SECTIONS = [
|
||||
{
|
||||
csvName: "p0-new-users",
|
||||
fallbackColumns: [
|
||||
{ key: "app_first_open_users", label: "下载APP人数", type: "count" },
|
||||
{ key: "new_users", label: "新增注册", type: "count" },
|
||||
{ key: "profile_complete_users", label: "完善资料", type: "count" },
|
||||
{ key: "first_room_join_users", label: "首次进房", type: "count" }
|
||||
],
|
||||
key: "new_users",
|
||||
label: "新用户"
|
||||
},
|
||||
{
|
||||
csvName: "p1-revenue",
|
||||
fallbackColumns: [
|
||||
@ -68,25 +57,21 @@ const REQUIREMENT_SECTIONS = [
|
||||
{ key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
|
||||
],
|
||||
key: "revenue",
|
||||
label: "营收",
|
||||
label: "充值",
|
||||
newUserFilter: true,
|
||||
payerFilter: true,
|
||||
roleFilter: true
|
||||
},
|
||||
{
|
||||
csvName: "p2-retention",
|
||||
csvName: "p0-new-users",
|
||||
fallbackColumns: [
|
||||
{ key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" },
|
||||
{ key: "active_d1_active_users", label: "次留人数", type: "count" },
|
||||
{ key: "active_d1_active_rate", label: "用户次留", type: "ratio" },
|
||||
{ key: "active_d7_active_rate", label: "用户7日留", type: "ratio" },
|
||||
{ key: "active_d30_active_rate", label: "用户30日留", type: "ratio" }
|
||||
{ key: "app_first_open_users", label: "下载APP人数", type: "count" },
|
||||
{ key: "new_users", label: "新增注册", type: "count" },
|
||||
{ key: "profile_complete_users", label: "完善资料", type: "count" },
|
||||
{ key: "first_room_join_users", label: "首次进房", type: "count" }
|
||||
],
|
||||
key: "retention",
|
||||
label: "留存",
|
||||
newUserFilter: true,
|
||||
payerFilter: true,
|
||||
roleFilter: true
|
||||
key: "new_users",
|
||||
label: "新增"
|
||||
},
|
||||
{
|
||||
csvName: "p2-active",
|
||||
@ -98,7 +83,7 @@ const REQUIREMENT_SECTIONS = [
|
||||
{ key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
|
||||
],
|
||||
key: "active",
|
||||
label: "活跃",
|
||||
label: "日活",
|
||||
newUserFilter: true,
|
||||
payerFilter: true,
|
||||
roleFilter: true
|
||||
@ -112,7 +97,7 @@ const REQUIREMENT_SECTIONS = [
|
||||
{ key: "gift_paid_users", label: "付费送礼用户", type: "count" }
|
||||
],
|
||||
key: "gift",
|
||||
label: "送礼",
|
||||
label: "礼物",
|
||||
newUserFilter: true,
|
||||
roleFilter: true
|
||||
},
|
||||
@ -129,6 +114,21 @@ const REQUIREMENT_SECTIONS = [
|
||||
label: "游戏",
|
||||
newUserFilter: true,
|
||||
roleFilter: true
|
||||
},
|
||||
{
|
||||
csvName: "p2-retention",
|
||||
fallbackColumns: [
|
||||
{ key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" },
|
||||
{ key: "active_d1_active_users", label: "次留人数", type: "count" },
|
||||
{ key: "active_d1_active_rate", label: "用户次留", type: "ratio" },
|
||||
{ key: "active_d7_active_rate", label: "用户7日留", type: "ratio" },
|
||||
{ key: "active_d30_active_rate", label: "用户30日留", type: "ratio" }
|
||||
],
|
||||
key: "retention",
|
||||
label: "留存",
|
||||
newUserFilter: true,
|
||||
payerFilter: true,
|
||||
roleFilter: true
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
// 数据契约见 SocialBiApp.jsx 的 useSocialBi();本视图只做展示与聚合,不直接调 API。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import { EChart } from "../../charts/EChart.jsx";
|
||||
import { deltaDirection, formatCount, formatDeltaRate, formatMoney, isBlank } from "../format.js";
|
||||
import { deltaDirection, formatCount, formatDeltaRate, formatMoney, formatPercentValue, isBlank } from "../format.js";
|
||||
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
|
||||
import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
|
||||
import { bucketDay } from "../state.js";
|
||||
@ -19,10 +20,12 @@ const HERO_METRICS = [
|
||||
];
|
||||
|
||||
const TREND_METRICS = [
|
||||
{ key: "recharge_usd_minor", label: "充值" },
|
||||
{ key: "new_users", label: "新增" },
|
||||
{ key: "active_users", label: "日活" },
|
||||
{ key: "gift_coin_spent", label: "礼物流水" }
|
||||
{ key: "recharge_usd_minor", label: "充值", requirementSection: "revenue" },
|
||||
{ key: "new_users", label: "新增", requirementSection: "new_users" },
|
||||
{ key: "active_users", label: "日活", requirementSection: "active" },
|
||||
{ key: "gift_coin_spent", label: "礼物", requirementSection: "gift" },
|
||||
{ key: "game_turnover", label: "游戏", requirementSection: "game" },
|
||||
{ key: "d1_retention_rate", label: "留存", requirementSection: "retention" }
|
||||
];
|
||||
|
||||
const COMPARE_METRICS = [
|
||||
@ -40,12 +43,16 @@ const MONEY_METRIC_KEYS = new Set([
|
||||
"mifapay_recharge_usd_minor",
|
||||
"coin_seller_recharge_usd_minor"
|
||||
]);
|
||||
const RATIO_METRIC_KEYS = new Set(["d1_retention_rate"]);
|
||||
|
||||
// 图表值已由 metricChartValue 换算(金额→美元),tooltip 里按指标类型补格式。
|
||||
function formatChartAmount(metricKey, value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
if (RATIO_METRIC_KEYS.has(metricKey)) {
|
||||
return formatPercentValue(value);
|
||||
}
|
||||
return MONEY_METRIC_KEYS.has(metricKey) ? formatMoney(value) : formatCount(value);
|
||||
}
|
||||
|
||||
@ -63,6 +70,7 @@ function buildBuckets(rows, granularity) {
|
||||
}
|
||||
|
||||
function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
|
||||
const isRatioMetric = RATIO_METRIC_KEYS.has(valueMetric);
|
||||
return {
|
||||
grid: { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 },
|
||||
legend: { itemHeight: 10, itemWidth: 10, left: 0, textStyle: { color: "#5b7089", fontSize: 12 }, top: 0 },
|
||||
@ -82,7 +90,7 @@ function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
|
||||
return `${item.marker}${item.seriesName} <b>${formatChartAmount(valueMetric, numeric)}</b>`;
|
||||
});
|
||||
const header = list[0]?.axisValueLabel || list[0]?.name || "";
|
||||
if (list.length > 1) {
|
||||
if (list.length > 1 && !isRatioMetric) {
|
||||
lines.push(`合计 <b>${formatChartAmount(valueMetric, total)}</b>`);
|
||||
}
|
||||
return [header, ...lines].filter(Boolean).join("<br/>");
|
||||
@ -98,7 +106,7 @@ function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
|
||||
type: "category"
|
||||
},
|
||||
yAxis: {
|
||||
axisLabel: { color: "#5b7089", fontSize: 11 },
|
||||
axisLabel: { color: "#5b7089", fontSize: 11, ...(isRatioMetric ? { formatter: "{value}%" } : {}) },
|
||||
splitLine: { lineStyle: { color: "#eef3f9" } },
|
||||
type: "value"
|
||||
}
|
||||
@ -228,12 +236,13 @@ export function OverviewView() {
|
||||
}, [appCodes, buckets]);
|
||||
|
||||
const trendOption = useMemo(() => {
|
||||
const shouldStack = !RATIO_METRIC_KEYS.has(trendMetric);
|
||||
const series = appBuckets.apps.map((app) => ({
|
||||
barMaxWidth: 26,
|
||||
data: app.cells.map((cell) => (cell ? metricChartValue(trendMetric, cell[trendMetric]) : null)),
|
||||
itemStyle: { color: appColor(app.app_code, appCodes) },
|
||||
name: app.app_name,
|
||||
stack: "total",
|
||||
...(shouldStack ? { stack: "total" } : {}),
|
||||
type: "bar"
|
||||
}));
|
||||
return buildStackedBarOption({ bucketKeys: appBuckets.keys, series, valueMetric: trendMetric });
|
||||
@ -296,7 +305,7 @@ export function OverviewView() {
|
||||
const soloApp = !derived.regionScoped && derived.appTotals.length === 1 ? derived.appTotals[0] : null;
|
||||
const hasRestricted = derived.appTotals.some((row) => row.restricted) || derived.appErrors.some((row) => row.restricted);
|
||||
const hasTrend = appBuckets.keys.length > 0 && appBuckets.apps.length > 0;
|
||||
const trendLabel = TREND_METRICS.find((item) => item.key === trendMetric)?.label || "";
|
||||
const trendConfig = TREND_METRICS.find((item) => item.key === trendMetric) || TREND_METRICS[0];
|
||||
|
||||
if (isLoading && !derived.appTotals.length && !derived.regionTotals.length) {
|
||||
return <OverviewSkeleton />;
|
||||
@ -329,29 +338,32 @@ export function OverviewView() {
|
||||
|
||||
<section className="sbi-card">
|
||||
<header className="sbi-card-header">
|
||||
<strong>{trendLabel}趋势</strong>
|
||||
<small>按 App 堆叠</small>
|
||||
<strong>{trendConfig.label}趋势</strong>
|
||||
<small>{RATIO_METRIC_KEYS.has(trendMetric) ? "按 App 对比" : "按 App 堆叠"}</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="group" aria-label="趋势指标与明细入口">
|
||||
{/* 趋势 Tab 与数据需求分组一一映射,按钮始终跟随当前指标进入对应明细。 */}
|
||||
<Button
|
||||
onClick={() => openRequirementSection(trendConfig.requirementSection)}
|
||||
size="small"
|
||||
title={`进入数据需求中的${trendConfig.label}明细`}
|
||||
variant="outlined"
|
||||
>
|
||||
查看明细
|
||||
</Button>
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="趋势指标">
|
||||
{TREND_METRICS.map((item) => (
|
||||
<button
|
||||
aria-pressed={trendMetric === item.key}
|
||||
aria-checked={trendMetric === item.key}
|
||||
className={trendMetric === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => setTrendMetric(item.key)}
|
||||
role="radio"
|
||||
title={metricTooltip(item.key)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => openRequirementSection("game")}
|
||||
title="查看数据需求中的游戏数据"
|
||||
type="button"
|
||||
>
|
||||
游戏流水
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>filter-bar repro</title>
|
||||
<style>html { background: #0d0d11; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="external-admin-root"></div>
|
||||
<script type="module" src="/external-admin/__repro__.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,42 +0,0 @@
|
||||
// Untracked verification harness: renders the real UsersPage (filter bar included)
|
||||
// with the production theme, emotion cache and stylesheets, but without routing/auth
|
||||
// guards, so layout can be measured at arbitrary viewport widths. Delete after use.
|
||||
import { CacheProvider } from "@emotion/react";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import React, { useMemo } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { createExternalTheme } from "./src/theme.js";
|
||||
import { ExternalAuthProvider } from "./src/auth/ExternalAuthProvider.jsx";
|
||||
import { ExternalI18nProvider, useExternalI18n } from "./src/i18n/ExternalI18nProvider.jsx";
|
||||
import { externalEmotionCache } from "./src/i18n/emotionCache.js";
|
||||
import UsersPage from "./src/pages/UsersPage.jsx";
|
||||
import "@/styles/tokens.css";
|
||||
import "@/styles/shared-ui.css";
|
||||
import "./src/styles/index.css";
|
||||
|
||||
createRoot(document.getElementById("external-admin-root")).render(
|
||||
<React.StrictMode>
|
||||
<ExternalI18nProvider>
|
||||
<Root />
|
||||
</ExternalI18nProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
function Root() {
|
||||
const { direction } = useExternalI18n();
|
||||
const theme = useMemo(() => createExternalTheme(direction), [direction]);
|
||||
return (
|
||||
<CacheProvider value={externalEmotionCache(direction)}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ToastProvider>
|
||||
<CssBaseline />
|
||||
<ExternalAuthProvider>
|
||||
<UsersPage />
|
||||
</ExternalAuthProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</CacheProvider>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html dir="ltr" lang="en">
|
||||
<html data-external-admin-theme="black-gold" dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0d0d11" />
|
||||
<title>Admin System</title>
|
||||
<title>External Admin</title>
|
||||
<style>html { background: #0d0d11; }</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { RequireExternalAuth, RequireExternalCapability, RequirePasswordChanged } from "./auth/RouteGuards.jsx";
|
||||
import { RequireExternalAuth, RequireExternalCapability } from "./auth/RouteGuards.jsx";
|
||||
import { ExternalAdminLayout } from "./layout/ExternalAdminLayout.jsx";
|
||||
import { ExternalPageState } from "./shared/PageComponents.jsx";
|
||||
|
||||
@ -21,30 +21,28 @@ export function ExternalAdminApp() {
|
||||
<Route element={<LoginPage />} path="/login" />
|
||||
<Route element={<RequireExternalAuth />}>
|
||||
<Route element={<ChangePasswordPage />} path="/change-password" />
|
||||
<Route element={<RequirePasswordChanged />}>
|
||||
<Route element={<ExternalAdminLayout />}>
|
||||
<Route element={<Navigate replace to="/overview" />} index />
|
||||
<Route element={<OverviewPage />} path="/overview" />
|
||||
<Route element={<RequireExternalCapability anyOf={["user:list", "user:update", "user:ban", "user-level:grant"]} />}>
|
||||
<Route element={<UsersPage />} path="/users" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["user-ban:list", "user:unban"]} />}>
|
||||
<Route element={<BansPage />} path="/bans" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", "team:view"]} />}>
|
||||
<Route element={<OrganizationPage />} path="/organization/:kind" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["room:list", "room:update"]} />}>
|
||||
<Route element={<RoomsPage />} path="/rooms" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"]} />}>
|
||||
<Route element={<GrantsPage />} path="/grants" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["banner:create"]} />}>
|
||||
<Route element={<BannersPage />} path="/banners" />
|
||||
</Route>
|
||||
<Route element={<Navigate replace to="/overview" />} path="*" />
|
||||
<Route element={<ExternalAdminLayout />}>
|
||||
<Route element={<Navigate replace to="/overview" />} index />
|
||||
<Route element={<OverviewPage />} path="/overview" />
|
||||
<Route element={<RequireExternalCapability anyOf={["user:list", "user:update", "user:ban", "user-level:grant"]} />}>
|
||||
<Route element={<UsersPage />} path="/users" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["user-ban:list", "user:unban"]} />}>
|
||||
<Route element={<BansPage />} path="/bans" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", "team:view"]} />}>
|
||||
<Route element={<OrganizationPage />} path="/organization/:kind" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["room:list", "room:update"]} />}>
|
||||
<Route element={<RoomsPage />} path="/rooms" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"]} />}>
|
||||
<Route element={<GrantsPage />} path="/grants" />
|
||||
</Route>
|
||||
<Route element={<RequireExternalCapability anyOf={["banner:create"]} />}>
|
||||
<Route element={<BannersPage />} path="/banners" />
|
||||
</Route>
|
||||
<Route element={<Navigate replace to="/overview" />} path="*" />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@ -66,6 +66,7 @@ export function normalizeSession(data) {
|
||||
account: textValue(accountRecord.username, accountRecord.account, legacyAccount.username, legacyAccount.account, root.accountName, typeof root.account === "string" ? root.account : "", root.username),
|
||||
appCode: textValue(root.appCode, root.app_code, app.appCode, app.app_code, accountRecord.appCode, accountRecord.app_code, linkedUser.appCode, linkedUser.app_code),
|
||||
appName: textValue(app.name, app.appName, app.app_name, root.appName, root.app_name),
|
||||
logoUrl: textValue(app.logoUrl, app.logo_url, root.logoUrl, root.logo_url),
|
||||
capabilities: [...new Set(capabilities)],
|
||||
displayName: textValue(linkedUser.displayName, linkedUser.display_name, linkedUser.name, linkedUser.username, accountRecord.username, legacyAccount.username, root.displayName),
|
||||
id: textValue(accountRecord.id, accountRecord.externalAdminUserId, accountRecord.external_admin_user_id, legacyAccount.id, root.accountId, root.account_id),
|
||||
|
||||
@ -9,13 +9,14 @@ describe("normalizeSession", () => {
|
||||
test("normalizes account objects, fixed app and capabilities without stringifying objects", () => {
|
||||
expect(normalizeSession({
|
||||
account: { id: "ext-1", username: "fami-manager" },
|
||||
app: { appCode: "fami", appName: "Fami" },
|
||||
app: { appCode: "fami", appName: "Fami", logoUrl: "https://media.example.com/fami.png" },
|
||||
capabilities: ["user:list"],
|
||||
passwordChangeRequired: true
|
||||
})).toEqual(expect.objectContaining({
|
||||
account: "fami-manager",
|
||||
appCode: "fami",
|
||||
appName: "Fami",
|
||||
logoUrl: "https://media.example.com/fami.png",
|
||||
capabilities: ["user:list"],
|
||||
passwordChangeRequired: true
|
||||
}));
|
||||
|
||||
@ -38,11 +38,6 @@ export function RequireExternalAuth() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function RequirePasswordChanged() {
|
||||
const { session } = useExternalAuth();
|
||||
return session?.passwordChangeRequired ? <Navigate replace to="/change-password" /> : <Outlet />;
|
||||
}
|
||||
|
||||
export function RequireExternalCapability({ anyOf }) {
|
||||
const { session } = useExternalAuth();
|
||||
const { t } = useExternalI18n();
|
||||
|
||||
@ -22,7 +22,7 @@ const displayScopeOptions = [
|
||||
];
|
||||
|
||||
export function BannerCreateDialog({ loading, onClose, onSubmit, open }) {
|
||||
const { locale, t } = useExternalI18n();
|
||||
const { direction, locale, t } = useExternalI18n();
|
||||
const [form, setForm] = useState(initialForm);
|
||||
useEffect(() => setForm(initialForm), [open]);
|
||||
|
||||
@ -57,6 +57,7 @@ export function BannerCreateDialog({ loading, onClose, onSubmit, open }) {
|
||||
messages={appJumpMessages(t)}
|
||||
paramValue={form.param}
|
||||
required
|
||||
showHint={false}
|
||||
typeLabel={t("banners.jumpType")}
|
||||
typeValue={form.bannerType}
|
||||
onChange={({ param, type }) => setForm({ ...form, bannerType: type, param })}
|
||||
@ -78,6 +79,7 @@ export function BannerCreateDialog({ loading, onClose, onSubmit, open }) {
|
||||
<TextField disabled={loading} label={t("banners.sort")} slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.sortOrder} onChange={(event) => setForm({ ...form, sortOrder: event.target.value })} />
|
||||
</Stack>
|
||||
<TimeRangeFilter
|
||||
direction={direction}
|
||||
disabled={loading}
|
||||
label={t("banners.schedule")}
|
||||
locale={locale}
|
||||
@ -119,7 +121,6 @@ function appJumpMessages(t) {
|
||||
appTarget: t("jump.appTarget"),
|
||||
gameId: t("jump.gameId"),
|
||||
giftId: t("jump.giftId"),
|
||||
hint: t("jump.hint"),
|
||||
redPacketNo: t("jump.redPacketNo"),
|
||||
roomId: t("jump.roomId"),
|
||||
targets: Object.fromEntries(["wallet", "room", "room_random", "room_window", "room_random_window", "room_game", "room_random_game", "explore_game", "custom"].map((value) => [value, t(`jump.target.${value}`)])),
|
||||
|
||||
@ -16,5 +16,6 @@ describe("BannerCreateDialog", () => {
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "App destination" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Parameter (App navigation JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" }));
|
||||
expect(screen.queryByText("App navigation parameters are generated as JSON. You can edit the JSON directly for custom parameters.")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
@ -16,7 +15,6 @@ export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resource
|
||||
useEffect(() => setForm(resourceGrantInitial), [open]);
|
||||
return (
|
||||
<GrantDialog loading={loading} open={open} title={t("grants.resourceTitle")} onClose={onClose} onSubmit={() => onSubmit(form)}>
|
||||
<TargetNotice />
|
||||
<TextField disabled={loading} label={t("grants.target")} required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
|
||||
<TextField disabled={loading} label={t("grants.resource")} required select value={form.resourceId} onChange={(event) => setForm({ ...form, resourceId: event.target.value })}>
|
||||
{resources.map((item) => <MenuItem key={item.id} value={item.id}>{item.name} ({item.id})</MenuItem>)}
|
||||
@ -34,7 +32,6 @@ export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) {
|
||||
useEffect(() => setForm(prettyGrantInitial), [open]);
|
||||
return (
|
||||
<GrantDialog loading={loading} open={open} title={t("grants.prettyIdTitle")} onClose={onClose} onSubmit={() => onSubmit(form)}>
|
||||
<TargetNotice />
|
||||
<TextField disabled={loading} label={t("grants.currentTarget")} required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
|
||||
<TextField disabled={loading} label={t("grants.prettyId")} required slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }} value={form.prettyId} onChange={(event) => setForm({ ...form, prettyId: event.target.value.trim() })} />
|
||||
<TextField disabled={loading} helperText={t("grants.permanentHint")} label={t("grants.validDays")} required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
|
||||
@ -43,11 +40,6 @@ export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TargetNotice() {
|
||||
const { t } = useExternalI18n();
|
||||
return <Alert severity="info">{t("grants.notice")}</Alert>;
|
||||
}
|
||||
|
||||
function GrantDialog({ children, loading, onClose, onSubmit, open, title }) {
|
||||
const { t } = useExternalI18n();
|
||||
return (
|
||||
|
||||
17
external-admin/src/components/GrantDialogs.test.jsx
Normal file
17
external-admin/src/components/GrantDialogs.test.jsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx";
|
||||
import { ResourceGrantDialog } from "./GrantDialogs.jsx";
|
||||
|
||||
test("shows only actionable grant fields without a descriptive notice", () => {
|
||||
render(
|
||||
<ExternalI18nProvider>
|
||||
<ResourceGrantDialog loading={false} onClose={vi.fn()} onSubmit={vi.fn()} open resources={[]} />
|
||||
</ExternalI18nProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Grant privilege item" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Before submission, the user is verified in the current App and the server-returned user ID is used for the grant.")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Enter 0 for permanent validity")).toBeInTheDocument();
|
||||
});
|
||||
@ -41,7 +41,6 @@ export function ExternalI18nProvider({ children }) {
|
||||
root.lang = locale;
|
||||
root.dir = direction;
|
||||
globalThis.document.body?.setAttribute("dir", direction);
|
||||
globalThis.document.title = t("app.title");
|
||||
}, [direction, locale, t]);
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@ -16,7 +16,7 @@ describe("ExternalI18nProvider", () => {
|
||||
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
||||
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent("en");
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("Admin System");
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("External Admin");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "en");
|
||||
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
||||
});
|
||||
@ -26,25 +26,25 @@ describe("ExternalI18nProvider", () => {
|
||||
const firstRender = render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "ar" }));
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("نظام الإدارة");
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("الإدارة الخارجية");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "ar");
|
||||
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
||||
expect(document.body).toHaveAttribute("dir", "rtl");
|
||||
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("ar");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "tr" }));
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("Yönetim Sistemi");
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("Harici Yönetim");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "tr");
|
||||
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "zh-CN" }));
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("管理系统");
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("外管后台");
|
||||
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("zh-CN");
|
||||
|
||||
firstRender.unmount();
|
||||
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN");
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("管理系统");
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("外管后台");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
25
external-admin/src/i18n/emotionCache.integration.test.jsx
Normal file
25
external-admin/src/i18n/emotionCache.integration.test.jsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { CacheProvider } from "@emotion/react";
|
||||
import Button from "@mui/material/Button";
|
||||
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
import { externalEmotionCache } from "./emotionCache.js";
|
||||
|
||||
test("keeps MUI mounted when the live page switches from LTR to RTL", () => {
|
||||
const view = render(<DirectionProbe direction="ltr" />);
|
||||
expect(screen.getByRole("button", { name: "ltr" })).toBeInTheDocument();
|
||||
|
||||
// Exercise Emotion's real insert pipeline: a standalone Stylis serializer test cannot detect a plugin resolved from a second, incompatible Stylis version.
|
||||
expect(() => view.rerender(<DirectionProbe direction="rtl" />)).not.toThrow();
|
||||
expect(screen.getByRole("button", { name: "rtl" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function DirectionProbe({ direction }) {
|
||||
return (
|
||||
<CacheProvider value={externalEmotionCache(direction)}>
|
||||
<ThemeProvider theme={createTheme({ direction })}>
|
||||
<Button sx={{ marginLeft: 1, paddingRight: 2 }}>{direction}</Button>
|
||||
</ThemeProvider>
|
||||
</CacheProvider>
|
||||
);
|
||||
}
|
||||
@ -10,8 +10,8 @@ export const EXTERNAL_LANGUAGE_OPTIONS = Object.freeze([
|
||||
]);
|
||||
|
||||
const en = {
|
||||
"app.title": "Admin System",
|
||||
"app.shortTitle": "Admin System",
|
||||
"app.title": "External Admin",
|
||||
"app.shortTitle": "External Admin",
|
||||
"language.label": "Language",
|
||||
"common.actions": "Actions",
|
||||
"common.active": "Active",
|
||||
@ -41,7 +41,6 @@ const en = {
|
||||
"auth.confirmNewPassword": "Confirm new password",
|
||||
"auth.currentPassword": "Current password",
|
||||
"auth.fillAccountPassword": "Enter your account and password",
|
||||
"auth.firstLoginChange": "You must change your password on first sign-in",
|
||||
"auth.hidePassword": "Hide password",
|
||||
"auth.invalidCredentials": "Incorrect account or password",
|
||||
"auth.login": "Sign in",
|
||||
@ -52,8 +51,6 @@ const en = {
|
||||
"auth.password": "Password",
|
||||
"auth.passwordChangedFailed": "Failed to change password",
|
||||
"auth.passwordMismatch": "The new passwords do not match",
|
||||
"auth.passwordMin": "At least 8 characters",
|
||||
"auth.passwordMinError": "The new password must be at least 8 characters",
|
||||
"auth.passwordWhitespace": "The new password cannot be empty or contain only whitespace",
|
||||
"auth.recheck": "Check again",
|
||||
"auth.returnOverview": "Back to Home",
|
||||
@ -194,7 +191,6 @@ const en = {
|
||||
"grants.validDays": "Validity (days)",
|
||||
"grants.permanentHint": "Enter 0 for permanent validity",
|
||||
"grants.reason": "Grant reason",
|
||||
"grants.notice": "Before submission, the user is verified in the current App and the server-returned user ID is used for the grant.",
|
||||
"grants.confirm": "Confirm grant",
|
||||
"banners.title": "Banner management",
|
||||
"banners.banner": "Banner",
|
||||
@ -260,7 +256,6 @@ const en = {
|
||||
"jump.gameId": "Game ID",
|
||||
"jump.giftId": "Gift ID",
|
||||
"jump.redPacketNo": "Red packet number",
|
||||
"jump.hint": "App navigation parameters are generated as JSON. You can edit the JSON directly for custom parameters.",
|
||||
"time.unlimited": "Any time",
|
||||
"time.quickHour": "Last hour",
|
||||
"time.quickDay": "Last 24 hours",
|
||||
@ -326,11 +321,11 @@ const en = {
|
||||
};
|
||||
|
||||
const zhCN = {
|
||||
"app.title": "管理系统",
|
||||
"app.shortTitle": "管理系统",
|
||||
"app.title": "外管后台",
|
||||
"app.shortTitle": "外管后台",
|
||||
"language.label": "语言",
|
||||
"common.actions": "操作", "common.active": "正常", "common.all": "全部", "common.allStatuses": "全部状态", "common.banned": "已封禁", "common.cancel": "取消", "common.confirm": "确认", "common.create": "创建", "common.disabled": "禁用", "common.edit": "编辑", "common.enabled": "启用", "common.expired": "过期", "common.loading": "正在加载", "common.noData": "当前无数据", "common.normal": "正常", "common.query": "查询", "common.reason": "原因", "common.reload": "重新加载", "common.retry": "重试", "common.save": "保存", "common.status": "状态", "common.user": "用户",
|
||||
"auth.account": "账号", "auth.changePassword": "修改密码", "auth.checkingSession": "正在校验会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.firstLoginChange": "首次登录必须修改密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordMin": "至少 8 个字符", "auth.passwordMinError": "新密码至少 8 个字符", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回首页", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码",
|
||||
"auth.account": "账号", "auth.changePassword": "修改密码", "auth.checkingSession": "正在校验会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回首页", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码",
|
||||
"nav.expand": "展开菜单", "nav.more": "更多", "nav.overview": "首页", "nav.users": "用户管理", "nav.bans": "封禁管理", "nav.hosts": "主播列表", "nav.agencies": "公会列表", "nav.bds": "BD 列表", "nav.bdManagers": "BD Manager 列表", "nav.superAdmins": "Super Admin 列表", "nav.rooms": "房间管理", "nav.grants": "资源与靓号", "nav.banners": "Banner 管理", "nav.team": "我的团队",
|
||||
"capability.userList": "用户列表", "capability.userUpdate": "用户信息编辑", "capability.banList": "账号封禁列表", "capability.ban": "执行封禁", "capability.unban": "解除封禁", "capability.hostList": "主播列表", "capability.agencyList": "公会列表", "capability.bdList": "BD 列表", "capability.bdManagerList": "BD Manager 列表", "capability.superAdminList": "Super Admin 列表", "capability.roomList": "房间管理", "capability.roomUpdate": "房间编辑", "capability.privilegeList": "用户特权道具列表", "capability.privilegeGrant": "特权道具发放", "capability.bannerCreate": "Banner 创建", "capability.prettyIdGrant": "靓号下发", "capability.userTitleGrant": "用户称号下发", "capability.roomBackgroundGrant": "房间背景图下发", "capability.userLevelGrant": "财富/VIP 等级下发", "capability.teamView": "我的团队",
|
||||
"overview.currentApp": "当前 App", "overview.account": "登录账号", "overview.permissions": "已开通权限", "overview.welcome": "欢迎回来", "overview.features": "功能入口", "overview.enabled": "已开通", "overview.notEnabled": "未开通",
|
||||
@ -338,21 +333,21 @@ const zhCN = {
|
||||
"bans.title": "账号封禁列表", "bans.reason": "封禁原因", "bans.unban": "解除封禁", "bans.unbanned": "用户已解除封禁", "bans.unbanFailed": "解除封禁失败", "bans.confirmTitle": "解除封禁", "bans.confirmContent": "确认解除用户 {user} 的封禁?",
|
||||
"organization.title": "组织管理", "organization.agencies": "公会列表", "organization.bds": "BD 列表", "organization.bdManagers": "BD Manager 列表", "organization.hosts": "主播列表", "organization.managers": "管理员列表", "organization.superAdmins": "Super Admin 列表", "organization.team": "我的团队", "organization.agency": "公会", "organization.owner": "负责人", "organization.parentBd": "上级 BD", "organization.member": "成员", "organization.country": "国家/地区", "organization.userReference": "用户 {id}", "organization.agencyReference": "公会 {id}", "organization.teamSearch": "公会 ID / 负责人用户 ID / 上级 BD ID", "organization.noPermission": "当前账号没有访问该组织列表的权限。", "organization.bdLeaderCount": "BD Leader:{count}", "organization.bdCount": "BD:{count}", "organization.agencyCount": "公会:{count}", "organization.truncated": "团队规模超过接口上限,当前结果不完整",
|
||||
"rooms.title": "房间管理", "rooms.room": "房间", "rooms.owner": "房主", "rooms.visibleRegion": "可见区域", "rooms.search": "房间 ID / 名称 / 房主", "rooms.updated": "房间信息已更新", "rooms.updateFailed": "房间更新失败", "rooms.editTitle": "编辑房间", "rooms.background": "房间背景图", "rooms.name": "房间名称", "rooms.description": "房间介绍", "rooms.visibleRegionId": "可见区域 ID", "rooms.allRegions": "全部",
|
||||
"grants.title": "资源与靓号", "grants.grantResource": "发放道具", "grants.grantPrettyId": "下发靓号", "grants.availableResources": "可发放资源", "grants.targetUser": "目标用户", "grants.resource": "资源", "grants.quantity": "数量", "grants.resourceGranted": "特权道具已发放", "grants.prettyIdGranted": "靓号已下发", "grants.failed": "发放失败", "grants.resourceTitle": "特权道具发放", "grants.prettyIdTitle": "靓号下发", "grants.target": "用户 ID / 短 ID(靓号)", "grants.currentTarget": "用户 ID / 当前短 ID", "grants.prettyId": "下发靓号", "grants.validDays": "有效天数", "grants.permanentHint": "填 0 表示永久有效", "grants.reason": "发放原因", "grants.notice": "提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。", "grants.confirm": "确认发放",
|
||||
"grants.title": "资源与靓号", "grants.grantResource": "发放道具", "grants.grantPrettyId": "下发靓号", "grants.availableResources": "可发放资源", "grants.targetUser": "目标用户", "grants.resource": "资源", "grants.quantity": "数量", "grants.resourceGranted": "特权道具已发放", "grants.prettyIdGranted": "靓号已下发", "grants.failed": "发放失败", "grants.resourceTitle": "特权道具发放", "grants.prettyIdTitle": "靓号下发", "grants.target": "用户 ID / 短 ID(靓号)", "grants.currentTarget": "用户 ID / 当前短 ID", "grants.prettyId": "下发靓号", "grants.validDays": "有效天数", "grants.permanentHint": "填 0 表示永久有效", "grants.reason": "发放原因", "grants.confirm": "确认发放",
|
||||
"banners.title": "Banner 管理", "banners.banner": "Banner", "banners.type": "类型", "banners.platform": "平台", "banners.sort": "排序", "banners.create": "创建 Banner", "banners.created": "Banner 已创建", "banners.createFailed": "Banner 创建失败", "banners.image": "Banner 图片", "banners.roomImage": "房间内小屏图", "banners.description": "描述", "banners.jumpType": "跳转类型", "banners.h5Param": "参数(H5 链接)", "banners.appParam": "参数(APP 公共跳转 JSON)", "banners.displayScope": "展示范围", "banners.scopeHome": "首页", "banners.scopeRoom": "房间", "banners.scopeRecharge": "充值", "banners.scopeMe": "我的", "banners.regionId": "区域 ID", "banners.countryCode": "国家码", "banners.countryCodeHint": "2-3 位字母,留空为全部", "banners.schedule": "投放时间",
|
||||
"upload.preview": "{label}预览", "upload.empty": "暂无图片", "upload.uploading": "上传中", "upload.replace": "更换图片", "upload.select": "上传图片", "upload.imageOnly": "请选择图片文件", "upload.sizeLimit": "图片不能超过 10MB", "upload.missingUrl": "上传结果缺少图片地址", "upload.failed": "图片上传失败",
|
||||
"pagination.total": "共 {count} 条", "pagination.previous": "上一页", "pagination.next": "下一页",
|
||||
"status.active": "正常", "status.enabled": "启用", "status.running": "运行中", "status.normal": "正常", "status.unbanned": "未封禁", "status.banned": "已封禁", "status.disabled": "禁用", "status.failed": "失败", "status.blocked": "已拦截", "status.expired": "过期",
|
||||
"jump.appTarget": "APP 目标", "jump.target.wallet": "钱包", "jump.target.room": "指定房间", "jump.target.room_random": "随机房间", "jump.target.room_window": "指定房内拉起窗口", "jump.target.room_random_window": "随机房内拉起窗口", "jump.target.room_game": "指定房内拉起游戏", "jump.target.room_random_game": "随机房内拉起游戏", "jump.target.explore_game": "Explore 游戏 H5", "jump.target.custom": "自定义 JSON", "jump.roomId": "房间 ID", "jump.window": "房内窗口", "jump.window.game_window": "游戏窗口", "jump.window.gift_panel": "礼物面板", "jump.window.red_packet_claim": "领红包弹窗", "jump.window.wheel": "转盘窗口", "jump.gameId": "游戏 ID", "jump.giftId": "礼物 ID", "jump.redPacketNo": "红包编号", "jump.hint": "APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。",
|
||||
"jump.appTarget": "APP 目标", "jump.target.wallet": "钱包", "jump.target.room": "指定房间", "jump.target.room_random": "随机房间", "jump.target.room_window": "指定房内拉起窗口", "jump.target.room_random_window": "随机房内拉起窗口", "jump.target.room_game": "指定房内拉起游戏", "jump.target.room_random_game": "随机房内拉起游戏", "jump.target.explore_game": "Explore 游戏 H5", "jump.target.custom": "自定义 JSON", "jump.roomId": "房间 ID", "jump.window": "房内窗口", "jump.window.game_window": "游戏窗口", "jump.window.gift_panel": "礼物面板", "jump.window.red_packet_claim": "领红包弹窗", "jump.window.wheel": "转盘窗口", "jump.gameId": "游戏 ID", "jump.giftId": "礼物 ID", "jump.redPacketNo": "红包编号",
|
||||
"time.unlimited": "不限", "time.quickHour": "近 1 小时", "time.quickDay": "近 24 小时", "time.quickWeek": "近 7 天", "time.start": "开始时间", "time.end": "结束时间", "time.startDate": "开始日期", "time.endDate": "结束日期", "time.pending": "待选择", "time.startMark": "始", "time.endMark": "末", "time.previousMonth": "上个月", "time.nextMonth": "下个月", "time.hour": "时", "time.minute": "分", "time.clear": "清空", "time.apply": "确定", "time.endAfterStart": "结束时间必须晚于开始时间", "time.endDateAfterStart": "结束日期不能早于开始日期", "time.completeRange": "选择完整区间", "time.endLaterShort": "结束需晚于开始", "time.endDateLaterShort": "结束不能早于开始", "time.durationDays": "{count} 天", "time.durationHours": "{count} 小时", "time.durationMinutes": "{count} 分钟", "time.duration": "间隔 {value}", "time.sameDay": "同一天", "time.lessMinute": "间隔少于 1 分钟",
|
||||
"errors.loadFailed": "数据加载失败", "errors.requestFailed": "请求失败", "errors.unauthorized": "会话已失效,请重新登录。", "errors.forbidden": "你没有执行此操作的权限。", "errors.notFound": "未找到请求的资源。", "errors.conflict": "当前数据状态存在冲突,请刷新后重试。", "errors.tooLarge": "请求内容过大。", "errors.rateLimited": "请求过于频繁,请稍后重试。", "errors.server": "服务暂时不可用,请稍后重试。", "errors.accountLocked": "账号已临时锁定,请稍后重试。", "errors.appInactive": "该账号关联的 App 已停用。", "errors.vipDisabled": "当前 App 的 VIP 体系已停用", "errors.vipLevelInvalid": "请选择当前 App 已配置的 VIP 等级", "errors.organizationRoute": "该组织列表尚无安全的数据接口", "errors.regionId": "可见区域 ID 必须是安全的非负整数", "errors.grantTarget": "未找到当前 App 内的目标用户", "errors.bannerType": "Banner 类型不正确", "errors.bannerPlatform": "Banner 平台不正确", "errors.bannerStatus": "Banner 状态不正确", "errors.bannerScope": "请至少选择一个展示范围", "errors.bannerImage": "请上传 Banner 图片", "errors.h5Url": "请填写合法的 H5 链接", "errors.h5Protocol": "H5 链接仅支持 http 或 https", "errors.appParamRequired": "请填写 APP 跳转参数", "errors.appParamObject": "APP 跳转参数必须是 JSON 对象", "errors.appParamType": "APP 跳转 type 不在公共跳转方案内", "errors.appParamRoom": "该 APP 跳转需要填写 room_id", "errors.appParamGame": "该 APP 跳转需要填写 game_id", "errors.appParamWindow": "该 APP 跳转需要填写有效 window", "errors.appParamGameCode": "Explore 游戏 H5 需要选择有效 game_code", "errors.roomBannerImage": "房间范围必须上传房间内小屏图", "errors.countryCode": "国家码必须是 2-3 位字母", "errors.bannerRegion": "区域 ID 不正确", "errors.bannerSchedule": "投放结束时间必须晚于开始时间"
|
||||
};
|
||||
|
||||
const ar = {
|
||||
...en,
|
||||
"app.title": "نظام الإدارة", "app.shortTitle": "نظام الإدارة", "language.label": "اللغة",
|
||||
"app.title": "الإدارة الخارجية", "app.shortTitle": "الإدارة الخارجية", "language.label": "اللغة",
|
||||
"common.actions": "الإجراءات", "common.active": "نشط", "common.all": "الكل", "common.allStatuses": "كل الحالات", "common.banned": "محظور", "common.cancel": "إلغاء", "common.confirm": "تأكيد", "common.create": "إنشاء", "common.disabled": "معطّل", "common.edit": "تعديل", "common.enabled": "مفعّل", "common.expired": "منتهي", "common.loading": "جارٍ التحميل", "common.noData": "لا توجد بيانات", "common.normal": "طبيعي", "common.query": "بحث", "common.reason": "السبب", "common.reload": "إعادة التحميل", "common.retry": "إعادة المحاولة", "common.save": "حفظ", "common.status": "الحالة", "common.user": "المستخدم",
|
||||
"auth.account": "الحساب", "auth.changePassword": "تغيير كلمة المرور", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.firstLoginChange": "يجب تغيير كلمة المرور عند تسجيل الدخول لأول مرة", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordMin": "8 أحرف على الأقل", "auth.passwordMinError": "يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى الرئيسية", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور",
|
||||
"auth.account": "الحساب", "auth.changePassword": "تغيير كلمة المرور", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى الرئيسية", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور",
|
||||
"nav.expand": "فتح القائمة", "nav.more": "المزيد", "nav.overview": "الرئيسية", "nav.users": "إدارة المستخدمين", "nav.bans": "إدارة الحظر", "nav.hosts": "المضيفون", "nav.agencies": "الوكالات", "nav.bds": "قائمة BD", "nav.bdManagers": "مديرو BD", "nav.superAdmins": "المشرفون العامون", "nav.rooms": "إدارة الغرف", "nav.grants": "الموارد والمعرّفات المميزة", "nav.banners": "إدارة اللافتات", "nav.team": "فريقي",
|
||||
"capability.userList": "قائمة المستخدمين", "capability.userUpdate": "تعديل بيانات المستخدم", "capability.banList": "قائمة الحسابات المحظورة", "capability.ban": "حظر المستخدمين", "capability.unban": "إلغاء حظر المستخدمين", "capability.hostList": "قائمة المضيفين", "capability.agencyList": "قائمة الوكالات", "capability.bdList": "قائمة BD", "capability.bdManagerList": "قائمة مديري BD", "capability.superAdminList": "قائمة المشرفين العامين", "capability.roomList": "إدارة الغرف", "capability.roomUpdate": "تعديل الغرف", "capability.privilegeList": "قائمة عناصر الامتياز", "capability.privilegeGrant": "منح عناصر الامتياز", "capability.bannerCreate": "إنشاء اللافتات", "capability.prettyIdGrant": "منح المعرّفات المميزة", "capability.userTitleGrant": "منح ألقاب المستخدمين", "capability.roomBackgroundGrant": "منح خلفيات الغرف", "capability.userLevelGrant": "منح مستويات الثروة/VIP", "capability.teamView": "فريقي",
|
||||
"overview.currentApp": "التطبيق الحالي", "overview.account": "الحساب", "overview.permissions": "الصلاحيات المفعّلة", "overview.welcome": "مرحبًا بعودتك", "overview.features": "الخدمات", "overview.enabled": "مفعّلة", "overview.notEnabled": "غير مفعّلة",
|
||||
@ -360,21 +355,21 @@ const ar = {
|
||||
"bans.title": "قائمة الحسابات المحظورة", "bans.reason": "سبب الحظر", "bans.unban": "إلغاء الحظر", "bans.unbanned": "تم إلغاء حظر المستخدم", "bans.unbanFailed": "فشل إلغاء حظر المستخدم", "bans.confirmTitle": "إلغاء حظر المستخدم", "bans.confirmContent": "هل تريد إلغاء حظر {user}؟",
|
||||
"organization.title": "المؤسسة", "organization.agencies": "قائمة الوكالات", "organization.bds": "قائمة BD", "organization.bdManagers": "قائمة مديري BD", "organization.hosts": "قائمة المضيفين", "organization.managers": "قائمة المديرين", "organization.superAdmins": "قائمة المشرفين العامين", "organization.team": "فريقي", "organization.agency": "الوكالة", "organization.owner": "المالك", "organization.parentBd": "BD الأعلى", "organization.member": "العضو", "organization.country": "الدولة / المنطقة", "organization.userReference": "المستخدم {id}", "organization.agencyReference": "الوكالة {id}", "organization.teamSearch": "معرّف الوكالة / معرّف المالك / معرّف BD الأعلى", "organization.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى قائمة المؤسسة هذه.", "organization.bdLeaderCount": "قادة BD: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "الوكالات: {count}", "organization.truncated": "يتجاوز حجم الفريق حد الواجهة. النتائج الحالية غير مكتملة.",
|
||||
"rooms.title": "إدارة الغرف", "rooms.room": "الغرفة", "rooms.owner": "المالك", "rooms.visibleRegion": "المنطقة المرئية", "rooms.search": "معرّف الغرفة / الاسم / المالك", "rooms.updated": "تم تحديث بيانات الغرفة", "rooms.updateFailed": "فشل تحديث الغرفة", "rooms.editTitle": "تعديل الغرفة", "rooms.background": "خلفية الغرفة", "rooms.name": "اسم الغرفة", "rooms.description": "وصف الغرفة", "rooms.visibleRegionId": "معرّف المنطقة المرئية", "rooms.allRegions": "الكل",
|
||||
"grants.title": "الموارد والمعرّفات المميزة", "grants.grantResource": "منح عنصر", "grants.grantPrettyId": "منح معرّف مميز", "grants.availableResources": "الموارد المتاحة", "grants.targetUser": "المستخدم المستهدف", "grants.resource": "المورد", "grants.quantity": "الكمية", "grants.resourceGranted": "تم منح عنصر الامتياز", "grants.prettyIdGranted": "تم منح المعرّف المميز", "grants.failed": "فشل المنح", "grants.resourceTitle": "منح عنصر امتياز", "grants.prettyIdTitle": "منح معرّف مميز", "grants.target": "معرّف المستخدم / المعرّف المميز", "grants.currentTarget": "معرّف المستخدم / المعرّف المميز الحالي", "grants.prettyId": "المعرّف المميز المراد منحه", "grants.validDays": "الصلاحية (بالأيام)", "grants.permanentHint": "أدخل 0 للصلاحية الدائمة", "grants.reason": "سبب المنح", "grants.notice": "قبل الإرسال، يتم التحقق من المستخدم داخل التطبيق الحالي ويُستخدم معرّف المستخدم الذي يعيده الخادم.", "grants.confirm": "تأكيد المنح",
|
||||
"grants.title": "الموارد والمعرّفات المميزة", "grants.grantResource": "منح عنصر", "grants.grantPrettyId": "منح معرّف مميز", "grants.availableResources": "الموارد المتاحة", "grants.targetUser": "المستخدم المستهدف", "grants.resource": "المورد", "grants.quantity": "الكمية", "grants.resourceGranted": "تم منح عنصر الامتياز", "grants.prettyIdGranted": "تم منح المعرّف المميز", "grants.failed": "فشل المنح", "grants.resourceTitle": "منح عنصر امتياز", "grants.prettyIdTitle": "منح معرّف مميز", "grants.target": "معرّف المستخدم / المعرّف المميز", "grants.currentTarget": "معرّف المستخدم / المعرّف المميز الحالي", "grants.prettyId": "المعرّف المميز المراد منحه", "grants.validDays": "الصلاحية (بالأيام)", "grants.permanentHint": "أدخل 0 للصلاحية الدائمة", "grants.reason": "سبب المنح", "grants.confirm": "تأكيد المنح",
|
||||
"banners.title": "إدارة اللافتات", "banners.banner": "اللافتة", "banners.type": "النوع", "banners.platform": "المنصة", "banners.sort": "الترتيب", "banners.create": "إنشاء لافتة", "banners.created": "تم إنشاء اللافتة", "banners.createFailed": "فشل إنشاء اللافتة", "banners.image": "صورة اللافتة", "banners.roomImage": "الصورة المصغرة داخل الغرفة", "banners.description": "الوصف", "banners.jumpType": "نوع الوجهة", "banners.h5Param": "المعامل (رابط H5)", "banners.appParam": "المعامل (JSON للتنقل داخل التطبيق)", "banners.displayScope": "نطاق العرض", "banners.scopeHome": "الرئيسية", "banners.scopeRoom": "الغرفة", "banners.scopeRecharge": "الشحن", "banners.scopeMe": "حسابي", "banners.regionId": "معرّف المنطقة", "banners.countryCode": "رمز الدولة", "banners.countryCodeHint": "حرفان أو 3؛ اتركه فارغًا للكل", "banners.schedule": "جدول العرض",
|
||||
"upload.preview": "معاينة {label}", "upload.empty": "لا توجد صورة", "upload.uploading": "جارٍ الرفع", "upload.replace": "استبدال الصورة", "upload.select": "رفع صورة", "upload.imageOnly": "اختر ملف صورة", "upload.sizeLimit": "يجب ألا يتجاوز حجم الصورة 10 ميغابايت", "upload.missingUrl": "لا تحتوي استجابة الرفع على رابط للصورة", "upload.failed": "فشل رفع الصورة",
|
||||
"pagination.total": "{count} عنصر", "pagination.previous": "السابق", "pagination.next": "التالي",
|
||||
"status.active": "نشط", "status.enabled": "مفعّل", "status.running": "قيد التشغيل", "status.normal": "طبيعي", "status.unbanned": "غير محظور", "status.banned": "محظور", "status.disabled": "معطّل", "status.failed": "فشل", "status.blocked": "محجوب", "status.expired": "منتهي",
|
||||
"jump.appTarget": "وجهة التطبيق", "jump.target.wallet": "المحفظة", "jump.target.room": "غرفة محددة", "jump.target.room_random": "غرفة عشوائية", "jump.target.room_window": "نافذة في غرفة محددة", "jump.target.room_random_window": "نافذة في غرفة عشوائية", "jump.target.room_game": "لعبة في غرفة محددة", "jump.target.room_random_game": "لعبة في غرفة عشوائية", "jump.target.explore_game": "لعبة Explore H5", "jump.target.custom": "JSON مخصص", "jump.roomId": "معرّف الغرفة", "jump.window": "نافذة داخل الغرفة", "jump.window.game_window": "نافذة اللعبة", "jump.window.gift_panel": "لوحة الهدايا", "jump.window.red_packet_claim": "نافذة الظرف الأحمر", "jump.window.wheel": "نافذة العجلة", "jump.gameId": "معرّف اللعبة", "jump.giftId": "معرّف الهدية", "jump.redPacketNo": "رقم الظرف الأحمر", "jump.hint": "يتم إنشاء معاملات التنقل داخل التطبيق بصيغة JSON، ويمكنك تعديلها مباشرة للمعاملات المخصصة.",
|
||||
"jump.appTarget": "وجهة التطبيق", "jump.target.wallet": "المحفظة", "jump.target.room": "غرفة محددة", "jump.target.room_random": "غرفة عشوائية", "jump.target.room_window": "نافذة في غرفة محددة", "jump.target.room_random_window": "نافذة في غرفة عشوائية", "jump.target.room_game": "لعبة في غرفة محددة", "jump.target.room_random_game": "لعبة في غرفة عشوائية", "jump.target.explore_game": "لعبة Explore H5", "jump.target.custom": "JSON مخصص", "jump.roomId": "معرّف الغرفة", "jump.window": "نافذة داخل الغرفة", "jump.window.game_window": "نافذة اللعبة", "jump.window.gift_panel": "لوحة الهدايا", "jump.window.red_packet_claim": "نافذة الظرف الأحمر", "jump.window.wheel": "نافذة العجلة", "jump.gameId": "معرّف اللعبة", "jump.giftId": "معرّف الهدية", "jump.redPacketNo": "رقم الظرف الأحمر",
|
||||
"time.unlimited": "أي وقت", "time.quickHour": "آخر ساعة", "time.quickDay": "آخر 24 ساعة", "time.quickWeek": "آخر 7 أيام", "time.start": "وقت البدء", "time.end": "وقت الانتهاء", "time.startDate": "تاريخ البدء", "time.endDate": "تاريخ الانتهاء", "time.pending": "غير محدد", "time.startMark": "ب", "time.endMark": "ن", "time.previousMonth": "الشهر السابق", "time.nextMonth": "الشهر التالي", "time.hour": "الساعة", "time.minute": "الدقيقة", "time.clear": "مسح", "time.apply": "تطبيق", "time.endAfterStart": "يجب أن يكون وقت الانتهاء بعد وقت البدء", "time.endDateAfterStart": "لا يمكن أن يسبق تاريخ الانتهاء تاريخ البدء", "time.completeRange": "حدد النطاق الكامل", "time.endLaterShort": "يجب أن تكون النهاية بعد البداية", "time.endDateLaterShort": "لا يمكن أن تسبق النهاية البداية", "time.durationDays": "{count} يوم", "time.durationHours": "{count} ساعة", "time.durationMinutes": "{count} دقيقة", "time.duration": "{value}", "time.sameDay": "اليوم نفسه", "time.lessMinute": "أقل من دقيقة واحدة",
|
||||
"errors.loadFailed": "فشل تحميل البيانات", "errors.requestFailed": "فشل الطلب", "errors.unauthorized": "انتهت الجلسة. سجّل الدخول مرة أخرى.", "errors.forbidden": "ليس لديك إذن لتنفيذ هذه العملية.", "errors.notFound": "لم يتم العثور على المورد المطلوب.", "errors.conflict": "تتعارض العملية مع البيانات الحالية. حدّث الصفحة وحاول مجددًا.", "errors.tooLarge": "حجم الطلب كبير جدًا.", "errors.rateLimited": "طلبات كثيرة جدًا. حاول لاحقًا.", "errors.server": "الخدمة غير متاحة مؤقتًا. حاول لاحقًا.", "errors.accountLocked": "الحساب مقفل مؤقتًا. حاول لاحقًا.", "errors.appInactive": "التطبيق المرتبط بهذا الحساب غير نشط.", "errors.vipDisabled": "نظام VIP معطّل في التطبيق الحالي", "errors.vipLevelInvalid": "اختر مستوى VIP معدًا للتطبيق الحالي", "errors.organizationRoute": "لا توجد نقطة بيانات آمنة لقائمة المؤسسة هذه", "errors.regionId": "يجب أن يكون معرّف المنطقة عددًا صحيحًا آمنًا وغير سالب", "errors.grantTarget": "لم يتم العثور على المستخدم المستهدف في التطبيق الحالي", "errors.bannerType": "نوع اللافتة غير صالح", "errors.bannerPlatform": "منصة اللافتة غير صالحة", "errors.bannerStatus": "حالة اللافتة غير صالحة", "errors.bannerScope": "اختر نطاق عرض واحدًا على الأقل", "errors.bannerImage": "ارفع صورة للافتة", "errors.h5Url": "أدخل رابط H5 صالحًا", "errors.h5Protocol": "يجب أن يستخدم رابط H5 بروتوكول http أو https", "errors.appParamRequired": "أدخل معاملات التنقل داخل التطبيق", "errors.appParamObject": "يجب أن تكون معاملات التطبيق كائن JSON", "errors.appParamType": "نوع التنقل داخل التطبيق غير مدعوم", "errors.appParamRoom": "تتطلب هذه الوجهة room_id", "errors.appParamGame": "تتطلب هذه الوجهة game_id", "errors.appParamWindow": "تتطلب هذه الوجهة نافذة صالحة", "errors.appParamGameCode": "تتطلب لعبة Explore H5 قيمة game_code صالحة", "errors.roomBannerImage": "ارفع صورة مصغرة لنطاق الغرفة", "errors.countryCode": "يجب أن يتكون رمز الدولة من حرفين أو 3", "errors.bannerRegion": "معرّف المنطقة غير صالح", "errors.bannerSchedule": "يجب أن يكون وقت انتهاء العرض بعد وقت البدء"
|
||||
};
|
||||
|
||||
const tr = {
|
||||
...en,
|
||||
"app.title": "Yönetim Sistemi", "app.shortTitle": "Yönetim Sistemi", "language.label": "Dil",
|
||||
"app.title": "Harici Yönetim", "app.shortTitle": "Harici Yönetim", "language.label": "Dil",
|
||||
"common.actions": "İşlemler", "common.active": "Aktif", "common.all": "Tümü", "common.allStatuses": "Tüm durumlar", "common.banned": "Yasaklı", "common.cancel": "İptal", "common.confirm": "Onayla", "common.create": "Oluştur", "common.disabled": "Devre dışı", "common.edit": "Düzenle", "common.enabled": "Etkin", "common.expired": "Süresi dolmuş", "common.loading": "Yükleniyor", "common.noData": "Veri yok", "common.normal": "Normal", "common.query": "Ara", "common.reason": "Neden", "common.reload": "Yeniden yükle", "common.retry": "Tekrar dene", "common.save": "Kaydet", "common.status": "Durum", "common.user": "Kullanıcı",
|
||||
"auth.account": "Hesap", "auth.changePassword": "Parolayı değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.firstLoginChange": "İlk girişte parolanızı değiştirmeniz gerekir", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": "Çıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu hesabın bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordMin": "En az 8 karakter", "auth.passwordMinError": "Yeni parola en az 8 karakter olmalıdır", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Ana sayfaya dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster",
|
||||
"auth.account": "Hesap", "auth.changePassword": "Parolayı değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": "Çıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu hesabın bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Ana sayfaya dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster",
|
||||
"nav.expand": "Menüyü aç", "nav.more": "Daha fazla", "nav.overview": "Ana sayfa", "nav.users": "Kullanıcı yönetimi", "nav.bans": "Yasak yönetimi", "nav.hosts": "Yayıncılar", "nav.agencies": "Ajanslar", "nav.bds": "BD listesi", "nav.bdManagers": "BD Yöneticileri", "nav.superAdmins": "Süper Yöneticiler", "nav.rooms": "Oda yönetimi", "nav.grants": "Kaynaklar ve özel ID'ler", "nav.banners": "Banner yönetimi", "nav.team": "Ekibim",
|
||||
"capability.userList": "Kullanıcı listesi", "capability.userUpdate": "Kullanıcı bilgilerini düzenleme", "capability.banList": "Yasaklı hesap listesi", "capability.ban": "Kullanıcı yasaklama", "capability.unban": "Kullanıcı yasağını kaldırma", "capability.hostList": "Yayıncı listesi", "capability.agencyList": "Ajans listesi", "capability.bdList": "BD listesi", "capability.bdManagerList": "BD Yöneticisi listesi", "capability.superAdminList": "Süper Yönetici listesi", "capability.roomList": "Oda yönetimi", "capability.roomUpdate": "Oda düzenleme", "capability.privilegeList": "Ayrıcalık öğesi listesi", "capability.privilegeGrant": "Ayrıcalık öğesi verme", "capability.bannerCreate": "Banner oluşturma", "capability.prettyIdGrant": "Özel ID verme", "capability.userTitleGrant": "Kullanıcı unvanı verme", "capability.roomBackgroundGrant": "Oda arka planı verme", "capability.userLevelGrant": "Servet/VIP seviyesi verme", "capability.teamView": "Ekibim",
|
||||
"overview.currentApp": "Geçerli uygulama", "overview.account": "Hesap", "overview.permissions": "Etkin yetkiler", "overview.welcome": "Tekrar hoş geldiniz", "overview.features": "Özellikler", "overview.enabled": "Etkin", "overview.notEnabled": "Etkin değil",
|
||||
@ -382,12 +377,12 @@ const tr = {
|
||||
"bans.title": "Yasaklı hesap listesi", "bans.reason": "Yasak nedeni", "bans.unban": "Yasağı kaldır", "bans.unbanned": "Kullanıcı yasağı kaldırıldı", "bans.unbanFailed": "Kullanıcı yasağı kaldırılamadı", "bans.confirmTitle": "Kullanıcı yasağını kaldır", "bans.confirmContent": "{user} kullanıcısının yasağı kaldırılsın mı?",
|
||||
"organization.title": "Organizasyon", "organization.agencies": "Ajans listesi", "organization.bds": "BD listesi", "organization.bdManagers": "BD Yöneticisi listesi", "organization.hosts": "Yayıncı listesi", "organization.managers": "Yönetici listesi", "organization.superAdmins": "Süper Yönetici listesi", "organization.team": "Ekibim", "organization.agency": "Ajans", "organization.owner": "Sahip", "organization.parentBd": "Üst BD", "organization.member": "Üye", "organization.country": "Ülke / bölge", "organization.userReference": "Kullanıcı {id}", "organization.agencyReference": "Ajans {id}", "organization.teamSearch": "Ajans ID / sahip kullanıcı ID / üst BD ID", "organization.noPermission": "Bu hesabın bu organizasyon listesine erişimi yok.", "organization.bdLeaderCount": "BD Liderleri: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "Ajanslar: {count}", "organization.truncated": "Ekip API sınırını aşıyor. Mevcut sonuçlar eksik.",
|
||||
"rooms.title": "Oda yönetimi", "rooms.room": "Oda", "rooms.owner": "Sahip", "rooms.visibleRegion": "Görünür bölge", "rooms.search": "Oda ID / ad / sahip", "rooms.updated": "Oda bilgileri güncellendi", "rooms.updateFailed": "Oda güncellenemedi", "rooms.editTitle": "Odayı düzenle", "rooms.background": "Oda arka planı", "rooms.name": "Oda adı", "rooms.description": "Oda açıklaması", "rooms.visibleRegionId": "Görünür bölge ID", "rooms.allRegions": "Tümü",
|
||||
"grants.title": "Kaynaklar ve özel ID'ler", "grants.grantResource": "Öğe ver", "grants.grantPrettyId": "Özel ID ver", "grants.availableResources": "Kullanılabilir kaynaklar", "grants.targetUser": "Hedef kullanıcı", "grants.resource": "Kaynak", "grants.quantity": "Miktar", "grants.resourceGranted": "Ayrıcalık öğesi verildi", "grants.prettyIdGranted": "Özel ID verildi", "grants.failed": "Verme işlemi başarısız", "grants.resourceTitle": "Ayrıcalık öğesi ver", "grants.prettyIdTitle": "Özel ID ver", "grants.target": "Kullanıcı ID / özel ID", "grants.currentTarget": "Kullanıcı ID / mevcut özel ID", "grants.prettyId": "Verilecek özel ID", "grants.validDays": "Geçerlilik (gün)", "grants.permanentHint": "Kalıcı geçerlilik için 0 girin", "grants.reason": "Verme nedeni", "grants.notice": "Göndermeden önce kullanıcı geçerli uygulamada doğrulanır ve sunucunun döndürdüğü kullanıcı ID kullanılır.", "grants.confirm": "Vermeyi onayla",
|
||||
"grants.title": "Kaynaklar ve özel ID'ler", "grants.grantResource": "Öğe ver", "grants.grantPrettyId": "Özel ID ver", "grants.availableResources": "Kullanılabilir kaynaklar", "grants.targetUser": "Hedef kullanıcı", "grants.resource": "Kaynak", "grants.quantity": "Miktar", "grants.resourceGranted": "Ayrıcalık öğesi verildi", "grants.prettyIdGranted": "Özel ID verildi", "grants.failed": "Verme işlemi başarısız", "grants.resourceTitle": "Ayrıcalık öğesi ver", "grants.prettyIdTitle": "Özel ID ver", "grants.target": "Kullanıcı ID / özel ID", "grants.currentTarget": "Kullanıcı ID / mevcut özel ID", "grants.prettyId": "Verilecek özel ID", "grants.validDays": "Geçerlilik (gün)", "grants.permanentHint": "Kalıcı geçerlilik için 0 girin", "grants.reason": "Verme nedeni", "grants.confirm": "Vermeyi onayla",
|
||||
"banners.title": "Banner yönetimi", "banners.banner": "Banner", "banners.type": "Tür", "banners.platform": "Platform", "banners.sort": "Sıralama", "banners.create": "Banner oluştur", "banners.created": "Banner oluşturuldu", "banners.createFailed": "Banner oluşturulamadı", "banners.image": "Banner görseli", "banners.roomImage": "Oda içi küçük görsel", "banners.description": "Açıklama", "banners.jumpType": "Hedef türü", "banners.h5Param": "Parametre (H5 bağlantısı)", "banners.appParam": "Parametre (uygulama yönlendirme JSON'u)", "banners.displayScope": "Gösterim kapsamı", "banners.scopeHome": "Ana sayfa", "banners.scopeRoom": "Oda", "banners.scopeRecharge": "Yükleme", "banners.scopeMe": "Ben", "banners.regionId": "Bölge ID", "banners.countryCode": "Ülke kodu", "banners.countryCodeHint": "2–3 harf; tümü için boş bırakın", "banners.schedule": "Gösterim zamanı",
|
||||
"upload.preview": "{label} önizlemesi", "upload.empty": "Görsel yok", "upload.uploading": "Yükleniyor", "upload.replace": "Görseli değiştir", "upload.select": "Görsel yükle", "upload.imageOnly": "Bir görsel dosyası seçin", "upload.sizeLimit": "Görseller 10 MB'ı aşamaz", "upload.missingUrl": "Yükleme yanıtında görsel URL'si yok", "upload.failed": "Görsel yüklenemedi",
|
||||
"pagination.total": "{count} öğe", "pagination.previous": "Önceki", "pagination.next": "Sonraki",
|
||||
"status.active": "Aktif", "status.enabled": "Etkin", "status.running": "Çalışıyor", "status.normal": "Normal", "status.unbanned": "Yasaksız", "status.banned": "Yasaklı", "status.disabled": "Devre dışı", "status.failed": "Başarısız", "status.blocked": "Engelli", "status.expired": "Süresi dolmuş",
|
||||
"jump.appTarget": "Uygulama hedefi", "jump.target.wallet": "Cüzdan", "jump.target.room": "Belirli oda", "jump.target.room_random": "Rastgele oda", "jump.target.room_window": "Belirli odada pencere", "jump.target.room_random_window": "Rastgele odada pencere", "jump.target.room_game": "Belirli odada oyun", "jump.target.room_random_game": "Rastgele odada oyun", "jump.target.explore_game": "Explore oyunu H5", "jump.target.custom": "Özel JSON", "jump.roomId": "Oda ID", "jump.window": "Oda içi pencere", "jump.window.game_window": "Oyun penceresi", "jump.window.gift_panel": "Hediye paneli", "jump.window.red_packet_claim": "Kırmızı paket penceresi", "jump.window.wheel": "Çark penceresi", "jump.gameId": "Oyun ID", "jump.giftId": "Hediye ID", "jump.redPacketNo": "Kırmızı paket numarası", "jump.hint": "Uygulama yönlendirme parametreleri JSON olarak oluşturulur; özel parametreler için JSON'u doğrudan düzenleyebilirsiniz.",
|
||||
"jump.appTarget": "Uygulama hedefi", "jump.target.wallet": "Cüzdan", "jump.target.room": "Belirli oda", "jump.target.room_random": "Rastgele oda", "jump.target.room_window": "Belirli odada pencere", "jump.target.room_random_window": "Rastgele odada pencere", "jump.target.room_game": "Belirli odada oyun", "jump.target.room_random_game": "Rastgele odada oyun", "jump.target.explore_game": "Explore oyunu H5", "jump.target.custom": "Özel JSON", "jump.roomId": "Oda ID", "jump.window": "Oda içi pencere", "jump.window.game_window": "Oyun penceresi", "jump.window.gift_panel": "Hediye paneli", "jump.window.red_packet_claim": "Kırmızı paket penceresi", "jump.window.wheel": "Çark penceresi", "jump.gameId": "Oyun ID", "jump.giftId": "Hediye ID", "jump.redPacketNo": "Kırmızı paket numarası",
|
||||
"time.unlimited": "Sınırsız", "time.quickHour": "Son 1 saat", "time.quickDay": "Son 24 saat", "time.quickWeek": "Son 7 gün", "time.start": "Başlangıç zamanı", "time.end": "Bitiş zamanı", "time.startDate": "Başlangıç tarihi", "time.endDate": "Bitiş tarihi", "time.pending": "Seçilmedi", "time.startMark": "B", "time.endMark": "S", "time.previousMonth": "Önceki ay", "time.nextMonth": "Sonraki ay", "time.hour": "Saat", "time.minute": "Dakika", "time.clear": "Temizle", "time.apply": "Uygula", "time.endAfterStart": "Bitiş zamanı başlangıçtan sonra olmalıdır", "time.endDateAfterStart": "Bitiş tarihi başlangıçtan önce olamaz", "time.completeRange": "Tam aralığı seçin", "time.endLaterShort": "Bitiş başlangıçtan sonra olmalı", "time.endDateLaterShort": "Bitiş başlangıçtan önce olamaz", "time.durationDays": "{count} gün", "time.durationHours": "{count} saat", "time.durationMinutes": "{count} dakika", "time.duration": "{value}", "time.sameDay": "Aynı gün", "time.lessMinute": "1 dakikadan az",
|
||||
"errors.loadFailed": "Veriler yüklenemedi", "errors.requestFailed": "İstek başarısız", "errors.unauthorized": "Oturumunuz sona erdi. Tekrar giriş yapın.", "errors.forbidden": "Bu işlemi yapma yetkiniz yok.", "errors.notFound": "İstenen kaynak bulunamadı.", "errors.conflict": "İşlem mevcut verilerle çakışıyor. Yenileyip tekrar deneyin.", "errors.tooLarge": "İstek çok büyük.", "errors.rateLimited": "Çok fazla istek gönderildi. Daha sonra tekrar deneyin.", "errors.server": "Hizmet geçici olarak kullanılamıyor. Daha sonra tekrar deneyin.", "errors.accountLocked": "Hesap geçici olarak kilitlendi. Daha sonra tekrar deneyin.", "errors.appInactive": "Bu hesaba bağlı uygulama etkin değil.", "errors.vipDisabled": "Geçerli uygulamanın VIP programı devre dışı", "errors.vipLevelInvalid": "Geçerli uygulama için yapılandırılmış bir VIP seviyesi seçin", "errors.organizationRoute": "Bu organizasyon listesi için güvenli veri uç noktası yok", "errors.regionId": "Görünür bölge ID güvenli ve negatif olmayan bir tam sayı olmalıdır", "errors.grantTarget": "Geçerli uygulamada hedef kullanıcı bulunamadı", "errors.bannerType": "Geçersiz banner türü", "errors.bannerPlatform": "Geçersiz banner platformu", "errors.bannerStatus": "Geçersiz banner durumu", "errors.bannerScope": "En az bir gösterim kapsamı seçin", "errors.bannerImage": "Bir banner görseli yükleyin", "errors.h5Url": "Geçerli bir H5 bağlantısı girin", "errors.h5Protocol": "H5 bağlantıları http veya https kullanmalıdır", "errors.appParamRequired": "Uygulama yönlendirme parametrelerini girin", "errors.appParamObject": "Uygulama parametreleri bir JSON nesnesi olmalıdır", "errors.appParamType": "Uygulama yönlendirme türü desteklenmiyor", "errors.appParamRoom": "Bu hedef room_id gerektirir", "errors.appParamGame": "Bu hedef game_id gerektirir", "errors.appParamWindow": "Bu hedef geçerli bir window gerektirir", "errors.appParamGameCode": "Explore oyunu H5 geçerli bir game_code gerektirir", "errors.roomBannerImage": "Oda kapsamı için oda içi küçük görsel yükleyin", "errors.countryCode": "Ülke kodu 2–3 harften oluşmalıdır", "errors.bannerRegion": "Geçersiz bölge ID", "errors.bannerSchedule": "Gösterim bitiş zamanı başlangıçtan sonra olmalıdır"
|
||||
};
|
||||
|
||||
@ -13,6 +13,12 @@ describe("external admin translations", () => {
|
||||
|
||||
test("uses native language names and Arabic RTL only", () => {
|
||||
expect(EXTERNAL_LANGUAGE_OPTIONS.map((item) => item.label)).toEqual(["English", "简体中文", "العربية", "Türkçe"]);
|
||||
expect(["en", "zh-CN", "ar", "tr"].map((locale) => externalMessage(locale, "app.title"))).toEqual([
|
||||
"External Admin", "外管后台", "الإدارة الخارجية", "Harici Yönetim"
|
||||
]);
|
||||
expect(["en", "zh-CN", "ar", "tr"].map((locale) => externalMessage(locale, "app.shortTitle"))).toEqual([
|
||||
"External Admin", "外管后台", "الإدارة الخارجية", "Harici Yönetim"
|
||||
]);
|
||||
expect(externalDirection("ar")).toBe("rtl");
|
||||
expect(externalDirection("en")).toBe("ltr");
|
||||
expect(externalDirection("zh-CN")).toBe("ltr");
|
||||
|
||||
@ -28,10 +28,12 @@ import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useMemo, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
import { hasAnyExternalCapability } from "../config/capabilities.js";
|
||||
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
|
||||
import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx";
|
||||
import { useExternalSessionBrand } from "../shared/useExternalSessionBrand.js";
|
||||
|
||||
const drawerWidth = 248;
|
||||
const navigation = [
|
||||
@ -67,6 +69,7 @@ export function ExternalAdminLayout() {
|
||||
// The H5 tab bar keeps three primary destinations reachable with a thumb; everything else lives in the "More" sheet.
|
||||
const tabs = visibleNavigation.slice(0, 3);
|
||||
const moreActive = !tabs.some((item) => isItemActive(item, location.pathname));
|
||||
useExternalSessionBrand(session, t("app.title"));
|
||||
|
||||
const handleLogout = async () => {
|
||||
setSheetOpen(false);
|
||||
@ -87,10 +90,14 @@ export function ExternalAdminLayout() {
|
||||
const sidebar = (
|
||||
<Box className="external-sidebar" sx={{ width: drawerWidth }}>
|
||||
<Box className="external-logo">
|
||||
<Box>
|
||||
<Typography className="external-logo-name" fontWeight={650}>{t("app.shortTitle")}</Typography>
|
||||
<Typography color="text.secondary" variant="caption">{session?.appName || session?.appCode}</Typography>
|
||||
</Box>
|
||||
<AppIdentity
|
||||
appCode={session?.appCode}
|
||||
appName={session?.appName || session?.appCode}
|
||||
className="external-sidebar-brand"
|
||||
logoUrl={session?.logoUrl}
|
||||
showLogoFallback={false}
|
||||
size="large"
|
||||
/>
|
||||
</Box>
|
||||
<Divider />
|
||||
<List className="external-nav">
|
||||
@ -131,7 +138,18 @@ export function ExternalAdminLayout() {
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<Typography className="external-header-title" component="h2" noWrap>{mobile && !current ? t("app.shortTitle") : t(current?.labelKey || "app.shortTitle")}</Typography>
|
||||
{mobile ? (
|
||||
<AppIdentity
|
||||
appCode={session?.appCode}
|
||||
appName={session?.appName || session?.appCode}
|
||||
className="external-mobile-brand"
|
||||
logoUrl={session?.logoUrl}
|
||||
showLogoFallback={false}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<Typography className="external-header-title" component="h2" noWrap>{t(current?.labelKey || "app.shortTitle")}</Typography>
|
||||
)}
|
||||
<Box flex={1} />
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<LanguageSwitcher compact={mobile} />
|
||||
@ -186,7 +204,7 @@ export function ExternalAdminLayout() {
|
||||
})}
|
||||
</Box>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
||||
<Button color="inherit" fullWidth startIcon={<KeyOutlined />} onClick={() => { setSheetOpen(false); navigate("/change-password"); }}>{t("auth.changePassword")}</Button>
|
||||
<Button fullWidth startIcon={<LogoutOutlined />} variant="outlined" onClick={handleLogout}>{t("auth.logout")}</Button>
|
||||
</Stack>
|
||||
|
||||
@ -14,6 +14,7 @@ const auth = vi.hoisted(() => ({
|
||||
account: "fami-manager",
|
||||
appCode: "fami",
|
||||
appName: "Fami",
|
||||
logoUrl: "https://media.example.com/apps/fami.png",
|
||||
capabilities: ["user:list", "room:list"]
|
||||
}
|
||||
}));
|
||||
@ -26,6 +27,13 @@ describe("ExternalAdminLayout RTL", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(EXTERNAL_LOCALE_STORAGE_KEY, "ar");
|
||||
viewport.mobile = false;
|
||||
auth.session = {
|
||||
account: "fami-manager",
|
||||
appCode: "fami",
|
||||
appName: "Fami",
|
||||
logoUrl: "https://media.example.com/apps/fami.png",
|
||||
capabilities: ["user:list", "room:list"]
|
||||
};
|
||||
});
|
||||
|
||||
test("shows the H5 tab bar and opens the more sheet in Arabic", async () => {
|
||||
@ -47,7 +55,7 @@ describe("ExternalAdminLayout RTL", () => {
|
||||
});
|
||||
|
||||
test("places the desktop navigation drawer on the right for Arabic", () => {
|
||||
renderLayout();
|
||||
const view = renderLayout();
|
||||
|
||||
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "ar");
|
||||
@ -60,8 +68,37 @@ describe("ExternalAdminLayout RTL", () => {
|
||||
.find((cssText) => cssText.includes(`.${generatedClass}`));
|
||||
expect(drawerRule).toContain("right: 0px");
|
||||
expect(drawerRule).toContain("border-left:");
|
||||
expect(document.querySelector(".external-sidebar-brand img")).toHaveAttribute("src", "https://media.example.com/apps/fami.png");
|
||||
expect(screen.getAllByText("Fami").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("إدارة HYApp الخارجية")).not.toBeInTheDocument();
|
||||
expect(document.title).toBe("Fami");
|
||||
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/fami.png");
|
||||
expect(screen.getAllByText("الرئيسية").length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية");
|
||||
|
||||
view.unmount();
|
||||
expect(document.title).toBe("الإدارة الخارجية");
|
||||
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("uses the App name, logo and title resolved by the authenticated session", () => {
|
||||
auth.session = {
|
||||
...auth.session,
|
||||
account: "lalu-manager",
|
||||
appCode: "lalu",
|
||||
appName: "Lalu",
|
||||
logoUrl: "https://media.example.com/apps/lalu.png"
|
||||
};
|
||||
const view = renderLayout();
|
||||
|
||||
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0);
|
||||
expect(document.querySelector(".external-sidebar-brand img")).toHaveAttribute("src", "https://media.example.com/apps/lalu.png");
|
||||
expect(document.title).toBe("Lalu");
|
||||
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/lalu.png");
|
||||
|
||||
view.unmount();
|
||||
expect(document.title).toBe("الإدارة الخارجية");
|
||||
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -8,10 +8,12 @@ import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
|
||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
|
||||
import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx";
|
||||
import { translateExternalError } from "../i18n/errors.js";
|
||||
import { useExternalSessionBrand } from "../shared/useExternalSessionBrand.js";
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const { changePassword, logout, session } = useExternalAuth();
|
||||
@ -20,6 +22,7 @@ export default function ChangePasswordPage() {
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
useExternalSessionBrand(session, t("app.title"));
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
@ -27,10 +30,6 @@ export default function ChangePasswordPage() {
|
||||
setError(t("auth.passwordWhitespace"));
|
||||
return;
|
||||
}
|
||||
if (form.newPassword.length < 8) {
|
||||
setError(t("auth.passwordMinError"));
|
||||
return;
|
||||
}
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
setError(t("auth.passwordMismatch"));
|
||||
return;
|
||||
@ -57,11 +56,22 @@ export default function ChangePasswordPage() {
|
||||
<Card className="external-login-card" elevation={0}>
|
||||
<CardContent>
|
||||
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
|
||||
<Stack spacing={1.5} sx={{ alignItems: "center" }}><Typography className="external-login-title" component="h1" variant="h5">{t("auth.changePassword")}</Typography><Typography color="text.secondary">{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account}</Typography></Stack>
|
||||
<Stack spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<AppIdentity
|
||||
appCode={session?.appCode}
|
||||
appName={session?.appName || session?.appCode}
|
||||
className="external-change-password-brand"
|
||||
logoUrl={session?.logoUrl}
|
||||
showLogoFallback={false}
|
||||
size="large"
|
||||
/>
|
||||
<Typography className="external-login-title" component="h1" variant="h5">{t("auth.changePassword")}</Typography>
|
||||
<Typography color="text.secondary">{session?.account}</Typography>
|
||||
</Stack>
|
||||
<Stack component="form" spacing={2} onSubmit={submit}>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
<TextField autoComplete="current-password" disabled={submitting} label={t("auth.currentPassword")} required type="password" value={form.oldPassword} onChange={(event) => setForm({ ...form, oldPassword: event.target.value })} />
|
||||
<TextField autoComplete="new-password" disabled={submitting} helperText={t("auth.passwordMin")} label={t("auth.newPassword")} required type="password" value={form.newPassword} onChange={(event) => setForm({ ...form, newPassword: event.target.value })} />
|
||||
<TextField autoComplete="new-password" disabled={submitting} label={t("auth.newPassword")} required type="password" value={form.newPassword} onChange={(event) => setForm({ ...form, newPassword: event.target.value })} />
|
||||
<TextField autoComplete="new-password" disabled={submitting} label={t("auth.confirmNewPassword")} required type="password" value={form.confirmPassword} onChange={(event) => setForm({ ...form, confirmPassword: event.target.value })} />
|
||||
<Stack direction="row" spacing={1}><Button disabled={submitting} fullWidth onClick={cancel} variant="outlined">{t("auth.logout")}</Button><Button disabled={submitting} fullWidth type="submit" variant="contained">{t("auth.savePassword")}</Button></Stack>
|
||||
</Stack>
|
||||
|
||||
@ -7,7 +7,13 @@ import ChangePasswordPage from "./ChangePasswordPage.jsx";
|
||||
const auth = vi.hoisted(() => ({
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
session: { account: "fami-manager", passwordChangeRequired: true }
|
||||
session: {
|
||||
account: "fami-manager",
|
||||
appCode: "fami",
|
||||
appName: "Fami",
|
||||
logoUrl: "https://media.example.com/apps/fami.png",
|
||||
passwordChangeRequired: true
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth }));
|
||||
@ -16,6 +22,29 @@ describe("ChangePasswordPage", () => {
|
||||
beforeEach(() => {
|
||||
auth.changePassword.mockReset().mockResolvedValue({});
|
||||
auth.logout.mockReset().mockResolvedValue(undefined);
|
||||
auth.session = {
|
||||
account: "fami-manager",
|
||||
appCode: "fami",
|
||||
appName: "Fami",
|
||||
logoUrl: "https://media.example.com/apps/fami.png",
|
||||
passwordChangeRequired: true
|
||||
};
|
||||
});
|
||||
|
||||
test("shows the App brand resolved by the authenticated session", () => {
|
||||
auth.session = {
|
||||
...auth.session,
|
||||
account: "lalu-manager",
|
||||
appCode: "lalu",
|
||||
appName: "Lalu",
|
||||
logoUrl: "https://media.example.com/apps/lalu.png"
|
||||
};
|
||||
const view = render(<MemoryRouter><ChangePasswordPage /></MemoryRouter>);
|
||||
|
||||
expect(screen.getByText("Lalu")).toBeInTheDocument();
|
||||
expect(view.container.querySelector("img")).toHaveAttribute("src", "https://media.example.com/apps/lalu.png");
|
||||
expect(document.title).toBe("Lalu");
|
||||
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/lalu.png");
|
||||
});
|
||||
|
||||
test("blocks a whitespace-only new password", async () => {
|
||||
|
||||
@ -28,9 +28,17 @@ export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// 登录前没有可信 App 上下文;使用通用标题并清掉上次认证遗留的图标,避免误导用户所属 App。
|
||||
globalThis.document.title = t("app.title");
|
||||
globalThis.document.head.querySelector('link[data-external-app-favicon="true"]')?.remove();
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializing && session) {
|
||||
const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview";
|
||||
// Password rotation is optional. An authenticated session always returns to the
|
||||
// requested business page, including sessions created by older server versions.
|
||||
const destination = location.state?.from?.pathname || "/overview";
|
||||
navigate(destination, { replace: true });
|
||||
}
|
||||
}, [initializing, location.state, navigate, session]);
|
||||
@ -44,8 +52,8 @@ export default function LoginPage() {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const nextSession = await login(form);
|
||||
navigate(nextSession.passwordChangeRequired ? "/change-password" : "/overview", { replace: true });
|
||||
await login(form);
|
||||
navigate("/overview", { replace: true });
|
||||
} catch (requestError) {
|
||||
// Every login 401 uses one generic credential message so unknown, disabled and locked accounts cannot be distinguished.
|
||||
setError(requestError?.status === 401 ? t("auth.invalidCredentials") : translateExternalError(requestError, t, "auth.loginFailed"));
|
||||
|
||||
@ -18,11 +18,22 @@ describe("LoginPage", () => {
|
||||
|
||||
test("defaults to English, has no App field and submits only account and password", async () => {
|
||||
const user = userEvent.setup();
|
||||
const staleFavicon = document.createElement("link");
|
||||
staleFavicon.dataset.externalAppFavicon = "true";
|
||||
staleFavicon.rel = "icon";
|
||||
staleFavicon.href = "https://media.example.com/apps/lalu.png";
|
||||
document.head.append(staleFavicon);
|
||||
document.title = "Lalu";
|
||||
const { container } = renderLogin();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Admin System" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "External Admin" })).toBeInTheDocument();
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(document.title).toBe("External Admin");
|
||||
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument();
|
||||
await user.type(screen.getByRole("textbox", { name: "Account" }), "fami-manager");
|
||||
expect(screen.getByRole("heading", { name: "External Admin" })).toBeInTheDocument();
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
|
||||
@ -55,20 +55,9 @@ const CAPABILITY_ICONS = {
|
||||
export default function OverviewPage() {
|
||||
const { session } = useExternalAuth();
|
||||
const { t } = useExternalI18n();
|
||||
const enabledCount = EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length;
|
||||
|
||||
return (
|
||||
<ExternalPage title={t("nav.overview")}>
|
||||
<Box className="external-hero">
|
||||
<Typography className="external-hero-eyebrow">{t("overview.welcome")}</Typography>
|
||||
<Typography className="external-hero-title" component="h2">{session.appName || session.appCode}</Typography>
|
||||
<Box className="external-hero-meta">
|
||||
<Box><Typography color="text.secondary" variant="caption">{t("overview.currentApp")}</Typography><Typography fontWeight={700}>{session.appName || session.appCode} ({session.appCode})</Typography></Box>
|
||||
<Box><Typography color="text.secondary" variant="caption">{t("overview.account")}</Typography><Typography fontWeight={700}>{session.account}</Typography></Box>
|
||||
<Box><Typography color="text.secondary" variant="caption">{t("overview.permissions")}</Typography><Typography fontWeight={700}>{enabledCount} / {EXTERNAL_CAPABILITIES.length}</Typography></Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography className="external-section-title" component="h3">{t("overview.features")}</Typography>
|
||||
<Box className="external-capability-grid">
|
||||
{EXTERNAL_CAPABILITIES.map((item) => {
|
||||
const enabled = hasExternalCapability(session, item.code);
|
||||
|
||||
32
external-admin/src/shared/useExternalSessionBrand.js
Normal file
32
external-admin/src/shared/useExternalSessionBrand.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useExternalSessionBrand(session, fallbackTitle) {
|
||||
const appName = String(session?.appName || session?.appCode || fallbackTitle || "External Admin").trim();
|
||||
const logoUrl = String(session?.logoUrl || "").trim();
|
||||
|
||||
useEffect(() => {
|
||||
// 认证后的 App 品牌只采用服务端 session;session 又来自 apps 行,前端不解析账号名称猜租户。
|
||||
globalThis.document.title = appName;
|
||||
syncBrandFavicon(logoUrl);
|
||||
return () => {
|
||||
globalThis.document.title = fallbackTitle || "External Admin";
|
||||
syncBrandFavicon("");
|
||||
};
|
||||
}, [appName, fallbackTitle, logoUrl]);
|
||||
}
|
||||
|
||||
function syncBrandFavicon(logoUrl) {
|
||||
const selector = 'link[data-external-app-favicon="true"]';
|
||||
const current = globalThis.document?.head?.querySelector(selector);
|
||||
if (!logoUrl) {
|
||||
current?.remove();
|
||||
return;
|
||||
}
|
||||
const link = current || globalThis.document.createElement("link");
|
||||
link.dataset.externalAppFavicon = "true";
|
||||
link.rel = "icon";
|
||||
link.href = logoUrl;
|
||||
if (!current) {
|
||||
globalThis.document.head.append(link);
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@
|
||||
* All directional rules use CSS logical properties because the app also runs RTL (Arabic).
|
||||
*/
|
||||
|
||||
:root {
|
||||
:root[data-external-admin-theme="black-gold"] {
|
||||
--bg-page: #0d0d11;
|
||||
--bg-sidebar: #101015;
|
||||
--bg-header: rgba(14, 14, 19, 0.88);
|
||||
@ -88,7 +88,7 @@
|
||||
|
||||
/* Bigger touch targets on handsets; 16px inputs also stop iOS Safari focus-zoom. */
|
||||
@media (max-width: 899px) {
|
||||
:root {
|
||||
:root[data-external-admin-theme="black-gold"] {
|
||||
--control-height: 44px;
|
||||
--control-font-size: 16px;
|
||||
--control-line-height: 22px;
|
||||
@ -170,6 +170,14 @@ body {
|
||||
min-width: 140px !important;
|
||||
}
|
||||
|
||||
.external-change-password-brand {
|
||||
color: var(--primary-strong);
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.external-full-state {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
@ -216,6 +224,22 @@ body {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.external-mobile-brand {
|
||||
max-width: min(48vw, 180px);
|
||||
color: var(--primary-strong);
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.external-mobile-brand img,
|
||||
.external-sidebar-brand img,
|
||||
.external-change-password-brand img {
|
||||
border-color: var(--primary-border);
|
||||
box-shadow: 0 0 0 1px rgba(214, 181, 110, 0.08), 0 8px 24px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.external-user-avatar {
|
||||
width: 34px !important;
|
||||
height: 34px !important;
|
||||
@ -245,8 +269,11 @@ body {
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.external-logo-name {
|
||||
font-family: var(--font-display) !important;
|
||||
.external-sidebar-brand {
|
||||
color: var(--primary-strong);
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES } from "../constants.js";
|
||||
import { FinanceCoinSellerRechargeOrderList } from "./FinanceCoinSellerRechargeOrderList.jsx";
|
||||
|
||||
afterEach(() => {
|
||||
@ -86,6 +87,15 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
||||
);
|
||||
}, 10000);
|
||||
|
||||
test("MiFaPay and V5Pay expose the complete runtime currency list", () => {
|
||||
const runtimeCurrencies = Intl.supportedValuesOf("currency");
|
||||
|
||||
expect(COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES.mifapay).toEqual(expect.arrayContaining(runtimeCurrencies));
|
||||
expect(COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES.v5pay).toEqual(expect.arrayContaining(runtimeCurrencies));
|
||||
expect(COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES.mifapay).toContain("BRL");
|
||||
expect(COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES.v5pay).toContain("BRL");
|
||||
});
|
||||
|
||||
test("makeup order keeps zero coins while creating a normal recharge record", async () => {
|
||||
const nowMS = new Date(2026, 6, 13, 9, 0, 0, 0).getTime();
|
||||
vi.spyOn(Date, "now").mockReturnValue(nowMS);
|
||||
|
||||
@ -29,9 +29,37 @@ export const COIN_SELLER_RECHARGE_CHAINS = [
|
||||
["BSC", "BSC"],
|
||||
];
|
||||
|
||||
// 三方回单使用 ISO 4217 字母代码,前端不再维护渠道币种白名单;白名单会随渠道扩区而过期,
|
||||
// 造成后端能够校验的订单在录单入口不可选。Intl 数据来自浏览器运行时,并保留旧浏览器所需的现有币种兜底。
|
||||
const FALLBACK_PROVIDER_CURRENCIES = [
|
||||
"AED",
|
||||
"BDT",
|
||||
"BHD",
|
||||
"BRL",
|
||||
"EGP",
|
||||
"IDR",
|
||||
"INR",
|
||||
"KWD",
|
||||
"MXN",
|
||||
"OMR",
|
||||
"PHP",
|
||||
"PKR",
|
||||
"QAR",
|
||||
"SAR",
|
||||
"TRY",
|
||||
"VND",
|
||||
];
|
||||
export const COIN_SELLER_RECHARGE_CURRENCIES = [
|
||||
...new Set(
|
||||
typeof Intl.supportedValuesOf === "function"
|
||||
? [...Intl.supportedValuesOf("currency"), ...FALLBACK_PROVIDER_CURRENCIES]
|
||||
: FALLBACK_PROVIDER_CURRENCIES,
|
||||
),
|
||||
].sort();
|
||||
|
||||
export const COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES = {
|
||||
mifapay: ["PHP", "INR", "IDR", "VND", "SAR", "AED", "BHD", "QAR", "OMR", "KWD", "BDT", "PKR", "EGP"],
|
||||
v5pay: ["PHP", "SAR", "EGP", "AED", "IDR", "INR", "PKR", "TRY", "MXN"],
|
||||
mifapay: COIN_SELLER_RECHARGE_CURRENCIES,
|
||||
v5pay: COIN_SELLER_RECHARGE_CURRENCIES,
|
||||
};
|
||||
|
||||
export const COIN_SELLER_RECHARGE_STATUS = [
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"stylis": "4.4.0",
|
||||
"stylis": "4.2.0",
|
||||
"svgaplayerweb": "2.3.2",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@ -25,7 +25,7 @@ importers:
|
||||
version: 9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@mui/stylis-plugin-rtl':
|
||||
specifier: 9.1.1
|
||||
version: 9.1.1(stylis@4.4.0)
|
||||
version: 9.1.1(stylis@4.2.0)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.100.6
|
||||
version: 5.100.6(react@19.2.5)
|
||||
@ -45,8 +45,8 @@ importers:
|
||||
specifier: ^7.14.2
|
||||
version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
stylis:
|
||||
specifier: 4.4.0
|
||||
version: 4.4.0
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0
|
||||
svgaplayerweb:
|
||||
specifier: 2.3.2
|
||||
version: 2.3.2
|
||||
@ -1876,9 +1876,6 @@ packages:
|
||||
stylis@4.2.0:
|
||||
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
|
||||
|
||||
stylis@4.4.0:
|
||||
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
@ -2591,11 +2588,11 @@ snapshots:
|
||||
'@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)
|
||||
|
||||
'@mui/stylis-plugin-rtl@9.1.1(stylis@4.4.0)':
|
||||
'@mui/stylis-plugin-rtl@9.1.1(stylis@4.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
cssjanus: 2.3.1
|
||||
stylis: 4.4.0
|
||||
stylis: 4.2.0
|
||||
|
||||
'@mui/system@9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)':
|
||||
dependencies:
|
||||
@ -3844,8 +3841,6 @@ snapshots:
|
||||
|
||||
stylis@4.2.0: {}
|
||||
|
||||
stylis@4.4.0: {}
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
@ -63,6 +63,7 @@ export const PERMISSIONS = {
|
||||
coinAdjustmentCreate: "coin-adjustment:create",
|
||||
externalAdminUserView: "external-admin-user:view",
|
||||
externalAdminUserCreate: "external-admin-user:create",
|
||||
externalAdminUserPermissions: "external-admin-user:permissions",
|
||||
externalAdminUserStatus: "external-admin-user:status",
|
||||
externalAdminUserResetPassword: "external-admin-user:reset-password",
|
||||
reportView: "report:view",
|
||||
|
||||
@ -2,10 +2,13 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
|
||||
import {
|
||||
createExternalAdminUser,
|
||||
getExternalAdminUserPermissions,
|
||||
listExternalAdminUsers,
|
||||
listExternalAdminPermissionCatalog,
|
||||
lookupExternalAdminUserTarget,
|
||||
resetExternalAdminUserPassword,
|
||||
updateExternalAdminUserStatus,
|
||||
updateExternalAdminUserPermissions,
|
||||
} from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
@ -14,6 +17,56 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("loads the backend-driven permission catalog and sends an explicit permission snapshot", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(
|
||||
envelope({
|
||||
items: [
|
||||
{
|
||||
capabilities: ["user:list"],
|
||||
code: "user:list",
|
||||
defaultGranted: true,
|
||||
dependencies: [],
|
||||
group: "用户管理",
|
||||
label: "用户列表",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(envelope({ accountId: "71", permissions: ["user:list"], revision: 4 }))
|
||||
.mockResolvedValueOnce(envelope({ ...externalUserFixture(), permissions: [] })),
|
||||
);
|
||||
|
||||
const catalog = await listExternalAdminPermissionCatalog("fami");
|
||||
const current = await getExternalAdminUserPermissions("fami", 71);
|
||||
const updated = await updateExternalAdminUserPermissions("fami", 71, [], 4);
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls;
|
||||
expect(calls.map(([, init]) => init?.method)).toEqual(["GET", "GET", "PUT"]);
|
||||
expect(String(calls[0][0])).toContain("/api/v1/admin/external-admin-users/permission-catalog");
|
||||
expect(String(calls[1][0])).toContain("/api/v1/admin/external-admin-users/71/permissions");
|
||||
expect(String(calls[2][0])).toContain("/api/v1/admin/external-admin-users/71/permissions");
|
||||
expect(requestBody(calls[2][1])).toEqual({ expectedRevision: 4, permissions: [] });
|
||||
expect(catalog).toEqual({
|
||||
items: [
|
||||
{
|
||||
capabilities: ["user:list"],
|
||||
code: "user:list",
|
||||
defaultGranted: true,
|
||||
dependencies: [],
|
||||
group: "用户管理",
|
||||
label: "用户列表",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
expect(current).toEqual({ accountId: "71", permissions: ["user:list"], revision: 4 });
|
||||
expect(updated.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
test("external admin APIs pin every read and write to the explicit App scope", async () => {
|
||||
setSelectedAppCode("lalu");
|
||||
const externalUser = externalUserFixture();
|
||||
@ -76,6 +129,7 @@ test("external admin APIs pin every read and write to the explicit App scope", a
|
||||
userId: "user-1001",
|
||||
},
|
||||
permissions: ["user:list", "room:edit"],
|
||||
permissionRevision: 3,
|
||||
status: "active",
|
||||
username: "ops.fami",
|
||||
},
|
||||
@ -114,6 +168,7 @@ function externalUserFixture() {
|
||||
username: "Fami user",
|
||||
},
|
||||
permissions: ["user:list", "room:edit"],
|
||||
permission_revision: 3,
|
||||
status: "active",
|
||||
updated_at_ms: "1784077200000",
|
||||
username: "ops.fami",
|
||||
|
||||
@ -17,6 +17,7 @@ export interface ExternalAdminUserDto {
|
||||
lastLoginAtMs?: number;
|
||||
linkedUser: ExternalAdminLinkedUserDto;
|
||||
permissions: string[];
|
||||
permissionRevision: number;
|
||||
status: ExternalAdminUserStatus;
|
||||
updatedAtMs?: number;
|
||||
username: string;
|
||||
@ -26,8 +27,29 @@ export interface ExternalAdminUserTargetDto extends ExternalAdminLinkedUserDto {
|
||||
existingExternalAdminUser?: ExternalAdminUserDto;
|
||||
}
|
||||
|
||||
export interface ExternalAdminPermissionCatalogItemDto {
|
||||
capabilities: string[];
|
||||
code: string;
|
||||
defaultGranted: boolean;
|
||||
dependencies: string[];
|
||||
group: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ExternalAdminPermissionCatalogDto {
|
||||
items: ExternalAdminPermissionCatalogItemDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ExternalAdminUserPermissionsDto {
|
||||
accountId: string;
|
||||
permissions: string[];
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface CreateExternalAdminUserPayload {
|
||||
password: string;
|
||||
permissions?: string[];
|
||||
targetUserId: string;
|
||||
username: string;
|
||||
}
|
||||
@ -58,6 +80,32 @@ export async function lookupExternalAdminUserTarget(
|
||||
return normalizeTarget(data);
|
||||
}
|
||||
|
||||
export async function listExternalAdminPermissionCatalog(
|
||||
appCode: string,
|
||||
): Promise<ExternalAdminPermissionCatalogDto> {
|
||||
const endpoint = API_ENDPOINTS.listExternalAdminPermissionCatalog;
|
||||
const data = await apiRequest<RawRecord>(apiEndpointPath(API_OPERATIONS.listExternalAdminPermissionCatalog), {
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
});
|
||||
return normalizePermissionCatalog(data);
|
||||
}
|
||||
|
||||
export async function getExternalAdminUserPermissions(
|
||||
appCode: string,
|
||||
id: EntityId,
|
||||
): Promise<ExternalAdminUserPermissionsDto> {
|
||||
const endpoint = API_ENDPOINTS.getExternalAdminUserPermissions;
|
||||
const data = await apiRequest<RawRecord>(
|
||||
apiEndpointPath(API_OPERATIONS.getExternalAdminUserPermissions, { id }),
|
||||
{
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizeUserPermissions(data);
|
||||
}
|
||||
|
||||
export async function createExternalAdminUser(
|
||||
appCode: string,
|
||||
payload: CreateExternalAdminUserPayload,
|
||||
@ -108,6 +156,24 @@ export async function resetExternalAdminUserPassword(
|
||||
return normalizeExternalAdminUser(data);
|
||||
}
|
||||
|
||||
export async function updateExternalAdminUserPermissions(
|
||||
appCode: string,
|
||||
id: EntityId,
|
||||
permissions: string[],
|
||||
expectedRevision: number,
|
||||
): Promise<ExternalAdminUserDto> {
|
||||
const endpoint = API_ENDPOINTS.updateExternalAdminUserPermissions;
|
||||
const data = await apiRequest<RawRecord, { expectedRevision: number; permissions: string[] }>(
|
||||
apiEndpointPath(API_OPERATIONS.updateExternalAdminUserPermissions, { id }),
|
||||
{
|
||||
body: { expectedRevision, permissions },
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizeExternalAdminUser(data);
|
||||
}
|
||||
|
||||
function appScopeHeaders(appCode: string) {
|
||||
// 外管账号是强 App 隔离数据;显式固定请求头,避免切换 App 后全局请求上下文把写操作送到另一租户。
|
||||
return { "X-App-Code": String(appCode || "").trim().toLowerCase() };
|
||||
@ -151,6 +217,7 @@ function normalizeExternalAdminUser(raw: RawRecord): ExternalAdminUserDto {
|
||||
lastLoginAtMs: optionalNumber(item.lastLoginAtMs ?? item.last_login_at_ms),
|
||||
linkedUser,
|
||||
permissions: stringArray(item.permissions),
|
||||
permissionRevision: numberValue(item.permissionRevision ?? item.permission_revision),
|
||||
status: status === "disabled" ? "disabled" : "active",
|
||||
updatedAtMs: optionalNumber(item.updatedAtMs ?? item.updated_at_ms),
|
||||
username: stringValue(item.username ?? item.account ?? item.accountName ?? item.account_name),
|
||||
@ -178,6 +245,34 @@ function normalizeTarget(raw: RawRecord): ExternalAdminUserTargetDto {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePermissionCatalog(raw: RawRecord): ExternalAdminPermissionCatalogDto {
|
||||
const item = asRecord(raw) || {};
|
||||
const items = (Array.isArray(item.items) ? item.items : [])
|
||||
.map((entry) => {
|
||||
const permission = asRecord(entry) || {};
|
||||
return {
|
||||
capabilities: stringArray(permission.capabilities),
|
||||
code: stringValue(permission.code),
|
||||
defaultGranted: Boolean(permission.defaultGranted ?? permission.default_granted),
|
||||
dependencies: stringArray(permission.dependencies),
|
||||
group: stringValue(permission.group),
|
||||
label: stringValue(permission.label),
|
||||
};
|
||||
})
|
||||
// 空编码无法用于提交,直接从可选目录剔除,避免 UI 生成不可保存的勾选项。
|
||||
.filter((permission) => permission.code);
|
||||
return { items, total: numberValue(item.total, items.length) };
|
||||
}
|
||||
|
||||
function normalizeUserPermissions(raw: RawRecord): ExternalAdminUserPermissionsDto {
|
||||
const item = asRecord(raw) || {};
|
||||
return {
|
||||
accountId: stringValue(item.accountId ?? item.account_id),
|
||||
permissions: stringArray(item.permissions),
|
||||
revision: numberValue(item.revision),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUser(raw: RawRecord): ExternalAdminLinkedUserDto {
|
||||
const user = asRecord(raw) || {};
|
||||
return {
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { ExternalAdminPermissionDrawer } from "./ExternalAdminPermissionDrawer.jsx";
|
||||
import { ExternalAdminUserFormDialog } from "./ExternalAdminUserFormDialog.jsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const abilities = { canCreate: true, canManagePermissions: true };
|
||||
|
||||
test("disables account creation when a successful permission catalog is empty", () => {
|
||||
render(
|
||||
<ExternalAdminUserFormDialog
|
||||
abilities={abilities}
|
||||
catalog={[]}
|
||||
catalogError=""
|
||||
catalogLoading={false}
|
||||
form={{
|
||||
confirmPassword: "StrongPass123!",
|
||||
displayUserId: "1001",
|
||||
password: "StrongPass123!",
|
||||
permissions: [],
|
||||
username: "ops.fami",
|
||||
}}
|
||||
loading={false}
|
||||
lookupLoading={false}
|
||||
open
|
||||
targetUser={{ displayUserId: "1001", userId: "user-1", username: "Fami user" }}
|
||||
onClose={vi.fn()}
|
||||
onDisplayUserIdChange={vi.fn()}
|
||||
onFormChange={vi.fn()}
|
||||
onLookup={vi.fn()}
|
||||
onReloadCatalog={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "创建" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("disables permission updates when a successful catalog is empty", () => {
|
||||
render(
|
||||
<ExternalAdminPermissionDrawer
|
||||
abilities={abilities}
|
||||
catalog={[]}
|
||||
catalogError=""
|
||||
catalogLoading={false}
|
||||
error=""
|
||||
loading={false}
|
||||
permissions={[]}
|
||||
permissionsLoading={false}
|
||||
user={{ id: 71, username: "ops.fami" }}
|
||||
onChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onReloadCatalog={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "保存权限" })).toBeDisabled();
|
||||
});
|
||||
@ -0,0 +1,60 @@
|
||||
import { ExternalAdminPermissionSelector } from "@/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx";
|
||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
|
||||
export function ExternalAdminPermissionDrawer({
|
||||
abilities,
|
||||
catalog,
|
||||
catalogError,
|
||||
catalogLoading,
|
||||
error,
|
||||
loading,
|
||||
onChange,
|
||||
onClose,
|
||||
onReloadCatalog,
|
||||
onRetry,
|
||||
onSubmit,
|
||||
permissions,
|
||||
permissionsLoading,
|
||||
user,
|
||||
}) {
|
||||
const open = Boolean(user);
|
||||
const unavailable = Boolean(
|
||||
!catalog.length || catalogError || error || catalogLoading || permissionsLoading || loading,
|
||||
);
|
||||
|
||||
return (
|
||||
<SideDrawer
|
||||
actions={
|
||||
<>
|
||||
<Button disabled={loading} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!abilities.canManagePermissions || unavailable}
|
||||
variant="primary"
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{loading ? "保存中" : "保存权限"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
contentClassName={styles.permissionDrawerBody}
|
||||
open={open}
|
||||
title={`配置权限${user?.username ? ` · ${user.username}` : ""}`}
|
||||
width="detail"
|
||||
onClose={onClose}
|
||||
>
|
||||
<ExternalAdminPermissionSelector
|
||||
catalog={catalog}
|
||||
disabled={!abilities.canManagePermissions || loading}
|
||||
error={catalogError || error}
|
||||
loading={catalogLoading || permissionsLoading}
|
||||
value={permissions}
|
||||
onChange={onChange}
|
||||
onRetry={catalogError ? onReloadCatalog : error ? onRetry : undefined}
|
||||
/>
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||
import {
|
||||
updatePermissionGroup,
|
||||
updatePermissionSelection,
|
||||
} from "@/features/external-admin-users/permissionSelection.js";
|
||||
|
||||
export { updatePermissionSelection } from "@/features/external-admin-users/permissionSelection.js";
|
||||
|
||||
export function ExternalAdminPermissionSelector({
|
||||
catalog = [],
|
||||
disabled = false,
|
||||
error = "",
|
||||
loading = false,
|
||||
onChange,
|
||||
onRetry,
|
||||
value = [],
|
||||
}) {
|
||||
const groups = useMemo(() => groupCatalog(catalog), [catalog]);
|
||||
const knownCodes = useMemo(() => catalog.map((permission) => permission.code), [catalog]);
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const selectedCount = knownCodes.filter((code) => selected.has(code)).length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.permissionState} role="status">
|
||||
<CircularProgress size={22} />
|
||||
<span>加载权限目录</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.permissionState} role="alert">
|
||||
<span>{error}</span>
|
||||
{onRetry ? <Button onClick={onRetry}>重新加载</Button> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.permissionSelector}>
|
||||
<div className={styles.permissionToolbar}>
|
||||
<strong>
|
||||
已选 {selectedCount} / {knownCodes.length} 项
|
||||
</strong>
|
||||
<div className={styles.permissionToolbarActions}>
|
||||
<Button
|
||||
disabled={disabled || selectedCount === knownCodes.length}
|
||||
onClick={() => onChange?.(knownCodes)}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button disabled={disabled || selectedCount === 0} onClick={() => onChange?.([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.length ? (
|
||||
<div className={styles.permissionGroups}>
|
||||
{groups.map((group) => {
|
||||
const groupCodes = group.items.map((permission) => permission.code);
|
||||
const groupSelectedCount = groupCodes.filter((code) => selected.has(code)).length;
|
||||
return (
|
||||
<section className={styles.permissionGroup} key={group.name}>
|
||||
<header className={styles.permissionGroupHeader}>
|
||||
<strong>{group.name}</strong>
|
||||
<label>
|
||||
<Checkbox
|
||||
checked={groupSelectedCount === groupCodes.length}
|
||||
disabled={disabled}
|
||||
indeterminate={groupSelectedCount > 0 && groupSelectedCount < groupCodes.length}
|
||||
slotProps={{ input: { "aria-label": `${group.name}全选` } }}
|
||||
size="small"
|
||||
onChange={(event) =>
|
||||
onChange?.(
|
||||
updatePermissionGroup(
|
||||
catalog,
|
||||
value,
|
||||
groupCodes,
|
||||
event.target.checked,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{groupSelectedCount}/{groupCodes.length}
|
||||
</span>
|
||||
</label>
|
||||
</header>
|
||||
<div className={styles.permissionOptions}>
|
||||
{group.items.map((permission) => {
|
||||
return (
|
||||
<label className={styles.permissionOption} key={permission.code}>
|
||||
<Checkbox
|
||||
checked={selected.has(permission.code)}
|
||||
disabled={disabled}
|
||||
slotProps={{ input: { "aria-label": permission.label } }}
|
||||
size="small"
|
||||
onChange={(event) =>
|
||||
onChange?.(
|
||||
updatePermissionSelection(
|
||||
catalog,
|
||||
value,
|
||||
permission.code,
|
||||
event.target.checked,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{permission.label}</strong>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.permissionState}>当前无可配置权限</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupCatalog(catalog) {
|
||||
const groups = new Map();
|
||||
catalog.forEach((permission) => {
|
||||
const groupName = permission.group || "其他";
|
||||
if (!groups.has(groupName)) {
|
||||
groups.set(groupName, []);
|
||||
}
|
||||
groups.get(groupName).push(permission);
|
||||
});
|
||||
return [...groups].map(([name, items]) => ({ items, name }));
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
ExternalAdminPermissionSelector,
|
||||
updatePermissionSelection,
|
||||
} from "./ExternalAdminPermissionSelector.jsx";
|
||||
import { defaultPermissionSelection } from "@/features/external-admin-users/permissionSelection.js";
|
||||
|
||||
const catalog = [
|
||||
{ code: "user:list", dependencies: [], group: "用户管理", label: "用户列表" },
|
||||
{ code: "user:update", dependencies: ["user:list"], group: "用户管理", label: "编辑用户" },
|
||||
{ code: "room:list", dependencies: [], group: "房间管理", label: "房间列表" },
|
||||
];
|
||||
|
||||
describe("ExternalAdminPermissionSelector", () => {
|
||||
test("adds required permissions and removes actions whose dependency is cleared", () => {
|
||||
expect(updatePermissionSelection(catalog, [], "user:update", true)).toEqual(["user:list", "user:update"]);
|
||||
expect(
|
||||
updatePermissionSelection(catalog, ["user:list", "user:update", "room:list"], "user:list", false),
|
||||
).toEqual(["room:list"]);
|
||||
});
|
||||
|
||||
test("expands dependencies declared by default-granted catalog entries", () => {
|
||||
expect(
|
||||
defaultPermissionSelection([
|
||||
{ code: "user:list", defaultGranted: false, dependencies: [] },
|
||||
{ code: "user:update", defaultGranted: true, dependencies: ["user:list"] },
|
||||
]),
|
||||
).toEqual(["user:list", "user:update"]);
|
||||
});
|
||||
|
||||
test("resolves cyclic dependencies without recursion or removal loops", () => {
|
||||
const cyclicCatalog = [
|
||||
{ code: "level:view", dependencies: ["vip:view"], group: "等级", label: "等级" },
|
||||
{ code: "vip:view", dependencies: ["level:view"], group: "等级", label: "VIP" },
|
||||
];
|
||||
|
||||
expect(updatePermissionSelection(cyclicCatalog, [], "level:view", true)).toEqual([
|
||||
"level:view",
|
||||
"vip:view",
|
||||
]);
|
||||
expect(updatePermissionSelection(cyclicCatalog, ["level:view", "vip:view"], "level:view", false)).toEqual([]);
|
||||
});
|
||||
|
||||
test("renders backend groups and supports explicit select all and clear", () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<ExternalAdminPermissionSelector catalog={catalog} onChange={onChange} value={["user:list"]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("已选 1 / 3 项")).toBeInTheDocument();
|
||||
expect(screen.getByText("用户管理")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "全选" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(["user:list", "user:update", "room:list"]);
|
||||
|
||||
rerender(<ExternalAdminPermissionSelector catalog={catalog} onChange={onChange} value={catalog.map((item) => item.code)} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "清空" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
});
|
||||
@ -8,9 +8,13 @@ import {
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||
import { ExternalAdminPermissionSelector } from "@/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx";
|
||||
|
||||
export function ExternalAdminUserFormDialog({
|
||||
abilities,
|
||||
catalog,
|
||||
catalogError,
|
||||
catalogLoading,
|
||||
form,
|
||||
loading,
|
||||
lookupLoading,
|
||||
@ -18,6 +22,7 @@ export function ExternalAdminUserFormDialog({
|
||||
onDisplayUserIdChange,
|
||||
onFormChange,
|
||||
onLookup,
|
||||
onReloadCatalog,
|
||||
onSubmit,
|
||||
open,
|
||||
targetUser,
|
||||
@ -30,7 +35,14 @@ export function ExternalAdminUserFormDialog({
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="large"
|
||||
submitDisabled={!abilities.canCreate || loading || !targetUser?.userId || Boolean(existingAccount)}
|
||||
submitDisabled={
|
||||
!abilities.canCreate ||
|
||||
loading ||
|
||||
!targetUser?.userId ||
|
||||
Boolean(existingAccount) ||
|
||||
(abilities.canManagePermissions &&
|
||||
(catalogLoading || Boolean(catalogError) || !catalog.length))
|
||||
}
|
||||
submitLabel="创建"
|
||||
title="创建外管用户"
|
||||
onClose={onClose}
|
||||
@ -108,6 +120,20 @@ export function ExternalAdminUserFormDialog({
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
|
||||
{abilities.canManagePermissions ? (
|
||||
<AdminFormSection title="外管权限">
|
||||
<ExternalAdminPermissionSelector
|
||||
catalog={catalog}
|
||||
disabled={loading}
|
||||
error={catalogError}
|
||||
loading={catalogLoading}
|
||||
value={form.permissions || []}
|
||||
onChange={(permissions) => onFormChange({ permissions })}
|
||||
onRetry={onReloadCatalog}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
) : null}
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -50,6 +50,137 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permissionSelector {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.permissionToolbar {
|
||||
display: flex;
|
||||
min-height: var(--control-height);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.permissionToolbar > strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--control-font-size);
|
||||
}
|
||||
|
||||
.permissionToolbarActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.permissionToolbarActions :global(.MuiButton-root) {
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.permissionGroups {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.permissionGroup {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.permissionGroupHeader {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-3);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.permissionGroupHeader > strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--control-font-size);
|
||||
}
|
||||
|
||||
.permissionGroupHeader label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.permissionOptions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.permissionOption {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
background var(--motion-fast) var(--ease-standard),
|
||||
transform var(--motion-base) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.permissionOption:hover {
|
||||
border-color: var(--primary-border-strong);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.permissionOption:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.permissionOption > span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.permissionOption strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permissionOption strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--control-font-size);
|
||||
}
|
||||
|
||||
.permissionState {
|
||||
display: grid;
|
||||
min-height: 150px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.permissionDrawerBody {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.lookupRow {
|
||||
grid-template-columns: 1fr;
|
||||
@ -58,4 +189,24 @@
|
||||
.lookupButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.permissionToolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.permissionToolbarActions,
|
||||
.permissionToolbarActions :global(.MuiButton-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.permissionOptions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.permissionOption {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,16 +3,21 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||
import {
|
||||
createExternalAdminUser,
|
||||
getExternalAdminUserPermissions,
|
||||
listExternalAdminUsers,
|
||||
listExternalAdminPermissionCatalog,
|
||||
lookupExternalAdminUserTarget,
|
||||
resetExternalAdminUserPassword,
|
||||
updateExternalAdminUserPermissions,
|
||||
updateExternalAdminUserStatus,
|
||||
} from "@/features/external-admin-users/api";
|
||||
import { useExternalAdminUserAbilities } from "@/features/external-admin-users/permissions.js";
|
||||
import { defaultPermissionSelection } from "@/features/external-admin-users/permissionSelection.js";
|
||||
import {
|
||||
externalAdminUserCreateFormSchema,
|
||||
externalAdminUserLookupSchema,
|
||||
externalAdminUserPasswordFormSchema,
|
||||
externalAdminUserPermissionsFormSchema,
|
||||
} from "@/features/external-admin-users/schema";
|
||||
import { toPageQuery } from "@/shared/api/query";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
@ -23,8 +28,9 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const pageSize = 50;
|
||||
const emptyPage = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyCreateForm = () => ({ confirmPassword: "", displayUserId: "", password: "", username: "" });
|
||||
const emptyCreateForm = () => ({ confirmPassword: "", displayUserId: "", password: "", permissions: [], username: "" });
|
||||
const emptyPasswordForm = () => ({ confirmPassword: "", password: "" });
|
||||
const emptyPermissionCatalog = { items: [], total: 0 };
|
||||
|
||||
export function useExternalAdminUsersPage() {
|
||||
const { appCode } = useAppScope();
|
||||
@ -34,6 +40,7 @@ export function useExternalAdminUsersPage() {
|
||||
const { showToast } = useToast();
|
||||
const currentAppCodeRef = useRef(appCode);
|
||||
const lookupRequestRef = useRef(0);
|
||||
const permissionRequestRef = useRef(0);
|
||||
|
||||
const [keyword, setKeywordState] = useState("");
|
||||
const [status, setStatusState] = useState("");
|
||||
@ -43,10 +50,15 @@ export function useExternalAdminUsersPage() {
|
||||
const [createForm, setCreateFormState] = useState(emptyCreateForm);
|
||||
const [targetUser, setTargetUser] = useState(null);
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [createPermissionsReady, setCreatePermissionsReady] = useState(false);
|
||||
const [passwordUser, setPasswordUser] = useState(null);
|
||||
const [passwordForm, setPasswordFormState] = useState(emptyPasswordForm);
|
||||
const [actionAppCode, setActionAppCode] = useState("");
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [permissionUser, setPermissionUser] = useState(null);
|
||||
const [permissionForm, setPermissionFormState] = useState({ expectedRevision: null, permissions: [] });
|
||||
const [permissionLoading, setPermissionLoading] = useState(false);
|
||||
const [permissionError, setPermissionError] = useState("");
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => toPageQuery({ keyword, page, pageSize, status }),
|
||||
@ -64,6 +76,22 @@ export function useExternalAdminUsersPage() {
|
||||
initialData: emptyPage,
|
||||
queryKey: ["external-admin-users", appCode, requestQuery],
|
||||
});
|
||||
const catalogQueryFn = useCallback(() => listExternalAdminPermissionCatalog(appCode), [appCode]);
|
||||
const {
|
||||
data: permissionCatalog = emptyPermissionCatalog,
|
||||
error: catalogQueryError,
|
||||
loading: catalogLoading,
|
||||
reload: reloadPermissionCatalog,
|
||||
} = useAdminQuery(catalogQueryFn, {
|
||||
enabled: Boolean(appCode && abilities.canManagePermissions),
|
||||
errorMessage: "加载外管权限目录失败",
|
||||
initialData: emptyPermissionCatalog,
|
||||
queryKey: ["external-admin-permission-catalog", appCode],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const catalogEmpty =
|
||||
abilities.canManagePermissions && !catalogLoading && !catalogQueryError && permissionCatalog.items.length === 0;
|
||||
const catalogError = catalogQueryError || (catalogEmpty ? "外管权限目录为空,请联系管理员" : "");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// 写操作完成回调依赖最新租户;布局 effect 在用户能继续交互前同步边界,又不在渲染阶段读写 ref。
|
||||
@ -73,20 +101,51 @@ export function useExternalAdminUsersPage() {
|
||||
useEffect(() => {
|
||||
// App 是外管账号的租户边界;切换后必须立即丢弃账号、密码和用户匹配结果,禁止跨 App 复用敏感表单。
|
||||
lookupRequestRef.current += 1;
|
||||
permissionRequestRef.current += 1;
|
||||
setCreateOpen(false);
|
||||
setCreateFormState(emptyCreateForm());
|
||||
setCreatePermissionsReady(false);
|
||||
setTargetUser(null);
|
||||
setLookupLoading(false);
|
||||
setPasswordUser(null);
|
||||
setPasswordFormState(emptyPasswordForm());
|
||||
setActionAppCode("");
|
||||
setLoadingAction("");
|
||||
setPermissionUser(null);
|
||||
setPermissionFormState({ expectedRevision: null, permissions: [] });
|
||||
setPermissionLoading(false);
|
||||
setPermissionError("");
|
||||
setKeywordState("");
|
||||
setStatusState("");
|
||||
setPage(1);
|
||||
setMergedPage({ ...emptyPage, appCode });
|
||||
}, [appCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!createOpen ||
|
||||
!abilities.canManagePermissions ||
|
||||
createPermissionsReady ||
|
||||
catalogLoading ||
|
||||
catalogError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 默认值完全由目录声明,避免前端把未来新增的高风险能力自动授权;即使默认集合为空,也显式保留 []。
|
||||
setCreateFormState((current) => ({
|
||||
...current,
|
||||
permissions: defaultPermissionSelection(permissionCatalog.items || []),
|
||||
}));
|
||||
setCreatePermissionsReady(true);
|
||||
}, [
|
||||
abilities.canManagePermissions,
|
||||
catalogError,
|
||||
catalogLoading,
|
||||
createOpen,
|
||||
createPermissionsReady,
|
||||
permissionCatalog.items,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setMergedPage({ ...emptyPage, appCode });
|
||||
}, [appCode, keyword, status]);
|
||||
@ -134,6 +193,9 @@ export function useExternalAdminUsersPage() {
|
||||
};
|
||||
|
||||
const setCreateForm = (patch) => {
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "permissions")) {
|
||||
setCreatePermissionsReady(true);
|
||||
}
|
||||
setCreateFormState((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
const changeDisplayUserId = (value) => {
|
||||
@ -144,7 +206,15 @@ export function useExternalAdminUsersPage() {
|
||||
};
|
||||
const openCreate = () => {
|
||||
lookupRequestRef.current += 1;
|
||||
setCreateFormState(emptyCreateForm());
|
||||
const catalogReady = !catalogLoading && !catalogError;
|
||||
setCreateFormState({
|
||||
...emptyCreateForm(),
|
||||
permissions:
|
||||
abilities.canManagePermissions && catalogReady
|
||||
? defaultPermissionSelection(permissionCatalog.items || [])
|
||||
: [],
|
||||
});
|
||||
setCreatePermissionsReady(!abilities.canManagePermissions || catalogReady);
|
||||
setTargetUser(null);
|
||||
setLookupLoading(false);
|
||||
setActionAppCode(appCode);
|
||||
@ -157,6 +227,7 @@ export function useExternalAdminUsersPage() {
|
||||
lookupRequestRef.current += 1;
|
||||
setCreateOpen(false);
|
||||
setCreateFormState(emptyCreateForm());
|
||||
setCreatePermissionsReady(false);
|
||||
setTargetUser(null);
|
||||
setLookupLoading(false);
|
||||
setActionAppCode("");
|
||||
@ -206,6 +277,10 @@ export function useExternalAdminUsersPage() {
|
||||
const submitCreate = async (event) => {
|
||||
event.preventDefault();
|
||||
const scope = actionAppCode;
|
||||
if (!abilities.canCreate) {
|
||||
showToast("没有创建外管用户的权限", "error");
|
||||
return;
|
||||
}
|
||||
if (!scope || scope !== appCode) {
|
||||
showToast("应用已切换,请重新创建", "error");
|
||||
return;
|
||||
@ -218,6 +293,10 @@ export function useExternalAdminUsersPage() {
|
||||
showToast("该 App 用户已绑定外管账号", "error");
|
||||
return;
|
||||
}
|
||||
if (catalogLoading || catalogError || permissionCatalog.items.length === 0) {
|
||||
showToast(catalogError || "外管权限目录尚未加载", "error");
|
||||
return;
|
||||
}
|
||||
let formPayload;
|
||||
try {
|
||||
formPayload = parseForm(externalAdminUserCreateFormSchema, createForm);
|
||||
@ -230,6 +309,8 @@ export function useExternalAdminUsersPage() {
|
||||
try {
|
||||
await createExternalAdminUser(scope, {
|
||||
password: formPayload.password,
|
||||
// 创建权限已收口为 create + permissions,始终提交显式快照,[] 表示主动创建零权限账号。
|
||||
permissions: formPayload.permissions || [],
|
||||
// 始终提交查询接口返回的稳定 userId,短 ID / 靓号只用于定位,避免后续改号造成错误绑定。
|
||||
targetUserId: targetUser.userId,
|
||||
username: formPayload.username,
|
||||
@ -239,6 +320,7 @@ export function useExternalAdminUsersPage() {
|
||||
}
|
||||
setCreateOpen(false);
|
||||
setCreateFormState(emptyCreateForm());
|
||||
setCreatePermissionsReady(false);
|
||||
setTargetUser(null);
|
||||
setActionAppCode("");
|
||||
showToast("外管用户已创建", "success");
|
||||
@ -341,10 +423,121 @@ export function useExternalAdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPermissions = useCallback(
|
||||
async (item, scope = appCode) => {
|
||||
const requestId = permissionRequestRef.current + 1;
|
||||
permissionRequestRef.current = requestId;
|
||||
setPermissionLoading(true);
|
||||
setPermissionError("");
|
||||
try {
|
||||
const result = await getExternalAdminUserPermissions(scope, item.id);
|
||||
if (currentAppCodeRef.current !== scope || permissionRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setPermissionFormState({
|
||||
// 详情和目录可能并发返回,先保留服务端快照;提交边界再按届时已加载的目录过滤。
|
||||
expectedRevision: result.revision,
|
||||
permissions: result.permissions,
|
||||
});
|
||||
} catch (err) {
|
||||
if (currentAppCodeRef.current === scope && permissionRequestRef.current === requestId) {
|
||||
setPermissionError(err.message || "加载账号权限失败");
|
||||
}
|
||||
} finally {
|
||||
if (currentAppCodeRef.current === scope && permissionRequestRef.current === requestId) {
|
||||
setPermissionLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[appCode],
|
||||
);
|
||||
|
||||
const openPermissions = (item) => {
|
||||
setPermissionUser(item);
|
||||
setPermissionFormState({ expectedRevision: null, permissions: [] });
|
||||
setPermissionError("");
|
||||
setActionAppCode(appCode);
|
||||
void loadPermissions(item, appCode);
|
||||
};
|
||||
const closePermissions = () => {
|
||||
if (loadingAction.startsWith("permissions:")) {
|
||||
return;
|
||||
}
|
||||
permissionRequestRef.current += 1;
|
||||
setPermissionUser(null);
|
||||
setPermissionFormState({ expectedRevision: null, permissions: [] });
|
||||
setPermissionLoading(false);
|
||||
setPermissionError("");
|
||||
setActionAppCode("");
|
||||
};
|
||||
const retryPermissions = () => {
|
||||
if (permissionUser) {
|
||||
void loadPermissions(permissionUser, actionAppCode || appCode);
|
||||
}
|
||||
};
|
||||
const setPermissions = (permissions) => {
|
||||
setPermissionFormState((current) => ({ ...current, permissions }));
|
||||
};
|
||||
const submitPermissions = async () => {
|
||||
const scope = actionAppCode;
|
||||
const item = permissionUser;
|
||||
if (!abilities.canManagePermissions) {
|
||||
showToast("没有配置外管权限的权限", "error");
|
||||
return;
|
||||
}
|
||||
if (!item || !scope || scope !== appCode) {
|
||||
showToast("应用已切换,请重新操作", "error");
|
||||
return;
|
||||
}
|
||||
if (catalogLoading || catalogError || permissionCatalog.items.length === 0) {
|
||||
showToast(catalogError || "外管权限目录尚未加载", "error");
|
||||
return;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = parseForm(externalAdminUserPermissionsFormSchema, permissionForm);
|
||||
} catch (err) {
|
||||
showToast(err.message || "权限参数不正确", "error");
|
||||
return;
|
||||
}
|
||||
const knownCodes = new Set((permissionCatalog.items || []).map((permission) => permission.code));
|
||||
const permissions = payload.permissions.filter((permission) => knownCodes.has(permission));
|
||||
|
||||
setPermissionError("");
|
||||
setLoadingAction(`permissions:${item.id}`);
|
||||
try {
|
||||
// 目录是允许写入的唯一边界;被服务端下线的旧编码不应在本次编辑中重新写回。
|
||||
await updateExternalAdminUserPermissions(scope, item.id, permissions, payload.expectedRevision);
|
||||
if (currentAppCodeRef.current !== scope) {
|
||||
return;
|
||||
}
|
||||
setPermissionUser(null);
|
||||
setPermissionFormState({ expectedRevision: null, permissions: [] });
|
||||
setPermissionError("");
|
||||
setActionAppCode("");
|
||||
showToast("权限已更新,该账号需重新登录", "success");
|
||||
await refreshList(scope);
|
||||
} catch (err) {
|
||||
if (currentAppCodeRef.current === scope) {
|
||||
const message =
|
||||
Number(err?.status) === 409
|
||||
? "账号权限已被更新,请重新加载"
|
||||
: "更新外管权限失败";
|
||||
setPermissionError(message);
|
||||
showToast(message, "error");
|
||||
}
|
||||
} finally {
|
||||
if (currentAppCodeRef.current === scope) {
|
||||
setLoadingAction("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
closeCreate,
|
||||
closePassword,
|
||||
closePermissions,
|
||||
createForm,
|
||||
createOpen,
|
||||
data,
|
||||
@ -356,21 +549,33 @@ export function useExternalAdminUsersPage() {
|
||||
lookupTarget,
|
||||
openCreate,
|
||||
openPassword,
|
||||
openPermissions,
|
||||
page,
|
||||
pageSize,
|
||||
passwordForm,
|
||||
passwordUser,
|
||||
permissionCatalog,
|
||||
permissionError,
|
||||
permissionForm,
|
||||
permissionLoading,
|
||||
permissionUser,
|
||||
catalogError,
|
||||
catalogLoading,
|
||||
reload,
|
||||
resetFilters,
|
||||
reloadPermissionCatalog,
|
||||
retryPermissions,
|
||||
setCreateForm,
|
||||
setDisplayUserId: changeDisplayUserId,
|
||||
setKeyword,
|
||||
setPage,
|
||||
setPasswordForm,
|
||||
setPermissions,
|
||||
setStatus,
|
||||
status,
|
||||
submitCreate,
|
||||
submitPassword,
|
||||
submitPermissions,
|
||||
targetUser,
|
||||
toggleStatus,
|
||||
};
|
||||
|
||||
@ -2,12 +2,18 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
abilities: { canCreate: true, canResetPassword: true, canStatus: true, canView: true },
|
||||
abilities: { canCreate: true, canManagePermissions: true, canResetPassword: true, canStatus: true, canView: true },
|
||||
appCode: "fami",
|
||||
catalogError: "",
|
||||
catalogLoading: false,
|
||||
catalogPage: null,
|
||||
catalogQueryOptions: null,
|
||||
confirm: vi.fn(),
|
||||
createExternalAdminUser: vi.fn(),
|
||||
getExternalAdminUserPermissions: vi.fn(),
|
||||
invalidateQueries: vi.fn(),
|
||||
listExternalAdminUsers: vi.fn(),
|
||||
listExternalAdminPermissionCatalog: vi.fn(),
|
||||
lookupExternalAdminUserTarget: vi.fn(),
|
||||
queryOptions: null,
|
||||
queryPage: { items: [], page: 1, pageSize: 50, total: 0 },
|
||||
@ -15,6 +21,7 @@ const mocks = vi.hoisted(() => ({
|
||||
resetExternalAdminUserPassword: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
updateExternalAdminUserStatus: vi.fn(),
|
||||
updateExternalAdminUserPermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
@ -27,10 +34,13 @@ vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({
|
||||
|
||||
vi.mock("@/features/external-admin-users/api", () => ({
|
||||
createExternalAdminUser: mocks.createExternalAdminUser,
|
||||
getExternalAdminUserPermissions: mocks.getExternalAdminUserPermissions,
|
||||
listExternalAdminUsers: mocks.listExternalAdminUsers,
|
||||
listExternalAdminPermissionCatalog: mocks.listExternalAdminPermissionCatalog,
|
||||
lookupExternalAdminUserTarget: mocks.lookupExternalAdminUserTarget,
|
||||
resetExternalAdminUserPassword: mocks.resetExternalAdminUserPassword,
|
||||
updateExternalAdminUserStatus: mocks.updateExternalAdminUserStatus,
|
||||
updateExternalAdminUserPermissions: mocks.updateExternalAdminUserPermissions,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/external-admin-users/permissions.js", () => ({
|
||||
@ -40,6 +50,18 @@ vi.mock("@/features/external-admin-users/permissions.js", () => ({
|
||||
vi.mock("@/shared/hooks/useAdminQuery.js", () => ({
|
||||
useAdminQuery: (_queryFn, options) => {
|
||||
mocks.queryOptions = options;
|
||||
if (options.queryKey[0] === "external-admin-permission-catalog") {
|
||||
mocks.catalogQueryOptions = options;
|
||||
if (options.enabled) {
|
||||
void _queryFn();
|
||||
}
|
||||
return {
|
||||
data: mocks.catalogPage,
|
||||
error: mocks.catalogError,
|
||||
loading: mocks.catalogLoading,
|
||||
reload: mocks.reload,
|
||||
};
|
||||
}
|
||||
return { data: mocks.queryPage, error: "", loading: false, reload: mocks.reload };
|
||||
},
|
||||
}));
|
||||
@ -55,15 +77,26 @@ vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
||||
import { useExternalAdminUsersPage } from "./useExternalAdminUsersPage.js";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.abilities = { canCreate: true, canManagePermissions: true, canResetPassword: true, canStatus: true, canView: true };
|
||||
mocks.appCode = "fami";
|
||||
mocks.catalogError = "";
|
||||
mocks.catalogLoading = false;
|
||||
mocks.catalogPage = permissionCatalogFixture();
|
||||
mocks.queryPage = { items: [], page: 1, pageSize: 50, total: 0 };
|
||||
mocks.confirm.mockResolvedValue(true);
|
||||
mocks.createExternalAdminUser.mockResolvedValue(externalUserFixture());
|
||||
mocks.listExternalAdminPermissionCatalog.mockResolvedValue(permissionCatalogFixture());
|
||||
mocks.getExternalAdminUserPermissions.mockResolvedValue({
|
||||
accountId: "71",
|
||||
permissions: ["user:list"],
|
||||
revision: 4,
|
||||
});
|
||||
mocks.invalidateQueries.mockResolvedValue(undefined);
|
||||
mocks.lookupExternalAdminUserTarget.mockResolvedValue(targetFixture());
|
||||
mocks.reload.mockResolvedValue(undefined);
|
||||
mocks.resetExternalAdminUserPassword.mockResolvedValue(externalUserFixture());
|
||||
mocks.updateExternalAdminUserStatus.mockResolvedValue({ ...externalUserFixture(), status: "disabled" });
|
||||
mocks.updateExternalAdminUserPermissions.mockResolvedValue(externalUserFixture());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -88,6 +121,7 @@ test("creates an App-scoped account with the canonical userId returned by lookup
|
||||
expect(mocks.lookupExternalAdminUserTarget).toHaveBeenCalledWith("fami", "VIP-1001");
|
||||
expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", {
|
||||
password: "StrongPass123!",
|
||||
permissions: ["user:list", "user:update"],
|
||||
targetUserId: "internal-user-1001",
|
||||
username: "ops.fami",
|
||||
});
|
||||
@ -95,6 +129,67 @@ test("creates an App-scoped account with the canonical userId returned by lookup
|
||||
expect(result.current.createOpen).toBe(false);
|
||||
});
|
||||
|
||||
test("creates an explicit zero-permission account after the operator clears defaults", async () => {
|
||||
const { result } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
act(() => result.current.openCreate());
|
||||
act(() => result.current.setCreateForm({ permissions: [] }));
|
||||
act(() => result.current.setDisplayUserId("VIP-1001"));
|
||||
await act(async () => result.current.lookupTarget());
|
||||
act(() =>
|
||||
result.current.setCreateForm({
|
||||
confirmPassword: "StrongPass123!",
|
||||
password: "StrongPass123!",
|
||||
username: "ops.zero",
|
||||
}),
|
||||
);
|
||||
await act(async () => result.current.submitCreate({ preventDefault: vi.fn() }));
|
||||
|
||||
expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", {
|
||||
password: "StrongPass123!",
|
||||
permissions: [],
|
||||
targetUserId: "internal-user-1001",
|
||||
username: "ops.zero",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not request the permission catalog without the configure-permissions grant", () => {
|
||||
mocks.abilities = { ...mocks.abilities, canCreate: false, canManagePermissions: false };
|
||||
|
||||
renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
expect(mocks.catalogQueryOptions.enabled).toBe(false);
|
||||
expect(mocks.listExternalAdminPermissionCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("treats an empty successful catalog as unavailable and blocks create and update writes", async () => {
|
||||
mocks.catalogPage = { items: [], total: 0 };
|
||||
const item = externalUserFixture();
|
||||
const { result } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
expect(result.current.catalogError).toBe("外管权限目录为空,请联系管理员");
|
||||
act(() => result.current.openCreate());
|
||||
act(() => result.current.setDisplayUserId("VIP-1001"));
|
||||
await act(async () => result.current.lookupTarget());
|
||||
act(() =>
|
||||
result.current.setCreateForm({
|
||||
confirmPassword: "StrongPass123!",
|
||||
password: "StrongPass123!",
|
||||
username: "ops.empty",
|
||||
}),
|
||||
);
|
||||
await act(async () => result.current.submitCreate({ preventDefault: vi.fn() }));
|
||||
expect(mocks.createExternalAdminUser).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.closeCreate());
|
||||
act(() => result.current.openPermissions(item));
|
||||
await waitFor(() => expect(result.current.permissionLoading).toBe(false));
|
||||
await act(async () => result.current.submitPermissions());
|
||||
|
||||
expect(mocks.updateExternalAdminUserPermissions).not.toHaveBeenCalled();
|
||||
expect(mocks.showToast).toHaveBeenCalledWith("外管权限目录为空,请联系管理员", "error");
|
||||
});
|
||||
|
||||
test("clears list filters, lookup results and password form when App scope changes", async () => {
|
||||
const { result, rerender } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
@ -112,13 +207,76 @@ test("clears list filters, lookup results and password form when App scope chang
|
||||
await waitFor(() => expect(result.current.keyword).toBe(""));
|
||||
expect(result.current.status).toBe("");
|
||||
expect(result.current.createOpen).toBe(false);
|
||||
expect(result.current.createForm).toEqual({ confirmPassword: "", displayUserId: "", password: "", username: "" });
|
||||
expect(result.current.createForm).toEqual({ confirmPassword: "", displayUserId: "", password: "", permissions: [], username: "" });
|
||||
expect(result.current.targetUser).toBeNull();
|
||||
expect(result.current.passwordUser).toBeNull();
|
||||
expect(result.current.passwordForm).toEqual({ confirmPassword: "", password: "" });
|
||||
expect(mocks.queryOptions.queryKey[1]).toBe("lalu");
|
||||
});
|
||||
|
||||
test("loads and replaces an account permission snapshot, including explicit zero permissions", async () => {
|
||||
const item = externalUserFixture();
|
||||
const { result } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
act(() => result.current.openPermissions(item));
|
||||
await waitFor(() => expect(result.current.permissionForm.permissions).toEqual(["user:list"]));
|
||||
expect(mocks.getExternalAdminUserPermissions).toHaveBeenCalledWith("fami", 71);
|
||||
|
||||
act(() => result.current.setPermissions([]));
|
||||
await act(async () => result.current.submitPermissions());
|
||||
|
||||
expect(mocks.updateExternalAdminUserPermissions).toHaveBeenCalledWith("fami", 71, [], 4);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith("权限已更新,该账号需重新登录", "success");
|
||||
expect(result.current.permissionUser).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps the permission drawer open and offers reload after a concurrent update", async () => {
|
||||
const conflict = Object.assign(new Error("conflict"), { status: 409 });
|
||||
mocks.updateExternalAdminUserPermissions.mockRejectedValueOnce(conflict);
|
||||
const item = externalUserFixture();
|
||||
const { result } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
act(() => result.current.openPermissions(item));
|
||||
await waitFor(() => expect(result.current.permissionLoading).toBe(false));
|
||||
await act(async () => result.current.submitPermissions());
|
||||
|
||||
expect(result.current.permissionUser).toEqual(item);
|
||||
expect(result.current.permissionError).toBe("账号权限已被更新,请重新加载");
|
||||
expect(mocks.showToast).toHaveBeenCalledWith("账号权限已被更新,请重新加载", "error");
|
||||
|
||||
mocks.getExternalAdminUserPermissions.mockResolvedValueOnce({
|
||||
accountId: "71",
|
||||
permissions: ["user:list", "user:update"],
|
||||
revision: 5,
|
||||
});
|
||||
act(() => result.current.retryPermissions());
|
||||
await waitFor(() => expect(result.current.permissionForm.expectedRevision).toBe(5));
|
||||
await act(async () => result.current.submitPermissions());
|
||||
|
||||
expect(mocks.updateExternalAdminUserPermissions).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"fami",
|
||||
71,
|
||||
["user:list", "user:update"],
|
||||
5,
|
||||
);
|
||||
expect(result.current.permissionUser).toBeNull();
|
||||
});
|
||||
|
||||
test("does not expose backend permission codes when a non-conflict update fails", async () => {
|
||||
mocks.updateExternalAdminUserPermissions.mockRejectedValueOnce(
|
||||
new Error("privilege:grant requires user-title:grant"),
|
||||
);
|
||||
const { result } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
act(() => result.current.openPermissions(externalUserFixture()));
|
||||
await waitFor(() => expect(result.current.permissionLoading).toBe(false));
|
||||
await act(async () => result.current.submitPermissions());
|
||||
|
||||
expect(result.current.permissionError).toBe("更新外管权限失败");
|
||||
expect(mocks.showToast).toHaveBeenCalledWith("更新外管权限失败", "error");
|
||||
});
|
||||
|
||||
test("ignores a late user lookup response after switching App", async () => {
|
||||
let resolveLookup;
|
||||
mocks.lookupExternalAdminUserTarget.mockImplementationOnce(
|
||||
@ -184,8 +342,20 @@ function externalUserFixture() {
|
||||
lastLoginAtMs: 1784077200000,
|
||||
linkedUser: targetFixture(),
|
||||
permissions: [],
|
||||
permissionRevision: 4,
|
||||
status: "active",
|
||||
updatedAtMs: 1784077200000,
|
||||
username: "ops.fami",
|
||||
};
|
||||
}
|
||||
|
||||
function permissionCatalogFixture() {
|
||||
return {
|
||||
items: [
|
||||
{ capabilities: ["user:list"], code: "user:list", defaultGranted: true, dependencies: [], group: "用户管理", label: "用户列表" },
|
||||
{ capabilities: ["user:list", "user:update"], code: "user:update", defaultGranted: true, dependencies: ["user:list"], group: "用户管理", label: "编辑用户" },
|
||||
{ capabilities: ["room:list"], code: "room:list", defaultGranted: false, dependencies: [], group: "房间管理", label: "房间列表" },
|
||||
],
|
||||
total: 3,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
||||
import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
|
||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||
import AdminPanelSettingsOutlined from "@mui/icons-material/AdminPanelSettingsOutlined";
|
||||
import { ExternalAdminPermissionDrawer } from "@/features/external-admin-users/components/ExternalAdminPermissionDrawer.jsx";
|
||||
import { ExternalAdminUserFormDialog } from "@/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx";
|
||||
import { ExternalAdminUserPasswordDialog } from "@/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx";
|
||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||
@ -56,7 +58,7 @@ const columns = [
|
||||
label: "状态",
|
||||
render: (item, _index, context) => {
|
||||
const page = context?.page;
|
||||
// 外管状态和密码都是账号级敏感写操作;同页串行化可避免两个请求互相覆盖全局 loading 状态。
|
||||
// 外管状态、密码和权限都是账号级敏感写操作;同页串行化可避免多个请求互相覆盖全局 loading 状态。
|
||||
const accountActionInFlight = Boolean(page?.loadingAction);
|
||||
return (
|
||||
<AdminSwitch
|
||||
@ -74,7 +76,7 @@ const columns = [
|
||||
{
|
||||
key: "permissions",
|
||||
label: "外管权限",
|
||||
render: (item) => (item.permissions?.length ? `${item.permissions.length} 项` : "-"),
|
||||
render: (item) => `${item.permissions?.length || 0} 项`,
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
@ -102,6 +104,13 @@ const columns = [
|
||||
const page = context?.page;
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={!page?.abilities.canManagePermissions || Boolean(page.loadingAction)}
|
||||
label="配置权限"
|
||||
onClick={() => page.openPermissions(item)}
|
||||
>
|
||||
<AdminPanelSettingsOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!page?.abilities.canResetPassword || Boolean(page.loadingAction)}
|
||||
label="重置密码"
|
||||
@ -113,7 +122,7 @@ const columns = [
|
||||
);
|
||||
},
|
||||
resizable: false,
|
||||
width: "88px",
|
||||
width: "132px",
|
||||
},
|
||||
];
|
||||
|
||||
@ -192,6 +201,9 @@ export function ExternalAdminUsersPage() {
|
||||
</DataState>
|
||||
<ExternalAdminUserFormDialog
|
||||
abilities={page.abilities}
|
||||
catalog={page.permissionCatalog.items || []}
|
||||
catalogError={page.catalogError}
|
||||
catalogLoading={page.catalogLoading}
|
||||
form={page.createForm}
|
||||
loading={page.loadingAction === "create"}
|
||||
lookupLoading={page.lookupLoading}
|
||||
@ -201,8 +213,25 @@ export function ExternalAdminUsersPage() {
|
||||
onDisplayUserIdChange={page.setDisplayUserId}
|
||||
onFormChange={page.setCreateForm}
|
||||
onLookup={page.lookupTarget}
|
||||
onReloadCatalog={page.reloadPermissionCatalog}
|
||||
onSubmit={page.submitCreate}
|
||||
/>
|
||||
<ExternalAdminPermissionDrawer
|
||||
abilities={page.abilities}
|
||||
catalog={page.permissionCatalog.items || []}
|
||||
catalogError={page.catalogError}
|
||||
catalogLoading={page.catalogLoading}
|
||||
error={page.permissionError}
|
||||
loading={page.loadingAction.startsWith("permissions:")}
|
||||
permissions={page.permissionForm.permissions}
|
||||
permissionsLoading={page.permissionLoading}
|
||||
user={page.permissionUser}
|
||||
onChange={page.setPermissions}
|
||||
onClose={page.closePermissions}
|
||||
onReloadCatalog={page.reloadPermissionCatalog}
|
||||
onRetry={page.retryPermissions}
|
||||
onSubmit={page.submitPermissions}
|
||||
/>
|
||||
<ExternalAdminUserPasswordDialog
|
||||
abilities={page.abilities}
|
||||
form={page.passwordForm}
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ openPermissions: vi.fn(), page: null }));
|
||||
|
||||
vi.mock("@/features/external-admin-users/hooks/useExternalAdminUsersPage.js", () => ({
|
||||
useExternalAdminUsersPage: () => mocks.page,
|
||||
}));
|
||||
|
||||
import { ExternalAdminUsersPage } from "./ExternalAdminUsersPage.jsx";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.openPermissions.mockReset();
|
||||
mocks.page = pageFixture();
|
||||
});
|
||||
|
||||
test("exposes a dedicated row action for configuring external account permissions", () => {
|
||||
render(<ExternalAdminUsersPage />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "配置权限" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mocks.openPermissions).toHaveBeenCalledWith(mocks.page.data.items[0]);
|
||||
expect(screen.getByText("2 项")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("disables permission configuration without the main-admin grant ability", () => {
|
||||
mocks.page.abilities.canManagePermissions = false;
|
||||
render(<ExternalAdminUsersPage />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "配置权限" })).toBeDisabled();
|
||||
});
|
||||
|
||||
function pageFixture() {
|
||||
const item = {
|
||||
appCode: "fami",
|
||||
createdAtMs: 1784073600000,
|
||||
createdByAdminId: 1,
|
||||
id: 71,
|
||||
linkedUser: { displayUserId: "123456", userId: "user-1", username: "fami123456" },
|
||||
permissions: ["user:list", "user:update"],
|
||||
permissionRevision: 3,
|
||||
status: "active",
|
||||
username: "123456",
|
||||
};
|
||||
return {
|
||||
abilities: {
|
||||
canCreate: true,
|
||||
canManagePermissions: true,
|
||||
canResetPassword: true,
|
||||
canStatus: true,
|
||||
canView: true,
|
||||
},
|
||||
catalogError: "",
|
||||
catalogLoading: false,
|
||||
closeCreate: vi.fn(),
|
||||
closePassword: vi.fn(),
|
||||
closePermissions: vi.fn(),
|
||||
createForm: { confirmPassword: "", displayUserId: "", password: "", permissions: [], username: "" },
|
||||
createOpen: false,
|
||||
data: { items: [item], page: 1, pageSize: 50, total: 1 },
|
||||
error: "",
|
||||
keyword: "",
|
||||
loading: false,
|
||||
loadingAction: "",
|
||||
lookupLoading: false,
|
||||
lookupTarget: vi.fn(),
|
||||
openCreate: vi.fn(),
|
||||
openPassword: vi.fn(),
|
||||
openPermissions: mocks.openPermissions,
|
||||
page: 1,
|
||||
passwordForm: { confirmPassword: "", password: "" },
|
||||
passwordUser: null,
|
||||
permissionCatalog: { items: [], total: 0 },
|
||||
permissionError: "",
|
||||
permissionForm: { expectedRevision: null, permissions: [] },
|
||||
permissionLoading: false,
|
||||
permissionUser: null,
|
||||
reload: vi.fn(),
|
||||
reloadPermissionCatalog: vi.fn(),
|
||||
resetFilters: vi.fn(),
|
||||
retryPermissions: vi.fn(),
|
||||
setCreateForm: vi.fn(),
|
||||
setDisplayUserId: vi.fn(),
|
||||
setKeyword: vi.fn(),
|
||||
setPage: vi.fn(),
|
||||
setPasswordForm: vi.fn(),
|
||||
setPermissions: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
status: "",
|
||||
submitCreate: vi.fn(),
|
||||
submitPassword: vi.fn(),
|
||||
submitPermissions: vi.fn(),
|
||||
targetUser: null,
|
||||
toggleStatus: vi.fn(),
|
||||
};
|
||||
}
|
||||
58
src/features/external-admin-users/permissionSelection.js
Normal file
58
src/features/external-admin-users/permissionSelection.js
Normal file
@ -0,0 +1,58 @@
|
||||
export function defaultPermissionSelection(catalog) {
|
||||
return catalog
|
||||
.filter((permission) => permission.defaultGranted)
|
||||
.reduce(
|
||||
(selected, permission) => updatePermissionSelection(catalog, selected, permission.code, true),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePermissionSelection(catalog, current, code, checked) {
|
||||
const permissionMap = new Map(catalog.map((permission) => [permission.code, permission]));
|
||||
const selected = new Set(current.filter((permissionCode) => permissionMap.has(permissionCode)));
|
||||
|
||||
if (checked) {
|
||||
// 目录依赖由后端维护;勾选业务动作时递归补齐前置能力,保证提交的是可执行权限快照。
|
||||
addWithDependencies(code, permissionMap, selected, new Set());
|
||||
} else {
|
||||
selected.delete(code);
|
||||
// 取消前置能力后同步移除所有失去依赖的动作,避免保存一个界面无法真实使用的组合。
|
||||
removeBrokenDependents(permissionMap, selected);
|
||||
}
|
||||
|
||||
return catalog.map((permission) => permission.code).filter((permissionCode) => selected.has(permissionCode));
|
||||
}
|
||||
|
||||
export function updatePermissionGroup(catalog, current, groupCodes, checked) {
|
||||
return groupCodes.reduce(
|
||||
(next, code) => updatePermissionSelection(catalog, next, code, checked),
|
||||
current,
|
||||
);
|
||||
}
|
||||
|
||||
function addWithDependencies(code, permissionMap, selected, visiting) {
|
||||
if (!permissionMap.has(code) || visiting.has(code)) {
|
||||
return;
|
||||
}
|
||||
visiting.add(code);
|
||||
const permission = permissionMap.get(code);
|
||||
(permission.dependencies || []).forEach((dependency) =>
|
||||
addWithDependencies(dependency, permissionMap, selected, visiting),
|
||||
);
|
||||
selected.add(code);
|
||||
visiting.delete(code);
|
||||
}
|
||||
|
||||
function removeBrokenDependents(permissionMap, selected) {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
selected.forEach((code) => {
|
||||
const dependencies = permissionMap.get(code)?.dependencies || [];
|
||||
if (dependencies.some((dependency) => !selected.has(dependency))) {
|
||||
selected.delete(code);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -3,9 +3,14 @@ import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useExternalAdminUserAbilities() {
|
||||
const { can } = useAuth();
|
||||
const canCreateAccount = can(PERMISSIONS.externalAdminUserCreate);
|
||||
const canManagePermissions = can(PERMISSIONS.externalAdminUserPermissions);
|
||||
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.externalAdminUserCreate),
|
||||
// 创建必须同时能配置权限,禁止通过“省略权限字段使用默认值”绕过独立授权边界。
|
||||
canCreate: canCreateAccount && canManagePermissions,
|
||||
canCreateAccount,
|
||||
canManagePermissions,
|
||||
canResetPassword: can(PERMISSIONS.externalAdminUserResetPassword),
|
||||
canStatus: can(PERMISSIONS.externalAdminUserStatus),
|
||||
canView: can(PERMISSIONS.externalAdminUserView),
|
||||
|
||||
28
src/features/external-admin-users/permissions.test.jsx
Normal file
28
src/features/external-admin-users/permissions.test.jsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ granted: new Set() }));
|
||||
|
||||
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
|
||||
useAuth: () => ({ can: (permission) => mocks.granted.has(permission) }),
|
||||
}));
|
||||
|
||||
import { useExternalAdminUserAbilities } from "./permissions.js";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.granted = new Set();
|
||||
});
|
||||
|
||||
test("requires both account creation and permission configuration grants to create", () => {
|
||||
mocks.granted.add(PERMISSIONS.externalAdminUserCreate);
|
||||
const { result, rerender } = renderHook(() => useExternalAdminUserAbilities());
|
||||
|
||||
expect(result.current.canCreateAccount).toBe(true);
|
||||
expect(result.current.canManagePermissions).toBe(false);
|
||||
expect(result.current.canCreate).toBe(false);
|
||||
|
||||
mocks.granted.add(PERMISSIONS.externalAdminUserPermissions);
|
||||
rerender();
|
||||
expect(result.current.canCreate).toBe(true);
|
||||
});
|
||||
@ -3,6 +3,7 @@ import {
|
||||
externalAdminUserCreateFormSchema,
|
||||
externalAdminUserLookupSchema,
|
||||
externalAdminUserPasswordFormSchema,
|
||||
externalAdminUserPermissionsFormSchema,
|
||||
} from "./schema";
|
||||
|
||||
describe("external admin user schemas", () => {
|
||||
@ -62,4 +63,16 @@ describe("external admin user schemas", () => {
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves an explicit zero-permission snapshot and removes duplicate codes", () => {
|
||||
expect(
|
||||
externalAdminUserPermissionsFormSchema.parse({ expectedRevision: 4, permissions: [] }),
|
||||
).toEqual({ expectedRevision: 4, permissions: [] });
|
||||
expect(
|
||||
externalAdminUserPermissionsFormSchema.parse({
|
||||
expectedRevision: 4,
|
||||
permissions: ["user:list", "user:list", "room:list"],
|
||||
}),
|
||||
).toEqual({ expectedRevision: 4, permissions: ["user:list", "room:list"] });
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,10 +16,21 @@ const usernameSchema = z
|
||||
// 外管系统使用独立登录标识;限制为跨浏览器、网关和审计日志均稳定的 ASCII 账号字符集。
|
||||
.regex(/^[A-Za-z0-9._-]+$/, "外管账号只能包含字母、数字、点、下划线和连字符");
|
||||
|
||||
// 密码属于登录凭证,不能 trim 或自动改写;前端只做长度与确认值校验,服务端会重复校验并仅持久化密码摘要。
|
||||
const permissionCodeSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "权限编码不能为空")
|
||||
.max(128, "权限编码不能超过 128 个字符")
|
||||
.refine((value) => !/[\s\p{Cc}]/u.test(value), "权限编码不能包含空白或控制字符");
|
||||
|
||||
const permissionsSchema = z.array(permissionCodeSchema).max(200, "权限数量不能超过 200 项").transform((items) => [
|
||||
...new Set(items),
|
||||
]);
|
||||
|
||||
// 密码属于登录凭证,不能 trim 或自动改写;外管不设最小长度,但空值和全空白仍不可作为有效凭证。
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(8, "密码至少 8 位")
|
||||
.min(1, "请输入密码")
|
||||
.max(72, "密码不能超过 72 个字符")
|
||||
.refine((value) => value.trim().length > 0, "密码不能全部为空白字符");
|
||||
|
||||
@ -32,6 +43,7 @@ export const externalAdminUserCreateFormSchema = z
|
||||
confirmPassword: passwordSchema,
|
||||
displayUserId: displayUserIdSchema,
|
||||
password: passwordSchema,
|
||||
permissions: permissionsSchema.optional(),
|
||||
username: usernameSchema,
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
@ -57,7 +69,15 @@ export const externalAdminUserStatusSchema = z.object({
|
||||
status: z.enum(["active", "disabled"]),
|
||||
});
|
||||
|
||||
export const externalAdminUserPermissionsFormSchema = z.object({
|
||||
expectedRevision: z.number().int().positive("权限版本不正确"),
|
||||
// 空数组是明确的零权限快照,不能用默认权限替换,也不能在提交前过滤掉该字段。
|
||||
permissions: permissionsSchema,
|
||||
});
|
||||
|
||||
export type ExternalAdminUserCreateForm = z.input<typeof externalAdminUserCreateFormSchema>;
|
||||
export type ExternalAdminUserCreatePayload = z.output<typeof externalAdminUserCreateFormSchema>;
|
||||
export type ExternalAdminUserPasswordForm = z.input<typeof externalAdminUserPasswordFormSchema>;
|
||||
export type ExternalAdminUserPasswordPayload = z.output<typeof externalAdminUserPasswordFormSchema>;
|
||||
export type ExternalAdminUserPermissionsForm = z.input<typeof externalAdminUserPermissionsFormSchema>;
|
||||
export type ExternalAdminUserPermissionsPayload = z.output<typeof externalAdminUserPermissionsFormSchema>;
|
||||
|
||||
670
src/features/games/adapterConfigModel.js
Normal file
670
src/features/games/adapterConfigModel.js
Normal file
@ -0,0 +1,670 @@
|
||||
const commonFields = [
|
||||
{
|
||||
key: "uid_mode",
|
||||
label: "用户标识",
|
||||
type: "select",
|
||||
options: [
|
||||
["", "按协议默认"],
|
||||
["user_id", "内部用户 ID"],
|
||||
["display_user_id", "用户短号 / 展示 ID"],
|
||||
],
|
||||
},
|
||||
{ key: "default_lang", label: "默认语言", placeholder: "按协议默认", type: "string" },
|
||||
{
|
||||
key: "token_ttl_seconds",
|
||||
label: "Token 有效期",
|
||||
min: 1,
|
||||
placeholder: "86400",
|
||||
type: "integer",
|
||||
unit: "秒",
|
||||
},
|
||||
];
|
||||
|
||||
const catalogKeys = ["game_urls", "game_names", "game_icons", "game_covers", "game_level_uids"];
|
||||
|
||||
const globalConfigKeys = [
|
||||
"bridge_script_url",
|
||||
"bridge_script_version",
|
||||
"bridge_script_sha256",
|
||||
"bridgeScriptUrl",
|
||||
"bridgeScriptVersion",
|
||||
"bridgeScriptSha256",
|
||||
"bridge_script_hash",
|
||||
];
|
||||
|
||||
// 单一注册表同时驱动选择器、字段、顶层接入语义和序列化类型,避免页面与提交逻辑各维护一份厂商分支。
|
||||
export const adapterDefinitions = {
|
||||
yomi_v4: {
|
||||
label: "小游 Yomi V4",
|
||||
apiUrlLabel: "Auth / Gateway URL",
|
||||
apiUrlRequired: true,
|
||||
callbackSecretLabel: "Yomi AppSecret",
|
||||
ipWhitelist: true,
|
||||
catalog: true,
|
||||
catalogPrimary: "name",
|
||||
fields: [
|
||||
{ key: "game_level_uid", label: "默认场次 ID", min: 0, type: "integer" },
|
||||
{ key: "plat_payload", label: "平台透传参数", type: "string" },
|
||||
],
|
||||
},
|
||||
leadercc_v1: {
|
||||
label: "灵仙 LeaderCC V1",
|
||||
apiUrl: false,
|
||||
callbackSecretLabel: "灵仙签名 Key",
|
||||
ipWhitelist: false,
|
||||
catalog: false,
|
||||
fields: [],
|
||||
},
|
||||
zeeone_v1: {
|
||||
label: "ZeeOne V1",
|
||||
apiUrlLabel: "默认 H5 URL",
|
||||
callbackSecretLabel: "ZeeOne 签名 Secret",
|
||||
ipWhitelist: false,
|
||||
catalog: true,
|
||||
fields: [
|
||||
{ key: "merchant_id", label: "商户 ID", min: 1, required: true, type: "integer" },
|
||||
{ key: "platform_id", label: "平台 ID", min: 1, required: true, type: "integer" },
|
||||
{ key: "app_id", label: "回调 App ID", placeholder: "默认 M{商户ID}_P{平台ID}", type: "string" },
|
||||
{ key: "game_mode", label: "游戏模式", type: "string" },
|
||||
{ key: "extra", label: "启动透传参数", type: "string" },
|
||||
{
|
||||
className: "wide",
|
||||
key: "launch_url_template",
|
||||
label: "启动 URL 模板",
|
||||
placeholder: "https://game.example/{provider_game_id}",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
},
|
||||
baishun_v1: {
|
||||
label: "百顺 BAISHUN V1",
|
||||
apiUrlLabel: "厂商 API / 默认 H5 URL",
|
||||
callbackSecretLabel: "百顺 AppKey",
|
||||
ipWhitelist: true,
|
||||
catalog: true,
|
||||
fields: [
|
||||
{ key: "app_id", label: "App ID", min: 1, required: true, type: "integer" },
|
||||
{ key: "app_channel", label: "App Channel", required: true, type: "string" },
|
||||
{ key: "game_list_url", label: "游戏列表 URL", type: "string" },
|
||||
{ key: "game_list_type", label: "游戏列表类型", min: 1, placeholder: "1", type: "integer" },
|
||||
{ key: "provider_api_base_url", label: "厂商 API Base URL", type: "string" },
|
||||
{ key: "game_mode", label: "游戏模式", placeholder: "3", type: "string" },
|
||||
{ key: "scene_mode", label: "场景模式", min: 0, type: "integer" },
|
||||
{ key: "currency_icon", label: "货币图标 URL", type: "string" },
|
||||
{ key: "gsp", label: "GSP", min: 0, type: "integer" },
|
||||
{
|
||||
className: "wide",
|
||||
key: "launch_url_template",
|
||||
label: "启动 URL 模板",
|
||||
placeholder: "https://game.example/{provider_game_id}",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
advancedFields: [
|
||||
{ key: "user_type", label: "用户类型", min: 0, type: "integer" },
|
||||
{ key: "release_cond", label: "放行条件", min: 0, type: "integer" },
|
||||
{
|
||||
key: "is_old_user",
|
||||
label: "历史用户",
|
||||
type: "selectBoolean",
|
||||
options: [
|
||||
["", "不传"],
|
||||
["true", "是"],
|
||||
["false", "否"],
|
||||
],
|
||||
},
|
||||
{ key: "extend", label: "扩展参数", type: "string" },
|
||||
],
|
||||
},
|
||||
vivagames_v1: {
|
||||
label: "VIVAGAMES V1",
|
||||
apiUrlLabel: "默认 H5 URL",
|
||||
callbackSecretLabel: "VIVAGAMES AppKey",
|
||||
ipWhitelist: true,
|
||||
catalog: true,
|
||||
fields: [
|
||||
{ key: "app_id", label: "App ID", min: 1, required: true, type: "integer" },
|
||||
{ key: "app_secret", label: "App Secret", type: "string" },
|
||||
{ key: "coin_type", label: "币种类型", min: 0, type: "integer" },
|
||||
{ key: "amount_rate", label: "金额倍率", min: 1, placeholder: "1", type: "integer" },
|
||||
{
|
||||
key: "bgm_switch",
|
||||
label: "背景音乐",
|
||||
type: "selectNumber",
|
||||
options: [
|
||||
["", "按协议默认"],
|
||||
["1", "开启"],
|
||||
["0", "关闭"],
|
||||
],
|
||||
},
|
||||
{ key: "gsp", label: "GSP", min: 1, placeholder: "101", type: "integer" },
|
||||
{ key: "currency_icon", label: "货币图标 URL", type: "string" },
|
||||
{ key: "metadata", label: "启动 Metadata", type: "string" },
|
||||
{
|
||||
className: "wide",
|
||||
key: "launch_url_template",
|
||||
label: "启动 URL 模板",
|
||||
placeholder: "https://game.example/{provider_game_id}",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
},
|
||||
reyou_v1: {
|
||||
label: "热游 Reyou V1",
|
||||
apiUrlLabel: "默认 H5 URL",
|
||||
callbackSecretLabel: "热游签名 Key",
|
||||
ipWhitelist: true,
|
||||
catalog: true,
|
||||
fields: [
|
||||
{
|
||||
className: "wide",
|
||||
key: "launch_url_template",
|
||||
label: "启动 URL 模板",
|
||||
placeholder: "https://game.example/{provider_game_id}",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
},
|
||||
zgame_v1: {
|
||||
label: "ZGame V1",
|
||||
apiUrlLabel: "默认 H5 URL",
|
||||
callbackSecretLabel: "ZGame 签名 Secret",
|
||||
fixedPlatformCode: "zgame",
|
||||
fixedPlatformName: "ZGame",
|
||||
ipWhitelist: true,
|
||||
catalog: true,
|
||||
fields: [
|
||||
{
|
||||
className: "wide",
|
||||
key: "callback_base",
|
||||
label: "公网回调 Base URL",
|
||||
placeholder: "https://api.example.com/api/v1/zgame/callback",
|
||||
type: "string",
|
||||
},
|
||||
{ key: "merchant_payload", label: "商户透传参数", type: "string" },
|
||||
{ key: "level", label: "默认等级", min: 0, type: "integer" },
|
||||
{ key: "gender", label: "默认性别", min: 0, type: "integer" },
|
||||
{
|
||||
className: "wide",
|
||||
key: "launch_url_template",
|
||||
label: "启动 URL 模板",
|
||||
placeholder: "https://game.example/{provider_game_id}",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
},
|
||||
amg_v1: {
|
||||
label: "AMG V1",
|
||||
apiUrlLabel: "默认 H5 URL",
|
||||
callbackSecretLabel: "AMG PKCS#8 私钥(Base64)",
|
||||
callbackSecretPlaceholder: "粘贴厂商提供的单行 Base64 私钥",
|
||||
fixedPlatformCode: "amg",
|
||||
fixedPlatformName: "AMG",
|
||||
ipWhitelist: true,
|
||||
catalog: true,
|
||||
numericGameID: true,
|
||||
fields: [
|
||||
{ key: "app_key", label: "AMG App Key", required: true, type: "string" },
|
||||
{
|
||||
key: "coin_scale",
|
||||
label: "金币精度",
|
||||
type: "selectNumber",
|
||||
options: [
|
||||
["", "默认 1"],
|
||||
["1", "1(整数金币)"],
|
||||
["10", "10(1 位小数)"],
|
||||
["100", "100(2 位小数)"],
|
||||
["1000", "1,000(3 位小数)"],
|
||||
["10000", "10,000(4 位小数)"],
|
||||
["100000", "100,000(5 位小数)"],
|
||||
["1000000", "1,000,000(6 位小数)"],
|
||||
],
|
||||
},
|
||||
{ key: "ext", label: "启动扩展参数", type: "string" },
|
||||
{ key: "loading", label: "Loading 参数", type: "string" },
|
||||
{
|
||||
className: "wide",
|
||||
key: "launch_url_template",
|
||||
label: "启动 URL 模板",
|
||||
placeholder: "https://game.example/{provider_game_id}",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
advancedFields: [
|
||||
{ key: "mini", label: "Mini 参数", min: 0, type: "integer" },
|
||||
{ key: "app_audio", label: "App Audio 参数", min: 0, type: "integer" },
|
||||
{ key: "debug", label: "Debug 参数", min: 0, type: "integer" },
|
||||
{ key: "use_lang", label: "Use Lang 参数", min: 0, type: "integer" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const adapterTypeOptions = Object.entries(adapterDefinitions).map(([value, definition]) => [
|
||||
value,
|
||||
definition.label,
|
||||
]);
|
||||
|
||||
export const writableBridgeAdapterTypes = new Set(Object.keys(adapterDefinitions));
|
||||
|
||||
const aliasesByKey = {
|
||||
app_id: ["appId"],
|
||||
app_channel: ["appChannel"],
|
||||
platform_id: ["platform"],
|
||||
callback_base: ["callbackBase"],
|
||||
provider_api_base_url: ["providerApiBaseUrl"],
|
||||
game_list_url: ["gameListUrl"],
|
||||
game_list_type: ["gameListType"],
|
||||
};
|
||||
|
||||
export function adapterDefinition(adapterType) {
|
||||
return adapterDefinitions[adapterType] || adapterDefinitions.yomi_v4;
|
||||
}
|
||||
|
||||
export function adapterCommonFields() {
|
||||
return commonFields;
|
||||
}
|
||||
|
||||
export function adapterSecretState(form, editing = false) {
|
||||
const adapterType = String(form.adapterType || "").trim() || "yomi_v4";
|
||||
const adapterChanged =
|
||||
editing && Boolean(form.initialAdapterType) && String(form.initialAdapterType).trim() !== adapterType;
|
||||
const configuredSecretUsable = editing && !adapterChanged && Boolean(form.callbackSecretSet);
|
||||
return {
|
||||
adapterChanged,
|
||||
configuredSecretUsable,
|
||||
required:
|
||||
adapterChanged || ((String(form.status || "").trim() || "active") === "active" && !configuredSecretUsable),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAdapterCallbackSecret(form, editing = false) {
|
||||
const callbackSecret = String(form.callbackSecret || "").trim();
|
||||
if (adapterSecretState(form, editing).required && !callbackSecret) {
|
||||
const definition = adapterDefinition(String(form.adapterType || "").trim());
|
||||
throw new Error(`请填写${definition.callbackSecretLabel || "回调密钥"}`);
|
||||
}
|
||||
return callbackSecret;
|
||||
}
|
||||
|
||||
export function readAdapterConfig(raw) {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function adapterConfigFieldValue(raw, field) {
|
||||
const entries = parseObjectEntries(raw);
|
||||
if (!entries) {
|
||||
return "";
|
||||
}
|
||||
const rawValue = findFieldRawValue(entries, field, aliasesByKey[field.key] || []);
|
||||
const value = decodeRawValue(rawValue);
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (field.type === "selectBoolean") {
|
||||
return value === true || value === "true" ? "true" : value === false || value === "false" ? "false" : "";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function updateAdapterConfigField(raw, field, inputValue) {
|
||||
const entries = parseObjectEntries(raw);
|
||||
if (!entries) {
|
||||
// 非法原始 JSON 必须由高级编辑器先修正;此时拒绝结构化 patch,避免把无法解析的历史配置覆盖成新对象。
|
||||
return String(raw || "{}");
|
||||
}
|
||||
const rawValue =
|
||||
inputValue === "" || inputValue === null || inputValue === undefined
|
||||
? undefined
|
||||
: encodeFieldRawValue(field, inputValue);
|
||||
const next = replaceObjectEntry(entries, field.key, aliasesByKey[field.key] || [], rawValue);
|
||||
return serializeObjectEntries(next, true);
|
||||
}
|
||||
|
||||
export function normalizeAdapterConfigJson(raw, adapterType) {
|
||||
let entries = parseObjectEntries(raw);
|
||||
if (!entries) {
|
||||
throw new Error("适配器高级配置必须是 JSON 对象");
|
||||
}
|
||||
const activeFields = new Map(
|
||||
[
|
||||
...commonFields,
|
||||
...(adapterDefinition(adapterType).fields || []),
|
||||
...(adapterDefinition(adapterType).advancedFields || []),
|
||||
].map((field) => [field.key, field]),
|
||||
);
|
||||
for (const field of activeFields.values()) {
|
||||
const aliases = aliasesByKey[field.key] || [];
|
||||
const rawValue = findFieldRawValue(entries, field, aliases);
|
||||
if (rawValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
const value = decodeRawValue(rawValue);
|
||||
// 只迁移当前厂商实际识别的历史别名;其他 key 可能属于未来协议,必须按原始 JSON token 保留。
|
||||
entries = replaceObjectEntry(
|
||||
entries,
|
||||
field.key,
|
||||
aliases,
|
||||
value === "" || value === null ? undefined : encodeFieldRawValue(field, value, rawValue),
|
||||
);
|
||||
}
|
||||
return serializeObjectEntries(entries);
|
||||
}
|
||||
|
||||
export function migrateAdapterConfigJson(raw, previousAdapterType, nextAdapterType) {
|
||||
let entries = parseObjectEntries(raw);
|
||||
if (!entries || previousAdapterType === nextAdapterType) {
|
||||
return String(raw || "{}");
|
||||
}
|
||||
const preservedKeys = new Set([...commonFields.map((field) => field.key), ...catalogKeys, ...globalConfigKeys]);
|
||||
const previous = adapterDefinition(previousAdapterType);
|
||||
for (const field of [...(previous.fields || []), ...(previous.advancedFields || [])]) {
|
||||
if (!preservedKeys.has(field.key)) {
|
||||
entries = replaceObjectEntry(entries, field.key, aliasesByKey[field.key] || [], undefined);
|
||||
}
|
||||
}
|
||||
return serializeObjectEntries(entries, true);
|
||||
}
|
||||
|
||||
export function adapterCatalogRows(raw) {
|
||||
const entries = parseObjectEntries(raw);
|
||||
if (!entries) {
|
||||
return [];
|
||||
}
|
||||
const maps = Object.fromEntries(catalogKeys.map((key) => [key, readRawObjectMap(entries, key)]));
|
||||
const ids = [];
|
||||
const seen = new Set();
|
||||
for (const key of catalogKeys) {
|
||||
for (const id of Object.keys(maps[key])) {
|
||||
const normalized = String(id || "").trim();
|
||||
if (normalized && !seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
ids.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids.map((providerGameId) => ({
|
||||
providerGameId,
|
||||
gameName: stringValue(maps.game_names[providerGameId]),
|
||||
gameUrl: stringValue(maps.game_urls[providerGameId]),
|
||||
iconUrl: stringValue(maps.game_icons[providerGameId]),
|
||||
coverUrl: stringValue(maps.game_covers[providerGameId]),
|
||||
gameLevelUid: numberInputValue(maps.game_level_uids[providerGameId]),
|
||||
}));
|
||||
}
|
||||
|
||||
export function updateAdapterCatalog(raw, rows) {
|
||||
let entries = parseObjectEntries(raw);
|
||||
if (!entries) {
|
||||
return String(raw || "{}");
|
||||
}
|
||||
const providerGameIDs = (rows || []).map((row) => String(row.providerGameId || "").trim()).filter(Boolean);
|
||||
if (new Set(providerGameIDs).size !== providerGameIDs.length) {
|
||||
// JSON map 无法表达重复 key;拒绝改写底层配置,让表单保留两行并通过原生校验提示用户修正。
|
||||
return String(raw || "{}");
|
||||
}
|
||||
const gameURLs = new Map();
|
||||
const gameNames = new Map();
|
||||
const gameIcons = new Map();
|
||||
const gameCovers = new Map();
|
||||
const gameLevelUIDs = new Map();
|
||||
for (const row of rows || []) {
|
||||
const providerGameID = String(row.providerGameId || "").trim();
|
||||
if (!providerGameID) {
|
||||
continue;
|
||||
}
|
||||
assignTrimmed(gameURLs, providerGameID, row.gameUrl);
|
||||
assignTrimmed(gameNames, providerGameID, row.gameName);
|
||||
assignTrimmed(gameIcons, providerGameID, row.iconUrl);
|
||||
assignTrimmed(gameCovers, providerGameID, row.coverUrl);
|
||||
const levelUID = positiveIntegerRawValue(row.gameLevelUid);
|
||||
if (levelUID) {
|
||||
gameLevelUIDs.set(providerGameID, levelUID);
|
||||
}
|
||||
}
|
||||
entries = replaceObjectEntry(entries, "game_urls", [], serializeStringMap(gameURLs));
|
||||
entries = replaceObjectEntry(entries, "game_names", [], serializeStringMap(gameNames));
|
||||
entries = replaceObjectEntry(entries, "game_icons", [], serializeStringMap(gameIcons));
|
||||
entries = replaceObjectEntry(entries, "game_covers", [], serializeStringMap(gameCovers));
|
||||
entries = replaceObjectEntry(entries, "game_level_uids", [], serializeRawMap(gameLevelUIDs));
|
||||
return serializeObjectEntries(entries, true);
|
||||
}
|
||||
|
||||
// 结构化编辑只重写目标顶层字段。未知字段的 raw JSON token(尤其 int64)必须原样保留,不能经过 Number。
|
||||
function encodeFieldRawValue(field, value, fallbackRawValue) {
|
||||
if (field.type === "integer" || field.type === "selectNumber") {
|
||||
return integerRawValue(value) || fallbackRawValue || JSON.stringify(String(value).trim());
|
||||
}
|
||||
if (field.type === "selectBoolean") {
|
||||
return value === true || value === "true" ? "true" : "false";
|
||||
}
|
||||
return JSON.stringify(String(value).trim());
|
||||
}
|
||||
|
||||
function parseObjectEntries(raw) {
|
||||
const source = String(raw || "").trim() || "{}";
|
||||
try {
|
||||
const parsed = JSON.parse(source);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const entries = [];
|
||||
let index = skipWhitespace(source, 1);
|
||||
while (index < source.length && source[index] !== "}") {
|
||||
if (source[index] !== '"') {
|
||||
return null;
|
||||
}
|
||||
const keyEnd = scanJSONString(source, index);
|
||||
const key = JSON.parse(source.slice(index, keyEnd));
|
||||
index = skipWhitespace(source, keyEnd);
|
||||
if (source[index] !== ":") {
|
||||
return null;
|
||||
}
|
||||
const valueStart = skipWhitespace(source, index + 1);
|
||||
const valueEnd = scanJSONValue(source, valueStart);
|
||||
entries.push({ key, rawValue: source.slice(valueStart, valueEnd) });
|
||||
index = skipWhitespace(source, valueEnd);
|
||||
if (source[index] === ",") {
|
||||
index = skipWhitespace(source, index + 1);
|
||||
} else if (source[index] !== "}") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function scanJSONString(source, start) {
|
||||
let index = start + 1;
|
||||
while (index < source.length) {
|
||||
if (source[index] === "\\") {
|
||||
index += 2;
|
||||
} else if (source[index] === '"') {
|
||||
return index + 1;
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
function scanJSONValue(source, start) {
|
||||
if (source[start] === '"') {
|
||||
return scanJSONString(source, start);
|
||||
}
|
||||
if (source[start] === "{" || source[start] === "[") {
|
||||
const stack = [source[start]];
|
||||
let index = start + 1;
|
||||
while (index < source.length && stack.length > 0) {
|
||||
if (source[index] === '"') {
|
||||
index = scanJSONString(source, index);
|
||||
continue;
|
||||
}
|
||||
if (source[index] === "{" || source[index] === "[") {
|
||||
stack.push(source[index]);
|
||||
} else if (source[index] === "}" || source[index] === "]") {
|
||||
stack.pop();
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
let index = start;
|
||||
while (index < source.length && !/[\s,}\]]/.test(source[index])) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function skipWhitespace(source, start) {
|
||||
let index = start;
|
||||
while (index < source.length && /\s/.test(source[index])) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function findEntryRawValue(entries, key, aliases) {
|
||||
for (const candidate of [key, ...aliases]) {
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
if (entries[index].key === candidate) {
|
||||
return entries[index].rawValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findFieldRawValue(entries, field, aliases) {
|
||||
const canonical = findEntryRawValue(entries, field.key, []);
|
||||
if (
|
||||
aliases.length === 0 ||
|
||||
(canonical !== undefined && !fieldValueFallsBackToAlias(field, decodeRawValue(canonical)))
|
||||
) {
|
||||
return canonical;
|
||||
}
|
||||
const alias = findEntryRawValue(entries, aliases[0], aliases.slice(1));
|
||||
return alias === undefined ? canonical : alias;
|
||||
}
|
||||
|
||||
function fieldValueFallsBackToAlias(field, value) {
|
||||
if (value === undefined || value === null) {
|
||||
return true;
|
||||
}
|
||||
if (field.type === "integer" || field.type === "selectNumber") {
|
||||
const normalized = integerRawValue(value);
|
||||
return normalized === "0";
|
||||
}
|
||||
return typeof value === "string" && value.trim() === "";
|
||||
}
|
||||
|
||||
function decodeRawValue(rawValue) {
|
||||
if (rawValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const value = String(rawValue).trim();
|
||||
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
function replaceObjectEntry(entries, key, aliases, rawValue) {
|
||||
const replacedKeys = new Set([key, ...aliases]);
|
||||
const firstIndex = entries.findIndex((entry) => replacedKeys.has(entry.key));
|
||||
const next = entries.filter((entry) => !replacedKeys.has(entry.key));
|
||||
if (rawValue === undefined || rawValue === null || rawValue === "") {
|
||||
return next;
|
||||
}
|
||||
next.splice(firstIndex < 0 ? next.length : Math.min(firstIndex, next.length), 0, { key, rawValue });
|
||||
return next;
|
||||
}
|
||||
|
||||
function serializeObjectEntries(entries, pretty = false) {
|
||||
if (!pretty) {
|
||||
return `{${entries.map((entry) => `${JSON.stringify(entry.key)}:${entry.rawValue}`).join(",")}}`;
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return "{}";
|
||||
}
|
||||
const body = entries
|
||||
.map((entry) => ` ${JSON.stringify(entry.key)}: ${String(entry.rawValue).replace(/\n/g, "\n ")}`)
|
||||
.join(",\n");
|
||||
return `{\n${body}\n}`;
|
||||
}
|
||||
|
||||
export function formatAdapterConfigJson(raw) {
|
||||
const entries = parseObjectEntries(raw);
|
||||
if (!entries) {
|
||||
throw new Error("适配器高级配置必须是 JSON 对象");
|
||||
}
|
||||
return serializeObjectEntries(entries, true);
|
||||
}
|
||||
|
||||
function integerRawValue(value) {
|
||||
const text = String(value).trim();
|
||||
if (!/^-?(?:0|[1-9]\d*)$/.test(text)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return BigInt(text).toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function positiveIntegerRawValue(value) {
|
||||
const normalized = integerRawValue(value);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return BigInt(normalized) > 0n ? normalized : "";
|
||||
}
|
||||
|
||||
function readRawObjectMap(entries, key) {
|
||||
const nestedEntries = parseObjectEntries(findEntryRawValue(entries, key, []) || "{}");
|
||||
const value = Object.create(null);
|
||||
for (const entry of nestedEntries || []) {
|
||||
value[entry.key] = decodeRawValue(entry.rawValue);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function numberInputValue(value) {
|
||||
return positiveIntegerRawValue(value);
|
||||
}
|
||||
|
||||
function assignTrimmed(target, key, value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (normalized) {
|
||||
target.set(key, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeStringMap(value) {
|
||||
if (value.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `{${[...value].map(([key, item]) => `${JSON.stringify(key)}:${JSON.stringify(item)}`).join(",")}}`;
|
||||
}
|
||||
|
||||
function serializeRawMap(value) {
|
||||
if (value.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `{${[...value].map(([key, item]) => `${JSON.stringify(key)}:${item}`).join(",")}}`;
|
||||
}
|
||||
@ -14,7 +14,7 @@ export interface GamePlatformDto {
|
||||
callbackSecret?: string;
|
||||
callbackSecretSet: boolean;
|
||||
callbackIpWhitelist: string[];
|
||||
// 厂商扩展配置保留 JSON 字符串,后台前端不绑定具体字段结构。
|
||||
// JSON 是服务端传输格式;配置页会按 adapterType 显示结构化字段,并保留未来新增的未知 key。
|
||||
adapterConfigJson: string;
|
||||
sortOrder: number;
|
||||
createdAtMs?: number;
|
||||
|
||||
320
src/features/games/components/AdapterConfigFields.jsx
Normal file
320
src/features/games/components/AdapterConfigFields.jsx
Normal file
@ -0,0 +1,320 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
adapterCatalogRows,
|
||||
adapterCommonFields,
|
||||
adapterConfigFieldValue,
|
||||
adapterDefinition,
|
||||
formatAdapterConfigJson,
|
||||
readAdapterConfig,
|
||||
updateAdapterCatalog,
|
||||
updateAdapterConfigField,
|
||||
} from "@/features/games/adapterConfigModel.js";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
|
||||
import styles from "@/features/games/components/AdapterConfigFields.module.css";
|
||||
|
||||
export function AdapterConfigFields({ adapterType, disabled = false, value, onChange }) {
|
||||
const definition = adapterDefinition(adapterType);
|
||||
const configValid = Boolean(readAdapterConfig(value));
|
||||
const visualFieldsDisabled = disabled || !configValid;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{!configValid ? <div className={styles.invalid}>请先在高级配置中修正 JSON 格式</div> : null}
|
||||
<ConfigFieldGroup
|
||||
disabled={visualFieldsDisabled}
|
||||
fields={adapterCommonFields()}
|
||||
title="会话参数"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{(definition.fields || []).length > 0 ? (
|
||||
<ConfigFieldGroup
|
||||
disabled={visualFieldsDisabled}
|
||||
fields={definition.fields}
|
||||
title="厂商参数"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : null}
|
||||
{definition.catalog ? (
|
||||
<GameCatalogEditor
|
||||
adapterType={adapterType}
|
||||
disabled={visualFieldsDisabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : null}
|
||||
{(definition.advancedFields || []).length > 0 ? (
|
||||
<details className={styles.details}>
|
||||
<summary>可选厂商参数</summary>
|
||||
<ConfigFieldGrid
|
||||
disabled={visualFieldsDisabled}
|
||||
fields={definition.advancedFields}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</details>
|
||||
) : null}
|
||||
<details className={styles.details} open={configValid ? undefined : true}>
|
||||
<summary>高级配置 · 完整 JSON</summary>
|
||||
<div className={styles.advancedEditor}>
|
||||
<JsonEditorField
|
||||
disabled={disabled}
|
||||
formatter={formatAdapterConfigJson}
|
||||
label="完整配置 JSON"
|
||||
minRows={5}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigFieldGroup({ disabled, fields, title, value, onChange }) {
|
||||
return (
|
||||
<section className={styles.group}>
|
||||
<div className={styles.groupTitle}>{title}</div>
|
||||
<ConfigFieldGrid disabled={disabled} fields={fields} value={value} onChange={onChange} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigFieldGrid({ disabled, fields, value, onChange }) {
|
||||
return (
|
||||
<div className={styles.fieldGrid}>
|
||||
{fields.map((field) => (
|
||||
<AdapterField
|
||||
disabled={disabled}
|
||||
field={field}
|
||||
key={field.key}
|
||||
value={adapterConfigFieldValue(value, field)}
|
||||
onChange={(inputValue) => onChange(updateAdapterConfigField(value, field, inputValue))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdapterField({ disabled, field, value, onChange }) {
|
||||
const className = field.className === "wide" ? styles.wideField : undefined;
|
||||
const commonProps = {
|
||||
className,
|
||||
disabled,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
value,
|
||||
onChange: (event) => onChange(event.target.value),
|
||||
};
|
||||
|
||||
if (field.type === "select" || field.type === "selectNumber" || field.type === "selectBoolean") {
|
||||
return (
|
||||
<TextField {...commonProps} select>
|
||||
{(field.options || []).map(([optionValue, optionLabel]) => (
|
||||
<MenuItem key={`${field.key}-${optionValue}`} value={optionValue}>
|
||||
{optionLabel}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...commonProps}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: field.min,
|
||||
step: field.type === "integer" ? 1 : undefined,
|
||||
},
|
||||
input: field.unit
|
||||
? { endAdornment: <InputAdornment position="end">{field.unit}</InputAdornment> }
|
||||
: undefined,
|
||||
}}
|
||||
type={field.type === "integer" ? "number" : "text"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
||||
const definition = adapterDefinition(adapterType);
|
||||
const externalRows = useMemo(() => adapterCatalogRows(value), [value]);
|
||||
const externalSignature = useMemo(() => catalogSignature(externalRows), [externalRows]);
|
||||
const lastEmittedSignature = useRef(externalSignature);
|
||||
const adapterTypeRef = useRef(adapterType);
|
||||
const rowSequence = useRef(externalRows.length);
|
||||
const [rows, setRows] = useState(() => withRowKeys(externalRows, "initial"));
|
||||
|
||||
useEffect(() => {
|
||||
const adapterChanged = adapterTypeRef.current !== adapterType;
|
||||
if (adapterChanged || externalSignature !== lastEmittedSignature.current) {
|
||||
rowSequence.current += externalRows.length;
|
||||
setRows(withRowKeys(externalRows, `sync-${rowSequence.current}`));
|
||||
}
|
||||
adapterTypeRef.current = adapterType;
|
||||
lastEmittedSignature.current = externalSignature;
|
||||
}, [adapterType, externalRows, externalSignature]);
|
||||
|
||||
const emitRows = (nextRows) => {
|
||||
setRows(nextRows);
|
||||
const nextValue = updateAdapterCatalog(value, nextRows);
|
||||
lastEmittedSignature.current = catalogSignature(adapterCatalogRows(nextValue));
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
rowSequence.current += 1;
|
||||
setRows((current) => [
|
||||
...current,
|
||||
{
|
||||
rowKey: `draft-${rowSequence.current}`,
|
||||
providerGameId: "",
|
||||
gameName: "",
|
||||
gameUrl: "",
|
||||
iconUrl: "",
|
||||
coverUrl: "",
|
||||
gameLevelUid: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const changeRow = (rowKey, patch) => {
|
||||
emitRows(rows.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
|
||||
};
|
||||
|
||||
const removeRow = (rowKey) => {
|
||||
emitRows(rows.filter((row) => row.rowKey !== rowKey));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.catalog}>
|
||||
<div className={styles.catalogHeader}>
|
||||
<div className={styles.groupTitle}>游戏目录 · {rows.length}</div>
|
||||
<Button disabled={disabled} onClick={addRow}>
|
||||
<AddOutlined fontSize="small" />
|
||||
添加游戏
|
||||
</Button>
|
||||
</div>
|
||||
{rows.length > 0 ? (
|
||||
<div className={styles.catalogRows}>
|
||||
{rows.map((row, index) => {
|
||||
const duplicate =
|
||||
row.providerGameId.trim() !== "" &&
|
||||
rows.some(
|
||||
(candidate, candidateIndex) =>
|
||||
candidateIndex !== index &&
|
||||
candidate.providerGameId.trim() === row.providerGameId.trim(),
|
||||
);
|
||||
return (
|
||||
<div className={styles.catalogRow} key={row.rowKey}>
|
||||
<div className={styles.catalogMain}>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
error={duplicate}
|
||||
helperText={duplicate ? "游戏 ID 重复" : undefined}
|
||||
label={definition.numericGameID ? "游戏 ID(正整数)" : "游戏 ID"}
|
||||
required
|
||||
slotProps={{
|
||||
// 重复 ID 无法同时写进 JSON 对象;用原生 pattern 阻止提交,并保留当前行供用户修正。
|
||||
htmlInput: {
|
||||
inputMode: definition.numericGameID ? "numeric" : undefined,
|
||||
pattern: duplicate
|
||||
? "a^"
|
||||
: definition.numericGameID
|
||||
? "[1-9][0-9]*"
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
value={row.providerGameId}
|
||||
onChange={(event) =>
|
||||
changeRow(row.rowKey, { providerGameId: event.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="游戏名称"
|
||||
required={definition.catalogPrimary === "name"}
|
||||
value={row.gameName}
|
||||
onChange={(event) => changeRow(row.rowKey, { gameName: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="H5 URL"
|
||||
required={definition.catalogPrimary !== "name"}
|
||||
value={row.gameUrl}
|
||||
onChange={(event) => changeRow(row.rowKey, { gameUrl: event.target.value })}
|
||||
/>
|
||||
<Button
|
||||
aria-label={`删除第 ${index + 1} 个游戏`}
|
||||
disabled={disabled}
|
||||
onClick={() => removeRow(row.rowKey)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</Button>
|
||||
</div>
|
||||
<details className={styles.rowDetails}>
|
||||
<summary>素材与游戏参数</summary>
|
||||
<div className={styles.rowExtra}>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="图标 URL"
|
||||
value={row.iconUrl}
|
||||
onChange={(event) => changeRow(row.rowKey, { iconUrl: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="封面 URL"
|
||||
value={row.coverUrl}
|
||||
onChange={(event) =>
|
||||
changeRow(row.rowKey, { coverUrl: event.target.value })
|
||||
}
|
||||
/>
|
||||
{adapterType === "yomi_v4" ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="场次 ID"
|
||||
slotProps={{ htmlInput: { min: 1, step: 1 } }}
|
||||
type="number"
|
||||
value={row.gameLevelUid}
|
||||
onChange={(event) =>
|
||||
changeRow(row.rowKey, { gameLevelUid: event.target.value })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>当前无游戏目录</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function withRowKeys(rows, prefix) {
|
||||
return rows.map((row, index) => ({ ...row, rowKey: `${prefix}-${index}` }));
|
||||
}
|
||||
|
||||
function catalogSignature(rows) {
|
||||
return JSON.stringify(
|
||||
(rows || []).map(({ providerGameId, gameName, gameUrl, iconUrl, coverUrl, gameLevelUid }) => ({
|
||||
providerGameId,
|
||||
gameName,
|
||||
gameUrl,
|
||||
iconUrl,
|
||||
coverUrl,
|
||||
gameLevelUid,
|
||||
})),
|
||||
);
|
||||
}
|
||||
162
src/features/games/components/AdapterConfigFields.module.css
Normal file
162
src/features/games/components/AdapterConfigFields.module.css
Normal file
@ -0,0 +1,162 @@
|
||||
.root {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.group,
|
||||
.catalog {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.fieldGrid > :global(.MuiTextField-root),
|
||||
.fieldGrid > :global(.MuiFormControl-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wideField {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--danger) 7%, transparent);
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.catalogHeader {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.catalogRows {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.catalogRow {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.catalogMain {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(130px, 0.55fr) minmax(150px, 0.7fr) minmax(260px, 1.5fr) auto;
|
||||
align-items: start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.catalogMain > :global(.MuiTextField-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rowDetails,
|
||||
.details {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.rowDetails {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rowDetails > summary,
|
||||
.details > summary {
|
||||
padding: 9px 11px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.rowDetails > summary {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.details[open] > summary {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.rowExtra {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.advancedEditor,
|
||||
.details > .fieldGrid {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
min-height: 48px;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fieldGrid,
|
||||
.rowExtra {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.catalogMain {
|
||||
grid-template-columns: minmax(120px, 0.6fr) minmax(150px, 0.8fr) minmax(220px, 1.4fr) auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fieldGrid,
|
||||
.rowExtra,
|
||||
.catalogMain {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wideField {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.catalogMain > :global(button) {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
210
src/features/games/components/PlatformFormDialog.jsx
Normal file
210
src/features/games/components/PlatformFormDialog.jsx
Normal file
@ -0,0 +1,210 @@
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdapterConfigFields } from "@/features/games/components/AdapterConfigFields.jsx";
|
||||
import {
|
||||
adapterDefinition,
|
||||
adapterSecretState,
|
||||
adapterTypeOptions,
|
||||
migrateAdapterConfigJson,
|
||||
readAdapterConfig,
|
||||
} from "@/features/games/adapterConfigModel.js";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import styles from "@/features/games/components/PlatformFormDialog.module.css";
|
||||
|
||||
export function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
||||
const definition = adapterDefinition(form.adapterType);
|
||||
const configValid = Boolean(readAdapterConfig(form.adapterConfigJson));
|
||||
const disabled = !page.abilities.canUpdate;
|
||||
const fixedAdapterType = adapterTypeOptions.find(
|
||||
([value]) => adapterDefinition(value).fixedPlatformCode === form.platformCode.trim(),
|
||||
)?.[0];
|
||||
const { configuredSecretUsable, required: secretRequired } = adapterSecretState(form, mode === "edit");
|
||||
|
||||
const changeAdapter = (adapterType) => {
|
||||
const previousDefinition = adapterDefinition(form.adapterType);
|
||||
const nextDefinition = adapterDefinition(adapterType);
|
||||
const adapterDrafts = {
|
||||
...(form.adapterDrafts || {}),
|
||||
[form.adapterType]: {
|
||||
adapterConfigJson: form.adapterConfigJson,
|
||||
apiBaseUrl: form.apiBaseUrl,
|
||||
callbackIpWhitelistText: form.callbackIpWhitelistText,
|
||||
},
|
||||
};
|
||||
const savedDraft = adapterDrafts[adapterType];
|
||||
const next = {
|
||||
...form,
|
||||
adapterType,
|
||||
adapterDrafts,
|
||||
// 不允许把上一厂商刚输入的签名密钥带进新协议;编辑迁移时下方会强制重新录入。
|
||||
callbackSecret: "",
|
||||
// 每个厂商保留独立草稿,切回时恢复;首次切入才从公共字段和游戏目录生成兼容配置。
|
||||
adapterConfigJson:
|
||||
savedDraft?.adapterConfigJson ??
|
||||
migrateAdapterConfigJson(form.adapterConfigJson, form.adapterType, adapterType),
|
||||
apiBaseUrl: savedDraft?.apiBaseUrl ?? "",
|
||||
callbackIpWhitelistText: savedDraft?.callbackIpWhitelistText ?? "",
|
||||
};
|
||||
if (mode === "create" && nextDefinition.fixedPlatformCode) {
|
||||
const canReplaceName =
|
||||
!form.platformName.trim() || form.platformName.trim() === previousDefinition.fixedPlatformName;
|
||||
// ZGame/AMG 的回调按固定 platformCode 查启动会话;这里必须锁定,不能让可保存但无法结算的配置进入库。
|
||||
next.platformCode = nextDefinition.fixedPlatformCode;
|
||||
if (canReplaceName) {
|
||||
next.platformName = nextDefinition.fixedPlatformName;
|
||||
}
|
||||
} else if (
|
||||
mode === "create" &&
|
||||
previousDefinition.fixedPlatformCode &&
|
||||
form.platformCode.trim() === previousDefinition.fixedPlatformCode
|
||||
) {
|
||||
next.platformCode = "";
|
||||
if (form.platformName.trim() === previousDefinition.fixedPlatformName) {
|
||||
next.platformName = "";
|
||||
}
|
||||
}
|
||||
page.setPlatformForm(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminFormDialog
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="large"
|
||||
submitDisabled={disabled || loading || !configValid}
|
||||
submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
|
||||
title={mode === "edit" ? "编辑平台" : "添加平台"}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormSection title="平台信息">
|
||||
<AdminFormFieldGrid columns="repeat(4, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled || mode === "edit" || Boolean(definition.fixedPlatformCode)}
|
||||
helperText={definition.fixedPlatformCode ? "协议固定 Code" : undefined}
|
||||
label="平台 Code"
|
||||
required
|
||||
value={form.platformCode}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, platformCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="平台名称"
|
||||
required
|
||||
value={form.platformName}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label="游戏平台启用状态"
|
||||
uncheckedLabel="维护"
|
||||
onChange={(checked) =>
|
||||
page.setPlatformForm({
|
||||
...form,
|
||||
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="接入与回调">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled || (mode === "edit" && Boolean(fixedAdapterType))}
|
||||
helperText={mode === "edit" && fixedAdapterType ? "协议固定适配器" : undefined}
|
||||
label="适配器"
|
||||
select
|
||||
value={form.adapterType}
|
||||
onChange={(event) => changeAdapter(event.target.value)}
|
||||
>
|
||||
{adapterTypeOptions.map(([value, label]) => {
|
||||
const optionDefinition = adapterDefinition(value);
|
||||
const fixedCodeMismatch =
|
||||
mode === "edit" &&
|
||||
optionDefinition.fixedPlatformCode &&
|
||||
optionDefinition.fixedPlatformCode !== form.platformCode.trim();
|
||||
return (
|
||||
<MenuItem disabled={Boolean(fixedCodeMismatch)} key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</TextField>
|
||||
{definition.apiUrl !== false ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label={definition.apiUrlLabel || "默认 H5 / API URL"}
|
||||
required={definition.apiUrlRequired}
|
||||
value={form.apiBaseUrl}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
<SecretField
|
||||
configured={configuredSecretUsable}
|
||||
disabled={disabled}
|
||||
label={definition.callbackSecretLabel || "回调密钥"}
|
||||
placeholder={definition.callbackSecretPlaceholder}
|
||||
required={secretRequired}
|
||||
value={form.callbackSecret}
|
||||
onChange={(callbackSecret) => page.setPlatformForm({ ...form, callbackSecret })}
|
||||
/>
|
||||
{definition.ipWhitelist ? (
|
||||
<TextField
|
||||
className={styles.ipWhitelist}
|
||||
disabled={disabled}
|
||||
label="回调 IP 白名单(每行一个 IP / CIDR)"
|
||||
maxRows={3}
|
||||
minRows={1}
|
||||
multiline
|
||||
value={form.callbackIpWhitelistText}
|
||||
onChange={(event) =>
|
||||
page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title={`${definition.label} 参数`}>
|
||||
<AdapterConfigFields
|
||||
adapterType={form.adapterType}
|
||||
disabled={disabled}
|
||||
value={form.adapterConfigJson}
|
||||
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SecretField({ configured, disabled, label, placeholder, required, value, onChange }) {
|
||||
const whitespaceOnly = required && Boolean(value) && !value.trim();
|
||||
return (
|
||||
<TextField
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
error={whitespaceOnly}
|
||||
helperText={whitespaceOnly ? "密钥不能只包含空格" : configured && !value ? "已配置,留空不修改" : undefined}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
.ipWhitelist {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@ -50,12 +50,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secretMeta {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.switchOnlyField {
|
||||
display: inline-flex;
|
||||
min-height: 56px;
|
||||
|
||||
@ -15,8 +15,13 @@ import {
|
||||
import { useGameAbilities } from "@/features/games/permissions.js";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
adapterDefinition,
|
||||
normalizeAdapterCallbackSecret,
|
||||
normalizeAdapterConfigJson,
|
||||
writableBridgeAdapterTypes,
|
||||
} from "@/features/games/adapterConfigModel.js";
|
||||
|
||||
const defaultGameForm = {
|
||||
gameId: "",
|
||||
@ -43,12 +48,14 @@ const defaultPlatformForm = {
|
||||
apiBaseUrl: "",
|
||||
// 新建平台必须选择真实厂商协议,避免把线上配置误落成 demo。
|
||||
adapterType: "yomi_v4",
|
||||
// 后台现在会回显厂商 key,编辑弹窗直接显示当前值;提交空值仍代表“不覆盖旧值”。
|
||||
initialAdapterType: "yomi_v4",
|
||||
// 已保存密钥只回显“已配置”,不会重新进入表单状态;提交空值仍代表“不覆盖旧值”。
|
||||
callbackSecret: "",
|
||||
callbackSecretSet: false,
|
||||
callbackIpWhitelistText: "",
|
||||
// 厂商扩展配置以 JSON 透传,服务端按 adapter_type 自己解析需要的字段。
|
||||
adapterConfigJson: "{}",
|
||||
adapterDrafts: {},
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
@ -57,17 +64,6 @@ const defaultBridgeScriptForm = {
|
||||
bridgeScriptVersion: "",
|
||||
};
|
||||
|
||||
const writableBridgeAdapterTypes = new Set([
|
||||
"yomi_v4",
|
||||
"leadercc_v1",
|
||||
"zeeone_v1",
|
||||
"baishun_v1",
|
||||
"vivagames_v1",
|
||||
"reyou_v1",
|
||||
"zgame_v1",
|
||||
"amg_v1",
|
||||
]);
|
||||
|
||||
const emptyCatalog = { items: [], pageSize: 50 };
|
||||
const emptyPlatforms = { items: [] };
|
||||
const defaultPageSize = 50;
|
||||
@ -302,7 +298,7 @@ export function useGamesPage() {
|
||||
event.preventDefault();
|
||||
let payload;
|
||||
try {
|
||||
payload = platformPayload(platformForm);
|
||||
payload = platformPayload(platformForm, Boolean(editingPlatformCode));
|
||||
} catch (err) {
|
||||
showToast(err.message || "适配器配置 JSON 不正确", "error");
|
||||
return;
|
||||
@ -784,11 +780,13 @@ function platformToForm(platform) {
|
||||
status: platform.status || "active",
|
||||
apiBaseUrl: platform.apiBaseUrl || "",
|
||||
adapterType: platform.adapterType || "yomi_v4",
|
||||
// 使用后端返回的当前密钥,方便运营直接核对;用户清空提交时后端会保留旧值。
|
||||
initialAdapterType: platform.adapterType || "yomi_v4",
|
||||
// 运营要求密钥明文可核对;后端有返回时直接进入编辑框,没有返回时仍保留“已配置”状态语义。
|
||||
callbackSecret: platform.callbackSecret || "",
|
||||
callbackSecretSet: Boolean(platform.callbackSecretSet),
|
||||
callbackIpWhitelistText: (platform.callbackIpWhitelist || []).join("\n"),
|
||||
adapterConfigJson: platform.adapterConfigJson || "{}",
|
||||
adapterDrafts: {},
|
||||
sortOrder: Number(platform.sortOrder || 0),
|
||||
};
|
||||
}
|
||||
@ -823,24 +821,29 @@ function normalizeLaunchMode(value) {
|
||||
return "full_screen";
|
||||
}
|
||||
|
||||
function platformPayload(form) {
|
||||
const isLeaderCC = form.adapterType.trim() === "leadercc_v1";
|
||||
function platformPayload(form, editing) {
|
||||
const adapterType = form.adapterType.trim() || "yomi_v4";
|
||||
const definition = adapterDefinition(adapterType);
|
||||
const status = form.status.trim() || "active";
|
||||
const callbackSecret = normalizeAdapterCallbackSecret(form, editing);
|
||||
// required 属性只能拦截空字符串,纯空格提交后会被 trim 成空并触发后端“保留旧密钥”;提交层必须再次校验。
|
||||
return {
|
||||
platformCode: form.platformCode.trim(),
|
||||
platformName: form.platformName.trim(),
|
||||
status: form.status.trim() || "active",
|
||||
apiBaseUrl: isLeaderCC ? "" : form.apiBaseUrl.trim(),
|
||||
adapterType: form.adapterType.trim() || "yomi_v4",
|
||||
status,
|
||||
apiBaseUrl: definition.apiUrl === false ? "" : form.apiBaseUrl.trim(),
|
||||
adapterType,
|
||||
// 空密钥提交给后端后会保留旧值;非空才覆盖,用于安全轮换 key/AppSecret。
|
||||
callbackSecret: form.callbackSecret.trim(),
|
||||
callbackSecret,
|
||||
// 支持换行或逗号分隔,方便直接粘贴厂商给的多 IP 白名单。
|
||||
callbackIpWhitelist: isLeaderCC
|
||||
? []
|
||||
: form.callbackIpWhitelistText
|
||||
callbackIpWhitelist: definition.ipWhitelist
|
||||
? form.callbackIpWhitelistText
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
adapterConfigJson: normalizeJsonObjectString(form.adapterConfigJson),
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
// 可视字段只 patch 当前 key;提交时再统一类型与历史别名,未知字段、游戏目录和桥接配置原样保留。
|
||||
adapterConfigJson: normalizeAdapterConfigJson(form.adapterConfigJson, adapterType),
|
||||
sortOrder: Number(form.sortOrder || 0),
|
||||
};
|
||||
}
|
||||
|
||||
@ -14,7 +14,6 @@ import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
@ -27,6 +26,7 @@ import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { useGamesPage } from "@/features/games/hooks/useGamesPage.js";
|
||||
import { GameWhitelistDialog } from "@/features/games/components/GameWhitelistDialog.jsx";
|
||||
import { PlatformFormDialog } from "@/features/games/components/PlatformFormDialog.jsx";
|
||||
import styles from "@/features/games/games.module.css";
|
||||
|
||||
const statusOptions = [
|
||||
@ -45,18 +45,6 @@ const launchModeOptions = [
|
||||
["half_screen", "半屏"],
|
||||
["three_quarter_screen", "3/4屏"],
|
||||
];
|
||||
// 这里的值必须和 game-service 的 adapter_type 白名单一致;新增厂商先后端登记,再放到后台可选项。
|
||||
const adapterTypeOptions = [
|
||||
["yomi_v4", "小游 Yomi V4"],
|
||||
["leadercc_v1", "灵仙 LeaderCC V1"],
|
||||
["zeeone_v1", "ZeeOne V1"],
|
||||
["baishun_v1", "百顺 BAISHUN V1"],
|
||||
["vivagames_v1", "VIVAGAMES V1"],
|
||||
["reyou_v1", "热游 Reyou V1"],
|
||||
["zgame_v1", "ZGame V1"],
|
||||
["amg_v1", "AMG V1"],
|
||||
];
|
||||
|
||||
const baseColumns = [
|
||||
{
|
||||
key: "game",
|
||||
@ -487,108 +475,6 @@ function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
||||
const isLeaderCC = form.adapterType === "leadercc_v1";
|
||||
return (
|
||||
<AdminFormDialog
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={loading}
|
||||
open={open}
|
||||
submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
|
||||
title={mode === "edit" ? "编辑平台" : "添加平台"}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormSection title="平台信息">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={mode === "edit"}
|
||||
label="平台 Code"
|
||||
required
|
||||
value={form.platformCode}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, platformCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="平台名称"
|
||||
required
|
||||
value={form.platformName}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })}
|
||||
/>
|
||||
<SwitchOnlyField
|
||||
checked={form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="游戏平台启用状态"
|
||||
uncheckedLabel="维护"
|
||||
onChange={(checked) =>
|
||||
page.setPlatformForm({
|
||||
...form,
|
||||
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="接入配置">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
{/* 适配器决定服务端启动参数、签名/解密和错误码映射。 */}
|
||||
<TextField
|
||||
label="适配器"
|
||||
select
|
||||
value={form.adapterType}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, adapterType: event.target.value })}
|
||||
>
|
||||
{adapterTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{!isLeaderCC ? (
|
||||
<TextField
|
||||
label="H5/Auth/API URL"
|
||||
required={form.adapterType === "yomi_v4" || form.adapterType === "baishun_v1"}
|
||||
value={form.apiBaseUrl}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
{/* 后台直接回显当前厂商 key;清空保存时后端保持旧密钥,输入新值才轮换。 */}
|
||||
<TextField
|
||||
label="回调密钥"
|
||||
value={form.callbackSecret}
|
||||
onChange={(event) => page.setPlatformForm({ ...form, callbackSecret: event.target.value })}
|
||||
/>
|
||||
{!isLeaderCC ? (
|
||||
<TextField
|
||||
label="IP 白名单"
|
||||
minRows={3}
|
||||
multiline
|
||||
value={form.callbackIpWhitelistText}
|
||||
onChange={(event) =>
|
||||
page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{/* 厂商差异字段放 JSON:如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */}
|
||||
<JsonEditorField
|
||||
className={styles.fullField}
|
||||
label="适配器配置 JSON"
|
||||
minRows={5}
|
||||
value={form.adapterConfigJson}
|
||||
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformManageDialog({ open, page, onClose }) {
|
||||
const platforms = page.platformData.items || [];
|
||||
return (
|
||||
@ -613,9 +499,6 @@ function PlatformManageDialog({ open, page, onClose }) {
|
||||
{platform.platformCode} / {platform.adapterType || "未配置"}
|
||||
</span>
|
||||
{platform.apiBaseUrl ? <span className={styles.meta}>{platform.apiBaseUrl}</span> : null}
|
||||
<span className={`${styles.meta} ${styles.secretMeta}`}>
|
||||
密钥:{platform.callbackSecret || "未配置"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.platformActions}>
|
||||
<span className={styles.meta}>
|
||||
|
||||
@ -138,6 +138,7 @@ export const API_OPERATIONS = {
|
||||
getCPConfig: "getCPConfig",
|
||||
getCPWeeklyRankConfig: "getCPWeeklyRankConfig",
|
||||
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
||||
getExternalAdminUserPermissions: "getExternalAdminUserPermissions",
|
||||
getFinanceCoinSellerRechargeExchangeRate: "getFinanceCoinSellerRechargeExchangeRate",
|
||||
getFinanceScope: "getFinanceScope",
|
||||
getFinanceUSDTAddresses: "getFinanceUSDTAddresses",
|
||||
@ -213,6 +214,7 @@ export const API_OPERATIONS = {
|
||||
listEmojiPackCategories: "listEmojiPackCategories",
|
||||
listEmojiPacks: "listEmojiPacks",
|
||||
listExploreTabs: "listExploreTabs",
|
||||
listExternalAdminPermissionCatalog: "listExternalAdminPermissionCatalog",
|
||||
listExternalAdminUsers: "listExternalAdminUsers",
|
||||
listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders",
|
||||
listFinanceScopeAssignments: "listFinanceScopeAssignments",
|
||||
@ -350,6 +352,7 @@ export const API_OPERATIONS = {
|
||||
updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig",
|
||||
updateDiceConfig: "updateDiceConfig",
|
||||
updateExploreTab: "updateExploreTab",
|
||||
updateExternalAdminUserPermissions: "updateExternalAdminUserPermissions",
|
||||
updateExternalAdminUserStatus: "updateExternalAdminUserStatus",
|
||||
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
|
||||
updateGift: "updateGift",
|
||||
@ -688,7 +691,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
operationId: API_OPERATIONS.createExternalAdminUser,
|
||||
path: "/v1/admin/external-admin-users",
|
||||
permission: "external-admin-user:create",
|
||||
permissions: ["external-admin-user:create"]
|
||||
permissions: ["external-admin-user:create","external-admin-user:permissions"]
|
||||
},
|
||||
createFinanceCoinSellerRechargeOrder: {
|
||||
method: "POST",
|
||||
@ -1285,6 +1288,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "cumulative-recharge-reward:view",
|
||||
permissions: ["cumulative-recharge-reward:view"]
|
||||
},
|
||||
getExternalAdminUserPermissions: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getExternalAdminUserPermissions,
|
||||
path: "/v1/admin/external-admin-users/{id}/permissions",
|
||||
permission: "external-admin-user:permissions",
|
||||
permissions: ["external-admin-user:permissions"]
|
||||
},
|
||||
getFinanceCoinSellerRechargeExchangeRate: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getFinanceCoinSellerRechargeExchangeRate,
|
||||
@ -1808,6 +1818,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "app-config:view",
|
||||
permissions: ["app-config:view"]
|
||||
},
|
||||
listExternalAdminPermissionCatalog: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listExternalAdminPermissionCatalog,
|
||||
path: "/v1/admin/external-admin-users/permission-catalog",
|
||||
permission: "external-admin-user:permissions",
|
||||
permissions: ["external-admin-user:permissions"]
|
||||
},
|
||||
listExternalAdminUsers: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listExternalAdminUsers,
|
||||
@ -2753,6 +2770,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
updateExternalAdminUserPermissions: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateExternalAdminUserPermissions,
|
||||
path: "/v1/admin/external-admin-users/{id}/permissions",
|
||||
permission: "external-admin-user:permissions",
|
||||
permissions: ["external-admin-user:permissions"]
|
||||
},
|
||||
updateExternalAdminUserStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateExternalAdminUserStatus,
|
||||
|
||||
132
src/shared/api/generated/schema.d.ts
vendored
132
src/shared/api/generated/schema.d.ts
vendored
@ -3676,6 +3676,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/external-admin-users/permission-catalog": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listExternalAdminPermissionCatalog"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/external-admin-users/target": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -3724,6 +3740,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/external-admin-users/{id}/permissions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getExternalAdminUserPermissions"];
|
||||
put: operations["updateExternalAdminUserPermissions"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/exports/app-users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -4918,6 +4950,12 @@ export interface components {
|
||||
ApiResponseExternalAdminUserTarget: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ExternalAdminUserTargetLookup"];
|
||||
};
|
||||
ApiResponseExternalAdminPermissionCatalog: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ExternalAdminPermissionCatalog"];
|
||||
};
|
||||
ApiResponseExternalAdminUserPermissions: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ExternalAdminUserPermissions"];
|
||||
};
|
||||
ApiPageExternalAdminUser: {
|
||||
items: components["schemas"]["ExternalAdminUser"][];
|
||||
page: number;
|
||||
@ -4947,6 +4985,8 @@ export interface components {
|
||||
status: "active" | "disabled";
|
||||
linkedUser: components["schemas"]["ExternalAdminUserTarget"];
|
||||
permissions: string[];
|
||||
/** Format: int64 */
|
||||
permissionRevision: number;
|
||||
createdByAdminId?: number;
|
||||
/** Format: int64 */
|
||||
lastLoginAtMs?: number | null;
|
||||
@ -4959,6 +4999,30 @@ export interface components {
|
||||
targetUserId: string;
|
||||
username: string;
|
||||
password: string;
|
||||
permissions?: string[];
|
||||
};
|
||||
ExternalAdminPermissionCatalogItem: {
|
||||
code: string;
|
||||
label: string;
|
||||
group: string;
|
||||
dependencies: string[];
|
||||
capabilities: string[];
|
||||
defaultGranted: boolean;
|
||||
};
|
||||
ExternalAdminPermissionCatalog: {
|
||||
items: components["schemas"]["ExternalAdminPermissionCatalogItem"][];
|
||||
total: number;
|
||||
};
|
||||
ExternalAdminUserPermissions: {
|
||||
accountId: string;
|
||||
permissions: string[];
|
||||
/** Format: int64 */
|
||||
revision: number;
|
||||
};
|
||||
ExternalAdminUserPermissionsInput: {
|
||||
permissions: string[];
|
||||
/** Format: int64 */
|
||||
expectedRevision: number;
|
||||
};
|
||||
ExternalAdminUserStatusInput: {
|
||||
/** @enum {string} */
|
||||
@ -7152,6 +7216,24 @@ export interface components {
|
||||
"application/json": components["schemas"]["ApiResponseExternalAdminUserTarget"];
|
||||
};
|
||||
};
|
||||
/** @description External admin permission catalog */
|
||||
ExternalAdminPermissionCatalogResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiResponseExternalAdminPermissionCatalog"];
|
||||
};
|
||||
};
|
||||
/** @description External admin account permissions */
|
||||
ExternalAdminUserPermissionsResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiResponseExternalAdminUserPermissions"];
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
AgencyPageResponse: {
|
||||
headers: {
|
||||
@ -12464,6 +12546,20 @@ export interface operations {
|
||||
201: components["responses"]["ExternalAdminUserResponse"];
|
||||
};
|
||||
};
|
||||
listExternalAdminPermissionCatalog: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["ExternalAdminPermissionCatalogResponse"];
|
||||
};
|
||||
};
|
||||
lookupExternalAdminUserTarget: {
|
||||
parameters: {
|
||||
query: {
|
||||
@ -12520,6 +12616,42 @@ export interface operations {
|
||||
200: components["responses"]["ExternalAdminUserResponse"];
|
||||
};
|
||||
};
|
||||
getExternalAdminUserPermissions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||
};
|
||||
path: {
|
||||
id: components["parameters"]["Id"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["ExternalAdminUserPermissionsResponse"];
|
||||
};
|
||||
};
|
||||
updateExternalAdminUserPermissions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||
};
|
||||
path: {
|
||||
id: components["parameters"]["Id"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExternalAdminUserPermissionsInput"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: components["responses"]["ExternalAdminUserResponse"];
|
||||
};
|
||||
};
|
||||
appExportUsers: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@ -84,3 +84,17 @@ test("does not replay mutations after a network failure", async () => {
|
||||
await expect(apiRequest("/v1/admin/countries", { body: {}, method: "POST" })).rejects.toThrow("Failed to fetch");
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("preserves the HTTP status on API errors for optimistic concurrency handling", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ code: 409, message: "账号权限版本冲突" }), { status: 409 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(apiRequest("/v1/admin/external-admin-users/71/permissions", { body: {}, method: "PUT" })).rejects.toMatchObject({
|
||||
message: "账号权限版本冲突",
|
||||
status: 409
|
||||
});
|
||||
});
|
||||
|
||||
@ -124,19 +124,26 @@ export async function apiRequest<TData = unknown, TBody = unknown>(
|
||||
|
||||
if (raw) {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText || "请求失败");
|
||||
throw requestError(response.statusText || "请求失败", response.status);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = await readJSON(response);
|
||||
if (!response.ok || payload.code !== 0) {
|
||||
throw new Error(resolveErrorMessage(response, payload.message));
|
||||
throw requestError(resolveErrorMessage(response, payload.message), response.status);
|
||||
}
|
||||
|
||||
return payload.data as TData;
|
||||
}
|
||||
|
||||
function requestError(message: string, status: number) {
|
||||
const error = new Error(message) as Error & { status: number };
|
||||
// 保留 HTTP 状态供并发写入等业务分支精确判断,同时维持既有 Error message 行为。
|
||||
error.status = status;
|
||||
return error;
|
||||
}
|
||||
|
||||
function refreshOnce() {
|
||||
if (!refreshHandler) {
|
||||
return Promise.resolve(false);
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import styles from "./AppIdentity.module.css";
|
||||
|
||||
// AppIdentity 是后台中 App 的唯一展示形式:业务页只传目录数据,避免各个下拉、表格和弹窗分别维护 Logo 规则。
|
||||
export function AppIdentity({ app, appCode, appName, className = "", logoUrl, size = "medium" }) {
|
||||
export function AppIdentity({ app, appCode, appName, className = "", logoUrl, showLogoFallback = true, size = "medium" }) {
|
||||
// 主后台使用 camelCase,Social BI 的服务端聚合契约使用 snake_case;组件在展示边界统一兼容,业务视图不复制字段转换。
|
||||
const resolvedCode = String(app?.appCode || app?.app_code || appCode || "").trim();
|
||||
const resolvedName = String(app?.appName || app?.app_name || appName || resolvedCode || "-").trim();
|
||||
@ -10,13 +10,13 @@ export function AppIdentity({ app, appCode, appName, className = "", logoUrl, si
|
||||
|
||||
return (
|
||||
<span className={[styles.root, styles[size] || styles.medium, className].filter(Boolean).join(" ")}>
|
||||
<AppLogo appCode={resolvedCode} appName={resolvedName} logoUrl={resolvedLogoUrl} />
|
||||
<AppLogo appCode={resolvedCode} appName={resolvedName} logoUrl={resolvedLogoUrl} showFallback={showLogoFallback} />
|
||||
<span className={styles.name}>{resolvedName}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AppLogo({ appCode, appName, logoUrl }) {
|
||||
function AppLogo({ appCode, appName, logoUrl, showFallback }) {
|
||||
const [failedLogoUrl, setFailedLogoUrl] = useState("");
|
||||
const canRenderImage = Boolean(logoUrl) && failedLogoUrl !== logoUrl;
|
||||
|
||||
@ -32,6 +32,11 @@ function AppLogo({ appCode, appName, logoUrl }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!showFallback) {
|
||||
// 外管品牌只能展示 apps.logo_url 的真实图片;缺失或加载失败时不伪造一个看似品牌 Logo 的首字母图标。
|
||||
return null;
|
||||
}
|
||||
|
||||
// 网络失败或历史数据暂无 Logo 时保留稳定占位,不让选择项高度和表格对齐抖动。
|
||||
const initial =
|
||||
String(appName || appCode || "?")
|
||||
|
||||
@ -29,3 +29,27 @@ test("supports the snake case Social BI app contract", () => {
|
||||
expect(screen.getByText("Aslan")).toBeInTheDocument();
|
||||
expect(container.querySelector("img")).toHaveAttribute("src", "https://media.example.com/aslan.png");
|
||||
});
|
||||
|
||||
test("can hide the fallback when a surface must only show a database logo", () => {
|
||||
const { container } = render(
|
||||
<AppIdentity app={{ appCode: "fami", appName: "Fami", logoUrl: "" }} showLogoFallback={false} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(screen.getByText("Fami")).toBeInTheDocument();
|
||||
expect(screen.queryByText("F", { selector: "span" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not invent a fallback when an external portal database logo fails to load", () => {
|
||||
const { container } = render(
|
||||
<AppIdentity
|
||||
app={{ appCode: "fami", appName: "Fami", logoUrl: "https://media.example.com/apps/missing-fami.png" }}
|
||||
showLogoFallback={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.error(container.querySelector("img"));
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(screen.getByText("Fami")).toBeInTheDocument();
|
||||
expect(screen.queryByText("F", { selector: "span" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -23,6 +23,7 @@ export function AppJumpConfigField({
|
||||
onChange,
|
||||
paramValue,
|
||||
required = false,
|
||||
showHint = true,
|
||||
typeLabel = "跳转类型",
|
||||
typeValue,
|
||||
}) {
|
||||
@ -102,7 +103,7 @@ export function AppJumpConfigField({
|
||||
</>
|
||||
)}
|
||||
</AdminFormFieldGrid>
|
||||
{type === "app" ? (
|
||||
{type === "app" && showHint ? (
|
||||
<p className={styles.hint}>{messages.hint || "APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。"}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,7 @@ import styles from "@/shared/ui/JsonEditorField.module.css";
|
||||
export function JsonEditorField({
|
||||
className = "",
|
||||
disabled = false,
|
||||
formatter,
|
||||
label,
|
||||
minRows = 6,
|
||||
onChange,
|
||||
@ -28,7 +29,8 @@ export function JsonEditorField({
|
||||
if (!result.valid) {
|
||||
return;
|
||||
}
|
||||
onChange(JSON.stringify(result.value, null, 2));
|
||||
// 厂商适配器可提供保留 int64 token 的格式化器;默认路径仍沿用普通 JSON 格式化。
|
||||
onChange(formatter ? formatter(value) : JSON.stringify(result.value, null, 2));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -49,7 +49,7 @@ const defaultMessages = {
|
||||
|
||||
// withTime=false 时是纯日期区间选择器:隐藏时/分与小时级快捷区间,
|
||||
// 选中的开始/结束日分别取当天 00:00:00.000 与 23:59:59.999 的本地毫秒。
|
||||
export function TimeRangeFilter({ className = "", disabled = false, label = "时间", locale = "zh-CN", messages, onChange, value, withTime = true }) {
|
||||
export function TimeRangeFilter({ className = "", direction = "ltr", disabled = false, label = "时间", locale = "zh-CN", messages, onChange, value, withTime = true }) {
|
||||
const copy = useMemo(() => ({ ...defaultMessages, ...messages }), [messages]);
|
||||
const range = useMemo(() => normalizeRange(value), [value]);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
@ -69,6 +69,12 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
||||
const rangeDistance = formatLocalizedRangeDistance(draft.startMs, draft.endMs, withTime, copy);
|
||||
const weekLabels = useMemo(() => localizedWeekLabels(locale), [locale]);
|
||||
const hasRange = Boolean(range.startMs || range.endMs);
|
||||
// CSS Modules do not pass through the external-admin Emotion RTL cache. Keep
|
||||
// popover anchoring and directional glyphs explicit so Arabic mirrors as a unit.
|
||||
const rtl = direction === "rtl";
|
||||
const horizontalOrigin = rtl ? "right" : "left";
|
||||
const PreviousMonthIcon = rtl ? ChevronRightOutlined : ChevronLeftOutlined;
|
||||
const NextMonthIcon = rtl ? ChevronLeftOutlined : ChevronRightOutlined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@ -183,9 +189,9 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
||||
<Popover
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
|
||||
slotProps={{ paper: { className: styles.paper } }}
|
||||
transformOrigin={{ horizontal: "left", vertical: "top" }}
|
||||
anchorOrigin={{ horizontal: horizontalOrigin, vertical: "bottom" }}
|
||||
slotProps={{ paper: { className: styles.paper, dir: direction } }}
|
||||
transformOrigin={{ horizontal: horizontalOrigin, vertical: "top" }}
|
||||
onClose={closePopover}
|
||||
>
|
||||
<form className={styles.panel} onSubmit={submitDraft}>
|
||||
@ -233,11 +239,11 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
||||
</div>
|
||||
<div className={styles.calendarHeader}>
|
||||
<IconButton aria-label={copy.previousMonth} size="small" onClick={() => changeMonth(-1)}>
|
||||
<ChevronLeftOutlined fontSize="small" />
|
||||
<PreviousMonthIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<span className={styles.calendarTitle}>{monthTitle(viewDate, locale)}</span>
|
||||
<IconButton aria-label={copy.nextMonth} size="small" onClick={() => changeMonth(1)}>
|
||||
<ChevronRightOutlined fontSize="small" />
|
||||
<NextMonthIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={`${styles.calendarGrid} ${styles.rangeCalendarGrid}`}>
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
width: auto;
|
||||
min-width: 132px !important;
|
||||
max-width: min(420px, 100%);
|
||||
padding: 7px 28px 3px var(--control-padding-x) !important;
|
||||
padding-block: 7px 3px !important;
|
||||
padding-inline: var(--control-padding-x) 28px !important;
|
||||
border-color: var(--border) !important;
|
||||
background: var(--bg-input-strong) !important;
|
||||
box-shadow: none !important;
|
||||
@ -29,8 +30,8 @@
|
||||
|
||||
.triggerMeta {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: var(--space-2);
|
||||
inset-block-start: -7px;
|
||||
inset-inline-start: var(--space-2);
|
||||
display: inline-flex;
|
||||
max-width: calc(100% - var(--space-4));
|
||||
align-items: center;
|
||||
@ -58,15 +59,15 @@
|
||||
color: var(--text-primary);
|
||||
font-size: var(--control-font-size);
|
||||
line-height: var(--control-line-height);
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.triggerChevron {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--space-2);
|
||||
inset-block-start: 50%;
|
||||
inset-inline-end: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@ -94,8 +95,7 @@
|
||||
|
||||
.quickButton {
|
||||
min-width: 0 !important;
|
||||
padding-right: var(--space-2) !important;
|
||||
padding-left: var(--space-2) !important;
|
||||
padding-inline: var(--space-2) !important;
|
||||
}
|
||||
|
||||
.rangeFields {
|
||||
@ -115,7 +115,7 @@
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input-strong);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
transition:
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
box-shadow var(--motion-fast) var(--ease-standard),
|
||||
@ -275,11 +275,15 @@
|
||||
}
|
||||
|
||||
.dayRangeStart {
|
||||
border-radius: var(--radius-control) 0 0 var(--radius-control);
|
||||
border-radius: 0;
|
||||
border-start-start-radius: var(--radius-control);
|
||||
border-end-start-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.dayRangeEnd {
|
||||
border-radius: 0 var(--radius-control) var(--radius-control) 0;
|
||||
border-radius: 0;
|
||||
border-start-end-radius: var(--radius-control);
|
||||
border-end-end-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.dayRangeStart.dayRangeEnd {
|
||||
@ -304,8 +308,8 @@
|
||||
|
||||
.daySelected::after {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 3px;
|
||||
inset-block-start: 1px;
|
||||
inset-inline-end: 3px;
|
||||
color: currentcolor;
|
||||
content: attr(data-range-edge);
|
||||
font-size: 8px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user