568 lines
24 KiB
JavaScript
568 lines
24 KiB
JavaScript
import Add from "@mui/icons-material/Add";
|
|
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
|
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
|
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
|
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
|
|
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";
|
|
import {
|
|
AdminFormAmountField,
|
|
AdminFormFieldGrid,
|
|
AdminFormReadOnlyField,
|
|
AdminFormSection,
|
|
} from "@/shared/ui/AdminFormDialog.jsx";
|
|
import { formatMillis } from "@/shared/utils/time.js";
|
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
|
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
|
import { InlineEditableCell } from "@/shared/ui/InlineEditableCell.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";
|
|
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
|
import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSellersPage.js";
|
|
import { CoinSellerLedgerTable } from "@/features/operations/components/CoinSellerLedgerTable.jsx";
|
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
|
import styles from "@/features/host-org/host-org.module.css";
|
|
|
|
const baseColumns = [
|
|
{
|
|
key: "seller",
|
|
label: "币商",
|
|
render: (item) => <SellerIdentity item={item} />,
|
|
width: "minmax(220px, 1.3fr)",
|
|
},
|
|
{
|
|
key: "regionId",
|
|
label: "区域",
|
|
render: (item, _index, context) => regionName(item, context?.regionOptions),
|
|
width: "minmax(140px, 0.8fr)",
|
|
},
|
|
{
|
|
key: "contact",
|
|
label: "联系方式",
|
|
render: (item, _index, context) => <SellerContact item={item} page={context?.page} />,
|
|
width: "minmax(170px, 1fr)",
|
|
},
|
|
{
|
|
key: "merchantBalance",
|
|
label: "币商余额",
|
|
sortable: true,
|
|
sortField: "merchant_balance",
|
|
render: (item) => formatNumber(item.merchantBalance),
|
|
width: "minmax(120px, 0.8fr)",
|
|
},
|
|
{
|
|
key: "totalRechargeUsdt",
|
|
label: "累充USDT",
|
|
sortable: true,
|
|
sortField: "total_recharge_usdt",
|
|
render: (item) => formatUSDTMicro(item.totalRechargeUsdtMicro),
|
|
width: "minmax(130px, 0.8fr)",
|
|
},
|
|
{
|
|
key: "updatedAtMs",
|
|
label: "更新时间",
|
|
render: (item) => formatMillis(item.updatedAtMs),
|
|
width: "minmax(170px, 1fr)",
|
|
},
|
|
];
|
|
|
|
export function HostCoinSellersPage() {
|
|
const page = useHostCoinSellersPage();
|
|
const items = page.data.items || [];
|
|
const total = page.data.total || 0;
|
|
const createDisabled = !page.abilities.canCreate;
|
|
const updateDisabled = !page.abilities.canUpdate;
|
|
const columns = [
|
|
...baseColumns.slice(0, 2),
|
|
{
|
|
key: "status",
|
|
label: "状态",
|
|
render: (item) => <SellerStatusSwitch item={item} page={page} />,
|
|
width: "minmax(90px, 0.6fr)",
|
|
},
|
|
...baseColumns.slice(2),
|
|
page.abilities.canStockCredit || page.abilities.canLedger
|
|
? {
|
|
key: "actions",
|
|
label: "操作",
|
|
render: (item) => <SellerActions item={item} page={page} />,
|
|
width: "minmax(90px, 0.5fr)",
|
|
}
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.map((column) => {
|
|
if (column.key === "seller") {
|
|
return {
|
|
...column,
|
|
filter: createTextColumnFilter({
|
|
placeholder: "搜索用户 ID、展示 ID、用户名",
|
|
value: page.query,
|
|
onChange: page.changeQuery,
|
|
}),
|
|
};
|
|
}
|
|
if (column.key === "status") {
|
|
return {
|
|
...column,
|
|
filter: createOptionsColumnFilter({
|
|
options: coinSellerStatusFilters,
|
|
placeholder: "搜索状态",
|
|
value: page.status,
|
|
onChange: page.changeStatus,
|
|
}),
|
|
};
|
|
}
|
|
if (column.sortable) {
|
|
return {
|
|
...column,
|
|
header: (
|
|
<SortHeader
|
|
field={column.sortField}
|
|
label={column.label}
|
|
sortBy={page.sortBy}
|
|
sortDirection={page.sortDirection}
|
|
onToggle={page.changeSort}
|
|
/>
|
|
),
|
|
};
|
|
}
|
|
return column;
|
|
});
|
|
const regionFilter = createRegionColumnFilter({
|
|
loading: page.loadingRegions,
|
|
options: page.regionOptions,
|
|
value: page.regionId,
|
|
onChange: page.changeRegionId,
|
|
});
|
|
const leadingActions = [
|
|
page.abilities.canExchangeRate
|
|
? { icon: <SettingsOutlined fontSize="small" />, label: "工资兑换比例", onClick: page.openRateSettings }
|
|
: null,
|
|
].filter(Boolean);
|
|
const toolbarActions = [
|
|
page.abilities.canCreate
|
|
? { icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }
|
|
: null,
|
|
].filter(Boolean);
|
|
|
|
return (
|
|
<section className={styles.root}>
|
|
<div className={styles.contentPanel}>
|
|
<HostOrgToolbar
|
|
actions={toolbarActions}
|
|
leadingActions={leadingActions}
|
|
/>
|
|
|
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
|
<div className={styles.listBlock}>
|
|
<HostOrgTable
|
|
columns={columns}
|
|
context={{ page, regionOptions: page.regionOptions }}
|
|
items={items}
|
|
minWidth="930px"
|
|
pagination={
|
|
total > 0
|
|
? {
|
|
page: page.page,
|
|
pageSize: page.data.pageSize || 50,
|
|
total,
|
|
onPageChange: page.setPage,
|
|
}
|
|
: undefined
|
|
}
|
|
regionFilter={regionFilter}
|
|
rowKey={(item) => item.userId}
|
|
/>
|
|
</div>
|
|
</DataState>
|
|
</div>
|
|
|
|
<HostOrgActionModal
|
|
disabled={createDisabled}
|
|
loading={page.loadingAction === "coin-seller-create"}
|
|
open={page.activeAction === "create"}
|
|
sectionTitle="币商信息"
|
|
onClose={page.closeAction}
|
|
onSubmit={page.submitCreateSeller}
|
|
title="添加币商"
|
|
>
|
|
<TextField
|
|
disabled={createDisabled}
|
|
label="用户短 ID"
|
|
required
|
|
type="number"
|
|
value={page.createForm.targetUserId}
|
|
onChange={(event) => page.setCreateForm({ ...page.createForm, targetUserId: event.target.value })}
|
|
/>
|
|
<TextField
|
|
disabled={createDisabled}
|
|
label="联系方式"
|
|
value={page.createForm.contact}
|
|
onChange={(event) => page.setCreateForm({ ...page.createForm, contact: event.target.value })}
|
|
/>
|
|
</HostOrgActionModal>
|
|
|
|
<HostOrgActionModal
|
|
disabled={updateDisabled}
|
|
loading={String(page.loadingAction).startsWith("coin-seller-edit")}
|
|
open={page.activeAction === "edit"}
|
|
sectionTitle="联系方式"
|
|
onClose={page.closeAction}
|
|
onSubmit={page.submitEditSeller}
|
|
title="编辑联系方式"
|
|
>
|
|
<TextField disabled label="用户 ID" required value={page.editForm.targetUserId} />
|
|
<TextField
|
|
disabled={updateDisabled}
|
|
label="联系方式"
|
|
value={page.editForm.contact}
|
|
onChange={(event) => page.setEditForm({ ...page.editForm, contact: event.target.value })}
|
|
/>
|
|
</HostOrgActionModal>
|
|
|
|
<HostOrgActionModal
|
|
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
|
|
loading={String(page.loadingAction).startsWith("coin-seller-stock")}
|
|
open={page.activeAction === "stock"}
|
|
sectioned={false}
|
|
size="standard"
|
|
submitLabel="确认入账"
|
|
onClose={page.closeAction}
|
|
onSubmit={page.submitStockCredit}
|
|
title="币商进货"
|
|
>
|
|
<AdminFormSection title="基础信息">
|
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
|
<AdminFormReadOnlyField label="币商用户 ID" value={page.selectedSeller?.userId || ""} />
|
|
<AdminFormReadOnlyField
|
|
label="当前库存余额"
|
|
value={formatNumber(page.selectedSeller?.merchantBalance)}
|
|
/>
|
|
</AdminFormFieldGrid>
|
|
</AdminFormSection>
|
|
<AdminFormSection title="充值信息">
|
|
<TextField
|
|
disabled={!page.abilities.canStockCredit}
|
|
label="类型"
|
|
required
|
|
select
|
|
value={page.stockForm.type}
|
|
onChange={(event) =>
|
|
page.setStockForm({
|
|
...page.stockForm,
|
|
type: event.target.value,
|
|
rechargeAmount: "",
|
|
})
|
|
}
|
|
>
|
|
<MenuItem value="usdt_purchase">USDT进货</MenuItem>
|
|
<MenuItem value="coin_compensation">金币补偿</MenuItem>
|
|
</TextField>
|
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
|
<AdminFormAmountField
|
|
disabled={!page.abilities.canStockCredit}
|
|
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
|
label="充值金币"
|
|
required
|
|
unit="金币"
|
|
value={page.stockForm.coinAmount}
|
|
onChange={(event) =>
|
|
page.setStockForm({ ...page.stockForm, coinAmount: event.target.value })
|
|
}
|
|
/>
|
|
{page.stockForm.type === "usdt_purchase" ? (
|
|
<AdminFormAmountField
|
|
disabled={!page.abilities.canStockCredit}
|
|
label="充值金额"
|
|
required
|
|
unit="USDT"
|
|
value={page.stockForm.rechargeAmount}
|
|
onChange={(event) =>
|
|
page.setStockForm({ ...page.stockForm, rechargeAmount: event.target.value })
|
|
}
|
|
/>
|
|
) : null}
|
|
</AdminFormFieldGrid>
|
|
</AdminFormSection>
|
|
<AdminFormSection title="备注信息">
|
|
<TextField
|
|
disabled={!page.abilities.canStockCredit}
|
|
label="原因"
|
|
multiline
|
|
minRows={2}
|
|
value={page.stockForm.reason}
|
|
onChange={(event) => page.setStockForm({ ...page.stockForm, reason: event.target.value })}
|
|
/>
|
|
</AdminFormSection>
|
|
</HostOrgActionModal>
|
|
|
|
<SideDrawer
|
|
className={styles.ledgerDrawer}
|
|
contentClassName={styles.ledgerDrawerBody}
|
|
open={page.activeAction === "ledger"}
|
|
title={`币商流水 - ${sellerDrawerTitle(page.selectedSeller)}`}
|
|
width="wide"
|
|
onClose={page.closeAction}
|
|
>
|
|
{page.selectedSeller ? (
|
|
<CoinSellerLedgerTable lockedSeller={page.selectedSeller} sellerUserId={page.selectedSeller.userId} />
|
|
) : null}
|
|
</SideDrawer>
|
|
|
|
<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)}
|
|
>
|
|
<DeleteOutlineOutlined fontSize="small" />
|
|
</AdminActionIconButton>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</form>
|
|
</SideDrawer>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SellerIdentity({ item }) {
|
|
const name = item.username || "-";
|
|
const meta = item.displayUserId || item.userId;
|
|
|
|
return (
|
|
<div className={styles.sellerIdentity}>
|
|
{item.avatar ? (
|
|
<img alt="" className={styles.sellerAvatar} src={item.avatar} />
|
|
) : (
|
|
<span className={styles.sellerAvatar}>{name.slice(0, 1).toUpperCase()}</span>
|
|
)}
|
|
<span className={styles.sellerText}>
|
|
<span className={styles.sellerName}>{name}</span>
|
|
<span className={styles.sellerMeta}>{meta}</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SellerStatusSwitch({ item, page }) {
|
|
return (
|
|
<AdminSwitch
|
|
checked={item.status === "active"}
|
|
disabled={!page.abilities.canUpdate || page.loadingAction === `coin-seller-status-${item.userId}`}
|
|
inputProps={{ "aria-label": item.status === "active" ? "停用币商" : "启用币商" }}
|
|
onChange={(event) => page.toggleSeller(item, event.target.checked)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SellerContact({ item, page }) {
|
|
const contact = String(item.contact || "").trim();
|
|
return (
|
|
<InlineEditableCell
|
|
buttonTitle="点击编辑联系方式"
|
|
displayValue={contact || (page?.abilities.canUpdate ? "填写联系方式" : "未填写")}
|
|
editable={Boolean(page?.abilities.canUpdate)}
|
|
emptyLabel={page?.abilities.canUpdate ? "填写联系方式" : "未填写"}
|
|
inputProps={{ maxLength: 128 }}
|
|
maxWidth="170px"
|
|
normalizeValue={(value) => String(value ?? "").trim()}
|
|
saving={Boolean(page?.contactSavingIds?.[item.userId])}
|
|
value={contact}
|
|
variant="link"
|
|
width="100%"
|
|
onSave={(nextValue) => page.saveSellerContact(item, nextValue)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SellerActions({ item, page }) {
|
|
return (
|
|
<AdminRowActions>
|
|
{page.abilities.canLedger ? (
|
|
<AdminActionIconButton label="币商流水" sx={coinLedgerActionSx} onClick={() => page.openSellerLedger(item)}>
|
|
<ReceiptLongOutlined fontSize="small" />
|
|
</AdminActionIconButton>
|
|
) : null}
|
|
{page.abilities.canStockCredit ? (
|
|
<AdminActionIconButton
|
|
disabled={item.status !== "active" || page.loadingAction === `coin-seller-stock-${item.userId}`}
|
|
label="加金币"
|
|
sx={coinCreditActionSx}
|
|
onClick={() => page.openStockCredit(item)}
|
|
>
|
|
<MonetizationOnOutlined fontSize="small" />
|
|
</AdminActionIconButton>
|
|
) : null}
|
|
</AdminRowActions>
|
|
);
|
|
}
|
|
|
|
function sellerDrawerTitle(seller) {
|
|
if (!seller) {
|
|
return "";
|
|
}
|
|
const name = seller.username || "-";
|
|
const displayId = seller.displayUserId || seller.userId || "";
|
|
return displayId ? `${name} / ${displayId}` : name;
|
|
}
|
|
|
|
function regionName(item, regionOptions = []) {
|
|
if (item.regionName) {
|
|
return item.regionName;
|
|
}
|
|
const option = regionOptions.find((region) => String(region.value) === String(item.regionId));
|
|
return option?.name || item.regionId || "-";
|
|
}
|
|
|
|
function formatNumber(value) {
|
|
return Number(value || 0).toLocaleString("zh-CN");
|
|
}
|
|
|
|
function formatUSDTMicro(value) {
|
|
const amount = Number(value || 0);
|
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|
return "0";
|
|
}
|
|
const whole = Math.trunc(amount / 1_000_000);
|
|
const fraction = Math.trunc(Math.abs(amount % 1_000_000));
|
|
const trimmedFraction = String(fraction).padStart(6, "0").replace(/0+$/, "");
|
|
return trimmedFraction ? `${whole}.${trimmedFraction}` : String(whole);
|
|
}
|
|
|
|
function SortHeader({ field, label, onToggle, sortBy, sortDirection }) {
|
|
const active = sortBy === field;
|
|
const SortIcon =
|
|
active && sortDirection === "asc"
|
|
? ArrowUpwardOutlined
|
|
: active && sortDirection === "desc"
|
|
? ArrowDownwardOutlined
|
|
: SwapVertOutlined;
|
|
const directionLabel = sortDirection === "asc" ? "升序" : "降序";
|
|
const nextDirectionLabel = active && sortDirection === "desc" ? "升序" : "降序";
|
|
return (
|
|
<button
|
|
aria-label={`按${label}${nextDirectionLabel}排序`}
|
|
className={["admin-cell__head-trigger", active ? "admin-cell__head-trigger--active" : ""]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
type="button"
|
|
onClick={() => onToggle(field)}
|
|
>
|
|
<SortIcon className="admin-cell__head-icon" fontSize="inherit" />
|
|
<span className="admin-cell__head-label">
|
|
{label}
|
|
{active ? <span className="admin-cell__head-filter">({directionLabel})</span> : null}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const coinLedgerActionSx = {
|
|
borderColor: "rgba(37, 99, 235, 0.28)",
|
|
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
|
color: "#1d4ed8",
|
|
"&:hover": {
|
|
borderColor: "rgba(37, 99, 235, 0.48)",
|
|
backgroundColor: "rgba(37, 99, 235, 0.14)",
|
|
color: "#1e40af",
|
|
},
|
|
"& .MuiSvgIcon-root": {
|
|
color: "#2563eb",
|
|
},
|
|
};
|
|
|
|
const coinCreditActionSx = {
|
|
borderColor: "rgba(245, 158, 11, 0.45)",
|
|
backgroundColor: "rgba(251, 191, 36, 0.16)",
|
|
color: "#b45309",
|
|
"&:hover": {
|
|
borderColor: "rgba(245, 158, 11, 0.72)",
|
|
backgroundColor: "rgba(251, 191, 36, 0.24)",
|
|
color: "#92400e",
|
|
},
|
|
"& .MuiSvgIcon-root": {
|
|
color: "#f59e0b",
|
|
},
|
|
};
|