经理相关

This commit is contained in:
zhx 2026-06-18 18:04:28 +08:00
parent ec252cec2c
commit 28051f2acd
7 changed files with 290 additions and 31 deletions

View File

@ -21,6 +21,7 @@ import {
setCoinSellerStatus,
updateBDLeaderPositionAlias,
updateCountry,
updateManager,
} from "./api";
afterEach(() => {
@ -60,7 +61,8 @@ test("host org list APIs use generated admin paths and filters", async () => {
region_id: 7,
status: "active",
});
await createManager({ contact: "+63", targetUserId: "1003" });
await createManager({ canBlockUser: false, contact: "+63", targetUserId: "1003" });
await updateManager("1003", { canBlockUser: false, canGrantVehicle: true, contact: "+63" });
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
await listCoinSellers({
page: 1,
@ -98,15 +100,16 @@ test("host org list APIs use generated admin paths and filters", async () => {
const [agencyUrl] = vi.mocked(fetch).mock.calls[1];
const [managerUrl, managerInit] = vi.mocked(fetch).mock.calls[2];
const [managerCreateUrl, managerCreateInit] = vi.mocked(fetch).mock.calls[3];
const [hostUrl] = vi.mocked(fetch).mock.calls[4];
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[5];
const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[6];
const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[7];
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[8];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[9];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[10];
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[11];
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[12];
const [managerUpdateUrl, managerUpdateInit] = vi.mocked(fetch).mock.calls[4];
const [hostUrl] = vi.mocked(fetch).mock.calls[5];
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[6];
const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[7];
const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[8];
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[9];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[10];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[11];
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[12];
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[13];
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
@ -127,7 +130,18 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(managerInit?.method).toBe("GET");
expect(String(managerCreateUrl)).toContain("/api/v1/admin/managers");
expect(managerCreateInit?.method).toBe("POST");
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({ contact: "+63", targetUserId: "1003" });
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
canBlockUser: false,
contact: "+63",
targetUserId: "1003",
});
expect(String(managerUpdateUrl)).toContain("/api/v1/admin/managers/1003");
expect(managerUpdateInit?.method).toBe("PUT");
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
canBlockUser: false,
canGrantVehicle: true,
contact: "+63",
});
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
expect(String(hostUrl)).toContain("agency_id=41");
expect(String(hostUrl)).toContain("sort_by=diamond");

View File

@ -38,6 +38,7 @@ import type {
SalaryWalletAdjustPayload,
SalaryWalletAdjustResultDto,
SalaryWalletHistoryDto,
UpdateManagerPayload,
} from "@/shared/api/types";
export function listCountries(query: PageQuery = {}): Promise<ApiList<CountryDto>> {
@ -186,6 +187,13 @@ export function createManager(payload: CreateManagerPayload): Promise<ManagerDto
});
}
export function updateManager(userId: EntityId, payload: UpdateManagerPayload): Promise<ManagerDto> {
return apiRequest<ManagerDto, UpdateManagerPayload>(`/v1/admin/managers/${encodeURIComponent(String(userId))}`, {
body: payload,
method: "PUT",
});
}
export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto>> {
const endpoint = API_ENDPOINTS.listHosts;
return apiRequest<ApiPage<HostProfileDto>>(apiEndpointPath(API_OPERATIONS.listHosts), {
@ -210,10 +218,13 @@ export function listSalaryWalletHistory(query: PageQuery = {}): Promise<ApiPage<
}
export function adjustSalaryWallet(payload: SalaryWalletAdjustPayload): Promise<SalaryWalletAdjustResultDto> {
return apiRequest<SalaryWalletAdjustResultDto, SalaryWalletAdjustPayload>("/v1/admin/host-org/salary-wallets/adjust", {
body: payload,
method: "POST",
});
return apiRequest<SalaryWalletAdjustResultDto, SalaryWalletAdjustPayload>(
"/v1/admin/host-org/salary-wallets/adjust",
{
body: payload,
method: "POST",
},
);
}
export function createBDLeader(payload: CreateBDLeaderPayload): Promise<BDProfileDto> {

View File

@ -2,20 +2,42 @@ import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { createManager, listManagers } from "@/features/host-org/api";
import { createManager, listManagers, updateManager } from "@/features/host-org/api";
import { useBDAbilities } from "@/features/host-org/permissions.js";
import { createManagerSchema } from "@/features/host-org/schema";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyManagerForm = () => ({ contact: "", targetUserId: "" });
export const managerPermissionFields = [
"canGrantAvatarFrame",
"canGrantVehicle",
"canUpdateUserLevel",
"canAddBdLeader",
"canAddAdmin",
"canAddSuperadmin",
"canBlockUser",
];
const emptyPermissions = () => Object.fromEntries(managerPermissionFields.map((field) => [field, true]));
const emptyManagerForm = () => ({ contact: "", targetUserId: "", ...emptyPermissions() });
const managerPermissionPayload = (form) =>
Object.fromEntries(managerPermissionFields.map((field) => [field, form[field] !== false]));
const managerFormFromItem = (item) => ({
contact: item?.contact || "",
targetUserId: String(item?.userId || ""),
...Object.fromEntries(managerPermissionFields.map((field) => [field, item?.[field] !== false])),
});
export function useHostManagersPage() {
const abilities = useBDAbilities();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [activeAction, setActiveAction] = useState("");
const [editingManager, setEditingManager] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const [managerForm, setManagerForm] = useState(emptyManagerForm);
const [query, setQuery] = useState("");
@ -68,16 +90,30 @@ export function useHostManagersPage() {
const submitManager = async (event) => {
event.preventDefault();
setLoadingAction("manager-create");
const isEdit = activeAction === "manager-edit" && editingManager?.userId;
setLoadingAction(isEdit ? "manager-update" : "manager-create");
try {
const payload = parseForm(createManagerSchema, managerForm);
await createManager(payload);
if (isEdit) {
await updateManager(editingManager.userId, {
contact: payload.contact,
...managerPermissionPayload(payload),
});
showToast("经理权限已更新", "success");
} else {
await createManager({
contact: payload.contact,
targetUserId: payload.targetUserId,
...managerPermissionPayload(payload),
});
showToast("经理已添加", "success");
}
setManagerForm(emptyManagerForm());
setActiveAction("");
showToast("经理已添加", "success");
setEditingManager(null);
await reload();
} catch (err) {
showToast(err.message || "添加经理失败", "error");
showToast(err.message || (isEdit ? "更新经理权限失败" : "添加经理失败"), "error");
} finally {
setLoadingAction("");
}
@ -85,9 +121,22 @@ export function useHostManagersPage() {
const closeAction = () => {
setActiveAction("");
setEditingManager(null);
setManagerForm(emptyManagerForm());
};
const openManagerForm = () => {
setEditingManager(null);
setManagerForm(emptyManagerForm());
setActiveAction("manager");
};
const openManagerEdit = (item) => {
setEditingManager(item);
setManagerForm(managerFormFromItem(item));
setActiveAction("manager-edit");
};
return {
abilities,
activeAction,
@ -96,12 +145,14 @@ export function useHostManagersPage() {
changeStatus,
closeAction,
data,
editingManager,
error,
loadingAction,
loading,
loadingRegions,
managerForm,
openManagerForm: () => setActiveAction("manager"),
openManagerEdit,
openManagerForm,
page,
query,
regionId,

View File

@ -104,6 +104,69 @@
gap: var(--space-2);
}
.permissionGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
}
.permissionSwitch {
display: flex;
min-width: 0;
min-height: 40px;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 0 var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input);
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.permissionSwitch span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.permissionList {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.permissionPill {
display: inline-flex;
min-height: 24px;
align-items: center;
gap: 5px;
padding: 0 8px;
border: 1px solid var(--success-border);
border-radius: var(--radius-pill);
background: var(--success-surface);
color: var(--success);
font-size: 12px;
font-weight: 720;
line-height: 1;
white-space: nowrap;
}
.permissionPill span {
color: inherit;
font-weight: 780;
}
.permissionPillOff {
border-color: var(--border);
background: var(--bg-input);
color: var(--text-tertiary);
}
.rateDrawerForm {
display: flex;
flex-direction: column;

View File

@ -1,7 +1,11 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { formatMillis } from "@/shared/utils/time.js";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { managerStatusFilters, statusLabels } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
@ -11,6 +15,16 @@ import { useHostManagersPage } from "@/features/host-org/hooks/useHostManagersPa
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css";
const managerPermissionOptions = [
{ key: "canGrantAvatarFrame", label: "头像框" },
{ key: "canGrantVehicle", label: "Car" },
{ key: "canUpdateUserLevel", label: "升级等级" },
{ key: "canAddBdLeader", label: "BD Leader" },
{ key: "canAddAdmin", label: "Admin" },
{ key: "canAddSuperadmin", label: "Superadmin" },
{ key: "canBlockUser", label: "Block User" },
];
const managerColumns = [
{
key: "userId",
@ -30,6 +44,12 @@ const managerColumns = [
render: (item) => item.contact || "-",
width: "minmax(150px, 0.85fr)",
},
{
key: "permissions",
label: "权限详情",
render: (item) => <ManagerPermissions item={item} />,
width: "minmax(270px, 1.35fr)",
},
{
key: "status",
label: "状态",
@ -54,11 +74,19 @@ const managerColumns = [
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
},
{
key: "actions",
label: "操作",
render: (item, _index, context) => <ManagerActions item={item} page={context?.page} />,
resizable: false,
width: "88px",
},
];
export function HostManagersPage() {
const page = useHostManagersPage();
const createDisabled = !page.abilities.canCreate;
const isEditing = page.activeAction === "manager-edit";
const formDisabled = isEditing ? !page.abilities.canUpdate : !page.abilities.canCreate;
const items = page.data.items || [];
const total = page.data.total || 0;
const toolbarActions = [
@ -101,7 +129,7 @@ export function HostManagersPage() {
columns={columns}
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1240px"
minWidth="1640px"
pagination={
total > 0
? {
@ -118,16 +146,16 @@ export function HostManagersPage() {
</DataState>
</div>
<HostOrgActionModal
disabled={createDisabled}
loading={page.loadingAction === "manager-create"}
open={page.activeAction === "manager"}
disabled={formDisabled}
loading={["manager-create", "manager-update"].includes(page.loadingAction)}
open={page.activeAction === "manager" || page.activeAction === "manager-edit"}
sectionTitle="经理信息"
onClose={page.closeAction}
onSubmit={page.submitManager}
title="添加经理"
title={isEditing ? "编辑经理权限" : "添加经理"}
>
<TextField
disabled={createDisabled}
disabled={formDisabled || isEditing}
label="用户短 ID"
required
type="number"
@ -135,11 +163,12 @@ export function HostManagersPage() {
onChange={(event) => page.setManagerForm({ ...page.managerForm, targetUserId: event.target.value })}
/>
<TextField
disabled={createDisabled}
disabled={formDisabled}
label="联系方式"
value={page.managerForm.contact}
onChange={(event) => page.setManagerForm({ ...page.managerForm, contact: event.target.value })}
/>
<PermissionSwitchGrid disabled={formDisabled} form={page.managerForm} onChange={page.setManagerForm} />
</HostOrgActionModal>
</section>
);
@ -164,3 +193,59 @@ function ManagerStatus({ item }) {
</span>
);
}
function ManagerPermissions({ item }) {
return (
<div className={styles.permissionList}>
{managerPermissionOptions.map((permission) => {
const enabled = item?.[permission.key] !== false;
return (
<span
className={
enabled ? styles.permissionPill : `${styles.permissionPill} ${styles.permissionPillOff}`
}
key={permission.key}
>
{permission.label}
<span>{enabled ? "开" : "关"}</span>
</span>
);
})}
</div>
);
}
function ManagerActions({ item, page }) {
const disabled = !page?.abilities?.canUpdate;
return (
<div className={styles.rowActions}>
<Tooltip title={disabled ? "无编辑权限" : "编辑权限"}>
<span>
<IconButton disabled={disabled} label="编辑权限" onClick={() => page.openManagerEdit(item)}>
<EditOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
</div>
);
}
function PermissionSwitchGrid({ disabled, form, onChange }) {
return (
<div className={styles.permissionGrid}>
{managerPermissionOptions.map((permission) => (
<label className={styles.permissionSwitch} key={permission.key}>
<span>{permission.label}</span>
<AdminSwitch
checked={form?.[permission.key] !== false}
checkedLabel="开"
disabled={disabled}
label={permission.label}
uncheckedLabel="关"
onChange={(event) => onChange({ ...form, [permission.key]: event.target.checked })}
/>
</label>
))}
</div>
);
}

View File

@ -18,7 +18,10 @@ const userIdSchema = z.preprocess(
}
return String(value).trim();
},
z.string().min(1, "请输入有效用户短 ID").regex(/^[1-9]\d*$/, "请输入有效用户短 ID"),
z
.string()
.min(1, "请输入有效用户短 ID")
.regex(/^[1-9]\d*$/, "请输入有效用户短 ID"),
);
const entityIdSchema = z.preprocess(
(value) => {
@ -106,6 +109,13 @@ export const createCoinSellerSchema = commandContactSchema.extend({
});
export const createManagerSchema = z.object({
canAddAdmin: z.boolean().optional().default(true),
canAddBdLeader: z.boolean().optional().default(true),
canAddSuperadmin: z.boolean().optional().default(true),
canBlockUser: z.boolean().optional().default(true),
canGrantAvatarFrame: z.boolean().optional().default(true),
canGrantVehicle: z.boolean().optional().default(true),
canUpdateUserLevel: z.boolean().optional().default(true),
contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
targetUserId: userIdSchema,
});

View File

@ -561,6 +561,13 @@ export interface AgencyDto {
export interface ManagerDto {
avatar?: string;
bdLeaderCount?: number;
canAddAdmin?: boolean;
canAddBdLeader?: boolean;
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantVehicle?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
createdAtMs?: number;
displayUserId?: string;
@ -574,10 +581,28 @@ export interface ManagerDto {
}
export interface CreateManagerPayload {
canAddAdmin?: boolean;
canAddBdLeader?: boolean;
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantVehicle?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
targetUserId: string;
}
export interface UpdateManagerPayload {
canAddAdmin?: boolean;
canAddBdLeader?: boolean;
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantVehicle?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
}
export interface HostProfileDto {
avatar?: string;
createdAtMs?: number;