From ef260df74920910b729bc82b8043f719d405660c Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 10 Jul 2026 14:31:56 +0800 Subject: [PATCH] =?UTF-8?q?ui=E7=9B=B8=E5=85=B3=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops-center/src/OpsCenterApp.jsx | 739 +++--------------- ops-center/src/OpsCenterApp.test.jsx | 183 +++-- ops-center/src/api.js | 193 +++-- ops-center/src/api.test.js | 146 ++-- ops-center/src/components/AppsView.jsx | 97 +++ ops-center/src/components/ConfigsView.jsx | 90 +++ ops-center/src/components/DrawsView.jsx | 169 ++++ .../src/components/LuckyGiftConfigDialog.jsx | 294 +++++++ ops-center/src/components/OpsControls.jsx | 30 - ops-center/src/components/OpsOverview.jsx | 23 - ops-center/src/components/OpsSection.jsx | 14 - ops-center/src/components/OpsShell.jsx | 27 +- ops-center/src/components/OpsStatusBadge.jsx | 15 + ops-center/src/components/OpsTable.jsx | 59 -- ops-center/src/components/OverviewView.jsx | 124 +++ ops-center/src/configForm.js | 158 ++++ ops-center/src/format.js | 32 + ops-center/src/main.jsx | 1 + ops-center/src/styles/index.css | 645 +++------------ 19 files changed, 1532 insertions(+), 1507 deletions(-) create mode 100644 ops-center/src/components/AppsView.jsx create mode 100644 ops-center/src/components/ConfigsView.jsx create mode 100644 ops-center/src/components/DrawsView.jsx create mode 100644 ops-center/src/components/LuckyGiftConfigDialog.jsx delete mode 100644 ops-center/src/components/OpsControls.jsx delete mode 100644 ops-center/src/components/OpsOverview.jsx delete mode 100644 ops-center/src/components/OpsSection.jsx create mode 100644 ops-center/src/components/OpsStatusBadge.jsx delete mode 100644 ops-center/src/components/OpsTable.jsx create mode 100644 ops-center/src/components/OverviewView.jsx create mode 100644 ops-center/src/configForm.js create mode 100644 ops-center/src/format.js diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx index e487084..3c55ee0 100644 --- a/ops-center/src/OpsCenterApp.jsx +++ b/ops-center/src/OpsCenterApp.jsx @@ -1,688 +1,165 @@ +import AppsOutlined from "@mui/icons-material/AppsOutlined"; +import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; +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 { fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; -import { stageOptions } from "@/features/lucky-gift/constants.js"; -import { - applyProbabilityMap, - correctRTPProbabilities, - decimal, - expectedRTPPercent, - formatDecimal, - probabilityTotal -} from "@/shared/utils/rtpProbability.js"; -import { OpsControls } from "./components/OpsControls.jsx"; -import { OpsOverview } from "./components/OpsOverview.jsx"; -import { OpsSection } from "./components/OpsSection.jsx"; +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 { AppsView } from "./components/AppsView.jsx"; +import { ConfigsView } from "./components/ConfigsView.jsx"; +import { DrawsView } from "./components/DrawsView.jsx"; +import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx"; import { OpsShell } from "./components/OpsShell.jsx"; -import { OpsTable } from "./components/OpsTable.jsx"; +import { OverviewView } from "./components/OverviewView.jsx"; const views = [ - { icon: "应", key: "apps", label: "应用列表" }, - { icon: "礼", key: "luckyGifts", label: "幸运礼物配置" }, - { icon: "奖", key: "lotteryRecords", label: "抽奖记录" }, - { icon: "数", key: "summary", label: "数据汇总" } + { icon: InsightsOutlined, key: "overview", label: "数据总览" }, + { icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" }, + { icon: CasinoOutlined, key: "draws", label: "抽奖记录" }, + { icon: AppsOutlined, key: "apps", label: "应用列表" } ]; const initialDashboard = { apps: [], - lotteryRecords: [], + appSummaries: [], luckyGifts: [], + poolBalances: [], summary: {} }; export function OpsCenterApp() { - const [activeView, setActiveView] = useState("apps"); - const [filters, setFilters] = useState({ appCode: "", status: "" }); - const [state, setState] = useState({ data: initialDashboard, error: "", loading: true }); - const [editingConfig, setEditingConfig] = useState(null); + 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 query = useMemo( - () => ({ - app_code: filters.appCode, - status: filters.status - }), - [filters] - ); + const { showToast } = useToast(); const loadDashboard = useCallback(async () => { setState((current) => ({ ...current, error: "", loading: true })); try { - const data = await fetchOpsDashboard(query); - setState({ data, error: "", loading: false }); + const data = await fetchOpsDashboard(); + setState({ data, error: "", loaded: true, loading: false }); } catch (error) { - // 运营中心先接前端子应用,接口灰度期间保留当前页面结构和筛选上下文,避免单个 404 让整页白屏。 + // 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。 setState((current) => ({ data: current.data || initialDashboard, error: error.message || "运营接口暂不可用", + loaded: current.loaded, loading: false })); } - }, [query]); + }, []); useEffect(() => { loadDashboard(); }, [loadDashboard]); - const openLuckyGiftConfig = useCallback( - (config) => { - const fallbackConfig = - state.data.luckyGifts.find((item) => (item.app_code ?? item.appCode) === state.data.selectedAppCode) ?? state.data.luckyGifts[0]; - if (!config && !fallbackConfig) { - setState((current) => ({ ...current, error: "暂无可添加配置的应用" })); - return; - } - setEditingConfig(cloneLuckyGiftConfig(config || fallbackConfig)); - }, - [state.data] + 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 handleSaveLuckyGiftConfig = useCallback( + // 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。 + 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); - setEditingConfig(null); + showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success"); + setDialog(null); await loadDashboard(); } catch (error) { - setState((current) => ({ ...current, error: error.message || "保存幸运礼物配置失败" })); + // 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。 + showToast(error.message || "保存幸运礼物配置失败", "error"); } finally { setSavingConfig(false); } }, - [loadDashboard] + [loadDashboard, showToast] ); - const sectionConfig = useMemo( - () => - createSectionConfig({ - onOpenLuckyGiftConfig: openLuckyGiftConfig, - savingConfig - }), - [openLuckyGiftConfig, savingConfig] - ); - const activeConfig = sectionConfig[activeView]; + const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? ""; return (
-
-
-

运营配置中心

-
+ {state.loading ? "同步中" : "已就绪"} -
- - - {state.error ?
{state.error}
: null} - - {activeView === "summary" ? ( - - ) : ( - - - - )} - {editingConfig ? ( + + + {/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */} + {state.error && state.loaded ? ( +
+ {state.error} +
+ ) : null} + + {activeView === "overview" ? : null} + {activeView === "configs" ? ( + + ) : null} + {activeView === "draws" ? : null} + {activeView === "apps" ? : null} + + {dialog ? ( setEditingConfig(null)} - onSubmit={handleSaveLuckyGiftConfig} + appOptions={appOptions} + config={dialog.config} + mode={dialog.mode} saving={savingConfig} + onClose={() => setDialog(null)} + onSubmit={handleSaveConfig} /> ) : null}
); } - -function SummarySection({ data, sectionConfig }) { - const summaryViews = views.filter((view) => view.key !== "summary"); - const poolBalanceColumns = [ - { key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode }, - { key: "pool_id", label: "Pool ID", render: (item) => item.pool_id ?? item.poolId }, - { 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) }, - { key: "materialized", label: "状态", render: renderPoolMaterialized }, - { key: "updated_at", label: "更新时间", render: (item) => renderTime(item, "updated_at") } - ]; - - return ( - -
- {summaryViews.map((view) => { - const config = sectionConfig[view.key]; - return ( -
- {view.label} - {data[config.dataKey]?.length || 0} - {config.emptyText} -
- ); - })} -
-
-
-

当前奖池余额

-
- -
-
- ); -} - -function createSectionConfig({ onOpenLuckyGiftConfig, savingConfig }) { - return { - apps: { - columns: [ - { key: "app_code", label: "应用编码", render: (item) => item.app_code ?? item.appCode }, - { key: "app_name", label: "应用名称", render: (item) => item.app_name ?? item.appName }, - { key: "status", label: "状态" }, - { key: "updated_at", label: "更新时间", render: renderTime } - ], - dataKey: "apps", - description: "管理可运营的应用实例和状态。", - emptyText: "暂无应用", - title: "应用列表" - }, - lotteryRecords: { - columns: [ - { key: "draw_id", label: "抽奖 ID", render: (item) => item.draw_id ?? item.drawId }, - { key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode }, - { key: "user_id", label: "用户/外部用户", render: (item) => item.external_user_id ?? item.externalUserId ?? item.user_id ?? item.userId }, - { key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId }, - { key: "effective_reward_coins", label: "中奖金币", render: (item) => item.effective_reward_coins ?? item.effectiveRewardCoins ?? 0 }, - { key: "created_at", label: "抽奖时间", render: renderTime } - ], - dataKey: "lotteryRecords", - description: "查询幸运礼物抽奖流水、奖品和用户命中记录。", - emptyText: "暂无抽奖记录", - title: "抽奖记录" - }, - luckyGifts: { - actions: ( - - ), - columns: [ - { key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode }, - { key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId }, - { key: "rule_version", label: "规则版本", render: (item) => (Number(item.rule_version ?? item.ruleVersion) > 0 ? item.rule_version ?? item.ruleVersion : "-") }, - { key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent ?? item.targetRtpPercent) }, - { key: "enabled", label: "状态", render: renderLuckyGiftStatus }, - { - key: "action", - label: "操作", - render: (item) => ( - - ) - } - ], - dataKey: "luckyGifts", - description: "维护幸运礼物奖池、开关和应用维度配置。", - emptyText: "暂无幸运礼物配置", - title: "幸运礼物配置" - } - }; -} - -function LuckyGiftConfigDialog({ config, onClose, onSubmit, saving }) { - const [form, setForm] = useState(() => configToForm(config)); - const [activeStage, setActiveStage] = useState("novice"); - 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); - - useEffect(() => { - setForm(configToForm(config)); - setActiveStage("novice"); - }, [config]); - - const updateField = (field, value) => { - setForm((current) => { - const next = { ...current, [field]: value }; - 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 addStageTier = (stageKey) => { - setForm((current) => - correctAllStageProbabilities({ - ...current, - stages: current.stages.map((stage) => - stage.stage === stageKey - ? { - ...stage, - tiers: [ - ...stage.tiers, - { - enabled: true, - 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)); - }; - - return ( -
-
-
-
-
-

{config.is_default ? "添加幸运礼物配置" : "新增幸运礼物版本"}

- - {form.app_code || "-"} / {form.pool_id || "default"} / {config.is_default ? "默认草稿" : `版本 ${config.rule_version ?? config.ruleVersion ?? "-"}`} - -
- -
- -
- - - - - - -
- -
- - -
-
-
-

阶段奖档

-
-
-
- {stageSummaries.map((summary) => ( - - ))} -
-
- {activeStageConfig ? ( - - ) : null} -
-
-
- -
- - -
-
-
-
- ); -} - -function ConfigMetric({ label, tone = "", value }) { - return ( -
- {label} - {value} -
- ); -} - -function ConfigSection({ children, index, title }) { - return ( -
-
- {index} -

{title}

-
- {children} -
- ); -} - -function LuckyGiftStageEditor({ onAddTier, onRemoveTier, onUpdateTier, stage }) { - const stageLabel = stage.stage === "normal" ? "普通阶段" : stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage; - - return ( -
-
-
-

{stageLabel}

-
- -
-
- - {stage.tiers.map((tier, index) => ( -
- onUpdateTier(stage.stage, index, { multiplier: value, highWaterOnly: Number(value) >= 10 || tier.highWaterOnly })} - step="0.01" - /> - - - - -
- ))} -
-
- ); -} - -function NumberField({ className = "", form, label, name, onChange, step = "1" }) { - return ( - - ); -} - -function configToForm(config = {}) { - const targetRTPPercent = formNumber(config.target_rtp_percent ?? config.targetRtpPercent); - return { - app_code: config.app_code || config.appCode || "", - pool_id: config.pool_id || config.poolId || "default", - enabled: Boolean(config.enabled), - target_rtp_percent: targetRTPPercent, - pool_rate_percent: formNumber(config.pool_rate_percent ?? config.poolRatePercent), - settlement_window_wager: formNumber(config.settlement_window_wager ?? config.settlementWindowWager), - control_band_percent: formNumber(config.control_band_percent ?? config.controlBandPercent), - gift_price_reference: formNumber(config.gift_price_reference ?? config.giftPriceReference), - stages: normalizeFormStages(config.stages, targetRTPPercent) - }; -} - -function formToConfig(config, form) { - return { - ...config, - app_code: form.app_code, - pool_id: form.pool_id, - enabled: Boolean(form.enabled), - target_rtp_percent: numberFromForm(form.target_rtp_percent), - pool_rate_percent: numberFromForm(form.pool_rate_percent), - settlement_window_wager: numberFromForm(form.settlement_window_wager), - control_band_percent: numberFromForm(form.control_band_percent), - gift_price_reference: numberFromForm(form.gift_price_reference), - stages: form.stages - }; -} - -function cloneLuckyGiftConfig(config) { - return JSON.parse(JSON.stringify(config)); -} - -function renderLuckyGiftStatus(item) { - if (item.is_default || Number(item.rule_version ?? item.ruleVersion) <= 0) { - return "默认草稿"; - } - return item.enabled ? "启用" : "停用"; -} - -function renderPoolMaterialized(item) { - return item.materialized === false ? "默认水位" : "已入账"; -} - -function formNumber(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? String(parsed) : "0"; -} - -function numberFromForm(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function normalizeFormStages(stages, targetRTPPercent = 95) { - const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage])); - return correctAllStageProbabilities({ - target_rtp_percent: targetRTPPercent, - 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 || "" - })) - }; - }) - }).stages; -} - -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 - }; - }) - }; -} - -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 }) - ]; - } - return [ - defaultTier(`${stage}_none`, 0, 10), - defaultTier(`${stage}_0_5x`, 0.5, 20), - defaultTier(`${stage}_1x`, 1, 55), - defaultTier(`${stage}_2x`, 2, 15) - ]; -} - -function defaultTier(tierID, multiplier, probabilityPercent, options = {}) { - return { - enabled: true, - highWaterOnly: Boolean(options.high_water_only), - multiplier, - probabilityPercent, - tierId: tierID - }; -} - -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; -} - -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 tabLabel = stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, ""); - - return { - expected, - ok: probabilityClosed && rtpInBand, - probability, - probabilityClosed, - rtpInBand, - stageKey: stage.stage, - status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合", - tabLabel - }; -} - -function formatPercent(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return "-"; - } - return `${parsed.toFixed(2)}%`; -} - -function formatNumber(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return "-"; - } - return new Intl.NumberFormat("zh-CN").format(parsed); -} - -function renderTime(item, key) { - const value = item[key] ?? item[`${key}_ms`] ?? item[key.replace(/_at$/, "AtMs")]; - const timestamp = Number(value); - if (!Number.isFinite(timestamp) || timestamp <= 0) { - return value || "-"; - } - return new Date(timestamp < 10_000_000_000 ? timestamp * 1000 : timestamp).toLocaleString("zh-CN", { hour12: false }); -} diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index eca8d0c..6a6b61b 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -1,95 +1,176 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; 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 { fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; vi.mock("./api.js", () => ({ + DEFAULT_POOL_ID: "default", + fetchLuckyGiftDraws: vi.fn(), fetchOpsDashboard: vi.fn(), saveLuckyGiftConfig: vi.fn() })); beforeEach(() => { vi.clearAllMocks(); - saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "default" }); + 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", status: "active" }], - lotteryRecords: [{ app_code: "lalu", draw_id: "draw-1", effective_reward_coins: 100, pool_id: "super_lucky", user_id: "10001" }], - luckyGifts: [{ app_code: "lalu", enabled: false, is_default: true, pool_id: "default", rule_version: 0, target_rtp_percent: 95 }], - poolBalances: [{ app_code: "lalu", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 }], - selectedAppCode: "lalu", - summary: { activeApps: 1, lotteryCount: 3, luckyGiftCount: 1, poolBalanceTotal: 382500, totalPrizeCoin: 1000 } + 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 + } }); }); -test("renders simplified ops center navigation and app table", async () => { - render(); +function renderApp() { + return render( + + + + ); +} + +test("renders overview with aggregated kpis and pool balances", async () => { + renderApp(); expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy(); expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy(); - ["应用列表", "幸运礼物配置", "抽奖记录", "数据汇总"].forEach((label) => { + ["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => { expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy(); }); - ["厂商列表", "密钥管理", "模块开通"].forEach((label) => { - expect(screen.queryByRole("button", { name: new RegExp(label) })).toBeNull(); - }); - const appSection = screen.getByRole("region", { name: "应用列表" }); - expect(within(appSection).getByText("Lalu")).toBeTruthy(); - expect(screen.getByText("运营应用")).toBeTruthy(); - expect(screen.getAllByText("幸运礼物配置").length).toBeGreaterThan(0); - expect(fetchOpsDashboard).toHaveBeenCalledWith({ app_code: "", status: "" }); + 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("switches to lucky gift config skeleton", async () => { - render(); +test("lists every pool config including published non-default pools", async () => { + renderApp(); + await screen.findByText("运营应用"); - await screen.findByText("Lalu"); fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - const luckyGiftSection = screen.getByRole("region", { name: "幸运礼物配置" }); - expect(within(luckyGiftSection).getByText("default")).toBeTruthy(); - expect(within(luckyGiftSection).getByText("目标 RTP")).toBeTruthy(); - expect(within(luckyGiftSection).getByText("默认草稿")).toBeTruthy(); - expect(within(luckyGiftSection).getAllByRole("button", { name: "添加配置" }).length).toBeGreaterThan(0); + // 核心回归: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("shows pool balances in summary view", async () => { - render(); +test("opens config dialog from a published pool row and saves a new version", async () => { + renderApp(); + await screen.findByText("运营应用"); - await screen.findByText("Lalu"); - fireEvent.click(screen.getByRole("button", { name: /数据汇总/ })); - - const poolBalanceSection = screen.getByRole("region", { name: "当前奖池余额" }); - expect(within(poolBalanceSection).getByText("default")).toBeTruthy(); - expect(within(poolBalanceSection).getByText("382,500")).toBeTruthy(); - expect(within(poolBalanceSection).getByText("默认水位")).toBeTruthy(); -}); - -test("opens lucky gift config dialog and saves a default config", async () => { - render(); - - await screen.findByText("Lalu"); fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - const luckyGiftSection = screen.getByRole("region", { name: "幸运礼物配置" }); + await screen.findByText("lucky"); - fireEvent.click(within(luckyGiftSection).getAllByRole("button", { name: "添加配置" })[0]); - const dialog = screen.getByRole("dialog", { name: "添加幸运礼物配置" }); - expect(within(dialog).getByText("新手阶段")).toBeTruthy(); + 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); - ["预算来源", "广播", "RTP 贡献", "单次返奖上限", "用户日返奖上限"].forEach((label) => { - expect(within(dialog).queryByText(label)).toBeNull(); - }); - fireEvent.change(within(dialog).getByLabelText("目标 RTP (%)"), { target: { value: "88" } }); + + 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: "default", + pool_id: "lucky", stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]), target_rtp_percent: 88 }) ); }); }); + +test("loads draw records for the first app with server pagination", async () => { + renderApp(); + await screen.findByText("运营应用"); + + 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(); +}); + +test("shows app registry and fixed integrations in apps view", async () => { + renderApp(); + await screen.findByText("运营应用"); + + fireEvent.click(screen.getByRole("button", { name: /应用列表/ })); + + 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 e4e10fd..1983fb4 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -1,23 +1,68 @@ import { getAccessToken } from "@/shared/api/request"; export const OPS_API_PREFIX = "/api/v1/admin/ops-center"; +export const DEFAULT_POOL_ID = "default"; export async function fetchOpsDashboard(query = {}) { - // Ops Center 先拉应用列表,再逐个读取默认配置草稿;GET /config 不写库,只有 PUT 才发布规则版本。 + // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 + // lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。 const appsPayload = await opsRequest("/apps", { query }); const apps = normalizeItems(appsPayload); - const selectedAppCode = query.app_code || firstAppCode(apps); - const luckyQuery = { - app_code: selectedAppCode - }; - const [luckyGifts, poolBalances, lotteryRecords, summary] = await Promise.all([ - fetchLuckyGiftConfigs(apps, selectedAppCode), - fetchLuckyGiftPoolBalances(apps, selectedAppCode), - opsRequest("/lucky-gifts/draws", { query: luckyQuery }), - opsRequest("/lucky-gifts/summary", { query: luckyQuery }) - ]); + const appCodes = uniqueAppCodes(apps); - return normalizeDashboard({ apps, lotteryRecords, luckyGifts, poolBalances, summary, selectedAppCode }); + 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 }); +} + +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] : []; +} + +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) + }; } export async function saveLuckyGiftConfig(config = {}) { @@ -61,54 +106,49 @@ export function buildOpsUrl(path, query = {}) { return url.toString(); } -export function normalizeDashboard(data = {}) { - const apps = normalizeItems(data.apps); - const lotteryRecords = normalizeItems(data.lotteryRecords); - const luckyGifts = normalizeItems(data.luckyGifts); - const poolBalances = normalizeItems(data.poolBalances); +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); - return { - apps, - lotteryRecords, - luckyGifts, - poolBalances, - selectedAppCode: data.selectedAppCode || firstAppCode(apps), - summary: normalizeSummary(data.summary, { - activeApps: apps.length, - lotteryCount: lotteryRecords.length, - luckyGiftCount: luckyGifts.length, - poolBalanceTotal: poolBalances.reduce((total, item) => total + numberValue(item.balance ?? item.Balance), 0) - }) + 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") }; + + return { apps, appSummaries, luckyGifts, poolBalances, summary }; } -function firstAppCode(items) { - const first = items.find((item) => item?.app_code || item?.appCode); - return first?.app_code || first?.appCode || "aslan"; -} - -async function fetchLuckyGiftConfigs(apps, selectedAppCode) { - const appCodes = uniqueAppCodes(apps); - const codes = appCodes.length ? appCodes : [selectedAppCode || "aslan"]; - const configs = await Promise.all( - codes.map(async (appCode) => { - const config = await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }); - return normalizeLuckyGiftConfig(config, appCode); - }) - ); - return configs.filter((config) => config.app_code); -} - -async function fetchLuckyGiftPoolBalances(apps, selectedAppCode) { - const appCodes = uniqueAppCodes(apps); - const codes = appCodes.length ? appCodes : [selectedAppCode || "aslan"]; - const groups = await Promise.all( - codes.map(async (appCode) => { - const payload = await opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }); - return normalizeItems(payload); - }) - ); - return groups.flat().filter((item) => item?.app_code || item?.appCode); +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) + }; } function uniqueAppCodes(apps) { @@ -131,28 +171,29 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") { return { ...config, app_code: appCode, - pool_id: config.pool_id || config.poolId || "default", - rule_version: ruleVersion, - is_default: ruleVersion <= 0 + // rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。 + is_default: ruleVersion <= 0, + pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, + rule_version: ruleVersion }; } function luckyGiftConfigPayload(config = {}) { const appCode = String(config.app_code || config.appCode || "").trim().toLowerCase(); - const poolID = String(config.pool_id || config.poolId || "default").trim() || "default"; + const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; return { app_code: appCode, - pool_id: poolID, + control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent), + effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMS), enabled: Boolean(config.enabled), - target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent), + 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), - control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent), - gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference), - novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws), - normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws), - effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMS), - stages: normalizeStages(config.stages) + stages: normalizeStages(config.stages), + target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent) }; } @@ -195,14 +236,8 @@ function normalizeItems(value) { return []; } -function normalizeSummary(value = {}, fallback = {}) { - return { - activeApps: numberValue(value.active_apps ?? value.activeApps ?? fallback.activeApps), - lotteryCount: numberValue(value.total_draws ?? value.lottery_count ?? value.lotteryCount ?? fallback.lotteryCount), - luckyGiftCount: numberValue(value.lucky_gift_count ?? value.luckyGiftCount ?? fallback.luckyGiftCount), - poolBalanceTotal: numberValue(value.pool_balance_total ?? value.poolBalanceTotal ?? fallback.poolBalanceTotal), - totalPrizeCoin: numberValue(value.total_reward_coins ?? value.total_prize_coin ?? value.totalPrizeCoin) - }; +function sumBy(items, key, fallbackKey) { + return items.reduce((total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), 0); } function numberValue(value) { @@ -218,11 +253,11 @@ function normalizeStages(stages) { stage: stage.stage || stage.Stage || "", tiers: Array.isArray(stage.tiers) ? stage.tiers.map((tier) => ({ - tier_id: tier.tier_id || tier.tierId || "", + 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), - high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly), - enabled: tier.enabled !== false + tier_id: tier.tier_id || tier.tierId || "" })) : [] })); diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js index baaefb5..aa52235 100644 --- a/ops-center/src/api.test.js +++ b/ops-center/src/api.test.js @@ -1,10 +1,17 @@ import { afterEach, expect, test, vi } from "vitest"; -import { buildOpsUrl, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js"; +import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js"; afterEach(() => { vi.restoreAllMocks(); }); +function jsonResponse(data) { + 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" }); @@ -12,78 +19,107 @@ 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("loads dashboard resources from ops api endpoints", async () => { +test("loads every pool config per app through the plural configs endpoint", async () => { const calls = []; vi.spyOn(window, "fetch").mockImplementation(async (url) => { - calls.push(String(url)); - const data = String(url).includes("/lucky-gifts/pools?") - ? { items: [{ app_code: "lalu", pool_id: "default", balance: 382500, available_balance: 361500, reserve_floor: 21000 }] } - : String(url).includes("/lucky-gifts/config?") - ? { pool_id: "default", target_rtp_percent: 95 } - : { items: [] }; - return new Response(JSON.stringify({ code: 0, data }), { - headers: { "Content-Type": "application/json" }, - status: 200 - }); - }); - - const data = await fetchOpsDashboard({ app_code: "lalu" }); - - expect(data.apps).toEqual([]); - expect(calls).toEqual([ - "http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?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/draws?app_code=lalu", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu" - ]); - expect(data.luckyGifts).toEqual([{ app_code: "lalu", is_default: true, pool_id: "default", rule_version: 0, target_rtp_percent: 95 }]); - expect(data.poolBalances).toEqual([{ app_code: "lalu", pool_id: "default", balance: 382500, available_balance: 361500, reserve_floor: 21000 }]); - expect(data.summary.poolBalanceTotal).toBe(382500); -}); - -test("derives app code for lucky gift resources from the app list", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { - calls.push(String(url)); - if (String(url).endsWith("/apps")) { - return new Response(JSON.stringify({ code: 0, data: { items: [{ app_code: "yumi" }] } }), { - headers: { "Content-Type": "application/json" }, - status: 200 + 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 (String(url).includes("/lucky-gifts/config?")) { - return new Response(JSON.stringify({ code: 0, data: { pool_id: "default", rule_version: 0 } }), { - headers: { "Content-Type": "application/json" }, - status: 200 - }); + 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 new Response(JSON.stringify({ code: 0, data: { items: [] } }), { - headers: { "Content-Type": "application/json" }, - status: 200 - }); + return jsonResponse({ items: [] }); }); const data = await fetchOpsDashboard(); - expect(data.selectedAppCode).toBe("yumi"); expect(calls).toEqual([ "http://localhost:3000/api/v1/admin/ops-center/apps", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=yumi", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/draws?app_code=yumi", - "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=yumi" + "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 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 }) + ]); +}); + +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 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 }); }); 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 new Response(JSON.stringify({ code: 0, data: { app_code: "aslan", pool_id: "default" } }), { - headers: { "Content-Type": "application/json" }, - status: 200 - }); + return jsonResponse({ app_code: "aslan", pool_id: "default" }); }); await saveLuckyGiftConfig({ diff --git a/ops-center/src/components/AppsView.jsx b/ops-center/src/components/AppsView.jsx new file mode 100644 index 0000000..b3f449c --- /dev/null +++ b/ops-center/src/components/AppsView.jsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from "react"; +import { AdminFilterResetButton, AdminFilterSelect, AdminListToolbar, AdminSearchBox } from "@/shared/ui/AdminListLayout.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { TimeText } from "@/shared/ui/TimeText.jsx"; +import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; + +export function AppsView({ apps }) { + const [keyword, setKeyword] = useState(""); + const [status, setStatus] = useState(""); + + // 应用主数据总量很小(个位数),筛选全部在前端完成,不回源。 + const items = useMemo(() => { + const normalizedKeyword = keyword.trim().toLowerCase(); + return apps.filter((item) => { + const code = String(item.app_code ?? item.appCode ?? "").toLowerCase(); + const name = String(item.app_name ?? item.appName ?? "").toLowerCase(); + if (normalizedKeyword && !code.includes(normalizedKeyword) && !name.includes(normalizedKeyword)) { + return false; + } + if (status && String(item.status || "").toLowerCase() !== status) { + return false; + } + return true; + }); + }, [apps, keyword, status]); + + return ( +
+ + + + { + setKeyword(""); + setStatus(""); + }} + /> + + } + /> + item.app_code ?? item.appCode} + title="应用列表" + total={items.length} + /> +
+ ); +} + +const appColumns = [ + { key: "app_code", label: "应用编码", render: (item) => item.app_code ?? item.appCode, width: "minmax(100px, 0.8fr)" }, + { key: "app_name", label: "应用名称", render: (item) => item.app_name ?? item.appName }, + { key: "platform", label: "平台", render: (item) => item.platform || "-", width: "minmax(80px, 0.6fr)" }, + { key: "package_name", label: "包名", render: (item) => item.package_name ?? item.packageName ?? "-", width: "minmax(180px, 1.4fr)" }, + { + key: "status", + label: "状态", + render: (item) => + String(item.status || "").toLowerCase() === "active" ? ( + + ) : ( + + ), + width: "minmax(112px, 0.6fr)" + }, + { + key: "source", + label: "来源", + // fixed 来源是 admin-server 为外部幸运礼物接入硬编码补齐的白名单 App(yumi/aslan), + // 不在 app registry 主数据里,运营需要知道它们没有包名/平台等完整信息。 + render: (item) => + item.source === "fixed" ? : , + width: "minmax(124px, 0.7fr)" + }, + { + key: "updated_at_ms", + label: "更新时间", + render: (item) => (item.source === "fixed" ? "-" : ), + width: "minmax(150px, 1fr)" + } +]; diff --git a/ops-center/src/components/ConfigsView.jsx b/ops-center/src/components/ConfigsView.jsx new file mode 100644 index 0000000..62f8a2e --- /dev/null +++ b/ops-center/src/components/ConfigsView.jsx @@ -0,0 +1,90 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import { useMemo, useState } from "react"; +import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { TimeText } from "@/shared/ui/TimeText.jsx"; +import { formatNumber, formatPercent } from "../format.js"; +import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; + +export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) { + 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 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} + /> +
+ ); +} + +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) => ( + + ) + } + ]; +} diff --git a/ops-center/src/components/DrawsView.jsx b/ops-center/src/components/DrawsView.jsx new file mode 100644 index 0000000..41a0e9f --- /dev/null +++ b/ops-center/src/components/DrawsView.jsx @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { drawStatusOptions } from "@/features/lucky-gift/constants.js"; +import { AdminFilterResetButton, AdminFilterSelect, AdminListToolbar, AdminSearchBox } from "@/shared/ui/AdminListLayout.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { TimeText } from "@/shared/ui/TimeText.jsx"; +import { fetchLuckyGiftDraws } from "../api.js"; +import { formatMultiplierFromPPM, formatNumber } from "../format.js"; +import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; + +const PAGE_SIZE = 20; + +const initialFilters = { poolId: "", status: "", userId: "" }; + +export function DrawsView({ apps, defaultAppCode, poolIDsByApp }) { + // 抽奖流水量大且服务端分页,必须选定应用后按需拉取,不能像配置那样一次拉全。 + const [appCode, setAppCode] = useState(defaultAppCode); + const [filters, setFilters] = useState(initialFilters); + const [page, setPage] = useState(1); + const [state, setState] = useState({ error: "", items: [], loading: false, total: 0 }); + + useEffect(() => { + // 首屏 dashboard 加载完成前 defaultAppCode 为空,就绪后自动选中第一个应用。 + setAppCode((current) => current || defaultAppCode); + }, [defaultAppCode]); + + useEffect(() => { + if (!appCode) { + return undefined; + } + let cancelled = false; + setState((current) => ({ ...current, error: "", loading: true })); + fetchLuckyGiftDraws({ appCode, page, pageSize: PAGE_SIZE, poolId: filters.poolId, status: filters.status, userId: filters.userId }) + .then((result) => { + if (cancelled) { + return; + } + setState((current) => ({ + error: "", + // 滚动加载:翻页时向已加载列表追加;筛选变化会把 page 重置为 1,从头替换。 + items: page > 1 ? [...current.items, ...result.items] : result.items, + loading: false, + total: result.total + })); + }) + .catch((error) => { + if (cancelled) { + return; + } + setState((current) => ({ ...current, error: error.message || "获取抽奖记录失败", loading: false })); + }); + return () => { + cancelled = true; + }; + }, [appCode, filters, page]); + + const changeAppCode = useCallback((nextAppCode) => { + setAppCode(nextAppCode); + // 奖池归属于应用,切换应用后旧奖池筛选一定失效,一并重置。 + setFilters(initialFilters); + setPage(1); + }, []); + + const changeFilter = useCallback((key, value) => { + setFilters((current) => ({ ...current, [key]: value })); + setPage(1); + }, []); + + const resetFilters = useCallback(() => { + setFilters(initialFilters); + setPage(1); + }, []); + + const appOptions = useMemo(() => apps.map((item) => [item.app_code ?? item.appCode, item.app_code ?? item.appCode]), [apps]); + const poolOptions = useMemo(() => (poolIDsByApp.get(appCode) || []).map((poolID) => [poolID, poolID]), [appCode, poolIDsByApp]); + const hasActiveFilters = Boolean(filters.poolId || filters.status || filters.userId); + + return ( +
+ + + changeFilter("poolId", value)} + /> + changeFilter("status", value)} + /> + changeFilter("userId", value)} + /> + + + } + /> + {state.error ?
{state.error}
: null} + item.draw_id ?? item.drawId} + title="抽奖记录" + total={state.total} + /> +
+ ); +} + +const drawColumns = [ + { + key: "created_at_ms", + label: "抽奖时间", + render: (item) => , + width: "minmax(150px, 1fr)" + }, + { key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode, width: "minmax(80px, 0.6fr)" }, + { + key: "user", + label: "用户", + // 外部接入应用只有 external_user_id,内部应用只有数字 user_id,两者互斥,取有值的一侧。 + render: (item) => item.external_user_id || item.externalUserId || item.user_id || item.userId || "-", + width: "minmax(120px, 1fr)" + }, + { key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId, width: "minmax(100px, 0.8fr)" }, + { key: "gift_id", label: "礼物", render: (item) => item.gift_id ?? item.giftId, width: "minmax(100px, 0.8fr)" }, + { + key: "multiplier_ppm", + label: "命中倍率", + render: (item) => formatMultiplierFromPPM(item.multiplier_ppm ?? item.multiplierPpm), + width: "minmax(88px, 0.6fr)" + }, + { key: "effective_reward_coins", label: "中奖金币", render: (item) => formatNumber(item.effective_reward_coins ?? item.effectiveRewardCoins) }, + { + key: "reward_status", + label: "发放状态", + render: (item) => renderRewardStatus(item.reward_status ?? item.rewardStatus), + width: "minmax(124px, 0.8fr)" + }, + { + key: "rule_version", + label: "规则版本", + render: (item) => `v${item.rule_version ?? item.ruleVersion ?? "-"}`, + width: "minmax(80px, 0.5fr)" + } +]; + +function renderRewardStatus(status) { + if (status === "granted") { + return ; + } + if (status === "failed") { + return ; + } + if (status === "pending") { + return ; + } + return ; +} diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx new file mode 100644 index 0000000..f93438d --- /dev/null +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -0,0 +1,294 @@ +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 { Button } from "@/shared/ui/Button.jsx"; +import { formatDecimal } from "@/shared/utils/rtpProbability.js"; +import { + configToForm, + correctAllStageProbabilities, + formToConfig, + nextStageMultiplier, + stageSummary +} 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 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 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 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 + }) + } + /> + {/* 概率由目标 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 ConfigMetric({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} + +function NumberField({ form, label, name, onChange, step = "1" }) { + return ( + onChange(name, event.target.value)} + /> + ); +} diff --git a/ops-center/src/components/OpsControls.jsx b/ops-center/src/components/OpsControls.jsx deleted file mode 100644 index 8403ab0..0000000 --- a/ops-center/src/components/OpsControls.jsx +++ /dev/null @@ -1,30 +0,0 @@ -export function OpsControls({ filters, onFiltersChange, onRefresh, refreshing }) { - const updateFilter = (key, value) => { - onFiltersChange({ ...filters, [key]: value }); - }; - - return ( -
- - - -
- ); -} diff --git a/ops-center/src/components/OpsOverview.jsx b/ops-center/src/components/OpsOverview.jsx deleted file mode 100644 index 8c11bec..0000000 --- a/ops-center/src/components/OpsOverview.jsx +++ /dev/null @@ -1,23 +0,0 @@ -const summaryCards = [ - { key: "activeApps", label: "运营应用" }, - { key: "luckyGiftCount", label: "幸运礼物配置" }, - { key: "lotteryCount", label: "抽奖次数" }, - { key: "poolBalanceTotal", label: "当前奖池金币" } -]; - -export function OpsOverview({ summary }) { - return ( -
- {summaryCards.map((card) => ( -
- {card.label} - {formatNumber(summary?.[card.key])} -
- ))} -
- ); -} - -function formatNumber(value) { - return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); -} diff --git a/ops-center/src/components/OpsSection.jsx b/ops-center/src/components/OpsSection.jsx deleted file mode 100644 index 06abeb5..0000000 --- a/ops-center/src/components/OpsSection.jsx +++ /dev/null @@ -1,14 +0,0 @@ -export function OpsSection({ actions, children, description, title }) { - return ( -
-
-
-

{title}

- {description ?

{description}

: null} -
- {actions ?
{actions}
: null} -
- {children} -
- ); -} diff --git a/ops-center/src/components/OpsShell.jsx b/ops-center/src/components/OpsShell.jsx index 24724b9..86a4fad 100644 --- a/ops-center/src/components/OpsShell.jsx +++ b/ops-center/src/components/OpsShell.jsx @@ -10,18 +10,21 @@ export function OpsShell({ activeView, children, onViewChange, views }) {
{children}
diff --git a/ops-center/src/components/OpsStatusBadge.jsx b/ops-center/src/components/OpsStatusBadge.jsx new file mode 100644 index 0000000..15ab62d --- /dev/null +++ b/ops-center/src/components/OpsStatusBadge.jsx @@ -0,0 +1,15 @@ +import Chip from "@mui/material/Chip"; + +// 状态徽章统一走 shared-ui 的 status-badge 体系,保证 ops-center 和主后台的状态色一致。 +// tone 为空时使用中性样式(灰点),用于"默认草稿/默认水位"这类非告警状态。 +export function OpsStatusBadge({ label, tone = "" }) { + return ( + } + label={label} + size="small" + variant="outlined" + /> + ); +} diff --git a/ops-center/src/components/OpsTable.jsx b/ops-center/src/components/OpsTable.jsx deleted file mode 100644 index 076c8d6..0000000 --- a/ops-center/src/components/OpsTable.jsx +++ /dev/null @@ -1,59 +0,0 @@ -export function OpsTable({ columns, emptyText = "暂无数据", items }) { - return ( -
- - - - {columns.map((column) => ( - - ))} - - - - {items.length ? ( - items.map((item, index) => ( - - {columns.map((column) => ( - - ))} - - )) - ) : ( - - - - )} - -
{column.label}
{renderCell(item, column)}
- {emptyText} -
-
- ); -} - -function rowKey(item, index) { - if (item.draw_id || item.drawId) { - return item.draw_id ?? item.drawId; - } - if ((item.app_code || item.appCode) && (item.pool_id || item.poolId)) { - return `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`; - } - return item.id ?? item.code ?? item.app_code ?? item.appCode ?? `${item.name || "row"}-${index}`; -} - -function renderCell(item, column) { - if (column.render) { - return column.render(item, column.key); - } - return textValue(item[column.key]); -} - -function textValue(value) { - if (value === undefined || value === null || value === "") { - return "-"; - } - if (typeof value === "boolean") { - return value ? "是" : "否"; - } - return String(value); -} diff --git a/ops-center/src/components/OverviewView.jsx b/ops-center/src/components/OverviewView.jsx new file mode 100644 index 0000000..8f98126 --- /dev/null +++ b/ops-center/src/components/OverviewView.jsx @@ -0,0 +1,124 @@ +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 { DataTable } from "@/shared/ui/DataTable.jsx"; +import { KpiCard } from "@/shared/ui/KpiCard.jsx"; +import { TimeText } from "@/shared/ui/TimeText.jsx"; +import { formatNumber, formatPercentFromPPM } from "../format.js"; +import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; + +export function OverviewView({ data }) { + const { appSummaries, poolBalances, summary } = data; + + return ( +
+
+ + + + +
+ + `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`} + title="奖池实时水位" + total={poolBalances.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)" + } +]; + +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 ; + }, + width: "minmax(170px, 1.2fr)" + } +]; + +function countBySource(apps = [], source) { + return apps.filter((item) => (item.source || "registry") === source).length; +} diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js new file mode 100644 index 0000000..8d907df --- /dev/null +++ b/ops-center/src/configForm.js @@ -0,0 +1,158 @@ +import { stageOptions } from "@/features/lucky-gift/constants.js"; +import { + applyProbabilityMap, + correctRTPProbabilities, + decimal, + expectedRTPPercent, + formatDecimal, + probabilityTotal +} from "@/shared/utils/rtpProbability.js"; +import { DEFAULT_POOL_ID } from "./api.js"; + +// 幸运礼物配置表单的纯领域逻辑:配置 <-> 表单换算、概率闭合校正、阶段摘要。 +// 与展示层分离,弹窗组件只负责渲染和事件绑定。 + +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 + }; +} + +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) + }; +} + +// 概率列必须整体闭合到 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 + }; + }) + }; +} + +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; + + 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 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; +} + +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 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 }) + ]; + } + return [ + defaultTier(`${stage}_none`, 0, 10), + defaultTier(`${stage}_0_5x`, 0.5, 20), + defaultTier(`${stage}_1x`, 1, 55), + defaultTier(`${stage}_2x`, 2, 15) + ]; +} + +function defaultTier(tierID, multiplier, probabilityPercent, options = {}) { + return { + enabled: true, + highWaterOnly: Boolean(options.high_water_only), + multiplier, + probabilityPercent, + tierId: tierID + }; +} + +export function formNumber(value) { + 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; +} diff --git a/ops-center/src/format.js b/ops-center/src/format.js new file mode 100644 index 0000000..dc77134 --- /dev/null +++ b/ops-center/src/format.js @@ -0,0 +1,32 @@ +export function formatNumber(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return "-"; + } + return new Intl.NumberFormat("zh-CN").format(parsed); +} + +export function formatPercent(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return "-"; + } + return `${parsed.toFixed(2)}%`; +} + +// 服务端 RTP 一律以 ppm(百万分比)存储避免浮点误差,展示层统一在这里换算成百分比。 +export function formatPercentFromPPM(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return "-"; + } + return `${(parsed / 10_000).toFixed(2)}%`; +} + +export function formatMultiplierFromPPM(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return "-"; + } + return `${(parsed / 1_000_000).toFixed(2).replace(/\.?0+$/, "")}x`; +} diff --git a/ops-center/src/main.jsx b/ops-center/src/main.jsx index 61c14b8..44f4414 100644 --- a/ops-center/src/main.jsx +++ b/ops-center/src/main.jsx @@ -7,6 +7,7 @@ import { theme } from "@/theme.js"; import { OpsCenterApp } from "./OpsCenterApp.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 a922d85..ee7e595 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -10,28 +10,24 @@ body { background: var(--bg-page); } -button, -input, -select { - font: inherit; -} - #ops-center-root { min-height: 100vh; } +/* ---- 应用外壳:左侧固定导航 + 内容区,宽度对齐主后台侧边栏(248px) ---- */ + .ops-shell { display: grid; min-height: 100vh; - grid-template-columns: 236px minmax(0, 1fr); + grid-template-columns: 248px minmax(0, 1fr); } .ops-sidebar { display: flex; flex-direction: column; gap: var(--space-5); - border-right: 1px solid #d9e2ef; - background: #ffffff; + border-right: 1px solid var(--border); + background: var(--bg-card); padding: var(--space-5) var(--space-3); } @@ -47,9 +43,9 @@ select { width: 36px; height: 36px; place-items: center; - border-radius: 8px; - background: #0f766e; - color: #fff; + border-radius: var(--radius-control); + background: var(--primary); + color: var(--active-contrast, #fff); font-size: 13px; font-weight: 800; } @@ -75,69 +71,52 @@ select { width: 100%; height: 38px; align-items: center; - gap: var(--space-2); + gap: var(--space-3); border: 0; - border-radius: 8px; + 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, -.ops-nav button.is-active { - background: #ecfdf5; - color: #0f766e; +.ops-nav button:hover { + 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; } -.ops-nav button span { - display: inline-grid; - width: 22px; - height: 22px; - place-items: center; - border-radius: 6px; - background: #e2e8f0; - color: #334155; - font-size: 12px; - font-weight: 800; -} - -.ops-nav button.is-active span { - background: #0f766e; - color: #fff; -} - .ops-main { min-width: 0; - background: #f6f8fb; + background: var(--bg-page); } +/* ---- 页面骨架 ---- */ + .ops-page { display: grid; gap: var(--space-5); padding: var(--space-6); } -.ops-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-4); +.ops-view { + display: grid; + gap: var(--space-5); } -.ops-header h1 { - margin: 0; - font-size: 24px; - letter-spacing: 0; - line-height: 1.25; -} - -.ops-header p, -.ops-section__head p { - margin: var(--space-1) 0 0; - color: var(--text-tertiary); +.ops-kpi-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); } .ops-status { @@ -159,275 +138,37 @@ select { color: var(--info); } -.ops-controls, -.ops-section, .ops-alert { - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-card); - box-shadow: var(--shadow-soft); -} - -.ops-controls { - display: grid; - grid-template-columns: repeat(2, minmax(160px, 1fr)) auto; - gap: var(--space-3); - padding: var(--space-4); -} - -.ops-controls label { - display: grid; - gap: var(--space-1); - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; -} - -.ops-controls input, -.ops-controls select { - height: 34px; - border: 1px solid var(--border); - border-radius: 8px; - background: #fff; - color: var(--text-primary); - padding: 0 var(--space-3); -} - -.ops-primary-action, -.ops-section__actions button, -.ops-inline-action, -.ops-modal__foot button:last-child { - align-self: end; - height: 34px; - border: 0; - border-radius: 8px; - background: #0f766e; - color: #fff; - cursor: pointer; - font-weight: 700; - padding: 0 var(--space-4); -} - -.ops-primary-action:disabled, -.ops-section__actions button:disabled, -.ops-inline-action:disabled, -.ops-modal__foot button:disabled { - cursor: wait; - opacity: 0.68; -} - -.ops-inline-action { - height: 30px; - padding: 0 var(--space-3); -} - -.ops-alert { - border-color: var(--warning-border); + 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-overview { - display: grid; - grid-template-columns: repeat(4, minmax(120px, 1fr)); - gap: var(--space-3); -} +/* ---- 幸运礼物配置弹窗 ---- */ -.ops-kpi, -.ops-summary-card { - display: grid; - gap: var(--space-1); - border: 1px solid var(--border); - border-radius: 8px; - background: #fff; - padding: var(--space-4); -} - -.ops-kpi span, -.ops-summary-card span { - color: var(--text-tertiary); - font-size: 12px; - font-weight: 700; -} - -.ops-kpi strong, -.ops-summary-card strong { - font-size: 24px; - line-height: 1.2; -} - -.ops-section { - display: grid; - gap: var(--space-4); - padding: var(--space-5); -} - -.ops-section__head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-4); -} - -.ops-section__head h2 { - margin: 0; - font-size: 18px; - letter-spacing: 0; -} - -.ops-table-wrap { - overflow-x: auto; - border: 1px solid var(--border); - border-radius: 8px; -} - -.ops-table { - width: 100%; - min-width: 720px; - border-collapse: collapse; -} - -.ops-table th, -.ops-table td { - border-bottom: 1px solid var(--row-border); - padding: 12px 14px; - text-align: left; - vertical-align: middle; -} - -.ops-table th { - background: #f8fafc; - color: var(--text-secondary); - font-size: 12px; - font-weight: 800; -} - -.ops-table td { - color: var(--text-primary); - font-size: 13px; -} - -.ops-table tr:last-child td { - border-bottom: 0; -} - -.ops-empty-cell { - height: 140px; - color: var(--text-tertiary); - text-align: center !important; -} - -.ops-summary-grid { - display: grid; - grid-template-columns: repeat(3, minmax(180px, 1fr)); - gap: var(--space-4); -} - -.ops-summary-card small { - color: var(--text-tertiary); -} - -.ops-summary-table { - display: grid; - gap: var(--space-3); -} - -.ops-summary-table__head h3 { - margin: 0; - font-size: 15px; - line-height: 1.2; -} - -.ops-modal-layer { - position: fixed; - inset: 0; - z-index: 30; - display: grid; - place-items: center; - background: rgba(15, 23, 42, 0.42); - padding: var(--space-5); -} - -.ops-modal { - box-sizing: border-box; - width: min(1440px, calc(100vw - 48px)); - max-height: calc(100vh - 48px); - overflow: auto; - border: 1px solid var(--border); - border-radius: 8px; - background: #fff; - box-shadow: 0 24px 60px rgba(15, 23, 42, 0.24); -} - -.ops-config-modal { - scrollbar-gutter: stable; -} - -.ops-modal form, .ops-config-form { - display: grid; - gap: var(--space-4); - padding: var(--space-5); -} - -.ops-modal__head, -.ops-modal__foot { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); -} - -.ops-config-head { - position: sticky; - z-index: 1; - top: 0; - margin: calc(var(--space-5) * -1) calc(var(--space-5) * -1) 0; - padding: var(--space-5); - border-bottom: 1px solid var(--border); - background: rgba(255, 255, 255, 0.96); - backdrop-filter: blur(12px); -} - -.ops-modal__head h2 { - margin: 0; - font-size: 18px; - line-height: 1.3; + display: contents; } .ops-config-title { display: grid; - min-width: 0; gap: var(--space-1); } -.ops-config-title span { - overflow: hidden; +.ops-config-title small { color: var(--text-tertiary); font-size: 12px; font-weight: 700; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ops-modal__head button, -.ops-modal__foot button:first-child { - height: 34px; - border: 1px solid var(--border); - border-radius: 8px; - background: #fff; - color: var(--text-secondary); - cursor: pointer; - font-weight: 700; - padding: 0 var(--space-3); } .ops-config-metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--space-2); + margin-bottom: var(--space-4); } .ops-config-metric { @@ -435,8 +176,8 @@ select { min-width: 0; gap: var(--space-1); border: 1px solid var(--border); - border-radius: 8px; - background: #f8fafc; + border-radius: var(--radius-control); + background: var(--bg-card-strong, #f8fafc); padding: 10px var(--space-3); } @@ -452,109 +193,54 @@ select { .ops-config-metric strong { overflow: hidden; color: var(--text-primary); - font-size: 16px; + font-size: 15px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } -.ops-config-metric.is-success { - border-color: var(--success-border); - background: var(--success-surface); -} - -.ops-config-metric.is-success strong { - color: var(--success); -} - -.ops-config-metric.is-muted strong { - color: var(--text-secondary); -} - .ops-config-layout { display: grid; - grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); align-items: start; - gap: var(--space-3); + gap: var(--space-4); } -.ops-config-params, -.ops-stage-designer { +.ops-config-params { display: grid; min-width: 0; gap: var(--space-4); } -.ops-config-params { - position: sticky; - top: 88px; -} - -.ops-config-section, -.ops-stage-designer { - border: 1px solid var(--border); - border-radius: 8px; - background: #fff; -} - .ops-config-section { display: grid; - gap: var(--space-2); - padding: var(--space-3); -} - -.ops-config-section > header, -.ops-stage-designer__head { - display: flex; - min-width: 0; - align-items: center; 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 > header { - justify-content: flex-start; -} - -.ops-stage-designer__head { - justify-content: space-between; -} - -.ops-config-section > header span { - display: inline-grid; - width: 26px; - height: 24px; - flex: 0 0 auto; - place-items: center; - border-radius: 6px; - background: var(--neutral-surface); - color: var(--text-secondary); - font-size: 11px; - font-weight: 900; -} - -.ops-config-section h3, -.ops-stage-designer__head h3 { +.ops-config-section h3 { margin: 0; color: var(--text-primary); font-size: 15px; line-height: 1.3; } -.ops-stage-designer__head { - padding: var(--space-3) var(--space-3) 0; -} - -.ops-stage-designer__head > div { - display: flex; - min-width: 0; - align-items: baseline; +.ops-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } -.ops-stage-designer__head span { - color: var(--text-tertiary); - font-size: 12px; - font-weight: 800; +.ops-stage-designer { + display: grid; + min-width: 0; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); } .ops-stage-tabs { @@ -568,7 +254,7 @@ select { .ops-stage-tab { display: inline-flex; min-width: 0; - height: 34px; + height: 38px; align-items: center; border: 0; border-bottom: 2px solid transparent; @@ -576,6 +262,8 @@ select { background: transparent; color: var(--text-secondary); cursor: pointer; + font-size: 13px; + font-weight: 800; padding: 0 var(--space-4); text-align: center; transition: @@ -585,80 +273,28 @@ select { } .ops-stage-tab:hover { - background: var(--primary-surface); + background: var(--primary-hover); } .ops-stage-tab.is-active { border-bottom-color: var(--primary); - background: transparent; color: var(--text-primary); } -.ops-stage-tab.is-active.is-ok { - border-bottom-color: var(--success); +/* 阶段有问题(概率未闭合 / RTP 偏离)时 tab 下划线转告警色,切换前就能看到哪个阶段需要处理 */ +.ops-stage-tab.is-warn { + color: var(--warning); } .ops-stage-tab.is-active.is-warn { border-bottom-color: var(--warning); -} - -.ops-stage-tab span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ops-stage-tab span { - font-size: 13px; - font-weight: 900; - line-height: 1.25; -} - -.ops-form-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-3); -} - -.ops-form-grid--single { - grid-template-columns: minmax(0, 1fr); -} - -.ops-form-grid label { - display: grid; - gap: var(--space-1); - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; -} - -.ops-form-grid input, -.ops-form-grid select { - height: 34px; - min-width: 0; - border: 1px solid var(--border); - border-radius: 8px; - background: #fff; - color: var(--text-primary); - padding: 0 var(--space-3); -} - -.ops-form-grid input:disabled { - background: #f8fafc; - color: var(--text-tertiary); + color: var(--warning); } .ops-stage-stack { display: grid; gap: var(--space-3); - padding: var(--space-3); -} - -.ops-stage-block { - display: grid; - gap: var(--space-2); - padding: 0; - background: transparent; + padding: var(--space-4); } .ops-stage-block__head { @@ -671,47 +307,22 @@ select { .ops-stage-block__head > div { display: flex; flex-wrap: wrap; - align-items: baseline; + align-items: center; gap: var(--space-3); } .ops-stage-block__head h3 { margin: 0; - font-size: 16px; -} - -.ops-stage-block__head span { - color: var(--text-secondary); - font-weight: 800; -} - -.ops-stage-block__head span.is-ok { - color: var(--success); -} - -.ops-stage-block__head span.is-warn { - color: var(--warning); -} - -.ops-stage-block__head button, -.ops-tier-row button { - height: 30px; - border: 1px solid var(--border); - border-radius: 8px; - background: #fff; - color: var(--text-secondary); - cursor: pointer; - font-weight: 700; - padding: 0 var(--space-3); + font-size: 15px; } .ops-tier-table { display: grid; min-width: 0; overflow-x: auto; - border: 1px solid #dbe4f0; - border-radius: 8px; - background: #fff; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); } .ops-tier-head, @@ -720,19 +331,19 @@ select { box-sizing: border-box; min-width: 520px; grid-template-columns: - minmax(104px, 1fr) - minmax(112px, 1fr) + minmax(110px, 1fr) + minmax(110px, 1fr) minmax(64px, auto) - minmax(58px, auto) - minmax(56px, auto); + minmax(64px, auto) + minmax(72px, auto); align-items: center; gap: var(--space-2); } .ops-tier-head { - min-height: 32px; + min-height: 34px; border-bottom: 1px solid var(--row-border); - background: #f8fafc; + background: var(--bg-card-strong, #f8fafc); color: var(--text-tertiary); font-size: 12px; font-weight: 900; @@ -748,48 +359,7 @@ select { border-bottom: 0; } -.ops-tier-row label { - display: grid; - gap: var(--space-1); - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; -} - -.ops-tier-row input, -.ops-tier-row select { - height: 30px; - min-width: 0; - border: 1px solid var(--border); - border-radius: 8px; - background: #fff; - color: var(--text-primary); - padding: 0 var(--space-3); -} - -.ops-tier-row input[readonly] { - background: #f8fafc; - color: var(--text-secondary); -} - -.ops-tier-field > span { - display: none; -} - -.ops-check-field { - display: inline-flex !important; - grid-template-columns: none !important; - align-items: center; - gap: var(--space-2) !important; - min-height: 30px; - white-space: nowrap; -} - -.ops-check-field input { - width: 18px; - height: 18px; - padding: 0; -} +/* ---- 响应式 ---- */ @media (max-width: 960px) { .ops-shell { @@ -801,68 +371,37 @@ select { } .ops-nav, - .ops-overview, - .ops-controls, - .ops-config-metrics, - .ops-stage-tabs, - .ops-summary-grid { + .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-params { - position: static; - } } @media (max-width: 640px) { - .ops-modal-layer { - padding: var(--space-2); - } - - .ops-modal { - width: auto; - max-width: 100%; - max-height: calc(100vh - var(--space-4)); - justify-self: stretch; - } - .ops-page { padding: var(--space-4); } - .ops-header, - .ops-section__head { - display: grid; - } - .ops-nav, - .ops-overview, - .ops-controls, + .ops-kpi-grid, .ops-config-metrics, .ops-form-grid, - .ops-stage-tabs, - .ops-tier-row, - .ops-summary-grid { + .ops-tier-row { grid-template-columns: 1fr; } - .ops-config-head { - margin: calc(var(--space-4) * -1) calc(var(--space-4) * -1) 0; - padding: var(--space-4); - } - - .ops-modal form, - .ops-config-form { - padding: var(--space-4); - } - - .ops-stage-block__head, - .ops-stage-block__head > div { - align-items: flex-start; - flex-direction: column; + .ops-tier-head { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .ops-nav button, + .ops-stage-tab { + transition: none; } }