礼物资源

This commit is contained in:
zhx 2026-07-05 01:31:45 +08:00
parent 216031f776
commit 08872009e1
17 changed files with 908 additions and 93 deletions

View File

@ -817,6 +817,38 @@
"x-permissions": ["bd:create"]
}
},
"/admin/managers/{user_id}": {
"put": {
"operationId": "updateManager",
"parameters": [
{
"in": "path",
"name": "user_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateManagerPayload"
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/ManagerResponse"
}
},
"x-permission": "bd:update",
"x-permissions": ["bd:update"]
}
},
"/admin/agencies": {
"get": {
"operationId": "listAgencies",
@ -7189,6 +7221,36 @@
"bdLeaderCount": {
"type": "integer"
},
"canAddAdmin": {
"type": "boolean"
},
"canAddBdLeader": {
"type": "boolean"
},
"canAddSuperadmin": {
"type": "boolean"
},
"canBlockUser": {
"type": "boolean"
},
"canGrantAvatarFrame": {
"type": "boolean"
},
"canGrantBadge": {
"type": "boolean"
},
"canGrantVehicle": {
"type": "boolean"
},
"canGrantVip": {
"type": "boolean"
},
"canTransferUserCountry": {
"type": "boolean"
},
"canUpdateUserLevel": {
"type": "boolean"
},
"createdAtMs": {
"type": "integer"
},
@ -7226,6 +7288,36 @@
"type": "object",
"required": ["targetUserId"],
"properties": {
"canAddAdmin": {
"type": "boolean"
},
"canAddBdLeader": {
"type": "boolean"
},
"canAddSuperadmin": {
"type": "boolean"
},
"canBlockUser": {
"type": "boolean"
},
"canGrantAvatarFrame": {
"type": "boolean"
},
"canGrantBadge": {
"type": "boolean"
},
"canGrantVehicle": {
"type": "boolean"
},
"canGrantVip": {
"type": "boolean"
},
"canTransferUserCountry": {
"type": "boolean"
},
"canUpdateUserLevel": {
"type": "boolean"
},
"contact": {
"type": "string",
"maxLength": 128
@ -7235,6 +7327,49 @@
}
}
},
"UpdateManagerPayload": {
"type": "object",
"properties": {
"canAddAdmin": {
"type": "boolean"
},
"canAddBdLeader": {
"type": "boolean"
},
"canAddSuperadmin": {
"type": "boolean"
},
"canBlockUser": {
"type": "boolean"
},
"canGrantAvatarFrame": {
"type": "boolean"
},
"canGrantBadge": {
"type": "boolean"
},
"canGrantVehicle": {
"type": "boolean"
},
"canGrantVip": {
"type": "boolean"
},
"canTransferUserCountry": {
"type": "boolean"
},
"canUpdateUserLevel": {
"type": "boolean"
},
"contact": {
"type": "string",
"maxLength": 128
},
"status": {
"type": "string",
"enum": ["active", "disabled"]
}
}
},
"BDProfile": {
"type": "object",
"required": ["userId"],

View File

@ -62,10 +62,11 @@ test("host org list APIs use generated admin paths and filters", async () => {
region_id: 7,
status: "active",
});
await createManager({ canBlockUser: false, canGrantBadge: false, contact: "+63", targetUserId: "1003" });
await createManager({ canBlockUser: false, canGrantBadge: false, canGrantVip: true, contact: "+63", targetUserId: "1003" });
await updateManager("1003", {
canBlockUser: false,
canGrantVehicle: true,
canGrantVip: false,
canTransferUserCountry: false,
contact: "+63",
status: "disabled",
@ -147,6 +148,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
canBlockUser: false,
canGrantBadge: false,
canGrantVip: true,
contact: "+63",
targetUserId: "1003",
});
@ -155,6 +157,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
canBlockUser: false,
canGrantVehicle: true,
canGrantVip: false,
canTransferUserCountry: false,
contact: "+63",
status: "disabled",

View File

@ -191,9 +191,10 @@ export function createManager(payload: CreateManagerPayload): Promise<ManagerDto
}
export function updateManager(userId: EntityId, payload: UpdateManagerPayload): Promise<ManagerDto> {
return apiRequest<ManagerDto, UpdateManagerPayload>(`/v1/admin/managers/${encodeURIComponent(String(userId))}`, {
const endpoint = API_ENDPOINTS.updateManager;
return apiRequest<ManagerDto, UpdateManagerPayload>(apiEndpointPath(API_OPERATIONS.updateManager, { user_id: userId }), {
body: payload,
method: "PUT",
method: endpoint.method,
});
}

View File

@ -14,6 +14,7 @@ export const managerPermissionFields = [
"canGrantAvatarFrame",
"canGrantVehicle",
"canGrantBadge",
"canGrantVip",
"canUpdateUserLevel",
"canAddBdLeader",
"canAddAdmin",

View File

@ -19,6 +19,7 @@ const managerPermissionOptions = [
{ key: "canGrantAvatarFrame", label: "头像框" },
{ key: "canGrantVehicle", label: "Car" },
{ key: "canGrantBadge", label: "徽章" },
{ key: "canGrantVip", label: "VIP" },
{ key: "canUpdateUserLevel", label: "升级等级" },
{ key: "canAddBdLeader", label: "BD Leader" },
{ key: "canAddAdmin", label: "Admin" },

View File

@ -21,6 +21,8 @@ test("manager status column uses a switch and toggles through the page hook", ()
render(<HostManagersPage />);
expect(screen.getByText("VIP")).toBeInTheDocument();
const statusSwitch = screen.getByRole("switch", { name: "停用经理" });
expect(statusSwitch).toBeChecked();
@ -55,6 +57,7 @@ function pageFixture(patch = {}) {
canGrantAvatarFrame: true,
canGrantBadge: true,
canGrantVehicle: true,
canGrantVip: true,
canTransferUserCountry: true,
canUpdateUserLevel: true,
contact: "",
@ -88,6 +91,7 @@ function managerFixture() {
canGrantAvatarFrame: true,
canGrantBadge: true,
canGrantVehicle: true,
canGrantVip: true,
canTransferUserCountry: true,
canUpdateUserLevel: true,
contact: "+63 910 323 3670",

View File

@ -1,5 +1,5 @@
import { expect, test } from "vitest";
import { agencyCloseSchema, agencyJoinEnabledSchema, coinSellerStatusSchema } from "./schema";
import { agencyCloseSchema, agencyJoinEnabledSchema, coinSellerStatusSchema, createManagerSchema } from "./schema";
test("coin seller contact accepts plus sign with large internal user id", () => {
const result = coinSellerStatusSchema.safeParse({
@ -40,3 +40,17 @@ test("agency action schemas preserve large agency ids as strings", () => {
expect(joinResult.data.agencyId).toBe(agencyId);
}
});
test("manager schema defaults vip grant permission on and preserves explicit false", () => {
const defaultResult = createManagerSchema.safeParse({ targetUserId: "1003" });
const disabledResult = createManagerSchema.safeParse({ canGrantVip: false, targetUserId: "1003" });
expect(defaultResult.success).toBe(true);
expect(disabledResult.success).toBe(true);
if (defaultResult.success) {
expect(defaultResult.data.canGrantVip).toBe(true);
}
if (disabledResult.success) {
expect(disabledResult.data.canGrantVip).toBe(false);
}
});

View File

@ -116,6 +116,7 @@ export const createManagerSchema = z.object({
canGrantAvatarFrame: z.boolean().optional().default(true),
canGrantBadge: z.boolean().optional().default(true),
canGrantVehicle: z.boolean().optional().default(true),
canGrantVip: 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(""),

View File

@ -21,6 +21,7 @@ const drawerZIndex = 1600;
export function GiftResourceSelectField({
disabled = false,
drawerTitle = "选择资源",
excludedResourceIds = [],
giftTypes = [],
gifts = [],
label = "资源",
@ -36,6 +37,8 @@ export function GiftResourceSelectField({
const [activeType, setActiveType] = useState("");
const selectedIds = useMemo(() => selectedResourceIds.map((id) => String(id)), [selectedResourceIds]);
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const excludedIds = useMemo(() => excludedResourceIds.map((id) => String(id)), [excludedResourceIds]);
const excludedIdSet = useMemo(() => new Set(excludedIds), [excludedIds]);
const giftsByResourceId = useMemo(() => {
const items = new Map();
gifts.forEach((gift) => {
@ -49,10 +52,19 @@ export function GiftResourceSelectField({
() => buildResourceItems(resources, giftsByResourceId),
[giftsByResourceId, resources],
);
const itemCounts = useMemo(() => countResourceItems(resourceItems), [resourceItems]);
const visibleResourceItems = useMemo(
() =>
resourceItems.filter((item) => {
const resourceId = String(item.resource.resourceId);
return !excludedIdSet.has(resourceId) || selectedIdSet.has(resourceId);
}),
[excludedIdSet, resourceItems, selectedIdSet],
);
const visibleResources = useMemo(() => visibleResourceItems.map((item) => item.resource), [visibleResourceItems]);
const itemCounts = useMemo(() => countResourceItems(visibleResourceItems), [visibleResourceItems]);
const categoryOptions = useMemo(
() => buildCategoryOptions(giftTypes, resources, resourceItems),
[giftTypes, resourceItems, resources],
() => buildCategoryOptions(giftTypes, visibleResources, visibleResourceItems),
[giftTypes, visibleResourceItems, visibleResources],
);
const activeCategory = categoryOptions.some((item) => item.value === activeType)
? activeType
@ -76,7 +88,7 @@ export function GiftResourceSelectField({
}, [resourcesById, selectedIds]);
const filteredResources = useMemo(() => {
const keyword = query.trim().toLowerCase();
return resourceItems.filter((item) => {
return visibleResourceItems.filter((item) => {
if (activeCategory && item.categoryValue !== activeCategory) {
return false;
}
@ -99,7 +111,7 @@ export function GiftResourceSelectField({
.toLowerCase();
return haystack.includes(keyword);
});
}, [activeCategory, query, resourceItems]);
}, [activeCategory, query, visibleResourceItems]);
const openDrawer = () => {
if (!disabled && !loading) {

View File

@ -117,3 +117,71 @@ test("gift resource select drawer filters by gift type and supports multiple sel
expect(changes.at(-1)).toEqual(["11", "12"]);
expect(screen.getByDisplayValue("Rose GiftCP Ring")).toBeInTheDocument();
});
test("gift resource select drawer hides excluded gift resources from candidates", () => {
function Harness() {
const [value, setValue] = useState([]);
return (
<GiftResourceSelectField
excludedResourceIds={[11]}
giftTypes={giftTypes}
gifts={gifts}
resources={resources}
selectedResourceIds={value}
onChange={setValue}
/>
);
}
render(<Harness />);
fireEvent.click(screen.getByPlaceholderText("请选择资源"));
fireEvent.click(screen.getByRole("tab", { name: "普通礼物" }));
expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument();
expect(screen.getByText("当前无可选资源")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "CP礼物" }));
expect(screen.getByText("CP Ring")).toBeInTheDocument();
});
test("gift resource select drawer hides excluded resources but keeps selected resources removable", () => {
const changes = [];
function Harness() {
const [value, setValue] = useState(["11"]);
return (
<GiftResourceSelectField
excludedResourceIds={[11, 21]}
giftTypes={giftTypes}
gifts={gifts}
resources={resources}
selectedResourceIds={value}
onChange={(nextValue) => {
changes.push(nextValue);
setValue(nextValue);
}}
/>
);
}
render(<Harness />);
fireEvent.click(screen.getByPlaceholderText("请选择资源"));
expect(screen.getByText("Rose Gift")).toBeInTheDocument();
expect(screen.queryByText("VIP Vehicle")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "CP礼物" }));
fireEvent.click(screen.getByText("CP Ring"));
expect(changes.at(-1)).toEqual(["11", "12"]);
expect(screen.getByDisplayValue("Rose GiftCP Ring")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "普通礼物" }));
fireEvent.click(screen.getByText("Rose Gift"));
expect(changes.at(-1)).toEqual(["12"]);
expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument();
expect(screen.getByDisplayValue("CP Ring")).toBeInTheDocument();
});

View File

@ -163,6 +163,7 @@ const emptyGiftForm = (gift = {}) => {
priceVersion: gift.priceVersion || "default",
regionIds: (gift.regionIds?.length ? gift.regionIds : [0]).map(String),
resourceId: gift.resourceId ? String(gift.resourceId) : "",
items: [],
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
};
};
@ -180,6 +181,52 @@ export function applyGiftResourceSelection(form, resource, fillIdentity = true)
return applyGiftResourcePrice(nextForm, resource);
}
export function buildGiftResourceDrafts(form, resourceIds = [], resources = []) {
const resourcesById = new Map(resources.map((resource) => [String(resource.resourceId), resource]));
const existingDraftsByResourceId = new Map(
(form.items || [])
.filter((item) => item?.resourceId)
.map((item) => [String(item.resourceId), item]),
);
return resourceIds
.map((resourceId) => String(resourceId || "").trim())
.filter(Boolean)
.map((resourceId) => {
const existingDraft = existingDraftsByResourceId.get(resourceId);
if (existingDraft) {
return existingDraft;
}
const resource = resourcesById.get(resourceId) || { resourceId };
return {
...applyGiftResourceSelection(giftDraftDefaultsFromForm(form), resource, true),
resource,
};
});
}
function giftDraftDefaultsFromForm(form) {
const giftTypeCode = form.giftTypeCode || "normal";
const cpRelationType = giftTypeCode === cpGiftTypeCode ? form.cpRelationType || defaultCPRelationType : "";
return {
chargeAssetType: form.chargeAssetType || "COIN",
coinPrice: "",
cpRelationType,
effectTypes: form.effectTypes || [],
effectiveFrom: form.effectiveFrom || "",
effectiveTo: form.effectiveTo || "",
enabled: form.enabled !== false,
giftId: "",
giftTypeCode,
name: "",
presentationJson: normalizeGiftPresentationJson(form.presentationJson || "{}", giftTypeCode, cpRelationType),
priceVersion: form.priceVersion || "default",
regionIds: form.regionIds?.length ? form.regionIds : ["0"],
resourceId: "",
sortOrder: form.sortOrder || "0",
};
}
export function applyGiftPriceDefaults(form, coinPrice) {
const priceValue = String(coinPrice ?? "");
return {
@ -234,12 +281,12 @@ export function normalizeGiftPresentationJson(presentationJson, giftTypeCode, cp
return isCPGift ? cpPresentationJson(nextPresentation, cpRelationType) : JSON.stringify(nextPresentation);
}
export async function fetchAllOptionPages(fetcher, query = {}, pageSize = optionPageSize) {
export async function fetchAllOptionPages(fetcher, query = {}, pageSize = optionPageSize, maxPages = optionMaxPages) {
const items = [];
let total = Number.POSITIVE_INFINITY;
// 后台会把选项接口的 page_size 截断到 100抽屉候选项必须按真实分页拉全避免只展示第一页资源。
for (let page = 1; page <= optionMaxPages; page += 1) {
for (let page = 1; page <= maxPages; page += 1) {
const data = await fetcher({ ...query, page, page_size: pageSize });
const pageItems = Array.isArray(data?.items) ? data.items : [];
const responsePageSize = Number(data?.pageSize || data?.page_size || pageSize) || pageSize;
@ -1049,6 +1096,8 @@ export function useGiftListPage() {
const [giftTypeDialogOpen, setGiftTypeDialogOpen] = useState(false);
const [giftTypesLoading, setGiftTypesLoading] = useState(false);
const [resourceOptions, setResourceOptions] = useState([]);
const [giftResourceGiftOptions, setGiftResourceGiftOptions] = useState([]);
const [existingGiftResourceIds, setExistingGiftResourceIds] = useState([]);
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
const { loadingRegions, regionOptions } = useRegionOptions();
const filters = useMemo(
@ -1113,10 +1162,25 @@ export function useGiftListPage() {
}
let ignore = false;
setResourceOptionsLoading(true);
fetchAllOptionPages(listResources, { resource_type: "gift" })
.then((data) => {
const optionRequests =
activeAction === "create"
? [
fetchAllOptionPages(listResources, { resource_type: "gift" }),
fetchAllOptionPages(listGifts, {}, optionPageSize, 1000),
]
: [fetchAllOptionPages(listResources, { resource_type: "gift" }), Promise.resolve({ items: [] })];
Promise.all(optionRequests)
.then(([resourceData, giftOptionsData]) => {
if (!ignore) {
setResourceOptions(toGiftResourceOptions(data.items || [], selectedGift));
const giftOptions = giftOptionsData.items || [];
setGiftResourceGiftOptions(giftOptions);
setExistingGiftResourceIds(
giftOptions
.map((gift) => gift?.resourceId)
.filter((resourceId) => resourceId || resourceId === 0)
.map(String),
);
setResourceOptions(toGiftResourceOptions(resourceData.items || [], selectedGift));
}
})
.catch((err) => {
@ -1165,6 +1229,66 @@ export function useGiftListPage() {
});
};
const selectGiftResources = (resourceIds) => {
setForm((currentForm) => {
const items = buildGiftResourceDrafts(currentForm, resourceIds, resourceOptions);
return {
...currentForm,
items,
resourceId: items[0]?.resourceId || "",
};
});
};
const updateGiftDraft = (resourceId, patch) => {
setForm((currentForm) => ({
...currentForm,
items: (currentForm.items || []).map((item) => {
if (String(item.resourceId) !== String(resourceId)) {
return item;
}
const nextItem = { ...item, ...patch };
if (Object.prototype.hasOwnProperty.call(patch, "giftTypeCode")) {
const cpRelationType =
nextItem.giftTypeCode === cpGiftTypeCode
? nextItem.cpRelationType || defaultCPRelationType
: "";
return {
...nextItem,
cpRelationType,
presentationJson: normalizeGiftPresentationJson(
nextItem.presentationJson,
nextItem.giftTypeCode,
cpRelationType,
),
};
}
if (Object.prototype.hasOwnProperty.call(patch, "cpRelationType")) {
return {
...nextItem,
presentationJson: normalizeGiftPresentationJson(
nextItem.presentationJson,
nextItem.giftTypeCode,
nextItem.cpRelationType,
),
};
}
return nextItem;
}),
}));
};
const removeGiftDraft = (resourceId) => {
setForm((currentForm) => {
const items = (currentForm.items || []).filter((item) => String(item.resourceId) !== String(resourceId));
return {
...currentForm,
items,
resourceId: items[0]?.resourceId || "",
};
});
};
const changeGiftPrice = (coinPrice) => {
setForm((currentForm) => applyGiftPriceDefaults(currentForm, coinPrice));
};
@ -1249,21 +1373,55 @@ export function useGiftListPage() {
return;
}
setLoadingAction(editing ? "gift-edit" : "gift-create");
const createdDrafts = [];
let shouldReload = false;
try {
const payload = buildGiftPayload(parseForm(giftFormSchema, form));
const createDrafts = !editing && Array.isArray(form.items) && form.items.length ? form.items : [form];
if (editing) {
const payload = buildGiftPayload(parseForm(giftFormSchema, form));
await updateGift(selectedGift.giftId, payload);
} else {
await createGift(payload);
const payloads = createDrafts.map((draft) => ({
draft,
payload: buildGiftPayload(parseForm(giftFormSchema, draft)),
}));
for (const { draft, payload } of payloads) {
await createGift(payload);
createdDrafts.push(draft);
}
}
showToast(editing ? "礼物已更新" : "礼物已创建", "success");
showToast(editing ? "礼物已更新" : `已创建 ${createDrafts.length} 个礼物`, "success");
closeAction();
await result.reload();
shouldReload = true;
} catch (err) {
showToast(err.message || "操作失败", "error");
if (!editing && createdDrafts.length) {
const createdResourceIds = new Set(createdDrafts.map((draft) => String(draft.resourceId)));
setExistingGiftResourceIds((current) => Array.from(new Set([...current, ...createdResourceIds])));
setForm((currentForm) => {
const items = (currentForm.items || []).filter(
(item) => !createdResourceIds.has(String(item.resourceId)),
);
return {
...currentForm,
items,
resourceId: items[0]?.resourceId || "",
};
});
shouldReload = true;
showToast(`已创建 ${createdDrafts.length} 个礼物,剩余礼物创建失败:${err.message || "操作失败"}`, "error");
} else {
showToast(err.message || "操作失败", "error");
}
} finally {
setLoadingAction("");
}
if (shouldReload) {
try {
await result.reload();
} catch (err) {
showToast(err.message || "刷新礼物列表失败", "error");
}
}
};
const toggleGift = async (gift, nextEnabled = gift.status !== "active") => {
@ -1389,6 +1547,8 @@ export function useGiftListPage() {
giftTypeForm,
giftTypeOptions: giftTypes,
giftTypesLoading,
giftResourceGiftOptions,
existingGiftResourceIds,
loadingAction,
loadingRegions,
openCreateGift,
@ -1401,12 +1561,15 @@ export function useGiftListPage() {
selectedGift,
resetFilters,
selectGiftResource,
selectGiftResources,
removeGiftDraft,
setForm,
saveGiftSortOrder,
submitGiftTypes,
status,
submitGift,
toggleGift,
updateGiftDraft,
updateGiftTypeFormItem,
};
}

View File

@ -2,6 +2,7 @@ import { expect, test, vi } from "vitest";
import {
applyGiftPriceDefaults,
applyGiftResourceSelection,
buildGiftResourceDrafts,
cpRelationTypeFromGift,
cpRelationTypeFromPresentationJson,
cpRelationTypeShortLabel,
@ -47,6 +48,76 @@ test("fills gift price without gift points", () => {
});
});
test("builds gift drafts from multiple selected resources and preserves edits", () => {
const form = {
chargeAssetType: "COIN",
cpRelationType: "",
effectTypes: ["animation"],
effectiveFrom: "",
effectiveTo: "",
enabled: true,
giftTypeCode: "normal",
items: [
{
chargeAssetType: "COIN",
coinPrice: "88",
effectTypes: [],
effectiveFrom: "",
effectiveTo: "",
enabled: false,
giftId: "custom_rose",
giftTypeCode: "normal",
name: "Custom Rose",
presentationJson: "{}",
priceVersion: "default",
regionIds: ["0"],
resourceId: "11",
sortOrder: "7",
},
],
presentationJson: "{}",
priceVersion: "default",
regionIds: ["0", "1001"],
sortOrder: "3",
};
const drafts = buildGiftResourceDrafts(form, ["11", "12"], [
{
coinPrice: 10,
name: "Rose",
priceType: "coin",
resourceCode: "gift_rose",
resourceId: 11,
},
{
coinPrice: 20,
name: "Ring",
priceType: "coin",
resourceCode: "gift_ring",
resourceId: 12,
},
]);
expect(drafts).toHaveLength(2);
expect(drafts[0]).toMatchObject({
coinPrice: "88",
giftId: "custom_rose",
name: "Custom Rose",
resourceId: "11",
sortOrder: "7",
});
expect(drafts[1]).toMatchObject({
coinPrice: "20",
effectTypes: ["animation"],
giftId: "12",
giftTypeCode: "normal",
name: "Ring",
regionIds: ["0", "1001"],
resourceId: "12",
sortOrder: "3",
});
});
test("fetches all option pages instead of only the first resource page", async () => {
const fetcher = vi
.fn()

View File

@ -43,6 +43,7 @@ import {
resourceStatusFilters,
} from "@/features/resources/constants.js";
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx";
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
import {
cpRelationTypeFromGift,
@ -137,7 +138,7 @@ const baseColumns = (giftTypeOptions) => [
export function GiftListPage() {
const page = useGiftListPage();
const items = page.data.items || [];
const items = useMemo(() => page.data.items || [], [page.data.items]);
const total = page.data.total || 0;
const [selectedGiftIds, setSelectedGiftIds] = useState([]);
const isEditing = page.activeAction === "edit";
@ -419,7 +420,14 @@ function GiftSortOrderCell({ gift, page }) {
function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) {
const submitDisabled =
disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading;
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
const isCreateMode = mode === "create";
const giftDrafts = Array.isArray(form.items) ? form.items : [];
const selectedResourceIds = isCreateMode
? giftDrafts.map((item) => item.resourceId).filter(Boolean)
: form.resourceId
? [form.resourceId]
: [];
const showGiftIdentityFields = mode === "edit" && Boolean(form.resourceId);
const isCPGiftType = form.giftTypeCode === "cp";
return (
<AdminFormDialog
@ -433,15 +441,30 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
>
<AdminFormSection title="资源信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<ResourceSelectField
disabled={disabled || page.resourceOptionsLoading}
drawerTitle="选择礼物资源"
label="礼物资源"
loading={page.resourceOptionsLoading}
resources={page.resourceOptions}
value={form.resourceId}
onChange={(resourceId) => page.selectGiftResource(resourceId)}
/>
{isCreateMode ? (
<GiftResourceSelectField
disabled={disabled || page.resourceOptionsLoading}
drawerTitle="选择礼物资源"
excludedResourceIds={page.existingGiftResourceIds}
giftTypes={page.giftTypeOptions}
gifts={page.giftResourceGiftOptions}
label="礼物资源"
loading={page.resourceOptionsLoading}
resources={page.resourceOptions}
selectedResourceIds={selectedResourceIds}
onChange={page.selectGiftResources}
/>
) : (
<ResourceSelectField
disabled={disabled || page.resourceOptionsLoading}
drawerTitle="选择礼物资源"
label="礼物资源"
loading={page.resourceOptionsLoading}
resources={page.resourceOptions}
value={form.resourceId}
onChange={(resourceId) => page.selectGiftResource(resourceId)}
/>
)}
{showGiftIdentityFields ? (
<>
<TextField
@ -460,21 +483,23 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
/>
</>
) : null}
<TextField
disabled={disabled || page.giftTypesLoading}
label="礼物类型"
required
select
value={form.giftTypeCode}
onChange={(event) => page.changeGiftFormTypeCode(event.target.value)}
>
{page.giftTypeOptions.map((item) => (
<MenuItem key={item.tabKey} value={item.tabKey}>
{item.displayName || item.tabKey}
</MenuItem>
))}
</TextField>
{isCPGiftType ? (
{!isCreateMode ? (
<TextField
disabled={disabled || page.giftTypesLoading}
label="礼物类型"
required
select
value={form.giftTypeCode}
onChange={(event) => page.changeGiftFormTypeCode(event.target.value)}
>
{page.giftTypeOptions.map((item) => (
<MenuItem key={item.tabKey} value={item.tabKey}>
{item.displayName || item.tabKey}
</MenuItem>
))}
</TextField>
) : null}
{!isCreateMode && isCPGiftType ? (
<TextField
disabled={disabled}
label="关系类型"
@ -492,56 +517,216 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格与排序">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField disabled label="价格" required type="number" value={form.coinPrice} />
{isCreateMode ? (
<GiftDraftSection
disabled={disabled}
drafts={giftDrafts}
page={page}
regionOptions={regionOptions}
/>
) : (
<>
<AdminFormSection title="价格与排序">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField disabled label="价格" required type="number" value={form.coinPrice} />
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
/>
<AdminFormSwitchField
checked={form.enabled}
checkedLabel="启用"
className={styles.giftSwitch}
disabled={disabled}
label="礼物状态"
uncheckedLabel="禁用"
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="投放与时间">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<GiftRegionSelect
disabled={disabled || page.loadingRegions}
labels={buildRegionLabelMap(regionOptions)}
options={regionOptions}
value={form.regionIds}
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
/>
<DateTimeField
disabled={disabled}
label="有效开始时间"
value={form.effectiveFrom}
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
/>
<DateTimeField
disabled={disabled}
label="有效结束时间"
value={form.effectiveTo}
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
/>
<GiftEffectSelect
disabled={disabled}
value={form.effectTypes}
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</>
)}
</AdminFormDialog>
);
}
function GiftDraftSection({ disabled, drafts, page, regionOptions }) {
const regionLabels = buildRegionLabelMap(regionOptions);
if (!drafts.length) {
return (
<AdminFormSection title="礼物配置">
<div className={styles.giftDraftEmpty}>当前无数据</div>
</AdminFormSection>
);
}
return (
<AdminFormSection title={`礼物配置(${drafts.length}`}>
<div className={styles.giftDraftList}>
{drafts.map((draft, index) => (
<GiftDraftEditor
disabled={disabled}
draft={draft}
index={index}
key={draft.resourceId || index}
page={page}
regionLabels={regionLabels}
regionOptions={regionOptions}
/>
))}
</div>
</AdminFormSection>
);
}
function GiftDraftEditor({ disabled, draft, index, page, regionLabels, regionOptions }) {
const isCPGiftType = draft.giftTypeCode === "cp";
const updateDraft = (patch) => page.updateGiftDraft(draft.resourceId, patch);
return (
<div className={styles.giftDraftItem}>
<div className={styles.giftDraftHeader}>
<GiftDraftThumb draft={draft} />
<div className={styles.giftDraftTitle}>
<span>{draft.name || draft.giftId || `礼物 ${index + 1}`}</span>
<span>资源 {draft.resourceId || "-"}</span>
</div>
<AdminActionIconButton
disabled={disabled}
label="移除礼物"
onClick={() => page.removeGiftDraft(draft.resourceId)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</div>
<AdminFormFieldGrid columns="repeat(4, minmax(0, 1fr))">
<TextField
disabled={disabled}
label="礼物名称"
required
value={draft.name}
onChange={(event) => updateDraft({ name: event.target.value })}
/>
<TextField
disabled={disabled}
label="礼物 ID"
required
value={draft.giftId}
onChange={(event) => updateDraft({ giftId: event.target.value })}
/>
<TextField disabled label="价格" required type="number" value={draft.coinPrice} />
<TextField
disabled={disabled}
label="排序"
type="number"
value={draft.sortOrder}
onChange={(event) => updateDraft({ sortOrder: event.target.value })}
/>
<TextField
disabled={disabled || page.giftTypesLoading}
label="礼物类型"
required
select
value={draft.giftTypeCode}
onChange={(event) => updateDraft({ giftTypeCode: event.target.value })}
>
{page.giftTypeOptions.map((item) => (
<MenuItem key={item.tabKey} value={item.tabKey}>
{item.displayName || item.tabKey}
</MenuItem>
))}
</TextField>
{isCPGiftType ? (
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
/>
<AdminFormSwitchField
checked={form.enabled}
checkedLabel="启用"
className={styles.giftSwitch}
disabled={disabled}
label="礼物状态"
uncheckedLabel="禁用"
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="投放与时间">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<GiftRegionSelect
disabled={disabled || page.loadingRegions}
labels={buildRegionLabelMap(regionOptions)}
options={regionOptions}
value={form.regionIds}
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
/>
<DateTimeField
disabled={disabled}
label="有效开始时间"
value={form.effectiveFrom}
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
/>
<DateTimeField
disabled={disabled}
label="有效结束时间"
value={form.effectiveTo}
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
/>
<GiftEffectSelect
disabled={disabled}
value={form.effectTypes}
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
label="关系类型"
required
select
value={draft.cpRelationType || "cp"}
onChange={(event) => updateDraft({ cpRelationType: event.target.value })}
>
{cpRelationTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : null}
<GiftRegionSelect
disabled={disabled || page.loadingRegions}
labels={regionLabels}
options={regionOptions}
value={draft.regionIds}
onChange={(regionIds) => updateDraft({ regionIds })}
/>
<AdminFormSwitchField
checked={draft.enabled}
checkedLabel="启用"
className={styles.giftSwitch}
disabled={disabled}
label="礼物状态"
uncheckedLabel="禁用"
onChange={(checked) => updateDraft({ enabled: checked })}
/>
<DateTimeField
disabled={disabled}
label="有效开始时间"
value={draft.effectiveFrom}
onChange={(effectiveFrom) => updateDraft({ effectiveFrom })}
/>
<DateTimeField
disabled={disabled}
label="有效结束时间"
value={draft.effectiveTo}
onChange={(effectiveTo) => updateDraft({ effectiveTo })}
/>
<GiftEffectSelect
disabled={disabled}
value={draft.effectTypes}
onChange={(effectTypes) => updateDraft({ effectTypes })}
/>
</AdminFormFieldGrid>
</div>
);
}
function GiftDraftThumb({ draft }) {
const resource = draft.resource || {};
const imageUrl = imageURL(resource.previewUrl || resource.assetUrl || resource.animationUrl);
return (
<span className={styles.giftDraftThumb}>
{imageUrl ? <img alt="" src={imageUrl} /> : <CardGiftcardOutlined fontSize="small" />}
</span>
);
}

View File

@ -308,6 +308,82 @@
color: var(--text-tertiary);
}
.giftDraftList {
display: grid;
gap: var(--space-3);
}
.giftDraftItem {
display: grid;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
}
.giftDraftHeader {
display: grid;
min-width: 0;
align-items: center;
gap: var(--space-2);
grid-template-columns: 44px minmax(0, 1fr) var(--control-height);
}
.giftDraftThumb {
display: inline-flex;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.giftDraftThumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.giftDraftTitle {
display: grid;
min-width: 0;
gap: 3px;
}
.giftDraftTitle span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.giftDraftTitle span:first-child {
color: var(--text-primary);
font-weight: 750;
}
.giftDraftTitle span:last-child {
color: var(--text-tertiary);
font-size: 12px;
}
.giftDraftEmpty {
display: flex;
min-height: 120px;
align-items: center;
justify-content: center;
border: 1px solid var(--border-muted);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-tertiary);
font-weight: 650;
}
.batchInput {
display: none;
}

View File

@ -299,6 +299,7 @@ export const API_OPERATIONS = {
updateHostAgencyPolicy: "updateHostAgencyPolicy",
updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig",
updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig",
updateManager: "updateManager",
updateMenu: "updateMenu",
updateMenuVisible: "updateMenuVisible",
updatePermission: "updatePermission",
@ -2329,6 +2330,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "invite-activity-reward:update",
permissions: ["invite-activity-reward:update"]
},
updateManager: {
method: "PUT",
operationId: API_OPERATIONS.updateManager,
path: "/v1/admin/managers/{user_id}",
permission: "bd:update",
permissions: ["bd:update"]
},
updateMenu: {
method: "PATCH",
operationId: API_OPERATIONS.updateMenu,

View File

@ -676,6 +676,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/managers/{user_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateManager"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/agencies": {
parameters: {
query?: never;
@ -3997,6 +4013,16 @@ export interface components {
Manager: {
avatar?: string;
bdLeaderCount?: number;
canAddAdmin?: boolean;
canAddBdLeader?: boolean;
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantBadge?: boolean;
canGrantVehicle?: boolean;
canGrantVip?: boolean;
canTransferUserCountry?: boolean;
canUpdateUserLevel?: boolean;
createdAtMs?: number;
contact?: string;
displayUserId?: string;
@ -4010,9 +4036,34 @@ export interface components {
username?: string;
};
CreateManagerPayload: {
canAddAdmin?: boolean;
canAddBdLeader?: boolean;
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantBadge?: boolean;
canGrantVehicle?: boolean;
canGrantVip?: boolean;
canTransferUserCountry?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
targetUserId: string;
};
UpdateManagerPayload: {
canAddAdmin?: boolean;
canAddBdLeader?: boolean;
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantBadge?: boolean;
canGrantVehicle?: boolean;
canGrantVip?: boolean;
canTransferUserCountry?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
/** @enum {string} */
status?: "active" | "disabled";
};
BDProfile: {
agencyCount?: number;
createdAtMs?: number;
@ -5416,6 +5467,24 @@ export interface operations {
201: components["responses"]["ManagerResponse"];
};
};
updateManager: {
parameters: {
query?: never;
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateManagerPayload"];
};
};
responses: {
200: components["responses"]["ManagerResponse"];
};
};
listAgencies: {
parameters: {
query?: {

View File

@ -681,6 +681,7 @@ export interface ManagerDto {
canGrantAvatarFrame?: boolean;
canGrantBadge?: boolean;
canGrantVehicle?: boolean;
canGrantVip?: boolean;
canTransferUserCountry?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
@ -703,6 +704,7 @@ export interface CreateManagerPayload {
canGrantAvatarFrame?: boolean;
canGrantBadge?: boolean;
canGrantVehicle?: boolean;
canGrantVip?: boolean;
canTransferUserCountry?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
@ -717,6 +719,7 @@ export interface UpdateManagerPayload {
canGrantAvatarFrame?: boolean;
canGrantBadge?: boolean;
canGrantVehicle?: boolean;
canGrantVip?: boolean;
canTransferUserCountry?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;