2026-06-09 13:14:11 +08:00

1858 lines
62 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 { 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 {
cpRelationTypeLabels,
cpRelationTypeOptions,
defaultGiftTypeOptions,
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,
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 || "",
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) || ""
: "",
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 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) : "",
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 ?? "");
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) {
const items = [];
let total = Number.POSITIVE_INFINITY;
// 后台会把选项接口的 page_size 截断到 100抽屉候选项必须按真实分页拉全避免只展示第一页资源。
for (let page = 1; page <= optionMaxPages; 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 { 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 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,
});
await updateResource(resource.resourceId, buildResourcePayload(formValue));
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,
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 [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);
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 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);
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]);
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 [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 [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);
fetchAllOptionPages(listResources, { 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 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");
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 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,
form,
giftSortSavingIds,
giftTypeCode,
giftTypeDialogOpen,
giftTypeForm,
giftTypeOptions: giftTypes,
giftTypesLoading,
loadingAction,
loadingRegions,
openCreateGift,
openEditGift,
openGiftTypeDialog,
regionId,
regionOptions,
resourceOptions,
resourceOptionsLoading,
selectedGift,
resetFilters,
selectGiftResource,
setForm,
saveGiftSortOrder,
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([
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 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,
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 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) {
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 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));
}