Compare commits

...

4 Commits

Author SHA1 Message Date
zhx
38a417a8bd 增加翻译 2026-06-02 16:57:02 +08:00
zhx
1a1c6ecc88 Merge branch 'main' into test 2026-06-02 15:54:57 +08:00
zhx
efaaef7e86 feat: update voice room red packet admin UI 2026-05-29 17:36:33 +08:00
zhx
c22b23cb8c feat: add report list page 2026-05-29 16:48:00 +08:00
6 changed files with 264 additions and 26 deletions

View File

@ -132,7 +132,7 @@
"x-permissions": ["user-leaderboard:view"]
}
},
"/admin/activity/red-packets": {
"/resident-activity/voice-room-red-packet/records": {
"get": {
"operationId": "listRedPackets",
"responses": {
@ -144,7 +144,7 @@
"x-permissions": ["red-packet:view"]
}
},
"/admin/activity/red-packets/{packet_id}": {
"/resident-activity/voice-room-red-packet/records/{packet_id}": {
"get": {
"operationId": "getRedPacket",
"responses": {
@ -166,7 +166,7 @@
"x-permissions": ["red-packet:view"]
}
},
"/admin/activity/red-packets/config": {
"/resident-activity/voice-room-red-packet/config": {
"get": {
"operationId": "getRedPacketConfig",
"responses": {
@ -177,7 +177,7 @@
"x-permission": "red-packet:view",
"x-permissions": ["red-packet:view"]
},
"put": {
"post": {
"operationId": "updateRedPacketConfig",
"responses": {
"200": {

View File

@ -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) {

View File

@ -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) {

View File

@ -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();

View File

@ -751,14 +751,14 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
getRedPacket: {
method: "GET",
operationId: API_OPERATIONS.getRedPacket,
path: "/v1/admin/activity/red-packets/{packet_id}",
path: "/v1/resident-activity/voice-room-red-packet/records/{packet_id}",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
getRedPacketConfig: {
method: "GET",
operationId: API_OPERATIONS.getRedPacketConfig,
path: "/v1/admin/activity/red-packets/config",
path: "/v1/resident-activity/voice-room-red-packet/config",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
@ -1057,7 +1057,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
listRedPackets: {
method: "GET",
operationId: API_OPERATIONS.listRedPackets,
path: "/v1/admin/activity/red-packets",
path: "/v1/resident-activity/voice-room-red-packet/records",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
@ -1449,9 +1449,9 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permissions: ["payment-product:update"]
},
updateRedPacketConfig: {
method: "PUT",
method: "POST",
operationId: API_OPERATIONS.updateRedPacketConfig,
path: "/v1/admin/activity/red-packets/config",
path: "/v1/resident-activity/voice-room-red-packet/config",
permission: "red-packet:update",
permissions: ["red-packet:update"]
},

View File

@ -100,7 +100,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets": {
"/resident-activity/voice-room-red-packet/records": {
parameters: {
query?: never;
header?: never;
@ -116,7 +116,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets/{packet_id}": {
"/resident-activity/voice-room-red-packet/records/{packet_id}": {
parameters: {
query?: never;
header?: never;
@ -132,7 +132,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets/config": {
"/resident-activity/voice-room-red-packet/config": {
parameters: {
query?: never;
header?: never;
@ -140,8 +140,24 @@ export interface paths {
cookie?: never;
};
get: operations["getRedPacketConfig"];
put: operations["updateRedPacketConfig"];
post?: never;
put?: never;
post: operations["updateRedPacketConfig"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/resident-activity/voice-room-red-packet/refund/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["retryRedPacketRefund"];
delete?: never;
options?: never;
head?: never;