523 lines
21 KiB
JavaScript
523 lines
21 KiB
JavaScript
import { z } from "zod";
|
|
import { cpRelationTypeOptions, resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js";
|
|
import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js";
|
|
|
|
const resourceTypes = [
|
|
"avatar_frame",
|
|
"profile_card",
|
|
"coin",
|
|
"vehicle",
|
|
"chat_bubble",
|
|
"badge",
|
|
"floating_screen",
|
|
"gift",
|
|
"mic_seat_icon",
|
|
"mic_seat_animation",
|
|
"emoji_pack",
|
|
];
|
|
const walletAssetTypes = ["COIN"];
|
|
const resourcePriceTypes = ["coin", "free"];
|
|
const avatarFrameKinds = ["normal", "level"];
|
|
const badgeForms = ["strip", "tile"];
|
|
const badgeKinds = ["normal", "level"];
|
|
const badgeLevelTracks = ["wealth", "game", "charm"];
|
|
const resourceShopDurations = ["1", "3", "7"];
|
|
const cpRelationTypes = cpRelationTypeOptions.map(([value]) => value);
|
|
const optionalWalletAssetTypeSchema = z
|
|
.preprocess((value) => {
|
|
if (typeof value !== "string") {
|
|
return value;
|
|
}
|
|
const normalized = value.trim().toUpperCase();
|
|
return normalized || undefined;
|
|
}, z.string().optional())
|
|
.refine((value) => !value || walletAssetTypes.includes(value), "请选择金币");
|
|
|
|
export const resourceCreateFormSchema = z
|
|
.object({
|
|
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
|
avatarFrameKind: z.enum(avatarFrameKinds).optional(),
|
|
badgeForm: z.enum(badgeForms).optional(),
|
|
badgeKind: z.enum(badgeKinds).optional(),
|
|
enabled: z.boolean(),
|
|
levelTrack: z.enum(badgeLevelTracks).optional().or(z.literal("")),
|
|
managerGrantEnabled: z.boolean(),
|
|
metadataJson: z.string().trim().max(4096, "资源元数据不能超过 4096 个字符").optional(),
|
|
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
|
coinPrice: z.union([z.string(), z.number()]).optional(),
|
|
previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"),
|
|
priceType: z.enum(resourcePriceTypes, "请选择价格类型"),
|
|
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
|
|
resourceType: z.enum(resourceTypes, "请选择资源类型"),
|
|
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
|
|
})
|
|
.superRefine((value, context) => {
|
|
if (value.resourceType !== "coin") {
|
|
if (value.resourceType === "badge" && !value.badgeForm) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择徽章属性",
|
|
path: ["badgeForm"],
|
|
});
|
|
}
|
|
if (value.resourceType === "badge" && value.badgeKind === "level" && !value.levelTrack) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择等级徽章所属等级",
|
|
path: ["levelTrack"],
|
|
});
|
|
}
|
|
if (value.resourceType === "avatar_frame" && value.avatarFrameKind === "level" && !value.levelTrack) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择等级头像框所属等级",
|
|
path: ["levelTrack"],
|
|
});
|
|
}
|
|
} else {
|
|
const amount = Number(value.walletAssetAmount);
|
|
if (!Number.isInteger(amount) || amount <= 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请输入金币数量",
|
|
path: ["walletAssetAmount"],
|
|
});
|
|
}
|
|
}
|
|
if (value.priceType === "coin") {
|
|
const coinPrice = Number(value.coinPrice);
|
|
if (!Number.isInteger(coinPrice) || coinPrice <= 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请输入资源价格",
|
|
path: ["coinPrice"],
|
|
});
|
|
}
|
|
}
|
|
if (hasUnconfirmedMp4Layout(value.metadataJson)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请确认 MP4 透明布局",
|
|
path: ["metadataJson"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const resourceFormSchema = resourceCreateFormSchema;
|
|
|
|
export const emojiPackFormSchema = z.object({
|
|
animationUrl: z.string().trim().min(1, "请上传动效图").max(512, "动效图不能超过 512 个字符"),
|
|
category: z.string().trim().min(1, "请输入分类").max(64, "分类不能超过 64 个字符"),
|
|
coverUrl: z.string().trim().min(1, "请上传封面图").max(512, "封面图不能超过 512 个字符"),
|
|
name: z.string().trim().min(1, "请输入名称").max(128, "名称不能超过 128 个字符"),
|
|
pricingType: z.enum(["free", "paid"]),
|
|
regionIds: z.array(z.union([z.string(), z.number()])).optional(),
|
|
sortOrder: z.union([z.string(), z.number()]).optional(),
|
|
});
|
|
|
|
const groupItemSchema = z.object({
|
|
durationDays: z.union([z.string(), z.number()]).optional(),
|
|
itemType: z.enum(["resource", "wallet_asset"]),
|
|
resourceId: z.union([z.string(), z.number()]).optional(),
|
|
sortOrder: z.union([z.string(), z.number()]).optional(),
|
|
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
|
|
walletAssetType: optionalWalletAssetTypeSchema,
|
|
});
|
|
|
|
export const resourceGroupCreateFormSchema = z
|
|
.object({
|
|
description: z.string().trim().max(512, "资源组说明不能超过 512 个字符").optional(),
|
|
enabled: z.boolean(),
|
|
groupCode: z.string().trim().min(1, "请输入资源组编码").max(96, "资源组编码不能超过 96 个字符"),
|
|
items: z.array(groupItemSchema).min(1, "请至少配置一个资源组条目"),
|
|
name: z.string().trim().min(1, "请输入资源组名称").max(128, "资源组名称不能超过 128 个字符"),
|
|
sortOrder: z.union([z.string(), z.number()]).optional(),
|
|
})
|
|
.superRefine((value, context) => {
|
|
const resourceIds = new Set();
|
|
const walletAssets = new Set();
|
|
value.items.forEach((item, index) => {
|
|
if (item.itemType === "wallet_asset") {
|
|
const amount = Number(item.walletAssetAmount);
|
|
if (!item.walletAssetType || !Number.isInteger(amount) || amount <= 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请输入金币数量",
|
|
path: ["items", index, "walletAssetAmount"],
|
|
});
|
|
}
|
|
if (item.walletAssetType) {
|
|
if (walletAssets.has(item.walletAssetType)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "同一种钱包资产不能重复配置",
|
|
path: ["items", index, "walletAssetType"],
|
|
});
|
|
}
|
|
walletAssets.add(item.walletAssetType);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const resourceId = Number(item.resourceId);
|
|
const durationDays = Number(item.durationDays);
|
|
if (!Number.isInteger(resourceId) || resourceId <= 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择资源",
|
|
path: ["items", index, "resourceId"],
|
|
});
|
|
}
|
|
if (!Number.isInteger(durationDays) || durationDays <= 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请输入有效天数",
|
|
path: ["items", index, "durationDays"],
|
|
});
|
|
}
|
|
if (resourceIds.has(resourceId)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "同一个资源不能重复配置",
|
|
path: ["items", index, "resourceId"],
|
|
});
|
|
}
|
|
resourceIds.add(resourceId);
|
|
});
|
|
});
|
|
|
|
const identityAutoGrantItemSchema = z.object({
|
|
identityType: z.enum(resourceIdentityAutoGrantTypes, "请选择身份"),
|
|
resourceGroupId: z.union([z.string(), z.number()]).optional(),
|
|
});
|
|
|
|
export const resourceIdentityAutoGrantFormSchema = z
|
|
.object({
|
|
items: z.array(identityAutoGrantItemSchema),
|
|
})
|
|
.superRefine((value, context) => {
|
|
const requiredIdentityTypes = new Set(resourceIdentityAutoGrantTypes);
|
|
const seenIdentityTypes = new Set();
|
|
|
|
if (value.items.length !== resourceIdentityAutoGrantTypes.length) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "必须配置所有身份",
|
|
path: ["items"],
|
|
});
|
|
}
|
|
|
|
value.items.forEach((item, index) => {
|
|
if (seenIdentityTypes.has(item.identityType)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "身份不能重复配置",
|
|
path: ["items", index, "identityType"],
|
|
});
|
|
}
|
|
seenIdentityTypes.add(item.identityType);
|
|
requiredIdentityTypes.delete(item.identityType);
|
|
|
|
const resourceGroupId =
|
|
item.resourceGroupId === undefined || item.resourceGroupId === "" ? 0 : Number(item.resourceGroupId);
|
|
if (!Number.isInteger(resourceGroupId) || resourceGroupId < 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "资源组必须是有效资源组",
|
|
path: ["items", index, "resourceGroupId"],
|
|
});
|
|
}
|
|
});
|
|
|
|
requiredIdentityTypes.forEach((identityType) => {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "必须配置所有身份",
|
|
path: ["items", identityType],
|
|
});
|
|
});
|
|
});
|
|
|
|
export const giftFormSchema = z
|
|
.object({
|
|
chargeAssetType: z.enum(["COIN"]),
|
|
coinPrice: z.union([z.string(), z.number()]),
|
|
cpRelationType: z.string().trim().optional(),
|
|
effectTypes: z.array(z.enum(["animation", "music", "global_broadcast"])).optional(),
|
|
effectiveFrom: z.string().optional(),
|
|
effectiveTo: z.string().optional(),
|
|
enabled: z.boolean(),
|
|
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
|
|
giftTypeCode: z
|
|
.string()
|
|
.trim()
|
|
.min(1, "请选择礼物类型")
|
|
.max(32, "礼物类型不能超过 32 个字符")
|
|
.regex(/^[a-z][a-z0-9_]*$/, "礼物类型只能使用小写字母、数字和下划线"),
|
|
name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"),
|
|
presentationJson: z.string().trim().optional(),
|
|
priceVersion: z.string().trim().min(1, "请输入价格版本").max(64, "价格版本不能超过 64 个字符"),
|
|
regionIds: z.array(z.union([z.string(), z.number()])).min(1, "请选择区域"),
|
|
resourceId: z.union([z.string(), z.number()]),
|
|
sortOrder: z.union([z.string(), z.number()]).optional(),
|
|
})
|
|
.superRefine((value, context) => {
|
|
const resourceId = Number(value.resourceId);
|
|
const coinPrice = Number(value.coinPrice);
|
|
const effectiveFromMs = datetimeLocalToMs(value.effectiveFrom);
|
|
const effectiveToMs = datetimeLocalToMs(value.effectiveTo);
|
|
|
|
if (!Number.isInteger(resourceId) || resourceId <= 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择礼物资源",
|
|
path: ["resourceId"],
|
|
});
|
|
}
|
|
if (!Number.isInteger(coinPrice) || coinPrice < 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请输入价格",
|
|
path: ["coinPrice"],
|
|
});
|
|
}
|
|
if (value.giftTypeCode === "cp" && !cpRelationTypes.includes(value.cpRelationType)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择关系类型",
|
|
path: ["cpRelationType"],
|
|
});
|
|
}
|
|
if (effectiveFromMs < 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择有效开始时间",
|
|
path: ["effectiveFrom"],
|
|
});
|
|
}
|
|
if (effectiveToMs < 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择有效结束时间",
|
|
path: ["effectiveTo"],
|
|
});
|
|
}
|
|
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "有效结束时间必须晚于开始时间",
|
|
path: ["effectiveTo"],
|
|
});
|
|
}
|
|
value.regionIds.forEach((regionId, index) => {
|
|
const normalizedRegionId = Number(regionId);
|
|
if (!Number.isInteger(normalizedRegionId) || normalizedRegionId < 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择区域",
|
|
path: ["regionIds", index],
|
|
});
|
|
}
|
|
});
|
|
if (value.presentationJson) {
|
|
try {
|
|
JSON.parse(value.presentationJson);
|
|
} catch {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "展示配置必须是合法 JSON",
|
|
path: ["presentationJson"],
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
const giftTypeTabKeySchema = z
|
|
.string()
|
|
.trim()
|
|
.min(1, "Tab Key 不能为空")
|
|
.max(32, "Tab Key 不能超过 32 个字符")
|
|
.regex(/^[a-z][a-z0-9_]*$/, "Tab Key 只能使用小写字母、数字和下划线");
|
|
|
|
export const giftTypeConfigFormSchema = z.object({
|
|
items: z
|
|
.array(
|
|
z.object({
|
|
enabled: z.boolean(),
|
|
displayName: z.string().trim().min(1, "请输入显示名称").max(64, "显示名称不能超过 64 个字符"),
|
|
sortOrder: z.union([z.string(), z.number()]).optional(),
|
|
tabKey: giftTypeTabKeySchema,
|
|
tabName: z.string().trim().min(1, "请输入 Tab Name").max(32, "Tab Name 不能超过 32 个字符"),
|
|
}),
|
|
)
|
|
.min(1, "请至少保留一个礼物类型"),
|
|
});
|
|
|
|
export const resourceShopItemsFormSchema = z
|
|
.object({
|
|
items: z
|
|
.array(
|
|
z.object({
|
|
durationDays: z.union([z.string(), z.number()]),
|
|
effectiveFrom: z.string().optional(),
|
|
effectiveTo: z.string().optional(),
|
|
resourceId: z.union([z.string(), z.number()]),
|
|
sortOrder: z.union([z.string(), z.number()]).optional(),
|
|
}),
|
|
)
|
|
.min(1, "请至少选择一个资源"),
|
|
})
|
|
.superRefine((value, context) => {
|
|
const resourceIds = new Set();
|
|
value.items.forEach((item, index) => {
|
|
const resourceId = Number(item.resourceId);
|
|
const durationDays = String(item.durationDays || "").trim();
|
|
const effectiveFromMs = datetimeLocalToMs(item.effectiveFrom);
|
|
const effectiveToMs = datetimeLocalToMs(item.effectiveTo);
|
|
const sortOrder = Number(item.sortOrder || 0);
|
|
|
|
if (!Number.isInteger(resourceId) || resourceId <= 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择资源",
|
|
path: ["items", index, "resourceId"],
|
|
});
|
|
}
|
|
if (resourceIds.has(resourceId)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "同一个资源不能重复添加",
|
|
path: ["items", index, "resourceId"],
|
|
});
|
|
}
|
|
resourceIds.add(resourceId);
|
|
if (!resourceShopDurations.includes(durationDays)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "售卖天数只能选择 1、3、7 天",
|
|
path: ["items", index, "durationDays"],
|
|
});
|
|
}
|
|
if (effectiveFromMs < 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择生效开始时间",
|
|
path: ["items", index, "effectiveFrom"],
|
|
});
|
|
}
|
|
if (effectiveToMs < 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择生效结束时间",
|
|
path: ["items", index, "effectiveTo"],
|
|
});
|
|
}
|
|
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "生效结束时间必须晚于开始时间",
|
|
path: ["items", index, "effectiveTo"],
|
|
});
|
|
}
|
|
if (!Number.isInteger(sortOrder) || sortOrder < 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "排序不能小于 0",
|
|
path: ["items", index, "sortOrder"],
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
function datetimeLocalToMs(value) {
|
|
const trimmed = String(value || "").trim();
|
|
if (!trimmed) {
|
|
return 0;
|
|
}
|
|
const parsed = new Date(trimmed).getTime();
|
|
return Number.isFinite(parsed) ? parsed : -1;
|
|
}
|
|
|
|
export const resourceGrantFormSchema = z
|
|
.object({
|
|
durationDays: z.union([z.string(), z.number()]).optional(),
|
|
groupId: z.union([z.string(), z.number()]).optional(),
|
|
quantity: z.union([z.string(), z.number()]).optional(),
|
|
reason: z.string().trim().min(1, "请输入原因").max(240, "原因不能超过 240 个字符"),
|
|
resourceId: z.union([z.string(), z.number()]).optional(),
|
|
resourceIds: z.array(z.union([z.string(), z.number()])).optional(),
|
|
subjectType: z.enum(["resource", "resource_group", "vip"]),
|
|
targetUserId: z.union([z.string(), z.number()]),
|
|
vipLevel: z.union([z.string(), z.number()]).optional(),
|
|
})
|
|
.superRefine((value, context) => {
|
|
const targetUserId = String(value.targetUserId || "").trim();
|
|
const resourceIds = grantResourceIds(value);
|
|
const groupId = Number(value.groupId || 0);
|
|
const quantity = Number(value.quantity || 1);
|
|
const durationDays = Number(value.durationDays || 0);
|
|
const vipLevel = Number(value.vipLevel || 0);
|
|
|
|
if (!/^[1-9]\d*$/.test(targetUserId)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请输入用户 ID",
|
|
path: ["targetUserId"],
|
|
});
|
|
}
|
|
if (value.subjectType === "resource") {
|
|
if (!resourceIds.length) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择资源",
|
|
path: ["resourceIds"],
|
|
});
|
|
}
|
|
resourceIds.forEach((resourceIdValue, index) => {
|
|
const resourceId = Number(resourceIdValue || 0);
|
|
if (!Number.isInteger(resourceId) || resourceId <= 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择资源",
|
|
path: ["resourceIds", index],
|
|
});
|
|
}
|
|
});
|
|
if (!Number.isInteger(quantity) || quantity <= 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请输入数量",
|
|
path: ["quantity"],
|
|
});
|
|
}
|
|
if (!Number.isFinite(durationDays) || durationDays < 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请输入有效天数",
|
|
path: ["durationDays"],
|
|
});
|
|
}
|
|
}
|
|
if (value.subjectType === "resource_group" && (!Number.isInteger(groupId) || groupId <= 0)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择资源组",
|
|
path: ["groupId"],
|
|
});
|
|
}
|
|
if (value.subjectType === "vip" && (!Number.isInteger(vipLevel) || vipLevel <= 0)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "请选择VIP等级",
|
|
path: ["vipLevel"],
|
|
});
|
|
}
|
|
});
|
|
|
|
function grantResourceIds(value) {
|
|
if (Array.isArray(value.resourceIds) && value.resourceIds.length) {
|
|
return value.resourceIds;
|
|
}
|
|
return value.resourceId ? [value.resourceId] : [];
|
|
}
|