From 28051f2acdaa55359ad53b68f699aa7c25ccfe00 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 18 Jun 2026 18:04:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=8F=E7=90=86=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/host-org/api.test.ts | 36 +++++-- src/features/host-org/api.ts | 19 +++- .../host-org/hooks/useHostManagersPage.js | 65 +++++++++-- src/features/host-org/host-org.module.css | 63 +++++++++++ .../host-org/pages/HostManagersPage.jsx | 101 ++++++++++++++++-- src/features/host-org/schema.ts | 12 ++- src/shared/api/types.ts | 25 +++++ 7 files changed, 290 insertions(+), 31 deletions(-) diff --git a/src/features/host-org/api.test.ts b/src/features/host-org/api.test.ts index 6d9a6ba..585ef28 100644 --- a/src/features/host-org/api.test.ts +++ b/src/features/host-org/api.test.ts @@ -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"); diff --git a/src/features/host-org/api.ts b/src/features/host-org/api.ts index f3b92d9..d67cab5 100644 --- a/src/features/host-org/api.ts +++ b/src/features/host-org/api.ts @@ -38,6 +38,7 @@ import type { SalaryWalletAdjustPayload, SalaryWalletAdjustResultDto, SalaryWalletHistoryDto, + UpdateManagerPayload, } from "@/shared/api/types"; export function listCountries(query: PageQuery = {}): Promise> { @@ -186,6 +187,13 @@ export function createManager(payload: CreateManagerPayload): Promise { + return apiRequest(`/v1/admin/managers/${encodeURIComponent(String(userId))}`, { + body: payload, + method: "PUT", + }); +} + export function listHosts(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.listHosts; return apiRequest>(apiEndpointPath(API_OPERATIONS.listHosts), { @@ -210,10 +218,13 @@ export function listSalaryWalletHistory(query: PageQuery = {}): Promise { - return apiRequest("/v1/admin/host-org/salary-wallets/adjust", { - body: payload, - method: "POST", - }); + return apiRequest( + "/v1/admin/host-org/salary-wallets/adjust", + { + body: payload, + method: "POST", + }, + ); } export function createBDLeader(payload: CreateBDLeaderPayload): Promise { diff --git a/src/features/host-org/hooks/useHostManagersPage.js b/src/features/host-org/hooks/useHostManagersPage.js index 954696c..1bf6623 100644 --- a/src/features/host-org/hooks/useHostManagersPage.js +++ b/src/features/host-org/hooks/useHostManagersPage.js @@ -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, diff --git a/src/features/host-org/host-org.module.css b/src/features/host-org/host-org.module.css index e1f5b1d..8a8f57c 100644 --- a/src/features/host-org/host-org.module.css +++ b/src/features/host-org/host-org.module.css @@ -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; diff --git a/src/features/host-org/pages/HostManagersPage.jsx b/src/features/host-org/pages/HostManagersPage.jsx index f46eb3e..bc9693c 100644 --- a/src/features/host-org/pages/HostManagersPage.jsx +++ b/src/features/host-org/pages/HostManagersPage.jsx @@ -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) => , + 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) => , + 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() { page.setManagerForm({ ...page.managerForm, targetUserId: event.target.value })} /> page.setManagerForm({ ...page.managerForm, contact: event.target.value })} /> + ); @@ -164,3 +193,59 @@ function ManagerStatus({ item }) { ); } + +function ManagerPermissions({ item }) { + return ( +
+ {managerPermissionOptions.map((permission) => { + const enabled = item?.[permission.key] !== false; + return ( + + {permission.label} + {enabled ? "开" : "关"} + + ); + })} +
+ ); +} + +function ManagerActions({ item, page }) { + const disabled = !page?.abilities?.canUpdate; + return ( +
+ + + page.openManagerEdit(item)}> + + + + +
+ ); +} + +function PermissionSwitchGrid({ disabled, form, onChange }) { + return ( +
+ {managerPermissionOptions.map((permission) => ( + + ))} +
+ ); +} diff --git a/src/features/host-org/schema.ts b/src/features/host-org/schema.ts index 382288f..cabafa8 100644 --- a/src/features/host-org/schema.ts +++ b/src/features/host-org/schema.ts @@ -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, }); diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 0f031ca..94a8a21 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -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;