From 2f384687b557f45914941da04097d8468943189d Mon Sep 17 00:00:00 2001 From: zhx Date: Sun, 12 Jul 2026 17:38:51 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=AF=E5=88=86=E6=94=B6=E7=9B=8A=E6=94=BF?= =?UTF-8?q?=E7=AD=96h5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/permissions.ts | 3 + src/features/app-users/app-users.module.css | 2 +- .../pages/PointWithdrawalConfigPage.jsx | 275 ++++++++++ .../host-org/pointWithdrawalApi.test.ts | 47 ++ src/features/host-org/pointWithdrawalApi.ts | 42 ++ src/features/host-org/routes.js | 9 + .../components/PolicyRuleEditorDrawer.jsx | 473 +++++++++++++++--- .../policy-config/pages/PolicyConfigPage.jsx | 90 +++- src/features/policy-config/schema.test.ts | 80 ++- src/features/policy-config/schema.ts | 426 +++++++++++++--- src/styles/tokens.css | 1 + 11 files changed, 1300 insertions(+), 148 deletions(-) create mode 100644 src/features/host-org/pages/PointWithdrawalConfigPage.jsx create mode 100644 src/features/host-org/pointWithdrawalApi.test.ts create mode 100644 src/features/host-org/pointWithdrawalApi.ts diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 179f5a5..0f4686b 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -46,6 +46,8 @@ export const PERMISSIONS = { coinSellerSubApplicationView: "coin-seller-sub-application:view", coinSellerSubApplicationAudit: "coin-seller-sub-application:audit", hostWithdrawalView: "host-withdrawal:view", + pointWithdrawalConfigView: "point-withdrawal-config:view", + pointWithdrawalConfigUpdate: "point-withdrawal-config:update", financeView: "finance:view", financeApplicationCreate: "finance-application:create", financeApplicationAudit: "finance-application:audit", @@ -294,6 +296,7 @@ export const MENU_CODES = { hostOrgCoinSellers: "host-org-coin-sellers", hostOrgCoinSellerSubApplications: "host-org-coin-seller-sub-applications", hostWithdrawals: "host-withdrawals", + hostOrgPointWithdrawalConfig: "host-org-point-withdrawal-config", payment: "payment", paymentBillList: "payment-bill-list", paymentRechargeProducts: "payment-recharge-products", diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index f69aefc..8cbaab7 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -260,7 +260,7 @@ } .genderAgeFemale { - background: var(--danger); + background: var(--gender-female); } .genderAgeMale { diff --git a/src/features/host-org/pages/PointWithdrawalConfigPage.jsx b/src/features/host-org/pages/PointWithdrawalConfigPage.jsx new file mode 100644 index 0000000..2076f1a --- /dev/null +++ b/src/features/host-org/pages/PointWithdrawalConfigPage.jsx @@ -0,0 +1,275 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useCallback, useState } from "react"; +import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; +import { useAuth } from "@/app/auth/AuthProvider.jsx"; +import { PERMISSIONS } from "@/app/permissions"; +import { + deletePointWithdrawalCoinSeller, + listPointWithdrawalCoinSellers, + upsertPointWithdrawalCoinSeller, +} from "@/features/host-org/pointWithdrawalApi"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { + AdminActionIconButton, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminRowActions, +} from "@/shared/ui/AdminListLayout.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; + +const emptyForm = { + sellerUserId: "", + sortOrder: "0", + pointAmount: "", + sellerCoinAmount: "", + serviceCountryCodes: "", + status: "active", +}; + +export function PointWithdrawalConfigPage() { + const { appCode } = useAppScope(); + const { can } = useAuth(); + const { showToast } = useToast(); + const canUpdate = can(PERMISSIONS.pointWithdrawalConfigUpdate); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + const queryFn = useCallback(() => listPointWithdrawalCoinSellers(), []); + const query = useAdminQuery(queryFn, { + errorMessage: "获取币商提现白名单失败", + queryKey: ["point-withdrawal-coin-sellers", appCode], + }); + + const openCreate = () => { + setEditing({ mode: "create" }); + setForm(emptyForm); + }; + const openEdit = (item) => { + setEditing({ mode: "edit", sellerUserId: item.sellerUserId }); + setForm({ + sellerUserId: item.sellerUserId, + sortOrder: String(item.sortOrder), + pointAmount: String(item.pointAmount), + sellerCoinAmount: String(item.sellerCoinAmount), + serviceCountryCodes: (item.serviceCountryCodes || []).join(", "), + status: item.status || "active", + }); + }; + const closeDialog = () => { + if (!saving) setEditing(null); + }; + + const submit = async () => { + const sellerUserId = Number(form.sellerUserId); + const sortOrder = Number(form.sortOrder); + const pointAmount = Number(form.pointAmount); + const sellerCoinAmount = Number(form.sellerCoinAmount); + if ( + ![sellerUserId, sortOrder, pointAmount, sellerCoinAmount].every(Number.isSafeInteger) || + sellerUserId <= 0 || + sortOrder < 0 || + pointAmount <= 0 || + sellerCoinAmount <= 0 + ) { + showToast("币商 ID、排序和兑换比例必须填写有效整数", "error"); + return; + } + setSaving(true); + try { + // 国家码只负责展示/准入范围;去重和大写会在 Admin API 再做一次,避免不同客户端写出不同格式。 + const serviceCountryCodes = [ + ...new Set( + form.serviceCountryCodes + .split(/[,,\s]+/) + .map((value) => value.trim().toUpperCase()) + .filter(Boolean), + ), + ]; + await upsertPointWithdrawalCoinSeller({ + sellerUserId, + sortOrder, + pointAmount, + sellerCoinAmount, + serviceCountryCodes, + status: form.status, + }); + showToast("币商提现配置已保存", "success"); + setEditing(null); + await query.reload(); + } catch (error) { + showToast(error.message || "保存币商提现配置失败", "error"); + } finally { + setSaving(false); + } + }; + + const remove = async (item) => { + if (!window.confirm(`确认删除币商 ${item.displayUserId || item.sellerUserId} 的提现配置?`)) return; + try { + await deletePointWithdrawalCoinSeller(item.sellerUserId); + showToast("币商提现配置已删除", "success"); + await query.reload(); + } catch (error) { + showToast(error.message || "删除币商提现配置失败", "error"); + } + }; + + const columns = (() => { + const base = [ + { key: "sortOrder", label: "排序", render: (item) => item.sortOrder, width: "90px" }, + { + key: "seller", + label: "币商", + render: (item) => , + width: "minmax(220px, 1.2fr)", + }, + { + key: "countries", + label: "服务国家", + render: (item) => (item.serviceCountryCodes?.length ? item.serviceCountryCodes.join(", ") : "全部国家"), + width: "minmax(160px, 1fr)", + }, + { + key: "ratio", + label: "主播 POINT : 币商金币", + render: (item) => `${formatNumber(item.pointAmount)} : ${formatNumber(item.sellerCoinAmount)}`, + width: "minmax(210px, 1fr)", + }, + { + key: "status", + label: "状态", + render: (item) => (item.status === "active" ? "启用" : "停用"), + width: "100px", + }, + { key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "170px" }, + ]; + if (canUpdate) { + base.push({ + key: "actions", + label: "操作", + width: "120px", + render: (item) => ( + + openEdit(item)}> + + + remove(item)}> + + + + ), + }); + } + return base; + })(); + + return ( + + } variant="primary" onClick={openCreate}> + 添加币商 + + ) : null + } + filters={当前 App:{appCode} · 配置为空时 H5 不展示币商提现选项} + /> + + + item.sellerUserId} + /> + + + + {editing?.mode === "edit" ? "编辑币商提现配置" : "添加币商提现配置"} + + setForm({ ...form, sellerUserId: event.target.value })} + /> + setForm({ ...form, sortOrder: event.target.value })} + /> + setForm({ ...form, pointAmount: event.target.value })} + /> + setForm({ ...form, sellerCoinAmount: event.target.value })} + /> + setForm({ ...form, serviceCountryCodes: event.target.value })} + /> + setForm({ ...form, status: event.target.value })} + > + 启用 + 停用 + + + + + + + + + ); +} + +function SellerCell({ item }) { + return ( +
+ {item.nickname || "未找到用户"} +
+ {item.displayUserId || "-"} · UID {item.sellerUserId} +
+ {item.countryName || "-"} +
+ ); +} + +function formatNumber(value) { + return new Intl.NumberFormat("en-US").format(Number(value || 0)); +} diff --git a/src/features/host-org/pointWithdrawalApi.test.ts b/src/features/host-org/pointWithdrawalApi.test.ts new file mode 100644 index 0000000..0ce11b6 --- /dev/null +++ b/src/features/host-org/pointWithdrawalApi.test.ts @@ -0,0 +1,47 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { setAccessToken, setSelectedAppCode } from "@/shared/api/request"; +import { + deletePointWithdrawalCoinSeller, + listPointWithdrawalCoinSellers, + upsertPointWithdrawalCoinSeller, +} from "./pointWithdrawalApi"; + +afterEach(() => { + setAccessToken(""); + setSelectedAppCode("lalu"); + vi.unstubAllGlobals(); +}); + +test("point withdrawal whitelist APIs use selected app scope and server-owned ratio payload", async () => { + setSelectedAppCode("fami"); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [] } }))), + ); + + await listPointWithdrawalCoinSellers(); + await upsertPointWithdrawalCoinSeller({ + sellerUserId: 90001, + sortOrder: 1, + pointAmount: 100000, + sellerCoinAmount: 92000, + serviceCountryCodes: ["SA"], + status: "active", + }); + await deletePointWithdrawalCoinSeller("90001"); + + const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0]; + const [putUrl, putInit] = vi.mocked(fetch).mock.calls[1]; + const [deleteUrl, deleteInit] = vi.mocked(fetch).mock.calls[2]; + expect(String(listUrl)).toContain("/api/v1/admin/point-withdrawal/coin-sellers"); + expect(listInit?.headers).toMatchObject({ "X-App-Code": "fami" }); + expect(String(putUrl)).toContain("/api/v1/admin/point-withdrawal/coin-sellers"); + expect(putInit?.method).toBe("PUT"); + expect(JSON.parse(String(putInit?.body))).toMatchObject({ + pointAmount: 100000, + sellerCoinAmount: 92000, + serviceCountryCodes: ["SA"], + }); + expect(String(deleteUrl)).toContain("/api/v1/admin/point-withdrawal/coin-sellers/90001"); + expect(deleteInit?.method).toBe("DELETE"); +}); diff --git a/src/features/host-org/pointWithdrawalApi.ts b/src/features/host-org/pointWithdrawalApi.ts new file mode 100644 index 0000000..8ba068e --- /dev/null +++ b/src/features/host-org/pointWithdrawalApi.ts @@ -0,0 +1,42 @@ +import { apiRequest } from "@/shared/api/request"; + +export interface PointWithdrawalCoinSellerDto { + appCode: string; + sellerUserId: string; + displayUserId: string; + nickname: string; + avatar: string; + countryName: string; + sortOrder: number; + pointAmount: number; + sellerCoinAmount: number; + serviceCountryCodes: string[]; + status: "active" | "disabled"; + createdAtMs: number; + updatedAtMs: number; +} + +export interface PointWithdrawalCoinSellerPayload { + sellerUserId: number; + sortOrder: number; + pointAmount: number; + sellerCoinAmount: number; + serviceCountryCodes: string[]; + status: "active" | "disabled"; +} + +export function listPointWithdrawalCoinSellers(): Promise<{ items: PointWithdrawalCoinSellerDto[] }> { + return apiRequest("/v1/admin/point-withdrawal/coin-sellers", { method: "GET" }); +} + +export function upsertPointWithdrawalCoinSeller( + payload: PointWithdrawalCoinSellerPayload, +): Promise { + return apiRequest("/v1/admin/point-withdrawal/coin-sellers", { body: payload, method: "PUT" }); +} + +export function deletePointWithdrawalCoinSeller(sellerUserId: string): Promise<{ deleted: boolean }> { + return apiRequest(`/v1/admin/point-withdrawal/coin-sellers/${encodeURIComponent(sellerUserId)}`, { + method: "DELETE", + }); +} diff --git a/src/features/host-org/routes.js b/src/features/host-org/routes.js index da219bc..c087024 100644 --- a/src/features/host-org/routes.js +++ b/src/features/host-org/routes.js @@ -84,4 +84,13 @@ export const hostOrgRoutes = [ path: "/host/withdrawals", permission: PERMISSIONS.hostWithdrawalView, }, + { + label: "币商提现白名单", + loader: () => + import("./pages/PointWithdrawalConfigPage.jsx").then((module) => module.PointWithdrawalConfigPage), + menuCode: MENU_CODES.hostOrgPointWithdrawalConfig, + pageKey: "host-org-point-withdrawal-config", + path: "/host/point-withdrawal-config", + permission: PERMISSIONS.pointWithdrawalConfigView, + }, ]; diff --git a/src/features/policy-config/components/PolicyRuleEditorDrawer.jsx b/src/features/policy-config/components/PolicyRuleEditorDrawer.jsx index 1a6e07c..8ab39bf 100644 --- a/src/features/policy-config/components/PolicyRuleEditorDrawer.jsx +++ b/src/features/policy-config/components/PolicyRuleEditorDrawer.jsx @@ -71,14 +71,51 @@ function TemplateBaseFields({ disabled, form, setForm }) {

模板基础

- patch({ name: event.target.value })} /> - patch({ templateCode: event.target.value })} /> - patch({ templateVersion: event.target.value })} /> - patch({ status: event.target.value })}> + 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 })} /> + patch({ description: event.target.value })} + />
); @@ -91,8 +128,18 @@ function GlobalRuleSection({ disabled, form, setForm }) {

全局口径

- updateRule(setForm, { pointsPerUsd })} /> - updateRule(setForm, { googleCoinPerUsd })} /> + updateRule(setForm, { pointsPerUsd })} + /> + updateRule(setForm, { googleCoinPerUsd })} + /> 主播收益
- updateSection(setForm, "host", { pointRatioPercent })} /> - updateSection(setForm, "host", { minimumWithdrawPoints })} /> - updateSection(setForm, "host", { withdrawFeeBps })} /> + updateSection(setForm, "host", { pointRatioPercent })} + /> + updateSection(setForm, "agency", { pointRatioPercent })} + /> + updateSection(setForm, "host", { minimumWithdrawPoints })} + /> + updateSection(setForm, "host", { withdrawFeeBps })} + />

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 })} /> + 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 })} + />
@@ -157,14 +270,56 @@ function AgentRuleSection({ disabled, form, setForm }) {
{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)} /> + 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)} + />
))}
@@ -178,15 +333,43 @@ function BDRuleSection({ disabled, form, setForm }) {

BD 规则

-
- updateSection(setForm, "bd", { period })} /> - updateSection(setForm, "bd", { base: event.target.value })} /> - updateSection(setForm, "bd", { qualifiedAgencyMin })} /> - updateSection(setForm, "bd", { qualifiedHostPerAgencyMin })} /> + updateSection(setForm, "bd", { period })} + /> + updateSection(setForm, "bd", { base: event.target.value })} + /> + updateSection(setForm, "bd", { qualifiedAgencyMin })} + /> + + updateSection(setForm, "bd", { qualifiedHostPerAgencyMin }) + } + />
@@ -199,12 +382,43 @@ function BDRuleSection({ disabled, form, setForm }) {
{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)} /> + 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)} + />
))}
@@ -219,9 +433,33 @@ function ManagerRuleSection({ disabled, form, setForm }) {

经理规则

- updateSection(setForm, "manager", { coinSellerRechargeThresholdUsd })} /> - updateSection(setForm, "manager", { coinSellerRechargeCommissionPercent })} /> - updateSection(setForm, "manager", { hostPointCommissionPercent })} /> + + updateSection(setForm, "manager", { coinSellerRechargeThresholdUsd }) + } + /> + + updateSection(setForm, "manager", { coinSellerRechargeCommissionPercent }) + } + /> + + updateSection(setForm, "manager", { hostPointCommissionPercent }) + } + />
); @@ -233,16 +471,55 @@ function CoinSellerRuleSection({ disabled, form, setForm }) {

币商规则

-
- updateSection(setForm, "coinSeller", { withdrawOrderEnabled })} /> - updateSection(setForm, "coinSeller", { orderTimeoutSeconds })} /> - updateSection(setForm, "coinSeller", { sellerPointSettlePercent })} /> - updateSection(setForm, "coinSeller", { sellerPointRewardPercent })} /> - updateSection(setForm, "coinSeller", { successRateCloseThresholdPercent })} /> + updateSection(setForm, "coinSeller", { withdrawOrderEnabled })} + /> + updateSection(setForm, "coinSeller", { orderTimeoutSeconds })} + /> + + updateSection(setForm, "coinSeller", { sellerPointSettlePercent }) + } + /> + + updateSection(setForm, "coinSeller", { sellerPointRewardPercent }) + } + /> + + updateSection(setForm, "coinSeller", { successRateCloseThresholdPercent }) + } + />
@@ -255,12 +532,47 @@ function CoinSellerRuleSection({ disabled, form, setForm }) {
{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)} /> + 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)} + />
))}
@@ -275,10 +587,32 @@ function TaskAndGameRuleSection({ disabled, form, setForm }) {

任务与游戏邀请

- updateSection(setForm, "tasks", { rewardAssetType })} /> - updateSection(setForm, "tasks", { newHost7dMaxPoints })} /> - updateSection(setForm, "gameInvite", { enabled })} /> - updateSection(setForm, "gameInvite", { period })} /> + updateSection(setForm, "tasks", { rewardAssetType })} + /> + updateSection(setForm, "tasks", { newHost7dMaxPoints })} + /> + updateSection(setForm, "gameInvite", { enabled })} + /> + updateSection(setForm, "gameInvite", { period })} + />
); @@ -306,7 +640,14 @@ function NumberField({ bare = false, disabled, label, onChange, prefix, suffix, function SelectField({ disabled, label, onChange, options, value }) { return ( - onChange(event.target.value)}> + onChange(event.target.value)} + > {options.map(([optionValue, optionLabel]) => ( {optionLabel} @@ -320,7 +661,14 @@ function SwitchField({ checked, disabled, label, onChange }) { return (
{label} - onChange(event.target.checked)} /> + onChange(event.target.checked)} + />
); } @@ -338,7 +686,10 @@ function updateRule(setForm, patch) { } function updateSection(setForm, section, patch) { - setForm((current) => ({ ...current, rule: { ...current.rule, [section]: { ...current.rule[section], ...patch } } })); + setForm((current) => ({ + ...current, + rule: { ...current.rule, [section]: { ...current.rule[section], ...patch } }, + })); } function updateAgentDifferential(setForm, patch) { @@ -373,7 +724,9 @@ function updateArrayItem(setForm, section, key, index, patch) { ...current.rule, [section]: { ...current.rule[section], - [key]: current.rule[section][key].map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)), + [key]: current.rule[section][key].map((item, itemIndex) => + itemIndex === index ? { ...item, ...patch } : item, + ), }, }, })); diff --git a/src/features/policy-config/pages/PolicyConfigPage.jsx b/src/features/policy-config/pages/PolicyConfigPage.jsx index 1063bb2..e194c99 100644 --- a/src/features/policy-config/pages/PolicyConfigPage.jsx +++ b/src/features/policy-config/pages/PolicyConfigPage.jsx @@ -125,23 +125,42 @@ 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, + column.key === "actions" + ? { ...column, render: (item) => } + : column, ); return ( <> page.openTemplateEditor(null)}> + page.openTemplateEditor(null)} + > ) : null } filters={
- - - + + +
} /> @@ -151,7 +170,16 @@ function TemplatePanel({ page }) { columns={columns} items={items} minWidth="1240px" - pagination={total > 0 ? { page: page.templatePage, pageSize: page.templates.data.pageSize || 50, total, onPageChange: page.setTemplatePage } : undefined} + pagination={ + total > 0 + ? { + page: page.templatePage, + pageSize: page.templates.data.pageSize || 50, + total, + onPageChange: page.setTemplatePage, + } + : undefined + } rowKey={(item) => `${item.templateCode}:${item.templateVersion}`} /> @@ -177,16 +205,33 @@ function InstancePanel({ page }) { page.openInstanceCreator(null)}> + page.openInstanceCreator(null)} + > ) : null } filters={
- - - + + +
} /> @@ -197,7 +242,16 @@ function InstancePanel({ page }) { context={{ regionLabelMap: page.regionLabelMap }} items={items} minWidth="1260px" - pagination={total > 0 ? { page: page.instancePage, pageSize: page.instances.data.pageSize || 50, total, onPageChange: page.setInstancePage } : undefined} + pagination={ + total > 0 + ? { + page: page.instancePage, + pageSize: page.instances.data.pageSize || 50, + total, + onPageChange: page.setInstancePage, + } + : undefined + } rowKey={(item) => item.instanceId} /> @@ -219,11 +273,13 @@ function TemplateIdentity({ item }) { function TemplateRuntimeSummary({ item }) { const rule = item.ruleJson || {}; const host = rule.host || {}; + const agency = rule.agency || {}; const tasks = rule.tasks || {}; return (
{formatNumber(rule.points_per_usd)} POINT/USD 主播 {formatNumber(host.point_ratio_percent)}% 入 POINT + Agency {formatNumber(agency.point_ratio_percent || 0)}% 分成 提现手续费 {formatNumber(host.withdraw_fee_bps)} bps 任务资产 {tasks.reward_asset_type || "-"}
@@ -237,7 +293,9 @@ function TeamPolicySummary({ item }) { const coinSellerLevels = Array.isArray(rule.coin_seller?.levels) ? rule.coin_seller.levels : []; return (
- Agent {agentLevels.length} 档 · BD {bdLevels.length} 档 + + Agent {agentLevels.length} 档 · BD {bdLevels.length} 档 + 币商 {coinSellerLevels.length} 档 {rule.allow_self_brushing ? "允许自刷" : "不允许自刷"}
@@ -272,7 +330,9 @@ function InstanceScope({ item, regionLabelMap = {} }) {
{regionScopeLabel(item.regionScope, regionLabelMap)} 开始 {formatMillis(item.effectiveFromMs)} - 结束 {item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"} + + 结束 {item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"} +
); } @@ -281,7 +341,9 @@ function PublishSummary({ item }) { return (
{publishStatusLabel(item.publishStatus)} - {item.lastPublishedAtMs ? formatMillis(item.lastPublishedAtMs) : "未发布"} + + {item.lastPublishedAtMs ? formatMillis(item.lastPublishedAtMs) : "未发布"} + {item.publishError ? {item.publishError} : null}
); diff --git a/src/features/policy-config/schema.test.ts b/src/features/policy-config/schema.test.ts index 8926742..4773cec 100644 --- a/src/features/policy-config/schema.test.ts +++ b/src/features/policy-config/schema.test.ts @@ -58,6 +58,7 @@ describe("policy rule form schema", () => { const form = validRuleForm(); expect(form).toMatchObject({ + agency: { pointRatioPercent: "0" }, allowSelfBrushing: true, googleCoinPerUsd: "70000", host: { @@ -80,7 +81,12 @@ describe("policy rule form schema", () => { 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 }, + { + 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, @@ -89,7 +95,10 @@ describe("policy rule form schema", () => { host: { withdraw_fee_bps: 500 }, points_per_usd: 100000, }); - expect(JSON.parse(ruleJsonFromForm(form))).toMatchObject({ google_coin_per_usd: 70000, 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", () => { @@ -106,7 +115,10 @@ describe("policy rule form schema", () => { agent: { ...baseAgent, future_agent: "preserve-agent", - levels: [{ ...baseAgentLevels[0], future_agent_level: "preserve-agent-level" }, ...baseAgentLevels.slice(1)], + levels: [ + { ...baseAgentLevels[0], future_agent_level: "preserve-agent-level" }, + ...baseAgentLevels.slice(1), + ], }, bd: { ...baseBD, @@ -116,7 +128,10 @@ describe("policy rule form schema", () => { coin_seller: { ...baseCoinSeller, future_coin_seller: "preserve-coin-seller", - levels: [{ ...baseCoinSellerLevels[0], future_coin_seller_level: "preserve-coin-seller-level" }, ...baseCoinSellerLevels.slice(1)], + 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" }, @@ -127,6 +142,7 @@ describe("policy rule form schema", () => { form.allowSelfBrushing = false; form.googleCoinPerUsd = "71000"; form.host.pointRatioPercent = "72.5"; + form.agency.pointRatioPercent = "20"; form.agent.levels[0].ratioPercent = "6"; form.bd.levels[0].commissionPercent = "1.75"; form.coinSeller.levels[0].coinPerUsd = "93000"; @@ -149,11 +165,21 @@ describe("policy rule form schema", () => { }, game_invite: { enabled: false, future_game_invite: "preserve-game-invite" }, host: { future_host: "preserve-host", point_ratio_percent: 72.5 }, + agency: { point_ratio_percent: 20 }, 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" }); + 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", () => { @@ -168,11 +194,17 @@ describe("policy rule form schema", () => { ...base, agent: { ...baseAgent, - levels: [{ ...baseAgentLevels[0], marker: "deleted-agent-level" }, { ...baseAgentLevels[1], marker: "kept-agent-level" }], + 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" }], + levels: [ + { ...baseBDLevels[0], marker: "deleted-bd-level" }, + { ...baseBDLevels[1], marker: "kept-bd-level" }, + ], }, coin_seller: { ...baseCoinSeller, @@ -190,13 +222,18 @@ describe("policy rule form schema", () => { 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" }); + 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); + expect(invalidRuleMessages(nonObjectJson).some((message) => message.includes("规则 JSON 必须是对象"))).toBe( + true, + ); const invalidJson = validRuleForm(); invalidJson.rawJson = "{bad json"; @@ -255,6 +292,11 @@ describe("policy rule form schema", () => { const invalidFeeBps = validRuleForm(); invalidFeeBps.host.withdrawFeeBps = "10001"; expectInvalidRule(invalidFeeBps, "提现手续费 bps 必须在 0-10000 之间"); + + const invalidCombinedRatio = validRuleForm(); + invalidCombinedRatio.host.pointRatioPercent = "81"; + invalidCombinedRatio.agency.pointRatioPercent = "20"; + expectInvalidRule(invalidCombinedRatio, "主播收益比例和 Agency 分成比例合计不能超过 100"); }); }); @@ -293,10 +335,18 @@ describe("policy instance payload", () => { 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("生效时间范围不正确"); + 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", () => { diff --git a/src/features/policy-config/schema.ts b/src/features/policy-config/schema.ts index 029e8e0..dc4e086 100644 --- a/src/features/policy-config/schema.ts +++ b/src/features/policy-config/schema.ts @@ -53,6 +53,9 @@ export interface PolicyRuleForm { supportRatioPercent: NumericInput; }; allowSelfBrushing: boolean; + agency: { + pointRatioPercent: NumericInput; + }; bd: { base: string; levels: BDLevelForm[]; @@ -182,6 +185,9 @@ export const policyRuleFormSchema = z pointRatioPercent: z.union([z.string(), z.number()]), withdrawFeeBps: z.union([z.string(), z.number()]), }), + agency: z.object({ + pointRatioPercent: z.union([z.string(), z.number()]), + }), manager: z.object({ coinSellerRechargeCommissionPercent: z.union([z.string(), z.number()]), coinSellerRechargeThresholdUsd: z.union([z.string(), z.number()]), @@ -202,29 +208,145 @@ export const policyRuleFormSchema = z } 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, + ["host", "pointRatioPercent"], + value.host.pointRatioPercent, + "主播收益比例必须在 0-100 之间", + false, + ); + requirePercent( + context, + ["agency", "pointRatioPercent"], + value.agency.pointRatioPercent, + "Agency 分成比例必须在 0-100 之间", + true, + ); + if (numberValue(value.host.pointRatioPercent) + numberValue(value.agency.pointRatioPercent) > 100) { + context.addIssue({ + code: "custom", + message: "主播收益比例和 Agency 分成比例合计不能超过 100", + path: ["agency", "pointRatioPercent"], + }); } - requirePercent(context, ["coinSeller", "successRateCloseThresholdPercent"], value.coinSeller.successRateCloseThresholdPercent, "成功率关闭门槛必须在 0-100 之间", true); + 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"); + requireNonNegativeInteger( + context, + ["tasks", "newHost7dMaxPoints"], + value.tasks.newHost7dMaxPoints, + "新主播 7 日任务积分不能小于 0", + ); }); export const policyTemplateFormSchema = z.object({ @@ -258,10 +380,22 @@ export const policyInstanceFormSchema = z } }); -export function policyTemplateFormFromItem(item?: Partial & { ruleJson?: unknown; rule_json?: unknown; template_code?: string; template_version?: string } | null): PolicyTemplateForm { +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。"), + description: stringValue( + item?.description, + "新文档口径:允许自刷;BD 按 UTC 当月自然月;Huwaa 使用 POINT/COIN_SELLER_POINT。", + ), name: stringValue(item?.name, "第一套政策:Google 70000 + 币商 92000-100000"), rule, status: normalizeStatus(item?.status), @@ -282,13 +416,24 @@ export function policyTemplatePayloadFromForm(form: PolicyTemplateForm) { }; } -export function policyInstanceFormFromTemplate(template?: { templateCode?: string; templateVersion?: string; template_code?: string; template_version?: string } | null): PolicyInstanceForm { +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); + 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" : "", + instanceCode: + templateCode === DEFAULT_POLICY_TEMPLATE_CODE ? "huwaa_first_google70000_coin_seller_92000_100000" : "", regionId: "", regionScopeMode: "all_active_regions", status: "active", @@ -303,7 +448,10 @@ export function policyInstancePayloadFromForm(form: PolicyInstanceForm) { 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", + 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(), @@ -314,6 +462,7 @@ export function ruleFormFromJson(raw: unknown): PolicyRuleForm { const rule = parseRuleObject(raw); const host = objectValue(rule.host); const agent = objectValue(rule.agent); + const agency = objectValue(rule.agency); const differential = objectValue(agent.differential); const bd = objectValue(rule.bd); const manager = objectValue(rule.manager); @@ -321,6 +470,9 @@ export function ruleFormFromJson(raw: unknown): PolicyRuleForm { const tasks = objectValue(rule.tasks); const gameInvite = objectValue(rule.game_invite); return { + agency: { + pointRatioPercent: inputValue(agency.point_ratio_percent, 0), + }, agent: { differential: { crossLevelFormula: stringValue(differential.cross_level_formula, "parent_minus_direct_child"), @@ -384,12 +536,36 @@ export function ruleObjectFromForm(form: PolicyRuleForm): RuleObject { 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"]); + 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, + agency: { + ...objectValue(base.agency), + point_ratio_percent: numberValue(parsed.agency.pointRatioPercent), + }, allow_self_brushing: parsed.allowSelfBrushing, google_coin_per_usd: integerValue(parsed.googleCoinPerUsd), points_per_usd: integerValue(parsed.pointsPerUsd), @@ -408,7 +584,9 @@ export function ruleObjectFromForm(form: PolicyRuleForm): RuleObject { 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))) || {})), + 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), @@ -417,7 +595,9 @@ export function ruleObjectFromForm(form: PolicyRuleForm): RuleObject { bd: { ...baseBD, base: parsed.bd.base.trim(), - levels: parsed.bd.levels.map((level) => bdLevelToRule(level, baseBDLevelExtras.get(String(integerValue(level.level))) || {})), + 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), @@ -430,7 +610,9 @@ export function ruleObjectFromForm(form: PolicyRuleForm): RuleObject { }, coin_seller: { ...baseCoinSeller, - levels: parsed.coinSeller.levels.map((level) => coinSellerLevelToRule(level, baseCoinSellerLevelExtras.get(String(level.name || "").trim()) || {})), + 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), @@ -452,10 +634,16 @@ export function ruleObjectFromForm(form: PolicyRuleForm): RuleObject { export function defaultPolicyRule(): RuleObject { return { + agency: { point_ratio_percent: 0 }, 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 }, + 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, @@ -489,13 +677,22 @@ export function defaultPolicyRule(): RuleObject { { 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 }, + 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 }, + { + name: "Senior", + single_recharge_threshold_usd: 1000, + coin_per_usd: 100000, + withdraw_order_enabled: true, + }, ], order_timeout_seconds: 7200, seller_point_settle_percent: 95, @@ -510,7 +707,15 @@ export function defaultPolicyRule(): RuleObject { 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"]), + 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), @@ -537,7 +742,13 @@ 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"]), + 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, ""), @@ -593,16 +804,45 @@ function validateAgentLevels(context: z.RefinementCtx, levels: AgentLevelForm[]) 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"] }); + 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"); + 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"] }); + 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"); + requirePercent( + context, + ["agent", "levels", index, "ratioPercent"], + level.ratioPercent, + "Agent 提成比例必须在 0-100 之间", + false, + ); + requireNonNegativeInteger( + context, + ["agent", "levels", index, "qualifiedHostMin"], + level.qualifiedHostMin, + "Agent 合格主播数不能小于 0", + ); }); } @@ -610,18 +850,42 @@ 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) })) + .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"] }); + 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"] }); + 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); + requireNonNegativeNumber( + context, + ["bd", "levels", index, "salaryUsd"], + level.salaryUsd, + "BD 固定工资不能小于 0", + ); + requirePercent( + context, + ["bd", "levels", index, "commissionPercent"], + level.commissionPercent, + "BD 提成比例必须在 0-100 之间", + false, + ); previousThreshold = threshold; }); } @@ -629,17 +893,40 @@ function validateBDLevels(context: z.RefinementCtx, levels: BDLevelForm[]) { 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"] }); + context.addIssue({ + code: "custom", + message: "请输入币商等级名称", + path: ["coinSeller", "levels", index, "name"], + }); } - requirePositiveInteger(context, ["coinSeller", "levels", index, "coinPerUsd"], level.coinPerUsd, "币商金币单价必须大于 0"); + 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"] }); + context.addIssue({ + code: "custom", + message: "币商等级必须配置累计门槛或单次充值门槛", + path: ["coinSeller", "levels", index, "thresholdUsd"], + }); } if (String(level.thresholdUsd || "").trim()) { - requirePositiveNumber(context, ["coinSeller", "levels", index, "thresholdUsd"], level.thresholdUsd, "币商累计门槛必须大于 0"); + requirePositiveNumber( + context, + ["coinSeller", "levels", index, "thresholdUsd"], + level.thresholdUsd, + "币商累计门槛必须大于 0", + ); } if (String(level.singleRechargeThresholdUsd || "").trim()) { - requirePositiveNumber(context, ["coinSeller", "levels", index, "singleRechargeThresholdUsd"], level.singleRechargeThresholdUsd, "币商单次充值门槛必须大于 0"); + requirePositiveNumber( + context, + ["coinSeller", "levels", index, "singleRechargeThresholdUsd"], + level.singleRechargeThresholdUsd, + "币商单次充值门槛必须大于 0", + ); } }); } @@ -726,7 +1013,12 @@ function requirePositiveInteger(context: z.RefinementCtx, path: (number | string } } -function requireNonNegativeInteger(context: z.RefinementCtx, path: (number | string)[], value: unknown, message: string) { +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 }); @@ -740,21 +1032,39 @@ function requirePositiveNumber(context: z.RefinementCtx, path: (number | string) } } -function requireNonNegativeNumber(context: z.RefinementCtx, path: (number | string)[], value: unknown, message: string) { +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) { +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) { +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 }); diff --git a/src/styles/tokens.css b/src/styles/tokens.css index b86911e..d74ef72 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -78,6 +78,7 @@ --danger-border: rgba(220, 38, 38, 0.28); --danger-border-strong: rgba(220, 38, 38, 0.72); --danger-glow: rgba(220, 38, 38, 0.28); + --gender-female: #ec4899; --info: #0284c7; --info-surface: rgba(2, 132, 199, 0.1); --info-surface-strong: rgba(2, 132, 199, 0.14);