diff --git a/external-admin/index.html b/external-admin/index.html index c7759b6..60bbbf8 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -1,10 +1,10 @@ - + - HYApp 外管 + HYApp External Admin
diff --git a/external-admin/src/api/auth.js b/external-admin/src/api/auth.js index 596ac33..9371100 100644 --- a/external-admin/src/api/auth.js +++ b/external-admin/src/api/auth.js @@ -3,26 +3,16 @@ import { arrayValue, asRecord, textValue } from "./normalizers.js"; // These external-session auth routes intentionally have no main-admin OpenAPI operation. const EXTERNAL_AUTH_PATHS = Object.freeze({ - apps: "/auth/apps", changePassword: "/auth/change-password", login: "/auth/login", logout: "/auth/logout", me: "/auth/me" }); -export async function listExternalApps() { - const data = await externalRequest(EXTERNAL_AUTH_PATHS.apps, { notifyUnauthorized: false }); - const source = asRecord(data); - return arrayValue(data, source.items, source.apps) - .map((item) => normalizeApp(item)) - .filter((item) => item.appCode); -} - export async function loginExternal(payload) { const data = await externalRequest(EXTERNAL_AUTH_PATHS.login, { body: { account: String(payload.account || "").trim(), - appCode: String(payload.appCode || "").trim(), password: String(payload.password || "") }, method: "POST", @@ -84,11 +74,3 @@ export function normalizeSession(data) { ) }; } - -function normalizeApp(value) { - const item = typeof value === "string" ? { appCode: value } : asRecord(value); - return { - appCode: textValue(item.appCode, item.app_code, item.code, item.value), - name: textValue(item.name, item.appName, item.app_name, item.label, item.appCode, item.code) - }; -} diff --git a/external-admin/src/api/auth.test.js b/external-admin/src/api/auth.test.js index b509f0a..1c3584e 100644 --- a/external-admin/src/api/auth.test.js +++ b/external-admin/src/api/auth.test.js @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { changeExternalPassword, listExternalApps, normalizeSession } from "./auth.js"; +import { changeExternalPassword, loginExternal, normalizeSession } from "./auth.js"; afterEach(() => { vi.unstubAllGlobals(); @@ -43,9 +43,14 @@ describe("normalizeSession", () => { }); describe("external auth API contract", () => { - test("reads appName from auth/apps items", async () => { - vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: 0, data: { items: [{ appCode: "fami", appName: "Fami" }], total: 1 } }))); - await expect(listExternalApps()).resolves.toEqual([{ appCode: "fami", name: "Fami" }]); + test("login sends only account and password so the server resolves the App", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { account: "fami-manager", appCode: "fami" } })); + vi.stubGlobal("fetch", fetchMock); + + await loginExternal({ account: " fami-manager ", appCode: "must-not-be-sent", password: " password bytes " }); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/auth/login"); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ account: "fami-manager", password: " password bytes " }); }); test("change-password sends currentPassword and never oldPassword", async () => { diff --git a/external-admin/src/api/business.js b/external-admin/src/api/business.js index e42aaff..d3fb062 100644 --- a/external-admin/src/api/business.js +++ b/external-admin/src/api/business.js @@ -2,6 +2,7 @@ import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoint import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js"; import { externalRequest } from "./client.js"; import { asRecord, booleanValue, entityId, normalizePage, numberValue, textValue } from "./normalizers.js"; +import { externalUiError } from "../i18n/errors.js"; // my-team is external-session scoped and is not part of the main-admin OpenAPI contract yet. const EXTERNAL_ONLY_PATHS = Object.freeze({ myTeamAgencies: "/admin/my-team/agencies" }); @@ -42,10 +43,10 @@ export async function grantUserVip(targetUserId, payload) { const level = Number(payload.level); if (program.status === "disabled") { - throw new Error("当前 App 的 VIP 体系已停用"); + throw externalUiError("errors.vipDisabled"); } if (Array.isArray(levels) && levels.length && !levels.some((item) => Number(item.level) === level)) { - throw new Error("请选择当前 App 已配置的 VIP 等级"); + throw externalUiError("errors.vipLevelInvalid"); } const command = { @@ -74,7 +75,7 @@ export function listOrganizations(kind, query = {}) { team: EXTERNAL_ONLY_PATHS.myTeamAgencies }; if (!routes[kind]) { - throw new Error("该组织列表尚无安全的数据接口"); + throw externalUiError("errors.organizationRoute"); } const scopedQuery = kind === "super-admins" ? { ...query, status: query.status || "active" } : query; return externalRequest(routes[kind], { query: scopedQuery }).then((data) => { @@ -104,7 +105,7 @@ export function buildRoomUpdatePayload(payload) { const regionText = String(payload.visibleRegionId ?? "").trim(); const visibleRegionId = regionText ? Number(regionText) : 0; if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) { - throw new Error("可见区域 ID 必须是安全的正整数"); + throw externalUiError("errors.regionId"); } return { coverUrl: String(payload.coverUrl || "").trim(), @@ -151,53 +152,53 @@ export function createBanner(payload) { export function buildBannerPayload(payload) { if (!["h5", "app"].includes(payload.bannerType)) { - throw new Error("Banner 类型不正确"); + throw externalUiError("errors.bannerType"); } if (!["android", "ios"].includes(payload.platform)) { - throw new Error("Banner 平台不正确"); + throw externalUiError("errors.bannerPlatform"); } if (!["active", "disabled", "expired"].includes(payload.status)) { - throw new Error("Banner 状态不正确"); + throw externalUiError("errors.bannerStatus"); } const displayScopes = [...new Set((payload.displayScopes || []).filter((scope) => ["home", "room", "recharge", "me"].includes(scope)))]; if (!displayScopes.length) { - throw new Error("请至少选择一个展示范围"); + throw externalUiError("errors.bannerScope"); } if (!payload.coverUrl) { - throw new Error("请上传 Banner 图片"); + throw externalUiError("errors.bannerImage"); } if (payload.bannerType === "h5") { let target; try { target = new URL(payload.param); } catch { - throw new Error("请填写合法的 H5 链接"); + throw externalUiError("errors.h5Url"); } if (!["http:", "https:"].includes(target.protocol)) { - throw new Error("H5 链接仅支持 http 或 https"); + throw externalUiError("errors.h5Protocol"); } } if (payload.bannerType === "app") { const validation = validatePublicAppJumpParam(payload.param); if (!validation.valid) { - throw new Error(validation.message); + throw externalUiError(appJumpValidationKey(validation.message)); } } if (displayScopes.includes("room") && !payload.roomSmallImageUrl) { - throw new Error("房间范围必须上传房间内小屏图"); + throw externalUiError("errors.roomBannerImage"); } const countryCode = String(payload.countryCode || "").trim().toUpperCase(); if (countryCode && !/^[A-Z]{2,3}$/.test(countryCode)) { - throw new Error("国家码必须是 2-3 位字母"); + throw externalUiError("errors.countryCode"); } const regionId = Number(payload.regionId || 0); if (!Number.isSafeInteger(regionId) || regionId < 0) { - throw new Error("区域 ID 不正确"); + throw externalUiError("errors.bannerRegion"); } const startsAtMs = Number(payload.startsAtMs || 0); const endsAtMs = Number(payload.endsAtMs || 0); if (startsAtMs > 0 && endsAtMs > 0 && startsAtMs >= endsAtMs) { - throw new Error("投放结束时间必须晚于开始时间"); + throw externalUiError("errors.bannerSchedule"); } return { @@ -319,3 +320,15 @@ function externalOperationPath(operation, params) { // Generated main-admin paths start with /v1; the external gateway already owns that version prefix in its base URL. return apiEndpointPath(operation, params).replace(/^\/v1(?=\/)/, ""); } + +function appJumpValidationKey(message) { + return { + "APP 跳转参数必须是 JSON 对象": "errors.appParamObject", + "APP 跳转 type 不在公共跳转方案内": "errors.appParamType", + "Explore 游戏 H5 需要选择有效 game_code": "errors.appParamGameCode", + "该 APP 跳转需要填写 game_id": "errors.appParamGame", + "该 APP 跳转需要填写 room_id": "errors.appParamRoom", + "该 APP 跳转需要填写有效 window": "errors.appParamWindow", + "请填写 APP 跳转参数": "errors.appParamRequired" + }[message] || "errors.appParamObject"; +} diff --git a/external-admin/src/api/business.test.js b/external-admin/src/api/business.test.js index f7df65e..77ecece 100644 --- a/external-admin/src/api/business.test.js +++ b/external-admin/src/api/business.test.js @@ -40,7 +40,7 @@ describe("room API payload", () => { expect(buildRoomUpdatePayload({ coverUrl: " cover ", description: " desc ", title: " Room ", visibleRegionId: "18" })) .toEqual({ coverUrl: "cover", description: "desc", title: "Room", visibleRegionId: 18 }); expect(buildRoomUpdatePayload({ visibleRegionId: "" }).visibleRegionId).toBe(0); - expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("可见区域 ID"); + expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("errors.regionId"); }); }); @@ -134,7 +134,7 @@ describe("banner API payload", () => { status: "active" })); expect(() => buildBannerPayload({ bannerType: "link", coverUrl: "x", displayScopes: ["home"], platform: "all", status: "enabled" })) - .toThrow("Banner 类型不正确"); + .toThrow("errors.bannerType"); }); test("create API sends the normalized appconfig payload", async () => { diff --git a/external-admin/src/api/client.js b/external-admin/src/api/client.js index 507b791..f9de9bd 100644 --- a/external-admin/src/api/client.js +++ b/external-admin/src/api/client.js @@ -73,11 +73,11 @@ export async function externalRequest(path, options = {}) { } if (!response.ok) { - throw apiError(payload, response.status, response.statusText || "请求失败"); + throw apiError(payload, response.status, response.statusText || "Request failed"); } if (isEnvelopeFailure(payload)) { - throw apiError(payload, response.status, "请求失败"); + throw apiError(payload, response.status, "Request failed"); } const data = unwrapPayload(payload); diff --git a/external-admin/src/auth/ExternalAuthProvider.jsx b/external-admin/src/auth/ExternalAuthProvider.jsx index b7de90d..e637ccb 100644 --- a/external-admin/src/auth/ExternalAuthProvider.jsx +++ b/external-admin/src/auth/ExternalAuthProvider.jsx @@ -7,10 +7,10 @@ const ExternalAuthContext = createContext(null); export function ExternalAuthProvider({ children }) { const [session, setSession] = useState(null); const [initializing, setInitializing] = useState(true); - const [initializationError, setInitializationError] = useState(""); + const [initializationError, setInitializationError] = useState(null); const refresh = useCallback(async () => { - setInitializationError(""); + setInitializationError(null); try { const nextSession = await getExternalSession(); setSession(nextSession); @@ -18,7 +18,8 @@ export function ExternalAuthProvider({ children }) { } catch (error) { setSession(null); if (!(error instanceof ExternalApiError && error.status === 401)) { - setInitializationError(error.message || "会话校验失败"); + // Preserve the stable API status/code for the UI translation layer; never surface a backend-language message directly. + setInitializationError(error); } return null; } finally { diff --git a/external-admin/src/auth/RouteGuards.jsx b/external-admin/src/auth/RouteGuards.jsx index e47933a..d2ae5d0 100644 --- a/external-admin/src/auth/RouteGuards.jsx +++ b/external-admin/src/auth/RouteGuards.jsx @@ -5,17 +5,20 @@ import CircularProgress from "@mui/material/CircularProgress"; import Typography from "@mui/material/Typography"; import { Navigate, Outlet, useLocation } from "react-router-dom"; import { hasAnyExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { useExternalAuth } from "./ExternalAuthProvider.jsx"; export function RequireExternalAuth() { const { initializationError, initializing, refresh, session } = useExternalAuth(); + const { t } = useExternalI18n(); const location = useLocation(); if (initializing) { return ( - 正在校验外管会话 + {t("auth.checkingSession")} ); } @@ -24,8 +27,8 @@ export function RequireExternalAuth() { if (initializationError) { return ( - {initializationError} - + {translateExternalError(initializationError, t, "auth.sessionFailed")} + ); } @@ -42,6 +45,7 @@ export function RequirePasswordChanged() { export function RequireExternalCapability({ anyOf }) { const { session } = useExternalAuth(); + const { t } = useExternalI18n(); if (hasAnyExternalCapability(session, anyOf)) { return ; @@ -49,9 +53,9 @@ export function RequireExternalCapability({ anyOf }) { return ( - 当前外管账号没有访问此功能的权限。 + {t("auth.noPermission")} ); diff --git a/external-admin/src/components/BannerCreateDialog.jsx b/external-admin/src/components/BannerCreateDialog.jsx index 98ee4ad..486fec1 100644 --- a/external-admin/src/components/BannerCreateDialog.jsx +++ b/external-admin/src/components/BannerCreateDialog.jsx @@ -12,15 +12,17 @@ import { useEffect, useState } from "react"; import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; const displayScopeOptions = [ - ["home", "首页"], - ["room", "房间"], - ["recharge", "充值"], - ["me", "我的"] + ["home", "banners.scopeHome"], + ["room", "banners.scopeRoom"], + ["recharge", "banners.scopeRecharge"], + ["me", "banners.scopeMe"] ]; export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { + const { locale, t } = useExternalI18n(); const [form, setForm] = useState(initialForm); useEffect(() => setForm(initialForm), [open]); @@ -37,57 +39,60 @@ export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { return ( - 创建 Banner + {t("banners.create")} - setForm({ ...form, coverUrl })} /> - {form.displayScopes.includes("room") ? setForm({ ...form, roomSmallImageUrl })} /> : null} - setForm({ ...form, platform: event.target.value })}> + setForm({ ...form, coverUrl })} /> + {form.displayScopes.includes("room") ? setForm({ ...form, roomSmallImageUrl })} /> : null} + setForm({ ...form, platform: event.target.value })}> Android iOS - setForm({ ...form, description: event.target.value })} /> + setForm({ ...form, description: event.target.value })} /> setForm({ ...form, bannerType: type, param })} /> selected.map((value) => displayScopeOptions.find(([key]) => key === value)?.[1] || value).join("、") } }} + slotProps={{ select: { multiple: true, renderValue: (selected) => selected.map((value) => t(displayScopeOptions.find(([key]) => key === value)?.[1]) || value).join(", ") } }} value={form.displayScopes} onChange={(event) => setForm({ ...form, displayScopes: typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value })} > - {displayScopeOptions.map(([value, label]) => )} + {displayScopeOptions.map(([value, labelKey]) => )} - setForm({ ...form, regionId: event.target.value })} /> - setForm({ ...form, countryCode: event.target.value.toUpperCase() })} /> - setForm({ ...form, sortOrder: event.target.value })} /> + setForm({ ...form, regionId: event.target.value })} /> + setForm({ ...form, countryCode: event.target.value.toUpperCase() })} /> + setForm({ ...form, sortOrder: event.target.value })} /> setForm({ ...form, endsAtMs: endMs, startsAtMs: startMs })} /> - setForm({ ...form, status: event.target.value })}> - 启用 - 禁用 - 过期 + setForm({ ...form, status: event.target.value })}> + {t("common.enabled")} + {t("common.disabled")} + {t("common.expired")} - + ); @@ -108,3 +113,23 @@ const initialForm = { startsAtMs: 0, status: "active" }; + +function appJumpMessages(t) { + return { + appTarget: t("jump.appTarget"), + gameId: t("jump.gameId"), + giftId: t("jump.giftId"), + hint: t("jump.hint"), + redPacketNo: t("jump.redPacketNo"), + roomId: t("jump.roomId"), + targets: Object.fromEntries(["wallet", "room", "room_random", "room_window", "room_random_window", "room_game", "room_random_game", "explore_game", "custom"].map((value) => [value, t(`jump.target.${value}`)])), + window: t("jump.window"), + windows: Object.fromEntries(["game_window", "gift_panel", "red_packet_claim", "wheel"].map((value) => [value, t(`jump.window.${value}`)])) + }; +} + +function timeRangeMessages(t) { + return Object.fromEntries([ + "apply", "clear", "completeRange", "duration", "durationDays", "durationHours", "durationMinutes", "end", "endAfterStart", "endDate", "endDateAfterStart", "endDateLaterShort", "endLaterShort", "endMark", "hour", "lessMinute", "minute", "nextMonth", "pending", "previousMonth", "quickDay", "quickHour", "quickWeek", "sameDay", "start", "startDate", "startMark", "unlimited" + ].map((key) => [key, t(`time.${key}`)])); +} diff --git a/external-admin/src/components/BannerCreateDialog.test.jsx b/external-admin/src/components/BannerCreateDialog.test.jsx index 54b4fd8..bafad3e 100644 --- a/external-admin/src/components/BannerCreateDialog.test.jsx +++ b/external-admin/src/components/BannerCreateDialog.test.jsx @@ -8,13 +8,13 @@ describe("BannerCreateDialog", () => { const user = userEvent.setup(); render(); - expect(screen.getByRole("textbox", { name: "参数(H5 链接)" })).toBeInTheDocument(); - expect(screen.getByText("投放时间")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Parameter (H5 URL)" })).toBeInTheDocument(); + expect(screen.getByText("Schedule")).toBeInTheDocument(); - await user.click(screen.getByRole("combobox", { name: "跳转类型" })); + await user.click(screen.getByRole("combobox", { name: "Destination type" })); await user.click(await screen.findByRole("option", { name: "APP" })); - expect(screen.getByRole("combobox", { name: "APP 目标" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "参数(APP 公共跳转 JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" })); + expect(screen.getByRole("combobox", { name: "App destination" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Parameter (App navigation JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" })); }); }); diff --git a/external-admin/src/components/GrantDialogs.jsx b/external-admin/src/components/GrantDialogs.jsx index ab6d34d..6fce0f1 100644 --- a/external-admin/src/components/GrantDialogs.jsx +++ b/external-admin/src/components/GrantDialogs.jsx @@ -8,49 +8,54 @@ import MenuItem from "@mui/material/MenuItem"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import { useEffect, useState } from "react"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resources }) { + const { t } = useExternalI18n(); const [form, setForm] = useState(resourceGrantInitial); useEffect(() => setForm(resourceGrantInitial), [open]); return ( - onSubmit(form)}> + onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> - setForm({ ...form, resourceId: event.target.value })}> - {resources.map((item) => {item.name}({item.id}))} + setForm({ ...form, target: event.target.value.trim() })} /> + setForm({ ...form, resourceId: event.target.value })}> + {resources.map((item) => {item.name} ({item.id}))} - setForm({ ...form, quantity: event.target.value })} /> - setForm({ ...form, durationDays: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + setForm({ ...form, quantity: event.target.value })} /> + setForm({ ...form, durationDays: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { + const { t } = useExternalI18n(); const [form, setForm] = useState(prettyGrantInitial); useEffect(() => setForm(prettyGrantInitial), [open]); return ( - onSubmit(form)}> + onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> - setForm({ ...form, prettyId: event.target.value.trim() })} /> - setForm({ ...form, durationDays: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + setForm({ ...form, target: event.target.value.trim() })} /> + setForm({ ...form, prettyId: event.target.value.trim() })} /> + setForm({ ...form, durationDays: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } function TargetNotice() { - return 提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。; + const { t } = useExternalI18n(); + return {t("grants.notice")}; } function GrantDialog({ children, loading, onClose, onSubmit, open, title }) { + const { t } = useExternalI18n(); return ( { event.preventDefault(); onSubmit(); }}> {title} {children} - + ); diff --git a/external-admin/src/components/RoomEditDialog.jsx b/external-admin/src/components/RoomEditDialog.jsx index da60be6..c3fe458 100644 --- a/external-admin/src/components/RoomEditDialog.jsx +++ b/external-admin/src/components/RoomEditDialog.jsx @@ -7,8 +7,10 @@ import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import { useEffect, useState } from "react"; import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) { + const { t } = useExternalI18n(); const [form, setForm] = useState({ coverUrl: "", description: "", title: "", visibleRegionId: "" }); useEffect(() => { setForm({ coverUrl: room?.coverUrl || "", description: room?.description || "", title: room?.title || "", visibleRegionId: room?.visibleRegionId || "" }); @@ -16,14 +18,14 @@ export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) { return ( { event.preventDefault(); onSubmit(form); }}> - 编辑房间 + {t("rooms.editTitle")} - setForm({ ...form, coverUrl })} /> - setForm({ ...form, title: event.target.value })} /> - setForm({ ...form, description: event.target.value })} /> - setForm({ ...form, visibleRegionId: event.target.value.trim() })} /> + setForm({ ...form, coverUrl })} /> + setForm({ ...form, title: event.target.value })} /> + setForm({ ...form, description: event.target.value })} /> + setForm({ ...form, visibleRegionId: event.target.value.trim() })} /> - + ); diff --git a/external-admin/src/components/UserDialogs.jsx b/external-admin/src/components/UserDialogs.jsx index a227b14..3407b57 100644 --- a/external-admin/src/components/UserDialogs.jsx +++ b/external-admin/src/components/UserDialogs.jsx @@ -8,49 +8,54 @@ import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import { useEffect, useState } from "react"; import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function UserEditDialog({ loading, onClose, onSubmit, open, user }) { + const { t } = useExternalI18n(); const [form, setForm] = useState(emptyEditForm); useEffect(() => { setForm({ avatar: user?.avatar || "", country: user?.country || "", gender: user?.gender || "", username: user?.username || "" }); }, [user]); return ( - onSubmit(form)}> - setForm({ ...form, avatar })} /> - setForm({ ...form, username: event.target.value })} /> - setForm({ ...form, gender: event.target.value })}> - 未设置未知 + onSubmit(form)}> + setForm({ ...form, avatar })} /> + setForm({ ...form, username: event.target.value })} /> + setForm({ ...form, gender: event.target.value })}> + {t("users.notSet")}{t("users.genderMale")}{t("users.genderFemale")}{t("users.genderUnknown")} - setForm({ ...form, country: event.target.value.trim().toUpperCase() })} /> + setForm({ ...form, country: event.target.value.trim().toUpperCase() })} /> ); } export function UserBanDialog({ loading, onClose, onSubmit, open, user }) { + const { t } = useExternalI18n(); const [form, setForm] = useState({ days: "7", reason: "" }); useEffect(() => setForm({ days: "7", reason: "" }), [user]); return ( - onSubmit(form)}> - setForm({ ...form, days: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + onSubmit(form)}> + setForm({ ...form, days: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } export function UserLevelDialog({ loading, onClose, onSubmit, open, user }) { + const { t } = useExternalI18n(); const [form, setForm] = useState({ days: "30", level: "", reason: "", track: "wealth" }); useEffect(() => setForm({ days: "30", level: "", reason: "", track: "wealth" }), [user]); return ( - onSubmit(form)}> - setForm({ ...form, track: event.target.value })}>财富等级魅力等级VIP 等级 - setForm({ ...form, level: event.target.value })} /> - setForm({ ...form, days: event.target.value })} /> - setForm({ ...form, reason: event.target.value })} /> + onSubmit(form)}> + setForm({ ...form, track: event.target.value })}>{t("users.wealthLevel")}{t("users.charmLevel")}{t("users.vipLevel")} + setForm({ ...form, level: event.target.value })} /> + setForm({ ...form, days: event.target.value })} /> + setForm({ ...form, reason: event.target.value })} /> ); } function BaseDialog({ children, loading, onClose, onSubmit, open, title }) { + const { t } = useExternalI18n(); const handleSubmit = (event) => { event.preventDefault(); onSubmit(); @@ -60,7 +65,7 @@ function BaseDialog({ children, loading, onClose, onSubmit, open, title }) { {title} {children} - + ); diff --git a/external-admin/src/config/capabilities.js b/external-admin/src/config/capabilities.js index a346f9f..7d16f5a 100644 --- a/external-admin/src/config/capabilities.js +++ b/external-admin/src/config/capabilities.js @@ -1,24 +1,24 @@ export const EXTERNAL_CAPABILITIES = [ - { code: "user:list", label: "用户列表", path: "/users" }, - { code: "user:update", label: "用户信息编辑", path: "/users" }, - { code: "user-ban:list", label: "账号封禁列表", path: "/bans" }, - { code: "user:ban", label: "执行封禁", path: "/users" }, - { code: "user:unban", label: "解除封禁", path: "/bans" }, - { code: "host:list", label: "主播列表", path: "/organization/hosts" }, - { code: "agency:list", label: "公会列表", path: "/organization/agencies" }, - { code: "bd:list", label: "BD 列表", path: "/organization/bds" }, - { code: "bd-manager:list", label: "BD Manager 列表", path: "/organization/bd-leaders" }, - { code: "super-admin:list", label: "Super Admin 列表", path: "/organization/super-admins" }, - { code: "room:list", label: "房间管理", path: "/rooms" }, - { code: "room:update", label: "房间编辑", path: "/rooms" }, - { code: "privilege:list", label: "用户特权道具列表", path: "/grants" }, - { code: "privilege:grant", label: "特权道具发放", path: "/grants" }, - { code: "banner:create", label: "Banner 创建", path: "/banners" }, - { code: "pretty-id:grant", label: "靓号下发", path: "/grants" }, - { code: "user-title:grant", label: "用户称号下发", path: "/grants" }, - { code: "room-background:grant", label: "房间背景图下发", path: "/grants" }, - { code: "user-level:grant", label: "财富/VIP等级下发", path: "/users" }, - { code: "team:view", label: "我的团队", path: "/organization/team" } + { code: "user:list", labelKey: "capability.userList", path: "/users" }, + { code: "user:update", labelKey: "capability.userUpdate", path: "/users" }, + { code: "user-ban:list", labelKey: "capability.banList", path: "/bans" }, + { code: "user:ban", labelKey: "capability.ban", path: "/users" }, + { code: "user:unban", labelKey: "capability.unban", path: "/bans" }, + { code: "host:list", labelKey: "capability.hostList", path: "/organization/hosts" }, + { code: "agency:list", labelKey: "capability.agencyList", path: "/organization/agencies" }, + { code: "bd:list", labelKey: "capability.bdList", path: "/organization/bds" }, + { code: "bd-manager:list", labelKey: "capability.bdManagerList", path: "/organization/bd-leaders" }, + { code: "super-admin:list", labelKey: "capability.superAdminList", path: "/organization/super-admins" }, + { code: "room:list", labelKey: "capability.roomList", path: "/rooms" }, + { code: "room:update", labelKey: "capability.roomUpdate", path: "/rooms" }, + { code: "privilege:list", labelKey: "capability.privilegeList", path: "/grants" }, + { code: "privilege:grant", labelKey: "capability.privilegeGrant", path: "/grants" }, + { code: "banner:create", labelKey: "capability.bannerCreate", path: "/banners" }, + { code: "pretty-id:grant", labelKey: "capability.prettyIdGrant", path: "/grants" }, + { code: "user-title:grant", labelKey: "capability.userTitleGrant", path: "/grants" }, + { code: "room-background:grant", labelKey: "capability.roomBackgroundGrant", path: "/grants" }, + { code: "user-level:grant", labelKey: "capability.userLevelGrant", path: "/users" }, + { code: "team:view", labelKey: "capability.teamView", path: "/organization/team" } ]; export function hasExternalCapability(session, code) { diff --git a/external-admin/src/i18n/ExternalI18nProvider.jsx b/external-admin/src/i18n/ExternalI18nProvider.jsx new file mode 100644 index 0000000..b5fe1ee --- /dev/null +++ b/external-admin/src/i18n/ExternalI18nProvider.jsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { + DEFAULT_EXTERNAL_LOCALE, + EXTERNAL_LANGUAGE_OPTIONS, + EXTERNAL_LOCALE_STORAGE_KEY, + externalDirection, + externalMessage, + normalizeExternalLocale +} from "./messages.js"; + +const defaultTranslate = (key, values) => interpolate(externalMessage(DEFAULT_EXTERNAL_LOCALE, key), values); +const ExternalI18nContext = createContext({ + direction: "ltr", + languages: EXTERNAL_LANGUAGE_OPTIONS, + locale: DEFAULT_EXTERNAL_LOCALE, + setLocale: () => {}, + t: defaultTranslate +}); + +export function ExternalI18nProvider({ children }) { + // The product default is deliberately English. Browser language is ignored until the operator explicitly chooses and persists another locale. + const [locale, setLocaleState] = useState(readPersistedLocale); + const direction = externalDirection(locale); + const setLocale = useCallback((nextLocale) => { + const normalized = normalizeExternalLocale(nextLocale); + setLocaleState(normalized); + try { + globalThis.localStorage?.setItem(EXTERNAL_LOCALE_STORAGE_KEY, normalized); + } catch { + // Storage can be unavailable in hardened/private browser contexts; the in-memory locale still changes for the current session. + } + }, []); + const t = useCallback((key, values) => interpolate(externalMessage(locale, key), values), [locale]); + + useEffect(() => { + // Keep native controls, assistive technology, MUI portals and the shell aligned when Arabic changes writing direction. + const root = globalThis.document?.documentElement; + if (!root) { + return; + } + root.lang = locale; + root.dir = direction; + globalThis.document.body?.setAttribute("dir", direction); + globalThis.document.title = t("app.title"); + }, [direction, locale, t]); + + const value = useMemo( + () => ({ direction, languages: EXTERNAL_LANGUAGE_OPTIONS, locale, setLocale, t }), + [direction, locale, setLocale, t] + ); + + return {children}; +} + +export function useExternalI18n() { + return useContext(ExternalI18nContext); +} + +function readPersistedLocale() { + try { + return normalizeExternalLocale(globalThis.localStorage?.getItem(EXTERNAL_LOCALE_STORAGE_KEY)); + } catch { + return DEFAULT_EXTERNAL_LOCALE; + } +} + +function interpolate(message, values = {}) { + return String(message).replace(/\{(\w+)\}/g, (match, key) => (values[key] === undefined ? match : String(values[key]))); +} diff --git a/external-admin/src/i18n/ExternalI18nProvider.test.jsx b/external-admin/src/i18n/ExternalI18nProvider.test.jsx new file mode 100644 index 0000000..abcb047 --- /dev/null +++ b/external-admin/src/i18n/ExternalI18nProvider.test.jsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test } from "vitest"; +import { ExternalI18nProvider, useExternalI18n } from "./ExternalI18nProvider.jsx"; +import { EXTERNAL_LOCALE_STORAGE_KEY } from "./messages.js"; + +describe("ExternalI18nProvider", () => { + beforeEach(() => { + localStorage.removeItem(EXTERNAL_LOCALE_STORAGE_KEY); + document.documentElement.lang = ""; + document.documentElement.dir = ""; + }); + + test("defaults to English instead of the browser locale", () => { + Object.defineProperty(navigator, "language", { configurable: true, value: "zh-CN" }); + render(); + + expect(screen.getByTestId("locale")).toHaveTextContent("en"); + expect(screen.getByTestId("title")).toHaveTextContent("HYApp External Admin"); + expect(document.documentElement).toHaveAttribute("lang", "en"); + expect(document.documentElement).toHaveAttribute("dir", "ltr"); + }); + + test("supports and persists Chinese, Arabic RTL and Turkish", async () => { + const user = userEvent.setup(); + const firstRender = render(); + + await user.click(screen.getByRole("button", { name: "ar" })); + expect(screen.getByTestId("title")).toHaveTextContent("إدارة HYApp الخارجية"); + expect(document.documentElement).toHaveAttribute("lang", "ar"); + expect(document.documentElement).toHaveAttribute("dir", "rtl"); + expect(document.body).toHaveAttribute("dir", "rtl"); + expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("ar"); + + await user.click(screen.getByRole("button", { name: "tr" })); + expect(screen.getByTestId("title")).toHaveTextContent("HYApp Harici Yönetim"); + expect(document.documentElement).toHaveAttribute("lang", "tr"); + expect(document.documentElement).toHaveAttribute("dir", "ltr"); + + await user.click(screen.getByRole("button", { name: "zh-CN" })); + expect(screen.getByTestId("title")).toHaveTextContent("HYApp 外管后台"); + expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("zh-CN"); + + firstRender.unmount(); + render(); + expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"); + expect(screen.getByTestId("title")).toHaveTextContent("HYApp 外管后台"); + }); +}); + +function LocaleProbe() { + const { locale, setLocale, t } = useExternalI18n(); + return ( +
+ {locale} + {t("app.title")} + {["zh-CN", "ar", "tr"].map((value) => )} +
+ ); +} diff --git a/external-admin/src/i18n/LanguageSwitcher.jsx b/external-admin/src/i18n/LanguageSwitcher.jsx new file mode 100644 index 0000000..873b822 --- /dev/null +++ b/external-admin/src/i18n/LanguageSwitcher.jsx @@ -0,0 +1,26 @@ +import LanguageOutlined from "@mui/icons-material/LanguageOutlined"; +import InputAdornment from "@mui/material/InputAdornment"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useExternalI18n } from "./ExternalI18nProvider.jsx"; + +export function LanguageSwitcher({ compact = false }) { + const { languages, locale, setLocale, t } = useExternalI18n(); + + return ( + }, + select: { displayEmpty: true } + }} + value={locale} + onChange={(event) => setLocale(event.target.value)} + > + {languages.map((language) => {language.label})} + + ); +} diff --git a/external-admin/src/i18n/emotionCache.js b/external-admin/src/i18n/emotionCache.js new file mode 100644 index 0000000..7f86a56 --- /dev/null +++ b/external-admin/src/i18n/emotionCache.js @@ -0,0 +1,10 @@ +import createCache from "@emotion/cache"; +import rtlPlugin from "@mui/stylis-plugin-rtl"; +import { prefixer } from "stylis"; + +const externalLtrCache = createCache({ key: "external-ltr", prepend: true }); +const externalRtlCache = createCache({ key: "external-rtl", prepend: true, stylisPlugins: [prefixer, rtlPlugin] }); + +export function externalEmotionCache(direction) { + return direction === "rtl" ? externalRtlCache : externalLtrCache; +} diff --git a/external-admin/src/i18n/emotionCache.test.js b/external-admin/src/i18n/emotionCache.test.js new file mode 100644 index 0000000..62592e8 --- /dev/null +++ b/external-admin/src/i18n/emotionCache.test.js @@ -0,0 +1,24 @@ +import rtlPlugin from "@mui/stylis-plugin-rtl"; +import { compile, middleware, prefixer, serialize, stringify } from "stylis"; +import { describe, expect, test } from "vitest"; +import { externalEmotionCache } from "./emotionCache.js"; + +describe("external admin RTL Emotion cache", () => { + test("uses isolated LTR and RTL caches", () => { + expect(externalEmotionCache("ltr").key).toBe("external-ltr"); + expect(externalEmotionCache("rtl").key).toBe("external-rtl"); + expect(externalEmotionCache("rtl")).not.toBe(externalEmotionCache("ltr")); + }); + + test("mirrors physical MUI-style declarations for Arabic", () => { + const css = serialize( + compile(".control{margin-left:8px;padding-right:12px;text-align:left}"), + middleware([prefixer, rtlPlugin, stringify]) + ); + + expect(css).toContain("margin-right:8px"); + expect(css).toContain("padding-left:12px"); + expect(css).toContain("text-align:right"); + expect(css).not.toContain("margin-left:8px"); + }); +}); diff --git a/external-admin/src/i18n/errors.js b/external-admin/src/i18n/errors.js new file mode 100644 index 0000000..0880b5c --- /dev/null +++ b/external-admin/src/i18n/errors.js @@ -0,0 +1,51 @@ +const ERROR_CODE_KEYS = Object.freeze({ + account_locked: "errors.accountLocked", + app_inactive: "errors.appInactive", + invalid_credentials: "auth.invalidCredentials", + rate_limited: "errors.rateLimited", + too_many_requests: "errors.rateLimited" +}); + +export class ExternalUiError extends Error { + constructor(translationKey) { + super(translationKey); + this.name = "ExternalUiError"; + this.translationKey = translationKey; + } +} + +export function externalUiError(translationKey) { + return new ExternalUiError(translationKey); +} + +export function translateExternalError(error, t, fallbackKey = "errors.requestFailed") { + if (error?.translationKey) { + return t(error.translationKey); + } + const code = String(error?.code || "").trim().toLowerCase().replace(/[.-]/g, "_"); + if (ERROR_CODE_KEYS[code]) { + return t(ERROR_CODE_KEYS[code]); + } + if (error?.status === 401) { + return t("errors.unauthorized"); + } + if (error?.status === 403) { + return t("errors.forbidden"); + } + if (error?.status === 404) { + return t("errors.notFound"); + } + if (error?.status === 409) { + return t("errors.conflict"); + } + if (error?.status === 413) { + return t("errors.tooLarge"); + } + if (error?.status === 429) { + return t("errors.rateLimited"); + } + if (Number(error?.status) >= 500) { + return t("errors.server"); + } + return t(fallbackKey); +} diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js new file mode 100644 index 0000000..c442286 --- /dev/null +++ b/external-admin/src/i18n/messages.js @@ -0,0 +1,404 @@ +export const EXTERNAL_LOCALES = Object.freeze(["en", "zh-CN", "ar", "tr"]); +export const DEFAULT_EXTERNAL_LOCALE = "en"; +export const EXTERNAL_LOCALE_STORAGE_KEY = "hyapp_external_admin_locale"; + +export const EXTERNAL_LANGUAGE_OPTIONS = Object.freeze([ + { label: "English", value: "en" }, + { label: "简体中文", value: "zh-CN" }, + { label: "العربية", value: "ar" }, + { label: "Türkçe", value: "tr" } +]); + +const en = { + "app.title": "HYApp External Admin", + "app.shortTitle": "HYApp External Admin", + "language.label": "Language", + "common.actions": "Actions", + "common.active": "Active", + "common.all": "All", + "common.allStatuses": "All statuses", + "common.banned": "Banned", + "common.cancel": "Cancel", + "common.confirm": "Confirm", + "common.create": "Create", + "common.disabled": "Disabled", + "common.edit": "Edit", + "common.enabled": "Enabled", + "common.expired": "Expired", + "common.loading": "Loading", + "common.noData": "No data", + "common.normal": "Normal", + "common.query": "Search", + "common.reason": "Reason", + "common.reload": "Reload", + "common.retry": "Retry", + "common.save": "Save", + "common.status": "Status", + "common.user": "User", + "auth.account": "External admin account", + "auth.changePassword": "Change external admin password", + "auth.checkingSession": "Checking external admin session", + "auth.confirmNewPassword": "Confirm new password", + "auth.currentPassword": "Current password", + "auth.fillAccountPassword": "Enter your account and password", + "auth.firstLoginChange": "You must change your password on first sign-in", + "auth.hidePassword": "Hide password", + "auth.invalidCredentials": "Incorrect account or password", + "auth.login": "Sign in", + "auth.loginFailed": "Sign-in failed", + "auth.logout": "Sign out", + "auth.newPassword": "New password", + "auth.noPermission": "This external admin account does not have access to this feature.", + "auth.password": "Password", + "auth.passwordChangedFailed": "Failed to change password", + "auth.passwordMismatch": "The new passwords do not match", + "auth.passwordMin": "At least 8 characters", + "auth.passwordMinError": "The new password must be at least 8 characters", + "auth.passwordWhitespace": "The new password cannot be empty or contain only whitespace", + "auth.recheck": "Check again", + "auth.returnOverview": "Return to permission overview", + "auth.savePassword": "Save password", + "auth.sessionFailed": "Session verification failed", + "auth.showPassword": "Show password", + "nav.expand": "Open navigation", + "nav.overview": "Permission overview", + "nav.users": "User management", + "nav.bans": "Ban management", + "nav.hosts": "Hosts", + "nav.agencies": "Agencies", + "nav.bds": "BDs", + "nav.bdManagers": "BD Managers", + "nav.superAdmins": "Super Admins", + "nav.rooms": "Room management", + "nav.grants": "Resources & vanity IDs", + "nav.banners": "Banner management", + "nav.team": "My team", + "capability.userList": "User list", + "capability.userUpdate": "Edit user information", + "capability.banList": "Account ban list", + "capability.ban": "Ban users", + "capability.unban": "Unban users", + "capability.hostList": "Host list", + "capability.agencyList": "Agency list", + "capability.bdList": "BD list", + "capability.bdManagerList": "BD Manager list", + "capability.superAdminList": "Super Admin list", + "capability.roomList": "Room management", + "capability.roomUpdate": "Edit rooms", + "capability.privilegeList": "User privilege item list", + "capability.privilegeGrant": "Grant privilege items", + "capability.bannerCreate": "Create banners", + "capability.prettyIdGrant": "Grant vanity IDs", + "capability.userTitleGrant": "Grant user titles", + "capability.roomBackgroundGrant": "Grant room backgrounds", + "capability.userLevelGrant": "Grant wealth/VIP levels", + "capability.teamView": "My team", + "overview.currentApp": "Current App", + "overview.account": "External admin account", + "overview.permissions": "Permissions enabled", + "overview.enabled": "Enabled", + "overview.notEnabled": "Not enabled", + "users.title": "User management", + "users.search": "User ID / vanity ID / name", + "users.prettyId": "Vanity ID", + "users.countryGender": "Country / gender", + "users.level": "Level", + "users.levelSummary": "Wealth {wealth} / Charm {charm} / VIP {vip}", + "users.edit": "Edit", + "users.grantLevel": "Level", + "users.ban": "Ban", + "users.updated": "User information updated", + "users.banned": "User banned", + "users.levelGranted": "User level granted", + "users.operationFailed": "User operation failed", + "users.editTitle": "Edit user", + "users.avatar": "Avatar", + "users.name": "User name", + "users.gender": "Gender", + "users.countryCode": "Country/region code", + "users.notSet": "Not set", + "users.genderMale": "Male", + "users.genderFemale": "Female", + "users.genderUnknown": "Unknown", + "users.banTitle": "Ban user {user}", + "users.banDays": "Ban duration (days)", + "users.banForeverHint": "Enter 0 for a permanent ban", + "users.banReason": "Ban reason", + "users.levelTitle": "Grant level to {user}", + "users.levelType": "Level type", + "users.wealthLevel": "Wealth level", + "users.charmLevel": "Charm level", + "users.vipLevel": "VIP level", + "users.validDays": "Validity (days)", + "users.validDaysMin": "Validity must be at least 1 day", + "users.grantReason": "Grant reason", + "bans.title": "Account ban list", + "bans.reason": "Ban reason", + "bans.unban": "Unban", + "bans.unbanned": "User unbanned", + "bans.unbanFailed": "Failed to unban user", + "bans.confirmTitle": "Unban user", + "bans.confirmContent": "Unban {user}?", + "organization.title": "Organization", + "organization.agencies": "Agency list", + "organization.bds": "BD list", + "organization.bdManagers": "BD Manager list", + "organization.hosts": "Host list", + "organization.managers": "Manager list", + "organization.superAdmins": "Super Admin list", + "organization.team": "My team", + "organization.agency": "Agency", + "organization.owner": "Owner", + "organization.parentBd": "Parent BD", + "organization.member": "Member", + "organization.country": "Country / region", + "organization.userReference": "User {id}", + "organization.agencyReference": "Agency {id}", + "organization.teamSearch": "Agency ID / owner user ID / parent BD ID", + "organization.noPermission": "This account does not have access to this organization list.", + "organization.bdLeaderCount": "BD Leaders: {count}", + "organization.bdCount": "BDs: {count}", + "organization.agencyCount": "Agencies: {count}", + "organization.truncated": "The team exceeds the API limit. These results are incomplete.", + "rooms.title": "Room management", + "rooms.room": "Room", + "rooms.owner": "Owner", + "rooms.visibleRegion": "Visible region", + "rooms.search": "Room ID / name / owner", + "rooms.updated": "Room information updated", + "rooms.updateFailed": "Failed to update room", + "rooms.editTitle": "Edit room", + "rooms.background": "Room background", + "rooms.name": "Room name", + "rooms.description": "Room description", + "rooms.visibleRegionId": "Visible region ID", + "rooms.allRegions": "All", + "grants.title": "Resources & vanity IDs", + "grants.grantResource": "Grant item", + "grants.grantPrettyId": "Grant vanity ID", + "grants.availableResources": "Available resources", + "grants.targetUser": "Target user", + "grants.resource": "Resource", + "grants.quantity": "Quantity", + "grants.resourceGranted": "Privilege item granted", + "grants.prettyIdGranted": "Vanity ID granted", + "grants.failed": "Grant failed", + "grants.resourceTitle": "Grant privilege item", + "grants.prettyIdTitle": "Grant vanity ID", + "grants.target": "User ID / vanity ID", + "grants.currentTarget": "User ID / current vanity ID", + "grants.prettyId": "Vanity ID to grant", + "grants.validDays": "Validity (days)", + "grants.permanentHint": "Enter 0 for permanent validity", + "grants.reason": "Grant reason", + "grants.notice": "Before submission, the user is verified in the current App and the server-returned user ID is used for the grant.", + "grants.confirm": "Confirm grant", + "banners.title": "Banner management", + "banners.banner": "Banner", + "banners.type": "Type", + "banners.platform": "Platform", + "banners.sort": "Sort order", + "banners.create": "Create banner", + "banners.created": "Banner created", + "banners.createFailed": "Failed to create banner", + "banners.image": "Banner image", + "banners.roomImage": "In-room thumbnail", + "banners.description": "Description", + "banners.jumpType": "Destination type", + "banners.h5Param": "Parameter (H5 URL)", + "banners.appParam": "Parameter (App navigation JSON)", + "banners.displayScope": "Display scope", + "banners.scopeHome": "Home", + "banners.scopeRoom": "Room", + "banners.scopeRecharge": "Recharge", + "banners.scopeMe": "Me", + "banners.regionId": "Region ID", + "banners.countryCode": "Country code", + "banners.countryCodeHint": "2–3 letters; leave empty for all", + "banners.schedule": "Schedule", + "upload.preview": "{label} preview", + "upload.empty": "No image", + "upload.uploading": "Uploading", + "upload.replace": "Replace image", + "upload.select": "Upload image", + "upload.imageOnly": "Select an image file", + "upload.sizeLimit": "Images must not exceed 10 MB", + "upload.missingUrl": "The upload response did not contain an image URL", + "upload.failed": "Image upload failed", + "pagination.total": "{count} items", + "pagination.previous": "Previous", + "pagination.next": "Next", + "status.active": "Active", + "status.enabled": "Enabled", + "status.running": "Running", + "status.normal": "Normal", + "status.unbanned": "Unbanned", + "status.banned": "Banned", + "status.disabled": "Disabled", + "status.failed": "Failed", + "status.blocked": "Blocked", + "status.expired": "Expired", + "jump.appTarget": "App destination", + "jump.target.wallet": "Wallet", + "jump.target.room": "Specific room", + "jump.target.room_random": "Random room", + "jump.target.room_window": "Window in a specific room", + "jump.target.room_random_window": "Window in a random room", + "jump.target.room_game": "Game in a specific room", + "jump.target.room_random_game": "Game in a random room", + "jump.target.explore_game": "Explore game H5", + "jump.target.custom": "Custom JSON", + "jump.roomId": "Room ID", + "jump.window": "In-room window", + "jump.window.game_window": "Game window", + "jump.window.gift_panel": "Gift panel", + "jump.window.red_packet_claim": "Red packet dialog", + "jump.window.wheel": "Wheel window", + "jump.gameId": "Game ID", + "jump.giftId": "Gift ID", + "jump.redPacketNo": "Red packet number", + "jump.hint": "App navigation parameters are generated as JSON. You can edit the JSON directly for custom parameters.", + "time.unlimited": "Any time", + "time.quickHour": "Last hour", + "time.quickDay": "Last 24 hours", + "time.quickWeek": "Last 7 days", + "time.start": "Start time", + "time.end": "End time", + "time.startDate": "Start date", + "time.endDate": "End date", + "time.pending": "Not selected", + "time.startMark": "S", + "time.endMark": "E", + "time.previousMonth": "Previous month", + "time.nextMonth": "Next month", + "time.hour": "Hour", + "time.minute": "Minute", + "time.clear": "Clear", + "time.apply": "Apply", + "time.endAfterStart": "End time must be later than start time", + "time.endDateAfterStart": "End date cannot be earlier than start date", + "time.completeRange": "Select the full range", + "time.endLaterShort": "End must be later than start", + "time.endDateLaterShort": "End cannot be earlier than start", + "time.durationDays": "{count} days", + "time.durationHours": "{count} hours", + "time.durationMinutes": "{count} minutes", + "time.duration": "{value}", + "time.sameDay": "Same day", + "time.lessMinute": "Less than 1 minute", + "errors.loadFailed": "Failed to load data", + "errors.requestFailed": "Request failed", + "errors.unauthorized": "Your session has expired. Sign in again.", + "errors.forbidden": "You do not have permission to perform this operation.", + "errors.notFound": "The requested resource was not found.", + "errors.conflict": "This operation conflicts with the current data. Refresh and try again.", + "errors.tooLarge": "The request is too large.", + "errors.rateLimited": "Too many requests. Try again later.", + "errors.server": "The service is temporarily unavailable. Try again later.", + "errors.accountLocked": "This account is temporarily locked. Try again later.", + "errors.appInactive": "The App linked to this account is inactive.", + "errors.vipDisabled": "The VIP program for the current App is disabled", + "errors.vipLevelInvalid": "Select a VIP level configured for the current App", + "errors.organizationRoute": "This organization list has no secure data endpoint", + "errors.regionId": "Visible region ID must be a safe non-negative integer", + "errors.grantTarget": "No target user was found in the current App", + "errors.bannerType": "Invalid banner type", + "errors.bannerPlatform": "Invalid banner platform", + "errors.bannerStatus": "Invalid banner status", + "errors.bannerScope": "Select at least one display scope", + "errors.bannerImage": "Upload a banner image", + "errors.h5Url": "Enter a valid H5 URL", + "errors.h5Protocol": "H5 URLs must use http or https", + "errors.appParamRequired": "Enter App navigation parameters", + "errors.appParamObject": "App navigation parameters must be a JSON object", + "errors.appParamType": "The App navigation type is not supported", + "errors.appParamRoom": "This App destination requires room_id", + "errors.appParamGame": "This App destination requires game_id", + "errors.appParamWindow": "This App destination requires a valid window", + "errors.appParamGameCode": "Explore game H5 requires a valid game_code", + "errors.roomBannerImage": "Upload an in-room thumbnail for the room scope", + "errors.countryCode": "Country code must contain 2–3 letters", + "errors.bannerRegion": "Invalid region ID", + "errors.bannerSchedule": "The schedule end time must be later than the start time" +}; + +const zhCN = { + "app.title": "HYApp 外管后台", + "app.shortTitle": "HYApp 外管", + "language.label": "语言", + "common.actions": "操作", "common.active": "正常", "common.all": "全部", "common.allStatuses": "全部状态", "common.banned": "已封禁", "common.cancel": "取消", "common.confirm": "确认", "common.create": "创建", "common.disabled": "禁用", "common.edit": "编辑", "common.enabled": "启用", "common.expired": "过期", "common.loading": "正在加载", "common.noData": "当前无数据", "common.normal": "正常", "common.query": "查询", "common.reason": "原因", "common.reload": "重新加载", "common.retry": "重试", "common.save": "保存", "common.status": "状态", "common.user": "用户", + "auth.account": "外管账号", "auth.changePassword": "修改外管密码", "auth.checkingSession": "正在校验外管会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.firstLoginChange": "首次登录必须修改密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前外管账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordMin": "至少 8 个字符", "auth.passwordMinError": "新密码至少 8 个字符", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回权限总览", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码", + "nav.expand": "展开菜单", "nav.overview": "权限总览", "nav.users": "用户管理", "nav.bans": "封禁管理", "nav.hosts": "主播列表", "nav.agencies": "公会列表", "nav.bds": "BD 列表", "nav.bdManagers": "BD Manager 列表", "nav.superAdmins": "Super Admin 列表", "nav.rooms": "房间管理", "nav.grants": "资源与靓号", "nav.banners": "Banner 管理", "nav.team": "我的团队", + "capability.userList": "用户列表", "capability.userUpdate": "用户信息编辑", "capability.banList": "账号封禁列表", "capability.ban": "执行封禁", "capability.unban": "解除封禁", "capability.hostList": "主播列表", "capability.agencyList": "公会列表", "capability.bdList": "BD 列表", "capability.bdManagerList": "BD Manager 列表", "capability.superAdminList": "Super Admin 列表", "capability.roomList": "房间管理", "capability.roomUpdate": "房间编辑", "capability.privilegeList": "用户特权道具列表", "capability.privilegeGrant": "特权道具发放", "capability.bannerCreate": "Banner 创建", "capability.prettyIdGrant": "靓号下发", "capability.userTitleGrant": "用户称号下发", "capability.roomBackgroundGrant": "房间背景图下发", "capability.userLevelGrant": "财富/VIP 等级下发", "capability.teamView": "我的团队", + "overview.currentApp": "当前 App", "overview.account": "外管账号", "overview.permissions": "已开通权限", "overview.enabled": "已开通", "overview.notEnabled": "未开通", + "users.title": "用户管理", "users.search": "用户 ID / 短 ID / 名称", "users.prettyId": "短 ID(靓号)", "users.countryGender": "国家/性别", "users.level": "等级", "users.levelSummary": "财富 {wealth} / 魅力 {charm} / VIP {vip}", "users.edit": "编辑", "users.grantLevel": "等级", "users.ban": "封禁", "users.updated": "用户信息已更新", "users.banned": "用户已封禁", "users.levelGranted": "用户等级已下发", "users.operationFailed": "用户操作失败", "users.editTitle": "编辑用户", "users.avatar": "头像", "users.name": "用户名称", "users.gender": "性别", "users.countryCode": "国家/地区码", "users.notSet": "未设置", "users.genderMale": "男", "users.genderFemale": "女", "users.genderUnknown": "未知", "users.banTitle": "封禁用户 {user}", "users.banDays": "封禁天数", "users.banForeverHint": "填 0 表示永久封禁", "users.banReason": "封禁原因", "users.levelTitle": "等级下发 {user}", "users.levelType": "等级类型", "users.wealthLevel": "财富等级", "users.charmLevel": "魅力等级", "users.vipLevel": "VIP 等级", "users.validDays": "有效天数", "users.validDaysMin": "有效期至少 1 天", "users.grantReason": "下发原因", + "bans.title": "账号封禁列表", "bans.reason": "封禁原因", "bans.unban": "解除封禁", "bans.unbanned": "用户已解除封禁", "bans.unbanFailed": "解除封禁失败", "bans.confirmTitle": "解除封禁", "bans.confirmContent": "确认解除用户 {user} 的封禁?", + "organization.title": "组织管理", "organization.agencies": "公会列表", "organization.bds": "BD 列表", "organization.bdManagers": "BD Manager 列表", "organization.hosts": "主播列表", "organization.managers": "管理员列表", "organization.superAdmins": "Super Admin 列表", "organization.team": "我的团队", "organization.agency": "公会", "organization.owner": "负责人", "organization.parentBd": "上级 BD", "organization.member": "成员", "organization.country": "国家/地区", "organization.userReference": "用户 {id}", "organization.agencyReference": "公会 {id}", "organization.teamSearch": "公会 ID / 负责人用户 ID / 上级 BD ID", "organization.noPermission": "当前外管账号没有访问该组织列表的权限。", "organization.bdLeaderCount": "BD Leader:{count}", "organization.bdCount": "BD:{count}", "organization.agencyCount": "公会:{count}", "organization.truncated": "团队规模超过接口上限,当前结果不完整", + "rooms.title": "房间管理", "rooms.room": "房间", "rooms.owner": "房主", "rooms.visibleRegion": "可见区域", "rooms.search": "房间 ID / 名称 / 房主", "rooms.updated": "房间信息已更新", "rooms.updateFailed": "房间更新失败", "rooms.editTitle": "编辑房间", "rooms.background": "房间背景图", "rooms.name": "房间名称", "rooms.description": "房间介绍", "rooms.visibleRegionId": "可见区域 ID", "rooms.allRegions": "全部", + "grants.title": "资源与靓号", "grants.grantResource": "发放道具", "grants.grantPrettyId": "下发靓号", "grants.availableResources": "可发放资源", "grants.targetUser": "目标用户", "grants.resource": "资源", "grants.quantity": "数量", "grants.resourceGranted": "特权道具已发放", "grants.prettyIdGranted": "靓号已下发", "grants.failed": "发放失败", "grants.resourceTitle": "特权道具发放", "grants.prettyIdTitle": "靓号下发", "grants.target": "用户 ID / 短 ID(靓号)", "grants.currentTarget": "用户 ID / 当前短 ID", "grants.prettyId": "下发靓号", "grants.validDays": "有效天数", "grants.permanentHint": "填 0 表示永久有效", "grants.reason": "发放原因", "grants.notice": "提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。", "grants.confirm": "确认发放", + "banners.title": "Banner 管理", "banners.banner": "Banner", "banners.type": "类型", "banners.platform": "平台", "banners.sort": "排序", "banners.create": "创建 Banner", "banners.created": "Banner 已创建", "banners.createFailed": "Banner 创建失败", "banners.image": "Banner 图片", "banners.roomImage": "房间内小屏图", "banners.description": "描述", "banners.jumpType": "跳转类型", "banners.h5Param": "参数(H5 链接)", "banners.appParam": "参数(APP 公共跳转 JSON)", "banners.displayScope": "展示范围", "banners.scopeHome": "首页", "banners.scopeRoom": "房间", "banners.scopeRecharge": "充值", "banners.scopeMe": "我的", "banners.regionId": "区域 ID", "banners.countryCode": "国家码", "banners.countryCodeHint": "2-3 位字母,留空为全部", "banners.schedule": "投放时间", + "upload.preview": "{label}预览", "upload.empty": "暂无图片", "upload.uploading": "上传中", "upload.replace": "更换图片", "upload.select": "上传图片", "upload.imageOnly": "请选择图片文件", "upload.sizeLimit": "图片不能超过 10MB", "upload.missingUrl": "上传结果缺少图片地址", "upload.failed": "图片上传失败", + "pagination.total": "共 {count} 条", "pagination.previous": "上一页", "pagination.next": "下一页", + "status.active": "正常", "status.enabled": "启用", "status.running": "运行中", "status.normal": "正常", "status.unbanned": "未封禁", "status.banned": "已封禁", "status.disabled": "禁用", "status.failed": "失败", "status.blocked": "已拦截", "status.expired": "过期", + "jump.appTarget": "APP 目标", "jump.target.wallet": "钱包", "jump.target.room": "指定房间", "jump.target.room_random": "随机房间", "jump.target.room_window": "指定房内拉起窗口", "jump.target.room_random_window": "随机房内拉起窗口", "jump.target.room_game": "指定房内拉起游戏", "jump.target.room_random_game": "随机房内拉起游戏", "jump.target.explore_game": "Explore 游戏 H5", "jump.target.custom": "自定义 JSON", "jump.roomId": "房间 ID", "jump.window": "房内窗口", "jump.window.game_window": "游戏窗口", "jump.window.gift_panel": "礼物面板", "jump.window.red_packet_claim": "领红包弹窗", "jump.window.wheel": "转盘窗口", "jump.gameId": "游戏 ID", "jump.giftId": "礼物 ID", "jump.redPacketNo": "红包编号", "jump.hint": "APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。", + "time.unlimited": "不限", "time.quickHour": "近 1 小时", "time.quickDay": "近 24 小时", "time.quickWeek": "近 7 天", "time.start": "开始时间", "time.end": "结束时间", "time.startDate": "开始日期", "time.endDate": "结束日期", "time.pending": "待选择", "time.startMark": "始", "time.endMark": "末", "time.previousMonth": "上个月", "time.nextMonth": "下个月", "time.hour": "时", "time.minute": "分", "time.clear": "清空", "time.apply": "确定", "time.endAfterStart": "结束时间必须晚于开始时间", "time.endDateAfterStart": "结束日期不能早于开始日期", "time.completeRange": "选择完整区间", "time.endLaterShort": "结束需晚于开始", "time.endDateLaterShort": "结束不能早于开始", "time.durationDays": "{count} 天", "time.durationHours": "{count} 小时", "time.durationMinutes": "{count} 分钟", "time.duration": "间隔 {value}", "time.sameDay": "同一天", "time.lessMinute": "间隔少于 1 分钟", + "errors.loadFailed": "数据加载失败", "errors.requestFailed": "请求失败", "errors.unauthorized": "会话已失效,请重新登录。", "errors.forbidden": "你没有执行此操作的权限。", "errors.notFound": "未找到请求的资源。", "errors.conflict": "当前数据状态存在冲突,请刷新后重试。", "errors.tooLarge": "请求内容过大。", "errors.rateLimited": "请求过于频繁,请稍后重试。", "errors.server": "服务暂时不可用,请稍后重试。", "errors.accountLocked": "账号已临时锁定,请稍后重试。", "errors.appInactive": "该账号关联的 App 已停用。", "errors.vipDisabled": "当前 App 的 VIP 体系已停用", "errors.vipLevelInvalid": "请选择当前 App 已配置的 VIP 等级", "errors.organizationRoute": "该组织列表尚无安全的数据接口", "errors.regionId": "可见区域 ID 必须是安全的非负整数", "errors.grantTarget": "未找到当前 App 内的目标用户", "errors.bannerType": "Banner 类型不正确", "errors.bannerPlatform": "Banner 平台不正确", "errors.bannerStatus": "Banner 状态不正确", "errors.bannerScope": "请至少选择一个展示范围", "errors.bannerImage": "请上传 Banner 图片", "errors.h5Url": "请填写合法的 H5 链接", "errors.h5Protocol": "H5 链接仅支持 http 或 https", "errors.appParamRequired": "请填写 APP 跳转参数", "errors.appParamObject": "APP 跳转参数必须是 JSON 对象", "errors.appParamType": "APP 跳转 type 不在公共跳转方案内", "errors.appParamRoom": "该 APP 跳转需要填写 room_id", "errors.appParamGame": "该 APP 跳转需要填写 game_id", "errors.appParamWindow": "该 APP 跳转需要填写有效 window", "errors.appParamGameCode": "Explore 游戏 H5 需要选择有效 game_code", "errors.roomBannerImage": "房间范围必须上传房间内小屏图", "errors.countryCode": "国家码必须是 2-3 位字母", "errors.bannerRegion": "区域 ID 不正确", "errors.bannerSchedule": "投放结束时间必须晚于开始时间" +}; + +const ar = { + ...en, + "app.title": "إدارة HYApp الخارجية", "app.shortTitle": "إدارة HYApp الخارجية", "language.label": "اللغة", + "common.actions": "الإجراءات", "common.active": "نشط", "common.all": "الكل", "common.allStatuses": "كل الحالات", "common.banned": "محظور", "common.cancel": "إلغاء", "common.confirm": "تأكيد", "common.create": "إنشاء", "common.disabled": "معطّل", "common.edit": "تعديل", "common.enabled": "مفعّل", "common.expired": "منتهي", "common.loading": "جارٍ التحميل", "common.noData": "لا توجد بيانات", "common.normal": "طبيعي", "common.query": "بحث", "common.reason": "السبب", "common.reload": "إعادة التحميل", "common.retry": "إعادة المحاولة", "common.save": "حفظ", "common.status": "الحالة", "common.user": "المستخدم", + "auth.account": "حساب الإدارة الخارجية", "auth.changePassword": "تغيير كلمة مرور الإدارة الخارجية", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.firstLoginChange": "يجب تغيير كلمة المرور عند تسجيل الدخول لأول مرة", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordMin": "8 أحرف على الأقل", "auth.passwordMinError": "يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى ملخص الصلاحيات", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور", + "nav.expand": "فتح القائمة", "nav.overview": "ملخص الصلاحيات", "nav.users": "إدارة المستخدمين", "nav.bans": "إدارة الحظر", "nav.hosts": "المضيفون", "nav.agencies": "الوكالات", "nav.bds": "قائمة BD", "nav.bdManagers": "مديرو BD", "nav.superAdmins": "المشرفون العامون", "nav.rooms": "إدارة الغرف", "nav.grants": "الموارد والمعرّفات المميزة", "nav.banners": "إدارة اللافتات", "nav.team": "فريقي", + "capability.userList": "قائمة المستخدمين", "capability.userUpdate": "تعديل بيانات المستخدم", "capability.banList": "قائمة الحسابات المحظورة", "capability.ban": "حظر المستخدمين", "capability.unban": "إلغاء حظر المستخدمين", "capability.hostList": "قائمة المضيفين", "capability.agencyList": "قائمة الوكالات", "capability.bdList": "قائمة BD", "capability.bdManagerList": "قائمة مديري BD", "capability.superAdminList": "قائمة المشرفين العامين", "capability.roomList": "إدارة الغرف", "capability.roomUpdate": "تعديل الغرف", "capability.privilegeList": "قائمة عناصر الامتياز", "capability.privilegeGrant": "منح عناصر الامتياز", "capability.bannerCreate": "إنشاء اللافتات", "capability.prettyIdGrant": "منح المعرّفات المميزة", "capability.userTitleGrant": "منح ألقاب المستخدمين", "capability.roomBackgroundGrant": "منح خلفيات الغرف", "capability.userLevelGrant": "منح مستويات الثروة/VIP", "capability.teamView": "فريقي", + "overview.currentApp": "التطبيق الحالي", "overview.account": "حساب الإدارة الخارجية", "overview.permissions": "الصلاحيات المفعّلة", "overview.enabled": "مفعّلة", "overview.notEnabled": "غير مفعّلة", + "users.title": "إدارة المستخدمين", "users.search": "معرّف المستخدم / المعرّف المميز / الاسم", "users.prettyId": "المعرّف المميز", "users.countryGender": "الدولة / الجنس", "users.level": "المستوى", "users.levelSummary": "الثروة {wealth} / الجاذبية {charm} / VIP {vip}", "users.edit": "تعديل", "users.grantLevel": "المستوى", "users.ban": "حظر", "users.updated": "تم تحديث بيانات المستخدم", "users.banned": "تم حظر المستخدم", "users.levelGranted": "تم منح مستوى المستخدم", "users.operationFailed": "فشلت عملية المستخدم", "users.editTitle": "تعديل المستخدم", "users.avatar": "الصورة الشخصية", "users.name": "اسم المستخدم", "users.gender": "الجنس", "users.countryCode": "رمز الدولة/المنطقة", "users.notSet": "غير محدد", "users.genderMale": "ذكر", "users.genderFemale": "أنثى", "users.genderUnknown": "غير معروف", "users.banTitle": "حظر المستخدم {user}", "users.banDays": "مدة الحظر (بالأيام)", "users.banForeverHint": "أدخل 0 للحظر الدائم", "users.banReason": "سبب الحظر", "users.levelTitle": "منح مستوى إلى {user}", "users.levelType": "نوع المستوى", "users.wealthLevel": "مستوى الثروة", "users.charmLevel": "مستوى الجاذبية", "users.vipLevel": "مستوى VIP", "users.validDays": "الصلاحية (بالأيام)", "users.validDaysMin": "يجب ألا تقل الصلاحية عن يوم واحد", "users.grantReason": "سبب المنح", + "bans.title": "قائمة الحسابات المحظورة", "bans.reason": "سبب الحظر", "bans.unban": "إلغاء الحظر", "bans.unbanned": "تم إلغاء حظر المستخدم", "bans.unbanFailed": "فشل إلغاء حظر المستخدم", "bans.confirmTitle": "إلغاء حظر المستخدم", "bans.confirmContent": "هل تريد إلغاء حظر {user}؟", + "organization.title": "المؤسسة", "organization.agencies": "قائمة الوكالات", "organization.bds": "قائمة BD", "organization.bdManagers": "قائمة مديري BD", "organization.hosts": "قائمة المضيفين", "organization.managers": "قائمة المديرين", "organization.superAdmins": "قائمة المشرفين العامين", "organization.team": "فريقي", "organization.agency": "الوكالة", "organization.owner": "المالك", "organization.parentBd": "BD الأعلى", "organization.member": "العضو", "organization.country": "الدولة / المنطقة", "organization.userReference": "المستخدم {id}", "organization.agencyReference": "الوكالة {id}", "organization.teamSearch": "معرّف الوكالة / معرّف المالك / معرّف BD الأعلى", "organization.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى قائمة المؤسسة هذه.", "organization.bdLeaderCount": "قادة BD: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "الوكالات: {count}", "organization.truncated": "يتجاوز حجم الفريق حد الواجهة. النتائج الحالية غير مكتملة.", + "rooms.title": "إدارة الغرف", "rooms.room": "الغرفة", "rooms.owner": "المالك", "rooms.visibleRegion": "المنطقة المرئية", "rooms.search": "معرّف الغرفة / الاسم / المالك", "rooms.updated": "تم تحديث بيانات الغرفة", "rooms.updateFailed": "فشل تحديث الغرفة", "rooms.editTitle": "تعديل الغرفة", "rooms.background": "خلفية الغرفة", "rooms.name": "اسم الغرفة", "rooms.description": "وصف الغرفة", "rooms.visibleRegionId": "معرّف المنطقة المرئية", "rooms.allRegions": "الكل", + "grants.title": "الموارد والمعرّفات المميزة", "grants.grantResource": "منح عنصر", "grants.grantPrettyId": "منح معرّف مميز", "grants.availableResources": "الموارد المتاحة", "grants.targetUser": "المستخدم المستهدف", "grants.resource": "المورد", "grants.quantity": "الكمية", "grants.resourceGranted": "تم منح عنصر الامتياز", "grants.prettyIdGranted": "تم منح المعرّف المميز", "grants.failed": "فشل المنح", "grants.resourceTitle": "منح عنصر امتياز", "grants.prettyIdTitle": "منح معرّف مميز", "grants.target": "معرّف المستخدم / المعرّف المميز", "grants.currentTarget": "معرّف المستخدم / المعرّف المميز الحالي", "grants.prettyId": "المعرّف المميز المراد منحه", "grants.validDays": "الصلاحية (بالأيام)", "grants.permanentHint": "أدخل 0 للصلاحية الدائمة", "grants.reason": "سبب المنح", "grants.notice": "قبل الإرسال، يتم التحقق من المستخدم داخل التطبيق الحالي ويُستخدم معرّف المستخدم الذي يعيده الخادم.", "grants.confirm": "تأكيد المنح", + "banners.title": "إدارة اللافتات", "banners.banner": "اللافتة", "banners.type": "النوع", "banners.platform": "المنصة", "banners.sort": "الترتيب", "banners.create": "إنشاء لافتة", "banners.created": "تم إنشاء اللافتة", "banners.createFailed": "فشل إنشاء اللافتة", "banners.image": "صورة اللافتة", "banners.roomImage": "الصورة المصغرة داخل الغرفة", "banners.description": "الوصف", "banners.jumpType": "نوع الوجهة", "banners.h5Param": "المعامل (رابط H5)", "banners.appParam": "المعامل (JSON للتنقل داخل التطبيق)", "banners.displayScope": "نطاق العرض", "banners.scopeHome": "الرئيسية", "banners.scopeRoom": "الغرفة", "banners.scopeRecharge": "الشحن", "banners.scopeMe": "حسابي", "banners.regionId": "معرّف المنطقة", "banners.countryCode": "رمز الدولة", "banners.countryCodeHint": "حرفان أو 3؛ اتركه فارغًا للكل", "banners.schedule": "جدول العرض", + "upload.preview": "معاينة {label}", "upload.empty": "لا توجد صورة", "upload.uploading": "جارٍ الرفع", "upload.replace": "استبدال الصورة", "upload.select": "رفع صورة", "upload.imageOnly": "اختر ملف صورة", "upload.sizeLimit": "يجب ألا يتجاوز حجم الصورة 10 ميغابايت", "upload.missingUrl": "لا تحتوي استجابة الرفع على رابط للصورة", "upload.failed": "فشل رفع الصورة", + "pagination.total": "{count} عنصر", "pagination.previous": "السابق", "pagination.next": "التالي", + "status.active": "نشط", "status.enabled": "مفعّل", "status.running": "قيد التشغيل", "status.normal": "طبيعي", "status.unbanned": "غير محظور", "status.banned": "محظور", "status.disabled": "معطّل", "status.failed": "فشل", "status.blocked": "محجوب", "status.expired": "منتهي", + "jump.appTarget": "وجهة التطبيق", "jump.target.wallet": "المحفظة", "jump.target.room": "غرفة محددة", "jump.target.room_random": "غرفة عشوائية", "jump.target.room_window": "نافذة في غرفة محددة", "jump.target.room_random_window": "نافذة في غرفة عشوائية", "jump.target.room_game": "لعبة في غرفة محددة", "jump.target.room_random_game": "لعبة في غرفة عشوائية", "jump.target.explore_game": "لعبة Explore H5", "jump.target.custom": "JSON مخصص", "jump.roomId": "معرّف الغرفة", "jump.window": "نافذة داخل الغرفة", "jump.window.game_window": "نافذة اللعبة", "jump.window.gift_panel": "لوحة الهدايا", "jump.window.red_packet_claim": "نافذة الظرف الأحمر", "jump.window.wheel": "نافذة العجلة", "jump.gameId": "معرّف اللعبة", "jump.giftId": "معرّف الهدية", "jump.redPacketNo": "رقم الظرف الأحمر", "jump.hint": "يتم إنشاء معاملات التنقل داخل التطبيق بصيغة JSON، ويمكنك تعديلها مباشرة للمعاملات المخصصة.", + "time.unlimited": "أي وقت", "time.quickHour": "آخر ساعة", "time.quickDay": "آخر 24 ساعة", "time.quickWeek": "آخر 7 أيام", "time.start": "وقت البدء", "time.end": "وقت الانتهاء", "time.startDate": "تاريخ البدء", "time.endDate": "تاريخ الانتهاء", "time.pending": "غير محدد", "time.startMark": "ب", "time.endMark": "ن", "time.previousMonth": "الشهر السابق", "time.nextMonth": "الشهر التالي", "time.hour": "الساعة", "time.minute": "الدقيقة", "time.clear": "مسح", "time.apply": "تطبيق", "time.endAfterStart": "يجب أن يكون وقت الانتهاء بعد وقت البدء", "time.endDateAfterStart": "لا يمكن أن يسبق تاريخ الانتهاء تاريخ البدء", "time.completeRange": "حدد النطاق الكامل", "time.endLaterShort": "يجب أن تكون النهاية بعد البداية", "time.endDateLaterShort": "لا يمكن أن تسبق النهاية البداية", "time.durationDays": "{count} يوم", "time.durationHours": "{count} ساعة", "time.durationMinutes": "{count} دقيقة", "time.duration": "{value}", "time.sameDay": "اليوم نفسه", "time.lessMinute": "أقل من دقيقة واحدة", + "errors.loadFailed": "فشل تحميل البيانات", "errors.requestFailed": "فشل الطلب", "errors.unauthorized": "انتهت الجلسة. سجّل الدخول مرة أخرى.", "errors.forbidden": "ليس لديك إذن لتنفيذ هذه العملية.", "errors.notFound": "لم يتم العثور على المورد المطلوب.", "errors.conflict": "تتعارض العملية مع البيانات الحالية. حدّث الصفحة وحاول مجددًا.", "errors.tooLarge": "حجم الطلب كبير جدًا.", "errors.rateLimited": "طلبات كثيرة جدًا. حاول لاحقًا.", "errors.server": "الخدمة غير متاحة مؤقتًا. حاول لاحقًا.", "errors.accountLocked": "الحساب مقفل مؤقتًا. حاول لاحقًا.", "errors.appInactive": "التطبيق المرتبط بهذا الحساب غير نشط.", "errors.vipDisabled": "نظام VIP معطّل في التطبيق الحالي", "errors.vipLevelInvalid": "اختر مستوى VIP معدًا للتطبيق الحالي", "errors.organizationRoute": "لا توجد نقطة بيانات آمنة لقائمة المؤسسة هذه", "errors.regionId": "يجب أن يكون معرّف المنطقة عددًا صحيحًا آمنًا وغير سالب", "errors.grantTarget": "لم يتم العثور على المستخدم المستهدف في التطبيق الحالي", "errors.bannerType": "نوع اللافتة غير صالح", "errors.bannerPlatform": "منصة اللافتة غير صالحة", "errors.bannerStatus": "حالة اللافتة غير صالحة", "errors.bannerScope": "اختر نطاق عرض واحدًا على الأقل", "errors.bannerImage": "ارفع صورة للافتة", "errors.h5Url": "أدخل رابط H5 صالحًا", "errors.h5Protocol": "يجب أن يستخدم رابط H5 بروتوكول http أو https", "errors.appParamRequired": "أدخل معاملات التنقل داخل التطبيق", "errors.appParamObject": "يجب أن تكون معاملات التطبيق كائن JSON", "errors.appParamType": "نوع التنقل داخل التطبيق غير مدعوم", "errors.appParamRoom": "تتطلب هذه الوجهة room_id", "errors.appParamGame": "تتطلب هذه الوجهة game_id", "errors.appParamWindow": "تتطلب هذه الوجهة نافذة صالحة", "errors.appParamGameCode": "تتطلب لعبة Explore H5 قيمة game_code صالحة", "errors.roomBannerImage": "ارفع صورة مصغرة لنطاق الغرفة", "errors.countryCode": "يجب أن يتكون رمز الدولة من حرفين أو 3", "errors.bannerRegion": "معرّف المنطقة غير صالح", "errors.bannerSchedule": "يجب أن يكون وقت انتهاء العرض بعد وقت البدء" +}; + +const tr = { + ...en, + "app.title": "HYApp Harici Yönetim", "app.shortTitle": "HYApp Harici Yönetim", "language.label": "Dil", + "common.actions": "İşlemler", "common.active": "Aktif", "common.all": "Tümü", "common.allStatuses": "Tüm durumlar", "common.banned": "Yasaklı", "common.cancel": "İptal", "common.confirm": "Onayla", "common.create": "Oluştur", "common.disabled": "Devre dışı", "common.edit": "Düzenle", "common.enabled": "Etkin", "common.expired": "Süresi dolmuş", "common.loading": "Yükleniyor", "common.noData": "Veri yok", "common.normal": "Normal", "common.query": "Ara", "common.reason": "Neden", "common.reload": "Yeniden yükle", "common.retry": "Tekrar dene", "common.save": "Kaydet", "common.status": "Durum", "common.user": "Kullanıcı", + "auth.account": "Harici yönetim hesabı", "auth.changePassword": "Harici yönetim parolasını değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.firstLoginChange": "İlk girişte parolanızı değiştirmeniz gerekir", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": "Çıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu harici yönetim hesabının bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordMin": "En az 8 karakter", "auth.passwordMinError": "Yeni parola en az 8 karakter olmalıdır", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Yetki özetine dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", + "nav.expand": "Menüyü aç", "nav.overview": "Yetki özeti", "nav.users": "Kullanıcı yönetimi", "nav.bans": "Yasak yönetimi", "nav.hosts": "Yayıncılar", "nav.agencies": "Ajanslar", "nav.bds": "BD listesi", "nav.bdManagers": "BD Yöneticileri", "nav.superAdmins": "Süper Yöneticiler", "nav.rooms": "Oda yönetimi", "nav.grants": "Kaynaklar ve özel ID'ler", "nav.banners": "Banner yönetimi", "nav.team": "Ekibim", + "capability.userList": "Kullanıcı listesi", "capability.userUpdate": "Kullanıcı bilgilerini düzenleme", "capability.banList": "Yasaklı hesap listesi", "capability.ban": "Kullanıcı yasaklama", "capability.unban": "Kullanıcı yasağını kaldırma", "capability.hostList": "Yayıncı listesi", "capability.agencyList": "Ajans listesi", "capability.bdList": "BD listesi", "capability.bdManagerList": "BD Yöneticisi listesi", "capability.superAdminList": "Süper Yönetici listesi", "capability.roomList": "Oda yönetimi", "capability.roomUpdate": "Oda düzenleme", "capability.privilegeList": "Ayrıcalık öğesi listesi", "capability.privilegeGrant": "Ayrıcalık öğesi verme", "capability.bannerCreate": "Banner oluşturma", "capability.prettyIdGrant": "Özel ID verme", "capability.userTitleGrant": "Kullanıcı unvanı verme", "capability.roomBackgroundGrant": "Oda arka planı verme", "capability.userLevelGrant": "Servet/VIP seviyesi verme", "capability.teamView": "Ekibim", + "overview.currentApp": "Geçerli uygulama", "overview.account": "Harici yönetim hesabı", "overview.permissions": "Etkin yetkiler", "overview.enabled": "Etkin", "overview.notEnabled": "Etkin değil", + "users.title": "Kullanıcı yönetimi", "users.search": "Kullanıcı ID / özel ID / ad", "users.prettyId": "Özel ID", "users.countryGender": "Ülke / cinsiyet", "users.level": "Seviye", "users.levelSummary": "Servet {wealth} / Cazibe {charm} / VIP {vip}", "users.edit": "Düzenle", "users.grantLevel": "Seviye", "users.ban": "Yasakla", "users.updated": "Kullanıcı bilgileri güncellendi", "users.banned": "Kullanıcı yasaklandı", "users.levelGranted": "Kullanıcı seviyesi verildi", "users.operationFailed": "Kullanıcı işlemi başarısız", "users.editTitle": "Kullanıcıyı düzenle", "users.avatar": "Avatar", "users.name": "Kullanıcı adı", "users.gender": "Cinsiyet", "users.countryCode": "Ülke/bölge kodu", "users.notSet": "Ayarlanmadı", "users.genderMale": "Erkek", "users.genderFemale": "Kadın", "users.genderUnknown": "Bilinmiyor", "users.banTitle": "{user} kullanıcısını yasakla", "users.banDays": "Yasak süresi (gün)", "users.banForeverHint": "Kalıcı yasak için 0 girin", "users.banReason": "Yasak nedeni", "users.levelTitle": "{user} kullanıcısına seviye ver", "users.levelType": "Seviye türü", "users.wealthLevel": "Servet seviyesi", "users.charmLevel": "Cazibe seviyesi", "users.vipLevel": "VIP seviyesi", "users.validDays": "Geçerlilik (gün)", "users.validDaysMin": "Geçerlilik en az 1 gün olmalıdır", "users.grantReason": "Verme nedeni", + "bans.title": "Yasaklı hesap listesi", "bans.reason": "Yasak nedeni", "bans.unban": "Yasağı kaldır", "bans.unbanned": "Kullanıcı yasağı kaldırıldı", "bans.unbanFailed": "Kullanıcı yasağı kaldırılamadı", "bans.confirmTitle": "Kullanıcı yasağını kaldır", "bans.confirmContent": "{user} kullanıcısının yasağı kaldırılsın mı?", + "organization.title": "Organizasyon", "organization.agencies": "Ajans listesi", "organization.bds": "BD listesi", "organization.bdManagers": "BD Yöneticisi listesi", "organization.hosts": "Yayıncı listesi", "organization.managers": "Yönetici listesi", "organization.superAdmins": "Süper Yönetici listesi", "organization.team": "Ekibim", "organization.agency": "Ajans", "organization.owner": "Sahip", "organization.parentBd": "Üst BD", "organization.member": "Üye", "organization.country": "Ülke / bölge", "organization.userReference": "Kullanıcı {id}", "organization.agencyReference": "Ajans {id}", "organization.teamSearch": "Ajans ID / sahip kullanıcı ID / üst BD ID", "organization.noPermission": "Bu hesabın bu organizasyon listesine erişimi yok.", "organization.bdLeaderCount": "BD Liderleri: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "Ajanslar: {count}", "organization.truncated": "Ekip API sınırını aşıyor. Mevcut sonuçlar eksik.", + "rooms.title": "Oda yönetimi", "rooms.room": "Oda", "rooms.owner": "Sahip", "rooms.visibleRegion": "Görünür bölge", "rooms.search": "Oda ID / ad / sahip", "rooms.updated": "Oda bilgileri güncellendi", "rooms.updateFailed": "Oda güncellenemedi", "rooms.editTitle": "Odayı düzenle", "rooms.background": "Oda arka planı", "rooms.name": "Oda adı", "rooms.description": "Oda açıklaması", "rooms.visibleRegionId": "Görünür bölge ID", "rooms.allRegions": "Tümü", + "grants.title": "Kaynaklar ve özel ID'ler", "grants.grantResource": "Öğe ver", "grants.grantPrettyId": "Özel ID ver", "grants.availableResources": "Kullanılabilir kaynaklar", "grants.targetUser": "Hedef kullanıcı", "grants.resource": "Kaynak", "grants.quantity": "Miktar", "grants.resourceGranted": "Ayrıcalık öğesi verildi", "grants.prettyIdGranted": "Özel ID verildi", "grants.failed": "Verme işlemi başarısız", "grants.resourceTitle": "Ayrıcalık öğesi ver", "grants.prettyIdTitle": "Özel ID ver", "grants.target": "Kullanıcı ID / özel ID", "grants.currentTarget": "Kullanıcı ID / mevcut özel ID", "grants.prettyId": "Verilecek özel ID", "grants.validDays": "Geçerlilik (gün)", "grants.permanentHint": "Kalıcı geçerlilik için 0 girin", "grants.reason": "Verme nedeni", "grants.notice": "Göndermeden önce kullanıcı geçerli uygulamada doğrulanır ve sunucunun döndürdüğü kullanıcı ID kullanılır.", "grants.confirm": "Vermeyi onayla", + "banners.title": "Banner yönetimi", "banners.banner": "Banner", "banners.type": "Tür", "banners.platform": "Platform", "banners.sort": "Sıralama", "banners.create": "Banner oluştur", "banners.created": "Banner oluşturuldu", "banners.createFailed": "Banner oluşturulamadı", "banners.image": "Banner görseli", "banners.roomImage": "Oda içi küçük görsel", "banners.description": "Açıklama", "banners.jumpType": "Hedef türü", "banners.h5Param": "Parametre (H5 bağlantısı)", "banners.appParam": "Parametre (uygulama yönlendirme JSON'u)", "banners.displayScope": "Gösterim kapsamı", "banners.scopeHome": "Ana sayfa", "banners.scopeRoom": "Oda", "banners.scopeRecharge": "Yükleme", "banners.scopeMe": "Ben", "banners.regionId": "Bölge ID", "banners.countryCode": "Ülke kodu", "banners.countryCodeHint": "2–3 harf; tümü için boş bırakın", "banners.schedule": "Gösterim zamanı", + "upload.preview": "{label} önizlemesi", "upload.empty": "Görsel yok", "upload.uploading": "Yükleniyor", "upload.replace": "Görseli değiştir", "upload.select": "Görsel yükle", "upload.imageOnly": "Bir görsel dosyası seçin", "upload.sizeLimit": "Görseller 10 MB'ı aşamaz", "upload.missingUrl": "Yükleme yanıtında görsel URL'si yok", "upload.failed": "Görsel yüklenemedi", + "pagination.total": "{count} öğe", "pagination.previous": "Önceki", "pagination.next": "Sonraki", + "status.active": "Aktif", "status.enabled": "Etkin", "status.running": "Çalışıyor", "status.normal": "Normal", "status.unbanned": "Yasaksız", "status.banned": "Yasaklı", "status.disabled": "Devre dışı", "status.failed": "Başarısız", "status.blocked": "Engelli", "status.expired": "Süresi dolmuş", + "jump.appTarget": "Uygulama hedefi", "jump.target.wallet": "Cüzdan", "jump.target.room": "Belirli oda", "jump.target.room_random": "Rastgele oda", "jump.target.room_window": "Belirli odada pencere", "jump.target.room_random_window": "Rastgele odada pencere", "jump.target.room_game": "Belirli odada oyun", "jump.target.room_random_game": "Rastgele odada oyun", "jump.target.explore_game": "Explore oyunu H5", "jump.target.custom": "Özel JSON", "jump.roomId": "Oda ID", "jump.window": "Oda içi pencere", "jump.window.game_window": "Oyun penceresi", "jump.window.gift_panel": "Hediye paneli", "jump.window.red_packet_claim": "Kırmızı paket penceresi", "jump.window.wheel": "Çark penceresi", "jump.gameId": "Oyun ID", "jump.giftId": "Hediye ID", "jump.redPacketNo": "Kırmızı paket numarası", "jump.hint": "Uygulama yönlendirme parametreleri JSON olarak oluşturulur; özel parametreler için JSON'u doğrudan düzenleyebilirsiniz.", + "time.unlimited": "Sınırsız", "time.quickHour": "Son 1 saat", "time.quickDay": "Son 24 saat", "time.quickWeek": "Son 7 gün", "time.start": "Başlangıç zamanı", "time.end": "Bitiş zamanı", "time.startDate": "Başlangıç tarihi", "time.endDate": "Bitiş tarihi", "time.pending": "Seçilmedi", "time.startMark": "B", "time.endMark": "S", "time.previousMonth": "Önceki ay", "time.nextMonth": "Sonraki ay", "time.hour": "Saat", "time.minute": "Dakika", "time.clear": "Temizle", "time.apply": "Uygula", "time.endAfterStart": "Bitiş zamanı başlangıçtan sonra olmalıdır", "time.endDateAfterStart": "Bitiş tarihi başlangıçtan önce olamaz", "time.completeRange": "Tam aralığı seçin", "time.endLaterShort": "Bitiş başlangıçtan sonra olmalı", "time.endDateLaterShort": "Bitiş başlangıçtan önce olamaz", "time.durationDays": "{count} gün", "time.durationHours": "{count} saat", "time.durationMinutes": "{count} dakika", "time.duration": "{value}", "time.sameDay": "Aynı gün", "time.lessMinute": "1 dakikadan az", + "errors.loadFailed": "Veriler yüklenemedi", "errors.requestFailed": "İstek başarısız", "errors.unauthorized": "Oturumunuz sona erdi. Tekrar giriş yapın.", "errors.forbidden": "Bu işlemi yapma yetkiniz yok.", "errors.notFound": "İstenen kaynak bulunamadı.", "errors.conflict": "İşlem mevcut verilerle çakışıyor. Yenileyip tekrar deneyin.", "errors.tooLarge": "İstek çok büyük.", "errors.rateLimited": "Çok fazla istek gönderildi. Daha sonra tekrar deneyin.", "errors.server": "Hizmet geçici olarak kullanılamıyor. Daha sonra tekrar deneyin.", "errors.accountLocked": "Hesap geçici olarak kilitlendi. Daha sonra tekrar deneyin.", "errors.appInactive": "Bu hesaba bağlı uygulama etkin değil.", "errors.vipDisabled": "Geçerli uygulamanın VIP programı devre dışı", "errors.vipLevelInvalid": "Geçerli uygulama için yapılandırılmış bir VIP seviyesi seçin", "errors.organizationRoute": "Bu organizasyon listesi için güvenli veri uç noktası yok", "errors.regionId": "Görünür bölge ID güvenli ve negatif olmayan bir tam sayı olmalıdır", "errors.grantTarget": "Geçerli uygulamada hedef kullanıcı bulunamadı", "errors.bannerType": "Geçersiz banner türü", "errors.bannerPlatform": "Geçersiz banner platformu", "errors.bannerStatus": "Geçersiz banner durumu", "errors.bannerScope": "En az bir gösterim kapsamı seçin", "errors.bannerImage": "Bir banner görseli yükleyin", "errors.h5Url": "Geçerli bir H5 bağlantısı girin", "errors.h5Protocol": "H5 bağlantıları http veya https kullanmalıdır", "errors.appParamRequired": "Uygulama yönlendirme parametrelerini girin", "errors.appParamObject": "Uygulama parametreleri bir JSON nesnesi olmalıdır", "errors.appParamType": "Uygulama yönlendirme türü desteklenmiyor", "errors.appParamRoom": "Bu hedef room_id gerektirir", "errors.appParamGame": "Bu hedef game_id gerektirir", "errors.appParamWindow": "Bu hedef geçerli bir window gerektirir", "errors.appParamGameCode": "Explore oyunu H5 geçerli bir game_code gerektirir", "errors.roomBannerImage": "Oda kapsamı için oda içi küçük görsel yükleyin", "errors.countryCode": "Ülke kodu 2–3 harften oluşmalıdır", "errors.bannerRegion": "Geçersiz bölge ID", "errors.bannerSchedule": "Gösterim bitiş zamanı başlangıçtan sonra olmalıdır" +}; + +export const externalMessages = Object.freeze({ ar, en, tr, "zh-CN": zhCN }); + +export function normalizeExternalLocale(value) { + return EXTERNAL_LOCALES.includes(value) ? value : DEFAULT_EXTERNAL_LOCALE; +} + +export function externalDirection(locale) { + return locale === "ar" ? "rtl" : "ltr"; +} + +export function externalMessage(locale, key) { + return externalMessages[normalizeExternalLocale(locale)]?.[key] ?? en[key] ?? key; +} diff --git a/external-admin/src/i18n/messages.test.js b/external-admin/src/i18n/messages.test.js new file mode 100644 index 0000000..7fa4dbb --- /dev/null +++ b/external-admin/src/i18n/messages.test.js @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { translateExternalError } from "./errors.js"; +import { EXTERNAL_LANGUAGE_OPTIONS, externalDirection, externalMessage, externalMessages } from "./messages.js"; + +describe("external admin translations", () => { + test("keeps all four locale catalogs structurally complete", () => { + const englishKeys = Object.keys(externalMessages.en).sort(); + expect(englishKeys.length).toBeGreaterThan(250); + for (const locale of ["zh-CN", "ar", "tr"]) { + expect(Object.keys(externalMessages[locale]).sort()).toEqual(englishKeys); + } + }); + + test("uses native language names and Arabic RTL only", () => { + expect(EXTERNAL_LANGUAGE_OPTIONS.map((item) => item.label)).toEqual(["English", "简体中文", "العربية", "Türkçe"]); + expect(externalDirection("ar")).toBe("rtl"); + expect(externalDirection("en")).toBe("ltr"); + expect(externalDirection("zh-CN")).toBe("ltr"); + expect(externalDirection("tr")).toBe("ltr"); + }); + + test("never exposes an unknown backend-language message", () => { + const t = (key) => externalMessage("en", key); + expect(translateExternalError({ message: "服务端中文错误" }, t)).toBe("Request failed"); + expect(translateExternalError({ message: "服务端中文错误", status: 429 }, t)).toBe("Too many requests. Try again later."); + }); +}); diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx index 59a5787..3ff4361 100644 --- a/external-admin/src/layout/ExternalAdminLayout.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -31,21 +31,23 @@ import { useMemo, useState } from "react"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasAnyExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; const drawerWidth = 248; const navigation = [ - { icon: DashboardOutlined, label: "权限总览", path: "/overview" }, - { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, label: "用户管理", path: "/users" }, - { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, label: "封禁管理", path: "/bans" }, - { capabilities: ["host:list"], icon: BadgeOutlined, label: "主播列表", path: "/organization/hosts" }, - { capabilities: ["agency:list"], icon: ApartmentOutlined, label: "公会列表", path: "/organization/agencies" }, - { capabilities: ["bd:list"], icon: GroupsOutlined, label: "BD 列表", path: "/organization/bds" }, - { capabilities: ["bd-manager:list"], icon: SecurityOutlined, label: "BD Manager", path: "/organization/bd-leaders" }, - { capabilities: ["super-admin:list"], icon: SecurityOutlined, label: "Super Admin", path: "/organization/super-admins" }, - { capabilities: ["room:list", "room:update"], icon: MeetingRoomOutlined, label: "房间管理", path: "/rooms" }, - { capabilities: ["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"], icon: RedeemOutlined, label: "资源与靓号", path: "/grants" }, - { capabilities: ["banner:create"], icon: ImageOutlined, label: "Banner 管理", path: "/banners" }, - { capabilities: ["team:view"], icon: GroupsOutlined, label: "我的团队", path: "/organization/team" } + { icon: DashboardOutlined, labelKey: "nav.overview", path: "/overview" }, + { capabilities: ["user:list", "user:update", "user:ban", "user-level:grant"], icon: PeopleOutlineOutlined, labelKey: "nav.users", path: "/users" }, + { capabilities: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, labelKey: "nav.bans", path: "/bans" }, + { capabilities: ["host:list"], icon: BadgeOutlined, labelKey: "nav.hosts", path: "/organization/hosts" }, + { capabilities: ["agency:list"], icon: ApartmentOutlined, labelKey: "nav.agencies", path: "/organization/agencies" }, + { capabilities: ["bd:list"], icon: GroupsOutlined, labelKey: "nav.bds", path: "/organization/bds" }, + { capabilities: ["bd-manager:list"], icon: SecurityOutlined, labelKey: "nav.bdManagers", path: "/organization/bd-leaders" }, + { capabilities: ["super-admin:list"], icon: SecurityOutlined, labelKey: "nav.superAdmins", path: "/organization/super-admins" }, + { capabilities: ["room:list", "room:update"], icon: MeetingRoomOutlined, labelKey: "nav.rooms", path: "/rooms" }, + { capabilities: ["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"], icon: RedeemOutlined, labelKey: "nav.grants", path: "/grants" }, + { capabilities: ["banner:create"], icon: ImageOutlined, labelKey: "nav.banners", path: "/banners" }, + { capabilities: ["team:view"], icon: GroupsOutlined, labelKey: "nav.team", path: "/organization/team" } ]; export function ExternalAdminLayout() { @@ -53,6 +55,7 @@ export function ExternalAdminLayout() { const mobile = useMediaQuery(theme.breakpoints.down("md")); const [drawerOpen, setDrawerOpen] = useState(false); const { logout, session } = useExternalAuth(); + const { direction, t } = useExternalI18n(); const location = useLocation(); const navigate = useNavigate(); const visibleNavigation = useMemo( @@ -71,7 +74,7 @@ export function ExternalAdminLayout() { HY - HYApp 外管 + {t("app.shortTitle")} {session?.appName || session?.appCode} @@ -88,7 +91,7 @@ export function ExternalAdminLayout() { onClick={() => setDrawerOpen(false)} > - + ); })} @@ -98,28 +101,39 @@ export function ExternalAdminLayout() { return ( - + - {mobile ? setDrawerOpen(true)}> : null} - {current?.label || "外管后台"} + {mobile ? setDrawerOpen(true)}> : null} + {current ? t(current.labelKey) : t("app.title")} - - {String(session?.account || "管").slice(0, 1).toUpperCase()} + + + {String(session?.account || "A").slice(0, 1).toUpperCase()} {!mobile ? ( - {session?.account} + {session?.account} {session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} ) : null} - - + + - setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} - {drawer} - + setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} + {drawer} + diff --git a/external-admin/src/layout/ExternalAdminLayout.test.jsx b/external-admin/src/layout/ExternalAdminLayout.test.jsx new file mode 100644 index 0000000..525e67b --- /dev/null +++ b/external-admin/src/layout/ExternalAdminLayout.test.jsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; +import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js"; +import { ExternalAdminLayout } from "./ExternalAdminLayout.jsx"; + +const auth = vi.hoisted(() => ({ + logout: vi.fn(), + session: { + account: "fami-manager", + appCode: "fami", + appName: "Fami", + capabilities: ["user:list", "room:list"] + } +})); +const viewport = vi.hoisted(() => ({ mobile: false })); + +vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth })); +vi.mock("@mui/material/useMediaQuery", () => ({ default: () => viewport.mobile })); + +describe("ExternalAdminLayout RTL", () => { + beforeEach(() => { + localStorage.setItem(EXTERNAL_LOCALE_STORAGE_KEY, "ar"); + viewport.mobile = false; + }); + + test("opens the mobile navigation from the right in Arabic", async () => { + const user = userEvent.setup(); + viewport.mobile = true; + renderLayout(); + + await user.click(screen.getByRole("button", { name: "فتح القائمة" })); + + const openTemporaryDrawer = Array.from(document.querySelectorAll(".MuiDrawer-paper")).find((node) => node.closest(".MuiModal-root")); + expect(openTemporaryDrawer).toBeInTheDocument(); + expect(openTemporaryDrawer.parentElement).not.toHaveStyle({ visibility: "hidden" }); + expect(screen.getAllByRole("link", { name: "ملخص الصلاحيات" }).length).toBeGreaterThan(0); + }); + + test("places the desktop navigation drawer on the right for Arabic", () => { + renderLayout(); + + expect(document.documentElement).toHaveAttribute("dir", "rtl"); + expect(document.documentElement).toHaveAttribute("lang", "ar"); + const permanentDrawer = document.querySelectorAll(".MuiDrawer-paper")[0]; + const generatedClass = Array.from(permanentDrawer.classList).find((className) => className.startsWith("css-")); + const drawerRule = Array.from(document.styleSheets) + .flatMap((sheet) => Array.from(sheet.cssRules || [])) + .map((rule) => rule.cssText) + .find((cssText) => cssText.includes(`.${generatedClass}`)); + expect(drawerRule).toContain("right: 0px"); + expect(drawerRule).toContain("border-left:"); + expect(screen.getAllByText("ملخص الصلاحيات").length).toBeGreaterThan(0); + expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية"); + }); +}); + +function renderLayout() { + return render( + + + + }> + overview content} path="/overview" /> + + + + + ); +} diff --git a/external-admin/src/main.jsx b/external-admin/src/main.jsx index 353822a..7cad114 100644 --- a/external-admin/src/main.jsx +++ b/external-admin/src/main.jsx @@ -1,27 +1,47 @@ +import { CacheProvider } from "@emotion/react"; import CssBaseline from "@mui/material/CssBaseline"; -import { ThemeProvider } from "@mui/material/styles"; -import React from "react"; +import { createTheme, ThemeProvider } from "@mui/material/styles"; +import React, { useMemo } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { theme } from "@/theme.js"; import { ExternalAdminApp } from "./ExternalAdminApp.jsx"; import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx"; +import { ExternalI18nProvider, useExternalI18n } from "./i18n/ExternalI18nProvider.jsx"; +import { externalEmotionCache } from "./i18n/emotionCache.js"; import "@/styles/tokens.css"; import "@/styles/shared-ui.css"; import "./styles/index.css"; createRoot(document.getElementById("external-admin-root")).render( - - - - - - - - - - + + + ); + +function ExternalThemeRoot() { + const { direction } = useExternalI18n(); + // A direction-aware MUI theme keeps inputs, menus and dialog controls consistent with the Arabic document direction. + const externalTheme = useMemo(() => createTheme(theme, { + direction, + typography: { fontFamily: 'Inter, "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif' } + }), [direction]); + + return ( + + + + + + + + + + + + + ); +} diff --git a/external-admin/src/pages/BannersPage.jsx b/external-admin/src/pages/BannersPage.jsx index 5c0144d..29ec076 100644 --- a/external-admin/src/pages/BannersPage.jsx +++ b/external-admin/src/pages/BannersPage.jsx @@ -7,40 +7,43 @@ import { useCallback, useMemo, useState } from "react"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { createBanner, listBanners } from "../api/business.js"; import { BannerCreateDialog } from "../components/BannerCreateDialog.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function BannersPage() { const { showToast } = useToast(); + const { t } = useExternalI18n(); const [open, setOpen] = useState(false); const [page, setPage] = useState(1); const [submitting, setSubmitting] = useState(false); const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]); const { data, error, loading, reload } = usePagedResource(load); const columns = useMemo(() => [ - { key: "banner", label: "Banner", render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, - { key: "type", label: "类型", render: (item) => item.bannerType || "-" }, - { key: "platform", label: "平台", render: (item) => item.platform || "all" }, - { key: "sort", label: "排序", render: (item) => item.sortOrder }, - { key: "status", label: "状态", render: (item) => } - ], []); + { key: "banner", label: t("banners.banner"), render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, + { key: "type", label: t("banners.type"), render: (item) => item.bannerType || "-" }, + { key: "platform", label: t("banners.platform"), render: (item) => item.platform || t("common.all") }, + { key: "sort", label: t("banners.sort"), render: (item) => item.sortOrder }, + { key: "status", label: t("common.status"), render: (item) => } + ], [t]); const submit = async (form) => { setSubmitting(true); try { await createBanner(form); - showToast("Banner 已创建", "success"); + showToast(t("banners.created"), "success"); setOpen(false); reload(); } catch (requestError) { - showToast(requestError.message || "Banner 创建失败", "error"); + showToast(translateExternalError(requestError, t, "banners.createFailed"), "error"); } finally { setSubmitting(false); } }; return ( - setOpen(true)} startIcon={} variant="contained">创建 Banner} title="Banner 管理"> + setOpen(true)} startIcon={} variant="contained">{t("banners.create")}} title={t("banners.title")}> {!loading && !error ? <> item.id} /> : null} setOpen(false)} onSubmit={submit} /> diff --git a/external-admin/src/pages/BansPage.jsx b/external-admin/src/pages/BansPage.jsx index 773edfb..1efaf84 100644 --- a/external-admin/src/pages/BansPage.jsx +++ b/external-admin/src/pages/BansPage.jsx @@ -10,6 +10,8 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { listBannedUsers, unbanUser } from "../api/business.js"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { ConfirmDialog } from "../shared/ConfirmDialog.jsx"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; @@ -17,6 +19,7 @@ import { usePagedResource } from "../shared/usePagedResource.js"; export default function BansPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [keyword, setKeyword] = useState(""); const [query, setQuery] = useState({ keyword: "", page: 1 }); const [target, setTarget] = useState(null); @@ -29,33 +32,33 @@ export default function BansPage() { setSubmitting(true); try { await unbanUser(target.id, { reason: "external-admin manual unban" }); - showToast("用户已解除封禁", "success"); + showToast(t("bans.unbanned"), "success"); setTarget(null); reload(); } catch (requestError) { - showToast(requestError.message || "解除封禁失败", "error"); + showToast(translateExternalError(requestError, t, "bans.unbanFailed"), "error"); } finally { setSubmitting(false); } }; const columns = useMemo(() => [ - { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} }, - { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" }, - { key: "reason", label: "封禁原因", render: (user) => user.reason || "-" }, - { key: "status", label: "状态", render: () => }, - { key: "actions", label: "操作", render: (user) => canUnban ? : "-" } - ], [canUnban]); + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, + { key: "reason", label: t("bans.reason"), render: (user) => user.reason || "-" }, + { key: "status", label: t("common.status"), render: () => }, + { key: "actions", label: t("common.actions"), render: (user) => canUnban ? : "-" } + ], [canUnban, t]); return ( - + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> - setKeyword(event.target.value)} /> - + setKeyword(event.target.value)} /> + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} - setTarget(null)} onConfirm={handleUnban} /> + setTarget(null)} onConfirm={handleUnban} /> ); } diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx index 36d1eb9..ca8c517 100644 --- a/external-admin/src/pages/ChangePasswordPage.jsx +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -11,9 +11,13 @@ import Typography from "@mui/material/Typography"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export default function ChangePasswordPage() { const { changePassword, logout, session } = useExternalAuth(); + const { t } = useExternalI18n(); const [form, setForm] = useState({ confirmPassword: "", newPassword: "", oldPassword: "" }); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -22,15 +26,15 @@ export default function ChangePasswordPage() { const submit = async (event) => { event.preventDefault(); if (!form.newPassword.trim()) { - setError("新密码不能为空或全为空白字符"); + setError(t("auth.passwordWhitespace")); return; } if (form.newPassword.length < 8) { - setError("新密码至少 8 个字符"); + setError(t("auth.passwordMinError")); return; } if (form.newPassword !== form.confirmPassword) { - setError("两次输入的新密码不一致"); + setError(t("auth.passwordMismatch")); return; } setSubmitting(true); @@ -39,7 +43,7 @@ export default function ChangePasswordPage() { await changePassword({ newPassword: form.newPassword, oldPassword: form.oldPassword }); navigate("/overview", { replace: true }); } catch (requestError) { - setError(requestError.message || "密码修改失败"); + setError(translateExternalError(requestError, t, "auth.passwordChangedFailed")); } finally { setSubmitting(false); } @@ -54,13 +58,14 @@ export default function ChangePasswordPage() { - 修改外管密码{session?.passwordChangeRequired ? "首次登录必须修改密码" : session?.account} + + {t("auth.changePassword")}{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} {error ? {error} : null} - setForm({ ...form, oldPassword: event.target.value })} /> - setForm({ ...form, newPassword: event.target.value })} /> - setForm({ ...form, confirmPassword: event.target.value })} /> - + setForm({ ...form, oldPassword: event.target.value })} /> + setForm({ ...form, newPassword: event.target.value })} /> + setForm({ ...form, confirmPassword: event.target.value })} /> + diff --git a/external-admin/src/pages/ChangePasswordPage.test.jsx b/external-admin/src/pages/ChangePasswordPage.test.jsx index a724534..51351d7 100644 --- a/external-admin/src/pages/ChangePasswordPage.test.jsx +++ b/external-admin/src/pages/ChangePasswordPage.test.jsx @@ -26,9 +26,9 @@ describe("ChangePasswordPage", () => { fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: "old-password" } }); fireEvent.change(newPassword, { target: { value: " " } }); fireEvent.change(confirmPassword, { target: { value: " " } }); - await user.click(screen.getByRole("button", { name: "保存密码" })); + await user.click(screen.getByRole("button", { name: "Save password" })); - expect(screen.getByText("新密码不能为空或全为空白字符")).toBeInTheDocument(); + expect(screen.getByText("The new password cannot be empty or contain only whitespace")).toBeInTheDocument(); expect(auth.changePassword).not.toHaveBeenCalled(); }); @@ -40,7 +40,7 @@ describe("ChangePasswordPage", () => { fireEvent.change(container.querySelector('input[autocomplete="current-password"]'), { target: { value: " current-password " } }); fireEvent.change(newPassword, { target: { value: " pass word " } }); fireEvent.change(confirmPassword, { target: { value: " pass word " } }); - await user.click(screen.getByRole("button", { name: "保存密码" })); + await user.click(screen.getByRole("button", { name: "Save password" })); expect(auth.changePassword).toHaveBeenCalledWith({ newPassword: " pass word ", oldPassword: " current-password " }); }); diff --git a/external-admin/src/pages/GrantsPage.jsx b/external-admin/src/pages/GrantsPage.jsx index 4fe3522..9f33fbd 100644 --- a/external-admin/src/pages/GrantsPage.jsx +++ b/external-admin/src/pages/GrantsPage.jsx @@ -11,12 +11,15 @@ import { grantPrettyId, grantResource, listResourceGrants, listResources, lookup import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { PrettyIdGrantDialog, ResourceGrantDialog } from "../components/GrantDialogs.jsx"; import { hasAnyExternalCapability, hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { externalUiError, translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function GrantsPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [dialog, setDialog] = useState(""); const [page, setPage] = useState(1); const [submitting, setSubmitting] = useState(false); @@ -28,15 +31,15 @@ export default function GrantsPage() { const resourcesState = usePagedResource(loadResources); const grantsState = usePagedResource(loadGrants); - const execute = async (request, successMessage) => { + const execute = async (request, successKey) => { setSubmitting(true); try { await request(); - showToast(successMessage, "success"); + showToast(t(successKey), "success"); setDialog(""); grantsState.reload(); } catch (requestError) { - showToast(requestError.message || "发放失败", "error"); + showToast(translateExternalError(requestError, t, "grants.failed"), "error"); } finally { setSubmitting(false); } @@ -45,7 +48,7 @@ export default function GrantsPage() { const resolveTarget = async (input) => { const target = await lookupGrantTarget(input); if (!target.id) { - throw new Error("未找到当前 App 内的目标用户"); + throw externalUiError("errors.grantTarget"); } return target; }; @@ -60,7 +63,7 @@ export default function GrantsPage() { resourceId: Number(form.resourceId), targetUserId: target.id }); - }, "特权道具已发放"); + }, "grants.resourceGranted"); const submitPrettyId = (form) => execute(async () => { const target = await resolveTarget(form.target); @@ -70,27 +73,27 @@ export default function GrantsPage() { reason: form.reason.trim(), target_user_id: target.id }); - }, "靓号已下发"); + }, "grants.prettyIdGranted"); const columns = useMemo(() => [ - { key: "target", label: "目标用户", render: (item) => String(item.targetUserId || item.target_user_id || item.userId || item.user_id || "-") }, - { key: "resource", label: "资源", render: (item) => String(item.resourceName || item.resource_name || item.resourceId || item.resource_id || item.subject || "-") }, - { key: "quantity", label: "数量", render: (item) => String(item.quantity ?? item.amount ?? "-") }, - { key: "status", label: "状态", render: (item) => }, - { key: "reason", label: "原因", render: (item) => String(item.reason || "-") } - ], []); + { key: "target", label: t("grants.targetUser"), render: (item) => String(item.targetUserId || item.target_user_id || item.userId || item.user_id || "-") }, + { key: "resource", label: t("grants.resource"), render: (item) => String(item.resourceName || item.resource_name || item.resourceId || item.resource_id || item.subject || "-") }, + { key: "quantity", label: t("grants.quantity"), render: (item) => String(item.quantity ?? item.amount ?? "-") }, + { key: "status", label: t("common.status"), render: (item) => }, + { key: "reason", label: t("common.reason"), render: (item) => String(item.reason || "-") } + ], [t]); return ( {canGrant ? : null}{canGrantPrettyId ? : null}} - title="资源与靓号" + actions={<>{canGrant ? : null}{canGrantPrettyId ? : null}} + title={t("grants.title")} > {canGrant ? ( - 可发放资源 + {t("grants.availableResources")} - {resourcesState.data.items.slice(0, 12).map((resource) => {resource.name})} - {!resourcesState.loading && !resourcesState.data.items.length ? 当前无数据 : null} + {resourcesState.data.items.slice(0, 12).map((resource) => R{resource.name})} + {!resourcesState.loading && !resourcesState.data.items.length ? {t("common.noData")} : null} ) : null} diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index ba44b37..b58ff84 100644 --- a/external-admin/src/pages/LoginPage.jsx +++ b/external-admin/src/pages/LoginPage.jsx @@ -8,53 +8,28 @@ import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import CircularProgress from "@mui/material/CircularProgress"; -import FormControl from "@mui/material/FormControl"; import IconButton from "@mui/material/IconButton"; import InputAdornment from "@mui/material/InputAdornment"; -import InputLabel from "@mui/material/InputLabel"; -import MenuItem from "@mui/material/MenuItem"; -import Select from "@mui/material/Select"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; -import { useCallback, useEffect, useState } from "react"; -import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; -import { listExternalApps } from "../api/auth.js"; +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; +import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export default function LoginPage() { const { initializing, login, session } = useExternalAuth(); - const [apps, setApps] = useState([]); - const [appsError, setAppsError] = useState(""); - const [appsLoading, setAppsLoading] = useState(true); - const [form, setForm] = useState({ account: "", appCode: "", password: "" }); + const { t } = useExternalI18n(); + const [form, setForm] = useState({ account: "", password: "" }); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [showPassword, setShowPassword] = useState(false); - const [searchParams] = useSearchParams(); const navigate = useNavigate(); const location = useLocation(); - const loadApps = useCallback(async () => { - setAppsLoading(true); - setAppsError(""); - try { - const items = await listExternalApps(); - setApps(items); - const requestedApp = searchParams.get("app") || ""; - const preferredApp = items.find((item) => item.appCode === requestedApp)?.appCode || items[0]?.appCode || requestedApp; - setForm((current) => ({ ...current, appCode: current.appCode || preferredApp })); - } catch (requestError) { - setAppsError(requestError.message || "App 列表加载失败"); - } finally { - setAppsLoading(false); - } - }, [searchParams]); - - useEffect(() => { - loadApps(); - }, [loadApps]); - useEffect(() => { if (!initializing && session) { const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview"; @@ -64,8 +39,8 @@ export default function LoginPage() { const handleSubmit = async (event) => { event.preventDefault(); - if (!form.appCode || !form.account.trim() || !form.password) { - setError("请选择 App 并填写账号、密码"); + if (!form.account.trim() || !form.password) { + setError(t("auth.fillAccountPassword")); return; } setSubmitting(true); @@ -74,7 +49,8 @@ export default function LoginPage() { const nextSession = await login(form); navigate(nextSession.passwordChangeRequired ? "/change-password" : "/overview", { replace: true }); } catch (requestError) { - setError(requestError.message || "登录失败"); + // Every login 401 uses one generic credential message so unknown, disabled and locked accounts cannot be distinguished. + setError(requestError?.status === 401 ? t("auth.invalidCredentials") : translateExternalError(requestError, t, "auth.loginFailed")); } finally { setSubmitting(false); } @@ -84,36 +60,26 @@ export default function LoginPage() { - + + - 外管后台登录 + {t("app.title")} {error ? {error} : null} - {appsError ? 重试} severity="error">{appsError} : null} - {apps.length || appsLoading ? ( - - App - - - ) : ( - setForm({ ...form, appCode: event.target.value.trim() })} /> - )} - setForm({ ...form, account: event.target.value })} /> + setForm({ ...form, account: event.target.value })} /> setForm({ ...form, password: event.target.value })} - slotProps={{ input: { endAdornment: setShowPassword((current) => !current)}>{showPassword ? : } } }} + slotProps={{ input: { endAdornment: setShowPassword((current) => !current)}>{showPassword ? : } } }} /> - diff --git a/external-admin/src/pages/LoginPage.test.jsx b/external-admin/src/pages/LoginPage.test.jsx new file mode 100644 index 0000000..bac99f1 --- /dev/null +++ b/external-admin/src/pages/LoginPage.test.jsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; +import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js"; +import LoginPage from "./LoginPage.jsx"; + +const auth = vi.hoisted(() => ({ initializing: false, login: vi.fn(), session: null })); + +vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth })); + +describe("LoginPage", () => { + beforeEach(() => { + localStorage.removeItem(EXTERNAL_LOCALE_STORAGE_KEY); + auth.login.mockReset().mockResolvedValue({ appCode: "fami", passwordChangeRequired: false }); + }); + + test("defaults to English, has no App field and submits only account and password", async () => { + const user = userEvent.setup(); + const { container } = renderLogin(); + + expect(screen.getByRole("heading", { name: "HYApp External Admin" })).toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument(); + await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager"); + await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + expect(auth.login).toHaveBeenCalledWith({ account: "fami-manager", password: "secret-password" }); + }); + + test("localizes a numeric login 401 without exposing the backend message", async () => { + const user = userEvent.setup(); + auth.login.mockRejectedValueOnce({ code: 40100, message: "账号已禁用", status: 401 }); + const { container } = renderLogin(); + + await user.type(screen.getByRole("textbox", { name: "External admin account" }), "unknown-account"); + await user.type(container.querySelector('input[autocomplete="current-password"]'), "bad-password"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + expect(await screen.findByText("Incorrect account or password")).toBeInTheDocument(); + expect(screen.queryByText("账号已禁用")).not.toBeInTheDocument(); + expect(screen.queryByText("Your session has expired. Sign in again.")).not.toBeInTheDocument(); + }); +}); + +function renderLogin() { + return render(); +} diff --git a/external-admin/src/pages/OrganizationPage.jsx b/external-admin/src/pages/OrganizationPage.jsx index 57eb724..6f03ee5 100644 --- a/external-admin/src/pages/OrganizationPage.jsx +++ b/external-admin/src/pages/OrganizationPage.jsx @@ -10,58 +10,60 @@ import { useParams } from "react-router-dom"; import { listOrganizations } from "../api/business.js"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; const organizationKinds = { - agencies: { capability: "agency:list", title: "公会列表" }, - bds: { capability: "bd:list", title: "BD 列表" }, - "bd-leaders": { capability: "bd-manager:list", title: "BD Manager 列表" }, - hosts: { capability: "host:list", title: "主播列表" }, - managers: { capability: "bd-manager:list", title: "管理员列表" }, - "super-admins": { capability: "super-admin:list", title: "Super Admin 列表" }, - team: { capability: "team:view", title: "我的团队" } + agencies: { capability: "agency:list", titleKey: "organization.agencies" }, + bds: { capability: "bd:list", titleKey: "organization.bds" }, + "bd-leaders": { capability: "bd-manager:list", titleKey: "organization.bdManagers" }, + hosts: { capability: "host:list", titleKey: "organization.hosts" }, + managers: { capability: "bd-manager:list", titleKey: "organization.managers" }, + "super-admins": { capability: "super-admin:list", titleKey: "organization.superAdmins" }, + team: { capability: "team:view", titleKey: "organization.team" } }; export default function OrganizationPage() { const { kind = "hosts" } = useParams(); const config = organizationKinds[kind]; const { session } = useExternalAuth(); + const { t } = useExternalI18n(); const [keyword, setKeyword] = useState(""); const [query, setQuery] = useState({ keyword: "", page: 1 }); const allowed = Boolean(config && hasExternalCapability(session, config.capability)); const load = useCallback(() => allowed ? listOrganizations(kind, { keyword: query.keyword, page: query.page, page_size: 20 }) : Promise.resolve({ items: [], page: 1, pageSize: 20, total: 0 }), [allowed, kind, query]); const { data, error, loading, reload } = usePagedResource(load); const columns = useMemo(() => kind === "team" ? [ - { key: "agency", label: "公会", render: (item) => 公会 {item.id || "-"}Owner {item.userId || "-"} }, - { key: "parent", label: "上级 BD", render: (item) => item.parentBdUserId || "-" }, - { key: "status", label: "状态", render: (item) => } + { key: "agency", label: t("organization.agency"), render: (item) => {t("organization.agencyReference", { id: item.id || "-" })}{t("organization.owner")} {item.userId || "-"} }, + { key: "parent", label: t("organization.parentBd"), render: (item) => item.parentBdUserId || "-" }, + { key: "status", label: t("common.status"), render: (item) => } ] : [ - { key: "identity", label: "成员", render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / 用户 ${item.userId}` : ""} }, - { key: "prettyId", label: "短 ID(靓号)", render: (item) => item.prettyId || "-" }, - { key: "country", label: "国家/地区", render: (item) => item.country || "-" }, - { key: "status", label: "状态", render: (item) => } - ], [kind]); + { key: "identity", label: t("organization.member"), render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""} }, + { key: "prettyId", label: t("users.prettyId"), render: (item) => item.prettyId || "-" }, + { key: "country", label: t("organization.country"), render: (item) => item.country || "-" }, + { key: "status", label: t("common.status"), render: (item) => } + ], [kind, t]); if (!config || !allowed) { - return 当前外管账号没有访问该组织列表的权限。; + return {t("organization.noPermission")}; } return ( - + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> - setKeyword(event.target.value)} /> - + setKeyword(event.target.value)} /> + {kind === "team" && !loading && !error ? ( - BD Leader:{data.totalBdLeaders || 0} - BD:{data.totalBds || 0} - 公会:{data.total || data.items.length} + {t("organization.bdLeaderCount", { count: data.totalBdLeaders || 0 })} + {t("organization.bdCount", { count: data.totalBds || 0 })} + {t("organization.agencyCount", { count: data.total || data.items.length })} - {data.truncated ? 团队规模超过接口上限,当前结果不完整 : null} + {data.truncated ? {t("organization.truncated")} : null} ) : null} {!loading && !error ? <> item.id || index} /> setQuery({ ...query, page })} /> : null} diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx index c2bfc3a..965b803 100644 --- a/external-admin/src/pages/OverviewPage.jsx +++ b/external-admin/src/pages/OverviewPage.jsx @@ -10,19 +10,21 @@ import Typography from "@mui/material/Typography"; import { Link } from "react-router-dom"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { ExternalPage } from "../shared/PageComponents.jsx"; export default function OverviewPage() { const { session } = useExternalAuth(); + const { t } = useExternalI18n(); return ( - + - 当前 App{session.appName || session.appCode}({session.appCode}) - 外管账号{session.account} - 已开通权限{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} + {t("overview.currentApp")}{session.appName || session.appCode} ({session.appCode}) + {t("overview.account")}{session.account} + {t("overview.permissions")}{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} @@ -33,8 +35,8 @@ export default function OverviewPage() { {enabled ? : } - {item.label} - + {t(item.labelKey)} + ); diff --git a/external-admin/src/pages/OverviewPage.test.jsx b/external-admin/src/pages/OverviewPage.test.jsx index 90540d9..07827d5 100644 --- a/external-admin/src/pages/OverviewPage.test.jsx +++ b/external-admin/src/pages/OverviewPage.test.jsx @@ -22,10 +22,10 @@ describe("OverviewPage", () => { render(); expect(EXTERNAL_CAPABILITIES).toHaveLength(20); - expect(screen.getAllByText("已开通")).toHaveLength(1); - expect(screen.getAllByText("未开通")).toHaveLength(19); - expect(screen.getByText("用户列表").closest("a")).toHaveAttribute("href", "/users"); - expect(screen.getByText("用户信息编辑").closest("a")).toBeNull(); - expect(screen.getByText("我的团队")).toBeInTheDocument(); + expect(screen.getAllByText("Enabled")).toHaveLength(1); + expect(screen.getAllByText("Not enabled")).toHaveLength(19); + expect(screen.getByText("User list").closest("a")).toHaveAttribute("href", "/users"); + expect(screen.getByText("Edit user information").closest("a")).toBeNull(); + expect(screen.getByText("My team")).toBeInTheDocument(); }); }); diff --git a/external-admin/src/pages/RoomsPage.jsx b/external-admin/src/pages/RoomsPage.jsx index 706fecd..63c883e 100644 --- a/external-admin/src/pages/RoomsPage.jsx +++ b/external-admin/src/pages/RoomsPage.jsx @@ -11,12 +11,15 @@ import { listRooms, updateRoom } from "../api/business.js"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { RoomEditDialog } from "../components/RoomEditDialog.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { externalUiError, translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function RoomsPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [keyword, setKeyword] = useState(""); const [query, setQuery] = useState({ keyword: "", page: 1 }); const [room, setRoom] = useState(null); @@ -26,12 +29,12 @@ export default function RoomsPage() { const canEdit = hasExternalCapability(session, "room:update"); const columns = useMemo(() => [ - { key: "room", label: "房间", render: (item) => {item.title || "-"}ID {item.id} }, - { key: "owner", label: "房主", render: (item) => item.ownerName || "-" }, - { key: "region", label: "可见区域", render: (item) => item.visibleRegionId || "全部" }, - { key: "status", label: "状态", render: (item) => }, - { key: "actions", label: "操作", render: (item) => canEdit ? : "-" } - ], [canEdit]); + { key: "room", label: t("rooms.room"), render: (item) => R{item.title || "-"}ID {item.id} }, + { key: "owner", label: t("rooms.owner"), render: (item) => item.ownerName || "-" }, + { key: "region", label: t("rooms.visibleRegion"), render: (item) => item.visibleRegionId || t("rooms.allRegions") }, + { key: "status", label: t("common.status"), render: (item) => }, + { key: "actions", label: t("common.actions"), render: (item) => canEdit ? : "-" } + ], [canEdit, t]); const submit = async (form) => { setSubmitting(true); @@ -39,22 +42,22 @@ export default function RoomsPage() { const regionText = String(form.visibleRegionId || "").trim(); const visibleRegionId = regionText ? Number(regionText) : 0; if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) { - throw new Error("可见区域 ID 必须是安全的正整数"); + throw externalUiError("errors.regionId"); } await updateRoom(room.id, { ...form, visibleRegionId }); - showToast("房间信息已更新", "success"); + showToast(t("rooms.updated"), "success"); setRoom(null); reload(); } catch (requestError) { - showToast(requestError.message || "房间更新失败", "error"); + showToast(translateExternalError(requestError, t, "rooms.updateFailed"), "error"); } finally { setSubmitting(false); } }; return ( - - { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> setKeyword(event.target.value)} /> + + { event.preventDefault(); setQuery({ keyword, page: 1 }); }}> setKeyword(event.target.value)} /> {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} setRoom(null)} onSubmit={submit} /> diff --git a/external-admin/src/pages/UsersPage.jsx b/external-admin/src/pages/UsersPage.jsx index 72db4b7..020c76d 100644 --- a/external-admin/src/pages/UsersPage.jsx +++ b/external-admin/src/pages/UsersPage.jsx @@ -14,12 +14,15 @@ import { banUser, grantUserVip, listUsers, updateUser, updateUserLevels } from " import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { UserBanDialog, UserEditDialog, UserLevelDialog } from "../components/UserDialogs.jsx"; import { hasExternalCapability } from "../config/capabilities.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; import { ExternalPage, ExternalPageState, PagePagination, ResponsiveDataList, StatusChip } from "../shared/PageComponents.jsx"; import { usePagedResource } from "../shared/usePagedResource.js"; export default function UsersPage() { const { session } = useExternalAuth(); const { showToast } = useToast(); + const { t } = useExternalI18n(); const [filters, setFilters] = useState({ keyword: "", status: "" }); const [query, setQuery] = useState({ keyword: "", page: 1, status: "" }); const [dialog, setDialog] = useState({ type: "", user: null }); @@ -31,47 +34,52 @@ export default function UsersPage() { const canGrantLevel = hasExternalCapability(session, "user-level:grant"); const closeDialog = () => setDialog({ type: "", user: null }); - const execute = async (request, successMessage) => { + const execute = async (request, successKey) => { setSubmitting(true); try { await request(); - showToast(successMessage, "success"); + showToast(t(successKey), "success"); closeDialog(); reload(); } catch (requestError) { - showToast(requestError.message || "用户操作失败", "error"); + showToast(translateExternalError(requestError, t, "users.operationFailed"), "error"); } finally { setSubmitting(false); } }; const columns = useMemo(() => [ - { key: "user", label: "用户", render: (user) => {user.username.slice(0, 1)}{user.username || "-"}ID {user.id} }, - { key: "prettyId", label: "短 ID(靓号)", render: (user) => user.prettyId || "-" }, - { key: "country", label: "国家/性别", render: (user) => `${user.country || "-"} / ${user.gender || "-"}` }, - { key: "level", label: "等级", render: (user) => `财富 ${user.wealthLevel} / 魅力 ${user.charmLevel} / VIP ${user.vipLevel}` }, - { key: "status", label: "状态", render: (user) => }, - { key: "actions", label: "操作", render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } - ], [canBan, canEdit, canGrantLevel]); + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, + { key: "country", label: t("users.countryGender"), render: (user) => `${user.country || "-"} / ${translatedGender(user.gender, t)}` }, + { key: "level", label: t("users.level"), render: (user) => t("users.levelSummary", { wealth: user.wealthLevel, charm: user.charmLevel, vip: user.vipLevel }) }, + { key: "status", label: t("common.status"), render: (user) => }, + { key: "actions", label: t("common.actions"), render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } + ], [canBan, canEdit, canGrantLevel, t]); return ( - + { event.preventDefault(); setQuery({ ...filters, page: 1 }); }}> - setFilters({ ...filters, keyword: event.target.value })} /> - setFilters({ ...filters, status: event.target.value })}>全部状态正常已封禁 - + setFilters({ ...filters, keyword: event.target.value })} /> + setFilters({ ...filters, status: event.target.value })}>{t("common.allStatuses")}{t("common.normal")}{t("common.banned")} + {!loading && !error ? <> item.id} /> setQuery({ ...query, page })} /> : null} - execute(() => updateUser(dialog.user.id, form), "用户信息已更新")} /> - execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "用户已封禁")} /> + execute(() => updateUser(dialog.user.id, form), "users.updated")} /> + execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "users.banned")} /> execute(() => form.track === "vip" ? grantUserVip(dialog.user.id, { commandId: createLevelCommandId(), durationMs: Number(form.days) * 86400000, level: Number(form.level), reason: form.reason.trim() }) - : updateUserLevels(dialog.user.id, { adjustments: [{ duration_days: Number(form.days), level: Number(form.level), track: form.track }], reason: form.reason.trim() }), "用户等级已下发")} /> + : updateUserLevels(dialog.user.id, { adjustments: [{ duration_days: Number(form.days), level: Number(form.level), track: form.track }], reason: form.reason.trim() }), "users.levelGranted")} /> ); } +function translatedGender(gender, t) { + const key = { female: "users.genderFemale", male: "users.genderMale", unknown: "users.genderUnknown" }[String(gender || "").toLowerCase()]; + return key ? t(key) : "-"; +} + function createLevelCommandId() { return `external-level-grant-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; } diff --git a/external-admin/src/shared/ConfirmDialog.jsx b/external-admin/src/shared/ConfirmDialog.jsx index e947974..72be4ee 100644 --- a/external-admin/src/shared/ConfirmDialog.jsx +++ b/external-admin/src/shared/ConfirmDialog.jsx @@ -4,13 +4,15 @@ import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; export function ConfirmDialog({ confirmColor = "primary", content, loading, onClose, onConfirm, open, title }) { + const { t } = useExternalI18n(); return ( {title} {content} - + ); } diff --git a/external-admin/src/shared/ExternalImageUploadField.jsx b/external-admin/src/shared/ExternalImageUploadField.jsx index 4851533..6d6f653 100644 --- a/external-admin/src/shared/ExternalImageUploadField.jsx +++ b/external-admin/src/shared/ExternalImageUploadField.jsx @@ -7,8 +7,11 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { useRef, useState } from "react"; import { uploadExternalImage } from "../api/business.js"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export function ExternalImageUploadField({ disabled, label, onChange, value }) { + const { t } = useExternalI18n(); const inputRef = useRef(null); const [error, setError] = useState(""); const [uploading, setUploading] = useState(false); @@ -20,11 +23,11 @@ export function ExternalImageUploadField({ disabled, label, onChange, value }) { return; } if (!file.type.startsWith("image/")) { - setError("请选择图片文件"); + setError(t("upload.imageOnly")); return; } if (file.size > 10 * 1024 * 1024) { - setError("图片不能超过 10MB"); + setError(t("upload.sizeLimit")); return; } @@ -33,11 +36,13 @@ export function ExternalImageUploadField({ disabled, label, onChange, value }) { try { const url = await uploadExternalImage(file); if (!url) { - throw new Error("上传结果缺少图片地址"); + const missingUrlError = new Error("missing upload URL"); + missingUrlError.translationKey = "upload.missingUrl"; + throw missingUrlError; } onChange(url); } catch (uploadError) { - setError(uploadError.message || "图片上传失败"); + setError(translateExternalError(uploadError, t, "upload.failed")); } finally { setUploading(false); } @@ -47,14 +52,14 @@ export function ExternalImageUploadField({ disabled, label, onChange, value }) { {label} - {value ? {`${label}预览`} : 暂无图片} + {value ? {t("upload.preview", : {t("upload.empty")}} diff --git a/external-admin/src/shared/PageComponents.jsx b/external-admin/src/shared/PageComponents.jsx index 86da941..fa1a04e 100644 --- a/external-admin/src/shared/PageComponents.jsx +++ b/external-admin/src/shared/PageComponents.jsx @@ -15,6 +15,8 @@ import TableRow from "@mui/material/TableRow"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; +import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; +import { translateExternalError } from "../i18n/errors.js"; export function ExternalPage({ actions, children, title }) { return ( @@ -31,9 +33,10 @@ export function ExternalPage({ actions, children, title }) { } export function ExternalPageState({ error, loading, onRetry }) { + const { t } = useExternalI18n(); if (loading) { return ( - + @@ -42,20 +45,21 @@ export function ExternalPageState({ error, loading, onRetry }) { if (error) { return ( - {error} - + {translateExternalError(error, t, "errors.loadFailed")} + ); } return null; } -export function ResponsiveDataList({ columns, emptyText = "当前无数据", items, rowKey }) { +export function ResponsiveDataList({ columns, emptyText, items, rowKey }) { const theme = useTheme(); const mobile = useMediaQuery(theme.breakpoints.down("sm")); + const { t } = useExternalI18n(); if (!items.length) { - return {emptyText}; + return {emptyText || t("common.noData")}; } if (mobile) { @@ -96,21 +100,25 @@ export function ResponsiveDataList({ columns, emptyText = "当前无数据", ite } export function StatusChip({ status }) { + const { t } = useExternalI18n(); const normalized = String(status || "-").toLowerCase(); const enabled = ["active", "enabled", "running", "normal", "unbanned"].includes(normalized); const dangerous = ["banned", "disabled", "failed", "blocked"].includes(normalized); - return ; + const statusKey = `status.${normalized}`; + const translated = t(statusKey); + return ; } export function PagePagination({ page, pageSize, total, onChange }) { + const { t } = useExternalI18n(); const totalPages = Math.max(1, Math.ceil(total / pageSize)); return ( - 共 {total} 条 + {t("pagination.total", { count: total })} - + {page} / {totalPages} - + ); diff --git a/external-admin/src/shared/usePagedResource.js b/external-admin/src/shared/usePagedResource.js index af442f8..df74d65 100644 --- a/external-admin/src/shared/usePagedResource.js +++ b/external-admin/src/shared/usePagedResource.js @@ -4,7 +4,7 @@ const EMPTY_PAGE = { items: [], page: 1, pageSize: 20, total: 0 }; export function usePagedResource(loader) { const [data, setData] = useState(EMPTY_PAGE); - const [error, setError] = useState(""); + const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [version, setVersion] = useState(0); @@ -13,7 +13,7 @@ export function usePagedResource(loader) { useEffect(() => { let active = true; setLoading(true); - setError(""); + setError(null); // Start inside the promise chain so synchronous validation errors use the same visible error state as network failures. Promise.resolve() .then(loader) @@ -24,7 +24,8 @@ export function usePagedResource(loader) { }) .catch((requestError) => { if (active) { - setError(requestError.message || "数据加载失败"); + // Keep the structured error so the rendering locale, not the backend message language, determines what users see. + setError(requestError); } }) .finally(() => { diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index 2969394..df45aae 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -31,6 +31,10 @@ body { padding: 32px; } +.external-language-switcher { + min-width: 140px !important; +} + .external-login-avatar, .external-logo-mark { background: var(--brand-gradient) !important; @@ -285,6 +289,7 @@ body { .external-capability-card { min-height: 128px; } .external-filter-bar .MuiTextField-root { width: 100%; } .external-image-field { align-items: flex-start; flex-direction: column; } + .external-header .external-language-switcher { min-width: 94px !important; max-width: 112px; } } @media (prefers-reduced-motion: reduce) { diff --git a/package.json b/package.json index ffdb0c5..0236793 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,19 @@ "preview": "vite preview --host 0.0.0.0" }, "dependencies": { + "@emotion/cache": "11.14.0", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", "@mui/icons-material": "9.0.0", "@mui/material": "9.0.0", + "@mui/stylis-plugin-rtl": "9.1.1", "@tanstack/react-query": "^5.100.6", "echarts": "6.0.0", "libpag": "4.5.52", "react": "19.2.5", "react-dom": "19.2.5", "react-router-dom": "^7.14.2", + "stylis": "4.4.0", "svgaplayerweb": "2.3.2", "zod": "^4.4.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22758b6..3385623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@emotion/cache': + specifier: 11.14.0 + version: 11.14.0 '@emotion/react': specifier: 11.14.0 version: 11.14.0(@types/react@19.2.14)(react@19.2.5) @@ -20,6 +23,9 @@ importers: '@mui/material': specifier: 9.0.0 version: 9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mui/stylis-plugin-rtl': + specifier: 9.1.1 + version: 9.1.1(stylis@4.4.0) '@tanstack/react-query': specifier: ^5.100.6 version: 5.100.6(react@19.2.5) @@ -38,6 +44,9 @@ importers: react-router-dom: specifier: ^7.14.2 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + stylis: + specifier: 4.4.0 + version: 4.4.0 svgaplayerweb: specifier: 2.3.2 version: 2.3.2 @@ -594,6 +603,12 @@ packages: '@emotion/styled': optional: true + '@mui/stylis-plugin-rtl@9.1.1': + resolution: {integrity: sha512-UeWnUOeZCCy8knYIRULs5/s1hzWVtuIKKDYqtr2lrYBCliAENYQJ8yFUtdqIXHQUrTbAWkmHDEFQIz5nEba8TQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + stylis: 4.x + '@mui/system@9.0.0': resolution: {integrity: sha512-YnC5Zg6j04IxiLc/boAKs0464jfZlLFVa7mf5E8lF0XOtZVUvG6R6gJK50lgUYdaaLdyLfxF6xR7LaPuEpeT/g==} engines: {node: '>=14.0.0'} @@ -1036,6 +1051,10 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssjanus@2.3.1: + resolution: {integrity: sha512-gWZQ/S0tthU2KCc55C5zbjxQN0XPK1sT0qufCqwCNUauGIsPDOfdBMBWosP+Kd4wf2A2Nxm/z6JAP6yNGnS7QQ==} + engines: {node: '>=10.0.0'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1857,6 +1876,9 @@ packages: stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -2569,6 +2591,12 @@ snapshots: '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@mui/stylis-plugin-rtl@9.1.1(stylis@4.4.0)': + dependencies: + '@babel/runtime': 7.29.2 + cssjanus: 2.3.1 + stylis: 4.4.0 + '@mui/system@9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 @@ -2964,6 +2992,8 @@ snapshots: css.escape@1.5.1: {} + cssjanus@2.3.1: {} + csstype@3.2.3: {} data-urls@7.0.0: @@ -3814,6 +3844,8 @@ snapshots: stylis@4.2.0: {} + stylis@4.4.0: {} + supports-color@10.2.2: {} supports-preserve-symlinks-flag@1.0.0: {} diff --git a/src/shared/ui/AppJumpConfigField.jsx b/src/shared/ui/AppJumpConfigField.jsx index 5a240df..64a3121 100644 --- a/src/shared/ui/AppJumpConfigField.jsx +++ b/src/shared/ui/AppJumpConfigField.jsx @@ -19,6 +19,7 @@ export function AppJumpConfigField({ className = "", disabled = false, h5Label = "H5 链接", + messages = {}, onChange, paramValue, required = false, @@ -75,7 +76,7 @@ export function AppJumpConfigField({ <> {appTargetOptions.map(([value, label]) => ( - {label} + {messages.targets?.[value] || label} ))} - + {type === "app" ? ( -

APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。

+

{messages.hint || "APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。"}

) : null} ); } -function AppJumpStructuredFields({ appJump, disabled, onChange }) { +function AppJumpStructuredFields({ appJump, disabled, messages, onChange }) { if (appJump.target === "custom" || appJump.target === "wallet" || appJump.target === "room_random") { return null; } @@ -118,7 +119,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsRoomId(appJump.target) ? ( onChange({ roomId: event.target.value })} @@ -127,7 +128,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsWindow(appJump.target) ? ( {appJumpWindowOptions.map(([value, label]) => ( - {label} + {messages.windows?.[value] || label} ))} @@ -143,7 +144,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsGameId(appJump.target, appJump.window) ? ( onChange({ gameId: event.target.value })} @@ -168,7 +169,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsGiftId(appJump.target, appJump.window) ? ( onChange({ giftId: event.target.value })} /> @@ -176,7 +177,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) { {needsRedPacketNo(appJump.target, appJump.window) ? ( onChange({ redPacketNo: event.target.value })} /> diff --git a/src/shared/ui/TimeRangeFilter.jsx b/src/shared/ui/TimeRangeFilter.jsx index 430a356..cf369df 100644 --- a/src/shared/ui/TimeRangeFilter.jsx +++ b/src/shared/ui/TimeRangeFilter.jsx @@ -12,15 +12,45 @@ import styles from "@/shared/ui/TimeRangeFilter.module.css"; const emptyRange = { endMs: "", startMs: "" }; const quickRanges = [ - ["近 1 小时", 60 * 60 * 1000], - ["近 24 小时", 24 * 60 * 60 * 1000], - ["近 7 天", 7 * 24 * 60 * 60 * 1000], + ["quickHour", 60 * 60 * 1000], + ["quickDay", 24 * 60 * 60 * 1000], + ["quickWeek", 7 * 24 * 60 * 60 * 1000], ]; -const weekLabels = ["一", "二", "三", "四", "五", "六", "日"]; +const defaultMessages = { + apply: "确定", + clear: "清空", + completeRange: "选择完整区间", + duration: "间隔 {value}", + durationDays: "{count} 天", + durationHours: "{count} 小时", + durationMinutes: "{count} 分钟", + end: "结束时间", + endAfterStart: "结束时间必须晚于开始时间", + endDate: "结束日期", + endDateAfterStart: "结束日期不能早于开始日期", + endDateLaterShort: "结束不能早于开始", + endLaterShort: "结束需晚于开始", + endMark: "末", + hour: "时", + lessMinute: "间隔少于 1 分钟", + minute: "分", + nextMonth: "下个月", + pending: "待选择", + previousMonth: "上个月", + quickDay: "近 24 小时", + quickHour: "近 1 小时", + quickWeek: "近 7 天", + sameDay: "同一天", + start: "开始时间", + startDate: "开始日期", + startMark: "始", + unlimited: "不限", +}; // withTime=false 时是纯日期区间选择器:隐藏时/分与小时级快捷区间, // 选中的开始/结束日分别取当天 00:00:00.000 与 23:59:59.999 的本地毫秒。 -export function TimeRangeFilter({ className = "", disabled = false, label = "时间", onChange, value, withTime = true }) { +export function TimeRangeFilter({ className = "", disabled = false, label = "时间", locale = "zh-CN", messages, onChange, value, withTime = true }) { + const copy = useMemo(() => ({ ...defaultMessages, ...messages }), [messages]); const range = useMemo(() => normalizeRange(value), [value]); const [anchorEl, setAnchorEl] = useState(null); const [draft, setDraft] = useState(() => range); @@ -36,7 +66,8 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 activeField, activeField === "end" && draft.startMs ? new Date(draft.startMs) : viewDate, ); - const rangeDistance = formatRangeDistance(draft.startMs, draft.endMs, withTime); + const rangeDistance = formatLocalizedRangeDistance(draft.startMs, draft.endMs, withTime, copy); + const weekLabels = useMemo(() => localizedWeekLabels(locale), [locale]); const hasRange = Boolean(range.startMs || range.endMs); useEffect(() => { @@ -53,8 +84,8 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 () => !range.startMs && !range.endMs ? label - : `${formatPart(range.startMs) || "不限"} - ${formatPart(range.endMs) || "不限"}`, - [formatPart, label, range], + : `${formatPart(range.startMs) || copy.unlimited} - ${formatPart(range.endMs) || copy.unlimited}`, + [copy.unlimited, formatPart, label, range], ); const openPopover = (event) => { @@ -127,7 +158,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 event.stopPropagation(); const nextRange = normalizeRange(draft); if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) { - setError(withTime ? "结束时间必须晚于开始时间" : "结束日期不能早于开始日期"); + setError(withTime ? copy.endAfterStart : copy.endDateAfterStart); return; } applyRange(nextRange); @@ -146,7 +177,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 {label} - {hasRange ? displayLabel : "不限"} + {hasRange ? displayLabel : copy.unlimited} activateField("start")} /> activateField("end")} />
- - {formatDayPosition(draft.startMs) || "待选择"} + {copy.startMark} + {formatDayPosition(draft.startMs) || copy.pending} {rangeDistance} - {formatDayPosition(draft.endMs) || "待选择"} - + {formatDayPosition(draft.endMs) || copy.pending} + {copy.endMark}
- changeMonth(-1)}> + changeMonth(-1)}> - {monthTitle(viewDate)} - changeMonth(1)}> + {monthTitle(viewDate, locale)} + changeMonth(1)}>
@@ -245,7 +278,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 {withTime ? (
- 确定 + {copy.apply}
@@ -285,7 +318,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 ); } -function RangeFieldButton({ active, formatPart = formatRangePart, label, onClick, value }) { +function RangeFieldButton({ active, formatPart = formatRangePart, label, onClick, unlimited = "不限", value }) { return ( ); } @@ -375,8 +408,11 @@ export function sameDay(left, right) { ); } -export function monthTitle(date) { - return `${date.getFullYear()}年${pad2(date.getMonth() + 1)}月`; +export function monthTitle(date, locale = "zh-CN") { + if (locale === "zh-CN") { + return `${date.getFullYear()}年${pad2(date.getMonth() + 1)}月`; + } + return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date); } export function formatRangePart(value) { @@ -437,6 +473,47 @@ export function formatRangeDistance(startValue, endValue, withTime = true) { return `间隔 ${parts.join(" ")}`; } +function formatLocalizedRangeDistance(startValue, endValue, withTime, messages) { + const startMs = normalizeMs(startValue); + const endMs = normalizeMs(endValue); + if (!startMs || !endMs) { + return messages.completeRange; + } + if (endMs <= startMs) { + return withTime ? messages.endLaterShort : messages.endDateLaterShort; + } + if (!withTime) { + const start = new Date(startMs); + const end = new Date(endMs); + const startDay = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate()); + const endDay = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate()); + const days = Math.round((endDay - startDay) / (24 * 60 * 60 * 1000)); + return days === 0 ? messages.sameDay : template(messages.duration, { value: template(messages.durationDays, { count: days }) }); + } + const totalMinutes = Math.floor((endMs - startMs) / (60 * 1000)); + if (totalMinutes < 1) { + return messages.lessMinute; + } + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + const parts = [ + days ? template(messages.durationDays, { count: days }) : "", + hours ? template(messages.durationHours, { count: hours }) : "", + minutes ? template(messages.durationMinutes, { count: minutes }) : "", + ].filter(Boolean); + return template(messages.duration, { value: parts.join(" ") }); +} + +function localizedWeekLabels(locale) { + const formatter = new Intl.DateTimeFormat(locale, { weekday: "narrow" }); + return Array.from({ length: 7 }, (_, index) => formatter.format(new Date(2024, 0, 1 + index))); +} + +function template(message, values) { + return String(message).replace(/\{(\w+)\}/g, (match, key) => (values[key] === undefined ? match : String(values[key]))); +} + function sameDayMs(day, value) { const ms = normalizeMs(value); return Boolean(ms) && sameDay(day, new Date(ms));