2026-06-04 11:04:48 +08:00

1595 lines
52 KiB
JavaScript

import { 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 { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createEmojiPack,
createGift,
createResource,
createResourceGroup,
disableResourceShopItem,
disableGift,
disableResource,
disableResourceGroup,
enableResourceShopItem,
enableGift,
enableResource,
enableResourceGroup,
grantResource,
grantResourceGroup,
listEmojiPackCategories,
listEmojiPacks,
listGiftTypes,
listGifts,
listResourceGrants,
listResourceGroups,
listResourceShopItems,
listResources,
lookupResourceGrantTarget,
updateGift,
updateGiftTypes,
updateResource,
updateResourceGroup,
upsertResourceShopItems,
} from "@/features/resources/api";
import { defaultGiftTypeOptions, resourceShopSellableTypes } from "@/features/resources/constants.js";
import { useResourceAbilities } from "@/features/resources/permissions.js";
import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js";
import {
emojiPackFormSchema,
giftFormSchema,
giftTypeConfigFormSchema,
resourceFormSchema,
resourceGrantFormSchema,
resourceGroupCreateFormSchema,
resourceShopItemsFormSchema,
} from "@/features/resources/schema.js";
const pageSize = 50;
const dayMillis = 24 * 60 * 60 * 1000;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyResourceForm = (resource = {}) => ({
animationUrl: resource.animationUrl || resource.assetUrl || "",
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.levelTrack || levelTrackFromMetadata(resource.metadataJson) || "" : "",
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 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 = {}) => ({
chargeAssetType: gift.chargeAssetType || "COIN",
coinPrice: gift.coinPrice === 0 || gift.coinPrice ? String(gift.coinPrice) : "",
effectTypes: gift.effectTypes || [],
effectiveFrom: msToDatetimeLocal(gift.effectiveFromMs),
effectiveTo: msToDatetimeLocal(gift.effectiveToMs),
enabled: gift.status ? gift.status === "active" : true,
giftId: gift.giftId || "",
giftPointAmount: gift.giftPointAmount === 0 || gift.giftPointAmount ? String(gift.giftPointAmount) : "0",
giftTypeCode: gift.giftTypeCode || "normal",
heatValue: gift.heatValue === 0 || gift.heatValue ? String(gift.heatValue) : "0",
name: gift.name || "",
presentationJson: gift.presentationJson || "{}",
priceVersion: gift.priceVersion || "default",
regionIds: (gift.regionIds?.length ? gift.regionIds : [0]).map(String),
resourceId: gift.resourceId ? String(gift.resourceId) : "",
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 applyGiftPriceDefaults(form, coinPrice) {
const priceValue = String(coinPrice ?? "");
const priceNumber = Number(priceValue);
return {
...form,
coinPrice: priceValue,
giftPointAmount: priceValue,
heatValue: priceValue.trim() && Number.isFinite(priceNumber) ? String(Math.ceil(priceNumber / 100)) : "",
};
}
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 { 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 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 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 () => {
if (nextStatus === "active") {
await enableResource(resource.resourceId);
} else {
await disableResource(resource.resourceId);
}
await result.reload();
},
);
};
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({ page, query, result, setPage, setQuery }),
abilities,
activeAction,
changeResourceType: resetSetter(setResourceType, setPage),
changeStatus: resetSetter(setStatus, setPage),
closeAction,
form,
loadingAction,
openCreateResource,
openEditResource,
resourceType,
selectedResource,
resetFilters,
setForm,
status,
submitResource,
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 [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 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);
listResources({ page: 1, page_size: 100, 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 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,
drawerOpen,
drawerQuery,
drawerResourceType,
filteredResourceOptions,
loadingAction,
openShopDrawer,
resourceOptionsLoading,
resourceType,
resetFilters,
selectedDrafts,
selectedResourceIds: new Set(Object.keys(draftByResourceId)),
status,
submitShopItems,
toggleDraftResource,
toggleShopItem,
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 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);
listResources({ page: 1, page_size: 100, 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]);
const openCreateGroup = () => {
setForm(emptyResourceGroupForm());
setSelectedGroup(null);
setActiveAction("create");
};
const openEditGroup = (group) => {
if (!group?.groupId) {
return;
}
setForm(emptyResourceGroupForm(group));
setSelectedGroup(group);
setActiveAction("edit");
};
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 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,
loadingAction,
openCreateGroup,
openEditGroup,
removeGroupItem,
resourceOptions,
resourceOptionsLoading,
selectedGroup,
resetFilters,
setForm,
status,
submitGroup,
toggleGroup,
updateGroupItem,
};
}
export function useGiftListPage() {
const abilities = useResourceAbilities();
const { showToast } = useToast();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [regionId, setRegionId] = 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 [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 [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
const { loadingRegions, regionOptions } = useRegionOptions();
const filters = useMemo(
() => ({
keyword: query,
region_id: regionId,
status,
}),
[query, regionId, status],
);
const result = usePaginatedQuery({
errorMessage: "加载礼物列表失败",
fetcher: listGifts,
filters,
page,
pageSize,
queryKey: ["gifts", filters, page],
});
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);
listResources({ page: 1, page_size: 100, resource_type: "gift" })
.then((data) => {
if (!ignore) {
setResourceOptions(toGiftResourceOptions(data.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 changeGiftPrice = (coinPrice) => {
setForm((currentForm) => applyGiftPriceDefaults(currentForm, coinPrice));
};
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");
try {
const payload = buildGiftPayload(parseForm(giftFormSchema, form));
if (editing) {
await updateGift(selectedGift.giftId, payload);
} else {
await createGift(payload);
}
showToast(editing ? "礼物已更新" : "礼物已创建", "success");
closeAction();
await result.reload();
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
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 resetFilters = () => {
setQuery("");
setStatus("");
setRegionId("");
setPage(1);
};
return {
...sharedPageState({ page, query, result, setPage, setQuery }),
abilities,
activeAction,
changeGiftPrice,
changeRegionId: resetSetter(setRegionId, setPage),
changeStatus: resetSetter(setStatus, setPage),
closeAction,
closeGiftTypeDialog,
form,
giftTypeDialogOpen,
giftTypeForm,
giftTypeOptions: giftTypes,
giftTypesLoading,
loadingAction,
loadingRegions,
openCreateGift,
openEditGift,
openGiftTypeDialog,
regionId,
regionOptions,
resourceOptions,
resourceOptionsLoading,
selectedGift,
resetFilters,
selectGiftResource,
setForm,
submitGiftTypes,
status,
submitGift,
toggleGift,
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 { showToast } = useToast();
const [query, setQuery] = useState("");
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,
target_user_id: query.trim(),
}),
[query, status],
);
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([
listResources({ page: 1, page_size: 500, status: "active" }),
listGifts({ page: 1, page_size: 500, status: "active" }),
listGiftTypes(),
listResourceGroups({ page: 1, page_size: 100, 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 resetFilters = () => {
setQuery("");
setStatus("");
setPage(1);
};
return {
...sharedPageState({ page, query, result, setPage, setQuery }),
abilities,
activeAction,
changeStatus: resetSetter(setStatus, setPage),
closeAction,
form,
giftOptions,
giftTypeOptions,
groupOptions,
loadingAction,
openCreateGrant,
optionsLoading,
resourceOptions,
resetFilters,
setForm,
status,
submitGrant,
};
}
function sharedPageState({ page, query, result, setPage, setQuery }) {
return {
data: result.data || emptyData,
error: result.error,
loading: result.loading,
page,
query,
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 animationUrl = form.animationUrl.trim();
return {
amount: isCoin ? Number(form.walletAssetAmount) : 0,
animationUrl,
assetUrl: animationUrl,
badgeForm: resourceType === "badge" ? form.badgeForm : undefined,
badgeKind: resourceType === "badge" ? form.badgeKind || "normal" : undefined,
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
levelTrack: resourceType === "badge" && form.badgeKind === "level" ? form.levelTrack : undefined,
metadataJson: resourceType === "profile_card" ? profileCardMetadataJSON(form.metadataJson) : undefined,
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 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 buildGiftPayload(form) {
return {
chargeAssetType: form.chargeAssetType,
coinPrice: Number(form.coinPrice),
effectiveAtMs: Date.now(),
effectiveFromMs: datetimeLocalToMs(form.effectiveFrom),
effectiveToMs: datetimeLocalToMs(form.effectiveTo),
effectTypes: form.effectTypes || [],
giftId: form.giftId.trim(),
giftPointAmount: Number(form.coinPrice || 0),
giftTypeCode: form.giftTypeCode,
heatValue: Number(form.heatValue || 0),
name: form.name.trim(),
presentationJson: form.presentationJson?.trim() || "{}",
priceVersion: form.priceVersion.trim(),
regionIds: form.regionIds.map(Number),
resourceId: Number(form.resourceId),
sortOrder: Number(form.sortOrder || 0),
status: form.enabled ? "active" : "disabled",
};
}
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 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));
}