bd leader 别名

This commit is contained in:
zhx 2026-06-05 18:24:47 +08:00
parent dcf5ea2045
commit cf83d334e7
9 changed files with 122 additions and 38 deletions

View File

@ -1,6 +1,7 @@
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request"; import { setAccessToken } from "@/shared/api/request";
import { import {
createBDLeader,
createCountry, createCountry,
createCoinSeller, createCoinSeller,
createRegion, createRegion,
@ -34,6 +35,12 @@ test("host org list APIs use generated admin paths and filters", async () => {
await listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 }); await listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 });
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" }); await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
await listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" }); await listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" });
await createBDLeader({
commandId: "bd-leader-test",
positionAlias: "Senior Admin",
regionId: 7,
targetUserId: 1002,
});
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 }); await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 });
await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" }); await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" });
await creditCoinSellerStock(1001, { await creditCoinSellerStock(1001, {
@ -47,9 +54,10 @@ test("host org list APIs use generated admin paths and filters", async () => {
const [agencyUrl] = vi.mocked(fetch).mock.calls[1]; const [agencyUrl] = vi.mocked(fetch).mock.calls[1];
const [hostUrl] = vi.mocked(fetch).mock.calls[2]; const [hostUrl] = vi.mocked(fetch).mock.calls[2];
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3]; const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3];
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[4]; const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[4];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[5]; const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[5];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[6]; const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[6];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[7];
expect(String(bdUrl)).toContain("/api/v1/admin/bds?"); expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21"); expect(String(bdUrl)).toContain("parent_leader_user_id=21");
@ -66,6 +74,9 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(String(coinSellerUrl)).toContain("region_id=7"); expect(String(coinSellerUrl)).toContain("region_id=7");
expect(String(coinSellerUrl)).toContain("status=active"); expect(String(coinSellerUrl)).toContain("status=active");
expect(coinSellerInit?.method).toBe("GET"); expect(coinSellerInit?.method).toBe("GET");
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(coinSellerCreateUrl)).toContain("/api/v1/admin/coin-sellers"); expect(String(coinSellerCreateUrl)).toContain("/api/v1/admin/coin-sellers");
expect(coinSellerCreateInit?.method).toBe("POST"); expect(coinSellerCreateInit?.method).toBe("POST");
expect(String(coinSellerStatusUrl)).toContain("/api/v1/admin/coin-sellers/1001/status"); expect(String(coinSellerStatusUrl)).toContain("/api/v1/admin/coin-sellers/1001/status");

View File

@ -1,32 +1,51 @@
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import styles from "@/features/host-org/host-org.module.css"; import styles from "@/features/host-org/host-org.module.css";
import { Button } from "@/shared/ui/Button.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx";
export function HostOrgToolbar({ actions = [] }) { export function HostOrgToolbar({ actions = [], leadingActions = [] }) {
if (!actions.length) { if (!actions.length && !leadingActions.length) {
return null; return null;
} }
return ( return (
<div className={styles.toolbar}> <div className={styles.toolbar}>
<div className={styles.toolbarActions}> {leadingActions.length ? (
{actions.map((action) => ( <div className={styles.toolbarLeft}>
<Tooltip arrow key={action.label} title={action.label}> {leadingActions.map((action) => (
<span> <Button
<IconButton className={styles.toolbarTextAction}
className={styles.toolbarAction} disabled={action.disabled}
disabled={action.disabled} key={action.label}
label={action.label} startIcon={action.icon}
tone={action.tone} variant={action.variant}
onClick={action.onClick} onClick={action.onClick}
sx={action.variant === "primary" ? primaryActionSx : undefined} >
> {action.label}
{action.icon} </Button>
</IconButton> ))}
</span> </div>
</Tooltip> ) : null}
))} {actions.length ? (
</div> <div className={styles.toolbarActions}>
{actions.map((action) => (
<Tooltip arrow key={action.label} title={action.label}>
<span>
<IconButton
className={styles.toolbarAction}
disabled={action.disabled}
label={action.label}
tone={action.tone}
onClick={action.onClick}
sx={action.variant === "primary" ? primaryActionSx : undefined}
>
{action.icon}
</IconButton>
</span>
</Tooltip>
))}
</div>
) : null}
</div> </div>
); );
} }

View File

@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
test("renders leading config action separately from right icon actions", async () => {
const user = userEvent.setup();
const openRates = vi.fn();
const openCreate = vi.fn();
render(
<HostOrgToolbar
actions={[{ icon: <span />, label: "添加币商", onClick: openCreate, variant: "primary" }]}
leadingActions={[{ icon: <span />, label: "工资兑换比例", onClick: openRates }]}
/>,
);
await user.click(screen.getByRole("button", { name: "工资兑换比例" }));
await user.click(screen.getByRole("button", { name: "添加币商" }));
expect(openRates).toHaveBeenCalledTimes(1);
expect(openCreate).toHaveBeenCalledTimes(1);
});

View File

@ -17,7 +17,13 @@ import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useBDAbilities } from "@/features/host-org/permissions.js"; import { useBDAbilities } from "@/features/host-org/permissions.js";
import { createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema"; import { createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema";
const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", targetUserId: "" }); const emptyBDLeaderForm = () => ({
commandId: makeCommandId("bd-leader"),
positionAlias: "",
reason: "",
regionId: "",
targetUserId: "",
});
const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" }); const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" });
const pageSize = 50; const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyData = { items: [], page: 1, pageSize, total: 0 };

View File

@ -66,6 +66,10 @@
margin-left: auto; margin-left: auto;
} }
.toolbarTextAction {
flex: 0 0 auto;
}
.filters { .filters {
display: flex; display: flex;
min-width: 0; min-width: 0;

View File

@ -28,6 +28,12 @@ const bdLeaderBaseColumns = [
render: (item, _index, context) => regionName(item, context?.regionOptions), render: (item, _index, context) => regionName(item, context?.regionOptions),
width: "minmax(130px, 0.75fr)", width: "minmax(130px, 0.75fr)",
}, },
{
key: "positionAlias",
label: "职位别名",
render: (item) => item.positionAlias || "-",
width: "minmax(130px, 0.75fr)",
},
{ {
key: "status", key: "status",
label: "状态", label: "状态",
@ -84,9 +90,7 @@ export function HostBdLeadersPage() {
return ( return (
<section className={styles.root}> <section className={styles.root}>
<div className={styles.contentPanel}> <div className={styles.contentPanel}>
<HostOrgToolbar <HostOrgToolbar actions={toolbarActions} />
actions={toolbarActions}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}> <div className={styles.listBlock}>
@ -139,6 +143,14 @@ export function HostBdLeadersPage() {
value={page.bdLeaderForm.regionId} value={page.bdLeaderForm.regionId}
onChange={(value) => page.setBDLeaderForm({ ...page.bdLeaderForm, regionId: value })} onChange={(value) => page.setBDLeaderForm({ ...page.bdLeaderForm, regionId: value })}
/> />
<TextField
disabled={createDisabled}
label="职位别名"
value={page.bdLeaderForm.positionAlias}
onChange={(event) =>
page.setBDLeaderForm({ ...page.bdLeaderForm, positionAlias: event.target.value })
}
/>
<TextField <TextField
disabled={createDisabled} disabled={createDisabled}
label="联系方式" label="联系方式"

View File

@ -125,10 +125,12 @@ export function HostCoinSellersPage() {
value: page.regionId, value: page.regionId,
onChange: page.changeRegionId, onChange: page.changeRegionId,
}); });
const toolbarActions = [ const leadingActions = [
page.abilities.canExchangeRate page.abilities.canExchangeRate
? { icon: <SettingsOutlined fontSize="small" />, label: "设置兑换比例", onClick: page.openRateSettings } ? { icon: <SettingsOutlined fontSize="small" />, label: "工资兑换比例", onClick: page.openRateSettings }
: null, : null,
].filter(Boolean);
const toolbarActions = [
page.abilities.canCreate page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" } ? { icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }
: null, : null,
@ -139,6 +141,7 @@ export function HostCoinSellersPage() {
<div className={styles.contentPanel}> <div className={styles.contentPanel}>
<HostOrgToolbar <HostOrgToolbar
actions={toolbarActions} actions={toolbarActions}
leadingActions={leadingActions}
/> />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>

View File

@ -12,15 +12,18 @@ const commandContactSchema = z.object({
}); });
const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID"); const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID");
const optionalUserIdSchema = z.preprocess((value) => { const optionalUserIdSchema = z.preprocess(
if (value === null || value === undefined) { (value) => {
return 0; if (value === null || value === undefined) {
} return 0;
if (typeof value === "string" && value.trim() === "") { }
return 0; if (typeof value === "string" && value.trim() === "") {
} return 0;
return value; }
}, z.coerce.number().int().min(0, "请输入有效用户短 ID")); return value;
},
z.coerce.number().int().min(0, "请输入有效用户短 ID"),
);
const regionIdSchema = z.coerce.number().int().positive("请输入有效区域 ID"); const regionIdSchema = z.coerce.number().int().positive("请输入有效区域 ID");
const sortOrderSchema = z.coerce.number().int().min(0, "排序不能小于 0").default(0); 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(""); const optionalTextSchema = (max: number, message: string) => z.string().trim().max(max, message).optional().default("");
@ -66,6 +69,7 @@ const regionBaseSchema = z
}); });
export const createBDLeaderSchema = commandContactSchema.extend({ export const createBDLeaderSchema = commandContactSchema.extend({
positionAlias: optionalTextSchema(64, "职位别名不能超过 64 个字符"),
regionId: regionIdSchema, regionId: regionIdSchema,
targetUserId: userIdSchema, targetUserId: userIdSchema,
}); });

View File

@ -386,6 +386,7 @@ export interface BDProfileDto {
parentLeaderDisplayUserId?: string; parentLeaderDisplayUserId?: string;
parentLeaderUsername?: string; parentLeaderUsername?: string;
parentLeaderAvatar?: string; parentLeaderAvatar?: string;
positionAlias?: string;
regionId?: number; regionId?: number;
regionName?: string; regionName?: string;
role?: string; role?: string;
@ -797,6 +798,7 @@ export interface HostCommandPayload {
} }
export interface CreateBDLeaderPayload extends HostCommandPayload { export interface CreateBDLeaderPayload extends HostCommandPayload {
positionAlias?: string;
regionId: number; regionId: number;
targetUserId: number; targetUserId: number;
} }