337 lines
10 KiB
JavaScript
337 lines
10 KiB
JavaScript
import { resourceMetadataJSON } from "@/features/resources/resourceMetadata.js";
|
||
|
||
export const resourceBatchUploadSize = 10;
|
||
|
||
const resourceCodeMaxLength = 96;
|
||
const resourceCodeSuffixLength = 3;
|
||
const randomResourceCodeChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||
const resourceTypeAliases = new Map([
|
||
["头像框", "avatar_frame"],
|
||
["坐骑", "vehicle"],
|
||
["座驾", "vehicle"],
|
||
["气泡", "chat_bubble"],
|
||
["勋章", "badge"],
|
||
["徽章", "badge"],
|
||
["飘窗", "floating_screen"],
|
||
["飘屏", "floating_screen"],
|
||
["mic声波", "mic_seat_animation"],
|
||
["麦位动效", "mic_seat_animation"],
|
||
["背景卡", "profile_card"],
|
||
["资料卡", "profile_card"],
|
||
["礼物", "gift"],
|
||
]);
|
||
|
||
const pricedResourceTypes = new Set(["avatar_frame", "vehicle", "gift"]);
|
||
const translationTimeoutMs = 2500;
|
||
const localNameTranslations = new Map([
|
||
["星光", "starlight"],
|
||
["星辰", "stars"],
|
||
["守护", "guardian"],
|
||
["夜色", "nightfall"],
|
||
["律动", "rhythm"],
|
||
["清新", "fresh"],
|
||
["梦幻", "dream"],
|
||
["浪漫", "romance"],
|
||
["彩虹", "rainbow"],
|
||
["月光", "moonlight"],
|
||
["阳光", "sunshine"],
|
||
["火焰", "flame"],
|
||
["冰雪", "snow"],
|
||
["海洋", "ocean"],
|
||
["森林", "forest"],
|
||
["皇冠", "crown"],
|
||
["皇家", "royal"],
|
||
["黄金", "gold"],
|
||
["白银", "silver"],
|
||
["青铜", "bronze"],
|
||
["钻石", "diamond"],
|
||
["爱心", "heart"],
|
||
["玫瑰", "rose"],
|
||
["天使", "angel"],
|
||
["恶魔", "devil"],
|
||
["贵族", "noble"],
|
||
["幸运", "lucky"],
|
||
["超级", "super"],
|
||
["炫彩", "colorful"],
|
||
["流光", "glow"],
|
||
["闪耀", "sparkle"],
|
||
["典藏", "classic"],
|
||
["限定", "limited"],
|
||
]);
|
||
|
||
export function parseResourceFolderFiles(fileList) {
|
||
const files = Array.from(fileList || []).filter((file) => file?.name && !file.name.startsWith("."));
|
||
const groups = new Map();
|
||
|
||
files.forEach((file) => {
|
||
const parsed = parseResourceFilename(file);
|
||
if (!parsed.ok) {
|
||
return;
|
||
}
|
||
|
||
const key = [parsed.resourceType, parsed.name, parsed.price, parsed.badgeForm].join("|");
|
||
const current = groups.get(key) || {
|
||
badgeForm: parsed.badgeForm,
|
||
coverFile: null,
|
||
animationFile: null,
|
||
name: parsed.name,
|
||
price: parsed.price,
|
||
resourceType: parsed.resourceType,
|
||
};
|
||
|
||
const fileKey = parsed.role === "cover" ? "coverFile" : "animationFile";
|
||
if (!current[fileKey]) {
|
||
current[fileKey] = file;
|
||
}
|
||
groups.set(key, current);
|
||
});
|
||
|
||
const resources = Array.from(groups.values())
|
||
.filter((item) => item.coverFile && item.animationFile)
|
||
.map((item, index) => ({
|
||
...item,
|
||
resourceCode: initialResourceCodeFor(item, index),
|
||
}));
|
||
|
||
return {
|
||
errors: [],
|
||
resources,
|
||
};
|
||
}
|
||
|
||
export function resourcePlanToPayload(item) {
|
||
const coinPrice = pricedResourceTypes.has(item.resourceType) ? Number(item.price || 0) : 0;
|
||
return {
|
||
amount: 0,
|
||
animationUrl: item.animationUrl,
|
||
assetUrl: item.animationUrl,
|
||
badgeForm: item.resourceType === "badge" ? item.badgeForm || "tile" : undefined,
|
||
badgeKind: item.resourceType === "badge" ? "normal" : undefined,
|
||
coinPrice,
|
||
managerGrantEnabled: true,
|
||
metadataJson: resourceMetadataJSON(item.resourceType, item.metadataJson),
|
||
name: item.name,
|
||
previewUrl: item.coverUrl,
|
||
priceType: coinPrice > 0 ? "coin" : "free",
|
||
resourceCode: item.resourceCode,
|
||
resourceType: item.resourceType,
|
||
sortOrder: 0,
|
||
status: "active",
|
||
};
|
||
}
|
||
|
||
export async function translateResourceCodes(resources, options = {}) {
|
||
const translated = [];
|
||
for (let index = 0; index < resources.length; index += 1) {
|
||
const resourceCode = await resourceCodeFor(resources[index], index);
|
||
translated.push({
|
||
...resources[index],
|
||
resourceCode: appendRandomResourceCodeSuffix(resourceCode, index),
|
||
});
|
||
options.onProgress?.({
|
||
completed: index + 1,
|
||
index,
|
||
resource: translated[index],
|
||
total: resources.length,
|
||
});
|
||
}
|
||
return translated;
|
||
}
|
||
|
||
function parseResourceFilename(file) {
|
||
const baseName = stripExtension(file.name);
|
||
const parts = baseName
|
||
.split("_")
|
||
.map((part) => part.trim())
|
||
.filter(Boolean);
|
||
if (parts.length < 3) {
|
||
return { ok: false };
|
||
}
|
||
|
||
const role = normalizeRole(parts[parts.length - 1]);
|
||
if (!role) {
|
||
return { ok: false };
|
||
}
|
||
|
||
const resourceType = resourceTypeAliases.get(parts[0].toLowerCase()) || resourceTypeAliases.get(parts[0]);
|
||
if (!resourceType) {
|
||
return { ok: false };
|
||
}
|
||
|
||
const name = parts[1];
|
||
if (!name) {
|
||
return { ok: false };
|
||
}
|
||
|
||
let price = 0;
|
||
let badgeForm = "tile";
|
||
if (pricedResourceTypes.has(resourceType)) {
|
||
price = Number(parts[2]);
|
||
if (!Number.isInteger(price) || price <= 0) {
|
||
return { ok: false };
|
||
}
|
||
} else if (resourceType === "badge") {
|
||
const formToken = parts.length >= 4 ? parts[2] : "";
|
||
badgeForm = formToken === "长" || formToken === "long" || formToken === "strip" ? "strip" : "tile";
|
||
}
|
||
|
||
return { ok: true, badgeForm, name, price, resourceType, role };
|
||
}
|
||
|
||
function normalizeRole(value) {
|
||
const role = String(value || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
if (role === "cover" || role === "封面") {
|
||
return "cover";
|
||
}
|
||
if (role === "animation" || role === "anim" || role === "动效") {
|
||
return "animation";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function resourceCodeFor(item, index) {
|
||
const name = String(item.name || "").trim();
|
||
if (!name) {
|
||
return fallbackResourceCode(index);
|
||
}
|
||
if (!hasChinese(name)) {
|
||
return slugifyResourceCode(name) || fallbackResourceCode(index);
|
||
}
|
||
|
||
const translated = await translateChineseToEnglish(name);
|
||
return slugifyResourceCode(translated) || fallbackResourceCode(index);
|
||
}
|
||
|
||
function initialResourceCodeFor(item, index) {
|
||
return String(item.name || "").trim() || fallbackResourceCode(index);
|
||
}
|
||
|
||
async function translateChineseToEnglish(text) {
|
||
const translators = [
|
||
translateWithMyMemory,
|
||
(value) => translateWithLingva(value, "https://lingva.ml"),
|
||
(value) => translateWithLingva(value, "https://lingva.lunar.icu"),
|
||
(value) => translateWithLibreTranslate(value, "https://translate.argosopentech.com"),
|
||
translateWithLocalDictionary,
|
||
];
|
||
|
||
for (const translator of translators) {
|
||
try {
|
||
const translated = String((await translator(text)) || "").trim();
|
||
if (translated && !hasChinese(translated)) {
|
||
return translated;
|
||
}
|
||
} catch {
|
||
// Public translation endpoints are best-effort; continue to the next provider.
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function translateWithMyMemory(text) {
|
||
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=zh-CN|en`;
|
||
const data = await fetchJSON(url);
|
||
return data?.responseData?.translatedText || "";
|
||
}
|
||
|
||
async function translateWithLingva(text, host) {
|
||
const url = `${host}/api/v1/zh/en/${encodeURIComponent(text)}`;
|
||
const data = await fetchJSON(url);
|
||
return data?.translation || "";
|
||
}
|
||
|
||
async function translateWithLibreTranslate(text, host) {
|
||
const body = new URLSearchParams({
|
||
format: "text",
|
||
q: text,
|
||
source: "zh",
|
||
target: "en",
|
||
});
|
||
const data = await fetchJSON(`${host}/translate`, {
|
||
body,
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
method: "POST",
|
||
});
|
||
return data?.translatedText || "";
|
||
}
|
||
|
||
function translateWithLocalDictionary(text) {
|
||
let index = 0;
|
||
const parts = [];
|
||
const keys = Array.from(localNameTranslations.keys()).sort((left, right) => right.length - left.length);
|
||
while (index < text.length) {
|
||
const rest = text.slice(index);
|
||
const matched = keys.find((key) => rest.startsWith(key));
|
||
if (matched) {
|
||
parts.push(localNameTranslations.get(matched));
|
||
index += matched.length;
|
||
continue;
|
||
}
|
||
|
||
const char = text[index];
|
||
if (/[a-z0-9]/i.test(char)) {
|
||
parts.push(char);
|
||
index += 1;
|
||
continue;
|
||
}
|
||
if (hasChinese(char)) {
|
||
return "";
|
||
}
|
||
index += 1;
|
||
}
|
||
return parts.join(" ");
|
||
}
|
||
|
||
async function fetchJSON(url, options = {}) {
|
||
const controller = new AbortController();
|
||
const timeout = window.setTimeout(() => controller.abort(), translationTimeoutMs);
|
||
try {
|
||
const response = await fetch(url, { ...options, signal: controller.signal });
|
||
if (!response.ok) {
|
||
return null;
|
||
}
|
||
return response.json();
|
||
} finally {
|
||
window.clearTimeout(timeout);
|
||
}
|
||
}
|
||
|
||
function slugifyResourceCode(value) {
|
||
return String(value || "")
|
||
.normalize("NFKD")
|
||
.replace(/[\u0300-\u036f]/g, "")
|
||
.replace(/['’]/g, "")
|
||
.replace(/&/g, " and ")
|
||
.replace(/[^a-z0-9]+/gi, "_")
|
||
.replace(/^_+|_+$/g, "")
|
||
.toLowerCase()
|
||
.slice(0, resourceCodeMaxLength);
|
||
}
|
||
|
||
function fallbackResourceCode(index) {
|
||
return `resource_${String(index + 1).padStart(2, "0")}`;
|
||
}
|
||
|
||
function appendRandomResourceCodeSuffix(value, index) {
|
||
const suffix = `_${randomResourceCodeChar()}${randomResourceCodeChar()}`;
|
||
const maxBaseLength = resourceCodeMaxLength - resourceCodeSuffixLength;
|
||
const base = String(value || fallbackResourceCode(index))
|
||
.slice(0, maxBaseLength)
|
||
.replace(/_+$/g, "");
|
||
return `${base || fallbackResourceCode(index)}${suffix}`;
|
||
}
|
||
|
||
function randomResourceCodeChar() {
|
||
return randomResourceCodeChars[Math.floor(Math.random() * randomResourceCodeChars.length)];
|
||
}
|
||
|
||
function hasChinese(value) {
|
||
return /[\u3400-\u9fff]/.test(String(value || ""));
|
||
}
|
||
|
||
function stripExtension(filename) {
|
||
return String(filename || "").replace(/\.[^.]+$/, "");
|
||
}
|