From 490e36b2cf86af880a9c525f29592b113b59da73 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 18:05:16 +0800 Subject: [PATCH] feat(external-admin): use database app branding --- external-admin/index.html | 2 +- external-admin/src/api/auth.js | 1 + external-admin/src/api/auth.test.js | 3 +- .../src/components/BannerCreateDialog.jsx | 2 +- .../components/BannerCreateDialog.test.jsx | 1 + .../src/components/GrantDialogs.jsx | 8 ---- .../src/components/GrantDialogs.test.jsx | 17 +++++++ .../src/i18n/ExternalI18nProvider.jsx | 1 - .../src/i18n/ExternalI18nProvider.test.jsx | 10 ++--- external-admin/src/i18n/messages.js | 26 +++++------ external-admin/src/i18n/messages.test.js | 4 ++ .../src/layout/ExternalAdminLayout.jsx | 45 ++++++++++++++++--- .../src/layout/ExternalAdminLayout.test.jsx | 39 +++++++++++++++- external-admin/src/pages/LoginPage.jsx | 3 -- external-admin/src/pages/LoginPage.test.jsx | 3 +- external-admin/src/styles/index.css | 14 ++---- src/shared/ui/AppIdentity.jsx | 11 +++-- src/shared/ui/AppIdentity.test.jsx | 10 +++++ src/shared/ui/AppJumpConfigField.jsx | 3 +- 19 files changed, 147 insertions(+), 56 deletions(-) create mode 100644 external-admin/src/components/GrantDialogs.test.jsx diff --git a/external-admin/index.html b/external-admin/index.html index 60bbbf8..a83fee8 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -4,7 +4,7 @@ - HYApp External Admin + Fami
diff --git a/external-admin/src/api/auth.js b/external-admin/src/api/auth.js index 9371100..f6dcae6 100644 --- a/external-admin/src/api/auth.js +++ b/external-admin/src/api/auth.js @@ -66,6 +66,7 @@ export function normalizeSession(data) { account: textValue(accountRecord.username, accountRecord.account, legacyAccount.username, legacyAccount.account, root.accountName, typeof root.account === "string" ? root.account : "", root.username), appCode: textValue(root.appCode, root.app_code, app.appCode, app.app_code, accountRecord.appCode, accountRecord.app_code, linkedUser.appCode, linkedUser.app_code), appName: textValue(app.name, app.appName, app.app_name, root.appName, root.app_name), + logoUrl: textValue(app.logoUrl, app.logo_url, root.logoUrl, root.logo_url), capabilities: [...new Set(capabilities)], displayName: textValue(linkedUser.displayName, linkedUser.display_name, linkedUser.name, linkedUser.username, accountRecord.username, legacyAccount.username, root.displayName), id: textValue(accountRecord.id, accountRecord.externalAdminUserId, accountRecord.external_admin_user_id, legacyAccount.id, root.accountId, root.account_id), diff --git a/external-admin/src/api/auth.test.js b/external-admin/src/api/auth.test.js index 1c3584e..f732676 100644 --- a/external-admin/src/api/auth.test.js +++ b/external-admin/src/api/auth.test.js @@ -9,13 +9,14 @@ describe("normalizeSession", () => { test("normalizes account objects, fixed app and capabilities without stringifying objects", () => { expect(normalizeSession({ account: { id: "ext-1", username: "fami-manager" }, - app: { appCode: "fami", appName: "Fami" }, + app: { appCode: "fami", appName: "Fami", logoUrl: "https://media.example.com/fami.png" }, capabilities: ["user:list"], passwordChangeRequired: true })).toEqual(expect.objectContaining({ account: "fami-manager", appCode: "fami", appName: "Fami", + logoUrl: "https://media.example.com/fami.png", capabilities: ["user:list"], passwordChangeRequired: true })); diff --git a/external-admin/src/components/BannerCreateDialog.jsx b/external-admin/src/components/BannerCreateDialog.jsx index 486fec1..340ecc2 100644 --- a/external-admin/src/components/BannerCreateDialog.jsx +++ b/external-admin/src/components/BannerCreateDialog.jsx @@ -57,6 +57,7 @@ export function BannerCreateDialog({ loading, onClose, onSubmit, open }) { messages={appJumpMessages(t)} paramValue={form.param} required + showHint={false} typeLabel={t("banners.jumpType")} typeValue={form.bannerType} onChange={({ param, type }) => setForm({ ...form, bannerType: type, param })} @@ -119,7 +120,6 @@ function appJumpMessages(t) { 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}`)])), diff --git a/external-admin/src/components/BannerCreateDialog.test.jsx b/external-admin/src/components/BannerCreateDialog.test.jsx index bafad3e..f07ecc1 100644 --- a/external-admin/src/components/BannerCreateDialog.test.jsx +++ b/external-admin/src/components/BannerCreateDialog.test.jsx @@ -16,5 +16,6 @@ describe("BannerCreateDialog", () => { expect(screen.getByRole("combobox", { name: "App destination" })).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "Parameter (App navigation JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" })); + expect(screen.queryByText("App navigation parameters are generated as JSON. You can edit the JSON directly for custom parameters.")).not.toBeInTheDocument(); }); }); diff --git a/external-admin/src/components/GrantDialogs.jsx b/external-admin/src/components/GrantDialogs.jsx index 6fce0f1..7f986ee 100644 --- a/external-admin/src/components/GrantDialogs.jsx +++ b/external-admin/src/components/GrantDialogs.jsx @@ -1,4 +1,3 @@ -import Alert from "@mui/material/Alert"; import Button from "@mui/material/Button"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; @@ -16,7 +15,6 @@ export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resource useEffect(() => setForm(resourceGrantInitial), [open]); return ( onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> setForm({ ...form, resourceId: event.target.value })}> {resources.map((item) => {item.name} ({item.id}))} @@ -34,7 +32,6 @@ export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { useEffect(() => setForm(prettyGrantInitial), [open]); return ( onSubmit(form)}> - setForm({ ...form, target: event.target.value.trim() })} /> setForm({ ...form, prettyId: event.target.value.trim() })} /> setForm({ ...form, durationDays: event.target.value })} /> @@ -43,11 +40,6 @@ export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) { ); } -function TargetNotice() { - const { t } = useExternalI18n(); - return {t("grants.notice")}; -} - function GrantDialog({ children, loading, onClose, onSubmit, open, title }) { const { t } = useExternalI18n(); return ( diff --git a/external-admin/src/components/GrantDialogs.test.jsx b/external-admin/src/components/GrantDialogs.test.jsx new file mode 100644 index 0000000..3c72d63 --- /dev/null +++ b/external-admin/src/components/GrantDialogs.test.jsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; +import { ResourceGrantDialog } from "./GrantDialogs.jsx"; + +test("shows only actionable grant fields without a descriptive notice", () => { + render( + + + + ); + + expect(screen.getByRole("dialog", { name: "Grant privilege item" })).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText("Before submission, the user is verified in the current App and the server-returned user ID is used for the grant.")).not.toBeInTheDocument(); + expect(screen.getByText("Enter 0 for permanent validity")).toBeInTheDocument(); +}); diff --git a/external-admin/src/i18n/ExternalI18nProvider.jsx b/external-admin/src/i18n/ExternalI18nProvider.jsx index b5fe1ee..3c244c4 100644 --- a/external-admin/src/i18n/ExternalI18nProvider.jsx +++ b/external-admin/src/i18n/ExternalI18nProvider.jsx @@ -41,7 +41,6 @@ export function ExternalI18nProvider({ children }) { root.lang = locale; root.dir = direction; globalThis.document.body?.setAttribute("dir", direction); - globalThis.document.title = t("app.title"); }, [direction, locale, t]); const value = useMemo( diff --git a/external-admin/src/i18n/ExternalI18nProvider.test.jsx b/external-admin/src/i18n/ExternalI18nProvider.test.jsx index abcb047..86af77f 100644 --- a/external-admin/src/i18n/ExternalI18nProvider.test.jsx +++ b/external-admin/src/i18n/ExternalI18nProvider.test.jsx @@ -16,7 +16,7 @@ describe("ExternalI18nProvider", () => { render(); expect(screen.getByTestId("locale")).toHaveTextContent("en"); - expect(screen.getByTestId("title")).toHaveTextContent("HYApp External Admin"); + expect(screen.getByTestId("title")).toHaveTextContent("Fami"); expect(document.documentElement).toHaveAttribute("lang", "en"); expect(document.documentElement).toHaveAttribute("dir", "ltr"); }); @@ -26,25 +26,25 @@ describe("ExternalI18nProvider", () => { const firstRender = render(); await user.click(screen.getByRole("button", { name: "ar" })); - expect(screen.getByTestId("title")).toHaveTextContent("إدارة HYApp الخارجية"); + expect(screen.getByTestId("title")).toHaveTextContent("Fami"); 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(screen.getByTestId("title")).toHaveTextContent("Fami"); 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(screen.getByTestId("title")).toHaveTextContent("Fami"); expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("zh-CN"); firstRender.unmount(); render(); expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"); - expect(screen.getByTestId("title")).toHaveTextContent("HYApp 外管后台"); + expect(screen.getByTestId("title")).toHaveTextContent("Fami"); }); }); diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js index c442286..cb399f3 100644 --- a/external-admin/src/i18n/messages.js +++ b/external-admin/src/i18n/messages.js @@ -10,8 +10,8 @@ export const EXTERNAL_LANGUAGE_OPTIONS = Object.freeze([ ]); const en = { - "app.title": "HYApp External Admin", - "app.shortTitle": "HYApp External Admin", + "app.title": "Fami", + "app.shortTitle": "Fami", "language.label": "Language", "common.actions": "Actions", "common.active": "Active", @@ -191,7 +191,6 @@ const en = { "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", @@ -257,7 +256,6 @@ const en = { "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", @@ -323,8 +321,8 @@ const en = { }; const zhCN = { - "app.title": "HYApp 外管后台", - "app.shortTitle": "HYApp 外管", + "app.title": "Fami", + "app.shortTitle": "Fami", "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": "显示密码", @@ -335,19 +333,19 @@ const zhCN = { "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": "确认发放", + "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.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。", + "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": "红包编号", "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": "اللغة", + "app.title": "Fami", "app.shortTitle": "Fami", "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": "فريقي", @@ -357,19 +355,19 @@ const ar = { "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": "تأكيد المنح", + "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.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، ويمكنك تعديلها مباشرة للمعاملات المخصصة.", + "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": "رقم الظرف الأحمر", "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", + "app.title": "Fami", "app.shortTitle": "Fami", "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", @@ -379,12 +377,12 @@ const tr = { "bans.title": "Yasaklı hesap listesi", "bans.reason": "Yasak nedeni", "bans.unban": "Yasağı kaldır", "bans.unbanned": "Kullanıcı yasağı kaldırıldı", "bans.unbanFailed": "Kullanıcı yasağı kaldırılamadı", "bans.confirmTitle": "Kullanıcı yasağını kaldır", "bans.confirmContent": "{user} kullanıcısının yasağı kaldırılsın mı?", "organization.title": "Organizasyon", "organization.agencies": "Ajans listesi", "organization.bds": "BD listesi", "organization.bdManagers": "BD Yöneticisi listesi", "organization.hosts": "Yayıncı listesi", "organization.managers": "Yönetici listesi", "organization.superAdmins": "Süper Yönetici listesi", "organization.team": "Ekibim", "organization.agency": "Ajans", "organization.owner": "Sahip", "organization.parentBd": "Üst BD", "organization.member": "Üye", "organization.country": "Ülke / bölge", "organization.userReference": "Kullanıcı {id}", "organization.agencyReference": "Ajans {id}", "organization.teamSearch": "Ajans ID / sahip kullanıcı ID / üst BD ID", "organization.noPermission": "Bu hesabın bu organizasyon listesine erişimi yok.", "organization.bdLeaderCount": "BD Liderleri: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "Ajanslar: {count}", "organization.truncated": "Ekip API sınırını aşıyor. Mevcut sonuçlar eksik.", "rooms.title": "Oda yönetimi", "rooms.room": "Oda", "rooms.owner": "Sahip", "rooms.visibleRegion": "Görünür bölge", "rooms.search": "Oda ID / ad / sahip", "rooms.updated": "Oda bilgileri güncellendi", "rooms.updateFailed": "Oda güncellenemedi", "rooms.editTitle": "Odayı düzenle", "rooms.background": "Oda arka planı", "rooms.name": "Oda adı", "rooms.description": "Oda açıklaması", "rooms.visibleRegionId": "Görünür bölge ID", "rooms.allRegions": "Tümü", - "grants.title": "Kaynaklar ve özel ID'ler", "grants.grantResource": "Öğe ver", "grants.grantPrettyId": "Özel ID ver", "grants.availableResources": "Kullanılabilir kaynaklar", "grants.targetUser": "Hedef kullanıcı", "grants.resource": "Kaynak", "grants.quantity": "Miktar", "grants.resourceGranted": "Ayrıcalık öğesi verildi", "grants.prettyIdGranted": "Özel ID verildi", "grants.failed": "Verme işlemi başarısız", "grants.resourceTitle": "Ayrıcalık öğesi ver", "grants.prettyIdTitle": "Özel ID ver", "grants.target": "Kullanıcı ID / özel ID", "grants.currentTarget": "Kullanıcı ID / mevcut özel ID", "grants.prettyId": "Verilecek özel ID", "grants.validDays": "Geçerlilik (gün)", "grants.permanentHint": "Kalıcı geçerlilik için 0 girin", "grants.reason": "Verme nedeni", "grants.notice": "Göndermeden önce kullanıcı geçerli uygulamada doğrulanır ve sunucunun döndürdüğü kullanıcı ID kullanılır.", "grants.confirm": "Vermeyi onayla", + "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.confirm": "Vermeyi onayla", "banners.title": "Banner yönetimi", "banners.banner": "Banner", "banners.type": "Tür", "banners.platform": "Platform", "banners.sort": "Sıralama", "banners.create": "Banner oluştur", "banners.created": "Banner oluşturuldu", "banners.createFailed": "Banner oluşturulamadı", "banners.image": "Banner görseli", "banners.roomImage": "Oda içi küçük görsel", "banners.description": "Açıklama", "banners.jumpType": "Hedef türü", "banners.h5Param": "Parametre (H5 bağlantısı)", "banners.appParam": "Parametre (uygulama yönlendirme JSON'u)", "banners.displayScope": "Gösterim kapsamı", "banners.scopeHome": "Ana sayfa", "banners.scopeRoom": "Oda", "banners.scopeRecharge": "Yükleme", "banners.scopeMe": "Ben", "banners.regionId": "Bölge ID", "banners.countryCode": "Ülke kodu", "banners.countryCodeHint": "2–3 harf; tümü için boş bırakın", "banners.schedule": "Gösterim zamanı", "upload.preview": "{label} önizlemesi", "upload.empty": "Görsel yok", "upload.uploading": "Yükleniyor", "upload.replace": "Görseli değiştir", "upload.select": "Görsel yükle", "upload.imageOnly": "Bir görsel dosyası seçin", "upload.sizeLimit": "Görseller 10 MB'ı aşamaz", "upload.missingUrl": "Yükleme yanıtında görsel URL'si yok", "upload.failed": "Görsel yüklenemedi", "pagination.total": "{count} öğe", "pagination.previous": "Önceki", "pagination.next": "Sonraki", "status.active": "Aktif", "status.enabled": "Etkin", "status.running": "Çalışıyor", "status.normal": "Normal", "status.unbanned": "Yasaksız", "status.banned": "Yasaklı", "status.disabled": "Devre dışı", "status.failed": "Başarısız", "status.blocked": "Engelli", "status.expired": "Süresi dolmuş", - "jump.appTarget": "Uygulama hedefi", "jump.target.wallet": "Cüzdan", "jump.target.room": "Belirli oda", "jump.target.room_random": "Rastgele oda", "jump.target.room_window": "Belirli odada pencere", "jump.target.room_random_window": "Rastgele odada pencere", "jump.target.room_game": "Belirli odada oyun", "jump.target.room_random_game": "Rastgele odada oyun", "jump.target.explore_game": "Explore oyunu H5", "jump.target.custom": "Özel JSON", "jump.roomId": "Oda ID", "jump.window": "Oda içi pencere", "jump.window.game_window": "Oyun penceresi", "jump.window.gift_panel": "Hediye paneli", "jump.window.red_packet_claim": "Kırmızı paket penceresi", "jump.window.wheel": "Çark penceresi", "jump.gameId": "Oyun ID", "jump.giftId": "Hediye ID", "jump.redPacketNo": "Kırmızı paket numarası", "jump.hint": "Uygulama yönlendirme parametreleri JSON olarak oluşturulur; özel parametreler için JSON'u doğrudan düzenleyebilirsiniz.", + "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ı", "time.unlimited": "Sınırsız", "time.quickHour": "Son 1 saat", "time.quickDay": "Son 24 saat", "time.quickWeek": "Son 7 gün", "time.start": "Başlangıç zamanı", "time.end": "Bitiş zamanı", "time.startDate": "Başlangıç tarihi", "time.endDate": "Bitiş tarihi", "time.pending": "Seçilmedi", "time.startMark": "B", "time.endMark": "S", "time.previousMonth": "Önceki ay", "time.nextMonth": "Sonraki ay", "time.hour": "Saat", "time.minute": "Dakika", "time.clear": "Temizle", "time.apply": "Uygula", "time.endAfterStart": "Bitiş zamanı başlangıçtan sonra olmalıdır", "time.endDateAfterStart": "Bitiş tarihi başlangıçtan önce olamaz", "time.completeRange": "Tam aralığı seçin", "time.endLaterShort": "Bitiş başlangıçtan sonra olmalı", "time.endDateLaterShort": "Bitiş başlangıçtan önce olamaz", "time.durationDays": "{count} gün", "time.durationHours": "{count} saat", "time.durationMinutes": "{count} dakika", "time.duration": "{value}", "time.sameDay": "Aynı gün", "time.lessMinute": "1 dakikadan az", "errors.loadFailed": "Veriler yüklenemedi", "errors.requestFailed": "İstek başarısız", "errors.unauthorized": "Oturumunuz sona erdi. Tekrar giriş yapın.", "errors.forbidden": "Bu işlemi yapma yetkiniz yok.", "errors.notFound": "İstenen kaynak bulunamadı.", "errors.conflict": "İşlem mevcut verilerle çakışıyor. Yenileyip tekrar deneyin.", "errors.tooLarge": "İstek çok büyük.", "errors.rateLimited": "Çok fazla istek gönderildi. Daha sonra tekrar deneyin.", "errors.server": "Hizmet geçici olarak kullanılamıyor. Daha sonra tekrar deneyin.", "errors.accountLocked": "Hesap geçici olarak kilitlendi. Daha sonra tekrar deneyin.", "errors.appInactive": "Bu hesaba bağlı uygulama etkin değil.", "errors.vipDisabled": "Geçerli uygulamanın VIP programı devre dışı", "errors.vipLevelInvalid": "Geçerli uygulama için yapılandırılmış bir VIP seviyesi seçin", "errors.organizationRoute": "Bu organizasyon listesi için güvenli veri uç noktası yok", "errors.regionId": "Görünür bölge ID güvenli ve negatif olmayan bir tam sayı olmalıdır", "errors.grantTarget": "Geçerli uygulamada hedef kullanıcı bulunamadı", "errors.bannerType": "Geçersiz banner türü", "errors.bannerPlatform": "Geçersiz banner platformu", "errors.bannerStatus": "Geçersiz banner durumu", "errors.bannerScope": "En az bir gösterim kapsamı seçin", "errors.bannerImage": "Bir banner görseli yükleyin", "errors.h5Url": "Geçerli bir H5 bağlantısı girin", "errors.h5Protocol": "H5 bağlantıları http veya https kullanmalıdır", "errors.appParamRequired": "Uygulama yönlendirme parametrelerini girin", "errors.appParamObject": "Uygulama parametreleri bir JSON nesnesi olmalıdır", "errors.appParamType": "Uygulama yönlendirme türü desteklenmiyor", "errors.appParamRoom": "Bu hedef room_id gerektirir", "errors.appParamGame": "Bu hedef game_id gerektirir", "errors.appParamWindow": "Bu hedef geçerli bir window gerektirir", "errors.appParamGameCode": "Explore oyunu H5 geçerli bir game_code gerektirir", "errors.roomBannerImage": "Oda kapsamı için oda içi küçük görsel yükleyin", "errors.countryCode": "Ülke kodu 2–3 harften oluşmalıdır", "errors.bannerRegion": "Geçersiz bölge ID", "errors.bannerSchedule": "Gösterim bitiş zamanı başlangıçtan sonra olmalıdır" }; diff --git a/external-admin/src/i18n/messages.test.js b/external-admin/src/i18n/messages.test.js index 7fa4dbb..08ee1cf 100644 --- a/external-admin/src/i18n/messages.test.js +++ b/external-admin/src/i18n/messages.test.js @@ -13,6 +13,10 @@ describe("external admin translations", () => { test("uses native language names and Arabic RTL only", () => { expect(EXTERNAL_LANGUAGE_OPTIONS.map((item) => item.label)).toEqual(["English", "简体中文", "العربية", "Türkçe"]); + for (const locale of ["en", "zh-CN", "ar", "tr"]) { + expect(externalMessage(locale, "app.title")).toBe("Fami"); + expect(externalMessage(locale, "app.shortTitle")).toBe("Fami"); + } expect(externalDirection("ar")).toBe("rtl"); expect(externalDirection("en")).toBe("ltr"); expect(externalDirection("zh-CN")).toBe("ltr"); diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx index 3ff4361..5354b28 100644 --- a/external-admin/src/layout/ExternalAdminLayout.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -27,8 +27,9 @@ import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; +import { AppIdentity } from "@/shared/ui/AppIdentity.jsx"; import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx"; import { hasAnyExternalCapability } from "../config/capabilities.js"; import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; @@ -64,6 +65,19 @@ export function ExternalAdminLayout() { ); const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path)); + useEffect(() => { + // Login resolves the account's App server-side. Both the visible brand and browser metadata must use that apps row rather than a bundled image or client-selected tenant. + const appName = String(session?.appName || session?.appCode || t("app.title")).trim(); + const logoUrl = String(session?.logoUrl || "").trim(); + globalThis.document.title = appName; + syncBrandFavicon(logoUrl); + return () => { + // Leaving an authenticated App session must not leak its brand into the next account's login screen. + globalThis.document.title = t("app.title"); + syncBrandFavicon(""); + }; + }, [session?.appCode, session?.appName, session?.logoUrl, t]); + const handleLogout = async () => { await logout(); navigate("/login", { replace: true }); @@ -72,11 +86,14 @@ export function ExternalAdminLayout() { const drawer = ( - HY - - {t("app.shortTitle")} - {session?.appName || session?.appCode} - + @@ -140,3 +157,19 @@ export function ExternalAdminLayout() { ); } + +function syncBrandFavicon(logoUrl) { + const selector = 'link[data-external-app-favicon="true"]'; + const current = globalThis.document?.head?.querySelector(selector); + if (!logoUrl) { + current?.remove(); + return; + } + const link = current || globalThis.document.createElement("link"); + link.dataset.externalAppFavicon = "true"; + link.rel = "icon"; + link.href = logoUrl; + if (!current) { + globalThis.document.head.append(link); + } +} diff --git a/external-admin/src/layout/ExternalAdminLayout.test.jsx b/external-admin/src/layout/ExternalAdminLayout.test.jsx index 525e67b..6f158b9 100644 --- a/external-admin/src/layout/ExternalAdminLayout.test.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.test.jsx @@ -12,6 +12,7 @@ const auth = vi.hoisted(() => ({ account: "fami-manager", appCode: "fami", appName: "Fami", + logoUrl: "https://media.example.com/apps/fami.png", capabilities: ["user:list", "room:list"] } })); @@ -24,6 +25,13 @@ describe("ExternalAdminLayout RTL", () => { beforeEach(() => { localStorage.setItem(EXTERNAL_LOCALE_STORAGE_KEY, "ar"); viewport.mobile = false; + auth.session = { + account: "fami-manager", + appCode: "fami", + appName: "Fami", + logoUrl: "https://media.example.com/apps/fami.png", + capabilities: ["user:list", "room:list"] + }; }); test("opens the mobile navigation from the right in Arabic", async () => { @@ -40,7 +48,7 @@ describe("ExternalAdminLayout RTL", () => { }); test("places the desktop navigation drawer on the right for Arabic", () => { - renderLayout(); + const view = renderLayout(); expect(document.documentElement).toHaveAttribute("dir", "rtl"); expect(document.documentElement).toHaveAttribute("lang", "ar"); @@ -52,8 +60,37 @@ describe("ExternalAdminLayout RTL", () => { .find((cssText) => cssText.includes(`.${generatedClass}`)); expect(drawerRule).toContain("right: 0px"); expect(drawerRule).toContain("border-left:"); + expect(document.querySelector(".external-sidebar-brand img")).toHaveAttribute("src", "https://media.example.com/apps/fami.png"); + expect(screen.getAllByText("Fami").length).toBeGreaterThan(0); + expect(screen.queryByText("إدارة HYApp الخارجية")).not.toBeInTheDocument(); + expect(document.title).toBe("Fami"); + expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/fami.png"); expect(screen.getAllByText("ملخص الصلاحيات").length).toBeGreaterThan(0); expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية"); + + view.unmount(); + expect(document.title).toBe("Fami"); + expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument(); + }); + + test("uses the App name, logo and title resolved by the authenticated session", () => { + auth.session = { + ...auth.session, + account: "lalu-manager", + appCode: "lalu", + appName: "Lalu", + logoUrl: "https://media.example.com/apps/lalu.png" + }; + const view = renderLayout(); + + expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0); + expect(document.querySelector(".external-sidebar-brand img")).toHaveAttribute("src", "https://media.example.com/apps/lalu.png"); + expect(document.title).toBe("Lalu"); + expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/lalu.png"); + + view.unmount(); + expect(document.title).toBe("Fami"); + expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument(); }); }); diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index b58ff84..c9a2747 100644 --- a/external-admin/src/pages/LoginPage.jsx +++ b/external-admin/src/pages/LoginPage.jsx @@ -1,8 +1,6 @@ -import LockOutlined from "@mui/icons-material/LockOutlined"; import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined"; import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; import Alert from "@mui/material/Alert"; -import Avatar from "@mui/material/Avatar"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; @@ -62,7 +60,6 @@ export default function LoginPage() { - {t("app.title")} diff --git a/external-admin/src/pages/LoginPage.test.jsx b/external-admin/src/pages/LoginPage.test.jsx index bac99f1..eb4ed36 100644 --- a/external-admin/src/pages/LoginPage.test.jsx +++ b/external-admin/src/pages/LoginPage.test.jsx @@ -20,7 +20,8 @@ describe("LoginPage", () => { const user = userEvent.setup(); const { container } = renderLogin(); - expect(screen.getByRole("heading", { name: "HYApp External Admin" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Fami" })).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); 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"); diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index df45aae..d0ed2ac 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -35,8 +35,7 @@ body { min-width: 140px !important; } -.external-login-avatar, -.external-logo-mark { +.external-login-avatar { background: var(--brand-gradient) !important; color: #fff; } @@ -89,14 +88,9 @@ body { padding: 12px 20px; } -.external-logo-mark { - width: 38px; - height: 38px; - display: grid; - place-items: center; - border-radius: 10px; - font-weight: 800; - box-shadow: var(--brand-shadow); +.external-sidebar-brand { + font-size: 16px; + font-weight: 750; } .external-nav { diff --git a/src/shared/ui/AppIdentity.jsx b/src/shared/ui/AppIdentity.jsx index 475f98a..702622e 100644 --- a/src/shared/ui/AppIdentity.jsx +++ b/src/shared/ui/AppIdentity.jsx @@ -2,7 +2,7 @@ import { useState } from "react"; import styles from "./AppIdentity.module.css"; // AppIdentity 是后台中 App 的唯一展示形式:业务页只传目录数据,避免各个下拉、表格和弹窗分别维护 Logo 规则。 -export function AppIdentity({ app, appCode, appName, className = "", logoUrl, size = "medium" }) { +export function AppIdentity({ app, appCode, appName, className = "", logoUrl, showLogoFallback = true, size = "medium" }) { // 主后台使用 camelCase,Social BI 的服务端聚合契约使用 snake_case;组件在展示边界统一兼容,业务视图不复制字段转换。 const resolvedCode = String(app?.appCode || app?.app_code || appCode || "").trim(); const resolvedName = String(app?.appName || app?.app_name || appName || resolvedCode || "-").trim(); @@ -10,13 +10,13 @@ export function AppIdentity({ app, appCode, appName, className = "", logoUrl, si return ( - + {resolvedName} ); } -function AppLogo({ appCode, appName, logoUrl }) { +function AppLogo({ appCode, appName, logoUrl, showFallback }) { const [failedLogoUrl, setFailedLogoUrl] = useState(""); const canRenderImage = Boolean(logoUrl) && failedLogoUrl !== logoUrl; @@ -32,6 +32,11 @@ function AppLogo({ appCode, appName, logoUrl }) { ); } + if (!showFallback) { + // 外管品牌只能展示 apps.logo_url 的真实图片;缺失或加载失败时不伪造一个看似品牌 Logo 的首字母图标。 + return null; + } + // 网络失败或历史数据暂无 Logo 时保留稳定占位,不让选择项高度和表格对齐抖动。 const initial = String(appName || appCode || "?") diff --git a/src/shared/ui/AppIdentity.test.jsx b/src/shared/ui/AppIdentity.test.jsx index 913afce..a4d3c88 100644 --- a/src/shared/ui/AppIdentity.test.jsx +++ b/src/shared/ui/AppIdentity.test.jsx @@ -29,3 +29,13 @@ test("supports the snake case Social BI app contract", () => { expect(screen.getByText("Aslan")).toBeInTheDocument(); expect(container.querySelector("img")).toHaveAttribute("src", "https://media.example.com/aslan.png"); }); + +test("can hide the fallback when a surface must only show a database logo", () => { + const { container } = render( + , + ); + + expect(container.querySelector("img")).toBeNull(); + expect(screen.getByText("Fami")).toBeInTheDocument(); + expect(screen.queryByText("F", { selector: "span" })).not.toBeInTheDocument(); +}); diff --git a/src/shared/ui/AppJumpConfigField.jsx b/src/shared/ui/AppJumpConfigField.jsx index 64a3121..f157b27 100644 --- a/src/shared/ui/AppJumpConfigField.jsx +++ b/src/shared/ui/AppJumpConfigField.jsx @@ -23,6 +23,7 @@ export function AppJumpConfigField({ onChange, paramValue, required = false, + showHint = true, typeLabel = "跳转类型", typeValue, }) { @@ -102,7 +103,7 @@ export function AppJumpConfigField({ )} - {type === "app" ? ( + {type === "app" && showHint ? (

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

) : null}