diff --git a/src/features/host-org/pages/HostAgenciesPage.jsx b/src/features/host-org/pages/HostAgenciesPage.jsx index 63c7e6d..f527b81 100644 --- a/src/features/host-org/pages/HostAgenciesPage.jsx +++ b/src/features/host-org/pages/HostAgenciesPage.jsx @@ -178,7 +178,6 @@ export function HostAgenciesPage() { page.setAgencyForm({ ...page.agencyForm, parentBdUserId: event.target.value })} diff --git a/src/features/host-org/pages/HostBdsPage.jsx b/src/features/host-org/pages/HostBdsPage.jsx index 78baa64..8d7d855 100644 --- a/src/features/host-org/pages/HostBdsPage.jsx +++ b/src/features/host-org/pages/HostBdsPage.jsx @@ -153,7 +153,6 @@ export function HostBdsPage() { page.setBDForm({ ...page.bdForm, parentLeaderUserId: event.target.value })} diff --git a/src/features/host-org/pages/HostCoinSellersPage.jsx b/src/features/host-org/pages/HostCoinSellersPage.jsx index b457c91..48e4584 100644 --- a/src/features/host-org/pages/HostCoinSellersPage.jsx +++ b/src/features/host-org/pages/HostCoinSellersPage.jsx @@ -1,5 +1,5 @@ import Add from "@mui/icons-material/Add"; -import DeleteOutline from "@mui/icons-material/DeleteOutline"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import Button from "@mui/material/Button"; @@ -361,7 +361,7 @@ export function HostCoinSellersPage() { label="删除区间" onClick={() => page.removeRateTier(index)} > - + ))} diff --git a/src/features/host-org/pages/HostHostsPage.jsx b/src/features/host-org/pages/HostHostsPage.jsx index cd81fff..0c88132 100644 --- a/src/features/host-org/pages/HostHostsPage.jsx +++ b/src/features/host-org/pages/HostHostsPage.jsx @@ -21,6 +21,12 @@ const hostColumns = [ render: (item) => , width: "minmax(90px, 0.7fr)", }, + { + key: "diamond", + label: "钻石", + render: (item) => formatNumber(item.diamond), + width: "minmax(110px, 0.7fr)", + }, { key: "regionId", label: "区域", @@ -114,7 +120,7 @@ export function HostHostsPage() { columns={columns} context={{ regionOptions: page.regionOptions }} items={items} - minWidth="1240px" + minWidth="1350px" pagination={ total > 0 ? { @@ -134,6 +140,10 @@ export function HostHostsPage() { ); } +function formatNumber(value) { + return Number(value || 0).toLocaleString("zh-CN"); +} + function HostUser({ item }) { return ( { + if (value === null || value === undefined) { + return 0; + } + if (typeof value === "string" && value.trim() === "") { + return 0; + } + return value; +}, z.coerce.number().int().min(0, "请输入有效用户短 ID")); const regionIdSchema = z.coerce.number().int().positive("请输入有效区域 ID"); const sortOrderSchema = z.coerce.number().int().min(0, "排序不能小于 0").default(0); const optionalTextSchema = (max: number, message: string) => z.string().trim().max(max, message).optional().default(""); @@ -62,7 +71,7 @@ export const createBDLeaderSchema = commandContactSchema.extend({ }); export const createBDSchema = commandBaseSchema.extend({ - parentLeaderUserId: userIdSchema, + parentLeaderUserId: optionalUserIdSchema, targetUserId: userIdSchema, }); @@ -106,7 +115,7 @@ export const createAgencySchema = commandBaseSchema.extend({ maxHosts: z.coerce.number().int().min(0, "最大主播数不能小于 0").default(0), name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"), ownerUserId: userIdSchema, - parentBdUserId: userIdSchema, + parentBdUserId: optionalUserIdSchema, }); export const agencyCloseSchema = commandBaseSchema.extend({ diff --git a/src/features/payment/pages/PaymentBillListPage.jsx b/src/features/payment/pages/PaymentBillListPage.jsx index 4b6c12e..9c813d0 100644 --- a/src/features/payment/pages/PaymentBillListPage.jsx +++ b/src/features/payment/pages/PaymentBillListPage.jsx @@ -1,16 +1,28 @@ -import { useMemo, useState } from "react"; +import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined"; +import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; +import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined"; +import Tooltip from "@mui/material/Tooltip"; +import { useEffect, useMemo, useState } from "react"; import { listRechargeBills } from "@/features/payment/api"; import { AdminListBody, AdminListPage, + 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 { IconButton } from "@/shared/ui/IconButton.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; +import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; +import styles from "@/features/payment/payment.module.css"; const pageSize = 50; +const exportPageSize = 100; const statusOptions = [ ["", "全部状态"], ["succeeded", "成功"], @@ -24,20 +36,20 @@ const columns = [ { key: "bill", label: "账单", - width: "minmax(240px, 1.4fr)", - render: (bill) => , + width: "minmax(82px, 0.35fr)", + render: (bill) => , }, { key: "userId", label: "用户 ID", - width: "minmax(120px, 0.65fr)", - render: (bill) => bill.userId || "-", + width: "minmax(190px, 1fr)", + render: (bill) => , }, { key: "sellerUserId", label: "币商 ID", - width: "minmax(120px, 0.65fr)", - render: (bill) => bill.sellerUserId || "-", + width: "minmax(190px, 1fr)", + render: (bill) => , }, { key: "amount", @@ -65,10 +77,10 @@ const columns = [ key: "region", label: "区域", width: "minmax(110px, 0.55fr)", - render: (bill) => ( + render: (bill, _index, context) => ( ), }, @@ -99,16 +111,23 @@ export function PaymentBillListPage() { const [rechargeType, setRechargeType] = useState(""); const [userId, setUserId] = useState(""); const [sellerUserId, setSellerUserId] = useState(""); + const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" }); + const [exporting, setExporting] = useState(false); + const { regionOptions } = useRegionOptions(); + const { showToast } = useToast(); + const regionNames = useMemo(() => regionNameMap(regionOptions), [regionOptions]); const filters = useMemo( () => ({ + end_at_ms: timeRange.endMs, keyword, recharge_type: rechargeType, seller_user_id: sellerUserId, + start_at_ms: timeRange.startMs, status, user_id: userId, }), - [keyword, rechargeType, sellerUserId, status, userId], + [keyword, rechargeType, sellerUserId, status, timeRange.endMs, timeRange.startMs, userId], ); const query = usePaginatedQuery({ errorMessage: "获取账单列表失败", @@ -126,6 +145,22 @@ export function PaymentBillListPage() { setter(value); setPage(1); }; + const changeTimeRange = (value) => { + setTimeRange(value); + setPage(1); + }; + const downloadBills = async () => { + setExporting(true); + try { + const bills = await fetchAllRechargeBills(filters); + downloadTextFile(createBillsCsv(bills, regionNames), `hyapp-payment-bills-${filenameTimestamp()}.csv`); + showToast(`已导出 ${bills.length} 条账单`, "success"); + } catch (err) { + showToast(err?.message || "导出账单失败", "error"); + } finally { + setExporting(false); + } + }; const tableColumns = columns.map((column) => { if (column.key === "bill") { @@ -185,12 +220,32 @@ export function PaymentBillListPage() { return ( + } + onClick={downloadBills} + > + {exporting ? "导出中" : "导出"} + + } + filters={ + + } + /> 0 ? { @@ -218,6 +273,58 @@ function Stack({ primary, secondary }) { ); } +function BillCopyButton({ value }) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) { + return undefined; + } + const timer = window.setTimeout(() => setCopied(false), 1200); + return () => window.clearTimeout(timer); + }, [copied]); + + const copyBill = async () => { + if (!value) { + return; + } + await copyText(String(value)); + setCopied(true); + }; + + return ( + + + + + + + + ); +} + +function BillUser({ bill, type }) { + const fallbackId = type === "seller" ? bill.sellerUserId : bill.userId; + const profile = normalizeBillUser(type === "seller" ? bill.seller : bill.user, fallbackId); + if (!profile.userId && !profile.displayUserId && !profile.username) { + return "-"; + } + + const shortId = profile.displayUserId || profile.userId || "-"; + const name = profile.username || "-"; + return ( +
+ + {profile.avatar ? : } + + + {shortId} + {name} + +
+ ); +} + function StatusBadge({ status }) { const tone = status === "succeeded" ? "succeeded" : status === "failed" ? "danger" : "stopped"; return ( @@ -259,6 +366,151 @@ function formatMoney(value, currency = "USD") { })}`; } +function normalizeBillUser(profile, fallbackId) { + const source = profile || {}; + return { + avatar: source.avatar || "", + displayUserId: source.displayUserId || source.display_user_id || "", + userId: String(source.userId || source.user_id || fallbackId || ""), + username: source.username || source.name || "", + }; +} + +function regionNameMap(regionOptions = []) { + return Object.fromEntries( + regionOptions.map((region) => [String(region.regionId || region.value), region.name || region.label]), + ); +} + +function regionName(regionId, regionNames = {}) { + if (!regionId) { + return "-"; + } + return regionNames[String(regionId)] || `区域 ${regionId}`; +} + +async function fetchAllRechargeBills(filters) { + const items = []; + let page = 1; + while (true) { + const data = await listRechargeBills({ ...filters, page, page_size: exportPageSize }); + const batch = Array.isArray(data?.items) ? data.items : []; + items.push(...batch); + + const total = Number(data?.total || 0); + if (!batch.length || (total > 0 && items.length >= total) || batch.length < exportPageSize) { + return items; + } + page += 1; + } +} + +function createBillsCsv(bills, regionNames) { + const headers = [ + "账单", + "命令号", + "外部单号", + "用户长ID", + "用户短ID", + "用户名称", + "币商长ID", + "币商短ID", + "币商名称", + "金币", + "金额", + "兑换政策", + "兑换金币", + "兑换金额", + "区域", + "来源区域", + "充值途径", + "状态", + "创建时间", + ]; + const rows = bills.map((bill) => { + const user = normalizeBillUser(bill.user, bill.userId); + const seller = normalizeBillUser(bill.seller, bill.sellerUserId); + return [ + bill.transactionId, + bill.commandId, + bill.externalRef, + user.userId, + user.displayUserId, + user.username, + seller.userId, + seller.displayUserId, + seller.username, + formatNumber(bill.coinAmount), + formatMoney(bill.usdMinorAmount, bill.currencyCode), + bill.policyVersion, + formatNumber(bill.exchangeCoinAmount), + formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode), + regionName(bill.targetRegionId, regionNames), + bill.sellerRegionId ? regionName(bill.sellerRegionId, regionNames) : "", + rechargeTypeLabel(bill.rechargeType), + statusLabel(bill.status), + formatMillis(bill.createdAtMs), + ]; + }); + return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n"); +} + +function csvCell(value) { + const text = value === 0 || value ? String(value) : ""; + if (/[",\n\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; +} + +function downloadTextFile(content, filename) { + const blob = new Blob([`\uFEFF${content}`], { type: "text/csv;charset=utf-8" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); +} + +async function copyText(value) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return; + } catch { + // 浏览器可能因权限或非安全上下文拒绝 Clipboard API,这里退回同步复制。 + } + } + + const input = document.createElement("textarea"); + input.value = value; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + input.remove(); +} + +function filenameTimestamp() { + const now = new Date(); + return [ + now.getFullYear(), + pad2(now.getMonth() + 1), + pad2(now.getDate()), + "-", + pad2(now.getHours()), + pad2(now.getMinutes()), + pad2(now.getSeconds()), + ].join(""); +} + +function pad2(value) { + return String(value).padStart(2, "0"); +} + function digitsOnly(value) { return value.replace(/\D/g, ""); } diff --git a/src/features/payment/payment.module.css b/src/features/payment/payment.module.css new file mode 100644 index 0000000..a1702fd --- /dev/null +++ b/src/features/payment/payment.module.css @@ -0,0 +1,56 @@ +.copyWrap { + display: inline-flex; + align-items: center; +} + +.identity { + display: inline-flex; + min-width: 0; + align-items: center; + gap: var(--space-3); +} + +.avatar { + display: inline-flex; + width: 32px; + height: 32px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--bg-card-strong); + color: var(--text-tertiary); +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.identityText { + display: inline-flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.identityId, +.identityName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.identityId { + color: var(--text-primary); + font-weight: 700; +} + +.identityName { + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 61cea0e..7cba106 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -300,7 +300,16 @@ export interface RechargeBillDto { targetRegionId?: number; transactionId: string; usdMinorAmount: number; + user?: RechargeBillUserDto; userId: number; + seller?: RechargeBillUserDto; +} + +export interface RechargeBillUserDto { + avatar?: string; + displayUserId?: string; + userId?: string; + username?: string; } export interface RechargeProductDto { @@ -410,6 +419,7 @@ export interface HostProfileDto { currentAgencyId?: number; currentAgencyName?: string; currentMembershipId?: number; + diamond?: number; displayUserId?: string; firstBecameHostAtMs?: number; regionId?: number;