diff --git a/.claude/launch.json b/.claude/launch.json index 63cf92b..90164a1 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,12 +6,6 @@ "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 7001 - }, - { - "name": "admin-platform-alt", - "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev", "--", "--port", "7031"], - "port": 7031 } ] } diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 3805bd7..5ed8487 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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": { diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index 6373b28..969e48c 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -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(); diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index cd8af39..0875c1f 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -88,7 +88,7 @@ export function SocialBiApp() { }, []); const openRequirementSection = useCallback((requirementSection) => { - // 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 新用户”。 + // 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 充值”。 setTableState({ mode: "requirements", requirementSection }); setViewKey("table"); }, []); diff --git a/databi/src/social/state.js b/databi/src/social/state.js index b0c8b9b..3dadb04 100644 --- a/databi/src/social/state.js +++ b/databi/src/social/state.js @@ -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); } } diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx index cf9004d..8a88dc0 100644 --- a/databi/src/social/views/DataTableView.jsx +++ b/databi/src/social/views/DataTableView.jsx @@ -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 } ]; diff --git a/databi/src/social/views/OverviewView.jsx b/databi/src/social/views/OverviewView.jsx index 7909faa..e876a6a 100644 --- a/databi/src/social/views/OverviewView.jsx +++ b/databi/src/social/views/OverviewView.jsx @@ -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}  ${formatChartAmount(valueMetric, numeric)}`; }); const header = list[0]?.axisValueLabel || list[0]?.name || ""; - if (list.length > 1) { + if (list.length > 1 && !isRatioMetric) { lines.push(`合计  ${formatChartAmount(valueMetric, total)}`); } return [header, ...lines].filter(Boolean).join("
"); @@ -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 ; @@ -329,29 +338,32 @@ export function OverviewView() {
- {trendLabel}趋势 - 按 App 堆叠 + {trendConfig.label}趋势 + {RATIO_METRIC_KEYS.has(trendMetric) ? "按 App 对比" : "按 App 堆叠"}
-
+ {/* 趋势 Tab 与数据需求分组一一映射,按钮始终跟随当前指标进入对应明细。 */} + +
{TREND_METRICS.map((item) => ( ))} -
diff --git a/external-admin/__repro__.html b/external-admin/__repro__.html deleted file mode 100644 index 54d509c..0000000 --- a/external-admin/__repro__.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - filter-bar repro - - - -
- - - diff --git a/external-admin/__repro__.jsx b/external-admin/__repro__.jsx deleted file mode 100644 index 36c9fbf..0000000 --- a/external-admin/__repro__.jsx +++ /dev/null @@ -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( - - - - - -); - -function Root() { - const { direction } = useExternalI18n(); - const theme = useMemo(() => createExternalTheme(direction), [direction]); - return ( - - - - - - - - - - - ); -} diff --git a/external-admin/index.html b/external-admin/index.html index d5b05c8..329eb1a 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -1,10 +1,10 @@ - + - Admin System + External Admin diff --git a/external-admin/src/ExternalAdminApp.jsx b/external-admin/src/ExternalAdminApp.jsx index aaad881..c559ebf 100644 --- a/external-admin/src/ExternalAdminApp.jsx +++ b/external-admin/src/ExternalAdminApp.jsx @@ -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() { } path="/login" /> }> } path="/change-password" /> - }> - }> - } index /> - } path="/overview" /> - }> - } path="/users" /> - - }> - } path="/bans" /> - - }> - } path="/organization/:kind" /> - - }> - } path="/rooms" /> - - }> - } path="/grants" /> - - }> - } path="/banners" /> - - } path="*" /> + }> + } index /> + } path="/overview" /> + }> + } path="/users" /> + }> + } path="/bans" /> + + }> + } path="/organization/:kind" /> + + }> + } path="/rooms" /> + + }> + } path="/grants" /> + + }> + } path="/banners" /> + + } path="*" /> diff --git a/external-admin/src/api/auth.js b/external-admin/src/api/auth.js index 9371100..f6dcae6 100644 --- a/external-admin/src/api/auth.js +++ b/external-admin/src/api/auth.js @@ -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), diff --git a/external-admin/src/api/auth.test.js b/external-admin/src/api/auth.test.js index 1c3584e..f732676 100644 --- a/external-admin/src/api/auth.test.js +++ b/external-admin/src/api/auth.test.js @@ -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 })); diff --git a/external-admin/src/auth/RouteGuards.jsx b/external-admin/src/auth/RouteGuards.jsx index d2ae5d0..599400b 100644 --- a/external-admin/src/auth/RouteGuards.jsx +++ b/external-admin/src/auth/RouteGuards.jsx @@ -38,11 +38,6 @@ export function RequireExternalAuth() { return ; } -export function RequirePasswordChanged() { - const { session } = useExternalAuth(); - return session?.passwordChangeRequired ? : ; -} - export function RequireExternalCapability({ anyOf }) { const { session } = useExternalAuth(); const { t } = useExternalI18n(); diff --git a/external-admin/src/components/BannerCreateDialog.jsx b/external-admin/src/components/BannerCreateDialog.jsx index 486fec1..077fcb6 100644 --- a/external-admin/src/components/BannerCreateDialog.jsx +++ b/external-admin/src/components/BannerCreateDialog.jsx @@ -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 }) { setForm({ ...form, sortOrder: event.target.value })} /> [value, t(`jump.target.${value}`)])), diff --git a/external-admin/src/components/BannerCreateDialog.test.jsx b/external-admin/src/components/BannerCreateDialog.test.jsx index bafad3e..f07ecc1 100644 --- a/external-admin/src/components/BannerCreateDialog.test.jsx +++ b/external-admin/src/components/BannerCreateDialog.test.jsx @@ -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(); }); }); diff --git a/external-admin/src/components/GrantDialogs.jsx b/external-admin/src/components/GrantDialogs.jsx index 6fce0f1..7f986ee 100644 --- a/external-admin/src/components/GrantDialogs.jsx +++ b/external-admin/src/components/GrantDialogs.jsx @@ -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 ( onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> setForm({ ...form, resourceId: event.target.value })}> {resources.map((item) => {item.name} ({item.id}))} @@ -34,7 +32,6 @@ export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { useEffect(() => setForm(prettyGrantInitial), [open]); return ( onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> setForm({ ...form, prettyId: event.target.value.trim() })} /> setForm({ ...form, durationDays: event.target.value })} /> @@ -43,11 +40,6 @@ export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { ); } -function TargetNotice() { - const { t } = useExternalI18n(); - return {t("grants.notice")}; -} - function GrantDialog({ children, loading, onClose, onSubmit, open, title }) { const { t } = useExternalI18n(); return ( diff --git a/external-admin/src/components/GrantDialogs.test.jsx b/external-admin/src/components/GrantDialogs.test.jsx new file mode 100644 index 0000000..3c72d63 --- /dev/null +++ b/external-admin/src/components/GrantDialogs.test.jsx @@ -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( + + + + ); + + 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(); +}); diff --git a/external-admin/src/i18n/ExternalI18nProvider.jsx b/external-admin/src/i18n/ExternalI18nProvider.jsx index b5fe1ee..3c244c4 100644 --- a/external-admin/src/i18n/ExternalI18nProvider.jsx +++ b/external-admin/src/i18n/ExternalI18nProvider.jsx @@ -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( diff --git a/external-admin/src/i18n/ExternalI18nProvider.test.jsx b/external-admin/src/i18n/ExternalI18nProvider.test.jsx index 562e9df..7a30b33 100644 --- a/external-admin/src/i18n/ExternalI18nProvider.test.jsx +++ b/external-admin/src/i18n/ExternalI18nProvider.test.jsx @@ -16,7 +16,7 @@ describe("ExternalI18nProvider", () => { render(); 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(); 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(); expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"); - expect(screen.getByTestId("title")).toHaveTextContent("管理系统"); + expect(screen.getByTestId("title")).toHaveTextContent("外管后台"); }); }); diff --git a/external-admin/src/i18n/emotionCache.integration.test.jsx b/external-admin/src/i18n/emotionCache.integration.test.jsx new file mode 100644 index 0000000..18acb18 --- /dev/null +++ b/external-admin/src/i18n/emotionCache.integration.test.jsx @@ -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(); + 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()).not.toThrow(); + expect(screen.getByRole("button", { name: "rtl" })).toBeInTheDocument(); +}); + +function DirectionProbe({ direction }) { + return ( + + + + + + ); +} diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js index e2865b1..97400cd 100644 --- a/external-admin/src/i18n/messages.js +++ b/external-admin/src/i18n/messages.js @@ -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" }; diff --git a/external-admin/src/i18n/messages.test.js b/external-admin/src/i18n/messages.test.js index 7fa4dbb..19c7cf1 100644 --- a/external-admin/src/i18n/messages.test.js +++ b/external-admin/src/i18n/messages.test.js @@ -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"); diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx index a7fadfe..1ce8839 100644 --- a/external-admin/src/layout/ExternalAdminLayout.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -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 = ( - - {t("app.shortTitle")} - {session?.appName || session?.appCode} - + @@ -131,7 +138,18 @@ export function ExternalAdminLayout() { }} > - {mobile && !current ? t("app.shortTitle") : t(current?.labelKey || "app.shortTitle")} + {mobile ? ( + + ) : ( + {t(current?.labelKey || "app.shortTitle")} + )} @@ -186,7 +204,7 @@ export function ExternalAdminLayout() { })} - + diff --git a/external-admin/src/layout/ExternalAdminLayout.test.jsx b/external-admin/src/layout/ExternalAdminLayout.test.jsx index 09f7da1..c767ab0 100644 --- a/external-admin/src/layout/ExternalAdminLayout.test.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.test.jsx @@ -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(); }); }); diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx index 436d82d..a14a139 100644 --- a/external-admin/src/pages/ChangePasswordPage.jsx +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -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() { - {t("auth.changePassword")}{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} + + + {t("auth.changePassword")} + {session?.account} + {error ? {error} : null} setForm({ ...form, oldPassword: event.target.value })} /> - setForm({ ...form, newPassword: event.target.value })} /> + setForm({ ...form, newPassword: event.target.value })} /> setForm({ ...form, confirmPassword: event.target.value })} /> diff --git a/external-admin/src/pages/ChangePasswordPage.test.jsx b/external-admin/src/pages/ChangePasswordPage.test.jsx index 51351d7..c1f6e4f 100644 --- a/external-admin/src/pages/ChangePasswordPage.test.jsx +++ b/external-admin/src/pages/ChangePasswordPage.test.jsx @@ -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(); + + 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 () => { diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index 683e340..280697a 100644 --- a/external-admin/src/pages/LoginPage.jsx +++ b/external-admin/src/pages/LoginPage.jsx @@ -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")); diff --git a/external-admin/src/pages/LoginPage.test.jsx b/external-admin/src/pages/LoginPage.test.jsx index d62f9d4..0511095 100644 --- a/external-admin/src/pages/LoginPage.test.jsx +++ b/external-admin/src/pages/LoginPage.test.jsx @@ -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" })); diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx index 9945f85..ee249f3 100644 --- a/external-admin/src/pages/OverviewPage.jsx +++ b/external-admin/src/pages/OverviewPage.jsx @@ -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 ( - - {t("overview.welcome")} - {session.appName || session.appCode} - - {t("overview.currentApp")}{session.appName || session.appCode} ({session.appCode}) - {t("overview.account")}{session.account} - {t("overview.permissions")}{enabledCount} / {EXTERNAL_CAPABILITIES.length} - - - {t("overview.features")} {EXTERNAL_CAPABILITIES.map((item) => { const enabled = hasExternalCapability(session, item.code); diff --git a/external-admin/src/shared/useExternalSessionBrand.js b/external-admin/src/shared/useExternalSessionBrand.js new file mode 100644 index 0000000..f7cbbef --- /dev/null +++ b/external-admin/src/shared/useExternalSessionBrand.js @@ -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); + } +} diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index c61ea93..b4ab771 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -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; } diff --git a/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx b/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx index 5a147a8..2178749 100644 --- a/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx +++ b/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx @@ -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); diff --git a/finance/src/constants.js b/finance/src/constants.js index 440d65b..8632bd1 100644 --- a/finance/src/constants.js +++ b/finance/src/constants.js @@ -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 = [ diff --git a/package.json b/package.json index 0236793..97dad83 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3385623..58abe24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/permissions.ts b/src/app/permissions.ts index b7b6ea9..cbe86f8 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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", diff --git a/src/features/external-admin-users/api.test.ts b/src/features/external-admin-users/api.test.ts index bc3e742..da4bad5 100644 --- a/src/features/external-admin-users/api.test.ts +++ b/src/features/external-admin-users/api.test.ts @@ -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", diff --git a/src/features/external-admin-users/api.ts b/src/features/external-admin-users/api.ts index e815302..0951492 100644 --- a/src/features/external-admin-users/api.ts +++ b/src/features/external-admin-users/api.ts @@ -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 { + const endpoint = API_ENDPOINTS.listExternalAdminPermissionCatalog; + const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listExternalAdminPermissionCatalog), { + headers: appScopeHeaders(appCode), + method: endpoint.method, + }); + return normalizePermissionCatalog(data); +} + +export async function getExternalAdminUserPermissions( + appCode: string, + id: EntityId, +): Promise { + const endpoint = API_ENDPOINTS.getExternalAdminUserPermissions; + const data = await apiRequest( + 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 { + const endpoint = API_ENDPOINTS.updateExternalAdminUserPermissions; + const data = await apiRequest( + 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 { diff --git a/src/features/external-admin-users/components/ExternalAdminPermissionDialogs.test.jsx b/src/features/external-admin-users/components/ExternalAdminPermissionDialogs.test.jsx new file mode 100644 index 0000000..30c43e1 --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminPermissionDialogs.test.jsx @@ -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( + , + ); + + expect(screen.getByRole("button", { name: "创建" })).toBeDisabled(); +}); + +test("disables permission updates when a successful catalog is empty", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "保存权限" })).toBeDisabled(); +}); diff --git a/src/features/external-admin-users/components/ExternalAdminPermissionDrawer.jsx b/src/features/external-admin-users/components/ExternalAdminPermissionDrawer.jsx new file mode 100644 index 0000000..66c354b --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminPermissionDrawer.jsx @@ -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 ( + + + + + } + contentClassName={styles.permissionDrawerBody} + open={open} + title={`配置权限${user?.username ? ` · ${user.username}` : ""}`} + width="detail" + onClose={onClose} + > + + + ); +} diff --git a/src/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx b/src/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx new file mode 100644 index 0000000..ff822d1 --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx @@ -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 ( +
+ + 加载权限目录 +
+ ); + } + + if (error) { + return ( +
+ {error} + {onRetry ? : null} +
+ ); + } + + return ( +
+
+ + 已选 {selectedCount} / {knownCodes.length} 项 + +
+ + +
+
+ + {groups.length ? ( +
+ {groups.map((group) => { + const groupCodes = group.items.map((permission) => permission.code); + const groupSelectedCount = groupCodes.filter((code) => selected.has(code)).length; + return ( +
+
+ {group.name} + +
+
+ {group.items.map((permission) => { + return ( + + ); + })} +
+
+ ); + })} +
+ ) : ( +
当前无可配置权限
+ )} +
+ ); +} + +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 })); +} diff --git a/src/features/external-admin-users/components/ExternalAdminPermissionSelector.test.jsx b/src/features/external-admin-users/components/ExternalAdminPermissionSelector.test.jsx new file mode 100644 index 0000000..43cb0a3 --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminPermissionSelector.test.jsx @@ -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( + , + ); + + 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( item.code)} />); + fireEvent.click(screen.getByRole("button", { name: "清空" })); + expect(onChange).toHaveBeenLastCalledWith([]); + }); +}); diff --git a/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx b/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx index ce0a679..1d071d8 100644 --- a/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx +++ b/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx @@ -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({ /> + + {abilities.canManagePermissions ? ( + + onFormChange({ permissions })} + onRetry={onReloadCatalog} + /> + + ) : null} ); } diff --git a/src/features/external-admin-users/external-admin-users.module.css b/src/features/external-admin-users/external-admin-users.module.css index 35ff7ed..7ff1a6b 100644 --- a/src/features/external-admin-users/external-admin-users.module.css +++ b/src/features/external-admin-users/external-admin-users.module.css @@ -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; + } } diff --git a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js index 4584d50..84f78c8 100644 --- a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js +++ b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js @@ -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, }; diff --git a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx index 1e25d6e..2a4e886 100644 --- a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx +++ b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx @@ -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, + }; +} diff --git a/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx b/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx index a6db87d..c00b450 100644 --- a/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx +++ b/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx @@ -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 ( (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 ( + page.openPermissions(item)} + > + + + ({ 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(); + + 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(); + + 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(), + }; +} diff --git a/src/features/external-admin-users/permissionSelection.js b/src/features/external-admin-users/permissionSelection.js new file mode 100644 index 0000000..7ff42a8 --- /dev/null +++ b/src/features/external-admin-users/permissionSelection.js @@ -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; + } + }); + } +} diff --git a/src/features/external-admin-users/permissions.js b/src/features/external-admin-users/permissions.js index 0d01521..3374cdb 100644 --- a/src/features/external-admin-users/permissions.js +++ b/src/features/external-admin-users/permissions.js @@ -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), diff --git a/src/features/external-admin-users/permissions.test.jsx b/src/features/external-admin-users/permissions.test.jsx new file mode 100644 index 0000000..f7b2915 --- /dev/null +++ b/src/features/external-admin-users/permissions.test.jsx @@ -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); +}); diff --git a/src/features/external-admin-users/schema.test.ts b/src/features/external-admin-users/schema.test.ts index ca1e605..f54b76f 100644 --- a/src/features/external-admin-users/schema.test.ts +++ b/src/features/external-admin-users/schema.test.ts @@ -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"] }); + }); }); diff --git a/src/features/external-admin-users/schema.ts b/src/features/external-admin-users/schema.ts index ca85633..6f2e719 100644 --- a/src/features/external-admin-users/schema.ts +++ b/src/features/external-admin-users/schema.ts @@ -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; export type ExternalAdminUserCreatePayload = z.output; export type ExternalAdminUserPasswordForm = z.input; export type ExternalAdminUserPasswordPayload = z.output; +export type ExternalAdminUserPermissionsForm = z.input; +export type ExternalAdminUserPermissionsPayload = z.output; diff --git a/src/features/games/adapterConfigModel.js b/src/features/games/adapterConfigModel.js new file mode 100644 index 0000000..c37fc21 --- /dev/null +++ b/src/features/games/adapterConfigModel.js @@ -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(",")}}`; +} diff --git a/src/features/games/api.ts b/src/features/games/api.ts index bd75dbf..bda99cd 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -14,7 +14,7 @@ export interface GamePlatformDto { callbackSecret?: string; callbackSecretSet: boolean; callbackIpWhitelist: string[]; - // 厂商扩展配置保留 JSON 字符串,后台前端不绑定具体字段结构。 + // JSON 是服务端传输格式;配置页会按 adapterType 显示结构化字段,并保留未来新增的未知 key。 adapterConfigJson: string; sortOrder: number; createdAtMs?: number; diff --git a/src/features/games/components/AdapterConfigFields.jsx b/src/features/games/components/AdapterConfigFields.jsx new file mode 100644 index 0000000..eeff97c --- /dev/null +++ b/src/features/games/components/AdapterConfigFields.jsx @@ -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 ( +
+ {!configValid ?
请先在高级配置中修正 JSON 格式
: null} + + {(definition.fields || []).length > 0 ? ( + + ) : null} + {definition.catalog ? ( + + ) : null} + {(definition.advancedFields || []).length > 0 ? ( +
+ 可选厂商参数 + +
+ ) : null} +
+ 高级配置 · 完整 JSON +
+ +
+
+
+ ); +} + +function ConfigFieldGroup({ disabled, fields, title, value, onChange }) { + return ( +
+
{title}
+ +
+ ); +} + +function ConfigFieldGrid({ disabled, fields, value, onChange }) { + return ( +
+ {fields.map((field) => ( + onChange(updateAdapterConfigField(value, field, inputValue))} + /> + ))} +
+ ); +} + +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 ( + + {(field.options || []).map(([optionValue, optionLabel]) => ( + + {optionLabel} + + ))} + + ); + } + + return ( + {field.unit} } + : 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 ( +
+
+
游戏目录 · {rows.length}
+ +
+ {rows.length > 0 ? ( +
+ {rows.map((row, index) => { + const duplicate = + row.providerGameId.trim() !== "" && + rows.some( + (candidate, candidateIndex) => + candidateIndex !== index && + candidate.providerGameId.trim() === row.providerGameId.trim(), + ); + return ( +
+
+ + changeRow(row.rowKey, { providerGameId: event.target.value }) + } + /> + changeRow(row.rowKey, { gameName: event.target.value })} + /> + changeRow(row.rowKey, { gameUrl: event.target.value })} + /> + +
+
+ 素材与游戏参数 +
+ changeRow(row.rowKey, { iconUrl: event.target.value })} + /> + + changeRow(row.rowKey, { coverUrl: event.target.value }) + } + /> + {adapterType === "yomi_v4" ? ( + + changeRow(row.rowKey, { gameLevelUid: event.target.value }) + } + /> + ) : null} +
+
+
+ ); + })} +
+ ) : ( +
当前无游戏目录
+ )} +
+ ); +} + +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, + })), + ); +} diff --git a/src/features/games/components/AdapterConfigFields.module.css b/src/features/games/components/AdapterConfigFields.module.css new file mode 100644 index 0000000..5723611 --- /dev/null +++ b/src/features/games/components/AdapterConfigFields.module.css @@ -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; + } +} diff --git a/src/features/games/components/PlatformFormDialog.jsx b/src/features/games/components/PlatformFormDialog.jsx new file mode 100644 index 0000000..f7f42a9 --- /dev/null +++ b/src/features/games/components/PlatformFormDialog.jsx @@ -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 ( + + + + page.setPlatformForm({ ...form, platformCode: event.target.value })} + /> + page.setPlatformForm({ ...form, platformName: event.target.value })} + /> + + page.setPlatformForm({ + ...form, + status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance", + }) + } + /> + page.setPlatformForm({ ...form, sortOrder: event.target.value })} + /> + + + + + changeAdapter(event.target.value)} + > + {adapterTypeOptions.map(([value, label]) => { + const optionDefinition = adapterDefinition(value); + const fixedCodeMismatch = + mode === "edit" && + optionDefinition.fixedPlatformCode && + optionDefinition.fixedPlatformCode !== form.platformCode.trim(); + return ( + + {label} + + ); + })} + + {definition.apiUrl !== false ? ( + page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })} + /> + ) : null} + page.setPlatformForm({ ...form, callbackSecret })} + /> + {definition.ipWhitelist ? ( + + page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value }) + } + /> + ) : null} + + + + page.setPlatformForm({ ...form, adapterConfigJson })} + /> + + + ); +} + +function SecretField({ configured, disabled, label, placeholder, required, value, onChange }) { + const whitespaceOnly = required && Boolean(value) && !value.trim(); + return ( + onChange(event.target.value)} + /> + ); +} diff --git a/src/features/games/components/PlatformFormDialog.module.css b/src/features/games/components/PlatformFormDialog.module.css new file mode 100644 index 0000000..a04bfab --- /dev/null +++ b/src/features/games/components/PlatformFormDialog.module.css @@ -0,0 +1,3 @@ +.ipWhitelist { + grid-column: 1 / -1; +} diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css index ebeb116..ff2b739 100644 --- a/src/features/games/games.module.css +++ b/src/features/games/games.module.css @@ -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; diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index a4419aa..728216f 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -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), }; } diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 68699e1..a6328a6 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -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 ( - - - - page.setPlatformForm({ ...form, platformCode: event.target.value })} - /> - page.setPlatformForm({ ...form, platformName: event.target.value })} - /> - - page.setPlatformForm({ - ...form, - status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance", - }) - } - /> - page.setPlatformForm({ ...form, sortOrder: event.target.value })} - /> - - - - - {/* 适配器决定服务端启动参数、签名/解密和错误码映射。 */} - page.setPlatformForm({ ...form, adapterType: event.target.value })} - > - {adapterTypeOptions.map(([value, label]) => ( - - {label} - - ))} - - {!isLeaderCC ? ( - page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })} - /> - ) : null} - {/* 后台直接回显当前厂商 key;清空保存时后端保持旧密钥,输入新值才轮换。 */} - page.setPlatformForm({ ...form, callbackSecret: event.target.value })} - /> - {!isLeaderCC ? ( - - page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value }) - } - /> - ) : null} - {/* 厂商差异字段放 JSON:如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */} - page.setPlatformForm({ ...form, adapterConfigJson })} - /> - - - - ); -} - function PlatformManageDialog({ open, page, onClose }) { const platforms = page.platformData.items || []; return ( @@ -613,9 +499,6 @@ function PlatformManageDialog({ open, page, onClose }) { {platform.platformCode} / {platform.adapterType || "未配置"} {platform.apiBaseUrl ? {platform.apiBaseUrl} : null} - - 密钥:{platform.callbackSecret || "未配置"} -
diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index d7d9737..2fe5977 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 3811424..87c1e47 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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?: { diff --git a/src/shared/api/request.test.ts b/src/shared/api/request.test.ts index 96c7337..7a18c87 100644 --- a/src/shared/api/request.test.ts +++ b/src/shared/api/request.test.ts @@ -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 + }); +}); diff --git a/src/shared/api/request.ts b/src/shared/api/request.ts index eefe9b5..0144db7 100644 --- a/src/shared/api/request.ts +++ b/src/shared/api/request.ts @@ -124,19 +124,26 @@ export async function apiRequest( 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); diff --git a/src/shared/ui/AppIdentity.jsx b/src/shared/ui/AppIdentity.jsx index 475f98a..702622e 100644 --- a/src/shared/ui/AppIdentity.jsx +++ b/src/shared/ui/AppIdentity.jsx @@ -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 ( - + {resolvedName} ); } -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 || "?") diff --git a/src/shared/ui/AppIdentity.test.jsx b/src/shared/ui/AppIdentity.test.jsx index 913afce..ac57526 100644 --- a/src/shared/ui/AppIdentity.test.jsx +++ b/src/shared/ui/AppIdentity.test.jsx @@ -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( + , + ); + + 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( + , + ); + + fireEvent.error(container.querySelector("img")); + expect(container.querySelector("img")).toBeNull(); + expect(screen.getByText("Fami")).toBeInTheDocument(); + expect(screen.queryByText("F", { selector: "span" })).not.toBeInTheDocument(); +}); diff --git a/src/shared/ui/AppJumpConfigField.jsx b/src/shared/ui/AppJumpConfigField.jsx index 64a3121..f157b27 100644 --- a/src/shared/ui/AppJumpConfigField.jsx +++ b/src/shared/ui/AppJumpConfigField.jsx @@ -23,6 +23,7 @@ export function AppJumpConfigField({ onChange, paramValue, required = false, + showHint = true, typeLabel = "跳转类型", typeValue, }) { @@ -102,7 +103,7 @@ export function AppJumpConfigField({ )} - {type === "app" ? ( + {type === "app" && showHint ? (

{messages.hint || "APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。"}

) : null}
diff --git a/src/shared/ui/JsonEditorField.jsx b/src/shared/ui/JsonEditorField.jsx index eeb7d65..1396cf5 100644 --- a/src/shared/ui/JsonEditorField.jsx +++ b/src/shared/ui/JsonEditorField.jsx @@ -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 ( diff --git a/src/shared/ui/TimeRangeFilter.jsx b/src/shared/ui/TimeRangeFilter.jsx index cf369df..6a90077 100644 --- a/src/shared/ui/TimeRangeFilter.jsx +++ b/src/shared/ui/TimeRangeFilter.jsx @@ -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 = "时
@@ -233,11 +239,11 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
changeMonth(-1)}> - + {monthTitle(viewDate, locale)} changeMonth(1)}> - +
diff --git a/src/shared/ui/TimeRangeFilter.module.css b/src/shared/ui/TimeRangeFilter.module.css index 7cc3c51..72447e1 100644 --- a/src/shared/ui/TimeRangeFilter.module.css +++ b/src/shared/ui/TimeRangeFilter.module.css @@ -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;