ui相关修复

This commit is contained in:
zhx 2026-07-10 14:31:56 +08:00
parent cf98d5bd9a
commit ef260df749
19 changed files with 1532 additions and 1507 deletions

View File

@ -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 });
}

View File

@ -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();
});

View File

@ -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 || ""
}))
: []
}));

View File

@ -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({

View 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 Appyumi/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)"
}
];

View 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>
)
}
];
}

View 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 || "-"} />;
}

View 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)}
/>
);
}

View File

@ -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>
);
}

View File

@ -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));
}

View File

@ -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>
);
}

View File

@ -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>

View 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"
/>
);
}

View File

@ -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);
}

View 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;
}

View 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
View 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`;
}

View File

@ -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(

View File

@ -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 优先级必须高于 hoverhover 到别的项时当前页仍要保持高亮底色 */
.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;
}
}