积分收益政策h5

This commit is contained in:
zhx 2026-07-12 17:38:51 +08:00
parent a4dd49c33d
commit 2f384687b5
11 changed files with 1300 additions and 148 deletions

View File

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

View File

@ -260,7 +260,7 @@
}
.genderAgeFemale {
background: var(--danger);
background: var(--gender-female);
}
.genderAgeMale {

View File

@ -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) => <SellerCell item={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) => (
<AdminRowActions>
<AdminActionIconButton label="编辑" onClick={() => openEdit(item)}>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton label="删除" onClick={() => remove(item)}>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
),
});
}
return base;
})();
return (
<AdminListPage>
<AdminListToolbar
actions={
canUpdate ? (
<Button startIcon={<AddOutlined />} variant="primary" onClick={openCreate}>
添加币商
</Button>
) : null
}
filters={<span>当前 App{appCode} · 配置为空时 H5 不展示币商提现选项</span>}
/>
<AdminListBody>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<DataTable
columns={columns}
items={query.data?.items || []}
minWidth="980px"
rowKey={(item) => item.sellerUserId}
/>
</DataState>
</AdminListBody>
<Dialog fullWidth maxWidth="sm" open={Boolean(editing)} onClose={closeDialog}>
<DialogTitle>{editing?.mode === "edit" ? "编辑币商提现配置" : "添加币商提现配置"}</DialogTitle>
<DialogContent sx={{ display: "grid", gap: 2, paddingTop: "16px !important" }}>
<TextField
disabled={editing?.mode === "edit"}
label="币商用户 ID"
required
value={form.sellerUserId}
onChange={(event) => setForm({ ...form, sellerUserId: event.target.value })}
/>
<TextField
label="排序"
required
type="number"
value={form.sortOrder}
onChange={(event) => setForm({ ...form, sortOrder: event.target.value })}
/>
<TextField
label="主播 POINT 数量"
required
type="number"
value={form.pointAmount}
onChange={(event) => setForm({ ...form, pointAmount: event.target.value })}
/>
<TextField
label="币商金币数量"
required
type="number"
value={form.sellerCoinAmount}
onChange={(event) => setForm({ ...form, sellerCoinAmount: event.target.value })}
/>
<TextField
helperText="使用 ISO 国家码,逗号分隔;留空表示全部国家"
label="服务国家"
value={form.serviceCountryCodes}
onChange={(event) => setForm({ ...form, serviceCountryCodes: event.target.value })}
/>
<TextField
select
label="状态"
value={form.status}
onChange={(event) => setForm({ ...form, status: event.target.value })}
>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem>
</TextField>
</DialogContent>
<DialogActions>
<Button disabled={saving} onClick={closeDialog}>
取消
</Button>
<Button disabled={saving} variant="primary" onClick={submit}>
保存
</Button>
</DialogActions>
</Dialog>
</AdminListPage>
);
}
function SellerCell({ item }) {
return (
<div>
<strong>{item.nickname || "未找到用户"}</strong>
<div>
{item.displayUserId || "-"} · UID {item.sellerUserId}
</div>
<small>{item.countryName || "-"}</small>
</div>
);
}
function formatNumber(value) {
return new Intl.NumberFormat("en-US").format(Number(value || 0));
}

View File

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

View File

@ -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<PointWithdrawalCoinSellerDto> {
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",
});
}

View File

@ -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,
},
];

View File

@ -71,14 +71,51 @@ function TemplateBaseFields({ disabled, form, setForm }) {
<h3>模板基础</h3>
</div>
<div className={styles.formGrid}>
<TextField required disabled={disabled} label="模板名称" size="small" value={form.name} onChange={(event) => patch({ name: event.target.value })} />
<TextField required disabled={disabled} label="模板编码" size="small" value={form.templateCode} onChange={(event) => patch({ templateCode: event.target.value })} />
<TextField required disabled={disabled} label="模板版本" size="small" value={form.templateVersion} onChange={(event) => patch({ templateVersion: event.target.value })} />
<TextField select disabled={disabled} label="状态" size="small" value={form.status} onChange={(event) => patch({ status: event.target.value })}>
<TextField
required
disabled={disabled}
label="模板名称"
size="small"
value={form.name}
onChange={(event) => patch({ name: event.target.value })}
/>
<TextField
required
disabled={disabled}
label="模板编码"
size="small"
value={form.templateCode}
onChange={(event) => patch({ templateCode: event.target.value })}
/>
<TextField
required
disabled={disabled}
label="模板版本"
size="small"
value={form.templateVersion}
onChange={(event) => patch({ templateVersion: event.target.value })}
/>
<TextField
select
disabled={disabled}
label="状态"
size="small"
value={form.status}
onChange={(event) => patch({ status: event.target.value })}
>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem>
</TextField>
<TextField className={styles.fieldWide} disabled={disabled} label="说明" maxRows={3} multiline size="small" value={form.description} onChange={(event) => patch({ description: event.target.value })} />
<TextField
className={styles.fieldWide}
disabled={disabled}
label="说明"
maxRows={3}
multiline
size="small"
value={form.description}
onChange={(event) => patch({ description: event.target.value })}
/>
</div>
</section>
);
@ -91,8 +128,18 @@ function GlobalRuleSection({ disabled, form, setForm }) {
<h3>全局口径</h3>
</div>
<div className={styles.formGrid}>
<NumberField disabled={disabled} label="POINT/USD" value={form.pointsPerUsd} onChange={(pointsPerUsd) => updateRule(setForm, { pointsPerUsd })} />
<NumberField disabled={disabled} label="Google 金币/USD" value={form.googleCoinPerUsd} onChange={(googleCoinPerUsd) => updateRule(setForm, { googleCoinPerUsd })} />
<NumberField
disabled={disabled}
label="POINT/USD"
value={form.pointsPerUsd}
onChange={(pointsPerUsd) => updateRule(setForm, { pointsPerUsd })}
/>
<NumberField
disabled={disabled}
label="Google 金币/USD"
value={form.googleCoinPerUsd}
onChange={(googleCoinPerUsd) => updateRule(setForm, { googleCoinPerUsd })}
/>
<SwitchField
checked={form.allowSelfBrushing}
disabled={disabled}
@ -111,9 +158,33 @@ function HostRuleSection({ disabled, form, setForm }) {
<h3>主播收益</h3>
</div>
<div className={styles.formGrid}>
<NumberField disabled={disabled} label="主播收益比例" suffix="%" value={form.host.pointRatioPercent} onChange={(pointRatioPercent) => updateSection(setForm, "host", { pointRatioPercent })} />
<NumberField disabled={disabled} label="最低提现积分" value={form.host.minimumWithdrawPoints} onChange={(minimumWithdrawPoints) => updateSection(setForm, "host", { minimumWithdrawPoints })} />
<NumberField disabled={disabled} label="提现手续费" suffix="bps" value={form.host.withdrawFeeBps} onChange={(withdrawFeeBps) => updateSection(setForm, "host", { withdrawFeeBps })} />
<NumberField
disabled={disabled}
label="主播收益比例"
suffix="%"
value={form.host.pointRatioPercent}
onChange={(pointRatioPercent) => updateSection(setForm, "host", { pointRatioPercent })}
/>
<NumberField
disabled={disabled}
label="Agency 分成比例"
suffix="%"
value={form.agency.pointRatioPercent}
onChange={(pointRatioPercent) => updateSection(setForm, "agency", { pointRatioPercent })}
/>
<NumberField
disabled={disabled}
label="最低提现积分"
value={form.host.minimumWithdrawPoints}
onChange={(minimumWithdrawPoints) => updateSection(setForm, "host", { minimumWithdrawPoints })}
/>
<NumberField
disabled={disabled}
label="提现手续费"
suffix="bps"
value={form.host.withdrawFeeBps}
onChange={(withdrawFeeBps) => updateSection(setForm, "host", { withdrawFeeBps })}
/>
<SwitchField
checked={form.host.affectsRoomHeat}
disabled={disabled}
@ -131,18 +202,60 @@ function AgentRuleSection({ disabled, form, setForm }) {
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>Agent 规则</h3>
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} onClick={() => addAgentLevel(setForm)}>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
onClick={() => addAgentLevel(setForm)}
>
添加等级
</Button>
</div>
<div className={styles.formGrid}>
<SelectField disabled={disabled} label="周期" options={agentPeriods} value={agent.period} onChange={(period) => updateSection(setForm, "agent", { period })} />
<NumberField disabled={disabled} label="首月扶持比例" suffix="%" value={agent.supportRatioPercent} onChange={(supportRatioPercent) => updateSection(setForm, "agent", { supportRatioPercent })} />
<NumberField disabled={disabled} label="扶持天数" value={agent.supportDays} onChange={(supportDays) => updateSection(setForm, "agent", { supportDays })} />
<NumberField disabled={disabled} label="合格主播数" value={agent.qualifiedHostMin} onChange={(qualifiedHostMin) => updateSection(setForm, "agent", { qualifiedHostMin })} />
<SwitchField checked={agent.differential.enabled} disabled={disabled} label="跨级差值" onChange={(enabled) => updateAgentDifferential(setForm, { enabled })} />
<NumberField disabled={disabled} label="差值深度" value={agent.differential.maxDepth} onChange={(maxDepth) => updateAgentDifferential(setForm, { maxDepth })} />
<TextField disabled={disabled} label="差值公式" size="small" value={agent.differential.crossLevelFormula} onChange={(event) => updateAgentDifferential(setForm, { crossLevelFormula: event.target.value })} />
<SelectField
disabled={disabled}
label="周期"
options={agentPeriods}
value={agent.period}
onChange={(period) => updateSection(setForm, "agent", { period })}
/>
<NumberField
disabled={disabled}
label="首月扶持比例"
suffix="%"
value={agent.supportRatioPercent}
onChange={(supportRatioPercent) => updateSection(setForm, "agent", { supportRatioPercent })}
/>
<NumberField
disabled={disabled}
label="扶持天数"
value={agent.supportDays}
onChange={(supportDays) => updateSection(setForm, "agent", { supportDays })}
/>
<NumberField
disabled={disabled}
label="合格主播数"
value={agent.qualifiedHostMin}
onChange={(qualifiedHostMin) => updateSection(setForm, "agent", { qualifiedHostMin })}
/>
<SwitchField
checked={agent.differential.enabled}
disabled={disabled}
label="跨级差值"
onChange={(enabled) => updateAgentDifferential(setForm, { enabled })}
/>
<NumberField
disabled={disabled}
label="差值深度"
value={agent.differential.maxDepth}
onChange={(maxDepth) => updateAgentDifferential(setForm, { maxDepth })}
/>
<TextField
disabled={disabled}
label="差值公式"
size="small"
value={agent.differential.crossLevelFormula}
onChange={(event) => updateAgentDifferential(setForm, { crossLevelFormula: event.target.value })}
/>
</div>
<div className={styles.levelTable}>
<div className={styles.agentLevelHeader}>
@ -157,14 +270,56 @@ function AgentRuleSection({ disabled, form, setForm }) {
</div>
{agent.levels.map((level, index) => (
<div className={styles.agentLevelRow} key={`${level.level}-${index}`}>
<NumberField bare disabled={disabled} value={level.level} onChange={(value) => updateAgentLevel(setForm, index, { level: value })} />
<TextField disabled={disabled} size="small" value={level.name} onChange={(event) => updateAgentLevel(setForm, index, { name: event.target.value })} />
<NumberField bare disabled={disabled} value={level.minUsd} onChange={(value) => updateAgentLevel(setForm, index, { minUsd: value })} />
<NumberField bare disabled={disabled} value={level.maxUsd} onChange={(value) => updateAgentLevel(setForm, index, { maxUsd: value })} />
<NumberField bare disabled={disabled} suffix="%" value={level.ratioPercent} onChange={(value) => updateAgentLevel(setForm, index, { ratioPercent: value })} />
<NumberField bare disabled={disabled} value={level.qualifiedHostMin} onChange={(value) => updateAgentLevel(setForm, index, { qualifiedHostMin: value })} />
<AdminSwitch checked={level.manualReview} disabled={disabled} label={`Agent 等级 ${index + 1} 人工审核`} onChange={(event) => updateAgentLevel(setForm, index, { manualReview: event.target.checked })} />
<RemoveButton disabled={disabled || agent.levels.length <= 1} label="删除 Agent 等级" onClick={() => removeAgentLevel(setForm, index)} />
<NumberField
bare
disabled={disabled}
value={level.level}
onChange={(value) => updateAgentLevel(setForm, index, { level: value })}
/>
<TextField
disabled={disabled}
size="small"
value={level.name}
onChange={(event) => updateAgentLevel(setForm, index, { name: event.target.value })}
/>
<NumberField
bare
disabled={disabled}
value={level.minUsd}
onChange={(value) => updateAgentLevel(setForm, index, { minUsd: value })}
/>
<NumberField
bare
disabled={disabled}
value={level.maxUsd}
onChange={(value) => updateAgentLevel(setForm, index, { maxUsd: value })}
/>
<NumberField
bare
disabled={disabled}
suffix="%"
value={level.ratioPercent}
onChange={(value) => updateAgentLevel(setForm, index, { ratioPercent: value })}
/>
<NumberField
bare
disabled={disabled}
value={level.qualifiedHostMin}
onChange={(value) => updateAgentLevel(setForm, index, { qualifiedHostMin: value })}
/>
<AdminSwitch
checked={level.manualReview}
disabled={disabled}
label={`Agent 等级 ${index + 1} 人工审核`}
onChange={(event) =>
updateAgentLevel(setForm, index, { manualReview: event.target.checked })
}
/>
<RemoveButton
disabled={disabled || agent.levels.length <= 1}
label="删除 Agent 等级"
onClick={() => removeAgentLevel(setForm, index)}
/>
</div>
))}
</div>
@ -178,15 +333,43 @@ function BDRuleSection({ disabled, form, setForm }) {
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>BD 规则</h3>
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} onClick={() => addBDLevel(setForm)}>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
onClick={() => addBDLevel(setForm)}
>
添加等级
</Button>
</div>
<div className={styles.formGrid}>
<SelectField disabled={disabled} label="周期" options={bdPeriods} value={bd.period} onChange={(period) => updateSection(setForm, "bd", { period })} />
<TextField disabled={disabled} label="结算基准" size="small" value={bd.base} onChange={(event) => updateSection(setForm, "bd", { base: event.target.value })} />
<NumberField disabled={disabled} label="合格 Agency 数" value={bd.qualifiedAgencyMin} onChange={(qualifiedAgencyMin) => updateSection(setForm, "bd", { qualifiedAgencyMin })} />
<NumberField disabled={disabled} label="每 Agency 主播数" value={bd.qualifiedHostPerAgencyMin} onChange={(qualifiedHostPerAgencyMin) => updateSection(setForm, "bd", { qualifiedHostPerAgencyMin })} />
<SelectField
disabled={disabled}
label="周期"
options={bdPeriods}
value={bd.period}
onChange={(period) => updateSection(setForm, "bd", { period })}
/>
<TextField
disabled={disabled}
label="结算基准"
size="small"
value={bd.base}
onChange={(event) => updateSection(setForm, "bd", { base: event.target.value })}
/>
<NumberField
disabled={disabled}
label="合格 Agency 数"
value={bd.qualifiedAgencyMin}
onChange={(qualifiedAgencyMin) => updateSection(setForm, "bd", { qualifiedAgencyMin })}
/>
<NumberField
disabled={disabled}
label="每 Agency 主播数"
value={bd.qualifiedHostPerAgencyMin}
onChange={(qualifiedHostPerAgencyMin) =>
updateSection(setForm, "bd", { qualifiedHostPerAgencyMin })
}
/>
</div>
<div className={styles.levelTable}>
<div className={styles.bdLevelHeader}>
@ -199,12 +382,43 @@ function BDRuleSection({ disabled, form, setForm }) {
</div>
{bd.levels.map((level, index) => (
<div className={styles.bdLevelRow} key={`${level.level}-${index}`}>
<NumberField bare disabled={disabled} value={level.level} onChange={(value) => updateBDLevel(setForm, index, { level: value })} />
<TextField disabled={disabled} size="small" value={level.name} onChange={(event) => updateBDLevel(setForm, index, { name: event.target.value })} />
<NumberField bare disabled={disabled} value={level.thresholdUsd} onChange={(value) => updateBDLevel(setForm, index, { thresholdUsd: value })} />
<NumberField bare disabled={disabled} prefix="$" value={level.salaryUsd} onChange={(value) => updateBDLevel(setForm, index, { salaryUsd: value })} />
<NumberField bare disabled={disabled} suffix="%" value={level.commissionPercent} onChange={(value) => updateBDLevel(setForm, index, { commissionPercent: value })} />
<RemoveButton disabled={disabled || bd.levels.length <= 1} label="删除 BD 等级" onClick={() => removeBDLevel(setForm, index)} />
<NumberField
bare
disabled={disabled}
value={level.level}
onChange={(value) => updateBDLevel(setForm, index, { level: value })}
/>
<TextField
disabled={disabled}
size="small"
value={level.name}
onChange={(event) => updateBDLevel(setForm, index, { name: event.target.value })}
/>
<NumberField
bare
disabled={disabled}
value={level.thresholdUsd}
onChange={(value) => updateBDLevel(setForm, index, { thresholdUsd: value })}
/>
<NumberField
bare
disabled={disabled}
prefix="$"
value={level.salaryUsd}
onChange={(value) => updateBDLevel(setForm, index, { salaryUsd: value })}
/>
<NumberField
bare
disabled={disabled}
suffix="%"
value={level.commissionPercent}
onChange={(value) => updateBDLevel(setForm, index, { commissionPercent: value })}
/>
<RemoveButton
disabled={disabled || bd.levels.length <= 1}
label="删除 BD 等级"
onClick={() => removeBDLevel(setForm, index)}
/>
</div>
))}
</div>
@ -219,9 +433,33 @@ function ManagerRuleSection({ disabled, form, setForm }) {
<h3>经理规则</h3>
</div>
<div className={styles.formGrid}>
<NumberField disabled={disabled} label="币商充值门槛" prefix="$" value={form.manager.coinSellerRechargeThresholdUsd} onChange={(coinSellerRechargeThresholdUsd) => updateSection(setForm, "manager", { coinSellerRechargeThresholdUsd })} />
<NumberField disabled={disabled} label="币商充值提成" suffix="%" value={form.manager.coinSellerRechargeCommissionPercent} onChange={(coinSellerRechargeCommissionPercent) => updateSection(setForm, "manager", { coinSellerRechargeCommissionPercent })} />
<NumberField disabled={disabled} label="主播收益提成" suffix="%" value={form.manager.hostPointCommissionPercent} onChange={(hostPointCommissionPercent) => updateSection(setForm, "manager", { hostPointCommissionPercent })} />
<NumberField
disabled={disabled}
label="币商充值门槛"
prefix="$"
value={form.manager.coinSellerRechargeThresholdUsd}
onChange={(coinSellerRechargeThresholdUsd) =>
updateSection(setForm, "manager", { coinSellerRechargeThresholdUsd })
}
/>
<NumberField
disabled={disabled}
label="币商充值提成"
suffix="%"
value={form.manager.coinSellerRechargeCommissionPercent}
onChange={(coinSellerRechargeCommissionPercent) =>
updateSection(setForm, "manager", { coinSellerRechargeCommissionPercent })
}
/>
<NumberField
disabled={disabled}
label="主播收益提成"
suffix="%"
value={form.manager.hostPointCommissionPercent}
onChange={(hostPointCommissionPercent) =>
updateSection(setForm, "manager", { hostPointCommissionPercent })
}
/>
</div>
</section>
);
@ -233,16 +471,55 @@ function CoinSellerRuleSection({ disabled, form, setForm }) {
<section className={styles.formSection}>
<div className={styles.sectionHeader}>
<h3>币商规则</h3>
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} onClick={() => addCoinSellerLevel(setForm)}>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
onClick={() => addCoinSellerLevel(setForm)}
>
添加档位
</Button>
</div>
<div className={styles.formGrid}>
<SwitchField checked={coinSeller.withdrawOrderEnabled} disabled={disabled} label="提现订单" onChange={(withdrawOrderEnabled) => updateSection(setForm, "coinSeller", { withdrawOrderEnabled })} />
<NumberField disabled={disabled} label="订单超时" suffix="秒" value={coinSeller.orderTimeoutSeconds} onChange={(orderTimeoutSeconds) => updateSection(setForm, "coinSeller", { orderTimeoutSeconds })} />
<NumberField disabled={disabled} label="币商结算" suffix="%" value={coinSeller.sellerPointSettlePercent} onChange={(sellerPointSettlePercent) => updateSection(setForm, "coinSeller", { sellerPointSettlePercent })} />
<NumberField disabled={disabled} label="币商奖励" suffix="%" value={coinSeller.sellerPointRewardPercent} onChange={(sellerPointRewardPercent) => updateSection(setForm, "coinSeller", { sellerPointRewardPercent })} />
<NumberField disabled={disabled} label="成功率关闭门槛" suffix="%" value={coinSeller.successRateCloseThresholdPercent} onChange={(successRateCloseThresholdPercent) => updateSection(setForm, "coinSeller", { successRateCloseThresholdPercent })} />
<SwitchField
checked={coinSeller.withdrawOrderEnabled}
disabled={disabled}
label="提现订单"
onChange={(withdrawOrderEnabled) => updateSection(setForm, "coinSeller", { withdrawOrderEnabled })}
/>
<NumberField
disabled={disabled}
label="订单超时"
suffix="秒"
value={coinSeller.orderTimeoutSeconds}
onChange={(orderTimeoutSeconds) => updateSection(setForm, "coinSeller", { orderTimeoutSeconds })}
/>
<NumberField
disabled={disabled}
label="币商结算"
suffix="%"
value={coinSeller.sellerPointSettlePercent}
onChange={(sellerPointSettlePercent) =>
updateSection(setForm, "coinSeller", { sellerPointSettlePercent })
}
/>
<NumberField
disabled={disabled}
label="币商奖励"
suffix="%"
value={coinSeller.sellerPointRewardPercent}
onChange={(sellerPointRewardPercent) =>
updateSection(setForm, "coinSeller", { sellerPointRewardPercent })
}
/>
<NumberField
disabled={disabled}
label="成功率关闭门槛"
suffix="%"
value={coinSeller.successRateCloseThresholdPercent}
onChange={(successRateCloseThresholdPercent) =>
updateSection(setForm, "coinSeller", { successRateCloseThresholdPercent })
}
/>
</div>
<div className={styles.levelTable}>
<div className={styles.coinSellerLevelHeader}>
@ -255,12 +532,47 @@ function CoinSellerRuleSection({ disabled, form, setForm }) {
</div>
{coinSeller.levels.map((level, index) => (
<div className={styles.coinSellerLevelRow} key={`${level.name}-${index}`}>
<TextField disabled={disabled} size="small" value={level.name} onChange={(event) => updateCoinSellerLevel(setForm, index, { name: event.target.value })} />
<NumberField bare disabled={disabled} prefix="$" value={level.thresholdUsd} onChange={(value) => updateCoinSellerLevel(setForm, index, { thresholdUsd: value })} />
<NumberField bare disabled={disabled} prefix="$" value={level.singleRechargeThresholdUsd} onChange={(value) => updateCoinSellerLevel(setForm, index, { singleRechargeThresholdUsd: value })} />
<NumberField bare disabled={disabled} value={level.coinPerUsd} onChange={(value) => updateCoinSellerLevel(setForm, index, { coinPerUsd: value })} />
<AdminSwitch checked={level.withdrawOrderEnabled} disabled={disabled} label={`币商档位 ${index + 1} 提现订单`} onChange={(event) => updateCoinSellerLevel(setForm, index, { withdrawOrderEnabled: event.target.checked })} />
<RemoveButton disabled={disabled || coinSeller.levels.length <= 1} label="删除币商档位" onClick={() => removeCoinSellerLevel(setForm, index)} />
<TextField
disabled={disabled}
size="small"
value={level.name}
onChange={(event) => updateCoinSellerLevel(setForm, index, { name: event.target.value })}
/>
<NumberField
bare
disabled={disabled}
prefix="$"
value={level.thresholdUsd}
onChange={(value) => updateCoinSellerLevel(setForm, index, { thresholdUsd: value })}
/>
<NumberField
bare
disabled={disabled}
prefix="$"
value={level.singleRechargeThresholdUsd}
onChange={(value) =>
updateCoinSellerLevel(setForm, index, { singleRechargeThresholdUsd: value })
}
/>
<NumberField
bare
disabled={disabled}
value={level.coinPerUsd}
onChange={(value) => updateCoinSellerLevel(setForm, index, { coinPerUsd: value })}
/>
<AdminSwitch
checked={level.withdrawOrderEnabled}
disabled={disabled}
label={`币商档位 ${index + 1} 提现订单`}
onChange={(event) =>
updateCoinSellerLevel(setForm, index, { withdrawOrderEnabled: event.target.checked })
}
/>
<RemoveButton
disabled={disabled || coinSeller.levels.length <= 1}
label="删除币商档位"
onClick={() => removeCoinSellerLevel(setForm, index)}
/>
</div>
))}
</div>
@ -275,10 +587,32 @@ function TaskAndGameRuleSection({ disabled, form, setForm }) {
<h3>任务与游戏邀请</h3>
</div>
<div className={styles.formGrid}>
<SelectField disabled={disabled} label="任务奖励资产" options={taskAssets} value={form.tasks.rewardAssetType} onChange={(rewardAssetType) => updateSection(setForm, "tasks", { rewardAssetType })} />
<NumberField disabled={disabled} label="新主播 7 日任务上限" value={form.tasks.newHost7dMaxPoints} onChange={(newHost7dMaxPoints) => updateSection(setForm, "tasks", { newHost7dMaxPoints })} />
<SwitchField checked={form.gameInvite.enabled} disabled={disabled} label="游戏邀请返利" onChange={(enabled) => updateSection(setForm, "gameInvite", { enabled })} />
<SelectField disabled={disabled} label="游戏邀请周期" options={gameInvitePeriods} value={form.gameInvite.period} onChange={(period) => updateSection(setForm, "gameInvite", { period })} />
<SelectField
disabled={disabled}
label="任务奖励资产"
options={taskAssets}
value={form.tasks.rewardAssetType}
onChange={(rewardAssetType) => updateSection(setForm, "tasks", { rewardAssetType })}
/>
<NumberField
disabled={disabled}
label="新主播 7 日任务上限"
value={form.tasks.newHost7dMaxPoints}
onChange={(newHost7dMaxPoints) => updateSection(setForm, "tasks", { newHost7dMaxPoints })}
/>
<SwitchField
checked={form.gameInvite.enabled}
disabled={disabled}
label="游戏邀请返利"
onChange={(enabled) => updateSection(setForm, "gameInvite", { enabled })}
/>
<SelectField
disabled={disabled}
label="游戏邀请周期"
options={gameInvitePeriods}
value={form.gameInvite.period}
onChange={(period) => updateSection(setForm, "gameInvite", { period })}
/>
</div>
</section>
);
@ -306,7 +640,14 @@ function NumberField({ bare = false, disabled, label, onChange, prefix, suffix,
function SelectField({ disabled, label, onChange, options, value }) {
return (
<TextField select disabled={disabled} label={label} size="small" value={value} onChange={(event) => onChange(event.target.value)}>
<TextField
select
disabled={disabled}
label={label}
size="small"
value={value}
onChange={(event) => onChange(event.target.value)}
>
{options.map(([optionValue, optionLabel]) => (
<MenuItem key={optionValue} value={optionValue}>
{optionLabel}
@ -320,7 +661,14 @@ function SwitchField({ checked, disabled, label, onChange }) {
return (
<div className={styles.switchField}>
<span>{label}</span>
<AdminSwitch checked={checked} checkedLabel="开" disabled={disabled} label={label} uncheckedLabel="关" onChange={(event) => onChange(event.target.checked)} />
<AdminSwitch
checked={checked}
checkedLabel="开"
disabled={disabled}
label={label}
uncheckedLabel="关"
onChange={(event) => onChange(event.target.checked)}
/>
</div>
);
}
@ -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,
),
},
},
}));

View File

@ -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) => <TemplateActions item={item} page={page} /> } : column,
column.key === "actions"
? { ...column, render: (item) => <TemplateActions item={item} page={page} /> }
: column,
);
return (
<>
<AdminListToolbar
actions={
page.abilities.canSaveTemplate ? (
<AdminActionIconButton label="新增收益政策模板" primary onClick={() => page.openTemplateEditor(null)}>
<AdminActionIconButton
label="新增收益政策模板"
primary
onClick={() => page.openTemplateEditor(null)}
>
<AddOutlined fontSize="small" />
</AdminActionIconButton>
) : null
}
filters={
<div className={styles.toolbarFilters}>
<AdminSearchBox label="模板" placeholder="搜索模板名称或编码" value={page.templateFilters.keyword} onChange={page.changeTemplateKeyword} />
<AdminFilterSelect label="状态" options={statusOptions} value={page.templateFilters.status} onChange={page.changeTemplateStatus} />
<AdminFilterResetButton disabled={!page.templateFilters.keyword && !page.templateFilters.status} onClick={page.resetTemplateFilters} />
<AdminSearchBox
label="模板"
placeholder="搜索模板名称或编码"
value={page.templateFilters.keyword}
onChange={page.changeTemplateKeyword}
/>
<AdminFilterSelect
label="状态"
options={statusOptions}
value={page.templateFilters.status}
onChange={page.changeTemplateStatus}
/>
<AdminFilterResetButton
disabled={!page.templateFilters.keyword && !page.templateFilters.status}
onClick={page.resetTemplateFilters}
/>
</div>
}
/>
@ -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}`}
/>
</AdminListBody>
@ -177,16 +205,33 @@ function InstancePanel({ page }) {
<AdminListToolbar
actions={
page.abilities.canCreateInstance ? (
<AdminActionIconButton label="创建策略实例" primary onClick={() => page.openInstanceCreator(null)}>
<AdminActionIconButton
label="创建策略实例"
primary
onClick={() => page.openInstanceCreator(null)}
>
<AddOutlined fontSize="small" />
</AdminActionIconButton>
) : null
}
filters={
<div className={styles.toolbarFilters}>
<AdminSearchBox label="实例" placeholder="搜索实例或模板编码" value={page.instanceFilters.keyword} onChange={page.changeInstanceKeyword} />
<AdminFilterSelect label="状态" options={statusOptions} value={page.instanceFilters.status} onChange={page.changeInstanceStatus} />
<AdminFilterResetButton disabled={!page.instanceFilters.keyword && !page.instanceFilters.status} onClick={page.resetInstanceFilters} />
<AdminSearchBox
label="实例"
placeholder="搜索实例或模板编码"
value={page.instanceFilters.keyword}
onChange={page.changeInstanceKeyword}
/>
<AdminFilterSelect
label="状态"
options={statusOptions}
value={page.instanceFilters.status}
onChange={page.changeInstanceStatus}
/>
<AdminFilterResetButton
disabled={!page.instanceFilters.keyword && !page.instanceFilters.status}
onClick={page.resetInstanceFilters}
/>
</div>
}
/>
@ -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}
/>
</AdminListBody>
@ -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 (
<div className={styles.stack}>
<span>{formatNumber(rule.points_per_usd)} POINT/USD</span>
<span className={styles.meta}>主播 {formatNumber(host.point_ratio_percent)}% POINT</span>
<span className={styles.meta}>Agency {formatNumber(agency.point_ratio_percent || 0)}% 分成</span>
<span className={styles.meta}>提现手续费 {formatNumber(host.withdraw_fee_bps)} bps</span>
<span className={styles.meta}>任务资产 {tasks.reward_asset_type || "-"}</span>
</div>
@ -237,7 +293,9 @@ function TeamPolicySummary({ item }) {
const coinSellerLevels = Array.isArray(rule.coin_seller?.levels) ? rule.coin_seller.levels : [];
return (
<div className={styles.stack}>
<span>Agent {agentLevels.length} · BD {bdLevels.length} </span>
<span>
Agent {agentLevels.length} · BD {bdLevels.length}
</span>
<span className={styles.meta}>币商 {coinSellerLevels.length} </span>
<span className={styles.meta}>{rule.allow_self_brushing ? "允许自刷" : "不允许自刷"}</span>
</div>
@ -272,7 +330,9 @@ function InstanceScope({ item, regionLabelMap = {} }) {
<div className={styles.stack}>
<span>{regionScopeLabel(item.regionScope, regionLabelMap)}</span>
<span className={styles.meta}>开始 {formatMillis(item.effectiveFromMs)}</span>
<span className={styles.meta}>结束 {item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"}</span>
<span className={styles.meta}>
结束 {item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"}
</span>
</div>
);
}
@ -281,7 +341,9 @@ function PublishSummary({ item }) {
return (
<div className={styles.stack}>
<span>{publishStatusLabel(item.publishStatus)}</span>
<span className={styles.meta}>{item.lastPublishedAtMs ? formatMillis(item.lastPublishedAtMs) : "未发布"}</span>
<span className={styles.meta}>
{item.lastPublishedAtMs ? formatMillis(item.lastPublishedAtMs) : "未发布"}
</span>
{item.publishError ? <span className={styles.errorText}>{item.publishError}</span> : null}
</div>
);

View File

@ -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", () => {

View File

@ -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<PolicyTemplateForm> & { ruleJson?: unknown; rule_json?: unknown; template_code?: string; template_version?: string } | null): PolicyTemplateForm {
export function policyTemplateFormFromItem(
item?:
| (Partial<PolicyTemplateForm> & {
ruleJson?: unknown;
rule_json?: unknown;
template_code?: string;
template_version?: string;
})
| null,
): PolicyTemplateForm {
const rule = ruleFormFromJson(item?.ruleJson ?? item?.rule_json ?? defaultPolicyRule());
return {
description: stringValue(item?.description, "新文档口径允许自刷BD 按 UTC 当月自然月Huwaa 使用 POINT/COIN_SELLER_POINT。"),
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 必须大于最小 USD0 表示无上限", path: ["agent", "levels", index, "maxUsd"] });
context.addIssue({
code: "custom",
message: "Agent 最大 USD 必须大于最小 USD0 表示无上限",
path: ["agent", "levels", index, "maxUsd"],
});
}
requirePercent(context, ["agent", "levels", index, "ratioPercent"], level.ratioPercent, "Agent 提成比例必须在 0-100 之间", false);
requireNonNegativeInteger(context, ["agent", "levels", index, "qualifiedHostMin"], level.qualifiedHostMin, "Agent 合格主播数不能小于 0");
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<number>();
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 });

View File

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