diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 3328903..b05573a 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -5018,6 +5018,47 @@ "x-permissions": ["resource-grant:revoke"] } }, + "/admin/users/{user_id}/resources/{entitlement_id}/revoke": { + "post": { + "operationId": "revokeAppUserResource", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { "type": "string" } + }, + { + "in": "path", + "name": "entitlement_id", + "required": true, + "schema": { "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["reason"], + "additionalProperties": false, + "properties": { + "reason": { "type": "string", "minLength": 1, "maxLength": 256 } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "resource-grant:revoke", + "x-permissions": ["resource-grant:revoke"] + } + }, "/admin/resource-grants/resource": { "post": { "operationId": "grantResource", @@ -5055,6 +5096,50 @@ }, "post": { "operationId": "upsertResourceShopItems", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items"], + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "durationDays", + "effectiveFromMs", + "effectiveToMs", + "resourceId", + "sortOrder", + "status" + ], + "additionalProperties": false, + "properties": { + "coinPrice": { + "type": "integer", + "minimum": 0, + "description": "售卖规格金币价;新建时省略或传 0 使用资源列表价格,更新已有规格时省略或传 0 保留当前售价" + }, + "durationDays": { "type": "integer", "enum": [1, 3, 7] }, + "effectiveFromMs": { "type": "integer", "minimum": 0 }, + "effectiveToMs": { "type": "integer", "minimum": 0 }, + "resourceId": { "type": "integer", "minimum": 1 }, + "shopItemId": { "type": "integer", "minimum": 1 }, + "sortOrder": { "type": "integer", "minimum": 0 }, + "status": { "type": "string", "enum": ["active", "disabled"] } + } + } + } + } + } + } + } + }, "responses": { "200": { "$ref": "#/components/responses/EmptyResponse" diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index 60a7131..6373b28 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -369,6 +369,111 @@ test("routes databi social page to Social BI and loads real overview rows", asyn expect(screen.getAllByText("$900").length).toBeGreaterThan(0); }); +test("filters operation KPI by person and shows totals with App and region amounts", async () => { + window.history.pushState(null, "", "/databi/social/?view=team&operator=9"); + fetchSocialBiMaster.mockResolvedValue({ + access: { all: true, scopes: [] }, + apps: [ + { app_code: "aslan", app_name: "Aslan", kind: "legacy" }, + { app_code: "yumi", app_name: "Yumi", kind: "legacy" } + ], + operators: [ + { + account: "omar", + name: "Omar", + scopes: [{ app_code: "aslan", region_id: 11 }, { app_code: "yumi", region_id: 20 }], + team: "中东运营部", + team_id: 2, + user_id: 9 + } + ], + permissions: {}, + regions: [ + { app_code: "aslan", region_id: 11, region_name: "中东大区" }, + { app_code: "yumi", region_id: 20, region_name: "南亚大区" } + ] + }); + fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] }); + fetchSocialBiKpi.mockResolvedValue({ + items: [ + { + app_code: "aslan", + app_name: "Aslan", + month_recharge_usd_minor: 90_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 5000, + region_id: 11, + region_name: "中东大区", + team: "中东运营部" + }, + { + app_code: "yumi", + app_name: "Yumi", + month_recharge_usd_minor: 50_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 3000, + region_id: 20, + region_name: "南亚大区", + team: "中东运营部" + } + ], + operator_app_rows: [ + { + app_code: "aslan", + app_name: "Aslan", + month_recharge_usd_minor: 90_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 5000, + region_count: 1, + region_names: "中东大区", + team: "中东运营部" + }, + { + app_code: "yumi", + app_name: "Yumi", + month_recharge_usd_minor: 50_000, + operator_account: "omar", + operator_name: "Omar", + operator_user_id: 9, + range_recharge_usd_minor: 3000, + region_count: 1, + region_names: "南亚大区", + team: "中东运营部" + } + ], + period_month: "2026-06", + summary: { month_recharge_usd_minor: 140_000, operator_count: 1, range_recharge_usd_minor: 8000 } + }); + + render(); + await flushEffects(); + + expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ operatorUserId: 9 })); + expect(screen.getByRole("combobox", { name: "运营人员筛选" })).toHaveValue("Omar"); + expect(window.location.search).toContain("operator=9"); + const summary = screen.getByLabelText("充值金额汇总"); + expect(within(summary).getByText("Omar")).toBeTruthy(); + expect(within(summary).getByText("$80")).toBeTruthy(); + expect(within(summary).getByText("$1,400")).toBeTruthy(); + expect(screen.getByRole("cell", { name: "中东大区" })).toBeTruthy(); + expect(screen.getByRole("cell", { name: "南亚大区" })).toBeTruthy(); + + await act(async () => { + screen.getByRole("radio", { name: "按人员×App" }).click(); + }); + + expect(screen.getByRole("cell", { name: "中东大区" })).toBeTruthy(); + expect(screen.getByRole("cell", { name: "南亚大区" })).toBeTruthy(); + expect(screen.getAllByText("$50").length).toBeGreaterThan(0); + expect(screen.getAllByText("$30").length).toBeGreaterThan(0); +}); + test("renders social BI data requirements table with section filters and CSV export", async () => { window.history.pushState(null, "", "/databi/social/?view=table®ions=lalu:9,lalu:11"); fetchSocialBiMaster.mockResolvedValue({ diff --git a/databi/src/api.test.js b/databi/src/api.test.js index 62d7beb..3f3fffb 100644 --- a/databi/src/api.test.js +++ b/databi/src/api.test.js @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest"; import { fetchFilterOptions, fetchSocialBiFilterOptions, + fetchSocialBiKpi, fetchSocialBiOverview, fetchSocialBiRequirements, fetchStatisticsOverview, @@ -138,6 +139,23 @@ test("loads social BI overview with an explicit multi-region Finance scope", asy expect(requestURL.searchParams.get("region_ids")).toBe("9,11"); }); +test("loads social BI KPI for the selected operation user", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ items: [], summary: {} }))); + + await fetchSocialBiKpi({ + appCodes: ["aslan", "yumi"], + endMs: 20, + operatorUserId: 9, + startMs: 10, + statTz: "Asia/Shanghai" + }); + + const requestURL = new URL(String(fetch.mock.calls[0][0])); + expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/kpi"); + expect(requestURL.searchParams.get("app_codes")).toBe("aslan,yumi"); + expect(requestURL.searchParams.get("operator_user_id")).toBe("9"); +}); + test("omits app scope headers and query params for all databi apps", async () => { vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ updated_at_ms: 1 }))); diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index 04a7de3..1f79363 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -9,6 +9,8 @@ import PublicOutlined from "@mui/icons-material/PublicOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import RepeatOutlined from "@mui/icons-material/RepeatOutlined"; import TableChartOutlined from "@mui/icons-material/TableChartOutlined"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; import { ALL, DATE_PRESETS, @@ -40,6 +42,7 @@ const VIEWS = [ ]; const SocialBiContext = createContext(null); +const EMPTY_OPERATORS = []; export function useSocialBi() { const context = useContext(SocialBiContext); @@ -160,6 +163,7 @@ function TopBar({ data, filters, updateFilter, viewKey }) {
+ {viewKey === "team" ? : null} {rangeLabel(data.range)} {data.isLoading ? : null}
@@ -167,6 +171,82 @@ function TopBar({ data, filters, updateFilter, viewKey }) { ); } +function operatorIdentityLabel(operator) { + if (String(operator?.user_id) === ALL) { + return "全部运营人员"; + } + return operator?.name || operator?.account || `#${operator?.user_id}`; +} + +function filterOperatorOptions(options, { inputValue }) { + const keyword = String(inputValue || "").trim().toLowerCase(); + if (!keyword) { + return options; + } + return options.filter((operator) => + [operatorIdentityLabel(operator), operator.account, operator.team, operator.user_id] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(keyword) + ); +} + +function OperatorSelect({ data, filters, updateFilter }) { + const operators = data.master?.operators || EMPTY_OPERATORS; + const options = useMemo(() => [{ name: "全部运营人员", user_id: ALL }, ...operators], [operators]); + const selected = options.find((operator) => String(operator.user_id) === String(filters.operator)) || options[0]; + + useEffect(() => { + if (!data.master || filters.operator === ALL) { + return; + } + // 人员离职或数据权限收回后,旧分享链接里的 ID 会失效;回退到全部,避免页面显示一个无法解释的空筛选。 + if (!operators.some((operator) => String(operator.user_id) === String(filters.operator))) { + updateFilter("operator", ALL); + } + }, [data.master, filters.operator, operators, updateFilter]); + + return ( + String(option.user_id) === String(value.user_id)} + noOptionsText="无匹配运营人员" + options={options} + size="small" + value={selected} + onChange={(_, operator) => updateFilter("operator", String(operator?.user_id || ALL))} + renderOption={(props, operator) => { + const { key, ...optionProps } = props; + const details = [operator.account, operator.team].filter(Boolean).join(" · "); + return ( +
  • + {operatorIdentityLabel(operator)} + {details ? {details} : null} +
  • + ); + }} + renderInput={(params) => ( + + )} + /> + ); +} + function DatePresetControl({ filters, updateFilter }) { return (
    diff --git a/databi/src/social/state.js b/databi/src/social/state.js index bb41257..efdc835 100644 --- a/databi/src/social/state.js +++ b/databi/src/social/state.js @@ -1,4 +1,4 @@ -// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、趋势粒度, +// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度, // 并同步到 URL query,保证筛选状态可以直接作为链接分享。 import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js"; @@ -27,6 +27,7 @@ export function createDefaultFilters() { customEnd: "", customStart: "", granularity: "day", + operator: ALL, preset: "yesterday", regions: [ALL] }; @@ -116,7 +117,7 @@ export function bucketDay(statDay, granularity) { return statDay; } -const URL_KEYS = ["preset", "start", "end", "apps", "regions", "view", "granularity"]; +const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity"]; export function readStateFromURL(location = window.location) { const params = new URLSearchParams(location.search); @@ -133,6 +134,9 @@ export function readStateFromURL(location = window.location) { if (params.get("regions")) { filters.regions = params.get("regions").split(",").map((item) => item.trim()).filter(Boolean); } + if (/^\d+$/.test(params.get("operator") || "")) { + filters.operator = params.get("operator"); + } if (params.get("granularity") && GRANULARITIES.some((item) => item.key === params.get("granularity"))) { filters.granularity = params.get("granularity"); } @@ -168,6 +172,9 @@ export function writeStateToURL(filters, view, history = window.history, locatio if (filters.regions.length && !filters.regions.includes(ALL)) { params.set("regions", filters.regions.join(",")); } + if (filters.operator !== ALL) { + params.set("operator", filters.operator); + } if (filters.granularity !== "day") { params.set("granularity", filters.granularity); } diff --git a/databi/src/social/useSocialBiData.js b/databi/src/social/useSocialBiData.js index 07d68bb..c188285 100644 --- a/databi/src/social/useSocialBiData.js +++ b/databi/src/social/useSocialBiData.js @@ -69,6 +69,11 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f const selected = filters.apps.filter((appCode) => available.includes(appCode)); return selected.length ? selected : available; }, [filters.apps, master]); + // 运营人员筛选只接受 master 目录中的正整数 ID;URL 中的脏值不能直接进入后端统计查询。 + const operatorUserId = useMemo(() => { + const value = String(filters.operator || ""); + return /^\d+$/.test(value) ? Number(value) : undefined; + }, [filters.operator]); const overviewRequestScopes = useMemo( () => buildOverviewRequestScopes(appCodes, filters.regions, master?.regions || []), [appCodes, filters.regions, master?.regions] @@ -87,7 +92,9 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f ? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null); // KPI 会执行当前区间和自然月两组负责范围聚合,只在运营中心可见时请求,避免其他视图承担额外查询成本。 - const kpiRequest = includeKpi ? fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null); + const kpiRequest = includeKpi + ? fetchSocialBiKpi({ appCodes, endMs, operatorUserId, startMs, statTz: SOCIAL_BI_TZ }) + : Promise.resolve(null); // 默认全量只请求一次;用户显式选择区域时按 App 下发已选区域,让后端只补这些区域的 Finance 逐日渠道。 const overviewRequest = overviewRequestScopes.length ? Promise.all( @@ -144,7 +151,7 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f setErrors(nextErrors); setIsLoading(false); }); - }, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, overviewRequestScopes, range, refreshToken]); + }, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, operatorUserId, overviewRequestScopes, range, refreshToken]); const loadRequirements = useCallback( ({ newUserType, payerType, section, userRole } = {}) => { diff --git a/databi/src/social/views/TeamKpiView.jsx b/databi/src/social/views/TeamKpiView.jsx index 348d81a..fa3edd9 100644 --- a/databi/src/social/views/TeamKpiView.jsx +++ b/databi/src/social/views/TeamKpiView.jsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx"; import { formatCount, formatMoneyMinor, isBlank } from "../format.js"; +import { ALL } from "../state.js"; import "./team-kpi-view.css"; const DIMENSIONS = [ @@ -40,7 +41,7 @@ function operatorCountOf(items) { } export function TeamKpiView() { - const { isLoading, kpi } = useSocialBi(); + const { filters, isLoading, kpi, master } = useSocialBi(); const [dimension, setDimension] = useState("person"); const items = useMemo(() => kpi?.items || [], [kpi]); @@ -88,21 +89,15 @@ export function TeamKpiView() { return rows; }, [items]); - if (!items.length) { - return isLoading ? ( - - ) : ( -
    -
    - 暂无充值数据 - 先在用户管理为运营人员分配 App/区域数据范围 -
    -
    - ); + if (isLoading && !kpi) { + return ; } - const summary = kpi.summary || {}; + const summary = kpi?.summary || {}; const operatorCount = isBlank(summary.operator_count) ? operatorCountOf(items) : summary.operator_count; + const selectedOperator = filters.operator === ALL + ? null + : (master?.operators || []).find((operator) => String(operator.user_id) === String(filters.operator)); return (
    @@ -110,7 +105,7 @@ export function TeamKpiView() {
    充值排行 - {kpi.period_month} · {formatCount(operatorCount)} 名运营 + {kpi?.period_month || "--"} · {formatCount(operatorCount)} 名运营
    @@ -129,6 +124,7 @@ export function TeamKpiView() {
    +
    {dimension === "person" ? ( @@ -143,6 +139,36 @@ export function TeamKpiView() { ); } +function KpiSummary({ operator, operatorCount, summary }) { + const details = operator + ? [operator.account, operator.team].filter(Boolean).join(" · ") + : `${formatCount(operatorCount)} 名运营`; + return ( +
    +
    + {operator ? "当前人员" : "当前范围"} + {operator ? operatorIdentity(operator) : "全部运营人员"} + {details} +
    + + +
    + ); +} + +function operatorIdentity(operator) { + return operator?.name || operator?.account || `#${operator?.user_id}`; +} + +function SummaryAmount({ label, value }) { + return ( +
    + {label} + {formatMoneyMinor(value)} +
    + ); +} + function KpiSkeleton() { return (
    @@ -184,6 +210,7 @@ function OperatorAppTable({ rows }) { + {!rows.length ? : null} {rows.map((row, index) => { const item = row.item; return ( @@ -230,6 +257,7 @@ function PersonTable({ rows }) { + {!rows.length ? : null} {rows.map((row, index) => { const item = row.item; return ( @@ -279,6 +307,7 @@ function TeamTable({ rows }) { + {!rows.length ? : null} {rows.map((row, index) => ( @@ -295,6 +324,14 @@ function TeamTable({ rows }) { ); } +function EmptyTableRow({ colSpan }) { + return ( + + 当前无数据 + + ); +} + function OperatorCell({ item }) { return (
    diff --git a/databi/src/social/views/team-kpi-view.css b/databi/src/social/views/team-kpi-view.css index be703f6..448bb69 100644 --- a/databi/src/social/views/team-kpi-view.css +++ b/databi/src/social/views/team-kpi-view.css @@ -8,10 +8,57 @@ .sbi-kpi-board-scroll { max-height: 620px; - margin-top: 12px; border-radius: 0 0 var(--sbi-radius) var(--sbi-radius); } +.sbi-kpi-summary { + display: grid; + grid-template-columns: minmax(220px, 1fr) repeat(2, minmax(170px, 220px)); + margin-top: 12px; + border-top: 1px solid var(--sbi-border); + border-bottom: 1px solid var(--sbi-border); + background: #f7fafd; +} + +.sbi-kpi-summary-person, +.sbi-kpi-summary-amount { + display: grid; + gap: 3px; + min-width: 0; + padding: 14px 18px; +} + +.sbi-kpi-summary-amount { + align-content: center; + border-left: 1px solid var(--sbi-border); + text-align: right; +} + +.sbi-kpi-summary span, +.sbi-kpi-summary small { + color: var(--sbi-text-3); + font-size: 11.5px; +} + +.sbi-kpi-summary-person strong { + overflow: hidden; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sbi-kpi-summary-amount strong { + color: var(--sbi-accent); + font-size: 22px; + font-variant-numeric: tabular-nums; +} + +.sbi-table td.sbi-kpi-empty-cell { + height: 120px; + color: var(--sbi-text-3); + text-align: center; +} + .sbi-kpi-rank { display: inline-grid; width: 24px; @@ -67,3 +114,9 @@ gap: 14px; padding: 18px; } + +@media (max-width: 1280px) { + .sbi-kpi-summary { + grid-template-columns: minmax(180px, 1fr) repeat(2, minmax(150px, 190px)); + } +} diff --git a/databi/src/styles/social-v2.css b/databi/src/styles/social-v2.css index ff19fdc..05219de 100644 --- a/databi/src/styles/social-v2.css +++ b/databi/src/styles/social-v2.css @@ -344,6 +344,25 @@ font-size: 12.5px; } +.sbi-operator-filter { + width: 210px; +} + +.sbi-operator-option { + display: grid !important; + gap: 2px; +} + +.sbi-operator-option strong { + color: var(--sbi-text); + font-size: 13px; +} + +.sbi-operator-option small { + color: var(--sbi-text-3); + font-size: 11.5px; +} + .sbi-range-label { color: var(--sbi-text-3); font-size: 12.5px; diff --git a/external-admin/index.html b/external-admin/index.html index 60bbbf8..d5b05c8 100644 --- a/external-admin/index.html +++ b/external-admin/index.html @@ -3,8 +3,9 @@ - - HYApp External Admin + + Admin System +
    diff --git a/external-admin/src/i18n/ExternalI18nProvider.test.jsx b/external-admin/src/i18n/ExternalI18nProvider.test.jsx index abcb047..562e9df 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("Admin System"); 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("نظام الإدارة"); 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("Yönetim Sistemi"); 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("管理系统"); 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("管理系统"); }); }); diff --git a/external-admin/src/i18n/emotionCache.js b/external-admin/src/i18n/emotionCache.js index 7f86a56..cb5ada7 100644 --- a/external-admin/src/i18n/emotionCache.js +++ b/external-admin/src/i18n/emotionCache.js @@ -1,9 +1,13 @@ import createCache from "@emotion/cache"; import rtlPlugin from "@mui/stylis-plugin-rtl"; -import { prefixer } from "stylis"; +// Emotion compiles styles with its own bundled stylis (4.2); running the standalone +// stylis 4.4 `prefixer` over those foreign AST nodes crashes in lift() on +// ::placeholder/:read-only rules because 4.2 elements carry no `siblings` field. +// The RTL cache therefore only runs the direction-flipping plugin; vendor prefixing +// is left out, which modern mobile browsers no longer need. const externalLtrCache = createCache({ key: "external-ltr", prepend: true }); -const externalRtlCache = createCache({ key: "external-rtl", prepend: true, stylisPlugins: [prefixer, rtlPlugin] }); +const externalRtlCache = createCache({ key: "external-rtl", prepend: true, stylisPlugins: [rtlPlugin] }); export function externalEmotionCache(direction) { return direction === "rtl" ? externalRtlCache : externalLtrCache; diff --git a/external-admin/src/i18n/emotionCache.test.js b/external-admin/src/i18n/emotionCache.test.js index 62592e8..e861ab8 100644 --- a/external-admin/src/i18n/emotionCache.test.js +++ b/external-admin/src/i18n/emotionCache.test.js @@ -1,5 +1,9 @@ +import { CacheProvider } from "@emotion/react"; +import styled from "@emotion/styled"; import rtlPlugin from "@mui/stylis-plugin-rtl"; -import { compile, middleware, prefixer, serialize, stringify } from "stylis"; +import { render } from "@testing-library/react"; +import { createElement } from "react"; +import { compile, middleware, serialize, stringify } from "stylis"; import { describe, expect, test } from "vitest"; import { externalEmotionCache } from "./emotionCache.js"; @@ -13,7 +17,7 @@ describe("external admin RTL Emotion cache", () => { 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]) + middleware([rtlPlugin, stringify]) ); expect(css).toContain("margin-right:8px"); @@ -21,4 +25,15 @@ describe("external admin RTL Emotion cache", () => { expect(css).toContain("text-align:right"); expect(css).not.toContain("margin-left:8px"); }); + + test("inserts ::placeholder rules through the RTL cache without crashing", () => { + // Regression: the standalone stylis 4.4 prefixer crashed in lift() when Emotion's + // bundled stylis 4.2 compiled a ::placeholder rule, unmounting the whole app in Arabic. + const Input = styled.input({ "&::placeholder": { color: "red", textAlign: "left" } }); + + expect(() => render( + createElement(CacheProvider, { value: externalEmotionCache("rtl") }, + createElement(Input, { placeholder: "مثال" })) + )).not.toThrow(); + }); }); diff --git a/external-admin/src/i18n/messages.js b/external-admin/src/i18n/messages.js index c442286..e2865b1 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": "Admin System", + "app.shortTitle": "Admin System", "language.label": "Language", "common.actions": "Actions", "common.active": "Active", @@ -35,9 +35,9 @@ const en = { "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.account": "Account", + "auth.changePassword": "Change password", + "auth.checkingSession": "Checking your session", "auth.confirmNewPassword": "Confirm new password", "auth.currentPassword": "Current password", "auth.fillAccountPassword": "Enter your account and password", @@ -48,7 +48,7 @@ const en = { "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.noPermission": "This 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", @@ -56,12 +56,13 @@ const en = { "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.returnOverview": "Back to Home", "auth.savePassword": "Save password", "auth.sessionFailed": "Session verification failed", "auth.showPassword": "Show password", "nav.expand": "Open navigation", - "nav.overview": "Permission overview", + "nav.more": "More", + "nav.overview": "Home", "nav.users": "User management", "nav.bans": "Ban management", "nav.hosts": "Hosts", @@ -94,8 +95,10 @@ const en = { "capability.userLevelGrant": "Grant wealth/VIP levels", "capability.teamView": "My team", "overview.currentApp": "Current App", - "overview.account": "External admin account", + "overview.account": "Account", "overview.permissions": "Permissions enabled", + "overview.welcome": "Welcome back", + "overview.features": "Features", "overview.enabled": "Enabled", "overview.notEnabled": "Not enabled", "users.title": "User management", @@ -323,17 +326,17 @@ const en = { }; const zhCN = { - "app.title": "HYApp 外管后台", - "app.shortTitle": "HYApp 外管", + "app.title": "管理系统", + "app.shortTitle": "管理系统", "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": "我的团队", + "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.more": "更多", "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": "未开通", + "overview.currentApp": "当前 App", "overview.account": "登录账号", "overview.permissions": "已开通权限", "overview.welcome": "欢迎回来", "overview.features": "功能入口", "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": "团队规模超过接口上限,当前结果不完整", + "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": "投放时间", @@ -347,12 +350,12 @@ const zhCN = { const ar = { ...en, - "app.title": "إدارة HYApp الخارجية", "app.shortTitle": "إدارة HYApp الخارجية", "language.label": "اللغة", + "app.title": "نظام الإدارة", "app.shortTitle": "نظام الإدارة", "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": "فريقي", + "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.more": "المزيد", "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": "غير مفعّلة", + "overview.currentApp": "التطبيق الحالي", "overview.account": "الحساب", "overview.permissions": "الصلاحيات المفعّلة", "overview.welcome": "مرحبًا بعودتك", "overview.features": "الخدمات", "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": "يتجاوز حجم الفريق حد الواجهة. النتائج الحالية غير مكتملة.", @@ -369,12 +372,12 @@ const ar = { const tr = { ...en, - "app.title": "HYApp Harici Yönetim", "app.shortTitle": "HYApp Harici Yönetim", "language.label": "Dil", + "app.title": "Yönetim Sistemi", "app.shortTitle": "Yönetim Sistemi", "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", + "auth.account": "Hesap", "auth.changePassword": "Parolayı 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 hesabı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": "Ana sayfaya dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster", + "nav.expand": "Menüyü aç", "nav.more": "Daha fazla", "nav.overview": "Ana sayfa", "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", + "overview.currentApp": "Geçerli uygulama", "overview.account": "Hesap", "overview.permissions": "Etkin yetkiler", "overview.welcome": "Tekrar hoş geldiniz", "overview.features": "Özellikler", "overview.enabled": "Etkin", "overview.notEnabled": "Etkin değil", "users.title": "Kullanıcı yönetimi", "users.search": "Kullanıcı ID / özel ID / ad", "users.prettyId": "Özel ID", "users.countryGender": "Ülke / cinsiyet", "users.level": "Seviye", "users.levelSummary": "Servet {wealth} / Cazibe {charm} / VIP {vip}", "users.edit": "Düzenle", "users.grantLevel": "Seviye", "users.ban": "Yasakla", "users.updated": "Kullanıcı bilgileri güncellendi", "users.banned": "Kullanıcı yasaklandı", "users.levelGranted": "Kullanıcı seviyesi verildi", "users.operationFailed": "Kullanıcı işlemi başarısız", "users.editTitle": "Kullanıcıyı düzenle", "users.avatar": "Avatar", "users.name": "Kullanıcı adı", "users.gender": "Cinsiyet", "users.countryCode": "Ülke/bölge kodu", "users.notSet": "Ayarlanmadı", "users.genderMale": "Erkek", "users.genderFemale": "Kadın", "users.genderUnknown": "Bilinmiyor", "users.banTitle": "{user} kullanıcısını yasakla", "users.banDays": "Yasak süresi (gün)", "users.banForeverHint": "Kalıcı yasak için 0 girin", "users.banReason": "Yasak nedeni", "users.levelTitle": "{user} kullanıcısına seviye ver", "users.levelType": "Seviye türü", "users.wealthLevel": "Servet seviyesi", "users.charmLevel": "Cazibe seviyesi", "users.vipLevel": "VIP seviyesi", "users.validDays": "Geçerlilik (gün)", "users.validDaysMin": "Geçerlilik en az 1 gün olmalıdır", "users.grantReason": "Verme nedeni", "bans.title": "Yasaklı hesap listesi", "bans.reason": "Yasak nedeni", "bans.unban": "Yasağı kaldır", "bans.unbanned": "Kullanıcı yasağı kaldırıldı", "bans.unbanFailed": "Kullanıcı yasağı kaldırılamadı", "bans.confirmTitle": "Kullanıcı yasağını kaldır", "bans.confirmContent": "{user} kullanıcısının yasağı kaldırılsın mı?", "organization.title": "Organizasyon", "organization.agencies": "Ajans listesi", "organization.bds": "BD listesi", "organization.bdManagers": "BD Yöneticisi listesi", "organization.hosts": "Yayıncı listesi", "organization.managers": "Yönetici listesi", "organization.superAdmins": "Süper Yönetici listesi", "organization.team": "Ekibim", "organization.agency": "Ajans", "organization.owner": "Sahip", "organization.parentBd": "Üst BD", "organization.member": "Üye", "organization.country": "Ülke / bölge", "organization.userReference": "Kullanıcı {id}", "organization.agencyReference": "Ajans {id}", "organization.teamSearch": "Ajans ID / sahip kullanıcı ID / üst BD ID", "organization.noPermission": "Bu hesabın bu organizasyon listesine erişimi yok.", "organization.bdLeaderCount": "BD Liderleri: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "Ajanslar: {count}", "organization.truncated": "Ekip API sınırını aşıyor. Mevcut sonuçlar eksik.", diff --git a/external-admin/src/layout/ExternalAdminLayout.jsx b/external-admin/src/layout/ExternalAdminLayout.jsx index 3ff4361..a7fadfe 100644 --- a/external-admin/src/layout/ExternalAdminLayout.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.jsx @@ -1,10 +1,11 @@ import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined"; import BadgeOutlined from "@mui/icons-material/BadgeOutlined"; -import DashboardOutlined from "@mui/icons-material/DashboardOutlined"; +import GridViewOutlined from "@mui/icons-material/GridViewOutlined"; import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; +import HomeOutlined from "@mui/icons-material/HomeOutlined"; import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import KeyOutlined from "@mui/icons-material/KeyOutlined"; import LogoutOutlined from "@mui/icons-material/LogoutOutlined"; -import MenuOutlined from "@mui/icons-material/MenuOutlined"; import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined"; import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined"; @@ -16,14 +17,12 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Divider from "@mui/material/Divider"; import Drawer from "@mui/material/Drawer"; -import IconButton from "@mui/material/IconButton"; import List from "@mui/material/List"; import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import Stack from "@mui/material/Stack"; import Toolbar from "@mui/material/Toolbar"; -import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; @@ -36,7 +35,7 @@ import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx"; const drawerWidth = 248; const navigation = [ - { icon: DashboardOutlined, labelKey: "nav.overview", path: "/overview" }, + { icon: HomeOutlined, 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" }, @@ -50,31 +49,46 @@ const navigation = [ { capabilities: ["team:view"], icon: GroupsOutlined, labelKey: "nav.team", path: "/organization/team" } ]; +const isItemActive = (item, pathname) => pathname === item.path || pathname.startsWith(`${item.path}/`); + export function ExternalAdminLayout() { const theme = useTheme(); const mobile = useMediaQuery(theme.breakpoints.down("md")); - const [drawerOpen, setDrawerOpen] = useState(false); + const [sheetOpen, setSheetOpen] = useState(false); const { logout, session } = useExternalAuth(); - const { direction, t } = useExternalI18n(); + const { t } = useExternalI18n(); const location = useLocation(); const navigate = useNavigate(); const visibleNavigation = useMemo( () => navigation.filter((item) => !item.capabilities || hasAnyExternalCapability(session, item.capabilities)), [session] ); - const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path)); + const current = [...visibleNavigation].reverse().find((item) => isItemActive(item, location.pathname)); + // The H5 tab bar keeps three primary destinations reachable with a thumb; everything else lives in the "More" sheet. + const tabs = visibleNavigation.slice(0, 3); + const moreActive = !tabs.some((item) => isItemActive(item, location.pathname)); const handleLogout = async () => { + setSheetOpen(false); await logout(); navigate("/login", { replace: true }); }; - const drawer = ( + const accountCard = ( + <> + {String(session?.account || "A").slice(0, 1).toUpperCase()} + + {session?.account} + {session?.appName || session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} + + + ); + + const sidebar = ( - HY - {t("app.shortTitle")} + {t("app.shortTitle")} {session?.appName || session?.appCode} @@ -86,9 +100,8 @@ export function ExternalAdminLayout() { setDrawerOpen(false)} > @@ -96,47 +109,88 @@ export function ExternalAdminLayout() { ); })} + + {accountCard} + + + ); return ( + {/* Physical margins/anchors are written for LTR; the RTL Emotion cache flips them for Arabic. */} - {mobile ? setDrawerOpen(true)}> : null} - {current ? t(current.labelKey) : t("app.title")} + {mobile && !current ? t("app.shortTitle") : t(current?.labelKey || "app.shortTitle")} - + - {String(session?.account || "A").slice(0, 1).toUpperCase()} - {!mobile ? ( - - {session?.account} - {session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""} - - ) : null} - - - + {!mobile ? accountCard : null} - setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer} - {drawer} - - + {sidebar} + + + {/* Shown below the md breakpoint via index.css; sx display rules would lose to the stylesheet cascade. */} + + {tabs.map((item) => { + const Icon = item.icon; + return ( + + + {t(item.labelKey)} + + ); + })} + + + setSheetOpen(false)} + > + + {accountCard} + + {visibleNavigation.map((item) => { + const Icon = item.icon; + return ( + setSheetOpen(false)} + > + + {t(item.labelKey)} + + ); + })} + + + + + + + ); } diff --git a/external-admin/src/layout/ExternalAdminLayout.test.jsx b/external-admin/src/layout/ExternalAdminLayout.test.jsx index 525e67b..09f7da1 100644 --- a/external-admin/src/layout/ExternalAdminLayout.test.jsx +++ b/external-admin/src/layout/ExternalAdminLayout.test.jsx @@ -1,7 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { CacheProvider } from "@emotion/react"; +import { render, screen, within } 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 { externalEmotionCache } from "../i18n/emotionCache.js"; import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx"; import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js"; import { ExternalAdminLayout } from "./ExternalAdminLayout.jsx"; @@ -26,17 +28,22 @@ describe("ExternalAdminLayout RTL", () => { viewport.mobile = false; }); - test("opens the mobile navigation from the right in Arabic", async () => { + test("shows the H5 tab bar and opens the more sheet in Arabic", async () => { const user = userEvent.setup(); viewport.mobile = true; renderLayout(); - await user.click(screen.getByRole("button", { name: "فتح القائمة" })); + const tabbar = document.querySelector(".external-tabbar"); + expect(tabbar).toBeInTheDocument(); + expect(within(tabbar).getByRole("link", { name: "الرئيسية" })).toBeInTheDocument(); - 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); + await user.click(within(tabbar).getByRole("button", { name: "المزيد" })); + + const sheet = document.querySelector(".MuiDrawer-paper.external-sheet"); + expect(sheet).toBeInTheDocument(); + expect(sheet.closest(".MuiModal-root")).not.toBeNull(); + expect(within(sheet).getByRole("link", { name: "الرئيسية" })).toBeInTheDocument(); + expect(within(sheet).getByRole("button", { name: "تسجيل الخروج" })).toBeInTheDocument(); }); test("places the desktop navigation drawer on the right for Arabic", () => { @@ -45,28 +52,33 @@ describe("ExternalAdminLayout RTL", () => { 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-")); + // Emotion prefixes generated classes with the cache key, so the RTL cache yields external-rtl-*. + const generatedClass = Array.from(permanentDrawer.classList).find((className) => className.startsWith("external-rtl-") || 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.getAllByText("الرئيسية").length).toBeGreaterThan(0); expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية"); }); }); function renderLayout() { + // The RTL Emotion cache is part of the production setup: physical styles are written + // for LTR and flipped by the cache, so the test must run the same pipeline. return render( - - - - }> - overview content
    } path="/overview" /> - - - - + + + + + }> + overview content
    } path="/overview" /> + + + + + ); } diff --git a/external-admin/src/main.jsx b/external-admin/src/main.jsx index 7cad114..2ee3265 100644 --- a/external-admin/src/main.jsx +++ b/external-admin/src/main.jsx @@ -1,12 +1,12 @@ import { CacheProvider } from "@emotion/react"; import CssBaseline from "@mui/material/CssBaseline"; -import { createTheme, ThemeProvider } from "@mui/material/styles"; +import { 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 { createExternalTheme } from "./theme.js"; import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx"; import { ExternalI18nProvider, useExternalI18n } from "./i18n/ExternalI18nProvider.jsx"; import { externalEmotionCache } from "./i18n/emotionCache.js"; @@ -25,10 +25,7 @@ createRoot(document.getElementById("external-admin-root")).render( function ExternalThemeRoot() { const { direction } = useExternalI18n(); // A direction-aware MUI theme keeps inputs, menus and dialog controls consistent with the Arabic document direction. - const externalTheme = useMemo(() => createTheme(theme, { - direction, - typography: { fontFamily: 'Inter, "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif' } - }), [direction]); + const externalTheme = useMemo(() => createExternalTheme(direction), [direction]); return ( diff --git a/external-admin/src/pages/BannersPage.jsx b/external-admin/src/pages/BannersPage.jsx index 29ec076..567aefa 100644 --- a/external-admin/src/pages/BannersPage.jsx +++ b/external-admin/src/pages/BannersPage.jsx @@ -21,7 +21,7 @@ export default function BannersPage() { const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]); const { data, error, loading, reload } = usePagedResource(load); const columns = useMemo(() => [ - { key: "banner", label: t("banners.banner"), render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, + { key: "banner", label: t("banners.banner"), render: (item) => {item.coverUrl ? : null}{item.description || "-"}ID {item.id || "-"} }, { key: "type", label: t("banners.type"), render: (item) => item.bannerType || "-" }, { key: "platform", label: t("banners.platform"), render: (item) => item.platform || t("common.all") }, { key: "sort", label: t("banners.sort"), render: (item) => item.sortOrder }, diff --git a/external-admin/src/pages/BansPage.jsx b/external-admin/src/pages/BansPage.jsx index 1efaf84..1d52c88 100644 --- a/external-admin/src/pages/BansPage.jsx +++ b/external-admin/src/pages/BansPage.jsx @@ -43,7 +43,7 @@ export default function BansPage() { }; const columns = useMemo(() => [ - { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, { key: "reason", label: t("bans.reason"), render: (user) => user.reason || "-" }, { key: "status", label: t("common.status"), render: () => }, diff --git a/external-admin/src/pages/ChangePasswordPage.jsx b/external-admin/src/pages/ChangePasswordPage.jsx index ca8c517..436d82d 100644 --- a/external-admin/src/pages/ChangePasswordPage.jsx +++ b/external-admin/src/pages/ChangePasswordPage.jsx @@ -1,6 +1,4 @@ -import KeyOutlined from "@mui/icons-material/KeyOutlined"; 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"; @@ -59,7 +57,7 @@ export default function ChangePasswordPage() { - {t("auth.changePassword")}{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} + {t("auth.changePassword")}{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account} {error ? {error} : null} setForm({ ...form, oldPassword: event.target.value })} /> diff --git a/external-admin/src/pages/GrantsPage.jsx b/external-admin/src/pages/GrantsPage.jsx index 9f33fbd..4316ffb 100644 --- a/external-admin/src/pages/GrantsPage.jsx +++ b/external-admin/src/pages/GrantsPage.jsx @@ -91,8 +91,8 @@ export default function GrantsPage() { {canGrant ? ( {t("grants.availableResources")} - - {resourcesState.data.items.slice(0, 12).map((resource) => R{resource.name})} + + {resourcesState.data.items.slice(0, 12).map((resource) => R{resource.name})} {!resourcesState.loading && !resourcesState.data.items.length ? {t("common.noData")} : null} diff --git a/external-admin/src/pages/LoginPage.jsx b/external-admin/src/pages/LoginPage.jsx index b58ff84..683e340 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,8 +60,7 @@ export default function LoginPage() { - - {t("app.title")} + {t("app.title")} {error ? {error} : null} diff --git a/external-admin/src/pages/LoginPage.test.jsx b/external-admin/src/pages/LoginPage.test.jsx index bac99f1..d62f9d4 100644 --- a/external-admin/src/pages/LoginPage.test.jsx +++ b/external-admin/src/pages/LoginPage.test.jsx @@ -20,9 +20,9 @@ describe("LoginPage", () => { const user = userEvent.setup(); const { container } = renderLogin(); - expect(screen.getByRole("heading", { name: "HYApp External Admin" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Admin System" })).toBeInTheDocument(); expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument(); - await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager"); + await user.type(screen.getByRole("textbox", { name: "Account" }), "fami-manager"); await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password"); await user.click(screen.getByRole("button", { name: "Sign in" })); @@ -34,7 +34,7 @@ describe("LoginPage", () => { 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(screen.getByRole("textbox", { name: "Account" }), "unknown-account"); await user.type(container.querySelector('input[autocomplete="current-password"]'), "bad-password"); await user.click(screen.getByRole("button", { name: "Sign in" })); diff --git a/external-admin/src/pages/OrganizationPage.jsx b/external-admin/src/pages/OrganizationPage.jsx index 6f03ee5..56a8238 100644 --- a/external-admin/src/pages/OrganizationPage.jsx +++ b/external-admin/src/pages/OrganizationPage.jsx @@ -39,7 +39,7 @@ export default function OrganizationPage() { { key: "parent", label: t("organization.parentBd"), render: (item) => item.parentBdUserId || "-" }, { key: "status", label: t("common.status"), render: (item) => } ] : [ - { key: "identity", label: t("organization.member"), render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""} }, + { key: "identity", label: t("organization.member"), render: (item) => {(item.name || item.username || "-").slice(0, 1)}{item.name || item.username || "-"}ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""} }, { key: "prettyId", label: t("users.prettyId"), render: (item) => item.prettyId || "-" }, { key: "country", label: t("organization.country"), render: (item) => item.country || "-" }, { key: "status", label: t("common.status"), render: (item) => } diff --git a/external-admin/src/pages/OverviewPage.jsx b/external-admin/src/pages/OverviewPage.jsx index 965b803..9945f85 100644 --- a/external-admin/src/pages/OverviewPage.jsx +++ b/external-admin/src/pages/OverviewPage.jsx @@ -1,5 +1,21 @@ -import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined"; -import LockOutlined from "@mui/icons-material/LockOutlined"; +import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined"; +import BadgeOutlined from "@mui/icons-material/BadgeOutlined"; +import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import GppBadOutlined from "@mui/icons-material/GppBadOutlined"; +import GppGoodOutlined from "@mui/icons-material/GppGoodOutlined"; +import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; +import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import ListAltOutlined from "@mui/icons-material/ListAltOutlined"; +import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined"; +import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; +import MilitaryTechOutlined from "@mui/icons-material/MilitaryTechOutlined"; +import NumbersOutlined from "@mui/icons-material/NumbersOutlined"; +import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined"; +import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined"; +import SecurityOutlined from "@mui/icons-material/SecurityOutlined"; +import TrendingUpOutlined from "@mui/icons-material/TrendingUpOutlined"; +import WallpaperOutlined from "@mui/icons-material/WallpaperOutlined"; import Box from "@mui/material/Box"; import Card from "@mui/material/Card"; import CardActionArea from "@mui/material/CardActionArea"; @@ -13,28 +29,54 @@ import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabili import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx"; import { ExternalPage } from "../shared/PageComponents.jsx"; +const CAPABILITY_ICONS = { + "agency:list": ApartmentOutlined, + "banner:create": ImageOutlined, + "bd-manager:list": SecurityOutlined, + "bd:list": GroupsOutlined, + "host:list": BadgeOutlined, + "pretty-id:grant": NumbersOutlined, + "privilege:grant": CardGiftcardOutlined, + "privilege:list": ListAltOutlined, + "room-background:grant": WallpaperOutlined, + "room:list": MeetingRoomOutlined, + "room:update": EditOutlined, + "super-admin:list": SecurityOutlined, + "team:view": GroupsOutlined, + "user-ban:list": PersonOffOutlined, + "user-level:grant": TrendingUpOutlined, + "user-title:grant": MilitaryTechOutlined, + "user:ban": GppBadOutlined, + "user:list": PeopleOutlineOutlined, + "user:unban": GppGoodOutlined, + "user:update": ManageAccountsOutlined +}; + export default function OverviewPage() { const { session } = useExternalAuth(); const { t } = useExternalI18n(); + const enabledCount = EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length; return ( - - - - {t("overview.currentApp")}{session.appName || session.appCode} ({session.appCode}) - {t("overview.account")}{session.account} - {t("overview.permissions")}{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length} - - - + + {t("overview.welcome")} + {session.appName || session.appCode} + + {t("overview.currentApp")}{session.appName || session.appCode} ({session.appCode}) + {t("overview.account")}{session.account} + {t("overview.permissions")}{enabledCount} / {EXTERNAL_CAPABILITIES.length} + + + {t("overview.features")} {EXTERNAL_CAPABILITIES.map((item) => { const enabled = hasExternalCapability(session, item.code); + const Icon = CAPABILITY_ICONS[item.code] || ListAltOutlined; const content = ( - - {enabled ? : } + + {t(item.labelKey)} diff --git a/external-admin/src/pages/RoomsPage.jsx b/external-admin/src/pages/RoomsPage.jsx index 63c883e..89e2fb5 100644 --- a/external-admin/src/pages/RoomsPage.jsx +++ b/external-admin/src/pages/RoomsPage.jsx @@ -29,7 +29,7 @@ export default function RoomsPage() { const canEdit = hasExternalCapability(session, "room:update"); const columns = useMemo(() => [ - { key: "room", label: t("rooms.room"), render: (item) => R{item.title || "-"}ID {item.id} }, + { key: "room", label: t("rooms.room"), render: (item) => R{item.title || "-"}ID {item.id} }, { key: "owner", label: t("rooms.owner"), render: (item) => item.ownerName || "-" }, { key: "region", label: t("rooms.visibleRegion"), render: (item) => item.visibleRegionId || t("rooms.allRegions") }, { key: "status", label: t("common.status"), render: (item) => }, diff --git a/external-admin/src/pages/UsersPage.jsx b/external-admin/src/pages/UsersPage.jsx index 020c76d..36fa92d 100644 --- a/external-admin/src/pages/UsersPage.jsx +++ b/external-admin/src/pages/UsersPage.jsx @@ -49,12 +49,12 @@ export default function UsersPage() { }; const columns = useMemo(() => [ - { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, + { key: "user", label: t("common.user"), render: (user) => {(user.username || "-").slice(0, 1)}{user.username || "-"}ID {user.id} }, { key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" }, { key: "country", label: t("users.countryGender"), render: (user) => `${user.country || "-"} / ${translatedGender(user.gender, t)}` }, { key: "level", label: t("users.level"), render: (user) => t("users.levelSummary", { wealth: user.wealthLevel, charm: user.charmLevel, vip: user.vipLevel }) }, { key: "status", label: t("common.status"), render: (user) => }, - { key: "actions", label: t("common.actions"), render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } + { key: "actions", label: t("common.actions"), render: (user) => {canEdit ? : null}{canGrantLevel ? : null}{canBan && !user.banned ? : null} } ], [canBan, canEdit, canGrantLevel, t]); return ( diff --git a/external-admin/src/shared/PageComponents.jsx b/external-admin/src/shared/PageComponents.jsx index fa1a04e..c6f7dfb 100644 --- a/external-admin/src/shared/PageComponents.jsx +++ b/external-admin/src/shared/PageComponents.jsx @@ -21,11 +21,11 @@ import { translateExternalError } from "../i18n/errors.js"; export function ExternalPage({ actions, children, title }) { return ( - + {title} - {actions ? {actions} : null} + {actions ? {actions} : null} {children} @@ -55,7 +55,8 @@ export function ExternalPageState({ error, loading, onRetry }) { export function ResponsiveDataList({ columns, emptyText, items, rowKey }) { const theme = useTheme(); - const mobile = useMediaQuery(theme.breakpoints.down("sm")); + // H5 is the primary surface: anything below desktop gets record cards instead of a sideways-scrolling table. + const mobile = useMediaQuery(theme.breakpoints.down("md")); const { t } = useExternalI18n(); if (!items.length) { @@ -113,7 +114,7 @@ export function PagePagination({ page, pageSize, total, onChange }) { const { t } = useExternalI18n(); const totalPages = Math.max(1, Math.ceil(total / pageSize)); return ( - + {t("pagination.total", { count: total })} diff --git a/external-admin/src/styles/index.css b/external-admin/src/styles/index.css index df45aae..c61ea93 100644 --- a/external-admin/src/styles/index.css +++ b/external-admin/src/styles/index.css @@ -1,3 +1,104 @@ +/* + * HY Partner Center — black & gold identity. + * This bundle loads after @/styles/tokens.css, so the :root block below re-skins + * every shared token for this entry only (the internal admin keeps its light theme). + * All directional rules use CSS logical properties because the app also runs RTL (Arabic). + */ + +:root { + --bg-page: #0d0d11; + --bg-sidebar: #101015; + --bg-header: rgba(14, 14, 19, 0.88); + --bg-card: #16161c; + --bg-card-strong: #1e1e26; + --bg-hover: rgba(214, 181, 110, 0.08); + --bg-input: rgba(255, 255, 255, 0.035); + --bg-input-strong: rgba(255, 255, 255, 0.07); + --bg-flyout: rgba(24, 24, 31, 0.98); + + --border: rgba(214, 181, 110, 0.2); + --border-soft: rgba(214, 181, 110, 0.12); + --row-border: rgba(214, 181, 110, 0.1); + --detail-border: rgba(214, 181, 110, 0.12); + + --primary: #d6b56e; + --primary-strong: #e4ca8c; + --primary-hover: rgba(214, 181, 110, 0.1); + --primary-surface: rgba(214, 181, 110, 0.12); + --primary-surface-strong: rgba(214, 181, 110, 0.16); + --primary-border: rgba(214, 181, 110, 0.45); + --primary-border-strong: rgba(214, 181, 110, 0.62); + --primary-border-active: rgba(214, 181, 110, 0.75); + --brand-secondary: #a8823f; + --brand-gradient: linear-gradient(135deg, #efdcab 0%, #d6b56e 45%, #a8823f 100%); + --brand-shadow: 0 12px 28px rgba(0, 0, 0, 0.55), 0 2px 10px rgba(214, 181, 110, 0.28); + --active-gradient: linear-gradient(90deg, rgba(214, 181, 110, 0.95), rgba(168, 130, 63, 0.4)); + --active-inset: inset 0 0 0 1px rgba(214, 181, 110, 0.24); + + --success: #66bd8b; + --success-surface: rgba(102, 189, 139, 0.12); + --success-border: rgba(102, 189, 139, 0.36); + --success-border-strong: rgba(102, 189, 139, 0.72); + --success-glow: rgba(102, 189, 139, 0.26); + --warning: #d9a441; + --warning-surface: rgba(217, 164, 65, 0.12); + --warning-border: rgba(217, 164, 65, 0.36); + --warning-glow: rgba(217, 164, 65, 0.26); + --danger: #e07b6d; + --danger-surface: rgba(224, 123, 109, 0.12); + --danger-border: rgba(224, 123, 109, 0.34); + --danger-border-strong: rgba(224, 123, 109, 0.72); + --danger-glow: rgba(224, 123, 109, 0.26); + --info: #9fb4cc; + --info-surface: rgba(159, 180, 204, 0.12); + --info-surface-strong: rgba(159, 180, 204, 0.18); + --info-border: rgba(159, 180, 204, 0.34); + --stopped: #8c8577; + --neutral-surface: rgba(255, 255, 255, 0.05); + --neutral-surface-strong: rgba(255, 255, 255, 0.09); + --neutral-border: rgba(255, 255, 255, 0.14); + --admin-tag-bg: var(--neutral-surface); + --admin-tag-border: var(--neutral-border); + --admin-tag-color: var(--text-secondary); + + --chart-grid: rgba(214, 181, 110, 0.12); + --chart-tooltip-bg: #1e1e26; + --chart-tooltip-border: rgba(214, 181, 110, 0.3); + --chart-tooltip-text: #f0e9d8; + + --text-primary: #f0e9d8; + --text-secondary: #b8ad92; + --text-tertiary: #8c836d; + --active-contrast: #1a1206; + + --shadow-soft: 0 12px 28px rgba(0, 0, 0, 0.45); + --shadow-panel: 0 20px 50px rgba(0, 0, 0, 0.55); + --shadow-dialog: 0 30px 90px rgba(0, 0, 0, 0.7); + --shadow-drawer: 0 -18px 60px rgba(0, 0, 0, 0.6); + --overlay-scrim: rgba(4, 4, 7, 0.66); + --focus-ring: rgba(214, 181, 110, 0.26); + --login-gradient: radial-gradient(120% 70% at 50% 0%, rgba(214, 181, 110, 0.12), transparent 60%); + --skeleton-surface: rgba(255, 255, 255, 0.045); + --skeleton-shine: rgba(214, 181, 110, 0.12); + --skeleton-gradient: linear-gradient(90deg, transparent, var(--skeleton-shine), transparent); + + --font-display: "Didot", "Bodoni MT", "Playfair Display", Georgia, "Times New Roman", serif; + --tabbar-height: 62px; +} + +/* Bigger touch targets on handsets; 16px inputs also stop iOS Safari focus-zoom. */ +@media (max-width: 899px) { + :root { + --control-height: 44px; + --control-font-size: 16px; + --control-line-height: 22px; + --control-padding-x: 14px; + --admin-font-size: 15px; + --admin-line-height: 22px; + --radius-control: 10px; + } +} + html, body, #external-admin-root { @@ -6,41 +107,69 @@ body, } body { - background: var(--bg-page); + background: + radial-gradient(1100px 460px at 50% -8%, rgba(214, 181, 110, 0.08), transparent 62%), + radial-gradient(900px 500px at 100% 110%, rgba(214, 181, 110, 0.045), transparent 55%), + var(--bg-page); + background-attachment: fixed; color: var(--text-primary); font-family: var(--font-system); + -webkit-tap-highlight-color: transparent; } +::selection { + background: rgba(214, 181, 110, 0.32); + color: #fdf9ef; +} + +* { + scrollbar-width: thin; + scrollbar-color: rgba(214, 181, 110, 0.28) transparent; +} + +/* --------------------------------------------------------------------------- + * Login / password screens + * ------------------------------------------------------------------------- */ + .external-login-page { min-height: 100dvh; display: grid; place-items: center; padding: max(24px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); - background: radial-gradient(circle at 50% 0, rgba(37, 99, 235, 0.13), transparent 42%), var(--bg-page); + background: + var(--login-gradient), + radial-gradient(700px 380px at 50% 108%, rgba(214, 181, 110, 0.07), transparent 60%), + var(--bg-page); } .external-login-card { width: min(100%, 420px); border: 1px solid var(--border); + border-radius: 20px !important; + background: + linear-gradient(180deg, rgba(214, 181, 110, 0.05), transparent 34%), + var(--bg-card) !important; box-shadow: var(--shadow-panel); } .external-login-card .MuiCardContent-root { display: grid; - gap: 28px; - padding: 32px; + gap: 26px; + padding: 30px 28px 34px; +} + +.external-login-title { + font-family: var(--font-display) !important; + font-size: 24px !important; + letter-spacing: 0.05em; + color: var(--primary-strong) !important; + padding-top: 6px; } .external-language-switcher { min-width: 140px !important; } -.external-login-avatar, -.external-logo-mark { - background: var(--brand-gradient) !important; - color: #fff; -} - .external-full-state { min-height: 100dvh; display: grid; @@ -50,34 +179,61 @@ body { padding: 24px; } +/* --------------------------------------------------------------------------- + * App shell + * ------------------------------------------------------------------------- */ + .external-shell, .external-main { min-height: 100dvh; } +.external-main { + background: transparent; +} + .external-header { - border-bottom: 1px solid var(--border); - background: rgba(255, 255, 255, 0.96) !important; - backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-soft); + background: var(--bg-header) !important; + backdrop-filter: blur(16px) saturate(130%); + -webkit-backdrop-filter: blur(16px) saturate(130%); + color: var(--text-primary) !important; + padding-top: env(safe-area-inset-top); } .external-header .MuiToolbar-root { - min-height: 64px; + min-height: 60px; gap: 10px; } +.external-toolbar-spacer { + min-height: calc(60px + env(safe-area-inset-top)) !important; +} + +.external-header-title { + font-family: var(--font-display) !important; + font-size: 17px !important; + letter-spacing: 0.03em; +} + .external-user-avatar { width: 34px !important; height: 34px !important; font-size: 13px !important; - background: var(--primary) !important; + background: var(--brand-gradient) !important; + color: var(--active-contrast) !important; + font-weight: 700; } +/* Desktop sidebar */ + .external-sidebar { min-height: 100%; display: flex; flex-direction: column; - background: var(--bg-sidebar); + background: + linear-gradient(180deg, rgba(214, 181, 110, 0.05), transparent 18%), + var(--bg-sidebar); } .external-logo { @@ -86,41 +242,44 @@ body { display: flex; align-items: center; gap: 12px; - padding: 12px 20px; + padding: 14px 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-logo-name { + font-family: var(--font-display) !important; + letter-spacing: 0.05em; } .external-nav { padding: 12px !important; overflow-y: auto; + flex: 1; } .external-nav .MuiListItemButton-root { min-height: 44px; margin-bottom: 4px; - border-radius: 8px; + border-radius: 10px; color: var(--text-secondary); - transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard), transform var(--motion-base) var(--ease-emphasized); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); } .external-nav .MuiListItemButton-root:hover { background: var(--bg-hover); + color: var(--text-primary); } .external-nav .MuiListItemButton-root.Mui-selected, .external-nav .MuiListItemButton-root.Mui-selected:hover { - background: var(--primary-surface-strong); - color: var(--primary); + background: linear-gradient(90deg, rgba(214, 181, 110, 0.16), rgba(214, 181, 110, 0.05)); + color: var(--primary-strong); font-weight: 700; + box-shadow: inset 3px 0 0 0 var(--primary); +} + +[dir="rtl"] .external-nav .MuiListItemButton-root.Mui-selected, +[dir="rtl"] .external-nav .MuiListItemButton-root.Mui-selected:hover { + box-shadow: inset -3px 0 0 0 var(--primary); } .external-nav .MuiListItemIcon-root { @@ -128,10 +287,158 @@ body { color: inherit; } -.external-main { - background: var(--bg-page); +.external-sidebar-footer { + padding: 14px 16px calc(14px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--border-soft); + display: grid; + gap: 10px; } +/* --------------------------------------------------------------------------- + * Mobile bottom tab bar + "more" sheet + * ------------------------------------------------------------------------- */ + +.external-tabbar { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 1100; + display: none; + align-items: stretch; + min-height: var(--tabbar-height); + padding: 6px max(8px, env(safe-area-inset-right)) max(6px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); + background: var(--bg-header); + backdrop-filter: blur(16px) saturate(130%); + -webkit-backdrop-filter: blur(16px) saturate(130%); + border-top: 1px solid var(--border-soft); +} + +.external-tab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + min-height: 50px; + padding: 4px 2px; + border: 0; + border-radius: 12px; + background: none; + color: var(--text-tertiary); + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-decoration: none; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.external-tab .MuiSvgIcon-root { + font-size: 22px; +} + +.external-tab-label { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.external-tab.is-active { + color: var(--primary-strong); +} + +.external-tab.is-active .MuiSvgIcon-root { + filter: drop-shadow(0 0 10px rgba(214, 181, 110, 0.45)); +} + +.external-tab:active { + background: var(--bg-hover); +} + +/* Bottom sheet (mobile navigation drawer + dialogs share the look) */ + +.external-sheet.MuiDrawer-paper { + border-radius: 22px 22px 0 0; + border-top: 1px solid var(--primary-border); + background: + linear-gradient(180deg, rgba(214, 181, 110, 0.06), transparent 30%), + var(--bg-card); + background-color: var(--bg-card); + box-shadow: var(--shadow-drawer); + padding: 10px 18px calc(18px + env(safe-area-inset-bottom)); + max-height: 86dvh; +} + +.external-sheet-handle { + width: 44px; + height: 4px; + margin: 2px auto 14px; + border-radius: 999px; + background: rgba(214, 181, 110, 0.35); +} + +.external-sheet-account { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + margin-bottom: 14px; + border: 1px solid var(--border-soft); + border-radius: 14px; + background: linear-gradient(135deg, rgba(214, 181, 110, 0.1), rgba(214, 181, 110, 0.02)); +} + +.external-sheet-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + overflow-y: auto; + padding-bottom: 4px; +} + +.external-sheet-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 76px; + padding: 10px 4px; + border-radius: 14px; + color: var(--text-secondary); + font-size: 12px; + text-align: center; + text-decoration: none; + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.external-sheet-item .MuiSvgIcon-root { + font-size: 24px; + color: var(--primary); +} + +.external-sheet-item:active, +.external-sheet-item.is-active { + background: var(--primary-surface); + color: var(--primary-strong); +} + +.external-sheet-item span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* --------------------------------------------------------------------------- + * Page scaffolding + * ------------------------------------------------------------------------- */ + .external-page { display: grid; gap: 20px; @@ -139,12 +446,17 @@ body { animation: external-page-enter var(--motion-slow) var(--ease-emphasized) both; } +.external-page > .MuiStack-root:first-child h1 { + font-family: var(--font-display); + letter-spacing: 0.03em; +} + .external-session-card, .external-table-container, .external-filter-bar, .external-form-panel, .external-empty { - border: 1px solid var(--border); + border: 1px solid var(--border-soft); border-radius: var(--radius-card); background: var(--bg-card); } @@ -158,32 +470,6 @@ body { min-width: min(100%, 220px); } -.external-capability-grid { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 12px; -} - -.external-capability-card { - min-height: 150px; - opacity: 0.68; -} - -.external-capability-card.is-enabled { - opacity: 1; -} - -.external-capability-card.is-enabled:hover { - border-color: var(--primary-border); - transform: translateY(-2px); - box-shadow: var(--shadow-soft); -} - -.external-capability-card .MuiCardActionArea-root, -.external-capability-card .MuiCardContent-root { - height: 100%; -} - .external-table-container { overflow-x: auto; } @@ -192,11 +478,23 @@ body { background: var(--bg-card-strong); } +.external-table-container .MuiTableHead-root .MuiTableCell-root { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .external-table-container .MuiTableCell-root { white-space: nowrap; border-color: var(--row-border); } +.external-table-container .MuiTableRow-hover:hover { + background: var(--bg-hover) !important; +} + .external-empty { min-height: 200px; display: grid; @@ -217,31 +515,151 @@ body { place-items: center; } +/* Mobile record cards */ + .external-mobile-card .external-mobile-row { display: grid; - grid-template-columns: minmax(92px, 0.38fr) minmax(0, 1fr); + grid-template-columns: minmax(92px, 0.36fr) minmax(0, 1fr); align-items: start; gap: 12px; - padding: 10px 0; + padding: 11px 0; border-bottom: 1px solid var(--row-border); } .external-mobile-card .external-mobile-row:last-child { border-bottom: 0; + padding-bottom: 2px; } +.external-mobile-card .external-mobile-row .MuiButton-root { + min-height: 40px; +} + +/* --------------------------------------------------------------------------- + * Home (overview) + * ------------------------------------------------------------------------- */ + +.external-hero { + position: relative; + overflow: hidden; + padding: 22px 24px; + border: 1px solid var(--primary-border); + border-radius: 18px; + background: + radial-gradient(420px 180px at 12% -30%, rgba(214, 181, 110, 0.22), transparent 70%), + linear-gradient(135deg, #1d1a12 0%, #14141a 55%, #101014 100%); + box-shadow: var(--shadow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.external-hero-eyebrow { + color: var(--text-tertiary) !important; + font-size: 11px !important; + letter-spacing: 0.28em; + text-transform: uppercase; +} + +.external-hero-title { + font-family: var(--font-display) !important; + font-size: 26px !important; + letter-spacing: 0.03em; + margin: 4px 0 14px !important; +} + +.external-hero-meta { + display: flex; + flex-wrap: wrap; + gap: 10px 28px; +} + +.external-section-title { + color: var(--text-tertiary) !important; + font-size: 12px !important; + font-weight: 700 !important; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.external-capability-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.external-capability-card { + min-height: 140px; + opacity: 0.62; + border-color: var(--border-soft) !important; +} + +.external-capability-card.is-enabled { + opacity: 1; +} + +.external-capability-card.is-enabled:hover { + border-color: var(--primary-border) !important; + transform: translateY(-2px); + box-shadow: var(--shadow-soft), 0 0 0 1px rgba(214, 181, 110, 0.12); +} + +.external-capability-card .MuiCardActionArea-root, +.external-capability-card .MuiCardContent-root { + height: 100%; +} + +.external-capability-icon { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 12px; + border: 1px solid rgba(214, 181, 110, 0.35); + background: linear-gradient(135deg, rgba(214, 181, 110, 0.16), rgba(214, 181, 110, 0.04)); + color: var(--primary-strong); +} + +.external-capability-card:not(.is-enabled) .external-capability-icon { + border-color: var(--neutral-border); + background: var(--neutral-surface); + color: var(--text-tertiary); +} + +/* --------------------------------------------------------------------------- + * Dialogs, inputs, misc chrome + * ------------------------------------------------------------------------- */ + .external-dialog-fields { padding-top: 8px; } +.MuiDialog-paper { + border: 1px solid var(--border); + background-image: linear-gradient(180deg, rgba(214, 181, 110, 0.05), transparent 26%) !important; + box-shadow: var(--shadow-dialog) !important; +} + +.MuiBackdrop-root { + background-color: var(--overlay-scrim) !important; + backdrop-filter: blur(3px); +} + +.MuiButton-containedPrimary:not(.Mui-disabled) { + background-image: var(--brand-gradient); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0 6px 18px rgba(0, 0, 0, 0.4); +} + +.MuiButton-containedPrimary:not(.Mui-disabled):hover { + background-image: linear-gradient(135deg, #f5e6bd 0%, #e0c079 45%, #b28a45 100%); +} + .external-image-field { display: flex; align-items: center; gap: 12px; min-height: 92px; padding: 12px; - border: 1px dashed var(--border); + border: 1px dashed var(--primary-border); border-radius: var(--radius-control); + background: rgba(214, 181, 110, 0.03); } .external-image-field img, @@ -249,7 +667,7 @@ body { width: 104px; height: 68px; object-fit: cover; - border-radius: 6px; + border-radius: 8px; background: var(--bg-card-strong); } @@ -257,7 +675,7 @@ body { width: 88px; height: 50px; object-fit: cover; - border-radius: 6px; + border-radius: 8px; background: var(--bg-card-strong); } @@ -273,23 +691,66 @@ body { to { opacity: 1; transform: translateY(0); } } +/* --------------------------------------------------------------------------- + * Responsive + * ------------------------------------------------------------------------- */ + @media (max-width: 1199px) { .external-capability-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 899px) { - .external-page { padding: 18px; } + .external-tabbar { display: flex; } + .external-page { + padding: 16px 16px calc(var(--tabbar-height) + 24px + env(safe-area-inset-bottom)); + } + /* The fixed top bar already names the page on handsets; the in-page h1 would repeat it. */ + .external-page > .MuiStack-root:first-child h1 { display: none; } .external-capability-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .external-filter-bar .MuiTextField-root { width: 100%; } + .external-filter-bar .MuiButton-root { min-height: var(--control-height); } + .external-hero { padding: 20px; } + .external-hero-title { font-size: 23px !important; } } @media (max-width: 599px) { - .external-login-card .MuiCardContent-root { padding: 24px 20px; } - .external-page { padding: 14px; gap: 14px; } - .external-capability-grid { grid-template-columns: 1fr; } - .external-capability-card { min-height: 128px; } - .external-filter-bar .MuiTextField-root { width: 100%; } + .external-login-card .MuiCardContent-root { padding: 26px 20px 30px; } + .external-page { gap: 14px; padding-inline: 14px; } + .external-capability-grid { gap: 10px; } + .external-capability-card { min-height: 118px; } .external-image-field { align-items: flex-start; flex-direction: column; } .external-header .external-language-switcher { min-width: 94px !important; max-width: 112px; } + .external-hero-meta { gap: 8px 20px; } + + /* Every dialog becomes an app-style bottom sheet on handsets. */ + .MuiDialog-container { + align-items: flex-end !important; + } + .MuiDialog-paper:not(.MuiDialog-paperFullScreen) { + margin: 0 !important; + width: 100% !important; + max-width: 100% !important; + max-height: 90dvh !important; + border-radius: 22px 22px 0 0 !important; + border-bottom: 0; + padding-bottom: env(safe-area-inset-bottom); + } + .MuiDialog-paper:not(.MuiDialog-paperFullScreen)::before { + content: ""; + display: block; + width: 44px; + height: 4px; + margin: 10px auto 0; + border-radius: 999px; + background: rgba(214, 181, 110, 0.35); + } + .MuiDialogActions-root { + padding: 12px 16px 16px !important; + } + .MuiDialogActions-root .MuiButton-root { + min-height: 44px; + flex: 1; + } } @media (prefers-reduced-motion: reduce) { diff --git a/external-admin/src/theme.js b/external-admin/src/theme.js new file mode 100644 index 0000000..1c0f3f2 --- /dev/null +++ b/external-admin/src/theme.js @@ -0,0 +1,69 @@ +import { createTheme } from "@mui/material/styles"; +import { theme as adminTheme } from "@/theme.js"; + +// The partner-facing app ships its own black-gold identity instead of the internal +// admin's light palette; component overrides are reused because they resolve through +// CSS tokens that external-admin/src/styles/index.css re-defines for this bundle. +const palette = { + mode: "dark", + primary: { + main: "#d6b56e", + light: "#ecd7a0", + dark: "#a8823f", + contrastText: "#1a1206" + }, + secondary: { + main: "#9c8a5e" + }, + success: { + main: "#66bd8b" + }, + warning: { + main: "#d9a441" + }, + error: { + main: "#e07b6d" + }, + info: { + main: "#9fb4cc" + }, + background: { + default: "#0d0d11", + paper: "#16161c" + }, + text: { + primary: "#f0e9d8", + secondary: "#b8ad92", + disabled: "#7d7460" + }, + divider: "rgba(214, 181, 110, 0.16)" +}; + +export function createExternalTheme(direction) { + return createTheme({ + direction, + palette, + typography: { + fontFamily: 'Inter, "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif', + fontSize: 14, + button: { + fontSize: 14, + textTransform: "none", + fontWeight: 650 + }, + h5: { + fontSize: 20, + fontWeight: 700, + letterSpacing: "0.01em" + }, + h6: { + fontSize: 17, + fontWeight: 700 + } + }, + shape: { + borderRadius: 10 + }, + components: adminTheme.components + }); +} diff --git a/src/features/app-users/api.test.ts b/src/features/app-users/api.test.ts index b4fe01a..218fad0 100644 --- a/src/features/app-users/api.test.ts +++ b/src/features/app-users/api.test.ts @@ -9,6 +9,7 @@ import { listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID, + revokeAppUserResource, unbanAppUser, } from "./api"; @@ -362,6 +363,38 @@ test("getAppUser uses detail endpoint and normalizes assets", async () => { }); }); +test("revokeAppUserResource uses the precise entitlement endpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + code: 0, + data: { + entitlementId: "ent-host-badge", + equipped: false, + remainingQuantity: 0, + resourceId: 91, + status: "revoked", + }, + }), + ), + ), + ); + + await revokeAppUserResource("318705991371722752", "ent-host-badge", { + reason: "后台用户详情撤回素材:host", + }); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + + expect(String(url)).toContain( + "/api/v1/admin/users/318705991371722752/resources/ent-host-badge/revoke", + ); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ reason: "后台用户详情撤回素材:host" }); +}); + test("app user projection normalizes levels roles ban and successful login fields", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts index 0b08f77..6868f7d 100644 --- a/src/features/app-users/api.ts +++ b/src/features/app-users/api.ts @@ -48,6 +48,29 @@ export async function getAppUser(userId: EntityId): Promise { return normalizeAppUser(data); } +export interface RevokeAppUserResourcePayload { + reason: string; +} + +// 详情页按 entitlement 撤回单个素材,不能复用 grant 级撤销;后者会把同一资源组内的其它素材一并回收。 +export function revokeAppUserResource( + userId: EntityId, + entitlementId: EntityId, + payload: RevokeAppUserResourcePayload, +): Promise { + const endpoint = API_ENDPOINTS.revokeAppUserResource; + return apiRequest( + apiEndpointPath(API_OPERATIONS.revokeAppUserResource, { + entitlement_id: entitlementId, + user_id: userId, + }), + { + body: payload, + method: endpoint.method, + }, + ); +} + export async function listAppUserLoginLogs(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.appListLoginLogs; const data = await apiRequest & { page_size?: number }>( diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index 8cbaab7..d1198fa 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -913,7 +913,7 @@ .resourceItem { display: grid; - grid-template-columns: 44px minmax(0, 1fr); + grid-template-columns: 44px minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; min-width: 0; @@ -923,6 +923,11 @@ background: var(--bg-card); } +.resourceRevokeButton { + min-width: 72px; + white-space: nowrap; +} + .resourceThumb, .resourceThumbFallback { width: 44px; diff --git a/src/features/app-users/components/AppUserDetailProvider.jsx b/src/features/app-users/components/AppUserDetailProvider.jsx index cfec46c..cb0cb84 100644 --- a/src/features/app-users/components/AppUserDetailProvider.jsx +++ b/src/features/app-users/components/AppUserDetailProvider.jsx @@ -60,12 +60,13 @@ export function AppUserDetailProvider({ children }) { open={Boolean(detailUser)} user={detailUser} onClose={closeAppUserDetail} + onReloadUser={() => openAppUserDetail(detailUser)} /> ); } -function AppUserDetailDrawer({ loading, onClose, open, user }) { +function AppUserDetailDrawer({ loading, onClose, onReloadUser, open, user }) { if (!open || !user) { return null; } @@ -83,6 +84,7 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) { loading={loading} user={user} onOpenFullDetails={onClose} + onReloadUser={onReloadUser} /> ); diff --git a/src/features/app-users/components/AppUserDetailProvider.test.jsx b/src/features/app-users/components/AppUserDetailProvider.test.jsx index 4ee72ee..7fc1a11 100644 --- a/src/features/app-users/components/AppUserDetailProvider.test.jsx +++ b/src/features/app-users/components/AppUserDetailProvider.test.jsx @@ -1,12 +1,17 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; +import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; -import { getAppUser, issueAppUserAccessToken } from "@/features/app-users/api"; +import { getAppUser, issueAppUserAccessToken, revokeAppUserResource } from "@/features/app-users/api"; import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx"; import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx"; -vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: vi.fn() })); +vi.mock("@/features/app-users/api", () => ({ + getAppUser: vi.fn(), + issueAppUserAccessToken: vi.fn(), + revokeAppUserResource: vi.fn(), +})); beforeEach(() => { vi.clearAllMocks(); @@ -81,11 +86,13 @@ test("detail drawer keeps the identity hero and shows the expanded admin project render( - - - - - + + + + + + + , ); fireEvent.click(screen.getByRole("button", { name: "打开详情" })); @@ -129,6 +136,72 @@ test("detail drawer keeps the identity hero and shows the expanded admin project expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); +test("detail drawer revokes one material by entitlement and reloads owner state", async () => { + const userWithHostBadge = { + defaultDisplayUserId: "163337", + equippedResources: [ + { + entitlementId: "ent-host-badge", + equipped: true, + name: "host", + quantity: 1, + remainingQuantity: 1, + resourceId: 91, + resourceType: "badge", + status: "active", + }, + ], + resources: [ + { + entitlementId: "ent-host-badge", + equipped: true, + name: "host", + quantity: 1, + remainingQuantity: 1, + resourceId: 91, + resourceType: "badge", + status: "active", + }, + ], + status: "active", + userId: "10001", + username: "tester", + }; + vi.mocked(getAppUser) + .mockResolvedValueOnce(userWithHostBadge) + .mockResolvedValueOnce({ ...userWithHostBadge, equippedResources: [], resources: [] }); + vi.mocked(revokeAppUserResource).mockResolvedValue({}); + + render( + + + + + + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "打开详情" })); + fireEvent.click(await screen.findByRole("tab", { name: "资源 (2)" })); + + const revokeButtons = screen.getAllByRole("button", { name: "撤回素材 host" }); + expect(revokeButtons).toHaveLength(2); + fireEvent.click(revokeButtons[0]); + expect(screen.getByText(/同一资源组的其他素材不受影响/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "确认撤回" })); + + await waitFor(() => + expect(revokeAppUserResource).toHaveBeenCalledWith("10001", "ent-host-badge", { + reason: "后台用户详情撤回素材:host", + }), + ); + await waitFor(() => expect(getAppUser).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByRole("button", { name: "撤回素材 host" })).not.toBeInTheDocument()); + expect(screen.getAllByText("当前无数据")).toHaveLength(2); +}); + function DetailTrigger() { const { openAppUserDetail } = useAppUserDetail(); return ( diff --git a/src/features/app-users/components/AppUserDetailView.jsx b/src/features/app-users/components/AppUserDetailView.jsx index 0e33379..392297d 100644 --- a/src/features/app-users/components/AppUserDetailView.jsx +++ b/src/features/app-users/components/AppUserDetailView.jsx @@ -1,13 +1,16 @@ import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined"; import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +import UndoOutlined from "@mui/icons-material/UndoOutlined"; import Tab from "@mui/material/Tab"; import Tabs from "@mui/material/Tabs"; import { useEffect, useState } from "react"; import { Link, useLocation } from "react-router-dom"; import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { Button } from "@/shared/ui/Button.jsx"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { formatMillis } from "@/shared/utils/time.js"; -import { issueAppUserAccessToken } from "@/features/app-users/api"; +import { issueAppUserAccessToken, revokeAppUserResource } from "@/features/app-users/api"; import { appUserStatusLabels } from "@/features/app-users/constants.js"; import { BanCountdown, @@ -33,6 +36,7 @@ export function AppUserDetailView({ loading = false, mode = "drawer", onOpenFullDetails, + onReloadUser, user, }) { const location = useLocation(); @@ -103,7 +107,12 @@ export function AppUserDetailView({ {activeTab === "overview" ? : null} {activeTab === "assets" ? : null} {activeTab === "resources" ? ( - + ) : null} {activeTab === "status" ? : null} {activeTab === "device" ? : null} @@ -167,14 +176,65 @@ function AssetsPanel({ balances, user }) { ); } -function ResourcesPanel({ equippedResources, resources }) { +function ResourcesPanel({ equippedResources, onReloadUser, resources, user }) { + const confirm = useConfirm(); + const { showToast } = useToast(); + const [revokingEntitlementId, setRevokingEntitlementId] = useState(""); + + const revokeResource = async (resource) => { + const entitlementId = String(resource?.entitlementId || "").trim(); + if (!entitlementId || revokingEntitlementId) { + return; + } + const resourceName = resourceDisplayName(resource); + const accepted = await confirm({ + title: "撤回用户素材", + message: `撤回后,用户「${user.username || user.displayUserId || user.userId}」将立即失去素材「${resourceName}」;若正在佩戴会同时卸下。同一资源组的其他素材不受影响。`, + confirmText: "确认撤回", + tone: "danger", + }); + if (!accepted) { + return; + } + + setRevokingEntitlementId(entitlementId); + try { + // entitlement 是用户素材实例的唯一标识;按它撤回才能避免 sourceGrantId 对应资源组时误伤同组素材。 + await revokeAppUserResource(user.userId, entitlementId, { + reason: `后台用户详情撤回素材:${resourceName}`.slice(0, 256), + }); + } catch (error) { + showToast(error.message || "素材撤回失败", "error"); + setRevokingEntitlementId(""); + return; + } + + showToast(`素材「${resourceName}」已撤回`, "success"); + try { + // 撤回成功后重新读取 owner 服务事实,不能只在前端本地删行掩盖后端未生效或残留佩戴状态。 + await onReloadUser?.(); + } catch (error) { + showToast(error.message || "素材已撤回,但用户详情刷新失败", "warning"); + } finally { + setRevokingEntitlementId(""); + } + }; + return ( <> - + - + ); @@ -437,7 +497,7 @@ function AssetBalanceList({ balances }) { ); } -function ResourceList({ resources }) { +function ResourceList({ onRevokeResource, resources, revokingEntitlementId }) { if (!resources.length) { return
    当前无数据
    ; } @@ -448,7 +508,7 @@ function ResourceList({ resources }) {
    - {resource.name || resource.resourceCode || `资源 ${resource.resourceId}`} + {resourceDisplayName(resource)} {resource.equipped ? 佩戴中 : null}
    @@ -456,12 +516,28 @@ function ResourceList({ resources }) {
    到期 {formatExpireTime(resource.expiresAtMs)}
    + {resource.entitlementId ? ( + + ) : null} ))} ); } +function resourceDisplayName(resource) { + return resource?.name || resource?.resourceCode || `资源 ${resource?.resourceId || "-"}`; +} + function ResourceThumb({ resource }) { const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl; if (!imageUrl) { diff --git a/src/features/app-users/pages/AppUserDetailPage.jsx b/src/features/app-users/pages/AppUserDetailPage.jsx index e1fd0bf..8a91ec4 100644 --- a/src/features/app-users/pages/AppUserDetailPage.jsx +++ b/src/features/app-users/pages/AppUserDetailPage.jsx @@ -47,7 +47,7 @@ export function AppUserDetailPage() {
    - {data ? : null} + {data ? : null}
    diff --git a/src/features/app-users/pages/AppUserDetailPage.test.jsx b/src/features/app-users/pages/AppUserDetailPage.test.jsx index 50fceae..8962610 100644 --- a/src/features/app-users/pages/AppUserDetailPage.test.jsx +++ b/src/features/app-users/pages/AppUserDetailPage.test.jsx @@ -2,11 +2,17 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { QueryProvider } from "@/shared/query/QueryProvider.jsx"; +import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx"; +import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { getAppUser } from "@/features/app-users/api"; import { AppUserDetailPage } from "@/features/app-users/pages/AppUserDetailPage.jsx"; // AppUserDetailView 会静态导入 issueAppUserAccessToken,mock 工厂缺少该导出会直接导致模块加载失败。 -vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: vi.fn() })); +vi.mock("@/features/app-users/api", () => ({ + getAppUser: vi.fn(), + issueAppUserAccessToken: vi.fn(), + revokeAppUserResource: vi.fn(), +})); test("full user detail route loads the URL user and returns to its source list", async () => { vi.mocked(getAppUser).mockResolvedValue({ @@ -26,19 +32,23 @@ test("full user detail route loads the URL user and returns to its source list", render( - - - } /> - } /> - - + + + + + } /> + } /> + + + + , ); diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index 04e36a9..5e15a85 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -433,6 +433,7 @@ test("resource shop APIs use generated admin paths", async () => { await upsertResourceShopItems({ items: [ { + coinPrice: 120, durationDays: 3, effectiveFromMs: 0, effectiveToMs: 0, @@ -462,7 +463,7 @@ test("resource shop APIs use generated admin paths", async () => { expect(String(upsertUrl)).toContain("/api/v1/admin/resource-shop/items"); expect(upsertInit?.method).toBe("POST"); expect(JSON.parse(String(upsertInit?.body))).toMatchObject({ - items: [{ durationDays: 3, resourceId: 11, status: "active" }], + items: [{ coinPrice: 120, durationDays: 3, resourceId: 11, status: "active" }], }); expect(String(enableUrl)).toContain("/api/v1/admin/resource-shop/items/99/enable"); expect(enableInit?.method).toBe("POST"); diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index f407fab..be656ff 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -328,6 +328,7 @@ export interface ResourceGroupPayload { } export interface ResourceShopItemPayload { + coinPrice: number; durationDays: number; effectiveFromMs: number; effectiveToMs: number; diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 6a834c7..323ba78 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -49,6 +49,7 @@ import { cpRelationTypeOptions, defaultGiftTypeOptions, resourceIdentityAutoGrantOptions, + resourceShopDurationOptions, resourceShopSellableTypes, } from "@/features/resources/constants.js"; import { useResourceAbilities } from "@/features/resources/permissions.js"; @@ -314,6 +315,68 @@ export async function fetchAllOptionPages(fetcher, query = {}, pageSize = option throw new Error("资源选项分页过多,请收窄筛选条件"); } +export function groupResourceShopItems(items = []) { + const itemsByResourceId = new Map(); + + // 同一资源可以同时售卖 1/3/7 天规格,按资源分组时必须保留完整数组,不能再用单值 Map 覆盖前面的规格。 + items.forEach((item) => { + const resourceId = Number(item?.resourceId || 0); + if (!Number.isInteger(resourceId) || resourceId <= 0) { + return; + } + const resourceItems = itemsByResourceId.get(resourceId) || []; + resourceItems.push(item); + itemsByResourceId.set(resourceId, resourceItems); + }); + itemsByResourceId.forEach((resourceItems) => { + resourceItems.sort((left, right) => Number(left.durationDays || 0) - Number(right.durationDays || 0)); + }); + return itemsByResourceId; +} + +export function buildResourceShopDraftGroup(resource, existingItems = [], fallbackSortOrder = 0) { + const resourceId = String(resource?.resourceId || ""); + const allowedDurations = new Set(resourceShopDurationOptions.map(([durationDays]) => durationDays)); + const specsByDuration = {}; + + existingItems.forEach((item) => { + const durationDays = String(item?.durationDays || "1"); + if (!allowedDurations.has(durationDays)) { + return; + } + specsByDuration[durationDays] = resourceShopSpecDraft(resource, durationDays, item, fallbackSortOrder); + }); + + // 首次选择资源默认创建 1 天规格并继承资源列表价格;其他天数由运营按需勾选且各自维护价格。 + if (Object.keys(specsByDuration).length === 0) { + specsByDuration["1"] = resourceShopSpecDraft(resource, "1", null, fallbackSortOrder); + } + return { resourceId, specsByDuration }; +} + +export function canUnselectResourceShopDraftSpec(spec) { + // 已落库规格只能在商店列表通过状态 Switch 启停;从抽屉静默移除不会删除数据库行,因此必须阻止这种误导操作。 + return !spec?.shopItemId; +} + +export function canUnselectResourceShopDraftGroup(draftGroup) { + // 父级取消会一次丢弃全部规格;只要其中一条已落库,就必须保留整组并让运营逐条通过列表 Switch 启停。 + return Object.values(draftGroup?.specsByDuration || {}).every(canUnselectResourceShopDraftSpec); +} + +function resourceShopSpecDraft(resource, durationDays, existingItem, fallbackSortOrder) { + return { + coinPrice: String(existingItem?.coinPrice ?? resource?.coinPrice ?? ""), + durationDays, + effectiveFromMs: existingItem?.effectiveFromMs || "", + effectiveToMs: existingItem?.effectiveToMs || "", + resourceId: String(resource?.resourceId || existingItem?.resourceId || ""), + shopItemId: existingItem?.shopItemId, + sortOrder: String(existingItem?.sortOrder ?? fallbackSortOrder), + status: existingItem?.status || "active", + }; +} + function loadResourceGrantOptions() { return Promise.all([ fetchAllOptionPages(listResources, { status: "active" }), @@ -685,9 +748,11 @@ export function useResourceShopPage() { const [purchaseKeyword, setPurchaseKeyword] = useState(""); const [drawerQuery, setDrawerQuery] = useState(""); const [drawerResourceType, setDrawerResourceType] = useState(""); - const [batchDurationDays, setBatchDurationDays] = useState("1"); const [resourceOptions, setResourceOptions] = useState([]); + const [shopItemOptions, setShopItemOptions] = useState([]); const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false); + const [resourceOptionsError, setResourceOptionsError] = useState(""); + const [resourceOptionsLoadVersion, setResourceOptionsLoadVersion] = useState(0); const [draftByResourceId, setDraftByResourceId] = useState({}); const [loadingAction, setLoadingAction] = useState(""); const filters = useMemo( @@ -732,25 +797,18 @@ export function useResourceShopPage() { queryKey: ["resource-shop-purchase-orders", purchaseDrawerOpen, purchaseFilters, purchasePage], }); const shopItemsByResourceId = useMemo(() => { - const items = new Map(); - (result.data?.items || []).forEach((item) => { - if (item.resourceId) { - items.set(Number(item.resourceId), item); - } - }); - return items; - }, [result.data?.items]); + return groupResourceShopItems(shopItemOptions); + }, [shopItemOptions]); const selectedDrafts = useMemo( () => Object.values(draftByResourceId) - .map((draft) => ({ - ...draft, - resource: resourceOptions.find( - (resource) => String(resource.resourceId) === String(draft.resourceId), - ), - })) - .sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)), - [draftByResourceId, resourceOptions], + .flatMap((draftGroup) => Object.values(draftGroup.specsByDuration || {})) + .sort( + (left, right) => + Number(left.sortOrder || 0) - Number(right.sortOrder || 0) || + Number(left.durationDays || 0) - Number(right.durationDays || 0), + ), + [draftByResourceId], ); const filteredResourceOptions = useMemo(() => { const keyword = drawerQuery.trim().toLowerCase(); @@ -771,15 +829,28 @@ export function useResourceShopPage() { } let ignore = false; setResourceOptionsLoading(true); - fetchAllOptionPages(listResources, { status: "active" }) - .then((data) => { + setResourceOptionsError(""); + setResourceOptions([]); + setShopItemOptions([]); + // 抽屉同时拉全资源和现有售卖项,避免当前列表分页把同一资源的其他天数规格漏掉或误判为新增。 + Promise.all([ + fetchAllOptionPages(listResources, { status: "active" }), + fetchAllOptionPages(listResourceShopItems), + ]) + .then(([resourceData, shopItemData]) => { if (!ignore) { - setResourceOptions(toShopResourceOptions(data.items || [])); + setResourceOptions(toShopResourceOptions(resourceData.items || [])); + setShopItemOptions(shopItemData.items || []); } }) .catch((err) => { if (!ignore) { - showToast(err.message || "加载售卖资源失败", "error"); + const message = err.message || "加载售卖资源失败"; + // 任一请求失败都不能沿用上次候选;否则“旧资源 + 空规格”会让运营误以为是在新增商品。 + setResourceOptions([]); + setShopItemOptions([]); + setResourceOptionsError(message); + showToast(message, "error"); } }) .finally(() => { @@ -790,19 +861,34 @@ export function useResourceShopPage() { return () => { ignore = true; }; - }, [drawerOpen, showToast]); + }, [drawerOpen, resourceOptionsLoadVersion, showToast]); const openShopDrawer = () => { setDraftByResourceId({}); setDrawerQuery(""); setDrawerResourceType(""); - setBatchDurationDays("1"); + setResourceOptions([]); + setShopItemOptions([]); + setResourceOptionsError(""); + setResourceOptionsLoadVersion((current) => current + 1); setDrawerOpen(true); }; + const reloadShopDrawerOptions = () => { + if (!drawerOpen) { + return; + } + setDraftByResourceId({}); + setResourceOptionsError(""); + setResourceOptionsLoadVersion((current) => current + 1); + }; + const closeShopDrawer = () => { setDrawerOpen(false); setDraftByResourceId({}); + setResourceOptions([]); + setShopItemOptions([]); + setResourceOptionsError(""); }; const openPurchaseDrawer = () => { @@ -821,41 +907,78 @@ export function useResourceShopPage() { const resourceId = String(resource.resourceId); setDraftByResourceId((current) => { if (current[resourceId]) { + if (!canUnselectResourceShopDraftGroup(current[resourceId])) { + return current; + } const next = { ...current }; delete next[resourceId]; return next; } - const existing = shopItemsByResourceId.get(Number(resource.resourceId)); return { ...current, - [resourceId]: { - durationDays: String(existing?.durationDays || batchDurationDays), - effectiveFrom: msToDatetimeLocal(existing?.effectiveFromMs), - effectiveTo: msToDatetimeLocal(existing?.effectiveToMs), - resourceId, - sortOrder: String(existing?.sortOrder ?? Object.keys(current).length), - }, + [resourceId]: buildResourceShopDraftGroup( + resource, + shopItemsByResourceId.get(Number(resource.resourceId)) || [], + Object.keys(current).length, + ), }; }); }; - const updateDraftItem = (resourceId, patch) => { - setDraftByResourceId((current) => ({ - ...current, - [resourceId]: { - ...current[resourceId], - ...patch, - }, - })); + const toggleDraftDuration = (resource, durationDays) => { + const resourceId = String(resource?.resourceId || ""); + const normalizedDuration = String(durationDays || ""); + if (!resourceId || !normalizedDuration) { + return; + } + setDraftByResourceId((current) => { + const draftGroup = current[resourceId]; + if (!draftGroup) { + return current; + } + const specsByDuration = { ...draftGroup.specsByDuration }; + if (specsByDuration[normalizedDuration]) { + if (!canUnselectResourceShopDraftSpec(specsByDuration[normalizedDuration])) { + return current; + } + delete specsByDuration[normalizedDuration]; + } else { + // 重新勾选已有规格时恢复其独立价格和主键;真正的新规格才继承资源列表价格。 + const existingItem = (shopItemsByResourceId.get(Number(resourceId)) || []).find( + (item) => String(item.durationDays || "1") === normalizedDuration, + ); + specsByDuration[normalizedDuration] = resourceShopSpecDraft( + resource, + normalizedDuration, + existingItem, + Object.keys(current).indexOf(resourceId), + ); + } + return { + ...current, + [resourceId]: { ...draftGroup, specsByDuration }, + }; + }); }; - const applyBatchDuration = (durationDays) => { - setBatchDurationDays(durationDays); - setDraftByResourceId((current) => - Object.fromEntries( - Object.entries(current).map(([resourceId, draft]) => [resourceId, { ...draft, durationDays }]), - ), - ); + const updateDraftSpec = (resourceId, durationDays, patch) => { + setDraftByResourceId((current) => { + const draftGroup = current[resourceId]; + const draft = draftGroup?.specsByDuration?.[durationDays]; + if (!draft) { + return current; + } + return { + ...current, + [resourceId]: { + ...draftGroup, + specsByDuration: { + ...draftGroup.specsByDuration, + [durationDays]: { ...draft, ...patch }, + }, + }, + }; + }); }; const submitShopItems = async (event) => { @@ -866,15 +989,17 @@ export function useResourceShopPage() { setLoadingAction("resource-shop-upsert"); try { const parsed = parseForm(resourceShopItemsFormSchema, { items: selectedDrafts }); + // 一个资源的每个天数规格都是独立商品行,价格、时间和排序必须逐条提交,不能再按资源合并。 await upsertResourceShopItems({ items: parsed.items.map((item) => ({ + coinPrice: Number(item.coinPrice), durationDays: Number(item.durationDays), - effectiveFromMs: datetimeLocalToMs(item.effectiveFrom), - effectiveToMs: datetimeLocalToMs(item.effectiveTo), + effectiveFromMs: Number(item.effectiveFromMs || 0), + effectiveToMs: Number(item.effectiveToMs || 0), resourceId: Number(item.resourceId), - shopItemId: shopItemsByResourceId.get(Number(item.resourceId))?.shopItemId, + shopItemId: item.shopItemId ? Number(item.shopItemId) : undefined, sortOrder: Number(item.sortOrder || 0), - status: "active", + status: item.status || "active", })), }); showToast("售卖资源已保存", "success"); @@ -920,8 +1045,6 @@ export function useResourceShopPage() { return { ...sharedPageState({ page, query, result, setPage, setQuery }), abilities, - applyBatchDuration, - batchDurationDays, changeDrawerQuery: setDrawerQuery, changeDrawerResourceType: setDrawerResourceType, changeResourceType: resetSetter(setResourceType, setPage), @@ -931,6 +1054,7 @@ export function useResourceShopPage() { drawerOpen, drawerQuery, drawerResourceType, + draftByResourceId, filteredResourceOptions, loadingAction, openShopDrawer, @@ -944,7 +1068,9 @@ export function useResourceShopPage() { purchaseResourceType, purchaseUserFilter, reloadPurchaseOrders: purchaseResult.reload, + reloadShopDrawerOptions, setPurchasePage, + resourceOptionsError, resourceOptionsLoading, resourceType, resetFilters, @@ -953,11 +1079,12 @@ export function useResourceShopPage() { status, submitShopItems, toggleDraftResource, + toggleDraftDuration, toggleShopItem, changePurchaseKeyword: resetSetter(setPurchaseKeyword, setPurchasePage), changePurchaseResourceType: resetSetter(setPurchaseResourceType, setPurchasePage), changePurchaseUserFilter: resetSetter(setPurchaseUserFilter, setPurchasePage), - updateDraftItem, + updateDraftSpec, }; } diff --git a/src/features/resources/hooks/useResourcePages.test.js b/src/features/resources/hooks/useResourcePages.test.js index 9d5f8d7..2336587 100644 --- a/src/features/resources/hooks/useResourcePages.test.js +++ b/src/features/resources/hooks/useResourcePages.test.js @@ -2,11 +2,15 @@ import { expect, test, vi } from "vitest"; import { applyGiftPriceDefaults, applyGiftResourceSelection, + buildResourceShopDraftGroup, buildGiftResourceDrafts, + canUnselectResourceShopDraftGroup, + canUnselectResourceShopDraftSpec, cpRelationTypeFromGift, cpRelationTypeFromPresentationJson, cpRelationTypeShortLabel, fetchAllOptionPages, + groupResourceShopItems, normalizeGiftPresentationJson, } from "./useResourcePages.js"; @@ -134,6 +138,45 @@ test("fetches all option pages instead of only the first resource page", async ( expect(result.total).toBe(5); }); +test("groups every shop duration instead of overwriting the same resource", () => { + const itemsByResourceId = groupResourceShopItems([ + { durationDays: 7, resourceId: 11, shopItemId: 3 }, + { durationDays: 1, resourceId: 11, shopItemId: 1 }, + { durationDays: 3, resourceId: 11, shopItemId: 2 }, + ]); + + expect(itemsByResourceId.get(11)?.map((item) => item.shopItemId)).toEqual([1, 2, 3]); +}); + +test("builds independent shop specs and defaults a new spec to the resource price", () => { + const resource = { coinPrice: 100, resourceId: 11 }; + const existingGroup = buildResourceShopDraftGroup(resource, [ + { coinPrice: 260, durationDays: 7, resourceId: 11, shopItemId: 3 }, + { coinPrice: 120, durationDays: 3, resourceId: 11, shopItemId: 2 }, + ]); + const newGroup = buildResourceShopDraftGroup(resource); + + expect(existingGroup.specsByDuration["3"]).toMatchObject({ + coinPrice: "120", + durationDays: "3", + shopItemId: 2, + }); + expect(existingGroup.specsByDuration["7"]).toMatchObject({ + coinPrice: "260", + durationDays: "7", + shopItemId: 3, + }); + expect(newGroup.specsByDuration["1"]).toMatchObject({ + coinPrice: "100", + durationDays: "1", + resourceId: "11", + }); + expect(canUnselectResourceShopDraftSpec(existingGroup.specsByDuration["3"])).toBe(false); + expect(canUnselectResourceShopDraftSpec(newGroup.specsByDuration["1"])).toBe(true); + expect(canUnselectResourceShopDraftGroup(existingGroup)).toBe(false); + expect(canUnselectResourceShopDraftGroup(newGroup)).toBe(true); +}); + test("reads CP relation type from gift presentation json", () => { expect(cpRelationTypeFromPresentationJson('{"cp_relation_type":"brother"}')).toBe("brother"); expect(cpRelationTypeFromPresentationJson('{"cpRelationType":"sister"}')).toBe("sister"); diff --git a/src/features/resources/mp4AlphaLayout.js b/src/features/resources/mp4AlphaLayout.js index aadd91b..654bbc5 100644 --- a/src/features/resources/mp4AlphaLayout.js +++ b/src/features/resources/mp4AlphaLayout.js @@ -123,13 +123,15 @@ export function updateMp4AlphaLayoutKind(metadataJson, alphaLayout) { if (!layout) { return normalizeMetadataJSON(metadataJson); } + const keepCustomFrames = alphaLayout === "custom"; const nextLayout = createMp4AlphaLayout({ alphaLayout, - alphaFrame: layout.alpha_frame, + // 左右/上下方向变化时必须按新方向重算矩形;沿用旧矩形会让预览仍显示上一个通道顺序。 + alphaFrame: keepCustomFrames ? layout.alpha_frame : undefined, confirmed: false, confidence: layout.confidence, detectVersion: layout.detect_version, - rgbFrame: layout.rgb_frame, + rgbFrame: keepCustomFrames ? layout.rgb_frame : undefined, videoH: layout.video_h, videoW: layout.video_w, }); diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 16d3e0f..98bf9bb 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -75,6 +75,7 @@ import { mp4AlphaLayoutOptions, mp4LayoutLabel, readMp4VideoInfo, + removeMp4AlphaLayoutMetadata, updateMp4AlphaLayoutFrame, updateMp4AlphaLayoutKind, } from "@/features/resources/mp4AlphaLayout.js"; @@ -995,39 +996,42 @@ function mp4ResourceAnimationSource(resource) { } function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, setForm, uploadDisabled }) { - const submitDisabled = disabled || loading || hasUnconfirmedMp4Layout(form.metadataJson); - const { showToast } = useToast(); + const [animationUploading, setAnimationUploading] = useState(false); + const [previewUploading, setPreviewUploading] = useState(false); + const submitDisabled = + disabled || loading || animationUploading || previewUploading || hasUnconfirmedMp4Layout(form.metadataJson); const profileCardLayout = form.resourceType === "profile_card" ? profileCardLayoutFromMetadata(form.metadataJson) : null; const mp4Layout = mp4AlphaLayoutFromMetadata(form.metadataJson); const handleAnimationSelected = async (file) => { + let detectedMp4Layout = null; if (isMp4File(file)) { - try { - const layout = await detectMp4AlphaLayout(file); - setForm((previous) => ({ - ...previous, - metadataJson: mergeMp4AlphaLayoutMetadata(previous.metadataJson, layout), - })); - } catch (err) { - showToast(err.message || "MP4 透明布局解析失败", "error"); + detectedMp4Layout = await detectMp4AlphaLayout(file); + } + let detectedProfileCardLayout = null; + if (form.resourceType === "profile_card") { + detectedProfileCardLayout = await detectProfileCardLayout(file); + } + // 只返回当次素材的检测增量,上传成功后再合并到最新表单,避免异步旧快照覆盖用户新修改。 + return { mp4Layout: detectedMp4Layout, profileCardLayout: detectedProfileCardLayout }; + }; + const handleAnimationChange = (animationUrl, selectionResult) => { + setForm((previous) => { + let metadataJson = previous.metadataJson; + if (!animationUrl) { + // 清除素材时,两类从该素材派生的布局元数据都必须同步清理。 + metadataJson = removeProfileCardLayoutMetadata(removeMp4AlphaLayoutMetadata(metadataJson)); + } else if (selectionResult) { + metadataJson = selectionResult.mp4Layout + ? mergeMp4AlphaLayoutMetadata(metadataJson, selectionResult.mp4Layout) + : removeMp4AlphaLayoutMetadata(metadataJson); + metadataJson = + previous.resourceType === "profile_card" && selectionResult.profileCardLayout + ? metadataWithProfileCardLayout(metadataJson, selectionResult.profileCardLayout) + : removeProfileCardLayoutMetadata(metadataJson); } - } - if (form.resourceType !== "profile_card") { - return; - } - try { - const layout = await detectProfileCardLayout(file); - setForm((previous) => - previous.resourceType === "profile_card" - ? { - ...previous, - metadataJson: metadataWithProfileCardLayout(previous.metadataJson, layout), - } - : previous, - ); - } catch (err) { - showToast(err.message || "资料卡内容高度解析失败", "error"); - } + return { ...previous, animationUrl, metadataJson }; + }); }; return ( setForm({ ...form, resourceCode: event.target.value })} /> setForm((previous) => ({ ...previous, previewUrl }))} + onUploadingChange={setPreviewUploading} /> setForm((previous) => ({ ...previous, animationUrl }))} + onChange={handleAnimationChange} onFileSelected={handleAnimationSelected} + onUploadingChange={setAnimationUploading} /> {mp4Layout ? ( setForm((previous) => ({ ...previous, metadataJson }))} /> diff --git a/src/features/resources/pages/ResourceShopPage.jsx b/src/features/resources/pages/ResourceShopPage.jsx index b04bfe3..8e26736 100644 --- a/src/features/resources/pages/ResourceShopPage.jsx +++ b/src/features/resources/pages/ResourceShopPage.jsx @@ -16,6 +16,7 @@ import { AdminListToolbar, } from "@/shared/ui/AdminListLayout.jsx"; import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { createOptionsColumnFilter, createTextColumnFilter, createUserIdentityColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; import { @@ -25,7 +26,11 @@ import { resourceStatusFilters, resourceTypeLabels, } from "@/features/resources/constants.js"; -import { useResourceShopPage } from "@/features/resources/hooks/useResourcePages.js"; +import { + canUnselectResourceShopDraftGroup, + canUnselectResourceShopDraftSpec, + useResourceShopPage, +} from "@/features/resources/hooks/useResourcePages.js"; import styles from "@/features/resources/resources.module.css"; const shopColumns = [ @@ -201,7 +206,12 @@ function ResourceShopDrawer({ page }) { 取消
    - + {page.filteredResourceOptions.length > 0 ? (
    {page.filteredResourceOptions.map((resource) => ( @@ -335,61 +336,94 @@ function ResourceShopPurchaseDrawer({ page }) { function ResourceSelectionRow({ disabled, page, resource }) { const resourceId = String(resource.resourceId); const selected = page.selectedResourceIds.has(resourceId); - const draft = selected ? page.selectedDrafts.find((item) => String(item.resourceId) === resourceId) : null; + const draftGroup = selected ? page.draftByResourceId[resourceId] : null; + const canUnselectGroup = !draftGroup || canUnselectResourceShopDraftGroup(draftGroup); return (
    page.toggleDraftResource(resource)} /> - {resourcePriceLabel(resource)} + 资源价 {resourcePriceLabel(resource)} + {draftGroup ? ( +
    + {resourceShopDurationOptions.map(([durationDays, label]) => ( + + ))} +
    + ) : null} +
    + ); +} + +function ResourceShopSpecRow({ disabled, draft, durationDays, label, page, resource }) { + const resourceId = String(resource.resourceId); + const updateDraft = (patch) => page.updateDraftSpec(resourceId, durationDays, patch); + return ( +
    + {draft ? ( -
    + <> page.updateDraftItem(resourceId, { durationDays: event.target.value })} - > - {resourceShopDurationOptions.map(([value, label]) => ( - - {label} - - ))} - - page.updateDraftItem(resourceId, { effectiveFrom: event.target.value })} + type="number" + value={draft.coinPrice} + onChange={(event) => updateDraft({ coinPrice: event.target.value })} /> - page.updateDraftItem(resourceId, { effectiveTo: event.target.value })} + label="生效时间" + value={{ endMs: draft.effectiveToMs, startMs: draft.effectiveFromMs }} + onChange={(range) => + updateDraft({ effectiveFromMs: range.startMs, effectiveToMs: range.endMs }) + } /> page.updateDraftItem(resourceId, { sortOrder: event.target.value })} + onChange={(event) => updateDraft({ sortOrder: event.target.value })} /> -
    - ) : null} + + ) : ( + 未售卖 + )}
    ); } diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css index 3612cfa..8238797 100644 --- a/src/features/resources/resources.module.css +++ b/src/features/resources/resources.module.css @@ -268,7 +268,7 @@ .shopDrawerTools { display: grid; - grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.7fr) minmax(150px, 0.55fr); + grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.7fr); gap: var(--space-3); } @@ -282,16 +282,43 @@ min-height: 56px; align-items: center; gap: var(--space-3); - grid-template-columns: 40px minmax(220px, 1fr) minmax(92px, 0.35fr) minmax(0, 2fr); + grid-template-columns: 40px minmax(220px, 1fr) minmax(132px, auto); padding: var(--space-2) 0; border-bottom: 1px solid var(--border-muted); } -.shopSelectionControls { +.shopSpecList { display: grid; min-width: 0; gap: var(--space-2); - grid-template-columns: minmax(112px, 0.7fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(84px, 0.45fr); + grid-column: 2 / -1; +} + +.shopSpecRow { + display: grid; + min-width: 0; + align-items: center; + gap: var(--space-2); + grid-template-columns: minmax(88px, 0.45fr) minmax(120px, 0.65fr) minmax(220px, 1.25fr) minmax(84px, 0.45fr); +} + +.shopSpecToggle { + display: inline-flex; + min-width: 0; + align-items: center; + color: var(--text-primary); + font-weight: 650; + white-space: nowrap; +} + +.shopSpecTimeRange { + width: 100% !important; + max-width: none !important; +} + +.shopSpecDisabled { + grid-column: 2 / -1; + color: var(--text-tertiary); } .shopBasePrice { @@ -667,7 +694,7 @@ @media (max-width: 860px) { .shopDrawerTools, .shopSelectionRow, - .shopSelectionControls, + .shopSpecRow, .batchTableHead, .batchTableRow { grid-template-columns: 1fr; @@ -676,4 +703,9 @@ .shopSelectionRow { align-items: stretch; } + + .shopSpecList, + .shopSpecDisabled { + grid-column: auto; + } } diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index 3007f01..b2e537e 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -359,23 +359,28 @@ export const resourceShopItemsFormSchema = z items: z .array( z.object({ + coinPrice: z.union([z.string(), z.number()]), durationDays: z.union([z.string(), z.number()]), - effectiveFrom: z.string().optional(), - effectiveTo: z.string().optional(), + effectiveFromMs: z.union([z.string(), z.number()]).optional(), + effectiveToMs: z.union([z.string(), z.number()]).optional(), resourceId: z.union([z.string(), z.number()]), + shopItemId: z.union([z.string(), z.number()]).optional(), sortOrder: z.union([z.string(), z.number()]).optional(), + status: z.enum(["active", "disabled"]).optional(), }), ) .min(1, "请至少选择一个资源"), }) .superRefine((value, context) => { - const resourceIds = new Set(); + const specificationKeys = new Set(); value.items.forEach((item, index) => { const resourceId = Number(item.resourceId); const durationDays = String(item.durationDays || "").trim(); - const effectiveFromMs = datetimeLocalToMs(item.effectiveFrom); - const effectiveToMs = datetimeLocalToMs(item.effectiveTo); + const coinPrice = Number(item.coinPrice); + const effectiveFromMs = optionalEpochMs(item.effectiveFromMs); + const effectiveToMs = optionalEpochMs(item.effectiveToMs); const sortOrder = Number(item.sortOrder || 0); + const specificationKey = `${resourceId}:${durationDays}`; if (!Number.isInteger(resourceId) || resourceId <= 0) { context.addIssue({ @@ -384,14 +389,14 @@ export const resourceShopItemsFormSchema = z path: ["items", index, "resourceId"], }); } - if (resourceIds.has(resourceId)) { + if (specificationKeys.has(specificationKey)) { context.addIssue({ code: "custom", - message: "同一个资源不能重复添加", - path: ["items", index, "resourceId"], + message: "同一个资源的相同售卖天数不能重复添加", + path: ["items", index, "durationDays"], }); } - resourceIds.add(resourceId); + specificationKeys.add(specificationKey); if (!resourceShopDurations.includes(durationDays)) { context.addIssue({ code: "custom", @@ -399,25 +404,32 @@ export const resourceShopItemsFormSchema = z path: ["items", index, "durationDays"], }); } + if (!Number.isSafeInteger(coinPrice) || coinPrice <= 0) { + context.addIssue({ + code: "custom", + message: "请输入大于 0 的售卖价格", + path: ["items", index, "coinPrice"], + }); + } if (effectiveFromMs < 0) { context.addIssue({ code: "custom", message: "请选择生效开始时间", - path: ["items", index, "effectiveFrom"], + path: ["items", index, "effectiveFromMs"], }); } if (effectiveToMs < 0) { context.addIssue({ code: "custom", message: "请选择生效结束时间", - path: ["items", index, "effectiveTo"], + path: ["items", index, "effectiveToMs"], }); } if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) { context.addIssue({ code: "custom", message: "生效结束时间必须晚于开始时间", - path: ["items", index, "effectiveTo"], + path: ["items", index, "effectiveToMs"], }); } if (!Number.isInteger(sortOrder) || sortOrder < 0) { @@ -430,6 +442,15 @@ export const resourceShopItemsFormSchema = z }); }); +function optionalEpochMs(value) { + const normalized = String(value ?? "").trim(); + if (!normalized) { + return 0; + } + const parsed = Number(normalized); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : -1; +} + function datetimeLocalToMs(value) { const trimmed = String(value || "").trim(); if (!trimmed) { diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index 5b5b1af..9d1b02c 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -340,13 +340,22 @@ describe("resource form schema", () => { expect(() => parseForm(resourceGrantFormSchema, { ...payload, durationDays: "0" })).toThrow(FormValidationError); }); - test("validates resource shop durations", () => { + test("validates resource shop duration and price specifications", () => { const payload = parseForm(resourceShopItemsFormSchema, { items: [ { + coinPrice: "100", durationDays: "3", - effectiveFrom: "", - effectiveTo: "", + effectiveFromMs: "", + effectiveToMs: "", + resourceId: "11", + sortOrder: "0", + }, + { + coinPrice: "260", + durationDays: "7", + effectiveFromMs: "", + effectiveToMs: "", resourceId: "11", sortOrder: "0", }, @@ -354,18 +363,33 @@ describe("resource form schema", () => { }); expect(payload.items[0].durationDays).toBe("3"); + expect(payload.items[1].coinPrice).toBe("260"); expect(() => parseForm(resourceShopItemsFormSchema, { items: [ { + coinPrice: "100", durationDays: "2", - effectiveFrom: "", - effectiveTo: "", + effectiveFromMs: "", + effectiveToMs: "", resourceId: "11", sortOrder: "0", }, ], }), ).toThrow(FormValidationError); + expect(() => + parseForm(resourceShopItemsFormSchema, { + items: [ + { coinPrice: "100", durationDays: "3", resourceId: "11" }, + { coinPrice: "120", durationDays: "3", resourceId: "11" }, + ], + }), + ).toThrow(FormValidationError); + expect(() => + parseForm(resourceShopItemsFormSchema, { + items: [{ coinPrice: "0", durationDays: "1", resourceId: "11" }], + }), + ).toThrow(FormValidationError); }); }); diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 5e6e90b..5389ecb 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -312,6 +312,7 @@ export const API_OPERATIONS = { retryRedPacketRefund: "retryRedPacketRefund", retryRoomRpsSettlement: "retryRoomRpsSettlement", retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement", + revokeAppUserResource: "revokeAppUserResource", revokeResourceGrant: "revokeResourceGrant", savePolicyTemplate: "savePolicyTemplate", search: "search", @@ -2486,6 +2487,13 @@ export const API_ENDPOINTS: Record = { permission: "room-turnover-reward:retry", permissions: ["room-turnover-reward:retry"] }, + revokeAppUserResource: { + method: "POST", + operationId: API_OPERATIONS.revokeAppUserResource, + path: "/v1/admin/users/{user_id}/resources/{entitlement_id}/revoke", + permission: "resource-grant:revoke", + permissions: ["resource-grant:revoke"] + }, revokeResourceGrant: { method: "POST", operationId: API_OPERATIONS.revokeResourceGrant, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 03c5917..dfa00af 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -2876,6 +2876,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/users/{user_id}/resources/{entitlement_id}/revoke": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["revokeAppUserResource"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/resource-grants/resource": { parameters: { query?: never; @@ -11343,6 +11359,27 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + revokeAppUserResource: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + entitlement_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + reason: string; + }; + }; + }; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; grantResource: { parameters: { query?: never; @@ -11386,7 +11423,25 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": { + items: { + /** @description 售卖规格金币价;新建时省略或传 0 使用资源列表价格,更新已有规格时省略或传 0 保留当前售价 */ + coinPrice?: number; + /** @enum {integer} */ + durationDays: 1 | 3 | 7; + effectiveFromMs: number; + effectiveToMs: number; + resourceId: number; + shopItemId?: number; + sortOrder: number; + /** @enum {string} */ + status: "active" | "disabled"; + }[]; + }; + }; + }; responses: { 200: components["responses"]["EmptyResponse"]; }; diff --git a/src/shared/ui/Mp4Preview.jsx b/src/shared/ui/Mp4Preview.jsx new file mode 100644 index 0000000..6254954 --- /dev/null +++ b/src/shared/ui/Mp4Preview.jsx @@ -0,0 +1,462 @@ +import PauseRounded from "@mui/icons-material/PauseRounded"; +import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; +import { useEffect, useRef, useState } from "react"; +import styles from "./Mp4Preview.module.css"; + +const alphaLowThreshold = 0.08; +const alphaHighThreshold = 0.3; + +export function Mp4Preview({ autoPlay = false, controls = false, expanded = false, layout, src }) { + const layoutKey = transparentLayoutKey(layout); + const reducedMotion = prefersReducedMotion(); + + if (!layoutKey) { + return ( + + ); + } + + return ( + + ); +} + +function NativeMp4Preview({ autoPlay, controls, expanded, src }) { + const [failed, setFailed] = useState(false); + + useEffect(() => { + setFailed(false); + }, [src]); + + return ( + + {failed ? ( + MP4 预览失败 + ) : ( + + ); +} + +function AlphaMp4Preview({ autoPlay, controls, expanded, layoutKey, src }) { + const canvasRef = useRef(null); + const videoRef = useRef(null); + const [failed, setFailed] = useState(false); + const [playing, setPlaying] = useState(false); + + useEffect(() => { + const canvas = canvasRef.current; + const video = videoRef.current; + if (!canvas || !video || !src) { + return undefined; + } + + let cancelled = false; + let started = false; + let renderer; + let videoFrameId; + let animationFrameId; + let frameScheduled = false; + let lastMediaTime = -1; + let stopped = false; + const layout = JSON.parse(layoutKey); + + setFailed(false); + setPlaying(false); + + const fail = () => { + if (cancelled || stopped) { + return; + } + // 绘制失败后立即停止帧循环并释放 GPU,避免 RAF 回退路径永久空转。 + stopped = true; + if (videoFrameId !== undefined && typeof video.cancelVideoFrameCallback === "function") { + video.cancelVideoFrameCallback(videoFrameId); + } + if (animationFrameId !== undefined) { + window.cancelAnimationFrame(animationFrameId); + } + renderer?.destroy(); + renderer = undefined; + video.pause(); + setPlaying(false); + setFailed(true); + }; + + const drawFrame = (mediaTime = video.currentTime) => { + if (cancelled || stopped || !renderer || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + // RAF 会比视频帧率更快;只在媒体时间变化时上传纹理,避免无意义地重复占用 GPU。 + if (mediaTime === lastMediaTime) { + return; + } + lastMediaTime = mediaTime; + try { + renderer.draw(); + } catch { + fail(); + } + }; + + const scheduleFrame = () => { + if (cancelled || stopped || frameScheduled || video.paused) { + return; + } + if (typeof video.requestVideoFrameCallback === "function") { + frameScheduled = true; + videoFrameId = video.requestVideoFrameCallback((_, metadata) => { + frameScheduled = false; + drawFrame(metadata?.mediaTime); + scheduleFrame(); + }); + return; + } + frameScheduled = true; + animationFrameId = window.requestAnimationFrame(() => { + frameScheduled = false; + drawFrame(); + scheduleFrame(); + }); + }; + + const handlePlay = () => { + if (!cancelled && !stopped) { + setPlaying(true); + scheduleFrame(); + } + }; + const handlePause = () => { + if (!cancelled) { + setPlaying(false); + } + }; + + const start = () => { + if (cancelled || stopped || started || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + started = true; + try { + // 透明 MP4 必须按检测得到的精确矩形合成;custom/VAPC 的 RGB 与 Alpha 尺寸可能并不相等。 + renderer = createAlphaRenderer(canvas, video, layout, expanded ? 1280 : 512); + drawFrame(); + if (autoPlay) { + video.play().catch(() => { + // 浏览器若阻止自动播放,首帧仍会保留;再次打开弹窗会由用户手势触发播放。 + }); + } + } catch { + fail(); + } + }; + + video.addEventListener("loadeddata", start); + video.addEventListener("error", fail); + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + // Canvas 读取跨域视频必须先设置 anonymous;当前上传媒体域允许 CORS,本地 Blob URL 也兼容该设置。 + video.crossOrigin = "anonymous"; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.preload = "auto"; + video.src = src; + video.load(); + start(); + + return () => { + cancelled = true; + video.removeEventListener("loadeddata", start); + video.removeEventListener("error", fail); + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + if (videoFrameId !== undefined && typeof video.cancelVideoFrameCallback === "function") { + video.cancelVideoFrameCallback(videoFrameId); + } + if (animationFrameId !== undefined) { + window.cancelAnimationFrame(animationFrameId); + } + renderer?.destroy(); + video.pause(); + video.removeAttribute("src"); + video.load(); + }; + }, [autoPlay, expanded, layoutKey, src]); + + const togglePlayback = () => { + const video = videoRef.current; + if (!video || failed) { + return; + } + if (video.paused) { + // 用户手势仍可能被浏览器策略暂时拒绝;保留播放入口,允许用户重试。 + video.play().catch(() => {}); + } else { + video.pause(); + } + }; + + return ( + + + + ); +} + +function createAlphaRenderer(canvas, video, layout, maxEdge) { + const outputSize = scaledOutputSize(layout.rgb_frame[2], layout.rgb_frame[3], maxEdge); + canvas.width = outputSize.width; + canvas.height = outputSize.height; + + const gl = canvas.getContext("webgl", { + alpha: true, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: false, + }); + if (!gl) { + return createCanvas2DRenderer(canvas, video, layout); + } + + const program = createProgram(gl, vertexShaderSource, fragmentShaderSource); + const positionBuffer = gl.createBuffer(); + const texture = gl.createTexture(); + if (!positionBuffer || !texture) { + throw new Error("MP4 WebGL 初始化失败"); + } + + gl.useProgram(program); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW); + const positionLocation = gl.getAttribLocation(program, "aPosition"); + gl.enableVertexAttribArray(positionLocation); + gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.uniform1i(gl.getUniformLocation(program, "uTexture"), 0); + gl.uniform4fv(gl.getUniformLocation(program, "uRgbRect"), normalizedRect(layout.rgb_frame, layout)); + gl.uniform4fv(gl.getUniformLocation(program, "uAlphaRect"), normalizedRect(layout.alpha_frame, layout)); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 0); + + return { + destroy() { + gl.deleteTexture(texture); + gl.deleteBuffer(positionBuffer); + gl.deleteProgram(program); + }, + draw() { + gl.clear(gl.COLOR_BUFFER_BIT); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video); + gl.drawArrays(gl.TRIANGLES, 0, 6); + }, + }; +} + +function createCanvas2DRenderer(canvas, video, layout) { + const context = canvas.getContext("2d", { willReadFrequently: true }); + const maskCanvas = document.createElement("canvas"); + maskCanvas.width = canvas.width; + maskCanvas.height = canvas.height; + const maskContext = maskCanvas.getContext("2d", { willReadFrequently: true }); + if (!context || !maskContext) { + throw new Error("MP4 Canvas 初始化失败"); + } + + const rgbFrame = scaledSourceFrame(layout.rgb_frame, layout, video); + const alphaFrame = scaledSourceFrame(layout.alpha_frame, layout, video); + return { + destroy() {}, + draw() { + context.clearRect(0, 0, canvas.width, canvas.height); + maskContext.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(video, ...rgbFrame, 0, 0, canvas.width, canvas.height); + maskContext.drawImage(video, ...alphaFrame, 0, 0, canvas.width, canvas.height); + const colorPixels = context.getImageData(0, 0, canvas.width, canvas.height); + const maskPixels = maskContext.getImageData(0, 0, canvas.width, canvas.height).data; + // 与 Android/iOS 客户端保持同一亮度与 smoothstep 阈值,后台看到的透明边缘才不会和 App 偏差。 + for (let index = 0; index < colorPixels.data.length; index += 4) { + const luminance = + (maskPixels[index] * 0.299 + maskPixels[index + 1] * 0.587 + maskPixels[index + 2] * 0.114) / 255; + const progress = clamp( + (luminance - alphaLowThreshold) / (alphaHighThreshold - alphaLowThreshold), + 0, + 1, + ); + colorPixels.data[index + 3] = Math.round(progress * progress * (3 - 2 * progress) * 255); + } + context.putImageData(colorPixels, 0, 0); + }, + }; +} + +function createProgram(gl, vertexSource, fragmentSource) { + const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource); + const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource); + const program = gl.createProgram(); + if (!program) { + throw new Error("MP4 WebGL Program 创建失败"); + } + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) || "MP4 WebGL Program 链接失败"; + gl.deleteProgram(program); + throw new Error(message); + } + return program; +} + +function compileShader(gl, type, source) { + const shader = gl.createShader(type); + if (!shader) { + throw new Error("MP4 WebGL Shader 创建失败"); + } + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const message = gl.getShaderInfoLog(shader) || "MP4 WebGL Shader 编译失败"; + gl.deleteShader(shader); + throw new Error(message); + } + return shader; +} + +function transparentLayoutKey(layout) { + if ( + !layout || + layout.alpha_layout === "normal" || + !validFrame(layout.rgb_frame) || + !validFrame(layout.alpha_frame) + ) { + return ""; + } + const videoWidth = positiveInteger(layout.video_w); + const videoHeight = positiveInteger(layout.video_h); + if (!videoWidth || !videoHeight) { + return ""; + } + return JSON.stringify({ + alpha_frame: layout.alpha_frame.slice(0, 4).map(Number), + alpha_layout: layout.alpha_layout, + rgb_frame: layout.rgb_frame.slice(0, 4).map(Number), + video_h: videoHeight, + video_w: videoWidth, + }); +} + +function normalizedRect(frame, layout) { + return new Float32Array([ + frame[0] / layout.video_w, + frame[1] / layout.video_h, + frame[2] / layout.video_w, + frame[3] / layout.video_h, + ]); +} + +function scaledSourceFrame(frame, layout, video) { + const scaleX = video.videoWidth / layout.video_w; + const scaleY = video.videoHeight / layout.video_h; + return [frame[0] * scaleX, frame[1] * scaleY, frame[2] * scaleX, frame[3] * scaleY]; +} + +function scaledOutputSize(width, height, maxEdge) { + const scale = Math.min(1, maxEdge / Math.max(width, height)); + return { + height: Math.max(1, Math.round(height * scale)), + width: Math.max(1, Math.round(width * scale)), + }; +} + +function validFrame(frame) { + return ( + Array.isArray(frame) && + frame.length >= 4 && + frame + .slice(0, 4) + .every((value, index) => Number.isFinite(Number(value)) && (index < 2 ? value >= 0 : value > 0)) + ); +} + +function positiveInteger(value) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : 0; +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +function prefersReducedMotion() { + return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; +} + +const vertexShaderSource = ` + attribute vec2 aPosition; + varying vec2 vOutputUv; + + void main() { + gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0); + vOutputUv = vec2(aPosition.x, 1.0 - aPosition.y); + } +`; + +const fragmentShaderSource = ` + precision mediump float; + uniform sampler2D uTexture; + uniform vec4 uRgbRect; + uniform vec4 uAlphaRect; + varying vec2 vOutputUv; + + vec2 mapRect(vec4 rect, vec2 uv) { + return vec2(rect.x + uv.x * rect.z, rect.y + uv.y * rect.w); + } + + void main() { + vec4 color = texture2D(uTexture, mapRect(uRgbRect, vOutputUv)); + vec4 mask = texture2D(uTexture, mapRect(uAlphaRect, vOutputUv)); + float alpha = dot(mask.rgb, vec3(0.299, 0.587, 0.114)); + alpha = smoothstep(${alphaLowThreshold.toFixed(2)}, ${alphaHighThreshold.toFixed(2)}, alpha); + gl_FragColor = vec4(color.rgb, alpha); + } +`; diff --git a/src/shared/ui/Mp4Preview.module.css b/src/shared/ui/Mp4Preview.module.css new file mode 100644 index 0000000..31e481d --- /dev/null +++ b/src/shared/ui/Mp4Preview.module.css @@ -0,0 +1,88 @@ +.root { + position: relative; + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.expanded { + min-height: 320px; + max-height: min(68vh, 640px); +} + +.media { + display: block; + width: auto; + max-width: 100%; + height: auto; + max-height: 100%; + object-fit: contain; +} + +.expanded .media { + max-height: min(68vh, 640px); +} + +.sourceVideo { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + opacity: 0; + pointer-events: none; +} + +.error { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-weight: 650; +} + +.playbackControl { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + display: inline-flex; + height: 34px; + align-items: center; + gap: var(--space-1); + padding: 0 var(--space-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.94); + color: var(--text-primary); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 650; + transition: + border-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + transform var(--motion-fast) var(--ease-standard); +} + +.playbackControl:hover, +.playbackControl:focus-visible { + border-color: var(--primary); + color: var(--primary); +} + +.playbackControl:active { + transform: scale(0.97); +} + +@media (prefers-reduced-motion: reduce) { + .playbackControl { + transition: none; + } +} diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx index 7e6d1ad..1fa8efe 100644 --- a/src/shared/ui/UploadField.jsx +++ b/src/shared/ui/UploadField.jsx @@ -15,6 +15,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { downloadResponse } from "@/shared/api/download"; import { uploadFile, uploadImage } from "@/shared/api/upload"; import { Button } from "@/shared/ui/Button.jsx"; +import { Mp4Preview } from "@/shared/ui/Mp4Preview.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import styles from "./UploadField.module.css"; @@ -30,8 +31,10 @@ export function UploadField({ kind = "image", label, hideType = false, + mp4Layout, onChange, onFileSelected, + onUploadingChange, panelClassName, previewClassName, showSourceActions = false, @@ -40,6 +43,8 @@ export function UploadField({ }) { const inputId = useId(); const inputRef = useRef(null); + const uploadGenerationRef = useRef(0); + const onUploadingChangeRef = useRef(onUploadingChange); const { showToast } = useToast(); const [uploading, setUploading] = useState(false); const [localPreview, setLocalPreview] = useState(null); @@ -53,14 +58,30 @@ export function UploadField({ const displayName = localPreview?.name || getDisplayValue(value); const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined; const canPreviewVideo = Boolean(source && assetKind === "mp4"); + const localPreviewURL = localPreview?.url; + const previewMp4Layout = Object.prototype.hasOwnProperty.call(localPreview?.selectionResult || {}, "mp4Layout") + ? localPreview.selectionResult.mp4Layout + : mp4Layout; + + useEffect(() => { + onUploadingChangeRef.current = onUploadingChange; + }, [onUploadingChange]); useEffect(() => { return () => { - if (localPreview?.url) { - URL.revokeObjectURL(localPreview.url); + // 上传请求无法中途取消时,用代次作废迟到结果,避免弹窗关闭后回写下一次编辑表单。 + uploadGenerationRef.current += 1; + onUploadingChangeRef.current?.(false); + }; + }, []); + + useEffect(() => { + return () => { + if (localPreviewURL) { + URL.revokeObjectURL(localPreviewURL); } }; - }, [localPreview]); + }, [localPreviewURL]); const openPicker = () => { if (!disabled && !uploading) { @@ -85,18 +106,38 @@ export function UploadField({ url: URL.createObjectURL(file), }; }); + const uploadGeneration = ++uploadGenerationRef.current; setUploading(true); + onUploadingChange?.(true); try { - await onFileSelected?.(file); + const selectionResult = await onFileSelected?.(file); + if (uploadGeneration !== uploadGenerationRef.current) { + return; + } + setLocalPreview((previous) => (previous ? { ...previous, selectionResult } : previous)); const result = useImageUpload ? await uploadImage(file) : await uploadFile(file); - onChange(result.url); + if (uploadGeneration !== uploadGenerationRef.current) { + return; + } + // 只有媒体检测和上传都成功后才把检测结果交给表单,避免新布局残留在旧 URL 上。 + if (selectionResult === undefined) { + onChange(result.url); + } else { + onChange(result.url, selectionResult); + } setLocalPreview(null); showToast("上传成功", "success"); } catch (err) { + if (uploadGeneration !== uploadGenerationRef.current) { + return; + } setLocalPreview(null); showToast(err.message || "上传失败", "error"); } finally { - setUploading(false); + if (uploadGeneration === uploadGenerationRef.current) { + setUploading(false); + onUploadingChange?.(false); + } } }; @@ -155,7 +196,12 @@ export function UploadField({ type="button" onClick={openPicker} > - + {uploading ? ( @@ -249,6 +295,7 @@ export function UploadField({
    setVideoPreviewOpen(false)} @@ -265,15 +312,13 @@ export function UploadField({ ); } -function VideoPreviewDialog({ label, onClose, open, src }) { +function VideoPreviewDialog({ label, mp4Layout, onClose, open, src }) { return ( {label} MP4
    - {src ? ( -
    @@ -283,7 +328,7 @@ function VideoPreviewDialog({ label, onClose, open, src }) { ); } -function AssetPreview({ assetKind, isImage, src }) { +function AssetPreview({ assetKind, isImage, mp4Layout, src }) { if (!src) { return ( @@ -297,6 +342,9 @@ function AssetPreview({ assetKind, isImage, src }) { if (assetKind === "pag") { return ; } + if (assetKind === "mp4") { + return ; + } if (assetKind === "image" || isImage) { return ; } @@ -390,7 +438,8 @@ function SVGAAssetPreview({ src }) { return ( - + {/* svgaplayerweb 2.3.2 只会为 HTMLDivElement 创建内部 Canvas,span 会在 setVideoItem 时稳定失败。 */} +
    {failed ? SVGA 预览失败 : null} ); diff --git a/src/shared/ui/UploadField.module.css b/src/shared/ui/UploadField.module.css index 6d390a9..f3bc43e 100644 --- a/src/shared/ui/UploadField.module.css +++ b/src/shared/ui/UploadField.module.css @@ -160,14 +160,14 @@ height: 100%; } -.playerSurface canvas, -.pagCanvas { - width: 100% !important; - height: 100% !important; +.playerSurface canvas { + display: block; } .pagCanvas { display: block; + width: 100% !important; + height: 100% !important; } .footer {