From 9b7df9b8ac6bd98784d8aac0d51f7e6a6b602ce8 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 14:49:39 +0800 Subject: [PATCH 01/14] feat(ops-center): support lucky gift dynamic v3 --- contracts/admin-openapi.json | 20 +- ops-center/src/OpsCenterApp.jsx | 316 ++++--- ops-center/src/OpsCenterApp.test.jsx | 436 +++++++--- ops-center/src/api.js | 479 ++++++----- ops-center/src/api.test.js | 334 +++++--- ops-center/src/components/ConfigsView.jsx | 184 ++-- .../src/components/LuckyGiftConfigDialog.jsx | 794 ++++++++++++------ ops-center/src/configForm.js | 544 +++++++++--- ops-center/src/configForm.test.js | 217 +++++ ops-center/src/styles/index.css | 509 +++++------ .../lucky-gift/pages/LuckyGiftConfigPage.jsx | 214 +---- .../pages/LuckyGiftConfigPage.test.jsx | 14 + src/shared/api/generated/endpoints.ts | 18 +- src/shared/api/generated/schema.d.ts | 36 +- 14 files changed, 2620 insertions(+), 1495 deletions(-) create mode 100644 ops-center/src/configForm.test.js create mode 100644 src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 29bd213..ffe1de5 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -320,7 +320,7 @@ "x-permissions": ["red-packet:update"] } }, - "/admin/activity/lucky-gifts/v2/config": { + "/admin/ops-center/lucky-gifts/config": { "get": { "operationId": "getLuckyGiftConfig", "responses": { @@ -342,7 +342,7 @@ "x-permissions": ["lucky-gift:update"] } }, - "/admin/activity/lucky-gifts/v2/configs": { + "/admin/ops-center/lucky-gifts/configs": { "get": { "operationId": "listLuckyGiftConfigs", "responses": { @@ -354,7 +354,7 @@ "x-permissions": ["lucky-gift:view"] } }, - "/admin/activity/lucky-gifts/v2/draws": { + "/admin/ops-center/lucky-gifts/draws": { "get": { "operationId": "listLuckyGiftDraws", "responses": { @@ -366,7 +366,7 @@ "x-permissions": ["lucky-gift:view"] } }, - "/admin/activity/lucky-gifts/v2/draw-summary": { + "/admin/ops-center/lucky-gifts/summary": { "get": { "operationId": "getLuckyGiftDrawSummary", "responses": { @@ -378,6 +378,18 @@ "x-permissions": ["lucky-gift:view"] } }, + "/admin/ops-center/lucky-gifts/pools": { + "get": { + "operationId": "listLuckyGiftPoolBalances", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "lucky-gift:view", + "x-permissions": ["lucky-gift:view"] + } + }, "/admin/activity/wheel/config": { "get": { "operationId": "getWheelConfig", diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx index 3c55ee0..7d0e75b 100644 --- a/ops-center/src/OpsCenterApp.jsx +++ b/ops-center/src/OpsCenterApp.jsx @@ -17,149 +17,185 @@ import { OpsShell } from "./components/OpsShell.jsx"; import { OverviewView } from "./components/OverviewView.jsx"; const views = [ - { icon: InsightsOutlined, key: "overview", label: "数据总览" }, - { icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" }, - { icon: CasinoOutlined, key: "draws", label: "抽奖记录" }, - { icon: AppsOutlined, key: "apps", label: "应用列表" } + { icon: InsightsOutlined, key: "overview", label: "数据总览" }, + { icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" }, + { icon: CasinoOutlined, key: "draws", label: "抽奖记录" }, + { icon: AppsOutlined, key: "apps", label: "应用列表" }, ]; +const viewKeys = new Set(views.map((view) => view.key)); const initialDashboard = { - apps: [], - appSummaries: [], - luckyGifts: [], - poolBalances: [], - summary: {} + apps: [], + appSummaries: [], + luckyGifts: [], + poolBalances: [], + summary: {}, }; -export function OpsCenterApp() { - const [activeView, setActiveView] = useState("overview"); - const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true }); - const [dialog, setDialog] = useState(null); - const [savingConfig, setSavingConfig] = useState(false); - const { showToast } = useToast(); - - const loadDashboard = useCallback(async () => { - setState((current) => ({ ...current, error: "", loading: true })); - try { - const data = await fetchOpsDashboard(); - setState({ data, error: "", loaded: true, loading: false }); - } catch (error) { - // 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。 - setState((current) => ({ - data: current.data || initialDashboard, - error: error.message || "运营接口暂不可用", - loaded: current.loaded, - loading: false - })); - } - }, []); - - useEffect(() => { - loadDashboard(); - }, [loadDashboard]); - - const { data } = state; - - const appOptions = useMemo( - () => data.apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]), - [data.apps] - ); - - // 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。 - const poolIDsByApp = useMemo(() => { - const byApp = new Map(); - const add = (appCode, poolID) => { - if (!appCode || !poolID) { - return; - } - if (!byApp.has(appCode)) { - byApp.set(appCode, new Set()); - } - byApp.get(appCode).add(poolID); - }; - data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id)); - data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId)); - return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()])); - }, [data.luckyGifts, data.poolBalances]); - - const openCreateConfig = useCallback(() => { - // 新建配置以第一个应用的现有配置做参数模板(每个应用至少有一行草稿或已发布配置), - // 应用和奖池在弹窗里由运营自行选择,避免旧版"添加配置"悄悄绑定到第一个应用的问题。 - const template = data.luckyGifts[0]; - if (!template) { - showToast("暂无可添加配置的应用", "warning"); - return; - } - setDialog({ - config: { ...structuredClone(template), is_default: true, pool_id: DEFAULT_POOL_ID, rule_version: 0 }, - mode: "create" - }); - }, [data.luckyGifts, showToast]); - - const openEditConfig = useCallback((config) => { - setDialog({ config: structuredClone(config), mode: "edit" }); - }, []); - - const handleSaveConfig = useCallback( - async (config) => { - setSavingConfig(true); - try { - await saveLuckyGiftConfig(config); - showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success"); - setDialog(null); - await loadDashboard(); - } catch (error) { - // 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。 - showToast(error.message || "保存幸运礼物配置失败", "error"); - } finally { - setSavingConfig(false); - } - }, - [loadDashboard, showToast] - ); - - const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? ""; - - return ( - -
- - {state.loading ? "同步中" : "已就绪"} - - - {/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */} - {state.error && state.loaded ? ( -
- {state.error} -
- ) : null} - - {activeView === "overview" ? : null} - {activeView === "configs" ? ( - - ) : null} - {activeView === "draws" ? : null} - {activeView === "apps" ? : null} - - {dialog ? ( - setDialog(null)} - onSubmit={handleSaveConfig} - /> - ) : null} -
-
- ); +export function initialOpsCenterView(search = globalThis.location?.search || "") { + const requested = new URLSearchParams(search).get("view") || ""; + return viewKeys.has(requested) ? requested : "overview"; +} + +export function OpsCenterApp() { + // 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。 + const [activeView, setActiveView] = useState(() => initialOpsCenterView()); + const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true }); + const [dialog, setDialog] = useState(null); + const [savingConfig, setSavingConfig] = useState(false); + const { showToast } = useToast(); + + const loadDashboard = useCallback(async () => { + setState((current) => ({ ...current, error: "", loading: true })); + try { + const data = await fetchOpsDashboard(); + setState({ data, error: "", loaded: true, loading: false }); + } catch (error) { + // 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。 + setState((current) => ({ + data: current.data || initialDashboard, + error: error.message || "运营接口暂不可用", + loaded: current.loaded, + loading: false, + })); + } + }, []); + + useEffect(() => { + loadDashboard(); + }, [loadDashboard]); + + const { data } = state; + + const appOptions = useMemo( + () => + data.apps.map((item) => [ + item.app_code ?? item.appCode, + `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`, + ]), + [data.apps], + ); + + // 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。 + const poolIDsByApp = useMemo(() => { + const byApp = new Map(); + const add = (appCode, poolID) => { + if (!appCode || !poolID) { + return; + } + if (!byApp.has(appCode)) { + byApp.set(appCode, new Set()); + } + byApp.get(appCode).add(poolID); + }; + data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id)); + data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId)); + return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()])); + }, [data.luckyGifts, data.poolBalances]); + + const openCreateConfig = useCallback(() => { + const firstAppCode = data.apps + .map((item) => + String(item.app_code ?? item.appCode ?? "") + .trim() + .toLowerCase(), + ) + .find(Boolean); + if (!firstAppCode) { + showToast("暂无可添加配置的应用", "warning"); + return; + } + // 金额门槛和六维上限按 App 金币口径配置,绝不能复制第一条已发布规则给另一个 App。 + // 仅传规则身份,让 configToForm 生成 disabled dynamic_v3 安全草稿;金额字段保持 0,运营补齐后才能启用。 + setDialog({ + config: { + app_code: firstAppCode, + enabled: false, + is_default: true, + pool_id: DEFAULT_POOL_ID, + rule_version: 0, + strategy_version: "dynamic_v3", + }, + mode: "create", + }); + }, [data.apps, showToast]); + + const openEditConfig = useCallback((config) => { + setDialog({ config: structuredClone(config), mode: "edit" }); + }, []); + + const handleSaveConfig = useCallback( + async (config) => { + setSavingConfig(true); + try { + await saveLuckyGiftConfig(config); + showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success"); + setDialog(null); + await loadDashboard(); + } catch (error) { + // 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。 + showToast(error.message || "保存幸运礼物配置失败", "error"); + } finally { + setSavingConfig(false); + } + }, + [loadDashboard, showToast], + ); + + const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? ""; + + return ( + +
+ + + {state.loading ? "同步中" : "已就绪"} + + + + {/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */} + {state.error && state.loaded ? ( +
+ {state.error} +
+ ) : null} + + {activeView === "overview" ? : null} + {activeView === "configs" ? ( + + ) : null} + {activeView === "draws" ? ( + + ) : null} + {activeView === "apps" ? : null} + + {dialog ? ( + setDialog(null)} + onSubmit={handleSaveConfig} + /> + ) : null} +
+
+ ); } diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index 6a6b61b..c5d4e21 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -2,175 +2,339 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import { beforeEach, expect, test, vi } from "vitest"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; -import { OpsCenterApp } from "./OpsCenterApp.jsx"; +import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx"; vi.mock("./api.js", () => ({ - DEFAULT_POOL_ID: "default", - fetchLuckyGiftDraws: vi.fn(), - fetchOpsDashboard: vi.fn(), - saveLuckyGiftConfig: vi.fn() + DEFAULT_POOL_ID: "default", + fetchLuckyGiftDraws: vi.fn(), + fetchOpsDashboard: vi.fn(), + saveLuckyGiftConfig: vi.fn(), })); beforeEach(() => { - vi.clearAllMocks(); - saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); - fetchLuckyGiftDraws.mockResolvedValue({ - items: [ - { - app_code: "lalu", - created_at_ms: 1767000000000, - draw_id: "draw-1", - effective_reward_coins: 100, - external_user_id: "ext-1", - gift_id: "gift-9", - multiplier_ppm: 2_000_000, - pool_id: "super_lucky", - reward_status: "granted", - rule_version: 2 - } - ], - page: 1, - pageSize: 20, - total: 1 - }); - fetchOpsDashboard.mockResolvedValue({ - apps: [ - { app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 }, - { app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" } - ], - appSummaries: [ - { - actual_rtp_ppm: 900000, - app_code: "lalu", - failed_draws: 0, - granted_draws: 3, - pending_draws: 0, - total_draws: 3, - total_reward_coins: 900, - total_spent_coins: 1000, - unique_rooms: 2, - unique_users: 2 - } - ], - luckyGifts: [ - { app_code: "lalu", enabled: true, is_default: false, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, - { app_code: "lalu", enabled: false, is_default: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }, - { app_code: "yumi", enabled: false, is_default: true, pool_id: "default", rule_version: 0, target_rtp_percent: 95 } - ], - poolBalances: [ - { app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000, updated_at_ms: 1767000000000 }, - { app_code: "yumi", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 } - ], - summary: { - activeApps: 2, - configuredPools: 2, - enabledPools: 1, - failedDraws: 0, - grantedDraws: 3, - pendingDraws: 0, - poolAvailableTotal: 38311517, - poolBalanceTotal: 38941517, - poolReserveTotal: 630000, - totalDraws: 3, - totalRewardCoins: 900, - totalSpentCoins: 1000 - } - }); + vi.clearAllMocks(); + window.history.replaceState({}, "", "/"); + saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); + fetchLuckyGiftDraws.mockResolvedValue({ + items: [ + { + app_code: "lalu", + created_at_ms: 1767000000000, + draw_id: "draw-1", + effective_reward_coins: 100, + external_user_id: "ext-1", + gift_id: "gift-9", + multiplier_ppm: 2_000_000, + pool_id: "super_lucky", + reward_status: "granted", + rule_version: 2, + }, + ], + page: 1, + pageSize: 20, + total: 1, + }); + fetchOpsDashboard.mockResolvedValue({ + apps: [ + { app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 }, + { app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" }, + ], + appSummaries: [ + { + actual_rtp_ppm: 900000, + app_code: "lalu", + failed_draws: 0, + granted_draws: 3, + pending_draws: 0, + total_draws: 3, + total_reward_coins: 900, + total_spent_coins: 1000, + unique_rooms: 2, + unique_users: 2, + }, + ], + luckyGifts: [ + { + ...mockDynamicConfig(), + app_code: "lalu", + enabled: true, + is_default: false, + pool_id: "lucky", + rule_version: 3, + }, + { + ...mockFixedConfig(92), + app_code: "lalu", + enabled: false, + is_default: false, + pool_id: "super_lucky", + rule_version: 2, + }, + { + ...mockDynamicConfig(), + app_code: "yumi", + enabled: false, + is_default: true, + pool_id: "default", + rule_version: 0, + }, + ], + poolBalances: [ + { + app_code: "lalu", + available_balance: 37950017, + balance: 38559017, + materialized: true, + pool_id: "lucky", + reserve_floor: 609000, + updated_at_ms: 1767000000000, + }, + { + app_code: "yumi", + available_balance: 361500, + balance: 382500, + materialized: false, + pool_id: "default", + reserve_floor: 21000, + }, + ], + summary: { + activeApps: 2, + configuredPools: 2, + enabledPools: 1, + failedDraws: 0, + grantedDraws: 3, + pendingDraws: 0, + poolAvailableTotal: 38311517, + poolBalanceTotal: 38941517, + poolReserveTotal: 630000, + totalDraws: 3, + totalRewardCoins: 900, + totalSpentCoins: 1000, + }, + }); }); function renderApp() { - return render( - - - - ); + return render( + + + , + ); } test("renders overview with aggregated kpis and pool balances", async () => { - renderApp(); + renderApp(); - expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy(); - expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy(); - ["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => { - expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy(); - }); + expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy(); + expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy(); + ["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => { + expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy(); + }); - expect(await screen.findByText("运营应用")).toBeTruthy(); - // 抽奖次数 KPI 必须是跨应用聚合值,而不是第一个应用的汇总(KPI 卡和汇总表列头各出现一次)。 - expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0); - expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0); - expect(screen.getByText("奖池实时水位")).toBeTruthy(); - expect(screen.getByText("已入账")).toBeTruthy(); - expect(screen.getByText("默认水位")).toBeTruthy(); - expect(fetchOpsDashboard).toHaveBeenCalledTimes(1); + expect(await screen.findByText("运营应用")).toBeTruthy(); + // 抽奖次数 KPI 必须是跨应用聚合值,而不是第一个应用的汇总(KPI 卡和汇总表列头各出现一次)。 + expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0); + expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0); + expect(screen.getByText("奖池实时水位")).toBeTruthy(); + expect(screen.getByText("已入账")).toBeTruthy(); + expect(screen.getByText("默认水位")).toBeTruthy(); + expect(fetchOpsDashboard).toHaveBeenCalledTimes(1); +}); + +test("opens the requested config view from the legacy admin document redirect", async () => { + expect(initialOpsCenterView("?view=configs")).toBe("configs"); + expect(initialOpsCenterView("?view=unknown")).toBe("overview"); + window.history.replaceState({}, "", "/ops-center/?view=configs"); + + renderApp(); + + expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy(); + expect(screen.getByText("lucky")).toBeTruthy(); }); test("lists every pool config including published non-default pools", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - // 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。 - expect(await screen.findByText("lucky")).toBeTruthy(); - expect(screen.getByText("super_lucky")).toBeTruthy(); - expect(screen.getByText("v3")).toBeTruthy(); - expect(screen.getByText("v2")).toBeTruthy(); - expect(screen.getByText("已启用")).toBeTruthy(); - expect(screen.getByText("已停用")).toBeTruthy(); - expect(screen.getByText("默认草稿")).toBeTruthy(); + // 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。 + expect(await screen.findByText("lucky")).toBeTruthy(); + expect(screen.getByText("super_lucky")).toBeTruthy(); + expect(screen.getByText("v3")).toBeTruthy(); + expect(screen.getByText("v2")).toBeTruthy(); + expect(screen.getByText("已启用")).toBeTruthy(); + expect(screen.getByText("已停用")).toBeTruthy(); + expect(screen.getByText("默认草稿")).toBeTruthy(); +}); + +test("creates a disabled dynamic draft without copying another app monetary limits", async () => { + renderApp(); + await screen.findByText("运营应用"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + fireEvent.click(await screen.findByRole("button", { name: "添加配置" })); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy(); + expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3"); + expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000"); + // 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。 + expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0"); + expect(within(dialog).getByText("发布后保持停用")).toBeTruthy(); }); test("opens config dialog from a published pool row and saves a new version", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - await screen.findByText("lucky"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + await screen.findByText("lucky"); - fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); - const dialog = await screen.findByRole("dialog"); - expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy(); - expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true); + fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy(); + expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3"); + expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000"); + expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000"); + expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true); - fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } }); - fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); + fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); - await waitFor(() => { - expect(saveLuckyGiftConfig).toHaveBeenCalledWith( - expect.objectContaining({ - app_code: "lalu", - pool_id: "lucky", - stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]), - target_rtp_percent: 88 - }) - ); - }); + await waitFor(() => { + expect(saveLuckyGiftConfig).toHaveBeenCalledWith( + expect.objectContaining({ + app_code: "lalu", + anchor_rate_percent: 1, + jackpot_multipliers: [200, 500, 1000], + pool_id: "lucky", + profit_rate_percent: 1, + stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]), + strategy_version: "dynamic_v3", + target_rtp_percent: 88, + }), + ); + }); }); +test("blocks an invalid enabled dynamic rule before calling the save API", async () => { + renderApp(); + await screen.findByText("运营应用"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + await screen.findByText("lucky"); + fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); + + const dialog = await screen.findByRole("dialog"); + fireEvent.change(within(dialog).getByLabelText(/初始奖池金币/), { target: { value: "0" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); + + expect(await within(dialog).findByRole("alert")).toHaveTextContent("初始奖池必须大于 0"); + expect(saveLuckyGiftConfig).not.toHaveBeenCalled(); + + // 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。 + fireEvent.change(within(dialog).getAllByLabelText("倍率")[0], { target: { value: "0.1" } }); + expect(within(dialog).queryByRole("alert")).toBeNull(); +}); + +function mockDynamicConfig() { + return { + anchor_daily_payout_cap: 6_000_000, + anchor_rate_percent: 1, + control_band_percent: 3, + device_daily_payout_cap: 4_000_000, + effective_from_ms: 0, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 1_000_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 5_000_000, + settlement_window_wager: 1_000_000, + stages: mockStages(98), + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 3_000_000, + user_hourly_payout_cap: 2_000_000, + }; +} + +function mockFixedConfig(targetRTP) { + return { + control_band_percent: 1, + gift_price_reference: 100, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_rate_percent: targetRTP + 1, + settlement_window_wager: 1_000_000, + stages: mockStages(targetRTP), + strategy_version: "fixed_v2", + target_rtp_percent: targetRTP, + }; +} + +function mockStages(targetRTP) { + return ["novice", "normal", "advanced"].map((stage, index) => ({ + min_recharge_30d_coins: index === 0 ? 0 : index, + min_recharge_7d_coins: index === 2 ? 1 : 0, + stage, + tiers: [ + { + enabled: true, + high_water_only: false, + multiplier: 0, + probability_percent: 100 - targetRTP, + tier_id: `${stage}_none`, + }, + { + enabled: true, + high_water_only: false, + multiplier: 1, + probability_percent: targetRTP, + tier_id: `${stage}_1x`, + }, + ], + })); +} + test("loads draw records for the first app with server pagination", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ })); + fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ })); - await waitFor(() => { - expect(fetchLuckyGiftDraws).toHaveBeenCalledWith( - expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }) - ); - }); - expect(await screen.findByText("ext-1")).toBeTruthy(); - expect(screen.getByText("已发放")).toBeTruthy(); - expect(screen.getByText("2x")).toBeTruthy(); + await waitFor(() => { + expect(fetchLuckyGiftDraws).toHaveBeenCalledWith( + expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }), + ); + }); + expect(await screen.findByText("ext-1")).toBeTruthy(); + expect(screen.getByText("已发放")).toBeTruthy(); + expect(screen.getByText("2x")).toBeTruthy(); }); test("shows app registry and fixed integrations in apps view", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /应用列表/ })); + fireEvent.click(screen.getByRole("button", { name: /应用列表/ })); - expect(await screen.findByText("Lalu")).toBeTruthy(); - expect(screen.getByText("主数据")).toBeTruthy(); - expect(screen.getByText("外部接入")).toBeTruthy(); + expect(await screen.findByText("Lalu")).toBeTruthy(); + expect(screen.getByText("主数据")).toBeTruthy(); + expect(screen.getByText("外部接入")).toBeTruthy(); }); diff --git a/ops-center/src/api.js b/ops-center/src/api.js index 1983fb4..9095cce 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -4,261 +4,344 @@ export const OPS_API_PREFIX = "/api/v1/admin/ops-center"; export const DEFAULT_POOL_ID = "default"; export async function fetchOpsDashboard(query = {}) { - // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 - // lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。 - const appsPayload = await opsRequest("/apps", { query }); - const apps = normalizeItems(appsPayload); - const appCodes = uniqueAppCodes(apps); + // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 + // lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。 + const appsPayload = await opsRequest("/apps", { query }); + const apps = normalizeItems(appsPayload); + const appCodes = uniqueAppCodes(apps); - const perApp = await Promise.all( - appCodes.map(async (appCode) => { - const [luckyGifts, poolBalances, drawSummary] = await Promise.all([ - fetchAppLuckyGiftConfigs(appCode), - opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), - opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }) - ]); - return { appCode, drawSummary, luckyGifts, poolBalances }; - }) - ); + const perApp = await Promise.all( + appCodes.map(async (appCode) => { + const [luckyGifts, poolBalances, drawSummary] = await Promise.all([ + fetchAppLuckyGiftConfigs(appCode), + opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), + opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }), + ]); + return { appCode, drawSummary, luckyGifts, poolBalances }; + }), + ); - return normalizeDashboard({ apps, perApp }); + return normalizeDashboard({ apps, perApp }); } async function fetchAppLuckyGiftConfigs(appCode) { - // 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。 - // 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。 - const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } })) - .map((config) => normalizeLuckyGiftConfig(config, appCode)) - .filter((config) => config.app_code); - if (published.length) { - return published; - } - // 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版; - // 草稿的默认参数(RTP、回收率、结算窗口)以服务端 DefaultRuleConfig 为准,不在前端伪造。 - const draft = normalizeLuckyGiftConfig(await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), appCode); - return draft.app_code ? [draft] : []; + // 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。 + // 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。 + const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } })) + .map((config) => normalizeLuckyGiftConfig(config, appCode)) + .filter((config) => config.app_code); + if (published.length) { + return published; + } + // 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版; + // 草稿的默认参数(RTP、回收率、结算窗口)以服务端 DefaultRuleConfig 为准,不在前端伪造。 + const draft = normalizeLuckyGiftConfig( + await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), + appCode, + ); + return draft.app_code ? [draft] : []; } -export async function fetchLuckyGiftDraws({ appCode, page = 1, pageSize = 20, poolId = "", status = "", userId = "" } = {}) { - const query = { - app_code: appCode, - page, - page_size: pageSize, - pool_id: poolId, - status - }; - // 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配, - // 其余走 external_user_id,避免运营要先分辨用户来源再选字段。 - const trimmedUserID = String(userId || "").trim(); - if (/^\d+$/.test(trimmedUserID)) { - query.user_id = trimmedUserID; - } else if (trimmedUserID) { - query.external_user_id = trimmedUserID; - } +export async function fetchLuckyGiftDraws({ + appCode, + page = 1, + pageSize = 20, + poolId = "", + status = "", + userId = "", +} = {}) { + const query = { + app_code: appCode, + page, + page_size: pageSize, + pool_id: poolId, + status, + }; + // 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配, + // 其余走 external_user_id,避免运营要先分辨用户来源再选字段。 + const trimmedUserID = String(userId || "").trim(); + if (/^\d+$/.test(trimmedUserID)) { + query.user_id = trimmedUserID; + } else if (trimmedUserID) { + query.external_user_id = trimmedUserID; + } - const payload = await opsRequest("/lucky-gifts/draws", { query }); - return { - items: normalizeItems(payload), - page: numberValue(payload.page) || page, - pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize, - total: numberValue(payload.total) - }; + const payload = await opsRequest("/lucky-gifts/draws", { query }); + return { + items: normalizeItems(payload), + page: numberValue(payload.page) || page, + pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize, + total: numberValue(payload.total), + }; } export async function saveLuckyGiftConfig(config = {}) { - const payload = luckyGiftConfigPayload(config); - return opsRequest("/lucky-gifts/config", { - body: payload, - method: "PUT", - query: { - app_code: payload.app_code, - pool_id: payload.pool_id - } - }); + const payload = luckyGiftConfigPayload(config); + return opsRequest("/lucky-gifts/config", { + body: payload, + method: "PUT", + query: { + app_code: payload.app_code, + pool_id: payload.pool_id, + }, + }); } export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) { - const response = await fetch(buildOpsUrl(path, query), { - body: body === undefined ? undefined : JSON.stringify(body), - credentials: "include", - headers: buildHeaders(body), - method - }); - const payload = await readPayload(response); + const response = await fetch(buildOpsUrl(path, query), { + body: body === undefined ? undefined : JSON.stringify(body), + credentials: "include", + headers: buildHeaders(body), + method, + }); + const payload = await readPayload(response); - if (!response.ok || payload.code !== 0) { - throw new Error(payload.message || response.statusText || "运营接口请求失败"); - } + if (!response.ok || payload.code !== 0) { + throw new Error(payload.message || response.statusText || "运营接口请求失败"); + } - return payload.data ?? {}; + return payload.data ?? {}; } export function buildOpsUrl(path, query = {}) { - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin); + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin); - Object.entries(query || {}).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - url.searchParams.set(key, String(value)); - } - }); + Object.entries(query || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + }); - return url.toString(); + return url.toString(); } export function normalizeDashboard({ apps = [], perApp = [] } = {}) { - const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); - const poolBalances = perApp - .flatMap((entry) => entry.poolBalances || []) - .filter((item) => item?.app_code || item?.appCode); - const appSummaries = perApp - .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) - .filter((summary) => summary.app_code); + const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); + const poolBalances = perApp + .flatMap((entry) => entry.poolBalances || []) + .filter((item) => item?.app_code || item?.appCode); + const appSummaries = perApp + .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) + .filter((summary) => summary.app_code); - const publishedConfigs = luckyGifts.filter((config) => !config.is_default); + const publishedConfigs = luckyGifts.filter((config) => !config.is_default); - // KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。 - const summary = { - activeApps: apps.length, - configuredPools: publishedConfigs.length, - enabledPools: publishedConfigs.filter((config) => config.enabled).length, - failedDraws: sumBy(appSummaries, "failed_draws"), - grantedDraws: sumBy(appSummaries, "granted_draws"), - pendingDraws: sumBy(appSummaries, "pending_draws"), - poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"), - poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"), - poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"), - totalDraws: sumBy(appSummaries, "total_draws"), - totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), - totalSpentCoins: sumBy(appSummaries, "total_spent_coins") - }; + // KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。 + const summary = { + activeApps: apps.length, + configuredPools: publishedConfigs.length, + enabledPools: publishedConfigs.filter((config) => config.enabled).length, + failedDraws: sumBy(appSummaries, "failed_draws"), + grantedDraws: sumBy(appSummaries, "granted_draws"), + pendingDraws: sumBy(appSummaries, "pending_draws"), + poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"), + poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"), + poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"), + totalDraws: sumBy(appSummaries, "total_draws"), + totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), + totalSpentCoins: sumBy(appSummaries, "total_spent_coins"), + }; - return { apps, appSummaries, luckyGifts, poolBalances, summary }; + return { apps, appSummaries, luckyGifts, poolBalances, summary }; } function normalizeDrawSummary(summary = {}, appCode = "") { - return { - actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), - app_code: String(appCode || summary.app_code || "").trim().toLowerCase(), - failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws), - granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws), - pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws), - total_draws: numberValue(summary.total_draws ?? summary.totalDraws), - total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins), - total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins), - unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms), - unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers) - }; + return { + actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), + app_code: String(appCode || summary.app_code || "") + .trim() + .toLowerCase(), + failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws), + granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws), + pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws), + total_draws: numberValue(summary.total_draws ?? summary.totalDraws), + total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins), + total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins), + unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms), + unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers), + }; } function uniqueAppCodes(apps) { - const seen = new Set(); - const codes = []; - apps.forEach((item) => { - const code = String(item?.app_code || item?.appCode || "").trim().toLowerCase(); - if (!code || seen.has(code)) { - return; - } - seen.add(code); - codes.push(code); - }); - return codes; + const seen = new Set(); + const codes = []; + apps.forEach((item) => { + const code = String(item?.app_code || item?.appCode || "") + .trim() + .toLowerCase(); + if (!code || seen.has(code)) { + return; + } + seen.add(code); + codes.push(code); + }); + return codes; } function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") { - const appCode = String(config.app_code || config.appCode || fallbackAppCode).trim().toLowerCase(); - const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); - return { - ...config, - app_code: appCode, - // rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。 - is_default: ruleVersion <= 0, - pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, - rule_version: ruleVersion - }; + const appCode = String(config.app_code || config.appCode || fallbackAppCode) + .trim() + .toLowerCase(); + const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); + return { + ...config, + app_code: appCode, + // rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。 + is_default: ruleVersion <= 0, + pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, + rule_version: ruleVersion, + // 升级前规则没有 strategy_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。 + strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2") + .trim() + .toLowerCase(), + }; } function luckyGiftConfigPayload(config = {}) { - const appCode = String(config.app_code || config.appCode || "").trim().toLowerCase(); - const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; - return { - app_code: appCode, - control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent), - effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMS), - enabled: Boolean(config.enabled), - gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference), - normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws), - novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws), - pool_id: poolID, - pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent), - settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager), - stages: normalizeStages(config.stages), - target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent) - }; + const appCode = String(config.app_code || config.appCode || "") + .trim() + .toLowerCase(); + const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; + const strategyVersion = String(config.strategy_version || config.strategyVersion || "fixed_v2") + .trim() + .toLowerCase(); + + // 规则版本是不可变快照,PUT 必须完整回传 owner GET 返回的策略字段。只提交旧版 RTP/奖档字段会让 + // 空 strategy_version 被服务端按 fixed_v2 发布,或让 dynamic_v3 因资金、水位、大奖字段归零而拒绝发布。 + return { + anchor_daily_payout_cap: numberValue(config.anchor_daily_payout_cap ?? config.anchorDailyPayoutCap), + anchor_rate_percent: numberValue(config.anchor_rate_percent ?? config.anchorRatePercent), + app_code: appCode, + control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent), + device_daily_payout_cap: numberValue(config.device_daily_payout_cap ?? config.deviceDailyPayoutCap), + effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMs ?? config.effectiveFromMS), + enabled: Boolean(config.enabled), + gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference), + high_water_nonzero_factor_percent: numberValue( + config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent, + ), + high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins), + initial_pool_coins: numberValue(config.initial_pool_coins ?? config.initialPoolCoins), + jackpot_global_rtp_max_percent: numberValue( + config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent, + ), + jackpot_multipliers: numberArray(config.jackpot_multipliers ?? config.jackpotMultipliers), + jackpot_spend_threshold_coins: numberValue( + config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins, + ), + jackpot_user_72h_rtp_max_percent: numberValue( + config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent, + ), + jackpot_user_day_rtp_max_percent: numberValue( + config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent, + ), + loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee), + low_water_nonzero_factor_percent: numberValue( + config.low_water_nonzero_factor_percent ?? config.lowWaterNonzeroFactorPercent, + ), + low_watermark_coins: numberValue(config.low_watermark_coins ?? config.lowWatermarkCoins), + max_jackpot_hits_per_user_day: numberValue( + config.max_jackpot_hits_per_user_day ?? config.maxJackpotHitsPerUserDay, + ), + max_single_payout: numberValue(config.max_single_payout ?? config.maxSinglePayout), + normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws), + novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws), + pool_id: poolID, + pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent), + profit_rate_percent: numberValue(config.profit_rate_percent ?? config.profitRatePercent), + recharge_boost_factor_percent: numberValue( + config.recharge_boost_factor_percent ?? config.rechargeBoostFactorPercent, + ), + recharge_boost_window_ms: numberValue( + config.recharge_boost_window_ms ?? config.rechargeBoostWindowMs ?? config.rechargeBoostWindowMS, + ), + room_hourly_payout_cap: numberValue(config.room_hourly_payout_cap ?? config.roomHourlyPayoutCap), + settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager), + stages: normalizeStages(config.stages), + strategy_version: strategyVersion, + user_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap), + user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap), + target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent), + }; } function buildHeaders(body) { - const headers = {}; - const token = getAccessToken(); + const headers = {}; + const token = getAccessToken(); - if (body !== undefined) { - headers["Content-Type"] = "application/json"; - } - if (token) { - headers.Authorization = `Bearer ${token}`; - } + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + } + if (token) { + headers.Authorization = `Bearer ${token}`; + } - return headers; + return headers; } async function readPayload(response) { - const text = await response.text(); - if (!text) { - return { code: response.ok ? 0 : response.status, data: {} }; - } - try { - return JSON.parse(text); - } catch { - return { code: response.ok ? 0 : response.status, data: text, message: text }; - } + const text = await response.text(); + if (!text) { + return { code: response.ok ? 0 : response.status, data: {} }; + } + try { + return JSON.parse(text); + } catch { + return { code: response.ok ? 0 : response.status, data: text, message: text }; + } } function normalizeItems(value) { - if (Array.isArray(value)) { - return value; - } - if (Array.isArray(value?.items)) { - return value.items; - } - if (Array.isArray(value?.list)) { - return value.list; - } - return []; + if (Array.isArray(value)) { + return value; + } + if (Array.isArray(value?.items)) { + return value.items; + } + if (Array.isArray(value?.list)) { + return value.list; + } + return []; } function sumBy(items, key, fallbackKey) { - return items.reduce((total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), 0); + return items.reduce( + (total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), + 0, + ); } function numberValue(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function numberArray(values) { + if (!Array.isArray(values)) { + return []; + } + // 不在传输层静默删除 0/负倍率;表单负责拦截,若有遗漏也应让 owner 返回明确校验错误, + // 否则数组位置变化会掩盖运营实际提交的错误值。 + return values.map(numberValue); } function normalizeStages(stages) { - if (!Array.isArray(stages)) { - return []; - } - return stages.map((stage) => ({ - stage: stage.stage || stage.Stage || "", - tiers: Array.isArray(stage.tiers) - ? stage.tiers.map((tier) => ({ - enabled: tier.enabled !== false, - high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly), - multiplier: numberValue(tier.multiplier), - probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent), - tier_id: tier.tier_id || tier.tierId || "" - })) - : [] - })); + if (!Array.isArray(stages)) { + return []; + } + return stages.map((stage) => ({ + min_recharge_30d_coins: numberValue(stage.min_recharge_30d_coins ?? stage.minRecharge30DCoins), + min_recharge_7d_coins: numberValue(stage.min_recharge_7d_coins ?? stage.minRecharge7DCoins), + stage: stage.stage || stage.Stage || "", + tiers: Array.isArray(stage.tiers) + ? stage.tiers.map((tier) => ({ + enabled: tier.enabled !== false, + high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly), + multiplier: numberValue(tier.multiplier), + probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent), + tier_id: tier.tier_id || tier.tierId || "", + })) + : [], + })); } diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js index aa52235..37b5a12 100644 --- a/ops-center/src/api.test.js +++ b/ops-center/src/api.test.js @@ -2,145 +2,251 @@ import { afterEach, expect, test, vi } from "vitest"; import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js"; afterEach(() => { - vi.restoreAllMocks(); + vi.restoreAllMocks(); }); function jsonResponse(data) { - return new Response(JSON.stringify({ code: 0, data }), { - headers: { "Content-Type": "application/json" }, - status: 200 - }); + return new Response(JSON.stringify({ code: 0, data }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); } test("builds ops api urls under the dedicated prefix", () => { - const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" }); + const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" }); - expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center"); - expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active"); + expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center"); + expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active"); }); test("loads every pool config per app through the plural configs endpoint", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { - const text = String(url); - calls.push(text); - if (text.endsWith("/apps")) { - return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] }); - } - if (text.includes("/lucky-gifts/configs?")) { - // lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。 - return jsonResponse([ - { app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, - { app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 } - ]); - } - if (text.includes("/lucky-gifts/pools?")) { - return jsonResponse({ - items: [ - { app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000 }, - { app_code: "lalu", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 } - ] - }); - } - if (text.includes("/lucky-gifts/summary?")) { - return jsonResponse({ granted_draws: 9, pending_draws: 1, total_draws: 10, total_reward_coins: 900, total_spent_coins: 1000 }); - } - return jsonResponse({ items: [] }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url) => { + const text = String(url); + calls.push(text); + if (text.endsWith("/apps")) { + return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] }); + } + if (text.includes("/lucky-gifts/configs?")) { + // lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。 + return jsonResponse([ + { app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, + { app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }, + ]); + } + if (text.includes("/lucky-gifts/pools?")) { + return jsonResponse({ + items: [ + { + app_code: "lalu", + available_balance: 37950017, + balance: 38559017, + materialized: true, + pool_id: "lucky", + reserve_floor: 609000, + }, + { + app_code: "lalu", + available_balance: 361500, + balance: 382500, + materialized: false, + pool_id: "default", + reserve_floor: 21000, + }, + ], + }); + } + if (text.includes("/lucky-gifts/summary?")) { + return jsonResponse({ + granted_draws: 9, + pending_draws: 1, + total_draws: 10, + total_reward_coins: 900, + total_spent_coins: 1000, + }); + } + return jsonResponse({ items: [] }); + }); - const data = await fetchOpsDashboard(); + const data = await fetchOpsDashboard(); - expect(calls).toEqual([ - "http://localhost:3000/api/v1/admin/ops-center/apps", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu" - ]); - expect(data.luckyGifts).toEqual([ - expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }), - expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }) - ]); - expect(data.summary).toMatchObject({ - activeApps: 1, - configuredPools: 2, - enabledPools: 1, - poolBalanceTotal: 38941517, - totalDraws: 10 - }); + expect(calls).toEqual([ + "http://localhost:3000/api/v1/admin/ops-center/apps", + "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu", + "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu", + "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu", + ]); + expect(data.luckyGifts).toEqual([ + expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }), + expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }), + ]); + expect(data.summary).toMatchObject({ + activeApps: 1, + configuredPools: 2, + enabledPools: 1, + poolBalanceTotal: 38941517, + totalDraws: 10, + }); }); test("falls back to the server default draft when an app has no published config", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { - const text = String(url); - calls.push(text); - if (text.endsWith("/apps")) { - return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] }); - } - if (text.includes("/lucky-gifts/configs?")) { - return jsonResponse([]); - } - if (text.includes("/lucky-gifts/config?")) { - return jsonResponse({ app_code: "yumi", enabled: false, pool_id: "default", rule_version: 0, target_rtp_percent: 95 }); - } - return jsonResponse({ items: [] }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url) => { + const text = String(url); + calls.push(text); + if (text.endsWith("/apps")) { + return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] }); + } + if (text.includes("/lucky-gifts/configs?")) { + return jsonResponse([]); + } + if (text.includes("/lucky-gifts/config?")) { + return jsonResponse({ + app_code: "yumi", + enabled: false, + pool_id: "default", + rule_version: 0, + target_rtp_percent: 95, + }); + } + return jsonResponse({ items: [] }); + }); - const data = await fetchOpsDashboard(); + const data = await fetchOpsDashboard(); - expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi"); - expect(data.luckyGifts).toEqual([ - expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }) - ]); + expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi"); + expect(data.luckyGifts).toEqual([ + expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }), + ]); }); test("fetches draws with numeric ids as user_id and other ids as external_user_id", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { - calls.push(String(url)); - return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url) => { + calls.push(String(url)); + return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 }); + }); - const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" }); - await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" }); + const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" }); + await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" }); - const numericParams = new URL(calls[0]).searchParams; - expect(numericParams.get("app_code")).toBe("lalu"); - expect(numericParams.get("pool_id")).toBe("lucky"); - expect(numericParams.get("user_id")).toBe("10001"); - expect(numericParams.get("external_user_id")).toBeNull(); - const externalParams = new URL(calls[1]).searchParams; - expect(externalParams.get("external_user_id")).toBe("ext-abc"); - expect(externalParams.get("user_id")).toBeNull(); - expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 }); + const numericParams = new URL(calls[0]).searchParams; + expect(numericParams.get("app_code")).toBe("lalu"); + expect(numericParams.get("pool_id")).toBe("lucky"); + expect(numericParams.get("user_id")).toBe("10001"); + expect(numericParams.get("external_user_id")).toBeNull(); + const externalParams = new URL(calls[1]).searchParams; + expect(externalParams.get("external_user_id")).toBe("ext-abc"); + expect(externalParams.get("user_id")).toBeNull(); + expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 }); }); test("saves lucky gift config through admin ops route", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url, init) => { - calls.push({ body: init.body, method: init.method, url: String(url) }); - return jsonResponse({ app_code: "aslan", pool_id: "default" }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url, init) => { + calls.push({ body: init.body, method: init.method, url: String(url) }); + return jsonResponse({ app_code: "aslan", pool_id: "default" }); + }); - await saveLuckyGiftConfig({ - app_code: "aslan", - pool_id: "default", - enabled: true, - target_rtp_percent: 95, - pool_rate_percent: 96, - settlement_window_wager: 1_000_000, - control_band_percent: 1, - gift_price_reference: 100, - stages: [{ stage: "novice", tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }] }] - }); + await saveLuckyGiftConfig({ + anchor_daily_payout_cap: 12_312_000, + anchor_rate_percent: 1, + app_code: "aslan", + control_band_percent: 3, + device_daily_payout_cap: 1_026_000, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 50_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "default", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 684_000, + settlement_window_wager: 1_000_000, + stages: [ + { + min_recharge_30d_coins: 0, + min_recharge_7d_coins: 0, + stage: "novice", + tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }], + }, + ], + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 615_600, + user_hourly_payout_cap: 34_200, + }); - expect(calls[0].method).toBe("PUT"); - expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default"); - expect(JSON.parse(calls[0].body)).toMatchObject({ - app_code: "aslan", - enabled: true, - pool_id: "default", - stages: [{ stage: "novice", tiers: [{ probability_percent: 100 }] }], - target_rtp_percent: 95 - }); + expect(calls[0].method).toBe("PUT"); + expect(calls[0].url).toBe( + "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default", + ); + // 精确比较完整 owner payload;子集断言会让新增/遗漏的策略字段在回归中静默通过。 + expect(JSON.parse(calls[0].body)).toEqual({ + anchor_daily_payout_cap: 12_312_000, + anchor_rate_percent: 1, + app_code: "aslan", + control_band_percent: 3, + device_daily_payout_cap: 1_026_000, + effective_from_ms: 0, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 50_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "default", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 684_000, + settlement_window_wager: 1_000_000, + stages: [ + { + min_recharge_30d_coins: 0, + min_recharge_7d_coins: 0, + stage: "novice", + tiers: [ + { + enabled: true, + high_water_only: false, + multiplier: 1, + probability_percent: 100, + tier_id: "", + }, + ], + }, + ], + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 615_600, + user_hourly_payout_cap: 34_200, + }); }); diff --git a/ops-center/src/components/ConfigsView.jsx b/ops-center/src/components/ConfigsView.jsx index 62f8a2e..dff4af3 100644 --- a/ops-center/src/components/ConfigsView.jsx +++ b/ops-center/src/components/ConfigsView.jsx @@ -8,83 +8,121 @@ import { formatNumber, formatPercent } from "../format.js"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) { - const [appFilter, setAppFilter] = useState(""); + const [appFilter, setAppFilter] = useState(""); - const appOptions = useMemo( - () => apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]), - [apps] - ); - // 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。 - const items = useMemo( - () => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts), - [appFilter, luckyGifts] - ); + const appOptions = useMemo( + () => + apps.map((item) => [ + item.app_code ?? item.appCode, + `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`, + ]), + [apps], + ); + // 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。 + const items = useMemo( + () => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts), + [appFilter, luckyGifts], + ); - const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]); + const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]); - return ( -
- } variant="primary" onClick={onCreate}> - 添加配置 - - } - filters={} - /> - `${item.app_code}:${item.pool_id}:${item.rule_version}`} - title="幸运礼物配置" - total={items.length} - /> -
- ); + return ( +
+ } + variant="primary" + onClick={onCreate} + > + 添加配置 + + } + filters={ + + } + /> + `${item.app_code}:${item.pool_id}:${item.rule_version}`} + title="幸运礼物配置" + total={items.length} + /> +
+ ); } function buildColumns({ onEdit, saving }) { - return [ - { key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" }, - { key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" }, - { - key: "rule_version", - label: "版本", - render: (item) => (item.is_default ? "-" : `v${item.rule_version}`), - width: "minmax(72px, 0.5fr)" - }, - { - key: "enabled", - label: "状态", - render: (item) => { - // 默认草稿是服务端为未配置奖池生成的占位,不参与线上抽奖,必须与"停用"区分开。 - if (item.is_default) { - return ; - } - return item.enabled ? : ; - }, - width: "minmax(128px, 0.8fr)" - }, - { key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) }, - { key: "pool_rate_percent", label: "奖池回收", render: (item) => formatPercent(item.pool_rate_percent) }, - { key: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) }, - { key: "settlement_window_wager", label: "结算窗口流水", render: (item) => formatNumber(item.settlement_window_wager) }, - { - key: "created_at_ms", - label: "发布时间", - render: (item) => (item.is_default ? "-" : ), - width: "minmax(176px, 1.1fr)" - }, - { - key: "actions", - label: "操作", - render: (item) => ( - - ) - } - ]; + return [ + { key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" }, + { key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" }, + { + key: "rule_version", + label: "版本", + render: (item) => (item.is_default ? "-" : `v${item.rule_version}`), + width: "minmax(72px, 0.5fr)", + }, + { + key: "strategy_version", + label: "策略", + // 升级前的不可变版本可能没有该字段,owner 会按 fixed_v2 解释;列表保持同一兼容口径, + // 不能把空值展示成 dynamic_v3,避免运营误判已完成策略切换。 + render: (item) => item.strategy_version || "fixed_v2", + width: "minmax(112px, 0.8fr)", + }, + { + key: "enabled", + label: "状态", + render: (item) => { + // 默认草稿是服务端为未配置奖池生成的占位,不参与线上抽奖,必须与"停用"区分开。 + if (item.is_default) { + return ; + } + return item.enabled ? ( + + ) : ( + + ); + }, + width: "minmax(128px, 0.8fr)", + }, + { key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) }, + { key: "pool_rate_percent", label: "公共奖池", render: (item) => formatPercent(item.pool_rate_percent) }, + { + key: "profit_anchor_rate", + label: "平台 / 主播", + render: (item) => `${formatPercent(item.profit_rate_percent)} / ${formatPercent(item.anchor_rate_percent)}`, + width: "minmax(144px, 1fr)", + }, + { key: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) }, + { + key: "settlement_window_wager", + label: "结算窗口流水", + render: (item) => formatNumber(item.settlement_window_wager), + }, + { + key: "created_at_ms", + label: "发布时间", + render: (item) => (item.is_default ? "-" : ), + width: "minmax(176px, 1.1fr)", + }, + { + key: "actions", + label: "操作", + render: (item) => ( + + ), + }, + ]; } diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index f93438d..54bc3b1 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -12,283 +12,555 @@ import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { formatDecimal } from "@/shared/utils/rtpProbability.js"; import { - configToForm, - correctAllStageProbabilities, - formToConfig, - nextStageMultiplier, - stageSummary + applyDynamicStrategyDefaults, + configToForm, + correctAllStageProbabilities, + formToConfig, + nextStageMultiplier, + stageSummary, + validateLuckyGiftForm, } from "../configForm.js"; import { formatNumber, formatPercent } from "../format.js"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) { - const [form, setForm] = useState(() => configToForm(config)); - const [activeStage, setActiveStage] = useState("novice"); + const [form, setForm] = useState(() => configToForm(config)); + const [activeStage, setActiveStage] = useState("novice"); + const [validationErrors, setValidationErrors] = useState([]); - const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); - const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) ? activeStage : form.stages[0]?.stage; - const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); - const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); + const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); + const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) + ? activeStage + : form.stages[0]?.stage; + const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); + const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); - const updateField = (field, value) => { - setForm((current) => { - const next = { ...current, [field]: value }; - // 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。 - return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next; - }); - }; + const updateField = (field, value) => { + setValidationErrors([]); + setForm((current) => { + const next = { ...current, [field]: value }; + if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") { + return applyDynamicStrategyDefaults(next); + } + // 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。 + return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next; + }); + }; - const updateStageTier = (stageKey, tierIndex, patch) => { - setForm((current) => - correctAllStageProbabilities({ - ...current, - stages: current.stages.map((stage) => - stage.stage === stageKey - ? { - ...stage, - tiers: stage.tiers.map((tier, index) => (index === tierIndex ? { ...tier, ...patch } : tier)) - } - : stage - ) - }) - ); - }; + const updateStageField = (stageKey, field, value) => { + setValidationErrors([]); + setForm((current) => ({ + ...current, + stages: current.stages.map((stage) => (stage.stage === stageKey ? { ...stage, [field]: value } : stage)), + })); + }; - const addStageTier = (stageKey) => { - setForm((current) => - correctAllStageProbabilities({ - ...current, - stages: current.stages.map((stage) => - stage.stage === stageKey - ? { - ...stage, - tiers: [ - ...stage.tiers, - { - enabled: true, - // 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。 - highWaterOnly: nextStageMultiplier(stage.tiers) >= 10, - multiplier: String(nextStageMultiplier(stage.tiers)), - probabilityPercent: "0", - tierId: "" - } - ] - } - : stage - ) - }) - ); - }; - - const removeStageTier = (stageKey, tierIndex) => { - setForm((current) => - correctAllStageProbabilities({ - ...current, - stages: current.stages.map((stage) => - stage.stage === stageKey ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } : stage - ) - }) - ); - }; - - const handleSubmit = (event) => { - event.preventDefault(); - onSubmit(formToConfig(config, form)); - }; - - const isCreate = mode === "create"; - const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; - - return ( - -
- - {title} - - {form.app_code || "-"} / {form.pool_id || "default"} /{" "} - {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} - - - -
- - - - - - -
- -
- - -
-
- {stageSummaries.map((summary) => ( - - ))} -
- {activeStageConfig ? ( -
-
-
-

{activeSummary?.stageLabel}

- -
- -
-
- - {activeStageConfig.tiers.map((tier, index) => ( -
- - updateStageTier(activeStageConfig.stage, index, { - // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 - highWaterOnly: Number(event.target.value) >= 10 || tier.highWaterOnly, - multiplier: event.target.value - }) + const updateStageTier = (stageKey, tierIndex, patch) => { + setValidationErrors([]); + setForm((current) => + correctAllStageProbabilities({ + ...current, + stages: current.stages.map((stage) => + stage.stage === stageKey + ? { + ...stage, + tiers: stage.tiers.map((tier, index) => + index === tierIndex ? { ...tier, ...patch } : tier, + ), } + : stage, + ), + }), + ); + }; + + const addStageTier = (stageKey) => { + setValidationErrors([]); + setForm((current) => + correctAllStageProbabilities({ + ...current, + stages: current.stages.map((stage) => + stage.stage === stageKey + ? { + ...stage, + tiers: [ + ...stage.tiers, + { + enabled: true, + // 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。 + highWaterOnly: nextStageMultiplier(stage.tiers) >= 10, + multiplier: String(nextStageMultiplier(stage.tiers)), + probabilityPercent: "0", + tierId: "", + }, + ], + } + : stage, + ), + }), + ); + }; + + const removeStageTier = (stageKey, tierIndex) => { + setValidationErrors([]); + setForm((current) => + correctAllStageProbabilities({ + ...current, + stages: current.stages.map((stage) => + stage.stage === stageKey + ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } + : stage, + ), + }), + ); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + const errors = validateLuckyGiftForm(form); + if (errors.length) { + setValidationErrors(errors); + return; + } + onSubmit(formToConfig(config, form)); + }; + + const isCreate = mode === "create"; + const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; + + return ( + + + + {title} + + {form.app_code || "-"} / {form.pool_id || "default"} /{" "} + {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} + + + +
+ + + + + - {/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */} - - = 10} - size="small" - slotProps={{ input: { "aria-label": "高水位" } }} - onChange={(event) => updateStageTier(activeStageConfig.stage, index, { highWaterOnly: event.target.checked })} - /> - updateStageTier(activeStageConfig.stage, index, { enabled: event.target.checked })} - /> - -
- ))} -
+ + +
+ + {validationErrors.length ? ( +
+ 配置不能发布 +
    + {validationErrors.map((message) => ( +
  • {message}
  • + ))} +
+
+ ) : null} + +
+ + +
+
+ {stageSummaries.map((summary) => ( + + ))} +
+ {activeStageConfig ? ( +
+
+
+

{activeSummary?.stageLabel}

+ +
+ +
+ {form.strategy_version === "dynamic_v3" ? ( +
+ + updateStageField( + activeStageConfig.stage, + "min_recharge_7d_coins", + event.target.value, + ) + } + /> + + updateStageField( + activeStageConfig.stage, + "min_recharge_30d_coins", + event.target.value, + ) + } + /> +
+ ) : null} +
+ + {activeStageConfig.tiers.map((tier, index) => ( +
+ + updateStageTier(activeStageConfig.stage, index, { + // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 + highWaterOnly: + Number(event.target.value) >= 10 || tier.highWaterOnly, + multiplier: event.target.value, + }) + } + /> + {/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */} + + = 10} + size="small" + slotProps={{ input: { "aria-label": "高水位" } }} + onChange={(event) => + updateStageTier(activeStageConfig.stage, index, { + highWaterOnly: event.target.checked, + }) + } + /> + + updateStageTier(activeStageConfig.stage, index, { + enabled: event.target.checked, + }) + } + /> + +
+ ))} +
+
+ ) : null} +
+
+ + + + + + +
+ ); +} + +function DynamicStrategySections({ form, onChange }) { + return ( + <> +
+

动态水位

+
+ + + + + + + +
- ) : null}
- - - - - - - - - ); + +
+

大奖控制

+
+ onChange("jackpot_multipliers", event.target.value)} + /> + + + + + +
+
+ +
+

六维返奖上限

+
+ + + + + + +
+
+ + ); } function ConfigMetric({ label, value }) { - return ( -
- {label} - {value} -
- ); + return ( +
+ {label} + {value} +
+ ); } -function NumberField({ form, label, name, onChange, step = "1" }) { - return ( - onChange(name, event.target.value)} - /> - ); +function NumberField({ form, helperText, label, name, onChange, step = "1" }) { + return ( + onChange(name, event.target.value)} + /> + ); } diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index 8d907df..f814777 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -1,158 +1,458 @@ import { stageOptions } from "@/features/lucky-gift/constants.js"; import { - applyProbabilityMap, - correctRTPProbabilities, - decimal, - expectedRTPPercent, - formatDecimal, - probabilityTotal + applyProbabilityMap, + correctRTPProbabilities, + decimal, + expectedRTPPercent, + formatDecimal, + probabilityTotal, } from "@/shared/utils/rtpProbability.js"; import { DEFAULT_POOL_ID } from "./api.js"; -// 幸运礼物配置表单的纯领域逻辑:配置 <-> 表单换算、概率闭合校正、阶段摘要。 -// 与展示层分离,弹窗组件只负责渲染和事件绑定。 +const DYNAMIC_STRATEGY = "dynamic_v3"; +const PPM_SCALE = 1_000_000; + +// 只有跨 App 通用的算法参数才有默认值;50 美元等值门槛和六维金额上限必须由运营按 App 金币口径填写, +// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。 +const dynamicDefaults = { + anchor_daily_payout_cap: 0, + anchor_rate_percent: 1, + control_band_percent: 3, + device_daily_payout_cap: 0, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 0, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 0, + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 0, + target_rtp_percent: 98, + user_daily_payout_cap: 0, + user_hourly_payout_cap: 0, +}; + +const numericFields = [ + "anchor_daily_payout_cap", + "anchor_rate_percent", + "control_band_percent", + "device_daily_payout_cap", + "effective_from_ms", + "gift_price_reference", + "high_water_nonzero_factor_percent", + "high_watermark_coins", + "initial_pool_coins", + "jackpot_global_rtp_max_percent", + "jackpot_spend_threshold_coins", + "jackpot_user_72h_rtp_max_percent", + "jackpot_user_day_rtp_max_percent", + "loss_streak_guarantee", + "low_water_nonzero_factor_percent", + "low_watermark_coins", + "max_jackpot_hits_per_user_day", + "max_single_payout", + "normal_max_equivalent_draws", + "novice_max_equivalent_draws", + "pool_rate_percent", + "profit_rate_percent", + "recharge_boost_factor_percent", + "recharge_boost_window_ms", + "room_hourly_payout_cap", + "settlement_window_wager", + "target_rtp_percent", + "user_daily_payout_cap", + "user_hourly_payout_cap", +]; + +const payoutCapFields = [ + ["max_single_payout", "单次返奖上限"], + ["user_hourly_payout_cap", "用户小时上限"], + ["user_daily_payout_cap", "用户每日上限"], + ["device_daily_payout_cap", "设备每日上限"], + ["room_hourly_payout_cap", "房间小时上限"], + ["anchor_daily_payout_cap", "主播每日上限"], +]; export function configToForm(config = {}) { - const targetRTPPercent = formNumber(config.target_rtp_percent ?? config.targetRtpPercent); - return { - app_code: config.app_code || config.appCode || "", - control_band_percent: formNumber(config.control_band_percent ?? config.controlBandPercent), - enabled: Boolean(config.enabled), - gift_price_reference: formNumber(config.gift_price_reference ?? config.giftPriceReference), - pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, - pool_rate_percent: formNumber(config.pool_rate_percent ?? config.poolRatePercent), - settlement_window_wager: formNumber(config.settlement_window_wager ?? config.settlementWindowWager), - stages: normalizeFormStages(config.stages, targetRTPPercent), - target_rtp_percent: targetRTPPercent - }; + const hasPersistedShape = Object.keys(config).length > 0; + const strategyVersion = stringConfigValue( + config, + "strategy_version", + "strategyVersion", + hasPersistedShape ? "fixed_v2" : DYNAMIC_STRATEGY, + ); + const targetRTPPercent = formConfigNumber( + config, + "target_rtp_percent", + "targetRtpPercent", + strategyVersion === DYNAMIC_STRATEGY ? dynamicDefaults.target_rtp_percent : 95, + ); + const form = { + app_code: config.app_code || config.appCode || "", + enabled: Boolean(config.enabled), + jackpot_multipliers: multiplierListToForm( + config.jackpot_multipliers ?? config.jackpotMultipliers, + strategyVersion, + ), + pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, + stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion), + strategy_version: strategyVersion, + }; + for (const field of numericFields) { + if (field === "target_rtp_percent") { + form[field] = targetRTPPercent; + continue; + } + form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)); + } + return form; } export function formToConfig(config, form) { - return { - ...config, - app_code: form.app_code, - control_band_percent: numberFromForm(form.control_band_percent), - enabled: Boolean(form.enabled), - gift_price_reference: numberFromForm(form.gift_price_reference), - pool_id: form.pool_id, - pool_rate_percent: numberFromForm(form.pool_rate_percent), - settlement_window_wager: numberFromForm(form.settlement_window_wager), - stages: form.stages, - target_rtp_percent: numberFromForm(form.target_rtp_percent) - }; + const output = { + ...config, + app_code: form.app_code, + enabled: Boolean(form.enabled), + jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers), + pool_id: form.pool_id, + stages: (form.stages || []).map((stage) => ({ + min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins), + min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins), + stage: stage.stage, + tiers: (stage.tiers || []).map((tier) => ({ + enabled: tier.enabled !== false, + high_water_only: Boolean(tier.highWaterOnly), + multiplier: numberFromForm(tier.multiplier), + probability_percent: numberFromForm(tier.probabilityPercent), + tier_id: tier.tierId || "", + })), + })), + strategy_version: form.strategy_version, + }; + for (const field of numericFields) { + output[field] = numberFromForm(form[field]); + } + return output; +} + +// 从历史 fixed_v2 显式切换到 dynamic_v3 时,先停用并载入安全草稿;金额型上限仍保持 0, +// 运营补齐后才能通过启用校验,避免一次策略切换直接发布无法结算的规则。 +export function applyDynamicStrategyDefaults(form) { + const next = { + ...form, + enabled: false, + jackpot_multipliers: dynamicDefaults.jackpot_multipliers.join(", "), + strategy_version: DYNAMIC_STRATEGY, + }; + for (const [field, value] of Object.entries(dynamicDefaults)) { + if (field !== "jackpot_multipliers") { + next[field] = String(value); + } + } + next.stages = stageOptions.map(([stage]) => { + const [min7d, min30d] = defaultRechargeThreshold(stage); + return { + min_recharge_7d_coins: String(min7d), + min_recharge_30d_coins: String(min30d), + stage, + // fixed_v2 可能含历史高倍基础档;切换策略时必须回到 owner 的 98% 安全基线, + // 不能只改策略名后把旧概率意外带入 dynamic_v3 的第一版。 + tiers: correctedDefaultTiers(stage, dynamicDefaults.target_rtp_percent), + }; + }); + return next; } // 概率列必须整体闭合到 100% 且期望 RTP 逼近目标值,所以任何倍率/目标 RTP 改动后都重算全阶段概率, // 不允许运营手工填概率绕过服务端的闭合校验。 export function correctAllStageProbabilities(form) { - return { - ...form, - stages: (form.stages || []).map((stage) => { - const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, { - multiplier: (item) => item.multiplier - }); - return { - ...stage, - tiers: probabilityByIndex ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) : stage.tiers - }; - }) - }; + return { + ...form, + stages: (form.stages || []).map((stage) => { + const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, { + multiplier: (item) => item.multiplier, + }); + return { + ...stage, + tiers: probabilityByIndex + ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) + : stage.tiers, + }; + }), + }; } export function stageSummary(stage, form) { - const probability = probabilityTotal(stage.tiers); - const expected = expectedRTPPercent(stage.tiers, { - multiplier: (item) => item.multiplier - }); - const target = Number(form.target_rtp_percent || 0); - const band = Number(form.control_band_percent || 0); - const probabilityClosed = Math.abs(probability - 100) < 0.0001; - const rtpInBand = expected >= target - band && expected <= target + band; - const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage; + const probability = probabilityTotal(stage.tiers); + const expected = expectedRTPPercent(stage.tiers, { multiplier: (item) => item.multiplier }); + const target = Number(form.target_rtp_percent || 0); + const band = Number(form.control_band_percent || 0); + const probabilityClosed = Math.abs(probability - 100) < 0.0001; + const rtpInBand = expected >= target - band && expected <= target + band; + const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage; + return { + expected, + ok: probabilityClosed && rtpInBand, + probability, + probabilityClosed, + rtpInBand, + stageKey: stage.stage, + stageLabel: fullLabel, + status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合", + tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, ""), + }; +} - return { - expected, - ok: probabilityClosed && rtpInBand, - probability, - probabilityClosed, - rtpInBand, - stageKey: stage.stage, - stageLabel: fullLabel, - // 发布按钮不做硬拦截(服务端仍会校验),这里的状态只用于提示运营当前阶段是否可安全发布。 - status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合", - tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, "") - }; +export function validateLuckyGiftForm(form) { + const errors = []; + const number = (field) => numberFromForm(form[field]); + if (!String(form.app_code || "").trim()) errors.push("请选择应用"); + if (!String(form.pool_id || "").trim()) errors.push("奖池不能为空"); + if (!["fixed_v2", DYNAMIC_STRATEGY].includes(form.strategy_version)) errors.push("策略版本不受支持"); + if (number("target_rtp_percent") < 10 || number("target_rtp_percent") > 99) + errors.push("目标 RTP 必须在 10%-99% 之间"); + if (number("pool_rate_percent") < number("target_rtp_percent") || number("pool_rate_percent") > 100) + errors.push("奖池比例必须在目标 RTP 与 100% 之间"); + if (number("control_band_percent") <= 0 || number("control_band_percent") > 5) + errors.push("控制带宽必须大于 0 且不超过 5%"); + if (number("settlement_window_wager") <= 0) errors.push("结算窗口流水必须大于 0"); + if (number("gift_price_reference") <= 0) errors.push("礼物参考金额必须大于 0"); + if ( + number("novice_max_equivalent_draws") < 0 || + number("normal_max_equivalent_draws") < number("novice_max_equivalent_draws") + ) + errors.push("等价抽数阶段门槛不正确"); + + // 阶段概率与 RTP 是两代策略共用的 owner 校验,fixed_v2 也不能跳过。 + for (const stageKey of ["novice", "normal", "advanced"]) { + const stage = form.stages?.find((item) => item.stage === stageKey); + if (!stage) { + errors.push("必须配置新手、正常和高阶三个阶段"); + continue; + } + const enabled = stage.tiers.filter((tier) => tier.enabled !== false); + if (enabled.some((tier) => numberFromForm(tier.multiplier) < 0)) + errors.push(`${stageLabel(stageKey)}倍率不能小于 0`); + const probabilityPPM = enabled.reduce((sum, tier) => sum + percentToPPM(tier.probabilityPercent), 0); + if (probabilityPPM !== PPM_SCALE) errors.push(`${stageLabel(stageKey)}启用奖档概率必须合计 100%`); + if (!enabled.some((tier) => Number(tier.multiplier) === 0)) + errors.push(`${stageLabel(stageKey)}必须包含启用的 0x 档`); + if (enabled.some((tier) => Number(tier.probabilityPercent) <= 0)) + errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`); + if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly)) + errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`); + const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier }); + if ( + expected < number("target_rtp_percent") - number("control_band_percent") || + expected > number("target_rtp_percent") + number("control_band_percent") + ) + errors.push(`${stageLabel(stageKey)}期望 RTP 超出控制带宽`); + } + + if (form.strategy_version !== DYNAMIC_STRATEGY) return unique(errors); + + if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0) + errors.push("平台盈利和主播收益比例不能小于 0"); + if ( + percentToPPM(number("pool_rate_percent")) + + percentToPPM(number("profit_rate_percent")) + + percentToPPM(number("anchor_rate_percent")) !== + PPM_SCALE + ) + errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%"); + if (number("initial_pool_coins") <= 0) errors.push("初始奖池必须大于 0"); + if (number("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0"); + if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins")) + errors.push("高水位必须大于正数低水位"); + if (number("low_water_nonzero_factor_percent") <= 0 || number("low_water_nonzero_factor_percent") >= 100) + errors.push("低水位非零因子必须大于 0% 且小于 100%"); + if (number("high_water_nonzero_factor_percent") <= 100) errors.push("高水位非零因子必须大于 100%"); + if (number("recharge_boost_window_ms") <= 0 || number("recharge_boost_factor_percent") <= 100) + errors.push("充值加权窗口必须大于 0 且因子必须大于 100%"); + const jackpotTokens = String(form.jackpot_multipliers || "") + .split(/[,,\s]+/) + .filter(Boolean); + const jackpotMultipliers = multiplierListFromForm(form.jackpot_multipliers); + if ( + jackpotTokens.length !== jackpotMultipliers.length || + !jackpotMultipliers.length || + jackpotMultipliers.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultipliers[index - 1])) + ) { + errors.push("大奖倍率必须大于 1x 并严格递增"); + } + if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100) + errors.push("全局大奖 RTP 上限必须在 0%-100% 之间"); + if ( + number("jackpot_user_day_rtp_max_percent") <= 0 || + number("jackpot_user_day_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + ) + errors.push("用户每日 RTP 上限必须大于 0 且不超过全局上限"); + if ( + number("jackpot_user_72h_rtp_max_percent") <= 0 || + number("jackpot_user_72h_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + ) + errors.push("用户 72 小时 RTP 上限必须大于 0 且不超过全局上限"); + if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0"); + if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0"); + for (const [field, label] of payoutCapFields) { + if (number(field) < 0) errors.push(`${label}不能小于 0`); + } + validateRechargeStages(form.stages || [], errors); + if (form.enabled) { + if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写 50 美元等值的大奖消费门槛"); + for (const [field, label] of payoutCapFields) { + if (number(field) <= 0) errors.push(`启用前必须填写${label}`); + } + } + return unique(errors); } export function nextStageMultiplier(tiers = []) { - const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0); - if (!positive.length) { - return 1; - } - const max = Math.max(...positive); - if (max >= 5) { - return max * 2; - } - if (max >= 2) { - return 5; - } - return max + 1; + const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0); + if (!positive.length) return 1; + const max = Math.max(...positive); + if (max >= 5) return max * 2; + if (max >= 2) return 5; + return max + 1; } -function normalizeFormStages(stages, targetRTPPercent = 95) { - const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage])); - return correctAllStageProbabilities({ - stages: stageOptions.map(([stageKey]) => { - const source = byStage.get(stageKey); - const sourceTiers = Array.isArray(source?.tiers) && source.tiers.length ? source.tiers : defaultStageTiers(stageKey); - return { - stage: stageKey, - tiers: sourceTiers.map((tier) => ({ - enabled: tier.enabled !== false, - highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly), - multiplier: formNumber(tier.multiplier), - probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent), - tierId: tier.tier_id || tier.tierId || "" - })) - }; - }), - target_rtp_percent: targetRTPPercent - }).stages; +function normalizeFormStages(stages, targetRTPPercent, strategyVersion) { + const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage])); + return stageOptions.map(([stageKey]) => { + const source = byStage.get(stageKey); + const sourceTiers = + Array.isArray(source?.tiers) && source.tiers.length + ? source.tiers + : correctedDefaultTiers(stageKey, targetRTPPercent); + const [default7d, default30d] = + strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0]; + return { + min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d), + min_recharge_30d_coins: formNumber( + source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d, + ), + stage: stageKey, + tiers: sourceTiers.map((tier) => ({ + enabled: tier.enabled !== false, + highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly), + multiplier: formNumber(tier.multiplier), + probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent), + tierId: tier.tier_id || tier.tierId || "", + })), + }; + }); } -function defaultStageTiers(stage) { - if (stage === "advanced") { - return [ - defaultTier("advanced_none", 0, 27), - defaultTier("advanced_1x", 1, 60), - defaultTier("advanced_2x", 2, 10), - defaultTier("advanced_5x", 5, 3, { high_water_only: true }) +function correctedDefaultTiers(stage, targetRTPPercent) { + const tiers = [ + defaultTier(`${stage}_none`, 0, 5), + defaultTier(`${stage}_0_5x`, 0.5, 4), + defaultTier(`${stage}_1x`, 1, 86), + defaultTier(`${stage}_2x`, 2, 5), ]; - } - return [ - defaultTier(`${stage}_none`, 0, 10), - defaultTier(`${stage}_0_5x`, 0.5, 20), - defaultTier(`${stage}_1x`, 1, 55), - defaultTier(`${stage}_2x`, 2, 15) - ]; + // dynamic_v3 owner 的 98% 安全基线不只是数学 RTP,还规定了 0x/0.5x/1x/2x 的体验节奏。 + // 这组 5/4/86/5 本身已精确达到 98%,不能再交给无 baseline 的通用求解器重排成另一组同 RTP 概率。 + if (Math.abs(numberFromForm(targetRTPPercent) - dynamicDefaults.target_rtp_percent) < 0.0001) { + return tiers; + } + const probabilities = correctRTPProbabilities(tiers, targetRTPPercent, { multiplier: (item) => item.multiplier }); + return probabilities ? applyProbabilityMap(tiers, probabilities, formatDecimal) : tiers; } -function defaultTier(tierID, multiplier, probabilityPercent, options = {}) { - return { - enabled: true, - highWaterOnly: Boolean(options.high_water_only), - multiplier, - probabilityPercent, - tierId: tierID - }; +function defaultTier(tierID, multiplier, probabilityPercent) { + return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID }; +} + +function validateRechargeStages(stages, errors) { + const novice = stages.find((stage) => stage.stage === "novice"); + const normal = stages.find((stage) => stage.stage === "normal"); + const advanced = stages.find((stage) => stage.stage === "advanced"); + const threshold = (stage, field) => numberFromForm(stage?.[field]); + if (threshold(novice, "min_recharge_7d_coins") !== 0 || threshold(novice, "min_recharge_30d_coins") !== 0) + errors.push("新手阶段充值门槛必须为 0 / 0"); + const normal7d = threshold(normal, "min_recharge_7d_coins"); + const normal30d = threshold(normal, "min_recharge_30d_coins"); + const advanced7d = threshold(advanced, "min_recharge_7d_coins"); + const advanced30d = threshold(advanced, "min_recharge_30d_coins"); + if ([normal7d, normal30d, advanced7d, advanced30d].some((value) => value < 0)) { + errors.push("充值阶段门槛不能小于 0"); + return; + } + if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 0"); + if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d)) + errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高"); +} + +function defaultRechargeThreshold(stage) { + if (stage === "novice") return [0, 0]; + if (stage === "normal") return [0, 1]; + return [1, 1]; +} + +function defaultNumber(field, strategyVersion) { + if (field === "gift_price_reference") return 100; + if (field === "settlement_window_wager") return 1_000_000; + if (field === "novice_max_equivalent_draws") return 2_000; + if (field === "normal_max_equivalent_draws") return 20_000; + if (field === "effective_from_ms") return 0; + return strategyVersion === DYNAMIC_STRATEGY ? (dynamicDefaults[field] ?? 0) : 0; +} + +function multiplierListToForm(value, strategyVersion) { + if (Array.isArray(value) && value.length) return value.map(formatDecimal).join(", "); + if (typeof value === "string" && value.trim()) return value; + return strategyVersion === DYNAMIC_STRATEGY ? dynamicDefaults.jackpot_multipliers.join(", ") : ""; +} + +function multiplierListFromForm(value) { + return String(value || "") + .split(/[,,\s]+/) + .filter(Boolean) + .map(Number) + .filter(Number.isFinite); +} + +function stringConfigValue(config, snake, camel, fallback) { + const value = config[snake] ?? config[camel]; + return String(value || fallback); +} + +function formConfigNumber(config, snake, camel, fallback) { + return formNumber(config[snake] ?? config[camel] ?? fallback); +} + +function snakeToCamel(value) { + return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase()); +} + +function stageLabel(stage) { + return stageOptions.find(([key]) => key === stage)?.[1] || stage; +} + +function percentToPPM(value) { + return Math.round(Number(value || 0) * 10_000); +} + +function unique(values) { + return Array.from(new Set(values)); } export function formNumber(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? String(parsed) : "0"; + const parsed = Number(value); + return Number.isFinite(parsed) ? String(parsed) : "0"; } export function numberFromForm(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; } diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js new file mode 100644 index 0000000..df9118d --- /dev/null +++ b/ops-center/src/configForm.test.js @@ -0,0 +1,217 @@ +import { describe, expect, test } from "vitest"; +import { applyDynamicStrategyDefaults, configToForm, formToConfig, validateLuckyGiftForm } from "./configForm.js"; + +describe("ops center lucky gift dynamic_v3 form", () => { + test("loads safe dynamic defaults without inventing app-specific monetary caps", () => { + const form = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); + + expect(form).toMatchObject({ + anchor_rate_percent: "1", + enabled: false, + initial_pool_coins: "1000000", + jackpot_multipliers: "200, 500, 1000", + max_single_payout: "0", + pool_rate_percent: "98", + profit_rate_percent: "1", + strategy_version: "dynamic_v3", + target_rtp_percent: "98", + }); + expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([ + ["0", "0"], + ["0", "1"], + ["1", "1"], + ]); + expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ + [5, 4, 86, 5], + [5, 4, 86, 5], + [5, 4, 86, 5], + ]); + }); + + test("round-trips every dynamic strategy field and canonical stage keys", () => { + const config = completeDynamicConfig(); + // 使用空原对象可证明每个字段都真正经过 formToConfig 写回;若沿用 config 作为 spread 基底, + // 漏掉的动态字段也会被旧值掩盖,测试无法发现发布 payload 的字段丢失。 + const output = formToConfig({}, configToForm(config)); + + expect(output).toEqual(config); + expect(output.stages[1]).toMatchObject({ + min_recharge_30d_coins: 20_000, + min_recharge_7d_coins: 10_000, + stage: "normal", + }); + expect(output.stages[1].tiers[0]).toEqual({ + enabled: true, + high_water_only: false, + multiplier: 0, + probability_percent: 5, + tier_id: "normal_none", + }); + }); + + test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => { + const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); + expect(validateLuckyGiftForm(draft)).toEqual([]); + + const enabled = { ...draft, enabled: true }; + const errors = validateLuckyGiftForm(enabled); + + expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛"); + expect(errors).toContain("启用前必须填写单次返奖上限"); + expect(errors).toContain("启用前必须填写主播每日上限"); + }); + + test("keeps fixed_v2 readable and does not apply dynamic-only validation", () => { + const form = configToForm({ + app_code: "yumi", + control_band_percent: 1, + enabled: true, + gift_price_reference: 100, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "legacy", + pool_rate_percent: 96, + settlement_window_wager: 1_000_000, + stages: fixedStages(), + strategy_version: "fixed_v2", + target_rtp_percent: 95, + }); + + expect(form.strategy_version).toBe("fixed_v2"); + expect(validateLuckyGiftForm(form)).toEqual([]); + }); + + test("switching a legacy form to dynamic creates a disabled safe draft", () => { + const legacy = configToForm({ + app_code: "yumi", + control_band_percent: 1, + enabled: true, + gift_price_reference: 100, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "legacy", + pool_rate_percent: 96, + settlement_window_wager: 1_000_000, + stages: fixedStages(), + strategy_version: "fixed_v2", + target_rtp_percent: 95, + }); + const dynamic = applyDynamicStrategyDefaults(legacy); + + expect(dynamic).toMatchObject({ enabled: false, pool_rate_percent: "98", strategy_version: "dynamic_v3" }); + expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.multiplier)))).toEqual([ + [0, 0.5, 1, 2], + [0, 0.5, 1, 2], + [0, 0.5, 1, 2], + ]); + expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ + [5, 4, 86, 5], + [5, 4, 86, 5], + [5, 4, 86, 5], + ]); + expect(validateLuckyGiftForm(dynamic)).toEqual([]); + }); + + test("rejects invalid disabled draft values that the owner never accepts", () => { + const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); + const errors = validateLuckyGiftForm({ + ...draft, + jackpot_spend_threshold_coins: "-1", + max_single_payout: "-1", + profit_rate_percent: "-1", + anchor_rate_percent: "2", + stages: draft.stages.map((stage) => + stage.stage === "normal" ? { ...stage, min_recharge_7d_coins: "-1" } : stage, + ), + }); + + expect(errors).toContain("平台盈利和主播收益比例不能小于 0"); + expect(errors).toContain("大奖消费门槛不能小于 0"); + expect(errors).toContain("单次返奖上限不能小于 0"); + expect(errors).toContain("充值阶段门槛不能小于 0"); + }); +}); + +function completeDynamicConfig() { + return { + anchor_daily_payout_cap: 6_000_000, + anchor_rate_percent: 1, + app_code: "lalu", + control_band_percent: 3, + device_daily_payout_cap: 4_000_000, + effective_from_ms: 0, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 1_000_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "default", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 5_000_000, + settlement_window_wager: 1_000_000, + stages: dynamicStages(), + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 3_000_000, + user_hourly_payout_cap: 2_000_000, + }; +} + +function dynamicStages() { + return [ + dynamicStage("novice", 0, 0), + dynamicStage("normal", 10_000, 20_000), + dynamicStage("advanced", 20_000, 30_000), + ]; +} + +function dynamicStage(stage, min7d, min30d) { + return { + min_recharge_30d_coins: min30d, + min_recharge_7d_coins: min7d, + stage, + tiers: [ + tier(`${stage}_none`, 0, 5), + tier(`${stage}_0_5x`, 0.5, 4), + tier(`${stage}_1x`, 1, 86), + tier(`${stage}_2x`, 2, 5), + ], + }; +} + +function fixedStages() { + return ["novice", "normal", "advanced"].map((stage) => ({ + stage, + tiers: [ + tier(`${stage}_none`, 0, 10), + tier(`${stage}_0_5x`, 0.5, 20), + tier(`${stage}_1x`, 1, 55), + tier(`${stage}_2x`, 2, 15), + ], + })); +} + +function tier(tierId, multiplier, probabilityPercent) { + return { + enabled: true, + high_water_only: false, + multiplier, + probability_percent: probabilityPercent, + tier_id: tierId, + }; +} diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index ee7e595..14d2863 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -1,407 +1,440 @@ :root { - color: var(--text-primary); - background: var(--bg-page); - font-family: var(--font-system); + color: var(--text-primary); + background: var(--bg-page); + font-family: var(--font-system); } body { - margin: 0; - min-width: 320px; - background: var(--bg-page); + margin: 0; + min-width: 320px; + background: var(--bg-page); } #ops-center-root { - min-height: 100vh; + min-height: 100vh; } /* ---- 应用外壳:左侧固定导航 + 内容区,宽度对齐主后台侧边栏(248px) ---- */ .ops-shell { - display: grid; - min-height: 100vh; - grid-template-columns: 248px minmax(0, 1fr); + display: grid; + min-height: 100vh; + grid-template-columns: 248px minmax(0, 1fr); } .ops-sidebar { - display: flex; - flex-direction: column; - gap: var(--space-5); - border-right: 1px solid var(--border); - background: var(--bg-card); - padding: var(--space-5) var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-5); + border-right: 1px solid var(--border); + background: var(--bg-card); + padding: var(--space-5) var(--space-3); } .ops-brand { - display: flex; - align-items: center; - gap: var(--space-3); - padding: 0 var(--space-2); + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-2); } .ops-brand > span { - display: inline-grid; - width: 36px; - height: 36px; - place-items: center; - border-radius: var(--radius-control); - background: var(--primary); - color: var(--active-contrast, #fff); - font-size: 13px; - font-weight: 800; + display: inline-grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: var(--radius-control); + background: var(--primary); + color: var(--active-contrast, #fff); + font-size: 13px; + font-weight: 800; } .ops-brand strong { - display: block; - font-size: 16px; - line-height: 1.25; + display: block; + font-size: 16px; + line-height: 1.25; } .ops-brand small { - color: var(--text-tertiary); - font-size: 11px; + color: var(--text-tertiary); + font-size: 11px; } .ops-nav { - display: grid; - gap: var(--space-2); + display: grid; + gap: var(--space-2); } .ops-nav button { - display: flex; - width: 100%; - height: 38px; - align-items: center; - gap: var(--space-3); - border: 0; - border-radius: var(--radius-control); - background: transparent; - color: var(--text-secondary); - cursor: pointer; - padding: 0 var(--space-3); - text-align: left; - transition: - background var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard); + display: flex; + width: 100%; + height: 38px; + align-items: center; + gap: var(--space-3); + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + padding: 0 var(--space-3); + text-align: left; + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); } .ops-nav button:hover { - background: var(--primary-hover); - color: var(--text-primary); + background: var(--primary-hover); + color: var(--text-primary); } /* active 优先级必须高于 hover:hover 到别的项时当前页仍要保持高亮底色 */ .ops-nav button.is-active, .ops-nav button.is-active:hover { - background: var(--primary-surface); - color: var(--primary); - font-weight: 700; + background: var(--primary-surface); + color: var(--primary); + font-weight: 700; } .ops-main { - min-width: 0; - background: var(--bg-page); + min-width: 0; + background: var(--bg-page); } /* ---- 页面骨架 ---- */ .ops-page { - display: grid; - gap: var(--space-5); - padding: var(--space-6); + display: grid; + gap: var(--space-5); + padding: var(--space-6); } .ops-view { - display: grid; - gap: var(--space-5); + display: grid; + gap: var(--space-5); } .ops-kpi-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .ops-status { - display: inline-flex; - height: 28px; - align-items: center; - border: 1px solid var(--success-border); - border-radius: 999px; - background: var(--success-surface); - color: var(--success); - font-size: 12px; - font-weight: 700; - padding: 0 var(--space-3); + display: inline-flex; + height: 28px; + align-items: center; + border: 1px solid var(--success-border); + border-radius: 999px; + background: var(--success-surface); + color: var(--success); + font-size: 12px; + font-weight: 700; + padding: 0 var(--space-3); } .ops-status.is-loading { - border-color: var(--info-border); - background: var(--info-surface); - color: var(--info); + border-color: var(--info-border); + background: var(--info-surface); + color: var(--info); } .ops-alert { - border: 1px solid var(--warning-border); - border-radius: var(--radius-card); - background: var(--warning-surface); - color: var(--warning); - font-weight: 700; - padding: var(--space-3) var(--space-4); + border: 1px solid var(--warning-border); + border-radius: var(--radius-card); + background: var(--warning-surface); + color: var(--warning); + font-weight: 700; + padding: var(--space-3) var(--space-4); } /* ---- 幸运礼物配置弹窗 ---- */ .ops-config-form { - display: contents; + display: contents; } .ops-config-title { - display: grid; - gap: var(--space-1); + display: grid; + gap: var(--space-1); } .ops-config-title small { - color: var(--text-tertiary); - font-size: 12px; - font-weight: 700; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 700; } .ops-config-metrics { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: var(--space-2); - margin-bottom: var(--space-4); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.ops-config-validation { + display: grid; + gap: var(--space-2); + margin-bottom: var(--space-4); + border: 1px solid var(--danger-border, #fecaca); + border-radius: var(--radius-control); + background: var(--danger-surface, #fef2f2); + color: var(--danger, #dc2626); + padding: var(--space-3) var(--space-4); +} + +.ops-config-validation ul { + display: grid; + gap: 2px; + margin: 0; + padding-left: 20px; } .ops-config-metric { - display: grid; - min-width: 0; - gap: var(--space-1); - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--bg-card-strong, #f8fafc); - padding: 10px var(--space-3); + display: grid; + min-width: 0; + gap: var(--space-1); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong, #f8fafc); + padding: 10px var(--space-3); } .ops-config-metric span { - overflow: hidden; - color: var(--text-tertiary); - font-size: 12px; - font-weight: 800; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; } .ops-config-metric strong { - overflow: hidden; - color: var(--text-primary); - font-size: 15px; - line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + color: var(--text-primary); + font-size: 15px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; } .ops-config-layout { - display: grid; - grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); - align-items: start; - gap: var(--space-4); + display: grid; + grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); + align-items: start; + gap: var(--space-4); } .ops-config-params { - display: grid; - min-width: 0; - gap: var(--space-4); + display: grid; + min-width: 0; + gap: var(--space-4); } .ops-config-section { - display: grid; - gap: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card); - padding: var(--space-4); + display: grid; + gap: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); + padding: var(--space-4); } .ops-config-section h3 { - margin: 0; - color: var(--text-primary); - font-size: 15px; - line-height: 1.3; + margin: 0; + color: var(--text-primary); + font-size: 15px; + line-height: 1.3; } .ops-form-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-3); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.ops-form-grid__wide { + grid-column: 1 / -1; } .ops-stage-designer { - display: grid; - min-width: 0; - gap: 0; - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card); + display: grid; + min-width: 0; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); } .ops-stage-tabs { - display: flex; - min-width: 0; - gap: var(--space-1); - padding: 0 var(--space-3); - border-bottom: 1px solid var(--border); + display: flex; + min-width: 0; + gap: var(--space-1); + padding: 0 var(--space-3); + border-bottom: 1px solid var(--border); } .ops-stage-tab { - display: inline-flex; - min-width: 0; - height: 38px; - align-items: center; - border: 0; - border-bottom: 2px solid transparent; - border-radius: 0; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - font-size: 13px; - font-weight: 800; - padding: 0 var(--space-4); - text-align: center; - transition: - background var(--motion-fast) var(--ease-standard), - border-color var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard); + display: inline-flex; + min-width: 0; + height: 38px; + align-items: center; + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + font-weight: 800; + padding: 0 var(--space-4); + text-align: center; + transition: + background var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); } .ops-stage-tab:hover { - background: var(--primary-hover); + background: var(--primary-hover); } .ops-stage-tab.is-active { - border-bottom-color: var(--primary); - color: var(--text-primary); + border-bottom-color: var(--primary); + color: var(--text-primary); } /* 阶段有问题(概率未闭合 / RTP 偏离)时 tab 下划线转告警色,切换前就能看到哪个阶段需要处理 */ .ops-stage-tab.is-warn { - color: var(--warning); + color: var(--warning); } .ops-stage-tab.is-active.is-warn { - border-bottom-color: var(--warning); - color: var(--warning); + border-bottom-color: var(--warning); + color: var(--warning); } .ops-stage-stack { - display: grid; - gap: var(--space-3); - padding: var(--space-4); + display: grid; + gap: var(--space-3); + padding: var(--space-4); } .ops-stage-block__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); } .ops-stage-block__head > div { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-3); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); } .ops-stage-block__head h3 { - margin: 0; - font-size: 15px; + margin: 0; + font-size: 15px; +} + +.ops-stage-thresholds { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong, #f8fafc); } .ops-tier-table { - display: grid; - min-width: 0; - overflow-x: auto; - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card); + display: grid; + min-width: 0; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); } .ops-tier-head, .ops-tier-row { - display: grid; - box-sizing: border-box; - min-width: 520px; - grid-template-columns: - minmax(110px, 1fr) - minmax(110px, 1fr) - minmax(64px, auto) - minmax(64px, auto) - minmax(72px, auto); - align-items: center; - gap: var(--space-2); + display: grid; + box-sizing: border-box; + min-width: 520px; + grid-template-columns: + minmax(110px, 1fr) + minmax(110px, 1fr) + minmax(64px, auto) + minmax(64px, auto) + minmax(72px, auto); + align-items: center; + gap: var(--space-2); } .ops-tier-head { - min-height: 34px; - border-bottom: 1px solid var(--row-border); - background: var(--bg-card-strong, #f8fafc); - color: var(--text-tertiary); - font-size: 12px; - font-weight: 900; - padding: 0 var(--space-3); + min-height: 34px; + border-bottom: 1px solid var(--row-border); + background: var(--bg-card-strong, #f8fafc); + color: var(--text-tertiary); + font-size: 12px; + font-weight: 900; + padding: 0 var(--space-3); } .ops-tier-row { - border-bottom: 1px solid var(--row-border); - padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--row-border); + padding: var(--space-2) var(--space-3); } .ops-tier-row:last-child { - border-bottom: 0; + border-bottom: 0; } /* ---- 响应式 ---- */ @media (max-width: 960px) { - .ops-shell { - grid-template-columns: 1fr; - } + .ops-shell { + grid-template-columns: 1fr; + } - .ops-sidebar { - min-height: auto; - } + .ops-sidebar { + min-height: auto; + } - .ops-nav, - .ops-kpi-grid, - .ops-config-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } + .ops-nav, + .ops-kpi-grid, + .ops-config-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } - .ops-config-layout { - grid-template-columns: minmax(0, 1fr); - } + .ops-config-layout { + grid-template-columns: minmax(0, 1fr); + } } @media (max-width: 640px) { - .ops-page { - padding: var(--space-4); - } + .ops-page { + padding: var(--space-4); + } - .ops-nav, - .ops-kpi-grid, - .ops-config-metrics, - .ops-form-grid, - .ops-tier-row { - grid-template-columns: 1fr; - } + .ops-nav, + .ops-kpi-grid, + .ops-config-metrics, + .ops-form-grid, + .ops-stage-thresholds, + .ops-tier-row { + grid-template-columns: 1fr; + } - .ops-tier-head { - display: none; - } + .ops-tier-head { + display: none; + } } @media (prefers-reduced-motion: reduce) { - .ops-nav button, - .ops-stage-tab { - transition: none; - } + .ops-nav button, + .ops-stage-tab { + transition: none; + } } diff --git a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx index 996a738..80e0b9f 100644 --- a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx +++ b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx @@ -1,204 +1,18 @@ -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; -import TextField from "@mui/material/TextField"; -import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx"; -import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx"; -import { LuckyGiftSimulationPanel } from "@/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx"; -import { drawStatusOptions } from "@/features/lucky-gift/constants.js"; -import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js"; -import styles from "@/features/lucky-gift/lucky-gift.module.css"; -import { AdminFilterResetButton, AdminFilterSelect, AdminListPage } from "@/shared/ui/AdminListLayout.jsx"; -import { Button } from "@/shared/ui/Button.jsx"; +import { useEffect } from "react"; + +export const LUCKY_GIFT_OPS_CENTER_PATH = "/ops-center/?view=configs"; + +export function redirectToLuckyGiftOpsCenter(targetLocation = window.location) { + // Ops Center 是独立 HTML 入口,React Router 的 SPA Navigate 会落回主后台兜底路由; + // 必须触发文档级导航,才能加载新版跨 App 配置页并避免旧页面调用已下线的 activity/v2 接口。 + targetLocation.replace(LUCKY_GIFT_OPS_CENTER_PATH); +} export function LuckyGiftConfigPage() { - const page = useLuckyGiftPage(); + useEffect(() => { + redirectToLuckyGiftOpsCenter(); + }, []); - return ( - - - - - - - - ); -} - -function LuckyGiftDrawSummaryPanel({ page }) { - const summary = page.drawSummary || {}; - const netSpent = Number(summary.totalSpentCoins || 0) - Number(summary.totalRewardCoins || 0); - const stats = [ - { - hint: `房间 ${formatNumber(summary.uniqueRooms)}`, - label: "参与用户", - value: formatNumber(summary.uniqueUsers), - }, - { - hint: `成功 ${formatNumber(summary.grantedDraws)} / 待发放 ${formatNumber(summary.pendingDraws)} / 失败 ${formatNumber(summary.failedDraws)}`, - label: "抽奖次数", - value: formatNumber(summary.totalDraws), - }, - { - hint: `目标 ${formatPercent(page.config?.targetRTPPercent)}`, - label: "实际 RTP", - value: formatPPM(summary.actualRTPPPM), - }, - { - hint: `净消耗 ${formatNumber(netSpent)}`, - label: "消耗金币", - value: formatNumber(summary.totalSpentCoins), - }, - { - hint: `基础 ${formatNumber(summary.baseRewardCoins)}`, - label: "返还金币", - value: formatNumber(summary.totalRewardCoins), - }, - { - hint: `房间 ${formatNumber(summary.roomAtmosphereRewardCoins)} / 活动 ${formatNumber(summary.activitySubsidyCoins)}`, - label: "额外预算", - value: formatNumber( - Number(summary.roomAtmosphereRewardCoins || 0) + Number(summary.activitySubsidyCoins || 0), - ), - }, - ]; - - return ( -
-
-
-

抽奖数据概览

- 奖池 {summary.poolId || page.poolId} -
- -
-
- - page.changeGiftId(event.target.value)} - /> - page.changeUserId(event.target.value)} - /> - page.changeRoomId(event.target.value)} - /> - - -
- {page.drawSummaryError ? ( -
- {page.drawSummaryError} - -
- ) : ( -
- {stats.map((item) => ( -
- {item.label} - {page.drawSummaryLoading ? "-" : item.value} - {page.drawSummaryLoading ? "" : item.hint} -
- ))} -
- )} -
- ); -} - -function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) { - return ( - -
- 添加奖池 - - onChange(event.target.value)} - /> - - - - - -
-
- ); -} - -function formatPPM(value) { - return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`; -} - -function formatPercent(value) { - return `${Number(value || 0) - .toFixed(2) - .replace(/\.?0+$/, "")}%`; -} - -function formatNumber(value) { - return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); + // 文档跳转通常立即完成;保留可读状态,供慢网络和辅助技术明确当前动作。 + return

正在打开新版幸运礼物配置…

; } diff --git a/src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx b/src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx new file mode 100644 index 0000000..79bd5c2 --- /dev/null +++ b/src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx @@ -0,0 +1,14 @@ +import { expect, test, vi } from "vitest"; +import { + LUCKY_GIFT_OPS_CENTER_PATH, + redirectToLuckyGiftOpsCenter, +} from "@/features/lucky-gift/pages/LuckyGiftConfigPage.jsx"; + +test("moves the legacy lucky gift route to the ops-center document entry", () => { + const targetLocation = { replace: vi.fn() }; + + redirectToLuckyGiftOpsCenter(targetLocation); + + expect(targetLocation.replace).toHaveBeenCalledWith(LUCKY_GIFT_OPS_CENTER_PATH); + expect(LUCKY_GIFT_OPS_CENTER_PATH).toBe("/ops-center/?view=configs"); +}); diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index a75c051..0d384c3 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -229,6 +229,7 @@ export const API_OPERATIONS = { listLoginLogs: "listLoginLogs", listLuckyGiftConfigs: "listLuckyGiftConfigs", listLuckyGiftDraws: "listLuckyGiftDraws", + listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances", listManagers: "listManagers", listOperationLogs: "listOperationLogs", listPermissions: "listPermissions", @@ -1306,14 +1307,14 @@ export const API_ENDPOINTS: Record = { getLuckyGiftConfig: { method: "GET", operationId: API_OPERATIONS.getLuckyGiftConfig, - path: "/v1/admin/activity/lucky-gifts/v2/config", + path: "/v1/admin/ops-center/lucky-gifts/config", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, getLuckyGiftDrawSummary: { method: "GET", operationId: API_OPERATIONS.getLuckyGiftDrawSummary, - path: "/v1/admin/activity/lucky-gifts/v2/draw-summary", + path: "/v1/admin/ops-center/lucky-gifts/summary", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, @@ -1899,14 +1900,21 @@ export const API_ENDPOINTS: Record = { listLuckyGiftConfigs: { method: "GET", operationId: API_OPERATIONS.listLuckyGiftConfigs, - path: "/v1/admin/activity/lucky-gifts/v2/configs", + path: "/v1/admin/ops-center/lucky-gifts/configs", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, listLuckyGiftDraws: { method: "GET", operationId: API_OPERATIONS.listLuckyGiftDraws, - path: "/v1/admin/activity/lucky-gifts/v2/draws", + path: "/v1/admin/ops-center/lucky-gifts/draws", + permission: "lucky-gift:view", + permissions: ["lucky-gift:view"] + }, + listLuckyGiftPoolBalances: { + method: "GET", + operationId: API_OPERATIONS.listLuckyGiftPoolBalances, + path: "/v1/admin/ops-center/lucky-gifts/pools", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, @@ -3026,7 +3034,7 @@ export const API_ENDPOINTS: Record = { upsertLuckyGiftConfig: { method: "PUT", operationId: API_OPERATIONS.upsertLuckyGiftConfig, - path: "/v1/admin/activity/lucky-gifts/v2/config", + path: "/v1/admin/ops-center/lucky-gifts/config", permission: "lucky-gift:update", permissions: ["lucky-gift:update"] }, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index cff1343..1768226 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -244,7 +244,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/config": { + "/admin/ops-center/lucky-gifts/config": { parameters: { query?: never; header?: never; @@ -260,7 +260,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/configs": { + "/admin/ops-center/lucky-gifts/configs": { parameters: { query?: never; header?: never; @@ -276,7 +276,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/draws": { + "/admin/ops-center/lucky-gifts/draws": { parameters: { query?: never; header?: never; @@ -292,7 +292,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/draw-summary": { + "/admin/ops-center/lucky-gifts/summary": { parameters: { query?: never; header?: never; @@ -308,6 +308,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/ops-center/lucky-gifts/pools": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listLuckyGiftPoolBalances"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/activity/wheel/config": { parameters: { query?: never; @@ -7935,6 +7951,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listLuckyGiftPoolBalances: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; getWheelConfig: { parameters: { query?: never; From 4cbc980bd0b2c0c39158d5ba5d3eb21255ff4cbf Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 14:52:31 +0800 Subject: [PATCH 02/14] fix(permissions): register USDT address update --- src/app/permissions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/permissions.ts b/src/app/permissions.ts index a8904fe..9fcf5ec 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -55,6 +55,7 @@ export const PERMISSIONS = { financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create", financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify", financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant", + financeOrderUsdtAddressUpdate: "finance-order:usdt-address:update", coinLedgerView: "coin-ledger:view", coinSellerLedgerView: "coin-seller-ledger:view", giftRecordView: "gift-record:view", From 23f6b4a04c422536fad11c444b8d7e83b49da343 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 16:05:28 +0800 Subject: [PATCH 03/14] feat(admin): add external management system --- contracts/admin-openapi.json | 261 ++++++++++++ deploy/nginx.conf | 9 + external-admin/index.html | 13 + external-admin/src/ExternalAdminApp.jsx | 53 +++ external-admin/src/api/auth.js | 94 +++++ external-admin/src/api/auth.test.js | 63 +++ external-admin/src/api/business.js | 321 +++++++++++++++ external-admin/src/api/business.test.js | 187 +++++++++ external-admin/src/api/client.js | 149 +++++++ external-admin/src/api/client.test.js | 74 ++++ external-admin/src/api/normalizers.js | 59 +++ .../src/auth/ExternalAuthProvider.jsx | 73 ++++ external-admin/src/auth/RouteGuards.jsx | 58 +++ .../src/components/BannerCreateDialog.jsx | 110 +++++ .../components/BannerCreateDialog.test.jsx | 20 + .../src/components/GrantDialogs.jsx | 60 +++ .../src/components/RoomEditDialog.jsx | 30 ++ external-admin/src/components/UserDialogs.jsx | 69 ++++ external-admin/src/config/capabilities.js | 31 ++ .../src/config/entry-routing.test.js | 43 ++ .../src/layout/ExternalAdminLayout.jsx | 128 ++++++ external-admin/src/main.jsx | 27 ++ external-admin/src/pages/BannersPage.jsx | 49 +++ external-admin/src/pages/BansPage.jsx | 61 +++ .../src/pages/ChangePasswordPage.jsx | 69 ++++ .../src/pages/ChangePasswordPage.test.jsx | 47 +++ external-admin/src/pages/GrantsPage.jsx | 106 +++++ external-admin/src/pages/LoginPage.jsx | 123 ++++++ external-admin/src/pages/OrganizationPage.jsx | 70 ++++ external-admin/src/pages/OverviewPage.jsx | 50 +++ .../src/pages/OverviewPage.test.jsx | 31 ++ external-admin/src/pages/RoomsPage.jsx | 63 +++ external-admin/src/pages/UsersPage.jsx | 77 ++++ external-admin/src/shared/ConfirmDialog.jsx | 16 + .../src/shared/ExternalImageUploadField.jsx | 64 +++ external-admin/src/shared/PageComponents.jsx | 117 ++++++ external-admin/src/shared/usePagedResource.js | 41 ++ external-admin/src/styles/index.css | 299 ++++++++++++++ src/app/navigation/menu.js | 1 + src/app/navigation/menu.test.jsx | 17 + src/app/permissions.ts | 6 + src/app/router/routeConfig.ts | 2 + src/features/external-admin-users/api.test.ts | 121 ++++++ src/features/external-admin-users/api.ts | 223 +++++++++++ .../ExternalAdminUserFormDialog.jsx | 113 ++++++ .../ExternalAdminUserPasswordDialog.jsx | 55 +++ .../external-admin-users.module.css | 61 +++ .../hooks/useExternalAdminUsersPage.js | 377 ++++++++++++++++++ .../hooks/useExternalAdminUsersPage.test.jsx | 191 +++++++++ .../pages/ExternalAdminUsersPage.jsx | 217 ++++++++++ .../external-admin-users/permissions.js | 13 + src/features/external-admin-users/routes.js | 13 + .../external-admin-users/schema.test.ts | 65 +++ src/features/external-admin-users/schema.ts | 63 +++ src/features/roles/hooks/useRolesPage.js | 9 +- src/shared/api/generated/endpoints.ts | 40 ++ src/shared/api/generated/schema.d.ts | 242 +++++++++++ vite.config.js | 15 +- 58 files changed, 5056 insertions(+), 3 deletions(-) create mode 100644 external-admin/index.html create mode 100644 external-admin/src/ExternalAdminApp.jsx create mode 100644 external-admin/src/api/auth.js create mode 100644 external-admin/src/api/auth.test.js create mode 100644 external-admin/src/api/business.js create mode 100644 external-admin/src/api/business.test.js create mode 100644 external-admin/src/api/client.js create mode 100644 external-admin/src/api/client.test.js create mode 100644 external-admin/src/api/normalizers.js create mode 100644 external-admin/src/auth/ExternalAuthProvider.jsx create mode 100644 external-admin/src/auth/RouteGuards.jsx create mode 100644 external-admin/src/components/BannerCreateDialog.jsx create mode 100644 external-admin/src/components/BannerCreateDialog.test.jsx create mode 100644 external-admin/src/components/GrantDialogs.jsx create mode 100644 external-admin/src/components/RoomEditDialog.jsx create mode 100644 external-admin/src/components/UserDialogs.jsx create mode 100644 external-admin/src/config/capabilities.js create mode 100644 external-admin/src/config/entry-routing.test.js create mode 100644 external-admin/src/layout/ExternalAdminLayout.jsx create mode 100644 external-admin/src/main.jsx create mode 100644 external-admin/src/pages/BannersPage.jsx create mode 100644 external-admin/src/pages/BansPage.jsx create mode 100644 external-admin/src/pages/ChangePasswordPage.jsx create mode 100644 external-admin/src/pages/ChangePasswordPage.test.jsx create mode 100644 external-admin/src/pages/GrantsPage.jsx create mode 100644 external-admin/src/pages/LoginPage.jsx create mode 100644 external-admin/src/pages/OrganizationPage.jsx create mode 100644 external-admin/src/pages/OverviewPage.jsx create mode 100644 external-admin/src/pages/OverviewPage.test.jsx create mode 100644 external-admin/src/pages/RoomsPage.jsx create mode 100644 external-admin/src/pages/UsersPage.jsx create mode 100644 external-admin/src/shared/ConfirmDialog.jsx create mode 100644 external-admin/src/shared/ExternalImageUploadField.jsx create mode 100644 external-admin/src/shared/PageComponents.jsx create mode 100644 external-admin/src/shared/usePagedResource.js create mode 100644 external-admin/src/styles/index.css create mode 100644 src/features/external-admin-users/api.test.ts create mode 100644 src/features/external-admin-users/api.ts create mode 100644 src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx create mode 100644 src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx create mode 100644 src/features/external-admin-users/external-admin-users.module.css create mode 100644 src/features/external-admin-users/hooks/useExternalAdminUsersPage.js create mode 100644 src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx create mode 100644 src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx create mode 100644 src/features/external-admin-users/permissions.js create mode 100644 src/features/external-admin-users/routes.js create mode 100644 src/features/external-admin-users/schema.test.ts create mode 100644 src/features/external-admin-users/schema.ts diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index ce8a452..3328903 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -6133,6 +6133,125 @@ "x-permissions": ["app-user:view"] } }, + "/admin/external-admin-users": { + "get": { + "operationId": "listExternalAdminUsers", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" }, + { "$ref": "#/components/parameters/Page" }, + { "$ref": "#/components/parameters/PageSize" }, + { "$ref": "#/components/parameters/Keyword" }, + { "$ref": "#/components/parameters/Status" } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminUserPageResponse" + } + }, + "x-permission": "external-admin-user:view", + "x-permissions": ["external-admin-user:view"] + }, + "post": { + "operationId": "createExternalAdminUser", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAdminUserCreateInput" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/ExternalAdminUserResponse" + } + }, + "x-permission": "external-admin-user:create", + "x-permissions": ["external-admin-user:create"] + } + }, + "/admin/external-admin-users/target": { + "get": { + "operationId": "lookupExternalAdminUserTarget", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" }, + { + "in": "query", + "name": "display_user_id", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminUserTargetResponse" + } + }, + "x-permission": "external-admin-user:create", + "x-permissions": ["external-admin-user:create"] + } + }, + "/admin/external-admin-users/{id}/status": { + "patch": { + "operationId": "updateExternalAdminUserStatus", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" }, + { "$ref": "#/components/parameters/Id" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAdminUserStatusInput" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminUserResponse" + } + }, + "x-permission": "external-admin-user:status", + "x-permissions": ["external-admin-user:status"] + } + }, + "/admin/external-admin-users/{id}/password": { + "post": { + "operationId": "resetExternalAdminUserPassword", + "parameters": [ + { "$ref": "#/components/parameters/AppCodeHeader" }, + { "$ref": "#/components/parameters/Id" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAdminUserPasswordInput" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/ExternalAdminUserResponse" + } + }, + "x-permission": "external-admin-user:reset-password", + "x-permissions": ["external-admin-user:reset-password"] + } + }, "/exports/app-users": { "post": { "operationId": "appExportUsers", @@ -8562,6 +8681,30 @@ } } }, + "ExternalAdminUserPageResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUserPage" } + } + } + }, + "ExternalAdminUserResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUser" } + } + } + }, + "ExternalAdminUserTargetResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUserTarget" } + } + } + }, "AgencyPageResponse": { "description": "OK", "content": { @@ -9405,6 +9548,124 @@ } ] }, + "ApiResponseExternalAdminUserPage": { + "allOf": [ + { "$ref": "#/components/schemas/Envelope" }, + { + "type": "object", + "properties": { + "data": { "$ref": "#/components/schemas/ApiPageExternalAdminUser" } + } + } + ] + }, + "ApiResponseExternalAdminUser": { + "allOf": [ + { "$ref": "#/components/schemas/Envelope" }, + { + "type": "object", + "properties": { + "data": { "$ref": "#/components/schemas/ExternalAdminUser" } + } + } + ] + }, + "ApiResponseExternalAdminUserTarget": { + "allOf": [ + { "$ref": "#/components/schemas/Envelope" }, + { + "type": "object", + "properties": { + "data": { "$ref": "#/components/schemas/ExternalAdminUserTargetLookup" } + } + } + ] + }, + "ApiPageExternalAdminUser": { + "type": "object", + "required": ["items", "page", "pageSize", "total"], + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/ExternalAdminUser" } + }, + "page": { "type": "integer", "minimum": 1 }, + "pageSize": { "type": "integer", "minimum": 1 }, + "total": { "type": "integer", "format": "int64", "minimum": 0 } + } + }, + "ExternalAdminUserTarget": { + "type": "object", + "required": ["userId", "displayUserId", "status"], + "properties": { + "userId": { "type": "string" }, + "displayUserId": { "type": "string" }, + "defaultDisplayUserId": { "type": "string" }, + "prettyDisplayUserId": { "type": "string" }, + "prettyId": { "type": "string" }, + "username": { "type": "string" }, + "avatar": { "type": "string" }, + "status": { "type": "string" } + } + }, + "ExternalAdminUserTargetLookup": { + "type": "object", + "required": ["linkedUser"], + "properties": { + "linkedUser": { "$ref": "#/components/schemas/ExternalAdminUserTarget" }, + "existingExternalAdminUser": { "$ref": "#/components/schemas/ExternalAdminUser" } + } + }, + "ExternalAdminUser": { + "type": "object", + "required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "createdAtMs"], + "properties": { + "id": { "type": "integer" }, + "appCode": { "type": "string" }, + "username": { "type": "string" }, + "status": { "type": "string", "enum": ["active", "disabled"] }, + "linkedUser": { "$ref": "#/components/schemas/ExternalAdminUserTarget" }, + "permissions": { + "type": "array", + "items": { "type": "string" } + }, + "createdByAdminId": { "type": "integer" }, + "lastLoginAtMs": { "type": "integer", "format": "int64", "nullable": true }, + "createdAtMs": { "type": "integer", "format": "int64" }, + "updatedAtMs": { "type": "integer", "format": "int64" } + } + }, + "ExternalAdminUserCreateInput": { + "type": "object", + "required": ["targetUserId", "username", "password"], + "additionalProperties": false, + "properties": { + "targetUserId": { "type": "string", "minLength": 1 }, + "username": { + "type": "string", + "minLength": 3, + "maxLength": 64, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "password": { "type": "string", "minLength": 8, "maxLength": 72 } + } + }, + "ExternalAdminUserStatusInput": { + "type": "object", + "required": ["status"], + "additionalProperties": false, + "properties": { + "status": { "type": "string", "enum": ["active", "disabled"] } + } + }, + "ExternalAdminUserPasswordInput": { + "type": "object", + "required": ["password"], + "additionalProperties": false, + "properties": { + "password": { "type": "string", "minLength": 8, "maxLength": 72 } + } + }, "ApiPageAppUser": { "type": "object", "required": ["items", "page", "pageSize", "total"], diff --git a/deploy/nginx.conf b/deploy/nginx.conf index dba22a3..cee04e8 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -37,6 +37,15 @@ server { proxy_pass http://127.0.0.1:13100/readyz; } + location = /external-admin { + return 301 /external-admin/$is_args$args; + } + + # 外管使用独立 HTML 入口;深链必须回退到自己的入口,不能落入主后台的 index.html。 + location ^~ /external-admin/ { + try_files $uri $uri/ /external-admin/index.html; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/external-admin/index.html b/external-admin/index.html new file mode 100644 index 0000000..c7759b6 --- /dev/null +++ b/external-admin/index.html @@ -0,0 +1,13 @@ + + + + + + + HYApp 外管 + + +
+ + + diff --git a/external-admin/src/ExternalAdminApp.jsx b/external-admin/src/ExternalAdminApp.jsx new file mode 100644 index 0000000..aaad881 --- /dev/null +++ b/external-admin/src/ExternalAdminApp.jsx @@ -0,0 +1,53 @@ +import { lazy, Suspense } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { RequireExternalAuth, RequireExternalCapability, RequirePasswordChanged } from "./auth/RouteGuards.jsx"; +import { ExternalAdminLayout } from "./layout/ExternalAdminLayout.jsx"; +import { ExternalPageState } from "./shared/PageComponents.jsx"; + +const LoginPage = lazy(() => import("./pages/LoginPage.jsx")); +const OverviewPage = lazy(() => import("./pages/OverviewPage.jsx")); +const UsersPage = lazy(() => import("./pages/UsersPage.jsx")); +const BansPage = lazy(() => import("./pages/BansPage.jsx")); +const OrganizationPage = lazy(() => import("./pages/OrganizationPage.jsx")); +const RoomsPage = lazy(() => import("./pages/RoomsPage.jsx")); +const GrantsPage = lazy(() => import("./pages/GrantsPage.jsx")); +const BannersPage = lazy(() => import("./pages/BannersPage.jsx")); +const ChangePasswordPage = lazy(() => import("./pages/ChangePasswordPage.jsx")); + +export function ExternalAdminApp() { + return ( + }> + + } path="/login" /> + }> + } path="/change-password" /> + }> + }> + } 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 new file mode 100644 index 0000000..596ac33 --- /dev/null +++ b/external-admin/src/api/auth.js @@ -0,0 +1,94 @@ +import { externalRequest, rememberExternalCsrfToken } from "./client.js"; +import { arrayValue, asRecord, textValue } from "./normalizers.js"; + +// These external-session auth routes intentionally have no main-admin OpenAPI operation. +const EXTERNAL_AUTH_PATHS = Object.freeze({ + apps: "/auth/apps", + changePassword: "/auth/change-password", + login: "/auth/login", + logout: "/auth/logout", + me: "/auth/me" +}); + +export async function listExternalApps() { + const data = await externalRequest(EXTERNAL_AUTH_PATHS.apps, { notifyUnauthorized: false }); + const source = asRecord(data); + return arrayValue(data, source.items, source.apps) + .map((item) => normalizeApp(item)) + .filter((item) => item.appCode); +} + +export async function loginExternal(payload) { + const data = await externalRequest(EXTERNAL_AUTH_PATHS.login, { + body: { + account: String(payload.account || "").trim(), + appCode: String(payload.appCode || "").trim(), + password: String(payload.password || "") + }, + method: "POST", + notifyUnauthorized: false + }); + rememberExternalCsrfToken(asRecord(data).csrfToken); + return normalizeSession(data); +} + +export async function getExternalSession() { + const data = await externalRequest(EXTERNAL_AUTH_PATHS.me, { notifyUnauthorized: false }); + rememberExternalCsrfToken(asRecord(data).csrfToken); + return normalizeSession(data); +} + +export async function logoutExternal() { + try { + await externalRequest(EXTERNAL_AUTH_PATHS.logout, { method: "POST", notifyUnauthorized: false }); + } finally { + rememberExternalCsrfToken(""); + } +} + +export async function changeExternalPassword(payload) { + const data = await externalRequest(EXTERNAL_AUTH_PATHS.changePassword, { + body: { + currentPassword: payload.oldPassword, + newPassword: payload.newPassword + }, + method: "POST" + }); + return data ? normalizeSession(data) : null; +} + +export function normalizeSession(data) { + const root = asRecord(data); + const accountRecord = asRecord(root.account); + const linkedUser = asRecord(root.user || root.linkedUser || root.linked_user); + const legacyAccount = asRecord(root.externalAdminUser || root.external_admin_user); + const app = asRecord(root.app || linkedUser.app || accountRecord.app); + const capabilities = [ + ...arrayValue(root.capabilities), + ...arrayValue(root.permissions), + ...arrayValue(accountRecord.capabilities, accountRecord.permissions), + ...arrayValue(linkedUser.capabilities, linkedUser.permissions) + ] + .map(String) + .filter(Boolean); + + return { + 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), + 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), + passwordChangeRequired: Boolean( + root.passwordChangeRequired ?? root.password_change_required ?? accountRecord.passwordChangeRequired ?? accountRecord.password_change_required + ) + }; +} + +function normalizeApp(value) { + const item = typeof value === "string" ? { appCode: value } : asRecord(value); + return { + appCode: textValue(item.appCode, item.app_code, item.code, item.value), + name: textValue(item.name, item.appName, item.app_name, item.label, item.appCode, item.code) + }; +} diff --git a/external-admin/src/api/auth.test.js b/external-admin/src/api/auth.test.js new file mode 100644 index 0000000..b509f0a --- /dev/null +++ b/external-admin/src/api/auth.test.js @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { changeExternalPassword, listExternalApps, normalizeSession } from "./auth.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +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" }, + capabilities: ["user:list"], + passwordChangeRequired: true + })).toEqual(expect.objectContaining({ + account: "fami-manager", + appCode: "fami", + appName: "Fami", + capabilities: ["user:list"], + passwordChangeRequired: true + })); + }); + + test("merges permission aliases from root and bound user", () => { + const session = normalizeSession({ + appCode: "lalu", + permissions: ["host:list"], + user: { permissions: ["team:view"], username: "lalu-ops" } + }); + expect(session.capabilities).toEqual(["host:list", "team:view"]); + }); + + test("keeps the external account separate from the linked App user", () => { + const session = normalizeSession({ + account: { id: "external-account-8", username: "fami-manager" }, + app: { appCode: "fami", appName: "Fami" }, + user: { userId: "app-user-9", username: "App Nickname" } + }); + expect(session.account).toBe("fami-manager"); + expect(session.displayName).toBe("App Nickname"); + expect(session.id).toBe("external-account-8"); + }); +}); + +describe("external auth API contract", () => { + test("reads appName from auth/apps items", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: 0, data: { items: [{ appCode: "fami", appName: "Fami" }], total: 1 } }))); + await expect(listExternalApps()).resolves.toEqual([{ appCode: "fami", name: "Fami" }]); + }); + + test("change-password sends currentPassword and never oldPassword", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} })); + vi.stubGlobal("fetch", fetchMock); + + await changeExternalPassword({ newPassword: "new-password", oldPassword: "current-password" }); + + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ currentPassword: "current-password", newPassword: "new-password" }); + }); +}); + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status }); +} diff --git a/external-admin/src/api/business.js b/external-admin/src/api/business.js new file mode 100644 index 0000000..e42aaff --- /dev/null +++ b/external-admin/src/api/business.js @@ -0,0 +1,321 @@ +import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js"; +import { externalRequest } from "./client.js"; +import { asRecord, booleanValue, entityId, normalizePage, numberValue, textValue } from "./normalizers.js"; + +// my-team is external-session scoped and is not part of the main-admin OpenAPI contract yet. +const EXTERNAL_ONLY_PATHS = Object.freeze({ myTeamAgencies: "/admin/my-team/agencies" }); + +export function listUsers(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.appListUsers), { query }).then((data) => normalizePage(data, normalizeUser)); +} + +export function listBannedUsers(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.appListBannedUsers), { query }).then((data) => normalizePage(data, normalizeBannedUser)); +} + +export function updateUser(userId, payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.appUpdateUser, { id: userId }), { body: payload, method: "PATCH" }); +} + +export function banUser(userId, payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.appBanUser, { id: userId }), { body: payload, method: "POST" }); +} + +export function unbanUser(userId, payload = {}) { + return externalRequest(externalOperationPath(API_OPERATIONS.appUnbanUser, { id: userId }), { body: payload, method: "POST" }); +} + +export function updateUserLevels(userId, payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.appAdjustUserLevels, { id: userId }), { body: payload, method: "PUT" }); +} + +export async function grantUserVip(targetUserId, payload) { + const [programData, levelsData] = await Promise.all([ + externalRequest(externalOperationPath(API_OPERATIONS.getVipProgram)), + externalRequest(externalOperationPath(API_OPERATIONS.getVipConfig)) + ]); + const programRoot = asRecord(programData); + const program = asRecord(programRoot.config || programRoot.programConfig || programRoot.program_config || programRoot); + const levelsRoot = asRecord(levelsData); + const levels = Array.isArray(levelsData) ? levelsData : levelsRoot.levels || []; + const level = Number(payload.level); + + if (program.status === "disabled") { + throw new Error("当前 App 的 VIP 体系已停用"); + } + if (Array.isArray(levels) && levels.length && !levels.some((item) => Number(item.level) === level)) { + throw new Error("请选择当前 App 已配置的 VIP 等级"); + } + + const command = { + commandId: payload.commandId, + level, + reason: payload.reason, + targetUserId + }; + if (program.grantMode === "trial_card" || program.grant_mode === "trial_card") { + return externalRequest(externalOperationPath(API_OPERATIONS.grantVipTrialCard), { + body: { ...command, durationMs: Number(payload.durationMs) }, + method: "POST" + }); + } + return externalRequest(externalOperationPath(API_OPERATIONS.grantVip), { body: command, method: "POST" }); +} + +export function listOrganizations(kind, query = {}) { + const routes = { + agencies: externalOperationPath(API_OPERATIONS.listAgencies), + bds: externalOperationPath(API_OPERATIONS.listBDs), + "bd-leaders": externalOperationPath(API_OPERATIONS.listManagers), + hosts: externalOperationPath(API_OPERATIONS.listHosts), + managers: externalOperationPath(API_OPERATIONS.listManagers), + "super-admins": externalOperationPath(API_OPERATIONS.listBDLeaders), + team: EXTERNAL_ONLY_PATHS.myTeamAgencies + }; + if (!routes[kind]) { + throw new Error("该组织列表尚无安全的数据接口"); + } + const scopedQuery = kind === "super-admins" ? { ...query, status: query.status || "active" } : query; + return externalRequest(routes[kind], { query: scopedQuery }).then((data) => { + const page = normalizePage(data, normalizeOrganization); + if (kind !== "team") { + return page; + } + const source = asRecord(data); + return { + ...page, + totalBdLeaders: numberValue(source.totalBdLeaders ?? source.total_bd_leaders), + totalBds: numberValue(source.totalBds ?? source.total_bds), + truncated: booleanValue(source.truncated) + }; + }); +} + +export function listRooms(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.listRooms), { query }).then((data) => normalizePage(data, normalizeRoom)); +} + +export function updateRoom(roomId, payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.updateRoom, { room_id: roomId }), { body: buildRoomUpdatePayload(payload), method: "PATCH" }); +} + +export function buildRoomUpdatePayload(payload) { + const regionText = String(payload.visibleRegionId ?? "").trim(); + const visibleRegionId = regionText ? Number(regionText) : 0; + if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) { + throw new Error("可见区域 ID 必须是安全的正整数"); + } + return { + coverUrl: String(payload.coverUrl || "").trim(), + description: String(payload.description || "").trim(), + title: String(payload.title || "").trim(), + visibleRegionId + }; +} + +export function listResources(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.listResources), { query }).then((data) => normalizePage(data, normalizeResource)); +} + +export function listResourceGrants(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.listResourceGrants), { query }).then((data) => normalizePage(data)); +} + +export async function lookupGrantTarget(userIdOrPrettyId) { + const data = await externalRequest(externalOperationPath(API_OPERATIONS.lookupResourceGrantTarget), { query: { user_id: userIdOrPrettyId } }); + const source = asRecord(data); + const user = asRecord(source.user || source.targetUser || source.target_user); + return { + id: textValue(source.targetUserId, source.target_user_id, source.userId, source.user_id, user.id, user.userId, user.user_id), + prettyId: textValue(source.prettyId, source.pretty_id, user.prettyId, user.pretty_id, user.displayUserId, user.display_user_id), + username: textValue(source.username, source.nickname, user.username, user.nickname) + }; +} + +export function grantResource(payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.grantResource), { body: payload, method: "POST" }); +} + +export function grantPrettyId(payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.grantPrettyId), { body: payload, method: "POST" }); +} + +export function listBanners(query) { + return externalRequest(externalOperationPath(API_OPERATIONS.listBanners), { query }).then((data) => normalizePage(data, normalizeBanner)); +} + +export function createBanner(payload) { + return externalRequest(externalOperationPath(API_OPERATIONS.createBanner), { body: buildBannerPayload(payload), method: "POST" }); +} + +export function buildBannerPayload(payload) { + if (!["h5", "app"].includes(payload.bannerType)) { + throw new Error("Banner 类型不正确"); + } + if (!["android", "ios"].includes(payload.platform)) { + throw new Error("Banner 平台不正确"); + } + if (!["active", "disabled", "expired"].includes(payload.status)) { + throw new Error("Banner 状态不正确"); + } + const displayScopes = [...new Set((payload.displayScopes || []).filter((scope) => ["home", "room", "recharge", "me"].includes(scope)))]; + if (!displayScopes.length) { + throw new Error("请至少选择一个展示范围"); + } + if (!payload.coverUrl) { + throw new Error("请上传 Banner 图片"); + } + if (payload.bannerType === "h5") { + let target; + try { + target = new URL(payload.param); + } catch { + throw new Error("请填写合法的 H5 链接"); + } + if (!["http:", "https:"].includes(target.protocol)) { + throw new Error("H5 链接仅支持 http 或 https"); + } + } + if (payload.bannerType === "app") { + const validation = validatePublicAppJumpParam(payload.param); + if (!validation.valid) { + throw new Error(validation.message); + } + } + if (displayScopes.includes("room") && !payload.roomSmallImageUrl) { + throw new Error("房间范围必须上传房间内小屏图"); + } + const countryCode = String(payload.countryCode || "").trim().toUpperCase(); + if (countryCode && !/^[A-Z]{2,3}$/.test(countryCode)) { + throw new Error("国家码必须是 2-3 位字母"); + } + const regionId = Number(payload.regionId || 0); + if (!Number.isSafeInteger(regionId) || regionId < 0) { + throw new Error("区域 ID 不正确"); + } + const startsAtMs = Number(payload.startsAtMs || 0); + const endsAtMs = Number(payload.endsAtMs || 0); + if (startsAtMs > 0 && endsAtMs > 0 && startsAtMs >= endsAtMs) { + throw new Error("投放结束时间必须晚于开始时间"); + } + + return { + bannerType: payload.bannerType, + countryCode, + coverUrl: payload.coverUrl, + description: String(payload.description || "").trim(), + displayScope: displayScopes[0], + displayScopes, + endsAtMs, + param: String(payload.param || "").trim(), + platform: payload.platform, + regionId, + roomSmallImageUrl: displayScopes.includes("room") ? payload.roomSmallImageUrl : "", + sortOrder: Number(payload.sortOrder || 0), + startsAtMs, + status: payload.status + }; +} + +export async function uploadExternalImage(file) { + const body = new FormData(); + body.append("file", file); + const data = await externalRequest(externalOperationPath(API_OPERATIONS.uploadImage), { body, method: "POST" }); + const source = asRecord(data); + return textValue(source.url, source.fileUrl, source.file_url, asRecord(source.file).url); +} + +export function normalizeUser(item) { + const levels = asRecord(item.levels); + const wealth = asRecord(levels.wealth); + const charm = asRecord(levels.charm); + const vip = asRecord(item.vip); + const ban = asRecord(item.ban); + return { + avatar: textValue(item.avatar, item.avatarUrl, item.avatar_url), + banned: booleanValue(ban.active, item.banned, item.isBanned, item.is_banned) || ["banned", "disabled"].includes(String(item.status).toLowerCase()), + charmLevel: numberValue(item.charmLevel ?? item.charm_level ?? charm.displayLevel ?? charm.display_level ?? charm.level), + country: textValue(item.country, item.countryCode, item.country_code), + gender: textValue(item.gender), + id: entityId(item), + prettyId: textValue(item.prettyId, item.pretty_id, item.displayUserId, item.display_user_id, item.shortId, item.short_id), + status: textValue(item.status, item.banned ? "banned" : "active"), + username: textValue(item.username, item.nickname, item.nickName, item.name), + vipLevel: numberValue(item.vipLevel ?? item.vip_level ?? vip.level), + wealthLevel: numberValue(item.wealthLevel ?? item.wealth_level ?? wealth.displayLevel ?? wealth.display_level ?? wealth.level) + }; +} + +export function normalizeBannedUser(item) { + // Ban-list rows use the ban-log id as `id`; unban must always target the nested App user id instead. + const target = asRecord(item.target || item.user || item.targetUser || item.target_user); + const user = normalizeUser(target); + return { + ...user, + banned: true, + bannedAtMs: numberValue(item.bannedAtMs ?? item.banned_at_ms ?? item.createdAtMs ?? item.created_at_ms), + banLogId: textValue(item.id, item.banLogId, item.ban_log_id), + expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms), + id: textValue(target.userId, target.user_id, target.id), + prettyId: textValue(target.displayUserId, target.display_user_id, target.prettyId, target.pretty_id), + reason: textValue(item.reason) + }; +} + +export function normalizeOrganization(item) { + const user = asRecord(item.user || item.profile || item.manager); + const agency = Boolean(item.agencyId ?? item.agency_id); + return { + avatar: textValue(agency ? item.ownerAvatar : "", agency ? item.owner_avatar : "", item.avatar, item.avatarUrl, item.avatar_url, user.avatar), + appCode: textValue(item.appCode, item.app_code), + country: textValue(item.regionName, item.region_name, item.country, item.countryCode, item.country_code, user.regionName, user.region_name, user.country), + id: textValue(item.agencyId, item.agency_id, item.userId, item.user_id, item.id, entityId(user)), + name: textValue(item.name, agency ? item.ownerUsername : "", item.displayName, item.display_name, item.username, user.username, user.name), + parentBdUserId: textValue(item.parentBdUserId, item.parent_bd_user_id), + prettyId: textValue(agency ? item.ownerDisplayUserId : "", agency ? item.owner_display_user_id : "", item.displayUserId, item.display_user_id, item.prettyId, item.pretty_id, user.displayUserId, user.display_user_id, user.prettyId, user.pretty_id), + status: textValue(item.status, item.enabled === false ? "disabled" : "active"), + username: textValue(agency ? item.ownerUsername : "", agency ? item.owner_username : "", item.username, user.username, user.nickname), + userId: textValue(agency ? item.ownerUserId : "", agency ? item.owner_user_id : "", item.userId, item.user_id, user.userId, user.user_id) + }; +} + +function normalizeRoom(item) { + return { + coverUrl: textValue(item.coverUrl, item.cover_url, item.backgroundUrl, item.background_url), + description: textValue(item.description, item.introduction), + id: entityId(item), + ownerName: textValue(item.ownerName, item.owner_name, asRecord(item.owner).username), + status: textValue(item.status, item.liveStatus, item.live_status), + title: textValue(item.title, item.name, item.roomName, item.room_name), + visibleRegionId: textValue(item.visibleRegionId, item.visible_region_id) + }; +} + +function normalizeResource(item) { + return { + coverUrl: textValue(item.coverUrl, item.cover_url, item.iconUrl, item.icon_url), + id: textValue(item.resourceId, item.resource_id, item.id), + name: textValue(item.name, item.resourceName, item.resource_name, item.resourceCode, item.resource_code), + status: textValue(item.status), + type: textValue(item.resourceType, item.resource_type, item.type) + }; +} + +function normalizeBanner(item) { + return { + bannerType: textValue(item.bannerType, item.banner_type, item.type), + coverUrl: textValue(item.coverUrl, item.cover_url, item.imageUrl, item.image_url), + createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), + description: textValue(item.description, item.title), + id: textValue(item.id, item.bannerId, item.banner_id), + platform: textValue(item.platform), + sortOrder: numberValue(item.sortOrder ?? item.sort_order), + status: textValue(item.status) + }; +} + +function externalOperationPath(operation, params) { + // Generated main-admin paths start with /v1; the external gateway already owns that version prefix in its base URL. + return apiEndpointPath(operation, params).replace(/^\/v1(?=\/)/, ""); +} diff --git a/external-admin/src/api/business.test.js b/external-admin/src/api/business.test.js new file mode 100644 index 0000000..f7df65e --- /dev/null +++ b/external-admin/src/api/business.test.js @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { buildBannerPayload, buildRoomUpdatePayload, createBanner, grantUserVip, listOrganizations, normalizeBannedUser, normalizeOrganization, normalizeUser, unbanUser } from "./business.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("normalizeBannedUser", () => { + test("uses nested target user id instead of ban log id for unban actions", () => { + const item = normalizeBannedUser({ + id: "ban-log-9001", + reason: "spam", + target: { + avatar: "https://cdn.test/avatar.png", + displayUserId: "163000", + userId: "318705991371722752", + username: "Fami User" + } + }); + + expect(item.id).toBe("318705991371722752"); + expect(item.banLogId).toBe("ban-log-9001"); + expect(item.prettyId).toBe("163000"); + expect(item.reason).toBe("spam"); + }); + + test("unban request uses the normalized target user id", async () => { + const user = normalizeBannedUser({ id: "ban-log-1", target: { userId: "canonical-user-2" } }); + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} })); + vi.stubGlobal("fetch", fetchMock); + + await unbanUser(user.id, { reason: "manual" }); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/app/users/canonical-user-2/unban"); + }); +}); + +describe("room API payload", () => { + test("converts region id to int64-compatible number and maps empty to all regions", () => { + expect(buildRoomUpdatePayload({ coverUrl: " cover ", description: " desc ", title: " Room ", visibleRegionId: "18" })) + .toEqual({ coverUrl: "cover", description: "desc", title: "Room", visibleRegionId: 18 }); + expect(buildRoomUpdatePayload({ visibleRegionId: "" }).visibleRegionId).toBe(0); + expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("可见区域 ID"); + }); +}); + +describe("real external DTO normalization", () => { + test("reads nested levels, vip and ban state from the App user DTO", () => { + const user = normalizeUser({ + ban: { active: true }, + levels: { charm: { displayLevel: 8 }, wealth: { displayLevel: 12 } }, + userId: "user-1", + vip: { level: 4 } + }); + expect(user).toEqual(expect.objectContaining({ banned: true, charmLevel: 8, vipLevel: 4, wealthLevel: 12 })); + }); + + test("normalizes agency owner fields and ordinary member fields", () => { + expect(normalizeOrganization({ + agencyId: "agency-1", + name: "Fami Guild", + ownerAvatar: "owner.png", + ownerDisplayUserId: "163000", + ownerUserId: "owner-9", + ownerUsername: "Guild Owner", + regionName: "Middle East" + })).toEqual(expect.objectContaining({ + avatar: "owner.png", + id: "agency-1", + name: "Fami Guild", + prettyId: "163000", + userId: "owner-9", + username: "Guild Owner" + })); + expect(normalizeOrganization({ displayUserId: "9988", regionName: "SEA", userId: "bd-1", username: "BD One" })) + .toEqual(expect.objectContaining({ id: "bd-1", name: "BD One", prettyId: "9988" })); + }); + + test("maps BD Manager, Super Admin and team to their real scoped endpoints", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { items: [], total: 0 } })); + vi.stubGlobal("fetch", fetchMock); + + await listOrganizations("bd-leaders", { page: 1 }); + await listOrganizations("super-admins", { page: 1 }); + await listOrganizations("team", { page: 1 }); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "/api/v1/external/admin/managers?page=1", + "/api/v1/external/admin/bd-leaders?page=1&status=active", + "/api/v1/external/admin/my-team/agencies?page=1" + ]); + }); + + test("preserves scoped team totals and truncation metadata", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ + code: 0, + data: { + agencies: [{ agencyId: "agency-9", ownerUserId: "owner-9", parentBdUserId: "bd-7", status: "active" }], + total: 1, + totalBdLeaders: 2, + totalBds: 7, + truncated: true + } + }))); + + const team = await listOrganizations("team", { page: 1 }); + + expect(team).toEqual(expect.objectContaining({ total: 1, totalBdLeaders: 2, totalBds: 7, truncated: true })); + expect(team.items[0]).toEqual(expect.objectContaining({ id: "agency-9", parentBdUserId: "bd-7", userId: "owner-9" })); + }); +}); + +describe("banner API payload", () => { + test("uses only the real appconfig banner enums and room image contract", () => { + expect(buildBannerPayload({ + bannerType: "h5", + countryCode: "sa", + coverUrl: "https://cdn.test/banner.webp", + description: "Campaign", + displayScopes: ["home", "room"], + param: "https://fami.test/campaign", + platform: "android", + regionId: 8, + roomSmallImageUrl: "https://cdn.test/room.webp", + sortOrder: 2, + status: "active" + })).toEqual(expect.objectContaining({ + bannerType: "h5", + countryCode: "SA", + displayScope: "home", + displayScopes: ["home", "room"], + platform: "android", + roomSmallImageUrl: "https://cdn.test/room.webp", + status: "active" + })); + expect(() => buildBannerPayload({ bannerType: "link", coverUrl: "x", displayScopes: ["home"], platform: "all", status: "enabled" })) + .toThrow("Banner 类型不正确"); + }); + + test("create API sends the normalized appconfig payload", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} })); + vi.stubGlobal("fetch", fetchMock); + await createBanner({ + bannerType: "app", + coverUrl: "https://cdn.test/banner.webp", + displayScopes: ["me"], + param: JSON.stringify({ type: "wallet" }), + platform: "ios", + status: "disabled" + }); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/admin/app-config/banners"); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual(expect.objectContaining({ + bannerType: "app", + displayScope: "me", + displayScopes: ["me"], + platform: "ios", + status: "disabled" + })); + }); +}); + +describe("VIP grant routing", () => { + test("routes trial-card VIP grants to vipconfig and never sends track=vip to app users", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ code: 0, data: { config: { grantMode: "trial_card", status: "active" } } })) + .mockResolvedValueOnce(jsonResponse({ code: 0, data: { levels: [{ level: 5 }] } })) + .mockResolvedValueOnce(jsonResponse({ code: 0, data: {} })); + vi.stubGlobal("fetch", fetchMock); + + await grantUserVip("user-9", { commandId: "vip-test", durationMs: 604800000, level: 5, reason: "manual" }); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "/api/v1/external/admin/activity/vip-program", + "/api/v1/external/admin/activity/vip-levels", + "/api/v1/external/admin/activity/vip-trial-card-grants" + ]); + const payload = JSON.parse(fetchMock.mock.calls[2][1].body); + expect(payload).toEqual(expect.objectContaining({ durationMs: 604800000, level: 5, targetUserId: "user-9" })); + expect(payload).not.toHaveProperty("track"); + }); +}); + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status }); +} diff --git a/external-admin/src/api/client.js b/external-admin/src/api/client.js new file mode 100644 index 0000000..507b791 --- /dev/null +++ b/external-admin/src/api/client.js @@ -0,0 +1,149 @@ +const EXTERNAL_API_BASE = "/api/v1/external"; +const CSRF_COOKIE_NAME = "hyapp_external_csrf"; +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +let unauthorizedHandler = null; +let inMemoryCsrfToken = ""; + +export class ExternalApiError extends Error { + constructor(message, { code = "", status = 0, details = null } = {}) { + super(message); + this.name = "ExternalApiError"; + this.code = code; + this.status = status; + this.details = details; + } +} + +export function setExternalUnauthorizedHandler(handler) { + unauthorizedHandler = typeof handler === "function" ? handler : null; + return () => { + if (unauthorizedHandler === handler) { + unauthorizedHandler = null; + } + }; +} + +export function rememberExternalCsrfToken(token) { + // Cookie remains the source of truth. The memory copy only covers deployments where Cookie Path prevents page JavaScript from reading it. + inMemoryCsrfToken = String(token || "").trim(); +} + +export function readExternalCsrfToken(cookieText = globalThis.document?.cookie || "") { + const prefix = `${CSRF_COOKIE_NAME}=`; + const cookieValue = String(cookieText) + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)); + + if (!cookieValue) { + return inMemoryCsrfToken; + } + + try { + return decodeURIComponent(cookieValue.slice(prefix.length)); + } catch { + return cookieValue.slice(prefix.length); + } +} + +export async function externalRequest(path, options = {}) { + const method = String(options.method || "GET").toUpperCase(); + const headers = new Headers(options.headers || {}); + const body = createBody(options.body, headers); + + if (!SAFE_METHODS.has(method)) { + const csrfToken = readExternalCsrfToken(); + if (csrfToken) { + headers.set("X-CSRF-Token", csrfToken); + } + } + + const response = await fetch(buildUrl(path, options.query), { + body, + credentials: "include", + headers, + method, + signal: options.signal + }); + const payload = await readPayload(response); + + if (response.status === 401 && options.notifyUnauthorized !== false) { + unauthorizedHandler?.(); + } + + if (!response.ok) { + throw apiError(payload, response.status, response.statusText || "请求失败"); + } + + if (isEnvelopeFailure(payload)) { + throw apiError(payload, response.status, "请求失败"); + } + + const data = unwrapPayload(payload); + if (data && typeof data === "object" && "csrfToken" in data) { + rememberExternalCsrfToken(data.csrfToken); + } + return data; +} + +function buildUrl(path, query) { + const normalizedPath = String(path || "").startsWith("/") ? path : `/${path}`; + const params = new URLSearchParams(); + + Object.entries(query || {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === "") { + return; + } + (Array.isArray(value) ? value : [value]).forEach((item) => params.append(key, String(item))); + }); + + const search = params.toString(); + return `${EXTERNAL_API_BASE}${normalizedPath}${search ? `?${search}` : ""}`; +} + +function createBody(body, headers) { + if (body === undefined || body === null) { + return undefined; + } + if (body instanceof FormData || typeof body === "string" || body instanceof Blob) { + return body; + } + headers.set("Content-Type", "application/json"); + return JSON.stringify(body); +} + +async function readPayload(response) { + if (response.status === 204) { + return null; + } + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + return response.json(); + } + const text = await response.text(); + return text ? { message: text } : null; +} + +function isEnvelopeFailure(payload) { + if (!payload || typeof payload !== "object" || !("code" in payload)) { + return false; + } + return ![0, 200, "0", "200", "OK", "SUCCESS"].includes(payload.code); +} + +function unwrapPayload(payload) { + if (!payload || typeof payload !== "object") { + return payload; + } + return "data" in payload ? payload.data : payload; +} + +function apiError(payload, status, fallback) { + const source = payload && typeof payload === "object" ? payload : {}; + return new ExternalApiError(source.message || source.msg || fallback, { + code: source.code, + details: source.details || source.errors || null, + status + }); +} diff --git a/external-admin/src/api/client.test.js b/external-admin/src/api/client.test.js new file mode 100644 index 0000000..93fd1bb --- /dev/null +++ b/external-admin/src/api/client.test.js @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + ExternalApiError, + externalRequest, + readExternalCsrfToken, + rememberExternalCsrfToken, + setExternalUnauthorizedHandler +} from "./client.js"; + +describe("externalRequest", () => { + beforeEach(() => { + rememberExternalCsrfToken(""); + document.cookie = "hyapp_external_csrf=; Max-Age=0; Path=/"; + }); + + afterEach(() => { + setExternalUnauthorizedHandler(null); + vi.unstubAllGlobals(); + }); + + test("uses only the isolated cookie session and external API base", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { items: [] } })); + vi.stubGlobal("fetch", fetchMock); + + await externalRequest("/app/users", { query: { keyword: "163000", page: 1 } }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/external/app/users?keyword=163000&page=1"); + expect(init.credentials).toBe("include"); + expect(init.headers.get("Authorization")).toBeNull(); + expect(init.headers.get("X-App-Code")).toBeNull(); + expect(init.headers.get("X-CSRF-Token")).toBeNull(); + }); + + test("adds CSRF to writes from the dedicated cookie", async () => { + document.cookie = "hyapp_external_csrf=csrf%20token; Path=/"; + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { ok: true } })); + vi.stubGlobal("fetch", fetchMock); + + await externalRequest("/auth/change-password", { body: { currentPassword: "old", newPassword: "new" }, method: "POST" }); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.headers.get("X-CSRF-Token")).toBe("csrf token"); + expect(JSON.parse(init.body)).toEqual({ currentPassword: "old", newPassword: "new" }); + }); + + test("keeps csrfToken in memory when the API-scoped cookie is unreadable", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ code: 0, data: { csrfToken: "memory-csrf", user: { username: "operator" } } })) + .mockResolvedValueOnce(jsonResponse({ code: 0, data: {} })); + vi.stubGlobal("fetch", fetchMock); + + await externalRequest("/auth/me"); + await externalRequest("/auth/logout", { method: "POST" }); + + expect(fetchMock.mock.calls[1][1].headers.get("X-CSRF-Token")).toBe("memory-csrf"); + expect(readExternalCsrfToken("")).toBe("memory-csrf"); + }); + + test("invalidates the external session on protected 401", async () => { + const handler = vi.fn(); + setExternalUnauthorizedHandler(handler); + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: "UNAUTHORIZED", message: "请登录" }, 401))); + + await expect(externalRequest("/admin/rooms")).rejects.toEqual(expect.objectContaining({ status: 401 })); + expect(handler).toHaveBeenCalledTimes(1); + expect(new ExternalApiError("x")).toBeInstanceOf(Error); + }); +}); + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status }); +} diff --git a/external-admin/src/api/normalizers.js b/external-admin/src/api/normalizers.js new file mode 100644 index 0000000..c03506f --- /dev/null +++ b/external-admin/src/api/normalizers.js @@ -0,0 +1,59 @@ +export function normalizePage(data, mapItem = normalizeRecord) { + const source = asRecord(data); + const rawItems = Array.isArray(data) + ? data + : arrayValue(source.items, source.agencies, source.list, source.records, source.content, source.rows); + const page = positiveNumber(source.page ?? source.page_no ?? source.pageNo, 1); + const pageSize = positiveNumber(source.pageSize ?? source.page_size ?? source.size, rawItems.length || 20); + + return { + items: rawItems.map((item) => mapItem(asRecord(item))), + page, + pageSize, + total: nonNegativeNumber(source.total ?? source.total_count ?? source.totalCount, rawItems.length) + }; +} + +export function normalizeRecord(value) { + return asRecord(value); +} + +export function asRecord(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +export function arrayValue(...values) { + return values.find(Array.isArray) || []; +} + +export function textValue(...values) { + const value = values.find((item) => item !== undefined && item !== null && item !== ""); + return value === undefined ? "" : String(value); +} + +export function numberValue(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function booleanValue(...values) { + const value = values.find((item) => item !== undefined && item !== null); + if (typeof value === "string") { + return ["1", "true", "enabled", "active", "yes"].includes(value.toLowerCase()); + } + return Boolean(value); +} + +export function entityId(item) { + return textValue(item.id, item.userId, item.user_id, item.roomId, item.room_id, item.resourceId, item.resource_id); +} + +function positiveNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function nonNegativeNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} diff --git a/external-admin/src/auth/ExternalAuthProvider.jsx b/external-admin/src/auth/ExternalAuthProvider.jsx new file mode 100644 index 0000000..b7de90d --- /dev/null +++ b/external-admin/src/auth/ExternalAuthProvider.jsx @@ -0,0 +1,73 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { changeExternalPassword, getExternalSession, loginExternal, logoutExternal } from "../api/auth.js"; +import { ExternalApiError, setExternalUnauthorizedHandler } from "../api/client.js"; + +const ExternalAuthContext = createContext(null); + +export function ExternalAuthProvider({ children }) { + const [session, setSession] = useState(null); + const [initializing, setInitializing] = useState(true); + const [initializationError, setInitializationError] = useState(""); + + const refresh = useCallback(async () => { + setInitializationError(""); + try { + const nextSession = await getExternalSession(); + setSession(nextSession); + return nextSession; + } catch (error) { + setSession(null); + if (!(error instanceof ExternalApiError && error.status === 401)) { + setInitializationError(error.message || "会话校验失败"); + } + return null; + } finally { + setInitializing(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + useEffect(() => { + // Any protected request returning 401 invalidates the entire external session; it never mutates the main-admin session. + return setExternalUnauthorizedHandler(() => setSession(null)); + }, []); + + const login = useCallback(async (credentials) => { + const nextSession = await loginExternal(credentials); + setSession(nextSession); + return nextSession; + }, []); + + const logout = useCallback(async () => { + try { + await logoutExternal(); + } finally { + setSession(null); + } + }, []); + + const changePassword = useCallback(async (payload) => { + const responseSession = await changeExternalPassword(payload); + const nextSession = responseSession?.appCode ? responseSession : await getExternalSession(); + setSession({ ...nextSession, passwordChangeRequired: false }); + return nextSession; + }, []); + + const value = useMemo( + () => ({ changePassword, initializationError, initializing, login, logout, refresh, session }), + [changePassword, initializationError, initializing, login, logout, refresh, session] + ); + + return {children}; +} + +export function useExternalAuth() { + const context = useContext(ExternalAuthContext); + if (!context) { + throw new Error("useExternalAuth must be used inside ExternalAuthProvider"); + } + return context; +} diff --git a/external-admin/src/auth/RouteGuards.jsx b/external-admin/src/auth/RouteGuards.jsx new file mode 100644 index 0000000..e47933a --- /dev/null +++ b/external-admin/src/auth/RouteGuards.jsx @@ -0,0 +1,58 @@ +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import CircularProgress from "@mui/material/CircularProgress"; +import Typography from "@mui/material/Typography"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { hasAnyExternalCapability } from "../config/capabilities.js"; +import { useExternalAuth } from "./ExternalAuthProvider.jsx"; + +export function RequireExternalAuth() { + const { initializationError, initializing, refresh, session } = useExternalAuth(); + const location = useLocation(); + + if (initializing) { + return ( + + + 正在校验外管会话 + + ); + } + + if (!session) { + if (initializationError) { + return ( + + {initializationError} + + + ); + } + return ; + } + + return ; +} + +export function RequirePasswordChanged() { + const { session } = useExternalAuth(); + return session?.passwordChangeRequired ? : ; +} + +export function RequireExternalCapability({ anyOf }) { + const { session } = useExternalAuth(); + + if (hasAnyExternalCapability(session, anyOf)) { + return ; + } + + return ( + + 当前外管账号没有访问此功能的权限。 + + + ); +} diff --git a/external-admin/src/components/BannerCreateDialog.jsx b/external-admin/src/components/BannerCreateDialog.jsx new file mode 100644 index 0000000..98ee4ad --- /dev/null +++ b/external-admin/src/components/BannerCreateDialog.jsx @@ -0,0 +1,110 @@ +import Button from "@mui/material/Button"; +import Checkbox from "@mui/material/Checkbox"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import ListItemText from "@mui/material/ListItemText"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { useEffect, useState } from "react"; +import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; +import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; + +const displayScopeOptions = [ + ["home", "首页"], + ["room", "房间"], + ["recharge", "充值"], + ["me", "我的"] +]; + +export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { + const [form, setForm] = useState(initialForm); + useEffect(() => setForm(initialForm), [open]); + + const submit = (event) => { + event.preventDefault(); + onSubmit({ + ...form, + regionId: Number(form.regionId || 0), + roomSmallImageUrl: form.displayScopes.includes("room") ? form.roomSmallImageUrl : "", + sortOrder: Number(form.sortOrder || 0) + }); + }; + + return ( + + + 创建 Banner + + + setForm({ ...form, coverUrl })} /> + {form.displayScopes.includes("room") ? setForm({ ...form, roomSmallImageUrl })} /> : null} + setForm({ ...form, platform: event.target.value })}> + Android + iOS + + setForm({ ...form, description: event.target.value })} /> + setForm({ ...form, bannerType: type, param })} + /> + selected.map((value) => displayScopeOptions.find(([key]) => key === value)?.[1] || value).join("、") } }} + value={form.displayScopes} + onChange={(event) => setForm({ ...form, displayScopes: typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value })} + > + {displayScopeOptions.map(([value, label]) => )} + + + setForm({ ...form, regionId: event.target.value })} /> + setForm({ ...form, countryCode: event.target.value.toUpperCase() })} /> + setForm({ ...form, sortOrder: event.target.value })} /> + + setForm({ ...form, endsAtMs: endMs, startsAtMs: startMs })} + /> + setForm({ ...form, status: event.target.value })}> + 启用 + 禁用 + 过期 + + + + + + + ); +} + +const initialForm = { + bannerType: "h5", + countryCode: "", + coverUrl: "", + description: "", + displayScopes: ["home"], + endsAtMs: 0, + param: "", + platform: "android", + regionId: "", + roomSmallImageUrl: "", + sortOrder: "0", + startsAtMs: 0, + status: "active" +}; diff --git a/external-admin/src/components/BannerCreateDialog.test.jsx b/external-admin/src/components/BannerCreateDialog.test.jsx new file mode 100644 index 0000000..54b4fd8 --- /dev/null +++ b/external-admin/src/components/BannerCreateDialog.test.jsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, test, vi } from "vitest"; +import { BannerCreateDialog } from "./BannerCreateDialog.jsx"; + +describe("BannerCreateDialog", () => { + test("uses the shared structured app jump field and unified time range picker", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("textbox", { name: "参数(H5 链接)" })).toBeInTheDocument(); + expect(screen.getByText("投放时间")).toBeInTheDocument(); + + await user.click(screen.getByRole("combobox", { name: "跳转类型" })); + await user.click(await screen.findByRole("option", { name: "APP" })); + + expect(screen.getByRole("combobox", { name: "APP 目标" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "参数(APP 公共跳转 JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" })); + }); +}); diff --git a/external-admin/src/components/GrantDialogs.jsx b/external-admin/src/components/GrantDialogs.jsx new file mode 100644 index 0000000..ab6d34d --- /dev/null +++ b/external-admin/src/components/GrantDialogs.jsx @@ -0,0 +1,60 @@ +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { useEffect, useState } from "react"; + +export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resources }) { + const [form, setForm] = useState(resourceGrantInitial); + 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}))} + + setForm({ ...form, quantity: event.target.value })} /> + setForm({ ...form, durationDays: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> + + ); +} + +export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { + const [form, setForm] = useState(prettyGrantInitial); + 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 })} /> + setForm({ ...form, reason: event.target.value })} /> + + ); +} + +function TargetNotice() { + return 提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。; +} + +function GrantDialog({ children, loading, onClose, onSubmit, open, title }) { + return ( + + { event.preventDefault(); onSubmit(); }}> + {title} + {children} + + + + ); +} + +const resourceGrantInitial = { durationDays: "30", quantity: "1", reason: "", resourceId: "", target: "" }; +const prettyGrantInitial = { durationDays: "0", prettyId: "", reason: "", target: "" }; diff --git a/external-admin/src/components/RoomEditDialog.jsx b/external-admin/src/components/RoomEditDialog.jsx new file mode 100644 index 0000000..da60be6 --- /dev/null +++ b/external-admin/src/components/RoomEditDialog.jsx @@ -0,0 +1,30 @@ +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { useEffect, useState } from "react"; +import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; + +export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) { + const [form, setForm] = useState({ coverUrl: "", description: "", title: "", visibleRegionId: "" }); + useEffect(() => { + setForm({ coverUrl: room?.coverUrl || "", description: room?.description || "", title: room?.title || "", visibleRegionId: room?.visibleRegionId || "" }); + }, [room]); + return ( + + { event.preventDefault(); onSubmit(form); }}> + 编辑房间 + + setForm({ ...form, coverUrl })} /> + setForm({ ...form, title: event.target.value })} /> + setForm({ ...form, description: event.target.value })} /> + setForm({ ...form, visibleRegionId: event.target.value.trim() })} /> + + + + + ); +} diff --git a/external-admin/src/components/UserDialogs.jsx b/external-admin/src/components/UserDialogs.jsx new file mode 100644 index 0000000..a227b14 --- /dev/null +++ b/external-admin/src/components/UserDialogs.jsx @@ -0,0 +1,69 @@ +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { useEffect, useState } from "react"; +import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; + +export function UserEditDialog({ loading, onClose, onSubmit, open, user }) { + const [form, setForm] = useState(emptyEditForm); + useEffect(() => { + setForm({ avatar: user?.avatar || "", country: user?.country || "", gender: user?.gender || "", username: user?.username || "" }); + }, [user]); + return ( + onSubmit(form)}> + setForm({ ...form, avatar })} /> + setForm({ ...form, username: event.target.value })} /> + setForm({ ...form, gender: event.target.value })}> + 未设置未知 + + setForm({ ...form, country: event.target.value.trim().toUpperCase() })} /> + + ); +} + +export function UserBanDialog({ loading, onClose, onSubmit, open, user }) { + const [form, setForm] = useState({ days: "7", reason: "" }); + useEffect(() => setForm({ days: "7", reason: "" }), [user]); + return ( + onSubmit(form)}> + setForm({ ...form, days: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> + + ); +} + +export function UserLevelDialog({ loading, onClose, onSubmit, open, user }) { + const [form, setForm] = useState({ days: "30", level: "", reason: "", track: "wealth" }); + useEffect(() => setForm({ days: "30", level: "", reason: "", track: "wealth" }), [user]); + return ( + onSubmit(form)}> + setForm({ ...form, track: event.target.value })}>财富等级魅力等级VIP 等级 + setForm({ ...form, level: event.target.value })} /> + setForm({ ...form, days: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> + + ); +} + +function BaseDialog({ children, loading, onClose, onSubmit, open, title }) { + const handleSubmit = (event) => { + event.preventDefault(); + onSubmit(); + }; + return ( + + + {title} + {children} + + + + ); +} + +const emptyEditForm = { avatar: "", country: "", gender: "", username: "" }; diff --git a/external-admin/src/config/capabilities.js b/external-admin/src/config/capabilities.js new file mode 100644 index 0000000..a346f9f --- /dev/null +++ b/external-admin/src/config/capabilities.js @@ -0,0 +1,31 @@ +export const EXTERNAL_CAPABILITIES = [ + { code: "user:list", label: "用户列表", path: "/users" }, + { code: "user:update", label: "用户信息编辑", path: "/users" }, + { code: "user-ban:list", label: "账号封禁列表", path: "/bans" }, + { code: "user:ban", label: "执行封禁", path: "/users" }, + { code: "user:unban", label: "解除封禁", path: "/bans" }, + { code: "host:list", label: "主播列表", path: "/organization/hosts" }, + { code: "agency:list", label: "公会列表", path: "/organization/agencies" }, + { code: "bd:list", label: "BD 列表", path: "/organization/bds" }, + { code: "bd-manager:list", label: "BD Manager 列表", path: "/organization/bd-leaders" }, + { code: "super-admin:list", label: "Super Admin 列表", path: "/organization/super-admins" }, + { code: "room:list", label: "房间管理", path: "/rooms" }, + { code: "room:update", label: "房间编辑", path: "/rooms" }, + { code: "privilege:list", label: "用户特权道具列表", path: "/grants" }, + { code: "privilege:grant", label: "特权道具发放", path: "/grants" }, + { code: "banner:create", label: "Banner 创建", path: "/banners" }, + { code: "pretty-id:grant", label: "靓号下发", path: "/grants" }, + { code: "user-title:grant", label: "用户称号下发", path: "/grants" }, + { code: "room-background:grant", label: "房间背景图下发", path: "/grants" }, + { code: "user-level:grant", label: "财富/VIP等级下发", path: "/users" }, + { code: "team:view", label: "我的团队", path: "/organization/team" } +]; + +export function hasExternalCapability(session, code) { + const capabilities = session?.capabilities || []; + return capabilities.includes("*") || capabilities.includes("external-admin:*") || capabilities.includes(code); +} + +export function hasAnyExternalCapability(session, codes) { + return codes.some((code) => hasExternalCapability(session, code)); +} diff --git a/external-admin/src/config/entry-routing.test.js b/external-admin/src/config/entry-routing.test.js new file mode 100644 index 0000000..b7a3291 --- /dev/null +++ b/external-admin/src/config/entry-routing.test.js @@ -0,0 +1,43 @@ +import { describe, expect, test, vi } from "vitest"; +import { redirectSubsystemEntry } from "../../../vite.config.js"; + +describe("external-admin Vite entry routing", () => { + test("redirects the bare subsystem path and preserves the app query", () => { + const req = { url: "/external-admin?app=fami" }; + const res = responseStub(); + const next = vi.fn(); + + redirectSubsystemEntry(req, res, next); + + expect(res.statusCode).toBe(301); + expect(res.setHeader).toHaveBeenCalledWith("Location", "/external-admin/?app=fami"); + expect(res.end).toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + test("rewrites a BrowserRouter deep link to the isolated HTML document", () => { + const req = { url: "/external-admin/organization/hosts?from=test" }; + const res = responseStub(); + const next = vi.fn(); + + redirectSubsystemEntry(req, res, next); + + expect(req.url).toBe("/external-admin/index.html?from=test"); + expect(next).toHaveBeenCalledTimes(1); + expect(res.end).not.toHaveBeenCalled(); + }); + + test("does not rewrite source module requests", () => { + const req = { url: "/external-admin/src/main.jsx" }; + const next = vi.fn(); + + redirectSubsystemEntry(req, responseStub(), next); + + expect(req.url).toBe("/external-admin/src/main.jsx"); + expect(next).toHaveBeenCalledTimes(1); + }); +}); + +function responseStub() { + return { end: vi.fn(), setHeader: vi.fn(), statusCode: 200 }; +} diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx new file mode 100644 index 0000000..59a5787 --- /dev/null +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -0,0 +1,128 @@ +import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined"; +import BadgeOutlined from "@mui/icons-material/BadgeOutlined"; +import DashboardOutlined from "@mui/icons-material/DashboardOutlined"; +import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; +import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import LogoutOutlined from "@mui/icons-material/LogoutOutlined"; +import MenuOutlined from "@mui/icons-material/MenuOutlined"; +import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; +import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined"; +import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined"; +import RedeemOutlined from "@mui/icons-material/RedeemOutlined"; +import SecurityOutlined from "@mui/icons-material/SecurityOutlined"; +import AppBar from "@mui/material/AppBar"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Divider from "@mui/material/Divider"; +import Drawer from "@mui/material/Drawer"; +import IconButton from "@mui/material/IconButton"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Stack from "@mui/material/Stack"; +import Toolbar from "@mui/material/Toolbar"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +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 { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { hasAnyExternalCapability } from "../config/capabilities.js"; + +const drawerWidth = 248; +const navigation = [ + { icon: DashboardOutlined, label: "权限总览", path: "/overview" }, + { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, label: "用户管理", path: "/users" }, + { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, label: "封禁管理", path: "/bans" }, + { capabilities: ["host:list"], icon: BadgeOutlined, label: "主播列表", path: "/organization/hosts" }, + { capabilities: ["agency:list"], icon: ApartmentOutlined, label: "公会列表", path: "/organization/agencies" }, + { capabilities: ["bd:list"], icon: GroupsOutlined, label: "BD 列表", path: "/organization/bds" }, + { capabilities: ["bd-manager:list"], icon: SecurityOutlined, label: "BD Manager", path: "/organization/bd-leaders" }, + { capabilities: ["super-admin:list"], icon: SecurityOutlined, label: "Super Admin", path: "/organization/super-admins" }, + { capabilities: ["room:list", "room:update"], icon: MeetingRoomOutlined, label: "房间管理", path: "/rooms" }, + { capabilities: ["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"], icon: RedeemOutlined, label: "资源与靓号", path: "/grants" }, + { capabilities: ["banner:create"], icon: ImageOutlined, label: "Banner 管理", path: "/banners" }, + { capabilities: ["team:view"], icon: GroupsOutlined, label: "我的团队", path: "/organization/team" } +]; + +export function ExternalAdminLayout() { + const theme = useTheme(); + const mobile = useMediaQuery(theme.breakpoints.down("md")); + const [drawerOpen, setDrawerOpen] = useState(false); + const { logout, session } = useExternalAuth(); + const location = useLocation(); + const navigate = useNavigate(); + const visibleNavigation = useMemo( + () => navigation.filter((item) => !item.capabilities || hasAnyExternalCapability(session, item.capabilities)), + [session] + ); + const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path)); + + const handleLogout = async () => { + await logout(); + navigate("/login", { replace: true }); + }; + + const drawer = ( + + + HY + + HYApp 外管 + {session?.appName || session?.appCode} + + + + + {visibleNavigation.map((item) => { + const Icon = item.icon; + return ( + setDrawerOpen(false)} + > + + + + ); + })} + + + ); + + return ( + + + + {mobile ? setDrawerOpen(true)}> : null} + {current?.label || "外管后台"} + + + {String(session?.account || "管").slice(0, 1).toUpperCase()} + {!mobile ? ( + + {session?.account} + {session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} + + ) : null} + + + + + + + setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} + {drawer} + + + + + + ); +} diff --git a/external-admin/src/main.jsx b/external-admin/src/main.jsx new file mode 100644 index 0000000..353822a --- /dev/null +++ b/external-admin/src/main.jsx @@ -0,0 +1,27 @@ +import CssBaseline from "@mui/material/CssBaseline"; +import { ThemeProvider } from "@mui/material/styles"; +import React from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; +import { theme } from "@/theme.js"; +import { ExternalAdminApp } from "./ExternalAdminApp.jsx"; +import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx"; +import "@/styles/tokens.css"; +import "@/styles/shared-ui.css"; +import "./styles/index.css"; + +createRoot(document.getElementById("external-admin-root")).render( + + + + + + + + + + + + +); diff --git a/external-admin/src/pages/BannersPage.jsx b/external-admin/src/pages/BannersPage.jsx new file mode 100644 index 0000000..5c0144d --- /dev/null +++ b/external-admin/src/pages/BannersPage.jsx @@ -0,0 +1,49 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useCallback, useMemo, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { createBanner, listBanners } from "../api/business.js"; +import { BannerCreateDialog } from "../components/BannerCreateDialog.jsx"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +export default function BannersPage() { + const { showToast } = useToast(); + const [open, setOpen] = useState(false); + const [page, setPage] = useState(1); + const [submitting, setSubmitting] = useState(false); + const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]); + const { data, error, loading, reload } = usePagedResource(load); + const columns = useMemo(() => [ + { key: "banner", label: "Banner", render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, + { key: "type", label: "类型", render: (item) => item.bannerType || "-" }, + { key: "platform", label: "平台", render: (item) => item.platform || "all" }, + { key: "sort", label: "排序", render: (item) => item.sortOrder }, + { key: "status", label: "状态", render: (item) => } + ], []); + + const submit = async (form) => { + setSubmitting(true); + try { + await createBanner(form); + showToast("Banner 已创建", "success"); + setOpen(false); + reload(); + } catch (requestError) { + showToast(requestError.message || "Banner 创建失败", "error"); + } finally { + setSubmitting(false); + } + }; + + return ( + setOpen(true)} startIcon={} variant="contained">创建 Banner} title="Banner 管理"> + + {!loading && !error ? <> item.id} /> : null} + setOpen(false)} onSubmit={submit} /> + + ); +} diff --git a/external-admin/src/pages/BansPage.jsx b/external-admin/src/pages/BansPage.jsx new file mode 100644 index 0000000..773edfb --- /dev/null +++ b/external-admin/src/pages/BansPage.jsx @@ -0,0 +1,61 @@ +import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useCallback, useMemo, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { listBannedUsers, unbanUser } from "../api/business.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { hasExternalCapability } from "../config/capabilities.js"; +import { ConfirmDialog } from "../shared/ConfirmDialog.jsx"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +export default function BansPage() { + const { session } = useExternalAuth(); + const { showToast } = useToast(); + const [keyword, setKeyword] = useState(""); + const [query, setQuery] = useState({ keyword: "", page: 1 }); + const [target, setTarget] = useState(null); + const [submitting, setSubmitting] = useState(false); + const load = useCallback(() => listBannedUsers({ keyword: query.keyword, page: query.page, page_size: 20 }), [query]); + const { data, error, loading, reload } = usePagedResource(load); + const canUnban = hasExternalCapability(session, "user:unban"); + + const handleUnban = async () => { + setSubmitting(true); + try { + await unbanUser(target.id, { reason: "external-admin manual unban" }); + showToast("用户已解除封禁", "success"); + setTarget(null); + reload(); + } catch (requestError) { + showToast(requestError.message || "解除封禁失败", "error"); + } finally { + setSubmitting(false); + } + }; + + const columns = useMemo(() => [ + { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" }, + { key: "reason", label: "封禁原因", render: (user) => user.reason || "-" }, + { key: "status", label: "状态", render: () => }, + { key: "actions", label: "操作", render: (user) => canUnban ? : "-" } + ], [canUnban]); + + return ( + + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> + setKeyword(event.target.value)} /> + + + + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} + setTarget(null)} onConfirm={handleUnban} /> + + ); +} diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx new file mode 100644 index 0000000..36d1eb9 --- /dev/null +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -0,0 +1,69 @@ +import KeyOutlined from "@mui/icons-material/KeyOutlined"; +import Alert from "@mui/material/Alert"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; + +export default function ChangePasswordPage() { + const { changePassword, logout, session } = useExternalAuth(); + const [form, setForm] = useState({ confirmPassword: "", newPassword: "", oldPassword: "" }); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const navigate = useNavigate(); + + const submit = async (event) => { + event.preventDefault(); + if (!form.newPassword.trim()) { + setError("新密码不能为空或全为空白字符"); + return; + } + if (form.newPassword.length < 8) { + setError("新密码至少 8 个字符"); + return; + } + if (form.newPassword !== form.confirmPassword) { + setError("两次输入的新密码不一致"); + return; + } + setSubmitting(true); + setError(""); + try { + await changePassword({ newPassword: form.newPassword, oldPassword: form.oldPassword }); + navigate("/overview", { replace: true }); + } catch (requestError) { + setError(requestError.message || "密码修改失败"); + } finally { + setSubmitting(false); + } + }; + + const cancel = async () => { + await logout(); + navigate("/login", { replace: true }); + }; + + return ( + + + + 修改外管密码{session?.passwordChangeRequired ? "首次登录必须修改密码" : session?.account} + + {error ? {error} : null} + setForm({ ...form, oldPassword: 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 new file mode 100644 index 0000000..a724534 --- /dev/null +++ b/external-admin/src/pages/ChangePasswordPage.test.jsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import ChangePasswordPage from "./ChangePasswordPage.jsx"; + +const auth = vi.hoisted(() => ({ + changePassword: vi.fn(), + logout: vi.fn(), + session: { account: "fami-manager", passwordChangeRequired: true } +})); + +vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth })); + +describe("ChangePasswordPage", () => { + beforeEach(() => { + auth.changePassword.mockReset().mockResolvedValue({}); + auth.logout.mockReset().mockResolvedValue(undefined); + }); + + test("blocks a whitespace-only new password", async () => { + const user = userEvent.setup(); + const { container } = render(); + const [newPassword, confirmPassword] = container.querySelectorAll('input[autocomplete="new-password"]'); + + fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: "old-password" } }); + fireEvent.change(newPassword, { target: { value: " " } }); + fireEvent.change(confirmPassword, { target: { value: " " } }); + await user.click(screen.getByRole("button", { name: "保存密码" })); + + expect(screen.getByText("新密码不能为空或全为空白字符")).toBeInTheDocument(); + expect(auth.changePassword).not.toHaveBeenCalled(); + }); + + test("preserves password bytes instead of trimming before submission", async () => { + const user = userEvent.setup(); + const { container } = render(); + const [newPassword, confirmPassword] = container.querySelectorAll('input[autocomplete="new-password"]'); + + fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: " current-password " } }); + fireEvent.change(newPassword, { target: { value: " pass word " } }); + fireEvent.change(confirmPassword, { target: { value: " pass word " } }); + await user.click(screen.getByRole("button", { name: "保存密码" })); + + expect(auth.changePassword).toHaveBeenCalledWith({ newPassword: " pass word ", oldPassword: " current-password " }); + }); +}); diff --git a/external-admin/src/pages/GrantsPage.jsx b/external-admin/src/pages/GrantsPage.jsx new file mode 100644 index 0000000..4fe3522 --- /dev/null +++ b/external-admin/src/pages/GrantsPage.jsx @@ -0,0 +1,106 @@ +import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; +import NumbersOutlined from "@mui/icons-material/NumbersOutlined"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useCallback, useMemo, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { grantPrettyId, grantResource, listResourceGrants, listResources, lookupGrantTarget } from "../api/business.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { PrettyIdGrantDialog, ResourceGrantDialog } from "../components/GrantDialogs.jsx"; +import { hasAnyExternalCapability, hasExternalCapability } from "../config/capabilities.js"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +export default function GrantsPage() { + const { session } = useExternalAuth(); + const { showToast } = useToast(); + const [dialog, setDialog] = useState(""); + const [page, setPage] = useState(1); + const [submitting, setSubmitting] = useState(false); + const canList = hasExternalCapability(session, "privilege:list"); + const canGrant = hasAnyExternalCapability(session, ["privilege:grant", "user-title:grant", "room-background:grant"]); + const canGrantPrettyId = hasExternalCapability(session, "pretty-id:grant"); + const loadResources = useCallback(() => canGrant ? listResources({ page: 1, page_size: 200, status: "active" }) : Promise.resolve({ items: [], page: 1, pageSize: 200, total: 0 }), [canGrant]); + const loadGrants = useCallback(() => canList ? listResourceGrants({ page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [canList, page]); + const resourcesState = usePagedResource(loadResources); + const grantsState = usePagedResource(loadGrants); + + const execute = async (request, successMessage) => { + setSubmitting(true); + try { + await request(); + showToast(successMessage, "success"); + setDialog(""); + grantsState.reload(); + } catch (requestError) { + showToast(requestError.message || "发放失败", "error"); + } finally { + setSubmitting(false); + } + }; + + const resolveTarget = async (input) => { + const target = await lookupGrantTarget(input); + if (!target.id) { + throw new Error("未找到当前 App 内的目标用户"); + } + return target; + }; + + const submitResource = (form) => execute(async () => { + const target = await resolveTarget(form.target); + await grantResource({ + commandId: createCommandId("external-resource-grant"), + durationMs: Number(form.durationDays) * 86400000, + quantity: Number(form.quantity), + reason: form.reason.trim(), + resourceId: Number(form.resourceId), + targetUserId: target.id + }); + }, "特权道具已发放"); + + const submitPrettyId = (form) => execute(async () => { + const target = await resolveTarget(form.target); + await grantPrettyId({ + display_user_id: form.prettyId, + duration_ms: Number(form.durationDays) * 86400000, + reason: form.reason.trim(), + target_user_id: target.id + }); + }, "靓号已下发"); + + const columns = useMemo(() => [ + { key: "target", label: "目标用户", render: (item) => String(item.targetUserId || item.target_user_id || item.userId || item.user_id || "-") }, + { key: "resource", label: "资源", render: (item) => String(item.resourceName || item.resource_name || item.resourceId || item.resource_id || item.subject || "-") }, + { key: "quantity", label: "数量", render: (item) => String(item.quantity ?? item.amount ?? "-") }, + { key: "status", label: "状态", render: (item) => }, + { key: "reason", label: "原因", render: (item) => String(item.reason || "-") } + ], []); + + return ( + {canGrant ? : null}{canGrantPrettyId ? : null}} + title="资源与靓号" + > + {canGrant ? ( + + 可发放资源 + + {resourcesState.data.items.slice(0, 12).map((resource) => {resource.name})} + {!resourcesState.loading && !resourcesState.data.items.length ? 当前无数据 : null} + + + ) : null} + {canList ? <>{!grantsState.loading && !grantsState.error ? <> item.grantId || item.grant_id || index} /> : null} : null} + setDialog("")} onSubmit={submitResource} /> + setDialog("")} onSubmit={submitPrettyId} /> + + ); +} + +function createCommandId(prefix) { + return `${prefix}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; +} diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx new file mode 100644 index 0000000..ba44b37 --- /dev/null +++ b/external-admin/src/pages/LoginPage.jsx @@ -0,0 +1,123 @@ +import LockOutlined from "@mui/icons-material/LockOutlined"; +import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined"; +import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; +import Alert from "@mui/material/Alert"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import CircularProgress from "@mui/material/CircularProgress"; +import FormControl from "@mui/material/FormControl"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import InputLabel from "@mui/material/InputLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useCallback, useEffect, useState } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { listExternalApps } from "../api/auth.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; + +export default function LoginPage() { + const { initializing, login, session } = useExternalAuth(); + const [apps, setApps] = useState([]); + const [appsError, setAppsError] = useState(""); + const [appsLoading, setAppsLoading] = useState(true); + const [form, setForm] = useState({ account: "", appCode: "", password: "" }); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const location = useLocation(); + + const loadApps = useCallback(async () => { + setAppsLoading(true); + setAppsError(""); + try { + const items = await listExternalApps(); + setApps(items); + const requestedApp = searchParams.get("app") || ""; + const preferredApp = items.find((item) => item.appCode === requestedApp)?.appCode || items[0]?.appCode || requestedApp; + setForm((current) => ({ ...current, appCode: current.appCode || preferredApp })); + } catch (requestError) { + setAppsError(requestError.message || "App 列表加载失败"); + } finally { + setAppsLoading(false); + } + }, [searchParams]); + + useEffect(() => { + loadApps(); + }, [loadApps]); + + useEffect(() => { + if (!initializing && session) { + const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview"; + navigate(destination, { replace: true }); + } + }, [initializing, location.state, navigate, session]); + + const handleSubmit = async (event) => { + event.preventDefault(); + if (!form.appCode || !form.account.trim() || !form.password) { + setError("请选择 App 并填写账号、密码"); + return; + } + setSubmitting(true); + setError(""); + try { + const nextSession = await login(form); + navigate(nextSession.passwordChangeRequired ? "/change-password" : "/overview", { replace: true }); + } catch (requestError) { + setError(requestError.message || "登录失败"); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + + 外管后台登录 + + + {error ? {error} : null} + {appsError ? 重试} severity="error">{appsError} : null} + {apps.length || appsLoading ? ( + + App + + + ) : ( + setForm({ ...form, appCode: event.target.value.trim() })} /> + )} + setForm({ ...form, account: event.target.value })} /> + setForm({ ...form, password: event.target.value })} + slotProps={{ input: { endAdornment: setShowPassword((current) => !current)}>{showPassword ? : } } }} + /> + + + + + + ); +} diff --git a/external-admin/src/pages/OrganizationPage.jsx b/external-admin/src/pages/OrganizationPage.jsx new file mode 100644 index 0000000..57eb724 --- /dev/null +++ b/external-admin/src/pages/OrganizationPage.jsx @@ -0,0 +1,70 @@ +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import Alert from "@mui/material/Alert"; +import { useCallback, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { listOrganizations } from "../api/business.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { hasExternalCapability } from "../config/capabilities.js"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +const organizationKinds = { + agencies: { capability: "agency:list", title: "公会列表" }, + bds: { capability: "bd:list", title: "BD 列表" }, + "bd-leaders": { capability: "bd-manager:list", title: "BD Manager 列表" }, + hosts: { capability: "host:list", title: "主播列表" }, + managers: { capability: "bd-manager:list", title: "管理员列表" }, + "super-admins": { capability: "super-admin:list", title: "Super Admin 列表" }, + team: { capability: "team:view", title: "我的团队" } +}; + +export default function OrganizationPage() { + const { kind = "hosts" } = useParams(); + const config = organizationKinds[kind]; + const { session } = useExternalAuth(); + const [keyword, setKeyword] = useState(""); + const [query, setQuery] = useState({ keyword: "", page: 1 }); + const allowed = Boolean(config && hasExternalCapability(session, config.capability)); + const load = useCallback(() => allowed ? listOrganizations(kind, { keyword: query.keyword, page: query.page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [allowed, kind, query]); + const { data, error, loading, reload } = usePagedResource(load); + const columns = useMemo(() => kind === "team" ? [ + { key: "agency", label: "公会", render: (item) => 公会 {item.id || "-"}Owner {item.userId || "-"} }, + { key: "parent", label: "上级 BD", render: (item) => item.parentBdUserId || "-" }, + { key: "status", label: "状态", render: (item) => } + ] : [ + { key: "identity", label: "成员", render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / 用户 ${item.userId}` : ""} }, + { key: "prettyId", label: "短 ID(靓号)", render: (item) => item.prettyId || "-" }, + { key: "country", label: "国家/地区", render: (item) => item.country || "-" }, + { key: "status", label: "状态", render: (item) => } + ], [kind]); + + if (!config || !allowed) { + return 当前外管账号没有访问该组织列表的权限。; + } + + return ( + + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> + setKeyword(event.target.value)} /> + + + + {kind === "team" && !loading && !error ? ( + + + BD Leader:{data.totalBdLeaders || 0} + BD:{data.totalBds || 0} + 公会:{data.total || data.items.length} + + {data.truncated ? 团队规模超过接口上限,当前结果不完整 : null} + + ) : null} + {!loading && !error ? <> item.id || index} /> setQuery({ ...query, page })} /> : null} + + ); +} diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx new file mode 100644 index 0000000..c2bfc3a --- /dev/null +++ b/external-admin/src/pages/OverviewPage.jsx @@ -0,0 +1,50 @@ +import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined"; +import LockOutlined from "@mui/icons-material/LockOutlined"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardActionArea from "@mui/material/CardActionArea"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { Link } from "react-router-dom"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabilities.js"; +import { ExternalPage } from "../shared/PageComponents.jsx"; + +export default function OverviewPage() { + const { session } = useExternalAuth(); + + return ( + + + + + 当前 App{session.appName || session.appCode}({session.appCode}) + 外管账号{session.account} + 已开通权限{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} + + + + + {EXTERNAL_CAPABILITIES.map((item) => { + const enabled = hasExternalCapability(session, item.code); + const content = ( + + + {enabled ? : } + {item.label} + + + + ); + return ( + + {enabled ? {content} : content} + + ); + })} + + + ); +} diff --git a/external-admin/src/pages/OverviewPage.test.jsx b/external-admin/src/pages/OverviewPage.test.jsx new file mode 100644 index 0000000..90540d9 --- /dev/null +++ b/external-admin/src/pages/OverviewPage.test.jsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, test, vi } from "vitest"; +import { EXTERNAL_CAPABILITIES } from "../config/capabilities.js"; +import OverviewPage from "./OverviewPage.jsx"; + +const authState = vi.hoisted(() => ({ + session: { + account: "fami-operator", + appCode: "fami", + appName: "Fami", + capabilities: ["user:list"] + } +})); + +vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ + useExternalAuth: () => authState +})); + +describe("OverviewPage", () => { + test("renders all 20 market capability entries and only links enabled entries", () => { + render(); + + expect(EXTERNAL_CAPABILITIES).toHaveLength(20); + expect(screen.getAllByText("已开通")).toHaveLength(1); + expect(screen.getAllByText("未开通")).toHaveLength(19); + expect(screen.getByText("用户列表").closest("a")).toHaveAttribute("href", "/users"); + expect(screen.getByText("用户信息编辑").closest("a")).toBeNull(); + expect(screen.getByText("我的团队")).toBeInTheDocument(); + }); +}); diff --git a/external-admin/src/pages/RoomsPage.jsx b/external-admin/src/pages/RoomsPage.jsx new file mode 100644 index 0000000..706fecd --- /dev/null +++ b/external-admin/src/pages/RoomsPage.jsx @@ -0,0 +1,63 @@ +import EditOutlined from "@mui/icons-material/EditOutlined"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useCallback, useMemo, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { listRooms, updateRoom } from "../api/business.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { RoomEditDialog } from "../components/RoomEditDialog.jsx"; +import { hasExternalCapability } from "../config/capabilities.js"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +export default function RoomsPage() { + const { session } = useExternalAuth(); + const { showToast } = useToast(); + const [keyword, setKeyword] = useState(""); + const [query, setQuery] = useState({ keyword: "", page: 1 }); + const [room, setRoom] = useState(null); + const [submitting, setSubmitting] = useState(false); + const load = useCallback(() => listRooms({ keyword: query.keyword, page: query.page, page_size: 20 }), [query]); + const { data, error, loading, reload } = usePagedResource(load); + const canEdit = hasExternalCapability(session, "room:update"); + + const columns = useMemo(() => [ + { key: "room", label: "房间", render: (item) => {item.title || "-"}ID {item.id} }, + { key: "owner", label: "房主", render: (item) => item.ownerName || "-" }, + { key: "region", label: "可见区域", render: (item) => item.visibleRegionId || "全部" }, + { key: "status", label: "状态", render: (item) => }, + { key: "actions", label: "操作", render: (item) => canEdit ? : "-" } + ], [canEdit]); + + const submit = async (form) => { + setSubmitting(true); + try { + const regionText = String(form.visibleRegionId || "").trim(); + const visibleRegionId = regionText ? Number(regionText) : 0; + if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) { + throw new Error("可见区域 ID 必须是安全的正整数"); + } + await updateRoom(room.id, { ...form, visibleRegionId }); + showToast("房间信息已更新", "success"); + setRoom(null); + reload(); + } catch (requestError) { + showToast(requestError.message || "房间更新失败", "error"); + } finally { + setSubmitting(false); + } + }; + + return ( + + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> setKeyword(event.target.value)} /> + + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} + setRoom(null)} onSubmit={submit} /> + + ); +} diff --git a/external-admin/src/pages/UsersPage.jsx b/external-admin/src/pages/UsersPage.jsx new file mode 100644 index 0000000..72db4b7 --- /dev/null +++ b/external-admin/src/pages/UsersPage.jsx @@ -0,0 +1,77 @@ +import EditOutlined from "@mui/icons-material/EditOutlined"; +import GppBadOutlined from "@mui/icons-material/GppBadOutlined"; +import TrendingUpOutlined from "@mui/icons-material/TrendingUpOutlined"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useCallback, useMemo, useState } from "react"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { banUser, grantUserVip, listUsers, updateUser, updateUserLevels } from "../api/business.js"; +import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { UserBanDialog, UserEditDialog, UserLevelDialog } from "../components/UserDialogs.jsx"; +import { hasExternalCapability } from "../config/capabilities.js"; +import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; +import { usePagedResource } from "../shared/usePagedResource.js"; + +export default function UsersPage() { + const { session } = useExternalAuth(); + const { showToast } = useToast(); + const [filters, setFilters] = useState({ keyword: "", status: "" }); + const [query, setQuery] = useState({ keyword: "", page: 1, status: "" }); + const [dialog, setDialog] = useState({ type: "", user: null }); + const [submitting, setSubmitting] = useState(false); + const load = useCallback(() => listUsers({ keyword: query.keyword, page: query.page, page_size: 20, status: query.status }), [query]); + const { data, error, loading, reload } = usePagedResource(load); + const canEdit = hasExternalCapability(session, "user:update"); + const canBan = hasExternalCapability(session, "user:ban"); + const canGrantLevel = hasExternalCapability(session, "user-level:grant"); + + const closeDialog = () => setDialog({ type: "", user: null }); + const execute = async (request, successMessage) => { + setSubmitting(true); + try { + await request(); + showToast(successMessage, "success"); + closeDialog(); + reload(); + } catch (requestError) { + showToast(requestError.message || "用户操作失败", "error"); + } finally { + setSubmitting(false); + } + }; + + const columns = useMemo(() => [ + { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" }, + { key: "country", label: "国家/性别", render: (user) => `${user.country || "-"} / ${user.gender || "-"}` }, + { key: "level", label: "等级", render: (user) => `财富 ${user.wealthLevel} / 魅力 ${user.charmLevel} / VIP ${user.vipLevel}` }, + { key: "status", label: "状态", render: (user) => }, + { key: "actions", label: "操作", render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } + ], [canBan, canEdit, canGrantLevel]); + + return ( + + { event.preventDefault(); setQuery({ ...filters, page: 1 }); }}> + setFilters({ ...filters, keyword: event.target.value })} /> + setFilters({ ...filters, status: event.target.value })}>全部状态正常已封禁 + + + + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} + execute(() => updateUser(dialog.user.id, form), "用户信息已更新")} /> + execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "用户已封禁")} /> + execute(() => form.track === "vip" + ? grantUserVip(dialog.user.id, { commandId: createLevelCommandId(), durationMs: Number(form.days) * 86400000, level: Number(form.level), reason: form.reason.trim() }) + : updateUserLevels(dialog.user.id, { adjustments: [{ duration_days: Number(form.days), level: Number(form.level), track: form.track }], reason: form.reason.trim() }), "用户等级已下发")} /> + + ); +} + +function createLevelCommandId() { + return `external-level-grant-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; +} diff --git a/external-admin/src/shared/ConfirmDialog.jsx b/external-admin/src/shared/ConfirmDialog.jsx new file mode 100644 index 0000000..e947974 --- /dev/null +++ b/external-admin/src/shared/ConfirmDialog.jsx @@ -0,0 +1,16 @@ +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; + +export function ConfirmDialog({ confirmColor = "primary", content, loading, onClose, onConfirm, open, title }) { + return ( + + {title} + {content} + + + ); +} diff --git a/external-admin/src/shared/ExternalImageUploadField.jsx b/external-admin/src/shared/ExternalImageUploadField.jsx new file mode 100644 index 0000000..4851533 --- /dev/null +++ b/external-admin/src/shared/ExternalImageUploadField.jsx @@ -0,0 +1,64 @@ +import CloudUploadOutlined from "@mui/icons-material/CloudUploadOutlined"; +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import CircularProgress from "@mui/material/CircularProgress"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useRef, useState } from "react"; +import { uploadExternalImage } from "../api/business.js"; + +export function ExternalImageUploadField({ disabled, label, onChange, value }) { + const inputRef = useRef(null); + const [error, setError] = useState(""); + const [uploading, setUploading] = useState(false); + + const handleFile = async (event) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + if (!file.type.startsWith("image/")) { + setError("请选择图片文件"); + return; + } + if (file.size > 10 * 1024 * 1024) { + setError("图片不能超过 10MB"); + return; + } + + setError(""); + setUploading(true); + try { + const url = await uploadExternalImage(file); + if (!url) { + throw new Error("上传结果缺少图片地址"); + } + onChange(url); + } catch (uploadError) { + setError(uploadError.message || "图片上传失败"); + } finally { + setUploading(false); + } + }; + + return ( + + {label} + + {value ? {`${label}预览`} : 暂无图片} + + + + {error ? {error} : null} + + ); +} diff --git a/external-admin/src/shared/PageComponents.jsx b/external-admin/src/shared/PageComponents.jsx new file mode 100644 index 0000000..86da941 --- /dev/null +++ b/external-admin/src/shared/PageComponents.jsx @@ -0,0 +1,117 @@ +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Typography from "@mui/material/Typography"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; + +export function ExternalPage({ actions, children, title }) { + return ( + + + + {title} + + {actions ? {actions} : null} + + {children} + + ); +} + +export function ExternalPageState({ error, loading, onRetry }) { + if (loading) { + return ( + + + + + ); + } + if (error) { + return ( + + {error} + + + ); + } + return null; +} + +export function ResponsiveDataList({ columns, emptyText = "当前无数据", items, rowKey }) { + const theme = useTheme(); + const mobile = useMediaQuery(theme.breakpoints.down("sm")); + + if (!items.length) { + return {emptyText}; + } + + if (mobile) { + return ( + + {items.map((item, index) => ( + + + {columns.map((column) => ( + + {column.label} + {column.render(item)} + + ))} + + + ))} + + ); + } + + return ( + + + + {columns.map((column) => {column.label})} + + + {items.map((item, index) => ( + + {columns.map((column) => {column.render(item)})} + + ))} + +
+
+ ); +} + +export function StatusChip({ status }) { + const normalized = String(status || "-").toLowerCase(); + const enabled = ["active", "enabled", "running", "normal", "unbanned"].includes(normalized); + const dangerous = ["banned", "disabled", "failed", "blocked"].includes(normalized); + return ; +} + +export function PagePagination({ page, pageSize, total, onChange }) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + return ( + + 共 {total} 条 + + + {page} / {totalPages} + + + + ); +} diff --git a/external-admin/src/shared/usePagedResource.js b/external-admin/src/shared/usePagedResource.js new file mode 100644 index 0000000..af442f8 --- /dev/null +++ b/external-admin/src/shared/usePagedResource.js @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useState } from "react"; + +const EMPTY_PAGE = { items: [], page: 1, pageSize: 20, total: 0 }; + +export function usePagedResource(loader) { + const [data, setData] = useState(EMPTY_PAGE); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const [version, setVersion] = useState(0); + + const reload = useCallback(() => setVersion((current) => current + 1), []); + + useEffect(() => { + let active = true; + setLoading(true); + setError(""); + // Start inside the promise chain so synchronous validation errors use the same visible error state as network failures. + Promise.resolve() + .then(loader) + .then((result) => { + if (active) { + setData(result || EMPTY_PAGE); + } + }) + .catch((requestError) => { + if (active) { + setError(requestError.message || "数据加载失败"); + } + }) + .finally(() => { + if (active) { + setLoading(false); + } + }); + return () => { + active = false; + }; + }, [loader, version]); + + return { data, error, loading, reload }; +} diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css new file mode 100644 index 0000000..2969394 --- /dev/null +++ b/external-admin/src/styles/index.css @@ -0,0 +1,299 @@ +html, +body, +#external-admin-root { + min-height: 100%; + margin: 0; +} + +body { + background: var(--bg-page); + color: var(--text-primary); + font-family: var(--font-system); +} + +.external-login-page { + min-height: 100dvh; + display: grid; + place-items: center; + padding: max(24px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); + background: radial-gradient(circle at 50% 0, rgba(37, 99, 235, 0.13), transparent 42%), var(--bg-page); +} + +.external-login-card { + width: min(100%, 420px); + border: 1px solid var(--border); + box-shadow: var(--shadow-panel); +} + +.external-login-card .MuiCardContent-root { + display: grid; + gap: 28px; + padding: 32px; +} + +.external-login-avatar, +.external-logo-mark { + background: var(--brand-gradient) !important; + color: #fff; +} + +.external-full-state { + min-height: 100dvh; + display: grid; + place-content: center; + justify-items: center; + gap: 16px; + padding: 24px; +} + +.external-shell, +.external-main { + min-height: 100dvh; +} + +.external-header { + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, 0.96) !important; + backdrop-filter: blur(12px); +} + +.external-header .MuiToolbar-root { + min-height: 64px; + gap: 10px; +} + +.external-user-avatar { + width: 34px !important; + height: 34px !important; + font-size: 13px !important; + background: var(--primary) !important; +} + +.external-sidebar { + min-height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-sidebar); +} + +.external-logo { + width: 248px; + min-height: 72px; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; +} + +.external-logo-mark { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 10px; + font-weight: 800; + box-shadow: var(--brand-shadow); +} + +.external-nav { + padding: 12px !important; + overflow-y: auto; +} + +.external-nav .MuiListItemButton-root { + min-height: 44px; + margin-bottom: 4px; + border-radius: 8px; + color: var(--text-secondary); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard), transform var(--motion-base) var(--ease-emphasized); +} + +.external-nav .MuiListItemButton-root:hover { + background: var(--bg-hover); +} + +.external-nav .MuiListItemButton-root.Mui-selected, +.external-nav .MuiListItemButton-root.Mui-selected:hover { + background: var(--primary-surface-strong); + color: var(--primary); + font-weight: 700; +} + +.external-nav .MuiListItemIcon-root { + min-width: 38px; + color: inherit; +} + +.external-main { + background: var(--bg-page); +} + +.external-page { + display: grid; + gap: 20px; + padding: 24px; + animation: external-page-enter var(--motion-slow) var(--ease-emphasized) both; +} + +.external-session-card, +.external-table-container, +.external-filter-bar, +.external-form-panel, +.external-empty { + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); +} + +.external-filter-bar, +.external-form-panel { + padding: 16px; +} + +.external-filter-bar .MuiTextField-root { + min-width: min(100%, 220px); +} + +.external-capability-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.external-capability-card { + min-height: 150px; + opacity: 0.68; +} + +.external-capability-card.is-enabled { + opacity: 1; +} + +.external-capability-card.is-enabled:hover { + border-color: var(--primary-border); + transform: translateY(-2px); + box-shadow: var(--shadow-soft); +} + +.external-capability-card .MuiCardActionArea-root, +.external-capability-card .MuiCardContent-root { + height: 100%; +} + +.external-table-container { + overflow-x: auto; +} + +.external-table-container .MuiTableHead-root { + background: var(--bg-card-strong); +} + +.external-table-container .MuiTableCell-root { + white-space: nowrap; + border-color: var(--row-border); +} + +.external-empty { + min-height: 200px; + display: grid; + place-items: center; + color: var(--text-secondary); +} + +.external-skeleton { + min-height: 50vh; + display: grid; + grid-template-rows: auto 1fr; + gap: 12px; +} + +.external-page-number { + min-width: 64px; + display: grid; + place-items: center; +} + +.external-mobile-card .external-mobile-row { + display: grid; + grid-template-columns: minmax(92px, 0.38fr) minmax(0, 1fr); + align-items: start; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--row-border); +} + +.external-mobile-card .external-mobile-row:last-child { + border-bottom: 0; +} + +.external-dialog-fields { + padding-top: 8px; +} + +.external-image-field { + display: flex; + align-items: center; + gap: 12px; + min-height: 92px; + padding: 12px; + border: 1px dashed var(--border); + border-radius: var(--radius-control); +} + +.external-image-field img, +.external-image-placeholder { + width: 104px; + height: 68px; + object-fit: cover; + border-radius: 6px; + background: var(--bg-card-strong); +} + +.external-banner-thumb { + width: 88px; + height: 50px; + object-fit: cover; + border-radius: 6px; + background: var(--bg-card-strong); +} + +.external-image-placeholder { + display: grid; + place-items: center; + color: var(--text-tertiary); + font-size: 12px; +} + +@keyframes external-page-enter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (max-width: 1199px) { + .external-capability-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + +@media (max-width: 899px) { + .external-page { padding: 18px; } + .external-capability-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 599px) { + .external-login-card .MuiCardContent-root { padding: 24px 20px; } + .external-page { padding: 14px; gap: 14px; } + .external-capability-grid { grid-template-columns: 1fr; } + .external-capability-card { min-height: 128px; } + .external-filter-bar .MuiTextField-root { width: 100%; } + .external-image-field { align-items: flex-start; flex-direction: column; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 66267e9..799281e 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -191,6 +191,7 @@ export const fallbackNavigation = [ routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }), routeNavItem("operation-coin-seller-ledger", { icon: ReceiptLongOutlined }), routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }), + routeNavItem("operation-external-admin-users", { icon: ManageAccountsOutlined }), routeNavItem("lucky-gift", { icon: RedeemOutlined }), routeNavItem("operation-reports", { icon: FlagOutlined }), routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }), diff --git a/src/app/navigation/menu.test.jsx b/src/app/navigation/menu.test.jsx index 33f9e5e..46520a7 100644 --- a/src/app/navigation/menu.test.jsx +++ b/src/app/navigation/menu.test.jsx @@ -100,6 +100,23 @@ describe("navigation menu helpers", () => { expect(flattenCodes(merged)).toContain("host-org-managers"); }); + test("places external admin users under operations with an isolated permission", () => { + const items = filterNavigationByPermission( + fallbackNavigation, + (code) => code === "external-admin-user:view", + ); + const operations = items.find((item) => item.code === "operations"); + + expect(operations?.children).toEqual([ + expect.objectContaining({ + code: "operation-external-admin-users", + label: "外管用户", + path: "/operations/external-admin-users", + permissionCode: "external-admin-user:view", + }), + ]); + }); + test("keeps payment children under payment menu when backend menu is current", () => { const mapped = mapBackendMenus([ { children: [], code: "app-config", icon: "settings", id: "app-config", label: "APP配置" }, diff --git a/src/app/permissions.ts b/src/app/permissions.ts index a8904fe..ea6a475 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -55,11 +55,16 @@ export const PERMISSIONS = { financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create", financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify", financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant", + financeOrderUSDTAddressUpdate: "finance-order:usdt-address:update", coinLedgerView: "coin-ledger:view", coinSellerLedgerView: "coin-seller-ledger:view", giftRecordView: "gift-record:view", coinAdjustmentView: "coin-adjustment:view", coinAdjustmentCreate: "coin-adjustment:create", + externalAdminUserView: "external-admin-user:view", + externalAdminUserCreate: "external-admin-user:create", + externalAdminUserStatus: "external-admin-user:status", + externalAdminUserResetPassword: "external-admin-user:reset-password", reportView: "report:view", giftDiamondView: "gift-diamond:view", giftDiamondUpdate: "gift-diamond:update", @@ -261,6 +266,7 @@ export const MENU_CODES = { operationCoinSellerLedger: "operation-coin-seller-ledger", operationGiftRecords: "operation-gift-records", operationCoinAdjustment: "operation-coin-adjustment", + operationExternalAdminUsers: "operation-external-admin-users", operationReports: "operation-reports", operationGiftDiamond: "operation-gift-diamond", policyTemplate: "policy-template", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index 42272d8..ce62705 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -9,6 +9,7 @@ import { cpConfigRoutes } from "@/features/cp-config/routes.js"; import { cpWeeklyRankRoutes } from "@/features/cp-weekly-rank/routes.js"; import { dashboardRoutes } from "@/features/dashboard/routes.js"; import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js"; +import { externalAdminUserRoutes } from "@/features/external-admin-users/routes.js"; import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js"; import { gameRoutes } from "@/features/games/routes.js"; import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js"; @@ -68,6 +69,7 @@ export const adminRoutes: AdminRoute[] = [ ...vipConfigRoutes, ...resourceRoutes, ...operationsRoutes, + ...externalAdminUserRoutes, ...policyConfigRoutes, ...luckyGiftRoutes, ...paymentRoutes, diff --git a/src/features/external-admin-users/api.test.ts b/src/features/external-admin-users/api.test.ts new file mode 100644 index 0000000..bc3e742 --- /dev/null +++ b/src/features/external-admin-users/api.test.ts @@ -0,0 +1,121 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { setAccessToken, setSelectedAppCode } from "@/shared/api/request"; +import { + createExternalAdminUser, + listExternalAdminUsers, + lookupExternalAdminUserTarget, + resetExternalAdminUserPassword, + updateExternalAdminUserStatus, +} from "./api"; + +afterEach(() => { + setAccessToken(""); + setSelectedAppCode("lalu"); + vi.unstubAllGlobals(); +}); + +test("external admin APIs pin every read and write to the explicit App scope", async () => { + setSelectedAppCode("lalu"); + const externalUser = externalUserFixture(); + vi.stubGlobal( + "fetch", + vi.fn() + .mockResolvedValueOnce(envelope({ items: [externalUser], page: 1, page_size: 50, total: 1 })) + .mockResolvedValueOnce(envelope({ linked_user: externalUser.linked_user })) + .mockResolvedValueOnce(envelope(externalUser, 201)) + .mockResolvedValueOnce(envelope({ ...externalUser, status: "disabled" })) + .mockResolvedValueOnce(envelope(externalUser)), + ); + + const list = await listExternalAdminUsers(" Huwaa ", { + keyword: "ops.fami", + page: 1, + page_size: 50, + status: "active", + }); + const target = await lookupExternalAdminUserTarget("huwaa", "VIP-1001"); + await createExternalAdminUser("huwaa", { + password: "StrongPass123!", + targetUserId: "user-1001", + username: "ops.fami", + }); + await updateExternalAdminUserStatus("huwaa", 71, "disabled"); + await resetExternalAdminUserPassword("huwaa", 71, "NextStrongPass123!"); + + const calls = vi.mocked(fetch).mock.calls; + expect(calls.map(([, init]) => init?.method)).toEqual(["GET", "GET", "POST", "PATCH", "POST"]); + calls.forEach(([, init]) => { + expect(init?.headers).toMatchObject({ "X-App-Code": "huwaa" }); + }); + expect(String(calls[0][0])).toContain("/api/v1/admin/external-admin-users?"); + expect(String(calls[0][0])).toContain("keyword=ops.fami"); + expect(String(calls[0][0])).toContain("status=active"); + expect(String(calls[1][0])).toContain("/api/v1/admin/external-admin-users/target?display_user_id=VIP-1001"); + expect(String(calls[3][0])).toContain("/api/v1/admin/external-admin-users/71/status"); + expect(String(calls[4][0])).toContain("/api/v1/admin/external-admin-users/71/password"); + expect(requestBody(calls[2][1])).toEqual({ + password: "StrongPass123!", + targetUserId: "user-1001", + username: "ops.fami", + }); + expect(requestBody(calls[3][1])).toEqual({ status: "disabled" }); + expect(requestBody(calls[4][1])).toEqual({ password: "NextStrongPass123!" }); + expect(list).toMatchObject({ + page: 1, + pageSize: 50, + total: 1, + items: [ + { + appCode: "huwaa", + createdAtMs: 1784073600000, + createdByAdminId: 9, + id: 71, + linkedUser: { + defaultDisplayUserId: "1001", + prettyDisplayUserId: "VIP-1001", + userId: "user-1001", + }, + permissions: ["user:list", "room:edit"], + status: "active", + username: "ops.fami", + }, + ], + }); + expect(target).toMatchObject({ + defaultDisplayUserId: "1001", + prettyDisplayUserId: "VIP-1001", + userId: "user-1001", + }); +}); + +function envelope(data: unknown, status = 200) { + return new Response(JSON.stringify({ code: 0, data }), { status }); +} + +function requestBody(init: RequestInit | undefined) { + return JSON.parse(String(init?.body || "{}")); +} + +function externalUserFixture() { + return { + app_code: "huwaa", + created_at_ms: "1784073600000", + created_by_admin_id: 9, + id: 71, + last_login_at_ms: null, + linked_user: { + avatar: "https://cdn.example.com/u.png", + default_display_user_id: "1001", + display_user_id: "VIP-1001", + pretty_display_user_id: "VIP-1001", + pretty_id: "pretty-1", + status: "active", + user_id: "user-1001", + username: "Fami user", + }, + permissions: ["user:list", "room:edit"], + 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 new file mode 100644 index 0000000..e815302 --- /dev/null +++ b/src/features/external-admin-users/api.ts @@ -0,0 +1,223 @@ +import { apiRequest } from "@/shared/api/request"; +import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import type { ApiPage, CoinLedgerUserDto, EntityId, PageQuery } from "@/shared/api/types"; + +export type ExternalAdminUserStatus = "active" | "disabled"; + +export interface ExternalAdminLinkedUserDto extends CoinLedgerUserDto { + defaultDisplayUserId?: string; + status?: string; +} + +export interface ExternalAdminUserDto { + appCode?: string; + createdAtMs?: number; + createdByAdminId?: EntityId; + id: EntityId; + lastLoginAtMs?: number; + linkedUser: ExternalAdminLinkedUserDto; + permissions: string[]; + status: ExternalAdminUserStatus; + updatedAtMs?: number; + username: string; +} + +export interface ExternalAdminUserTargetDto extends ExternalAdminLinkedUserDto { + existingExternalAdminUser?: ExternalAdminUserDto; +} + +export interface CreateExternalAdminUserPayload { + password: string; + targetUserId: string; + username: string; +} + +export async function listExternalAdminUsers( + appCode: string, + query: PageQuery = {}, +): Promise> { + const endpoint = API_ENDPOINTS.listExternalAdminUsers; + const data = await apiRequest>(apiEndpointPath(API_OPERATIONS.listExternalAdminUsers), { + headers: appScopeHeaders(appCode), + method: endpoint.method, + query, + }); + return normalizePage(data, query); +} + +export async function lookupExternalAdminUserTarget( + appCode: string, + displayUserId: string, +): Promise { + const endpoint = API_ENDPOINTS.lookupExternalAdminUserTarget; + const data = await apiRequest(apiEndpointPath(API_OPERATIONS.lookupExternalAdminUserTarget), { + headers: appScopeHeaders(appCode), + method: endpoint.method, + query: { display_user_id: displayUserId }, + }); + return normalizeTarget(data); +} + +export async function createExternalAdminUser( + appCode: string, + payload: CreateExternalAdminUserPayload, +): Promise { + const endpoint = API_ENDPOINTS.createExternalAdminUser; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.createExternalAdminUser), + { + body: payload, + headers: appScopeHeaders(appCode), + method: endpoint.method, + }, + ); + return normalizeExternalAdminUser(data); +} + +export async function updateExternalAdminUserStatus( + appCode: string, + id: EntityId, + status: ExternalAdminUserStatus, +): Promise { + const endpoint = API_ENDPOINTS.updateExternalAdminUserStatus; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.updateExternalAdminUserStatus, { id }), + { + body: { status }, + headers: appScopeHeaders(appCode), + method: endpoint.method, + }, + ); + return normalizeExternalAdminUser(data); +} + +export async function resetExternalAdminUserPassword( + appCode: string, + id: EntityId, + password: string, +): Promise { + const endpoint = API_ENDPOINTS.resetExternalAdminUserPassword; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.resetExternalAdminUserPassword, { id }), + { + body: { password }, + headers: appScopeHeaders(appCode), + method: endpoint.method, + }, + ); + return normalizeExternalAdminUser(data); +} + +function appScopeHeaders(appCode: string) { + // 外管账号是强 App 隔离数据;显式固定请求头,避免切换 App 后全局请求上下文把写操作送到另一租户。 + return { "X-App-Code": String(appCode || "").trim().toLowerCase() }; +} + +type RawRecord = Record; + +interface RawPage { + items?: TItem[]; + page?: number; + page_size?: number; + pageSize?: number; + total?: number; +} + +function normalizePage(data: RawPage, query: PageQuery): ApiPage { + return { + items: (Array.isArray(data?.items) ? data.items : []).map(normalizeExternalAdminUser), + page: numberValue(data?.page, Number(query.page || 1)), + pageSize: numberValue(data?.pageSize ?? data?.page_size, Number(query.page_size || 50)), + total: numberValue(data?.total), + }; +} + +function normalizeExternalAdminUser(raw: RawRecord): ExternalAdminUserDto { + const item = asRecord(raw) || {}; + const linkedUser = normalizeUser( + asRecord(item.linkedUser) || + asRecord(item.linked_user) || + asRecord(item.user) || + asRecord(item.targetUser) || + asRecord(item.target_user) || + item, + ); + const status = stringValue(item.status).toLowerCase(); + return { + appCode: stringValue(item.appCode ?? item.app_code), + createdAtMs: optionalNumber(item.createdAtMs ?? item.created_at_ms), + createdByAdminId: entityValue(item.createdByAdminId ?? item.created_by_admin_id), + id: entityValue(item.id ?? item.externalAdminUserId ?? item.external_admin_user_id), + lastLoginAtMs: optionalNumber(item.lastLoginAtMs ?? item.last_login_at_ms), + linkedUser, + permissions: stringArray(item.permissions), + status: status === "disabled" ? "disabled" : "active", + updatedAtMs: optionalNumber(item.updatedAtMs ?? item.updated_at_ms), + username: stringValue(item.username ?? item.account ?? item.accountName ?? item.account_name), + }; +} + +function normalizeTarget(raw: RawRecord): ExternalAdminUserTargetDto { + const item = asRecord(raw) || {}; + const user = normalizeUser( + asRecord(item.linkedUser) || + asRecord(item.linked_user) || + asRecord(item.user) || + asRecord(item.targetUser) || + asRecord(item.target_user) || + item, + ); + const existing = + asRecord(item.existingExternalAdminUser) || + asRecord(item.existing_external_admin_user) || + asRecord(item.existingAccount) || + asRecord(item.existing_account); + return { + ...user, + existingExternalAdminUser: existing ? normalizeExternalAdminUser(existing) : undefined, + }; +} + +function normalizeUser(raw: RawRecord): ExternalAdminLinkedUserDto { + const user = asRecord(raw) || {}; + return { + avatar: stringValue(user.avatar), + defaultDisplayUserId: stringValue(user.defaultDisplayUserId ?? user.default_display_user_id), + displayUserId: stringValue( + user.displayUserId ?? user.display_user_id ?? user.defaultDisplayUserId ?? user.default_display_user_id, + ), + prettyDisplayUserId: stringValue(user.prettyDisplayUserId ?? user.pretty_display_user_id), + prettyId: stringValue(user.prettyId ?? user.pretty_id), + status: stringValue(user.status), + userId: stringValue(user.userId ?? user.user_id ?? user.targetUserId ?? user.target_user_id), + username: stringValue(user.username ?? user.name), + }; +} + +function asRecord(value: unknown): RawRecord | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : undefined; +} + +function entityValue(value: unknown): EntityId { + return typeof value === "number" ? value : stringValue(value); +} + +function numberValue(value: unknown, fallback = 0) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; +} + +function optionalNumber(value: unknown) { + if (value === undefined || value === null || value === "") { + return undefined; + } + return numberValue(value); +} + +function stringValue(value: unknown) { + return value === undefined || value === null ? "" : String(value); +} + +function stringArray(value: unknown) { + return Array.isArray(value) ? value.map(stringValue).filter(Boolean) : []; +} diff --git a/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx b/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx new file mode 100644 index 0000000..ce0a679 --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx @@ -0,0 +1,113 @@ +import SearchOutlined from "@mui/icons-material/SearchOutlined"; +import TextField from "@mui/material/TextField"; +import { Button } from "@/shared/ui/Button.jsx"; +import { + AdminFormDialog, + AdminFormFieldGrid, + AdminFormSection, +} from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; +import styles from "@/features/external-admin-users/external-admin-users.module.css"; + +export function ExternalAdminUserFormDialog({ + abilities, + form, + loading, + lookupLoading, + onClose, + onDisplayUserIdChange, + onFormChange, + onLookup, + onSubmit, + open, + targetUser, +}) { + const existingAccount = targetUser?.existingExternalAdminUser; + + return ( + + +
+ onDisplayUserIdChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onLookup(); + } + }} + /> + +
+ {targetUser ? ( +
+ + {existingAccount ? ( +
+ 该用户已绑定外管账号 {existingAccount.username || existingAccount.id},不能重复创建。 +
+ ) : null} +
+ ) : null} +
+ + + + onFormChange({ username: event.target.value })} + /> + onFormChange({ password: event.target.value })} + /> + onFormChange({ confirmPassword: event.target.value })} + /> + + +
+ ); +} diff --git a/src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx b/src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx new file mode 100644 index 0000000..aac97ea --- /dev/null +++ b/src/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx @@ -0,0 +1,55 @@ +import TextField from "@mui/material/TextField"; +import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; + +export function ExternalAdminUserPasswordDialog({ + abilities, + form, + loading, + onClose, + onFormChange, + onSubmit, + user, +}) { + return ( + + + + + + + onFormChange({ password: event.target.value })} + /> + onFormChange({ confirmPassword: event.target.value })} + /> + + + + ); +} diff --git a/src/features/external-admin-users/external-admin-users.module.css b/src/features/external-admin-users/external-admin-users.module.css new file mode 100644 index 0000000..35ff7ed --- /dev/null +++ b/src/features/external-admin-users/external-admin-users.module.css @@ -0,0 +1,61 @@ +.lookupRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: var(--space-3); +} + +.lookupButton { + min-width: 92px; +} + +.targetPanel { + display: grid; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); +} + +.existingWarning { + padding: var(--space-3); + border: 1px solid var(--danger-border); + border-radius: var(--radius-control); + background: var(--danger-surface); + color: var(--danger); + font-size: 13px; + line-height: 20px; +} + +.accountCell { + display: grid; + min-width: 0; + gap: var(--space-1); +} + +.accountName { + overflow: hidden; + color: var(--text-primary); + font-weight: 720; + text-overflow: ellipsis; + white-space: nowrap; +} + +.accountMeta { + overflow: hidden; + color: var(--text-tertiary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 760px) { + .lookupRow { + grid-template-columns: 1fr; + } + + .lookupButton { + width: 100%; + } +} diff --git a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js new file mode 100644 index 0000000..4584d50 --- /dev/null +++ b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.js @@ -0,0 +1,377 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; +import { + createExternalAdminUser, + listExternalAdminUsers, + lookupExternalAdminUserTarget, + resetExternalAdminUserPassword, + updateExternalAdminUserStatus, +} from "@/features/external-admin-users/api"; +import { useExternalAdminUserAbilities } from "@/features/external-admin-users/permissions.js"; +import { + externalAdminUserCreateFormSchema, + externalAdminUserLookupSchema, + externalAdminUserPasswordFormSchema, +} from "@/features/external-admin-users/schema"; +import { toPageQuery } from "@/shared/api/query"; +import { parseForm } from "@/shared/forms/validation"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +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 emptyPasswordForm = () => ({ confirmPassword: "", password: "" }); + +export function useExternalAdminUsersPage() { + const { appCode } = useAppScope(); + const abilities = useExternalAdminUserAbilities(); + const confirm = useConfirm(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const currentAppCodeRef = useRef(appCode); + const lookupRequestRef = useRef(0); + + const [keyword, setKeywordState] = useState(""); + const [status, setStatusState] = useState(""); + const [page, setPage] = useState(1); + const [mergedPage, setMergedPage] = useState(() => ({ ...emptyPage, appCode })); + const [createOpen, setCreateOpen] = useState(false); + const [createForm, setCreateFormState] = useState(emptyCreateForm); + const [targetUser, setTargetUser] = useState(null); + const [lookupLoading, setLookupLoading] = useState(false); + const [passwordUser, setPasswordUser] = useState(null); + const [passwordForm, setPasswordFormState] = useState(emptyPasswordForm); + const [actionAppCode, setActionAppCode] = useState(""); + const [loadingAction, setLoadingAction] = useState(""); + + const requestQuery = useMemo( + () => toPageQuery({ keyword, page, pageSize, status }), + [keyword, page, status], + ); + const queryFn = useCallback(() => listExternalAdminUsers(appCode, requestQuery), [appCode, requestQuery]); + const { + data: queryPage = emptyPage, + error, + loading: queryLoading, + reload, + } = useAdminQuery(queryFn, { + enabled: Boolean(appCode && abilities.canView), + errorMessage: "加载外管用户失败", + initialData: emptyPage, + queryKey: ["external-admin-users", appCode, requestQuery], + }); + + useLayoutEffect(() => { + // 写操作完成回调依赖最新租户;布局 effect 在用户能继续交互前同步边界,又不在渲染阶段读写 ref。 + currentAppCodeRef.current = appCode; + }, [appCode]); + + useEffect(() => { + // App 是外管账号的租户边界;切换后必须立即丢弃账号、密码和用户匹配结果,禁止跨 App 复用敏感表单。 + lookupRequestRef.current += 1; + setCreateOpen(false); + setCreateFormState(emptyCreateForm()); + setTargetUser(null); + setLookupLoading(false); + setPasswordUser(null); + setPasswordFormState(emptyPasswordForm()); + setActionAppCode(""); + setLoadingAction(""); + setKeywordState(""); + setStatusState(""); + setPage(1); + setMergedPage({ ...emptyPage, appCode }); + }, [appCode]); + + useEffect(() => { + setMergedPage({ ...emptyPage, appCode }); + }, [appCode, keyword, status]); + + useEffect(() => { + if (queryLoading) { + return; + } + const nextItems = Array.isArray(queryPage?.items) ? queryPage.items : []; + const nextPage = Number(queryPage?.page || page || 1); + const nextPageSize = Number(queryPage?.pageSize || pageSize); + const nextTotal = Number(queryPage?.total || 0); + + setMergedPage((current) => { + // 即使旧 App 请求晚到,也不能进入当前表格;scope 标记同时避免 React effect 执行前闪现旧租户数据。 + if (current.appCode !== appCode || nextPage <= 1) { + return { ...queryPage, appCode, items: nextItems, page: nextPage, pageSize: nextPageSize, total: nextTotal }; + } + return { + ...queryPage, + appCode, + items: mergePaginatedItems(current.items || [], nextItems), + page: nextPage, + pageSize: nextPageSize, + total: nextTotal, + }; + }); + }, [appCode, page, queryLoading, queryPage]); + + const data = mergedPage.appCode === appCode ? mergedPage : { ...emptyPage, appCode }; + const loading = queryLoading && data.items.length === 0; + + const setKeyword = (value) => { + setKeywordState(value); + setPage(1); + }; + const setStatus = (value) => { + setStatusState(value); + setPage(1); + }; + const resetFilters = () => { + setKeywordState(""); + setStatusState(""); + setPage(1); + }; + + const setCreateForm = (patch) => { + setCreateFormState((current) => ({ ...current, ...patch })); + }; + const changeDisplayUserId = (value) => { + lookupRequestRef.current += 1; + setCreateForm({ displayUserId: value }); + setTargetUser(null); + setLookupLoading(false); + }; + const openCreate = () => { + lookupRequestRef.current += 1; + setCreateFormState(emptyCreateForm()); + setTargetUser(null); + setLookupLoading(false); + setActionAppCode(appCode); + setCreateOpen(true); + }; + const closeCreate = () => { + if (loadingAction === "create") { + return; + } + lookupRequestRef.current += 1; + setCreateOpen(false); + setCreateFormState(emptyCreateForm()); + setTargetUser(null); + setLookupLoading(false); + setActionAppCode(""); + }; + + const lookupTarget = async () => { + let displayUserId; + try { + ({ displayUserId } = parseForm(externalAdminUserLookupSchema, createForm)); + } catch (err) { + showToast(err.message || "请输入用户短 ID / 靓号", "error"); + return; + } + const scope = appCode; + const requestId = lookupRequestRef.current + 1; + lookupRequestRef.current = requestId; + setLookupLoading(true); + setTargetUser(null); + try { + const result = await lookupExternalAdminUserTarget(scope, displayUserId); + if (currentAppCodeRef.current === scope && lookupRequestRef.current === requestId) { + setTargetUser(result); + } + } catch (err) { + if (currentAppCodeRef.current === scope && lookupRequestRef.current === requestId) { + showToast(err.message || "查询 App 用户失败", "error"); + } + } finally { + if (currentAppCodeRef.current === scope && lookupRequestRef.current === requestId) { + setLookupLoading(false); + } + } + }; + + const refreshList = useCallback( + async (scope) => { + if (currentAppCodeRef.current !== scope) { + return; + } + setPage(1); + setMergedPage({ ...emptyPage, appCode: scope }); + await queryClient.invalidateQueries({ queryKey: ["external-admin-users", scope] }); + }, + [queryClient], + ); + + const submitCreate = async (event) => { + event.preventDefault(); + const scope = actionAppCode; + if (!scope || scope !== appCode) { + showToast("应用已切换,请重新创建", "error"); + return; + } + if (!targetUser?.userId) { + showToast("请先搜索并确认 App 用户", "error"); + return; + } + if (targetUser.existingExternalAdminUser) { + showToast("该 App 用户已绑定外管账号", "error"); + return; + } + let formPayload; + try { + formPayload = parseForm(externalAdminUserCreateFormSchema, createForm); + } catch (err) { + showToast(err.message || "创建参数不正确", "error"); + return; + } + + setLoadingAction("create"); + try { + await createExternalAdminUser(scope, { + password: formPayload.password, + // 始终提交查询接口返回的稳定 userId,短 ID / 靓号只用于定位,避免后续改号造成错误绑定。 + targetUserId: targetUser.userId, + username: formPayload.username, + }); + if (currentAppCodeRef.current !== scope) { + return; + } + setCreateOpen(false); + setCreateFormState(emptyCreateForm()); + setTargetUser(null); + setActionAppCode(""); + showToast("外管用户已创建", "success"); + await refreshList(scope); + } catch (err) { + if (currentAppCodeRef.current === scope) { + showToast(err.message || "创建外管用户失败", "error"); + } + } finally { + if (currentAppCodeRef.current === scope) { + setLoadingAction(""); + } + } + }; + + const toggleStatus = async (item) => { + const scope = appCode; + const nextStatus = item.status === "active" ? "disabled" : "active"; + const actionLabel = nextStatus === "active" ? "启用" : "停用"; + const ok = await confirm({ + confirmText: actionLabel, + message: `${item.username || item.id} 的外管登录权限会立即${nextStatus === "active" ? "恢复" : "失效"}。`, + title: `${actionLabel}外管用户`, + tone: nextStatus === "active" ? "primary" : "danger", + }); + if (!ok || currentAppCodeRef.current !== scope) { + return; + } + + setLoadingAction(`status:${item.id}`); + try { + await updateExternalAdminUserStatus(scope, item.id, nextStatus); + if (currentAppCodeRef.current !== scope) { + return; + } + showToast(`外管用户已${actionLabel}`, "success"); + await refreshList(scope); + } catch (err) { + if (currentAppCodeRef.current === scope) { + showToast(err.message || `${actionLabel}外管用户失败`, "error"); + } + } finally { + if (currentAppCodeRef.current === scope) { + setLoadingAction(""); + } + } + }; + + const openPassword = (item) => { + setPasswordUser(item); + setPasswordFormState(emptyPasswordForm()); + setActionAppCode(appCode); + }; + const closePassword = () => { + if (loadingAction.startsWith("password:")) { + return; + } + setPasswordUser(null); + setPasswordFormState(emptyPasswordForm()); + setActionAppCode(""); + }; + const setPasswordForm = (patch) => { + setPasswordFormState((current) => ({ ...current, ...patch })); + }; + const submitPassword = async (event) => { + event.preventDefault(); + const scope = actionAppCode; + const item = passwordUser; + if (!item || !scope || scope !== appCode) { + showToast("应用已切换,请重新操作", "error"); + return; + } + let payload; + try { + payload = parseForm(externalAdminUserPasswordFormSchema, passwordForm); + } catch (err) { + showToast(err.message || "密码参数不正确", "error"); + return; + } + + setLoadingAction(`password:${item.id}`); + try { + await resetExternalAdminUserPassword(scope, item.id, payload.password); + if (currentAppCodeRef.current !== scope) { + return; + } + setPasswordUser(null); + setPasswordFormState(emptyPasswordForm()); + setActionAppCode(""); + showToast("外管密码已重置", "success"); + await refreshList(scope); + } catch (err) { + if (currentAppCodeRef.current === scope) { + showToast(err.message || "重置外管密码失败", "error"); + } + } finally { + if (currentAppCodeRef.current === scope) { + setLoadingAction(""); + } + } + }; + + return { + abilities, + closeCreate, + closePassword, + createForm, + createOpen, + data, + error, + keyword, + loading, + loadingAction, + lookupLoading, + lookupTarget, + openCreate, + openPassword, + page, + pageSize, + passwordForm, + passwordUser, + reload, + resetFilters, + setCreateForm, + setDisplayUserId: changeDisplayUserId, + setKeyword, + setPage, + setPasswordForm, + setStatus, + status, + submitCreate, + submitPassword, + targetUser, + toggleStatus, + }; +} diff --git a/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx new file mode 100644 index 0000000..1e25d6e --- /dev/null +++ b/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx @@ -0,0 +1,191 @@ +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 }, + appCode: "fami", + confirm: vi.fn(), + createExternalAdminUser: vi.fn(), + invalidateQueries: vi.fn(), + listExternalAdminUsers: vi.fn(), + lookupExternalAdminUserTarget: vi.fn(), + queryOptions: null, + queryPage: { items: [], page: 1, pageSize: 50, total: 0 }, + reload: vi.fn(), + resetExternalAdminUserPassword: vi.fn(), + showToast: vi.fn(), + updateExternalAdminUserStatus: vi.fn(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }), +})); + +vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({ + useAppScope: () => ({ appCode: mocks.appCode }), +})); + +vi.mock("@/features/external-admin-users/api", () => ({ + createExternalAdminUser: mocks.createExternalAdminUser, + listExternalAdminUsers: mocks.listExternalAdminUsers, + lookupExternalAdminUserTarget: mocks.lookupExternalAdminUserTarget, + resetExternalAdminUserPassword: mocks.resetExternalAdminUserPassword, + updateExternalAdminUserStatus: mocks.updateExternalAdminUserStatus, +})); + +vi.mock("@/features/external-admin-users/permissions.js", () => ({ + useExternalAdminUserAbilities: () => mocks.abilities, +})); + +vi.mock("@/shared/hooks/useAdminQuery.js", () => ({ + useAdminQuery: (_queryFn, options) => { + mocks.queryOptions = options; + return { data: mocks.queryPage, error: "", loading: false, reload: mocks.reload }; + }, +})); + +vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({ + useConfirm: () => mocks.confirm, +})); + +vi.mock("@/shared/ui/ToastProvider.jsx", () => ({ + useToast: () => ({ showToast: mocks.showToast }), +})); + +import { useExternalAdminUsersPage } from "./useExternalAdminUsersPage.js"; + +beforeEach(() => { + mocks.appCode = "fami"; + mocks.queryPage = { items: [], page: 1, pageSize: 50, total: 0 }; + mocks.confirm.mockResolvedValue(true); + mocks.createExternalAdminUser.mockResolvedValue(externalUserFixture()); + mocks.invalidateQueries.mockResolvedValue(undefined); + mocks.lookupExternalAdminUserTarget.mockResolvedValue(targetFixture()); + mocks.reload.mockResolvedValue(undefined); + mocks.resetExternalAdminUserPassword.mockResolvedValue(externalUserFixture()); + mocks.updateExternalAdminUserStatus.mockResolvedValue({ ...externalUserFixture(), status: "disabled" }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +test("creates an App-scoped account with the canonical userId returned by lookup", async () => { + const { result } = renderHook(() => useExternalAdminUsersPage()); + + 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.fami", + }), + ); + await act(async () => result.current.submitCreate({ preventDefault: vi.fn() })); + + expect(mocks.lookupExternalAdminUserTarget).toHaveBeenCalledWith("fami", "VIP-1001"); + expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", { + password: "StrongPass123!", + targetUserId: "internal-user-1001", + username: "ops.fami", + }); + expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["external-admin-users", "fami"] }); + expect(result.current.createOpen).toBe(false); +}); + +test("clears list filters, lookup results and password form when App scope changes", async () => { + const { result, rerender } = renderHook(() => useExternalAdminUsersPage()); + + act(() => result.current.setKeyword("old account")); + act(() => result.current.setStatus("active")); + act(() => result.current.openCreate()); + act(() => result.current.setDisplayUserId("VIP-1001")); + await act(async () => result.current.lookupTarget()); + act(() => result.current.openPassword(externalUserFixture())); + act(() => result.current.setPasswordForm({ confirmPassword: "OldPass123!", password: "OldPass123!" })); + + mocks.appCode = "lalu"; + rerender(); + + 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.targetUser).toBeNull(); + expect(result.current.passwordUser).toBeNull(); + expect(result.current.passwordForm).toEqual({ confirmPassword: "", password: "" }); + expect(mocks.queryOptions.queryKey[1]).toBe("lalu"); +}); + +test("ignores a late user lookup response after switching App", async () => { + let resolveLookup; + mocks.lookupExternalAdminUserTarget.mockImplementationOnce( + () => new Promise((resolve) => { + resolveLookup = resolve; + }), + ); + const { result, rerender } = renderHook(() => useExternalAdminUsersPage()); + + act(() => result.current.openCreate()); + act(() => result.current.setDisplayUserId("VIP-1001")); + let lookupPromise; + act(() => { + lookupPromise = result.current.lookupTarget(); + }); + mocks.appCode = "lalu"; + rerender(); + await waitFor(() => expect(result.current.createOpen).toBe(false)); + await act(async () => { + resolveLookup(targetFixture()); + await lookupPromise; + }); + + expect(result.current.targetUser).toBeNull(); +}); + +test("refreshes the App-scoped list after status and password changes", async () => { + const item = externalUserFixture(); + const { result } = renderHook(() => useExternalAdminUsersPage()); + + await act(async () => result.current.toggleStatus(item)); + expect(mocks.updateExternalAdminUserStatus).toHaveBeenCalledWith("fami", 71, "disabled"); + + act(() => result.current.openPassword(item)); + act(() => result.current.setPasswordForm({ confirmPassword: "NextPass123!", password: "NextPass123!" })); + await act(async () => result.current.submitPassword({ preventDefault: vi.fn() })); + + expect(mocks.resetExternalAdminUserPassword).toHaveBeenCalledWith("fami", 71, "NextPass123!"); + expect(mocks.invalidateQueries).toHaveBeenCalledTimes(2); + expect(mocks.invalidateQueries).toHaveBeenNthCalledWith(1, { queryKey: ["external-admin-users", "fami"] }); + expect(mocks.invalidateQueries).toHaveBeenNthCalledWith(2, { queryKey: ["external-admin-users", "fami"] }); +}); + +function targetFixture() { + return { + avatar: "https://cdn.example.com/user.png", + defaultDisplayUserId: "1001", + displayUserId: "VIP-1001", + prettyDisplayUserId: "VIP-1001", + prettyId: "pretty-1", + status: "active", + userId: "internal-user-1001", + username: "Fami user", + }; +} + +function externalUserFixture() { + return { + appCode: "fami", + createdAtMs: 1784073600000, + createdByAdminId: 9, + id: 71, + lastLoginAtMs: 1784077200000, + linkedUser: targetFixture(), + permissions: [], + status: "active", + updatedAtMs: 1784077200000, + username: "ops.fami", + }; +} diff --git a/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx b/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx new file mode 100644 index 0000000..a6db87d --- /dev/null +++ b/src/features/external-admin-users/pages/ExternalAdminUsersPage.jsx @@ -0,0 +1,217 @@ +import PasswordOutlined from "@mui/icons-material/PasswordOutlined"; +import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined"; +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +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"; +import { useExternalAdminUsersPage } from "@/features/external-admin-users/hooks/useExternalAdminUsersPage.js"; +import { + AdminActionIconButton, + AdminFilterResetButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminRowActions, + AdminSearchBox, +} from "@/shared/ui/AdminListLayout.jsx"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; + +const statusOptions = [ + ["", "全部状态"], + ["active", "启用"], + ["disabled", "停用"], +]; + +const columns = [ + { + key: "linkedUser", + label: "绑定 App 用户", + render: (item) => ( + + ), + width: "minmax(250px, 1.2fr)", + }, + { + key: "username", + label: "外管账号", + render: (item) => ( +
+ {item.username || "-"} + ID {item.id || "-"} +
+ ), + width: "minmax(190px, 0.95fr)", + }, + { + key: "status", + label: "状态", + render: (item, _index, context) => { + const page = context?.page; + // 外管状态和密码都是账号级敏感写操作;同页串行化可避免两个请求互相覆盖全局 loading 状态。 + const accountActionInFlight = Boolean(page?.loadingAction); + return ( + page.toggleStatus(item)} + /> + ); + }, + width: "minmax(120px, 0.65fr)", + }, + { + key: "permissions", + label: "外管权限", + render: (item) => (item.permissions?.length ? `${item.permissions.length} 项` : "-"), + width: "minmax(120px, 0.65fr)", + }, + { + key: "createdByAdminId", + label: "创建人", + render: (item) => item.createdByAdminId || "-", + width: "minmax(130px, 0.7fr)", + }, + { + key: "lastLoginAtMs", + label: "最近登录", + render: (item) => formatMillis(item.lastLoginAtMs), + width: "minmax(170px, 0.85fr)", + }, + { + key: "createdAtMs", + label: "创建时间", + render: (item) => formatMillis(item.createdAtMs), + width: "minmax(170px, 0.85fr)", + }, + { + key: "actions", + label: "操作", + render: (item, _index, context) => { + const page = context?.page; + return ( + + page.openPassword(item)} + > + + + + ); + }, + resizable: false, + width: "88px", + }, +]; + +export function ExternalAdminUsersPage() { + const page = useExternalAdminUsersPage(); + const items = page.data.items || []; + const total = Number(page.data.total || 0); + + return ( + + + + + + {page.abilities.canCreate ? ( + + + + ) : null} + + } + filters={ + <> + + + + + } + /> + + + 0 + ? { + onPageChange: page.setPage, + page: page.page, + pageSize: page.data.pageSize || page.pageSize, + total, + } + : undefined + } + rowKey={(item) => item.id || `${item.appCode}-${item.username}`} + /> + + + + + + ); +} diff --git a/src/features/external-admin-users/permissions.js b/src/features/external-admin-users/permissions.js new file mode 100644 index 0000000..0d01521 --- /dev/null +++ b/src/features/external-admin-users/permissions.js @@ -0,0 +1,13 @@ +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; + +export function useExternalAdminUserAbilities() { + const { can } = useAuth(); + + return { + canCreate: can(PERMISSIONS.externalAdminUserCreate), + canResetPassword: can(PERMISSIONS.externalAdminUserResetPassword), + canStatus: can(PERMISSIONS.externalAdminUserStatus), + canView: can(PERMISSIONS.externalAdminUserView), + }; +} diff --git a/src/features/external-admin-users/routes.js b/src/features/external-admin-users/routes.js new file mode 100644 index 0000000..f6adec2 --- /dev/null +++ b/src/features/external-admin-users/routes.js @@ -0,0 +1,13 @@ +import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; + +export const externalAdminUserRoutes = [ + { + label: "外管用户", + loader: () => + import("./pages/ExternalAdminUsersPage.jsx").then((module) => module.ExternalAdminUsersPage), + menuCode: MENU_CODES.operationExternalAdminUsers, + pageKey: "operation-external-admin-users", + path: "/operations/external-admin-users", + permission: PERMISSIONS.externalAdminUserView, + }, +]; diff --git a/src/features/external-admin-users/schema.test.ts b/src/features/external-admin-users/schema.test.ts new file mode 100644 index 0000000..ca1e605 --- /dev/null +++ b/src/features/external-admin-users/schema.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; +import { + externalAdminUserCreateFormSchema, + externalAdminUserLookupSchema, + externalAdminUserPasswordFormSchema, +} from "./schema"; + +describe("external admin user schemas", () => { + test("accepts Unicode pretty IDs and trims login identifiers", () => { + expect(externalAdminUserLookupSchema.parse({ displayUserId: " 靓号-一号 " })).toEqual({ + displayUserId: "靓号-一号", + }); + + expect( + externalAdminUserCreateFormSchema.parse({ + confirmPassword: " StrongPass123! ", + displayUserId: " VIP-1001 ", + password: " StrongPass123! ", + username: " ops.fami ", + }), + ).toEqual({ + displayUserId: "VIP-1001", + password: " StrongPass123! ", + username: "ops.fami", + }); + }); + + test("keeps password bytes unchanged and rejects mismatched confirmation", () => { + expect( + externalAdminUserPasswordFormSchema.parse({ + confirmPassword: " password-with-spaces ", + password: " password-with-spaces ", + }), + ).toEqual({ password: " password-with-spaces " }); + + expect(() => + externalAdminUserPasswordFormSchema.parse({ + confirmPassword: "DifferentPass123!", + password: "StrongPass123!", + }), + ).toThrow("两次输入的密码不一致"); + }); + + test("rejects all-whitespace credentials that cannot complete first-login rotation", () => { + const result = externalAdminUserCreateFormSchema.safeParse({ + confirmPassword: " ", + displayUserId: "163000", + password: " ", + username: "fami-operator", + }); + + expect(result.success).toBe(false); + }); + + test("enforces the login account character set from the API contract", () => { + const result = externalAdminUserCreateFormSchema.safeParse({ + confirmPassword: "StrongPass123!", + displayUserId: "1001", + password: "StrongPass123!", + username: "运营账号", + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/src/features/external-admin-users/schema.ts b/src/features/external-admin-users/schema.ts new file mode 100644 index 0000000..ca85633 --- /dev/null +++ b/src/features/external-admin-users/schema.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +const displayUserIdSchema = z + .string() + .trim() + .min(1, "请输入用户短 ID / 靓号") + .refine((value) => Array.from(value).length <= 64, "用户短 ID / 靓号不能超过 64 个字符") + // 靓号可能包含 Unicode 字形,不能收窄为纯数字;这里只拦截会破坏查询协议和日志的空白、控制字符。 + .refine((value) => !/[\s\p{Cc}]/u.test(value), "用户短 ID / 靓号不能包含空白或控制字符"); + +const usernameSchema = z + .string() + .trim() + .min(3, "外管账号至少 3 位") + .max(64, "外管账号不能超过 64 个字符") + // 外管系统使用独立登录标识;限制为跨浏览器、网关和审计日志均稳定的 ASCII 账号字符集。 + .regex(/^[A-Za-z0-9._-]+$/, "外管账号只能包含字母、数字、点、下划线和连字符"); + +// 密码属于登录凭证,不能 trim 或自动改写;前端只做长度与确认值校验,服务端会重复校验并仅持久化密码摘要。 +const passwordSchema = z + .string() + .min(8, "密码至少 8 位") + .max(72, "密码不能超过 72 个字符") + .refine((value) => value.trim().length > 0, "密码不能全部为空白字符"); + +export const externalAdminUserLookupSchema = z.object({ + displayUserId: displayUserIdSchema, +}); + +export const externalAdminUserCreateFormSchema = z + .object({ + confirmPassword: passwordSchema, + displayUserId: displayUserIdSchema, + password: passwordSchema, + username: usernameSchema, + }) + .superRefine((value, context) => { + if (value.password !== value.confirmPassword) { + context.addIssue({ code: "custom", message: "两次输入的密码不一致", path: ["confirmPassword"] }); + } + }) + .transform(({ confirmPassword: _confirmPassword, ...payload }) => payload); + +export const externalAdminUserPasswordFormSchema = z + .object({ + confirmPassword: passwordSchema, + password: passwordSchema, + }) + .superRefine((value, context) => { + if (value.password !== value.confirmPassword) { + context.addIssue({ code: "custom", message: "两次输入的密码不一致", path: ["confirmPassword"] }); + } + }) + .transform(({ confirmPassword: _confirmPassword, ...payload }) => payload); + +export const externalAdminUserStatusSchema = z.object({ + status: z.enum(["active", "disabled"]), +}); + +export type ExternalAdminUserCreateForm = z.input; +export type ExternalAdminUserCreatePayload = z.output; +export type ExternalAdminUserPasswordForm = z.input; +export type ExternalAdminUserPasswordPayload = z.output; diff --git a/src/features/roles/hooks/useRolesPage.js b/src/features/roles/hooks/useRolesPage.js index 9fc6878..6c0d0f0 100644 --- a/src/features/roles/hooks/useRolesPage.js +++ b/src/features/roles/hooks/useRolesPage.js @@ -32,7 +32,14 @@ const permissionGroupDefinitions = [ }, { key: "operations", - prefixes: ["coin-ledger", "coin-seller-ledger", "coin-adjustment", "report", "gift-diamond"], + prefixes: [ + "coin-ledger", + "coin-seller-ledger", + "coin-adjustment", + "external-admin-user", + "report", + "gift-diamond", + ], title: "运营管理", }, { key: "payment", prefixes: ["payment-bill", "payment-product", "payment-third-party"], title: "支付管理" }, diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 5f54e3e..5e6e90b 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -52,6 +52,7 @@ export const API_OPERATIONS = { createCountry: "createCountry", createEmojiPack: "createEmojiPack", createExploreTab: "createExploreTab", + createExternalAdminUser: "createExternalAdminUser", createFinanceCoinSellerRechargeOrder: "createFinanceCoinSellerRechargeOrder", createFullServerNoticeFanout: "createFullServerNoticeFanout", createGift: "createGift", @@ -210,6 +211,7 @@ export const API_OPERATIONS = { listEmojiPackCategories: "listEmojiPackCategories", listEmojiPacks: "listEmojiPacks", listExploreTabs: "listExploreTabs", + listExternalAdminUsers: "listExternalAdminUsers", listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders", listFinanceScopeAssignments: "listFinanceScopeAssignments", listFinanceWithdrawalApplications: "listFinanceWithdrawalApplications", @@ -282,6 +284,7 @@ export const API_OPERATIONS = { login: "login", logout: "logout", lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget", + lookupExternalAdminUserTarget: "lookupExternalAdminUserTarget", lookupResourceGrantTarget: "lookupResourceGrantTarget", me: "me", navigationMenus: "navigationMenus", @@ -302,6 +305,7 @@ export const API_OPERATIONS = { replaceRolePermissions: "replaceRolePermissions", replaceUserAppScopes: "replaceUserAppScopes", replaceUserFinanceScopes: "replaceUserFinanceScopes", + resetExternalAdminUserPassword: "resetExternalAdminUserPassword", resetUserPassword: "resetUserPassword", retryActivityTemplateRewardJob: "retryActivityTemplateRewardJob", retryActivityTemplateTaskClaim: "retryActivityTemplateTaskClaim", @@ -343,6 +347,7 @@ export const API_OPERATIONS = { updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig", updateDiceConfig: "updateDiceConfig", updateExploreTab: "updateExploreTab", + updateExternalAdminUserStatus: "updateExternalAdminUserStatus", updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig", updateGift: "updateGift", updateGiftType: "updateGiftType", @@ -675,6 +680,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:update", permissions: ["app-config:update"] }, + createExternalAdminUser: { + method: "POST", + operationId: API_OPERATIONS.createExternalAdminUser, + path: "/v1/admin/external-admin-users", + permission: "external-admin-user:create", + permissions: ["external-admin-user:create"] + }, createFinanceCoinSellerRechargeOrder: { method: "POST", operationId: API_OPERATIONS.createFinanceCoinSellerRechargeOrder, @@ -1779,6 +1791,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:view", permissions: ["app-config:view"] }, + listExternalAdminUsers: { + method: "GET", + operationId: API_OPERATIONS.listExternalAdminUsers, + path: "/v1/admin/external-admin-users", + permission: "external-admin-user:view", + permissions: ["external-admin-user:view"] + }, listFinanceCoinSellerRechargeOrders: { method: "GET", operationId: API_OPERATIONS.listFinanceCoinSellerRechargeOrders, @@ -2277,6 +2296,13 @@ export const API_ENDPOINTS: Record = { permission: "coin-adjustment:create", permissions: ["coin-adjustment:create"] }, + lookupExternalAdminUserTarget: { + method: "GET", + operationId: API_OPERATIONS.lookupExternalAdminUserTarget, + path: "/v1/admin/external-admin-users/target", + permission: "external-admin-user:create", + permissions: ["external-admin-user:create"] + }, lookupResourceGrantTarget: { method: "GET", operationId: API_OPERATIONS.lookupResourceGrantTarget, @@ -2411,6 +2437,13 @@ export const API_ENDPOINTS: Record = { permission: "user:update", permissions: ["user:update"] }, + resetExternalAdminUserPassword: { + method: "POST", + operationId: API_OPERATIONS.resetExternalAdminUserPassword, + path: "/v1/admin/external-admin-users/{id}/password", + permission: "external-admin-user:reset-password", + permissions: ["external-admin-user:reset-password"] + }, resetUserPassword: { method: "POST", operationId: API_OPERATIONS.resetUserPassword, @@ -2696,6 +2729,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:update", permissions: ["app-config:update"] }, + updateExternalAdminUserStatus: { + method: "PATCH", + operationId: API_OPERATIONS.updateExternalAdminUserStatus, + path: "/v1/admin/external-admin-users/{id}/status", + permission: "external-admin-user:status", + permissions: ["external-admin-user:status"] + }, updateFirstRechargeRewardConfig: { method: "PUT", operationId: API_OPERATIONS.updateFirstRechargeRewardConfig, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index be01785..03c5917 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -3612,6 +3612,70 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/external-admin-users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listExternalAdminUsers"]; + put?: never; + post: operations["createExternalAdminUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/external-admin-users/target": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["lookupExternalAdminUserTarget"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/external-admin-users/{id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updateExternalAdminUserStatus"]; + trace?: never; + }; + "/admin/external-admin-users/{id}/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["resetExternalAdminUserPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/exports/app-users": { parameters: { query?: never; @@ -4739,6 +4803,64 @@ export interface components { ApiResponseAppUserAccessToken: components["schemas"]["Envelope"] & { data?: components["schemas"]["AppUserAccessToken"]; }; + ApiResponseExternalAdminUserPage: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ApiPageExternalAdminUser"]; + }; + ApiResponseExternalAdminUser: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ExternalAdminUser"]; + }; + ApiResponseExternalAdminUserTarget: components["schemas"]["Envelope"] & { + data?: components["schemas"]["ExternalAdminUserTargetLookup"]; + }; + ApiPageExternalAdminUser: { + items: components["schemas"]["ExternalAdminUser"][]; + page: number; + pageSize: number; + /** Format: int64 */ + total: number; + }; + ExternalAdminUserTarget: { + userId: string; + displayUserId: string; + defaultDisplayUserId?: string; + prettyDisplayUserId?: string; + prettyId?: string; + username?: string; + avatar?: string; + status: string; + }; + ExternalAdminUserTargetLookup: { + linkedUser: components["schemas"]["ExternalAdminUserTarget"]; + existingExternalAdminUser?: components["schemas"]["ExternalAdminUser"]; + }; + ExternalAdminUser: { + id: number; + appCode: string; + username: string; + /** @enum {string} */ + status: "active" | "disabled"; + linkedUser: components["schemas"]["ExternalAdminUserTarget"]; + permissions: string[]; + createdByAdminId?: number; + /** Format: int64 */ + lastLoginAtMs?: number | null; + /** Format: int64 */ + createdAtMs: number; + /** Format: int64 */ + updatedAtMs?: number; + }; + ExternalAdminUserCreateInput: { + targetUserId: string; + username: string; + password: string; + }; + ExternalAdminUserStatusInput: { + /** @enum {string} */ + status: "active" | "disabled"; + }; + ExternalAdminUserPasswordInput: { + password: string; + }; ApiPageAppUser: { items: components["schemas"]["AppUser"][]; page: number; @@ -6898,6 +7020,33 @@ export interface components { }; }; /** @description OK */ + ExternalAdminUserPageResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseExternalAdminUserPage"]; + }; + }; + /** @description OK */ + ExternalAdminUserResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseExternalAdminUser"]; + }; + }; + /** @description OK */ + ExternalAdminUserTargetResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseExternalAdminUserTarget"]; + }; + }; + /** @description OK */ AgencyPageResponse: { headers: { [name: string]: unknown; @@ -12056,6 +12205,99 @@ export interface operations { 200: components["responses"]["AppUserLoginLogPageResponse"]; }; }; + listExternalAdminUsers: { + parameters: { + query?: { + page?: components["parameters"]["Page"]; + page_size?: components["parameters"]["PageSize"]; + keyword?: components["parameters"]["Keyword"]; + status?: components["parameters"]["Status"]; + }; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ExternalAdminUserPageResponse"]; + }; + }; + createExternalAdminUser: { + parameters: { + query?: never; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExternalAdminUserCreateInput"]; + }; + }; + responses: { + 201: components["responses"]["ExternalAdminUserResponse"]; + }; + }; + lookupExternalAdminUserTarget: { + parameters: { + query: { + display_user_id: string; + }; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["ExternalAdminUserTargetResponse"]; + }; + }; + updateExternalAdminUserStatus: { + parameters: { + query?: never; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExternalAdminUserStatusInput"]; + }; + }; + responses: { + 200: components["responses"]["ExternalAdminUserResponse"]; + }; + }; + resetExternalAdminUserPassword: { + parameters: { + query?: never; + header: { + "X-App-Code": components["parameters"]["AppCodeHeader"]; + }; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExternalAdminUserPasswordInput"]; + }; + }; + responses: { + 200: components["responses"]["ExternalAdminUserResponse"]; + }; + }; appExportUsers: { parameters: { query?: { diff --git a/vite.config.js b/vite.config.js index 77e7ea9..bc6aeb6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -function redirectSubsystemEntry(req, res, next) { +export function redirectSubsystemEntry(req, res, next) { const rawUrl = req.url || ""; const [pathname, query = ""] = rawUrl.split("?"); @@ -19,7 +19,7 @@ function redirectSubsystemEntry(req, res, next) { return; } - if (pathname === "/databi" || pathname === "/finance") { + if (pathname === "/databi" || pathname === "/finance" || pathname === "/external-admin") { res.statusCode = 301; res.setHeader("Location", `${pathname}/${query ? `?${query}` : ""}`); res.end(); @@ -33,6 +33,16 @@ function redirectSubsystemEntry(req, res, next) { return; } + if ( + pathname.startsWith("/external-admin/") && + pathname !== "/external-admin/" && + !pathname.startsWith("/external-admin/src/") && + !pathname.split("/").at(-1).includes(".") + ) { + // Vite's generic SPA fallback points unknown paths at the main admin entry; keep external-admin deep links inside its isolated document. + req.url = `/external-admin/index.html${query ? `?${query}` : ""}`; + } + next(); } @@ -68,6 +78,7 @@ export default defineConfig({ databi: new URL("./databi/index.html", import.meta.url).pathname, databiSocial: new URL("./databi/social/index.html", import.meta.url).pathname, finance: new URL("./finance/index.html", import.meta.url).pathname, + externalAdmin: new URL("./external-admin/index.html", import.meta.url).pathname, moneyRedirect: new URL("./money/index.html", import.meta.url).pathname, opsCenter: new URL("./ops-center/index.html", import.meta.url).pathname } From 66b10b4f1758bfdd6fd52c63334295a0ea2fc60d Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 16:11:05 +0800 Subject: [PATCH 04/14] feat(ops-center): manage strategy-specific lucky pools --- contracts/admin-openapi.json | 199 ++++++++++++++ ops-center/src/OpsCenterApp.jsx | 89 ++++++- ops-center/src/OpsCenterApp.test.jsx | 147 +++++++++-- ops-center/src/OpsCenterEntry.jsx | 26 ++ ops-center/src/OpsCenterEntry.test.jsx | 100 +++++++ ops-center/src/api.js | 155 +++++++---- ops-center/src/api.test.js | 131 ++++++++- ops-center/src/components/ConfigsView.jsx | 126 +++++++-- .../src/components/LuckyGiftConfigDialog.jsx | 12 +- .../src/components/LuckyGiftPoolActions.jsx | 48 ++++ .../LuckyGiftPoolAdjustmentDialog.jsx | 132 ++++++++++ ops-center/src/components/OverviewView.jsx | 249 +++++++++++------- ops-center/src/configForm.js | 15 +- ops-center/src/configForm.test.js | 20 +- ops-center/src/main.jsx | 21 +- ops-center/src/styles/index.css | 38 +++ src/app/permissions.ts | 2 + src/shared/api/generated/endpoints.ts | 16 ++ src/shared/api/generated/schema.d.ts | 167 ++++++++++++ 19 files changed, 1481 insertions(+), 212 deletions(-) create mode 100644 ops-center/src/OpsCenterEntry.jsx create mode 100644 ops-center/src/OpsCenterEntry.test.jsx create mode 100644 ops-center/src/components/LuckyGiftPoolActions.jsx create mode 100644 ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index ce8a452..5165a7f 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -390,6 +390,86 @@ "x-permissions": ["lucky-gift:view"] } }, + "/admin/ops-center/lucky-gifts/pools/credit": { + "post": { + "operationId": "creditLuckyGiftPool", + "parameters": [ + { + "in": "query", + "name": "app_code", + "required": true, + "schema": { "type": "string", "minLength": 1, "maxLength": 32 } + }, + { + "in": "query", + "name": "pool_id", + "required": true, + "schema": { "type": "string", "minLength": 1, "maxLength": 96 } + }, + { + "in": "query", + "name": "strategy_version", + "required": true, + "schema": { "type": "string", "enum": ["fixed_v2", "dynamic_v3"] } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentInput" } + } + } + }, + "responses": { + "200": { "$ref": "#/components/responses/LuckyGiftPoolAdjustmentResponse" }, + "400": { "description": "调整参数不正确" }, + "409": { "description": "幂等键冲突或可用水位不足" } + }, + "x-permission": "lucky-gift:pool-credit", + "x-permissions": ["lucky-gift:pool-credit"] + } + }, + "/admin/ops-center/lucky-gifts/pools/debit": { + "post": { + "operationId": "debitLuckyGiftPool", + "parameters": [ + { + "in": "query", + "name": "app_code", + "required": true, + "schema": { "type": "string", "minLength": 1, "maxLength": 32 } + }, + { + "in": "query", + "name": "pool_id", + "required": true, + "schema": { "type": "string", "minLength": 1, "maxLength": 96 } + }, + { + "in": "query", + "name": "strategy_version", + "required": true, + "schema": { "type": "string", "enum": ["fixed_v2", "dynamic_v3"] } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentInput" } + } + } + }, + "responses": { + "200": { "$ref": "#/components/responses/LuckyGiftPoolAdjustmentResponse" }, + "400": { "description": "调整参数不正确" }, + "409": { "description": "幂等键冲突或可用水位不足" } + }, + "x-permission": "lucky-gift:pool-debit", + "x-permissions": ["lucky-gift:pool-debit"] + } + }, "/admin/activity/wheel/config": { "get": { "operationId": "getWheelConfig", @@ -8772,6 +8852,16 @@ } } }, + "LuckyGiftPoolAdjustmentResponse": { + "description": "奖池水位调整成功或同幂等请求的首次结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseLuckyGiftPoolAdjustmentResult" + } + } + } + }, "RechargeBillPageResponse": { "description": "OK", "content": { @@ -9184,6 +9274,115 @@ } }, "schemas": { + "LuckyGiftPoolAdjustmentInput": { + "type": "object", + "additionalProperties": false, + "required": ["adjustment_id", "amount_coins", "reason"], + "properties": { + "adjustment_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "amount_coins": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + }, + "LuckyGiftPoolBalance": { + "type": "object", + "required": [ + "app_code", + "pool_id", + "strategy_version", + "balance", + "reserve_floor", + "available_balance", + "total_in", + "total_out", + "manual_credit_total", + "manual_debit_total", + "materialized", + "updated_at_ms" + ], + "properties": { + "app_code": { "type": "string" }, + "pool_id": { "type": "string" }, + "strategy_version": { + "type": "string", + "enum": ["fixed_v2", "dynamic_v3"] + }, + "balance": { "type": "integer", "format": "int64" }, + "reserve_floor": { "type": "integer", "format": "int64" }, + "available_balance": { "type": "integer", "format": "int64" }, + "total_in": { "type": "integer", "format": "int64" }, + "total_out": { "type": "integer", "format": "int64" }, + "manual_credit_total": { "type": "integer", "format": "int64" }, + "manual_debit_total": { "type": "integer", "format": "int64" }, + "materialized": { "type": "boolean" }, + "updated_at_ms": { "type": "integer", "format": "int64" } + } + }, + "LuckyGiftPoolAdjustment": { + "type": "object", + "required": [ + "adjustment_id", + "app_code", + "pool_id", + "strategy_version", + "direction", + "amount_coins", + "reason", + "operator_admin_id", + "balance_before", + "balance_after", + "created_at_ms" + ], + "properties": { + "adjustment_id": { "type": "string" }, + "app_code": { "type": "string" }, + "pool_id": { "type": "string" }, + "strategy_version": { + "type": "string", + "enum": ["fixed_v2", "dynamic_v3"] + }, + "direction": { "type": "string", "enum": ["in", "out"] }, + "amount_coins": { "type": "integer", "format": "int64" }, + "reason": { "type": "string" }, + "operator_admin_id": { "type": "integer", "format": "int64" }, + "balance_before": { "type": "integer", "format": "int64" }, + "balance_after": { "type": "integer", "format": "int64" }, + "created_at_ms": { "type": "integer", "format": "int64" } + } + }, + "LuckyGiftPoolAdjustmentResult": { + "type": "object", + "required": ["adjustment", "pool", "idempotent_replay"], + "properties": { + "adjustment": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustment" }, + "pool": { "$ref": "#/components/schemas/LuckyGiftPoolBalance" }, + "idempotent_replay": { "type": "boolean" } + } + }, + "ApiResponseLuckyGiftPoolAdjustmentResult": { + "allOf": [ + { "$ref": "#/components/schemas/Envelope" }, + { + "type": "object", + "properties": { + "requestId": { "type": "string" }, + "data": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentResult" } + } + } + ] + }, "ApiResponseAdminAppList": { "allOf": [ { diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx index 7d0e75b..54e461e 100644 --- a/ops-center/src/OpsCenterApp.jsx +++ b/ops-center/src/OpsCenterApp.jsx @@ -4,17 +4,20 @@ import CasinoOutlined from "@mui/icons-material/CasinoOutlined"; import InsightsOutlined from "@mui/icons-material/InsightsOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { PERMISSIONS } from "@/app/permissions"; import { Button } from "@/shared/ui/Button.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { PageHead } from "@/shared/ui/PageHead.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; -import { DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; +import { adjustLuckyGiftPool, DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; import { AppsView } from "./components/AppsView.jsx"; import { ConfigsView } from "./components/ConfigsView.jsx"; import { DrawsView } from "./components/DrawsView.jsx"; import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx"; +import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx"; import { OpsShell } from "./components/OpsShell.jsx"; import { OverviewView } from "./components/OverviewView.jsx"; +import { formatNumber } from "./format.js"; const views = [ { icon: InsightsOutlined, key: "overview", label: "数据总览" }, @@ -37,13 +40,20 @@ export function initialOpsCenterView(search = globalThis.location?.search || "") return viewKeys.has(requested) ? requested : "overview"; } -export function OpsCenterApp() { +const denyPermission = () => false; + +export function OpsCenterApp({ can = denyPermission }) { // 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。 const [activeView, setActiveView] = useState(() => initialOpsCenterView()); const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true }); const [dialog, setDialog] = useState(null); + const [poolDialog, setPoolDialog] = useState(null); + const [adjustingPool, setAdjustingPool] = useState(false); const [savingConfig, setSavingConfig] = useState(false); const { showToast } = useToast(); + const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit); + const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit); + const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate); const loadDashboard = useCallback(async () => { setState((current) => ({ ...current, error: "", loading: true })); @@ -124,6 +134,18 @@ export function OpsCenterApp() { setDialog({ config: structuredClone(config), mode: "edit" }); }, []); + const openPoolAdjustment = useCallback((pool, direction) => { + if (!pool) { + return; + } + // 幂等键绑定一次弹窗会话,而不是一次 fetch;网络超时或 409 后弹窗保留,后续重试不会重复入账。 + setPoolDialog({ + adjustmentId: createPoolAdjustmentID(), + direction, + pool: structuredClone(pool), + }); + }, []); + const handleSaveConfig = useCallback( async (config) => { setSavingConfig(true); @@ -142,6 +164,31 @@ export function OpsCenterApp() { [loadDashboard, showToast], ); + const handleAdjustPool = useCallback( + async (request) => { + setAdjustingPool(true); + try { + const result = await adjustLuckyGiftPool(request); + const adjustment = result.adjustment || {}; + const action = request.direction === "out" ? "扣减" : "添加"; + showToast( + `已${action}水位 ${request.appCode} / ${request.poolId} / ${request.strategyVersion}:${formatNumber( + adjustment.balance_before, + )} → ${formatNumber(adjustment.balance_after)}`, + "success", + ); + setPoolDialog(null); + await loadDashboard(); + } catch (error) { + // owner 可能已经成功但响应丢失;保留表单与 adjustment_id,用户重试时由 owner 返回首次结果而不重复调整。 + showToast(error.message || "调整奖池水位失败", "error"); + } finally { + setAdjustingPool(false); + } + }, + [loadDashboard, showToast], + ); + const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? ""; return ( @@ -170,13 +217,29 @@ export function OpsCenterApp() { loading={state.loading && !state.loaded} onRetry={loadDashboard} > - {activeView === "overview" ? : null} + {activeView === "overview" ? ( + openPoolAdjustment(pool, "in")} + onDebit={(pool) => openPoolAdjustment(pool, "out")} + /> + ) : null} {activeView === "configs" ? ( openPoolAdjustment(pool, "in")} + onDebit={(pool) => openPoolAdjustment(pool, "out")} onEdit={openEditConfig} /> ) : null} @@ -195,7 +258,27 @@ export function OpsCenterApp() { onSubmit={handleSaveConfig} /> ) : null} + {poolDialog ? ( + setPoolDialog(null)} + onSubmit={handleAdjustPool} + /> + ) : null} ); } + +let fallbackAdjustmentSequence = 0; + +function createPoolAdjustmentID() { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + fallbackAdjustmentSequence += 1; + return `pool-adjust-${Date.now().toString(36)}-${fallbackAdjustmentSequence.toString(36)}`; +} diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index c5d4e21..4f30e7c 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -1,13 +1,17 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; +import { PERMISSIONS } from "@/app/permissions"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; -import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; +import { adjustLuckyGiftPool, fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx"; vi.mock("./api.js", () => ({ DEFAULT_POOL_ID: "default", + adjustLuckyGiftPool: vi.fn(), fetchLuckyGiftDraws: vi.fn(), fetchOpsDashboard: vi.fn(), + luckyGiftPoolKey: (item = {}) => + `${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`, saveLuckyGiftConfig: vi.fn(), })); @@ -15,6 +19,10 @@ beforeEach(() => { vi.clearAllMocks(); window.history.replaceState({}, "", "/"); saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); + adjustLuckyGiftPool.mockResolvedValue({ + adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 }, + idempotent_replay: false, + }); fetchLuckyGiftDraws.mockResolvedValue({ items: [ { @@ -62,6 +70,14 @@ beforeEach(() => { pool_id: "lucky", rule_version: 3, }, + { + ...mockFixedConfig(95), + app_code: "lalu", + enabled: false, + is_default: false, + pool_id: "lucky", + rule_version: 2, + }, { ...mockFixedConfig(92), app_code: "lalu", @@ -84,9 +100,24 @@ beforeEach(() => { app_code: "lalu", available_balance: 37950017, balance: 38559017, + manual_credit_total: 1_000_000, + manual_debit_total: 200_000, materialized: true, pool_id: "lucky", reserve_floor: 609000, + strategy_version: "dynamic_v3", + updated_at_ms: 1767000000000, + }, + { + app_code: "lalu", + available_balance: 0, + balance: 900, + manual_credit_total: 100, + manual_debit_total: 20, + materialized: true, + pool_id: "lucky", + reserve_floor: 900, + strategy_version: "fixed_v2", updated_at_ms: 1767000000000, }, { @@ -96,6 +127,7 @@ beforeEach(() => { materialized: false, pool_id: "default", reserve_floor: 21000, + strategy_version: "dynamic_v3", }, ], summary: { @@ -115,10 +147,12 @@ beforeEach(() => { }); }); -function renderApp() { +const allPermissions = [PERMISSIONS.luckyGiftPoolCredit, PERMISSIONS.luckyGiftPoolDebit, PERMISSIONS.luckyGiftUpdate]; + +function renderApp(permissions = allPermissions) { return render( - + permissions.includes(code)} /> , ); } @@ -137,7 +171,7 @@ test("renders overview with aggregated kpis and pool balances", async () => { expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0); expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0); expect(screen.getByText("奖池实时水位")).toBeTruthy(); - expect(screen.getByText("已入账")).toBeTruthy(); + expect(screen.getAllByText("已入账").length).toBeGreaterThan(1); expect(screen.getByText("默认水位")).toBeTruthy(); expect(fetchOpsDashboard).toHaveBeenCalledTimes(1); }); @@ -150,7 +184,7 @@ test("opens the requested config view from the legacy admin document redirect", renderApp(); expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy(); - expect(screen.getByText("lucky")).toBeTruthy(); + expect(screen.getAllByText("lucky").length).toBeGreaterThan(0); }); test("lists every pool config including published non-default pools", async () => { @@ -160,15 +194,92 @@ test("lists every pool config including published non-default pools", async () = fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); // 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。 - expect(await screen.findByText("lucky")).toBeTruthy(); + expect((await screen.findAllByText("lucky")).length).toBeGreaterThan(1); expect(screen.getByText("super_lucky")).toBeTruthy(); expect(screen.getByText("v3")).toBeTruthy(); - expect(screen.getByText("v2")).toBeTruthy(); + expect(screen.getAllByText("v2").length).toBeGreaterThan(0); expect(screen.getByText("已启用")).toBeTruthy(); - expect(screen.getByText("已停用")).toBeTruthy(); + expect(screen.getAllByText("已停用").length).toBeGreaterThan(1); expect(screen.getByText("默认草稿")).toBeTruthy(); }); +test("shows strategy-specific balances and adjustment actions directly in the config list", async () => { + renderApp(); + await screen.findByText("运营应用"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + + expect(await screen.findByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / fixed_v2" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / fixed_v2" })).toBeDisabled(); + expect(screen.getByText("38,559,017")).toBeTruthy(); + expect(screen.getByText("900")).toBeTruthy(); +}); + +test("shows credit and debit buttons only when the standalone session grants each permission", async () => { + renderApp([PERMISSIONS.luckyGiftPoolCredit]); + await screen.findByText("运营应用"); + + expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + expect(screen.queryByRole("button", { name: "添加配置" })).toBeNull(); + expect(screen.queryByRole("button", { name: /新增版本 lalu/ })).toBeNull(); +}); + +test("submits a pool credit, reports before and after balances, closes, and refreshes", async () => { + renderApp(); + await screen.findByText("运营应用"); + fireEvent.click(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })); + + const dialog = await screen.findByRole("dialog", { name: "添加奖池水位" }); + expect(within(dialog).getByText("dynamic_v3")).toBeTruthy(); + fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "1000000" } }); + fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "运营补水" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "确认添加" })); + + await waitFor(() => { + expect(adjustLuckyGiftPool).toHaveBeenCalledWith( + expect.objectContaining({ + adjustmentId: expect.any(String), + amountCoins: 1_000_000, + appCode: "lalu", + direction: "in", + poolId: "lucky", + reason: "运营补水", + strategyVersion: "dynamic_v3", + }), + ); + }); + expect(await screen.findByText(/38,559,017 → 39,559,017/)).toBeTruthy(); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "添加奖池水位" })).toBeNull()); + expect(fetchOpsDashboard).toHaveBeenCalledTimes(2); +}); + +test("keeps the form and adjustment id stable when a debit request is retried", async () => { + adjustLuckyGiftPool.mockRejectedValueOnce(new Error("可用水位已变化")).mockResolvedValueOnce({ + adjustment: { balance_after: 38_558_917, balance_before: 38_559_017 }, + idempotent_replay: false, + }); + renderApp(); + await screen.findByText("运营应用"); + fireEvent.click(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })); + + const dialog = await screen.findByRole("dialog", { name: "扣减奖池水位" }); + fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "100" } }); + fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "冲正" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" })); + + expect(await screen.findByText("可用水位已变化")).toBeTruthy(); + expect(within(dialog).getByLabelText(/调整金币/)).toHaveValue("100"); + expect(within(dialog).getByLabelText(/调整原因/)).toHaveValue("冲正"); + const firstAdjustmentID = adjustLuckyGiftPool.mock.calls[0][0].adjustmentId; + + fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" })); + await waitFor(() => expect(adjustLuckyGiftPool).toHaveBeenCalledTimes(2)); + expect(adjustLuckyGiftPool.mock.calls[1][0].adjustmentId).toBe(firstAdjustmentID); +}); + test("creates a disabled dynamic draft without copying another app monetary limits", async () => { renderApp(); await screen.findByText("运营应用"); @@ -178,7 +289,7 @@ test("creates a disabled dynamic draft without copying another app monetary limi const dialog = await screen.findByRole("dialog"); expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy(); expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3"); - expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000"); + expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0"); // 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。 expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0"); expect(within(dialog).getByText("发布后保持停用")).toBeTruthy(); @@ -189,13 +300,14 @@ test("opens config dialog from a published pool row and saves a new version", as await screen.findByText("运营应用"); fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - await screen.findByText("lucky"); + await screen.findAllByText("lucky"); - fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); + fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" })); const dialog = await screen.findByRole("dialog"); expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy(); expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3"); - expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000"); + expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0"); + expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled(); expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000"); expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true); @@ -207,6 +319,7 @@ test("opens config dialog from a published pool row and saves a new version", as expect.objectContaining({ app_code: "lalu", anchor_rate_percent: 1, + initial_pool_coins: 0, jackpot_multipliers: [200, 500, 1000], pool_id: "lucky", profit_rate_percent: 1, @@ -222,14 +335,14 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async renderApp(); await screen.findByText("运营应用"); fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - await screen.findByText("lucky"); - fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); + await screen.findAllByText("lucky"); + fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" })); const dialog = await screen.findByRole("dialog"); - fireEvent.change(within(dialog).getByLabelText(/初始奖池金币/), { target: { value: "0" } }); + fireEvent.change(within(dialog).getByLabelText(/低水位金币/), { target: { value: "0" } }); fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); - expect(await within(dialog).findByRole("alert")).toHaveTextContent("初始奖池必须大于 0"); + expect(await within(dialog).findByRole("alert")).toHaveTextContent("高水位必须大于正数低水位"); expect(saveLuckyGiftConfig).not.toHaveBeenCalled(); // 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。 @@ -247,7 +360,7 @@ function mockDynamicConfig() { gift_price_reference: 100, high_water_nonzero_factor_percent: 130, high_watermark_coins: 20_000_000, - initial_pool_coins: 1_000_000, + initial_pool_coins: 0, jackpot_global_rtp_max_percent: 98, jackpot_multipliers: [200, 500, 1000], jackpot_spend_threshold_coins: 50_000, diff --git a/ops-center/src/OpsCenterEntry.jsx b/ops-center/src/OpsCenterEntry.jsx new file mode 100644 index 0000000..43a32ed --- /dev/null +++ b/ops-center/src/OpsCenterEntry.jsx @@ -0,0 +1,26 @@ +import { useEffect } from "react"; +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js"; +import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx"; +import { OpsCenterApp } from "./OpsCenterApp.jsx"; + +export function AuthenticatedOpsCenter({ targetWindow = window }) { + const { can, loading, user } = useAuth(); + const loginPath = buildLoginRedirectPath(targetWindow.location); + + useEffect(() => { + if (!loading && !user) { + // ops-center 是独立 HTML 入口,不能使用 SPA Navigate。登录页沿用主站既有 `redirect` 协议, + // 成功后 LoginPage 会识别 /ops-center/ 为 document entry,再通过 location.replace 回到完整路径。 + targetWindow.location.replace(loginPath); + } + }, [loading, loginPath, targetWindow, user]); + + if (loading || !user) { + // AuthProvider 可能正在用 refresh cookie 更新过期 token,完成前不能挂载 OpsCenterApp; + // 否则 dashboard 的并发请求会先拿旧 token 返回 401,且认证完成后没有新的挂载时机自动恢复。 + return ; + } + + return ; +} diff --git a/ops-center/src/OpsCenterEntry.test.jsx b/ops-center/src/OpsCenterEntry.test.jsx new file mode 100644 index 0000000..fabce8e --- /dev/null +++ b/ops-center/src/OpsCenterEntry.test.jsx @@ -0,0 +1,100 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, test, vi } from "vitest"; +import { AuthProvider } from "@/app/auth/AuthProvider.jsx"; +import * as authApi from "@/features/auth/api"; +import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request"; +import { AuthenticatedOpsCenter } from "./OpsCenterEntry.jsx"; + +vi.mock("@/features/auth/api", () => ({ + getMe: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + refreshSession: vi.fn(), +})); + +vi.mock("./OpsCenterApp.jsx", () => ({ + OpsCenterApp: ({ can }) => ( +
+ ), +})); + +beforeEach(() => { + vi.clearAllMocks(); + setAccessToken(""); + setRefreshHandler(null); + setUnauthorizedHandler(null); +}); + +test("does not mount the dashboard without a session and redirects to login with the full ops path", async () => { + authApi.refreshSession.mockRejectedValue(new Error("no session")); + const targetWindow = fakeWindow("/ops-center/", "?view=configs", "#dynamic_v3"); + + render( + + + , + ); + + // refresh 尚未结束以及确定无用户后的跳转窗口都只展示骨架,Dashboard 不会发出任何业务请求。 + expect(screen.queryByTestId("ops-center-app")).toBeNull(); + await waitFor(() => { + expect(targetWindow.location.replace).toHaveBeenCalledWith( + "/login?redirect=%2Fops-center%2F%3Fview%3Dconfigs%23dynamic_v3", + ); + }); + expect(screen.queryByTestId("ops-center-app")).toBeNull(); +}); + +test("waits for stale-token refresh fallback before mounting the dashboard", async () => { + const refresh = deferred(); + const me = deferred(); + setAccessToken("stale-token"); + authApi.refreshSession.mockReturnValue(refresh.promise); + authApi.getMe.mockReturnValue(me.promise); + const targetWindow = fakeWindow("/ops-center/", "?view=overview", ""); + + render( + + + , + ); + + expect(screen.queryByTestId("ops-center-app")).toBeNull(); + await act(async () => { + refresh.reject(new Error("refresh cookie expired")); + }); + await waitFor(() => expect(authApi.getMe).toHaveBeenCalledTimes(1)); + expect(screen.queryByTestId("ops-center-app")).toBeNull(); + + await act(async () => { + me.resolve({ + accessToken: "fresh-token", + permissions: ["lucky-gift:pool-credit"], + user: { id: 1, username: "ops" }, + }); + }); + + expect(await screen.findByTestId("ops-center-app")).toHaveAttribute("data-can-credit", "true"); + expect(targetWindow.location.replace).not.toHaveBeenCalled(); +}); + +function fakeWindow(pathname, search, hash) { + return { + location: { + hash, + pathname, + replace: vi.fn(), + search, + }, + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} diff --git a/ops-center/src/api.js b/ops-center/src/api.js index 9095cce..7ef47d5 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -1,4 +1,5 @@ -import { getAccessToken } from "@/shared/api/request"; +import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; +import { apiRequest } from "@/shared/api/request"; export const OPS_API_PREFIX = "/api/v1/admin/ops-center"; export const DEFAULT_POOL_ID = "default"; @@ -87,20 +88,50 @@ export async function saveLuckyGiftConfig(config = {}) { }); } -export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) { - const response = await fetch(buildOpsUrl(path, query), { - body: body === undefined ? undefined : JSON.stringify(body), - credentials: "include", - headers: buildHeaders(body), - method, - }); - const payload = await readPayload(response); - - if (!response.ok || payload.code !== 0) { - throw new Error(payload.message || response.statusText || "运营接口请求失败"); +export async function adjustLuckyGiftPool({ + adjustmentId, + amountCoins, + appCode, + direction, + poolId, + reason, + strategyVersion, +} = {}) { + if (direction !== "in" && direction !== "out") { + throw new Error("奖池水位调整方向不正确"); } + const operation = direction === "out" ? API_OPERATIONS.debitLuckyGiftPool : API_OPERATIONS.creditLuckyGiftPool; + const operationPath = apiEndpointPath(operation); - return payload.data ?? {}; + // adjustment_id 是 owner 侧资金变更的幂等键,同一个弹窗失败重试必须原样复用;request_id 只做链路追踪, + // 不能替代业务幂等。方向映射到两条独立路由,使前后端权限也能按补水、扣水分别收敛。 + const payload = await opsRequest(opsRelativePath(operationPath), { + body: { + adjustment_id: String(adjustmentId || "").trim(), + amount_coins: numberValue(amountCoins), + reason: String(reason || "").trim(), + }, + method: "POST", + query: { + app_code: String(appCode || "") + .trim() + .toLowerCase(), + pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID, + strategy_version: normalizeStrategyVersion(strategyVersion), + }, + }); + return normalizePoolAdjustmentResult(payload); +} + +export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + // 复用主后台请求层才能共享 refreshOnce:运行期间 token 过期时先刷新再重放一次请求。 + // 水位写接口携带稳定 adjustment_id,因此认证重放仍由 owner 幂等收敛;409 文案继续原样抛给弹窗保留表单。 + return apiRequest(`/v1/admin/ops-center${normalizedPath}`, { + body, + method, + query, + }); } export function buildOpsUrl(path, query = {}) { @@ -119,8 +150,8 @@ export function buildOpsUrl(path, query = {}) { export function normalizeDashboard({ apps = [], perApp = [] } = {}) { const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); const poolBalances = perApp - .flatMap((entry) => entry.poolBalances || []) - .filter((item) => item?.app_code || item?.appCode); + .flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode))) + .filter((item) => item.app_code); const appSummaries = perApp .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) .filter((summary) => summary.app_code); @@ -146,6 +177,37 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) { return { apps, appSummaries, luckyGifts, poolBalances, summary }; } +export function normalizePoolBalance(pool = {}, fallbackAppCode = "") { + return { + ...pool, + app_code: String(pool.app_code || pool.appCode || fallbackAppCode) + .trim() + .toLowerCase(), + available_balance: numberValue(pool.available_balance ?? pool.availableBalance), + balance: numberValue(pool.balance ?? pool.Balance), + manual_credit_total: numberValue(pool.manual_credit_total ?? pool.manualCreditTotal), + manual_debit_total: numberValue(pool.manual_debit_total ?? pool.manualDebitTotal), + materialized: Boolean(pool.materialized), + pool_id: String(pool.pool_id || pool.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID, + reserve_floor: numberValue(pool.reserve_floor ?? pool.reserveFloor), + strategy_version: normalizeStrategyVersion(pool.strategy_version ?? pool.strategyVersion), + total_in: numberValue(pool.total_in ?? pool.totalIn), + total_out: numberValue(pool.total_out ?? pool.totalOut), + updated_at_ms: numberValue(pool.updated_at_ms ?? pool.updatedAtMs), + }; +} + +export function luckyGiftPoolKey(item = {}) { + const appCode = String(item.app_code || item.appCode || "") + .trim() + .toLowerCase(); + const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; + const strategyVersion = normalizeStrategyVersion(item.strategy_version ?? item.strategyVersion); + // V2/V3 资金由 owner 按策略版本隔离;任何列表关联或 React rowKey 都必须包含 strategy_version, + // 否则同一 app+pool 的两口奖池会在前端被覆盖成一行。 + return `${appCode}:${poolID}:${strategyVersion}`; +} + function normalizeDrawSummary(summary = {}, appCode = "") { return { actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), @@ -192,12 +254,39 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") { pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, rule_version: ruleVersion, // 升级前规则没有 strategy_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。 - strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2") - .trim() - .toLowerCase(), + strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion), }; } +function normalizePoolAdjustmentResult(payload = {}) { + const adjustment = payload.adjustment || {}; + return { + ...payload, + adjustment: { + ...adjustment, + amount_coins: numberValue(adjustment.amount_coins ?? adjustment.amountCoins), + balance_after: numberValue(adjustment.balance_after ?? adjustment.balanceAfter), + balance_before: numberValue(adjustment.balance_before ?? adjustment.balanceBefore), + strategy_version: normalizeStrategyVersion(adjustment.strategy_version ?? adjustment.strategyVersion), + }, + pool: normalizePoolBalance(payload.pool || {}), + }; +} + +function opsRelativePath(operationPath) { + const prefix = "/v1/admin/ops-center"; + if (!operationPath.startsWith(prefix)) { + throw new Error(`运营接口不属于 ops-center: ${operationPath}`); + } + return operationPath.slice(prefix.length); +} + +function normalizeStrategyVersion(value) { + return String(value || "fixed_v2") + .trim() + .toLowerCase(); +} + function luckyGiftConfigPayload(config = {}) { const appCode = String(config.app_code || config.appCode || "") .trim() @@ -222,7 +311,9 @@ function luckyGiftConfigPayload(config = {}) { config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent, ), high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins), - initial_pool_coins: numberValue(config.initial_pool_coins ?? config.initialPoolCoins), + // dynamic_v3 已改为通过带审计的 pool credit 接口注资;即使旧表单快照仍带 1m,也不能在发布新版本时继续隐式 seed。 + initial_pool_coins: + strategyVersion === "dynamic_v3" ? 0 : numberValue(config.initial_pool_coins ?? config.initialPoolCoins), jackpot_global_rtp_max_percent: numberValue( config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent, ), @@ -266,32 +357,6 @@ function luckyGiftConfigPayload(config = {}) { }; } -function buildHeaders(body) { - const headers = {}; - const token = getAccessToken(); - - if (body !== undefined) { - headers["Content-Type"] = "application/json"; - } - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - return headers; -} - -async function readPayload(response) { - const text = await response.text(); - if (!text) { - return { code: response.ok ? 0 : response.status, data: {} }; - } - try { - return JSON.parse(text); - } catch { - return { code: response.ok ? 0 : response.status, data: text, message: text }; - } -} - function normalizeItems(value) { if (Array.isArray(value)) { return value; diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js index 37b5a12..7b24247 100644 --- a/ops-center/src/api.test.js +++ b/ops-center/src/api.test.js @@ -1,8 +1,22 @@ import { afterEach, expect, test, vi } from "vitest"; -import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js"; +import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request"; +import { + adjustLuckyGiftPool, + buildOpsUrl, + fetchLuckyGiftDraws, + fetchOpsDashboard, + luckyGiftPoolKey, + normalizeDashboard, + opsRequest, + OPS_API_PREFIX, + saveLuckyGiftConfig, +} from "./api.js"; afterEach(() => { vi.restoreAllMocks(); + setAccessToken(""); + setRefreshHandler(null); + setUnauthorizedHandler(null); }); function jsonResponse(data) { @@ -19,6 +33,27 @@ test("builds ops api urls under the dedicated prefix", () => { expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active"); }); +test("refreshes a stale access token before retrying an ops-center request", async () => { + setAccessToken("stale-token"); + setRefreshHandler(async () => { + setAccessToken("fresh-token"); + return true; + }); + vi.spyOn(window, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ code: 40100, message: "访问凭证已失效" }), { + headers: { "Content-Type": "application/json" }, + status: 401, + }), + ) + .mockResolvedValueOnce(jsonResponse({ items: [{ app_code: "lalu" }] })); + + await expect(opsRequest("/apps")).resolves.toEqual({ items: [{ app_code: "lalu" }] }); + expect(window.fetch).toHaveBeenCalledTimes(2); + expect(window.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer stale-token"); + expect(window.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer fresh-token"); +}); + test("loads every pool config per app through the plural configs endpoint", async () => { const calls = []; vi.spyOn(window, "fetch").mockImplementation(async (url) => { @@ -208,7 +243,7 @@ test("saves lucky gift config through admin ops route", async () => { gift_price_reference: 100, high_water_nonzero_factor_percent: 130, high_watermark_coins: 20_000_000, - initial_pool_coins: 1_000_000, + initial_pool_coins: 0, jackpot_global_rtp_max_percent: 98, jackpot_multipliers: [200, 500, 1000], jackpot_spend_threshold_coins: 50_000, @@ -250,3 +285,95 @@ test("saves lucky gift config through admin ops route", async () => { user_hourly_payout_cap: 34_200, }); }); + +test("keeps fixed_v2 and dynamic_v3 balances independent for the same app and pool", () => { + const data = normalizeDashboard({ + apps: [{ app_code: "lalu" }], + perApp: [ + { + appCode: "lalu", + poolBalances: [ + { + availableBalance: 700, + balance: 900, + manualCreditTotal: 100, + manualDebitTotal: 20, + poolId: "lucky", + reserveFloor: 200, + strategyVersion: "fixed_v2", + }, + { + available_balance: 3_000, + balance: 4_000, + manual_credit_total: 2_000, + manual_debit_total: 500, + pool_id: "lucky", + reserve_floor: 1_000, + strategy_version: "dynamic_v3", + }, + ], + }, + ], + }); + + expect(data.poolBalances).toHaveLength(2); + expect(new Set(data.poolBalances.map(luckyGiftPoolKey))).toEqual( + new Set(["lalu:lucky:fixed_v2", "lalu:lucky:dynamic_v3"]), + ); + expect(data.poolBalances[0]).toMatchObject({ manual_credit_total: 100, manual_debit_total: 20 }); + expect(data.poolBalances[1]).toMatchObject({ manual_credit_total: 2_000, manual_debit_total: 500 }); + expect(data.summary.poolBalanceTotal).toBe(4_900); +}); + +test.each([ + ["in", "credit"], + ["out", "debit"], +])("posts %s pool adjustments with the strategy identity and idempotency key", async (direction, route) => { + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url, init) => { + calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) }); + return jsonResponse({ + adjustment: { + adjustment_id: "adjust-1", + amount_coins: 1_000, + balance_after: direction === "out" ? 9_000 : 11_000, + balance_before: 10_000, + strategy_version: "dynamic_v3", + }, + idempotent_replay: false, + pool: { + app_code: "lalu", + available_balance: 8_000, + balance: direction === "out" ? 9_000 : 11_000, + manual_credit_total: direction === "in" ? 1_000 : 0, + manual_debit_total: direction === "out" ? 1_000 : 0, + materialized: true, + pool_id: "lucky", + reserve_floor: 1_000, + strategy_version: "dynamic_v3", + }, + }); + }); + + const result = await adjustLuckyGiftPool({ + adjustmentId: "adjust-1", + amountCoins: 1_000, + appCode: "LALU", + direction, + poolId: "lucky", + reason: " 运营调节 ", + strategyVersion: "dynamic_v3", + }); + + expect(calls).toEqual([ + { + body: { adjustment_id: "adjust-1", amount_coins: 1_000, reason: "运营调节" }, + method: "POST", + url: `http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools/${route}?app_code=lalu&pool_id=lucky&strategy_version=dynamic_v3`, + }, + ]); + expect(result).toMatchObject({ + adjustment: { balance_before: 10_000, strategy_version: "dynamic_v3" }, + pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" }, + }); +}); diff --git a/ops-center/src/components/ConfigsView.jsx b/ops-center/src/components/ConfigsView.jsx index dff4af3..278c7ff 100644 --- a/ops-center/src/components/ConfigsView.jsx +++ b/ops-center/src/components/ConfigsView.jsx @@ -4,10 +4,25 @@ import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout import { Button } from "@/shared/ui/Button.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { TimeText } from "@/shared/ui/TimeText.jsx"; +import { luckyGiftPoolKey } from "../api.js"; import { formatNumber, formatPercent } from "../format.js"; +import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; -export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) { +export function ConfigsView({ + apps, + canCredit, + canDebit, + canUpdate, + disabled, + luckyGifts, + onCreate, + onCredit, + onDebit, + onEdit, + poolBalances, + saving, +}) { const [appFilter, setAppFilter] = useState(""); const appOptions = useMemo( @@ -19,25 +34,46 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) { [apps], ); // 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。 - const items = useMemo( - () => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts), - [appFilter, luckyGifts], - ); + const items = useMemo(() => { + const poolByKey = new Map(poolBalances.map((pool) => [luckyGiftPoolKey(pool), pool])); + const visibleConfigs = appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts; + // 余额必须按 app+pool+strategy 精确关联;同一个 app/pool 同时存在 fixed_v2 和 dynamic_v3 时, + // 任何缺少 strategy_version 的关联都会把两口独立资金池串行或覆盖。 + return visibleConfigs.map((config) => ({ + ...config, + pool_balance: poolByKey.get(luckyGiftPoolKey(config)), + })); + }, [appFilter, luckyGifts, poolBalances]); - const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]); + const columns = useMemo( + () => + buildColumns({ + canCredit, + canDebit, + canUpdate, + disabled, + onCredit, + onDebit, + onEdit, + saving, + }), + [canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving], + ); return (
} - variant="primary" - onClick={onCreate} - > - 添加配置 - + canUpdate ? ( + + ) : null } filters={ `${item.app_code}:${item.pool_id}:${item.rule_version}`} + minWidth="1540px" + rowKey={(item) => `${luckyGiftPoolKey(item)}:${item.rule_version}`} title="幸运礼物配置" total={items.length} /> @@ -61,8 +97,8 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) { ); } -function buildColumns({ onEdit, saving }) { - return [ +function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving }) { + const columns = [ { key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" }, { key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" }, { @@ -79,6 +115,12 @@ function buildColumns({ onEdit, saving }) { render: (item) => item.strategy_version || "fixed_v2", width: "minmax(112px, 0.8fr)", }, + { + key: "pool_balance", + label: "奖池余额", + render: (item) => (item.pool_balance ? formatNumber(item.pool_balance.balance) : "-"), + width: "minmax(120px, 0.9fr)", + }, { key: "enabled", label: "状态", @@ -115,14 +157,46 @@ function buildColumns({ onEdit, saving }) { render: (item) => (item.is_default ? "-" : ), width: "minmax(176px, 1.1fr)", }, - { + ]; + + if (canUpdate || canCredit || canDebit) { + columns.push({ + fixed: "right", key: "actions", label: "操作", - render: (item) => ( - - ), - }, - ]; + render: (item) => { + const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`; + const actionPool = item.pool_balance || { + app_code: item.app_code, + pool_id: item.pool_id, + strategy_version: item.strategy_version, + }; + return ( +
+ + {canUpdate ? ( + + ) : null} +
+ ); + }, + width: `${(canCredit ? 110 : 0) + (canDebit ? 110 : 0) + (canUpdate ? 110 : 0) + 20}px`, + }); + } + + return columns; } diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index 54bc3b1..c017168 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -444,7 +444,14 @@ function DynamicStrategySections({ form, onChange }) {

动态水位

- + + {canCredit ? ( + + ) : null} + {canDebit ? ( + + ) : null} +
+ ); +} diff --git a/ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx b/ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx new file mode 100644 index 0000000..43d4f39 --- /dev/null +++ b/ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx @@ -0,0 +1,132 @@ +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import TextField from "@mui/material/TextField"; +import { useState } from "react"; +import { Button } from "@/shared/ui/Button.jsx"; +import { formatNumber } from "../format.js"; + +export function LuckyGiftPoolAdjustmentDialog({ adjustmentId, direction, onClose, onSubmit, pool, submitting }) { + const [amount, setAmount] = useState(""); + const [reason, setReason] = useState(""); + const [errors, setErrors] = useState({}); + const debit = direction === "out"; + const appCode = pool.app_code; + const poolID = pool.pool_id; + const strategyVersion = pool.strategy_version; + const availableBalance = Number(pool.available_balance || 0); + const title = debit ? "扣减奖池水位" : "添加奖池水位"; + + const submit = (event) => { + event.preventDefault(); + const nextErrors = validateAdjustment({ amount, availableBalance, debit, reason }); + setErrors(nextErrors); + if (Object.keys(nextErrors).length) { + return; + } + + // adjustmentId 在打开弹窗时创建,此处只原样透传。请求失败后组件不会卸载,因此用户修正或直接重试时 + // 仍使用同一幂等键,网络超时场景不会把已成功的资金动作重复执行。 + onSubmit({ + adjustmentId, + amountCoins: Number(amount), + appCode, + direction, + poolId: poolID, + reason: reason.trim(), + strategyVersion, + }); + }; + + return ( + +
+ {title} + +
+ + + + + + +
+ {pool.materialized === false ? ( +
+ 当前为默认水位,提交后将物化为独立策略奖池。 +
+ ) : null} + { + setAmount(event.target.value); + setErrors((current) => ({ ...current, amount: "" })); + }} + /> + { + setReason(event.target.value); + setErrors((current) => ({ ...current, reason: "" })); + }} + /> +
+ + + + +
+
+ ); +} + +function PoolMetric({ label, value }) { + return ( +
+ {label} + {value || "-"} +
+ ); +} + +function validateAdjustment({ amount, availableBalance, debit, reason }) { + const errors = {}; + const rawAmount = String(amount || "").trim(); + if (!/^[1-9]\d*$/.test(rawAmount) || !Number.isSafeInteger(Number(rawAmount))) { + errors.amount = "请输入大于 0 的整数金币数量"; + } else if (debit && Number(rawAmount) > availableBalance) { + errors.amount = `扣减不能超过可用余额 ${formatNumber(availableBalance)}`; + } + if (!String(reason || "").trim()) { + errors.reason = "请输入调整原因"; + } + return errors; +} diff --git a/ops-center/src/components/OverviewView.jsx b/ops-center/src/components/OverviewView.jsx index 8f98126..ed64516 100644 --- a/ops-center/src/components/OverviewView.jsx +++ b/ops-center/src/components/OverviewView.jsx @@ -2,123 +2,168 @@ import AppsOutlined from "@mui/icons-material/AppsOutlined"; import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; import CasinoOutlined from "@mui/icons-material/CasinoOutlined"; import SavingsOutlined from "@mui/icons-material/SavingsOutlined"; +import { useMemo } from "react"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { KpiCard } from "@/shared/ui/KpiCard.jsx"; import { TimeText } from "@/shared/ui/TimeText.jsx"; +import { luckyGiftPoolKey } from "../api.js"; import { formatNumber, formatPercentFromPPM } from "../format.js"; +import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; -export function OverviewView({ data }) { - const { appSummaries, poolBalances, summary } = data; +export function OverviewView({ canCredit, canDebit, data, disabled, onCredit, onDebit }) { + const { appSummaries, poolBalances, summary } = data; + const poolBalanceColumns = useMemo( + () => buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }), + [canCredit, canDebit, disabled, onCredit, onDebit], + ); - return ( -
-
- - - - -
+ return ( +
+
+ + + + +
- `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`} - title="奖池实时水位" - total={poolBalances.length} - /> + - item.app_code} - title="各应用抽奖汇总" - total={appSummaries.length} - /> -
- ); + item.app_code} + title="各应用抽奖汇总" + total={appSummaries.length} + /> +
+ ); } -const poolBalanceColumns = [ - { key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode, width: "minmax(96px, 0.8fr)" }, - { key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId, width: "minmax(110px, 1fr)" }, - { - key: "materialized", - label: "水位状态", - // materialized=false 表示该奖池还没有真实入账流水,余额是规则推导的初始水位,不能当作可支配资金。 - render: (item) => - item.materialized === false ? : , - width: "minmax(132px, 0.9fr)" - }, - { key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance ?? item.Balance) }, - { key: "available_balance", label: "可用余额", render: (item) => formatNumber(item.available_balance ?? item.availableBalance) }, - { key: "reserve_floor", label: "保底线", render: (item) => formatNumber(item.reserve_floor ?? item.reserveFloor), width: "minmax(96px, 0.8fr)" }, - { key: "total_in", label: "累计流入", render: (item) => formatNumber(item.total_in ?? item.totalIn) }, - { key: "total_out", label: "累计流出", render: (item) => formatNumber(item.total_out ?? item.totalOut) }, - { - key: "updated_at_ms", - label: "更新时间", - render: (item) => - item.materialized === false ? "-" : , - width: "minmax(150px, 1fr)" - } -]; +function buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }) { + const columns = [ + { key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" }, + { key: "pool_id", label: "奖池", width: "minmax(110px, 1fr)" }, + { key: "strategy_version", label: "策略", width: "minmax(112px, 0.9fr)" }, + { + key: "materialized", + label: "水位状态", + // materialized=false 是 owner 根据当前策略推导的初始水位;仍允许补水或扣水,首次调整由 owner 原子物化该策略奖池。 + render: (item) => + item.materialized === false ? ( + + ) : ( + + ), + width: "minmax(132px, 0.9fr)", + }, + { key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance) }, + { key: "available_balance", label: "可用余额", render: (item) => formatNumber(item.available_balance) }, + { + key: "reserve_floor", + label: "保底线", + render: (item) => formatNumber(item.reserve_floor), + width: "minmax(96px, 0.8fr)", + }, + { key: "total_in", label: "业务流入", render: (item) => formatNumber(item.total_in) }, + { key: "total_out", label: "返奖流出", render: (item) => formatNumber(item.total_out) }, + { key: "manual_credit_total", label: "人工添加", render: (item) => formatNumber(item.manual_credit_total) }, + { key: "manual_debit_total", label: "人工扣减", render: (item) => formatNumber(item.manual_debit_total) }, + { + key: "updated_at_ms", + label: "更新时间", + render: (item) => (item.materialized === false ? "-" : ), + width: "minmax(150px, 1fr)", + }, + ]; + + if (canCredit || canDebit) { + columns.push({ + fixed: "right", + key: "actions", + label: "操作", + render: (item) => ( + + ), + width: canCredit && canDebit ? "220px" : "120px", + }); + } + return columns; +} const appSummaryColumns = [ - { key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" }, - { key: "total_draws", label: "抽奖次数", render: (item) => formatNumber(item.total_draws) }, - { key: "unique_users", label: "参与用户", render: (item) => formatNumber(item.unique_users) }, - { key: "unique_rooms", label: "参与房间", render: (item) => formatNumber(item.unique_rooms) }, - { key: "total_spent_coins", label: "消耗金币", render: (item) => formatNumber(item.total_spent_coins) }, - { key: "total_reward_coins", label: "返还金币", render: (item) => formatNumber(item.total_reward_coins) }, - { - key: "actual_rtp_ppm", - label: "实际 RTP", - render: (item) => (item.total_draws > 0 ? formatPercentFromPPM(item.actual_rtp_ppm) : "-") - }, - { - key: "exceptions", - label: "发放异常", - // 待发放和失败合并成一列告警:正常应为 0,非 0 说明发奖链路有积压或失败,需要跟进抽奖记录。 - render: (item) => { - const pending = Number(item.pending_draws || 0); - const failed = Number(item.failed_draws || 0); - if (!pending && !failed) { - return ; - } - return ; + { key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" }, + { key: "total_draws", label: "抽奖次数", render: (item) => formatNumber(item.total_draws) }, + { key: "unique_users", label: "参与用户", render: (item) => formatNumber(item.unique_users) }, + { key: "unique_rooms", label: "参与房间", render: (item) => formatNumber(item.unique_rooms) }, + { key: "total_spent_coins", label: "消耗金币", render: (item) => formatNumber(item.total_spent_coins) }, + { key: "total_reward_coins", label: "返还金币", render: (item) => formatNumber(item.total_reward_coins) }, + { + key: "actual_rtp_ppm", + label: "实际 RTP", + render: (item) => (item.total_draws > 0 ? formatPercentFromPPM(item.actual_rtp_ppm) : "-"), + }, + { + key: "exceptions", + label: "发放异常", + // 待发放和失败合并成一列告警:正常应为 0,非 0 说明发奖链路有积压或失败,需要跟进抽奖记录。 + render: (item) => { + const pending = Number(item.pending_draws || 0); + const failed = Number(item.failed_draws || 0); + if (!pending && !failed) { + return ; + } + return ( + + ); + }, + width: "minmax(170px, 1.2fr)", }, - width: "minmax(170px, 1.2fr)" - } ]; function countBySource(apps = [], source) { - return apps.filter((item) => (item.source || "registry") === source).length; + return apps.filter((item) => (item.source || "registry") === source).length; } diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index f814777..bc7f3cf 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -21,7 +21,8 @@ const dynamicDefaults = { device_daily_payout_cap: 0, high_water_nonzero_factor_percent: 130, high_watermark_coins: 20_000_000, - initial_pool_coins: 1_000_000, + // dynamic_v3 的运行资金只能通过带审计的奖池水位接口注入,发布规则不再隐式 seed。 + initial_pool_coins: 0, jackpot_global_rtp_max_percent: 98, jackpot_multipliers: [200, 500, 1000], jackpot_spend_threshold_coins: 0, @@ -113,6 +114,11 @@ export function configToForm(config = {}) { form[field] = targetRTPPercent; continue; } + if (field === "initial_pool_coins" && strategyVersion === DYNAMIC_STRATEGY) { + // 旧 dynamic_v3 快照可能仍保存 1,000,000;新增版本必须显式归零,不能把历史隐式注资语义继续复制。 + form[field] = "0"; + continue; + } form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)); } return form; @@ -140,7 +146,10 @@ export function formToConfig(config, form) { strategy_version: form.strategy_version, }; for (const field of numericFields) { - output[field] = numberFromForm(form[field]); + output[field] = + field === "initial_pool_coins" && form.strategy_version === DYNAMIC_STRATEGY + ? 0 + : numberFromForm(form[field]); } return output; } @@ -270,7 +279,7 @@ export function validateLuckyGiftForm(form) { PPM_SCALE ) errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%"); - if (number("initial_pool_coins") <= 0) errors.push("初始奖池必须大于 0"); + if (number("initial_pool_coins") !== 0) errors.push("dynamic_v3 初始奖池必须为 0,请通过水位操作注资"); if (number("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0"); if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins")) errors.push("高水位必须大于正数低水位"); diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index df9118d..a20ec5c 100644 --- a/ops-center/src/configForm.test.js +++ b/ops-center/src/configForm.test.js @@ -8,7 +8,7 @@ describe("ops center lucky gift dynamic_v3 form", () => { expect(form).toMatchObject({ anchor_rate_percent: "1", enabled: false, - initial_pool_coins: "1000000", + initial_pool_coins: "0", jackpot_multipliers: "200, 500, 1000", max_single_payout: "0", pool_rate_percent: "98", @@ -49,6 +49,15 @@ describe("ops center lucky gift dynamic_v3 form", () => { }); }); + test("drops legacy dynamic initial seed when loading and publishing a new version", () => { + const legacy = completeDynamicConfig(); + legacy.initial_pool_coins = 1_000_000; + + const form = configToForm(legacy); + expect(form.initial_pool_coins).toBe("0"); + expect(formToConfig(legacy, form).initial_pool_coins).toBe(0); + }); + test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => { const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); expect(validateLuckyGiftForm(draft)).toEqual([]); @@ -98,7 +107,12 @@ describe("ops center lucky gift dynamic_v3 form", () => { }); const dynamic = applyDynamicStrategyDefaults(legacy); - expect(dynamic).toMatchObject({ enabled: false, pool_rate_percent: "98", strategy_version: "dynamic_v3" }); + expect(dynamic).toMatchObject({ + enabled: false, + initial_pool_coins: "0", + pool_rate_percent: "98", + strategy_version: "dynamic_v3", + }); expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.multiplier)))).toEqual([ [0, 0.5, 1, 2], [0, 0.5, 1, 2], @@ -144,7 +158,7 @@ function completeDynamicConfig() { gift_price_reference: 100, high_water_nonzero_factor_percent: 130, high_watermark_coins: 20_000_000, - initial_pool_coins: 1_000_000, + initial_pool_coins: 0, jackpot_global_rtp_max_percent: 98, jackpot_multipliers: [200, 500, 1000], jackpot_spend_threshold_coins: 50_000, diff --git a/ops-center/src/main.jsx b/ops-center/src/main.jsx index 44f4414..205ab47 100644 --- a/ops-center/src/main.jsx +++ b/ops-center/src/main.jsx @@ -2,21 +2,24 @@ import CssBaseline from "@mui/material/CssBaseline"; import { ThemeProvider } from "@mui/material/styles"; import React from "react"; import { createRoot } from "react-dom/client"; +import { AuthProvider } from "@/app/auth/AuthProvider.jsx"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { theme } from "@/theme.js"; -import { OpsCenterApp } from "./OpsCenterApp.jsx"; +import { AuthenticatedOpsCenter } from "./OpsCenterEntry.jsx"; import "@/styles/tokens.css"; import "@/styles/shared-ui.css"; import "@/styles/responsive.css"; import "./styles/index.css"; createRoot(document.getElementById("ops-center-root")).render( - - - - - - - - + + + + + + + + + + , ); diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index 14d2863..277fc65 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -147,6 +147,43 @@ body { padding: var(--space-3) var(--space-4); } +.ops-pool-actions, +.ops-config-actions { + display: flex; + align-items: center; + gap: var(--space-2); + white-space: nowrap; +} + +.ops-config-actions { + justify-content: flex-end; +} + +.ops-pool-adjustment-form { + display: contents; +} + +.ops-pool-adjustment-content { + display: grid; + gap: var(--space-4); + padding-top: var(--space-4) !important; +} + +.ops-pool-adjustment-metrics { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 0; +} + +.ops-pool-adjustment-note { + border: 1px solid var(--info-border); + border-radius: var(--radius-control); + background: var(--info-surface); + color: var(--info); + font-size: 13px; + font-weight: 700; + padding: var(--space-3); +} + /* ---- 幸运礼物配置弹窗 ---- */ .ops-config-form { @@ -421,6 +458,7 @@ body { .ops-nav, .ops-kpi-grid, .ops-config-metrics, + .ops-pool-adjustment-metrics, .ops-form-grid, .ops-stage-thresholds, .ops-tier-row { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 9fcf5ec..1c90d76 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -187,6 +187,8 @@ export const PERMISSIONS = { sevenDayCheckInUpdate: "seven-day-checkin:update", luckyGiftView: "lucky-gift:view", luckyGiftUpdate: "lucky-gift:update", + luckyGiftPoolCredit: "lucky-gift:pool-credit", + luckyGiftPoolDebit: "lucky-gift:pool-debit", wheelView: "wheel:view", wheelUpdate: "wheel:update", roomRocketView: "room-rocket:view", diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 5f54e3e..005f3c8 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -80,9 +80,11 @@ export const API_OPERATIONS = { createUser: "createUser", createWeeklyStarCycle: "createWeeklyStarCycle", creditCoinSellerStock: "creditCoinSellerStock", + creditLuckyGiftPool: "creditLuckyGiftPool", dashboardOverview: "dashboardOverview", dashboardUserProfileOverview: "dashboardUserProfileOverview", debitCoinSellerStock: "debitCoinSellerStock", + debitLuckyGiftPool: "debitLuckyGiftPool", deleteAchievementDefinition: "deleteAchievementDefinition", deleteActivityTemplate: "deleteActivityTemplate", deleteAgency: "deleteAgency", @@ -871,6 +873,13 @@ export const API_ENDPOINTS: Record = { permission: "coin-seller:stock-credit", permissions: ["coin-seller:stock-credit"] }, + creditLuckyGiftPool: { + method: "POST", + operationId: API_OPERATIONS.creditLuckyGiftPool, + path: "/v1/admin/ops-center/lucky-gifts/pools/credit", + permission: "lucky-gift:pool-credit", + permissions: ["lucky-gift:pool-credit"] + }, dashboardOverview: { method: "GET", operationId: API_OPERATIONS.dashboardOverview, @@ -892,6 +901,13 @@ export const API_ENDPOINTS: Record = { permission: "coin-seller:stock-credit", permissions: ["coin-seller:stock-credit"] }, + debitLuckyGiftPool: { + method: "POST", + operationId: API_OPERATIONS.debitLuckyGiftPool, + path: "/v1/admin/ops-center/lucky-gifts/pools/debit", + permission: "lucky-gift:pool-debit", + permissions: ["lucky-gift:pool-debit"] + }, deleteAchievementDefinition: { method: "DELETE", operationId: API_OPERATIONS.deleteAchievementDefinition, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index be01785..3b59929 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -324,6 +324,38 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/ops-center/lucky-gifts/pools/credit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["creditLuckyGiftPool"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/ops-center/lucky-gifts/pools/debit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["debitLuckyGiftPool"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/activity/wheel/config": { parameters: { query?: never; @@ -4680,6 +4712,64 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + LuckyGiftPoolAdjustmentInput: { + adjustment_id: string; + /** Format: int64 */ + amount_coins: number; + reason: string; + }; + LuckyGiftPoolBalance: { + app_code: string; + pool_id: string; + /** @enum {string} */ + strategy_version: "fixed_v2" | "dynamic_v3"; + /** Format: int64 */ + balance: number; + /** Format: int64 */ + reserve_floor: number; + /** Format: int64 */ + available_balance: number; + /** Format: int64 */ + total_in: number; + /** Format: int64 */ + total_out: number; + /** Format: int64 */ + manual_credit_total: number; + /** Format: int64 */ + manual_debit_total: number; + materialized: boolean; + /** Format: int64 */ + updated_at_ms: number; + }; + LuckyGiftPoolAdjustment: { + adjustment_id: string; + app_code: string; + pool_id: string; + /** @enum {string} */ + strategy_version: "fixed_v2" | "dynamic_v3"; + /** @enum {string} */ + direction: "in" | "out"; + /** Format: int64 */ + amount_coins: number; + reason: string; + /** Format: int64 */ + operator_admin_id: number; + /** Format: int64 */ + balance_before: number; + /** Format: int64 */ + balance_after: number; + /** Format: int64 */ + created_at_ms: number; + }; + LuckyGiftPoolAdjustmentResult: { + adjustment: components["schemas"]["LuckyGiftPoolAdjustment"]; + pool: components["schemas"]["LuckyGiftPoolBalance"]; + idempotent_replay: boolean; + }; + ApiResponseLuckyGiftPoolAdjustmentResult: components["schemas"]["Envelope"] & { + requestId?: string; + data?: components["schemas"]["LuckyGiftPoolAdjustmentResult"]; + }; ApiResponseAdminAppList: components["schemas"]["Envelope"] & { data?: components["schemas"]["AdminAppList"]; }; @@ -7086,6 +7176,15 @@ export interface components { "application/json": components["schemas"]["ApiResponseEmpty"]; }; }; + /** @description 奖池水位调整成功或同幂等请求的首次结果 */ + LuckyGiftPoolAdjustmentResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseLuckyGiftPoolAdjustmentResult"]; + }; + }; /** @description OK */ RechargeBillPageResponse: { headers: { @@ -8007,6 +8106,74 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + creditLuckyGiftPool: { + parameters: { + query: { + app_code: string; + pool_id: string; + strategy_version: "fixed_v2" | "dynamic_v3"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LuckyGiftPoolAdjustmentInput"]; + }; + }; + responses: { + 200: components["responses"]["LuckyGiftPoolAdjustmentResponse"]; + /** @description 调整参数不正确 */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description 幂等键冲突或可用水位不足 */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + debitLuckyGiftPool: { + parameters: { + query: { + app_code: string; + pool_id: string; + strategy_version: "fixed_v2" | "dynamic_v3"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LuckyGiftPoolAdjustmentInput"]; + }; + }; + responses: { + 200: components["responses"]["LuckyGiftPoolAdjustmentResponse"]; + /** @description 调整参数不正确 */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description 幂等键冲突或可用水位不足 */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getWheelConfig: { parameters: { query?: never; From 9f272257b8fba364c7133d9631c576f6ce0afa16 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 16:51:18 +0800 Subject: [PATCH 05/14] feat(external-admin): add account login and i18n --- external-admin/index.html | 4 +- external-admin/src/api/auth.js | 18 - external-admin/src/api/auth.test.js | 13 +- external-admin/src/api/business.js | 45 +- external-admin/src/api/business.test.js | 4 +- external-admin/src/api/client.js | 4 +- .../src/auth/ExternalAuthProvider.jsx | 7 +- external-admin/src/auth/RouteGuards.jsx | 14 +- .../src/components/BannerCreateDialog.jsx | 73 ++-- .../components/BannerCreateDialog.test.jsx | 10 +- .../src/components/GrantDialogs.jsx | 33 +- .../src/components/RoomEditDialog.jsx | 14 +- external-admin/src/components/UserDialogs.jsx | 35 +- external-admin/src/config/capabilities.js | 40 +- .../src/i18n/ExternalI18nProvider.jsx | 69 +++ .../src/i18n/ExternalI18nProvider.test.jsx | 60 +++ external-admin/src/i18n/LanguageSwitcher.jsx | 26 ++ external-admin/src/i18n/emotionCache.js | 10 + external-admin/src/i18n/emotionCache.test.js | 24 ++ external-admin/src/i18n/errors.js | 51 +++ external-admin/src/i18n/messages.js | 404 ++++++++++++++++++ external-admin/src/i18n/messages.test.js | 27 ++ .../src/layout/ExternalAdminLayout.jsx | 64 +-- .../src/layout/ExternalAdminLayout.test.jsx | 72 ++++ external-admin/src/main.jsx | 44 +- external-admin/src/pages/BannersPage.jsx | 21 +- external-admin/src/pages/BansPage.jsx | 27 +- .../src/pages/ChangePasswordPage.jsx | 23 +- .../src/pages/ChangePasswordPage.test.jsx | 6 +- external-admin/src/pages/GrantsPage.jsx | 37 +- external-admin/src/pages/LoginPage.jsx | 72 +--- external-admin/src/pages/LoginPage.test.jsx | 49 +++ external-admin/src/pages/OrganizationPage.jsx | 48 ++- external-admin/src/pages/OverviewPage.jsx | 14 +- .../src/pages/OverviewPage.test.jsx | 10 +- external-admin/src/pages/RoomsPage.jsx | 25 +- external-admin/src/pages/UsersPage.jsx | 42 +- external-admin/src/shared/ConfirmDialog.jsx | 4 +- .../src/shared/ExternalImageUploadField.jsx | 17 +- external-admin/src/shared/PageComponents.jsx | 26 +- external-admin/src/shared/usePagedResource.js | 7 +- external-admin/src/styles/index.css | 5 + package.json | 3 + pnpm-lock.yaml | 32 ++ src/shared/ui/AppJumpConfigField.jsx | 23 +- src/shared/ui/TimeRangeFilter.jsx | 137 ++++-- 46 files changed, 1395 insertions(+), 398 deletions(-) create mode 100644 external-admin/src/i18n/ExternalI18nProvider.jsx create mode 100644 external-admin/src/i18n/ExternalI18nProvider.test.jsx create mode 100644 external-admin/src/i18n/LanguageSwitcher.jsx create mode 100644 external-admin/src/i18n/emotionCache.js create mode 100644 external-admin/src/i18n/emotionCache.test.js create mode 100644 external-admin/src/i18n/errors.js create mode 100644 external-admin/src/i18n/messages.js create mode 100644 external-admin/src/i18n/messages.test.js create mode 100644 external-admin/src/layout/ExternalAdminLayout.test.jsx create mode 100644 external-admin/src/pages/LoginPage.test.jsx diff --git a/external-admin/index.html b/external-admin/index.html index c7759b6..60bbbf8 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -1,10 +1,10 @@ - + - HYApp 外管 + HYApp External Admin
diff --git a/external-admin/src/api/auth.js b/external-admin/src/api/auth.js index 596ac33..9371100 100644 --- a/external-admin/src/api/auth.js +++ b/external-admin/src/api/auth.js @@ -3,26 +3,16 @@ import { arrayValue, asRecord, textValue } from "./normalizers.js"; // These external-session auth routes intentionally have no main-admin OpenAPI operation. const EXTERNAL_AUTH_PATHS = Object.freeze({ - apps: "/auth/apps", changePassword: "/auth/change-password", login: "/auth/login", logout: "/auth/logout", me: "/auth/me" }); -export async function listExternalApps() { - const data = await externalRequest(EXTERNAL_AUTH_PATHS.apps, { notifyUnauthorized: false }); - const source = asRecord(data); - return arrayValue(data, source.items, source.apps) - .map((item) => normalizeApp(item)) - .filter((item) => item.appCode); -} - export async function loginExternal(payload) { const data = await externalRequest(EXTERNAL_AUTH_PATHS.login, { body: { account: String(payload.account || "").trim(), - appCode: String(payload.appCode || "").trim(), password: String(payload.password || "") }, method: "POST", @@ -84,11 +74,3 @@ export function normalizeSession(data) { ) }; } - -function normalizeApp(value) { - const item = typeof value === "string" ? { appCode: value } : asRecord(value); - return { - appCode: textValue(item.appCode, item.app_code, item.code, item.value), - name: textValue(item.name, item.appName, item.app_name, item.label, item.appCode, item.code) - }; -} diff --git a/external-admin/src/api/auth.test.js b/external-admin/src/api/auth.test.js index b509f0a..1c3584e 100644 --- a/external-admin/src/api/auth.test.js +++ b/external-admin/src/api/auth.test.js @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { changeExternalPassword, listExternalApps, normalizeSession } from "./auth.js"; +import { changeExternalPassword, loginExternal, normalizeSession } from "./auth.js"; afterEach(() => { vi.unstubAllGlobals(); @@ -43,9 +43,14 @@ describe("normalizeSession", () => { }); describe("external auth API contract", () => { - test("reads appName from auth/apps items", async () => { - vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: 0, data: { items: [{ appCode: "fami", appName: "Fami" }], total: 1 } }))); - await expect(listExternalApps()).resolves.toEqual([{ appCode: "fami", name: "Fami" }]); + test("login sends only account and password so the server resolves the App", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { account: "fami-manager", appCode: "fami" } })); + vi.stubGlobal("fetch", fetchMock); + + await loginExternal({ account: " fami-manager ", appCode: "must-not-be-sent", password: " password bytes " }); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/auth/login"); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ account: "fami-manager", password: " password bytes " }); }); test("change-password sends currentPassword and never oldPassword", async () => { diff --git a/external-admin/src/api/business.js b/external-admin/src/api/business.js index e42aaff..d3fb062 100644 --- a/external-admin/src/api/business.js +++ b/external-admin/src/api/business.js @@ -2,6 +2,7 @@ import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoint import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js"; import { externalRequest } from "./client.js"; import { asRecord, booleanValue, entityId, normalizePage, numberValue, textValue } from "./normalizers.js"; +import { externalUiError } from "../i18n/errors.js"; // my-team is external-session scoped and is not part of the main-admin OpenAPI contract yet. const EXTERNAL_ONLY_PATHS = Object.freeze({ myTeamAgencies: "/admin/my-team/agencies" }); @@ -42,10 +43,10 @@ export async function grantUserVip(targetUserId, payload) { const level = Number(payload.level); if (program.status === "disabled") { - throw new Error("当前 App 的 VIP 体系已停用"); + throw externalUiError("errors.vipDisabled"); } if (Array.isArray(levels) && levels.length && !levels.some((item) => Number(item.level) === level)) { - throw new Error("请选择当前 App 已配置的 VIP 等级"); + throw externalUiError("errors.vipLevelInvalid"); } const command = { @@ -74,7 +75,7 @@ export function listOrganizations(kind, query = {}) { team: EXTERNAL_ONLY_PATHS.myTeamAgencies }; if (!routes[kind]) { - throw new Error("该组织列表尚无安全的数据接口"); + throw externalUiError("errors.organizationRoute"); } const scopedQuery = kind === "super-admins" ? { ...query, status: query.status || "active" } : query; return externalRequest(routes[kind], { query: scopedQuery }).then((data) => { @@ -104,7 +105,7 @@ export function buildRoomUpdatePayload(payload) { const regionText = String(payload.visibleRegionId ?? "").trim(); const visibleRegionId = regionText ? Number(regionText) : 0; if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) { - throw new Error("可见区域 ID 必须是安全的正整数"); + throw externalUiError("errors.regionId"); } return { coverUrl: String(payload.coverUrl || "").trim(), @@ -151,53 +152,53 @@ export function createBanner(payload) { export function buildBannerPayload(payload) { if (!["h5", "app"].includes(payload.bannerType)) { - throw new Error("Banner 类型不正确"); + throw externalUiError("errors.bannerType"); } if (!["android", "ios"].includes(payload.platform)) { - throw new Error("Banner 平台不正确"); + throw externalUiError("errors.bannerPlatform"); } if (!["active", "disabled", "expired"].includes(payload.status)) { - throw new Error("Banner 状态不正确"); + throw externalUiError("errors.bannerStatus"); } const displayScopes = [...new Set((payload.displayScopes || []).filter((scope) => ["home", "room", "recharge", "me"].includes(scope)))]; if (!displayScopes.length) { - throw new Error("请至少选择一个展示范围"); + throw externalUiError("errors.bannerScope"); } if (!payload.coverUrl) { - throw new Error("请上传 Banner 图片"); + throw externalUiError("errors.bannerImage"); } if (payload.bannerType === "h5") { let target; try { target = new URL(payload.param); } catch { - throw new Error("请填写合法的 H5 链接"); + throw externalUiError("errors.h5Url"); } if (!["http:", "https:"].includes(target.protocol)) { - throw new Error("H5 链接仅支持 http 或 https"); + throw externalUiError("errors.h5Protocol"); } } if (payload.bannerType === "app") { const validation = validatePublicAppJumpParam(payload.param); if (!validation.valid) { - throw new Error(validation.message); + throw externalUiError(appJumpValidationKey(validation.message)); } } if (displayScopes.includes("room") && !payload.roomSmallImageUrl) { - throw new Error("房间范围必须上传房间内小屏图"); + throw externalUiError("errors.roomBannerImage"); } const countryCode = String(payload.countryCode || "").trim().toUpperCase(); if (countryCode && !/^[A-Z]{2,3}$/.test(countryCode)) { - throw new Error("国家码必须是 2-3 位字母"); + throw externalUiError("errors.countryCode"); } const regionId = Number(payload.regionId || 0); if (!Number.isSafeInteger(regionId) || regionId < 0) { - throw new Error("区域 ID 不正确"); + throw externalUiError("errors.bannerRegion"); } const startsAtMs = Number(payload.startsAtMs || 0); const endsAtMs = Number(payload.endsAtMs || 0); if (startsAtMs > 0 && endsAtMs > 0 && startsAtMs >= endsAtMs) { - throw new Error("投放结束时间必须晚于开始时间"); + throw externalUiError("errors.bannerSchedule"); } return { @@ -319,3 +320,15 @@ function externalOperationPath(operation, params) { // Generated main-admin paths start with /v1; the external gateway already owns that version prefix in its base URL. return apiEndpointPath(operation, params).replace(/^\/v1(?=\/)/, ""); } + +function appJumpValidationKey(message) { + return { + "APP 跳转参数必须是 JSON 对象": "errors.appParamObject", + "APP 跳转 type 不在公共跳转方案内": "errors.appParamType", + "Explore 游戏 H5 需要选择有效 game_code": "errors.appParamGameCode", + "该 APP 跳转需要填写 game_id": "errors.appParamGame", + "该 APP 跳转需要填写 room_id": "errors.appParamRoom", + "该 APP 跳转需要填写有效 window": "errors.appParamWindow", + "请填写 APP 跳转参数": "errors.appParamRequired" + }[message] || "errors.appParamObject"; +} diff --git a/external-admin/src/api/business.test.js b/external-admin/src/api/business.test.js index f7df65e..77ecece 100644 --- a/external-admin/src/api/business.test.js +++ b/external-admin/src/api/business.test.js @@ -40,7 +40,7 @@ describe("room API payload", () => { expect(buildRoomUpdatePayload({ coverUrl: " cover ", description: " desc ", title: " Room ", visibleRegionId: "18" })) .toEqual({ coverUrl: "cover", description: "desc", title: "Room", visibleRegionId: 18 }); expect(buildRoomUpdatePayload({ visibleRegionId: "" }).visibleRegionId).toBe(0); - expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("可见区域 ID"); + expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("errors.regionId"); }); }); @@ -134,7 +134,7 @@ describe("banner API payload", () => { status: "active" })); expect(() => buildBannerPayload({ bannerType: "link", coverUrl: "x", displayScopes: ["home"], platform: "all", status: "enabled" })) - .toThrow("Banner 类型不正确"); + .toThrow("errors.bannerType"); }); test("create API sends the normalized appconfig payload", async () => { diff --git a/external-admin/src/api/client.js b/external-admin/src/api/client.js index 507b791..f9de9bd 100644 --- a/external-admin/src/api/client.js +++ b/external-admin/src/api/client.js @@ -73,11 +73,11 @@ export async function externalRequest(path, options = {}) { } if (!response.ok) { - throw apiError(payload, response.status, response.statusText || "请求失败"); + throw apiError(payload, response.status, response.statusText || "Request failed"); } if (isEnvelopeFailure(payload)) { - throw apiError(payload, response.status, "请求失败"); + throw apiError(payload, response.status, "Request failed"); } const data = unwrapPayload(payload); diff --git a/external-admin/src/auth/ExternalAuthProvider.jsx b/external-admin/src/auth/ExternalAuthProvider.jsx index b7de90d..e637ccb 100644 --- a/external-admin/src/auth/ExternalAuthProvider.jsx +++ b/external-admin/src/auth/ExternalAuthProvider.jsx @@ -7,10 +7,10 @@ const ExternalAuthContext = createContext(null); export function ExternalAuthProvider({ children }) { const [session, setSession] = useState(null); const [initializing, setInitializing] = useState(true); - const [initializationError, setInitializationError] = useState(""); + const [initializationError, setInitializationError] = useState(null); const refresh = useCallback(async () => { - setInitializationError(""); + setInitializationError(null); try { const nextSession = await getExternalSession(); setSession(nextSession); @@ -18,7 +18,8 @@ export function ExternalAuthProvider({ children }) { } catch (error) { setSession(null); if (!(error instanceof ExternalApiError && error.status === 401)) { - setInitializationError(error.message || "会话校验失败"); + // Preserve the stable API status/code for the UI translation layer; never surface a backend-language message directly. + setInitializationError(error); } return null; } finally { diff --git a/external-admin/src/auth/RouteGuards.jsx b/external-admin/src/auth/RouteGuards.jsx index e47933a..d2ae5d0 100644 --- a/external-admin/src/auth/RouteGuards.jsx +++ b/external-admin/src/auth/RouteGuards.jsx @@ -5,17 +5,20 @@ import CircularProgress from "@mui/material/CircularProgress"; import Typography from "@mui/material/Typography"; import { Navigate, Outlet, useLocation } from "react-router-dom"; import { hasAnyExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { useExternalAuth } from "./ExternalAuthProvider.jsx"; export function RequireExternalAuth() { const { initializationError, initializing, refresh, session } = useExternalAuth(); + const { t } = useExternalI18n(); const location = useLocation(); if (initializing) { return ( - 正在校验外管会话 + {t("auth.checkingSession")} ); } @@ -24,8 +27,8 @@ export function RequireExternalAuth() { if (initializationError) { return ( - {initializationError} - + {translateExternalError(initializationError, t, "auth.sessionFailed")} + ); } @@ -42,6 +45,7 @@ export function RequirePasswordChanged() { export function RequireExternalCapability({ anyOf }) { const { session } = useExternalAuth(); + const { t } = useExternalI18n(); if (hasAnyExternalCapability(session, anyOf)) { return ; @@ -49,9 +53,9 @@ export function RequireExternalCapability({ anyOf }) { return ( - 当前外管账号没有访问此功能的权限。 + {t("auth.noPermission")} ); diff --git a/external-admin/src/components/BannerCreateDialog.jsx b/external-admin/src/components/BannerCreateDialog.jsx index 98ee4ad..486fec1 100644 --- a/external-admin/src/components/BannerCreateDialog.jsx +++ b/external-admin/src/components/BannerCreateDialog.jsx @@ -12,15 +12,17 @@ import { useEffect, useState } from "react"; import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; const displayScopeOptions = [ - ["home", "首页"], - ["room", "房间"], - ["recharge", "充值"], - ["me", "我的"] + ["home", "banners.scopeHome"], + ["room", "banners.scopeRoom"], + ["recharge", "banners.scopeRecharge"], + ["me", "banners.scopeMe"] ]; export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { + const { locale, t } = useExternalI18n(); const [form, setForm] = useState(initialForm); useEffect(() => setForm(initialForm), [open]); @@ -37,57 +39,60 @@ export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { return ( - 创建 Banner + {t("banners.create")} - setForm({ ...form, coverUrl })} /> - {form.displayScopes.includes("room") ? setForm({ ...form, roomSmallImageUrl })} /> : null} - setForm({ ...form, platform: event.target.value })}> + setForm({ ...form, coverUrl })} /> + {form.displayScopes.includes("room") ? setForm({ ...form, roomSmallImageUrl })} /> : null} + setForm({ ...form, platform: event.target.value })}> Android iOS - setForm({ ...form, description: event.target.value })} /> + setForm({ ...form, description: event.target.value })} /> setForm({ ...form, bannerType: type, param })} /> selected.map((value) => displayScopeOptions.find(([key]) => key === value)?.[1] || value).join("、") } }} + slotProps={{ select: { multiple: true, renderValue: (selected) => selected.map((value) => t(displayScopeOptions.find(([key]) => key === value)?.[1]) || value).join(", ") } }} value={form.displayScopes} onChange={(event) => setForm({ ...form, displayScopes: typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value })} > - {displayScopeOptions.map(([value, label]) => )} + {displayScopeOptions.map(([value, labelKey]) => )} - setForm({ ...form, regionId: event.target.value })} /> - setForm({ ...form, countryCode: event.target.value.toUpperCase() })} /> - setForm({ ...form, sortOrder: event.target.value })} /> + setForm({ ...form, regionId: event.target.value })} /> + setForm({ ...form, countryCode: event.target.value.toUpperCase() })} /> + setForm({ ...form, sortOrder: event.target.value })} /> setForm({ ...form, endsAtMs: endMs, startsAtMs: startMs })} /> - setForm({ ...form, status: event.target.value })}> - 启用 - 禁用 - 过期 + setForm({ ...form, status: event.target.value })}> + {t("common.enabled")} + {t("common.disabled")} + {t("common.expired")} - + ); @@ -108,3 +113,23 @@ const initialForm = { startsAtMs: 0, status: "active" }; + +function appJumpMessages(t) { + return { + appTarget: t("jump.appTarget"), + gameId: t("jump.gameId"), + giftId: t("jump.giftId"), + hint: t("jump.hint"), + redPacketNo: t("jump.redPacketNo"), + roomId: t("jump.roomId"), + targets: Object.fromEntries(["wallet", "room", "room_random", "room_window", "room_random_window", "room_game", "room_random_game", "explore_game", "custom"].map((value) => [value, t(`jump.target.${value}`)])), + window: t("jump.window"), + windows: Object.fromEntries(["game_window", "gift_panel", "red_packet_claim", "wheel"].map((value) => [value, t(`jump.window.${value}`)])) + }; +} + +function timeRangeMessages(t) { + return Object.fromEntries([ + "apply", "clear", "completeRange", "duration", "durationDays", "durationHours", "durationMinutes", "end", "endAfterStart", "endDate", "endDateAfterStart", "endDateLaterShort", "endLaterShort", "endMark", "hour", "lessMinute", "minute", "nextMonth", "pending", "previousMonth", "quickDay", "quickHour", "quickWeek", "sameDay", "start", "startDate", "startMark", "unlimited" + ].map((key) => [key, t(`time.${key}`)])); +} diff --git a/external-admin/src/components/BannerCreateDialog.test.jsx b/external-admin/src/components/BannerCreateDialog.test.jsx index 54b4fd8..bafad3e 100644 --- a/external-admin/src/components/BannerCreateDialog.test.jsx +++ b/external-admin/src/components/BannerCreateDialog.test.jsx @@ -8,13 +8,13 @@ describe("BannerCreateDialog", () => { const user = userEvent.setup(); render(); - expect(screen.getByRole("textbox", { name: "参数(H5 链接)" })).toBeInTheDocument(); - expect(screen.getByText("投放时间")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Parameter (H5 URL)" })).toBeInTheDocument(); + expect(screen.getByText("Schedule")).toBeInTheDocument(); - await user.click(screen.getByRole("combobox", { name: "跳转类型" })); + await user.click(screen.getByRole("combobox", { name: "Destination type" })); await user.click(await screen.findByRole("option", { name: "APP" })); - expect(screen.getByRole("combobox", { name: "APP 目标" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "参数(APP 公共跳转 JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" })); + expect(screen.getByRole("combobox", { name: "App destination" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Parameter (App navigation JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" })); }); }); diff --git a/external-admin/src/components/GrantDialogs.jsx b/external-admin/src/components/GrantDialogs.jsx index ab6d34d..6fce0f1 100644 --- a/external-admin/src/components/GrantDialogs.jsx +++ b/external-admin/src/components/GrantDialogs.jsx @@ -8,49 +8,54 @@ import MenuItem from "@mui/material/MenuItem"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import { useEffect, useState } from "react"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resources }) { + const { t } = useExternalI18n(); const [form, setForm] = useState(resourceGrantInitial); useEffect(() => setForm(resourceGrantInitial), [open]); return ( - onSubmit(form)}> + onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> - setForm({ ...form, resourceId: event.target.value })}> - {resources.map((item) => {item.name}({item.id}))} + setForm({ ...form, target: event.target.value.trim() })} /> + setForm({ ...form, resourceId: event.target.value })}> + {resources.map((item) => {item.name} ({item.id}))} - setForm({ ...form, quantity: event.target.value })} /> - setForm({ ...form, durationDays: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + setForm({ ...form, quantity: event.target.value })} /> + setForm({ ...form, durationDays: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { + const { t } = useExternalI18n(); const [form, setForm] = useState(prettyGrantInitial); useEffect(() => setForm(prettyGrantInitial), [open]); return ( - onSubmit(form)}> + onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> - setForm({ ...form, prettyId: event.target.value.trim() })} /> - setForm({ ...form, durationDays: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + setForm({ ...form, target: event.target.value.trim() })} /> + setForm({ ...form, prettyId: event.target.value.trim() })} /> + setForm({ ...form, durationDays: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } function TargetNotice() { - return 提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。; + const { t } = useExternalI18n(); + return {t("grants.notice")}; } function GrantDialog({ children, loading, onClose, onSubmit, open, title }) { + const { t } = useExternalI18n(); return ( { event.preventDefault(); onSubmit(); }}> {title} {children} - + ); diff --git a/external-admin/src/components/RoomEditDialog.jsx b/external-admin/src/components/RoomEditDialog.jsx index da60be6..c3fe458 100644 --- a/external-admin/src/components/RoomEditDialog.jsx +++ b/external-admin/src/components/RoomEditDialog.jsx @@ -7,8 +7,10 @@ import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import { useEffect, useState } from "react"; import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) { + const { t } = useExternalI18n(); const [form, setForm] = useState({ coverUrl: "", description: "", title: "", visibleRegionId: "" }); useEffect(() => { setForm({ coverUrl: room?.coverUrl || "", description: room?.description || "", title: room?.title || "", visibleRegionId: room?.visibleRegionId || "" }); @@ -16,14 +18,14 @@ export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) { return ( { event.preventDefault(); onSubmit(form); }}> - 编辑房间 + {t("rooms.editTitle")} - setForm({ ...form, coverUrl })} /> - setForm({ ...form, title: event.target.value })} /> - setForm({ ...form, description: event.target.value })} /> - setForm({ ...form, visibleRegionId: event.target.value.trim() })} /> + setForm({ ...form, coverUrl })} /> + setForm({ ...form, title: event.target.value })} /> + setForm({ ...form, description: event.target.value })} /> + setForm({ ...form, visibleRegionId: event.target.value.trim() })} /> - + ); diff --git a/external-admin/src/components/UserDialogs.jsx b/external-admin/src/components/UserDialogs.jsx index a227b14..3407b57 100644 --- a/external-admin/src/components/UserDialogs.jsx +++ b/external-admin/src/components/UserDialogs.jsx @@ -8,49 +8,54 @@ import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import { useEffect, useState } from "react"; import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function UserEditDialog({ loading, onClose, onSubmit, open, user }) { + const { t } = useExternalI18n(); const [form, setForm] = useState(emptyEditForm); useEffect(() => { setForm({ avatar: user?.avatar || "", country: user?.country || "", gender: user?.gender || "", username: user?.username || "" }); }, [user]); return ( - onSubmit(form)}> - setForm({ ...form, avatar })} /> - setForm({ ...form, username: event.target.value })} /> - setForm({ ...form, gender: event.target.value })}> - 未设置未知 + onSubmit(form)}> + setForm({ ...form, avatar })} /> + setForm({ ...form, username: event.target.value })} /> + setForm({ ...form, gender: event.target.value })}> + {t("users.notSet")}{t("users.genderMale")}{t("users.genderFemale")}{t("users.genderUnknown")} - setForm({ ...form, country: event.target.value.trim().toUpperCase() })} /> + setForm({ ...form, country: event.target.value.trim().toUpperCase() })} /> ); } export function UserBanDialog({ loading, onClose, onSubmit, open, user }) { + const { t } = useExternalI18n(); const [form, setForm] = useState({ days: "7", reason: "" }); useEffect(() => setForm({ days: "7", reason: "" }), [user]); return ( - onSubmit(form)}> - setForm({ ...form, days: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + onSubmit(form)}> + setForm({ ...form, days: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } export function UserLevelDialog({ loading, onClose, onSubmit, open, user }) { + const { t } = useExternalI18n(); const [form, setForm] = useState({ days: "30", level: "", reason: "", track: "wealth" }); useEffect(() => setForm({ days: "30", level: "", reason: "", track: "wealth" }), [user]); return ( - onSubmit(form)}> - setForm({ ...form, track: event.target.value })}>财富等级魅力等级VIP 等级 - setForm({ ...form, level: event.target.value })} /> - setForm({ ...form, days: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + onSubmit(form)}> + setForm({ ...form, track: event.target.value })}>{t("users.wealthLevel")}{t("users.charmLevel")}{t("users.vipLevel")} + setForm({ ...form, level: event.target.value })} /> + setForm({ ...form, days: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } function BaseDialog({ children, loading, onClose, onSubmit, open, title }) { + const { t } = useExternalI18n(); const handleSubmit = (event) => { event.preventDefault(); onSubmit(); @@ -60,7 +65,7 @@ function BaseDialog({ children, loading, onClose, onSubmit, open, title }) { {title} {children} - + ); diff --git a/external-admin/src/config/capabilities.js b/external-admin/src/config/capabilities.js index a346f9f..7d16f5a 100644 --- a/external-admin/src/config/capabilities.js +++ b/external-admin/src/config/capabilities.js @@ -1,24 +1,24 @@ export const EXTERNAL_CAPABILITIES = [ - { code: "user:list", label: "用户列表", path: "/users" }, - { code: "user:update", label: "用户信息编辑", path: "/users" }, - { code: "user-ban:list", label: "账号封禁列表", path: "/bans" }, - { code: "user:ban", label: "执行封禁", path: "/users" }, - { code: "user:unban", label: "解除封禁", path: "/bans" }, - { code: "host:list", label: "主播列表", path: "/organization/hosts" }, - { code: "agency:list", label: "公会列表", path: "/organization/agencies" }, - { code: "bd:list", label: "BD 列表", path: "/organization/bds" }, - { code: "bd-manager:list", label: "BD Manager 列表", path: "/organization/bd-leaders" }, - { code: "super-admin:list", label: "Super Admin 列表", path: "/organization/super-admins" }, - { code: "room:list", label: "房间管理", path: "/rooms" }, - { code: "room:update", label: "房间编辑", path: "/rooms" }, - { code: "privilege:list", label: "用户特权道具列表", path: "/grants" }, - { code: "privilege:grant", label: "特权道具发放", path: "/grants" }, - { code: "banner:create", label: "Banner 创建", path: "/banners" }, - { code: "pretty-id:grant", label: "靓号下发", path: "/grants" }, - { code: "user-title:grant", label: "用户称号下发", path: "/grants" }, - { code: "room-background:grant", label: "房间背景图下发", path: "/grants" }, - { code: "user-level:grant", label: "财富/VIP等级下发", path: "/users" }, - { code: "team:view", label: "我的团队", path: "/organization/team" } + { code: "user:list", labelKey: "capability.userList", path: "/users" }, + { code: "user:update", labelKey: "capability.userUpdate", path: "/users" }, + { code: "user-ban:list", labelKey: "capability.banList", path: "/bans" }, + { code: "user:ban", labelKey: "capability.ban", path: "/users" }, + { code: "user:unban", labelKey: "capability.unban", path: "/bans" }, + { code: "host:list", labelKey: "capability.hostList", path: "/organization/hosts" }, + { code: "agency:list", labelKey: "capability.agencyList", path: "/organization/agencies" }, + { code: "bd:list", labelKey: "capability.bdList", path: "/organization/bds" }, + { code: "bd-manager:list", labelKey: "capability.bdManagerList", path: "/organization/bd-leaders" }, + { code: "super-admin:list", labelKey: "capability.superAdminList", path: "/organization/super-admins" }, + { code: "room:list", labelKey: "capability.roomList", path: "/rooms" }, + { code: "room:update", labelKey: "capability.roomUpdate", path: "/rooms" }, + { code: "privilege:list", labelKey: "capability.privilegeList", path: "/grants" }, + { code: "privilege:grant", labelKey: "capability.privilegeGrant", path: "/grants" }, + { code: "banner:create", labelKey: "capability.bannerCreate", path: "/banners" }, + { code: "pretty-id:grant", labelKey: "capability.prettyIdGrant", path: "/grants" }, + { code: "user-title:grant", labelKey: "capability.userTitleGrant", path: "/grants" }, + { code: "room-background:grant", labelKey: "capability.roomBackgroundGrant", path: "/grants" }, + { code: "user-level:grant", labelKey: "capability.userLevelGrant", path: "/users" }, + { code: "team:view", labelKey: "capability.teamView", path: "/organization/team" } ]; export function hasExternalCapability(session, code) { diff --git a/external-admin/src/i18n/ExternalI18nProvider.jsx b/external-admin/src/i18n/ExternalI18nProvider.jsx new file mode 100644 index 0000000..b5fe1ee --- /dev/null +++ b/external-admin/src/i18n/ExternalI18nProvider.jsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { + DEFAULT_EXTERNAL_LOCALE, + EXTERNAL_LANGUAGE_OPTIONS, + EXTERNAL_LOCALE_STORAGE_KEY, + externalDirection, + externalMessage, + normalizeExternalLocale +} from "./messages.js"; + +const defaultTranslate = (key, values) => interpolate(externalMessage(DEFAULT_EXTERNAL_LOCALE, key), values); +const ExternalI18nContext = createContext({ + direction: "ltr", + languages: EXTERNAL_LANGUAGE_OPTIONS, + locale: DEFAULT_EXTERNAL_LOCALE, + setLocale: () => {}, + t: defaultTranslate +}); + +export function ExternalI18nProvider({ children }) { + // The product default is deliberately English. Browser language is ignored until the operator explicitly chooses and persists another locale. + const [locale, setLocaleState] = useState(readPersistedLocale); + const direction = externalDirection(locale); + const setLocale = useCallback((nextLocale) => { + const normalized = normalizeExternalLocale(nextLocale); + setLocaleState(normalized); + try { + globalThis.localStorage?.setItem(EXTERNAL_LOCALE_STORAGE_KEY, normalized); + } catch { + // Storage can be unavailable in hardened/private browser contexts; the in-memory locale still changes for the current session. + } + }, []); + const t = useCallback((key, values) => interpolate(externalMessage(locale, key), values), [locale]); + + useEffect(() => { + // Keep native controls, assistive technology, MUI portals and the shell aligned when Arabic changes writing direction. + const root = globalThis.document?.documentElement; + if (!root) { + return; + } + root.lang = locale; + root.dir = direction; + globalThis.document.body?.setAttribute("dir", direction); + globalThis.document.title = t("app.title"); + }, [direction, locale, t]); + + const value = useMemo( + () => ({ direction, languages: EXTERNAL_LANGUAGE_OPTIONS, locale, setLocale, t }), + [direction, locale, setLocale, t] + ); + + return {children}; +} + +export function useExternalI18n() { + return useContext(ExternalI18nContext); +} + +function readPersistedLocale() { + try { + return normalizeExternalLocale(globalThis.localStorage?.getItem(EXTERNAL_LOCALE_STORAGE_KEY)); + } catch { + return DEFAULT_EXTERNAL_LOCALE; + } +} + +function interpolate(message, values = {}) { + return String(message).replace(/\{(\w+)\}/g, (match, key) => (values[key] === undefined ? match : String(values[key]))); +} diff --git a/external-admin/src/i18n/ExternalI18nProvider.test.jsx b/external-admin/src/i18n/ExternalI18nProvider.test.jsx new file mode 100644 index 0000000..abcb047 --- /dev/null +++ b/external-admin/src/i18n/ExternalI18nProvider.test.jsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test } from "vitest"; +import { ExternalI18nProvider, useExternalI18n } from "./ExternalI18nProvider.jsx"; +import { EXTERNAL_LOCALE_STORAGE_KEY } from "./messages.js"; + +describe("ExternalI18nProvider", () => { + beforeEach(() => { + localStorage.removeItem(EXTERNAL_LOCALE_STORAGE_KEY); + document.documentElement.lang = ""; + document.documentElement.dir = ""; + }); + + test("defaults to English instead of the browser locale", () => { + Object.defineProperty(navigator, "language", { configurable: true, value: "zh-CN" }); + render(); + + expect(screen.getByTestId("locale")).toHaveTextContent("en"); + expect(screen.getByTestId("title")).toHaveTextContent("HYApp External Admin"); + expect(document.documentElement).toHaveAttribute("lang", "en"); + expect(document.documentElement).toHaveAttribute("dir", "ltr"); + }); + + test("supports and persists Chinese, Arabic RTL and Turkish", async () => { + const user = userEvent.setup(); + const firstRender = render(); + + await user.click(screen.getByRole("button", { name: "ar" })); + expect(screen.getByTestId("title")).toHaveTextContent("إدارة HYApp الخارجية"); + 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("HYApp 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("HYApp 外管后台"); + 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("HYApp 外管后台"); + }); +}); + +function LocaleProbe() { + const { locale, setLocale, t } = useExternalI18n(); + return ( +
+ {locale} + {t("app.title")} + {["zh-CN", "ar", "tr"].map((value) => )} +
+ ); +} diff --git a/external-admin/src/i18n/LanguageSwitcher.jsx b/external-admin/src/i18n/LanguageSwitcher.jsx new file mode 100644 index 0000000..873b822 --- /dev/null +++ b/external-admin/src/i18n/LanguageSwitcher.jsx @@ -0,0 +1,26 @@ +import LanguageOutlined from "@mui/icons-material/LanguageOutlined"; +import InputAdornment from "@mui/material/InputAdornment"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useExternalI18n } from "./ExternalI18nProvider.jsx"; + +export function LanguageSwitcher({ compact = false }) { + const { languages, locale, setLocale, t } = useExternalI18n(); + + return ( + }, + select: { displayEmpty: true } + }} + value={locale} + onChange={(event) => setLocale(event.target.value)} + > + {languages.map((language) => {language.label})} + + ); +} diff --git a/external-admin/src/i18n/emotionCache.js b/external-admin/src/i18n/emotionCache.js new file mode 100644 index 0000000..7f86a56 --- /dev/null +++ b/external-admin/src/i18n/emotionCache.js @@ -0,0 +1,10 @@ +import createCache from "@emotion/cache"; +import rtlPlugin from "@mui/stylis-plugin-rtl"; +import { prefixer } from "stylis"; + +const externalLtrCache = createCache({ key: "external-ltr", prepend: true }); +const externalRtlCache = createCache({ key: "external-rtl", prepend: true, stylisPlugins: [prefixer, rtlPlugin] }); + +export function externalEmotionCache(direction) { + return direction === "rtl" ? externalRtlCache : externalLtrCache; +} diff --git a/external-admin/src/i18n/emotionCache.test.js b/external-admin/src/i18n/emotionCache.test.js new file mode 100644 index 0000000..62592e8 --- /dev/null +++ b/external-admin/src/i18n/emotionCache.test.js @@ -0,0 +1,24 @@ +import rtlPlugin from "@mui/stylis-plugin-rtl"; +import { compile, middleware, prefixer, serialize, stringify } from "stylis"; +import { describe, expect, test } from "vitest"; +import { externalEmotionCache } from "./emotionCache.js"; + +describe("external admin RTL Emotion cache", () => { + test("uses isolated LTR and RTL caches", () => { + expect(externalEmotionCache("ltr").key).toBe("external-ltr"); + expect(externalEmotionCache("rtl").key).toBe("external-rtl"); + expect(externalEmotionCache("rtl")).not.toBe(externalEmotionCache("ltr")); + }); + + test("mirrors physical MUI-style declarations for Arabic", () => { + const css = serialize( + compile(".control{margin-left:8px;padding-right:12px;text-align:left}"), + middleware([prefixer, rtlPlugin, stringify]) + ); + + expect(css).toContain("margin-right:8px"); + expect(css).toContain("padding-left:12px"); + expect(css).toContain("text-align:right"); + expect(css).not.toContain("margin-left:8px"); + }); +}); diff --git a/external-admin/src/i18n/errors.js b/external-admin/src/i18n/errors.js new file mode 100644 index 0000000..0880b5c --- /dev/null +++ b/external-admin/src/i18n/errors.js @@ -0,0 +1,51 @@ +const ERROR_CODE_KEYS = Object.freeze({ + account_locked: "errors.accountLocked", + app_inactive: "errors.appInactive", + invalid_credentials: "auth.invalidCredentials", + rate_limited: "errors.rateLimited", + too_many_requests: "errors.rateLimited" +}); + +export class ExternalUiError extends Error { + constructor(translationKey) { + super(translationKey); + this.name = "ExternalUiError"; + this.translationKey = translationKey; + } +} + +export function externalUiError(translationKey) { + return new ExternalUiError(translationKey); +} + +export function translateExternalError(error, t, fallbackKey = "errors.requestFailed") { + if (error?.translationKey) { + return t(error.translationKey); + } + const code = String(error?.code || "").trim().toLowerCase().replace(/[.-]/g, "_"); + if (ERROR_CODE_KEYS[code]) { + return t(ERROR_CODE_KEYS[code]); + } + if (error?.status === 401) { + return t("errors.unauthorized"); + } + if (error?.status === 403) { + return t("errors.forbidden"); + } + if (error?.status === 404) { + return t("errors.notFound"); + } + if (error?.status === 409) { + return t("errors.conflict"); + } + if (error?.status === 413) { + return t("errors.tooLarge"); + } + if (error?.status === 429) { + return t("errors.rateLimited"); + } + if (Number(error?.status) >= 500) { + return t("errors.server"); + } + return t(fallbackKey); +} diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js new file mode 100644 index 0000000..c442286 --- /dev/null +++ b/external-admin/src/i18n/messages.js @@ -0,0 +1,404 @@ +export const EXTERNAL_LOCALES = Object.freeze(["en", "zh-CN", "ar", "tr"]); +export const DEFAULT_EXTERNAL_LOCALE = "en"; +export const EXTERNAL_LOCALE_STORAGE_KEY = "hyapp_external_admin_locale"; + +export const EXTERNAL_LANGUAGE_OPTIONS = Object.freeze([ + { label: "English", value: "en" }, + { label: "简体中文", value: "zh-CN" }, + { label: "العربية", value: "ar" }, + { label: "Türkçe", value: "tr" } +]); + +const en = { + "app.title": "HYApp External Admin", + "app.shortTitle": "HYApp External Admin", + "language.label": "Language", + "common.actions": "Actions", + "common.active": "Active", + "common.all": "All", + "common.allStatuses": "All statuses", + "common.banned": "Banned", + "common.cancel": "Cancel", + "common.confirm": "Confirm", + "common.create": "Create", + "common.disabled": "Disabled", + "common.edit": "Edit", + "common.enabled": "Enabled", + "common.expired": "Expired", + "common.loading": "Loading", + "common.noData": "No data", + "common.normal": "Normal", + "common.query": "Search", + "common.reason": "Reason", + "common.reload": "Reload", + "common.retry": "Retry", + "common.save": "Save", + "common.status": "Status", + "common.user": "User", + "auth.account": "External admin account", + "auth.changePassword": "Change external admin password", + "auth.checkingSession": "Checking external admin session", + "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", + "auth.loginFailed": "Sign-in failed", + "auth.logout": "Sign out", + "auth.newPassword": "New password", + "auth.noPermission": "This external admin account does not have access to this feature.", + "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": "Return to permission overview", + "auth.savePassword": "Save password", + "auth.sessionFailed": "Session verification failed", + "auth.showPassword": "Show password", + "nav.expand": "Open navigation", + "nav.overview": "Permission overview", + "nav.users": "User management", + "nav.bans": "Ban management", + "nav.hosts": "Hosts", + "nav.agencies": "Agencies", + "nav.bds": "BDs", + "nav.bdManagers": "BD Managers", + "nav.superAdmins": "Super Admins", + "nav.rooms": "Room management", + "nav.grants": "Resources & vanity IDs", + "nav.banners": "Banner management", + "nav.team": "My team", + "capability.userList": "User list", + "capability.userUpdate": "Edit user information", + "capability.banList": "Account ban list", + "capability.ban": "Ban users", + "capability.unban": "Unban users", + "capability.hostList": "Host list", + "capability.agencyList": "Agency list", + "capability.bdList": "BD list", + "capability.bdManagerList": "BD Manager list", + "capability.superAdminList": "Super Admin list", + "capability.roomList": "Room management", + "capability.roomUpdate": "Edit rooms", + "capability.privilegeList": "User privilege item list", + "capability.privilegeGrant": "Grant privilege items", + "capability.bannerCreate": "Create banners", + "capability.prettyIdGrant": "Grant vanity IDs", + "capability.userTitleGrant": "Grant user titles", + "capability.roomBackgroundGrant": "Grant room backgrounds", + "capability.userLevelGrant": "Grant wealth/VIP levels", + "capability.teamView": "My team", + "overview.currentApp": "Current App", + "overview.account": "External admin account", + "overview.permissions": "Permissions enabled", + "overview.enabled": "Enabled", + "overview.notEnabled": "Not enabled", + "users.title": "User management", + "users.search": "User ID / vanity ID / name", + "users.prettyId": "Vanity ID", + "users.countryGender": "Country / gender", + "users.level": "Level", + "users.levelSummary": "Wealth {wealth} / Charm {charm} / VIP {vip}", + "users.edit": "Edit", + "users.grantLevel": "Level", + "users.ban": "Ban", + "users.updated": "User information updated", + "users.banned": "User banned", + "users.levelGranted": "User level granted", + "users.operationFailed": "User operation failed", + "users.editTitle": "Edit user", + "users.avatar": "Avatar", + "users.name": "User name", + "users.gender": "Gender", + "users.countryCode": "Country/region code", + "users.notSet": "Not set", + "users.genderMale": "Male", + "users.genderFemale": "Female", + "users.genderUnknown": "Unknown", + "users.banTitle": "Ban user {user}", + "users.banDays": "Ban duration (days)", + "users.banForeverHint": "Enter 0 for a permanent ban", + "users.banReason": "Ban reason", + "users.levelTitle": "Grant level to {user}", + "users.levelType": "Level type", + "users.wealthLevel": "Wealth level", + "users.charmLevel": "Charm level", + "users.vipLevel": "VIP level", + "users.validDays": "Validity (days)", + "users.validDaysMin": "Validity must be at least 1 day", + "users.grantReason": "Grant reason", + "bans.title": "Account ban list", + "bans.reason": "Ban reason", + "bans.unban": "Unban", + "bans.unbanned": "User unbanned", + "bans.unbanFailed": "Failed to unban user", + "bans.confirmTitle": "Unban user", + "bans.confirmContent": "Unban {user}?", + "organization.title": "Organization", + "organization.agencies": "Agency list", + "organization.bds": "BD list", + "organization.bdManagers": "BD Manager list", + "organization.hosts": "Host list", + "organization.managers": "Manager list", + "organization.superAdmins": "Super Admin list", + "organization.team": "My team", + "organization.agency": "Agency", + "organization.owner": "Owner", + "organization.parentBd": "Parent BD", + "organization.member": "Member", + "organization.country": "Country / region", + "organization.userReference": "User {id}", + "organization.agencyReference": "Agency {id}", + "organization.teamSearch": "Agency ID / owner user ID / parent BD ID", + "organization.noPermission": "This account does not have access to this organization list.", + "organization.bdLeaderCount": "BD Leaders: {count}", + "organization.bdCount": "BDs: {count}", + "organization.agencyCount": "Agencies: {count}", + "organization.truncated": "The team exceeds the API limit. These results are incomplete.", + "rooms.title": "Room management", + "rooms.room": "Room", + "rooms.owner": "Owner", + "rooms.visibleRegion": "Visible region", + "rooms.search": "Room ID / name / owner", + "rooms.updated": "Room information updated", + "rooms.updateFailed": "Failed to update room", + "rooms.editTitle": "Edit room", + "rooms.background": "Room background", + "rooms.name": "Room name", + "rooms.description": "Room description", + "rooms.visibleRegionId": "Visible region ID", + "rooms.allRegions": "All", + "grants.title": "Resources & vanity IDs", + "grants.grantResource": "Grant item", + "grants.grantPrettyId": "Grant vanity ID", + "grants.availableResources": "Available resources", + "grants.targetUser": "Target user", + "grants.resource": "Resource", + "grants.quantity": "Quantity", + "grants.resourceGranted": "Privilege item granted", + "grants.prettyIdGranted": "Vanity ID granted", + "grants.failed": "Grant failed", + "grants.resourceTitle": "Grant privilege item", + "grants.prettyIdTitle": "Grant vanity ID", + "grants.target": "User ID / vanity ID", + "grants.currentTarget": "User ID / current vanity ID", + "grants.prettyId": "Vanity ID to grant", + "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", + "banners.type": "Type", + "banners.platform": "Platform", + "banners.sort": "Sort order", + "banners.create": "Create banner", + "banners.created": "Banner created", + "banners.createFailed": "Failed to create banner", + "banners.image": "Banner image", + "banners.roomImage": "In-room thumbnail", + "banners.description": "Description", + "banners.jumpType": "Destination type", + "banners.h5Param": "Parameter (H5 URL)", + "banners.appParam": "Parameter (App navigation JSON)", + "banners.displayScope": "Display scope", + "banners.scopeHome": "Home", + "banners.scopeRoom": "Room", + "banners.scopeRecharge": "Recharge", + "banners.scopeMe": "Me", + "banners.regionId": "Region ID", + "banners.countryCode": "Country code", + "banners.countryCodeHint": "2–3 letters; leave empty for all", + "banners.schedule": "Schedule", + "upload.preview": "{label} preview", + "upload.empty": "No image", + "upload.uploading": "Uploading", + "upload.replace": "Replace image", + "upload.select": "Upload image", + "upload.imageOnly": "Select an image file", + "upload.sizeLimit": "Images must not exceed 10 MB", + "upload.missingUrl": "The upload response did not contain an image URL", + "upload.failed": "Image upload failed", + "pagination.total": "{count} items", + "pagination.previous": "Previous", + "pagination.next": "Next", + "status.active": "Active", + "status.enabled": "Enabled", + "status.running": "Running", + "status.normal": "Normal", + "status.unbanned": "Unbanned", + "status.banned": "Banned", + "status.disabled": "Disabled", + "status.failed": "Failed", + "status.blocked": "Blocked", + "status.expired": "Expired", + "jump.appTarget": "App destination", + "jump.target.wallet": "Wallet", + "jump.target.room": "Specific room", + "jump.target.room_random": "Random room", + "jump.target.room_window": "Window in a specific room", + "jump.target.room_random_window": "Window in a random room", + "jump.target.room_game": "Game in a specific room", + "jump.target.room_random_game": "Game in a random room", + "jump.target.explore_game": "Explore game H5", + "jump.target.custom": "Custom JSON", + "jump.roomId": "Room ID", + "jump.window": "In-room window", + "jump.window.game_window": "Game window", + "jump.window.gift_panel": "Gift panel", + "jump.window.red_packet_claim": "Red packet dialog", + "jump.window.wheel": "Wheel window", + "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", + "time.quickWeek": "Last 7 days", + "time.start": "Start time", + "time.end": "End time", + "time.startDate": "Start date", + "time.endDate": "End date", + "time.pending": "Not selected", + "time.startMark": "S", + "time.endMark": "E", + "time.previousMonth": "Previous month", + "time.nextMonth": "Next month", + "time.hour": "Hour", + "time.minute": "Minute", + "time.clear": "Clear", + "time.apply": "Apply", + "time.endAfterStart": "End time must be later than start time", + "time.endDateAfterStart": "End date cannot be earlier than start date", + "time.completeRange": "Select the full range", + "time.endLaterShort": "End must be later than start", + "time.endDateLaterShort": "End cannot be earlier than start", + "time.durationDays": "{count} days", + "time.durationHours": "{count} hours", + "time.durationMinutes": "{count} minutes", + "time.duration": "{value}", + "time.sameDay": "Same day", + "time.lessMinute": "Less than 1 minute", + "errors.loadFailed": "Failed to load data", + "errors.requestFailed": "Request failed", + "errors.unauthorized": "Your session has expired. Sign in again.", + "errors.forbidden": "You do not have permission to perform this operation.", + "errors.notFound": "The requested resource was not found.", + "errors.conflict": "This operation conflicts with the current data. Refresh and try again.", + "errors.tooLarge": "The request is too large.", + "errors.rateLimited": "Too many requests. Try again later.", + "errors.server": "The service is temporarily unavailable. Try again later.", + "errors.accountLocked": "This account is temporarily locked. Try again later.", + "errors.appInactive": "The App linked to this account is inactive.", + "errors.vipDisabled": "The VIP program for the current App is disabled", + "errors.vipLevelInvalid": "Select a VIP level configured for the current App", + "errors.organizationRoute": "This organization list has no secure data endpoint", + "errors.regionId": "Visible region ID must be a safe non-negative integer", + "errors.grantTarget": "No target user was found in the current App", + "errors.bannerType": "Invalid banner type", + "errors.bannerPlatform": "Invalid banner platform", + "errors.bannerStatus": "Invalid banner status", + "errors.bannerScope": "Select at least one display scope", + "errors.bannerImage": "Upload a banner image", + "errors.h5Url": "Enter a valid H5 URL", + "errors.h5Protocol": "H5 URLs must use http or https", + "errors.appParamRequired": "Enter App navigation parameters", + "errors.appParamObject": "App navigation parameters must be a JSON object", + "errors.appParamType": "The App navigation type is not supported", + "errors.appParamRoom": "This App destination requires room_id", + "errors.appParamGame": "This App destination requires game_id", + "errors.appParamWindow": "This App destination requires a valid window", + "errors.appParamGameCode": "Explore game H5 requires a valid game_code", + "errors.roomBannerImage": "Upload an in-room thumbnail for the room scope", + "errors.countryCode": "Country code must contain 2–3 letters", + "errors.bannerRegion": "Invalid region ID", + "errors.bannerSchedule": "The schedule end time must be later than the start time" +}; + +const zhCN = { + "app.title": "HYApp 外管后台", + "app.shortTitle": "HYApp 外管", + "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": "显示密码", + "nav.expand": "展开菜单", "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.enabled": "已开通", "overview.notEnabled": "未开通", + "users.title": "用户管理", "users.search": "用户 ID / 短 ID / 名称", "users.prettyId": "短 ID(靓号)", "users.countryGender": "国家/性别", "users.level": "等级", "users.levelSummary": "财富 {wealth} / 魅力 {charm} / VIP {vip}", "users.edit": "编辑", "users.grantLevel": "等级", "users.ban": "封禁", "users.updated": "用户信息已更新", "users.banned": "用户已封禁", "users.levelGranted": "用户等级已下发", "users.operationFailed": "用户操作失败", "users.editTitle": "编辑用户", "users.avatar": "头像", "users.name": "用户名称", "users.gender": "性别", "users.countryCode": "国家/地区码", "users.notSet": "未设置", "users.genderMale": "男", "users.genderFemale": "女", "users.genderUnknown": "未知", "users.banTitle": "封禁用户 {user}", "users.banDays": "封禁天数", "users.banForeverHint": "填 0 表示永久封禁", "users.banReason": "封禁原因", "users.levelTitle": "等级下发 {user}", "users.levelType": "等级类型", "users.wealthLevel": "财富等级", "users.charmLevel": "魅力等级", "users.vipLevel": "VIP 等级", "users.validDays": "有效天数", "users.validDaysMin": "有效期至少 1 天", "users.grantReason": "下发原因", + "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": "确认发放", + "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。", + "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": "إدارة HYApp الخارجية", "app.shortTitle": "إدارة HYApp الخارجية", "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": "إظهار كلمة المرور", + "nav.expand": "فتح القائمة", "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.enabled": "مفعّلة", "overview.notEnabled": "غير مفعّلة", + "users.title": "إدارة المستخدمين", "users.search": "معرّف المستخدم / المعرّف المميز / الاسم", "users.prettyId": "المعرّف المميز", "users.countryGender": "الدولة / الجنس", "users.level": "المستوى", "users.levelSummary": "الثروة {wealth} / الجاذبية {charm} / VIP {vip}", "users.edit": "تعديل", "users.grantLevel": "المستوى", "users.ban": "حظر", "users.updated": "تم تحديث بيانات المستخدم", "users.banned": "تم حظر المستخدم", "users.levelGranted": "تم منح مستوى المستخدم", "users.operationFailed": "فشلت عملية المستخدم", "users.editTitle": "تعديل المستخدم", "users.avatar": "الصورة الشخصية", "users.name": "اسم المستخدم", "users.gender": "الجنس", "users.countryCode": "رمز الدولة/المنطقة", "users.notSet": "غير محدد", "users.genderMale": "ذكر", "users.genderFemale": "أنثى", "users.genderUnknown": "غير معروف", "users.banTitle": "حظر المستخدم {user}", "users.banDays": "مدة الحظر (بالأيام)", "users.banForeverHint": "أدخل 0 للحظر الدائم", "users.banReason": "سبب الحظر", "users.levelTitle": "منح مستوى إلى {user}", "users.levelType": "نوع المستوى", "users.wealthLevel": "مستوى الثروة", "users.charmLevel": "مستوى الجاذبية", "users.vipLevel": "مستوى VIP", "users.validDays": "الصلاحية (بالأيام)", "users.validDaysMin": "يجب ألا تقل الصلاحية عن يوم واحد", "users.grantReason": "سبب المنح", + "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": "تأكيد المنح", + "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، ويمكنك تعديلها مباشرة للمعاملات المخصصة.", + "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": "HYApp Harici Yönetim", "app.shortTitle": "HYApp 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": "Harici yönetim hesabı", "auth.changePassword": "Harici yönetim parolasını 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 harici yönetim hesabını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": "Yetki özetine dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", + "nav.expand": "Menüyü aç", "nav.overview": "Yetki özeti", "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": "Harici yönetim hesabı", "overview.permissions": "Etkin yetkiler", "overview.enabled": "Etkin", "overview.notEnabled": "Etkin değil", + "users.title": "Kullanıcı yönetimi", "users.search": "Kullanıcı ID / özel ID / ad", "users.prettyId": "Özel ID", "users.countryGender": "Ülke / cinsiyet", "users.level": "Seviye", "users.levelSummary": "Servet {wealth} / Cazibe {charm} / VIP {vip}", "users.edit": "Düzenle", "users.grantLevel": "Seviye", "users.ban": "Yasakla", "users.updated": "Kullanıcı bilgileri güncellendi", "users.banned": "Kullanıcı yasaklandı", "users.levelGranted": "Kullanıcı seviyesi verildi", "users.operationFailed": "Kullanıcı işlemi başarısız", "users.editTitle": "Kullanıcıyı düzenle", "users.avatar": "Avatar", "users.name": "Kullanıcı adı", "users.gender": "Cinsiyet", "users.countryCode": "Ülke/bölge kodu", "users.notSet": "Ayarlanmadı", "users.genderMale": "Erkek", "users.genderFemale": "Kadın", "users.genderUnknown": "Bilinmiyor", "users.banTitle": "{user} kullanıcısını yasakla", "users.banDays": "Yasak süresi (gün)", "users.banForeverHint": "Kalıcı yasak için 0 girin", "users.banReason": "Yasak nedeni", "users.levelTitle": "{user} kullanıcısına seviye ver", "users.levelType": "Seviye türü", "users.wealthLevel": "Servet seviyesi", "users.charmLevel": "Cazibe seviyesi", "users.vipLevel": "VIP seviyesi", "users.validDays": "Geçerlilik (gün)", "users.validDaysMin": "Geçerlilik en az 1 gün olmalıdır", "users.grantReason": "Verme nedeni", + "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", + "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.", + "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" +}; + +export const externalMessages = Object.freeze({ ar, en, tr, "zh-CN": zhCN }); + +export function normalizeExternalLocale(value) { + return EXTERNAL_LOCALES.includes(value) ? value : DEFAULT_EXTERNAL_LOCALE; +} + +export function externalDirection(locale) { + return locale === "ar" ? "rtl" : "ltr"; +} + +export function externalMessage(locale, key) { + return externalMessages[normalizeExternalLocale(locale)]?.[key] ?? en[key] ?? key; +} diff --git a/external-admin/src/i18n/messages.test.js b/external-admin/src/i18n/messages.test.js new file mode 100644 index 0000000..7fa4dbb --- /dev/null +++ b/external-admin/src/i18n/messages.test.js @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { translateExternalError } from "./errors.js"; +import { EXTERNAL_LANGUAGE_OPTIONS, externalDirection, externalMessage, externalMessages } from "./messages.js"; + +describe("external admin translations", () => { + test("keeps all four locale catalogs structurally complete", () => { + const englishKeys = Object.keys(externalMessages.en).sort(); + expect(englishKeys.length).toBeGreaterThan(250); + for (const locale of ["zh-CN", "ar", "tr"]) { + expect(Object.keys(externalMessages[locale]).sort()).toEqual(englishKeys); + } + }); + + test("uses native language names and Arabic RTL only", () => { + expect(EXTERNAL_LANGUAGE_OPTIONS.map((item) => item.label)).toEqual(["English", "简体中文", "العربية", "Türkçe"]); + expect(externalDirection("ar")).toBe("rtl"); + expect(externalDirection("en")).toBe("ltr"); + expect(externalDirection("zh-CN")).toBe("ltr"); + expect(externalDirection("tr")).toBe("ltr"); + }); + + test("never exposes an unknown backend-language message", () => { + const t = (key) => externalMessage("en", key); + expect(translateExternalError({ message: "服务端中文错误" }, t)).toBe("Request failed"); + expect(translateExternalError({ message: "服务端中文错误", status: 429 }, t)).toBe("Too many requests. Try again later."); + }); +}); diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx index 59a5787..3ff4361 100644 --- a/external-admin/src/layout/ExternalAdminLayout.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -31,21 +31,23 @@ import { useMemo, useState } from "react"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasAnyExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; const drawerWidth = 248; const navigation = [ - { icon: DashboardOutlined, label: "权限总览", path: "/overview" }, - { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, label: "用户管理", path: "/users" }, - { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, label: "封禁管理", path: "/bans" }, - { capabilities: ["host:list"], icon: BadgeOutlined, label: "主播列表", path: "/organization/hosts" }, - { capabilities: ["agency:list"], icon: ApartmentOutlined, label: "公会列表", path: "/organization/agencies" }, - { capabilities: ["bd:list"], icon: GroupsOutlined, label: "BD 列表", path: "/organization/bds" }, - { capabilities: ["bd-manager:list"], icon: SecurityOutlined, label: "BD Manager", path: "/organization/bd-leaders" }, - { capabilities: ["super-admin:list"], icon: SecurityOutlined, label: "Super Admin", path: "/organization/super-admins" }, - { capabilities: ["room:list", "room:update"], icon: MeetingRoomOutlined, label: "房间管理", path: "/rooms" }, - { capabilities: ["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"], icon: RedeemOutlined, label: "资源与靓号", path: "/grants" }, - { capabilities: ["banner:create"], icon: ImageOutlined, label: "Banner 管理", path: "/banners" }, - { capabilities: ["team:view"], icon: GroupsOutlined, label: "我的团队", path: "/organization/team" } + { icon: DashboardOutlined, labelKey: "nav.overview", path: "/overview" }, + { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, labelKey: "nav.users", path: "/users" }, + { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, labelKey: "nav.bans", path: "/bans" }, + { capabilities: ["host:list"], icon: BadgeOutlined, labelKey: "nav.hosts", path: "/organization/hosts" }, + { capabilities: ["agency:list"], icon: ApartmentOutlined, labelKey: "nav.agencies", path: "/organization/agencies" }, + { capabilities: ["bd:list"], icon: GroupsOutlined, labelKey: "nav.bds", path: "/organization/bds" }, + { capabilities: ["bd-manager:list"], icon: SecurityOutlined, labelKey: "nav.bdManagers", path: "/organization/bd-leaders" }, + { capabilities: ["super-admin:list"], icon: SecurityOutlined, labelKey: "nav.superAdmins", path: "/organization/super-admins" }, + { capabilities: ["room:list", "room:update"], icon: MeetingRoomOutlined, labelKey: "nav.rooms", path: "/rooms" }, + { capabilities: ["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"], icon: RedeemOutlined, labelKey: "nav.grants", path: "/grants" }, + { capabilities: ["banner:create"], icon: ImageOutlined, labelKey: "nav.banners", path: "/banners" }, + { capabilities: ["team:view"], icon: GroupsOutlined, labelKey: "nav.team", path: "/organization/team" } ]; export function ExternalAdminLayout() { @@ -53,6 +55,7 @@ export function ExternalAdminLayout() { const mobile = useMediaQuery(theme.breakpoints.down("md")); const [drawerOpen, setDrawerOpen] = useState(false); const { logout, session } = useExternalAuth(); + const { direction, t } = useExternalI18n(); const location = useLocation(); const navigate = useNavigate(); const visibleNavigation = useMemo( @@ -71,7 +74,7 @@ export function ExternalAdminLayout() { HY - HYApp 外管 + {t("app.shortTitle")} {session?.appName || session?.appCode} @@ -88,7 +91,7 @@ export function ExternalAdminLayout() { onClick={() => setDrawerOpen(false)} > - + ); })} @@ -98,28 +101,39 @@ export function ExternalAdminLayout() { return ( - + - {mobile ? setDrawerOpen(true)}> : null} - {current?.label || "外管后台"} + {mobile ? setDrawerOpen(true)}> : null} + {current ? t(current.labelKey) : t("app.title")} - - {String(session?.account || "管").slice(0, 1).toUpperCase()} + + + {String(session?.account || "A").slice(0, 1).toUpperCase()} {!mobile ? ( - {session?.account} + {session?.account} {session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} ) : null} - - + + - setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} - {drawer} - + setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} + {drawer} + diff --git a/external-admin/src/layout/ExternalAdminLayout.test.jsx b/external-admin/src/layout/ExternalAdminLayout.test.jsx new file mode 100644 index 0000000..525e67b --- /dev/null +++ b/external-admin/src/layout/ExternalAdminLayout.test.jsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; +import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js"; +import { ExternalAdminLayout } from "./ExternalAdminLayout.jsx"; + +const auth = vi.hoisted(() => ({ + logout: vi.fn(), + session: { + account: "fami-manager", + appCode: "fami", + appName: "Fami", + capabilities: ["user:list", "room:list"] + } +})); +const viewport = vi.hoisted(() => ({ mobile: false })); + +vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth })); +vi.mock("@mui/material/useMediaQuery", () => ({ default: () => viewport.mobile })); + +describe("ExternalAdminLayout RTL", () => { + beforeEach(() => { + localStorage.setItem(EXTERNAL_LOCALE_STORAGE_KEY, "ar"); + viewport.mobile = false; + }); + + test("opens the mobile navigation from the right in Arabic", async () => { + const user = userEvent.setup(); + viewport.mobile = true; + renderLayout(); + + await user.click(screen.getByRole("button", { name: "فتح القائمة" })); + + const openTemporaryDrawer = Array.from(document.querySelectorAll(".MuiDrawer-paper")).find((node) => node.closest(".MuiModal-root")); + expect(openTemporaryDrawer).toBeInTheDocument(); + expect(openTemporaryDrawer.parentElement).not.toHaveStyle({ visibility: "hidden" }); + expect(screen.getAllByRole("link", { name: "ملخص الصلاحيات" }).length).toBeGreaterThan(0); + }); + + test("places the desktop navigation drawer on the right for Arabic", () => { + renderLayout(); + + expect(document.documentElement).toHaveAttribute("dir", "rtl"); + expect(document.documentElement).toHaveAttribute("lang", "ar"); + const permanentDrawer = document.querySelectorAll(".MuiDrawer-paper")[0]; + const generatedClass = Array.from(permanentDrawer.classList).find((className) => className.startsWith("css-")); + const drawerRule = Array.from(document.styleSheets) + .flatMap((sheet) => Array.from(sheet.cssRules || [])) + .map((rule) => rule.cssText) + .find((cssText) => cssText.includes(`.${generatedClass}`)); + expect(drawerRule).toContain("right: 0px"); + expect(drawerRule).toContain("border-left:"); + expect(screen.getAllByText("ملخص الصلاحيات").length).toBeGreaterThan(0); + expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية"); + }); +}); + +function renderLayout() { + return render( + + + + }> + overview content
} path="/overview" /> + + + + + ); +} diff --git a/external-admin/src/main.jsx b/external-admin/src/main.jsx index 353822a..7cad114 100644 --- a/external-admin/src/main.jsx +++ b/external-admin/src/main.jsx @@ -1,27 +1,47 @@ +import { CacheProvider } from "@emotion/react"; import CssBaseline from "@mui/material/CssBaseline"; -import { ThemeProvider } from "@mui/material/styles"; -import React from "react"; +import { createTheme, ThemeProvider } from "@mui/material/styles"; +import React, { useMemo } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { theme } from "@/theme.js"; import { ExternalAdminApp } from "./ExternalAdminApp.jsx"; import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx"; +import { ExternalI18nProvider, useExternalI18n } from "./i18n/ExternalI18nProvider.jsx"; +import { externalEmotionCache } from "./i18n/emotionCache.js"; import "@/styles/tokens.css"; import "@/styles/shared-ui.css"; import "./styles/index.css"; createRoot(document.getElementById("external-admin-root")).render( - - - - - - - - - - + + + ); + +function ExternalThemeRoot() { + const { direction } = useExternalI18n(); + // A direction-aware MUI theme keeps inputs, menus and dialog controls consistent with the Arabic document direction. + const externalTheme = useMemo(() => createTheme(theme, { + direction, + typography: { fontFamily: 'Inter, "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif' } + }), [direction]); + + return ( + + + + + + + + + + + + + ); +} diff --git a/external-admin/src/pages/BannersPage.jsx b/external-admin/src/pages/BannersPage.jsx index 5c0144d..29ec076 100644 --- a/external-admin/src/pages/BannersPage.jsx +++ b/external-admin/src/pages/BannersPage.jsx @@ -7,40 +7,43 @@ import { useCallback, useMemo, useState } from "react"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { createBanner, listBanners } from "../api/business.js"; import { BannerCreateDialog } from "../components/BannerCreateDialog.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function BannersPage() { const { showToast } = useToast(); + const { t } = useExternalI18n(); const [open, setOpen] = useState(false); const [page, setPage] = useState(1); const [submitting, setSubmitting] = useState(false); const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]); const { data, error, loading, reload } = usePagedResource(load); const columns = useMemo(() => [ - { key: "banner", label: "Banner", render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, - { key: "type", label: "类型", render: (item) => item.bannerType || "-" }, - { key: "platform", label: "平台", render: (item) => item.platform || "all" }, - { key: "sort", label: "排序", render: (item) => item.sortOrder }, - { key: "status", label: "状态", render: (item) => } - ], []); + { key: "banner", label: t("banners.banner"), render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, + { key: "type", label: t("banners.type"), render: (item) => item.bannerType || "-" }, + { key: "platform", label: t("banners.platform"), render: (item) => item.platform || t("common.all") }, + { key: "sort", label: t("banners.sort"), render: (item) => item.sortOrder }, + { key: "status", label: t("common.status"), render: (item) => } + ], [t]); const submit = async (form) => { setSubmitting(true); try { await createBanner(form); - showToast("Banner 已创建", "success"); + showToast(t("banners.created"), "success"); setOpen(false); reload(); } catch (requestError) { - showToast(requestError.message || "Banner 创建失败", "error"); + showToast(translateExternalError(requestError, t, "banners.createFailed"), "error"); } finally { setSubmitting(false); } }; return ( - setOpen(true)} startIcon={} variant="contained">创建 Banner} title="Banner 管理"> + setOpen(true)} startIcon={} variant="contained">{t("banners.create")}} title={t("banners.title")}> {!loading && !error ? <> item.id} /> : null} setOpen(false)} onSubmit={submit} /> diff --git a/external-admin/src/pages/BansPage.jsx b/external-admin/src/pages/BansPage.jsx index 773edfb..1efaf84 100644 --- a/external-admin/src/pages/BansPage.jsx +++ b/external-admin/src/pages/BansPage.jsx @@ -10,6 +10,8 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { listBannedUsers, unbanUser } from "../api/business.js"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { ConfirmDialog } from "../shared/ConfirmDialog.jsx"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; @@ -17,6 +19,7 @@ import { usePagedResource } from "../shared/usePagedResource.js"; export default function BansPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [keyword, setKeyword] = useState(""); const [query, setQuery] = useState({ keyword: "", page: 1 }); const [target, setTarget] = useState(null); @@ -29,33 +32,33 @@ export default function BansPage() { setSubmitting(true); try { await unbanUser(target.id, { reason: "external-admin manual unban" }); - showToast("用户已解除封禁", "success"); + showToast(t("bans.unbanned"), "success"); setTarget(null); reload(); } catch (requestError) { - showToast(requestError.message || "解除封禁失败", "error"); + showToast(translateExternalError(requestError, t, "bans.unbanFailed"), "error"); } finally { setSubmitting(false); } }; const columns = useMemo(() => [ - { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} }, - { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" }, - { key: "reason", label: "封禁原因", render: (user) => user.reason || "-" }, - { key: "status", label: "状态", render: () => }, - { key: "actions", label: "操作", render: (user) => canUnban ? : "-" } - ], [canUnban]); + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, + { key: "reason", label: t("bans.reason"), render: (user) => user.reason || "-" }, + { key: "status", label: t("common.status"), render: () => }, + { key: "actions", label: t("common.actions"), render: (user) => canUnban ? : "-" } + ], [canUnban, t]); return ( - + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> - setKeyword(event.target.value)} /> - + setKeyword(event.target.value)} /> + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} - setTarget(null)} onConfirm={handleUnban} /> + setTarget(null)} onConfirm={handleUnban} /> ); } diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx index 36d1eb9..ca8c517 100644 --- a/external-admin/src/pages/ChangePasswordPage.jsx +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -11,9 +11,13 @@ import Typography from "@mui/material/Typography"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export default function ChangePasswordPage() { const { changePassword, logout, session } = useExternalAuth(); + const { t } = useExternalI18n(); const [form, setForm] = useState({ confirmPassword: "", newPassword: "", oldPassword: "" }); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -22,15 +26,15 @@ export default function ChangePasswordPage() { const submit = async (event) => { event.preventDefault(); if (!form.newPassword.trim()) { - setError("新密码不能为空或全为空白字符"); + setError(t("auth.passwordWhitespace")); return; } if (form.newPassword.length < 8) { - setError("新密码至少 8 个字符"); + setError(t("auth.passwordMinError")); return; } if (form.newPassword !== form.confirmPassword) { - setError("两次输入的新密码不一致"); + setError(t("auth.passwordMismatch")); return; } setSubmitting(true); @@ -39,7 +43,7 @@ export default function ChangePasswordPage() { await changePassword({ newPassword: form.newPassword, oldPassword: form.oldPassword }); navigate("/overview", { replace: true }); } catch (requestError) { - setError(requestError.message || "密码修改失败"); + setError(translateExternalError(requestError, t, "auth.passwordChangedFailed")); } finally { setSubmitting(false); } @@ -54,13 +58,14 @@ export default function ChangePasswordPage() { - 修改外管密码{session?.passwordChangeRequired ? "首次登录必须修改密码" : session?.account} + + {t("auth.changePassword")}{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} {error ? {error} : null} - setForm({ ...form, oldPassword: event.target.value })} /> - setForm({ ...form, newPassword: event.target.value })} /> - setForm({ ...form, confirmPassword: event.target.value })} /> - + setForm({ ...form, oldPassword: 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 a724534..51351d7 100644 --- a/external-admin/src/pages/ChangePasswordPage.test.jsx +++ b/external-admin/src/pages/ChangePasswordPage.test.jsx @@ -26,9 +26,9 @@ describe("ChangePasswordPage", () => { fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: "old-password" } }); fireEvent.change(newPassword, { target: { value: " " } }); fireEvent.change(confirmPassword, { target: { value: " " } }); - await user.click(screen.getByRole("button", { name: "保存密码" })); + await user.click(screen.getByRole("button", { name: "Save password" })); - expect(screen.getByText("新密码不能为空或全为空白字符")).toBeInTheDocument(); + expect(screen.getByText("The new password cannot be empty or contain only whitespace")).toBeInTheDocument(); expect(auth.changePassword).not.toHaveBeenCalled(); }); @@ -40,7 +40,7 @@ describe("ChangePasswordPage", () => { fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: " current-password " } }); fireEvent.change(newPassword, { target: { value: " pass word " } }); fireEvent.change(confirmPassword, { target: { value: " pass word " } }); - await user.click(screen.getByRole("button", { name: "保存密码" })); + await user.click(screen.getByRole("button", { name: "Save password" })); expect(auth.changePassword).toHaveBeenCalledWith({ newPassword: " pass word ", oldPassword: " current-password " }); }); diff --git a/external-admin/src/pages/GrantsPage.jsx b/external-admin/src/pages/GrantsPage.jsx index 4fe3522..9f33fbd 100644 --- a/external-admin/src/pages/GrantsPage.jsx +++ b/external-admin/src/pages/GrantsPage.jsx @@ -11,12 +11,15 @@ import { grantPrettyId, grantResource, listResourceGrants, listResources, lookup import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { PrettyIdGrantDialog, ResourceGrantDialog } from "../components/GrantDialogs.jsx"; import { hasAnyExternalCapability, hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { externalUiError, translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function GrantsPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [dialog, setDialog] = useState(""); const [page, setPage] = useState(1); const [submitting, setSubmitting] = useState(false); @@ -28,15 +31,15 @@ export default function GrantsPage() { const resourcesState = usePagedResource(loadResources); const grantsState = usePagedResource(loadGrants); - const execute = async (request, successMessage) => { + const execute = async (request, successKey) => { setSubmitting(true); try { await request(); - showToast(successMessage, "success"); + showToast(t(successKey), "success"); setDialog(""); grantsState.reload(); } catch (requestError) { - showToast(requestError.message || "发放失败", "error"); + showToast(translateExternalError(requestError, t, "grants.failed"), "error"); } finally { setSubmitting(false); } @@ -45,7 +48,7 @@ export default function GrantsPage() { const resolveTarget = async (input) => { const target = await lookupGrantTarget(input); if (!target.id) { - throw new Error("未找到当前 App 内的目标用户"); + throw externalUiError("errors.grantTarget"); } return target; }; @@ -60,7 +63,7 @@ export default function GrantsPage() { resourceId: Number(form.resourceId), targetUserId: target.id }); - }, "特权道具已发放"); + }, "grants.resourceGranted"); const submitPrettyId = (form) => execute(async () => { const target = await resolveTarget(form.target); @@ -70,27 +73,27 @@ export default function GrantsPage() { reason: form.reason.trim(), target_user_id: target.id }); - }, "靓号已下发"); + }, "grants.prettyIdGranted"); const columns = useMemo(() => [ - { key: "target", label: "目标用户", render: (item) => String(item.targetUserId || item.target_user_id || item.userId || item.user_id || "-") }, - { key: "resource", label: "资源", render: (item) => String(item.resourceName || item.resource_name || item.resourceId || item.resource_id || item.subject || "-") }, - { key: "quantity", label: "数量", render: (item) => String(item.quantity ?? item.amount ?? "-") }, - { key: "status", label: "状态", render: (item) => }, - { key: "reason", label: "原因", render: (item) => String(item.reason || "-") } - ], []); + { key: "target", label: t("grants.targetUser"), render: (item) => String(item.targetUserId || item.target_user_id || item.userId || item.user_id || "-") }, + { key: "resource", label: t("grants.resource"), render: (item) => String(item.resourceName || item.resource_name || item.resourceId || item.resource_id || item.subject || "-") }, + { key: "quantity", label: t("grants.quantity"), render: (item) => String(item.quantity ?? item.amount ?? "-") }, + { key: "status", label: t("common.status"), render: (item) => }, + { key: "reason", label: t("common.reason"), render: (item) => String(item.reason || "-") } + ], [t]); return ( {canGrant ? : null}{canGrantPrettyId ? : null}} - title="资源与靓号" + actions={<>{canGrant ? : null}{canGrantPrettyId ? : null}} + title={t("grants.title")} > {canGrant ? ( - 可发放资源 + {t("grants.availableResources")} - {resourcesState.data.items.slice(0, 12).map((resource) => {resource.name})} - {!resourcesState.loading && !resourcesState.data.items.length ? 当前无数据 : null} + {resourcesState.data.items.slice(0, 12).map((resource) => R{resource.name})} + {!resourcesState.loading && !resourcesState.data.items.length ? {t("common.noData")} : null} ) : null} diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index ba44b37..b58ff84 100644 --- a/external-admin/src/pages/LoginPage.jsx +++ b/external-admin/src/pages/LoginPage.jsx @@ -8,53 +8,28 @@ import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import CircularProgress from "@mui/material/CircularProgress"; -import FormControl from "@mui/material/FormControl"; import IconButton from "@mui/material/IconButton"; import InputAdornment from "@mui/material/InputAdornment"; -import InputLabel from "@mui/material/InputLabel"; -import MenuItem from "@mui/material/MenuItem"; -import Select from "@mui/material/Select"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; -import { useCallback, useEffect, useState } from "react"; -import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; -import { listExternalApps } from "../api/auth.js"; +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export default function LoginPage() { const { initializing, login, session } = useExternalAuth(); - const [apps, setApps] = useState([]); - const [appsError, setAppsError] = useState(""); - const [appsLoading, setAppsLoading] = useState(true); - const [form, setForm] = useState({ account: "", appCode: "", password: "" }); + const { t } = useExternalI18n(); + const [form, setForm] = useState({ account: "", password: "" }); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [showPassword, setShowPassword] = useState(false); - const [searchParams] = useSearchParams(); const navigate = useNavigate(); const location = useLocation(); - const loadApps = useCallback(async () => { - setAppsLoading(true); - setAppsError(""); - try { - const items = await listExternalApps(); - setApps(items); - const requestedApp = searchParams.get("app") || ""; - const preferredApp = items.find((item) => item.appCode === requestedApp)?.appCode || items[0]?.appCode || requestedApp; - setForm((current) => ({ ...current, appCode: current.appCode || preferredApp })); - } catch (requestError) { - setAppsError(requestError.message || "App 列表加载失败"); - } finally { - setAppsLoading(false); - } - }, [searchParams]); - - useEffect(() => { - loadApps(); - }, [loadApps]); - useEffect(() => { if (!initializing && session) { const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview"; @@ -64,8 +39,8 @@ export default function LoginPage() { const handleSubmit = async (event) => { event.preventDefault(); - if (!form.appCode || !form.account.trim() || !form.password) { - setError("请选择 App 并填写账号、密码"); + if (!form.account.trim() || !form.password) { + setError(t("auth.fillAccountPassword")); return; } setSubmitting(true); @@ -74,7 +49,8 @@ export default function LoginPage() { const nextSession = await login(form); navigate(nextSession.passwordChangeRequired ? "/change-password" : "/overview", { replace: true }); } catch (requestError) { - setError(requestError.message || "登录失败"); + // 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")); } finally { setSubmitting(false); } @@ -84,36 +60,26 @@ export default function LoginPage() { - + + - 外管后台登录 + {t("app.title")} {error ? {error} : null} - {appsError ? 重试} severity="error">{appsError} : null} - {apps.length || appsLoading ? ( - - App - - - ) : ( - setForm({ ...form, appCode: event.target.value.trim() })} /> - )} - setForm({ ...form, account: event.target.value })} /> + setForm({ ...form, account: event.target.value })} /> setForm({ ...form, password: event.target.value })} - slotProps={{ input: { endAdornment: setShowPassword((current) => !current)}>{showPassword ? : } } }} + slotProps={{ input: { endAdornment: setShowPassword((current) => !current)}>{showPassword ? : } } }} /> - diff --git a/external-admin/src/pages/LoginPage.test.jsx b/external-admin/src/pages/LoginPage.test.jsx new file mode 100644 index 0000000..bac99f1 --- /dev/null +++ b/external-admin/src/pages/LoginPage.test.jsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; +import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js"; +import LoginPage from "./LoginPage.jsx"; + +const auth = vi.hoisted(() => ({ initializing: false, login: vi.fn(), session: null })); + +vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth })); + +describe("LoginPage", () => { + beforeEach(() => { + localStorage.removeItem(EXTERNAL_LOCALE_STORAGE_KEY); + auth.login.mockReset().mockResolvedValue({ appCode: "fami", passwordChangeRequired: false }); + }); + + test("defaults to English, has no App field and submits only account and password", async () => { + const user = userEvent.setup(); + const { container } = renderLogin(); + + expect(screen.getByRole("heading", { name: "HYApp External Admin" })).toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument(); + await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager"); + await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + expect(auth.login).toHaveBeenCalledWith({ account: "fami-manager", password: "secret-password" }); + }); + + test("localizes a numeric login 401 without exposing the backend message", async () => { + const user = userEvent.setup(); + auth.login.mockRejectedValueOnce({ code: 40100, message: "账号已禁用", status: 401 }); + const { container } = renderLogin(); + + await user.type(screen.getByRole("textbox", { name: "External admin account" }), "unknown-account"); + await user.type(container.querySelector('input[autocomplete="current-password"]'), "bad-password"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + expect(await screen.findByText("Incorrect account or password")).toBeInTheDocument(); + expect(screen.queryByText("账号已禁用")).not.toBeInTheDocument(); + expect(screen.queryByText("Your session has expired. Sign in again.")).not.toBeInTheDocument(); + }); +}); + +function renderLogin() { + return render(); +} diff --git a/external-admin/src/pages/OrganizationPage.jsx b/external-admin/src/pages/OrganizationPage.jsx index 57eb724..6f03ee5 100644 --- a/external-admin/src/pages/OrganizationPage.jsx +++ b/external-admin/src/pages/OrganizationPage.jsx @@ -10,58 +10,60 @@ import { useParams } from "react-router-dom"; import { listOrganizations } from "../api/business.js"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; const organizationKinds = { - agencies: { capability: "agency:list", title: "公会列表" }, - bds: { capability: "bd:list", title: "BD 列表" }, - "bd-leaders": { capability: "bd-manager:list", title: "BD Manager 列表" }, - hosts: { capability: "host:list", title: "主播列表" }, - managers: { capability: "bd-manager:list", title: "管理员列表" }, - "super-admins": { capability: "super-admin:list", title: "Super Admin 列表" }, - team: { capability: "team:view", title: "我的团队" } + agencies: { capability: "agency:list", titleKey: "organization.agencies" }, + bds: { capability: "bd:list", titleKey: "organization.bds" }, + "bd-leaders": { capability: "bd-manager:list", titleKey: "organization.bdManagers" }, + hosts: { capability: "host:list", titleKey: "organization.hosts" }, + managers: { capability: "bd-manager:list", titleKey: "organization.managers" }, + "super-admins": { capability: "super-admin:list", titleKey: "organization.superAdmins" }, + team: { capability: "team:view", titleKey: "organization.team" } }; export default function OrganizationPage() { const { kind = "hosts" } = useParams(); const config = organizationKinds[kind]; const { session } = useExternalAuth(); + const { t } = useExternalI18n(); const [keyword, setKeyword] = useState(""); const [query, setQuery] = useState({ keyword: "", page: 1 }); const allowed = Boolean(config && hasExternalCapability(session, config.capability)); const load = useCallback(() => allowed ? listOrganizations(kind, { keyword: query.keyword, page: query.page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [allowed, kind, query]); const { data, error, loading, reload } = usePagedResource(load); const columns = useMemo(() => kind === "team" ? [ - { key: "agency", label: "公会", render: (item) => 公会 {item.id || "-"}Owner {item.userId || "-"} }, - { key: "parent", label: "上级 BD", render: (item) => item.parentBdUserId || "-" }, - { key: "status", label: "状态", render: (item) => } + { key: "agency", label: t("organization.agency"), render: (item) => {t("organization.agencyReference", { id: item.id || "-" })}{t("organization.owner")} {item.userId || "-"} }, + { key: "parent", label: t("organization.parentBd"), render: (item) => item.parentBdUserId || "-" }, + { key: "status", label: t("common.status"), render: (item) => } ] : [ - { key: "identity", label: "成员", render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / 用户 ${item.userId}` : ""} }, - { key: "prettyId", label: "短 ID(靓号)", render: (item) => item.prettyId || "-" }, - { key: "country", label: "国家/地区", render: (item) => item.country || "-" }, - { key: "status", label: "状态", render: (item) => } - ], [kind]); + { key: "identity", label: t("organization.member"), render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""} }, + { key: "prettyId", label: t("users.prettyId"), render: (item) => item.prettyId || "-" }, + { key: "country", label: t("organization.country"), render: (item) => item.country || "-" }, + { key: "status", label: t("common.status"), render: (item) => } + ], [kind, t]); if (!config || !allowed) { - return 当前外管账号没有访问该组织列表的权限。; + return {t("organization.noPermission")}; } return ( - + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> - setKeyword(event.target.value)} /> - + setKeyword(event.target.value)} /> + {kind === "team" && !loading && !error ? ( - BD Leader:{data.totalBdLeaders || 0} - BD:{data.totalBds || 0} - 公会:{data.total || data.items.length} + {t("organization.bdLeaderCount", { count: data.totalBdLeaders || 0 })} + {t("organization.bdCount", { count: data.totalBds || 0 })} + {t("organization.agencyCount", { count: data.total || data.items.length })} - {data.truncated ? 团队规模超过接口上限,当前结果不完整 : null} + {data.truncated ? {t("organization.truncated")} : null} ) : null} {!loading && !error ? <> item.id || index} /> setQuery({ ...query, page })} /> : null} diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx index c2bfc3a..965b803 100644 --- a/external-admin/src/pages/OverviewPage.jsx +++ b/external-admin/src/pages/OverviewPage.jsx @@ -10,19 +10,21 @@ import Typography from "@mui/material/Typography"; import { Link } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { ExternalPage } from "../shared/PageComponents.jsx"; export default function OverviewPage() { const { session } = useExternalAuth(); + const { t } = useExternalI18n(); return ( - + - 当前 App{session.appName || session.appCode}({session.appCode}) - 外管账号{session.account} - 已开通权限{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} + {t("overview.currentApp")}{session.appName || session.appCode} ({session.appCode}) + {t("overview.account")}{session.account} + {t("overview.permissions")}{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} @@ -33,8 +35,8 @@ export default function OverviewPage() { {enabled ? : } - {item.label} - + {t(item.labelKey)} + ); diff --git a/external-admin/src/pages/OverviewPage.test.jsx b/external-admin/src/pages/OverviewPage.test.jsx index 90540d9..07827d5 100644 --- a/external-admin/src/pages/OverviewPage.test.jsx +++ b/external-admin/src/pages/OverviewPage.test.jsx @@ -22,10 +22,10 @@ describe("OverviewPage", () => { render(); expect(EXTERNAL_CAPABILITIES).toHaveLength(20); - expect(screen.getAllByText("已开通")).toHaveLength(1); - expect(screen.getAllByText("未开通")).toHaveLength(19); - expect(screen.getByText("用户列表").closest("a")).toHaveAttribute("href", "/users"); - expect(screen.getByText("用户信息编辑").closest("a")).toBeNull(); - expect(screen.getByText("我的团队")).toBeInTheDocument(); + expect(screen.getAllByText("Enabled")).toHaveLength(1); + expect(screen.getAllByText("Not enabled")).toHaveLength(19); + expect(screen.getByText("User list").closest("a")).toHaveAttribute("href", "/users"); + expect(screen.getByText("Edit user information").closest("a")).toBeNull(); + expect(screen.getByText("My team")).toBeInTheDocument(); }); }); diff --git a/external-admin/src/pages/RoomsPage.jsx b/external-admin/src/pages/RoomsPage.jsx index 706fecd..63c883e 100644 --- a/external-admin/src/pages/RoomsPage.jsx +++ b/external-admin/src/pages/RoomsPage.jsx @@ -11,12 +11,15 @@ import { listRooms, updateRoom } from "../api/business.js"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { RoomEditDialog } from "../components/RoomEditDialog.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { externalUiError, translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function RoomsPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [keyword, setKeyword] = useState(""); const [query, setQuery] = useState({ keyword: "", page: 1 }); const [room, setRoom] = useState(null); @@ -26,12 +29,12 @@ export default function RoomsPage() { const canEdit = hasExternalCapability(session, "room:update"); const columns = useMemo(() => [ - { key: "room", label: "房间", render: (item) => {item.title || "-"}ID {item.id} }, - { key: "owner", label: "房主", render: (item) => item.ownerName || "-" }, - { key: "region", label: "可见区域", render: (item) => item.visibleRegionId || "全部" }, - { key: "status", label: "状态", render: (item) => }, - { key: "actions", label: "操作", render: (item) => canEdit ? : "-" } - ], [canEdit]); + { key: "room", label: t("rooms.room"), render: (item) => R{item.title || "-"}ID {item.id} }, + { key: "owner", label: t("rooms.owner"), render: (item) => item.ownerName || "-" }, + { key: "region", label: t("rooms.visibleRegion"), render: (item) => item.visibleRegionId || t("rooms.allRegions") }, + { key: "status", label: t("common.status"), render: (item) => }, + { key: "actions", label: t("common.actions"), render: (item) => canEdit ? : "-" } + ], [canEdit, t]); const submit = async (form) => { setSubmitting(true); @@ -39,22 +42,22 @@ export default function RoomsPage() { const regionText = String(form.visibleRegionId || "").trim(); const visibleRegionId = regionText ? Number(regionText) : 0; if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) { - throw new Error("可见区域 ID 必须是安全的正整数"); + throw externalUiError("errors.regionId"); } await updateRoom(room.id, { ...form, visibleRegionId }); - showToast("房间信息已更新", "success"); + showToast(t("rooms.updated"), "success"); setRoom(null); reload(); } catch (requestError) { - showToast(requestError.message || "房间更新失败", "error"); + showToast(translateExternalError(requestError, t, "rooms.updateFailed"), "error"); } finally { setSubmitting(false); } }; return ( - - { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> setKeyword(event.target.value)} /> + + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> setKeyword(event.target.value)} /> {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} setRoom(null)} onSubmit={submit} /> diff --git a/external-admin/src/pages/UsersPage.jsx b/external-admin/src/pages/UsersPage.jsx index 72db4b7..020c76d 100644 --- a/external-admin/src/pages/UsersPage.jsx +++ b/external-admin/src/pages/UsersPage.jsx @@ -14,12 +14,15 @@ import { banUser, grantUserVip, listUsers, updateUser, updateUserLevels } from " import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { UserBanDialog, UserEditDialog, UserLevelDialog } from "../components/UserDialogs.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function UsersPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [filters, setFilters] = useState({ keyword: "", status: "" }); const [query, setQuery] = useState({ keyword: "", page: 1, status: "" }); const [dialog, setDialog] = useState({ type: "", user: null }); @@ -31,47 +34,52 @@ export default function UsersPage() { const canGrantLevel = hasExternalCapability(session, "user-level:grant"); const closeDialog = () => setDialog({ type: "", user: null }); - const execute = async (request, successMessage) => { + const execute = async (request, successKey) => { setSubmitting(true); try { await request(); - showToast(successMessage, "success"); + showToast(t(successKey), "success"); closeDialog(); reload(); } catch (requestError) { - showToast(requestError.message || "用户操作失败", "error"); + showToast(translateExternalError(requestError, t, "users.operationFailed"), "error"); } finally { setSubmitting(false); } }; const columns = useMemo(() => [ - { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} }, - { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" }, - { key: "country", label: "国家/性别", render: (user) => `${user.country || "-"} / ${user.gender || "-"}` }, - { key: "level", label: "等级", render: (user) => `财富 ${user.wealthLevel} / 魅力 ${user.charmLevel} / VIP ${user.vipLevel}` }, - { key: "status", label: "状态", render: (user) => }, - { key: "actions", label: "操作", render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } - ], [canBan, canEdit, canGrantLevel]); + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, + { key: "country", label: t("users.countryGender"), render: (user) => `${user.country || "-"} / ${translatedGender(user.gender, t)}` }, + { key: "level", label: t("users.level"), render: (user) => t("users.levelSummary", { wealth: user.wealthLevel, charm: user.charmLevel, vip: user.vipLevel }) }, + { key: "status", label: t("common.status"), render: (user) => }, + { key: "actions", label: t("common.actions"), render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } + ], [canBan, canEdit, canGrantLevel, t]); return ( - + { event.preventDefault(); setQuery({ ...filters, page: 1 }); }}> - setFilters({ ...filters, keyword: event.target.value })} /> - setFilters({ ...filters, status: event.target.value })}>全部状态正常已封禁 - + setFilters({ ...filters, keyword: event.target.value })} /> + setFilters({ ...filters, status: event.target.value })}>{t("common.allStatuses")}{t("common.normal")}{t("common.banned")} + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} - execute(() => updateUser(dialog.user.id, form), "用户信息已更新")} /> - execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "用户已封禁")} /> + execute(() => updateUser(dialog.user.id, form), "users.updated")} /> + execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "users.banned")} /> execute(() => form.track === "vip" ? grantUserVip(dialog.user.id, { commandId: createLevelCommandId(), durationMs: Number(form.days) * 86400000, level: Number(form.level), reason: form.reason.trim() }) - : updateUserLevels(dialog.user.id, { adjustments: [{ duration_days: Number(form.days), level: Number(form.level), track: form.track }], reason: form.reason.trim() }), "用户等级已下发")} /> + : updateUserLevels(dialog.user.id, { adjustments: [{ duration_days: Number(form.days), level: Number(form.level), track: form.track }], reason: form.reason.trim() }), "users.levelGranted")} /> ); } +function translatedGender(gender, t) { + const key = { female: "users.genderFemale", male: "users.genderMale", unknown: "users.genderUnknown" }[String(gender || "").toLowerCase()]; + return key ? t(key) : "-"; +} + function createLevelCommandId() { return `external-level-grant-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; } diff --git a/external-admin/src/shared/ConfirmDialog.jsx b/external-admin/src/shared/ConfirmDialog.jsx index e947974..72be4ee 100644 --- a/external-admin/src/shared/ConfirmDialog.jsx +++ b/external-admin/src/shared/ConfirmDialog.jsx @@ -4,13 +4,15 @@ import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function ConfirmDialog({ confirmColor = "primary", content, loading, onClose, onConfirm, open, title }) { + const { t } = useExternalI18n(); return ( {title} {content} - + ); } diff --git a/external-admin/src/shared/ExternalImageUploadField.jsx b/external-admin/src/shared/ExternalImageUploadField.jsx index 4851533..6d6f653 100644 --- a/external-admin/src/shared/ExternalImageUploadField.jsx +++ b/external-admin/src/shared/ExternalImageUploadField.jsx @@ -7,8 +7,11 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { useRef, useState } from "react"; import { uploadExternalImage } from "../api/business.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export function ExternalImageUploadField({ disabled, label, onChange, value }) { + const { t } = useExternalI18n(); const inputRef = useRef(null); const [error, setError] = useState(""); const [uploading, setUploading] = useState(false); @@ -20,11 +23,11 @@ export function ExternalImageUploadField({ disabled, label, onChange, value }) { return; } if (!file.type.startsWith("image/")) { - setError("请选择图片文件"); + setError(t("upload.imageOnly")); return; } if (file.size > 10 * 1024 * 1024) { - setError("图片不能超过 10MB"); + setError(t("upload.sizeLimit")); return; } @@ -33,11 +36,13 @@ export function ExternalImageUploadField({ disabled, label, onChange, value }) { try { const url = await uploadExternalImage(file); if (!url) { - throw new Error("上传结果缺少图片地址"); + const missingUrlError = new Error("missing upload URL"); + missingUrlError.translationKey = "upload.missingUrl"; + throw missingUrlError; } onChange(url); } catch (uploadError) { - setError(uploadError.message || "图片上传失败"); + setError(translateExternalError(uploadError, t, "upload.failed")); } finally { setUploading(false); } @@ -47,14 +52,14 @@ export function ExternalImageUploadField({ disabled, label, onChange, value }) { {label} - {value ? {`${label}预览`} : 暂无图片} + {value ? {t("upload.preview", : {t("upload.empty")}} diff --git a/external-admin/src/shared/PageComponents.jsx b/external-admin/src/shared/PageComponents.jsx index 86da941..fa1a04e 100644 --- a/external-admin/src/shared/PageComponents.jsx +++ b/external-admin/src/shared/PageComponents.jsx @@ -15,6 +15,8 @@ import TableRow from "@mui/material/TableRow"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export function ExternalPage({ actions, children, title }) { return ( @@ -31,9 +33,10 @@ export function ExternalPage({ actions, children, title }) { } export function ExternalPageState({ error, loading, onRetry }) { + const { t } = useExternalI18n(); if (loading) { return ( - + @@ -42,20 +45,21 @@ export function ExternalPageState({ error, loading, onRetry }) { if (error) { return ( - {error} - + {translateExternalError(error, t, "errors.loadFailed")} + ); } return null; } -export function ResponsiveDataList({ columns, emptyText = "当前无数据", items, rowKey }) { +export function ResponsiveDataList({ columns, emptyText, items, rowKey }) { const theme = useTheme(); const mobile = useMediaQuery(theme.breakpoints.down("sm")); + const { t } = useExternalI18n(); if (!items.length) { - return {emptyText}; + return {emptyText || t("common.noData")}; } if (mobile) { @@ -96,21 +100,25 @@ export function ResponsiveDataList({ columns, emptyText = "当前无数据", ite } export function StatusChip({ status }) { + const { t } = useExternalI18n(); const normalized = String(status || "-").toLowerCase(); const enabled = ["active", "enabled", "running", "normal", "unbanned"].includes(normalized); const dangerous = ["banned", "disabled", "failed", "blocked"].includes(normalized); - return ; + const statusKey = `status.${normalized}`; + const translated = t(statusKey); + return ; } export function PagePagination({ page, pageSize, total, onChange }) { + const { t } = useExternalI18n(); const totalPages = Math.max(1, Math.ceil(total / pageSize)); return ( - 共 {total} 条 + {t("pagination.total", { count: total })} - + {page} / {totalPages} - + ); diff --git a/external-admin/src/shared/usePagedResource.js b/external-admin/src/shared/usePagedResource.js index af442f8..df74d65 100644 --- a/external-admin/src/shared/usePagedResource.js +++ b/external-admin/src/shared/usePagedResource.js @@ -4,7 +4,7 @@ const EMPTY_PAGE = { items: [], page: 1, pageSize: 20, total: 0 }; export function usePagedResource(loader) { const [data, setData] = useState(EMPTY_PAGE); - const [error, setError] = useState(""); + const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [version, setVersion] = useState(0); @@ -13,7 +13,7 @@ export function usePagedResource(loader) { useEffect(() => { let active = true; setLoading(true); - setError(""); + setError(null); // Start inside the promise chain so synchronous validation errors use the same visible error state as network failures. Promise.resolve() .then(loader) @@ -24,7 +24,8 @@ export function usePagedResource(loader) { }) .catch((requestError) => { if (active) { - setError(requestError.message || "数据加载失败"); + // Keep the structured error so the rendering locale, not the backend message language, determines what users see. + setError(requestError); } }) .finally(() => { diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index 2969394..df45aae 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -31,6 +31,10 @@ body { padding: 32px; } +.external-language-switcher { + min-width: 140px !important; +} + .external-login-avatar, .external-logo-mark { background: var(--brand-gradient) !important; @@ -285,6 +289,7 @@ body { .external-capability-card { min-height: 128px; } .external-filter-bar .MuiTextField-root { width: 100%; } .external-image-field { align-items: flex-start; flex-direction: column; } + .external-header .external-language-switcher { min-width: 94px !important; max-width: 112px; } } @media (prefers-reduced-motion: reduce) { diff --git a/package.json b/package.json index ffdb0c5..0236793 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,19 @@ "preview": "vite preview --host 0.0.0.0" }, "dependencies": { + "@emotion/cache": "11.14.0", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", "@mui/icons-material": "9.0.0", "@mui/material": "9.0.0", + "@mui/stylis-plugin-rtl": "9.1.1", "@tanstack/react-query": "^5.100.6", "echarts": "6.0.0", "libpag": "4.5.52", "react": "19.2.5", "react-dom": "19.2.5", "react-router-dom": "^7.14.2", + "stylis": "4.4.0", "svgaplayerweb": "2.3.2", "zod": "^4.4.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22758b6..3385623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@emotion/cache': + specifier: 11.14.0 + version: 11.14.0 '@emotion/react': specifier: 11.14.0 version: 11.14.0(@types/react@19.2.14)(react@19.2.5) @@ -20,6 +23,9 @@ importers: '@mui/material': specifier: 9.0.0 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) '@tanstack/react-query': specifier: ^5.100.6 version: 5.100.6(react@19.2.5) @@ -38,6 +44,9 @@ importers: react-router-dom: 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 svgaplayerweb: specifier: 2.3.2 version: 2.3.2 @@ -594,6 +603,12 @@ packages: '@emotion/styled': optional: true + '@mui/stylis-plugin-rtl@9.1.1': + resolution: {integrity: sha512-UeWnUOeZCCy8knYIRULs5/s1hzWVtuIKKDYqtr2lrYBCliAENYQJ8yFUtdqIXHQUrTbAWkmHDEFQIz5nEba8TQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + stylis: 4.x + '@mui/system@9.0.0': resolution: {integrity: sha512-YnC5Zg6j04IxiLc/boAKs0464jfZlLFVa7mf5E8lF0XOtZVUvG6R6gJK50lgUYdaaLdyLfxF6xR7LaPuEpeT/g==} engines: {node: '>=14.0.0'} @@ -1036,6 +1051,10 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssjanus@2.3.1: + resolution: {integrity: sha512-gWZQ/S0tthU2KCc55C5zbjxQN0XPK1sT0qufCqwCNUauGIsPDOfdBMBWosP+Kd4wf2A2Nxm/z6JAP6yNGnS7QQ==} + engines: {node: '>=10.0.0'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1857,6 +1876,9 @@ 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'} @@ -2569,6 +2591,12 @@ 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)': + dependencies: + '@babel/runtime': 7.29.2 + cssjanus: 2.3.1 + stylis: 4.4.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: '@babel/runtime': 7.29.2 @@ -2964,6 +2992,8 @@ snapshots: css.escape@1.5.1: {} + cssjanus@2.3.1: {} + csstype@3.2.3: {} data-urls@7.0.0: @@ -3814,6 +3844,8 @@ 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/shared/ui/AppJumpConfigField.jsx b/src/shared/ui/AppJumpConfigField.jsx index 5a240df..64a3121 100644 --- a/src/shared/ui/AppJumpConfigField.jsx +++ b/src/shared/ui/AppJumpConfigField.jsx @@ -19,6 +19,7 @@ export function AppJumpConfigField({ className = "", disabled = false, h5Label = "H5 链接", + messages = {}, onChange, paramValue, required = false, @@ -75,7 +76,7 @@ export function AppJumpConfigField({ <> {appTargetOptions.map(([value, label]) => ( - {label} + {messages.targets?.[value] || label} ))} - + {type === "app" ? ( -

APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。

+

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

) : null}
); } -function AppJumpStructuredFields({ appJump, disabled, onChange }) { +function AppJumpStructuredFields({ appJump, disabled, messages, onChange }) { if (appJump.target === "custom" || appJump.target === "wallet" || appJump.target === "room_random") { return null; } @@ -118,7 +119,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsRoomId(appJump.target) ? ( onChange({ roomId: event.target.value })} @@ -127,7 +128,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsWindow(appJump.target) ? ( {appJumpWindowOptions.map(([value, label]) => ( - {label} + {messages.windows?.[value] || label} ))} @@ -143,7 +144,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsGameId(appJump.target, appJump.window) ? ( onChange({ gameId: event.target.value })} @@ -168,7 +169,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsGiftId(appJump.target, appJump.window) ? ( onChange({ giftId: event.target.value })} /> @@ -176,7 +177,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsRedPacketNo(appJump.target, appJump.window) ? ( onChange({ redPacketNo: event.target.value })} /> diff --git a/src/shared/ui/TimeRangeFilter.jsx b/src/shared/ui/TimeRangeFilter.jsx index 430a356..cf369df 100644 --- a/src/shared/ui/TimeRangeFilter.jsx +++ b/src/shared/ui/TimeRangeFilter.jsx @@ -12,15 +12,45 @@ import styles from "@/shared/ui/TimeRangeFilter.module.css"; const emptyRange = { endMs: "", startMs: "" }; const quickRanges = [ - ["近 1 小时", 60 * 60 * 1000], - ["近 24 小时", 24 * 60 * 60 * 1000], - ["近 7 天", 7 * 24 * 60 * 60 * 1000], + ["quickHour", 60 * 60 * 1000], + ["quickDay", 24 * 60 * 60 * 1000], + ["quickWeek", 7 * 24 * 60 * 60 * 1000], ]; -const weekLabels = ["一", "二", "三", "四", "五", "六", "日"]; +const defaultMessages = { + apply: "确定", + clear: "清空", + completeRange: "选择完整区间", + duration: "间隔 {value}", + durationDays: "{count} 天", + durationHours: "{count} 小时", + durationMinutes: "{count} 分钟", + end: "结束时间", + endAfterStart: "结束时间必须晚于开始时间", + endDate: "结束日期", + endDateAfterStart: "结束日期不能早于开始日期", + endDateLaterShort: "结束不能早于开始", + endLaterShort: "结束需晚于开始", + endMark: "末", + hour: "时", + lessMinute: "间隔少于 1 分钟", + minute: "分", + nextMonth: "下个月", + pending: "待选择", + previousMonth: "上个月", + quickDay: "近 24 小时", + quickHour: "近 1 小时", + quickWeek: "近 7 天", + sameDay: "同一天", + start: "开始时间", + startDate: "开始日期", + startMark: "始", + unlimited: "不限", +}; // withTime=false 时是纯日期区间选择器:隐藏时/分与小时级快捷区间, // 选中的开始/结束日分别取当天 00:00:00.000 与 23:59:59.999 的本地毫秒。 -export function TimeRangeFilter({ className = "", disabled = false, label = "时间", onChange, value, withTime = true }) { +export function TimeRangeFilter({ className = "", 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); const [draft, setDraft] = useState(() => range); @@ -36,7 +66,8 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 activeField, activeField === "end" && draft.startMs ? new Date(draft.startMs) : viewDate, ); - const rangeDistance = formatRangeDistance(draft.startMs, draft.endMs, withTime); + const rangeDistance = formatLocalizedRangeDistance(draft.startMs, draft.endMs, withTime, copy); + const weekLabels = useMemo(() => localizedWeekLabels(locale), [locale]); const hasRange = Boolean(range.startMs || range.endMs); useEffect(() => { @@ -53,8 +84,8 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 () => !range.startMs && !range.endMs ? label - : `${formatPart(range.startMs) || "不限"} - ${formatPart(range.endMs) || "不限"}`, - [formatPart, label, range], + : `${formatPart(range.startMs) || copy.unlimited} - ${formatPart(range.endMs) || copy.unlimited}`, + [copy.unlimited, formatPart, label, range], ); const openPopover = (event) => { @@ -127,7 +158,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 event.stopPropagation(); const nextRange = normalizeRange(draft); if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) { - setError(withTime ? "结束时间必须晚于开始时间" : "结束日期不能早于开始日期"); + setError(withTime ? copy.endAfterStart : copy.endDateAfterStart); return; } applyRange(nextRange); @@ -146,7 +177,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 {label} - {hasRange ? displayLabel : "不限"} + {hasRange ? displayLabel : copy.unlimited} activateField("start")} /> activateField("end")} />
- - {formatDayPosition(draft.startMs) || "待选择"} + {copy.startMark} + {formatDayPosition(draft.startMs) || copy.pending} {rangeDistance} - {formatDayPosition(draft.endMs) || "待选择"} - + {formatDayPosition(draft.endMs) || copy.pending} + {copy.endMark}
- changeMonth(-1)}> + changeMonth(-1)}> - {monthTitle(viewDate)} - changeMonth(1)}> + {monthTitle(viewDate, locale)} + changeMonth(1)}>
@@ -245,7 +278,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 {withTime ? (
- 确定 + {copy.apply}
@@ -285,7 +318,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 ); } -function RangeFieldButton({ active, formatPart = formatRangePart, label, onClick, value }) { +function RangeFieldButton({ active, formatPart = formatRangePart, label, onClick, unlimited = "不限", value }) { return ( ); } @@ -375,8 +408,11 @@ export function sameDay(left, right) { ); } -export function monthTitle(date) { - return `${date.getFullYear()}年${pad2(date.getMonth() + 1)}月`; +export function monthTitle(date, locale = "zh-CN") { + if (locale === "zh-CN") { + return `${date.getFullYear()}年${pad2(date.getMonth() + 1)}月`; + } + return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date); } export function formatRangePart(value) { @@ -437,6 +473,47 @@ export function formatRangeDistance(startValue, endValue, withTime = true) { return `间隔 ${parts.join(" ")}`; } +function formatLocalizedRangeDistance(startValue, endValue, withTime, messages) { + const startMs = normalizeMs(startValue); + const endMs = normalizeMs(endValue); + if (!startMs || !endMs) { + return messages.completeRange; + } + if (endMs <= startMs) { + return withTime ? messages.endLaterShort : messages.endDateLaterShort; + } + if (!withTime) { + const start = new Date(startMs); + const end = new Date(endMs); + const startDay = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate()); + const endDay = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate()); + const days = Math.round((endDay - startDay) / (24 * 60 * 60 * 1000)); + return days === 0 ? messages.sameDay : template(messages.duration, { value: template(messages.durationDays, { count: days }) }); + } + const totalMinutes = Math.floor((endMs - startMs) / (60 * 1000)); + if (totalMinutes < 1) { + return messages.lessMinute; + } + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + const parts = [ + days ? template(messages.durationDays, { count: days }) : "", + hours ? template(messages.durationHours, { count: hours }) : "", + minutes ? template(messages.durationMinutes, { count: minutes }) : "", + ].filter(Boolean); + return template(messages.duration, { value: parts.join(" ") }); +} + +function localizedWeekLabels(locale) { + const formatter = new Intl.DateTimeFormat(locale, { weekday: "narrow" }); + return Array.from({ length: 7 }, (_, index) => formatter.format(new Date(2024, 0, 1 + index))); +} + +function template(message, values) { + return String(message).replace(/\{(\w+)\}/g, (match, key) => (values[key] === undefined ? match : String(values[key]))); +} + function sameDayMs(day, value) { const ms = normalizeMs(value); return Boolean(ms) && sameDay(day, new Date(ms)); From 8629a52e333a9e07b6cc5e012a8a953cc8980ff6 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 16:59:22 +0800 Subject: [PATCH 06/14] feat(ops-center): densify lucky gift configuration --- ops-center/src/OpsCenterApp.test.jsx | 4 +- .../src/components/LuckyGiftConfigDialog.jsx | 483 +++-------------- .../components/LuckyGiftConfigDialog.test.jsx | 332 ++++++++++++ .../components/LuckyGiftConfigSections.jsx | 484 +++++++++++++++++ .../src/components/LuckyGiftStageDesigner.jsx | 223 ++++++++ ops-center/src/styles/index.css | 491 +++++++++++++++--- 6 files changed, 1539 insertions(+), 478 deletions(-) create mode 100644 ops-center/src/components/LuckyGiftConfigDialog.test.jsx create mode 100644 ops-center/src/components/LuckyGiftConfigSections.jsx create mode 100644 ops-center/src/components/LuckyGiftStageDesigner.jsx diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index 4f30e7c..e91954c 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -309,7 +309,7 @@ test("opens config dialog from a published pool row and saves a new version", as expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0"); expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled(); expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000"); - expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true); + expect(within(dialog).getByLabelText("新手阶段第 1 档概率 %").readOnly).toBe(true); fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } }); fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); @@ -346,7 +346,7 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async expect(saveLuckyGiftConfig).not.toHaveBeenCalled(); // 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。 - fireEvent.change(within(dialog).getAllByLabelText("倍率")[0], { target: { value: "0.1" } }); + fireEvent.change(within(dialog).getByLabelText("新手阶段第 1 档倍率"), { target: { value: "0.1" } }); expect(within(dialog).queryByRole("alert")).toBeNull(); }); diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index c017168..bc7fd61 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -1,16 +1,9 @@ -import AddOutlined from "@mui/icons-material/AddOutlined"; -import Checkbox from "@mui/material/Checkbox"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import MenuItem from "@mui/material/MenuItem"; -import TextField from "@mui/material/TextField"; -import { useMemo, useState } from "react"; -import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/shared/ui/Button.jsx"; -import { formatDecimal } from "@/shared/utils/rtpProbability.js"; import { applyDynamicStrategyDefaults, configToForm, @@ -21,12 +14,14 @@ import { validateLuckyGiftForm, } from "../configForm.js"; import { formatNumber, formatPercent } from "../format.js"; -import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; +import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx"; +import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx"; export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) { const [form, setForm] = useState(() => configToForm(config)); const [activeStage, setActiveStage] = useState("novice"); const [validationErrors, setValidationErrors] = useState([]); + const validationRef = useRef(null); const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) @@ -35,6 +30,12 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); + useEffect(() => { + if (!validationErrors.length) return; + // 保存按钮位于固定底栏;失败后把焦点送回错误摘要,避免键盘或读屏用户停留在无变化的按钮上。 + validationRef.current?.focus(); + }, [validationErrors]); + const updateField = (field, value) => { setValidationErrors([]); setForm((current) => { @@ -129,31 +130,48 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; return ( - +
- + {title} {form.app_code || "-"} / {form.pool_id || "default"} /{" "} {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} - -
- - - - - - - -
- +
+ + + + + + + +
+ {validationErrors.length ? ( -
+
配置不能发布
    {validationErrors.map((message) => ( @@ -164,268 +182,27 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", ) : null}
    - - -
    -
    - {stageSummaries.map((summary) => ( - - ))} -
    - {activeStageConfig ? ( -
    -
    -
    -

    {activeSummary?.stageLabel}

    - -
    - -
    - {form.strategy_version === "dynamic_v3" ? ( -
    - - updateStageField( - activeStageConfig.stage, - "min_recharge_7d_coins", - event.target.value, - ) - } - /> - - updateStageField( - activeStageConfig.stage, - "min_recharge_30d_coins", - event.target.value, - ) - } - /> -
    - ) : null} -
    - - {activeStageConfig.tiers.map((tier, index) => ( -
    - - updateStageTier(activeStageConfig.stage, index, { - // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 - highWaterOnly: - Number(event.target.value) >= 10 || tier.highWaterOnly, - multiplier: event.target.value, - }) - } - /> - {/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */} - - = 10} - size="small" - slotProps={{ input: { "aria-label": "高水位" } }} - onChange={(event) => - updateStageTier(activeStageConfig.stage, index, { - highWaterOnly: event.target.checked, - }) - } - /> - - updateStageTier(activeStageConfig.stage, index, { - enabled: event.target.checked, - }) - } - /> - -
    - ))} -
    -
    - ) : null} -
    + +
    - + @@ -438,137 +215,11 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", ); } -function DynamicStrategySections({ form, onChange }) { - return ( - <> -
    -

    动态水位

    -
    - - - - - - - - -
    -
    - -
    -

    大奖控制

    -
    - onChange("jackpot_multipliers", event.target.value)} - /> - - - - - -
    -
    - -
    -

    六维返奖上限

    -
    - - - - - - -
    -
    - - ); -} - function ConfigMetric({ label, value }) { return ( -
    - {label} - {value} -
    - ); -} - -function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) { - return ( - onChange(name, event.target.value)} - /> +
    +
    {label}
    +
    {value}
    +
    ); } diff --git a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx new file mode 100644 index 0000000..3a49876 --- /dev/null +++ b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx @@ -0,0 +1,332 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { LuckyGiftConfigDialog } from "./LuckyGiftConfigDialog.jsx"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test("keeps every dynamic_v3 control reachable while locking rule-time pool injection", () => { + renderDialog(dynamicConfig()); + + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByRole("navigation", { name: "全局参数分区" })).toBeInTheDocument(); + ["规则", "资金", "水位", "大奖", "限额"].forEach((label) => { + expect(within(dialog).getByRole("button", { name: label })).toBeInTheDocument(); + }); + + expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked(); + expect(within(dialog).getByLabelText(/初始奖池金币/)).toHaveValue(0); + expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled(); + [ + "低水位金币", + "高水位金币", + "充值加权窗口 (ms)", + "大奖倍率", + "全局大奖 RTP 上限 (%)", + "单次返奖上限", + "主播每日上限", + ].forEach((label) => + expect(within(dialog).getByLabelText((accessibleName) => accessibleName.startsWith(label))).toBeInTheDocument(), + ); + + expect(within(dialog).getAllByRole("tab")).toHaveLength(3); + expect(within(dialog).getByRole("columnheader", { name: "倍率" })).toBeInTheDocument(); + expect(within(dialog).getByLabelText("新手阶段第 1 档倍率")).toBeInTheDocument(); + expect(within(dialog).getByLabelText("新手阶段第 2 档概率 %")).toHaveAttribute("readonly"); + expect(within(dialog).getByRole("button", { name: "删除新手阶段第 1 档" })).toBeInTheDocument(); +}); + +test("section navigation handles reduced motion and falls back after switching from dynamic_v3 to fixed_v2", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ + addEventListener: vi.fn(), + matches: true, + media: "(prefers-reduced-motion: reduce)", + removeEventListener: vi.fn(), + }), + ); + renderDialog(dynamicConfig()); + + const dialog = screen.getByRole("dialog"); + const sectionList = dialog.querySelector(".ops-config-section-list"); + const identitySection = dialog.querySelector('[data-config-section="identity"]'); + const waterSection = dialog.querySelector('[data-config-section="water"]'); + const scrollTo = vi.fn(); + Object.defineProperty(sectionList, "clientHeight", { configurable: true, value: 480 }); + Object.defineProperty(sectionList, "scrollHeight", { configurable: true, value: 1_400 }); + Object.defineProperty(sectionList, "scrollTo", { configurable: true, value: scrollTo }); + Object.defineProperty(sectionList, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 200 }), + }); + Object.defineProperty(waterSection, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 520 }), + }); + // 尺寸变化后监听器也必须从响应式外层重新绑定到桌面内层 scroller。 + fireEvent(window, new Event("resize")); + + fireEvent.click(within(dialog).getByRole("button", { name: "水位" })); + expect(scrollTo).toHaveBeenLastCalledWith({ behavior: "auto", top: 312 }); + expect(within(dialog).getByRole("button", { name: "水位" })).toHaveAttribute("aria-current", "true"); + expect(within(dialog).getByRole("heading", { name: "动态水位" })).toHaveFocus(); + + sectionList.scrollTop = 137; + Object.defineProperty(identitySection, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 71 }), + }); + fireEvent.click(within(dialog).getByRole("button", { name: "规则" })); + expect(scrollTo).toHaveBeenLastCalledWith({ behavior: "auto", top: 0 }); + expect(within(dialog).getByRole("heading", { name: "规则身份" })).toHaveFocus(); + + fireEvent.click(within(dialog).getByRole("button", { name: "水位" })); + + fireEvent.mouseDown(within(dialog).getByLabelText("策略版本")); + fireEvent.click(screen.getByRole("option", { name: "fixed_v2(历史兼容)" })); + + await waitFor(() => { + expect(within(dialog).queryByRole("button", { name: "水位" })).not.toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "规则" })).toHaveAttribute("aria-current", "true"); + }); + expect(within(dialog).queryByLabelText(/初始奖池金币/)).not.toBeInTheDocument(); + expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeInTheDocument(); +}); + +test.each([390, 1024])( + "uses the dialog content scroller for %ipx responsive navigation and active-section sync", + (viewportWidth) => { + renderDialog(dynamicConfig()); + + const dialog = screen.getByRole("dialog"); + const sectionNav = dialog.querySelector(".ops-config-section-nav"); + const sectionList = dialog.querySelector(".ops-config-section-list"); + const outerScroller = sectionList.closest(".ops-config-dialog-content"); + const innerScrollTo = vi.fn(); + const outerScrollTo = vi.fn(); + // 不向组件暴露 viewport 宽度;测试通过导航的真实 flex 布局区分手机横栏和平板左栏。 + sectionNav.style.flexDirection = viewportWidth === 390 ? "row" : "column"; + Object.defineProperty(sectionNav, "getBoundingClientRect", { + configurable: true, + value: () => ({ height: 50 }), + }); + Object.defineProperty(sectionList, "clientHeight", { configurable: true, value: 1_200 }); + Object.defineProperty(sectionList, "scrollHeight", { configurable: true, value: 1_200 }); + Object.defineProperty(sectionList, "scrollTo", { configurable: true, value: innerScrollTo }); + Object.defineProperty(outerScroller, "scrollTop", { configurable: true, value: 1_837, writable: true }); + Object.defineProperty(outerScroller, "scrollTo", { configurable: true, value: outerScrollTo }); + Object.defineProperty(outerScroller, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 120 }), + }); + + const sectionTops = { + funds: -800, + identity: -1_000, + jackpot: 180, + limits: 800, + water: -579, + }; + for (const [sectionKey, top] of Object.entries(sectionTops)) { + Object.defineProperty( + dialog.querySelector(`[data-config-section="${sectionKey}"]`), + "getBoundingClientRect", + { + configurable: true, + value: () => ({ top }), + }, + ); + } + + fireEvent.scroll(outerScroller); + const expectedActiveSection = viewportWidth === 390 ? "大奖" : "水位"; + expect(within(dialog).getByRole("button", { name: expectedActiveSection })).toHaveAttribute( + "aria-current", + "true", + ); + + fireEvent.click(within(dialog).getByRole("button", { name: "水位" })); + expect(outerScrollTo).toHaveBeenCalledWith({ + behavior: "smooth", + // 横向 sticky nav 占 50px,目标标题落在其底边下方 8px;左栏布局不需要额外偏移。 + top: viewportWidth === 390 ? 1_080 : 1_130, + }); + expect(innerScrollTo).not.toHaveBeenCalled(); + }, +); + +test("stage tabs expose complete aria relationships and support roving keyboard navigation", () => { + renderDialog(dynamicConfig()); + + const dialog = screen.getByRole("dialog"); + const noviceTab = within(dialog).getByRole("tab", { name: /新手/ }); + const normalTab = within(dialog).getByRole("tab", { name: /普通/ }); + const advancedTab = within(dialog).getByRole("tab", { name: /高阶/ }); + + expect(noviceTab).toHaveAttribute("aria-controls", "ops-stage-panel-novice"); + expect(noviceTab).toHaveAttribute("tabindex", "0"); + expect(normalTab).toHaveAttribute("tabindex", "-1"); + expect(within(dialog).getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "ops-stage-tab-novice"); + + fireEvent.keyDown(noviceTab, { key: "ArrowRight" }); + expect(normalTab).toHaveFocus(); + expect(normalTab).toHaveAttribute("aria-selected", "true"); + expect(within(dialog).getByRole("tabpanel")).toHaveAttribute("id", "ops-stage-panel-normal"); + expect(within(dialog).getByLabelText("正常阶段第 1 档倍率")).toBeInTheDocument(); + + fireEvent.keyDown(normalTab, { key: "End" }); + expect(advancedTab).toHaveFocus(); + expect(advancedTab).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(advancedTab, { key: "Home" }); + expect(noviceTab).toHaveFocus(); + expect(noviceTab).toHaveAttribute("aria-selected", "true"); +}); + +test("fixed_v2 hides only dynamic controls and preserves immutable snapshot fields on submit", () => { + const onSubmit = vi.fn(); + renderDialog(fixedConfig(), { onSubmit }); + + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked(); + expect(within(dialog).queryByRole("button", { name: "水位" })).not.toBeInTheDocument(); + expect(within(dialog).queryByText(/资金合计/)).not.toBeInTheDocument(); + expect(within(dialog).queryByLabelText(/初始奖池金币/)).not.toBeInTheDocument(); + expect(within(dialog).getAllByRole("tab")).toHaveLength(3); + + fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + effective_from_ms: 987_654_321, + enabled: true, + strategy_version: "fixed_v2", + stages: expect.arrayContaining([ + expect.objectContaining({ + stage: "novice", + tiers: expect.arrayContaining([ + expect.objectContaining({ + enabled: true, + high_water_only: false, + tier_id: "novice_none", + }), + ]), + }), + ]), + }), + ); +}); + +test("focuses the validation summary when the fixed action bar submission is rejected", async () => { + const onSubmit = vi.fn(); + renderDialog(dynamicConfig({ low_watermark_coins: 0 }), { onSubmit }); + + const dialog = screen.getByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); + + const alert = await within(dialog).findByRole("alert"); + expect(alert).toHaveTextContent("高水位必须大于正数低水位"); + expect(alert).toHaveFocus(); + expect(onSubmit).not.toHaveBeenCalled(); +}); + +function renderDialog(config, overrides = {}) { + return render( + , + ); +} + +function dynamicConfig(overrides = {}) { + return { + anchor_daily_payout_cap: 6_000_000, + anchor_rate_percent: 1, + app_code: "lalu", + control_band_percent: 3, + device_daily_payout_cap: 4_000_000, + effective_from_ms: 123_456_789, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 0, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 1_000_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "lucky", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 5_000_000, + rule_version: 3, + settlement_window_wager: 1_000_000, + stages: stagesFor(98), + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 3_000_000, + user_hourly_payout_cap: 2_000_000, + ...overrides, + }; +} + +function fixedConfig() { + return { + app_code: "lalu", + control_band_percent: 1, + effective_from_ms: 987_654_321, + enabled: true, + gift_price_reference: 100, + pool_id: "legacy", + pool_rate_percent: 95, + rule_version: 8, + settlement_window_wager: 1_000_000, + stages: stagesFor(95), + strategy_version: "fixed_v2", + target_rtp_percent: 95, + }; +} + +function stagesFor(targetRTP) { + return ["novice", "normal", "advanced"].map((stage, index) => ({ + min_recharge_30d_coins: index === 0 ? 0 : 1, + min_recharge_7d_coins: index === 2 ? 1 : 0, + stage, + tiers: [ + { + enabled: true, + high_water_only: false, + multiplier: 0, + probability_percent: 100 - targetRTP, + tier_id: `${stage}_none`, + }, + { + enabled: true, + high_water_only: false, + multiplier: 1, + probability_percent: targetRTP, + tier_id: `${stage}_1x`, + }, + ], + })); +} diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx new file mode 100644 index 0000000..19c7a0e --- /dev/null +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -0,0 +1,484 @@ +import FormControlLabel from "@mui/material/FormControlLabel"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; + +const BASE_SECTIONS = [ + ["identity", "规则"], + ["funds", "资金"], +]; + +const DYNAMIC_SECTIONS = [ + ["water", "水位"], + ["jackpot", "大奖"], + ["limits", "限额"], +]; + +export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }) { + const scrollRef = useRef(null); + const sections = useMemo( + () => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS), + [form.strategy_version], + ); + const [activeSection, setActiveSection] = useState(sections[0][0]); + + useEffect(() => { + if (sections.some(([sectionKey]) => sectionKey === activeSection)) return; + // dynamic_v3 的专属分区会在切回 fixed_v2 后卸载;同步回首个有效分区,避免导航继续指向不存在的面板。 + setActiveSection(sections[0][0]); + if (scrollRef.current) resolveConfigScroller(scrollRef.current).scrollTop = 0; + }, [activeSection, sections]); + + useEffect(() => { + const sectionList = scrollRef.current; + if (!sectionList) return undefined; + const view = sectionList.ownerDocument.defaultView; + let boundScroller; + + const syncActiveSection = () => { + if (!boundScroller) return; + const readingLine = + boundScroller.getBoundingClientRect().top + configNavInset(sectionList, boundScroller) + 32; + let nextSection = sections[0][0]; + for (const [sectionKey] of sections) { + const section = sectionList.querySelector(`[data-config-section="${sectionKey}"]`); + if (section && section.getBoundingClientRect().top <= readingLine) nextSection = sectionKey; + } + setActiveSection((current) => (current === nextSection ? current : nextSection)); + }; + + const bindActualScroller = () => { + const nextScroller = resolveConfigScroller(sectionList); + if (nextScroller === boundScroller) return; + boundScroller?.removeEventListener("scroll", syncActiveSection); + boundScroller = nextScroller; + // 桌面由参数面板独立滚动,平板/手机由 DialogContent 单滚动;只监听真实 scroller, + // 避免小屏内层 overflow:visible 时导航状态更新、内容位置却完全没有变化。 + boundScroller.addEventListener("scroll", syncActiveSection, { passive: true }); + }; + + bindActualScroller(); + view?.addEventListener("resize", bindActualScroller); + const ResizeObserverClass = view?.ResizeObserver; + const resizeObserver = ResizeObserverClass ? new ResizeObserverClass(bindActualScroller) : null; + resizeObserver?.observe(sectionList); + + return () => { + boundScroller?.removeEventListener("scroll", syncActiveSection); + view?.removeEventListener("resize", bindActualScroller); + resizeObserver?.disconnect(); + }; + }, [sections]); + + const jumpToSection = (sectionKey) => { + const sectionList = scrollRef.current; + const section = sectionList?.querySelector(`[data-config-section="${sectionKey}"]`); + if (!sectionList || !section) return; + const scroller = resolveConfigScroller(sectionList); + setActiveSection(sectionKey); + // offsetParent 不是可靠的滚动参考系;无论当前是桌面内层还是响应式外层滚动,都用视口坐标 + // 换算同一个 scroller 的目标位点;窄屏横向导航是 sticky 覆盖层,目标还必须落到导航下方。 + const navInset = configNavInset(sectionList, scroller); + const top = Math.max( + scroller.scrollTop + + section.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + navInset - + 8, + 0, + ); + const reduceMotion = scroller.ownerDocument.defaultView?.matchMedia?.( + "(prefers-reduced-motion: reduce)", + ).matches; + if (typeof scroller.scrollTo === "function") { + scroller.scrollTo({ behavior: reduceMotion ? "auto" : "smooth", top }); + } else { + // jsdom 和部分旧 WebView 没有 Element.scrollTo,直接写 scrollTop 仍能保持导航可用。 + scroller.scrollTop = top; + } + // 导航按钮只负责选择分区;焦点落到标题后,读屏会立即获得当前编辑上下文。 + section.querySelector("h3")?.focus({ preventScroll: true }); + }; + + const fundTotal = + Number(form.pool_rate_percent || 0) + + Number(form.profit_rate_percent || 0) + + Number(form.anchor_rate_percent || 0); + + return ( + + ); +} + +function SectionHeading({ detail, id, ok = false, title }) { + return ( +
    +

    + {title} +

    + {detail ? {detail} : null} +
    + ); +} + +function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) { + return ( + onChange(name, event.target.value)} + /> + ); +} + +function formatCompactNumber(value) { + return Number.isFinite(value) ? Number(value.toFixed(4)).toString() : "-"; +} + +function resolveConfigScroller(sectionList) { + // 桌面参数列限定高度后由自己滚动;响应式规则把它改为 overflow:visible,此时 scrollHeight 与 + // clientHeight 相等,实际滚动责任上移到 DialogContent。判定能力而非 viewport 宽度可兼容缩放和嵌入场景。 + if (sectionList.scrollHeight > sectionList.clientHeight) return sectionList; + return sectionList.closest(".ops-config-dialog-content") || sectionList; +} + +function configNavInset(sectionList, scroller) { + if (scroller === sectionList) return 0; + const nav = sectionList.previousElementSibling; + if (!nav?.classList.contains("ops-config-section-nav")) return 0; + // 响应式是否形成覆盖层由真实布局决定:390px 时 nav 为横向 row,1024px 时仍是左侧 column。 + // 读取 computed style 而不是猜 innerWidth,才能兼容浏览器缩放、嵌入容器和未来断点调整。 + const flexDirection = nav.ownerDocument.defaultView?.getComputedStyle(nav).flexDirection; + return flexDirection === "row" ? nav.getBoundingClientRect().height : 0; +} diff --git a/ops-center/src/components/LuckyGiftStageDesigner.jsx b/ops-center/src/components/LuckyGiftStageDesigner.jsx new file mode 100644 index 0000000..7fb5d49 --- /dev/null +++ b/ops-center/src/components/LuckyGiftStageDesigner.jsx @@ -0,0 +1,223 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import DeleteOutlined from "@mui/icons-material/DeleteOutlined"; +import Checkbox from "@mui/material/Checkbox"; +import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; +import { Button } from "@/shared/ui/Button.jsx"; +import { formatDecimal } from "@/shared/utils/rtpProbability.js"; +import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; + +export function LuckyGiftStageDesigner({ + activeStageConfig, + activeStageKey, + activeSummary, + onAddTier, + onRemoveTier, + onStageChange, + onStageFieldChange, + onTierChange, + stageSummaries, + strategyVersion, +}) { + const selectStageFromKeyboard = (event, currentIndex) => { + const lastIndex = stageSummaries.length - 1; + let nextIndex; + if (["ArrowRight", "ArrowDown"].includes(event.key)) nextIndex = (currentIndex + 1) % stageSummaries.length; + if (["ArrowLeft", "ArrowUp"].includes(event.key)) + nextIndex = (currentIndex - 1 + stageSummaries.length) % stageSummaries.length; + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = lastIndex; + if (nextIndex === undefined) return; + event.preventDefault(); + const nextStage = stageSummaries[nextIndex]?.stageKey; + if (!nextStage) return; + // Tabs 遵循 roving tabindex:方向键既切换阶段,也把焦点移到新 tab,键盘操作不会落回隐藏面板。 + onStageChange(nextStage); + event.currentTarget.ownerDocument.getElementById(`ops-stage-tab-${nextStage}`)?.focus(); + }; + + return ( +
    +
    + {stageSummaries.map((summary, index) => { + const isActive = summary.stageKey === activeStageKey; + return ( + + ); + })} +
    + + {activeStageConfig ? ( +
    +
    +
    +

    {activeSummary?.stageLabel}

    + +
    + +
    + + {strategyVersion === "dynamic_v3" ? ( +
    + 充值门槛 + + onStageFieldChange( + activeStageConfig.stage, + "min_recharge_7d_coins", + event.target.value, + ) + } + /> + + onStageFieldChange( + activeStageConfig.stage, + "min_recharge_30d_coins", + event.target.value, + ) + } + /> +
    + ) : null} + +
    +
    + 倍率 + 概率 % + 高水位 + 启用 + 操作 +
    + {activeStageConfig.tiers.map((tier, index) => { + const rowLabel = `${activeSummary?.stageLabel || activeStageConfig.stage}第 ${index + 1} 档`; + return ( +
    +
    + + onTierChange(activeStageConfig.stage, index, { + // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 + highWaterOnly: + Number(event.target.value) >= 10 || tier.highWaterOnly, + multiplier: event.target.value, + }) + } + /> +
    +
    + {/* 概率由目标 RTP 和倍率统一校正;保持只读可避免运营绕过服务端概率闭合校验。 */} + +
    +
    + = 10} + size="small" + slotProps={{ input: { "aria-label": `${rowLabel}高水位限制` } }} + onChange={(event) => + onTierChange(activeStageConfig.stage, index, { + highWaterOnly: event.target.checked, + }) + } + /> +
    +
    + + onTierChange(activeStageConfig.stage, index, { + enabled: event.target.checked, + }) + } + /> +
    +
    + onRemoveTier(activeStageConfig.stage, index)} + > + + +
    +
    + ); + })} +
    +
    + ) : null} +
    + ); +} diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index 277fc65..56a5129 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -186,13 +186,27 @@ body { /* ---- 幸运礼物配置弹窗 ---- */ +.MuiDialog-paper.ops-config-dialog-paper { + width: 94vw; + max-width: 1520px; + height: 92vh; + max-height: 92vh; + margin: var(--space-4); + overflow: hidden; +} + .ops-config-form { - display: contents; + display: flex; + min-height: 0; + height: 100%; + flex-direction: column; } .ops-config-title { display: grid; + flex: 0 0 auto; gap: var(--space-1); + padding: var(--space-4) var(--space-6) var(--space-3); } .ops-config-title small { @@ -201,17 +215,31 @@ body { font-weight: 700; } -.ops-config-metrics { +.ops-config-summary { display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: var(--space-2); - margin-bottom: var(--space-4); + flex: 0 0 auto; + grid-template-columns: repeat(7, minmax(0, 1fr)); + margin: 0; + padding: 0 var(--space-6) var(--space-3); +} + +.ops-config-dialog-content { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden !important; + background: var(--bg-card); + padding: 0 !important; } .ops-config-validation { display: grid; + max-height: 112px; + flex: 0 0 auto; gap: var(--space-2); - margin-bottom: var(--space-4); + overflow-y: auto; + margin: var(--space-3) var(--space-4) 0; border: 1px solid var(--danger-border, #fecaca); border-radius: var(--radius-control); background: var(--danger-surface, #fef2f2); @@ -219,6 +247,11 @@ body { padding: var(--space-3) var(--space-4); } +.ops-config-validation:focus-visible { + outline: 2px solid var(--danger, #dc2626); + outline-offset: 2px; +} + .ops-config-validation ul { display: grid; gap: 2px; @@ -229,26 +262,31 @@ body { .ops-config-metric { display: grid; min-width: 0; - gap: var(--space-1); - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--bg-card-strong, #f8fafc); - padding: 10px var(--space-3); + gap: 2px; + border-left: 1px solid var(--border); + padding: var(--space-1) var(--space-3); } -.ops-config-metric span { +.ops-config-metric:first-child { + border-left: 0; + padding-left: 0; +} + +.ops-config-metric dt { overflow: hidden; color: var(--text-tertiary); - font-size: 12px; + font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } -.ops-config-metric strong { +.ops-config-metric dd { overflow: hidden; + margin: 0; color: var(--text-primary); - font-size: 15px; + font-size: 13px; + font-weight: 800; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; @@ -256,24 +294,94 @@ body { .ops-config-layout { display: grid; - grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); - align-items: start; - gap: var(--space-4); + min-height: 0; + height: 100%; + flex: 1 1 auto; + grid-template-columns: clamp(500px, 40vw, 620px) minmax(0, 1fr); + overflow: hidden; } .ops-config-params { display: grid; min-width: 0; - gap: var(--space-4); + min-height: 0; + grid-template-columns: 92px minmax(0, 1fr); + overflow: hidden; + border-right: 1px solid var(--border); +} + +.ops-config-section-nav { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-1); + overflow-y: auto; + border-right: 1px solid var(--border); + background: var(--bg-card-strong, #f8fafc); + padding: var(--space-3) var(--space-2); +} + +.ops-config-section-nav strong { + color: var(--text-tertiary); + font-size: 11px; + letter-spacing: 0.04em; + padding: var(--space-1) var(--space-2) var(--space-2); +} + +.ops-config-section-nav button { + min-height: 34px; + border: 0; + border-left: 2px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 800; + padding: 0 var(--space-2); + text-align: left; + transition: + background var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); +} + +.ops-config-section-nav button:hover { + background: var(--primary-hover); +} + +.ops-config-section-nav button.is-active { + border-left-color: var(--primary); + background: var(--primary-surface); + color: var(--primary); +} + +.ops-config-section-list { + min-width: 0; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--space-3) var(--space-4) var(--space-6); + scroll-behavior: smooth; } .ops-config-section { display: grid; gap: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-card); + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; background: var(--bg-card); - padding: var(--space-4); + padding: var(--space-3) 0 var(--space-5); +} + +.ops-config-section + .ops-config-section { + padding-top: var(--space-5); +} + +.ops-config-section:last-child { + border-bottom: 0; } .ops-config-section h3 { @@ -283,6 +391,28 @@ body { line-height: 1.3; } +.ops-config-section__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.ops-config-section__head span { + border-radius: var(--radius-pill); + background: var(--warning-surface); + color: var(--warning); + font-size: 11px; + font-weight: 800; + padding: 3px var(--space-2); + white-space: nowrap; +} + +.ops-config-section__head span.is-ok { + background: var(--success-surface); + color: var(--success); +} + .ops-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -293,44 +423,100 @@ body { grid-column: 1 / -1; } +.ops-config-enabled-field { + min-height: var(--control-height); + justify-content: space-between; + margin: 0 !important; + border: 1px solid var(--border); + border-radius: var(--radius-control); + padding: 0 var(--space-3); +} + +.ops-config-enabled-field .MuiFormControlLabel-label { + color: var(--text-secondary); + font-size: 13px; + font-weight: 700; +} + +.ops-field-note { + margin: 0; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.5; +} + .ops-stage-designer { display: grid; min-width: 0; - gap: 0; - border: 1px solid var(--border); - border-radius: var(--radius-card); + min-height: 0; + align-content: start; + overflow-y: auto; + overscroll-behavior: contain; + border: 0; + border-radius: 0; background: var(--bg-card); } .ops-stage-tabs { - display: flex; + position: sticky; + z-index: 3; + top: 0; + display: grid; min-width: 0; - gap: var(--space-1); - padding: 0 var(--space-3); + min-height: 64px; + grid-template-columns: repeat(3, minmax(0, 1fr)); border-bottom: 1px solid var(--border); + background: var(--bg-card); + padding: 0 var(--space-4); } .ops-stage-tab { - display: inline-flex; + display: grid; min-width: 0; - height: 38px; - align-items: center; + min-height: 64px; + align-content: center; + gap: 3px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--text-secondary); cursor: pointer; - font-size: 13px; - font-weight: 800; - padding: 0 var(--space-4); - text-align: center; + padding: var(--space-2) var(--space-3); + text-align: left; transition: background var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard); } +.ops-stage-tab__name { + display: flex; + min-width: 0; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); +} + +.ops-stage-tab__name strong { + color: inherit; + font-size: 13px; +} + +.ops-stage-tab__name small, +.ops-stage-tab__metrics { + overflow: hidden; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ops-stage-tab.is-warn .ops-stage-tab__name small { + color: var(--warning); +} + .ops-stage-tab:hover { background: var(--primary-hover); } @@ -353,17 +539,24 @@ body { .ops-stage-stack { display: grid; gap: var(--space-3); - padding: var(--space-4); + padding: 0 var(--space-4) var(--space-6); } .ops-stage-block__head { + position: sticky; + z-index: 2; + top: 64px; display: flex; + min-height: 58px; align-items: center; justify-content: space-between; gap: var(--space-3); + border-bottom: 1px solid var(--border); + background: var(--bg-card); + padding: var(--space-2) 0; } -.ops-stage-block__head > div { +.ops-stage-block__summary { display: flex; flex-wrap: wrap; align-items: center; @@ -377,12 +570,17 @@ body { .ops-stage-thresholds { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: auto repeat(2, minmax(140px, 220px)); + align-items: center; gap: var(--space-3); - padding: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--bg-card-strong, #f8fafc); + justify-content: end; + padding: var(--space-1) 0; +} + +.ops-stage-thresholds > span { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 800; } .ops-tier-table { @@ -398,13 +596,13 @@ body { .ops-tier-row { display: grid; box-sizing: border-box; - min-width: 520px; + min-width: 590px; grid-template-columns: + minmax(100px, 1fr) minmax(110px, 1fr) - minmax(110px, 1fr) - minmax(64px, auto) - minmax(64px, auto) - minmax(72px, auto); + 76px + 64px + 56px; align-items: center; gap: var(--space-2); } @@ -419,17 +617,86 @@ body { padding: 0 var(--space-3); } +.ops-tier-head span:nth-child(n + 3) { + text-align: center; +} + .ops-tier-row { border-bottom: 1px solid var(--row-border); - padding: var(--space-2) var(--space-3); + padding: 6px var(--space-3); } .ops-tier-row:last-child { border-bottom: 0; } +.ops-tier-cell { + display: flex; + min-width: 0; + align-items: center; +} + +.ops-tier-cell > .MuiFormControl-root { + width: 100%; +} + +.ops-tier-cell--center { + justify-content: center; +} + +.ops-config-dialog-actions { + min-height: 60px; + flex: 0 0 auto; + border-top: 1px solid var(--border); + background: var(--bg-card); + padding: var(--space-2) var(--space-6); +} + /* ---- 响应式 ---- */ +@media (max-width: 1279px) { + .ops-config-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + row-gap: var(--space-2); + } + + .ops-config-dialog-content { + overflow-y: auto !important; + } + + .ops-config-layout { + height: auto; + min-height: 100%; + grid-template-columns: minmax(0, 1fr); + overflow: visible; + } + + .ops-config-params { + min-height: auto; + grid-template-columns: 112px minmax(0, 1fr); + overflow: visible; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .ops-config-section-nav { + position: sticky; + top: 0; + max-height: 100%; + align-self: start; + } + + .ops-config-section-list, + .ops-stage-designer { + min-height: auto; + overflow: visible; + } + + .ops-stage-designer { + border-top: var(--space-2) solid var(--bg-page); + } +} + @media (max-width: 960px) { .ops-shell { grid-template-columns: 1fr; @@ -440,14 +707,121 @@ body { } .ops-nav, - .ops-kpi-grid, - .ops-config-metrics { + .ops-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} - .ops-config-layout { +@media (max-width: 767px) { + .MuiDialog-paper.ops-config-dialog-paper { + width: 100vw; + max-width: none; + height: 100dvh; + max-height: 100dvh; + margin: 0; + border-radius: 0; + } + + .ops-config-title { + padding: var(--space-3) var(--space-4) var(--space-2); + } + + .ops-config-summary { + display: flex; + gap: 0; + overflow-x: auto; + padding: 0 var(--space-4) var(--space-2); + } + + .ops-config-metric { + min-width: 124px; + } + + .ops-config-params { grid-template-columns: minmax(0, 1fr); } + + .ops-config-section-nav { + z-index: 4; + display: flex; + max-width: 100vw; + flex-direction: row; + overflow-x: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + padding: var(--space-2); + } + + .ops-config-section-nav strong { + display: none; + } + + .ops-config-section-nav button { + min-width: 72px; + flex: 0 0 auto; + border-left: 0; + border-bottom: 2px solid transparent; + text-align: center; + } + + .ops-config-section-nav button.is-active { + border-bottom-color: var(--primary); + } + + .ops-config-section-list { + padding: var(--space-2) var(--space-3) var(--space-5); + } + + .ops-form-grid { + grid-template-columns: minmax(0, 1fr); + } + + .ops-stage-tabs { + display: flex; + max-width: 100vw; + overflow-x: auto; + padding: 0 var(--space-2); + } + + .ops-stage-tab { + min-width: 180px; + flex: 1 0 180px; + } + + .ops-stage-stack { + padding: 0 var(--space-3) var(--space-5); + } + + .ops-stage-block__head { + align-items: flex-start; + flex-wrap: wrap; + } + + .ops-stage-block__summary { + align-items: flex-start; + flex-direction: column; + gap: var(--space-1); + } + + .ops-stage-thresholds { + grid-template-columns: repeat(2, minmax(0, 1fr)); + justify-content: stretch; + } + + .ops-stage-thresholds > span { + grid-column: 1 / -1; + } + + /* 小屏保留完整列头并让表格横向滚动,控件同时有阶段+行号标签,避免一列卡片失去字段关系。 */ + .ops-tier-head, + .ops-tier-row { + min-width: 600px; + } + + .ops-config-dialog-actions { + min-height: 56px; + padding: var(--space-2) var(--space-4) max(var(--space-2), env(safe-area-inset-bottom)); + } } @media (max-width: 640px) { @@ -457,22 +831,19 @@ body { .ops-nav, .ops-kpi-grid, - .ops-config-metrics, - .ops-pool-adjustment-metrics, - .ops-form-grid, - .ops-stage-thresholds, - .ops-tier-row { + .ops-pool-adjustment-metrics { grid-template-columns: 1fr; } - - .ops-tier-head { - display: none; - } } @media (prefers-reduced-motion: reduce) { .ops-nav button, + .ops-config-section-nav button, .ops-stage-tab { transition: none; } + + .ops-config-section-list { + scroll-behavior: auto; + } } From 662c497771d1a04a585afbf9369a456ac520025d Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 17:46:03 +0800 Subject: [PATCH 07/14] feat(ops-center): separate current and historical lucky pools --- ops-center/src/api.js | 42 +++++++++++++++-- ops-center/src/components/OverviewView.jsx | 54 ++++++++++++++++++---- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/ops-center/src/api.js b/ops-center/src/api.js index 7ef47d5..1c3be33 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -149,7 +149,7 @@ export function buildOpsUrl(path, query = {}) { export function normalizeDashboard({ apps = [], perApp = [] } = {}) { const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); - const poolBalances = perApp + const normalizedPoolBalances = perApp .flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode))) .filter((item) => item.app_code); const appSummaries = perApp @@ -157,8 +157,32 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) { .filter((summary) => summary.app_code); const publishedConfigs = luckyGifts.filter((config) => !config.is_default); + const currentConfigByPool = new Map(); + luckyGifts.forEach((config) => { + const familyKey = luckyGiftPoolFamilyKey(config); + const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); + const current = currentConfigByPool.get(familyKey); + // 正常接口只返回每个奖池的最新版本;这里仍按最大 rule_version 收敛,避免兼容接口返回多版本时 + // 由数组顺序把旧策略误判为当前。版本相同则保留首条,确保结果不随返回顺序抖动。 + if (!current || ruleVersion > current.ruleVersion) { + currentConfigByPool.set(familyKey, { + ruleVersion, + strategyVersion: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion), + }); + } + }); + // 账本接口会同时返回同一 app+pool 的 V2/V3 独立水位;最新规则配置才决定哪口是当前奖池。 + // 没有配置可关联的孤立账本继续按当前展示,避免前端误把仍需运营处理的资金静默藏进历史筛选。 + const poolBalances = normalizedPoolBalances.map((pool) => { + const currentStrategy = currentConfigByPool.get(luckyGiftPoolFamilyKey(pool))?.strategyVersion; + return { + ...pool, + is_historical: Boolean(currentStrategy && currentStrategy !== pool.strategy_version), + }; + }); + const currentPoolBalances = poolBalances.filter((pool) => !pool.is_historical); - // KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。 + // KPI 一律跨应用聚合;奖池金额只汇总当前策略,避免历史 V2 与当前 V3 的独立账本重复计入运营水位。 const summary = { activeApps: apps.length, configuredPools: publishedConfigs.length, @@ -166,9 +190,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) { failedDraws: sumBy(appSummaries, "failed_draws"), grantedDraws: sumBy(appSummaries, "granted_draws"), pendingDraws: sumBy(appSummaries, "pending_draws"), - poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"), - poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"), - poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"), + poolAvailableTotal: sumBy(currentPoolBalances, "available_balance", "availableBalance"), + poolBalanceTotal: sumBy(currentPoolBalances, "balance", "Balance"), + poolReserveTotal: sumBy(currentPoolBalances, "reserve_floor", "reserveFloor"), totalDraws: sumBy(appSummaries, "total_draws"), totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), totalSpentCoins: sumBy(appSummaries, "total_spent_coins"), @@ -208,6 +232,14 @@ export function luckyGiftPoolKey(item = {}) { return `${appCode}:${poolID}:${strategyVersion}`; } +function luckyGiftPoolFamilyKey(item = {}) { + const appCode = String(item.app_code || item.appCode || "") + .trim() + .toLowerCase(); + const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; + return `${appCode}:${poolID}`; +} + function normalizeDrawSummary(summary = {}, appCode = "") { return { actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), diff --git a/ops-center/src/components/OverviewView.jsx b/ops-center/src/components/OverviewView.jsx index ed64516..5ee4c7d 100644 --- a/ops-center/src/components/OverviewView.jsx +++ b/ops-center/src/components/OverviewView.jsx @@ -2,7 +2,8 @@ import AppsOutlined from "@mui/icons-material/AppsOutlined"; import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; import CasinoOutlined from "@mui/icons-material/CasinoOutlined"; import SavingsOutlined from "@mui/icons-material/SavingsOutlined"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { KpiCard } from "@/shared/ui/KpiCard.jsx"; import { TimeText } from "@/shared/ui/TimeText.jsx"; @@ -13,10 +14,29 @@ import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; export function OverviewView({ canCredit, canDebit, data, disabled, onCredit, onDebit }) { const { appSummaries, poolBalances, summary } = data; + const [poolScope, setPoolScope] = useState("current"); const poolBalanceColumns = useMemo( () => buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }), [canCredit, canDebit, disabled, onCredit, onDebit], ); + const poolCounts = useMemo( + () => ({ + all: poolBalances.length, + current: poolBalances.filter((pool) => !pool.is_historical).length, + history: poolBalances.filter((pool) => pool.is_historical).length, + }), + [poolBalances], + ); + const visiblePoolBalances = useMemo(() => { + if (poolScope === "history") { + return poolBalances.filter((pool) => pool.is_historical); + } + if (poolScope === "all") { + return poolBalances; + } + // 当前策略是默认视图;历史账本只从显式筛选进入,避免同名 V2/V3 被误读为重复奖池。 + return poolBalances.filter((pool) => !pool.is_historical); + }, [poolBalances, poolScope]); return (
    @@ -43,21 +63,35 @@ export function OverviewView({ canCredit, canDebit, data, disabled, onCredit, on />
    + + } + /> - item.materialized === false ? ( + render: (item) => { + if (item.is_historical) { + return ; + } + return item.materialized === false ? ( ) : ( - ), + ); + }, width: "minmax(132px, 0.9fr)", }, { key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance) }, From 43682534b09de860cf0820430e021ccf5b0b4f37 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 18:16:19 +0800 Subject: [PATCH 08/14] fix(ops-center): clarify lucky gift thresholds --- .../src/components/LuckyGiftConfigDialog.jsx | 2 + .../components/LuckyGiftConfigDialog.test.jsx | 9 +++ .../components/LuckyGiftConfigSections.jsx | 3 +- .../src/components/LuckyGiftStageDesigner.jsx | 35 ++++++++-- ops-center/src/configForm.js | 68 +++++++++++-------- ops-center/src/configForm.test.js | 15 ++-- ops-center/src/styles/index.css | 25 +++++++ 7 files changed, 119 insertions(+), 38 deletions(-) diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index bc7fd61..c986e6d 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -29,6 +29,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", : form.stages[0]?.stage; const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); + const normalStageConfig = form.stages.find((stage) => stage.stage === "normal"); useEffect(() => { if (!validationErrors.length) return; @@ -192,6 +193,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", activeStageConfig={activeStageConfig} activeStageKey={activeStageKey} activeSummary={activeSummary} + normalStageConfig={normalStageConfig} stageSummaries={stageSummaries} strategyVersion={form.strategy_version} onAddTier={addStageTier} diff --git a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx index 3a49876..2b54a32 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx @@ -24,6 +24,7 @@ test("keeps every dynamic_v3 control reachable while locking rule-time pool inje "充值加权窗口 (ms)", "大奖倍率", "全局大奖 RTP 上限 (%)", + "用户日累计消费触发门槛(金币)", "单次返奖上限", "主播每日上限", ].forEach((label) => @@ -35,6 +36,14 @@ test("keeps every dynamic_v3 control reachable while locking rule-time pool inje expect(within(dialog).getByLabelText("新手阶段第 1 档倍率")).toBeInTheDocument(); expect(within(dialog).getByLabelText("新手阶段第 2 档概率 %")).toHaveAttribute("readonly"); expect(within(dialog).getByRole("button", { name: "删除新手阶段第 1 档" })).toBeInTheDocument(); + expect(within(dialog).getByLabelText("新手阶段派生充值范围")).toHaveTextContent("7 日充值 < 0 或 30 日充值 < 1"); + expect( + within(dialog).getByText("任一未达到即为新手;7 日与 30 日均达到门槛(含等于)后进入普通。"), + ).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole("tab", { name: /普通/ })); + expect(within(dialog).getByLabelText("7 日最低充值(金币)")).toHaveValue(0); + expect(within(dialog).getByLabelText("30 日最低充值(金币)")).toHaveValue(1); }); test("section navigation handles reduced motion and falls back after switching from dynamic_v3 to fixed_v2", async () => { diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index 19c7a0e..141fb0f 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -375,7 +375,8 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } /> diff --git a/ops-center/src/components/LuckyGiftStageDesigner.jsx b/ops-center/src/components/LuckyGiftStageDesigner.jsx index 7fb5d49..65b6638 100644 --- a/ops-center/src/components/LuckyGiftStageDesigner.jsx +++ b/ops-center/src/components/LuckyGiftStageDesigner.jsx @@ -5,12 +5,14 @@ import IconButton from "@mui/material/IconButton"; import TextField from "@mui/material/TextField"; import { Button } from "@/shared/ui/Button.jsx"; import { formatDecimal } from "@/shared/utils/rtpProbability.js"; +import { formatNumber } from "../format.js"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; export function LuckyGiftStageDesigner({ activeStageConfig, activeStageKey, activeSummary, + normalStageConfig, onAddTier, onRemoveTier, onStageChange, @@ -90,12 +92,13 @@ export function LuckyGiftStageDesigner({
- {strategyVersion === "dynamic_v3" ? ( + {strategyVersion === "dynamic_v3" && activeStageConfig.stage === "novice" ? ( + + ) : strategyVersion === "dynamic_v3" ? (
充值门槛 ); } + +function NoviceRechargeRange({ normalStageConfig }) { + const normal7D = Number(normalStageConfig?.min_recharge_7d_coins || 0); + const normal30D = Number(normalStageConfig?.min_recharge_30d_coins || 0); + // normal 是二维 AND 下限,合法配置只需至少一维大于 0;为 0 的维度也必须照实展示。 + const configured = Number.isFinite(normal7D) && Number.isFinite(normal30D) && (normal7D > 0 || normal30D > 0); + + return ( +
+ 派生范围 +
+ + 7 日充值 < {formatNumber(normal7D)} 或 30 日充值 < {formatNumber(normal30D)} + + {/* 普通阶段需要两个下限同时成立;因此任一维未达到都应留在新手,等于下限则进入普通。 */} + + {configured ? "" : "普通阶段门槛待配置;"}任一未达到即为新手;7 日与 30 + 日均达到门槛(含等于)后进入普通。 + +
+
+ ); +} diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index bc7f3cf..24655d3 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -12,7 +12,7 @@ import { DEFAULT_POOL_ID } from "./api.js"; const DYNAMIC_STRATEGY = "dynamic_v3"; const PPM_SCALE = 1_000_000; -// 只有跨 App 通用的算法参数才有默认值;50 美元等值门槛和六维金额上限必须由运营按 App 金币口径填写, +// 只有跨 App 通用的算法参数才有默认值;用户日累计消费触发门槛和六维金额上限必须由运营按 App 奖池的金币口径填写, // 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。 const dynamicDefaults = { anchor_daily_payout_cap: 0, @@ -131,18 +131,23 @@ export function formToConfig(config, form) { enabled: Boolean(form.enabled), jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers), pool_id: form.pool_id, - stages: (form.stages || []).map((stage) => ({ - min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins), - min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins), - stage: stage.stage, - tiers: (stage.tiers || []).map((tier) => ({ - enabled: tier.enabled !== false, - high_water_only: Boolean(tier.highWaterOnly), - multiplier: numberFromForm(tier.multiplier), - probability_percent: numberFromForm(tier.probabilityPercent), - tier_id: tier.tierId || "", - })), - })), + stages: (form.stages || []).map((stage) => { + // dynamic_v3 的新手范围由普通阶段下限反向派生;0/0 只是旧 DTO 要求的兜底哨兵值, + // 不是可运营的“充值为 0 才是新手”规则。序列化时强制归零,避免历史异常值改变分层。 + const isDynamicNovice = form.strategy_version === DYNAMIC_STRATEGY && stage.stage === "novice"; + return { + min_recharge_30d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_30d_coins), + min_recharge_7d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_7d_coins), + stage: stage.stage, + tiers: (stage.tiers || []).map((tier) => ({ + enabled: tier.enabled !== false, + high_water_only: Boolean(tier.highWaterOnly), + multiplier: numberFromForm(tier.multiplier), + probability_percent: numberFromForm(tier.probabilityPercent), + tier_id: tier.tierId || "", + })), + }; + }), strategy_version: form.strategy_version, }; for (const field of numericFields) { @@ -316,9 +321,9 @@ export function validateLuckyGiftForm(form) { for (const [field, label] of payoutCapFields) { if (number(field) < 0) errors.push(`${label}不能小于 0`); } - validateRechargeStages(form.stages || [], errors); + validateRechargeStages(form.stages || [], errors, form.enabled); if (form.enabled) { - if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写 50 美元等值的大奖消费门槛"); + if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写用户日累计消费触发门槛(金币)"); for (const [field, label] of payoutCapFields) { if (number(field) <= 0) errors.push(`启用前必须填写${label}`); } @@ -345,11 +350,15 @@ function normalizeFormStages(stages, targetRTPPercent, strategyVersion) { : correctedDefaultTiers(stageKey, targetRTPPercent); const [default7d, default30d] = strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0]; + const isDynamicNovice = strategyVersion === DYNAMIC_STRATEGY && stageKey === "novice"; return { - min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d), - min_recharge_30d_coins: formNumber( - source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d, - ), + // 只在表单内保留兼容哨兵值;界面上的新手上界始终从普通门槛派生,不读取历史 novice 门槛。 + min_recharge_7d_coins: isDynamicNovice + ? "0" + : formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d), + min_recharge_30d_coins: isDynamicNovice + ? "0" + : formNumber(source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d), stage: stageKey, tiers: sourceTiers.map((tier) => ({ enabled: tier.enabled !== false, @@ -382,13 +391,10 @@ function defaultTier(tierID, multiplier, probabilityPercent) { return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID }; } -function validateRechargeStages(stages, errors) { - const novice = stages.find((stage) => stage.stage === "novice"); +function validateRechargeStages(stages, errors, requireComplete) { const normal = stages.find((stage) => stage.stage === "normal"); const advanced = stages.find((stage) => stage.stage === "advanced"); const threshold = (stage, field) => numberFromForm(stage?.[field]); - if (threshold(novice, "min_recharge_7d_coins") !== 0 || threshold(novice, "min_recharge_30d_coins") !== 0) - errors.push("新手阶段充值门槛必须为 0 / 0"); const normal7d = threshold(normal, "min_recharge_7d_coins"); const normal30d = threshold(normal, "min_recharge_30d_coins"); const advanced7d = threshold(advanced, "min_recharge_7d_coins"); @@ -397,15 +403,21 @@ function validateRechargeStages(stages, errors) { errors.push("充值阶段门槛不能小于 0"); return; } - if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 0"); + const isUnconfigured = [normal7d, normal30d, advanced7d, advanced30d].every((value) => value === 0); + // 新建的停用草稿可以全 0,避免猜测 App 金币价值;一旦开始填写或准备启用, + // 就必须遵守 owner 的二维下限契约:normal 至少一维大于 0,advanced 逐维不低且至少一维更高。 + if (!requireComplete && isUnconfigured) return; + if (normal7d === 0 && normal30d === 0) + errors.push( + requireComplete ? "启用前普通阶段至少一个最低充值门槛必须大于 0" : "普通阶段至少一个充值门槛必须大于 0", + ); if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d)) errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高"); } -function defaultRechargeThreshold(stage) { - if (stage === "novice") return [0, 0]; - if (stage === "normal") return [0, 1]; - return [1, 1]; +function defaultRechargeThreshold() { + // 充值门槛受 App 金币比例影响,新草稿不猜测 1 金币等于可用配置;运营填完后才能启用。 + return [0, 0]; } function defaultNumber(field, strategyVersion) { diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index a20ec5c..971a489 100644 --- a/ops-center/src/configForm.test.js +++ b/ops-center/src/configForm.test.js @@ -18,8 +18,8 @@ describe("ops center lucky gift dynamic_v3 form", () => { }); expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([ ["0", "0"], - ["0", "1"], - ["1", "1"], + ["0", "0"], + ["0", "0"], ]); expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ [5, 4, 86, 5], @@ -52,10 +52,15 @@ describe("ops center lucky gift dynamic_v3 form", () => { test("drops legacy dynamic initial seed when loading and publishing a new version", () => { const legacy = completeDynamicConfig(); legacy.initial_pool_coins = 1_000_000; + legacy.stages[0].min_recharge_7d_coins = 12_345; + legacy.stages[0].min_recharge_30d_coins = 67_890; const form = configToForm(legacy); expect(form.initial_pool_coins).toBe("0"); - expect(formToConfig(legacy, form).initial_pool_coins).toBe(0); + expect(form.stages[0]).toMatchObject({ min_recharge_7d_coins: "0", min_recharge_30d_coins: "0" }); + const output = formToConfig(legacy, form); + expect(output.initial_pool_coins).toBe(0); + expect(output.stages[0]).toMatchObject({ min_recharge_7d_coins: 0, min_recharge_30d_coins: 0 }); }); test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => { @@ -65,7 +70,9 @@ describe("ops center lucky gift dynamic_v3 form", () => { const enabled = { ...draft, enabled: true }; const errors = validateLuckyGiftForm(enabled); - expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛"); + expect(errors).toContain("启用前必须填写用户日累计消费触发门槛(金币)"); + expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0"); + expect(errors).toContain("高阶充值门槛必须逐维不低于正常阶段且至少一项更高"); expect(errors).toContain("启用前必须填写单次返奖上限"); expect(errors).toContain("启用前必须填写主播每日上限"); }); diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index 56a5129..9ba809e 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -583,6 +583,27 @@ body { font-weight: 800; } +.ops-stage-threshold-rule { + display: grid; + grid-column: 2 / -1; + gap: 2px; + min-width: 0; + border-left: 3px solid var(--primary); + background: var(--bg-card-strong, #f8fafc); + padding: var(--space-2) var(--space-3); +} + +.ops-stage-threshold-rule strong { + color: var(--text-primary); + font-size: 13px; +} + +.ops-stage-threshold-rule small { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} + .ops-tier-table { display: grid; min-width: 0; @@ -812,6 +833,10 @@ body { grid-column: 1 / -1; } + .ops-stage-threshold-rule { + grid-column: 1 / -1; + } + /* 小屏保留完整列头并让表格横向滚动,控件同时有阶段+行号标签,避免一列卡片失去字段关系。 */ .ops-tier-head, .ops-tier-row { From 2151a5a183e4397c54cadee1b11af178dede5da6 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 18:28:38 +0800 Subject: [PATCH 09/14] feat(ops-center): explain lucky gift configuration --- .../src/components/LuckyGiftConfigDialog.jsx | 9 +- .../components/LuckyGiftConfigSections.jsx | 99 ++++--- .../src/components/LuckyGiftFieldHelp.jsx | 276 ++++++++++++++++++ .../src/components/LuckyGiftStageDesigner.jsx | 47 ++- ops-center/src/styles/index.css | 103 ++++++- 5 files changed, 476 insertions(+), 58 deletions(-) create mode 100644 ops-center/src/components/LuckyGiftFieldHelp.jsx diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index bc7fd61..0cf4a16 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -88,7 +88,8 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", ...stage.tiers, { enabled: true, - // 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。 + // 10 倍以上先写入服务端要求的高倍率安全标记;实际开奖仍以实时余额和返奖上限 + // 判断是否付得起,前端默认值不能被解释成“高余额时一定会发”。 highWaterOnly: nextStageMultiplier(stage.tiers) >= 10, multiplier: String(nextStageMultiplier(stage.tiers)), probabilityPercent: "0", @@ -150,8 +151,8 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
- - + + - +
diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index 19c7a0e..97a78b1 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -3,6 +3,7 @@ import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import { useEffect, useMemo, useRef, useState } from "react"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { LuckyGiftFieldLabel } from "./LuckyGiftFieldHelp.jsx"; const BASE_SECTIONS = [ ["identity", "规则"], @@ -137,7 +138,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } } select size="small" value={form.app_code} @@ -150,19 +151,25 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } ))} ) : ( - + } + size="small" + value={form.app_code} + /> )} } size="small" value={form.pool_id} onChange={(event) => onChange("pool_id", event.target.value)} /> } select size="small" value={form.strategy_version} @@ -180,7 +187,12 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } onChange={(event) => onChange("enabled", event.target.checked)} /> } - label={form.enabled ? "发布后立即启用" : "发布后保持停用"} + label={ + + } />

修改奖池 ID 会发布到新奖池,不影响原奖池。

@@ -200,66 +212,73 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } } id="ops-config-heading-funds" ok={Math.abs(fundTotal - 100) < 0.0001} - title="资金与 RTP" + title="返奖与资金分配" />
- +

- 主播收益必须与该奖池实际送礼类型在各适用地区的返币比例一致,否则发布校验失败。 + 收礼对象收益必须与该奖池实际送礼类型在各适用地区的返币比例一致,否则发布校验失败。

@@ -277,51 +296,51 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } disabled form={form} helperText="请通过奖池水位操作注资" - label="初始奖池金币" + label="初始奖池金币(固定为 0)" name="initial_pool_coins" onChange={onChange} /> - +
+ } required size="small" value={form.jackpot_multipliers} @@ -348,34 +369,34 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } /> @@ -388,41 +409,41 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } data-config-section="limits" id="ops-config-section-limits" > - +
@@ -451,7 +472,7 @@ function NumberField({ disabled = false, form, helperText, label, name, onChange } required size="small" slotProps={{ htmlInput: { inputMode: "decimal", min: 0, step } }} diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx new file mode 100644 index 0000000..30c9e95 --- /dev/null +++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx @@ -0,0 +1,276 @@ +import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined"; +import Tooltip from "@mui/material/Tooltip"; + +// 字段说明集中维护,避免同一参数在不同分区出现互相矛盾的运营口径。 +// 每条都回答“控制什么、调大会怎样、发布时受什么限制”,Tooltip 只负责展示,不参与表单值或校验。 +const FIELD_HELP = { + app_code: { + summary: "决定这套幸运礼物规则属于哪个 App。不同 App 的配置、抽奖记录和奖池余额彼此隔离。", + adjust: "选择错 App 会把新版本发布到另一套业务数据中;编辑已有配置时不能在这里迁移 App。", + constraint: "发布前确认应用与实际送礼入口一致。", + }, + pool_id: { + summary: "决定礼物使用哪一组规则和哪一本奖池账。同一 App 内,送礼接口会按这个 ID 找到配置。", + adjust: "改成新 ID 会创建一组新的规则,不会搬走旧奖池里的金币,也不会改写旧抽奖记录。", + constraint: "fixed_v2 与 dynamic_v3 即使使用相同 ID,也会分别记账;ID 必须与礼物接入配置一致。", + }, + strategy_version: { + summary: + "选择开奖方式。fixed_v2 保留旧版固定规则;dynamic_v3 会根据奖池余额、充值阶段、大奖条件和返奖上限动态调整。", + adjust: "切到 dynamic_v3 会载入安全草稿并保持停用,确认全部金额口径后再发布。", + constraint: "dynamic_v3 使用独立奖池且从 0 开始,发布规则不会自动注入金币。", + }, + enabled: { + summary: "决定新版本发布后是否立即用于真实送礼。关闭时规则仍会保存,但不会触发幸运开奖。", + adjust: "首次配置或大幅调整时可先保持停用,核对资金比例和上限后再启用。", + constraint: "启用 dynamic_v3 前必须填完大奖消费门槛和所有返奖上限。", + }, + target_rtp_percent: { + summary: "表示基础奖档的配置目标。例如填 98,代表各阶段的“倍率 × 基础概率”预计返还约为 98%。", + adjust: "修改后,页面会重新分配各档基础概率;调高通常增加基础返奖,调低则相反。", + constraint: + "只能填 10%–99%,进入奖池的比例不能低于它。水位、充值加成、大奖和上限都会改变实际结果,它不是线上返还承诺。", + }, + control_band_percent: { + summary: "允许每个用户阶段的奖档组合与平均返还目标存在多大偏差。", + adjust: "调大更容易通过发布,但实际返还可能离目标更远;调小更严格,也更难配置出可发布的倍率组合。", + constraint: "必须大于 0 且不超过 5 个百分点,不会强行补偿单次或单个用户的结果。", + }, + pool_rate_percent: { + summary: "每笔幸运礼物消费中,有多少金币进入公共奖池,后续中奖从这里支付。", + adjust: "调高会让奖池补充更快;调低会留出更多平台或收礼份额,但奖池更容易不足。", + constraint: "不能低于平均返还目标;V3 中它与平台、收礼份额合计必须等于 100%,按整数金币向下取整。", + }, + profit_rate_percent: { + summary: "每笔消费中直接计为平台收入的比例,不进入奖池,也不发给收礼对象。", + adjust: "调高会增加平台份额,但必须相应减少奖池或收礼份额。", + constraint: "不能为负数;V3 的三项资金比例必须精确合计 100%,整数拆分后不能整除的余数也计入平台。", + }, + anchor_rate_percent: { + summary: "每笔消费中返给本次收礼对象的比例,通常是主播,但不一定等于房主;这部分不是用户中奖奖励。", + adjust: "调高会增加收礼对象所得,并相应减少奖池或平台份额。", + constraint: "按整数金币向下取整,且必须与钱包实际发给收礼对象的金币完全一致,否则 V3 会拒绝开奖。", + }, + settlement_window_wager: { + summary: "V3 按实际消费金币累计一轮返还观察窗口;某一抽跨过门槛时仍归旧窗口,下一抽才进入新窗口。", + adjust: "调大后更少重开窗口;调小后会更频繁清空全局窗口样本。V2 会先按礼物参考金额换算成窗口抽数。", + constraint: "必须大于 0。新窗口还没有消费样本时,不会触发返还偏低补偿大奖。", + }, + gift_price_reference: { + summary: "V2 用它把累计消费换算成“折算次数”,同时计算用户阶段和每轮窗口的抽数;它不是礼物真实售价。", + adjust: "调高后,同样消费折算成更少次数,用户升级更慢且 V2 窗口抽数更少;调低则相反。", + constraint: "必须大于 0。V3 的窗口直接看真实金币流水,用户阶段看 7 日和 30 日充值,不用它做这两项判断。", + }, + novice_max_equivalent_draws: { + summary: "fixed_v2 中,用户累计消费折算次数不超过这个值时使用新手奖档。", + adjust: "调高会让用户在新手阶段停留更久;调低会更早进入普通阶段。", + constraint: "不能小于 0,也不能高于普通阶段上限;dynamic_v3 不使用这个值分层。", + }, + normal_max_equivalent_draws: { + summary: "fixed_v2 中,用户超过新手上限后,不超过这个值时使用普通奖档;再超过才进入高阶奖档。", + adjust: "调高会推迟进入高阶阶段;调低会让用户更早进入高阶阶段。", + constraint: "不能小于新手阶段上限;dynamic_v3 不使用这个值分层。", + }, + initial_pool_coins: { + summary: "表示发布规则时是否顺便给奖池放入启动金币。dynamic_v3 已改为不允许规则自动注资。", + adjust: "该值固定为 0 且不可编辑,避免每次发新版本重复增加奖池余额。", + constraint: "需要启动资金时,请在配置列表使用“添加水位”,该操作会保留独立审计记录。", + }, + loss_streak_guarantee: { + summary: + "同一用户在当前礼物奖池连续多少次返还为 0 后,下一次普通开奖会先去掉 0 倍档。填 5 表示连续 5 次为 0 后,第 6 次普通开奖尝试正奖。", + adjust: "调小会更快触发体验保护;调大会降低触发频率并减少奖池压力。", + constraint: "必须大于 0;奖池不足或任一返奖上限不足时仍可返回 0,不会透支。", + }, + low_watermark_coins: { + summary: "本抽应进入奖池的金币先入账;入账后余额低于这个值时,系统会降低普通开奖中非 0 倍档的基础机会。", + adjust: "调高会更早开始保护奖池;调低会让正常中奖节奏保持更久。", + constraint: "必须大于 0,并且小于高余额分界线。", + }, + low_water_nonzero_factor_percent: { + summary: "奖池处于低余额时,普通开奖中非 0 倍档的基础机会保留多少。填 70 表示按原来的 70% 参与计算。", + adjust: "调低更保护奖池但中奖更少;调高更接近正常节奏但消耗更快。", + constraint: "必须大于 0% 且小于 100%,0 倍档接收剩余概率;它不会直接提高或降低两种大奖的触发机会。", + }, + high_watermark_coins: { + summary: "本抽应进入奖池的金币先入账;入账后余额高于这个值时,系统会提高普通开奖中非 0 倍档的基础机会。", + adjust: "调低会更早提高中奖机会;调高会要求奖池积累更多后才加速返奖。", + constraint: "必须严格大于低余额分界线;刚好等于该值时仍按正常状态处理。", + }, + high_water_nonzero_factor_percent: { + summary: "奖池处于高余额时,普通开奖中非 0 倍档的基础机会放大到多少。填 130 表示变为原来的 130%。", + adjust: "调高会更快释放奖池、增加正奖机会;调低则更接近正常节奏。", + constraint: "必须大于 100%;放大后会重新归一化,并继续受奖池和返奖上限限制;它不会直接提高大奖触发机会。", + }, + recharge_boost_window_ms: { + summary: "用户完成一次可验证充值后,普通开奖中非 0 倍档的机会加成持续多久。300000 毫秒等于 5 分钟。", + adjust: "调大会延长充值后的加成时间;调小会让加成更快结束。", + constraint: "必须大于 0,生效区间包含充值时刻但不包含结束时刻;外部接入没有可信充值事实,不会触发加成。", + }, + recharge_boost_factor_percent: { + summary: "充值加成期间,普通开奖中非 0 倍档的基础机会放大到多少。填 110 表示变为原来的 110%。", + adjust: "调高会增强充值后的中奖体验,也会增加奖池消耗;调低则更温和。", + constraint: "必须大于 100%,会与高低余额调整相乘;它只改变普通奖档,不会直接提高大奖触发机会。", + }, + jackpot_multipliers: { + summary: "列出大奖可能采用的倍数,例如 200、500、1000。实际奖励按本抽消费金额乘以倍数计算。", + adjust: "增加或调高倍数会放大奖励金额和资金压力;删除倍数会减少可选大奖。", + constraint: "用逗号分隔,必须都大于 1 且从小到大;系统会在所有付得起的大奖倍数中等机会选择一档。", + }, + jackpot_global_rtp_max_percent: { + summary: "当前 App、奖池、规则版本在本轮窗口内的平均返还不高于这条线时,才满足补偿大奖的全局条件。", + adjust: "调高会让补偿大奖更容易进入候选;调低会要求全局返还更低才触发。", + constraint: + "必须大于 0% 且不超过 100%。全局、用户当日和近 72 小时三项都必须有消费样本并全部通过,才会直接选择可支付大奖。", + }, + jackpot_user_day_rtp_max_percent: { + summary: "用户当天的平均返还不高于这条线时,才允许尝试补偿大奖。", + adjust: "调高会让更多用户满足条件;调低会把大奖更集中给当天返还明显偏低的用户。", + constraint: "按当前奖池的 UTC 自然日统计;没有消费样本时不触发。必须大于 0 且不能超过全局触发线。", + }, + jackpot_user_72h_rtp_max_percent: { + summary: "用户最近连续 72 小时的平均返还不高于这条线时,才允许尝试补偿大奖。", + adjust: "调高会放宽长期条件;调低会要求用户近三天整体返还更低。", + constraint: "按当前奖池滚动 72 小时统计,不是三个本地自然日;没有消费样本时不触发,且不能超过全局触发线。", + }, + max_jackpot_hits_per_user_day: { + summary: "限制同一用户在当前奖池的一个 UTC 自然日内最多真正拿到多少次大奖。", + adjust: "调高允许同一用户一天获得更多大奖;调低会更早停止该用户当天的大奖,不保证转给其他用户。", + constraint: "必须大于 0;达到次数后仍可参与普通奖档,尚未使用的消费资格会保留。", + }, + jackpot_spend_threshold_coins: { + summary: + "填写该 App 中约等于 50 美元的金币数。用户在当前奖池的同一 UTC 日内,每消费满一份门槛就保存一个后续大奖资格。", + adjust: "调高会要求消费更多才获得资格;调低会更频繁地产生资格。", + constraint: + "启用前必须大于 0,系统不会猜币价;一笔消费可产生多个资格,资格从下一抽开始用,未能支付时不会丢失。", + }, + max_single_payout: { + summary: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。", + adjust: "调高允许更大的单次奖励,也增加资金风险;调低会直接挡住更多高倍率结果。", + constraint: + "启用 V3 时必须大于 0;批量送礼按每个子抽分别判断。奖档超过任一剩余额度会整档挡住,不会裁剪成较小金额。", + }, + user_hourly_payout_cap: { + summary: "限制同一用户在当前 UTC 小时内累计最多返多少金币。", + adjust: "调高允许用户短时间获得更多奖励;调低会更早限制高频送礼用户。", + constraint: "启用 V3 时必须大于 0;按当前奖池和实际支付时间所在的 UTC 小时计算,到下一小时重新累计。", + }, + user_daily_payout_cap: { + summary: "限制同一用户在当前 UTC 自然日内累计最多返多少金币。", + adjust: "调高允许单个用户一天获得更多奖励;调低会更均匀地分散预算。", + constraint: "启用 V3 时必须大于 0;按当前奖池和实际支付时间所在的 UTC 日期累计。", + }, + device_daily_payout_cap: { + summary: "限制同一设备上的所有账号在一个 UTC 自然日内合计最多返多少金币,用来防止换账号绕过用户上限。", + adjust: "调高会放宽同设备多账号的总额度;调低会更严格地控制集中领取。", + constraint: "启用 V3 时必须大于 0,并按当前奖池隔离;必须拿到稳定设备 ID,外部接入也不能用用户 ID 代替。", + }, + room_hourly_payout_cap: { + summary: "限制同一房间内所有用户在当前 UTC 小时合计最多返多少金币。", + adjust: "调高允许热门房间更集中地产生奖励;调低会更早限制单房间的消耗速度。", + constraint: + "启用 V3 时必须大于 0,并按当前奖池隔离;外部接口没有真实房间,同一 App 的外部请求会共用一个虚拟房间额度。", + }, + anchor_daily_payout_cap: { + summary: "限制同一房主关联的所有房间和用户在一个 UTC 自然日内合计最多返多少金币。", + adjust: "调高允许奖励更集中在单个主播;调低会把预算分散到更多主播。", + constraint: + "启用 V3 时必须大于 0,并按当前奖池隔离;外部接口没有房主,同一 App 的外部请求会共用一个虚拟主播额度。", + }, + stage_novice: { + summary: + "新手阶段使用的一组基础奖档。V2 看累计消费折算次数;V3 中未同时达到普通阶段两项充值门槛的用户都会落在这里。", + adjust: "可通过倍率组合调整未达到普通门槛用户的体验,但预计平均返还仍要落在允许范围内。", + constraint: + "V3 的新手充值门槛固定为 0 / 0;外部接入当前也始终按新手阶段处理。每个阶段都必须保留启用的 0 倍档。", + }, + stage_normal: { + summary: "普通阶段使用的一组基础奖档。V2 看累计消费折算次数;V3 必须同时达到这里的 7 日和 30 日最低充值。", + adjust: "提高门槛会减少进入普通阶段的人数;降低门槛会让更多用户使用这组奖档。", + constraint: "至少一个充值门槛必须大于 0,并且不能高于高阶阶段对应门槛。", + }, + stage_advanced: { + summary: + "高阶阶段使用的一组基础奖档。V2 超过普通折算次数上限后进入;V3 会选择同时满足 7 日和 30 日门槛的最高阶段。", + adjust: "提高门槛会缩小高阶用户范围;降低门槛会扩大范围。", + constraint: "两项门槛都不能低于普通阶段,且至少一项必须更高。", + }, + min_recharge_7d_coins: { + summary: "用户最近 7 个 UTC 自然日(含当天)的充值金币至少达到多少,才满足当前阶段的一半条件。", + adjust: "调高会减少进入该阶段的人数;调低会扩大范围。", + constraint: "用户还必须同时达到 30 日门槛;新手阶段固定为 0。外部接入没有可信充值快照时按新手处理。", + }, + min_recharge_30d_coins: { + summary: "用户最近 30 个 UTC 自然日(含当天)的充值金币至少达到多少,才满足当前阶段的另一半条件。", + adjust: "调高会更看重长期充值;调低会让近期活跃用户更容易进入该阶段。", + constraint: "用户还必须同时达到 7 日门槛;新手阶段固定为 0。", + }, + tier_multiplier: { + summary: "这一档返还本抽消费金额的多少倍。0 倍表示没有返还,1 倍表示返还与本抽消费相同。", + adjust: "调高会增加这一档的奖励金额;系统会自动重算各档基础概率来尽量保持平均返还目标。", + constraint: "不能小于 0;10 倍及以上必须勾选“高倍率安全标记”,实际支付仍不能超过奖池和返奖上限。", + }, + tier_probability: { + summary: "表示在当前用户阶段中抽到这一档的基础机会,所有启用奖档必须合计 100%。", + adjust: "该值由平均返还目标和各档倍数自动计算,只读展示;修改倍数或目标后会重新计算。", + constraint: "水位、充值加成、奖池是否付得起和返奖上限会在开奖时继续调整或过滤候选。", + }, + tier_high_water_only: { + summary: "这是发布高倍率基础奖档前的安全确认标记;当前运行逻辑不会根据它判断奖池处于哪种水位。", + adjust: "勾选或取消不会直接放行、拦截奖档;实际是否能发仍看实时奖池余额和返奖上限。", + constraint: + "10 倍及以上必须勾选才能发布。不要把它当成独立的中奖开关;V3 的高低余额变化由上方两组中奖权重控制。", + }, + tier_enabled: { + summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。", + adjust: "关闭高倍率档会降低大额结果种类;重新开启后系统会重新计算整组概率。", + constraint: + "每个阶段启用档位的概率必须合计 100%,并至少保留一个 0 倍档;若同倍率另列在大奖倍率中,大奖机制仍可能选中它。", + }, +}; + +export function LuckyGiftFieldLabel({ field, label }) { + const help = FIELD_HELP[field]; + if (!help) return label; + return ( + } + > + + {label} + + + + ); +} + +function FieldHelpContent({ help, label }) { + return ( +
+ {label} +
+
+
作用
+
{help.summary}
+
+
+
调整影响
+
{help.adjust}
+
+
+
注意
+
{help.constraint}
+
+
+
+ ); +} diff --git a/ops-center/src/components/LuckyGiftStageDesigner.jsx b/ops-center/src/components/LuckyGiftStageDesigner.jsx index 7fb5d49..6e296c8 100644 --- a/ops-center/src/components/LuckyGiftStageDesigner.jsx +++ b/ops-center/src/components/LuckyGiftStageDesigner.jsx @@ -5,6 +5,7 @@ import IconButton from "@mui/material/IconButton"; import TextField from "@mui/material/TextField"; import { Button } from "@/shared/ui/Button.jsx"; import { formatDecimal } from "@/shared/utils/rtpProbability.js"; +import { LuckyGiftFieldLabel } from "./LuckyGiftFieldHelp.jsx"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; export function LuckyGiftStageDesigner({ @@ -56,10 +57,10 @@ export function LuckyGiftStageDesigner({ > {summary.tabLabel} - {summary.status} + {plainStageStatus(summary.status)} - 概率 {formatDecimal(summary.probability)}% · RTP {formatDecimal(summary.expected)}% + 概率 {formatDecimal(summary.probability)}% · 平均返还 {formatDecimal(summary.expected)}% ); @@ -75,9 +76,14 @@ export function LuckyGiftStageDesigner({ >
-

{activeSummary?.stageLabel}

+

+ +

@@ -92,10 +98,10 @@ export function LuckyGiftStageDesigner({ {strategyVersion === "dynamic_v3" ? (
- 充值门槛 + 进入本阶段所需充值 } size="small" slotProps={{ htmlInput: { min: 0, step: 1 } }} type="number" @@ -110,7 +116,7 @@ export function LuckyGiftStageDesigner({ /> } size="small" slotProps={{ htmlInput: { min: 0, step: 1 } }} type="number" @@ -128,10 +134,18 @@ export function LuckyGiftStageDesigner({
- 倍率 - 概率 % - 高水位 - 启用 + + + + + + + + + + + + 操作
{activeStageConfig.tiers.map((tier, index) => { @@ -154,7 +168,8 @@ export function LuckyGiftStageDesigner({ value={tier.multiplier} onChange={(event) => onTierChange(activeStageConfig.stage, index, { - // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 + // 10 倍及以上是服务端发布校验要求的高倍率标记;运行时仍会独立检查真实奖池余额, + // 不能把前端勾选本身当成资金足够或必然可发奖的依据。 highWaterOnly: Number(event.target.value) >= 10 || tier.highWaterOnly, multiplier: event.target.value, @@ -180,7 +195,7 @@ export function LuckyGiftStageDesigner({ checked={Boolean(tier.highWaterOnly)} disabled={Number(tier.multiplier) >= 10} size="small" - slotProps={{ input: { "aria-label": `${rowLabel}高水位限制` } }} + slotProps={{ input: { "aria-label": `${rowLabel}高倍率安全标记` } }} onChange={(event) => onTierChange(activeStageConfig.stage, index, { highWaterOnly: event.target.checked, @@ -221,3 +236,9 @@ export function LuckyGiftStageDesigner({ ); } + +function plainStageStatus(status) { + if (status === "RTP 偏离") return "平均返还偏离"; + if (status === "概率未闭合") return "概率合计不为 100%"; + return status || "待检查"; +} diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index 56a5129..e5bb8fa 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -445,6 +445,97 @@ body { line-height: 1.5; } +.ops-field-label { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 4px; + border-radius: 3px; + cursor: help; + white-space: nowrap; +} + +/* OutlinedInput 会在 notch 的 legend 中复制一次 label;保留其宽度计算,但隐藏复制品, + * 避免同一个说明入口在键盘 Tab 顺序中出现两次。 */ +.MuiOutlinedInput-notchedOutline legend .ops-field-label { + visibility: hidden; +} + +.ops-field-help-trigger { + display: inline-flex; + width: 16px; + height: 16px; + flex: 0 0 16px; + align-items: center; + justify-content: center; + border-radius: 50%; + color: var(--text-tertiary); + font-size: 14px; + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); +} + +.ops-field-label:hover .ops-field-help-trigger, +.ops-field-label:focus-visible .ops-field-help-trigger { + background: var(--primary-hover); + color: var(--primary); +} + +.ops-field-label:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--primary-border, #93c5fd); +} + +/* Tooltip 通过 portal 渲染,必须用自身 class 承载完整排版;字段内只保留问号,不牺牲表单密度。 */ +.MuiTooltip-tooltip.ops-field-help-tooltip { + max-width: min(420px, calc(100vw - 32px)); + border: 1px solid rgb(148 163 184 / 32%); + border-radius: var(--radius-control); + background: #0f172a; + box-shadow: 0 16px 40px rgb(15 23 42 / 28%); + color: #f8fafc; + padding: var(--space-3) var(--space-4); +} + +.ops-field-help-content { + display: grid; + gap: var(--space-2); +} + +.ops-field-help-content > strong { + color: #ffffff; + font-size: 13px; + line-height: 1.4; +} + +.ops-field-help-content dl { + display: grid; + gap: 7px; + margin: 0; +} + +.ops-field-help-content dl > div { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); + align-items: start; + gap: var(--space-2); +} + +.ops-field-help-content dt { + color: #93c5fd; + font-size: 12px; + font-weight: 800; + line-height: 1.55; +} + +.ops-field-help-content dd { + margin: 0; + color: #e2e8f0; + font-size: 12px; + line-height: 1.55; +} + .ops-stage-designer { display: grid; min-width: 0; @@ -599,8 +690,8 @@ body { min-width: 590px; grid-template-columns: minmax(100px, 1fr) - minmax(110px, 1fr) - 76px + minmax(124px, 1fr) + 96px 64px 56px; align-items: center; @@ -621,6 +712,14 @@ body { text-align: center; } +/* 奖档表头允许自然换行,说明图标不会在窄弹窗中挤到相邻列。 */ +.ops-tier-head .ops-field-label { + justify-content: center; + line-height: 1.2; + text-align: center; + white-space: normal; +} + .ops-tier-row { border-bottom: 1px solid var(--row-border); padding: 6px var(--space-3); From 52c54f6914ad460d3d36708c7086f8c31feafcd3 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 19:34:11 +0800 Subject: [PATCH 10/14] feat(admin): enhance resource and management workflows --- contracts/admin-openapi.json | 85 +++ databi/src/DatabiApp.test.jsx | 105 +++ databi/src/api.test.js | 18 + databi/src/social/SocialBiApp.jsx | 80 +++ databi/src/social/state.js | 11 +- databi/src/social/useSocialBiData.js | 11 +- databi/src/social/views/TeamKpiView.jsx | 65 +- databi/src/social/views/team-kpi-view.css | 55 +- databi/src/styles/social-v2.css | 19 + external-admin/index.html | 5 +- .../src/i18n/ExternalI18nProvider.test.jsx | 10 +- external-admin/src/i18n/emotionCache.js | 8 +- external-admin/src/i18n/emotionCache.test.js | 19 +- external-admin/src/i18n/messages.js | 49 +- .../src/layout/ExternalAdminLayout.jsx | 118 +++- .../src/layout/ExternalAdminLayout.test.jsx | 48 +- external-admin/src/main.jsx | 9 +- external-admin/src/pages/BannersPage.jsx | 2 +- external-admin/src/pages/BansPage.jsx | 2 +- .../src/pages/ChangePasswordPage.jsx | 4 +- external-admin/src/pages/GrantsPage.jsx | 4 +- external-admin/src/pages/LoginPage.jsx | 5 +- external-admin/src/pages/LoginPage.test.jsx | 6 +- external-admin/src/pages/OrganizationPage.jsx | 2 +- external-admin/src/pages/OverviewPage.jsx | 68 +- external-admin/src/pages/RoomsPage.jsx | 2 +- external-admin/src/pages/UsersPage.jsx | 4 +- external-admin/src/shared/PageComponents.jsx | 9 +- external-admin/src/styles/index.css | 599 ++++++++++++++++-- external-admin/src/theme.js | 69 ++ src/features/app-users/api.test.ts | 33 + src/features/app-users/api.ts | 23 + src/features/app-users/app-users.module.css | 7 +- .../components/AppUserDetailProvider.jsx | 4 +- .../components/AppUserDetailProvider.test.jsx | 89 ++- .../components/AppUserDetailView.jsx | 90 ++- .../app-users/pages/AppUserDetailPage.jsx | 2 +- .../pages/AppUserDetailPage.test.jsx | 38 +- src/features/resources/api.test.ts | 3 +- src/features/resources/api.ts | 1 + .../resources/hooks/useResourcePages.js | 233 +++++-- .../resources/hooks/useResourcePages.test.js | 43 ++ src/features/resources/mp4AlphaLayout.js | 6 +- .../resources/pages/ResourceListPage.jsx | 69 +- .../resources/pages/ResourceShopPage.jsx | 136 ++-- src/features/resources/resources.module.css | 42 +- src/features/resources/schema.js | 45 +- src/features/resources/schema.test.ts | 34 +- src/shared/api/generated/endpoints.ts | 8 + src/shared/api/generated/schema.d.ts | 57 +- src/shared/ui/Mp4Preview.jsx | 462 ++++++++++++++ src/shared/ui/Mp4Preview.module.css | 88 +++ src/shared/ui/UploadField.jsx | 75 ++- src/shared/ui/UploadField.module.css | 8 +- 54 files changed, 2665 insertions(+), 422 deletions(-) create mode 100644 external-admin/src/theme.js create mode 100644 src/shared/ui/Mp4Preview.jsx create mode 100644 src/shared/ui/Mp4Preview.module.css diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 3328903..b05573a 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -5018,6 +5018,47 @@ "x-permissions": ["resource-grant:revoke"] } }, + "/admin/users/{user_id}/resources/{entitlement_id}/revoke": { + "post": { + "operationId": "revokeAppUserResource", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { "type": "string" } + }, + { + "in": "path", + "name": "entitlement_id", + "required": true, + "schema": { "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["reason"], + "additionalProperties": false, + "properties": { + "reason": { "type": "string", "minLength": 1, "maxLength": 256 } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-grant:revoke", + "x-permissions": ["resource-grant:revoke"] + } + }, "/admin/resource-grants/resource": { "post": { "operationId": "grantResource", @@ -5055,6 +5096,50 @@ }, "post": { "operationId": "upsertResourceShopItems", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items"], + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "durationDays", + "effectiveFromMs", + "effectiveToMs", + "resourceId", + "sortOrder", + "status" + ], + "additionalProperties": false, + "properties": { + "coinPrice": { + "type": "integer", + "minimum": 0, + "description": "售卖规格金币价;新建时省略或传 0 使用资源列表价格,更新已有规格时省略或传 0 保留当前售价" + }, + "durationDays": { "type": "integer", "enum": [1, 3, 7] }, + "effectiveFromMs": { "type": "integer", "minimum": 0 }, + "effectiveToMs": { "type": "integer", "minimum": 0 }, + "resourceId": { "type": "integer", "minimum": 1 }, + "shopItemId": { "type": "integer", "minimum": 1 }, + "sortOrder": { "type": "integer", "minimum": 0 }, + "status": { "type": "string", "enum": ["active", "disabled"] } + } + } + } + } + } + } + } + }, "responses": { "200": { "$ref": "#/components/responses/EmptyResponse" diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index 60a7131..6373b28 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -369,6 +369,111 @@ test("routes databi social page to Social BI and loads real overview rows", asyn expect(screen.getAllByText("$900").length).toBeGreaterThan(0); }); +test("filters operation KPI by person and shows totals with App and region amounts", async () => { + window.history.pushState(null, "", "/databi/social/?view=team&operator=9"); + fetchSocialBiMaster.mockResolvedValue({ + access: { all: true, scopes: [] }, + apps: [ + { app_code: "aslan", app_name: "Aslan", kind: "legacy" }, + { app_code: "yumi", app_name: "Yumi", kind: "legacy" } + ], + operators: [ + { + account: "omar", + name: "Omar", + scopes: [{ app_code: "aslan", region_id: 11 }, { app_code: "yumi", region_id: 20 }], + team: "中东运营部", + team_id: 2, + user_id: 9 + } + ], + permissions: {}, + regions: [ + { app_code: "aslan", region_id: 11, region_name: "中东大区" }, + { app_code: "yumi", region_id: 20, region_name: "南亚大区" } + ] + }); + fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] }); + fetchSocialBiKpi.mockResolvedValue({ + items: [ + { + app_code: "aslan", + app_name: "Aslan", + month_recharge_usd_minor: 90_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 5000, + region_id: 11, + region_name: "中东大区", + team: "中东运营部" + }, + { + app_code: "yumi", + app_name: "Yumi", + month_recharge_usd_minor: 50_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 3000, + region_id: 20, + region_name: "南亚大区", + team: "中东运营部" + } + ], + operator_app_rows: [ + { + app_code: "aslan", + app_name: "Aslan", + month_recharge_usd_minor: 90_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 5000, + region_count: 1, + region_names: "中东大区", + team: "中东运营部" + }, + { + app_code: "yumi", + app_name: "Yumi", + month_recharge_usd_minor: 50_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 3000, + region_count: 1, + region_names: "南亚大区", + team: "中东运营部" + } + ], + period_month: "2026-06", + summary: { month_recharge_usd_minor: 140_000, operator_count: 1, range_recharge_usd_minor: 8000 } + }); + + render(); + await flushEffects(); + + expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ operatorUserId: 9 })); + expect(screen.getByRole("combobox", { name: "运营人员筛选" })).toHaveValue("Omar"); + expect(window.location.search).toContain("operator=9"); + const summary = screen.getByLabelText("充值金额汇总"); + expect(within(summary).getByText("Omar")).toBeTruthy(); + expect(within(summary).getByText("$80")).toBeTruthy(); + expect(within(summary).getByText("$1,400")).toBeTruthy(); + expect(screen.getByRole("cell", { name: "中东大区" })).toBeTruthy(); + expect(screen.getByRole("cell", { name: "南亚大区" })).toBeTruthy(); + + await act(async () => { + screen.getByRole("radio", { name: "按人员×App" }).click(); + }); + + expect(screen.getByRole("cell", { name: "中东大区" })).toBeTruthy(); + expect(screen.getByRole("cell", { name: "南亚大区" })).toBeTruthy(); + expect(screen.getAllByText("$50").length).toBeGreaterThan(0); + expect(screen.getAllByText("$30").length).toBeGreaterThan(0); +}); + test("renders social BI data requirements table with section filters and CSV export", async () => { window.history.pushState(null, "", "/databi/social/?view=table®ions=lalu:9,lalu:11"); fetchSocialBiMaster.mockResolvedValue({ diff --git a/databi/src/api.test.js b/databi/src/api.test.js index 62d7beb..3f3fffb 100644 --- a/databi/src/api.test.js +++ b/databi/src/api.test.js @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest"; import { fetchFilterOptions, fetchSocialBiFilterOptions, + fetchSocialBiKpi, fetchSocialBiOverview, fetchSocialBiRequirements, fetchStatisticsOverview, @@ -138,6 +139,23 @@ test("loads social BI overview with an explicit multi-region Finance scope", asy expect(requestURL.searchParams.get("region_ids")).toBe("9,11"); }); +test("loads social BI KPI for the selected operation user", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ items: [], summary: {} }))); + + await fetchSocialBiKpi({ + appCodes: ["aslan", "yumi"], + endMs: 20, + operatorUserId: 9, + startMs: 10, + statTz: "Asia/Shanghai" + }); + + const requestURL = new URL(String(fetch.mock.calls[0][0])); + expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/kpi"); + expect(requestURL.searchParams.get("app_codes")).toBe("aslan,yumi"); + expect(requestURL.searchParams.get("operator_user_id")).toBe("9"); +}); + test("omits app scope headers and query params for all databi apps", async () => { vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ updated_at_ms: 1 }))); diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index 04a7de3..1f79363 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -9,6 +9,8 @@ import PublicOutlined from "@mui/icons-material/PublicOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import RepeatOutlined from "@mui/icons-material/RepeatOutlined"; import TableChartOutlined from "@mui/icons-material/TableChartOutlined"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; import { ALL, DATE_PRESETS, @@ -40,6 +42,7 @@ const VIEWS = [ ]; const SocialBiContext = createContext(null); +const EMPTY_OPERATORS = []; export function useSocialBi() { const context = useContext(SocialBiContext); @@ -160,6 +163,7 @@ function TopBar({ data, filters, updateFilter, viewKey }) {
+ {viewKey === "team" ? : null} {rangeLabel(data.range)} {data.isLoading ? : null}
@@ -167,6 +171,82 @@ function TopBar({ data, filters, updateFilter, viewKey }) { ); } +function operatorIdentityLabel(operator) { + if (String(operator?.user_id) === ALL) { + return "全部运营人员"; + } + return operator?.name || operator?.account || `#${operator?.user_id}`; +} + +function filterOperatorOptions(options, { inputValue }) { + const keyword = String(inputValue || "").trim().toLowerCase(); + if (!keyword) { + return options; + } + return options.filter((operator) => + [operatorIdentityLabel(operator), operator.account, operator.team, operator.user_id] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(keyword) + ); +} + +function OperatorSelect({ data, filters, updateFilter }) { + const operators = data.master?.operators || EMPTY_OPERATORS; + const options = useMemo(() => [{ name: "全部运营人员", user_id: ALL }, ...operators], [operators]); + const selected = options.find((operator) => String(operator.user_id) === String(filters.operator)) || options[0]; + + useEffect(() => { + if (!data.master || filters.operator === ALL) { + return; + } + // 人员离职或数据权限收回后,旧分享链接里的 ID 会失效;回退到全部,避免页面显示一个无法解释的空筛选。 + if (!operators.some((operator) => String(operator.user_id) === String(filters.operator))) { + updateFilter("operator", ALL); + } + }, [data.master, filters.operator, operators, updateFilter]); + + return ( + String(option.user_id) === String(value.user_id)} + noOptionsText="无匹配运营人员" + options={options} + size="small" + value={selected} + onChange={(_, operator) => updateFilter("operator", String(operator?.user_id || ALL))} + renderOption={(props, operator) => { + const { key, ...optionProps } = props; + const details = [operator.account, operator.team].filter(Boolean).join(" · "); + return ( +
  • + {operatorIdentityLabel(operator)} + {details ? {details} : null} +
  • + ); + }} + renderInput={(params) => ( + + )} + /> + ); +} + function DatePresetControl({ filters, updateFilter }) { return (
    diff --git a/databi/src/social/state.js b/databi/src/social/state.js index bb41257..efdc835 100644 --- a/databi/src/social/state.js +++ b/databi/src/social/state.js @@ -1,4 +1,4 @@ -// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、趋势粒度, +// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度, // 并同步到 URL query,保证筛选状态可以直接作为链接分享。 import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js"; @@ -27,6 +27,7 @@ export function createDefaultFilters() { customEnd: "", customStart: "", granularity: "day", + operator: ALL, preset: "yesterday", regions: [ALL] }; @@ -116,7 +117,7 @@ export function bucketDay(statDay, granularity) { return statDay; } -const URL_KEYS = ["preset", "start", "end", "apps", "regions", "view", "granularity"]; +const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity"]; export function readStateFromURL(location = window.location) { const params = new URLSearchParams(location.search); @@ -133,6 +134,9 @@ export function readStateFromURL(location = window.location) { if (params.get("regions")) { filters.regions = params.get("regions").split(",").map((item) => item.trim()).filter(Boolean); } + if (/^\d+$/.test(params.get("operator") || "")) { + filters.operator = params.get("operator"); + } if (params.get("granularity") && GRANULARITIES.some((item) => item.key === params.get("granularity"))) { filters.granularity = params.get("granularity"); } @@ -168,6 +172,9 @@ export function writeStateToURL(filters, view, history = window.history, locatio if (filters.regions.length && !filters.regions.includes(ALL)) { params.set("regions", filters.regions.join(",")); } + if (filters.operator !== ALL) { + params.set("operator", filters.operator); + } if (filters.granularity !== "day") { params.set("granularity", filters.granularity); } diff --git a/databi/src/social/useSocialBiData.js b/databi/src/social/useSocialBiData.js index 07d68bb..c188285 100644 --- a/databi/src/social/useSocialBiData.js +++ b/databi/src/social/useSocialBiData.js @@ -69,6 +69,11 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f const selected = filters.apps.filter((appCode) => available.includes(appCode)); return selected.length ? selected : available; }, [filters.apps, master]); + // 运营人员筛选只接受 master 目录中的正整数 ID;URL 中的脏值不能直接进入后端统计查询。 + const operatorUserId = useMemo(() => { + const value = String(filters.operator || ""); + return /^\d+$/.test(value) ? Number(value) : undefined; + }, [filters.operator]); const overviewRequestScopes = useMemo( () => buildOverviewRequestScopes(appCodes, filters.regions, master?.regions || []), [appCodes, filters.regions, master?.regions] @@ -87,7 +92,9 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f ? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null); // KPI 会执行当前区间和自然月两组负责范围聚合,只在运营中心可见时请求,避免其他视图承担额外查询成本。 - const kpiRequest = includeKpi ? fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null); + const kpiRequest = includeKpi + ? fetchSocialBiKpi({ appCodes, endMs, operatorUserId, startMs, statTz: SOCIAL_BI_TZ }) + : Promise.resolve(null); // 默认全量只请求一次;用户显式选择区域时按 App 下发已选区域,让后端只补这些区域的 Finance 逐日渠道。 const overviewRequest = overviewRequestScopes.length ? Promise.all( @@ -144,7 +151,7 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f setErrors(nextErrors); setIsLoading(false); }); - }, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, overviewRequestScopes, range, refreshToken]); + }, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, operatorUserId, overviewRequestScopes, range, refreshToken]); const loadRequirements = useCallback( ({ newUserType, payerType, section, userRole } = {}) => { diff --git a/databi/src/social/views/TeamKpiView.jsx b/databi/src/social/views/TeamKpiView.jsx index 348d81a..fa3edd9 100644 --- a/databi/src/social/views/TeamKpiView.jsx +++ b/databi/src/social/views/TeamKpiView.jsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx"; import { formatCount, formatMoneyMinor, isBlank } from "../format.js"; +import { ALL } from "../state.js"; import "./team-kpi-view.css"; const DIMENSIONS = [ @@ -40,7 +41,7 @@ function operatorCountOf(items) { } export function TeamKpiView() { - const { isLoading, kpi } = useSocialBi(); + const { filters, isLoading, kpi, master } = useSocialBi(); const [dimension, setDimension] = useState("person"); const items = useMemo(() => kpi?.items || [], [kpi]); @@ -88,21 +89,15 @@ export function TeamKpiView() { return rows; }, [items]); - if (!items.length) { - return isLoading ? ( - - ) : ( -
    -
    - 暂无充值数据 - 先在用户管理为运营人员分配 App/区域数据范围 -
    -
    - ); + if (isLoading && !kpi) { + return ; } - const summary = kpi.summary || {}; + const summary = kpi?.summary || {}; const operatorCount = isBlank(summary.operator_count) ? operatorCountOf(items) : summary.operator_count; + const selectedOperator = filters.operator === ALL + ? null + : (master?.operators || []).find((operator) => String(operator.user_id) === String(filters.operator)); return (
    @@ -110,7 +105,7 @@ export function TeamKpiView() {
    充值排行 - {kpi.period_month} · {formatCount(operatorCount)} 名运营 + {kpi?.period_month || "--"} · {formatCount(operatorCount)} 名运营
    @@ -129,6 +124,7 @@ export function TeamKpiView() {
    +
    {dimension === "person" ? ( @@ -143,6 +139,36 @@ export function TeamKpiView() { ); } +function KpiSummary({ operator, operatorCount, summary }) { + const details = operator + ? [operator.account, operator.team].filter(Boolean).join(" · ") + : `${formatCount(operatorCount)} 名运营`; + return ( +
    +
    + {operator ? "当前人员" : "当前范围"} + {operator ? operatorIdentity(operator) : "全部运营人员"} + {details} +
    + + +
    + ); +} + +function operatorIdentity(operator) { + return operator?.name || operator?.account || `#${operator?.user_id}`; +} + +function SummaryAmount({ label, value }) { + return ( +
    + {label} + {formatMoneyMinor(value)} +
    + ); +} + function KpiSkeleton() { return (
    @@ -184,6 +210,7 @@ function OperatorAppTable({ rows }) { + {!rows.length ? : null} {rows.map((row, index) => { const item = row.item; return ( @@ -230,6 +257,7 @@ function PersonTable({ rows }) { + {!rows.length ? : null} {rows.map((row, index) => { const item = row.item; return ( @@ -279,6 +307,7 @@ function TeamTable({ rows }) { + {!rows.length ? : null} {rows.map((row, index) => ( @@ -295,6 +324,14 @@ function TeamTable({ rows }) { ); } +function EmptyTableRow({ colSpan }) { + return ( + + 当前无数据 + + ); +} + function OperatorCell({ item }) { return (
    diff --git a/databi/src/social/views/team-kpi-view.css b/databi/src/social/views/team-kpi-view.css index be703f6..448bb69 100644 --- a/databi/src/social/views/team-kpi-view.css +++ b/databi/src/social/views/team-kpi-view.css @@ -8,10 +8,57 @@ .sbi-kpi-board-scroll { max-height: 620px; - margin-top: 12px; border-radius: 0 0 var(--sbi-radius) var(--sbi-radius); } +.sbi-kpi-summary { + display: grid; + grid-template-columns: minmax(220px, 1fr) repeat(2, minmax(170px, 220px)); + margin-top: 12px; + border-top: 1px solid var(--sbi-border); + border-bottom: 1px solid var(--sbi-border); + background: #f7fafd; +} + +.sbi-kpi-summary-person, +.sbi-kpi-summary-amount { + display: grid; + gap: 3px; + min-width: 0; + padding: 14px 18px; +} + +.sbi-kpi-summary-amount { + align-content: center; + border-left: 1px solid var(--sbi-border); + text-align: right; +} + +.sbi-kpi-summary span, +.sbi-kpi-summary small { + color: var(--sbi-text-3); + font-size: 11.5px; +} + +.sbi-kpi-summary-person strong { + overflow: hidden; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sbi-kpi-summary-amount strong { + color: var(--sbi-accent); + font-size: 22px; + font-variant-numeric: tabular-nums; +} + +.sbi-table td.sbi-kpi-empty-cell { + height: 120px; + color: var(--sbi-text-3); + text-align: center; +} + .sbi-kpi-rank { display: inline-grid; width: 24px; @@ -67,3 +114,9 @@ gap: 14px; padding: 18px; } + +@media (max-width: 1280px) { + .sbi-kpi-summary { + grid-template-columns: minmax(180px, 1fr) repeat(2, minmax(150px, 190px)); + } +} diff --git a/databi/src/styles/social-v2.css b/databi/src/styles/social-v2.css index ff19fdc..05219de 100644 --- a/databi/src/styles/social-v2.css +++ b/databi/src/styles/social-v2.css @@ -344,6 +344,25 @@ font-size: 12.5px; } +.sbi-operator-filter { + width: 210px; +} + +.sbi-operator-option { + display: grid !important; + gap: 2px; +} + +.sbi-operator-option strong { + color: var(--sbi-text); + font-size: 13px; +} + +.sbi-operator-option small { + color: var(--sbi-text-3); + font-size: 11.5px; +} + .sbi-range-label { color: var(--sbi-text-3); font-size: 12.5px; diff --git a/external-admin/index.html b/external-admin/index.html index 60bbbf8..d5b05c8 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -3,8 +3,9 @@ - - HYApp External Admin + + Admin System +
    diff --git a/external-admin/src/i18n/ExternalI18nProvider.test.jsx b/external-admin/src/i18n/ExternalI18nProvider.test.jsx index abcb047..562e9df 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("HYApp External Admin"); + expect(screen.getByTestId("title")).toHaveTextContent("Admin System"); 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("إدارة HYApp الخارجية"); + 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("HYApp Harici Yönetim"); + expect(screen.getByTestId("title")).toHaveTextContent("Yönetim Sistemi"); 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("HYApp 外管后台"); + 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("HYApp 外管后台"); + expect(screen.getByTestId("title")).toHaveTextContent("管理系统"); }); }); diff --git a/external-admin/src/i18n/emotionCache.js b/external-admin/src/i18n/emotionCache.js index 7f86a56..cb5ada7 100644 --- a/external-admin/src/i18n/emotionCache.js +++ b/external-admin/src/i18n/emotionCache.js @@ -1,9 +1,13 @@ import createCache from "@emotion/cache"; import rtlPlugin from "@mui/stylis-plugin-rtl"; -import { prefixer } from "stylis"; +// Emotion compiles styles with its own bundled stylis (4.2); running the standalone +// stylis 4.4 `prefixer` over those foreign AST nodes crashes in lift() on +// ::placeholder/:read-only rules because 4.2 elements carry no `siblings` field. +// The RTL cache therefore only runs the direction-flipping plugin; vendor prefixing +// is left out, which modern mobile browsers no longer need. const externalLtrCache = createCache({ key: "external-ltr", prepend: true }); -const externalRtlCache = createCache({ key: "external-rtl", prepend: true, stylisPlugins: [prefixer, rtlPlugin] }); +const externalRtlCache = createCache({ key: "external-rtl", prepend: true, stylisPlugins: [rtlPlugin] }); export function externalEmotionCache(direction) { return direction === "rtl" ? externalRtlCache : externalLtrCache; diff --git a/external-admin/src/i18n/emotionCache.test.js b/external-admin/src/i18n/emotionCache.test.js index 62592e8..e861ab8 100644 --- a/external-admin/src/i18n/emotionCache.test.js +++ b/external-admin/src/i18n/emotionCache.test.js @@ -1,5 +1,9 @@ +import { CacheProvider } from "@emotion/react"; +import styled from "@emotion/styled"; import rtlPlugin from "@mui/stylis-plugin-rtl"; -import { compile, middleware, prefixer, serialize, stringify } from "stylis"; +import { render } from "@testing-library/react"; +import { createElement } from "react"; +import { compile, middleware, serialize, stringify } from "stylis"; import { describe, expect, test } from "vitest"; import { externalEmotionCache } from "./emotionCache.js"; @@ -13,7 +17,7 @@ describe("external admin RTL Emotion cache", () => { test("mirrors physical MUI-style declarations for Arabic", () => { const css = serialize( compile(".control{margin-left:8px;padding-right:12px;text-align:left}"), - middleware([prefixer, rtlPlugin, stringify]) + middleware([rtlPlugin, stringify]) ); expect(css).toContain("margin-right:8px"); @@ -21,4 +25,15 @@ describe("external admin RTL Emotion cache", () => { expect(css).toContain("text-align:right"); expect(css).not.toContain("margin-left:8px"); }); + + test("inserts ::placeholder rules through the RTL cache without crashing", () => { + // Regression: the standalone stylis 4.4 prefixer crashed in lift() when Emotion's + // bundled stylis 4.2 compiled a ::placeholder rule, unmounting the whole app in Arabic. + const Input = styled.input({ "&::placeholder": { color: "red", textAlign: "left" } }); + + expect(() => render( + createElement(CacheProvider, { value: externalEmotionCache("rtl") }, + createElement(Input, { placeholder: "مثال" })) + )).not.toThrow(); + }); }); diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js index c442286..e2865b1 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": "HYApp External Admin", - "app.shortTitle": "HYApp External Admin", + "app.title": "Admin System", + "app.shortTitle": "Admin System", "language.label": "Language", "common.actions": "Actions", "common.active": "Active", @@ -35,9 +35,9 @@ const en = { "common.save": "Save", "common.status": "Status", "common.user": "User", - "auth.account": "External admin account", - "auth.changePassword": "Change external admin password", - "auth.checkingSession": "Checking external admin session", + "auth.account": "Account", + "auth.changePassword": "Change password", + "auth.checkingSession": "Checking your session", "auth.confirmNewPassword": "Confirm new password", "auth.currentPassword": "Current password", "auth.fillAccountPassword": "Enter your account and password", @@ -48,7 +48,7 @@ const en = { "auth.loginFailed": "Sign-in failed", "auth.logout": "Sign out", "auth.newPassword": "New password", - "auth.noPermission": "This external admin account does not have access to this feature.", + "auth.noPermission": "This account does not have access to this feature.", "auth.password": "Password", "auth.passwordChangedFailed": "Failed to change password", "auth.passwordMismatch": "The new passwords do not match", @@ -56,12 +56,13 @@ const en = { "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": "Return to permission overview", + "auth.returnOverview": "Back to Home", "auth.savePassword": "Save password", "auth.sessionFailed": "Session verification failed", "auth.showPassword": "Show password", "nav.expand": "Open navigation", - "nav.overview": "Permission overview", + "nav.more": "More", + "nav.overview": "Home", "nav.users": "User management", "nav.bans": "Ban management", "nav.hosts": "Hosts", @@ -94,8 +95,10 @@ const en = { "capability.userLevelGrant": "Grant wealth/VIP levels", "capability.teamView": "My team", "overview.currentApp": "Current App", - "overview.account": "External admin account", + "overview.account": "Account", "overview.permissions": "Permissions enabled", + "overview.welcome": "Welcome back", + "overview.features": "Features", "overview.enabled": "Enabled", "overview.notEnabled": "Not enabled", "users.title": "User management", @@ -323,17 +326,17 @@ const en = { }; const zhCN = { - "app.title": "HYApp 外管后台", - "app.shortTitle": "HYApp 外管", + "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": "显示密码", - "nav.expand": "展开菜单", "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": "我的团队", + "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": "显示密码", + "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.enabled": "已开通", "overview.notEnabled": "未开通", + "overview.currentApp": "当前 App", "overview.account": "登录账号", "overview.permissions": "已开通权限", "overview.welcome": "欢迎回来", "overview.features": "功能入口", "overview.enabled": "已开通", "overview.notEnabled": "未开通", "users.title": "用户管理", "users.search": "用户 ID / 短 ID / 名称", "users.prettyId": "短 ID(靓号)", "users.countryGender": "国家/性别", "users.level": "等级", "users.levelSummary": "财富 {wealth} / 魅力 {charm} / VIP {vip}", "users.edit": "编辑", "users.grantLevel": "等级", "users.ban": "封禁", "users.updated": "用户信息已更新", "users.banned": "用户已封禁", "users.levelGranted": "用户等级已下发", "users.operationFailed": "用户操作失败", "users.editTitle": "编辑用户", "users.avatar": "头像", "users.name": "用户名称", "users.gender": "性别", "users.countryCode": "国家/地区码", "users.notSet": "未设置", "users.genderMale": "男", "users.genderFemale": "女", "users.genderUnknown": "未知", "users.banTitle": "封禁用户 {user}", "users.banDays": "封禁天数", "users.banForeverHint": "填 0 表示永久封禁", "users.banReason": "封禁原因", "users.levelTitle": "等级下发 {user}", "users.levelType": "等级类型", "users.wealthLevel": "财富等级", "users.charmLevel": "魅力等级", "users.vipLevel": "VIP 等级", "users.validDays": "有效天数", "users.validDaysMin": "有效期至少 1 天", "users.grantReason": "下发原因", "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": "团队规模超过接口上限,当前结果不完整", + "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": "确认发放", "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": "投放时间", @@ -347,12 +350,12 @@ const zhCN = { const ar = { ...en, - "app.title": "إدارة HYApp الخارجية", "app.shortTitle": "إدارة HYApp الخارجية", "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": "إظهار كلمة المرور", - "nav.expand": "فتح القائمة", "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": "فريقي", + "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": "إظهار كلمة المرور", + "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.enabled": "مفعّلة", "overview.notEnabled": "غير مفعّلة", + "overview.currentApp": "التطبيق الحالي", "overview.account": "الحساب", "overview.permissions": "الصلاحيات المفعّلة", "overview.welcome": "مرحبًا بعودتك", "overview.features": "الخدمات", "overview.enabled": "مفعّلة", "overview.notEnabled": "غير مفعّلة", "users.title": "إدارة المستخدمين", "users.search": "معرّف المستخدم / المعرّف المميز / الاسم", "users.prettyId": "المعرّف المميز", "users.countryGender": "الدولة / الجنس", "users.level": "المستوى", "users.levelSummary": "الثروة {wealth} / الجاذبية {charm} / VIP {vip}", "users.edit": "تعديل", "users.grantLevel": "المستوى", "users.ban": "حظر", "users.updated": "تم تحديث بيانات المستخدم", "users.banned": "تم حظر المستخدم", "users.levelGranted": "تم منح مستوى المستخدم", "users.operationFailed": "فشلت عملية المستخدم", "users.editTitle": "تعديل المستخدم", "users.avatar": "الصورة الشخصية", "users.name": "اسم المستخدم", "users.gender": "الجنس", "users.countryCode": "رمز الدولة/المنطقة", "users.notSet": "غير محدد", "users.genderMale": "ذكر", "users.genderFemale": "أنثى", "users.genderUnknown": "غير معروف", "users.banTitle": "حظر المستخدم {user}", "users.banDays": "مدة الحظر (بالأيام)", "users.banForeverHint": "أدخل 0 للحظر الدائم", "users.banReason": "سبب الحظر", "users.levelTitle": "منح مستوى إلى {user}", "users.levelType": "نوع المستوى", "users.wealthLevel": "مستوى الثروة", "users.charmLevel": "مستوى الجاذبية", "users.vipLevel": "مستوى VIP", "users.validDays": "الصلاحية (بالأيام)", "users.validDaysMin": "يجب ألا تقل الصلاحية عن يوم واحد", "users.grantReason": "سبب المنح", "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": "يتجاوز حجم الفريق حد الواجهة. النتائج الحالية غير مكتملة.", @@ -369,12 +372,12 @@ const ar = { const tr = { ...en, - "app.title": "HYApp Harici Yönetim", "app.shortTitle": "HYApp Harici Yönetim", "language.label": "Dil", + "app.title": "Yönetim Sistemi", "app.shortTitle": "Yönetim Sistemi", "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": "Harici yönetim hesabı", "auth.changePassword": "Harici yönetim parolasını 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 harici yönetim hesabını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": "Yetki özetine dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", - "nav.expand": "Menüyü aç", "nav.overview": "Yetki özeti", "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", + "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", + "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": "Harici yönetim hesabı", "overview.permissions": "Etkin yetkiler", "overview.enabled": "Etkin", "overview.notEnabled": "Etkin değil", + "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", "users.title": "Kullanıcı yönetimi", "users.search": "Kullanıcı ID / özel ID / ad", "users.prettyId": "Özel ID", "users.countryGender": "Ülke / cinsiyet", "users.level": "Seviye", "users.levelSummary": "Servet {wealth} / Cazibe {charm} / VIP {vip}", "users.edit": "Düzenle", "users.grantLevel": "Seviye", "users.ban": "Yasakla", "users.updated": "Kullanıcı bilgileri güncellendi", "users.banned": "Kullanıcı yasaklandı", "users.levelGranted": "Kullanıcı seviyesi verildi", "users.operationFailed": "Kullanıcı işlemi başarısız", "users.editTitle": "Kullanıcıyı düzenle", "users.avatar": "Avatar", "users.name": "Kullanıcı adı", "users.gender": "Cinsiyet", "users.countryCode": "Ülke/bölge kodu", "users.notSet": "Ayarlanmadı", "users.genderMale": "Erkek", "users.genderFemale": "Kadın", "users.genderUnknown": "Bilinmiyor", "users.banTitle": "{user} kullanıcısını yasakla", "users.banDays": "Yasak süresi (gün)", "users.banForeverHint": "Kalıcı yasak için 0 girin", "users.banReason": "Yasak nedeni", "users.levelTitle": "{user} kullanıcısına seviye ver", "users.levelType": "Seviye türü", "users.wealthLevel": "Servet seviyesi", "users.charmLevel": "Cazibe seviyesi", "users.vipLevel": "VIP seviyesi", "users.validDays": "Geçerlilik (gün)", "users.validDaysMin": "Geçerlilik en az 1 gün olmalıdır", "users.grantReason": "Verme nedeni", "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.", diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx index 3ff4361..a7fadfe 100644 --- a/external-admin/src/layout/ExternalAdminLayout.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -1,10 +1,11 @@ import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined"; import BadgeOutlined from "@mui/icons-material/BadgeOutlined"; -import DashboardOutlined from "@mui/icons-material/DashboardOutlined"; +import GridViewOutlined from "@mui/icons-material/GridViewOutlined"; import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; +import HomeOutlined from "@mui/icons-material/HomeOutlined"; import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import KeyOutlined from "@mui/icons-material/KeyOutlined"; import LogoutOutlined from "@mui/icons-material/LogoutOutlined"; -import MenuOutlined from "@mui/icons-material/MenuOutlined"; import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined"; import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined"; @@ -16,14 +17,12 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Divider from "@mui/material/Divider"; import Drawer from "@mui/material/Drawer"; -import IconButton from "@mui/material/IconButton"; import List from "@mui/material/List"; import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import Stack from "@mui/material/Stack"; import Toolbar from "@mui/material/Toolbar"; -import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; @@ -36,7 +35,7 @@ import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; const drawerWidth = 248; const navigation = [ - { icon: DashboardOutlined, labelKey: "nav.overview", path: "/overview" }, + { icon: HomeOutlined, labelKey: "nav.overview", path: "/overview" }, { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, labelKey: "nav.users", path: "/users" }, { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, labelKey: "nav.bans", path: "/bans" }, { capabilities: ["host:list"], icon: BadgeOutlined, labelKey: "nav.hosts", path: "/organization/hosts" }, @@ -50,31 +49,46 @@ const navigation = [ { capabilities: ["team:view"], icon: GroupsOutlined, labelKey: "nav.team", path: "/organization/team" } ]; +const isItemActive = (item, pathname) => pathname === item.path || pathname.startsWith(`${item.path}/`); + export function ExternalAdminLayout() { const theme = useTheme(); const mobile = useMediaQuery(theme.breakpoints.down("md")); - const [drawerOpen, setDrawerOpen] = useState(false); + const [sheetOpen, setSheetOpen] = useState(false); const { logout, session } = useExternalAuth(); - const { direction, t } = useExternalI18n(); + const { t } = useExternalI18n(); const location = useLocation(); const navigate = useNavigate(); const visibleNavigation = useMemo( () => navigation.filter((item) => !item.capabilities || hasAnyExternalCapability(session, item.capabilities)), [session] ); - const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path)); + const current = [...visibleNavigation].reverse().find((item) => isItemActive(item, location.pathname)); + // 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)); const handleLogout = async () => { + setSheetOpen(false); await logout(); navigate("/login", { replace: true }); }; - const drawer = ( + const accountCard = ( + <> + {String(session?.account || "A").slice(0, 1).toUpperCase()} + + {session?.account} + {session?.appName || session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} + + + ); + + const sidebar = ( - HY - {t("app.shortTitle")} + {t("app.shortTitle")} {session?.appName || session?.appCode} @@ -86,9 +100,8 @@ export function ExternalAdminLayout() { setDrawerOpen(false)} > @@ -96,47 +109,88 @@ export function ExternalAdminLayout() { ); })} + + {accountCard} + + + ); return ( + {/* Physical margins/anchors are written for LTR; the RTL Emotion cache flips them for Arabic. */} - {mobile ? setDrawerOpen(true)}> : null} - {current ? t(current.labelKey) : t("app.title")} + {mobile && !current ? t("app.shortTitle") : t(current?.labelKey || "app.shortTitle")} - + - {String(session?.account || "A").slice(0, 1).toUpperCase()} - {!mobile ? ( - - {session?.account} - {session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} - - ) : null} - - - + {!mobile ? accountCard : null} - setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} - {drawer} - - + {sidebar} + + + {/* Shown below the md breakpoint via index.css; sx display rules would lose to the stylesheet cascade. */} + + {tabs.map((item) => { + const Icon = item.icon; + return ( + + + {t(item.labelKey)} + + ); + })} + + + setSheetOpen(false)} + > + + {accountCard} + + {visibleNavigation.map((item) => { + const Icon = item.icon; + return ( + setSheetOpen(false)} + > + + {t(item.labelKey)} + + ); + })} + + + + + + + ); } diff --git a/external-admin/src/layout/ExternalAdminLayout.test.jsx b/external-admin/src/layout/ExternalAdminLayout.test.jsx index 525e67b..09f7da1 100644 --- a/external-admin/src/layout/ExternalAdminLayout.test.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.test.jsx @@ -1,7 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { CacheProvider } from "@emotion/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { externalEmotionCache } from "../i18n/emotionCache.js"; import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js"; import { ExternalAdminLayout } from "./ExternalAdminLayout.jsx"; @@ -26,17 +28,22 @@ describe("ExternalAdminLayout RTL", () => { viewport.mobile = false; }); - test("opens the mobile navigation from the right in Arabic", async () => { + test("shows the H5 tab bar and opens the more sheet in Arabic", async () => { const user = userEvent.setup(); viewport.mobile = true; renderLayout(); - await user.click(screen.getByRole("button", { name: "فتح القائمة" })); + const tabbar = document.querySelector(".external-tabbar"); + expect(tabbar).toBeInTheDocument(); + expect(within(tabbar).getByRole("link", { name: "الرئيسية" })).toBeInTheDocument(); - const openTemporaryDrawer = Array.from(document.querySelectorAll(".MuiDrawer-paper")).find((node) => node.closest(".MuiModal-root")); - expect(openTemporaryDrawer).toBeInTheDocument(); - expect(openTemporaryDrawer.parentElement).not.toHaveStyle({ visibility: "hidden" }); - expect(screen.getAllByRole("link", { name: "ملخص الصلاحيات" }).length).toBeGreaterThan(0); + await user.click(within(tabbar).getByRole("button", { name: "المزيد" })); + + const sheet = document.querySelector(".MuiDrawer-paper.external-sheet"); + expect(sheet).toBeInTheDocument(); + expect(sheet.closest(".MuiModal-root")).not.toBeNull(); + expect(within(sheet).getByRole("link", { name: "الرئيسية" })).toBeInTheDocument(); + expect(within(sheet).getByRole("button", { name: "تسجيل الخروج" })).toBeInTheDocument(); }); test("places the desktop navigation drawer on the right for Arabic", () => { @@ -45,28 +52,33 @@ describe("ExternalAdminLayout RTL", () => { expect(document.documentElement).toHaveAttribute("dir", "rtl"); expect(document.documentElement).toHaveAttribute("lang", "ar"); const permanentDrawer = document.querySelectorAll(".MuiDrawer-paper")[0]; - const generatedClass = Array.from(permanentDrawer.classList).find((className) => className.startsWith("css-")); + // Emotion prefixes generated classes with the cache key, so the RTL cache yields external-rtl-*. + const generatedClass = Array.from(permanentDrawer.classList).find((className) => className.startsWith("external-rtl-") || className.startsWith("css-")); const drawerRule = Array.from(document.styleSheets) .flatMap((sheet) => Array.from(sheet.cssRules || [])) .map((rule) => rule.cssText) .find((cssText) => cssText.includes(`.${generatedClass}`)); expect(drawerRule).toContain("right: 0px"); expect(drawerRule).toContain("border-left:"); - expect(screen.getAllByText("ملخص الصلاحيات").length).toBeGreaterThan(0); + expect(screen.getAllByText("الرئيسية").length).toBeGreaterThan(0); expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية"); }); }); function renderLayout() { + // The RTL Emotion cache is part of the production setup: physical styles are written + // for LTR and flipped by the cache, so the test must run the same pipeline. return render( - - - - }> - overview content
    } path="/overview" /> - - - - + + + + + }> + overview content
    } path="/overview" /> + + + + + ); } diff --git a/external-admin/src/main.jsx b/external-admin/src/main.jsx index 7cad114..2ee3265 100644 --- a/external-admin/src/main.jsx +++ b/external-admin/src/main.jsx @@ -1,12 +1,12 @@ import { CacheProvider } from "@emotion/react"; import CssBaseline from "@mui/material/CssBaseline"; -import { createTheme, ThemeProvider } from "@mui/material/styles"; +import { ThemeProvider } from "@mui/material/styles"; import React, { useMemo } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; -import { theme } from "@/theme.js"; import { ExternalAdminApp } from "./ExternalAdminApp.jsx"; +import { createExternalTheme } from "./theme.js"; import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx"; import { ExternalI18nProvider, useExternalI18n } from "./i18n/ExternalI18nProvider.jsx"; import { externalEmotionCache } from "./i18n/emotionCache.js"; @@ -25,10 +25,7 @@ createRoot(document.getElementById("external-admin-root")).render( function ExternalThemeRoot() { const { direction } = useExternalI18n(); // A direction-aware MUI theme keeps inputs, menus and dialog controls consistent with the Arabic document direction. - const externalTheme = useMemo(() => createTheme(theme, { - direction, - typography: { fontFamily: 'Inter, "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif' } - }), [direction]); + const externalTheme = useMemo(() => createExternalTheme(direction), [direction]); return ( diff --git a/external-admin/src/pages/BannersPage.jsx b/external-admin/src/pages/BannersPage.jsx index 29ec076..567aefa 100644 --- a/external-admin/src/pages/BannersPage.jsx +++ b/external-admin/src/pages/BannersPage.jsx @@ -21,7 +21,7 @@ export default function BannersPage() { const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]); const { data, error, loading, reload } = usePagedResource(load); const columns = useMemo(() => [ - { key: "banner", label: t("banners.banner"), render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, + { key: "banner", label: t("banners.banner"), render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, { key: "type", label: t("banners.type"), render: (item) => item.bannerType || "-" }, { key: "platform", label: t("banners.platform"), render: (item) => item.platform || t("common.all") }, { key: "sort", label: t("banners.sort"), render: (item) => item.sortOrder }, diff --git a/external-admin/src/pages/BansPage.jsx b/external-admin/src/pages/BansPage.jsx index 1efaf84..1d52c88 100644 --- a/external-admin/src/pages/BansPage.jsx +++ b/external-admin/src/pages/BansPage.jsx @@ -43,7 +43,7 @@ export default function BansPage() { }; const columns = useMemo(() => [ - { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, { key: "reason", label: t("bans.reason"), render: (user) => user.reason || "-" }, { key: "status", label: t("common.status"), render: () => }, diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx index ca8c517..436d82d 100644 --- a/external-admin/src/pages/ChangePasswordPage.jsx +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -1,6 +1,4 @@ -import KeyOutlined from "@mui/icons-material/KeyOutlined"; import Alert from "@mui/material/Alert"; -import Avatar from "@mui/material/Avatar"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; @@ -59,7 +57,7 @@ export default function ChangePasswordPage() { - {t("auth.changePassword")}{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} + {t("auth.changePassword")}{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} {error ? {error} : null} setForm({ ...form, oldPassword: event.target.value })} /> diff --git a/external-admin/src/pages/GrantsPage.jsx b/external-admin/src/pages/GrantsPage.jsx index 9f33fbd..4316ffb 100644 --- a/external-admin/src/pages/GrantsPage.jsx +++ b/external-admin/src/pages/GrantsPage.jsx @@ -91,8 +91,8 @@ export default function GrantsPage() { {canGrant ? ( {t("grants.availableResources")} - - {resourcesState.data.items.slice(0, 12).map((resource) => R{resource.name})} + + {resourcesState.data.items.slice(0, 12).map((resource) => R{resource.name})} {!resourcesState.loading && !resourcesState.data.items.length ? {t("common.noData")} : null} diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index b58ff84..683e340 100644 --- a/external-admin/src/pages/LoginPage.jsx +++ b/external-admin/src/pages/LoginPage.jsx @@ -1,8 +1,6 @@ -import LockOutlined from "@mui/icons-material/LockOutlined"; import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined"; import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; import Alert from "@mui/material/Alert"; -import Avatar from "@mui/material/Avatar"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; @@ -62,8 +60,7 @@ export default function LoginPage() { - - {t("app.title")} + {t("app.title")} {error ? {error} : null} diff --git a/external-admin/src/pages/LoginPage.test.jsx b/external-admin/src/pages/LoginPage.test.jsx index bac99f1..d62f9d4 100644 --- a/external-admin/src/pages/LoginPage.test.jsx +++ b/external-admin/src/pages/LoginPage.test.jsx @@ -20,9 +20,9 @@ describe("LoginPage", () => { const user = userEvent.setup(); const { container } = renderLogin(); - expect(screen.getByRole("heading", { name: "HYApp External Admin" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Admin System" })).toBeInTheDocument(); expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument(); - await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager"); + await user.type(screen.getByRole("textbox", { name: "Account" }), "fami-manager"); await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password"); await user.click(screen.getByRole("button", { name: "Sign in" })); @@ -34,7 +34,7 @@ describe("LoginPage", () => { auth.login.mockRejectedValueOnce({ code: 40100, message: "账号已禁用", status: 401 }); const { container } = renderLogin(); - await user.type(screen.getByRole("textbox", { name: "External admin account" }), "unknown-account"); + await user.type(screen.getByRole("textbox", { name: "Account" }), "unknown-account"); await user.type(container.querySelector('input[autocomplete="current-password"]'), "bad-password"); await user.click(screen.getByRole("button", { name: "Sign in" })); diff --git a/external-admin/src/pages/OrganizationPage.jsx b/external-admin/src/pages/OrganizationPage.jsx index 6f03ee5..56a8238 100644 --- a/external-admin/src/pages/OrganizationPage.jsx +++ b/external-admin/src/pages/OrganizationPage.jsx @@ -39,7 +39,7 @@ export default function OrganizationPage() { { key: "parent", label: t("organization.parentBd"), render: (item) => item.parentBdUserId || "-" }, { key: "status", label: t("common.status"), render: (item) => } ] : [ - { key: "identity", label: t("organization.member"), render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""} }, + { key: "identity", label: t("organization.member"), render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""} }, { key: "prettyId", label: t("users.prettyId"), render: (item) => item.prettyId || "-" }, { key: "country", label: t("organization.country"), render: (item) => item.country || "-" }, { key: "status", label: t("common.status"), render: (item) => } diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx index 965b803..9945f85 100644 --- a/external-admin/src/pages/OverviewPage.jsx +++ b/external-admin/src/pages/OverviewPage.jsx @@ -1,5 +1,21 @@ -import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined"; -import LockOutlined from "@mui/icons-material/LockOutlined"; +import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined"; +import BadgeOutlined from "@mui/icons-material/BadgeOutlined"; +import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import GppBadOutlined from "@mui/icons-material/GppBadOutlined"; +import GppGoodOutlined from "@mui/icons-material/GppGoodOutlined"; +import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; +import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import ListAltOutlined from "@mui/icons-material/ListAltOutlined"; +import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined"; +import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; +import MilitaryTechOutlined from "@mui/icons-material/MilitaryTechOutlined"; +import NumbersOutlined from "@mui/icons-material/NumbersOutlined"; +import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined"; +import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined"; +import SecurityOutlined from "@mui/icons-material/SecurityOutlined"; +import TrendingUpOutlined from "@mui/icons-material/TrendingUpOutlined"; +import WallpaperOutlined from "@mui/icons-material/WallpaperOutlined"; import Box from "@mui/material/Box"; import Card from "@mui/material/Card"; import CardActionArea from "@mui/material/CardActionArea"; @@ -13,28 +29,54 @@ import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabili import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { ExternalPage } from "../shared/PageComponents.jsx"; +const CAPABILITY_ICONS = { + "agency:list": ApartmentOutlined, + "banner:create": ImageOutlined, + "bd-manager:list": SecurityOutlined, + "bd:list": GroupsOutlined, + "host:list": BadgeOutlined, + "pretty-id:grant": NumbersOutlined, + "privilege:grant": CardGiftcardOutlined, + "privilege:list": ListAltOutlined, + "room-background:grant": WallpaperOutlined, + "room:list": MeetingRoomOutlined, + "room:update": EditOutlined, + "super-admin:list": SecurityOutlined, + "team:view": GroupsOutlined, + "user-ban:list": PersonOffOutlined, + "user-level:grant": TrendingUpOutlined, + "user-title:grant": MilitaryTechOutlined, + "user:ban": GppBadOutlined, + "user:list": PeopleOutlineOutlined, + "user:unban": GppGoodOutlined, + "user:update": ManageAccountsOutlined +}; + export default function OverviewPage() { const { session } = useExternalAuth(); const { t } = useExternalI18n(); + const enabledCount = EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length; return ( - - - - {t("overview.currentApp")}{session.appName || session.appCode} ({session.appCode}) - {t("overview.account")}{session.account} - {t("overview.permissions")}{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} - - - + + {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); + const Icon = CAPABILITY_ICONS[item.code] || ListAltOutlined; const content = ( - - {enabled ? : } + + {t(item.labelKey)} diff --git a/external-admin/src/pages/RoomsPage.jsx b/external-admin/src/pages/RoomsPage.jsx index 63c883e..89e2fb5 100644 --- a/external-admin/src/pages/RoomsPage.jsx +++ b/external-admin/src/pages/RoomsPage.jsx @@ -29,7 +29,7 @@ export default function RoomsPage() { const canEdit = hasExternalCapability(session, "room:update"); const columns = useMemo(() => [ - { key: "room", label: t("rooms.room"), render: (item) => R{item.title || "-"}ID {item.id} }, + { key: "room", label: t("rooms.room"), render: (item) => R{item.title || "-"}ID {item.id} }, { key: "owner", label: t("rooms.owner"), render: (item) => item.ownerName || "-" }, { key: "region", label: t("rooms.visibleRegion"), render: (item) => item.visibleRegionId || t("rooms.allRegions") }, { key: "status", label: t("common.status"), render: (item) => }, diff --git a/external-admin/src/pages/UsersPage.jsx b/external-admin/src/pages/UsersPage.jsx index 020c76d..36fa92d 100644 --- a/external-admin/src/pages/UsersPage.jsx +++ b/external-admin/src/pages/UsersPage.jsx @@ -49,12 +49,12 @@ export default function UsersPage() { }; const columns = useMemo(() => [ - { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, { key: "country", label: t("users.countryGender"), render: (user) => `${user.country || "-"} / ${translatedGender(user.gender, t)}` }, { key: "level", label: t("users.level"), render: (user) => t("users.levelSummary", { wealth: user.wealthLevel, charm: user.charmLevel, vip: user.vipLevel }) }, { key: "status", label: t("common.status"), render: (user) => }, - { key: "actions", label: t("common.actions"), render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } + { key: "actions", label: t("common.actions"), render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } ], [canBan, canEdit, canGrantLevel, t]); return ( diff --git a/external-admin/src/shared/PageComponents.jsx b/external-admin/src/shared/PageComponents.jsx index fa1a04e..c6f7dfb 100644 --- a/external-admin/src/shared/PageComponents.jsx +++ b/external-admin/src/shared/PageComponents.jsx @@ -21,11 +21,11 @@ import { translateExternalError } from "../i18n/errors.js"; export function ExternalPage({ actions, children, title }) { return ( - + {title} - {actions ? {actions} : null} + {actions ? {actions} : null} {children} @@ -55,7 +55,8 @@ export function ExternalPageState({ error, loading, onRetry }) { export function ResponsiveDataList({ columns, emptyText, items, rowKey }) { const theme = useTheme(); - const mobile = useMediaQuery(theme.breakpoints.down("sm")); + // H5 is the primary surface: anything below desktop gets record cards instead of a sideways-scrolling table. + const mobile = useMediaQuery(theme.breakpoints.down("md")); const { t } = useExternalI18n(); if (!items.length) { @@ -113,7 +114,7 @@ export function PagePagination({ page, pageSize, total, onChange }) { const { t } = useExternalI18n(); const totalPages = Math.max(1, Math.ceil(total / pageSize)); return ( - + {t("pagination.total", { count: total })} diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index df45aae..c61ea93 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -1,3 +1,104 @@ +/* + * HY Partner Center — black & gold identity. + * This bundle loads after @/styles/tokens.css, so the :root block below re-skins + * every shared token for this entry only (the internal admin keeps its light theme). + * All directional rules use CSS logical properties because the app also runs RTL (Arabic). + */ + +:root { + --bg-page: #0d0d11; + --bg-sidebar: #101015; + --bg-header: rgba(14, 14, 19, 0.88); + --bg-card: #16161c; + --bg-card-strong: #1e1e26; + --bg-hover: rgba(214, 181, 110, 0.08); + --bg-input: rgba(255, 255, 255, 0.035); + --bg-input-strong: rgba(255, 255, 255, 0.07); + --bg-flyout: rgba(24, 24, 31, 0.98); + + --border: rgba(214, 181, 110, 0.2); + --border-soft: rgba(214, 181, 110, 0.12); + --row-border: rgba(214, 181, 110, 0.1); + --detail-border: rgba(214, 181, 110, 0.12); + + --primary: #d6b56e; + --primary-strong: #e4ca8c; + --primary-hover: rgba(214, 181, 110, 0.1); + --primary-surface: rgba(214, 181, 110, 0.12); + --primary-surface-strong: rgba(214, 181, 110, 0.16); + --primary-border: rgba(214, 181, 110, 0.45); + --primary-border-strong: rgba(214, 181, 110, 0.62); + --primary-border-active: rgba(214, 181, 110, 0.75); + --brand-secondary: #a8823f; + --brand-gradient: linear-gradient(135deg, #efdcab 0%, #d6b56e 45%, #a8823f 100%); + --brand-shadow: 0 12px 28px rgba(0, 0, 0, 0.55), 0 2px 10px rgba(214, 181, 110, 0.28); + --active-gradient: linear-gradient(90deg, rgba(214, 181, 110, 0.95), rgba(168, 130, 63, 0.4)); + --active-inset: inset 0 0 0 1px rgba(214, 181, 110, 0.24); + + --success: #66bd8b; + --success-surface: rgba(102, 189, 139, 0.12); + --success-border: rgba(102, 189, 139, 0.36); + --success-border-strong: rgba(102, 189, 139, 0.72); + --success-glow: rgba(102, 189, 139, 0.26); + --warning: #d9a441; + --warning-surface: rgba(217, 164, 65, 0.12); + --warning-border: rgba(217, 164, 65, 0.36); + --warning-glow: rgba(217, 164, 65, 0.26); + --danger: #e07b6d; + --danger-surface: rgba(224, 123, 109, 0.12); + --danger-border: rgba(224, 123, 109, 0.34); + --danger-border-strong: rgba(224, 123, 109, 0.72); + --danger-glow: rgba(224, 123, 109, 0.26); + --info: #9fb4cc; + --info-surface: rgba(159, 180, 204, 0.12); + --info-surface-strong: rgba(159, 180, 204, 0.18); + --info-border: rgba(159, 180, 204, 0.34); + --stopped: #8c8577; + --neutral-surface: rgba(255, 255, 255, 0.05); + --neutral-surface-strong: rgba(255, 255, 255, 0.09); + --neutral-border: rgba(255, 255, 255, 0.14); + --admin-tag-bg: var(--neutral-surface); + --admin-tag-border: var(--neutral-border); + --admin-tag-color: var(--text-secondary); + + --chart-grid: rgba(214, 181, 110, 0.12); + --chart-tooltip-bg: #1e1e26; + --chart-tooltip-border: rgba(214, 181, 110, 0.3); + --chart-tooltip-text: #f0e9d8; + + --text-primary: #f0e9d8; + --text-secondary: #b8ad92; + --text-tertiary: #8c836d; + --active-contrast: #1a1206; + + --shadow-soft: 0 12px 28px rgba(0, 0, 0, 0.45); + --shadow-panel: 0 20px 50px rgba(0, 0, 0, 0.55); + --shadow-dialog: 0 30px 90px rgba(0, 0, 0, 0.7); + --shadow-drawer: 0 -18px 60px rgba(0, 0, 0, 0.6); + --overlay-scrim: rgba(4, 4, 7, 0.66); + --focus-ring: rgba(214, 181, 110, 0.26); + --login-gradient: radial-gradient(120% 70% at 50% 0%, rgba(214, 181, 110, 0.12), transparent 60%); + --skeleton-surface: rgba(255, 255, 255, 0.045); + --skeleton-shine: rgba(214, 181, 110, 0.12); + --skeleton-gradient: linear-gradient(90deg, transparent, var(--skeleton-shine), transparent); + + --font-display: "Didot", "Bodoni MT", "Playfair Display", Georgia, "Times New Roman", serif; + --tabbar-height: 62px; +} + +/* Bigger touch targets on handsets; 16px inputs also stop iOS Safari focus-zoom. */ +@media (max-width: 899px) { + :root { + --control-height: 44px; + --control-font-size: 16px; + --control-line-height: 22px; + --control-padding-x: 14px; + --admin-font-size: 15px; + --admin-line-height: 22px; + --radius-control: 10px; + } +} + html, body, #external-admin-root { @@ -6,41 +107,69 @@ body, } body { - background: var(--bg-page); + background: + radial-gradient(1100px 460px at 50% -8%, rgba(214, 181, 110, 0.08), transparent 62%), + radial-gradient(900px 500px at 100% 110%, rgba(214, 181, 110, 0.045), transparent 55%), + var(--bg-page); + background-attachment: fixed; color: var(--text-primary); font-family: var(--font-system); + -webkit-tap-highlight-color: transparent; } +::selection { + background: rgba(214, 181, 110, 0.32); + color: #fdf9ef; +} + +* { + scrollbar-width: thin; + scrollbar-color: rgba(214, 181, 110, 0.28) transparent; +} + +/* --------------------------------------------------------------------------- + * Login / password screens + * ------------------------------------------------------------------------- */ + .external-login-page { min-height: 100dvh; display: grid; place-items: center; padding: max(24px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); - background: radial-gradient(circle at 50% 0, rgba(37, 99, 235, 0.13), transparent 42%), var(--bg-page); + background: + var(--login-gradient), + radial-gradient(700px 380px at 50% 108%, rgba(214, 181, 110, 0.07), transparent 60%), + var(--bg-page); } .external-login-card { width: min(100%, 420px); border: 1px solid var(--border); + border-radius: 20px !important; + background: + linear-gradient(180deg, rgba(214, 181, 110, 0.05), transparent 34%), + var(--bg-card) !important; box-shadow: var(--shadow-panel); } .external-login-card .MuiCardContent-root { display: grid; - gap: 28px; - padding: 32px; + gap: 26px; + padding: 30px 28px 34px; +} + +.external-login-title { + font-family: var(--font-display) !important; + font-size: 24px !important; + letter-spacing: 0.05em; + color: var(--primary-strong) !important; + padding-top: 6px; } .external-language-switcher { min-width: 140px !important; } -.external-login-avatar, -.external-logo-mark { - background: var(--brand-gradient) !important; - color: #fff; -} - .external-full-state { min-height: 100dvh; display: grid; @@ -50,34 +179,61 @@ body { padding: 24px; } +/* --------------------------------------------------------------------------- + * App shell + * ------------------------------------------------------------------------- */ + .external-shell, .external-main { min-height: 100dvh; } +.external-main { + background: transparent; +} + .external-header { - border-bottom: 1px solid var(--border); - background: rgba(255, 255, 255, 0.96) !important; - backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-soft); + background: var(--bg-header) !important; + backdrop-filter: blur(16px) saturate(130%); + -webkit-backdrop-filter: blur(16px) saturate(130%); + color: var(--text-primary) !important; + padding-top: env(safe-area-inset-top); } .external-header .MuiToolbar-root { - min-height: 64px; + min-height: 60px; gap: 10px; } +.external-toolbar-spacer { + min-height: calc(60px + env(safe-area-inset-top)) !important; +} + +.external-header-title { + font-family: var(--font-display) !important; + font-size: 17px !important; + letter-spacing: 0.03em; +} + .external-user-avatar { width: 34px !important; height: 34px !important; font-size: 13px !important; - background: var(--primary) !important; + background: var(--brand-gradient) !important; + color: var(--active-contrast) !important; + font-weight: 700; } +/* Desktop sidebar */ + .external-sidebar { min-height: 100%; display: flex; flex-direction: column; - background: var(--bg-sidebar); + background: + linear-gradient(180deg, rgba(214, 181, 110, 0.05), transparent 18%), + var(--bg-sidebar); } .external-logo { @@ -86,41 +242,44 @@ body { display: flex; align-items: center; gap: 12px; - padding: 12px 20px; + padding: 14px 20px; } -.external-logo-mark { - width: 38px; - height: 38px; - display: grid; - place-items: center; - border-radius: 10px; - font-weight: 800; - box-shadow: var(--brand-shadow); +.external-logo-name { + font-family: var(--font-display) !important; + letter-spacing: 0.05em; } .external-nav { padding: 12px !important; overflow-y: auto; + flex: 1; } .external-nav .MuiListItemButton-root { min-height: 44px; margin-bottom: 4px; - border-radius: 8px; + border-radius: 10px; color: var(--text-secondary); - transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard), transform var(--motion-base) var(--ease-emphasized); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); } .external-nav .MuiListItemButton-root:hover { background: var(--bg-hover); + color: var(--text-primary); } .external-nav .MuiListItemButton-root.Mui-selected, .external-nav .MuiListItemButton-root.Mui-selected:hover { - background: var(--primary-surface-strong); - color: var(--primary); + background: linear-gradient(90deg, rgba(214, 181, 110, 0.16), rgba(214, 181, 110, 0.05)); + color: var(--primary-strong); font-weight: 700; + box-shadow: inset 3px 0 0 0 var(--primary); +} + +[dir="rtl"] .external-nav .MuiListItemButton-root.Mui-selected, +[dir="rtl"] .external-nav .MuiListItemButton-root.Mui-selected:hover { + box-shadow: inset -3px 0 0 0 var(--primary); } .external-nav .MuiListItemIcon-root { @@ -128,10 +287,158 @@ body { color: inherit; } -.external-main { - background: var(--bg-page); +.external-sidebar-footer { + padding: 14px 16px calc(14px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--border-soft); + display: grid; + gap: 10px; } +/* --------------------------------------------------------------------------- + * Mobile bottom tab bar + "more" sheet + * ------------------------------------------------------------------------- */ + +.external-tabbar { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 1100; + display: none; + align-items: stretch; + min-height: var(--tabbar-height); + padding: 6px max(8px, env(safe-area-inset-right)) max(6px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); + background: var(--bg-header); + backdrop-filter: blur(16px) saturate(130%); + -webkit-backdrop-filter: blur(16px) saturate(130%); + border-top: 1px solid var(--border-soft); +} + +.external-tab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + min-height: 50px; + padding: 4px 2px; + border: 0; + border-radius: 12px; + background: none; + color: var(--text-tertiary); + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-decoration: none; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.external-tab .MuiSvgIcon-root { + font-size: 22px; +} + +.external-tab-label { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.external-tab.is-active { + color: var(--primary-strong); +} + +.external-tab.is-active .MuiSvgIcon-root { + filter: drop-shadow(0 0 10px rgba(214, 181, 110, 0.45)); +} + +.external-tab:active { + background: var(--bg-hover); +} + +/* Bottom sheet (mobile navigation drawer + dialogs share the look) */ + +.external-sheet.MuiDrawer-paper { + border-radius: 22px 22px 0 0; + border-top: 1px solid var(--primary-border); + background: + linear-gradient(180deg, rgba(214, 181, 110, 0.06), transparent 30%), + var(--bg-card); + background-color: var(--bg-card); + box-shadow: var(--shadow-drawer); + padding: 10px 18px calc(18px + env(safe-area-inset-bottom)); + max-height: 86dvh; +} + +.external-sheet-handle { + width: 44px; + height: 4px; + margin: 2px auto 14px; + border-radius: 999px; + background: rgba(214, 181, 110, 0.35); +} + +.external-sheet-account { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + margin-bottom: 14px; + border: 1px solid var(--border-soft); + border-radius: 14px; + background: linear-gradient(135deg, rgba(214, 181, 110, 0.1), rgba(214, 181, 110, 0.02)); +} + +.external-sheet-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + overflow-y: auto; + padding-bottom: 4px; +} + +.external-sheet-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 76px; + padding: 10px 4px; + border-radius: 14px; + color: var(--text-secondary); + font-size: 12px; + text-align: center; + text-decoration: none; + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.external-sheet-item .MuiSvgIcon-root { + font-size: 24px; + color: var(--primary); +} + +.external-sheet-item:active, +.external-sheet-item.is-active { + background: var(--primary-surface); + color: var(--primary-strong); +} + +.external-sheet-item span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* --------------------------------------------------------------------------- + * Page scaffolding + * ------------------------------------------------------------------------- */ + .external-page { display: grid; gap: 20px; @@ -139,12 +446,17 @@ body { animation: external-page-enter var(--motion-slow) var(--ease-emphasized) both; } +.external-page > .MuiStack-root:first-child h1 { + font-family: var(--font-display); + letter-spacing: 0.03em; +} + .external-session-card, .external-table-container, .external-filter-bar, .external-form-panel, .external-empty { - border: 1px solid var(--border); + border: 1px solid var(--border-soft); border-radius: var(--radius-card); background: var(--bg-card); } @@ -158,32 +470,6 @@ body { min-width: min(100%, 220px); } -.external-capability-grid { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 12px; -} - -.external-capability-card { - min-height: 150px; - opacity: 0.68; -} - -.external-capability-card.is-enabled { - opacity: 1; -} - -.external-capability-card.is-enabled:hover { - border-color: var(--primary-border); - transform: translateY(-2px); - box-shadow: var(--shadow-soft); -} - -.external-capability-card .MuiCardActionArea-root, -.external-capability-card .MuiCardContent-root { - height: 100%; -} - .external-table-container { overflow-x: auto; } @@ -192,11 +478,23 @@ body { background: var(--bg-card-strong); } +.external-table-container .MuiTableHead-root .MuiTableCell-root { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .external-table-container .MuiTableCell-root { white-space: nowrap; border-color: var(--row-border); } +.external-table-container .MuiTableRow-hover:hover { + background: var(--bg-hover) !important; +} + .external-empty { min-height: 200px; display: grid; @@ -217,31 +515,151 @@ body { place-items: center; } +/* Mobile record cards */ + .external-mobile-card .external-mobile-row { display: grid; - grid-template-columns: minmax(92px, 0.38fr) minmax(0, 1fr); + grid-template-columns: minmax(92px, 0.36fr) minmax(0, 1fr); align-items: start; gap: 12px; - padding: 10px 0; + padding: 11px 0; border-bottom: 1px solid var(--row-border); } .external-mobile-card .external-mobile-row:last-child { border-bottom: 0; + padding-bottom: 2px; } +.external-mobile-card .external-mobile-row .MuiButton-root { + min-height: 40px; +} + +/* --------------------------------------------------------------------------- + * Home (overview) + * ------------------------------------------------------------------------- */ + +.external-hero { + position: relative; + overflow: hidden; + padding: 22px 24px; + border: 1px solid var(--primary-border); + border-radius: 18px; + background: + radial-gradient(420px 180px at 12% -30%, rgba(214, 181, 110, 0.22), transparent 70%), + linear-gradient(135deg, #1d1a12 0%, #14141a 55%, #101014 100%); + box-shadow: var(--shadow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.external-hero-eyebrow { + color: var(--text-tertiary) !important; + font-size: 11px !important; + letter-spacing: 0.28em; + text-transform: uppercase; +} + +.external-hero-title { + font-family: var(--font-display) !important; + font-size: 26px !important; + letter-spacing: 0.03em; + margin: 4px 0 14px !important; +} + +.external-hero-meta { + display: flex; + flex-wrap: wrap; + gap: 10px 28px; +} + +.external-section-title { + color: var(--text-tertiary) !important; + font-size: 12px !important; + font-weight: 700 !important; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.external-capability-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.external-capability-card { + min-height: 140px; + opacity: 0.62; + border-color: var(--border-soft) !important; +} + +.external-capability-card.is-enabled { + opacity: 1; +} + +.external-capability-card.is-enabled:hover { + border-color: var(--primary-border) !important; + transform: translateY(-2px); + box-shadow: var(--shadow-soft), 0 0 0 1px rgba(214, 181, 110, 0.12); +} + +.external-capability-card .MuiCardActionArea-root, +.external-capability-card .MuiCardContent-root { + height: 100%; +} + +.external-capability-icon { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 12px; + border: 1px solid rgba(214, 181, 110, 0.35); + background: linear-gradient(135deg, rgba(214, 181, 110, 0.16), rgba(214, 181, 110, 0.04)); + color: var(--primary-strong); +} + +.external-capability-card:not(.is-enabled) .external-capability-icon { + border-color: var(--neutral-border); + background: var(--neutral-surface); + color: var(--text-tertiary); +} + +/* --------------------------------------------------------------------------- + * Dialogs, inputs, misc chrome + * ------------------------------------------------------------------------- */ + .external-dialog-fields { padding-top: 8px; } +.MuiDialog-paper { + border: 1px solid var(--border); + background-image: linear-gradient(180deg, rgba(214, 181, 110, 0.05), transparent 26%) !important; + box-shadow: var(--shadow-dialog) !important; +} + +.MuiBackdrop-root { + background-color: var(--overlay-scrim) !important; + backdrop-filter: blur(3px); +} + +.MuiButton-containedPrimary:not(.Mui-disabled) { + background-image: var(--brand-gradient); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0 6px 18px rgba(0, 0, 0, 0.4); +} + +.MuiButton-containedPrimary:not(.Mui-disabled):hover { + background-image: linear-gradient(135deg, #f5e6bd 0%, #e0c079 45%, #b28a45 100%); +} + .external-image-field { display: flex; align-items: center; gap: 12px; min-height: 92px; padding: 12px; - border: 1px dashed var(--border); + border: 1px dashed var(--primary-border); border-radius: var(--radius-control); + background: rgba(214, 181, 110, 0.03); } .external-image-field img, @@ -249,7 +667,7 @@ body { width: 104px; height: 68px; object-fit: cover; - border-radius: 6px; + border-radius: 8px; background: var(--bg-card-strong); } @@ -257,7 +675,7 @@ body { width: 88px; height: 50px; object-fit: cover; - border-radius: 6px; + border-radius: 8px; background: var(--bg-card-strong); } @@ -273,23 +691,66 @@ body { to { opacity: 1; transform: translateY(0); } } +/* --------------------------------------------------------------------------- + * Responsive + * ------------------------------------------------------------------------- */ + @media (max-width: 1199px) { .external-capability-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 899px) { - .external-page { padding: 18px; } + .external-tabbar { display: flex; } + .external-page { + padding: 16px 16px calc(var(--tabbar-height) + 24px + env(safe-area-inset-bottom)); + } + /* The fixed top bar already names the page on handsets; the in-page h1 would repeat it. */ + .external-page > .MuiStack-root:first-child h1 { display: none; } .external-capability-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .external-filter-bar .MuiTextField-root { width: 100%; } + .external-filter-bar .MuiButton-root { min-height: var(--control-height); } + .external-hero { padding: 20px; } + .external-hero-title { font-size: 23px !important; } } @media (max-width: 599px) { - .external-login-card .MuiCardContent-root { padding: 24px 20px; } - .external-page { padding: 14px; gap: 14px; } - .external-capability-grid { grid-template-columns: 1fr; } - .external-capability-card { min-height: 128px; } - .external-filter-bar .MuiTextField-root { width: 100%; } + .external-login-card .MuiCardContent-root { padding: 26px 20px 30px; } + .external-page { gap: 14px; padding-inline: 14px; } + .external-capability-grid { gap: 10px; } + .external-capability-card { min-height: 118px; } .external-image-field { align-items: flex-start; flex-direction: column; } .external-header .external-language-switcher { min-width: 94px !important; max-width: 112px; } + .external-hero-meta { gap: 8px 20px; } + + /* Every dialog becomes an app-style bottom sheet on handsets. */ + .MuiDialog-container { + align-items: flex-end !important; + } + .MuiDialog-paper:not(.MuiDialog-paperFullScreen) { + margin: 0 !important; + width: 100% !important; + max-width: 100% !important; + max-height: 90dvh !important; + border-radius: 22px 22px 0 0 !important; + border-bottom: 0; + padding-bottom: env(safe-area-inset-bottom); + } + .MuiDialog-paper:not(.MuiDialog-paperFullScreen)::before { + content: ""; + display: block; + width: 44px; + height: 4px; + margin: 10px auto 0; + border-radius: 999px; + background: rgba(214, 181, 110, 0.35); + } + .MuiDialogActions-root { + padding: 12px 16px 16px !important; + } + .MuiDialogActions-root .MuiButton-root { + min-height: 44px; + flex: 1; + } } @media (prefers-reduced-motion: reduce) { diff --git a/external-admin/src/theme.js b/external-admin/src/theme.js new file mode 100644 index 0000000..1c0f3f2 --- /dev/null +++ b/external-admin/src/theme.js @@ -0,0 +1,69 @@ +import { createTheme } from "@mui/material/styles"; +import { theme as adminTheme } from "@/theme.js"; + +// The partner-facing app ships its own black-gold identity instead of the internal +// admin's light palette; component overrides are reused because they resolve through +// CSS tokens that external-admin/src/styles/index.css re-defines for this bundle. +const palette = { + mode: "dark", + primary: { + main: "#d6b56e", + light: "#ecd7a0", + dark: "#a8823f", + contrastText: "#1a1206" + }, + secondary: { + main: "#9c8a5e" + }, + success: { + main: "#66bd8b" + }, + warning: { + main: "#d9a441" + }, + error: { + main: "#e07b6d" + }, + info: { + main: "#9fb4cc" + }, + background: { + default: "#0d0d11", + paper: "#16161c" + }, + text: { + primary: "#f0e9d8", + secondary: "#b8ad92", + disabled: "#7d7460" + }, + divider: "rgba(214, 181, 110, 0.16)" +}; + +export function createExternalTheme(direction) { + return createTheme({ + direction, + palette, + typography: { + fontFamily: 'Inter, "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif', + fontSize: 14, + button: { + fontSize: 14, + textTransform: "none", + fontWeight: 650 + }, + h5: { + fontSize: 20, + fontWeight: 700, + letterSpacing: "0.01em" + }, + h6: { + fontSize: 17, + fontWeight: 700 + } + }, + shape: { + borderRadius: 10 + }, + components: adminTheme.components + }); +} diff --git a/src/features/app-users/api.test.ts b/src/features/app-users/api.test.ts index b4fe01a..218fad0 100644 --- a/src/features/app-users/api.test.ts +++ b/src/features/app-users/api.test.ts @@ -9,6 +9,7 @@ import { listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID, + revokeAppUserResource, unbanAppUser, } from "./api"; @@ -362,6 +363,38 @@ test("getAppUser uses detail endpoint and normalizes assets", async () => { }); }); +test("revokeAppUserResource uses the precise entitlement endpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + code: 0, + data: { + entitlementId: "ent-host-badge", + equipped: false, + remainingQuantity: 0, + resourceId: 91, + status: "revoked", + }, + }), + ), + ), + ); + + await revokeAppUserResource("318705991371722752", "ent-host-badge", { + reason: "后台用户详情撤回素材:host", + }); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + + expect(String(url)).toContain( + "/api/v1/admin/users/318705991371722752/resources/ent-host-badge/revoke", + ); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ reason: "后台用户详情撤回素材:host" }); +}); + test("app user projection normalizes levels roles ban and successful login fields", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts index 0b08f77..6868f7d 100644 --- a/src/features/app-users/api.ts +++ b/src/features/app-users/api.ts @@ -48,6 +48,29 @@ export async function getAppUser(userId: EntityId): Promise { return normalizeAppUser(data); } +export interface RevokeAppUserResourcePayload { + reason: string; +} + +// 详情页按 entitlement 撤回单个素材,不能复用 grant 级撤销;后者会把同一资源组内的其它素材一并回收。 +export function revokeAppUserResource( + userId: EntityId, + entitlementId: EntityId, + payload: RevokeAppUserResourcePayload, +): Promise { + const endpoint = API_ENDPOINTS.revokeAppUserResource; + return apiRequest( + apiEndpointPath(API_OPERATIONS.revokeAppUserResource, { + entitlement_id: entitlementId, + user_id: userId, + }), + { + body: payload, + method: endpoint.method, + }, + ); +} + export async function listAppUserLoginLogs(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.appListLoginLogs; const data = await apiRequest & { page_size?: number }>( diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index 8cbaab7..d1198fa 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -913,7 +913,7 @@ .resourceItem { display: grid; - grid-template-columns: 44px minmax(0, 1fr); + grid-template-columns: 44px minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; min-width: 0; @@ -923,6 +923,11 @@ background: var(--bg-card); } +.resourceRevokeButton { + min-width: 72px; + white-space: nowrap; +} + .resourceThumb, .resourceThumbFallback { width: 44px; diff --git a/src/features/app-users/components/AppUserDetailProvider.jsx b/src/features/app-users/components/AppUserDetailProvider.jsx index cfec46c..cb0cb84 100644 --- a/src/features/app-users/components/AppUserDetailProvider.jsx +++ b/src/features/app-users/components/AppUserDetailProvider.jsx @@ -60,12 +60,13 @@ export function AppUserDetailProvider({ children }) { open={Boolean(detailUser)} user={detailUser} onClose={closeAppUserDetail} + onReloadUser={() => openAppUserDetail(detailUser)} /> ); } -function AppUserDetailDrawer({ loading, onClose, open, user }) { +function AppUserDetailDrawer({ loading, onClose, onReloadUser, open, user }) { if (!open || !user) { return null; } @@ -83,6 +84,7 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) { loading={loading} user={user} onOpenFullDetails={onClose} + onReloadUser={onReloadUser} /> ); diff --git a/src/features/app-users/components/AppUserDetailProvider.test.jsx b/src/features/app-users/components/AppUserDetailProvider.test.jsx index 4ee72ee..7fc1a11 100644 --- a/src/features/app-users/components/AppUserDetailProvider.test.jsx +++ b/src/features/app-users/components/AppUserDetailProvider.test.jsx @@ -1,12 +1,17 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; +import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; -import { getAppUser, issueAppUserAccessToken } from "@/features/app-users/api"; +import { getAppUser, issueAppUserAccessToken, revokeAppUserResource } from "@/features/app-users/api"; import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx"; import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx"; -vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: vi.fn() })); +vi.mock("@/features/app-users/api", () => ({ + getAppUser: vi.fn(), + issueAppUserAccessToken: vi.fn(), + revokeAppUserResource: vi.fn(), +})); beforeEach(() => { vi.clearAllMocks(); @@ -81,11 +86,13 @@ test("detail drawer keeps the identity hero and shows the expanded admin project render( - - - - - + + + + + + + , ); fireEvent.click(screen.getByRole("button", { name: "打开详情" })); @@ -129,6 +136,72 @@ test("detail drawer keeps the identity hero and shows the expanded admin project expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); +test("detail drawer revokes one material by entitlement and reloads owner state", async () => { + const userWithHostBadge = { + defaultDisplayUserId: "163337", + equippedResources: [ + { + entitlementId: "ent-host-badge", + equipped: true, + name: "host", + quantity: 1, + remainingQuantity: 1, + resourceId: 91, + resourceType: "badge", + status: "active", + }, + ], + resources: [ + { + entitlementId: "ent-host-badge", + equipped: true, + name: "host", + quantity: 1, + remainingQuantity: 1, + resourceId: 91, + resourceType: "badge", + status: "active", + }, + ], + status: "active", + userId: "10001", + username: "tester", + }; + vi.mocked(getAppUser) + .mockResolvedValueOnce(userWithHostBadge) + .mockResolvedValueOnce({ ...userWithHostBadge, equippedResources: [], resources: [] }); + vi.mocked(revokeAppUserResource).mockResolvedValue({}); + + render( + + + + + + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "打开详情" })); + fireEvent.click(await screen.findByRole("tab", { name: "资源 (2)" })); + + const revokeButtons = screen.getAllByRole("button", { name: "撤回素材 host" }); + expect(revokeButtons).toHaveLength(2); + fireEvent.click(revokeButtons[0]); + expect(screen.getByText(/同一资源组的其他素材不受影响/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "确认撤回" })); + + await waitFor(() => + expect(revokeAppUserResource).toHaveBeenCalledWith("10001", "ent-host-badge", { + reason: "后台用户详情撤回素材:host", + }), + ); + await waitFor(() => expect(getAppUser).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByRole("button", { name: "撤回素材 host" })).not.toBeInTheDocument()); + expect(screen.getAllByText("当前无数据")).toHaveLength(2); +}); + function DetailTrigger() { const { openAppUserDetail } = useAppUserDetail(); return ( diff --git a/src/features/app-users/components/AppUserDetailView.jsx b/src/features/app-users/components/AppUserDetailView.jsx index 0e33379..392297d 100644 --- a/src/features/app-users/components/AppUserDetailView.jsx +++ b/src/features/app-users/components/AppUserDetailView.jsx @@ -1,13 +1,16 @@ import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined"; import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +import UndoOutlined from "@mui/icons-material/UndoOutlined"; import Tab from "@mui/material/Tab"; import Tabs from "@mui/material/Tabs"; import { useEffect, useState } from "react"; import { Link, useLocation } from "react-router-dom"; import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { Button } from "@/shared/ui/Button.jsx"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { formatMillis } from "@/shared/utils/time.js"; -import { issueAppUserAccessToken } from "@/features/app-users/api"; +import { issueAppUserAccessToken, revokeAppUserResource } from "@/features/app-users/api"; import { appUserStatusLabels } from "@/features/app-users/constants.js"; import { BanCountdown, @@ -33,6 +36,7 @@ export function AppUserDetailView({ loading = false, mode = "drawer", onOpenFullDetails, + onReloadUser, user, }) { const location = useLocation(); @@ -103,7 +107,12 @@ export function AppUserDetailView({ {activeTab === "overview" ? : null} {activeTab === "assets" ? : null} {activeTab === "resources" ? ( - + ) : null} {activeTab === "status" ? : null} {activeTab === "device" ? : null} @@ -167,14 +176,65 @@ function AssetsPanel({ balances, user }) { ); } -function ResourcesPanel({ equippedResources, resources }) { +function ResourcesPanel({ equippedResources, onReloadUser, resources, user }) { + const confirm = useConfirm(); + const { showToast } = useToast(); + const [revokingEntitlementId, setRevokingEntitlementId] = useState(""); + + const revokeResource = async (resource) => { + const entitlementId = String(resource?.entitlementId || "").trim(); + if (!entitlementId || revokingEntitlementId) { + return; + } + const resourceName = resourceDisplayName(resource); + const accepted = await confirm({ + title: "撤回用户素材", + message: `撤回后,用户「${user.username || user.displayUserId || user.userId}」将立即失去素材「${resourceName}」;若正在佩戴会同时卸下。同一资源组的其他素材不受影响。`, + confirmText: "确认撤回", + tone: "danger", + }); + if (!accepted) { + return; + } + + setRevokingEntitlementId(entitlementId); + try { + // entitlement 是用户素材实例的唯一标识;按它撤回才能避免 sourceGrantId 对应资源组时误伤同组素材。 + await revokeAppUserResource(user.userId, entitlementId, { + reason: `后台用户详情撤回素材:${resourceName}`.slice(0, 256), + }); + } catch (error) { + showToast(error.message || "素材撤回失败", "error"); + setRevokingEntitlementId(""); + return; + } + + showToast(`素材「${resourceName}」已撤回`, "success"); + try { + // 撤回成功后重新读取 owner 服务事实,不能只在前端本地删行掩盖后端未生效或残留佩戴状态。 + await onReloadUser?.(); + } catch (error) { + showToast(error.message || "素材已撤回,但用户详情刷新失败", "warning"); + } finally { + setRevokingEntitlementId(""); + } + }; + return ( <> - + - + ); @@ -437,7 +497,7 @@ function AssetBalanceList({ balances }) { ); } -function ResourceList({ resources }) { +function ResourceList({ onRevokeResource, resources, revokingEntitlementId }) { if (!resources.length) { return
    当前无数据
    ; } @@ -448,7 +508,7 @@ function ResourceList({ resources }) {
    - {resource.name || resource.resourceCode || `资源 ${resource.resourceId}`} + {resourceDisplayName(resource)} {resource.equipped ? 佩戴中 : null}
    @@ -456,12 +516,28 @@ function ResourceList({ resources }) {
    到期 {formatExpireTime(resource.expiresAtMs)}
    + {resource.entitlementId ? ( + + ) : null} ))} ); } +function resourceDisplayName(resource) { + return resource?.name || resource?.resourceCode || `资源 ${resource?.resourceId || "-"}`; +} + function ResourceThumb({ resource }) { const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl; if (!imageUrl) { diff --git a/src/features/app-users/pages/AppUserDetailPage.jsx b/src/features/app-users/pages/AppUserDetailPage.jsx index e1fd0bf..8a91ec4 100644 --- a/src/features/app-users/pages/AppUserDetailPage.jsx +++ b/src/features/app-users/pages/AppUserDetailPage.jsx @@ -47,7 +47,7 @@ export function AppUserDetailPage() {
    - {data ? : null} + {data ? : null}
    diff --git a/src/features/app-users/pages/AppUserDetailPage.test.jsx b/src/features/app-users/pages/AppUserDetailPage.test.jsx index 50fceae..8962610 100644 --- a/src/features/app-users/pages/AppUserDetailPage.test.jsx +++ b/src/features/app-users/pages/AppUserDetailPage.test.jsx @@ -2,11 +2,17 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { QueryProvider } from "@/shared/query/QueryProvider.jsx"; +import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx"; +import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { getAppUser } from "@/features/app-users/api"; import { AppUserDetailPage } from "@/features/app-users/pages/AppUserDetailPage.jsx"; // AppUserDetailView 会静态导入 issueAppUserAccessToken,mock 工厂缺少该导出会直接导致模块加载失败。 -vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: vi.fn() })); +vi.mock("@/features/app-users/api", () => ({ + getAppUser: vi.fn(), + issueAppUserAccessToken: vi.fn(), + revokeAppUserResource: vi.fn(), +})); test("full user detail route loads the URL user and returns to its source list", async () => { vi.mocked(getAppUser).mockResolvedValue({ @@ -26,19 +32,23 @@ test("full user detail route loads the URL user and returns to its source list", render( - - - } /> - } /> - - + + + + + } /> + } /> + + + + , ); diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index 04e36a9..5e15a85 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -433,6 +433,7 @@ test("resource shop APIs use generated admin paths", async () => { await upsertResourceShopItems({ items: [ { + coinPrice: 120, durationDays: 3, effectiveFromMs: 0, effectiveToMs: 0, @@ -462,7 +463,7 @@ test("resource shop APIs use generated admin paths", async () => { expect(String(upsertUrl)).toContain("/api/v1/admin/resource-shop/items"); expect(upsertInit?.method).toBe("POST"); expect(JSON.parse(String(upsertInit?.body))).toMatchObject({ - items: [{ durationDays: 3, resourceId: 11, status: "active" }], + items: [{ coinPrice: 120, durationDays: 3, resourceId: 11, status: "active" }], }); expect(String(enableUrl)).toContain("/api/v1/admin/resource-shop/items/99/enable"); expect(enableInit?.method).toBe("POST"); diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index f407fab..be656ff 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -328,6 +328,7 @@ export interface ResourceGroupPayload { } export interface ResourceShopItemPayload { + coinPrice: number; durationDays: number; effectiveFromMs: number; effectiveToMs: number; diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 6a834c7..323ba78 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -49,6 +49,7 @@ import { cpRelationTypeOptions, defaultGiftTypeOptions, resourceIdentityAutoGrantOptions, + resourceShopDurationOptions, resourceShopSellableTypes, } from "@/features/resources/constants.js"; import { useResourceAbilities } from "@/features/resources/permissions.js"; @@ -314,6 +315,68 @@ export async function fetchAllOptionPages(fetcher, query = {}, pageSize = option throw new Error("资源选项分页过多,请收窄筛选条件"); } +export function groupResourceShopItems(items = []) { + const itemsByResourceId = new Map(); + + // 同一资源可以同时售卖 1/3/7 天规格,按资源分组时必须保留完整数组,不能再用单值 Map 覆盖前面的规格。 + items.forEach((item) => { + const resourceId = Number(item?.resourceId || 0); + if (!Number.isInteger(resourceId) || resourceId <= 0) { + return; + } + const resourceItems = itemsByResourceId.get(resourceId) || []; + resourceItems.push(item); + itemsByResourceId.set(resourceId, resourceItems); + }); + itemsByResourceId.forEach((resourceItems) => { + resourceItems.sort((left, right) => Number(left.durationDays || 0) - Number(right.durationDays || 0)); + }); + return itemsByResourceId; +} + +export function buildResourceShopDraftGroup(resource, existingItems = [], fallbackSortOrder = 0) { + const resourceId = String(resource?.resourceId || ""); + const allowedDurations = new Set(resourceShopDurationOptions.map(([durationDays]) => durationDays)); + const specsByDuration = {}; + + existingItems.forEach((item) => { + const durationDays = String(item?.durationDays || "1"); + if (!allowedDurations.has(durationDays)) { + return; + } + specsByDuration[durationDays] = resourceShopSpecDraft(resource, durationDays, item, fallbackSortOrder); + }); + + // 首次选择资源默认创建 1 天规格并继承资源列表价格;其他天数由运营按需勾选且各自维护价格。 + if (Object.keys(specsByDuration).length === 0) { + specsByDuration["1"] = resourceShopSpecDraft(resource, "1", null, fallbackSortOrder); + } + return { resourceId, specsByDuration }; +} + +export function canUnselectResourceShopDraftSpec(spec) { + // 已落库规格只能在商店列表通过状态 Switch 启停;从抽屉静默移除不会删除数据库行,因此必须阻止这种误导操作。 + return !spec?.shopItemId; +} + +export function canUnselectResourceShopDraftGroup(draftGroup) { + // 父级取消会一次丢弃全部规格;只要其中一条已落库,就必须保留整组并让运营逐条通过列表 Switch 启停。 + return Object.values(draftGroup?.specsByDuration || {}).every(canUnselectResourceShopDraftSpec); +} + +function resourceShopSpecDraft(resource, durationDays, existingItem, fallbackSortOrder) { + return { + coinPrice: String(existingItem?.coinPrice ?? resource?.coinPrice ?? ""), + durationDays, + effectiveFromMs: existingItem?.effectiveFromMs || "", + effectiveToMs: existingItem?.effectiveToMs || "", + resourceId: String(resource?.resourceId || existingItem?.resourceId || ""), + shopItemId: existingItem?.shopItemId, + sortOrder: String(existingItem?.sortOrder ?? fallbackSortOrder), + status: existingItem?.status || "active", + }; +} + function loadResourceGrantOptions() { return Promise.all([ fetchAllOptionPages(listResources, { status: "active" }), @@ -685,9 +748,11 @@ export function useResourceShopPage() { const [purchaseKeyword, setPurchaseKeyword] = useState(""); const [drawerQuery, setDrawerQuery] = useState(""); const [drawerResourceType, setDrawerResourceType] = useState(""); - const [batchDurationDays, setBatchDurationDays] = useState("1"); const [resourceOptions, setResourceOptions] = useState([]); + const [shopItemOptions, setShopItemOptions] = useState([]); const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false); + const [resourceOptionsError, setResourceOptionsError] = useState(""); + const [resourceOptionsLoadVersion, setResourceOptionsLoadVersion] = useState(0); const [draftByResourceId, setDraftByResourceId] = useState({}); const [loadingAction, setLoadingAction] = useState(""); const filters = useMemo( @@ -732,25 +797,18 @@ export function useResourceShopPage() { queryKey: ["resource-shop-purchase-orders", purchaseDrawerOpen, purchaseFilters, purchasePage], }); const shopItemsByResourceId = useMemo(() => { - const items = new Map(); - (result.data?.items || []).forEach((item) => { - if (item.resourceId) { - items.set(Number(item.resourceId), item); - } - }); - return items; - }, [result.data?.items]); + return groupResourceShopItems(shopItemOptions); + }, [shopItemOptions]); const selectedDrafts = useMemo( () => Object.values(draftByResourceId) - .map((draft) => ({ - ...draft, - resource: resourceOptions.find( - (resource) => String(resource.resourceId) === String(draft.resourceId), - ), - })) - .sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)), - [draftByResourceId, resourceOptions], + .flatMap((draftGroup) => Object.values(draftGroup.specsByDuration || {})) + .sort( + (left, right) => + Number(left.sortOrder || 0) - Number(right.sortOrder || 0) || + Number(left.durationDays || 0) - Number(right.durationDays || 0), + ), + [draftByResourceId], ); const filteredResourceOptions = useMemo(() => { const keyword = drawerQuery.trim().toLowerCase(); @@ -771,15 +829,28 @@ export function useResourceShopPage() { } let ignore = false; setResourceOptionsLoading(true); - fetchAllOptionPages(listResources, { status: "active" }) - .then((data) => { + setResourceOptionsError(""); + setResourceOptions([]); + setShopItemOptions([]); + // 抽屉同时拉全资源和现有售卖项,避免当前列表分页把同一资源的其他天数规格漏掉或误判为新增。 + Promise.all([ + fetchAllOptionPages(listResources, { status: "active" }), + fetchAllOptionPages(listResourceShopItems), + ]) + .then(([resourceData, shopItemData]) => { if (!ignore) { - setResourceOptions(toShopResourceOptions(data.items || [])); + setResourceOptions(toShopResourceOptions(resourceData.items || [])); + setShopItemOptions(shopItemData.items || []); } }) .catch((err) => { if (!ignore) { - showToast(err.message || "加载售卖资源失败", "error"); + const message = err.message || "加载售卖资源失败"; + // 任一请求失败都不能沿用上次候选;否则“旧资源 + 空规格”会让运营误以为是在新增商品。 + setResourceOptions([]); + setShopItemOptions([]); + setResourceOptionsError(message); + showToast(message, "error"); } }) .finally(() => { @@ -790,19 +861,34 @@ export function useResourceShopPage() { return () => { ignore = true; }; - }, [drawerOpen, showToast]); + }, [drawerOpen, resourceOptionsLoadVersion, showToast]); const openShopDrawer = () => { setDraftByResourceId({}); setDrawerQuery(""); setDrawerResourceType(""); - setBatchDurationDays("1"); + setResourceOptions([]); + setShopItemOptions([]); + setResourceOptionsError(""); + setResourceOptionsLoadVersion((current) => current + 1); setDrawerOpen(true); }; + const reloadShopDrawerOptions = () => { + if (!drawerOpen) { + return; + } + setDraftByResourceId({}); + setResourceOptionsError(""); + setResourceOptionsLoadVersion((current) => current + 1); + }; + const closeShopDrawer = () => { setDrawerOpen(false); setDraftByResourceId({}); + setResourceOptions([]); + setShopItemOptions([]); + setResourceOptionsError(""); }; const openPurchaseDrawer = () => { @@ -821,41 +907,78 @@ export function useResourceShopPage() { const resourceId = String(resource.resourceId); setDraftByResourceId((current) => { if (current[resourceId]) { + if (!canUnselectResourceShopDraftGroup(current[resourceId])) { + return current; + } const next = { ...current }; delete next[resourceId]; return next; } - const existing = shopItemsByResourceId.get(Number(resource.resourceId)); return { ...current, - [resourceId]: { - durationDays: String(existing?.durationDays || batchDurationDays), - effectiveFrom: msToDatetimeLocal(existing?.effectiveFromMs), - effectiveTo: msToDatetimeLocal(existing?.effectiveToMs), - resourceId, - sortOrder: String(existing?.sortOrder ?? Object.keys(current).length), - }, + [resourceId]: buildResourceShopDraftGroup( + resource, + shopItemsByResourceId.get(Number(resource.resourceId)) || [], + Object.keys(current).length, + ), }; }); }; - const updateDraftItem = (resourceId, patch) => { - setDraftByResourceId((current) => ({ - ...current, - [resourceId]: { - ...current[resourceId], - ...patch, - }, - })); + const toggleDraftDuration = (resource, durationDays) => { + const resourceId = String(resource?.resourceId || ""); + const normalizedDuration = String(durationDays || ""); + if (!resourceId || !normalizedDuration) { + return; + } + setDraftByResourceId((current) => { + const draftGroup = current[resourceId]; + if (!draftGroup) { + return current; + } + const specsByDuration = { ...draftGroup.specsByDuration }; + if (specsByDuration[normalizedDuration]) { + if (!canUnselectResourceShopDraftSpec(specsByDuration[normalizedDuration])) { + return current; + } + delete specsByDuration[normalizedDuration]; + } else { + // 重新勾选已有规格时恢复其独立价格和主键;真正的新规格才继承资源列表价格。 + const existingItem = (shopItemsByResourceId.get(Number(resourceId)) || []).find( + (item) => String(item.durationDays || "1") === normalizedDuration, + ); + specsByDuration[normalizedDuration] = resourceShopSpecDraft( + resource, + normalizedDuration, + existingItem, + Object.keys(current).indexOf(resourceId), + ); + } + return { + ...current, + [resourceId]: { ...draftGroup, specsByDuration }, + }; + }); }; - const applyBatchDuration = (durationDays) => { - setBatchDurationDays(durationDays); - setDraftByResourceId((current) => - Object.fromEntries( - Object.entries(current).map(([resourceId, draft]) => [resourceId, { ...draft, durationDays }]), - ), - ); + const updateDraftSpec = (resourceId, durationDays, patch) => { + setDraftByResourceId((current) => { + const draftGroup = current[resourceId]; + const draft = draftGroup?.specsByDuration?.[durationDays]; + if (!draft) { + return current; + } + return { + ...current, + [resourceId]: { + ...draftGroup, + specsByDuration: { + ...draftGroup.specsByDuration, + [durationDays]: { ...draft, ...patch }, + }, + }, + }; + }); }; const submitShopItems = async (event) => { @@ -866,15 +989,17 @@ export function useResourceShopPage() { setLoadingAction("resource-shop-upsert"); try { const parsed = parseForm(resourceShopItemsFormSchema, { items: selectedDrafts }); + // 一个资源的每个天数规格都是独立商品行,价格、时间和排序必须逐条提交,不能再按资源合并。 await upsertResourceShopItems({ items: parsed.items.map((item) => ({ + coinPrice: Number(item.coinPrice), durationDays: Number(item.durationDays), - effectiveFromMs: datetimeLocalToMs(item.effectiveFrom), - effectiveToMs: datetimeLocalToMs(item.effectiveTo), + effectiveFromMs: Number(item.effectiveFromMs || 0), + effectiveToMs: Number(item.effectiveToMs || 0), resourceId: Number(item.resourceId), - shopItemId: shopItemsByResourceId.get(Number(item.resourceId))?.shopItemId, + shopItemId: item.shopItemId ? Number(item.shopItemId) : undefined, sortOrder: Number(item.sortOrder || 0), - status: "active", + status: item.status || "active", })), }); showToast("售卖资源已保存", "success"); @@ -920,8 +1045,6 @@ export function useResourceShopPage() { return { ...sharedPageState({ page, query, result, setPage, setQuery }), abilities, - applyBatchDuration, - batchDurationDays, changeDrawerQuery: setDrawerQuery, changeDrawerResourceType: setDrawerResourceType, changeResourceType: resetSetter(setResourceType, setPage), @@ -931,6 +1054,7 @@ export function useResourceShopPage() { drawerOpen, drawerQuery, drawerResourceType, + draftByResourceId, filteredResourceOptions, loadingAction, openShopDrawer, @@ -944,7 +1068,9 @@ export function useResourceShopPage() { purchaseResourceType, purchaseUserFilter, reloadPurchaseOrders: purchaseResult.reload, + reloadShopDrawerOptions, setPurchasePage, + resourceOptionsError, resourceOptionsLoading, resourceType, resetFilters, @@ -953,11 +1079,12 @@ export function useResourceShopPage() { status, submitShopItems, toggleDraftResource, + toggleDraftDuration, toggleShopItem, changePurchaseKeyword: resetSetter(setPurchaseKeyword, setPurchasePage), changePurchaseResourceType: resetSetter(setPurchaseResourceType, setPurchasePage), changePurchaseUserFilter: resetSetter(setPurchaseUserFilter, setPurchasePage), - updateDraftItem, + updateDraftSpec, }; } diff --git a/src/features/resources/hooks/useResourcePages.test.js b/src/features/resources/hooks/useResourcePages.test.js index 9d5f8d7..2336587 100644 --- a/src/features/resources/hooks/useResourcePages.test.js +++ b/src/features/resources/hooks/useResourcePages.test.js @@ -2,11 +2,15 @@ import { expect, test, vi } from "vitest"; import { applyGiftPriceDefaults, applyGiftResourceSelection, + buildResourceShopDraftGroup, buildGiftResourceDrafts, + canUnselectResourceShopDraftGroup, + canUnselectResourceShopDraftSpec, cpRelationTypeFromGift, cpRelationTypeFromPresentationJson, cpRelationTypeShortLabel, fetchAllOptionPages, + groupResourceShopItems, normalizeGiftPresentationJson, } from "./useResourcePages.js"; @@ -134,6 +138,45 @@ test("fetches all option pages instead of only the first resource page", async ( expect(result.total).toBe(5); }); +test("groups every shop duration instead of overwriting the same resource", () => { + const itemsByResourceId = groupResourceShopItems([ + { durationDays: 7, resourceId: 11, shopItemId: 3 }, + { durationDays: 1, resourceId: 11, shopItemId: 1 }, + { durationDays: 3, resourceId: 11, shopItemId: 2 }, + ]); + + expect(itemsByResourceId.get(11)?.map((item) => item.shopItemId)).toEqual([1, 2, 3]); +}); + +test("builds independent shop specs and defaults a new spec to the resource price", () => { + const resource = { coinPrice: 100, resourceId: 11 }; + const existingGroup = buildResourceShopDraftGroup(resource, [ + { coinPrice: 260, durationDays: 7, resourceId: 11, shopItemId: 3 }, + { coinPrice: 120, durationDays: 3, resourceId: 11, shopItemId: 2 }, + ]); + const newGroup = buildResourceShopDraftGroup(resource); + + expect(existingGroup.specsByDuration["3"]).toMatchObject({ + coinPrice: "120", + durationDays: "3", + shopItemId: 2, + }); + expect(existingGroup.specsByDuration["7"]).toMatchObject({ + coinPrice: "260", + durationDays: "7", + shopItemId: 3, + }); + expect(newGroup.specsByDuration["1"]).toMatchObject({ + coinPrice: "100", + durationDays: "1", + resourceId: "11", + }); + expect(canUnselectResourceShopDraftSpec(existingGroup.specsByDuration["3"])).toBe(false); + expect(canUnselectResourceShopDraftSpec(newGroup.specsByDuration["1"])).toBe(true); + expect(canUnselectResourceShopDraftGroup(existingGroup)).toBe(false); + expect(canUnselectResourceShopDraftGroup(newGroup)).toBe(true); +}); + test("reads CP relation type from gift presentation json", () => { expect(cpRelationTypeFromPresentationJson('{"cp_relation_type":"brother"}')).toBe("brother"); expect(cpRelationTypeFromPresentationJson('{"cpRelationType":"sister"}')).toBe("sister"); diff --git a/src/features/resources/mp4AlphaLayout.js b/src/features/resources/mp4AlphaLayout.js index aadd91b..654bbc5 100644 --- a/src/features/resources/mp4AlphaLayout.js +++ b/src/features/resources/mp4AlphaLayout.js @@ -123,13 +123,15 @@ export function updateMp4AlphaLayoutKind(metadataJson, alphaLayout) { if (!layout) { return normalizeMetadataJSON(metadataJson); } + const keepCustomFrames = alphaLayout === "custom"; const nextLayout = createMp4AlphaLayout({ alphaLayout, - alphaFrame: layout.alpha_frame, + // 左右/上下方向变化时必须按新方向重算矩形;沿用旧矩形会让预览仍显示上一个通道顺序。 + alphaFrame: keepCustomFrames ? layout.alpha_frame : undefined, confirmed: false, confidence: layout.confidence, detectVersion: layout.detect_version, - rgbFrame: layout.rgb_frame, + rgbFrame: keepCustomFrames ? layout.rgb_frame : undefined, videoH: layout.video_h, videoW: layout.video_w, }); diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 16d3e0f..98bf9bb 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -75,6 +75,7 @@ import { mp4AlphaLayoutOptions, mp4LayoutLabel, readMp4VideoInfo, + removeMp4AlphaLayoutMetadata, updateMp4AlphaLayoutFrame, updateMp4AlphaLayoutKind, } from "@/features/resources/mp4AlphaLayout.js"; @@ -995,39 +996,42 @@ function mp4ResourceAnimationSource(resource) { } function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, setForm, uploadDisabled }) { - const submitDisabled = disabled || loading || hasUnconfirmedMp4Layout(form.metadataJson); - const { showToast } = useToast(); + const [animationUploading, setAnimationUploading] = useState(false); + const [previewUploading, setPreviewUploading] = useState(false); + const submitDisabled = + disabled || loading || animationUploading || previewUploading || hasUnconfirmedMp4Layout(form.metadataJson); const profileCardLayout = form.resourceType === "profile_card" ? profileCardLayoutFromMetadata(form.metadataJson) : null; const mp4Layout = mp4AlphaLayoutFromMetadata(form.metadataJson); const handleAnimationSelected = async (file) => { + let detectedMp4Layout = null; if (isMp4File(file)) { - try { - const layout = await detectMp4AlphaLayout(file); - setForm((previous) => ({ - ...previous, - metadataJson: mergeMp4AlphaLayoutMetadata(previous.metadataJson, layout), - })); - } catch (err) { - showToast(err.message || "MP4 透明布局解析失败", "error"); + detectedMp4Layout = await detectMp4AlphaLayout(file); + } + let detectedProfileCardLayout = null; + if (form.resourceType === "profile_card") { + detectedProfileCardLayout = await detectProfileCardLayout(file); + } + // 只返回当次素材的检测增量,上传成功后再合并到最新表单,避免异步旧快照覆盖用户新修改。 + return { mp4Layout: detectedMp4Layout, profileCardLayout: detectedProfileCardLayout }; + }; + const handleAnimationChange = (animationUrl, selectionResult) => { + setForm((previous) => { + let metadataJson = previous.metadataJson; + if (!animationUrl) { + // 清除素材时,两类从该素材派生的布局元数据都必须同步清理。 + metadataJson = removeProfileCardLayoutMetadata(removeMp4AlphaLayoutMetadata(metadataJson)); + } else if (selectionResult) { + metadataJson = selectionResult.mp4Layout + ? mergeMp4AlphaLayoutMetadata(metadataJson, selectionResult.mp4Layout) + : removeMp4AlphaLayoutMetadata(metadataJson); + metadataJson = + previous.resourceType === "profile_card" && selectionResult.profileCardLayout + ? metadataWithProfileCardLayout(metadataJson, selectionResult.profileCardLayout) + : removeProfileCardLayoutMetadata(metadataJson); } - } - if (form.resourceType !== "profile_card") { - return; - } - try { - const layout = await detectProfileCardLayout(file); - setForm((previous) => - previous.resourceType === "profile_card" - ? { - ...previous, - metadataJson: metadataWithProfileCardLayout(previous.metadataJson, layout), - } - : previous, - ); - } catch (err) { - showToast(err.message || "资料卡内容高度解析失败", "error"); - } + return { ...previous, animationUrl, metadataJson }; + }); }; return ( setForm({ ...form, resourceCode: event.target.value })} /> setForm((previous) => ({ ...previous, previewUrl }))} + onUploadingChange={setPreviewUploading} /> setForm((previous) => ({ ...previous, animationUrl }))} + onChange={handleAnimationChange} onFileSelected={handleAnimationSelected} + onUploadingChange={setAnimationUploading} /> {mp4Layout ? ( setForm((previous) => ({ ...previous, metadataJson }))} /> diff --git a/src/features/resources/pages/ResourceShopPage.jsx b/src/features/resources/pages/ResourceShopPage.jsx index b04bfe3..8e26736 100644 --- a/src/features/resources/pages/ResourceShopPage.jsx +++ b/src/features/resources/pages/ResourceShopPage.jsx @@ -16,6 +16,7 @@ import { AdminListToolbar, } from "@/shared/ui/AdminListLayout.jsx"; import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { createOptionsColumnFilter, createTextColumnFilter, createUserIdentityColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; import { @@ -25,7 +26,11 @@ import { resourceStatusFilters, resourceTypeLabels, } from "@/features/resources/constants.js"; -import { useResourceShopPage } from "@/features/resources/hooks/useResourcePages.js"; +import { + canUnselectResourceShopDraftGroup, + canUnselectResourceShopDraftSpec, + useResourceShopPage, +} from "@/features/resources/hooks/useResourcePages.js"; import styles from "@/features/resources/resources.module.css"; const shopColumns = [ @@ -201,7 +206,12 @@ function ResourceShopDrawer({ page }) { 取消
    - + {page.filteredResourceOptions.length > 0 ? (
    {page.filteredResourceOptions.map((resource) => ( @@ -335,61 +336,94 @@ function ResourceShopPurchaseDrawer({ page }) { function ResourceSelectionRow({ disabled, page, resource }) { const resourceId = String(resource.resourceId); const selected = page.selectedResourceIds.has(resourceId); - const draft = selected ? page.selectedDrafts.find((item) => String(item.resourceId) === resourceId) : null; + const draftGroup = selected ? page.draftByResourceId[resourceId] : null; + const canUnselectGroup = !draftGroup || canUnselectResourceShopDraftGroup(draftGroup); return (
    page.toggleDraftResource(resource)} /> - {resourcePriceLabel(resource)} + 资源价 {resourcePriceLabel(resource)} + {draftGroup ? ( +
    + {resourceShopDurationOptions.map(([durationDays, label]) => ( + + ))} +
    + ) : null} +
    + ); +} + +function ResourceShopSpecRow({ disabled, draft, durationDays, label, page, resource }) { + const resourceId = String(resource.resourceId); + const updateDraft = (patch) => page.updateDraftSpec(resourceId, durationDays, patch); + return ( +
    + {draft ? ( -
    + <> page.updateDraftItem(resourceId, { durationDays: event.target.value })} - > - {resourceShopDurationOptions.map(([value, label]) => ( - - {label} - - ))} - - page.updateDraftItem(resourceId, { effectiveFrom: event.target.value })} + type="number" + value={draft.coinPrice} + onChange={(event) => updateDraft({ coinPrice: event.target.value })} /> - page.updateDraftItem(resourceId, { effectiveTo: event.target.value })} + label="生效时间" + value={{ endMs: draft.effectiveToMs, startMs: draft.effectiveFromMs }} + onChange={(range) => + updateDraft({ effectiveFromMs: range.startMs, effectiveToMs: range.endMs }) + } /> page.updateDraftItem(resourceId, { sortOrder: event.target.value })} + onChange={(event) => updateDraft({ sortOrder: event.target.value })} /> -
    - ) : null} + + ) : ( + 未售卖 + )}
    ); } diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css index 3612cfa..8238797 100644 --- a/src/features/resources/resources.module.css +++ b/src/features/resources/resources.module.css @@ -268,7 +268,7 @@ .shopDrawerTools { display: grid; - grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.7fr) minmax(150px, 0.55fr); + grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.7fr); gap: var(--space-3); } @@ -282,16 +282,43 @@ min-height: 56px; align-items: center; gap: var(--space-3); - grid-template-columns: 40px minmax(220px, 1fr) minmax(92px, 0.35fr) minmax(0, 2fr); + grid-template-columns: 40px minmax(220px, 1fr) minmax(132px, auto); padding: var(--space-2) 0; border-bottom: 1px solid var(--border-muted); } -.shopSelectionControls { +.shopSpecList { display: grid; min-width: 0; gap: var(--space-2); - grid-template-columns: minmax(112px, 0.7fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(84px, 0.45fr); + grid-column: 2 / -1; +} + +.shopSpecRow { + display: grid; + min-width: 0; + align-items: center; + gap: var(--space-2); + grid-template-columns: minmax(88px, 0.45fr) minmax(120px, 0.65fr) minmax(220px, 1.25fr) minmax(84px, 0.45fr); +} + +.shopSpecToggle { + display: inline-flex; + min-width: 0; + align-items: center; + color: var(--text-primary); + font-weight: 650; + white-space: nowrap; +} + +.shopSpecTimeRange { + width: 100% !important; + max-width: none !important; +} + +.shopSpecDisabled { + grid-column: 2 / -1; + color: var(--text-tertiary); } .shopBasePrice { @@ -667,7 +694,7 @@ @media (max-width: 860px) { .shopDrawerTools, .shopSelectionRow, - .shopSelectionControls, + .shopSpecRow, .batchTableHead, .batchTableRow { grid-template-columns: 1fr; @@ -676,4 +703,9 @@ .shopSelectionRow { align-items: stretch; } + + .shopSpecList, + .shopSpecDisabled { + grid-column: auto; + } } diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index 3007f01..b2e537e 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -359,23 +359,28 @@ export const resourceShopItemsFormSchema = z items: z .array( z.object({ + coinPrice: z.union([z.string(), z.number()]), durationDays: z.union([z.string(), z.number()]), - effectiveFrom: z.string().optional(), - effectiveTo: z.string().optional(), + effectiveFromMs: z.union([z.string(), z.number()]).optional(), + effectiveToMs: z.union([z.string(), z.number()]).optional(), resourceId: z.union([z.string(), z.number()]), + shopItemId: z.union([z.string(), z.number()]).optional(), sortOrder: z.union([z.string(), z.number()]).optional(), + status: z.enum(["active", "disabled"]).optional(), }), ) .min(1, "请至少选择一个资源"), }) .superRefine((value, context) => { - const resourceIds = new Set(); + const specificationKeys = new Set(); value.items.forEach((item, index) => { const resourceId = Number(item.resourceId); const durationDays = String(item.durationDays || "").trim(); - const effectiveFromMs = datetimeLocalToMs(item.effectiveFrom); - const effectiveToMs = datetimeLocalToMs(item.effectiveTo); + const coinPrice = Number(item.coinPrice); + const effectiveFromMs = optionalEpochMs(item.effectiveFromMs); + const effectiveToMs = optionalEpochMs(item.effectiveToMs); const sortOrder = Number(item.sortOrder || 0); + const specificationKey = `${resourceId}:${durationDays}`; if (!Number.isInteger(resourceId) || resourceId <= 0) { context.addIssue({ @@ -384,14 +389,14 @@ export const resourceShopItemsFormSchema = z path: ["items", index, "resourceId"], }); } - if (resourceIds.has(resourceId)) { + if (specificationKeys.has(specificationKey)) { context.addIssue({ code: "custom", - message: "同一个资源不能重复添加", - path: ["items", index, "resourceId"], + message: "同一个资源的相同售卖天数不能重复添加", + path: ["items", index, "durationDays"], }); } - resourceIds.add(resourceId); + specificationKeys.add(specificationKey); if (!resourceShopDurations.includes(durationDays)) { context.addIssue({ code: "custom", @@ -399,25 +404,32 @@ export const resourceShopItemsFormSchema = z path: ["items", index, "durationDays"], }); } + if (!Number.isSafeInteger(coinPrice) || coinPrice <= 0) { + context.addIssue({ + code: "custom", + message: "请输入大于 0 的售卖价格", + path: ["items", index, "coinPrice"], + }); + } if (effectiveFromMs < 0) { context.addIssue({ code: "custom", message: "请选择生效开始时间", - path: ["items", index, "effectiveFrom"], + path: ["items", index, "effectiveFromMs"], }); } if (effectiveToMs < 0) { context.addIssue({ code: "custom", message: "请选择生效结束时间", - path: ["items", index, "effectiveTo"], + path: ["items", index, "effectiveToMs"], }); } if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) { context.addIssue({ code: "custom", message: "生效结束时间必须晚于开始时间", - path: ["items", index, "effectiveTo"], + path: ["items", index, "effectiveToMs"], }); } if (!Number.isInteger(sortOrder) || sortOrder < 0) { @@ -430,6 +442,15 @@ export const resourceShopItemsFormSchema = z }); }); +function optionalEpochMs(value) { + const normalized = String(value ?? "").trim(); + if (!normalized) { + return 0; + } + const parsed = Number(normalized); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : -1; +} + function datetimeLocalToMs(value) { const trimmed = String(value || "").trim(); if (!trimmed) { diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index 5b5b1af..9d1b02c 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -340,13 +340,22 @@ describe("resource form schema", () => { expect(() => parseForm(resourceGrantFormSchema, { ...payload, durationDays: "0" })).toThrow(FormValidationError); }); - test("validates resource shop durations", () => { + test("validates resource shop duration and price specifications", () => { const payload = parseForm(resourceShopItemsFormSchema, { items: [ { + coinPrice: "100", durationDays: "3", - effectiveFrom: "", - effectiveTo: "", + effectiveFromMs: "", + effectiveToMs: "", + resourceId: "11", + sortOrder: "0", + }, + { + coinPrice: "260", + durationDays: "7", + effectiveFromMs: "", + effectiveToMs: "", resourceId: "11", sortOrder: "0", }, @@ -354,18 +363,33 @@ describe("resource form schema", () => { }); expect(payload.items[0].durationDays).toBe("3"); + expect(payload.items[1].coinPrice).toBe("260"); expect(() => parseForm(resourceShopItemsFormSchema, { items: [ { + coinPrice: "100", durationDays: "2", - effectiveFrom: "", - effectiveTo: "", + effectiveFromMs: "", + effectiveToMs: "", resourceId: "11", sortOrder: "0", }, ], }), ).toThrow(FormValidationError); + expect(() => + parseForm(resourceShopItemsFormSchema, { + items: [ + { coinPrice: "100", durationDays: "3", resourceId: "11" }, + { coinPrice: "120", durationDays: "3", resourceId: "11" }, + ], + }), + ).toThrow(FormValidationError); + expect(() => + parseForm(resourceShopItemsFormSchema, { + items: [{ coinPrice: "0", durationDays: "1", resourceId: "11" }], + }), + ).toThrow(FormValidationError); }); }); diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 5e6e90b..5389ecb 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -312,6 +312,7 @@ export const API_OPERATIONS = { retryRedPacketRefund: "retryRedPacketRefund", retryRoomRpsSettlement: "retryRoomRpsSettlement", retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement", + revokeAppUserResource: "revokeAppUserResource", revokeResourceGrant: "revokeResourceGrant", savePolicyTemplate: "savePolicyTemplate", search: "search", @@ -2486,6 +2487,13 @@ export const API_ENDPOINTS: Record = { permission: "room-turnover-reward:retry", permissions: ["room-turnover-reward:retry"] }, + revokeAppUserResource: { + method: "POST", + operationId: API_OPERATIONS.revokeAppUserResource, + path: "/v1/admin/users/{user_id}/resources/{entitlement_id}/revoke", + permission: "resource-grant:revoke", + permissions: ["resource-grant:revoke"] + }, revokeResourceGrant: { method: "POST", operationId: API_OPERATIONS.revokeResourceGrant, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 03c5917..dfa00af 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -2876,6 +2876,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/users/{user_id}/resources/{entitlement_id}/revoke": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["revokeAppUserResource"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/resource-grants/resource": { parameters: { query?: never; @@ -11343,6 +11359,27 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + revokeAppUserResource: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + entitlement_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + reason: string; + }; + }; + }; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; grantResource: { parameters: { query?: never; @@ -11386,7 +11423,25 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": { + items: { + /** @description 售卖规格金币价;新建时省略或传 0 使用资源列表价格,更新已有规格时省略或传 0 保留当前售价 */ + coinPrice?: number; + /** @enum {integer} */ + durationDays: 1 | 3 | 7; + effectiveFromMs: number; + effectiveToMs: number; + resourceId: number; + shopItemId?: number; + sortOrder: number; + /** @enum {string} */ + status: "active" | "disabled"; + }[]; + }; + }; + }; responses: { 200: components["responses"]["EmptyResponse"]; }; diff --git a/src/shared/ui/Mp4Preview.jsx b/src/shared/ui/Mp4Preview.jsx new file mode 100644 index 0000000..6254954 --- /dev/null +++ b/src/shared/ui/Mp4Preview.jsx @@ -0,0 +1,462 @@ +import PauseRounded from "@mui/icons-material/PauseRounded"; +import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; +import { useEffect, useRef, useState } from "react"; +import styles from "./Mp4Preview.module.css"; + +const alphaLowThreshold = 0.08; +const alphaHighThreshold = 0.3; + +export function Mp4Preview({ autoPlay = false, controls = false, expanded = false, layout, src }) { + const layoutKey = transparentLayoutKey(layout); + const reducedMotion = prefersReducedMotion(); + + if (!layoutKey) { + return ( + + ); + } + + return ( + + ); +} + +function NativeMp4Preview({ autoPlay, controls, expanded, src }) { + const [failed, setFailed] = useState(false); + + useEffect(() => { + setFailed(false); + }, [src]); + + return ( + + {failed ? ( + MP4 预览失败 + ) : ( + + ); +} + +function AlphaMp4Preview({ autoPlay, controls, expanded, layoutKey, src }) { + const canvasRef = useRef(null); + const videoRef = useRef(null); + const [failed, setFailed] = useState(false); + const [playing, setPlaying] = useState(false); + + useEffect(() => { + const canvas = canvasRef.current; + const video = videoRef.current; + if (!canvas || !video || !src) { + return undefined; + } + + let cancelled = false; + let started = false; + let renderer; + let videoFrameId; + let animationFrameId; + let frameScheduled = false; + let lastMediaTime = -1; + let stopped = false; + const layout = JSON.parse(layoutKey); + + setFailed(false); + setPlaying(false); + + const fail = () => { + if (cancelled || stopped) { + return; + } + // 绘制失败后立即停止帧循环并释放 GPU,避免 RAF 回退路径永久空转。 + stopped = true; + if (videoFrameId !== undefined && typeof video.cancelVideoFrameCallback === "function") { + video.cancelVideoFrameCallback(videoFrameId); + } + if (animationFrameId !== undefined) { + window.cancelAnimationFrame(animationFrameId); + } + renderer?.destroy(); + renderer = undefined; + video.pause(); + setPlaying(false); + setFailed(true); + }; + + const drawFrame = (mediaTime = video.currentTime) => { + if (cancelled || stopped || !renderer || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + // RAF 会比视频帧率更快;只在媒体时间变化时上传纹理,避免无意义地重复占用 GPU。 + if (mediaTime === lastMediaTime) { + return; + } + lastMediaTime = mediaTime; + try { + renderer.draw(); + } catch { + fail(); + } + }; + + const scheduleFrame = () => { + if (cancelled || stopped || frameScheduled || video.paused) { + return; + } + if (typeof video.requestVideoFrameCallback === "function") { + frameScheduled = true; + videoFrameId = video.requestVideoFrameCallback((_, metadata) => { + frameScheduled = false; + drawFrame(metadata?.mediaTime); + scheduleFrame(); + }); + return; + } + frameScheduled = true; + animationFrameId = window.requestAnimationFrame(() => { + frameScheduled = false; + drawFrame(); + scheduleFrame(); + }); + }; + + const handlePlay = () => { + if (!cancelled && !stopped) { + setPlaying(true); + scheduleFrame(); + } + }; + const handlePause = () => { + if (!cancelled) { + setPlaying(false); + } + }; + + const start = () => { + if (cancelled || stopped || started || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + started = true; + try { + // 透明 MP4 必须按检测得到的精确矩形合成;custom/VAPC 的 RGB 与 Alpha 尺寸可能并不相等。 + renderer = createAlphaRenderer(canvas, video, layout, expanded ? 1280 : 512); + drawFrame(); + if (autoPlay) { + video.play().catch(() => { + // 浏览器若阻止自动播放,首帧仍会保留;再次打开弹窗会由用户手势触发播放。 + }); + } + } catch { + fail(); + } + }; + + video.addEventListener("loadeddata", start); + video.addEventListener("error", fail); + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + // Canvas 读取跨域视频必须先设置 anonymous;当前上传媒体域允许 CORS,本地 Blob URL 也兼容该设置。 + video.crossOrigin = "anonymous"; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.preload = "auto"; + video.src = src; + video.load(); + start(); + + return () => { + cancelled = true; + video.removeEventListener("loadeddata", start); + video.removeEventListener("error", fail); + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + if (videoFrameId !== undefined && typeof video.cancelVideoFrameCallback === "function") { + video.cancelVideoFrameCallback(videoFrameId); + } + if (animationFrameId !== undefined) { + window.cancelAnimationFrame(animationFrameId); + } + renderer?.destroy(); + video.pause(); + video.removeAttribute("src"); + video.load(); + }; + }, [autoPlay, expanded, layoutKey, src]); + + const togglePlayback = () => { + const video = videoRef.current; + if (!video || failed) { + return; + } + if (video.paused) { + // 用户手势仍可能被浏览器策略暂时拒绝;保留播放入口,允许用户重试。 + video.play().catch(() => {}); + } else { + video.pause(); + } + }; + + return ( + + + + ); +} + +function createAlphaRenderer(canvas, video, layout, maxEdge) { + const outputSize = scaledOutputSize(layout.rgb_frame[2], layout.rgb_frame[3], maxEdge); + canvas.width = outputSize.width; + canvas.height = outputSize.height; + + const gl = canvas.getContext("webgl", { + alpha: true, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: false, + }); + if (!gl) { + return createCanvas2DRenderer(canvas, video, layout); + } + + const program = createProgram(gl, vertexShaderSource, fragmentShaderSource); + const positionBuffer = gl.createBuffer(); + const texture = gl.createTexture(); + if (!positionBuffer || !texture) { + throw new Error("MP4 WebGL 初始化失败"); + } + + gl.useProgram(program); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW); + const positionLocation = gl.getAttribLocation(program, "aPosition"); + gl.enableVertexAttribArray(positionLocation); + gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.uniform1i(gl.getUniformLocation(program, "uTexture"), 0); + gl.uniform4fv(gl.getUniformLocation(program, "uRgbRect"), normalizedRect(layout.rgb_frame, layout)); + gl.uniform4fv(gl.getUniformLocation(program, "uAlphaRect"), normalizedRect(layout.alpha_frame, layout)); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 0); + + return { + destroy() { + gl.deleteTexture(texture); + gl.deleteBuffer(positionBuffer); + gl.deleteProgram(program); + }, + draw() { + gl.clear(gl.COLOR_BUFFER_BIT); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video); + gl.drawArrays(gl.TRIANGLES, 0, 6); + }, + }; +} + +function createCanvas2DRenderer(canvas, video, layout) { + const context = canvas.getContext("2d", { willReadFrequently: true }); + const maskCanvas = document.createElement("canvas"); + maskCanvas.width = canvas.width; + maskCanvas.height = canvas.height; + const maskContext = maskCanvas.getContext("2d", { willReadFrequently: true }); + if (!context || !maskContext) { + throw new Error("MP4 Canvas 初始化失败"); + } + + const rgbFrame = scaledSourceFrame(layout.rgb_frame, layout, video); + const alphaFrame = scaledSourceFrame(layout.alpha_frame, layout, video); + return { + destroy() {}, + draw() { + context.clearRect(0, 0, canvas.width, canvas.height); + maskContext.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(video, ...rgbFrame, 0, 0, canvas.width, canvas.height); + maskContext.drawImage(video, ...alphaFrame, 0, 0, canvas.width, canvas.height); + const colorPixels = context.getImageData(0, 0, canvas.width, canvas.height); + const maskPixels = maskContext.getImageData(0, 0, canvas.width, canvas.height).data; + // 与 Android/iOS 客户端保持同一亮度与 smoothstep 阈值,后台看到的透明边缘才不会和 App 偏差。 + for (let index = 0; index < colorPixels.data.length; index += 4) { + const luminance = + (maskPixels[index] * 0.299 + maskPixels[index + 1] * 0.587 + maskPixels[index + 2] * 0.114) / 255; + const progress = clamp( + (luminance - alphaLowThreshold) / (alphaHighThreshold - alphaLowThreshold), + 0, + 1, + ); + colorPixels.data[index + 3] = Math.round(progress * progress * (3 - 2 * progress) * 255); + } + context.putImageData(colorPixels, 0, 0); + }, + }; +} + +function createProgram(gl, vertexSource, fragmentSource) { + const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource); + const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource); + const program = gl.createProgram(); + if (!program) { + throw new Error("MP4 WebGL Program 创建失败"); + } + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) || "MP4 WebGL Program 链接失败"; + gl.deleteProgram(program); + throw new Error(message); + } + return program; +} + +function compileShader(gl, type, source) { + const shader = gl.createShader(type); + if (!shader) { + throw new Error("MP4 WebGL Shader 创建失败"); + } + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const message = gl.getShaderInfoLog(shader) || "MP4 WebGL Shader 编译失败"; + gl.deleteShader(shader); + throw new Error(message); + } + return shader; +} + +function transparentLayoutKey(layout) { + if ( + !layout || + layout.alpha_layout === "normal" || + !validFrame(layout.rgb_frame) || + !validFrame(layout.alpha_frame) + ) { + return ""; + } + const videoWidth = positiveInteger(layout.video_w); + const videoHeight = positiveInteger(layout.video_h); + if (!videoWidth || !videoHeight) { + return ""; + } + return JSON.stringify({ + alpha_frame: layout.alpha_frame.slice(0, 4).map(Number), + alpha_layout: layout.alpha_layout, + rgb_frame: layout.rgb_frame.slice(0, 4).map(Number), + video_h: videoHeight, + video_w: videoWidth, + }); +} + +function normalizedRect(frame, layout) { + return new Float32Array([ + frame[0] / layout.video_w, + frame[1] / layout.video_h, + frame[2] / layout.video_w, + frame[3] / layout.video_h, + ]); +} + +function scaledSourceFrame(frame, layout, video) { + const scaleX = video.videoWidth / layout.video_w; + const scaleY = video.videoHeight / layout.video_h; + return [frame[0] * scaleX, frame[1] * scaleY, frame[2] * scaleX, frame[3] * scaleY]; +} + +function scaledOutputSize(width, height, maxEdge) { + const scale = Math.min(1, maxEdge / Math.max(width, height)); + return { + height: Math.max(1, Math.round(height * scale)), + width: Math.max(1, Math.round(width * scale)), + }; +} + +function validFrame(frame) { + return ( + Array.isArray(frame) && + frame.length >= 4 && + frame + .slice(0, 4) + .every((value, index) => Number.isFinite(Number(value)) && (index < 2 ? value >= 0 : value > 0)) + ); +} + +function positiveInteger(value) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : 0; +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +function prefersReducedMotion() { + return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; +} + +const vertexShaderSource = ` + attribute vec2 aPosition; + varying vec2 vOutputUv; + + void main() { + gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0); + vOutputUv = vec2(aPosition.x, 1.0 - aPosition.y); + } +`; + +const fragmentShaderSource = ` + precision mediump float; + uniform sampler2D uTexture; + uniform vec4 uRgbRect; + uniform vec4 uAlphaRect; + varying vec2 vOutputUv; + + vec2 mapRect(vec4 rect, vec2 uv) { + return vec2(rect.x + uv.x * rect.z, rect.y + uv.y * rect.w); + } + + void main() { + vec4 color = texture2D(uTexture, mapRect(uRgbRect, vOutputUv)); + vec4 mask = texture2D(uTexture, mapRect(uAlphaRect, vOutputUv)); + float alpha = dot(mask.rgb, vec3(0.299, 0.587, 0.114)); + alpha = smoothstep(${alphaLowThreshold.toFixed(2)}, ${alphaHighThreshold.toFixed(2)}, alpha); + gl_FragColor = vec4(color.rgb, alpha); + } +`; diff --git a/src/shared/ui/Mp4Preview.module.css b/src/shared/ui/Mp4Preview.module.css new file mode 100644 index 0000000..31e481d --- /dev/null +++ b/src/shared/ui/Mp4Preview.module.css @@ -0,0 +1,88 @@ +.root { + position: relative; + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.expanded { + min-height: 320px; + max-height: min(68vh, 640px); +} + +.media { + display: block; + width: auto; + max-width: 100%; + height: auto; + max-height: 100%; + object-fit: contain; +} + +.expanded .media { + max-height: min(68vh, 640px); +} + +.sourceVideo { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + opacity: 0; + pointer-events: none; +} + +.error { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-weight: 650; +} + +.playbackControl { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + display: inline-flex; + height: 34px; + align-items: center; + gap: var(--space-1); + padding: 0 var(--space-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.94); + color: var(--text-primary); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 650; + transition: + border-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + transform var(--motion-fast) var(--ease-standard); +} + +.playbackControl:hover, +.playbackControl:focus-visible { + border-color: var(--primary); + color: var(--primary); +} + +.playbackControl:active { + transform: scale(0.97); +} + +@media (prefers-reduced-motion: reduce) { + .playbackControl { + transition: none; + } +} diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx index 7e6d1ad..1fa8efe 100644 --- a/src/shared/ui/UploadField.jsx +++ b/src/shared/ui/UploadField.jsx @@ -15,6 +15,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { downloadResponse } from "@/shared/api/download"; import { uploadFile, uploadImage } from "@/shared/api/upload"; import { Button } from "@/shared/ui/Button.jsx"; +import { Mp4Preview } from "@/shared/ui/Mp4Preview.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import styles from "./UploadField.module.css"; @@ -30,8 +31,10 @@ export function UploadField({ kind = "image", label, hideType = false, + mp4Layout, onChange, onFileSelected, + onUploadingChange, panelClassName, previewClassName, showSourceActions = false, @@ -40,6 +43,8 @@ export function UploadField({ }) { const inputId = useId(); const inputRef = useRef(null); + const uploadGenerationRef = useRef(0); + const onUploadingChangeRef = useRef(onUploadingChange); const { showToast } = useToast(); const [uploading, setUploading] = useState(false); const [localPreview, setLocalPreview] = useState(null); @@ -53,14 +58,30 @@ export function UploadField({ const displayName = localPreview?.name || getDisplayValue(value); const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined; const canPreviewVideo = Boolean(source && assetKind === "mp4"); + const localPreviewURL = localPreview?.url; + const previewMp4Layout = Object.prototype.hasOwnProperty.call(localPreview?.selectionResult || {}, "mp4Layout") + ? localPreview.selectionResult.mp4Layout + : mp4Layout; + + useEffect(() => { + onUploadingChangeRef.current = onUploadingChange; + }, [onUploadingChange]); useEffect(() => { return () => { - if (localPreview?.url) { - URL.revokeObjectURL(localPreview.url); + // 上传请求无法中途取消时,用代次作废迟到结果,避免弹窗关闭后回写下一次编辑表单。 + uploadGenerationRef.current += 1; + onUploadingChangeRef.current?.(false); + }; + }, []); + + useEffect(() => { + return () => { + if (localPreviewURL) { + URL.revokeObjectURL(localPreviewURL); } }; - }, [localPreview]); + }, [localPreviewURL]); const openPicker = () => { if (!disabled && !uploading) { @@ -85,18 +106,38 @@ export function UploadField({ url: URL.createObjectURL(file), }; }); + const uploadGeneration = ++uploadGenerationRef.current; setUploading(true); + onUploadingChange?.(true); try { - await onFileSelected?.(file); + const selectionResult = await onFileSelected?.(file); + if (uploadGeneration !== uploadGenerationRef.current) { + return; + } + setLocalPreview((previous) => (previous ? { ...previous, selectionResult } : previous)); const result = useImageUpload ? await uploadImage(file) : await uploadFile(file); - onChange(result.url); + if (uploadGeneration !== uploadGenerationRef.current) { + return; + } + // 只有媒体检测和上传都成功后才把检测结果交给表单,避免新布局残留在旧 URL 上。 + if (selectionResult === undefined) { + onChange(result.url); + } else { + onChange(result.url, selectionResult); + } setLocalPreview(null); showToast("上传成功", "success"); } catch (err) { + if (uploadGeneration !== uploadGenerationRef.current) { + return; + } setLocalPreview(null); showToast(err.message || "上传失败", "error"); } finally { - setUploading(false); + if (uploadGeneration === uploadGenerationRef.current) { + setUploading(false); + onUploadingChange?.(false); + } } }; @@ -155,7 +196,12 @@ export function UploadField({ type="button" onClick={openPicker} > - + {uploading ? ( @@ -249,6 +295,7 @@ export function UploadField({
    setVideoPreviewOpen(false)} @@ -265,15 +312,13 @@ export function UploadField({ ); } -function VideoPreviewDialog({ label, onClose, open, src }) { +function VideoPreviewDialog({ label, mp4Layout, onClose, open, src }) { return ( {label} MP4
    - {src ? ( -
    @@ -283,7 +328,7 @@ function VideoPreviewDialog({ label, onClose, open, src }) { ); } -function AssetPreview({ assetKind, isImage, src }) { +function AssetPreview({ assetKind, isImage, mp4Layout, src }) { if (!src) { return ( @@ -297,6 +342,9 @@ function AssetPreview({ assetKind, isImage, src }) { if (assetKind === "pag") { return ; } + if (assetKind === "mp4") { + return ; + } if (assetKind === "image" || isImage) { return ; } @@ -390,7 +438,8 @@ function SVGAAssetPreview({ src }) { return ( - + {/* svgaplayerweb 2.3.2 只会为 HTMLDivElement 创建内部 Canvas,span 会在 setVideoItem 时稳定失败。 */} +
    {failed ? SVGA 预览失败 : null} ); diff --git a/src/shared/ui/UploadField.module.css b/src/shared/ui/UploadField.module.css index 6d390a9..f3bc43e 100644 --- a/src/shared/ui/UploadField.module.css +++ b/src/shared/ui/UploadField.module.css @@ -160,14 +160,14 @@ height: 100%; } -.playerSurface canvas, -.pagCanvas { - width: 100% !important; - height: 100% !important; +.playerSurface canvas { + display: block; } .pagCanvas { display: block; + width: 100% !important; + height: 100% !important; } .footer { From a99fe68c360c6bb70f3b022adb6f20a02f104a8e Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 19:37:17 +0800 Subject: [PATCH 11/14] fix(resources): block form submit during uploads --- src/features/resources/pages/ResourceListPage.jsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 98bf9bb..f888691 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -1033,6 +1033,14 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, return { ...previous, animationUrl, metadataJson }; }); }; + const handleSubmit = (event) => { + if (submitDisabled) { + // disabled 按钮之外再拦截表单事件,避免上传期间按 Enter 仍提交旧素材 URL。 + event.preventDefault(); + return; + } + onSubmit(event); + }; return ( From 3853330b085218e70888b2803eea32dc45d61f1a Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 20:36:19 +0800 Subject: [PATCH 12/14] feat(ops-center): explain 48h jackpot eligibility --- ops-center/src/api.js | 18 ++++- .../components/LuckyGiftConfigSections.jsx | 32 ++++---- .../src/components/LuckyGiftFieldHelp.jsx | 47 +++++++----- ops-center/src/configForm.js | 75 +++++++++++++++---- ops-center/src/styles/index.css | 33 ++++++++ 5 files changed, 155 insertions(+), 50 deletions(-) diff --git a/ops-center/src/api.js b/ops-center/src/api.js index 1c3be33..e206959 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -353,11 +353,21 @@ function luckyGiftConfigPayload(config = {}) { jackpot_spend_threshold_coins: numberValue( config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins, ), - jackpot_user_72h_rtp_max_percent: numberValue( - config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent, + jackpot_user_48h_rtp_max_percent: numberValue( + config.jackpot_user_48h_rtp_max_percent ?? + config.jackpotUser48hRTPMaxPercent ?? + config.jackpotUser48hRtpMaxPercent ?? + config.jackpot_user_72h_rtp_max_percent ?? + config.jackpotUser72hRTPMaxPercent ?? + config.jackpotUser72hRtpMaxPercent, ), - jackpot_user_day_rtp_max_percent: numberValue( - config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent, + jackpot_user_round_rtp_max_percent: numberValue( + config.jackpot_user_round_rtp_max_percent ?? + config.jackpotUserRoundRTPMaxPercent ?? + config.jackpotUserRoundRtpMaxPercent ?? + config.jackpot_user_day_rtp_max_percent ?? + config.jackpotUserDayRTPMaxPercent ?? + config.jackpotUserDayRtpMaxPercent, ), loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee), low_water_nonzero_factor_percent: numberValue( diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index 966d003..2d8e00b 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -377,15 +377,15 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } /> @@ -396,11 +396,13 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } onChange={onChange} />
    @@ -473,28 +475,30 @@ function JackpotFlowSummary() { return (
    - 1. 消费达标优先 - 当天累计消费每跨过一次门槛,就保存一次大奖资格;资格从下一抽开始使用。 + 1. 大盘先通过 + 本轮大盘 RTP 不高于全局触发线,才继续检查用户;等于触发线也可通过。
    - 2. 返还偏低补偿 - 没有待用资格时,整体、用户当天和近 72 小时平均返还都不高于触发线,才尝试大奖。 + 2. 用户两项都通过 + 用户本轮 RTP 和近 48 小时 RTP 都必须严格低于各自触发线,少一项都不能出大奖。
    - 3. 只从付得起的档位选 - 先剔除奖池或限额不足的倍率,再在剩余倍率中等机会选择;消费资格付不起时会保留。 + 3. 下次送礼尝试大奖 + + 用户下次送礼时,先剔除奖池或限额付不起的倍率,再随机选择;没有可支付倍率就不出大奖,本轮资格也不会顺延或重复使用。 +
    ); } -function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) { +function NumberField({ disabled = false, form, helperText, label, name, onChange, required = true, step = "1" }) { return ( } - required + required={required} size="small" slotProps={{ htmlInput: { inputMode: "decimal", min: 0, step } }} type="number" diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx index 09fc0e1..84bbddd 100644 --- a/ops-center/src/components/LuckyGiftFieldHelp.jsx +++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx @@ -23,7 +23,7 @@ const FIELD_HELP = { enabled: { summary: "决定新版本发布后是否立即用于真实送礼。关闭时规则仍会保存,但不会触发幸运开奖。", adjust: "首次配置或大幅调整时可先保持停用,核对资金比例和上限后再启用。", - constraint: "启用 dynamic_v3 前必须填完大奖消费门槛和所有返奖上限。", + constraint: "启用 dynamic_v3 前必须填完三项大奖 RTP 触发线和所有返奖上限。", }, target_rtp_percent: { summary: "表示基础奖档的配置目标。例如填 98,代表各阶段的“倍率 × 基础概率”预计返还约为 98%。", @@ -54,9 +54,10 @@ const FIELD_HELP = { "按整数金币向下取整,且必须与钱包实际发给收礼对象的金币完全一致,否则 V3 会拒绝开奖;V2 不使用该比例开奖。", }, settlement_window_wager: { - summary: "V3 按实际消费金币累计一轮返还观察窗口;某一抽跨过门槛时仍归旧窗口,下一抽才进入新窗口。", - adjust: "调大后更少重开窗口;调小后会更频繁清空全局窗口样本。V2 会先按礼物参考金额换算成窗口抽数。", - constraint: "必须大于 0。新窗口还没有消费样本时,不会触发返还偏低补偿大奖。", + summary: + "V3 按实际消费金币累计一轮大盘 RTP;某一抽跨过门槛时仍归旧轮,下一抽才进入新轮。大盘结算并重置时,也会按完全相同的起止时间结算每个用户的本轮 RTP。", + adjust: "调大后大盘和用户本轮 RTP 都会更久才结算;调小后两者都会更频繁结算。V2 会先按礼物参考金额换算成窗口抽数。", + constraint: "必须大于 0。新一轮还没有消费样本时,不会触发返还偏低大奖。", }, gift_price_reference: { summary: "V2 用它把累计消费换算成“折算次数”,同时计算用户阶段和每轮窗口的抽数;它不是礼物真实售价。", @@ -117,34 +118,42 @@ const FIELD_HELP = { jackpot_multipliers: { summary: "列出大奖可能采用的倍数,例如 200、500、1000。实际奖励按本抽消费金额乘以倍数计算。", adjust: "增加或调高倍数会放大奖励金额和资金压力;删除倍数会减少可选大奖。", - constraint: "用逗号分隔,必须都大于 1 且从小到大;系统会在所有付得起的大奖倍数中等机会选择一档。", + constraint: + "用逗号分隔,必须都大于 1 且从小到大;系统会在所有付得起的大奖倍数中等机会选择一档。这里的倍率不能再作为普通奖档设置正概率,否则配置无法发布。", }, jackpot_global_rtp_max_percent: { - summary: "当前 App、奖池、规则版本在本轮窗口内的平均返还不高于这条线时,才满足补偿大奖的全局条件。", + summary: + "系统每次达到结算流水后,会结束并重置一轮大盘 RTP。刚结算的本轮大盘 RTP 不高于这条线时,才继续检查用户的两项返还条件。", adjust: "调高会让补偿大奖更容易进入候选;调低会要求全局返还更低才触发。", constraint: - "必须大于 0% 且不超过 100%。全局、用户当日和近 72 小时三项都必须有消费样本并全部通过,才会直接选择可支付大奖。", + "必须大于 0% 且不超过 100%。大盘 RTP 等于本项配置值时可以通过;还必须同时满足用户本轮 RTP 严格低于本轮触发线、用户近 48 小时 RTP 严格低于长期触发线,且奖池和各项限额足够。闭窗资格只在用户下一次送礼时尝试一次,奖池或限额不足也不会顺延。", }, - jackpot_user_day_rtp_max_percent: { - summary: "用户当天的平均返还不高于这条线时,才允许尝试补偿大奖。", - adjust: "调高会让更多用户满足条件;调低会把大奖更集中给当天返还明显偏低的用户。", - constraint: "按当前奖池的 UTC 自然日统计;没有消费样本时不触发。必须大于 0 且不能超过全局触发线。", + jackpot_user_round_rtp_max_percent: { + summary: + "系统每次重置大盘 RTP 时,会按完全相同的起止时间结算这个用户的本轮 RTP。例如 9 点重置一次、10 点再次重置,10 点结算时就检查用户在 9 点到 10 点这一轮的总返奖 ÷ 总消费。", + adjust: "调高会让更多本轮返还偏低的用户通过;调低会更严格。用户本轮 RTP 必须严格小于配置值,刚好等于也不通过。", + constraint: + "只统计当前 App、当前奖池和同一结算轮次内的实际消费与返奖;本轮没有消费样本时不通过。它必须与近 48 小时条件同时通过,不能只满足其中一项。", }, - jackpot_user_72h_rtp_max_percent: { - summary: "用户最近连续 72 小时的平均返还不高于这条线时,才允许尝试补偿大奖。", - adjust: "调高会放宽长期条件;调低会要求用户近三天整体返还更低。", - constraint: "按当前奖池滚动 72 小时统计,不是三个本地自然日;没有消费样本时不触发,且不能超过全局触发线。", + jackpot_user_48h_rtp_max_percent: { + summary: + "判断这个用户近 48 小时是否整体返还偏低。本轮结算时,用户注册已满 48 小时,系统才会把结算时点向前 48 小时内的返奖金币加总,再除以同期实际消费金币;同一 App、奖池下跨房间、主播和设备合并统计,结果严格低于这条线才通过。", + adjust: "调高会更容易通过,调低会更严格;结果等于配置值也不通过。例如近 48 小时消费 10,000、返奖 9,500,RTP 为 95%,配置 96% 时通过;返奖 9,600、RTP 为 96% 时不通过。", + constraint: + "先看注册时间:注册未满 48 小时直接不通过;注册已满 48 小时后,不要求连续玩满 48 小时,只统计窗口内真实发生的消费和返奖。即使前 47 小时都没玩、最近只玩 5 分钟,也用这 5 分钟的数据计算;整段时间没有消费时不通过。它还必须与大盘条件、用户本轮条件同时通过,并受每日大奖次数、奖池余额和所有返奖上限限制。", }, max_jackpot_hits_per_user_day: { summary: "限制同一用户在当前奖池的一个 UTC 自然日内最多真正拿到多少次大奖。", adjust: "调高允许同一用户一天获得更多大奖;调低会更早停止该用户当天的大奖,不保证转给其他用户。", - constraint: "必须大于 0;达到次数后仍可参与普通奖档,尚未使用的消费资格会保留。", + constraint: + "必须大于 0;达到次数后,即使大盘、本轮和近 48 小时 RTP 都通过也不再出大奖,但仍可参与普通奖档;下一个 UTC 自然日重新累计。", }, jackpot_spend_threshold_coins: { - summary: "用户在当前奖池的同一 UTC 日内,每累计消费满一份门槛,就保存一个供后续抽奖使用的大奖资格。", - adjust: "按各 App 奖池确认的金币口径填写;调高会要求消费更多才获得资格,调低会更频繁地产生资格。", + summary: + "这是旧规则按用户日累计消费保存独立大奖资格时使用的历史字段。当前规则已停用这条路径,保留数值只为读取和回传旧版本配置。", + adjust: "当前不可编辑;无论原值是多少,都不会增加用户出大奖的机会。", constraint: - "启用前必须大于 0,系统不会根据币价或充值记录自行猜额度;一笔消费可产生多个资格,资格从下一抽开始用,未能支付时不会丢失。", + "当前大奖必须经过大盘条件,并同时满足用户本轮 RTP 与近 48 小时 RTP 两项条件;这个历史字段不能替代或绕过任何一项。", }, max_single_payout: { summary: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。", diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index 24655d3..90dc23e 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -11,8 +11,21 @@ import { DEFAULT_POOL_ID } from "./api.js"; const DYNAMIC_STRATEGY = "dynamic_v3"; const PPM_SCALE = 1_000_000; +const LEGACY_JACKPOT_RTP_MODE = "__legacy_jackpot_rtp_fields"; +const legacyJackpotRTPAliases = { + jackpot_user_48h_rtp_max_percent: [ + "jackpot_user_72h_rtp_max_percent", + "jackpotUser72hRTPMaxPercent", + "jackpotUser72hRtpMaxPercent", + ], + jackpot_user_round_rtp_max_percent: [ + "jackpot_user_day_rtp_max_percent", + "jackpotUserDayRTPMaxPercent", + "jackpotUserDayRtpMaxPercent", + ], +}; -// 只有跨 App 通用的算法参数才有默认值;用户日累计消费触发门槛和六维金额上限必须由运营按 App 奖池的金币口径填写, +// 只有跨 App 通用的算法参数才有默认值;六维金额上限必须由运营按 App 奖池的金币口径填写, // 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。 const dynamicDefaults = { anchor_daily_payout_cap: 0, @@ -26,8 +39,8 @@ const dynamicDefaults = { jackpot_global_rtp_max_percent: 98, jackpot_multipliers: [200, 500, 1000], jackpot_spend_threshold_coins: 0, - jackpot_user_72h_rtp_max_percent: 96, - jackpot_user_day_rtp_max_percent: 96, + jackpot_user_48h_rtp_max_percent: 96, + jackpot_user_round_rtp_max_percent: 96, loss_streak_guarantee: 5, low_water_nonzero_factor_percent: 70, low_watermark_coins: 10_000_000, @@ -55,8 +68,8 @@ const numericFields = [ "initial_pool_coins", "jackpot_global_rtp_max_percent", "jackpot_spend_threshold_coins", - "jackpot_user_72h_rtp_max_percent", - "jackpot_user_day_rtp_max_percent", + "jackpot_user_48h_rtp_max_percent", + "jackpot_user_round_rtp_max_percent", "loss_streak_guarantee", "low_water_nonzero_factor_percent", "low_watermark_coins", @@ -109,6 +122,12 @@ export function configToForm(config = {}) { stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion), strategy_version: strategyVersion, }; + // 只在明确收到旧 day/72h 且缺少对应 canonical 字段时进入兼容模式。页面始终编辑 canonical 值, + // 这个标记仅让纯函数保留旧对象形状;HTTP 层仍会归一成 round/48h 后再发送。 + form[LEGACY_JACKPOT_RTP_MODE] = Object.entries(legacyJackpotRTPAliases).some( + ([canonical, aliases]) => + !hasConfigValue(config, [canonical, snakeToCamel(canonical)]) && hasConfigValue(config, aliases), + ); for (const field of numericFields) { if (field === "target_rtp_percent") { form[field] = targetRTPPercent; @@ -119,7 +138,14 @@ export function configToForm(config = {}) { form[field] = "0"; continue; } - form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)); + const legacyAliases = legacyJackpotRTPAliases[field] || []; + form[field] = hasConfigValue(config, [field, snakeToCamel(field)]) + ? formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)) + : formNumber(firstConfigValue(config, legacyAliases) ?? defaultNumber(field, strategyVersion)); + } + if (form[LEGACY_JACKPOT_RTP_MODE]) { + form.jackpot_user_72h_rtp_max_percent = form.jackpot_user_48h_rtp_max_percent; + form.jackpot_user_day_rtp_max_percent = form.jackpot_user_round_rtp_max_percent; } return form; } @@ -156,6 +182,17 @@ export function formToConfig(config, form) { ? 0 : numberFromForm(form[field]); } + if (form[LEGACY_JACKPOT_RTP_MODE]) { + // 旧输入的纯函数 round-trip 保持原 shape,避免调用方只做本地比较时出现伪 diff;真正保存时 + // luckyGiftConfigPayload 会把这两个别名归一成 canonical HTTP 字段,绝不会双发两套字段。 + output.jackpot_user_72h_rtp_max_percent = output.jackpot_user_48h_rtp_max_percent; + output.jackpot_user_day_rtp_max_percent = output.jackpot_user_round_rtp_max_percent; + delete output.jackpot_user_48h_rtp_max_percent; + delete output.jackpot_user_round_rtp_max_percent; + } else { + delete output.jackpot_user_72h_rtp_max_percent; + delete output.jackpot_user_day_rtp_max_percent; + } return output; } @@ -307,23 +344,24 @@ export function validateLuckyGiftForm(form) { if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100) errors.push("全局大奖 RTP 上限必须在 0%-100% 之间"); if ( - number("jackpot_user_day_rtp_max_percent") <= 0 || - number("jackpot_user_day_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + number("jackpot_user_round_rtp_max_percent") <= 0 || + number("jackpot_user_round_rtp_max_percent") > number("jackpot_global_rtp_max_percent") ) - errors.push("用户每日 RTP 上限必须大于 0 且不超过全局上限"); + errors.push("用户本轮 RTP 触发线必须大于 0 且不超过全局上限"); if ( - number("jackpot_user_72h_rtp_max_percent") <= 0 || - number("jackpot_user_72h_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + number("jackpot_user_48h_rtp_max_percent") <= 0 || + number("jackpot_user_48h_rtp_max_percent") > number("jackpot_global_rtp_max_percent") ) - errors.push("用户 72 小时 RTP 上限必须大于 0 且不超过全局上限"); + errors.push("用户 48 小时 RTP 触发线必须大于 0 且不超过全局上限"); if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0"); if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0"); + if (form[LEGACY_JACKPOT_RTP_MODE] && form.enabled && number("jackpot_spend_threshold_coins") <= 0) + errors.push("启用前必须填写用户日累计消费触发门槛(金币)"); for (const [field, label] of payoutCapFields) { if (number(field) < 0) errors.push(`${label}不能小于 0`); } validateRechargeStages(form.stages || [], errors, form.enabled); if (form.enabled) { - if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写用户日累计消费触发门槛(金币)"); for (const [field, label] of payoutCapFields) { if (number(field) <= 0) errors.push(`启用前必须填写${label}`); } @@ -452,6 +490,17 @@ function formConfigNumber(config, snake, camel, fallback) { return formNumber(config[snake] ?? config[camel] ?? fallback); } +function firstConfigValue(config, keys) { + for (const key of keys) { + if (config[key] !== undefined && config[key] !== null) return config[key]; + } + return undefined; +} + +function hasConfigValue(config, keys) { + return firstConfigValue(config, keys) !== undefined; +} + function snakeToCamel(value) { return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase()); } diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index b565b0c..636911b 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -445,6 +445,35 @@ body { line-height: 1.5; } +.ops-jackpot-flow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--border); +} + +.ops-jackpot-flow > div { + display: grid; + align-content: start; + gap: 4px; + background: var(--bg-card-strong, #f8fafc); + padding: var(--space-2) var(--space-3); +} + +.ops-jackpot-flow strong { + color: var(--text-primary); + font-size: 12px; +} + +.ops-jackpot-flow span { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.5; +} + .ops-field-label { display: inline-flex; min-width: 0; @@ -896,6 +925,10 @@ body { grid-template-columns: minmax(0, 1fr); } + .ops-jackpot-flow { + grid-template-columns: minmax(0, 1fr); + } + .ops-stage-tabs { display: flex; max-width: 100vw; From d2898f3faee93c0ba8b441879bddd823f8e82668 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 10:35:05 +0800 Subject: [PATCH 13/14] fix(ops): validate lucky gift jackpot tiers --- ops-center/src/configForm.js | 24 ++++++++++++++++++++---- ops-center/src/configForm.test.js | 27 ++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index 90dc23e..316b750 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -284,6 +284,9 @@ export function validateLuckyGiftForm(form) { ) errors.push("等价抽数阶段门槛不正确"); + const jackpotMultipliers = + form.strategy_version === DYNAMIC_STRATEGY ? new Set(multiplierListFromForm(form.jackpot_multipliers)) : null; + // 阶段概率与 RTP 是两代策略共用的 owner 校验,fixed_v2 也不能跳过。 for (const stageKey of ["novice", "normal", "advanced"]) { const stage = form.stages?.find((item) => item.stage === stageKey); @@ -302,6 +305,19 @@ export function validateLuckyGiftForm(form) { errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`); if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly)) errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`); + const jackpotStageMultipliers = + jackpotMultipliers && + enabled + .filter( + (tier) => + jackpotMultipliers.has(numberFromForm(tier.multiplier)) && + percentToPPM(tier.probabilityPercent) > 0, + ) + .map((tier) => formatDecimal(numberFromForm(tier.multiplier))); + if (jackpotStageMultipliers?.length) { + // dynamic_v3 的大奖倍率只能走大奖补偿路径;普通阶段再给正概率会绕过 owner 的双 RTP 门槛。 + errors.push(`${stageLabel(stageKey)}普通奖档不能包含大奖倍率:${unique(jackpotStageMultipliers).join("x、")}x`); + } const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier }); if ( expected < number("target_rtp_percent") - number("control_band_percent") || @@ -333,11 +349,11 @@ export function validateLuckyGiftForm(form) { const jackpotTokens = String(form.jackpot_multipliers || "") .split(/[,,\s]+/) .filter(Boolean); - const jackpotMultipliers = multiplierListFromForm(form.jackpot_multipliers); + const jackpotMultiplierValues = multiplierListFromForm(form.jackpot_multipliers); if ( - jackpotTokens.length !== jackpotMultipliers.length || - !jackpotMultipliers.length || - jackpotMultipliers.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultipliers[index - 1])) + jackpotTokens.length !== jackpotMultiplierValues.length || + !jackpotMultiplierValues.length || + jackpotMultiplierValues.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultiplierValues[index - 1])) ) { errors.push("大奖倍率必须大于 1x 并严格递增"); } diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index 971a489..1c302dd 100644 --- a/ops-center/src/configForm.test.js +++ b/ops-center/src/configForm.test.js @@ -70,7 +70,6 @@ describe("ops center lucky gift dynamic_v3 form", () => { const enabled = { ...draft, enabled: true }; const errors = validateLuckyGiftForm(enabled); - expect(errors).toContain("启用前必须填写用户日累计消费触发门槛(金币)"); expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0"); expect(errors).toContain("高阶充值门槛必须逐维不低于正常阶段且至少一项更高"); expect(errors).toContain("启用前必须填写单次返奖上限"); @@ -151,6 +150,32 @@ describe("ops center lucky gift dynamic_v3 form", () => { expect(errors).toContain("单次返奖上限不能小于 0"); expect(errors).toContain("充值阶段门槛不能小于 0"); }); + + test("rejects jackpot multipliers that still have ordinary stage probability", () => { + const form = configToForm(completeDynamicConfig()); + const errors = validateLuckyGiftForm({ + ...form, + stages: form.stages.map((stage) => + stage.stage === "normal" + ? { + ...stage, + tiers: [ + ...stage.tiers, + { + enabled: true, + highWaterOnly: true, + multiplier: "200", + probabilityPercent: "0.01", + tierId: "normal_200x", + }, + ], + } + : stage, + ), + }); + + expect(errors).toContain("正常阶段普通奖档不能包含大奖倍率:200x"); + }); }); function completeDynamicConfig() { From 8c002e023bc81c8a9ce14531849d2e2e83a43b48 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 10:36:48 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=E6=B8=B8=E6=88=8F=E6=B5=81=E6=B0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/launch.json | 6 ++++ databi/src/social/SocialBiApp.jsx | 20 ++++++++--- databi/src/social/state.js | 35 ++++++++++++++++--- databi/src/social/views/DataTableView.jsx | 12 ++++--- databi/src/social/views/OverviewView.jsx | 14 +++++--- external-admin/__repro__.html | 13 +++++++ external-admin/__repro__.jsx | 42 +++++++++++++++++++++++ ops-center/src/configForm.js | 20 ++++++++--- ops-center/src/configForm.test.js | 26 ++++++++++++++ 9 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 external-admin/__repro__.html create mode 100644 external-admin/__repro__.jsx diff --git a/.claude/launch.json b/.claude/launch.json index 90164a1..63cf92b 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 7001 + }, + { + "name": "admin-platform-alt", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "7031"], + "port": 7031 } ] } diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index 1f79363..cd8af39 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -16,6 +16,7 @@ import { DATE_PRESETS, GRANULARITIES, createDefaultFilters, + createDefaultTableState, rangeLabel, readStateFromURL, toggleMultiValue, @@ -63,6 +64,7 @@ export function SocialAppIdentity({ app, appCode, appName, className = "", size export function SocialBiApp() { const initial = useMemo(() => readStateFromURL(), []); const [filters, setFilters] = useState(initial.filters); + const [tableState, setTableState] = useState(() => initial.table || createDefaultTableState()); const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview")); const data = useSocialBiData(filters, { includeFunnel: viewKey === "funnel", @@ -70,8 +72,8 @@ export function SocialBiApp() { }); useEffect(() => { - writeStateToURL(filters, viewKey); - }, [filters, viewKey]); + writeStateToURL(filters, viewKey, tableState); + }, [filters, tableState, viewKey]); const updateFilter = useCallback((key, value) => { setFilters((current) => ({ ...current, [key]: value })); @@ -81,9 +83,19 @@ export function SocialBiApp() { setFilters(createDefaultFilters()); }, []); + const updateTableState = useCallback((patch) => { + setTableState((current) => ({ ...current, ...patch })); + }, []); + + const openRequirementSection = useCallback((requirementSection) => { + // 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 新用户”。 + setTableState({ mode: "requirements", requirementSection }); + setViewKey("table"); + }, []); + const contextValue = useMemo( - () => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }), - [data, filters, resetFilters, updateFilter, viewKey] + () => ({ ...data, filters, openRequirementSection, resetFilters, setViewKey, tableState, updateFilter, updateTableState, viewKey }), + [data, filters, openRequirementSection, resetFilters, tableState, updateFilter, updateTableState, viewKey] ); const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component; diff --git a/databi/src/social/state.js b/databi/src/social/state.js index efdc835..b0c8b9b 100644 --- a/databi/src/social/state.js +++ b/databi/src/social/state.js @@ -1,10 +1,20 @@ -// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度, -// 并同步到 URL query,保证筛选状态可以直接作为链接分享。 +// 社交 BI v2 的全局筛选与可下钻视图状态:日期/App/区域/运营人员/趋势粒度,以及数据明细模式, +// 统一同步到 URL query,保证筛选结果和“数据需求”目标分组都可以直接作为链接分享。 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"]); + +export function createDefaultTableState() { + return { + mode: "wide", + requirementSection: "new_users" + }; +} + export const DATE_PRESETS = [ { key: "today", label: "今日" }, { key: "yesterday", label: "昨日" }, @@ -117,11 +127,12 @@ export function bucketDay(statDay, granularity) { return statDay; } -const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity"]; +const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity", "mode", "section"]; export function readStateFromURL(location = window.location) { const params = new URLSearchParams(location.search); const filters = createDefaultFilters(); + const table = createDefaultTableState(); let view = ""; if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) { filters.preset = params.get("preset"); @@ -143,16 +154,23 @@ export function readStateFromURL(location = window.location) { if (params.get("view")) { view = params.get("view"); } + const requestedTableMode = params.get("mode"); + if (view === "table" && TABLE_MODES.has(requestedTableMode)) { + table.mode = requestedTableMode; + if (requestedTableMode === "requirements" && REQUIREMENT_SECTIONS.has(params.get("section"))) { + table.requirementSection = params.get("section"); + } + } if (!filters.apps.length) { filters.apps = [ALL]; } if (!filters.regions.length) { filters.regions = [ALL]; } - return { filters, view }; + return { filters, table, view }; } -export function writeStateToURL(filters, view, history = window.history, location = window.location) { +export function writeStateToURL(filters, view, table, history = window.history, location = window.location) { const params = new URLSearchParams(location.search); URL_KEYS.forEach((key) => params.delete(key)); if (filters.preset !== "yesterday") { @@ -181,6 +199,13 @@ export function writeStateToURL(filters, view, history = window.history, locatio if (view && view !== "overview") { params.set("view", view); } + if (view === "table" && table?.mode === "requirements") { + // 目标分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个游戏数据视图。 + params.set("mode", "requirements"); + if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "new_users") { + params.set("section", table.requirementSection); + } + } const query = params.toString(); history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`); } diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx index f574b16..cf9004d 100644 --- a/databi/src/social/views/DataTableView.jsx +++ b/databi/src/social/views/DataTableView.jsx @@ -477,16 +477,18 @@ export function DataTableView() { range, refreshToken, requirements, - requirementsError + requirementsError, + tableState, + updateTableState } = useSocialBi(); - const [mode, setMode] = useState("wide"); const [dimKey, setDimKey] = useState("appDaily"); const [closedGroups, setClosedGroups] = useState(readClosedGroups); const [sort, setSort] = useState(null); - const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key); const [requirementRole, setRequirementRole] = useState("all"); const [requirementPayer, setRequirementPayer] = useState("all"); const [requirementNewUser, setRequirementNewUser] = useState("all"); + const mode = tableState?.mode || TABLE_MODES[0].key; + const requirementSection = tableState?.requirementSection || REQUIREMENT_SECTIONS[0].key; const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0]; const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]); @@ -575,7 +577,7 @@ export function DataTableView() { const handleRequirementSectionChange = (key) => { const nextSection = requirementSectionConfig(key); - setRequirementSection(nextSection.key); + updateTableState({ requirementSection: nextSection.key }); if (!nextSection.roleFilter) { setRequirementRole("all"); } @@ -889,7 +891,7 @@ export function DataTableView() { aria-checked={mode === item.key} className={mode === item.key ? "is-active" : ""} key={item.key} - onClick={() => setMode(item.key)} + onClick={() => updateTableState({ mode: item.key })} role="radio" type="button" > diff --git a/databi/src/social/views/OverviewView.jsx b/databi/src/social/views/OverviewView.jsx index 68d76db..7909faa 100644 --- a/databi/src/social/views/OverviewView.jsx +++ b/databi/src/social/views/OverviewView.jsx @@ -174,7 +174,7 @@ function OverviewSkeleton() { } export function OverviewView() { - const { appCodes, derived, filters, isLoading, setViewKey, updateFilter } = useSocialBi(); + const { appCodes, derived, filters, isLoading, openRequirementSection, setViewKey, updateFilter } = useSocialBi(); const [trendMetric, setTrendMetric] = useState("recharge_usd_minor"); const [compareMetric, setCompareMetric] = useState("recharge_usd_minor"); @@ -332,20 +332,26 @@ export function OverviewView() { {trendLabel}趋势 按 App 堆叠
    -
    +
    {TREND_METRICS.map((item) => ( ))} +
    diff --git a/external-admin/__repro__.html b/external-admin/__repro__.html new file mode 100644 index 0000000..54d509c --- /dev/null +++ b/external-admin/__repro__.html @@ -0,0 +1,13 @@ + + + + + + filter-bar repro + + + +
    + + + diff --git a/external-admin/__repro__.jsx b/external-admin/__repro__.jsx new file mode 100644 index 0000000..36c9fbf --- /dev/null +++ b/external-admin/__repro__.jsx @@ -0,0 +1,42 @@ +// 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/ops-center/src/configForm.js b/ops-center/src/configForm.js index f814777..2274762 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -233,6 +233,9 @@ export function validateLuckyGiftForm(form) { ) errors.push("等价抽数阶段门槛不正确"); + const jackpotMultipliers = + form.strategy_version === DYNAMIC_STRATEGY ? new Set(multiplierListFromForm(form.jackpot_multipliers)) : null; + // 阶段概率与 RTP 是两代策略共用的 owner 校验,fixed_v2 也不能跳过。 for (const stageKey of ["novice", "normal", "advanced"]) { const stage = form.stages?.find((item) => item.stage === stageKey); @@ -251,6 +254,15 @@ export function validateLuckyGiftForm(form) { errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`); if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly)) errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`); + const jackpotStageMultipliers = + jackpotMultipliers && + enabled + .filter((tier) => jackpotMultipliers.has(numberFromForm(tier.multiplier)) && percentToPPM(tier.probabilityPercent) > 0) + .map((tier) => formatDecimal(numberFromForm(tier.multiplier))); + if (jackpotStageMultipliers?.length) { + // dynamic_v3 的大奖倍率只能走大奖补偿路径;普通阶段再给正概率会绕过 owner 的双 RTP 门槛。 + errors.push(`${stageLabel(stageKey)}普通奖档不能包含大奖倍率:${unique(jackpotStageMultipliers).join("x、")}x`); + } const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier }); if ( expected < number("target_rtp_percent") - number("control_band_percent") || @@ -282,11 +294,11 @@ export function validateLuckyGiftForm(form) { const jackpotTokens = String(form.jackpot_multipliers || "") .split(/[,,\s]+/) .filter(Boolean); - const jackpotMultipliers = multiplierListFromForm(form.jackpot_multipliers); + const jackpotMultiplierValues = multiplierListFromForm(form.jackpot_multipliers); if ( - jackpotTokens.length !== jackpotMultipliers.length || - !jackpotMultipliers.length || - jackpotMultipliers.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultipliers[index - 1])) + jackpotTokens.length !== jackpotMultiplierValues.length || + !jackpotMultiplierValues.length || + jackpotMultiplierValues.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultiplierValues[index - 1])) ) { errors.push("大奖倍率必须大于 1x 并严格递增"); } diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index df9118d..6e22daf 100644 --- a/ops-center/src/configForm.test.js +++ b/ops-center/src/configForm.test.js @@ -130,6 +130,32 @@ describe("ops center lucky gift dynamic_v3 form", () => { expect(errors).toContain("单次返奖上限不能小于 0"); expect(errors).toContain("充值阶段门槛不能小于 0"); }); + + test("rejects jackpot multipliers that still have ordinary stage probability", () => { + const form = configToForm(completeDynamicConfig()); + const errors = validateLuckyGiftForm({ + ...form, + stages: form.stages.map((stage) => + stage.stage === "normal" + ? { + ...stage, + tiers: [ + ...stage.tiers, + { + enabled: true, + highWaterOnly: true, + multiplier: "200", + probabilityPercent: "0.01", + tierId: "normal_200x", + }, + ], + } + : stage, + ), + }); + + expect(errors).toContain("正常阶段普通奖档不能包含大奖倍率:200x"); + }); }); function completeDynamicConfig() {