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}
+ />
+
+
+
+
+ );
+}
+
+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 规则
- } onClick={() => addAgentLevel(setForm)}>
+ }
+ onClick={() => addAgentLevel(setForm)}
+ >
添加等级
- 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 规则
- } onClick={() => addBDLevel(setForm)}>
+ }
+ onClick={() => addBDLevel(setForm)}
+ >
添加等级
- 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 }) {
币商规则
- } onClick={() => addCoinSellerLevel(setForm)}>
+ }
+ onClick={() => addCoinSellerLevel(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]) => (