公会相关

This commit is contained in:
zhx 2026-06-04 23:05:10 +08:00
parent ca194e46a3
commit 0f92efea87
20 changed files with 712 additions and 43 deletions

View File

@ -957,6 +957,48 @@
"x-permissions": ["coin-seller:stock-credit"]
}
},
"/admin/coin-seller-salary-rates/{region_id}": {
"get": {
"operationId": "getCoinSellerSalaryRates",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "region_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "coin-seller:exchange-rate",
"x-permissions": ["coin-seller:exchange-rate"]
},
"put": {
"operationId": "replaceCoinSellerSalaryRates",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "region_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "coin-seller:exchange-rate",
"x-permissions": ["coin-seller:exchange-rate"]
}
},
"/admin/countries": {
"get": {
"operationId": "listCountries",

View File

@ -36,6 +36,7 @@ export const PERMISSIONS = {
coinSellerCreate: "coin-seller:create",
coinSellerUpdate: "coin-seller:update",
coinSellerStockCredit: "coin-seller:stock-credit",
coinSellerExchangeRate: "coin-seller:exchange-rate",
coinLedgerView: "coin-ledger:view",
coinAdjustmentView: "coin-adjustment:view",
coinAdjustmentCreate: "coin-adjustment:create",

View File

@ -35,10 +35,17 @@
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex: 0 0 auto;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.selectedMeta {
color: var(--text-secondary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.filters {
display: flex;
min-width: 0;

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
@ -28,6 +28,8 @@ export function useAppUsersPage() {
const [countryForm, setCountryForm] = useState(emptyCountryForm);
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
const [loadingAction, setLoadingAction] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState([]);
const [patchedUsers, setPatchedUsers] = useState({});
const filters = useMemo(
() => ({
keyword: query,
@ -36,7 +38,7 @@ export function useAppUsersPage() {
[query, status],
);
const {
data = emptyData,
data: queryData = emptyData,
error,
loading,
reload,
@ -48,6 +50,18 @@ export function useAppUsersPage() {
pageSize,
queryKey: ["app-users", filters, page],
});
const data = useMemo(() => {
const items = queryData.items || [];
return {
...queryData,
items: items.map((user) => (patchedUsers[user.userId] ? { ...user, ...patchedUsers[user.userId] } : user)),
};
}, [patchedUsers, queryData]);
useEffect(() => {
setSelectedUserIds([]);
setPatchedUsers({});
}, [page, query, status]);
const changeQuery = (value) => {
setQuery(value);
@ -132,23 +146,58 @@ export function useAppUsersPage() {
const toggleBan = async (user) => {
const banned = isBanned(user);
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
const result = banned ? await unbanAppUser(user.userId) : await banAppUser(user.userId);
patchUsers([mergeUserStatus(user, result, banned ? "active" : "banned")]);
setSelectedUserIds((current) => current.filter((userId) => userId !== user.userId));
});
};
const batchBan = async () => {
const users = (data.items || []).filter((user) => selectedUserIds.includes(user.userId) && !isBanned(user));
if (!users.length) {
showToast("请先选择未封禁用户", "warning");
return;
}
const ok = await confirm({
confirmText: banned ? "解封" : "封禁",
message: `${user.username || user.displayUserId || user.userId} 的状态会立即变更。`,
title: banned ? "解封用户" : "封禁用户",
tone: banned ? "primary" : "danger",
confirmText: "封禁",
message: `将封禁 ${users.length} 个用户`,
title: "批量封禁用户",
tone: "danger",
});
if (!ok) {
return;
}
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
if (banned) {
await unbanAppUser(user.userId);
} else {
await banAppUser(user.userId);
setLoadingAction("batch-ban");
try {
const results = await Promise.allSettled(users.map((user) => banAppUser(user.userId)));
const updatedUsers = results
.map((result, index) =>
result.status === "fulfilled" ? mergeUserStatus(users[index], result.value, "banned") : null,
)
.filter(Boolean);
const failedCount = results.length - updatedUsers.length;
if (updatedUsers.length) {
patchUsers(updatedUsers);
setSelectedUserIds((current) =>
current.filter((userId) => !updatedUsers.some((updatedUser) => updatedUser.userId === userId)),
);
}
await reload();
});
if (failedCount > 0) {
showToast(
updatedUsers.length ? `已封禁 ${updatedUsers.length} 个,${failedCount} 个失败` : "批量封禁失败",
updatedUsers.length ? "warning" : "error",
);
return;
}
showToast(`已封禁 ${updatedUsers.length} 个用户`, "success");
} finally {
setLoadingAction("");
}
};
const runAction = async (action, successMessage, submitter) => {
@ -163,10 +212,24 @@ export function useAppUsersPage() {
}
};
const patchUsers = (users) => {
setPatchedUsers((current) => {
const next = { ...current };
users.forEach((user) => {
if (!user?.userId) {
return;
}
next[user.userId] = { ...(next[user.userId] || {}), ...user };
});
return next;
});
};
return {
abilities,
activeAction,
activeUser,
batchBan,
changeQuery,
changeStatus,
closeAction,
@ -186,10 +249,12 @@ export function useAppUsersPage() {
query,
reload,
resetFilters,
selectedUserIds,
setCountryForm,
setEditForm,
setPage,
setPasswordForm,
setSelectedUserIds,
status,
submitCountry,
submitEdit,
@ -201,3 +266,11 @@ export function useAppUsersPage() {
function isBanned(user) {
return user.status === "banned" || user.status === "disabled";
}
function mergeUserStatus(currentUser, result, fallbackStatus) {
return {
...currentUser,
...(result || {}),
status: result?.status || fallbackStatus,
};
}

View File

@ -4,10 +4,12 @@ import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import Checkbox from "@mui/material/Checkbox";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
@ -49,7 +51,51 @@ export function AppUserListPage() {
const page = useAppUsersPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const selectableItems = items.filter((user) => !isBannedUser(user));
const allChecked =
selectableItems.length > 0 && selectableItems.every((user) => page.selectedUserIds.includes(user.userId));
const selectedOnPageCount = selectableItems.filter((user) => page.selectedUserIds.includes(user.userId)).length;
const someChecked = selectedOnPageCount > 0 && !allChecked;
const toggleAll = (checked) => {
if (!checked) {
page.setSelectedUserIds((current) =>
current.filter((userId) => !selectableItems.some((user) => user.userId === userId)),
);
return;
}
page.setSelectedUserIds((current) => Array.from(new Set([...current, ...selectableItems.map((user) => user.userId)])));
};
const tableColumns = [
{
key: "select",
header: (
<Checkbox
checked={allChecked}
disabled={!page.abilities.canStatus || selectableItems.length === 0}
indeterminate={someChecked}
size="small"
onChange={(event) => toggleAll(event.target.checked)}
/>
),
width: "52px",
render: (user) => {
const banned = isBannedUser(user);
return (
<Checkbox
checked={page.selectedUserIds.includes(user.userId)}
disabled={!page.abilities.canStatus || banned || page.loadingAction === "batch-ban"}
size="small"
onChange={(event) => {
page.setSelectedUserIds((current) =>
event.target.checked
? Array.from(new Set([...current, user.userId]))
: current.filter((userId) => userId !== user.userId),
);
}}
/>
);
},
},
{
...columns[0],
filter: createTextColumnFilter({
@ -90,6 +136,19 @@ export function AppUserListPage() {
<div className={styles.contentPanel}>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
{page.abilities.canStatus ? (
<div className={styles.toolbar}>
<span className={styles.selectedMeta}>已选 {page.selectedUserIds.length} </span>
<Button
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
startIcon={<BlockOutlined fontSize="small" />}
variant="danger"
onClick={page.batchBan}
>
批量封禁
</Button>
</div>
) : null}
<DataTable
columns={tableColumns}
items={items}
@ -310,3 +369,7 @@ function formatNumber(value) {
function formatCountry(user) {
return user.countryDisplayName || user.countryName || user.country || "-";
}
function isBannedUser(user) {
return user.status === "banned" || user.status === "disabled";
}

View File

@ -1,5 +1,6 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
@ -119,19 +120,18 @@ function PolicyBaseFields({ disabled, page }) {
required
className={styles.fieldCompact}
disabled={disabled}
label="金币转钻石比例"
helperText="0 表示不折算"
label="剩余钻石兑美元"
size="small"
value={form.giftCoinToDiamondRatio}
onChange={(event) => setForm({ giftCoinToDiamondRatio: event.target.value })}
/>
<TextField
required
className={styles.fieldCompact}
disabled={disabled}
label="剩余钻石转美元比例"
size="small"
value={form.residualDiamondToUsdRate}
onChange={(event) => setForm({ residualDiamondToUsdRate: event.target.value })}
slotProps={{
htmlInput: { min: 0, step: 1 },
input: {
endAdornment: <InputAdornment position="end">钻石=1美元</InputAdornment>,
},
}}
type="number"
value={form.residualDiamondsPerUsd}
onChange={(event) => setForm({ residualDiamondsPerUsd: event.target.value })}
/>
<TimeRangeFilter
className={styles.timeRangeField}

View File

@ -0,0 +1,54 @@
import { render, screen } from "@testing-library/react";
import { vi, test, expect } from "vitest";
import { HostAgencyPolicyDrawer } from "@/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx";
test("host agency policy drawer shows residual diamonds per USD and hides gift ratio input", () => {
render(<HostAgencyPolicyDrawer page={drawerPage()} />);
expect(screen.queryByLabelText("金币转钻石比例")).not.toBeInTheDocument();
expect(screen.getByText("剩余钻石兑美元")).toBeInTheDocument();
expect(screen.getByDisplayValue("100")).toBeInTheDocument();
expect(screen.getByText("钻石=1美元")).toBeInTheDocument();
expect(screen.getByText("0 表示不折算")).toBeInTheDocument();
});
function drawerPage() {
return {
abilities: {
canCreate: true,
canUpdate: true,
},
addLevel: vi.fn(),
closeDrawer: vi.fn(),
drawerOpen: true,
editingPolicy: null,
form: {
effectiveFrom: "",
effectiveTo: "",
enabled: false,
levels: [
{
agencySalaryUsd: "0.5",
hostCoinReward: "90000",
hostSalaryUsd: "1.5",
level: "1",
requiredDiamonds: "350000",
sortOrder: "1",
status: "active",
},
],
name: "Lalu Host & Agency Salary Policy",
regionId: "1001",
residualDiamondsPerUsd: "100",
settlementMode: "daily",
settlementTriggerMode: "automatic",
},
loadingAction: "",
loadingRegions: false,
regionOptions: [{ label: "默认区域", value: "1001" }],
removeLevel: vi.fn(),
setForm: vi.fn(),
submitPolicy: vi.fn((event) => event.preventDefault()),
updateLevel: vi.fn(),
};
}

View File

@ -12,6 +12,10 @@ import {
updateHostAgencyPolicy,
} from "@/features/host-agency-policy/api";
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
import {
residualDiamondsPerUsdToRate,
residualRateToDiamondsPerUsd,
} from "@/features/host-agency-policy/residualRate.js";
import { hostAgencyPolicyFormSchema } from "@/features/host-agency-policy/schema";
const pageSize = 50;
@ -51,12 +55,11 @@ const emptyForm = (policy = null) => ({
effectiveTo: normalizeTimeMs(policy?.effectiveToMs),
// 新建默认停用,运营保存草稿后再手动启用,避免半配置状态影响结算匹配。
enabled: policy ? policy.status === "active" : false,
giftCoinToDiamondRatio: policy?.giftCoinToDiamondRatio || "1",
// 编辑时保留后端等级;新建时一次铺满 24 个默认等级,减少运营逐级录入成本。
levels: policy?.levels?.length ? policy.levels.map(levelToForm) : defaultLevels.map(defaultLevelToForm),
name: policy?.name || "Lalu Host & Agency Salary Policy",
regionId: policy?.regionId ? String(policy.regionId) : "",
residualDiamondToUsdRate: policy?.residualDiamondToUsdRate || "0",
residualDiamondsPerUsd: residualRateToDiamondsPerUsd(policy?.residualDiamondToUsdRate || "0"),
settlementMode: policy?.settlementMode || "daily",
settlementTriggerMode: policy?.settlementTriggerMode || "automatic",
});
@ -321,7 +324,8 @@ function buildPayload(form) {
return {
effective_from_ms: timeValueToMs(form.effectiveFrom),
effective_to_ms: timeValueToMs(form.effectiveTo),
gift_coin_to_diamond_ratio: String(form.giftCoinToDiamondRatio || "1").trim(),
// 主播周期钻石入账比例由“礼物钻石”配置页控制;工资政策不再提供第二套可编辑比例。
gift_coin_to_diamond_ratio: "1",
levels: form.levels
.map((level) => ({
// 保存等级时统一转 number避免字符串数字进入后端后造成弱类型歧义。
@ -337,7 +341,7 @@ function buildPayload(form) {
.sort((left, right) => left.level - right.level),
name: String(form.name || "").trim(),
region_id: Number(form.regionId || 0),
residual_diamond_to_usd_rate: String(form.residualDiamondToUsdRate || "0").trim(),
residual_diamond_to_usd_rate: residualDiamondsPerUsdToRate(form.residualDiamondsPerUsd),
settlement_mode: form.settlementMode || "daily",
// 触发方式会被发布到 wallet 运行表manual 表示后续只能从工资结算页人工触发。
settlement_trigger_mode: form.settlementTriggerMode || "automatic",

View File

@ -21,6 +21,7 @@ import { formatMillis } from "@/shared/utils/time.js";
import { HostAgencyPolicyDrawer } from "@/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx";
import { useHostAgencyPolicyPage } from "@/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js";
import { TeamSalaryPolicyPanel } from "@/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx";
import { residualRateLabel } from "@/features/host-agency-policy/residualRate.js";
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
const statusOptions = [
@ -238,8 +239,8 @@ function SettlementSummary({ item }) {
<div className={styles.stack}>
<span>{settlementLabel(item.settlementMode)}</span>
<span className={styles.meta}>{settlementTriggerLabel(item.settlementTriggerMode)}</span>
<span className={styles.meta}>金币转钻石 {item.giftCoinToDiamondRatio || "1"}</span>
<span className={styles.meta}>剩余钻石转美元 {item.residualDiamondToUsdRate || "0"}</span>
<span className={styles.meta}>入账钻石按礼物钻石配置</span>
<span className={styles.meta}>{residualRateLabel(item.residualDiamondToUsdRate)}</span>
</div>
);
}

View File

@ -0,0 +1,37 @@
const maxRateScale = 12;
export function residualDiamondsPerUsdToRate(value) {
const diamonds = Number(String(value ?? "").trim());
if (!Number.isFinite(diamonds) || diamonds <= 0) {
return "0";
}
return trimDecimal((1 / diamonds).toFixed(maxRateScale));
}
export function residualRateToDiamondsPerUsd(rate) {
const value = Number(String(rate ?? "").trim());
if (!Number.isFinite(value) || value <= 0) {
return "0";
}
const diamonds = 1 / value;
const rounded = Math.round(diamonds);
// 后端 rate 只保留 12 位小数1/3 这类配置回显时会有极小误差;贴近整数时按整数展示。
if (Math.abs(diamonds - rounded) < 0.000001) {
return String(rounded);
}
return trimDecimal(diamonds.toFixed(6));
}
export function residualRateLabel(rate) {
const diamonds = residualRateToDiamondsPerUsd(rate);
return diamonds === "0" ? "剩余钻石不折美元" : `剩余钻石 ${diamonds}钻石=1美元`;
}
function trimDecimal(value) {
const text = String(value || "0");
if (!text.includes(".")) {
return text;
}
const trimmed = text.replace(/0+$/, "").replace(/\.$/, "");
return trimmed || "0";
}

View File

@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import {
residualDiamondsPerUsdToRate,
residualRateLabel,
residualRateToDiamondsPerUsd,
} from "@/features/host-agency-policy/residualRate.js";
describe("host agency residual diamond rate", () => {
test("submits diamonds per USD as backend USD-per-diamond rate", () => {
expect(residualDiamondsPerUsdToRate("100")).toBe("0.01");
expect(residualDiamondsPerUsdToRate("1000000")).toBe("0.000001");
expect(residualDiamondsPerUsdToRate("0")).toBe("0");
});
test("displays backend rate as diamonds per USD", () => {
expect(residualRateToDiamondsPerUsd("0.01")).toBe("100");
expect(residualRateLabel("0.01")).toBe("剩余钻石 100钻石=1美元");
expect(residualRateLabel("0")).toBe("剩余钻石不折美元");
});
});

View File

@ -21,11 +21,10 @@ export const hostAgencyPolicyFormSchema = z
effectiveFrom: z.union([z.string(), z.number()]).optional(),
effectiveTo: z.union([z.string(), z.number()]).optional(),
enabled: z.boolean(),
giftCoinToDiamondRatio: z.union([z.string(), z.number()]),
levels: z.array(levelSchema).min(1, "请至少配置一个等级"),
name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"),
regionId: z.union([z.string(), z.number()]),
residualDiamondToUsdRate: z.union([z.string(), z.number()]),
residualDiamondsPerUsd: z.union([z.string(), z.number()]),
settlementMode: z.enum(settlementModes),
settlementTriggerMode: z.enum(settlementTriggerModes),
})
@ -35,14 +34,11 @@ export const hostAgencyPolicyFormSchema = z
if (!Number.isInteger(regionId) || regionId <= 0) {
context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] });
}
if (!isFixedDecimal(value.giftCoinToDiamondRatio, 6, false)) {
context.addIssue({ code: "custom", message: "金币转钻石比例不正确", path: ["giftCoinToDiamondRatio"] });
}
if (!isFixedDecimal(value.residualDiamondToUsdRate, 12, true)) {
if (!isNonNegativeInteger(value.residualDiamondsPerUsd)) {
context.addIssue({
code: "custom",
message: "剩余钻石转美元比例不正确",
path: ["residualDiamondToUsdRate"],
message: "剩余钻石兑美元配置不正确",
path: ["residualDiamondsPerUsd"],
});
}
const effectiveFromMs = timeValueToMs(value.effectiveFrom);
@ -190,6 +186,11 @@ function isFixedDecimal(value: unknown, scale: number, allowZero: boolean) {
return decimalToScaled(value, scale, allowZero) >= 0;
}
function isNonNegativeInteger(value: unknown) {
const raw = String(value ?? "").trim();
return /^\d+$/.test(raw);
}
function decimalToScaled(value: unknown, scale: number, allowZero = true) {
// 小数校验转换成整数刻度比较,避免 JS 浮点数直接参与金额大小判断。
const raw = String(value ?? "").trim();

View File

@ -9,6 +9,8 @@ import type {
BDProfileDto,
BDStatusPayload,
CoinSellerDto,
CoinSellerSalaryRatesDto,
CoinSellerSalaryRatesPayload,
CoinSellerStockCreditDto,
CoinSellerStockCreditPayload,
CoinSellerStatusPayload,
@ -250,6 +252,30 @@ export function creditCoinSellerStock(
);
}
export function getCoinSellerSalaryRates(regionId: EntityId): Promise<CoinSellerSalaryRatesDto> {
const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates;
return apiRequest<CoinSellerSalaryRatesDto>(
apiEndpointPath(API_OPERATIONS.getCoinSellerSalaryRates, { region_id: regionId }),
{
method: endpoint.method,
},
);
}
export function replaceCoinSellerSalaryRates(
regionId: EntityId,
payload: CoinSellerSalaryRatesPayload,
): Promise<CoinSellerSalaryRatesDto> {
const endpoint = API_ENDPOINTS.replaceCoinSellerSalaryRates;
return apiRequest<CoinSellerSalaryRatesDto, CoinSellerSalaryRatesPayload>(
apiEndpointPath(API_OPERATIONS.replaceCoinSellerSalaryRates, { region_id: regionId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function createAgency(payload: CreateAgencyPayload): Promise<CreateAgencyResultDto> {
const endpoint = API_ENDPOINTS.createAgency;
return apiRequest<CreateAgencyResultDto, CreateAgencyPayload>(apiEndpointPath(API_OPERATIONS.createAgency), {

View File

@ -3,7 +3,14 @@ import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createCoinSeller, creditCoinSellerStock, listCoinSellers, setCoinSellerStatus } from "@/features/host-org/api";
import {
createCoinSeller,
creditCoinSellerStock,
getCoinSellerSalaryRates,
listCoinSellers,
replaceCoinSellerSalaryRates,
setCoinSellerStatus,
} from "@/features/host-org/api";
import { useCoinSellerAbilities } from "@/features/host-org/permissions.js";
import {
coinSellerStatusSchema,
@ -28,6 +35,13 @@ const emptyStockForm = () => ({
rechargeAmount: "",
type: "usdt_purchase",
});
const emptyRateTier = (index = 0) => ({
coinPerUsd: "",
enabled: true,
maxUsd: "",
minUsd: "",
sortOrder: (index + 1) * 10,
});
export function useHostCoinSellersPage() {
const abilities = useCoinSellerAbilities();
@ -41,6 +55,9 @@ export function useHostCoinSellersPage() {
const [createForm, setCreateForm] = useState(emptyCreateForm);
const [editForm, setEditForm] = useState(emptyEditForm);
const [stockForm, setStockForm] = useState(emptyStockForm);
const [rateRegionId, setRateRegionId] = useState("");
const [rateTiers, setRateTiers] = useState([]);
const [loadingRates, setLoadingRates] = useState(false);
const [selectedSeller, setSelectedSeller] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
@ -117,11 +134,69 @@ export function useHostCoinSellersPage() {
setActiveAction("stock");
};
const openRateSettings = async () => {
if (!abilities.canExchangeRate) {
return;
}
const nextRegionId = rateRegionId || regionId || regionOptions[0]?.value || "";
setRateRegionId(nextRegionId);
setActiveAction("rates");
if (nextRegionId) {
await loadRateTiers(nextRegionId);
} else {
setRateTiers([]);
}
};
const closeAction = () => {
setActiveAction("");
setSelectedSeller(null);
};
const changeRateRegionId = async (value) => {
setRateRegionId(value);
if (value) {
await loadRateTiers(value);
} else {
setRateTiers([]);
}
};
const loadRateTiers = async (targetRegionId = rateRegionId) => {
if (!targetRegionId) {
return;
}
setLoadingRates(true);
try {
const data = await getCoinSellerSalaryRates(targetRegionId);
const next = (data?.tiers || []).map((tier, index) => ({
coinPerUsd: String(tier.coinPerUsd || ""),
enabled: tier.status ? tier.status === "active" : tier.enabled !== false,
maxUsd: tier.maxUsd || centsToUSD(tier.maxUsdMinor),
minUsd: tier.minUsd || centsToUSD(tier.minUsdMinor),
sortOrder: tier.sortOrder || (index + 1) * 10,
}));
setRateTiers(next.length ? next : [emptyRateTier(0)]);
} catch (err) {
setRateTiers([emptyRateTier(0)]);
showToast(err.message || "加载兑换比例失败", "error");
} finally {
setLoadingRates(false);
}
};
const addRateTier = () => {
setRateTiers((items) => [...items, emptyRateTier(items.length)]);
};
const removeRateTier = (index) => {
setRateTiers((items) => items.filter((_, itemIndex) => itemIndex !== index));
};
const updateRateTier = (index, patch) => {
setRateTiers((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)));
};
const submitCreateSeller = async (event) => {
event.preventDefault();
await runAction("coin-seller-create", "币商已添加", async () => {
@ -163,6 +238,29 @@ export function useHostCoinSellersPage() {
});
};
const submitRateSettings = async (event) => {
event.preventDefault();
if (!rateRegionId) {
showToast("请选择区域", "error");
return;
}
await runAction("coin-seller-rates", "兑换比例已保存", async () => {
const payload = {
tiers: rateTiers
.filter((tier) => tier.minUsd || tier.maxUsd || tier.coinPerUsd)
.map((tier, index) => ({
coinPerUsd: Number(tier.coinPerUsd),
enabled: tier.enabled !== false,
maxUsd: String(tier.maxUsd || "").trim(),
minUsd: String(tier.minUsd || "").trim(),
sortOrder: Number(tier.sortOrder || (index + 1) * 10),
})),
};
await replaceCoinSellerSalaryRates(rateRegionId, payload);
await loadRateTiers(rateRegionId);
});
};
const toggleSeller = async (seller, nextEnabled = seller.status !== "active") => {
if (!abilities.canUpdate || !seller?.userId) {
return;
@ -206,7 +304,9 @@ export function useHostCoinSellersPage() {
error,
loading,
loadingAction,
loadingRates,
loadingRegions,
openRateSettings,
openCreateSeller,
openEditSeller,
openStockCredit,
@ -214,7 +314,12 @@ export function useHostCoinSellersPage() {
query,
regionId,
regionOptions,
rateRegionId,
rateTiers,
reload,
addRateTier,
changeRateRegionId,
removeRateTier,
resetFilters,
selectedSeller,
setCreateForm,
@ -223,13 +328,23 @@ export function useHostCoinSellersPage() {
setStockForm,
status,
stockForm,
submitRateSettings,
submitCreateSeller,
submitEditSeller,
submitStockCredit,
toggleSeller,
updateRateTier,
};
}
function makeCommandId(prefix) {
return `${prefix}-${Date.now()}`;
}
function centsToUSD(value) {
const amount = Number(value || 0);
if (!Number.isFinite(amount) || amount <= 0) {
return "";
}
return `${Math.trunc(amount / 100)}.${String(Math.abs(amount % 100)).padStart(2, "0")}`;
}

View File

@ -100,6 +100,43 @@
gap: var(--space-2);
}
.rateDrawerForm {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.rateTierHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-weight: 760;
color: var(--text-primary);
}
.rateTierList {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.rateTierRow {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) auto auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-card);
}
.rateTierSwitch {
display: flex;
justify-content: center;
}
.flagCell {
font-size: var(--admin-font-size);
}

View File

@ -1,5 +1,8 @@
import Add from "@mui/icons-material/Add";
import DeleteOutline from "@mui/icons-material/DeleteOutline";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
@ -12,6 +15,7 @@ import {
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
@ -121,9 +125,14 @@ export function HostCoinSellersPage() {
value: page.regionId,
onChange: page.changeRegionId,
});
const toolbarActions = page.abilities.canCreate
? [{ icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }]
: [];
const toolbarActions = [
page.abilities.canExchangeRate
? { icon: <SettingsOutlined fontSize="small" />, label: "设置兑换比例", onClick: page.openRateSettings }
: null,
page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }
: null,
].filter(Boolean);
return (
<section className={styles.root}>
@ -274,6 +283,91 @@ export function HostCoinSellersPage() {
/>
</AdminFormSection>
</HostOrgActionModal>
<SideDrawer
actions={
<>
<Button onClick={page.closeAction}>取消</Button>
<Button
disabled={!page.abilities.canExchangeRate || page.loadingAction === "coin-seller-rates"}
variant="contained"
onClick={page.submitRateSettings}
>
保存
</Button>
</>
}
open={page.activeAction === "rates"}
title="币商工资兑换比例"
width="wide"
onClose={page.closeAction}
>
<form className={styles.rateDrawerForm} onSubmit={page.submitRateSettings}>
<TextField
disabled={page.loadingRegions || page.loadingRates}
label="区域"
required
select
value={page.rateRegionId}
onChange={(event) => page.changeRateRegionId(event.target.value)}
>
{page.regionOptions.map((region) => (
<MenuItem key={region.value} value={region.value}>
{region.label}
</MenuItem>
))}
</TextField>
<div className={styles.rateTierHeader}>
<span>区间配置</span>
<Button size="small" variant="outlined" onClick={page.addRateTier}>
添加区间
</Button>
</div>
<div className={styles.rateTierList}>
{page.rateTiers.map((tier, index) => (
<div className={styles.rateTierRow} key={`${index}-${tier.sortOrder}`}>
<TextField
label="min USD"
required
value={tier.minUsd}
onChange={(event) => page.updateRateTier(index, { minUsd: event.target.value })}
/>
<TextField
label="max USD"
required
value={tier.maxUsd}
onChange={(event) => page.updateRateTier(index, { maxUsd: event.target.value })}
/>
<TextField
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
label="coin per USD"
required
value={tier.coinPerUsd}
onChange={(event) =>
page.updateRateTier(index, { coinPerUsd: event.target.value })
}
/>
<div className={styles.rateTierSwitch}>
<AdminSwitch
checked={tier.enabled !== false}
checkedLabel="启用"
label="状态"
uncheckedLabel="停用"
onChange={(event) => page.updateRateTier(index, { enabled: event.target.checked })}
/>
</div>
<AdminActionIconButton
disabled={page.rateTiers.length <= 1}
label="删除区间"
onClick={() => page.removeRateTier(index)}
>
<DeleteOutline fontSize="small" />
</AdminActionIconButton>
</div>
))}
</div>
</form>
</SideDrawer>
</section>
);
}

View File

@ -56,6 +56,7 @@ export function useCoinSellerAbilities() {
return {
canCreate: can(PERMISSIONS.coinSellerCreate),
canExchangeRate: can(PERMISSIONS.coinSellerExchangeRate),
canStockCredit: can(PERMISSIONS.coinSellerStockCredit),
canUpdate: can(PERMISSIONS.coinSellerUpdate),
canView: can(PERMISSIONS.coinSellerView),

View File

@ -85,6 +85,7 @@ export const API_OPERATIONS = {
exportLoginLogs: "exportLoginLogs",
exportOperationLogs: "exportOperationLogs",
exportUsers: "exportUsers",
getCoinSellerSalaryRates: "getCoinSellerSalaryRates",
getCountry: "getCountry",
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
getLuckyGiftConfig: "getLuckyGiftConfig",
@ -159,6 +160,7 @@ export const API_OPERATIONS = {
navigationMenus: "navigationMenus",
publishHostAgencyPolicy: "publishHostAgencyPolicy",
refresh: "refresh",
replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates",
replaceRegionBlocks: "replaceRegionBlocks",
replaceRegionCountries: "replaceRegionCountries",
replaceRoleDataScopes: "replaceRoleDataScopes",
@ -721,6 +723,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "user:export",
permissions: ["user:export"]
},
getCoinSellerSalaryRates: {
method: "GET",
operationId: API_OPERATIONS.getCoinSellerSalaryRates,
path: "/v1/admin/coin-seller-salary-rates/{region_id}",
permission: "coin-seller:exchange-rate",
permissions: ["coin-seller:exchange-rate"]
},
getCountry: {
method: "GET",
operationId: API_OPERATIONS.getCountry,
@ -1227,6 +1236,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
operationId: API_OPERATIONS.refresh,
path: "/v1/auth/refresh"
},
replaceCoinSellerSalaryRates: {
method: "PUT",
operationId: API_OPERATIONS.replaceCoinSellerSalaryRates,
path: "/v1/admin/coin-seller-salary-rates/{region_id}",
permission: "coin-seller:exchange-rate",
permissions: ["coin-seller:exchange-rate"]
},
replaceRegionBlocks: {
method: "PUT",
operationId: API_OPERATIONS.replaceRegionBlocks,

View File

@ -660,6 +660,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/coin-seller-salary-rates/{region_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getCoinSellerSalaryRates"];
put: operations["replaceCoinSellerSalaryRates"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/countries": {
parameters: {
query?: never;
@ -3512,6 +3528,34 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
getCoinSellerSalaryRates: {
parameters: {
query?: never;
header?: never;
path: {
region_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
replaceCoinSellerSalaryRates: {
parameters: {
query?: never;
header?: never;
path: {
region_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listCountries: {
parameters: {
query?: never;

View File

@ -457,6 +457,39 @@ export interface CoinSellerStockCreditDto {
transactionId: string;
}
export interface CoinSellerSalaryRateTierDto {
coinPerUsd: number;
enabled?: boolean;
maxUsd?: string;
maxUsdMinor: number;
minUsd?: string;
minUsdMinor: number;
regionId?: EntityId;
sortOrder?: number;
status?: string;
updatedAtMs?: number;
}
export interface CoinSellerSalaryRatesDto {
regionId: EntityId;
tiers: CoinSellerSalaryRateTierDto[];
}
export interface CoinSellerSalaryRateTierPayload {
coinPerUsd: number;
enabled?: boolean;
maxUsd?: string;
maxUsdMinor?: number;
minUsd?: string;
minUsdMinor?: number;
sortOrder?: number;
status?: string;
}
export interface CoinSellerSalaryRatesPayload {
tiers: CoinSellerSalaryRateTierPayload[];
}
export interface AppUserDto {
avatar?: string;
coin?: number;