增加翻译
This commit is contained in:
parent
1a1c6ecc88
commit
38a417a8bd
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user