diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index df7ede9..ea75ae0 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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", diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 0b8dcd9..b9ce024 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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", diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index a10d49e..83e330f 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -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; diff --git a/src/features/app-users/hooks/useAppUsersPage.js b/src/features/app-users/hooks/useAppUsersPage.js index 661bbf5..a108eb8 100644 --- a/src/features/app-users/hooks/useAppUsersPage.js +++ b/src/features/app-users/hooks/useAppUsersPage.js @@ -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, + }; +} diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx index b09b92d..88455b9 100644 --- a/src/features/app-users/pages/AppUserListPage.jsx +++ b/src/features/app-users/pages/AppUserListPage.jsx @@ -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: ( + toggleAll(event.target.checked)} + /> + ), + width: "52px", + render: (user) => { + const banned = isBannedUser(user); + return ( + { + 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() {
+ {page.abilities.canStatus ? ( +
+ 已选 {page.selectedUserIds.length} 个 + +
+ ) : null} setForm({ giftCoinToDiamondRatio: event.target.value })} - /> - setForm({ residualDiamondToUsdRate: event.target.value })} + slotProps={{ + htmlInput: { min: 0, step: 1 }, + input: { + endAdornment: 钻石=1美元, + }, + }} + type="number" + value={form.residualDiamondsPerUsd} + onChange={(event) => setForm({ residualDiamondsPerUsd: event.target.value })} /> { + render(); + + 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(), + }; +} diff --git a/src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js b/src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js index cc3084b..8d1e457 100644 --- a/src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js +++ b/src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js @@ -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", diff --git a/src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx b/src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx index 664df9f..293dde2 100644 --- a/src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx +++ b/src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx @@ -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 }) {
{settlementLabel(item.settlementMode)} {settlementTriggerLabel(item.settlementTriggerMode)} - 金币转钻石 {item.giftCoinToDiamondRatio || "1"} - 剩余钻石转美元 {item.residualDiamondToUsdRate || "0"} + 入账钻石按礼物钻石配置 + {residualRateLabel(item.residualDiamondToUsdRate)}
); } diff --git a/src/features/host-agency-policy/residualRate.js b/src/features/host-agency-policy/residualRate.js new file mode 100644 index 0000000..94be6d4 --- /dev/null +++ b/src/features/host-agency-policy/residualRate.js @@ -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"; +} diff --git a/src/features/host-agency-policy/residualRate.test.js b/src/features/host-agency-policy/residualRate.test.js new file mode 100644 index 0000000..fdabec5 --- /dev/null +++ b/src/features/host-agency-policy/residualRate.test.js @@ -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("剩余钻石不折美元"); + }); +}); diff --git a/src/features/host-agency-policy/schema.ts b/src/features/host-agency-policy/schema.ts index f9bec7d..dd3ca4b 100644 --- a/src/features/host-agency-policy/schema.ts +++ b/src/features/host-agency-policy/schema.ts @@ -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(); diff --git a/src/features/host-org/api.ts b/src/features/host-org/api.ts index b285663..be53cee 100644 --- a/src/features/host-org/api.ts +++ b/src/features/host-org/api.ts @@ -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 { + const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates; + return apiRequest( + apiEndpointPath(API_OPERATIONS.getCoinSellerSalaryRates, { region_id: regionId }), + { + method: endpoint.method, + }, + ); +} + +export function replaceCoinSellerSalaryRates( + regionId: EntityId, + payload: CoinSellerSalaryRatesPayload, +): Promise { + const endpoint = API_ENDPOINTS.replaceCoinSellerSalaryRates; + return apiRequest( + apiEndpointPath(API_OPERATIONS.replaceCoinSellerSalaryRates, { region_id: regionId }), + { + body: payload, + method: endpoint.method, + }, + ); +} + export function createAgency(payload: CreateAgencyPayload): Promise { const endpoint = API_ENDPOINTS.createAgency; return apiRequest(apiEndpointPath(API_OPERATIONS.createAgency), { diff --git a/src/features/host-org/hooks/useHostCoinSellersPage.js b/src/features/host-org/hooks/useHostCoinSellersPage.js index bdeccff..7e94cb2 100644 --- a/src/features/host-org/hooks/useHostCoinSellersPage.js +++ b/src/features/host-org/hooks/useHostCoinSellersPage.js @@ -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")}`; +} diff --git a/src/features/host-org/host-org.module.css b/src/features/host-org/host-org.module.css index 6c888aa..1a0d3f6 100644 --- a/src/features/host-org/host-org.module.css +++ b/src/features/host-org/host-org.module.css @@ -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); } diff --git a/src/features/host-org/pages/HostCoinSellersPage.jsx b/src/features/host-org/pages/HostCoinSellersPage.jsx index 2cdbf04..b457c91 100644 --- a/src/features/host-org/pages/HostCoinSellersPage.jsx +++ b/src/features/host-org/pages/HostCoinSellersPage.jsx @@ -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: , label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }] - : []; + const toolbarActions = [ + page.abilities.canExchangeRate + ? { icon: , label: "设置兑换比例", onClick: page.openRateSettings } + : null, + page.abilities.canCreate + ? { icon: , label: "添加币商", onClick: page.openCreateSeller, variant: "primary" } + : null, + ].filter(Boolean); return (
@@ -274,6 +283,91 @@ export function HostCoinSellersPage() { /> + + + + + + } + open={page.activeAction === "rates"} + title="币商工资兑换比例" + width="wide" + onClose={page.closeAction} + > +
+ page.changeRateRegionId(event.target.value)} + > + {page.regionOptions.map((region) => ( + + {region.label} + + ))} + +
+ 区间配置 + +
+
+ {page.rateTiers.map((tier, index) => ( +
+ page.updateRateTier(index, { minUsd: event.target.value })} + /> + page.updateRateTier(index, { maxUsd: event.target.value })} + /> + + page.updateRateTier(index, { coinPerUsd: event.target.value }) + } + /> +
+ page.updateRateTier(index, { enabled: event.target.checked })} + /> +
+ page.removeRateTier(index)} + > + + +
+ ))} +
+
+
); } diff --git a/src/features/host-org/permissions.js b/src/features/host-org/permissions.js index 09fa625..8ec5585 100644 --- a/src/features/host-org/permissions.js +++ b/src/features/host-org/permissions.js @@ -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), diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index b098f8a..cbcdd7e 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 6c00382..8c03a37 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 60e896f..61cea0e 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -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;