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

337 lines
10 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 { 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(/\.[^.]+$/, "");
}