Add ops center lucky gift platform

This commit is contained in:
zhx 2026-07-08 22:30:46 +08:00
parent 7711f2a9ee
commit 5e9ed4c0c5
37 changed files with 6202 additions and 28 deletions

View File

@ -2874,6 +2874,252 @@
"x-permissions": ["report:view"]
}
},
"/admin/policy-templates": {
"get": {
"operationId": "listPolicyTemplates",
"parameters": [
{ "in": "query", "name": "keyword", "schema": { "type": "string" } },
{ "in": "query", "name": "status", "schema": { "type": "string", "enum": ["active", "disabled"] } },
{ "in": "query", "name": "page", "schema": { "type": "integer", "minimum": 1 } },
{ "in": "query", "name": "page_size", "schema": { "type": "integer", "minimum": 1, "maximum": 100 } }
],
"responses": {
"200": {
"description": "收益政策模板分页",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/Envelope" },
{
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/PolicyTemplate" }
},
"page": { "type": "integer" },
"pageSize": { "type": "integer" },
"page_size": { "type": "integer" },
"total": { "type": "integer", "format": "int64" }
}
}
}
}
]
}
}
}
}
},
"x-permission": "policy-template:view",
"x-permissions": ["policy-template:view"]
},
"post": {
"operationId": "savePolicyTemplate",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/PolicyTemplateInput" }
}
}
},
"responses": {
"201": {
"description": "已保存的收益政策模板",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/Envelope" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/PolicyTemplate" }
}
}
]
}
}
}
}
},
"x-permission": "policy-template:create",
"x-permissions": ["policy-template:create"]
}
},
"/admin/policy-instances": {
"get": {
"operationId": "listPolicyInstances",
"parameters": [
{ "in": "query", "name": "keyword", "schema": { "type": "string" } },
{ "in": "query", "name": "status", "schema": { "type": "string", "enum": ["active", "disabled"] } },
{ "in": "query", "name": "page", "schema": { "type": "integer", "minimum": 1 } },
{ "in": "query", "name": "page_size", "schema": { "type": "integer", "minimum": 1, "maximum": 100 } }
],
"responses": {
"200": {
"description": "收益政策实例分页",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/Envelope" },
{
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/PolicyInstance" }
},
"page": { "type": "integer" },
"pageSize": { "type": "integer" },
"page_size": { "type": "integer" },
"total": { "type": "integer", "format": "int64" }
}
}
}
}
]
}
}
}
}
},
"x-permission": "policy-template:view",
"x-permissions": ["policy-template:view"]
},
"post": {
"operationId": "createPolicyInstance",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/PolicyInstanceInput" }
}
}
},
"responses": {
"201": {
"description": "已创建的收益政策实例",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/Envelope" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/PolicyInstance" }
}
}
]
}
}
}
}
},
"x-permission": "policy-instance:create",
"x-permissions": ["policy-instance:create"]
}
},
"/admin/policy-instances/{instance_id}/publish": {
"post": {
"operationId": "publishPolicyInstance",
"parameters": [
{
"name": "instance_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "收益政策发布结果",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/Envelope" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/PolicyPublishResult" }
}
}
]
}
}
}
}
},
"x-permission": "policy-instance:publish",
"x-permissions": ["policy-instance:publish"]
}
},
"/admin/policy-instances/{instance_id}/status": {
"put": {
"operationId": "updatePolicyInstanceStatus",
"parameters": [
{
"name": "instance_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["status"],
"properties": {
"status": { "type": "string", "enum": ["active", "disabled"] }
}
}
}
}
},
"responses": {
"200": {
"description": "已更新状态的收益政策实例",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/Envelope" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/PolicyInstance" }
}
}
]
}
}
}
}
},
"x-permission": "policy-instance:update",
"x-permissions": ["policy-instance:update"]
}
},
"/admin/payment/recharge-bills": {
"get": {
"operationId": "listRechargeBills",
@ -6968,6 +7214,83 @@
}
}
},
"PolicyTemplate": {
"type": "object",
"properties": {
"template_id": { "type": "integer", "format": "int64" },
"version_id": { "type": "integer", "format": "int64" },
"template_code": { "type": "string" },
"template_version": { "type": "string" },
"name": { "type": "string" },
"status": { "type": "string", "enum": ["active", "disabled"] },
"rule_json": { "type": "object", "additionalProperties": true },
"description": { "type": "string" },
"created_at_ms": { "type": "integer", "format": "int64" },
"updated_at_ms": { "type": "integer", "format": "int64" }
}
},
"PolicyTemplateInput": {
"type": "object",
"required": ["template_code", "template_version", "name", "rule_json"],
"properties": {
"template_code": { "type": "string" },
"template_version": { "type": "string" },
"name": { "type": "string" },
"status": { "type": "string", "enum": ["active", "disabled"] },
"rule_json": { "type": "object", "additionalProperties": true },
"description": { "type": "string" }
}
},
"PolicyInstance": {
"type": "object",
"properties": {
"instance_id": { "type": "integer", "format": "int64" },
"app_code": { "type": "string" },
"instance_code": { "type": "string" },
"template_code": { "type": "string" },
"template_version": { "type": "string" },
"region_scope": { "type": "string" },
"status": { "type": "string", "enum": ["active", "disabled"] },
"effective_from_ms": { "type": "integer", "format": "int64" },
"effective_to_ms": { "type": "integer", "format": "int64" },
"publish_status": { "type": "string", "enum": ["draft", "published", "failed"] },
"publish_error": { "type": "string" },
"last_published_at_ms": { "type": "integer", "format": "int64" },
"created_at_ms": { "type": "integer", "format": "int64" },
"updated_at_ms": { "type": "integer", "format": "int64" }
}
},
"PolicyInstanceInput": {
"type": "object",
"required": ["instance_code", "template_code", "template_version"],
"properties": {
"instance_code": { "type": "string" },
"template_code": { "type": "string" },
"template_version": { "type": "string" },
"region_scope": { "type": "string" },
"status": { "type": "string", "enum": ["active", "disabled"] },
"effective_from_ms": { "type": "integer", "format": "int64" },
"effective_to_ms": { "type": "integer", "format": "int64" }
}
},
"PolicyPublishResult": {
"type": "object",
"properties": {
"job_id": { "type": "integer", "format": "int64" },
"instance_id": { "type": "integer", "format": "int64" },
"app_code": { "type": "string" },
"instance_code": { "type": "string" },
"template_code": { "type": "string" },
"template_version": { "type": "string" },
"status": { "type": "string" },
"target_count": { "type": "integer" },
"published_region_ids": {
"type": "array",
"items": { "type": "integer", "format": "int64" }
},
"published_at_ms": { "type": "integer", "format": "int64" }
}
},
"ApiResponseCreateUserResult": {
"allOf": [
{

View File

@ -20,6 +20,15 @@ server {
proxy_pass http://127.0.0.1:13100/api/;
}
location /ops-api/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:13114/ops-api/;
}
location = /healthz {
proxy_pass http://127.0.0.1:13100/healthz;
}

View File

@ -45,6 +45,7 @@ import {
validateApplicationPayload,
} from "./format.js";
import { downloadCsv } from "@/shared/api/download";
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -61,10 +62,13 @@ function chinaDayStart(timestamp) {
function timePresets() {
const now = Date.now();
const todayStart = chinaDayStart(now);
const yesterdayStart = todayStart - DAY_MS;
const chinaNow = new Date(now + CHINA_OFFSET_MS);
const monthStart = Date.UTC(chinaNow.getUTCFullYear(), chinaNow.getUTCMonth(), 1) - CHINA_OFFSET_MS;
return [
{ key: "today", label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } },
// 00:00 00:00 24
{ key: "yesterday", label: "昨天", range: { endMs: todayStart, startMs: yesterdayStart } },
{ key: "7d", label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } },
{ key: "30d", label: "近30天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 29 * DAY_MS } },
{ key: "month", label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } },
@ -145,7 +149,7 @@ export function FinanceApp() {
paidState: "",
rechargeType: "",
regionId: "",
timeRange: timePresets().find((preset) => preset.key === "30d").range,
timeRange: timePresets().find((preset) => preset.key === "yesterday").range,
}));
const [rechargeExporting, setRechargeExporting] = useState(false);
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
@ -735,7 +739,13 @@ export function FinanceApp() {
}
if (sessionState.error) {
return <StateScreen actionHref="/login" actionText="重新登录" title={sessionState.error} />;
return (
<StateScreen
actionHref={buildLoginRedirectPath(window.location)}
actionText="重新登录"
title={sessionState.error}
/>
);
}
if (!session?.canAccessFinance) {

View File

@ -1,45 +1,124 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { FinanceApp } from "./FinanceApp.jsx";
import {
fetchFinanceApplicationOptions,
fetchFinanceRechargeApps,
fetchFinanceRechargeOverview,
fetchFinanceRechargeSummaries,
fetchFinanceSession,
listFinanceApplications,
listFinanceCoinSellerRechargeOrders,
listFinanceRechargeDetails,
listFinanceRechargeRegions,
listFinanceWithdrawalApplications,
listMyFinanceApplications
} from "./api.js";
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY } from "./format.js";
vi.mock("./api.js", () => ({
approveFinanceApplication: vi.fn(),
approveFinanceWithdrawalApplication: vi.fn(),
createFinanceApplication: vi.fn(),
createFinanceCoinSellerRechargeOrder: vi.fn(),
fetchFinanceApplicationOptions: vi.fn(async () => ({
apps: [{ appCode: "lalu", appName: "lalu" }],
operations: [{ label: "增加用户金币", permissionCode: "finance-operation:user-coin-credit", value: "user_coin_credit" }],
walletIdentities: []
})),
fetchFinanceSession: vi.fn(async () => ({
canAccessFinance: true,
canAuditApplication: false,
canCreateApplication: true,
canCreateCoinSellerRechargeOrder: false,
canGrantCoinSellerRechargeOrder: false,
canVerifyCoinSellerRechargeOrder: false,
canViewAppRechargeDetails: false,
canViewCoinSellerRechargeOrders: false,
canViewWithdrawalApplications: false,
permissions: ["finance-application:create", "finance-operation:user-coin-credit"],
user: { account: "admin", name: "admin" }
})),
exportFinanceRechargeBills: vi.fn(),
fetchFinanceApplicationOptions: vi.fn(),
fetchFinanceRechargeApps: vi.fn(),
fetchFinanceRechargeOverview: vi.fn(),
fetchFinanceRechargeSummaries: vi.fn(),
fetchFinanceSession: vi.fn(),
filterOperationsByPermissions: vi.fn((operations, permissions) => operations.filter((item) => permissions.includes(item.permissionCode))),
grantFinanceCoinSellerRechargeOrder: vi.fn(),
listFinanceApplications: vi.fn(),
listFinanceCoinSellerRechargeOrders: vi.fn(async () => ({ items: [], page: 1, pageSize: 50, total: 0 })),
listFinanceCoinSellerRechargeOrders: vi.fn(),
listFinanceRechargeDetails: vi.fn(),
listFinanceRechargeRegions: vi.fn(),
listFinanceWithdrawalApplications: vi.fn(),
listMyFinanceApplications: vi.fn(async () => ({ items: [], page: 1, pageSize: 50, total: 0 })),
listMyFinanceApplications: vi.fn(),
rejectFinanceApplication: vi.fn(),
rejectFinanceWithdrawalApplication: vi.fn(),
refreshFinanceGoogleRechargePaid: vi.fn(),
verifyFinanceCoinSellerRechargeOrder: vi.fn()
}));
const defaultSession = {
canAccessFinance: true,
canAuditApplication: false,
canCreateApplication: true,
canCreateCoinSellerRechargeOrder: false,
canGrantCoinSellerRechargeOrder: false,
canVerifyCoinSellerRechargeOrder: false,
canViewAppRechargeDetails: false,
canViewCoinSellerRechargeOrders: false,
canViewWithdrawalApplications: false,
permissions: ["finance-application:create", "finance-operation:user-coin-credit"],
user: { account: "admin", name: "admin" }
};
beforeEach(() => {
vi.mocked(fetchFinanceApplicationOptions).mockResolvedValue({
apps: [{ appCode: "lalu", appName: "lalu" }],
operations: [{ label: "增加用户金币", permissionCode: "finance-operation:user-coin-credit", value: "user_coin_credit" }],
walletIdentities: []
});
vi.mocked(fetchFinanceRechargeApps).mockResolvedValue([{ appCode: "lalu", appName: "Lalu" }]);
vi.mocked(fetchFinanceRechargeOverview).mockResolvedValue(EMPTY_RECHARGE_OVERVIEW);
vi.mocked(fetchFinanceRechargeSummaries).mockResolvedValue({ merged: EMPTY_RECHARGE_SUMMARY, perApp: [] });
vi.mocked(fetchFinanceSession).mockResolvedValue(defaultSession);
vi.mocked(listFinanceApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
vi.mocked(listFinanceCoinSellerRechargeOrders).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
vi.mocked(listFinanceRechargeDetails).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
vi.mocked(listFinanceRechargeRegions).mockResolvedValue([]);
vi.mocked(listFinanceWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
vi.mocked(listMyFinanceApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
window.history.pushState(null, "", "/");
});
test("preserves the finance entry when the expired session asks the user to log in again", async () => {
vi.mocked(fetchFinanceSession).mockRejectedValueOnce(new Error("登录已失效"));
window.history.pushState(null, "", "/finance/?view=rechargeDetails");
render(
<ToastProvider>
<FinanceApp />
</ToastProvider>
);
expect(await screen.findByRole("link", { name: "重新登录" })).toHaveAttribute(
"href",
"/login?redirect=%2Ffinance%2F%3Fview%3DrechargeDetails"
);
});
test("defaults recharge finance views to yesterday in China timezone", async () => {
vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 6, 8, 4));
vi.mocked(fetchFinanceSession).mockResolvedValue({
...defaultSession,
canCreateApplication: false,
canViewAppRechargeDetails: true,
permissions: ["finance-recharge-detail:view"]
});
render(
<ToastProvider>
<FinanceApp />
</ToastProvider>
);
const yesterdayButton = await screen.findByRole("button", { name: "昨天" });
expect(yesterdayButton).toHaveClass("is-on");
expect(screen.getByRole("button", { name: "今天" })).toBeInTheDocument();
await waitFor(() => expect(listFinanceRechargeDetails).toHaveBeenCalled());
const [query] = vi.mocked(listFinanceRechargeDetails).mock.calls.at(-1);
expect(query.start_at_ms).toBe(Date.UTC(2026, 6, 6, 16));
expect(query.end_at_ms).toBe(Date.UTC(2026, 6, 7, 16));
});
test("keeps create application dialog open when backdrop is clicked", async () => {

12
ops-center/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HYApp 运营中心</title>
</head>
<body>
<div id="ops-center-root"></div>
<script type="module" src="/ops-center/src/main.jsx"></script>
</body>
</html>

View File

@ -0,0 +1,688 @@
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 { OpsShell } from "./components/OpsShell.jsx";
import { OpsTable } from "./components/OpsTable.jsx";
const views = [
{ icon: "应", key: "apps", label: "应用列表" },
{ icon: "礼", key: "luckyGifts", label: "幸运礼物配置" },
{ icon: "奖", key: "lotteryRecords", label: "抽奖记录" },
{ icon: "数", key: "summary", label: "数据汇总" }
];
const initialDashboard = {
apps: [],
lotteryRecords: [],
luckyGifts: [],
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 [savingConfig, setSavingConfig] = useState(false);
const query = useMemo(
() => ({
app_code: filters.appCode,
status: filters.status
}),
[filters]
);
const loadDashboard = useCallback(async () => {
setState((current) => ({ ...current, error: "", loading: true }));
try {
const data = await fetchOpsDashboard(query);
setState({ data, error: "", loading: false });
} catch (error) {
// 404
setState((current) => ({
data: current.data || initialDashboard,
error: error.message || "运营接口暂不可用",
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 handleSaveLuckyGiftConfig = useCallback(
async (config) => {
setSavingConfig(true);
try {
await saveLuckyGiftConfig(config);
setEditingConfig(null);
await loadDashboard();
} catch (error) {
setState((current) => ({ ...current, error: error.message || "保存幸运礼物配置失败" }));
} finally {
setSavingConfig(false);
}
},
[loadDashboard]
);
const sectionConfig = useMemo(
() =>
createSectionConfig({
onOpenLuckyGiftConfig: openLuckyGiftConfig,
savingConfig
}),
[openLuckyGiftConfig, savingConfig]
);
const activeConfig = sectionConfig[activeView];
return (
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
<div className="ops-page">
<header className="ops-header">
<div>
<h1>运营配置中心</h1>
</div>
<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 ? (
<LuckyGiftConfigDialog
config={editingConfig}
onClose={() => setEditingConfig(null)}
onSubmit={handleSaveLuckyGiftConfig}
saving={savingConfig}
/>
) : 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

@ -0,0 +1,95 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest";
import { OpsCenterApp } from "./OpsCenterApp.jsx";
import { fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
vi.mock("./api.js", () => ({
fetchOpsDashboard: vi.fn(),
saveLuckyGiftConfig: vi.fn()
}));
beforeEach(() => {
vi.clearAllMocks();
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "default" });
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 }
});
});
test("renders simplified ops center navigation and app table", async () => {
render(<OpsCenterApp />);
expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy();
expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy();
["应用列表", "幸运礼物配置", "抽奖记录", "数据汇总"].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: "" });
});
test("switches to lucky gift config skeleton", async () => {
render(<OpsCenterApp />);
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);
});
test("shows pool balances in summary view", async () => {
render(<OpsCenterApp />);
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: "幸运礼物配置" });
fireEvent.click(within(luckyGiftSection).getAllByRole("button", { name: "添加配置" })[0]);
const dialog = screen.getByRole("dialog", { name: "添加幸运礼物配置" });
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.click(within(dialog).getByRole("button", { name: "保存配置" }));
await waitFor(() => {
expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
expect.objectContaining({
app_code: "lalu",
pool_id: "default",
stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]),
target_rtp_percent: 88
})
);
});
});

229
ops-center/src/api.js Normal file
View File

@ -0,0 +1,229 @@
import { getAccessToken } from "@/shared/api/request";
export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
export async function fetchOpsDashboard(query = {}) {
// Ops Center 先拉应用列表再逐个读取默认配置草稿GET /config 不写库,只有 PUT 才发布规则版本。
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 })
]);
return normalizeDashboard({ apps, lotteryRecords, luckyGifts, poolBalances, summary, selectedAppCode });
}
export async function saveLuckyGiftConfig(config = {}) {
const payload = luckyGiftConfigPayload(config);
return opsRequest("/lucky-gifts/config", {
body: payload,
method: "PUT",
query: {
app_code: payload.app_code,
pool_id: payload.pool_id
}
});
}
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
const response = await fetch(buildOpsUrl(path, query), {
body: body === undefined ? undefined : JSON.stringify(body),
credentials: "include",
headers: buildHeaders(body),
method
});
const payload = await readPayload(response);
if (!response.ok || payload.code !== 0) {
throw new Error(payload.message || response.statusText || "运营接口请求失败");
}
return payload.data ?? {};
}
export function buildOpsUrl(path, query = {}) {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin);
Object.entries(query || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
});
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);
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)
})
};
}
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 uniqueAppCodes(apps) {
const seen = new Set();
const codes = [];
apps.forEach((item) => {
const code = String(item?.app_code || item?.appCode || "").trim().toLowerCase();
if (!code || seen.has(code)) {
return;
}
seen.add(code);
codes.push(code);
});
return codes;
}
function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
const appCode = String(config.app_code || config.appCode || fallbackAppCode).trim().toLowerCase();
const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
return {
...config,
app_code: appCode,
pool_id: config.pool_id || config.poolId || "default",
rule_version: ruleVersion,
is_default: ruleVersion <= 0
};
}
function luckyGiftConfigPayload(config = {}) {
const appCode = String(config.app_code || config.appCode || "").trim().toLowerCase();
const poolID = String(config.pool_id || config.poolId || "default").trim() || "default";
return {
app_code: appCode,
pool_id: poolID,
enabled: Boolean(config.enabled),
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent),
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)
};
}
function buildHeaders(body) {
const headers = {};
const token = getAccessToken();
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
async function readPayload(response) {
const text = await response.text();
if (!text) {
return { code: response.ok ? 0 : response.status, data: {} };
}
try {
return JSON.parse(text);
} catch {
return { code: response.ok ? 0 : response.status, data: text, message: text };
}
}
function normalizeItems(value) {
if (Array.isArray(value)) {
return value;
}
if (Array.isArray(value?.items)) {
return value.items;
}
if (Array.isArray(value?.list)) {
return value.list;
}
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 numberValue(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function normalizeStages(stages) {
if (!Array.isArray(stages)) {
return [];
}
return stages.map((stage) => ({
stage: stage.stage || stage.Stage || "",
tiers: Array.isArray(stage.tiers)
? stage.tiers.map((tier) => ({
tier_id: tier.tier_id || tier.tierId || "",
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
}))
: []
}));
}

110
ops-center/src/api.test.js Normal file
View File

@ -0,0 +1,110 @@
import { afterEach, expect, test, vi } from "vitest";
import { buildOpsUrl, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js";
afterEach(() => {
vi.restoreAllMocks();
});
test("builds ops api urls under the dedicated prefix", () => {
const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" });
expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center");
expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active");
});
test("loads dashboard resources from ops api endpoints", 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
});
}
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
});
}
return new Response(JSON.stringify({ code: 0, data: { items: [] } }), {
headers: { "Content-Type": "application/json" },
status: 200
});
});
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"
]);
});
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
});
});
await saveLuckyGiftConfig({
app_code: "aslan",
pool_id: "default",
enabled: true,
target_rtp_percent: 95,
pool_rate_percent: 96,
settlement_window_wager: 1_000_000,
control_band_percent: 1,
gift_price_reference: 100,
stages: [{ stage: "novice", tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }] }]
});
expect(calls[0].method).toBe("PUT");
expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default");
expect(JSON.parse(calls[0].body)).toMatchObject({
app_code: "aslan",
enabled: true,
pool_id: "default",
stages: [{ stage: "novice", tiers: [{ probability_percent: 100 }] }],
target_rtp_percent: 95
});
});

View File

@ -0,0 +1,30 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,14 @@
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

@ -0,0 +1,30 @@
export function OpsShell({ activeView, children, onViewChange, views }) {
return (
<div className="ops-shell">
<aside className="ops-sidebar" aria-label="运营中心导航">
<div className="ops-brand">
<span>OP</span>
<div>
<strong>运营中心</strong>
<small>Ops Center</small>
</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>
))}
</nav>
</aside>
<main className="ops-main">{children}</main>
</div>
);
}

View File

@ -0,0 +1,59 @@
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);
}

21
ops-center/src/main.jsx Normal file
View File

@ -0,0 +1,21 @@
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import React from "react";
import { createRoot } from "react-dom/client";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { theme } from "@/theme.js";
import { OpsCenterApp } from "./OpsCenterApp.jsx";
import "@/styles/tokens.css";
import "@/styles/shared-ui.css";
import "./styles/index.css";
createRoot(document.getElementById("ops-center-root")).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<ToastProvider>
<CssBaseline />
<OpsCenterApp />
</ToastProvider>
</ThemeProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,868 @@
:root {
color: var(--text-primary);
background: var(--bg-page);
font-family: var(--font-system);
}
body {
margin: 0;
min-width: 320px;
background: var(--bg-page);
}
button,
input,
select {
font: inherit;
}
#ops-center-root {
min-height: 100vh;
}
.ops-shell {
display: grid;
min-height: 100vh;
grid-template-columns: 236px minmax(0, 1fr);
}
.ops-sidebar {
display: flex;
flex-direction: column;
gap: var(--space-5);
border-right: 1px solid #d9e2ef;
background: #ffffff;
padding: var(--space-5) var(--space-3);
}
.ops-brand {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0 var(--space-2);
}
.ops-brand > span {
display: inline-grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 8px;
background: #0f766e;
color: #fff;
font-size: 13px;
font-weight: 800;
}
.ops-brand strong {
display: block;
font-size: 16px;
line-height: 1.25;
}
.ops-brand small {
color: var(--text-tertiary);
font-size: 11px;
}
.ops-nav {
display: grid;
gap: var(--space-2);
}
.ops-nav button {
display: flex;
width: 100%;
height: 38px;
align-items: center;
gap: var(--space-2);
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
padding: 0 var(--space-3);
text-align: left;
}
.ops-nav button:hover,
.ops-nav button.is-active {
background: #ecfdf5;
color: #0f766e;
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;
}
.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-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-status {
display: inline-flex;
height: 28px;
align-items: center;
border: 1px solid var(--success-border);
border-radius: 999px;
background: var(--success-surface);
color: var(--success);
font-size: 12px;
font-weight: 700;
padding: 0 var(--space-3);
}
.ops-status.is-loading {
border-color: var(--info-border);
background: var(--info-surface);
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);
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;
}
.ops-config-title {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.ops-config-title span {
overflow: hidden;
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);
}
.ops-config-metric {
display: grid;
min-width: 0;
gap: var(--space-1);
border: 1px solid var(--border);
border-radius: 8px;
background: #f8fafc;
padding: 10px var(--space-3);
}
.ops-config-metric span {
overflow: hidden;
color: var(--text-tertiary);
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-config-metric strong {
overflow: hidden;
color: var(--text-primary);
font-size: 16px;
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);
align-items: start;
gap: var(--space-3);
}
.ops-config-params,
.ops-stage-designer {
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);
}
.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 {
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;
gap: var(--space-3);
}
.ops-stage-designer__head span {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 800;
}
.ops-stage-tabs {
display: flex;
min-width: 0;
gap: var(--space-1);
padding: 0 var(--space-3);
border-bottom: 1px solid var(--border);
}
.ops-stage-tab {
display: inline-flex;
min-width: 0;
height: 34px;
align-items: center;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
padding: 0 var(--space-4);
text-align: center;
transition:
background var(--motion-fast) var(--ease-standard),
border-color var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
}
.ops-stage-tab:hover {
background: var(--primary-surface);
}
.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);
}
.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);
}
.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;
}
.ops-stage-block__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.ops-stage-block__head > div {
display: flex;
flex-wrap: wrap;
align-items: baseline;
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);
}
.ops-tier-table {
display: grid;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe4f0;
border-radius: 8px;
background: #fff;
}
.ops-tier-head,
.ops-tier-row {
display: grid;
box-sizing: border-box;
min-width: 520px;
grid-template-columns:
minmax(104px, 1fr)
minmax(112px, 1fr)
minmax(64px, auto)
minmax(58px, auto)
minmax(56px, auto);
align-items: center;
gap: var(--space-2);
}
.ops-tier-head {
min-height: 32px;
border-bottom: 1px solid var(--row-border);
background: #f8fafc;
color: var(--text-tertiary);
font-size: 12px;
font-weight: 900;
padding: 0 var(--space-3);
}
.ops-tier-row {
border-bottom: 1px solid var(--row-border);
padding: var(--space-2) var(--space-3);
}
.ops-tier-row:last-child {
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 {
grid-template-columns: 1fr;
}
.ops-sidebar {
min-height: auto;
}
.ops-nav,
.ops-overview,
.ops-controls,
.ops-config-metrics,
.ops-stage-tabs,
.ops-summary-grid {
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-config-metrics,
.ops-form-grid,
.ops-stage-tabs,
.ops-tier-row,
.ops-summary-grid {
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;
}
}

View File

@ -191,6 +191,7 @@ export const fallbackNavigation = [
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
routeNavItem("operation-reports", { icon: FlagOutlined }),
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
routeNavItem("policy-template", { icon: SettingsApplicationsOutlined }),
],
},
{

View File

@ -64,6 +64,11 @@ export const PERMISSIONS = {
reportView: "report:view",
giftDiamondView: "gift-diamond:view",
giftDiamondUpdate: "gift-diamond:update",
policyTemplateView: "policy-template:view",
policyTemplateCreate: "policy-template:create",
policyInstanceCreate: "policy-instance:create",
policyInstanceUpdate: "policy-instance:update",
policyInstancePublish: "policy-instance:publish",
fullServerNoticeView: "full-server-notice:view",
fullServerNoticeSend: "full-server-notice:send",
paymentBillView: "payment-bill:view",
@ -246,6 +251,7 @@ export const MENU_CODES = {
operationCoinAdjustment: "operation-coin-adjustment",
operationReports: "operation-reports",
operationGiftDiamond: "operation-gift-diamond",
policyTemplate: "policy-template",
operationFullServerNotice: "operation-full-server-notice",
luckyGift: "lucky-gift",
wheel: "wheel",

View File

@ -1,5 +1,6 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
import { defaultAdminPath } from "./routeConfig";
@ -12,7 +13,7 @@ export function RequireAuth() {
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
return <Navigate to={buildLoginRedirectPath(location)} state={{ from: location }} replace />;
}
return <Outlet />;

View File

@ -19,6 +19,7 @@ import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js";
import { menusRoutes } from "@/features/menus/routes.js";
import { operationsRoutes } from "@/features/operations/routes.js";
import { paymentRoutes } from "@/features/payment/routes.js";
import { policyConfigRoutes } from "@/features/policy-config/routes.js";
import { registrationRewardRoutes } from "@/features/registration-reward/routes.js";
import { redPacketRoutes } from "@/features/red-packets/routes.js";
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
@ -65,6 +66,7 @@ export const adminRoutes: AdminRoute[] = [
...vipConfigRoutes,
...resourceRoutes,
...operationsRoutes,
...policyConfigRoutes,
...luckyGiftRoutes,
...paymentRoutes,
...gameRoutes,

View File

@ -0,0 +1,74 @@
const redirectParam = "redirect";
const defaultRedirectTarget = "/overview";
const documentEntryPrefixes = ["/finance", "/databi", "/ops-center", "/money"];
export function locationToPath(location) {
if (!location?.pathname) {
return "";
}
return `${location.pathname}${location.search || ""}${location.hash || ""}`;
}
export function buildLoginRedirectPath(location) {
const target = normalizeRedirectTarget(locationToPath(location));
if (!target) {
return "/login";
}
return `/login?${redirectParam}=${encodeURIComponent(target)}`;
}
export function resolveLoginRedirectTarget(location, fallback = defaultRedirectTarget) {
const queryTarget = new URLSearchParams(location?.search || "").get(redirectParam);
return normalizeRedirectTarget(queryTarget) || normalizeRedirectTarget(locationToPath(location?.state?.from)) || fallback;
}
export function isDocumentEntryTarget(target) {
const normalized = normalizeRedirectTarget(target);
if (!normalized) {
return false;
}
const { pathname } = new URL(normalized, currentOrigin());
return documentEntryPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
export function redirectAfterLogin(target, navigate, targetWindow = window) {
const normalized = normalizeRedirectTarget(target) || defaultRedirectTarget;
if (isDocumentEntryTarget(normalized)) {
// Subsystems are separate HTML entries; SPA navigation would hit the admin fallback route.
targetWindow.location.replace(normalized);
return;
}
navigate(normalized, { replace: true });
}
function normalizeRedirectTarget(value) {
const raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) {
return "";
}
let url;
try {
url = new URL(raw, currentOrigin());
} catch {
return "";
}
if (url.origin !== currentOrigin()) {
return "";
}
const target = `${url.pathname}${url.search}${url.hash}`;
if (!target.startsWith("/") || isLoginTarget(target)) {
return "";
}
return target;
}
function isLoginTarget(target) {
return new URL(target, currentOrigin()).pathname === "/login";
}
function currentOrigin() {
return globalThis.window?.location?.origin || "http://localhost";
}

View File

@ -0,0 +1,67 @@
import { expect, test, vi } from "vitest";
import {
buildLoginRedirectPath,
isDocumentEntryTarget,
locationToPath,
redirectAfterLogin,
resolveLoginRedirectTarget
} from "./loginRedirect.js";
test("builds login path with the full current path", () => {
const loginPath = buildLoginRedirectPath({
hash: "#summary",
pathname: "/finance/",
search: "?view=rechargeDetails&app=lalu"
});
expect(loginPath).toBe("/login?redirect=%2Ffinance%2F%3Fview%3DrechargeDetails%26app%3Dlalu%23summary");
});
test("resolves login redirect from query before router state", () => {
const target = resolveLoginRedirectTarget({
pathname: "/login",
search: "?redirect=%2Ffinance%2F%3Fview%3Drecon",
state: { from: { pathname: "/system/users", search: "?page=2" } }
});
expect(target).toBe("/finance/?view=recon");
});
test("falls back to router state and preserves search and hash", () => {
const target = resolveLoginRedirectTarget({
pathname: "/login",
search: "",
state: { from: { hash: "#role", pathname: "/system/users", search: "?page=2" } }
});
expect(target).toBe("/system/users?page=2#role");
});
test("rejects unsafe or looping redirect targets", () => {
expect(resolveLoginRedirectTarget({ search: "?redirect=https%3A%2F%2Fevil.example%2Ffinance%2F" })).toBe("/overview");
expect(resolveLoginRedirectTarget({ search: "?redirect=%2F%2Fevil.example%2Ffinance%2F" })).toBe("/overview");
expect(resolveLoginRedirectTarget({ search: "?redirect=%2Flogin%3Fredirect%3D%252Ffinance%252F" })).toBe("/overview");
});
test("detects document entry targets", () => {
expect(isDocumentEntryTarget("/finance/?view=overview")).toBe(true);
expect(isDocumentEntryTarget("/databi/social/?view=funnel")).toBe(true);
expect(isDocumentEntryTarget("/ops-center/")).toBe(true);
expect(isDocumentEntryTarget("/overview")).toBe(false);
});
test("redirectAfterLogin reloads document entries and uses router navigation for admin routes", () => {
const navigate = vi.fn();
const targetWindow = { location: { replace: vi.fn() } };
redirectAfterLogin("/finance/?view=rechargeDetails", navigate, targetWindow);
expect(targetWindow.location.replace).toHaveBeenCalledWith("/finance/?view=rechargeDetails");
expect(navigate).not.toHaveBeenCalled();
redirectAfterLogin("/system/users?page=2", navigate, targetWindow);
expect(navigate).toHaveBeenCalledWith("/system/users?page=2", { replace: true });
});
test("locationToPath returns an empty string for missing location", () => {
expect(locationToPath(null)).toBe("");
});

View File

@ -1,9 +1,10 @@
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import LoginOutlined from "@mui/icons-material/LoginOutlined";
import TextField from "@mui/material/TextField";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { isDocumentEntryTarget, redirectAfterLogin, resolveLoginRedirectTarget } from "@/features/auth/loginRedirect.js";
import { Button } from "@/shared/ui/Button.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -15,9 +16,19 @@ export function LoginPage() {
const { showToast } = useToast();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/overview";
const from = resolveLoginRedirectTarget(location);
const needsDocumentRedirect = isDocumentEntryTarget(from);
useEffect(() => {
if (user && needsDocumentRedirect) {
redirectAfterLogin(from, navigate);
}
}, [from, navigate, needsDocumentRedirect, user]);
if (user) {
if (needsDocumentRedirect) {
return null;
}
return <Navigate to={from} replace />;
}
@ -27,7 +38,7 @@ export function LoginPage() {
try {
await login({ username, password });
showToast("登录成功", "success");
navigate(from, { replace: true });
redirectAfterLogin(from, navigate);
} catch (err) {
showToast(err.message || "登录失败", "error");
} finally {

View File

@ -0,0 +1,310 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
import { createPolicyInstance, listPolicyInstances, listPolicyTemplates, publishPolicyInstance, savePolicyTemplate, updatePolicyInstanceStatus } from "./api";
afterEach(() => {
setAccessToken("");
setSelectedAppCode("lalu");
vi.unstubAllGlobals();
});
function mockEnvelope(data: unknown, status = 200) {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data }), { status })),
);
}
function requestHeaders(init: RequestInit | undefined): Record<string, string> {
return (init?.headers || {}) as Record<string, string>;
}
function requestBody(init: RequestInit | undefined): Record<string, unknown> {
return JSON.parse(String(init?.body || "{}")) as Record<string, unknown>;
}
test("listPolicyTemplates normalizes snake case page and template fields", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
items: [
{
created_at_ms: "1767225600000",
description: "Huwaa default policy",
name: "Huwaa Google + coin seller",
rule_json: "{\"google_coin_per_usd\":70000,\"coin_seller\":{\"levels\":[{\"coin_per_usd\":92000}]}}",
status: "active",
template_code: "first_google70000_coin_seller_92000_100000_v1",
template_id: "101",
template_version: "v1",
updated_at_ms: "1767229200000",
version_id: "202",
},
],
page: 1,
page_size: 50,
total: 1,
},
}),
),
),
);
const result = await listPolicyTemplates({ page: 1, page_size: 50, status: "active" });
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/policy-templates?");
expect(String(url)).toContain("page_size=50");
expect(String(url)).toContain("status=active");
expect(init?.method).toBe("GET");
expect(result.pageSize).toBe(50);
expect(result.items[0]).toMatchObject({
createdAtMs: 1767225600000,
ruleJson: {
coin_seller: { levels: [{ coin_per_usd: 92000 }] },
google_coin_per_usd: 70000,
},
templateCode: "first_google70000_coin_seller_92000_100000_v1",
templateId: 101,
templateVersion: "v1",
updatedAtMs: 1767229200000,
versionId: 202,
});
});
test("savePolicyTemplate sends typed upsert payload and normalizes response", async () => {
mockEnvelope(
{
created_at_ms: "1767225600000",
description: "desc",
name: "Policy",
rule_json: { points_per_usd: 100000 },
status: "active",
template_code: "code",
template_id: "101",
template_version: "v2",
updated_at_ms: "1767229200000",
version_id: "202",
},
201,
);
const result = await savePolicyTemplate({
description: "desc",
name: "Policy",
rule_json: { points_per_usd: 100000 },
status: "active",
template_code: "code",
template_version: "v2",
});
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/policy-templates");
expect(init?.method).toBe("POST");
expect(requestHeaders(init)["Content-Type"]).toBe("application/json");
expect(requestBody(init)).toMatchObject({
name: "Policy",
rule_json: { points_per_usd: 100000 },
template_code: "code",
template_version: "v2",
});
expect(result).toMatchObject({
ruleJson: { points_per_usd: 100000 },
templateCode: "code",
templateId: 101,
templateVersion: "v2",
versionId: 202,
});
});
test("listPolicyInstances normalizes snake case page and instance fields", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
items: [
{
app_code: "huwaa",
created_at_ms: "1767225600000",
effective_from_ms: "1767225600000",
effective_to_ms: "1767312000000",
instance_code: "huwaa_policy",
instance_id: "301",
last_published_at_ms: "1767229200000",
publish_error: "",
publish_status: "published",
region_scope: "region_id:1001",
status: "active",
template_code: "first_google70000_coin_seller_92000_100000_v1",
template_version: "v1",
updated_at_ms: "1767229300000",
},
],
page: 2,
page_size: 20,
total: 31,
},
}),
),
),
);
const result = await listPolicyInstances({ keyword: "huwaa", page: 2, page_size: 20 });
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/policy-instances?");
expect(String(url)).toContain("keyword=huwaa");
expect(String(url)).toContain("page_size=20");
expect(init?.method).toBe("GET");
expect(result.pageSize).toBe(20);
expect(result.items[0]).toMatchObject({
appCode: "huwaa",
createdAtMs: 1767225600000,
effectiveFromMs: 1767225600000,
effectiveToMs: 1767312000000,
instanceCode: "huwaa_policy",
instanceId: 301,
lastPublishedAtMs: 1767229200000,
publishStatus: "published",
regionScope: "region_id:1001",
templateCode: "first_google70000_coin_seller_92000_100000_v1",
templateVersion: "v1",
updatedAtMs: 1767229300000,
});
});
test("createPolicyInstance sends app-scoped payload with selected X-App-Code", async () => {
setSelectedAppCode("huwaa");
mockEnvelope(
{
app_code: "huwaa",
created_at_ms: "1767225600000",
effective_from_ms: "1767225600000",
effective_to_ms: "0",
instance_code: "huwaa_policy",
instance_id: "301",
last_published_at_ms: "0",
publish_error: "",
publish_status: "draft",
region_scope: "all_active_regions",
status: "active",
template_code: "code",
template_version: "v1",
updated_at_ms: "1767225600000",
},
201,
);
const result = await createPolicyInstance({
effective_from_ms: 1767225600000,
effective_to_ms: 0,
instance_code: "huwaa_policy",
region_scope: "all_active_regions",
status: "active",
template_code: "code",
template_version: "v1",
});
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/policy-instances");
expect(init?.method).toBe("POST");
expect(requestHeaders(init)["X-App-Code"]).toBe("huwaa");
expect(requestBody(init)).toMatchObject({
effective_from_ms: 1767225600000,
instance_code: "huwaa_policy",
region_scope: "all_active_regions",
template_code: "code",
});
expect(result).toMatchObject({
appCode: "huwaa",
instanceCode: "huwaa_policy",
instanceId: 301,
regionScope: "all_active_regions",
});
});
test("publishPolicyInstance normalizes snake case publish result fields", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
app_code: "huwaa",
instance_code: "huwaa_policy",
instance_id: "301",
job_id: "401",
published_at_ms: "1767229300000",
published_region_ids: ["1001", "1002"],
status: "published",
target_count: "2",
template_code: "first_google70000_coin_seller_92000_100000_v1",
template_version: "v1",
},
}),
),
),
);
const result = await publishPolicyInstance("301");
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/policy-instances/301/publish");
expect(init?.method).toBe("POST");
expect(result).toMatchObject({
appCode: "huwaa",
instanceCode: "huwaa_policy",
instanceId: 301,
jobId: 401,
publishedAtMs: 1767229300000,
publishedRegionIds: [1001, 1002],
status: "published",
targetCount: 2,
templateCode: "first_google70000_coin_seller_92000_100000_v1",
templateVersion: "v1",
});
});
test("updatePolicyInstanceStatus sends PUT status with selected X-App-Code", async () => {
setSelectedAppCode("huwaa");
mockEnvelope({
app_code: "huwaa",
created_at_ms: "1767225600000",
effective_from_ms: "1767225600000",
effective_to_ms: "0",
instance_code: "huwaa_policy",
instance_id: "301",
last_published_at_ms: "0",
publish_error: "",
publish_status: "draft",
region_scope: "all_active_regions",
status: "disabled",
template_code: "code",
template_version: "v1",
updated_at_ms: "1767229300000",
});
const result = await updatePolicyInstanceStatus(301, "disabled");
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/policy-instances/301/status");
expect(init?.method).toBe("PUT");
expect(requestHeaders(init)["X-App-Code"]).toBe("huwaa");
expect(requestBody(init)).toEqual({ status: "disabled" });
expect(result).toMatchObject({
appCode: "huwaa",
instanceId: 301,
status: "disabled",
});
});

View File

@ -0,0 +1,239 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types";
export interface PolicyTemplateDto {
createdAtMs: number;
description: string;
name: string;
ruleJson: Record<string, unknown>;
status: string;
templateCode: string;
templateId: number;
templateVersion: string;
updatedAtMs: number;
versionId: number;
}
export interface PolicyInstanceDto {
appCode: string;
createdAtMs: number;
effectiveFromMs: number;
effectiveToMs: number;
instanceCode: string;
instanceId: number;
lastPublishedAtMs: number;
publishError: string;
publishStatus: string;
regionScope: string;
status: string;
templateCode: string;
templateVersion: string;
updatedAtMs: number;
}
export interface PolicyPublishDto {
appCode: string;
instanceCode: string;
instanceId: number;
jobId: number;
publishedAtMs: number;
publishedRegionIds: number[];
status: string;
targetCount: number;
templateCode: string;
templateVersion: string;
}
export interface PolicyTemplatePayload {
description: string;
name: string;
rule_json: Record<string, unknown>;
status: string;
template_code: string;
template_version: string;
}
export interface PolicyInstancePayload {
effective_from_ms: number;
effective_to_ms: number;
instance_code: string;
region_scope: string;
status: string;
template_code: string;
template_version: string;
}
export function listPolicyTemplates(query: PageQuery = {}): Promise<ApiPage<PolicyTemplateDto>> {
const endpoint = API_ENDPOINTS.listPolicyTemplates;
return apiRequest<ApiPage<RawPolicyTemplate>>(apiEndpointPath(API_OPERATIONS.listPolicyTemplates), {
method: endpoint.method,
query,
}).then(normalizeTemplatePage);
}
export function savePolicyTemplate(payload: PolicyTemplatePayload): Promise<PolicyTemplateDto> {
const endpoint = API_ENDPOINTS.savePolicyTemplate;
return apiRequest<RawPolicyTemplate, PolicyTemplatePayload>(apiEndpointPath(API_OPERATIONS.savePolicyTemplate), {
body: payload,
method: endpoint.method,
}).then(normalizeTemplate);
}
export function listPolicyInstances(query: PageQuery = {}): Promise<ApiPage<PolicyInstanceDto>> {
const endpoint = API_ENDPOINTS.listPolicyInstances;
return apiRequest<ApiPage<RawPolicyInstance>>(apiEndpointPath(API_OPERATIONS.listPolicyInstances), {
method: endpoint.method,
query,
}).then(normalizeInstancePage);
}
export function createPolicyInstance(payload: PolicyInstancePayload): Promise<PolicyInstanceDto> {
const endpoint = API_ENDPOINTS.createPolicyInstance;
return apiRequest<RawPolicyInstance, PolicyInstancePayload>(apiEndpointPath(API_OPERATIONS.createPolicyInstance), {
body: payload,
method: endpoint.method,
}).then(normalizeInstance);
}
export function publishPolicyInstance(instanceId: EntityId): Promise<PolicyPublishDto> {
const endpoint = API_ENDPOINTS.publishPolicyInstance;
return apiRequest<RawPolicyPublish>(apiEndpointPath(API_OPERATIONS.publishPolicyInstance, { instance_id: instanceId }), {
method: endpoint.method,
}).then(normalizePublish);
}
export function updatePolicyInstanceStatus(instanceId: EntityId, status: string): Promise<PolicyInstanceDto> {
const endpoint = API_ENDPOINTS.updatePolicyInstanceStatus;
return apiRequest<RawPolicyInstance, { status: string }>(
apiEndpointPath(API_OPERATIONS.updatePolicyInstanceStatus, { instance_id: instanceId }),
{
body: { status },
method: endpoint.method,
},
).then(normalizeInstance);
}
type RawPolicyTemplate = PolicyTemplateDto & {
created_at_ms?: number | string;
rule_json?: unknown;
template_code?: string;
template_id?: number | string;
template_version?: string;
updated_at_ms?: number | string;
version_id?: number | string;
} & Record<string, unknown>;
type RawPolicyInstance = PolicyInstanceDto & {
app_code?: string;
created_at_ms?: number | string;
effective_from_ms?: number | string;
effective_to_ms?: number | string;
instance_code?: string;
instance_id?: number | string;
last_published_at_ms?: number | string;
publish_error?: string;
publish_status?: string;
region_scope?: string;
template_code?: string;
template_version?: string;
updated_at_ms?: number | string;
} & Record<string, unknown>;
type RawPolicyPublish = PolicyPublishDto & {
app_code?: string;
instance_code?: string;
instance_id?: number | string;
job_id?: number | string;
published_at_ms?: number | string;
published_region_ids?: Array<number | string>;
target_count?: number | string;
template_code?: string;
template_version?: string;
} & Record<string, unknown>;
function normalizeTemplatePage(page: ApiPage<RawPolicyTemplate>): ApiPage<PolicyTemplateDto> {
return {
...page,
pageSize: numberValue(page.pageSize ?? (page as unknown as Record<string, unknown>).page_size),
items: (page.items || []).map(normalizeTemplate),
};
}
function normalizeInstancePage(page: ApiPage<RawPolicyInstance>): ApiPage<PolicyInstanceDto> {
return {
...page,
pageSize: numberValue(page.pageSize ?? (page as unknown as Record<string, unknown>).page_size),
items: (page.items || []).map(normalizeInstance),
};
}
function normalizeTemplate(item: RawPolicyTemplate): PolicyTemplateDto {
return {
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
description: stringValue(item.description),
name: stringValue(item.name),
ruleJson: normalizeRuleJson(item.ruleJson ?? item.rule_json),
status: stringValue(item.status || "active"),
templateCode: stringValue(item.templateCode ?? item.template_code),
templateId: numberValue(item.templateId ?? item.template_id),
templateVersion: stringValue(item.templateVersion ?? item.template_version),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
versionId: numberValue(item.versionId ?? item.version_id),
};
}
function normalizeInstance(item: RawPolicyInstance): PolicyInstanceDto {
return {
appCode: stringValue(item.appCode ?? item.app_code),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
effectiveFromMs: numberValue(item.effectiveFromMs ?? item.effective_from_ms),
effectiveToMs: numberValue(item.effectiveToMs ?? item.effective_to_ms),
instanceCode: stringValue(item.instanceCode ?? item.instance_code),
instanceId: numberValue(item.instanceId ?? item.instance_id),
lastPublishedAtMs: numberValue(item.lastPublishedAtMs ?? item.last_published_at_ms),
publishError: stringValue(item.publishError ?? item.publish_error),
publishStatus: stringValue((item.publishStatus ?? item.publish_status) || "draft"),
regionScope: stringValue((item.regionScope ?? item.region_scope) || "all_active_regions"),
status: stringValue(item.status || "active"),
templateCode: stringValue(item.templateCode ?? item.template_code),
templateVersion: stringValue(item.templateVersion ?? item.template_version),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizePublish(item: RawPolicyPublish): PolicyPublishDto {
return {
appCode: stringValue(item.appCode ?? item.app_code),
instanceCode: stringValue(item.instanceCode ?? item.instance_code),
instanceId: numberValue(item.instanceId ?? item.instance_id),
jobId: numberValue(item.jobId ?? item.job_id),
publishedAtMs: numberValue(item.publishedAtMs ?? item.published_at_ms),
publishedRegionIds: (item.publishedRegionIds || item.published_region_ids || []).map(numberValue),
status: stringValue(item.status),
targetCount: numberValue(item.targetCount ?? item.target_count),
templateCode: stringValue(item.templateCode ?? item.template_code),
templateVersion: stringValue(item.templateVersion ?? item.template_version),
};
}
function normalizeRuleJson(value: unknown): Record<string, unknown> {
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,114 @@
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { Button } from "@/shared/ui/Button.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import styles from "@/features/policy-config/policy-config.module.css";
export function PolicyInstanceDrawer({ page }) {
const saving = page.loadingAction === "instance-create";
const disabled = saving || !page.abilities.canCreateInstance;
const form = page.instanceForm;
const selectedTemplateValue = `${form.templateCode}:${form.templateVersion}`;
const patch = (value) => page.setInstanceForm((current) => ({ ...current, ...value }));
return (
<SideDrawer
actions={
<>
<Button disabled={saving} onClick={page.closeInstanceDrawer}>
取消
</Button>
<Button disabled={disabled} type="submit" variant="primary">
创建实例
</Button>
</>
}
as="form"
className={styles.instanceDrawer}
contentClassName={styles.drawerBody}
open={page.instanceDrawerOpen}
title="创建 App 策略实例"
width="wide"
onClose={saving ? undefined : page.closeInstanceDrawer}
onSubmit={page.submitInstance}
>
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>实例配置</h3>
</div>
<div className={styles.formGrid}>
<TextField
required
disabled={disabled}
label="实例编码"
size="small"
value={form.instanceCode}
onChange={(event) => patch({ instanceCode: event.target.value })}
/>
<TextField
required
select
disabled={disabled || !page.templateOptions.length}
label="模板版本"
size="small"
value={selectedTemplateValue}
onChange={(event) => {
const [templateCode, templateVersion] = String(event.target.value).split(":");
patch({ templateCode, templateVersion });
}}
>
{page.templateOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
{!page.templateOptions.length ? <MenuItem value={selectedTemplateValue}>{selectedTemplateValue}</MenuItem> : null}
</TextField>
<TextField
select
disabled={disabled}
label="区域范围"
size="small"
value={form.regionScopeMode}
onChange={(event) => patch({ regionScopeMode: event.target.value, regionId: "" })}
>
<MenuItem value="all_active_regions">全部启用地区</MenuItem>
<MenuItem value="region_id">指定地区</MenuItem>
</TextField>
{form.regionScopeMode === "region_id" ? (
<RegionSelect
disabled={disabled}
emptyLabel="选择地区"
label="指定地区"
loading={page.loadingRegions}
options={page.regionOptions}
required
value={form.regionId}
onChange={(regionId) => patch({ regionId })}
/>
) : null}
<TextField
select
disabled={disabled}
label="实例状态"
size="small"
value={form.status}
onChange={(event) => patch({ status: event.target.value })}
>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem>
</TextField>
<TimeRangeFilter
className={styles.timeRangeField}
disabled={disabled}
label="生效时间"
value={{ endMs: form.effectiveTo, startMs: form.effectiveFrom }}
onChange={(range) => patch({ effectiveFrom: range.startMs, effectiveTo: range.endMs })}
/>
</div>
</section>
</SideDrawer>
);
}

View File

@ -0,0 +1,481 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import styles from "@/features/policy-config/policy-config.module.css";
const agentPeriods = [["rolling_30d", "rolling_30d"]];
const bdPeriods = [["utc_calendar_month", "utc_calendar_month"]];
const gameInvitePeriods = [["rolling_30d", "rolling_30d"]];
const taskAssets = [
["POINT", "POINT"],
["COIN", "COIN"],
];
export function PolicyRuleEditorDrawer({ page }) {
const saving = page.loadingAction === "template-save";
const disabled = saving || !page.abilities.canSaveTemplate;
const form = page.templateForm;
return (
<SideDrawer
actions={
<>
<Button disabled={saving} onClick={page.closeTemplateDrawer}>
取消
</Button>
<Button disabled={disabled} type="submit" variant="primary">
保存模板版本
</Button>
</>
}
as="form"
className={styles.ruleDrawer}
contentClassName={styles.drawerBody}
open={page.templateDrawerOpen}
title={page.editingTemplate ? "编辑收益政策模板" : "新增收益政策模板"}
width="wide"
onClose={saving ? undefined : page.closeTemplateDrawer}
onSubmit={page.submitTemplate}
>
<TemplateBaseFields disabled={disabled} form={form} setForm={page.setTemplateForm} />
<GlobalRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
<HostRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
<AgentRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
<BDRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
<ManagerRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
<CoinSellerRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
<TaskAndGameRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
<JsonEditorField
disabled={disabled}
label="高级 JSON"
minRows={12}
value={form.rule.rawJson}
onChange={(rawJson) => updateRule(page.setTemplateForm, { rawJson })}
/>
</SideDrawer>
);
}
function TemplateBaseFields({ disabled, form, setForm }) {
const patch = (value) => setForm((current) => ({ ...current, ...value }));
return (
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>模板基础</h3>
</div>
<div className={styles.formGrid}>
<TextField required disabled={disabled} label="模板名称" size="small" value={form.name} onChange={(event) => patch({ name: event.target.value })} />
<TextField required disabled={disabled} label="模板编码" size="small" value={form.templateCode} onChange={(event) => patch({ templateCode: event.target.value })} />
<TextField required disabled={disabled} label="模板版本" size="small" value={form.templateVersion} onChange={(event) => patch({ templateVersion: event.target.value })} />
<TextField select disabled={disabled} label="状态" size="small" value={form.status} onChange={(event) => patch({ status: event.target.value })}>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem>
</TextField>
<TextField className={styles.fieldWide} disabled={disabled} label="说明" maxRows={3} multiline size="small" value={form.description} onChange={(event) => patch({ description: event.target.value })} />
</div>
</section>
);
}
function GlobalRuleSection({ disabled, form, setForm }) {
return (
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>全局口径</h3>
</div>
<div className={styles.formGrid}>
<NumberField disabled={disabled} label="POINT/USD" value={form.pointsPerUsd} onChange={(pointsPerUsd) => updateRule(setForm, { pointsPerUsd })} />
<NumberField disabled={disabled} label="Google 金币/USD" value={form.googleCoinPerUsd} onChange={(googleCoinPerUsd) => updateRule(setForm, { googleCoinPerUsd })} />
<SwitchField
checked={form.allowSelfBrushing}
disabled={disabled}
label="允许自刷"
onChange={(allowSelfBrushing) => updateRule(setForm, { allowSelfBrushing })}
/>
</div>
</section>
);
}
function HostRuleSection({ disabled, form, setForm }) {
return (
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>主播收益</h3>
</div>
<div className={styles.formGrid}>
<NumberField disabled={disabled} label="主播收益比例" suffix="%" value={form.host.pointRatioPercent} onChange={(pointRatioPercent) => updateSection(setForm, "host", { pointRatioPercent })} />
<NumberField disabled={disabled} label="最低提现积分" value={form.host.minimumWithdrawPoints} onChange={(minimumWithdrawPoints) => updateSection(setForm, "host", { minimumWithdrawPoints })} />
<NumberField disabled={disabled} label="提现手续费" suffix="bps" value={form.host.withdrawFeeBps} onChange={(withdrawFeeBps) => updateSection(setForm, "host", { withdrawFeeBps })} />
<SwitchField
checked={form.host.affectsRoomHeat}
disabled={disabled}
label="影响房间热度"
onChange={(affectsRoomHeat) => updateSection(setForm, "host", { affectsRoomHeat })}
/>
</div>
</section>
);
}
function AgentRuleSection({ disabled, form, setForm }) {
const agent = form.agent;
return (
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>Agent 规则</h3>
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} onClick={() => addAgentLevel(setForm)}>
添加等级
</Button>
</div>
<div className={styles.formGrid}>
<SelectField disabled={disabled} label="周期" options={agentPeriods} value={agent.period} onChange={(period) => updateSection(setForm, "agent", { period })} />
<NumberField disabled={disabled} label="首月扶持比例" suffix="%" value={agent.supportRatioPercent} onChange={(supportRatioPercent) => updateSection(setForm, "agent", { supportRatioPercent })} />
<NumberField disabled={disabled} label="扶持天数" value={agent.supportDays} onChange={(supportDays) => updateSection(setForm, "agent", { supportDays })} />
<NumberField disabled={disabled} label="合格主播数" value={agent.qualifiedHostMin} onChange={(qualifiedHostMin) => updateSection(setForm, "agent", { qualifiedHostMin })} />
<SwitchField checked={agent.differential.enabled} disabled={disabled} label="跨级差值" onChange={(enabled) => updateAgentDifferential(setForm, { enabled })} />
<NumberField disabled={disabled} label="差值深度" value={agent.differential.maxDepth} onChange={(maxDepth) => updateAgentDifferential(setForm, { maxDepth })} />
<TextField disabled={disabled} label="差值公式" size="small" value={agent.differential.crossLevelFormula} onChange={(event) => updateAgentDifferential(setForm, { crossLevelFormula: event.target.value })} />
</div>
<div className={styles.levelTable}>
<div className={styles.agentLevelHeader}>
<span>等级</span>
<span>名称</span>
<span>最小 USD</span>
<span>最大 USD</span>
<span>比例</span>
<span>合格主播</span>
<span>人工</span>
<span />
</div>
{agent.levels.map((level, index) => (
<div className={styles.agentLevelRow} key={`${level.level}-${index}`}>
<NumberField bare disabled={disabled} value={level.level} onChange={(value) => updateAgentLevel(setForm, index, { level: value })} />
<TextField disabled={disabled} size="small" value={level.name} onChange={(event) => updateAgentLevel(setForm, index, { name: event.target.value })} />
<NumberField bare disabled={disabled} value={level.minUsd} onChange={(value) => updateAgentLevel(setForm, index, { minUsd: value })} />
<NumberField bare disabled={disabled} value={level.maxUsd} onChange={(value) => updateAgentLevel(setForm, index, { maxUsd: value })} />
<NumberField bare disabled={disabled} suffix="%" value={level.ratioPercent} onChange={(value) => updateAgentLevel(setForm, index, { ratioPercent: value })} />
<NumberField bare disabled={disabled} value={level.qualifiedHostMin} onChange={(value) => updateAgentLevel(setForm, index, { qualifiedHostMin: value })} />
<AdminSwitch checked={level.manualReview} disabled={disabled} label={`Agent 等级 ${index + 1} 人工审核`} onChange={(event) => updateAgentLevel(setForm, index, { manualReview: event.target.checked })} />
<RemoveButton disabled={disabled || agent.levels.length <= 1} label="删除 Agent 等级" onClick={() => removeAgentLevel(setForm, index)} />
</div>
))}
</div>
</section>
);
}
function BDRuleSection({ disabled, form, setForm }) {
const bd = form.bd;
return (
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>BD 规则</h3>
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} onClick={() => addBDLevel(setForm)}>
添加等级
</Button>
</div>
<div className={styles.formGrid}>
<SelectField disabled={disabled} label="周期" options={bdPeriods} value={bd.period} onChange={(period) => updateSection(setForm, "bd", { period })} />
<TextField disabled={disabled} label="结算基准" size="small" value={bd.base} onChange={(event) => updateSection(setForm, "bd", { base: event.target.value })} />
<NumberField disabled={disabled} label="合格 Agency 数" value={bd.qualifiedAgencyMin} onChange={(qualifiedAgencyMin) => updateSection(setForm, "bd", { qualifiedAgencyMin })} />
<NumberField disabled={disabled} label="每 Agency 主播数" value={bd.qualifiedHostPerAgencyMin} onChange={(qualifiedHostPerAgencyMin) => updateSection(setForm, "bd", { qualifiedHostPerAgencyMin })} />
</div>
<div className={styles.levelTable}>
<div className={styles.bdLevelHeader}>
<span>等级</span>
<span>名称</span>
<span>门槛 USD</span>
<span>固定工资</span>
<span>提成</span>
<span />
</div>
{bd.levels.map((level, index) => (
<div className={styles.bdLevelRow} key={`${level.level}-${index}`}>
<NumberField bare disabled={disabled} value={level.level} onChange={(value) => updateBDLevel(setForm, index, { level: value })} />
<TextField disabled={disabled} size="small" value={level.name} onChange={(event) => updateBDLevel(setForm, index, { name: event.target.value })} />
<NumberField bare disabled={disabled} value={level.thresholdUsd} onChange={(value) => updateBDLevel(setForm, index, { thresholdUsd: value })} />
<NumberField bare disabled={disabled} prefix="$" value={level.salaryUsd} onChange={(value) => updateBDLevel(setForm, index, { salaryUsd: value })} />
<NumberField bare disabled={disabled} suffix="%" value={level.commissionPercent} onChange={(value) => updateBDLevel(setForm, index, { commissionPercent: value })} />
<RemoveButton disabled={disabled || bd.levels.length <= 1} label="删除 BD 等级" onClick={() => removeBDLevel(setForm, index)} />
</div>
))}
</div>
</section>
);
}
function ManagerRuleSection({ disabled, form, setForm }) {
return (
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>经理规则</h3>
</div>
<div className={styles.formGrid}>
<NumberField disabled={disabled} label="币商充值门槛" prefix="$" value={form.manager.coinSellerRechargeThresholdUsd} onChange={(coinSellerRechargeThresholdUsd) => updateSection(setForm, "manager", { coinSellerRechargeThresholdUsd })} />
<NumberField disabled={disabled} label="币商充值提成" suffix="%" value={form.manager.coinSellerRechargeCommissionPercent} onChange={(coinSellerRechargeCommissionPercent) => updateSection(setForm, "manager", { coinSellerRechargeCommissionPercent })} />
<NumberField disabled={disabled} label="主播收益提成" suffix="%" value={form.manager.hostPointCommissionPercent} onChange={(hostPointCommissionPercent) => updateSection(setForm, "manager", { hostPointCommissionPercent })} />
</div>
</section>
);
}
function CoinSellerRuleSection({ disabled, form, setForm }) {
const coinSeller = form.coinSeller;
return (
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>币商规则</h3>
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} onClick={() => addCoinSellerLevel(setForm)}>
添加档位
</Button>
</div>
<div className={styles.formGrid}>
<SwitchField checked={coinSeller.withdrawOrderEnabled} disabled={disabled} label="提现订单" onChange={(withdrawOrderEnabled) => updateSection(setForm, "coinSeller", { withdrawOrderEnabled })} />
<NumberField disabled={disabled} label="订单超时" suffix="秒" value={coinSeller.orderTimeoutSeconds} onChange={(orderTimeoutSeconds) => updateSection(setForm, "coinSeller", { orderTimeoutSeconds })} />
<NumberField disabled={disabled} label="币商结算" suffix="%" value={coinSeller.sellerPointSettlePercent} onChange={(sellerPointSettlePercent) => updateSection(setForm, "coinSeller", { sellerPointSettlePercent })} />
<NumberField disabled={disabled} label="币商奖励" suffix="%" value={coinSeller.sellerPointRewardPercent} onChange={(sellerPointRewardPercent) => updateSection(setForm, "coinSeller", { sellerPointRewardPercent })} />
<NumberField disabled={disabled} label="成功率关闭门槛" suffix="%" value={coinSeller.successRateCloseThresholdPercent} onChange={(successRateCloseThresholdPercent) => updateSection(setForm, "coinSeller", { successRateCloseThresholdPercent })} />
</div>
<div className={styles.levelTable}>
<div className={styles.coinSellerLevelHeader}>
<span>名称</span>
<span>累计门槛</span>
<span>单次门槛</span>
<span>金币/USD</span>
<span>订单</span>
<span />
</div>
{coinSeller.levels.map((level, index) => (
<div className={styles.coinSellerLevelRow} key={`${level.name}-${index}`}>
<TextField disabled={disabled} size="small" value={level.name} onChange={(event) => updateCoinSellerLevel(setForm, index, { name: event.target.value })} />
<NumberField bare disabled={disabled} prefix="$" value={level.thresholdUsd} onChange={(value) => updateCoinSellerLevel(setForm, index, { thresholdUsd: value })} />
<NumberField bare disabled={disabled} prefix="$" value={level.singleRechargeThresholdUsd} onChange={(value) => updateCoinSellerLevel(setForm, index, { singleRechargeThresholdUsd: value })} />
<NumberField bare disabled={disabled} value={level.coinPerUsd} onChange={(value) => updateCoinSellerLevel(setForm, index, { coinPerUsd: value })} />
<AdminSwitch checked={level.withdrawOrderEnabled} disabled={disabled} label={`币商档位 ${index + 1} 提现订单`} onChange={(event) => updateCoinSellerLevel(setForm, index, { withdrawOrderEnabled: event.target.checked })} />
<RemoveButton disabled={disabled || coinSeller.levels.length <= 1} label="删除币商档位" onClick={() => removeCoinSellerLevel(setForm, index)} />
</div>
))}
</div>
</section>
);
}
function TaskAndGameRuleSection({ disabled, form, setForm }) {
return (
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>任务与游戏邀请</h3>
</div>
<div className={styles.formGrid}>
<SelectField disabled={disabled} label="任务奖励资产" options={taskAssets} value={form.tasks.rewardAssetType} onChange={(rewardAssetType) => updateSection(setForm, "tasks", { rewardAssetType })} />
<NumberField disabled={disabled} label="新主播 7 日任务上限" value={form.tasks.newHost7dMaxPoints} onChange={(newHost7dMaxPoints) => updateSection(setForm, "tasks", { newHost7dMaxPoints })} />
<SwitchField checked={form.gameInvite.enabled} disabled={disabled} label="游戏邀请返利" onChange={(enabled) => updateSection(setForm, "gameInvite", { enabled })} />
<SelectField disabled={disabled} label="游戏邀请周期" options={gameInvitePeriods} value={form.gameInvite.period} onChange={(period) => updateSection(setForm, "gameInvite", { period })} />
</div>
</section>
);
}
function NumberField({ bare = false, disabled, label, onChange, prefix, suffix, value }) {
return (
<TextField
disabled={disabled}
label={bare ? undefined : label}
size="small"
slotProps={{
htmlInput: { min: 0, step: "any" },
input: {
startAdornment: prefix ? <InputAdornment position="start">{prefix}</InputAdornment> : undefined,
endAdornment: suffix ? <InputAdornment position="end">{suffix}</InputAdornment> : undefined,
},
}}
type="number"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
}
function SelectField({ disabled, label, onChange, options, value }) {
return (
<TextField select disabled={disabled} label={label} size="small" value={value} onChange={(event) => onChange(event.target.value)}>
{options.map(([optionValue, optionLabel]) => (
<MenuItem key={optionValue} value={optionValue}>
{optionLabel}
</MenuItem>
))}
</TextField>
);
}
function SwitchField({ checked, disabled, label, onChange }) {
return (
<div className={styles.switchField}>
<span>{label}</span>
<AdminSwitch checked={checked} checkedLabel="开" disabled={disabled} label={label} uncheckedLabel="关" onChange={(event) => onChange(event.target.checked)} />
</div>
);
}
function RemoveButton({ disabled, label, onClick }) {
return (
<IconButton disabled={disabled} label={label} tone="danger" onClick={onClick}>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
);
}
function updateRule(setForm, patch) {
setForm((current) => ({ ...current, rule: { ...current.rule, ...patch } }));
}
function updateSection(setForm, section, patch) {
setForm((current) => ({ ...current, rule: { ...current.rule, [section]: { ...current.rule[section], ...patch } } }));
}
function updateAgentDifferential(setForm, patch) {
setForm((current) => ({
...current,
rule: {
...current.rule,
agent: {
...current.rule.agent,
differential: { ...current.rule.agent.differential, ...patch },
},
},
}));
}
function updateAgentLevel(setForm, index, patch) {
updateArrayItem(setForm, "agent", "levels", index, patch);
}
function updateBDLevel(setForm, index, patch) {
updateArrayItem(setForm, "bd", "levels", index, patch);
}
function updateCoinSellerLevel(setForm, index, patch) {
updateArrayItem(setForm, "coinSeller", "levels", index, patch);
}
function updateArrayItem(setForm, section, key, index, patch) {
setForm((current) => ({
...current,
rule: {
...current.rule,
[section]: {
...current.rule[section],
[key]: current.rule[section][key].map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)),
},
},
}));
}
function addAgentLevel(setForm) {
setForm((current) => {
const last = current.rule.agent.levels[current.rule.agent.levels.length - 1];
const nextLevel = Number(last?.level || current.rule.agent.levels.length) + 1;
return {
...current,
rule: {
...current.rule,
agent: {
...current.rule.agent,
levels: [
...current.rule.agent.levels,
{
level: String(nextLevel),
manualReview: false,
maxUsd: "0",
minUsd: last?.maxUsd || "0",
name: `Lv${nextLevel}`,
qualifiedHostMin: last?.qualifiedHostMin || "0",
ratioPercent: last?.ratioPercent || "0",
},
],
},
},
};
});
}
function addBDLevel(setForm) {
setForm((current) => {
const last = current.rule.bd.levels[current.rule.bd.levels.length - 1];
const nextLevel = Number(last?.level || current.rule.bd.levels.length) + 1;
return {
...current,
rule: {
...current.rule,
bd: {
...current.rule.bd,
levels: [
...current.rule.bd.levels,
{
commissionPercent: last?.commissionPercent || "0",
level: String(nextLevel),
name: `BD-${nextLevel}`,
salaryUsd: last?.salaryUsd || "0",
thresholdUsd: "",
},
],
},
},
};
});
}
function addCoinSellerLevel(setForm) {
setForm((current) => ({
...current,
rule: {
...current.rule,
coinSeller: {
...current.rule.coinSeller,
levels: [
...current.rule.coinSeller.levels,
{
coinPerUsd: "0",
name: "",
singleRechargeThresholdUsd: "",
thresholdUsd: "",
withdrawOrderEnabled: false,
},
],
},
},
}));
}
function removeAgentLevel(setForm, index) {
removeArrayItem(setForm, "agent", "levels", index);
}
function removeBDLevel(setForm, index) {
removeArrayItem(setForm, "bd", "levels", index);
}
function removeCoinSellerLevel(setForm, index) {
removeArrayItem(setForm, "coinSeller", "levels", index);
}
function removeArrayItem(setForm, section, key, index) {
setForm((current) => ({
...current,
rule: {
...current.rule,
[section]: {
...current.rule[section],
[key]: current.rule[section][key].filter((_, itemIndex) => itemIndex !== index),
},
},
}));
}

View File

@ -0,0 +1,247 @@
import { useMemo, useState } from "react";
import { toPageQuery } from "@/shared/api/query";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createPolicyInstance,
listPolicyInstances,
listPolicyTemplates,
publishPolicyInstance,
savePolicyTemplate,
updatePolicyInstanceStatus,
} from "@/features/policy-config/api";
import { usePolicyConfigAbilities } from "@/features/policy-config/permissions.js";
import {
policyInstanceFormFromTemplate,
policyInstancePayloadFromForm,
policyTemplateFormFromItem,
policyTemplatePayloadFromForm,
} from "@/features/policy-config/schema";
const pageSize = 50;
export function usePolicyConfigPage() {
const abilities = usePolicyConfigAbilities();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [activeTab, setActiveTab] = useState("templates");
const [templateKeyword, setTemplateKeyword] = useState("");
const [templateStatus, setTemplateStatus] = useState("");
const [templatePage, setTemplatePage] = useState(1);
const [instanceKeyword, setInstanceKeyword] = useState("");
const [instanceStatus, setInstanceStatus] = useState("");
const [instancePage, setInstancePage] = useState(1);
const [templateDrawerOpen, setTemplateDrawerOpen] = useState(false);
const [instanceDrawerOpen, setInstanceDrawerOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState(null);
const [templateForm, setTemplateForm] = useState(() => policyTemplateFormFromItem(null));
const [instanceForm, setInstanceForm] = useState(() => policyInstanceFormFromTemplate(null));
const [loadingAction, setLoadingAction] = useState("");
const templateFilters = useMemo(
() => ({ keyword: templateKeyword, status: templateStatus }),
[templateKeyword, templateStatus],
);
const instanceFilters = useMemo(
() => ({ keyword: instanceKeyword, status: instanceStatus }),
[instanceKeyword, instanceStatus],
);
const templates = usePaginatedQuery({
errorMessage: "加载收益政策模板失败",
fetcher: listPolicyTemplates,
filters: templateFilters,
page: templatePage,
pageSize,
queryKey: ["policy-templates", toPageQuery({ ...templateFilters, page: templatePage, pageSize })],
});
const instances = usePaginatedQuery({
errorMessage: "加载收益政策实例失败",
fetcher: listPolicyInstances,
filters: instanceFilters,
page: instancePage,
pageSize,
queryKey: ["policy-instances", toPageQuery({ ...instanceFilters, page: instancePage, pageSize })],
});
const templateOptions = useMemo(
() =>
(templates.data.items || []).map((template) => ({
label: `${template.name || template.templateCode} · ${template.templateVersion}`,
templateCode: template.templateCode,
templateVersion: template.templateVersion,
value: `${template.templateCode}:${template.templateVersion}`,
})),
[templates.data.items],
);
const regionLabelMap = useMemo(
() =>
regionOptions.reduce((labels, region) => {
labels[String(region.regionId)] = region.label;
return labels;
}, {}),
[regionOptions],
);
const openTemplateEditor = (template = null) => {
setEditingTemplate(template);
setTemplateForm(policyTemplateFormFromItem(template));
setTemplateDrawerOpen(true);
};
const closeTemplateDrawer = () => {
if (loadingAction === "template-save") {
return;
}
setTemplateDrawerOpen(false);
setEditingTemplate(null);
};
const openInstanceCreator = (template = null) => {
setInstanceForm(policyInstanceFormFromTemplate(template || templates.data.items?.[0] || null));
setInstanceDrawerOpen(true);
};
const closeInstanceDrawer = () => {
if (loadingAction === "instance-create") {
return;
}
setInstanceDrawerOpen(false);
};
const submitTemplate = async (event) => {
event.preventDefault();
if (!abilities.canSaveTemplate) {
return;
}
await runAction("template-save", "收益政策模板已保存", async () => {
// 模板 POST 是 code+version 维度的 upsert保存只更新 admin 草稿,运行侧必须再发布实例。
await savePolicyTemplate(policyTemplatePayloadFromForm(templateForm));
setTemplateDrawerOpen(false);
setEditingTemplate(null);
await templates.reload();
});
};
const submitInstance = async (event) => {
event.preventDefault();
if (!abilities.canCreateInstance) {
return;
}
await runAction("instance-create", "收益政策实例已创建", async () => {
// app_code 不在表单中提交,由 apiRequest 的 X-App-Code 和 admin-server appctx 共同确定。
await createPolicyInstance(policyInstancePayloadFromForm(instanceForm));
setInstanceDrawerOpen(false);
await instances.reload();
});
};
const toggleInstanceStatus = async (instance, nextEnabled = instance.status !== "active") => {
if (!instance?.instanceId || !abilities.canUpdateInstance || (instance.status === "active") === nextEnabled) {
return;
}
await runAction(`instance-status-${instance.instanceId}`, nextEnabled ? "收益政策实例已启用" : "收益政策实例已停用", async () => {
// 停用实例要同步 owner service 运行表;不能只在前端隐藏。
await updatePolicyInstanceStatus(instance.instanceId, nextEnabled ? "active" : "disabled");
await instances.reload();
});
};
const publishInstance = async (instance) => {
if (!instance?.instanceId || !abilities.canPublishInstance) {
return;
}
await runAction(`instance-publish-${instance.instanceId}`, "收益政策实例已发布", async () => {
// 发布是 admin 配置进入 wallet/activity runtime 的唯一入口;模板保存本身不会影响线上结算。
await publishPolicyInstance(instance.instanceId);
await instances.reload();
});
};
const changeTemplateKeyword = (value) => {
setTemplateKeyword(value);
setTemplatePage(1);
};
const changeTemplateStatus = (value) => {
setTemplateStatus(value);
setTemplatePage(1);
};
const changeInstanceKeyword = (value) => {
setInstanceKeyword(value);
setInstancePage(1);
};
const changeInstanceStatus = (value) => {
setInstanceStatus(value);
setInstancePage(1);
};
const resetTemplateFilters = () => {
setTemplateKeyword("");
setTemplateStatus("");
setTemplatePage(1);
};
const resetInstanceFilters = () => {
setInstanceKeyword("");
setInstanceStatus("");
setInstancePage(1);
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (error) {
showToast(error?.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeTab,
changeInstanceKeyword,
changeInstanceStatus,
changeTemplateKeyword,
changeTemplateStatus,
closeInstanceDrawer,
closeTemplateDrawer,
editingTemplate,
instanceDrawerOpen,
instanceFilters: { keyword: instanceKeyword, status: instanceStatus },
instanceForm,
instancePage,
instances,
loadingAction,
loadingRegions,
openInstanceCreator,
openTemplateEditor,
publishInstance,
regionLabelMap,
regionOptions,
resetInstanceFilters,
resetTemplateFilters,
setActiveTab,
setInstanceForm,
setInstancePage,
setTemplateForm,
setTemplatePage,
submitInstance,
submitTemplate,
templateDrawerOpen,
templateFilters: { keyword: templateKeyword, status: templateStatus },
templateForm,
templateOptions,
templatePage,
templates,
toggleInstanceStatus,
};
}

View File

@ -0,0 +1,350 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import CloudUploadOutlined from "@mui/icons-material/CloudUploadOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminFilterSelect,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminSearchBox,
} from "@/shared/ui/AdminListLayout.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { PolicyInstanceDrawer } from "@/features/policy-config/components/PolicyInstanceDrawer.jsx";
import { PolicyRuleEditorDrawer } from "@/features/policy-config/components/PolicyRuleEditorDrawer.jsx";
import { usePolicyConfigPage } from "@/features/policy-config/hooks/usePolicyConfigPage.js";
import styles from "@/features/policy-config/policy-config.module.css";
const statusOptions = [
["", "全部状态"],
["active", "启用"],
["disabled", "停用"],
];
const templateColumns = [
{
key: "template",
label: "模板",
width: "minmax(320px, 1.2fr)",
render: (item) => <TemplateIdentity item={item} />,
},
{
key: "runtime",
label: "核心运行字段",
width: "minmax(240px, 0.9fr)",
render: (item) => <TemplateRuntimeSummary item={item} />,
},
{
key: "bd",
label: "团队政策",
width: "minmax(220px, 0.8fr)",
render: (item) => <TeamPolicySummary item={item} />,
},
{
key: "status",
label: "状态",
width: "120px",
render: (item) => statusLabel(item.status),
},
{
key: "updatedAt",
label: "更新时间",
width: "180px",
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "96px",
resizable: false,
},
];
const instanceColumns = [
{
key: "instance",
label: "实例",
width: "minmax(300px, 1.1fr)",
render: (item) => <InstanceIdentity item={item} />,
},
{
key: "scope",
label: "范围与时间",
width: "minmax(260px, 0.9fr)",
render: (item, _index, context) => <InstanceScope item={item} regionLabelMap={context.regionLabelMap} />,
},
{
key: "publish",
label: "发布",
width: "minmax(220px, 0.85fr)",
render: (item) => <PublishSummary item={item} />,
},
{
key: "status",
label: "状态",
width: "120px",
},
{
key: "updatedAt",
label: "更新时间",
width: "180px",
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "144px",
resizable: false,
},
];
export function PolicyConfigPage() {
const page = usePolicyConfigPage();
return (
<AdminListPage>
<div className={styles.tabsBar}>
<Tabs value={page.activeTab} onChange={(_, value) => page.setActiveTab(value)}>
<Tab className={styles.tab} label="模板规则" value="templates" />
<Tab className={styles.tab} label="App 实例" value="instances" />
</Tabs>
</div>
{page.activeTab === "templates" ? <TemplatePanel page={page} /> : <InstancePanel page={page} />}
<PolicyRuleEditorDrawer page={page} />
<PolicyInstanceDrawer page={page} />
</AdminListPage>
);
}
function TemplatePanel({ page }) {
const items = page.templates.data.items || [];
const total = page.templates.data.total || 0;
const columns = templateColumns.map((column) =>
column.key === "actions" ? { ...column, render: (item) => <TemplateActions item={item} page={page} /> } : column,
);
return (
<>
<AdminListToolbar
actions={
page.abilities.canSaveTemplate ? (
<AdminActionIconButton label="新增收益政策模板" primary onClick={() => page.openTemplateEditor(null)}>
<AddOutlined fontSize="small" />
</AdminActionIconButton>
) : null
}
filters={
<div className={styles.toolbarFilters}>
<AdminSearchBox label="模板" placeholder="搜索模板名称或编码" value={page.templateFilters.keyword} onChange={page.changeTemplateKeyword} />
<AdminFilterSelect label="状态" options={statusOptions} value={page.templateFilters.status} onChange={page.changeTemplateStatus} />
<AdminFilterResetButton disabled={!page.templateFilters.keyword && !page.templateFilters.status} onClick={page.resetTemplateFilters} />
</div>
}
/>
<DataState error={page.templates.error} loading={page.templates.loading} onRetry={page.templates.reload}>
<AdminListBody>
<DataTable
columns={columns}
items={items}
minWidth="1240px"
pagination={total > 0 ? { page: page.templatePage, pageSize: page.templates.data.pageSize || 50, total, onPageChange: page.setTemplatePage } : undefined}
rowKey={(item) => `${item.templateCode}:${item.templateVersion}`}
/>
</AdminListBody>
</DataState>
</>
);
}
function InstancePanel({ page }) {
const items = page.instances.data.items || [];
const total = page.instances.data.total || 0;
const columns = instanceColumns.map((column) => {
if (column.key === "status") {
return { ...column, render: (item) => <InstanceStatusSwitch item={item} page={page} /> };
}
if (column.key === "actions") {
return { ...column, render: (item) => <InstanceActions item={item} page={page} /> };
}
return column;
});
return (
<>
<AdminListToolbar
actions={
page.abilities.canCreateInstance ? (
<AdminActionIconButton label="创建策略实例" primary onClick={() => page.openInstanceCreator(null)}>
<AddOutlined fontSize="small" />
</AdminActionIconButton>
) : null
}
filters={
<div className={styles.toolbarFilters}>
<AdminSearchBox label="实例" placeholder="搜索实例或模板编码" value={page.instanceFilters.keyword} onChange={page.changeInstanceKeyword} />
<AdminFilterSelect label="状态" options={statusOptions} value={page.instanceFilters.status} onChange={page.changeInstanceStatus} />
<AdminFilterResetButton disabled={!page.instanceFilters.keyword && !page.instanceFilters.status} onClick={page.resetInstanceFilters} />
</div>
}
/>
<DataState error={page.instances.error} loading={page.instances.loading} onRetry={page.instances.reload}>
<AdminListBody>
<DataTable
columns={columns}
context={{ regionLabelMap: page.regionLabelMap }}
items={items}
minWidth="1260px"
pagination={total > 0 ? { page: page.instancePage, pageSize: page.instances.data.pageSize || 50, total, onPageChange: page.setInstancePage } : undefined}
rowKey={(item) => item.instanceId}
/>
</AdminListBody>
</DataState>
</>
);
}
function TemplateIdentity({ item }) {
return (
<div className={styles.stack}>
<span className={styles.name}>{item.name}</span>
<span className={styles.meta}>{item.templateCode}</span>
<span className={styles.meta}>版本 {item.templateVersion}</span>
</div>
);
}
function TemplateRuntimeSummary({ item }) {
const rule = item.ruleJson || {};
const host = rule.host || {};
const tasks = rule.tasks || {};
return (
<div className={styles.stack}>
<span>{formatNumber(rule.points_per_usd)} POINT/USD</span>
<span className={styles.meta}>主播 {formatNumber(host.point_ratio_percent)}% POINT</span>
<span className={styles.meta}>提现手续费 {formatNumber(host.withdraw_fee_bps)} bps</span>
<span className={styles.meta}>任务资产 {tasks.reward_asset_type || "-"}</span>
</div>
);
}
function TeamPolicySummary({ item }) {
const rule = item.ruleJson || {};
const agentLevels = Array.isArray(rule.agent?.levels) ? rule.agent.levels : [];
const bdLevels = Array.isArray(rule.bd?.levels) ? rule.bd.levels : [];
const coinSellerLevels = Array.isArray(rule.coin_seller?.levels) ? rule.coin_seller.levels : [];
return (
<div className={styles.stack}>
<span>Agent {agentLevels.length} · BD {bdLevels.length} </span>
<span className={styles.meta}>币商 {coinSellerLevels.length} </span>
<span className={styles.meta}>{rule.allow_self_brushing ? "允许自刷" : "不允许自刷"}</span>
</div>
);
}
function TemplateActions({ item, page }) {
if (!page.abilities.canSaveTemplate) {
return null;
}
return (
<AdminActionIconButton label="编辑模板规则" onClick={() => page.openTemplateEditor(item)}>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
);
}
function InstanceIdentity({ item }) {
return (
<div className={styles.stack}>
<span className={styles.name}>{item.instanceCode}</span>
<span className={styles.meta}>{item.appCode || "-"}</span>
<span className={styles.meta}>
{item.templateCode} · {item.templateVersion}
</span>
</div>
);
}
function InstanceScope({ item, regionLabelMap = {} }) {
return (
<div className={styles.stack}>
<span>{regionScopeLabel(item.regionScope, regionLabelMap)}</span>
<span className={styles.meta}>开始 {formatMillis(item.effectiveFromMs)}</span>
<span className={styles.meta}>结束 {item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"}</span>
</div>
);
}
function PublishSummary({ item }) {
return (
<div className={styles.stack}>
<span>{publishStatusLabel(item.publishStatus)}</span>
<span className={styles.meta}>{item.lastPublishedAtMs ? formatMillis(item.lastPublishedAtMs) : "未发布"}</span>
{item.publishError ? <span className={styles.errorText}>{item.publishError}</span> : null}
</div>
);
}
function InstanceStatusSwitch({ item, page }) {
const action = `instance-status-${item.instanceId}`;
return (
<AdminSwitch
checked={item.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdateInstance || page.loadingAction === action}
label="策略实例状态"
uncheckedLabel="停用"
onChange={(event) => page.toggleInstanceStatus(item, event.target.checked)}
/>
);
}
function InstanceActions({ item, page }) {
const action = `instance-publish-${item.instanceId}`;
if (!page.abilities.canPublishInstance) {
return null;
}
return (
<AdminActionIconButton
disabled={item.status !== "active" || page.loadingAction === action}
label="发布到运行侧"
primary
onClick={() => page.publishInstance(item)}
>
<CloudUploadOutlined fontSize="small" />
</AdminActionIconButton>
);
}
function statusLabel(status) {
return status === "active" ? "启用" : "停用";
}
function publishStatusLabel(status) {
switch (status) {
case "published":
return "已发布";
case "failed":
return "发布失败";
default:
return "草稿";
}
}
function regionScopeLabel(scope, labels) {
if (scope === "all_active_regions" || !scope) {
return "全部启用地区";
}
if (String(scope).startsWith("region_id:")) {
const regionId = String(scope).replace("region_id:", "");
return labels[regionId] || `区域 ${regionId}`;
}
return scope;
}
function formatNumber(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString("en-US") : "-";
}

View File

@ -0,0 +1,14 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function usePolicyConfigAbilities() {
const { can } = useAuth();
return {
canCreateInstance: can(PERMISSIONS.policyInstanceCreate),
canPublishInstance: can(PERMISSIONS.policyInstancePublish),
canSaveTemplate: can(PERMISSIONS.policyTemplateCreate),
canUpdateInstance: can(PERMISSIONS.policyInstanceUpdate),
canView: can(PERMISSIONS.policyTemplateView),
};
}

View File

@ -0,0 +1,174 @@
.tabsBar {
display: flex;
align-items: center;
min-height: 48px;
border-bottom: 1px solid var(--border);
padding: 0 16px;
}
.tab {
min-height: 48px;
text-transform: none;
}
.toolbarFilters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.stack {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
font-weight: 720;
}
.meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-tertiary);
font-size: 12px;
}
.errorText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--danger);
font-size: 12px;
}
.ruleDrawer,
.instanceDrawer {
width: min(1120px, calc(100vw - 32px));
}
.drawerBody {
display: flex;
flex-direction: column;
gap: 18px;
}
.formSection {
display: flex;
flex-direction: column;
gap: 14px;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sectionHeader h3 {
margin: 0;
color: var(--text-primary);
font-size: 15px;
font-weight: 760;
letter-spacing: 0;
}
.formGrid {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 12px;
align-items: start;
}
.fieldWide {
grid-column: 1 / -1;
}
.timeRangeField {
grid-column: span 2;
}
.switchField {
display: flex;
min-height: var(--control-height);
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
padding: 0 12px;
color: var(--text-secondary);
font-size: var(--admin-font-size);
}
.levelTable {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
overflow-x: auto;
}
.agentLevelHeader,
.agentLevelRow,
.bdLevelHeader,
.bdLevelRow,
.coinSellerLevelHeader,
.coinSellerLevelRow {
display: grid;
min-width: 920px;
align-items: center;
gap: 8px;
}
.agentLevelHeader,
.bdLevelHeader,
.coinSellerLevelHeader {
min-height: 34px;
border-radius: 8px;
background: var(--bg-card-strong);
padding: 0 10px;
color: var(--text-tertiary);
font-size: 12px;
font-weight: 720;
}
.agentLevelRow,
.bdLevelRow,
.coinSellerLevelRow {
min-height: 44px;
}
.agentLevelHeader,
.agentLevelRow {
grid-template-columns: 72px minmax(130px, 1fr) 110px 110px 120px 110px 76px 44px;
}
.bdLevelHeader,
.bdLevelRow {
grid-template-columns: 72px minmax(160px, 1fr) 130px 130px 120px 44px;
}
.coinSellerLevelHeader,
.coinSellerLevelRow {
grid-template-columns: minmax(160px, 1fr) 130px 130px 130px 92px 44px;
}
@media (max-width: 900px) {
.formGrid {
grid-template-columns: 1fr;
}
.timeRangeField {
grid-column: auto;
}
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const policyConfigRoutes = [
{
label: "收益政策模板",
loader: () => import("./pages/PolicyConfigPage.jsx").then((module) => module.PolicyConfigPage),
menuCode: MENU_CODES.policyTemplate,
pageKey: "policy-config",
path: "/policy/templates",
permission: PERMISSIONS.policyTemplateView,
},
];

View File

@ -0,0 +1,312 @@
import { describe, expect, test } from "vitest";
import {
defaultPolicyRule,
policyInstancePayloadFromForm,
policyRuleFormSchema,
ruleFormFromJson,
ruleJsonFromForm,
ruleObjectFromForm,
type PolicyInstanceForm,
type PolicyRuleForm,
} from "./schema";
type RuleRecord = Record<string, unknown>;
function validRuleForm(): PolicyRuleForm {
return ruleFormFromJson(defaultPolicyRule());
}
function clone<TValue>(value: TValue): TValue {
return JSON.parse(JSON.stringify(value)) as TValue;
}
function record(value: unknown): RuleRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as RuleRecord) : {};
}
function recordArray(value: unknown): RuleRecord[] {
return Array.isArray(value) ? value.map(record) : [];
}
function invalidRuleMessages(form: PolicyRuleForm): string[] {
const result = policyRuleFormSchema.safeParse(form);
if (result.success) {
throw new Error("expected policy rule form to be invalid");
}
return result.error.issues.map((issue) => issue.message);
}
function expectInvalidRule(form: PolicyRuleForm, message: string) {
expect(invalidRuleMessages(form)).toContain(message);
}
function baseInstanceForm(): PolicyInstanceForm {
return {
effectiveFrom: "",
effectiveTo: "",
instanceCode: " huwaa_policy ",
regionId: "",
regionScopeMode: "all_active_regions",
status: "active",
templateCode: " first_google70000_coin_seller_92000_100000_v1 ",
templateVersion: " v1 ",
};
}
describe("policy rule form schema", () => {
test("hydrates and serializes the Huwaa default policy rule", () => {
const form = validRuleForm();
expect(form).toMatchObject({
allowSelfBrushing: true,
googleCoinPerUsd: "70000",
host: {
affectsRoomHeat: false,
minimumWithdrawPoints: "1000000",
pointRatioPercent: "70",
withdrawFeeBps: "500",
},
pointsPerUsd: "100000",
});
expect(form.agent.levels).toHaveLength(4);
expect(form.agent.levels[3]).toMatchObject({ level: "4", manualReview: true, maxUsd: "0", minUsd: "2000" });
expect(form.bd.levels).toHaveLength(12);
expect(form.coinSeller.levels.map((level) => level.coinPerUsd)).toEqual(["92000", "96000", "100000"]);
const rule = ruleObjectFromForm(form);
expect(rule).toMatchObject({
allow_self_brushing: true,
coin_seller: {
levels: [
{ coin_per_usd: 92000, name: "Beginner", threshold_usd: 100, withdraw_order_enabled: false },
{ coin_per_usd: 96000, name: "Standard", threshold_usd: 500, withdraw_order_enabled: false },
{ coin_per_usd: 100000, name: "Senior", single_recharge_threshold_usd: 1000, withdraw_order_enabled: true },
],
seller_point_reward_percent: 5,
seller_point_settle_percent: 95,
},
google_coin_per_usd: 70000,
host: { withdraw_fee_bps: 500 },
points_per_usd: 100000,
});
expect(JSON.parse(ruleJsonFromForm(form))).toMatchObject({ google_coin_per_usd: 70000, points_per_usd: 100000 });
});
test("preserves unknown JSON fields while visual fields override known policy keys", () => {
const base = defaultPolicyRule();
const baseAgent = record(base.agent);
const baseBD = record(base.bd);
const baseCoinSeller = record(base.coin_seller);
const baseAgentLevels = recordArray(baseAgent.levels);
const baseBDLevels = recordArray(baseBD.levels);
const baseCoinSellerLevels = recordArray(baseCoinSeller.levels);
const rawRule = {
...base,
future_top_level: "preserve-top",
agent: {
...baseAgent,
future_agent: "preserve-agent",
levels: [{ ...baseAgentLevels[0], future_agent_level: "preserve-agent-level" }, ...baseAgentLevels.slice(1)],
},
bd: {
...baseBD,
future_bd: "preserve-bd",
levels: [{ ...baseBDLevels[0], future_bd_level: "preserve-bd-level" }, ...baseBDLevels.slice(1)],
},
coin_seller: {
...baseCoinSeller,
future_coin_seller: "preserve-coin-seller",
levels: [{ ...baseCoinSellerLevels[0], future_coin_seller_level: "preserve-coin-seller-level" }, ...baseCoinSellerLevels.slice(1)],
},
game_invite: { ...record(base.game_invite), future_game_invite: "preserve-game-invite" },
host: { ...record(base.host), future_host: "preserve-host" },
tasks: { ...record(base.tasks), future_tasks: "preserve-tasks" },
};
const form = ruleFormFromJson(rawRule);
form.allowSelfBrushing = false;
form.googleCoinPerUsd = "71000";
form.host.pointRatioPercent = "72.5";
form.agent.levels[0].ratioPercent = "6";
form.bd.levels[0].commissionPercent = "1.75";
form.coinSeller.levels[0].coinPerUsd = "93000";
form.gameInvite.enabled = false;
form.tasks.rewardAssetType = "COIN";
const rule = ruleObjectFromForm(form);
expect(rule).toMatchObject({
allow_self_brushing: false,
future_top_level: "preserve-top",
google_coin_per_usd: 71000,
agent: {
future_agent: "preserve-agent",
},
bd: {
future_bd: "preserve-bd",
},
coin_seller: {
future_coin_seller: "preserve-coin-seller",
},
game_invite: { enabled: false, future_game_invite: "preserve-game-invite" },
host: { future_host: "preserve-host", point_ratio_percent: 72.5 },
tasks: { future_tasks: "preserve-tasks", reward_asset_type: "COIN" },
});
expect(recordArray(record(rule.agent).levels)[0]).toMatchObject({ future_agent_level: "preserve-agent-level", ratio_percent: 6 });
expect(recordArray(record(rule.bd).levels)[0]).toMatchObject({ commission_percent: 1.75, future_bd_level: "preserve-bd-level" });
expect(recordArray(record(rule.coin_seller).levels)[0]).toMatchObject({ coin_per_usd: 93000, future_coin_seller_level: "preserve-coin-seller-level" });
});
test("does not reattach removed level unknown fields by array index", () => {
const base = defaultPolicyRule();
const baseAgent = record(base.agent);
const baseBD = record(base.bd);
const baseCoinSeller = record(base.coin_seller);
const baseAgentLevels = recordArray(baseAgent.levels);
const baseBDLevels = recordArray(baseBD.levels);
const baseCoinSellerLevels = recordArray(baseCoinSeller.levels);
const form = ruleFormFromJson({
...base,
agent: {
...baseAgent,
levels: [{ ...baseAgentLevels[0], marker: "deleted-agent-level" }, { ...baseAgentLevels[1], marker: "kept-agent-level" }],
},
bd: {
...baseBD,
levels: [{ ...baseBDLevels[0], marker: "deleted-bd-level" }, { ...baseBDLevels[1], marker: "kept-bd-level" }],
},
coin_seller: {
...baseCoinSeller,
levels: [
{ ...baseCoinSellerLevels[0], marker: "deleted-coin-seller-level" },
{ ...baseCoinSellerLevels[1], marker: "kept-coin-seller-level" },
],
},
});
form.agent.levels = form.agent.levels.slice(1);
form.bd.levels = form.bd.levels.slice(1);
form.coinSeller.levels = form.coinSeller.levels.slice(1);
const rule = ruleObjectFromForm(form);
expect(recordArray(record(rule.agent).levels)[0]).toMatchObject({ level: 2, marker: "kept-agent-level" });
expect(recordArray(record(rule.bd).levels)[0]).toMatchObject({ level: 2, marker: "kept-bd-level" });
expect(recordArray(record(rule.coin_seller).levels)[0]).toMatchObject({ name: "Standard", marker: "kept-coin-seller-level" });
});
test("rejects non-object and invalid advanced JSON", () => {
const nonObjectJson = validRuleForm();
nonObjectJson.rawJson = "[]";
expect(invalidRuleMessages(nonObjectJson).some((message) => message.includes("规则 JSON 必须是对象"))).toBe(true);
const invalidJson = validRuleForm();
invalidJson.rawJson = "{bad json";
expect(invalidRuleMessages(invalidJson).some((message) => message.startsWith("JSON 格式错误:"))).toBe(true);
expect(() => ruleFormFromJson("[]")).toThrow("规则 JSON 必须是对象");
});
test("rejects invalid Agent level identity, thresholds, and ratio range", () => {
const duplicateLevel = validRuleForm();
duplicateLevel.agent.levels[1].level = duplicateLevel.agent.levels[0].level;
expectInvalidRule(duplicateLevel, "Agent 等级必须为不重复的正整数");
const invalidWindow = validRuleForm();
invalidWindow.agent.levels[0].minUsd = "100";
invalidWindow.agent.levels[0].maxUsd = "100";
expectInvalidRule(invalidWindow, "Agent 最大 USD 必须大于最小 USD0 表示无上限");
const invalidRatio = validRuleForm();
invalidRatio.agent.levels[0].ratioPercent = "101";
expectInvalidRule(invalidRatio, "Agent 提成比例必须在 0-100 之间");
});
test("rejects invalid BD level identity, increasing threshold, and commission range", () => {
const duplicateLevel = validRuleForm();
duplicateLevel.bd.levels[1].level = duplicateLevel.bd.levels[0].level;
expectInvalidRule(duplicateLevel, "BD 等级必须为不重复的正整数");
const nonIncreasingThreshold = validRuleForm();
nonIncreasingThreshold.bd.levels[1].thresholdUsd = nonIncreasingThreshold.bd.levels[0].thresholdUsd;
expectInvalidRule(nonIncreasingThreshold, "BD 收入门槛必须逐级递增");
const invalidCommission = validRuleForm();
invalidCommission.bd.levels[0].commissionPercent = "0";
expectInvalidRule(invalidCommission, "BD 提成比例必须在 0-100 之间");
});
test("rejects invalid coin seller level thresholds, percentages, and host fee bps", () => {
const missingThreshold = validRuleForm();
missingThreshold.coinSeller.levels[0].thresholdUsd = "";
missingThreshold.coinSeller.levels[0].singleRechargeThresholdUsd = "";
expectInvalidRule(missingThreshold, "币商等级必须配置累计门槛或单次充值门槛");
const invalidCoinRate = validRuleForm();
invalidCoinRate.coinSeller.levels[0].coinPerUsd = "0";
expectInvalidRule(invalidCoinRate, "币商金币单价必须大于 0");
const invalidPercent = validRuleForm();
invalidPercent.coinSeller.sellerPointSettlePercent = "101";
expectInvalidRule(invalidPercent, "币商结算比例必须在 0-100 之间");
const invalidPercentTotal = validRuleForm();
invalidPercentTotal.coinSeller.sellerPointSettlePercent = "96";
invalidPercentTotal.coinSeller.sellerPointRewardPercent = "5";
expectInvalidRule(invalidPercentTotal, "币商结算比例和奖励比例合计不能超过 100");
const invalidFeeBps = validRuleForm();
invalidFeeBps.host.withdrawFeeBps = "10001";
expectInvalidRule(invalidFeeBps, "提现手续费 bps 必须在 0-10000 之间");
});
});
describe("policy instance payload", () => {
test("builds all_active_regions payload and trims template fields", () => {
expect(policyInstancePayloadFromForm(baseInstanceForm())).toEqual({
effective_from_ms: 0,
effective_to_ms: 0,
instance_code: "huwaa_policy",
region_scope: "all_active_regions",
status: "active",
template_code: "first_google70000_coin_seller_92000_100000_v1",
template_version: "v1",
});
});
test("builds region_id scope and preserves UTC epoch millisecond boundaries", () => {
const startMs = Date.UTC(2026, 0, 1, 0, 0, 0, 0);
const endMs = startMs + 1;
expect(
policyInstancePayloadFromForm({
...baseInstanceForm(),
effectiveFrom: startMs,
effectiveTo: endMs,
regionId: "1001",
regionScopeMode: "region_id",
}),
).toMatchObject({
effective_from_ms: 1767225600000,
effective_to_ms: 1767225600001,
region_scope: "region_id:1001",
});
});
test("rejects missing region id and invalid effective time boundaries", () => {
const startMs = Date.UTC(2026, 0, 1, 0, 0, 0, 0);
expect(() => policyInstancePayloadFromForm({ ...baseInstanceForm(), regionScopeMode: "region_id" })).toThrow("请选择适用区域");
expect(() => policyInstancePayloadFromForm({ ...baseInstanceForm(), effectiveFrom: startMs, effectiveTo: startMs })).toThrow("生效时间范围不正确");
expect(() => policyInstancePayloadFromForm({ ...baseInstanceForm(), effectiveFrom: startMs, effectiveTo: startMs - 1 })).toThrow("生效时间范围不正确");
expect(() => policyInstancePayloadFromForm({ ...baseInstanceForm(), effectiveFrom: "-1", effectiveTo: "" })).toThrow("生效时间范围不正确");
});
test("truncates decimal millisecond inputs after schema validation accepts the range", () => {
const form = clone(baseInstanceForm());
form.effectiveFrom = "1767225600000.9";
form.effectiveTo = "1767225600002.1";
expect(policyInstancePayloadFromForm(form)).toMatchObject({
effective_from_ms: 1767225600000,
effective_to_ms: 1767225600002,
});
});
});

View File

@ -0,0 +1,783 @@
import { z } from "zod";
export const DEFAULT_POLICY_TEMPLATE_CODE = "first_google70000_coin_seller_92000_100000_v1";
export const DEFAULT_POLICY_TEMPLATE_VERSION = "v1";
const statusValues = ["active", "disabled"] as const;
const rewardAssetValues = ["COIN", "POINT"] as const;
const regionScopeModes = ["all_active_regions", "region_id"] as const;
type NumericInput = number | string;
type RuleObject = Record<string, unknown>;
export interface AgentLevelForm {
extra?: RuleObject;
level: NumericInput;
manualReview: boolean;
maxUsd: NumericInput;
minUsd: NumericInput;
name: string;
qualifiedHostMin: NumericInput;
ratioPercent: NumericInput;
}
export interface BDLevelForm {
commissionPercent: NumericInput;
extra?: RuleObject;
level: NumericInput;
name: string;
salaryUsd: NumericInput;
thresholdUsd: NumericInput;
}
export interface CoinSellerLevelForm {
coinPerUsd: NumericInput;
extra?: RuleObject;
name: string;
singleRechargeThresholdUsd: NumericInput;
thresholdUsd: NumericInput;
withdrawOrderEnabled: boolean;
}
export interface PolicyRuleForm {
agent: {
differential: {
crossLevelFormula: string;
enabled: boolean;
maxDepth: NumericInput;
};
levels: AgentLevelForm[];
period: string;
qualifiedHostMin: NumericInput;
supportDays: NumericInput;
supportRatioPercent: NumericInput;
};
allowSelfBrushing: boolean;
bd: {
base: string;
levels: BDLevelForm[];
period: string;
qualifiedAgencyMin: NumericInput;
qualifiedHostPerAgencyMin: NumericInput;
};
coinSeller: {
levels: CoinSellerLevelForm[];
orderTimeoutSeconds: NumericInput;
sellerPointRewardPercent: NumericInput;
sellerPointSettlePercent: NumericInput;
successRateCloseThresholdPercent: NumericInput;
withdrawOrderEnabled: boolean;
};
gameInvite: {
enabled: boolean;
period: string;
};
googleCoinPerUsd: NumericInput;
host: {
affectsRoomHeat: boolean;
minimumWithdrawPoints: NumericInput;
pointRatioPercent: NumericInput;
withdrawFeeBps: NumericInput;
};
manager: {
coinSellerRechargeCommissionPercent: NumericInput;
coinSellerRechargeThresholdUsd: NumericInput;
hostPointCommissionPercent: NumericInput;
};
pointsPerUsd: NumericInput;
rawJson: string;
tasks: {
newHost7dMaxPoints: NumericInput;
rewardAssetType: (typeof rewardAssetValues)[number];
};
}
export interface PolicyTemplateForm {
description: string;
name: string;
rule: PolicyRuleForm;
status: (typeof statusValues)[number];
templateCode: string;
templateVersion: string;
}
export interface PolicyInstanceForm {
effectiveFrom: NumericInput | "";
effectiveTo: NumericInput | "";
instanceCode: string;
regionId: NumericInput | "";
regionScopeMode: (typeof regionScopeModes)[number];
status: (typeof statusValues)[number];
templateCode: string;
templateVersion: string;
}
const agentLevelSchema = z.object({
extra: z.record(z.string(), z.unknown()).optional(),
level: z.union([z.string(), z.number()]),
manualReview: z.boolean(),
maxUsd: z.union([z.string(), z.number()]),
minUsd: z.union([z.string(), z.number()]),
name: z.string(),
qualifiedHostMin: z.union([z.string(), z.number()]),
ratioPercent: z.union([z.string(), z.number()]),
});
const bdLevelSchema = z.object({
commissionPercent: z.union([z.string(), z.number()]),
extra: z.record(z.string(), z.unknown()).optional(),
level: z.union([z.string(), z.number()]),
name: z.string(),
salaryUsd: z.union([z.string(), z.number()]),
thresholdUsd: z.union([z.string(), z.number()]),
});
const coinSellerLevelSchema = z.object({
coinPerUsd: z.union([z.string(), z.number()]),
extra: z.record(z.string(), z.unknown()).optional(),
name: z.string(),
singleRechargeThresholdUsd: z.union([z.string(), z.number()]),
thresholdUsd: z.union([z.string(), z.number()]),
withdrawOrderEnabled: z.boolean(),
});
export const policyRuleFormSchema = z
.object({
agent: z.object({
differential: z.object({
crossLevelFormula: z.string().trim().min(1, "请输入 Agent 差值公式"),
enabled: z.boolean(),
maxDepth: z.union([z.string(), z.number()]),
}),
levels: z.array(agentLevelSchema).min(1, "请至少配置一个 Agent 等级"),
period: z.string().trim().min(1, "请输入 Agent 周期"),
qualifiedHostMin: z.union([z.string(), z.number()]),
supportDays: z.union([z.string(), z.number()]),
supportRatioPercent: z.union([z.string(), z.number()]),
}),
allowSelfBrushing: z.boolean(),
bd: z.object({
base: z.string().trim().min(1, "请输入 BD 结算基准"),
levels: z.array(bdLevelSchema).min(1, "请至少配置一个 BD 等级"),
period: z.string().trim().min(1, "请输入 BD 周期"),
qualifiedAgencyMin: z.union([z.string(), z.number()]),
qualifiedHostPerAgencyMin: z.union([z.string(), z.number()]),
}),
coinSeller: z.object({
levels: z.array(coinSellerLevelSchema).min(1, "请至少配置一个币商等级"),
orderTimeoutSeconds: z.union([z.string(), z.number()]),
sellerPointRewardPercent: z.union([z.string(), z.number()]),
sellerPointSettlePercent: z.union([z.string(), z.number()]),
successRateCloseThresholdPercent: z.union([z.string(), z.number()]),
withdrawOrderEnabled: z.boolean(),
}),
gameInvite: z.object({
enabled: z.boolean(),
period: z.string().trim().min(1, "请输入游戏邀请周期"),
}),
googleCoinPerUsd: z.union([z.string(), z.number()]),
host: z.object({
affectsRoomHeat: z.boolean(),
minimumWithdrawPoints: z.union([z.string(), z.number()]),
pointRatioPercent: z.union([z.string(), z.number()]),
withdrawFeeBps: z.union([z.string(), z.number()]),
}),
manager: z.object({
coinSellerRechargeCommissionPercent: z.union([z.string(), z.number()]),
coinSellerRechargeThresholdUsd: z.union([z.string(), z.number()]),
hostPointCommissionPercent: z.union([z.string(), z.number()]),
}),
pointsPerUsd: z.union([z.string(), z.number()]),
rawJson: z.string(),
tasks: z.object({
newHost7dMaxPoints: z.union([z.string(), z.number()]),
rewardAssetType: z.enum(rewardAssetValues),
}),
})
.superRefine((value, context) => {
try {
parseRuleObject(value.rawJson);
} catch (error) {
context.addIssue({ code: "custom", message: errorMessage(error), path: ["rawJson"] });
}
requirePositiveInteger(context, ["pointsPerUsd"], value.pointsPerUsd, "POINT/USD 必须大于 0");
requirePositiveInteger(context, ["googleCoinPerUsd"], value.googleCoinPerUsd, "Google 金币单价必须大于 0");
requirePercent(context, ["host", "pointRatioPercent"], value.host.pointRatioPercent, "主播收益比例必须在 0-100 之间", false);
requireNonNegativeInteger(context, ["host", "minimumWithdrawPoints"], value.host.minimumWithdrawPoints, "最低提现积分不能小于 0");
requireIntegerRange(context, ["host", "withdrawFeeBps"], value.host.withdrawFeeBps, 0, 10000, "提现手续费 bps 必须在 0-10000 之间");
requirePercent(context, ["agent", "supportRatioPercent"], value.agent.supportRatioPercent, "Agent 扶持比例必须在 0-100 之间", false);
requireNonNegativeInteger(context, ["agent", "supportDays"], value.agent.supportDays, "Agent 扶持天数不能小于 0");
requirePositiveInteger(context, ["agent", "qualifiedHostMin"], value.agent.qualifiedHostMin, "Agent 合格主播数必须大于 0");
requireNonNegativeInteger(context, ["agent", "differential", "maxDepth"], value.agent.differential.maxDepth, "Agent 差值深度不能小于 0");
validateAgentLevels(context, value.agent.levels);
requirePositiveInteger(context, ["bd", "qualifiedAgencyMin"], value.bd.qualifiedAgencyMin, "BD 合格 Agency 数必须大于 0");
requirePositiveInteger(context, ["bd", "qualifiedHostPerAgencyMin"], value.bd.qualifiedHostPerAgencyMin, "BD 每 Agency 合格主播数必须大于 0");
validateBDLevels(context, value.bd.levels);
requirePositiveNumber(context, ["manager", "coinSellerRechargeThresholdUsd"], value.manager.coinSellerRechargeThresholdUsd, "经理币商充值门槛必须大于 0");
requirePercent(context, ["manager", "coinSellerRechargeCommissionPercent"], value.manager.coinSellerRechargeCommissionPercent, "经理币商提成比例必须在 0-100 之间", true);
requirePercent(context, ["manager", "hostPointCommissionPercent"], value.manager.hostPointCommissionPercent, "经理主播提成比例必须在 0-100 之间", true);
requirePositiveInteger(context, ["coinSeller", "orderTimeoutSeconds"], value.coinSeller.orderTimeoutSeconds, "币商订单超时秒数必须大于 0");
requirePercent(context, ["coinSeller", "sellerPointSettlePercent"], value.coinSeller.sellerPointSettlePercent, "币商结算比例必须在 0-100 之间", true);
requirePercent(context, ["coinSeller", "sellerPointRewardPercent"], value.coinSeller.sellerPointRewardPercent, "币商奖励比例必须在 0-100 之间", true);
if (numberValue(value.coinSeller.sellerPointSettlePercent) + numberValue(value.coinSeller.sellerPointRewardPercent) > 100) {
context.addIssue({ code: "custom", message: "币商结算比例和奖励比例合计不能超过 100", path: ["coinSeller", "sellerPointRewardPercent"] });
}
requirePercent(context, ["coinSeller", "successRateCloseThresholdPercent"], value.coinSeller.successRateCloseThresholdPercent, "成功率关闭门槛必须在 0-100 之间", true);
validateCoinSellerLevels(context, value.coinSeller.levels);
requireNonNegativeInteger(context, ["tasks", "newHost7dMaxPoints"], value.tasks.newHost7dMaxPoints, "新主播 7 日任务积分不能小于 0");
});
export const policyTemplateFormSchema = z.object({
description: z.string().trim().max(512, "模板说明不能超过 512 个字符"),
name: z.string().trim().min(1, "请输入模板名称").max(160, "模板名称不能超过 160 个字符"),
rule: policyRuleFormSchema,
status: z.enum(statusValues),
templateCode: z.string().trim().min(1, "请输入模板编码").max(96, "模板编码不能超过 96 个字符"),
templateVersion: z.string().trim().min(1, "请输入模板版本").max(64, "模板版本不能超过 64 个字符"),
});
export const policyInstanceFormSchema = z
.object({
effectiveFrom: z.union([z.string(), z.number()]).optional(),
effectiveTo: z.union([z.string(), z.number()]).optional(),
instanceCode: z.string().trim().min(1, "请输入实例编码").max(96, "实例编码不能超过 96 个字符"),
regionId: z.union([z.string(), z.number()]).optional(),
regionScopeMode: z.enum(regionScopeModes),
status: z.enum(statusValues),
templateCode: z.string().trim().min(1, "请选择模板编码").max(96, "模板编码不能超过 96 个字符"),
templateVersion: z.string().trim().min(1, "请选择模板版本").max(64, "模板版本不能超过 64 个字符"),
})
.superRefine((value, context) => {
if (value.regionScopeMode === "region_id") {
requirePositiveInteger(context, ["regionId"], value.regionId || "", "请选择适用区域");
}
const fromMs = timeValueToMs(value.effectiveFrom);
const toMs = timeValueToMs(value.effectiveTo);
if (fromMs < 0 || toMs < 0 || (toMs > 0 && fromMs > 0 && toMs <= fromMs)) {
context.addIssue({ code: "custom", message: "生效时间范围不正确", path: ["effectiveTo"] });
}
});
export function policyTemplateFormFromItem(item?: Partial<PolicyTemplateForm> & { ruleJson?: unknown; rule_json?: unknown; template_code?: string; template_version?: string } | null): PolicyTemplateForm {
const rule = ruleFormFromJson(item?.ruleJson ?? item?.rule_json ?? defaultPolicyRule());
return {
description: stringValue(item?.description, "新文档口径允许自刷BD 按 UTC 当月自然月Huwaa 使用 POINT/COIN_SELLER_POINT。"),
name: stringValue(item?.name, "第一套政策Google 70000 + 币商 92000-100000"),
rule,
status: normalizeStatus(item?.status),
templateCode: stringValue(item?.templateCode ?? item?.template_code, DEFAULT_POLICY_TEMPLATE_CODE),
templateVersion: stringValue(item?.templateVersion ?? item?.template_version, DEFAULT_POLICY_TEMPLATE_VERSION),
};
}
export function policyTemplatePayloadFromForm(form: PolicyTemplateForm) {
const parsed = policyTemplateFormSchema.parse(form);
return {
description: parsed.description.trim(),
name: parsed.name.trim(),
rule_json: ruleObjectFromForm(parsed.rule),
status: parsed.status,
template_code: parsed.templateCode.trim(),
template_version: parsed.templateVersion.trim(),
};
}
export function policyInstanceFormFromTemplate(template?: { templateCode?: string; templateVersion?: string; template_code?: string; template_version?: string } | null): PolicyInstanceForm {
const templateCode = stringValue(template?.templateCode ?? template?.template_code, DEFAULT_POLICY_TEMPLATE_CODE);
const templateVersion = stringValue(template?.templateVersion ?? template?.template_version, DEFAULT_POLICY_TEMPLATE_VERSION);
return {
effectiveFrom: "",
effectiveTo: "",
instanceCode: templateCode === DEFAULT_POLICY_TEMPLATE_CODE ? "huwaa_first_google70000_coin_seller_92000_100000" : "",
regionId: "",
regionScopeMode: "all_active_regions",
status: "active",
templateCode,
templateVersion,
};
}
export function policyInstancePayloadFromForm(form: PolicyInstanceForm) {
const parsed = policyInstanceFormSchema.parse(form);
return {
effective_from_ms: timeValueToMs(parsed.effectiveFrom),
effective_to_ms: timeValueToMs(parsed.effectiveTo),
instance_code: parsed.instanceCode.trim(),
region_scope: parsed.regionScopeMode === "region_id" ? `region_id:${integerValue(parsed.regionId || 0)}` : "all_active_regions",
status: parsed.status,
template_code: parsed.templateCode.trim(),
template_version: parsed.templateVersion.trim(),
};
}
export function ruleFormFromJson(raw: unknown): PolicyRuleForm {
const rule = parseRuleObject(raw);
const host = objectValue(rule.host);
const agent = objectValue(rule.agent);
const differential = objectValue(agent.differential);
const bd = objectValue(rule.bd);
const manager = objectValue(rule.manager);
const coinSeller = objectValue(rule.coin_seller);
const tasks = objectValue(rule.tasks);
const gameInvite = objectValue(rule.game_invite);
return {
agent: {
differential: {
crossLevelFormula: stringValue(differential.cross_level_formula, "parent_minus_direct_child"),
enabled: booleanValue(differential.enabled, true),
maxDepth: inputValue(differential.max_depth, 2),
},
levels: arrayValue(agent.levels).map(agentLevelFromRule),
period: stringValue(agent.period, "rolling_30d"),
qualifiedHostMin: inputValue(agent.qualified_host_min, 3),
supportDays: inputValue(agent.support_days, 30),
supportRatioPercent: inputValue(agent.support_ratio_percent, 8),
},
allowSelfBrushing: booleanValue(rule.allow_self_brushing, true),
bd: {
base: stringValue(bd.base, "host_effective_income"),
levels: arrayValue(bd.levels).map(bdLevelFromRule),
period: stringValue(bd.period, "utc_calendar_month"),
qualifiedAgencyMin: inputValue(bd.qualified_agency_min, 3),
qualifiedHostPerAgencyMin: inputValue(bd.qualified_host_per_agency_min, 3),
},
coinSeller: {
levels: arrayValue(coinSeller.levels).map(coinSellerLevelFromRule),
orderTimeoutSeconds: inputValue(coinSeller.order_timeout_seconds, 7200),
sellerPointRewardPercent: inputValue(coinSeller.seller_point_reward_percent, 5),
sellerPointSettlePercent: inputValue(coinSeller.seller_point_settle_percent, 95),
successRateCloseThresholdPercent: inputValue(coinSeller.success_rate_close_threshold_percent, 95),
withdrawOrderEnabled: booleanValue(coinSeller.withdraw_order_enabled, true),
},
gameInvite: {
enabled: booleanValue(gameInvite.enabled, true),
period: stringValue(gameInvite.period, "rolling_30d"),
},
googleCoinPerUsd: inputValue(rule.google_coin_per_usd, 70000),
host: {
affectsRoomHeat: booleanValue(host.affects_room_heat, false),
minimumWithdrawPoints: inputValue(host.minimum_withdraw_points, 1000000),
pointRatioPercent: inputValue(host.point_ratio_percent, 70),
withdrawFeeBps: inputValue(host.withdraw_fee_bps, 500),
},
manager: {
coinSellerRechargeCommissionPercent: inputValue(manager.coin_seller_recharge_commission_percent, 3),
coinSellerRechargeThresholdUsd: inputValue(manager.coin_seller_recharge_threshold_usd, 2000),
hostPointCommissionPercent: inputValue(manager.host_point_commission_percent, 2),
},
pointsPerUsd: inputValue(rule.points_per_usd, 100000),
rawJson: JSON.stringify(rule, null, 2),
tasks: {
newHost7dMaxPoints: inputValue(tasks.new_host_7d_max_points, 350000),
rewardAssetType: normalizeRewardAsset(tasks.reward_asset_type),
},
};
}
export function ruleJsonFromForm(form: PolicyRuleForm): string {
return JSON.stringify(ruleObjectFromForm(form));
}
export function ruleObjectFromForm(form: PolicyRuleForm): RuleObject {
const parsed = policyRuleFormSchema.parse(form);
const base = parseRuleObject(parsed.rawJson);
const baseAgent = objectValue(base.agent);
const baseBD = objectValue(base.bd);
const baseCoinSeller = objectValue(base.coin_seller);
const baseAgentLevelExtras = extraByKey(arrayValue(baseAgent.levels), "level", ["level", "manual_review", "max_usd", "min_usd", "name", "qualified_host_min", "ratio_percent"]);
const baseBDLevelExtras = extraByKey(arrayValue(baseBD.levels), "level", ["commission_percent", "level", "name", "salary_usd", "threshold_usd"]);
const baseCoinSellerLevelExtras = extraByKey(arrayValue(baseCoinSeller.levels), "name", ["coin_per_usd", "name", "single_recharge_threshold_usd", "threshold_usd", "withdraw_order_enabled"]);
return {
...base,
allow_self_brushing: parsed.allowSelfBrushing,
google_coin_per_usd: integerValue(parsed.googleCoinPerUsd),
points_per_usd: integerValue(parsed.pointsPerUsd),
host: {
...objectValue(base.host),
affects_room_heat: parsed.host.affectsRoomHeat,
minimum_withdraw_points: integerValue(parsed.host.minimumWithdrawPoints),
point_ratio_percent: numberValue(parsed.host.pointRatioPercent),
withdraw_fee_bps: integerValue(parsed.host.withdrawFeeBps),
},
agent: {
...baseAgent,
differential: {
...objectValue(baseAgent.differential),
cross_level_formula: parsed.agent.differential.crossLevelFormula.trim(),
enabled: parsed.agent.differential.enabled,
max_depth: integerValue(parsed.agent.differential.maxDepth),
},
levels: parsed.agent.levels.map((level) => agentLevelToRule(level, baseAgentLevelExtras.get(String(integerValue(level.level))) || {})),
period: parsed.agent.period.trim(),
qualified_host_min: integerValue(parsed.agent.qualifiedHostMin),
support_days: integerValue(parsed.agent.supportDays),
support_ratio_percent: numberValue(parsed.agent.supportRatioPercent),
},
bd: {
...baseBD,
base: parsed.bd.base.trim(),
levels: parsed.bd.levels.map((level) => bdLevelToRule(level, baseBDLevelExtras.get(String(integerValue(level.level))) || {})),
period: parsed.bd.period.trim(),
qualified_agency_min: integerValue(parsed.bd.qualifiedAgencyMin),
qualified_host_per_agency_min: integerValue(parsed.bd.qualifiedHostPerAgencyMin),
},
manager: {
...objectValue(base.manager),
coin_seller_recharge_commission_percent: numberValue(parsed.manager.coinSellerRechargeCommissionPercent),
coin_seller_recharge_threshold_usd: numberValue(parsed.manager.coinSellerRechargeThresholdUsd),
host_point_commission_percent: numberValue(parsed.manager.hostPointCommissionPercent),
},
coin_seller: {
...baseCoinSeller,
levels: parsed.coinSeller.levels.map((level) => coinSellerLevelToRule(level, baseCoinSellerLevelExtras.get(String(level.name || "").trim()) || {})),
order_timeout_seconds: integerValue(parsed.coinSeller.orderTimeoutSeconds),
seller_point_reward_percent: numberValue(parsed.coinSeller.sellerPointRewardPercent),
seller_point_settle_percent: numberValue(parsed.coinSeller.sellerPointSettlePercent),
success_rate_close_threshold_percent: numberValue(parsed.coinSeller.successRateCloseThresholdPercent),
withdraw_order_enabled: parsed.coinSeller.withdrawOrderEnabled,
},
tasks: {
...objectValue(base.tasks),
new_host_7d_max_points: integerValue(parsed.tasks.newHost7dMaxPoints),
reward_asset_type: parsed.tasks.rewardAssetType,
},
game_invite: {
...objectValue(base.game_invite),
enabled: parsed.gameInvite.enabled,
period: parsed.gameInvite.period.trim(),
},
};
}
export function defaultPolicyRule(): RuleObject {
return {
points_per_usd: 100000,
allow_self_brushing: true,
google_coin_per_usd: 70000,
host: { point_ratio_percent: 70, affects_room_heat: false, minimum_withdraw_points: 1000000, withdraw_fee_bps: 500 },
agent: {
period: "rolling_30d",
support_ratio_percent: 8,
support_days: 30,
qualified_host_min: 3,
levels: [
{ level: 1, name: "Lv1", min_usd: 0, max_usd: 200, ratio_percent: 4, qualified_host_min: 3 },
{ level: 2, name: "Lv2", min_usd: 200, max_usd: 500, ratio_percent: 8, qualified_host_min: 5 },
{ level: 3, name: "Lv3", min_usd: 500, max_usd: 2000, ratio_percent: 16, qualified_host_min: 10 },
{ level: 4, name: "Lv4", min_usd: 2000, max_usd: 0, ratio_percent: 20, manual_review: true },
],
differential: { enabled: true, max_depth: 2, cross_level_formula: "parent_minus_direct_child" },
},
bd: {
period: "utc_calendar_month",
qualified_agency_min: 3,
qualified_host_per_agency_min: 3,
base: "host_effective_income",
levels: [
{ level: 1, name: "测试BD-1", threshold_usd: 100, salary_usd: 5, commission_percent: 1 },
{ level: 2, name: "测试BD-2", threshold_usd: 300, salary_usd: 15, commission_percent: 1 },
{ level: 3, name: "正式BD-1", threshold_usd: 700, salary_usd: 35, commission_percent: 1.5 },
{ level: 4, name: "正式BD-2", threshold_usd: 1000, salary_usd: 50, commission_percent: 1.5 },
{ level: 5, name: "高级BD-1", threshold_usd: 2000, salary_usd: 100, commission_percent: 2 },
{ level: 6, name: "高级BD-2", threshold_usd: 3000, salary_usd: 150, commission_percent: 2 },
{ level: 7, name: "普通BD-1", threshold_usd: 5000, salary_usd: 250, commission_percent: 2.5 },
{ level: 8, name: "普通BD-2", threshold_usd: 7000, salary_usd: 350, commission_percent: 2.5 },
{ level: 9, name: "普通Admin-1", threshold_usd: 10000, salary_usd: 500, commission_percent: 3 },
{ level: 10, name: "普通Admin-2", threshold_usd: 20000, salary_usd: 1000, commission_percent: 3 },
{ level: 11, name: "超级Admin-1", threshold_usd: 30000, salary_usd: 1500, commission_percent: 3 },
{ level: 12, name: "超级Admin-2", threshold_usd: 40000, salary_usd: 2000, commission_percent: 3 },
],
},
manager: { coin_seller_recharge_threshold_usd: 2000, coin_seller_recharge_commission_percent: 3, host_point_commission_percent: 2 },
coin_seller: {
withdraw_order_enabled: true,
levels: [
{ name: "Beginner", threshold_usd: 100, coin_per_usd: 92000, withdraw_order_enabled: false },
{ name: "Standard", threshold_usd: 500, coin_per_usd: 96000, withdraw_order_enabled: false },
{ name: "Senior", single_recharge_threshold_usd: 1000, coin_per_usd: 100000, withdraw_order_enabled: true },
],
order_timeout_seconds: 7200,
seller_point_settle_percent: 95,
seller_point_reward_percent: 5,
success_rate_close_threshold_percent: 95,
},
tasks: { reward_asset_type: "POINT", new_host_7d_max_points: 350000 },
game_invite: { enabled: true, period: "rolling_30d" },
};
}
function agentLevelFromRule(raw: unknown): AgentLevelForm {
const level = objectValue(raw);
return {
extra: omitKeys(level, ["level", "manual_review", "max_usd", "min_usd", "name", "qualified_host_min", "ratio_percent"]),
level: inputValue(level.level, ""),
manualReview: booleanValue(level.manual_review, false),
maxUsd: inputValue(level.max_usd, 0),
minUsd: inputValue(level.min_usd, 0),
name: stringValue(level.name, ""),
qualifiedHostMin: inputValue(level.qualified_host_min, 0),
ratioPercent: inputValue(level.ratio_percent, 0),
};
}
function bdLevelFromRule(raw: unknown): BDLevelForm {
const level = objectValue(raw);
return {
commissionPercent: inputValue(level.commission_percent, 0),
extra: omitKeys(level, ["commission_percent", "level", "name", "salary_usd", "threshold_usd"]),
level: inputValue(level.level, ""),
name: stringValue(level.name, ""),
salaryUsd: inputValue(level.salary_usd, 0),
thresholdUsd: inputValue(level.threshold_usd, 0),
};
}
function coinSellerLevelFromRule(raw: unknown): CoinSellerLevelForm {
const level = objectValue(raw);
return {
coinPerUsd: inputValue(level.coin_per_usd, 0),
extra: omitKeys(level, ["coin_per_usd", "name", "single_recharge_threshold_usd", "threshold_usd", "withdraw_order_enabled"]),
name: stringValue(level.name, ""),
singleRechargeThresholdUsd: inputValue(level.single_recharge_threshold_usd, ""),
thresholdUsd: inputValue(level.threshold_usd, ""),
withdrawOrderEnabled: booleanValue(level.withdraw_order_enabled, false),
};
}
function agentLevelToRule(level: AgentLevelForm, rawExtra: RuleObject): RuleObject {
return {
...level.extra,
...rawExtra,
level: integerValue(level.level),
name: String(level.name || "").trim(),
min_usd: numberValue(level.minUsd),
max_usd: numberValue(level.maxUsd),
ratio_percent: numberValue(level.ratioPercent),
qualified_host_min: integerValue(level.qualifiedHostMin),
...(level.manualReview ? { manual_review: true } : {}),
};
}
function bdLevelToRule(level: BDLevelForm, rawExtra: RuleObject): RuleObject {
return {
...level.extra,
...rawExtra,
level: integerValue(level.level),
name: String(level.name || "").trim(),
threshold_usd: numberValue(level.thresholdUsd),
salary_usd: numberValue(level.salaryUsd),
commission_percent: numberValue(level.commissionPercent),
};
}
function coinSellerLevelToRule(level: CoinSellerLevelForm, rawExtra: RuleObject): RuleObject {
const result: RuleObject = {
...level.extra,
...rawExtra,
name: String(level.name || "").trim(),
coin_per_usd: integerValue(level.coinPerUsd),
withdraw_order_enabled: level.withdrawOrderEnabled,
};
if (String(level.singleRechargeThresholdUsd || "").trim()) {
result.single_recharge_threshold_usd = numberValue(level.singleRechargeThresholdUsd);
}
if (String(level.thresholdUsd || "").trim()) {
result.threshold_usd = numberValue(level.thresholdUsd);
}
return result;
}
function validateAgentLevels(context: z.RefinementCtx, levels: AgentLevelForm[]) {
const seen = new Set<number>();
levels.forEach((level, index) => {
const levelNo = integerValue(level.level);
if (!Number.isInteger(levelNo) || levelNo <= 0 || seen.has(levelNo)) {
context.addIssue({ code: "custom", message: "Agent 等级必须为不重复的正整数", path: ["agent", "levels", index, "level"] });
}
seen.add(levelNo);
requireNonNegativeNumber(context, ["agent", "levels", index, "minUsd"], level.minUsd, "Agent 最小 USD 不能小于 0");
requireNonNegativeNumber(context, ["agent", "levels", index, "maxUsd"], level.maxUsd, "Agent 最大 USD 不能小于 0");
if (numberValue(level.maxUsd) > 0 && numberValue(level.maxUsd) <= numberValue(level.minUsd)) {
context.addIssue({ code: "custom", message: "Agent 最大 USD 必须大于最小 USD0 表示无上限", path: ["agent", "levels", index, "maxUsd"] });
}
requirePercent(context, ["agent", "levels", index, "ratioPercent"], level.ratioPercent, "Agent 提成比例必须在 0-100 之间", false);
requireNonNegativeInteger(context, ["agent", "levels", index, "qualifiedHostMin"], level.qualifiedHostMin, "Agent 合格主播数不能小于 0");
});
}
function validateBDLevels(context: z.RefinementCtx, levels: BDLevelForm[]) {
const seen = new Set<number>();
let previousThreshold = 0;
levels
.map((level, index) => ({ index, level, levelNo: integerValue(level.level), threshold: numberValue(level.thresholdUsd) }))
.sort((left, right) => left.levelNo - right.levelNo)
.forEach(({ index, level, levelNo, threshold }) => {
if (!Number.isInteger(levelNo) || levelNo <= 0 || seen.has(levelNo)) {
context.addIssue({ code: "custom", message: "BD 等级必须为不重复的正整数", path: ["bd", "levels", index, "level"] });
}
seen.add(levelNo);
if (threshold <= 0 || threshold <= previousThreshold) {
context.addIssue({ code: "custom", message: "BD 收入门槛必须逐级递增", path: ["bd", "levels", index, "thresholdUsd"] });
}
requireNonNegativeNumber(context, ["bd", "levels", index, "salaryUsd"], level.salaryUsd, "BD 固定工资不能小于 0");
requirePercent(context, ["bd", "levels", index, "commissionPercent"], level.commissionPercent, "BD 提成比例必须在 0-100 之间", false);
previousThreshold = threshold;
});
}
function validateCoinSellerLevels(context: z.RefinementCtx, levels: CoinSellerLevelForm[]) {
levels.forEach((level, index) => {
if (!String(level.name || "").trim()) {
context.addIssue({ code: "custom", message: "请输入币商等级名称", path: ["coinSeller", "levels", index, "name"] });
}
requirePositiveInteger(context, ["coinSeller", "levels", index, "coinPerUsd"], level.coinPerUsd, "币商金币单价必须大于 0");
if (!String(level.thresholdUsd || "").trim() && !String(level.singleRechargeThresholdUsd || "").trim()) {
context.addIssue({ code: "custom", message: "币商等级必须配置累计门槛或单次充值门槛", path: ["coinSeller", "levels", index, "thresholdUsd"] });
}
if (String(level.thresholdUsd || "").trim()) {
requirePositiveNumber(context, ["coinSeller", "levels", index, "thresholdUsd"], level.thresholdUsd, "币商累计门槛必须大于 0");
}
if (String(level.singleRechargeThresholdUsd || "").trim()) {
requirePositiveNumber(context, ["coinSeller", "levels", index, "singleRechargeThresholdUsd"], level.singleRechargeThresholdUsd, "币商单次充值门槛必须大于 0");
}
});
}
function parseRuleObject(raw: unknown): RuleObject {
if (typeof raw === "string") {
try {
return parseRuleObject(JSON.parse(raw || "{}"));
} catch (error) {
throw new Error(`JSON 格式错误:${errorMessage(error)}`);
}
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error("规则 JSON 必须是对象");
}
return raw as RuleObject;
}
function objectValue(value: unknown): RuleObject {
return value && typeof value === "object" && !Array.isArray(value) ? (value as RuleObject) : {};
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function inputValue(value: unknown, fallback: NumericInput): NumericInput {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
if (typeof value === "string" && value.trim()) {
return value.trim();
}
return String(fallback);
}
function stringValue(value: unknown, fallback = ""): string {
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return fallback;
}
return String(value);
}
function booleanValue(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function normalizeStatus(value: unknown): (typeof statusValues)[number] {
return String(value || "active").toLowerCase() === "disabled" ? "disabled" : "active";
}
function normalizeRewardAsset(value: unknown): (typeof rewardAssetValues)[number] {
return String(value || "COIN").toUpperCase() === "POINT" ? "POINT" : "COIN";
}
function timeValueToMs(value: unknown): number {
if (value === "" || value === null || value === undefined) {
return 0;
}
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : -1;
}
function numberValue(value: unknown): number {
const raw = String(value ?? "").trim();
if (!/^\d+(\.\d+)?$/.test(raw)) {
return Number.NaN;
}
return Number(raw);
}
function integerValue(value: unknown): number {
const number = numberValue(value);
return Number.isFinite(number) ? Math.trunc(number) : Number.NaN;
}
function requirePositiveInteger(context: z.RefinementCtx, path: (number | string)[], value: unknown, message: string) {
const number = integerValue(value);
if (!Number.isInteger(number) || number <= 0) {
context.addIssue({ code: "custom", message, path });
}
}
function requireNonNegativeInteger(context: z.RefinementCtx, path: (number | string)[], value: unknown, message: string) {
const number = integerValue(value);
if (!Number.isInteger(number) || number < 0) {
context.addIssue({ code: "custom", message, path });
}
}
function requirePositiveNumber(context: z.RefinementCtx, path: (number | string)[], value: unknown, message: string) {
const number = numberValue(value);
if (!Number.isFinite(number) || number <= 0) {
context.addIssue({ code: "custom", message, path });
}
}
function requireNonNegativeNumber(context: z.RefinementCtx, path: (number | string)[], value: unknown, message: string) {
const number = numberValue(value);
if (!Number.isFinite(number) || number < 0) {
context.addIssue({ code: "custom", message, path });
}
}
function requirePercent(context: z.RefinementCtx, path: (number | string)[], value: unknown, message: string, allowZero: boolean) {
const number = numberValue(value);
if (!Number.isFinite(number) || number > 100 || (allowZero ? number < 0 : number <= 0)) {
context.addIssue({ code: "custom", message, path });
}
}
function requireIntegerRange(context: z.RefinementCtx, path: (number | string)[], value: unknown, min: number, max: number, message: string) {
const number = integerValue(value);
if (!Number.isInteger(number) || number < min || number > max) {
context.addIssue({ code: "custom", message, path });
}
}
function extraByKey(items: unknown[], key: string, knownKeys: string[]): Map<string, RuleObject> {
const result = new Map<string, RuleObject>();
items.forEach((item) => {
const record = objectValue(item);
const rawKey = record[key];
if (rawKey !== undefined && rawKey !== null && String(rawKey).trim()) {
result.set(String(rawKey).trim(), omitKeys(record, knownKeys));
}
});
return result;
}
function omitKeys(source: RuleObject, keys: string[]): RuleObject {
const blocked = new Set(keys);
return Object.fromEntries(Object.entries(source).filter(([key]) => !blocked.has(key)));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error || "未知错误");
}

View File

@ -54,6 +54,7 @@ export const API_OPERATIONS = {
createMenu: "createMenu",
createPermission: "createPermission",
createPlatform: "createPlatform",
createPolicyInstance: "createPolicyInstance",
createPopup: "createPopup",
createPrettyIdPool: "createPrettyIdPool",
createRechargeProduct: "createRechargeProduct",
@ -201,6 +202,8 @@ export const API_OPERATIONS = {
listPlatformGrantRecords: "listPlatformGrantRecords",
listPlatformGrantUsers: "listPlatformGrantUsers",
listPlatforms: "listPlatforms",
listPolicyInstances: "listPolicyInstances",
listPolicyTemplates: "listPolicyTemplates",
listPopups: "listPopups",
listPrettyIdPools: "listPrettyIdPools",
listPrettyIds: "listPrettyIds",
@ -248,6 +251,7 @@ export const API_OPERATIONS = {
me: "me",
navigationMenus: "navigationMenus",
publishHostAgencyPolicy: "publishHostAgencyPolicy",
publishPolicyInstance: "publishPolicyInstance",
recyclePrettyId: "recyclePrettyId",
refresh: "refresh",
refreshGoogleRechargePaid: "refreshGoogleRechargePaid",
@ -264,6 +268,7 @@ export const API_OPERATIONS = {
retryRoomRpsSettlement: "retryRoomRpsSettlement",
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
revokeResourceGrant: "revokeResourceGrant",
savePolicyTemplate: "savePolicyTemplate",
search: "search",
selfGameStatisticsOverview: "selfGameStatisticsOverview",
setAgencyJoinEnabled: "setAgencyJoinEnabled",
@ -308,6 +313,7 @@ export const API_OPERATIONS = {
updateMenuVisible: "updateMenuVisible",
updatePermission: "updatePermission",
updatePlatform: "updatePlatform",
updatePolicyInstanceStatus: "updatePolicyInstanceStatus",
updatePopup: "updatePopup",
updatePrettyIdPool: "updatePrettyIdPool",
updateRechargeProduct: "updateRechargeProduct",
@ -635,6 +641,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:update",
permissions: ["game:update"]
},
createPolicyInstance: {
method: "POST",
operationId: API_OPERATIONS.createPolicyInstance,
path: "/v1/admin/policy-instances",
permission: "policy-instance:create",
permissions: ["policy-instance:create"]
},
createPopup: {
method: "POST",
operationId: API_OPERATIONS.createPopup,
@ -1662,6 +1675,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:view",
permissions: ["game:view"]
},
listPolicyInstances: {
method: "GET",
operationId: API_OPERATIONS.listPolicyInstances,
path: "/v1/admin/policy-instances",
permission: "policy-template:view",
permissions: ["policy-template:view"]
},
listPolicyTemplates: {
method: "GET",
operationId: API_OPERATIONS.listPolicyTemplates,
path: "/v1/admin/policy-templates",
permission: "policy-template:view",
permissions: ["policy-template:view"]
},
listPopups: {
method: "GET",
operationId: API_OPERATIONS.listPopups,
@ -1983,6 +2010,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "host-agency-policy:publish",
permissions: ["host-agency-policy:publish"]
},
publishPolicyInstance: {
method: "POST",
operationId: API_OPERATIONS.publishPolicyInstance,
path: "/v1/admin/policy-instances/{instance_id}/publish",
permission: "policy-instance:publish",
permissions: ["policy-instance:publish"]
},
recyclePrettyId: {
method: "POST",
operationId: API_OPERATIONS.recyclePrettyId,
@ -2093,6 +2127,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "resource-grant:revoke",
permissions: ["resource-grant:revoke"]
},
savePolicyTemplate: {
method: "POST",
operationId: API_OPERATIONS.savePolicyTemplate,
path: "/v1/admin/policy-templates",
permission: "policy-template:create",
permissions: ["policy-template:create"]
},
search: {
method: "GET",
operationId: API_OPERATIONS.search,
@ -2399,6 +2440,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:update",
permissions: ["game:update"]
},
updatePolicyInstanceStatus: {
method: "PUT",
operationId: API_OPERATIONS.updatePolicyInstanceStatus,
path: "/v1/admin/policy-instances/{instance_id}/status",
permission: "policy-instance:update",
permissions: ["policy-instance:update"]
},
updatePopup: {
method: "PUT",
operationId: API_OPERATIONS.updatePopup,

View File

@ -1892,6 +1892,70 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/policy-templates": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listPolicyTemplates"];
put?: never;
post: operations["savePolicyTemplate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/policy-instances": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listPolicyInstances"];
put?: never;
post: operations["createPolicyInstance"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/policy-instances/{instance_id}/publish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["publishPolicyInstance"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/policy-instances/{instance_id}/status": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updatePolicyInstanceStatus"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/payment/recharge-bills": {
parameters: {
query?: never;
@ -3978,6 +4042,87 @@ export interface components {
pageSize: number;
total: number;
};
PolicyTemplate: {
/** Format: int64 */
template_id?: number;
/** Format: int64 */
version_id?: number;
template_code?: string;
template_version?: string;
name?: string;
/** @enum {string} */
status?: "active" | "disabled";
rule_json?: {
[key: string]: unknown;
};
description?: string;
/** Format: int64 */
created_at_ms?: number;
/** Format: int64 */
updated_at_ms?: number;
};
PolicyTemplateInput: {
template_code: string;
template_version: string;
name: string;
/** @enum {string} */
status?: "active" | "disabled";
rule_json: {
[key: string]: unknown;
};
description?: string;
};
PolicyInstance: {
/** Format: int64 */
instance_id?: number;
app_code?: string;
instance_code?: string;
template_code?: string;
template_version?: string;
region_scope?: string;
/** @enum {string} */
status?: "active" | "disabled";
/** Format: int64 */
effective_from_ms?: number;
/** Format: int64 */
effective_to_ms?: number;
/** @enum {string} */
publish_status?: "draft" | "published" | "failed";
publish_error?: string;
/** Format: int64 */
last_published_at_ms?: number;
/** Format: int64 */
created_at_ms?: number;
/** Format: int64 */
updated_at_ms?: number;
};
PolicyInstanceInput: {
instance_code: string;
template_code: string;
template_version: string;
region_scope?: string;
/** @enum {string} */
status?: "active" | "disabled";
/** Format: int64 */
effective_from_ms?: number;
/** Format: int64 */
effective_to_ms?: number;
};
PolicyPublishResult: {
/** Format: int64 */
job_id?: number;
/** Format: int64 */
instance_id?: number;
app_code?: string;
instance_code?: string;
template_code?: string;
template_version?: string;
status?: string;
target_count?: number;
published_region_ids?: number[];
/** Format: int64 */
published_at_ms?: number;
};
ApiResponseCreateUserResult: components["schemas"]["Envelope"] & {
data?: components["schemas"]["CreateUserResult"];
};
@ -7221,6 +7366,181 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listPolicyTemplates: {
parameters: {
query?: {
keyword?: string;
status?: "active" | "disabled";
page?: number;
page_size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description 收益政策模板分页 */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Envelope"] & {
data?: {
items?: components["schemas"]["PolicyTemplate"][];
page?: number;
pageSize?: number;
page_size?: number;
/** Format: int64 */
total?: number;
};
};
};
};
};
};
savePolicyTemplate: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PolicyTemplateInput"];
};
};
responses: {
/** @description 已保存的收益政策模板 */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Envelope"] & {
data?: components["schemas"]["PolicyTemplate"];
};
};
};
};
};
listPolicyInstances: {
parameters: {
query?: {
keyword?: string;
status?: "active" | "disabled";
page?: number;
page_size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description 收益政策实例分页 */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Envelope"] & {
data?: {
items?: components["schemas"]["PolicyInstance"][];
page?: number;
pageSize?: number;
page_size?: number;
/** Format: int64 */
total?: number;
};
};
};
};
};
};
createPolicyInstance: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PolicyInstanceInput"];
};
};
responses: {
/** @description 已创建的收益政策实例 */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Envelope"] & {
data?: components["schemas"]["PolicyInstance"];
};
};
};
};
};
publishPolicyInstance: {
parameters: {
query?: never;
header?: never;
path: {
instance_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description 收益政策发布结果 */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Envelope"] & {
data?: components["schemas"]["PolicyPublishResult"];
};
};
};
};
};
updatePolicyInstanceStatus: {
parameters: {
query?: never;
header?: never;
path: {
instance_id: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @enum {string} */
status: "active" | "disabled";
};
};
};
responses: {
/** @description 已更新状态的收益政策实例 */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Envelope"] & {
data?: components["schemas"]["PolicyInstance"];
};
};
};
};
};
listRechargeBills: {
parameters: {
query?: {

View File

@ -26,6 +26,13 @@ function redirectSubsystemEntry(req, res, next) {
return;
}
if (pathname === "/ops-center") {
res.statusCode = 301;
res.setHeader("Location", `/ops-center/${query ? `?${query}` : ""}`);
res.end();
return;
}
next();
}
@ -61,7 +68,8 @@ export default defineConfig({
databi: new URL("./databi/index.html", import.meta.url).pathname,
databiSocial: new URL("./databi/social/index.html", import.meta.url).pathname,
finance: new URL("./finance/index.html", import.meta.url).pathname,
moneyRedirect: new URL("./money/index.html", import.meta.url).pathname
moneyRedirect: new URL("./money/index.html", import.meta.url).pathname,
opsCenter: new URL("./ops-center/index.html", import.meta.url).pathname
}
}
},