公会相关
This commit is contained in:
parent
ca194e46a3
commit
0f92efea87
@ -957,6 +957,48 @@
|
|||||||
"x-permissions": ["coin-seller:stock-credit"]
|
"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": {
|
"/admin/countries": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listCountries",
|
"operationId": "listCountries",
|
||||||
|
|||||||
@ -36,6 +36,7 @@ export const PERMISSIONS = {
|
|||||||
coinSellerCreate: "coin-seller:create",
|
coinSellerCreate: "coin-seller:create",
|
||||||
coinSellerUpdate: "coin-seller:update",
|
coinSellerUpdate: "coin-seller:update",
|
||||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||||
|
coinSellerExchangeRate: "coin-seller:exchange-rate",
|
||||||
coinLedgerView: "coin-ledger:view",
|
coinLedgerView: "coin-ledger:view",
|
||||||
coinAdjustmentView: "coin-adjustment:view",
|
coinAdjustmentView: "coin-adjustment:view",
|
||||||
coinAdjustmentCreate: "coin-adjustment:create",
|
coinAdjustmentCreate: "coin-adjustment:create",
|
||||||
|
|||||||
@ -35,10 +35,17 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
flex: 0 0 auto;
|
||||||
padding: var(--space-4) var(--space-5);
|
padding: var(--space-4) var(--space-5);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selectedMeta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
@ -28,6 +28,8 @@ export function useAppUsersPage() {
|
|||||||
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
||||||
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
|
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState([]);
|
||||||
|
const [patchedUsers, setPatchedUsers] = useState({});
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
keyword: query,
|
keyword: query,
|
||||||
@ -36,7 +38,7 @@ export function useAppUsersPage() {
|
|||||||
[query, status],
|
[query, status],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
data = emptyData,
|
data: queryData = emptyData,
|
||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
reload,
|
reload,
|
||||||
@ -48,6 +50,18 @@ export function useAppUsersPage() {
|
|||||||
pageSize,
|
pageSize,
|
||||||
queryKey: ["app-users", filters, page],
|
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) => {
|
const changeQuery = (value) => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
@ -132,23 +146,58 @@ export function useAppUsersPage() {
|
|||||||
|
|
||||||
const toggleBan = async (user) => {
|
const toggleBan = async (user) => {
|
||||||
const banned = isBanned(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({
|
const ok = await confirm({
|
||||||
confirmText: banned ? "解封" : "封禁",
|
confirmText: "封禁",
|
||||||
message: `${user.username || user.displayUserId || user.userId} 的状态会立即变更。`,
|
message: `将封禁 ${users.length} 个用户。`,
|
||||||
title: banned ? "解封用户" : "封禁用户",
|
title: "批量封禁用户",
|
||||||
tone: banned ? "primary" : "danger",
|
tone: "danger",
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
|
|
||||||
if (banned) {
|
setLoadingAction("batch-ban");
|
||||||
await unbanAppUser(user.userId);
|
try {
|
||||||
} else {
|
const results = await Promise.allSettled(users.map((user) => banAppUser(user.userId)));
|
||||||
await 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) => {
|
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 {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
activeUser,
|
activeUser,
|
||||||
|
batchBan,
|
||||||
changeQuery,
|
changeQuery,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
closeAction,
|
closeAction,
|
||||||
@ -186,10 +249,12 @@ export function useAppUsersPage() {
|
|||||||
query,
|
query,
|
||||||
reload,
|
reload,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
selectedUserIds,
|
||||||
setCountryForm,
|
setCountryForm,
|
||||||
setEditForm,
|
setEditForm,
|
||||||
setPage,
|
setPage,
|
||||||
setPasswordForm,
|
setPasswordForm,
|
||||||
|
setSelectedUserIds,
|
||||||
status,
|
status,
|
||||||
submitCountry,
|
submitCountry,
|
||||||
submitEdit,
|
submitEdit,
|
||||||
@ -201,3 +266,11 @@ export function useAppUsersPage() {
|
|||||||
function isBanned(user) {
|
function isBanned(user) {
|
||||||
return user.status === "banned" || user.status === "disabled";
|
return user.status === "banned" || user.status === "disabled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeUserStatus(currentUser, result, fallbackStatus) {
|
||||||
|
return {
|
||||||
|
...currentUser,
|
||||||
|
...(result || {}),
|
||||||
|
status: result?.status || fallbackStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -4,10 +4,12 @@ import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
|
|||||||
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||||
import Autocomplete from "@mui/material/Autocomplete";
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
@ -49,7 +51,51 @@ export function AppUserListPage() {
|
|||||||
const page = useAppUsersPage();
|
const page = useAppUsersPage();
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
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 = [
|
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],
|
...columns[0],
|
||||||
filter: createTextColumnFilter({
|
filter: createTextColumnFilter({
|
||||||
@ -90,6 +136,19 @@ export function AppUserListPage() {
|
|||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<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
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
items={items}
|
items={items}
|
||||||
@ -310,3 +369,7 @@ function formatNumber(value) {
|
|||||||
function formatCountry(user) {
|
function formatCountry(user) {
|
||||||
return user.countryDisplayName || user.countryName || user.country || "-";
|
return user.countryDisplayName || user.countryName || user.country || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBannedUser(user) {
|
||||||
|
return user.status === "banned" || user.status === "disabled";
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
@ -119,19 +120,18 @@ function PolicyBaseFields({ disabled, page }) {
|
|||||||
required
|
required
|
||||||
className={styles.fieldCompact}
|
className={styles.fieldCompact}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="金币转钻石比例"
|
helperText="0 表示不折算"
|
||||||
|
label="剩余钻石兑美元"
|
||||||
size="small"
|
size="small"
|
||||||
value={form.giftCoinToDiamondRatio}
|
slotProps={{
|
||||||
onChange={(event) => setForm({ giftCoinToDiamondRatio: event.target.value })}
|
htmlInput: { min: 0, step: 1 },
|
||||||
/>
|
input: {
|
||||||
<TextField
|
endAdornment: <InputAdornment position="end">钻石=1美元</InputAdornment>,
|
||||||
required
|
},
|
||||||
className={styles.fieldCompact}
|
}}
|
||||||
disabled={disabled}
|
type="number"
|
||||||
label="剩余钻石转美元比例"
|
value={form.residualDiamondsPerUsd}
|
||||||
size="small"
|
onChange={(event) => setForm({ residualDiamondsPerUsd: event.target.value })}
|
||||||
value={form.residualDiamondToUsdRate}
|
|
||||||
onChange={(event) => setForm({ residualDiamondToUsdRate: event.target.value })}
|
|
||||||
/>
|
/>
|
||||||
<TimeRangeFilter
|
<TimeRangeFilter
|
||||||
className={styles.timeRangeField}
|
className={styles.timeRangeField}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -12,6 +12,10 @@ import {
|
|||||||
updateHostAgencyPolicy,
|
updateHostAgencyPolicy,
|
||||||
} from "@/features/host-agency-policy/api";
|
} from "@/features/host-agency-policy/api";
|
||||||
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
|
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";
|
import { hostAgencyPolicyFormSchema } from "@/features/host-agency-policy/schema";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
@ -51,12 +55,11 @@ const emptyForm = (policy = null) => ({
|
|||||||
effectiveTo: normalizeTimeMs(policy?.effectiveToMs),
|
effectiveTo: normalizeTimeMs(policy?.effectiveToMs),
|
||||||
// 新建默认停用,运营保存草稿后再手动启用,避免半配置状态影响结算匹配。
|
// 新建默认停用,运营保存草稿后再手动启用,避免半配置状态影响结算匹配。
|
||||||
enabled: policy ? policy.status === "active" : false,
|
enabled: policy ? policy.status === "active" : false,
|
||||||
giftCoinToDiamondRatio: policy?.giftCoinToDiamondRatio || "1",
|
|
||||||
// 编辑时保留后端等级;新建时一次铺满 24 个默认等级,减少运营逐级录入成本。
|
// 编辑时保留后端等级;新建时一次铺满 24 个默认等级,减少运营逐级录入成本。
|
||||||
levels: policy?.levels?.length ? policy.levels.map(levelToForm) : defaultLevels.map(defaultLevelToForm),
|
levels: policy?.levels?.length ? policy.levels.map(levelToForm) : defaultLevels.map(defaultLevelToForm),
|
||||||
name: policy?.name || "Lalu Host & Agency Salary Policy",
|
name: policy?.name || "Lalu Host & Agency Salary Policy",
|
||||||
regionId: policy?.regionId ? String(policy.regionId) : "",
|
regionId: policy?.regionId ? String(policy.regionId) : "",
|
||||||
residualDiamondToUsdRate: policy?.residualDiamondToUsdRate || "0",
|
residualDiamondsPerUsd: residualRateToDiamondsPerUsd(policy?.residualDiamondToUsdRate || "0"),
|
||||||
settlementMode: policy?.settlementMode || "daily",
|
settlementMode: policy?.settlementMode || "daily",
|
||||||
settlementTriggerMode: policy?.settlementTriggerMode || "automatic",
|
settlementTriggerMode: policy?.settlementTriggerMode || "automatic",
|
||||||
});
|
});
|
||||||
@ -321,7 +324,8 @@ function buildPayload(form) {
|
|||||||
return {
|
return {
|
||||||
effective_from_ms: timeValueToMs(form.effectiveFrom),
|
effective_from_ms: timeValueToMs(form.effectiveFrom),
|
||||||
effective_to_ms: timeValueToMs(form.effectiveTo),
|
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
|
levels: form.levels
|
||||||
.map((level) => ({
|
.map((level) => ({
|
||||||
// 保存等级时统一转 number,避免字符串数字进入后端后造成弱类型歧义。
|
// 保存等级时统一转 number,避免字符串数字进入后端后造成弱类型歧义。
|
||||||
@ -337,7 +341,7 @@ function buildPayload(form) {
|
|||||||
.sort((left, right) => left.level - right.level),
|
.sort((left, right) => left.level - right.level),
|
||||||
name: String(form.name || "").trim(),
|
name: String(form.name || "").trim(),
|
||||||
region_id: Number(form.regionId || 0),
|
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",
|
settlement_mode: form.settlementMode || "daily",
|
||||||
// 触发方式会被发布到 wallet 运行表;manual 表示后续只能从工资结算页人工触发。
|
// 触发方式会被发布到 wallet 运行表;manual 表示后续只能从工资结算页人工触发。
|
||||||
settlement_trigger_mode: form.settlementTriggerMode || "automatic",
|
settlement_trigger_mode: form.settlementTriggerMode || "automatic",
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { formatMillis } from "@/shared/utils/time.js";
|
|||||||
import { HostAgencyPolicyDrawer } from "@/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx";
|
import { HostAgencyPolicyDrawer } from "@/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx";
|
||||||
import { useHostAgencyPolicyPage } from "@/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js";
|
import { useHostAgencyPolicyPage } from "@/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js";
|
||||||
import { TeamSalaryPolicyPanel } from "@/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx";
|
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";
|
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
@ -238,8 +239,8 @@ function SettlementSummary({ item }) {
|
|||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{settlementLabel(item.settlementMode)}</span>
|
<span>{settlementLabel(item.settlementMode)}</span>
|
||||||
<span className={styles.meta}>{settlementTriggerLabel(item.settlementTriggerMode)}</span>
|
<span className={styles.meta}>{settlementTriggerLabel(item.settlementTriggerMode)}</span>
|
||||||
<span className={styles.meta}>金币转钻石 {item.giftCoinToDiamondRatio || "1"}</span>
|
<span className={styles.meta}>入账钻石按礼物钻石配置</span>
|
||||||
<span className={styles.meta}>剩余钻石转美元 {item.residualDiamondToUsdRate || "0"}</span>
|
<span className={styles.meta}>{residualRateLabel(item.residualDiamondToUsdRate)}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/features/host-agency-policy/residualRate.js
Normal file
37
src/features/host-agency-policy/residualRate.js
Normal 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";
|
||||||
|
}
|
||||||
20
src/features/host-agency-policy/residualRate.test.js
Normal file
20
src/features/host-agency-policy/residualRate.test.js
Normal 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("剩余钻石不折美元");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -21,11 +21,10 @@ export const hostAgencyPolicyFormSchema = z
|
|||||||
effectiveFrom: z.union([z.string(), z.number()]).optional(),
|
effectiveFrom: z.union([z.string(), z.number()]).optional(),
|
||||||
effectiveTo: z.union([z.string(), z.number()]).optional(),
|
effectiveTo: z.union([z.string(), z.number()]).optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
giftCoinToDiamondRatio: z.union([z.string(), z.number()]),
|
|
||||||
levels: z.array(levelSchema).min(1, "请至少配置一个等级"),
|
levels: z.array(levelSchema).min(1, "请至少配置一个等级"),
|
||||||
name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"),
|
name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"),
|
||||||
regionId: z.union([z.string(), z.number()]),
|
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),
|
settlementMode: z.enum(settlementModes),
|
||||||
settlementTriggerMode: z.enum(settlementTriggerModes),
|
settlementTriggerMode: z.enum(settlementTriggerModes),
|
||||||
})
|
})
|
||||||
@ -35,14 +34,11 @@ export const hostAgencyPolicyFormSchema = z
|
|||||||
if (!Number.isInteger(regionId) || regionId <= 0) {
|
if (!Number.isInteger(regionId) || regionId <= 0) {
|
||||||
context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] });
|
context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] });
|
||||||
}
|
}
|
||||||
if (!isFixedDecimal(value.giftCoinToDiamondRatio, 6, false)) {
|
if (!isNonNegativeInteger(value.residualDiamondsPerUsd)) {
|
||||||
context.addIssue({ code: "custom", message: "金币转钻石比例不正确", path: ["giftCoinToDiamondRatio"] });
|
|
||||||
}
|
|
||||||
if (!isFixedDecimal(value.residualDiamondToUsdRate, 12, true)) {
|
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "剩余钻石转美元比例不正确",
|
message: "剩余钻石兑美元配置不正确",
|
||||||
path: ["residualDiamondToUsdRate"],
|
path: ["residualDiamondsPerUsd"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const effectiveFromMs = timeValueToMs(value.effectiveFrom);
|
const effectiveFromMs = timeValueToMs(value.effectiveFrom);
|
||||||
@ -190,6 +186,11 @@ function isFixedDecimal(value: unknown, scale: number, allowZero: boolean) {
|
|||||||
return decimalToScaled(value, scale, allowZero) >= 0;
|
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) {
|
function decimalToScaled(value: unknown, scale: number, allowZero = true) {
|
||||||
// 小数校验转换成整数刻度比较,避免 JS 浮点数直接参与金额大小判断。
|
// 小数校验转换成整数刻度比较,避免 JS 浮点数直接参与金额大小判断。
|
||||||
const raw = String(value ?? "").trim();
|
const raw = String(value ?? "").trim();
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import type {
|
|||||||
BDProfileDto,
|
BDProfileDto,
|
||||||
BDStatusPayload,
|
BDStatusPayload,
|
||||||
CoinSellerDto,
|
CoinSellerDto,
|
||||||
|
CoinSellerSalaryRatesDto,
|
||||||
|
CoinSellerSalaryRatesPayload,
|
||||||
CoinSellerStockCreditDto,
|
CoinSellerStockCreditDto,
|
||||||
CoinSellerStockCreditPayload,
|
CoinSellerStockCreditPayload,
|
||||||
CoinSellerStatusPayload,
|
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> {
|
export function createAgency(payload: CreateAgencyPayload): Promise<CreateAgencyResultDto> {
|
||||||
const endpoint = API_ENDPOINTS.createAgency;
|
const endpoint = API_ENDPOINTS.createAgency;
|
||||||
return apiRequest<CreateAgencyResultDto, CreateAgencyPayload>(apiEndpointPath(API_OPERATIONS.createAgency), {
|
return apiRequest<CreateAgencyResultDto, CreateAgencyPayload>(apiEndpointPath(API_OPERATIONS.createAgency), {
|
||||||
|
|||||||
@ -3,7 +3,14 @@ import { parseForm } from "@/shared/forms/validation";
|
|||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
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 { useCoinSellerAbilities } from "@/features/host-org/permissions.js";
|
||||||
import {
|
import {
|
||||||
coinSellerStatusSchema,
|
coinSellerStatusSchema,
|
||||||
@ -28,6 +35,13 @@ const emptyStockForm = () => ({
|
|||||||
rechargeAmount: "",
|
rechargeAmount: "",
|
||||||
type: "usdt_purchase",
|
type: "usdt_purchase",
|
||||||
});
|
});
|
||||||
|
const emptyRateTier = (index = 0) => ({
|
||||||
|
coinPerUsd: "",
|
||||||
|
enabled: true,
|
||||||
|
maxUsd: "",
|
||||||
|
minUsd: "",
|
||||||
|
sortOrder: (index + 1) * 10,
|
||||||
|
});
|
||||||
|
|
||||||
export function useHostCoinSellersPage() {
|
export function useHostCoinSellersPage() {
|
||||||
const abilities = useCoinSellerAbilities();
|
const abilities = useCoinSellerAbilities();
|
||||||
@ -41,6 +55,9 @@ export function useHostCoinSellersPage() {
|
|||||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||||
const [editForm, setEditForm] = useState(emptyEditForm);
|
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||||
const [stockForm, setStockForm] = useState(emptyStockForm);
|
const [stockForm, setStockForm] = useState(emptyStockForm);
|
||||||
|
const [rateRegionId, setRateRegionId] = useState("");
|
||||||
|
const [rateTiers, setRateTiers] = useState([]);
|
||||||
|
const [loadingRates, setLoadingRates] = useState(false);
|
||||||
const [selectedSeller, setSelectedSeller] = useState(null);
|
const [selectedSeller, setSelectedSeller] = useState(null);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
@ -117,11 +134,69 @@ export function useHostCoinSellersPage() {
|
|||||||
setActiveAction("stock");
|
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 = () => {
|
const closeAction = () => {
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
setSelectedSeller(null);
|
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) => {
|
const submitCreateSeller = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await runAction("coin-seller-create", "币商已添加", async () => {
|
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") => {
|
const toggleSeller = async (seller, nextEnabled = seller.status !== "active") => {
|
||||||
if (!abilities.canUpdate || !seller?.userId) {
|
if (!abilities.canUpdate || !seller?.userId) {
|
||||||
return;
|
return;
|
||||||
@ -206,7 +304,9 @@ export function useHostCoinSellersPage() {
|
|||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
|
loadingRates,
|
||||||
loadingRegions,
|
loadingRegions,
|
||||||
|
openRateSettings,
|
||||||
openCreateSeller,
|
openCreateSeller,
|
||||||
openEditSeller,
|
openEditSeller,
|
||||||
openStockCredit,
|
openStockCredit,
|
||||||
@ -214,7 +314,12 @@ export function useHostCoinSellersPage() {
|
|||||||
query,
|
query,
|
||||||
regionId,
|
regionId,
|
||||||
regionOptions,
|
regionOptions,
|
||||||
|
rateRegionId,
|
||||||
|
rateTiers,
|
||||||
reload,
|
reload,
|
||||||
|
addRateTier,
|
||||||
|
changeRateRegionId,
|
||||||
|
removeRateTier,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
selectedSeller,
|
selectedSeller,
|
||||||
setCreateForm,
|
setCreateForm,
|
||||||
@ -223,13 +328,23 @@ export function useHostCoinSellersPage() {
|
|||||||
setStockForm,
|
setStockForm,
|
||||||
status,
|
status,
|
||||||
stockForm,
|
stockForm,
|
||||||
|
submitRateSettings,
|
||||||
submitCreateSeller,
|
submitCreateSeller,
|
||||||
submitEditSeller,
|
submitEditSeller,
|
||||||
submitStockCredit,
|
submitStockCredit,
|
||||||
toggleSeller,
|
toggleSeller,
|
||||||
|
updateRateTier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCommandId(prefix) {
|
function makeCommandId(prefix) {
|
||||||
return `${prefix}-${Date.now()}`;
|
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")}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -100,6 +100,43 @@
|
|||||||
gap: var(--space-2);
|
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 {
|
.flagCell {
|
||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import DeleteOutline from "@mui/icons-material/DeleteOutline";
|
||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
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 MenuItem from "@mui/material/MenuItem";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
@ -12,6 +15,7 @@ import {
|
|||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.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 { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
@ -121,9 +125,14 @@ export function HostCoinSellersPage() {
|
|||||||
value: page.regionId,
|
value: page.regionId,
|
||||||
onChange: page.changeRegionId,
|
onChange: page.changeRegionId,
|
||||||
});
|
});
|
||||||
const toolbarActions = page.abilities.canCreate
|
const toolbarActions = [
|
||||||
? [{ icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }]
|
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 (
|
return (
|
||||||
<section className={styles.root}>
|
<section className={styles.root}>
|
||||||
@ -274,6 +283,91 @@ export function HostCoinSellersPage() {
|
|||||||
/>
|
/>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
</HostOrgActionModal>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,6 +56,7 @@ export function useCoinSellerAbilities() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
canCreate: can(PERMISSIONS.coinSellerCreate),
|
canCreate: can(PERMISSIONS.coinSellerCreate),
|
||||||
|
canExchangeRate: can(PERMISSIONS.coinSellerExchangeRate),
|
||||||
canStockCredit: can(PERMISSIONS.coinSellerStockCredit),
|
canStockCredit: can(PERMISSIONS.coinSellerStockCredit),
|
||||||
canUpdate: can(PERMISSIONS.coinSellerUpdate),
|
canUpdate: can(PERMISSIONS.coinSellerUpdate),
|
||||||
canView: can(PERMISSIONS.coinSellerView),
|
canView: can(PERMISSIONS.coinSellerView),
|
||||||
|
|||||||
@ -85,6 +85,7 @@ export const API_OPERATIONS = {
|
|||||||
exportLoginLogs: "exportLoginLogs",
|
exportLoginLogs: "exportLoginLogs",
|
||||||
exportOperationLogs: "exportOperationLogs",
|
exportOperationLogs: "exportOperationLogs",
|
||||||
exportUsers: "exportUsers",
|
exportUsers: "exportUsers",
|
||||||
|
getCoinSellerSalaryRates: "getCoinSellerSalaryRates",
|
||||||
getCountry: "getCountry",
|
getCountry: "getCountry",
|
||||||
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
||||||
getLuckyGiftConfig: "getLuckyGiftConfig",
|
getLuckyGiftConfig: "getLuckyGiftConfig",
|
||||||
@ -159,6 +160,7 @@ export const API_OPERATIONS = {
|
|||||||
navigationMenus: "navigationMenus",
|
navigationMenus: "navigationMenus",
|
||||||
publishHostAgencyPolicy: "publishHostAgencyPolicy",
|
publishHostAgencyPolicy: "publishHostAgencyPolicy",
|
||||||
refresh: "refresh",
|
refresh: "refresh",
|
||||||
|
replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates",
|
||||||
replaceRegionBlocks: "replaceRegionBlocks",
|
replaceRegionBlocks: "replaceRegionBlocks",
|
||||||
replaceRegionCountries: "replaceRegionCountries",
|
replaceRegionCountries: "replaceRegionCountries",
|
||||||
replaceRoleDataScopes: "replaceRoleDataScopes",
|
replaceRoleDataScopes: "replaceRoleDataScopes",
|
||||||
@ -721,6 +723,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:export",
|
permission: "user:export",
|
||||||
permissions: ["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: {
|
getCountry: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getCountry,
|
operationId: API_OPERATIONS.getCountry,
|
||||||
@ -1227,6 +1236,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
operationId: API_OPERATIONS.refresh,
|
operationId: API_OPERATIONS.refresh,
|
||||||
path: "/v1/auth/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: {
|
replaceRegionBlocks: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.replaceRegionBlocks,
|
operationId: API_OPERATIONS.replaceRegionBlocks,
|
||||||
|
|||||||
44
src/shared/api/generated/schema.d.ts
vendored
44
src/shared/api/generated/schema.d.ts
vendored
@ -660,6 +660,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/countries": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3512,6 +3528,34 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
listCountries: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -457,6 +457,39 @@ export interface CoinSellerStockCreditDto {
|
|||||||
transactionId: string;
|
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 {
|
export interface AppUserDto {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
coin?: number;
|
coin?: number;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user