币商流水增加
This commit is contained in:
parent
cf83d334e7
commit
af9da06615
@ -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",
|
||||
|
||||
@ -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 }),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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)",
|
||||
|
||||
116
src/features/host-org/pages/HostCoinSellersPage.test.jsx
Normal file
116
src/features/host-org/pages/HostCoinSellersPage.test.jsx
Normal 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",
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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), {
|
||||
|
||||
229
src/features/operations/components/CoinSellerLedgerTable.jsx
Normal file
229
src/features/operations/components/CoinSellerLedgerTable.jsx
Normal 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");
|
||||
}
|
||||
10
src/features/operations/pages/CoinSellerLedgerPage.jsx
Normal file
10
src/features/operations/pages/CoinSellerLedgerPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
28
src/shared/api/generated/schema.d.ts
vendored
28
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user