feat: add manager region invite switch

This commit is contained in:
zhx 2026-07-11 18:27:19 +08:00
parent c206c72612
commit 8ef5b0c08d
9 changed files with 173 additions and 37 deletions

View File

@ -8875,8 +8875,11 @@
}, },
"ApiPageManager": { "ApiPageManager": {
"type": "object", "type": "object",
"required": ["items", "page", "pageSize", "total"], "required": ["items", "page", "pageSize", "total", "bdLeaderInviteScopePolicy"],
"properties": { "properties": {
"bdLeaderInviteScopePolicy": {
"$ref": "#/components/schemas/ManagerRoleScopePolicy"
},
"items": { "items": {
"type": "array", "type": "array",
"items": { "items": {
@ -10112,6 +10115,19 @@
} }
} }
}, },
"ManagerRoleScopePolicy": {
"type": "object",
"required": ["baseScope", "regionExpansionConfigurable"],
"properties": {
"baseScope": {
"type": "string",
"enum": ["country", "region", "global"]
},
"regionExpansionConfigurable": {
"type": "boolean"
}
}
},
"Manager": { "Manager": {
"type": "object", "type": "object",
"required": ["userId"], "required": ["userId"],
@ -10128,6 +10144,20 @@
"canAddBdLeader": { "canAddBdLeader": {
"type": "boolean" "type": "boolean"
}, },
"canAddBdLeaderInRegion": {
"type": "boolean"
},
"bdLeaderInviteBaseScope": {
"type": "string",
"enum": ["country", "region", "global"]
},
"bdLeaderInviteEffectiveScope": {
"type": "string",
"enum": ["country", "region", "global"]
},
"bdLeaderRegionExpansionConfigurable": {
"type": "boolean"
},
"canAddSuperadmin": { "canAddSuperadmin": {
"type": "boolean" "type": "boolean"
}, },
@ -10638,6 +10668,9 @@
"canAddBdLeader": { "canAddBdLeader": {
"type": "boolean" "type": "boolean"
}, },
"canAddBdLeaderInRegion": {
"type": "boolean"
},
"canAddSuperadmin": { "canAddSuperadmin": {
"type": "boolean" "type": "boolean"
}, },
@ -10680,6 +10713,9 @@
"canAddBdLeader": { "canAddBdLeader": {
"type": "boolean" "type": "boolean"
}, },
"canAddBdLeaderInRegion": {
"type": "boolean"
},
"canAddSuperadmin": { "canAddSuperadmin": {
"type": "boolean" "type": "boolean"
}, },

View File

@ -69,6 +69,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
status: "active", status: "active",
}); });
await createManager({ await createManager({
canAddBdLeaderInRegion: false,
canBlockUser: false, canBlockUser: false,
canGrantBadge: false, canGrantBadge: false,
canGrantVip: true, canGrantVip: true,
@ -76,6 +77,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
targetUserId: "1003", targetUserId: "1003",
}); });
await updateManager("1003", { await updateManager("1003", {
canAddBdLeaderInRegion: true,
canBlockUser: false, canBlockUser: false,
canGrantVehicle: true, canGrantVehicle: true,
canGrantVip: false, canGrantVip: false,
@ -158,6 +160,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(String(managerCreateUrl)).toContain("/api/v1/admin/managers"); expect(String(managerCreateUrl)).toContain("/api/v1/admin/managers");
expect(managerCreateInit?.method).toBe("POST"); expect(managerCreateInit?.method).toBe("POST");
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({ expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
canAddBdLeaderInRegion: false,
canBlockUser: false, canBlockUser: false,
canGrantBadge: false, canGrantBadge: false,
canGrantVip: true, canGrantVip: true,
@ -167,6 +170,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(String(managerUpdateUrl)).toContain("/api/v1/admin/managers/1003"); expect(String(managerUpdateUrl)).toContain("/api/v1/admin/managers/1003");
expect(managerUpdateInit?.method).toBe("PUT"); expect(managerUpdateInit?.method).toBe("PUT");
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({ expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
canAddBdLeaderInRegion: true,
canBlockUser: false, canBlockUser: false,
canGrantVehicle: true, canGrantVehicle: true,
canGrantVip: false, canGrantVip: false,

View File

@ -17,29 +17,44 @@ export const managerPermissionFields = [
"canGrantVip", "canGrantVip",
"canUpdateUserLevel", "canUpdateUserLevel",
"canAddBdLeader", "canAddBdLeader",
"canAddBdLeaderInRegion",
"canAddAdmin", "canAddAdmin",
"canAddSuperadmin", "canAddSuperadmin",
"canBlockUser", "canBlockUser",
"canTransferUserCountry", "canTransferUserCountry",
]; ];
const emptyPermissions = () => Object.fromEntries(managerPermissionFields.map((field) => [field, true])); const managerPermissionDefault = (field) => field !== "canAddBdLeaderInRegion";
const emptyManagerForm = () => ({ contact: "", targetUserId: "", ...emptyPermissions() }); const managerPermissionEnabled = (source, field) => {
const value = source?.[field];
return value === undefined || value === null ? managerPermissionDefault(field) : value !== false;
};
const emptyPermissions = () =>
Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionDefault(field)]));
const emptyManagerForm = (regionExpansionConfigurable = false) => ({
bdLeaderRegionExpansionConfigurable: regionExpansionConfigurable,
contact: "",
targetUserId: "",
...emptyPermissions(),
});
const managerPermissionPayload = (form) => const managerPermissionPayload = (form) =>
Object.fromEntries(managerPermissionFields.map((field) => [field, form[field] !== false])); Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionEnabled(form, field)]));
const managerStatusPayload = (item, status) => ({ const managerStatusPayload = (item, status) => ({
contact: item?.contact || "", contact: item?.contact || "",
status, status,
...Object.fromEntries(managerPermissionFields.map((field) => [field, item?.[field] !== false])), ...Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionEnabled(item, field)])),
}); });
const managerFormFromItem = (item) => ({ const managerFormFromItem = (item) => ({
bdLeaderRegionExpansionConfigurable: item?.bdLeaderRegionExpansionConfigurable === true,
contact: item?.contact || "", contact: item?.contact || "",
targetUserId: String(item?.userId || ""), targetUserId: String(item?.userId || ""),
...Object.fromEntries(managerPermissionFields.map((field) => [field, item?.[field] !== false])), ...Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionEnabled(item, field)])),
}); });
export function useHostManagersPage() { export function useHostManagersPage() {
@ -118,7 +133,7 @@ export function useHostManagersPage() {
}); });
showToast("经理已添加", "success"); showToast("经理已添加", "success");
} }
setManagerForm(emptyManagerForm()); setManagerForm(emptyManagerForm(data.bdLeaderInviteScopePolicy?.regionExpansionConfigurable === true));
setActiveAction(""); setActiveAction("");
setEditingManager(null); setEditingManager(null);
await reload(); await reload();
@ -132,12 +147,12 @@ export function useHostManagersPage() {
const closeAction = () => { const closeAction = () => {
setActiveAction(""); setActiveAction("");
setEditingManager(null); setEditingManager(null);
setManagerForm(emptyManagerForm()); setManagerForm(emptyManagerForm(data.bdLeaderInviteScopePolicy?.regionExpansionConfigurable === true));
}; };
const openManagerForm = () => { const openManagerForm = () => {
setEditingManager(null); setEditingManager(null);
setManagerForm(emptyManagerForm()); setManagerForm(emptyManagerForm(data.bdLeaderInviteScopePolicy?.regionExpansionConfigurable === true));
setActiveAction("manager"); setActiveAction("manager");
}; };

View File

@ -22,6 +22,7 @@ const managerPermissionOptions = [
{ key: "canGrantVip", label: "VIP" }, { key: "canGrantVip", label: "VIP" },
{ key: "canUpdateUserLevel", label: "升级等级" }, { key: "canUpdateUserLevel", label: "升级等级" },
{ key: "canAddBdLeader", label: "BD Leader" }, { key: "canAddBdLeader", label: "BD Leader" },
{ key: "canAddBdLeaderInRegion", label: "区域邀请 BD Leader", regionExpansionOnly: true },
{ key: "canAddAdmin", label: "Admin" }, { key: "canAddAdmin", label: "Admin" },
{ key: "canAddSuperadmin", label: "Superadmin" }, { key: "canAddSuperadmin", label: "Superadmin" },
{ key: "canBlockUser", label: "Block User" }, { key: "canBlockUser", label: "Block User" },
@ -201,20 +202,28 @@ function ManagerStatusSwitch({ item, page }) {
function ManagerPermissions({ item }) { function ManagerPermissions({ item }) {
return ( return (
<div className={styles.permissionList}> <div className={styles.permissionList}>
{managerPermissionOptions.map((permission) => { {managerPermissionOptions
const enabled = item?.[permission.key] !== false; .filter(
return ( (permission) =>
<span !permission.regionExpansionOnly || item?.bdLeaderRegionExpansionConfigurable === true,
className={ )
enabled ? styles.permissionPill : `${styles.permissionPill} ${styles.permissionPillOff}` .map((permission) => {
} const enabled =
key={permission.key} permission.key === "canAddBdLeaderInRegion"
> ? item?.[permission.key] === true
{permission.label} : item?.[permission.key] !== false;
<span>{enabled ? "开" : "关"}</span> return (
</span> <span
); className={
})} enabled ? styles.permissionPill : `${styles.permissionPill} ${styles.permissionPillOff}`
}
key={permission.key}
>
{permission.label}
<span>{enabled ? "开" : "关"}</span>
</span>
);
})}
</div> </div>
); );
} }
@ -237,19 +246,28 @@ function ManagerActions({ item, page }) {
function PermissionSwitchGrid({ disabled, form, onChange }) { function PermissionSwitchGrid({ disabled, form, onChange }) {
return ( return (
<div className={styles.permissionGrid}> <div className={styles.permissionGrid}>
{managerPermissionOptions.map((permission) => ( {managerPermissionOptions
<label className={styles.permissionSwitch} key={permission.key}> .filter(
<span>{permission.label}</span> (permission) =>
<AdminSwitch !permission.regionExpansionOnly || form?.bdLeaderRegionExpansionConfigurable === true,
checked={form?.[permission.key] !== false} )
checkedLabel="开" .map((permission) => (
disabled={disabled} <label className={styles.permissionSwitch} key={permission.key}>
label={permission.label} <span>{permission.label}</span>
uncheckedLabel="关" <AdminSwitch
onChange={(event) => onChange({ ...form, [permission.key]: event.target.checked })} checked={
/> permission.key === "canAddBdLeaderInRegion"
</label> ? form?.[permission.key] === true
))} : form?.[permission.key] !== false
}
checkedLabel="开"
disabled={disabled}
label={permission.label}
uncheckedLabel="关"
onChange={(event) => onChange({ ...form, [permission.key]: event.target.checked })}
/>
</label>
))}
</div> </div>
); );
} }

View File

@ -17,7 +17,9 @@ afterEach(() => {
test("manager status column uses a switch and toggles through the page hook", () => { test("manager status column uses a switch and toggles through the page hook", () => {
const manager = managerFixture(); const manager = managerFixture();
vi.mocked(useHostManagersPage).mockReturnValue(pageFixture({ data: { items: [manager], page: 1, pageSize: 50, total: 1 } })); vi.mocked(useHostManagersPage).mockReturnValue(
pageFixture({ data: { items: [manager], page: 1, pageSize: 50, total: 1 } }),
);
render(<HostManagersPage />); render(<HostManagersPage />);
@ -31,6 +33,35 @@ test("manager status column uses a switch and toggles through the page hook", ()
expect(mocks.toggleManager).toHaveBeenCalledWith(manager, false); expect(mocks.toggleManager).toHaveBeenCalledWith(manager, false);
}); });
test("manager form only shows the region invitation switch when backend policy allows configuration", () => {
vi.mocked(useHostManagersPage).mockReturnValue(
pageFixture({
activeAction: "manager",
managerForm: {
...pageFixture().managerForm,
bdLeaderRegionExpansionConfigurable: true,
canAddBdLeaderInRegion: false,
},
}),
);
const { rerender } = render(<HostManagersPage />);
expect(screen.getByRole("switch", { name: "区域邀请 BD Leader" })).not.toBeChecked();
vi.mocked(useHostManagersPage).mockReturnValue(
pageFixture({
activeAction: "manager",
managerForm: {
...pageFixture().managerForm,
bdLeaderRegionExpansionConfigurable: false,
canAddBdLeaderInRegion: false,
},
}),
);
rerender(<HostManagersPage />);
expect(screen.queryByRole("switch", { name: "区域邀请 BD Leader" })).not.toBeInTheDocument();
});
function pageFixture(patch = {}) { function pageFixture(patch = {}) {
return { return {
abilities: { abilities: {
@ -50,8 +81,10 @@ function pageFixture(patch = {}) {
loadingAction: "", loadingAction: "",
loadingRegions: false, loadingRegions: false,
managerForm: { managerForm: {
bdLeaderRegionExpansionConfigurable: true,
canAddAdmin: true, canAddAdmin: true,
canAddBdLeader: true, canAddBdLeader: true,
canAddBdLeaderInRegion: false,
canAddSuperadmin: true, canAddSuperadmin: true,
canBlockUser: true, canBlockUser: true,
canGrantAvatarFrame: true, canGrantAvatarFrame: true,
@ -86,6 +119,8 @@ function managerFixture() {
bdLeaderCount: 3, bdLeaderCount: 3,
canAddAdmin: true, canAddAdmin: true,
canAddBdLeader: false, canAddBdLeader: false,
canAddBdLeaderInRegion: false,
bdLeaderRegionExpansionConfigurable: true,
canAddSuperadmin: true, canAddSuperadmin: true,
canBlockUser: true, canBlockUser: true,
canGrantAvatarFrame: true, canGrantAvatarFrame: true,

View File

@ -132,6 +132,7 @@ test("manager schema defaults vip grant permission on and preserves explicit fal
expect(disabledResult.success).toBe(true); expect(disabledResult.success).toBe(true);
if (defaultResult.success) { if (defaultResult.success) {
expect(defaultResult.data.canGrantVip).toBe(true); expect(defaultResult.data.canGrantVip).toBe(true);
expect(defaultResult.data.canAddBdLeaderInRegion).toBe(false);
} }
if (disabledResult.success) { if (disabledResult.success) {
expect(disabledResult.data.canGrantVip).toBe(false); expect(disabledResult.data.canGrantVip).toBe(false);

View File

@ -112,6 +112,7 @@ export const createCoinSellerSchema = commandContactSchema.extend({
export const createManagerSchema = z.object({ export const createManagerSchema = z.object({
canAddAdmin: z.boolean().optional().default(true), canAddAdmin: z.boolean().optional().default(true),
canAddBdLeader: z.boolean().optional().default(true), canAddBdLeader: z.boolean().optional().default(true),
canAddBdLeaderInRegion: z.boolean().optional().default(false),
canAddSuperadmin: z.boolean().optional().default(true), canAddSuperadmin: z.boolean().optional().default(true),
canBlockUser: z.boolean().optional().default(true), canBlockUser: z.boolean().optional().default(true),
canGrantAvatarFrame: z.boolean().optional().default(true), canGrantAvatarFrame: z.boolean().optional().default(true),

View File

@ -4677,6 +4677,7 @@ export interface components {
total: number; total: number;
}; };
ApiPageManager: { ApiPageManager: {
bdLeaderInviteScopePolicy: components["schemas"]["ManagerRoleScopePolicy"];
items: components["schemas"]["Manager"][]; items: components["schemas"]["Manager"][];
page: number; page: number;
pageSize: number; pageSize: number;
@ -5056,11 +5057,22 @@ export interface components {
status?: string; status?: string;
updatedAtMs?: number; updatedAtMs?: number;
}; };
ManagerRoleScopePolicy: {
/** @enum {string} */
baseScope: "country" | "region" | "global";
regionExpansionConfigurable: boolean;
};
Manager: { Manager: {
avatar?: string; avatar?: string;
bdLeaderCount?: number; bdLeaderCount?: number;
canAddAdmin?: boolean; canAddAdmin?: boolean;
canAddBdLeader?: boolean; canAddBdLeader?: boolean;
canAddBdLeaderInRegion?: boolean;
/** @enum {string} */
bdLeaderInviteBaseScope?: "country" | "region" | "global";
/** @enum {string} */
bdLeaderInviteEffectiveScope?: "country" | "region" | "global";
bdLeaderRegionExpansionConfigurable?: boolean;
canAddSuperadmin?: boolean; canAddSuperadmin?: boolean;
canBlockUser?: boolean; canBlockUser?: boolean;
canGrantAvatarFrame?: boolean; canGrantAvatarFrame?: boolean;
@ -5232,6 +5244,7 @@ export interface components {
CreateManagerPayload: { CreateManagerPayload: {
canAddAdmin?: boolean; canAddAdmin?: boolean;
canAddBdLeader?: boolean; canAddBdLeader?: boolean;
canAddBdLeaderInRegion?: boolean;
canAddSuperadmin?: boolean; canAddSuperadmin?: boolean;
canBlockUser?: boolean; canBlockUser?: boolean;
canGrantAvatarFrame?: boolean; canGrantAvatarFrame?: boolean;
@ -5246,6 +5259,7 @@ export interface components {
UpdateManagerPayload: { UpdateManagerPayload: {
canAddAdmin?: boolean; canAddAdmin?: boolean;
canAddBdLeader?: boolean; canAddBdLeader?: boolean;
canAddBdLeaderInRegion?: boolean;
canAddSuperadmin?: boolean; canAddSuperadmin?: boolean;
canBlockUser?: boolean; canBlockUser?: boolean;
canGrantAvatarFrame?: boolean; canGrantAvatarFrame?: boolean;

View File

@ -1,10 +1,16 @@
export interface ApiPage<TItem> { export interface ApiPage<TItem> {
bdLeaderInviteScopePolicy?: ManagerRoleScopePolicy;
items: TItem[]; items: TItem[];
page: number; page: number;
pageSize: number; pageSize: number;
total: number; total: number;
} }
export interface ManagerRoleScopePolicy {
baseScope: "country" | "region" | "global";
regionExpansionConfigurable: boolean;
}
export interface ApiList<TItem> { export interface ApiList<TItem> {
items: TItem[]; items: TItem[];
total: number; total: number;
@ -756,6 +762,10 @@ export interface ManagerDto {
bdLeaderCount?: number; bdLeaderCount?: number;
canAddAdmin?: boolean; canAddAdmin?: boolean;
canAddBdLeader?: boolean; canAddBdLeader?: boolean;
canAddBdLeaderInRegion?: boolean;
bdLeaderInviteBaseScope?: "country" | "region" | "global";
bdLeaderInviteEffectiveScope?: "country" | "region" | "global";
bdLeaderRegionExpansionConfigurable?: boolean;
canAddSuperadmin?: boolean; canAddSuperadmin?: boolean;
canBlockUser?: boolean; canBlockUser?: boolean;
canGrantAvatarFrame?: boolean; canGrantAvatarFrame?: boolean;
@ -779,6 +789,7 @@ export interface ManagerDto {
export interface CreateManagerPayload { export interface CreateManagerPayload {
canAddAdmin?: boolean; canAddAdmin?: boolean;
canAddBdLeader?: boolean; canAddBdLeader?: boolean;
canAddBdLeaderInRegion?: boolean;
canAddSuperadmin?: boolean; canAddSuperadmin?: boolean;
canBlockUser?: boolean; canBlockUser?: boolean;
canGrantAvatarFrame?: boolean; canGrantAvatarFrame?: boolean;
@ -794,6 +805,7 @@ export interface CreateManagerPayload {
export interface UpdateManagerPayload { export interface UpdateManagerPayload {
canAddAdmin?: boolean; canAddAdmin?: boolean;
canAddBdLeader?: boolean; canAddBdLeader?: boolean;
canAddBdLeaderInRegion?: boolean;
canAddSuperadmin?: boolean; canAddSuperadmin?: boolean;
canBlockUser?: boolean; canBlockUser?: boolean;
canGrantAvatarFrame?: boolean; canGrantAvatarFrame?: boolean;