币商流水增加

This commit is contained in:
zhx 2026-06-05 19:09:16 +08:00
parent cf83d334e7
commit af9da06615
24 changed files with 796 additions and 17 deletions

View File

@ -1687,6 +1687,18 @@
"x-permissions": ["coin-ledger:view"]
}
},
"/admin/operations/coin-seller-ledger": {
"get": {
"operationId": "listCoinSellerLedger",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "coin-seller-ledger:view",
"x-permissions": ["coin-seller-ledger:view"]
}
},
"/admin/operations/reports": {
"get": {
"operationId": "listReports",

View File

@ -146,10 +146,11 @@ export const fallbackNavigation = [
label: "运营管理",
children: [
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
routeNavItem("operation-coin-seller-ledger", { icon: ReceiptLongOutlined }),
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
routeNavItem("operation-reports", { icon: FlagOutlined }),
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
],
},
{

View File

@ -38,6 +38,7 @@ export const PERMISSIONS = {
coinSellerStockCredit: "coin-seller:stock-credit",
coinSellerExchangeRate: "coin-seller:exchange-rate",
coinLedgerView: "coin-ledger:view",
coinSellerLedgerView: "coin-seller-ledger:view",
coinAdjustmentView: "coin-adjustment:view",
coinAdjustmentCreate: "coin-adjustment:create",
reportView: "report:view",
@ -174,6 +175,7 @@ export const MENU_CODES = {
emojiPackList: "emoji-pack-list",
operations: "operations",
operationCoinLedger: "operation-coin-ledger",
operationCoinSellerLedger: "operation-coin-seller-ledger",
operationCoinAdjustment: "operation-coin-adjustment",
operationReports: "operation-reports",
operationGiftDiamond: "operation-gift-diamond",

View File

@ -15,6 +15,7 @@ import {
listRegions,
replaceRegionCountries,
setCoinSellerStatus,
updateBDLeaderPositionAlias,
updateCountry,
} from "./api";
@ -41,6 +42,10 @@ test("host org list APIs use generated admin paths and filters", async () => {
regionId: 7,
targetUserId: 1002,
});
await updateBDLeaderPositionAlias(1002, {
commandId: "bd-leader-position-alias-test",
positionAlias: "Regional Admin",
});
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 });
await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" });
await creditCoinSellerStock(1001, {
@ -55,9 +60,10 @@ test("host org list APIs use generated admin paths and filters", async () => {
const [hostUrl] = vi.mocked(fetch).mock.calls[2];
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3];
const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[4];
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[5];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[6];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[7];
const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[5];
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8];
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
@ -77,6 +83,9 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(String(bdLeaderCreateUrl)).toContain("/api/v1/admin/bd-leaders");
expect(bdLeaderCreateInit?.method).toBe("POST");
expect(JSON.parse(String(bdLeaderCreateInit?.body))).toMatchObject({ positionAlias: "Senior Admin" });
expect(String(bdLeaderAliasUrl)).toContain("/api/v1/admin/bd-leaders/1002/position-alias");
expect(bdLeaderAliasInit?.method).toBe("PATCH");
expect(JSON.parse(String(bdLeaderAliasInit?.body))).toMatchObject({ positionAlias: "Regional Admin" });
expect(String(coinSellerCreateUrl)).toContain("/api/v1/admin/coin-sellers");
expect(coinSellerCreateInit?.method).toBe("POST");
expect(String(coinSellerStatusUrl)).toContain("/api/v1/admin/coin-sellers/1001/status");

View File

@ -6,6 +6,7 @@ import type {
AgencyJoinEnabledPayload,
ApiList,
ApiPage,
BDLeaderPositionAliasPayload,
BDProfileDto,
BDStatusPayload,
CoinSellerDto,
@ -200,6 +201,19 @@ export function deleteBDLeader(userId: EntityId): Promise<BDProfileDto> {
});
}
export function updateBDLeaderPositionAlias(
userId: EntityId,
payload: BDLeaderPositionAliasPayload,
): Promise<BDProfileDto> {
return apiRequest<BDProfileDto, BDLeaderPositionAliasPayload>(
`/v1/admin/bd-leaders/${encodeURIComponent(String(userId))}/position-alias`,
{
body: payload,
method: "PATCH",
},
);
}
export function setBDLeaderStatus(userId: EntityId, payload: BDStatusPayload): Promise<BDProfileDto> {
const endpoint = API_ENDPOINTS.setBDLeaderStatus;
return apiRequest<BDProfileDto, BDStatusPayload>(

View File

@ -11,6 +11,7 @@ import {
listBDs,
setBDLeaderStatus,
setBDStatus,
updateBDLeaderPositionAlias,
} from "@/features/host-org/api";
import { listAppUsers } from "@/features/app-users/api";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
@ -48,6 +49,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
const [selectedLeader, setSelectedLeader] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState("");
const [profileOverrides, setProfileOverrides] = useState({});
const filters = useMemo(
() => ({
keyword: query,
@ -71,6 +73,16 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
pageSize,
queryKey: ["host-org", "bds", profileType, filters, page],
});
const displayData = useMemo(
() => ({
...data,
items: (data.items || []).map((item) => ({
...item,
...(profileOverrides[String(item.userId)] || {}),
})),
}),
[data, profileOverrides],
);
const loadLeaderBDs = useCallback(async (leader) => {
if (!leader?.userId) {
@ -222,6 +234,44 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
});
};
const saveBDLeaderPositionAlias = useCallback(
async (leader, rawAlias) => {
const userId = leader?.userId;
const positionAlias = String(rawAlias ?? "").trim();
const currentAlias = String(leader?.positionAlias ?? "").trim();
if (!userId || positionAlias === currentAlias) {
return leader;
}
if (Array.from(positionAlias).length > 64) {
const err = new Error("职位别名不能超过 64 个字符");
showToast(err.message, "error");
throw err;
}
try {
const profile = await updateBDLeaderPositionAlias(userId, {
commandId: makeCommandId("bd-leader-position-alias"),
positionAlias,
reason: "",
});
setProfileOverrides((current) => ({
...current,
[String(userId)]: {
...(current[String(userId)] || {}),
...(profile || {}),
positionAlias: profile?.positionAlias ?? positionAlias,
},
}));
showToast("职位别名已保存", "success");
return profile;
} catch (err) {
showToast(err.message || "保存职位别名失败", "error");
throw err;
}
},
[showToast],
);
const removeBDLeader = async (leader) => {
const ok = await confirm({
confirmText: "删除",
@ -284,7 +334,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
changeQuery,
changeRegionId,
changeStatus,
data,
data: displayData,
error,
loading,
loadingAction,
@ -301,6 +351,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
reload,
removeBDLeader,
resetFilters,
saveBDLeaderPositionAlias,
closeAction,
loadLeaderBDs: () => loadLeaderBDs(selectedLeader),
openAddLeaderBD,

View File

@ -134,6 +134,14 @@ export function useHostCoinSellersPage() {
setActiveAction("stock");
};
const openSellerLedger = (seller) => {
if (!seller?.userId || !abilities.canLedger) {
return;
}
setSelectedSeller(seller);
setActiveAction("ledger");
};
const openRateSettings = async () => {
if (!abilities.canExchangeRate) {
return;
@ -309,6 +317,7 @@ export function useHostCoinSellersPage() {
openRateSettings,
openCreateSeller,
openEditSeller,
openSellerLedger,
openStockCredit,
page,
query,

View File

@ -162,6 +162,46 @@
font-weight: 650;
}
.inlineAliasText {
color: var(--text-secondary);
font-weight: 650;
}
.inlineAliasButton {
display: inline-flex;
max-width: 100%;
min-height: 28px;
align-items: center;
padding: 0;
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
font-weight: 650;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.inlineAliasButton:hover {
color: var(--primary);
}
.inlineAliasInput {
width: min(180px, 100%);
min-height: 32px;
padding: 0 var(--space-2);
border: 1px solid var(--primary);
border-radius: var(--radius-control);
outline: 0;
background: var(--bg-card);
color: var(--text-primary);
font: inherit;
font-weight: 650;
}
.sellerIdentity {
display: inline-flex;
min-width: 0;

View File

@ -1,6 +1,7 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
import { useEffect, useRef, useState } from "react";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
@ -31,7 +32,7 @@ const bdLeaderBaseColumns = [
{
key: "positionAlias",
label: "职位别名",
render: (item) => item.positionAlias || "-",
render: (item, _index, context) => <PositionAliasCell item={item} page={context?.page} />,
width: "minmax(130px, 0.75fr)",
},
{
@ -268,6 +269,96 @@ function BDLeaderStatusSwitch({ item, page }) {
);
}
function PositionAliasCell({ item, page }) {
const inputRef = useRef(null);
const cancelSaveRef = useRef(false);
const originalValue = String(item.positionAlias || "").trim();
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [value, setValue] = useState(originalValue);
const canEdit = Boolean(page?.abilities.canUpdate);
useEffect(() => {
if (!editing) {
setValue(originalValue);
}
}, [editing, originalValue]);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
const save = async () => {
if (cancelSaveRef.current) {
cancelSaveRef.current = false;
setValue(originalValue);
setEditing(false);
return;
}
const nextValue = value.trim();
if (nextValue === originalValue) {
setValue(originalValue);
setEditing(false);
return;
}
setSaving(true);
try {
await page.saveBDLeaderPositionAlias(item, nextValue);
setEditing(false);
} catch {
setValue(originalValue);
setEditing(false);
} finally {
setSaving(false);
}
};
if (editing) {
return (
<input
ref={inputRef}
className={styles.inlineAliasInput}
disabled={saving}
maxLength={64}
value={value}
onBlur={save}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
if (event.key === "Escape") {
cancelSaveRef.current = true;
setValue(originalValue);
setEditing(false);
}
}}
/>
);
}
if (!canEdit) {
return <span className={styles.inlineAliasText}>{originalValue || "-"}</span>;
}
return (
<button
className={styles.inlineAliasButton}
title="点击编辑职位别名"
type="button"
onClick={() => {
cancelSaveRef.current = false;
setEditing(true);
}}
>
{originalValue || "-"}
</button>
);
}
function SubBDCount({ item, page }) {
return (
<button className={styles.contactButton} type="button" onClick={() => page.openLeaderBDs(item)}>

View File

@ -1,6 +1,7 @@
import Add from "@mui/icons-material/Add";
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 Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
@ -21,6 +22,7 @@ import { HostOrgActionModal } from "@/features/host-org/components/HostOrgAction
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";
@ -85,7 +87,7 @@ export function HostCoinSellersPage() {
width: "minmax(90px, 0.6fr)",
},
...baseColumns.slice(2),
page.abilities.canStockCredit
page.abilities.canStockCredit || page.abilities.canLedger
? {
key: "actions",
label: "操作",
@ -287,6 +289,17 @@ export function HostCoinSellersPage() {
</AdminFormSection>
</HostOrgActionModal>
<SideDrawer
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={
<>
@ -421,6 +434,11 @@ function SellerContact({ item, page }) {
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}`}
@ -435,6 +453,15 @@ function SellerActions({ item, page }) {
);
}
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;
@ -447,6 +474,20 @@ function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
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)",

View File

@ -0,0 +1,116 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { HostCoinSellersPage } from "./HostCoinSellersPage.jsx";
import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSellersPage.js";
const mocks = vi.hoisted(() => ({
ledgerTable: vi.fn(),
openSellerLedger: vi.fn(),
}));
vi.mock("@/features/host-org/hooks/useHostCoinSellersPage.js", () => ({
useHostCoinSellersPage: vi.fn(),
}));
vi.mock("@/features/operations/components/CoinSellerLedgerTable.jsx", () => ({
CoinSellerLedgerTable: (props) => {
mocks.ledgerTable(props);
return <div data-testid="coin-seller-ledger-table">{props.sellerUserId}</div>;
},
}));
afterEach(() => {
vi.clearAllMocks();
});
test("coin seller row action opens seller ledger with current seller", () => {
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture());
render(<HostCoinSellersPage />);
const ledgerButton = screen.getAllByLabelText("币商流水").find((node) => node.tagName.toLowerCase() === "button");
fireEvent.click(ledgerButton);
expect(mocks.openSellerLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "3001" }));
});
test("coin seller ledger drawer passes locked seller id to shared table", () => {
const seller = sellerFixture();
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ activeAction: "ledger", selectedSeller: seller }));
render(<HostCoinSellersPage />);
expect(screen.getByTestId("coin-seller-ledger-table")).toHaveTextContent("3001");
expect(mocks.ledgerTable).toHaveBeenCalledWith(expect.objectContaining({ lockedSeller: seller, sellerUserId: "3001" }));
});
function pageFixture(patch = {}) {
return {
abilities: {
canCreate: false,
canExchangeRate: false,
canLedger: true,
canStockCredit: false,
canUpdate: false,
canView: true,
},
activeAction: "",
addRateTier: vi.fn(),
changeQuery: vi.fn(),
changeRateRegionId: vi.fn(),
changeRegionId: vi.fn(),
changeStatus: vi.fn(),
closeAction: vi.fn(),
createForm: { contact: "", targetUserId: "" },
data: { items: [sellerFixture()], page: 1, pageSize: 50, total: 1 },
editForm: { contact: "", targetUserId: "" },
error: null,
loading: false,
loadingAction: "",
loadingRates: false,
loadingRegions: false,
openCreateSeller: vi.fn(),
openEditSeller: vi.fn(),
openRateSettings: vi.fn(),
openSellerLedger: mocks.openSellerLedger,
openStockCredit: vi.fn(),
page: 1,
query: "",
rateRegionId: "",
rateTiers: [],
regionId: "",
regionOptions: [],
reload: vi.fn(),
removeRateTier: vi.fn(),
resetFilters: vi.fn(),
selectedSeller: null,
setCreateForm: vi.fn(),
setEditForm: vi.fn(),
setPage: vi.fn(),
setStockForm: vi.fn(),
status: "",
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
submitCreateSeller: vi.fn(),
submitEditSeller: vi.fn(),
submitRateSettings: vi.fn(),
submitStockCredit: vi.fn(),
toggleSeller: vi.fn(),
updateRateTier: vi.fn(),
...patch,
};
}
function sellerFixture() {
return {
avatar: "",
contact: "菲律賓 +63",
displayUserId: "164425",
merchantAssetType: "COIN_SELLER_COIN",
merchantBalance: 9400000,
regionId: 1,
status: "active",
updatedAtMs: 1760000000000,
userId: "3001",
username: "Flower92",
};
}

View File

@ -57,6 +57,7 @@ export function useCoinSellerAbilities() {
return {
canCreate: can(PERMISSIONS.coinSellerCreate),
canExchangeRate: can(PERMISSIONS.coinSellerExchangeRate),
canLedger: can(PERMISSIONS.coinSellerLedgerView),
canStockCredit: can(PERMISSIONS.coinSellerStockCredit),
canUpdate: can(PERMISSIONS.coinSellerUpdate),
canView: can(PERMISSIONS.coinSellerView),

View File

@ -4,6 +4,7 @@ import {
createCoinAdjustment,
getGiftDiamondRatios,
listCoinAdjustments,
listCoinSellerLedger,
lookupCoinAdjustmentTarget,
updateGiftDiamondRatios,
} from "./api";
@ -69,3 +70,26 @@ test("coin adjustment APIs use generated admin paths", async () => {
expect(String(createUrl)).toContain("/api/v1/admin/operations/coin-adjustments");
expect(createInit?.method).toBe("POST");
});
test("coin seller ledger API uses generated admin path and filters", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } })),
),
);
await listCoinSellerLedger({
ledger_type: "seller_transfer",
page: 1,
page_size: 20,
seller_user_id: "3001",
});
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
expect(String(listUrl)).toContain("/api/v1/admin/operations/coin-seller-ledger?");
expect(String(listUrl)).toContain("seller_user_id=3001");
expect(String(listUrl)).toContain("ledger_type=seller_transfer");
expect(listInit?.method).toBe("GET");
});

View File

@ -7,6 +7,7 @@ import type {
CoinAdjustmentPayload,
CoinLedgerEntryDto,
CoinLedgerUserDto,
CoinSellerLedgerDto,
PageQuery,
ReportDto,
} from "@/shared/api/types";
@ -33,6 +34,14 @@ export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedge
});
}
export function listCoinSellerLedger(query: PageQuery = {}): Promise<ApiPage<CoinSellerLedgerDto>> {
const endpoint = API_ENDPOINTS.listCoinSellerLedger;
return apiRequest<ApiPage<CoinSellerLedgerDto>>(apiEndpointPath(API_OPERATIONS.listCoinSellerLedger), {
method: endpoint.method,
query,
});
}
export function listCoinAdjustments(query: PageQuery = {}): Promise<ApiPage<CoinAdjustmentDto>> {
const endpoint = API_ENDPOINTS.listCoinAdjustments;
return apiRequest<ApiPage<CoinAdjustmentDto>>(apiEndpointPath(API_OPERATIONS.listCoinAdjustments), {

View File

@ -0,0 +1,229 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useMemo, useState } from "react";
import { listCoinSellerLedger } from "@/features/operations/api";
import styles from "@/features/operations/operations.module.css";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import {
AdminFilterResetButton,
AdminListBody,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
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: "币商进货",
coin_seller_transfer: "币商转用户",
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: "receiver",
label: "收款人信息",
render: (entry) => <UserCell user={entry.receiver} />,
width: "minmax(240px, 1.2fr)",
},
{
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 [sellerKeyword, setSellerKeyword] = useState("");
const [ledgerType, setLedgerType] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
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 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 tableColumns = baseColumns.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({
options: ledgerTypeOptions,
placeholder: "流水类型",
value: ledgerType,
onChange: changeLedgerType,
}),
};
}
return column;
});
return (
<>
<AdminListToolbar
filters={
<>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterResetButton
disabled={!sellerKeyword && !ledgerType && !timeRange.startMs && !timeRange.endMs}
onClick={resetFilters}
/>
</>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
emptyLabel="当前无币商流水"
items={items}
minWidth="1030px"
pagination={
total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(entry) => entry.entryId}
/>
</AdminListBody>
</DataState>
</>
);
}
function UserCell({ user = {} }) {
const name = user.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{name}</span>
<span className={styles.meta}>{userIdText(user)}</span>
</span>
</div>
);
}
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) {
return ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-";
}
function userIdText(user = {}) {
const displayUserId = String(user.displayUserId || "").trim();
const userId = String(user.userId || "").trim();
if (displayUserId && userId && displayUserId !== userId) {
return `${displayUserId} / ${userId}`;
}
return displayUserId || userId || "-";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -0,0 +1,10 @@
import { CoinSellerLedgerTable } from "@/features/operations/components/CoinSellerLedgerTable.jsx";
import { AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
export function CoinSellerLedgerPage() {
return (
<AdminListPage>
<CoinSellerLedgerTable />
</AdminListPage>
);
}

View File

@ -9,6 +9,14 @@ export const operationsRoutes = [
path: "/operations/coin-ledger",
permission: PERMISSIONS.coinLedgerView,
},
{
label: "币商流水",
loader: () => import("./pages/CoinSellerLedgerPage.jsx").then((module) => module.CoinSellerLedgerPage),
menuCode: MENU_CODES.operationCoinSellerLedger,
pageKey: "operation-coin-seller-ledger",
path: "/operations/coin-seller-ledger",
permission: PERMISSIONS.coinSellerLedgerView,
},
{
label: "金币增减",
loader: () => import("./pages/CoinAdjustmentPage.jsx").then((module) => module.CoinAdjustmentPage),

View File

@ -3,7 +3,7 @@
}
.drawer.drawer {
width: min(860px, 100vw);
width: min(720px, 100vw);
height: 100%;
}
@ -44,6 +44,9 @@
.grid {
display: grid;
min-height: 0;
align-content: start;
align-items: start;
grid-auto-rows: minmax(184px, auto);
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: var(--space-3);
overflow-y: auto;
@ -56,6 +59,7 @@
min-width: 0;
min-height: 184px;
align-content: start;
align-self: start;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border);

View File

@ -3,7 +3,7 @@
}
.drawer.drawer {
width: min(780px, 100vw);
width: min(720px, 100vw);
height: 100%;
}
@ -29,6 +29,9 @@
.grid {
display: grid;
min-height: 0;
align-content: start;
align-items: start;
grid-auto-rows: minmax(164px, auto);
grid-template-columns: repeat(auto-fill, minmax(156px, 1fr));
gap: var(--space-3);
overflow-y: auto;
@ -40,6 +43,7 @@
min-width: 0;
min-height: 164px;
align-content: start;
align-self: start;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border);

View File

@ -47,6 +47,8 @@ import {
} from "@/features/resources/schema.js";
const pageSize = 50;
const optionPageSize = 100;
const optionMaxPages = 200;
const dayMillis = 24 * 60 * 60 * 1000;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyResourceForm = (resource = {}) => ({
@ -141,6 +143,35 @@ export function applyGiftPriceDefaults(form, coinPrice) {
};
}
export async function fetchAllOptionPages(fetcher, query = {}, pageSize = optionPageSize) {
const items = [];
let total = Number.POSITIVE_INFINITY;
// 后台会把选项接口的 page_size 截断到 100抽屉候选项必须按真实分页拉全避免只展示第一页资源。
for (let page = 1; page <= optionMaxPages; page += 1) {
const data = await fetcher({ ...query, page, page_size: pageSize });
const pageItems = Array.isArray(data?.items) ? data.items : [];
const responsePageSize = Number(data?.pageSize || data?.page_size || pageSize) || pageSize;
const responseTotal = Number(data?.total);
items.push(...pageItems);
if (Number.isFinite(responseTotal) && responseTotal >= 0) {
total = responseTotal;
}
if (!pageItems.length || items.length >= total || pageItems.length < responsePageSize) {
return {
...data,
items,
page: 1,
pageSize: responsePageSize,
total: Number.isFinite(total) ? total : items.length,
};
}
}
throw new Error("资源选项分页过多,请收窄筛选条件");
}
function applyGiftResourcePrice(form, resource) {
const priceType = resource?.priceType || (resource?.coinPrice > 0 ? "coin" : "");
if (priceType === "free") {
@ -388,7 +419,7 @@ export function useResourceShopPage() {
}
let ignore = false;
setResourceOptionsLoading(true);
listResources({ page: 1, page_size: 100, status: "active" })
fetchAllOptionPages(listResources, { status: "active" })
.then((data) => {
if (!ignore) {
setResourceOptions(toShopResourceOptions(data.items || []));
@ -588,7 +619,7 @@ export function useResourceGroupListPage() {
}
let ignore = false;
setResourceOptionsLoading(true);
listResources({ page: 1, page_size: 100, status: "active" })
fetchAllOptionPages(listResources, { status: "active" })
.then((data) => {
if (!ignore) {
setResourceOptions(toGroupResourceOptions(data.items || [], selectedGroup));
@ -812,7 +843,7 @@ export function useGiftListPage() {
}
let ignore = false;
setResourceOptionsLoading(true);
listResources({ page: 1, page_size: 100, resource_type: "gift" })
fetchAllOptionPages(listResources, { resource_type: "gift" })
.then((data) => {
if (!ignore) {
setResourceOptions(toGiftResourceOptions(data.items || [], selectedGift));
@ -1210,10 +1241,10 @@ export function useResourceGrantListPage() {
let ignore = false;
setOptionsLoading(true);
Promise.all([
listResources({ page: 1, page_size: 500, status: "active" }),
listGifts({ page: 1, page_size: 500, status: "active" }),
fetchAllOptionPages(listResources, { status: "active" }),
fetchAllOptionPages(listGifts, { status: "active" }),
listGiftTypes(),
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
fetchAllOptionPages(listResourceGroups, { status: "active" }),
])
.then(([resources, gifts, giftTypes, groups]) => {
if (!ignore) {

View File

@ -1,5 +1,5 @@
import { expect, test } from "vitest";
import { applyGiftPriceDefaults, applyGiftResourceSelection } from "./useResourcePages.js";
import { expect, test, vi } from "vitest";
import { applyGiftPriceDefaults, applyGiftResourceSelection, fetchAllOptionPages } from "./useResourcePages.js";
test("fills gift name and id from selected gift resource", () => {
const form = {
@ -38,3 +38,19 @@ test("fills gift price without gift points", () => {
coinPrice: "101",
});
});
test("fetches all option pages instead of only the first resource page", async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce({ items: [{ resourceId: 1 }, { resourceId: 2 }], pageSize: 2, total: 5 })
.mockResolvedValueOnce({ items: [{ resourceId: 3 }, { resourceId: 4 }], pageSize: 2, total: 5 })
.mockResolvedValueOnce({ items: [{ resourceId: 5 }], pageSize: 2, total: 5 });
const result = await fetchAllOptionPages(fetcher, { status: "active" }, 2);
expect(fetcher).toHaveBeenNthCalledWith(1, { page: 1, page_size: 2, status: "active" });
expect(fetcher).toHaveBeenNthCalledWith(2, { page: 2, page_size: 2, status: "active" });
expect(fetcher).toHaveBeenNthCalledWith(3, { page: 3, page_size: 2, status: "active" });
expect(result.items.map((item) => item.resourceId)).toEqual([1, 2, 3, 4, 5]);
expect(result.total).toBe(5);
});

View File

@ -113,6 +113,7 @@ export const API_OPERATIONS = {
listCatalog: "listCatalog",
listCoinAdjustments: "listCoinAdjustments",
listCoinLedger: "listCoinLedger",
listCoinSellerLedger: "listCoinSellerLedger",
listCoinSellers: "listCoinSellers",
listCountries: "listCountries",
listEmojiPackCategories: "listEmojiPackCategories",
@ -917,6 +918,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "coin-ledger:view",
permissions: ["coin-ledger:view"]
},
listCoinSellerLedger: {
method: "GET",
operationId: API_OPERATIONS.listCoinSellerLedger,
path: "/v1/admin/operations/coin-seller-ledger",
permission: "coin-seller-ledger:view",
permissions: ["coin-seller-ledger:view"]
},
listCoinSellers: {
method: "GET",
operationId: API_OPERATIONS.listCoinSellers,

View File

@ -1140,6 +1140,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/operations/coin-seller-ledger": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listCoinSellerLedger"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/operations/reports": {
parameters: {
query?: never;
@ -4115,6 +4131,18 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listCoinSellerLedger: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listReports: {
parameters: {
query?: never;

View File

@ -211,6 +211,23 @@ export interface CoinLedgerEntryDto {
userId: string;
}
export interface CoinSellerLedgerDto {
amount: number;
availableDelta: number;
bizType?: string;
commandId?: string;
counterpartyUserId?: string;
createdAtMs?: number;
direction: "income" | "expense" | string;
entryId: number;
ledgerType?: string;
metadata?: Record<string, unknown>;
receiver: CoinLedgerUserDto;
seller: CoinLedgerUserDto;
sellerBalanceAfter: number;
transactionId: string;
}
export interface CoinAdjustmentOperatorDto {
adminId?: string;
name?: string;
@ -803,6 +820,10 @@ export interface CreateBDLeaderPayload extends HostCommandPayload {
targetUserId: number;
}
export interface BDLeaderPositionAliasPayload extends HostCommandPayload {
positionAlias?: string;
}
export interface CreateBDPayload extends HostCommandPayload {
parentLeaderUserId: number;
targetUserId: number;