304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
|
import { useMemo, useState } from "react";
|
|
import { exportCoinSellerLedger, listCoinSellerLedger } from "@/features/operations/api";
|
|
import styles from "@/features/operations/operations.module.css";
|
|
import { downloadCsv } from "@/shared/api/download";
|
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
|
import { AdminFilterResetButton, AdminListBody, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
|
import { Button } from "@/shared/ui/Button.jsx";
|
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
|
import { formatMillis } from "@/shared/utils/time.js";
|
|
|
|
const pageSize = 50;
|
|
|
|
const ledgerTypeOptions = [
|
|
{ label: "后台入账", value: "admin_stock_credit" },
|
|
{ label: "币商转用户", value: "seller_transfer" },
|
|
{ label: "工资转币商", value: "salary_transfer_received" },
|
|
];
|
|
|
|
const ledgerTypeLabels = {
|
|
admin_stock_credit: "后台入账",
|
|
salary_transfer_received: "工资转币商",
|
|
seller_transfer: "币商转用户",
|
|
};
|
|
|
|
const bizTypeLabels = {
|
|
coin_seller_coin_compensation: "金币补偿",
|
|
coin_seller_stock_purchase: "USDT进货",
|
|
coin_seller_transfer: "币商转用户",
|
|
manual_credit: "金币增加",
|
|
salary_transfer_to_coin_seller: "工资转币商",
|
|
};
|
|
|
|
const baseColumns = [
|
|
{
|
|
key: "seller",
|
|
label: "币商信息",
|
|
render: (entry) => <UserCell user={entry.seller} />,
|
|
width: "minmax(240px, 1.2fr)",
|
|
},
|
|
{
|
|
key: "ledgerType",
|
|
label: "流水类型",
|
|
render: (entry) => ledgerTypeLabel(entry),
|
|
width: "minmax(150px, 0.75fr)",
|
|
},
|
|
{
|
|
key: "amount",
|
|
label: "金币变动",
|
|
render: (entry) => <AmountValue entry={entry} />,
|
|
width: "minmax(130px, 0.65fr)",
|
|
},
|
|
{
|
|
key: "transferUsdMinor",
|
|
label: "转账金额",
|
|
render: (entry) => <SalaryTransferAmount entry={entry} />,
|
|
width: "minmax(130px, 0.65fr)",
|
|
},
|
|
{
|
|
key: "paidAmountMicro",
|
|
label: "USDT数量",
|
|
render: (entry) => <PaidUSDTAmount entry={entry} />,
|
|
width: "minmax(130px, 0.65fr)",
|
|
},
|
|
{
|
|
key: "receiver",
|
|
label: "收款人信息",
|
|
render: (entry) => <UserCell user={entry.receiver} />,
|
|
width: "minmax(240px, 1.2fr)",
|
|
},
|
|
{
|
|
key: "operator",
|
|
label: "操作人",
|
|
render: (entry) => <OperatorCell entry={entry} />,
|
|
width: "minmax(130px, 0.65fr)",
|
|
},
|
|
{
|
|
key: "sellerBalanceAfter",
|
|
label: "币商余额",
|
|
render: (entry) => formatNumber(entry.sellerBalanceAfter),
|
|
width: "minmax(120px, 0.65fr)",
|
|
},
|
|
{
|
|
key: "createdAtMs",
|
|
label: "转账时间",
|
|
render: (entry) => formatMillis(entry.createdAtMs),
|
|
width: "minmax(170px, 0.85fr)",
|
|
},
|
|
];
|
|
|
|
export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }) {
|
|
const fixedSellerUserId = String(sellerUserId || lockedSeller?.userId || "").trim();
|
|
const [page, setPage] = useState(1);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [sellerKeyword, setSellerKeyword] = useState("");
|
|
const [ledgerType, setLedgerType] = useState("");
|
|
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
|
const { showToast } = useToast();
|
|
|
|
const filters = useMemo(
|
|
() => ({
|
|
end_at_ms: timeRange.endMs || "",
|
|
ledger_type: ledgerType,
|
|
seller_keyword: fixedSellerUserId ? "" : sellerKeyword,
|
|
seller_user_id: fixedSellerUserId,
|
|
start_at_ms: timeRange.startMs || "",
|
|
}),
|
|
[fixedSellerUserId, ledgerType, sellerKeyword, timeRange.endMs, timeRange.startMs],
|
|
);
|
|
const query = usePaginatedQuery({
|
|
errorMessage: "获取币商流水失败",
|
|
fetcher: listCoinSellerLedger,
|
|
filters,
|
|
page,
|
|
pageSize,
|
|
queryKey: ["coin-seller-ledger", filters, page],
|
|
});
|
|
const data = query.data || { items: [], total: 0, page, pageSize };
|
|
const items = data.items || [];
|
|
const total = data.total || 0;
|
|
const hasActiveFilters = Boolean(sellerKeyword || ledgerType || timeRange.startMs || timeRange.endMs);
|
|
|
|
const changeSellerKeyword = (value) => {
|
|
setSellerKeyword(value);
|
|
setPage(1);
|
|
};
|
|
const changeLedgerType = (value) => {
|
|
setLedgerType(value);
|
|
setPage(1);
|
|
};
|
|
const changeTimeRange = (nextRange) => {
|
|
setTimeRange(nextRange);
|
|
setPage(1);
|
|
};
|
|
const resetFilters = () => {
|
|
setSellerKeyword("");
|
|
setLedgerType("");
|
|
setTimeRange({ endMs: "", startMs: "" });
|
|
setPage(1);
|
|
};
|
|
const downloadLedger = async () => {
|
|
setExporting(true);
|
|
try {
|
|
await downloadCsv(() => exportCoinSellerLedger(filters), "hyapp-coin-seller-ledger.csv");
|
|
showToast("币商流水已导出", "success");
|
|
} catch (err) {
|
|
showToast(err?.message || "导出币商流水失败", "error");
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
const tableColumns = baseColumns
|
|
.filter((column) => !(column.key === "seller" && fixedSellerUserId))
|
|
.map((column) => {
|
|
if (column.key === "seller" && !fixedSellerUserId) {
|
|
return {
|
|
...column,
|
|
filter: createTextColumnFilter({
|
|
placeholder: "长 ID / 短 ID / 名称",
|
|
value: sellerKeyword,
|
|
onChange: changeSellerKeyword,
|
|
}),
|
|
};
|
|
}
|
|
if (column.key === "ledgerType") {
|
|
return {
|
|
...column,
|
|
filter: createOptionsColumnFilter({
|
|
emptyLabel: "全部",
|
|
options: ledgerTypeOptions,
|
|
placeholder: "流水类型",
|
|
value: ledgerType,
|
|
onChange: changeLedgerType,
|
|
}),
|
|
};
|
|
}
|
|
return {
|
|
...column,
|
|
};
|
|
});
|
|
|
|
return (
|
|
<div className={styles.ledgerTableShell}>
|
|
<AdminListToolbar
|
|
filters={
|
|
<>
|
|
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
|
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
|
|
</>
|
|
}
|
|
actions={
|
|
<Button
|
|
disabled={exporting}
|
|
startIcon={<FileDownloadOutlined fontSize="small" />}
|
|
onClick={downloadLedger}
|
|
>
|
|
{exporting ? "导出中" : "导出"}
|
|
</Button>
|
|
}
|
|
/>
|
|
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
|
<AdminListBody>
|
|
<DataTable
|
|
columns={tableColumns}
|
|
emptyLabel="当前无币商流水"
|
|
items={items}
|
|
minWidth={fixedSellerUserId ? "1130px" : "1380px"}
|
|
pagination={
|
|
total > 0
|
|
? {
|
|
page,
|
|
pageSize: data.pageSize || pageSize,
|
|
total,
|
|
onPageChange: setPage,
|
|
}
|
|
: undefined
|
|
}
|
|
rowKey={(entry) => entry.entryId}
|
|
/>
|
|
</AdminListBody>
|
|
</DataState>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OperatorCell({ entry }) {
|
|
if (entry.ledgerType !== "admin_stock_credit") {
|
|
return "-";
|
|
}
|
|
const operator = entry.operator || {};
|
|
const operatorId = String(operator.adminId || entry.operatorUserId || "").trim();
|
|
const name = operator.username || operator.name || operatorId || "-";
|
|
return (
|
|
<span className={styles.identityText}>
|
|
<span className={styles.primaryText}>{name}</span>
|
|
{operatorId && operatorId !== name ? <span className={styles.meta}>{operatorId}</span> : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function UserCell({ user = {} }) {
|
|
return <AdminUserIdentity openInAppUserDetail user={user} />;
|
|
}
|
|
|
|
function AmountValue({ entry }) {
|
|
const expense = entry.direction === "expense" || Number(entry.availableDelta || 0) < 0;
|
|
const sign = expense ? "-" : "+";
|
|
return (
|
|
<span className={`${styles.amount} ${expense ? styles.amountExpense : styles.amountIncome}`}>
|
|
{sign}
|
|
{formatNumber(entry.amount ?? Math.abs(Number(entry.availableDelta || 0)))}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ledgerTypeLabel(entry) {
|
|
if (entry.bizType === "manual_credit" && (entry.direction === "expense" || Number(entry.availableDelta || 0) < 0)) {
|
|
return "金币扣除";
|
|
}
|
|
return bizTypeLabels[entry.bizType] || ledgerTypeLabels[entry.ledgerType] || entry.ledgerType || entry.bizType || "-";
|
|
}
|
|
|
|
function formatNumber(value) {
|
|
return Number(value || 0).toLocaleString("zh-CN");
|
|
}
|
|
|
|
function SalaryTransferAmount({ entry }) {
|
|
if (entry.bizType !== "salary_transfer_to_coin_seller" && entry.ledgerType !== "salary_transfer_received") {
|
|
return "-";
|
|
}
|
|
const amountMinor = Number(entry.transferUsdMinor || entry.metadata?.salary_usd_minor || 0);
|
|
return amountMinor > 0 ? `$${formatUSDMinor(amountMinor)}` : "-";
|
|
}
|
|
|
|
function PaidUSDTAmount({ entry }) {
|
|
const stockType = entry.stockType || entry.metadata?.stock_type;
|
|
if (entry.bizType !== "coin_seller_stock_purchase" && stockType !== "usdt_purchase") {
|
|
return "-";
|
|
}
|
|
const amountMicro = Number(entry.paidAmountMicro || entry.metadata?.paid_amount_micro || 0);
|
|
return amountMicro > 0 ? formatUSDTMicro(amountMicro) : "-";
|
|
}
|
|
|
|
function formatUSDMinor(value) {
|
|
return (Number(value || 0) / 100).toFixed(2);
|
|
}
|
|
|
|
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);
|
|
}
|