2422 lines
84 KiB
JavaScript
2422 lines
84 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { parseForm } from "@/shared/forms/validation";
|
||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
|
||
import {
|
||
batchCreateGifts,
|
||
batchDeleteResources as requestBatchDeleteResources,
|
||
createEmojiPack,
|
||
createGift,
|
||
createResource,
|
||
createResourceGroup,
|
||
deleteGift as requestDeleteGift,
|
||
deleteResource as requestDeleteResource,
|
||
disableResourceShopItem,
|
||
disableGift,
|
||
disableResource,
|
||
disableResourceGroup,
|
||
enableResourceShopItem,
|
||
enableGift,
|
||
enableResource,
|
||
enableResourceGroup,
|
||
getResourceIdentityAutoGrantConfig,
|
||
grantResource,
|
||
grantResourceGroup,
|
||
listEmojiPackCategories,
|
||
listEmojiPacks,
|
||
listGiftTypes,
|
||
listGifts,
|
||
listResourceGrants,
|
||
listResourceGroups,
|
||
listResourceShopItems,
|
||
listResourceShopPurchaseOrders,
|
||
listResources,
|
||
lookupResourceGrantTarget,
|
||
revokeResourceGrant,
|
||
updateGift,
|
||
updateGiftTypes,
|
||
updateResource,
|
||
updateResourceIdentityAutoGrantConfig,
|
||
updateResourceGroup,
|
||
upsertResourceShopItems,
|
||
} from "@/features/resources/api";
|
||
import {
|
||
cpRelationTypeLabels,
|
||
cpRelationTypeOptions,
|
||
defaultGiftTypeOptions,
|
||
resourceIdentityAutoGrantOptions,
|
||
resourceShopSellableTypes,
|
||
} from "@/features/resources/constants.js";
|
||
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
||
import { resourceMetadataJSON } from "@/features/resources/resourceMetadata.js";
|
||
import {
|
||
emojiPackFormSchema,
|
||
giftFormSchema,
|
||
giftTypeConfigFormSchema,
|
||
resourceFormSchema,
|
||
resourceGrantFormSchema,
|
||
resourceGroupCreateFormSchema,
|
||
resourceIdentityAutoGrantFormSchema,
|
||
resourceShopItemsFormSchema,
|
||
} from "@/features/resources/schema.js";
|
||
|
||
const pageSize = 50;
|
||
const optionPageSize = 100;
|
||
const optionMaxPages = 200;
|
||
const dayMillis = 24 * 60 * 60 * 1000;
|
||
const cpGiftTypeCode = "cp";
|
||
const defaultCPRelationType = cpRelationTypeOptions[0][0];
|
||
const cpRelationTypeValues = new Set(cpRelationTypeOptions.map(([value]) => value));
|
||
const cpPresentationRelationKeys = ["cp_relation_type", "cpRelationType"];
|
||
const cpPresentationLabelKeys = ["cp_relation_label", "cpRelationLabel"];
|
||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||
const emptyResourceForm = (resource = {}) => ({
|
||
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
||
avatarFrameKind:
|
||
resource.resourceType === "avatar_frame"
|
||
? resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson) || "normal"
|
||
: "normal",
|
||
badgeForm:
|
||
resource.resourceType === "badge"
|
||
? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile"
|
||
: "tile",
|
||
badgeKind:
|
||
resource.resourceType === "badge"
|
||
? resource.badgeKind || badgeKindFromMetadata(resource.metadataJson) || "normal"
|
||
: "normal",
|
||
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
|
||
enabled: resource.status ? resource.status === "active" : true,
|
||
levelTrack:
|
||
resource.resourceType === "badge" || resource.resourceType === "avatar_frame"
|
||
? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || ""
|
||
: "",
|
||
managerGrantEnabled:
|
||
resource.managerGrantEnabled === undefined || resource.managerGrantEnabled === null
|
||
? true
|
||
: Boolean(resource.managerGrantEnabled),
|
||
metadataJson: resource.metadataJson || "",
|
||
name: resource.name || "",
|
||
previewUrl: resource.previewUrl || "",
|
||
priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin",
|
||
resourceCode: resource.resourceCode || "",
|
||
resourceType: resource.resourceType || "avatar_frame",
|
||
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
|
||
});
|
||
const emptyEmojiPackForm = () => ({
|
||
animationUrl: "",
|
||
category: "默认",
|
||
coverUrl: "",
|
||
name: "",
|
||
pricingType: "free",
|
||
regionIds: ["0"],
|
||
sortOrder: "0",
|
||
});
|
||
const emptyResourceGroupForm = (group = {}) => ({
|
||
description: group.description || "",
|
||
enabled: group.status ? group.status === "active" : true,
|
||
groupCode: group.groupCode || "",
|
||
items: group.items?.length ? group.items.map(groupItemToForm) : [emptyGroupResourceItem()],
|
||
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",
|
||
resourceId: "",
|
||
sortOrder: "0",
|
||
walletAssetAmount: "",
|
||
walletAssetType: "",
|
||
});
|
||
const emptyGroupWalletAssetItem = (walletAssetType) => ({
|
||
durationDays: "",
|
||
itemType: "wallet_asset",
|
||
resourceId: "",
|
||
sortOrder: "0",
|
||
walletAssetAmount: "",
|
||
walletAssetType,
|
||
});
|
||
const emptyGiftForm = (gift = {}) => {
|
||
const giftTypeCode = gift.giftTypeCode || "normal";
|
||
const presentationJson = gift.presentationJson || "{}";
|
||
const cpRelationType = cpRelationTypeFromGift({ ...gift, giftTypeCode, presentationJson }) || defaultCPRelationType;
|
||
return {
|
||
chargeAssetType: gift.chargeAssetType || "COIN",
|
||
coinPrice: gift.coinPrice === 0 || gift.coinPrice ? String(gift.coinPrice) : "",
|
||
cpRelationType: giftTypeCode === cpGiftTypeCode ? cpRelationType : "",
|
||
effectTypes: gift.effectTypes || [],
|
||
effectiveFrom: msToDatetimeLocal(gift.effectiveFromMs),
|
||
effectiveTo: msToDatetimeLocal(gift.effectiveToMs),
|
||
enabled: gift.status ? gift.status === "active" : true,
|
||
giftId: gift.giftId || "",
|
||
giftTypeCode,
|
||
name: gift.name || "",
|
||
presentationJson: normalizeGiftPresentationJson(presentationJson, giftTypeCode, cpRelationType),
|
||
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",
|
||
};
|
||
};
|
||
|
||
export function applyGiftResourceSelection(form, resource, fillIdentity = true) {
|
||
const resourceId = resource?.resourceId ? String(resource.resourceId) : "";
|
||
const nextForm = {
|
||
...form,
|
||
resourceId,
|
||
};
|
||
if (fillIdentity) {
|
||
nextForm.giftId = resourceId;
|
||
nextForm.name = resource?.name || resource?.resourceCode || resourceId;
|
||
}
|
||
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 {
|
||
...form,
|
||
coinPrice: priceValue,
|
||
};
|
||
}
|
||
|
||
export function cpRelationTypeFromPresentationJson(presentationJson) {
|
||
const presentation = parseGiftPresentationObject(presentationJson);
|
||
const relationType = cpPresentationRelationKeys
|
||
.map((key) =>
|
||
String(presentation?.[key] || "")
|
||
.trim()
|
||
.toLowerCase(),
|
||
)
|
||
.find((value) => cpRelationTypeValues.has(value));
|
||
return relationType || "";
|
||
}
|
||
|
||
export function cpRelationTypeFromGift(gift = {}) {
|
||
if (gift?.giftTypeCode !== cpGiftTypeCode) {
|
||
return "";
|
||
}
|
||
return validCPRelationType(gift.cpRelationType) || cpRelationTypeFromPresentationJson(gift.presentationJson);
|
||
}
|
||
|
||
export function cpRelationTypeShortLabel(value) {
|
||
const label = cpRelationTypeLabels[validCPRelationType(value)] || String(value || "").trim();
|
||
return label.endsWith("礼物") ? label.slice(0, -2) : label;
|
||
}
|
||
|
||
export function normalizeGiftPresentationJson(presentationJson, giftTypeCode, cpRelationType) {
|
||
const rawValue = String(presentationJson || "").trim();
|
||
const isCPGift = giftTypeCode === cpGiftTypeCode;
|
||
let parsedValue;
|
||
|
||
// 礼物展示配置由后端透传给 room/wallet 链路;这里只对对象型 JSON 做 CP 字段收敛,避免误改旧的非对象配置。
|
||
try {
|
||
parsedValue = rawValue ? JSON.parse(rawValue) : {};
|
||
} catch {
|
||
return rawValue || "{}";
|
||
}
|
||
if (!parsedValue || typeof parsedValue !== "object" || Array.isArray(parsedValue)) {
|
||
return isCPGift ? cpPresentationJson({}, cpRelationType) : rawValue || "{}";
|
||
}
|
||
|
||
const nextPresentation = { ...parsedValue };
|
||
[...cpPresentationRelationKeys, ...cpPresentationLabelKeys].forEach((key) => {
|
||
delete nextPresentation[key];
|
||
});
|
||
return isCPGift ? cpPresentationJson(nextPresentation, cpRelationType) : JSON.stringify(nextPresentation);
|
||
}
|
||
|
||
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 <= 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;
|
||
const responseTotal = Number(data?.total);
|
||
|
||
items.push(...pageItems);
|
||
if (Number.isFinite(responseTotal) && responseTotal >= 0) {
|
||
total = responseTotal;
|
||
}
|
||
if (!pageItems.length || items.length >= total || pageItems.length < responsePageSize) {
|
||
return {
|
||
...data,
|
||
items,
|
||
page: 1,
|
||
pageSize: responsePageSize,
|
||
total: Number.isFinite(total) ? total : items.length,
|
||
};
|
||
}
|
||
}
|
||
|
||
throw new Error("资源选项分页过多,请收窄筛选条件");
|
||
}
|
||
|
||
function parseGiftPresentationObject(presentationJson) {
|
||
const rawValue = String(presentationJson || "").trim();
|
||
if (!rawValue) {
|
||
return {};
|
||
}
|
||
try {
|
||
const parsedValue = JSON.parse(rawValue);
|
||
return parsedValue && typeof parsedValue === "object" && !Array.isArray(parsedValue) ? parsedValue : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function normalizedCPRelationType(value) {
|
||
return validCPRelationType(value) || defaultCPRelationType;
|
||
}
|
||
|
||
function validCPRelationType(value) {
|
||
const relationType = String(value || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
return cpRelationTypeValues.has(relationType) ? relationType : "";
|
||
}
|
||
|
||
function cpPresentationJson(basePresentation, cpRelationType) {
|
||
const relationType = normalizedCPRelationType(cpRelationType);
|
||
return JSON.stringify({
|
||
...basePresentation,
|
||
cp_relation_label: cpRelationTypeLabels[relationType] || relationType,
|
||
cp_relation_type: relationType,
|
||
});
|
||
}
|
||
|
||
function applyGiftResourcePrice(form, resource) {
|
||
const priceType = resource?.priceType || (resource?.coinPrice > 0 ? "coin" : "");
|
||
if (priceType === "free") {
|
||
return applyGiftPriceDefaults({ ...form, chargeAssetType: "COIN" }, 0);
|
||
}
|
||
if (priceType === "coin") {
|
||
return applyGiftPriceDefaults({ ...form, chargeAssetType: "COIN" }, resource?.coinPrice || 0);
|
||
}
|
||
return form;
|
||
}
|
||
|
||
const emptyGrantForm = () => ({
|
||
durationDays: "7",
|
||
groupId: "",
|
||
quantity: "1",
|
||
reason: "",
|
||
resourceId: "",
|
||
resourceIds: [],
|
||
subjectType: "resource",
|
||
targetUserId: "",
|
||
});
|
||
|
||
function groupItemToForm(item) {
|
||
const sortOrder = item.sortOrder === 0 || item.sortOrder ? String(item.sortOrder) : "0";
|
||
if (item.itemType === "wallet_asset") {
|
||
return {
|
||
durationDays: "",
|
||
itemType: "wallet_asset",
|
||
resourceId: "",
|
||
sortOrder,
|
||
walletAssetAmount:
|
||
item.walletAssetAmount === 0 || item.walletAssetAmount ? String(item.walletAssetAmount) : "",
|
||
walletAssetType: item.walletAssetType || "",
|
||
};
|
||
}
|
||
|
||
return {
|
||
durationDays: item.durationMs ? formatDurationDays(item.durationMs) : "1",
|
||
itemType: "resource",
|
||
resourceId: item.resourceId ? String(item.resourceId) : "",
|
||
sortOrder,
|
||
walletAssetAmount: "",
|
||
walletAssetType: "",
|
||
};
|
||
}
|
||
|
||
export function useResourceListPage() {
|
||
const abilities = useResourceAbilities();
|
||
const confirm = useConfirm();
|
||
const { showToast } = useToast();
|
||
const [query, setQuery] = useState("");
|
||
const [status, setStatus] = useState("");
|
||
const [resourceType, setResourceType] = useState("");
|
||
const [page, setPage] = useState(1);
|
||
const [activeAction, setActiveAction] = useState("");
|
||
const [form, setForm] = useState(emptyResourceForm);
|
||
const [selectedResource, setSelectedResource] = useState(null);
|
||
const [loadingAction, setLoadingAction] = useState("");
|
||
const [resourceRowPatches, setResourceRowPatches] = useState({});
|
||
const [hiddenResourceIds, setHiddenResourceIds] = useState([]);
|
||
const filters = useMemo(
|
||
() => ({
|
||
keyword: query,
|
||
resource_type: resourceType,
|
||
status,
|
||
}),
|
||
[query, resourceType, status],
|
||
);
|
||
const result = usePaginatedQuery({
|
||
errorMessage: "加载资源列表失败",
|
||
fetcher: listResources,
|
||
filters,
|
||
page,
|
||
pageSize,
|
||
queryKey: ["resources", filters, page],
|
||
});
|
||
const hiddenResourceIdSet = useMemo(() => new Set(hiddenResourceIds), [hiddenResourceIds]);
|
||
const resourceData = useMemo(() => {
|
||
const source = result.data || emptyData;
|
||
const sourceItems = Array.isArray(source.items) ? source.items : [];
|
||
const patchedItems = sourceItems
|
||
.map((item) => ({
|
||
...item,
|
||
...(resourceRowPatches[item.resourceId] || {}),
|
||
}))
|
||
.filter((item) => !hiddenResourceIdSet.has(item.resourceId));
|
||
return {
|
||
...source,
|
||
items: patchedItems,
|
||
};
|
||
}, [hiddenResourceIdSet, resourceRowPatches, result.data]);
|
||
|
||
useEffect(() => {
|
||
setResourceRowPatches({});
|
||
setHiddenResourceIds([]);
|
||
}, [filters]);
|
||
|
||
const patchResourceRow = (resourceId, patch) => {
|
||
if (!resourceId) {
|
||
return;
|
||
}
|
||
setHiddenResourceIds((current) => current.filter((hiddenId) => hiddenId !== resourceId));
|
||
setResourceRowPatches((current) => ({
|
||
...current,
|
||
[resourceId]: {
|
||
...(current[resourceId] || {}),
|
||
...patch,
|
||
},
|
||
}));
|
||
};
|
||
|
||
const hideResourceRow = (resourceId) => {
|
||
if (!resourceId) {
|
||
return;
|
||
}
|
||
setHiddenResourceIds((current) => (current.includes(resourceId) ? current : [...current, resourceId]));
|
||
};
|
||
|
||
const hideResourceRows = (resourceIds) => {
|
||
const nextIds = resourceIds.filter(Boolean);
|
||
if (!nextIds.length) {
|
||
return;
|
||
}
|
||
setHiddenResourceIds((current) => Array.from(new Set([...current, ...nextIds])));
|
||
};
|
||
|
||
const reloadResources = async () => {
|
||
setResourceRowPatches({});
|
||
setHiddenResourceIds([]);
|
||
return result.reload();
|
||
};
|
||
|
||
const openCreateResource = () => {
|
||
setForm(emptyResourceForm());
|
||
setSelectedResource(null);
|
||
setActiveAction("create");
|
||
};
|
||
|
||
const openEditResource = (resource) => {
|
||
if (!resource?.resourceId) {
|
||
return;
|
||
}
|
||
setForm(emptyResourceForm(resource));
|
||
setSelectedResource(resource);
|
||
setActiveAction("edit");
|
||
};
|
||
|
||
const closeAction = () => {
|
||
setActiveAction("");
|
||
setSelectedResource(null);
|
||
};
|
||
|
||
const submitResource = async (event) => {
|
||
event.preventDefault();
|
||
const editing = activeAction === "edit";
|
||
await runAction(
|
||
editing ? "resource-edit" : "resource-create",
|
||
editing ? "资源已更新" : "资源已创建",
|
||
async () => {
|
||
const payload = buildResourcePayload(parseForm(resourceFormSchema, form));
|
||
if (editing) {
|
||
await updateResource(selectedResource.resourceId, payload);
|
||
} else {
|
||
await createResource(payload);
|
||
}
|
||
setForm(emptyResourceForm());
|
||
closeAction();
|
||
await result.reload();
|
||
},
|
||
);
|
||
};
|
||
|
||
const toggleResource = async (resource, nextEnabled = resource.status !== "active") => {
|
||
if (!abilities.canUpdate || !resource?.resourceId) {
|
||
return;
|
||
}
|
||
const nextStatus = nextEnabled ? "active" : "disabled";
|
||
if ((resource.status === "active") === nextEnabled) {
|
||
return;
|
||
}
|
||
await runAction(
|
||
`resource-status-${resource.resourceId}`,
|
||
nextEnabled ? "资源已启用" : "资源已禁用",
|
||
async () => {
|
||
let updatedResource;
|
||
if (nextStatus === "active") {
|
||
updatedResource = await enableResource(resource.resourceId);
|
||
} else {
|
||
updatedResource = await disableResource(resource.resourceId);
|
||
}
|
||
if (status && nextStatus !== status) {
|
||
hideResourceRow(resource.resourceId);
|
||
} else {
|
||
patchResourceRow(resource.resourceId, {
|
||
...(updatedResource || {}),
|
||
status: updatedResource?.status || nextStatus,
|
||
updatedAtMs: updatedResource?.updatedAtMs ?? Date.now(),
|
||
});
|
||
}
|
||
},
|
||
);
|
||
};
|
||
|
||
const toggleManagerGrant = async (resource, nextEnabled = !resource.managerGrantEnabled) => {
|
||
if (!abilities.canUpdate || !resource?.resourceId) {
|
||
return;
|
||
}
|
||
if (Boolean(resource.managerGrantEnabled) === nextEnabled) {
|
||
return;
|
||
}
|
||
await runAction(
|
||
`resource-manager-grant-${resource.resourceId}`,
|
||
nextEnabled ? "经理赠送已开启" : "经理赠送已关闭",
|
||
async () => {
|
||
const formValue = parseForm(resourceFormSchema, {
|
||
...emptyResourceForm(resource),
|
||
managerGrantEnabled: nextEnabled,
|
||
});
|
||
const updatedResource = await updateResource(resource.resourceId, buildResourcePayload(formValue));
|
||
patchResourceRow(resource.resourceId, {
|
||
...(updatedResource || {}),
|
||
managerGrantEnabled: updatedResource?.managerGrantEnabled ?? nextEnabled,
|
||
updatedAtMs: updatedResource?.updatedAtMs ?? Date.now(),
|
||
});
|
||
},
|
||
);
|
||
};
|
||
|
||
const deleteResources = async (resources = []) => {
|
||
const targetResources = resources.filter((resource) => resource?.resourceId);
|
||
if (!abilities.canDelete || !targetResources.length) {
|
||
return [];
|
||
}
|
||
const title = targetResources.length > 1 ? "批量删除资源" : "删除资源";
|
||
const message =
|
||
targetResources.length > 1
|
||
? `确认删除选中的 ${targetResources.length} 个资源?删除成功后资源会从资源列表移除。`
|
||
: `确认删除资源 ${targetResources[0].name || targetResources[0].resourceCode || targetResources[0].resourceId}?删除成功后资源会从资源列表移除。`;
|
||
const ok = await confirm({
|
||
confirmText: "删除",
|
||
message,
|
||
title,
|
||
tone: "danger",
|
||
});
|
||
if (!ok) {
|
||
return [];
|
||
}
|
||
|
||
const deletedResourceIds = [];
|
||
setLoadingAction(
|
||
targetResources.length > 1 ? "resource-delete-batch" : `resource-delete-${targetResources[0].resourceId}`,
|
||
);
|
||
try {
|
||
if (targetResources.length > 1) {
|
||
await requestBatchDeleteResources(targetResources.map((resource) => resource.resourceId));
|
||
deletedResourceIds.push(...targetResources.map((resource) => resource.resourceId));
|
||
} else {
|
||
await requestDeleteResource(targetResources[0].resourceId);
|
||
deletedResourceIds.push(targetResources[0].resourceId);
|
||
}
|
||
hideResourceRows(deletedResourceIds);
|
||
showToast(targetResources.length > 1 ? `已删除 ${deletedResourceIds.length} 个资源` : "资源已删除", "success");
|
||
return deletedResourceIds;
|
||
} catch (err) {
|
||
showToast(err.message || "删除资源失败", "error");
|
||
return [];
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const runAction = async (action, successMessage, submitter) => {
|
||
setLoadingAction(action);
|
||
try {
|
||
await submitter();
|
||
showToast(successMessage, "success");
|
||
} catch (err) {
|
||
showToast(err.message || "操作失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setQuery("");
|
||
setStatus("");
|
||
setResourceType("");
|
||
setPage(1);
|
||
};
|
||
|
||
return {
|
||
...sharedPageState({ data: resourceData, page, query, reload: reloadResources, result, setPage, setQuery }),
|
||
abilities,
|
||
activeAction,
|
||
changeResourceType: resetSetter(setResourceType, setPage),
|
||
changeStatus: resetSetter(setStatus, setPage),
|
||
closeAction,
|
||
deleteResources,
|
||
form,
|
||
loadingAction,
|
||
openCreateResource,
|
||
openEditResource,
|
||
resourceType,
|
||
selectedResource,
|
||
resetFilters,
|
||
setForm,
|
||
status,
|
||
submitResource,
|
||
toggleManagerGrant,
|
||
toggleResource,
|
||
};
|
||
}
|
||
|
||
export function useResourceShopPage() {
|
||
const abilities = useResourceAbilities();
|
||
const { showToast } = useToast();
|
||
const [query, setQuery] = useState("");
|
||
const [status, setStatus] = useState("");
|
||
const [resourceType, setResourceType] = useState("");
|
||
const [page, setPage] = useState(1);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [purchaseDrawerOpen, setPurchaseDrawerOpen] = useState(false);
|
||
const [purchasePage, setPurchasePage] = useState(1);
|
||
const [purchaseUserFilter, setPurchaseUserFilter] = useState(emptyUserIdentityFilter);
|
||
const [purchaseResourceType, setPurchaseResourceType] = useState("");
|
||
const [purchaseKeyword, setPurchaseKeyword] = useState("");
|
||
const [drawerQuery, setDrawerQuery] = useState("");
|
||
const [drawerResourceType, setDrawerResourceType] = useState("");
|
||
const [batchDurationDays, setBatchDurationDays] = useState("1");
|
||
const [resourceOptions, setResourceOptions] = useState([]);
|
||
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
|
||
const [draftByResourceId, setDraftByResourceId] = useState({});
|
||
const [loadingAction, setLoadingAction] = useState("");
|
||
const filters = useMemo(
|
||
() => ({
|
||
keyword: query,
|
||
resource_type: resourceType,
|
||
status,
|
||
}),
|
||
[query, resourceType, status],
|
||
);
|
||
const result = usePaginatedQuery({
|
||
errorMessage: "加载道具商店失败",
|
||
fetcher: listResourceShopItems,
|
||
filters,
|
||
page,
|
||
pageSize,
|
||
queryKey: ["resource-shop", filters, page],
|
||
});
|
||
const purchaseFilters = useMemo(
|
||
() => ({
|
||
keyword: purchaseKeyword,
|
||
resource_type: purchaseResourceType,
|
||
...userIdentityFilterParams(purchaseUserFilter),
|
||
}),
|
||
[purchaseKeyword, purchaseResourceType, purchaseUserFilter],
|
||
);
|
||
const fetchPurchaseOrders = useCallback(
|
||
(requestQuery) => {
|
||
if (!purchaseDrawerOpen) {
|
||
return Promise.resolve(emptyData);
|
||
}
|
||
return listResourceShopPurchaseOrders(requestQuery);
|
||
},
|
||
[purchaseDrawerOpen],
|
||
);
|
||
const purchaseResult = usePaginatedQuery({
|
||
errorMessage: "加载售卖记录失败",
|
||
fetcher: fetchPurchaseOrders,
|
||
filters: purchaseFilters,
|
||
page: purchasePage,
|
||
pageSize,
|
||
queryKey: ["resource-shop-purchase-orders", purchaseDrawerOpen, purchaseFilters, purchasePage],
|
||
});
|
||
const shopItemsByResourceId = useMemo(() => {
|
||
const items = new Map();
|
||
(result.data?.items || []).forEach((item) => {
|
||
if (item.resourceId) {
|
||
items.set(Number(item.resourceId), item);
|
||
}
|
||
});
|
||
return items;
|
||
}, [result.data?.items]);
|
||
const selectedDrafts = useMemo(
|
||
() =>
|
||
Object.values(draftByResourceId)
|
||
.map((draft) => ({
|
||
...draft,
|
||
resource: resourceOptions.find(
|
||
(resource) => String(resource.resourceId) === String(draft.resourceId),
|
||
),
|
||
}))
|
||
.sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)),
|
||
[draftByResourceId, resourceOptions],
|
||
);
|
||
const filteredResourceOptions = useMemo(() => {
|
||
const keyword = drawerQuery.trim().toLowerCase();
|
||
return resourceOptions.filter((resource) => {
|
||
if (drawerResourceType && resource.resourceType !== drawerResourceType) {
|
||
return false;
|
||
}
|
||
if (!keyword) {
|
||
return true;
|
||
}
|
||
return `${resource.name || ""} ${resource.resourceCode || ""}`.toLowerCase().includes(keyword);
|
||
});
|
||
}, [drawerQuery, drawerResourceType, resourceOptions]);
|
||
|
||
useEffect(() => {
|
||
if (!drawerOpen) {
|
||
return undefined;
|
||
}
|
||
let ignore = false;
|
||
setResourceOptionsLoading(true);
|
||
fetchAllOptionPages(listResources, { status: "active" })
|
||
.then((data) => {
|
||
if (!ignore) {
|
||
setResourceOptions(toShopResourceOptions(data.items || []));
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
if (!ignore) {
|
||
showToast(err.message || "加载售卖资源失败", "error");
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!ignore) {
|
||
setResourceOptionsLoading(false);
|
||
}
|
||
});
|
||
return () => {
|
||
ignore = true;
|
||
};
|
||
}, [drawerOpen, showToast]);
|
||
|
||
const openShopDrawer = () => {
|
||
setDraftByResourceId({});
|
||
setDrawerQuery("");
|
||
setDrawerResourceType("");
|
||
setBatchDurationDays("1");
|
||
setDrawerOpen(true);
|
||
};
|
||
|
||
const closeShopDrawer = () => {
|
||
setDrawerOpen(false);
|
||
setDraftByResourceId({});
|
||
};
|
||
|
||
const openPurchaseDrawer = () => {
|
||
setPurchaseDrawerOpen(true);
|
||
setPurchasePage(1);
|
||
};
|
||
|
||
const closePurchaseDrawer = () => {
|
||
setPurchaseDrawerOpen(false);
|
||
};
|
||
|
||
const toggleDraftResource = (resource) => {
|
||
if (!resource?.resourceId) {
|
||
return;
|
||
}
|
||
const resourceId = String(resource.resourceId);
|
||
setDraftByResourceId((current) => {
|
||
if (current[resourceId]) {
|
||
const next = { ...current };
|
||
delete next[resourceId];
|
||
return next;
|
||
}
|
||
const existing = shopItemsByResourceId.get(Number(resource.resourceId));
|
||
return {
|
||
...current,
|
||
[resourceId]: {
|
||
durationDays: String(existing?.durationDays || batchDurationDays),
|
||
effectiveFrom: msToDatetimeLocal(existing?.effectiveFromMs),
|
||
effectiveTo: msToDatetimeLocal(existing?.effectiveToMs),
|
||
resourceId,
|
||
sortOrder: String(existing?.sortOrder ?? Object.keys(current).length),
|
||
},
|
||
};
|
||
});
|
||
};
|
||
|
||
const updateDraftItem = (resourceId, patch) => {
|
||
setDraftByResourceId((current) => ({
|
||
...current,
|
||
[resourceId]: {
|
||
...current[resourceId],
|
||
...patch,
|
||
},
|
||
}));
|
||
};
|
||
|
||
const applyBatchDuration = (durationDays) => {
|
||
setBatchDurationDays(durationDays);
|
||
setDraftByResourceId((current) =>
|
||
Object.fromEntries(
|
||
Object.entries(current).map(([resourceId, draft]) => [resourceId, { ...draft, durationDays }]),
|
||
),
|
||
);
|
||
};
|
||
|
||
const submitShopItems = async (event) => {
|
||
event.preventDefault();
|
||
if (!abilities.canUpdateShop) {
|
||
return;
|
||
}
|
||
setLoadingAction("resource-shop-upsert");
|
||
try {
|
||
const parsed = parseForm(resourceShopItemsFormSchema, { items: selectedDrafts });
|
||
await upsertResourceShopItems({
|
||
items: parsed.items.map((item) => ({
|
||
durationDays: Number(item.durationDays),
|
||
effectiveFromMs: datetimeLocalToMs(item.effectiveFrom),
|
||
effectiveToMs: datetimeLocalToMs(item.effectiveTo),
|
||
resourceId: Number(item.resourceId),
|
||
shopItemId: shopItemsByResourceId.get(Number(item.resourceId))?.shopItemId,
|
||
sortOrder: Number(item.sortOrder || 0),
|
||
status: "active",
|
||
})),
|
||
});
|
||
showToast("售卖资源已保存", "success");
|
||
closeShopDrawer();
|
||
await result.reload();
|
||
} catch (err) {
|
||
showToast(err.message || "保存售卖资源失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const toggleShopItem = async (item, nextEnabled = item.status !== "active") => {
|
||
if (!abilities.canUpdateShop || !item?.shopItemId) {
|
||
return;
|
||
}
|
||
if ((item.status === "active") === nextEnabled) {
|
||
return;
|
||
}
|
||
setLoadingAction(`resource-shop-status-${item.shopItemId}`);
|
||
try {
|
||
if (nextEnabled) {
|
||
await enableResourceShopItem(item.shopItemId);
|
||
} else {
|
||
await disableResourceShopItem(item.shopItemId);
|
||
}
|
||
showToast(nextEnabled ? "售卖资源已启用" : "售卖资源已禁用", "success");
|
||
await result.reload();
|
||
} catch (err) {
|
||
showToast(err.message || "操作失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setQuery("");
|
||
setStatus("");
|
||
setResourceType("");
|
||
setPage(1);
|
||
};
|
||
|
||
return {
|
||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||
abilities,
|
||
applyBatchDuration,
|
||
batchDurationDays,
|
||
changeDrawerQuery: setDrawerQuery,
|
||
changeDrawerResourceType: setDrawerResourceType,
|
||
changeResourceType: resetSetter(setResourceType, setPage),
|
||
changeStatus: resetSetter(setStatus, setPage),
|
||
closeShopDrawer,
|
||
closePurchaseDrawer,
|
||
drawerOpen,
|
||
drawerQuery,
|
||
drawerResourceType,
|
||
filteredResourceOptions,
|
||
loadingAction,
|
||
openShopDrawer,
|
||
openPurchaseDrawer,
|
||
purchaseData: purchaseResult.data,
|
||
purchaseDrawerOpen,
|
||
purchaseError: purchaseResult.error,
|
||
purchaseKeyword,
|
||
purchaseLoading: purchaseResult.loading,
|
||
purchasePage,
|
||
purchaseResourceType,
|
||
purchaseUserFilter,
|
||
reloadPurchaseOrders: purchaseResult.reload,
|
||
setPurchasePage,
|
||
resourceOptionsLoading,
|
||
resourceType,
|
||
resetFilters,
|
||
selectedDrafts,
|
||
selectedResourceIds: new Set(Object.keys(draftByResourceId)),
|
||
status,
|
||
submitShopItems,
|
||
toggleDraftResource,
|
||
toggleShopItem,
|
||
changePurchaseKeyword: resetSetter(setPurchaseKeyword, setPurchasePage),
|
||
changePurchaseResourceType: resetSetter(setPurchaseResourceType, setPurchasePage),
|
||
changePurchaseUserFilter: resetSetter(setPurchaseUserFilter, setPurchasePage),
|
||
updateDraftItem,
|
||
};
|
||
}
|
||
|
||
export function useResourceGroupListPage() {
|
||
const abilities = useResourceAbilities();
|
||
const { showToast } = useToast();
|
||
const [query, setQuery] = useState("");
|
||
const [status, setStatus] = useState("");
|
||
const [page, setPage] = useState(1);
|
||
const [activeAction, setActiveAction] = useState("");
|
||
const [form, setForm] = useState(emptyResourceGroupForm);
|
||
const [selectedGroup, setSelectedGroup] = useState(null);
|
||
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,
|
||
status,
|
||
}),
|
||
[query, status],
|
||
);
|
||
const result = usePaginatedQuery({
|
||
errorMessage: "加载资源组列表失败",
|
||
fetcher: listResourceGroups,
|
||
filters,
|
||
page,
|
||
pageSize,
|
||
queryKey: ["resource-groups", filters, page],
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (activeAction !== "create" && activeAction !== "edit") {
|
||
return undefined;
|
||
}
|
||
let ignore = false;
|
||
setResourceOptionsLoading(true);
|
||
fetchAllOptionPages(listResources, { status: "active" })
|
||
.then((data) => {
|
||
if (!ignore) {
|
||
setResourceOptions(toGroupResourceOptions(data.items || [], selectedGroup));
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
if (!ignore) {
|
||
showToast(err.message || "加载资源选项失败", "error");
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!ignore) {
|
||
setResourceOptionsLoading(false);
|
||
}
|
||
});
|
||
return () => {
|
||
ignore = true;
|
||
};
|
||
}, [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);
|
||
setActiveAction("create");
|
||
};
|
||
|
||
const openEditGroup = (group) => {
|
||
if (!group?.groupId) {
|
||
return;
|
||
}
|
||
setForm(emptyResourceGroupForm(group));
|
||
setSelectedGroup(group);
|
||
setActiveAction("edit");
|
||
};
|
||
|
||
const openIdentityGrantConfig = () => {
|
||
setIdentityGrantLoading(true);
|
||
setActiveAction("identity-config");
|
||
};
|
||
|
||
const closeAction = () => {
|
||
setActiveAction("");
|
||
setSelectedGroup(null);
|
||
};
|
||
|
||
const addResourceItem = () => {
|
||
setForm((current) => ({ ...current, items: [...current.items, emptyGroupResourceItem()] }));
|
||
};
|
||
|
||
const addWalletAssetItem = (walletAssetType) => {
|
||
setForm((current) => ({ ...current, items: [...current.items, emptyGroupWalletAssetItem(walletAssetType)] }));
|
||
};
|
||
|
||
const updateGroupItem = (index, patch) => {
|
||
setForm((current) => ({
|
||
...current,
|
||
items: current.items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)),
|
||
}));
|
||
};
|
||
|
||
const removeGroupItem = (index) => {
|
||
setForm((current) => ({
|
||
...current,
|
||
items: current.items.filter((_, itemIndex) => itemIndex !== index),
|
||
}));
|
||
};
|
||
|
||
const submitGroup = async (event) => {
|
||
event.preventDefault();
|
||
const editing = activeAction === "edit";
|
||
const action = editing ? "group-edit" : "group-create";
|
||
const successMessage = editing ? "资源组已更新" : "资源组已创建";
|
||
setLoadingAction(action);
|
||
try {
|
||
const payload = buildResourceGroupPayload(parseForm(resourceGroupCreateFormSchema, form));
|
||
if (editing) {
|
||
await updateResourceGroup(selectedGroup.groupId, payload);
|
||
} else {
|
||
await createResourceGroup(payload);
|
||
}
|
||
showToast(successMessage, "success");
|
||
setForm(emptyResourceGroupForm());
|
||
closeAction();
|
||
await result.reload();
|
||
} catch (err) {
|
||
showToast(err.message || "操作失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
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;
|
||
}
|
||
if ((group.status === "active") === nextEnabled) {
|
||
return;
|
||
}
|
||
setLoadingAction(`group-status-${group.groupId}`);
|
||
try {
|
||
if (nextEnabled) {
|
||
await enableResourceGroup(group.groupId);
|
||
} else {
|
||
await disableResourceGroup(group.groupId);
|
||
}
|
||
showToast(nextEnabled ? "资源组已启用" : "资源组已禁用", "success");
|
||
await result.reload();
|
||
} catch (err) {
|
||
showToast(err.message || "操作失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setQuery("");
|
||
setStatus("");
|
||
setPage(1);
|
||
};
|
||
|
||
return {
|
||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||
abilities,
|
||
activeAction,
|
||
addResourceItem,
|
||
addWalletAssetItem,
|
||
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,
|
||
};
|
||
}
|
||
|
||
export function useGiftListPage() {
|
||
const abilities = useResourceAbilities();
|
||
const confirm = useConfirm();
|
||
const { showToast } = useToast();
|
||
const [query, setQuery] = useState("");
|
||
const [status, setStatus] = useState("");
|
||
const [regionId, setRegionId] = useState("");
|
||
const [giftTypeCode, setGiftTypeCode] = useState("");
|
||
const [page, setPage] = useState(1);
|
||
const [activeAction, setActiveAction] = useState("");
|
||
const [form, setForm] = useState(emptyGiftForm);
|
||
const [selectedGift, setSelectedGift] = useState(null);
|
||
const [loadingAction, setLoadingAction] = useState("");
|
||
const [giftSortSavingIds, setGiftSortSavingIds] = useState({});
|
||
const [giftRowPatches, setGiftRowPatches] = useState({});
|
||
const [giftTypes, setGiftTypes] = useState(defaultGiftTypeOptions);
|
||
const [giftTypeForm, setGiftTypeForm] = useState({ items: defaultGiftTypeOptions.map(giftTypeToForm) });
|
||
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(
|
||
() => ({
|
||
gift_type_code: giftTypeCode,
|
||
keyword: query,
|
||
region_id: regionId,
|
||
status,
|
||
}),
|
||
[giftTypeCode, query, regionId, status],
|
||
);
|
||
const result = usePaginatedQuery({
|
||
errorMessage: "加载礼物列表失败",
|
||
fetcher: listGifts,
|
||
filters,
|
||
page,
|
||
pageSize,
|
||
queryKey: ["gifts", filters, page],
|
||
});
|
||
const giftData = useMemo(() => {
|
||
const source = result.data || emptyData;
|
||
const sourceItems = Array.isArray(source.items) ? source.items : [];
|
||
return {
|
||
...source,
|
||
items: sourceItems.map((item) => ({
|
||
...item,
|
||
...(giftRowPatches[item.giftId] || {}),
|
||
})),
|
||
};
|
||
}, [giftRowPatches, result.data]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
setGiftTypesLoading(true);
|
||
listGiftTypes()
|
||
.then((items = []) => {
|
||
if (ignore) {
|
||
return;
|
||
}
|
||
const nextItems = mergeGiftTypeOptions(items);
|
||
setGiftTypes(nextItems);
|
||
setGiftTypeForm({ items: nextItems.map(giftTypeToForm) });
|
||
})
|
||
.catch((err) => {
|
||
if (!ignore) {
|
||
showToast(err.message || "加载礼物类型失败", "error");
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!ignore) {
|
||
setGiftTypesLoading(false);
|
||
}
|
||
});
|
||
return () => {
|
||
ignore = true;
|
||
};
|
||
}, [showToast]);
|
||
|
||
useEffect(() => {
|
||
if (activeAction !== "create" && activeAction !== "edit") {
|
||
return undefined;
|
||
}
|
||
let ignore = false;
|
||
setResourceOptionsLoading(true);
|
||
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) {
|
||
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) => {
|
||
if (!ignore) {
|
||
showToast(err.message || "加载礼物资源失败", "error");
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!ignore) {
|
||
setResourceOptionsLoading(false);
|
||
}
|
||
});
|
||
return () => {
|
||
ignore = true;
|
||
};
|
||
}, [activeAction, selectedGift, showToast]);
|
||
|
||
const openCreateGift = () => {
|
||
setForm(emptyGiftForm());
|
||
setSelectedGift(null);
|
||
setActiveAction("create");
|
||
};
|
||
|
||
const openEditGift = (gift) => {
|
||
if (!gift?.giftId) {
|
||
return;
|
||
}
|
||
setForm(emptyGiftForm(gift));
|
||
setSelectedGift(gift);
|
||
setActiveAction("edit");
|
||
};
|
||
|
||
const closeAction = () => {
|
||
setActiveAction("");
|
||
setSelectedGift(null);
|
||
};
|
||
|
||
const selectGiftResource = (resourceId) => {
|
||
const selectedResource = resourceOptions.find((resource) => String(resource.resourceId) === String(resourceId));
|
||
setForm((currentForm) => {
|
||
return applyGiftResourceSelection(
|
||
currentForm,
|
||
selectedResource || { resourceId },
|
||
activeAction === "create",
|
||
);
|
||
});
|
||
};
|
||
|
||
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));
|
||
};
|
||
|
||
const changeGiftFormTypeCode = (nextGiftTypeCode) => {
|
||
setForm((currentForm) => {
|
||
const cpRelationType =
|
||
nextGiftTypeCode === cpGiftTypeCode
|
||
? currentForm.cpRelationType || cpRelationTypeFromPresentationJson(currentForm.presentationJson)
|
||
: "";
|
||
return {
|
||
...currentForm,
|
||
cpRelationType: cpRelationType || (nextGiftTypeCode === cpGiftTypeCode ? defaultCPRelationType : ""),
|
||
giftTypeCode: nextGiftTypeCode,
|
||
presentationJson: normalizeGiftPresentationJson(
|
||
currentForm.presentationJson,
|
||
nextGiftTypeCode,
|
||
cpRelationType,
|
||
),
|
||
};
|
||
});
|
||
};
|
||
|
||
const changeGiftCPRelationType = (nextCPRelationType) => {
|
||
setForm((currentForm) => ({
|
||
...currentForm,
|
||
cpRelationType: nextCPRelationType,
|
||
presentationJson: normalizeGiftPresentationJson(
|
||
currentForm.presentationJson,
|
||
currentForm.giftTypeCode,
|
||
nextCPRelationType,
|
||
),
|
||
}));
|
||
};
|
||
|
||
const openGiftTypeDialog = () => {
|
||
setGiftTypeForm({ items: giftTypes.map(giftTypeToForm) });
|
||
setGiftTypeDialogOpen(true);
|
||
};
|
||
|
||
const closeGiftTypeDialog = () => {
|
||
setGiftTypeDialogOpen(false);
|
||
};
|
||
|
||
const updateGiftTypeFormItem = (index, patch) => {
|
||
setGiftTypeForm((current) => ({
|
||
...current,
|
||
items: current.items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)),
|
||
}));
|
||
};
|
||
|
||
const submitGiftTypes = async (event) => {
|
||
event.preventDefault();
|
||
if (!abilities.canUpdateGift) {
|
||
return;
|
||
}
|
||
setLoadingAction("gift-types");
|
||
try {
|
||
const parsed = parseForm(giftTypeConfigFormSchema, giftTypeForm);
|
||
const savedItems = await updateGiftTypes({
|
||
items: parsed.items.map((item) => ({
|
||
tabKey: item.tabKey,
|
||
...buildGiftTypePayload(item),
|
||
})),
|
||
});
|
||
const nextItems = mergeGiftTypeOptions(savedItems);
|
||
setGiftTypes(nextItems);
|
||
setGiftTypeForm({ items: nextItems.map(giftTypeToForm) });
|
||
showToast("礼物类型已保存", "success");
|
||
setGiftTypeDialogOpen(false);
|
||
} catch (err) {
|
||
showToast(err.message || "保存礼物类型失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const submitGift = async (event) => {
|
||
event.preventDefault();
|
||
const editing = activeAction === "edit";
|
||
if (editing && !selectedGift?.giftId) {
|
||
return;
|
||
}
|
||
setLoadingAction(editing ? "gift-edit" : "gift-create");
|
||
const createdDrafts = [];
|
||
let shouldReload = false;
|
||
try {
|
||
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 {
|
||
const payloads = createDrafts.map((draft) => ({
|
||
draft,
|
||
payload: buildGiftPayload(parseForm(giftFormSchema, draft)),
|
||
}));
|
||
if (payloads.length > 1) {
|
||
await batchCreateGifts(payloads.map(({ payload }) => payload));
|
||
createdDrafts.push(...payloads.map(({ draft }) => draft));
|
||
} else {
|
||
await createGift(payloads[0].payload);
|
||
createdDrafts.push(payloads[0].draft);
|
||
}
|
||
}
|
||
showToast(editing ? "礼物已更新" : `已创建 ${createDrafts.length} 个礼物`, "success");
|
||
closeAction();
|
||
shouldReload = true;
|
||
} catch (err) {
|
||
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") => {
|
||
if (!abilities.canStatusGift || !gift?.giftId) {
|
||
return;
|
||
}
|
||
if ((gift.status === "active") === nextEnabled) {
|
||
return;
|
||
}
|
||
setLoadingAction(`gift-status-${gift.giftId}`);
|
||
try {
|
||
if (nextEnabled) {
|
||
await enableGift(gift.giftId);
|
||
} else {
|
||
await disableGift(gift.giftId);
|
||
}
|
||
showToast(nextEnabled ? "礼物已启用" : "礼物已禁用", "success");
|
||
await result.reload();
|
||
} catch (err) {
|
||
showToast(err.message || "操作失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const deleteGifts = async (gifts = []) => {
|
||
const targetGifts = gifts.filter((gift) => gift?.giftId);
|
||
if (!abilities.canDeleteGift || !targetGifts.length) {
|
||
return false;
|
||
}
|
||
const title = targetGifts.length > 1 ? "批量删除礼物" : "删除礼物";
|
||
const message =
|
||
targetGifts.length > 1
|
||
? `确认删除选中的 ${targetGifts.length} 个礼物?只删除礼物列表数据,不删除资源列表数据。`
|
||
: `确认删除礼物 ${targetGifts[0].name || targetGifts[0].giftId}?只删除礼物列表数据,不删除资源列表数据。`;
|
||
const ok = await confirm({
|
||
confirmText: "删除",
|
||
message,
|
||
title,
|
||
tone: "danger",
|
||
});
|
||
if (!ok) {
|
||
return false;
|
||
}
|
||
setLoadingAction(targetGifts.length > 1 ? "gift-delete-batch" : `gift-delete-${targetGifts[0].giftId}`);
|
||
try {
|
||
for (const gift of targetGifts) {
|
||
await requestDeleteGift(gift.giftId);
|
||
}
|
||
showToast(targetGifts.length > 1 ? `已删除 ${targetGifts.length} 个礼物` : "礼物已删除", "success");
|
||
await result.reload();
|
||
return true;
|
||
} catch (err) {
|
||
showToast(err.message || "删除礼物失败", "error");
|
||
return false;
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const saveGiftSortOrder = async (gift, rawSortOrder) => {
|
||
if (!abilities.canUpdateGift || !gift?.giftId) {
|
||
return false;
|
||
}
|
||
const sortOrder = Number(rawSortOrder);
|
||
if (!Number.isInteger(sortOrder) || sortOrder < 0) {
|
||
showToast("排序必须是大于等于 0 的整数", "error");
|
||
return false;
|
||
}
|
||
if (sortOrder === Number(gift.sortOrder || 0)) {
|
||
return true;
|
||
}
|
||
setGiftSortSavingIds((current) => ({ ...current, [gift.giftId]: true }));
|
||
try {
|
||
await updateGift(gift.giftId, buildGiftInlineSortPayload(gift, sortOrder));
|
||
setGiftRowPatches((current) => ({
|
||
...current,
|
||
[gift.giftId]: {
|
||
...(current[gift.giftId] || {}),
|
||
sortOrder,
|
||
},
|
||
}));
|
||
showToast("排序已保存", "success");
|
||
return true;
|
||
} catch (err) {
|
||
showToast(err.message || "保存排序失败", "error");
|
||
return false;
|
||
} finally {
|
||
setGiftSortSavingIds((current) => {
|
||
const next = { ...current };
|
||
delete next[gift.giftId];
|
||
return next;
|
||
});
|
||
}
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setQuery("");
|
||
setStatus("");
|
||
setRegionId("");
|
||
setGiftTypeCode("");
|
||
setPage(1);
|
||
};
|
||
|
||
return {
|
||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||
data: giftData,
|
||
abilities,
|
||
activeAction,
|
||
changeGiftCPRelationType,
|
||
changeGiftPrice,
|
||
changeGiftFormTypeCode,
|
||
changeRegionId: resetSetter(setRegionId, setPage),
|
||
changeStatus: resetSetter(setStatus, setPage),
|
||
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
|
||
closeAction,
|
||
closeGiftTypeDialog,
|
||
deleteGifts,
|
||
form,
|
||
giftSortSavingIds,
|
||
giftTypeCode,
|
||
giftTypeDialogOpen,
|
||
giftTypeForm,
|
||
giftTypeOptions: giftTypes,
|
||
giftTypesLoading,
|
||
giftResourceGiftOptions,
|
||
existingGiftResourceIds,
|
||
loadingAction,
|
||
loadingRegions,
|
||
openCreateGift,
|
||
openEditGift,
|
||
openGiftTypeDialog,
|
||
regionId,
|
||
regionOptions,
|
||
resourceOptions,
|
||
resourceOptionsLoading,
|
||
selectedGift,
|
||
resetFilters,
|
||
selectGiftResource,
|
||
selectGiftResources,
|
||
removeGiftDraft,
|
||
setForm,
|
||
saveGiftSortOrder,
|
||
submitGiftTypes,
|
||
status,
|
||
submitGift,
|
||
toggleGift,
|
||
updateGiftDraft,
|
||
updateGiftTypeFormItem,
|
||
};
|
||
}
|
||
|
||
export function useEmojiPackListPage() {
|
||
const abilities = useResourceAbilities();
|
||
const { showToast } = useToast();
|
||
const [query, setQuery] = useState("");
|
||
const [status, setStatus] = useState("");
|
||
const [page, setPage] = useState(1);
|
||
const [activeAction, setActiveAction] = useState("");
|
||
const [form, setForm] = useState(emptyEmojiPackForm);
|
||
const [loadingAction, setLoadingAction] = useState("");
|
||
const [categoryOptions, setCategoryOptions] = useState([]);
|
||
const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false);
|
||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||
const filters = useMemo(
|
||
() => ({
|
||
keyword: query,
|
||
status,
|
||
}),
|
||
[query, status],
|
||
);
|
||
const result = usePaginatedQuery({
|
||
errorMessage: "加载表情包列表失败",
|
||
fetcher: listEmojiPacks,
|
||
filters,
|
||
page,
|
||
pageSize,
|
||
queryKey: ["emoji-packs", filters, page],
|
||
});
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
setCategoryOptionsLoading(true);
|
||
listEmojiPackCategories()
|
||
.then((options) => {
|
||
if (!ignore) {
|
||
setCategoryOptions(mergeEmojiPackCategoryOptions(options));
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
if (!ignore) {
|
||
showToast(err.message || "加载表情包分类失败", "error");
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!ignore) {
|
||
setCategoryOptionsLoading(false);
|
||
}
|
||
});
|
||
return () => {
|
||
ignore = true;
|
||
};
|
||
}, [showToast]);
|
||
|
||
const openCreateEmojiPack = () => {
|
||
setForm(emptyEmojiPackForm());
|
||
setActiveAction("create");
|
||
};
|
||
|
||
const closeAction = () => {
|
||
setActiveAction("");
|
||
};
|
||
|
||
const submitEmojiPack = async (event) => {
|
||
event.preventDefault();
|
||
if (!abilities.canCreateEmojiPack) {
|
||
return;
|
||
}
|
||
setLoadingAction("emoji-pack-create");
|
||
try {
|
||
const payload = buildEmojiPackPayload(parseForm(emojiPackFormSchema, form));
|
||
await createEmojiPack(payload);
|
||
setCategoryOptions((options) => mergeEmojiPackCategoryOptions(options, payload.category));
|
||
showToast("表情包已创建", "success");
|
||
closeAction();
|
||
setForm(emptyEmojiPackForm());
|
||
await result.reload();
|
||
} catch (err) {
|
||
showToast(err.message || "操作失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const toggleEmojiPack = async (emojiPack, nextEnabled = emojiPack.status !== "active") => {
|
||
if (!abilities.canUpdate || !emojiPack?.resourceId) {
|
||
return;
|
||
}
|
||
if ((emojiPack.status === "active") === nextEnabled) {
|
||
return;
|
||
}
|
||
setLoadingAction(`emoji-pack-status-${emojiPack.resourceId}`);
|
||
try {
|
||
if (nextEnabled) {
|
||
await enableResource(emojiPack.resourceId);
|
||
} else {
|
||
await disableResource(emojiPack.resourceId);
|
||
}
|
||
showToast(nextEnabled ? "表情包已启用" : "表情包已禁用", "success");
|
||
await result.reload();
|
||
} catch (err) {
|
||
showToast(err.message || "操作失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setQuery("");
|
||
setStatus("");
|
||
setPage(1);
|
||
};
|
||
|
||
return {
|
||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||
abilities,
|
||
activeAction,
|
||
categoryOptions,
|
||
categoryOptionsLoading,
|
||
changeStatus: resetSetter(setStatus, setPage),
|
||
closeAction,
|
||
form,
|
||
loadingAction,
|
||
loadingRegions,
|
||
openCreateEmojiPack,
|
||
regionOptions,
|
||
resetFilters,
|
||
setForm,
|
||
status,
|
||
submitEmojiPack,
|
||
toggleEmojiPack,
|
||
};
|
||
}
|
||
|
||
export function useResourceGrantListPage() {
|
||
const abilities = useResourceAbilities();
|
||
const confirm = useConfirm();
|
||
const { showToast } = useToast();
|
||
const [targetFilter, setTargetFilter] = useState(emptyUserIdentityFilter);
|
||
const [status, setStatus] = useState("");
|
||
const [page, setPage] = useState(1);
|
||
const [activeAction, setActiveAction] = useState("");
|
||
const [form, setForm] = useState(emptyGrantForm);
|
||
const [giftOptions, setGiftOptions] = useState([]);
|
||
const [giftTypeOptions, setGiftTypeOptions] = useState(defaultGiftTypeOptions);
|
||
const [groupOptions, setGroupOptions] = useState([]);
|
||
const [resourceOptions, setResourceOptions] = useState([]);
|
||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||
const [loadingAction, setLoadingAction] = useState("");
|
||
const filters = useMemo(
|
||
() => ({
|
||
status,
|
||
...userIdentityFilterParams(targetFilter, {
|
||
displayUserId: "target_display_user_id",
|
||
userId: "target_user_id",
|
||
username: "target_username",
|
||
}),
|
||
}),
|
||
[status, targetFilter],
|
||
);
|
||
const result = usePaginatedQuery({
|
||
errorMessage: "加载资源赠送记录失败",
|
||
fetcher: listResourceGrants,
|
||
filters,
|
||
page,
|
||
pageSize,
|
||
queryKey: ["resource-grants", filters, page],
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (activeAction !== "create") {
|
||
return undefined;
|
||
}
|
||
let ignore = false;
|
||
setOptionsLoading(true);
|
||
Promise.all([
|
||
fetchAllOptionPages(listResources, { status: "active" }),
|
||
fetchAllOptionPages(listGifts, { status: "active" }),
|
||
listGiftTypes(),
|
||
fetchAllOptionPages(listResourceGroups, { status: "active" }),
|
||
])
|
||
.then(([resources, gifts, giftTypes, groups]) => {
|
||
if (!ignore) {
|
||
setResourceOptions(
|
||
(resources.items || []).filter(
|
||
(resource) => resource.status !== "disabled" && resource.grantable !== false,
|
||
),
|
||
);
|
||
setGiftOptions((gifts.items || []).filter((gift) => gift.resourceId && gift.status !== "disabled"));
|
||
setGiftTypeOptions(mergeGiftTypeOptions(giftTypes));
|
||
setGroupOptions(groups.items || []);
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
if (!ignore) {
|
||
showToast(err.message || "加载资源选项失败", "error");
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!ignore) {
|
||
setOptionsLoading(false);
|
||
}
|
||
});
|
||
return () => {
|
||
ignore = true;
|
||
};
|
||
}, [activeAction, showToast]);
|
||
|
||
const openCreateGrant = () => {
|
||
setForm(emptyGrantForm());
|
||
setActiveAction("create");
|
||
};
|
||
|
||
const closeAction = () => {
|
||
setActiveAction("");
|
||
};
|
||
|
||
const submitGrant = async (event) => {
|
||
event.preventDefault();
|
||
if (!abilities.canCreateGrant) {
|
||
return;
|
||
}
|
||
setLoadingAction("grant-create");
|
||
try {
|
||
const parsedForm = parseForm(resourceGrantFormSchema, form);
|
||
const targetUser = await lookupResourceGrantTarget(parsedForm.targetUserId);
|
||
const targetUserId = normalizedResolvedUserId(targetUser);
|
||
const payload = buildGrantPayload(parsedForm, targetUserId);
|
||
if (payload.subjectType === "resource") {
|
||
for (const resourcePayload of payload.resources) {
|
||
await grantResource(resourcePayload);
|
||
}
|
||
} else {
|
||
await grantResourceGroup(payload.group);
|
||
}
|
||
showToast("资源已赠送", "success");
|
||
closeAction();
|
||
setForm(emptyGrantForm());
|
||
await result.reload();
|
||
} catch (err) {
|
||
showToast(err.message || "操作失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const revokeGrant = async (grant) => {
|
||
if (
|
||
!abilities.canRevokeGrant ||
|
||
!grant?.grantId ||
|
||
grant.status !== "succeeded" ||
|
||
!isRevocableGrantSubject(grant.grantSubjectType)
|
||
) {
|
||
return;
|
||
}
|
||
const accepted = await confirm({
|
||
title: "撤销资源赠送",
|
||
message:
|
||
"撤销后会按原发放内容扣回资源:素材扣减对应有效天数,金币扣减对应数量,最低扣至 0。该操作不可自动恢复。",
|
||
confirmText: "撤销",
|
||
tone: "danger",
|
||
});
|
||
if (!accepted) {
|
||
return;
|
||
}
|
||
setLoadingAction(`grant-revoke-${grant.grantId}`);
|
||
try {
|
||
await revokeResourceGrant(grant.grantId);
|
||
showToast("资源赠送已撤销", "success");
|
||
await result.reload();
|
||
} catch (err) {
|
||
showToast(err.message || "撤销失败", "error");
|
||
} finally {
|
||
setLoadingAction("");
|
||
}
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setTargetFilter(emptyUserIdentityFilter);
|
||
setStatus("");
|
||
setPage(1);
|
||
};
|
||
|
||
return {
|
||
abilities,
|
||
activeAction,
|
||
changeTargetFilter: resetSetter(setTargetFilter, setPage),
|
||
changeStatus: resetSetter(setStatus, setPage),
|
||
closeAction,
|
||
data: result.data || emptyData,
|
||
error: result.error,
|
||
form,
|
||
giftOptions,
|
||
giftTypeOptions,
|
||
groupOptions,
|
||
loading: result.loading,
|
||
loadingAction,
|
||
openCreateGrant,
|
||
optionsLoading,
|
||
page,
|
||
reload: result.reload,
|
||
resourceOptions,
|
||
resetFilters,
|
||
revokeGrant,
|
||
setForm,
|
||
setPage,
|
||
status,
|
||
submitGrant,
|
||
targetFilter,
|
||
};
|
||
}
|
||
|
||
function sharedPageState({ data, page, query, reload, result, setPage, setQuery }) {
|
||
return {
|
||
data: data || result.data || emptyData,
|
||
error: result.error,
|
||
loading: result.loading,
|
||
page,
|
||
query,
|
||
reload: reload || result.reload,
|
||
setPage,
|
||
changeQuery: resetSetter(setQuery, setPage),
|
||
};
|
||
}
|
||
|
||
function resetSetter(setValue, setPage) {
|
||
return (value) => {
|
||
setValue(value);
|
||
setPage(1);
|
||
};
|
||
}
|
||
|
||
function buildResourcePayload(form) {
|
||
const resourceType = form.resourceType;
|
||
const isCoin = resourceType === "coin";
|
||
const isLevelAvatarFrame = resourceType === "avatar_frame" && form.avatarFrameKind === "level";
|
||
const isLevelBadge = resourceType === "badge" && form.badgeKind === "level";
|
||
const animationUrl = form.animationUrl.trim();
|
||
return {
|
||
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
||
animationUrl,
|
||
assetUrl: animationUrl,
|
||
avatarFrameKind: resourceType === "avatar_frame" ? form.avatarFrameKind || "normal" : undefined,
|
||
badgeForm: resourceType === "badge" ? form.badgeForm : undefined,
|
||
badgeKind: resourceType === "badge" ? form.badgeKind || "normal" : undefined,
|
||
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
||
levelTrack: isLevelBadge || isLevelAvatarFrame ? form.levelTrack : undefined,
|
||
managerGrantEnabled: Boolean(form.managerGrantEnabled),
|
||
metadataJson: resourceMetadataJSON(resourceType, form.metadataJson),
|
||
name: form.name.trim(),
|
||
previewUrl: form.previewUrl.trim(),
|
||
priceType: form.priceType,
|
||
resourceCode: form.resourceCode.trim(),
|
||
resourceType,
|
||
sortOrder: 0,
|
||
status: form.enabled ? "active" : "disabled",
|
||
};
|
||
}
|
||
|
||
function badgeFormFromMetadata(metadataJson) {
|
||
if (!metadataJson) {
|
||
return "";
|
||
}
|
||
try {
|
||
const metadata = JSON.parse(metadataJson);
|
||
const badgeForm = String(metadata?.badge_form || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
if (badgeForm === "strip" || badgeForm === "tile") {
|
||
return badgeForm;
|
||
}
|
||
} catch {
|
||
return "";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function badgeKindFromMetadata(metadataJson) {
|
||
if (!metadataJson) {
|
||
return "";
|
||
}
|
||
try {
|
||
const metadata = JSON.parse(metadataJson);
|
||
const badgeKind = String(metadata?.badge_kind || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
if (badgeKind === "level") {
|
||
return "level";
|
||
}
|
||
} catch {
|
||
return "";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function avatarFrameKindFromMetadata(metadataJson) {
|
||
if (!metadataJson) {
|
||
return "";
|
||
}
|
||
try {
|
||
const metadata = JSON.parse(metadataJson);
|
||
const avatarFrameKind = String(metadata?.avatar_frame_kind || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
if (avatarFrameKind === "level" || avatarFrameKind === "normal") {
|
||
return avatarFrameKind;
|
||
}
|
||
} catch {
|
||
return "";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function levelTrackFromMetadata(metadataJson) {
|
||
if (!metadataJson) {
|
||
return "";
|
||
}
|
||
try {
|
||
const metadata = JSON.parse(metadataJson);
|
||
const levelTrack = String(metadata?.level_track || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
if (levelTrack === "wealth" || levelTrack === "game" || levelTrack === "charm") {
|
||
return levelTrack;
|
||
}
|
||
} catch {
|
||
return "";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function buildEmojiPackPayload(form) {
|
||
const regionIds = (form.regionIds || [])
|
||
.map(Number)
|
||
.filter((regionId) => Number.isInteger(regionId) && regionId >= 0);
|
||
return {
|
||
animationUrl: form.animationUrl.trim(),
|
||
category: form.category.trim(),
|
||
coverUrl: form.coverUrl.trim(),
|
||
name: form.name.trim(),
|
||
pricingType: form.pricingType,
|
||
regionIds: regionIds.length ? regionIds : [0],
|
||
sortOrder: Number(form.sortOrder || 0),
|
||
};
|
||
}
|
||
|
||
function mergeEmojiPackCategoryOptions(options = [], value = "默认") {
|
||
const merged = new Set(["默认"]);
|
||
options.forEach((option) => {
|
||
const category = String(option || "").trim();
|
||
if (category) {
|
||
merged.add(category);
|
||
}
|
||
});
|
||
const category = String(value || "").trim();
|
||
if (category) {
|
||
merged.add(category);
|
||
}
|
||
return Array.from(merged).sort((left, right) => left.localeCompare(right, "zh-Hans-CN"));
|
||
}
|
||
|
||
function buildResourceGroupPayload(form) {
|
||
return {
|
||
description: (form.description || "").trim(),
|
||
groupCode: form.groupCode.trim(),
|
||
items: form.items.map((item, index) => {
|
||
const sortOrder = Number(item.sortOrder || index);
|
||
if (item.itemType === "wallet_asset") {
|
||
return {
|
||
amount: Number(item.walletAssetAmount),
|
||
assetType: item.walletAssetType,
|
||
itemType: "wallet_asset",
|
||
sortOrder: Number.isInteger(sortOrder) ? sortOrder : index,
|
||
};
|
||
}
|
||
return {
|
||
durationDays: Number(item.durationDays),
|
||
itemType: "resource",
|
||
resourceId: Number(item.resourceId),
|
||
sortOrder: Number.isInteger(sortOrder) ? sortOrder : index,
|
||
};
|
||
}),
|
||
name: form.name.trim(),
|
||
sortOrder: Number(form.sortOrder || 0),
|
||
status: form.enabled ? "active" : "disabled",
|
||
};
|
||
}
|
||
|
||
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 {
|
||
chargeAssetType: form.chargeAssetType,
|
||
coinPrice: Number(form.coinPrice),
|
||
cpRelationType,
|
||
effectiveAtMs: Date.now(),
|
||
effectiveFromMs: datetimeLocalToMs(form.effectiveFrom),
|
||
effectiveToMs: datetimeLocalToMs(form.effectiveTo),
|
||
effectTypes: form.effectTypes || [],
|
||
giftId: form.giftId.trim(),
|
||
giftTypeCode: form.giftTypeCode,
|
||
name: form.name.trim(),
|
||
presentationJson: normalizeGiftPresentationJson(form.presentationJson, form.giftTypeCode, form.cpRelationType),
|
||
priceVersion: form.priceVersion.trim(),
|
||
regionIds: form.regionIds.map(Number),
|
||
resourceId: Number(form.resourceId),
|
||
sortOrder: Number(form.sortOrder || 0),
|
||
status: form.enabled ? "active" : "disabled",
|
||
};
|
||
}
|
||
|
||
function buildGiftInlineSortPayload(gift, sortOrder) {
|
||
const cpRelationType = cpRelationTypeFromGift(gift);
|
||
return {
|
||
chargeAssetType: gift.chargeAssetType || "COIN",
|
||
coinPrice: Number(gift.coinPrice || 0),
|
||
cpRelationType: gift.giftTypeCode === cpGiftTypeCode ? cpRelationType || defaultCPRelationType : "",
|
||
effectiveAtMs: Date.now(),
|
||
effectiveFromMs: Number(gift.effectiveFromMs || 0),
|
||
effectiveToMs: Number(gift.effectiveToMs || 0),
|
||
effectTypes: Array.isArray(gift.effectTypes) ? gift.effectTypes : [],
|
||
giftId: String(gift.giftId || "").trim(),
|
||
giftTypeCode: gift.giftTypeCode || "normal",
|
||
name: String(gift.name || gift.resource?.name || gift.giftId || "").trim(),
|
||
presentationJson: String(gift.presentationJson || "{}").trim() || "{}",
|
||
priceVersion: String(gift.priceVersion || "default").trim(),
|
||
regionIds: Array.isArray(gift.regionIds) && gift.regionIds.length ? gift.regionIds.map(Number) : [0],
|
||
resourceId: Number(gift.resourceId || 0),
|
||
sortOrder,
|
||
status: gift.status || "active",
|
||
};
|
||
}
|
||
|
||
function buildGiftTypePayload(form) {
|
||
return {
|
||
displayName: form.displayName.trim(),
|
||
sortOrder: Number(form.sortOrder || 0),
|
||
status: form.enabled ? "active" : "disabled",
|
||
tabName: form.tabName.trim(),
|
||
};
|
||
}
|
||
|
||
function giftTypeToForm(item) {
|
||
const normalized = normalizeGiftTypeOption(item);
|
||
return {
|
||
enabled: normalized.status ? normalized.status === "active" : true,
|
||
displayName: normalized.displayName || "",
|
||
sortOrder: normalized.sortOrder === 0 || normalized.sortOrder ? String(normalized.sortOrder) : "0",
|
||
tabKey: normalized.tabKey || "",
|
||
tabName: normalized.tabName || "",
|
||
};
|
||
}
|
||
|
||
function mergeGiftTypeOptions(items = []) {
|
||
const byCode = new Map(defaultGiftTypeOptions.map((item) => [item.tabKey, normalizeGiftTypeOption(item)]));
|
||
items.forEach((item) => {
|
||
const normalized = normalizeGiftTypeOption(item);
|
||
if (normalized.tabKey) {
|
||
byCode.set(normalized.tabKey, {
|
||
...byCode.get(normalized.tabKey),
|
||
...normalized,
|
||
});
|
||
}
|
||
});
|
||
return Array.from(byCode.values()).sort(
|
||
(left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0),
|
||
);
|
||
}
|
||
|
||
function normalizeGiftTypeOption(item = {}) {
|
||
const tabKey = String(item.typeCode || item.tabKey || "").trim();
|
||
const tabName = String(item.tabName || (item.typeCode ? item.tabKey : "") || "").trim();
|
||
const displayName = String(item.displayName || item.name || "").trim();
|
||
return {
|
||
...item,
|
||
displayName,
|
||
tabKey,
|
||
tabName,
|
||
};
|
||
}
|
||
|
||
function msToDatetimeLocal(value) {
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
const date = new Date(Number(value));
|
||
if (Number.isNaN(date.getTime())) {
|
||
return "";
|
||
}
|
||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||
return local.toISOString().slice(0, 16);
|
||
}
|
||
|
||
function datetimeLocalToMs(value) {
|
||
const trimmed = String(value || "").trim();
|
||
if (!trimmed) {
|
||
return 0;
|
||
}
|
||
const parsed = new Date(trimmed).getTime();
|
||
return Number.isFinite(parsed) ? parsed : 0;
|
||
}
|
||
|
||
function normalizedResolvedUserId(user) {
|
||
const userId = String(user?.userId || user?.user_id || "").trim();
|
||
if (!/^[1-9]\d*$/.test(userId)) {
|
||
throw new Error("用户不存在或用户 ID 无效");
|
||
}
|
||
return userId;
|
||
}
|
||
|
||
function buildGrantPayload(form, resolvedTargetUserId) {
|
||
const targetUserId = String(resolvedTargetUserId || "").trim();
|
||
const reason = form.reason.trim();
|
||
if (form.subjectType === "resource") {
|
||
const resourceIds = grantResourceIds(form);
|
||
return {
|
||
resources: resourceIds.map((resourceId, index) => ({
|
||
commandId: makeCommandId(`resource-grant-${resourceId}-${index}`),
|
||
durationMs: Math.round(Number(form.durationDays || 0) * dayMillis),
|
||
quantity: Number(form.quantity || 1),
|
||
reason,
|
||
resourceId: Number(resourceId),
|
||
targetUserId,
|
||
})),
|
||
subjectType: "resource",
|
||
};
|
||
}
|
||
return {
|
||
group: {
|
||
commandId: makeCommandId("resource-group-grant"),
|
||
groupId: Number(form.groupId),
|
||
reason,
|
||
targetUserId,
|
||
},
|
||
subjectType: "resource_group",
|
||
};
|
||
}
|
||
|
||
function grantResourceIds(form) {
|
||
if (Array.isArray(form.resourceIds) && form.resourceIds.length) {
|
||
return form.resourceIds;
|
||
}
|
||
return form.resourceId ? [form.resourceId] : [];
|
||
}
|
||
|
||
function isRevocableGrantSubject(subjectType) {
|
||
return subjectType === "resource" || subjectType === "resource_group";
|
||
}
|
||
|
||
function makeCommandId(prefix) {
|
||
return `${prefix}-${Date.now()}`;
|
||
}
|
||
|
||
function toGroupResourceOptions(items, selectedGroup) {
|
||
const selectedResources = (selectedGroup?.items || [])
|
||
.map((item) => item.resource)
|
||
.filter((resource) => resource?.resourceId);
|
||
return uniqueResources([...items, ...selectedResources]).filter(
|
||
(resource) => resource.resourceType !== "coin" && resource.status === "active" && resource.grantable !== false,
|
||
);
|
||
}
|
||
|
||
function toGiftResourceOptions(items, selectedGift) {
|
||
const selectedResource = selectedGift?.resource?.resourceId
|
||
? [selectedGift.resource]
|
||
: selectedGift?.resourceId
|
||
? [{ name: String(selectedGift.resourceId), resourceId: selectedGift.resourceId, resourceType: "gift" }]
|
||
: [];
|
||
return uniqueResources([...items, ...selectedResource]).filter(
|
||
(resource) => resource.resourceType === "gift" || resource.resourceId === selectedGift?.resourceId,
|
||
);
|
||
}
|
||
|
||
function toShopResourceOptions(items) {
|
||
return uniqueResources(items).filter(
|
||
(resource) =>
|
||
resourceShopSellableTypes.includes(resource.resourceType) &&
|
||
resource.status === "active" &&
|
||
resource.priceType === "coin" &&
|
||
Number(resource.coinPrice || 0) > 0,
|
||
);
|
||
}
|
||
|
||
function uniqueResources(resources) {
|
||
const seen = new Set();
|
||
return resources.filter((resource) => {
|
||
if (!resource?.resourceId || seen.has(resource.resourceId)) {
|
||
return false;
|
||
}
|
||
seen.add(resource.resourceId);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function formatDurationDays(durationMs) {
|
||
const days = Number(durationMs || 0) / dayMillis;
|
||
return Number.isInteger(days) ? String(days) : String(days.toFixed(1));
|
||
}
|