2026-07-06 12:43:25 +08:00

2422 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));
}