feat(external-admin): add account login and i18n

This commit is contained in:
zhx 2026-07-15 16:51:18 +08:00
parent bcc3854114
commit d302d8a360
46 changed files with 1395 additions and 398 deletions

View File

@ -1,10 +1,10 @@
<!doctype html>
<html lang="zh-CN">
<html dir="ltr" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<title>HYApp 外管</title>
<title>HYApp External Admin</title>
</head>
<body>
<div id="external-admin-root"></div>

View File

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

View File

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

View File

@ -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";
}

View File

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

View File

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

View File

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

View File

@ -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 (
<Box className="external-full-state" role="status">
<CircularProgress size={28} />
<Typography color="text.secondary">正在校验外管会话</Typography>
<Typography color="text.secondary">{t("auth.checkingSession")}</Typography>
</Box>
);
}
@ -24,8 +27,8 @@ export function RequireExternalAuth() {
if (initializationError) {
return (
<Box className="external-full-state">
<Alert severity="error">{initializationError}</Alert>
<Button onClick={refresh}>重新校验</Button>
<Alert severity="error">{translateExternalError(initializationError, t, "auth.sessionFailed")}</Alert>
<Button onClick={refresh}>{t("auth.recheck")}</Button>
</Box>
);
}
@ -42,6 +45,7 @@ export function RequirePasswordChanged() {
export function RequireExternalCapability({ anyOf }) {
const { session } = useExternalAuth();
const { t } = useExternalI18n();
if (hasAnyExternalCapability(session, anyOf)) {
return <Outlet />;
@ -49,9 +53,9 @@ export function RequireExternalCapability({ anyOf }) {
return (
<Box className="external-page external-full-state">
<Alert severity="warning">当前外管账号没有访问此功能的权限</Alert>
<Alert severity="warning">{t("auth.noPermission")}</Alert>
<Button component="a" href="/external-admin/overview" variant="outlined">
返回权限总览
{t("auth.returnOverview")}
</Button>
</Box>
);

View File

@ -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 (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={submit}>
<DialogTitle>创建 Banner</DialogTitle>
<DialogTitle>{t("banners.create")}</DialogTitle>
<DialogContent>
<Stack className="external-dialog-fields" spacing={2}>
<ExternalImageUploadField disabled={loading} label="Banner 图片" value={form.coverUrl} onChange={(coverUrl) => setForm({ ...form, coverUrl })} />
{form.displayScopes.includes("room") ? <ExternalImageUploadField disabled={loading} label="房间内小屏图" value={form.roomSmallImageUrl} onChange={(roomSmallImageUrl) => setForm({ ...form, roomSmallImageUrl })} /> : null}
<TextField disabled={loading} label="平台" required select value={form.platform} onChange={(event) => setForm({ ...form, platform: event.target.value })}>
<ExternalImageUploadField disabled={loading} label={t("banners.image")} value={form.coverUrl} onChange={(coverUrl) => setForm({ ...form, coverUrl })} />
{form.displayScopes.includes("room") ? <ExternalImageUploadField disabled={loading} label={t("banners.roomImage")} value={form.roomSmallImageUrl} onChange={(roomSmallImageUrl) => setForm({ ...form, roomSmallImageUrl })} /> : null}
<TextField disabled={loading} label={t("banners.platform")} required select value={form.platform} onChange={(event) => setForm({ ...form, platform: event.target.value })}>
<MenuItem value="android">Android</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<TextField disabled={loading} label="描述" slotProps={{ htmlInput: { maxLength: 255 } }} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
<TextField disabled={loading} label={t("banners.description")} slotProps={{ htmlInput: { maxLength: 255 } }} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
<AppJumpConfigField
allowCustom
appParamLabel="参数APP 公共跳转 JSON"
appParamLabel={t("banners.appParam")}
disabled={loading}
h5Label="参数H5 链接)"
h5Label={t("banners.h5Param")}
messages={appJumpMessages(t)}
paramValue={form.param}
required
typeLabel="跳转类型"
typeLabel={t("banners.jumpType")}
typeValue={form.bannerType}
onChange={({ param, type }) => setForm({ ...form, bannerType: type, param })}
/>
<TextField
disabled={loading}
label="展示范围"
label={t("banners.displayScope")}
required
select
slotProps={{ select: { multiple: true, renderValue: (selected) => 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]) => <MenuItem key={value} value={value}><Checkbox checked={form.displayScopes.includes(value)} /><ListItemText primary={label} /></MenuItem>)}
{displayScopeOptions.map(([value, labelKey]) => <MenuItem key={value} value={value}><Checkbox checked={form.displayScopes.includes(value)} /><ListItemText primary={t(labelKey)} /></MenuItem>)}
</TextField>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<TextField disabled={loading} label="区域 ID" slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.regionId} onChange={(event) => setForm({ ...form, regionId: event.target.value })} />
<TextField disabled={loading} helperText="2-3 位字母,留空为全部" label="国家码" value={form.countryCode} onChange={(event) => setForm({ ...form, countryCode: event.target.value.toUpperCase() })} />
<TextField disabled={loading} label="排序" slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.sortOrder} onChange={(event) => setForm({ ...form, sortOrder: event.target.value })} />
<TextField disabled={loading} label={t("banners.regionId")} slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.regionId} onChange={(event) => setForm({ ...form, regionId: event.target.value })} />
<TextField disabled={loading} helperText={t("banners.countryCodeHint")} label={t("banners.countryCode")} value={form.countryCode} onChange={(event) => setForm({ ...form, countryCode: event.target.value.toUpperCase() })} />
<TextField disabled={loading} label={t("banners.sort")} slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.sortOrder} onChange={(event) => setForm({ ...form, sortOrder: event.target.value })} />
</Stack>
<TimeRangeFilter
disabled={loading}
label="投放时间"
label={t("banners.schedule")}
locale={locale}
messages={timeRangeMessages(t)}
value={{ endMs: form.endsAtMs, startMs: form.startsAtMs }}
onChange={({ endMs, startMs }) => setForm({ ...form, endsAtMs: endMs, startsAtMs: startMs })}
/>
<TextField disabled={loading} label="状态" select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">禁用</MenuItem>
<MenuItem value="expired">过期</MenuItem>
<TextField disabled={loading} label={t("common.status")} select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
<MenuItem value="active">{t("common.enabled")}</MenuItem>
<MenuItem value="disabled">{t("common.disabled")}</MenuItem>
<MenuItem value="expired">{t("common.expired")}</MenuItem>
</TextField>
</Stack>
</DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading || !form.coverUrl} type="submit" variant="contained">创建</Button></DialogActions>
<DialogActions><Button disabled={loading} onClick={onClose}>{t("common.cancel")}</Button><Button disabled={loading || !form.coverUrl} type="submit" variant="contained">{t("common.create")}</Button></DialogActions>
</Stack>
</Dialog>
);
@ -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}`)]));
}

View File

@ -8,13 +8,13 @@ describe("BannerCreateDialog", () => {
const user = userEvent.setup();
render(<BannerCreateDialog open onClose={vi.fn()} onSubmit={vi.fn()} />);
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" }));
});
});

View File

@ -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 (
<GrantDialog loading={loading} open={open} title="特权道具发放" onClose={onClose} onSubmit={() => onSubmit(form)}>
<GrantDialog loading={loading} open={open} title={t("grants.resourceTitle")} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TargetNotice />
<TextField disabled={loading} label="用户 ID / 短 ID靓号" required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
<TextField disabled={loading} label="资源" required select value={form.resourceId} onChange={(event) => setForm({ ...form, resourceId: event.target.value })}>
{resources.map((item) => <MenuItem key={item.id} value={item.id}>{item.name}{item.id}</MenuItem>)}
<TextField disabled={loading} label={t("grants.target")} required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
<TextField disabled={loading} label={t("grants.resource")} required select value={form.resourceId} onChange={(event) => setForm({ ...form, resourceId: event.target.value })}>
{resources.map((item) => <MenuItem key={item.id} value={item.id}>{item.name} ({item.id})</MenuItem>)}
</TextField>
<TextField disabled={loading} label="数量" required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.quantity} onChange={(event) => setForm({ ...form, quantity: event.target.value })} />
<TextField disabled={loading} helperText="填 0 表示永久有效" label="有效天数" required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
<TextField disabled={loading} label="发放原因" minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
<TextField disabled={loading} label={t("grants.quantity")} required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.quantity} onChange={(event) => setForm({ ...form, quantity: event.target.value })} />
<TextField disabled={loading} helperText={t("grants.permanentHint")} label={t("grants.validDays")} required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
<TextField disabled={loading} label={t("grants.reason")} minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</GrantDialog>
);
}
export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) {
const { t } = useExternalI18n();
const [form, setForm] = useState(prettyGrantInitial);
useEffect(() => setForm(prettyGrantInitial), [open]);
return (
<GrantDialog loading={loading} open={open} title="靓号下发" onClose={onClose} onSubmit={() => onSubmit(form)}>
<GrantDialog loading={loading} open={open} title={t("grants.prettyIdTitle")} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TargetNotice />
<TextField disabled={loading} label="用户 ID / 当前短 ID" required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
<TextField disabled={loading} label="下发靓号" required slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }} value={form.prettyId} onChange={(event) => setForm({ ...form, prettyId: event.target.value.trim() })} />
<TextField disabled={loading} helperText="填 0 表示永久有效" label="有效天数" required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
<TextField disabled={loading} label="下发原因" minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
<TextField disabled={loading} label={t("grants.currentTarget")} required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
<TextField disabled={loading} label={t("grants.prettyId")} required slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }} value={form.prettyId} onChange={(event) => setForm({ ...form, prettyId: event.target.value.trim() })} />
<TextField disabled={loading} helperText={t("grants.permanentHint")} label={t("grants.validDays")} required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
<TextField disabled={loading} label={t("grants.reason")} minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</GrantDialog>
);
}
function TargetNotice() {
return <Alert severity="info">提交前会校验当前 App 内的用户并使用服务端返回的用户 ID 执行发放</Alert>;
const { t } = useExternalI18n();
return <Alert severity="info">{t("grants.notice")}</Alert>;
}
function GrantDialog({ children, loading, onClose, onSubmit, open, title }) {
const { t } = useExternalI18n();
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={(event) => { event.preventDefault(); onSubmit(); }}>
<DialogTitle>{title}</DialogTitle>
<DialogContent><Stack className="external-dialog-fields" spacing={2}>{children}</Stack></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading} type="submit" variant="contained">确认发放</Button></DialogActions>
<DialogActions><Button disabled={loading} onClick={onClose}>{t("common.cancel")}</Button><Button disabled={loading} type="submit" variant="contained">{t("grants.confirm")}</Button></DialogActions>
</Stack>
</Dialog>
);

View File

@ -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 (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={(event) => { event.preventDefault(); onSubmit(form); }}>
<DialogTitle>编辑房间</DialogTitle>
<DialogTitle>{t("rooms.editTitle")}</DialogTitle>
<DialogContent><Stack className="external-dialog-fields" spacing={2}>
<ExternalImageUploadField disabled={loading} label="房间背景图" value={form.coverUrl} onChange={(coverUrl) => setForm({ ...form, coverUrl })} />
<TextField disabled={loading} label="房间名称" required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} />
<TextField disabled={loading} label="房间介绍" minRows={3} multiline value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
<TextField disabled={loading} label="可见区域 ID" value={form.visibleRegionId} onChange={(event) => setForm({ ...form, visibleRegionId: event.target.value.trim() })} />
<ExternalImageUploadField disabled={loading} label={t("rooms.background")} value={form.coverUrl} onChange={(coverUrl) => setForm({ ...form, coverUrl })} />
<TextField disabled={loading} label={t("rooms.name")} required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} />
<TextField disabled={loading} label={t("rooms.description")} minRows={3} multiline value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
<TextField disabled={loading} label={t("rooms.visibleRegionId")} value={form.visibleRegionId} onChange={(event) => setForm({ ...form, visibleRegionId: event.target.value.trim() })} />
</Stack></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading} type="submit" variant="contained">保存</Button></DialogActions>
<DialogActions><Button disabled={loading} onClick={onClose}>{t("common.cancel")}</Button><Button disabled={loading} type="submit" variant="contained">{t("common.save")}</Button></DialogActions>
</Stack>
</Dialog>
);

View File

@ -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 (
<BaseDialog loading={loading} open={open} title="编辑用户" onClose={onClose} onSubmit={() => onSubmit(form)}>
<ExternalImageUploadField disabled={loading} label="头像" value={form.avatar} onChange={(avatar) => setForm({ ...form, avatar })} />
<TextField disabled={loading} label="用户名称" required value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} />
<TextField disabled={loading} label="性别" select value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}>
<MenuItem value="">未设置</MenuItem><MenuItem value="male"></MenuItem><MenuItem value="female"></MenuItem><MenuItem value="unknown">未知</MenuItem>
<BaseDialog loading={loading} open={open} title={t("users.editTitle")} onClose={onClose} onSubmit={() => onSubmit(form)}>
<ExternalImageUploadField disabled={loading} label={t("users.avatar")} value={form.avatar} onChange={(avatar) => setForm({ ...form, avatar })} />
<TextField disabled={loading} label={t("users.name")} required value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} />
<TextField disabled={loading} label={t("users.gender")} select value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}>
<MenuItem value="">{t("users.notSet")}</MenuItem><MenuItem value="male">{t("users.genderMale")}</MenuItem><MenuItem value="female">{t("users.genderFemale")}</MenuItem><MenuItem value="unknown">{t("users.genderUnknown")}</MenuItem>
</TextField>
<TextField disabled={loading} label="国家/地区码" value={form.country} onChange={(event) => setForm({ ...form, country: event.target.value.trim().toUpperCase() })} />
<TextField disabled={loading} label={t("users.countryCode")} value={form.country} onChange={(event) => setForm({ ...form, country: event.target.value.trim().toUpperCase() })} />
</BaseDialog>
);
}
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 (
<BaseDialog loading={loading} open={open} title={`封禁用户 ${user?.prettyId || user?.username || ""}`} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TextField disabled={loading} helperText="填 0 表示永久封禁" label="封禁天数" required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.days} onChange={(event) => setForm({ ...form, days: event.target.value })} />
<TextField disabled={loading} label="封禁原因" minRows={3} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
<BaseDialog loading={loading} open={open} title={t("users.banTitle", { user: user?.prettyId || user?.username || "" })} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TextField disabled={loading} helperText={t("users.banForeverHint")} label={t("users.banDays")} required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.days} onChange={(event) => setForm({ ...form, days: event.target.value })} />
<TextField disabled={loading} label={t("users.banReason")} minRows={3} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</BaseDialog>
);
}
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 (
<BaseDialog loading={loading} open={open} title={`等级下发 ${user?.prettyId || user?.username || ""}`} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TextField disabled={loading} label="等级类型" select value={form.track} onChange={(event) => setForm({ ...form, track: event.target.value })}><MenuItem value="wealth">财富等级</MenuItem><MenuItem value="charm">魅力等级</MenuItem><MenuItem value="vip">VIP 等级</MenuItem></TextField>
<TextField disabled={loading} label="等级" required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.level} onChange={(event) => setForm({ ...form, level: event.target.value })} />
<TextField disabled={loading} helperText="有效期至少 1 天" label="有效天数" required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.days} onChange={(event) => setForm({ ...form, days: event.target.value })} />
<TextField disabled={loading} label="下发原因" minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
<BaseDialog loading={loading} open={open} title={t("users.levelTitle", { user: user?.prettyId || user?.username || "" })} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TextField disabled={loading} label={t("users.levelType")} select value={form.track} onChange={(event) => setForm({ ...form, track: event.target.value })}><MenuItem value="wealth">{t("users.wealthLevel")}</MenuItem><MenuItem value="charm">{t("users.charmLevel")}</MenuItem><MenuItem value="vip">{t("users.vipLevel")}</MenuItem></TextField>
<TextField disabled={loading} label={t("users.level")} required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.level} onChange={(event) => setForm({ ...form, level: event.target.value })} />
<TextField disabled={loading} helperText={t("users.validDaysMin")} label={t("users.validDays")} required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.days} onChange={(event) => setForm({ ...form, days: event.target.value })} />
<TextField disabled={loading} label={t("users.grantReason")} minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</BaseDialog>
);
}
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 }) {
<Stack component="form" onSubmit={handleSubmit}>
<DialogTitle>{title}</DialogTitle>
<DialogContent><Stack className="external-dialog-fields" spacing={2}>{children}</Stack></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button disabled={loading} type="submit" variant="contained">确认</Button></DialogActions>
<DialogActions><Button disabled={loading} onClick={onClose}>{t("common.cancel")}</Button><Button disabled={loading} type="submit" variant="contained">{t("common.confirm")}</Button></DialogActions>
</Stack>
</Dialog>
);

View File

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

View File

@ -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 <ExternalI18nContext.Provider value={value}>{children}</ExternalI18nContext.Provider>;
}
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])));
}

View File

@ -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(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
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(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
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(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN");
expect(screen.getByTestId("title")).toHaveTextContent("HYApp 外管后台");
});
});
function LocaleProbe() {
const { locale, setLocale, t } = useExternalI18n();
return (
<div>
<span data-testid="locale">{locale}</span>
<span data-testid="title">{t("app.title")}</span>
{["zh-CN", "ar", "tr"].map((value) => <button key={value} type="button" onClick={() => setLocale(value)}>{value}</button>)}
</div>
);
}

View File

@ -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 (
<TextField
className="external-language-switcher"
label={t("language.label")}
select
size="small"
slotProps={{
input: { startAdornment: compact ? null : <InputAdornment position="start"><LanguageOutlined fontSize="small" /></InputAdornment> },
select: { displayEmpty: true }
}}
value={locale}
onChange={(event) => setLocale(event.target.value)}
>
{languages.map((language) => <MenuItem key={language.value} value={language.value}>{language.label}</MenuItem>)}
</TextField>
);
}

View File

@ -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;
}

View File

@ -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");
});
});

View File

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

View File

@ -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": "23 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 23 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ıı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": "23 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 23 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;
}

View File

@ -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.");
});
});

View File

@ -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() {
<Box className="external-logo">
<Box className="external-logo-mark">HY</Box>
<Box>
<Typography fontWeight={750}>HYApp 外管</Typography>
<Typography fontWeight={750}>{t("app.shortTitle")}</Typography>
<Typography color="text.secondary" variant="caption">{session?.appName || session?.appCode}</Typography>
</Box>
</Box>
@ -88,7 +91,7 @@ export function ExternalAdminLayout() {
onClick={() => setDrawerOpen(false)}
>
<ListItemIcon><Icon fontSize="small" /></ListItemIcon>
<ListItemText primary={item.label} />
<ListItemText primary={t(item.labelKey)} />
</ListItemButton>
);
})}
@ -98,28 +101,39 @@ export function ExternalAdminLayout() {
return (
<Box className="external-shell">
<AppBar className="external-header" color="inherit" elevation={0} position="fixed" sx={{ ml: { md: `${drawerWidth}px` }, width: { md: `calc(100% - ${drawerWidth}px)` } }}>
<AppBar
className="external-header"
color="inherit"
elevation={0}
position="fixed"
sx={{
ml: direction === "ltr" ? { md: `${drawerWidth}px` } : 0,
mr: direction === "rtl" ? { md: `${drawerWidth}px` } : 0,
width: { md: `calc(100% - ${drawerWidth}px)` }
}}
>
<Toolbar>
{mobile ? <IconButton aria-label="展开菜单" onClick={() => setDrawerOpen(true)}><MenuOutlined /></IconButton> : null}
<Typography component="h2" fontWeight={700} noWrap>{current?.label || "外管后台"}</Typography>
{mobile ? <IconButton aria-label={t("nav.expand")} onClick={() => setDrawerOpen(true)}><MenuOutlined /></IconButton> : null}
<Typography component="h2" fontWeight={700} noWrap>{current ? t(current.labelKey) : t("app.title")}</Typography>
<Box flex={1} />
<Stack alignItems="center" direction="row" spacing={1}>
<Avatar className="external-user-avatar">{String(session?.account || "管").slice(0, 1).toUpperCase()}</Avatar>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<LanguageSwitcher compact={mobile} />
<Avatar className="external-user-avatar">{String(session?.account || "A").slice(0, 1).toUpperCase()}</Avatar>
{!mobile ? (
<Box>
<Typography fontWeight={650} lineHeight={1.2}>{session?.account}</Typography>
<Typography fontWeight={650} sx={{ lineHeight: 1.2 }}>{session?.account}</Typography>
<Typography color="text.secondary" variant="caption">{session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""}</Typography>
</Box>
) : null}
<Tooltip title="退出登录">
<Button aria-label="退出登录" color="inherit" onClick={handleLogout} startIcon={<LogoutOutlined />}>{mobile ? "" : "退出"}</Button>
<Tooltip title={t("auth.logout")}>
<Button aria-label={t("auth.logout")} color="inherit" onClick={handleLogout} startIcon={<LogoutOutlined />}>{mobile ? "" : t("auth.logout")}</Button>
</Tooltip>
</Stack>
</Toolbar>
</AppBar>
<Drawer open={drawerOpen} variant="temporary" onClose={() => setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}</Drawer>
<Drawer open variant="permanent" sx={{ display: { xs: "none", md: "block" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}</Drawer>
<Box className="external-main" component="main" sx={{ ml: { md: `${drawerWidth}px` } }}>
<Drawer anchor={direction === "rtl" ? "right" : "left"} open={drawerOpen} variant="temporary" onClose={() => setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}</Drawer>
<Drawer anchor={direction === "rtl" ? "right" : "left"} open variant="permanent" sx={{ display: { xs: "none", md: "block" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}</Drawer>
<Box className="external-main" component="main" sx={{ ml: direction === "ltr" ? { md: `${drawerWidth}px` } : 0, mr: direction === "rtl" ? { md: `${drawerWidth}px` } : 0 }}>
<Toolbar />
<Outlet />
</Box>

View File

@ -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(
<ExternalI18nProvider>
<MemoryRouter initialEntries={["/overview"]}>
<Routes>
<Route element={<ExternalAdminLayout />}>
<Route element={<div>overview content</div>} path="/overview" />
</Route>
</Routes>
</MemoryRouter>
</ExternalI18nProvider>
);
}

View File

@ -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(
<React.StrictMode>
<ThemeProvider theme={theme}>
<ToastProvider>
<CssBaseline />
<BrowserRouter basename="/external-admin">
<ExternalAuthProvider>
<ExternalAdminApp />
</ExternalAuthProvider>
</BrowserRouter>
</ToastProvider>
</ThemeProvider>
<ExternalI18nProvider>
<ExternalThemeRoot />
</ExternalI18nProvider>
</React.StrictMode>
);
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 (
<CacheProvider value={externalEmotionCache(direction)}>
<ThemeProvider theme={externalTheme}>
<ToastProvider>
<CssBaseline />
<BrowserRouter basename="/external-admin">
<ExternalAuthProvider>
<ExternalAdminApp />
</ExternalAuthProvider>
</BrowserRouter>
</ToastProvider>
</ThemeProvider>
</CacheProvider>
);
}

View File

@ -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) => <Stack alignItems="center" direction="row" spacing={1}>{item.coverUrl ? <Box alt={item.description} className="external-banner-thumb" component="img" src={item.coverUrl} /> : null}<Box><Typography fontWeight={650}>{item.description || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}</Typography></Box></Stack> },
{ 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) => <StatusChip status={item.status} /> }
], []);
{ key: "banner", label: t("banners.banner"), render: (item) => <Stack alignItems="center" direction="row" spacing={1}>{item.coverUrl ? <Box alt={item.description} className="external-banner-thumb" component="img" src={item.coverUrl} /> : null}<Box><Typography fontWeight={650}>{item.description || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}</Typography></Box></Stack> },
{ 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) => <StatusChip status={item.status} /> }
], [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 (
<ExternalPage actions={<Button onClick={() => setOpen(true)} startIcon={<AddOutlined />} variant="contained">创建 Banner</Button>} title="Banner 管理">
<ExternalPage actions={<Button onClick={() => setOpen(true)} startIcon={<AddOutlined />} variant="contained">{t("banners.create")}</Button>} title={t("banners.title")}>
<ExternalPageState error={error} loading={loading} onRetry={reload} />
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={setPage} /></> : null}
<BannerCreateDialog loading={submitting} open={open} onClose={() => setOpen(false)} onSubmit={submit} />

View File

@ -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) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={user.avatar}>{user.username.slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
{ key: "prettyId", label: "短 ID靓号", render: (user) => user.prettyId || "-" },
{ key: "reason", label: "封禁原因", render: (user) => user.reason || "-" },
{ key: "status", label: "状态", render: () => <StatusChip status="banned" /> },
{ key: "actions", label: "操作", render: (user) => canUnban ? <Button onClick={() => setTarget(user)} startIcon={<LockOpenOutlined />}>解除封禁</Button> : "-" }
], [canUnban]);
{ key: "user", label: t("common.user"), render: (user) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={user.avatar}>{(user.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
{ 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: () => <StatusChip status="banned" /> },
{ key: "actions", label: t("common.actions"), render: (user) => canUnban ? <Button onClick={() => setTarget(user)} startIcon={<LockOpenOutlined />}>{t("bans.unban")}</Button> : "-" }
], [canUnban, t]);
return (
<ExternalPage title="账号封禁列表">
<ExternalPage title={t("bans.title")}>
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ keyword, page: 1 }); }}>
<TextField label="用户 ID / 短 ID / 名称" value={keyword} onChange={(event) => setKeyword(event.target.value)} />
<Button type="submit" variant="contained">查询</Button>
<TextField label={t("users.search")} value={keyword} onChange={(event) => setKeyword(event.target.value)} />
<Button type="submit" variant="contained">{t("common.query")}</Button>
</Stack>
<ExternalPageState error={error} loading={loading} onRetry={reload} />
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
<ConfirmDialog confirmColor="primary" content={`确认解除用户 ${target?.prettyId || target?.username || ""} 的封禁?`} loading={submitting} open={Boolean(target)} title="解除封禁" onClose={() => setTarget(null)} onConfirm={handleUnban} />
<ConfirmDialog confirmColor="primary" content={t("bans.confirmContent", { user: target?.prettyId || target?.username || "" })} loading={submitting} open={Boolean(target)} title={t("bans.confirmTitle")} onClose={() => setTarget(null)} onConfirm={handleUnban} />
</ExternalPage>
);
}

View File

@ -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() {
<Box className="external-login-page">
<Card className="external-login-card" elevation={0}>
<CardContent>
<Stack alignItems="center" spacing={1.5}><Avatar className="external-login-avatar"><KeyOutlined /></Avatar><Typography component="h1" variant="h5">修改外管密码</Typography><Typography color="text.secondary">{session?.passwordChangeRequired ? "首次登录必须修改密码" : session?.account}</Typography></Stack>
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
<Stack spacing={1.5} sx={{ alignItems: "center" }}><Avatar className="external-login-avatar"><KeyOutlined /></Avatar><Typography component="h1" variant="h5">{t("auth.changePassword")}</Typography><Typography color="text.secondary">{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account}</Typography></Stack>
<Stack component="form" spacing={2} onSubmit={submit}>
{error ? <Alert severity="error">{error}</Alert> : null}
<TextField autoComplete="current-password" disabled={submitting} label="当前密码" required type="password" value={form.oldPassword} onChange={(event) => setForm({ ...form, oldPassword: event.target.value })} />
<TextField autoComplete="new-password" disabled={submitting} helperText="至少 8 个字符" label="新密码" required type="password" value={form.newPassword} onChange={(event) => setForm({ ...form, newPassword: event.target.value })} />
<TextField autoComplete="new-password" disabled={submitting} label="确认新密码" required type="password" value={form.confirmPassword} onChange={(event) => setForm({ ...form, confirmPassword: event.target.value })} />
<Stack direction="row" spacing={1}><Button disabled={submitting} fullWidth onClick={cancel} variant="outlined">退出登录</Button><Button disabled={submitting} fullWidth type="submit" variant="contained">保存密码</Button></Stack>
<TextField autoComplete="current-password" disabled={submitting} label={t("auth.currentPassword")} required type="password" value={form.oldPassword} onChange={(event) => setForm({ ...form, oldPassword: event.target.value })} />
<TextField autoComplete="new-password" disabled={submitting} helperText={t("auth.passwordMin")} label={t("auth.newPassword")} required type="password" value={form.newPassword} onChange={(event) => setForm({ ...form, newPassword: event.target.value })} />
<TextField autoComplete="new-password" disabled={submitting} label={t("auth.confirmNewPassword")} required type="password" value={form.confirmPassword} onChange={(event) => setForm({ ...form, confirmPassword: event.target.value })} />
<Stack direction="row" spacing={1}><Button disabled={submitting} fullWidth onClick={cancel} variant="outlined">{t("auth.logout")}</Button><Button disabled={submitting} fullWidth type="submit" variant="contained">{t("auth.savePassword")}</Button></Stack>
</Stack>
</CardContent>
</Card>

View File

@ -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 " });
});

View File

@ -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) => <StatusChip status={item.status} /> },
{ 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) => <StatusChip status={item.status} /> },
{ key: "reason", label: t("common.reason"), render: (item) => String(item.reason || "-") }
], [t]);
return (
<ExternalPage
actions={<>{canGrant ? <Button disabled={resourcesState.loading} onClick={() => setDialog("resource")} startIcon={<CardGiftcardOutlined />} variant="contained">发放道具</Button> : null}{canGrantPrettyId ? <Button onClick={() => setDialog("pretty")} startIcon={<NumbersOutlined />} variant="outlined">下发靓号</Button> : null}</>}
title="资源与靓号"
actions={<>{canGrant ? <Button disabled={resourcesState.loading} onClick={() => setDialog("resource")} startIcon={<CardGiftcardOutlined />} variant="contained">{t("grants.grantResource")}</Button> : null}{canGrantPrettyId ? <Button onClick={() => setDialog("pretty")} startIcon={<NumbersOutlined />} variant="outlined">{t("grants.grantPrettyId")}</Button> : null}</>}
title={t("grants.title")}
>
{canGrant ? (
<Box className="external-form-panel">
<Typography fontWeight={700} gutterBottom>可发放资源</Typography>
<Typography fontWeight={700} gutterBottom>{t("grants.availableResources")}</Typography>
<Stack direction="row" flexWrap="wrap" gap={1}>
{resourcesState.data.items.slice(0, 12).map((resource) => <Stack alignItems="center" direction="row" key={resource.id} spacing={0.75}><Avatar src={resource.coverUrl} sx={{ height: 28, width: 28 }} variant="rounded"></Avatar><Typography variant="body2">{resource.name}</Typography></Stack>)}
{!resourcesState.loading && !resourcesState.data.items.length ? <Typography color="text.secondary">当前无数据</Typography> : null}
{resourcesState.data.items.slice(0, 12).map((resource) => <Stack alignItems="center" direction="row" key={resource.id} spacing={0.75}><Avatar src={resource.coverUrl} sx={{ height: 28, width: 28 }} variant="rounded">R</Avatar><Typography variant="body2">{resource.name}</Typography></Stack>)}
{!resourcesState.loading && !resourcesState.data.items.length ? <Typography color="text.secondary">{t("common.noData")}</Typography> : null}
</Stack>
</Box>
) : null}

View File

@ -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() {
<Box className="external-login-page">
<Card className="external-login-card" elevation={0}>
<CardContent>
<Stack alignItems="center" spacing={1.5}>
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
<Stack spacing={1.5} sx={{ alignItems: "center" }}>
<Avatar className="external-login-avatar"><LockOutlined /></Avatar>
<Typography component="h1" variant="h5">外管后台登录</Typography>
<Typography component="h1" variant="h5">{t("app.title")}</Typography>
</Stack>
<Stack component="form" spacing={2.25} onSubmit={handleSubmit}>
{error ? <Alert severity="error">{error}</Alert> : null}
{appsError ? <Alert action={<Button color="inherit" onClick={loadApps} size="small">重试</Button>} severity="error">{appsError}</Alert> : null}
{apps.length || appsLoading ? (
<FormControl disabled={appsLoading || submitting} fullWidth required>
<InputLabel id="external-login-app-label">App</InputLabel>
<Select label="App" labelId="external-login-app-label" value={form.appCode} onChange={(event) => setForm({ ...form, appCode: event.target.value })}>
{apps.map((app) => <MenuItem key={app.appCode} value={app.appCode}>{app.name}{app.appCode}</MenuItem>)}
</Select>
</FormControl>
) : (
<TextField disabled={submitting} label="App Code" required value={form.appCode} onChange={(event) => setForm({ ...form, appCode: event.target.value.trim() })} />
)}
<TextField autoComplete="username" disabled={submitting} label="外管账号" required value={form.account} onChange={(event) => setForm({ ...form, account: event.target.value })} />
<TextField autoComplete="username" disabled={submitting} label={t("auth.account")} required value={form.account} onChange={(event) => setForm({ ...form, account: event.target.value })} />
<TextField
autoComplete="current-password"
disabled={submitting}
label="密码"
label={t("auth.password")}
required
type={showPassword ? "text" : "password"}
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton aria-label={showPassword ? "隐藏密码" : "显示密码"} edge="end" onClick={() => setShowPassword((current) => !current)}>{showPassword ? <VisibilityOffOutlined /> : <VisibilityOutlined />}</IconButton></InputAdornment> } }}
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")} edge="end" onClick={() => setShowPassword((current) => !current)}>{showPassword ? <VisibilityOffOutlined /> : <VisibilityOutlined />}</IconButton></InputAdornment> } }}
/>
<Button disabled={appsLoading || submitting} size="large" type="submit" variant="contained">
{submitting ? <CircularProgress color="inherit" size={20} /> : "登录"}
<Button disabled={submitting} size="large" type="submit" variant="contained">
{submitting ? <CircularProgress color="inherit" size={20} /> : t("auth.login")}
</Button>
</Stack>
</CardContent>

View File

@ -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(<ExternalI18nProvider><MemoryRouter><LoginPage /></MemoryRouter></ExternalI18nProvider>);
}

View File

@ -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) => <Box><Typography fontWeight={650}>公会 {item.id || "-"}</Typography><Typography color="text.secondary" variant="caption">Owner {item.userId || "-"}</Typography></Box> },
{ key: "parent", label: "上级 BD", render: (item) => item.parentBdUserId || "-" },
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> }
{ key: "agency", label: t("organization.agency"), render: (item) => <Box><Typography fontWeight={650}>{t("organization.agencyReference", { id: item.id || "-" })}</Typography><Typography color="text.secondary" variant="caption">{t("organization.owner")} {item.userId || "-"}</Typography></Box> },
{ key: "parent", label: t("organization.parentBd"), render: (item) => item.parentBdUserId || "-" },
{ key: "status", label: t("common.status"), render: (item) => <StatusChip status={item.status} /> }
] : [
{ key: "identity", label: "成员", render: (item) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={item.avatar}>{(item.name || item.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{item.name || item.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / 用户 ${item.userId}` : ""}</Typography></Box></Stack> },
{ key: "prettyId", label: "短 ID靓号", render: (item) => item.prettyId || "-" },
{ key: "country", label: "国家/地区", render: (item) => item.country || "-" },
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> }
], [kind]);
{ key: "identity", label: t("organization.member"), render: (item) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={item.avatar}>{(item.name || item.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{item.name || item.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""}</Typography></Box></Stack> },
{ 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) => <StatusChip status={item.status} /> }
], [kind, t]);
if (!config || !allowed) {
return <ExternalPage title={config?.title || "组织管理"}><Alert severity="warning">当前外管账号没有访问该组织列表的权限</Alert></ExternalPage>;
return <ExternalPage title={config ? t(config.titleKey) : t("organization.title")}><Alert severity="warning">{t("organization.noPermission")}</Alert></ExternalPage>;
}
return (
<ExternalPage title={config.title}>
<ExternalPage title={t(config.titleKey)}>
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ keyword, page: 1 }); }}>
<TextField label={kind === "team" ? "公会 ID / Owner 用户 ID / 上级 BD ID" : "用户 ID / 短 ID / 名称"} value={keyword} onChange={(event) => setKeyword(event.target.value)} />
<Button type="submit" variant="contained">查询</Button>
<TextField label={kind === "team" ? t("organization.teamSearch") : t("users.search")} value={keyword} onChange={(event) => setKeyword(event.target.value)} />
<Button type="submit" variant="contained">{t("common.query")}</Button>
</Stack>
<ExternalPageState error={error} loading={loading} onRetry={reload} />
{kind === "team" && !loading && !error ? (
<Box className="external-form-panel">
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 4 }}>
<Typography>BD Leader<strong>{data.totalBdLeaders || 0}</strong></Typography>
<Typography>BD<strong>{data.totalBds || 0}</strong></Typography>
<Typography>公会<strong>{data.total || data.items.length}</strong></Typography>
<Typography>{t("organization.bdLeaderCount", { count: data.totalBdLeaders || 0 })}</Typography>
<Typography>{t("organization.bdCount", { count: data.totalBds || 0 })}</Typography>
<Typography>{t("organization.agencyCount", { count: data.total || data.items.length })}</Typography>
</Stack>
{data.truncated ? <Alert severity="warning" sx={{ mt: 2 }}>团队规模超过接口上限当前结果不完整</Alert> : null}
{data.truncated ? <Alert severity="warning" sx={{ mt: 2 }}>{t("organization.truncated")}</Alert> : null}
</Box>
) : null}
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item, index) => item.id || index} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}

View File

@ -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 (
<ExternalPage title="权限总览">
<ExternalPage title={t("nav.overview")}>
<Card className="external-session-card" variant="outlined">
<CardContent>
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 4 }}>
<Box><Typography color="text.secondary" variant="caption">当前 App</Typography><Typography fontWeight={700}>{session.appName || session.appCode}{session.appCode}</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">外管账号</Typography><Typography fontWeight={700}>{session.account}</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">已开通权限</Typography><Typography fontWeight={700}>{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length}</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">{t("overview.currentApp")}</Typography><Typography fontWeight={700}>{session.appName || session.appCode} ({session.appCode})</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">{t("overview.account")}</Typography><Typography fontWeight={700}>{session.account}</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">{t("overview.permissions")}</Typography><Typography fontWeight={700}>{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length}</Typography></Box>
</Stack>
</CardContent>
</Card>
@ -33,8 +35,8 @@ export default function OverviewPage() {
<CardContent>
<Stack alignItems="flex-start" spacing={1.5}>
{enabled ? <CheckCircleOutlineOutlined color="success" /> : <LockOutlined color="disabled" />}
<Typography fontWeight={700}>{item.label}</Typography>
<Chip color={enabled ? "success" : "default"} label={enabled ? "已开通" : "未开通"} size="small" variant="outlined" />
<Typography fontWeight={700}>{t(item.labelKey)}</Typography>
<Chip color={enabled ? "success" : "default"} label={enabled ? t("overview.enabled") : t("overview.notEnabled")} size="small" variant="outlined" />
</Stack>
</CardContent>
);

View File

@ -22,10 +22,10 @@ describe("OverviewPage", () => {
render(<MemoryRouter><OverviewPage /></MemoryRouter>);
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();
});
});

View File

@ -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) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={item.coverUrl} variant="rounded"></Avatar><Box><Typography fontWeight={650}>{item.title || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id}</Typography></Box></Stack> },
{ key: "owner", label: "房主", render: (item) => item.ownerName || "-" },
{ key: "region", label: "可见区域", render: (item) => item.visibleRegionId || "全部" },
{ key: "status", label: "状态", render: (item) => <StatusChip status={item.status} /> },
{ key: "actions", label: "操作", render: (item) => canEdit ? <Button onClick={() => setRoom(item)} startIcon={<EditOutlined />}>编辑</Button> : "-" }
], [canEdit]);
{ key: "room", label: t("rooms.room"), render: (item) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={item.coverUrl} variant="rounded">R</Avatar><Box><Typography fontWeight={650}>{item.title || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id}</Typography></Box></Stack> },
{ 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) => <StatusChip status={item.status} /> },
{ key: "actions", label: t("common.actions"), render: (item) => canEdit ? <Button onClick={() => setRoom(item)} startIcon={<EditOutlined />}>{t("common.edit")}</Button> : "-" }
], [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 (
<ExternalPage title="房间管理">
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ keyword, page: 1 }); }}><TextField label="房间 ID / 名称 / 房主" value={keyword} onChange={(event) => setKeyword(event.target.value)} /><Button type="submit" variant="contained">查询</Button></Stack>
<ExternalPage title={t("rooms.title")}>
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ keyword, page: 1 }); }}><TextField label={t("rooms.search")} value={keyword} onChange={(event) => setKeyword(event.target.value)} /><Button type="submit" variant="contained">{t("common.query")}</Button></Stack>
<ExternalPageState error={error} loading={loading} onRetry={reload} />
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
<RoomEditDialog loading={submitting} open={Boolean(room)} room={room} onClose={() => setRoom(null)} onSubmit={submit} />

View File

@ -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) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={user.avatar}>{user.username.slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
{ 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) => <StatusChip status={user.banned ? "banned" : user.status} /> },
{ key: "actions", label: "操作", render: (user) => <Stack direction="row" flexWrap="wrap" gap={0.5}>{canEdit ? <Button onClick={() => setDialog({ type: "edit", user })} startIcon={<EditOutlined />}>编辑</Button> : null}{canGrantLevel ? <Button onClick={() => setDialog({ type: "level", user })} startIcon={<TrendingUpOutlined />}>等级</Button> : null}{canBan && !user.banned ? <Button color="error" onClick={() => setDialog({ type: "ban", user })} startIcon={<GppBadOutlined />}>封禁</Button> : null}</Stack> }
], [canBan, canEdit, canGrantLevel]);
{ key: "user", label: t("common.user"), render: (user) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={user.avatar}>{(user.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
{ 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) => <StatusChip status={user.banned ? "banned" : user.status} /> },
{ key: "actions", label: t("common.actions"), render: (user) => <Stack direction="row" flexWrap="wrap" gap={0.5}>{canEdit ? <Button onClick={() => setDialog({ type: "edit", user })} startIcon={<EditOutlined />}>{t("users.edit")}</Button> : null}{canGrantLevel ? <Button onClick={() => setDialog({ type: "level", user })} startIcon={<TrendingUpOutlined />}>{t("users.grantLevel")}</Button> : null}{canBan && !user.banned ? <Button color="error" onClick={() => setDialog({ type: "ban", user })} startIcon={<GppBadOutlined />}>{t("users.ban")}</Button> : null}</Stack> }
], [canBan, canEdit, canGrantLevel, t]);
return (
<ExternalPage title="用户管理">
<ExternalPage title={t("users.title")}>
<Stack className="external-filter-bar" component="form" direction={{ xs: "column", sm: "row" }} spacing={1.5} onSubmit={(event) => { event.preventDefault(); setQuery({ ...filters, page: 1 }); }}>
<TextField label="用户 ID / 短 ID / 名称" value={filters.keyword} onChange={(event) => setFilters({ ...filters, keyword: event.target.value })} />
<TextField label="状态" select value={filters.status} onChange={(event) => setFilters({ ...filters, status: event.target.value })}><MenuItem value="">全部状态</MenuItem><MenuItem value="active">正常</MenuItem><MenuItem value="banned">已封禁</MenuItem></TextField>
<Button type="submit" variant="contained">查询</Button>
<TextField label={t("users.search")} value={filters.keyword} onChange={(event) => setFilters({ ...filters, keyword: event.target.value })} />
<TextField label={t("common.status")} select value={filters.status} onChange={(event) => setFilters({ ...filters, status: event.target.value })}><MenuItem value="">{t("common.allStatuses")}</MenuItem><MenuItem value="active">{t("common.normal")}</MenuItem><MenuItem value="banned">{t("common.banned")}</MenuItem></TextField>
<Button type="submit" variant="contained">{t("common.query")}</Button>
</Stack>
<ExternalPageState error={error} loading={loading} onRetry={reload} />
{!loading && !error ? <><ResponsiveDataList columns={columns} items={data.items} rowKey={(item) => item.id} /><PagePagination {...data} onChange={(page) => setQuery({ ...query, page })} /></> : null}
<UserEditDialog loading={submitting} open={dialog.type === "edit"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => execute(() => updateUser(dialog.user.id, form), "用户信息已更新")} />
<UserBanDialog loading={submitting} open={dialog.type === "ban"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => execute(() => banUser(dialog.user.id, { expires_at_ms: Number(form.days) === 0 ? 0 : Date.now() + Number(form.days) * 86400000, reason: form.reason.trim() }), "用户已封禁")} />
<UserEditDialog loading={submitting} open={dialog.type === "edit"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => execute(() => updateUser(dialog.user.id, form), "users.updated")} />
<UserBanDialog loading={submitting} open={dialog.type === "ban"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => 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")} />
<UserLevelDialog loading={submitting} open={dialog.type === "level"} user={dialog.user} onClose={closeDialog} onSubmit={(form) => 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")} />
</ExternalPage>
);
}
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)}`}`;
}

View File

@ -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 (
<Dialog fullWidth maxWidth="xs" open={open} onClose={loading ? undefined : onClose}>
<DialogTitle>{title}</DialogTitle>
<DialogContent><DialogContentText>{content}</DialogContentText></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>取消</Button><Button color={confirmColor} disabled={loading} onClick={onConfirm} variant="contained">确认</Button></DialogActions>
<DialogActions><Button disabled={loading} onClick={onClose}>{t("common.cancel")}</Button><Button color={confirmColor} disabled={loading} onClick={onConfirm} variant="contained">{t("common.confirm")}</Button></DialogActions>
</Dialog>
);
}

View File

@ -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 }) {
<Stack spacing={1}>
<Typography color="text.secondary" variant="body2">{label}</Typography>
<Box className="external-image-field">
{value ? <img alt={`${label}预览`} src={value} /> : <Box className="external-image-placeholder">暂无图片</Box>}
{value ? <img alt={t("upload.preview", { label })} src={value} /> : <Box className="external-image-placeholder">{t("upload.empty")}</Box>}
<Button
disabled={disabled || uploading}
onClick={() => inputRef.current?.click()}
startIcon={uploading ? <CircularProgress size={16} /> : <CloudUploadOutlined />}
variant="outlined"
>
{uploading ? "上传中" : value ? "更换图片" : "上传图片"}
{uploading ? t("upload.uploading") : value ? t("upload.replace") : t("upload.select")}
</Button>
<input accept="image/*" hidden ref={inputRef} type="file" onChange={handleFile} />
</Box>

View File

@ -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 (
<Box aria-label="正在加载" className="external-skeleton" role="status">
<Box aria-label={t("common.loading")} className="external-skeleton" role="status">
<Skeleton height={46} variant="rounded" />
<Skeleton height="min(52vh, 520px)" variant="rounded" />
</Box>
@ -42,20 +45,21 @@ export function ExternalPageState({ error, loading, onRetry }) {
if (error) {
return (
<Stack spacing={2}>
<Alert severity="error">{error}</Alert>
<Button onClick={onRetry} variant="outlined">重新加载</Button>
<Alert severity="error">{translateExternalError(error, t, "errors.loadFailed")}</Alert>
<Button onClick={onRetry} variant="outlined">{t("common.reload")}</Button>
</Stack>
);
}
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 <Box className="external-empty">{emptyText}</Box>;
return <Box className="external-empty">{emptyText || t("common.noData")}</Box>;
}
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 <Chip color={enabled ? "success" : dangerous ? "error" : "default"} label={status || "-"} size="small" variant="outlined" />;
const statusKey = `status.${normalized}`;
const translated = t(statusKey);
return <Chip color={enabled ? "success" : dangerous ? "error" : "default"} label={translated === statusKey ? status || "-" : translated} size="small" variant="outlined" />;
}
export function PagePagination({ page, pageSize, total, onChange }) {
const { t } = useExternalI18n();
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<Stack alignItems="center" direction="row" justifyContent="space-between" spacing={1}>
<Typography color="text.secondary" variant="body2"> {total} </Typography>
<Typography color="text.secondary" variant="body2">{t("pagination.total", { count: total })}</Typography>
<Stack direction="row" spacing={1}>
<Button disabled={page <= 1} onClick={() => onChange(page - 1)} variant="outlined">上一页</Button>
<Button disabled={page <= 1} onClick={() => onChange(page - 1)} variant="outlined">{t("pagination.previous")}</Button>
<Typography className="external-page-number" variant="body2">{page} / {totalPages}</Typography>
<Button disabled={page >= totalPages} onClick={() => onChange(page + 1)} variant="outlined">下一页</Button>
<Button disabled={page >= totalPages} onClick={() => onChange(page + 1)} variant="outlined">{t("pagination.next")}</Button>
</Stack>
</Stack>
);

View File

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

View File

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

View File

@ -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"
},

32
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -19,6 +19,7 @@ export function AppJumpConfigField({
className = "",
disabled = false,
h5Label = "H5 链接",
messages = {},
onChange,
paramValue,
required = false,
@ -75,7 +76,7 @@ export function AppJumpConfigField({
<>
<TextField
disabled={disabled}
label="APP 目标"
label={messages.appTarget || "APP 目标"}
required
select
value={appTargetValue}
@ -83,11 +84,11 @@ export function AppJumpConfigField({
>
{appTargetOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
{messages.targets?.[value] || label}
</MenuItem>
))}
</TextField>
<AppJumpStructuredFields appJump={appJump} disabled={disabled} onChange={setAppJump} />
<AppJumpStructuredFields appJump={appJump} disabled={disabled} messages={messages} onChange={setAppJump} />
<TextField
className={styles.wide}
disabled={disabled}
@ -102,13 +103,13 @@ export function AppJumpConfigField({
)}
</AdminFormFieldGrid>
{type === "app" ? (
<p className={styles.hint}>APP 参数会按公共跳转方案生成 JSON需要特殊参数时可直接编辑 JSON</p>
<p className={styles.hint}>{messages.hint || "APP 参数会按公共跳转方案生成 JSON需要特殊参数时可直接编辑 JSON。"}</p>
) : null}
</div>
);
}
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) ? (
<TextField
disabled={disabled}
label="房间 ID"
label={messages.roomId || "房间 ID"}
required
value={appJump.roomId}
onChange={(event) => onChange({ roomId: event.target.value })}
@ -127,7 +128,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) {
{needsWindow(appJump.target) ? (
<TextField
disabled={disabled}
label="房内窗口"
label={messages.window || "房内窗口"}
required
select
value={appJump.window}
@ -135,7 +136,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) {
>
{appJumpWindowOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
{messages.windows?.[value] || label}
</MenuItem>
))}
</TextField>
@ -143,7 +144,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) {
{needsGameId(appJump.target, appJump.window) ? (
<TextField
disabled={disabled}
label="游戏 ID"
label={messages.gameId || "游戏 ID"}
required={appJump.target === "room_game" || appJump.target === "room_random_game"}
value={appJump.gameId}
onChange={(event) => onChange({ gameId: event.target.value })}
@ -168,7 +169,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) {
{needsGiftId(appJump.target, appJump.window) ? (
<TextField
disabled={disabled}
label="礼物 ID"
label={messages.giftId || "礼物 ID"}
value={appJump.giftId}
onChange={(event) => onChange({ giftId: event.target.value })}
/>
@ -176,7 +177,7 @@ function AppJumpStructuredFields({ appJump, disabled, onChange }) {
{needsRedPacketNo(appJump.target, appJump.window) ? (
<TextField
disabled={disabled}
label="红包编号"
label={messages.redPacketNo || "红包编号"}
value={appJump.redPacketNo}
onChange={(event) => onChange({ redPacketNo: event.target.value })}
/>

View File

@ -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 = "时
<AccessTimeOutlined fontSize="inherit" />
<span>{label}</span>
</span>
<span className={styles.triggerText}>{hasRange ? displayLabel : "不限"}</span>
<span className={styles.triggerText}>{hasRange ? displayLabel : copy.unlimited}</span>
<KeyboardArrowDownOutlined className={styles.triggerChevron} fontSize="small" />
</Button>
<Popover
@ -160,13 +191,13 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
<form className={styles.panel} onSubmit={submitDraft}>
{withTime ? (
<div className={styles.quickRanges}>
{quickRanges.map(([quickLabel, durationMs]) => (
{quickRanges.map(([quickKey, durationMs]) => (
<Button
key={quickLabel}
key={quickKey}
className={styles.quickButton}
onClick={() => applyQuickRange(durationMs)}
>
{quickLabel}
{copy[quickKey]}
</Button>
))}
</div>
@ -175,35 +206,37 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
<RangeFieldButton
active={activeField === "start"}
formatPart={formatPart}
label={withTime ? "开始时间" : "开始日期"}
label={withTime ? copy.start : copy.startDate}
unlimited={copy.unlimited}
value={draft.startMs}
onClick={() => activateField("start")}
/>
<RangeFieldButton
active={activeField === "end"}
formatPart={formatPart}
label={withTime ? "结束时间" : "结束日期"}
label={withTime ? copy.end : copy.endDate}
unlimited={copy.unlimited}
value={draft.endMs}
onClick={() => activateField("end")}
/>
</div>
<div className={styles.rangeOverview} aria-live="polite">
<span className={styles.rangeEdge}>
<span className={`${styles.rangeEdgeMarker} ${styles.rangeEdgeMarkerStart}`}></span>
{formatDayPosition(draft.startMs) || "待选择"}
<span className={`${styles.rangeEdgeMarker} ${styles.rangeEdgeMarkerStart}`}>{copy.startMark}</span>
{formatDayPosition(draft.startMs) || copy.pending}
</span>
<span className={styles.rangeDistance}>{rangeDistance}</span>
<span className={`${styles.rangeEdge} ${styles.rangeEdgeEnd}`}>
{formatDayPosition(draft.endMs) || "待选择"}
<span className={`${styles.rangeEdgeMarker} ${styles.rangeEdgeMarkerEnd}`}></span>
{formatDayPosition(draft.endMs) || copy.pending}
<span className={`${styles.rangeEdgeMarker} ${styles.rangeEdgeMarkerEnd}`}>{copy.endMark}</span>
</span>
</div>
<div className={styles.calendarHeader}>
<IconButton aria-label="上个月" size="small" onClick={() => changeMonth(-1)}>
<IconButton aria-label={copy.previousMonth} size="small" onClick={() => changeMonth(-1)}>
<ChevronLeftOutlined fontSize="small" />
</IconButton>
<span className={styles.calendarTitle}>{monthTitle(viewDate)}</span>
<IconButton aria-label="下个月" size="small" onClick={() => changeMonth(1)}>
<span className={styles.calendarTitle}>{monthTitle(viewDate, locale)}</span>
<IconButton aria-label={copy.nextMonth} size="small" onClick={() => changeMonth(1)}>
<ChevronRightOutlined fontSize="small" />
</IconButton>
</div>
@ -245,7 +278,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
{withTime ? (
<div className={styles.timeGrid}>
<TextField
label="时"
label={copy.hour}
select
size="small"
value={String(selectedDate.getHours())}
@ -258,7 +291,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
))}
</TextField>
<TextField
label="分"
label={copy.minute}
select
size="small"
value={String(selectedDate.getMinutes())}
@ -274,9 +307,9 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
) : null}
{error ? <div className={styles.error}>{error}</div> : null}
<div className={styles.actions}>
<Button onClick={clearRange}>清空</Button>
<Button onClick={clearRange}>{copy.clear}</Button>
<Button type="submit" variant="primary">
确定
{copy.apply}
</Button>
</div>
</form>
@ -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 (
<button
className={[styles.rangeField, active ? styles.rangeFieldActive : ""].filter(Boolean).join(" ")}
@ -293,7 +326,7 @@ function RangeFieldButton({ active, formatPart = formatRangePart, label, onClick
onClick={onClick}
>
<span className={styles.rangeFieldLabel}>{label}</span>
<span className={styles.rangeFieldValue}>{formatPart(value) || "不限"}</span>
<span className={styles.rangeFieldValue}>{formatPart(value) || unlimited}</span>
</button>
);
}
@ -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));