身份自动赠送
This commit is contained in:
parent
4166fe0786
commit
dae91e892e
@ -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",
|
||||
|
||||
@ -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?");
|
||||
|
||||
@ -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]));
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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: "增加数量",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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] || "钱包资产";
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"]),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
40
src/shared/api/generated/schema.d.ts
vendored
40
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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("结束时间必须晚于开始时间");
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user