ui相关修复
This commit is contained in:
parent
cf98d5bd9a
commit
ef260df749
@ -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 (
|
||||
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
|
||||
<div className="ops-page">
|
||||
<header className="ops-header">
|
||||
<div>
|
||||
<h1>运营配置中心</h1>
|
||||
</div>
|
||||
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
|
||||
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>{state.loading ? "同步中" : "已就绪"}</span>
|
||||
</header>
|
||||
|
||||
<OpsControls filters={filters} onFiltersChange={setFilters} onRefresh={loadDashboard} refreshing={state.loading} />
|
||||
{state.error ? <div className="ops-alert">{state.error}</div> : null}
|
||||
<OpsOverview summary={state.data.summary} />
|
||||
{activeView === "summary" ? (
|
||||
<SummarySection data={state.data} sectionConfig={sectionConfig} />
|
||||
) : (
|
||||
<OpsSection actions={activeConfig.actions} title={activeConfig.title}>
|
||||
<OpsTable columns={activeConfig.columns} emptyText={activeConfig.emptyText} items={state.data[activeConfig.dataKey]} />
|
||||
</OpsSection>
|
||||
)}
|
||||
{editingConfig ? (
|
||||
<Button disabled={state.loading} startIcon={<RefreshOutlined fontSize="small" />} onClick={loadDashboard}>
|
||||
刷新
|
||||
</Button>
|
||||
</PageHead>
|
||||
{/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */}
|
||||
{state.error && state.loaded ? (
|
||||
<div className="ops-alert" role="alert">
|
||||
{state.error}
|
||||
</div>
|
||||
) : null}
|
||||
<DataState error={state.loaded ? "" : state.error} loading={state.loading && !state.loaded} onRetry={loadDashboard}>
|
||||
{activeView === "overview" ? <OverviewView data={data} /> : null}
|
||||
{activeView === "configs" ? (
|
||||
<ConfigsView
|
||||
apps={data.apps}
|
||||
luckyGifts={data.luckyGifts}
|
||||
saving={savingConfig}
|
||||
onCreate={openCreateConfig}
|
||||
onEdit={openEditConfig}
|
||||
/>
|
||||
) : null}
|
||||
{activeView === "draws" ? <DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} /> : null}
|
||||
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
|
||||
</DataState>
|
||||
{dialog ? (
|
||||
<LuckyGiftConfigDialog
|
||||
config={editingConfig}
|
||||
onClose={() => setEditingConfig(null)}
|
||||
onSubmit={handleSaveLuckyGiftConfig}
|
||||
appOptions={appOptions}
|
||||
config={dialog.config}
|
||||
mode={dialog.mode}
|
||||
saving={savingConfig}
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={handleSaveConfig}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</OpsShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<OpsSection title="数据汇总">
|
||||
<div className="ops-summary-grid">
|
||||
{summaryViews.map((view) => {
|
||||
const config = sectionConfig[view.key];
|
||||
return (
|
||||
<article className="ops-summary-card" key={view.key}>
|
||||
<span>{view.label}</span>
|
||||
<strong>{data[config.dataKey]?.length || 0}</strong>
|
||||
<small>{config.emptyText}</small>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="ops-summary-table" aria-label="当前奖池余额" role="region">
|
||||
<div className="ops-summary-table__head">
|
||||
<h3>当前奖池余额</h3>
|
||||
</div>
|
||||
<OpsTable columns={poolBalanceColumns} emptyText="暂无奖池余额" items={data.poolBalances || []} />
|
||||
</div>
|
||||
</OpsSection>
|
||||
);
|
||||
}
|
||||
|
||||
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: (
|
||||
<button disabled={savingConfig} onClick={() => onOpenLuckyGiftConfig()} type="button">
|
||||
添加配置
|
||||
</button>
|
||||
),
|
||||
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) => (
|
||||
<button className="ops-inline-action" disabled={savingConfig} onClick={() => onOpenLuckyGiftConfig(item)} type="button">
|
||||
{item.is_default ? "添加配置" : "新增版本"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
],
|
||||
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 (
|
||||
<div className="ops-modal-layer">
|
||||
<div aria-label="添加幸运礼物配置" aria-modal="true" className="ops-modal ops-config-modal" role="dialog">
|
||||
<form className="ops-config-form" onSubmit={handleSubmit}>
|
||||
<header className="ops-modal__head ops-config-head">
|
||||
<div className="ops-config-title">
|
||||
<h2>{config.is_default ? "添加幸运礼物配置" : "新增幸运礼物版本"}</h2>
|
||||
<span>
|
||||
{form.app_code || "-"} / {form.pool_id || "default"} / {config.is_default ? "默认草稿" : `版本 ${config.rule_version ?? config.ruleVersion ?? "-"}`}
|
||||
</span>
|
||||
</div>
|
||||
<button aria-label="关闭" disabled={saving} onClick={onClose} type="button">
|
||||
X
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="ops-config-metrics">
|
||||
<ConfigMetric label="应用" value={form.app_code || "-"} />
|
||||
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
|
||||
<ConfigMetric label="状态" tone={form.enabled ? "success" : "muted"} value={form.enabled ? "启用" : "停用"} />
|
||||
<ConfigMetric label="目标 RTP" value={formatPercent(form.target_rtp_percent)} />
|
||||
<ConfigMetric label="奖池回收" value={formatPercent(form.pool_rate_percent)} />
|
||||
<ConfigMetric label="结算窗口" value={formatNumber(form.settlement_window_wager)} />
|
||||
</div>
|
||||
|
||||
<div className="ops-config-layout">
|
||||
<aside className="ops-config-params" aria-label="基础参数">
|
||||
<ConfigSection index="01" title="规则身份">
|
||||
<div className="ops-form-grid ops-form-grid--single">
|
||||
<label>
|
||||
应用
|
||||
<input disabled value={form.app_code} />
|
||||
</label>
|
||||
<label>
|
||||
奖池
|
||||
<input onChange={(event) => updateField("pool_id", event.target.value)} required value={form.pool_id} />
|
||||
</label>
|
||||
<label>
|
||||
状态
|
||||
<select onChange={(event) => updateField("enabled", event.target.value === "true")} value={String(form.enabled)}>
|
||||
<option value="false">停用</option>
|
||||
<option value="true">启用</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection index="02" title="RTP 与奖池">
|
||||
<div className="ops-form-grid">
|
||||
<NumberField form={form} label="目标 RTP (%)" name="target_rtp_percent" onChange={updateField} step="0.01" />
|
||||
<NumberField form={form} label="奖池回收率 (%)" name="pool_rate_percent" onChange={updateField} step="0.01" />
|
||||
<NumberField form={form} label="控制带宽 (%)" name="control_band_percent" onChange={updateField} step="0.01" />
|
||||
<NumberField form={form} label="结算窗口流水" name="settlement_window_wager" onChange={updateField} />
|
||||
<NumberField form={form} label="礼物参考金额" name="gift_price_reference" onChange={updateField} />
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
</aside>
|
||||
|
||||
<section className="ops-stage-designer" aria-label="阶段奖档设计">
|
||||
<header className="ops-stage-designer__head">
|
||||
<div>
|
||||
<h3>阶段奖档</h3>
|
||||
</div>
|
||||
</header>
|
||||
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
|
||||
{stageSummaries.map((summary) => (
|
||||
<button
|
||||
aria-selected={summary.stageKey === activeStageKey}
|
||||
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
|
||||
key={summary.stageKey}
|
||||
role="tab"
|
||||
type="button"
|
||||
onClick={() => setActiveStage(summary.stageKey)}
|
||||
>
|
||||
<span>{summary.tabLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ops-stage-stack">
|
||||
{activeStageConfig ? (
|
||||
<LuckyGiftStageEditor
|
||||
key={activeStageConfig.stage}
|
||||
onAddTier={addStageTier}
|
||||
onRemoveTier={removeStageTier}
|
||||
onUpdateTier={updateStageTier}
|
||||
stage={activeStageConfig}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="ops-modal__foot">
|
||||
<button disabled={saving} onClick={onClose} type="button">
|
||||
取消
|
||||
</button>
|
||||
<button disabled={saving} type="submit">
|
||||
{saving ? "保存中" : "保存配置"}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigMetric({ label, tone = "", value }) {
|
||||
return (
|
||||
<article className={["ops-config-metric", tone ? `is-${tone}` : ""].filter(Boolean).join(" ")}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigSection({ children, index, title }) {
|
||||
return (
|
||||
<section className="ops-config-section">
|
||||
<header>
|
||||
<span>{index}</span>
|
||||
<h3>{title}</h3>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LuckyGiftStageEditor({ onAddTier, onRemoveTier, onUpdateTier, stage }) {
|
||||
const stageLabel = stage.stage === "normal" ? "普通阶段" : stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage;
|
||||
|
||||
return (
|
||||
<section className="ops-stage-block">
|
||||
<header className="ops-stage-block__head">
|
||||
<div>
|
||||
<h3>{stageLabel}</h3>
|
||||
</div>
|
||||
<button onClick={() => onAddTier(stage.stage)} type="button">
|
||||
添加奖档
|
||||
</button>
|
||||
</header>
|
||||
<div className="ops-tier-table">
|
||||
<div className="ops-tier-head" aria-hidden="true">
|
||||
<span>倍率</span>
|
||||
<span>概率 %</span>
|
||||
<span>高水位</span>
|
||||
<span>启用</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{stage.tiers.map((tier, index) => (
|
||||
<div className="ops-tier-row" key={`${stage.stage}-${index}`}>
|
||||
<NumberField
|
||||
className="ops-tier-field"
|
||||
form={tier}
|
||||
label="倍率"
|
||||
name="multiplier"
|
||||
onChange={(_, value) => onUpdateTier(stage.stage, index, { multiplier: value, highWaterOnly: Number(value) >= 10 || tier.highWaterOnly })}
|
||||
step="0.01"
|
||||
/>
|
||||
<label className="ops-tier-field">
|
||||
<span>概率 %</span>
|
||||
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
|
||||
<input aria-label="概率 %" readOnly value={formatDecimal(tier.probabilityPercent)} />
|
||||
</label>
|
||||
<label className="ops-check-field">
|
||||
<input
|
||||
checked={Boolean(tier.highWaterOnly)}
|
||||
disabled={Number(tier.multiplier) >= 10}
|
||||
onChange={(event) => onUpdateTier(stage.stage, index, { highWaterOnly: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
高水位
|
||||
</label>
|
||||
<label className="ops-check-field">
|
||||
<input
|
||||
checked={tier.enabled !== false}
|
||||
onChange={(event) => onUpdateTier(stage.stage, index, { enabled: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<button disabled={stage.tiers.length <= 1} onClick={() => onRemoveTier(stage.stage, index)} type="button">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ className = "", form, label, name, onChange, step = "1" }) {
|
||||
return (
|
||||
<label className={className}>
|
||||
<span>{label}</span>
|
||||
<input aria-label={label} min="0" onChange={(event) => onChange(name, event.target.value)} required step={step} type="number" value={form[name]} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@ -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(<OpsCenterApp />);
|
||||
function renderApp() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<OpsCenterApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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(<OpsCenterApp />);
|
||||
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(<OpsCenterApp />);
|
||||
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(<OpsCenterApp />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@ -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 || ""
|
||||
}))
|
||||
: []
|
||||
}));
|
||||
|
||||
@ -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({
|
||||
|
||||
97
ops-center/src/components/AppsView.jsx
Normal file
97
ops-center/src/components/AppsView.jsx
Normal file
@ -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 (
|
||||
<div className="ops-view">
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox label="应用" placeholder="应用编码 / 名称" value={keyword} onChange={setKeyword} />
|
||||
<AdminFilterSelect
|
||||
label="状态"
|
||||
options={[
|
||||
["", "全部状态"],
|
||||
["active", "已启用"],
|
||||
["disabled", "已停用"]
|
||||
]}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<AdminFilterResetButton
|
||||
disabled={!keyword && !status}
|
||||
onClick={() => {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
columns={appColumns}
|
||||
emptyLabel="暂无应用"
|
||||
items={items}
|
||||
minWidth="880px"
|
||||
rowKey={(item) => item.app_code ?? item.appCode}
|
||||
title="应用列表"
|
||||
total={items.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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" ? (
|
||||
<OpsStatusBadge label="已启用" tone="succeeded" />
|
||||
) : (
|
||||
<OpsStatusBadge label={item.status || "-"} />
|
||||
),
|
||||
width: "minmax(112px, 0.6fr)"
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
label: "来源",
|
||||
// fixed 来源是 admin-server 为外部幸运礼物接入硬编码补齐的白名单 App(yumi/aslan),
|
||||
// 不在 app registry 主数据里,运营需要知道它们没有包名/平台等完整信息。
|
||||
render: (item) =>
|
||||
item.source === "fixed" ? <OpsStatusBadge label="外部接入" tone="warning" /> : <OpsStatusBadge label="主数据" tone="succeeded" />,
|
||||
width: "minmax(124px, 0.7fr)"
|
||||
},
|
||||
{
|
||||
key: "updated_at_ms",
|
||||
label: "更新时间",
|
||||
render: (item) => (item.source === "fixed" ? "-" : <TimeText value={item.updated_at_ms ?? item.updatedAtMs} />),
|
||||
width: "minmax(150px, 1fr)"
|
||||
}
|
||||
];
|
||||
90
ops-center/src/components/ConfigsView.jsx
Normal file
90
ops-center/src/components/ConfigsView.jsx
Normal file
@ -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 (
|
||||
<div className="ops-view">
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<Button disabled={saving} startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={onCreate}>
|
||||
添加配置
|
||||
</Button>
|
||||
}
|
||||
filters={<AdminFilterSelect label="应用" options={[["", "全部应用"], ...appOptions]} value={appFilter} onChange={setAppFilter} />}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
emptyLabel="暂无幸运礼物配置"
|
||||
items={items}
|
||||
minWidth="1080px"
|
||||
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
|
||||
title="幸运礼物配置"
|
||||
total={items.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <OpsStatusBadge label="默认草稿" tone="warning" />;
|
||||
}
|
||||
return item.enabled ? <OpsStatusBadge label="已启用" tone="succeeded" /> : <OpsStatusBadge label="已停用" />;
|
||||
},
|
||||
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 ? "-" : <TimeText value={item.created_at_ms} />),
|
||||
width: "minmax(176px, 1.1fr)"
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => (
|
||||
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
|
||||
{item.is_default ? "发布配置" : "新增版本"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
169
ops-center/src/components/DrawsView.jsx
Normal file
169
ops-center/src/components/DrawsView.jsx
Normal file
@ -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 (
|
||||
<div className="ops-view">
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminFilterSelect label="应用" options={appOptions} value={appCode || ""} onChange={changeAppCode} />
|
||||
<AdminFilterSelect
|
||||
label="奖池"
|
||||
options={[["", "全部奖池"], ...poolOptions]}
|
||||
value={filters.poolId}
|
||||
onChange={(value) => changeFilter("poolId", value)}
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
label="发放状态"
|
||||
options={[["", "全部状态"], ...drawStatusOptions]}
|
||||
value={filters.status}
|
||||
onChange={(value) => changeFilter("status", value)}
|
||||
/>
|
||||
<AdminSearchBox
|
||||
label="用户"
|
||||
placeholder="用户 ID / 外部用户 ID"
|
||||
value={filters.userId}
|
||||
onChange={(value) => changeFilter("userId", value)}
|
||||
/>
|
||||
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{state.error ? <div className="ops-alert" role="alert">{state.error}</div> : null}
|
||||
<DataTable
|
||||
columns={drawColumns}
|
||||
emptyLabel={state.loading ? "加载中..." : "暂无抽奖记录"}
|
||||
items={state.items}
|
||||
minWidth="1080px"
|
||||
pagination={{ onPageChange: setPage, page, total: state.total }}
|
||||
rowKey={(item) => item.draw_id ?? item.drawId}
|
||||
title="抽奖记录"
|
||||
total={state.total}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const drawColumns = [
|
||||
{
|
||||
key: "created_at_ms",
|
||||
label: "抽奖时间",
|
||||
render: (item) => <TimeText value={item.created_at_ms ?? item.createdAtMs} />,
|
||||
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 <OpsStatusBadge label="已发放" tone="succeeded" />;
|
||||
}
|
||||
if (status === "failed") {
|
||||
return <OpsStatusBadge label="发放失败" tone="danger" />;
|
||||
}
|
||||
if (status === "pending") {
|
||||
return <OpsStatusBadge label="发放中" tone="warning" />;
|
||||
}
|
||||
return <OpsStatusBadge label={status || "-"} />;
|
||||
}
|
||||
294
ops-center/src/components/LuckyGiftConfigDialog.jsx
Normal file
294
ops-center/src/components/LuckyGiftConfigDialog.jsx
Normal file
@ -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 (
|
||||
<Dialog fullWidth maxWidth="lg" open onClose={saving ? undefined : onClose}>
|
||||
<form className="ops-config-form" onSubmit={handleSubmit}>
|
||||
<DialogTitle className="ops-config-title">
|
||||
<span>{title}</span>
|
||||
<small>
|
||||
{form.app_code || "-"} / {form.pool_id || "default"} /{" "}
|
||||
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`}
|
||||
</small>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<div className="ops-config-metrics">
|
||||
<ConfigMetric label="应用" value={form.app_code || "-"} />
|
||||
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
|
||||
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
|
||||
<ConfigMetric label="奖池回收" value={formatPercent(Number(form.pool_rate_percent))} />
|
||||
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
|
||||
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
|
||||
</div>
|
||||
|
||||
<div className="ops-config-layout">
|
||||
<aside className="ops-config-params" aria-label="基础参数">
|
||||
<section className="ops-config-section">
|
||||
<h3>规则身份</h3>
|
||||
{isCreate ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="应用"
|
||||
select
|
||||
size="small"
|
||||
value={form.app_code}
|
||||
onChange={(event) => updateField("app_code", event.target.value)}
|
||||
>
|
||||
{appOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
helperText="修改奖池 ID 会发布到新的奖池,不影响原奖池"
|
||||
label="奖池"
|
||||
size="small"
|
||||
value={form.pool_id}
|
||||
onChange={(event) => updateField("pool_id", event.target.value)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<AdminSwitch checked={form.enabled} onChange={(event) => updateField("enabled", event.target.checked)} />}
|
||||
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
|
||||
sx={{ gap: 1, marginLeft: 0 }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="ops-config-section">
|
||||
<h3>RTP 与奖池</h3>
|
||||
<div className="ops-form-grid">
|
||||
<NumberField form={form} label="目标 RTP (%)" name="target_rtp_percent" step="0.01" onChange={updateField} />
|
||||
<NumberField form={form} label="奖池回收率 (%)" name="pool_rate_percent" step="0.01" onChange={updateField} />
|
||||
<NumberField form={form} label="控制带宽 (%)" name="control_band_percent" step="0.01" onChange={updateField} />
|
||||
<NumberField form={form} label="结算窗口流水" name="settlement_window_wager" onChange={updateField} />
|
||||
<NumberField form={form} label="礼物参考金额" name="gift_price_reference" onChange={updateField} />
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section className="ops-stage-designer" aria-label="阶段奖档设计">
|
||||
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
|
||||
{stageSummaries.map((summary) => (
|
||||
<button
|
||||
aria-selected={summary.stageKey === activeStageKey}
|
||||
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
|
||||
key={summary.stageKey}
|
||||
role="tab"
|
||||
type="button"
|
||||
onClick={() => setActiveStage(summary.stageKey)}
|
||||
>
|
||||
<span>{summary.tabLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeStageConfig ? (
|
||||
<div className="ops-stage-stack">
|
||||
<div className="ops-stage-block__head">
|
||||
<div>
|
||||
<h3>{activeSummary?.stageLabel}</h3>
|
||||
<OpsStatusBadge
|
||||
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
|
||||
tone={activeSummary?.ok ? "succeeded" : "warning"}
|
||||
/>
|
||||
</div>
|
||||
<Button startIcon={<AddOutlined fontSize="small" />} onClick={() => addStageTier(activeStageConfig.stage)}>
|
||||
添加奖档
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ops-tier-table">
|
||||
<div className="ops-tier-head" aria-hidden="true">
|
||||
<span>倍率</span>
|
||||
<span>概率 %</span>
|
||||
<span>高水位</span>
|
||||
<span>启用</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{activeStageConfig.tiers.map((tier, index) => (
|
||||
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`}>
|
||||
<TextField
|
||||
required
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" } }}
|
||||
type="number"
|
||||
value={tier.multiplier}
|
||||
onChange={(event) =>
|
||||
updateStageTier(activeStageConfig.stage, index, {
|
||||
// 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。
|
||||
highWaterOnly: Number(event.target.value) >= 10 || tier.highWaterOnly,
|
||||
multiplier: event.target.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
|
||||
<TextField
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { "aria-label": "概率 %", readOnly: true } }}
|
||||
value={formatDecimal(tier.probabilityPercent)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={Boolean(tier.highWaterOnly)}
|
||||
disabled={Number(tier.multiplier) >= 10}
|
||||
size="small"
|
||||
slotProps={{ input: { "aria-label": "高水位" } }}
|
||||
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { highWaterOnly: event.target.checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={tier.enabled !== false}
|
||||
size="small"
|
||||
slotProps={{ input: { "aria-label": "启用" } }}
|
||||
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { enabled: event.target.checked })}
|
||||
/>
|
||||
<Button
|
||||
disabled={activeStageConfig.tiers.length <= 1}
|
||||
variant="danger"
|
||||
onClick={() => removeStageTier(activeStageConfig.stage, index)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={saving} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={saving} type="submit" variant="primary">
|
||||
{saving ? "保存中" : "保存配置"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigMetric({ label, value }) {
|
||||
return (
|
||||
<article className="ops-config-metric">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ form, label, name, onChange, step = "1" }) {
|
||||
return (
|
||||
<TextField
|
||||
label={label}
|
||||
required
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { min: 0, step } }}
|
||||
type="number"
|
||||
value={form[name]}
|
||||
onChange={(event) => onChange(name, event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
export function OpsControls({ filters, onFiltersChange, onRefresh, refreshing }) {
|
||||
const updateFilter = (key, value) => {
|
||||
onFiltersChange({ ...filters, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="ops-controls" aria-label="运营筛选">
|
||||
<label>
|
||||
<span>应用</span>
|
||||
<input
|
||||
value={filters.appCode}
|
||||
onChange={(event) => updateFilter("appCode", event.target.value)}
|
||||
placeholder="app_code"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select value={filters.status} onChange={(event) => updateFilter("status", event.target.value)}>
|
||||
<option value="">全部</option>
|
||||
<option value="active">已启用</option>
|
||||
<option value="disabled">已停用</option>
|
||||
<option value="pending">待处理</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="ops-primary-action" disabled={refreshing} onClick={onRefresh} type="button">
|
||||
{refreshing ? "刷新中" : "刷新"}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
const summaryCards = [
|
||||
{ key: "activeApps", label: "运营应用" },
|
||||
{ key: "luckyGiftCount", label: "幸运礼物配置" },
|
||||
{ key: "lotteryCount", label: "抽奖次数" },
|
||||
{ key: "poolBalanceTotal", label: "当前奖池金币" }
|
||||
];
|
||||
|
||||
export function OpsOverview({ summary }) {
|
||||
return (
|
||||
<section className="ops-overview" aria-label="数据汇总">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="ops-kpi" key={card.key}>
|
||||
<span>{card.label}</span>
|
||||
<strong>{formatNumber(summary?.[card.key])}</strong>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
export function OpsSection({ actions, children, description, title }) {
|
||||
return (
|
||||
<section className="ops-section" aria-label={title}>
|
||||
<header className="ops-section__head">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="ops-section__actions">{actions}</div> : null}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -10,18 +10,21 @@ export function OpsShell({ activeView, children, onViewChange, views }) {
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="运营中心导航" className="ops-nav">
|
||||
{views.map((view) => (
|
||||
<button
|
||||
aria-current={activeView === view.key ? "page" : undefined}
|
||||
className={activeView === view.key ? "is-active" : ""}
|
||||
key={view.key}
|
||||
onClick={() => onViewChange(view.key)}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">{view.icon}</span>
|
||||
{view.label}
|
||||
</button>
|
||||
))}
|
||||
{views.map((view) => {
|
||||
const Icon = view.icon;
|
||||
return (
|
||||
<button
|
||||
aria-current={activeView === view.key ? "page" : undefined}
|
||||
className={activeView === view.key ? "is-active" : ""}
|
||||
key={view.key}
|
||||
onClick={() => onViewChange(view.key)}
|
||||
type="button"
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
{view.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="ops-main">{children}</main>
|
||||
|
||||
15
ops-center/src/components/OpsStatusBadge.jsx
Normal file
15
ops-center/src/components/OpsStatusBadge.jsx
Normal file
@ -0,0 +1,15 @@
|
||||
import Chip from "@mui/material/Chip";
|
||||
|
||||
// 状态徽章统一走 shared-ui 的 status-badge 体系,保证 ops-center 和主后台的状态色一致。
|
||||
// tone 为空时使用中性样式(灰点),用于"默认草稿/默认水位"这类非告警状态。
|
||||
export function OpsStatusBadge({ label, tone = "" }) {
|
||||
return (
|
||||
<Chip
|
||||
className={["status-badge", tone ? `status-badge--${tone}` : ""].filter(Boolean).join(" ")}
|
||||
icon={<span className="status-point" />}
|
||||
label={label}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
export function OpsTable({ columns, emptyText = "暂无数据", items }) {
|
||||
return (
|
||||
<div className="ops-table-wrap">
|
||||
<table className="ops-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key}>{column.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length ? (
|
||||
items.map((item, index) => (
|
||||
<tr key={rowKey(item, index)}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key}>{renderCell(item, column)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="ops-empty-cell" colSpan={columns.length}>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
124
ops-center/src/components/OverviewView.jsx
Normal file
124
ops-center/src/components/OverviewView.jsx
Normal file
@ -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 (
|
||||
<div className="ops-view">
|
||||
<div className="kpi-grid ops-kpi-grid">
|
||||
<KpiCard
|
||||
icon={AppsOutlined}
|
||||
label="运营应用"
|
||||
sub={`主数据 ${formatNumber(countBySource(data.apps, "registry"))} · 外部接入 ${formatNumber(countBySource(data.apps, "fixed"))}`}
|
||||
value={formatNumber(summary.activeApps)}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CardGiftcardOutlined}
|
||||
label="已发布奖池配置"
|
||||
sub={`启用 ${formatNumber(summary.enabledPools)} · 停用 ${formatNumber(summary.configuredPools - summary.enabledPools)}`}
|
||||
tone="info"
|
||||
value={formatNumber(summary.configuredPools)}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CasinoOutlined}
|
||||
label="抽奖次数"
|
||||
sub={`成功 ${formatNumber(summary.grantedDraws)} · 待发放 ${formatNumber(summary.pendingDraws)} · 失败 ${formatNumber(summary.failedDraws)}`}
|
||||
tone="warning"
|
||||
value={formatNumber(summary.totalDraws)}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={SavingsOutlined}
|
||||
label="奖池金币"
|
||||
sub={`可用 ${formatNumber(summary.poolAvailableTotal)} · 保底 ${formatNumber(summary.poolReserveTotal)}`}
|
||||
tone="success"
|
||||
value={formatNumber(summary.poolBalanceTotal)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={poolBalanceColumns}
|
||||
emptyLabel="暂无奖池余额"
|
||||
items={poolBalances}
|
||||
minWidth="920px"
|
||||
rowKey={(item) => `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`}
|
||||
title="奖池实时水位"
|
||||
total={poolBalances.length}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={appSummaryColumns}
|
||||
emptyLabel="暂无抽奖数据"
|
||||
items={appSummaries}
|
||||
minWidth="920px"
|
||||
rowKey={(item) => item.app_code}
|
||||
title="各应用抽奖汇总"
|
||||
total={appSummaries.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? <OpsStatusBadge label="默认水位" /> : <OpsStatusBadge label="已入账" tone="succeeded" />,
|
||||
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 ? "-" : <TimeText value={item.updated_at_ms ?? item.updatedAtMs} />,
|
||||
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 <OpsStatusBadge label="正常" tone="succeeded" />;
|
||||
}
|
||||
return <OpsStatusBadge label={`待发放 ${formatNumber(pending)} · 失败 ${formatNumber(failed)}`} tone={failed ? "danger" : "warning"} />;
|
||||
},
|
||||
width: "minmax(170px, 1.2fr)"
|
||||
}
|
||||
];
|
||||
|
||||
function countBySource(apps = [], source) {
|
||||
return apps.filter((item) => (item.source || "registry") === source).length;
|
||||
}
|
||||
158
ops-center/src/configForm.js
Normal file
158
ops-center/src/configForm.js
Normal file
@ -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;
|
||||
}
|
||||
32
ops-center/src/format.js
Normal file
32
ops-center/src/format.js
Normal file
@ -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`;
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user