主播相关页面改动

This commit is contained in:
zhx 2026-06-05 13:19:29 +08:00
parent 0f92efea87
commit 58be420ae9
8 changed files with 354 additions and 19 deletions

View File

@ -178,7 +178,6 @@ export function HostAgenciesPage() {
<TextField
disabled={createDisabled}
label="上级 BD 短 ID"
required
type="number"
value={page.agencyForm.parentBdUserId}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, parentBdUserId: event.target.value })}

View File

@ -153,7 +153,6 @@ export function HostBdsPage() {
<TextField
disabled={createDisabled}
label="上级 Leader 短 ID"
required
type="number"
value={page.bdForm.parentLeaderUserId}
onChange={(event) => page.setBDForm({ ...page.bdForm, parentLeaderUserId: event.target.value })}

View File

@ -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)}
>
<DeleteOutline fontSize="small" />
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</div>
))}

View File

@ -21,6 +21,12 @@ const hostColumns = [
render: (item) => <HostStatusSwitch item={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 (
<HostOrgPerson

View File

@ -12,6 +12,15 @@ const commandContactSchema = z.object({
});
const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID");
const optionalUserIdSchema = z.preprocess((value) => {
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({

View File

@ -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) => <Stack primary={bill.transactionId} secondary={bill.commandId || bill.externalRef || "-"} />,
width: "minmax(82px, 0.35fr)",
render: (bill) => <BillCopyButton value={bill.transactionId} />,
},
{
key: "userId",
label: "用户 ID",
width: "minmax(120px, 0.65fr)",
render: (bill) => bill.userId || "-",
width: "minmax(190px, 1fr)",
render: (bill) => <BillUser bill={bill} type="user" />,
},
{
key: "sellerUserId",
label: "币商 ID",
width: "minmax(120px, 0.65fr)",
render: (bill) => bill.sellerUserId || "-",
width: "minmax(190px, 1fr)",
render: (bill) => <BillUser bill={bill} type="seller" />,
},
{
key: "amount",
@ -65,10 +77,10 @@ const columns = [
key: "region",
label: "区域",
width: "minmax(110px, 0.55fr)",
render: (bill) => (
render: (bill, _index, context) => (
<Stack
primary={bill.targetRegionId ? `区域 ${bill.targetRegionId}` : "-"}
secondary={bill.sellerRegionId ? `来源 ${bill.sellerRegionId}` : "-"}
primary={regionName(bill.targetRegionId, context?.regionNames)}
secondary={bill.sellerRegionId ? `来源 ${regionName(bill.sellerRegionId, context?.regionNames)}` : "-"}
/>
),
},
@ -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 (
<AdminListPage>
<AdminListToolbar
actions={
<Button
disabled={exporting}
startIcon={<DownloadOutlined fontSize="small" />}
onClick={downloadBills}
>
{exporting ? "导出中" : "导出"}
</Button>
}
filters={
<TimeRangeFilter
disabled={exporting}
label="创建时间"
value={timeRange}
onChange={changeTimeRange}
/>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
context={{ regionNames }}
items={items}
minWidth="1260px"
minWidth="1380px"
pagination={
total > 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 (
<Tooltip arrow title={copied ? "已复制" : "复制账单"}>
<span className={styles.copyWrap}>
<IconButton disabled={!value} label="复制账单" onClick={copyBill}>
<ContentCopyOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
);
}
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 (
<div className={styles.identity}>
<span className={styles.avatar}>
{profile.avatar ? <img alt="" src={profile.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.identityId}>{shortId}</span>
<span className={styles.identityName}>{name}</span>
</span>
</div>
);
}
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, "");
}

View File

@ -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);
}

View File

@ -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;