diff --git a/src/features/resources/batchUpload.js b/src/features/resources/batchUpload.js index e857644..ffb1a04 100644 --- a/src/features/resources/batchUpload.js +++ b/src/features/resources/batchUpload.js @@ -16,8 +16,44 @@ const resourceTypeAliases = new Map([ ]); const pricedResourceTypes = new Set(["avatar_frame", "vehicle"]); +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, now = Date.now()) { +export function parseResourceFolderFiles(fileList) { const files = Array.from(fileList || []).filter((file) => file?.name && !file.name.startsWith(".")); const groups = new Map(); @@ -48,7 +84,7 @@ export function parseResourceFolderFiles(fileList, now = Date.now()) { .filter((item) => item.coverFile && item.animationFile) .map((item, index) => ({ ...item, - resourceCode: resourceCodeFor(item, index, now), + resourceCode: initialResourceCodeFor(item, index), })); return { @@ -75,6 +111,17 @@ export function resourcePlanToPayload(item) { }; } +export async function translateResourceCodes(resources) { + const translated = []; + for (let index = 0; index < resources.length; index += 1) { + translated.push({ + ...resources[index], + resourceCode: await resourceCodeFor(resources[index], index), + }); + } + return translated; +} + function parseResourceFilename(file) { const baseName = stripExtension(file.name); const parts = baseName @@ -128,9 +175,131 @@ function normalizeRole(value) { return ""; } -function resourceCodeFor(item, index, now) { - const suffix = Number(now || Date.now()).toString(36); - return `batch_${item.resourceType}_${suffix}_${String(index + 1).padStart(2, "0")}`; +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, 96); +} + +function fallbackResourceCode(index) { + return `resource_${String(index + 1).padStart(2, "0")}`; +} + +function hasChinese(value) { + return /[\u3400-\u9fff]/.test(String(value || "")); } function stripExtension(filename) { diff --git a/src/features/resources/batchUpload.test.js b/src/features/resources/batchUpload.test.js index 85f03f6..1cf670d 100644 --- a/src/features/resources/batchUpload.test.js +++ b/src/features/resources/batchUpload.test.js @@ -1,5 +1,9 @@ -import { expect, test } from "vitest"; -import { parseResourceFolderFiles, resourcePlanToPayload } from "./batchUpload.js"; +import { afterEach, expect, test, vi } from "vitest"; +import { parseResourceFolderFiles, resourcePlanToPayload, translateResourceCodes } from "./batchUpload.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); test("parses folder resource filenames into resource plans", () => { const files = [ @@ -22,6 +26,7 @@ test("parses folder resource filenames into resource plans", () => { ["夜色", "profile_card", 0, "tile"], ["律动", "mic_seat_animation", 0, "tile"], ]); + expect(plan.resources.map((item) => item.resourceCode)).toEqual(["星光", "守护", "夜色", "律动"]); expect(resourcePlanToPayload({ ...plan.resources[0], coverUrl: "cover", animationUrl: "animation" })).toMatchObject( { @@ -47,7 +52,51 @@ test("silently ignores invalid and unpaired material", () => { expect(plan.errors).toEqual([]); expect(plan.resources).toHaveLength(1); - expect(plan.resources[0]).toMatchObject({ name: "星光", resourceType: "avatar_frame" }); + expect(plan.resources[0]).toMatchObject({ name: "星光", resourceCode: "星光", resourceType: "avatar_frame" }); +}); + +test("translates chinese names to english resource codes with API fallback", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url) => { + if (String(url).includes("mymemory")) { + return new Response(JSON.stringify({ responseData: { translatedText: "Starlight" } })); + } + return new Response("{}", { status: 500 }); + }), + ); + + const translated = await translateResourceCodes([ + { + name: "星光", + resourceCode: "星光", + resourceType: "avatar_frame", + }, + { + name: "c11", + resourceCode: "c11", + resourceType: "avatar_frame", + }, + ]); + + expect(translated.map((item) => item.resourceCode)).toEqual(["starlight", "c11"]); +}); + +test("uses local dictionary when public translation APIs fail", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}", { status: 500 })), + ); + + const translated = await translateResourceCodes([ + { + name: "守护", + resourceCode: "守护", + resourceType: "badge", + }, + ]); + + expect(translated[0].resourceCode).toBe("guardian"); }); function file(name) { diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 967d0c5..6510615 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -42,6 +42,7 @@ import { parseResourceFolderFiles, resourceBatchUploadSize, resourcePlanToPayload, + translateResourceCodes, } from "@/features/resources/batchUpload.js"; import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js"; import { uploadImagesBatch } from "@/shared/api/upload"; @@ -188,12 +189,15 @@ export function ResourceListPage() { }); } - for (let index = 0; index < resources.length; index += 1) { - setBatchProgress({ label: `创建资源 ${index + 1}/${resources.length}`, running: true }); - await createResource(resourcePlanToPayload(resources[index])); + setBatchProgress({ label: "翻译资源编码", running: true }); + const translatedResources = await translateResourceCodes(resources); + + for (let index = 0; index < translatedResources.length; index += 1) { + setBatchProgress({ label: `创建资源 ${index + 1}/${translatedResources.length}`, running: true }); + await createResource(resourcePlanToPayload(translatedResources[index])); } - showToast(`批量上传完成,共创建 ${resources.length} 个资源`, "success"); + showToast(`批量上传完成,共创建 ${translatedResources.length} 个资源`, "success"); setBatchOpen(false); setBatchPlan({ errors: [], resources: [] }); await page.reload();