身份自动赠送

This commit is contained in:
zhx 2026-06-24 14:45:55 +08:00
parent 4166fe0786
commit dae91e892e
19 changed files with 597 additions and 6 deletions

View File

@ -2959,6 +2959,28 @@
"x-permissions": ["resource-shop:update"]
}
},
"/admin/resource-groups/identity-auto-grant-config": {
"get": {
"operationId": "getResourceIdentityAutoGrantConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "resource-group:view",
"x-permissions": ["resource-group:view"]
},
"put": {
"operationId": "updateResourceIdentityAutoGrantConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "resource-group:update",
"x-permissions": ["resource-group:update"]
}
},
"/admin/resource-groups": {
"get": {
"operationId": "listResourceGroups",

View File

@ -61,8 +61,13 @@ test("host org list APIs use generated admin paths and filters", async () => {
region_id: 7,
status: "active",
});
await createManager({ canBlockUser: false, contact: "+63", targetUserId: "1003" });
await updateManager("1003", { canBlockUser: false, canGrantVehicle: true, contact: "+63" });
await createManager({ canBlockUser: false, canGrantBadge: false, contact: "+63", targetUserId: "1003" });
await updateManager("1003", {
canBlockUser: false,
canGrantVehicle: true,
canTransferUserCountry: false,
contact: "+63",
});
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
await listCoinSellers({
page: 1,
@ -133,6 +138,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(managerCreateInit?.method).toBe("POST");
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
canBlockUser: false,
canGrantBadge: false,
contact: "+63",
targetUserId: "1003",
});
@ -141,6 +147,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
canBlockUser: false,
canGrantVehicle: true,
canTransferUserCountry: false,
contact: "+63",
});
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");

View File

@ -12,11 +12,13 @@ const emptyData = { items: [], page: 1, pageSize, total: 0 };
export const managerPermissionFields = [
"canGrantAvatarFrame",
"canGrantVehicle",
"canGrantBadge",
"canUpdateUserLevel",
"canAddBdLeader",
"canAddAdmin",
"canAddSuperadmin",
"canBlockUser",
"canTransferUserCountry",
];
const emptyPermissions = () => Object.fromEntries(managerPermissionFields.map((field) => [field, true]));

View File

@ -18,11 +18,13 @@ import styles from "@/features/host-org/host-org.module.css";
const managerPermissionOptions = [
{ key: "canGrantAvatarFrame", label: "头像框" },
{ key: "canGrantVehicle", label: "Car" },
{ key: "canGrantBadge", label: "徽章" },
{ key: "canUpdateUserLevel", label: "升级等级" },
{ key: "canAddBdLeader", label: "BD Leader" },
{ key: "canAddAdmin", label: "Admin" },
{ key: "canAddSuperadmin", label: "Superadmin" },
{ key: "canBlockUser", label: "Block User" },
{ key: "canTransferUserCountry", label: "转移国家" },
];
const managerColumns = [

View File

@ -114,7 +114,9 @@ export const createManagerSchema = z.object({
canAddSuperadmin: z.boolean().optional().default(true),
canBlockUser: z.boolean().optional().default(true),
canGrantAvatarFrame: z.boolean().optional().default(true),
canGrantBadge: z.boolean().optional().default(true),
canGrantVehicle: z.boolean().optional().default(true),
canTransferUserCountry: z.boolean().optional().default(true),
canUpdateUserLevel: z.boolean().optional().default(true),
contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
targetUserId: userIdSchema,

View File

@ -14,6 +14,7 @@ import {
enableGift,
enableResource,
enableResourceGroup,
getResourceIdentityAutoGrantConfig,
grantResource,
grantResourceGroup,
listEmojiPackCategories,
@ -28,6 +29,7 @@ import {
updateGift,
updateGiftTypes,
updateResource,
updateResourceIdentityAutoGrantConfig,
updateResourceMp4Layouts,
updateResourceGroup,
upsertResourceShopItems,
@ -262,6 +264,58 @@ test("resource APIs use generated admin paths", async () => {
expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: "1001" });
});
test("resource identity auto grant config APIs use generated admin paths", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
items: [
{ identity_type: "host", resource_group_id: 11 },
{ identityType: "bd", resourceGroupId: 12 },
],
},
}),
),
),
);
const config = await getResourceIdentityAutoGrantConfig();
await updateResourceIdentityAutoGrantConfig({
items: [
{ identity_type: "host", resource_group_id: 11 },
{ identity_type: "bd", resource_group_id: 12 },
{ identity_type: "bd_leader", resource_group_id: 13 },
{ identity_type: "agency", resource_group_id: 14 },
{ identity_type: "manager", resource_group_id: 0 },
],
});
const [getUrl, getInit] = vi.mocked(fetch).mock.calls[0];
const [putUrl, putInit] = vi.mocked(fetch).mock.calls[1];
expect(config.items).toMatchObject([
{ identityType: "host", resourceGroupId: 11 },
{ identityType: "bd", resourceGroupId: 12 },
]);
expect(String(getUrl)).toContain("/api/v1/admin/resource-groups/identity-auto-grant-config");
expect(getInit?.method).toBe("GET");
expect(String(putUrl)).toContain("/api/v1/admin/resource-groups/identity-auto-grant-config");
expect(putInit?.method).toBe("PUT");
expect(JSON.parse(String(putInit?.body))).toEqual({
items: [
{ identity_type: "host", resource_group_id: 11 },
{ identity_type: "bd", resource_group_id: 12 },
{ identity_type: "bd_leader", resource_group_id: 13 },
{ identity_type: "agency", resource_group_id: 14 },
{ identity_type: "manager", resource_group_id: 0 },
],
});
});
test("resource mp4 layout batch API uses narrow update endpoint", async () => {
vi.stubGlobal(
"fetch",

View File

@ -1,6 +1,7 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, CoinLedgerUserDto, EntityId, PageQuery } from "@/shared/api/types";
import { resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js";
export interface ResourceDto {
appCode?: string;
@ -108,6 +109,30 @@ export interface ResourceGroupDto {
updatedAtMs?: number;
}
export type ResourceIdentityAutoGrantType = "host" | "bd" | "bd_leader" | "agency" | "manager";
export interface ResourceIdentityAutoGrantItemDto {
identityType: ResourceIdentityAutoGrantType;
resourceGroupId: number;
resourceGroup?: ResourceGroupDto;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface ResourceIdentityAutoGrantConfigDto {
items: ResourceIdentityAutoGrantItemDto[];
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface ResourceIdentityAutoGrantConfigPayload {
items: Array<{
identity_type: ResourceIdentityAutoGrantType;
resource_group_id: number;
}>;
}
export interface ResourceShopItemDto {
appCode?: string;
shopItemId: number;
@ -446,6 +471,29 @@ export function disableResourceGroup(groupId: EntityId): Promise<ResourceGroupDt
});
}
export function getResourceIdentityAutoGrantConfig(): Promise<ResourceIdentityAutoGrantConfigDto> {
const endpoint = API_ENDPOINTS.getResourceIdentityAutoGrantConfig;
return apiRequest<RawResourceIdentityAutoGrantConfig | RawResourceIdentityAutoGrantItem[]>(
apiEndpointPath(API_OPERATIONS.getResourceIdentityAutoGrantConfig),
{
method: endpoint.method,
},
).then(normalizeResourceIdentityAutoGrantConfig);
}
export function updateResourceIdentityAutoGrantConfig(
payload: ResourceIdentityAutoGrantConfigPayload,
): Promise<ResourceIdentityAutoGrantConfigDto> {
const endpoint = API_ENDPOINTS.updateResourceIdentityAutoGrantConfig;
return apiRequest<
RawResourceIdentityAutoGrantConfig | RawResourceIdentityAutoGrantItem[],
ResourceIdentityAutoGrantConfigPayload
>(apiEndpointPath(API_OPERATIONS.updateResourceIdentityAutoGrantConfig), {
body: payload,
method: endpoint.method,
}).then(normalizeResourceIdentityAutoGrantConfig);
}
export function listGifts(query: PageQuery = {}): Promise<ApiPage<GiftDto>> {
const endpoint = API_ENDPOINTS.listGifts;
return apiRequest<ApiPage<GiftDto>>(apiEndpointPath(API_OPERATIONS.listGifts), {
@ -571,3 +619,58 @@ export function grantResourceGroup(payload: GrantResourceGroupPayload): Promise<
method: endpoint.method,
});
}
type RawResourceIdentityAutoGrantItem = ResourceIdentityAutoGrantItemDto & {
created_at_ms?: unknown;
identity_type?: unknown;
resource_group?: unknown;
resource_group_id?: unknown;
updated_at_ms?: unknown;
};
type RawResourceIdentityAutoGrantConfig = ResourceIdentityAutoGrantConfigDto & {
configs?: RawResourceIdentityAutoGrantItem[];
created_at_ms?: unknown;
items?: RawResourceIdentityAutoGrantItem[];
updated_at_ms?: unknown;
updated_by_admin_id?: unknown;
};
function normalizeResourceIdentityAutoGrantConfig(
source: RawResourceIdentityAutoGrantConfig | RawResourceIdentityAutoGrantItem[],
): ResourceIdentityAutoGrantConfigDto {
const rawItems = Array.isArray(source) ? source : source.items || source.configs || [];
return {
items: rawItems.map(normalizeResourceIdentityAutoGrantItem),
updatedByAdminId: Array.isArray(source) ? 0 : numberValue(source.updatedByAdminId ?? source.updated_by_admin_id),
createdAtMs: Array.isArray(source) ? 0 : numberValue(source.createdAtMs ?? source.created_at_ms),
updatedAtMs: Array.isArray(source) ? 0 : numberValue(source.updatedAtMs ?? source.updated_at_ms),
};
}
function normalizeResourceIdentityAutoGrantItem(
item: RawResourceIdentityAutoGrantItem,
): ResourceIdentityAutoGrantItemDto {
const resourceGroup = item.resourceGroup ?? item.resource_group;
return {
identityType: normalizeIdentityType(item.identityType ?? item.identity_type),
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
resourceGroup: resourceGroup && typeof resourceGroup === "object" ? (resourceGroup as ResourceGroupDto) : undefined,
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeIdentityType(value: unknown): ResourceIdentityAutoGrantType {
const identityType = stringValue(value) as ResourceIdentityAutoGrantType;
return resourceIdentityAutoGrantTypes.includes(identityType) ? identityType : "host";
}
function numberValue(value: unknown): number {
const parsed = Number(value || 0);
return Number.isFinite(parsed) ? parsed : 0;
}
function stringValue(value: unknown): string {
return value === undefined || value === null ? "" : String(value);
}

View File

@ -127,6 +127,18 @@ export const resourceGroupAssetOptions = [
export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions);
export const resourceIdentityAutoGrantOptions = [
["host", "主播"],
["bd", "BD"],
["bd_leader", "BD Leader"],
["agency", "Agency"],
["manager", "Manager"],
];
export const resourceIdentityAutoGrantTypes = resourceIdentityAutoGrantOptions.map(([value]) => value);
export const resourceIdentityAutoGrantLabels = Object.fromEntries(resourceIdentityAutoGrantOptions);
export const grantStrategyLabels = {
extend_expiry: "延长有效期",
increase_quantity: "增加数量",

View File

@ -18,6 +18,7 @@ import {
enableGift,
enableResource,
enableResourceGroup,
getResourceIdentityAutoGrantConfig,
grantResource,
grantResourceGroup,
listEmojiPackCategories,
@ -33,6 +34,7 @@ import {
updateGift,
updateGiftTypes,
updateResource,
updateResourceIdentityAutoGrantConfig,
updateResourceGroup,
upsertResourceShopItems,
} from "@/features/resources/api";
@ -40,6 +42,7 @@ import {
cpRelationTypeLabels,
cpRelationTypeOptions,
defaultGiftTypeOptions,
resourceIdentityAutoGrantOptions,
resourceShopSellableTypes,
} from "@/features/resources/constants.js";
import { useResourceAbilities } from "@/features/resources/permissions.js";
@ -51,6 +54,7 @@ import {
resourceFormSchema,
resourceGrantFormSchema,
resourceGroupCreateFormSchema,
resourceIdentityAutoGrantFormSchema,
resourceShopItemsFormSchema,
} from "@/features/resources/schema.js";
@ -109,6 +113,15 @@ const emptyResourceGroupForm = (group = {}) => ({
name: group.name || "",
sortOrder: group.sortOrder === 0 || group.sortOrder ? String(group.sortOrder) : "0",
});
const emptyResourceIdentityAutoGrantForm = (items = []) => {
const itemByIdentityType = new Map(items.map((item) => [item.identityType, item]));
return resourceIdentityAutoGrantOptions.map(([identityType]) => ({
identityType,
resourceGroupId: itemByIdentityType.get(identityType)?.resourceGroupId
? String(itemByIdentityType.get(identityType).resourceGroupId)
: "",
}));
};
const emptyGroupResourceItem = () => ({
durationDays: "1",
itemType: "resource",
@ -778,6 +791,9 @@ export function useResourceGroupListPage() {
const [loadingAction, setLoadingAction] = useState("");
const [resourceOptions, setResourceOptions] = useState([]);
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
const [identityGrantForm, setIdentityGrantForm] = useState(() => emptyResourceIdentityAutoGrantForm());
const [identityGrantGroupOptions, setIdentityGrantGroupOptions] = useState([]);
const [identityGrantLoading, setIdentityGrantLoading] = useState(false);
const filters = useMemo(
() => ({
keyword: query,
@ -821,6 +837,40 @@ export function useResourceGroupListPage() {
};
}, [activeAction, selectedGroup, showToast]);
useEffect(() => {
if (activeAction !== "identity-config") {
return undefined;
}
let ignore = false;
setIdentityGrantLoading(true);
// 这里并行拉取配置和可发放资源组,避免弹窗先显示旧配置再异步换值造成误保存。
Promise.all([
getResourceIdentityAutoGrantConfig(),
fetchAllOptionPages(listResourceGroups, { status: "active" }),
])
.then(([config, groups]) => {
if (ignore) {
return;
}
setIdentityGrantForm(emptyResourceIdentityAutoGrantForm(config.items || []));
setIdentityGrantGroupOptions(mergeResourceGroupOptions(groups.items || [], config.items || []));
})
.catch((err) => {
if (!ignore) {
showToast(err.message || "加载身份资源组配置失败", "error");
setActiveAction("");
}
})
.finally(() => {
if (!ignore) {
setIdentityGrantLoading(false);
}
});
return () => {
ignore = true;
};
}, [activeAction, showToast]);
const openCreateGroup = () => {
setForm(emptyResourceGroupForm());
setSelectedGroup(null);
@ -836,6 +886,11 @@ export function useResourceGroupListPage() {
setActiveAction("edit");
};
const openIdentityGrantConfig = () => {
setIdentityGrantLoading(true);
setActiveAction("identity-config");
};
const closeAction = () => {
setActiveAction("");
setSelectedGroup(null);
@ -887,6 +942,27 @@ export function useResourceGroupListPage() {
}
};
const submitIdentityGrantConfig = async (event) => {
event.preventDefault();
if (!abilities.canUpdateGroup) {
return;
}
setLoadingAction("identity-grant-config");
try {
const payload = buildResourceIdentityAutoGrantPayload(
parseForm(resourceIdentityAutoGrantFormSchema, { items: identityGrantForm }),
);
const nextConfig = await updateResourceIdentityAutoGrantConfig(payload);
setIdentityGrantForm(emptyResourceIdentityAutoGrantForm(nextConfig.items || []));
showToast("身份资源组配置已保存", "success");
closeAction();
} catch (err) {
showToast(err.message || "保存身份资源组配置失败", "error");
} finally {
setLoadingAction("");
}
};
const toggleGroup = async (group, nextEnabled = group.status !== "active") => {
if (!abilities.canUpdateGroup || !group?.groupId) {
return;
@ -925,16 +1001,22 @@ export function useResourceGroupListPage() {
changeStatus: resetSetter(setStatus, setPage),
closeAction,
form,
identityGrantForm,
identityGrantGroupOptions,
identityGrantLoading,
loadingAction,
openCreateGroup,
openEditGroup,
openIdentityGrantConfig,
removeGroupItem,
resourceOptions,
resourceOptionsLoading,
selectedGroup,
resetFilters,
setForm,
setIdentityGrantForm,
status,
submitIdentityGrantConfig,
submitGroup,
toggleGroup,
updateGroupItem,
@ -1744,6 +1826,35 @@ function buildResourceGroupPayload(form) {
};
}
function buildResourceIdentityAutoGrantPayload(form) {
const itemByIdentityType = new Map(form.items.map((item) => [item.identityType, item]));
return {
items: resourceIdentityAutoGrantOptions.map(([identityType]) => {
const resourceGroupId = Number(itemByIdentityType.get(identityType)?.resourceGroupId || 0);
return {
identity_type: identityType,
resource_group_id: Number.isInteger(resourceGroupId) && resourceGroupId >= 0 ? resourceGroupId : 0,
};
}),
};
}
function mergeResourceGroupOptions(activeGroups = [], configItems = []) {
const groupById = new Map();
activeGroups.forEach((group) => {
if (group?.groupId) {
groupById.set(String(group.groupId), group);
}
});
configItems.forEach((item) => {
const group = item?.resourceGroup;
if (group?.groupId && !groupById.has(String(group.groupId))) {
groupById.set(String(group.groupId), group);
}
});
return Array.from(groupById.values());
}
function buildGiftPayload(form) {
const cpRelationType = form.giftTypeCode === cpGiftTypeCode ? normalizedCPRelationType(form.cpRelationType) : "";
return {

View File

@ -4,6 +4,7 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import {
@ -28,8 +29,14 @@ import {
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
import {
resourceGroupAssetLabels,
resourceIdentityAutoGrantLabels,
resourceStatusFilters,
resourceTypeLabels,
} from "@/features/resources/constants.js";
import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import styles from "@/features/resources/resources.module.css";
const dayMillis = 24 * 60 * 60 * 1000;
@ -114,6 +121,13 @@ export function ResourceGroupListPage() {
</AdminActionIconButton>
) : null
}
filters={
page.abilities.canViewGroup ? (
<AdminActionIconButton label="配置身份资源组" onClick={page.openIdentityGrantConfig}>
<SettingsOutlined fontSize="small" />
</AdminActionIconButton>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
@ -151,6 +165,16 @@ export function ResourceGroupListPage() {
onClose={page.closeAction}
onSubmit={page.submitGroup}
/>
<ResourceIdentityAutoGrantDialog
disabled={!page.abilities.canUpdateGroup}
form={page.identityGrantForm}
groupOptions={page.identityGrantGroupOptions}
loading={page.identityGrantLoading || page.loadingAction === "identity-grant-config"}
open={page.activeAction === "identity-config"}
page={page}
onClose={page.closeAction}
onSubmit={page.submitIdentityGrantConfig}
/>
</AdminListPage>
);
}
@ -271,6 +295,53 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
);
}
function ResourceIdentityAutoGrantDialog({ disabled, form, groupOptions, loading, onClose, onSubmit, open, page }) {
const submitDisabled = disabled || loading;
return (
<AdminFormDialog
loading={loading}
open={open}
size="wide"
submitDisabled={submitDisabled}
submitLabel="保存配置"
title="身份资源组配置"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="自动发放资源组">
<AdminFormList>
{form.map((item, index) => (
<AdminFormListRow columns="140px minmax(260px, 1fr)" key={item.identityType}>
<span className={styles.identityGrantLabel}>
{resourceIdentityAutoGrantLabels[item.identityType] || item.identityType}
</span>
<ResourceGroupSelectField
allowEmpty
disabled={disabled || loading}
drawerTitle={`${resourceIdentityAutoGrantLabels[item.identityType] || item.identityType}资源组`}
emptyLabel="不自动发放"
groups={groupOptions}
label="资源组"
placeholder="点击选择资源组"
value={item.resourceGroupId}
onChange={(value) =>
page.setIdentityGrantForm((current) =>
current.map((currentItem, itemIndex) =>
itemIndex === index
? { ...currentItem, resourceGroupId: value }
: currentItem,
),
)
}
/>
</AdminFormListRow>
))}
</AdminFormList>
</AdminFormSection>
</AdminFormDialog>
);
}
function ResourceGroupItemEditor({ disabled, index, item, page }) {
if (item.itemType === "wallet_asset") {
const label = resourceGroupAssetLabels[item.walletAssetType] || "钱包资产";

View File

@ -20,6 +20,7 @@ export function useResourceAbilities() {
canView: can(PERMISSIONS.resourceView),
canViewEmojiPack: can(PERMISSIONS.emojiPackView),
canViewGrant: can(PERMISSIONS.resourceGrantView),
canViewGroup: can(PERMISSIONS.resourceGroupView),
canViewShop: can(PERMISSIONS.resourceShopView),
};
}

View File

@ -100,6 +100,14 @@
white-space: nowrap;
}
.identityGrantLabel {
display: inline-flex;
min-height: var(--control-height);
align-items: center;
color: var(--text-primary);
font-weight: 700;
}
.selectColumn {
justify-content: center;
}

View File

@ -1,5 +1,5 @@
import { z } from "zod";
import { cpRelationTypeOptions } from "@/features/resources/constants.js";
import { cpRelationTypeOptions, resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js";
import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js";
const resourceTypes = [
@ -177,6 +177,58 @@ export const resourceGroupCreateFormSchema = z
});
});
const identityAutoGrantItemSchema = z.object({
identityType: z.enum(resourceIdentityAutoGrantTypes, "请选择身份"),
resourceGroupId: z.union([z.string(), z.number()]).optional(),
});
export const resourceIdentityAutoGrantFormSchema = z
.object({
items: z.array(identityAutoGrantItemSchema),
})
.superRefine((value, context) => {
const requiredIdentityTypes = new Set(resourceIdentityAutoGrantTypes);
const seenIdentityTypes = new Set();
if (value.items.length !== resourceIdentityAutoGrantTypes.length) {
context.addIssue({
code: "custom",
message: "必须配置所有身份",
path: ["items"],
});
}
value.items.forEach((item, index) => {
if (seenIdentityTypes.has(item.identityType)) {
context.addIssue({
code: "custom",
message: "身份不能重复配置",
path: ["items", index, "identityType"],
});
}
seenIdentityTypes.add(item.identityType);
requiredIdentityTypes.delete(item.identityType);
const resourceGroupId =
item.resourceGroupId === undefined || item.resourceGroupId === "" ? 0 : Number(item.resourceGroupId);
if (!Number.isInteger(resourceGroupId) || resourceGroupId < 0) {
context.addIssue({
code: "custom",
message: "资源组必须是有效资源组",
path: ["items", index, "resourceGroupId"],
});
}
});
requiredIdentityTypes.forEach((identityType) => {
context.addIssue({
code: "custom",
message: "必须配置所有身份",
path: ["items", identityType],
});
});
});
export const giftFormSchema = z
.object({
chargeAssetType: z.enum(["COIN"]),

View File

@ -6,6 +6,7 @@ import {
resourceFormSchema,
resourceGrantFormSchema,
resourceGroupCreateFormSchema,
resourceIdentityAutoGrantFormSchema,
resourceShopItemsFormSchema,
} from "./schema.js";
@ -162,6 +163,62 @@ describe("resource form schema", () => {
).toThrow(FormValidationError);
});
test("validates identity auto grant resource group config", () => {
const payload = parseForm(resourceIdentityAutoGrantFormSchema, {
items: [
{ identityType: "host", resourceGroupId: "11" },
{ identityType: "bd", resourceGroupId: "12" },
{ identityType: "bd_leader", resourceGroupId: "13" },
{ identityType: "agency", resourceGroupId: "14" },
{ identityType: "manager", resourceGroupId: "" },
],
});
expect(payload.items).toHaveLength(5);
expect(payload.items[4].resourceGroupId).toBe("");
});
test("rejects incomplete identity auto grant config", () => {
expect(() =>
parseForm(resourceIdentityAutoGrantFormSchema, {
items: [
{ identityType: "host", resourceGroupId: "11" },
{ identityType: "bd", resourceGroupId: "12" },
{ identityType: "bd_leader", resourceGroupId: "13" },
{ identityType: "agency", resourceGroupId: "14" },
],
}),
).toThrow(FormValidationError);
});
test("rejects duplicate identity auto grant config", () => {
expect(() =>
parseForm(resourceIdentityAutoGrantFormSchema, {
items: [
{ identityType: "host", resourceGroupId: "11" },
{ identityType: "bd", resourceGroupId: "12" },
{ identityType: "bd_leader", resourceGroupId: "13" },
{ identityType: "agency", resourceGroupId: "14" },
{ identityType: "agency", resourceGroupId: "15" },
],
}),
).toThrow(FormValidationError);
});
test("rejects invalid identity auto grant resource group id", () => {
expect(() =>
parseForm(resourceIdentityAutoGrantFormSchema, {
items: [
{ identityType: "host", resourceGroupId: "-1" },
{ identityType: "bd", resourceGroupId: "12" },
{ identityType: "bd_leader", resourceGroupId: "13" },
{ identityType: "agency", resourceGroupId: "14" },
{ identityType: "manager", resourceGroupId: "0" },
],
}),
).toThrow(FormValidationError);
});
test("validates editable gift form fields", () => {
const payload = parseForm(giftFormSchema, {
chargeAssetType: "COIN",

View File

@ -117,6 +117,7 @@ export const API_OPERATIONS = {
getRegistrationRewardConfig: "getRegistrationRewardConfig",
getResource: "getResource",
getResourceGroup: "getResourceGroup",
getResourceIdentityAutoGrantConfig: "getResourceIdentityAutoGrantConfig",
getRoleDataScopes: "getRoleDataScopes",
getRoomConfig: "getRoomConfig",
getRoomRocketConfig: "getRoomRocketConfig",
@ -267,6 +268,7 @@ export const API_OPERATIONS = {
updateResource: "updateResource",
updateResourceGroup: "updateResourceGroup",
updateResourceGroupItems: "updateResourceGroupItems",
updateResourceIdentityAutoGrantConfig: "updateResourceIdentityAutoGrantConfig",
updateRole: "updateRole",
updateRoom: "updateRoom",
updateRoomConfig: "updateRoomConfig",
@ -1021,6 +1023,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "resource-group:view",
permissions: ["resource-group:view"]
},
getResourceIdentityAutoGrantConfig: {
method: "GET",
operationId: API_OPERATIONS.getResourceIdentityAutoGrantConfig,
path: "/v1/admin/resource-groups/identity-auto-grant-config",
permission: "resource-group:view",
permissions: ["resource-group:view"]
},
getRoleDataScopes: {
method: "GET",
operationId: API_OPERATIONS.getRoleDataScopes,
@ -2057,6 +2066,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "resource-group:update",
permissions: ["resource-group:update"]
},
updateResourceIdentityAutoGrantConfig: {
method: "PUT",
operationId: API_OPERATIONS.updateResourceIdentityAutoGrantConfig,
path: "/v1/admin/resource-groups/identity-auto-grant-config",
permission: "resource-group:update",
permissions: ["resource-group:update"]
},
updateRole: {
method: "PATCH",
operationId: API_OPERATIONS.updateRole,

View File

@ -2084,6 +2084,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/resource-groups/identity-auto-grant-config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getResourceIdentityAutoGrantConfig"];
put: operations["updateResourceIdentityAutoGrantConfig"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/resource-groups": {
parameters: {
query?: never;
@ -6151,6 +6167,30 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
getResourceIdentityAutoGrantConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateResourceIdentityAutoGrantConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listResourceGroups: {
parameters: {
query?: never;

View File

@ -566,7 +566,9 @@ export interface ManagerDto {
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantBadge?: boolean;
canGrantVehicle?: boolean;
canTransferUserCountry?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
createdAtMs?: number;
@ -586,7 +588,9 @@ export interface CreateManagerPayload {
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantBadge?: boolean;
canGrantVehicle?: boolean;
canTransferUserCountry?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
targetUserId: string;
@ -598,7 +602,9 @@ export interface UpdateManagerPayload {
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantBadge?: boolean;
canGrantVehicle?: boolean;
canTransferUserCountry?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
}

View File

@ -99,6 +99,10 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
const clearRange = () => applyRange(emptyRange);
const submitDraft = (event) => {
event.preventDefault();
// Popover content is rendered through a portal, but React still bubbles
// submit events through the component tree. Stop here so a picker confirm
// never submits an outer edit drawer before the new range reaches state.
event.stopPropagation();
const nextRange = normalizeRange(draft);
if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) {
setError("结束时间必须晚于开始时间");

View File

@ -1,5 +1,7 @@
import { describe, expect, test } from "vitest";
import { datetimeTextToMs, normalizeRange, timeRangeLabel } from "@/shared/ui/TimeRangeFilter.jsx";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, test, vi } from "vitest";
import { TimeRangeFilter, datetimeTextToMs, normalizeRange, timeRangeLabel } from "@/shared/ui/TimeRangeFilter.jsx";
describe("TimeRangeFilter helpers", () => {
test("parses strict local date time text", () => {
@ -24,4 +26,23 @@ describe("TimeRangeFilter helpers", () => {
expect(timeRangeLabel("时间", {})).toBe("时间");
});
test("confirms picker changes without submitting an outer form", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onSubmit = vi.fn((event) => event.preventDefault());
const startMs = datetimeTextToMs("2026-05-13 10:00");
const endMs = datetimeTextToMs("2026-05-13 12:00");
render(
<form onSubmit={onSubmit}>
<TimeRangeFilter label="活动时间" value={{ endMs, startMs }} onChange={onChange} />
</form>,
);
await user.click(screen.getByRole("button", { name: /活动时间/ }));
await user.click(screen.getByRole("button", { name: "确定" }));
expect(onChange).toHaveBeenCalledWith({ endMs, startMs });
expect(onSubmit).not.toHaveBeenCalled();
});
});