diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index cb603f1..dc12785 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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": [ { diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 2801a31..dba22a3 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -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; } diff --git a/finance/src/FinanceApp.jsx b/finance/src/FinanceApp.jsx index 8ed3f5e..da92019 100644 --- a/finance/src/FinanceApp.jsx +++ b/finance/src/FinanceApp.jsx @@ -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 ; + return ( + + ); } if (!session?.canAccessFinance) { diff --git a/finance/src/FinanceApp.test.jsx b/finance/src/FinanceApp.test.jsx index 58dd2c1..64edab5 100644 --- a/finance/src/FinanceApp.test.jsx +++ b/finance/src/FinanceApp.test.jsx @@ -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( + + + + ); + + 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( + + + + ); + + 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 () => { diff --git a/ops-center/index.html b/ops-center/index.html new file mode 100644 index 0000000..46719c4 --- /dev/null +++ b/ops-center/index.html @@ -0,0 +1,12 @@ + + + + + + HYApp 运营中心 + + +
+ + + diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx new file mode 100644 index 0000000..e487084 --- /dev/null +++ b/ops-center/src/OpsCenterApp.jsx @@ -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 ( + +
+
+
+

运营配置中心

+
+ {state.loading ? "同步中" : "已就绪"} +
+ + + {state.error ?
{state.error}
: null} + + {activeView === "summary" ? ( + + ) : ( + + + + )} + {editingConfig ? ( + setEditingConfig(null)} + onSubmit={handleSaveLuckyGiftConfig} + saving={savingConfig} + /> + ) : null} +
+
+ ); +} + +function SummarySection({ data, sectionConfig }) { + const summaryViews = views.filter((view) => view.key !== "summary"); + const poolBalanceColumns = [ + { key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode }, + { key: "pool_id", label: "Pool ID", render: (item) => item.pool_id ?? item.poolId }, + { key: "balance", label: "当前金额", render: (item) => formatNumber(item.balance ?? item.Balance) }, + { key: "available_balance", label: "可用金额", render: (item) => formatNumber(item.available_balance ?? item.availableBalance) }, + { key: "reserve_floor", label: "保底线", render: (item) => formatNumber(item.reserve_floor ?? item.reserveFloor) }, + { key: "materialized", label: "状态", render: renderPoolMaterialized }, + { key: "updated_at", label: "更新时间", render: (item) => renderTime(item, "updated_at") } + ]; + + return ( + +
+ {summaryViews.map((view) => { + const config = sectionConfig[view.key]; + return ( +
+ {view.label} + {data[config.dataKey]?.length || 0} + {config.emptyText} +
+ ); + })} +
+
+
+

当前奖池余额

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

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

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

阶段奖档

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

{title}

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

{stageLabel}

+
+ +
+
+ + {stage.tiers.map((tier, index) => ( +
+ onUpdateTier(stage.stage, index, { multiplier: value, highWaterOnly: Number(value) >= 10 || tier.highWaterOnly })} + step="0.01" + /> + + + + +
+ ))} +
+
+ ); +} + +function NumberField({ className = "", form, label, name, onChange, step = "1" }) { + return ( + + ); +} + +function configToForm(config = {}) { + const targetRTPPercent = formNumber(config.target_rtp_percent ?? config.targetRtpPercent); + return { + app_code: config.app_code || config.appCode || "", + pool_id: config.pool_id || config.poolId || "default", + enabled: Boolean(config.enabled), + target_rtp_percent: targetRTPPercent, + pool_rate_percent: formNumber(config.pool_rate_percent ?? config.poolRatePercent), + settlement_window_wager: formNumber(config.settlement_window_wager ?? config.settlementWindowWager), + control_band_percent: formNumber(config.control_band_percent ?? config.controlBandPercent), + gift_price_reference: formNumber(config.gift_price_reference ?? config.giftPriceReference), + stages: normalizeFormStages(config.stages, targetRTPPercent) + }; +} + +function formToConfig(config, form) { + return { + ...config, + app_code: form.app_code, + pool_id: form.pool_id, + enabled: Boolean(form.enabled), + target_rtp_percent: numberFromForm(form.target_rtp_percent), + pool_rate_percent: numberFromForm(form.pool_rate_percent), + settlement_window_wager: numberFromForm(form.settlement_window_wager), + control_band_percent: numberFromForm(form.control_band_percent), + gift_price_reference: numberFromForm(form.gift_price_reference), + stages: form.stages + }; +} + +function cloneLuckyGiftConfig(config) { + return JSON.parse(JSON.stringify(config)); +} + +function renderLuckyGiftStatus(item) { + if (item.is_default || Number(item.rule_version ?? item.ruleVersion) <= 0) { + return "默认草稿"; + } + return item.enabled ? "启用" : "停用"; +} + +function renderPoolMaterialized(item) { + return item.materialized === false ? "默认水位" : "已入账"; +} + +function formNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? String(parsed) : "0"; +} + +function numberFromForm(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeFormStages(stages, targetRTPPercent = 95) { + const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage])); + return correctAllStageProbabilities({ + target_rtp_percent: targetRTPPercent, + stages: stageOptions.map(([stageKey]) => { + const source = byStage.get(stageKey); + const sourceTiers = Array.isArray(source?.tiers) && source.tiers.length ? source.tiers : defaultStageTiers(stageKey); + return { + stage: stageKey, + tiers: sourceTiers.map((tier) => ({ + enabled: tier.enabled !== false, + highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly), + multiplier: formNumber(tier.multiplier), + probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent), + tierId: tier.tier_id || tier.tierId || "" + })) + }; + }) + }).stages; +} + +function correctAllStageProbabilities(form) { + return { + ...form, + stages: (form.stages || []).map((stage) => { + const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, { + multiplier: (item) => item.multiplier + }); + return { + ...stage, + tiers: probabilityByIndex ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) : stage.tiers + }; + }) + }; +} + +function defaultStageTiers(stage) { + if (stage === "advanced") { + return [ + defaultTier("advanced_none", 0, 27), + defaultTier("advanced_1x", 1, 60), + defaultTier("advanced_2x", 2, 10), + defaultTier("advanced_5x", 5, 3, { high_water_only: true }) + ]; + } + return [ + defaultTier(`${stage}_none`, 0, 10), + defaultTier(`${stage}_0_5x`, 0.5, 20), + defaultTier(`${stage}_1x`, 1, 55), + defaultTier(`${stage}_2x`, 2, 15) + ]; +} + +function defaultTier(tierID, multiplier, probabilityPercent, options = {}) { + return { + enabled: true, + highWaterOnly: Boolean(options.high_water_only), + multiplier, + probabilityPercent, + tierId: tierID + }; +} + +function nextStageMultiplier(tiers = []) { + const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0); + if (!positive.length) { + return 1; + } + const max = Math.max(...positive); + if (max >= 5) { + return max * 2; + } + if (max >= 2) { + return 5; + } + return max + 1; +} + +function stageSummary(stage, form) { + const probability = probabilityTotal(stage.tiers); + const expected = expectedRTPPercent(stage.tiers, { + multiplier: (item) => item.multiplier + }); + const target = Number(form.target_rtp_percent || 0); + const band = Number(form.control_band_percent || 0); + const probabilityClosed = Math.abs(probability - 100) < 0.0001; + const rtpInBand = expected >= target - band && expected <= target + band; + const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage; + const tabLabel = stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, ""); + + return { + expected, + ok: probabilityClosed && rtpInBand, + probability, + probabilityClosed, + rtpInBand, + stageKey: stage.stage, + status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合", + tabLabel + }; +} + +function formatPercent(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return "-"; + } + return `${parsed.toFixed(2)}%`; +} + +function formatNumber(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return "-"; + } + return new Intl.NumberFormat("zh-CN").format(parsed); +} + +function renderTime(item, key) { + const value = item[key] ?? item[`${key}_ms`] ?? item[key.replace(/_at$/, "AtMs")]; + const timestamp = Number(value); + if (!Number.isFinite(timestamp) || timestamp <= 0) { + return value || "-"; + } + return new Date(timestamp < 10_000_000_000 ? timestamp * 1000 : timestamp).toLocaleString("zh-CN", { hour12: false }); +} diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx new file mode 100644 index 0000000..eca8d0c --- /dev/null +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -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(); + + 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(); + + 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(); + + await screen.findByText("Lalu"); + fireEvent.click(screen.getByRole("button", { name: /数据汇总/ })); + + const poolBalanceSection = screen.getByRole("region", { name: "当前奖池余额" }); + expect(within(poolBalanceSection).getByText("default")).toBeTruthy(); + expect(within(poolBalanceSection).getByText("382,500")).toBeTruthy(); + expect(within(poolBalanceSection).getByText("默认水位")).toBeTruthy(); +}); + +test("opens lucky gift config dialog and saves a default config", async () => { + render(); + + await screen.findByText("Lalu"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + const luckyGiftSection = screen.getByRole("region", { name: "幸运礼物配置" }); + + 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 + }) + ); + }); +}); diff --git a/ops-center/src/api.js b/ops-center/src/api.js new file mode 100644 index 0000000..e4e10fd --- /dev/null +++ b/ops-center/src/api.js @@ -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 + })) + : [] + })); +} diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js new file mode 100644 index 0000000..baaefb5 --- /dev/null +++ b/ops-center/src/api.test.js @@ -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 + }); +}); diff --git a/ops-center/src/components/OpsControls.jsx b/ops-center/src/components/OpsControls.jsx new file mode 100644 index 0000000..8403ab0 --- /dev/null +++ b/ops-center/src/components/OpsControls.jsx @@ -0,0 +1,30 @@ +export function OpsControls({ filters, onFiltersChange, onRefresh, refreshing }) { + const updateFilter = (key, value) => { + onFiltersChange({ ...filters, [key]: value }); + }; + + return ( +
+ + + +
+ ); +} diff --git a/ops-center/src/components/OpsOverview.jsx b/ops-center/src/components/OpsOverview.jsx new file mode 100644 index 0000000..8c11bec --- /dev/null +++ b/ops-center/src/components/OpsOverview.jsx @@ -0,0 +1,23 @@ +const summaryCards = [ + { key: "activeApps", label: "运营应用" }, + { key: "luckyGiftCount", label: "幸运礼物配置" }, + { key: "lotteryCount", label: "抽奖次数" }, + { key: "poolBalanceTotal", label: "当前奖池金币" } +]; + +export function OpsOverview({ summary }) { + return ( +
+ {summaryCards.map((card) => ( +
+ {card.label} + {formatNumber(summary?.[card.key])} +
+ ))} +
+ ); +} + +function formatNumber(value) { + return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); +} diff --git a/ops-center/src/components/OpsSection.jsx b/ops-center/src/components/OpsSection.jsx new file mode 100644 index 0000000..06abeb5 --- /dev/null +++ b/ops-center/src/components/OpsSection.jsx @@ -0,0 +1,14 @@ +export function OpsSection({ actions, children, description, title }) { + return ( +
+
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ {children} +
+ ); +} diff --git a/ops-center/src/components/OpsShell.jsx b/ops-center/src/components/OpsShell.jsx new file mode 100644 index 0000000..24724b9 --- /dev/null +++ b/ops-center/src/components/OpsShell.jsx @@ -0,0 +1,30 @@ +export function OpsShell({ activeView, children, onViewChange, views }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/ops-center/src/components/OpsTable.jsx b/ops-center/src/components/OpsTable.jsx new file mode 100644 index 0000000..076c8d6 --- /dev/null +++ b/ops-center/src/components/OpsTable.jsx @@ -0,0 +1,59 @@ +export function OpsTable({ columns, emptyText = "暂无数据", items }) { + return ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {items.length ? ( + items.map((item, index) => ( + + {columns.map((column) => ( + + ))} + + )) + ) : ( + + + + )} + +
{column.label}
{renderCell(item, column)}
+ {emptyText} +
+
+ ); +} + +function rowKey(item, index) { + if (item.draw_id || item.drawId) { + return item.draw_id ?? item.drawId; + } + if ((item.app_code || item.appCode) && (item.pool_id || item.poolId)) { + return `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`; + } + return item.id ?? item.code ?? item.app_code ?? item.appCode ?? `${item.name || "row"}-${index}`; +} + +function renderCell(item, column) { + if (column.render) { + return column.render(item, column.key); + } + return textValue(item[column.key]); +} + +function textValue(value) { + if (value === undefined || value === null || value === "") { + return "-"; + } + if (typeof value === "boolean") { + return value ? "是" : "否"; + } + return String(value); +} diff --git a/ops-center/src/main.jsx b/ops-center/src/main.jsx new file mode 100644 index 0000000..61c14b8 --- /dev/null +++ b/ops-center/src/main.jsx @@ -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( + + + + + + + + +); diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css new file mode 100644 index 0000000..a922d85 --- /dev/null +++ b/ops-center/src/styles/index.css @@ -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; + } +} diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 10a8740..aa91db2 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -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 }), ], }, { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 36f670a..98a4492 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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", diff --git a/src/app/router/guards.jsx b/src/app/router/guards.jsx index c1ffb88..eced292 100644 --- a/src/app/router/guards.jsx +++ b/src/app/router/guards.jsx @@ -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 ; + return ; } return ; diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index f06c3dc..8ec6582 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -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, diff --git a/src/features/auth/loginRedirect.js b/src/features/auth/loginRedirect.js new file mode 100644 index 0000000..1c8ef45 --- /dev/null +++ b/src/features/auth/loginRedirect.js @@ -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"; +} diff --git a/src/features/auth/loginRedirect.test.js b/src/features/auth/loginRedirect.test.js new file mode 100644 index 0000000..f02a0ea --- /dev/null +++ b/src/features/auth/loginRedirect.test.js @@ -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(""); +}); diff --git a/src/features/auth/pages/LoginPage.jsx b/src/features/auth/pages/LoginPage.jsx index ea81fdd..5c90c2a 100644 --- a/src/features/auth/pages/LoginPage.jsx +++ b/src/features/auth/pages/LoginPage.jsx @@ -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 ; } @@ -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 { diff --git a/src/features/policy-config/api.test.ts b/src/features/policy-config/api.test.ts new file mode 100644 index 0000000..be33393 --- /dev/null +++ b/src/features/policy-config/api.test.ts @@ -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 { + return (init?.headers || {}) as Record; +} + +function requestBody(init: RequestInit | undefined): Record { + return JSON.parse(String(init?.body || "{}")) as Record; +} + +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", + }); +}); diff --git a/src/features/policy-config/api.ts b/src/features/policy-config/api.ts new file mode 100644 index 0000000..6540cbd --- /dev/null +++ b/src/features/policy-config/api.ts @@ -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; + 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; + 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> { + const endpoint = API_ENDPOINTS.listPolicyTemplates; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listPolicyTemplates), { + method: endpoint.method, + query, + }).then(normalizeTemplatePage); +} + +export function savePolicyTemplate(payload: PolicyTemplatePayload): Promise { + const endpoint = API_ENDPOINTS.savePolicyTemplate; + return apiRequest(apiEndpointPath(API_OPERATIONS.savePolicyTemplate), { + body: payload, + method: endpoint.method, + }).then(normalizeTemplate); +} + +export function listPolicyInstances(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listPolicyInstances; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listPolicyInstances), { + method: endpoint.method, + query, + }).then(normalizeInstancePage); +} + +export function createPolicyInstance(payload: PolicyInstancePayload): Promise { + const endpoint = API_ENDPOINTS.createPolicyInstance; + return apiRequest(apiEndpointPath(API_OPERATIONS.createPolicyInstance), { + body: payload, + method: endpoint.method, + }).then(normalizeInstance); +} + +export function publishPolicyInstance(instanceId: EntityId): Promise { + const endpoint = API_ENDPOINTS.publishPolicyInstance; + return apiRequest(apiEndpointPath(API_OPERATIONS.publishPolicyInstance, { instance_id: instanceId }), { + method: endpoint.method, + }).then(normalizePublish); +} + +export function updatePolicyInstanceStatus(instanceId: EntityId, status: string): Promise { + const endpoint = API_ENDPOINTS.updatePolicyInstanceStatus; + return apiRequest( + 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; + +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; + +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; + target_count?: number | string; + template_code?: string; + template_version?: string; +} & Record; + +function normalizeTemplatePage(page: ApiPage): ApiPage { + return { + ...page, + pageSize: numberValue(page.pageSize ?? (page as unknown as Record).page_size), + items: (page.items || []).map(normalizeTemplate), + }; +} + +function normalizeInstancePage(page: ApiPage): ApiPage { + return { + ...page, + pageSize: numberValue(page.pageSize ?? (page as unknown as Record).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 { + 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) : {}; +} + +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; +} diff --git a/src/features/policy-config/components/PolicyInstanceDrawer.jsx b/src/features/policy-config/components/PolicyInstanceDrawer.jsx new file mode 100644 index 0000000..8d6fe6b --- /dev/null +++ b/src/features/policy-config/components/PolicyInstanceDrawer.jsx @@ -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 ( + + + + + } + as="form" + className={styles.instanceDrawer} + contentClassName={styles.drawerBody} + open={page.instanceDrawerOpen} + title="创建 App 策略实例" + width="wide" + onClose={saving ? undefined : page.closeInstanceDrawer} + onSubmit={page.submitInstance} + > +
+
+

实例配置

+
+
+ patch({ instanceCode: event.target.value })} + /> + { + const [templateCode, templateVersion] = String(event.target.value).split(":"); + patch({ templateCode, templateVersion }); + }} + > + {page.templateOptions.map((option) => ( + + {option.label} + + ))} + {!page.templateOptions.length ? {selectedTemplateValue} : null} + + patch({ regionScopeMode: event.target.value, regionId: "" })} + > + 全部启用地区 + 指定地区 + + {form.regionScopeMode === "region_id" ? ( + patch({ regionId })} + /> + ) : null} + patch({ status: event.target.value })} + > + 启用 + 停用 + + patch({ effectiveFrom: range.startMs, effectiveTo: range.endMs })} + /> +
+
+
+ ); +} diff --git a/src/features/policy-config/components/PolicyRuleEditorDrawer.jsx b/src/features/policy-config/components/PolicyRuleEditorDrawer.jsx new file mode 100644 index 0000000..1a6e07c --- /dev/null +++ b/src/features/policy-config/components/PolicyRuleEditorDrawer.jsx @@ -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 ( + + + + + } + as="form" + className={styles.ruleDrawer} + contentClassName={styles.drawerBody} + open={page.templateDrawerOpen} + title={page.editingTemplate ? "编辑收益政策模板" : "新增收益政策模板"} + width="wide" + onClose={saving ? undefined : page.closeTemplateDrawer} + onSubmit={page.submitTemplate} + > + + + + + + + + + updateRule(page.setTemplateForm, { rawJson })} + /> + + ); +} + +function TemplateBaseFields({ disabled, form, setForm }) { + const patch = (value) => setForm((current) => ({ ...current, ...value })); + return ( +
+
+

模板基础

+
+
+ patch({ name: event.target.value })} /> + patch({ templateCode: event.target.value })} /> + patch({ templateVersion: event.target.value })} /> + patch({ status: event.target.value })}> + 启用 + 停用 + + patch({ description: event.target.value })} /> +
+
+ ); +} + +function GlobalRuleSection({ disabled, form, setForm }) { + return ( +
+
+

全局口径

+
+
+ updateRule(setForm, { pointsPerUsd })} /> + updateRule(setForm, { googleCoinPerUsd })} /> + updateRule(setForm, { allowSelfBrushing })} + /> +
+
+ ); +} + +function HostRuleSection({ disabled, form, setForm }) { + return ( +
+
+

主播收益

+
+
+ updateSection(setForm, "host", { pointRatioPercent })} /> + updateSection(setForm, "host", { minimumWithdrawPoints })} /> + updateSection(setForm, "host", { withdrawFeeBps })} /> + updateSection(setForm, "host", { affectsRoomHeat })} + /> +
+
+ ); +} + +function AgentRuleSection({ disabled, form, setForm }) { + const agent = form.agent; + return ( +
+
+

Agent 规则

+ +
+
+ updateSection(setForm, "agent", { period })} /> + updateSection(setForm, "agent", { supportRatioPercent })} /> + updateSection(setForm, "agent", { supportDays })} /> + updateSection(setForm, "agent", { qualifiedHostMin })} /> + updateAgentDifferential(setForm, { enabled })} /> + updateAgentDifferential(setForm, { maxDepth })} /> + updateAgentDifferential(setForm, { crossLevelFormula: event.target.value })} /> +
+
+
+ 等级 + 名称 + 最小 USD + 最大 USD + 比例 + 合格主播 + 人工 + +
+ {agent.levels.map((level, index) => ( +
+ updateAgentLevel(setForm, index, { level: value })} /> + updateAgentLevel(setForm, index, { name: event.target.value })} /> + updateAgentLevel(setForm, index, { minUsd: value })} /> + updateAgentLevel(setForm, index, { maxUsd: value })} /> + updateAgentLevel(setForm, index, { ratioPercent: value })} /> + updateAgentLevel(setForm, index, { qualifiedHostMin: value })} /> + updateAgentLevel(setForm, index, { manualReview: event.target.checked })} /> + removeAgentLevel(setForm, index)} /> +
+ ))} +
+
+ ); +} + +function BDRuleSection({ disabled, form, setForm }) { + const bd = form.bd; + return ( +
+
+

BD 规则

+ +
+
+ updateSection(setForm, "bd", { period })} /> + updateSection(setForm, "bd", { base: event.target.value })} /> + updateSection(setForm, "bd", { qualifiedAgencyMin })} /> + updateSection(setForm, "bd", { qualifiedHostPerAgencyMin })} /> +
+
+
+ 等级 + 名称 + 门槛 USD + 固定工资 + 提成 + +
+ {bd.levels.map((level, index) => ( +
+ updateBDLevel(setForm, index, { level: value })} /> + updateBDLevel(setForm, index, { name: event.target.value })} /> + updateBDLevel(setForm, index, { thresholdUsd: value })} /> + updateBDLevel(setForm, index, { salaryUsd: value })} /> + updateBDLevel(setForm, index, { commissionPercent: value })} /> + removeBDLevel(setForm, index)} /> +
+ ))} +
+
+ ); +} + +function ManagerRuleSection({ disabled, form, setForm }) { + return ( +
+
+

经理规则

+
+
+ updateSection(setForm, "manager", { coinSellerRechargeThresholdUsd })} /> + updateSection(setForm, "manager", { coinSellerRechargeCommissionPercent })} /> + updateSection(setForm, "manager", { hostPointCommissionPercent })} /> +
+
+ ); +} + +function CoinSellerRuleSection({ disabled, form, setForm }) { + const coinSeller = form.coinSeller; + return ( +
+
+

币商规则

+ +
+
+ updateSection(setForm, "coinSeller", { withdrawOrderEnabled })} /> + updateSection(setForm, "coinSeller", { orderTimeoutSeconds })} /> + updateSection(setForm, "coinSeller", { sellerPointSettlePercent })} /> + updateSection(setForm, "coinSeller", { sellerPointRewardPercent })} /> + updateSection(setForm, "coinSeller", { successRateCloseThresholdPercent })} /> +
+
+
+ 名称 + 累计门槛 + 单次门槛 + 金币/USD + 订单 + +
+ {coinSeller.levels.map((level, index) => ( +
+ updateCoinSellerLevel(setForm, index, { name: event.target.value })} /> + updateCoinSellerLevel(setForm, index, { thresholdUsd: value })} /> + updateCoinSellerLevel(setForm, index, { singleRechargeThresholdUsd: value })} /> + updateCoinSellerLevel(setForm, index, { coinPerUsd: value })} /> + updateCoinSellerLevel(setForm, index, { withdrawOrderEnabled: event.target.checked })} /> + removeCoinSellerLevel(setForm, index)} /> +
+ ))} +
+
+ ); +} + +function TaskAndGameRuleSection({ disabled, form, setForm }) { + return ( +
+
+

任务与游戏邀请

+
+
+ updateSection(setForm, "tasks", { rewardAssetType })} /> + updateSection(setForm, "tasks", { newHost7dMaxPoints })} /> + updateSection(setForm, "gameInvite", { enabled })} /> + updateSection(setForm, "gameInvite", { period })} /> +
+
+ ); +} + +function NumberField({ bare = false, disabled, label, onChange, prefix, suffix, value }) { + return ( + {prefix} : undefined, + endAdornment: suffix ? {suffix} : undefined, + }, + }} + type="number" + value={value} + onChange={(event) => onChange(event.target.value)} + /> + ); +} + +function SelectField({ disabled, label, onChange, options, value }) { + return ( + onChange(event.target.value)}> + {options.map(([optionValue, optionLabel]) => ( + + {optionLabel} + + ))} + + ); +} + +function SwitchField({ checked, disabled, label, onChange }) { + return ( +
+ {label} + onChange(event.target.checked)} /> +
+ ); +} + +function RemoveButton({ disabled, label, onClick }) { + return ( + + + + ); +} + +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), + }, + }, + })); +} diff --git a/src/features/policy-config/hooks/usePolicyConfigPage.js b/src/features/policy-config/hooks/usePolicyConfigPage.js new file mode 100644 index 0000000..7087d92 --- /dev/null +++ b/src/features/policy-config/hooks/usePolicyConfigPage.js @@ -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, + }; +} diff --git a/src/features/policy-config/pages/PolicyConfigPage.jsx b/src/features/policy-config/pages/PolicyConfigPage.jsx new file mode 100644 index 0000000..1063bb2 --- /dev/null +++ b/src/features/policy-config/pages/PolicyConfigPage.jsx @@ -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) => , + }, + { + key: "runtime", + label: "核心运行字段", + width: "minmax(240px, 0.9fr)", + render: (item) => , + }, + { + key: "bd", + label: "团队政策", + width: "minmax(220px, 0.8fr)", + render: (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) => , + }, + { + key: "scope", + label: "范围与时间", + width: "minmax(260px, 0.9fr)", + render: (item, _index, context) => , + }, + { + key: "publish", + label: "发布", + width: "minmax(220px, 0.85fr)", + render: (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 ( + +
+ page.setActiveTab(value)}> + + + +
+ {page.activeTab === "templates" ? : } + + +
+ ); +} + +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) => } : column, + ); + return ( + <> + page.openTemplateEditor(null)}> + + + ) : null + } + filters={ +
+ + + +
+ } + /> + + + 0 ? { page: page.templatePage, pageSize: page.templates.data.pageSize || 50, total, onPageChange: page.setTemplatePage } : undefined} + rowKey={(item) => `${item.templateCode}:${item.templateVersion}`} + /> + + + + ); +} + +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) => }; + } + if (column.key === "actions") { + return { ...column, render: (item) => }; + } + return column; + }); + return ( + <> + page.openInstanceCreator(null)}> + + + ) : null + } + filters={ +
+ + + +
+ } + /> + + + 0 ? { page: page.instancePage, pageSize: page.instances.data.pageSize || 50, total, onPageChange: page.setInstancePage } : undefined} + rowKey={(item) => item.instanceId} + /> + + + + ); +} + +function TemplateIdentity({ item }) { + return ( +
+ {item.name} + {item.templateCode} + 版本 {item.templateVersion} +
+ ); +} + +function TemplateRuntimeSummary({ item }) { + const rule = item.ruleJson || {}; + const host = rule.host || {}; + const tasks = rule.tasks || {}; + return ( +
+ {formatNumber(rule.points_per_usd)} POINT/USD + 主播 {formatNumber(host.point_ratio_percent)}% 入 POINT + 提现手续费 {formatNumber(host.withdraw_fee_bps)} bps + 任务资产 {tasks.reward_asset_type || "-"} +
+ ); +} + +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 ( +
+ Agent {agentLevels.length} 档 · BD {bdLevels.length} 档 + 币商 {coinSellerLevels.length} 档 + {rule.allow_self_brushing ? "允许自刷" : "不允许自刷"} +
+ ); +} + +function TemplateActions({ item, page }) { + if (!page.abilities.canSaveTemplate) { + return null; + } + return ( + page.openTemplateEditor(item)}> + + + ); +} + +function InstanceIdentity({ item }) { + return ( +
+ {item.instanceCode} + {item.appCode || "-"} + + {item.templateCode} · {item.templateVersion} + +
+ ); +} + +function InstanceScope({ item, regionLabelMap = {} }) { + return ( +
+ {regionScopeLabel(item.regionScope, regionLabelMap)} + 开始 {formatMillis(item.effectiveFromMs)} + 结束 {item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"} +
+ ); +} + +function PublishSummary({ item }) { + return ( +
+ {publishStatusLabel(item.publishStatus)} + {item.lastPublishedAtMs ? formatMillis(item.lastPublishedAtMs) : "未发布"} + {item.publishError ? {item.publishError} : null} +
+ ); +} + +function InstanceStatusSwitch({ item, page }) { + const action = `instance-status-${item.instanceId}`; + return ( + page.toggleInstanceStatus(item, event.target.checked)} + /> + ); +} + +function InstanceActions({ item, page }) { + const action = `instance-publish-${item.instanceId}`; + if (!page.abilities.canPublishInstance) { + return null; + } + return ( + page.publishInstance(item)} + > + + + ); +} + +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") : "-"; +} diff --git a/src/features/policy-config/permissions.js b/src/features/policy-config/permissions.js new file mode 100644 index 0000000..67f2ce7 --- /dev/null +++ b/src/features/policy-config/permissions.js @@ -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), + }; +} diff --git a/src/features/policy-config/policy-config.module.css b/src/features/policy-config/policy-config.module.css new file mode 100644 index 0000000..251155c --- /dev/null +++ b/src/features/policy-config/policy-config.module.css @@ -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; + } +} diff --git a/src/features/policy-config/routes.js b/src/features/policy-config/routes.js new file mode 100644 index 0000000..bab7b7b --- /dev/null +++ b/src/features/policy-config/routes.js @@ -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, + }, +]; diff --git a/src/features/policy-config/schema.test.ts b/src/features/policy-config/schema.test.ts new file mode 100644 index 0000000..8926742 --- /dev/null +++ b/src/features/policy-config/schema.test.ts @@ -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; + +function validRuleForm(): PolicyRuleForm { + return ruleFormFromJson(defaultPolicyRule()); +} + +function clone(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 必须大于最小 USD,0 表示无上限"); + + 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, + }); + }); +}); diff --git a/src/features/policy-config/schema.ts b/src/features/policy-config/schema.ts new file mode 100644 index 0000000..029e8e0 --- /dev/null +++ b/src/features/policy-config/schema.ts @@ -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; + +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 & { 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(); + 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 必须大于最小 USD,0 表示无上限", 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(); + 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 { + const result = new Map(); + 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 || "未知错误"); +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 2f6da0a..f6e5f55 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 5fcd923..fcb6520 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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?: { diff --git a/vite.config.js b/vite.config.js index 367b91a..77e7ea9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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 } } },