主播相关页面改动
This commit is contained in:
parent
0f92efea87
commit
58be420ae9
@ -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 })}
|
||||
|
||||
@ -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 })}
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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, "");
|
||||
}
|
||||
|
||||
56
src/features/payment/payment.module.css
Normal file
56
src/features/payment/payment.module.css
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user